svcloud 0.1.6 → 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.6",
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",
@@ -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/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
  /**