@codeyam-editor/codeyam-editor 0.1.0-staging.0f231be

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/npm/cloud.js ADDED
@@ -0,0 +1,966 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cloud.js — one-command orchestrator for hosting a codeyam-editor session
5
+ * on a GCE VM. Reuses `scripts/gcp-bootstrap.sh` and
6
+ * `scripts/gcp-project-bootstrap.sh` verbatim by streaming them over
7
+ * `gcloud compute ssh`. Per-project state lives in `.codeyam/cloud.json`
8
+ * (gitignored) so the second invocation is zero-arg. See
9
+ * `docs/cloud-gcp.md` for the runbook this wraps.
10
+ */
11
+
12
+ const { spawn, spawnSync } = require("child_process");
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const readline = require("readline");
16
+ const {
17
+ rootDir,
18
+ isPortInUse,
19
+ waitForPort,
20
+ deepMergeJson,
21
+ killChildProcess,
22
+ } = require("./utils");
23
+ const { open } = require("./open");
24
+
25
+ // =============================================================================
26
+ // Pure helpers — every export here is unit-tested in cloud.test.js without
27
+ // touching `gcloud` or the network.
28
+ // =============================================================================
29
+
30
+ const AGENT_ENV_VARS = {
31
+ claude: "ANTHROPIC_API_KEY",
32
+ codex: "OPENAI_API_KEY",
33
+ gemini: "GEMINI_API_KEY",
34
+ };
35
+
36
+ const KNOWN_FLAGS = ["repo", "branch", "instance-name", "project", "zone", "agent"];
37
+
38
+ const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
39
+ const INSTANCE_TAG = "codeyam-editor";
40
+ const EDITOR_PORT = 14199;
41
+ const VITE_PORT = 5173;
42
+
43
+ /** Map an agent name to its API-key env var. Throws on unknown agents so
44
+ * adding an agent is a one-entry change in `AGENT_ENV_VARS`, never a
45
+ * silent fallthrough that ships unauthenticated. */
46
+ function agentEnvVarName(agent) {
47
+ if (!Object.prototype.hasOwnProperty.call(AGENT_ENV_VARS, agent)) {
48
+ throw new Error(
49
+ `Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_ENV_VARS).join(", ")}`,
50
+ );
51
+ }
52
+ return AGENT_ENV_VARS[agent];
53
+ }
54
+
55
+ /** Parse `--flag value` argv into an object. Unknown flags or missing values
56
+ * throw with a usage hint — silently dropping them would let typos like
57
+ * `--branchh main` succeed with no branch flag at all. */
58
+ function parseFlags(argv) {
59
+ const flags = {};
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const arg = argv[i];
62
+ if (!arg.startsWith("--")) {
63
+ throw new Error(
64
+ `Unexpected positional argument: ${arg}\n` +
65
+ `Usage: npm run cloud:<up|tunnel|stop|destroy|status> -- [${KNOWN_FLAGS
66
+ .map((f) => `--${f} <value>`)
67
+ .join("] [")}]`,
68
+ );
69
+ }
70
+ const key = arg.slice(2);
71
+ if (!KNOWN_FLAGS.includes(key)) {
72
+ throw new Error(`Unknown flag: ${arg}. Known: --${KNOWN_FLAGS.join(", --")}`);
73
+ }
74
+ const value = argv[i + 1];
75
+ if (value === undefined || value.startsWith("--")) {
76
+ throw new Error(`${arg} requires a value`);
77
+ }
78
+ const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
79
+ flags[camel] = value;
80
+ i++;
81
+ }
82
+ return flags;
83
+ }
84
+
85
+ /** Strip `.git`, take the basename, lowercase, collapse non-alphanumerics to
86
+ * hyphens. Mirrors the existing `scripts/gcp-project-bootstrap.sh` derivation
87
+ * so the directory layout on the VM matches the instance name 1:1. */
88
+ function repoBasename(repoUrl) {
89
+ const stripped = repoUrl.replace(/\.git$/i, "");
90
+ const parts = stripped.split(/[/:]+/).filter(Boolean);
91
+ return parts[parts.length - 1] || "project";
92
+ }
93
+
94
+ /** Construct a deterministic instance name from a repo URL. GCE caps
95
+ * instance names at 63 chars; we cap at 40 to keep room for hyphen
96
+ * separators and stay well below the limit. */
97
+ function deriveInstanceName(repoUrl) {
98
+ const base = repoBasename(repoUrl);
99
+ let slug = base
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9]+/g, "-")
102
+ .replace(/^-+|-+$/g, "");
103
+ if (!slug) slug = "project";
104
+ let name = `codeyam-editor-${slug}`;
105
+ if (name.length > 40) name = name.slice(0, 40).replace(/-+$/g, "");
106
+ return name;
107
+ }
108
+
109
+ function cloudConfigPath(projectDir) {
110
+ return path.join(projectDir, ".codeyam", "cloud.json");
111
+ }
112
+
113
+ /** Returns the parsed config, or `null` when the file is absent. Throws
114
+ * with the full path on malformed JSON so the user knows which file to
115
+ * fix — silently returning `null` would mask corruption as "no config". */
116
+ function loadCloudConfig(projectDir) {
117
+ const p = cloudConfigPath(projectDir);
118
+ if (!fs.existsSync(p)) return null;
119
+ let raw;
120
+ try {
121
+ raw = fs.readFileSync(p, "utf8");
122
+ } catch (e) {
123
+ throw new Error(`Failed to read ${p}: ${e.message}`);
124
+ }
125
+ try {
126
+ return JSON.parse(raw);
127
+ } catch (e) {
128
+ throw new Error(`Failed to parse ${p}: ${e.message}`);
129
+ }
130
+ }
131
+
132
+ function saveCloudConfig(projectDir, cfg) {
133
+ const p = cloudConfigPath(projectDir);
134
+ fs.mkdirSync(path.dirname(p), { recursive: true });
135
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
136
+ }
137
+
138
+ /** Build the JSON patch to merge into the VM's `editor.local.json` so the
139
+ * configured agent picks up the user's local API key on first launch.
140
+ * Returns `null` when the key isn't set locally — the wrapper then prints
141
+ * the interactive-auth fallback line instead of writing an empty patch. */
142
+ function buildEditorLocalEnvPatch(localEnv, agent) {
143
+ const varName = agentEnvVarName(agent);
144
+ const value = localEnv[varName];
145
+ if (!value) return null;
146
+ return { env: { [varName]: value } };
147
+ }
148
+
149
+ /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
150
+ * line) and return the editor service's `State` string, or null when the
151
+ * service is absent / output is unparseable. Two callers shared an identical
152
+ * inline parser; this centralizes the shape so a compose-output change is
153
+ * one update, not two. */
154
+ function parseEditorComposeState(stdout) {
155
+ if (!stdout) return null;
156
+ const lines = String(stdout).trim().split("\n").filter(Boolean);
157
+ for (const line of lines) {
158
+ let svc;
159
+ try {
160
+ svc = JSON.parse(line);
161
+ } catch {
162
+ continue;
163
+ }
164
+ if (svc && svc.Service === "editor") {
165
+ return svc.State || null;
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
172
+ * switch's job is to keep every per-verb command shape in one reviewable
173
+ * place — tests assert the arrays without ever spawning a process. */
174
+ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
175
+ const { project, zone, instanceName } = cfg;
176
+ switch (verb) {
177
+ case "config-get-project":
178
+ return ["config", "get-value", "project"];
179
+ case "config-get-zone":
180
+ return ["config", "get-value", "compute/zone"];
181
+ case "instances-create":
182
+ // The VM keeps the default ephemeral external IP so apt / docker /
183
+ // NodeSource / npm can reach the public internet during bootstrap.
184
+ // Inbound is still IAP-SSH-only via the firewall rule above; the
185
+ // external IP is for egress, not exposure. `--no-address` was the
186
+ // earlier default but produced a VM that could not install Docker
187
+ // without a Cloud NAT + Cloud Router pair the wrapper does not set up.
188
+ return [
189
+ "compute", "instances", "create", instanceName,
190
+ "--project", project, "--zone", zone,
191
+ "--machine-type=e2-standard-4",
192
+ "--image-family=debian-12",
193
+ "--image-project=debian-cloud",
194
+ "--boot-disk-size=100GB",
195
+ "--boot-disk-type=pd-balanced",
196
+ `--tags=${INSTANCE_TAG}`,
197
+ ];
198
+ case "instances-start":
199
+ return ["compute", "instances", "start", instanceName, "--project", project, "--zone", zone];
200
+ case "instances-stop":
201
+ return ["compute", "instances", "stop", instanceName, "--project", project, "--zone", zone];
202
+ case "instances-delete":
203
+ return ["compute", "instances", "delete", instanceName, "--project", project, "--zone", zone, "--quiet"];
204
+ case "instances-describe":
205
+ return ["compute", "instances", "describe", instanceName, "--project", project, "--zone", zone, "--format=json"];
206
+ case "instances-list-tagged":
207
+ return [
208
+ "compute", "instances", "list",
209
+ "--project", project,
210
+ `--filter=tags.items:${INSTANCE_TAG}`,
211
+ "--format=value(name)",
212
+ ];
213
+ case "firewall-describe":
214
+ return ["compute", "firewall-rules", "describe", FIREWALL_RULE_NAME, "--project", project, "--format=json"];
215
+ case "firewall-create":
216
+ return [
217
+ "compute", "firewall-rules", "create", FIREWALL_RULE_NAME,
218
+ "--project", project,
219
+ "--direction=INGRESS",
220
+ "--action=ALLOW",
221
+ "--rules=tcp:22",
222
+ "--source-ranges=35.235.240.0/20",
223
+ `--target-tags=${INSTANCE_TAG}`,
224
+ ];
225
+ case "firewall-delete":
226
+ return ["compute", "firewall-rules", "delete", FIREWALL_RULE_NAME, "--project", project, "--quiet"];
227
+ case "ssh-probe":
228
+ return [
229
+ "compute", "ssh", instanceName,
230
+ "--project", project, "--zone", zone,
231
+ "--tunnel-through-iap",
232
+ "--command", extras.command,
233
+ ];
234
+ case "ssh-script":
235
+ // Stream a script body as base64 over --command so we don't fight
236
+ // shell-quoting through gcloud → ssh → remote bash. The script body
237
+ // is delivered as one argument; the wrapper feeds the base64 into
238
+ // `extras.command` and the caller sets `input` to the script body.
239
+ return [
240
+ "compute", "ssh", instanceName,
241
+ "--project", project, "--zone", zone,
242
+ "--tunnel-through-iap",
243
+ "--command", extras.command,
244
+ ];
245
+ case "ssh-tunnel":
246
+ return [
247
+ "compute", "ssh", instanceName,
248
+ "--project", project, "--zone", zone,
249
+ "--tunnel-through-iap",
250
+ "--",
251
+ "-L", `${EDITOR_PORT}:127.0.0.1:${EDITOR_PORT}`,
252
+ "-L", `${VITE_PORT}:127.0.0.1:${VITE_PORT}`,
253
+ "-N",
254
+ ];
255
+ default:
256
+ throw new Error(`Unknown gcloud verb: ${verb}`);
257
+ }
258
+ }
259
+
260
+ // =============================================================================
261
+ // Side-effecting helpers — exercised end-to-end by `npm run cloud:up` in
262
+ // real use. Unit tests pin their exports so a refactor cannot drop them.
263
+ // =============================================================================
264
+
265
+ function runGcloud(args, opts = {}) {
266
+ return spawnSync("gcloud", args, {
267
+ stdio: opts.stdio || "inherit",
268
+ input: opts.input,
269
+ encoding: opts.encoding || "utf8",
270
+ });
271
+ }
272
+
273
+ function ensureGcloud() {
274
+ const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
275
+ if (result.error || result.status !== 0) {
276
+ console.error(
277
+ "gcloud is not on PATH. Install: https://cloud.google.com/sdk/docs/install",
278
+ );
279
+ process.exit(1);
280
+ }
281
+ }
282
+
283
+ function gcloudGetValue(verb) {
284
+ const result = runGcloud(buildGcloudArgs(verb), { stdio: ["ignore", "pipe", "ignore"] });
285
+ if (result.error || result.status !== 0) return null;
286
+ const val = String(result.stdout || "").trim();
287
+ if (!val || val === "(unset)") return null;
288
+ return val;
289
+ }
290
+
291
+ /** Run `gcloud compute instances describe ... --format=json` and return the
292
+ * parsed `.status` field (e.g. "RUNNING", "TERMINATED", "PROVISIONING"), or
293
+ * null when the instance is absent or describe returned unparseable output.
294
+ * Centralizes the describe-and-parse pattern shared by ensureInstance (used
295
+ * twice — initial probe + poll), cmdTunnel, and cmdStatus. */
296
+ function fetchInstanceState(cfg) {
297
+ const result = runGcloud(buildGcloudArgs("instances-describe", cfg), {
298
+ stdio: ["ignore", "pipe", "ignore"],
299
+ });
300
+ if (result.status !== 0) return null;
301
+ try {
302
+ const parsed = JSON.parse(result.stdout);
303
+ return parsed && typeof parsed.status === "string" ? parsed.status : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ function loadOrInitCloudConfig(projectDir, flags) {
310
+ const existing = loadCloudConfig(projectDir);
311
+ if (existing) {
312
+ const merged = { ...existing, ...flags };
313
+ if (JSON.stringify(merged) !== JSON.stringify(existing)) {
314
+ saveCloudConfig(projectDir, merged);
315
+ }
316
+ return merged;
317
+ }
318
+
319
+ const cfg = { ...flags };
320
+ cfg.project = cfg.project || gcloudGetValue("config-get-project");
321
+ cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
322
+ cfg.agent = cfg.agent || "claude";
323
+ agentEnvVarName(cfg.agent);
324
+
325
+ if (!cfg.repo) {
326
+ throw new Error(
327
+ "--repo <url> is required on first run. Subsequent runs read .codeyam/cloud.json.",
328
+ );
329
+ }
330
+ if (!cfg.project) {
331
+ throw new Error(
332
+ "--project not provided and `gcloud config get-value project` returned no default.\n" +
333
+ "Run `gcloud config set project <id>` or pass --project <id>.",
334
+ );
335
+ }
336
+ if (!cfg.zone) {
337
+ throw new Error(
338
+ "--zone not provided and `gcloud config get-value compute/zone` returned no default.\n" +
339
+ "Run `gcloud config set compute/zone <zone>` or pass --zone <zone>.",
340
+ );
341
+ }
342
+
343
+ cfg.instanceName = cfg.instanceName || deriveInstanceName(cfg.repo);
344
+ saveCloudConfig(projectDir, cfg);
345
+ return cfg;
346
+ }
347
+
348
+ async function ensureFirewallRule(cfg) {
349
+ console.error(`Checking firewall rule ${FIREWALL_RULE_NAME}...`);
350
+ const describe = runGcloud(buildGcloudArgs("firewall-describe", cfg), {
351
+ stdio: ["ignore", "pipe", "pipe"],
352
+ });
353
+ if (describe.status === 0) {
354
+ console.error(` ✓ ${FIREWALL_RULE_NAME} already exists`);
355
+ return;
356
+ }
357
+ console.error(` creating ${FIREWALL_RULE_NAME}...`);
358
+ const create = runGcloud(buildGcloudArgs("firewall-create", cfg));
359
+ if (create.status !== 0) {
360
+ throw new Error(`Failed to create firewall rule ${FIREWALL_RULE_NAME}`);
361
+ }
362
+ }
363
+
364
+ async function ensureInstance(cfg) {
365
+ console.error(`Checking instance ${cfg.instanceName}...`);
366
+ const initialState = fetchInstanceState(cfg);
367
+ if (initialState === "RUNNING") {
368
+ console.error(` ✓ ${cfg.instanceName} is RUNNING`);
369
+ return;
370
+ }
371
+ if (initialState === "TERMINATED") {
372
+ console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
373
+ const start = runGcloud(buildGcloudArgs("instances-start", cfg));
374
+ if (start.status !== 0) {
375
+ throw new Error(`Failed to start instance ${cfg.instanceName}`);
376
+ }
377
+ } else if (initialState === null) {
378
+ console.error(` ${cfg.instanceName} not found — creating...`);
379
+ const create = runGcloud(buildGcloudArgs("instances-create", cfg));
380
+ if (create.status !== 0) {
381
+ throw new Error(`Failed to create instance ${cfg.instanceName}`);
382
+ }
383
+ } else {
384
+ console.error(` ${cfg.instanceName} is ${initialState}; waiting...`);
385
+ }
386
+
387
+ for (let i = 0; i < 60; i++) {
388
+ if (fetchInstanceState(cfg) === "RUNNING") {
389
+ console.error(` ✓ ${cfg.instanceName} is RUNNING`);
390
+ await waitForIapReady(cfg);
391
+ return;
392
+ }
393
+ await new Promise((r) => setTimeout(r, 2000));
394
+ }
395
+ throw new Error(`Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes`);
396
+ }
397
+
398
+ /** GCE reports the instance as RUNNING the moment the boot disk is attached,
399
+ * but the IAP control plane has its own catalog that updates a few seconds
400
+ * later. The first `gcloud compute ssh --tunnel-through-iap` after a fresh
401
+ * create races that catalog and fails with `[4047: 'Failed to lookup
402
+ * instance']`. Probe an IAP-tunneled SSH that just runs `true` until it
403
+ * succeeds — cheap on the warm path (one ~1s round-trip) and bounded so a
404
+ * genuinely unreachable VM fails loud rather than silently. */
405
+ async function waitForIapReady(cfg) {
406
+ console.error(` waiting for IAP to register ${cfg.instanceName}...`);
407
+ const deadline = Date.now() + 60_000;
408
+ let attempts = 0;
409
+ let lastErr = "";
410
+ while (Date.now() < deadline) {
411
+ attempts++;
412
+ const probe = runGcloud(
413
+ buildGcloudArgs("ssh-probe", cfg, { command: "true" }),
414
+ { stdio: ["ignore", "pipe", "pipe"] },
415
+ );
416
+ if (probe.status === 0) {
417
+ console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
418
+ return;
419
+ }
420
+ lastErr = String(probe.stderr || "").trim().split("\n").slice(-1)[0] || "";
421
+ // Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
422
+ const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
423
+ await new Promise((r) => setTimeout(r, delay));
424
+ }
425
+ throw new Error(
426
+ `IAP did not become ready for ${cfg.instanceName} within 60s ` +
427
+ `(${attempts} probes; last error: ${lastErr || "unknown"})`,
428
+ );
429
+ }
430
+
431
+ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
432
+ const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
433
+ const argsString = scriptArgs.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
434
+ // Prefix each env var as `KEY='value'` so they're set for the bash -s
435
+ // process only — no persistence in the VM's shell history, no /etc/environment
436
+ // edit. Single-quoted values are safe against shell metacharacters; an
437
+ // embedded apostrophe in the value would be exotic for these (token,
438
+ // bool flag), but escape it the same way as scriptArgs for symmetry.
439
+ const envString = Object.entries(envVars)
440
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
441
+ .map(([k, v]) => `${k}='${String(v).replace(/'/g, "'\\''")}'`)
442
+ .join(" ");
443
+ const envPrefix = envString ? `${envString} ` : "";
444
+ return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
445
+ }
446
+
447
+ async function ensureBootstrapped(cfg) {
448
+ console.error("Checking VM bootstrap state...");
449
+ const probe = runGcloud(
450
+ buildGcloudArgs("ssh-probe", cfg, {
451
+ command: "command -v docker >/dev/null && docker image inspect codeyam-editor:local >/dev/null 2>&1",
452
+ }),
453
+ { stdio: ["ignore", "pipe", "pipe"] },
454
+ );
455
+ if (probe.status === 0) {
456
+ console.error(" ✓ VM already bootstrapped (docker + codeyam-editor:local present)");
457
+ return;
458
+ }
459
+ console.error(" streaming scripts/gcp-bootstrap.sh to VM (this may take ~10 minutes)...");
460
+ const scriptPath = path.join(rootDir, "scripts", "gcp-bootstrap.sh");
461
+ const scriptBody = fs.readFileSync(scriptPath, "utf8");
462
+ // GITHUB_TOKEN is passed through to the bootstrap script so it can clone
463
+ // a private editor repo (the bootstrap clones $REPO_URL to build the
464
+ // codeyam-editor:local image). Without this the git clone hits the
465
+ // "could not read Username for 'https://github.com'" wall whenever the
466
+ // repo isn't public — codeyam-editor itself today.
467
+ //
468
+ // --branch is mandatory when the user is testing wrapper / Dockerfile
469
+ // patches from a feature branch: without it, the bootstrap clones the
470
+ // repo's default branch and the image build uses a stale Dockerfile,
471
+ // even though the user invoked cloud:up with --branch.
472
+ const bootstrapArgs = ["--agent", cfg.agent, "--repo", cfg.repo];
473
+ if (cfg.branch) {
474
+ bootstrapArgs.push("--branch", cfg.branch);
475
+ }
476
+ const command = sshScriptCommand(scriptBody, bootstrapArgs, {
477
+ GITHUB_TOKEN: resolveGithubToken(),
478
+ });
479
+ const result = runGcloud(
480
+ buildGcloudArgs("ssh-script", cfg, { command }),
481
+ { stdio: "inherit" },
482
+ );
483
+ if (result.status !== 0) {
484
+ throw new Error("scripts/gcp-bootstrap.sh failed on the VM");
485
+ }
486
+ }
487
+
488
+ async function ensureProjectBootstrapped(cfg) {
489
+ const repoName = repoBasename(cfg.repo);
490
+ console.error(`Checking project bootstrap state for ${repoName}...`);
491
+ // Probe both .codeyam/ (created by `editor init`) AND
492
+ // docker-compose.cloud.yml (written near the end of the script). A previous
493
+ // run that crashed between init and the compose write would leave the
494
+ // first marker but not the second; the old probe wrongly considered the
495
+ // project bootstrapped and the next `cloud:up` then hit
496
+ // "open docker-compose.cloud.yml: no such file or directory".
497
+ // The probe is the seam where idempotency lives — make sure it gates on
498
+ // the LAST artifact the script produces, not the first.
499
+ const probe = runGcloud(
500
+ buildGcloudArgs("ssh-probe", cfg, {
501
+ command:
502
+ `test -d ~/projects/${repoName}/.codeyam ` +
503
+ `&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
504
+ }),
505
+ { stdio: ["ignore", "pipe", "pipe"] },
506
+ );
507
+ if (probe.status === 0) {
508
+ console.error(` ✓ ${repoName} already bootstrapped on VM`);
509
+ return;
510
+ }
511
+ console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
512
+ const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
513
+ const scriptBody = fs.readFileSync(scriptPath, "utf8");
514
+ const scriptArgs = [cfg.repo];
515
+ if (cfg.branch) {
516
+ scriptArgs.push("--branch", cfg.branch);
517
+ }
518
+ const command = sshScriptCommand(scriptBody, scriptArgs, {
519
+ GITHUB_TOKEN: resolveGithubToken(),
520
+ });
521
+ const result = runGcloud(
522
+ buildGcloudArgs("ssh-script", cfg, { command }),
523
+ { stdio: "inherit" },
524
+ );
525
+ if (result.status !== 0) {
526
+ throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
527
+ }
528
+ }
529
+
530
+ async function ensureEditorContainerUp(cfg) {
531
+ const repoName = repoBasename(cfg.repo);
532
+ // -f docker-compose.cloud.yml everywhere: the editor repo's own
533
+ // docker-compose.yml does `build: .` from source and would shadow the
534
+ // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
535
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
536
+ console.error("Checking editor container state...");
537
+ const probe = runGcloud(
538
+ buildGcloudArgs("ssh-probe", cfg, {
539
+ command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
540
+ }),
541
+ { stdio: ["ignore", "pipe", "ignore"] },
542
+ );
543
+ if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
544
+ console.error(" ✓ editor container is already running");
545
+ return;
546
+ }
547
+ console.error(" starting editor container (docker compose up -d)...");
548
+ const up = runGcloud(
549
+ buildGcloudArgs("ssh-probe", cfg, {
550
+ command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
551
+ }),
552
+ { stdio: "inherit" },
553
+ );
554
+ if (up.status !== 0) {
555
+ throw new Error("docker compose up -d failed on the VM");
556
+ }
557
+ }
558
+
559
+ /** Look up a GitHub token from local environment, falling back to `gh auth
560
+ * token`. Returns empty string if neither is available — callers should
561
+ * treat empty as "no auth" and either skip or print a hint. Memoizing
562
+ * intentionally not done: the cost of one `gh` subprocess per cloud:up is
563
+ * trivial and lets a user `gh auth login` between operations. */
564
+ function resolveGithubToken() {
565
+ const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
566
+ if (env) return env;
567
+ const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
568
+ if (ghOut.status === 0) {
569
+ return ghOut.stdout.trim();
570
+ }
571
+ return "";
572
+ }
573
+
574
+ /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
575
+ * the VM so the cloud compose can mount it into /root/.git-credentials —
576
+ * without this, claude / codex / gemini inside the container can clone the
577
+ * initial repo (the wrapper does that step out-of-band with a one-shot
578
+ * inlined URL) but cannot `git pull` / `git push` afterward. The file is
579
+ * chmod 600 and never committed; if the local environment has no token,
580
+ * the placeholder file from gcp-project-bootstrap.sh stays in place. */
581
+ async function seedGithubCredentials(cfg) {
582
+ const token = resolveGithubToken();
583
+ if (!token) {
584
+ console.error(
585
+ "\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
586
+ "claude inside the cloud container will not be able to git pull/push.\n" +
587
+ "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
588
+ );
589
+ return;
590
+ }
591
+ console.error("Writing GitHub token into ~/.codeyam-cloud-git-credentials on the VM...");
592
+ const credLine = `https://x-access-token:${token}@github.com`;
593
+ // Quote the token through one shell layer (gcloud ssh --command); the
594
+ // value is base64'd to keep it out of the shell history.
595
+ const b64 = Buffer.from(`${credLine}\n`).toString("base64");
596
+ const remoteCmd =
597
+ `f=$HOME/.codeyam-cloud-git-credentials && ` +
598
+ `echo ${b64} | base64 -d > $f && chmod 600 $f && echo OK`;
599
+ const result = runGcloud(
600
+ buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
601
+ { stdio: ["ignore", "pipe", "inherit"] },
602
+ );
603
+ if (result.status !== 0) {
604
+ throw new Error("Failed to write ~/.codeyam-cloud-git-credentials on the VM");
605
+ }
606
+ }
607
+
608
+ /** Write a default `previewOrigin` into the VM's `editor.local.json` so the
609
+ * Live Preview iframe URL the laptop browser loads is whatever the SSH
610
+ * tunnel exposes, decoupled from the in-VM dev-server port. Without this,
611
+ * the iframe falls back to `http://localhost:${apps[0].port}` — fine when
612
+ * the user happens to have a same-port tunnel (today's `VITE_PORT=5173`),
613
+ * but broken the moment they tunnel a different local port. Skips when
614
+ * the user has already set `previewOrigin` themselves so per-VM overrides
615
+ * aren't clobbered on every `cloud:up`. */
616
+ async function seedPreviewOrigin(cfg) {
617
+ const repoName = repoBasename(cfg.repo);
618
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
619
+ const desiredOrigin = `http://localhost:${VITE_PORT}`;
620
+
621
+ const read = runGcloud(
622
+ buildGcloudArgs("ssh-probe", cfg, {
623
+ command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
624
+ }),
625
+ { stdio: ["ignore", "pipe", "ignore"] },
626
+ );
627
+ let current = {};
628
+ if (read.status === 0) {
629
+ try {
630
+ const parsed = JSON.parse(read.stdout);
631
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
632
+ current = parsed;
633
+ }
634
+ } catch {
635
+ // Unparseable remote content is treated as `{}` — overwriting a
636
+ // corrupt local override file with a known-good one is the safer
637
+ // move than refusing to seed at all.
638
+ }
639
+ }
640
+ if (typeof current.previewOrigin === "string" && current.previewOrigin.length > 0) {
641
+ return;
642
+ }
643
+
644
+ console.error(`Seeding previewOrigin=${desiredOrigin} into ${remotePath}...`);
645
+ const merged = deepMergeJson(current, { previewOrigin: desiredOrigin });
646
+ const mergedJson = JSON.stringify(merged, null, 2);
647
+ const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
648
+ const write = runGcloud(
649
+ buildGcloudArgs("ssh-probe", cfg, {
650
+ command: `mkdir -p ~/projects/${repoName}/.codeyam && echo '${b64}' | base64 -d > ${remotePath}`,
651
+ }),
652
+ { stdio: ["ignore", "inherit", "inherit"] },
653
+ );
654
+ if (write.status !== 0) {
655
+ throw new Error(`Failed to write ${remotePath} on VM`);
656
+ }
657
+ }
658
+
659
+ async function seedAgentApiKey(cfg) {
660
+ const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
661
+ if (!patch) {
662
+ console.error(
663
+ `\nNo ${agentEnvVarName(cfg.agent)} set locally — skipping agent auth seed.\n` +
664
+ `Authenticate ${cfg.agent} later with:\n` +
665
+ ` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
666
+ `--tunnel-through-iap -- ${cfg.agent} auth login\n`,
667
+ );
668
+ return;
669
+ }
670
+ const repoName = repoBasename(cfg.repo);
671
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
672
+ console.error(`Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`);
673
+
674
+ const read = runGcloud(
675
+ buildGcloudArgs("ssh-probe", cfg, {
676
+ command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
677
+ }),
678
+ { stdio: ["ignore", "pipe", "ignore"] },
679
+ );
680
+ let current = {};
681
+ if (read.status === 0) {
682
+ try {
683
+ const parsed = JSON.parse(read.stdout);
684
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
685
+ current = parsed;
686
+ }
687
+ } catch {
688
+ // Treat unparseable remote content as `{}` — overwriting a corrupt
689
+ // local override file with a known-good one is the safer move than
690
+ // refusing to seed at all.
691
+ }
692
+ }
693
+ const merged = deepMergeJson(current, patch);
694
+ const mergedJson = JSON.stringify(merged, null, 2);
695
+ const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
696
+ const write = runGcloud(
697
+ buildGcloudArgs("ssh-probe", cfg, {
698
+ command: `mkdir -p ~/projects/${repoName}/.codeyam && echo '${b64}' | base64 -d > ${remotePath}`,
699
+ }),
700
+ { stdio: ["ignore", "inherit", "inherit"] },
701
+ );
702
+ if (write.status !== 0) {
703
+ throw new Error(`Failed to write ${remotePath} on VM`);
704
+ }
705
+ }
706
+
707
+ async function openTunnel(cfg) {
708
+ console.error(`Opening IAP tunnel to ${cfg.instanceName}...`);
709
+ const child = spawn("gcloud", buildGcloudArgs("ssh-tunnel", cfg), { stdio: "inherit" });
710
+ const ready14199 = await waitForPort(EDITOR_PORT, 30000, 500);
711
+ const ready5173 = await waitForPort(VITE_PORT, 30000, 500);
712
+ if (!ready14199 || !ready5173) {
713
+ console.error(
714
+ `Warning: tunnel ports not reachable on localhost (${EDITOR_PORT}=${ready14199}, ${VITE_PORT}=${ready5173}).`,
715
+ );
716
+ }
717
+ console.error(` Editor UI: http://localhost:${EDITOR_PORT}`);
718
+ console.error(` Live Preview: http://localhost:${VITE_PORT}`);
719
+ console.error(" (Ctrl+C to tear down the tunnel)");
720
+ return child;
721
+ }
722
+
723
+ function attachKeepAliveAndTeardown(child) {
724
+ const keepAlive = setInterval(() => {}, 60_000);
725
+ process.on("SIGINT", () => {
726
+ clearInterval(keepAlive);
727
+ killChildProcess(child);
728
+ process.exit(0);
729
+ });
730
+ }
731
+
732
+ function promptForInstanceName(expected) {
733
+ return new Promise((resolve) => {
734
+ const rl = readline.createInterface({
735
+ input: process.stdin,
736
+ output: process.stderr,
737
+ });
738
+ rl.question(
739
+ `Type the instance name '${expected}' to confirm destroy: `,
740
+ (ans) => {
741
+ rl.close();
742
+ resolve(ans.trim() === expected);
743
+ },
744
+ );
745
+ });
746
+ }
747
+
748
+ // =============================================================================
749
+ // Subcommand handlers
750
+ // =============================================================================
751
+
752
+ async function cmdUp(args) {
753
+ ensureGcloud();
754
+ const flags = parseFlags(args);
755
+ const cfg = loadOrInitCloudConfig(rootDir, flags);
756
+
757
+ await ensureFirewallRule(cfg);
758
+ await ensureInstance(cfg);
759
+ await ensureBootstrapped(cfg);
760
+ await ensureProjectBootstrapped(cfg);
761
+ await seedPreviewOrigin(cfg);
762
+ // Write the GitHub token BEFORE compose up so the credentials file exists
763
+ // when the bind-mount happens — bind-mounting a missing file would silently
764
+ // create a directory instead and the agent's first `git pull` would fail.
765
+ await seedGithubCredentials(cfg);
766
+ await ensureEditorContainerUp(cfg);
767
+ await seedAgentApiKey(cfg);
768
+ const tunnelChild = await openTunnel(cfg);
769
+ open(`http://localhost:${EDITOR_PORT}`);
770
+ attachKeepAliveAndTeardown(tunnelChild);
771
+ }
772
+
773
+ async function cmdTunnel(args) {
774
+ ensureGcloud();
775
+ const flags = parseFlags(args);
776
+ const existing = loadCloudConfig(rootDir);
777
+ if (!existing) {
778
+ throw new Error(
779
+ "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
780
+ );
781
+ }
782
+ const cfg = { ...existing, ...flags };
783
+
784
+ const state = fetchInstanceState(cfg);
785
+ if (state === null) {
786
+ throw new Error(
787
+ `Instance ${cfg.instanceName} not found in project ${cfg.project} / zone ${cfg.zone}. ` +
788
+ "Has it been deleted? Try `npm run cloud:up`.",
789
+ );
790
+ }
791
+ if (state !== "RUNNING") {
792
+ throw new Error(
793
+ `Instance ${cfg.instanceName} is ${state}; run \`npm run cloud:up\` to start it.`,
794
+ );
795
+ }
796
+
797
+ const tunnelChild = await openTunnel(cfg);
798
+ open(`http://localhost:${EDITOR_PORT}`);
799
+ attachKeepAliveAndTeardown(tunnelChild);
800
+ }
801
+
802
+ async function cmdStop(args) {
803
+ ensureGcloud();
804
+ parseFlags(args);
805
+ const cfg = loadCloudConfig(rootDir);
806
+ if (!cfg) {
807
+ throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
808
+ }
809
+ console.error(`Stopping ${cfg.instanceName}...`);
810
+ const result = runGcloud(buildGcloudArgs("instances-stop", cfg));
811
+ if (result.status !== 0) {
812
+ throw new Error(`Failed to stop instance ${cfg.instanceName}`);
813
+ }
814
+ console.error(
815
+ `\n ✓ ${cfg.instanceName} is stopped.\n` +
816
+ " Persistent-disk billing continues (~$10/month for 100 GB pd-balanced).\n" +
817
+ " Resume with `npm run cloud:up`.\n",
818
+ );
819
+ }
820
+
821
+ async function cmdDestroy(args) {
822
+ ensureGcloud();
823
+ parseFlags(args);
824
+ const cfg = loadCloudConfig(rootDir);
825
+ if (!cfg) {
826
+ throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
827
+ }
828
+
829
+ const confirmed = await promptForInstanceName(cfg.instanceName);
830
+ if (!confirmed) {
831
+ console.error("Aborted.");
832
+ return;
833
+ }
834
+
835
+ console.error(`Deleting instance ${cfg.instanceName}...`);
836
+ const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
837
+ if (del.status !== 0) {
838
+ throw new Error(`Failed to delete instance ${cfg.instanceName}`);
839
+ }
840
+
841
+ const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
842
+ stdio: ["ignore", "pipe", "ignore"],
843
+ });
844
+ const others = String(list.stdout || "").trim().split("\n").filter(Boolean);
845
+ if (others.length === 0) {
846
+ console.error(`No other ${INSTANCE_TAG}-tagged instances remain — deleting firewall rule...`);
847
+ const fwDel = runGcloud(buildGcloudArgs("firewall-delete", cfg));
848
+ if (fwDel.status !== 0) {
849
+ console.error(` Warning: failed to delete firewall rule ${FIREWALL_RULE_NAME}.`);
850
+ }
851
+ } else {
852
+ console.error(
853
+ `Leaving ${FIREWALL_RULE_NAME} in place (${others.length} other ${INSTANCE_TAG}-tagged instance(s) still use it).`,
854
+ );
855
+ }
856
+
857
+ try {
858
+ fs.unlinkSync(cloudConfigPath(rootDir));
859
+ console.error("Deleted .codeyam/cloud.json");
860
+ } catch (e) {
861
+ if (e.code !== "ENOENT") throw e;
862
+ }
863
+ }
864
+
865
+ async function cmdStatus(args) {
866
+ ensureGcloud();
867
+ parseFlags(args);
868
+ const cfg = loadCloudConfig(rootDir);
869
+ if (!cfg) {
870
+ console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
871
+ return;
872
+ }
873
+
874
+ const vmState = fetchInstanceState(cfg) || "NOT_FOUND";
875
+ console.error(`VM: ${cfg.instanceName} (${cfg.zone}) — ${vmState}`);
876
+
877
+ if (vmState !== "RUNNING") {
878
+ console.error(" (image / container / tunnel checks skipped — VM is not RUNNING)");
879
+ return;
880
+ }
881
+
882
+ const imgProbe = runGcloud(
883
+ buildGcloudArgs("ssh-probe", cfg, {
884
+ command:
885
+ "docker image inspect codeyam-editor:local >/dev/null 2>&1 && echo built || echo missing",
886
+ }),
887
+ { stdio: ["ignore", "pipe", "ignore"] },
888
+ );
889
+ const imgStatus = String(imgProbe.stdout || "").trim() || "?";
890
+ console.error(` image: ${imgStatus}`);
891
+
892
+ const repoName = repoBasename(cfg.repo);
893
+ const containerProbe = runGcloud(
894
+ buildGcloudArgs("ssh-probe", cfg, {
895
+ command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
896
+ }),
897
+ { stdio: ["ignore", "pipe", "ignore"] },
898
+ );
899
+ const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
900
+ console.error(` editor container: ${containerStatus}`);
901
+
902
+ const p14199 = await isPortInUse(EDITOR_PORT);
903
+ const p5173 = await isPortInUse(VITE_PORT);
904
+ console.error(
905
+ ` tunnel: ${EDITOR_PORT} ${p14199 ? "✓" : "✗"} ${VITE_PORT} ${p5173 ? "✓" : "✗"}`,
906
+ );
907
+ }
908
+
909
+ const HANDLERS = {
910
+ up: cmdUp,
911
+ tunnel: cmdTunnel,
912
+ stop: cmdStop,
913
+ destroy: cmdDestroy,
914
+ status: cmdStatus,
915
+ };
916
+
917
+ async function cloudMain() {
918
+ const subcmd = process.argv[2];
919
+ const args = process.argv.slice(3);
920
+ if (!subcmd || !Object.prototype.hasOwnProperty.call(HANDLERS, subcmd)) {
921
+ console.error(
922
+ `Usage: npm run cloud:<${Object.keys(HANDLERS).join("|")}> [-- flags]`,
923
+ );
924
+ process.exit(2);
925
+ }
926
+ try {
927
+ await HANDLERS[subcmd](args);
928
+ } catch (e) {
929
+ console.error(`Error: ${e.message}`);
930
+ process.exit(1);
931
+ }
932
+ }
933
+
934
+ module.exports = {
935
+ // pure helpers
936
+ agentEnvVarName,
937
+ AGENT_ENV_VARS,
938
+ KNOWN_FLAGS,
939
+ FIREWALL_RULE_NAME,
940
+ INSTANCE_TAG,
941
+ EDITOR_PORT,
942
+ VITE_PORT,
943
+ parseFlags,
944
+ repoBasename,
945
+ deriveInstanceName,
946
+ cloudConfigPath,
947
+ loadCloudConfig,
948
+ saveCloudConfig,
949
+ buildEditorLocalEnvPatch,
950
+ buildGcloudArgs,
951
+ sshScriptCommand,
952
+ parseEditorComposeState,
953
+ fetchInstanceState,
954
+ // side-effecting handlers
955
+ cmdUp,
956
+ cmdTunnel,
957
+ cmdStop,
958
+ cmdDestroy,
959
+ cmdStatus,
960
+ cloudMain,
961
+ seedGithubCredentials,
962
+ };
963
+
964
+ if (require.main === module) {
965
+ cloudMain();
966
+ }