svcloud 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -8,17 +8,33 @@ the way an AI coding agent reaches the platform.
8
8
 
9
9
  ## Install
10
10
 
11
+ Installing `svcloud` is a required step of setting up an SV Cloud project, not
12
+ an optional extra: it is how you sign in, and it is the only way your coding
13
+ agent reaches the platform's tools.
14
+
11
15
  ```bash
12
16
  npm install -g svcloud
17
+ svcloud --version
13
18
  svcloud login
14
19
  ```
15
20
 
16
- Or try it once without installing:
21
+ The `--version` line is there to be run, not skipped. If it prints a version
22
+ you are done. If it prints `command not found`, the install worked but your
23
+ npm global bin directory is not on your shell's `PATH`:
17
24
 
18
25
  ```bash
19
- npx svcloud login
26
+ echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
20
27
  ```
21
28
 
29
+ Use `~/.bashrc` instead of `~/.zshrc` on most Linux shells. Then re-run
30
+ `svcloud --version`. `svcloud mcp check` reports the same thing, along with
31
+ everything else about your setup.
32
+
33
+ `npx svcloud <command>` works too, and is fine for trying one command. It is
34
+ the wrong thing to leave in place: every invocation re-resolves the package
35
+ from the npm registry, which makes the registry a startup dependency of your
36
+ coding agent's tools.
37
+
22
38
  An installed `svcloud` is what every example below uses, and is what a
23
39
  coding agent's harness config should point at too — see [Connect your coding
