@codeyam-editor/codeyam-editor 0.1.0-staging.4d8da9a → 0.1.0-staging.6200ab1

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 (39) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +352 -50
  3. package/npm/disk-pressure.js +17 -0
  4. package/npm/editor-dev.js +63 -13
  5. package/npm/editor.js +57 -26
  6. package/npm/fleet-auth.js +6 -1
  7. package/npm/fleet-cleanup.js +52 -4
  8. package/npm/fleet-effect-status.js +33 -32
  9. package/npm/fleet-host.js +74 -0
  10. package/npm/fleet-job-badge.js +1 -1
  11. package/npm/fleet-queue.js +38 -1
  12. package/npm/fleet-reset.js +117 -7
  13. package/npm/fleet-roster.js +6 -8
  14. package/npm/fleet-self-update.js +105 -0
  15. package/npm/fleet-sleep-status.js +33 -0
  16. package/npm/google-ingress.js +143 -0
  17. package/npm/launch-config.js +17 -3
  18. package/npm/scenario-check.js +363 -14
  19. package/npm/scenario-handlers.js +54 -2
  20. package/npm/scenario-metrics.js +37 -0
  21. package/npm/scenario-mocks.js +20 -0
  22. package/npm/scenario-playwright.js +236 -29
  23. package/npm/script-drift.js +81 -0
  24. package/package.json +5 -5
  25. package/ui/dist/assets/{ContainerInspector-C8EkH4PP.js → ContainerInspector-DhJ-Oh-9.js} +2 -2
  26. package/ui/dist/assets/EditorShell-08glcZVy.css +1 -0
  27. package/ui/dist/assets/EditorShell-BPSWpFGH.js +189 -0
  28. package/ui/dist/assets/ScenarioDataPanel-961YITEd.js +5 -0
  29. package/ui/dist/assets/{ScenarioDataPanelFullPage-5G6KuZgw.js → ScenarioDataPanelFullPage-DHncFah1.js} +1 -1
  30. package/ui/dist/assets/ScenarioRenderer-zZfej9Vx.js +21 -0
  31. package/ui/dist/assets/index-DiezX3Bw.css +1 -0
  32. package/ui/dist/assets/{index-BuK0lizt.js → index-W4-0pn6S.js} +9 -9
  33. package/ui/dist/codeyam-mark.svg +4 -0
  34. package/ui/dist/codeyam-wordmark.svg +5 -0
  35. package/ui/dist/index.html +2 -2
  36. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  37. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  38. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  39. package/ui/dist/assets/index-BGqairFV.css +0 -1
@@ -358,7 +358,7 @@ async function branchSwitchMain() {
358
358
  process.exit(1);
359
359
  }
360
360
 
361
- const roster = readRoster();
361
+ const roster = readRoster() || [];
362
362
  if (!roster.length) {
363
363
  console.error('Error: roster is empty — no VMs to switch.');
364
364
  process.exit(1);
package/npm/cloud.js CHANGED
@@ -352,11 +352,21 @@ function resolveSeedPreviewOrigin(
352
352
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
353
353
  };
354
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.
355
+ // Quick-tunnel / domain-free mode: support both 'local' (default) and 'tunnel' modes.
356
+ // In 'local' mode, we use http://localhost:<15172 + VM_NUMBER> directly.
357
+ // In 'tunnel' mode, we prefer the operator's published quick-tunnel URL.
358
+ const mode = env.PREVIEW_ORIGIN_MODE || "local";
359
+ if (mode === "local" && vm != null) {
360
+ // http://localhost inside an https:// editor page is allowed.
361
+ // Browsers treat http://localhost as a potentially-trustworthy origin,
362
+ // so the mixed-content blocker does not apply.
363
+ const previewPort = 15172 + vm;
364
+ return {
365
+ previewOrigin: `http://localhost:${previewPort}`,
366
+ _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
367
+ };
368
+ }
369
+ // Quick-tunnel 'tunnel' mode fallback / non-fleet project fallback:
360
370
  const previewKey = vm != null ? `${vm}-preview` : null;
361
371
  const tunnelUrl =
362
372
  previewKey &&
@@ -411,6 +421,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
411
421
  );
