@moda-ai/cli 1.22.1 → 1.23.0
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/cli-c4se7a5d.js +762 -0
- package/dist/cli-mnqa4rp6.js +1563 -0
- package/dist/cli-stmjdgvy.js +2842 -0
- package/dist/cli-y55bgg14.js +6980 -0
- package/dist/cli-ysegdrm5.js +286 -0
- package/dist/cli.js +4779 -1159
- package/dist/harness-github-actions-7t3cncnt.js +95 -0
- package/dist/index-4ser01zr.js +2769 -0
- package/dist/provision-hm1ppwj6.js +74 -0
- package/package.json +3 -2
- package/skills/integration/index.json +2 -2
- package/dist/agent-schemas.d.ts +0 -3
- package/dist/agent-schemas.js +0 -358
- package/dist/agent.d.ts +0 -5
- package/dist/agent.js +0 -477
- package/dist/analyst-tui.d.ts +0 -81
- package/dist/analyst-tui.js +0 -314
- package/dist/api-client.d.ts +0 -46
- package/dist/api-client.js +0 -120
- package/dist/ask-stream.d.ts +0 -146
- package/dist/ask-stream.js +0 -473
- package/dist/auth-storage.d.ts +0 -35
- package/dist/auth-storage.js +0 -159
- package/dist/auth.d.ts +0 -50
- package/dist/auth.js +0 -694
- package/dist/cli-session.d.ts +0 -26
- package/dist/cli-session.js +0 -169
- package/dist/cli.d.ts +0 -44
- package/dist/command-context.d.ts +0 -62
- package/dist/command-context.js +0 -57
- package/dist/command-registry.d.ts +0 -22
- package/dist/command-registry.js +0 -15
- package/dist/config.d.ts +0 -60
- package/dist/config.js +0 -110
- package/dist/doctor.d.ts +0 -79
- package/dist/doctor.js +0 -1182
- package/dist/errors.d.ts +0 -50
- package/dist/errors.js +0 -64
- package/dist/exit-funnel.d.ts +0 -14
- package/dist/exit-funnel.js +0 -53
- package/dist/harness-artifact-bodies.d.ts +0 -28
- package/dist/harness-artifact-bodies.js +0 -276
- package/dist/harness-candidates.d.ts +0 -77
- package/dist/harness-candidates.js +0 -482
- package/dist/harness-remote.d.ts +0 -135
- package/dist/harness-remote.js +0 -540
- package/dist/harness-report.d.ts +0 -145
- package/dist/harness-report.js +0 -1579
- package/dist/harness-sync-limits.d.ts +0 -8
- package/dist/harness-sync-limits.js +0 -21
- package/dist/harness.d.ts +0 -463
- package/dist/harness.js +0 -4009
- package/dist/init/index.d.ts +0 -78
- package/dist/init/index.js +0 -1442
- package/dist/init/mcp.d.ts +0 -2
- package/dist/init/mcp.js +0 -27
- package/dist/init/progress-board.d.ts +0 -80
- package/dist/init/progress-board.js +0 -225
- package/dist/init/rules.d.ts +0 -6
- package/dist/init/rules.js +0 -171
- package/dist/init/sdk-catalog.d.ts +0 -10
- package/dist/init/sdk-catalog.js +0 -80
- package/dist/init/sdk-guide.d.ts +0 -19
- package/dist/init/sdk-guide.js +0 -185
- package/dist/init/sdk-integration.d.ts +0 -53
- package/dist/init/sdk-integration.js +0 -835
- package/dist/init/sdk-protocol.d.ts +0 -135
- package/dist/init/sdk-protocol.js +0 -152
- package/dist/init/sdk-verification.d.ts +0 -27
- package/dist/init/sdk-verification.js +0 -123
- package/dist/init/tenant.d.ts +0 -67
- package/dist/init/tenant.js +0 -421
- package/dist/intelligence-schema.d.ts +0 -349
- package/dist/intelligence-schema.js +0 -174
- package/dist/keys.d.ts +0 -47
- package/dist/keys.js +0 -136
- package/dist/manifest.d.ts +0 -5
- package/dist/manifest.js +0 -114
- package/dist/markdown-terminal.d.ts +0 -49
- package/dist/markdown-terminal.js +0 -212
- package/dist/output.d.ts +0 -191
- package/dist/output.js +0 -624
- package/dist/production-intelligence.d.ts +0 -175
- package/dist/production-intelligence.js +0 -1430
- package/dist/profile-commands.d.ts +0 -10
- package/dist/profile-commands.js +0 -342
- package/dist/profiles.d.ts +0 -112
- package/dist/profiles.js +0 -573
- package/dist/prompts-ab.d.ts +0 -71
- package/dist/prompts-ab.js +0 -313
- package/dist/prompts-propose.d.ts +0 -15
- package/dist/prompts-propose.js +0 -212
- package/dist/prompts.d.ts +0 -58
- package/dist/prompts.js +0 -418
- package/dist/provision.d.ts +0 -28
- package/dist/provision.js +0 -104
- package/dist/redact.d.ts +0 -2
- package/dist/redact.js +0 -90
- package/dist/schemas.d.ts +0 -225
- package/dist/schemas.js +0 -126
- package/dist/skills-bundled.d.ts +0 -35
- package/dist/skills-bundled.js +0 -157
- package/dist/skills-manifest.d.ts +0 -33
- package/dist/skills-manifest.js +0 -244
- package/dist/skills.d.ts +0 -57
- package/dist/skills.js +0 -783
- package/dist/sync.d.ts +0 -5
- package/dist/sync.js +0 -36
- package/dist/telemetry.d.ts +0 -56
- package/dist/telemetry.js +0 -167
- package/dist/transforms.d.ts +0 -25
- package/dist/transforms.js +0 -102
- package/dist/tui.d.ts +0 -48
- package/dist/tui.js +0 -393
- package/dist/update-check.d.ts +0 -31
- package/dist/update-check.js +0 -141
- package/dist/workspace.d.ts +0 -12
- package/dist/workspace.js +0 -1294
package/dist/init/tenant.js
DELETED
|
@@ -1,421 +0,0 @@
|
|
|
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';
|
|
6
|
-
import { select } from '@inquirer/prompts';
|
|
7
|
-
import { authFetch, getCachedLoginContext } from '../auth.js';
|
|
8
|
-
import { embeddedTenantIdFromApiKey, loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
|
|
9
|
-
import { getModaDir, resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, writeSecretJsonFile, } from '../profiles.js';
|
|
10
|
-
import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
|
|
11
|
-
/**
|
|
12
|
-
* Classify a failed /api/cli/context (or bootstrap) response so moda init
|
|
13
|
-
* distinguishes retryable infra outages (Postgres pool exhaustion → 503
|
|
14
|
-
* `database_unavailable`) from auth failures. Auth itself can succeed from
|
|
15
|
-
* a cached moda_ct_ token even when this hydrate call 500s (MODA-742).
|
|
16
|
-
*/
|
|
17
|
-
export function formatControlPlaneFetchError(action, status, body) {
|
|
18
|
-
let parsed;
|
|
19
|
-
try {
|
|
20
|
-
parsed = JSON.parse(body);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
// Non-JSON body — fall through to generic formatting.
|
|
24
|
-
}
|
|
25
|
-
const isDbUnavailable = status === 503 &&
|
|
26
|
-
(parsed?.error === 'database_unavailable' ||
|
|
27
|
-
parsed?.retryable === true ||
|
|
28
|
-
/database_unavailable|too many database connections|rds_reserved|connection pool/i.test(body));
|
|
29
|
-
if (isDbUnavailable) {
|
|
30
|
-
return (`Failed to ${action} (${status}): Moda control-plane database is temporarily unavailable ` +
|
|
31
|
-
`(connection pool exhausted). This is not an authentication failure — your session token ` +
|
|
32
|
-
`is fine. Retry in a few seconds.` +
|
|
33
|
-
(parsed?.message ? ` (${parsed.message})` : ''));
|
|
34
|
-
}
|
|
35
|
-
if (status >= 500 &&
|
|
36
|
-
/too many database connections|rds_reserved|remaining connection slots/i.test(body)) {
|
|
37
|
-
return (`Failed to ${action} (${status}): Moda control-plane database ran out of connections. ` +
|
|
38
|
-
`This is a retryable infra outage, not an auth failure. Retry in a few seconds. Body: ${body}`);
|
|
39
|
-
}
|
|
40
|
-
return `Failed to ${action} (${status}): ${body}`;
|
|
41
|
-
}
|
|
42
|
-
// Mirrors the backend's checkAdminAccess: only the viewer role is denied.
|
|
43
|
-
const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
|
|
44
|
-
const ADMIN_ACCESS_GUIDANCE = 'Ask your organization owner to upgrade your role.';
|
|
45
|
-
// Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
|
|
46
|
-
// endpoint must never block init — we just mint a fresh key instead.
|
|
47
|
-
const VALIDATE_TIMEOUT_MS = 3_000;
|
|
48
|
-
/**
|
|
49
|
-
* Post-bootstrap validate-retry schedule (WP-2.3): key provisioning spans
|
|
50
|
-
* Postgres + Cloudflare KV, so a freshly minted key can lag a moment before
|
|
51
|
-
* it validates. Retry the liveness probe at these delays (3 attempts after
|
|
52
|
-
* the initial call, ~3.5s of retry sleeps plus up to VALIDATE_TIMEOUT_MS per
|
|
53
|
-
* probe against a hanging endpoint) before warning and moving on.
|
|
54
|
-
*/
|
|
55
|
-
const POST_BOOTSTRAP_VALIDATE_DELAYS_MS = [
|
|
56
|
-
500, 1_000, 2_000,
|
|
57
|
-
];
|
|
58
|
-
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
|
-
// The backend truncates the bootstrap label to 60 printable chars; clip the
|
|
60
|
-
// hostname client-side too so what we send is what lands on the key name.
|
|
61
|
-
const MAX_LABEL_LENGTH = 60;
|
|
62
|
-
// Backend contract for the Idempotency-Key header (UUIDs match this too).
|
|
63
|
-
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
64
|
-
/**
|
|
65
|
-
* State file backing bootstrap idempotency across process restarts:
|
|
66
|
-
* ~/.moda/pending-bootstrap.json maps tenant id -> the Idempotency-Key of an
|
|
67
|
-
* in-flight (or failed) bootstrap POST. Written BEFORE the POST and cleared
|
|
68
|
-
* on success, so a retry after a network failure reuses the same key and the
|
|
69
|
-
* backend replays the original response instead of minting another ApiKey.
|
|
70
|
-
*
|
|
71
|
-
* Deliberately global (~/.moda, like config.json) and shared across
|
|
72
|
-
* profiles: the server scopes keys to the authenticated user, so the worst
|
|
73
|
-
* case of cross-profile sharing is one extra key minted — not worth
|
|
74
|
-
* profile-scoping the file.
|
|
75
|
-
*/
|
|
76
|
-
function pendingBootstrapPath(options = {}) {
|
|
77
|
-
return join(getModaDir(options), 'pending-bootstrap.json');
|
|
78
|
-
}
|
|
79
|
-
function loadPendingBootstrapState(options = {}) {
|
|
80
|
-
try {
|
|
81
|
-
const path = pendingBootstrapPath(options);
|
|
82
|
-
if (!existsSync(path))
|
|
83
|
-
return {};
|
|
84
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
85
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
86
|
-
return {};
|
|
87
|
-
}
|
|
88
|
-
const state = {};
|
|
89
|
-
for (const [tenantId, entry] of Object.entries(parsed)) {
|
|
90
|
-
if (entry &&
|
|
91
|
-
typeof entry === 'object' &&
|
|
92
|
-
typeof entry.idempotency_key === 'string') {
|
|
93
|
-
state[tenantId] = entry;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return state;
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// A corrupt state file must never block init; fresh keys are always safe.
|
|
100
|
-
return {};
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Return the Idempotency-Key to use for this tenant's bootstrap POST,
|
|
105
|
-
* persisting it before the network call. A key left over from a previous
|
|
106
|
-
* failed attempt is reused so the retry replays instead of double-minting.
|
|
107
|
-
* File-system failures degrade to an in-memory key (init must never block).
|
|
108
|
-
*/
|
|
109
|
-
function reserveBootstrapIdempotencyKey(tenantId, options = {}) {
|
|
110
|
-
const state = loadPendingBootstrapState(options);
|
|
111
|
-
const existing = state[tenantId]?.idempotency_key;
|
|
112
|
-
if (existing && IDEMPOTENCY_KEY_PATTERN.test(existing)) {
|
|
113
|
-
return existing;
|
|
114
|
-
}
|
|
115
|
-
const key = randomUUID();
|
|
116
|
-
state[tenantId] = {
|
|
117
|
-
idempotency_key: key,
|
|
118
|
-
created_at: new Date().toISOString(),
|
|
119
|
-
};
|
|
120
|
-
try {
|
|
121
|
-
writeSecretJsonFile(pendingBootstrapPath(options), state);
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// Best-effort persistence: without it a retry mints a fresh key, which
|
|
125
|
-
// is the pre-idempotency behavior, not a failure.
|
|
126
|
-
}
|
|
127
|
-
return key;
|
|
128
|
-
}
|
|
129
|
-
/** Clear the tenant's pending key after a successful bootstrap. */
|
|
130
|
-
function clearBootstrapIdempotencyKey(tenantId, options = {}) {
|
|
131
|
-
try {
|
|
132
|
-
const state = loadPendingBootstrapState(options);
|
|
133
|
-
if (!(tenantId in state))
|
|
134
|
-
return;
|
|
135
|
-
delete state[tenantId];
|
|
136
|
-
const path = pendingBootstrapPath(options);
|
|
137
|
-
if (Object.keys(state).length === 0) {
|
|
138
|
-
rmSync(path, { force: true });
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
writeSecretJsonFile(path, state);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
// Leftover state is harmless: the key replays the stored response.
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
/** Label sent in the bootstrap body; the backend sanitizes it further. */
|
|
149
|
-
function bootstrapLabel(custom) {
|
|
150
|
-
const clipped = custom?.trim().slice(0, MAX_LABEL_LENGTH);
|
|
151
|
-
if (clipped)
|
|
152
|
-
return clipped;
|
|
153
|
-
try {
|
|
154
|
-
return hostname().slice(0, MAX_LABEL_LENGTH) || 'moda init';
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
return 'moda init';
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Signed CLI keys embed their tenant (moda_sk_<base64url("v1:keyId:tenantId:
|
|
162
|
-
* timestamp:signature")>). Decoding locally lets dedup reject a cross-tenant
|
|
163
|
-
* or malformed key structurally, with no network call. Shared with the
|
|
164
|
-
* pre-upload identity banner via `embeddedTenantIdFromApiKey` in config.ts;
|
|
165
|
-
* liveness (not revoked) is probed separately by isStoredKeyLive().
|
|
166
|
-
*/
|
|
167
|
-
const embeddedTenantId = embeddedTenantIdFromApiKey;
|
|
168
|
-
/**
|
|
169
|
-
* Liveness probe for a stored key. Deliberately NOT the backend's public
|
|
170
|
-
* POST /api/api-keys/validate: the CLI's resolved base URL is the FRONTEND
|
|
171
|
-
* origin (moda.dev in prod, localhost:3000 in dev), which has no
|
|
172
|
-
* /api/api-keys/validate route and no rewrite for it — only /api/v1/data/* is
|
|
173
|
-
* proxied to the backend. Probing the Data API is the only path that reaches
|
|
174
|
-
* key validation from here; revisit when a validate proxy is added. Only a
|
|
175
|
-
* 200 with a JSON body counts as live; 401/403 (revoked/invalid), any other
|
|
176
|
-
* status, a non-JSON 200 (e.g. a frontend error page), a network error, or a
|
|
177
|
-
* timeout all return false so init falls through to minting — never block.
|
|
178
|
-
* The key value must never appear in logs, errors, or telemetry.
|
|
179
|
-
*/
|
|
180
|
-
async function isStoredKeyLive(key, options = {}) {
|
|
181
|
-
try {
|
|
182
|
-
// Resolve the base URL with the same profile/env context as the stored-key
|
|
183
|
-
// lookup, or a valid key for a non-active profile (e.g. `init --profile
|
|
184
|
-
// staging` while prod is active) is probed against the wrong origin, read
|
|
185
|
-
// as dead, and re-minted on every run.
|
|
186
|
-
const response = await fetch(`${resolveModaBaseUrl(options)}/api/v1/data/overview`, {
|
|
187
|
-
method: 'GET',
|
|
188
|
-
headers: { 'x-api-key': key },
|
|
189
|
-
signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS),
|
|
190
|
-
});
|
|
191
|
-
if (response.status !== 200)
|
|
192
|
-
return false;
|
|
193
|
-
await response.json();
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* A CLI key previously stored for this tenant, if any: the legacy
|
|
202
|
-
* ~/.moda/config.json first, then the active profile's key reference. Only a
|
|
203
|
-
* key whose stored tenant_id matches the selected tenant is a candidate.
|
|
204
|
-
*/
|
|
205
|
-
function findStoredKeyForTenant(tenantId, options = {}) {
|
|
206
|
-
const legacy = loadCliConfig(options);
|
|
207
|
-
if (legacy.api_key && legacy.tenant_id === tenantId) {
|
|
208
|
-
return legacy.api_key;
|
|
209
|
-
}
|
|
210
|
-
// Honors options.profile when init was run with --profile; otherwise the
|
|
211
|
-
// active profile / MODA_PROFILE resolution applies.
|
|
212
|
-
if (resolveTenantIdFromActiveProfile(options) === tenantId) {
|
|
213
|
-
const profileKey = resolveApiKeyFromActiveProfile(options);
|
|
214
|
-
if (profileKey)
|
|
215
|
-
return profileKey;
|
|
216
|
-
}
|
|
217
|
-
return undefined;
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* Post-bootstrap verification (WP-2.3): confirm a freshly minted key is live
|
|
221
|
-
* before declaring success, reusing the same Data API probe as dedup.
|
|
222
|
-
* Provisioning spans Postgres + Cloudflare KV (eventual consistency), so
|
|
223
|
-
* failures are retried at the POST_BOOTSTRAP_VALIDATE_DELAYS_MS schedule.
|
|
224
|
-
* Exhaustion is a stderr warning, never an init failure: init's final doctor
|
|
225
|
-
* step surfaces persistent problems, and blocking here past the retry
|
|
226
|
-
* schedule would punish every user for a transient propagation delay.
|
|
227
|
-
*/
|
|
228
|
-
async function verifyFreshKeyLive(key, probeOptions, overrides) {
|
|
229
|
-
const delays = overrides?.delaysMs ?? POST_BOOTSTRAP_VALIDATE_DELAYS_MS;
|
|
230
|
-
const sleep = overrides?.sleep ?? defaultSleep;
|
|
231
|
-
if (await isStoredKeyLive(key, probeOptions))
|
|
232
|
-
return;
|
|
233
|
-
for (const delayMs of delays) {
|
|
234
|
-
await sleep(delayMs);
|
|
235
|
-
if (await isStoredKeyLive(key, probeOptions))
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
console.error(' Warning: API key created but not yet active; propagation can take a few seconds. moda doctor --online will confirm.');
|
|
239
|
-
}
|
|
240
|
-
export async function selectTenantAndCreateKey(options = {}) {
|
|
241
|
-
const log = (message) => {
|
|
242
|
-
if (!options.silent)
|
|
243
|
-
console.log(message);
|
|
244
|
-
};
|
|
245
|
-
// Auth-session reads and stored-key lookups must honor init's --profile, or
|
|
246
|
-
// a profile-scoped login (secrets/auth/<profile>.json) is invisible here and
|
|
247
|
-
// init fails with "Not authenticated" despite a fresh token.
|
|
248
|
-
const authOptions = {
|
|
249
|
-
profile: options.profile,
|
|
250
|
-
env: options.env,
|
|
251
|
-
};
|
|
252
|
-
// 1. Fetch CLI context. A PKCE login that just ran in this process already
|
|
253
|
-
// returned the user + tenants in the exchange response; reuse it and skip
|
|
254
|
-
// one /api/cli/context round-trip. The cache is profile-keyed (a login for
|
|
255
|
-
// a different profile returns null here), and the fetch stays as the
|
|
256
|
-
// fallback for cached-token runs (no login happened) and legacy logins.
|
|
257
|
-
let ctx;
|
|
258
|
-
const cachedContext = getCachedLoginContext(authOptions);
|
|
259
|
-
if (cachedContext) {
|
|
260
|
-
ctx = cachedContext;
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
const contextRes = await authFetch('/api/cli/context', {}, authOptions);
|
|
264
|
-
if (!contextRes.ok) {
|
|
265
|
-
const body = await contextRes.text();
|
|
266
|
-
throw new Error(formatControlPlaneFetchError('fetch context', contextRes.status, body));
|
|
267
|
-
}
|
|
268
|
-
ctx = (await contextRes.json());
|
|
269
|
-
}
|
|
270
|
-
log(` Authenticated as ${ctx.user.email}`);
|
|
271
|
-
// 2. Select tenant
|
|
272
|
-
if (ctx.tenants.length === 0) {
|
|
273
|
-
throw new CliAccessError('No organizations found. Create one at https://moda.dev');
|
|
274
|
-
}
|
|
275
|
-
let selected;
|
|
276
|
-
if (options.tenantId !== undefined) {
|
|
277
|
-
// Exact id match only (case-sensitive; never slug/name, never a silent
|
|
278
|
-
// fallback to another tenant).
|
|
279
|
-
const match = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
|
|
280
|
-
if (!match) {
|
|
281
|
-
const valid = ctx.tenants
|
|
282
|
-
.map((tenant) => ` ${tenant.id} ${tenant.name} (${tenant.role})`)
|
|
283
|
-
.join('\n');
|
|
284
|
-
throw new CliInputError(`No organization with id '${options.tenantId}'. Valid organizations:\n${valid}`, 'Re-run with --tenant-id=<id> using one of the ids listed above.');
|
|
285
|
-
}
|
|
286
|
-
selected = match;
|
|
287
|
-
log(` Organization: ${selected.name}`);
|
|
288
|
-
}
|
|
289
|
-
else if (ctx.tenants.length === 1) {
|
|
290
|
-
selected = ctx.tenants[0];
|
|
291
|
-
log(` Organization: ${selected.name}`);
|
|
292
|
-
}
|
|
293
|
-
else if (options.nonInteractive) {
|
|
294
|
-
throw new CliInputRequiredError('Multiple Moda organizations are available; choose one and re-run with --tenant-id.', {
|
|
295
|
-
id: 'init_tenant_selection',
|
|
296
|
-
question: 'Select organization',
|
|
297
|
-
kind: 'select',
|
|
298
|
-
choices: ctx.tenants.map((tenant) => ({
|
|
299
|
-
value: tenant.id,
|
|
300
|
-
label: `${tenant.name} (${tenant.role})`,
|
|
301
|
-
resume_flags: ['--tenant-id', tenant.id],
|
|
302
|
-
})),
|
|
303
|
-
resume_command: options.resumeCommand,
|
|
304
|
-
}, 'Re-run `moda init --tenant-id <tenant_id>`.');
|
|
305
|
-
}
|
|
306
|
-
else {
|
|
307
|
-
selected = await select({
|
|
308
|
-
message: 'Select organization',
|
|
309
|
-
choices: ctx.tenants.map((tenant) => ({
|
|
310
|
-
name: `${tenant.name} (${tenant.role})`,
|
|
311
|
-
value: tenant,
|
|
312
|
-
})),
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
// Role pre-check: the backend's bootstrap endpoint 403s viewers, so fail
|
|
316
|
-
// before creating anything. The 403 handler below stays as a backstop.
|
|
317
|
-
if (selected.role.toLowerCase() === 'viewer') {
|
|
318
|
-
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
319
|
-
}
|
|
320
|
-
// 3. Reuse a previously minted key for this tenant when it structurally
|
|
321
|
-
// belongs to it (embedded tenant id) AND still works against the Data API,
|
|
322
|
-
// so re-running init does not accumulate one CLI key per run. A failed
|
|
323
|
-
// decode, tenant mismatch, or probe failure falls through to minting.
|
|
324
|
-
let result;
|
|
325
|
-
let reused = false;
|
|
326
|
-
const storedKey = findStoredKeyForTenant(selected.id, authOptions);
|
|
327
|
-
if (storedKey &&
|
|
328
|
-
embeddedTenantId(storedKey) === selected.id &&
|
|
329
|
-
(await isStoredKeyLive(storedKey, authOptions))) {
|
|
330
|
-
log(` Using existing CLI key for ${selected.name} (created earlier)`);
|
|
331
|
-
// A pending idempotency key left by an earlier failed bootstrap is moot
|
|
332
|
-
// once the stored key is reused; clear it so it does not linger.
|
|
333
|
-
clearBootstrapIdempotencyKey(selected.id, authOptions);
|
|
334
|
-
reused = true;
|
|
335
|
-
result = {
|
|
336
|
-
apiKey: storedKey,
|
|
337
|
-
tenantId: selected.id,
|
|
338
|
-
tenantName: selected.name,
|
|
339
|
-
tenantSlug: selected.slug,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
// 4. Create API key via bootstrap endpoint. The Idempotency-Key is
|
|
343
|
-
// persisted to ~/.moda/pending-bootstrap.json BEFORE the POST, so a retry
|
|
344
|
-
// after a network failure reuses the same key and the backend replays the
|
|
345
|
-
// original response instead of minting a second ApiKey.
|
|
346
|
-
if (!result) {
|
|
347
|
-
const idempotencyKey = reserveBootstrapIdempotencyKey(selected.id, authOptions);
|
|
348
|
-
const bootstrapRes = await authFetch('/api/cli/bootstrap', {
|
|
349
|
-
method: 'POST',
|
|
350
|
-
headers: {
|
|
351
|
-
'Content-Type': 'application/json',
|
|
352
|
-
'Idempotency-Key': idempotencyKey,
|
|
353
|
-
},
|
|
354
|
-
body: JSON.stringify({
|
|
355
|
-
tenantId: selected.id,
|
|
356
|
-
label: bootstrapLabel(options.label),
|
|
357
|
-
}),
|
|
358
|
-
}, authOptions);
|
|
359
|
-
if (bootstrapRes.status === 403) {
|
|
360
|
-
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
361
|
-
}
|
|
362
|
-
if (bootstrapRes.status === 409) {
|
|
363
|
-
const body = await bootstrapRes.text();
|
|
364
|
-
let parsed;
|
|
365
|
-
try {
|
|
366
|
-
parsed = JSON.parse(body);
|
|
367
|
-
}
|
|
368
|
-
catch {
|
|
369
|
-
// Fall through to the generic error below.
|
|
370
|
-
}
|
|
371
|
-
if (parsed?.error === 'cli_key_cap_reached') {
|
|
372
|
-
throw new CliAccessError(parsed.message ||
|
|
373
|
-
'This organization has reached its CLI API key limit.', {
|
|
374
|
-
// Command-agnostic: this path is shared by `moda init` and
|
|
375
|
-
// `moda provision`. Prefer asking an admin to raise the
|
|
376
|
-
// per-tenant cap; revoke/reuse remain valid escape hatches.
|
|
377
|
-
guidance: 'Ask your organization admin to raise the CLI key limit in the dashboard ' +
|
|
378
|
-
'(Settings → API Keys → CLI key limit), or revoke unused "CLI - ..." keys, ' +
|
|
379
|
-
'or reuse the existing key already saved in ~/.moda/config.json from a previous ' +
|
|
380
|
-
'moda init / moda provision --save, then retry.',
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
throw new Error(`Failed to create API key (409): ${body}`);
|
|
384
|
-
}
|
|
385
|
-
if (!bootstrapRes.ok) {
|
|
386
|
-
const body = await bootstrapRes.text();
|
|
387
|
-
throw new Error(formatControlPlaneFetchError('create API key', bootstrapRes.status, body));
|
|
388
|
-
}
|
|
389
|
-
result = (await bootstrapRes.json());
|
|
390
|
-
clearBootstrapIdempotencyKey(selected.id, authOptions);
|
|
391
|
-
// Fresh mints only: the reused-key path above already proved liveness
|
|
392
|
-
// via the dedup probe, so re-validating there would be a wasted call.
|
|
393
|
-
await verifyFreshKeyLive(result.apiKey, authOptions, options.validateRetry);
|
|
394
|
-
}
|
|
395
|
-
// Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
|
|
396
|
-
// hook can authenticate (api_key) and the local dev orchestrator can scope
|
|
397
|
-
// ClickHouse (tenant_id) without the user exporting env vars into the shell
|
|
398
|
-
// that launches Claude. Env still wins over the file at read time.
|
|
399
|
-
//
|
|
400
|
-
// Headless callers (persistConfig: false) skip this block entirely: they
|
|
401
|
-
// own their credential storage, and the env-shadow warning below only
|
|
402
|
-
// makes sense when a selection was actually saved.
|
|
403
|
-
if (options.persistConfig !== false) {
|
|
404
|
-
saveCliConfig({
|
|
405
|
-
api_key: result.apiKey,
|
|
406
|
-
tenant_id: result.tenantId,
|
|
407
|
-
ingest_url: resolveIngestUrl(authOptions),
|
|
408
|
-
}, authOptions);
|
|
409
|
-
// resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
|
|
410
|
-
// env var silently shadows the selection we just saved. Warn unless the env
|
|
411
|
-
// var was itself the source of the selection.
|
|
412
|
-
const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
|
|
413
|
-
if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
|
|
414
|
-
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.`);
|
|
415
|
-
}
|
|
416
|
-
log(' API key + tenant saved to ~/.moda/config.json\n');
|
|
417
|
-
log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
|
|
418
|
-
}
|
|
419
|
-
return { ...result, reused };
|
|
420
|
-
}
|
|
421
|
-
//# sourceMappingURL=tenant.js.map
|