@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5
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-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +100 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +453 -43
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/launch-config.js
CHANGED
|
@@ -8,7 +8,16 @@
|
|
|
8
8
|
// runs on a VM; the history is the cache that makes the last choice the default
|
|
9
9
|
// and past launches one click to re-run.
|
|
10
10
|
|
|
11
|
-
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
|
|
11
|
+
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
|
|
12
|
+
const { normalizeVmSlug } = require("./vm-urls");
|
|
13
|
+
const { normalizeAccessList } = require("./fleet-vm-access");
|
|
14
|
+
|
|
15
|
+
/** The build agents a VM's editor terminal can run, as the keys of cloud.js's
|
|
16
|
+
* AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
|
|
17
|
+
* source of truth with the agent→API-key-env-var mapping (cloud.js) and the
|
|
18
|
+
* gcp-bootstrap CLI installer, so adding an agent is still a one-line change in
|
|
19
|
+
* AGENT_ENV_VARS rather than a list to keep in sync here. */
|
|
20
|
+
const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS));
|
|
12
21
|
|
|
13
22
|
const EMPTY_CONFIG = Object.freeze({
|
|
14
23
|
clientRepo: "",
|
|
@@ -18,20 +27,52 @@ const EMPTY_CONFIG = Object.freeze({
|
|
|
18
27
|
newProjectName: "",
|
|
19
28
|
editorVariant: "dev",
|
|
20
29
|
editorBranch: "",
|
|
30
|
+
// Build agent provider the VM's editor terminal runs (claude | codex | gemini).
|
|
31
|
+
// Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
|
|
32
|
+
agent: "claude",
|
|
33
|
+
// Optional human-readable hostname slug for a SINGLE VM. When set, cloudUpArgs
|
|
34
|
+
// forwards it as cloud:up's --vm-slug flag so the VM's public URLs become
|
|
35
|
+
// `<slug>.editor.<domain>`. Empty ⇒ the numeric vm-<N> scheme (unchanged).
|
|
36
|
+
vmSlug: "",
|
|
37
|
+
// Optional per-VM IAP access whitelist for a SINGLE VM: a comma-separated list
|
|
38
|
+
// of principals (user:/group:/domain:, a bare email defaults to user:) granted
|
|
39
|
+
// resource-level access to this VM's surfaces. cloudUpArgs forwards it as
|
|
40
|
+
// cloud:up's --vm-access flag. Empty ⇒ only the owner's project-wide grant.
|
|
41
|
+
vmAccess: "",
|
|
21
42
|
});
|
|
22
43
|
|
|
44
|
+
/** Collapse a free-text access list (the add-form textarea, where the operator
|
|
45
|
+
* may separate principals by newlines or commas) into the canonical
|
|
46
|
+
* comma-separated string cloud:up's --vm-access expects. Pure + never throws —
|
|
47
|
+
* shape validation is validateLaunchConfig's job. */
|
|
48
|
+
function normalizeAccessText(raw) {
|
|
49
|
+
if (typeof raw !== "string") return "";
|
|
50
|
+
return raw
|
|
51
|
+
.split(/[\n,]+/)
|
|
52
|
+
.map((s) => s.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join(",");
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
/** Coerce a raw form/query object into the canonical launch-config shape: trim
|
|
24
58
|
* strings, default the variant to a known value. Never throws — validation is
|
|
25
59
|
* a separate, explicit step. */
|
|
26
60
|
function normalizeLaunchConfig(input = {}) {
|
|
27
61
|
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
28
62
|
const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
|
|
63
|
+
// Coerce an unknown/absent agent to the default rather than rejecting — the
|
|
64
|
+
// form's agent selector only ever submits a known value, so an out-of-set
|
|
65
|
+
// agent is a stale query param, not an error.
|
|
66
|
+
const agent = AGENTS.includes(input.agent) ? input.agent : "claude";
|
|
29
67
|
return {
|
|
30
68
|
clientRepo: str(input.clientRepo),
|
|
31
69
|
clientBranch: str(input.clientBranch),
|
|
32
70
|
newProjectName: str(input.newProjectName),
|
|
33
71
|
editorVariant,
|
|
34
72
|
editorBranch: str(input.editorBranch),
|
|
73
|
+
agent,
|
|
74
|
+
vmSlug: str(input.vmSlug),
|
|
75
|
+
vmAccess: normalizeAccessText(input.vmAccess),
|
|
35
76
|
};
|
|
36
77
|
}
|
|
37
78
|
|
|
@@ -55,6 +96,26 @@ function validateLaunchConfig(cfg) {
|
|
|
55
96
|
if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
|
|
56
97
|
return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
|
|
57
98
|
}
|
|
99
|
+
// A custom slug, when supplied, must be a single DNS label (same rule cloud:up
|
|
100
|
+
// enforces) — surface a friendly reason here rather than letting the spawned
|
|
101
|
+
// job fail loud later. Empty ⇒ numeric vm-<N>, no check.
|
|
102
|
+
if (cfg.vmSlug) {
|
|
103
|
+
try {
|
|
104
|
+
normalizeVmSlug(cfg.vmSlug);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, msg: e.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// A per-VM access whitelist, when supplied, must be a list of valid principals
|
|
110
|
+
// (same rule cloud:up enforces) — surface a friendly reason here rather than
|
|
111
|
+
// letting the spawned job fail loud later. Empty ⇒ no check.
|
|
112
|
+
if (cfg.vmAccess) {
|
|
113
|
+
try {
|
|
114
|
+
normalizeAccessList(cfg.vmAccess);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { ok: false, msg: e.message };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
58
119
|
return { ok: true };
|
|
59
120
|
}
|
|
60
121
|
|
|
@@ -108,18 +169,52 @@ function variantImageTag(variant) {
|
|
|
108
169
|
return null; // dev / unknown — no packaged image
|
|
109
170
|
}
|
|
110
171
|
|
|
172
|
+
/** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"),
|
|
173
|
+
* is null, empty, or whitespace, signaling that it is not a valid GCP project name.
|
|
174
|
+
* Documented as the home for future fixture sentinels. Pure; no I/O. */
|
|
175
|
+
function isPlaceholderProject(value) {
|
|
176
|
+
if (!value) return true;
|
|
177
|
+
if (typeof value !== "string") return true;
|
|
178
|
+
const trimmed = value.trim();
|
|
179
|
+
if (trimmed === "") return true;
|
|
180
|
+
if (trimmed.toLowerCase() === "gcp-proj") return true;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
111
184
|
/** Build the `cloud:up` flag array for a launch config + instance name. The
|
|
112
185
|
* editor source is the canonical codeyam-editor repo (dev variant builds the
|
|
113
186
|
* image from editorBranch); the client repo/branch is what lands in
|
|
114
|
-
* /workspace.
|
|
115
|
-
|
|
187
|
+
* /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
|
|
188
|
+
* guarantees a known value, defaulting to claude), so a single config object
|
|
189
|
+
* carries every launch choice. Decoupled exactly as cloud.js consumes them. */
|
|
190
|
+
function cloudUpArgs(cfg, instanceName, project) {
|
|
116
191
|
const args = ["--instance-name", instanceName];
|
|
192
|
+
// Pass the authoritative GCP project (the dashboard's resolved PROJECT)
|
|
193
|
+
// explicitly so cloud:up never relies on the spawning shell's ambient
|
|
194
|
+
// `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES
|
|
195
|
+
// a persisted .codeyam/cloud.json `project` (cloud.js merges
|
|
196
|
+
// `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
|
|
197
|
+
// gcloud fallback — the sticky-wrong-project failure mode where a stale
|
|
198
|
+
// placeholder in cloud.json (validated via isPlaceholderProject) wedges every
|
|
199
|
+
// re-provision with a firewall-create PERMISSION_DENIED. Omitted when unset to
|
|
200
|
+
// preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
|
|
201
|
+
// default, as before).
|
|
202
|
+
if (project) args.push("--project", project);
|
|
117
203
|
// Greenfield (new folder) vs clone an existing repo.
|
|
118
204
|
if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
|
|
119
205
|
else args.push("--repo", cfg.clientRepo);
|
|
120
|
-
args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
|
|
206
|
+
args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
|
|
121
207
|
if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
|
|
122
208
|
if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
|
|
209
|
+
// Forward an optional custom hostname slug. Only single-VM Adds carry one (the
|
|
210
|
+
// dashboard strips it for batch count > 1 — N VMs can't share one hostname), so
|
|
211
|
+
// cloudUpArgs simply emits it when present and trusts the caller's batch guard.
|
|
212
|
+
if (cfg.vmSlug) args.push("--vm-slug", cfg.vmSlug);
|
|
213
|
+
// Forward an optional per-VM access whitelist. Like the slug, only single-VM
|
|
214
|
+
// Adds carry one (the dashboard strips it for batch count > 1 — the same guard
|
|
215
|
+
// actionAdd applies to vmSlug), so cloudUpArgs emits it when present and trusts
|
|
216
|
+
// the caller's batch guard.
|
|
217
|
+
if (cfg.vmAccess) args.push("--vm-access", cfg.vmAccess);
|
|
123
218
|
// The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
|
|
124
219
|
// it lets cloud:up exit cleanly and avoids parallel batches colliding on the
|
|
125
220
|
// fixed local ports 14199/5173 (the cause of multi-add "false failures").
|
|
@@ -237,6 +332,40 @@ function distinctConfigs(history, limit = 12) {
|
|
|
237
332
|
return out;
|
|
238
333
|
}
|
|
239
334
|
|
|
335
|
+
/** Display-side companion to `distinctConfigs`: derive the two label strings the
|
|
336
|
+
* "recent launches" picker renders for one config, so two configs that differ
|
|
337
|
+
* only by build agent (or greenfield project name) look different instead of
|
|
338
|
+
* collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
|
|
339
|
+
* the rows are genuinely distinct launches — the old inline label just never
|
|
340
|
+
* surfaced the distinguishing fields (agent, greenfield name).
|
|
341
|
+
*
|
|
342
|
+
* Returns { primary, secondary }:
|
|
343
|
+
* - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
|
|
344
|
+
* two different greenfield projects no longer both read `(none)`;
|
|
345
|
+
* else `repoBasename(clientRepo)` + (`@<branch>` when set); else
|
|
346
|
+
* `(none)` when there's neither a repo nor a project name.
|
|
347
|
+
* - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
|
|
348
|
+
*
|
|
349
|
+
* Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
|
|
350
|
+
* `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
|
|
351
|
+
* semantics stay single-sourced. Pure; no I/O. */
|
|
352
|
+
function recentLaunchDisplay(config) {
|
|
353
|
+
const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
|
|
354
|
+
let primary;
|
|
355
|
+
if (!cfg.clientRepo && cfg.newProjectName) {
|
|
356
|
+
primary = `new: ${cfg.newProjectName}`;
|
|
357
|
+
} else if (cfg.clientRepo) {
|
|
358
|
+
primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
|
|
359
|
+
} else {
|
|
360
|
+
primary = "(none)";
|
|
361
|
+
}
|
|
362
|
+
const secondary =
|
|
363
|
+
`editor ${cfg.editorVariant || "dev"}` +
|
|
364
|
+
(cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
|
|
365
|
+
` · ${cfg.agent || "claude"}`;
|
|
366
|
+
return { primary, secondary };
|
|
367
|
+
}
|
|
368
|
+
|
|
240
369
|
/** The default config to pre-fill the launch form: the most recent launch's
|
|
241
370
|
* config, or an empty config when there's no history. */
|
|
242
371
|
function defaultConfig(history) {
|
|
@@ -252,6 +381,28 @@ function configForVm(history, n) {
|
|
|
252
381
|
return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
|
|
253
382
|
}
|
|
254
383
|
|
|
384
|
+
/** The most-recent launch timestamp for VM `n`, or null when it has no recorded
|
|
385
|
+
* launch. The history is newest-first (pushHistory unshifts), so the FIRST entry
|
|
386
|
+
* for the VM is its latest launch — the same lookup shape configForVm uses.
|
|
387
|
+
* Powers the editor-starting grace window (server.js's deriveStatus): "was this
|
|
388
|
+
* unreachable VM launched recently enough to still be starting up?" Pure. */
|
|
389
|
+
function latestLaunchTs(history, n) {
|
|
390
|
+
const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
|
|
391
|
+
return h && typeof h.ts === "string" ? h.ts : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** True when ISO launch timestamp `ts` is within `graceMs` of `nowMs` — the
|
|
395
|
+
* editor-starting grace window. A null/unparseable `ts` (no launch on record) is
|
|
396
|
+
* never "recent", so a VM with no history never reads as starting. Half-open
|
|
397
|
+
* window: exactly at the boundary (`nowMs - ts === graceMs`) is NOT recent, so
|
|
398
|
+
* the calm-blue window has a crisp, testable end. Pure; caller owns `nowMs`. */
|
|
399
|
+
function recentlyLaunchedWithin(ts, nowMs, graceMs) {
|
|
400
|
+
if (typeof ts !== "string") return false;
|
|
401
|
+
const t = Date.parse(ts);
|
|
402
|
+
if (Number.isNaN(t)) return false;
|
|
403
|
+
return nowMs - t < graceMs;
|
|
404
|
+
}
|
|
405
|
+
|
|
255
406
|
/** Resolve the card's display identity, preferring LIVE truth (the editor's
|
|
256
407
|
* `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
|
|
257
408
|
* history. This is the fix for fragility #33's lying label: a CLI branch
|
|
@@ -321,6 +472,31 @@ function stickyClientProject(prev, info) {
|
|
|
321
472
|
return prev || null;
|
|
322
473
|
}
|
|
323
474
|
|
|
475
|
+
/** Resolve a VM's app dev-server port stickily: prefer the freshly-polled
|
|
476
|
+
* numeric port, else retain the last-known-good `prevAppPort`, so a transient
|
|
477
|
+
* null poll (timeout / partial probe) never blanks a port the VM has already
|
|
478
|
+
* reported. Mirrors `stickyClientProject` — a populated `prev` survives a
|
|
479
|
+
* null/absent fresh poll, a genuinely-changed fresh poll always wins
|
|
480
|
+
* (stickiness must NOT pin a stale port: a real 3000→4321 move is honored),
|
|
481
|
+
* and `null` results ONLY when neither a polled nor a previous port exists —
|
|
482
|
+
* the true first-provision case, where the caller applies its 5173 bootstrap
|
|
483
|
+
* fallback.
|
|
484
|
+
*
|
|
485
|
+
* Without this, `appPort` was `(info && info.appPort) || null`, so one
|
|
486
|
+
* timed-out poll blanked the resolved port; the tunnel-open fallback then
|
|
487
|
+
* re-seeded the hardcoded 5173 Vite default and the next poll saw a 5173→3000
|
|
488
|
+
* drift and re-wired — a churn loop that never converged. Pure; no I/O.
|
|
489
|
+
*
|
|
490
|
+
* @param prevAppPort the previous `state.vms[n].appPort` (null/undefined ok)
|
|
491
|
+
* @param info the fresh poll result object (null when the probe failed)
|
|
492
|
+
* @returns the app port to store: fresh numeric port when present, else the
|
|
493
|
+
* last-known-good `prevAppPort`, else null. */
|
|
494
|
+
function stickyAppPort(prevAppPort, info) {
|
|
495
|
+
const fresh = info && info.appPort;
|
|
496
|
+
if (typeof fresh === 'number' && !Number.isNaN(fresh)) return fresh;
|
|
497
|
+
return typeof prevAppPort === 'number' && !Number.isNaN(prevAppPort) ? prevAppPort : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
324
500
|
/** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
|
|
325
501
|
* identity reported in `/api/session-info` (`clientProject.repo`), with
|
|
326
502
|
* launch-history `clientRepo` as a fallback when live is absent. A guest is
|
|
@@ -479,6 +655,21 @@ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubT
|
|
|
479
655
|
return { mode: "none", donor: null };
|
|
480
656
|
}
|
|
481
657
|
|
|
658
|
+
/** True when the resolved creds `mode` (see `resolveCredsSource`) carries a real
|
|
659
|
+
* Claude-auth seed source, so the add path can seed it automatically and must
|
|
660
|
+
* NOT raise the `claude-auth-not-seeded` setup step:
|
|
661
|
+
* - "donor" — copies the donor's `.credentials.json` + `.claude.json`.
|
|
662
|
+
* - "host-stash" — pushes the operator's CLAUDE_CREDS_SRC `.credentials.json`.
|
|
663
|
+
* The token-only and none modes carry NO Claude credentials — a GITHUB_TOKEN
|
|
664
|
+
* seeds *git* auth at boot but not Claude, so those genuinely have nothing to
|
|
665
|
+
* seed Claude from and the add path raises `claude-auth-not-seeded` (rendered
|
|
666
|
+
* as the calm Plan-1 `auth` setup step with the one-click Login). Branching on
|
|
667
|
+
* this instead of unconditionally landing degraded is what keeps the reason from
|
|
668
|
+
* appearing when a donor/stash WAS available. Pure; no I/O. */
|
|
669
|
+
function claudeAuthSeeded(mode) {
|
|
670
|
+
return mode === "donor" || mode === "host-stash";
|
|
671
|
+
}
|
|
672
|
+
|
|
482
673
|
// Single-quote a string for safe inclusion in a remote shell command. Mirrors
|
|
483
674
|
// the server.js helper so callers don't have to thread one through.
|
|
484
675
|
function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
@@ -591,7 +782,111 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
|
|
|
591
782
|
return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
|
|
592
783
|
}
|
|
593
784
|
|
|
785
|
+
/** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
|
|
786
|
+
* EDITOR reachability forward (`editorPort` → VM:14199) as its OWN ssh process,
|
|
787
|
+
* logging to `/tmp/ssh-tunnel-vm-<n>.log`. The editor forward is the dashboard's
|
|
788
|
+
* reachability signal — the probe to `PORT_BASE+N` connects through it — so it
|
|
789
|
+
* must have a lifetime INDEPENDENT of the preview forward (whose port legitimately
|
|
790
|
+
* changes as the app's dev server is resolved). Splitting the two forwards into
|
|
791
|
+
* separate ssh processes is what makes a preview re-wire non-disruptive: a
|
|
792
|
+
* preview-port change restarts only the preview tunnel and never blips :14199, so
|
|
793
|
+
* the reachability probe can't flip the VM to a false "unreachable" (the
|
|
794
|
+
* VM-1/2/3 flapping observed 2026-06-22). Pairs with `previewTunnelCmd`; the two
|
|
795
|
+
* builders are the single source of truth for the attach (addVm) and re-wire call
|
|
796
|
+
* sites so neither forward's `-L` spec can drift back into the other's process.
|
|
797
|
+
* Pure; no I/O. */
|
|
798
|
+
function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) {
|
|
799
|
+
return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${editorPort}:127.0.0.1:14199 > /tmp/ssh-tunnel-vm-${n}.log 2>&1 &`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
|
|
803
|
+
* LIVE-PREVIEW forward (`previewPort` → VM:<appPort>) as its OWN ssh process,
|
|
804
|
+
* logging to `/tmp/ssh-preview-vm-<n>.log`. The preview forward's target port
|
|
805
|
+
* changes whenever the app's dev-server port is (re)resolved, so it lives apart
|
|
806
|
+
* from the editor reachability forward (`editorTunnelCmd`): a preview re-wire
|
|
807
|
+
* pkills + respawns ONLY this process, leaving the editor :14199 forward — and
|
|
808
|
+
* the reachability probe riding it — untouched. The preview local port is unique
|
|
809
|
+
* per VM and never collides with the editor port, so the re-wire/destroy
|
|
810
|
+
* `pkill -f "<previewPort>:127.0.0.1:"` targets exactly this process. Pure; no
|
|
811
|
+
* I/O. */
|
|
812
|
+
function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) {
|
|
813
|
+
return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${previewPort}:127.0.0.1:${appPort} > /tmp/ssh-preview-vm-${n}.log 2>&1 &`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
|
|
817
|
+
* container-healthy instead of reaping it mid-flight.
|
|
818
|
+
*
|
|
819
|
+
* History — two bugs, one helper: (1) vm-add-ingress-survives-bes-create: the Add
|
|
820
|
+
* used to `kill $UP_PID` ~15s after the container was Up, SIGTERMing
|
|
821
|
+
* `backend-services create` mid-flight (the 503 strand), so this began polling
|
|
822
|
+
* `cloud:up` to exit on its own rather than reaping it. (2)
|
|
823
|
+
* vm--async-add-time-lb-ingress-wiring: `cloud:up` no longer BLOCKS on the wiring
|
|
824
|
+
* tail — it kicks the EXTERNAL_MANAGED `backend-services create` + propagation off
|
|
825
|
+
* DETACHED (see cloud.js startDetachedAddWiring) and returns at container-healthy,
|
|
826
|
+
* so the detached child can no longer be reaped by anything that kills `cloud:up`.
|
|
827
|
+
* This poll therefore now resolves in ~seconds on the happy path (cloud:up has
|
|
828
|
+
* already exited); it stays only as the bounded backstop that reaps a `cloud:up`
|
|
829
|
+
* genuinely wedged BEFORE the early return (e.g. a stuck container gate). The
|
|
830
|
+
* background wiring converges via the dashboard's auto-rewire gather, so
|
|
831
|
+
* `deadlineSec` shrinks from the old 900s wiring budget to a container-readiness
|
|
832
|
+
* bound. `pidVar` is referenced by name so it expands at run time. Pure; no I/O. */
|
|
833
|
+
function awaitWiringCmd({ inst, deadlineSec = 180, pidVar = "UP_PID" } = {}) {
|
|
834
|
+
return [
|
|
835
|
+
`echo "awaiting cloud:up exit at container-healthy for ${inst} (up to ${deadlineSec}s; LB wiring converges in the background)..."`,
|
|
836
|
+
`__cy_wiring_waited=0`,
|
|
837
|
+
`while kill -0 $${pidVar} 2>/dev/null; do`,
|
|
838
|
+
` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`,
|
|
839
|
+
` echo "WARN cloud:up still running after ${deadlineSec}s (wedged before the container-healthy early return) — reaping it (the dashboard self-heal will converge any half-wired backend)"`,
|
|
840
|
+
` kill $${pidVar} 2>/dev/null; pkill -f "cloud.js up --instance-name ${inst}" 2>/dev/null; break`,
|
|
841
|
+
` fi`,
|
|
842
|
+
` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`,
|
|
843
|
+
`done`,
|
|
844
|
+
`wait $${pidVar} 2>/dev/null || true`,
|
|
845
|
+
].join("\n");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Get authenticated Git URL for checking client branch. */
|
|
849
|
+
function getAuthRepoUrl(repo, token) {
|
|
850
|
+
if (!repo) return "";
|
|
851
|
+
if (!token) return repo;
|
|
852
|
+
const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
|
|
853
|
+
const sshMatch = repo.match(sshRegex);
|
|
854
|
+
if (sshMatch) {
|
|
855
|
+
return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
|
|
856
|
+
}
|
|
857
|
+
if (repo.startsWith("https://github.com/")) {
|
|
858
|
+
return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
|
|
859
|
+
}
|
|
860
|
+
return repo;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Interpret the output of git ls-remote to see if a branch exists. */
|
|
864
|
+
function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
|
|
865
|
+
const b = (branch || "").trim();
|
|
866
|
+
if (!b) {
|
|
867
|
+
return { ok: true, msg: "using repo default branch" };
|
|
868
|
+
}
|
|
869
|
+
if (err || code !== 0) {
|
|
870
|
+
const errMsg = (err && (err.message || String(err))) || "command failed";
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
soft: true,
|
|
874
|
+
msg: `couldn't verify branch: ${errMsg.trim()}`
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
const trimmed = (stdout || "").trim();
|
|
878
|
+
if (trimmed) {
|
|
879
|
+
return { ok: true };
|
|
880
|
+
} else {
|
|
881
|
+
return {
|
|
882
|
+
ok: false,
|
|
883
|
+
msg: `branch '${b}' not found in ${repo || "repository"}`
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
594
888
|
module.exports = {
|
|
889
|
+
AGENTS,
|
|
595
890
|
EMPTY_CONFIG,
|
|
596
891
|
normalizeLaunchConfig,
|
|
597
892
|
validateLaunchConfig,
|
|
@@ -606,12 +901,17 @@ module.exports = {
|
|
|
606
901
|
queuedStatusForAction,
|
|
607
902
|
pushHistory,
|
|
608
903
|
distinctConfigs,
|
|
904
|
+
recentLaunchDisplay,
|
|
609
905
|
defaultConfig,
|
|
610
906
|
configForVm,
|
|
907
|
+
latestLaunchTs,
|
|
908
|
+
recentlyLaunchedWithin,
|
|
611
909
|
resolveCardIdentity,
|
|
612
910
|
stickyClientProject,
|
|
911
|
+
stickyAppPort,
|
|
613
912
|
classifyProjectKind,
|
|
614
913
|
resolveCredsSource,
|
|
914
|
+
claudeAuthSeeded,
|
|
615
915
|
buildCredsSteps,
|
|
616
916
|
buildClaudeJsonPreserveSteps,
|
|
617
917
|
buildEnvApplyCommand,
|
|
@@ -619,4 +919,10 @@ module.exports = {
|
|
|
619
919
|
editorSourceShellResolve,
|
|
620
920
|
rebuildStrategy,
|
|
621
921
|
decideStartJob,
|
|
922
|
+
editorTunnelCmd,
|
|
923
|
+
previewTunnelCmd,
|
|
924
|
+
awaitWiringCmd,
|
|
925
|
+
getAuthRepoUrl,
|
|
926
|
+
interpretLsRemote,
|
|
927
|
+
isPlaceholderProject,
|
|
622
928
|
};
|