412
422
  }
413
423
 
424
+ // A loopback / localhost previewOrigin: `http://127.0.0.1:<port>`,
425
+ // `http://localhost:<port>`, `http://[::1]:<port>`, with or without a port,
426
+ // path, or query. These are reachable only from the editor host itself (or a
427
+ // laptop `-L` forward), never from a remote browser — exactly the dead-end the
428
+ // dedicated_preview_origin fallback lands on when the seed was wrong-moded.
429
+ const LOOPBACK_ORIGIN_RE =
430
+ /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#]|$)/i;
431
+
432
+ /** Decide whether a VM's current `editor.local.json` carries a WRONG-MODE
433
+ * previewOrigin for a stack that is NOT same-origin-safe (Next.js etc.) and so
434
+ * must be self-healed to a dedicated origin. This is the inverse of
435
+ * `isRottedManagedPreviewOrigin`: it fires when the stack.json race froze the
436
+ * same-origin-safe default (or a loopback bootstrap) onto a client-routed
437
+ * stack — the VM7 failure where the iframe shows `127.0.0.1 refused to
438
+ * connect`. True iff BOTH hold:
439
+ * - the stack is NOT same-origin-safe (a same-origin-safe stack legitimately
440
+ * keeps the `/__codeyam_preview` subpath — never heal it; that is
441
+ * `isRottedManagedPreviewOrigin`'s job in the other direction); and
442
+ * - the `previewOrigin` is the same-origin subpath sentinel, any other
443
+ * relative path, or a loopback/localhost origin — none of which a remote
444
+ * browser can load for a dedicated-origin stack. A real dedicated origin
445
+ * (an absolute non-loopback URL — quick-tunnel or named-tunnel host) is
446
+ * left alone, so re-running the heal once a good origin is in place is a
447
+ * no-op.
448
+ * Symmetric with `isRottedManagedPreviewOrigin` and tested as a pair. Pure —
449
+ * `localJson` is a parsed editor.local.json object. */
450
+ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
451
+ if (sameOriginSafe) return false;
452
+ if (!localJson || typeof localJson !== "object") return false;
453
+ const origin = localJson.previewOrigin;
454
+ if (typeof origin !== "string" || origin.length === 0) return false;
455
+ // The reserved same-origin subpath, or any relative path (no scheme): these
456
+ // recurse into the editor SPA for a client-routed stack.
457
+ if (origin === SAME_ORIGIN_PREVIEW_PATH) return true;
458
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return true;
459
+ // An absolute loopback/localhost origin: reachable only from the host, dead
460
+ // for a remote browser.
461
+ return LOOPBACK_ORIGIN_RE.test(origin);
462
+ }
463
+
414
464
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
415
465
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
416
466
  function readCloudflaredUrls() {
@@ -491,9 +541,25 @@ function editorImageBootstrapArgs(cfg) {
491
541
  * so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
492
542
  * — can't override the new intent. Without this, launching `--repo X` after a
493
543
  * 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;
544
+ * prioritizes newProject). Mutates and returns cfg.
545
+ *
546
+ * `prev` is the pre-merge persisted config (default `{}`). When `--repo`
547
+ * SWITCHES to a different repo and no `--branch` was passed this run, the
548
+ * inherited client branch is dropped: a persisted branch belongs to the OLD
549
+ * repo and may not exist on the new one. (The harvardintech VM was torn down
550
+ * because a `--repo harvardintech` re-up reused `editor-improvements-48` — a
551
+ * branch that lives only on the editor repo — and `git clone --branch` failed.)
552
+ * An explicit `--branch` this run always wins (the drop is gated on
553
+ * `!flags.branch`), and a same-repo re-up keeps its persisted branch — that is
554
+ * the legitimate "set the branch once, reuse it" workflow. `sameRepo` is used
555
+ * for the comparison so `.git`/case differences don't trigger a spurious drop. */
556
+ function reconcileProjectExclusivity(cfg, flags = {}, prev = {}) {
557
+ if (flags.repo) {
558
+ delete cfg.newProject;
559
+ if (!flags.branch && prev.repo && !sameRepo(prev.repo, flags.repo)) {
560
+ delete cfg.branch;
561
+ }
562
+ }
497
563
  if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
498
564
  return cfg;
499
565
  }
@@ -546,14 +612,27 @@ function loadCloudConfig(projectDir) {
546
612
  try {
547
613
  return JSON.parse(raw);
548
614
  } catch (e) {
549
- throw new Error(`Failed to parse ${p}: ${e.message}`);
615
+ try {
616
+ raw = fs.readFileSync(p, "utf8");
617
+ return JSON.parse(raw);
618
+ } catch (e2) {
619
+ throw new Error(`Failed to parse ${p}: ${e2.message}`);
620
+ }
550
621
  }
551
622
  }
552
623
 
553
624
  function saveCloudConfig(projectDir, cfg) {
554
625
  const p = cloudConfigPath(projectDir);
555
- fs.mkdirSync(path.dirname(p), { recursive: true });
556
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
626
+ const dir = path.dirname(p);
627
+ fs.mkdirSync(dir, { recursive: true });
628
+ const tmpPath = path.join(dir, `cloud.json.${process.pid}.tmp`);
629
+ try {
630
+ fs.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + "\n");
631
+ fs.renameSync(tmpPath, p);
632
+ } catch (e) {
633
+ fs.rmSync(tmpPath, { force: true });
634
+ throw e;
635
+ }
557
636
  }
