@moda-ai/cli 1.13.0 → 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.
@@ -0,0 +1,26 @@
1
+ import { CliAuthError } from './errors.js';
2
+ import { type AuthSessionOptions, type StoredAuth } from './auth-storage.js';
3
+ /**
4
+ * Fire-and-forget sliding-expiry renewal, invoked on every authenticated
5
+ * request: a no-op unless the session is a moda_ct token inside the renewal
6
+ * window. Never awaited by callers, never throws, never holds the process
7
+ * open (unref'd socket); on success the stored expires_at is rewritten in
8
+ * place.
9
+ */
10
+ export declare function scheduleRenewalIfNearExpiry(session: StoredAuth, authOptions: AuthSessionOptions): void;
11
+ /** The CliAuthError thrown whenever a stored moda_ct session has expired. */
12
+ export declare function cliSessionExpiredError(): CliAuthError;
13
+ /**
14
+ * Classify a 401 for a moda_ct session. Returns the CliAuthError to throw,
15
+ * or null when the renew probe says the token is actually alive (the caller
16
+ * then surfaces the original response unchanged).
17
+ */
18
+ export declare function classifyModaCtUnauthorized(session: StoredAuth, authOptions: AuthSessionOptions): Promise<CliAuthError | null>;
19
+ /**
20
+ * Best-effort server-side revocation of a stored moda_ct session token,
21
+ * called by `moda auth logout` before local deletion. No-op for legacy
22
+ * auth0-kind sessions; all failures are swallowed (the local deletion is
23
+ * the contract).
24
+ */
25
+ export declare function revokeCliSessionToken(options?: AuthSessionOptions): Promise<void>;
26
+ //# sourceMappingURL=cli-session.d.ts.map
@@ -0,0 +1,169 @@
1
+ // src/cli-session.ts — moda_ct CLI session token lifecycle: renewal
2
+ // (sliding expiry), revocation, and 401 classification against the
3
+ // /api/cli/auth/* endpoints.
4
+ import { request as httpRequest } from 'node:http';
5
+ import { request as httpsRequest } from 'node:https';
6
+ import { resolveModaBaseUrl } from './config.js';
7
+ import { CliAuthError } from './errors.js';
8
+ import { isModaCtSession, loadAuthSession, persistAuthSession, } from './auth-storage.js';
9
+ // Background sliding-expiry renewal fires when a moda_ct session has less
10
+ // than this long left.
11
+ const RENEW_WINDOW_SECONDS = 7 * 86_400;
12
+ const RENEW_TIMEOUT_MS = 10_000;
13
+ // Synchronous renew probe used to classify a 401, and best-effort revoke on
14
+ // logout: both must never stall a command, so they get a tight budget.
15
+ const RENEW_PROBE_TIMEOUT_MS = 2_000;
16
+ const REVOKE_TIMEOUT_MS = 2_000;
17
+ /**
18
+ * POST an Authorization-bearer request to a CLI session lifecycle endpoint
19
+ * (/api/cli/auth/renew or /api/cli/auth/revoke) over raw node http/https.
20
+ * Not fetch: the background renewal must never delay process exit, and only
21
+ * a raw socket can be unref'd. Rejects on network failure or timeout.
22
+ */
23
+ function postCliSessionEndpoint(path, token, timeoutMs, opts = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ let url;
26
+ try {
27
+ // Resolve the origin from the same profile/env context the session was
28
+ // loaded with: a `--profile staging` session's renew/revoke must hit the
29
+ // staging backend that minted it, not the active/default profile's.
30
+ url = new URL(`${resolveModaBaseUrl(opts.authOptions)}${path}`);
31
+ }
32
+ catch (err) {
33
+ reject(err instanceof Error ? err : new Error(String(err)));
34
+ return;
35
+ }
36
+ const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
37
+ const req = requestFn(url, {
38
+ method: 'POST',
39
+ headers: { Authorization: `Bearer ${token}` },
40
+ timeout: timeoutMs,
41
+ }, (res) => {
42
+ const chunks = [];
43
+ res.on('data', (chunk) => chunks.push(chunk));
44
+ res.on('end', () => {
45
+ resolve({
46
+ status: res.statusCode ?? 0,
47
+ body: Buffer.concat(chunks).toString('utf8'),
48
+ });
49
+ });
50
+ res.on('error', reject);
51
+ });
52
+ if (opts.unrefSocket) {
53
+ req.on('socket', (socket) => {
54
+ socket.unref();
55
+ });
56
+ }
57
+ req.on('timeout', () => {
58
+ req.destroy(new Error(`CLI session request timed out after ${timeoutMs}ms`));
59
+ });
60
+ req.on('error', reject);
61
+ req.end();
62
+ });
63
+ }
64
+ /**
65
+ * Parse a renew response body and rewrite the stored session's expiry in
66
+ * place, preserving the profile scoping the session was loaded with.
67
+ * Best-effort: an unreadable or incomplete body is simply ignored.
68
+ */
69
+ function persistRenewedExpiry(session, body, authOptions) {
70
+ try {
71
+ const parsed = JSON.parse(body);
72
+ const expiresAtMs = typeof parsed.expires_at === 'string' ? Date.parse(parsed.expires_at) : NaN;
73
+ if (!Number.isFinite(expiresAtMs))
74
+ return;
75
+ persistAuthSession({ ...session, expires_at: Math.floor(expiresAtMs / 1000) }, authOptions);
76
+ }
77
+ catch {
78
+ // Best-effort persistence only.
79
+ }
80
+ }
81
+ // Stampede guard: at most one background renewal in flight per process.
82
+ let renewInFlight = false;
83
+ /**
84
+ * Fire-and-forget sliding-expiry renewal, invoked on every authenticated
85
+ * request: a no-op unless the session is a moda_ct token inside the renewal
86
+ * window. Never awaited by callers, never throws, never holds the process
87
+ * open (unref'd socket); on success the stored expires_at is rewritten in
88
+ * place.
89
+ */
90
+ export function scheduleRenewalIfNearExpiry(session, authOptions) {
91
+ if (!isModaCtSession(session))
92
+ return;
93
+ if (session.expires_at - Date.now() / 1000 >= RENEW_WINDOW_SECONDS)
94
+ return;
95
+ if (renewInFlight)
96
+ return;
97
+ renewInFlight = true;
98
+ void (async () => {
99
+ try {
100
+ const result = await postCliSessionEndpoint('/api/cli/auth/renew', session.access_token, RENEW_TIMEOUT_MS, { unrefSocket: true, authOptions });
101
+ if (result.status !== 200)
102
+ return;
103
+ persistRenewedExpiry(session, result.body, authOptions);
104
+ }
105
+ catch {
106
+ // Silent by design: renewal is opportunistic, the command result must
107
+ // not depend on it.
108
+ }
109
+ finally {
110
+ renewInFlight = false;
111
+ }
112
+ })();
113
+ }
114
+ /** The CliAuthError thrown whenever a stored moda_ct session has expired. */
115
+ export function cliSessionExpiredError() {
116
+ return new CliAuthError('The stored CLI session token has expired. Run "moda auth login" to sign in again.', { reason: 'token_expired', login_command: ['moda', 'auth', 'login'] });
117
+ }
118
+ /**
119
+ * Classify a 401 for a moda_ct session. Returns the CliAuthError to throw,
120
+ * or null when the renew probe says the token is actually alive (the caller
121
+ * then surfaces the original response unchanged).
122
+ */
123
+ export async function classifyModaCtUnauthorized(session, authOptions) {
124
+ const loginCommand = ['moda', 'auth', 'login'];
125
+ if (Date.now() / 1000 > session.expires_at) {
126
+ return cliSessionExpiredError();
127
+ }
128
+ let probe;
129
+ try {
130
+ probe = await postCliSessionEndpoint('/api/cli/auth/renew', session.access_token, RENEW_PROBE_TIMEOUT_MS, { authOptions });
131
+ }
132
+ catch {
133
+ return new CliAuthError('The Moda API rejected the stored CLI session token, and the renewal check could not reach the server. ' +
134
+ 'The token has not expired, so this is likely transient: retry the command instead of logging in again.', { reason: 'renew_failed', login_command: loginCommand });
135
+ }
136
+ if (probe.status === 401) {
137
+ return new CliAuthError('The stored CLI session token has been revoked. Run "moda auth login" to sign in again.', { reason: 'token_revoked', login_command: loginCommand });
138
+ }
139
+ if (probe.status === 200) {
140
+ // The token is demonstrably alive (the probe just slid its expiry) but
141
+ // the original request still 401'd; persist the fresh expiry and let the
142
+ // caller see the original response.
143
+ persistRenewedExpiry(session, probe.body, authOptions);
144
+ return null;
145
+ }
146
+ // Any other renew status (e.g. a 5xx) is indistinguishable from a
147
+ // transient server problem: same retry guidance as a network failure.
148
+ return new CliAuthError('The Moda API rejected the stored CLI session token, and the renewal check failed ' +
149
+ `(HTTP ${probe.status}). The token has not expired, so this is likely transient: ` +
150
+ 'retry the command instead of logging in again.', { reason: 'renew_failed', login_command: loginCommand });
151
+ }
152
+ /**
153
+ * Best-effort server-side revocation of a stored moda_ct session token,
154
+ * called by `moda auth logout` before local deletion. No-op for legacy
155
+ * auth0-kind sessions; all failures are swallowed (the local deletion is
156
+ * the contract).
157
+ */
158
+ export async function revokeCliSessionToken(options = {}) {
159
+ const session = loadAuthSession({ ...options, includeExpired: true });
160
+ if (!session || !isModaCtSession(session))
161
+ return;
162
+ try {
163
+ await postCliSessionEndpoint('/api/cli/auth/revoke', session.access_token, REVOKE_TIMEOUT_MS, { authOptions: options });
164
+ }
165
+ catch {
166
+ // Best-effort: logout proceeds with local deletion regardless.
167
+ }
168
+ }
169
+ //# sourceMappingURL=cli-session.js.map
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;
@@ -44,8 +44,13 @@ export async function runInit(flags, options = {}) {
44
44
  emit('welcome', 'Onboarding this project as a Moda agent harness.', 'completed');
45
45
  phases.push({ id: 'welcome', title: 'Welcome', status: 'completed' });
46
46
  // Step 2: Authentication
47
+ // Every auth touchpoint below (cached-token check, browser login, tenant
48
+ // setup) must share the same --profile context, or `moda init --profile X`
49
+ // logs in / stores under the default profile while the rest of init reads
50
+ // from X — the session then looks unauthenticated or uses the wrong key.
51
+ const profile = stringFlag(flags.profile);
47
52
  log(renderInitStep(2, 7, 'Authentication'));
48
- let token = loadToken();
53
+ let token = loadToken({ profile });
49
54
  if (!token) {
50
55
  if (json || agent) {
51
56
  throw new CliAuthError('Browser authentication is required before non-interactive init can continue.', {
@@ -66,7 +71,7 @@ export async function runInit(flags, options = {}) {
66
71
  }
67
72
  log(renderInitNote(`Opening browser to ${action === 'signup' ? 'sign up' : 'log in'}...`, 'warning'));
68
73
  emit('auth', `Opening browser to ${action}.`);
69
- token = await login(action);
74
+ token = await login(action, { profile });
70
75
  log(renderInitNote('Authentication successful'));
71
76
  }
72
77
  else {
@@ -87,7 +92,7 @@ export async function runInit(flags, options = {}) {
87
92
  nonInteractive: json || agent,
88
93
  silent: !human,
89
94
  resumeCommand: ['moda', 'init', ...resumeFlagsForInit(flags)],
90
- profile: stringFlag(flags.profile),
95
+ profile,
91
96
  });
92
97
  log(renderInitNote(`Active workspace: ${tenantName}`));
93
98
  emit('workspace', `Active workspace: ${tenantName}`, 'completed');
@@ -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