@codeyam-editor/codeyam-editor 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/npm/cloud.js CHANGED
@@ -110,11 +110,11 @@ const CLOUDFLARED_URLS_FILE =
110
110
  // image out of the client repo.
111
111
  const CANONICAL_EDITOR_REPO = "https://github.com/codeyam-ai/codeyam-editor.git";
112
112
 
113
- // The editor distribution channels an operator can pick per VM:
113
+ // The editor distribution channels an operator can pick per VM. All three are
114
+ // provisionable (gcp-bootstrap dispatches on --editor-variant):
114
115
  // dev — build the editor image from the editor repo @ editorBranch
115
- // (today's path; the only one wired into provisioning so far)
116
- // staging — `npm i -g @codeyam-editor/codeyam-editor@staging` (Phase 2)
117
- // production — `npm i -g @codeyam-editor/codeyam-editor` (Phase 2)
116
+ // staging — run the published `@codeyam-editor/codeyam-editor@staging` image
117
+ // production — run the published `@codeyam-editor/codeyam-editor` (latest) image
118
118
  const EDITOR_VARIANTS = ["dev", "staging", "production"];
119
119
 
120
120
  /** Map an agent name to its API-key env var. Throws on unknown agents so
@@ -302,33 +302,98 @@ function resolveSeedPreviewOrigin(
302
302
  urls = {},
303
303
  appPort = VITE_PORT,
304
304
  env = process.env,
305
+ sameOriginSafe = true,
305
306
  ) {
307
+ // Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
308
+ // same-origin under the reserved relative path, in every fleet mode. The
309
+ // editor mounts `/__codeyam_preview` on its own origin and forwards to the
310
+ // in-VM app-port reverse proxy, so the iframe loads from the exact origin the
311
+ // editor is served on (public quick-tunnel, named tunnel, SSH-tunnel
312
+ // localhost, anything). It is a CONSTANT relative path: it never rotates and
313
+ // depends only on the editor's own origin being reachable, so it can never rot
314
+ // the way a frozen managed quick-tunnel URL does (the VM-1/2/6 leftover this
315
+ // plan fixes). No `_managedPreviewOrigin` marker is stamped — the marker is
316
+ // reserved for the genuinely-managed dedicated origins below, so the self-heal
317
+ // predicate can tell a rotting managed URL apart from this constant path.
318
+ if (sameOriginSafe) {
319
+ return { previewOrigin: SAME_ORIGIN_PREVIEW_PATH };
320
+ }
321
+
322
+ // NOT same-origin-safe (client-routed stacks — Next.js etc.): the client
323
+ // router breaks the same-origin subpath proxy (it pushState's to root-absolute
324
+ // paths and recurses into the editor), so the preview must come from a
325
+ // DEDICATED origin. We stamp `_managedPreviewOrigin` so the (legacy) monitor
326
+ // recognises the value as ours and won't mistake it for a user pin.
306
327
  const vm = resolveFleetVmNumber(instanceName);
307
328
  const domain = resolveFleetDomain(env);
308
- // Named tunnels are per fleet-VM; a non-fleet client project (vm == null)
309
- // keeps the legacy localhost seed even when a domain is configured.
329
+ // Named-tunnel mode: the deterministic `vm-<N>.preview.<domain>` host is a
330
+ // dedicated sibling subdomain of the editor and never rotates. A non-fleet
331
+ // client project (vm == null) keeps the localhost seed even with a domain set.
310
332
  if (domain && vm != null) {
311
333
  return {
312
334
  previewOrigin: vmUrls(vm, domain).preview,
313
335
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
314
336
  };
315
337
  }
316
- // Quick-tunnel / domain-free mode: serve the app same-origin under the
317
- // reserved relative path. The editor mounts `/__codeyam_preview` on its own
318
- // origin and forwards to the in-VM app-port reverse proxy, so the iframe loads
319
- // from the exact origin the editor is served on (public quick-tunnel,
320
- // SSH-tunnel localhost, anything). This replaces the prior
321
- // `http://localhost:${appPort}` seed — a loopback URL a public editor page
322
- // could not load (Private Network Access block) — and the monitor's rotating
323
- // `<N>-preview` quick-tunnel URL, which a constant relative path makes moot.
324
- // `appPort` / `urls` are retained in the signature for callers but no longer
325
- // seeded — the same-origin path is a constant.
338
+ // Quick-tunnel / domain-free mode: prefer the operator's published
339
+ // `<N>-preview` quick-tunnel URL (the public origin a remote browser can
340
+ // actually load), else the SSH-tunnel bootstrap localhost origin
341
+ // (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
342
+ // the same-origin subpath — that is exactly what recurses for this stack.
343
+ const previewKey = vm != null ? `${vm}-preview` : null;
344
+ const tunnelUrl =
345
+ previewKey &&
346
+ typeof urls[previewKey] === "string" &&
347
+ urls[previewKey].length > 0
348
+ ? urls[previewKey]
349
+ : `http://localhost:${appPort}`;
326
350
  return {
327
- previewOrigin: SAME_ORIGIN_PREVIEW_PATH,
351
+ previewOrigin: tunnelUrl,
328
352
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
329
353
  };
330
354
  }
331
355
 
356
+ /** Extract the stack's `preview.sameOriginSafe` flag from a parsed
357
+ * `.codeyam/stack.json` object. Absent / non-object / non-boolean ⇒ `true`
358
+ * (the same-origin default), mirroring `PreviewConfig::same_origin_safe()` on
359
+ * the Rust side so the seeder and the editor agree on the truth table. Pure. */
360
+ function previewSameOriginSafeFromStack(stackJson) {
361
+ const preview =
362
+ stackJson && typeof stackJson === "object" ? stackJson.preview : null;
363
+ const flag =
364
+ preview && typeof preview === "object" ? preview.sameOriginSafe : undefined;
365
+ return typeof flag === "boolean" ? flag : true;
366
+ }
367
+
368
+ // Matches an absolute cloudflared quick-tunnel origin
369
+ // (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
370
+ // URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
371
+ const TRYCLOUDFLARE_ORIGIN_RE =
372
+ /^https:\/\/[a-z0-9-]+\.trycloudflare\.com(?:[:/?#]|$)/i;
373
+
374
+ /** Decide whether a VM's current `editor.local.json` carries a ROTTED managed
375
+ * cloudflared previewOrigin that must be self-healed back to the same-origin
376
+ * path. True iff ALL hold:
377
+ * - the stack is same-origin-safe (a client-routed stack legitimately keeps a
378
+ * dedicated origin — never heal it);
379
+ * - the value is stamped `_managedPreviewOrigin: cloudflared-monitor` (WE
380
+ * wrote it, not a user pin); and
381
+ * - the `previewOrigin` is an absolute `*.trycloudflare.com` quick-tunnel URL
382
+ * (the rotating kind that goes dead — a deterministic named-tunnel host or
383
+ * the constant `/__codeyam_preview` path is left alone).
384
+ * Idempotent by construction: a VM already on `/__codeyam_preview` (or any
385
+ * non-trycloudflare origin) returns false, so re-running the heal is a no-op.
386
+ * Pure — `localJson` is a parsed editor.local.json object. */
387
+ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
388
+ if (!sameOriginSafe) return false;
389
+ if (!localJson || typeof localJson !== "object") return false;
390
+ if (localJson._managedPreviewOrigin !== MANAGED_PREVIEW_ORIGIN) return false;
391
+ return (
392
+ typeof localJson.previewOrigin === "string" &&
393
+ TRYCLOUDFLARE_ORIGIN_RE.test(localJson.previewOrigin)
394
+ );
395
+ }
396
+
332
397
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
333
398
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
334
399
  function readCloudflaredUrls() {
@@ -370,26 +435,33 @@ function validateEditorVariant(variant) {
370
435
  return variant;
371
436
  }
372
437
 
373
- /** Phase-1 gate: only `dev` is wired into cloud provisioning. The
374
- * staging/production npm-install container image lands in Phase 2. Fails loud
375
- * with an actionable message rather than silently building a dev image. */
438
+ /** Assert a variant can be provisioned onto a cloud VM. Every known variant is
439
+ * provisionable now — `dev` builds the editor image from source, while
440
+ * `staging`/`production` run the published npm-package image (gcp-bootstrap
441
+ * pulls/builds Dockerfile.npm keyed off the variant). So this collapses to the
442
+ * unknown-variant guard: a typo'd `--editor-variant` fails loud rather than
443
+ * silently launching the wrong (or no) image. */
376
444
  function assertVariantProvisionable(variant) {
377
- validateEditorVariant(variant);
378
- if (variant !== "dev") {
379
- throw new Error(
380
- `Editor variant '${variant}' is not yet wired for cloud provisioning ` +
381
- `(Phase 2 — the @staging/production npm-install image). Use --editor-variant dev.`,
382
- );
383
- }
384
- return variant;
445
+ return validateEditorVariant(variant);
385
446
  }
386
447
 
387
- /** Args for scripts/gcp-bootstrap.sh, which builds the codeyam-editor:local
388
- * image from the EDITOR source — NOT the client project. */
448
+ /** Args for scripts/gcp-bootstrap.sh, which produces the codeyam-editor:local
449
+ * image. For the `dev` variant that image is built from the EDITOR source
450
+ * (--repo/--branch); for packaged variants (staging/production) it comes from
451
+ * the published npm package, so the editor source repo/branch is irrelevant and
452
+ * omitted — gcp-bootstrap pulls/builds Dockerfile.npm keyed off --editor-variant.
453
+ * The CLIENT project repo/branch is unaffected: it flows through cloud:up's own
454
+ * project bootstrap, not this function. */
389
455
  function editorImageBootstrapArgs(cfg) {
390
- const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
391
- const args = ["--agent", cfg.agent, "--repo", editorRepo];
392
- if (editorBranch) args.push("--branch", editorBranch);
456
+ const { editorRepo, editorBranch, editorVariant } = resolveEditorConfig(cfg);
457
+ const args = ["--agent", cfg.agent, "--editor-variant", editorVariant];
458
+ // Stack assumption: only the `dev` variant builds the editor image from source,
459
+ // so only it needs the editor repo/branch plumbing gcp-bootstrap's source path
460
+ // consumes. Packaged variants install the binary from the published npm package.
461
+ if (editorVariant === "dev") {
462
+ args.push("--repo", editorRepo);
463
+ if (editorBranch) args.push("--branch", editorBranch);
464
+ }
393
465
  return args;
394
466
  }
395
467
 
@@ -568,10 +640,19 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
568
640
  // shell-quoting through gcloud → ssh → remote bash. The script body
569
641
  // is delivered as one argument; the wrapper feeds the base64 into
570
642
  // `extras.command` and the caller sets `input` to the script body.
643
+ //
644
+ // Keepalive flags are defense-in-depth for the long-running bootstrap
645
+ // follow session: a quiet stretch during `cargo build` (no stdout for
646
+ // minutes) must not trip an idle disconnect. ServerAliveInterval pings
647
+ // the VM every 30s and ServerAliveCountMax tolerates 6 missed replies
648
+ // (~3 min) before giving up, while TCPKeepAlive keeps NAT mappings warm.
649
+ // The detached server-side runner (bootstrapLaunchCommand) makes a real
650
+ // drop non-fatal; keepalive just makes spurious drops rarer.
571
651
  return [
572
652
  "compute", "ssh", instanceName,
573
653
  "--project", project, "--zone", zone,
574
654
  "--tunnel-through-iap",
655
+ ...SSH_KEEPALIVE_FLAGS,
575
656
  "--command", extras.command,
576
657
  ];
577
658
  case "ssh-tunnel": {
@@ -807,19 +888,194 @@ function resolveSetupForwardEnv(env = process.env) {
807
888
  return out;
808
889
  }
809
890
 
810
- function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
811
- const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
812
- const argsString = scriptArgs.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
813
- // Prefix each env var as `KEY='value'` so they're set for the bash -s
814
- // process only — no persistence in the VM's shell history, no /etc/environment
815
- // edit. Single-quoted values are safe against shell metacharacters; an
816
- // embedded apostrophe in the value would be exotic for these (token,
817
- // bool flag), but escape it the same way as scriptArgs for symmetry.
818
- const envString = Object.entries(envVars)
891
+ // --ssh-flag values gcloud forwards verbatim to the underlying `ssh`. Shaped as
892
+ // `-o Key=Value` so they survive the gcloud → ssh hop unchanged. Used by the
893
+ // ssh-script verb (the long bootstrap follow session) — short ssh-probe calls
894
+ // don't need them. See the ssh-script case in buildGcloudArgs for the rationale.
895
+ const SSH_KEEPALIVE_FLAGS = [
896
+ "--ssh-flag=-o ServerAliveInterval=30",
897
+ "--ssh-flag=-o ServerAliveCountMax=6",
898
+ "--ssh-flag=-o TCPKeepAlive=yes",
899
+ ];
900
+
901
+ // Canonical VM-side paths for the detached bootstrap. The build runs from
902
+ // BOOTSTRAP_RUNNER_PATH (a tiny wrapper that execs the real script), streams to
903
+ // BOOTSTRAP_LOG_PATH, and writes its exit status into BOOTSTRAP_DONE_PATH on
904
+ // completion. Completion + success are derived from these files (plus the
905
+ // docker image), NOT from the SSH exit code — so a dropped follow-session over
906
+ // a healthy VM is no longer misreported as "failed on the VM".
907
+ const BOOTSTRAP_SCRIPT_PATH = "$HOME/.codeyam-bootstrap.sh";
908
+ const BOOTSTRAP_RUNNER_PATH = "$HOME/.codeyam-bootstrap-run.sh";
909
+ const BOOTSTRAP_LOG_PATH = "$HOME/.codeyam-bootstrap.log";
910
+ const BOOTSTRAP_DONE_PATH = "$HOME/.codeyam-bootstrap.done";
911
+ const BOOTSTRAP_PID_PATH = "$HOME/.codeyam-bootstrap.pid";
912
+ const BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_BOOTSTRAP_LAUNCHED";
913
+ const BOOTSTRAP_STATUS_MARKER = "CYBOOT";
914
+ // improve40 — VM-side path of the PROJECT-bootstrap terminal completion marker.
915
+ // gcp-project-bootstrap.sh writes this as its very last step (only reached after
916
+ // `set -euo pipefail` carried every provisioning step to success), and
917
+ // ensureProjectBootstrapped's done-probe gates on it so a half-finished
918
+ // bootstrap is correctly re-run instead of false-skipped (the VM-7 incident).
919
+ // Lives outside the project's git tree (a $HOME dotfile, keyed by repo name) so
920
+ // it never churns the repo. Pure so the bash writer and the JS probe derive the
921
+ // EXACT same path; tested without a live VM.
922
+ function projectBootstrapDoneMarker(repoName) {
923
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.done`;
924
+ }
925
+ // improve40 — the VM-side shell `test` chain ensureProjectBootstrapped runs to
926
+ // decide "is this project already bootstrapped?". Gates on the terminal `.done`
927
+ // marker FIRST (the VM-7 false-skip fix — both structural artifacts can exist on
928
+ // a half-done bootstrap), then keeps the `.codeyam/` + docker-compose.cloud.yml
929
+ // checks as belt-and-suspenders for a legacy VM whose marker predates this change.
930
+ // Pure (just the command string) so the gating is unit-testable without gcloud.
931
+ function projectBootstrapDoneProbeCommand(repoName) {
932
+ return (
933
+ `test -f ${projectBootstrapDoneMarker(repoName)} ` +
934
+ `&& test -d ~/projects/${repoName}/.codeyam ` +
935
+ `&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`
936
+ );
937
+ }
938
+ // One follow session caps at 30 min (comfortably over the ~14-min source
939
+ // build); the overall wait caps at 40 min across re-attaches before we bail
940
+ // loudly with the VM-side log location.
941
+ const BOOTSTRAP_FOLLOW_SECS = 1800;
942
+ const BOOTSTRAP_DEADLINE_MS = 40 * 60 * 1000;
943
+
944
+ // Quote a shell value the same way scriptArgs are quoted: single-quote it and
945
+ // escape any embedded apostrophe. Shared by the env-prefix and arg encoders so
946
+ // a token with an exotic character can't break out of its quoting.
947
+ function shQuote(v) {
948
+ return `'${String(v).replace(/'/g, "'\\''")}'`;
949
+ }
950
+
951
+ // Encode env vars as a shell-assignment PREFIX (`KEY='value' …`). Assignment
952
+ // prefixes set the variable in the launched command's environment only — never
953
+ // in its argv — so tokens stay out of `ps` on the VM. Empty / nullish values
954
+ // are dropped so the no-token path is unchanged.
955
+ function envAssignmentPrefix(envVars) {
956
+ const s = Object.entries(envVars)
819
957
  .filter(([, v]) => v !== undefined && v !== null && v !== "")
820
- .map(([k, v]) => `${k}='${String(v).replace(/'/g, "'\\''")}'`)
958
+ .map(([k, v]) => `${k}=${shQuote(v)}`)
821
959
  .join(" ");
822
- const envPrefix = envString ? `${envString} ` : "";
960
+ return s ? `${s} ` : "";
961
+ }
962
+
963
+ // The tiny VM-side wrapper the detached launch execs. It runs the real
964
+ // bootstrap, redirects all output to the log, and records the exit status in
965
+ // the sentinel — the single source of truth for "did the build finish, and
966
+ // did it succeed?" once the SSH follow-session is gone. Contains NO secrets
967
+ // (tokens arrive via the launch env prefix, never written to disk).
968
+ function bootstrapRunnerScript() {
969
+ return [
970
+ "#!/usr/bin/env bash",
971
+ "# Detached bootstrap runner (written by npm/cloud.js ensureBootstrapped).",
972
+ "# Survives SSH disconnect: the laptop only *follows* the log below; this",
973
+ "# process keeps running under its own session if the follow drops.",
974
+ "# Records its own PID so the status probe can check liveness via `kill -0`",
975
+ "# (a `pgrep` on the script path would also match the probe command itself).",
976
+ `echo "$$" > "$HOME/.codeyam-bootstrap.pid"`,
977
+ `bash "$HOME/.codeyam-bootstrap.sh" "$@" > "$HOME/.codeyam-bootstrap.log" 2>&1`,
978
+ "status=$?",
979
+ `echo "$status" > "$HOME/.codeyam-bootstrap.done"`,
980
+ `rm -f "$HOME/.codeyam-bootstrap.pid"`,
981
+ "",
982
+ ].join("\n");
983
+ }
984
+
985
+ // Build the remote command that (1) materializes the bootstrap script + runner
986
+ // on the VM, (2) clears any stale sentinel, (3) launches the runner DETACHED
987
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
988
+ // session, and (4) prints a launched marker. The detach is the durable fix:
989
+ // the build no longer dies when the laptop's SSH pipe breaks.
990
+ function bootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}) {
991
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
992
+ const runnerB64 = Buffer.from(bootstrapRunnerScript(), "utf8").toString("base64");
993
+ const argsString = scriptArgs.map(shQuote).join(" ");
994
+ const envPrefix = envAssignmentPrefix(envVars);
995
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
996
+ // aborts before launch; then the runner is detached. The env prefix is on the
997
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
998
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
999
+ // launch in the background while still confirming it fired.
1000
+ const writes = [
1001
+ `echo '${scriptB64}' | base64 -d > ${BOOTSTRAP_SCRIPT_PATH}`,
1002
+ `echo '${runnerB64}' | base64 -d > ${BOOTSTRAP_RUNNER_PATH}`,
1003
+ `rm -f ${BOOTSTRAP_DONE_PATH}`,
1004
+ ].join(" && ");
1005
+ const launch =
1006
+ `${envPrefix}nohup setsid bash ${BOOTSTRAP_RUNNER_PATH} ${argsString}`.trimEnd() +
1007
+ ` </dev/null >/dev/null 2>&1 &`;
1008
+ return `${writes}; ${launch} echo ${BOOTSTRAP_LAUNCHED_MARKER}`;
1009
+ }
1010
+
1011
+ // Build the remote command that FOLLOWS the detached build: tail the log live
1012
+ // for the operator, polling for the sentinel, and exit when the build finishes
1013
+ // OR a per-session deadline elapses. If the SSH session drops mid-follow this
1014
+ // command dies but the detached build keeps going — the caller then re-attaches.
1015
+ function bootstrapFollowCommand(deadlineSecs = BOOTSTRAP_FOLLOW_SECS) {
1016
+ return [
1017
+ `touch ${BOOTSTRAP_LOG_PATH}`,
1018
+ `tail -n +1 -F ${BOOTSTRAP_LOG_PATH} 2>/dev/null & TP=$!`,
1019
+ `waited=0`,
1020
+ `while [ ! -f ${BOOTSTRAP_DONE_PATH} ]; do ` +
1021
+ `sleep 3; waited=$((waited+3)); ` +
1022
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1023
+ `done`,
1024
+ `sleep 1`,
1025
+ `kill "$TP" 2>/dev/null`,
1026
+ ].join("; ");
1027
+ }
1028
+
1029
+ // Build the remote command that reports terminal VM state in one parseable
1030
+ // line: whether the image exists, the sentinel's exit code (or `none`), and
1031
+ // whether a bootstrap process is still alive. Cheap — runs over ssh-probe.
1032
+ function bootstrapStatusCommand() {
1033
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1034
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1035
+ // match this very probe and always report running. A stale PID file from a
1036
+ // killed runner reads as not-running because `kill -0` fails.
1037
+ return [
1038
+ `img=absent; docker image inspect codeyam-editor:local >/dev/null 2>&1 && img=present`,
1039
+ `done=none; [ -f ${BOOTSTRAP_DONE_PATH} ] && done=$(cat ${BOOTSTRAP_DONE_PATH} 2>/dev/null)`,
1040
+ `run=no; if [ -f ${BOOTSTRAP_PID_PATH} ]; then p=$(cat ${BOOTSTRAP_PID_PATH} 2>/dev/null); ` +
1041
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1042
+ `echo "${BOOTSTRAP_STATUS_MARKER} img=$img done=$done running=$run"`,
1043
+ ].join("; ");
1044
+ }
1045
+
1046
+ // Parse the single status line emitted by bootstrapStatusCommand. Returns
1047
+ // `{ img, done, running }` or null when no status line is present (e.g. the
1048
+ // probe ssh itself failed and produced no stdout).
1049
+ function parseBootstrapStatus(stdout) {
1050
+ const m = String(stdout || "").match(
1051
+ new RegExp(`${BOOTSTRAP_STATUS_MARKER} img=(\\S+) done=(\\S+) running=(\\S+)`),
1052
+ );
1053
+ if (!m) return null;
1054
+ return { img: m[1], done: m[2], running: m[3] };
1055
+ }
1056
+
1057
+ // Decide the bootstrap outcome from VM-side state alone — never the SSH exit
1058
+ // code. The image is the real artifact, so its presence is success regardless
1059
+ // of how the follow session ended. A live process means "re-attach". A sentinel
1060
+ // without an image means the build ran and failed. Nothing at all means it was
1061
+ // never started (or the probe couldn't see it).
1062
+ function decideBootstrapOutcome(status) {
1063
+ if (!status) return "absent";
1064
+ const { img, done, running } = status;
1065
+ if (img === "present") return "success";
1066
+ if (running === "yes") return "running";
1067
+ if (done && done !== "none") return "failed";
1068
+ return "absent";
1069
+ }
1070
+
1071
+ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
1072
+ const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
1073
+ // Args + env share the quoting/encoding helpers with bootstrapLaunchCommand:
1074
+ // single-quoted args (shQuote) and a `KEY='value'` assignment prefix
1075
+ // (envAssignmentPrefix) that sets the vars for the `bash -s` process only —
1076
+ // no persistence in the VM's shell history, no argv exposure of tokens.
1077
+ const argsString = scriptArgs.map(shQuote).join(" ");
1078
+ const envPrefix = envAssignmentPrefix(envVars);
823
1079
  return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
