svcloud 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svcloud",
3
- "version": "0.1.5",
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,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
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ import { projectsCommand } from "./commands/projects";
17
17
  import { runsCommand } from "./commands/runs";
18
18
  import { secretsCommand } from "./commands/secrets";
19
19
  import { storageCommand } from "./commands/storage";
20
+ import { promoteCommand } from "./commands/promote";
21
+ import { stagingCommand } from "./commands/staging";
20
22
  import { statusCommand } from "./commands/status";
21
23
  import { whoamiCommand } from "./commands/whoami";
22
24
  import { AuthRequiredError, ApiError } from "./lib/api";
@@ -38,6 +40,8 @@ Usage:
38
40
  svcloud storage presign Get a direct URL for one file in an app's storage
39
41
  svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
40
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
41
45
  svcloud bundle <cmd> Keep an app's starter current (status/update/done)
42
46
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
43
47
  svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
@@ -109,6 +113,12 @@ async function main(): Promise<void> {
109
113
  case "deploy":
110
114
  await deployCommand(rest, json);
111
115
  return;
116
+ case "staging":
117
+ await stagingCommand(rest, json);
118
+ return;
119
+ case "promote":
120
+ await promoteCommand(rest, json);
121
+ return;
112
122
  case "version":
113
123
  case "-v":
114
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
+ }