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 +1 -1
- package/src/lib/credentials.ts +49 -6
- package/src/lib/self.ts +104 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svcloud",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/lib/credentials.ts
CHANGED
|
@@ -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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
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
|
-
'@
|
|
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
|
-
*
|
|
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.
|
|
38
|
-
*
|
|
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
|
-
|
|
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}`),
|
|
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
|
-
|
|
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
|
/**
|