@moda-ai/cli 1.13.1 → 1.13.2

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.js CHANGED
@@ -52,7 +52,17 @@ function safeEqual(a, b) {
52
52
  function looksLikeOneTimeCode(value) {
53
53
  return /^[A-Za-z0-9_-]{32,96}$/.test(value);
54
54
  }
55
- // --- PKCE (RFC 7636, S256) ---
55
+ class CodeExchangeError extends Error {
56
+ reason;
57
+ constructor(message, reason) {
58
+ super(message);
59
+ this.reason = reason;
60
+ this.name = 'CodeExchangeError';
61
+ }
62
+ }
63
+ function exchangeFailureReason(error) {
64
+ return error instanceof CodeExchangeError ? error.reason : 'exchange_failed';
65
+ }
56
66
  /** base64url(sha256(verifier)) — the S256 code challenge for a verifier. */
57
67
  export function computePkceChallenge(codeVerifier) {
58
68
  return createHash('sha256').update(codeVerifier).digest('base64url');
@@ -186,6 +196,10 @@ export async function login(screen = 'login', options = {}) {
186
196
  }
187
197
  exchangeInFlight = true;
188
198
  void (async () => {
199
+ // A PKCE grant implies a frontend deployment new enough to host
200
+ // /auth/cli/done, so the browser is bounced to the branded page
201
+ // instead of inline HTML. Only fixed, token-free params ride the
202
+ // Location header — never the code, verifier, token, or state.
189
203
  try {
190
204
  const cliToken = await exchangeCodeForSession(code);
191
205
  // Leave exchangeInFlight true on success: succeed() only runs
@@ -194,16 +208,23 @@ export async function login(screen = 'login', options = {}) {
194
208
  // callback could start a concurrent exchange before `settled`
195
209
  // flips. The flag only needs to clear on failure, where a
196
210
  // retry paste is still legitimate.
197
- res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
198
- res.end(SUCCESS_HTML, () => {
211
+ res.writeHead(302, {
212
+ Location: `${resolveModaBaseUrl()}/auth/cli/done?status=success`,
213
+ Connection: 'close',
214
+ });
215
+ res.end(() => {
199
216
  succeed(cliToken);
200
217
  });
201
218
  }
202
219
  catch (err) {
203
220
  exchangeInFlight = false;
204
221
  const error = err instanceof Error ? err : new Error(String(err));
205
- res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
206
- res.end(`<html><body><h2>Error</h2><p>${escapeHtml(error.message)}</p></body></html>`, () => {
222
+ res.writeHead(302, {
223
+ Location: `${resolveModaBaseUrl()}/auth/cli/done` +
224
+ `?status=error&reason=${exchangeFailureReason(err)}`,
225
+ Connection: 'close',
226
+ });
227
+ res.end(() => {
207
228
  fail(error);
208
229
  });
209
230
  }
@@ -349,30 +370,30 @@ export async function login(screen = 'login', options = {}) {
349
370
  });
350
371
  }
351
372
  catch {
352
- throw new Error('Could not reach the Moda API to exchange the login code. Check your network and try again.');
373
+ throw new CodeExchangeError('Could not reach the Moda API to exchange the login code. Check your network and try again.', 'unreachable');
353
374
  }
354
375
  if (response.status >= 300 && response.status < 400) {
355
- throw new Error(`The Moda API redirected the login code exchange (HTTP ${response.status}). ` +
376
+ throw new CodeExchangeError(`The Moda API redirected the login code exchange (HTTP ${response.status}). ` +
356
377
  'Refusing to re-send the login code across a redirect. ' +
357
- 'Check MODA_BASE_URL (use https://moda.dev, not www.moda.dev) and try again.');
378
+ 'Check MODA_BASE_URL (use https://moda.dev, not www.moda.dev) and try again.', 'exchange_failed');
358
379
  }
359
380
  if (response.status === 401) {
360
- throw new Error('The login code was rejected (invalid, expired, or already used). Run the login command again.');
381
+ throw new CodeExchangeError('The login code was rejected (invalid, expired, or already used). Run the login command again.', 'code_rejected');
361
382
  }
362
383
  if (!response.ok) {
363
- throw new Error(`Login code exchange failed (HTTP ${response.status}). Try again.`);
384
+ throw new CodeExchangeError(`Login code exchange failed (HTTP ${response.status}). Try again.`, 'exchange_failed');
364
385
  }
365
386
  let payload;
366
387
  try {
367
388
  payload = (await response.json());
368
389
  }
369
390
  catch {
370
- throw new Error('The login code exchange returned an unreadable response. Try again.');
391
+ throw new CodeExchangeError('The login code exchange returned an unreadable response. Try again.', 'exchange_failed');
371
392
  }
372
393
  const cliToken = typeof payload.cli_token === 'string' ? payload.cli_token : undefined;
373
394
  const expiresAtMs = typeof payload.expires_at === 'string' ? Date.parse(payload.expires_at) : NaN;
374
395
  if (!cliToken || !Number.isFinite(expiresAtMs)) {
375
- throw new Error('The login code exchange returned an incomplete response. Try again.');
396
+ throw new CodeExchangeError('The login code exchange returned an incomplete response. Try again.', 'exchange_failed');
376
397
  }
377
398
  const userEmail = typeof payload.user?.email === 'string' ? payload.user.email : undefined;
378
399
  persistAuthSession({
package/dist/cli.d.ts CHANGED
@@ -11,6 +11,16 @@ export interface ParsedArgs {
11
11
  }
12
12
  export declare function parseArgs(argv: string[]): ParsedArgs;
13
13
  export declare function flagsToArgs(flags: Record<string, string>, positionalKey?: string, positionalValue?: string): Record<string, unknown>;
14
+ /**
15
+ * Build the argv the caller should re-run to resume an interrupted command,
16
+ * echoing back the raw flags exactly as parsed (so per-invocation opt-outs
17
+ * like --no-telemetry survive). Treats `--flag` and `--flag=true` identically.
18
+ *
19
+ * Limitation: positional arguments are not included — the current callers
20
+ * (`moda init`, `moda provision`) take none. Extend the signature before
21
+ * reusing this for a command with positionals.
22
+ */
23
+ export declare function buildResumeCommand(command: string, flags: Record<string, string>): string[];
14
24
  export declare function runCommand(command: string, positional: string | undefined, flags: Record<string, string>, positionals?: string[], context?: CommandContext): Promise<number>;
15
25
  interface CommandDispatchResult {
16
26
  exitCode: number;
package/dist/cli.js CHANGED
@@ -101,12 +101,29 @@ export function flagsToArgs(flags, positionalKey, positionalValue) {
101
101
  }
102
102
  return result;
103
103
  }
104
+ /**
105
+ * Build the argv the caller should re-run to resume an interrupted command,
106
+ * echoing back the raw flags exactly as parsed (so per-invocation opt-outs
107
+ * like --no-telemetry survive). Treats `--flag` and `--flag=true` identically.
108
+ *
109
+ * Limitation: positional arguments are not included — the current callers
110
+ * (`moda init`, `moda provision`) take none. Extend the signature before
111
+ * reusing this for a command with positionals.
112
+ */
113
+ export function buildResumeCommand(command, flags) {
114
+ return [
115
+ 'moda',
116
+ command,
117
+ ...Object.entries(flags).map(([key, value]) => value === 'true' ? `--${key}` : `--${key}=${value}`),
118
+ ];
119
+ }
104
120
  // --- Help ---
105
121
  function printHelp() {
106
122
  console.error(`Usage: moda <command> [options]
107
123
 
108
124
  Commands:
109
125
  init Set up Moda in your project
126
+ provision Headless tenant + API key provisioning (JSON credentials on stdout)
110
127
  status Show concise local Moda setup status
111
128
  manifest Describe the CLI machine protocol and commands
112
129
  doctor Diagnose local Moda setup and data flow
@@ -234,8 +251,17 @@ Init flags:
234
251
  --no-autosync Skip autosync workflow creation
235
252
  --offline Run final validation offline (skip the online telemetry check)
236
253
 
254
+ Provision flags (headless; requires an existing "moda auth login" session):
255
+ --tenant-id=ID Organization to provision (required with multiple
256
+ organizations; MODA_TENANT_ID env is also honored,
257
+ flag wins). Never prompts, even on a TTY.
258
+ --label=TEXT API key label (default: machine hostname)
259
+ --save Also persist the key to ~/.moda/config.json
260
+ (default: print only; headless callers own storage)
261
+
237
262
  Examples:
238
263
  moda init
264
+ moda provision --tenant-id=<org-id>
239
265
  moda status
240
266
  moda manifest --json
241
267
  moda doctor --json
@@ -734,6 +760,32 @@ export const commandRegistry = createCommandRegistry([
734
760
  return { exitCode: 0, apiKey: result.apiKey };
735
761
  },
736
762
  },
763
+ {
764
+ name: 'provision',
765
+ description: 'Headless tenant + API key provisioning for CI and agents (JSON credentials on stdout)',
766
+ examples: [
767
+ 'moda provision',
768
+ 'moda provision --tenant-id=<org-id>',
769
+ 'moda provision --tenant-id=<org-id> --label=ci-runner --save',
770
+ 'KEY=$(moda provision --tenant-id <org-id> | jq -r .apiKey)',
771
+ ],
772
+ auth: 'none',
773
+ outputModes: ['human', 'json', 'agent'],
774
+ mutability: 'write',
775
+ validateConfigBeforeRun: false,
776
+ resetApiRequestCountBeforeRun: true,
777
+ // result-and-error like init: recoverable failures (auth_required /
778
+ // input_required / access_error) are recorded, not just successes.
779
+ telemetry: 'result-and-error',
780
+ handler: async (context) => {
781
+ const { runProvision } = await import('./provision.js');
782
+ return runProvision(context, {
783
+ // rawFlags (not context.flags) so per-invocation opt-outs like
784
+ // --no-telemetry survive into the resume command.
785
+ resumeCommand: buildResumeCommand('provision', context.rawFlags),
786
+ });
787
+ },
788
+ },
737
789
  {
738
790
  name: 'manifest',
739
791
  description: 'Describe the CLI machine protocol, commands, events, and exit codes',
@@ -38,6 +38,13 @@ export interface CommandContext {
38
38
  /** Snapshot of telemetry recorded so far by the handler. */
39
39
  getTelemetryMeta(): CommandTelemetryMeta;
40
40
  }
41
+ /**
42
+ * Normalize a raw flag/env value to a trimmed non-empty string. Accepts
43
+ * non-string values because flagsToArgs number-coerces numeric-looking flag
44
+ * values (which must never happen to tenant ids, profile names, or labels);
45
+ * empty values (`--label=`) become undefined so defaults apply.
46
+ */
47
+ export declare function stringOption(value: unknown): string | undefined;
41
48
  export declare function createCommandContext(input: CommandContextInput, options?: {
42
49
  commandName?: string;
43
50
  commandFlags?: Record<string, string>;
@@ -2,6 +2,18 @@ import { createCliOutput, outputModeFromFlags } from './output.js';
2
2
  function flagEnabled(flags, key) {
3
3
  return flags[key] === 'true';
4
4
  }
5
+ /**
6
+ * Normalize a raw flag/env value to a trimmed non-empty string. Accepts
7
+ * non-string values because flagsToArgs number-coerces numeric-looking flag
8
+ * values (which must never happen to tenant ids, profile names, or labels);
9
+ * empty values (`--label=`) become undefined so defaults apply.
10
+ */
11
+ export function stringOption(value) {
12
+ if (value === undefined || value === null)
13
+ return undefined;
14
+ const text = String(value).trim();
15
+ return text.length > 0 ? text : undefined;
16
+ }
5
17
  export function createCommandContext(input, options = {}) {
6
18
  const flags = options.commandFlags ?? input.flags;
7
19
  const rawFlags = options.rawFlags ?? input.flags;
@@ -4,17 +4,39 @@ interface BootstrapResult {
4
4
  tenantName: string;
5
5
  tenantSlug: string;
6
6
  }
7
+ export interface TenantKeyResult extends BootstrapResult {
8
+ /**
9
+ * True when a previously stored key for the selected tenant passed the
10
+ * liveness probe and was reused instead of minting a new one.
11
+ */
12
+ reused: boolean;
13
+ }
7
14
  export interface SelectTenantAndCreateKeyOptions {
8
15
  /** Tenant id preselected via --tenant-id (flag) or MODA_TENANT_ID (env). */
9
16
  tenantId?: string;
10
17
  /** Where the preselected tenant id came from; drives the env-shadow warning. */
11
18
  tenantIdSource?: 'flag' | 'env';
12
- /** Non-interactive runs must never open the selection prompt. */
19
+ /**
20
+ * Non-interactive runs must never open the selection prompt. Headless-by-
21
+ * contract callers (`moda provision`) pass true unconditionally — regardless
22
+ * of terminal state.
23
+ */
13
24
  nonInteractive?: boolean;
14
25
  /** Suppress human prose so it never interleaves with JSON envelopes on stdout. */
15
26
  silent?: boolean;
16
27
  /** Resume command echoed into the needs_input event for multi-tenant runs. */
17
28
  resumeCommand?: string[];
29
+ /**
30
+ * Bootstrap key label sent to the backend (clipped to its 60-char cap).
31
+ * Defaults to the machine hostname.
32
+ */
33
+ label?: string;
34
+ /**
35
+ * When false, skip the ~/.moda/config.json persistence entirely —
36
+ * headless callers (`moda provision`) own their credential storage.
37
+ * Defaults to true (init behavior).
38
+ */
39
+ persistConfig?: boolean;
18
40
  /**
19
41
  * Profile context (--profile) threaded from init: scopes the auth-session
20
42
  * reads and request origin behind authFetch, the stored-key candidate
@@ -22,7 +44,17 @@ export interface SelectTenantAndCreateKeyOptions {
22
44
  */
23
45
  profile?: string;
24
46
  env?: NodeJS.ProcessEnv;
47
+ /**
48
+ * Test seam for the post-bootstrap validate-retry: override the retry
49
+ * delays and/or the sleep implementation so tests never wait on real
50
+ * timers. Production callers leave this unset (defaults:
51
+ * POST_BOOTSTRAP_VALIDATE_DELAYS_MS via setTimeout).
52
+ */
53
+ validateRetry?: {
54
+ delaysMs?: readonly number[];
55
+ sleep?: (ms: number) => Promise<void>;
56
+ };
25
57
  }
26
- export declare function selectTenantAndCreateKey(options?: SelectTenantAndCreateKeyOptions): Promise<BootstrapResult>;
58
+ export declare function selectTenantAndCreateKey(options?: SelectTenantAndCreateKeyOptions): Promise<TenantKeyResult>;
27
59
  export {};
28
60
  //# sourceMappingURL=tenant.d.ts.map
@@ -1,8 +1,12 @@
1
1
  // src/init/tenant.ts — Tenant selection and API key provisioning
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
4
+ import { hostname } from 'node:os';
5
+ import { join } from 'node:path';
2
6
  import { select } from '@inquirer/prompts';
3
7
  import { authFetch, getCachedLoginContext } from '../auth.js';
4
8
  import { loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
5
- import { resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, } from '../profiles.js';
9
+ import { getModaDir, resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, writeSecretJsonFile, } from '../profiles.js';
6
10
  import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
7
11
  // Mirrors the backend's checkAdminAccess: only the viewer role is denied.
8
12
  const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
@@ -12,6 +16,118 @@ const SIGNED_KEY_VERSION = 'v1';
12
16
  // Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
13
17
  // endpoint must never block init — we just mint a fresh key instead.
14
18
  const VALIDATE_TIMEOUT_MS = 3_000;
19
+ /**
20
+ * Post-bootstrap validate-retry schedule (WP-2.3): key provisioning spans
21
+ * Postgres + Cloudflare KV, so a freshly minted key can lag a moment before
22
+ * it validates. Retry the liveness probe at these delays (3 attempts after
23
+ * the initial call, ~3.5s of retry sleeps plus up to VALIDATE_TIMEOUT_MS per
24
+ * probe against a hanging endpoint) before warning and moving on.
25
+ */
26
+ const POST_BOOTSTRAP_VALIDATE_DELAYS_MS = [
27
+ 500, 1_000, 2_000,
28
+ ];
29
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
30
+ // The backend truncates the bootstrap label to 60 printable chars; clip the
31
+ // hostname client-side too so what we send is what lands on the key name.
32
+ const MAX_LABEL_LENGTH = 60;
33
+ // Backend contract for the Idempotency-Key header (UUIDs match this too).
34
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
35
+ /**
36
+ * State file backing bootstrap idempotency across process restarts:
37
+ * ~/.moda/pending-bootstrap.json maps tenant id -> the Idempotency-Key of an
38
+ * in-flight (or failed) bootstrap POST. Written BEFORE the POST and cleared
39
+ * on success, so a retry after a network failure reuses the same key and the
40
+ * backend replays the original response instead of minting another ApiKey.
41
+ *
42
+ * Deliberately global (~/.moda, like config.json) and shared across
43
+ * profiles: the server scopes keys to the authenticated user, so the worst
44
+ * case of cross-profile sharing is one extra key minted — not worth
45
+ * profile-scoping the file.
46
+ */
47
+ function pendingBootstrapPath(options = {}) {
48
+ return join(getModaDir(options), 'pending-bootstrap.json');
49
+ }
50
+ function loadPendingBootstrapState(options = {}) {
51
+ try {
52
+ const path = pendingBootstrapPath(options);
53
+ if (!existsSync(path))
54
+ return {};
55
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
56
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
57
+ return {};
58
+ }
59
+ const state = {};
60
+ for (const [tenantId, entry] of Object.entries(parsed)) {
61
+ if (entry &&
62
+ typeof entry === 'object' &&
63
+ typeof entry.idempotency_key === 'string') {
64
+ state[tenantId] = entry;
65
+ }
66
+ }
67
+ return state;
68
+ }
69
+ catch {
70
+ // A corrupt state file must never block init; fresh keys are always safe.
71
+ return {};
72
+ }
73
+ }
74
+ /**
75
+ * Return the Idempotency-Key to use for this tenant's bootstrap POST,
76
+ * persisting it before the network call. A key left over from a previous
77
+ * failed attempt is reused so the retry replays instead of double-minting.
78
+ * File-system failures degrade to an in-memory key (init must never block).
79
+ */
80
+ function reserveBootstrapIdempotencyKey(tenantId, options = {}) {
81
+ const state = loadPendingBootstrapState(options);
82
+ const existing = state[tenantId]?.idempotency_key;
83
+ if (existing && IDEMPOTENCY_KEY_PATTERN.test(existing)) {
84
+ return existing;
85
+ }
86
+ const key = randomUUID();
87
+ state[tenantId] = {
88
+ idempotency_key: key,
89
+ created_at: new Date().toISOString(),
90
+ };
91
+ try {
92
+ writeSecretJsonFile(pendingBootstrapPath(options), state);
93
+ }
94
+ catch {
95
+ // Best-effort persistence: without it a retry mints a fresh key, which
96
+ // is the pre-idempotency behavior, not a failure.
97
+ }
98
+ return key;
99
+ }
100
+ /** Clear the tenant's pending key after a successful bootstrap. */
101
+ function clearBootstrapIdempotencyKey(tenantId, options = {}) {
102
+ try {
103
+ const state = loadPendingBootstrapState(options);
104
+ if (!(tenantId in state))
105
+ return;
106
+ delete state[tenantId];
107
+ const path = pendingBootstrapPath(options);
108
+ if (Object.keys(state).length === 0) {
109
+ rmSync(path, { force: true });
110
+ }
111
+ else {
112
+ writeSecretJsonFile(path, state);
113
+ }
114
+ }
115
+ catch {
116
+ // Leftover state is harmless: the key replays the stored response.
117
+ }
118
+ }
119
+ /** Label sent in the bootstrap body; the backend sanitizes it further. */
120
+ function bootstrapLabel(custom) {
121
+ const clipped = custom?.trim().slice(0, MAX_LABEL_LENGTH);
122
+ if (clipped)
123
+ return clipped;
124
+ try {
125
+ return hostname().slice(0, MAX_LABEL_LENGTH) || 'moda init';
126
+ }
127
+ catch {
128
+ return 'moda init';
129
+ }
130
+ }
15
131
  /**
16
132
  * Signed CLI keys embed their tenant: the format is
17
133
  * moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
@@ -87,6 +203,27 @@ function findStoredKeyForTenant(tenantId, options = {}) {
87
203
  }
88
204
  return undefined;
89
205
  }
206
+ /**
207
+ * Post-bootstrap verification (WP-2.3): confirm a freshly minted key is live
208
+ * before declaring success, reusing the same Data API probe as dedup.
209
+ * Provisioning spans Postgres + Cloudflare KV (eventual consistency), so
210
+ * failures are retried at the POST_BOOTSTRAP_VALIDATE_DELAYS_MS schedule.
211
+ * Exhaustion is a stderr warning, never an init failure: init's final doctor
212
+ * step surfaces persistent problems, and blocking here past the retry
213
+ * schedule would punish every user for a transient propagation delay.
214
+ */
215
+ async function verifyFreshKeyLive(key, probeOptions, overrides) {
216
+ const delays = overrides?.delaysMs ?? POST_BOOTSTRAP_VALIDATE_DELAYS_MS;
217
+ const sleep = overrides?.sleep ?? defaultSleep;
218
+ if (await isStoredKeyLive(key, probeOptions))
219
+ return;
220
+ for (const delayMs of delays) {
221
+ await sleep(delayMs);
222
+ if (await isStoredKeyLive(key, probeOptions))
223
+ return;
224
+ }
225
+ console.error(' Warning: API key created but not yet active; propagation can take a few seconds. moda doctor --online will confirm.');
226
+ }
90
227
  export async function selectTenantAndCreateKey(options = {}) {
91
228
  const log = (message) => {
92
229
  if (!options.silent)
@@ -172,11 +309,16 @@ export async function selectTenantAndCreateKey(options = {}) {
172
309
  // so re-running init does not accumulate one CLI key per run. A failed
173
310
  // decode, tenant mismatch, or probe failure falls through to minting.
174
311
  let result;
312
+ let reused = false;
175
313
  const storedKey = findStoredKeyForTenant(selected.id, authOptions);
176
314
  if (storedKey &&
177
315
  embeddedTenantId(storedKey) === selected.id &&
178
316
  (await isStoredKeyLive(storedKey, authOptions))) {
179
317
  log(` Using existing CLI key for ${selected.name} (created earlier)`);
318
+ // A pending idempotency key left by an earlier failed bootstrap is moot
319
+ // once the stored key is reused; clear it so it does not linger.
320
+ clearBootstrapIdempotencyKey(selected.id, authOptions);
321
+ reused = true;
180
322
  result = {
181
323
  apiKey: storedKey,
182
324
  tenantId: selected.id,
@@ -184,40 +326,79 @@ export async function selectTenantAndCreateKey(options = {}) {
184
326
  tenantSlug: selected.slug,
185
327
  };
186
328
  }
187
- // 4. Create API key via bootstrap endpoint
329
+ // 4. Create API key via bootstrap endpoint. The Idempotency-Key is
330
+ // persisted to ~/.moda/pending-bootstrap.json BEFORE the POST, so a retry
331
+ // after a network failure reuses the same key and the backend replays the
332
+ // original response instead of minting a second ApiKey.
188
333
  if (!result) {
334
+ const idempotencyKey = reserveBootstrapIdempotencyKey(selected.id, authOptions);
189
335
  const bootstrapRes = await authFetch('/api/cli/bootstrap', {
190
336
  method: 'POST',
191
- headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({ tenantId: selected.id }),
337
+ headers: {
338
+ 'Content-Type': 'application/json',
339
+ 'Idempotency-Key': idempotencyKey,
340
+ },
341
+ body: JSON.stringify({
342
+ tenantId: selected.id,
343
+ label: bootstrapLabel(options.label),
344
+ }),
193
345
  }, authOptions);
194
346
  if (bootstrapRes.status === 403) {
195
347
  throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
196
348
  }
349
+ if (bootstrapRes.status === 409) {
350
+ const body = await bootstrapRes.text();
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(body);
354
+ }
355
+ catch {
356
+ // Fall through to the generic error below.
357
+ }
358
+ if (parsed?.error === 'cli_key_cap_reached') {
359
+ throw new CliAccessError(parsed.message ||
360
+ 'This organization has reached its CLI API key limit.', {
361
+ // Command-agnostic: this path is shared by `moda init` and
362
+ // `moda provision`.
363
+ guidance: 'Revoke old CLI keys in the dashboard (Settings -> API Keys), then retry.',
364
+ });
365
+ }
366
+ throw new Error(`Failed to create API key (409): ${body}`);
367
+ }
197
368
  if (!bootstrapRes.ok) {
198
369
  const body = await bootstrapRes.text();
199
370
  throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
200
371
  }
201
372
  result = (await bootstrapRes.json());
373
+ clearBootstrapIdempotencyKey(selected.id, authOptions);
374
+ // Fresh mints only: the reused-key path above already proved liveness
375
+ // via the dedup probe, so re-validating there would be a wasted call.
376
+ await verifyFreshKeyLive(result.apiKey, authOptions, options.validateRetry);
202
377
  }
203
378
  // Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
204
379
  // hook can authenticate (api_key) and the local dev orchestrator can scope
205
380
  // ClickHouse (tenant_id) without the user exporting env vars into the shell
206
381
  // that launches Claude. Env still wins over the file at read time.
207
- saveCliConfig({
208
- api_key: result.apiKey,
209
- tenant_id: result.tenantId,
210
- ingest_url: resolveIngestUrl(authOptions),
211
- }, authOptions);
212
- // resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
213
- // env var silently shadows the selection we just saved. Warn unless the env
214
- // var was itself the source of the selection.
215
- const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
216
- if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
217
- 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.`);
218
- }
219
- log(' API key + tenant saved to ~/.moda/config.json\n');
220
- log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
221
- return result;
382
+ //
383
+ // Headless callers (persistConfig: false) skip this block entirely: they
384
+ // own their credential storage, and the env-shadow warning below only
385
+ // makes sense when a selection was actually saved.
386
+ if (options.persistConfig !== false) {
387
+ saveCliConfig({
388
+ api_key: result.apiKey,
389
+ tenant_id: result.tenantId,
390
+ ingest_url: resolveIngestUrl(authOptions),
391
+ }, authOptions);
392
+ // resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
393
+ // env var silently shadows the selection we just saved. Warn unless the env
394
+ // var was itself the source of the selection.
395
+ const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
396
+ if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
397
+ 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.`);
398
+ }
399
+ log(' API key + tenant saved to ~/.moda/config.json\n');
400
+ log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
401
+ }
402
+ return { ...result, reused };
222
403
  }
223
404
  //# sourceMappingURL=tenant.js.map
@@ -0,0 +1,28 @@
1
+ import { type CommandContext } from './command-context.js';
2
+ export interface ProvisionRunOptions {
3
+ /**
4
+ * Original invocation as an argv array (e.g. ['moda', 'provision',
5
+ * '--save']), threaded into recoverable failures so the caller knows
6
+ * exactly how to resume after logging in.
7
+ */
8
+ resumeCommand?: string[];
9
+ }
10
+ export interface ProvisionResult {
11
+ exitCode: number;
12
+ apiKey?: string;
13
+ }
14
+ /**
15
+ * The credentials document printed to stdout. Field names are a public
16
+ * contract (`moda provision | jq -r .apiKey`); do not rename.
17
+ */
18
+ export interface ProvisionPayload {
19
+ apiKey: string;
20
+ tenantId: string;
21
+ tenantName: string;
22
+ tenantSlug: string;
23
+ ingestUrl: string;
24
+ baseUrl: string;
25
+ reused: boolean;
26
+ }
27
+ export declare function runProvision(context: CommandContext, options?: ProvisionRunOptions): Promise<ProvisionResult>;
28
+ //# sourceMappingURL=provision.d.ts.map
@@ -0,0 +1,104 @@
1
+ // src/provision.ts — moda provision: headless tenant + API key provisioning
2
+ // for CI jobs and agents. Requires an existing CLI session (moda auth login
3
+ // run previously); browserless signup is explicitly out of scope (decision
4
+ // D3), so a missing session fails fast with CliAuthError instead of opening
5
+ // a browser.
6
+ import { loadAuthSession } from './auth.js';
7
+ import { stringOption } from './command-context.js';
8
+ import { resolveIngestUrl, resolveModaBaseUrl } from './config.js';
9
+ import { CliAuthError } from './errors.js';
10
+ import { selectTenantAndCreateKey } from './init/tenant.js';
11
+ export async function runProvision(context, options = {}) {
12
+ const resumeCommand = options.resumeCommand ?? ['moda', 'provision'];
13
+ // Pre-coercion raw values (like init's tenantIdFlag/profileFlag): the
14
+ // dispatcher's flagsToArgs number-coerces numeric-looking values, which
15
+ // must never happen to tenant ids, profile names, or labels.
16
+ const profileFlag = stringOption(context.rawFlags.profile);
17
+ const authOptions = { profile: profileFlag, env: context.env };
18
+ // Provision never opens a browser (headless by contract), so the session
19
+ // check happens up front: no stored session is a recoverable exit-4
20
+ // failure with explicit instructions, not a login prompt.
21
+ const session = loadAuthSession(authOptions);
22
+ if (!session) {
23
+ const expiredSession = loadAuthSession({ ...authOptions, includeExpired: true });
24
+ throw new CliAuthError('moda provision requires an existing CLI session and never opens a browser. ' +
25
+ 'Run "moda auth login" on a machine with a browser first, then re-run provision here ' +
26
+ '(or skip provisioning and set MODA_API_KEY directly).', {
27
+ reason: expiredSession ? 'token_expired' : 'no_stored_credentials',
28
+ login_command: ['moda', 'auth', 'login'],
29
+ resume_command: resumeCommand,
30
+ });
31
+ }
32
+ const flagTenantId = stringOption(context.rawFlags['tenant-id'] ?? context.rawFlags.tenant_id);
33
+ const envTenantId = stringOption(context.env.MODA_TENANT_ID);
34
+ const label = stringOption(context.rawFlags.label);
35
+ const save = context.flags.save === 'true';
36
+ // Reuses init's dedup + bootstrap machinery (stored-key liveness probe,
37
+ // Idempotency-Key persistence, label, cap/viewer errors). Multi-tenant
38
+ // accounts without --tenant-id/MODA_TENANT_ID throw CliInputRequiredError
39
+ // (exit 5) with per-tenant resume flags — even on a TTY, provision is
40
+ // headless by contract and never prompts.
41
+ let result;
42
+ try {
43
+ result = await selectTenantAndCreateKey({
44
+ tenantId: flagTenantId ?? envTenantId,
45
+ tenantIdSource: flagTenantId !== undefined ? 'flag' : envTenantId !== undefined ? 'env' : undefined,
46
+ // Unconditional (init derives this from flags/TTY): provision is
47
+ // headless by contract even on an interactive terminal.
48
+ nonInteractive: true,
49
+ // stdout must stay exactly one JSON credentials document; all provision
50
+ // prose goes to stderr, so suppress the tenant-selection prose entirely.
51
+ silent: true,
52
+ // Multi-tenant runs surface CliInputRequiredError (exit 5); echo this
53
+ // invocation's resume command so the caller knows how to continue.
54
+ resumeCommand,
55
+ label,
56
+ // Headless callers own their credential storage; --save opts into the
57
+ // init-style ~/.moda/config.json persistence.
58
+ persistConfig: save,
59
+ profile: profileFlag,
60
+ env: context.env,
61
+ });
62
+ }
63
+ catch (error) {
64
+ // A mid-flight 401 classified by authFetch (token_expired /
65
+ // token_revoked / renew_failed during the context or bootstrap call)
66
+ // throws a CliAuthError that carries only the login command. Re-throw
67
+ // with this invocation's resume command attached so the caller knows how
68
+ // to continue after recovering, matching the upfront session gate above.
69
+ if (error instanceof CliAuthError && !error.auth.resume_command) {
70
+ throw new CliAuthError(error.message, {
71
+ reason: error.auth.reason,
72
+ login_command: error.auth.login_command,
73
+ resume_command: resumeCommand,
74
+ });
75
+ }
76
+ throw error;
77
+ }
78
+ const payload = {
79
+ apiKey: result.apiKey,
80
+ tenantId: result.tenantId,
81
+ tenantName: result.tenantName,
82
+ tenantSlug: result.tenantSlug,
83
+ ingestUrl: resolveIngestUrl(authOptions),
84
+ baseUrl: resolveModaBaseUrl(authOptions),
85
+ reused: result.reused,
86
+ };
87
+ // Security note on stderr, never stdout: stdout carries a live credential
88
+ // and must stay exactly one parseable JSON document.
89
+ console.error('Output contains a live API key. Avoid piping to logs.');
90
+ if (save) {
91
+ console.error('Saved API key + tenant to ~/.moda/config.json (--save).');
92
+ }
93
+ // `moda provision` exists to be composed: `KEY=$(moda provision | jq -r
94
+ // .apiKey)`. Command substitution IS a pipe, so stdout is a non-TTY and
95
+ // the CLI would auto-select agent mode and wrap the payload in a
96
+ // moda.agent.v1 envelope, burying apiKey under `.data`. Like `moda auth
97
+ // token`, this command therefore deliberately bypasses the CliOutput
98
+ // envelope machinery in EVERY output mode (human/json/agent) and writes
99
+ // exactly one JSON document to stdout — nothing else. Human TTY runs get
100
+ // the same machine-parseable JSON: credentials output is the whole point.
101
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
102
+ return { exitCode: 0, apiKey: result.apiKey };
103
+ }
104
+ //# sourceMappingURL=provision.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
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-06T10:28:52.049Z",
4
- "cli_version": "1.13.1",
3
+ "bundled_at": "2026-07-06T10:32:52.250Z",
4
+ "cli_version": "1.13.2",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",
@@ -44,10 +44,12 @@ the user specifically asked for `moda prompts sync` or `moda prompts promote`.
44
44
 
45
45
  ## Required inputs
46
46
 
47
- Before running any command except `moda init`:
47
+ Before running any command except `moda init`, `moda provision`, and the
48
+ `moda auth ...` session commands (which use the browser/CLI session instead):
48
49
 
49
50
  - `MODA_API_KEY` env var must be set (`moda_sk_...`). If missing, run
50
- `moda init` to provision one, or have the user export it.
51
+ `moda init` (interactive) or `moda provision` (headless, needs an existing
52
+ session) to provision one, or have the user export it.
51
53
 
52
54
  Optional:
53
55
 
@@ -78,6 +80,56 @@ npx -p @moda-ai/cli moda <command>
78
80
  export MODA_API_KEY="moda_sk_..." # from moda.dev/settings
79
81
  ```
80
82
 
83
+ ### Headless provisioning (CI / agents): `moda provision`
84
+
85
+ `moda provision` mints (or reuses) a tenant API key with zero prompts and
86
+ prints a single JSON credentials document to stdout — in every output mode,
87
+ even on an interactive terminal, so it composes directly:
88
+
89
+ ```bash
90
+ KEY=$(moda provision --tenant-id t1 | jq -r .apiKey)
91
+ ```
92
+
93
+ Output shape:
94
+
95
+ ```json
96
+ {
97
+ "apiKey": "moda_sk_...",
98
+ "tenantId": "t1",
99
+ "tenantName": "Acme",
100
+ "tenantSlug": "acme",
101
+ "ingestUrl": "https://moda-ingest.modas.workers.dev",
102
+ "baseUrl": "https://moda.dev",
103
+ "reused": false
104
+ }
105
+ ```
106
+
107
+ Rules and limitations:
108
+
109
+ - **Requires an existing CLI session.** There is no browserless signup or
110
+ login: with no stored session it exits `4` (`auth_required`) telling you to
111
+ run `moda auth login` on a machine with a browser first, or to skip
112
+ provisioning and export `MODA_API_KEY` directly.
113
+ - **Never prompts, even on a TTY.** A single-tenant account auto-selects;
114
+ with multiple tenants you must pass `--tenant-id=<id>` (or set
115
+ `MODA_TENANT_ID`), otherwise it exits `5` (`needs_input`) with per-tenant
116
+ `resume_flags`.
117
+ - `reused: true` means a previously stored key for that tenant passed a
118
+ liveness probe and was reused instead of minting a new one (same dedup +
119
+ idempotent bootstrap machinery as `moda init`).
120
+ - **Does not write `~/.moda/config.json` by default** — headless callers own
121
+ their credential storage. Pass `--save` to opt into the init-style
122
+ persistence.
123
+ - `--label=<text>` names the created key (default: machine hostname);
124
+ `--profile=NAME` scopes the session lookup.
125
+ - stdout carries a live credential — a warning is printed to stderr; never
126
+ pipe provision stdout into logs.
127
+
128
+ **When to use which:** `moda init` is the interactive first-time onboarding
129
+ wizard for a repo (harness discovery, skills, prompts, validation);
130
+ `moda provision` is for CI jobs and agents that already have a session and
131
+ just need credentials.
132
+
81
133
  ### Auth session inspection and token composition
82
134
 
83
135
  ```bash
@@ -694,6 +746,7 @@ npx: `npx -p @moda-ai/cli moda <command>`.
694
746
  | Command | Purpose |
695
747
  |---|---|
696
748
  | `moda init` | Browser auth + provision API key + install agent rules |
749
+ | `moda provision` | Headless tenant + API key provisioning; single JSON credentials doc on stdout (exit `4` without a session, `5` when `--tenant-id` is needed) |
697
750
  | `moda auth login` | Browser PKCE sign-in; stores a revocable `moda_ct_` session token |
698
751
  | `moda auth logout` | Revoke the CLI session server-side, then delete it locally |
699
752
  | `moda auth status` | Local auth session state (exit `4` = logged out — expected, not an error) |