@moda-ai/cli 1.22.1 → 1.23.0

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 (118) hide show
  1. package/dist/cli-c4se7a5d.js +762 -0
  2. package/dist/cli-mnqa4rp6.js +1563 -0
  3. package/dist/cli-stmjdgvy.js +2842 -0
  4. package/dist/cli-y55bgg14.js +6980 -0
  5. package/dist/cli-ysegdrm5.js +286 -0
  6. package/dist/cli.js +4779 -1159
  7. package/dist/harness-github-actions-7t3cncnt.js +95 -0
  8. package/dist/index-4ser01zr.js +2769 -0
  9. package/dist/provision-hm1ppwj6.js +74 -0
  10. package/package.json +3 -2
  11. package/skills/integration/index.json +2 -2
  12. package/dist/agent-schemas.d.ts +0 -3
  13. package/dist/agent-schemas.js +0 -358
  14. package/dist/agent.d.ts +0 -5
  15. package/dist/agent.js +0 -477
  16. package/dist/analyst-tui.d.ts +0 -81
  17. package/dist/analyst-tui.js +0 -314
  18. package/dist/api-client.d.ts +0 -46
  19. package/dist/api-client.js +0 -120
  20. package/dist/ask-stream.d.ts +0 -146
  21. package/dist/ask-stream.js +0 -473
  22. package/dist/auth-storage.d.ts +0 -35
  23. package/dist/auth-storage.js +0 -159
  24. package/dist/auth.d.ts +0 -50
  25. package/dist/auth.js +0 -694
  26. package/dist/cli-session.d.ts +0 -26
  27. package/dist/cli-session.js +0 -169
  28. package/dist/cli.d.ts +0 -44
  29. package/dist/command-context.d.ts +0 -62
  30. package/dist/command-context.js +0 -57
  31. package/dist/command-registry.d.ts +0 -22
  32. package/dist/command-registry.js +0 -15
  33. package/dist/config.d.ts +0 -60
  34. package/dist/config.js +0 -110
  35. package/dist/doctor.d.ts +0 -79
  36. package/dist/doctor.js +0 -1182
  37. package/dist/errors.d.ts +0 -50
  38. package/dist/errors.js +0 -64
  39. package/dist/exit-funnel.d.ts +0 -14
  40. package/dist/exit-funnel.js +0 -53
  41. package/dist/harness-artifact-bodies.d.ts +0 -28
  42. package/dist/harness-artifact-bodies.js +0 -276
  43. package/dist/harness-candidates.d.ts +0 -77
  44. package/dist/harness-candidates.js +0 -482
  45. package/dist/harness-remote.d.ts +0 -135
  46. package/dist/harness-remote.js +0 -540
  47. package/dist/harness-report.d.ts +0 -145
  48. package/dist/harness-report.js +0 -1579
  49. package/dist/harness-sync-limits.d.ts +0 -8
  50. package/dist/harness-sync-limits.js +0 -21
  51. package/dist/harness.d.ts +0 -463
  52. package/dist/harness.js +0 -4009
  53. package/dist/init/index.d.ts +0 -78
  54. package/dist/init/index.js +0 -1442
  55. package/dist/init/mcp.d.ts +0 -2
  56. package/dist/init/mcp.js +0 -27
  57. package/dist/init/progress-board.d.ts +0 -80
  58. package/dist/init/progress-board.js +0 -225
  59. package/dist/init/rules.d.ts +0 -6
  60. package/dist/init/rules.js +0 -171
  61. package/dist/init/sdk-catalog.d.ts +0 -10
  62. package/dist/init/sdk-catalog.js +0 -80
  63. package/dist/init/sdk-guide.d.ts +0 -19
  64. package/dist/init/sdk-guide.js +0 -185
  65. package/dist/init/sdk-integration.d.ts +0 -53
  66. package/dist/init/sdk-integration.js +0 -835
  67. package/dist/init/sdk-protocol.d.ts +0 -135
  68. package/dist/init/sdk-protocol.js +0 -152
  69. package/dist/init/sdk-verification.d.ts +0 -27
  70. package/dist/init/sdk-verification.js +0 -123
  71. package/dist/init/tenant.d.ts +0 -67
  72. package/dist/init/tenant.js +0 -421
  73. package/dist/intelligence-schema.d.ts +0 -349
  74. package/dist/intelligence-schema.js +0 -174
  75. package/dist/keys.d.ts +0 -47
  76. package/dist/keys.js +0 -136
  77. package/dist/manifest.d.ts +0 -5
  78. package/dist/manifest.js +0 -114
  79. package/dist/markdown-terminal.d.ts +0 -49
  80. package/dist/markdown-terminal.js +0 -212
  81. package/dist/output.d.ts +0 -191
  82. package/dist/output.js +0 -624
  83. package/dist/production-intelligence.d.ts +0 -175
  84. package/dist/production-intelligence.js +0 -1430
  85. package/dist/profile-commands.d.ts +0 -10
  86. package/dist/profile-commands.js +0 -342
  87. package/dist/profiles.d.ts +0 -112
  88. package/dist/profiles.js +0 -573
  89. package/dist/prompts-ab.d.ts +0 -71
  90. package/dist/prompts-ab.js +0 -313
  91. package/dist/prompts-propose.d.ts +0 -15
  92. package/dist/prompts-propose.js +0 -212
  93. package/dist/prompts.d.ts +0 -58
  94. package/dist/prompts.js +0 -418
  95. package/dist/provision.d.ts +0 -28
  96. package/dist/provision.js +0 -104
  97. package/dist/redact.d.ts +0 -2
  98. package/dist/redact.js +0 -90
  99. package/dist/schemas.d.ts +0 -225
  100. package/dist/schemas.js +0 -126
  101. package/dist/skills-bundled.d.ts +0 -35
  102. package/dist/skills-bundled.js +0 -157
  103. package/dist/skills-manifest.d.ts +0 -33
  104. package/dist/skills-manifest.js +0 -244
  105. package/dist/skills.d.ts +0 -57
  106. package/dist/skills.js +0 -783
  107. package/dist/sync.d.ts +0 -5
  108. package/dist/sync.js +0 -36
  109. package/dist/telemetry.d.ts +0 -56
  110. package/dist/telemetry.js +0 -167
  111. package/dist/transforms.d.ts +0 -25
  112. package/dist/transforms.js +0 -102
  113. package/dist/tui.d.ts +0 -48
  114. package/dist/tui.js +0 -393
  115. package/dist/update-check.d.ts +0 -31
  116. package/dist/update-check.js +0 -141
  117. package/dist/workspace.d.ts +0 -12
  118. package/dist/workspace.js +0 -1294
