@moda-ai/cli 1.12.6 → 1.13.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/auth.d.ts +8 -1
- package/dist/auth.js +145 -10
- package/dist/cli.js +22 -6
- package/dist/config.d.ts +4 -1
- package/dist/config.js +6 -3
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +15 -0
- package/dist/exit-funnel.js +5 -1
- package/dist/init/index.js +7 -1
- package/dist/init/tenant.d.ts +16 -2
- package/dist/init/tenant.js +164 -38
- package/dist/output.d.ts +1 -1
- package/dist/output.js +12 -1
- package/dist/profile-commands.js +72 -3
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
- package/skills/moda-cli/SKILL.md +4 -1
package/dist/auth.d.ts
CHANGED
|
@@ -13,10 +13,17 @@ export declare function loadToken(options?: AuthSessionOptions): string | null;
|
|
|
13
13
|
export declare function loadAuthSession(options?: AuthSessionOptions): StoredAuth | null;
|
|
14
14
|
export declare function saveAuthSession(accessToken: string, expiresIn: number, options?: ProfileIOOptions): StoredAuth;
|
|
15
15
|
export declare function clearAuthSession(options?: ProfileIOOptions): void;
|
|
16
|
-
export interface LoginOptions {
|
|
16
|
+
export interface LoginOptions extends ProfileIOOptions {
|
|
17
|
+
/** Use this exact port for the localhost callback (no fallback ladder). */
|
|
17
18
|
callbackPort?: number;
|
|
18
19
|
timeoutMs?: number;
|
|
19
20
|
openUrl?: (url: string) => Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Test hook: readable stream used for the manual-paste fallback instead of
|
|
23
|
+
* process.stdin. A truthy `isTTY` property on the stream enables the
|
|
24
|
+
* interactive bare-token confirmation path.
|
|
25
|
+
*/
|
|
26
|
+
promptStream?: NodeJS.ReadableStream;
|
|
20
27
|
}
|
|
21
28
|
export declare function login(screen?: 'login' | 'signup', options?: LoginOptions): Promise<string>;
|
|
22
29
|
export declare function authFetch(path: string, options?: RequestInit, authOptions?: AuthSessionOptions): Promise<Response>;
|
package/dist/auth.js
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
5
6
|
import { resolveModaBaseUrl } from './config.js';
|
|
6
7
|
import { CliAuthError } from './errors.js';
|
|
7
8
|
import { getModaPaths, loadProfileConfig, saveProfileConfig, secretFilePathFromRef, writeSecretJsonFile, } from './profiles.js';
|
|
8
9
|
const CALLBACK_PORT = 8432;
|
|
10
|
+
// Fallback ports tried in order when CALLBACK_PORT is taken. The frontend
|
|
11
|
+
// callback redirect is port-parameterized, so any of these works as long as
|
|
12
|
+
// the auth URL carries the port that actually got bound.
|
|
13
|
+
const CALLBACK_PORT_LADDER = [CALLBACK_PORT, 8433, 8434, 8435];
|
|
9
14
|
const AUTH_TIMEOUT_MS = 120_000;
|
|
15
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 86_400;
|
|
10
16
|
function escapeHtml(value) {
|
|
11
17
|
return value
|
|
12
18
|
.replace(/&/g, '&')
|
|
@@ -154,8 +160,8 @@ export function clearAuthSession(options = {}) {
|
|
|
154
160
|
}
|
|
155
161
|
rmSync(tokenPath(options), { force: true });
|
|
156
162
|
}
|
|
157
|
-
function saveToken(accessToken, expiresIn) {
|
|
158
|
-
saveAuthSession(accessToken, expiresIn);
|
|
163
|
+
function saveToken(accessToken, expiresIn, options = {}) {
|
|
164
|
+
saveAuthSession(accessToken, expiresIn, options);
|
|
159
165
|
}
|
|
160
166
|
// --- Login Flow ---
|
|
161
167
|
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
@@ -196,9 +202,20 @@ function closeAuthServer(server) {
|
|
|
196
202
|
export async function login(screen = 'login', options = {}) {
|
|
197
203
|
return new Promise((resolve, reject) => {
|
|
198
204
|
let settled = false;
|
|
199
|
-
let localPort = options.callbackPort ?? CALLBACK_PORT;
|
|
200
|
-
let timeout;
|
|
201
205
|
const state = randomBytes(16).toString('hex');
|
|
206
|
+
// An explicit callbackPort is used as-is (tests bind port 0 for an
|
|
207
|
+
// ephemeral port); only the default flow walks the fallback ladder.
|
|
208
|
+
const portsToTry = options.callbackPort !== undefined
|
|
209
|
+
? [options.callbackPort]
|
|
210
|
+
: [...CALLBACK_PORT_LADDER];
|
|
211
|
+
let portIndex = 0;
|
|
212
|
+
let localPort = portsToTry[0];
|
|
213
|
+
let timeout;
|
|
214
|
+
// Manual-paste fallback state (used when the browser can't reach this box).
|
|
215
|
+
let promptInput;
|
|
216
|
+
let promptStreamIsTTY = false;
|
|
217
|
+
// Holds a bare token awaiting the interactive [y/N] confirmation.
|
|
218
|
+
let pendingBareToken = null;
|
|
202
219
|
const server = createServer(async (req, res) => {
|
|
203
220
|
if (settled)
|
|
204
221
|
return;
|
|
@@ -244,7 +261,10 @@ export async function login(screen = 'login', options = {}) {
|
|
|
244
261
|
});
|
|
245
262
|
return;
|
|
246
263
|
}
|
|
247
|
-
saveToken(token, parseInt(expiresIn || '86400', 10)
|
|
264
|
+
saveToken(token, parseInt(expiresIn || '86400', 10), {
|
|
265
|
+
profile: options.profile,
|
|
266
|
+
env: options.env,
|
|
267
|
+
});
|
|
248
268
|
res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
|
|
249
269
|
res.end(SUCCESS_HTML, () => {
|
|
250
270
|
succeed(token);
|
|
@@ -254,6 +274,13 @@ export async function login(screen = 'login', options = {}) {
|
|
|
254
274
|
process.off('SIGINT', handleSigint);
|
|
255
275
|
process.off('SIGTERM', handleSigterm);
|
|
256
276
|
}
|
|
277
|
+
function closePromptInput() {
|
|
278
|
+
if (!promptInput)
|
|
279
|
+
return;
|
|
280
|
+
promptInput.removeListener('line', handlePastedLine);
|
|
281
|
+
promptInput.close();
|
|
282
|
+
promptInput = undefined;
|
|
283
|
+
}
|
|
257
284
|
function finish(callback) {
|
|
258
285
|
if (settled)
|
|
259
286
|
return;
|
|
@@ -261,6 +288,7 @@ export async function login(screen = 'login', options = {}) {
|
|
|
261
288
|
if (timeout)
|
|
262
289
|
clearTimeout(timeout);
|
|
263
290
|
removeSignalHandlers();
|
|
291
|
+
closePromptInput();
|
|
264
292
|
// Always attempt to close the server: if SIGINT arrives between
|
|
265
293
|
// server.listen() and its listening callback, the socket may already be
|
|
266
294
|
// bound by the OS. closeAuthServer ignores ERR_SERVER_NOT_RUNNING.
|
|
@@ -273,35 +301,138 @@ export async function login(screen = 'login', options = {}) {
|
|
|
273
301
|
function fail(error) {
|
|
274
302
|
finish(() => reject(error));
|
|
275
303
|
}
|
|
304
|
+
function completeWithToken(token, expiresIn) {
|
|
305
|
+
saveToken(token, expiresIn, { profile: options.profile, env: options.env });
|
|
306
|
+
succeed(token);
|
|
307
|
+
}
|
|
276
308
|
function handleSigint() {
|
|
277
309
|
fail(new Error('Authentication cancelled.'));
|
|
278
310
|
}
|
|
279
311
|
function handleSigterm() {
|
|
280
312
|
fail(new Error('Authentication terminated.'));
|
|
281
313
|
}
|
|
314
|
+
// Accepts a line pasted by the user when the browser cannot reach this
|
|
315
|
+
// machine: a full /callback-token?token=... URL, a /callback#token=...
|
|
316
|
+
// fragment URL, or a bare token string.
|
|
317
|
+
function handlePastedLine(line) {
|
|
318
|
+
if (settled)
|
|
319
|
+
return;
|
|
320
|
+
const trimmed = line.trim();
|
|
321
|
+
if (pendingBareToken !== null) {
|
|
322
|
+
const token = pendingBareToken;
|
|
323
|
+
pendingBareToken = null;
|
|
324
|
+
if (/^y(es)?$/i.test(trimmed)) {
|
|
325
|
+
completeWithToken(token, DEFAULT_EXPIRES_IN_SECONDS);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
// A bare Enter is the conventional No for a [y/N] prompt.
|
|
329
|
+
fail(new Error('Bare token rejected: state cannot be verified.'));
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!trimmed)
|
|
334
|
+
return;
|
|
335
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
336
|
+
let pastedUrl;
|
|
337
|
+
try {
|
|
338
|
+
pastedUrl = new URL(trimmed);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// A malformed paste intentionally aborts the login so a fresh state
|
|
342
|
+
// is minted on the next attempt.
|
|
343
|
+
fail(new Error('Could not parse the pasted URL. Paste the full callback URL from the browser.'));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Fragment URLs (/callback#token=...) carry the same key/value pairs as
|
|
347
|
+
// the /callback-token query string; parse whichever is present.
|
|
348
|
+
const params = pastedUrl.hash.length > 1
|
|
349
|
+
? new URLSearchParams(pastedUrl.hash.slice(1))
|
|
350
|
+
: pastedUrl.searchParams;
|
|
351
|
+
const token = params.get('token');
|
|
352
|
+
const expiresIn = params.get('expires_in');
|
|
353
|
+
const pastedState = params.get('state');
|
|
354
|
+
if (!pastedState || !safeEqual(pastedState, state)) {
|
|
355
|
+
fail(new Error('Invalid authentication state.'));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!token) {
|
|
359
|
+
fail(new Error('No token received'));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
completeWithToken(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// Bare token: there is no state to verify, so require an explicit,
|
|
366
|
+
// interactive confirmation before trusting it.
|
|
367
|
+
if (promptStreamIsTTY) {
|
|
368
|
+
pendingBareToken = trimmed;
|
|
369
|
+
console.error(' State cannot be verified for a bare token. Continue? [y/N]');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
fail(new Error('State cannot be verified for a bare token in non-interactive mode. Paste the full callback URL instead.'));
|
|
373
|
+
}
|
|
374
|
+
function attachPromptInput() {
|
|
375
|
+
if (settled || promptInput)
|
|
376
|
+
return;
|
|
377
|
+
const stream = options.promptStream ??
|
|
378
|
+
(process.stdin && (process.stdin.isTTY || !process.stdin.destroyed)
|
|
379
|
+
? process.stdin
|
|
380
|
+
: undefined);
|
|
381
|
+
if (!stream)
|
|
382
|
+
return;
|
|
383
|
+
promptStreamIsTTY = Boolean(stream.isTTY);
|
|
384
|
+
promptInput = createInterface({ input: stream, terminal: false });
|
|
385
|
+
promptInput.on('line', handlePastedLine);
|
|
386
|
+
// Waiting for a paste must not keep the process alive on its own; the
|
|
387
|
+
// callback server and the auth timeout already do that.
|
|
388
|
+
stream.unref?.();
|
|
389
|
+
}
|
|
282
390
|
timeout = setTimeout(() => {
|
|
283
391
|
fail(new Error('Authentication timed out. Please try again.'));
|
|
284
392
|
}, options.timeoutMs ?? AUTH_TIMEOUT_MS);
|
|
285
393
|
server.on('error', (err) => {
|
|
394
|
+
if (settled)
|
|
395
|
+
return;
|
|
286
396
|
if (err.code === 'EADDRINUSE') {
|
|
287
|
-
|
|
397
|
+
portIndex += 1;
|
|
398
|
+
if (portIndex < portsToTry.length) {
|
|
399
|
+
localPort = portsToTry[portIndex];
|
|
400
|
+
server.listen(localPort, '127.0.0.1');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (options.callbackPort !== undefined) {
|
|
404
|
+
fail(new Error(`Port ${options.callbackPort} is in use. Close the process using it and try again.`));
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
fail(new Error(`Ports ${CALLBACK_PORT_LADDER.join(', ')} are all in use. ` +
|
|
408
|
+
'Close a process using one of them and try again.'));
|
|
409
|
+
}
|
|
288
410
|
}
|
|
289
411
|
else {
|
|
290
412
|
fail(err);
|
|
291
413
|
}
|
|
292
414
|
});
|
|
293
415
|
server.listen(localPort, '127.0.0.1', async () => {
|
|
416
|
+
if (settled)
|
|
417
|
+
return;
|
|
294
418
|
const address = server.address();
|
|
295
419
|
if (address && typeof address === 'object') {
|
|
296
420
|
localPort = address.port;
|
|
297
421
|
}
|
|
298
|
-
|
|
422
|
+
// Resolve the auth URL with the same profile/env context so `--profile
|
|
423
|
+
// staging` opens the staging flow (not whatever the active profile is)
|
|
424
|
+
// and the minted session matches where it will be stored.
|
|
425
|
+
const authUrl = `${resolveModaBaseUrl({ profile: options.profile, env: options.env })}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
|
|
426
|
+
attachPromptInput();
|
|
299
427
|
try {
|
|
300
428
|
await (options.openUrl ?? openAuthUrl)(authUrl);
|
|
301
429
|
}
|
|
302
430
|
catch {
|
|
303
|
-
|
|
431
|
+
// The URL is printed below regardless of whether the browser opened.
|
|
304
432
|
}
|
|
433
|
+
console.error(` Open this URL in your browser if it did not open automatically:\n ${authUrl}`);
|
|
434
|
+
console.error(" Paste the callback URL or code here if the browser can't reach this machine\n" +
|
|
435
|
+
"(if the browser page failed to redirect, copy the URL from the browser's address bar):");
|
|
305
436
|
});
|
|
306
437
|
process.once('SIGINT', handleSigint);
|
|
307
438
|
process.once('SIGTERM', handleSigterm);
|
|
@@ -321,8 +452,12 @@ export async function authFetch(path, options = {}, authOptions = {}) {
|
|
|
321
452
|
if (!headers.has('Content-Type') && options.body) {
|
|
322
453
|
headers.set('Content-Type', 'application/json');
|
|
323
454
|
}
|
|
324
|
-
|
|
325
|
-
|
|
455
|
+
// Resolve the origin from the same profile/env context used to load the
|
|
456
|
+
// token, or a profile-scoped session (e.g. `--profile staging`) is sent to
|
|
457
|
+
// the active/default origin — provisioning against the wrong environment.
|
|
458
|
+
const baseUrl = resolveModaBaseUrl(authOptions);
|
|
459
|
+
const origin = new URL(baseUrl).origin;
|
|
460
|
+
let url = `${baseUrl}${path}`;
|
|
326
461
|
// Follow redirects manually so EVERY hop is origin-checked, not just the
|
|
327
462
|
// first. Native `redirect: 'follow'` would silently strip the Authorization
|
|
328
463
|
// header on a cross-host hop (per the Fetch spec) — the exact failure that
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { runSkillsCommand } from './skills.js';
|
|
|
12
12
|
import { runHarnessCommand } from './harness.js';
|
|
13
13
|
import { runSyncCommand } from './sync.js';
|
|
14
14
|
import { runDoctorCommand, runStatusCommand } from './doctor.js';
|
|
15
|
-
import { CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError } from './errors.js';
|
|
15
|
+
import { CliAccessError, CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError } from './errors.js';
|
|
16
16
|
import { buildSearchAdoptionTelemetry, isTelemetryDisabled, SEARCH_MODES, sendCliUsageTelemetry, stripTelemetryFlag, } from './telemetry.js';
|
|
17
17
|
import { isUpdateCheckDisabled, printUpdateNotice, startUpdateCheck, stripUpdateCheckFlag, } from './update-check.js';
|
|
18
18
|
import { createCommandContext } from './command-context.js';
|
|
@@ -111,6 +111,8 @@ Commands:
|
|
|
111
111
|
manifest Describe the CLI machine protocol and commands
|
|
112
112
|
doctor Diagnose local Moda setup and data flow
|
|
113
113
|
auth login Authenticate with Moda
|
|
114
|
+
auth status Show local auth session state (exit 4 when logged out); --online probes the API
|
|
115
|
+
auth token Print the raw bearer token for $(moda auth token)
|
|
114
116
|
auth logout Clear cached Moda auth
|
|
115
117
|
auth whoami Show the authenticated Moda user
|
|
116
118
|
profiles list List local Moda profiles
|
|
@@ -759,7 +761,14 @@ export const commandRegistry = createCommandRegistry([
|
|
|
759
761
|
{
|
|
760
762
|
name: 'auth',
|
|
761
763
|
description: 'Authenticate and inspect browser auth sessions',
|
|
762
|
-
examples: [
|
|
764
|
+
examples: [
|
|
765
|
+
'moda auth login',
|
|
766
|
+
'moda auth status',
|
|
767
|
+
'moda auth status --online',
|
|
768
|
+
'moda auth token',
|
|
769
|
+
'moda auth whoami',
|
|
770
|
+
'moda auth logout',
|
|
771
|
+
],
|
|
763
772
|
auth: 'none',
|
|
764
773
|
outputModes: ['human', 'json', 'agent'],
|
|
765
774
|
mutability: 'write',
|
|
@@ -1075,10 +1084,12 @@ export function classifyCliError(error) {
|
|
|
1075
1084
|
if (error instanceof z.ZodError ||
|
|
1076
1085
|
error instanceof CliInputError ||
|
|
1077
1086
|
error instanceof CliInputRequiredError ||
|
|
1078
|
-
error instanceof CliAuthError
|
|
1079
|
-
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1087
|
+
error instanceof CliAuthError ||
|
|
1088
|
+
error instanceof CliAccessError) {
|
|
1089
|
+
// Auth/input-required/access are caller-actionable states (missing
|
|
1090
|
+
// credentials, needs a choice, insufficient role), not unknown failures.
|
|
1091
|
+
// Bucket them with validation so the missing-API-key case keeps its
|
|
1092
|
+
// pre-hardening telemetry type.
|
|
1082
1093
|
return 'validation_error';
|
|
1083
1094
|
}
|
|
1084
1095
|
if (error instanceof ApiError || isApiLikeError(error)) {
|
|
@@ -1095,6 +1106,11 @@ export function printCliError(error) {
|
|
|
1095
1106
|
if (error.usage)
|
|
1096
1107
|
safeWriteStderr(`${error.usage}\n`);
|
|
1097
1108
|
}
|
|
1109
|
+
else if (error instanceof CliAccessError) {
|
|
1110
|
+
safeWriteStderr(`Error: ${error.message}\n`);
|
|
1111
|
+
if (error.guidance)
|
|
1112
|
+
safeWriteStderr(`${error.guidance}\n`);
|
|
1113
|
+
}
|
|
1098
1114
|
else if (error instanceof z.ZodError) {
|
|
1099
1115
|
const issues = error.errors.map((e) => {
|
|
1100
1116
|
const field = e.path.length ? e.path.join('.') : 'input';
|
package/dist/config.d.ts
CHANGED
|
@@ -17,8 +17,11 @@ export declare function loadCliConfig(options?: ProfileIOOptions): CliConfig;
|
|
|
17
17
|
/**
|
|
18
18
|
* Merge-and-write the persisted config. Always chmod 0600 since it holds the
|
|
19
19
|
* API key. Best-effort: never throws on chmod failure (some filesystems).
|
|
20
|
+
* `options.profile` scopes the best-effort profile sync (e.g. `moda init
|
|
21
|
+
* --profile staging`), so the key is synced to the requested profile rather
|
|
22
|
+
* than whatever profile happens to be active.
|
|
20
23
|
*/
|
|
21
|
-
export declare function saveCliConfig(update: CliConfig): void;
|
|
24
|
+
export declare function saveCliConfig(update: CliConfig, options?: ProfileIOOptions): void;
|
|
22
25
|
/**
|
|
23
26
|
* Resolve the API key: prefer the environment (so CI / explicit exports win),
|
|
24
27
|
* then fall back to the persisted `~/.moda/config.json`. Read lazily so a
|
package/dist/config.js
CHANGED
|
@@ -14,11 +14,14 @@ export function loadCliConfig(options = {}) {
|
|
|
14
14
|
/**
|
|
15
15
|
* Merge-and-write the persisted config. Always chmod 0600 since it holds the
|
|
16
16
|
* API key. Best-effort: never throws on chmod failure (some filesystems).
|
|
17
|
+
* `options.profile` scopes the best-effort profile sync (e.g. `moda init
|
|
18
|
+
* --profile staging`), so the key is synced to the requested profile rather
|
|
19
|
+
* than whatever profile happens to be active.
|
|
17
20
|
*/
|
|
18
|
-
export function saveCliConfig(update) {
|
|
19
|
-
const merged = writeLegacyCliConfig(update);
|
|
21
|
+
export function saveCliConfig(update, options = {}) {
|
|
22
|
+
const merged = writeLegacyCliConfig(update, options);
|
|
20
23
|
try {
|
|
21
|
-
syncLegacyConfigToProfile(merged);
|
|
24
|
+
syncLegacyConfigToProfile(merged, options);
|
|
22
25
|
}
|
|
23
26
|
catch {
|
|
24
27
|
// The legacy config write is the compatibility contract; profile sync is a
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,6 +2,19 @@ export declare class CliInputError extends Error {
|
|
|
2
2
|
readonly usage?: string | undefined;
|
|
3
3
|
constructor(message: string, usage?: string | undefined);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* The authenticated user cannot perform the requested action with their
|
|
7
|
+
* current access (no organizations, or insufficient role). This is an access
|
|
8
|
+
* problem, not missing auth — the dispatcher maps it to EXIT_ERROR (1),
|
|
9
|
+
* never EXIT_AUTH_REQUIRED.
|
|
10
|
+
*/
|
|
11
|
+
export declare class CliAccessError extends Error {
|
|
12
|
+
/** Optional follow-up line telling the user how to obtain access. */
|
|
13
|
+
readonly guidance?: string;
|
|
14
|
+
constructor(message: string, options?: {
|
|
15
|
+
guidance?: string;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
5
18
|
export interface AgentInputChoice {
|
|
6
19
|
value: string;
|
|
7
20
|
label: string;
|
package/dist/errors.js
CHANGED
|
@@ -6,6 +6,21 @@ export class CliInputError extends Error {
|
|
|
6
6
|
this.name = 'CliInputError';
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* The authenticated user cannot perform the requested action with their
|
|
11
|
+
* current access (no organizations, or insufficient role). This is an access
|
|
12
|
+
* problem, not missing auth — the dispatcher maps it to EXIT_ERROR (1),
|
|
13
|
+
* never EXIT_AUTH_REQUIRED.
|
|
14
|
+
*/
|
|
15
|
+
export class CliAccessError extends Error {
|
|
16
|
+
/** Optional follow-up line telling the user how to obtain access. */
|
|
17
|
+
guidance;
|
|
18
|
+
constructor(message, options = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CliAccessError';
|
|
21
|
+
this.guidance = options.guidance;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
9
24
|
export class CliInputRequiredError extends Error {
|
|
10
25
|
input_request;
|
|
11
26
|
constructor(message, input_request, usage) {
|
package/dist/exit-funnel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
|
|
1
|
+
import { CliAccessError, CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
|
|
2
2
|
import { EXIT_AUTH_REQUIRED, EXIT_CANCELLED, EXIT_ERROR, EXIT_INPUT_REQUIRED, EXIT_OK, wasOutputCancelled, } from './output.js';
|
|
3
3
|
const finishedContexts = new WeakSet();
|
|
4
4
|
export async function finishRun(context, outcome) {
|
|
@@ -37,6 +37,10 @@ export function exitCodeForError(error) {
|
|
|
37
37
|
return EXIT_INPUT_REQUIRED;
|
|
38
38
|
if (error instanceof CliCancellationError)
|
|
39
39
|
return EXIT_CANCELLED;
|
|
40
|
+
// An access problem (missing org / insufficient role) is a hard failure,
|
|
41
|
+
// never auth-required — it stays EXIT_ERROR (the default), made explicit here.
|
|
42
|
+
if (error instanceof CliAccessError)
|
|
43
|
+
return EXIT_ERROR;
|
|
40
44
|
return EXIT_ERROR;
|
|
41
45
|
}
|
|
42
46
|
function exitCodeForOutcome(outcome) {
|
package/dist/init/index.js
CHANGED
|
@@ -77,11 +77,17 @@ export async function runInit(flags, options = {}) {
|
|
|
77
77
|
// Step 3: Tenant + API Key
|
|
78
78
|
log(renderInitStep(3, 7, 'Workspace & Profile'));
|
|
79
79
|
emit('workspace', 'Selecting workspace and creating API key.');
|
|
80
|
+
// --tenant-id flag wins over MODA_TENANT_ID; the source drives the
|
|
81
|
+
// env-shadow warning so we don't warn when the env var was the selection.
|
|
82
|
+
const tenantIdFlag = stringFlag(flagValue(flags, 'tenant-id', 'tenant_id'));
|
|
83
|
+
const tenantIdEnv = process.env.MODA_TENANT_ID;
|
|
80
84
|
const { apiKey, tenantName } = await selectTenantAndCreateKey({
|
|
81
|
-
tenantId:
|
|
85
|
+
tenantId: tenantIdFlag ?? tenantIdEnv,
|
|
86
|
+
tenantIdSource: tenantIdFlag ? 'flag' : tenantIdEnv ? 'env' : undefined,
|
|
82
87
|
nonInteractive: json || agent,
|
|
83
88
|
silent: !human,
|
|
84
89
|
resumeCommand: ['moda', 'init', ...resumeFlagsForInit(flags)],
|
|
90
|
+
profile: stringFlag(flags.profile),
|
|
85
91
|
});
|
|
86
92
|
log(renderInitNote(`Active workspace: ${tenantName}`));
|
|
87
93
|
emit('workspace', `Active workspace: ${tenantName}`, 'completed');
|
package/dist/init/tenant.d.ts
CHANGED
|
@@ -4,11 +4,25 @@ interface BootstrapResult {
|
|
|
4
4
|
tenantName: string;
|
|
5
5
|
tenantSlug: string;
|
|
6
6
|
}
|
|
7
|
-
export
|
|
7
|
+
export interface SelectTenantAndCreateKeyOptions {
|
|
8
|
+
/** Tenant id preselected via --tenant-id (flag) or MODA_TENANT_ID (env). */
|
|
8
9
|
tenantId?: string;
|
|
10
|
+
/** Where the preselected tenant id came from; drives the env-shadow warning. */
|
|
11
|
+
tenantIdSource?: 'flag' | 'env';
|
|
12
|
+
/** Non-interactive runs must never open the selection prompt. */
|
|
9
13
|
nonInteractive?: boolean;
|
|
14
|
+
/** Suppress human prose so it never interleaves with JSON envelopes on stdout. */
|
|
10
15
|
silent?: boolean;
|
|
16
|
+
/** Resume command echoed into the needs_input event for multi-tenant runs. */
|
|
11
17
|
resumeCommand?: string[];
|
|
12
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Profile context (--profile) threaded from init: scopes the auth-session
|
|
20
|
+
* reads and request origin behind authFetch, the stored-key candidate
|
|
21
|
+
* lookup, and the liveness-probe base URL to the given profile.
|
|
22
|
+
*/
|
|
23
|
+
profile?: string;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
}
|
|
26
|
+
export declare function selectTenantAndCreateKey(options?: SelectTenantAndCreateKeyOptions): Promise<BootstrapResult>;
|
|
13
27
|
export {};
|
|
14
28
|
//# sourceMappingURL=tenant.d.ts.map
|
package/dist/init/tenant.js
CHANGED
|
@@ -1,36 +1,133 @@
|
|
|
1
1
|
// src/init/tenant.ts — Tenant selection and API key provisioning
|
|
2
2
|
import { select } from '@inquirer/prompts';
|
|
3
3
|
import { authFetch } from '../auth.js';
|
|
4
|
-
import { resolveIngestUrl, saveCliConfig } from '../config.js';
|
|
5
|
-
import {
|
|
4
|
+
import { loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
|
|
5
|
+
import { resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, } from '../profiles.js';
|
|
6
|
+
import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
|
|
7
|
+
// Mirrors the backend's checkAdminAccess: only the viewer role is denied.
|
|
8
|
+
const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
|
|
9
|
+
const ADMIN_ACCESS_GUIDANCE = 'Ask your organization owner to upgrade your role.';
|
|
10
|
+
const API_KEY_PREFIX = 'moda_sk_';
|
|
11
|
+
const SIGNED_KEY_VERSION = 'v1';
|
|
12
|
+
// Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
|
|
13
|
+
// endpoint must never block init — we just mint a fresh key instead.
|
|
14
|
+
const VALIDATE_TIMEOUT_MS = 3_000;
|
|
15
|
+
/**
|
|
16
|
+
* Signed CLI keys embed their tenant: the format is
|
|
17
|
+
* moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
|
|
18
|
+
* createApiKey/parseAndVerifySignedKey in
|
|
19
|
+
* apps/moda-backend/src/api-keys/api-keys.service.ts. Decoding locally lets
|
|
20
|
+
* dedup reject a cross-tenant or malformed key structurally, with no network
|
|
21
|
+
* call. The HMAC signature can only be verified server-side; liveness (not
|
|
22
|
+
* revoked) is probed separately by isStoredKeyLive().
|
|
23
|
+
*/
|
|
24
|
+
function embeddedTenantId(key) {
|
|
25
|
+
if (!key.startsWith(API_KEY_PREFIX))
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const decoded = Buffer.from(key.slice(API_KEY_PREFIX.length), 'base64url').toString('utf8');
|
|
29
|
+
// Expected format: version:keyId:tenantId:timestamp:signature
|
|
30
|
+
const parts = decoded.split(':');
|
|
31
|
+
if (parts.length !== 5 || parts[0] !== SIGNED_KEY_VERSION)
|
|
32
|
+
return undefined;
|
|
33
|
+
return parts[2] || undefined;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Liveness probe for a stored key. Deliberately NOT the backend's public
|
|
41
|
+
* POST /api/api-keys/validate: the CLI's resolved base URL is the FRONTEND
|
|
42
|
+
* origin (moda.dev in prod, localhost:3000 in dev), which has no
|
|
43
|
+
* /api/api-keys/validate route and no rewrite for it — only /api/v1/data/* is
|
|
44
|
+
* proxied to the backend. Probing the Data API is the only path that reaches
|
|
45
|
+
* key validation from here; revisit when a validate proxy is added. Only a
|
|
46
|
+
* 200 with a JSON body counts as live; 401/403 (revoked/invalid), any other
|
|
47
|
+
* status, a non-JSON 200 (e.g. a frontend error page), a network error, or a
|
|
48
|
+
* timeout all return false so init falls through to minting — never block.
|
|
49
|
+
* The key value must never appear in logs, errors, or telemetry.
|
|
50
|
+
*/
|
|
51
|
+
async function isStoredKeyLive(key, options = {}) {
|
|
52
|
+
try {
|
|
53
|
+
// Resolve the base URL with the same profile/env context as the stored-key
|
|
54
|
+
// lookup, or a valid key for a non-active profile (e.g. `init --profile
|
|
55
|
+
// staging` while prod is active) is probed against the wrong origin, read
|
|
56
|
+
// as dead, and re-minted on every run.
|
|
57
|
+
const response = await fetch(`${resolveModaBaseUrl(options)}/api/v1/data/overview`, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: { 'x-api-key': key },
|
|
60
|
+
signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS),
|
|
61
|
+
});
|
|
62
|
+
if (response.status !== 200)
|
|
63
|
+
return false;
|
|
64
|
+
await response.json();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A CLI key previously stored for this tenant, if any: the legacy
|
|
73
|
+
* ~/.moda/config.json first, then the active profile's key reference. Only a
|
|
74
|
+
* key whose stored tenant_id matches the selected tenant is a candidate.
|
|
75
|
+
*/
|
|
76
|
+
function findStoredKeyForTenant(tenantId, options = {}) {
|
|
77
|
+
const legacy = loadCliConfig(options);
|
|
78
|
+
if (legacy.api_key && legacy.tenant_id === tenantId) {
|
|
79
|
+
return legacy.api_key;
|
|
80
|
+
}
|
|
81
|
+
// Honors options.profile when init was run with --profile; otherwise the
|
|
82
|
+
// active profile / MODA_PROFILE resolution applies.
|
|
83
|
+
if (resolveTenantIdFromActiveProfile(options) === tenantId) {
|
|
84
|
+
const profileKey = resolveApiKeyFromActiveProfile(options);
|
|
85
|
+
if (profileKey)
|
|
86
|
+
return profileKey;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
6
90
|
export async function selectTenantAndCreateKey(options = {}) {
|
|
91
|
+
const log = (message) => {
|
|
92
|
+
if (!options.silent)
|
|
93
|
+
console.log(message);
|
|
94
|
+
};
|
|
95
|
+
// Auth-session reads and stored-key lookups must honor init's --profile, or
|
|
96
|
+
// a profile-scoped login (secrets/auth/<profile>.json) is invisible here and
|
|
97
|
+
// init fails with "Not authenticated" despite a fresh token.
|
|
98
|
+
const authOptions = {
|
|
99
|
+
profile: options.profile,
|
|
100
|
+
env: options.env,
|
|
101
|
+
};
|
|
7
102
|
// 1. Fetch CLI context
|
|
8
|
-
const contextRes = await authFetch('/api/cli/context');
|
|
103
|
+
const contextRes = await authFetch('/api/cli/context', {}, authOptions);
|
|
9
104
|
if (!contextRes.ok) {
|
|
10
105
|
const body = await contextRes.text();
|
|
11
106
|
throw new Error(`Failed to fetch context (${contextRes.status}): ${body}`);
|
|
12
107
|
}
|
|
13
108
|
const ctx = (await contextRes.json());
|
|
14
|
-
|
|
15
|
-
console.log(` Authenticated as ${ctx.user.email}`);
|
|
109
|
+
log(` Authenticated as ${ctx.user.email}`);
|
|
16
110
|
// 2. Select tenant
|
|
17
111
|
if (ctx.tenants.length === 0) {
|
|
18
|
-
throw new
|
|
112
|
+
throw new CliAccessError('No organizations found. Create one at https://moda.dev');
|
|
19
113
|
}
|
|
20
|
-
let
|
|
21
|
-
if (options.tenantId) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
114
|
+
let selected;
|
|
115
|
+
if (options.tenantId !== undefined) {
|
|
116
|
+
// Exact id match only (case-sensitive; never slug/name, never a silent
|
|
117
|
+
// fallback to another tenant).
|
|
118
|
+
const match = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
|
|
119
|
+
if (!match) {
|
|
120
|
+
const valid = ctx.tenants
|
|
121
|
+
.map((tenant) => ` ${tenant.id} ${tenant.name} (${tenant.role})`)
|
|
122
|
+
.join('\n');
|
|
123
|
+
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.');
|
|
25
124
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.log(` Organization: ${selected.name}`);
|
|
125
|
+
selected = match;
|
|
126
|
+
log(` Organization: ${selected.name}`);
|
|
29
127
|
}
|
|
30
128
|
else if (ctx.tenants.length === 1) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
console.log(` Organization: ${ctx.tenants[0].name}`);
|
|
129
|
+
selected = ctx.tenants[0];
|
|
130
|
+
log(` Organization: ${selected.name}`);
|
|
34
131
|
}
|
|
35
132
|
else if (options.nonInteractive) {
|
|
36
133
|
throw new CliInputRequiredError('Multiple Moda organizations are available; choose one and re-run with --tenant-id.', {
|
|
@@ -46,28 +143,52 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
46
143
|
}, 'Re-run `moda init --tenant-id <tenant_id>`.');
|
|
47
144
|
}
|
|
48
145
|
else {
|
|
49
|
-
|
|
146
|
+
selected = await select({
|
|
50
147
|
message: 'Select organization',
|
|
51
|
-
choices: ctx.tenants.map((
|
|
52
|
-
name: `${
|
|
53
|
-
value:
|
|
148
|
+
choices: ctx.tenants.map((tenant) => ({
|
|
149
|
+
name: `${tenant.name} (${tenant.role})`,
|
|
150
|
+
value: tenant,
|
|
54
151
|
})),
|
|
55
152
|
});
|
|
56
153
|
}
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
154
|
+
// Role pre-check: the backend's bootstrap endpoint 403s viewers, so fail
|
|
155
|
+
// before creating anything. The 403 handler below stays as a backstop.
|
|
156
|
+
if (selected.role.toLowerCase() === 'viewer') {
|
|
157
|
+
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
158
|
+
}
|
|
159
|
+
// 3. Reuse a previously minted key for this tenant when it structurally
|
|
160
|
+
// belongs to it (embedded tenant id) AND still works against the Data API,
|
|
161
|
+
// so re-running init does not accumulate one CLI key per run. A failed
|
|
162
|
+
// decode, tenant mismatch, or probe failure falls through to minting.
|
|
163
|
+
let result;
|
|
164
|
+
const storedKey = findStoredKeyForTenant(selected.id, authOptions);
|
|
165
|
+
if (storedKey &&
|
|
166
|
+
embeddedTenantId(storedKey) === selected.id &&
|
|
167
|
+
(await isStoredKeyLive(storedKey, authOptions))) {
|
|
168
|
+
log(` Using existing CLI key for ${selected.name} (created earlier)`);
|
|
169
|
+
result = {
|
|
170
|
+
apiKey: storedKey,
|
|
171
|
+
tenantId: selected.id,
|
|
172
|
+
tenantName: selected.name,
|
|
173
|
+
tenantSlug: selected.slug,
|
|
174
|
+
};
|
|
65
175
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
176
|
+
// 4. Create API key via bootstrap endpoint
|
|
177
|
+
if (!result) {
|
|
178
|
+
const bootstrapRes = await authFetch('/api/cli/bootstrap', {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({ tenantId: selected.id }),
|
|
182
|
+
}, authOptions);
|
|
183
|
+
if (bootstrapRes.status === 403) {
|
|
184
|
+
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
185
|
+
}
|
|
186
|
+
if (!bootstrapRes.ok) {
|
|
187
|
+
const body = await bootstrapRes.text();
|
|
188
|
+
throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
|
|
189
|
+
}
|
|
190
|
+
result = (await bootstrapRes.json());
|
|
69
191
|
}
|
|
70
|
-
const result = (await bootstrapRes.json());
|
|
71
192
|
// Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
|
|
72
193
|
// hook can authenticate (api_key) and the local dev orchestrator can scope
|
|
73
194
|
// ClickHouse (tenant_id) without the user exporting env vars into the shell
|
|
@@ -75,12 +196,17 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
75
196
|
saveCliConfig({
|
|
76
197
|
api_key: result.apiKey,
|
|
77
198
|
tenant_id: result.tenantId,
|
|
78
|
-
ingest_url: resolveIngestUrl(),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
199
|
+
ingest_url: resolveIngestUrl(authOptions),
|
|
200
|
+
}, authOptions);
|
|
201
|
+
// resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
|
|
202
|
+
// env var silently shadows the selection we just saved. Warn unless the env
|
|
203
|
+
// var was itself the source of the selection.
|
|
204
|
+
const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
|
|
205
|
+
if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
|
|
206
|
+
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.`);
|
|
83
207
|
}
|
|
208
|
+
log(' API key + tenant saved to ~/.moda/config.json\n');
|
|
209
|
+
log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
|
|
84
210
|
return result;
|
|
85
211
|
}
|
|
86
212
|
//# sourceMappingURL=tenant.js.map
|
package/dist/output.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare const EXIT_DEGRADED = 3;
|
|
|
19
19
|
export declare const EXIT_AUTH_REQUIRED = 4;
|
|
20
20
|
export declare const EXIT_INPUT_REQUIRED = 5;
|
|
21
21
|
export declare const EXIT_CANCELLED = 130;
|
|
22
|
-
export declare const AGENT_ERROR_CODES: readonly ["input_error", "api_error", "network_error", "auth_required", "input_required", "cancelled", "command_error", "unknown_error", "cloud_unavailable"];
|
|
22
|
+
export declare const AGENT_ERROR_CODES: readonly ["input_error", "api_error", "network_error", "auth_required", "input_required", "cancelled", "command_error", "access_denied", "unknown_error", "cloud_unavailable"];
|
|
23
23
|
export type AgentErrorCode = typeof AGENT_ERROR_CODES[number];
|
|
24
24
|
export type AgentStatus = 'ok' | 'degraded' | 'error';
|
|
25
25
|
export type AgentConfidence = 'high' | 'medium' | 'low' | 'unknown';
|
package/dist/output.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError, } from './errors.js';
|
|
2
|
+
import { CliAccessError, CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError, } from './errors.js';
|
|
3
3
|
import { redactDeep } from './redact.js';
|
|
4
4
|
export const AGENT_SCHEMA_VERSION = 'moda.agent.v1';
|
|
5
5
|
export const AGENT_EVENT_SCHEMA_VERSION = 'moda.agent_event.v1';
|
|
@@ -28,6 +28,7 @@ export const AGENT_ERROR_CODES = [
|
|
|
28
28
|
'input_required',
|
|
29
29
|
'cancelled',
|
|
30
30
|
'command_error',
|
|
31
|
+
'access_denied',
|
|
31
32
|
'unknown_error',
|
|
32
33
|
'cloud_unavailable',
|
|
33
34
|
];
|
|
@@ -416,6 +417,16 @@ export function errorToAgentError(error) {
|
|
|
416
417
|
blocking: true,
|
|
417
418
|
};
|
|
418
419
|
}
|
|
420
|
+
if (error instanceof CliAccessError) {
|
|
421
|
+
// Access problem (missing organization or insufficient role), not
|
|
422
|
+
// auth-missing: exit stays EXIT_ERROR and no login command is suggested.
|
|
423
|
+
return {
|
|
424
|
+
code: 'access_denied',
|
|
425
|
+
message: error.message,
|
|
426
|
+
cause: error.guidance,
|
|
427
|
+
blocking: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
419
430
|
if (error instanceof Error) {
|
|
420
431
|
if (error instanceof CliInputError || error.name === 'ZodError') {
|
|
421
432
|
return {
|
package/dist/profile-commands.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { authFetch, clearAuthSession, loadAuthSession, login } from './auth.js';
|
|
1
|
+
import { authFetch, clearAuthSession, isAuthSessionValid, loadAuthSession, login } from './auth.js';
|
|
2
|
+
import { EXIT_AUTH_REQUIRED, EXIT_OK } from './output.js';
|
|
2
3
|
import { KEY_LIFECYCLE_ENDPOINT_REQUIREMENTS, createLocalKeyDescriptor, loadLocalKeyDescriptors, upsertLocalKeyDescriptor, writeLocalApiKeySecret, } from './keys.js';
|
|
3
4
|
import { DEFAULT_INGEST_URL, PROD_BASE_URL, ensureSecretFilePermissions, getActiveProfile, getModaPaths, loadProfileConfig, resolveApiKeyFromActiveProfile, resolveSecretReference, secretFilePathFromRef, setActiveProfile, upsertProfile, } from './profiles.js';
|
|
4
5
|
import { CliInputError } from './errors.js';
|
|
6
|
+
// Bound the `auth status --online` probe: without a signal, undici's defaults
|
|
7
|
+
// can stall a status check for minutes on a black-holed connection.
|
|
8
|
+
const ONLINE_PROBE_TIMEOUT_MS = 5_000;
|
|
5
9
|
function subcommand(context) {
|
|
6
10
|
return context.positionals[0];
|
|
7
11
|
}
|
|
@@ -60,7 +64,7 @@ export async function runAuthCommand(context) {
|
|
|
60
64
|
switch (subcommand(context)) {
|
|
61
65
|
case 'login': {
|
|
62
66
|
const screen = flag(context, 'screen') === 'signup' ? 'signup' : 'login';
|
|
63
|
-
await login(screen);
|
|
67
|
+
await login(screen, { profile: context.profile, env: context.env });
|
|
64
68
|
context.output.writeData({
|
|
65
69
|
status: 'authenticated',
|
|
66
70
|
profile: context.profile ?? process.env.MODA_PROFILE ?? null,
|
|
@@ -96,8 +100,73 @@ export async function runAuthCommand(context) {
|
|
|
96
100
|
});
|
|
97
101
|
return { exitCode: 0 };
|
|
98
102
|
}
|
|
103
|
+
case 'status': {
|
|
104
|
+
// Being logged out is an expected state for `auth status`, not a
|
|
105
|
+
// failure: it must never throw CliAuthError (no error block, no error
|
|
106
|
+
// telemetry classification) and must never trigger a browser open. It
|
|
107
|
+
// reports clean data in every mode and signals session validity purely
|
|
108
|
+
// through the exit code (0 valid, 4 auth required).
|
|
109
|
+
const authOptions = { profile: context.profile, env: context.env };
|
|
110
|
+
const session = loadAuthSession({ ...authOptions, includeExpired: true });
|
|
111
|
+
const loggedIn = isAuthSessionValid(session);
|
|
112
|
+
const data = {
|
|
113
|
+
loggedIn,
|
|
114
|
+
profile: context.profile ??
|
|
115
|
+
context.env.MODA_PROFILE ??
|
|
116
|
+
session?.profile ??
|
|
117
|
+
null,
|
|
118
|
+
expiresAt: session
|
|
119
|
+
? new Date(session.expires_at * 1000).toISOString()
|
|
120
|
+
: null,
|
|
121
|
+
};
|
|
122
|
+
if (boolFlag(context, 'online')) {
|
|
123
|
+
let online = { reachable: false };
|
|
124
|
+
try {
|
|
125
|
+
const response = await authFetch('/api/cli/context', { signal: AbortSignal.timeout(ONLINE_PROBE_TIMEOUT_MS) }, authOptions);
|
|
126
|
+
// Any HTTP response means the server was reached; `ok` separates a
|
|
127
|
+
// healthy 2xx from e.g. a 401/500.
|
|
128
|
+
online = { reachable: true, ok: response.ok };
|
|
129
|
+
if (response.ok) {
|
|
130
|
+
const body = await response.json();
|
|
131
|
+
if (Array.isArray(body.tenants)) {
|
|
132
|
+
online.tenantCount = body.tenants.length;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Network failure, probe timeout, or no stored token to send: report
|
|
138
|
+
// reachable: false; the exit code still follows local session
|
|
139
|
+
// validity, never the online probe.
|
|
140
|
+
}
|
|
141
|
+
data.online = online;
|
|
142
|
+
}
|
|
143
|
+
context.output.writeData(data);
|
|
144
|
+
return { exitCode: loggedIn ? EXIT_OK : EXIT_AUTH_REQUIRED };
|
|
145
|
+
}
|
|
146
|
+
case 'token': {
|
|
147
|
+
const session = loadAuthSession({ profile: context.profile, env: context.env });
|
|
148
|
+
if (!session) {
|
|
149
|
+
if (context.flags.json === 'true') {
|
|
150
|
+
// An explicit --json caller gets a parseable failure on stdout.
|
|
151
|
+
process.stdout.write(`${JSON.stringify({ error: 'not_authenticated' })}\n`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.error('Not authenticated. Run "moda auth login".');
|
|
155
|
+
}
|
|
156
|
+
return { exitCode: EXIT_AUTH_REQUIRED };
|
|
157
|
+
}
|
|
158
|
+
// `auth token` exists to compose as $(moda auth token) and in pipes.
|
|
159
|
+
// Command substitution IS a pipe, so stdout is a non-TTY and this CLI
|
|
160
|
+
// would auto-select agent mode and wrap the token in a JSON envelope,
|
|
161
|
+
// breaking every composition. This command therefore deliberately
|
|
162
|
+
// bypasses the CliOutput envelope machinery in EVERY output mode
|
|
163
|
+
// (human/json/agent/agent-stream) and writes exactly the bare token plus
|
|
164
|
+
// a trailing newline to stdout — nothing else.
|
|
165
|
+
process.stdout.write(`${session.access_token}\n`);
|
|
166
|
+
return { exitCode: EXIT_OK };
|
|
167
|
+
}
|
|
99
168
|
default:
|
|
100
|
-
throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|whoami>');
|
|
169
|
+
throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|status|token|whoami>');
|
|
101
170
|
}
|
|
102
171
|
}
|
|
103
172
|
export async function runProfilesCommand(context) {
|
package/package.json
CHANGED
package/skills/moda-cli/SKILL.md
CHANGED
|
@@ -140,7 +140,10 @@ Fields an agent should read:
|
|
|
140
140
|
|
|
141
141
|
`errors[].code` is one of `input_error`, `api_error`, `network_error`,
|
|
142
142
|
`auth_required`, `input_required`, `cancelled`, `command_error`,
|
|
143
|
-
`unknown_error`, or a documented domain code such as
|
|
143
|
+
`access_denied`, `unknown_error`, or a documented domain code such as
|
|
144
|
+
`cloud_unavailable`. `access_denied` (exit 1) is an access problem — no
|
|
145
|
+
organizations or an insufficient (viewer) role — distinct from `auth_required`
|
|
146
|
+
(exit 4, missing/expired credentials).
|
|
144
147
|
|
|
145
148
|
### The `moda.agent_event.v1` stream
|
|
146
149
|
|