@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.
- package/dist/auth-storage.d.ts +35 -0
- package/dist/auth-storage.js +159 -0
- package/dist/auth.d.ts +17 -13
- package/dist/auth.js +350 -173
- package/dist/cli-session.d.ts +26 -0
- package/dist/cli-session.js +169 -0
- 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/index.js +8 -3
- package/dist/init/tenant.d.ts +34 -2
- package/dist/init/tenant.js +218 -26
- package/dist/profile-commands.js +10 -1
- 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 +171 -4
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
|
-
import { authFetch } from '../auth.js';
|
|
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)
|
|
@@ -99,13 +236,24 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
99
236
|
profile: options.profile,
|
|
100
237
|
env: options.env,
|
|
101
238
|
};
|
|
102
|
-
// 1. Fetch CLI context
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
239
|
+
// 1. Fetch CLI context. A PKCE login that just ran in this process already
|
|
240
|
+
// returned the user + tenants in the exchange response; reuse it and skip
|
|
241
|
+
// one /api/cli/context round-trip. The cache is profile-keyed (a login for
|
|
242
|
+
// a different profile returns null here), and the fetch stays as the
|
|
243
|
+
// fallback for cached-token runs (no login happened) and legacy logins.
|
|
244
|
+
let ctx;
|
|
245
|
+
const cachedContext = getCachedLoginContext(authOptions);
|
|
246
|
+
if (cachedContext) {
|
|
247
|
+
ctx = cachedContext;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const contextRes = await authFetch('/api/cli/context', {}, authOptions);
|
|
251
|
+
if (!contextRes.ok) {
|
|
252
|
+
const body = await contextRes.text();
|
|
253
|
+
throw new Error(`Failed to fetch context (${contextRes.status}): ${body}`);
|
|
254
|
+
}
|
|
255
|
+
ctx = (await contextRes.json());
|
|
107
256
|
}
|
|
108
|
-
const ctx = (await contextRes.json());
|
|
109
257
|
log(` Authenticated as ${ctx.user.email}`);
|
|
110
258
|
// 2. Select tenant
|
|
111
259
|
if (ctx.tenants.length === 0) {
|
|
@@ -161,11 +309,16 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
161
309
|
// so re-running init does not accumulate one CLI key per run. A failed
|
|
162
310
|
// decode, tenant mismatch, or probe failure falls through to minting.
|
|
163
311
|
let result;
|
|
312
|
+
let reused = false;
|
|
164
313
|
const storedKey = findStoredKeyForTenant(selected.id, authOptions);
|
|
165
314
|
if (storedKey &&
|
|
166
315
|
embeddedTenantId(storedKey) === selected.id &&
|
|
167
316
|
(await isStoredKeyLive(storedKey, authOptions))) {
|
|
168
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;
|
|
169
322
|
result = {
|
|
170
323
|
apiKey: storedKey,
|
|
171
324
|
tenantId: selected.id,
|
|
@@ -173,40 +326,79 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
173
326
|
tenantSlug: selected.slug,
|
|
174
327
|
};
|
|
175
328
|
}
|
|
176
|
-
// 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.
|
|
177
333
|
if (!result) {
|
|
334
|
+
const idempotencyKey = reserveBootstrapIdempotencyKey(selected.id, authOptions);
|
|
178
335
|
const bootstrapRes = await authFetch('/api/cli/bootstrap', {
|
|
179
336
|
method: 'POST',
|
|
180
|
-
headers: {
|
|
181
|
-
|
|
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
|
+
}),
|
|
182
345
|
}, authOptions);
|
|
183
346
|
if (bootstrapRes.status === 403) {
|
|
184
347
|
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
185
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
|
+
}
|
|
186
368
|
if (!bootstrapRes.ok) {
|
|
187
369
|
const body = await bootstrapRes.text();
|
|
188
370
|
throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
|
|
189
371
|
}
|
|
190
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);
|
|
191
377
|
}
|
|
192
378
|
// Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
|
|
193
379
|
// hook can authenticate (api_key) and the local dev orchestrator can scope
|
|
194
380
|
// ClickHouse (tenant_id) without the user exporting env vars into the shell
|
|
195
381
|
// that launches Claude. Env still wins over the file at read time.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 };
|
|
211
403
|
}
|
|
212
404
|
//# sourceMappingURL=tenant.js.map
|
package/dist/profile-commands.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { authFetch, clearAuthSession, isAuthSessionValid, loadAuthSession, login } from './auth.js';
|
|
1
|
+
import { authFetch, clearAuthSession, isAuthSessionValid, loadAuthSession, login, revokeCliSessionToken, } from './auth.js';
|
|
2
2
|
import { EXIT_AUTH_REQUIRED, EXIT_OK } from './output.js';
|
|
3
3
|
import { KEY_LIFECYCLE_ENDPOINT_REQUIREMENTS, createLocalKeyDescriptor, loadLocalKeyDescriptors, upsertLocalKeyDescriptor, writeLocalApiKeySecret, } from './keys.js';
|
|
4
4
|
import { DEFAULT_INGEST_URL, PROD_BASE_URL, ensureSecretFilePermissions, getActiveProfile, getModaPaths, loadProfileConfig, resolveApiKeyFromActiveProfile, resolveSecretReference, secretFilePathFromRef, setActiveProfile, upsertProfile, } from './profiles.js';
|
|
@@ -72,6 +72,10 @@ export async function runAuthCommand(context) {
|
|
|
72
72
|
return { exitCode: 0 };
|
|
73
73
|
}
|
|
74
74
|
case 'logout': {
|
|
75
|
+
// moda_ct session tokens are server-side revocable: best-effort revoke
|
|
76
|
+
// (bounded, catch-all inside) before the local deletion. Legacy
|
|
77
|
+
// auth0-kind sessions have nothing to revoke.
|
|
78
|
+
await revokeCliSessionToken({ profile: context.profile, env: context.env });
|
|
75
79
|
clearAuthSession({ profile: context.profile, env: context.env });
|
|
76
80
|
context.output.writeData({
|
|
77
81
|
status: 'logged_out',
|
|
@@ -119,6 +123,11 @@ export async function runAuthCommand(context) {
|
|
|
119
123
|
? new Date(session.expires_at * 1000).toISOString()
|
|
120
124
|
: null,
|
|
121
125
|
};
|
|
126
|
+
// `user` is emitted only when the stored session carries an email —
|
|
127
|
+
// the PKCE exchange path persists user_email; legacy sessions lack it.
|
|
128
|
+
if (session?.user_email) {
|
|
129
|
+
data.user = { email: session.user_email };
|
|
130
|
+
}
|
|
122
131
|
if (boolFlag(context, 'online')) {
|
|
123
132
|
let online = { reachable: false };
|
|
124
133
|
try {
|
|
@@ -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