558
637
 
559
638
  /** Build the JSON patch to merge into the VM's `editor.local.json` so the
@@ -837,7 +916,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
837
916
  // `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
838
917
  const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
839
918
  if (existing) {
840
- const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
919
+ const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags, existing);
920
+ const { isPlaceholderProject } = require("./launch-config");
921
+ if (isPlaceholderProject(merged.project)) {
922
+ const configPath = cloudConfigPath(projectDir);
923
+ throw new Error(
924
+ `Failed to load cloud config: project '${merged.project}' in ${configPath} is a test-fixture placeholder. ` +
925
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
926
+ );
927
+ }
841
928
  const toSave = persist(merged);
842
929
  if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
843
930
  saveCloudConfig(projectDir, toSave);
@@ -847,6 +934,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
847
934
 
848
935
  const cfg = reconcileProjectExclusivity({ ...flags }, flags);
849
936
  cfg.project = cfg.project || gcloudGetValue("config-get-project");
937
+
938
+ const { isPlaceholderProject } = require("./launch-config");
939
+ if (isPlaceholderProject(cfg.project)) {
940
+ throw new Error(
941
+ `Failed to load cloud config: resolved project '${cfg.project}' is a test-fixture placeholder. ` +
942
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
943
+ );
944
+ }
945
+
850
946
  cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
851
947
  cfg.agent = cfg.agent || "claude";
852
948
  agentEnvVarName(cfg.agent);
@@ -1651,27 +1747,43 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1651
1747
  );
1652
1748
 
1653
1749
  // 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.
1750
+ // is normally a user pin or a good prior seed and is left untouched. There are
1751
+ // TWO symmetric heal exceptions, both of which the frozen skip-guard would
1752
+ // otherwise pin forever:
1753
+ // 1. a ROTTED managed cloudflared quick-tunnel URL on a same-origin-safe VM
1754
+ // (the VM-1/2/6 leftover from before the improve38 same-origin
1755
+ // migration): that frozen URL points at a dead rotated tunnel (NXDOMAIN /
1756
+ // blank preview), so we heal it back to the same-origin path; and
1757
+ // 2. a WRONG-MODE previewOrigin on a NOT-same-origin-safe VM (the VM7 race:
1758
+ // the first seed ran before onboarding wrote stack.json, defaulted to
1759
+ // `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
1760
+ // remote browser then loads as `127.0.0.1 refused to connect`). Once
1761
+ // stack.json exists, heal it forward to the dedicated origin.
1762
+ // Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
1763
+ // re-runs this seed over cloud:up, either heal reaches every touch-point the
1764
+ // moment stack.json is present — closing the race without re-ordering
1765
+ // provisioning, and without a separate per-VM hand-fix.
1662
1766
  const originAlreadySet =
1663
1767
  typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1664
1768
  const healing =
1665
- originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
1769
+ originAlreadySet &&
1770
+ (isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
1771
+ isWrongModePreviewOrigin(current, sameOriginSafe));
1666
1772
  if (originAlreadySet && !healing) {
1667
1773
  return;
1668
1774
  }
1669
1775
 
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;
1776
+ // On heal, reconcile the `_managedPreviewOrigin` marker to the healed value's
1777
+ // own shape. The rotted→same-origin heal (seed carries NO marker) DROPS any
1778
+ // stale marker (deepMergeJson deletes a key whose overlay value is null) — the
1779
+ // constant same-origin path has nothing for the monitor to manage, exactly
1780
+ // matching the manual `config-override --unset _managedPreviewOrigin` fix. The
1781
+ // wrong-mode→dedicated heal (seed carries the marker) KEEPS it — that origin
1782
+ // IS managed (the monitor's gated rotation push owns it), so the marker must
1783
+ // survive so the next respawn recognises the value as ours.
1784
+ const overlay = healing
1785
+ ? { ...seed, _managedPreviewOrigin: seed._managedPreviewOrigin ?? null }
1786
+ : seed;
1675
1787
  console.error(
1676
1788
  healing
1677
1789
  ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
@@ -1691,6 +1803,82 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1691
1803
  }
1692
1804
  }
1693
1805
 
1806
+ /** READY-probe auto-heal: after the editor container is verifiably Up, ask the
1807
+ * running editor's own `/api/config` for its stack verdict and effective
1808
+ * preview origin, and heal a wrong-moded seed in place — never failing the add.
1809
+ *
1810
+ * The race this closes (VM7, 2026-06-10): the pre-container `seedPreviewOrigin`
1811
+ * at line ~1906 runs BEFORE onboarding writes stack.json, so it defaults to
1812
+ * `sameOriginSafe: true` and seeds the `/__codeyam_preview` subpath. For a
1813
+ * client-routed stack (Next.js) the editor then reports `previewSameOriginSafe:
1814
+ * false` but, with only the subpath in editor.local.json, falls its
1815
+ * `dedicated_preview_origin` back to `http://127.0.0.1:<appPort>` — which a
1816
+ * remote browser renders as "refused to connect". If stack.json appeared since
1817
+ * the first seed (the editor reads it live from disk), we re-run the seed — now
1818
+ * the wrong-mode heal fires (`isWrongModePreviewOrigin`) — and POST
1819
+ * `/api/editor-reload-config` so the running server serves the healed origin
1820
+ * with no restart.
1821
+ *
1822
+ * Best-effort and non-fatal by contract: a probe failure, a still-absent
1823
+ * stack.json (verdict stays `true`), or a heal that can only reach a loopback
1824
+ * bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
1825
+ * leave the add green. The cloudflared-monitor's periodic tick (section 2) is
1826
+ * the durable owner that heals post-add flips — onboarding finishing minutes
1827
+ * after READY. */
1828
+ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
1829
+ const probe = runGcloud(
1830
+ buildGcloudArgs("ssh-probe", cfg, {
1831
+ command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
1832
+ }),
1833
+ { stdio: ["ignore", "pipe", "ignore"] },
1834
+ );
1835
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
1836
+ return;
1837
+ }
1838
+ let config;
1839
+ try {
1840
+ config = JSON.parse(probe.stdout);
1841
+ } catch {
1842
+ return;
1843
+ }
1844
+ if (!config || typeof config !== "object") return;
1845
+
1846
+ // The editor's own verdict (section 3's `/api/config` field) plus the
1847
+ // wrong-mode predicate (a subpath / relative / loopback origin on a
1848
+ // not-same-origin-safe stack) — the exact dead-end the dedicated-origin
1849
+ // fallback lands on. Same-origin-safe stacks (verdict true) never enter here.
1850
+ if (config.previewSameOriginSafe !== false) return;
1851
+ if (!isWrongModePreviewOrigin(config, false)) return;
1852
+
1853
+ console.error(
1854
+ `READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
1855
+ `but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
1856
+ );
1857
+ // Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
1858
+ // the dedicated origin into editor.local.json (a published `<N>-preview`
1859
+ // tunnel URL, else the localhost bootstrap for the window before the monitor's
1860
+ // first tick).
1861
+ await seedPreviewOrigin(cfg, appPort);
1862
+ // Tell the running server to re-read editor.local.json — no restart (the
1863
+ // footgun that killed VM7's live Claude session).
1864
+ const reload = runGcloud(
1865
+ buildGcloudArgs("ssh-probe", cfg, {
1866
+ command: `curl -fsS --max-time 5 -X POST http://localhost:${EDITOR_PORT}/api/editor-reload-config 2>/dev/null || true`,
1867
+ }),
1868
+ { stdio: ["ignore", "inherit", "inherit"] },
1869
+ );
1870
+ if (reload.status !== 0) {
1871
+ console.error(
1872
+ ` Warning: heal wrote editor.local.json but /api/editor-reload-config did ` +
1873
+ `not return cleanly — the next monitor tick or cloud:up touch-point will reconcile.`,
1874
+ );
1875
+ }
1876
+ console.error(
1877
+ ` Live Preview origin healed. If the operator <N>-preview tunnel is not ` +
1878
+ `published yet, the cloudflared-monitor's periodic tick provides the ongoing heal.`,
1879
+ );
1880
+ }
1881
+
1694
1882
  /** Write the chosen agent provider and local API keys into editor.local.json on
1695
1883
  * the VM BEFORE compose up so the file exists when the bind-mount happens —
1696
1884
  * the provider seed MUST run before ensureEditorContainerUp, because the editor
@@ -1709,9 +1897,12 @@ async function seedAgentApiKey(cfg) {
1709
1897
  if (!hasKey) {
1710
1898
  console.error(
1711
1899
  `\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`,
1900
+ `Authenticate ${cfg.agent} on the VM by either:\n` +
1901
+ ` • exporting ${agentEnvVarName(cfg.agent)} locally and re-running cloud:up, or\n` +
1902
+ ` • pasting the key into the Build tab's auth gate in the editor.\n` +
1903
+ `Either path now flows the key through to the agent process on the VM.\n` +
1904
+ `Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
1905
+ `supported headlessly on the VM.\n`,
1715
1906
  );
1716
1907
  }
1717
1908
  const repoName = resolveProject(cfg).name;
@@ -1787,22 +1978,18 @@ async function ensureAgentCliInstalled(cfg) {
1787
1978
  `AGENT="${agent}"`,
1788
1979
  "",
1789
1980
  "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",
1981
+ " echo \" ✓ $BINARY already installed on PATH\"",
1982
+ " exit 0",
1799
1983
  "fi",
1800
1984
  "",
1801
1985
  "echo \"Installing $PACKAGE globally in container...\"",
1802
1986
  "if [ \"$AGENT\" = \"claude\" ]; then",
1803
1987
  " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
1804
1988
  " 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",
1989
+ " RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
1990
+ " if [ -n \"$RESOLVED\" ]; then",
1991
+ " ln -sf \"$RESOLVED\" /usr/local/bin/claude",
1992
+ " fi",
1806
1993
  "else",
1807
1994
  " npm install -g \"$PACKAGE\"",
1808
1995
  "fi",
@@ -1864,19 +2051,117 @@ function promptForInstanceName(expected) {
1864
2051
  });
1865
2052
  }
1866
2053
 
2054
+ const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
2055
+
2056
+ /** Check if the streamed bootstrap scripts have drifted from the remote repository
2057
+ * and abort provisioning if they are out of date to prevent silent container/VM failures. */
2058
+ function checkScriptDrift(cfg) {
2059
+ if (process.env.CODEYAM_SKIP_SCRIPT_DRIFT_CHECK === "1") {
2060
+ console.error(" [Warning] CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1 active. Skipping streamed-script drift preflight check.");
2061
+ return;
2062
+ }
2063
+
2064
+ const { editorBranch = "main" } = resolveEditorConfig(cfg);
2065
+
2066
+ console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
2067
+
2068
+ // 1. git fetch origin <editorBranch>
2069
+ const fetchResult = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
2070
+ if (fetchResult.status !== 0) {
2071
+ console.error(` [Warning] Failed to fetch origin ${editorBranch} (${(fetchResult.stderr || "").trim() || "unknown error"}). Skipping drift check.`);
2072
+ return;
2073
+ }
2074
+
2075
+ // 2. Find the merge base between HEAD and origin/<editorBranch>
2076
+ const mbResult = spawnSync("git", ["merge-base", "HEAD", `origin/${editorBranch}`], { encoding: "utf8" });
2077
+ let mergeBase = null;
2078
+ if (mbResult.status === 0) {
2079
+ mergeBase = mbResult.stdout.trim();
2080
+ }
2081
+
2082
+ const filesData = [];
2083
+
2084
+ for (const scriptPath of STREAMED_SCRIPTS) {
2085
+ // a. localHash
2086
+ const localHashResult = spawnSync("git", ["hash-object", scriptPath], { encoding: "utf8" });
2087
+ if (localHashResult.status !== 0) {
2088
+ console.error(` [Warning] Failed to compute local hash for ${scriptPath}. Skipping drift check.`);
2089
+ return;
2090
+ }
2091
+ const localHash = localHashResult.stdout.trim();
2092
+
2093
+ // b. remoteHash (from origin/<editorBranch>)
2094
+ let remoteHash = null;
2095
+ const remoteHashResult = spawnSync("git", ["rev-parse", `origin/${editorBranch}:${scriptPath}`], { encoding: "utf8" });
2096
+ if (remoteHashResult.status === 0) {
2097
+ remoteHash = remoteHashResult.stdout.trim();
2098
+ }
2099
+
2100
+ // c. baseHash (from mergeBase)
2101
+ let baseHash = null;
2102
+ if (mergeBase) {
2103
+ const baseHashResult = spawnSync("git", ["rev-parse", `${mergeBase}:${scriptPath}`], { encoding: "utf8" });
2104
+ if (baseHashResult.status === 0) {
2105
+ baseHash = baseHashResult.stdout.trim();
2106
+ }
2107
+ }
2108
+
2109
+ // d. dirty status
2110
+ const statusResult = spawnSync("git", ["status", "--porcelain", scriptPath], { encoding: "utf8" });
2111
+ const dirty = statusResult.status === 0 && statusResult.stdout.trim() !== "";
2112
+
2113
+ filesData.push({
2114
+ path: scriptPath,
2115
+ localHash,
2116
+ remoteHash,
2117
+ baseHash,
2118
+ dirty
2119
+ });
2120
+ }
2121
+
2122
+ const { classifyScriptDrift } = require("./script-drift");
2123
+ const verdictResult = classifyScriptDrift({ files: filesData });
2124
+
2125
+ if (verdictResult.verdict === "stale" || verdictResult.verdict === "diverged") {
2126
+ console.error("");
2127
+ console.error("================================================================================");
2128
+ console.error(`✗ ERROR: Streamed scripts have drifted from origin/${editorBranch}!`);
2129
+ console.error(` Verdict: ${verdictResult.verdict.toUpperCase()}`);
2130
+ console.error(" The following files are out of date on your machine:");
2131
+ for (const f of verdictResult.staleFiles) {
2132
+ console.error(` - ${f}`);
2133
+ }
2134
+ console.error("");
2135
+ console.error(" To proceed safely, please update your local checkout first:");
2136
+ console.error(` git pull --no-rebase origin ${editorBranch}`);
2137
+ console.error("");
2138
+ console.error(" If you must bypass this check (emergency provisioning), set:");
2139
+ console.error(" CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1");
2140
+ console.error("================================================================================");
2141
+ console.error("");
2142
+ process.exit(1);
2143
+ } else if (verdictResult.verdict === "local-ahead") {
2144
+ console.error(" [Warning] Streaming locally-modified scripts to VM (developer mode).");
2145
+ } else {
2146
+ console.error(" ✓ Streamed scripts are in sync with remote.");
2147
+ }
2148
+ }
2149
+
1867
2150
  // =============================================================================
