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,89 @@
1
+ /**
2
+ * `svcloud secrets` — PLANNING.md §4 Step 5. F10 "keys & settings" is
3
+ * zero-knowledge (`apps/cloud/src/routes/secrets.ts`'s file header): a value
4
+ * is written straight to the app's runtime and never stored or returned by
5
+ * `cloud-api` again. `list` therefore only ever shows names — there is no
6
+ * `svcloud secrets get`, by design, not an oversight.
7
+ */
8
+ import { apiFetch } from "../lib/api";
9
+ import { findProjectBySlug } from "../lib/find-project";
10
+ import { die, printJson, printTable } from "../lib/output";
11
+ import { isInteractive } from "../lib/prompt";
12
+
13
+ const USAGE = `Usage:
14
+ svcloud secrets list <app>
15
+ svcloud secrets set <app> <key> [value] Reads from stdin if value is omitted
16
+ svcloud secrets remove <app> <key>`;
17
+
18
+ async function resolveProjectId(slug: string | undefined): Promise<string> {
19
+ if (!slug) die(USAGE);
20
+ const project = await findProjectBySlug(slug);
21
+ if (!project) die(`No app named "${slug}".`);
22
+ return project.id;
23
+ }
24
+
25
+ /** A value typed directly on the command line lands in shell history and `ps` output — reading from stdin avoids that. */
26
+ async function readStdin(): Promise<string> {
27
+ const chunks: Buffer[] = [];
28
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
29
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
30
+ }
31
+
32
+ export async function secretsCommand(argv: string[], json: boolean): Promise<void> {
33
+ const [subcommand, ...rest] = argv;
34
+
35
+ switch (subcommand) {
36
+ case "list": {
37
+ const projectId = await resolveProjectId(rest[0]);
38
+ const { keys } = await apiFetch<{ keys: string[] }>(
39
+ `/api/v1/projects/${projectId}/settings`,
40
+ );
41
+ if (json) {
42
+ printJson(keys);
43
+ return;
44
+ }
45
+ printTable(
46
+ keys.map((key) => ({ Key: key })),
47
+ ["Key"],
48
+ );
49
+ return;
50
+ }
51
+ case "set": {
52
+ const [slug, key, inlineValue] = rest;
53
+ if (!key) die(USAGE);
54
+ const projectId = await resolveProjectId(slug);
55
+ let value = inlineValue;
56
+ if (value === undefined) {
57
+ if (isInteractive() && !json) console.error(`Enter value for "${key}", then press Ctrl-D:`);
58
+ value = await readStdin();
59
+ }
60
+ if (!value) die('Value must be a non-empty string.');
61
+ await apiFetch(`/api/v1/projects/${projectId}/settings/${encodeURIComponent(key)}`, {
62
+ method: "PUT",
63
+ body: { value },
64
+ });
65
+ if (json) {
66
+ printJson({ key, set: true });
67
+ return;
68
+ }
69
+ console.log(`Set "${key}".`);
70
+ return;
71
+ }
72
+ case "remove": {
73
+ const [slug, key] = rest;
74
+ if (!key) die(USAGE);
75
+ const projectId = await resolveProjectId(slug);
76
+ await apiFetch(`/api/v1/projects/${projectId}/settings/${encodeURIComponent(key)}`, {
77
+ method: "DELETE",
78
+ });
79
+ if (json) {
80
+ printJson({ key, removed: true });
81
+ return;
82
+ }
83
+ console.log(`Removed "${key}".`);
84
+ return;
85
+ }
86
+ default:
87
+ die(USAGE);
88
+ }
89
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `svcloud status <slug>` — PLANNING.md §3.1: render the health endpoint's
3
+ * plain-language traffic light as-is, don't re-word it.
4
+ */
5
+ import { findProjectBySlug } from "../lib/find-project";
6
+ import { apiFetch } from "../lib/api";
7
+ import { die, printJson } from "../lib/output";
8
+
9
+ interface Health {
10
+ status: "green" | "yellow" | "red" | string;
11
+ message: string;
12
+ updated_at: string;
13
+ }
14
+
15
+ export async function statusCommand(slug: string | undefined, json: boolean): Promise<void> {
16
+ if (!slug) {
17
+ die("Usage: svcloud status <app>\nRun `svcloud projects list` to see your apps.");
18
+ }
19
+
20
+ const project = await findProjectBySlug(slug);
21
+ if (!project) die(`No app named "${slug}".`);
22
+
23
+ const health = await apiFetch<Health>(`/api/v1/projects/${project.id}/health`);
24
+
25
+ if (json) {
26
+ printJson({ ...project, health });
27
+ return;
28
+ }
29
+
30
+ console.log(project.name);
31
+ console.log(` ${project.web_address ?? "(no web address yet)"}`);
32
+ console.log(` ${health.message}`);
33
+ if (project.needs_initialization) {
34
+ console.log(" This app hasn't finished setting up yet.");
35
+ }
36
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `svcloud whoami` — after `login`, nothing else showed which account was
3
+ * signed in. `GET /auth/me` already exists (cloud-web's AppShell auth guard
4
+ * uses it the same way); this just exposes it.
5
+ */
6
+ import { apiFetch } from "../lib/api";
7
+ import { printJson } from "../lib/output";
8
+
9
+ interface Account {
10
+ id: string;
11
+ email: string;
12
+ display_name: string | null;
13
+ mode: "simple" | "advanced";
14
+ created_at: string;
15
+ }
16
+
17
+ export async function whoamiCommand(json: boolean): Promise<void> {
18
+ const account = await apiFetch<Account>("/api/v1/auth/me");
19
+
20
+ if (json) {
21
+ printJson(account);
22
+ return;
23
+ }
24
+
25
+ console.log(account.display_name ? `${account.display_name} <${account.email}>` : account.email);
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Argument parsing and command dispatch — PLANNING.md §4 Step 1:
4
+ * `login`, `logout`, `whoami`, `status`, `open`, `projects list`.
5
+ * Hand-rolled rather than a dependency: six commands, one global flag, no
6
+ * subcommand ambiguity worth a parser library yet.
7
+ */
8
+ import { dbCommand } from "./commands/db";
9
+ import { deployCommand } from "./commands/deploy";
10
+ import { initCommand } from "./commands/init";
11
+ import { loginCommand } from "./commands/login";
12
+ import { logoutCommand } from "./commands/logout";
13
+ import { mcpCommand } from "./commands/mcp";
14
+ import { openCommand } from "./commands/open";
15
+ import { projectsCommand } from "./commands/projects";
16
+ import { runsCommand } from "./commands/runs";
17
+ import { secretsCommand } from "./commands/secrets";
18
+ import { statusCommand } from "./commands/status";
19
+ import { whoamiCommand } from "./commands/whoami";
20
+ import { AuthRequiredError, ApiError } from "./lib/api";
21
+ import { hasFlag } from "./lib/output";
22
+
23
+ const USAGE = `svcloud — the SV Cloud CLI
24
+
25
+ Usage:
26
+ svcloud login Sign in to SV Cloud
27
+ svcloud logout Sign out
28
+ svcloud whoami Show the signed-in account
29
+ svcloud status <app> Show an app's health
30
+ svcloud open <app> Open an app's web address in your browser
31
+ svcloud projects list List your apps
32
+ svcloud init Connect a local repo as a new app
33
+ svcloud runs <app> Show or watch an app's provisioning run
34
+ svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
35
+ svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
36
+ svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
37
+ svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
38
+
39
+ Flags:
40
+ --json Machine-readable output, on every read command
41
+ --repo, --name, --slug, --installation, --id, --watch, --limit, --primary-key, --default
42
+ See each command's own usage
43
+ `;
44
+
45
+ async function main(): Promise<void> {
46
+ const { present: json, rest: argv } = hasFlag(process.argv.slice(2), "json");
47
+ const [command, ...rest] = argv;
48
+
49
+ switch (command) {
50
+ case "login":
51
+ await loginCommand();
52
+ return;
53
+ case "logout":
54
+ await logoutCommand();
55
+ return;
56
+ case "whoami":
57
+ await whoamiCommand(json);
58
+ return;
59
+ case "status":
60
+ await statusCommand(rest[0], json);
61
+ return;
62
+ case "open":
63
+ await openCommand(rest[0], json);
64
+ return;
65
+ case "projects":
66
+ await projectsCommand(rest[0], json);
67
+ return;
68
+ case "mcp":
69
+ await mcpCommand();
70
+ return;
71
+ case "init":
72
+ await initCommand(rest, json);
73
+ return;
74
+ case "runs":
75
+ await runsCommand(rest, json);
76
+ return;
77
+ case "secrets":
78
+ await secretsCommand(rest, json);
79
+ return;
80
+ case "db":
81
+ await dbCommand(rest, json);
82
+ return;
83
+ case "deploy":
84
+ await deployCommand(rest, json);
85
+ return;
86
+ case undefined:
87
+ case "-h":
88
+ case "--help":
89
+ console.log(USAGE);
90
+ return;
91
+ default:
92
+ console.error(`Unknown command: ${command}\n`);
93
+ console.log(USAGE);
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ main().catch((err: unknown) => {
99
+ if (err instanceof AuthRequiredError) {
100
+ console.error(err.message);
101
+ } else if (err instanceof ApiError) {
102
+ console.error(err.message);
103
+ } else {
104
+ console.error(err instanceof Error ? err.message : String(err));
105
+ }
106
+ process.exit(1);
107
+ });
package/src/lib/api.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The one place that talks to cloud-api over HTTP — mirrors
3
+ * `apps/cloud-web/src/lib/http.ts`'s shape deliberately (same error
4
+ * envelope, same idea of one fetch wrapper) so the two clients don't drift
5
+ * in how they read the same API.
6
+ *
7
+ * Adds the one thing a long-lived CLI process needs that a browser tab
8
+ * doesn't: transparent access-token refresh. A browser session dies with the
9
+ * tab; a `svcloud status` run tomorrow has to notice its access token from
10
+ * yesterday expired and refresh before failing.
11
+ *
12
+ * `currentAccessToken`/`forceRefreshAccessToken` are exported for
13
+ * `commands/mcp.ts` (PLANNING.md §3.5): the bridge talks to `/mcp` directly
14
+ * with a raw `fetch` rather than through `apiFetch`, since `/mcp` returns
15
+ * JSON-RPC bodies on non-2xx statuses that must be forwarded verbatim, not
16
+ * parsed as this file's REST error envelope. Both surfaces share the same
17
+ * token/refresh path so there is exactly one place that talks to the token
18
+ * endpoint.
19
+ */
20
+ import { apiBaseUrl, CLIENT_ID } from "./config";
21
+ import { clearTokens, loadTokens, saveTokens, type TokenSet } from "./credentials";
22
+ import { getAuthServerMetadata } from "./discovery";
23
+
24
+ export class ApiError extends Error {
25
+ constructor(
26
+ message: string,
27
+ readonly code: string,
28
+ readonly status: number,
29
+ ) {
30
+ super(message);
31
+ this.name = "ApiError";
32
+ }
33
+ }
34
+
35
+ /** Thrown when there is no usable session at all — the command should tell the user to run `svcloud login`. */
36
+ export class AuthRequiredError extends Error {
37
+ constructor() {
38
+ super("Not signed in. Run `svcloud login` first.");
39
+ this.name = "AuthRequiredError";
40
+ }
41
+ }
42
+
43
+ interface ErrorEnvelope {
44
+ error: { code: string; message: string; details?: Record<string, unknown> };
45
+ request_id: string;
46
+ }
47
+
48
+ // Refresh a little before the real expiry so a request never races it.
49
+ const EXPIRY_SKEW_MS = 30_000;
50
+
51
+ async function refreshRequest(refreshToken: string): Promise<TokenSet> {
52
+ const meta = await getAuthServerMetadata();
53
+ const res = await fetch(meta.token_endpoint, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({
57
+ grant_type: "refresh_token",
58
+ refresh_token: refreshToken,
59
+ client_id: CLIENT_ID,
60
+ }),
61
+ });
62
+ if (!res.ok) {
63
+ await clearTokens();
64
+ throw new AuthRequiredError();
65
+ }
66
+ const body = (await res.json()) as {
67
+ access_token: string;
68
+ refresh_token: string;
69
+ expires_in: number;
70
+ scope: string;
71
+ };
72
+ const next: TokenSet = {
73
+ accessToken: body.access_token,
74
+ refreshToken: body.refresh_token,
75
+ expiresAt: Date.now() + body.expires_in * 1000,
76
+ scopes: body.scope.split(" ").filter(Boolean),
77
+ };
78
+ await saveTokens(next);
79
+ return next;
80
+ }
81
+
82
+ /**
83
+ * Single-flight, cross-request refresh (PLANNING.md §3.5 rule 6 — "the
84
+ * bridge and a human running `svcloud status` use the same keychain entry.
85
+ * On a 401, re-read the store before refreshing (another process may have
86
+ * already rotated it), refresh once, retry once, then fail"). Refresh
87
+ * tokens rotate server-side on every use (`services/oauth.ts`'s
88
+ * `refreshTokenGrant` revokes the pair it consumes), so two concurrent
89
+ * refreshes racing the same stored refresh token is not a slow path, it's a
90
+ * bug: the loser gets `OAUTH_INVALID_GRANT` and would otherwise clear a
91
+ * perfectly good session out from under the winner. The bridge's concurrent
92
+ * `tools/call`s are exactly the case that triggers this.
93
+ *
94
+ * `staleToken` is what the caller was using when it decided a refresh was
95
+ * needed. Re-reading disk first means a refresh already completed by
96
+ * another in-flight call (in this process, via the dedupe below, or in a
97
+ * sibling `svcloud` process) is picked up instead of triggering a second,
98
+ * doomed refresh.
99
+ */
100
+ let refreshInFlight: Promise<TokenSet> | undefined;
101
+
102
+ async function refreshOnce(staleToken: TokenSet): Promise<TokenSet> {
103
+ if (refreshInFlight) return refreshInFlight;
104
+ refreshInFlight = (async () => {
105
+ const onDisk = await loadTokens();
106
+ if (onDisk && onDisk.refreshToken !== staleToken.refreshToken) return onDisk;
107
+ return refreshRequest((onDisk ?? staleToken).refreshToken);
108
+ })();
109
+ try {
110
+ return await refreshInFlight;
111
+ } finally {
112
+ refreshInFlight = undefined;
113
+ }
114
+ }
115
+
116
+ /** The current access token, refreshing first if it's expired or about to be. */
117
+ export async function currentAccessToken(): Promise<string> {
118
+ const tokens = await loadTokens();
119
+ if (!tokens) throw new AuthRequiredError();
120
+ if (Date.now() < tokens.expiresAt - EXPIRY_SKEW_MS) return tokens.accessToken;
121
+ return (await refreshOnce(tokens)).accessToken;
122
+ }
123
+
124
+ /** Forces a refresh regardless of the stored expiry — for a 401 the local clock didn't see coming. */
125
+ export async function forceRefreshAccessToken(): Promise<string> {
126
+ const tokens = await loadTokens();
127
+ if (!tokens) throw new AuthRequiredError();
128
+ return (await refreshOnce(tokens)).accessToken;
129
+ }
130
+
131
+ export interface ApiFetchOptions {
132
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
133
+ body?: unknown;
134
+ /** Only `login`'s own calls (discovery, token exchange) need this — everything else requires a session. */
135
+ unauthenticated?: boolean;
136
+ }
137
+
138
+ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
139
+ const headers: Record<string, string> = {};
140
+ if (options.body !== undefined) headers["content-type"] = "application/json";
141
+ if (!options.unauthenticated) {
142
+ headers.authorization = `Bearer ${await currentAccessToken()}`;
143
+ }
144
+
145
+ const doFetch = () =>
146
+ fetch(`${apiBaseUrl()}${path}`, {
147
+ method: options.method ?? "GET",
148
+ headers,
149
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
150
+ });
151
+
152
+ let res = await doFetch();
153
+
154
+ // One retry after a forced refresh: covers a token cloud-api revoked
155
+ // early (e.g. `svcloud logout` run from another shell) that our own
156
+ // expiry clock didn't yet know about.
157
+ if (res.status === 401 && !options.unauthenticated) {
158
+ headers.authorization = `Bearer ${await forceRefreshAccessToken()}`;
159
+ res = await doFetch();
160
+ }
161
+
162
+ if (res.status === 204) return undefined as T;
163
+
164
+ if (!res.ok) {
165
+ let envelope: ErrorEnvelope | undefined;
166
+ try {
167
+ envelope = (await res.json()) as ErrorEnvelope;
168
+ } catch {
169
+ // A non-JSON error body (e.g. a 502 in front of the Worker) still surfaces as a plain ApiError below.
170
+ }
171
+ if (res.status === 401 && !options.unauthenticated) {
172
+ await clearTokens();
173
+ throw new AuthRequiredError();
174
+ }
175
+ throw new ApiError(
176
+ envelope?.error.message ?? `Request failed (${res.status}).`,
177
+ envelope?.error.code ?? "unknown_error",
178
+ res.status,
179
+ );
180
+ }
181
+
182
+ return (await res.json()) as T;
183
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Best-effort "open a URL in the default browser". The CLI's audience
3
+ * pairs with a coding agent shelling out to it, so a failure here (no
4
+ * display, no default browser registered) must never block the command —
5
+ * the URL is always printed too, and the caller decides whether that's
6
+ * enough.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+
10
+ export function openBrowser(url: string): void {
11
+ 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
+ }
19
+ } catch {
20
+ // The printed URL is the fallback; nothing else to do here.
21
+ }
22
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Constants shared by every command. `SVCLOUD_API_URL` is the one env
3
+ * override, for pointing the CLI at a local `wrangler dev` instance while
4
+ * building it — production has no reason to ever set it.
5
+ */
6
+
7
+ export const CLIENT_ID = "svcloud-cli";
8
+
9
+ export function apiBaseUrl(): string {
10
+ return process.env.SVCLOUD_API_URL ?? "https://api.cloud.sv-academy.org";
11
+ }
12
+
13
+ /** Service/account names the credential store (lib/credentials.ts) files this CLI's token set under. */
14
+ export const CREDENTIAL_SERVICE = "svcloud-cli";
15
+ export const CREDENTIAL_ACCOUNT = "default";