svcloud 0.1.0-alpha.3

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * `svcloud init` — PLANNING.md §3.1/§4 Step 3: the connect flow, covering
3
+ * what `cloud-web`'s NameApp → ConnectCode → FirstBuild screens do. No new
4
+ * `cloud-api` route — every call here already exists and is inside
5
+ * `svcloud-cli`'s scopes.
6
+ */
7
+ import type {
8
+ ProjectDetail,
9
+ ProvisionAccepted,
10
+ RunDetail,
11
+ } from "@sv/cloud-contracts";
12
+ import { apiFetch } from "../lib/api";
13
+ import { openBrowser } from "../lib/browser";
14
+ import { inferRepoFromCwd } from "../lib/git";
15
+ import { die, printJson, takeOption } from "../lib/output";
16
+ import { choose, isInteractive, prompt } from "../lib/prompt";
17
+ import { pollRun } from "../lib/run-poll";
18
+
19
+ interface Installation {
20
+ id: number;
21
+ github_account_login: string;
22
+ created_at: string;
23
+ }
24
+
25
+ interface InstallationRepo {
26
+ name: string;
27
+ full_name: string;
28
+ empty: boolean;
29
+ pushed_at: string | null;
30
+ }
31
+
32
+ interface InstallationContext {
33
+ account_login: string;
34
+ repos: InstallationRepo[];
35
+ }
36
+
37
+ interface SubdomainCheck {
38
+ available: boolean;
39
+ reason?: "reserved" | "invalid" | "taken";
40
+ }
41
+
42
+ function slugify(input: string): string {
43
+ return input
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9-]+/g, "-")
46
+ .replace(/-{2,}/g, "-")
47
+ .replace(/^-+|-+$/g, "");
48
+ }
49
+
50
+ function slugReason(reason: SubdomainCheck["reason"]): string {
51
+ switch (reason) {
52
+ case "taken":
53
+ return "already in use";
54
+ case "reserved":
55
+ return "reserved";
56
+ case "invalid":
57
+ return "not a valid web address (lowercase letters, numbers, and dashes, 3-63 characters)";
58
+ default:
59
+ return "unavailable";
60
+ }
61
+ }
62
+
63
+ async function checkSlug(slug: string): Promise<SubdomainCheck> {
64
+ return apiFetch<SubdomainCheck>(`/api/v1/projects/subdomain?slug=${encodeURIComponent(slug)}`);
65
+ }
66
+
67
+ async function resolveSlug(desired: string, interactive: boolean): Promise<string> {
68
+ let candidate = desired;
69
+ for (;;) {
70
+ const check = await checkSlug(candidate);
71
+ if (check.available) return candidate;
72
+ if (!interactive) {
73
+ die(
74
+ `Web address "${candidate}" is ${slugReason(check.reason)}. Pass a different one with --slug.`,
75
+ );
76
+ }
77
+ console.log(`"${candidate}" is ${slugReason(check.reason)}.`);
78
+ candidate = slugify(await prompt("Try a different web address"));
79
+ if (!candidate) console.log("That's empty once turned into a web address — try again.");
80
+ }
81
+ }
82
+
83
+ async function resolveInstallation(
84
+ fullName: string,
85
+ flagValue: string | undefined,
86
+ interactive: boolean,
87
+ ): Promise<number> {
88
+ if (flagValue) {
89
+ const id = Number.parseInt(flagValue, 10);
90
+ if (!Number.isInteger(id) || id <= 0) die(`--installation must be a positive integer, got "${flagValue}".`);
91
+ return id;
92
+ }
93
+
94
+ const { installations } = await apiFetch<{ installations: Installation[] }>("/api/v1/installations");
95
+
96
+ if (installations.length === 0) {
97
+ return await installGitHubAppAndWait(interactive);
98
+ }
99
+
100
+ if (installations.length === 1) {
101
+ return (installations[0] as Installation).id;
102
+ }
103
+
104
+ // Several installations: prefer the one whose repos actually include this repo.
105
+ const matches: Installation[] = [];
106
+ for (const inst of installations) {
107
+ const ctx = await apiFetch<InstallationContext>(`/api/v1/installations/${inst.id}`);
108
+ if (ctx.repos.some((r) => r.full_name.toLowerCase() === fullName.toLowerCase())) {
109
+ matches.push(inst);
110
+ }
111
+ }
112
+ if (matches.length === 1) return (matches[0] as Installation).id;
113
+
114
+ const candidates = matches.length > 0 ? matches : installations;
115
+ if (!interactive) {
116
+ die(
117
+ `Multiple GitHub connections could own "${fullName}". Pass --installation with one of: ${candidates
118
+ .map((i) => `${i.id} (${i.github_account_login})`)
119
+ .join(", ")}.`,
120
+ );
121
+ }
122
+ const idx = await choose(
123
+ "Which GitHub connection has this repo?",
124
+ candidates.map((i) => `${i.github_account_login} (installation ${i.id})`),
125
+ );
126
+ return (candidates[idx] as Installation).id;
127
+ }
128
+
129
+ async function installGitHubAppAndWait(interactive: boolean): Promise<number> {
130
+ if (!interactive) {
131
+ die(
132
+ "No GitHub connection found on your account. Run `svcloud init` from a real terminal " +
133
+ "to install the GitHub App, or connect it first at the dashboard.",
134
+ );
135
+ }
136
+ const { url } = await apiFetch<{ url: string }>("/api/v1/installations/connect-url");
137
+ console.log("No GitHub connection found. Opening your browser to install the SV Cloud GitHub App...");
138
+ console.log(`If it doesn't open, visit:\n ${url}`);
139
+ openBrowser(url);
140
+
141
+ console.log("Waiting for the install to finish...");
142
+ const timeoutMs = 5 * 60 * 1000;
143
+ const intervalMs = 3000;
144
+ const start = Date.now();
145
+ for (;;) {
146
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
147
+ const { installations } = await apiFetch<{ installations: Installation[] }>("/api/v1/installations");
148
+ if (installations.length > 0) return (installations[0] as Installation).id;
149
+ if (Date.now() - start > timeoutMs) {
150
+ die("Timed out waiting for the GitHub App install. Run `svcloud init` again once it's done.");
151
+ }
152
+ }
153
+ }
154
+
155
+ function isProvisionAccepted(x: ProjectDetail | ProvisionAccepted): x is ProvisionAccepted {
156
+ return "run_id" in x;
157
+ }
158
+
159
+ export async function initCommand(argv: string[], json: boolean): Promise<void> {
160
+ const repoOpt = takeOption(argv, "repo");
161
+ const nameOpt = takeOption(repoOpt.rest, "name");
162
+ const slugOpt = takeOption(nameOpt.rest, "slug");
163
+ const installOpt = takeOption(slugOpt.rest, "installation");
164
+
165
+ const interactive = isInteractive();
166
+
167
+ let repoFullName = repoOpt.value;
168
+ if (!repoFullName) {
169
+ const inferred = await inferRepoFromCwd();
170
+ if (!inferred) {
171
+ die(
172
+ "Couldn't find a GitHub remote in this directory. Run this from a repo with an " +
173
+ '"origin" remote, or pass --repo <owner/name>.',
174
+ );
175
+ }
176
+ repoFullName = inferred.fullName;
177
+ }
178
+ const repoName = (repoFullName.split("/")[1] ?? repoFullName).toLowerCase();
179
+
180
+ const name = nameOpt.value ?? repoName;
181
+ const desiredSlug = slugOpt.value ? slugify(slugOpt.value) : slugify(repoName);
182
+ const slug = await resolveSlug(desiredSlug || "my-app", interactive);
183
+
184
+ const installationId = await resolveInstallation(repoFullName, installOpt.value, interactive);
185
+
186
+ if (!json) console.log(`Connecting ${repoFullName} as "${name}" (${slug})...`);
187
+
188
+ const result = await apiFetch<ProjectDetail | ProvisionAccepted>("/api/v1/projects/connect", {
189
+ method: "POST",
190
+ body: {
191
+ name,
192
+ repo_full_name: repoFullName,
193
+ github_installation_id: installationId,
194
+ requested_subdomain: slug,
195
+ framework_type: "hono",
196
+ scheduled_cleanup_at: null,
197
+ },
198
+ });
199
+
200
+ if (!isProvisionAccepted(result)) {
201
+ // Already connected — nothing to provision.
202
+ if (json) {
203
+ printJson(result);
204
+ return;
205
+ }
206
+ console.log(`Already connected: ${result.web_address ?? "(no web address yet)"}`);
207
+ return;
208
+ }
209
+
210
+ if (!json) console.log("Provisioning...");
211
+ const run = await pollRun(() => apiFetch<RunDetail>(`/api/v1/runs/${result.run_id}`), {
212
+ resumeHint: `svcloud runs ${slug}`,
213
+ quiet: json,
214
+ });
215
+
216
+ if (run.status === "failed") {
217
+ if (json) {
218
+ printJson({ run });
219
+ return;
220
+ }
221
+ die(`Provisioning failed: ${run.error?.message ?? "unknown error"}`);
222
+ }
223
+
224
+ const project = await apiFetch<ProjectDetail>(`/api/v1/projects/${result.project_id}`);
225
+
226
+ if (json) {
227
+ printJson({ project, run });
228
+ return;
229
+ }
230
+
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
+ );
236
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `svcloud login` — PLANNING.md §2's flow, steps 1-7, end to end.
3
+ */
4
+ import { randomBytes } from "node:crypto";
5
+ import { openBrowser } from "../lib/browser";
6
+ import { challengeFromVerifier, generateVerifier } from "../lib/pkce";
7
+ import { apiFetch } from "../lib/api";
8
+ import { CLIENT_ID } from "../lib/config";
9
+ import { saveTokens, type TokenSet } from "../lib/credentials";
10
+ import { getAuthServerMetadata } from "../lib/discovery";
11
+ import { startLoopbackListener } from "../lib/loopback-server";
12
+ import { die } from "../lib/output";
13
+
14
+ interface TokenResponse {
15
+ access_token: string;
16
+ refresh_token: string;
17
+ expires_in: number;
18
+ scope: string;
19
+ }
20
+
21
+ interface Account {
22
+ email: string;
23
+ display_name: string | null;
24
+ }
25
+
26
+ const CODE_TTL_MS = 5 * 60 * 1000;
27
+
28
+ export async function loginCommand(): Promise<void> {
29
+ const verifier = generateVerifier();
30
+ const challenge = challengeFromVerifier(verifier);
31
+ const state = randomBytes(16).toString("base64url");
32
+
33
+ const meta = await getAuthServerMetadata();
34
+ const listener = await startLoopbackListener();
35
+ const redirectUri = `http://127.0.0.1:${listener.port}/callback`;
36
+
37
+ const authorizeUrl = new URL(meta.authorization_endpoint);
38
+ authorizeUrl.searchParams.set("response_type", "code");
39
+ authorizeUrl.searchParams.set("client_id", CLIENT_ID);
40
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
41
+ authorizeUrl.searchParams.set("code_challenge", challenge);
42
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
43
+ authorizeUrl.searchParams.set("state", state);
44
+
45
+ console.log("Opening your browser to sign in to SV Cloud...");
46
+ console.log(`If it doesn't open, visit:\n ${authorizeUrl.toString()}`);
47
+ openBrowser(authorizeUrl.toString());
48
+
49
+ let result: Awaited<ReturnType<typeof listener.waitForCallback>>;
50
+ try {
51
+ result = await listener.waitForCallback(CODE_TTL_MS);
52
+ } finally {
53
+ listener.close();
54
+ }
55
+
56
+ if (result.error || !result.code) {
57
+ die(`Sign-in didn't finish${result.error ? `: ${result.error}` : "."}`);
58
+ }
59
+ if (result.state !== state) {
60
+ die("Sign-in response didn't match this request. Run `svcloud login` again.");
61
+ }
62
+
63
+ const tokenRes = await fetch(meta.token_endpoint, {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({
67
+ grant_type: "authorization_code",
68
+ code: result.code,
69
+ code_verifier: verifier,
70
+ client_id: CLIENT_ID,
71
+ redirect_uri: redirectUri,
72
+ }),
73
+ });
74
+ if (!tokenRes.ok) {
75
+ die("Could not complete sign-in. Run `svcloud login` again.");
76
+ }
77
+ const body = (await tokenRes.json()) as TokenResponse;
78
+ const tokens: TokenSet = {
79
+ accessToken: body.access_token,
80
+ refreshToken: body.refresh_token,
81
+ expiresAt: Date.now() + body.expires_in * 1000,
82
+ scopes: body.scope.split(" ").filter(Boolean),
83
+ };
84
+ await saveTokens(tokens);
85
+
86
+ const account = await apiFetch<Account>("/api/v1/auth/me");
87
+ console.log(`Signed in as ${account.email}.`);
88
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `svcloud logout` — revokes the stored refresh token (which revokes its
3
+ * paired access token too, per `apps/cloud/src/services/oauth.ts`'s
4
+ * `revokePair`), then clears local storage either way. RFC 7009: revoking
5
+ * an already-invalid token is a success, not an error, so a failed network
6
+ * call still clears local state rather than leaving the user stuck signed
7
+ * in locally against a session cloud-api no longer honours.
8
+ */
9
+ import { CLIENT_ID } from "../lib/config";
10
+ import { clearTokens, loadTokens } from "../lib/credentials";
11
+ import { getAuthServerMetadata } from "../lib/discovery";
12
+
13
+ export async function logoutCommand(): Promise<void> {
14
+ const tokens = await loadTokens();
15
+ if (!tokens) {
16
+ console.log("Already signed out.");
17
+ return;
18
+ }
19
+ try {
20
+ const meta = await getAuthServerMetadata();
21
+ await fetch(meta.revocation_endpoint, {
22
+ method: "POST",
23
+ headers: { "content-type": "application/json" },
24
+ body: JSON.stringify({ token: tokens.refreshToken, client_id: CLIENT_ID }),
25
+ });
26
+ } catch {
27
+ // Best-effort: local state is cleared below regardless.
28
+ }
29
+ await clearTokens();
30
+ console.log("Signed out.");
31
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `svcloud mcp` — the local MCP bridge (PLANNING.md §1.5, §3.5). An MCP
3
+ * server toward the coding agent on stdio, an MCP client toward cloud-api's
4
+ * `POST /mcp` over HTTPS, signed with the session `svcloud login` already
5
+ * put in the OS keychain. It is a transport, not a second implementation:
6
+ * the tool registry stays server-side
7
+ * (`apps/cloud/src/services/mcp/tools.ts`), so a tool change needs no
8
+ * release here — this file forwards `initialize`/`tools/list`/`tools/call`
9
+ * and returns exactly what came back.
10
+ *
11
+ * Rules, in the order they bite (§3.5):
12
+ * 1. stdout is JSON-RPC only — every diagnostic goes to stderr. Enforced
13
+ * below by rebinding console.log/info for this command's lifetime, so
14
+ * a stray call from an imported module can't corrupt the stream too.
15
+ * 2. Pure pass-through — no caching/re-ordering `tools/list`, no
16
+ * unwrapping `untrustedResult()`'s note, no dropping `approximate:
17
+ * true`, no turning `isError: true` into a protocol error. Forwarding
18
+ * is done with a raw `fetch`, not `lib/api.ts`'s `apiFetch`: `/mcp`
19
+ * returns a JSON-RPC body on a 400 (malformed request) exactly as
20
+ * often as on a 200 (protocol-level tool error), and `apiFetch`'s
21
+ * REST error-envelope parsing would swallow that body instead of
22
+ * forwarding it.
23
+ * 3. Notifications (no `id`) are consumed locally, never forwarded and
24
+ * never answered — the remote is a stateless request/response
25
+ * endpoint with nothing to notify.
26
+ * 4. Concurrency by id — each request is its own fetch, launched without
27
+ * awaiting the previous one, so a slow `tools/call` never blocks the
28
+ * next line read from stdin.
29
+ * 5. Auth failures do not open a browser. With no stored session, answer
30
+ * the request in-band and keep serving; the moment a `svcloud login`
31
+ * lands elsewhere, the next call works.
32
+ * 6. Refresh is shared and racy — handled once, in `lib/api.ts`
33
+ * (`currentAccessToken`/`forceRefreshAccessToken`), not duplicated
34
+ * here.
35
+ * 7. Transport failure (a timeout, a 502, a dropped connection) is a
36
+ * per-request JSON-RPC error, not a crash — the process keeps serving.
37
+ * 8. Log metadata, never payloads — tool results carry attacker-writable
38
+ * text (log lines, database rows).
39
+ */
40
+ import { createInterface } from "node:readline";
41
+ import { AuthRequiredError, currentAccessToken, forceRefreshAccessToken } from "../lib/api";
42
+ import { apiBaseUrl } from "../lib/config";
43
+ import { loadTokens } from "../lib/credentials";
44
+
45
+ type JsonRpcId = string | number | null;
46
+
47
+ const NOT_SIGNED_IN_MESSAGE = "Not signed in to SV Cloud; the owner needs to run `svcloud login`.";
48
+
49
+ function stderrLine(line: string): void {
50
+ process.stderr.write(`${line}\n`);
51
+ }
52
+
53
+ function writeLine(payload: unknown): void {
54
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
55
+ }
56
+
57
+ function jsonRpcError(id: JsonRpcId, message: string): { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string } } {
58
+ // -32000 is JSON-RPC's reserved "server error" range start — these are
59
+ // local bridge failures (auth, transport), distinct from the protocol
60
+ // errors (-326xx) the remote itself returns and that pass straight
61
+ // through untouched.
62
+ return { jsonrpc: "2.0", id, error: { code: -32000, message } };
63
+ }
64
+
65
+ /** A JSON-RPC request with no `id` is a notification. A non-object line still gets `id: null` so it can receive an error reply. */
66
+ function readEnvelope(msg: unknown): { isNotification: boolean; id: JsonRpcId; method: string } {
67
+ if (typeof msg !== "object" || msg === null) return { isNotification: false, id: null, method: "?" };
68
+ const rec = msg as Record<string, unknown>;
69
+ const method = typeof rec.method === "string" ? rec.method : "?";
70
+ if (rec.id === undefined) return { isNotification: true, id: null, method };
71
+ return { isNotification: false, id: rec.id as JsonRpcId, method };
72
+ }
73
+
74
+ let warnedNotSignedIn = false;
75
+
76
+ function warnNotSignedInOnce(): void {
77
+ if (warnedNotSignedIn) return;
78
+ warnedNotSignedIn = true;
79
+ stderrLine(NOT_SIGNED_IN_MESSAGE);
80
+ }
81
+
82
+ type RemoteResult =
83
+ | { ok: true; body: unknown }
84
+ | { ok: false; reason: "auth" }
85
+ | { ok: false; reason: "transport"; detail: string };
86
+
87
+ /** One request to `POST /mcp`, refreshing once on a 401, whatever the eventual status. Never throws. */
88
+ async function callRemote(msg: unknown): Promise<RemoteResult> {
89
+ let accessToken: string;
90
+ try {
91
+ accessToken = await currentAccessToken();
92
+ } catch (err) {
93
+ if (err instanceof AuthRequiredError) return { ok: false, reason: "auth" };
94
+ return { ok: false, reason: "transport", detail: err instanceof Error ? err.message : String(err) };
95
+ }
96
+
97
+ const post = (token: string) =>
98
+ fetch(`${apiBaseUrl()}/mcp`, {
99
+ method: "POST",
100
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
101
+ body: JSON.stringify(msg),
102
+ });
103
+
104
+ let res: Response;
105
+ try {
106
+ res = await post(accessToken);
107
+ if (res.status === 401) {
108
+ let refreshed: string;
109
+ try {
110
+ refreshed = await forceRefreshAccessToken();
111
+ } catch (err) {
112
+ if (err instanceof AuthRequiredError) return { ok: false, reason: "auth" };
113
+ return { ok: false, reason: "transport", detail: err instanceof Error ? err.message : String(err) };
114
+ }
115
+ res = await post(refreshed);
116
+ }
117
+ } catch (err) {
118
+ return { ok: false, reason: "transport", detail: err instanceof Error ? err.message : String(err) };
119
+ }
120
+
121
+ try {
122
+ return { ok: true, body: await res.json() };
123
+ } catch {
124
+ return { ok: false, reason: "transport", detail: `unexpected response (status ${res.status})` };
125
+ }
126
+ }
127
+
128
+ async function forwardToRemote(msg: unknown, id: JsonRpcId, method: string): Promise<void> {
129
+ const result = await callRemote(msg);
130
+ if (result.ok) {
131
+ writeLine(result.body); // rule 2 — verbatim, whatever shape it is.
132
+ return;
133
+ }
134
+ if (result.reason === "auth") {
135
+ warnNotSignedInOnce();
136
+ writeLine(jsonRpcError(id, NOT_SIGNED_IN_MESSAGE));
137
+ return;
138
+ }
139
+ stderrLine(`mcp: transport error on ${method} (id ${String(id)}): ${result.detail}`);
140
+ writeLine(jsonRpcError(id, "SV Cloud request failed. Try again."));
141
+ }
142
+
143
+ /** Best-effort startup diagnostic (PLANNING.md §3.6) — never blocks serving. */
144
+ async function logStartupState(): Promise<void> {
145
+ const tokens = await loadTokens();
146
+ if (!tokens) {
147
+ stderrLine(`svcloud mcp: ${NOT_SIGNED_IN_MESSAGE}`);
148
+ return;
149
+ }
150
+ stderrLine(`svcloud mcp: signed in, ${tokens.scopes.length} scopes.`);
151
+ const result = await callRemote({ jsonrpc: "2.0", id: "__svcloud_mcp_startup__", method: "tools/list", params: {} });
152
+ if (result.ok) {
153
+ const body = result.body as { result?: { tools?: unknown[] } };
154
+ const count = body.result?.tools?.length;
155
+ if (typeof count === "number") stderrLine(`svcloud mcp: ${count} tools visible to this token.`);
156
+ }
157
+ }
158
+
159
+ export async function mcpCommand(): Promise<void> {
160
+ // Rule 1: stdout is JSON-RPC only. Nothing in this command's own code
161
+ // calls console.log, but rebinding here means an imported module doing
162
+ // so can't corrupt the stream either.
163
+ console.log = (...args: unknown[]) => stderrLine(args.map(String).join(" "));
164
+ console.info = console.log;
165
+
166
+ await logStartupState();
167
+
168
+ const rl = createInterface({ input: process.stdin, terminal: false });
169
+ rl.on("line", (line) => {
170
+ const trimmed = line.trim();
171
+ if (!trimmed) return;
172
+ let msg: unknown;
173
+ try {
174
+ msg = JSON.parse(trimmed);
175
+ } catch {
176
+ stderrLine("mcp: dropped a non-JSON line from stdin.");
177
+ return;
178
+ }
179
+ const { isNotification, id, method } = readEnvelope(msg);
180
+ if (isNotification) return; // rule 3.
181
+ void forwardToRemote(msg, id, method); // rule 4 — fire and forget, matched by id.
182
+ });
183
+
184
+ await new Promise<void>((resolve) => rl.on("close", resolve));
185
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `svcloud open <slug>` — PLANNING.md §3.1: no endpoint of its own, just
3
+ * `GET /projects?slug=` and the row's `web_address`.
4
+ */
5
+ import { openBrowser } from "../lib/browser";
6
+ import { findProjectBySlug } from "../lib/find-project";
7
+ import { die, printJson } from "../lib/output";
8
+
9
+ export async function openCommand(slug: string | undefined, json: boolean): Promise<void> {
10
+ if (!slug) {
11
+ die("Usage: svcloud open <app>\nRun `svcloud projects list` to see your apps.");
12
+ }
13
+
14
+ const project = await findProjectBySlug(slug);
15
+ if (!project) die(`No app named "${slug}".`);
16
+ if (!project.web_address) die(`"${slug}" doesn't have a web address yet.`);
17
+
18
+ if (json) {
19
+ printJson({ web_address: project.web_address });
20
+ return;
21
+ }
22
+
23
+ console.log(`Opening ${project.web_address}`);
24
+ openBrowser(project.web_address);
25
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `svcloud projects list` — PLANNING.md §3.1: "falls out of status for free."
3
+ */
4
+ import type { ProjectSummary } from "@sv/cloud-contracts";
5
+ import { apiFetch } from "../lib/api";
6
+ import { die, printJson, printTable } from "../lib/output";
7
+
8
+ export async function projectsCommand(subcommand: string | undefined, json: boolean): Promise<void> {
9
+ if (subcommand !== "list") {
10
+ die(`Usage: svcloud projects list${subcommand ? `\nUnknown subcommand: ${subcommand}` : ""}`);
11
+ }
12
+
13
+ const { projects } = await apiFetch<{ projects: ProjectSummary[] }>("/api/v1/projects");
14
+
15
+ if (json) {
16
+ printJson(projects);
17
+ return;
18
+ }
19
+
20
+ printTable(
21
+ projects.map((p) => ({
22
+ App: p.name,
23
+ // status and open take the SLUG, not the name shown in the App column
24
+ // (they resolve it via GET /projects?slug=) - without this column,
25
+ // "my-app" vs "My App" was a silent lookup failure the table gave no
26
+ // way to debug.
27
+ Slug: p.slug,
28
+ "Web address": p.web_address ?? "(none yet)",
29
+ Status: p.needs_initialization ? "setting up" : (p.latest_deploy_status ?? p.status),
30
+ })),
31
+ ["App", "Slug", "Web address", "Status"],
32
+ );
33
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `svcloud runs <app>` — PLANNING.md §3.1/§4 Step 3. Watches or reads a
3
+ * provision/teardown run. `--id` reads `GET /runs/:id` directly and never
4
+ * touches `/projects` — that route is deliberately not nested under a
5
+ * project, since a rolled-back connect deletes the `projects` row but the
6
+ * run explaining why must still be readable.
7
+ */
8
+ import type { RunDetail } from "@sv/cloud-contracts";
9
+ import { apiFetch } from "../lib/api";
10
+ import { findProjectBySlug } from "../lib/find-project";
11
+ import { die, hasFlag, printJson, printTable, takeOption } from "../lib/output";
12
+ import { pollRun } from "../lib/run-poll";
13
+
14
+ function printSnapshot(run: RunDetail): void {
15
+ console.log(`Run ${run.id} — ${run.kind} — ${run.status}`);
16
+ printTable(
17
+ run.steps.map((s) => ({ Step: s.label, Status: s.status })),
18
+ ["Step", "Status"],
19
+ );
20
+ if (run.status === "failed" && run.error) {
21
+ console.log(`Error: ${run.error.message ?? run.error.code ?? "unknown error"}`);
22
+ }
23
+ }
24
+
25
+ function printFinal(run: RunDetail): void {
26
+ if (run.status === "failed") {
27
+ console.log(`Run failed: ${run.error?.message ?? "unknown error"}`);
28
+ return;
29
+ }
30
+ console.log(`Run ${run.status}.`);
31
+ }
32
+
33
+ export async function runsCommand(argv: string[], json: boolean): Promise<void> {
34
+ const idOpt = takeOption(argv, "id");
35
+ const { present: watch, rest } = hasFlag(idOpt.rest, "watch");
36
+ const slug = rest[0];
37
+
38
+ let fetchRun: () => Promise<RunDetail>;
39
+ let resumeHint: string;
40
+
41
+ if (idOpt.value) {
42
+ const runId = idOpt.value;
43
+ fetchRun = () => apiFetch<RunDetail>(`/api/v1/runs/${runId}`);
44
+ resumeHint = `svcloud runs --id ${runId}`;
45
+ } else {
46
+ if (!slug) die("Usage: svcloud runs <app> [--id <run-id>] [--watch]");
47
+ const project = await findProjectBySlug(slug);
48
+ if (!project) die(`No app named "${slug}".`);
49
+ fetchRun = () => apiFetch<RunDetail>(`/api/v1/projects/${project.id}/runs/latest`);
50
+ resumeHint = `svcloud runs ${slug}`;
51
+ }
52
+
53
+ if (!watch) {
54
+ const run = await fetchRun();
55
+ if (json) {
56
+ printJson(run);
57
+ return;
58
+ }
59
+ printSnapshot(run);
60
+ return;
61
+ }
62
+
63
+ const run = await pollRun(fetchRun, { resumeHint, quiet: json });
64
+ if (json) {
65
+ printJson(run);
66
+ return;
67
+ }
68
+ printFinal(run);
69
+ }