Install the VS Code extension from the npm package

unstaged · index → working-tree · GPT-6 (Codex)

The npm package bundles its tested VSIX and provides an explicit VS Code installation command without GitHub or Marketplace access. Optional Marketplace installation uses the same extension identity after publication. Packaging is part of prepack; publishing remains an explicit separate action requiring publisher access. Offline npm-to-editor installation was checked in an isolated VS Code profile; no release or normal-profile installation occurred.

Execution evidence · 17 records

Author confidence and passing claims are not measured accuracy. Records are unauthenticated; source hashes establish freshness, not who ran a command.

pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.038Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T10:58:54.039Z · 1145ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 1.1s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/4c790a1d26481d92f95c3d3ac6896b13e103b378ae67a82aa4172b5d0eae9b02.log · SHA-256 4c790a1d26481d92f95c3d3ac6896b13e103b378ae67a82aa4172b5d0eae9b02
pass · imported, freshness unchecked · npm run lint
Recorded: 2026-09-17T10:58:54.041Z · 3203ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 3.2s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/12a7d4451b2edec6f0c59ab46c9523bfbefc2994173ad529ae49df3148c244e1.log · SHA-256 12a7d4451b2edec6f0c59ab46c9523bfbefc2994173ad529ae49df3148c244e1
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.041Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.042Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.042Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.042Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.043Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.043Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.044Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.044Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.044Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.045Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.045Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.045Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.046Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T10:58:54.047Z · 5902ms · exit 0
whymark@0.3.0 · v22.23.2 · cwd .
exit 0 in 5.9s
Source: 92c0ad9372c6745f28518c2e64c09efae9bc8d232704fe0235a0bc1424ddb2b9
Output: .artifacts/whymark/e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc.log · SHA-256 e0baa3faf3d216eb1bca88177b9ac54a6e83c7e331b65421bc3657883428ebfc
annotations14 of 14 shown · j k to step through

.gitignore

