@mycadet/cli 0.12.8 → 0.12.9-dev.202610131620.d833e01
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/events/show-cloud-login.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/package.json +4 -3
- package/src/__tests__/client-web-config.test.ts +0 -1
- package/src/__tests__/cloud-releases.test.ts +23 -13
- package/src/__tests__/ensure-binary.test.ts +182 -0
- package/src/__tests__/nginx-ingress.test.ts +6 -11
- package/src/__tests__/ngrok-spawn.test.ts +49 -0
- package/src/__tests__/pair.test.ts +149 -78
- package/src/__tests__/ps-cloud-status.test.ts +4 -3
- package/src/__tests__/ps-table.test.ts +79 -0
- package/src/__tests__/tailscale.test.ts +1 -1
- package/src/__tests__/teleport.test.ts +1 -16
- package/src/__tests__/tunnel.test.ts +274 -82
- package/src/__tests__/uninstall.test.ts +141 -9
- package/src/__tests__/wake.test.ts +2 -2
- package/src/commands/backup.ts +3 -3
- package/src/commands/client.ts +4 -6
- package/src/commands/completions.test.ts +104 -0
- package/src/commands/completions.ts +87 -0
- package/src/commands/exec.ts +7 -7
- package/src/commands/flags.ts +2 -2
- package/src/commands/hatch.ts +5 -2
- package/src/commands/logs.ts +7 -7
- package/src/commands/pair.ts +140 -37
- package/src/commands/ps.ts +63 -26
- package/src/commands/restore.ts +2 -2
- package/src/commands/retire.ts +2 -2
- package/src/commands/rollback.ts +2 -2
- package/src/commands/sleep.ts +2 -2
- package/src/commands/teleport.ts +18 -37
- package/src/commands/terminal.ts +1 -1
- package/src/commands/tunnel.ts +101 -78
- package/src/commands/uninstall.ts +108 -73
- package/src/commands/upgrade.ts +8 -8
- package/src/commands/use.ts +1 -1
- package/src/commands/wake.ts +6 -6
- package/src/index.ts +15 -0
- package/src/lib/__tests__/docker.test.ts +168 -20
- package/src/lib/__tests__/job-polling.test.ts +1 -1
- package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
- package/src/lib/cloud-client.ts +0 -3
- package/src/lib/cloud-login.ts +0 -1
- package/src/lib/cloud-releases.ts +6 -4
- package/src/lib/color.ts +4 -0
- package/src/lib/docker.ts +190 -102
- package/src/lib/ensure-binary.ts +171 -0
- 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/flag-args.test.ts +1 -1
- package/src/lib/flag-args.ts +1 -1
- package/src/lib/guardian-token.ts +1 -1
- package/src/lib/hatch-local.ts +15 -8
- package/src/lib/hatch-next-steps.ts +5 -9
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local.ts +37 -4
- package/src/lib/nginx-ingress.ts +3 -5
- package/src/lib/pair.ts +1 -1
- package/src/lib/step-runner.ts +6 -1
- package/src/lib/sync-cloud-assistants.ts +2 -2
- package/src/lib/tunnels/cloudflare.ts +21 -11
- package/src/lib/tunnels/ngrok.ts +198 -108
- package/src/lib/tunnels/tailscale.ts +34 -4
- package/src/lib/upgrade-lifecycle.ts +5 -4
package/src/lib/tunnels/ngrok.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
|
13
13
|
|
|
14
14
|
import { GATEWAY_PORT } from "../constants.js";
|
|
15
15
|
import { clearRootProcess, stampRootProcess } from "../assistant-config.js";
|
|
16
|
+
import { ensureBinaryInstalled, readLineText } from "../ensure-binary.js";
|
|
16
17
|
import {
|
|
17
18
|
clearIngressUrl,
|
|
18
19
|
getDefaultWorkspaceDir,
|
|
@@ -81,6 +82,52 @@ export function getNgrokVersion(): string | null {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/** Whether captured agent output says the startup failure was authentication. */
|
|
86
|
+
export function isNgrokAuthError(output: string): boolean {
|
|
87
|
+
return /authtoken|authenticat|unauthorized|usage limit|ERR_NGROK_10\d\d|ERR_NGROK_40\d\d/i.test(
|
|
88
|
+
output,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An ngrok agent that exited during startup, with its captured output. */
|
|
93
|
+
export class NgrokStartupError extends Error {
|
|
94
|
+
readonly exitCode: number;
|
|
95
|
+
readonly output: string;
|
|
96
|
+
|
|
97
|
+
constructor(exitCode: number, output: string) {
|
|
98
|
+
super(`ngrok exited with code ${exitCode}`);
|
|
99
|
+
this.exitCode = exitCode;
|
|
100
|
+
this.output = output;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Paste-in attempts before the tunnel command gives up and says how. */
|
|
105
|
+
const MAX_AUTH_RETRIES = 2;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run `ngrok config add-authtoken <token>` — writing the user's default
|
|
109
|
+
* ngrok config, which is exactly the config the tunnel spawn loads. The
|
|
110
|
+
* token is never echoed: output is piped and any failure message has the
|
|
111
|
+
* token scrubbed.
|
|
112
|
+
*/
|
|
113
|
+
export function saveNgrokAuthToken(token: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
execFileSync("ngrok", ["config", "add-authtoken", token], {
|
|
116
|
+
encoding: "utf-8",
|
|
117
|
+
timeout: 10_000,
|
|
118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
});
|
|
121
|
+
return true;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
console.error(
|
|
125
|
+
`⚠ Couldn't save the authtoken: ${message.replaceAll(token, "***")}`,
|
|
126
|
+
);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
/**
|
|
85
132
|
* Query the ngrok local API for running tunnels.
|
|
86
133
|
* Returns the list of tunnels, or null if the API is unreachable.
|
|
@@ -159,11 +206,11 @@ type NgrokConfigFile = z.infer<typeof NgrokConfigFileSchema>;
|
|
|
159
206
|
type NgrokTunnelDef = z.infer<typeof NgrokTunnelDefSchema>;
|
|
160
207
|
|
|
161
208
|
/**
|
|
162
|
-
* The ngrok-format config file this CLI owns in the XDG config dir
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
209
|
+
* The ngrok-format config file this CLI owns in the XDG config dir. It no
|
|
210
|
+
* longer participates in spawning (plain `ngrok http` loads the user's
|
|
211
|
+
* default config, keeping their authtoken in play); it is the source of
|
|
212
|
+
* truth for the reserved domain at this layer — the workspace config no
|
|
213
|
+
* longer records one.
|
|
167
214
|
*/
|
|
168
215
|
export function ngrokConfigPath(): string {
|
|
169
216
|
return join(getConfigDir(getCurrentEnvironment()), "tunnels", "ngrok.yaml");
|
|
@@ -250,43 +297,34 @@ function urlMatchesDomain(publicUrl: string, domain: string): boolean {
|
|
|
250
297
|
}
|
|
251
298
|
}
|
|
252
299
|
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
function writeNgrokConfig(targetPort: number, domain?: string): void {
|
|
260
|
-
const config = readNgrokConfigFile();
|
|
261
|
-
const cadet: NgrokTunnelDef = {
|
|
262
|
-
...config.tunnels?.[NGROK_TUNNEL_NAME],
|
|
263
|
-
proto: "http",
|
|
264
|
-
// Explicit over a bare port, which ngrok expands to `localhost`, whose
|
|
265
|
-
// ::1 answer resolves first on macOS and races the edge's IPv4 bind.
|
|
266
|
-
addr: `127.0.0.1:${targetPort}`,
|
|
267
|
-
};
|
|
300
|
+
/** The argv for an ngrok agent forwarding the edge port, domain when set. */
|
|
301
|
+
export function buildNgrokSpawnArgs(
|
|
302
|
+
targetPort: number,
|
|
303
|
+
domain?: string,
|
|
304
|
+
): string[] {
|
|
305
|
+
const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
|
|
268
306
|
if (domain) {
|
|
269
|
-
|
|
307
|
+
args.push(`--domain=${domain}`);
|
|
270
308
|
}
|
|
271
|
-
|
|
272
|
-
tunnels[NGROK_TUNNEL_NAME] = cadet;
|
|
273
|
-
const next: NgrokConfigFile = { ...config, version: config.version ?? "2" };
|
|
274
|
-
next.tunnels = tunnels;
|
|
275
|
-
writeNgrokConfigFile(next);
|
|
309
|
+
return args;
|
|
276
310
|
}
|
|
277
311
|
|
|
278
312
|
/**
|
|
279
|
-
* Start an ngrok process tunneling HTTP traffic to the given local port
|
|
280
|
-
*
|
|
281
|
-
*
|
|
313
|
+
* Start an ngrok process tunneling HTTP traffic to the given local port.
|
|
314
|
+
*
|
|
315
|
+
* Plain `ngrok http` is spawned deliberately: ngrok only loads the user's
|
|
316
|
+
* default config — where `ngrok config add-authtoken` writes — when no
|
|
317
|
+
* `--config` is passed, so this is what keeps the authtoken in play. The
|
|
318
|
+
* explicit `127.0.0.1:` host avoids a bare port, which ngrok expands to
|
|
319
|
+
* `localhost`, whose ::1 answer resolves first on macOS and races the edge's
|
|
320
|
+
* IPv4 bind.
|
|
282
321
|
*
|
|
283
322
|
* When `logFilePath` is provided, stdout/stderr are redirected to that file
|
|
284
323
|
* instead of being piped. This avoids keeping pipe handles open in the
|
|
285
324
|
* parent process — which would either prevent the CLI from exiting (if
|
|
286
325
|
* handles are left open) or send SIGPIPE to ngrok (if destroyed).
|
|
287
326
|
*
|
|
288
|
-
* When `domain` is set, the spawned tunnel binds that reserved ngrok domain
|
|
289
|
-
* recorded in the XDG config alongside the loopback target.
|
|
327
|
+
* When `domain` is set, the spawned tunnel binds that reserved ngrok domain.
|
|
290
328
|
*
|
|
291
329
|
* Returns the spawned child process.
|
|
292
330
|
*/
|
|
@@ -304,15 +342,7 @@ export function startNgrokProcess(
|
|
|
304
342
|
stdio = ["ignore", fd, fd];
|
|
305
343
|
}
|
|
306
344
|
|
|
307
|
-
|
|
308
|
-
const args = [
|
|
309
|
-
"start",
|
|
310
|
-
NGROK_TUNNEL_NAME,
|
|
311
|
-
`--config=${ngrokConfigPath()}`,
|
|
312
|
-
"--log=stdout",
|
|
313
|
-
];
|
|
314
|
-
|
|
315
|
-
const child = spawn("ngrok", args, {
|
|
345
|
+
const child = spawn("ngrok", buildNgrokSpawnArgs(targetPort, domain), {
|
|
316
346
|
detached: true,
|
|
317
347
|
stdio,
|
|
318
348
|
windowsHide: true,
|
|
@@ -534,18 +564,26 @@ export interface RunNgrokTunnelOptions {
|
|
|
534
564
|
export async function runNgrokTunnel(
|
|
535
565
|
opts: RunNgrokTunnelOptions = {},
|
|
536
566
|
): Promise<ProviderTunnelResult> {
|
|
537
|
-
const version =
|
|
567
|
+
const version = await ensureBinaryInstalled({
|
|
568
|
+
binary: "ngrok",
|
|
569
|
+
purpose: "the ngrok tunnel",
|
|
570
|
+
packages: {
|
|
571
|
+
brew: ["brew", "install", "ngrok/ngrok/ngrok"],
|
|
572
|
+
snap: ["sudo", "snap", "install", "ngrok"],
|
|
573
|
+
choco: ["choco", "install", "-y", "ngrok"],
|
|
574
|
+
winget: [
|
|
575
|
+
"winget",
|
|
576
|
+
"install",
|
|
577
|
+
"--silent",
|
|
578
|
+
"--accept-package-agreements",
|
|
579
|
+
"--accept-source-agreements",
|
|
580
|
+
"ngrok.ngrok",
|
|
581
|
+
],
|
|
582
|
+
},
|
|
583
|
+
manualHint: "See https://ngrok.com/download for how to install",
|
|
584
|
+
probe: getNgrokVersion,
|
|
585
|
+
});
|
|
538
586
|
if (!version) {
|
|
539
|
-
console.error("Error: ngrok is not installed.");
|
|
540
|
-
console.error("");
|
|
541
|
-
console.error("Install ngrok:");
|
|
542
|
-
console.error(" macOS: brew install ngrok/ngrok/ngrok");
|
|
543
|
-
console.error(" Linux: sudo snap install ngrok");
|
|
544
|
-
console.error("");
|
|
545
|
-
console.error("Then authenticate: ngrok config add-authtoken <your-token>");
|
|
546
|
-
console.error(
|
|
547
|
-
" Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
548
|
-
);
|
|
549
587
|
process.exit(1);
|
|
550
588
|
}
|
|
551
589
|
|
|
@@ -607,12 +645,14 @@ export async function runNgrokTunnel(
|
|
|
607
645
|
console.log(`Starting ngrok tunnel to localhost:${port}...`);
|
|
608
646
|
|
|
609
647
|
let publicUrl: string | undefined;
|
|
610
|
-
|
|
611
|
-
|
|
648
|
+
// Points at the live agent across auth retries; signals and cleanup act on
|
|
649
|
+
// whichever attempt is current.
|
|
650
|
+
let currentNgrok: ChildProcess | null = null;
|
|
651
|
+
let authAttempts = 0;
|
|
612
652
|
|
|
613
653
|
const cleanup = () => {
|
|
614
|
-
if (!
|
|
615
|
-
|
|
654
|
+
if (currentNgrok && !currentNgrok.killed) {
|
|
655
|
+
currentNgrok.kill("SIGTERM");
|
|
616
656
|
}
|
|
617
657
|
clearRootProcess("tunnel");
|
|
618
658
|
if (publicUrl) {
|
|
@@ -630,66 +670,116 @@ export async function runNgrokTunnel(
|
|
|
630
670
|
process.exit(0);
|
|
631
671
|
});
|
|
632
672
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
});
|
|
673
|
+
while (publicUrl === undefined) {
|
|
674
|
+
const ngrokProcess = startNgrokProcess(port, undefined, domain);
|
|
675
|
+
currentNgrok = ngrokProcess;
|
|
637
676
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
);
|
|
677
|
+
// Captured per attempt so an authentication failure can be told apart
|
|
678
|
+
// from other startup failures and recovered with a pasted-in token.
|
|
679
|
+
const ngrokOutput: string[] = [];
|
|
680
|
+
|
|
681
|
+
ngrokProcess.on("error", (err: Error) => {
|
|
682
|
+
console.error(`ngrok process error: ${err.message}`);
|
|
644
683
|
process.exit(1);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Pipe ngrok stdout/stderr to console for visibility
|
|
687
|
+
ngrokProcess.stdout?.on("data", (data: Buffer) => {
|
|
688
|
+
const line = data.toString().trim();
|
|
689
|
+
if (line) {
|
|
690
|
+
ngrokOutput.push(line);
|
|
691
|
+
console.log(`[ngrok] ${line}`);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
ngrokProcess.stderr?.on("data", (data: Buffer) => {
|
|
695
|
+
const line = data.toString().trim();
|
|
696
|
+
if (line) {
|
|
697
|
+
ngrokOutput.push(line);
|
|
698
|
+
console.error(`[ngrok] ${line}`);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// An agent that exits during startup rejects this instead of leaving
|
|
703
|
+
// waitForNgrokUrl to poll a dead API for its full timeout.
|
|
704
|
+
const exitFailure = new Promise<never>((_, reject) => {
|
|
705
|
+
ngrokProcess.on("exit", (code: number | null) => {
|
|
706
|
+
if (code !== null && code !== 0) {
|
|
707
|
+
reject(new NgrokStartupError(code, ngrokOutput.join("\n")));
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
let url: string;
|
|
713
|
+
try {
|
|
714
|
+
url = await Promise.race([waitForNgrokUrl(port, domain), exitFailure]);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (err instanceof NgrokStartupError && isNgrokAuthError(err.output)) {
|
|
717
|
+
if (authAttempts >= MAX_AUTH_RETRIES) {
|
|
718
|
+
console.error("\nngrok still rejects the tunnel as unauthenticated.");
|
|
719
|
+
console.error(
|
|
720
|
+
"Authenticate manually: ngrok config add-authtoken <your-token>",
|
|
721
|
+
);
|
|
722
|
+
console.error(
|
|
723
|
+
" Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
724
|
+
);
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
console.log("");
|
|
728
|
+
console.log("⚠ ngrok rejected the tunnel: it isn't authenticated.");
|
|
729
|
+
console.log(
|
|
730
|
+
" Get your authtoken at: https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
731
|
+
);
|
|
732
|
+
const token = (
|
|
733
|
+
await readLineText(
|
|
734
|
+
"Paste your ngrok authtoken to continue (Enter to cancel): ",
|
|
735
|
+
)
|
|
736
|
+
).trim();
|
|
737
|
+
if (!token) {
|
|
738
|
+
console.log("No authtoken entered.");
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
authAttempts++;
|
|
742
|
+
if (saveNgrokAuthToken(token)) {
|
|
743
|
+
console.log("✅ ngrok authtoken saved. Retrying the tunnel...");
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
cleanup();
|
|
748
|
+
if (domain && !opts.domain) {
|
|
749
|
+
console.error(savedDomainRecoveryHint(domain));
|
|
750
|
+
}
|
|
751
|
+
throw err;
|
|
645
752
|
}
|
|
646
|
-
|
|
753
|
+
publicUrl = url;
|
|
647
754
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if (line) console.log(`[ngrok] ${line}`);
|
|
652
|
-
});
|
|
653
|
-
ngrokProcess.stderr?.on("data", (data: Buffer) => {
|
|
654
|
-
const line = data.toString().trim();
|
|
655
|
-
if (line) console.error(`[ngrok] ${line}`);
|
|
656
|
-
});
|
|
755
|
+
console.log("");
|
|
756
|
+
console.log(`Tunnel established: ${publicUrl}`);
|
|
757
|
+
console.log(`Forwarding to: localhost:${port}`);
|
|
657
758
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
759
|
+
// The domain is standing intent, not tunnel state: cleanup clears the
|
|
760
|
+
// ingress URL but leaves the domain saved for wake/daemon restores.
|
|
761
|
+
saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
|
|
762
|
+
if (ngrokProcess.pid) {
|
|
763
|
+
stampRootProcess("tunnel", {
|
|
764
|
+
pid: ngrokProcess.pid,
|
|
765
|
+
provider: "ngrok",
|
|
766
|
+
startedAt: new Date().toISOString(),
|
|
767
|
+
assistantId: opts.assistantId,
|
|
768
|
+
});
|
|
664
769
|
}
|
|
665
|
-
|
|
666
|
-
|
|
770
|
+
if (opts.domain) {
|
|
771
|
+
saveNgrokDomain(opts.domain);
|
|
772
|
+
}
|
|
773
|
+
console.log("Ingress URL saved to config.");
|
|
774
|
+
console.log("");
|
|
775
|
+
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
667
776
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
// The domain is standing intent, not tunnel state: cleanup clears the
|
|
673
|
-
// ingress URL but leaves the domain saved for wake/daemon restores.
|
|
674
|
-
saveIngressUrl(workspaceDir, publicUrl, opts.assistantId, "ngrok");
|
|
675
|
-
if (ngrokProcess.pid) {
|
|
676
|
-
stampRootProcess("tunnel", {
|
|
677
|
-
pid: ngrokProcess.pid,
|
|
678
|
-
provider: "ngrok",
|
|
679
|
-
startedAt: new Date().toISOString(),
|
|
680
|
-
assistantId: opts.assistantId,
|
|
777
|
+
// Keep running until the ngrok process exits or we receive a signal
|
|
778
|
+
const waitForExit = new Promise<void>((resolve) => {
|
|
779
|
+
ngrokProcess.on("exit", () => resolve());
|
|
681
780
|
});
|
|
781
|
+
return { publicUrl, waitForExit };
|
|
682
782
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
console.log("Ingress URL saved to config.");
|
|
687
|
-
console.log("");
|
|
688
|
-
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
689
|
-
|
|
690
|
-
// Keep running until the ngrok process exits or we receive a signal
|
|
691
|
-
const waitForExit = new Promise<void>((resolve) => {
|
|
692
|
-
ngrokProcess.on("exit", () => resolve());
|
|
693
|
-
});
|
|
694
|
-
return { publicUrl, waitForExit };
|
|
783
|
+
// Unreachable: the loop only exits via return, process.exit, or a throw.
|
|
784
|
+
throw new Error("ngrok tunnel did not start.");
|
|
695
785
|
}
|
|
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { win32 } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { GATEWAY_PORT } from "../constants.js";
|
|
7
|
+
import { ensureBinaryInstalled } from "../ensure-binary.js";
|
|
7
8
|
import {
|
|
8
9
|
clearIngressUrl,
|
|
9
10
|
getDefaultWorkspaceDir,
|
|
@@ -54,7 +55,7 @@ export function getTailscaleInstallMessage(
|
|
|
54
55
|
hostPlatform === "win32"
|
|
55
56
|
? " Windows: winget install Tailscale.Tailscale"
|
|
56
57
|
: hostPlatform === "darwin"
|
|
57
|
-
? " macOS: brew install tailscale
|
|
58
|
+
? " macOS: brew install tailscale, or install the Tailscale app"
|
|
58
59
|
: " Linux: https://tailscale.com/download/linux";
|
|
59
60
|
return [
|
|
60
61
|
"Tailscale is not installed or could not be found.",
|
|
@@ -79,12 +80,12 @@ export interface TailscaleCommandResult {
|
|
|
79
80
|
*/
|
|
80
81
|
export interface TailscaleDeps {
|
|
81
82
|
/** Locate the tailscale CLI; returns the invocable path, null if absent. */
|
|
82
|
-
findBinary(): string | null
|
|
83
|
+
findBinary(): Promise<string | null>;
|
|
83
84
|
/** Run `<bin> <args>` synchronously (spawnSync semantics). */
|
|
84
85
|
run(bin: string, args: string[]): TailscaleCommandResult;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function
|
|
88
|
+
function probeTailscaleBinary(): string | null {
|
|
88
89
|
for (const candidate of getTailscaleBinaryCandidates()) {
|
|
89
90
|
const res = spawnSync(candidate, ["version"], {
|
|
90
91
|
encoding: "utf-8",
|
|
@@ -99,6 +100,35 @@ function realFindBinary(): string | null {
|
|
|
99
100
|
return null;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The real binary finder offers to install tailscale when it is missing
|
|
105
|
+
* (prompt-install via the shared helper; no-op hint on a non-interactive
|
|
106
|
+
* terminal) and re-probes after.
|
|
107
|
+
*/
|
|
108
|
+
async function realFindBinary(): Promise<string | null> {
|
|
109
|
+
const found = probeTailscaleBinary();
|
|
110
|
+
if (found) return found;
|
|
111
|
+
await ensureBinaryInstalled({
|
|
112
|
+
binary: "tailscale",
|
|
113
|
+
purpose: "the tailscale tunnel",
|
|
114
|
+
packages: {
|
|
115
|
+
brew: ["brew", "install", "tailscale"],
|
|
116
|
+
winget: [
|
|
117
|
+
"winget",
|
|
118
|
+
"install",
|
|
119
|
+
"--silent",
|
|
120
|
+
"--accept-package-agreements",
|
|
121
|
+
"--accept-source-agreements",
|
|
122
|
+
"Tailscale.Tailscale",
|
|
123
|
+
],
|
|
124
|
+
choco: ["choco", "install", "-y", "tailscale"],
|
|
125
|
+
},
|
|
126
|
+
manualHint: getTailscaleInstallMessage(),
|
|
127
|
+
probe: () => probeTailscaleBinary(),
|
|
128
|
+
});
|
|
129
|
+
return probeTailscaleBinary();
|
|
130
|
+
}
|
|
131
|
+
|
|
102
132
|
function realRun(bin: string, args: string[]): TailscaleCommandResult {
|
|
103
133
|
const res = spawnSync(bin, args, {
|
|
104
134
|
encoding: "utf-8",
|
|
@@ -292,7 +322,7 @@ export async function startTailscaleServe(
|
|
|
292
322
|
opts: RunTailscaleTunnelOptions = {},
|
|
293
323
|
deps: TailscaleDeps = realTailscaleDeps(),
|
|
294
324
|
): Promise<TailscaleServeInfo> {
|
|
295
|
-
const binary = deps.findBinary();
|
|
325
|
+
const binary = await deps.findBinary();
|
|
296
326
|
if (!binary) {
|
|
297
327
|
throw new Error(getTailscaleInstallMessage());
|
|
298
328
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
dockerResourceNames,
|
|
18
18
|
GATEWAY_INTERNAL_PORT,
|
|
19
19
|
ASSISTANT_INTERNAL_PORT,
|
|
20
|
+
pullImage,
|
|
20
21
|
startContainers,
|
|
21
22
|
stopContainers,
|
|
22
23
|
} from "./docker.js";
|
|
@@ -40,7 +41,7 @@ import {
|
|
|
40
41
|
type DockerStatefulSetSpec,
|
|
41
42
|
type ServiceName,
|
|
42
43
|
} from "./statefulset.js";
|
|
43
|
-
import {
|
|
44
|
+
import { execOutput } from "./step-runner.js";
|
|
44
45
|
import { compareVersions, stripVersionPrefix } from "./version-compat.js";
|
|
45
46
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
46
47
|
|
|
@@ -418,7 +419,7 @@ export async function waitForReady(runtimeUrl: string): Promise<boolean> {
|
|
|
418
419
|
const parts = [body?.status].filter(Boolean);
|
|
419
420
|
if (body?.upstream != null) parts.push(`upstream=${body.upstream}`);
|
|
420
421
|
const detail = parts.length > 0 ? ` — ${parts.join(", ")}` : "";
|
|
421
|
-
console.log(`Readiness check: ${resp.status}${detail}
|
|
422
|
+
console.log(`Readiness check: ${resp.status}${detail}, retrying...`);
|
|
422
423
|
} catch {
|
|
423
424
|
// Connection refused / timeout — not up yet
|
|
424
425
|
}
|
|
@@ -754,7 +755,7 @@ export async function performDockerRollback(
|
|
|
754
755
|
console.log(` Backup saved: ${preRollbackBackupPath}\n`);
|
|
755
756
|
pruneOldBackups(entry.assistantId, 3);
|
|
756
757
|
} else {
|
|
757
|
-
console.warn("⚠️ Pre-rollback backup failed
|
|
758
|
+
console.warn("⚠️ Pre-rollback backup failed, continuing with rollback\n");
|
|
758
759
|
}
|
|
759
760
|
|
|
760
761
|
const {
|
|
@@ -806,7 +807,7 @@ export async function performDockerRollback(
|
|
|
806
807
|
try {
|
|
807
808
|
for (const [service, image] of pullImages) {
|
|
808
809
|
console.log(` Pulling ${service}: ${image}`);
|
|
809
|
-
await
|
|
810
|
+
await pullImage(image);
|
|
810
811
|
}
|
|
811
812
|
} catch (pullErr) {
|
|
812
813
|
const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);
|