From b233aa3546d30e7af01413d08ad92ca02fc5de95 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 21 May 2026 22:46:33 -0700 Subject: [PATCH 1/6] ci: add Windows job to verify .cmd and HOME fallback fixes Matrix-ize lint/build across ubuntu+windows, add a unit job that runs the bun:test suite on both OSes (previously not in CI at all), and add a focused windows-qmd-smoke job that: - demonstrates Node's stock execFile cannot launch qmd.cmd on Windows (continue-on-error, informational only) - asserts setupQmdCollection() succeeds through the wrapped execFile - asserts resolveMemoryDir() falls back to USERPROFILE when HOME is unset, with no literal '~' subdirectory in the resolved path Gives us a runnable signal for PR #11 before merging it. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jay Zeng --- .github/workflows/ci.yml | 103 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09fed2b..8667632 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,11 @@ permissions: jobs: lint: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -20,7 +24,11 @@ jobs: - run: npm run lint build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -30,6 +38,97 @@ jobs: - run: npm ci - run: npm run build + unit: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + - name: Run unit tests + run: bun test test/unit.test.ts + + windows-qmd-smoke: + name: windows-qmd-smoke (PR #11 verification) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + + - name: Install qmd via Bun + shell: pwsh + run: bun install -g https://github.com/tobi/qmd + + - name: Ensure bun global bin is on PATH + shell: pwsh + run: | + $bunBin = Join-Path $env:USERPROFILE ".bun\bin" + Write-Host "Adding to PATH: $bunBin" + Add-Content -Path $env:GITHUB_PATH -Value $bunBin + + - name: Confirm qmd is on PATH + shell: pwsh + run: | + Get-Command qmd + qmd --version + + # Demonstrates the original bug: Node's stock execFile cannot launch + # qmd.cmd on Windows. We expect this step to FAIL on a vanilla Node + # install — it's informational, so we don't fail the job on it. + - name: Smoke (informational) — stock Node execFile -> qmd + shell: pwsh + continue-on-error: true + run: | + node -e "const { execFile } = require('node:child_process'); execFile('qmd', ['--version'], (err, stdout, stderr) => { if (err) { console.error('STOCK execFile FAILED (expected on Windows):', err.code, err.message); process.exit(1); } console.log('STOCK execFile OK:', stdout.trim()); });" + + # Exercises the PR's wrapExecFileForQmd path. The extension's + # setupQmdCollection() calls qmd via the wrapped execFile, so a + # successful run here verifies the .cmd wrapper actually works. + - name: Smoke — extension wrapper -> qmd (must succeed) + shell: pwsh + run: | + bun -e "import('./index.ts').then(async (m) => { const ok = await m.setupQmdCollection(); if (!ok) { console.error('setupQmdCollection returned false — wrapper did not fix .cmd invocation'); process.exit(1); } console.log('OK: extension wrapper successfully invoked qmd through cmd.exe /c'); }).catch((e) => { console.error('FAILED:', e); process.exit(1); });" + + # Verifies resolveMemoryDir() falls back to USERPROFILE when HOME + # is unset (the second bug PR #11 fixes). We import the module in a + # subprocess with HOME deliberately removed, then have it create the + # default memory directory and print its location. + - name: Smoke — resolveMemoryDir USERPROFILE fallback + shell: pwsh + run: | + Remove-Item Env:HOME -ErrorAction SilentlyContinue + Remove-Item Env:PI_MEMORY_DIR -ErrorAction SilentlyContinue + if (-not $env:USERPROFILE) { Write-Error "USERPROFILE is not set on this runner"; exit 1 } + $expectedPrefix = Join-Path $env:USERPROFILE ".pi\agent\memory" + Write-Host "Expected prefix: $expectedPrefix" + $actual = bun -e "import('./index.ts').then(m => { console.log(m.dailyPath('2026-01-01')); });" + Write-Host "ensureDirs/dailyPath returned: $actual" + if ($actual -notlike "$expectedPrefix*") { + Write-Error "FAILED: resolveMemoryDir did not honor USERPROFILE fallback. Got: $actual" + exit 1 + } + if ($actual -like "*~\.pi*") { + Write-Error "FAILED: literal '~' subdirectory still present in resolved path" + exit 1 + } + Write-Host "OK: memory directory resolved under USERPROFILE" + test: runs-on: ubuntu-latest env: From 1bdad719cec94de3450990b9d85e8fc25dfa8dab Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 21 May 2026 22:49:41 -0700 Subject: [PATCH 2/6] ci: enforce LF line endings via .gitattributes actions/checkout on windows-latest converts LF to CRLF, which trips biome's formatter check on every TS file. Pin all text files to LF so the lint job behaves identically across OSes. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jay Zeng --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c4c1f90 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.ico binary From ab43471dc3b8436b23d232deda0aa7cc4b0ced48 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 21 May 2026 23:00:42 -0700 Subject: [PATCH 3/6] fix: support qmd smoke on Windows Signed-off-by: Jay Zeng --- .github/workflows/ci.yml | 22 ++++++++++-------- index.ts | 49 +++++++++++++++++++++++++++++++++++----- test/unit.test.ts | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8667632..e5e430a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - uses: oven-sh/setup-bun@v2 with: @@ -75,12 +75,17 @@ jobs: shell: pwsh run: bun install -g https://github.com/tobi/qmd - - name: Ensure bun global bin is on PATH + - name: Ensure qmd prerequisites are on PATH shell: pwsh run: | $bunBin = Join-Path $env:USERPROFILE ".bun\bin" Write-Host "Adding to PATH: $bunBin" Add-Content -Path $env:GITHUB_PATH -Value $bunBin + $gitUsrBin = Join-Path $env:ProgramFiles "Git\usr\bin" + if (Test-Path $gitUsrBin) { + Write-Host "Adding to PATH: $gitUsrBin" + Add-Content -Path $env:GITHUB_PATH -Value $gitUsrBin + } - name: Confirm qmd is on PATH shell: pwsh @@ -88,22 +93,21 @@ jobs: Get-Command qmd qmd --version - # Demonstrates the original bug: Node's stock execFile cannot launch - # qmd.cmd on Windows. We expect this step to FAIL on a vanilla Node - # install — it's informational, so we don't fail the job on it. + # Captures stock Node execFile behavior for the runner's qmd shim. This + # is informational because shim behavior varies by package manager. - name: Smoke (informational) — stock Node execFile -> qmd shell: pwsh continue-on-error: true run: | node -e "const { execFile } = require('node:child_process'); execFile('qmd', ['--version'], (err, stdout, stderr) => { if (err) { console.error('STOCK execFile FAILED (expected on Windows):', err.code, err.message); process.exit(1); } console.log('STOCK execFile OK:', stdout.trim()); });" - # Exercises the PR's wrapExecFileForQmd path. The extension's + # Exercises the extension's qmd execFile wrapper under Node. The # setupQmdCollection() calls qmd via the wrapped execFile, so a - # successful run here verifies the .cmd wrapper actually works. + # successful run here verifies Windows shell invocation. - name: Smoke — extension wrapper -> qmd (must succeed) shell: pwsh run: | - bun -e "import('./index.ts').then(async (m) => { const ok = await m.setupQmdCollection(); if (!ok) { console.error('setupQmdCollection returned false — wrapper did not fix .cmd invocation'); process.exit(1); } console.log('OK: extension wrapper successfully invoked qmd through cmd.exe /c'); }).catch((e) => { console.error('FAILED:', e); process.exit(1); });" + node --import tsx -e "import('./index.ts').then(async (m) => { m.ensureDirs(); const ok = await m.setupQmdCollection(); if (!ok) { console.error('setupQmdCollection returned false — wrapper did not fix qmd invocation'); process.exit(1); } console.log('OK: extension wrapper successfully invoked qmd through the Windows shell'); }).catch((e) => { console.error('FAILED:', e); process.exit(1); });" # Verifies resolveMemoryDir() falls back to USERPROFILE when HOME # is unset (the second bug PR #11 fixes). We import the module in a @@ -117,7 +121,7 @@ jobs: if (-not $env:USERPROFILE) { Write-Error "USERPROFILE is not set on this runner"; exit 1 } $expectedPrefix = Join-Path $env:USERPROFILE ".pi\agent\memory" Write-Host "Expected prefix: $expectedPrefix" - $actual = bun -e "import('./index.ts').then(m => { console.log(m.dailyPath('2026-01-01')); });" + $actual = node --import tsx -e "import('./index.ts').then(m => { console.log(m.dailyPath('2026-01-01')); });" Write-Host "ensureDirs/dailyPath returned: $actual" if ($actual -notlike "$expectedPrefix*") { Write-Error "FAILED: resolveMemoryDir did not honor USERPROFILE fallback. Got: $actual" diff --git a/index.ts b/index.ts index 8b961dc..0be487d 100644 --- a/index.ts +++ b/index.ts @@ -20,7 +20,7 @@ * - MEMORY.md + SCRATCHPAD.md + today's + yesterday's daily logs injected into every turn */ -import { execFile } from "node:child_process"; +import { type ExecFileOptions, execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { complete, type Message, StringEnum } from "@mariozechner/pi-ai"; @@ -37,9 +37,23 @@ import { Type } from "@sinclair/typebox"; // Paths (mutable for testing via _setBaseDir / _resetBaseDir) // --------------------------------------------------------------------------- -const DEFAULT_MEMORY_DIR = process.env.PI_MEMORY_DIR ?? path.join(process.env.HOME ?? "~", ".pi", "agent", "memory"); +type MemoryEnv = Partial< + Record<"PI_MEMORY_DIR" | "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH", string | undefined> +> & { + [key: string]: string | undefined; +}; + +export function resolveMemoryDir(env: MemoryEnv = process.env): string { + if (env.PI_MEMORY_DIR) return env.PI_MEMORY_DIR; + const home = + env.HOME ?? + env.USERPROFILE ?? + (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ?? + "~"; + return path.join(home, ".pi", "agent", "memory"); +} -let MEMORY_DIR = DEFAULT_MEMORY_DIR; +let MEMORY_DIR = resolveMemoryDir(); let MEMORY_FILE = path.join(MEMORY_DIR, "MEMORY.md"); let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md"); let DAILY_DIR = path.join(MEMORY_DIR, "daily"); @@ -54,7 +68,7 @@ export function _setBaseDir(baseDir: string) { /** Reset to default paths (for testing). */ export function _resetBaseDir() { - _setBaseDir(DEFAULT_MEMORY_DIR); + _setBaseDir(resolveMemoryDir()); } // --------------------------------------------------------------------------- @@ -604,7 +618,30 @@ export function buildMemoryContext(searchResults?: string): string { // --------------------------------------------------------------------------- type ExecFileFn = typeof execFile; -let execFileFn: ExecFileFn = execFile; + +function isQmdCommand(file: string | URL): boolean { + if (typeof file !== "string") return false; + const basename = file.replace(/\\/g, "/").split("/").pop()?.toLowerCase(); + return basename === "qmd" || basename === "qmd.cmd" || basename === "qmd.exe"; +} + +export function qmdExecFileOptions( + file: string | URL, + options: ExecFileOptions, + platform: NodeJS.Platform = process.platform, +): ExecFileOptions { + if (platform !== "win32" || !isQmdCommand(file) || options.shell) return options; + return { ...options, shell: true }; +} + +const execFileWithQmdOptions: ExecFileFn = (( + file: string, + args: readonly string[], + options: ExecFileOptions, + callback: (...args: any[]) => void, +) => execFile(file, args as string[], qmdExecFileOptions(file, options), callback as any)) as ExecFileFn; + +let execFileFn: ExecFileFn = execFileWithQmdOptions; let qmdAvailable = false; let qmdAvailabilityCheckedAt = 0; @@ -629,7 +666,7 @@ export function _setExecFileForTest(fn: ExecFileFn) { /** Reset execFile implementation (for testing). */ export function _resetExecFileForTest() { - execFileFn = execFile; + execFileFn = execFileWithQmdOptions; } /** Set qmd availability flag (for testing). */ diff --git a/test/unit.test.ts b/test/unit.test.ts index 2dca417..a106d7e 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -26,8 +26,10 @@ import { nowTimestamp, parseScratchpad, qmdCollectionInstructions, + qmdExecFileOptions, qmdInstallInstructions, readFileSafe, + resolveMemoryDir, type ScratchpadItem, scheduleQmdUpdate, serializeScratchpad, @@ -149,6 +151,43 @@ describe("nowTimestamp", () => { }); }); +describe("resolveMemoryDir", () => { + test("prefers PI_MEMORY_DIR", () => { + const env = { + PI_MEMORY_DIR: path.join("custom", "memory"), + HOME: path.join("home", "ignored"), + USERPROFILE: path.join("profile", "ignored"), + }; + + expect(resolveMemoryDir(env)).toBe(env.PI_MEMORY_DIR); + }); + + test("falls back to USERPROFILE when HOME is unset", () => { + const env = { + USERPROFILE: path.join("Users", "runneradmin"), + }; + + expect(resolveMemoryDir(env)).toBe(path.join(env.USERPROFILE, ".pi", "agent", "memory")); + }); +}); + +describe("qmdExecFileOptions", () => { + test("uses the shell for qmd on Windows", () => { + const options = { timeout: 10_000 }; + + expect(qmdExecFileOptions("qmd", options, "win32")).toEqual({ + timeout: 10_000, + shell: true, + }); + }); + + test("leaves non-qmd commands unchanged", () => { + const options = { timeout: 10_000 }; + + expect(qmdExecFileOptions("node", options, "win32")).toBe(options); + }); +}); + describe("shortSessionId", () => { test("returns first 8 characters", () => { expect(shortSessionId("abcdef1234567890")).toBe("abcdef12"); From a4f1232939cb4bec2a83d099f23eff9082b7438f Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 21 May 2026 23:05:53 -0700 Subject: [PATCH 4/6] ci: install qmd with npm on Windows Signed-off-by: Jay Zeng --- .github/workflows/ci.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5e430a..41dda76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,21 +66,18 @@ jobs: with: node-version: 22 cache: npm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - run: npm ci - - name: Install qmd via Bun + - name: Install qmd via npm shell: pwsh - run: bun install -g https://github.com/tobi/qmd + run: npm install -g @tobilu/qmd - name: Ensure qmd prerequisites are on PATH shell: pwsh run: | - $bunBin = Join-Path $env:USERPROFILE ".bun\bin" - Write-Host "Adding to PATH: $bunBin" - Add-Content -Path $env:GITHUB_PATH -Value $bunBin + $npmPrefix = npm prefix -g + Write-Host "Adding to PATH: $npmPrefix" + Add-Content -Path $env:GITHUB_PATH -Value $npmPrefix $gitUsrBin = Join-Path $env:ProgramFiles "Git\usr\bin" if (Test-Path $gitUsrBin) { Write-Host "Adding to PATH: $gitUsrBin" @@ -91,7 +88,7 @@ jobs: shell: pwsh run: | Get-Command qmd - qmd --version + cmd /c qmd --version # Captures stock Node execFile behavior for the runner's qmd shim. This # is informational because shim behavior varies by package manager. From bd65bf97abe5ee4beb503e81d048447be34e967b Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 21 May 2026 23:27:30 -0700 Subject: [PATCH 5/6] fix: route qmd through PowerShell on Windows npm install -g @tobilu/qmd on modern npm + Node 22 produces only qmd.ps1 (no qmd.cmd), and cmd.exe's default PATHEXT does not include .PS1. The previous wrapper used `shell: true`, which spawns cmd.exe and therefore could not resolve the .ps1 shim. Replace the options wrapper with buildQmdSpawn, which on Windows transforms qmd calls into `powershell.exe -NoProfile -NoLogo -Command "& qmd '' ...; exit $LASTEXITCODE"`. PS resolves qmd against .cmd/.exe/.ps1, and single- quoted PS literals neutralize $/backticks so paths with spaces or $ pass through unchanged. PI_QMD_POWERSHELL env var allows forcing pwsh. Also dump npm prefix shims + Get-Command -All in the windows-qmd-smoke job for diagnostics, and verify qmd via the same path the wrapper uses. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jay Zeng --- .github/workflows/ci.yml | 13 ++++++++++-- index.ts | 36 +++++++++++++++++++++++++------ test/unit.test.ts | 46 ++++++++++++++++++++++++++++++---------- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41dda76..6b1d644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,17 @@ jobs: - name: Confirm qmd is on PATH shell: pwsh run: | - Get-Command qmd - cmd /c qmd --version + $prefix = (npm prefix -g).Trim() + Write-Host "--- npm prefix shims ($prefix) ---" + Get-ChildItem -Path $prefix -Filter "qmd*" -ErrorAction SilentlyContinue | Format-Table -AutoSize Name, Length + Write-Host "--- Get-Command qmd -All ---" + Get-Command qmd -All | Format-Table -AutoSize + Write-Host "--- qmd --version (resolved via pwsh, same path the wrapper uses) ---" + qmd --version + if ($LASTEXITCODE -ne 0) { + Write-Error "qmd --version failed (exit $LASTEXITCODE)" + exit 1 + } # Captures stock Node execFile behavior for the runner's qmd shim. This # is informational because shim behavior varies by package manager. diff --git a/index.ts b/index.ts index 0be487d..7c5d6eb 100644 --- a/index.ts +++ b/index.ts @@ -625,13 +625,32 @@ function isQmdCommand(file: string | URL): boolean { return basename === "qmd" || basename === "qmd.cmd" || basename === "qmd.exe"; } -export function qmdExecFileOptions( - file: string | URL, - options: ExecFileOptions, +// PowerShell single-quoted literal: a literal single quote is escaped by doubling. +// `$`, backticks, and other PS metacharacters are inert inside single quotes. +function quoteForPowerShell(s: string): string { + return `'${String(s).replace(/'/g, "''")}'`; +} + +// On Windows, route qmd through PowerShell so it resolves regardless of which +// shim type npm produced — .cmd, .exe, or .ps1. cmd.exe's PATHEXT does not +// include .PS1, so `shell: true` (which uses cmd) fails when npm only creates +// qmd.ps1 (observed with npm 11 + Node 22 on github actions runners). +export function buildQmdSpawn( + file: string, + args: readonly string[], platform: NodeJS.Platform = process.platform, -): ExecFileOptions { - if (platform !== "win32" || !isQmdCommand(file) || options.shell) return options; - return { ...options, shell: true }; + shellOverride?: string, +): { file: string; args: string[] } { + if (platform !== "win32" || !isQmdCommand(file)) { + return { file, args: [...args] }; + } + const psShell = shellOverride ?? process.env.PI_QMD_POWERSHELL ?? "powershell.exe"; + const quoted = args.map(quoteForPowerShell).join(" "); + const command = quoted.length > 0 ? `& qmd ${quoted}; exit $LASTEXITCODE` : "& qmd; exit $LASTEXITCODE"; + return { + file: psShell, + args: ["-NoProfile", "-NoLogo", "-Command", command], + }; } const execFileWithQmdOptions: ExecFileFn = (( @@ -639,7 +658,10 @@ const execFileWithQmdOptions: ExecFileFn = (( args: readonly string[], options: ExecFileOptions, callback: (...args: any[]) => void, -) => execFile(file, args as string[], qmdExecFileOptions(file, options), callback as any)) as ExecFileFn; +) => { + const spawn = buildQmdSpawn(file, args ?? []); + return execFile(spawn.file, spawn.args, options, callback as any); +}) as ExecFileFn; let execFileFn: ExecFileFn = execFileWithQmdOptions; diff --git a/test/unit.test.ts b/test/unit.test.ts index a106d7e..3dd8808 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -21,12 +21,12 @@ import { _setExecFileForTest, _setQmdAvailable, buildMemoryContext, + buildQmdSpawn, dailyPath, ensureDirs, nowTimestamp, parseScratchpad, qmdCollectionInstructions, - qmdExecFileOptions, qmdInstallInstructions, readFileSafe, resolveMemoryDir, @@ -171,20 +171,44 @@ describe("resolveMemoryDir", () => { }); }); -describe("qmdExecFileOptions", () => { - test("uses the shell for qmd on Windows", () => { - const options = { timeout: 10_000 }; +describe("buildQmdSpawn", () => { + test("wraps qmd in PowerShell on Windows", () => { + const out = buildQmdSpawn("qmd", ["collection", "list"], "win32", "powershell.exe"); + expect(out.file).toBe("powershell.exe"); + expect(out.args).toEqual(["-NoProfile", "-NoLogo", "-Command", "& qmd 'collection' 'list'; exit $LASTEXITCODE"]); + }); - expect(qmdExecFileOptions("qmd", options, "win32")).toEqual({ - timeout: 10_000, - shell: true, - }); + test("no-arg qmd invocation still routes through PowerShell on Windows", () => { + const out = buildQmdSpawn("qmd", [], "win32", "powershell.exe"); + expect(out.file).toBe("powershell.exe"); + expect(out.args[3]).toBe("& qmd; exit $LASTEXITCODE"); }); - test("leaves non-qmd commands unchanged", () => { - const options = { timeout: 10_000 }; + test("escapes embedded single quotes in args", () => { + const out = buildQmdSpawn("qmd", ["it's fine"], "win32", "powershell.exe"); + expect(out.args[3]).toBe("& qmd 'it''s fine'; exit $LASTEXITCODE"); + }); + + test("paths with spaces and `$` pass through as literals", () => { + const out = buildQmdSpawn("qmd", ["collection", "add", "C:\\Users\\Foo Bar\\$mem"], "win32", "powershell.exe"); + expect(out.args[3]).toBe("& qmd 'collection' 'add' 'C:\\Users\\Foo Bar\\$mem'; exit $LASTEXITCODE"); + }); + + test("recognizes qmd.cmd and qmd.exe as qmd commands on Windows", () => { + expect(buildQmdSpawn("qmd.cmd", ["update"], "win32", "powershell.exe").file).toBe("powershell.exe"); + expect(buildQmdSpawn("qmd.exe", ["update"], "win32", "powershell.exe").file).toBe("powershell.exe"); + }); + + test("passes through unchanged on non-Windows", () => { + const out = buildQmdSpawn("qmd", ["update"], "linux"); + expect(out.file).toBe("qmd"); + expect(out.args).toEqual(["update"]); + }); - expect(qmdExecFileOptions("node", options, "win32")).toBe(options); + test("passes through unchanged for non-qmd commands on Windows", () => { + const out = buildQmdSpawn("node", ["-v"], "win32"); + expect(out.file).toBe("node"); + expect(out.args).toEqual(["-v"]); }); }); From 8b7099d8b7d9fe8a6391dbf2442573abb3599a22 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Fri, 22 May 2026 07:52:19 -0700 Subject: [PATCH 6/6] fix: bypass broken Windows qmd shims via direct node invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI diagnostic revealed that npm install -g @tobilu/qmd produces all three shims (qmd, qmd.cmd, qmd.ps1) but cmd-shim writes the literal `/bin/sh` from the package's shebang into both qmd.cmd and qmd.ps1, so both shims fail: cmd.exe with "system cannot find the path specified" and PowerShell with "'/bin/sh.exe' is not recognized". This means PowerShell wrapping (the previous fix) can't help — the .ps1 shim itself is broken outside cygwin/git-bash trees. Replace the PS wrapper with direct node invocation: on Windows, walk PATH for a sibling node_modules/@tobilu/qmd/dist/cli/qmd.js (where both npm and pnpm install) and spawn `node `. This mirrors what bin/qmd itself does (`exec node "$JS" "$@"`) for npm-installed packages, skipping the broken shims entirely. The resolution is cached for the session; resetQmdJsResolution is exposed for tests. Add resolveQmdJsPath tests against a real temp tree, and update the windows-qmd-smoke job to verify the direct-node path the wrapper now uses (and keep the diagnostic dump of npm prefix + Get-Command -All). Co-Authored-By: Claude Opus 4.7 Signed-off-by: Jay Zeng --- .github/workflows/ci.yml | 17 ++++++-- index.ts | 56 +++++++++++++++++-------- test/unit.test.ts | 88 +++++++++++++++++++++++++++++++--------- 3 files changed, 119 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b1d644..c645ea8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: Add-Content -Path $env:GITHUB_PATH -Value $gitUsrBin } - - name: Confirm qmd is on PATH + - name: Confirm qmd is callable (via the same direct-node path the wrapper uses) shell: pwsh run: | $prefix = (npm prefix -g).Trim() @@ -92,10 +92,19 @@ jobs: Get-ChildItem -Path $prefix -Filter "qmd*" -ErrorAction SilentlyContinue | Format-Table -AutoSize Name, Length Write-Host "--- Get-Command qmd -All ---" Get-Command qmd -All | Format-Table -AutoSize - Write-Host "--- qmd --version (resolved via pwsh, same path the wrapper uses) ---" - qmd --version + # cmd-shim writes literal `/bin/sh` into both qmd.cmd and qmd.ps1 on + # Windows, so neither shim is callable directly. The extension bypasses + # this by invoking qmd's JS entry with node. Verify that path exists + # and works. + $qmdJs = Join-Path $prefix "node_modules\@tobilu\qmd\dist\cli\qmd.js" + Write-Host "--- expected qmd.js: $qmdJs ---" + if (-not (Test-Path $qmdJs)) { + Write-Error "qmd.js not found at $qmdJs" + exit 1 + } + node $qmdJs --version if ($LASTEXITCODE -ne 0) { - Write-Error "qmd --version failed (exit $LASTEXITCODE)" + Write-Error "node $qmdJs --version failed (exit $LASTEXITCODE)" exit 1 } diff --git a/index.ts b/index.ts index 7c5d6eb..7ba1787 100644 --- a/index.ts +++ b/index.ts @@ -625,32 +625,51 @@ function isQmdCommand(file: string | URL): boolean { return basename === "qmd" || basename === "qmd.cmd" || basename === "qmd.exe"; } -// PowerShell single-quoted literal: a literal single quote is escaped by doubling. -// `$`, backticks, and other PS metacharacters are inert inside single quotes. -function quoteForPowerShell(s: string): string { - return `'${String(s).replace(/'/g, "''")}'`; +const QMD_JS_REL = path.join("node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js"); + +let cachedQmdJsPath: string | null | undefined; + +// On Windows, cmd-shim writes the literal `/bin/sh` (the package's shebang +// interpreter) into both qmd.cmd and qmd.ps1, so both shims fail with +// "system cannot find the path specified" / "'/bin/sh.exe' is not recognized" +// outside cygwin/git-bash trees. Bypass the shims by locating qmd's JS entry +// in a sibling node_modules directory of a PATH entry and invoking it with +// node directly — the same thing the sh script in bin/qmd does when launched +// via npm. +export function resolveQmdJsPath(env: NodeJS.ProcessEnv = process.env): string | null { + if (cachedQmdJsPath !== undefined) return cachedQmdJsPath; + const pathStr = env.PATH ?? env.Path ?? ""; + const entries = pathStr.split(path.delimiter).filter(Boolean); + for (const dir of entries) { + try { + const candidate = path.join(dir, QMD_JS_REL); + if (fs.statSync(candidate).isFile()) { + cachedQmdJsPath = candidate; + return candidate; + } + } catch { + // keep scanning + } + } + cachedQmdJsPath = null; + return null; +} + +/** Clear the resolved qmd.js cache (for testing). */ +export function _resetQmdJsResolutionForTest() { + cachedQmdJsPath = undefined; } -// On Windows, route qmd through PowerShell so it resolves regardless of which -// shim type npm produced — .cmd, .exe, or .ps1. cmd.exe's PATHEXT does not -// include .PS1, so `shell: true` (which uses cmd) fails when npm only creates -// qmd.ps1 (observed with npm 11 + Node 22 on github actions runners). export function buildQmdSpawn( file: string, args: readonly string[], platform: NodeJS.Platform = process.platform, - shellOverride?: string, + qmdJsPath: string | null = null, ): { file: string; args: string[] } { - if (platform !== "win32" || !isQmdCommand(file)) { + if (platform !== "win32" || !isQmdCommand(file) || !qmdJsPath) { return { file, args: [...args] }; } - const psShell = shellOverride ?? process.env.PI_QMD_POWERSHELL ?? "powershell.exe"; - const quoted = args.map(quoteForPowerShell).join(" "); - const command = quoted.length > 0 ? `& qmd ${quoted}; exit $LASTEXITCODE` : "& qmd; exit $LASTEXITCODE"; - return { - file: psShell, - args: ["-NoProfile", "-NoLogo", "-Command", command], - }; + return { file: "node", args: [qmdJsPath, ...args] }; } const execFileWithQmdOptions: ExecFileFn = (( @@ -659,7 +678,8 @@ const execFileWithQmdOptions: ExecFileFn = (( options: ExecFileOptions, callback: (...args: any[]) => void, ) => { - const spawn = buildQmdSpawn(file, args ?? []); + const qmdJs = process.platform === "win32" && isQmdCommand(file) ? resolveQmdJsPath() : null; + const spawn = buildQmdSpawn(file, args ?? [], process.platform, qmdJs); return execFile(spawn.file, spawn.args, options, callback as any); }) as ExecFileFn; diff --git a/test/unit.test.ts b/test/unit.test.ts index 3dd8808..b02c616 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -17,6 +17,7 @@ import { _resetBaseDir, _resetExecFileForTest, _resetMemorySnapshot, + _resetQmdJsResolutionForTest, _setBaseDir, _setExecFileForTest, _setQmdAvailable, @@ -30,6 +31,7 @@ import { qmdInstallInstructions, readFileSafe, resolveMemoryDir, + resolveQmdJsPath, type ScratchpadItem, scheduleQmdUpdate, serializeScratchpad, @@ -172,46 +174,92 @@ describe("resolveMemoryDir", () => { }); describe("buildQmdSpawn", () => { - test("wraps qmd in PowerShell on Windows", () => { - const out = buildQmdSpawn("qmd", ["collection", "list"], "win32", "powershell.exe"); - expect(out.file).toBe("powershell.exe"); - expect(out.args).toEqual(["-NoProfile", "-NoLogo", "-Command", "& qmd 'collection' 'list'; exit $LASTEXITCODE"]); - }); + const QMD_JS = "C:\\npm\\prefix\\node_modules\\@tobilu\\qmd\\dist\\cli\\qmd.js"; - test("no-arg qmd invocation still routes through PowerShell on Windows", () => { - const out = buildQmdSpawn("qmd", [], "win32", "powershell.exe"); - expect(out.file).toBe("powershell.exe"); - expect(out.args[3]).toBe("& qmd; exit $LASTEXITCODE"); + test("invokes qmd's JS entry via node on Windows when resolution succeeds", () => { + const out = buildQmdSpawn("qmd", ["collection", "list"], "win32", QMD_JS); + expect(out.file).toBe("node"); + expect(out.args).toEqual([QMD_JS, "collection", "list"]); }); - test("escapes embedded single quotes in args", () => { - const out = buildQmdSpawn("qmd", ["it's fine"], "win32", "powershell.exe"); - expect(out.args[3]).toBe("& qmd 'it''s fine'; exit $LASTEXITCODE"); + test("no-arg qmd invocation still uses node + resolved JS path on Windows", () => { + const out = buildQmdSpawn("qmd", [], "win32", QMD_JS); + expect(out.file).toBe("node"); + expect(out.args).toEqual([QMD_JS]); }); - test("paths with spaces and `$` pass through as literals", () => { - const out = buildQmdSpawn("qmd", ["collection", "add", "C:\\Users\\Foo Bar\\$mem"], "win32", "powershell.exe"); - expect(out.args[3]).toBe("& qmd 'collection' 'add' 'C:\\Users\\Foo Bar\\$mem'; exit $LASTEXITCODE"); + test("paths with spaces and `$` in user args pass through as literal argv", () => { + const arg = "C:\\Users\\Foo Bar\\$mem"; + const out = buildQmdSpawn("qmd", ["collection", "add", arg], "win32", QMD_JS); + expect(out.args).toEqual([QMD_JS, "collection", "add", arg]); }); test("recognizes qmd.cmd and qmd.exe as qmd commands on Windows", () => { - expect(buildQmdSpawn("qmd.cmd", ["update"], "win32", "powershell.exe").file).toBe("powershell.exe"); - expect(buildQmdSpawn("qmd.exe", ["update"], "win32", "powershell.exe").file).toBe("powershell.exe"); + expect(buildQmdSpawn("qmd.cmd", ["update"], "win32", QMD_JS).file).toBe("node"); + expect(buildQmdSpawn("qmd.exe", ["update"], "win32", QMD_JS).file).toBe("node"); }); - test("passes through unchanged on non-Windows", () => { - const out = buildQmdSpawn("qmd", ["update"], "linux"); + test("falls through to bare qmd when resolution returns null", () => { + const out = buildQmdSpawn("qmd", ["update"], "win32", null); + expect(out.file).toBe("qmd"); + expect(out.args).toEqual(["update"]); + }); + + test("passes through unchanged on non-Windows even with a resolved path", () => { + const out = buildQmdSpawn("qmd", ["update"], "linux", QMD_JS); expect(out.file).toBe("qmd"); expect(out.args).toEqual(["update"]); }); test("passes through unchanged for non-qmd commands on Windows", () => { - const out = buildQmdSpawn("node", ["-v"], "win32"); + const out = buildQmdSpawn("node", ["-v"], "win32", QMD_JS); expect(out.file).toBe("node"); expect(out.args).toEqual(["-v"]); }); }); +describe("resolveQmdJsPath", () => { + let scratchDir: string; + beforeEach(() => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-memory-qmd-resolve-")); + _resetQmdJsResolutionForTest(); + }); + afterEach(() => { + fs.rmSync(scratchDir, { recursive: true, force: true }); + _resetQmdJsResolutionForTest(); + }); + + test("returns the sibling node_modules path for a PATH entry that contains the install", () => { + const prefix = path.join(scratchDir, "prefix"); + const qmdJs = path.join(prefix, "node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js"); + fs.mkdirSync(path.dirname(qmdJs), { recursive: true }); + fs.writeFileSync(qmdJs, "// stub", "utf-8"); + + const found = resolveQmdJsPath({ PATH: prefix } as NodeJS.ProcessEnv); + expect(found).toBe(qmdJs); + }); + + test("returns null when no PATH entry has a sibling install", () => { + const empty = path.join(scratchDir, "empty"); + fs.mkdirSync(empty, { recursive: true }); + const found = resolveQmdJsPath({ PATH: empty } as NodeJS.ProcessEnv); + expect(found).toBeNull(); + }); + + test("caches the resolved path across calls", () => { + const prefix = path.join(scratchDir, "prefix"); + const qmdJs = path.join(prefix, "node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js"); + fs.mkdirSync(path.dirname(qmdJs), { recursive: true }); + fs.writeFileSync(qmdJs, "// stub", "utf-8"); + + const first = resolveQmdJsPath({ PATH: prefix } as NodeJS.ProcessEnv); + // Second call with an empty PATH still returns the cached value + const second = resolveQmdJsPath({ PATH: "" } as NodeJS.ProcessEnv); + expect(first).toBe(qmdJs); + expect(second).toBe(qmdJs); + }); +}); + describe("shortSessionId", () => { test("returns first 8 characters", () => { expect(shortSessionId("abcdef1234567890")).toBe("abcdef12");