824
1080
  }
825
1081
 
@@ -850,42 +1106,124 @@ async function ensureBootstrapped(cfg) {
850
1106
  // wrapper / Dockerfile patches from a feature branch; without it the image
851
1107
  // build uses the editor repo's default branch.
852
1108
  const bootstrapArgs = editorImageBootstrapArgs(cfg);
853
- // CODEYAM_GHCR_TOKEN lets the bootstrap `docker login ghcr.io` and pull the
854
- // PRIVATE prebuilt base + branch-binary packages — without it every pull
855
- // 403s and the bootstrap silently slow-builds (~14 min) from source.
856
- // CODEYAM_GHCR_USER is an optional login-username override (the bootstrap
857
- // otherwise derives it from the registry owner). Empty values are dropped by
858
- // sshScriptCommand, so the no-registry / local-only path is unchanged.
859
- const command = sshScriptCommand(scriptBody, bootstrapArgs, {
1109
+ // Token threading into the bootstrap env (assignment prefix, never argv):
1110
+ // CODEYAM_GHCR_TOKEN — read:packages, for `docker login` + the PRIVATE
1111
+ // prebuilt base/binary PULLS. Without it every pull 403s and the
1112
+ // bootstrap slow-builds (~14 min) from source.
1113
+ // CODEYAM_GHCR_PUSH_TOKEN — write:packages, used ONLY by the post-compile
1114
+ // publish step's push. Distinct from the read token because a push needs
1115
+ // a strictly broader scope; reusing the read token guarantees a 403.
1116
+ // Absent → the bootstrap skips the publish cleanly (no doomed push).
1117
+ // CODEYAM_GHCR_USER / CODEYAM_GHCR_PUSH_USER — optional login-username
1118
+ // overrides. Empty values are dropped, so the no-registry path is
1119
+ // unchanged.
1120
+ const bootstrapEnv = {
860
1121
  GITHUB_TOKEN: resolveGithubToken(),
861
1122
  CODEYAM_GHCR_TOKEN: resolveGhcrToken(),
862
1123
  CODEYAM_GHCR_USER: process.env.CODEYAM_GHCR_USER || "",
863
- });
864
- const result = runGcloud(
865
- buildGcloudArgs("ssh-script", cfg, { command }),
866
- { stdio: "inherit" },
867
- );
868
- if (result.status !== 0) {
869
- throw new Error("scripts/gcp-bootstrap.sh failed on the VM");
1124
+ CODEYAM_GHCR_PUSH_TOKEN: resolveGhcrPushToken(),
1125
+ CODEYAM_GHCR_PUSH_USER: process.env.CODEYAM_GHCR_PUSH_USER || "",
1126
+ };
1127
+
1128
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may
1129
+ // have left a detached build still running (or already finished). Converge
1130
+ // on it instead of starting over.
1131
+ const initial = probeBootstrapOutcome(cfg);
1132
+ if (initial === "success") {
1133
+ console.error(" ✓ a previous detached bootstrap already completed");
1134
+ return;
1135
+ }
1136
+ if (initial === "running") {
1137
+ console.error(" ↺ a detached bootstrap is already in progress — re-attaching to its log...");
1138
+ } else {
1139
+ // "absent" (never started) OR "failed" (a prior run died) → (re)launch a
1140
+ // fresh detached build that will survive an SSH disconnect.
1141
+ console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
1142
+ const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
1143
+ const launched = runGcloud(
1144
+ buildGcloudArgs("ssh-script", cfg, { command: launch }),
1145
+ { stdio: ["ignore", "pipe", "pipe"] },
1146
+ );
1147
+ if (launched.status !== 0) {
1148
+ throw new Error("failed to launch the detached bootstrap on the VM");
1149
+ }
1150
+ }
1151
+
1152
+ // Follow the live log, re-attaching across any follow-session drops, until
1153
+ // the VM reports a terminal state or the overall deadline elapses. Success
1154
+ // and failure are read from VM state (image + sentinel), never the SSH exit
1155
+ // code — so "my laptop's SSH dropped" no longer masquerades as a build error.
1156
+ const deadline = Date.now() + BOOTSTRAP_DEADLINE_MS;
1157
+ for (;;) {
1158
+ runGcloud(
1159
+ buildGcloudArgs("ssh-script", cfg, { command: bootstrapFollowCommand() }),
1160
+ { stdio: "inherit" },
1161
+ );
1162
+ const outcome = probeBootstrapOutcome(cfg);
1163
+ if (outcome === "success") {
1164
+ console.error(" ✓ VM bootstrap complete");
1165
+ return;
1166
+ }
1167
+ if (outcome === "failed") {
1168
+ const tail = bootstrapLogTail(cfg);
1169
+ throw new Error(
1170
+ "scripts/gcp-bootstrap.sh failed on the VM. " +
1171
+ `Last lines of ~/.codeyam-bootstrap.log:\n${tail}`,
1172
+ );
1173
+ }
1174
+ if (Date.now() >= deadline) {
1175
+ throw new Error(
1176
+ "Timed out waiting for the VM bootstrap. It may still be running — " +
1177
+ "re-run cloud:up to re-attach, or inspect ~/.codeyam-bootstrap.log on the VM.",
1178
+ );
1179
+ }
1180
+ // outcome === "running": the follow session ended but the build is alive.
1181
+ console.error(" ↺ follow session ended but the build is still running — re-attaching...");
870
1182
  }
871
1183
  }
872
1184
 
1185
+ // Probe the VM for the detached bootstrap's terminal state and reduce it to a
1186
+ // single outcome string ("success" | "failed" | "running" | "absent"). Used
1187
+ // both for the idempotent re-attach decision and after each follow session.
1188
+ function probeBootstrapOutcome(cfg) {
1189
+ const r = runGcloud(
1190
+ buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
1191
+ { stdio: ["ignore", "pipe", "pipe"] },
1192
+ );
1193
+ return decideBootstrapOutcome(parseBootstrapStatus(r.stdout));
1194
+ }
1195
+
1196
+ // Fetch the tail of the VM-side bootstrap log for an actionable failure
1197
+ // message — the operator sees WHY the build failed, not a bare exit code.
1198
+ function bootstrapLogTail(cfg, lines = 40) {
1199
+ const r = runGcloud(
1200
+ buildGcloudArgs("ssh-probe", cfg, {
1201
+ command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
1202
+ }),
1203
+ { stdio: ["ignore", "pipe", "pipe"] },
1204
+ );
1205
+ return String(r.stdout || "").trim() || "(log unavailable)";
1206
+ }
1207
+
873
1208
  async function ensureProjectBootstrapped(cfg) {
874
1209
  const repoName = resolveProject(cfg).name;
875
1210
  console.error(`Checking project bootstrap state for ${repoName}...`);
876
- // Probe both .codeyam/ (created by `editor init`) AND
877
- // docker-compose.cloud.yml (written near the end of the script). A previous
878
- // run that crashed between init and the compose write would leave the
879
- // first marker but not the second; the old probe wrongly considered the
880
- // project bootstrapped and the next `cloud:up` then hit
881
- // "open docker-compose.cloud.yml: no such file or directory".
882
- // The probe is the seam where idempotency lives — make sure it gates on
883
- // the LAST artifact the script produces, not the first.
1211
+ // Gate on the TERMINAL completion marker the script writes as its very last
1212
+ // step (scripts/gcp-project-bootstrap.sh → ~/.codeyam-project-bootstrap-<repo>.done),
1213
+ // not on intermediate structural artifacts. Earlier this probed only `.codeyam/`
1214
+ // + `docker-compose.cloud.yml` — but BOTH can exist while the bootstrap is
1215
+ // still incomplete (init scaffolds `.codeyam/` and the compose render happens
1216
+ // mid-script, before the creds/claude-dir setup and `npm install`/`setup`
1217
+ // finish). The improve40 VM-7 incident hit exactly this: a recovery re-run saw
1218
+ // the two markers, declared "already bootstrapped", and SKIPPED the unfinished
1219
+ // work. The `.done` marker is written only after `set -euo pipefail` has
1220
+ // carried every step to success, so an interrupted bootstrap lacks it and is
1221
+ // correctly re-run (the re-run is idempotent — skip-on-exists per step). The
1222
+ // two structural checks stay as belt-and-suspenders for a legacy VM whose
1223
+ // marker predates this change but is otherwise wired.
884
1224
  const probe = runGcloud(
885
1225
  buildGcloudArgs("ssh-probe", cfg, {
886
- command:
887
- `test -d ~/projects/${repoName}/.codeyam ` +
888
- `&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
1226
+ command: projectBootstrapDoneProbeCommand(repoName),
889
1227
  }),
890
1228
  { stdio: ["ignore", "pipe", "pipe"] },
891
1229
  );
@@ -991,7 +1329,7 @@ function resolveGithubToken() {
991
1329
  return "";
992
1330
  }
993
1331
 
994
- /** A `read:packages`-scoped token the VM bootstrap uses to `docker login
1332
+ /** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
995
1333
  * ghcr.io` and pull the PRIVATE prebuilt cloud-smoke base + branch-binary
996
1334
  * packages. Prefer an explicit CODEYAM_GHCR_TOKEN (the operator's scoped
997
1335
  * fleet secret); fall back to the same GitHub token already used for the
@@ -999,11 +1337,29 @@ function resolveGithubToken() {
999
1337
  * in the common fleet setup. Empty string when neither is available — the
1000
1338
  * bootstrap then skips login and the private pulls fall back to a source
1001
1339
  * build (today's behavior, never worse). Threaded into gcp-bootstrap.sh via
1002
- * the env channel in ensureBootstrapped; never passed as an argv. */
1340
+ * the env channel in ensureBootstrapped; never passed as an argv.
1341
+ *
1342
+ * This token is NOT sufficient for the post-compile PUBLISH — a `docker push`
1343
+ * needs write:packages. Reusing it for the push is exactly the 403 that kept
1344
+ * publish-on-compile from ever priming the fast path. Use resolveGhcrPushToken
1345
+ * for that. */
1003
1346
  function resolveGhcrToken() {
1004
1347
  return process.env.CODEYAM_GHCR_TOKEN || resolveGithubToken();
1005
1348
  }
1006
1349
 
1350
+ /** A WRITE-scoped (`write:packages`) token used ONLY for the post-compile
1351
+ * publish — `publish_compiled_binary`'s `docker push` of the just-built
1352
+ * binary. Deliberately has NO fallback to the read/clone token: a `docker
1353
+ * push` with a read-only token 403s, so silently reusing the read token would
1354
+ * reintroduce the exact failure that kept the fast path from priming. Returns
1355
+ * the empty string when CODEYAM_GHCR_PUSH_TOKEN is unset — the bootstrap then
1356
+ * SKIPS the publish loudly (no doomed push, no 403 spam) and the next VM
1357
+ * simply recompiles that commit. Threaded into gcp-bootstrap.sh via the env
1358
+ * channel in ensureBootstrapped; never passed as an argv. */
1359
+ function resolveGhcrPushToken() {
1360
+ return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1361
+ }
1362
+
1007
1363
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1008
1364
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1009
1365
  * without this, claude / codex / gemini inside the container can clone the
@@ -1121,37 +1477,76 @@ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
1121
1477
  async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1122
1478
  const repoName = resolveProject(cfg).name;
1123
1479
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
1124
- const seed = resolveSeedPreviewOrigin(
1125
- cfg.instanceName,
1126
- readCloudflaredUrls(),
1127
- appPort,
1128
- );
1480
+ const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
1129
1481
 
1482
+ // One round-trip reads both the current editor.local.json (for the
1483
+ // skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
1484
+ // decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
1485
+ // separator-delimited cat.
1130
1486
  const read = runGcloud(
1131
1487
  buildGcloudArgs("ssh-probe", cfg, {
1132
- command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
1488
+ command:
1489
+ `cat ${remotePath} 2>/dev/null || echo '{}'; ` +
1490
+ `echo '<<<STACK>>>'; ` +
1491
+ `cat ${stackPath} 2>/dev/null || echo '{}'`,
1133
1492
  }),
1134
1493
  { stdio: ["ignore", "pipe", "ignore"] },
1135
1494
  );
1136
- let current = {};
1137
- if (read.status === 0) {
1495
+ const parseObj = (s) => {
1138
1496
  try {
1139
- const parsed = JSON.parse(read.stdout);
1140
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1141
- current = parsed;
1142
- }
1497
+ const v = JSON.parse(s);
1498
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1143
1499
  } catch {
1144
1500
  // Unparseable remote content is treated as `{}` — overwriting a
1145
1501
  // corrupt local override file with a known-good one is the safer
1146
1502
  // move than refusing to seed at all.
1503
+ return {};
1147
1504
  }
1505
+ };
1506
+ let current = {};
1507
+ let sameOriginSafe = true;
1508
+ if (read.status === 0 && typeof read.stdout === "string") {
1509
+ const [localJson = "", stackJson = ""] = read.stdout.split("<<<STACK>>>");
1510
+ current = parseObj(localJson);
1511
+ sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
1148
1512
  }
1149
- if (typeof current.previewOrigin === "string" && current.previewOrigin.length > 0) {
1513
+
1514
+ const seed = resolveSeedPreviewOrigin(
1515
+ cfg.instanceName,
1516
+ readCloudflaredUrls(),
1517
+ appPort,
1518
+ process.env,
1519
+ sameOriginSafe,
1520
+ );
1521
+
1522
+ // Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
1523
+ // is normally a user pin or a good prior seed and is left untouched. The one
1524
+ // exception is a ROTTED managed cloudflared quick-tunnel URL on a
1525
+ // same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
1526
+ // same-origin migration): that frozen URL points at a dead rotated tunnel
1527
+ // (NXDOMAIN / blank preview), so we heal it back to the same-origin path
1528
+ // instead of preserving it. Because every dashboard action (Reset / Update /
1529
+ // Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
1530
+ // every touch-point without a separate per-VM hand-fix.
1531
+ const originAlreadySet =
1532
+ typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1533
+ const healing =
1534
+ originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
1535
+ if (originAlreadySet && !healing) {
1150
1536
  return;
1151
1537
  }
1152
1538
 
1153
- console.error(`Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`);
1154
- const merged = deepMergeJson(current, seed);
1539
+ // On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
1540
+ // deletes any key whose overlay value is null) — the same-origin path is a
1541
+ // constant the retired monitor push has nothing to manage, exactly matching
1542
+ // the manual `config-override --unset _managedPreviewOrigin` fix.
1543
+ const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
1544
+ console.error(
1545
+ healing
1546
+ ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
1547
+ : `Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`,
1548
+ );
1549
+ const merged = deepMergeJson(current, overlay);
1155
1550
  const mergedJson = JSON.stringify(merged, null, 2);
1156
1551
  const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
1157
1552
  const write = runGcloud(
@@ -1485,12 +1880,15 @@ module.exports = {
1485
1880
  rosterProvisionedVm,
1486
1881
  resolveConfiguredAppPort,
1487
1882
  resolveSeedPreviewOrigin,
1883
+ previewSameOriginSafeFromStack,
1884
+ isRottedManagedPreviewOrigin,
1488
1885
  resolveEditorConfig,
1489
1886
  validateEditorVariant,
1490
1887
  assertVariantProvisionable,
1491
1888
  editorImageBootstrapArgs,
1492
1889
  reconcileProjectExclusivity,
1493
1890
  resolveGhcrToken,
1891
+ resolveGhcrPushToken,
1494
1892
  resolveProject,
1495
1893
  clientProjectBootstrapArgs,
1496
1894
  cloudConfigPath,
@@ -1498,8 +1896,18 @@ module.exports = {
1498
1896
  saveCloudConfig,
1499
1897
  buildEditorLocalEnvPatch,
1500
1898
  buildEditorLocalSeedWriteCommand,
1899
+ seedPreviewOrigin,
1501
1900
  buildGcloudArgs,
1502
1901
  sshScriptCommand,
1902
+ bootstrapRunnerScript,
1903
+ bootstrapLaunchCommand,
1904
+ bootstrapFollowCommand,
1905
+ bootstrapStatusCommand,
1906
+ parseBootstrapStatus,
1907
+ decideBootstrapOutcome,
1908
+ projectBootstrapDoneMarker,
1909
+ projectBootstrapDoneProbeCommand,
1910
+ SSH_KEEPALIVE_FLAGS,
1503
1911
  resolveSetupForwardEnv,
1504
1912
  FORWARD_ENV_KEYS_ENV,
1505
1913
  parseEditorComposeState,