@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.
Files changed (89) hide show
  1. package/eslint.config.mjs +7 -0
  2. package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
  3. package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
  4. package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
  5. package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
  6. package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
  7. package/package.json +4 -3
  8. package/src/__tests__/assistant-config.test.ts +30 -28
  9. package/src/__tests__/debug.test.ts +8 -8
  10. package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
  11. package/src/__tests__/flags.test.ts +2 -2
  12. package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
  13. package/src/__tests__/ingress-config.test.ts +2 -2
  14. package/src/__tests__/login-loopback.test.ts +1 -1
  15. package/src/__tests__/nginx-ingress.test.ts +68 -76
  16. package/src/__tests__/pair.test.ts +135 -121
  17. package/src/__tests__/platform-client.test.ts +2 -2
  18. package/src/__tests__/recover.test.ts +96 -3
  19. package/src/__tests__/retire.test.ts +5 -3
  20. package/src/__tests__/signing-key-continuity.test.ts +261 -0
  21. package/src/__tests__/sleep.test.ts +189 -6
  22. package/src/__tests__/tailscale-tunnel.test.ts +1 -1
  23. package/src/__tests__/uninstall.test.ts +171 -0
  24. package/src/__tests__/upgrade-local.test.ts +16 -3
  25. package/src/__tests__/use.test.ts +2 -2
  26. package/src/__tests__/wake-error.test.ts +76 -0
  27. package/src/__tests__/wake.test.ts +309 -36
  28. package/src/commands/backup.ts +6 -2
  29. package/src/commands/clean.ts +6 -2
  30. package/src/commands/client.ts +91 -41
  31. package/src/commands/confirm.ts +6 -2
  32. package/src/commands/connect.ts +6 -2
  33. package/src/commands/debug.ts +6 -2
  34. package/src/commands/devices.ts +6 -2
  35. package/src/commands/events.ts +6 -2
  36. package/src/commands/exec.ts +50 -12
  37. package/src/commands/flags.ts +6 -2
  38. package/src/commands/gateway.ts +6 -2
  39. package/src/commands/hatch.ts +6 -2
  40. package/src/commands/login.ts +26 -356
  41. package/src/commands/logs.ts +6 -2
  42. package/src/commands/message.ts +6 -2
  43. package/src/commands/pair.ts +6 -2
  44. package/src/commands/ps.ts +7 -2
  45. package/src/commands/recover.ts +21 -16
  46. package/src/commands/restore.ts +6 -2
  47. package/src/commands/retire.ts +6 -2
  48. package/src/commands/roadmap.ts +6 -2
  49. package/src/commands/rollback.ts +6 -2
  50. package/src/commands/setup.ts +76 -9
  51. package/src/commands/sleep.ts +131 -31
  52. package/src/commands/ssh.ts +6 -2
  53. package/src/commands/teleport.ts +6 -2
  54. package/src/commands/terminal.ts +6 -2
  55. package/src/commands/tunnel.ts +6 -2
  56. package/src/commands/uninstall.ts +228 -0
  57. package/src/commands/unpair.ts +6 -2
  58. package/src/commands/upgrade.ts +13 -8
  59. package/src/commands/use.ts +6 -2
  60. package/src/commands/voice.ts +6 -2
  61. package/src/commands/wake.ts +69 -37
  62. package/src/commands/workflows.ts +6 -2
  63. package/src/components/DefaultMainScreen.tsx +250 -36
  64. package/src/index.ts +23 -55
  65. package/src/lib/assistant-config.ts +27 -13
  66. package/src/lib/backup-ops.ts +3 -4
  67. package/src/lib/cli-error.ts +5 -0
  68. package/src/lib/client-identity.ts +8 -10
  69. package/src/lib/cloud-login.ts +422 -0
  70. package/src/lib/color.ts +16 -0
  71. package/src/lib/default-main-screen-formatting.ts +32 -8
  72. package/src/lib/environments/__tests__/paths.test.ts +9 -11
  73. package/src/lib/environments/paths.ts +2 -20
  74. package/src/lib/guardian-token.ts +72 -18
  75. package/src/lib/hatch-local.ts +52 -1
  76. package/src/lib/health-check.ts +24 -15
  77. package/src/lib/lifecycle-reporter.ts +9 -1
  78. package/src/lib/local-hatch-lock.ts +19 -27
  79. package/src/lib/local-runtime-client.ts +25 -14
  80. package/src/lib/local.ts +17 -43
  81. package/src/lib/ngrok.ts +48 -16
  82. package/src/lib/platform-client.ts +141 -72
  83. package/src/lib/retire-archive.ts +4 -4
  84. package/src/lib/retire-local.ts +2 -25
  85. package/src/lib/teleport-backup.ts +23 -9
  86. package/src/lib/terminal-client.ts +21 -9
  87. package/src/lib/tunnel-edge.ts +11 -13
  88. package/src/lib/upgrade-lifecycle.ts +16 -4
  89. 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 (`readAssistants` only returns entries that have one)
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
- * `readAssistants`). The index signature preserves unknown on-disk fields so a
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 readAssistants(): AssistantEntry[] {
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 = readAssistants();
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 readAssistants().find((entry) => entry.assistantId === name) ?? null;
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 = readAssistants();
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 = readAssistants();
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 = readAssistants();
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 = readAssistants().filter(
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
@@ -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
- const dataHome =
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,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
- * `~/.config/cadet/client-id` so the daemon's event hub can
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
- function getConfigDir(): string {
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 configDir = getConfigDir();
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(configDir, { recursive: true });
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, "&amp;")
45
+ .replace(/</g, "&lt;")
46
+ .replace(/>/g, "&gt;")
47
+ .replace(/"/g, "&quot;")
48
+ .replace(/'/g, "&#39;");
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
+ }
@@ -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
+ }