@codeyam-editor/codeyam-editor 0.1.0-staging.4d8da9a → 0.1.0-staging.6286eae

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.
@@ -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
@@ -411,6 +411,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
411
411
  );
412
412
  }
413
413
 
414
+ // A loopback / localhost previewOrigin: `http://127.0.0.1:<port>`,
415
+ // `http://localhost:<port>`, `http://[::1]:<port>`, with or without a port,
416
+ // path, or query. These are reachable only from the editor host itself (or a
417
+ // laptop `-L` forward), never from a remote browser — exactly the dead-end the
418
+ // dedicated_preview_origin fallback lands on when the seed was wrong-moded.
419
+ const LOOPBACK_ORIGIN_RE =
420
+ /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#]|$)/i;
421
+
422
+ /** Decide whether a VM's current `editor.local.json` carries a WRONG-MODE
423
+ * previewOrigin for a stack that is NOT same-origin-safe (Next.js etc.) and so
424
+ * must be self-healed to a dedicated origin. This is the inverse of
425
+ * `isRottedManagedPreviewOrigin`: it fires when the stack.json race froze the
426
+ * same-origin-safe default (or a loopback bootstrap) onto a client-routed
427
+ * stack — the VM7 failure where the iframe shows `127.0.0.1 refused to
428
+ * connect`. True iff BOTH hold:
429
+ * - the stack is NOT same-origin-safe (a same-origin-safe stack legitimately
430
+ * keeps the `/__codeyam_preview` subpath — never heal it; that is
431
+ * `isRottedManagedPreviewOrigin`'s job in the other direction); and
432
+ * - the `previewOrigin` is the same-origin subpath sentinel, any other
433
+ * relative path, or a loopback/localhost origin — none of which a remote
434
+ * browser can load for a dedicated-origin stack. A real dedicated origin
435
+ * (an absolute non-loopback URL — quick-tunnel or named-tunnel host) is
436
+ * left alone, so re-running the heal once a good origin is in place is a
437
+ * no-op.
438
+ * Symmetric with `isRottedManagedPreviewOrigin` and tested as a pair. Pure —
439
+ * `localJson` is a parsed editor.local.json object. */
440
+ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
441
+ if (sameOriginSafe) return false;
442
+ if (!localJson || typeof localJson !== "object") return false;
443
+ const origin = localJson.previewOrigin;
444
+ if (typeof origin !== "string" || origin.length === 0) return false;
445
+ // The reserved same-origin subpath, or any relative path (no scheme): these
446
+ // recurse into the editor SPA for a client-routed stack.
447
+ if (origin === SAME_ORIGIN_PREVIEW_PATH) return true;
448
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return true;
449
+ // An absolute loopback/localhost origin: reachable only from the host, dead
450
+ // for a remote browser.
451
+ return LOOPBACK_ORIGIN_RE.test(origin);
452
+ }
453
+
414
454
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
415
455
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
416
456
  function readCloudflaredUrls() {
@@ -838,6 +878,14 @@ function loadOrInitCloudConfig(projectDir, flags) {
838
878
  const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
839
879
  if (existing) {
840
880
  const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
881
+ const { isPlaceholderProject } = require("./launch-config");
882
+ if (isPlaceholderProject(merged.project)) {
883
+ const configPath = cloudConfigPath(projectDir);
884
+ throw new Error(
885
+ `Failed to load cloud config: project '${merged.project}' in ${configPath} is a test-fixture placeholder. ` +
886
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
887
+ );
888
+ }
841
889
  const toSave = persist(merged);
842
890
  if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
843
891
  saveCloudConfig(projectDir, toSave);
@@ -847,6 +895,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
847
895
 
848
896
  const cfg = reconcileProjectExclusivity({ ...flags }, flags);
849
897
  cfg.project = cfg.project || gcloudGetValue("config-get-project");
898
+
899
+ const { isPlaceholderProject } = require("./launch-config");
900
+ if (isPlaceholderProject(cfg.project)) {
901
+ throw new Error(
902
+ `Failed to load cloud config: resolved project '${cfg.project}' is a test-fixture placeholder. ` +
903
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
904
+ );
905
+ }
906
+
850
907
  cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
851
908
  cfg.agent = cfg.agent || "claude";
852
909
  agentEnvVarName(cfg.agent);
@@ -1651,27 +1708,43 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1651
1708
  );
1652
1709
 
1653
1710
  // 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.
1711
+ // is normally a user pin or a good prior seed and is left untouched. There are
1712
+ // TWO symmetric heal exceptions, both of which the frozen skip-guard would
1713
+ // otherwise pin forever:
1714
+ // 1. a ROTTED managed cloudflared quick-tunnel URL on a same-origin-safe VM
1715
+ // (the VM-1/2/6 leftover from before the improve38 same-origin
1716
+ // migration): that frozen URL points at a dead rotated tunnel (NXDOMAIN /
1717
+ // blank preview), so we heal it back to the same-origin path; and
1718
+ // 2. a WRONG-MODE previewOrigin on a NOT-same-origin-safe VM (the VM7 race:
1719
+ // the first seed ran before onboarding wrote stack.json, defaulted to
1720
+ // `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
1721
+ // remote browser then loads as `127.0.0.1 refused to connect`). Once
1722
+ // stack.json exists, heal it forward to the dedicated origin.
1723
+ // Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
1724
+ // re-runs this seed over cloud:up, either heal reaches every touch-point the
1725
+ // moment stack.json is present — closing the race without re-ordering
1726
+ // provisioning, and without a separate per-VM hand-fix.
1662
1727
  const originAlreadySet =
1663
1728
  typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1664
1729
  const healing =
1665
- originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
1730
+ originAlreadySet &&
1731
+ (isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
1732
+ isWrongModePreviewOrigin(current, sameOriginSafe));
1666
1733
  if (originAlreadySet && !healing) {
1667
1734
  return;
1668
1735
  }
1669
1736
 
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;
1737
+ // On heal, reconcile the `_managedPreviewOrigin` marker to the healed value's
1738
+ // own shape. The rotted→same-origin heal (seed carries NO marker) DROPS any
1739
+ // stale marker (deepMergeJson deletes a key whose overlay value is null) — the
1740
+ // constant same-origin path has nothing for the monitor to manage, exactly
1741
+ // matching the manual `config-override --unset _managedPreviewOrigin` fix. The
1742
+ // wrong-mode→dedicated heal (seed carries the marker) KEEPS it — that origin
1743
+ // IS managed (the monitor's gated rotation push owns it), so the marker must
1744
+ // survive so the next respawn recognises the value as ours.
1745
+ const overlay = healing
1746
+ ? { ...seed, _managedPreviewOrigin: seed._managedPreviewOrigin ?? null }
1747
+ : seed;
1675
1748
  console.error(
1676
1749
  healing
1677
1750
  ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
@@ -1691,6 +1764,82 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1691
1764
  }
1692
1765
  }
1693
1766
 
1767
+ /** READY-probe auto-heal: after the editor container is verifiably Up, ask the
1768
+ * running editor's own `/api/config` for its stack verdict and effective
1769
+ * preview origin, and heal a wrong-moded seed in place — never failing the add.
1770
+ *
1771
+ * The race this closes (VM7, 2026-06-10): the pre-container `seedPreviewOrigin`
1772
+ * at line ~1906 runs BEFORE onboarding writes stack.json, so it defaults to
1773
+ * `sameOriginSafe: true` and seeds the `/__codeyam_preview` subpath. For a
1774
+ * client-routed stack (Next.js) the editor then reports `previewSameOriginSafe:
1775
+ * false` but, with only the subpath in editor.local.json, falls its
1776
+ * `dedicated_preview_origin` back to `http://127.0.0.1:<appPort>` — which a
1777
+ * remote browser renders as "refused to connect". If stack.json appeared since
1778
+ * the first seed (the editor reads it live from disk), we re-run the seed — now
1779
+ * the wrong-mode heal fires (`isWrongModePreviewOrigin`) — and POST
1780
+ * `/api/editor-reload-config` so the running server serves the healed origin
1781
+ * with no restart.
1782
+ *
1783
+ * Best-effort and non-fatal by contract: a probe failure, a still-absent
1784
+ * stack.json (verdict stays `true`), or a heal that can only reach a loopback
1785
+ * bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
1786
+ * leave the add green. The cloudflared-monitor's periodic tick (section 2) is
1787
+ * the durable owner that heals post-add flips — onboarding finishing minutes
1788
+ * after READY. */
1789
+ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
1790
+ const probe = runGcloud(
1791
+ buildGcloudArgs("ssh-probe", cfg, {
1792
+ command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
1793
+ }),
1794
+ { stdio: ["ignore", "pipe", "ignore"] },
1795
+ );
1796
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
1797
+ return;
1798
+ }
1799
+ let config;
1800
+ try {
1801
+ config = JSON.parse(probe.stdout);
1802
+ } catch {
1803
+ return;
1804
+ }
1805
+ if (!config || typeof config !== "object") return;
1806
+
1807
+ // The editor's own verdict (section 3's `/api/config` field) plus the
1808
+ // wrong-mode predicate (a subpath / relative / loopback origin on a
1809
+ // not-same-origin-safe stack) — the exact dead-end the dedicated-origin
1810
+ // fallback lands on. Same-origin-safe stacks (verdict true) never enter here.
1811
+ if (config.previewSameOriginSafe !== false) return;
1812
+ if (!isWrongModePreviewOrigin(config, false)) return;
1813
+
1814
+ console.error(
1815
+ `READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
1816
+ `but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
1817
+ );
1818
+ // Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
1819
+ // the dedicated origin into editor.local.json (a published `<N>-preview`
1820
+ // tunnel URL, else the localhost bootstrap for the window before the monitor's
1821
+ // first tick).
1822
+ await seedPreviewOrigin(cfg, appPort);
1823
+ // Tell the running server to re-read editor.local.json — no restart (the
1824
+ // footgun that killed VM7's live Claude session).
1825
+ const reload = runGcloud(
1826
+ buildGcloudArgs("ssh-probe", cfg, {
1827
+ command: `curl -fsS --max-time 5 -X POST http://localhost:${EDITOR_PORT}/api/editor-reload-config 2>/dev/null || true`,
1828
+ }),
1829
+ { stdio: ["ignore", "inherit", "inherit"] },
1830
+ );
1831
+ if (reload.status !== 0) {
1832
+ console.error(
1833
+ ` Warning: heal wrote editor.local.json but /api/editor-reload-config did ` +
1834
+ `not return cleanly — the next monitor tick or cloud:up touch-point will reconcile.`,
1835
+ );
1836
+ }
1837
+ console.error(
1838
+ ` Live Preview origin healed. If the operator <N>-preview tunnel is not ` +
1839
+ `published yet, the cloudflared-monitor's periodic tick provides the ongoing heal.`,
1840
+ );
1841
+ }
1842
+
1694
1843
  /** Write the chosen agent provider and local API keys into editor.local.json on
1695
1844
  * the VM BEFORE compose up so the file exists when the bind-mount happens —
1696
1845
  * the provider seed MUST run before ensureEditorContainerUp, because the editor
@@ -1787,22 +1936,18 @@ async function ensureAgentCliInstalled(cfg) {
1787
1936
  `AGENT="${agent}"`,
1788
1937
  "",
1789
1938
  "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",
1939
+ " echo \" ✓ $BINARY already installed on PATH\"",
1940
+ " exit 0",
1799
1941
  "fi",
1800
1942
  "",
1801
1943
  "echo \"Installing $PACKAGE globally in container...\"",
1802
1944
  "if [ \"$AGENT\" = \"claude\" ]; then",
1803
1945
  " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
1804
1946
  " 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",
1947
+ " RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
1948
+ " if [ -n \"$RESOLVED\" ]; then",
1949
+ " ln -sf \"$RESOLVED\" /usr/local/bin/claude",
1950
+ " fi",
1806
1951
  "else",
1807
1952
  " npm install -g \"$PACKAGE\"",
1808
1953
  "fi",
@@ -1864,19 +2009,117 @@ function promptForInstanceName(expected) {
1864
2009
  });
1865
2010
  }
1866
2011
 
2012
+ const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
2013
+
2014
+ /** Check if the streamed bootstrap scripts have drifted from the remote repository
2015
+ * and abort provisioning if they are out of date to prevent silent container/VM failures. */
2016
+ function checkScriptDrift(cfg) {
2017
+ if (process.env.CODEYAM_SKIP_SCRIPT_DRIFT_CHECK === "1") {
2018
+ console.error(" [Warning] CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1 active. Skipping streamed-script drift preflight check.");
2019
+ return;
2020
+ }
2021
+
2022
+ const { editorBranch = "main" } = resolveEditorConfig(cfg);
2023
+
2024
+ console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
2025
+
2026
+ // 1. git fetch origin <editorBranch>
2027
+ const fetchResult = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
2028
+ if (fetchResult.status !== 0) {
2029
+ console.error(` [Warning] Failed to fetch origin ${editorBranch} (${(fetchResult.stderr || "").trim() || "unknown error"}). Skipping drift check.`);
2030
+ return;
2031
+ }
2032
+
2033
+ // 2. Find the merge base between HEAD and origin/<editorBranch>
2034
+ const mbResult = spawnSync("git", ["merge-base", "HEAD", `origin/${editorBranch}`], { encoding: "utf8" });
2035
+ let mergeBase = null;
2036
+ if (mbResult.status === 0) {
2037
+ mergeBase = mbResult.stdout.trim();
2038
+ }
2039
+
2040
+ const filesData = [];
2041
+
2042
+ for (const scriptPath of STREAMED_SCRIPTS) {
2043
+ // a. localHash
2044
+ const localHashResult = spawnSync("git", ["hash-object", scriptPath], { encoding: "utf8" });
2045
+ if (localHashResult.status !== 0) {
2046
+ console.error(` [Warning] Failed to compute local hash for ${scriptPath}. Skipping drift check.`);
2047
+ return;
2048
+ }
2049
+ const localHash = localHashResult.stdout.trim();
2050
+
2051
+ // b. remoteHash (from origin/<editorBranch>)
2052
+ let remoteHash = null;
2053
+ const remoteHashResult = spawnSync("git", ["rev-parse", `origin/${editorBranch}:${scriptPath}`], { encoding: "utf8" });
2054
+ if (remoteHashResult.status === 0) {
2055
+ remoteHash = remoteHashResult.stdout.trim();
2056
+ }
2057
+
2058
+ // c. baseHash (from mergeBase)
2059
+ let baseHash = null;
2060
+ if (mergeBase) {
2061
+ const baseHashResult = spawnSync("git", ["rev-parse", `${mergeBase}:${scriptPath}`], { encoding: "utf8" });
2062
+ if (baseHashResult.status === 0) {
2063
+ baseHash = baseHashResult.stdout.trim();
2064
+ }
2065
+ }
2066
+
2067
+ // d. dirty status
2068
+ const statusResult = spawnSync("git", ["status", "--porcelain", scriptPath], { encoding: "utf8" });
2069
+ const dirty = statusResult.status === 0 && statusResult.stdout.trim() !== "";
2070
+
2071
+ filesData.push({
2072
+ path: scriptPath,
2073
+ localHash,
2074
+ remoteHash,
2075
+ baseHash,
2076
+ dirty
2077
+ });
2078
+ }
2079
+
2080
+ const { classifyScriptDrift } = require("./script-drift");
2081
+ const verdictResult = classifyScriptDrift({ files: filesData });
2082
+
2083
+ if (verdictResult.verdict === "stale" || verdictResult.verdict === "diverged") {
2084
+ console.error("");
2085
+ console.error("================================================================================");
2086
+ console.error(`✗ ERROR: Streamed scripts have drifted from origin/${editorBranch}!`);
2087
+ console.error(` Verdict: ${verdictResult.verdict.toUpperCase()}`);
2088
+ console.error(" The following files are out of date on your machine:");
2089
+ for (const f of verdictResult.staleFiles) {
2090
+ console.error(` - ${f}`);
2091
+ }
2092
+ console.error("");
2093
+ console.error(" To proceed safely, please update your local checkout first:");
2094
+ console.error(` git pull --no-rebase origin ${editorBranch}`);
2095
+ console.error("");
2096
+ console.error(" If you must bypass this check (emergency provisioning), set:");
2097
+ console.error(" CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1");
2098
+ console.error("================================================================================");
2099
+ console.error("");
2100
+ process.exit(1);
2101
+ } else if (verdictResult.verdict === "local-ahead") {
2102
+ console.error(" [Warning] Streaming locally-modified scripts to VM (developer mode).");
2103
+ } else {
2104
+ console.error(" ✓ Streamed scripts are in sync with remote.");
2105
+ }
2106
+ }
2107
+
1867
2108
  // =============================================================================
