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 +181 -0
- package/bin/svcloud.js +37 -0
- package/package.json +31 -0
- package/src/commands/db.ts +243 -0
- package/src/commands/deploy.ts +101 -0
- package/src/commands/init.ts +236 -0
- package/src/commands/login.ts +88 -0
- package/src/commands/logout.ts +31 -0
- package/src/commands/mcp.ts +185 -0
- package/src/commands/open.ts +25 -0
- package/src/commands/projects.ts +33 -0
- package/src/commands/runs.ts +69 -0
- package/src/commands/secrets.ts +89 -0
- package/src/commands/status.ts +36 -0
- package/src/commands/whoami.ts +26 -0
- package/src/index.ts +107 -0
- package/src/lib/api.ts +183 -0
- package/src/lib/browser.ts +22 -0
- package/src/lib/config.ts +15 -0
- package/src/lib/credentials.ts +294 -0
- package/src/lib/deploy-poll.ts +64 -0
- package/src/lib/discovery.ts +28 -0
- package/src/lib/find-project.ts +10 -0
- package/src/lib/git.ts +77 -0
- package/src/lib/loopback-server.ts +71 -0
- package/src/lib/output.ts +47 -0
- package/src/lib/pkce.ts +17 -0
- package/src/lib/prompt.ts +43 -0
- package/src/lib/run-poll.ts +68 -0
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
|
+
*/
|