package/dist/auth.js DELETED
@@ -1,694 +0,0 @@
1
- // src/auth.ts — Browser-based authentication for moda init via frontend.
2
- // Owns the interactive login flow (localhost callback server, PKCE exchange,
3
- // manual paste fallback) and authFetch. Session persistence lives in
4
- // ./auth-storage.ts; the moda_ct renew/revoke/401-classification lifecycle
5
- // lives in ./cli-session.ts — both re-exported here so auth.js stays the
6
- // single import surface for auth.
7
- import { createServer } from 'node:http';
8
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
- import { hostname } from 'node:os';
10
- import { createInterface } from 'node:readline';
11
- import { resolveModaBaseUrl } from './config.js';
12
- import { CliAuthError, CliInputError } from './errors.js';
13
- import { loadProfileConfig } from './profiles.js';
14
- import { isModaCtSession, loadAuthSession, persistAuthSession, resolveStorageProfile, saveAuthSession, } from './auth-storage.js';
15
- import { classifyModaCtUnauthorized, cliSessionExpiredError, scheduleRenewalIfNearExpiry, } from './cli-session.js';
16
- export { clearAuthSession, isAuthSessionValid, loadAuthSession, loadToken, saveAuthSession, } from './auth-storage.js';
17
- export { revokeCliSessionToken } from './cli-session.js';
18
- const CALLBACK_PORT = 8432;
19
- // Fallback ports tried in order when CALLBACK_PORT is taken. The frontend
20
- // callback redirect is port-parameterized, so any of these works as long as
21
- // the auth URL carries the port that actually got bound.
22
- const CALLBACK_PORT_LADDER = [CALLBACK_PORT, 8433, 8434, 8435];
23
- /** Default wait for browser Auth0/consent during `moda auth login` / init. */
24
- export const DEFAULT_AUTH_TIMEOUT_MS = 600_000;
25
- /** Floor / ceiling for MODA_AUTH_TIMEOUT_MS (30s .. 30m). */
26
- const AUTH_TIMEOUT_MIN_MS = 30_000;
27
- const AUTH_TIMEOUT_MAX_MS = 30 * 60_000;
28
- const DEFAULT_EXPIRES_IN_SECONDS = 86_400;
29
- // PKCE code exchange against {base}/api/cli/auth/exchange.
30
- const EXCHANGE_TIMEOUT_MS = 10_000;
31
- /**
32
- * Resolve the browser-auth wait. Explicit LoginOptions.timeoutMs wins;
33
- * otherwise MODA_AUTH_TIMEOUT_MS (clamped); otherwise a 10-minute default that
34
- * covers real Auth0 + MFA + slow consent without abandoning mid-login.
35
- */
36
- export function resolveAuthTimeoutMs(options = {}) {
37
- if (typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs)) {
38
- return Math.max(1, Math.floor(options.timeoutMs));
39
- }
40
- const env = options.env ?? process.env;
41
- const raw = env.MODA_AUTH_TIMEOUT_MS?.trim();
42
- if (raw) {
43
- const parsed = Number.parseInt(raw, 10);
44
- if (Number.isFinite(parsed) && parsed > 0) {
45
- return Math.min(AUTH_TIMEOUT_MAX_MS, Math.max(AUTH_TIMEOUT_MIN_MS, parsed));
46
- }
47
- }
48
- return DEFAULT_AUTH_TIMEOUT_MS;
49
- }
50
- function escapeHtml(value) {
51
- return value
52
- .replace(/&/g, '&')
53
- .replace(/</g, '&lt;')
54
- .replace(/>/g, '&gt;')
55
- .replace(/"/g, '&quot;')
56
- .replace(/'/g, '&#39;');
57
- }
58
- function safeEqual(a, b) {
59
- const aBuf = Buffer.from(a);
60
- const bBuf = Buffer.from(b);
61
- if (aBuf.length !== bBuf.length)
62
- return false;
63
- return timingSafeEqual(aBuf, bBuf);
64
- }
65
- /**
66
- * Classify a bare paste as a PKCE one-time code vs a legacy token. One-time
67
- * codes are base64url(32 random bytes) = 43 chars with no dots; legacy Auth0
68
- * access tokens are JWTs (dot-separated segments, hundreds of chars). Only
69
- * pastes that plausibly ARE codes (base64url alphabet, code-sized) go down
70
- * the exchange path — everything else keeps the legacy bare-token behavior.
71
- * Collision analysis: every legacy Moda credential is a JWT, and a JWT always
72
- * contains dots, so the dot-free 32-96 char band cannot match one — a paste
73
- * classified as a code here is never a misrouted legacy token.
74
- */
75
- function looksLikeOneTimeCode(value) {
76
- return /^[A-Za-z0-9_-]{32,96}$/.test(value);
77
- }
78
- class CodeExchangeError extends Error {
79
- reason;
80
- constructor(message, reason) {
81
- super(message);
82
- this.reason = reason;
83
- this.name = 'CodeExchangeError';
84
- }
85
- }
86
- function exchangeFailureReason(error) {
87
- return error instanceof CodeExchangeError ? error.reason : 'exchange_failed';
88
- }
89
- /** base64url(sha256(verifier)) — the S256 code challenge for a verifier. */
90
- export function computePkceChallenge(codeVerifier) {
91
- return createHash('sha256').update(codeVerifier).digest('base64url');
92
- }
93
- function generatePkceVerifier() {
94
- // 32 random bytes -> 43 base64url chars, within the RFC 7636 43-128 range.
95
- return randomBytes(32).toString('base64url');
96
- }
97
- /**
98
- * The most recent PKCE exchange response for THIS process, so `moda init`
99
- * can skip one /api/cli/context round-trip right after login. Never
100
- * persisted to disk. Keyed by the storage profile the login targeted, and
101
- * cleared at the start of every login(), so a context from one profile's
102
- * exchange can never leak into another profile's init.
103
- */
104
- let cachedLoginContext = null;
105
- export function getCachedLoginContext(options = {}) {
106
- if (!cachedLoginContext)
107
- return null;
108
- if (cachedLoginContext.profile !== resolveStorageProfile(options))
109
- return null;
110
- return cachedLoginContext.context;
111
- }
112
- // --- Login Flow ---
113
- const SUCCESS_HTML = `<!DOCTYPE html>
114
- <html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#fafafa">
115
- <div style="text-align:center">
116
- <h2>Authenticated</h2>
117
- <p>You can close this tab and return to your terminal.</p>
118
- </div>
119
- <script>setTimeout(()=>window.close(),2000)</script>
120
- </body></html>`;
121
- // Page served at /callback that extracts token from URL fragment and
122
- // forwards it to /callback-token as query params (fragments never reach the server).
123
- const FRAGMENT_EXTRACTOR_HTML = `<!DOCTYPE html>
124
- <html><body>
125
- <script>
126
- var h = window.location.hash.substring(1);
127
- if (h) {
128
- window.location.replace('/callback-token?' + h);
129
- } else {
130
- document.body.innerText = 'No token received.';
131
- }
132
- </script>
133
- </body></html>`;
134
- async function openAuthUrl(authUrl) {
135
- const open = (await import('open')).default;
136
- await open(authUrl);
137
- }
138
- function closeAuthServer(server) {
139
- server.close((err) => {
140
- if (err && err.code !== 'ERR_SERVER_NOT_RUNNING') {
141
- // Nothing useful to surface to users during auth teardown.
142
- }
143
- });
144
- // Node 18 keeps idle keep-alive sockets open after server.close(). The browser
145
- // auth callback is single-use, so close idle sockets aggressively.
146
- server.closeIdleConnections?.();
147
- }
148
- export async function login(screen = 'login', options = {}) {
149
- return new Promise((resolve, reject) => {
150
- // A fresh login invalidates any context cached by a previous login in
151
- // this process (which may have targeted a different profile).
152
- cachedLoginContext = null;
153
- let settled = false;
154
- // An explicit callbackPort is used as-is (tests bind port 0 for an
155
- // ephemeral port); only the default flow walks the fallback ladder.
156
- const portsToTry = options.callbackPort !== undefined
157
- ? [options.callbackPort]
158
- : [...CALLBACK_PORT_LADDER];
159
- let portIndex = 0;
160
- let localPort = portsToTry[0];
161
- let timeout;
162
- let promptInput;
163
- let promptStream;
164
- let promptStreamIsTTY = false;
165
- let pendingBarePaste = null;
166
- let exchangeInFlight = false;
167
- const state = randomBytes(16).toString('hex');
168
- // PKCE: the verifier never leaves this process; only its S256 challenge
169
- // rides the auth URL. An old deployed frontend ignores the challenge
170
- // params and falls back to the legacy fragment flow, which the /callback
171
- // handler below still supports.
172
- const codeVerifier = generatePkceVerifier();
173
- const codeChallenge = computePkceChallenge(codeVerifier);
174
- // Every token save site (HTTP callback and manual paste) must honor the
175
- // caller's profile context, or `--profile X` logins land in the legacy
176
- // ~/.moda/auth.json instead of ~/.moda/secrets/auth/X.json.
177
- const storageOptions = {
178
- home: options.home,
179
- profile: options.profile,
180
- now: options.now,
181
- env: options.env,
182
- };
183
- // Fail fast on an unknown profile BEFORE binding the callback server or
184
- // opening the browser: deferring to the save at the end of the flow would
185
- // complete the whole browser roundtrip only to throw from inside the
186
- // request handler (unhandled) and leave the user's tab hanging.
187
- const targetProfile = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
188
- if (targetProfile && !loadProfileConfig(storageOptions).profiles[targetProfile]) {
189
- reject(new CliInputError(`Moda profile '${targetProfile}' does not exist.`, 'Create it with "moda profiles create <name>" or check "moda profiles list".'));
190
- return;
191
- }
192
- const server = createServer(async (req, res) => {
193
- if (settled) {
194
- // Answer stragglers instead of leaving the socket dangling: an
195
- // unanswered in-flight request would hold the event loop open.
196
- res.writeHead(410, { Connection: 'close' });
197
- res.end('Login attempt already completed');
198
- return;
199
- }
200
- const url = new URL(req.url || '/', `http://localhost:${localPort}`);
201
- if (url.pathname === '/callback') {
202
- const code = url.searchParams.get('code');
203
- if (code) {
204
- // PKCE path: the frontend redirected with ?code=&state= query
205
- // params (no fragment hop). Validate state, then exchange the
206
- // one-time code + verifier for a moda_ct session token.
207
- const callbackState = url.searchParams.get('state');
208
- if (!callbackState || !safeEqual(callbackState, state)) {
209
- res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
210
- res.end('<html><body><h2>Error</h2><p>Invalid authentication state.</p></body></html>', () => {
211
- fail(new Error('Invalid authentication state.'));
212
- });
213
- return;
214
- }
215
- if (exchangeInFlight) {
216
- res.writeHead(409, { Connection: 'close' });
217
- res.end('Login code exchange already in progress');
218
- return;
219
- }
220
- exchangeInFlight = true;
221
- void (async () => {
222
- // A PKCE grant implies a frontend deployment new enough to host
223
- // /auth/cli/done, so the browser is bounced to the branded page
224
- // instead of inline HTML. Only fixed, token-free params ride the
225
- // Location header — never the code, verifier, token, or state.
226
- try {
227
- const cliToken = await exchangeCodeForSession(code);
228
- // Leave exchangeInFlight true on success: succeed() only runs
229
- // in the res.end flush callback, so resetting here (or in a
230
- // finally) would open a window where a second valid-state
231
- // callback could start a concurrent exchange before `settled`
232
- // flips. The flag only needs to clear on failure, where a
233
- // retry paste is still legitimate.
234
- res.writeHead(302, {
235
- Location: `${resolveModaBaseUrl(storageOptions)}/auth/cli/done?status=success`,
236
- Connection: 'close',
237
- });
238
- res.end(() => {
239
- succeed(cliToken);
240
- });
241
- }
242
- catch (err) {
243
- exchangeInFlight = false;
244
- const error = err instanceof Error ? err : new Error(String(err));
245
- res.writeHead(302, {
246
- Location: `${resolveModaBaseUrl(storageOptions)}/auth/cli/done` +
247
- `?status=error&reason=${exchangeFailureReason(err)}`,
248
- Connection: 'close',
249
- });
250
- res.end(() => {
251
- fail(error);
252
- });
253
- }
254
- })();
255
- return;
256
- }
257
- // Legacy fallback: an old deployed frontend (no PKCE support) ignores
258
- // the code_challenge params and redirects here with the token in the
259
- // URL fragment (#token=...), which never reaches the server. Serve
260
- // the extractor page that forwards it to /callback-token as query
261
- // params.
262
- res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
263
- res.end(FRAGMENT_EXTRACTOR_HTML);
264
- return;
265
- }
266
- if (url.pathname !== '/callback-token') {
267
- res.writeHead(404, { Connection: 'close' });
268
- res.end('Not found');
269
- return;
270
- }
271
- const token = url.searchParams.get('token');
272
- const expiresIn = url.searchParams.get('expires_in');
273
- const error = url.searchParams.get('error');
274
- const callbackState = url.searchParams.get('state');
275
- // Bind the callback to this login session: any page in the browser (or
276
- // any process on localhost) could POST to this endpoint, so a missing or
277
- // mismatched state value means we must reject before touching `token`.
278
- if (!callbackState || !safeEqual(callbackState, state)) {
279
- res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
280
- res.end('<html><body><h2>Error</h2><p>Invalid authentication state.</p></body></html>', () => {
281
- fail(new Error('Invalid authentication state.'));
282
- });
283
- return;
284
- }
285
- if (error) {
286
- res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
287
- res.end(`<html><body><h2>Error</h2><p>${escapeHtml(error)}</p></body></html>`, () => {
288
- fail(new Error(`Authentication error: ${error}`));
289
- });
290
- return;
291
- }
292
- if (!token) {
293
- res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
294
- res.end('<html><body><h2>Error</h2><p>No token received.</p></body></html>', () => {
295
- fail(new Error('No token received'));
296
- });
297
- return;
298
- }
299
- // Backstop for the fail-fast profile check above: a save failure must
300
- // reject the login promise (not crash the process) and still answer the
301
- // browser so the tab does not hang.
302
- try {
303
- saveAuthSession(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10), storageOptions);
304
- }
305
- catch (err) {
306
- res.writeHead(500, { 'Content-Type': 'text/html', Connection: 'close' });
307
- res.end('<html><body><h2>Error</h2><p>Authenticated, but the CLI could not store the token. Return to your terminal.</p></body></html>', () => {
308
- fail(err instanceof Error ? err : new Error(String(err)));
309
- });
310
- return;
311
- }
312
- res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
313
- res.end(SUCCESS_HTML, () => {
314
- succeed(token);
315
- });
316
- });
317
- function removeSignalHandlers() {
318
- process.off('SIGINT', handleSigint);
319
- process.off('SIGTERM', handleSigterm);
320
- }
321
- function closePromptInput() {
322
- if (!promptInput)
323
- return;
324
- promptInput.close();
325
- promptInput = undefined;
326
- if (promptStream === process.stdin) {
327
- // Let the process exit naturally once auth settles.
328
- process.stdin.pause();
329
- }
330
- promptStream = undefined;
331
- }
332
- function finish(callback) {
333
- if (settled)
334
- return;
335
- settled = true;
336
- if (timeout)
337
- clearTimeout(timeout);
338
- removeSignalHandlers();
339
- closePromptInput();
340
- // Always attempt to close the server: if SIGINT arrives between
341
- // server.listen() and its listening callback, the socket may already be
342
- // bound by the OS. closeAuthServer ignores ERR_SERVER_NOT_RUNNING.
343
- closeAuthServer(server);
344
- callback();
345
- }
346
- function succeed(token) {
347
- finish(() => resolve(token));
348
- }
349
- function fail(error) {
350
- finish(() => reject(error));
351
- }
352
- function handleSigint() {
353
- fail(new Error('Authentication cancelled.'));
354
- }
355
- function handleSigterm() {
356
- fail(new Error('Authentication terminated.'));
357
- }
358
- function completeWithToken(token, expiresIn) {
359
- // Backstop: this runs inside a readline 'line' handler, where an
360
- // uncaught save error would crash the process instead of rejecting.
361
- try {
362
- saveAuthSession(token, expiresIn, storageOptions);
363
- }
364
- catch (err) {
365
- fail(err instanceof Error ? err : new Error(String(err)));
366
- return;
367
- }
368
- succeed(token);
369
- }
370
- /**
371
- * PKCE exchange: POST the one-time code + verifier and store the
372
- * resulting moda_ct session. Throws with a user-facing message on any
373
- * failure; the raw code and token are never logged.
374
- */
375
- async function exchangeCodeForSession(code) {
376
- let response;
377
- try {
378
- // redirect: 'manual' — never follow a redirect on this POST: a
379
- // 307/308 would re-send the one-time code and verifier to whatever
380
- // host the Location header names. Any redirect is treated as a hard
381
- // failure below (clearer than redirect:'error', whose rejection is
382
- // indistinguishable from a network failure in the catch).
383
- response = await fetch(`${resolveModaBaseUrl(storageOptions)}/api/cli/auth/exchange`, {
384
- method: 'POST',
385
- headers: { 'Content-Type': 'application/json' },
386
- body: JSON.stringify({
387
- code,
388
- code_verifier: codeVerifier,
389
- label: hostname(),
390
- }),
391
- redirect: 'manual',
392
- signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
393
- });
394
- }
395
- catch {
396
- throw new CodeExchangeError('Could not reach the Moda API to exchange the login code. Check your network and try again.', 'unreachable');
397
- }
398
- if (response.status >= 300 && response.status < 400) {
399
- throw new CodeExchangeError(`The Moda API redirected the login code exchange (HTTP ${response.status}). ` +
400
- 'Refusing to re-send the login code across a redirect. ' +
401
- 'Check MODA_BASE_URL (use https://moda.dev, not www.moda.dev) and try again.', 'exchange_failed');
402
- }
403
- if (response.status === 401) {
404
- throw new CodeExchangeError('The login code was rejected (invalid, expired, or already used). Run the login command again.', 'code_rejected');
405
- }
406
- if (!response.ok) {
407
- throw new CodeExchangeError(`Login code exchange failed (HTTP ${response.status}). Try again.`, 'exchange_failed');
408
- }
409
- let payload;
410
- try {
411
- payload = (await response.json());
412
- }
413
- catch {
414
- throw new CodeExchangeError('The login code exchange returned an unreadable response. Try again.', 'exchange_failed');
415
- }
416
- const cliToken = typeof payload.cli_token === 'string' ? payload.cli_token : undefined;
417
- const expiresAtMs = typeof payload.expires_at === 'string' ? Date.parse(payload.expires_at) : NaN;
418
- if (!cliToken || !Number.isFinite(expiresAtMs)) {
419
- throw new CodeExchangeError('The login code exchange returned an incomplete response. Try again.', 'exchange_failed');
420
- }
421
- const userEmail = typeof payload.user?.email === 'string' ? payload.user.email : undefined;
422
- persistAuthSession({
423
- access_token: cliToken,
424
- expires_at: Math.floor(expiresAtMs / 1000),
425
- profile: resolveStorageProfile(storageOptions),
426
- token_type: 'bearer',
427
- token_kind: 'moda_ct',
428
- ...(userEmail ? { user_email: userEmail } : {}),
429
- }, storageOptions);
430
- cachedLoginContext = {
431
- profile: resolveStorageProfile(storageOptions),
432
- context: {
433
- user: {
434
- email: userEmail ?? '',
435
- name: typeof payload.user?.name === 'string' ? payload.user.name : null,
436
- },
437
- tenants: Array.isArray(payload.tenants)
438
- ? payload.tenants
439
- : [],
440
- },
441
- };
442
- return cliToken;
443
- }
444
- /**
445
- * Paste-path variant of the exchange: converts throws into fail().
446
- * Shares the exchangeInFlight guard with the HTTP callback path so a
447
- * double paste (or a paste racing the browser callback) never runs two
448
- * concurrent exchanges; the duplicate line is simply dropped and the
449
- * first exchange settles the login.
450
- */
451
- async function completeWithCode(code) {
452
- if (exchangeInFlight)
453
- return;
454
- exchangeInFlight = true;
455
- try {
456
- const cliToken = await exchangeCodeForSession(code);
457
- succeed(cliToken);
458
- }
459
- catch (err) {
460
- exchangeInFlight = false;
461
- fail(err instanceof Error ? err : new Error(String(err)));
462
- }
463
- }
464
- // Accepts a line pasted by the user when the browser cannot reach this
465
- // machine: a full PKCE /callback?code=... URL, a /callback-token?token=...
466
- // URL, a /callback#token=... fragment URL, a bare one-time code (copied
467
- // from the consent page), or a bare token string.
468
- function handlePastedLine(line) {
469
- if (settled)
470
- return;
471
- const trimmed = line.trim();
472
- if (pendingBarePaste !== null) {
473
- const pending = pendingBarePaste;
474
- pendingBarePaste = null;
475
- if (/^y(es)?$/i.test(trimmed)) {
476
- if (pending.kind === 'code') {
477
- void completeWithCode(pending.value);
478
- }
479
- else {
480
- completeWithToken(pending.value, DEFAULT_EXPIRES_IN_SECONDS);
481
- }
482
- }
483
- else {
484
- // A bare Enter is the conventional No for a [y/N] prompt.
485
- fail(new Error(pending.kind === 'code'
486
- ? 'Pasted code rejected: state cannot be verified.'
487
- : 'Bare token rejected: state cannot be verified.'));
488
- }
489
- return;
490
- }
491
- if (!trimmed)
492
- return;
493
- if (/^https?:\/\//i.test(trimmed)) {
494
- let pastedUrl;
495
- try {
496
- pastedUrl = new URL(trimmed);
497
- }
498
- catch {
499
- // A malformed paste intentionally aborts the login so a fresh state
500
- // is minted on the next attempt.
501
- fail(new Error('Could not parse the pasted URL. Paste the full callback URL from the browser.'));
502
- return;
503
- }
504
- // Fragment URLs (/callback#token=...) carry the same key/value pairs
505
- // as the /callback-token query string; parse whichever is present.
506
- const params = pastedUrl.hash.length > 1
507
- ? new URLSearchParams(pastedUrl.hash.slice(1))
508
- : pastedUrl.searchParams;
509
- const token = params.get('token');
510
- const code = params.get('code');
511
- const expiresIn = params.get('expires_in');
512
- const pastedState = params.get('state');
513
- if (!pastedState || !safeEqual(pastedState, state)) {
514
- fail(new Error('Invalid authentication state.'));
515
- return;
516
- }
517
- if (token) {
518
- completeWithToken(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10));
519
- return;
520
- }
521
- if (code) {
522
- // New-style PKCE callback URL pasted manually: state verified
523
- // above, so exchange the one-time code like the HTTP path does.
524
- void completeWithCode(code);
525
- return;
526
- }
527
- fail(new Error('No token received'));
528
- return;
529
- }
530
- // Bare paste. A one-time code (the consent page renders a copy block)
531
- // has no state to verify either, so it gets the same explicit
532
- // interactive confirmation gate as a bare token.
533
- const kind = looksLikeOneTimeCode(trimmed) ? 'code' : 'token';
534
- if (promptStreamIsTTY) {
535
- pendingBarePaste = { kind, value: trimmed };
536
- console.error(kind === 'code'
537
- ? ' State cannot be verified for a pasted code. Continue? [y/N]'
538
- : ' State cannot be verified for a bare token. Continue? [y/N]');
539
- return;
540
- }
541
- fail(new Error(kind === 'code'
542
- ? 'State cannot be verified for a pasted code in non-interactive mode. Paste the full callback URL instead.'
543
- : 'State cannot be verified for a bare token in non-interactive mode. Paste the full callback URL instead.'));
544
- }
545
- function attachPromptInput() {
546
- if (settled || promptInput)
547
- return;
548
- const stream = options.promptStream ??
549
- (process.stdin && (process.stdin.isTTY || !process.stdin.destroyed)
550
- ? process.stdin
551
- : undefined);
552
- if (!stream)
553
- return;
554
- promptStream = stream;
555
- promptStreamIsTTY = Boolean(stream.isTTY);
556
- promptInput = createInterface({ input: stream, terminal: false });
557
- promptInput.on('line', handlePastedLine);
558
- // Waiting for a paste must not keep the process alive on its own; the
559
- // callback server and the auth timeout already do that.
560
- stream.unref?.();
561
- }
562
- timeout = setTimeout(() => {
563
- fail(new Error('Authentication timed out. Please try again.'));
564
- }, resolveAuthTimeoutMs({ timeoutMs: options.timeoutMs, env: options.env }));
565
- server.on('error', (err) => {
566
- if (settled)
567
- return;
568
- if (err.code === 'EADDRINUSE') {
569
- portIndex += 1;
570
- if (portIndex < portsToTry.length) {
571
- localPort = portsToTry[portIndex];
572
- server.listen(localPort, '127.0.0.1');
573
- return;
574
- }
575
- if (options.callbackPort !== undefined) {
576
- fail(new Error(`Port ${options.callbackPort} is in use. Close the process using it and try again.`));
577
- }
578
- else {
579
- fail(new Error(`Ports ${CALLBACK_PORT_LADDER.join(', ')} are all in use. ` +
580
- 'Close a process using one of them and try again.'));
581
- }
582
- }
583
- else {
584
- fail(err);
585
- }
586
- });
587
- async function onListening() {
588
- if (settled)
589
- return;
590
- const address = server.address();
591
- if (address && typeof address === 'object') {
592
- localPort = address.port;
593
- }
594
- // Resolve the auth URL with the same profile/env context so `--profile
595
- // staging` opens the staging flow (not whatever the active profile is)
596
- // and the minted session matches where it will be stored.
597
- // code_challenge/challenge_method upgrade the frontend to the PKCE
598
- // consent flow; an old deployed frontend ignores them and runs the
599
- // legacy fragment flow, which the /callback handler still accepts.
600
- const authUrl = `${resolveModaBaseUrl(storageOptions)}/auth/cli?port=${localPort}&screen=${screen}&state=${state}` +
601
- `&code_challenge=${codeChallenge}&challenge_method=S256`;
602
- attachPromptInput();
603
- try {
604
- await (options.openUrl ?? openAuthUrl)(authUrl);
605
- }
606
- catch {
607
- // The URL is printed below regardless of whether the browser opened.
608
- }
609
- console.error(` Open this URL in your browser if it did not open automatically:\n ${authUrl}`);
610
- console.error(" Paste the callback URL or code here if the browser can't reach this machine\n" +
611
- " (if the browser page failed to redirect, copy the URL from the browser's address bar):");
612
- }
613
- // Register the listening handler exactly once: passing a callback to each
614
- // server.listen() call would accumulate once('listening') handlers across
615
- // ladder retries, and every accumulated callback would fire on the
616
- // eventual successful bind (opening the browser multiple times).
617
- server.once('listening', onListening);
618
- server.listen(localPort, '127.0.0.1');
619
- process.once('SIGINT', handleSigint);
620
- process.once('SIGTERM', handleSigterm);
621
- });
622
- }
623
- // --- Authenticated Fetch ---
624
- export async function authFetch(path, options = {}, authOptions = {}) {
625
- const session = loadAuthSession(authOptions);
626
- if (!session) {
627
- // An expired moda_ct session is a classifiable auth failure; legacy
628
- // sessions keep the historical plain error.
629
- const expired = loadAuthSession({ ...authOptions, includeExpired: true });
630
- if (expired && isModaCtSession(expired)) {
631
- throw cliSessionExpiredError();
632
- }
633
- throw new CliAuthError('Not authenticated. Run "moda init" to log in.', {
634
- reason: 'no_stored_credentials',
635
- login_command: ['moda', 'init'],
636
- });
637
- }
638
- const token = session.access_token;
639
- // Sliding expiry: renew opportunistically when a moda_ct session is inside
640
- // the renewal window. Fire-and-forget; the request below never waits on it.
641
- scheduleRenewalIfNearExpiry(session, authOptions);
642
- const headers = new Headers(options.headers);
643
- headers.set('Authorization', `Bearer ${token}`);
644
- if (!headers.has('Content-Type') && options.body) {
645
- headers.set('Content-Type', 'application/json');
646
- }
647
- // Resolve the origin from the same profile/env context used to load the
648
- // token, or a profile-scoped session (e.g. `--profile staging`) is sent to
649
- // the active/default origin — provisioning against the wrong environment.
650
- const baseUrl = resolveModaBaseUrl(authOptions);
651
- const origin = new URL(baseUrl).origin;
652
- let url = `${baseUrl}${path}`;
653
- // Follow redirects manually so EVERY hop is origin-checked, not just the
654
- // first. Native `redirect: 'follow'` would silently strip the Authorization
655
- // header on a cross-host hop (per the Fetch spec) — the exact failure that
656
- // broke moda init (the legacy www → apex 308). Same-host hops keep the header
657
- // and are safe to follow; any hop that leaves the Moda origin fails fast with
658
- // an actionable error instead of surfacing as a confusing 401.
659
- const MAX_REDIRECTS = 5;
660
- for (let hop = 0;; hop++) {
661
- const response = await fetch(url, { ...options, headers, redirect: 'manual' });
662
- if (response.status < 300 || response.status >= 400) {
663
- if (response.status === 401 && isModaCtSession(session)) {
664
- // 401 taxonomy for moda_ct tokens: expired vs revoked vs a transient
665
- // renewal failure (each with distinct recovery guidance). Legacy
666
- // auth0-kind sessions keep the historical pass-through behavior.
667
- const authError = await classifyModaCtUnauthorized(session, authOptions);
668
- if (authError)
669
- throw authError;
670
- }
671
- return response;
672
- }
673
- const location = response.headers.get('location');
674
- const target = location ? safeResolveUrl(location, url) : null;
675
- if (!target || target.origin !== origin) {
676
- throw new Error(`Moda API redirected (${response.status}) to ${location ?? '(unknown)'}. ` +
677
- 'Cross-host redirects strip Authorization and break moda init. ' +
678
- 'Use MODA_BASE_URL=https://moda.dev (not www.moda.dev).');
679
- }
680
- if (hop >= MAX_REDIRECTS) {
681
- throw new Error(`Moda API exceeded ${MAX_REDIRECTS} same-host redirects for ${path}.`);
682
- }
683
- url = target.toString();
684
- }
685
- }
686
- function safeResolveUrl(location, base) {
687
- try {
688
- return new URL(location, base);
689
- }
690
- catch {
691
- return null;
692
- }
693
- }
694
- //# sourceMappingURL=auth.js.map