@mycadet/cli 0.12.10 → 0.13.0
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/README.md +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +3 -2
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +233 -36
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +8 -7
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +17 -7
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +13 -3
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/tunnel.ts +43 -4
- package/src/commands/upgrade.ts +84 -157
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +20 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +254 -36
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/tunnel.ts +86 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/lib/xdg-log.ts +11 -0
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -13,9 +13,12 @@ import {
|
|
|
13
13
|
rmSync,
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
|
+
import { createServer } from "node:net";
|
|
16
17
|
import { networkInterfaces } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
19
22
|
import { cloudAssistantHubUrl } from "@mycadet/environments";
|
|
20
23
|
|
|
21
24
|
import {
|
|
@@ -27,7 +30,7 @@ import {
|
|
|
27
30
|
} from "./assistant-config.js";
|
|
28
31
|
import type { RemoteHost } from "./constants.js";
|
|
29
32
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
30
|
-
import { waitForDaemonReady } from "./http-client.js";
|
|
33
|
+
import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
|
|
31
34
|
import {
|
|
32
35
|
getDefaultWorkspaceDir,
|
|
33
36
|
isLocalContainerEntry,
|
|
@@ -35,7 +38,8 @@ import {
|
|
|
35
38
|
parseGatewayPortFromEntryUrls,
|
|
36
39
|
saveRawConfig,
|
|
37
40
|
} from "./ingress-config.js";
|
|
38
|
-
import {
|
|
41
|
+
import { stripVersionPrefix } from "./version-compat.js";
|
|
42
|
+
import { findWebDistDir, readWebReleaseVersion } from "./web-dist.js";
|
|
39
43
|
|
|
40
44
|
export { findWebDistDir } from "./web-dist.js";
|
|
41
45
|
|
|
@@ -140,6 +144,8 @@ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
|
|
|
140
144
|
export interface RemoteWebIngressOptions {
|
|
141
145
|
webDistDir: string;
|
|
142
146
|
indexHtmlPath?: string;
|
|
147
|
+
/** Assistant release verified against the served UI artifact. */
|
|
148
|
+
version?: string;
|
|
143
149
|
config?: Record<string, unknown>;
|
|
144
150
|
/** Serving assistant's display name, stamped into the served config so
|
|
145
151
|
* remote clients can label this origin. Absent in older served configs. */
|
|
@@ -149,24 +155,31 @@ export interface RemoteWebIngressOptions {
|
|
|
149
155
|
* configs. */
|
|
150
156
|
assistantId?: string;
|
|
151
157
|
/** Cloud web SPA base the remote client can hand this origin to (see
|
|
152
|
-
* `
|
|
158
|
+
* `cloudAssistantHubUrl`). Absent in older served configs. */
|
|
153
159
|
hubUrl?: string;
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
/**
|
|
157
|
-
* Cloud web SPA base URL for a build environment. The mapping lives in
|
|
158
|
-
* `@mycadet/environments` (`cloudAssistantHubUrl`) and is shared with the
|
|
159
|
-
* Capacitor shell's `server.url`, so the two consumers cannot drift.
|
|
160
|
-
*/
|
|
161
|
-
export function cloudWebHubUrl(env: string | undefined): string {
|
|
162
|
-
return cloudAssistantHubUrl(env);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
162
|
// The edge serves remote browsers, so the bundle's remote-gateway machinery
|
|
166
163
|
// must always run: it is reached through this self-hosted edge however the
|
|
167
164
|
// cadet itself is hosted. "cloud" is never stamped (the cloud serves its
|
|
168
165
|
// own bundles).
|
|
169
|
-
|
|
166
|
+
/** The lockfile's recorded remote kind for the assistant, if one is found. */
|
|
167
|
+
function lockfileAssistantRemote(assistantId: string): string | undefined {
|
|
168
|
+
const result = lookupAssistantByIdentifier(assistantId);
|
|
169
|
+
return result.status === "found" ? result.entry.cloud : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The remote kind stamped into the served SPA config. Docker and custom
|
|
174
|
+
* cadets are stamped truthfully; every other hosting shape — a local cadet
|
|
175
|
+
* reached through this edge, paired remotes, unknown entries — reads as
|
|
176
|
+
* "custom" so the served bundle always runs the remote-gateway machinery
|
|
177
|
+
* (this edge serves remote browsers however the cadet itself is hosted).
|
|
178
|
+
* "cloud" is never stamped: the cloud serves its own bundles.
|
|
179
|
+
*/
|
|
180
|
+
export function edgeServedRemote(recorded: string | undefined): RemoteHost {
|
|
181
|
+
return recorded === "docker" ? "docker" : "custom";
|
|
182
|
+
}
|
|
170
183
|
|
|
171
184
|
function remoteWebIngressConfig(
|
|
172
185
|
opts: Pick<
|
|
@@ -175,7 +188,9 @@ function remoteWebIngressConfig(
|
|
|
175
188
|
>,
|
|
176
189
|
): Record<string, unknown> {
|
|
177
190
|
return {
|
|
178
|
-
remote:
|
|
191
|
+
remote: edgeServedRemote(
|
|
192
|
+
opts.assistantId ? lockfileAssistantRemote(opts.assistantId) : undefined,
|
|
193
|
+
),
|
|
179
194
|
apiBaseUrl: "/v1",
|
|
180
195
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
181
196
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
@@ -189,17 +204,29 @@ function remoteWebIngressConfig(
|
|
|
189
204
|
* fingerprint matches, so this must change whenever the generated index or
|
|
190
205
|
* nginx template does.
|
|
191
206
|
*/
|
|
192
|
-
export const EDGE_TEMPLATE_VERSION =
|
|
207
|
+
export const EDGE_TEMPLATE_VERSION = 12;
|
|
193
208
|
|
|
194
209
|
/**
|
|
195
|
-
* Stable fingerprint of the SPA config
|
|
196
|
-
*
|
|
210
|
+
* Stable fingerprint of the injected SPA config, its source bundle, and
|
|
211
|
+
* the template that renders them. Recorded
|
|
197
212
|
* alongside the edge state so a reuse decision can tell whether a running edge
|
|
198
213
|
* already serves the requested config (see `IngressState.remoteWebConfigHash`).
|
|
199
214
|
*/
|
|
200
|
-
function remoteWebConfigFingerprint(
|
|
215
|
+
function remoteWebConfigFingerprint(
|
|
216
|
+
config: Record<string, unknown>,
|
|
217
|
+
webDistDir: string,
|
|
218
|
+
version?: string,
|
|
219
|
+
): string {
|
|
201
220
|
return createHash("sha256")
|
|
202
|
-
.update(
|
|
221
|
+
.update(
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
template: EDGE_TEMPLATE_VERSION,
|
|
224
|
+
config,
|
|
225
|
+
webDistDir,
|
|
226
|
+
version,
|
|
227
|
+
indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
203
230
|
.digest("hex");
|
|
204
231
|
}
|
|
205
232
|
|
|
@@ -278,9 +305,138 @@ export function hasIpv6Loopback(): boolean {
|
|
|
278
305
|
.some((addr) => addr.address === "::1");
|
|
279
306
|
}
|
|
280
307
|
|
|
308
|
+
/** Whether a TCP listener already holds `port` on the given loopback host. */
|
|
309
|
+
async function isPortInUse(
|
|
310
|
+
port: number,
|
|
311
|
+
host: "127.0.0.1" | "::1",
|
|
312
|
+
): Promise<boolean> {
|
|
313
|
+
return await new Promise((resolve) => {
|
|
314
|
+
const probe = createServer();
|
|
315
|
+
probe.once("error", () => resolve(true));
|
|
316
|
+
probe.once("listening", () => {
|
|
317
|
+
probe.close(() => resolve(false));
|
|
318
|
+
});
|
|
319
|
+
probe.listen(port, host);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
281
323
|
/**
|
|
282
|
-
*
|
|
324
|
+
* The first free loopback port at or above `startPort`, scanning a short band,
|
|
325
|
+
* or null when the whole band is busy. Both stacks the edge may bind are
|
|
326
|
+
* probed (the IPv4 loopback always, the IPv6 loopback when the host has one),
|
|
327
|
+
* so the returned port is one nginx can actually take.
|
|
283
328
|
*/
|
|
329
|
+
export async function findAvailableIngressPort(
|
|
330
|
+
startPort: number,
|
|
331
|
+
attempts = 20,
|
|
332
|
+
): Promise<number | null> {
|
|
333
|
+
const checkIpv6 = hasIpv6Loopback();
|
|
334
|
+
for (let port = startPort; port < startPort + attempts; port++) {
|
|
335
|
+
if (await isPortInUse(port, "127.0.0.1")) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (checkIpv6 && (await isPortInUse(port, "::1"))) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
return port;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const AssistantHostConfigSchema = z.object({
|
|
347
|
+
assistantId: z.string().min(1).optional(),
|
|
348
|
+
assistantName: z.string().optional(),
|
|
349
|
+
version: z.string().min(1).optional(),
|
|
350
|
+
hubUrl: z.string().url().optional(),
|
|
351
|
+
appAllowedOrigins: z.array(z.string().url()).default([]),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
function assistantHostConfig(config: Record<string, unknown>) {
|
|
355
|
+
const result = AssistantHostConfigSchema.safeParse(config);
|
|
356
|
+
if (!result.success) {
|
|
357
|
+
throw new Error("Invalid assistant host configuration.");
|
|
358
|
+
}
|
|
359
|
+
return result.data;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function appOrigins(config: Record<string, unknown>): string[] {
|
|
363
|
+
const metadata = assistantHostConfig(config);
|
|
364
|
+
const origins = [
|
|
365
|
+
...metadata.appAllowedOrigins,
|
|
366
|
+
...(metadata.hubUrl ? [new URL(metadata.hubUrl).origin] : []),
|
|
367
|
+
];
|
|
368
|
+
return [...new Set(origins)].map((origin) => {
|
|
369
|
+
const url = new URL(origin);
|
|
370
|
+
if (
|
|
371
|
+
(url.protocol !== "https:" &&
|
|
372
|
+
!(
|
|
373
|
+
url.protocol === "http:" &&
|
|
374
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
375
|
+
)) ||
|
|
376
|
+
url.origin !== origin.replace(/\/$/, "")
|
|
377
|
+
) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
"App origins must be HTTPS origins or loopback HTTP origins.",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return url.origin;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function assistantMetadataLocations(config: Record<string, unknown>): string {
|
|
387
|
+
const metadata = assistantHostConfig(config);
|
|
388
|
+
const id = metadata.assistantId;
|
|
389
|
+
const choice = id
|
|
390
|
+
? {
|
|
391
|
+
id,
|
|
392
|
+
...(metadata.assistantName ? { name: metadata.assistantName } : {}),
|
|
393
|
+
}
|
|
394
|
+
: undefined;
|
|
395
|
+
const responses = [
|
|
396
|
+
{
|
|
397
|
+
path: "/v1/assistants",
|
|
398
|
+
status: 200,
|
|
399
|
+
body: { results: choice ? [choice] : [], next: null },
|
|
400
|
+
},
|
|
401
|
+
...(choice && id
|
|
402
|
+
? [
|
|
403
|
+
{
|
|
404
|
+
path: `/v1/assistants/${encodeURIComponent(id)}`,
|
|
405
|
+
status: metadata.version ? 200 : 409,
|
|
406
|
+
body: metadata.version
|
|
407
|
+
? {
|
|
408
|
+
...choice,
|
|
409
|
+
status: "active",
|
|
410
|
+
version: metadata.version,
|
|
411
|
+
url: "assistant/",
|
|
412
|
+
}
|
|
413
|
+
: { detail: "assistant is not ready" },
|
|
414
|
+
},
|
|
415
|
+
]
|
|
416
|
+
: []),
|
|
417
|
+
];
|
|
418
|
+
return responses
|
|
419
|
+
.flatMap(({ path, status, body }) =>
|
|
420
|
+
[path, `${path}/`].map(
|
|
421
|
+
(route) => `
|
|
422
|
+
location = ${nginxQuoted(route, "assistant API path")} {
|
|
423
|
+
default_type application/json;
|
|
424
|
+
add_header Cache-Control "no-store" always;
|
|
425
|
+
add_header Access-Control-Allow-Origin $cadet_app_origin always;
|
|
426
|
+
add_header Access-Control-Allow-Credentials "true" always;
|
|
427
|
+
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
|
428
|
+
add_header Access-Control-Allow-Headers "Accept, Cadet-Organization-Id" always;
|
|
429
|
+
add_header Vary "Origin" always;
|
|
430
|
+
if ($request_method = OPTIONS) { return 204; }
|
|
431
|
+
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
|
432
|
+
return ${status} ${nginxQuoted(JSON.stringify(body), "assistant metadata")};
|
|
433
|
+
}`,
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
.join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Build the nginx config for the tunnel edge and its public assistant metadata. */
|
|
284
440
|
export function buildIngressNginxConfig(opts: {
|
|
285
441
|
gatewayPort: number;
|
|
286
442
|
listenPort: number;
|
|
@@ -296,12 +452,16 @@ export function buildIngressNginxConfig(opts: {
|
|
|
296
452
|
? ` listen [::1]:${opts.listenPort};\n`
|
|
297
453
|
: "";
|
|
298
454
|
const remoteWebIngress = opts.remoteWebIngress;
|
|
455
|
+
const allowedOrigins = remoteWebIngress
|
|
456
|
+
? appOrigins(remoteWebIngressConfig(remoteWebIngress))
|
|
457
|
+
: [];
|
|
299
458
|
const serverLocations = remoteWebIngress
|
|
300
459
|
? buildRemoteWebIngressLocations({
|
|
301
460
|
gatewayPort: opts.gatewayPort,
|
|
302
461
|
webDistDir: remoteWebIngress.webDistDir,
|
|
303
462
|
indexHtmlPath: remoteWebIngress.indexHtmlPath,
|
|
304
463
|
config: remoteWebIngressConfig(remoteWebIngress),
|
|
464
|
+
version: remoteWebIngress.version,
|
|
305
465
|
})
|
|
306
466
|
: `${DENYLIST_LOCATIONS}
|
|
307
467
|
|
|
@@ -350,6 +510,11 @@ http {
|
|
|
350
510
|
text/plain txt;
|
|
351
511
|
}
|
|
352
512
|
|
|
513
|
+
map $http_origin $cadet_app_origin {
|
|
514
|
+
default "";
|
|
515
|
+
${allowedOrigins.map((origin) => ` ${nginxQuoted(origin, "app origin")} ${nginxQuoted(origin, "app origin")};`).join("\n")}
|
|
516
|
+
}
|
|
517
|
+
|
|
353
518
|
map $http_upgrade $connection_upgrade {
|
|
354
519
|
default upgrade;
|
|
355
520
|
"" close;
|
|
@@ -390,6 +555,7 @@ function buildRemoteWebIngressLocations(opts: {
|
|
|
390
555
|
webDistDir: string;
|
|
391
556
|
indexHtmlPath?: string;
|
|
392
557
|
config: Record<string, unknown>;
|
|
558
|
+
version?: string;
|
|
393
559
|
}): string {
|
|
394
560
|
const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
|
|
395
561
|
const webDistDir = nginxDirPath(opts.webDistDir);
|
|
@@ -400,6 +566,8 @@ function buildRemoteWebIngressLocations(opts: {
|
|
|
400
566
|
|
|
401
567
|
return `${DENYLIST_LOCATIONS}
|
|
402
568
|
|
|
569
|
+
${assistantMetadataLocations({ ...opts.config, version: opts.version })}
|
|
570
|
+
|
|
403
571
|
location = /healthz {
|
|
404
572
|
${proxyBlock}
|
|
405
573
|
}
|
|
@@ -574,7 +742,7 @@ export interface IngressState {
|
|
|
574
742
|
*/
|
|
575
743
|
gatewayPort?: number;
|
|
576
744
|
/**
|
|
577
|
-
* Fingerprint of the SPA config
|
|
745
|
+
* Fingerprint of the injected SPA config and its source bundle. Undefined
|
|
578
746
|
* for webhooks-only edges and for SPA records that predate the field; an
|
|
579
747
|
* SPA edge without a recorded fingerprint is treated as drifted and
|
|
580
748
|
* restarted so the served index provably carries the requested config.
|
|
@@ -688,6 +856,8 @@ export function startIngressNginx(opts: {
|
|
|
688
856
|
? {
|
|
689
857
|
remoteWebConfigHash: remoteWebConfigFingerprint(
|
|
690
858
|
remoteWebIngress.config,
|
|
859
|
+
remoteWebIngress.webDistDir,
|
|
860
|
+
remoteWebIngress.version,
|
|
691
861
|
),
|
|
692
862
|
}
|
|
693
863
|
: {}),
|
|
@@ -857,7 +1027,7 @@ export async function startRemoteWebIngress(opts: {
|
|
|
857
1027
|
listenPort: number;
|
|
858
1028
|
}) => void;
|
|
859
1029
|
}): Promise<StartRemoteWebIngressResult> {
|
|
860
|
-
|
|
1030
|
+
let listenPort = opts.listenPort ?? getNginxIngressPort();
|
|
861
1031
|
const includeWebApp = opts.includeWebApp ?? true;
|
|
862
1032
|
|
|
863
1033
|
const version = getNginxVersion();
|
|
@@ -868,18 +1038,45 @@ export async function startRemoteWebIngress(opts: {
|
|
|
868
1038
|
const running = isIngressRunning(opts.workspaceDir);
|
|
869
1039
|
const recorded = running ? readIngressState(opts.workspaceDir) : null;
|
|
870
1040
|
const recordedMode = recorded?.includeWebApp ?? true;
|
|
1041
|
+
if (
|
|
1042
|
+
recorded &&
|
|
1043
|
+
opts.listenPort === undefined &&
|
|
1044
|
+
process.env.CADET_NGINX_INGRESS_PORT === undefined
|
|
1045
|
+
) {
|
|
1046
|
+
listenPort = recorded.listenPort;
|
|
1047
|
+
}
|
|
871
1048
|
// The SPA config the served index must carry, computed up front so the
|
|
872
1049
|
// reuse decision and the actual spawn share one value and cannot drift.
|
|
873
1050
|
const spaOptions = includeWebApp
|
|
874
1051
|
? {
|
|
875
|
-
hubUrl:
|
|
1052
|
+
hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
|
|
876
1053
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
877
1054
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
878
1055
|
}
|
|
879
1056
|
: undefined;
|
|
880
|
-
const
|
|
881
|
-
|
|
1057
|
+
const webDistDir = includeWebApp ? findWebDistDir() : null;
|
|
1058
|
+
if (includeWebApp && !webDistDir) {
|
|
1059
|
+
return { status: "web-dist-missing" };
|
|
1060
|
+
}
|
|
1061
|
+
const runtimeVersion = webDistDir
|
|
1062
|
+
? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
|
|
1063
|
+
: undefined;
|
|
1064
|
+
const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
|
|
1065
|
+
const assistantVersion = runtimeVersion
|
|
1066
|
+
? stripVersionPrefix(runtimeVersion)
|
|
882
1067
|
: undefined;
|
|
1068
|
+
const matchedVersion =
|
|
1069
|
+
assistantVersion && webVersion === assistantVersion
|
|
1070
|
+
? assistantVersion
|
|
1071
|
+
: undefined;
|
|
1072
|
+
const requestedConfigHash =
|
|
1073
|
+
spaOptions && webDistDir
|
|
1074
|
+
? remoteWebConfigFingerprint(
|
|
1075
|
+
remoteWebIngressConfig(spaOptions),
|
|
1076
|
+
webDistDir,
|
|
1077
|
+
matchedVersion,
|
|
1078
|
+
)
|
|
1079
|
+
: undefined;
|
|
883
1080
|
// Both sides are undefined in webhooks-only mode; an SPA record without a
|
|
884
1081
|
// fingerprint predates the field and counts as drifted (see IngressState).
|
|
885
1082
|
const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
|
|
@@ -906,14 +1103,6 @@ export async function startRemoteWebIngress(opts: {
|
|
|
906
1103
|
return alreadyRunning();
|
|
907
1104
|
}
|
|
908
1105
|
|
|
909
|
-
let webDistDir: string | null = null;
|
|
910
|
-
if (includeWebApp) {
|
|
911
|
-
webDistDir = findWebDistDir();
|
|
912
|
-
if (!webDistDir) {
|
|
913
|
-
return { status: "web-dist-missing" };
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
1106
|
// The running edge serves the other mode, targets a different gateway
|
|
918
1107
|
// port, or injects a stale SPA config; restart it with the requested
|
|
919
1108
|
// config. A false stop can mean the old edge exited on its own after the
|
|
@@ -926,13 +1115,32 @@ export async function startRemoteWebIngress(opts: {
|
|
|
926
1115
|
return alreadyRunning();
|
|
927
1116
|
}
|
|
928
1117
|
|
|
1118
|
+
// A foreign process squatting the default edge port (another assistant's
|
|
1119
|
+
// edge, a stale dev server) must not kill the tunnel: the edge port is
|
|
1120
|
+
// loopback-local and the public tunnel URL never carries it, so moving to
|
|
1121
|
+
// the next free port is invisible to remote clients. Explicit pins
|
|
1122
|
+
// (opts.listenPort / CADET_NGINX_INGRESS_PORT) keep their intent and still
|
|
1123
|
+
// fail on a collision.
|
|
1124
|
+
if (
|
|
1125
|
+
opts.listenPort === undefined &&
|
|
1126
|
+
process.env.CADET_NGINX_INGRESS_PORT === undefined
|
|
1127
|
+
) {
|
|
1128
|
+
const discovered = await findAvailableIngressPort(listenPort);
|
|
1129
|
+
if (discovered !== null && discovered !== listenPort) {
|
|
1130
|
+
console.log(`Port ${listenPort} in use; using ${discovered} instead.`);
|
|
1131
|
+
listenPort = discovered;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
929
1135
|
opts.onStarting?.({ version, webDistDir, listenPort });
|
|
930
1136
|
|
|
931
1137
|
const child = startIngressNginx({
|
|
932
1138
|
workspaceDir: opts.workspaceDir,
|
|
933
1139
|
gatewayPort: opts.gatewayPort,
|
|
934
1140
|
listenPort,
|
|
935
|
-
remoteWebIngress: webDistDir
|
|
1141
|
+
remoteWebIngress: webDistDir
|
|
1142
|
+
? { webDistDir, ...spaOptions, version: matchedVersion }
|
|
1143
|
+
: undefined,
|
|
936
1144
|
});
|
|
937
1145
|
let exited = false;
|
|
938
1146
|
child.once("exit", () => {
|
|
@@ -1042,6 +1250,7 @@ export async function stopContainerTunnelEdge(
|
|
|
1042
1250
|
function stampAppProcessEntry(
|
|
1043
1251
|
workspaceDir: string,
|
|
1044
1252
|
assistantId?: string,
|
|
1253
|
+
port = getNginxIngressPort(),
|
|
1045
1254
|
): void {
|
|
1046
1255
|
const pid = getIngressPid(workspaceDir);
|
|
1047
1256
|
if (pid !== null) {
|
|
@@ -1049,6 +1258,7 @@ function stampAppProcessEntry(
|
|
|
1049
1258
|
pid,
|
|
1050
1259
|
startedAt: new Date().toISOString(),
|
|
1051
1260
|
assistantId,
|
|
1261
|
+
port,
|
|
1052
1262
|
});
|
|
1053
1263
|
}
|
|
1054
1264
|
}
|
|
@@ -1116,7 +1326,11 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1116
1326
|
|
|
1117
1327
|
switch (result.status) {
|
|
1118
1328
|
case "started":
|
|
1119
|
-
stampAppProcessEntry(
|
|
1329
|
+
stampAppProcessEntry(
|
|
1330
|
+
opts.workspaceDir,
|
|
1331
|
+
opts.assistantId,
|
|
1332
|
+
result.listenPort,
|
|
1333
|
+
);
|
|
1120
1334
|
return {
|
|
1121
1335
|
port: result.listenPort,
|
|
1122
1336
|
started: true,
|
|
@@ -1150,7 +1364,11 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1150
1364
|
stuckEdgeRecoveryHint(opts.workspaceDir),
|
|
1151
1365
|
);
|
|
1152
1366
|
}
|
|
1153
|
-
stampAppProcessEntry(
|
|
1367
|
+
stampAppProcessEntry(
|
|
1368
|
+
opts.workspaceDir,
|
|
1369
|
+
opts.assistantId,
|
|
1370
|
+
result.listenPort,
|
|
1371
|
+
);
|
|
1154
1372
|
return {
|
|
1155
1373
|
port: result.listenPort,
|
|
1156
1374
|
started: false,
|
package/src/lib/process.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAEMON_STOP_TIMEOUT_MS } from "@mycadet/local-mode";
|
|
1
2
|
import { execFileSync } from "child_process";
|
|
2
3
|
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
3
4
|
import { platform } from "os";
|
|
@@ -324,6 +325,7 @@ export async function stopProcess(
|
|
|
324
325
|
});
|
|
325
326
|
},
|
|
326
327
|
readIdentity: (pid: number) => string | null = readPosixCommandLine,
|
|
328
|
+
gracefulOnly = false,
|
|
327
329
|
): Promise<boolean> {
|
|
328
330
|
try {
|
|
329
331
|
process.kill(pid, 0);
|
|
@@ -339,6 +341,9 @@ export async function stopProcess(
|
|
|
339
341
|
console.log(`Stopping ${label} (pid ${pid})...`);
|
|
340
342
|
let waitForGracefulExit = true;
|
|
341
343
|
if (hostPlatform === "win32") {
|
|
344
|
+
if (gracefulOnly) {
|
|
345
|
+
throw new Error("Graceful-only shutdown is not supported for Windows local services.");
|
|
346
|
+
}
|
|
342
347
|
try {
|
|
343
348
|
runTaskkill(["/PID", String(pid), "/T"], timeoutMs);
|
|
344
349
|
} catch {
|
|
@@ -363,6 +368,7 @@ export async function stopProcess(
|
|
|
363
368
|
} catch {
|
|
364
369
|
return true;
|
|
365
370
|
}
|
|
371
|
+
if (gracefulOnly) return false;
|
|
366
372
|
if (hostPlatform === "win32") {
|
|
367
373
|
console.log(`${label} did not exit, terminating its process tree...`);
|
|
368
374
|
try {
|
|
@@ -415,6 +421,13 @@ export async function stopProcess(
|
|
|
415
421
|
}
|
|
416
422
|
}
|
|
417
423
|
|
|
424
|
+
/** Stop a service without escalation. Failure preserves PID tracking and aborts capture/replacement. */
|
|
425
|
+
export async function stopProcessGracefully(pid: number, label: string, timeoutMs = DAEMON_STOP_TIMEOUT_MS): Promise<boolean> {
|
|
426
|
+
const stopped = await stopProcess(pid, label, timeoutMs, undefined, undefined, undefined, true);
|
|
427
|
+
if (!stopped) throw new Error(`${label} did not stop gracefully.`);
|
|
428
|
+
return stopped;
|
|
429
|
+
}
|
|
430
|
+
|
|
418
431
|
/** Remove one or more files, ignoring missing-file errors. */
|
|
419
432
|
function removeFiles(...files: (string | string[] | undefined)[]): void {
|
|
420
433
|
for (const entry of files) {
|