24
40
  agent](#connect-your-coding-agent).
@@ -41,7 +57,9 @@ svcloud init Connect a repo (defaults to the one in your workin
41
57
  svcloud runs <app> Show or watch an app's provisioning run
42
58
  svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
43
59
  svcloud db <cmd> Browse and edit an app's database (see `svcloud db` for subcommands)
60
+ svcloud storage presign Get a direct URL for one file in an app's file storage
44
61
  svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
62
+ svcloud bundle <cmd> Keep an app's starter current (status/update/done)
45
63
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
46
64
  svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
47
65
  svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
@@ -59,6 +77,33 @@ to skip what it would otherwise infer or ask about; `runs` reads `--id
59
77
 
60
78
  `logs` and `dev` are still on the way.
61
79
 
80
+ ### Keeping an app's starter current
81
+
82
+ SV Cloud's starter gets better over time, and an app that never catches up
83
+ keeps working but stops being able to do things the platform can now do. It can
84
+ also keep a deploy step that fails for reasons nothing in the repo explains.
85
+
86
+ ```bash
87
+ svcloud bundle status <app> # is this app behind?
88
+ svcloud bundle update <app> # stage the update for review
89
+ svcloud bundle done <app> # record it, after you commit
90
+ ```
91
+
92
+ `update` writes the new versions into `sv-cloud-bundle/` along with an
93
+ `UPDATE.md` explaining what to do with each file. It changes nothing in your
94
+ app's own source, and it does not commit or push — you read the diff, and
95
+ `git` stays the undo. Most people will hand this to their coding agent
96
+ instead: it can read the same plan through SV Cloud's tools, and it knows
97
+ which files it may replace outright and which ones it has to merge into work
98
+ you did.
99
+
100
+ `update` refuses to run with uncommitted changes, so that the diff it produces
101
+ means exactly one thing.
102
+
103
+ Run `svcloud bundle done` once the changes are committed. Skipping it does no
104
+ damage, but the app keeps reporting itself out of date and the next update
105
+ starts from the wrong place.
106
+
62
107
  ## Connect your coding agent
63
108
 
64
109
  `svcloud mcp` runs a local MCP server that your coding agent — Antigravity,
@@ -85,8 +130,18 @@ actually expects — the config blocks below are the same data, by hand, for a
85
130
  harness that isn't in the list yet, or if you'd rather see exactly what gets
86
131
  written first. If something still doesn't work after setup, run
87
132
  `svcloud mcp check` — it reports sign-in state, a live count of tools your
88
- token can see, and whether each known harness's config file exists and has
89
- the right shape.
133
+ token can see, whether `svcloud` is on your PATH, and whether each known
134
+ harness's config file exists and has the right shape.
135
+
136
+ **A note on `"command": "svcloud"` in the blocks below.** Your harness spawns
137
+ that command itself, with its own environment, so the bare name only works if
138
+ `svcloud` is on the PATH that harness inherits — and a harness that cannot
139
+ spawn it shows you no error at all, just an MCP server with no tools.
140
+ `svcloud mcp setup` checks first and writes an absolute path instead when the
141
+ bare name would not resolve, which is one more reason to let it write the
142
+ file rather than copying a block by hand. If you are writing one by hand and
143
+ `svcloud --version` does not work in a fresh terminal, fix your PATH first
144
+ (see [Install](#install)).
90
145
 
91
146
  ### Antigravity
92
147
  Add to `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json` (workspace):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svcloud",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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,224 @@
1
+ /**
2
+ * `svcloud bundle` — PLANNING.md §4 Step 8, apps/cloud F27. Brings a connected
3
+ * repo up to a newer starter template.
4
+ *
5
+ * The merge rules are the platform's, not the CLI's: `GET /bundle/plan`
6
+ * returns the base version's bytes, the new version's bytes, and a per-file
7
+ * action, and everything here is staging and presentation. That is deliberate,
8
+ * for the same reason the MCP bridge holds no tool registry — a rule that
9
+ * lives on the server cannot drift against the version of the CLI somebody
10
+ * happens to have installed.
11
+ *
12
+ * `update` writes into `sv-cloud-bundle/` and stops. It does not `git add`,
13
+ * commit, or push, and it never touches `src/`: the owner reads a diff, and
14
+ * git stays the undo. `done` is a separate command precisely because it has to
15
+ * run AFTER a human commits — folding it into `update` would mean recording
16
+ * that the update landed before it had.
17
+ */
18
+ import { mkdir, writeFile } from "node:fs/promises";
19
+ import { dirname, join } from "node:path";
20
+ import { apiFetch } from "../lib/api";
21
+ import { findProjectBySlug } from "../lib/find-project";
22
+ import { gitWorkingState } from "../lib/git";
23
+ import { die, printJson, printTable } from "../lib/output";
24
+
25
+ const STAGE_DIR = "sv-cloud-bundle";
26
+
27
+ interface BundleStatus {
28
+ project_id: string;
29
+ current_version: string | null;
30
+ latest_version: string;
31
+ update_available: boolean;
32
+ legacy: boolean;
33
+ base_unavailable_reason: string | null;
34
+ workflow_stale: boolean;
35
+ workflow_warning: string | null;
36
+ }
37
+
38
+ interface PlannedFile {
39
+ src: string;
40
+ path: string;
41
+ class: string;
42
+ action: string;
43
+ target?: string;
44
+ base?: string;
45
+ guidance: string;
46
+ retirement?: { src: string; replaced_by?: string | null; note: string };
47
+ }
48
+
49
+ interface BundlePlan extends BundleStatus {
50
+ framework: string;
51
+ migrate_once: boolean;
52
+ files: PlannedFile[];
53
+ migration_notes: string | null;
54
+ manifest_merge: unknown;
55
+ instructions: string;
56
+ }
57
+
58
+ /** Human-facing one-liners. The wire's `action` stays machine-readable. */
59
+ const ACTION_LABEL: Record<string, string> = {
60
+ skip: "unchanged",
61
+ "fast-forward": "replace",
62
+ conflict: "merge (you edited it)",
63
+ create: "new file",
64
+ retire: "delete",
65
+ "retire-conflict": "retired, you use it",
66
+ locate: "find it first",
67
+ "merge-manifest": "merge fields",
68
+ "report-only": "yours, unchanged",
69
+ };
70
+
71
+ async function resolve(slug: string | undefined): Promise<{ id: string; name: string }> {
72
+ if (!slug) die("Usage: svcloud bundle <status|update|done> <app>");
73
+ const project = await findProjectBySlug(slug);
74
+ if (!project) die(`No app named "${slug}".`);
75
+ return { id: project.id, name: project.name };
76
+ }
77
+
78
+ async function statusCmd(slug: string | undefined, json: boolean): Promise<void> {
79
+ const project = await resolve(slug);
80
+ const status = await apiFetch<BundleStatus>(`/api/v1/projects/${project.id}/bundle`);
81
+ if (json) {
82
+ printJson(status);
83
+ return;
84
+ }
85
+ console.log(`App: ${project.name}`);
86
+ console.log(`Starter: ${status.current_version ?? "not recorded"}`);
87
+ console.log(`Newest: ${status.latest_version}`);
88
+ if (status.workflow_warning) console.log(`\n! ${status.workflow_warning}`);
89
+ if (!status.update_available) {
90
+ console.log("\nUp to date.");
91
+ return;
92
+ }
93
+ if (status.legacy) {
94
+ console.log(
95
+ "\nThis app was set up before starter versions were recorded, so the first update " +
96
+ "has no starting point to compare against. Review every change it proposes.",
97
+ );
98
+ }
99
+ console.log("\nAn update is available. Run `svcloud bundle update " + (slug ?? "") + "`.");
100
+ }
101
+
102
+ async function updateCmd(slug: string | undefined, json: boolean): Promise<void> {
103
+ const project = await resolve(slug);
104
+
105
+ // Same preconditions as `deploy`, for the same reason: this writes files
106
+ // into the working tree, and a dirty tree makes the resulting diff
107
+ // impossible for the owner to read as "what the update did".
108
+ const state = await gitWorkingState();
109
+ if (!state) {
110
+ die("Run this from the app's local git repository (no repo found, or `git` isn't on PATH).");
111
+ }
112
+ if (state.dirty) {
113
+ die(
114
+ "Working tree has uncommitted changes. Commit or stash them first, so the update's own " +
115
+ "changes are the only thing in the diff.",
116
+ );
117
+ }
118
+
119
+ const plan = await apiFetch<BundlePlan>(`/api/v1/projects/${project.id}/bundle/plan`);
120
+ if (json) {
121
+ printJson(plan);
122
+ return;
123
+ }
124
+
125
+ if (!plan.update_available) {
126
+ console.log(`${project.name} is already on the newest starter (${plan.latest_version}).`);
127
+ return;
128
+ }
129
+
130
+ const actionable = plan.files.filter((f) => f.action !== "skip");
131
+ const cwd = process.cwd();
132
+ const written: string[] = [];
133
+
134
+ for (const f of actionable) {
135
+ if (f.target === undefined) continue;
136
+ const staged = join(cwd, STAGE_DIR, "files", f.src);
137
+ await mkdir(dirname(staged), { recursive: true });
138
+ await writeFile(staged, f.target, "utf8");
139
+ written.push(f.src);
140
+ }
141
+
142
+ const notes = [
143
+ `# Starter update: ${plan.current_version ?? "unrecorded"} to ${plan.latest_version}`,
144
+ "",
145
+ plan.instructions,
146
+ "",
147
+ plan.base_unavailable_reason ? `**Note.** ${plan.base_unavailable_reason}\n` : "",
148
+ "## Files",
149
+ "",
150
+ ...actionable.map((f) => {
151
+ const head = `### ${f.path} — ${ACTION_LABEL[f.action] ?? f.action}`;
152
+ const staged =
153
+ f.target === undefined ? "" : `\nNew version staged at \`${STAGE_DIR}/files/${f.src}\`.`;
154
+ const retired = f.retirement
155
+ ? `\nRetired${f.retirement.replaced_by ? `, superseded by ${f.retirement.replaced_by}` : ""}. ${f.retirement.note}`
156
+ : "";
157
+ return `${head}\n\n${f.guidance}${staged}${retired}\n`;
158
+ }),
159
+ plan.migration_notes ? `## Migration notes\n\n${plan.migration_notes}\n` : "",
160
+ ].join("\n");
161
+
162
+ await writeFile(join(cwd, STAGE_DIR, "UPDATE.md"), notes, "utf8");
163
+
164
+ printTable(
165
+ actionable.map((f) => ({
166
+ file: f.path,
167
+ what: ACTION_LABEL[f.action] ?? f.action,
168
+ })),
169
+ ["file", "what"],
170
+ );
171
+
172
+ console.log(`\nStaged ${written.length} file(s) under ${STAGE_DIR}/ — nothing in src/ changed.`);
173
+ console.log(`Read ${STAGE_DIR}/UPDATE.md, or open this repo with your coding agent and ask it`);
174
+ console.log("to apply the starter update; it can read the same plan through SV Cloud's tools.");
175
+ console.log(`\nWhen the changes are committed, run \`svcloud bundle done ${slug ?? ""}\`.`);
176
+ }
177
+
178
+ async function doneCmd(slug: string | undefined, json: boolean): Promise<void> {
179
+ const project = await resolve(slug);
180
+ const result = await apiFetch<{
181
+ version: string;
182
+ marker_path: string;
183
+ marker_contents: string;
184
+ changed: boolean;
185
+ }>(`/api/v1/projects/${project.id}/bundle/applied`, { method: "POST", body: {} });
186
+
187
+ // The marker is written HERE rather than by the server, because the server
188
+ // does not touch repos — same rule the rest of this feature follows.
189
+ const target = join(process.cwd(), result.marker_path);
190
+ await mkdir(dirname(target), { recursive: true });
191
+ await writeFile(target, result.marker_contents, "utf8");
192
+
193
+ if (json) {
194
+ printJson(result);
195
+ return;
196
+ }
197
+ console.log(`Recorded ${project.name} as running starter ${result.version}.`);
198
+ console.log(`Wrote ${result.marker_path}. Commit it — it is how the next update knows where to`);
199
+ console.log("start, and an app whose marker disagrees with its files updates badly.");
200
+ }
201
+
202
+ export async function bundleCommand(argv: string[], json: boolean): Promise<void> {
203
+ const [sub, ...rest] = argv;
204
+ switch (sub) {
205
+ case "status":
206
+ await statusCmd(rest[0], json);
207
+ return;
208
+ case "update":
209
+ await updateCmd(rest[0], json);
210
+ return;
211
+ case "done":
212
+ await doneCmd(rest[0], json);
213
+ return;
214
+ default:
215
+ die(
216
+ [
217
+ "Usage:",
218
+ " svcloud bundle status <app> Is this app running an out-of-date starter?",
219
+ " svcloud bundle update <app> Stage the update under sv-cloud-bundle/ for review",
220
+ " svcloud bundle done <app> Record the update after you have committed it",
221
+ ].join("\n"),
222
+ );
223
+ }
224
+ }
@@ -229,8 +229,25 @@ export async function initCommand(argv: string[], json: boolean): Promise<void>
229
229
  }
