svcloud 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svcloud",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "The SV Cloud CLI. Alpha: login, logout, status, open, projects list, mcp, init, and runs are built; see PLANNING.md for what's still missing.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `svcloud promote <app>` — put the commit staging is running onto the live app.
3
+ *
4
+ * All this does is create a `prod-*` tag and push it. The promotion itself is
5
+ * GitHub Actions calling SV Cloud, exactly as it would if somebody tagged by
6
+ * hand — this is a convenience over `git tag`, not a second way in, for the
7
+ * same reason `svcloud deploy` pushes a branch rather than uploading a build.
8
+ *
9
+ * A NEW TAG EVERY TIME, never a moving `prod`. A moving tag needs a force-push
10
+ * and makes "which commit is live" a question nobody can answer; the server
11
+ * refuses to point one at a second commit. The date-plus-counter name is picked
12
+ * here so nobody has to think about it.
13
+ */
14
+ import type { ProjectSummary } from "@sv/cloud-contracts";
15
+ import { findProjectBySlug } from "../lib/find-project";
16
+ import { existingPromotionTags, gitWorkingState, headSha, pushTag } from "../lib/git";
17
+ import { die, printJson } from "../lib/output";
18
+
19
+ const USAGE = `Usage: svcloud promote <app> [--commit <sha>] [--tag <name>]
20
+
21
+ Tags a commit and pushes it, which promotes it to your live app.
22
+ With no --commit, promotes the commit currently checked out.`;
23
+
24
+ export async function promoteCommand(argv: string[], json: boolean): Promise<void> {
25
+ const slug = argv.find((a) => !a.startsWith("--"));
26
+ if (!slug) die(USAGE);
27
+
28
+ const project = await findProjectBySlug(slug as string);
29
+ if (!project) die(`No app named "${slug}".`);
30
+ assertStaged(project);
31
+
32
+ const explicitSha = flagValue(argv, "--commit");
33
+ const sha = explicitSha ?? (await headSha());
34
+ if (!sha) {
35
+ die("Could not work out which commit to promote. Run this inside the app's git repository, or pass --commit <sha>.");
36
+ }
37
+
38
+ // A dirty tree is not fatal here the way it is for `deploy` — the tag names a
39
+ // commit, and uncommitted work simply is not in it — but somebody who thinks
40
+ // their latest edit is going live should hear otherwise before it does not.
41
+ const state = await gitWorkingState();
42
+ if (state?.dirty && !explicitSha) {
43
+ console.warn("You have uncommitted changes. They are NOT part of this promotion.");
44
+ }
45
+
46
+ const tag = flagValue(argv, "--tag") ?? (await nextTagName());
47
+
48
+ await pushTag(tag, sha as string);
49
+
50
+ if (json) {
51
+ return printJson({ tag, commit_sha: sha, project: project.slug });
52
+ }
53
+ console.log(`Tagged ${(sha as string).slice(0, 7)} as ${tag} and pushed it.`);
54
+ console.log("");
55
+ console.log("GitHub Actions is promoting it now. Watch it in your repository's Actions tab;");
56
+ console.log(`your live app is at ${project.web_address ?? "its web address"}.`);
57
+ }
58
+
59
+ function assertStaged(project: ProjectSummary): void {
60
+ if (project.deploy_mode === "staged") return;
61
+ die(
62
+ `"${project.slug}" does not use a staging environment, so there is nothing to promote — ` +
63
+ "pushing to its default branch already updates it.\n" +
64
+ "To start using staging: svcloud staging create <app>",
65
+ );
66
+ }
67
+
68
+ /**
69
+ * `prod-<today>.<n>`, where n is the next free counter for today. Reads the
70
+ * repo's existing tags rather than assuming, so re-promoting twice in one day
71
+ * does not collide with a tag the server would refuse to reuse.
72
+ */
73
+ async function nextTagName(): Promise<string> {
74
+ const today = new Date().toISOString().slice(0, 10);
75
+ const prefix = `prod-${today}.`;
76
+ const used = new Set(await existingPromotionTags());
77
+ let n = 1;
78
+ while (used.has(`${prefix}${n}`)) n++;
79
+ return `${prefix}${n}`;
80
+ }
81
+
82
+ function flagValue(argv: string[], flag: string): string | undefined {
83
+ const i = argv.indexOf(flag);
84
+ return i === -1 ? undefined : argv[i + 1];
85
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `svcloud staging create|status|on|off <app>` — an app's staging environment.
3
+ *
4
+ * The rules all live on the server (apps/cloud's services/staging.ts), for the
5
+ * same reason `bundle` keeps its merge rules there and the MCP bridge holds no
6
+ * tool registry: a rule enforced in whatever CLI version somebody happens to
7
+ * have installed is a rule that drifts.
8
+ *
9
+ * What this file owes the person running it is the consequence, said plainly.
10
+ * `on` is the moment pushes to their default branch stop reaching their live
11
+ * app, and somebody who does not realise that discovers it when a customer asks
12
+ * where the fix went.
13
+ */
14
+ import type { ProjectDetail } from "@sv/cloud-contracts";
15
+ import { apiFetch } from "../lib/api";
16
+ import { findProjectBySlug } from "../lib/find-project";
17
+ import { die, printJson } from "../lib/output";
18
+
19
+ const USAGE = `Usage:
20
+ svcloud staging create <app> Give an app a staging environment
21
+ svcloud staging status <app> Show whether it is ready to switch over
22
+ svcloud staging on <app> Deploy the default branch to staging from now on
23
+ svcloud staging off <app> Go back to deploying the live app from the default branch`;
24
+
25
+ interface Preflight {
26
+ staging_ready: boolean;
27
+ workflow_ok: boolean;
28
+ workflow_problem: string | null;
29
+ missing_secret_names: string[];
30
+ deploy_mode: "direct" | "staged";
31
+ }
32
+
33
+ export async function stagingCommand(argv: string[], json: boolean): Promise<void> {
34
+ const [subcommand, slug] = argv;
35
+ if (!subcommand || !slug) die(USAGE);
36
+
37
+ const project = await findProjectBySlug(slug as string);
38
+ if (!project) die(`No app named "${slug}".`);
39
+
40
+ switch (subcommand) {
41
+ case "create":
42
+ return create(project.id, json);
43
+ case "status":
44
+ return status(project.id, json);
45
+ case "on":
46
+ return on(project.id, argv.includes("--force"), json);
47
+ case "off":
48
+ return off(project.id, json);
49
+ default:
50
+ die(`Unknown subcommand: ${subcommand}\n\n${USAGE}`);
51
+ }
52
+ }
53
+
54
+ async function create(projectId: string, json: boolean): Promise<void> {
55
+ const result = await apiFetch<{
56
+ staging?: ProjectDetail;
57
+ already_exists?: boolean;
58
+ run_id?: string;
59
+ }>(`/api/v1/projects/${projectId}/staging`, { method: "POST" });
60
+
61
+ if (json) return printJson(result);
62
+
63
+ if (result.already_exists) {
64
+ console.log("This app already has a staging environment.");
65
+ console.log(` ${result.staging?.web_address ?? ""}`);
66
+ return;
67
+ }
68
+ console.log("Setting up a staging environment.");
69
+ console.log(` ${result.staging?.web_address ?? ""}`);
70
+ console.log("");
71
+ // Said before they ask: creating it costs money and changes nothing yet.
72
+ console.log("This is a second copy of your app, with its own database and files.");
73
+ console.log("Its usage comes out of the same plan allowance as your live app.");
74
+ console.log("");
75
+ console.log("Nothing has changed about where your pushes go yet.");
76
+ console.log("Run `svcloud staging status <app>` to see what is left before switching over.");
77
+ }
78
+
79
+ async function status(projectId: string, json: boolean): Promise<void> {
80
+ const result = await apiFetch<Preflight>(`/api/v1/projects/${projectId}/staging/preflight`);
81
+ if (json) return printJson(result);
82
+
83
+ if (result.deploy_mode === "staged") {
84
+ console.log("Pushes to your default branch deploy staging.");
85
+ console.log("Your live app changes when you push a prod-* tag.");
86
+ return;
87
+ }
88
+
89
+ console.log("Pushes to your default branch deploy your live app.");
90
+ console.log("");
91
+ console.log(` staging environment ${result.staging_ready ? "ready" : "not created yet"}`);
92
+ console.log(
93
+ ` deploy workflow ${result.workflow_ok ? "ready" : (result.workflow_problem ?? "not ready")}`,
94
+ );
95
+ if (result.missing_secret_names.length > 0) {
96
+ console.log(` settings ${result.missing_secret_names.length} missing on staging:`);
97
+ for (const name of result.missing_secret_names) console.log(` ${name}`);
98
+ console.log("");
99
+ // The likeliest reason a first staging build fails, and nothing can copy
100
+ // them across: SV Cloud never sees a setting's value after it is set.
101
+ console.log("Add these with `svcloud secrets set` before switching over, or the");
102
+ console.log("first staging build is likely to fail.");
103
+ } else {
104
+ console.log(" settings ready");
105
+ }
106
+ }
107
+
108
+ async function on(projectId: string, force: boolean, json: boolean): Promise<void> {
109
+ const result = await apiFetch<{ project: ProjectDetail; note: string }>(
110
+ `/api/v1/projects/${projectId}/staging/enable${force ? "?force=1" : ""}`,
111
+ { method: "POST" },
112
+ );
113
+ if (json) return printJson(result);
114
+ console.log(result.note);
115
+ console.log("");
116
+ console.log("To go back at any time: svcloud staging off <app>");
117
+ }
118
+
119
+ async function off(projectId: string, json: boolean): Promise<void> {
120
+ const result = await apiFetch<{ project: ProjectDetail; note: string }>(
121
+ `/api/v1/projects/${projectId}/staging/disable`,
122
+ { method: "POST" },
123
+ );
124
+ if (json) return printJson(result);
125
+ console.log(result.note);
126
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ import { projectsCommand } from "./commands/projects";
17
17
  import { runsCommand } from "./commands/runs";
18
18
  import { secretsCommand } from "./commands/secrets";
19
19
  import { storageCommand } from "./commands/storage";
20
+ import { promoteCommand } from "./commands/promote";
21
+ import { stagingCommand } from "./commands/staging";
20
22
  import { statusCommand } from "./commands/status";
21
23
  import { whoamiCommand } from "./commands/whoami";
22
24
  import { AuthRequiredError, ApiError } from "./lib/api";
@@ -38,6 +40,8 @@ Usage:
38
40
  svcloud storage presign Get a direct URL for one file in an app's storage
39
41
  svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
40
42
  svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
43
+ svcloud staging <cmd> An app's staging environment (create/status/on/off)
44
+ svcloud promote <app> Tag the current commit and put it on the live app
41
45
  svcloud bundle <cmd> Keep an app's starter current (status/update/done)
42
46
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
43
47
  svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
@@ -109,6 +113,12 @@ async function main(): Promise<void> {
109
113
  case "deploy":
110
114
  await deployCommand(rest, json);
111
115
  return;
116
+ case "staging":
117
+ await stagingCommand(rest, json);
118
+ return;
119
+ case "promote":
120
+ await promoteCommand(rest, json);
121
+ return;
112
122
  case "version":
113
123
  case "-v":
114
124
  case "--version":
@@ -7,15 +7,49 @@
7
7
  */
8
8
  import { spawn } from "node:child_process";
9
9
 
10
+ export interface LaunchPlan {
11
+ command: string;
12
+ args: string[];
13
+ /** Windows only: pass the args through to cmd.exe exactly as written. */
14
+ verbatim: boolean;
15
+ }
16
+
17
+ /**
18
+ * Split out from `openBrowser` so the Windows quoting can be tested without
19
+ * spawning anything. The Windows branch is the whole reason this exists:
20
+ * cmd.exe parses the command line before `start` ever sees it, and `&` is a
21
+ * command separator there, so an unquoted OAuth URL is truncated at its first
22
+ * parameter boundary. The browser then opens `...?response_type=code` with
23
+ * nothing after it and `GET /oauth/authorize` answers `Query parameter
24
+ * "client_id" is required.` Quoting the URL stops the split, and
25
+ * `windowsVerbatimArguments` stops Node from re-quoting the quotes we just
26
+ * added (its own escaping targets the MSVCRT argument parser, which runs
27
+ * after cmd's, and so never escapes cmd metacharacters). The bare `""` is
28
+ * `start`'s optional window-title argument: without it, `start` takes the
29
+ * quoted URL for the title and opens nothing.
30
+ */
31
+ export function planBrowserLaunch(platform: NodeJS.Platform, url: string): LaunchPlan {
32
+ if (platform === "darwin") {
33
+ return { command: "open", args: [url], verbatim: false };
34
+ }
35
+ if (platform === "win32") {
36
+ return {
37
+ command: "cmd.exe",
38
+ args: ["/c", "start", '""', `"${url}"`],
39
+ verbatim: true,
40
+ };
41
+ }
42
+ return { command: "xdg-open", args: [url], verbatim: false };
43
+ }
44
+
10
45
  export function openBrowser(url: string): void {
11
46
  try {
12
- if (process.platform === "darwin") {
13
- spawn("open", [url], { stdio: "ignore", detached: true }).unref();
14
- } else if (process.platform === "win32") {
15
- spawn("cmd", ["/c", "start", '""', url], { stdio: "ignore", detached: true }).unref();
16
- } else {
17
- spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
18
- }
47
+ const plan = planBrowserLaunch(process.platform, url);
48
+ spawn(plan.command, plan.args, {
49
+ stdio: "ignore",
50
+ detached: true,
51
+ windowsVerbatimArguments: plan.verbatim,
52
+ }).unref();
19
53
  } catch {
20
54
  // The printed URL is the fallback; nothing else to do here.
21
55
  }
@@ -145,15 +145,44 @@ async function linuxClear(): Promise<void> {
145
145
  * Windows Credential Manager has no CLI that can both write and read a
146
146
  * secret back out (`cmdkey` is write-only), so this goes through
147
147
  * PowerShell's P/Invoke of advapi32's CredWrite/CredRead/CredDelete
148
- * directly — untested on Windows (this CLI was built and verified on
149
- * macOS); any failure here falls back to the plaintext file below rather
150
- * than surfacing a build-only error to a novice.
148
+ * directly. Verified on Windows 11 / PowerShell 5.1 on 2026-09-18, after
149
+ * it had never once worked: two defects, and the second is why the first
150
+ * was invisible for so long.
151
+ *
152
+ * 1. The prologue passed `-UsingNamespace System.Runtime.InteropServices`.
153
+ * `Add-Type -MemberDefinition` ALREADY emits that using directive, so
154
+ * this produced a second one — CS0105 — and Add-Type compiles
155
+ * warnings-as-errors, so the type never came into existence and every
156
+ * `[SvCloud.Cred]::...` call below failed with "Unable to find type".
157
+ * Do not re-add it; the directive is implicit.
158
+ *
159
+ * 2. Neither failure set a non-zero exit code. Add-Type's compile error and
160
+ * the subsequent "type not found" are both NON-TERMINATING errors, and
161
+ * `powershell -Command` exits 0 after them, so `execFile` saw success:
162
+ * `platformSave` returned true, the plaintext fallback never fired, and
163
+ * the token went nowhere. `svcloud login` printed no error and every
164
+ * later command said "Not signed in" — which reads as a server bug.
165
+ * Hence `$ErrorActionPreference` plus the `trap`: any terminating error
166
+ * now exits 1, which is the only thing `run()` can actually detect.
167
+ *
168
+ * `platformSave` additionally reads back what it wrote (see there), so a
169
+ * store that accepts a write it cannot return is treated as no store at
170
+ * all, on every platform rather than just this one.
151
171
  */
152
172
  function psTarget(): string {
153
173
  return `${CREDENTIAL_SERVICE}/${credentialAccount()}`;
154
174
  }
155
175
 
156
- const PS_PROLOGUE = `
176
+ /**
177
+ * Exported only so `test/windows-credentials.test.ts` can pin the two
178
+ * defects above from any platform — the same reason `planBrowserLaunch` is
179
+ * split out of `openBrowser`. Neither is reproducible on the machines this
180
+ * CLI is developed on, and both failed silently, so the shape is worth
181
+ * asserting directly rather than trusting a reviewer to notice.
182
+ */
183
+ export const PS_PROLOGUE = `
184
+ $ErrorActionPreference = "Stop"
185
+ trap { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }
157
186
  Add-Type -Namespace SvCloud -Name Cred -MemberDefinition @'
158
187
  [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
159
188
  public static extern bool CredWrite(ref CREDENTIAL credential, uint flags);
@@ -170,7 +199,7 @@ public struct CREDENTIAL {
170
199
  public uint Persist; public uint AttributeCount; public IntPtr Attributes;
171
200
  public string TargetAlias; public string UserName;
172
201
  }
173
- '@ -UsingNamespace System.Runtime.InteropServices
202
+ '@
174
203
  `;
175
204
 
176
205
  async function windowsSave(json: string): Promise<void> {
@@ -232,16 +261,30 @@ async function fallbackClear(): Promise<void> {
232
261
  await rm(fallbackFile(), { force: true });
233
262
  }
234
263
 
264
+ /**
265
+ * Returns true only if the keychain both accepted the write AND hands the
266
+ * same bytes back. The read-back is the point: a store that swallows a write
267
+ * and then returns nothing is WORSE than an absent one, because `saveTokens`
268
+ * treats it as done and the plaintext fallback never runs — so `svcloud
269
+ * login` reports success, stores nothing anywhere, and every later command
270
+ * says "Not signed in" with no error to explain it. That is exactly how the
271
+ * Windows path failed from its introduction until 2026-09-18 (see the
272
+ * Windows section's header). Verifying here rather than in `windowsSave`
273
+ * covers all three platforms, since nothing about the trap is specific to
274
+ * this one: a locked macOS Keychain or a container with no Secret Service
275
+ * would land the same way. A mismatch degrades to the documented plaintext
276
+ * file with its warning, which is a working sign-in.
277
+ */
235
278
  async function platformSave(json: string): Promise<boolean> {
236
279
  try {
237
280
  if (process.platform === "darwin") await macosSave(json);
238
281
  else if (process.platform === "linux") await linuxSave(json);
239
282
  else if (process.platform === "win32") await windowsSave(json);
240
283
  else return false;
241
- return true;
242
284
  } catch {
243
285
  return false;
244
286
  }
287
+ return (await platformLoad()) === json;
245
288
  }
246
289
 
247
290
  async function platformLoad(): Promise<string | undefined> {
package/src/lib/git.ts CHANGED
@@ -75,3 +75,36 @@ export async function gitWorkingState(cwd: string = process.cwd()): Promise<GitW
75
75
  export async function pushCurrentBranch(cwd: string = process.cwd()): Promise<void> {
76
76
  await run("git", ["push"], { cwd });
77
77
  }
78
+
79
+ /** The commit the current branch points at, for naming and confirming a promotion. */
80
+ export async function headSha(cwd: string = process.cwd()): Promise<string | undefined> {
81
+ try {
82
+ const { stdout } = await run("git", ["rev-parse", "HEAD"], { cwd });
83
+ return stdout.trim() || undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ /** Create an annotated tag and push it. Throws with git's own stderr on failure. */
90
+ export async function pushTag(
91
+ tag: string,
92
+ sha: string,
93
+ cwd: string = process.cwd(),
94
+ ): Promise<void> {
95
+ await run("git", ["tag", tag, sha], { cwd });
96
+ await run("git", ["push", "origin", tag], { cwd });
97
+ }
98
+
99
+ /** The promotion tags already on this repo, newest first, so a new one can avoid colliding. */
100
+ export async function existingPromotionTags(cwd: string = process.cwd()): Promise<string[]> {
101
+ try {
102
+ const { stdout } = await run("git", ["tag", "--list", "prod-*"], { cwd });
103
+ return stdout
104
+ .split("\n")
105
+ .map((t) => t.trim())
106
+ .filter(Boolean);
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
package/src/lib/self.ts CHANGED
@@ -18,6 +18,12 @@
18
18
  * used rather than the npm shim: the shim's exec bit and its `.cmd`
19
19
  * wrapper on Windows are two more things that can be wrong, and
20
20
  * `process.execPath` is neither.
21
+ *
22
+ * ON WINDOWS THE BARE NAME IS NEVER WRITTEN, whatever PATH says — see
23
+ * `planBridgeCommand`. That is a separate question from whether the
24
+ * OWNER's shell can reach `svcloud`, which is what `isOnPath` answers and
25
+ * what `mcp check` reports; the two were previously the same code path and
26
+ * are now deliberately not.
21
27
  */
22
28
  import { accessSync, constants } from "node:fs";
23
29
  import { delimiter, dirname, join } from "node:path";
@@ -29,23 +35,76 @@ export function ownBinPath(): string {
29
35
  return join(here, "..", "..", "bin", "svcloud.js");
30
36
  }
31
37
 
38
+ /** Windows' documented default when PATHEXT is unset. */
39
+ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
40
+
32
41
  /**
33
- * Whether a bare `svcloud` resolves to an executable on this PATH.
42
+ * The suffixes a bare name may resolve through on this platform.
43
+ *
44
+ * Windows has no execute BIT. `accessSync(..., X_OK)` is documented as
45
+ * having no effect there and degrades to a plain existence check, and
46
+ * `chmodSync` only toggles the read-only flag — so on Windows the suffix
47
+ * list is the ONLY thing carrying the meaning of "executable", and it has
48
+ * to come from PATHEXT rather than a hardcoded guess. (A real machine's
49
+ * PATHEXT is longer than the four anyone hardcodes: the one this was
50
+ * verified on carries twelve, `.JS` among them.)
51
+ *
52
+ * The extensionless file is deliberately NOT accepted on Windows, and that
53
+ * is the decision worth knowing here. `npm install -g svcloud` writes
54
+ * THREE shims — `svcloud`, `svcloud.cmd` and `svcloud.ps1` — and only the
55
+ * bare one is unrunnable by cmd.exe, PowerShell and CreateProcess alike.
56
+ * Git Bash would run it, which is the argument for keeping it, but every
57
+ * real global install has the `.cmd` beside it, so accepting the bare file
58
+ * changes the answer only when the `.cmd` is ABSENT — precisely the case
59
+ * where claiming "on PATH" would be wrong for two of the three shells a
60
+ * Windows owner might be in. Being wrong in that direction is the
61
+ * expensive one: a false yes leaves an owner believing a broken install is
62
+ * fine, while a false no costs a `pathHint` nobody is harmed by.
63
+ */
64
+ export function executableSuffixes(
65
+ platform: NodeJS.Platform,
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ ): string[] {
68
+ if (platform !== "win32") return [""];
69
+ const raw = env.PATHEXT ?? DEFAULT_PATHEXT;
70
+ const parsed = raw
71
+ .split(";")
72
+ .map((ext) => ext.trim())
73
+ .filter(Boolean);
74
+ // An empty or whitespace-only PATHEXT is a broken environment, not an
75
+ // instruction to accept every extensionless file on PATH.
76
+ return parsed.length > 0 ? parsed : DEFAULT_PATHEXT.split(";");
77
+ }
78
+
79
+ /**
80
+ * Whether a bare `svcloud` resolves to something the OWNER'S SHELL would
81
+ * run, on this PATH.
34
82
  *
35
83
  * Deliberately not `which`/`where`: spawning a shell to answer a question
36
84
  * about the environment we are already in is slower and less predictable
37
- * than reading PATH ourselves. On Windows a bare name is resolved against
38
- * PATHEXT, so the shim's real filename there is `svcloud.cmd`.
85
+ * than reading PATH ourselves.
86
+ *
87
+ * This answers a question about a shell, NOT about what a harness can
88
+ * spawn — those differ on Windows, and conflating them is what
89
+ * `planBridgeCommand` now keeps apart. `mcp check` reports this one to the
90
+ * owner; the config written to disk does not depend on it there.
91
+ *
92
+ * `name` is assumed to carry no extension of its own (its only caller
93
+ * passes the default): on Windows every candidate is `name` + a PATHEXT
94
+ * suffix, so passing `"svcloud.cmd"` would look for `svcloud.cmd.exe`.
39
95
  */
40
96
  export function isOnPath(name = "svcloud", env: NodeJS.ProcessEnv = process.env): boolean {
41
97
  const raw = env.PATH ?? env.Path ?? "";
42
98
  if (!raw) return false;
43
- const suffixes = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
99
+ // X_OK means something only where an execute bit exists; see
100
+ // `executableSuffixes` for why Windows checks mere existence instead.
101
+ const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK;
102
+ const suffixes = executableSuffixes(process.platform, env);
44
103
  for (const dir of raw.split(delimiter)) {
45
104
  if (!dir) continue;
46
105
  for (const suffix of suffixes) {
47
106
  try {
48
- accessSync(join(dir, `${name}${suffix}`), constants.X_OK);
107
+ accessSync(join(dir, `${name}${suffix}`), mode);
49
108
  return true;
50
109
  } catch {
51
110
  /* Not here; keep looking. */
@@ -62,10 +121,48 @@ export interface BridgeCommand {
62
121
  onPath: boolean;
63
122
  }
64
123
 
124
+ /**
125
+ * Split out of `bridgeCommand` so the Windows rule is testable from the
126
+ * platforms this CLI is actually developed on — the same reason
127
+ * `planBrowserLaunch` exists in `lib/browser.ts`.
128
+ *
129
+ * ON WINDOWS THE BARE NAME IS REFUSED EVEN WHEN IT IS ON PATH. A harness
130
+ * starts an MCP server by spawning a child process, and Node's default is
131
+ * `shell: false`. Measured on Windows 11 / Node 24.19 against the shims
132
+ * `npm install -g svcloud` really writes:
133
+ *
134
+ * spawn("svcloud", …) no shell → ENOENT — CreateProcess appends
135
+ * only `.exe`, and npm writes no
136
+ * `svcloud.exe`; PATHEXT is a
137
+ * SHELL's rule, not CreateProcess's
138
+ * spawn("svcloud.cmd", …) no shell → EINVAL — Node refuses to spawn
139
+ * .cmd/.bat without a shell at all
140
+ * (the CVE-2024-27980 mitigation)
141
+ * spawn(execPath, [binPath]) no shell → works
142
+ *
143
+ * So on Windows `isOnPath` being true says the owner's shell can reach
144
+ * `svcloud`, and says nothing about whether a harness can — writing the
145
+ * bare name there produces exactly the failure this file exists to
146
+ * prevent: a server that never starts, with no error the owner ever sees.
147
+ * The absolute form works with or without a shell, so Windows always gets
148
+ * it. `onPath` reports the form that was CHOSEN, not what PATH holds;
149
+ * `mcp check` calls `isOnPath` directly for the latter.
150
+ */
151
+ export function planBridgeCommand(
152
+ platform: NodeJS.Platform,
153
+ onPath: boolean,
154
+ execPath: string,
155
+ binPath: string,
156
+ ): BridgeCommand {
157
+ if (platform !== "win32" && onPath) {
158
+ return { command: "svcloud", args: ["mcp"], onPath: true };
159
+ }
160
+ return { command: execPath, args: [binPath, "mcp"], onPath: false };
161
+ }
162
+
65
163
  /** The `command` + `args` a harness config should use to run `svcloud mcp`. */
66
164
  export function bridgeCommand(): BridgeCommand {
67
- if (isOnPath()) return { command: "svcloud", args: ["mcp"], onPath: true };
68
- return { command: process.execPath, args: [ownBinPath(), "mcp"], onPath: false };
165
+ return planBridgeCommand(process.platform, isOnPath(), process.execPath, ownBinPath());
69
166
  }
70
167
 
71
168
  /**