@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a

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