@moda-ai/cli 1.12.6 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-storage.d.ts +35 -0
- package/dist/auth-storage.js +159 -0
- package/dist/auth.d.ts +25 -14
- package/dist/auth.js +442 -151
- package/dist/cli-session.d.ts +26 -0
- package/dist/cli-session.js +169 -0
- 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 +14 -3
- package/dist/init/tenant.d.ts +16 -2
- package/dist/init/tenant.js +182 -45
- package/dist/output.d.ts +1 -1
- package/dist/output.js +12 -1
- package/dist/profile-commands.js +81 -3
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
- package/skills/moda-cli/SKILL.md +120 -3
package/dist/init/tenant.js
CHANGED
|
@@ -1,36 +1,144 @@
|
|
|
1
1
|
// src/init/tenant.ts — Tenant selection and API key provisioning
|
|
2
2
|
import { select } from '@inquirer/prompts';
|
|
3
|
-
import { authFetch } from '../auth.js';
|
|
4
|
-
import { resolveIngestUrl, saveCliConfig } from '../config.js';
|
|
5
|
-
import {
|
|
3
|
+
import { authFetch, getCachedLoginContext } from '../auth.js';
|
|
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 = {}) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
};
|
|
102
|
+
// 1. Fetch CLI context. A PKCE login that just ran in this process already
|
|
103
|
+
// returned the user + tenants in the exchange response; reuse it and skip
|
|
104
|
+
// one /api/cli/context round-trip. The cache is profile-keyed (a login for
|
|
105
|
+
// a different profile returns null here), and the fetch stays as the
|
|
106
|
+
// fallback for cached-token runs (no login happened) and legacy logins.
|
|
107
|
+
let ctx;
|
|
108
|
+
const cachedContext = getCachedLoginContext(authOptions);
|
|
109
|
+
if (cachedContext) {
|
|
110
|
+
ctx = cachedContext;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const contextRes = await authFetch('/api/cli/context', {}, authOptions);
|
|
114
|
+
if (!contextRes.ok) {
|
|
115
|
+
const body = await contextRes.text();
|
|
116
|
+
throw new Error(`Failed to fetch context (${contextRes.status}): ${body}`);
|
|
117
|
+
}
|
|
118
|
+
ctx = (await contextRes.json());
|
|
119
|
+
}
|
|
120
|
+
log(` Authenticated as ${ctx.user.email}`);
|
|
16
121
|
// 2. Select tenant
|
|
17
122
|
if (ctx.tenants.length === 0) {
|
|
18
|
-
throw new
|
|
123
|
+
throw new CliAccessError('No organizations found. Create one at https://moda.dev');
|
|
19
124
|
}
|
|
20
|
-
let
|
|
21
|
-
if (options.tenantId) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
125
|
+
let selected;
|
|
126
|
+
if (options.tenantId !== undefined) {
|
|
127
|
+
// Exact id match only (case-sensitive; never slug/name, never a silent
|
|
128
|
+
// fallback to another tenant).
|
|
129
|
+
const match = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
|
|
130
|
+
if (!match) {
|
|
131
|
+
const valid = ctx.tenants
|
|
132
|
+
.map((tenant) => ` ${tenant.id} ${tenant.name} (${tenant.role})`)
|
|
133
|
+
.join('\n');
|
|
134
|
+
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
135
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.log(` Organization: ${selected.name}`);
|
|
136
|
+
selected = match;
|
|
137
|
+
log(` Organization: ${selected.name}`);
|
|
29
138
|
}
|
|
30
139
|
else if (ctx.tenants.length === 1) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
console.log(` Organization: ${ctx.tenants[0].name}`);
|
|
140
|
+
selected = ctx.tenants[0];
|
|
141
|
+
log(` Organization: ${selected.name}`);
|
|
34
142
|
}
|
|
35
143
|
else if (options.nonInteractive) {
|
|
36
144
|
throw new CliInputRequiredError('Multiple Moda organizations are available; choose one and re-run with --tenant-id.', {
|
|
@@ -46,28 +154,52 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
46
154
|
}, 'Re-run `moda init --tenant-id <tenant_id>`.');
|
|
47
155
|
}
|
|
48
156
|
else {
|
|
49
|
-
|
|
157
|
+
selected = await select({
|
|
50
158
|
message: 'Select organization',
|
|
51
|
-
choices: ctx.tenants.map((
|
|
52
|
-
name: `${
|
|
53
|
-
value:
|
|
159
|
+
choices: ctx.tenants.map((tenant) => ({
|
|
160
|
+
name: `${tenant.name} (${tenant.role})`,
|
|
161
|
+
value: tenant,
|
|
54
162
|
})),
|
|
55
163
|
});
|
|
56
164
|
}
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
165
|
+
// Role pre-check: the backend's bootstrap endpoint 403s viewers, so fail
|
|
166
|
+
// before creating anything. The 403 handler below stays as a backstop.
|
|
167
|
+
if (selected.role.toLowerCase() === 'viewer') {
|
|
168
|
+
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
169
|
+
}
|
|
170
|
+
// 3. Reuse a previously minted key for this tenant when it structurally
|
|
171
|
+
// belongs to it (embedded tenant id) AND still works against the Data API,
|
|
172
|
+
// so re-running init does not accumulate one CLI key per run. A failed
|
|
173
|
+
// decode, tenant mismatch, or probe failure falls through to minting.
|
|
174
|
+
let result;
|
|
175
|
+
const storedKey = findStoredKeyForTenant(selected.id, authOptions);
|
|
176
|
+
if (storedKey &&
|
|
177
|
+
embeddedTenantId(storedKey) === selected.id &&
|
|
178
|
+
(await isStoredKeyLive(storedKey, authOptions))) {
|
|
179
|
+
log(` Using existing CLI key for ${selected.name} (created earlier)`);
|
|
180
|
+
result = {
|
|
181
|
+
apiKey: storedKey,
|
|
182
|
+
tenantId: selected.id,
|
|
183
|
+
tenantName: selected.name,
|
|
184
|
+
tenantSlug: selected.slug,
|
|
185
|
+
};
|
|
65
186
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
187
|
+
// 4. Create API key via bootstrap endpoint
|
|
188
|
+
if (!result) {
|
|
189
|
+
const bootstrapRes = await authFetch('/api/cli/bootstrap', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify({ tenantId: selected.id }),
|
|
193
|
+
}, authOptions);
|
|
194
|
+
if (bootstrapRes.status === 403) {
|
|
195
|
+
throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
|
|
196
|
+
}
|
|
197
|
+
if (!bootstrapRes.ok) {
|
|
198
|
+
const body = await bootstrapRes.text();
|
|
199
|
+
throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
|
|
200
|
+
}
|
|
201
|
+
result = (await bootstrapRes.json());
|
|
69
202
|
}
|
|
70
|
-
const result = (await bootstrapRes.json());
|
|
71
203
|
// Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
|
|
72
204
|
// hook can authenticate (api_key) and the local dev orchestrator can scope
|
|
73
205
|
// ClickHouse (tenant_id) without the user exporting env vars into the shell
|
|
@@ -75,12 +207,17 @@ export async function selectTenantAndCreateKey(options = {}) {
|
|
|
75
207
|
saveCliConfig({
|
|
76
208
|
api_key: result.apiKey,
|
|
77
209
|
tenant_id: result.tenantId,
|
|
78
|
-
ingest_url: resolveIngestUrl(),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
210
|
+
ingest_url: resolveIngestUrl(authOptions),
|
|
211
|
+
}, authOptions);
|
|
212
|
+
// resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
|
|
213
|
+
// env var silently shadows the selection we just saved. Warn unless the env
|
|
214
|
+
// var was itself the source of the selection.
|
|
215
|
+
const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
|
|
216
|
+
if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
|
|
217
|
+
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
218
|
}
|
|
219
|
+
log(' API key + tenant saved to ~/.moda/config.json\n');
|
|
220
|
+
log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
|
|
84
221
|
return result;
|
|
85
222
|
}
|
|
86
223
|
//# 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, revokeCliSessionToken, } 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,
|
|
@@ -68,6 +72,10 @@ export async function runAuthCommand(context) {
|
|
|
68
72
|
return { exitCode: 0 };
|
|
69
73
|
}
|
|
70
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 });
|
|
71
79
|
clearAuthSession({ profile: context.profile, env: context.env });
|
|
72
80
|
context.output.writeData({
|
|
73
81
|
status: 'logged_out',
|
|
@@ -96,8 +104,78 @@ export async function runAuthCommand(context) {
|
|
|
96
104
|
});
|
|
97
105
|
return { exitCode: 0 };
|
|
98
106
|
}
|
|
107
|
+
case 'status': {
|
|
108
|
+
// Being logged out is an expected state for `auth status`, not a
|
|
109
|
+
// failure: it must never throw CliAuthError (no error block, no error
|
|
110
|
+
// telemetry classification) and must never trigger a browser open. It
|
|
111
|
+
// reports clean data in every mode and signals session validity purely
|
|
112
|
+
// through the exit code (0 valid, 4 auth required).
|
|
113
|
+
const authOptions = { profile: context.profile, env: context.env };
|
|
114
|
+
const session = loadAuthSession({ ...authOptions, includeExpired: true });
|
|
115
|
+
const loggedIn = isAuthSessionValid(session);
|
|
116
|
+
const data = {
|
|
117
|
+
loggedIn,
|
|
118
|
+
profile: context.profile ??
|
|
119
|
+
context.env.MODA_PROFILE ??
|
|
120
|
+
session?.profile ??
|
|
121
|
+
null,
|
|
122
|
+
expiresAt: session
|
|
123
|
+
? new Date(session.expires_at * 1000).toISOString()
|
|
124
|
+
: null,
|
|
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
|
+
}
|
|
131
|
+
if (boolFlag(context, 'online')) {
|
|
132
|
+
let online = { reachable: false };
|
|
133
|
+
try {
|
|
134
|
+
const response = await authFetch('/api/cli/context', { signal: AbortSignal.timeout(ONLINE_PROBE_TIMEOUT_MS) }, authOptions);
|
|
135
|
+
// Any HTTP response means the server was reached; `ok` separates a
|
|
136
|
+
// healthy 2xx from e.g. a 401/500.
|
|
137
|
+
online = { reachable: true, ok: response.ok };
|
|
138
|
+
if (response.ok) {
|
|
139
|
+
const body = await response.json();
|
|
140
|
+
if (Array.isArray(body.tenants)) {
|
|
141
|
+
online.tenantCount = body.tenants.length;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Network failure, probe timeout, or no stored token to send: report
|
|
147
|
+
// reachable: false; the exit code still follows local session
|
|
148
|
+
// validity, never the online probe.
|
|
149
|
+
}
|
|
150
|
+
data.online = online;
|
|
151
|
+
}
|
|
152
|
+
context.output.writeData(data);
|
|
153
|
+
return { exitCode: loggedIn ? EXIT_OK : EXIT_AUTH_REQUIRED };
|
|
154
|
+
}
|
|
155
|
+
case 'token': {
|
|
156
|
+
const session = loadAuthSession({ profile: context.profile, env: context.env });
|
|
157
|
+
if (!session) {
|
|
158
|
+
if (context.flags.json === 'true') {
|
|
159
|
+
// An explicit --json caller gets a parseable failure on stdout.
|
|
160
|
+
process.stdout.write(`${JSON.stringify({ error: 'not_authenticated' })}\n`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
console.error('Not authenticated. Run "moda auth login".');
|
|
164
|
+
}
|
|
165
|
+
return { exitCode: EXIT_AUTH_REQUIRED };
|
|
166
|
+
}
|
|
167
|
+
// `auth token` exists to compose as $(moda auth token) and in pipes.
|
|
168
|
+
// Command substitution IS a pipe, so stdout is a non-TTY and this CLI
|
|
169
|
+
// would auto-select agent mode and wrap the token in a JSON envelope,
|
|
170
|
+
// breaking every composition. This command therefore deliberately
|
|
171
|
+
// bypasses the CliOutput envelope machinery in EVERY output mode
|
|
172
|
+
// (human/json/agent/agent-stream) and writes exactly the bare token plus
|
|
173
|
+
// a trailing newline to stdout — nothing else.
|
|
174
|
+
process.stdout.write(`${session.access_token}\n`);
|
|
175
|
+
return { exitCode: EXIT_OK };
|
|
176
|
+
}
|
|
99
177
|
default:
|
|
100
|
-
throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|whoami>');
|
|
178
|
+
throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|status|token|whoami>');
|
|
101
179
|
}
|
|
102
180
|
}
|
|
103
181
|
export async function runProfilesCommand(context) {
|
package/package.json
CHANGED
package/skills/moda-cli/SKILL.md
CHANGED
|
@@ -78,6 +78,53 @@ npx -p @moda-ai/cli moda <command>
|
|
|
78
78
|
export MODA_API_KEY="moda_sk_..." # from moda.dev/settings
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
### Auth session inspection and token composition
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
moda auth login # browser PKCE sign-in; stores a moda_ct_ session token
|
|
85
|
+
moda auth logout # revoke the session server-side, then delete it locally
|
|
86
|
+
moda auth status # local session state; exit 0 logged in, exit 4 logged out
|
|
87
|
+
moda auth status --online # additionally probe GET /api/cli/context
|
|
88
|
+
moda auth token # print the raw bearer token + newline, nothing else
|
|
89
|
+
curl -H "Authorization: Bearer $(moda auth token)" https://moda.dev/api/cli/context
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `moda auth login` opens the browser consent flow at `{MODA_BASE_URL}/auth/cli`
|
|
93
|
+
with a localhost callback (ports 8432-8435, first free). Approving in the
|
|
94
|
+
browser hands the CLI a one-time code that it exchanges for a `moda_ct_...`
|
|
95
|
+
session token (sliding 30-day expiry, renewed automatically in the
|
|
96
|
+
background while you keep using the CLI). If the browser cannot reach the
|
|
97
|
+
machine, paste the callback URL or the code shown on the consent page into
|
|
98
|
+
the terminal.
|
|
99
|
+
- `moda auth logout` revokes the stored `moda_ct_` session server-side
|
|
100
|
+
(best-effort) and deletes the local credential; legacy sessions are just
|
|
101
|
+
deleted locally.
|
|
102
|
+
- `moda auth status` reports
|
|
103
|
+
`{ "loggedIn": <bool>, "profile": <string|null>, "expiresAt": <ISO|null> }`
|
|
104
|
+
plus `"user": { "email": <string> }` when the stored session carries an
|
|
105
|
+
email (PKCE `moda_ct_` sessions do; legacy sessions omit it), and exits `4`
|
|
106
|
+
when logged out. That exit is an **expected state signal, not
|
|
107
|
+
an error**: the payload is clean data (no error block, no stderr noise), and
|
|
108
|
+
no browser is ever opened. With `--online` the payload adds
|
|
109
|
+
`{ "online": { "reachable": <bool>, "ok": <bool>, "tenantCount": <n> } }`:
|
|
110
|
+
`reachable: true` means any HTTP response arrived (even a 401/500), `ok`
|
|
111
|
+
reflects HTTP success, and `tenantCount` is present only when the response
|
|
112
|
+
was ok and carried a tenants array. A network failure or probe timeout
|
|
113
|
+
(5 s) yields `reachable: false`; the exit code always follows local session
|
|
114
|
+
validity only, never the probe.
|
|
115
|
+
- `moda auth token` writes exactly the bare token plus a trailing newline to
|
|
116
|
+
stdout in **every** output mode — including piped/json/agent, because
|
|
117
|
+
command substitution is a pipe — so `$(moda auth token)` composes safely
|
|
118
|
+
with no envelope to strip. When logged out it prints a stderr message and
|
|
119
|
+
exits `4`; with an explicit `--json` flag it instead prints
|
|
120
|
+
`{"error":"not_authenticated"}` to stdout (still exit `4`).
|
|
121
|
+
- `moda auth token` prints whichever credential kind is stored. Newer logins
|
|
122
|
+
store a `moda_ct_...` CLI session token, which authenticates **`/api/cli/*`
|
|
123
|
+
endpoints only** — it is not accepted by the Data API (`/api/v1/data/*`
|
|
124
|
+
wants a `moda_sk_...` API key) or other dashboard endpoints. Legacy
|
|
125
|
+
sessions print the Auth0 bearer token as before.
|
|
126
|
+
- Both respect `--profile=NAME` and `MODA_PROFILE`.
|
|
127
|
+
|
|
81
128
|
## Agent protocol
|
|
82
129
|
|
|
83
130
|
Run `moda manifest --json` first when integrating with the CLI. It returns the
|
|
@@ -117,10 +164,73 @@ Branch on the exit code; it is stable and cheaper than parsing the payload:
|
|
|
117
164
|
| `0` | ok — answer is grounded in Moda Cloud intelligence | Trust it. |
|
|
118
165
|
| `1` | error — bad input, API/network failure, or unexpected failure | Read `errors[]`; retry only when the code is retryable. |
|
|
119
166
|
| `3` | degraded — Moda Cloud was unavailable; answer synthesized from local Data API evidence | Answer is present but lower-trust; verify before acting. |
|
|
120
|
-
| `4` | auth required | Run `
|
|
121
|
-
| `5` | input required | Pick a choice
|
|
167
|
+
| `4` | auth required — credentials are missing or expired | Run the `login_command` from the `auth` payload (`moda auth login`), then re-run `resume_command`. |
|
|
168
|
+
| `5` | input required — a choice is needed that the non-interactive run could not answer | Pick a choice from the `input_request` payload and re-invoke the same command with that choice's `resume_flags` appended. |
|
|
122
169
|
| `130` | cancelled | Treat as interrupted or closed pipe; do not retry blindly. |
|
|
123
170
|
|
|
171
|
+
Exit codes `4` and `5` are recoverable-by-caller failures: the envelope's
|
|
172
|
+
`errors[]` entry carries the exact commands/flags to recover with, as argv
|
|
173
|
+
arrays (execute element-wise; join with spaces only for display).
|
|
174
|
+
|
|
175
|
+
#### `auth_required` payload (exit `4`)
|
|
176
|
+
|
|
177
|
+
The envelope's `errors[]` entry carries the recovery data on its `auth` field
|
|
178
|
+
(in `--agent --stream` mode the same error arrives as an NDJSON event with
|
|
179
|
+
`"event": "auth_required"`):
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"code": "auth_required",
|
|
184
|
+
"message": "Browser authentication is required before non-interactive init can continue.",
|
|
185
|
+
"suggested_commands": ["moda auth login", "moda init --yes"],
|
|
186
|
+
"blocking": true,
|
|
187
|
+
"auth": {
|
|
188
|
+
"reason": "no_stored_credentials",
|
|
189
|
+
"login_command": ["moda", "auth", "login"],
|
|
190
|
+
"resume_command": ["moda", "init", "--yes"]
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`reason` is one of:
|
|
196
|
+
|
|
197
|
+
- `no_stored_credentials` — no session stored. Run `login_command`, then
|
|
198
|
+
`resume_command` (when present).
|
|
199
|
+
- `token_expired` — the stored session expired. Run `login_command`, then
|
|
200
|
+
`resume_command`.
|
|
201
|
+
- `token_revoked` — the stored `moda_ct_` CLI session token was revoked
|
|
202
|
+
server-side (e.g. from the dashboard). Run `login_command`, then
|
|
203
|
+
`resume_command`.
|
|
204
|
+
- `renew_failed` — the API rejected an **unexpired** `moda_ct_` token but the
|
|
205
|
+
renewal check could not reach the server, so this is likely transient:
|
|
206
|
+
**retry the command; do not re-login.**
|
|
207
|
+
|
|
208
|
+
#### `needs_input` payload (exit `5`)
|
|
209
|
+
|
|
210
|
+
The envelope's `errors[]` entry carries the recovery data on its
|
|
211
|
+
`input_request` field (in `--agent --stream` mode the event type is
|
|
212
|
+
`"needs_input"`):
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"code": "needs_input",
|
|
217
|
+
"message": "An organization must be selected to continue.",
|
|
218
|
+
"blocking": true,
|
|
219
|
+
"input_request": {
|
|
220
|
+
"id": "init_tenant_selection",
|
|
221
|
+
"question": "Select organization",
|
|
222
|
+
"kind": "select",
|
|
223
|
+
"choices": [
|
|
224
|
+
{ "value": "tnt_a", "label": "Acme (owner)", "resume_flags": ["--tenant-id", "tnt_a"] },
|
|
225
|
+
{ "value": "tnt_b", "label": "Beta (viewer)", "resume_flags": ["--tenant-id", "tnt_b"] }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Re-invoke the original command with the chosen `resume_flags` appended (here:
|
|
232
|
+
`moda init --tenant-id tnt_a`).
|
|
233
|
+
|
|
124
234
|
### The `moda.agent.v1` envelope
|
|
125
235
|
|
|
126
236
|
Fields an agent should read:
|
|
@@ -140,7 +250,10 @@ Fields an agent should read:
|
|
|
140
250
|
|
|
141
251
|
`errors[].code` is one of `input_error`, `api_error`, `network_error`,
|
|
142
252
|
`auth_required`, `input_required`, `cancelled`, `command_error`,
|
|
143
|
-
`unknown_error`, or a documented domain code such as
|
|
253
|
+
`access_denied`, `unknown_error`, or a documented domain code such as
|
|
254
|
+
`cloud_unavailable`. `access_denied` (exit 1) is an access problem — no
|
|
255
|
+
organizations or an insufficient (viewer) role — distinct from `auth_required`
|
|
256
|
+
(exit 4, missing/expired credentials).
|
|
144
257
|
|
|
145
258
|
### The `moda.agent_event.v1` stream
|
|
146
259
|
|
|
@@ -581,6 +694,10 @@ npx: `npx -p @moda-ai/cli moda <command>`.
|
|
|
581
694
|
| Command | Purpose |
|
|
582
695
|
|---|---|
|
|
583
696
|
| `moda init` | Browser auth + provision API key + install agent rules |
|
|
697
|
+
| `moda auth login` | Browser PKCE sign-in; stores a revocable `moda_ct_` session token |
|
|
698
|
+
| `moda auth logout` | Revoke the CLI session server-side, then delete it locally |
|
|
699
|
+
| `moda auth status` | Local auth session state (exit `4` = logged out — expected, not an error) |
|
|
700
|
+
| `moda auth token` | Print the raw bearer token for `$(moda auth token)` composition |
|
|
584
701
|
| `moda search "<query>"` | **Message-grain semantic/keyword/hybrid search (incl. tool calls); primary content-discovery path** |
|
|
585
702
|
| `moda overview` | Dashboard KPIs, top clusters, recent activity |
|
|
586
703
|
| `moda ask "<question>"` | Natural-language production/harness answer with evidence (exit `3` = degraded local fallback) |
|