@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 +33 -12
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +52 -0
- package/dist/command-context.d.ts +7 -0
- package/dist/command-context.js +12 -0
- package/dist/init/tenant.d.ts +34 -2
- package/dist/init/tenant.js +200 -19
- package/dist/provision.d.ts +28 -0
- package/dist/provision.js +104 -0
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
- package/skills/moda-cli/SKILL.md +55 -2
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
|
-
|
|
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(
|
|
198
|
-
|
|
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(
|
|
206
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>;
|
package/dist/command-context.js
CHANGED
|
@@ -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;
|
package/dist/init/tenant.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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<
|
|
58
|
+
export declare function selectTenantAndCreateKey(options?: SelectTenantAndCreateKeyOptions): Promise<TenantKeyResult>;
|
|
27
59
|
export {};
|
|
28
60
|
//# sourceMappingURL=tenant.d.ts.map
|
package/dist/init/tenant.js
CHANGED
|
@@ -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: {
|
|
192
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
package/skills/moda-cli/SKILL.md
CHANGED
|
@@ -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`
|
|
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) |
|