1868
2109
  // Subcommand handlers
1869
2110
  // =============================================================================
1870
2111
 
1871
- async function cmdUp(args) {
2112
+ async function cmdUp(args, projectDir = rootDir) {
1872
2113
  ensureGcloud();
1873
2114
  const flags = parseFlags(args);
1874
- const cfg = loadOrInitCloudConfig(rootDir, flags);
2115
+ const cfg = loadOrInitCloudConfig(projectDir, flags);
1875
2116
  // Fail loud BEFORE creating a GCE instance if an unprovisionable editor
1876
2117
  // variant was requested — otherwise we'd spin up a VM, build a dev image,
1877
2118
  // and silently ignore the operator's staging/production intent.
1878
2119
  assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1879
2120
 
2121
+ checkScriptDrift(cfg);
2122
+
1880
2123
  await ensureFirewallRule(cfg);
1881
2124
  const { createdThisRun } = await ensureInstance(cfg);
1882
2125
 
@@ -1904,6 +2147,18 @@ async function cmdUp(args) {
1904
2147
  // us — a run reported success with no container). A failure here is a loud
1905
2148
  // non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
1906
2149
  await assertEditorContainerRunning(cfg);
2150
+ // Now that the editor answers on its own surface, ask it whether the
2151
+ // pre-container seed wrong-moded the previewOrigin (the stack.json race) and
2152
+ // heal it in place. Best-effort, never fatal — wrapped so a probe hiccup
2153
+ // can't abort an otherwise-green add.
2154
+ try {
2155
+ await healPreviewOriginAtReady(cfg, appPort);
2156
+ } catch (healErr) {
2157
+ console.error(
2158
+ ` Warning: READY preview-origin auto-heal probe failed (${healErr.message}); ` +
2159
+ `the cloudflared-monitor tick will reconcile.`,
2160
+ );
2161
+ }
1907
2162
  } catch (err) {
1908
2163
  if (createdThisRun) {
1909
2164
  console.error(
@@ -1940,10 +2195,10 @@ async function cmdUp(args) {
1940
2195
  attachKeepAliveAndTeardown(tunnelChild);
1941
2196
  }
1942
2197
 
1943
- async function cmdTunnel(args) {
2198
+ async function cmdTunnel(args, projectDir = rootDir) {
1944
2199
  ensureGcloud();
1945
2200
  const flags = parseFlags(args);
1946
- const existing = loadCloudConfig(rootDir);
2201
+ const existing = loadCloudConfig(projectDir);
1947
2202
  if (!existing) {
1948
2203
  throw new Error(
1949
2204
  "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
@@ -1971,10 +2226,10 @@ async function cmdTunnel(args) {
1971
2226
  attachKeepAliveAndTeardown(tunnelChild);
1972
2227
  }
1973
2228
 
1974
- async function cmdStop(args) {
2229
+ async function cmdStop(args, projectDir = rootDir) {
1975
2230
  ensureGcloud();
1976
2231
  parseFlags(args);
1977
- const cfg = loadCloudConfig(rootDir);
2232
+ const cfg = loadCloudConfig(projectDir);
1978
2233
  if (!cfg) {
1979
2234
  throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
1980
2235
  }
@@ -1990,10 +2245,10 @@ async function cmdStop(args) {
1990
2245
  );
1991
2246
  }
1992
2247
 
1993
- async function cmdDestroy(args) {
2248
+ async function cmdDestroy(args, projectDir = rootDir) {
1994
2249
  ensureGcloud();
1995
2250
  parseFlags(args);
1996
- const cfg = loadCloudConfig(rootDir);
2251
+ const cfg = loadCloudConfig(projectDir);
1997
2252
  if (!cfg) {
1998
2253
  throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
1999
2254
  }
@@ -2027,17 +2282,17 @@ async function cmdDestroy(args) {
2027
2282
  }
2028
2283
 
2029
2284
  try {
2030
- fs.unlinkSync(cloudConfigPath(rootDir));
2285
+ fs.unlinkSync(cloudConfigPath(projectDir));
2031
2286
  console.error("Deleted .codeyam/cloud.json");
2032
2287
  } catch (e) {
2033
2288
  if (e.code !== "ENOENT") throw e;
2034
2289
  }
2035
2290
  }
2036
2291
 
2037
- async function cmdStatus(args) {
2292
+ async function cmdStatus(args, projectDir = rootDir) {
2038
2293
  ensureGcloud();
2039
2294
  parseFlags(args);
2040
- const cfg = loadCloudConfig(rootDir);
2295
+ const cfg = loadCloudConfig(projectDir);
2041
2296
  if (!cfg) {
2042
2297
  console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
2043
2298
  return;
@@ -2131,6 +2386,7 @@ module.exports = {
2131
2386
  resolveSeedPreviewOrigin,
2132
2387
  previewSameOriginSafeFromStack,
2133
2388
  isRottedManagedPreviewOrigin,
2389
+ isWrongModePreviewOrigin,
2134
2390
  resolveEditorConfig,
2135
2391
  validateEditorVariant,
2136
2392
  assertVariantProvisionable,
@@ -2143,9 +2399,11 @@ module.exports = {
2143
2399
  cloudConfigPath,
2144
2400
  loadCloudConfig,
2145
2401
  saveCloudConfig,
2402
+ loadOrInitCloudConfig,
2146
2403
  buildEditorLocalEnvPatch,
2147
2404
  buildEditorLocalSeedWriteCommand,
2148
2405
  seedPreviewOrigin,
2406
+ healPreviewOriginAtReady,
2149
2407
  buildGcloudArgs,
2150
2408
  sshScriptCommand,
2151
2409
  bootstrapRunnerScript,
@@ -2167,6 +2425,8 @@ module.exports = {
2167
2425
  ensureInstance,
2168
2426
  ensureBootstrapped,
2169
2427
  assertEditorContainerRunning,
2428
+ checkScriptDrift,
2429
+ STREAMED_SCRIPTS,
2170
2430
  // side-effecting handlers
2171
2431
  cmdUp,
2172
2432
  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
  };
package/npm/fleet-auth.js CHANGED
@@ -108,4 +108,9 @@ function deriveDefaultAuthAccount(vms, lastAuthAt) {
108
108
  return best ? best.account : null;
109
109
  }
110
110
 
111
- module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount };
111
+ // Return true if this one VM is eligible for an auth-start action now.
112
+ function isAuthStartable(v, job) {
113
+ return classifyAuthVm(v, job).eligible;
114
+ }
115
+
116
+ module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount, isAuthStartable };
@@ -297,11 +297,59 @@ function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
297
297
  // posture). "Busy" (a live session or an in-flight add to this number) also
298
298
  // holds the entry for an operator rather than auto-removing — the shouldn't-
299
299
  // happen-but-fail-safe case where a vanished instance still reports a session.
300
- function reconcileRosteredVm({ exists, hasSession, hasPendingAdd } = {}) {
300
+ // verdict when dispatch
301
+ // 'cleanup' instance gone AND the VM is idle runDestroyCleanup
302
+ // 'leave' exists uncertain (null), OR the VM is busy do nothing this pass
303
+ // 'keep' instance still exists do nothing (healthy)
304
+ // 'reset' existing idle instance unreachable too long trigger auto-reset
305
+ // Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence
306
+ // (exists === false) — a flaky/transient describe (null) is always 'leave', so a
307
+ // gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave`
308
+ // posture). "Busy" (a live session or an in-flight add to this number) also
309
+ // holds the entry for an operator rather than auto-removing — the shouldn't-
310
+ // happen-but-fail-safe case where a vanished instance still reports a session.
311
+ // auto-reset escalation (vm: OOM Wedge Prevention):
312
+ // When the VM has been unreachable for at least RESET_UNREACHABLE_THRESHOLD
313
+ // (e.g. 200, which is roughly 10 minutes at a 3s poll rate), we issue a 'reset'
314
+ // verdict. This auto-reset is guarded and bounded by a cooldown window (in ms)
315
+ // and a max attempts limit to prevent infinite reboot loops.
316
+ function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetHistory, threshold, cooldownMs, maxAttempts } = {}) {
301
317
  if (exists === null || exists === undefined) return "leave";
302
- if (exists) return "keep";
303
- if (hasSession || hasPendingAdd) return "leave";
304
- return "cleanup";
318
+ if (!exists) {
319
+ if (hasSession || hasPendingAdd) return "leave";
320
+ return "cleanup";
321
+ }
322
+
323
+ // If the VM exists, check if it's dead-unreachable and eligible for auto-reset.
324
+ if (streak !== undefined && threshold !== undefined) {
325
+ if (streak >= threshold) {
326
+ if (hasSession || hasPendingAdd) {
327
+ // Do NOT auto-reset a VM that is busy (holding a live session or an in-flight add)
328
+ return "keep";
329
+ }
330
+
331
+ const history = resetHistory || { count: 0, lastResetAt: 0 };
332
+ const attempts = history.count || 0;
333
+ const limit = maxAttempts !== undefined ? maxAttempts : 3;
334
+
335
+ if (attempts >= limit) {
336
+ // Budget exhausted (VM wedges repeatedly) -> park (needs-manual-recovery status)
337
+ return "keep";
338
+ }
339
+
340
+ const lastReset = history.lastResetAt || 0;
341
+ const cooldown = cooldownMs !== undefined ? cooldownMs : 600000; // default 10 minutes
342
+ const now = Date.now();
343
+ if (now - lastReset < cooldown) {
344
+ // Within cooldown window -> do not reset again yet
345
+ return "keep";
346
+ }
347
+
348
+ return "reset";
349
+ }
350
+ }
351
+
352
+ return "keep";
305
353
  }
306
354
 
307
355
  module.exports = {
@@ -31,16 +31,14 @@ function normalizeRoster(arr) {
31
31
  return [...new Set((arr || []).filter(Number.isInteger))].sort((a, b) => a - b);
32
32
  }
33
33
 
34
- // Read the roster, returning a normalized integer array. A missing or malformed
35
- // file is treated as the empty roster `[]` (the documented contract the previous
36
- // `readRoster`/`loadVmsState` duplicates shared) so a truncated write or an
37
- // absent file never throws into a consumer's poll loop.
34
+ // Read the roster, returning a normalized integer array, or null if the file is
35
+ // missing or malformed.
38
36
  function readRoster() {
39
37
  try {
40
38
  const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8'));
41
39
  if (Array.isArray(data)) return normalizeRoster(data);
42
- } catch { /* missing or malformed — treated as empty */ }
43
- return [];
40
+ } catch { /* missing or malformed */ }
41
+ return null;
44
42
  }
45
43
 
46
44
  // Persist `arr` as the roster, atomically: write a temp file then `rename` it
@@ -62,13 +60,13 @@ function writeRoster(arr) {
62
60
  // onSuccess rosters again — is a harmless no-op, which is exactly what closes
63
61
  // the race where one writer's roster write could fail.
64
62
  function addToRoster(n) {
65
- return writeRoster([...readRoster(), n]);
63
+ return writeRoster([...(readRoster() || []), n]);
66
64
  }
67
65
 
68
66
  // Remove `n` from the roster, returning the new roster. Removing an absent
69
67
  // entry is a no-op.
70
68
  function removeFromRoster(n) {
71
- return writeRoster(readRoster().filter((x) => x !== n));
69
+ return writeRoster((readRoster() || []).filter((x) => x !== n));
72
70
  }
73
71
 
74
72
  module.exports = {
@@ -123,6 +123,18 @@ function variantImageTag(variant) {
123
123
  return null; // dev / unknown — no packaged image
124
124
  }
125
125
 
126
+ /** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"),
127
+ * is null, empty, or whitespace, signaling that it is not a valid GCP project name.
128
+ * Documented as the home for future fixture sentinels. Pure; no I/O. */
129
+ function isPlaceholderProject(value) {
130
+ if (!value) return true;
131
+ if (typeof value !== "string") return true;
132
+ const trimmed = value.trim();
133
+ if (trimmed === "") return true;
134
+ if (trimmed.toLowerCase() === "gcp-proj") return true;
135
+ return false;
136
+ }
137
+
126
138
  /** Build the `cloud:up` flag array for a launch config + instance name. The
127
139
  * editor source is the canonical codeyam-editor repo (dev variant builds the
128
140
  * image from editorBranch); the client repo/branch is what lands in
@@ -137,9 +149,10 @@ function cloudUpArgs(cfg, instanceName, project) {
137
149
  // a persisted .codeyam/cloud.json `project` (cloud.js merges
138
150
  // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
139
151
  // gcloud fallback — the sticky-wrong-project failure mode where a stale
140
- // "gcp-proj" in cloud.json wedges every re-provision with a firewall-create
141
- // PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape
142
- // (cloud.js then falls back to the gcloud default, as before).
152
+ // placeholder in cloud.json (validated via isPlaceholderProject) wedges every
153
+ // re-provision with a firewall-create PERMISSION_DENIED. Omitted when unset to
154
+ // preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
155
+ // default, as before).
143
156
  if (project) args.push("--project", project);
144
157
  // Greenfield (new folder) vs clone an existing repo.
145
158
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
@@ -724,4 +737,5 @@ module.exports = {
724
737
  decideStartJob,
725
738
  getAuthRepoUrl,
726
739
  interpretLsRemote,
740
+ isPlaceholderProject,
727
741
  };