@mycadet/cli 0.12.6-dev.202610091338.ee7f3e3 → 0.12.7
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/package.json +4 -3
- package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +66 -74
- package/src/__tests__/pair.test.ts +135 -121
- package/src/__tests__/uninstall.test.ts +171 -0
- package/src/commands/client.ts +76 -34
- package/src/commands/ps.ts +1 -0
- package/src/commands/uninstall.ts +228 -0
- package/src/index.ts +2 -0
- package/src/lib/assistant-config.ts +9 -13
- package/src/lib/backup-ops.ts +3 -4
- package/src/lib/client-identity.ts +8 -10
- package/src/lib/guardian-token.ts +72 -18
- package/src/lib/health-check.ts +24 -15
- package/src/lib/local-hatch-lock.ts +19 -27
- package/src/lib/local-runtime-client.ts +25 -14
- package/src/lib/ngrok.ts +48 -17
- package/src/lib/platform-client.ts +141 -72
- package/src/lib/retire-archive.ts +4 -4
- package/src/lib/retire-local.ts +2 -25
- package/src/lib/teleport-backup.ts +22 -13
- package/src/lib/terminal-client.ts +19 -12
- package/src/lib/tunnel-edge.ts +11 -13
- package/src/lib/upgrade-lifecycle.ts +16 -4
- package/src/lib/workos-pkce.ts +51 -25
- package/src/lib/json.ts +0 -6
|
@@ -93,10 +93,10 @@ export interface ContainerInfo {
|
|
|
93
93
|
* validation boundary — the same toolchain writes and reads them — so they are
|
|
94
94
|
* modeled as plain types here rather than re-validated.
|
|
95
95
|
*
|
|
96
|
-
* `runtimeUrl` is required (`
|
|
96
|
+
* `runtimeUrl` is required (`loadAllAssistants` only returns entries that have one)
|
|
97
97
|
* and `resources` is the richer host shape. `cloud` is inherited from
|
|
98
98
|
* `LockfileAssistant` (always present; normalized at the read seam — see
|
|
99
|
-
* `
|
|
99
|
+
* `loadAllAssistants`). The index signature preserves unknown on-disk fields so a
|
|
100
100
|
* read→write round-trip never drops a newer writer's data.
|
|
101
101
|
*/
|
|
102
102
|
export type AssistantEntry = Omit<
|
|
@@ -330,7 +330,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
330
330
|
return mutated;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
function
|
|
333
|
+
export function loadAllAssistants(): AssistantEntry[] {
|
|
334
334
|
const data = readLockfile();
|
|
335
335
|
const entries = data.assistants;
|
|
336
336
|
if (!Array.isArray(entries)) {
|
|
@@ -384,7 +384,7 @@ function writeAssistants(entries: AssistantEntry[]): void {
|
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
export function loadLatestAssistant(): AssistantEntry | null {
|
|
387
|
-
const entries =
|
|
387
|
+
const entries = loadAllAssistants();
|
|
388
388
|
if (entries.length === 0) {
|
|
389
389
|
return null;
|
|
390
390
|
}
|
|
@@ -397,7 +397,7 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
400
|
-
return
|
|
400
|
+
return loadAllAssistants().find((entry) => entry.assistantId === name) ?? null;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
403
|
export function getAssistantDisplayName(entry: AssistantEntry): string {
|
|
@@ -430,7 +430,7 @@ function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
|
|
|
430
430
|
export function lookupAssistantByIdentifier(
|
|
431
431
|
identifier: string,
|
|
432
432
|
): AssistantLookupResult {
|
|
433
|
-
const entries =
|
|
433
|
+
const entries = loadAllAssistants();
|
|
434
434
|
const exactId = entries.find((entry) => entry.assistantId === identifier);
|
|
435
435
|
if (exactId) {
|
|
436
436
|
return { status: "found", entry: exactId };
|
|
@@ -483,10 +483,6 @@ export function removeAssistantEntry(assistantId: string): void {
|
|
|
483
483
|
});
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
-
export function loadAllAssistants(): AssistantEntry[] {
|
|
487
|
-
return readAssistants();
|
|
488
|
-
}
|
|
489
|
-
|
|
490
486
|
/**
|
|
491
487
|
* Read the first existing lockfile for an explicitly-provided environment,
|
|
492
488
|
* without applying legacy migrations. This is the cross-env read path used by
|
|
@@ -585,7 +581,7 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
|
|
|
585
581
|
// Active assistant no longer exists in lockfile — fall through
|
|
586
582
|
}
|
|
587
583
|
|
|
588
|
-
const all =
|
|
584
|
+
const all = loadAllAssistants();
|
|
589
585
|
if (all.length === 1) return all[0];
|
|
590
586
|
|
|
591
587
|
return null;
|
|
@@ -617,7 +613,7 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
|
617
613
|
// Active assistant no longer exists in lockfile — fall through.
|
|
618
614
|
}
|
|
619
615
|
|
|
620
|
-
const all =
|
|
616
|
+
const all = loadAllAssistants();
|
|
621
617
|
if (all.length === 1) return all[0];
|
|
622
618
|
|
|
623
619
|
if (all.length === 0) {
|
|
@@ -648,7 +644,7 @@ export function extractHostFromUrl(url: string): string {
|
|
|
648
644
|
|
|
649
645
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
650
646
|
withCliLockfileLock(() => {
|
|
651
|
-
const entries =
|
|
647
|
+
const entries = loadAllAssistants().filter(
|
|
652
648
|
(e) => e.assistantId !== entry.assistantId,
|
|
653
649
|
);
|
|
654
650
|
entries.unshift(entry);
|
package/src/lib/backup-ops.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
unlinkSync,
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "fs";
|
|
10
|
-
import { homedir } from "os";
|
|
11
10
|
import { dirname, join } from "path";
|
|
12
11
|
|
|
13
12
|
import {
|
|
@@ -17,12 +16,12 @@ import {
|
|
|
17
16
|
} from "./bundle-staging.js";
|
|
18
17
|
import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
|
|
19
18
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
19
|
+
import { getStateDir } from "./environments/paths.js";
|
|
20
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
20
21
|
|
|
21
22
|
/** Default backup directory following XDG convention */
|
|
22
23
|
export function getBackupsDir(): string {
|
|
23
|
-
|
|
24
|
-
process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
|
|
25
|
-
return join(dataHome, "cadet", "backups");
|
|
24
|
+
return join(getStateDir(getCurrentEnvironment()), "backups");
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
/** Human-readable file size */
|
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stable per-install client identity for the CLI.
|
|
3
3
|
*
|
|
4
|
-
* Generates a UUID on first use and persists it to
|
|
5
|
-
*
|
|
4
|
+
* Generates a UUID on first use and persists it to the environment's
|
|
5
|
+
* config dir (`client-id`) so the daemon's event hub can
|
|
6
6
|
* track this terminal across SSE reconnects and CLI restarts.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
10
|
import { randomUUID } from "crypto";
|
|
11
|
-
import { homedir } from "os";
|
|
12
11
|
import { join } from "path";
|
|
13
12
|
|
|
13
|
+
import { getConfigDir } from "./environments/paths.js";
|
|
14
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
15
|
+
|
|
14
16
|
export const CLI_INTERFACE_ID = "cli";
|
|
15
17
|
export const WEB_INTERFACE_ID = "web";
|
|
16
18
|
|
|
17
19
|
let cached: string | null = null;
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
21
|
-
return join(configHome, "cadet");
|
|
22
|
-
}
|
|
21
|
+
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Returns a stable UUID identifying this CLI installation.
|
|
@@ -28,8 +27,7 @@ function getConfigDir(): string {
|
|
|
28
27
|
export function getClientId(): string {
|
|
29
28
|
if (cached) return cached;
|
|
30
29
|
|
|
31
|
-
const
|
|
32
|
-
const idFile = join(configDir, "client-id");
|
|
30
|
+
const idFile = join(getConfigDir(getCurrentEnvironment()), "client-id");
|
|
33
31
|
|
|
34
32
|
try {
|
|
35
33
|
if (existsSync(idFile)) {
|
|
@@ -45,7 +43,7 @@ export function getClientId(): string {
|
|
|
45
43
|
|
|
46
44
|
const id = randomUUID();
|
|
47
45
|
try {
|
|
48
|
-
mkdirSync(
|
|
46
|
+
mkdirSync(getConfigDir(getCurrentEnvironment()), { recursive: true });
|
|
49
47
|
writeFileSync(idFile, id, "utf-8");
|
|
50
48
|
} catch {
|
|
51
49
|
/* best-effort persist — transient id still works for this session */
|
|
@@ -28,6 +28,39 @@ import {
|
|
|
28
28
|
import { getConfigDir, getConfigDirs } from "./environments/paths.js";
|
|
29
29
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
30
30
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The guardian token fields as they arrive untrusted (gateway refresh/init
|
|
35
|
+
* responses, persisted token files). Every field is independently optional
|
|
36
|
+
* and independently caught: one malformed field must not invalidate the
|
|
37
|
+
* others — callers fall back per field.
|
|
38
|
+
*/
|
|
39
|
+
const GuardianTokenFieldsSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
guardianPrincipalId: z.string().optional().catch(undefined),
|
|
42
|
+
accessToken: z.string().optional().catch(undefined),
|
|
43
|
+
accessTokenExpiresAt: z
|
|
44
|
+
.union([z.string(), z.number()])
|
|
45
|
+
.optional()
|
|
46
|
+
.catch(undefined),
|
|
47
|
+
refreshToken: z.string().optional().catch(undefined),
|
|
48
|
+
refreshTokenExpiresAt: z
|
|
49
|
+
.union([z.string(), z.number()])
|
|
50
|
+
.optional()
|
|
51
|
+
.catch(undefined),
|
|
52
|
+
refreshAfter: z.string().optional().catch(undefined),
|
|
53
|
+
isNew: z.boolean().optional().catch(undefined),
|
|
54
|
+
deviceId: z.string().optional().catch(undefined),
|
|
55
|
+
})
|
|
56
|
+
.catch({});
|
|
57
|
+
|
|
58
|
+
/** Narrow untrusted JSON to the token fields whose values have the right
|
|
59
|
+
* shape; fields absent or malformed come back undefined for callers to
|
|
60
|
+
* fall back on or reject. */
|
|
61
|
+
function parseGuardianTokenFields(value: unknown): Partial<GuardianTokenData> {
|
|
62
|
+
return GuardianTokenFieldsSchema.parse(value);
|
|
63
|
+
}
|
|
31
64
|
|
|
32
65
|
const DEVICE_ID_SALT = "mycadet-host-id";
|
|
33
66
|
|
|
@@ -470,19 +503,24 @@ export async function refreshGuardianTokenResult(
|
|
|
470
503
|
return classifyRefreshHttpStatus(response.status);
|
|
471
504
|
}
|
|
472
505
|
|
|
473
|
-
const
|
|
506
|
+
const parsed = parseGuardianTokenFields(await response.json());
|
|
507
|
+
if (parsed.accessToken == null) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
status: 502,
|
|
511
|
+
error: "Assistant gateway returned a malformed token refresh response",
|
|
512
|
+
};
|
|
513
|
+
}
|
|
474
514
|
const refreshed: GuardianTokenData = {
|
|
475
515
|
guardianPrincipalId:
|
|
476
|
-
|
|
477
|
-
accessToken:
|
|
516
|
+
parsed.guardianPrincipalId ?? tokenData.guardianPrincipalId,
|
|
517
|
+
accessToken: parsed.accessToken,
|
|
478
518
|
accessTokenExpiresAt:
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
|
|
519
|
+
parsed.accessTokenExpiresAt ?? tokenData.accessTokenExpiresAt,
|
|
520
|
+
refreshToken: parsed.refreshToken ?? tokenData.refreshToken,
|
|
482
521
|
refreshTokenExpiresAt:
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
|
|
522
|
+
parsed.refreshTokenExpiresAt ?? tokenData.refreshTokenExpiresAt,
|
|
523
|
+
refreshAfter: parsed.refreshAfter ?? tokenData.refreshAfter,
|
|
486
524
|
isNew: false,
|
|
487
525
|
deviceId: tokenData.deviceId,
|
|
488
526
|
leasedAt: new Date().toISOString(),
|
|
@@ -583,15 +621,28 @@ export async function leaseGuardianToken(
|
|
|
583
621
|
throw new Error(`guardian/init failed (${response.status}): ${body}`);
|
|
584
622
|
}
|
|
585
623
|
|
|
586
|
-
const
|
|
624
|
+
const parsed = parseGuardianTokenFields(await response.json());
|
|
625
|
+
if (
|
|
626
|
+
parsed.guardianPrincipalId == null ||
|
|
627
|
+
parsed.accessToken == null ||
|
|
628
|
+
parsed.accessTokenExpiresAt == null ||
|
|
629
|
+
parsed.refreshToken == null ||
|
|
630
|
+
parsed.refreshTokenExpiresAt == null ||
|
|
631
|
+
parsed.refreshAfter == null ||
|
|
632
|
+
parsed.isNew == null
|
|
633
|
+
) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`guardian/init returned a malformed token response (${response.status})`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
587
638
|
const tokenData: GuardianTokenData = {
|
|
588
|
-
guardianPrincipalId:
|
|
589
|
-
accessToken:
|
|
590
|
-
accessTokenExpiresAt:
|
|
591
|
-
refreshToken:
|
|
592
|
-
refreshTokenExpiresAt:
|
|
593
|
-
refreshAfter:
|
|
594
|
-
isNew:
|
|
639
|
+
guardianPrincipalId: parsed.guardianPrincipalId,
|
|
640
|
+
accessToken: parsed.accessToken,
|
|
641
|
+
accessTokenExpiresAt: parsed.accessTokenExpiresAt,
|
|
642
|
+
refreshToken: parsed.refreshToken,
|
|
643
|
+
refreshTokenExpiresAt: parsed.refreshTokenExpiresAt,
|
|
644
|
+
refreshAfter: parsed.refreshAfter,
|
|
645
|
+
isNew: parsed.isNew,
|
|
595
646
|
deviceId,
|
|
596
647
|
leasedAt: new Date().toISOString(),
|
|
597
648
|
};
|
|
@@ -675,7 +726,10 @@ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
|
|
|
675
726
|
for (const { path: sibling } of candidates) {
|
|
676
727
|
try {
|
|
677
728
|
const raw = readFileSync(sibling);
|
|
678
|
-
const parsed =
|
|
729
|
+
const parsed = parseGuardianTokenFields(
|
|
730
|
+
JSON.parse(raw.toString("utf-8")),
|
|
731
|
+
);
|
|
732
|
+
if (parsed.refreshTokenExpiresAt == null) continue;
|
|
679
733
|
const refreshExpiry = new Date(parsed.refreshTokenExpiresAt).getTime();
|
|
680
734
|
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
|
|
681
735
|
const dir = dirname(destPath);
|
package/src/lib/health-check.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const HealthResponseSchema = z.object({
|
|
5
|
+
status: z.string().optional(),
|
|
6
|
+
message: z.string().optional(),
|
|
7
|
+
version: z.string().optional(),
|
|
8
|
+
});
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
status: string;
|
|
7
|
-
message?: string;
|
|
8
|
-
version?: string;
|
|
9
|
-
}
|
|
10
|
+
export const HEALTH_CHECK_TIMEOUT_MS = 1500;
|
|
10
11
|
|
|
11
12
|
export interface HealthCheckResult {
|
|
12
13
|
status: string;
|
|
@@ -57,7 +58,9 @@ export async function checkManagedHealth(
|
|
|
57
58
|
return { status: `error (${response.status})`, detail: null };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
const data =
|
|
61
|
+
const data = HealthResponseSchema
|
|
62
|
+
.catch({})
|
|
63
|
+
.parse(await response.json());
|
|
61
64
|
const status = data.status || "unknown";
|
|
62
65
|
return {
|
|
63
66
|
status,
|
|
@@ -168,12 +171,12 @@ export async function fetchManagedPs(
|
|
|
168
171
|
}
|
|
169
172
|
}
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
state: string
|
|
173
|
-
is_awake: boolean
|
|
174
|
-
pod_status: string
|
|
175
|
-
detail: string
|
|
176
|
-
}
|
|
174
|
+
const LegacyConnectionStatusSchema = z.object({
|
|
175
|
+
state: z.string(),
|
|
176
|
+
is_awake: z.boolean(),
|
|
177
|
+
pod_status: z.string().nullable(),
|
|
178
|
+
detail: z.string().nullable(),
|
|
179
|
+
});
|
|
177
180
|
|
|
178
181
|
async function fetchLegacyConnectionStatus(
|
|
179
182
|
runtimeUrl: string,
|
|
@@ -194,7 +197,11 @@ async function fetchLegacyConnectionStatus(
|
|
|
194
197
|
clearTimeout(timeoutId);
|
|
195
198
|
if (!response.ok) return null;
|
|
196
199
|
|
|
197
|
-
const
|
|
200
|
+
const parsed = LegacyConnectionStatusSchema.safeParse(
|
|
201
|
+
await response.json(),
|
|
202
|
+
);
|
|
203
|
+
if (!parsed.success) return null;
|
|
204
|
+
const data = parsed.data;
|
|
198
205
|
|
|
199
206
|
// Translate legacy shape into the ps process tree
|
|
200
207
|
const status: ManagedProcessEntry["status"] = data.is_awake
|
|
@@ -240,7 +247,9 @@ export async function checkHealth(
|
|
|
240
247
|
return { status: `error (${response.status})`, detail: null };
|
|
241
248
|
}
|
|
242
249
|
|
|
243
|
-
const data =
|
|
250
|
+
const data = HealthResponseSchema
|
|
251
|
+
.catch({})
|
|
252
|
+
.parse(await response.json());
|
|
244
253
|
const status = data.status || "unknown";
|
|
245
254
|
return {
|
|
246
255
|
status,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
2
|
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import {
|
|
4
5
|
closeSync,
|
|
5
6
|
mkdirSync,
|
|
@@ -57,7 +58,7 @@ function processIsAlive(pid: number): boolean {
|
|
|
57
58
|
process.kill(pid, 0);
|
|
58
59
|
return true;
|
|
59
60
|
} catch (error) {
|
|
60
|
-
return (error
|
|
61
|
+
return errnoCode(error) === "EPERM";
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -87,30 +88,21 @@ function processStartedAt(pid: number): string | undefined {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function errnoCode(error: unknown): string | undefined {
|
|
92
|
+
if (!(error instanceof Error)) return undefined;
|
|
93
|
+
if (!("code" in error) || typeof error.code !== "string") return undefined;
|
|
94
|
+
return error.code;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const LockOwnerFileSchema = z.object({
|
|
98
|
+
pid: z.number().int().positive(),
|
|
99
|
+
processStartedAt: z.string().min(1).optional().catch(undefined),
|
|
100
|
+
token: z.string().min(1),
|
|
101
|
+
});
|
|
102
|
+
|
|
90
103
|
function parseOwner(raw: string): LockOwner | undefined {
|
|
91
104
|
try {
|
|
92
|
-
|
|
93
|
-
pid?: unknown;
|
|
94
|
-
processStartedAt?: unknown;
|
|
95
|
-
token?: unknown;
|
|
96
|
-
};
|
|
97
|
-
if (
|
|
98
|
-
typeof parsed.pid === "number" &&
|
|
99
|
-
Number.isInteger(parsed.pid) &&
|
|
100
|
-
parsed.pid > 0 &&
|
|
101
|
-
typeof parsed.token === "string" &&
|
|
102
|
-
parsed.token.length > 0
|
|
103
|
-
) {
|
|
104
|
-
return {
|
|
105
|
-
pid: parsed.pid,
|
|
106
|
-
processStartedAt:
|
|
107
|
-
typeof parsed.processStartedAt === "string" &&
|
|
108
|
-
parsed.processStartedAt.length > 0
|
|
109
|
-
? parsed.processStartedAt
|
|
110
|
-
: undefined,
|
|
111
|
-
token: parsed.token,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
105
|
+
return LockOwnerFileSchema.parse(JSON.parse(raw));
|
|
114
106
|
} catch {}
|
|
115
107
|
return undefined;
|
|
116
108
|
}
|
|
@@ -195,7 +187,7 @@ function acquireRecoveryClaim(
|
|
|
195
187
|
writeOwnerFile(claimPath, owner);
|
|
196
188
|
return () => releaseLock(claimPath, owner.token);
|
|
197
189
|
} catch (error) {
|
|
198
|
-
if ((error
|
|
190
|
+
if (errnoCode(error) !== "EEXIST") {
|
|
199
191
|
throw error;
|
|
200
192
|
}
|
|
201
193
|
if (
|
|
@@ -248,7 +240,7 @@ function removeStaleLock(
|
|
|
248
240
|
unlinkSync(lockPath);
|
|
249
241
|
return true;
|
|
250
242
|
} catch (error) {
|
|
251
|
-
return (error
|
|
243
|
+
return errnoCode(error) === "ENOENT";
|
|
252
244
|
}
|
|
253
245
|
} finally {
|
|
254
246
|
releaseRecoveryClaim();
|
|
@@ -262,7 +254,7 @@ function releaseLock(lockPath: string, token: string): void {
|
|
|
262
254
|
try {
|
|
263
255
|
unlinkSync(lockPath);
|
|
264
256
|
} catch (error) {
|
|
265
|
-
if ((error
|
|
257
|
+
if (errnoCode(error) !== "ENOENT") {
|
|
266
258
|
throw error;
|
|
267
259
|
}
|
|
268
260
|
}
|
|
@@ -306,7 +298,7 @@ export async function withLocalHatchLock<T>(
|
|
|
306
298
|
try {
|
|
307
299
|
writeOwnerFile(lockPath, owner);
|
|
308
300
|
} catch (error) {
|
|
309
|
-
if ((error
|
|
301
|
+
if (errnoCode(error) !== "EEXIST") {
|
|
310
302
|
throw error;
|
|
311
303
|
}
|
|
312
304
|
if (removeStaleLock(lockPath, owner, liveOwnerRecoveryGraceMs)) {
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
resolveRuntimeUrl,
|
|
12
12
|
} from "./runtime-url.js";
|
|
13
13
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
const RuntimeIdentitySchema = z.object({ version: z.string().min(1) });
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Thrown when the local runtime returns 409 for an export/import request
|
|
@@ -66,15 +69,23 @@ async function migrationRequestHeaders(
|
|
|
66
69
|
return bearerHeaders(token);
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
detail
|
|
72
|
+
const Raw409BodySchema = z.object({
|
|
73
|
+
detail: z.string().optional(),
|
|
71
74
|
// The runtime's current 409 contract nests the payload under `error`:
|
|
72
75
|
// { error: { code: "export_in_progress" | "import_in_progress", job_id } }
|
|
73
76
|
// We also tolerate a legacy flat shape ({ code, job_id }) for resilience.
|
|
74
|
-
error
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
error: z
|
|
78
|
+
.union([
|
|
79
|
+
z.string(),
|
|
80
|
+
z.object({
|
|
81
|
+
code: z.string().optional(),
|
|
82
|
+
job_id: z.string().optional(),
|
|
83
|
+
}),
|
|
84
|
+
])
|
|
85
|
+
.optional(),
|
|
86
|
+
code: z.string().optional(),
|
|
87
|
+
job_id: z.string().optional(),
|
|
88
|
+
});
|
|
78
89
|
|
|
79
90
|
/** Common 409 → MigrationInProgressError parsing used by the two POST helpers. */
|
|
80
91
|
async function throwIfInProgress(
|
|
@@ -82,11 +93,11 @@ async function throwIfInProgress(
|
|
|
82
93
|
defaultKind: "export_in_progress" | "import_in_progress",
|
|
83
94
|
): Promise<void> {
|
|
84
95
|
if (response.status !== 409) return;
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const parsed = Raw409BodySchema.safeParse(
|
|
97
|
+
await response.json().catch(() => ({})),
|
|
98
|
+
);
|
|
99
|
+
const body = parsed.success ? parsed.data : {};
|
|
100
|
+
const nested = typeof body.error === "object" ? body.error : undefined;
|
|
90
101
|
const jobId = nested?.job_id ?? body.job_id ?? "";
|
|
91
102
|
const rawKind =
|
|
92
103
|
nested?.code ??
|
|
@@ -355,9 +366,9 @@ export async function localRuntimeIdentity(
|
|
|
355
366
|
`Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
|
|
356
367
|
);
|
|
357
368
|
}
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
369
|
+
const parsed = RuntimeIdentitySchema.safeParse(await response.json());
|
|
370
|
+
if (!parsed.success) {
|
|
360
371
|
throw new Error("Runtime identity response missing version");
|
|
361
372
|
}
|
|
362
|
-
return { version:
|
|
373
|
+
return { version: parsed.data.version };
|
|
363
374
|
}
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -11,21 +11,36 @@ import {
|
|
|
11
11
|
saveIngressUrl,
|
|
12
12
|
saveNgrokDomain,
|
|
13
13
|
} from "./ingress-config.js";
|
|
14
|
-
import { isJsonObject } from "./json";
|
|
15
14
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
16
|
|
|
17
17
|
const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
|
|
18
18
|
const NGROK_POLL_INTERVAL_MS = 500;
|
|
19
19
|
const NGROK_POLL_TIMEOUT_MS = 15_000;
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
public_url: string
|
|
23
|
-
config
|
|
24
|
-
|
|
21
|
+
const NgrokTunnelSchema = z.object({
|
|
22
|
+
public_url: z.string(),
|
|
23
|
+
config: z
|
|
24
|
+
.object({
|
|
25
|
+
addr: z.string().optional(),
|
|
26
|
+
})
|
|
27
|
+
.optional(),
|
|
28
|
+
});
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
type NgrokTunnel = z.infer<typeof NgrokTunnelSchema>;
|
|
31
|
+
|
|
32
|
+
const NgrokTunnelsResponseSchema = z.object({
|
|
33
|
+
tunnels: z.array(NgrokTunnelSchema).optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The ingress block of the workspace config, as read by the tunnel edge:
|
|
38
|
+
* explicitly enabled with a saved public URL.
|
|
39
|
+
*/
|
|
40
|
+
export const IngressConfigSchema = z.object({
|
|
41
|
+
enabled: z.boolean().optional(),
|
|
42
|
+
publicBaseUrl: z.string().optional(),
|
|
43
|
+
});
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
46
|
* Check whether ngrok is installed and accessible on the PATH.
|
|
@@ -55,8 +70,8 @@ async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
|
|
|
55
70
|
signal: AbortSignal.timeout(2_000),
|
|
56
71
|
});
|
|
57
72
|
if (!res.ok) return null;
|
|
58
|
-
const
|
|
59
|
-
return data.tunnels ?? [];
|
|
73
|
+
const parsed = NgrokTunnelsResponseSchema.safeParse(await res.json());
|
|
74
|
+
return parsed.success ? (parsed.data.tunnels ?? []) : null;
|
|
60
75
|
} catch {
|
|
61
76
|
return null;
|
|
62
77
|
}
|
|
@@ -195,13 +210,27 @@ export async function waitForNgrokUrl(
|
|
|
195
210
|
* Check whether an already-loaded workspace config has webhook-based
|
|
196
211
|
* integrations (e.g. Telegram, Twilio) that require a public ingress URL.
|
|
197
212
|
*/
|
|
213
|
+
const TelegramIntegrationSchema = z.object({
|
|
214
|
+
botUsername: z.unknown().optional(),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const TwilioIntegrationSchema = z.object({
|
|
218
|
+
accountSid: z.unknown().optional(),
|
|
219
|
+
phoneNumber: z.unknown().optional(),
|
|
220
|
+
});
|
|
221
|
+
|
|
198
222
|
export function hasWebhookIntegrations(
|
|
199
223
|
config: Record<string, unknown>,
|
|
200
224
|
): boolean {
|
|
201
|
-
const telegram =
|
|
202
|
-
if (telegram
|
|
203
|
-
const twilio =
|
|
204
|
-
if (
|
|
225
|
+
const telegram = TelegramIntegrationSchema.safeParse(config.telegram);
|
|
226
|
+
if (telegram.success && telegram.data.botUsername) return true;
|
|
227
|
+
const twilio = TwilioIntegrationSchema.safeParse(config.twilio);
|
|
228
|
+
if (
|
|
229
|
+
twilio.success &&
|
|
230
|
+
(twilio.data.accountSid || twilio.data.phoneNumber)
|
|
231
|
+
) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
205
234
|
return false;
|
|
206
235
|
}
|
|
207
236
|
|
|
@@ -227,9 +256,11 @@ export function hasWebhookIntegrationsConfigured(
|
|
|
227
256
|
function hasNonNgrokIngressUrl(workspaceDir: string): boolean {
|
|
228
257
|
try {
|
|
229
258
|
const config = loadRawConfig(workspaceDir);
|
|
230
|
-
const
|
|
231
|
-
const publicBaseUrl =
|
|
232
|
-
|
|
259
|
+
const parsed = IngressConfigSchema.safeParse(config.ingress);
|
|
260
|
+
const publicBaseUrl = parsed.success
|
|
261
|
+
? parsed.data.publicBaseUrl
|
|
262
|
+
: undefined;
|
|
263
|
+
if (!publicBaseUrl) return false;
|
|
233
264
|
return !publicBaseUrl.includes("ngrok");
|
|
234
265
|
} catch {
|
|
235
266
|
return false;
|