@moda-ai/cli 1.12.6 → 1.13.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.
package/dist/auth.d.ts CHANGED
@@ -13,10 +13,17 @@ export declare function loadToken(options?: AuthSessionOptions): string | null;
13
13
  export declare function loadAuthSession(options?: AuthSessionOptions): StoredAuth | null;
14
14
  export declare function saveAuthSession(accessToken: string, expiresIn: number, options?: ProfileIOOptions): StoredAuth;
15
15
  export declare function clearAuthSession(options?: ProfileIOOptions): void;
16
- export interface LoginOptions {
16
+ export interface LoginOptions extends ProfileIOOptions {
17
+ /** Use this exact port for the localhost callback (no fallback ladder). */
17
18
  callbackPort?: number;
18
19
  timeoutMs?: number;
19
20
  openUrl?: (url: string) => Promise<void>;
21
+ /**
22
+ * Test hook: readable stream used for the manual-paste fallback instead of
23
+ * process.stdin. A truthy `isTTY` property on the stream enables the
24
+ * interactive bare-token confirmation path.
25
+ */
26
+ promptStream?: NodeJS.ReadableStream;
20
27
  }
21
28
  export declare function login(screen?: 'login' | 'signup', options?: LoginOptions): Promise<string>;
22
29
  export declare function authFetch(path: string, options?: RequestInit, authOptions?: AuthSessionOptions): Promise<Response>;
package/dist/auth.js CHANGED
@@ -2,11 +2,17 @@
2
2
  import { createServer } from 'node:http';
3
3
  import { existsSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import { createInterface } from 'node:readline';
5
6
  import { resolveModaBaseUrl } from './config.js';
6
7
  import { CliAuthError } from './errors.js';
7
8
  import { getModaPaths, loadProfileConfig, saveProfileConfig, secretFilePathFromRef, writeSecretJsonFile, } from './profiles.js';
8
9
  const CALLBACK_PORT = 8432;
10
+ // Fallback ports tried in order when CALLBACK_PORT is taken. The frontend
11
+ // callback redirect is port-parameterized, so any of these works as long as
12
+ // the auth URL carries the port that actually got bound.
13
+ const CALLBACK_PORT_LADDER = [CALLBACK_PORT, 8433, 8434, 8435];
9
14
  const AUTH_TIMEOUT_MS = 120_000;
15
+ const DEFAULT_EXPIRES_IN_SECONDS = 86_400;
10
16
  function escapeHtml(value) {
11
17
  return value
12
18
  .replace(/&/g, '&amp;')
@@ -154,8 +160,8 @@ export function clearAuthSession(options = {}) {
154
160
  }
155
161
  rmSync(tokenPath(options), { force: true });
156
162
  }
157
- function saveToken(accessToken, expiresIn) {
158
- saveAuthSession(accessToken, expiresIn);
163
+ function saveToken(accessToken, expiresIn, options = {}) {
164
+ saveAuthSession(accessToken, expiresIn, options);
159
165
  }
160
166
  // --- Login Flow ---
161
167
  const SUCCESS_HTML = `<!DOCTYPE html>
@@ -196,9 +202,20 @@ function closeAuthServer(server) {
196
202
  export async function login(screen = 'login', options = {}) {
197
203
  return new Promise((resolve, reject) => {
198
204
  let settled = false;
199
- let localPort = options.callbackPort ?? CALLBACK_PORT;
200
- let timeout;
201
205
  const state = randomBytes(16).toString('hex');
206
+ // An explicit callbackPort is used as-is (tests bind port 0 for an
207
+ // ephemeral port); only the default flow walks the fallback ladder.
208
+ const portsToTry = options.callbackPort !== undefined
209
+ ? [options.callbackPort]
210
+ : [...CALLBACK_PORT_LADDER];
211
+ let portIndex = 0;
212
+ let localPort = portsToTry[0];
213
+ let timeout;
214
+ // Manual-paste fallback state (used when the browser can't reach this box).
215
+ let promptInput;
216
+ let promptStreamIsTTY = false;
217
+ // Holds a bare token awaiting the interactive [y/N] confirmation.
218
+ let pendingBareToken = null;
202
219
  const server = createServer(async (req, res) => {
203
220
  if (settled)
204
221
  return;
@@ -244,7 +261,10 @@ export async function login(screen = 'login', options = {}) {
244
261
  });
245
262
  return;
246
263
  }
247
- saveToken(token, parseInt(expiresIn || '86400', 10));
264
+ saveToken(token, parseInt(expiresIn || '86400', 10), {
265
+ profile: options.profile,
266
+ env: options.env,
267
+ });
248
268
  res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
249
269
  res.end(SUCCESS_HTML, () => {
250
270
  succeed(token);
@@ -254,6 +274,13 @@ export async function login(screen = 'login', options = {}) {
254
274
  process.off('SIGINT', handleSigint);
255
275
  process.off('SIGTERM', handleSigterm);
256
276
  }
277
+ function closePromptInput() {
278
+ if (!promptInput)
279
+ return;
280
+ promptInput.removeListener('line', handlePastedLine);
281
+ promptInput.close();
282
+ promptInput = undefined;
283
+ }
257
284
  function finish(callback) {
258
285
  if (settled)
259
286
  return;
@@ -261,6 +288,7 @@ export async function login(screen = 'login', options = {}) {
261
288
  if (timeout)
262
289
  clearTimeout(timeout);
263
290
  removeSignalHandlers();
291
+ closePromptInput();
264
292
  // Always attempt to close the server: if SIGINT arrives between
265
293
  // server.listen() and its listening callback, the socket may already be
266
294
  // bound by the OS. closeAuthServer ignores ERR_SERVER_NOT_RUNNING.
@@ -273,35 +301,138 @@ export async function login(screen = 'login', options = {}) {
273
301
  function fail(error) {
274
302
  finish(() => reject(error));
275
303
  }
304
+ function completeWithToken(token, expiresIn) {
305
+ saveToken(token, expiresIn, { profile: options.profile, env: options.env });
306
+ succeed(token);
307
+ }
276
308
  function handleSigint() {
277
309
  fail(new Error('Authentication cancelled.'));
278
310
  }
279
311
  function handleSigterm() {
280
312
  fail(new Error('Authentication terminated.'));
281
313
  }
314
+ // Accepts a line pasted by the user when the browser cannot reach this
315
+ // machine: a full /callback-token?token=... URL, a /callback#token=...
316
+ // fragment URL, or a bare token string.
317
+ function handlePastedLine(line) {
318
+ if (settled)
319
+ return;
320
+ const trimmed = line.trim();
321
+ if (pendingBareToken !== null) {
322
+ const token = pendingBareToken;
323
+ pendingBareToken = null;
324
+ if (/^y(es)?$/i.test(trimmed)) {
325
+ completeWithToken(token, DEFAULT_EXPIRES_IN_SECONDS);
326
+ }
327
+ else {
328
+ // A bare Enter is the conventional No for a [y/N] prompt.
329
+ fail(new Error('Bare token rejected: state cannot be verified.'));
330
+ }
331
+ return;
332
+ }
333
+ if (!trimmed)
334
+ return;
335
+ if (/^https?:\/\//i.test(trimmed)) {
336
+ let pastedUrl;
337
+ try {
338
+ pastedUrl = new URL(trimmed);
339
+ }
340
+ catch {
341
+ // A malformed paste intentionally aborts the login so a fresh state
342
+ // is minted on the next attempt.
343
+ fail(new Error('Could not parse the pasted URL. Paste the full callback URL from the browser.'));
344
+ return;
345
+ }
346
+ // Fragment URLs (/callback#token=...) carry the same key/value pairs as
347
+ // the /callback-token query string; parse whichever is present.
348
+ const params = pastedUrl.hash.length > 1
349
+ ? new URLSearchParams(pastedUrl.hash.slice(1))
350
+ : pastedUrl.searchParams;
351
+ const token = params.get('token');
352
+ const expiresIn = params.get('expires_in');
353
+ const pastedState = params.get('state');
354
+ if (!pastedState || !safeEqual(pastedState, state)) {
355
+ fail(new Error('Invalid authentication state.'));
356
+ return;
357
+ }
358
+ if (!token) {
359
+ fail(new Error('No token received'));
360
+ return;
361
+ }
362
+ completeWithToken(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10));
363
+ return;
364
+ }
365
+ // Bare token: there is no state to verify, so require an explicit,
366
+ // interactive confirmation before trusting it.
367
+ if (promptStreamIsTTY) {
368
+ pendingBareToken = trimmed;
369
+ console.error(' State cannot be verified for a bare token. Continue? [y/N]');
370
+ return;
371
+ }
372
+ fail(new Error('State cannot be verified for a bare token in non-interactive mode. Paste the full callback URL instead.'));
373
+ }
374
+ function attachPromptInput() {
375
+ if (settled || promptInput)
376
+ return;
377
+ const stream = options.promptStream ??
378
+ (process.stdin && (process.stdin.isTTY || !process.stdin.destroyed)
379
+ ? process.stdin
380
+ : undefined);
381
+ if (!stream)
382
+ return;
383
+ promptStreamIsTTY = Boolean(stream.isTTY);
384
+ promptInput = createInterface({ input: stream, terminal: false });
385
+ promptInput.on('line', handlePastedLine);
386
+ // Waiting for a paste must not keep the process alive on its own; the
387
+ // callback server and the auth timeout already do that.
388
+ stream.unref?.();
389
+ }
282
390
  timeout = setTimeout(() => {
283
391
  fail(new Error('Authentication timed out. Please try again.'));
284
392
  }, options.timeoutMs ?? AUTH_TIMEOUT_MS);
285
393
  server.on('error', (err) => {
394
+ if (settled)
395
+ return;
286
396
  if (err.code === 'EADDRINUSE') {
287
- fail(new Error(`Port ${localPort} is in use. Close the process using it and try again.`));
397
+ portIndex += 1;
398
+ if (portIndex < portsToTry.length) {
399
+ localPort = portsToTry[portIndex];
400
+ server.listen(localPort, '127.0.0.1');
401
+ return;
402
+ }
403
+ if (options.callbackPort !== undefined) {
404
+ fail(new Error(`Port ${options.callbackPort} is in use. Close the process using it and try again.`));
405
+ }
406
+ else {
407
+ fail(new Error(`Ports ${CALLBACK_PORT_LADDER.join(', ')} are all in use. ` +
408
+ 'Close a process using one of them and try again.'));
409
+ }
288
410
  }
289
411
  else {
290
412
  fail(err);
291
413
  }
292
414
  });
293
415
  server.listen(localPort, '127.0.0.1', async () => {
416
+ if (settled)
417
+ return;
294
418
  const address = server.address();
295
419
  if (address && typeof address === 'object') {
296
420
  localPort = address.port;
297
421
  }
298
- const authUrl = `${resolveModaBaseUrl()}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
422
+ // Resolve the auth URL with the same profile/env context so `--profile
423
+ // staging` opens the staging flow (not whatever the active profile is)
424
+ // and the minted session matches where it will be stored.
425
+ const authUrl = `${resolveModaBaseUrl({ profile: options.profile, env: options.env })}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
426
+ attachPromptInput();
299
427
  try {
300
428
  await (options.openUrl ?? openAuthUrl)(authUrl);
301
429
  }
302
430
  catch {
303
- console.error(` Open this URL in your browser:\n ${authUrl}`);
431
+ // The URL is printed below regardless of whether the browser opened.
304
432
  }
433
+ console.error(` Open this URL in your browser if it did not open automatically:\n ${authUrl}`);
434
+ console.error(" Paste the callback URL or code here if the browser can't reach this machine\n" +
435
+ "(if the browser page failed to redirect, copy the URL from the browser's address bar):");
305
436
  });
306
437
  process.once('SIGINT', handleSigint);
307
438
  process.once('SIGTERM', handleSigterm);
@@ -321,8 +452,12 @@ export async function authFetch(path, options = {}, authOptions = {}) {
321
452
  if (!headers.has('Content-Type') && options.body) {
322
453
  headers.set('Content-Type', 'application/json');
323
454
  }
324
- const origin = new URL(resolveModaBaseUrl()).origin;
325
- let url = `${resolveModaBaseUrl()}${path}`;
455
+ // Resolve the origin from the same profile/env context used to load the
456
+ // token, or a profile-scoped session (e.g. `--profile staging`) is sent to
457
+ // the active/default origin — provisioning against the wrong environment.
458
+ const baseUrl = resolveModaBaseUrl(authOptions);
459
+ const origin = new URL(baseUrl).origin;
460
+ let url = `${baseUrl}${path}`;
326
461
  // Follow redirects manually so EVERY hop is origin-checked, not just the
327
462
  // first. Native `redirect: 'follow'` would silently strip the Authorization
328
463
  // header on a cross-host hop (per the Fetch spec) — the exact failure that
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { runSkillsCommand } from './skills.js';
12
12
  import { runHarnessCommand } from './harness.js';
13
13
  import { runSyncCommand } from './sync.js';
14
14
  import { runDoctorCommand, runStatusCommand } from './doctor.js';
15
- import { CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError } from './errors.js';
15
+ import { CliAccessError, CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError } from './errors.js';
16
16
  import { buildSearchAdoptionTelemetry, isTelemetryDisabled, SEARCH_MODES, sendCliUsageTelemetry, stripTelemetryFlag, } from './telemetry.js';
17
17
  import { isUpdateCheckDisabled, printUpdateNotice, startUpdateCheck, stripUpdateCheckFlag, } from './update-check.js';
18
18
  import { createCommandContext } from './command-context.js';
@@ -111,6 +111,8 @@ Commands:
111
111
  manifest Describe the CLI machine protocol and commands
112
112
  doctor Diagnose local Moda setup and data flow
113
113
  auth login Authenticate with Moda
114
+ auth status Show local auth session state (exit 4 when logged out); --online probes the API
115
+ auth token Print the raw bearer token for $(moda auth token)
114
116
  auth logout Clear cached Moda auth
115
117
  auth whoami Show the authenticated Moda user
116
118
  profiles list List local Moda profiles
@@ -759,7 +761,14 @@ export const commandRegistry = createCommandRegistry([
759
761
  {
760
762
  name: 'auth',
761
763
  description: 'Authenticate and inspect browser auth sessions',
762
- examples: ['moda auth login', 'moda auth whoami', 'moda auth logout'],
764
+ examples: [
765
+ 'moda auth login',
766
+ 'moda auth status',
767
+ 'moda auth status --online',
768
+ 'moda auth token',
769
+ 'moda auth whoami',
770
+ 'moda auth logout',
771
+ ],
763
772
  auth: 'none',
764
773
  outputModes: ['human', 'json', 'agent'],
765
774
  mutability: 'write',
@@ -1075,10 +1084,12 @@ export function classifyCliError(error) {
1075
1084
  if (error instanceof z.ZodError ||
1076
1085
  error instanceof CliInputError ||
1077
1086
  error instanceof CliInputRequiredError ||
1078
- error instanceof CliAuthError) {
1079
- // Auth/input-required are caller-actionable states (missing credentials,
1080
- // needs a choice), not unknown failures. Bucket them with validation so
1081
- // the missing-API-key case keeps its pre-hardening telemetry type.
1087
+ error instanceof CliAuthError ||
1088
+ error instanceof CliAccessError) {
1089
+ // Auth/input-required/access are caller-actionable states (missing
1090
+ // credentials, needs a choice, insufficient role), not unknown failures.
1091
+ // Bucket them with validation so the missing-API-key case keeps its
1092
+ // pre-hardening telemetry type.
1082
1093
  return 'validation_error';
1083
1094
  }
1084
1095
  if (error instanceof ApiError || isApiLikeError(error)) {
@@ -1095,6 +1106,11 @@ export function printCliError(error) {
1095
1106
  if (error.usage)
1096
1107
  safeWriteStderr(`${error.usage}\n`);
1097
1108
  }
1109
+ else if (error instanceof CliAccessError) {
1110
+ safeWriteStderr(`Error: ${error.message}\n`);
1111
+ if (error.guidance)
1112
+ safeWriteStderr(`${error.guidance}\n`);
1113
+ }
1098
1114
  else if (error instanceof z.ZodError) {
1099
1115
  const issues = error.errors.map((e) => {
1100
1116
  const field = e.path.length ? e.path.join('.') : 'input';
package/dist/config.d.ts CHANGED
@@ -17,8 +17,11 @@ export declare function loadCliConfig(options?: ProfileIOOptions): CliConfig;
17
17
  /**
18
18
  * Merge-and-write the persisted config. Always chmod 0600 since it holds the
19
19
  * API key. Best-effort: never throws on chmod failure (some filesystems).
20
+ * `options.profile` scopes the best-effort profile sync (e.g. `moda init
21
+ * --profile staging`), so the key is synced to the requested profile rather
22
+ * than whatever profile happens to be active.
20
23
  */
21
- export declare function saveCliConfig(update: CliConfig): void;
24
+ export declare function saveCliConfig(update: CliConfig, options?: ProfileIOOptions): void;
22
25
  /**
23
26
  * Resolve the API key: prefer the environment (so CI / explicit exports win),
24
27
  * then fall back to the persisted `~/.moda/config.json`. Read lazily so a
package/dist/config.js CHANGED
@@ -14,11 +14,14 @@ export function loadCliConfig(options = {}) {
14
14
  /**
15
15
  * Merge-and-write the persisted config. Always chmod 0600 since it holds the
16
16
  * API key. Best-effort: never throws on chmod failure (some filesystems).
17
+ * `options.profile` scopes the best-effort profile sync (e.g. `moda init
18
+ * --profile staging`), so the key is synced to the requested profile rather
19
+ * than whatever profile happens to be active.
17
20
  */
18
- export function saveCliConfig(update) {
19
- const merged = writeLegacyCliConfig(update);
21
+ export function saveCliConfig(update, options = {}) {
22
+ const merged = writeLegacyCliConfig(update, options);
20
23
  try {
21
- syncLegacyConfigToProfile(merged);
24
+ syncLegacyConfigToProfile(merged, options);
22
25
  }
23
26
  catch {
24
27
  // The legacy config write is the compatibility contract; profile sync is a
package/dist/errors.d.ts CHANGED
@@ -2,6 +2,19 @@ export declare class CliInputError extends Error {
2
2
  readonly usage?: string | undefined;
3
3
  constructor(message: string, usage?: string | undefined);
4
4
  }
5
+ /**
6
+ * The authenticated user cannot perform the requested action with their
7
+ * current access (no organizations, or insufficient role). This is an access
8
+ * problem, not missing auth — the dispatcher maps it to EXIT_ERROR (1),
9
+ * never EXIT_AUTH_REQUIRED.
10
+ */
11
+ export declare class CliAccessError extends Error {
12
+ /** Optional follow-up line telling the user how to obtain access. */
13
+ readonly guidance?: string;
14
+ constructor(message: string, options?: {
15
+ guidance?: string;
16
+ });
17
+ }
5
18
  export interface AgentInputChoice {
6
19
  value: string;
7
20
  label: string;
package/dist/errors.js CHANGED
@@ -6,6 +6,21 @@ export class CliInputError extends Error {
6
6
  this.name = 'CliInputError';
7
7
  }
8
8
  }
9
+ /**
10
+ * The authenticated user cannot perform the requested action with their
11
+ * current access (no organizations, or insufficient role). This is an access
12
+ * problem, not missing auth — the dispatcher maps it to EXIT_ERROR (1),
13
+ * never EXIT_AUTH_REQUIRED.
14
+ */
15
+ export class CliAccessError extends Error {
16
+ /** Optional follow-up line telling the user how to obtain access. */
17
+ guidance;
18
+ constructor(message, options = {}) {
19
+ super(message);
20
+ this.name = 'CliAccessError';
21
+ this.guidance = options.guidance;
22
+ }
23
+ }
9
24
  export class CliInputRequiredError extends Error {
10
25
  input_request;
11
26
  constructor(message, input_request, usage) {
@@ -1,4 +1,4 @@
1
- import { CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
1
+ import { CliAccessError, CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
2
2
  import { EXIT_AUTH_REQUIRED, EXIT_CANCELLED, EXIT_ERROR, EXIT_INPUT_REQUIRED, EXIT_OK, wasOutputCancelled, } from './output.js';
3
3
  const finishedContexts = new WeakSet();
4
4
  export async function finishRun(context, outcome) {
@@ -37,6 +37,10 @@ export function exitCodeForError(error) {
37
37
  return EXIT_INPUT_REQUIRED;
38
38
  if (error instanceof CliCancellationError)
39
39
  return EXIT_CANCELLED;
40
+ // An access problem (missing org / insufficient role) is a hard failure,
41
+ // never auth-required — it stays EXIT_ERROR (the default), made explicit here.
42
+ if (error instanceof CliAccessError)
43
+ return EXIT_ERROR;
40
44
  return EXIT_ERROR;
41
45
  }
42
46
  function exitCodeForOutcome(outcome) {
@@ -77,11 +77,17 @@ export async function runInit(flags, options = {}) {
77
77
  // Step 3: Tenant + API Key
78
78
  log(renderInitStep(3, 7, 'Workspace & Profile'));
79
79
  emit('workspace', 'Selecting workspace and creating API key.');
80
+ // --tenant-id flag wins over MODA_TENANT_ID; the source drives the
81
+ // env-shadow warning so we don't warn when the env var was the selection.
82
+ const tenantIdFlag = stringFlag(flagValue(flags, 'tenant-id', 'tenant_id'));
83
+ const tenantIdEnv = process.env.MODA_TENANT_ID;
80
84
  const { apiKey, tenantName } = await selectTenantAndCreateKey({
81
- tenantId: stringFlag(flagValue(flags, 'tenant-id', 'tenant_id')),
85
+ tenantId: tenantIdFlag ?? tenantIdEnv,
86
+ tenantIdSource: tenantIdFlag ? 'flag' : tenantIdEnv ? 'env' : undefined,
82
87
  nonInteractive: json || agent,
83
88
  silent: !human,
84
89
  resumeCommand: ['moda', 'init', ...resumeFlagsForInit(flags)],
90
+ profile: stringFlag(flags.profile),
85
91
  });
86
92
  log(renderInitNote(`Active workspace: ${tenantName}`));
87
93
  emit('workspace', `Active workspace: ${tenantName}`, 'completed');
@@ -4,11 +4,25 @@ interface BootstrapResult {
4
4
  tenantName: string;
5
5
  tenantSlug: string;
6
6
  }
7
- export declare function selectTenantAndCreateKey(options?: {
7
+ export interface SelectTenantAndCreateKeyOptions {
8
+ /** Tenant id preselected via --tenant-id (flag) or MODA_TENANT_ID (env). */
8
9
  tenantId?: string;
10
+ /** Where the preselected tenant id came from; drives the env-shadow warning. */
11
+ tenantIdSource?: 'flag' | 'env';
12
+ /** Non-interactive runs must never open the selection prompt. */
9
13
  nonInteractive?: boolean;
14
+ /** Suppress human prose so it never interleaves with JSON envelopes on stdout. */
10
15
  silent?: boolean;
16
+ /** Resume command echoed into the needs_input event for multi-tenant runs. */
11
17
  resumeCommand?: string[];
12
- }): Promise<BootstrapResult>;
18
+ /**
19
+ * Profile context (--profile) threaded from init: scopes the auth-session
20
+ * reads and request origin behind authFetch, the stored-key candidate
21
+ * lookup, and the liveness-probe base URL to the given profile.
22
+ */
23
+ profile?: string;
24
+ env?: NodeJS.ProcessEnv;
25
+ }
26
+ export declare function selectTenantAndCreateKey(options?: SelectTenantAndCreateKeyOptions): Promise<BootstrapResult>;
13
27
  export {};
14
28
  //# sourceMappingURL=tenant.d.ts.map
@@ -1,36 +1,133 @@
1
1
  // src/init/tenant.ts — Tenant selection and API key provisioning
2
2
  import { select } from '@inquirer/prompts';
3
3
  import { authFetch } from '../auth.js';
4
- import { resolveIngestUrl, saveCliConfig } from '../config.js';
5
- import { CliInputError, CliInputRequiredError } from '../errors.js';
4
+ import { loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
5
+ import { resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, } from '../profiles.js';
6
+ import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
7
+ // Mirrors the backend's checkAdminAccess: only the viewer role is denied.
8
+ const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
9
+ const ADMIN_ACCESS_GUIDANCE = 'Ask your organization owner to upgrade your role.';
10
+ const API_KEY_PREFIX = 'moda_sk_';
11
+ const SIGNED_KEY_VERSION = 'v1';
12
+ // Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
13
+ // endpoint must never block init — we just mint a fresh key instead.
14
+ const VALIDATE_TIMEOUT_MS = 3_000;
15
+ /**
16
+ * Signed CLI keys embed their tenant: the format is
17
+ * moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
18
+ * createApiKey/parseAndVerifySignedKey in
19
+ * apps/moda-backend/src/api-keys/api-keys.service.ts. Decoding locally lets
20
+ * dedup reject a cross-tenant or malformed key structurally, with no network
21
+ * call. The HMAC signature can only be verified server-side; liveness (not
22
+ * revoked) is probed separately by isStoredKeyLive().
23
+ */
24
+ function embeddedTenantId(key) {
25
+ if (!key.startsWith(API_KEY_PREFIX))
26
+ return undefined;
27
+ try {
28
+ const decoded = Buffer.from(key.slice(API_KEY_PREFIX.length), 'base64url').toString('utf8');
29
+ // Expected format: version:keyId:tenantId:timestamp:signature
30
+ const parts = decoded.split(':');
31
+ if (parts.length !== 5 || parts[0] !== SIGNED_KEY_VERSION)
32
+ return undefined;
33
+ return parts[2] || undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Liveness probe for a stored key. Deliberately NOT the backend's public
41
+ * POST /api/api-keys/validate: the CLI's resolved base URL is the FRONTEND
42
+ * origin (moda.dev in prod, localhost:3000 in dev), which has no
43
+ * /api/api-keys/validate route and no rewrite for it — only /api/v1/data/* is
44
+ * proxied to the backend. Probing the Data API is the only path that reaches
45
+ * key validation from here; revisit when a validate proxy is added. Only a
46
+ * 200 with a JSON body counts as live; 401/403 (revoked/invalid), any other
47
+ * status, a non-JSON 200 (e.g. a frontend error page), a network error, or a
48
+ * timeout all return false so init falls through to minting — never block.
49
+ * The key value must never appear in logs, errors, or telemetry.
50
+ */
51
+ async function isStoredKeyLive(key, options = {}) {
52
+ try {
53
+ // Resolve the base URL with the same profile/env context as the stored-key
54
+ // lookup, or a valid key for a non-active profile (e.g. `init --profile
55
+ // staging` while prod is active) is probed against the wrong origin, read
56
+ // as dead, and re-minted on every run.
57
+ const response = await fetch(`${resolveModaBaseUrl(options)}/api/v1/data/overview`, {
58
+ method: 'GET',
59
+ headers: { 'x-api-key': key },
60
+ signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS),
61
+ });
62
+ if (response.status !== 200)
63
+ return false;
64
+ await response.json();
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * A CLI key previously stored for this tenant, if any: the legacy
73
+ * ~/.moda/config.json first, then the active profile's key reference. Only a
74
+ * key whose stored tenant_id matches the selected tenant is a candidate.
75
+ */
76
+ function findStoredKeyForTenant(tenantId, options = {}) {
77
+ const legacy = loadCliConfig(options);
78
+ if (legacy.api_key && legacy.tenant_id === tenantId) {
79
+ return legacy.api_key;
80
+ }
81
+ // Honors options.profile when init was run with --profile; otherwise the
82
+ // active profile / MODA_PROFILE resolution applies.
83
+ if (resolveTenantIdFromActiveProfile(options) === tenantId) {
84
+ const profileKey = resolveApiKeyFromActiveProfile(options);
85
+ if (profileKey)
86
+ return profileKey;
87
+ }
88
+ return undefined;
89
+ }
6
90
  export async function selectTenantAndCreateKey(options = {}) {
91
+ const log = (message) => {
92
+ if (!options.silent)
93
+ console.log(message);
94
+ };
95
+ // Auth-session reads and stored-key lookups must honor init's --profile, or
96
+ // a profile-scoped login (secrets/auth/<profile>.json) is invisible here and
97
+ // init fails with "Not authenticated" despite a fresh token.
98
+ const authOptions = {
99
+ profile: options.profile,
100
+ env: options.env,
101
+ };
7
102
  // 1. Fetch CLI context
8
- const contextRes = await authFetch('/api/cli/context');
103
+ const contextRes = await authFetch('/api/cli/context', {}, authOptions);
9
104
  if (!contextRes.ok) {
10
105
  const body = await contextRes.text();
11
106
  throw new Error(`Failed to fetch context (${contextRes.status}): ${body}`);
12
107
  }
13
108
  const ctx = (await contextRes.json());
14
- if (!options.silent)
15
- console.log(` Authenticated as ${ctx.user.email}`);
109
+ log(` Authenticated as ${ctx.user.email}`);
16
110
  // 2. Select tenant
17
111
  if (ctx.tenants.length === 0) {
18
- throw new CliInputError('No organizations found. Create one at https://moda.dev', 'Create an organization in Moda, then rerun `moda init`.');
112
+ throw new CliAccessError('No organizations found. Create one at https://moda.dev');
19
113
  }
20
- let tenantId;
21
- if (options.tenantId) {
22
- const selected = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
23
- if (!selected) {
24
- throw new CliInputError(`Tenant '${options.tenantId}' is not available for the authenticated user.`, 'Run `moda init` without --tenant-id in a human terminal, or choose a tenant from the needs_input event.');
114
+ let selected;
115
+ if (options.tenantId !== undefined) {
116
+ // Exact id match only (case-sensitive; never slug/name, never a silent
117
+ // fallback to another tenant).
118
+ const match = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
119
+ if (!match) {
120
+ const valid = ctx.tenants
121
+ .map((tenant) => ` ${tenant.id} ${tenant.name} (${tenant.role})`)
122
+ .join('\n');
123
+ throw new CliInputError(`No organization with id '${options.tenantId}'. Valid organizations:\n${valid}`, 'Re-run with --tenant-id=<id> using one of the ids listed above.');
25
124
  }
26
- tenantId = selected.id;
27
- if (!options.silent)
28
- console.log(` Organization: ${selected.name}`);
125
+ selected = match;
126
+ log(` Organization: ${selected.name}`);
29
127
  }
30
128
  else if (ctx.tenants.length === 1) {
31
- tenantId = ctx.tenants[0].id;
32
- if (!options.silent)
33
- console.log(` Organization: ${ctx.tenants[0].name}`);
129
+ selected = ctx.tenants[0];
130
+ log(` Organization: ${selected.name}`);
34
131
  }
35
132
  else if (options.nonInteractive) {
36
133
  throw new CliInputRequiredError('Multiple Moda organizations are available; choose one and re-run with --tenant-id.', {
@@ -46,28 +143,52 @@ export async function selectTenantAndCreateKey(options = {}) {
46
143
  }, 'Re-run `moda init --tenant-id <tenant_id>`.');
47
144
  }
48
145
  else {
49
- tenantId = await select({
146
+ selected = await select({
50
147
  message: 'Select organization',
51
- choices: ctx.tenants.map((t) => ({
52
- name: `${t.name} (${t.role})`,
53
- value: t.id,
148
+ choices: ctx.tenants.map((tenant) => ({
149
+ name: `${tenant.name} (${tenant.role})`,
150
+ value: tenant,
54
151
  })),
55
152
  });
56
153
  }
57
- // 3. Create API key via bootstrap endpoint
58
- const bootstrapRes = await authFetch('/api/cli/bootstrap', {
59
- method: 'POST',
60
- headers: { 'Content-Type': 'application/json' },
61
- body: JSON.stringify({ tenantId }),
62
- });
63
- if (bootstrapRes.status === 403) {
64
- throw new CliInputError('You need admin or owner access to create API keys.', 'Ask your organization owner to upgrade your role.');
154
+ // Role pre-check: the backend's bootstrap endpoint 403s viewers, so fail
155
+ // before creating anything. The 403 handler below stays as a backstop.
156
+ if (selected.role.toLowerCase() === 'viewer') {
157
+ throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
158
+ }
159
+ // 3. Reuse a previously minted key for this tenant when it structurally
160
+ // belongs to it (embedded tenant id) AND still works against the Data API,
161
+ // so re-running init does not accumulate one CLI key per run. A failed
162
+ // decode, tenant mismatch, or probe failure falls through to minting.
163
+ let result;
164
+ const storedKey = findStoredKeyForTenant(selected.id, authOptions);
165
+ if (storedKey &&
166
+ embeddedTenantId(storedKey) === selected.id &&
167
+ (await isStoredKeyLive(storedKey, authOptions))) {
168
+ log(` Using existing CLI key for ${selected.name} (created earlier)`);
169
+ result = {
170
+ apiKey: storedKey,
171
+ tenantId: selected.id,
172
+ tenantName: selected.name,
173
+ tenantSlug: selected.slug,
174
+ };
65
175
  }
66
- if (!bootstrapRes.ok) {
67
- const body = await bootstrapRes.text();
68
- throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
176
+ // 4. Create API key via bootstrap endpoint
177
+ if (!result) {
178
+ const bootstrapRes = await authFetch('/api/cli/bootstrap', {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ tenantId: selected.id }),
182
+ }, authOptions);
183
+ if (bootstrapRes.status === 403) {
184
+ throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
185
+ }
186
+ if (!bootstrapRes.ok) {
187
+ const body = await bootstrapRes.text();
188
+ throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
189
+ }
190
+ result = (await bootstrapRes.json());
69
191
  }
70
- const result = (await bootstrapRes.json());
71
192
  // Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
72
193
  // hook can authenticate (api_key) and the local dev orchestrator can scope
73
194
  // ClickHouse (tenant_id) without the user exporting env vars into the shell
@@ -75,12 +196,17 @@ export async function selectTenantAndCreateKey(options = {}) {
75
196
  saveCliConfig({
76
197
  api_key: result.apiKey,
77
198
  tenant_id: result.tenantId,
78
- ingest_url: resolveIngestUrl(),
79
- });
80
- if (!options.silent) {
81
- console.log(' API key + tenant saved to ~/.moda/config.json\n');
82
- console.log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
199
+ ingest_url: resolveIngestUrl(authOptions),
200
+ }, authOptions);
201
+ // resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
202
+ // env var silently shadows the selection we just saved. Warn unless the env
203
+ // var was itself the source of the selection.
204
+ const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
205
+ if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
206
+ console.error(` Warning: MODA_TENANT_ID is set to a different organization id and will shadow the saved selection (${result.tenantId}) for later commands. Unset it or set it to the selected organization id.`);
83
207
  }
208
+ log(' API key + tenant saved to ~/.moda/config.json\n');
209
+ log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
84
210
  return result;
85
211
  }
86
212
  //# sourceMappingURL=tenant.js.map
package/dist/output.d.ts CHANGED
@@ -19,7 +19,7 @@ export declare const EXIT_DEGRADED = 3;
19
19
  export declare const EXIT_AUTH_REQUIRED = 4;
20
20
  export declare const EXIT_INPUT_REQUIRED = 5;
21
21
  export declare const EXIT_CANCELLED = 130;
22
- export declare const AGENT_ERROR_CODES: readonly ["input_error", "api_error", "network_error", "auth_required", "input_required", "cancelled", "command_error", "unknown_error", "cloud_unavailable"];
22
+ export declare const AGENT_ERROR_CODES: readonly ["input_error", "api_error", "network_error", "auth_required", "input_required", "cancelled", "command_error", "access_denied", "unknown_error", "cloud_unavailable"];
23
23
  export type AgentErrorCode = typeof AGENT_ERROR_CODES[number];
24
24
  export type AgentStatus = 'ok' | 'degraded' | 'error';
25
25
  export type AgentConfidence = 'high' | 'medium' | 'low' | 'unknown';
package/dist/output.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError, } from './errors.js';
2
+ import { CliAccessError, CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError, } from './errors.js';
3
3
  import { redactDeep } from './redact.js';
4
4
  export const AGENT_SCHEMA_VERSION = 'moda.agent.v1';
5
5
  export const AGENT_EVENT_SCHEMA_VERSION = 'moda.agent_event.v1';
@@ -28,6 +28,7 @@ export const AGENT_ERROR_CODES = [
28
28
  'input_required',
29
29
  'cancelled',
30
30
  'command_error',
31
+ 'access_denied',
31
32
  'unknown_error',
32
33
  'cloud_unavailable',
33
34
  ];
@@ -416,6 +417,16 @@ export function errorToAgentError(error) {
416
417
  blocking: true,
417
418
  };
418
419
  }
420
+ if (error instanceof CliAccessError) {
421
+ // Access problem (missing organization or insufficient role), not
422
+ // auth-missing: exit stays EXIT_ERROR and no login command is suggested.
423
+ return {
424
+ code: 'access_denied',
425
+ message: error.message,
426
+ cause: error.guidance,
427
+ blocking: true,
428
+ };
429
+ }
419
430
  if (error instanceof Error) {
420
431
  if (error instanceof CliInputError || error.name === 'ZodError') {
421
432
  return {
@@ -1,7 +1,11 @@
1
- import { authFetch, clearAuthSession, loadAuthSession, login } from './auth.js';
1
+ import { authFetch, clearAuthSession, isAuthSessionValid, loadAuthSession, login } from './auth.js';
2
+ import { EXIT_AUTH_REQUIRED, EXIT_OK } from './output.js';
2
3
  import { KEY_LIFECYCLE_ENDPOINT_REQUIREMENTS, createLocalKeyDescriptor, loadLocalKeyDescriptors, upsertLocalKeyDescriptor, writeLocalApiKeySecret, } from './keys.js';
3
4
  import { DEFAULT_INGEST_URL, PROD_BASE_URL, ensureSecretFilePermissions, getActiveProfile, getModaPaths, loadProfileConfig, resolveApiKeyFromActiveProfile, resolveSecretReference, secretFilePathFromRef, setActiveProfile, upsertProfile, } from './profiles.js';
4
5
  import { CliInputError } from './errors.js';
6
+ // Bound the `auth status --online` probe: without a signal, undici's defaults
7
+ // can stall a status check for minutes on a black-holed connection.
8
+ const ONLINE_PROBE_TIMEOUT_MS = 5_000;
5
9
  function subcommand(context) {
6
10
  return context.positionals[0];
7
11
  }
@@ -60,7 +64,7 @@ export async function runAuthCommand(context) {
60
64
  switch (subcommand(context)) {
61
65
  case 'login': {
62
66
  const screen = flag(context, 'screen') === 'signup' ? 'signup' : 'login';
63
- await login(screen);
67
+ await login(screen, { profile: context.profile, env: context.env });
64
68
  context.output.writeData({
65
69
  status: 'authenticated',
66
70
  profile: context.profile ?? process.env.MODA_PROFILE ?? null,
@@ -96,8 +100,73 @@ export async function runAuthCommand(context) {
96
100
  });
97
101
  return { exitCode: 0 };
98
102
  }
103
+ case 'status': {
104
+ // Being logged out is an expected state for `auth status`, not a
105
+ // failure: it must never throw CliAuthError (no error block, no error
106
+ // telemetry classification) and must never trigger a browser open. It
107
+ // reports clean data in every mode and signals session validity purely
108
+ // through the exit code (0 valid, 4 auth required).
109
+ const authOptions = { profile: context.profile, env: context.env };
110
+ const session = loadAuthSession({ ...authOptions, includeExpired: true });
111
+ const loggedIn = isAuthSessionValid(session);
112
+ const data = {
113
+ loggedIn,
114
+ profile: context.profile ??
115
+ context.env.MODA_PROFILE ??
116
+ session?.profile ??
117
+ null,
118
+ expiresAt: session
119
+ ? new Date(session.expires_at * 1000).toISOString()
120
+ : null,
121
+ };
122
+ if (boolFlag(context, 'online')) {
123
+ let online = { reachable: false };
124
+ try {
125
+ const response = await authFetch('/api/cli/context', { signal: AbortSignal.timeout(ONLINE_PROBE_TIMEOUT_MS) }, authOptions);
126
+ // Any HTTP response means the server was reached; `ok` separates a
127
+ // healthy 2xx from e.g. a 401/500.
128
+ online = { reachable: true, ok: response.ok };
129
+ if (response.ok) {
130
+ const body = await response.json();
131
+ if (Array.isArray(body.tenants)) {
132
+ online.tenantCount = body.tenants.length;
133
+ }
134
+ }
135
+ }
136
+ catch {
137
+ // Network failure, probe timeout, or no stored token to send: report
138
+ // reachable: false; the exit code still follows local session
139
+ // validity, never the online probe.
140
+ }
141
+ data.online = online;
142
+ }
143
+ context.output.writeData(data);
144
+ return { exitCode: loggedIn ? EXIT_OK : EXIT_AUTH_REQUIRED };
145
+ }
146
+ case 'token': {
147
+ const session = loadAuthSession({ profile: context.profile, env: context.env });
148
+ if (!session) {
149
+ if (context.flags.json === 'true') {
150
+ // An explicit --json caller gets a parseable failure on stdout.
151
+ process.stdout.write(`${JSON.stringify({ error: 'not_authenticated' })}\n`);
152
+ }
153
+ else {
154
+ console.error('Not authenticated. Run "moda auth login".');
155
+ }
156
+ return { exitCode: EXIT_AUTH_REQUIRED };
157
+ }
158
+ // `auth token` exists to compose as $(moda auth token) and in pipes.
159
+ // Command substitution IS a pipe, so stdout is a non-TTY and this CLI
160
+ // would auto-select agent mode and wrap the token in a JSON envelope,
161
+ // breaking every composition. This command therefore deliberately
162
+ // bypasses the CliOutput envelope machinery in EVERY output mode
163
+ // (human/json/agent/agent-stream) and writes exactly the bare token plus
164
+ // a trailing newline to stdout — nothing else.
165
+ process.stdout.write(`${session.access_token}\n`);
166
+ return { exitCode: EXIT_OK };
167
+ }
99
168
  default:
100
- throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|whoami>');
169
+ throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|status|token|whoami>');
101
170
  }
102
171
  }
103
172
  export async function runProfilesCommand(context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.12.6",
3
+ "version": "1.13.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-06T09:38:49.718Z",
4
- "cli_version": "1.12.6",
3
+ "bundled_at": "2026-07-06T09:53:55.227Z",
4
+ "cli_version": "1.13.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",
@@ -140,7 +140,10 @@ Fields an agent should read:
140
140
 
141
141
  `errors[].code` is one of `input_error`, `api_error`, `network_error`,
142
142
  `auth_required`, `input_required`, `cancelled`, `command_error`,
143
- `unknown_error`, or a documented domain code such as `cloud_unavailable`.
143
+ `access_denied`, `unknown_error`, or a documented domain code such as
144
+ `cloud_unavailable`. `access_denied` (exit 1) is an access problem — no
145
+ organizations or an insufficient (viewer) role — distinct from `auth_required`
146
+ (exit 4, missing/expired credentials).
144
147
 
145
148
  ### The `moda.agent_event.v1` stream
146
149