230
230
 
231
231
  console.log("");
232
- console.log(`Your app is at ${project.web_address ?? "(no web address yet)"} — not live yet.`);
233
- console.log(
234
- "Open the repo with a coding agent (it'll read SVAGENTS.md and get started), then push.",
235
- );
232
+ // `needs_initialization` is the honest discriminator and the reason this
233
+ // prints two different sentences. An EMPTY target repo got the starter at
234
+ // its real paths, so GitHub Actions is already building it and the app
235
+ // comes up on its own; a repo that already had code got the starter nested
236
+ // inert under `sv-cloud-bundle/` and needs an agent to unpack it first.
237
+ // Claiming either one for both was the old message's problem.
238
+ if (project.needs_initialization) {
239
+ console.log(`Your app is at ${project.web_address ?? "(no web address yet)"} — not live yet.`);
240
+ console.log(
241
+ "Your repository already had files in it, so the starter is sitting in sv-cloud-bundle/.",
242
+ );
243
+ console.log(
244
+ "Open the repo with a coding agent (it'll read SVAGENTS.md and unpack it), then push.",
245
+ );
246
+ } else {
247
+ console.log(`Your app is at ${project.web_address ?? "(no web address yet)"}.`);
248
+ console.log(
249
+ "The starter code is committed and GitHub Actions is building it now — give it a couple of minutes.",
250
+ );
251
+ console.log(`Watch it with: svcloud status ${project.slug}`);
252
+ }
236
253
  }
@@ -9,10 +9,11 @@
9
9
  * found, right shape or wrong.
10
10
  */
11
11
  import { callRemote } from "./mcp";
12
- import { CLI_VERSION, apiBaseUrl } from "../lib/config";
12
+ import { CLI_VERSION, PRODUCTION_API_URL, apiBaseUrl } from "../lib/config";
13
13
  import { loadTokens } from "../lib/credentials";
14
14
  import { HARNESSES, inspectHarness, type HarnessStatus } from "../lib/harnesses";
15
15
  import { printJson } from "../lib/output";
16
+ import { bridgeCommand, isOnPath, pathHint } from "../lib/self";
16
17
 
17
18
  // The scope vocabulary svcloud-cli/svcloud-mcp currently grant by default
18
19
  // (apps/cloud/src/services/oauth.ts's OAUTH_CLIENTS, widened 2026-08-25 to
@@ -21,6 +22,20 @@ import { printJson } from "../lib/output";
21
22
  // and only refreshed by signing in again, never silently upgraded.
22
23
  const EXPECTED_SCOPE_COUNT = 6;
23
24
 