+4 0read-only10%
intentunspecifiedconfidence unknown
@@ -45,3 +45,7 @@next-env.d.ts
4545  
4646 # downloadable snapshots of this repo, served by the dev server
4747 /public/*.zip
48+ 
49+# npm-bundled extension artifacts (rebuilt by prepack)
50+/dist/whymark-vscode.vsix
51+/dist/vscode-extension.json

README.md

+16 0read-only10%
intentunspecifiedconfidence unknown
@@ -366,3 +366,19 @@code --install-extension .artifacts/whymark-vscode.vsix
366366  
367367 See [extension usage and safety boundaries](extensions/vscode/README.md). The VSIX
368368 is local and self-contained; it has not been published to the Marketplace.
369+ 
370+### Install the VS Code extension through npm
371+ 
372+The next npm release includes its VSIX, so users with VS Code installed can run:
373+ 
374+```sh
375+npx whymark@latest vscode install
376+```
377+ 
378+Use `--dry-run` to preview, or `--code /path/to/code` if the editor launcher is not
379+on PATH. No GitHub access or Marketplace download is needed for this route.
380+After Marketplace publication, `npx whymark vscode install --marketplace` installs
381+`spink-dev.whymark` from the Marketplace instead. See the
382+[release guide](extensions/vscode/PUBLISHING.md) for publisher setup and separate
383+npm/Marketplace releases. These commands are implemented locally; neither updated
384+package has been published by this task.

dist/whymark.mjs

+118 42read-only10%
generatedunspecifiedconfidence unknown
@@ -2,8 +2,8 @@
22 /* generated by npm run build:cli — do not edit */
33  
44 // src/cli/whymark.ts
5import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
6import { dirname as dirname2, join as join5, relative as relative6, resolve as resolve6 } from "node:path";
5+import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
6+import { dirname as dirname3, join as join6, relative as relative6, resolve as resolve7 } from "node:path";
77 import { fileURLToPath } from "node:url";
88  
99 // src/lib/whymark/parse.ts
@@ -852,29 +852,29 @@function appendToField(ctx, text) {
852852 const note = ctx.note;
853853 const field = ctx.lastField;
854854 if (!field) return;
855 const join6 = (existing) => existing ? `${existing} ${text}` : text;
856 if (field.name === "why") note.why = join6(note.why);
857 else if (field.name === "what") note.what = join6(note.what);
858 else if (field.name === "impact") note.impact = join6(note.impact);
855+ const join7 = (existing) => existing ? `${existing} ${text}` : text;
856+ if (field.name === "why") note.why = join7(note.why);
857+ else if (field.name === "what") note.what = join7(note.what);
858+ else if (field.name === "impact") note.impact = join7(note.impact);
859859 else if (field.name === "source") {
860860 const ref = note.sources[field.index];
861 ref.note = join6(ref.note);
861+ ref.note = join7(ref.note);
862862 ref.raw = `${ref.raw} ${text}`;
863863 } else if (field.name === "verify") {
864864 const claim = note.verify[field.index];
865 claim.comment = join6(claim.comment);
865+ claim.comment = join7(claim.comment);
866866 claim.raw = `${claim.raw} ${text}`;
867867 } else if (field.name === "alt") {
868 note.alternatives[field.index] = join6(note.alternatives[field.index]);
868+ note.alternatives[field.index] = join7(note.alternatives[field.index]);
869869 } else if (field.name === "todo") {
870 note.todos[field.index] = join6(note.todos[field.index]);
870+ note.todos[field.index] = join7(note.todos[field.index]);
871871 } else if (field.name === "question") {
872 note.questions[field.index] = join6(note.questions[field.index]);
872+ note.questions[field.index] = join7(note.questions[field.index]);
873873 } else if (field.name === "ref") {
874 note.refs[field.index] = join6(note.refs[field.index]);
874+ note.refs[field.index] = join7(note.refs[field.index]);
875875 } else if (field.name.startsWith("extra:")) {
876876 const key = field.name.slice("extra:".length);
877 note.extra[key][field.index] = join6(note.extra[key][field.index]);
877+ note.extra[key][field.index] = join7(note.extra[key][field.index]);
878878 }
879879 }
880880 function finishNote(ctx, diagnostics) {
@@ -2466,39 +2466,94 @@async function qualityCommand(positionals, options) {
24662466 });
24672467 }
24682468  
2469// src/lib/skill/install.ts
2470import { existsSync as existsSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync5, realpathSync as realpathSync4, writeFileSync as writeFileSync3 } from "node:fs";
2471import { dirname, join as join4, relative as relative5, resolve as resolve5, sep as sep3 } from "node:path";
2469+// src/lib/vscode/install.ts
2470+import { spawnSync as spawnSync3 } from "node:child_process";
2471+import { accessSync, constants, existsSync as existsSync2, readFileSync as readFileSync5 } from "node:fs";
2472+import { createHash as createHash2 } from "node:crypto";
2473+import { delimiter, dirname, isAbsolute, join as join4, resolve as resolve5 } from "node:path";
24722474 import { homedir } from "node:os";
2475+function executable(candidate) {
2476+ try {
2477+ accessSync(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
2478+ return true;
2479+ } catch {
2480+ return false;
2481+ }
2482+}
2483+function codeLauncher(explicit) {
2484+ const names = explicit ? [explicit] : process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
2485+ const candidates = names.flatMap((name) => isAbsolute(name) || /[\\/]/.test(name) ? [resolve5(name)] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join4(dir, name)));
2486+ if (!explicit && process.platform === "darwin") candidates.push("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", join4(homedir(), "Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"));
2487+ if (!explicit && process.platform === "win32") {
2488+ for (const root of [process.env.LOCALAPPDATA && join4(process.env.LOCALAPPDATA, "Programs"), process.env.ProgramFiles]) if (root) candidates.push(join4(root, "Microsoft VS Code", "Code.exe"));
2489+ }
2490+ const command = candidates.find(executable);
2491+ if (!command) throw new Error("VS Code CLI not found. Install VS Code and add 'code' to PATH, or pass --code <path-to-code>.");
2492+ if (process.platform === "win32") {
2493+ const root = /\.(cmd|bat)$/i.test(command) ? resolve5(dirname(command), "..") : dirname(command);
2494+ const native = join4(root, "Code.exe");
2495+ const cli = join4(root, "resources/app/out/cli.js");
2496+ if (!existsSync2(native) || !existsSync2(cli)) throw new Error("Use --code with the standard VS Code Code.exe or bin/code.cmd installation path.");
2497+ return { command: native, args: [cli], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } };
2498+ }
2499+ return { command, args: [], env: process.env };
2500+}
2501+function installExtension(options) {
2502+ const metadataPath = join4(options.packageRoot, "dist/vscode-extension.json");
2503+ if (!existsSync2(metadataPath)) throw new Error("Extension bundle missing. In a source checkout run npm run package:vscode; otherwise reinstall the whymark npm package.");
2504+ const metadata = JSON.parse(readFileSync5(metadataPath, "utf8"));
2505+ if (!/^[a-z0-9-]+\.[a-z0-9-]+$/i.test(metadata.id) || !/^[a-f0-9]{64}$/.test(metadata.sha256)) throw new Error("Invalid bundled extension metadata. Reinstall the whymark npm package.");
2506+ const vsix = join4(options.packageRoot, "dist/whymark-vscode.vsix");
2507+ if (!options.marketplace && createHash2("sha256").update(readFileSync5(vsix)).digest("hex") !== metadata.sha256) throw new Error("Bundled VSIX checksum mismatch. Reinstall the whymark npm package.");
2508+ const launcher = codeLauncher(options.code);
2509+ const args = [...launcher.args, "--install-extension", options.marketplace ? metadata.id : vsix];
2510+ if (options.extensionsDir) args.push("--extensions-dir", resolve5(options.extensionsDir));
2511+ if (options.userDataDir) args.push("--user-data-dir", resolve5(options.userDataDir));
2512+ if (options.dryRun) {
2513+ process.stdout.write(`${JSON.stringify({ command: launcher.command, args, source: options.marketplace ? "marketplace" : "bundled", extension: metadata.id, bundledVersion: metadata.version }, null, 2)}
2514+`);
2515+ return;
2516+ }
2517+ const result = spawnSync3(launcher.command, args, { stdio: "inherit", env: launcher.env, shell: false, timeout: 12e4 });
2518+ if (result.error) throw new Error(`VS Code installation could not complete: ${result.error.message}`);
2519+ if (result.status !== 0) throw new Error(`VS Code installation failed (${result.signal ?? result.status}).${options.marketplace ? " The extension must be published before Marketplace installation works." : ""}`);
2520+ process.stdout.write(`Installed ${metadata.id}. Open the Command Palette and search for Whymark.
2521+`);
2522+}
2523+ 
2524+// src/lib/skill/install.ts
2525+import { existsSync as existsSync3, lstatSync as lstatSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync6, realpathSync as realpathSync4, writeFileSync as writeFileSync3 } from "node:fs";
2526+import { dirname as dirname2, join as join5, relative as relative5, resolve as resolve6, sep as sep3 } from "node:path";
2527+import { homedir as homedir2 } from "node:os";
24732528 function installSkill(options) {
24742529 const agents = [...new Set(options.agents?.length ? options.agents : ["codex"])];
24752530 if (agents.some((agent) => !["codex", "claude-code"].includes(agent))) {
24762531 throw new Error("Supported agents: codex, claude-code.");
24772532 }
2478 const base = realpathSync4(options.global ? options.home ?? homedir() : options.cwd);
2479 const skill = readFileSync5(join4(options.packageRoot, ".agents/skills/whymark/SKILL.md"), "utf8").replaceAll("spec/whymark-v1.md", "references/whymark-v1.md");
2533+ const base = realpathSync4(options.global ? options.home ?? homedir2() : options.cwd);
2534+ const skill = readFileSync6(join5(options.packageRoot, ".agents/skills/whymark/SKILL.md"), "utf8").replaceAll("spec/whymark-v1.md", "references/whymark-v1.md");
24802535 const assets = [
24812536 ["SKILL.md", skill],
2482 ["references/whymark-v1.md", readFileSync5(join4(options.packageRoot, "spec/whymark-v1.md"), "utf8")]
2537+ ["references/whymark-v1.md", readFileSync6(join5(options.packageRoot, "spec/whymark-v1.md"), "utf8")]
24832538 ];
24842539 const pending = [];
24852540 for (const agent of agents) {
2486 const destination = join4(base, agent === "codex" ? ".agents" : ".claude", "skills/whymark");
2541+ const destination = join5(base, agent === "codex" ? ".agents" : ".claude", "skills/whymark");
24872542 for (const [name, content] of assets) {
2488 const path = resolve5(destination, name);
2543+ const path = resolve6(destination, name);
24892544 assertNoSymlinks(base, path);
2490 const identical = existsSync2(path) && readFileSync5(path, "utf8") === content;
2491 if (!identical && existsSync2(path) && !options.force) {
2545+ const identical = existsSync3(path) && readFileSync6(path, "utf8") === content;
2546+ if (!identical && existsSync3(path) && !options.force) {
24922547 throw new Error(`Refusing to overwrite ${path}. Use --force to replace this skill explicitly.`);
24932548 }
2494 pending.push({ path, content, action: identical ? "unchanged" : existsSync2(path) ? "replace" : "create" });
2549+ pending.push({ path, content, action: identical ? "unchanged" : existsSync3(path) ? "replace" : "create" });
24952550 }
24962551 }
24972552 if (!options.dryRun) {
24982553 for (const file of pending) {
24992554 if (file.action === "unchanged") continue;
25002555 assertNoSymlinks(base, file.path);
2501 mkdirSync2(dirname(file.path), { recursive: true });
2556+ mkdirSync2(dirname2(file.path), { recursive: true });
25022557 writeFileSync3(file.path, file.content, { flag: file.action === "create" ? "wx" : "w" });
25032558 }
25042559 }
@@ -2509,7 +2564,7 @@function assertNoSymlinks(base, path) {
25092564 if (parts.includes("..")) throw new Error("Skill destination escapes its installation directory.");
25102565 let current = base;
25112566 for (const [index, part] of parts.entries()) {
2512 current = join4(current, part);
2567+ current = join5(current, part);
25132568 let info;
25142569 try {
25152570 info = lstatSync2(current);
@@ -2526,6 +2581,16 @@function assertNoSymlinks(base, path) {
25262581  
25272582 // src/cli/help.ts
25282583 var TOPICS = {
2584+ vscode: { aliases: [], render: () => `npx whymark vscode install [--code <path>] [--dry-run] [--marketplace]
2585+ 
2586+Installs the VSIX bundled with this npm package using the VS Code CLI.
2587+No GitHub access or web server is needed. Install VS Code first.
2588+--code <path> selects a VS Code launcher when it is not on PATH.
2589+--dry-run prints the exact command and arguments without changing VS Code.
2590+--marketplace installs the published extension ID instead (requires publication).
2591+--extensions-dir <path> and --user-data-dir <path> select isolated VS Code locations.
2592+No postinstall hook runs, and existing extensions are not force-downgraded.
2593+` },
25292594 quality: { aliases: [], render: () => `npx whymark quality init
25302595 npx whymark quality <review.whymark> --run [--write] [--baseline report.quality.json]
25312596 npx whymark quality <review.whymark> --run --watch --write
@@ -2595,6 +2660,7 @@${c2.bold("WORKFLOW")}
25952660 5. open https://whymark.x47.dev drop the file; it stays in the browser
25962661  
25972662 ${c2.bold("COMMANDS")}
2663+ vscode install the VS Code extension bundled with npm
25982664 quality configured local checks and ESLint findings
25992665 skill install the bundled agent skill from npm
26002666 new skeleton from a git diff ${c2.gray("alias: init")}
@@ -2965,6 +3031,16 @@function main() {
29653031 return cmdHelp(isHelpToken(args.command) ? args.positionals[0] : args.command);
29663032 }
29673033 switch (args.command) {
3034+ case "vscode": {
3035+ if (args.positionals.length !== 1 || args.positionals[0] !== "install") fail("Usage: npx whymark vscode install [--code <path>] [--dry-run] [--marketplace]");
3036+ for (const name of ["code", "extensions-dir", "user-data-dir"]) if (args.has(name) && !args.str(name)) fail(`--${name} requires a value.`);
3037+ try {
3038+ installExtension({ packageRoot: packageRoot(), code: args.str("code"), dryRun: args.has("dry-run"), marketplace: args.has("marketplace"), extensionsDir: args.str("extensions-dir"), userDataDir: args.str("user-data-dir") });
3039+ } catch (error) {
3040+ fail(error.message);
3041+ }
3042+ return;
3043+ }
29683044 case "quality":
29693045 return qualityCommand(args.positionals, { run: args.has("run"), watch: args.has("watch"), write: args.has("write"), config: args.str("config"), baseline: args.str("baseline"), importPath: args.str("import"), toolVersion: args.str("tool-version") }).catch((error) => fail(error.message));
29703046 case "skill": {
@@ -3065,8 +3141,8 @@function cmdNew(args) {
30653141 process.stdout.write(text);
30663142 return;
30673143 }
3068 const target = out ? resolve6(cwd, out) : resolve6(cwd, "reviews", `${slug(doc.meta.title)}.whymark`);
3069 mkdirSync3(dirname2(target), { recursive: true });
3144+ const target = out ? resolve7(cwd, out) : resolve7(cwd, "reviews", `${slug(doc.meta.title)}.whymark`);
3145+ mkdirSync3(dirname3(target), { recursive: true });
30703146 writeFileSync4(target, text);
30713147 const stats = computeStats(doc);
30723148 const rel = relative6(cwd, target) || target;
@@ -3099,15 +3175,15 @@function cmdPrompt(args) {
30993175 if (!diff.files.length) noChanges(doc.meta.scope);
31003176 const skeleton = serializeWhymark(doc);
31013177 const pack = packageRoot();
3102 const templatePath = [join5(cwd, "prompts", "whymark-author.md"), join5(pack, "prompts", "whymark-author.md")].find(
3103 existsSync3
3178+ const templatePath = [join6(cwd, "prompts", "whymark-author.md"), join6(pack, "prompts", "whymark-author.md")].find(
3179+ existsSync4
31043180 );
3105 const template = templatePath ? stripPreamble(readFileSync6(templatePath, "utf8")) : FALLBACK_PROMPT;
3106 const specInRepo = existsSync3(join5(cwd, "spec/whymark-v1.md"));
3181+ const template = templatePath ? stripPreamble(readFileSync7(templatePath, "utf8")) : FALLBACK_PROMPT;
3182+ const specInRepo = existsSync4(join6(cwd, "spec/whymark-v1.md"));
31073183 process.stdout.write(
31083184 template.replace("{{SKELETON}}", skeleton.trimEnd()).replace(
31093185 "{{SPEC_PATH}}",
3110 specInRepo ? "spec/whymark-v1.md" : join5(pack, "spec/whymark-v1.md")
3186+ specInRepo ? "spec/whymark-v1.md" : join6(pack, "spec/whymark-v1.md")
31113187 )
31123188 );
31133189 }
@@ -3126,8 +3202,8 @@command you ran. Do not narrate what the code does. Keep the diff bytes untouche
31263202 \`\`\`
31273203 `;
31283204 function loadDoc(path) {
3129 if (!existsSync3(path)) fail(`No such file: ${path}`);
3130 const text = readFileSync6(path, "utf8");
3205+ if (!existsSync4(path)) fail(`No such file: ${path}`);
3206+ const text = readFileSync7(path, "utf8");
31313207 return parseWhymark(text, { filename: path });
31323208 }
31333209 function cmdValidate(args) {
@@ -3314,20 +3390,20 @@function slug(title) {
33143390 return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "review";
33153391 }
33163392 function packageRoot() {
3317 const here = dirname2(fileURLToPath(import.meta.url));
3318 for (const dir of [join5(here, ".."), join5(here, "../..")]) {
3319 const pkgPath = join5(dir, "package.json");
3320 if (!existsSync3(pkgPath)) continue;
3393+ const here = dirname3(fileURLToPath(import.meta.url));
3394+ for (const dir of [join6(here, ".."), join6(here, "../..")]) {
3395+ const pkgPath = join6(dir, "package.json");
3396+ if (!existsSync4(pkgPath)) continue;
33213397 try {
3322 if (JSON.parse(readFileSync6(pkgPath, "utf8")).name === "whymark") return dir;
3398+ if (JSON.parse(readFileSync7(pkgPath, "utf8")).name === "whymark") return dir;
33233399 } catch {
33243400 }
33253401 }
3326 return join5(here, "../..");
3402+ return join6(here, "../..");
33273403 }
33283404 function pkgVersion() {
33293405 try {
3330 return JSON.parse(readFileSync6(join5(packageRoot(), "package.json"), "utf8")).version ?? "0.0.0";
3406+ return JSON.parse(readFileSync7(join6(packageRoot(), "package.json"), "utf8")).version ?? "0.0.0";
33313407 } catch {
33323408 return "0.0.0";
33333409 }

extensions/vscode/README.md

+20 0read-only10%
intentunspecifiedconfidence unknown
@@ -2,6 +2,26 @@
22  
33 Read AI-written changes with the reasons, sources and verification beside the code — directly in VS Code, without starting a web server.
44  
5+## Install from npm
6+ 
7+After the next npm release ships this command:
8+ 
9+```sh
10+npx whymark@latest vscode install
11+```
12+ 
13+It installs the extension bundled in the npm package through VS Code's CLI, with
14+no GitHub access or Marketplace dependency. VS Code must already be installed.
15+Use `--dry-run` to preview or `--code /path/to/code` for a custom installation.
16+Installation is explicit; installing the npm package alone does not change VS Code.
17+The VSIX version is tied to that npm release. Updates through npm require rerunning
18+the command; the installer does not force a downgrade of a newer installed extension.
19+ 
20+After Marketplace publication, search for **Whymark** by publisher **spink-dev**,
21+or run `npx whymark vscode install --marketplace`. That route installs the published
22+Marketplace version instead of the bundled version. Publisher availability and
23+publication are not yet confirmed.
24+ 
525 ## Install locally
626  
727 From the repository root:

package.json

+10 7read-only10%
intentunspecifiedconfidence unknown
@@ -1,7 +1,7 @@
11 {
22 "name": "whymark",
33 "version": "0.3.0",
4 "description": "whymark a file format and reviewer for reading AI-written code changes with the reasoning, sources, and verification attached to each line.",
4+ "description": "whymark \u2014 a file format and reviewer for reading AI-written code changes with the reasoning, sources, and verification attached to each line.",
55 "author": "Samuel Spink",
66 "license": "MIT",
77 "bin": {
@@ -14,7 +14,9 @@
1414 "spec/whymark-v1.md",
1515 "README.md",
1616 "LICENSE",
17 ".agents/skills/whymark/SKILL.md"
17+ ".agents/skills/whymark/SKILL.md",
18+ "dist/whymark-vscode.vsix",
19+ "dist/vscode-extension.json"
1820 ],
1921 "engines": {
2022 "node": "22.x"
@@ -37,20 +39,21 @@
3739 "scripts": {
3840 "dev": "next dev --port 43917",
3941 "build": "next build",
40 "build:cli": "esbuild src/cli/whymark.ts --bundle --platform=node --format=esm --outfile=dist/whymark.mjs --external:yaml --legal-comments=none --banner:js='/* generated by npm run build:cli do not edit */'",
42+ "build:cli": "esbuild src/cli/whymark.ts --bundle --platform=node --format=esm --outfile=dist/whymark.mjs --external:yaml --legal-comments=none --banner:js='/* generated by npm run build:cli \u2014 do not edit */'",
4143 "prepublishOnly": "npm run build:cli",
4244 "start": "next start --port 43917",
4345 "lint": "eslint",
4446 "typecheck": "tsc --noEmit",
45 "test": "npm run build:cli && vitest run",
47+ "test": "npm run build:cli && npm run package:vscode && vitest run",
4648 "test:watch": "vitest",
4749 "test:ui": "node tests/e2e/viewer.mjs",
4850 "whymark": "tsx src/cli/whymark.ts",
49 "prepack": "npm run build:cli",
51+ "prepack": "npm run build:cli && npm run package:vscode",
5052 "build:vscode": "node extensions/vscode/scripts/build.mjs",
51 "package:vscode": "npm run build:vscode && cd extensions/vscode && vsce package --no-dependencies --allow-missing-repository --out ../../.artifacts/whymark-vscode.vsix",
53+ "package:vscode": "node extensions/vscode/scripts/package.mjs",
5254 "test:vscode": "npm run build:vscode && node extensions/vscode/scripts/test.mjs",
53 "test:vscode:ui": "npm run build:vscode && node tests/e2e/vscode.mjs"
55+ "test:vscode:ui": "npm run build:vscode && node tests/e2e/vscode.mjs",
56+ "publish:vscode": "vsce publish --packagePath .artifacts/whymark-vscode.vsix"
5457 },
5558 "dependencies": {
5659 "yaml": "^2.8.1"

specs/002-vscode-extension/PLAN.md

+10 0read-only10%
intentunspecifiedconfidence unknown
@@ -41,3 +41,13 @@Review paths and webview messages are untrusted. Gate edits and Git on workspace
4141  
4242 ## Open questions
4343 None blocking. Initial extension targets desktop/remote filesystem workspaces; compare refresh is explicit and review source changes refresh automatically.
44+ 
45+## Distribution follow-up
46+ 
47+Bundle the tested VSIX plus identity/version/SHA-256 metadata in the npm tarball.
48+Add an explicit `whymark vscode install` command with a preview, launcher override,
49+isolated profile paths and optional Marketplace ID installation. Build the VSIX
50+in prepack and verify a real offline tarball consumer can install it into a clean
51+VS Code profile. Provide an explicit publish command for the already-tested VSIX
52+and a publisher/authentication guide. Publication and publisher provisioning are
53+outside this implementation request.

specs/002-vscode-extension/verification.md

+21 0read-only10%
intentunspecifiedconfidence unknown
@@ -61,3 +61,24 @@workspace host, branch/commit input dialogs and Save As dialog interaction were
6161 not exercised. API typing uses 1.96; installed-host testing uses 1.138. The save
6262 operation behind the dialog is tested with real VS Code APIs. The desktop/remote
6363 filesystem extension does not support browser-only virtual workspaces.
64+ 
65+## npm / Marketplace distribution follow-up
66+ 
67+The npm file allowlist now includes the built VSIX and identity/digest metadata;
68+`prepack` rebuilds them. `whymark vscode install` invokes the editor CLI with separate
69+arguments and checks the bundled digest before installation. `--dry-run` performs
70+no installation; `--marketplace` selects the extension ID for use after publication.
71+The explicit `publish:vscode` script publishes an already-built VSIX only when run.
72+ 
73+Verification: 106 tests / 15 files, typecheck and lint passed on Node 22. An actual
74+npm tarball and local yaml tarball were installed offline into a clean consumer;
75+that consumer's CLI installed the bundled VSIX into an isolated VS Code profile,
76+and VS Code listed `spink-dev.whymark@0.1.0`. The normal editor profile was unchanged.
77+Unit checks cover argument boundaries, paths with spaces/metacharacters, preview,
78+missing launchers, nonzero editor exits, Marketplace target selection and corrupt
79+bundled bytes. Windows launcher behavior is implemented but not runtime-tested.
80+ 
81+The distribution changes were inspected in separate source and behavior passes.
82+Existing staged changes from the extension implementation were preserved. Publisher
83+ownership, authenticated Marketplace publication and end-user Marketplace download
84+are not verified. Neither npm nor Marketplace was published.

src/cli/help.ts

+11 0read-only10%
intentunspecifiedconfidence unknown
@@ -7,6 +7,16 @@export type Palette = {
77 };
88  
99 const TOPICS: Record<string, { aliases: string[]; render: (c: Palette) => string }> = {
10+ vscode: { aliases: [], render: () => `npx whymark vscode install [--code <path>] [--dry-run] [--marketplace]
11+ 
12+Installs the VSIX bundled with this npm package using the VS Code CLI.
13+No GitHub access or web server is needed. Install VS Code first.
14+--code <path> selects a VS Code launcher when it is not on PATH.
15+--dry-run prints the exact command and arguments without changing VS Code.
16+--marketplace installs the published extension ID instead (requires publication).
17+--extensions-dir <path> and --user-data-dir <path> select isolated VS Code locations.
18+No postinstall hook runs, and existing extensions are not force-downgraded.
19+` },
1020 quality: { aliases: [], render: () => `npx whymark quality init
1121 npx whymark quality <review.whymark> --run [--write] [--baseline report.quality.json]
1222 npx whymark quality <review.whymark> --run --watch --write
@@ -82,6 +92,7 @@${c.bold("WORKFLOW")}
8292 5. open https://whymark.x47.dev drop the file; it stays in the browser
8393  
8494 ${c.bold("COMMANDS")}
95+ vscode install the VS Code extension bundled with npm
8596 quality configured local checks and ESLint findings
8697 skill install the bundled agent skill from npm
8798 new skeleton from a git diff ${c.gray("alias: init")}

src/cli/whymark.ts

+8 0read-only10%
intentunspecifiedconfidence unknown
@@ -11,5 +11,6 @@import { validateDocumentInRepo } from "../lib/whymark/validate-tree";
1111 import { summariseResults, verifyDocument, type ClaimResult } from "../lib/whymark/verify";
1212 import type { Scope } from "../lib/whymark/types";
1313 import { qualityCommand } from "./quality";
14+import { installExtension } from "../lib/vscode/install";
1415 import { installSkill } from "../lib/skill/install";
1516 import { renderHelp } from "./help";
@@ -118,6 +119,13 @@function main() {
118119 return cmdHelp(isHelpToken(args.command) ? args.positionals[0] : args.command);
119120 }
120121 switch (args.command) {
122+ case "vscode": {
123+ if (args.positionals.length !== 1 || args.positionals[0] !== "install") fail("Usage: npx whymark vscode install [--code <path>] [--dry-run] [--marketplace]");
124+ for (const name of ["code", "extensions-dir", "user-data-dir"]) if (args.has(name) && !args.str(name)) fail(`--${name} requires a value.`);
125+ try { installExtension({ packageRoot: packageRoot(), code: args.str("code"), dryRun: args.has("dry-run"), marketplace: args.has("marketplace"), extensionsDir: args.str("extensions-dir"), userDataDir: args.str("user-data-dir") }); }
126+ catch (error) { fail((error as Error).message); }
127+ return;
128+ }
121129 case "quality":
122130 return qualityCommand(args.positionals, { run: args.has("run"), watch: args.has("watch"), write: args.has("write"), config: args.str("config"), baseline: args.str("baseline"), importPath: args.str("import"), toolVersion: args.str("tool-version") }).catch(error => fail((error as Error).message));
123131 case "skill": {

tests/package.test.ts

+6 1read-only10%
testunspecifiedconfidence unknown
@@ -1,5 +1,5 @@
11 import { spawnSync } from "node:child_process";
2import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+import { existsSync, realpathSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join, resolve } from "node:path";
55 import { expect, it } from "vitest";
@@ -14,12 +14,17 @@it("installs the packed skill with only npm tarballs and no Git access", () => {
1414 try {
1515 const packed = JSON.parse(run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp]))[0];
1616 expect(packed.files.map((f: { path: string }) => f.path)).toContain(".agents/skills/whymark/SKILL.md");
17+ expect(packed.files.map((f: { path: string }) => f.path)).toContain("dist/whymark-vscode.vsix");
18+ expect(packed.files.map((f: { path: string }) => f.path)).toContain("dist/vscode-extension.json");
1719 const yaml = JSON.parse(run("npm", ["pack", "./node_modules/yaml", "--ignore-scripts", "--json", "--pack-destination", temp]))[0];
1820 const consumer = join(temp, "consumer"); mkdirSync(consumer);
1921 writeFileSync(join(consumer, "package.json"), '{"private":true}');
2022 run("npm", ["install", "--offline", "--ignore-scripts", "--no-audit", "--no-fund", join(temp, packed.filename), join(temp, yaml.filename)], consumer);
2123 const cli = join(consumer, "node_modules/whymark/bin/whymark.mjs");
2224 run(process.execPath, [cli, "skill", "install", "--agent", "codex", "--agent", "claude-code"], consumer);
25+ const preview = JSON.parse(run(process.execPath, [cli, "vscode", "install", "--dry-run", "--code", process.execPath], consumer));
26+ expect(preview.source).toBe("bundled");
27+ expect(preview.args[1]).toBe(realpathSync(join(consumer, "node_modules/whymark/dist/whymark-vscode.vsix")));
2328 const skill = join(consumer, ".agents/skills/whymark");
2429 expect(existsSync(join(skill, "references/whymark-v1.md"))).toBe(true);
2530 expect(readFileSync(join(skill, "SKILL.md"), "utf8")).toContain("references/whymark-v1.md");

extensions/vscode/PUBLISHING.md

+70 0read-only10%
intentunspecifiedconfidence unknown
@@ -0,0 +1,70 @@
1+# Distribution and release
2+ 
3+Two independent channels are supported. The npm package carries a VSIX built during
4+`prepack`; its explicit `whymark vscode install` command checks the packaged digest
5+and invokes the installed VS Code CLI. The digest detects corruption; it is not a
6+publisher signature. No postinstall hook installs extensions.
7+ 
8+The extension identity is `spink-dev.whymark`, as declared in the extension manifest.
9+Ownership/availability of that publisher must be confirmed before publishing. A
10+private GitHub repository can remain private: consumers install the npm tarball or
11+Marketplace artifact without cloning it. Both public releases expose their packaged
12+code and documentation, even when the source repository stays private.
13+ 
14+## Build and check locally
15+ 
16+Use Node 22 and run from the repository root:
17+ 
18+```sh
19+npm ci
20+npm test
21+npm run typecheck
22+npm run lint
23+npm run package:vscode
24+WHYMARK_VSIX=.artifacts/whymark-vscode.vsix npm run test:vscode
25+npm pack
26+```
27+ 
28+`package:vscode` builds the extension and creates the same VSIX in
29+`.artifacts/whymark-vscode.vsix` and `dist/whymark-vscode.vsix`. It also writes
30+`dist/vscode-extension.json` with identity, version and digest. The npm file allowlist
31+includes both dist files; `prepack` regenerates them so fresh checkouts can publish.
32+Check the tarball before releasing. Version the npm CLI and extension independently;
33+bump `extensions/vscode/package.json` for each Marketplace release, and the root
34+package version for npm releases. Do not reuse an already-published version.
35+ 
36+## Marketplace setup (one-time)
37+ 
38+1. Sign in to the [publisher management page](https://marketplace.visualstudio.com/manage).
39+2. Create or obtain access to the publisher ID in the manifest. If `spink-dev` is not
40+ yours, change the manifest publisher before rebuilding and testing both artifacts.
41+3. Configure publishing authentication. Current VS Code guidance recommends Microsoft
42+ Entra ID with workload identity federation for automation. Authorized publishers
43+ can also upload the tested VSIX through the management page.
44+ 
45+See the [official publishing guide](https://code.visualstudio.com/api/working-with-extensions/publishing-extension).
46+It states that global Azure DevOps PATs retire on December 1, 2026; do not establish
47+a new long-lived PAT-based automation pipeline. No credentials belong in this repo
48+or npm package.
49+ 
50+## Explicit publication
51+ 
52+Only after release approval, publish the tested VSIX without rebuilding it:
53+ 
54+```sh
55+npm run publish:vscode -- --azure-credential
56+```
57+ 
58+This delegates to `vsce publish --packagePath .artifacts/whymark-vscode.vsix` and
59+requires configured publisher access. Alternatively upload that VSIX in publisher
60+management. This repository does not provision publisher accounts or identities.
61+ 
62+Publish the npm package separately with `npm publish` after reviewing its packed
63+contents; its prepack lifecycle rebuilds the bundled extension. Neither publication
64+runs as part of normal builds, tests, packing, or installation. No release was made
65+while implementing these commands.
66+ 
67+After Marketplace publication users can install from the editor UI, run
68+`code --install-extension spink-dev.whymark`, or use
69+`npx whymark vscode install --marketplace`. Marketplace and npm have independent
70+release timing; bundled installation always uses the artifact shipped with npm.

extensions/vscode/scripts/package.mjs

+14 0read-only10%
intentunspecifiedconfidence unknown
@@ -0,0 +1,14 @@
1+import { createVSIX } from '@vscode/vsce';
2+import { createHash } from 'node:crypto';
3+import { copyFile, readFile, writeFile, mkdir } from 'node:fs/promises';
4+import { dirname, resolve } from 'node:path';
5+import { fileURLToPath } from 'node:url';
6+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
7+await mkdir(resolve(root, '.artifacts'), { recursive: true });
8+await mkdir(resolve(root, 'dist'), { recursive: true });
9+const target = resolve(root, '.artifacts/whymark-vscode.vsix');
10+await createVSIX({ cwd: resolve(root, 'extensions/vscode'), packagePath: target, dependencies: false });
11+await copyFile(target, resolve(root, 'dist/whymark-vscode.vsix'));
12+const manifest = JSON.parse(await readFile(resolve(root, 'extensions/vscode/package.json'), 'utf8'));
13+const sha256 = createHash('sha256').update(await readFile(target)).digest('hex');
14+await writeFile(resolve(root, 'dist/vscode-extension.json'), JSON.stringify({ id: `${manifest.publisher}.${manifest.name}`, version: manifest.version, sha256 }, null, 2) + '\n');

src/lib/vscode/install.ts

+58 0read-only10%
intentunspecifiedconfidence unknown
@@ -0,0 +1,58 @@
1+import { spawnSync } from "node:child_process";
2+import { accessSync, constants, existsSync, readFileSync } from "node:fs";
3+import { createHash } from "node:crypto";
4+import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
5+import { homedir } from "node:os";
6+ 
7+export interface InstallExtensionOptions {
8+ packageRoot: string;
9+ code?: string;
10+ dryRun?: boolean;
11+ marketplace?: boolean;
12+ extensionsDir?: string;
13+ userDataDir?: string;
14+}
15+ 
16+function executable(candidate: string): boolean {
17+ try { accessSync(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK); return true; } catch { return false; }
18+}
19+ 
20+export function codeLauncher(explicit?: string): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
21+ const names = explicit ? [explicit] : process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
22+ const candidates = names.flatMap(name => isAbsolute(name) || /[\\/]/.test(name) ? [resolve(name)] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map(dir => join(dir, name)));
23+ if (!explicit && process.platform === "darwin") candidates.push("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", join(homedir(), "Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"));
24+ if (!explicit && process.platform === "win32") for (const root of [process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs"), process.env.ProgramFiles]) if (root) candidates.push(join(root, "Microsoft VS Code", "Code.exe"));
25+ const command = candidates.find(executable);
26+ if (!command) throw new Error("VS Code CLI not found. Install VS Code and add 'code' to PATH, or pass --code <path-to-code>.");
27+ // Windows batch launchers need a shell. Invoke their native CLI directly instead,
28+ // preserving paths as arguments rather than interpolating them into cmd.exe.
29+ if (process.platform === "win32") {
30+ const root = /\.(cmd|bat)$/i.test(command) ? resolve(dirname(command), "..") : dirname(command);
31+ const native = join(root, "Code.exe");
32+ const cli = join(root, "resources/app/out/cli.js");
33+ if (!existsSync(native) || !existsSync(cli)) throw new Error("Use --code with the standard VS Code Code.exe or bin/code.cmd installation path.");
34+ return { command: native, args: [cli], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } };
35+ }
36+ return { command, args: [], env: process.env };
37+}
38+ 
39+export function installExtension(options: InstallExtensionOptions): void {
40+ const metadataPath = join(options.packageRoot, "dist/vscode-extension.json");
41+ if (!existsSync(metadataPath)) throw new Error("Extension bundle missing. In a source checkout run npm run package:vscode; otherwise reinstall the whymark npm package.");
42+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
43+ if (!/^[a-z0-9-]+\.[a-z0-9-]+$/i.test(metadata.id) || !/^[a-f0-9]{64}$/.test(metadata.sha256)) throw new Error("Invalid bundled extension metadata. Reinstall the whymark npm package.");
44+ const vsix = join(options.packageRoot, "dist/whymark-vscode.vsix");
45+ if (!options.marketplace && createHash("sha256").update(readFileSync(vsix)).digest("hex") !== metadata.sha256) throw new Error("Bundled VSIX checksum mismatch. Reinstall the whymark npm package.");
46+ const launcher = codeLauncher(options.code);
47+ const args = [...launcher.args, "--install-extension", options.marketplace ? metadata.id : vsix];
48+ if (options.extensionsDir) args.push("--extensions-dir", resolve(options.extensionsDir));
49+ if (options.userDataDir) args.push("--user-data-dir", resolve(options.userDataDir));
50+ if (options.dryRun) {
51+ process.stdout.write(`${JSON.stringify({ command: launcher.command, args, source: options.marketplace ? "marketplace" : "bundled", extension: metadata.id, bundledVersion: metadata.version }, null, 2)}\n`);
52+ return;
53+ }
54+ const result = spawnSync(launcher.command, args, { stdio: "inherit", env: launcher.env, shell: false, timeout: 120000 });
55+ if (result.error) throw new Error(`VS Code installation could not complete: ${result.error.message}`);
56+ if (result.status !== 0) throw new Error(`VS Code installation failed (${result.signal ?? result.status}).${options.marketplace ? " The extension must be published before Marketplace installation works." : ""}`);
57+ process.stdout.write(`Installed ${metadata.id}. Open the Command Palette and search for Whymark.\n`);
58+}

tests/vscode-install.test.ts

+32 0read-only10%
testunspecifiedconfidence unknown
@@ -0,0 +1,32 @@
1+import { expect, it } from "vitest";
2+import { createHash } from "node:crypto";
3+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
4+import { join } from "node:path";
5+import { tmpdir } from "node:os";
6+import { installExtension } from "../src/lib/vscode/install";
7+ 
8+it.skipIf(process.platform === "win32")("installs exact packaged bytes using safe arguments and propagates failures", () => {
9+ const root = mkdtempSync(join(tmpdir(), "whymark installer "));
10+ try {
11+ mkdirSync(join(root, "dist"));
12+ const payload = Buffer.from("fixture VSIX");
13+ writeFileSync(join(root, "dist/whymark-vscode.vsix"), payload);
14+ writeFileSync(join(root, "dist/vscode-extension.json"), JSON.stringify({ id: "spink-dev.whymark", version: "0.1.0", sha256: createHash("sha256").update(payload).digest("hex") }));
15+ const code = join(root, "code launcher");
16+ const log = join(root, "arguments.json");
17+ writeFileSync(code, `#!/usr/bin/env node\nrequire('node:fs').writeFileSync(${JSON.stringify(log)},JSON.stringify(process.argv.slice(2)));\n`, { mode: 0o755 });
18+ const destination = join(root, "extensions ; literal");
19+ installExtension({ packageRoot: root, code, extensionsDir: destination });
20+ expect(JSON.parse(readFileSync(log, "utf8"))).toEqual(["--install-extension", join(root, "dist/whymark-vscode.vsix"), "--extensions-dir", destination]);
21+ installExtension({ packageRoot: root, code, marketplace: true });
22+ expect(JSON.parse(readFileSync(log, "utf8"))).toEqual(["--install-extension", "spink-dev.whymark"]);
23+ writeFileSync(log, "unchanged");
24+ installExtension({ packageRoot: root, code, dryRun: true });
25+ expect(readFileSync(log, "utf8")).toBe("unchanged");
26+ expect(() => installExtension({ packageRoot: root, code: join(root, "missing") })).toThrow("CLI not found");
27+ writeFileSync(code, "#!/usr/bin/env node\nprocess.exit(7);\n", { mode: 0o755 });
28+ expect(() => installExtension({ packageRoot: root, code })).toThrow("failed (7)");
29+ writeFileSync(join(root, "dist/whymark-vscode.vsix"), "corrupt");
30+ expect(() => installExtension({ packageRoot: root, code })).toThrow("checksum mismatch");
31+ } finally { rmSync(root, { recursive: true, force: true }); }
32+});