@mycadet/cli 0.12.9 → 0.12.11
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/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/package.json +4 -3
- package/src/__tests__/client-web-config.test.ts +0 -1
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/devices.test.ts +62 -0
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +58 -26
- package/src/__tests__/pair.test.ts +149 -78
- package/src/__tests__/ps-cloud-status.test.ts +6 -4
- package/src/__tests__/setup.test.ts +14 -4
- package/src/__tests__/teleport.test.ts +0 -15
- 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 +265 -70
- package/src/__tests__/uninstall.test.ts +140 -8
- package/src/commands/client.ts +4 -6
- package/src/commands/devices.ts +66 -30
- package/src/commands/hatch.ts +14 -53
- package/src/commands/pair.ts +147 -38
- package/src/commands/ps.ts +10 -20
- package/src/commands/teleport.ts +2 -21
- package/src/commands/tunnel.ts +67 -28
- package/src/commands/uninstall.ts +106 -71
- package/src/commands/upgrade.ts +6 -1
- package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
- package/src/lib/assistant-config.ts +20 -5
- package/src/lib/cloud-client.ts +0 -3
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +3 -2
- package/src/lib/color.ts +4 -0
- package/src/lib/docker.ts +46 -70
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/environments/__tests__/paths.test.ts +27 -1
- package/src/lib/environments/paths.ts +16 -8
- package/src/lib/environments/resolve.ts +2 -4
- package/src/lib/hatch-local.ts +16 -59
- package/src/lib/hatch-next-steps.ts +18 -9
- package/src/lib/live-voice/tunnel.ts +2 -2
- package/src/lib/local.ts +42 -9
- package/src/lib/nginx-ingress.ts +124 -24
- package/src/lib/step-runner.ts +6 -1
- 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/xdg-log.ts +11 -0
package/src/lib/docker.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
ASSISTANT_INTERNAL_PORT,
|
|
28
28
|
getDefaultPorts,
|
|
29
29
|
} from "./environments/paths.js";
|
|
30
|
+
import { green, yellow } from "./color.js";
|
|
30
31
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
31
32
|
import { leaseGuardianToken } from "./guardian-token";
|
|
32
33
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
@@ -42,7 +43,6 @@ import { isDevVersion } from "./version-compat.js";
|
|
|
42
43
|
import {
|
|
43
44
|
collectProviderApiKey,
|
|
44
45
|
configureHatchProviderApiKey,
|
|
45
|
-
formatProviderName,
|
|
46
46
|
promptProviderChoice,
|
|
47
47
|
resolveHatchProvider,
|
|
48
48
|
shouldPromptForHatchProvider,
|
|
@@ -305,7 +305,6 @@ export interface HatchDockerParams {
|
|
|
305
305
|
* also enables hot-reload.
|
|
306
306
|
*/
|
|
307
307
|
sourcePath?: string | null;
|
|
308
|
-
analyze?: boolean;
|
|
309
308
|
/**
|
|
310
309
|
* Name of an existing container whose network namespace the assistant,
|
|
311
310
|
* gateway, and credential-executor join (`--network=container:<name>`),
|
|
@@ -1544,7 +1543,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1544
1543
|
}
|
|
1545
1544
|
}
|
|
1546
1545
|
}
|
|
1547
|
-
log("✅ Docker images acquired");
|
|
1546
|
+
log(green("✅ Docker images acquired", process.stdout));
|
|
1548
1547
|
}
|
|
1549
1548
|
|
|
1550
1549
|
const res = dockerResourceNames(instanceName);
|
|
@@ -1719,7 +1718,6 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1719
1718
|
logFd,
|
|
1720
1719
|
runtimeUrl,
|
|
1721
1720
|
containersUpAt,
|
|
1722
|
-
analyze: params.analyze ?? false,
|
|
1723
1721
|
});
|
|
1724
1722
|
|
|
1725
1723
|
if (!ready && !(watch && repoRoot)) {
|
|
@@ -1745,18 +1743,16 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1745
1743
|
);
|
|
1746
1744
|
} else if (providerSetupAction === "missing-token" && provider !== null) {
|
|
1747
1745
|
log(
|
|
1748
|
-
|
|
1749
|
-
|
|
1746
|
+
yellow(
|
|
1747
|
+
`⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
|
|
1748
|
+
` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
|
|
1749
|
+
process.stdout,
|
|
1750
|
+
),
|
|
1750
1751
|
);
|
|
1751
1752
|
} else if (
|
|
1752
1753
|
providerSetupAction === "configure" &&
|
|
1753
1754
|
provider !== undefined
|
|
1754
1755
|
) {
|
|
1755
|
-
log(
|
|
1756
|
-
provider === null
|
|
1757
|
-
? "Checking provider credentials..."
|
|
1758
|
-
: `Checking ${formatProviderName(provider)} credentials...`,
|
|
1759
|
-
);
|
|
1760
1756
|
await configureHatchProviderApiKey({
|
|
1761
1757
|
gatewayUrl: runtimeUrl,
|
|
1762
1758
|
provider,
|
|
@@ -1830,7 +1826,6 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1830
1826
|
logFd: number | "ignore";
|
|
1831
1827
|
runtimeUrl: string;
|
|
1832
1828
|
containersUpAt: number;
|
|
1833
|
-
analyze: boolean;
|
|
1834
1829
|
}): Promise<{
|
|
1835
1830
|
ready: boolean;
|
|
1836
1831
|
guardianAccessToken?: string;
|
|
@@ -1846,7 +1841,6 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1846
1841
|
logFd,
|
|
1847
1842
|
runtimeUrl,
|
|
1848
1843
|
containersUpAt,
|
|
1849
|
-
analyze,
|
|
1850
1844
|
} = opts;
|
|
1851
1845
|
|
|
1852
1846
|
const log = (msg: string): void => {
|
|
@@ -1855,25 +1849,12 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1855
1849
|
};
|
|
1856
1850
|
|
|
1857
1851
|
if (detached) {
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
container: containerName,
|
|
1865
|
-
},
|
|
1866
|
-
log,
|
|
1867
|
-
);
|
|
1868
|
-
log("");
|
|
1869
|
-
log(`Stop with: cadet retire ${instanceName}`);
|
|
1870
|
-
|
|
1871
|
-
// Lease a guardian token even in detached mode so that `cadet ps`,
|
|
1872
|
-
// `cadet exec`, and other CLI commands can authenticate to the
|
|
1873
|
-
// gateway. Skip the /readyz readiness poll (the caller asked to detach
|
|
1874
|
-
// and not block) but retry the lease itself since the gateway may need
|
|
1875
|
-
// a moment to accept connections after the container starts.
|
|
1876
|
-
const leaseStart = Date.now();
|
|
1852
|
+
// Lease a guardian token BEFORE declaring the assistant hatched so the
|
|
1853
|
+
// banner means `cadet ps`, `cadet exec`, and other CLI commands can
|
|
1854
|
+
// already authenticate. Skip the /readyz readiness poll (the caller
|
|
1855
|
+
// asked to detach and not block) but retry the lease silently since
|
|
1856
|
+
// the gateway may need a moment to accept connections after the
|
|
1857
|
+
// container starts; only a final failure surfaces to the user.
|
|
1877
1858
|
const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
|
|
1878
1859
|
let guardianAccessToken: string | undefined;
|
|
1879
1860
|
while (Date.now() < leaseDeadline) {
|
|
@@ -1884,27 +1865,33 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1884
1865
|
bootstrapSecret,
|
|
1885
1866
|
);
|
|
1886
1867
|
guardianAccessToken = tokenData.accessToken;
|
|
1887
|
-
const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
|
|
1888
|
-
log(
|
|
1889
|
-
`Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
|
|
1890
|
-
);
|
|
1891
1868
|
break;
|
|
1892
|
-
} catch
|
|
1893
|
-
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
1894
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1895
|
-
log(
|
|
1896
|
-
`Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
|
|
1897
|
-
);
|
|
1869
|
+
} catch {
|
|
1898
1870
|
await new Promise((r) => setTimeout(r, 2000));
|
|
1899
1871
|
}
|
|
1900
1872
|
}
|
|
1901
1873
|
if (!guardianAccessToken) {
|
|
1902
1874
|
log(
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1875
|
+
yellow(
|
|
1876
|
+
`⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
|
|
1877
|
+
` The assistant is running but CLI commands (cadet ps, cadet exec) will not authenticate.\n` +
|
|
1878
|
+
` Re-hatch or run \`cadet setup\` to recover.`,
|
|
1879
|
+
process.stdout,
|
|
1880
|
+
),
|
|
1906
1881
|
);
|
|
1907
1882
|
}
|
|
1883
|
+
|
|
1884
|
+
log(`\n${green("✅ Docker assistant hatched!", process.stdout)}\n`);
|
|
1885
|
+
log("Instance details:");
|
|
1886
|
+
printInstanceDetails(
|
|
1887
|
+
{
|
|
1888
|
+
name: instanceName,
|
|
1889
|
+
runtime: runtimeUrl,
|
|
1890
|
+
container: containerName,
|
|
1891
|
+
},
|
|
1892
|
+
log,
|
|
1893
|
+
);
|
|
1894
|
+
log("");
|
|
1908
1895
|
return { ready: true, guardianAccessToken };
|
|
1909
1896
|
}
|
|
1910
1897
|
|
|
@@ -1973,19 +1960,16 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1973
1960
|
const containersUpToReadyMs = readyAt - start;
|
|
1974
1961
|
const elapsedSec = (containersUpToReadyMs / 1000).toFixed(1);
|
|
1975
1962
|
log(`Assistant ready after ${elapsedSec}s`);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
);
|
|
1983
|
-
}
|
|
1963
|
+
console.info(
|
|
1964
|
+
`[cadet-hatch-timing] ${JSON.stringify({
|
|
1965
|
+
containers_up_to_ready_ms: containersUpToReadyMs,
|
|
1966
|
+
instance: instanceName,
|
|
1967
|
+
})}`,
|
|
1968
|
+
);
|
|
1984
1969
|
|
|
1985
1970
|
// Lease guardian token. The /readyz check confirms both gateway and
|
|
1986
1971
|
// assistant are reachable. Retry with backoff in case there is a brief
|
|
1987
1972
|
// window where readiness passes but the guardian endpoint is not yet ready.
|
|
1988
|
-
log(`Guardian token lease: starting for ${instanceName} at ${runtimeUrl}`);
|
|
1989
1973
|
const leaseStart = Date.now();
|
|
1990
1974
|
const leaseDeadline = start + DOCKER_READY_TIMEOUT_MS;
|
|
1991
1975
|
let leaseSuccess = false;
|
|
@@ -1999,38 +1983,30 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1999
1983
|
instanceName,
|
|
2000
1984
|
bootstrapSecret,
|
|
2001
1985
|
);
|
|
2002
|
-
const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
|
|
2003
|
-
log(
|
|
2004
|
-
`Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
|
|
2005
|
-
);
|
|
2006
1986
|
guardianAccessToken = tokenData.accessToken;
|
|
2007
1987
|
leaseSuccess = true;
|
|
2008
1988
|
break;
|
|
2009
1989
|
} catch (err) {
|
|
2010
1990
|
lastLeaseError =
|
|
2011
1991
|
err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
2012
|
-
// Log periodically so the user knows we're still trying
|
|
2013
|
-
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
2014
|
-
log(
|
|
2015
|
-
`Guardian token lease: attempt failed after ${elapsed}s (${
|
|
2016
|
-
lastLeaseError.split("\n")[0]
|
|
2017
|
-
}), retrying...`,
|
|
2018
|
-
);
|
|
2019
1992
|
}
|
|
2020
1993
|
await new Promise((r) => setTimeout(r, 2000));
|
|
2021
1994
|
}
|
|
2022
1995
|
|
|
2023
1996
|
if (!leaseSuccess) {
|
|
2024
1997
|
log(
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
1998
|
+
yellow(
|
|
1999
|
+
`\u26a0\ufe0f Guardian token lease: FAILED after ${(
|
|
2000
|
+
(Date.now() - leaseStart) /
|
|
2001
|
+
1000
|
|
2002
|
+
).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
|
|
2003
|
+
process.stdout,
|
|
2004
|
+
),
|
|
2029
2005
|
);
|
|
2030
2006
|
}
|
|
2031
2007
|
|
|
2032
2008
|
log("");
|
|
2033
|
-
log(`\u2705 Docker containers are up and running
|
|
2009
|
+
log(green(`\u2705 Docker containers are up and running!`, process.stdout));
|
|
2034
2010
|
log(` Name: ${instanceName}`);
|
|
2035
2011
|
log(` Runtime: ${runtimeUrl}`);
|
|
2036
2012
|
log("");
|
package/src/lib/ensure-binary.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface EnsureBinaryOptions {
|
|
|
31
31
|
runInstall?: (argv: string[]) => boolean;
|
|
32
32
|
/** Confirm the install. Default: promptLine "[Y/n]" on Enter-as-yes. */
|
|
33
33
|
confirmInstall?: () => Promise<boolean>;
|
|
34
|
+
platform?: NodeJS.Platform;
|
|
35
|
+
confirmHomebrewInstall?: () => Promise<boolean>;
|
|
34
36
|
streams?: {
|
|
35
37
|
input?: NodeJS.ReadStream | Readable;
|
|
36
38
|
output?: NodeJS.WriteStream | Writable;
|
|
@@ -69,9 +71,60 @@ export async function ensureBinaryInstalled(
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
const pmAvailable = opts.pmAvailable ?? probePackageManager;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
let argv = Object.entries(opts.packages).find(([pm]) => pmAvailable(pm))?.[1];
|
|
75
|
+
if (
|
|
76
|
+
!argv &&
|
|
77
|
+
(opts.platform ?? process.platform) === "darwin" &&
|
|
78
|
+
opts.packages.brew
|
|
79
|
+
) {
|
|
80
|
+
const brew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
|
|
81
|
+
pmAvailable,
|
|
82
|
+
);
|
|
83
|
+
if (brew) {
|
|
84
|
+
const prefix = brew.slice(0, -"/bin/brew".length);
|
|
85
|
+
process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
|
|
86
|
+
argv = [brew, ...opts.packages.brew.slice(1)];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
!argv &&
|
|
91
|
+
(opts.platform ?? process.platform) === "darwin" &&
|
|
92
|
+
opts.packages.brew
|
|
93
|
+
) {
|
|
94
|
+
output.write(
|
|
95
|
+
"Homebrew is required to install this dependency, but it isn't installed.\n",
|
|
96
|
+
);
|
|
97
|
+
const confirmHomebrew =
|
|
98
|
+
opts.confirmHomebrewInstall ?? defaultConfirm(opts, "Homebrew");
|
|
99
|
+
let approved = false;
|
|
100
|
+
try {
|
|
101
|
+
approved = await confirmHomebrew();
|
|
102
|
+
} catch {
|
|
103
|
+
/* No interactive approval. */
|
|
104
|
+
}
|
|
105
|
+
if (approved) {
|
|
106
|
+
const bootstrap = [
|
|
107
|
+
"/bin/bash",
|
|
108
|
+
"-c",
|
|
109
|
+
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/7a3f48c7e498d7df3237fd41c30f4d492857edf4/install.sh)"',
|
|
110
|
+
];
|
|
111
|
+
const installed = opts.runInstall
|
|
112
|
+
? opts.runInstall(bootstrap)
|
|
113
|
+
: runInstallArgv(bootstrap);
|
|
114
|
+
const brew = installed
|
|
115
|
+
? ["brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find(
|
|
116
|
+
pmAvailable,
|
|
117
|
+
)
|
|
118
|
+
: undefined;
|
|
119
|
+
if (brew) {
|
|
120
|
+
const prefix =
|
|
121
|
+
brew === "brew" ? "" : brew.slice(0, -"/bin/brew".length);
|
|
122
|
+
if (prefix)
|
|
123
|
+
process.env.PATH = `${prefix}/bin:${prefix}/sbin:${process.env.PATH ?? ""}`;
|
|
124
|
+
argv = [brew, ...opts.packages.brew.slice(1)];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
75
128
|
if (!argv) {
|
|
76
129
|
output.write(`⚠ No supported package manager found. ${opts.manualHint}\n`);
|
|
77
130
|
return null;
|
|
@@ -90,7 +143,10 @@ export async function ensureBinaryInstalled(
|
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
/** Default confirm: visible prompt, Enter = yes, non-TTY throws. */
|
|
93
|
-
function defaultConfirm(
|
|
146
|
+
function defaultConfirm(
|
|
147
|
+
opts: EnsureBinaryOptions,
|
|
148
|
+
binary = opts.binary,
|
|
149
|
+
): () => Promise<boolean> {
|
|
94
150
|
return async () => {
|
|
95
151
|
const input = opts.streams?.input ?? process.stdin;
|
|
96
152
|
const output = opts.streams?.output ?? process.stdout;
|
|
@@ -99,7 +155,7 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
|
|
|
99
155
|
throw new Error("Install confirm requires an interactive terminal.");
|
|
100
156
|
}
|
|
101
157
|
const answer = (
|
|
102
|
-
await readLineText(`Install ${
|
|
158
|
+
await readLineText(`Install ${binary} now? [Y/n] `, {
|
|
103
159
|
input,
|
|
104
160
|
output,
|
|
105
161
|
})
|
|
@@ -114,7 +170,10 @@ function defaultConfirm(opts: EnsureBinaryOptions): () => Promise<boolean> {
|
|
|
114
170
|
function runInstallArgv(argv: string[]): boolean {
|
|
115
171
|
const [command, ...args] = argv;
|
|
116
172
|
if (!command) return false;
|
|
117
|
-
const result = spawnSync(command, args, {
|
|
173
|
+
const result = spawnSync(command, args, {
|
|
174
|
+
stdio: "inherit",
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
});
|
|
118
177
|
return !result.error && result.status === 0;
|
|
119
178
|
}
|
|
120
179
|
|
|
@@ -39,9 +39,11 @@ const {
|
|
|
39
39
|
getConfigDir,
|
|
40
40
|
getConfigDirs,
|
|
41
41
|
getDefaultPorts,
|
|
42
|
+
getInputHistoryPath,
|
|
42
43
|
getLockfilePath,
|
|
43
44
|
getLockfilePaths,
|
|
44
45
|
getMultiInstanceDir,
|
|
46
|
+
getSharedStateDir,
|
|
45
47
|
} = await import("../paths.js");
|
|
46
48
|
type EnvironmentDefinition =
|
|
47
49
|
import("@mycadet/environments").EnvironmentDefinition;
|
|
@@ -58,7 +60,12 @@ const dev: EnvironmentDefinition = {
|
|
|
58
60
|
webUrl: "https://dev.mycadet.ai",
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
const XDG_ENV_VARS = [
|
|
63
|
+
const XDG_ENV_VARS = [
|
|
64
|
+
"XDG_DATA_HOME",
|
|
65
|
+
"XDG_CONFIG_HOME",
|
|
66
|
+
"XDG_STATE_HOME",
|
|
67
|
+
"APPDATA",
|
|
68
|
+
] as const;
|
|
62
69
|
|
|
63
70
|
describe("path helpers", () => {
|
|
64
71
|
let savedEnv: Record<string, string | undefined>;
|
|
@@ -224,6 +231,25 @@ describe("path helpers", () => {
|
|
|
224
231
|
});
|
|
225
232
|
});
|
|
226
233
|
|
|
234
|
+
describe("getSharedStateDir", () => {
|
|
235
|
+
test("is not environment-scoped: ~/.local/state/cadet for every env", () => {
|
|
236
|
+
expect(getSharedStateDir()).toBe(
|
|
237
|
+
join(TEST_HOME, ".local", "state", "cadet"),
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("respects XDG_STATE_HOME", () => {
|
|
242
|
+
process.env.XDG_STATE_HOME = "/custom/state";
|
|
243
|
+
expect(getSharedStateDir()).toBe("/custom/state/cadet");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("holds the shared terminal input history file", () => {
|
|
247
|
+
expect(getInputHistoryPath()).toBe(
|
|
248
|
+
join(getSharedStateDir(), "input-history"),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
227
253
|
describe("getDefaultPorts", () => {
|
|
228
254
|
test("returns production defaults for production", () => {
|
|
229
255
|
const ports = getDefaultPorts(prod);
|
|
@@ -82,18 +82,26 @@ export function getLogDir(env: EnvironmentDefinition): string {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
85
|
+
* The interactive CLI's shared state directory — `$XDG_STATE_HOME/cadet`.
|
|
86
86
|
*
|
|
87
|
-
* Follows the XDG Base Directory spec:
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* Defaults to `~/.local/state/cadet/input-history`.
|
|
87
|
+
* Follows the XDG Base Directory spec: session state is data that persists
|
|
88
|
+
* across runs but is not portable user-owned content, mirroring `bash`,
|
|
89
|
+
* `zsh`, `psql`, and `gh`. Defaults to `~/.local/state/cadet`.
|
|
91
90
|
*
|
|
92
|
-
* Not environment-scoped:
|
|
93
|
-
*
|
|
91
|
+
* Not environment-scoped: it holds per-user state (the terminal input
|
|
92
|
+
* history, below) that dev and prod CLIs share.
|
|
93
|
+
*/
|
|
94
|
+
export function getSharedStateDir(): string {
|
|
95
|
+
return join(xdgStateHome(), "cadet");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Path to the interactive CLI's input history file, inside
|
|
100
|
+
* {@link getSharedStateDir}. Defaults to
|
|
101
|
+
* `~/.local/state/cadet/input-history`.
|
|
94
102
|
*/
|
|
95
103
|
export function getInputHistoryPath(): string {
|
|
96
|
-
return join(
|
|
104
|
+
return join(getSharedStateDir(), "input-history");
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
/**
|
|
@@ -67,10 +67,8 @@ export function getCurrentEnvironment(
|
|
|
67
67
|
const seed = SEEDS[name];
|
|
68
68
|
if (!seed) {
|
|
69
69
|
if (name !== DEFAULT_ENVIRONMENT_NAME) {
|
|
70
|
-
// Warn on stderr instead of throwing
|
|
71
|
-
//
|
|
72
|
-
// which silently falls back to production; the CLI agrees so neither
|
|
73
|
-
// writer ends up in a disjoint state on a typo.
|
|
70
|
+
// Warn on stderr instead of throwing: an unknown environment falls back
|
|
71
|
+
// to production, so a typo never splits writes between two states.
|
|
74
72
|
process.stderr.write(
|
|
75
73
|
`warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
|
|
76
74
|
`Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setActiveAssistant,
|
|
22
22
|
} from "./assistant-config.js";
|
|
23
23
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
24
|
+
import { green, yellow } from "./color.js";
|
|
24
25
|
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
|
|
25
26
|
import {
|
|
26
27
|
generateLocalSigningKey,
|
|
@@ -51,7 +52,6 @@ import {
|
|
|
51
52
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
52
53
|
import { printInstanceDetails } from "./instance-details.js";
|
|
53
54
|
import { checkProviderApiKey } from "./api-key-check.js";
|
|
54
|
-
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
55
55
|
import {
|
|
56
56
|
describeLoginError,
|
|
57
57
|
ensureCloudLoginInteractive,
|
|
@@ -155,8 +155,6 @@ export interface HatchLocalParams {
|
|
|
155
155
|
name?: string | null;
|
|
156
156
|
/** Build from a local source tree and hot-reload on change. */
|
|
157
157
|
watch?: boolean;
|
|
158
|
-
/** Stay attached after hatch and poll the gateway until it stops. */
|
|
159
|
-
keepAlive?: boolean;
|
|
160
158
|
/** Hatch-time config values (key → value). */
|
|
161
159
|
configValues?: Record<string, string>;
|
|
162
160
|
/** Extra env vars forwarded into the assistant process. */
|
|
@@ -191,13 +189,7 @@ export interface HatchLocalResult {
|
|
|
191
189
|
export async function hatchLocal(
|
|
192
190
|
params: HatchLocalParams = {},
|
|
193
191
|
): Promise<HatchLocalResult> {
|
|
194
|
-
const {
|
|
195
|
-
name,
|
|
196
|
-
watch = false,
|
|
197
|
-
keepAlive = false,
|
|
198
|
-
configValues = {},
|
|
199
|
-
flagEnvVars = {},
|
|
200
|
-
} = params;
|
|
192
|
+
const { name, watch = false, configValues = {}, flagEnvVars = {} } = params;
|
|
201
193
|
const reporter = params.reporter ?? consoleLifecycleReporter;
|
|
202
194
|
const verbose = params.verbose ?? false;
|
|
203
195
|
const setupProviderCredentials = params.setupProviderCredentials !== false;
|
|
@@ -379,14 +371,18 @@ export async function hatchLocal(
|
|
|
379
371
|
) {
|
|
380
372
|
const delayMs = 2000 * 2 ** (attempt - 1);
|
|
381
373
|
reporter.error(
|
|
382
|
-
|
|
374
|
+
yellow(
|
|
375
|
+
`⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
|
|
376
|
+
),
|
|
383
377
|
);
|
|
384
378
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
385
379
|
} else {
|
|
386
380
|
reporter.error(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
381
|
+
yellow(
|
|
382
|
+
`⚠️ Guardian token lease failed${err instanceof GuardianLeasePermanentError ? ", not retrying — a malformed 200 will not fix itself" : ` after ${maxLeaseAttempts} attempts`}: ${err}\n` +
|
|
383
|
+
` The assistant is running but guardian-token.json was not written.\n` +
|
|
384
|
+
` Run \`cadet restart ${JSON.stringify(instanceName)} --repair-guardian\` to re-lease a token.`,
|
|
385
|
+
),
|
|
390
386
|
);
|
|
391
387
|
}
|
|
392
388
|
}
|
|
@@ -398,8 +394,10 @@ export async function hatchLocal(
|
|
|
398
394
|
|
|
399
395
|
if (provider !== undefined && provider !== null && !guardianAccessToken) {
|
|
400
396
|
reporter.error(
|
|
401
|
-
|
|
402
|
-
|
|
397
|
+
yellow(
|
|
398
|
+
`⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
|
|
399
|
+
` The assistant is still hatched. Run \`cadet setup --provider ${provider}\` after fixing the connection.`,
|
|
400
|
+
),
|
|
403
401
|
);
|
|
404
402
|
} else if (provider !== undefined) {
|
|
405
403
|
await configureHatchProviderApiKey({
|
|
@@ -429,7 +427,7 @@ export async function hatchLocal(
|
|
|
429
427
|
}
|
|
430
428
|
|
|
431
429
|
reporter.log("");
|
|
432
|
-
reporter.log(`✅ Local assistant hatched
|
|
430
|
+
reporter.log(green(`✅ Local assistant hatched!`, process.stdout));
|
|
433
431
|
printInstanceDetails(
|
|
434
432
|
{
|
|
435
433
|
name: instanceName,
|
|
@@ -438,7 +436,7 @@ export async function hatchLocal(
|
|
|
438
436
|
},
|
|
439
437
|
(message) => reporter.log(message),
|
|
440
438
|
);
|
|
441
|
-
logHatchNextSteps((message) => reporter.log(message)
|
|
439
|
+
logHatchNextSteps((message) => reporter.log(message));
|
|
442
440
|
|
|
443
441
|
const result: HatchLocalResult = {
|
|
444
442
|
assistantId: instanceName,
|
|
@@ -447,46 +445,5 @@ export async function hatchLocal(
|
|
|
447
445
|
guardianAccessToken,
|
|
448
446
|
};
|
|
449
447
|
|
|
450
|
-
if (keepAlive) {
|
|
451
|
-
const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
452
|
-
const healthTarget = "Gateway";
|
|
453
|
-
const POLL_INTERVAL_MS = 5000;
|
|
454
|
-
const MAX_FAILURES = 3;
|
|
455
|
-
let consecutiveFailures = 0;
|
|
456
|
-
|
|
457
|
-
const shutdown = async (): Promise<void> => {
|
|
458
|
-
reporter.log("\nShutting down local processes...");
|
|
459
|
-
await stopLocalProcesses(resources);
|
|
460
|
-
process.exit(0);
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
process.on("SIGTERM", () => void shutdown());
|
|
464
|
-
process.on("SIGINT", () => void shutdown());
|
|
465
|
-
|
|
466
|
-
// Poll the health endpoint until it stops responding.
|
|
467
|
-
while (true) {
|
|
468
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
469
|
-
try {
|
|
470
|
-
const res = await loopbackSafeFetch(healthUrl, {
|
|
471
|
-
signal: AbortSignal.timeout(3000),
|
|
472
|
-
});
|
|
473
|
-
if (res.ok) {
|
|
474
|
-
consecutiveFailures = 0;
|
|
475
|
-
} else {
|
|
476
|
-
consecutiveFailures++;
|
|
477
|
-
}
|
|
478
|
-
} catch {
|
|
479
|
-
consecutiveFailures++;
|
|
480
|
-
}
|
|
481
|
-
if (consecutiveFailures >= MAX_FAILURES) {
|
|
482
|
-
reporter.log(
|
|
483
|
-
`\n⚠️ ${healthTarget} stopped responding — shutting down.`,
|
|
484
|
-
);
|
|
485
|
-
await stopLocalProcesses(resources);
|
|
486
|
-
process.exit(1);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
448
|
return result;
|
|
492
449
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
2
|
+
|
|
3
|
+
function cliCommand(): string {
|
|
4
|
+
// Production keeps the bare `cadet` binary; every non-production
|
|
5
|
+
// environment installs as `cadet-<env>` (the same rule as the config and
|
|
6
|
+
// state dirs — see environments/paths.ts).
|
|
7
|
+
const env = getCurrentEnvironment();
|
|
8
|
+
return env.name === "production" ? "cadet" : `cadet-${env.name}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function logHatchNextSteps(log: (message: string) => void): void {
|
|
12
|
+
const command = cliCommand();
|
|
5
13
|
log("Next steps:");
|
|
6
|
-
log(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
14
|
+
log(
|
|
15
|
+
` - Run \`${command} client\` to interact with your cadet via the terminal`,
|
|
16
|
+
);
|
|
17
|
+
log(
|
|
18
|
+
` - Run \`${command} tunnel\` to make your cadet publicly accessible and interact with it via the browser`,
|
|
19
|
+
);
|
|
11
20
|
log("");
|
|
12
21
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* mint again rather than reuse what resolution returned.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
|
|
32
32
|
|
|
33
33
|
import { authHeaders } from "../cloud-client.js";
|
|
34
34
|
|
|
@@ -66,7 +66,7 @@ export function tunnelHostForCloudUrl(cloudUrl: string): string {
|
|
|
66
66
|
}
|
|
67
67
|
try {
|
|
68
68
|
return (
|
|
69
|
-
|
|
69
|
+
tunnelHostForCloudHost(new URL(cloudUrl).host) ??
|
|
70
70
|
DEFAULT_TUNNEL_HOST
|
|
71
71
|
);
|
|
72
72
|
} catch {
|