@codeyam-editor/codeyam-editor 0.1.1 → 0.1.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.
Files changed (53) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1929 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +368 -37
  6. package/npm/editor.js +133 -52
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-branch.js +49 -0
  10. package/npm/fleet-broker-death.js +44 -0
  11. package/npm/fleet-broker-debounce.js +63 -0
  12. package/npm/fleet-broker-status.js +45 -0
  13. package/npm/fleet-cleanup.js +297 -0
  14. package/npm/fleet-effect-status.js +179 -0
  15. package/npm/fleet-env.js +125 -0
  16. package/npm/fleet-grouping.js +56 -0
  17. package/npm/fleet-job-badge.js +39 -0
  18. package/npm/fleet-memory.js +157 -0
  19. package/npm/fleet-pool-policy.js +78 -0
  20. package/npm/fleet-pool.js +401 -0
  21. package/npm/fleet-queue.js +40 -0
  22. package/npm/fleet-reconcile.js +36 -0
  23. package/npm/fleet-reset.js +222 -0
  24. package/npm/fleet-roster.js +81 -0
  25. package/npm/launch-config.js +622 -0
  26. package/npm/preflight-guidance.js +77 -0
  27. package/npm/scenario-check.js +323 -14
  28. package/npm/scenario-handlers.js +16 -5
  29. package/npm/scenario-interactivity.js +245 -0
  30. package/npm/scenario-metrics.js +16 -2
  31. package/npm/scenario-mocks.js +62 -25
  32. package/npm/scenario-playwright.js +350 -8
  33. package/npm/utils.js +562 -22
  34. package/npm/vm-urls.js +117 -0
  35. package/package.json +8 -7
  36. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  37. package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
  38. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  39. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  40. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
  41. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
  42. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
  43. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  44. package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
  45. package/ui/dist/assets/index-KDFAgt2e.js +27 -0
  46. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  47. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  48. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  49. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  50. package/ui/dist/favicon.ico +0 -0
  51. package/ui/dist/favicon.png +0 -0
  52. package/ui/dist/index.html +16 -0
  53. package/npm/postinstall.js +0 -132
