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