@moda-ai/cli 1.18.1 → 1.19.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.js +6 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +28 -0
- package/dist/harness.d.ts +45 -0
- package/dist/harness.js +203 -12
- package/dist/init/index.d.ts +39 -1
- package/dist/init/index.js +422 -10
- package/dist/init/tenant.js +7 -25
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -250,6 +250,10 @@ Init flags:
|
|
|
250
250
|
--no-ci-cd Skip CI/CD workflow creation
|
|
251
251
|
--autosync Create GitHub Actions workflow to sync prompts + skills to Moda on push to main
|
|
252
252
|
--no-autosync Skip autosync workflow creation
|
|
253
|
+
--harness-rescan Create GitHub Actions workflow to re-scan the harness and sync it to Moda on push to main
|
|
254
|
+
--no-harness-rescan Skip harness rescan workflow creation
|
|
255
|
+
--harness-rescan-paths=GLOBS Comma-separated path globs that scope which pushes trigger the
|
|
256
|
+
harness rescan workflow (implies --harness-rescan)
|
|
253
257
|
--offline Run final validation offline (skip the online telemetry check)
|
|
254
258
|
|
|
255
259
|
Provision flags (headless; requires an existing "moda auth login" session):
|
|
@@ -292,6 +296,7 @@ Examples:
|
|
|
292
296
|
moda harness explain --agent=agent_support
|
|
293
297
|
moda init --ci-cd
|
|
294
298
|
moda init --autosync
|
|
299
|
+
moda init --harness-rescan --harness-rescan-paths='src/agents/**'
|
|
295
300
|
moda overview --days-back=30
|
|
296
301
|
moda clusters --time-range=7d
|
|
297
302
|
moda conversations --search="error" --limit=5
|
|
@@ -707,7 +712,7 @@ export const commandRegistry = createCommandRegistry([
|
|
|
707
712
|
{
|
|
708
713
|
name: 'init',
|
|
709
714
|
description: 'Set up Moda in your project',
|
|
710
|
-
examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync'],
|
|
715
|
+
examples: ['moda init', 'moda init --sdk-integrate', 'moda init --ci-cd', 'moda init --autosync', 'moda init --harness-rescan'],
|
|
711
716
|
auth: 'none',
|
|
712
717
|
outputModes: ['human', 'json', 'agent'],
|
|
713
718
|
mutability: 'write',
|
package/dist/config.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export declare function saveCliConfig(update: CliConfig, options?: ProfileIOOpti
|
|
|
28
28
|
* long-lived process (or a test) sees the current value.
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveApiKey(options?: ProfileIOOptions): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Signed CLI keys embed their tenant: the format is
|
|
33
|
+
* moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
|
|
34
|
+
* createApiKey/parseAndVerifySignedKey in
|
|
35
|
+
* apps/moda-backend/src/api-keys/api-keys.service.ts. The server derives the
|
|
36
|
+
* upload destination tenant from the key alone, so this is the only
|
|
37
|
+
* client-side tenant source that matches where data actually lands
|
|
38
|
+
* (config / MODA_TENANT_ID can disagree with a shadowing env key). The HMAC
|
|
39
|
+
* signature can only be verified server-side; treat the result as
|
|
40
|
+
* informational.
|
|
41
|
+
*/
|
|
42
|
+
export declare function embeddedTenantIdFromApiKey(key: string): string | undefined;
|
|
31
43
|
/**
|
|
32
44
|
* Resolve the ingest worker base URL used by `moda workspace snapshot`.
|
|
33
45
|
* Env override -> active profile -> legacy config -> production default.
|
package/dist/config.js
CHANGED
|
@@ -40,6 +40,34 @@ export function resolveApiKey(options = {}) {
|
|
|
40
40
|
loadCliConfig(options).api_key ||
|
|
41
41
|
undefined);
|
|
42
42
|
}
|
|
43
|
+
const SIGNED_API_KEY_PREFIX = 'moda_sk_';
|
|
44
|
+
const SIGNED_API_KEY_VERSION = 'v1';
|
|
45
|
+
/**
|
|
46
|
+
* Signed CLI keys embed their tenant: the format is
|
|
47
|
+
* moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
|
|
48
|
+
* createApiKey/parseAndVerifySignedKey in
|
|
49
|
+
* apps/moda-backend/src/api-keys/api-keys.service.ts. The server derives the
|
|
50
|
+
* upload destination tenant from the key alone, so this is the only
|
|
51
|
+
* client-side tenant source that matches where data actually lands
|
|
52
|
+
* (config / MODA_TENANT_ID can disagree with a shadowing env key). The HMAC
|
|
53
|
+
* signature can only be verified server-side; treat the result as
|
|
54
|
+
* informational.
|
|
55
|
+
*/
|
|
56
|
+
export function embeddedTenantIdFromApiKey(key) {
|
|
57
|
+
if (!key.startsWith(SIGNED_API_KEY_PREFIX))
|
|
58
|
+
return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const decoded = Buffer.from(key.slice(SIGNED_API_KEY_PREFIX.length), 'base64url').toString('utf8');
|
|
61
|
+
// Expected format: version:keyId:tenantId:timestamp:signature
|
|
62
|
+
const parts = decoded.split(':');
|
|
63
|
+
if (parts.length !== 5 || parts[0] !== SIGNED_API_KEY_VERSION)
|
|
64
|
+
return undefined;
|
|
65
|
+
return parts[2] || undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
43
71
|
/**
|
|
44
72
|
* Resolve the ingest worker base URL used by `moda workspace snapshot`.
|
|
45
73
|
* Env override -> active profile -> legacy config -> production default.
|
package/dist/harness.d.ts
CHANGED
|
@@ -390,6 +390,51 @@ declare function buildCommandScanPlan(context: CommandContext, rootDir: string):
|
|
|
390
390
|
};
|
|
391
391
|
export declare function writeHumanProgress(context: CommandContext, message: string): void;
|
|
392
392
|
export declare function readHarnessSyncState(rootDir: string): HarnessSyncState | undefined;
|
|
393
|
+
/**
|
|
394
|
+
* The identity a sync-style upload will actually authenticate as, resolved
|
|
395
|
+
* with the same precedence as the API call itself (MODA_API_KEY env wins over
|
|
396
|
+
* the active profile / legacy config). Used for the pre-upload
|
|
397
|
+
* "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
|
|
398
|
+
* is visible BEFORE data lands in the wrong tenant.
|
|
399
|
+
*
|
|
400
|
+
* The destination tenant is derived from the WINNING KEY itself (signed CLI
|
|
401
|
+
* keys embed their tenant, and the server resolves the destination from the
|
|
402
|
+
* key alone) — never from flag/env/profile config, which is exactly what a
|
|
403
|
+
* shadowing stale key disagrees with. When the key does not embed a tenant,
|
|
404
|
+
* the config-resolved id is returned with `tenantSource: 'config'` so
|
|
405
|
+
* callers must label it unverified. Best-effort: never throws (a broken
|
|
406
|
+
* MODA_PROFILE must not abort onboarding).
|
|
407
|
+
*/
|
|
408
|
+
export declare function describeSyncIdentity(options?: {
|
|
409
|
+
profile?: string;
|
|
410
|
+
env?: NodeJS.ProcessEnv;
|
|
411
|
+
}): {
|
|
412
|
+
credential: string;
|
|
413
|
+
tenantId?: string;
|
|
414
|
+
/** 'api-key' = decoded from the winning key (authoritative); 'config' = flag/env/profile fallback (unverified). */
|
|
415
|
+
tenantSource?: 'api-key' | 'config';
|
|
416
|
+
} | undefined;
|
|
417
|
+
/**
|
|
418
|
+
* Human tenant label for the identity banner: key-derived ids are printed
|
|
419
|
+
* bare; config-derived ids are explicitly marked unverified so the banner
|
|
420
|
+
* never asserts a destination the server might not use.
|
|
421
|
+
*/
|
|
422
|
+
export declare function describeSyncTenantLabel(identity: {
|
|
423
|
+
tenantId?: string;
|
|
424
|
+
tenantSource?: 'api-key' | 'config';
|
|
425
|
+
}): string;
|
|
426
|
+
/**
|
|
427
|
+
* Reports in subdirectories that are FRESHER than the root's
|
|
428
|
+
* `.moda/harness-report.json` (a missing root report counts as infinitely
|
|
429
|
+
* old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
|
|
430
|
+
* `<subdir>/.moda/harness-report.json`, and a later root-level
|
|
431
|
+
* `harness approve`/`sync` silently targets the stale (or absent) root report.
|
|
432
|
+
* Newest first.
|
|
433
|
+
*/
|
|
434
|
+
export declare function findFresherHarnessReports(rootDir: string): Array<{
|
|
435
|
+
path: string;
|
|
436
|
+
mtimeMs: number;
|
|
437
|
+
}>;
|
|
393
438
|
export declare function writeHarnessArtifacts(graph: HarnessGraph, rootDir: string, options?: {
|
|
394
439
|
summary?: boolean;
|
|
395
440
|
}): string[];
|
package/dist/harness.js
CHANGED
|
@@ -6,7 +6,7 @@ import { basename, delimiter, dirname, extname, join, relative, resolve, sep } f
|
|
|
6
6
|
import { confirm, select } from '@inquirer/prompts';
|
|
7
7
|
import { callControlAPI } from './api-client.js';
|
|
8
8
|
import { AnalystTui, shouldUseAnalystTui } from './analyst-tui.js';
|
|
9
|
-
import { resolveApiKey, resolveTenantId } from './config.js';
|
|
9
|
+
import { embeddedTenantIdFromApiKey, resolveApiKey, resolveTenantId } from './config.js';
|
|
10
10
|
import { CliInputError, CliInputRequiredError } from './errors.js';
|
|
11
11
|
import { HARNESS_REMOTE_RUN_STATE_PATH, isRemoteHarnessAnalyze, readRemoteRunState, runHarnessPull, runRemoteHarnessAnalyst, } from './harness-remote.js';
|
|
12
12
|
import { getActiveProfile } from './profiles.js';
|
|
@@ -101,6 +101,24 @@ export async function runHarnessCommand(context) {
|
|
|
101
101
|
if (!resolveApiKey({ profile: context.profile, env: context.env }) && !context.dryRun) {
|
|
102
102
|
throw new CliInputError('No Moda API key found for harness sync.', 'Run `moda init` first, set MODA_API_KEY, or select a profile with an API key.');
|
|
103
103
|
}
|
|
104
|
+
warnFresherSubdirHarnessReports(context, rootDir, context.flags['from-report'] ?? context.flags.report);
|
|
105
|
+
if (!context.dryRun) {
|
|
106
|
+
const identity = describeSyncIdentity({ profile: context.profile, env: context.env });
|
|
107
|
+
if (identity) {
|
|
108
|
+
// The banner's tenant comes from the winning key when it embeds
|
|
109
|
+
// one. Otherwise fall back to the flag/env/profile-resolved tenant
|
|
110
|
+
// but label it unverified — that source is precisely what a
|
|
111
|
+
// shadowing stale key disagrees with, so it must never be asserted
|
|
112
|
+
// as the destination.
|
|
113
|
+
const label = identity.tenantSource === 'api-key'
|
|
114
|
+
? describeSyncTenantLabel(identity)
|
|
115
|
+
: describeSyncTenantLabel({
|
|
116
|
+
tenantId: tenant?.tenantId ?? identity.tenantId,
|
|
117
|
+
tenantSource: 'config',
|
|
118
|
+
});
|
|
119
|
+
writeHumanProgress(context, `Syncing as ${identity.credential} → tenant ${label}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
104
122
|
if (context.flags['from-report'] || context.flags.report) {
|
|
105
123
|
const reportPath = resolveHarnessReportPath(rootDir, context.flags['from-report'] ?? context.flags.report);
|
|
106
124
|
const { report, validation, approval, approved } = loadHarnessReportForSync(rootDir, reportPath);
|
|
@@ -344,6 +362,7 @@ export async function runHarnessCommand(context) {
|
|
|
344
362
|
return { exitCode: validation.status === 'pass' ? 0 : 1 };
|
|
345
363
|
}
|
|
346
364
|
case 'approve': {
|
|
365
|
+
warnFresherSubdirHarnessReports(context, rootDir, context.positionals[1] ?? context.flags.report);
|
|
347
366
|
const reportPath = resolveHarnessReportPath(rootDir, context.positionals[1] ?? context.flags.report);
|
|
348
367
|
const approval = await approveHarnessReport({
|
|
349
368
|
rootDir,
|
|
@@ -363,7 +382,7 @@ export async function runHarnessCommand(context) {
|
|
|
363
382
|
return { exitCode: 0 };
|
|
364
383
|
}
|
|
365
384
|
case 'agents': {
|
|
366
|
-
const graph = loadOrScanHarness(rootDir, tenant);
|
|
385
|
+
const graph = loadOrScanHarness(rootDir, tenant, context);
|
|
367
386
|
context.output.writeData({
|
|
368
387
|
summary: summarizeHarness(graph),
|
|
369
388
|
agentFamilies: graph.agentFamilies,
|
|
@@ -373,12 +392,12 @@ export async function runHarnessCommand(context) {
|
|
|
373
392
|
return { exitCode: 0 };
|
|
374
393
|
}
|
|
375
394
|
case 'graph': {
|
|
376
|
-
const graph = loadOrScanHarness(rootDir, tenant);
|
|
395
|
+
const graph = loadOrScanHarness(rootDir, tenant, context);
|
|
377
396
|
context.output.writeData(graph);
|
|
378
397
|
return { exitCode: 0 };
|
|
379
398
|
}
|
|
380
399
|
case 'explain': {
|
|
381
|
-
const graph = loadOrScanHarness(rootDir, tenant);
|
|
400
|
+
const graph = loadOrScanHarness(rootDir, tenant, context);
|
|
382
401
|
if (selectedAgentId) {
|
|
383
402
|
const agent = graph.runtimeAgents.find((candidate) => candidate.id === selectedAgentId);
|
|
384
403
|
if (!agent) {
|
|
@@ -2040,15 +2059,187 @@ function selectedRuntimeAgentId(flags) {
|
|
|
2040
2059
|
return flags.agent;
|
|
2041
2060
|
return undefined;
|
|
2042
2061
|
}
|
|
2043
|
-
function loadOrScanHarness(rootDir, tenant) {
|
|
2062
|
+
function loadOrScanHarness(rootDir, tenant, context) {
|
|
2044
2063
|
const manifestPath = join(rootDir, HARNESS_PATH);
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
}
|
|
2064
|
+
if (existsSync(manifestPath))
|
|
2065
|
+
return readHarnessGraph(manifestPath);
|
|
2066
|
+
// Provenance banner: without a persisted manifest this command output comes
|
|
2067
|
+
// from a live scan of the current directory, which is easy to mistake for
|
|
2068
|
+
// synced/account data (especially right after switching repos or profiles).
|
|
2069
|
+
if (context) {
|
|
2070
|
+
writeHarnessNotice(context, `No ${HARNESS_PATH} found; showing a live scan of ${rootDir}. ` +
|
|
2071
|
+
'This is local scan output, NOT synced or account data. ' +
|
|
2072
|
+
'Run `moda harness scan` to persist it and `moda harness sync` to upload it.');
|
|
2073
|
+
}
|
|
2074
|
+
return scanHarness({
|
|
2075
|
+
rootDir,
|
|
2076
|
+
tenantId: tenant?.tenantId,
|
|
2077
|
+
tenantSource: tenant?.source,
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Warning line that must be visible in every output mode: styled progress on
|
|
2082
|
+
* stderr for humans, a plain `Warning:` stderr line for machine modes (stdout
|
|
2083
|
+
* stays machine-readable JSON either way). Quiet suppresses it like the rest
|
|
2084
|
+
* of the harness progress surface.
|
|
2085
|
+
*/
|
|
2086
|
+
function writeHarnessNotice(context, message) {
|
|
2087
|
+
if (context.quiet && context.flags.progress !== 'true')
|
|
2088
|
+
return;
|
|
2089
|
+
if (context.outputMode === 'human') {
|
|
2090
|
+
console.error(renderHarnessProgress(message, 'warning'));
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
console.error(`Warning: ${message}`);
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* The identity a sync-style upload will actually authenticate as, resolved
|
|
2097
|
+
* with the same precedence as the API call itself (MODA_API_KEY env wins over
|
|
2098
|
+
* the active profile / legacy config). Used for the pre-upload
|
|
2099
|
+
* "Syncing as <credential> → tenant <id>" banner so a shadowed or stale key
|
|
2100
|
+
* is visible BEFORE data lands in the wrong tenant.
|
|
2101
|
+
*
|
|
2102
|
+
* The destination tenant is derived from the WINNING KEY itself (signed CLI
|
|
2103
|
+
* keys embed their tenant, and the server resolves the destination from the
|
|
2104
|
+
* key alone) — never from flag/env/profile config, which is exactly what a
|
|
2105
|
+
* shadowing stale key disagrees with. When the key does not embed a tenant,
|
|
2106
|
+
* the config-resolved id is returned with `tenantSource: 'config'` so
|
|
2107
|
+
* callers must label it unverified. Best-effort: never throws (a broken
|
|
2108
|
+
* MODA_PROFILE must not abort onboarding).
|
|
2109
|
+
*/
|
|
2110
|
+
export function describeSyncIdentity(options = {}) {
|
|
2111
|
+
const apiKey = resolveApiKey(options);
|
|
2112
|
+
if (!apiKey)
|
|
2113
|
+
return undefined;
|
|
2114
|
+
const redacted = apiKey.length > 12
|
|
2115
|
+
? `${apiKey.slice(0, 8)}…${apiKey.slice(-4)}`
|
|
2116
|
+
: `…${apiKey.slice(-4)}`;
|
|
2117
|
+
const env = options.env ?? process.env;
|
|
2118
|
+
// getActiveProfile throws when the named profile is missing (e.g. a stale
|
|
2119
|
+
// MODA_PROFILE) even though the key already resolved via legacy config —
|
|
2120
|
+
// the banner is advisory and must never abort the command around it.
|
|
2121
|
+
let profileName;
|
|
2122
|
+
try {
|
|
2123
|
+
profileName = getActiveProfile(options).name;
|
|
2124
|
+
}
|
|
2125
|
+
catch {
|
|
2126
|
+
profileName = undefined;
|
|
2127
|
+
}
|
|
2128
|
+
const credential = env.MODA_API_KEY === apiKey
|
|
2129
|
+
? `MODA_API_KEY env key (${redacted})`
|
|
2130
|
+
: profileName
|
|
2131
|
+
? `profile '${profileName}' key (${redacted})`
|
|
2132
|
+
: `stored key (${redacted})`;
|
|
2133
|
+
const keyTenantId = embeddedTenantIdFromApiKey(apiKey);
|
|
2134
|
+
if (keyTenantId) {
|
|
2135
|
+
return { credential, tenantId: keyTenantId, tenantSource: 'api-key' };
|
|
2136
|
+
}
|
|
2137
|
+
const configTenantId = resolveTenantId(options);
|
|
2138
|
+
return {
|
|
2139
|
+
credential,
|
|
2140
|
+
tenantId: configTenantId,
|
|
2141
|
+
tenantSource: configTenantId ? 'config' : undefined,
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Human tenant label for the identity banner: key-derived ids are printed
|
|
2146
|
+
* bare; config-derived ids are explicitly marked unverified so the banner
|
|
2147
|
+
* never asserts a destination the server might not use.
|
|
2148
|
+
*/
|
|
2149
|
+
export function describeSyncTenantLabel(identity) {
|
|
2150
|
+
if (!identity.tenantId)
|
|
2151
|
+
return 'unresolved';
|
|
2152
|
+
if (identity.tenantSource === 'api-key')
|
|
2153
|
+
return identity.tenantId;
|
|
2154
|
+
return `${identity.tenantId} (unverified — from local config, not the API key; the server derives the destination tenant from the key)`;
|
|
2155
|
+
}
|
|
2156
|
+
/** Bounded walk looking for `<subdir>/.moda/harness-report.json` files. */
|
|
2157
|
+
const FRESHER_REPORT_MAX_DEPTH = 4;
|
|
2158
|
+
const FRESHER_REPORT_MAX_DIRS = 4_000;
|
|
2159
|
+
/**
|
|
2160
|
+
* Reports in subdirectories that are FRESHER than the root's
|
|
2161
|
+
* `.moda/harness-report.json` (a missing root report counts as infinitely
|
|
2162
|
+
* old). Catches the `--path` trap: `harness analyze --path=<subdir>` writes
|
|
2163
|
+
* `<subdir>/.moda/harness-report.json`, and a later root-level
|
|
2164
|
+
* `harness approve`/`sync` silently targets the stale (or absent) root report.
|
|
2165
|
+
* Newest first.
|
|
2166
|
+
*/
|
|
2167
|
+
export function findFresherHarnessReports(rootDir) {
|
|
2168
|
+
let rootMtimeMs = 0;
|
|
2169
|
+
try {
|
|
2170
|
+
rootMtimeMs = lstatSync(join(rootDir, HARNESS_REPORT_PATH)).mtimeMs;
|
|
2171
|
+
}
|
|
2172
|
+
catch {
|
|
2173
|
+
rootMtimeMs = 0;
|
|
2174
|
+
}
|
|
2175
|
+
const fresher = [];
|
|
2176
|
+
let visited = 0;
|
|
2177
|
+
const walk = (dir, depth) => {
|
|
2178
|
+
if (depth > FRESHER_REPORT_MAX_DEPTH || visited >= FRESHER_REPORT_MAX_DIRS)
|
|
2179
|
+
return;
|
|
2180
|
+
visited += 1;
|
|
2181
|
+
let entries;
|
|
2182
|
+
try {
|
|
2183
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2184
|
+
}
|
|
2185
|
+
catch {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
for (const entry of entries) {
|
|
2189
|
+
// Dot-dirs (including each dir's own `.moda`, checked explicitly below)
|
|
2190
|
+
// and scan-skipped dirs never host a user project subtree.
|
|
2191
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || SKIPPED_DIRS.has(entry.name))
|
|
2192
|
+
continue;
|
|
2193
|
+
const child = join(dir, entry.name);
|
|
2194
|
+
try {
|
|
2195
|
+
const candidate = lstatSync(join(child, HARNESS_REPORT_PATH));
|
|
2196
|
+
if (candidate.isFile() && candidate.mtimeMs > rootMtimeMs) {
|
|
2197
|
+
fresher.push({
|
|
2198
|
+
path: normalizePath(relative(rootDir, join(child, HARNESS_REPORT_PATH))),
|
|
2199
|
+
mtimeMs: candidate.mtimeMs,
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
catch {
|
|
2204
|
+
// No report in this subdir; keep walking.
|
|
2205
|
+
}
|
|
2206
|
+
walk(child, depth + 1);
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
walk(rootDir, 1);
|
|
2210
|
+
return fresher.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* `--path` trap guard for `harness approve` / `harness sync`: when the
|
|
2214
|
+
* report the command is about to use is older than a fresher
|
|
2215
|
+
* `<subdir>/.moda/harness-report.json`, point at the fresher one. Skipped
|
|
2216
|
+
* when the user explicitly named a report file (they already chose).
|
|
2217
|
+
*/
|
|
2218
|
+
function warnFresherSubdirHarnessReports(context, rootDir, explicitReport) {
|
|
2219
|
+
// Only treat an explicit report as an opt-out when it points somewhere other
|
|
2220
|
+
// than the default root report. `--from-report=.moda/harness-report.json`
|
|
2221
|
+
// still targets the default report, so the fresher-subdir warning must fire.
|
|
2222
|
+
const explicitReportPath = explicitReport && explicitReport !== 'true'
|
|
2223
|
+
? normalizePath(relative(rootDir, resolve(rootDir, explicitReport)))
|
|
2224
|
+
: undefined;
|
|
2225
|
+
if (explicitReportPath && explicitReportPath !== normalizePath(HARNESS_REPORT_PATH))
|
|
2226
|
+
return;
|
|
2227
|
+
let fresher;
|
|
2228
|
+
try {
|
|
2229
|
+
fresher = findFresherHarnessReports(rootDir);
|
|
2230
|
+
}
|
|
2231
|
+
catch {
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (fresher.length === 0)
|
|
2235
|
+
return;
|
|
2236
|
+
const newest = fresher[0];
|
|
2237
|
+
const subdir = normalizePath(dirname(dirname(newest.path)));
|
|
2238
|
+
const rootState = existsSync(join(rootDir, HARNESS_REPORT_PATH))
|
|
2239
|
+
? `older ${HARNESS_REPORT_PATH}`
|
|
2240
|
+
: `no ${HARNESS_REPORT_PATH}`;
|
|
2241
|
+
writeHarnessNotice(context, `A fresher harness report exists at ${newest.path} (this directory has ${rootState}). ` +
|
|
2242
|
+
`If you meant that report, re-run with --path=${subdir}.`);
|
|
2052
2243
|
}
|
|
2053
2244
|
function readHarnessGraph(manifestPath) {
|
|
2054
2245
|
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
package/dist/init/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface InitPhaseResult {
|
|
|
11
11
|
details?: string;
|
|
12
12
|
}
|
|
13
13
|
export interface InitSetupAction {
|
|
14
|
-
category: 'sdk' | 'harness' | 'hooks' | 'prompts' | 'skills' | 'ci-cd' | 'autosync' | 'validation';
|
|
14
|
+
category: 'sdk' | 'harness' | 'hooks' | 'prompts' | 'skills' | 'ci-cd' | 'autosync' | 'harness-rescan' | 'validation';
|
|
15
15
|
title: string;
|
|
16
16
|
status: 'planned' | 'applied' | 'skipped' | 'manual';
|
|
17
17
|
approved: boolean;
|
|
@@ -28,6 +28,44 @@ export interface InitRunOptions {
|
|
|
28
28
|
onEvent?: (event: InitProgressEvent) => void;
|
|
29
29
|
}
|
|
30
30
|
export declare function runInit(flags: Record<string, unknown>, options?: InitRunOptions): Promise<InitResult>;
|
|
31
|
+
export declare function writeCiCdWorkflow(cwd?: string): string;
|
|
32
|
+
export declare function writeAutosyncWorkflow(cwd?: string): string;
|
|
33
|
+
export declare function writeHarnessRescanWorkflow(cwd?: string, paths?: string[]): string;
|
|
34
|
+
/**
|
|
35
|
+
* Signals from the discovered graph used by the init 0-agent quality gate:
|
|
36
|
+
* runtime agent count plus the LLM framework/provider names detected in the
|
|
37
|
+
* repo.
|
|
38
|
+
*/
|
|
39
|
+
export interface HarnessQualitySignals {
|
|
40
|
+
runtimeAgents: number;
|
|
41
|
+
frameworks: string[];
|
|
42
|
+
providers: string[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the gate signals from the SAME graph the auto-sync uploads: the cited
|
|
46
|
+
* report (`.moda/harness-report.json`, consumed by `harness sync --from-report`)
|
|
47
|
+
* is authoritative, so read its `graph` first. Only fall back to the standalone
|
|
48
|
+
* manifest (`.moda/harness.json`) when no report exists, otherwise a stale or
|
|
49
|
+
* missing manifest could let an empty report slip past the gate and overwrite
|
|
50
|
+
* the tenant's harness. Returns undefined when neither is readable.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readHarnessQualitySignals(cwd?: string): HarnessQualitySignals | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* True when discovery found no runtime agents even though the repo carries
|
|
55
|
+
* LLM framework/provider signals — the signature of a failed discovery run,
|
|
56
|
+
* not an agent-free repo.
|
|
57
|
+
*/
|
|
58
|
+
export declare function harnessLooksIncomplete(signals: HarnessQualitySignals): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Deterministic repo-side LLM signals: well-known LLM package names found in
|
|
61
|
+
* dependency manifests. Independent of the discovery output, so a fully
|
|
62
|
+
* failed discovery (empty graph) cannot bypass the 0-agent gate.
|
|
63
|
+
*/
|
|
64
|
+
export declare function detectRepoLlmSignals(cwd?: string): {
|
|
65
|
+
frameworks: string[];
|
|
66
|
+
providers: string[];
|
|
67
|
+
};
|
|
68
|
+
export declare function resumeFlagsForInit(flags: Record<string, unknown>): string[];
|
|
31
69
|
/**
|
|
32
70
|
* Onboarding runs harness discovery SERVER-SIDE by default so the customer
|
|
33
71
|
* needs no local coding agent. Opt out with `--local`, `--no-remote`,
|
package/dist/init/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// harness analysis first and overlapping it with the fast local tasks and the
|
|
6
6
|
// SDK integration agent while a live progress board (progress-board.ts)
|
|
7
7
|
// renders each lane.
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { checkbox, confirm, select } from '@inquirer/prompts';
|
|
11
11
|
import { login, loadToken } from '../auth.js';
|
|
@@ -20,7 +20,7 @@ import { InitProgressBoard } from './progress-board.js';
|
|
|
20
20
|
import { createCommandContext } from '../command-context.js';
|
|
21
21
|
import { resolveModaBaseUrl, VERSION } from '../config.js';
|
|
22
22
|
import { CliAuthError, CliInputError } from '../errors.js';
|
|
23
|
-
import { detectAvailableCodingAgents, isDeterministicLocalAnalyst, runHarnessCommand, } from '../harness.js';
|
|
23
|
+
import { describeSyncIdentity, describeSyncTenantLabel, detectAvailableCodingAgents, isDeterministicLocalAnalyst, runHarnessCommand, } from '../harness.js';
|
|
24
24
|
import { pollRemoteAnalyzeUntilDone, startRemoteAnalyze, } from '../harness-remote.js';
|
|
25
25
|
import { buildDoctorReport } from '../doctor.js';
|
|
26
26
|
import { renderInitBanner, renderInitComplete, renderInitNote, renderInitSetupPlan, renderInitStep, } from '../tui.js';
|
|
@@ -113,7 +113,7 @@ export async function runInit(flags, options = {}) {
|
|
|
113
113
|
// selection; otherwise the exact-id match below fails with
|
|
114
114
|
// "No organization with id ''" instead of auto-selecting a sole org.
|
|
115
115
|
const tenantIdEnv = stringFlag(process.env.MODA_TENANT_ID);
|
|
116
|
-
const { apiKey, tenantName } = await selectTenantAndCreateKey({
|
|
116
|
+
const { apiKey, tenantId: selectedTenantId, tenantName } = await selectTenantAndCreateKey({
|
|
117
117
|
tenantId: tenantIdFlag ?? tenantIdEnv,
|
|
118
118
|
tenantIdSource: tenantIdFlag ? 'flag' : tenantIdEnv ? 'env' : undefined,
|
|
119
119
|
nonInteractive: json || agent,
|
|
@@ -134,6 +134,11 @@ export async function runInit(flags, options = {}) {
|
|
|
134
134
|
const sdkFlag = resolveSdkIntegrateFlag(flags);
|
|
135
135
|
const ciCdFlag = resolveCiCdFlag(flags);
|
|
136
136
|
const autosyncFlag = resolveAutosyncFlag(flags);
|
|
137
|
+
const harnessRescanFlag = resolveHarnessRescanFlag(flags);
|
|
138
|
+
const harnessRescanPaths = parseHarnessRescanPaths(flagValue(flags, 'harness-rescan-paths', 'harness_rescan_paths'));
|
|
139
|
+
// Passing --harness-rescan-paths alone is an explicit opt-in: scoping the
|
|
140
|
+
// rescan only makes sense when the workflow is being written.
|
|
141
|
+
const harnessRescanChoice = harnessRescanFlag ?? (harnessRescanPaths !== undefined ? true : undefined);
|
|
137
142
|
// SDK integration installs packages and edits source via a coding agent, so
|
|
138
143
|
// it defaults ON only when an agent CLI is installed to do the work; it
|
|
139
144
|
// never runs unattended without an explicit --sdk-integrate.
|
|
@@ -141,6 +146,7 @@ export async function runInit(flags, options = {}) {
|
|
|
141
146
|
let integrateSdk = sdkFlag !== undefined ? sdkFlag : yes ? false : codingAgentAvailable;
|
|
142
147
|
let configureCiCd = ciCdFlag ?? false;
|
|
143
148
|
let configureAutosync = autosyncFlag ?? false;
|
|
149
|
+
let configureHarnessRescan = harnessRescanChoice ?? false;
|
|
144
150
|
let setupPlan = [];
|
|
145
151
|
const renderPlan = () => {
|
|
146
152
|
setupPlan = buildSetupPlan({
|
|
@@ -148,6 +154,7 @@ export async function runInit(flags, options = {}) {
|
|
|
148
154
|
integrateSdk,
|
|
149
155
|
configureCiCd,
|
|
150
156
|
configureAutosync,
|
|
157
|
+
configureHarnessRescan,
|
|
151
158
|
remote,
|
|
152
159
|
});
|
|
153
160
|
log(renderInitSetupPlan(setupPlan, { snapshotConsent: remote }));
|
|
@@ -185,6 +192,13 @@ export async function runInit(flags, options = {}) {
|
|
|
185
192
|
checked: configureAutosync,
|
|
186
193
|
});
|
|
187
194
|
}
|
|
195
|
+
if (harnessRescanChoice === undefined) {
|
|
196
|
+
choices.push({
|
|
197
|
+
name: 'Re-scan the harness on push to main (.github/workflows/moda-harness-rescan.yml)',
|
|
198
|
+
value: 'harness-rescan',
|
|
199
|
+
checked: configureHarnessRescan,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
188
202
|
if (choices.length > 0) {
|
|
189
203
|
const selected = await checkbox({
|
|
190
204
|
message: 'Choose what runs (space toggles, enter confirms)',
|
|
@@ -198,13 +212,16 @@ export async function runInit(flags, options = {}) {
|
|
|
198
212
|
configureCiCd = selected.includes('ci-cd');
|
|
199
213
|
if (autosyncFlag === undefined)
|
|
200
214
|
configureAutosync = selected.includes('autosync');
|
|
215
|
+
if (harnessRescanChoice === undefined)
|
|
216
|
+
configureHarnessRescan = selected.includes('harness-rescan');
|
|
201
217
|
renderPlan();
|
|
202
218
|
}
|
|
203
219
|
}
|
|
204
220
|
}
|
|
205
221
|
if (json || agent) {
|
|
206
222
|
const skipped = setupPlan
|
|
207
|
-
.filter((action) => (action.category === 'sdk' || action.category === 'ci-cd' || action.category === 'autosync'
|
|
223
|
+
.filter((action) => (action.category === 'sdk' || action.category === 'ci-cd' || action.category === 'autosync' ||
|
|
224
|
+
action.category === 'harness-rescan') &&
|
|
208
225
|
action.approved === false)
|
|
209
226
|
.map((action) => `${action.category}: ${action.command ?? 'explicit flag required'}`);
|
|
210
227
|
if (skipped.length > 0) {
|
|
@@ -267,6 +284,61 @@ export async function runInit(flags, options = {}) {
|
|
|
267
284
|
// unapproved report, so approve it first. Best-effort: a network blip on
|
|
268
285
|
// either call must not abort onboarding.
|
|
269
286
|
const approveAndSyncHarness = async () => {
|
|
287
|
+
// Quality gate: a result with ZERO runtime agents on a repo that clearly
|
|
288
|
+
// uses LLM frameworks/providers almost always means discovery failed (bad
|
|
289
|
+
// snapshot, analyst error), not an agent-free repo. Auto-approving and
|
|
290
|
+
// syncing it would overwrite the tenant's harness with an empty graph, so
|
|
291
|
+
// withhold the upload and hand the user the retry commands instead.
|
|
292
|
+
// The framework/provider evidence is the union of what the discovered
|
|
293
|
+
// graph self-reports AND a deterministic repo-side dependency scan — a
|
|
294
|
+
// TOTAL discovery failure (0 agents, 0 frameworks, 0 providers) must not
|
|
295
|
+
// bypass the gate just because the artifact being judged is empty.
|
|
296
|
+
const graphSignals = readHarnessQualitySignals(rootDir);
|
|
297
|
+
const repoSignals = detectRepoLlmSignals(rootDir);
|
|
298
|
+
const signals = graphSignals
|
|
299
|
+
? {
|
|
300
|
+
runtimeAgents: graphSignals.runtimeAgents,
|
|
301
|
+
frameworks: [...new Set([...graphSignals.frameworks, ...repoSignals.frameworks])],
|
|
302
|
+
providers: [...new Set([...graphSignals.providers, ...repoSignals.providers])],
|
|
303
|
+
}
|
|
304
|
+
: undefined;
|
|
305
|
+
if (signals && harnessLooksIncomplete(signals)) {
|
|
306
|
+
const names = [...signals.frameworks, ...signals.providers].join(', ');
|
|
307
|
+
const retryCommand = remote
|
|
308
|
+
? 'moda harness analyze --remote --summary'
|
|
309
|
+
: 'moda harness analyze --analyst=claude|cursor|codex --summary';
|
|
310
|
+
warn(renderInitNote(`Harness discovery found 0 runtime agents, but this repo has LLM framework signals (${names}). ` +
|
|
311
|
+
'Discovery likely failed, so the result was NOT approved or synced.', 'warning'));
|
|
312
|
+
warn(` Retry with \`${retryCommand}\`, then \`moda harness approve --yes && moda harness sync --from-report\`.`);
|
|
313
|
+
emit('harness', 'Harness discovery found 0 agents despite LLM framework signals; sync withheld.', 'warning');
|
|
314
|
+
harnessPhase = {
|
|
315
|
+
id: 'harness',
|
|
316
|
+
title: 'Harness discovery',
|
|
317
|
+
status: 'warning',
|
|
318
|
+
details: '0 agents detected; sync withheld',
|
|
319
|
+
};
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Identity banner: the credential is resolved with the same precedence
|
|
323
|
+
// the upload uses (MODA_API_KEY env wins) and the tenant comes from the
|
|
324
|
+
// WINNING KEY itself (or is marked unverified), so a shadowed/stale key
|
|
325
|
+
// is visible — and never mislabeled — BEFORE the graph lands in the
|
|
326
|
+
// wrong tenant. Best-effort: must not abort onboarding.
|
|
327
|
+
try {
|
|
328
|
+
const identity = describeSyncIdentity({ profile, env: process.env });
|
|
329
|
+
if (identity) {
|
|
330
|
+
// The friendly workspace name only decorates the id when the winning
|
|
331
|
+
// key's embedded tenant IS the tenant init just selected — otherwise
|
|
332
|
+
// the raw (possibly surprising) destination id stands alone.
|
|
333
|
+
const tenantLabel = identity.tenantSource === 'api-key' && identity.tenantId
|
|
334
|
+
? `${identity.tenantId}${identity.tenantId === selectedTenantId ? ` (${tenantName})` : ''}`
|
|
335
|
+
: describeSyncTenantLabel(identity);
|
|
336
|
+
log(renderInitNote(`Syncing as ${identity.credential} → tenant ${tenantLabel}`, 'info'));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Advisory banner only.
|
|
341
|
+
}
|
|
270
342
|
try {
|
|
271
343
|
await runHarnessCommand(createCommandContext({
|
|
272
344
|
command: 'harness',
|
|
@@ -437,6 +509,12 @@ export async function runInit(flags, options = {}) {
|
|
|
437
509
|
laneBoard?.complete('autosync', `wrote ${workflowPath}`);
|
|
438
510
|
markApplied(setupPlan, 'autosync');
|
|
439
511
|
}
|
|
512
|
+
if (setupPlan.find((action) => action.category === 'harness-rescan')?.approved === true) {
|
|
513
|
+
const workflowPath = writeHarnessRescanWorkflow(process.cwd(), harnessRescanPaths);
|
|
514
|
+
laneBoard?.startLane('harness-rescan', 'Harness rescan workflow');
|
|
515
|
+
laneBoard?.complete('harness-rescan', `wrote ${workflowPath}`);
|
|
516
|
+
markApplied(setupPlan, 'harness-rescan');
|
|
517
|
+
}
|
|
440
518
|
const sdkAction = setupPlan.find((action) => action.category === 'sdk');
|
|
441
519
|
if (sdkAction?.approved === true) {
|
|
442
520
|
laneBoard?.startLane('sdk', 'SDK integration', {
|
|
@@ -667,6 +745,17 @@ function buildSetupPlan(options) {
|
|
|
667
745
|
command: 'moda prompts sync && moda skills sync',
|
|
668
746
|
reason: options.configureAutosync ? undefined : 'No autosync workflow is written until it is explicitly approved (--autosync).',
|
|
669
747
|
},
|
|
748
|
+
{
|
|
749
|
+
category: 'harness-rescan',
|
|
750
|
+
title: 'Re-scan the harness on push to main so the synced graph never drifts from the code',
|
|
751
|
+
status: options.configureHarnessRescan ? 'planned' : 'manual',
|
|
752
|
+
approved: options.configureHarnessRescan,
|
|
753
|
+
files: ['.github/workflows/moda-harness-rescan.yml'],
|
|
754
|
+
command: 'moda harness analyze --remote --yes && moda harness approve --yes && moda harness sync --from-report',
|
|
755
|
+
reason: options.configureHarnessRescan
|
|
756
|
+
? undefined
|
|
757
|
+
: 'No harness rescan workflow is written until it is explicitly approved (--harness-rescan).',
|
|
758
|
+
},
|
|
670
759
|
{
|
|
671
760
|
category: 'validation',
|
|
672
761
|
title: 'Run Moda doctor online to confirm telemetry is reaching Moda',
|
|
@@ -701,7 +790,14 @@ function changedFiles(setupPlan) {
|
|
|
701
790
|
// the Moda registry. The pin tracks the CLI that generated the file, so each
|
|
702
791
|
// `moda init` upgrade is an explicit, reviewable bump of the workflow.
|
|
703
792
|
const GENERATED_WORKFLOW_CLI = `@moda-ai/cli@${VERSION}`;
|
|
704
|
-
|
|
793
|
+
// npx dispatch shape matters: `npx -y @moda-ai/cli@X moda harness ...` resolves
|
|
794
|
+
// the package's default bin and passes the literal `moda` token through as the
|
|
795
|
+
// CLI's first argument, so every step would exit 1 with "Unknown command
|
|
796
|
+
// 'moda'". `--package=<pkg> moda` pins the package AND names the `moda` bin
|
|
797
|
+
// explicitly, so the CLI receives the real subcommand first.
|
|
798
|
+
const GENERATED_WORKFLOW_NPX = `npx -y --package=${GENERATED_WORKFLOW_CLI} moda`;
|
|
799
|
+
// Exported for tests (generated-workflows.test.ts validates npx dispatch shape).
|
|
800
|
+
export function writeCiCdWorkflow(cwd = process.cwd()) {
|
|
705
801
|
const relativePath = '.github/workflows/moda-validation.yml';
|
|
706
802
|
const path = join(cwd, relativePath);
|
|
707
803
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -730,7 +826,7 @@ function writeCiCdWorkflow(cwd = process.cwd()) {
|
|
|
730
826
|
' with:',
|
|
731
827
|
' node-version: 22',
|
|
732
828
|
' - name: Validate Moda setup',
|
|
733
|
-
` run:
|
|
829
|
+
` run: ${GENERATED_WORKFLOW_NPX} doctor --online --json`,
|
|
734
830
|
'',
|
|
735
831
|
].join('\n')}`);
|
|
736
832
|
return relativePath;
|
|
@@ -739,7 +835,7 @@ function writeCiCdWorkflow(cwd = process.cwd()) {
|
|
|
739
835
|
// sync with the repo: it pushes prompt and skill changes after they merge. It
|
|
740
836
|
// only runs on `main` (never on pull_request) so unmerged versions are never
|
|
741
837
|
// published. Keep its env in step with writeCiCdWorkflow().
|
|
742
|
-
function writeAutosyncWorkflow(cwd = process.cwd()) {
|
|
838
|
+
export function writeAutosyncWorkflow(cwd = process.cwd()) {
|
|
743
839
|
const relativePath = '.github/workflows/moda-autosync.yml';
|
|
744
840
|
const path = join(cwd, relativePath);
|
|
745
841
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -772,9 +868,72 @@ function writeAutosyncWorkflow(cwd = process.cwd()) {
|
|
|
772
868
|
' with:',
|
|
773
869
|
' node-version: 22',
|
|
774
870
|
` - name: Sync prompts to Moda`,
|
|
775
|
-
` run:
|
|
871
|
+
` run: ${GENERATED_WORKFLOW_NPX} prompts sync`,
|
|
776
872
|
` - name: Sync skills to Moda`,
|
|
777
|
-
` run:
|
|
873
|
+
` run: ${GENERATED_WORKFLOW_NPX} skills sync`,
|
|
874
|
+
'',
|
|
875
|
+
].join('\n')}`);
|
|
876
|
+
return relativePath;
|
|
877
|
+
}
|
|
878
|
+
// Generates the "living harness" rescan workflow: after every merge to main
|
|
879
|
+
// the hosted analyst re-analyzes the repo, the refreshed report is approved
|
|
880
|
+
// non-interactively, and the cited graph is synced to Moda — so the harness
|
|
881
|
+
// Moda serves never drifts from the code. It runs only on push to `main`
|
|
882
|
+
// (plus manual workflow_dispatch), never on pull_request, so unmerged agent
|
|
883
|
+
// changes are never synced. An optional path scope limits pushes that trigger
|
|
884
|
+
// a rescan. Keep its env in step with writeCiCdWorkflow().
|
|
885
|
+
export function writeHarnessRescanWorkflow(cwd = process.cwd(), paths) {
|
|
886
|
+
const relativePath = '.github/workflows/moda-harness-rescan.yml';
|
|
887
|
+
const path = join(cwd, relativePath);
|
|
888
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
889
|
+
const pathScope = paths !== undefined && paths.length > 0
|
|
890
|
+
? [
|
|
891
|
+
' # Only pushes touching these paths trigger a rescan.',
|
|
892
|
+
' paths:',
|
|
893
|
+
...paths.map((glob) => ` - '${glob.replace(/'/g, "''")}'`),
|
|
894
|
+
]
|
|
895
|
+
: [
|
|
896
|
+
' # Optional: scope rescans to agent-relevant paths, e.g.',
|
|
897
|
+
' # paths:',
|
|
898
|
+
" # - 'src/agents/**'",
|
|
899
|
+
];
|
|
900
|
+
writeFileSync(path, `${[
|
|
901
|
+
'name: Moda Harness Rescan',
|
|
902
|
+
'',
|
|
903
|
+
'on:',
|
|
904
|
+
' push:',
|
|
905
|
+
' branches: [main]',
|
|
906
|
+
...pathScope,
|
|
907
|
+
' workflow_dispatch:',
|
|
908
|
+
'',
|
|
909
|
+
'# Least privilege: this job reads the checked-out repo, uploads a safe',
|
|
910
|
+
'# source snapshot to Moda over the API key, and syncs the refreshed harness',
|
|
911
|
+
'# graph; it never writes back to the repository.',
|
|
912
|
+
'permissions:',
|
|
913
|
+
' contents: read',
|
|
914
|
+
'',
|
|
915
|
+
'# One in-flight rescan at a time; newer pushes supersede older runs.',
|
|
916
|
+
'concurrency:',
|
|
917
|
+
' group: moda-harness-rescan',
|
|
918
|
+
' cancel-in-progress: true',
|
|
919
|
+
'',
|
|
920
|
+
'jobs:',
|
|
921
|
+
' moda-harness-rescan:',
|
|
922
|
+
' runs-on: ubuntu-latest',
|
|
923
|
+
' env:',
|
|
924
|
+
' MODA_API_KEY: ${{ secrets.MODA_API_KEY }}',
|
|
925
|
+
' MODA_TENANT_ID: ${{ secrets.MODA_TENANT_ID }}',
|
|
926
|
+
' steps:',
|
|
927
|
+
' - uses: actions/checkout@v4',
|
|
928
|
+
' - uses: actions/setup-node@v4',
|
|
929
|
+
' with:',
|
|
930
|
+
' node-version: 22',
|
|
931
|
+
' - name: Re-analyze the harness on Moda',
|
|
932
|
+
` run: ${GENERATED_WORKFLOW_NPX} harness analyze --remote --yes`,
|
|
933
|
+
' - name: Approve the refreshed harness report',
|
|
934
|
+
` run: ${GENERATED_WORKFLOW_NPX} harness approve --yes`,
|
|
935
|
+
' - name: Sync the harness graph to Moda',
|
|
936
|
+
` run: ${GENERATED_WORKFLOW_NPX} harness sync --from-report`,
|
|
778
937
|
'',
|
|
779
938
|
].join('\n')}`);
|
|
780
939
|
return relativePath;
|
|
@@ -802,6 +961,225 @@ function printHarnessDetectionSummary(cwd = process.cwd()) {
|
|
|
802
961
|
console.log(' Detected harness summary is available in .moda/HARNESS.md');
|
|
803
962
|
}
|
|
804
963
|
}
|
|
964
|
+
function signalsFromGraph(graph) {
|
|
965
|
+
const names = (items) => (items ?? [])
|
|
966
|
+
.map((item) => (typeof item.name === 'string' ? item.name : undefined))
|
|
967
|
+
.filter((name) => Boolean(name));
|
|
968
|
+
return {
|
|
969
|
+
runtimeAgents: Array.isArray(graph.runtimeAgents) ? graph.runtimeAgents.length : 0,
|
|
970
|
+
frameworks: names(graph.agentFrameworks),
|
|
971
|
+
providers: names(graph.modelProviders),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Read the gate signals from the SAME graph the auto-sync uploads: the cited
|
|
976
|
+
* report (`.moda/harness-report.json`, consumed by `harness sync --from-report`)
|
|
977
|
+
* is authoritative, so read its `graph` first. Only fall back to the standalone
|
|
978
|
+
* manifest (`.moda/harness.json`) when no report exists, otherwise a stale or
|
|
979
|
+
* missing manifest could let an empty report slip past the gate and overwrite
|
|
980
|
+
* the tenant's harness. Returns undefined when neither is readable.
|
|
981
|
+
*/
|
|
982
|
+
export function readHarnessQualitySignals(cwd = process.cwd()) {
|
|
983
|
+
const reportPath = join(cwd, '.moda', 'harness-report.json');
|
|
984
|
+
if (existsSync(reportPath)) {
|
|
985
|
+
try {
|
|
986
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
987
|
+
if (report.graph && typeof report.graph === 'object') {
|
|
988
|
+
return signalsFromGraph(report.graph);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// Fall through to the manifest below.
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const manifestPath = join(cwd, '.moda', 'harness.json');
|
|
996
|
+
if (!existsSync(manifestPath))
|
|
997
|
+
return undefined;
|
|
998
|
+
try {
|
|
999
|
+
return signalsFromGraph(JSON.parse(readFileSync(manifestPath, 'utf8')));
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
return undefined;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* True when discovery found no runtime agents even though the repo carries
|
|
1007
|
+
* LLM framework/provider signals — the signature of a failed discovery run,
|
|
1008
|
+
* not an agent-free repo.
|
|
1009
|
+
*/
|
|
1010
|
+
export function harnessLooksIncomplete(signals) {
|
|
1011
|
+
return signals.runtimeAgents === 0 &&
|
|
1012
|
+
(signals.frameworks.length > 0 || signals.providers.length > 0);
|
|
1013
|
+
}
|
|
1014
|
+
// --- Repo-side LLM dependency scan (0-agent gate evidence) -----------------
|
|
1015
|
+
//
|
|
1016
|
+
// The gate must not read its "repo has LLM signals" evidence solely from the
|
|
1017
|
+
// artifact it is gating: a TOTAL discovery failure emits a graph with 0
|
|
1018
|
+
// agents AND 0 frameworks/providers, which would sail past a graph-only
|
|
1019
|
+
// check and overwrite the tenant's harness with an empty graph. This scan is
|
|
1020
|
+
// deterministic and independent of the analyst: it looks for well-known LLM
|
|
1021
|
+
// packages in dependency manifests (package.json, requirements.txt,
|
|
1022
|
+
// pyproject.toml) via a bounded walk.
|
|
1023
|
+
const REPO_SIGNAL_MAX_DEPTH = 3;
|
|
1024
|
+
const REPO_SIGNAL_MAX_DIRS = 500;
|
|
1025
|
+
const REPO_SIGNAL_SKIPPED_DIRS = new Set([
|
|
1026
|
+
'node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', 'target',
|
|
1027
|
+
'venv', 'env', '__pycache__', 'site-packages',
|
|
1028
|
+
]);
|
|
1029
|
+
/** Exact npm package name → signal. */
|
|
1030
|
+
const JS_LLM_DEPENDENCIES = {
|
|
1031
|
+
openai: { name: 'OpenAI', kind: 'provider' },
|
|
1032
|
+
'@anthropic-ai/sdk': { name: 'Anthropic', kind: 'provider' },
|
|
1033
|
+
'@google/generative-ai': { name: 'Google Gemini', kind: 'provider' },
|
|
1034
|
+
'@google/genai': { name: 'Google Gemini', kind: 'provider' },
|
|
1035
|
+
'cohere-ai': { name: 'Cohere', kind: 'provider' },
|
|
1036
|
+
'groq-sdk': { name: 'Groq', kind: 'provider' },
|
|
1037
|
+
'@mistralai/mistralai': { name: 'Mistral', kind: 'provider' },
|
|
1038
|
+
ollama: { name: 'Ollama', kind: 'provider' },
|
|
1039
|
+
'together-ai': { name: 'Together', kind: 'provider' },
|
|
1040
|
+
'@aws-sdk/client-bedrock-runtime': { name: 'AWS Bedrock', kind: 'provider' },
|
|
1041
|
+
ai: { name: 'Vercel AI SDK', kind: 'framework' },
|
|
1042
|
+
langchain: { name: 'LangChain', kind: 'framework' },
|
|
1043
|
+
llamaindex: { name: 'LlamaIndex', kind: 'framework' },
|
|
1044
|
+
'@anthropic-ai/claude-agent-sdk': { name: 'Claude Agent SDK', kind: 'framework' },
|
|
1045
|
+
'@openai/agents': { name: 'OpenAI Agents', kind: 'framework' },
|
|
1046
|
+
'@mastra/core': { name: 'Mastra', kind: 'framework' },
|
|
1047
|
+
};
|
|
1048
|
+
/** npm scope/prefix → signal (monorepo-style split packages). */
|
|
1049
|
+
const JS_LLM_DEPENDENCY_PREFIXES = [
|
|
1050
|
+
['@langchain/', { name: 'LangChain', kind: 'framework' }],
|
|
1051
|
+
['@ai-sdk/', { name: 'Vercel AI SDK', kind: 'framework' }],
|
|
1052
|
+
['@llamaindex/', { name: 'LlamaIndex', kind: 'framework' }],
|
|
1053
|
+
];
|
|
1054
|
+
/** Normalized (lowercase, `_`→`-`) Python distribution name → signal. */
|
|
1055
|
+
const PY_LLM_DEPENDENCIES = {
|
|
1056
|
+
openai: { name: 'OpenAI', kind: 'provider' },
|
|
1057
|
+
anthropic: { name: 'Anthropic', kind: 'provider' },
|
|
1058
|
+
'google-generativeai': { name: 'Google Gemini', kind: 'provider' },
|
|
1059
|
+
'google-genai': { name: 'Google Gemini', kind: 'provider' },
|
|
1060
|
+
cohere: { name: 'Cohere', kind: 'provider' },
|
|
1061
|
+
groq: { name: 'Groq', kind: 'provider' },
|
|
1062
|
+
mistralai: { name: 'Mistral', kind: 'provider' },
|
|
1063
|
+
ollama: { name: 'Ollama', kind: 'provider' },
|
|
1064
|
+
langgraph: { name: 'LangGraph', kind: 'framework' },
|
|
1065
|
+
litellm: { name: 'LiteLLM', kind: 'framework' },
|
|
1066
|
+
crewai: { name: 'CrewAI', kind: 'framework' },
|
|
1067
|
+
autogen: { name: 'AutoGen', kind: 'framework' },
|
|
1068
|
+
pyautogen: { name: 'AutoGen', kind: 'framework' },
|
|
1069
|
+
ag2: { name: 'AutoGen', kind: 'framework' },
|
|
1070
|
+
'pydantic-ai': { name: 'Pydantic AI', kind: 'framework' },
|
|
1071
|
+
'openai-agents': { name: 'OpenAI Agents', kind: 'framework' },
|
|
1072
|
+
'semantic-kernel': { name: 'Semantic Kernel', kind: 'framework' },
|
|
1073
|
+
dspy: { name: 'DSPy', kind: 'framework' },
|
|
1074
|
+
'haystack-ai': { name: 'Haystack', kind: 'framework' },
|
|
1075
|
+
};
|
|
1076
|
+
/** Python prefix matches (langchain, langchain-openai, llama-index-core, ...). */
|
|
1077
|
+
const PY_LLM_DEPENDENCY_PREFIXES = [
|
|
1078
|
+
['langchain', { name: 'LangChain', kind: 'framework' }],
|
|
1079
|
+
['llama-index', { name: 'LlamaIndex', kind: 'framework' }],
|
|
1080
|
+
];
|
|
1081
|
+
function jsDependencySignal(dep) {
|
|
1082
|
+
const exact = JS_LLM_DEPENDENCIES[dep];
|
|
1083
|
+
if (exact)
|
|
1084
|
+
return exact;
|
|
1085
|
+
for (const [prefix, signal] of JS_LLM_DEPENDENCY_PREFIXES) {
|
|
1086
|
+
if (dep.startsWith(prefix))
|
|
1087
|
+
return signal;
|
|
1088
|
+
}
|
|
1089
|
+
return undefined;
|
|
1090
|
+
}
|
|
1091
|
+
function pyDependencySignal(dep) {
|
|
1092
|
+
const normalized = dep.toLowerCase().replace(/_/g, '-');
|
|
1093
|
+
const exact = PY_LLM_DEPENDENCIES[normalized];
|
|
1094
|
+
if (exact)
|
|
1095
|
+
return exact;
|
|
1096
|
+
for (const [prefix, signal] of PY_LLM_DEPENDENCY_PREFIXES) {
|
|
1097
|
+
if (normalized === prefix || normalized.startsWith(`${prefix}-`))
|
|
1098
|
+
return signal;
|
|
1099
|
+
}
|
|
1100
|
+
return undefined;
|
|
1101
|
+
}
|
|
1102
|
+
/** Requirement names from requirements.txt lines / pyproject.toml specifiers. */
|
|
1103
|
+
function pythonRequirementNames(text, file) {
|
|
1104
|
+
const names = [];
|
|
1105
|
+
if (file === 'requirements.txt') {
|
|
1106
|
+
for (const line of text.split('\n')) {
|
|
1107
|
+
const match = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(line);
|
|
1108
|
+
if (match)
|
|
1109
|
+
names.push(match[1]);
|
|
1110
|
+
}
|
|
1111
|
+
return names;
|
|
1112
|
+
}
|
|
1113
|
+
// pyproject.toml: dependency specifiers are quoted strings whose leading
|
|
1114
|
+
// token is the distribution name ("openai>=1.0", "langchain-openai", ...).
|
|
1115
|
+
for (const match of text.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)[^"']*["']/g)) {
|
|
1116
|
+
names.push(match[1]);
|
|
1117
|
+
}
|
|
1118
|
+
return names;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Deterministic repo-side LLM signals: well-known LLM package names found in
|
|
1122
|
+
* dependency manifests. Independent of the discovery output, so a fully
|
|
1123
|
+
* failed discovery (empty graph) cannot bypass the 0-agent gate.
|
|
1124
|
+
*/
|
|
1125
|
+
export function detectRepoLlmSignals(cwd = process.cwd()) {
|
|
1126
|
+
const frameworks = new Set();
|
|
1127
|
+
const providers = new Set();
|
|
1128
|
+
const record = (signal) => {
|
|
1129
|
+
if (!signal)
|
|
1130
|
+
return;
|
|
1131
|
+
(signal.kind === 'framework' ? frameworks : providers).add(signal.name);
|
|
1132
|
+
};
|
|
1133
|
+
const scanDir = (dir) => {
|
|
1134
|
+
try {
|
|
1135
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
1136
|
+
for (const dep of [
|
|
1137
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
1138
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
1139
|
+
...Object.keys(pkg.peerDependencies ?? {}),
|
|
1140
|
+
]) {
|
|
1141
|
+
record(jsDependencySignal(dep));
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
// No/invalid package.json here.
|
|
1146
|
+
}
|
|
1147
|
+
for (const file of ['requirements.txt', 'pyproject.toml']) {
|
|
1148
|
+
try {
|
|
1149
|
+
const text = readFileSync(join(dir, file), 'utf8');
|
|
1150
|
+
for (const name of pythonRequirementNames(text, file)) {
|
|
1151
|
+
record(pyDependencySignal(name));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
// No such manifest here.
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
let visited = 0;
|
|
1160
|
+
const walk = (dir, depth) => {
|
|
1161
|
+
if (depth > REPO_SIGNAL_MAX_DEPTH || visited >= REPO_SIGNAL_MAX_DIRS)
|
|
1162
|
+
return;
|
|
1163
|
+
visited += 1;
|
|
1164
|
+
scanDir(dir);
|
|
1165
|
+
let entries;
|
|
1166
|
+
try {
|
|
1167
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
for (const entry of entries) {
|
|
1173
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
1174
|
+
continue;
|
|
1175
|
+
if (REPO_SIGNAL_SKIPPED_DIRS.has(entry.name))
|
|
1176
|
+
continue;
|
|
1177
|
+
walk(join(dir, entry.name), depth + 1);
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
walk(cwd, 0);
|
|
1181
|
+
return { frameworks: [...frameworks].sort(), providers: [...providers].sort() };
|
|
1182
|
+
}
|
|
805
1183
|
/** Agent/artifact counts for the completion screen's harness payoff line. */
|
|
806
1184
|
function readHarnessGraphCounts(cwd = process.cwd()) {
|
|
807
1185
|
const manifestPath = join(cwd, '.moda', 'harness.json');
|
|
@@ -826,7 +1204,7 @@ function commandFlags(flags) {
|
|
|
826
1204
|
function stringFlag(value) {
|
|
827
1205
|
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
828
1206
|
}
|
|
829
|
-
function resumeFlagsForInit(flags) {
|
|
1207
|
+
export function resumeFlagsForInit(flags) {
|
|
830
1208
|
const passthrough = [
|
|
831
1209
|
'profile',
|
|
832
1210
|
'tenant-id',
|
|
@@ -835,9 +1213,12 @@ function resumeFlagsForInit(flags) {
|
|
|
835
1213
|
'sdk-integrate',
|
|
836
1214
|
'ci-cd',
|
|
837
1215
|
'autosync',
|
|
1216
|
+
'harness-rescan',
|
|
1217
|
+
'harness-rescan-paths',
|
|
838
1218
|
'no-sdk-integrate',
|
|
839
1219
|
'no-ci-cd',
|
|
840
1220
|
'no-autosync',
|
|
1221
|
+
'no-harness-rescan',
|
|
841
1222
|
'yes',
|
|
842
1223
|
'agent',
|
|
843
1224
|
'json',
|
|
@@ -854,6 +1235,14 @@ function resumeFlagsForInit(flags) {
|
|
|
854
1235
|
else
|
|
855
1236
|
out.push(flag, String(value));
|
|
856
1237
|
}
|
|
1238
|
+
// The rescan choice is derived as `flag ?? (paths ? true : ...)`, so a bare
|
|
1239
|
+
// `--harness-rescan-paths` re-implies opt-in. If the original run explicitly
|
|
1240
|
+
// disabled rescan (via `--harness-rescan=false`, whose false value the loop
|
|
1241
|
+
// above drops), carry the negative through so a resume can't silently write
|
|
1242
|
+
// the workflow the user declined.
|
|
1243
|
+
if (resolveHarnessRescanFlag(flags) === false && !out.includes('--no-harness-rescan')) {
|
|
1244
|
+
out.push('--no-harness-rescan');
|
|
1245
|
+
}
|
|
857
1246
|
return out;
|
|
858
1247
|
}
|
|
859
1248
|
/**
|
|
@@ -877,6 +1266,29 @@ function resolveAutosyncFlag(flags) {
|
|
|
877
1266
|
return false;
|
|
878
1267
|
return undefined;
|
|
879
1268
|
}
|
|
1269
|
+
/** Explicit harness-rescan decision from flags, or undefined when the Plan beat decides. */
|
|
1270
|
+
function resolveHarnessRescanFlag(flags) {
|
|
1271
|
+
const explicit = parseOptionalBoolean(flagValue(flags, 'harness-rescan', 'harness_rescan'));
|
|
1272
|
+
if (explicit !== undefined)
|
|
1273
|
+
return explicit;
|
|
1274
|
+
if (isTruthyFlag(flagValue(flags, 'no-harness-rescan', 'no_harness_rescan')))
|
|
1275
|
+
return false;
|
|
1276
|
+
return undefined;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Comma-separated path globs for the rescan workflow's `on.push.paths` scope.
|
|
1280
|
+
* `undefined` when the flag is absent (or carries no usable globs), so a bare
|
|
1281
|
+
* `--harness-rescan` still writes an unscoped rescan-on-every-push workflow.
|
|
1282
|
+
*/
|
|
1283
|
+
function parseHarnessRescanPaths(value) {
|
|
1284
|
+
if (typeof value !== 'string')
|
|
1285
|
+
return undefined;
|
|
1286
|
+
const globs = value
|
|
1287
|
+
.split(',')
|
|
1288
|
+
.map((glob) => glob.trim())
|
|
1289
|
+
.filter((glob) => glob.length > 0 && glob !== 'true');
|
|
1290
|
+
return globs.length > 0 ? globs : undefined;
|
|
1291
|
+
}
|
|
880
1292
|
/**
|
|
881
1293
|
* Explicit SDK-integration decision from flags, or undefined when the Plan
|
|
882
1294
|
* beat decides (interactive default: on when a coding-agent CLI is installed;
|
package/dist/init/tenant.js
CHANGED
|
@@ -5,7 +5,7 @@ import { hostname } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { select } from '@inquirer/prompts';
|
|
7
7
|
import { authFetch, getCachedLoginContext } from '../auth.js';
|
|
8
|
-
import { loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
|
|
8
|
+
import { embeddedTenantIdFromApiKey, loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
|
|
9
9
|
import { getModaDir, resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, writeSecretJsonFile, } from '../profiles.js';
|
|
10
10
|
import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
|
|
11
11
|
/**
|
|
@@ -42,8 +42,6 @@ export function formatControlPlaneFetchError(action, status, body) {
|
|
|
42
42
|
// Mirrors the backend's checkAdminAccess: only the viewer role is denied.
|
|
43
43
|
const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
|
|
44
44
|
const ADMIN_ACCESS_GUIDANCE = 'Ask your organization owner to upgrade your role.';
|
|
45
|
-
const API_KEY_PREFIX = 'moda_sk_';
|
|
46
|
-
const SIGNED_KEY_VERSION = 'v1';
|
|
47
45
|
// Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
|
|
48
46
|
// endpoint must never block init — we just mint a fresh key instead.
|
|
49
47
|
const VALIDATE_TIMEOUT_MS = 3_000;
|
|
@@ -160,29 +158,13 @@ function bootstrapLabel(custom) {
|
|
|
160
158
|
}
|
|
161
159
|
}
|
|
162
160
|
/**
|
|
163
|
-
* Signed CLI keys embed their tenant:
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* call. The HMAC signature can only be verified server-side; liveness (not
|
|
169
|
-
* revoked) is probed separately by isStoredKeyLive().
|
|
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().
|
|
170
166
|
*/
|
|
171
|
-
|
|
172
|
-
if (!key.startsWith(API_KEY_PREFIX))
|
|
173
|
-
return undefined;
|
|
174
|
-
try {
|
|
175
|
-
const decoded = Buffer.from(key.slice(API_KEY_PREFIX.length), 'base64url').toString('utf8');
|
|
176
|
-
// Expected format: version:keyId:tenantId:timestamp:signature
|
|
177
|
-
const parts = decoded.split(':');
|
|
178
|
-
if (parts.length !== 5 || parts[0] !== SIGNED_KEY_VERSION)
|
|
179
|
-
return undefined;
|
|
180
|
-
return parts[2] || undefined;
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
167
|
+
const embeddedTenantId = embeddedTenantIdFromApiKey;
|
|
186
168
|
/**
|
|
187
169
|
* Liveness probe for a stored key. Deliberately NOT the backend's public
|
|
188
170
|
* POST /api/api-keys/validate: the CLI's resolved base URL is the FRONTEND
|
package/package.json
CHANGED