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,294 @@
1
+ /**
2
+ * Where `svcloud login` puts the token pair, and every other command reads
3
+ * it from. PLANNING.md §2's storage table: OS keychain first, with a
4
+ * documented (never silent) file fallback.
5
+ *
6
+ * macOS — Keychain, via the `security` CLI (no native module, no build
7
+ * step for a package that has to run via `npx`).
8
+ * Linux — Secret Service (libsecret), via `secret-tool`, when present.
9
+ * Windows — Credential Manager, via a small PowerShell script that
10
+ * P/Invokes advapi32's CredWrite/CredRead/CredDelete (same
11
+ * reasoning: no native module).
12
+ *
13
+ * Any of the above failing for any reason (tool missing, no desktop
14
+ * keyring running in a container, permission denied) falls back to
15
+ * `~/.config/svcloud/credentials.json` at mode 0600. `save()` prints a
16
+ * one-time warning when it lands there, since that is the moment an
17
+ * unencrypted token actually touches disk — every later read/write of the
18
+ * same fallback file stays silent, so routine commands aren't noisy.
19
+ *
20
+ * `SVCLOUD_CREDENTIALS_FILE`, if set, bypasses the OS keychain entirely and
21
+ * reads/writes that exact plaintext file instead — test-only, so the
22
+ * `mcp` command's stdio tests (`test/mcp-bridge.test.ts`) can drive a
23
+ * throwaway token store without touching a developer's real macOS Keychain
24
+ * or their real `svcloud login` session. Never set by any shipped command.
25
+ */
26
+ import { execFile } from "node:child_process";
27
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { promisify } from "node:util";
31
+ import { CREDENTIAL_ACCOUNT, CREDENTIAL_SERVICE } from "./config";
32
+
33
+ const run = promisify(execFile);
34
+
35
+ export interface TokenSet {
36
+ accessToken: string;
37
+ refreshToken: string;
38
+ /** Epoch ms. */
39
+ expiresAt: number;
40
+ scopes: string[];
41
+ }
42
+
43
+ const FALLBACK_DIR = join(homedir(), ".config", "svcloud");
44
+ const FALLBACK_FILE = join(FALLBACK_DIR, "credentials.json");
45
+
46
+ async function macosSave(json: string): Promise<void> {
47
+ // -U: update in place if an entry already exists, so a re-login doesn't
48
+ // need a delete-then-add and doesn't prompt twice.
49
+ await run("security", [
50
+ "add-generic-password",
51
+ "-a",
52
+ CREDENTIAL_ACCOUNT,
53
+ "-s",
54
+ CREDENTIAL_SERVICE,
55
+ "-w",
56
+ json,
57
+ "-U",
58
+ ]);
59
+ }
60
+
61
+ async function macosLoad(): Promise<string | undefined> {
62
+ try {
63
+ const { stdout } = await run("security", [
64
+ "find-generic-password",
65
+ "-a",
66
+ CREDENTIAL_ACCOUNT,
67
+ "-s",
68
+ CREDENTIAL_SERVICE,
69
+ "-w",
70
+ ]);
71
+ return stdout.replace(/\n$/, "");
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ async function macosClear(): Promise<void> {
78
+ await run("security", [
79
+ "delete-generic-password",
80
+ "-a",
81
+ CREDENTIAL_ACCOUNT,
82
+ "-s",
83
+ CREDENTIAL_SERVICE,
84
+ ]).catch(() => {
85
+ // Nothing stored: deleting an absent item is not a failure here.
86
+ });
87
+ }
88
+
89
+ async function linuxSave(json: string): Promise<void> {
90
+ // execFile has no `input` option (that's execFileSync-only); util.promisify's
91
+ // execFile attaches the live ChildProcess as `.child` on the returned
92
+ // promise specifically so callers can write to stdin before awaiting it.
93
+ const pending = run("secret-tool", [
94
+ "store",
95
+ "--label=SV Cloud CLI credentials",
96
+ "service",
97
+ CREDENTIAL_SERVICE,
98
+ "account",
99
+ CREDENTIAL_ACCOUNT,
100
+ ]);
101
+ pending.child.stdin?.end(json);
102
+ await pending;
103
+ }
104
+
105
+ async function linuxLoad(): Promise<string | undefined> {
106
+ try {
107
+ const { stdout } = await run("secret-tool", [
108
+ "lookup",
109
+ "service",
110
+ CREDENTIAL_SERVICE,
111
+ "account",
112
+ CREDENTIAL_ACCOUNT,
113
+ ]);
114
+ return stdout.replace(/\n$/, "") || undefined;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ async function linuxClear(): Promise<void> {
121
+ await run("secret-tool", [
122
+ "clear",
123
+ "service",
124
+ CREDENTIAL_SERVICE,
125
+ "account",
126
+ CREDENTIAL_ACCOUNT,
127
+ ]).catch(() => {});
128
+ }
129
+
130
+ /**
131
+ * Windows Credential Manager has no CLI that can both write and read a
132
+ * secret back out (`cmdkey` is write-only), so this goes through
133
+ * PowerShell's P/Invoke of advapi32's CredWrite/CredRead/CredDelete
134
+ * directly — untested on Windows (this CLI was built and verified on
135
+ * macOS); any failure here falls back to the plaintext file below rather
136
+ * than surfacing a build-only error to a novice.
137
+ */
138
+ const PS_TARGET = `${CREDENTIAL_SERVICE}/${CREDENTIAL_ACCOUNT}`;
139
+
140
+ const PS_PROLOGUE = `
141
+ Add-Type -Namespace SvCloud -Name Cred -MemberDefinition @'
142
+ [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
143
+ public static extern bool CredWrite(ref CREDENTIAL credential, uint flags);
144
+ [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
145
+ public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credentialPtr);
146
+ [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
147
+ public static extern bool CredDelete(string target, uint type, uint flags);
148
+ [DllImport("advapi32.dll")]
149
+ public static extern void CredFree(IntPtr buffer);
150
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
151
+ public struct CREDENTIAL {
152
+ public uint Flags; public uint Type; public string TargetName; public string Comment;
153
+ public long LastWritten; public uint CredentialBlobSize; public IntPtr CredentialBlob;
154
+ public uint Persist; public uint AttributeCount; public IntPtr Attributes;
155
+ public string TargetAlias; public string UserName;
156
+ }
157
+ '@ -UsingNamespace System.Runtime.InteropServices
158
+ `;
159
+
160
+ async function windowsSave(json: string): Promise<void> {
161
+ const bytes = Buffer.from(json, "utf16le");
162
+ const script = `${PS_PROLOGUE}
163
+ $bytes = [Convert]::FromBase64String("${bytes.toString("base64")}")
164
+ $blob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
165
+ [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length)
166
+ $cred = New-Object SvCloud.Cred+CREDENTIAL
167
+ $cred.Type = 1; $cred.TargetName = "${PS_TARGET}"; $cred.CredentialBlobSize = $bytes.Length
168
+ $cred.CredentialBlob = $blob; $cred.Persist = 2; $cred.UserName = "${CREDENTIAL_ACCOUNT}"
169
+ if (-not [SvCloud.Cred]::CredWrite([ref]$cred, 0)) { throw "CredWrite failed" }
170
+ [Runtime.InteropServices.Marshal]::FreeHGlobal($blob)`;
171
+ await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
172
+ }
173
+
174
+ async function windowsLoad(): Promise<string | undefined> {
175
+ const script = `${PS_PROLOGUE}
176
+ $ptr = [IntPtr]::Zero
177
+ if (-not [SvCloud.Cred]::CredRead("${PS_TARGET}", 1, 0, [ref]$ptr)) { exit 1 }
178
+ $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [Type][SvCloud.Cred+CREDENTIAL])
179
+ $bytes = New-Object byte[] $cred.CredentialBlobSize
180
+ [Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
181
+ [SvCloud.Cred]::CredFree($ptr)
182
+ [Console]::Out.Write([Convert]::ToBase64String($bytes))`;
183
+ try {
184
+ const { stdout } = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
185
+ if (!stdout.trim()) return undefined;
186
+ return Buffer.from(stdout.trim(), "base64").toString("utf16le");
187
+ } catch {
188
+ return undefined;
189
+ }
190
+ }
191
+
192
+ async function windowsClear(): Promise<void> {
193
+ const script = `${PS_PROLOGUE}[SvCloud.Cred]::CredDelete("${PS_TARGET}", 1, 0) | Out-Null`;
194
+ await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]).catch(() => {});
195
+ }
196
+
197
+ async function fallbackSave(json: string): Promise<void> {
198
+ await mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 });
199
+ await writeFile(FALLBACK_FILE, json, { mode: 0o600 });
200
+ console.error(
201
+ `Note: your SV Cloud sign-in is stored in plain text at ${FALLBACK_FILE} ` +
202
+ "because no system keychain was available. Anyone who can read that " +
203
+ "file can act as you until you run `svcloud logout`.",
204
+ );
205
+ }
206
+
207
+ async function fallbackLoad(): Promise<string | undefined> {
208
+ try {
209
+ return await readFile(FALLBACK_FILE, "utf-8");
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ async function fallbackClear(): Promise<void> {
216
+ await rm(FALLBACK_FILE, { force: true });
217
+ }
218
+
219
+ async function platformSave(json: string): Promise<boolean> {
220
+ try {
221
+ if (process.platform === "darwin") await macosSave(json);
222
+ else if (process.platform === "linux") await linuxSave(json);
223
+ else if (process.platform === "win32") await windowsSave(json);
224
+ else return false;
225
+ return true;
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ async function platformLoad(): Promise<string | undefined> {
232
+ try {
233
+ if (process.platform === "darwin") return await macosLoad();
234
+ if (process.platform === "linux") return await linuxLoad();
235
+ if (process.platform === "win32") return await windowsLoad();
236
+ return undefined;
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+
242
+ async function platformClear(): Promise<void> {
243
+ try {
244
+ if (process.platform === "darwin") await macosClear();
245
+ else if (process.platform === "linux") await linuxClear();
246
+ else if (process.platform === "win32") await windowsClear();
247
+ } catch {
248
+ // Best-effort: an absent or already-cleared entry is not a failure.
249
+ }
250
+ }
251
+
252
+ async function testOverrideSave(path: string, json: string): Promise<void> {
253
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
254
+ await writeFile(path, json, { mode: 0o600 });
255
+ }
256
+
257
+ async function testOverrideLoad(path: string): Promise<string | undefined> {
258
+ try {
259
+ return await readFile(path, "utf-8");
260
+ } catch {
261
+ return undefined;
262
+ }
263
+ }
264
+
265
+ export async function saveTokens(tokens: TokenSet): Promise<void> {
266
+ const json = JSON.stringify(tokens);
267
+ const override = process.env.SVCLOUD_CREDENTIALS_FILE;
268
+ if (override) return testOverrideSave(override, json);
269
+ if (await platformSave(json)) return;
270
+ await fallbackSave(json);
271
+ }
272
+
273
+ export async function loadTokens(): Promise<TokenSet | undefined> {
274
+ const override = process.env.SVCLOUD_CREDENTIALS_FILE;
275
+ const json = override
276
+ ? await testOverrideLoad(override)
277
+ : (await platformLoad()) ?? (await fallbackLoad());
278
+ if (!json) return undefined;
279
+ try {
280
+ return JSON.parse(json) as TokenSet;
281
+ } catch {
282
+ return undefined;
283
+ }
284
+ }
285
+
286
+ export async function clearTokens(): Promise<void> {
287
+ const override = process.env.SVCLOUD_CREDENTIALS_FILE;
288
+ if (override) {
289
+ await rm(override, { force: true });
290
+ return;
291
+ }
292
+ await platformClear();
293
+ await fallbackClear();
294
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `svcloud deploy`'s poller — watches `GET /:id/deploys` for a row that
3
+ * isn't the pre-push baseline id, since a push produces a *new* deploy row
4
+ * rather than moving a known one through states the caller already has an id
5
+ * for (unlike `lib/run-poll.ts`'s run polling). Split out from
6
+ * `commands/deploy.ts` so it's unit-testable with a tiny `intervalMs`/
7
+ * `timeoutMs`, the same reason `run-poll.ts` lives apart from `init`/`runs`.
8
+ */
9
+ import type { DeploySummary } from "@sv/cloud-contracts";
10
+
11
+ const TERMINAL_DEPLOY_STATUSES: ReadonlySet<DeploySummary["status"]> = new Set([
12
+ "live",
13
+ "failed",
14
+ "superseded",
15
+ "rolled_back",
16
+ ]);
17
+
18
+ export interface DeployPollResult {
19
+ deploy: DeploySummary | undefined;
20
+ /** A deploy row appeared but was still `building` when the timeout hit. */
21
+ timedOut: boolean;
22
+ }
23
+
24
+ export interface DeployPollOptions {
25
+ intervalMs?: number;
26
+ timeoutMs?: number;
27
+ quiet?: boolean;
28
+ }
29
+
30
+ function sleep(ms: number): Promise<void> {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+ }
33
+
34
+ /**
35
+ * Prints each status transition once (never per-tick), the same restraint
36
+ * `run-poll.ts` uses for run steps.
37
+ */
38
+ export async function pollForNewDeploy(
39
+ fetchDeploys: () => Promise<DeploySummary[]>,
40
+ baselineId: string | undefined,
41
+ opts: DeployPollOptions = {},
42
+ ): Promise<DeployPollResult> {
43
+ const interval = opts.intervalMs ?? 3000;
44
+ const timeout = opts.timeoutMs ?? 10 * 60 * 1000;
45
+ const start = Date.now();
46
+ let lastPrinted: string | undefined;
47
+
48
+ for (;;) {
49
+ const deploys = await fetchDeploys();
50
+ const latest = deploys[0];
51
+ const isNew = latest !== undefined && latest.id !== baselineId;
52
+ if (isNew && latest.status !== lastPrinted) {
53
+ if (!opts.quiet) console.log(` ${latest.status}`);
54
+ lastPrinted = latest.status;
55
+ }
56
+ if (isNew && TERMINAL_DEPLOY_STATUSES.has(latest.status)) {
57
+ return { deploy: latest, timedOut: false };
58
+ }
59
+ if (Date.now() - start > timeout) {
60
+ return { deploy: isNew ? latest : undefined, timedOut: true };
61
+ }
62
+ await sleep(interval);
63
+ }
64
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * RFC 8414 authorization-server metadata (`apps/cloud/src/routes/discovery.ts`).
3
+ * Read once per process and cached: PLANNING.md §2 is explicit that the CLI
4
+ * should read these endpoints from discovery rather than hardcode them, so a
5
+ * future change to cloud-api's OAuth path layout doesn't need a CLI release.
6
+ */
7
+ import { apiBaseUrl } from "./config";
8
+
9
+ export interface AuthServerMetadata {
10
+ issuer: string;
11
+ authorization_endpoint: string;
12
+ token_endpoint: string;
13
+ revocation_endpoint: string;
14
+ }
15
+
16
+ let cached: AuthServerMetadata | undefined;
17
+
18
+ export async function getAuthServerMetadata(): Promise<AuthServerMetadata> {
19
+ if (cached) return cached;
20
+ const res = await fetch(`${apiBaseUrl()}/.well-known/oauth-authorization-server`);
21
+ if (!res.ok) {
22
+ throw new Error(
23
+ `Could not reach SV Cloud (${res.status}). Check your connection and try again.`,
24
+ );
25
+ }
26
+ cached = (await res.json()) as AuthServerMetadata;
27
+ return cached;
28
+ }
@@ -0,0 +1,10 @@
1
+ /** Shared by `status` and `open`: resolve a slug to its `ProjectSummary` via `GET /projects?slug=`. */
2
+ import type { ProjectSummary } from "@sv/cloud-contracts";
3
+ import { apiFetch } from "./api";
4
+
5
+ export async function findProjectBySlug(slug: string): Promise<ProjectSummary | undefined> {
6
+ const { projects } = await apiFetch<{ projects: ProjectSummary[] }>(
7
+ `/api/v1/projects?slug=${encodeURIComponent(slug)}`,
8
+ );
9
+ return projects[0];
10
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `svcloud init`'s repo inference — `git remote get-url origin` in the
3
+ * working directory, parsed into `<owner>/<repo>` so a first-timer never has
4
+ * to type `--repo` for the common case (PLANNING.md §3.1: init is "adopt an
5
+ * existing local project", so the working directory is the starting point).
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+
10
+ const run = promisify(execFile);
11
+
12
+ export interface GitOriginRepo {
13
+ owner: string;
14
+ repo: string;
15
+ /** `<owner>/<repo>`, the shape `POST /projects/connect`'s `repo_full_name` expects. */
16
+ fullName: string;
17
+ }
18
+
19
+ function stripGitSuffix(raw: string): string {
20
+ return raw.replace(/\.git$/, "");
21
+ }
22
+
23
+ // git@host:owner/repo(.git)?
24
+ const SSH_RE = /^git@[^:]+:([^/]+)\/([^/]+)$/;
25
+ // https://host/owner/repo(.git)?(/)?
26
+ const HTTPS_RE = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+?)\/?$/;
27
+
28
+ function parseOriginUrl(url: string): GitOriginRepo | undefined {
29
+ const ssh = SSH_RE.exec(url.trim());
30
+ const https = HTTPS_RE.exec(url.trim());
31
+ const match = ssh ?? https;
32
+ if (!match) return undefined;
33
+ const owner = match[1] as string;
34
+ const repo = stripGitSuffix(match[2] as string);
35
+ return { owner, repo, fullName: `${owner}/${repo}` };
36
+ }
37
+
38
+ /** `undefined` if there's no `origin` remote, or it isn't a recognizable owner/repo URL. */
39
+ export async function inferRepoFromCwd(cwd: string = process.cwd()): Promise<GitOriginRepo | undefined> {
40
+ try {
41
+ const { stdout } = await run("git", ["remote", "get-url", "origin"], { cwd });
42
+ return parseOriginUrl(stdout);
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ export interface GitWorkingState {
49
+ branch: string;
50
+ dirty: boolean;
51
+ }
52
+
53
+ /**
54
+ * `svcloud deploy`'s preconditions (PLANNING.md §3.3's "refuse, with a clear
55
+ * message, if the working tree is dirty or the branch is not the project's
56
+ * default_branch"). `undefined` if `cwd` isn't a readable git repo (no `git`
57
+ * on PATH, not a repo, or detached HEAD — there's no branch to push from any
58
+ * of those, and the caller should say so rather than guessing).
59
+ */
60
+ export async function gitWorkingState(cwd: string = process.cwd()): Promise<GitWorkingState | undefined> {
61
+ try {
62
+ const [{ stdout: branchOut }, { stdout: statusOut }] = await Promise.all([
63
+ run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd }),
64
+ run("git", ["status", "--porcelain"], { cwd }),
65
+ ]);
66
+ const branch = branchOut.trim();
67
+ if (!branch || branch === "HEAD") return undefined;
68
+ return { branch, dirty: statusOut.trim().length > 0 };
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** `git push` (current branch, default remote) in `cwd`. Throws with git's own stderr on failure. */
75
+ export async function pushCurrentBranch(cwd: string = process.cwd()): Promise<void> {
76
+ await run("git", ["push"], { cwd });
77
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The `127.0.0.1:<random port>/callback` listener `svcloud login` opens the
3
+ * browser at cloud-api's `redirect_uri`. PLANNING.md §2 step 5: cloud-web's
4
+ * consent screen redirects the browser here with `?code=...`, this file
5
+ * captures it and shuts down, then step 6 exchanges the code.
6
+ */
7
+ import { createServer } from "node:http";
8
+ import type { AddressInfo } from "node:net";
9
+
10
+ export interface CallbackResult {
11
+ code?: string;
12
+ state?: string;
13
+ error?: string;
14
+ }
15
+
16
+ export interface LoopbackListener {
17
+ port: number;
18
+ /** Resolves with the first callback request's query params, or rejects on timeout. */
19
+ waitForCallback(timeoutMs: number): Promise<CallbackResult>;
20
+ close(): void;
21
+ }
22
+
23
+ const CALLBACK_PAGE = (ok: boolean) => `<!doctype html>
24
+ <html><head><title>SV Cloud</title></head>
25
+ <body style="font-family:system-ui;padding:2rem;color:#111">
26
+ <h1>${ok ? "You're signed in." : "Sign-in didn't finish."}</h1>
27
+ <p>You can close this tab and go back to your terminal.</p>
28
+ </body></html>`;
29
+
30
+ export function startLoopbackListener(): Promise<LoopbackListener> {
31
+ return new Promise((resolve, reject) => {
32
+ let resultResolve: ((r: CallbackResult) => void) | undefined;
33
+ const resultPromise = new Promise<CallbackResult>((res) => {
34
+ resultResolve = res;
35
+ });
36
+
37
+ const server = createServer((req, res) => {
38
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
39
+ if (url.pathname !== "/callback") {
40
+ res.writeHead(404).end();
41
+ return;
42
+ }
43
+ const code = url.searchParams.get("code") ?? undefined;
44
+ const state = url.searchParams.get("state") ?? undefined;
45
+ const error = url.searchParams.get("error") ?? undefined;
46
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
47
+ res.end(CALLBACK_PAGE(!error && !!code));
48
+ resultResolve?.({ code, state, error });
49
+ });
50
+
51
+ server.on("error", reject);
52
+ server.listen(0, "127.0.0.1", () => {
53
+ const { port } = server.address() as AddressInfo;
54
+ resolve({
55
+ port,
56
+ close: () => server.close(),
57
+ waitForCallback: (timeoutMs: number) =>
58
+ Promise.race([
59
+ resultPromise,
60
+ new Promise<CallbackResult>((_, rej) => {
61
+ const timer = setTimeout(
62
+ () => rej(new Error("Timed out waiting for the browser to finish sign-in.")),
63
+ timeoutMs,
64
+ );
65
+ timer.unref();
66
+ }),
67
+ ]),
68
+ });
69
+ });
70
+ });
71
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `--json` on every read command (PLANNING.md §1's non-negotiable #3), plus
3
+ * a plain table for humans. No color/box-drawing dependency: the audience
4
+ * includes an agent's stdout parser as often as a terminal.
5
+ */
6
+
7
+ export function hasFlag(argv: string[], name: string): { present: boolean; rest: string[] } {
8
+ const flag = `--${name}`;
9
+ const present = argv.includes(flag);
10
+ return { present, rest: argv.filter((a) => a !== flag) };
11
+ }
12
+
13
+ /** A `--name value` pair, pulled out of argv wherever it sits. Only the first occurrence counts. */
14
+ export function takeOption(argv: string[], name: string): { value: string | undefined; rest: string[] } {
15
+ const flag = `--${name}`;
16
+ const idx = argv.indexOf(flag);
17
+ if (idx === -1) return { value: undefined, rest: argv };
18
+ const value = argv[idx + 1];
19
+ const rest = [...argv.slice(0, idx), ...argv.slice(idx + 2)];
20
+ return { value, rest };
21
+ }
22
+
23
+ export function printJson(data: unknown): void {
24
+ console.log(JSON.stringify(data, null, 2));
25
+ }
26
+
27
+ /** Fixed-width columns, left-aligned, two spaces between them. */
28
+ export function printTable(rows: Record<string, string>[], columns: string[]): void {
29
+ if (rows.length === 0) {
30
+ console.log("(none)");
31
+ return;
32
+ }
33
+ const widths = columns.map((col) =>
34
+ Math.max(col.length, ...rows.map((r) => (r[col] ?? "").length)),
35
+ );
36
+ const line = (cells: string[]) =>
37
+ cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ");
38
+ console.log(line(columns));
39
+ for (const row of rows) {
40
+ console.log(line(columns.map((col) => row[col] ?? "")));
41
+ }
42
+ }
43
+
44
+ export function die(message: string): never {
45
+ console.error(message);
46
+ process.exit(1);
47
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PKCE (RFC 7636) for the loopback login flow. Mirrors what
3
+ * `apps/cloud/src/lib/pkce.ts` verifies server-side: a 43-128 character
4
+ * unreserved-charset verifier, and its S256 challenge. `plain` is never
5
+ * generated here since `cloud-api` refuses it outright.
6
+ */
7
+ import { createHash, randomBytes } from "node:crypto";
8
+
9
+ export function generateVerifier(): string {
10
+ // 32 random bytes -> 43 base64url characters, inside the 43-128 range RFC
11
+ // 7636 requires and cloud-api's verifyPkce enforces.
12
+ return randomBytes(32).toString("base64url");
13
+ }
14
+
15
+ export function challengeFromVerifier(verifier: string): string {
16
+ return createHash("sha256").update(verifier).digest("base64url");
17
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Interactive prompts for a human running `svcloud init` at a real terminal.
3
+ * `isInteractive()` is the gate every command using this should check first —
4
+ * a coding agent or CI shelling out gets no prompt, only a `die()` naming the
5
+ * flag that would have answered it (PLANNING.md's "an agent should be going
6
+ * through `svcloud mcp`, not shelling out and parsing text" — the flag-driven
7
+ * path is for the human and CI cases that remain).
8
+ */
9
+ import { createInterface } from "node:readline/promises";
10
+
11
+ export function isInteractive(): boolean {
12
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
13
+ }
14
+
15
+ export async function prompt(question: string, defaultValue?: string): Promise<string> {
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ try {
18
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
19
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
20
+ return answer.length > 0 ? answer : (defaultValue ?? "");
21
+ } finally {
22
+ rl.close();
23
+ }
24
+ }
25
+
26
+ export async function confirm(question: string, defaultYes = true): Promise<boolean> {
27
+ const suffix = defaultYes ? "Y/n" : "y/N";
28
+ const answer = (await prompt(`${question} (${suffix})`)).trim().toLowerCase();
29
+ if (answer.length === 0) return defaultYes;
30
+ return answer === "y" || answer === "yes";
31
+ }
32
+
33
+ /** Numbered choice, 1-indexed on screen. Re-prompts on an out-of-range or non-numeric answer. */
34
+ export async function choose(question: string, options: string[]): Promise<number> {
35
+ console.log(question);
36
+ options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
37
+ for (;;) {
38
+ const answer = await prompt("Enter a number");
39
+ const n = Number.parseInt(answer, 10);
40
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return n - 1;
41
+ console.log(`Enter a number from 1 to ${options.length}.`);
42
+ }
43
+ }