package/npm/cloud.js ADDED
@@ -0,0 +1,1929 @@
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
+ // `FORWARD_ENV_KEYS_ENV` is the env-var name (`CY_FORWARD_ENV_KEYS`) the
25
+ // fleet dashboard sets on `cloud:up`'s child to name the subset of
26
+ // `process.env` keys that came from the operator-supplied launch form.
27
+ // Imported from `fleet-env` (single source of truth) so the dashboard and
28
+ // cloud:up can never drift on the contract — a string mismatch would
29
+ // silently disable the forwarding.
30
+ const { FORWARD_ENV_KEYS_ENV } = require("./fleet-env");
31
+ // Deterministic fleet-ingress URL derivation. `resolveFleetDomain` reads the
32
+ // operator's owned domain from env (null when unset); `vmUrls` derives a VM's
33
+ // stable `https://vm-<N>.preview.<domain>` previewOrigin from its number. When
34
+ // no domain is configured these stay dormant and the legacy quick-tunnel seed
35
+ // path runs unchanged.
36
+ const { resolveFleetDomain, vmUrls } = require("./vm-urls");
37
+ // Shared fleet-roster writer. `cloud:up` rosters the VM it just provisioned so
38
+ // the dashboard, the cloudflared-monitor tunnel publisher, and the watchdog see
39
+ // it within one poll cycle — without a manual fleet-vms.json edit (the
40
+ // invisible-VM gap improve38 closes).
41
+ const { addToRoster } = require("./fleet-roster");
42
+
43
+ // =============================================================================
44
+ // Pure helpers — every export here is unit-tested in cloud.test.js without
45
+ // touching `gcloud` or the network.
46
+ // =============================================================================
47
+
48
+ const AGENT_ENV_VARS = {
49
+ claude: "ANTHROPIC_API_KEY",
50
+ codex: "OPENAI_API_KEY",
51
+ gemini: "GEMINI_API_KEY",
52
+ };
53
+
54
+ const KNOWN_FLAGS = [
55
+ "repo", "branch", "instance-name", "project", "zone", "agent",
56
+ // Editor-source selection, decoupled from the client project (repo/branch):
57
+ "editor-repo", "editor-branch", "editor-variant",
58
+ // Greenfield: launch against a NEW empty folder (no repo to clone) named
59
+ // <name>; the bootstrap mkdir+git-init+inits it and the editor's onboarding
60
+ // workflow takes over. Mutually exclusive with --repo.
61
+ "new-project",
62
+ // Provisioning-only: skip cloud:up's own laptop tunnel (the caller opens its
63
+ // own). Lets cloud:up exit cleanly + avoids parallel runs contending on the
64
+ // fixed local ports 14199/5173.
65
+ "no-tunnel",
66
+ // Opt out of the project's `npm run setup` at provision time. The bootstrap
67
+ // runs setup by default when the project ships one; pass this for projects
68
+ // where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
69
+ "skip-setup",
70
+ ];
71
+
72
+ // Flags that take no value (presence = true). parseFlags must not demand a
73
+ // value for these or `--no-tunnel` would error "requires a value".
74
+ const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
75
+
76
+ const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
77
+ const INSTANCE_TAG = "codeyam-editor";
78
+ const EDITOR_PORT = 14199;
79
+ const VITE_PORT = 5173;
80
+
81
+ // Sentinel stamped into editor.local.json's `_managedPreviewOrigin` whenever
82
+ // WE (cloud:up's seed, or the operator's cloudflared-monitor) write the
83
+ // previewOrigin. It lets the monitor tell its own writes (overwrite freely on
84
+ // respawn) apart from a user-pinned origin (never clobber). Must match the
85
+ // literal the bash monitor writes — see scripts/operator/cloudflared-monitor.sh.
86
+ const MANAGED_PREVIEW_ORIGIN = "cloudflared-monitor";
87
+
88
+ // The reserved same-origin path the Live Preview is served under. Seeded as the
89
+ // quick-tunnel `previewOrigin` so the iframe loads the app from the editor's own
90
+ // origin (relative path, no `localhost:<port>` URL) — killing Chrome's Private
91
+ // Network Access block and any cross-VM port collision. A constant relative path
92
+ // never rotates, so the cloudflared-monitor has nothing to push for it. Mirrors
93
+ // the Rust `preview_proxy_route::PREVIEW_PATH_PREFIX`.
94
+ const SAME_ORIGIN_PREVIEW_PATH = "/__codeyam_preview";
95
+
96
+ // Operator-laptop sidecar the cloudflared-monitor writes per-VM tunnel URLs
97
+ // into. cloud:up reads it to seed a VM's previewOrigin with the public
98
+ // `<N>-preview` URL when the monitor has already published one. Same default
99
+ // path + env override the monitor and fleet dashboard use.
100
+ const CLOUDFLARED_URLS_FILE =
101
+ process.env.URLS_FILE ||
102
+ path.join(rootDir, ".codeyam", "logs", "cloudflared-urls.json");
103
+
104
+ // Canonical codeyam-editor repository — the default source for the editor
105
+ // IMAGE, independent of the CLIENT project being edited (cfg.repo). Decoupling
106
+ // these is what lets one VM edit client project A while running the editor
107
+ // built from codeyam-editor branch B. Before this split, cfg.repo/cfg.branch
108
+ // fed BOTH the image build and the /workspace clone, so only the self-host case
109
+ // (client === editor) worked; a non-self-host VM tried to build the editor
110
+ // image out of the client repo.
111
+ const CANONICAL_EDITOR_REPO = "https://github.com/codeyam-ai/codeyam-editor.git";
112
+
113
+ // The editor distribution channels an operator can pick per VM. All three are
114
+ // provisionable (gcp-bootstrap dispatches on --editor-variant):
115
+ // dev — build the editor image from the editor repo @ editorBranch
116
+ // staging — run the published `@codeyam-editor/codeyam-editor@staging` image
117
+ // production — run the published `@codeyam-editor/codeyam-editor` (latest) image
118
+ const EDITOR_VARIANTS = ["dev", "staging", "production"];
119
+
120
+ /** Map an agent name to its API-key env var. Throws on unknown agents so
121
+ * adding an agent is a one-entry change in `AGENT_ENV_VARS`, never a
122
+ * silent fallthrough that ships unauthenticated. */
123
+ function agentEnvVarName(agent) {
124
+ if (!Object.prototype.hasOwnProperty.call(AGENT_ENV_VARS, agent)) {
125
+ throw new Error(
126
+ `Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_ENV_VARS).join(", ")}`,
127
+ );
128
+ }
129
+ return AGENT_ENV_VARS[agent];
130
+ }
131
+
132
+ /** Parse `--flag value` argv into an object. Unknown flags or missing values
133
+ * throw with a usage hint — silently dropping them would let typos like
134
+ * `--branchh main` succeed with no branch flag at all. */
135
+ function parseFlags(argv) {
136
+ const flags = {};
137
+ for (let i = 0; i < argv.length; i++) {
138
+ const arg = argv[i];
139
+ if (!arg.startsWith("--")) {
140
+ throw new Error(
141
+ `Unexpected positional argument: ${arg}\n` +
142
+ `Usage: npm run cloud:<up|tunnel|stop|destroy|status> -- [${KNOWN_FLAGS
143
+ .map((f) => `--${f} <value>`)
144
+ .join("] [")}]`,
145
+ );
146
+ }
147
+ const key = arg.slice(2);
148
+ if (!KNOWN_FLAGS.includes(key)) {
149
+ throw new Error(`Unknown flag: ${arg}. Known: --${KNOWN_FLAGS.join(", --")}`);
150
+ }
151
+ const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
152
+ if (BOOLEAN_FLAGS.includes(key)) {
153
+ flags[camel] = true;
154
+ continue;
155
+ }
156
+ const value = argv[i + 1];
157
+ if (value === undefined || value.startsWith("--")) {
158
+ throw new Error(`${arg} requires a value`);
159
+ }
160
+ flags[camel] = value;
161
+ i++;
162
+ }
163
+ return flags;
164
+ }
165
+
166
+ /** Strip `.git`, take the basename, lowercase, collapse non-alphanumerics to
167
+ * hyphens. Mirrors the existing `scripts/gcp-project-bootstrap.sh` derivation
168
+ * so the directory layout on the VM matches the instance name 1:1. */
169
+ function repoBasename(repoUrl) {
170
+ const stripped = repoUrl.replace(/\.git$/i, "");
171
+ const parts = stripped.split(/[/:]+/).filter(Boolean);
172
+ return parts[parts.length - 1] || "project";
173
+ }
174
+
175
+ /** Construct a deterministic instance name from a repo URL. GCE caps
176
+ * instance names at 63 chars; we cap at 40 to keep room for hyphen
177
+ * separators and stay well below the limit. */
178
+ function deriveInstanceName(repoUrl) {
179
+ const base = repoBasename(repoUrl);
180
+ let slug = base
181
+ .toLowerCase()
182
+ .replace(/[^a-z0-9]+/g, "-")
183
+ .replace(/^-+|-+$/g, "");
184
+ if (!slug) slug = "project";
185
+ let name = `codeyam-editor-${slug}`;
186
+ if (name.length > 40) name = name.slice(0, 40).replace(/-+$/g, "");
187
+ return name;
188
+ }
189
+
190
+ /** Normalize two repo URLs for equality: strip a trailing `.git`, lowercase.
191
+ * Used to detect the self-host case (client repo IS the editor repo). */
192
+ function sameRepo(a, b) {
193
+ const norm = (s) => String(s || "").replace(/\.git$/i, "").toLowerCase();
194
+ return !!a && !!b && norm(a) === norm(b);
195
+ }
196
+
197
+ /** Pick the default GCE instance name a freshly launched `cloud:up` should
198
+ * mint for `repoUrl`. The canonical fleet repo (`codeyam-editor`) is part of
199
+ * the numbered fleet — VM-1 must match the dashboard's `INSTANCE_PREFIX + 1`
200
+ * shape (`<base>-1`), every VM is `<base>-<n>`. A client repo (single-VM)
201
+ * keeps the un-suffixed base (`codeyam-editor-<slug>`). Pure / disk-free so
202
+ * the convention is testable without going through loadOrInitCloudConfig. */
203
+ function defaultInstanceNameFor(repoUrl) {
204
+ const base = deriveInstanceName(repoUrl);
205
+ return sameRepo(repoUrl, CANONICAL_EDITOR_REPO) ? `${base}-1` : base;
206
+ }
207
+
208
+ /** Resolve the fleet VM number from an instance name, or null when the name
209
+ * isn't a fleet self-host instance. The fleet base is the instance name
210
+ * `deriveInstanceName` returns for the canonical editor repo; every VM is
211
+ * `<base>-<n>` (VM-1 = `<base>-1`, VM-N = `<base>-N`), matching the
212
+ * dashboard's INSTANCE_PREFIX + n. As a back-compat read path the bare
213
+ * `<base>` (no suffix) also resolves to VM-1, so any lingering legacy
214
+ * instance or old log is still recognized. A non-fleet client project
215
+ * (`codeyam-editor-<slug>`) returns null so its previewOrigin seed never
216
+ * picks up a fleet tunnel URL. */
217
+ function resolveFleetVmNumber(instanceName) {
218
+ const base = deriveInstanceName(CANONICAL_EDITOR_REPO);
219
+ const name = String(instanceName || "");
220
+ if (name === base) return 1;
221
+ const m = new RegExp(`^${base}-(\\d+)$`).exec(name);
222
+ return m ? Number(m[1]) : null;
223
+ }
224
+
225
+ /** Roster a just-provisioned VM into the fleet dashboard's view so it (and the
226
+ * cloudflared-monitor + watchdog that read the same roster) pick it up within
227
+ * one poll cycle — closing the invisible-VM gap where a CLI `cloud:up` left the
228
+ * VM out of `fleet-vms.json` until a manual edit.
229
+ *
230
+ * Best-effort and non-fatal: a roster-write failure warns loudly (surfacing it
231
+ * is the whole point of this fix) but never aborts an otherwise-successful
232
+ * provision. A non-fleet instance name (`resolveFleetVmNumber` → null, e.g. a
233
+ * guest/standalone editor) is skipped — only fleet-numbered VMs roster.
234
+ *
235
+ * Returns `{ status, vm }` where `status` is `"rostered" | "skipped" |
236
+ * "failed"`. `deps` is injectable so the unit test drives every branch without
237
+ * touching the real roster file. */
238
+ function rosterProvisionedVm(instanceName, deps = {}) {
239
+ const resolve = deps.resolveFleetVmNumber || resolveFleetVmNumber;
240
+ const add = deps.addToRoster || addToRoster;
241
+ const log = deps.log || ((m) => console.error(m));
242
+ const n = resolve(instanceName);
243
+ if (n == null) {
244
+ log(`(instance '${instanceName}' is not a fleet VM — skipping dashboard roster)`);
245
+ return { status: "skipped", vm: null };
246
+ }
247
+ try {
248
+ add(n);
249
+ log(`✓ rostered VM-${n} into the fleet dashboard`);
250
+ return { status: "rostered", vm: n };
251
+ } catch (e) {
252
+ log(
253
+ `⚠ failed to roster VM-${n} (${e && e.message}). The VM is provisioned ` +
254
+ "but the dashboard won't show it until it's rostered — add it via the " +
255
+ "dashboard's Adopt action or re-run cloud:up.",
256
+ );
257
+ return { status: "failed", vm: n };
258
+ }
259
+ }
260
+
261
+ /** Resolve the app's dev-server port from a parsed editor.json plus an optional
262
+ * editor.local.json overlay, mirroring `EditorConfig::resolved_port()`:
263
+ * `apps[0].port` wins, then the top-level `port`, then `VITE_PORT` (5173) as
264
+ * the final fallback for a config that specifies neither. editor.local.json
265
+ * fields override editor.json (deep-merge precedence — the same rule the editor
266
+ * applies via `load_config_with_overrides`). The runtime env override
267
+ * (`CODEYAM_APP_PORT_OVERRIDE`) is intentionally NOT consulted here: cloud:up
268
+ * reads the static, committed config at provision time. */
269
+ function resolveConfiguredAppPort(editorJson = {}, editorLocalJson = {}) {
270
+ const merged = deepMergeJson(editorJson || {}, editorLocalJson || {});
271
+ const apps0 =
272
+ Array.isArray(merged.apps) && merged.apps[0] ? merged.apps[0] : {};
273
+ const raw = apps0.port != null ? apps0.port : merged.port;
274
+ const n = Number(raw);
275
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : VITE_PORT;
276
+ }
277
+
278
+ /** Decide the previewOrigin patch to seed into a VM's editor.local.json.
279
+ *
280
+ * Named-tunnel mode (FLEET_DOMAIN set, the VM is a fleet self-host VM): the
281
+ * previewOrigin is the VM's *deterministic* preview hostname
282
+ * (`https://vm-<N>.preview.<domain>`), derived purely from the VM number. It
283
+ * never rotates, so there is no monitor URL to look up and — crucially — no
284
+ * `http://localhost:<port>` fallback that could silently point the iframe at an
285
+ * unreachable origin (the failure class improve38 closes). The named tunnel
286
+ * fronts the dev server on whatever port it serves, so the seed stays
287
+ * port-agnostic in this mode.
288
+ *
289
+ * Legacy quick-tunnel mode (no FLEET_DOMAIN, or a non-fleet client project):
290
+ * when the operator's cloudflared-monitor has already published a `<N>-preview`
291
+ * quick-tunnel URL in cloudflared-urls.json, that URL is the steady-state
292
+ * origin; otherwise fall back to the in-VM dev-server port (`appPort`, defaults
293
+ * to 5173) — the bootstrap value for the window before the monitor's first
294
+ * tick. This is the unchanged behavior for any fleet that hasn't adopted a
295
+ * named-tunnel domain yet.
296
+ *
297
+ * Either way we stamp `_managedPreviewOrigin` so the (legacy) monitor
298
+ * recognises the value as ours and won't mistake the cloud:up seed for a user
299
+ * pin. `env` is injectable so the unit test can drive both modes. */
300
+ function resolveSeedPreviewOrigin(
301
+ instanceName,
302
+ urls = {},
303
+ appPort = VITE_PORT,
304
+ env = process.env,
305
+ sameOriginSafe = true,
306
+ ) {
307
+ // Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
308
+ // same-origin under the reserved relative path, in every fleet mode. The
309
+ // editor mounts `/__codeyam_preview` on its own origin and forwards to the
310
+ // in-VM app-port reverse proxy, so the iframe loads from the exact origin the
311
+ // editor is served on (public quick-tunnel, named tunnel, SSH-tunnel
312
+ // localhost, anything). It is a CONSTANT relative path: it never rotates and
313
+ // depends only on the editor's own origin being reachable, so it can never rot
314
+ // the way a frozen managed quick-tunnel URL does (the VM-1/2/6 leftover this
315
+ // plan fixes). No `_managedPreviewOrigin` marker is stamped — the marker is
316
+ // reserved for the genuinely-managed dedicated origins below, so the self-heal
317
+ // predicate can tell a rotting managed URL apart from this constant path.
318
+ if (sameOriginSafe) {
319
+ return { previewOrigin: SAME_ORIGIN_PREVIEW_PATH };
320
+ }
321
+
322
+ // NOT same-origin-safe (client-routed stacks — Next.js etc.): the client
323
+ // router breaks the same-origin subpath proxy (it pushState's to root-absolute
324
+ // paths and recurses into the editor), so the preview must come from a
325
+ // DEDICATED origin. We stamp `_managedPreviewOrigin` so the (legacy) monitor
326
+ // recognises the value as ours and won't mistake it for a user pin.
327
+ const vm = resolveFleetVmNumber(instanceName);
328
+ const domain = resolveFleetDomain(env);
329
+ // Named-tunnel mode: the deterministic `vm-<N>.preview.<domain>` host is a
330
+ // dedicated sibling subdomain of the editor and never rotates. A non-fleet
331
+ // client project (vm == null) keeps the localhost seed even with a domain set.
332
+ if (domain && vm != null) {
333
+ return {
334
+ previewOrigin: vmUrls(vm, domain).preview,
335
+ _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
336
+ };
337
+ }
338
+ // Quick-tunnel / domain-free mode: prefer the operator's published
339
+ // `<N>-preview` quick-tunnel URL (the public origin a remote browser can
340
+ // actually load), else the SSH-tunnel bootstrap localhost origin
341
+ // (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
342
+ // the same-origin subpath — that is exactly what recurses for this stack.
343
+ const previewKey = vm != null ? `${vm}-preview` : null;
344
+ const tunnelUrl =
345
+ previewKey &&
346
+ typeof urls[previewKey] === "string" &&
347
+ urls[previewKey].length > 0
348
+ ? urls[previewKey]
349
+ : `http://localhost:${appPort}`;
350
+ return {
351
+ previewOrigin: tunnelUrl,
352
+ _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
353
+ };
354
+ }
355
+
356
+ /** Extract the stack's `preview.sameOriginSafe` flag from a parsed
357
+ * `.codeyam/stack.json` object. Absent / non-object / non-boolean ⇒ `true`
358
+ * (the same-origin default), mirroring `PreviewConfig::same_origin_safe()` on
359
+ * the Rust side so the seeder and the editor agree on the truth table. Pure. */
360
+ function previewSameOriginSafeFromStack(stackJson) {
361
+ const preview =
362
+ stackJson && typeof stackJson === "object" ? stackJson.preview : null;
363
+ const flag =
364
+ preview && typeof preview === "object" ? preview.sameOriginSafe : undefined;
365
+ return typeof flag === "boolean" ? flag : true;
366
+ }
367
+
368
+ // Matches an absolute cloudflared quick-tunnel origin
369
+ // (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
370
+ // URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
371
+ const TRYCLOUDFLARE_ORIGIN_RE =
372
+ /^https:\/\/[a-z0-9-]+\.trycloudflare\.com(?:[:/?#]|$)/i;
373
+
374
+ /** Decide whether a VM's current `editor.local.json` carries a ROTTED managed
375
+ * cloudflared previewOrigin that must be self-healed back to the same-origin
376
+ * path. True iff ALL hold:
377
+ * - the stack is same-origin-safe (a client-routed stack legitimately keeps a
378
+ * dedicated origin — never heal it);
379
+ * - the value is stamped `_managedPreviewOrigin: cloudflared-monitor` (WE
380
+ * wrote it, not a user pin); and
381
+ * - the `previewOrigin` is an absolute `*.trycloudflare.com` quick-tunnel URL
382
+ * (the rotating kind that goes dead — a deterministic named-tunnel host or
383
+ * the constant `/__codeyam_preview` path is left alone).
384
+ * Idempotent by construction: a VM already on `/__codeyam_preview` (or any
385
+ * non-trycloudflare origin) returns false, so re-running the heal is a no-op.
386
+ * Pure — `localJson` is a parsed editor.local.json object. */
387
+ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
388
+ if (!sameOriginSafe) return false;
389
+ if (!localJson || typeof localJson !== "object") return false;
390
+ if (localJson._managedPreviewOrigin !== MANAGED_PREVIEW_ORIGIN) return false;
391
+ return (
392
+ typeof localJson.previewOrigin === "string" &&
393
+ TRYCLOUDFLARE_ORIGIN_RE.test(localJson.previewOrigin)
394
+ );
395
+ }
396
+
397
+ /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
398
+ * → {} (the monitor may not have run yet; the seed falls back to localhost). */
399
+ function readCloudflaredUrls() {
400
+ try {
401
+ const parsed = JSON.parse(fs.readFileSync(CLOUDFLARED_URLS_FILE, "utf8"));
402
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
403
+ ? parsed
404
+ : {};
405
+ } catch {
406
+ return {};
407
+ }
408
+ }
409
+
410
+ /** Resolve the editor-source config (repo/branch/variant) from cfg, applying
411
+ * defaults. `editorRepo` defaults to CANONICAL_EDITOR_REPO so a non-self-host
412
+ * client project still builds its editor image from the editor source, not the
413
+ * client repo. Self-host back-compat: when the client repo IS the editor repo
414
+ * and no explicit editorBranch was given, the client branch doubles as the
415
+ * editor source branch — so `cloud:up --repo codeyam-editor --branch X` still
416
+ * builds the editor at X, exactly as it did before the decoupling. */
417
+ function resolveEditorConfig(cfg = {}) {
418
+ const editorRepo = cfg.editorRepo || CANONICAL_EDITOR_REPO;
419
+ let editorBranch = cfg.editorBranch;
420
+ if ((editorBranch == null || editorBranch === "") && sameRepo(cfg.repo, editorRepo)) {
421
+ editorBranch = cfg.branch;
422
+ }
423
+ const editorVariant = cfg.editorVariant || "dev";
424
+ return { editorRepo, editorBranch: editorBranch || undefined, editorVariant };
425
+ }
426
+
427
+ /** Throw on an unknown editor variant (a typo'd `--editor-variant` should fail,
428
+ * not silently fall through to dev). */
429
+ function validateEditorVariant(variant) {
430
+ if (!EDITOR_VARIANTS.includes(variant)) {
431
+ throw new Error(
432
+ `Unknown editor variant: ${variant}. Supported: ${EDITOR_VARIANTS.join(", ")}`,
433
+ );
434
+ }
435
+ return variant;
436
+ }
437
+
438
+ /** Assert a variant can be provisioned onto a cloud VM. Every known variant is
439
+ * provisionable now — `dev` builds the editor image from source, while
440
+ * `staging`/`production` run the published npm-package image (gcp-bootstrap
441
+ * pulls/builds Dockerfile.npm keyed off the variant). So this collapses to the
442
+ * unknown-variant guard: a typo'd `--editor-variant` fails loud rather than
443
+ * silently launching the wrong (or no) image. */
444
+ function assertVariantProvisionable(variant) {
445
+ return validateEditorVariant(variant);
446
+ }
447
+
448
+ /** Args for scripts/gcp-bootstrap.sh, which produces the codeyam-editor:local
449
+ * image. For the `dev` variant that image is built from the EDITOR source
450
+ * (--repo/--branch); for packaged variants (staging/production) it comes from
451
+ * the published npm package, so the editor source repo/branch is irrelevant and
452
+ * omitted — gcp-bootstrap pulls/builds Dockerfile.npm keyed off --editor-variant.
453
+ * The CLIENT project repo/branch is unaffected: it flows through cloud:up's own
454
+ * project bootstrap, not this function. */
455
+ function editorImageBootstrapArgs(cfg) {
456
+ const { editorRepo, editorBranch, editorVariant } = resolveEditorConfig(cfg);
457
+ const args = ["--agent", cfg.agent, "--editor-variant", editorVariant];
458
+ // Stack assumption: only the `dev` variant builds the editor image from source,
459
+ // so only it needs the editor repo/branch plumbing gcp-bootstrap's source path
460
+ // consumes. Packaged variants install the binary from the published npm package.
461
+ if (editorVariant === "dev") {
462
+ args.push("--repo", editorRepo);
463
+ if (editorBranch) args.push("--branch", editorBranch);
464
+ }
465
+ return args;
466
+ }
467
+
468
+ /** Resolve the CLIENT project from cfg: either an existing repo to clone, or a
469
+ * greenfield new folder to scaffold. `name` is the on-VM directory basename
470
+ * (under ~/projects/) — derived from the repo for the clone case, or the
471
+ * given folder name for greenfield. */
472
+ /** --repo and --new-project are mutually exclusive. When one is explicitly
473
+ * passed THIS run, drop the other from the (possibly cloud.json-merged) config
474
+ * so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
475
+ * — can't override the new intent. Without this, launching `--repo X` after a
476
+ * greenfield run silently provisions the greenfield project (resolveProject
477
+ * prioritizes newProject). Mutates and returns cfg. */
478
+ function reconcileProjectExclusivity(cfg, flags = {}) {
479
+ if (flags.repo) delete cfg.newProject;
480
+ if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
481
+ return cfg;
482
+ }
483
+
484
+ function resolveProject(cfg = {}) {
485
+ if (cfg.newProject) {
486
+ return { greenfield: true, name: cfg.newProject, repo: null, branch: null };
487
+ }
488
+ return {
489
+ greenfield: false,
490
+ name: cfg.repo ? repoBasename(cfg.repo) : null,
491
+ repo: cfg.repo || null,
492
+ branch: cfg.branch || null,
493
+ };
494
+ }
495
+
496
+ /** Args for scripts/gcp-project-bootstrap.sh. For an existing repo: the repo URL
497
+ * (+ optional --branch) to clone. For greenfield: --new-project <name>, so the
498
+ * bootstrap creates an empty git-init'd folder and inits it instead of cloning. */
499
+ function clientProjectBootstrapArgs(cfg) {
500
+ const p = resolveProject(cfg);
501
+ // The bootstrap now runs `npm run setup` by default when the project ships
502
+ // one; --skip-setup is the opt-out, threaded from the cloud:up flag. (A
503
+ // greenfield launch starts from an empty git-init'd folder with no setup
504
+ // script, so the flag is a harmless no-op there.)
505
+ const setupArgs = cfg.skipSetup ? ["--skip-setup"] : [];
506
+ if (p.greenfield) return ["--new-project", p.name, ...setupArgs];
507
+ const args = [cfg.repo];
508
+ if (cfg.branch) args.push("--branch", cfg.branch);
509
+ args.push(...setupArgs);
510
+ return args;
511
+ }
512
+
513
+ function cloudConfigPath(projectDir) {
514
+ return path.join(projectDir, ".codeyam", "cloud.json");
515
+ }
516
+
517
+ /** Returns the parsed config, or `null` when the file is absent. Throws
518
+ * with the full path on malformed JSON so the user knows which file to
519
+ * fix — silently returning `null` would mask corruption as "no config". */
520
+ function loadCloudConfig(projectDir) {
521
+ const p = cloudConfigPath(projectDir);
522
+ if (!fs.existsSync(p)) return null;
523
+ let raw;
524
+ try {
525
+ raw = fs.readFileSync(p, "utf8");
526
+ } catch (e) {
527
+ throw new Error(`Failed to read ${p}: ${e.message}`);
528
+ }
529
+ try {
530
+ return JSON.parse(raw);
531
+ } catch (e) {
532
+ throw new Error(`Failed to parse ${p}: ${e.message}`);
533
+ }
534
+ }
535
+
536
+ function saveCloudConfig(projectDir, cfg) {
537
+ const p = cloudConfigPath(projectDir);
538
+ fs.mkdirSync(path.dirname(p), { recursive: true });
539
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
540
+ }
541
+
542
+ /** Build the JSON patch to merge into the VM's `editor.local.json` so the
543
+ * configured agent picks up the user's local API key on first launch.
544
+ * Returns `null` when the key isn't set locally — the wrapper then prints
545
+ * the interactive-auth fallback line instead of writing an empty patch. */
546
+ function buildEditorLocalEnvPatch(localEnv, agent) {
547
+ const varName = agentEnvVarName(agent);
548
+ const value = localEnv[varName];
549
+ if (!value) return null;
550
+ return { env: { [varName]: value } };
551
+ }
552
+
553
+ /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
554
+ * line) and return the editor service's `State` string, or null when the
555
+ * service is absent / output is unparseable. Two callers shared an identical
556
+ * inline parser; this centralizes the shape so a compose-output change is
557
+ * one update, not two. */
558
+ function parseEditorComposeState(stdout) {
559
+ if (!stdout) return null;
560
+ const lines = String(stdout).trim().split("\n").filter(Boolean);
561
+ for (const line of lines) {
562
+ let svc;
563
+ try {
564
+ svc = JSON.parse(line);
565
+ } catch {
566
+ continue;
567
+ }
568
+ if (svc && svc.Service === "editor") {
569
+ return svc.State || null;
570
+ }
571
+ }
572
+ return null;
573
+ }
574
+
575
+ /** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
576
+ * switch's job is to keep every per-verb command shape in one reviewable
577
+ * place — tests assert the arrays without ever spawning a process. */
578
+ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
579
+ const { project, zone, instanceName } = cfg;
580
+ switch (verb) {
581
+ case "config-get-project":
582
+ return ["config", "get-value", "project"];
583
+ case "config-get-zone":
584
+ return ["config", "get-value", "compute/zone"];
585
+ case "instances-create":
586
+ // The VM keeps the default ephemeral external IP so apt / docker /
587
+ // NodeSource / npm can reach the public internet during bootstrap.
588
+ // Inbound is still IAP-SSH-only via the firewall rule above; the
589
+ // external IP is for egress, not exposure. `--no-address` was the
590
+ // earlier default but produced a VM that could not install Docker
591
+ // without a Cloud NAT + Cloud Router pair the wrapper does not set up.
592
+ return [
593
+ "compute", "instances", "create", instanceName,
594
+ "--project", project, "--zone", zone,
595
+ "--machine-type=e2-standard-4",
596
+ "--image-family=debian-12",
597
+ "--image-project=debian-cloud",
598
+ "--boot-disk-size=100GB",
599
+ "--boot-disk-type=pd-balanced",
600
+ `--tags=${INSTANCE_TAG}`,
601
+ ];
602
+ case "instances-start":
603
+ return ["compute", "instances", "start", instanceName, "--project", project, "--zone", zone];
604
+ case "instances-stop":
605
+ return ["compute", "instances", "stop", instanceName, "--project", project, "--zone", zone];
606
+ case "instances-delete":
607
+ return ["compute", "instances", "delete", instanceName, "--project", project, "--zone", zone, "--quiet"];
608
+ case "instances-describe":
609
+ return ["compute", "instances", "describe", instanceName, "--project", project, "--zone", zone, "--format=json"];
610
+ case "instances-list-tagged":
611
+ return [
612
+ "compute", "instances", "list",
613
+ "--project", project,
614
+ `--filter=tags.items:${INSTANCE_TAG}`,
615
+ "--format=value(name)",
616
+ ];
617
+ case "firewall-describe":
618
+ return ["compute", "firewall-rules", "describe", FIREWALL_RULE_NAME, "--project", project, "--format=json"];
619
+ case "firewall-create":
620
+ return [
621
+ "compute", "firewall-rules", "create", FIREWALL_RULE_NAME,
622
+ "--project", project,
623
+ "--direction=INGRESS",
624
+ "--action=ALLOW",
625
+ "--rules=tcp:22",
626
+ "--source-ranges=35.235.240.0/20",
627
+ `--target-tags=${INSTANCE_TAG}`,
628
+ ];
629
+ case "firewall-delete":
630
+ return ["compute", "firewall-rules", "delete", FIREWALL_RULE_NAME, "--project", project, "--quiet"];
631
+ case "ssh-probe":
632
+ return [
633
+ "compute", "ssh", instanceName,
634
+ "--project", project, "--zone", zone,
635
+ "--tunnel-through-iap",
636
+ "--command", extras.command,
637
+ ];
638
+ case "ssh-script":
639
+ // Stream a script body as base64 over --command so we don't fight
640
+ // shell-quoting through gcloud → ssh → remote bash. The script body
641
+ // is delivered as one argument; the wrapper feeds the base64 into
642
+ // `extras.command` and the caller sets `input` to the script body.
643
+ //
644
+ // Keepalive flags are defense-in-depth for the long-running bootstrap
645
+ // follow session: a quiet stretch during `cargo build` (no stdout for
646
+ // minutes) must not trip an idle disconnect. ServerAliveInterval pings
647
+ // the VM every 30s and ServerAliveCountMax tolerates 6 missed replies
648
+ // (~3 min) before giving up, while TCPKeepAlive keeps NAT mappings warm.
649
+ // The detached server-side runner (bootstrapLaunchCommand) makes a real
650
+ // drop non-fatal; keepalive just makes spurious drops rarer.
651
+ return [
652
+ "compute", "ssh", instanceName,
653
+ "--project", project, "--zone", zone,
654
+ "--tunnel-through-iap",
655
+ ...SSH_KEEPALIVE_FLAGS,
656
+ "--command", extras.command,
657
+ ];
658
+ case "ssh-tunnel": {
659
+ // Bind BOTH IPv4 and IPv6 loopback for each port. The IPv4 `-L` is
660
+ // load-bearing for everything that explicitly dials 127.0.0.1; the
661
+ // `[::1]` `-L` exists because cloudflared (`tunnel --url
662
+ // http://localhost:N`) resolves `localhost` to `[::1]` first and,
663
+ // with an IPv4-only forward, hits connect:connection-refused until
664
+ // the QUIC stream cancels and the tunnel proc exits (~80% of
665
+ // cloudflared deaths in the 2026-05-21 fleet session). Binding the
666
+ // IPv6 loopback too lets that first dial succeed.
667
+ //
668
+ // The preview forward uses the resolved app port (extras.appPort,
669
+ // default VITE_PORT) on BOTH ends — laptop:<appPort> → VM:<appPort> —
670
+ // so the whole single-user path uses one number for a Vite 5173, a
671
+ // Next.js 3000, anything. The editor `-L` (14199) is unconditional.
672
+ const appPort = extras.appPort != null ? extras.appPort : VITE_PORT;
673
+ return [
674
+ "compute", "ssh", instanceName,
675
+ "--project", project, "--zone", zone,
676
+ "--tunnel-through-iap",
677
+ "--",
678
+ "-L", `${EDITOR_PORT}:127.0.0.1:${EDITOR_PORT}`,
679
+ "-L", `[::1]:${EDITOR_PORT}:127.0.0.1:${EDITOR_PORT}`,
680
+ "-L", `${appPort}:127.0.0.1:${appPort}`,
681
+ "-L", `[::1]:${appPort}:127.0.0.1:${appPort}`,
682
+ "-N",
683
+ ];
684
+ }
685
+ default:
686
+ throw new Error(`Unknown gcloud verb: ${verb}`);
687
+ }
688
+ }
689
+
690
+ // =============================================================================
691
+ // Side-effecting helpers — exercised end-to-end by `npm run cloud:up` in
692
+ // real use. Unit tests pin their exports so a refactor cannot drop them.
693
+ // =============================================================================
694
+
695
+ function runGcloud(args, opts = {}) {
696
+ return spawnSync("gcloud", args, {
697
+ stdio: opts.stdio || "inherit",
698
+ input: opts.input,
699
+ encoding: opts.encoding || "utf8",
700
+ });
701
+ }
702
+
703
+ function ensureGcloud() {
704
+ const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
705
+ if (result.error || result.status !== 0) {
706
+ console.error(
707
+ "gcloud is not on PATH. Install: https://cloud.google.com/sdk/docs/install",
708
+ );
709
+ process.exit(1);
710
+ }
711
+ }
712
+
713
+ function gcloudGetValue(verb) {
714
+ const result = runGcloud(buildGcloudArgs(verb), { stdio: ["ignore", "pipe", "ignore"] });
715
+ if (result.error || result.status !== 0) return null;
716
+ const val = String(result.stdout || "").trim();
717
+ if (!val || val === "(unset)") return null;
718
+ return val;
719
+ }
720
+
721
+ /** Run `gcloud compute instances describe ... --format=json` and return the
722
+ * parsed `.status` field (e.g. "RUNNING", "TERMINATED", "PROVISIONING"), or
723
+ * null when the instance is absent or describe returned unparseable output.
724
+ * Centralizes the describe-and-parse pattern shared by ensureInstance (used
725
+ * twice — initial probe + poll), cmdTunnel, and cmdStatus. */
726
+ function fetchInstanceState(cfg) {
727
+ const result = runGcloud(buildGcloudArgs("instances-describe", cfg), {
728
+ stdio: ["ignore", "pipe", "ignore"],
729
+ });
730
+ if (result.status !== 0) return null;
731
+ try {
732
+ const parsed = JSON.parse(result.stdout);
733
+ return parsed && typeof parsed.status === "string" ? parsed.status : null;
734
+ } catch {
735
+ return null;
736
+ }
737
+ }
738
+
739
+ function loadOrInitCloudConfig(projectDir, flags) {
740
+ const existing = loadCloudConfig(projectDir);
741
+ // noTunnel is a per-invocation behavior, never persisted — else a later manual
742
+ // `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
743
+ const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
744
+ if (existing) {
745
+ const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
746
+ const toSave = persist(merged);
747
+ if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
748
+ saveCloudConfig(projectDir, toSave);
749
+ }
750
+ return merged;
751
+ }
752
+
753
+ const cfg = reconcileProjectExclusivity({ ...flags }, flags);
754
+ cfg.project = cfg.project || gcloudGetValue("config-get-project");
755
+ cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
756
+ cfg.agent = cfg.agent || "claude";
757
+ agentEnvVarName(cfg.agent);
758
+
759
+ if (!cfg.repo && !cfg.newProject) {
760
+ throw new Error(
761
+ "--repo <url> (clone an existing project) or --new-project <name> (greenfield) " +
762
+ "is required on first run. Subsequent runs read .codeyam/cloud.json.",
763
+ );
764
+ }
765
+ if (!cfg.project) {
766
+ throw new Error(
767
+ "--project not provided and `gcloud config get-value project` returned no default.\n" +
768
+ "Run `gcloud config set project <id>` or pass --project <id>.",
769
+ );
770
+ }
771
+ if (!cfg.zone) {
772
+ throw new Error(
773
+ "--zone not provided and `gcloud config get-value compute/zone` returned no default.\n" +
774
+ "Run `gcloud config set compute/zone <zone>` or pass --zone <zone>.",
775
+ );
776
+ }
777
+
778
+ cfg.instanceName = cfg.instanceName || defaultInstanceNameFor(cfg.repo || cfg.newProject);
779
+ saveCloudConfig(projectDir, persist(cfg));
780
+ return cfg;
781
+ }
782
+
783
+ async function ensureFirewallRule(cfg) {
784
+ console.error(`Checking firewall rule ${FIREWALL_RULE_NAME}...`);
785
+ const describe = runGcloud(buildGcloudArgs("firewall-describe", cfg), {
786
+ stdio: ["ignore", "pipe", "pipe"],
787
+ });
788
+ if (describe.status === 0) {
789
+ console.error(` ✓ ${FIREWALL_RULE_NAME} already exists`);
790
+ return;
791
+ }
792
+ console.error(` creating ${FIREWALL_RULE_NAME}...`);
793
+ const create = runGcloud(buildGcloudArgs("firewall-create", cfg));
794
+ if (create.status !== 0) {
795
+ throw new Error(`Failed to create firewall rule ${FIREWALL_RULE_NAME}`);
796
+ }
797
+ }
798
+
799
+ async function ensureInstance(cfg) {
800
+ console.error(`Checking instance ${cfg.instanceName}...`);
801
+ const initialState = fetchInstanceState(cfg);
802
+ if (initialState === "RUNNING") {
803
+ console.error(` ✓ ${cfg.instanceName} is RUNNING`);
804
+ return;
805
+ }
806
+ if (initialState === "TERMINATED") {
807
+ console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
808
+ const start = runGcloud(buildGcloudArgs("instances-start", cfg));
809
+ if (start.status !== 0) {
810
+ throw new Error(`Failed to start instance ${cfg.instanceName}`);
811
+ }
812
+ } else if (initialState === null) {
813
+ console.error(` ${cfg.instanceName} not found — creating...`);
814
+ const create = runGcloud(buildGcloudArgs("instances-create", cfg));
815
+ if (create.status !== 0) {
816
+ throw new Error(`Failed to create instance ${cfg.instanceName}`);
817
+ }
818
+ } else {
819
+ console.error(` ${cfg.instanceName} is ${initialState}; waiting...`);
820
+ }
821
+
822
+ for (let i = 0; i < 60; i++) {
823
+ if (fetchInstanceState(cfg) === "RUNNING") {
824
+ console.error(` ✓ ${cfg.instanceName} is RUNNING`);
825
+ await waitForIapReady(cfg);
826
+ return;
827
+ }
828
+ await new Promise((r) => setTimeout(r, 2000));
829
+ }
830
+ throw new Error(`Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes`);
831
+ }
832
+
833
+ /** GCE reports the instance as RUNNING the moment the boot disk is attached,
834
+ * but the IAP control plane has its own catalog that updates a few seconds
835
+ * later. The first `gcloud compute ssh --tunnel-through-iap` after a fresh
836
+ * create races that catalog and fails with `[4047: 'Failed to lookup
837
+ * instance']`. Probe an IAP-tunneled SSH that just runs `true` until it
838
+ * succeeds — cheap on the warm path (one ~1s round-trip) and bounded so a
839
+ * genuinely unreachable VM fails loud rather than silently. */
840
+ async function waitForIapReady(cfg) {
841
+ console.error(` waiting for IAP to register ${cfg.instanceName}...`);
842
+ // 180s, not 60s: on a fresh instance the IAP catalog + SSH-key (osLogin)
843
+ // propagation can take well over a minute under fleet load, and each failed
844
+ // probe burns ~25-30s before gcloud gives up — so a 60s budget fit only ~2
845
+ // probes and aborted cloud:up before the VM was ever reachable, leaving a
846
+ // RUNNING-but-unbootstrapped instance.
847
+ const deadline = Date.now() + 180_000;
848
+ let attempts = 0;
849
+ let lastErr = "";
850
+ while (Date.now() < deadline) {
851
+ attempts++;
852
+ const probe = runGcloud(
853
+ buildGcloudArgs("ssh-probe", cfg, { command: "true" }),
854
+ { stdio: ["ignore", "pipe", "pipe"] },
855
+ );
856
+ if (probe.status === 0) {
857
+ console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
858
+ return;
859
+ }
860
+ lastErr = String(probe.stderr || "").trim().split("\n").slice(-1)[0] || "";
861
+ // Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
862
+ const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
863
+ await new Promise((r) => setTimeout(r, delay));
864
+ }
865
+ throw new Error(
866
+ `IAP did not become ready for ${cfg.instanceName} within 180s ` +
867
+ `(${attempts} probes; last error: ${lastErr || "unknown"})`,
868
+ );
869
+ }
870
+
871
+ /** Read `process.env[FORWARD_ENV_KEYS_ENV]` (comma-separated key list set by
872
+ * the fleet dashboard) and return the subset of `process.env` that should
873
+ * ride along into the VM's project-bootstrap shell. Whitelisted to the
874
+ * named keys: a fork of the dashboard's launch-form env, never the whole
875
+ * laptop env (which would leak PATH / NODE_OPTIONS / unrelated secrets).
876
+ * Empty / unset → {} so the standalone `cloud:up` path is unchanged. */
877
+ function resolveSetupForwardEnv(env = process.env) {
878
+ const list = env[FORWARD_ENV_KEYS_ENV];
879
+ if (!list) return {};
880
+ const out = {};
881
+ for (const raw of String(list).split(",")) {
882
+ const k = raw.trim();
883
+ if (!k) continue;
884
+ const v = env[k];
885
+ if (v == null || v === "") continue;
886
+ out[k] = v;
887
+ }
888
+ return out;
889
+ }
890
+
891
+ // --ssh-flag values gcloud forwards verbatim to the underlying `ssh`. Shaped as
892
+ // `-o Key=Value` so they survive the gcloud → ssh hop unchanged. Used by the
893
+ // ssh-script verb (the long bootstrap follow session) — short ssh-probe calls
894
+ // don't need them. See the ssh-script case in buildGcloudArgs for the rationale.
895
+ const SSH_KEEPALIVE_FLAGS = [
896
+ "--ssh-flag=-o ServerAliveInterval=30",
897
+ "--ssh-flag=-o ServerAliveCountMax=6",
898
+ "--ssh-flag=-o TCPKeepAlive=yes",
899
+ ];
900
+
901
+ // Canonical VM-side paths for the detached bootstrap. The build runs from
902
+ // BOOTSTRAP_RUNNER_PATH (a tiny wrapper that execs the real script), streams to
903
+ // BOOTSTRAP_LOG_PATH, and writes its exit status into BOOTSTRAP_DONE_PATH on
904
+ // completion. Completion + success are derived from these files (plus the
905
+ // docker image), NOT from the SSH exit code — so a dropped follow-session over
906
+ // a healthy VM is no longer misreported as "failed on the VM".
907
+ const BOOTSTRAP_SCRIPT_PATH = "$HOME/.codeyam-bootstrap.sh";
908
+ const BOOTSTRAP_RUNNER_PATH = "$HOME/.codeyam-bootstrap-run.sh";
909
+ const BOOTSTRAP_LOG_PATH = "$HOME/.codeyam-bootstrap.log";
910
+ const BOOTSTRAP_DONE_PATH = "$HOME/.codeyam-bootstrap.done";
911
+ const BOOTSTRAP_PID_PATH = "$HOME/.codeyam-bootstrap.pid";
912
+ const BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_BOOTSTRAP_LAUNCHED";
913
+ const BOOTSTRAP_STATUS_MARKER = "CYBOOT";
914
+ // improve40 — VM-side path of the PROJECT-bootstrap terminal completion marker.
915
+ // gcp-project-bootstrap.sh writes this as its very last step (only reached after
916
+ // `set -euo pipefail` carried every provisioning step to success), and
917
+ // ensureProjectBootstrapped's done-probe gates on it so a half-finished
918
+ // bootstrap is correctly re-run instead of false-skipped (the VM-7 incident).
919
+ // Lives outside the project's git tree (a $HOME dotfile, keyed by repo name) so
920
+ // it never churns the repo. Pure so the bash writer and the JS probe derive the
921
+ // EXACT same path; tested without a live VM.
922
+ function projectBootstrapDoneMarker(repoName) {
923
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.done`;
924
+ }
925
+ // improve40 — the VM-side shell `test` chain ensureProjectBootstrapped runs to
926
+ // decide "is this project already bootstrapped?". Gates on the terminal `.done`
927
+ // marker FIRST (the VM-7 false-skip fix — both structural artifacts can exist on
928
+ // a half-done bootstrap), then keeps the `.codeyam/` + docker-compose.cloud.yml
929
+ // checks as belt-and-suspenders for a legacy VM whose marker predates this change.
930
+ // Pure (just the command string) so the gating is unit-testable without gcloud.
931
+ function projectBootstrapDoneProbeCommand(repoName) {
932
+ return (
933
+ `test -f ${projectBootstrapDoneMarker(repoName)} ` +
934
+ `&& test -d ~/projects/${repoName}/.codeyam ` +
935
+ `&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`
936
+ );
937
+ }
938
+ // One follow session caps at 30 min (comfortably over the ~14-min source
939
+ // build); the overall wait caps at 40 min across re-attaches before we bail
940
+ // loudly with the VM-side log location.
941
+ const BOOTSTRAP_FOLLOW_SECS = 1800;
942
+ const BOOTSTRAP_DEADLINE_MS = 40 * 60 * 1000;
943
+
944
+ // Quote a shell value the same way scriptArgs are quoted: single-quote it and
945
+ // escape any embedded apostrophe. Shared by the env-prefix and arg encoders so
946
+ // a token with an exotic character can't break out of its quoting.
947
+ function shQuote(v) {
948
+ return `'${String(v).replace(/'/g, "'\\''")}'`;
949
+ }
950
+
951
+ // Encode env vars as a shell-assignment PREFIX (`KEY='value' …`). Assignment
952
+ // prefixes set the variable in the launched command's environment only — never
953
+ // in its argv — so tokens stay out of `ps` on the VM. Empty / nullish values
954
+ // are dropped so the no-token path is unchanged.
955
+ function envAssignmentPrefix(envVars) {
956
+ const s = Object.entries(envVars)
957
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
958
+ .map(([k, v]) => `${k}=${shQuote(v)}`)
959
+ .join(" ");
960
+ return s ? `${s} ` : "";
961
+ }
962
+
963
+ // The tiny VM-side wrapper the detached launch execs. It runs the real
964
+ // bootstrap, redirects all output to the log, and records the exit status in
965
+ // the sentinel — the single source of truth for "did the build finish, and
966
+ // did it succeed?" once the SSH follow-session is gone. Contains NO secrets
967
+ // (tokens arrive via the launch env prefix, never written to disk).
968
+ function bootstrapRunnerScript() {
969
+ return [
970
+ "#!/usr/bin/env bash",
971
+ "# Detached bootstrap runner (written by npm/cloud.js ensureBootstrapped).",
972
+ "# Survives SSH disconnect: the laptop only *follows* the log below; this",
973
+ "# process keeps running under its own session if the follow drops.",
974
+ "# Records its own PID so the status probe can check liveness via `kill -0`",
975
+ "# (a `pgrep` on the script path would also match the probe command itself).",
976
+ `echo "$$" > "$HOME/.codeyam-bootstrap.pid"`,
977
+ `bash "$HOME/.codeyam-bootstrap.sh" "$@" > "$HOME/.codeyam-bootstrap.log" 2>&1`,
978
+ "status=$?",
979
+ `echo "$status" > "$HOME/.codeyam-bootstrap.done"`,
980
+ `rm -f "$HOME/.codeyam-bootstrap.pid"`,
981
+ "",
982
+ ].join("\n");
983
+ }
984
+
985
+ // Build the remote command that (1) materializes the bootstrap script + runner
986
+ // on the VM, (2) clears any stale sentinel, (3) launches the runner DETACHED
987
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
988
+ // session, and (4) prints a launched marker. The detach is the durable fix:
989
+ // the build no longer dies when the laptop's SSH pipe breaks.
990
+ function bootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}) {
991
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
992
+ const runnerB64 = Buffer.from(bootstrapRunnerScript(), "utf8").toString("base64");
993
+ const argsString = scriptArgs.map(shQuote).join(" ");
994
+ const envPrefix = envAssignmentPrefix(envVars);
995
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
996
+ // aborts before launch; then the runner is detached. The env prefix is on the
997
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
998
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
999
+ // launch in the background while still confirming it fired.
1000
+ const writes = [
1001
+ `echo '${scriptB64}' | base64 -d > ${BOOTSTRAP_SCRIPT_PATH}`,
1002
+ `echo '${runnerB64}' | base64 -d > ${BOOTSTRAP_RUNNER_PATH}`,
1003
+ `rm -f ${BOOTSTRAP_DONE_PATH}`,
1004
+ ].join(" && ");
1005
+ const launch =
1006
+ `${envPrefix}nohup setsid bash ${BOOTSTRAP_RUNNER_PATH} ${argsString}`.trimEnd() +
1007
+ ` </dev/null >/dev/null 2>&1 &`;
1008
+ return `${writes}; ${launch} echo ${BOOTSTRAP_LAUNCHED_MARKER}`;
1009
+ }
1010
+
1011
+ // Build the remote command that FOLLOWS the detached build: tail the log live
1012
+ // for the operator, polling for the sentinel, and exit when the build finishes
1013
+ // OR a per-session deadline elapses. If the SSH session drops mid-follow this
1014
+ // command dies but the detached build keeps going — the caller then re-attaches.
1015
+ function bootstrapFollowCommand(deadlineSecs = BOOTSTRAP_FOLLOW_SECS) {
1016
+ return [
1017
+ `touch ${BOOTSTRAP_LOG_PATH}`,
1018
+ `tail -n +1 -F ${BOOTSTRAP_LOG_PATH} 2>/dev/null & TP=$!`,
1019
+ `waited=0`,
1020
+ `while [ ! -f ${BOOTSTRAP_DONE_PATH} ]; do ` +
1021
+ `sleep 3; waited=$((waited+3)); ` +
1022
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1023
+ `done`,
1024
+ `sleep 1`,
1025
+ `kill "$TP" 2>/dev/null`,
1026
+ ].join("; ");
1027
+ }
1028
+
1029
+ // Build the remote command that reports terminal VM state in one parseable
1030
+ // line: whether the image exists, the sentinel's exit code (or `none`), and
1031
+ // whether a bootstrap process is still alive. Cheap — runs over ssh-probe.
1032
+ function bootstrapStatusCommand() {
1033
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1034
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1035
+ // match this very probe and always report running. A stale PID file from a
1036
+ // killed runner reads as not-running because `kill -0` fails.
1037
+ return [
1038
+ `img=absent; docker image inspect codeyam-editor:local >/dev/null 2>&1 && img=present`,
1039
+ `done=none; [ -f ${BOOTSTRAP_DONE_PATH} ] && done=$(cat ${BOOTSTRAP_DONE_PATH} 2>/dev/null)`,
1040
+ `run=no; if [ -f ${BOOTSTRAP_PID_PATH} ]; then p=$(cat ${BOOTSTRAP_PID_PATH} 2>/dev/null); ` +
1041
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1042
+ `echo "${BOOTSTRAP_STATUS_MARKER} img=$img done=$done running=$run"`,
1043
+ ].join("; ");
1044
+ }
1045
+
1046
+ // Parse the single status line emitted by bootstrapStatusCommand. Returns
1047
+ // `{ img, done, running }` or null when no status line is present (e.g. the
1048
+ // probe ssh itself failed and produced no stdout).
1049
+ function parseBootstrapStatus(stdout) {
1050
+ const m = String(stdout || "").match(
1051
+ new RegExp(`${BOOTSTRAP_STATUS_MARKER} img=(\\S+) done=(\\S+) running=(\\S+)`),
1052
+ );
1053
+ if (!m) return null;
1054
+ return { img: m[1], done: m[2], running: m[3] };
1055
+ }
1056
+
1057
+ // Decide the bootstrap outcome from VM-side state alone — never the SSH exit
1058
+ // code. The image is the real artifact, so its presence is success regardless
1059
+ // of how the follow session ended. A live process means "re-attach". A sentinel
1060
+ // without an image means the build ran and failed. Nothing at all means it was
1061
+ // never started (or the probe couldn't see it).
1062
+ function decideBootstrapOutcome(status) {
1063
+ if (!status) return "absent";
1064
+ const { img, done, running } = status;
1065
+ if (img === "present") return "success";
1066
+ if (running === "yes") return "running";
1067
+ if (done && done !== "none") return "failed";
1068
+ return "absent";
1069
+ }
1070
+
1071
+ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
1072
+ const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
1073
+ // Args + env share the quoting/encoding helpers with bootstrapLaunchCommand:
1074
+ // single-quoted args (shQuote) and a `KEY='value'` assignment prefix
1075
+ // (envAssignmentPrefix) that sets the vars for the `bash -s` process only —
1076
+ // no persistence in the VM's shell history, no argv exposure of tokens.
1077
+ const argsString = scriptArgs.map(shQuote).join(" ");
1078
+ const envPrefix = envAssignmentPrefix(envVars);
1079
+ return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
1080
+ }
1081
+
1082
+ async function ensureBootstrapped(cfg) {
1083
+ console.error("Checking VM bootstrap state...");
1084
+ const probe = runGcloud(
1085
+ buildGcloudArgs("ssh-probe", cfg, {
1086
+ command: "command -v docker >/dev/null && docker image inspect codeyam-editor:local >/dev/null 2>&1",
1087
+ }),
1088
+ { stdio: ["ignore", "pipe", "pipe"] },
1089
+ );
1090
+ if (probe.status === 0) {
1091
+ console.error(" ✓ VM already bootstrapped (docker + codeyam-editor:local present)");
1092
+ return;
1093
+ }
1094
+ console.error(" streaming scripts/gcp-bootstrap.sh to VM (this may take ~10 minutes)...");
1095
+ const scriptPath = path.join(rootDir, "scripts", "gcp-bootstrap.sh");
1096
+ const scriptBody = fs.readFileSync(scriptPath, "utf8");
1097
+ // GITHUB_TOKEN is passed through to the bootstrap script so it can clone
1098
+ // a private editor repo (the bootstrap clones $REPO_URL to build the
1099
+ // codeyam-editor:local image). Without this the git clone hits the
1100
+ // "could not read Username for 'https://github.com'" wall whenever the
1101
+ // repo isn't public — codeyam-editor itself today.
1102
+ //
1103
+ // Build the editor image from the EDITOR source (cfg.editorRepo/editorBranch),
1104
+ // decoupled from the client project (cfg.repo/branch) which gets cloned into
1105
+ // /workspace by ensureProjectBootstrapped. --branch matters when testing
1106
+ // wrapper / Dockerfile patches from a feature branch; without it the image
1107
+ // build uses the editor repo's default branch.
1108
+ const bootstrapArgs = editorImageBootstrapArgs(cfg);
1109
+ // Token threading into the bootstrap env (assignment prefix, never argv):
1110
+ // CODEYAM_GHCR_TOKEN — read:packages, for `docker login` + the PRIVATE
1111
+ // prebuilt base/binary PULLS. Without it every pull 403s and the
1112
+ // bootstrap slow-builds (~14 min) from source.
1113
+ // CODEYAM_GHCR_PUSH_TOKEN — write:packages, used ONLY by the post-compile
1114
+ // publish step's push. Distinct from the read token because a push needs
1115
+ // a strictly broader scope; reusing the read token guarantees a 403.
1116
+ // Absent → the bootstrap skips the publish cleanly (no doomed push).
1117
+ // CODEYAM_GHCR_USER / CODEYAM_GHCR_PUSH_USER — optional login-username
1118
+ // overrides. Empty values are dropped, so the no-registry path is
1119
+ // unchanged.
1120
+ const bootstrapEnv = {
1121
+ GITHUB_TOKEN: resolveGithubToken(),
1122
+ CODEYAM_GHCR_TOKEN: resolveGhcrToken(),
1123
+ CODEYAM_GHCR_USER: process.env.CODEYAM_GHCR_USER || "",
1124
+ CODEYAM_GHCR_PUSH_TOKEN: resolveGhcrPushToken(),
1125
+ CODEYAM_GHCR_PUSH_USER: process.env.CODEYAM_GHCR_PUSH_USER || "",
1126
+ };
1127
+
1128
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may
1129
+ // have left a detached build still running (or already finished). Converge
1130
+ // on it instead of starting over.
1131
+ const initial = probeBootstrapOutcome(cfg);
1132
+ if (initial === "success") {
1133
+ console.error(" ✓ a previous detached bootstrap already completed");
1134
+ return;
1135
+ }
1136
+ if (initial === "running") {
1137
+ console.error(" ↺ a detached bootstrap is already in progress — re-attaching to its log...");
1138
+ } else {
1139
+ // "absent" (never started) OR "failed" (a prior run died) → (re)launch a
1140
+ // fresh detached build that will survive an SSH disconnect.
1141
+ console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
1142
+ const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
1143
+ const launched = runGcloud(
1144
+ buildGcloudArgs("ssh-script", cfg, { command: launch }),
1145
+ { stdio: ["ignore", "pipe", "pipe"] },
1146
+ );
1147
+ if (launched.status !== 0) {
1148
+ throw new Error("failed to launch the detached bootstrap on the VM");
1149
+ }
1150
+ }
1151
+
1152
+ // Follow the live log, re-attaching across any follow-session drops, until
1153
+ // the VM reports a terminal state or the overall deadline elapses. Success
1154
+ // and failure are read from VM state (image + sentinel), never the SSH exit
1155
+ // code — so "my laptop's SSH dropped" no longer masquerades as a build error.
1156
+ const deadline = Date.now() + BOOTSTRAP_DEADLINE_MS;
1157
+ for (;;) {
1158
+ runGcloud(
1159
+ buildGcloudArgs("ssh-script", cfg, { command: bootstrapFollowCommand() }),
1160
+ { stdio: "inherit" },
1161
+ );
1162
+ const outcome = probeBootstrapOutcome(cfg);
1163
+ if (outcome === "success") {
1164
+ console.error(" ✓ VM bootstrap complete");
1165
+ return;
1166
+ }
1167
+ if (outcome === "failed") {
1168
+ const tail = bootstrapLogTail(cfg);
1169
+ throw new Error(
1170
+ "scripts/gcp-bootstrap.sh failed on the VM. " +
1171
+ `Last lines of ~/.codeyam-bootstrap.log:\n${tail}`,
1172
+ );
1173
+ }
1174
+ if (Date.now() >= deadline) {
1175
+ throw new Error(
1176
+ "Timed out waiting for the VM bootstrap. It may still be running — " +
1177
+ "re-run cloud:up to re-attach, or inspect ~/.codeyam-bootstrap.log on the VM.",
1178
+ );
1179
+ }
1180
+ // outcome === "running": the follow session ended but the build is alive.
1181
+ console.error(" ↺ follow session ended but the build is still running — re-attaching...");
1182
+ }
1183
+ }
1184
+
1185
+ // Probe the VM for the detached bootstrap's terminal state and reduce it to a
1186
+ // single outcome string ("success" | "failed" | "running" | "absent"). Used
1187
+ // both for the idempotent re-attach decision and after each follow session.
1188
+ function probeBootstrapOutcome(cfg) {
1189
+ const r = runGcloud(
1190
+ buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
1191
+ { stdio: ["ignore", "pipe", "pipe"] },
1192
+ );
1193
+ return decideBootstrapOutcome(parseBootstrapStatus(r.stdout));
1194
+ }
1195
+
1196
+ // Fetch the tail of the VM-side bootstrap log for an actionable failure
1197
+ // message — the operator sees WHY the build failed, not a bare exit code.
1198
+ function bootstrapLogTail(cfg, lines = 40) {
1199
+ const r = runGcloud(
1200
+ buildGcloudArgs("ssh-probe", cfg, {
1201
+ command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
1202
+ }),
1203
+ { stdio: ["ignore", "pipe", "pipe"] },
1204
+ );
1205
+ return String(r.stdout || "").trim() || "(log unavailable)";
1206
+ }
1207
+
1208
+ async function ensureProjectBootstrapped(cfg) {
1209
+ const repoName = resolveProject(cfg).name;
1210
+ console.error(`Checking project bootstrap state for ${repoName}...`);
1211
+ // Gate on the TERMINAL completion marker the script writes as its very last
1212
+ // step (scripts/gcp-project-bootstrap.sh → ~/.codeyam-project-bootstrap-<repo>.done),
1213
+ // not on intermediate structural artifacts. Earlier this probed only `.codeyam/`
1214
+ // + `docker-compose.cloud.yml` — but BOTH can exist while the bootstrap is
1215
+ // still incomplete (init scaffolds `.codeyam/` and the compose render happens
1216
+ // mid-script, before the creds/claude-dir setup and `npm install`/`setup`
1217
+ // finish). The improve40 VM-7 incident hit exactly this: a recovery re-run saw
1218
+ // the two markers, declared "already bootstrapped", and SKIPPED the unfinished
1219
+ // work. The `.done` marker is written only after `set -euo pipefail` has
1220
+ // carried every step to success, so an interrupted bootstrap lacks it and is
1221
+ // correctly re-run (the re-run is idempotent — skip-on-exists per step). The
1222
+ // two structural checks stay as belt-and-suspenders for a legacy VM whose
1223
+ // marker predates this change but is otherwise wired.
1224
+ const probe = runGcloud(
1225
+ buildGcloudArgs("ssh-probe", cfg, {
1226
+ command: projectBootstrapDoneProbeCommand(repoName),
1227
+ }),
1228
+ { stdio: ["ignore", "pipe", "pipe"] },
1229
+ );
1230
+ if (probe.status === 0) {
1231
+ console.error(` ✓ ${repoName} already bootstrapped on VM`);
1232
+ return;
1233
+ }
1234
+ console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
1235
+ const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
1236
+ const scriptBody = fs.readFileSync(scriptPath, "utf8");
1237
+ const scriptArgs = clientProjectBootstrapArgs(cfg);
1238
+ // Forward the laptop's git author identity so the rendered compose file
1239
+ // carries CY_GIT_USER_{NAME,EMAIL} → the entrypoint writes them to
1240
+ // `git config --global` → plan_claim_refs::create_claim's commit-tree call
1241
+ // succeeds. Empty strings are fine (laptop has no identity, e.g. a CI
1242
+ // runner); the entrypoint warns and proceeds.
1243
+ const laptopGit = resolveLaptopGitIdentity();
1244
+ // Forward the operator's launch-form env keys (DATABASE_URL, etc.) into the
1245
+ // bootstrap shell so the bootstrap-time `npm run setup` sees them on the
1246
+ // first try — without this, Prisma `db:push`/`db:seed` fail for lack of
1247
+ // env and the bootstrap's fail-soft branch leaves the app schema-less until
1248
+ // the post-add `reapplyVmEnv` re-fires setup. See
1249
+ // `improve37-launch-form-env-injected-into-cloud-up-bootstrap-not-just-applied-after`.
1250
+ // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1251
+ // standalone `cloud:up` path, so that flow is unchanged.
1252
+ const forwardEnv = resolveSetupForwardEnv();
1253
+ const command = sshScriptCommand(scriptBody, scriptArgs, {
1254
+ GITHUB_TOKEN: resolveGithubToken(),
1255
+ CY_GIT_USER_NAME: laptopGit.name,
1256
+ CY_GIT_USER_EMAIL: laptopGit.email,
1257
+ ...forwardEnv,
1258
+ });
1259
+ const result = runGcloud(
1260
+ buildGcloudArgs("ssh-script", cfg, { command }),
1261
+ { stdio: "inherit" },
1262
+ );
1263
+ if (result.status !== 0) {
1264
+ throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
1265
+ }
1266
+ }
1267
+
1268
+ async function ensureEditorContainerUp(cfg) {
1269
+ const repoName = resolveProject(cfg).name;
1270
+ // -f docker-compose.cloud.yml everywhere: the editor repo's own
1271
+ // docker-compose.yml does `build: .` from source and would shadow the
1272
+ // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
1273
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1274
+ console.error("Checking editor container state...");
1275
+ const probe = runGcloud(
1276
+ buildGcloudArgs("ssh-probe", cfg, {
1277
+ command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
1278
+ }),
1279
+ { stdio: ["ignore", "pipe", "ignore"] },
1280
+ );
1281
+ if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
1282
+ console.error(" ✓ editor container is already running");
1283
+ return;
1284
+ }
1285
+ console.error(" starting editor container (docker compose up -d)...");
1286
+ const up = runGcloud(
1287
+ buildGcloudArgs("ssh-probe", cfg, {
1288
+ command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
1289
+ }),
1290
+ { stdio: "inherit" },
1291
+ );
1292
+ if (up.status !== 0) {
1293
+ throw new Error("docker compose up -d failed on the VM");
1294
+ }
1295
+ }
1296
+
1297
+ /** Read the laptop's git author identity (`user.name`, `user.email`) so
1298
+ * cloud:up can forward it to the VM's container entrypoint, which writes
1299
+ * it to `git config --global`. Without identity in the container,
1300
+ * plan_claim_refs::create_claim's `git commit-tree` fails with "Author
1301
+ * identity unknown" and plan-claim refs are silently never created — the
1302
+ * In-Flight panel goes blind to that VM's work. Returns `{ name, email }`
1303
+ * with empty strings for whichever field is unset locally; the entrypoint
1304
+ * fails-soft on either-empty, so a CI runner without git config simply
1305
+ * warns and boots. The two `git config` calls are cheap (single fs read)
1306
+ * and not memoized so a user can `git config --global user.email …`
1307
+ * between operations and have it picked up on the next cloud:up. */
1308
+ function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
1309
+ const read = (key) => {
1310
+ const r = spawnImpl("git", ["config", "--get", key], { encoding: "utf8" });
1311
+ if (r.status !== 0) return "";
1312
+ return String(r.stdout || "").trim();
1313
+ };
1314
+ return { name: read("user.name"), email: read("user.email") };
1315
+ }
1316
+
1317
+ /** Look up a GitHub token from local environment, falling back to `gh auth
1318
+ * token`. Returns empty string if neither is available — callers should
1319
+ * treat empty as "no auth" and either skip or print a hint. Memoizing
1320
+ * intentionally not done: the cost of one `gh` subprocess per cloud:up is
1321
+ * trivial and lets a user `gh auth login` between operations. */
1322
+ function resolveGithubToken() {
1323
+ const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1324
+ if (env) return env;
1325
+ const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
1326
+ if (ghOut.status === 0) {
1327
+ return ghOut.stdout.trim();
1328
+ }
1329
+ return "";
1330
+ }
1331
+
1332
+ /** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
1333
+ * ghcr.io` and pull the PRIVATE prebuilt cloud-smoke base + branch-binary
1334
+ * packages. Prefer an explicit CODEYAM_GHCR_TOKEN (the operator's scoped
1335
+ * fleet secret); fall back to the same GitHub token already used for the
1336
+ * git clone, which carries read:packages for a classic PAT / `gh auth token`
1337
+ * in the common fleet setup. Empty string when neither is available — the
1338
+ * bootstrap then skips login and the private pulls fall back to a source
1339
+ * build (today's behavior, never worse). Threaded into gcp-bootstrap.sh via
1340
+ * the env channel in ensureBootstrapped; never passed as an argv.
1341
+ *
1342
+ * This token is NOT sufficient for the post-compile PUBLISH — a `docker push`
1343
+ * needs write:packages. Reusing it for the push is exactly the 403 that kept
1344
+ * publish-on-compile from ever priming the fast path. Use resolveGhcrPushToken
1345
+ * for that. */
1346
+ function resolveGhcrToken() {
1347
+ return process.env.CODEYAM_GHCR_TOKEN || resolveGithubToken();
1348
+ }
1349
+
1350
+ /** A WRITE-scoped (`write:packages`) token used ONLY for the post-compile
1351
+ * publish — `publish_compiled_binary`'s `docker push` of the just-built
1352
+ * binary. Deliberately has NO fallback to the read/clone token: a `docker
1353
+ * push` with a read-only token 403s, so silently reusing the read token would
1354
+ * reintroduce the exact failure that kept the fast path from priming. Returns
1355
+ * the empty string when CODEYAM_GHCR_PUSH_TOKEN is unset — the bootstrap then
1356
+ * SKIPS the publish loudly (no doomed push, no 403 spam) and the next VM
1357
+ * simply recompiles that commit. Threaded into gcp-bootstrap.sh via the env
1358
+ * channel in ensureBootstrapped; never passed as an argv. */
1359
+ function resolveGhcrPushToken() {
1360
+ return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1361
+ }
1362
+
1363
+ /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1364
+ * the VM so the cloud compose can mount it into /root/.git-credentials —
1365
+ * without this, claude / codex / gemini inside the container can clone the
1366
+ * initial repo (the wrapper does that step out-of-band with a one-shot
1367
+ * inlined URL) but cannot `git pull` / `git push` afterward. The file is
1368
+ * chmod 600 and never committed; if the local environment has no token,
1369
+ * the placeholder file from gcp-project-bootstrap.sh stays in place. */
1370
+ async function seedGithubCredentials(cfg) {
1371
+ const token = resolveGithubToken();
1372
+ if (!token) {
1373
+ console.error(
1374
+ "\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
1375
+ "claude inside the cloud container will not be able to git pull/push.\n" +
1376
+ "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
1377
+ );
1378
+ return;
1379
+ }
1380
+ console.error("Writing GitHub token into ~/.codeyam-cloud-git-credentials on the VM...");
1381
+ const credLine = `https://x-access-token:${token}@github.com`;
1382
+ // Quote the token through one shell layer (gcloud ssh --command); the
1383
+ // value is base64'd to keep it out of the shell history.
1384
+ const b64 = Buffer.from(`${credLine}\n`).toString("base64");
1385
+ const remoteCmd =
1386
+ `f=$HOME/.codeyam-cloud-git-credentials && ` +
1387
+ `echo ${b64} | base64 -d > $f && chmod 600 $f && echo OK`;
1388
+ const result = runGcloud(
1389
+ buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
1390
+ { stdio: ["ignore", "pipe", "inherit"] },
1391
+ );
1392
+ if (result.status !== 0) {
1393
+ throw new Error("Failed to write ~/.codeyam-cloud-git-credentials on the VM");
1394
+ }
1395
+ }
1396
+
1397
+ /** Best-effort read of the VM's resolved app dev-server port so cloud:up's
1398
+ * laptop `-L` forward, readiness probe, and previewOrigin seed all target the
1399
+ * port the app actually serves on instead of a hardcoded 5173. Cats the VM's
1400
+ * editor.json and editor.local.json in one SSH round-trip and resolves via
1401
+ * `resolveConfiguredAppPort`. Any failure (SSH error, missing/corrupt JSON)
1402
+ * falls back to `VITE_PORT` — the same default `resolved_port()` lands on for
1403
+ * a portless config, so a 5173/greenfield project sees no behavior change. */
1404
+ async function readVmAppPort(cfg) {
1405
+ const repoName = resolveProject(cfg).name;
1406
+ const base = `~/projects/${repoName}/.codeyam`;
1407
+ const read = runGcloud(
1408
+ buildGcloudArgs("ssh-probe", cfg, {
1409
+ command:
1410
+ `cat ${base}/editor.json 2>/dev/null || echo '{}'; ` +
1411
+ `echo '<<<LOCAL>>>'; ` +
1412
+ `cat ${base}/editor.local.json 2>/dev/null || echo '{}'`,
1413
+ }),
1414
+ { stdio: ["ignore", "pipe", "ignore"] },
1415
+ );
1416
+ if (read.status !== 0 || typeof read.stdout !== "string") return VITE_PORT;
1417
+ const parse = (s) => {
1418
+ try {
1419
+ const v = JSON.parse(s);
1420
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1421
+ } catch {
1422
+ return {};
1423
+ }
1424
+ };
1425
+ const [baseJson = "", localJson = ""] = read.stdout.split("<<<LOCAL>>>");
1426
+ return resolveConfiguredAppPort(parse(baseJson), parse(localJson));
1427
+ }
1428
+
1429
+ /** Build the shell command that seeds a JSON patch into a host-side
1430
+ * `editor.local.json` over SSH. The pre-chown matters: the file lives at
1431
+ * `~/projects/<repo>/.codeyam/editor.local.json` on the VM host, but it is
1432
+ * bind-mounted into the running container at `/workspace/.codeyam/`. When
1433
+ * the container writes the file (e.g. provider-auth updates, dashboard
1434
+ * edits), the write runs as root inside the container, so the host file
1435
+ * lands `root:root 0600` on the bind-mount. The next `cloud:up` —
1436
+ * triggered by dashboard Reconfigure — runs as the unprivileged SSH user
1437
+ * (`jaredcosulich` on fleet) and EACCES'es trying to overwrite it. The
1438
+ * `sudo chown $USER:$USER ${remotePath} 2>/dev/null || true` flips
1439
+ * ownership back when the file is root-owned, no-ops when the file
1440
+ * doesn't exist yet (`|| true` swallows the missing-file error), and
1441
+ * silently fails on the rare missing-sudo misconfig (so we never make
1442
+ * things worse than they were). Leaving the file under SSH-user
1443
+ * ownership permanently also keeps subsequent host-side reads
1444
+ * (`docker compose` `.env` interpolation, operator scripts) from
1445
+ * EACCESsing. Sister fix to `buildEnvApplyCommand` in launch-config.js
1446
+ * (which solves the symmetric `.env` problem with
1447
+ * `chown --reference=/workspace`). Pure; no I/O. */
1448
+ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
1449
+ return (
1450
+ `sudo chown $USER:$USER ${remotePath} 2>/dev/null || true && ` +
1451
+ `mkdir -p ~/projects/${repoName}/.codeyam && ` +
1452
+ `echo '${b64}' | base64 -d > ${remotePath}`
1453
+ );
1454
+ }
1455
+
1456
+ /** Seed a *bootstrap* `previewOrigin` into the VM's `editor.local.json` so the
1457
+ * Live Preview iframe URL the laptop browser loads is decoupled from the
1458
+ * in-VM dev-server port. `appPort` is the VM's resolved app port (see
1459
+ * readVmAppPort); it defaults to `VITE_PORT` for a Vite/greenfield app or any
1460
+ * unresolvable config. Two topologies:
1461
+ *
1462
+ * - SSH-tunneled laptop user: the seed is `http://localhost:${appPort}`,
1463
+ * which the user's `-L ${appPort}:…` tunnel (openTunnel) forwards into the
1464
+ * VM. Works for a Vite 5173, a Next.js 3000, anything.
1465
+ * - Operator public trycloudflare URL: a localhost origin points at the laptop
1466
+ * itself, not the VM, so the iframe sticks on "Reconnecting Live Preview"
1467
+ * forever. The fix is a sibling `<N>-preview` cloudflared tunnel; once the
1468
+ * operator's cloudflared-monitor publishes its URL, that public origin is
1469
+ * seeded instead (see resolveSeedPreviewOrigin).
1470
+ *
1471
+ * This is the bootstrap value only — the cloudflared-monitor owns the
1472
+ * steady-state value and rewrites it on every respawn (matching our
1473
+ * `_managedPreviewOrigin` sentinel). We stamp the sentinel here too so the
1474
+ * monitor doesn't mistake the cloud:up seed for a user pin. The
1475
+ * skip-on-already-set guard stays: a non-empty previewOrigin (whether a user
1476
+ * pin or a monitor write) is left untouched on every `cloud:up`. */
1477
+ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1478
+ const repoName = resolveProject(cfg).name;
1479
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
1480
+ const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
1481
+
1482
+ // One round-trip reads both the current editor.local.json (for the
1483
+ // skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
1484
+ // decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
1485
+ // separator-delimited cat.
1486
+ const read = runGcloud(
1487
+ buildGcloudArgs("ssh-probe", cfg, {
1488
+ command:
1489
+ `cat ${remotePath} 2>/dev/null || echo '{}'; ` +
1490
+ `echo '<<<STACK>>>'; ` +
1491
+ `cat ${stackPath} 2>/dev/null || echo '{}'`,
1492
+ }),
1493
+ { stdio: ["ignore", "pipe", "ignore"] },
1494
+ );
1495
+ const parseObj = (s) => {
1496
+ try {
1497
+ const v = JSON.parse(s);
1498
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1499
+ } catch {
1500
+ // Unparseable remote content is treated as `{}` — overwriting a
1501
+ // corrupt local override file with a known-good one is the safer
1502
+ // move than refusing to seed at all.
1503
+ return {};
1504
+ }
1505
+ };
1506
+ let current = {};
1507
+ let sameOriginSafe = true;
1508
+ if (read.status === 0 && typeof read.stdout === "string") {
1509
+ const [localJson = "", stackJson = ""] = read.stdout.split("<<<STACK>>>");
1510
+ current = parseObj(localJson);
1511
+ sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
1512
+ }
1513
+
1514
+ const seed = resolveSeedPreviewOrigin(
1515
+ cfg.instanceName,
1516
+ readCloudflaredUrls(),
1517
+ appPort,
1518
+ process.env,
1519
+ sameOriginSafe,
1520
+ );
1521
+
1522
+ // Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
1523
+ // is normally a user pin or a good prior seed and is left untouched. The one
1524
+ // exception is a ROTTED managed cloudflared quick-tunnel URL on a
1525
+ // same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
1526
+ // same-origin migration): that frozen URL points at a dead rotated tunnel
1527
+ // (NXDOMAIN / blank preview), so we heal it back to the same-origin path
1528
+ // instead of preserving it. Because every dashboard action (Reset / Update /
1529
+ // Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
1530
+ // every touch-point without a separate per-VM hand-fix.
1531
+ const originAlreadySet =
1532
+ typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1533
+ const healing =
1534
+ originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
1535
+ if (originAlreadySet && !healing) {
1536
+ return;
1537
+ }
1538
+
1539
+ // On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
1540
+ // deletes any key whose overlay value is null) — the same-origin path is a
1541
+ // constant the retired monitor push has nothing to manage, exactly matching
1542
+ // the manual `config-override --unset _managedPreviewOrigin` fix.
1543
+ const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
1544
+ console.error(
1545
+ healing
1546
+ ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
1547
+ : `Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`,
1548
+ );
1549
+ const merged = deepMergeJson(current, overlay);
1550
+ const mergedJson = JSON.stringify(merged, null, 2);
1551
+ const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
1552
+ const write = runGcloud(
1553
+ buildGcloudArgs("ssh-probe", cfg, {
1554
+ command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
1555
+ }),
1556
+ { stdio: ["ignore", "inherit", "inherit"] },
1557
+ );
1558
+ if (write.status !== 0) {
1559
+ throw new Error(`Failed to write ${remotePath} on VM`);
1560
+ }
1561
+ }
1562
+
1563
+ async function seedAgentApiKey(cfg) {
1564
+ const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
1565
+ if (!patch) {
1566
+ console.error(
1567
+ `\nNo ${agentEnvVarName(cfg.agent)} set locally — skipping agent auth seed.\n` +
1568
+ `Authenticate ${cfg.agent} later with:\n` +
1569
+ ` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
1570
+ `--tunnel-through-iap -- ${cfg.agent} auth login\n`,
1571
+ );
1572
+ return;
1573
+ }
1574
+ const repoName = resolveProject(cfg).name;
1575
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
1576
+ console.error(`Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`);
1577
+
1578
+ const read = runGcloud(
1579
+ buildGcloudArgs("ssh-probe", cfg, {
1580
+ command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
1581
+ }),
1582
+ { stdio: ["ignore", "pipe", "ignore"] },
1583
+ );
1584
+ let current = {};
1585
+ if (read.status === 0) {
1586
+ try {
1587
+ const parsed = JSON.parse(read.stdout);
1588
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1589
+ current = parsed;
1590
+ }
1591
+ } catch {
1592
+ // Treat unparseable remote content as `{}` — overwriting a corrupt
1593
+ // local override file with a known-good one is the safer move than
1594
+ // refusing to seed at all.
1595
+ }
1596
+ }
1597
+ const merged = deepMergeJson(current, patch);
1598
+ const mergedJson = JSON.stringify(merged, null, 2);
1599
+ const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
1600
+ const write = runGcloud(
1601
+ buildGcloudArgs("ssh-probe", cfg, {
1602
+ command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
1603
+ }),
1604
+ { stdio: ["ignore", "inherit", "inherit"] },
1605
+ );
1606
+ if (write.status !== 0) {
1607
+ throw new Error(`Failed to write ${remotePath} on VM`);
1608
+ }
1609
+ }
1610
+
1611
+ async function openTunnel(cfg, appPort = VITE_PORT) {
1612
+ console.error(`Opening IAP tunnel to ${cfg.instanceName}...`);
1613
+ const child = spawn("gcloud", buildGcloudArgs("ssh-tunnel", cfg, { appPort }), { stdio: "inherit" });
1614
+ const readyEditor = await waitForPort(EDITOR_PORT, 30000, 500);
1615
+ const readyApp = await waitForPort(appPort, 30000, 500);
1616
+ if (!readyEditor || !readyApp) {
1617
+ console.error(
1618
+ `Warning: tunnel ports not reachable on localhost (${EDITOR_PORT}=${readyEditor}, ${appPort}=${readyApp}).`,
1619
+ );
1620
+ }
1621
+ console.error(` Editor UI: http://localhost:${EDITOR_PORT}`);
1622
+ console.error(` Live Preview: http://localhost:${appPort}`);
1623
+ console.error(" (Ctrl+C to tear down the tunnel)");
1624
+ return child;
1625
+ }
1626
+
1627
+ function attachKeepAliveAndTeardown(child) {
1628
+ const keepAlive = setInterval(() => {}, 60_000);
1629
+ process.on("SIGINT", () => {
1630
+ clearInterval(keepAlive);
1631
+ killChildProcess(child);
1632
+ process.exit(0);
1633
+ });
1634
+ }
1635
+
1636
+ function promptForInstanceName(expected) {
1637
+ return new Promise((resolve) => {
1638
+ const rl = readline.createInterface({
1639
+ input: process.stdin,
1640
+ output: process.stderr,
1641
+ });
1642
+ rl.question(
1643
+ `Type the instance name '${expected}' to confirm destroy: `,
1644
+ (ans) => {
1645
+ rl.close();
1646
+ resolve(ans.trim() === expected);
1647
+ },
1648
+ );
1649
+ });
1650
+ }
1651
+
1652
+ // =============================================================================
1653
+ // Subcommand handlers
1654
+ // =============================================================================
1655
+
1656
+ async function cmdUp(args) {
1657
+ ensureGcloud();
1658
+ const flags = parseFlags(args);
1659
+ const cfg = loadOrInitCloudConfig(rootDir, flags);
1660
+ // Fail loud BEFORE creating a GCE instance if an unprovisionable editor
1661
+ // variant was requested — otherwise we'd spin up a VM, build a dev image,
1662
+ // and silently ignore the operator's staging/production intent.
1663
+ assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1664
+
1665
+ await ensureFirewallRule(cfg);
1666
+ await ensureInstance(cfg);
1667
+ await ensureBootstrapped(cfg);
1668
+ await ensureProjectBootstrapped(cfg);
1669
+ // Resolve the app's dev-server port from the VM's now-bootstrapped config so
1670
+ // the previewOrigin seed and the laptop tunnel both target it (not 5173).
1671
+ const appPort = await readVmAppPort(cfg);
1672
+ await seedPreviewOrigin(cfg, appPort);
1673
+ // Write the GitHub token BEFORE compose up so the credentials file exists
1674
+ // when the bind-mount happens — bind-mounting a missing file would silently
1675
+ // create a directory instead and the agent's first `git pull` would fail.
1676
+ await seedGithubCredentials(cfg);
1677
+ await ensureEditorContainerUp(cfg);
1678
+ await seedAgentApiKey(cfg);
1679
+ // Roster the VM into the fleet dashboard NOW that it's provisioned and the
1680
+ // editor container is up. Runs BEFORE the --no-tunnel early-return so the
1681
+ // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
1682
+ // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
1683
+ rosterProvisionedVm(cfg.instanceName);
1684
+ // --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
1685
+ // per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
1686
+ // hanging on a tunnel, and — critically for parallel multi-add — avoids N
1687
+ // invocations all contending on the fixed local ports 14199/5173.
1688
+ if (cfg.noTunnel) {
1689
+ console.error("--no-tunnel: provisioning complete; skipping local tunnel + browser open.");
1690
+ return;
1691
+ }
1692
+ const tunnelChild = await openTunnel(cfg, appPort);
1693
+ open(`http://localhost:${EDITOR_PORT}`);
1694
+ attachKeepAliveAndTeardown(tunnelChild);
1695
+ }
1696
+
1697
+ async function cmdTunnel(args) {
1698
+ ensureGcloud();
1699
+ const flags = parseFlags(args);
1700
+ const existing = loadCloudConfig(rootDir);
1701
+ if (!existing) {
1702
+ throw new Error(
1703
+ "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
1704
+ );
1705
+ }
1706
+ const cfg = { ...existing, ...flags };
1707
+
1708
+ const state = fetchInstanceState(cfg);
1709
+ if (state === null) {
1710
+ throw new Error(
1711
+ `Instance ${cfg.instanceName} not found in project ${cfg.project} / zone ${cfg.zone}. ` +
1712
+ "Has it been deleted? Try `npm run cloud:up`.",
1713
+ );
1714
+ }
1715
+ if (state !== "RUNNING") {
1716
+ throw new Error(
1717
+ `Instance ${cfg.instanceName} is ${state}; run \`npm run cloud:up\` to start it.`,
1718
+ );
1719
+ }
1720
+
1721
+ // Forward the laptop preview `-L` to the VM's resolved app port, not 5173.
1722
+ const appPort = await readVmAppPort(cfg);
1723
+ const tunnelChild = await openTunnel(cfg, appPort);
1724
+ open(`http://localhost:${EDITOR_PORT}`);
1725
+ attachKeepAliveAndTeardown(tunnelChild);
1726
+ }
1727
+
1728
+ async function cmdStop(args) {
1729
+ ensureGcloud();
1730
+ parseFlags(args);
1731
+ const cfg = loadCloudConfig(rootDir);
1732
+ if (!cfg) {
1733
+ throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
1734
+ }
1735
+ console.error(`Stopping ${cfg.instanceName}...`);
1736
+ const result = runGcloud(buildGcloudArgs("instances-stop", cfg));
1737
+ if (result.status !== 0) {
1738
+ throw new Error(`Failed to stop instance ${cfg.instanceName}`);
1739
+ }
1740
+ console.error(
1741
+ `\n ✓ ${cfg.instanceName} is stopped.\n` +
1742
+ " Persistent-disk billing continues (~$10/month for 100 GB pd-balanced).\n" +
1743
+ " Resume with `npm run cloud:up`.\n",
1744
+ );
1745
+ }
1746
+
1747
+ async function cmdDestroy(args) {
1748
+ ensureGcloud();
1749
+ parseFlags(args);
1750
+ const cfg = loadCloudConfig(rootDir);
1751
+ if (!cfg) {
1752
+ throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
1753
+ }
1754
+
1755
+ const confirmed = await promptForInstanceName(cfg.instanceName);
1756
+ if (!confirmed) {
1757
+ console.error("Aborted.");
1758
+ return;
1759
+ }
1760
+
1761
+ console.error(`Deleting instance ${cfg.instanceName}...`);
1762
+ const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
1763
+ if (del.status !== 0) {
1764
+ throw new Error(`Failed to delete instance ${cfg.instanceName}`);
1765
+ }
1766
+
1767
+ const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
1768
+ stdio: ["ignore", "pipe", "ignore"],
1769
+ });
1770
+ const others = String(list.stdout || "").trim().split("\n").filter(Boolean);
1771
+ if (others.length === 0) {
1772
+ console.error(`No other ${INSTANCE_TAG}-tagged instances remain — deleting firewall rule...`);
1773
+ const fwDel = runGcloud(buildGcloudArgs("firewall-delete", cfg));
1774
+ if (fwDel.status !== 0) {
1775
+ console.error(` Warning: failed to delete firewall rule ${FIREWALL_RULE_NAME}.`);
1776
+ }
1777
+ } else {
1778
+ console.error(
1779
+ `Leaving ${FIREWALL_RULE_NAME} in place (${others.length} other ${INSTANCE_TAG}-tagged instance(s) still use it).`,
1780
+ );
1781
+ }
1782
+
1783
+ try {
1784
+ fs.unlinkSync(cloudConfigPath(rootDir));
1785
+ console.error("Deleted .codeyam/cloud.json");
1786
+ } catch (e) {
1787
+ if (e.code !== "ENOENT") throw e;
1788
+ }
1789
+ }
1790
+
1791
+ async function cmdStatus(args) {
1792
+ ensureGcloud();
1793
+ parseFlags(args);
1794
+ const cfg = loadCloudConfig(rootDir);
1795
+ if (!cfg) {
1796
+ console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
1797
+ return;
1798
+ }
1799
+
1800
+ const vmState = fetchInstanceState(cfg) || "NOT_FOUND";
1801
+ console.error(`VM: ${cfg.instanceName} (${cfg.zone}) — ${vmState}`);
1802
+
1803
+ if (vmState !== "RUNNING") {
1804
+ console.error(" (image / container / tunnel checks skipped — VM is not RUNNING)");
1805
+ return;
1806
+ }
1807
+
1808
+ const imgProbe = runGcloud(
1809
+ buildGcloudArgs("ssh-probe", cfg, {
1810
+ command:
1811
+ "docker image inspect codeyam-editor:local >/dev/null 2>&1 && echo built || echo missing",
1812
+ }),
1813
+ { stdio: ["ignore", "pipe", "ignore"] },
1814
+ );
1815
+ const imgStatus = String(imgProbe.stdout || "").trim() || "?";
1816
+ console.error(` image: ${imgStatus}`);
1817
+
1818
+ const repoName = resolveProject(cfg).name;
1819
+ const containerProbe = runGcloud(
1820
+ buildGcloudArgs("ssh-probe", cfg, {
1821
+ command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
1822
+ }),
1823
+ { stdio: ["ignore", "pipe", "ignore"] },
1824
+ );
1825
+ const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
1826
+ console.error(` editor container: ${containerStatus}`);
1827
+
1828
+ const appPort = await readVmAppPort(cfg);
1829
+ const pEditor = await isPortInUse(EDITOR_PORT);
1830
+ const pApp = await isPortInUse(appPort);
1831
+ console.error(
1832
+ ` tunnel: ${EDITOR_PORT} ${pEditor ? "✓" : "✗"} ${appPort} ${pApp ? "✓" : "✗"}`,
1833
+ );
1834
+ }
1835
+
1836
+ const HANDLERS = {
1837
+ up: cmdUp,
1838
+ tunnel: cmdTunnel,
1839
+ stop: cmdStop,
1840
+ destroy: cmdDestroy,
1841
+ status: cmdStatus,
1842
+ };
1843
+
1844
+ async function cloudMain() {
1845
+ const subcmd = process.argv[2];
1846
+ const args = process.argv.slice(3);
1847
+ if (!subcmd || !Object.prototype.hasOwnProperty.call(HANDLERS, subcmd)) {
1848
+ console.error(
1849
+ `Usage: npm run cloud:<${Object.keys(HANDLERS).join("|")}> [-- flags]`,
1850
+ );
1851
+ process.exit(2);
1852
+ }
1853
+ try {
1854
+ await HANDLERS[subcmd](args);
1855
+ } catch (e) {
1856
+ console.error(`Error: ${e.message}`);
1857
+ process.exit(1);
1858
+ }
1859
+ }
1860
+
1861
+ module.exports = {
1862
+ // pure helpers
1863
+ agentEnvVarName,
1864
+ AGENT_ENV_VARS,
1865
+ KNOWN_FLAGS,
1866
+ FIREWALL_RULE_NAME,
1867
+ INSTANCE_TAG,
1868
+ EDITOR_PORT,
1869
+ VITE_PORT,
1870
+ parseFlags,
1871
+ repoBasename,
1872
+ deriveInstanceName,
1873
+ defaultInstanceNameFor,
1874
+ CANONICAL_EDITOR_REPO,
1875
+ EDITOR_VARIANTS,
1876
+ MANAGED_PREVIEW_ORIGIN,
1877
+ SAME_ORIGIN_PREVIEW_PATH,
1878
+ sameRepo,
1879
+ resolveFleetVmNumber,
1880
+ rosterProvisionedVm,
1881
+ resolveConfiguredAppPort,
1882
+ resolveSeedPreviewOrigin,
1883
+ previewSameOriginSafeFromStack,
1884
+ isRottedManagedPreviewOrigin,
1885
+ resolveEditorConfig,
1886
+ validateEditorVariant,
1887
+ assertVariantProvisionable,
1888
+ editorImageBootstrapArgs,
1889
+ reconcileProjectExclusivity,
1890
+ resolveGhcrToken,
1891
+ resolveGhcrPushToken,
1892
+ resolveProject,
1893
+ clientProjectBootstrapArgs,
1894
+ cloudConfigPath,
1895
+ loadCloudConfig,
1896
+ saveCloudConfig,
1897
+ buildEditorLocalEnvPatch,
1898
+ buildEditorLocalSeedWriteCommand,
1899
+ seedPreviewOrigin,
1900
+ buildGcloudArgs,
1901
+ sshScriptCommand,
1902
+ bootstrapRunnerScript,
1903
+ bootstrapLaunchCommand,
1904
+ bootstrapFollowCommand,
1905
+ bootstrapStatusCommand,
1906
+ parseBootstrapStatus,
1907
+ decideBootstrapOutcome,
1908
+ projectBootstrapDoneMarker,
1909
+ projectBootstrapDoneProbeCommand,
1910
+ SSH_KEEPALIVE_FLAGS,
1911
+ resolveSetupForwardEnv,
1912
+ FORWARD_ENV_KEYS_ENV,
1913
+ parseEditorComposeState,
1914
+ fetchInstanceState,
1915
+ // side-effecting handlers
1916
+ cmdUp,
1917
+ cmdTunnel,
1918
+ cmdStop,
1919
+ cmdDestroy,
1920
+ cmdStatus,
1921
+ cloudMain,
1922
+ seedGithubCredentials,
1923
+ readVmAppPort,
1924
+ resolveLaptopGitIdentity,
1925
+ };
1926
+
1927
+ if (require.main === module) {
1928
+ cloudMain();
1929
+ }