1868
2151
  // Subcommand handlers
1869
2152
  // =============================================================================
1870
2153
 
1871
- async function cmdUp(args) {
2154
+ async function cmdUp(args, projectDir = rootDir) {
1872
2155
  ensureGcloud();
1873
2156
  const flags = parseFlags(args);
1874
- const cfg = loadOrInitCloudConfig(rootDir, flags);
2157
+ const cfg = loadOrInitCloudConfig(projectDir, flags);
1875
2158
  // Fail loud BEFORE creating a GCE instance if an unprovisionable editor
1876
2159
  // variant was requested — otherwise we'd spin up a VM, build a dev image,
1877
2160
  // and silently ignore the operator's staging/production intent.
1878
2161
  assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1879
2162
 
2163
+ checkScriptDrift(cfg);
2164
+
1880
2165
  await ensureFirewallRule(cfg);
1881
2166
  const { createdThisRun } = await ensureInstance(cfg);
1882
2167
 
@@ -1904,6 +2189,18 @@ async function cmdUp(args) {
1904
2189
  // us — a run reported success with no container). A failure here is a loud
1905
2190
  // non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
1906
2191
  await assertEditorContainerRunning(cfg);
2192
+ // Now that the editor answers on its own surface, ask it whether the
2193
+ // pre-container seed wrong-moded the previewOrigin (the stack.json race) and
2194
+ // heal it in place. Best-effort, never fatal — wrapped so a probe hiccup
2195
+ // can't abort an otherwise-green add.
2196
+ try {
2197
+ await healPreviewOriginAtReady(cfg, appPort);
2198
+ } catch (healErr) {
2199
+ console.error(
2200
+ ` Warning: READY preview-origin auto-heal probe failed (${healErr.message}); ` +
2201
+ `the cloudflared-monitor tick will reconcile.`,
2202
+ );
2203
+ }
1907
2204
  } catch (err) {
1908
2205
  if (createdThisRun) {
1909
2206
  console.error(
@@ -1940,10 +2237,10 @@ async function cmdUp(args) {
1940
2237
  attachKeepAliveAndTeardown(tunnelChild);
1941
2238
  }
1942
2239
 
1943
- async function cmdTunnel(args) {
2240
+ async function cmdTunnel(args, projectDir = rootDir) {
1944
2241
  ensureGcloud();
1945
2242
  const flags = parseFlags(args);
1946
- const existing = loadCloudConfig(rootDir);
2243
+ const existing = loadCloudConfig(projectDir);
1947
2244
  if (!existing) {
1948
2245
  throw new Error(
1949
2246
  "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
@@ -1971,10 +2268,10 @@ async function cmdTunnel(args) {
1971
2268
  attachKeepAliveAndTeardown(tunnelChild);
1972
2269
  }
1973
2270
 
1974
- async function cmdStop(args) {
2271
+ async function cmdStop(args, projectDir = rootDir) {
1975
2272
  ensureGcloud();
1976
2273
  parseFlags(args);
1977
- const cfg = loadCloudConfig(rootDir);
2274
+ const cfg = loadCloudConfig(projectDir);
1978
2275
  if (!cfg) {
1979
2276
  throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
1980
2277
  }
@@ -1990,10 +2287,10 @@ async function cmdStop(args) {
1990
2287
  );
1991
2288
  }
1992
2289
 
1993
- async function cmdDestroy(args) {
2290
+ async function cmdDestroy(args, projectDir = rootDir) {
1994
2291
  ensureGcloud();
1995
2292
  parseFlags(args);
1996
- const cfg = loadCloudConfig(rootDir);
2293
+ const cfg = loadCloudConfig(projectDir);
1997
2294
  if (!cfg) {
1998
2295
  throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
1999
2296
  }
@@ -2027,17 +2324,17 @@ async function cmdDestroy(args) {
2027
2324
  }
2028
2325
 
2029
2326
  try {
2030
- fs.unlinkSync(cloudConfigPath(rootDir));
2327
+ fs.unlinkSync(cloudConfigPath(projectDir));
2031
2328
  console.error("Deleted .codeyam/cloud.json");
2032
2329
  } catch (e) {
2033
2330
  if (e.code !== "ENOENT") throw e;
2034
2331
  }
2035
2332
  }
2036
2333
 
2037
- async function cmdStatus(args) {
2334
+ async function cmdStatus(args, projectDir = rootDir) {
2038
2335
  ensureGcloud();
2039
2336
  parseFlags(args);
2040
- const cfg = loadCloudConfig(rootDir);
2337
+ const cfg = loadCloudConfig(projectDir);
2041
2338
  if (!cfg) {
2042
2339
  console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
2043
2340
  return;
@@ -2131,6 +2428,7 @@ module.exports = {
2131
2428
  resolveSeedPreviewOrigin,
2132
2429
  previewSameOriginSafeFromStack,
2133
2430
  isRottedManagedPreviewOrigin,
2431
+ isWrongModePreviewOrigin,
2134
2432
  resolveEditorConfig,
2135
2433
  validateEditorVariant,
2136
2434
  assertVariantProvisionable,
@@ -2143,9 +2441,11 @@ module.exports = {
2143
2441
  cloudConfigPath,
2144
2442
  loadCloudConfig,
2145
2443
  saveCloudConfig,
2444
+ loadOrInitCloudConfig,
2146
2445
  buildEditorLocalEnvPatch,
2147
2446
  buildEditorLocalSeedWriteCommand,
2148
2447
  seedPreviewOrigin,
2448
+ healPreviewOriginAtReady,
2149
2449
  buildGcloudArgs,
2150
2450
  sshScriptCommand,
2151
2451
  bootstrapRunnerScript,
@@ -2167,6 +2467,8 @@ module.exports = {
2167
2467
  ensureInstance,
2168
2468
  ensureBootstrapped,
2169
2469
  assertEditorContainerRunning,
2470
+ checkScriptDrift,
2471
+ STREAMED_SCRIPTS,
2170
2472
  // side-effecting handlers
2171
2473
  cmdUp,
2172
2474
  cmdTunnel,
@@ -80,10 +80,27 @@ function parseDfUsedPct(dfOutput) {
80
80
  return null;
81
81
  }
82
82
 
83
+ // Classify a disk-guard sidecar age (updatedAt vs current time).
84
+ // If the sidecar is older than 600s (10 min), classify it as stale.
85
+ function classifySidecarAge(updatedAt, currentEpoch) {
86
+ if (typeof updatedAt !== 'number' || typeof currentEpoch !== 'number') return 'stale';
87
+ const age = currentEpoch - updatedAt;
88
+ if (age > 600) return 'stale';
89
+ return 'healthy';
90
+ }
91
+
92
+ // Map or classify a general disk state level including 'unknown-exec-failed'
93
+ function mapDiskStateLevel(usedPct, hasExecError) {
94
+ if (hasExecError) return 'unknown-exec-failed';
95
+ return classifyDiskPressure(usedPct);
96
+ }
97
+
83
98
  module.exports = {
84
99
  DISK_WARN_PCT,
85
100
  DISK_CRITICAL_PCT,
86
101
  PRUNABLE_CACHE_DIRS,
87
102
  classifyDiskPressure,
88
103
  parseDfUsedPct,
104
+ classifySidecarAge,
105
+ mapDiskStateLevel,
89
106
  };