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 +29 -0
- package/package.json +1 -1
- package/src/commands/bundle.ts +224 -0
- package/src/commands/promote.ts +85 -0
- package/src/commands/staging.ts +126 -0
- package/src/commands/storage.ts +108 -0
- package/src/index.ts +20 -0
- package/src/lib/browser.ts +41 -7
- package/src/lib/git.ts +33 -0
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.
|
|
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":
|
package/src/lib/browser.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|