@moda-ai/cli 1.12.6 → 1.13.1

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.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) {
@@ -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 {
@@ -77,11 +82,17 @@ export async function runInit(flags, options = {}) {
77
82
  // Step 3: Tenant + API Key
78
83
  log(renderInitStep(3, 7, 'Workspace & Profile'));
79
84
  emit('workspace', 'Selecting workspace and creating API key.');
85
+ // --tenant-id flag wins over MODA_TENANT_ID; the source drives the
86
+ // env-shadow warning so we don't warn when the env var was the selection.
87
+ const tenantIdFlag = stringFlag(flagValue(flags, 'tenant-id', 'tenant_id'));
88
+ const tenantIdEnv = process.env.MODA_TENANT_ID;
80
89
  const { apiKey, tenantName } = await selectTenantAndCreateKey({
81
- tenantId: stringFlag(flagValue(flags, 'tenant-id', 'tenant_id')),
90
+ tenantId: tenantIdFlag ?? tenantIdEnv,
91
+ tenantIdSource: tenantIdFlag ? 'flag' : tenantIdEnv ? 'env' : undefined,
82
92
  nonInteractive: json || agent,
83
93
  silent: !human,
84
94
  resumeCommand: ['moda', 'init', ...resumeFlagsForInit(flags)],
95
+ profile,
85
96
  });
86
97
  log(renderInitNote(`Active workspace: ${tenantName}`));
87
98
  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