@mycadet/cli 0.12.6-dev.202610082233.54244ee → 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/eslint.config.mjs +7 -0
- package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
- package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
- package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
- package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
- package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
- package/package.json +4 -3
- package/src/__tests__/assistant-config.test.ts +30 -28
- package/src/__tests__/debug.test.ts +8 -8
- package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
- package/src/__tests__/flags.test.ts +2 -2
- package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
- package/src/__tests__/ingress-config.test.ts +2 -2
- package/src/__tests__/login-loopback.test.ts +1 -1
- package/src/__tests__/nginx-ingress.test.ts +68 -76
- package/src/__tests__/pair.test.ts +135 -121
- package/src/__tests__/platform-client.test.ts +2 -2
- package/src/__tests__/recover.test.ts +96 -3
- package/src/__tests__/retire.test.ts +5 -3
- package/src/__tests__/signing-key-continuity.test.ts +261 -0
- package/src/__tests__/sleep.test.ts +189 -6
- package/src/__tests__/tailscale-tunnel.test.ts +1 -1
- package/src/__tests__/uninstall.test.ts +171 -0
- package/src/__tests__/upgrade-local.test.ts +16 -3
- package/src/__tests__/use.test.ts +2 -2
- package/src/__tests__/wake-error.test.ts +76 -0
- package/src/__tests__/wake.test.ts +309 -36
- package/src/commands/backup.ts +6 -2
- package/src/commands/clean.ts +6 -2
- package/src/commands/client.ts +91 -41
- package/src/commands/confirm.ts +6 -2
- package/src/commands/connect.ts +6 -2
- package/src/commands/debug.ts +6 -2
- package/src/commands/devices.ts +6 -2
- package/src/commands/events.ts +6 -2
- package/src/commands/exec.ts +50 -12
- package/src/commands/flags.ts +6 -2
- package/src/commands/gateway.ts +6 -2
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +26 -356
- package/src/commands/logs.ts +6 -2
- package/src/commands/message.ts +6 -2
- package/src/commands/pair.ts +6 -2
- package/src/commands/ps.ts +7 -2
- package/src/commands/recover.ts +21 -16
- package/src/commands/restore.ts +6 -2
- package/src/commands/retire.ts +6 -2
- package/src/commands/roadmap.ts +6 -2
- package/src/commands/rollback.ts +6 -2
- package/src/commands/setup.ts +76 -9
- package/src/commands/sleep.ts +131 -31
- package/src/commands/ssh.ts +6 -2
- package/src/commands/teleport.ts +6 -2
- package/src/commands/terminal.ts +6 -2
- package/src/commands/tunnel.ts +6 -2
- package/src/commands/uninstall.ts +228 -0
- package/src/commands/unpair.ts +6 -2
- package/src/commands/upgrade.ts +13 -8
- package/src/commands/use.ts +6 -2
- package/src/commands/voice.ts +6 -2
- package/src/commands/wake.ts +69 -37
- package/src/commands/workflows.ts +6 -2
- package/src/components/DefaultMainScreen.tsx +250 -36
- package/src/index.ts +23 -55
- package/src/lib/assistant-config.ts +27 -13
- package/src/lib/backup-ops.ts +3 -4
- package/src/lib/cli-error.ts +5 -0
- package/src/lib/client-identity.ts +8 -10
- package/src/lib/cloud-login.ts +422 -0
- package/src/lib/color.ts +16 -0
- package/src/lib/default-main-screen-formatting.ts +32 -8
- package/src/lib/environments/__tests__/paths.test.ts +9 -11
- package/src/lib/environments/paths.ts +2 -20
- package/src/lib/guardian-token.ts +72 -18
- package/src/lib/hatch-local.ts +52 -1
- package/src/lib/health-check.ts +24 -15
- package/src/lib/lifecycle-reporter.ts +9 -1
- package/src/lib/local-hatch-lock.ts +19 -27
- package/src/lib/local-runtime-client.ts +25 -14
- package/src/lib/local.ts +17 -43
- package/src/lib/ngrok.ts +48 -16
- 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 +23 -9
- package/src/lib/terminal-client.ts +21 -9
- package/src/lib/tunnel-edge.ts +11 -13
- package/src/lib/upgrade-lifecycle.ts +16 -4
- package/src/lib/workos-pkce.ts +52 -21
|
@@ -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<
|
|
@@ -144,6 +144,10 @@ export type AssistantEntry = Omit<
|
|
|
144
144
|
previousDbMigrationVersion?: number;
|
|
145
145
|
/** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
|
|
146
146
|
previousWorkspaceMigrationId?: string;
|
|
147
|
+
/** Client-minted uuid (web's temp-client-id pattern) the CLI addresses this
|
|
148
|
+
* assistant's chat conversation by. Minted once on first `cadet client`
|
|
149
|
+
* launch and persisted here so every session shares one conversation. */
|
|
150
|
+
conversationKey?: string;
|
|
147
151
|
[key: string]: unknown;
|
|
148
152
|
};
|
|
149
153
|
|
|
@@ -326,7 +330,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
326
330
|
return mutated;
|
|
327
331
|
}
|
|
328
332
|
|
|
329
|
-
function
|
|
333
|
+
export function loadAllAssistants(): AssistantEntry[] {
|
|
330
334
|
const data = readLockfile();
|
|
331
335
|
const entries = data.assistants;
|
|
332
336
|
if (!Array.isArray(entries)) {
|
|
@@ -380,7 +384,7 @@ function writeAssistants(entries: AssistantEntry[]): void {
|
|
|
380
384
|
}
|
|
381
385
|
|
|
382
386
|
export function loadLatestAssistant(): AssistantEntry | null {
|
|
383
|
-
const entries =
|
|
387
|
+
const entries = loadAllAssistants();
|
|
384
388
|
if (entries.length === 0) {
|
|
385
389
|
return null;
|
|
386
390
|
}
|
|
@@ -393,7 +397,7 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
393
397
|
}
|
|
394
398
|
|
|
395
399
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
396
|
-
return
|
|
400
|
+
return loadAllAssistants().find((entry) => entry.assistantId === name) ?? null;
|
|
397
401
|
}
|
|
398
402
|
|
|
399
403
|
export function getAssistantDisplayName(entry: AssistantEntry): string {
|
|
@@ -426,7 +430,7 @@ function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
|
|
|
426
430
|
export function lookupAssistantByIdentifier(
|
|
427
431
|
identifier: string,
|
|
428
432
|
): AssistantLookupResult {
|
|
429
|
-
const entries =
|
|
433
|
+
const entries = loadAllAssistants();
|
|
430
434
|
const exactId = entries.find((entry) => entry.assistantId === identifier);
|
|
431
435
|
if (exactId) {
|
|
432
436
|
return { status: "found", entry: exactId };
|
|
@@ -479,10 +483,6 @@ export function removeAssistantEntry(assistantId: string): void {
|
|
|
479
483
|
});
|
|
480
484
|
}
|
|
481
485
|
|
|
482
|
-
export function loadAllAssistants(): AssistantEntry[] {
|
|
483
|
-
return readAssistants();
|
|
484
|
-
}
|
|
485
|
-
|
|
486
486
|
/**
|
|
487
487
|
* Read the first existing lockfile for an explicitly-provided environment,
|
|
488
488
|
* without applying legacy migrations. This is the cross-env read path used by
|
|
@@ -581,7 +581,7 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
|
|
|
581
581
|
// Active assistant no longer exists in lockfile — fall through
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
-
const all =
|
|
584
|
+
const all = loadAllAssistants();
|
|
585
585
|
if (all.length === 1) return all[0];
|
|
586
586
|
|
|
587
587
|
return null;
|
|
@@ -613,7 +613,7 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
|
613
613
|
// Active assistant no longer exists in lockfile — fall through.
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
const all =
|
|
616
|
+
const all = loadAllAssistants();
|
|
617
617
|
if (all.length === 1) return all[0];
|
|
618
618
|
|
|
619
619
|
if (all.length === 0) {
|
|
@@ -644,7 +644,7 @@ export function extractHostFromUrl(url: string): string {
|
|
|
644
644
|
|
|
645
645
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
646
646
|
withCliLockfileLock(() => {
|
|
647
|
-
const entries =
|
|
647
|
+
const entries = loadAllAssistants().filter(
|
|
648
648
|
(e) => e.assistantId !== entry.assistantId,
|
|
649
649
|
);
|
|
650
650
|
entries.unshift(entry);
|
|
@@ -652,6 +652,20 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
|
652
652
|
});
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
+
/**
|
|
656
|
+
* The client-minted uuid this CLI addresses the assistant's chat
|
|
657
|
+
* conversation by (web's temp-client-id pattern: clients/web
|
|
658
|
+
* document-conversation mints a uuid, persists it, and passes it as the
|
|
659
|
+
* conversation key so creation is retry-safe). Minted once and persisted
|
|
660
|
+
* on the entry so every session shares one conversation.
|
|
661
|
+
*/
|
|
662
|
+
export function resolveConversationKey(entry: AssistantEntry): string {
|
|
663
|
+
if (entry.conversationKey) return entry.conversationKey;
|
|
664
|
+
const minted = crypto.randomUUID();
|
|
665
|
+
saveAssistantEntry({ ...entry, conversationKey: minted });
|
|
666
|
+
return minted;
|
|
667
|
+
}
|
|
668
|
+
|
|
655
669
|
/**
|
|
656
670
|
* Scan upward from `basePort` to find an available port. A port is considered
|
|
657
671
|
* available when `probePort()` returns false (nothing listening). Scans up to
|
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 */
|
package/src/lib/cli-error.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SigningKeyContinuityError } from "@mycadet/local-mode";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Structured CLI error reporting for upgrade/rollback commands.
|
|
3
5
|
*
|
|
@@ -17,6 +19,7 @@ export type CliErrorCategory =
|
|
|
17
19
|
| "ROLLBACK_FAILED"
|
|
18
20
|
| "ROLLBACK_NO_STATE"
|
|
19
21
|
| "VERSION_DIRECTION"
|
|
22
|
+
| "AUTH_IDENTITY_UNAVAILABLE"
|
|
20
23
|
| "AUTH_FAILED"
|
|
21
24
|
| "NETWORK_ERROR"
|
|
22
25
|
| "UNSUPPORTED_TOPOLOGY"
|
|
@@ -52,6 +55,8 @@ export function emitCliError(
|
|
|
52
55
|
* {@link CliErrorCategory} for common upgrade/rollback failures.
|
|
53
56
|
*/
|
|
54
57
|
export function categorizeUpgradeError(err: unknown): CliErrorCategory {
|
|
58
|
+
if (err instanceof SigningKeyContinuityError)
|
|
59
|
+
return "AUTH_IDENTITY_UNAVAILABLE";
|
|
55
60
|
const msg = String(err).toLowerCase();
|
|
56
61
|
|
|
57
62
|
if (
|
|
@@ -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 */
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cadet Cloud login session machinery — browser PKCE flow, loopback callback,
|
|
3
|
+
* and the post-login local-assistant registration/injection half of the
|
|
4
|
+
* desktop app's LocalAssistantBootstrapService flow. Shared by the
|
|
5
|
+
* `cadet login` command and `cadet hatch` (which completes a Cadet Cloud
|
|
6
|
+
* login when the user picks that provider).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from "crypto";
|
|
10
|
+
import { createServer } from "http";
|
|
11
|
+
import type { AddressInfo } from "net";
|
|
12
|
+
|
|
13
|
+
import { isManagedCloud } from "./managed-cloud.js";
|
|
14
|
+
import {
|
|
15
|
+
resolveAssistant,
|
|
16
|
+
saveAssistantEntry,
|
|
17
|
+
} from "./assistant-config.js";
|
|
18
|
+
import { resolveAssistantApiKeyForInjection } from "./assistant-api-key-resolution.js";
|
|
19
|
+
import { computeDeviceId } from "./guardian-token.js";
|
|
20
|
+
import { openBrowser } from "./open-browser.js";
|
|
21
|
+
import {
|
|
22
|
+
fetchCurrentUser,
|
|
23
|
+
fetchOrganizationId,
|
|
24
|
+
ensureSelfHostedLocalRegistration,
|
|
25
|
+
getPlatformUrl,
|
|
26
|
+
injectCredentialsIntoAssistant,
|
|
27
|
+
readPlatformToken,
|
|
28
|
+
savePlatformToken,
|
|
29
|
+
} from "./platform-client.js";
|
|
30
|
+
import { fetchAssistantIngressUrl, fetchCurrentVersion } from "./upgrade-lifecycle.js";
|
|
31
|
+
import {
|
|
32
|
+
CALLBACK_PATH,
|
|
33
|
+
buildAuthorizeUrl,
|
|
34
|
+
exchangeAccessTokenForSession,
|
|
35
|
+
exchangeCodeWithWorkos,
|
|
36
|
+
fetchWorkosClientId,
|
|
37
|
+
generatePkcePair,
|
|
38
|
+
} from "./workos-pkce.js";
|
|
39
|
+
|
|
40
|
+
const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
|
|
41
|
+
|
|
42
|
+
function escapeHtml(s: string): string {
|
|
43
|
+
return s
|
|
44
|
+
.replace(/&/g, "&")
|
|
45
|
+
.replace(/</g, "<")
|
|
46
|
+
.replace(/>/g, ">")
|
|
47
|
+
.replace(/"/g, """)
|
|
48
|
+
.replace(/'/g, "'");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderLoginPage(
|
|
52
|
+
title: string,
|
|
53
|
+
subtitle: string,
|
|
54
|
+
success: boolean,
|
|
55
|
+
): string {
|
|
56
|
+
const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
57
|
+
<circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
|
|
58
|
+
<path class="check" d="M17 28.5L24.5 36L39 21" stroke="var(--positive-fg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
59
|
+
</svg>`;
|
|
60
|
+
|
|
61
|
+
const errorSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
62
|
+
<circle cx="28" cy="28" r="28" fill="var(--negative-bg)"/>
|
|
63
|
+
<path class="cross cross-1" d="M20 20L36 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
|
64
|
+
<path class="cross cross-2" d="M36 20L20 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
|
65
|
+
</svg>`;
|
|
66
|
+
|
|
67
|
+
return `<!DOCTYPE html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<title>${escapeHtml(title)}</title>
|
|
73
|
+
<style>
|
|
74
|
+
:root {
|
|
75
|
+
--surface: #F5F3EB;
|
|
76
|
+
--surface-card: #FFFFFF;
|
|
77
|
+
--card-border: #E8E6DA;
|
|
78
|
+
--text-primary: #2A2A28;
|
|
79
|
+
--text-secondary: #4A4A46;
|
|
80
|
+
--positive-bg: #D4DFD0;
|
|
81
|
+
--positive-fg: #516748;
|
|
82
|
+
--negative-bg: #F7DAC9;
|
|
83
|
+
--negative-fg: #DA491A;
|
|
84
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.06);
|
|
85
|
+
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
86
|
+
}
|
|
87
|
+
@media (prefers-color-scheme: dark) {
|
|
88
|
+
:root {
|
|
89
|
+
--surface: #1A1A18;
|
|
90
|
+
--surface-card: #2A2A28;
|
|
91
|
+
--card-border: #3A3A37;
|
|
92
|
+
--text-primary: #F5F3EB;
|
|
93
|
+
--text-secondary: #BDB9A9;
|
|
94
|
+
--positive-bg: #1A2316;
|
|
95
|
+
--positive-fg: #7A8B6F;
|
|
96
|
+
--negative-bg: #4E281D;
|
|
97
|
+
--negative-fg: #E86B40;
|
|
98
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
102
|
+
body {
|
|
103
|
+
font-family: var(--font);
|
|
104
|
+
background: var(--surface);
|
|
105
|
+
color: var(--text-primary);
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
min-height: 100vh;
|
|
110
|
+
-webkit-font-smoothing: antialiased;
|
|
111
|
+
}
|
|
112
|
+
.card {
|
|
113
|
+
text-align: center;
|
|
114
|
+
padding: 48px 40px 40px;
|
|
115
|
+
background: var(--surface-card);
|
|
116
|
+
border: 1px solid var(--card-border);
|
|
117
|
+
border-radius: 16px;
|
|
118
|
+
box-shadow: var(--shadow);
|
|
119
|
+
max-width: 380px;
|
|
120
|
+
width: 100%;
|
|
121
|
+
opacity: 0;
|
|
122
|
+
transform: translateY(8px) scale(0.98);
|
|
123
|
+
animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
|
124
|
+
}
|
|
125
|
+
@keyframes cardIn {
|
|
126
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
127
|
+
}
|
|
128
|
+
.icon {
|
|
129
|
+
width: 56px;
|
|
130
|
+
height: 56px;
|
|
131
|
+
margin-bottom: 20px;
|
|
132
|
+
}
|
|
133
|
+
.check {
|
|
134
|
+
stroke-dasharray: 32;
|
|
135
|
+
stroke-dashoffset: 32;
|
|
136
|
+
animation: draw 0.4s ease-out 0.45s forwards;
|
|
137
|
+
}
|
|
138
|
+
.cross {
|
|
139
|
+
stroke-dasharray: 22;
|
|
140
|
+
stroke-dashoffset: 22;
|
|
141
|
+
}
|
|
142
|
+
.cross-1 { animation: draw 0.3s ease-out 0.45s forwards; }
|
|
143
|
+
.cross-2 { animation: draw 0.3s ease-out 0.55s forwards; }
|
|
144
|
+
@keyframes draw {
|
|
145
|
+
to { stroke-dashoffset: 0; }
|
|
146
|
+
}
|
|
147
|
+
h1 {
|
|
148
|
+
font-size: 18px;
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
letter-spacing: -0.2px;
|
|
151
|
+
color: var(--text-primary);
|
|
152
|
+
margin-bottom: 6px;
|
|
153
|
+
}
|
|
154
|
+
p {
|
|
155
|
+
font-size: 13px;
|
|
156
|
+
line-height: 1.5;
|
|
157
|
+
color: var(--text-secondary);
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
160
|
+
</head>
|
|
161
|
+
<body>
|
|
162
|
+
<div class="card">
|
|
163
|
+
${success ? checkmarkSvg : errorSvg}
|
|
164
|
+
<h1>${escapeHtml(title)}</h1>
|
|
165
|
+
<p>${escapeHtml(subtitle)}</p>
|
|
166
|
+
</div>
|
|
167
|
+
</body>
|
|
168
|
+
</html>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface LoopbackListener {
|
|
172
|
+
/** The full `http://127.0.0.1:<port>/auth/callback` redirect URI. */
|
|
173
|
+
redirectUri: string;
|
|
174
|
+
/** Resolves with the authorization code once the state-matched callback arrives. */
|
|
175
|
+
waitForCode: Promise<string>;
|
|
176
|
+
/** Tear down the server, rejecting any pending waiter with `reason`. */
|
|
177
|
+
close: (reason?: string) => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bind an ephemeral 127.0.0.1 listener and wait for the OAuth redirect.
|
|
182
|
+
* Exported for tests; production callers go through `workosPkceLogin`.
|
|
183
|
+
*/
|
|
184
|
+
export function startLoopbackListener(
|
|
185
|
+
expectedState: string,
|
|
186
|
+
): Promise<LoopbackListener> {
|
|
187
|
+
return new Promise((resolveListener, rejectListener) => {
|
|
188
|
+
let settle: {
|
|
189
|
+
resolve: (code: string) => void;
|
|
190
|
+
reject: (err: Error) => void;
|
|
191
|
+
};
|
|
192
|
+
const waitForCode = new Promise<string>((resolve, reject) => {
|
|
193
|
+
settle = { resolve, reject };
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const server = createServer((req, res) => {
|
|
197
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
198
|
+
if (
|
|
199
|
+
url.pathname !== CALLBACK_PATH ||
|
|
200
|
+
url.searchParams.get("state") !== expectedState
|
|
201
|
+
) {
|
|
202
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
203
|
+
res.end("Not found");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const error = url.searchParams.get("error");
|
|
208
|
+
const code = url.searchParams.get("code");
|
|
209
|
+
if (error || !code) {
|
|
210
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
211
|
+
res.end(
|
|
212
|
+
renderLoginPage(
|
|
213
|
+
"Login Failed",
|
|
214
|
+
"Please try again from your terminal.",
|
|
215
|
+
false,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
server.close();
|
|
219
|
+
settle.reject(
|
|
220
|
+
new Error(
|
|
221
|
+
`Authentication failed: ${error ?? "no authorization code received"}`,
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
228
|
+
res.end(
|
|
229
|
+
renderLoginPage(
|
|
230
|
+
"Login Successful",
|
|
231
|
+
"You can close this window and return to your terminal.",
|
|
232
|
+
true,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
server.close();
|
|
236
|
+
settle.resolve(code);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
server.on("error", rejectListener);
|
|
240
|
+
server.listen(0, "127.0.0.1", () => {
|
|
241
|
+
const addr = server.address();
|
|
242
|
+
if (!addr || typeof addr === "string") {
|
|
243
|
+
rejectListener(new Error("Failed to start local server."));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const { port } = addr as AddressInfo;
|
|
247
|
+
resolveListener({
|
|
248
|
+
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
|
|
249
|
+
waitForCode,
|
|
250
|
+
close: (reason?: string) => {
|
|
251
|
+
server.close();
|
|
252
|
+
settle.reject(new Error(reason ?? "Login cancelled."));
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** App-held WorkOS PKCE login */
|
|
260
|
+
export async function workosPkceLogin(platformUrl: string): Promise<string> {
|
|
261
|
+
const clientId = await fetchWorkosClientId(platformUrl);
|
|
262
|
+
const { verifier, challenge } = generatePkcePair();
|
|
263
|
+
const state = randomBytes(32).toString("hex");
|
|
264
|
+
|
|
265
|
+
const listener = await startLoopbackListener(state);
|
|
266
|
+
const timeout = setTimeout(() => {
|
|
267
|
+
listener.close("Login timed out. Please try again.");
|
|
268
|
+
}, LOGIN_TIMEOUT_MS);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
272
|
+
clientId,
|
|
273
|
+
redirectUri: listener.redirectUri,
|
|
274
|
+
challenge,
|
|
275
|
+
state,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
console.log("Opening browser for login...");
|
|
279
|
+
console.log(`If the browser doesn't open, visit: ${authorizeUrl}`);
|
|
280
|
+
openBrowser(authorizeUrl);
|
|
281
|
+
|
|
282
|
+
const code = await listener.waitForCode;
|
|
283
|
+
const accessToken = await exchangeCodeWithWorkos({
|
|
284
|
+
clientId,
|
|
285
|
+
code,
|
|
286
|
+
verifier,
|
|
287
|
+
});
|
|
288
|
+
return await exchangeAccessTokenForSession(
|
|
289
|
+
platformUrl,
|
|
290
|
+
clientId,
|
|
291
|
+
accessToken,
|
|
292
|
+
);
|
|
293
|
+
} finally {
|
|
294
|
+
clearTimeout(timeout);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface CloudLoginSession {
|
|
299
|
+
token: string;
|
|
300
|
+
user: { id: string; email: string };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Interactive Cadet Cloud login, reusable beyond `cadet login` (e.g. `cadet
|
|
305
|
+
* hatch` when the user picks the Cadet Cloud provider). Returns the stored
|
|
306
|
+
* session when one is already valid; otherwise runs the browser PKCE flow,
|
|
307
|
+
* validates the resulting token, and persists it.
|
|
308
|
+
*/
|
|
309
|
+
export async function ensureCloudLoginInteractive(): Promise<CloudLoginSession> {
|
|
310
|
+
const existingToken = readPlatformToken();
|
|
311
|
+
if (existingToken) {
|
|
312
|
+
try {
|
|
313
|
+
const user = await fetchCurrentUser(existingToken);
|
|
314
|
+
return { token: existingToken, user };
|
|
315
|
+
} catch {
|
|
316
|
+
// Stale/invalid — fall through to a fresh browser login.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const token = await workosPkceLogin(getPlatformUrl());
|
|
321
|
+
const user = await fetchCurrentUser(token);
|
|
322
|
+
savePlatformToken(token);
|
|
323
|
+
return { token, user };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* TLS failures surface as opaque messages like "unknown certificate
|
|
328
|
+
* verification error" from Bun's fetch. Translate the recognizable ones into
|
|
329
|
+
* something actionable — most commonly the platform host's managed
|
|
330
|
+
* certificate is still provisioning.
|
|
331
|
+
*/
|
|
332
|
+
export function describeLoginError(error: unknown): string {
|
|
333
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
334
|
+
const tlsish = /certificate|tls|ssl|unable to verify|self-signed/i.test(raw);
|
|
335
|
+
if (!tlsish) return raw;
|
|
336
|
+
return (
|
|
337
|
+
`Cadet Cloud is unreachable (${raw}). This usually means ` +
|
|
338
|
+
`${getPlatformUrl()}'s TLS certificate is missing or still provisioning. ` +
|
|
339
|
+
"Retry in a few minutes, or point CADET_PLATFORM_URL at a reachable host."
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Register the active self-hosted assistant with the platform and inject
|
|
345
|
+
* cloud credentials into its runtime — the post-login half of the desktop
|
|
346
|
+
* app's LocalAssistantBootstrapService flow. Extracted so `cadet hatch` can
|
|
347
|
+
* complete the same flow right after an interactive login on the Cadet Cloud
|
|
348
|
+
* provider path. Non-fatal by design; callers log failures.
|
|
349
|
+
*/
|
|
350
|
+
export async function registerLocalAssistantWithPlatform(
|
|
351
|
+
token: string,
|
|
352
|
+
user: { id: string; email: string },
|
|
353
|
+
options: { bearerToken?: string; log?: (message: string) => void } = {},
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
const log = options.log ?? console.log;
|
|
356
|
+
const entry = resolveAssistant();
|
|
357
|
+
|
|
358
|
+
// Skip managed ("cadet") assistants — they are handled by the platform.
|
|
359
|
+
if (!entry || isManagedCloud(entry.cloud)) return;
|
|
360
|
+
|
|
361
|
+
const orgId = await fetchOrganizationId(token);
|
|
362
|
+
const clientInstallationId = computeDeviceId();
|
|
363
|
+
const [assistantVersion, ingressUrl] = await Promise.all([
|
|
364
|
+
fetchCurrentVersion(entry.runtimeUrl),
|
|
365
|
+
fetchAssistantIngressUrl(entry.runtimeUrl, options.bearerToken ?? entry.bearerToken),
|
|
366
|
+
]);
|
|
367
|
+
const platformUrl = getPlatformUrl();
|
|
368
|
+
const registration = await ensureSelfHostedLocalRegistration(
|
|
369
|
+
token,
|
|
370
|
+
orgId,
|
|
371
|
+
clientInstallationId,
|
|
372
|
+
entry.assistantId,
|
|
373
|
+
"cli",
|
|
374
|
+
assistantVersion,
|
|
375
|
+
platformUrl,
|
|
376
|
+
ingressUrl,
|
|
377
|
+
);
|
|
378
|
+
saveAssistantEntry({
|
|
379
|
+
...entry,
|
|
380
|
+
platformAssistantId: registration.assistant.id,
|
|
381
|
+
platformBaseUrl: platformUrl,
|
|
382
|
+
platformOrganizationId: orgId,
|
|
383
|
+
});
|
|
384
|
+
log(
|
|
385
|
+
`Registered assistant: ${registration.assistant.name} (${registration.assistant.id})`,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Which key to hand the assistant, and why. Shared with teleport so
|
|
389
|
+
// the two injection paths cannot drift.
|
|
390
|
+
const resolved = await resolveAssistantApiKeyForInjection({
|
|
391
|
+
registrationApiKey: registration.assistant_api_key,
|
|
392
|
+
runtimeUrl: entry.runtimeUrl,
|
|
393
|
+
bearerToken: options.bearerToken ?? entry.bearerToken,
|
|
394
|
+
token,
|
|
395
|
+
organizationId: orgId,
|
|
396
|
+
clientInstallationId,
|
|
397
|
+
runtimeAssistantId: entry.assistantId,
|
|
398
|
+
clientPlatform: "cli",
|
|
399
|
+
});
|
|
400
|
+
const assistantApiKey = resolved.apiKey;
|
|
401
|
+
if (resolved.source === "reprovisioned") {
|
|
402
|
+
log("Provisioning a replacement API key...");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Inject credentials into the running assistant via the gateway,
|
|
406
|
+
// mirroring the desktop app's LocalAssistantBootstrapService flow.
|
|
407
|
+
const allInjected = await injectCredentialsIntoAssistant({
|
|
408
|
+
gatewayUrl: entry.runtimeUrl,
|
|
409
|
+
bearerToken: options.bearerToken ?? entry.bearerToken,
|
|
410
|
+
assistantApiKey,
|
|
411
|
+
platformAssistantId: registration.assistant.id,
|
|
412
|
+
platformBaseUrl: platformUrl,
|
|
413
|
+
organizationId: orgId,
|
|
414
|
+
userId: user.id,
|
|
415
|
+
webhookSecret: registration.webhook_secret,
|
|
416
|
+
});
|
|
417
|
+
if (allInjected) {
|
|
418
|
+
log("Injected cloud credentials into assistant.");
|
|
419
|
+
} else {
|
|
420
|
+
console.warn("Some credentials could not be injected into the assistant.");
|
|
421
|
+
}
|
|
422
|
+
}
|
package/src/lib/color.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stderr is colored only when it is a TTY and NO_COLOR is unset — the
|
|
3
|
+
* --no-color/--plain flags set NO_COLOR before any command runs.
|
|
4
|
+
*/
|
|
5
|
+
export function colorize(color: string, text: string, stream: NodeJS.WriteStream = process.stderr): string {
|
|
6
|
+
if (!stream.isTTY || process.env.NO_COLOR) return text;
|
|
7
|
+
return `${color}${text}\x1b[0m`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function red(text: string, stream?: NodeJS.WriteStream): string {
|
|
11
|
+
return colorize("\x1b[31m", text, stream);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function yellow(text: string, stream?: NodeJS.WriteStream): string {
|
|
15
|
+
return colorize("\x1b[33m", text, stream);
|
|
16
|
+
}
|