svcloud 0.1.4 → 0.1.6

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
@@ -57,7 +57,9 @@ svcloud init Connect a repo (defaults to the one in your workin
57
57
  svcloud runs <app> Show or watch an app's provisioning run
58
58
  svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
59
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
60
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)
61
63
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
62
64
  svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
63
65
  svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
@@ -75,6 +77,33 @@ to skip what it would otherwise infer or ask about; `runs` reads `--id
75
77
 
76
78
  `logs` and `dev` are still on the way.
77
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
+
78
107
  ## Connect your coding agent
79
108
 
80
109
  `svcloud mcp` runs a local MCP server that your coding agent — Antigravity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svcloud",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
+ }
@@ -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
+ }
@@ -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,9 @@ 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";
20
+ import { promoteCommand } from "./commands/promote";
21
+ import { stagingCommand } from "./commands/staging";
18
22
  import { statusCommand } from "./commands/status";
19
23
  import { whoamiCommand } from "./commands/whoami";
20
24
  import { AuthRequiredError, ApiError } from "./lib/api";
@@ -33,8 +37,12 @@ Usage:
33
37
  svcloud init Connect a local repo as a new app
34
38
  svcloud runs <app> Show or watch an app's provisioning run
35
39
  svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
40
+ svcloud storage presign Get a direct URL for one file in an app's storage
36
41
  svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
37
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
45
+ svcloud bundle <cmd> Keep an app's starter current (status/update/done)
38
46
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
39
47
  svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
40
48
  svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
@@ -69,6 +77,9 @@ async function main(): Promise<void> {
69
77
  case "logout":
70
78
  await logoutCommand();
71
79
  return;
80
+ case "bundle":
81
+ await bundleCommand(rest, json);
82
+ return;
72
83
  case "whoami":
73
84
  await whoamiCommand(json);
74
85
  return;
@@ -93,12 +104,21 @@ async function main(): Promise<void> {
93
104
  case "secrets":
94
105
  await secretsCommand(rest, json);
95
106
  return;
107
+ case "storage":
108
+ await storageCommand(rest, json);
109
+ return;
96
110
  case "db":
97
111
  await dbCommand(rest, json);
98
112
  return;
99
113
  case "deploy":
100
114
  await deployCommand(rest, json);
101
115
  return;
116
+ case "staging":
117
+ await stagingCommand(rest, json);
118
+ return;
119
+ case "promote":
120
+ await promoteCommand(rest, json);
121
+ return;
102
122
  case "version":
103
123
  case "-v":
104
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
  }
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
+ }