@mycadet/cli 0.12.11 → 0.13.0-dev.202610171617.9ea363e
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 +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/package.json +8 -3
- 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-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 +2 -2
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/nginx-ingress.test.ts +536 -23
- 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 +6 -6
- 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 +3 -3
- package/src/__tests__/shell-mode.test.ts +83 -0
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel.test.ts +10 -2
- 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__/wake.test.ts +4 -1
- 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 +51 -17
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/pair.ts +2 -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 +48 -7
- package/src/commands/upgrade.ts +78 -156
- 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 +23 -18
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login.ts +2 -1
- 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 +59 -22
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +17 -1
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/ingress-config.ts +31 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +1 -1
- package/src/lib/local.ts +4 -2
- package/src/lib/nginx-ingress.ts +454 -79
- 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 +1 -0
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +79 -1
- 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
|
@@ -17,6 +17,8 @@ import { createServer } from "node:net";
|
|
|
17
17
|
import { networkInterfaces } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
20
22
|
import { cloudAssistantHubUrl } from "@mycadet/environments";
|
|
21
23
|
|
|
22
24
|
import {
|
|
@@ -28,17 +30,24 @@ import {
|
|
|
28
30
|
} from "./assistant-config.js";
|
|
29
31
|
import type { RemoteHost } from "./constants.js";
|
|
30
32
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
31
|
-
import { waitForDaemonReady } from "./http-client.js";
|
|
33
|
+
import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
|
|
32
34
|
import {
|
|
33
35
|
getDefaultWorkspaceDir,
|
|
34
36
|
isLocalContainerEntry,
|
|
35
37
|
loadRawConfig,
|
|
36
38
|
parseGatewayPortFromEntryUrls,
|
|
39
|
+
readShellMode,
|
|
37
40
|
saveRawConfig,
|
|
38
41
|
} from "./ingress-config.js";
|
|
39
|
-
import {
|
|
42
|
+
import { stripVersionPrefix } from "./version-compat.js";
|
|
43
|
+
import {
|
|
44
|
+
findAppDistDir,
|
|
45
|
+
findWebDistDir,
|
|
46
|
+
readWebReleaseVersion,
|
|
47
|
+
} from "./web-dist.js";
|
|
40
48
|
|
|
41
49
|
export { findWebDistDir } from "./web-dist.js";
|
|
50
|
+
export { findAppDistDir } from "./web-dist.js";
|
|
42
51
|
|
|
43
52
|
/**
|
|
44
53
|
* CLI-managed nginx reverse proxy that fronts the gateway as the canonical
|
|
@@ -93,8 +102,8 @@ function nginxDirPath(dir: string): string {
|
|
|
93
102
|
return dir.endsWith("/") ? dir : `${dir}/`;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
function gatewayProxyBlock(gatewayPort: number): string {
|
|
97
|
-
return ` proxy_pass http://127.0.0.1:${gatewayPort};
|
|
105
|
+
function gatewayProxyBlock(gatewayPort: number, upstreamPath = ""): string {
|
|
106
|
+
return ` proxy_pass http://127.0.0.1:${gatewayPort}${upstreamPath};
|
|
98
107
|
proxy_http_version 1.1;
|
|
99
108
|
proxy_request_buffering off;
|
|
100
109
|
proxy_buffering off;
|
|
@@ -107,40 +116,71 @@ function gatewayProxyBlock(gatewayPort: number): string {
|
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
/**
|
|
110
|
-
* Sensitive local-only
|
|
111
|
-
* regardless of
|
|
119
|
+
* Sensitive local-only gateway paths the edge must never expose to the
|
|
120
|
+
* internet, regardless of which client bundles are being served. Each exact
|
|
121
|
+
* path is blocked with and without its trailing slash.
|
|
122
|
+
*/
|
|
123
|
+
const SENSITIVE_GATEWAY_ROUTES = [
|
|
124
|
+
"/auth/token",
|
|
125
|
+
"/v1/pair",
|
|
126
|
+
"/v1/pair/web-init",
|
|
127
|
+
"/v1/devices",
|
|
128
|
+
"/v1/devices/revoke",
|
|
129
|
+
"/v1/guardian/init",
|
|
130
|
+
"/v1/guardian/reset-bootstrap",
|
|
131
|
+
"/v1/remote-web/pairing-requests",
|
|
132
|
+
"/v1/remote-web/pairing-requests/approve",
|
|
133
|
+
"/v1/remote-web/pairing-requests/deny",
|
|
134
|
+
"/v1/remote-web/pairing-verification",
|
|
135
|
+
"/v1/shared/invites/redeem",
|
|
136
|
+
] as const;
|
|
137
|
+
|
|
138
|
+
/** Sensitive path prefixes, blocked with a prefix match. */
|
|
139
|
+
const SENSITIVE_GATEWAY_PREFIXES = [
|
|
140
|
+
"/assistant/__local/",
|
|
141
|
+
"/assistant/__gateway/",
|
|
142
|
+
"/assistant/__gateway-paired/",
|
|
143
|
+
] as const;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Denylist locations for gateway paths under one public prefix. The edge
|
|
147
|
+
* serves from the origin root (web mode) and — in app mode — from the
|
|
148
|
+
* `/web` iframe prefix, where the web UI's prefix-aware API paths
|
|
149
|
+
* (`remoteGatewayApiPath`) put the same gateway routes at `/web/v1/...`;
|
|
150
|
+
* the sensitive ones must be blocked in both spellings.
|
|
112
151
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
location =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
location
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
location = /v1/guardian/reset-bootstrap/ { return 404; }
|
|
127
|
-
location = /v1/remote-web/pairing-requests { return 404; }
|
|
128
|
-
location = /v1/remote-web/pairing-requests/ { return 404; }
|
|
129
|
-
location = /v1/remote-web/pairing-requests/approve { return 404; }
|
|
130
|
-
location = /v1/remote-web/pairing-requests/approve/ { return 404; }
|
|
131
|
-
location = /v1/remote-web/pairing-requests/deny { return 404; }
|
|
132
|
-
location = /v1/remote-web/pairing-requests/deny/ { return 404; }
|
|
133
|
-
location = /v1/remote-web/pairing-verification { return 404; }
|
|
134
|
-
location = /v1/remote-web/pairing-verification/ { return 404; }
|
|
135
|
-
location = /v1/shared/invites/redeem { return 404; }
|
|
136
|
-
location = /v1/shared/invites/redeem/ { return 404; }
|
|
137
|
-
location ^~ /assistant/__local/ { return 404; }
|
|
138
|
-
location ^~ /assistant/__gateway/ { return 404; }
|
|
139
|
-
location ^~ /assistant/__gateway-paired/ { return 404; }`;
|
|
152
|
+
function denylistLocations(prefix = ""): string {
|
|
153
|
+
const exact = SENSITIVE_GATEWAY_ROUTES.map((route) => {
|
|
154
|
+
const block = (path: string) =>
|
|
155
|
+
` location = ${prefix}${path} { return 404; }`;
|
|
156
|
+
return `${block(route)}\n${block(`${route}/`)}`;
|
|
157
|
+
});
|
|
158
|
+
const prefixes = SENSITIVE_GATEWAY_PREFIXES.map((route) => {
|
|
159
|
+
return ` location ^~ ${prefix}${route} { return 404; }`;
|
|
160
|
+
});
|
|
161
|
+
return [...exact, ...prefixes].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DENYLIST_LOCATIONS = denylistLocations();
|
|
140
165
|
|
|
141
166
|
export interface RemoteWebIngressOptions {
|
|
142
167
|
webDistDir: string;
|
|
168
|
+
/**
|
|
169
|
+
* Standalone app-shell bundle. Present only in app shell mode: the edge
|
|
170
|
+
* serves this bundle as the outer account/client shell from the origin
|
|
171
|
+
* root and iframes the web SPA from /web/assistant/. Absent (web mode)
|
|
172
|
+
* keeps serving the web SPA directly from /assistant/.
|
|
173
|
+
*/
|
|
174
|
+
appDistDir?: string;
|
|
175
|
+
/**
|
|
176
|
+
* The config-injected app-shell document. Defaults to the app bundle's own
|
|
177
|
+
* index; the edge start path points this at the ingress dir, where the
|
|
178
|
+
* injected copy is written.
|
|
179
|
+
*/
|
|
180
|
+
appIndexHtmlPath?: string;
|
|
143
181
|
indexHtmlPath?: string;
|
|
182
|
+
/** Assistant release verified against the served UI artifact. */
|
|
183
|
+
version?: string;
|
|
144
184
|
config?: Record<string, unknown>;
|
|
145
185
|
/** Serving assistant's display name, stamped into the served config so
|
|
146
186
|
* remote clients can label this origin. Absent in older served configs. */
|
|
@@ -150,17 +190,18 @@ export interface RemoteWebIngressOptions {
|
|
|
150
190
|
* configs. */
|
|
151
191
|
assistantId?: string;
|
|
152
192
|
/** Cloud web SPA base the remote client can hand this origin to (see
|
|
153
|
-
* `
|
|
193
|
+
* `cloudAssistantHubUrl`). Absent in older served configs. */
|
|
154
194
|
hubUrl?: string;
|
|
155
195
|
}
|
|
156
196
|
|
|
157
197
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
198
|
+
* The web UI entry the assistant detail API hands the shell, relative to the
|
|
199
|
+
* origin root. Web mode serves the web SPA directly at /assistant/; app mode
|
|
200
|
+
* serves it from the distinct /web/assistant/ iframe namespace so the shell's
|
|
201
|
+
* own /assistant/* host routes can never load the shell recursively.
|
|
161
202
|
*/
|
|
162
|
-
export function
|
|
163
|
-
return
|
|
203
|
+
export function assistantEntryUrl(appDistDir: string | undefined): string {
|
|
204
|
+
return appDistDir ? "web/assistant/" : "assistant/";
|
|
164
205
|
}
|
|
165
206
|
|
|
166
207
|
// The edge serves remote browsers, so the bundle's remote-gateway machinery
|
|
@@ -208,17 +249,19 @@ function remoteWebIngressConfig(
|
|
|
208
249
|
* fingerprint matches, so this must change whenever the generated index or
|
|
209
250
|
* nginx template does.
|
|
210
251
|
*/
|
|
211
|
-
export const EDGE_TEMPLATE_VERSION =
|
|
252
|
+
export const EDGE_TEMPLATE_VERSION = 13;
|
|
212
253
|
|
|
213
254
|
/**
|
|
214
|
-
* Stable fingerprint of the injected SPA config, its source
|
|
215
|
-
* the template that renders them. Recorded
|
|
255
|
+
* Stable fingerprint of the injected SPA config, its source bundles, the
|
|
256
|
+
* shell mode, and the template that renders them. Recorded
|
|
216
257
|
* alongside the edge state so a reuse decision can tell whether a running edge
|
|
217
258
|
* already serves the requested config (see `IngressState.remoteWebConfigHash`).
|
|
218
259
|
*/
|
|
219
260
|
function remoteWebConfigFingerprint(
|
|
220
261
|
config: Record<string, unknown>,
|
|
221
262
|
webDistDir: string,
|
|
263
|
+
version?: string,
|
|
264
|
+
appDistDir?: string,
|
|
222
265
|
): string {
|
|
223
266
|
return createHash("sha256")
|
|
224
267
|
.update(
|
|
@@ -226,6 +269,20 @@ function remoteWebConfigFingerprint(
|
|
|
226
269
|
template: EDGE_TEMPLATE_VERSION,
|
|
227
270
|
config,
|
|
228
271
|
webDistDir,
|
|
272
|
+
version,
|
|
273
|
+
// The shell mode is part of edge identity: flipping the persisted
|
|
274
|
+
// shell mode must rotate a running edge onto the requested shell
|
|
275
|
+
// even though every other fingerprint input may be unchanged.
|
|
276
|
+
shellMode: appDistDir ? "app" : "web",
|
|
277
|
+
...(appDistDir
|
|
278
|
+
? {
|
|
279
|
+
appDistDir,
|
|
280
|
+
appIndexHtml: readFileSync(
|
|
281
|
+
join(appDistDir, "index.html"),
|
|
282
|
+
"utf-8",
|
|
283
|
+
),
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
229
286
|
indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
|
|
230
287
|
}),
|
|
231
288
|
)
|
|
@@ -345,9 +402,102 @@ export async function findAvailableIngressPort(
|
|
|
345
402
|
return null;
|
|
346
403
|
}
|
|
347
404
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
const AssistantHostConfigSchema = z.object({
|
|
406
|
+
assistantId: z.string().min(1).optional(),
|
|
407
|
+
assistantName: z.string().optional(),
|
|
408
|
+
version: z.string().min(1).optional(),
|
|
409
|
+
hubUrl: z.string().url().optional(),
|
|
410
|
+
appAllowedOrigins: z.array(z.string().url()).default([]),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
function assistantHostConfig(config: Record<string, unknown>) {
|
|
414
|
+
const result = AssistantHostConfigSchema.safeParse(config);
|
|
415
|
+
if (!result.success) {
|
|
416
|
+
throw new Error("Invalid assistant host configuration.");
|
|
417
|
+
}
|
|
418
|
+
return result.data;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function appOrigins(config: Record<string, unknown>): string[] {
|
|
422
|
+
const metadata = assistantHostConfig(config);
|
|
423
|
+
const origins = [
|
|
424
|
+
...metadata.appAllowedOrigins,
|
|
425
|
+
...(metadata.hubUrl ? [new URL(metadata.hubUrl).origin] : []),
|
|
426
|
+
];
|
|
427
|
+
return [...new Set(origins)].map((origin) => {
|
|
428
|
+
const url = new URL(origin);
|
|
429
|
+
if (
|
|
430
|
+
(url.protocol !== "https:" &&
|
|
431
|
+
!(
|
|
432
|
+
url.protocol === "http:" &&
|
|
433
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
434
|
+
)) ||
|
|
435
|
+
url.origin !== origin.replace(/\/$/, "")
|
|
436
|
+
) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
"App origins must be HTTPS origins or loopback HTTP origins.",
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return url.origin;
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function assistantMetadataLocations(config: Record<string, unknown>): string {
|
|
446
|
+
const metadata = assistantHostConfig(config);
|
|
447
|
+
const id = metadata.assistantId;
|
|
448
|
+
const entryUrl =
|
|
449
|
+
typeof config.entryUrl === "string" ? config.entryUrl : undefined;
|
|
450
|
+
const choice = id
|
|
451
|
+
? {
|
|
452
|
+
id,
|
|
453
|
+
...(metadata.assistantName ? { name: metadata.assistantName } : {}),
|
|
454
|
+
}
|
|
455
|
+
: undefined;
|
|
456
|
+
const responses = [
|
|
457
|
+
{
|
|
458
|
+
path: "/v1/assistants",
|
|
459
|
+
status: 200,
|
|
460
|
+
body: { results: choice ? [choice] : [], next: null },
|
|
461
|
+
},
|
|
462
|
+
...(choice && id
|
|
463
|
+
? [
|
|
464
|
+
{
|
|
465
|
+
path: `/v1/assistants/${encodeURIComponent(id)}`,
|
|
466
|
+
status: metadata.version ? 200 : 409,
|
|
467
|
+
body: metadata.version
|
|
468
|
+
? {
|
|
469
|
+
...choice,
|
|
470
|
+
status: "active",
|
|
471
|
+
version: metadata.version,
|
|
472
|
+
url: entryUrl ?? "assistant/",
|
|
473
|
+
}
|
|
474
|
+
: { detail: "assistant is not ready" },
|
|
475
|
+
},
|
|
476
|
+
]
|
|
477
|
+
: []),
|
|
478
|
+
];
|
|
479
|
+
return responses
|
|
480
|
+
.flatMap(({ path, status, body }) =>
|
|
481
|
+
[path, `${path}/`].map(
|
|
482
|
+
(route) => `
|
|
483
|
+
location = ${nginxQuoted(route, "assistant API path")} {
|
|
484
|
+
default_type application/json;
|
|
485
|
+
add_header Cache-Control "no-store" always;
|
|
486
|
+
add_header Access-Control-Allow-Origin $cadet_app_origin always;
|
|
487
|
+
add_header Access-Control-Allow-Credentials "true" always;
|
|
488
|
+
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
|
489
|
+
add_header Access-Control-Allow-Headers "Accept, Cadet-Organization-Id" always;
|
|
490
|
+
add_header Vary "Origin" always;
|
|
491
|
+
if ($request_method = OPTIONS) { return 204; }
|
|
492
|
+
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
|
493
|
+
return ${status} ${nginxQuoted(JSON.stringify(body), "assistant metadata")};
|
|
494
|
+
}`,
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
.join("\n");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Build the nginx config for the tunnel edge and its public assistant metadata. */
|
|
351
501
|
export function buildIngressNginxConfig(opts: {
|
|
352
502
|
gatewayPort: number;
|
|
353
503
|
listenPort: number;
|
|
@@ -363,12 +513,18 @@ export function buildIngressNginxConfig(opts: {
|
|
|
363
513
|
? ` listen [::1]:${opts.listenPort};\n`
|
|
364
514
|
: "";
|
|
365
515
|
const remoteWebIngress = opts.remoteWebIngress;
|
|
516
|
+
const allowedOrigins = remoteWebIngress
|
|
517
|
+
? appOrigins(remoteWebIngressConfig(remoteWebIngress))
|
|
518
|
+
: [];
|
|
366
519
|
const serverLocations = remoteWebIngress
|
|
367
520
|
? buildRemoteWebIngressLocations({
|
|
368
521
|
gatewayPort: opts.gatewayPort,
|
|
369
522
|
webDistDir: remoteWebIngress.webDistDir,
|
|
523
|
+
appDistDir: remoteWebIngress.appDistDir,
|
|
524
|
+
appIndexHtmlPath: remoteWebIngress.appIndexHtmlPath,
|
|
370
525
|
indexHtmlPath: remoteWebIngress.indexHtmlPath,
|
|
371
526
|
config: remoteWebIngressConfig(remoteWebIngress),
|
|
527
|
+
version: remoteWebIngress.version,
|
|
372
528
|
})
|
|
373
529
|
: `${DENYLIST_LOCATIONS}
|
|
374
530
|
|
|
@@ -417,6 +573,11 @@ http {
|
|
|
417
573
|
text/plain txt;
|
|
418
574
|
}
|
|
419
575
|
|
|
576
|
+
map $http_origin $cadet_app_origin {
|
|
577
|
+
default "";
|
|
578
|
+
${allowedOrigins.map((origin) => ` ${nginxQuoted(origin, "app origin")} ${nginxQuoted(origin, "app origin")};`).join("\n")}
|
|
579
|
+
}
|
|
580
|
+
|
|
420
581
|
map $http_upgrade $connection_upgrade {
|
|
421
582
|
default upgrade;
|
|
422
583
|
"" close;
|
|
@@ -452,22 +613,13 @@ ${serverLocations}
|
|
|
452
613
|
`;
|
|
453
614
|
}
|
|
454
615
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const webDistDir = nginxDirPath(opts.webDistDir);
|
|
463
|
-
const webAssetsDir = join(opts.webDistDir, "assets");
|
|
464
|
-
const indexHtmlPath =
|
|
465
|
-
opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
|
|
466
|
-
const configJson = JSON.stringify(opts.config);
|
|
467
|
-
|
|
468
|
-
return `${DENYLIST_LOCATIONS}
|
|
469
|
-
|
|
470
|
-
location = /healthz {
|
|
616
|
+
/**
|
|
617
|
+
* Gateway-facing locations shared by both shell modes: liveness probes and
|
|
618
|
+
* the runtime API surface, all proxied verbatim.
|
|
619
|
+
*/
|
|
620
|
+
function gatewayProxyLocations(gatewayPort: number): string {
|
|
621
|
+
const proxyBlock = gatewayProxyBlock(gatewayPort);
|
|
622
|
+
return ` location = /healthz {
|
|
471
623
|
${proxyBlock}
|
|
472
624
|
}
|
|
473
625
|
|
|
@@ -481,18 +633,34 @@ ${proxyBlock}
|
|
|
481
633
|
|
|
482
634
|
location ^~ /webhooks/ {
|
|
483
635
|
${proxyBlock}
|
|
484
|
-
}
|
|
636
|
+
}`;
|
|
637
|
+
}
|
|
485
638
|
|
|
486
|
-
|
|
487
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Serve the web SPA from one path prefix: the exact-mount rewrites, the
|
|
641
|
+
* injected index, the inline config, and the SPA fallback. Used for the
|
|
642
|
+
* /assistant/ mount in web mode and the /web/assistant/ iframe mount in
|
|
643
|
+
* app mode. `prefix` carries both a leading and trailing slash.
|
|
644
|
+
*/
|
|
645
|
+
function webSpaLocations(opts: {
|
|
646
|
+
prefix: string;
|
|
647
|
+
webDistDir: string;
|
|
648
|
+
indexHtmlPath: string;
|
|
649
|
+
config: Record<string, unknown>;
|
|
650
|
+
}): string {
|
|
651
|
+
const indexHtmlPath = opts.indexHtmlPath;
|
|
652
|
+
const configJson = JSON.stringify(opts.config);
|
|
653
|
+
const prefix = opts.prefix.replace(/\/+$/, "");
|
|
654
|
+
return ` location = ${prefix} {
|
|
655
|
+
return 302 ${prefix}/;
|
|
488
656
|
}
|
|
489
657
|
|
|
490
|
-
location = /
|
|
491
|
-
rewrite ^ /
|
|
658
|
+
location = ${prefix}/ {
|
|
659
|
+
rewrite ^ ${prefix}/__remote-index.html last;
|
|
492
660
|
}
|
|
493
661
|
|
|
494
|
-
location = /
|
|
495
|
-
rewrite ^ /
|
|
662
|
+
location = ${prefix}/index.html {
|
|
663
|
+
rewrite ^ ${prefix}/__remote-index.html last;
|
|
496
664
|
}
|
|
497
665
|
|
|
498
666
|
# The shell and the unhashed files beside it revalidate rather than
|
|
@@ -500,39 +668,173 @@ ${proxyBlock}
|
|
|
500
668
|
# disk with a validator, so a repeat load costs a 304 instead of the whole
|
|
501
669
|
# document. __config is the exception below: it is returned inline, so
|
|
502
670
|
# nginx attaches no validator for a revalidation to match against.
|
|
503
|
-
location = /
|
|
671
|
+
location = ${prefix}/__remote-index.html {
|
|
504
672
|
internal;
|
|
505
673
|
gzip on;
|
|
506
674
|
alias ${nginxQuoted(indexHtmlPath, "remote web ingress index path")};
|
|
507
675
|
add_header Cache-Control "no-cache";
|
|
508
676
|
}
|
|
509
677
|
|
|
510
|
-
location = /
|
|
678
|
+
location = ${prefix}/__config {
|
|
511
679
|
default_type application/json;
|
|
512
680
|
add_header Cache-Control "no-store";
|
|
513
681
|
return 200 ${nginxQuoted(configJson, "remote web ingress config")};
|
|
514
682
|
}
|
|
515
683
|
|
|
516
|
-
location ^~ /
|
|
684
|
+
location ^~ ${prefix}/ {
|
|
517
685
|
gzip on;
|
|
518
|
-
alias ${nginxQuoted(nginxDirPath(
|
|
686
|
+
alias ${nginxQuoted(nginxDirPath(opts.webDistDir), "web dist path")};
|
|
687
|
+
try_files $uri $uri/ ${prefix}/__remote-index.html;
|
|
688
|
+
add_header Cache-Control "no-cache";
|
|
689
|
+
}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The web bundle's hashed chunks, cached immutably under its dist root. */
|
|
693
|
+
function webAssetsLocations(webDistDir: string): string {
|
|
694
|
+
return ` location ^~ /assistant/assets/ {
|
|
695
|
+
gzip on;
|
|
696
|
+
alias ${nginxQuoted(
|
|
697
|
+
nginxDirPath(join(webDistDir, "assets")),
|
|
698
|
+
"web assets path",
|
|
699
|
+
)};
|
|
519
700
|
try_files $uri =404;
|
|
520
701
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
702
|
+
}`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function buildRemoteWebIngressLocations(opts: {
|
|
706
|
+
gatewayPort: number;
|
|
707
|
+
webDistDir: string;
|
|
708
|
+
appDistDir?: string;
|
|
709
|
+
appIndexHtmlPath?: string;
|
|
710
|
+
indexHtmlPath?: string;
|
|
711
|
+
config: Record<string, unknown>;
|
|
712
|
+
version?: string;
|
|
713
|
+
}): string {
|
|
714
|
+
const webDistDir = nginxDirPath(opts.webDistDir);
|
|
715
|
+
const indexHtmlPath =
|
|
716
|
+
opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
|
|
717
|
+
|
|
718
|
+
if (!opts.appDistDir) {
|
|
719
|
+
// Web mode: the web SPA IS the served surface, at /assistant/.
|
|
720
|
+
return `${DENYLIST_LOCATIONS}
|
|
721
|
+
|
|
722
|
+
${assistantMetadataLocations({ ...opts.config, version: opts.version })}
|
|
723
|
+
|
|
724
|
+
${gatewayProxyLocations(opts.gatewayPort)}
|
|
725
|
+
|
|
726
|
+
${webAssetsLocations(opts.webDistDir)}
|
|
727
|
+
|
|
728
|
+
${webSpaLocations({
|
|
729
|
+
prefix: "/assistant",
|
|
730
|
+
webDistDir: opts.webDistDir,
|
|
731
|
+
indexHtmlPath,
|
|
732
|
+
config: opts.config,
|
|
733
|
+
})}
|
|
734
|
+
|
|
735
|
+
location = / {
|
|
736
|
+
return 302 /assistant/;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
location / {
|
|
740
|
+
return 404;
|
|
741
|
+
}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// App mode: the app bundle is the outer shell (origin root, including the
|
|
745
|
+
// app's own /assistant/* host routes) and the web SPA is iframed from the
|
|
746
|
+
// distinct /web/assistant/ namespace, so neither surface can load the
|
|
747
|
+
// other recursively. The web bundle's asset URLs keep their baked
|
|
748
|
+
// /assistant/ base, so its hashed chunks and public files stay served
|
|
749
|
+
// under that prefix even though the document now mounts from /web/.
|
|
750
|
+
const appIndexHtmlPath =
|
|
751
|
+
opts.appIndexHtmlPath ?? join(opts.appDistDir, "index.html");
|
|
752
|
+
return `${denylistLocations()}
|
|
753
|
+
|
|
754
|
+
${denylistLocations("/web")}
|
|
755
|
+
|
|
756
|
+
${assistantMetadataLocations({
|
|
757
|
+
...opts.config,
|
|
758
|
+
version: opts.version,
|
|
759
|
+
entryUrl: assistantEntryUrl(opts.appDistDir),
|
|
760
|
+
})}
|
|
761
|
+
|
|
762
|
+
${gatewayProxyLocations(opts.gatewayPort)}
|
|
763
|
+
|
|
764
|
+
location ^~ /web/v1/ {
|
|
765
|
+
${gatewayProxyBlock(opts.gatewayPort, "/v1/")}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
${webSpaLocations({
|
|
769
|
+
prefix: "/web/assistant",
|
|
770
|
+
webDistDir: opts.webDistDir,
|
|
771
|
+
indexHtmlPath,
|
|
772
|
+
config: opts.config,
|
|
773
|
+
})}
|
|
774
|
+
|
|
775
|
+
location = /web {
|
|
776
|
+
return 302 /web/assistant/;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
# The iframe namespace has no other routes: anything under /web that is
|
|
780
|
+
# not the assistant mount or its prefixed API path is a probe, not a page.
|
|
781
|
+
location ^~ /web/ {
|
|
782
|
+
return 404;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
# The web bundle's hashed chunks and unhashed public files under its baked
|
|
786
|
+
# build base. Real files win; anything else falls through to the app
|
|
787
|
+
# shell, which owns the /assistant/* host routes.
|
|
788
|
+
location = /assistant {
|
|
789
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
location = /assistant/ {
|
|
793
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
521
794
|
}
|
|
522
795
|
|
|
796
|
+
${webAssetsLocations(opts.webDistDir)}
|
|
797
|
+
|
|
523
798
|
location ^~ /assistant/ {
|
|
524
799
|
gzip on;
|
|
525
800
|
alias ${nginxQuoted(webDistDir, "web dist path")};
|
|
526
|
-
try_files $uri
|
|
801
|
+
try_files $uri @cadet_app_shell;
|
|
527
802
|
add_header Cache-Control "no-cache";
|
|
528
803
|
}
|
|
529
804
|
|
|
805
|
+
# The app shell: its document is the config-injected standalone index, and
|
|
806
|
+
# every shell route (/, /account/*, /logout, and the /assistant/* host
|
|
807
|
+
# routes) resolves through the SPA fallback.
|
|
808
|
+
location = /__cadet-app-index.html {
|
|
809
|
+
internal;
|
|
810
|
+
gzip on;
|
|
811
|
+
alias ${nginxQuoted(appIndexHtmlPath, "app shell ingress index path")};
|
|
812
|
+
add_header Cache-Control "no-cache";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
location @cadet_app_shell {
|
|
816
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
817
|
+
}
|
|
818
|
+
|
|
530
819
|
location = / {
|
|
531
|
-
|
|
820
|
+
rewrite ^ /__cadet-app-index.html last;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
location ^~ /assets/ {
|
|
824
|
+
gzip on;
|
|
825
|
+
alias ${nginxQuoted(
|
|
826
|
+
nginxDirPath(join(opts.appDistDir, "assets")),
|
|
827
|
+
"app assets path",
|
|
828
|
+
)};
|
|
829
|
+
try_files $uri =404;
|
|
830
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
532
831
|
}
|
|
533
832
|
|
|
534
833
|
location / {
|
|
535
|
-
|
|
834
|
+
gzip on;
|
|
835
|
+
alias ${nginxQuoted(nginxDirPath(opts.appDistDir), "app dist path")};
|
|
836
|
+
try_files $uri @cadet_app_shell;
|
|
837
|
+
add_header Cache-Control "no-cache";
|
|
536
838
|
}`;
|
|
537
839
|
}
|
|
538
840
|
|
|
@@ -728,6 +1030,19 @@ export function startIngressNginx(opts: {
|
|
|
728
1030
|
remoteWebIngress.indexHtmlPath,
|
|
729
1031
|
buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
|
|
730
1032
|
);
|
|
1033
|
+
if (remoteWebIngress.appDistDir) {
|
|
1034
|
+
// The app shell gets its own injected document: same remote-web config
|
|
1035
|
+
// (its readers ignore the web-only apiBaseUrl key), written beside the
|
|
1036
|
+
// web one so the generated locations can alias it internally.
|
|
1037
|
+
const rawAppIndexHtml = readFileSync(
|
|
1038
|
+
join(remoteWebIngress.appDistDir, "index.html"),
|
|
1039
|
+
"utf-8",
|
|
1040
|
+
);
|
|
1041
|
+
writeFileSync(
|
|
1042
|
+
join(paths.dir, "app-index.html"),
|
|
1043
|
+
buildRemoteWebIndexHtml(rawAppIndexHtml, remoteWebIngress.config),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
731
1046
|
}
|
|
732
1047
|
writeFileSync(
|
|
733
1048
|
paths.confPath,
|
|
@@ -756,6 +1071,8 @@ export function startIngressNginx(opts: {
|
|
|
756
1071
|
remoteWebConfigHash: remoteWebConfigFingerprint(
|
|
757
1072
|
remoteWebIngress.config,
|
|
758
1073
|
remoteWebIngress.webDistDir,
|
|
1074
|
+
remoteWebIngress.version,
|
|
1075
|
+
remoteWebIngress.appDistDir,
|
|
759
1076
|
),
|
|
760
1077
|
}
|
|
761
1078
|
: {}),
|
|
@@ -868,6 +1185,7 @@ export type StartRemoteWebIngressResult =
|
|
|
868
1185
|
}
|
|
869
1186
|
| { status: "nginx-missing" }
|
|
870
1187
|
| { status: "web-dist-missing" }
|
|
1188
|
+
| { status: "app-dist-missing" }
|
|
871
1189
|
| { status: "unreachable"; listenPort: number; logPath: string }
|
|
872
1190
|
| { status: "port-conflict"; listenPort: number; logPath: string };
|
|
873
1191
|
|
|
@@ -900,6 +1218,13 @@ export async function startRemoteWebIngress(opts: {
|
|
|
900
1218
|
* sensitive-route denylist (webhooks-only mode).
|
|
901
1219
|
*/
|
|
902
1220
|
includeWebApp?: boolean;
|
|
1221
|
+
/**
|
|
1222
|
+
* Which client bundle is the outer shell (default "web"). "app" additionally
|
|
1223
|
+
* serves the standalone app bundle as the outer account/client shell and
|
|
1224
|
+
* hands the shell a /web/assistant/ iframe URL for the web UI; "web" keeps
|
|
1225
|
+
* serving the web SPA directly from /assistant/.
|
|
1226
|
+
*/
|
|
1227
|
+
shellMode?: "web" | "app";
|
|
903
1228
|
/**
|
|
904
1229
|
* Serving assistant's display name, stamped into the served remote-web
|
|
905
1230
|
* config (`__CADET_CONFIG__.assistantName`) so remote clients can label
|
|
@@ -927,6 +1252,7 @@ export async function startRemoteWebIngress(opts: {
|
|
|
927
1252
|
}): Promise<StartRemoteWebIngressResult> {
|
|
928
1253
|
let listenPort = opts.listenPort ?? getNginxIngressPort();
|
|
929
1254
|
const includeWebApp = opts.includeWebApp ?? true;
|
|
1255
|
+
const shellMode = opts.shellMode ?? "web";
|
|
930
1256
|
|
|
931
1257
|
const version = getNginxVersion();
|
|
932
1258
|
if (!version) {
|
|
@@ -947,7 +1273,7 @@ export async function startRemoteWebIngress(opts: {
|
|
|
947
1273
|
// reuse decision and the actual spawn share one value and cannot drift.
|
|
948
1274
|
const spaOptions = includeWebApp
|
|
949
1275
|
? {
|
|
950
|
-
hubUrl:
|
|
1276
|
+
hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
|
|
951
1277
|
...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
|
|
952
1278
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
953
1279
|
}
|
|
@@ -956,11 +1282,31 @@ export async function startRemoteWebIngress(opts: {
|
|
|
956
1282
|
if (includeWebApp && !webDistDir) {
|
|
957
1283
|
return { status: "web-dist-missing" };
|
|
958
1284
|
}
|
|
1285
|
+
const appDistDir =
|
|
1286
|
+
includeWebApp && shellMode === "app"
|
|
1287
|
+
? (findAppDistDir() ?? undefined)
|
|
1288
|
+
: undefined;
|
|
1289
|
+
if (includeWebApp && shellMode === "app" && !appDistDir) {
|
|
1290
|
+
return { status: "app-dist-missing" };
|
|
1291
|
+
}
|
|
1292
|
+
const runtimeVersion = webDistDir
|
|
1293
|
+
? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
|
|
1294
|
+
: undefined;
|
|
1295
|
+
const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
|
|
1296
|
+
const assistantVersion = runtimeVersion
|
|
1297
|
+
? stripVersionPrefix(runtimeVersion)
|
|
1298
|
+
: undefined;
|
|
1299
|
+
const matchedVersion =
|
|
1300
|
+
assistantVersion && webVersion === assistantVersion
|
|
1301
|
+
? assistantVersion
|
|
1302
|
+
: undefined;
|
|
959
1303
|
const requestedConfigHash =
|
|
960
1304
|
spaOptions && webDistDir
|
|
961
1305
|
? remoteWebConfigFingerprint(
|
|
962
1306
|
remoteWebIngressConfig(spaOptions),
|
|
963
1307
|
webDistDir,
|
|
1308
|
+
matchedVersion,
|
|
1309
|
+
appDistDir,
|
|
964
1310
|
)
|
|
965
1311
|
: undefined;
|
|
966
1312
|
// Both sides are undefined in webhooks-only mode; an SPA record without a
|
|
@@ -1024,7 +1370,14 @@ export async function startRemoteWebIngress(opts: {
|
|
|
1024
1370
|
workspaceDir: opts.workspaceDir,
|
|
1025
1371
|
gatewayPort: opts.gatewayPort,
|
|
1026
1372
|
listenPort,
|
|
1027
|
-
remoteWebIngress: webDistDir
|
|
1373
|
+
remoteWebIngress: webDistDir
|
|
1374
|
+
? {
|
|
1375
|
+
webDistDir,
|
|
1376
|
+
...spaOptions,
|
|
1377
|
+
version: matchedVersion,
|
|
1378
|
+
...(appDistDir ? { appDistDir } : {}),
|
|
1379
|
+
}
|
|
1380
|
+
: undefined,
|
|
1028
1381
|
});
|
|
1029
1382
|
let exited = false;
|
|
1030
1383
|
child.once("exit", () => {
|
|
@@ -1097,8 +1450,14 @@ function lockfileAssistantName(assistantId: string): string | undefined {
|
|
|
1097
1450
|
}
|
|
1098
1451
|
|
|
1099
1452
|
/** User-facing label for the edge mode, shared by every edge status line. */
|
|
1100
|
-
export function formatEdgeMode(
|
|
1101
|
-
|
|
1453
|
+
export function formatEdgeMode(
|
|
1454
|
+
includesWebApp: boolean,
|
|
1455
|
+
shellMode: "web" | "app" = "web",
|
|
1456
|
+
): string {
|
|
1457
|
+
if (!includesWebApp) return "webhooks only";
|
|
1458
|
+
return shellMode === "app"
|
|
1459
|
+
? "app shell + remote web + webhooks"
|
|
1460
|
+
: "remote web + webhooks";
|
|
1102
1461
|
}
|
|
1103
1462
|
|
|
1104
1463
|
/**
|
|
@@ -1153,6 +1512,8 @@ function stampAppProcessEntry(
|
|
|
1153
1512
|
/** True when this call started the edge, false when a running one was reused. */
|
|
1154
1513
|
started: boolean;
|
|
1155
1514
|
includesWebApp: boolean;
|
|
1515
|
+
/** The shell mode the edge serves (the persisted lockfile setting). */
|
|
1516
|
+
shellMode: "web" | "app";
|
|
1156
1517
|
}
|
|
1157
1518
|
|
|
1158
1519
|
/**
|
|
@@ -1199,10 +1560,15 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1199
1560
|
const assistantName = opts.assistantId
|
|
1200
1561
|
? lockfileAssistantName(opts.assistantId)
|
|
1201
1562
|
: undefined;
|
|
1563
|
+
// The persisted lockfile setting owns the shell choice; every edge caller
|
|
1564
|
+
// (tunnel, wake restore, upgrade restore) reads it through here so a mode
|
|
1565
|
+
// flip only needs `cadet tunnel --shell` once.
|
|
1566
|
+
const shellMode = readShellMode();
|
|
1202
1567
|
const result = await startRemoteWebIngress({
|
|
1203
1568
|
workspaceDir: opts.workspaceDir,
|
|
1204
1569
|
gatewayPort: opts.gatewayPort,
|
|
1205
1570
|
includeWebApp: true,
|
|
1571
|
+
shellMode,
|
|
1206
1572
|
...(assistantName ? { assistantName } : {}),
|
|
1207
1573
|
...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
|
|
1208
1574
|
...(opts.onStarting ? { onStarting: opts.onStarting } : {}),
|
|
@@ -1219,6 +1585,7 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1219
1585
|
port: result.listenPort,
|
|
1220
1586
|
started: true,
|
|
1221
1587
|
includesWebApp: true,
|
|
1588
|
+
shellMode,
|
|
1222
1589
|
};
|
|
1223
1590
|
case "already-running": {
|
|
1224
1591
|
// `already-running` also covers a drifted edge whose restart failed, so
|
|
@@ -1257,6 +1624,7 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1257
1624
|
port: result.listenPort,
|
|
1258
1625
|
started: false,
|
|
1259
1626
|
includesWebApp: result.includeWebApp,
|
|
1627
|
+
shellMode,
|
|
1260
1628
|
};
|
|
1261
1629
|
}
|
|
1262
1630
|
case "nginx-missing":
|
|
@@ -1271,6 +1639,13 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1271
1639
|
"Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
|
|
1272
1640
|
"or install @mycadet/web so its packaged dist directory is available.",
|
|
1273
1641
|
);
|
|
1642
|
+
case "app-dist-missing":
|
|
1643
|
+
throw new Error(
|
|
1644
|
+
"The app shell edge mode is selected but no " +
|
|
1645
|
+
"built app bundle is available. Build it with `cd clients/app && bun run build`, " +
|
|
1646
|
+
"or install @mycadet/app so its packaged standalone directory is available. " +
|
|
1647
|
+
"Switch back to the web shell with `cadet tunnel --shell web`.",
|
|
1648
|
+
);
|
|
1274
1649
|
case "unreachable":
|
|
1275
1650
|
throw new Error(
|
|
1276
1651
|
`nginx edge did not become reachable on 127.0.0.1:${result.listenPort}. ` +
|