25
+ /**
26
+ * A configured entry can still be pointed at a different deployment than the
27
+ * shell running this check — shape is right, target is wrong. That mismatch
28
+ * is invisible in the config file and produces an agent that silently reads
29
+ * and writes the wrong environment, so it is called out by name rather than
30
+ * folded into "configured correctly".
31
+ */
32
+ function describeTarget(status: HarnessStatus): string | undefined {
33
+ if (status.state !== "ok") return undefined;
34
+ const entryUrl = status.apiUrl ?? PRODUCTION_API_URL;
35
+ if (entryUrl === apiBaseUrl()) return undefined;
36
+ return `points the bridge at ${entryUrl}, not ${apiBaseUrl()} — rerun \`svcloud mcp setup\` from this shell to repoint it`;
37
+ }
38
+
24
39
  function describeStatus(status: HarnessStatus): string {
25
40
  switch (status.state) {
26
41
  case "not_found":
@@ -39,6 +54,12 @@ function describeStatus(status: HarnessStatus): string {
39
54
  export async function mcpCheckCommand(json: boolean): Promise<void> {
40
55
  const tokens = await loadTokens();
41
56
  const cwd = process.cwd();
57
+ // A global install whose bin directory is not on PATH is invisible until
58
+ // something tries to spawn `svcloud` and gets ENOENT — which, for a
59
+ // harness, looks exactly like "SV Cloud has no tools". Reported here
60
+ // rather than left for the owner to work out.
61
+ const onPath = isOnPath();
62
+ const bridge = bridgeCommand();
42
63
 
43
64
  const harnessResults = await Promise.all(
44
65
  HARNESSES.map(async (h) => ({ harness: h, status: await inspectHarness(h, cwd) })),
@@ -67,6 +88,8 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
67
88
  printJson({
68
89
  version: CLI_VERSION,
69
90
  api_base_url: apiBaseUrl(),
91
+ svcloud_on_path: onPath,
92
+ bridge_command: [bridge.command, ...bridge.args],
70
93
  signed_in: tokens !== undefined,
71
94
  scopes: tokens?.scopes ?? [],
72
95
  scopes_possibly_stale: scopeStale,
@@ -76,6 +99,7 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
76
99
  id: harness.id,
77
100
  path: status.path,
78
101
  state: status.state,
102
+ api_url: status.state === "ok" ? (status.apiUrl ?? PRODUCTION_API_URL) : undefined,
79
103
  })),
80
104
  });
81
105
  return;
@@ -83,6 +107,15 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
83
107
 
84
108
  console.log(`svcloud ${CLI_VERSION} (API: ${apiBaseUrl()})`);
85
109
  console.log("");
110
+ if (onPath) {
111
+ console.log("PATH: `svcloud` resolves on your PATH.");
112
+ } else {
113
+ console.log(pathHint());
114
+ console.log(
115
+ "Harness configs written by `svcloud mcp setup` use this install's absolute path, so they still work.",
116
+ );
117
+ }
118
+ console.log("");
86
119
  if (!tokens) {
87
120
  console.log("Sign-in: not signed in. Run `svcloud login`.");
88
121
  } else {
@@ -104,6 +137,8 @@ export async function mcpCheckCommand(json: boolean): Promise<void> {
104
137
  for (const { harness, status } of harnessResults) {
105
138
  console.log(` ${harness.label.padEnd(14)} ${status.path}`);
106
139
  console.log(` ${"".padEnd(14)} ${describeStatus(status)}`);
140
+ const target = describeTarget(status);
141
+ if (target) console.log(` ${"".padEnd(14)} ${target}`);
107
142
  }
108
143
  console.log("");
109
144
  console.log("Fix a missing or wrong-shaped one with: svcloud mcp setup <harness>");
@@ -9,8 +9,34 @@
9
9
  */
10
10
  import { mkdir, readFile, writeFile } from "node:fs/promises";
11
11
  import { dirname } from "node:path";
12
- import { findHarness, HARNESSES, type Harness } from "../lib/harnesses";
12
+ import { apiBaseUrl, PRODUCTION_API_URL } from "../lib/config";
13
+ import { findHarness, HARNESSES, type Harness, readEntryApiUrl } from "../lib/harnesses";
13
14
  import { die, hasFlag } from "../lib/output";
15
+ import { bridgeCommand, pathHint } from "../lib/self";
16
+
17
+ /**
18
+ * The environment the written entry should spawn the bridge with — almost
19
+ * always none.
20
+ *
21
+ * A harness config records a command, and the harness spawns it later from
22
+ * its own environment, not from the shell that ran `mcp setup`. So a bridge
23
+ * set up in a staging shell would still have talked to PRODUCTION every time
24
+ * the agent called a tool: `svcloud mcp setup` could not point an agent at
25
+ * anything but production at all, which is the half of the staging problem
26
+ * that no amount of care at the terminal could work around.
27
+ *
28
+ * Inherited rather than given a flag, deliberately. Staging is ours, not the
29
+ * product's, so there is no `--env` for a user to find: this reads the
30
+ * ambient `SVCLOUD_API_URL` and writes nothing at all when it is unset or
31
+ * already production, which is every real user. Their config file is
32
+ * byte-identical to what this command wrote before passthrough existed.
33
+ */
34
+ function inheritedEnv(): Record<string, string> | undefined {
35
+ if (!process.env.SVCLOUD_API_URL) return undefined;
36
+ const base = apiBaseUrl();
37
+ if (base === PRODUCTION_API_URL) return undefined;
38
+ return { SVCLOUD_API_URL: base };
39
+ }
14
40
 
15
41
  function usage(): string {
16
42
  const ids = HARNESSES.map((h) => h.id).join(" | ");
@@ -53,21 +79,36 @@ export async function mcpSetupCommand(argv: string[], json: boolean): Promise<vo
53
79
 
54
80
  const servers = (config[harness.serversKey] ?? {}) as Record<string, unknown>;
55
81
  const existing = servers.svcloud;
56
- const entry = harness.buildEntry("svcloud", ["mcp"]);
82
+ // Resolved, never assumed: a config that says `svcloud` on a machine
83
+ // where `svcloud` is not on PATH produces a harness that silently has no
84
+ // SV Cloud tools at all. See lib/self.ts.
85
+ const bridge = bridgeCommand();
86
+ const env = inheritedEnv();
87
+ const entry = harness.buildEntry(bridge.command, bridge.args, env);
57
88
 
58
89
  if (existing !== undefined && !force) {
59
90
  if (harness.isWellFormed(existing)) {
60
- if (json) {
61
- console.log(JSON.stringify({ harness: harness.id, path, status: "already_configured" }));
62
- } else {
63
- console.log(`${harness.label} is already configured at ${path}.`);
91
+ // A well-formed entry pointed at a DIFFERENT deployment than this
92
+ // shell is not "already configured" — reporting it as such is how an
93
+ // agent ends up silently talking to production from a staging setup,
94
+ // and the reverse. Shape matches, so this is a correction of one
95
+ // value rather than the overwrite `--force` guards, and it falls
96
+ // through to the write below.
97
+ const desired = env?.SVCLOUD_API_URL;
98
+ if (readEntryApiUrl(existing) === desired) {
99
+ if (json) {
100
+ console.log(JSON.stringify({ harness: harness.id, path, status: "already_configured" }));
101
+ } else {
102
+ console.log(`${harness.label} is already configured at ${path}.`);
103
+ }
104
+ return;
64
105
  }
65
- return;
106
+ } else {
107
+ die(
108
+ `${path} already has a "svcloud" entry under "${harness.serversKey}", but it doesn't look right for ${harness.label}.\n` +
109
+ `Rerun with --force to overwrite it.`,
110
+ );
66
111
  }
67
- die(
68
- `${path} already has a "svcloud" entry under "${harness.serversKey}", but it doesn't look right for ${harness.label}.\n` +
69
- `Rerun with --force to overwrite it.`,
70
- );
71
112
  }
72
113
 
73
114
  servers.svcloud = entry;
@@ -77,10 +118,33 @@ export async function mcpSetupCommand(argv: string[], json: boolean): Promise<vo
77
118
  await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
78
119
 
79
120
  if (json) {
80
- console.log(JSON.stringify({ harness: harness.id, path, status: "written" }));
121
+ console.log(
122
+ JSON.stringify({
123
+ harness: harness.id,
124
+ path,
125
+ status: "written",
126
+ command: bridge.command,
127
+ args: bridge.args,
128
+ svcloud_on_path: bridge.onPath,
129
+ // Only when there is something to say: production writes no
130
+ // environment block, and reporting a key for it would imply one.
131
+ ...(env ? { api_url: env.SVCLOUD_API_URL } : {}),
132
+ }),
133
+ );
81
134
  return;
82
135
  }
83
136
  console.log(`Wrote ${harness.label}'s MCP config to ${path}.`);
137
+ if (env) {
138
+ console.log(`This entry points the bridge at ${env.SVCLOUD_API_URL}.`);
139
+ }
140
+ if (!bridge.onPath) {
141
+ console.log("");
142
+ console.log(
143
+ "Note: the config uses this install's absolute path, so your harness will find it either way.",
144
+ );
145
+ console.log(pathHint());
146
+ console.log("");
147
+ }
84
148
  console.log(`Sign in first if you haven't: svcloud login`);
85
149
  console.log(`Then restart ${harness.label} (or start a new session) to pick it up.`);
86
150
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `svcloud storage` — direct URLs into an app's file storage.
3
+ *
4
+ * Only presigning, deliberately. `svcloud storage put` and `get` would put the
5
+ * CLI in the data path for a file that may be gigabytes, which is the exact
6
+ * cost presigned URLs exist to avoid; the URL this hands back works with curl,
7
+ * a browser, or anything else, and moves bytes straight between the caller and
8
+ * storage.
9
+ *
10
+ * A GET URL is a shareable download link for as long as it lives. The server
11
+ * caps that lifetime and reports what it actually granted, which may be less
12
+ * than was asked for — so `expires_in` in the output is the truth, not the
13
+ * `--expires-in` flag.
14
+ */
15
+ import { statSync } from "node:fs";
16
+ import { apiFetch } from "../lib/api";
17
+ import { findProjectBySlug } from "../lib/find-project";
18
+ import { die, printJson } from "../lib/output";
19
+
20
+ const USAGE = `Usage:
21
+ svcloud storage presign <app> <key> [--method GET|PUT] [--expires-in <seconds>]
22
+ [--size <bytes> | --file <path>] [--content-type <type>]
23
+
24
+ --method GET (default) for a download or share link, PUT to upload.
25
+ --size Required for PUT. Signed into the URL, so an upload of any
26
+ other size is refused.
27
+ --file Read the size from a local file instead of passing --size.
28
+ --expires-in Seconds. Capped by the server; the output reports what was granted.`;
29
+
30
+ interface PresignResponse {
31
+ project_id: string;
32
+ key: string;
33
+ method: string;
34
+ url: string;
35
+ expires_in: number;
36
+ }
37
+
38
+ function flagValue(argv: string[], name: string): string | undefined {
39
+ const index = argv.indexOf(name);
40
+ if (index === -1) return undefined;
41
+ const value = argv[index + 1];
42
+ if (value === undefined || value.startsWith("--")) die(`${name} needs a value.\n\n${USAGE}`);
43
+ return value;
44
+ }
45
+
46
+ export async function storageCommand(argv: string[], json: boolean): Promise<void> {
47
+ const [subcommand, ...rest] = argv;
48
+ if (subcommand !== "presign") die(USAGE);
49
+
50
+ const [slug, key] = rest;
51
+ if (!slug || !key) die(USAGE);
52
+
53
+ const project = await findProjectBySlug(slug);
54
+ if (!project) die(`No app named "${slug}".`);
55
+
56
+ const method = (flagValue(rest, "--method") ?? "GET").toUpperCase();
57
+ if (method !== "GET" && method !== "PUT") die(`--method must be GET or PUT.\n\n${USAGE}`);
58
+
59
+ const body: Record<string, unknown> = { key, method };
60
+
61
+ const expiresIn = flagValue(rest, "--expires-in");
62
+ if (expiresIn !== undefined) {
63
+ const seconds = Number(expiresIn);
64
+ if (!Number.isInteger(seconds) || seconds <= 0) die("--expires-in must be a whole number of seconds.");
65
+ body.expires_in = seconds;
66
+ }
67
+
68
+ const contentType = flagValue(rest, "--content-type");
69
+ if (contentType) body.content_type = contentType;
70
+
71
+ if (method === "PUT") {
72
+ const size = flagValue(rest, "--size");
73
+ const file = flagValue(rest, "--file");
74
+ if (size !== undefined && file !== undefined) die("Pass --size or --file, not both.");
75
+ if (size !== undefined) {
76
+ const bytes = Number(size);
77
+ if (!Number.isInteger(bytes) || bytes < 0) die("--size must be a whole number of bytes.");
78
+ body.content_length = bytes;
79
+ } else if (file !== undefined) {
80
+ try {
81
+ body.content_length = statSync(file).size;
82
+ } catch {
83
+ die(`Could not read "${file}".`);
84
+ }
85
+ } else {
86
+ die(`A PUT needs --size or --file: the size is signed into the URL.\n\n${USAGE}`);
87
+ }
88
+ }
89
+
90
+ const result = await apiFetch<PresignResponse>(
91
+ `/api/v1/projects/${project.id}/storage/presign`,
92
+ { method: "POST", body: JSON.stringify(body) },
93
+ );
94
+
95
+ if (json) {
96
+ printJson(result);
97
+ return;
98
+ }
99
+
100
+ console.log(result.url);
101
+ console.error("");
102
+ console.error(`${result.method} ${result.key} — valid for ${result.expires_in}s.`);
103
+ if (result.method === "GET") {
104
+ console.error("Anybody with this link can download the file until it expires.");
105
+ } else {
106
+ console.error(`Upload with: curl -X PUT --upload-file <file> "<url>"`);
107
+ }
108
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * few that need subcommands (`secrets`, `db`, `mcp`) do their own
6
6
  * second-level switch — no subcommand ambiguity worth a parser library yet.
7
7
  */
8
+ import { bundleCommand } from "./commands/bundle";
8
9
  import { dbCommand } from "./commands/db";
9
10
  import { deployCommand } from "./commands/deploy";
10
11
  import { initCommand } from "./commands/init";
@@ -15,6 +16,7 @@ import { openCommand } from "./commands/open";
15
16
  import { projectsCommand } from "./commands/projects";
16
17
  import { runsCommand } from "./commands/runs";
17
18
  import { secretsCommand } from "./commands/secrets";
19
+ import { storageCommand } from "./commands/storage";
18
20
  import { statusCommand } from "./commands/status";
19
21
  import { whoamiCommand } from "./commands/whoami";
20
22
  import { AuthRequiredError, ApiError } from "./lib/api";
@@ -33,8 +35,10 @@ Usage:
33
35
  svcloud init Connect a local repo as a new app
34
36
  svcloud runs <app> Show or watch an app's provisioning run
35
37
  svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
38
+ svcloud storage presign Get a direct URL for one file in an app's storage
36
39
  svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
37
40
  svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
41
+ svcloud bundle <cmd> Keep an app's starter current (status/update/done)
38
42
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
39
43
  svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
40
44
  svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
@@ -69,6 +73,9 @@ async function main(): Promise<void> {
69
73
  case "logout":
70
74
  await logoutCommand();
71
75
  return;
76
+ case "bundle":
77
+ await bundleCommand(rest, json);
78
+ return;
72
79
  case "whoami":
73
80
  await whoamiCommand(json);
74
81
  return;
@@ -93,6 +100,9 @@ async function main(): Promise<void> {
93
100
  case "secrets":
94
101
  await secretsCommand(rest, json);
95
102
  return;
103
+ case "storage":
104
+ await storageCommand(rest, json);
105
+ return;
96
106
  case "db":
97
107
  await dbCommand(rest, json);
98
108
  return;
package/src/lib/api.ts CHANGED
@@ -169,8 +169,28 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
169
169
  // A non-JSON error body (e.g. a 502 in front of the Worker) still surfaces as a plain ApiError below.
170
170
  }
171
171
  if (res.status === 401 && !options.unauthenticated) {
172
- await clearTokens();
173
- throw new AuthRequiredError();
172
+ // Deliberately NOT clearTokens(). Reaching here means the forced
173
+ // refresh above SUCCEEDED — cloud-api accepted this session's refresh
174
+ // token — and then rejected the access token it had just minted. A
175
+ // live grant is not a dead session, so destroying the stored tokens is
176
+ // the wrong response: the likely causes are a token held against a
177
+ // DIFFERENT deployment than the one being called, or a transient
178
+ // server-side rejection, and neither is fixed by making the user sign
179
+ // in again from scratch. `refreshRequest` already clears the store on
180
+ // the one signal that does mean the grant is gone (the refresh itself
181
+ // being refused), and that stays the only path that wipes it.
182
+ //
183
+ // The hint names SVCLOUD_API_URL only when it is actually set: staging
184
+ // is ours, and a real user must never be pointed at a variable that
185
+ // plays no part in their setup.
186
+ const misdirected = process.env.SVCLOUD_API_URL
187
+ ? ` The CLI is pointed at ${apiBaseUrl()} — check SVCLOUD_API_URL if that is not what you meant.`
188
+ : "";
189
+ throw new ApiError(
190
+ `Signed in, but ${apiBaseUrl()} rejected this session.${misdirected} Run \`svcloud login\` to sign in against it.`,
191
+ envelope?.error.code ?? "unauthenticated",
192
+ 401,
193
+ );
174
194
  }
175
195
  throw new ApiError(
176
196
  envelope?.error.message ?? `Request failed (${res.status}).`,
package/src/lib/config.ts CHANGED
@@ -1,19 +1,74 @@
1
1
  /**
2
2
  * Constants shared by every command. `SVCLOUD_API_URL` is the one env
3
- * override, for pointing the CLI at a local `wrangler dev` instance while
4
- * building it — production has no reason to ever set it.
3
+ * override, for pointing the CLI at a deployment that is not production — a
4
+ * local `wrangler dev` instance while building it, or our own staging
5
+ * environment. It is deliberately NOT a flag: staging is ours, not the
6
+ * product's, so it appears in no `--help` output and no README, and a real
7
+ * user's CLI behaves as though it does not exist.
5
8
  */
6
9
  import { createRequire } from "node:module";
7
10
 
8
11
  export const CLIENT_ID = "svcloud-cli";
9
12
 
13
+ /** Service/account names the credential store (lib/credentials.ts) files this CLI's token set under. */
14
+ export const CREDENTIAL_SERVICE = "svcloud-cli";
15
+
16
+ export const PRODUCTION_API_URL = "https://api.cloud.sv-academy.org";
17
+
18
+ /**
19
+ * The API this process talks to, normalized.
20
+ *
21
+ * Normalizing rather than returning the raw string is load-bearing: the value
22
+ * is interpolated straight into `fetch()` (`lib/api.ts`) and parsed by
23
+ * `credentialAccount()` below, and a host with no scheme — the shape a person
24
+ * naturally types — produces `TypeError: Failed to parse URL from
25
+ * api.cloud-staging.sv-academy.org/api/v1/...` several frames from the actual
26
+ * mistake. So a scheme-less value gets `https://`, and anything still
27
+ * unusable after that throws a sentence naming the variable instead of
28
+ * surfacing as a URL parser error.
29
+ *
30
+ * Failing loudly here also closes a real footgun in `credentialAccount()`: it
31
+ * used to fall back to the production slot for a value it could not parse,
32
+ * which is the one slot a non-production token must never land in.
33
+ */
10
34
  export function apiBaseUrl(): string {
11
- return process.env.SVCLOUD_API_URL ?? "https://api.cloud.sv-academy.org";
35
+ const raw = process.env.SVCLOUD_API_URL?.trim();
36
+ if (!raw) return PRODUCTION_API_URL;
37
+
38
+ const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw) ? raw : `https://${raw}`;
39
+ let url: URL;
40
+ try {
41
+ url = new URL(candidate);
42
+ } catch {
43
+ throw new Error(`SVCLOUD_API_URL is not a valid URL: ${raw}`);
44
+ }
45
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
46
+ throw new Error(`SVCLOUD_API_URL must be an http(s) URL: ${raw}`);
47
+ }
48
+ if (!url.host) throw new Error(`SVCLOUD_API_URL has no host: ${raw}`);
49
+
50
+ // Every caller appends a path beginning with "/", so the base must not end
51
+ // with one — `${base}${path}` would otherwise produce a double slash.
52
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
12
53
  }
13
54
 
14
- /** Service/account names the credential store (lib/credentials.ts) files this CLI's token set under. */
15
- export const CREDENTIAL_SERVICE = "svcloud-cli";
16
- export const CREDENTIAL_ACCOUNT = "default";
55
+ /**
56
+ * The credential-store slot this CLI's token set lives in, keyed by which API
57
+ * it belongs to. Production keeps the literal "default" it has always used,
58
+ * so no existing login is invalidated; anything else (a staging deployment, a
59
+ * local `wrangler dev`) gets its own slot named for the host.
60
+ *
61
+ * Without this, `SVCLOUD_API_URL=… svcloud login` silently OVERWRITES the
62
+ * production token — same service, same account, one entry — and the next
63
+ * plain `svcloud` command runs against production with a token minted by a
64
+ * different environment, which fails in a way that looks like a session
65
+ * expiry rather than a footgun.
66
+ */
67
+ export function credentialAccount(): string {
68
+ const url = apiBaseUrl();
69
+ if (url === PRODUCTION_API_URL) return "default";
70
+ return new URL(url).host;
71
+ }
17
72
 
18
73
  /** This installed CLI's own version, from package.json — surfaced by `svcloud mcp check`. */
19
74
  export const CLI_VERSION: string = createRequire(import.meta.url)("../../package.json").version;
@@ -28,7 +28,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { promisify } from "node:util";
31
- import { CREDENTIAL_ACCOUNT, CREDENTIAL_SERVICE } from "./config";
31
+ import { credentialAccount, CREDENTIAL_SERVICE } from "./config";
32
32
 
33
33
  const run = promisify(execFile);
34
34
 
@@ -41,7 +41,21 @@ export interface TokenSet {
41
41
  }
42
42
 
43
43
  const FALLBACK_DIR = join(homedir(), ".config", "svcloud");
44
- const FALLBACK_FILE = join(FALLBACK_DIR, "credentials.json");
44
+
45
+ /**
46
+ * Every store below is keyed by `credentialAccount()`, not by a constant, so
47
+ * a token minted against one API never lands in another's slot — see that
48
+ * function's header. Resolved per call rather than at import: the CLI reads
49
+ * `SVCLOUD_API_URL` from the environment, which a test (and a user) can
50
+ * change between calls in one process.
51
+ */
52
+ function fallbackFile(): string {
53
+ const account = credentialAccount();
54
+ return join(
55
+ FALLBACK_DIR,
56
+ account === "default" ? "credentials.json" : `credentials-${account}.json`,
57
+ );
58
+ }
45
59
 
46
60
  async function macosSave(json: string): Promise<void> {
47
61
  // -U: update in place if an entry already exists, so a re-login doesn't
@@ -49,7 +63,7 @@ async function macosSave(json: string): Promise<void> {
49
63
  await run("security", [
50
64
  "add-generic-password",
51
65
  "-a",
52
- CREDENTIAL_ACCOUNT,
66
+ credentialAccount(),
53
67
  "-s",
54
68
  CREDENTIAL_SERVICE,
55
69
  "-w",
@@ -63,7 +77,7 @@ async function macosLoad(): Promise<string | undefined> {
63
77
  const { stdout } = await run("security", [
64
78
  "find-generic-password",
65
79
  "-a",
66
- CREDENTIAL_ACCOUNT,
80
+ credentialAccount(),
67
81
  "-s",
68
82
  CREDENTIAL_SERVICE,
69
83
  "-w",
@@ -78,7 +92,7 @@ async function macosClear(): Promise<void> {
78
92
  await run("security", [
79
93
  "delete-generic-password",
80
94
  "-a",
81
- CREDENTIAL_ACCOUNT,
95
+ credentialAccount(),
82
96
  "-s",
83
97
  CREDENTIAL_SERVICE,
84
98
  ]).catch(() => {
@@ -96,7 +110,7 @@ async function linuxSave(json: string): Promise<void> {
96
110
  "service",
97
111
  CREDENTIAL_SERVICE,
98
112
  "account",
99
- CREDENTIAL_ACCOUNT,
113
+ credentialAccount(),
100
114
  ]);
101
115
  pending.child.stdin?.end(json);
102
116
  await pending;
@@ -109,7 +123,7 @@ async function linuxLoad(): Promise<string | undefined> {
109
123
  "service",
110
124
  CREDENTIAL_SERVICE,
111
125
  "account",
112
- CREDENTIAL_ACCOUNT,
126
+ credentialAccount(),
113
127
  ]);
114
128
  return stdout.replace(/\n$/, "") || undefined;
115
129
  } catch {
@@ -123,7 +137,7 @@ async function linuxClear(): Promise<void> {
123
137
  "service",
124
138
  CREDENTIAL_SERVICE,
125
139
  "account",
126
- CREDENTIAL_ACCOUNT,
140
+ credentialAccount(),
127
141
  ]).catch(() => {});
128
142
  }
129
143
 
@@ -135,7 +149,9 @@ async function linuxClear(): Promise<void> {
135
149
  * macOS); any failure here falls back to the plaintext file below rather
136
150
  * than surfacing a build-only error to a novice.
137
151
  */
138
- const PS_TARGET = `${CREDENTIAL_SERVICE}/${CREDENTIAL_ACCOUNT}`;
152
+ function psTarget(): string {
153
+ return `${CREDENTIAL_SERVICE}/${credentialAccount()}`;
154
+ }
139
155
 
140
156
  const PS_PROLOGUE = `
141
157
  Add-Type -Namespace SvCloud -Name Cred -MemberDefinition @'
@@ -164,8 +180,8 @@ $bytes = [Convert]::FromBase64String("${bytes.toString("base64")}")
164
180
  $blob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
165
181
  [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length)
166
182
  $cred = New-Object SvCloud.Cred+CREDENTIAL
167
- $cred.Type = 1; $cred.TargetName = "${PS_TARGET}"; $cred.CredentialBlobSize = $bytes.Length
168
- $cred.CredentialBlob = $blob; $cred.Persist = 2; $cred.UserName = "${CREDENTIAL_ACCOUNT}"
183
+ $cred.Type = 1; $cred.TargetName = "${psTarget()}"; $cred.CredentialBlobSize = $bytes.Length
184
+ $cred.CredentialBlob = $blob; $cred.Persist = 2; $cred.UserName = "${credentialAccount()}"
169
185
  if (-not [SvCloud.Cred]::CredWrite([ref]$cred, 0)) { throw "CredWrite failed" }
170
186
  [Runtime.InteropServices.Marshal]::FreeHGlobal($blob)`;
171
187
  await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
@@ -174,7 +190,7 @@ if (-not [SvCloud.Cred]::CredWrite([ref]$cred, 0)) { throw "CredWrite failed" }
174
190
  async function windowsLoad(): Promise<string | undefined> {
175
191
  const script = `${PS_PROLOGUE}
176
192
  $ptr = [IntPtr]::Zero
177
- if (-not [SvCloud.Cred]::CredRead("${PS_TARGET}", 1, 0, [ref]$ptr)) { exit 1 }
193
+ if (-not [SvCloud.Cred]::CredRead("${psTarget()}", 1, 0, [ref]$ptr)) { exit 1 }
178
194
  $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [Type][SvCloud.Cred+CREDENTIAL])
179
195
  $bytes = New-Object byte[] $cred.CredentialBlobSize
180
196
  [Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
@@ -190,15 +206,15 @@ $bytes = New-Object byte[] $cred.CredentialBlobSize
190
206
  }
191
207
 
192
208
  async function windowsClear(): Promise<void> {
193
- const script = `${PS_PROLOGUE}[SvCloud.Cred]::CredDelete("${PS_TARGET}", 1, 0) | Out-Null`;
209
+ const script = `${PS_PROLOGUE}[SvCloud.Cred]::CredDelete("${psTarget()}", 1, 0) | Out-Null`;
194
210
  await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]).catch(() => {});
195
211
  }
196
212
 
197
213
  async function fallbackSave(json: string): Promise<void> {
198
214
  await mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 });
199
- await writeFile(FALLBACK_FILE, json, { mode: 0o600 });
215
+ await writeFile(fallbackFile(), json, { mode: 0o600 });
200
216
  console.error(
201
- `Note: your SV Cloud sign-in is stored in plain text at ${FALLBACK_FILE} ` +
217
+ `Note: your SV Cloud sign-in is stored in plain text at ${fallbackFile()} ` +
202
218
  "because no system keychain was available. Anyone who can read that " +
203
219
  "file can act as you until you run `svcloud logout`.",
204
220
  );
@@ -206,14 +222,14 @@ async function fallbackSave(json: string): Promise<void> {
206
222
 
207
223
  async function fallbackLoad(): Promise<string | undefined> {
208
224
  try {
209
- return await readFile(FALLBACK_FILE, "utf-8");
225
+ return await readFile(fallbackFile(), "utf-8");
210
226
  } catch {
211
227
  return undefined;
212
228
  }
213
229
  }
214
230
 
215
231
  async function fallbackClear(): Promise<void> {
216
- await rm(FALLBACK_FILE, { force: true });
232
+ await rm(fallbackFile(), { force: true });
217
233
  }
218
234
 
219
235
  async function platformSave(json: string): Promise<boolean> {
@@ -14,6 +14,14 @@
14
14
  * Codex's entry is carried over from the README as it stood before this
15
15
  * change (`~/.codex/config.json`, `mcpServers`) — not independently
16
16
  * re-verified here; only OpenCode's shape was reported wrong.
17
+ *
18
+ * `buildEntry` takes the command and args rather than hardcoding
19
+ * `"svcloud" ["mcp"]`, because what is spawnable depends on the machine:
20
+ * `lib/self.ts` resolves the bare name against PATH and falls back to this
21
+ * install's own absolute paths when it does not resolve. `isWellFormed`
22
+ * deliberately does NOT check the command's value, only its shape — an
23
+ * absolute-path entry a previous `mcp setup` wrote is correct, and so is a
24
+ * bare-name one somebody wrote by hand.
17
25
  */
18
26
  import { readFile } from "node:fs/promises";
19
27
  import { homedir } from "node:os";
@@ -25,6 +33,10 @@ export interface HarnessEntry {
25
33
  command: string | string[];
26
34
  args?: string[];
27
35
  type?: string;
36
+ /** Claude-style harnesses. Present only when there are variables to pass. */
37
+ env?: Record<string, string>;
38
+ /** OpenCode's spelling of the same thing — verified against opencode.ai/docs/mcp-servers/. */
39
+ environment?: Record<string, string>;
28
40
  }
29
41
 
30
42
  export interface Harness {
@@ -35,8 +47,13 @@ export interface Harness {
35
47
  configPath: (cwd: string) => string;
36
48
  /** The top-level object key an entry for svcloud lives under. */
37
49
  serversKey: string;
38
- /** Build this harness's shape of the svcloud server entry. */
39
- buildEntry: (command: string, args: string[]) => HarnessEntry;
50
+ /**
51
+ * Build this harness's shape of the svcloud server entry. `env` is omitted
52
+ * far more often than not — see `mcp-setup.ts`'s `inheritedEnv` — and when
53
+ * it is, the entry written must be byte-identical to what this table
54
+ * produced before environment passthrough existed.
55
+ */
56
+ buildEntry: (command: string, args: string[], env?: Record<string, string>) => HarnessEntry;
40
57
  /** True if an existing entry (already parsed as JSON) has this harness's expected shape. */
41
58
  isWellFormed: (entry: unknown) => boolean;
42
59
  }
@@ -45,7 +62,11 @@ function isStringArray(v: unknown): v is string[] {
45
62
  return Array.isArray(v) && v.every((x) => typeof x === "string");
46
63
  }
47
64
 
48
- const claudeStyleEntry = (command: string, args: string[]): HarnessEntry => ({ command, args });
65
+ const claudeStyleEntry = (
66
+ command: string,
67
+ args: string[],
68
+ env?: Record<string, string>,
69
+ ): HarnessEntry => ({ command, args, ...(env ? { env } : {}) });
49
70
  const claudeStyleWellFormed = (entry: unknown): boolean =>
50
71
  typeof entry === "object" &&
51
72
  entry !== null &&
@@ -104,7 +125,15 @@ export const HARNESSES: Harness[] = [
104
125
  scope: "global",
105
126
  configPath: () => join(homedir(), ".config", "opencode", "opencode.json"),
106
127
  serversKey: "mcp",
107
- buildEntry: (command, args) => ({ type: "local", command: [command, ...args] }),
128
+ // OpenCode spells the environment block `environment`, not `env` — the
129
+ // one shape difference in this table, verified against
130
+ // https://opencode.ai/docs/mcp-servers/ the same way its file path and
131
+ // `mcp` key were.
132
+ buildEntry: (command, args, env) => ({
133
+ type: "local",
134
+ command: [command, ...args],
135
+ ...(env ? { environment: env } : {}),
136
+ }),
108
137
  isWellFormed: (entry: unknown): boolean =>
109
138
  typeof entry === "object" &&
110
139
  entry !== null &&
@@ -118,12 +147,42 @@ export function findHarness(id: string): Harness | undefined {
118
147
  return HARNESSES.find((h) => h.id === id);
119
148
  }
120
149
 
150
+ /**
151
+ * The API deployment an already-written entry spawns the bridge against, or
152
+ * undefined for the default (production). Reads both spellings of the
153
+ * environment block, so one caller covers the whole table.
154
+ *
155
+ * Kept out of `isWellFormed`, which checks an entry's SHAPE and deliberately
156
+ * not its values (see this file's header): an entry naming a different
157
+ * deployment is well-formed, it is just pointed somewhere else, and telling
158
+ * those two cases apart is what lets `mcp setup` correct the second without
159
+ * demanding `--force` for the first.
160
+ */
161
+ export function readEntryApiUrl(entry: unknown): string | undefined {
162
+ if (typeof entry !== "object" || entry === null) return undefined;
163
+ const record = entry as Record<string, unknown>;
164
+ for (const key of ["env", "environment"]) {
165
+ const block = record[key];
166
+ if (typeof block !== "object" || block === null) continue;
167
+ const value = (block as Record<string, unknown>).SVCLOUD_API_URL;
168
+ if (typeof value === "string" && value) return value;
169
+ }
170
+ return undefined;
171
+ }
172
+
121
173
  export type HarnessStatus =
122
174
  | { state: "not_found"; path: string }
123
175
  | { state: "unreadable"; path: string; detail: string }
124
176
  | { state: "missing_entry"; path: string }
125
177
  | { state: "wrong_shape"; path: string }
126
- | { state: "ok"; path: string };
178
+ /**
179
+ * `apiUrl` is the deployment the recorded entry spawns the bridge against,
180
+ * undefined meaning the default (production). Carried on the status so
181
+ * `mcp check` can report an entry that is correctly shaped but pointed
182
+ * somewhere other than the shell doing the checking — the one failure this
183
+ * command exists to make visible that shape alone cannot show.
184
+ */
185
+ | { state: "ok"; path: string; apiUrl?: string };
127
186
 
128
187
  /** Reads and inspects a harness's config file — never throws. */
129
188
  export async function inspectHarness(harness: Harness, cwd: string): Promise<HarnessStatus> {
@@ -144,5 +203,5 @@ export async function inspectHarness(harness: Harness, cwd: string): Promise<Har
144
203
  const entry = (servers as Record<string, unknown> | undefined)?.svcloud;
145
204
  if (entry === undefined) return { state: "missing_entry", path };
146
205
  if (!harness.isWellFormed(entry)) return { state: "wrong_shape", path };
147
- return { state: "ok", path };
206
+ return { state: "ok", path, apiUrl: readEntryApiUrl(entry) };
148
207
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * How to spawn THIS CLI again, from a config file another program reads.
3
+ *
4
+ * `svcloud mcp setup` writes a harness config whose `command` is whatever
5
+ * that harness will later exec. Writing the bare name `svcloud` is right
6
+ * whenever the npm global bin directory is on PATH, and silently useless
7
+ * whenever it is not: the harness spawns, fails with ENOENT, and shows the
8
+ * owner an MCP server that is simply never there. That was a real report
9
+ * (2026-09-05) from someone who had run `npm install -g svcloud`
10
+ * successfully and still had to invoke `npx svcloud` for everything,
11
+ * because their shell's PATH did not include `$(npm prefix -g)/bin`.
12
+ *
13
+ * So the command is resolved rather than assumed. If a `svcloud`
14
+ * executable is genuinely reachable on PATH, that is what gets written —
15
+ * it survives a CLI upgrade that moves the install directory. If it is
16
+ * not, the config gets this process's own absolute paths instead, which
17
+ * works no matter what the harness's PATH looks like. Node's own binary is
18
+ * used rather than the npm shim: the shim's exec bit and its `.cmd`
19
+ * wrapper on Windows are two more things that can be wrong, and
20
+ * `process.execPath` is neither.
21
+ */
22
+ import { accessSync, constants } from "node:fs";
23
+ import { delimiter, dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ /** `bin/svcloud.js` in this installed package, absolute. */
27
+ export function ownBinPath(): string {
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ return join(here, "..", "..", "bin", "svcloud.js");
30
+ }
31
+
32
+ /**
33
+ * Whether a bare `svcloud` resolves to an executable on this PATH.
34
+ *
35
+ * Deliberately not `which`/`where`: spawning a shell to answer a question
36
+ * 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`.
39
+ */
40
+ export function isOnPath(name = "svcloud", env: NodeJS.ProcessEnv = process.env): boolean {
41
+ const raw = env.PATH ?? env.Path ?? "";
42
+ if (!raw) return false;
43
+ const suffixes = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
44
+ for (const dir of raw.split(delimiter)) {
45
+ if (!dir) continue;
46
+ for (const suffix of suffixes) {
47
+ try {
48
+ accessSync(join(dir, `${name}${suffix}`), constants.X_OK);
49
+ return true;
50
+ } catch {
51
+ /* Not here; keep looking. */
52
+ }
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ export interface BridgeCommand {
59
+ command: string;
60
+ args: string[];
61
+ /** True when the bare name resolved, so the config is portable. */
62
+ onPath: boolean;
63
+ }
64
+
65
+ /** The `command` + `args` a harness config should use to run `svcloud mcp`. */
66
+ export function bridgeCommand(): BridgeCommand {
67
+ if (isOnPath()) return { command: "svcloud", args: ["mcp"], onPath: true };
68
+ return { command: process.execPath, args: [ownBinPath(), "mcp"], onPath: false };
69
+ }
70
+
71
+ /**
72
+ * What to tell somebody whose npm global bin is not on PATH.
73
+ *
74
+ * The directory is derived from where this file actually is rather than
75
+ * from `npm prefix -g`, so the sentence names the path that would really
76
+ * work for this install, including inside a version manager's per-version
77
+ * prefix.
78
+ */
79
+ export function pathHint(): string {
80
+ const binDir = dirname(ownBinPath());
81
+ return (
82
+ `\`svcloud\` is not on your PATH, so your shell can only reach it through \`npx\`.\n` +
83
+ `Add it with:\n` +
84
+ ` echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n` +
85
+ `(use ~/.bashrc on Linux). This install's own binary is at:\n` +
86
+ ` ${join(binDir, "svcloud.js")}`
87
+ );
88
+ }