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.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # svcloud
2
+
3
+ The command-line interface for [SV Cloud](https://cloud.sv-academy.org) — and
4
+ the way an AI coding agent reaches the platform.
5
+
6
+ > **Alpha.** Sign-in, status, app listing, the `mcp` bridge, and `init`
7
+ > (below) are built — see Commands for the full current list. `logs`,
8
+ > `deploy`, `secrets`, `db`, and `dev` are still on the way. Published under
9
+ > the `alpha` dist-tag, not `latest`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npx svcloud@alpha login
15
+ ```
16
+
17
+ Or install it:
18
+
19
+ ```bash
20
+ npm install -g svcloud@alpha
21
+ svcloud login
22
+ ```
23
+
24
+ `login` opens your browser to sign in, then stores your session in your
25
+ system's keychain (Keychain on macOS; Secret Service on Linux; Credential
26
+ Manager on Windows — falling back to a local file, with a warning, if none
27
+ of those is available).
28
+
29
+ ## Commands
30
+
31
+ ```
32
+ svcloud login Sign in to SV Cloud
33
+ svcloud logout Sign out
34
+ svcloud whoami Show the signed-in account
35
+ svcloud status <app> Show an app's health
36
+ svcloud open <app> Open an app's web address in your browser
37
+ svcloud projects list List your apps
38
+ svcloud init Connect a repo (defaults to the one in your working directory) as a new app
39
+ svcloud runs <app> Show or watch an app's provisioning run
40
+ svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
41
+
42
+ Flags:
43
+ --json Machine-readable output, on every read command
44
+ ```
45
+
46
+ `init` reads `--repo <owner/name>`, `--name`, `--slug`, and `--installation`
47
+ to skip what it would otherwise infer or ask about; `runs` reads `--id
48
+ <run-id>` (read a specific run instead of an app's latest) and `--watch`
49
+ (follow it to completion). Neither needs any of that in the common case:
50
+ `cd` into your repo and run `svcloud init`.
51
+
52
+ More commands (`logs`, `deploy`, `secrets`, `db`, `dev`) are on the way.
53
+
54
+ ## Connect your coding agent
55
+
56
+ `svcloud mcp` runs a local MCP server that your coding agent — Antigravity,
57
+ Gemini CLI, Cursor, Claude Code, OpenCode, Codex, or anything else that speaks
58
+ MCP — connects to over stdio. It signs every request with the session you
59
+ created with `svcloud login` and forwards it to SV Cloud, so your agent can list
60
+ your apps, read errors, check a deploy, and work with your database without you
61
+ pasting anything into it.
62
+
63
+ First, sign in on your machine:
64
+
65
+ ```bash
66
+ svcloud login
67
+ ```
68
+
69
+ Then configure your agent:
70
+
71
+ ### Antigravity
72
+ Add to `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json` (workspace):
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "svcloud": {
78
+ "command": "npx",
79
+ "args": ["-y", "svcloud@alpha", "mcp"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ### Gemini CLI
86
+ ```bash
87
+ gemini mcp add svcloud -- npx -y svcloud@alpha mcp
88
+ ```
89
+ Or add the `svcloud` block under `mcpServers` in `~/.gemini/config.json` or `~/.gemini/mcp_config.json`.
90
+
91
+ ### Cursor
92
+ Go to **Settings** > **Features** > **MCP** > **Add New MCP Server**:
93
+ - **Name:** `svcloud`
94
+ - **Type:** `command`
95
+ - **Command:** `npx -y svcloud@alpha mcp`
96
+
97
+ Or add directly to `.cursor/mcp.json`:
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "svcloud": {
102
+ "command": "npx",
103
+ "args": ["-y", "svcloud@alpha", "mcp"]
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ ### OpenCode
110
+ In `~/.config/opencode/mcp.json` or your OpenCode configuration:
111
+ ```json
112
+ {
113
+ "mcpServers": {
114
+ "svcloud": {
115
+ "command": "npx",
116
+ "args": ["-y", "svcloud@alpha", "mcp"]
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ ### Claude Code
123
+ ```bash
124
+ claude mcp add svcloud -- npx -y svcloud@alpha mcp
125
+ ```
126
+
127
+ ### Codex
128
+ In `~/.codex/config.json` or your Codex MCP settings:
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "svcloud": {
133
+ "command": "npx",
134
+ "args": ["-y", "svcloud@alpha", "mcp"]
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### Other MCP harnesses
141
+ In any harness or configuration file that accepts an `mcpServers` block:
142
+
143
+ ```jsonc
144
+ { "svcloud": { "command": "npx", "args": ["-y", "svcloud@alpha", "mcp"] } }
145
+ ```
146
+
147
+ Your agent never sees your token and never has to sign in on its own. Signing
148
+ out (`svcloud logout`) cuts its access off too.
149
+
150
+ For an agent you use every day, install it rather than running it through
151
+ `npx`: an installed `svcloud` starts instantly, works offline, and only changes
152
+ version when you ask it to.
153
+
154
+ ```jsonc
155
+ { "svcloud": { "command": "svcloud", "args": ["mcp"] } }
156
+ ```
157
+
158
+ ### Upgrading
159
+
160
+ ```bash
161
+ npm install -g svcloud@alpha
162
+ ```
163
+
164
+ Your agent picks up the new version the next time it starts — restart the MCP
165
+ server (or the harness) after upgrading.
166
+
167
+ You will rarely need to. What SV Cloud can do for your agent is decided on our
168
+ side, not by your installed version, so new capabilities show up without an
169
+ upgrade. If a version ever *is* required, the error message will say so.
170
+
171
+ One thing an upgrade does not change: what your agent is allowed to do is fixed
172
+ at the moment you sign in. If we widen what the CLI can be granted, run
173
+ `svcloud login` again to pick it up — a stale session keeps the narrower tool
174
+ list no matter how new your CLI is, since scopes are frozen at sign-in, not
175
+ re-checked against the current defaults. (**2026-08-25:** we just did this —
176
+ run `svcloud login` again to see the full tool list, including database and
177
+ settings tools that were previously invisible to the bridge.)
178
+
179
+ A harness that can run its own OAuth flow (a hosted agent, not one running on
180
+ your machine) can also talk to SV Cloud directly at
181
+ `https://api.cloud.sv-academy.org/mcp` instead of through this bridge.
package/bin/svcloud.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx svcloud`'s actual entry point. Plain JS, no type stripping needed for
4
+ * this file itself: its only job is to run `src/index.ts` under `tsx`
5
+ * (a real dependency, not dev-only, since this is what a consumer with no
6
+ * build step runs). PLANNING.md §5 leaves a compiled/bundled build for
7
+ * later ("the option of a single-file binary later") — this is what makes
8
+ * `npx svcloud` work without one in the meantime.
9
+ *
10
+ * `--import` takes a bare specifier ("tsx") Node resolves as if requiring
11
+ * it from the CURRENT WORKING DIRECTORY — not from this file's own
12
+ * location. Found while building `init` (PLANNING.md §4 Step 3), the first
13
+ * command whose whole point is running from the owner's own project
14
+ * directory rather than from inside this package: from anywhere without a
15
+ * `tsx` reachable via cwd-based resolution (i.e. almost anywhere a real
16
+ * user runs `svcloud`), every command failed with `ERR_MODULE_NOT_FOUND`.
17
+ * `import.meta.resolve` fixes it by resolving `tsx` against THIS file's own
18
+ * location instead, since it ships as a real (non-dev) dependency right
19
+ * next to it. The child process's own `cwd` is left alone — commands like
20
+ * `init` read `process.cwd()` themselves and must see the owner's real
21
+ * working directory, not this package's.
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import { fileURLToPath } from "node:url";
25
+ import path from "node:path";
26
+
27
+ const here = path.dirname(fileURLToPath(import.meta.url));
28
+ const entry = path.join(here, "..", "src", "index.ts");
29
+ const tsxLoader = import.meta.resolve("tsx");
30
+
31
+ const child = spawn(process.execPath, ["--import", tsxLoader, entry, ...process.argv.slice(2)], {
32
+ stdio: "inherit",
33
+ });
34
+ child.on("exit", (code, signal) => {
35
+ if (signal) process.kill(process.pid, signal);
36
+ else process.exit(code ?? 0);
37
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "svcloud",
3
+ "version": "0.1.0-alpha.3",
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
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "svcloud": "./bin/svcloud.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22.18.0"
17
+ },
18
+ "dependencies": {
19
+ "tsx": "^4.20.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.13.0",
23
+ "typescript": "^5.9.3",
24
+ "@sv/cloud-contracts": "0.0.0"
25
+ },
26
+ "scripts": {
27
+ "dev": "tsx src/index.ts",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "node --import tsx --test test/*.test.ts"
30
+ }
31
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * `svcloud db` — PLANNING.md §4 Step 5. Mirrors F8's row editor
3
+ * (`apps/cloud/src/routes/database.ts`) plus the two DDL routes added
4
+ * alongside this command (`create_table`/`run_migration`, previously
5
+ * MCP-tool-only — see that file's header). Cell/column payloads are taken as
6
+ * JSON on the command line rather than a flag-per-field grammar: the primary
7
+ * caller for structured data like this is a coding agent, and it goes
8
+ * through `svcloud mcp`'s tools directly (`lib/prompt.ts`'s header), not this
9
+ * command — this path exists for a human or a script at a real terminal.
10
+ */
11
+ import { apiFetch } from "../lib/api";
12
+ import { findProjectBySlug } from "../lib/find-project";
13
+ import { die, printJson, printTable, takeOption } from "../lib/output";
14
+
15
+ const USAGE = `Usage:
16
+ svcloud db tables <app>
17
+ svcloud db rows <app> <table> [--limit N]
18
+ svcloud db insert <app> <table> <cells-json>
19
+ svcloud db update <app> <table> <row-id> <cells-json>
20
+ svcloud db delete <app> <table> <row-id>
21
+ svcloud db create-table <app> <table> <columns-json> --primary-key <name>
22
+ svcloud db migrate <app> <table> <column-json> [--default <value>]
23
+
24
+ <cells-json> is a JSON object, e.g. '{"title":"Hello"}'
25
+ <columns-json> is a JSON array, e.g. '[{"name":"id","type":"uuid","nullable":false},{"name":"title","type":"text"}]'
26
+ <column-json> is a single column, e.g. '{"name":"count","type":"number","nullable":false}'
27
+ Column types: text, number, boolean, timestamp, json, uuid`;
28
+
29
+ interface DbColumn {
30
+ name: string;
31
+ type: string;
32
+ nullable: boolean;
33
+ primary_key: boolean;
34
+ }
35
+
36
+ interface DbTable {
37
+ name: string;
38
+ row_count: number;
39
+ editable: boolean;
40
+ not_editable_reason: string | null;
41
+ }
42
+
43
+ interface DbRow {
44
+ id: string;
45
+ cells: Record<string, unknown>;
46
+ }
47
+
48
+ interface RowsPage {
49
+ columns: DbColumn[];
50
+ rows: DbRow[];
51
+ truncated: boolean;
52
+ editable: boolean;
53
+ not_editable_reason: string | null;
54
+ }
55
+
56
+ async function resolveProjectId(slug: string | undefined): Promise<string> {
57
+ if (!slug) die(USAGE);
58
+ const project = await findProjectBySlug(slug);
59
+ if (!project) die(`No app named "${slug}".`);
60
+ return project.id;
61
+ }
62
+
63
+ function parseJsonArg<T>(raw: string | undefined, label: string): T {
64
+ if (raw === undefined) die(USAGE);
65
+ try {
66
+ return JSON.parse(raw) as T;
67
+ } catch {
68
+ die(`${label} must be valid JSON.`);
69
+ }
70
+ }
71
+
72
+ function parseDefaultValue(raw: string): string | number | boolean | null {
73
+ try {
74
+ const parsed = JSON.parse(raw);
75
+ if (
76
+ parsed === null ||
77
+ typeof parsed === "string" ||
78
+ typeof parsed === "number" ||
79
+ typeof parsed === "boolean"
80
+ ) {
81
+ return parsed;
82
+ }
83
+ } catch {
84
+ // Not JSON — treat the raw text itself as the string default.
85
+ }
86
+ return raw;
87
+ }
88
+
89
+ function stringifyCell(value: unknown): string {
90
+ if (value === null || value === undefined) return "";
91
+ if (typeof value === "string") return value;
92
+ return JSON.stringify(value);
93
+ }
94
+
95
+ async function dbTables(argv: string[], json: boolean): Promise<void> {
96
+ const [slug] = argv;
97
+ const projectId = await resolveProjectId(slug);
98
+ const { tables } = await apiFetch<{ tables: DbTable[] }>(
99
+ `/api/v1/projects/${projectId}/database/tables`,
100
+ );
101
+ if (json) {
102
+ printJson(tables);
103
+ return;
104
+ }
105
+ printTable(
106
+ tables.map((t) => ({
107
+ Table: t.name,
108
+ Rows: String(t.row_count),
109
+ Editable: t.editable ? "yes" : `no (${t.not_editable_reason ?? "unknown reason"})`,
110
+ })),
111
+ ["Table", "Rows", "Editable"],
112
+ );
113
+ }
114
+
115
+ async function dbRows(argv: string[], json: boolean): Promise<void> {
116
+ const limitOpt = takeOption(argv, "limit");
117
+ const [slug, table] = limitOpt.rest;
118
+ if (!table) die(USAGE);
119
+ const projectId = await resolveProjectId(slug);
120
+ const query = limitOpt.value ? `?limit=${encodeURIComponent(limitOpt.value)}` : "";
121
+ const page = await apiFetch<RowsPage>(
122
+ `/api/v1/projects/${projectId}/database/tables/${encodeURIComponent(table)}/rows${query}`,
123
+ );
124
+ if (json) {
125
+ printJson(page);
126
+ return;
127
+ }
128
+ printTable(
129
+ page.rows.map((r) => ({
130
+ id: r.id,
131
+ ...Object.fromEntries(page.columns.map((c) => [c.name, stringifyCell(r.cells[c.name])])),
132
+ })),
133
+ ["id", ...page.columns.map((c) => c.name)],
134
+ );
135
+ if (page.truncated) console.log(`(showing the first ${page.rows.length} rows — more exist)`);
136
+ if (!page.editable) {
137
+ console.log(`Not editable: ${page.not_editable_reason ?? "unknown reason"}`);
138
+ }
139
+ }
140
+
141
+ async function dbInsert(argv: string[], json: boolean): Promise<void> {
142
+ const [slug, table, cellsJson] = argv;
143
+ if (!table) die(USAGE);
144
+ const projectId = await resolveProjectId(slug);
145
+ const cells = parseJsonArg<Record<string, unknown>>(cellsJson, "<cells-json>");
146
+ const { row } = await apiFetch<{ row: DbRow }>(
147
+ `/api/v1/projects/${projectId}/database/tables/${encodeURIComponent(table)}/rows`,
148
+ { method: "POST", body: { cells } },
149
+ );
150
+ if (json) {
151
+ printJson(row);
152
+ return;
153
+ }
154
+ console.log(`Inserted row ${row.id}.`);
155
+ }
156
+
157
+ async function dbUpdate(argv: string[], json: boolean): Promise<void> {
158
+ const [slug, table, rowId, cellsJson] = argv;
159
+ if (!table || !rowId) die(USAGE);
160
+ const projectId = await resolveProjectId(slug);
161
+ const cells = parseJsonArg<Record<string, unknown>>(cellsJson, "<cells-json>");
162
+ const { row } = await apiFetch<{ row: DbRow }>(
163
+ `/api/v1/projects/${projectId}/database/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(rowId)}`,
164
+ { method: "PATCH", body: { cells } },
165
+ );
166
+ if (json) {
167
+ printJson(row);
168
+ return;
169
+ }
170
+ console.log(`Updated row ${row.id}.`);
171
+ }
172
+
173
+ async function dbDelete(argv: string[], json: boolean): Promise<void> {
174
+ const [slug, table, rowId] = argv;
175
+ if (!table || !rowId) die(USAGE);
176
+ const projectId = await resolveProjectId(slug);
177
+ await apiFetch(
178
+ `/api/v1/projects/${projectId}/database/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(rowId)}`,
179
+ { method: "DELETE" },
180
+ );
181
+ if (json) {
182
+ printJson({ row_id: rowId, deleted: true });
183
+ return;
184
+ }
185
+ console.log(`Deleted row ${rowId}.`);
186
+ }
187
+
188
+ async function dbCreateTable(argv: string[], json: boolean): Promise<void> {
189
+ const pkOpt = takeOption(argv, "primary-key");
190
+ const [slug, table, columnsJson] = pkOpt.rest;
191
+ if (!table || !pkOpt.value) die(USAGE);
192
+ const projectId = await resolveProjectId(slug);
193
+ const columns = parseJsonArg<unknown[]>(columnsJson, "<columns-json>");
194
+ const result = await apiFetch<{ table: string; created: boolean }>(
195
+ `/api/v1/projects/${projectId}/database/tables`,
196
+ { method: "POST", body: { name: table, columns, primary_key: pkOpt.value } },
197
+ );
198
+ if (json) {
199
+ printJson(result);
200
+ return;
201
+ }
202
+ console.log(`Created table "${result.table}".`);
203
+ }
204
+
205
+ async function dbMigrate(argv: string[], json: boolean): Promise<void> {
206
+ const defaultOpt = takeOption(argv, "default");
207
+ const [slug, table, columnJson] = defaultOpt.rest;
208
+ if (!table) die(USAGE);
209
+ const projectId = await resolveProjectId(slug);
210
+ const column = parseJsonArg<Record<string, unknown>>(columnJson, "<column-json>");
211
+ const defaultValue = defaultOpt.value === undefined ? null : parseDefaultValue(defaultOpt.value);
212
+ const result = await apiFetch<{ table: string; column: string; added: boolean }>(
213
+ `/api/v1/projects/${projectId}/database/tables/${encodeURIComponent(table)}/columns`,
214
+ { method: "POST", body: { ...column, default_value: defaultValue } },
215
+ );
216
+ if (json) {
217
+ printJson(result);
218
+ return;
219
+ }
220
+ console.log(`Added column "${result.column}" to "${result.table}".`);
221
+ }
222
+
223
+ export async function dbCommand(argv: string[], json: boolean): Promise<void> {
224
+ const [subcommand, ...rest] = argv;
225
+ switch (subcommand) {
226
+ case "tables":
227
+ return dbTables(rest, json);
228
+ case "rows":
229
+ return dbRows(rest, json);
230
+ case "insert":
231
+ return dbInsert(rest, json);
232
+ case "update":
233
+ return dbUpdate(rest, json);
234
+ case "delete":
235
+ return dbDelete(rest, json);
236
+ case "create-table":
237
+ return dbCreateTable(rest, json);
238
+ case "migrate":
239
+ return dbMigrate(rest, json);
240
+ default:
241
+ die(USAGE);
242
+ }
243
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `svcloud deploy <app>` — PLANNING.md §3.3/§4 Step 6, option (a): trigger
3
+ * the existing push-triggered pipeline and poll, rather than building
4
+ * locally and uploading an artifact (option (b), deliberately not built —
5
+ * see this file's closing note). No `cloud-api` change: `git push` plus
6
+ * `GET /:id/deploys` already existed.
7
+ *
8
+ * This command never claims to have deployed anything itself — it pushes,
9
+ * then watches the deploy row GitHub Actions' own OIDC-authenticated call
10
+ * creates, and reports whatever status that row settles on (or hasn't, by
11
+ * the time it gives up watching).
12
+ */
13
+ import type { DeploySummary, ProjectDetail } from "@sv/cloud-contracts";
14
+ import { apiFetch } from "../lib/api";
15
+ import { pollForNewDeploy } from "../lib/deploy-poll";
16
+ import { findProjectBySlug } from "../lib/find-project";
17
+ import { gitWorkingState, pushCurrentBranch } from "../lib/git";
18
+ import { die, printJson } from "../lib/output";
19
+
20
+ async function fetchDeploys(projectId: string): Promise<DeploySummary[]> {
21
+ const { deploys } = await apiFetch<{ deploys: DeploySummary[] }>(
22
+ `/api/v1/projects/${projectId}/deploys`,
23
+ );
24
+ return deploys;
25
+ }
26
+
27
+ export async function deployCommand(argv: string[], json: boolean): Promise<void> {
28
+ const [slug] = argv;
29
+ if (!slug) die("Usage: svcloud deploy <app>");
30
+
31
+ const project = await findProjectBySlug(slug);
32
+ if (!project) die(`No app named "${slug}".`);
33
+
34
+ const detail = await apiFetch<ProjectDetail>(`/api/v1/projects/${project.id}?advanced=1`);
35
+ const defaultBranch = detail.advanced?.default_branch;
36
+ if (!defaultBranch) die("Couldn't determine this app's default branch. Try again.");
37
+
38
+ const state = await gitWorkingState();
39
+ if (!state) {
40
+ die("Run this from the app's local git repository (no repo found, or `git` isn't on PATH).");
41
+ }
42
+ if (state.dirty) {
43
+ die("Working tree has uncommitted changes. Commit or stash them, then run `svcloud deploy` again.");
44
+ }
45
+ if (state.branch !== defaultBranch) {
46
+ die(
47
+ `On branch "${state.branch}", but this app deploys from "${defaultBranch}". ` +
48
+ `Switch branches, or push "${defaultBranch}" directly with git.`,
49
+ );
50
+ }
51
+
52
+ const baselineId = (await fetchDeploys(project.id))[0]?.id;
53
+
54
+ if (!json) console.log(`Pushing ${state.branch}...`);
55
+ try {
56
+ await pushCurrentBranch();
57
+ } catch (err) {
58
+ die(`git push failed: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+
61
+ if (!json) console.log("Waiting for the build...");
62
+ const { deploy, timedOut } = await pollForNewDeploy(() => fetchDeploys(project.id), baselineId, {
63
+ quiet: json,
64
+ });
65
+
66
+ if (json) {
67
+ printJson({ deploy: deploy ?? null, timed_out: timedOut });
68
+ return;
69
+ }
70
+
71
+ if (!deploy) {
72
+ console.log(
73
+ `No build detected yet. Check \`svcloud status ${slug}\` shortly — the pipeline may still be starting.`,
74
+ );
75
+ return;
76
+ }
77
+ if (deploy.status === "failed") {
78
+ die(`Deploy failed.${deploy.log_url ? ` Logs: ${deploy.log_url}` : ""}`);
79
+ }
80
+ if (timedOut) {
81
+ console.log(`Still building. Check \`svcloud status ${slug}\` shortly.`);
82
+ return;
83
+ }
84
+ console.log(`Deployed. ${detail.web_address ?? ""}`.trim());
85
+ }
86
+
87
+ /**
88
+ * Option (b) — build locally, upload the artifact to a new user-token
89
+ * endpoint — is deliberately NOT built. Besides the refactor PLANNING.md
90
+ * §3.3 already scoped (`deployProject()` lifted off `GitHubOidcClaims`),
91
+ * there's an open security question flagged 2026-08-25, not yet
92
+ * investigated: today, only GitHub Actions running the repo's *own* checked-
93
+ * in workflow can ever produce a deploy — the artifact is whatever that CI
94
+ * job built from a specific commit. A user-token upload endpoint accepts
95
+ * whatever bytes the caller sends, built wherever they built it — no
96
+ * required correspondence to any commit in the repo at all. Whether that's
97
+ * actually a meaningful new risk for this product (small blast radius per
98
+ * project? already-authenticated owner deploying to their own app?) or a
99
+ * non-issue is unresolved. Investigate and record the answer here before (b)
100
+ * is designed further, let alone built.
101
+ */