@notis_ai/cli 0.2.0-beta.112.1 → 0.2.0-beta.113.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/package.json +1 -1
- package/src/cli.js +34 -12
- package/src/runtime/output.js +9 -2
- package/src/runtime/profiles.js +163 -12
- package/src/runtime/transport.js +14 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -5,7 +5,12 @@ import { Command } from 'commander';
|
|
|
5
5
|
import { COMMAND_SPECS, GROUP_SUMMARIES } from './command-specs/index.js';
|
|
6
6
|
import { OutputManager } from './runtime/output.js';
|
|
7
7
|
import { asCliError } from './runtime/errors.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_PROFILE,
|
|
10
|
+
resolveOutputMode,
|
|
11
|
+
resolveRuntimeProfile,
|
|
12
|
+
workspacePath,
|
|
13
|
+
} from './runtime/profiles.js';
|
|
9
14
|
|
|
10
15
|
// Read the version from package.json: the publish pipeline bumps the manifest
|
|
11
16
|
// (scripts/release-utils.js applyVersion), so a hardcoded string here goes
|
|
@@ -74,6 +79,33 @@ function buildRuntime(globalOptions, spec) {
|
|
|
74
79
|
};
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
function buildErrorRuntime(globalOptions) {
|
|
83
|
+
try {
|
|
84
|
+
return {
|
|
85
|
+
...resolveRuntimeProfile(globalOptions, {
|
|
86
|
+
requireAuth: false,
|
|
87
|
+
includeDebugEntitlementOverride: false,
|
|
88
|
+
}),
|
|
89
|
+
cliVersion: CLI_VERSION,
|
|
90
|
+
color: globalOptions.color !== false,
|
|
91
|
+
quiet: Boolean(globalOptions.quiet),
|
|
92
|
+
verbose: Boolean(globalOptions.verbose),
|
|
93
|
+
workspacePath,
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return {
|
|
97
|
+
profileName: globalOptions.profile || DEFAULT_PROFILE,
|
|
98
|
+
apiBase: null,
|
|
99
|
+
outputMode: resolveOutputMode(globalOptions),
|
|
100
|
+
cliVersion: CLI_VERSION,
|
|
101
|
+
color: globalOptions.color !== false,
|
|
102
|
+
quiet: Boolean(globalOptions.quiet),
|
|
103
|
+
verbose: Boolean(globalOptions.verbose),
|
|
104
|
+
workspacePath,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
function attachSpec(program, parentMap, spec, specs) {
|
|
78
110
|
const parent = ensureParentCommand(program, parentMap, spec.command_path.slice(0, -1));
|
|
79
111
|
const leaf = spec.command_path[spec.command_path.length - 1];
|
|
@@ -121,17 +153,7 @@ function attachSpec(program, parentMap, spec, specs) {
|
|
|
121
153
|
});
|
|
122
154
|
process.exitCode = typeof exitCode === 'number' ? exitCode : 0;
|
|
123
155
|
} catch (error) {
|
|
124
|
-
const runtime =
|
|
125
|
-
...resolveRuntimeProfile(globalOptions, {
|
|
126
|
-
requireAuth: false,
|
|
127
|
-
includeDebugEntitlementOverride: false,
|
|
128
|
-
}),
|
|
129
|
-
cliVersion: CLI_VERSION,
|
|
130
|
-
color: globalOptions.color !== false,
|
|
131
|
-
quiet: Boolean(globalOptions.quiet),
|
|
132
|
-
verbose: Boolean(globalOptions.verbose),
|
|
133
|
-
workspacePath,
|
|
134
|
-
};
|
|
156
|
+
const runtime = buildErrorRuntime(globalOptions);
|
|
135
157
|
const output = new OutputManager(runtime);
|
|
136
158
|
const cliError = asCliError(error);
|
|
137
159
|
process.exitCode = output.emitError({
|
package/src/runtime/output.js
CHANGED
|
@@ -22,6 +22,13 @@ function formatTable(rows, columns) {
|
|
|
22
22
|
return [header, divider, ...body].join('\n');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function formatHint(hint) {
|
|
26
|
+
if (typeof hint?.message === 'string' && hint.message) {
|
|
27
|
+
return ` ${hint.message}`;
|
|
28
|
+
}
|
|
29
|
+
return ` ${hint?.command || ''} ${hint?.reason || ''}`.trimEnd();
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
function yamlScalar(value) {
|
|
26
33
|
if (value === null || value === undefined) {
|
|
27
34
|
return 'null';
|
|
@@ -153,7 +160,7 @@ export class OutputManager {
|
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
if (hints.length) {
|
|
156
|
-
const lines = hints.map(
|
|
163
|
+
const lines = hints.map(formatHint);
|
|
157
164
|
process.stdout.write(`\nNext:\n${lines.join('\n')}\n`);
|
|
158
165
|
}
|
|
159
166
|
|
|
@@ -182,7 +189,7 @@ export class OutputManager {
|
|
|
182
189
|
|
|
183
190
|
process.stderr.write(`Error: ${error.message}\n`);
|
|
184
191
|
for (const hint of error.hints || []) {
|
|
185
|
-
process.stderr.write(
|
|
192
|
+
process.stderr.write(`${formatHint(hint)}\n`);
|
|
186
193
|
}
|
|
187
194
|
return error.exitCode || EXIT_CODES.unexpected;
|
|
188
195
|
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join, parse, resolve } from 'node:path';
|
|
4
4
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
5
5
|
import { getDesktopAuthRecovery } from './desktop-auth.js';
|
|
6
6
|
|
|
@@ -9,6 +9,8 @@ export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
|
9
9
|
export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
|
|
10
10
|
export const DEFAULT_API_BASE = 'https://api.notis.ai';
|
|
11
11
|
export const DEFAULT_PROFILE = 'default';
|
|
12
|
+
const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
|
|
13
|
+
const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
|
|
12
14
|
const LOCAL_DEFAULT_API_BASES = new Set([
|
|
13
15
|
'http://localhost:3001',
|
|
14
16
|
'http://127.0.0.1:3001',
|
|
@@ -77,17 +79,121 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
function readJsonFile(path) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findUp(filename, startDir = process.cwd()) {
|
|
91
|
+
let current = resolve(startDir);
|
|
92
|
+
const root = parse(current).root;
|
|
93
|
+
while (true) {
|
|
94
|
+
const candidate = join(current, filename);
|
|
95
|
+
if (existsSync(candidate)) {
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
if (current === root) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
current = dirname(current);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function processIsAlive(pid) {
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, 0);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
118
|
+
if (
|
|
119
|
+
process.env.NODE_ENV === 'test' &&
|
|
120
|
+
process.env.NOTIS_TEST_DISABLE_WORKTREE_ROUTING === '1'
|
|
121
|
+
) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const runtimePath = findUp(WORKTREE_RUNTIME_FILENAME, startDir);
|
|
125
|
+
const routingPath = findUp(WORKTREE_ROUTING_FILENAME, startDir);
|
|
126
|
+
const routing = routingPath ? readJsonFile(routingPath) : null;
|
|
127
|
+
|
|
128
|
+
if (!runtimePath) {
|
|
129
|
+
if (routing?.mode === 'local-only') {
|
|
130
|
+
throw new CliError({
|
|
131
|
+
code: 'dev_runtime_unavailable',
|
|
132
|
+
message: `This worktree is local-only, but its dev.sh runtime is not active`,
|
|
133
|
+
exitCode: EXIT_CODES.network,
|
|
134
|
+
hints: [
|
|
135
|
+
{ message: 'Start ./dev.sh in this worktree, then retry the command.' },
|
|
136
|
+
{ message: `Routing policy: ${routingPath}` },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const runtime = readJsonFile(runtimePath);
|
|
144
|
+
const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
|
|
145
|
+
const configFile = typeof runtime?.config_file === 'string' ? runtime.config_file : '';
|
|
146
|
+
const pid = Number(runtime?.dev_pid);
|
|
147
|
+
if (
|
|
148
|
+
runtime?.mode !== 'local-only' ||
|
|
149
|
+
!isLocalApiBase(apiBase) ||
|
|
150
|
+
!configFile ||
|
|
151
|
+
!processIsAlive(pid)
|
|
152
|
+
) {
|
|
153
|
+
throw new CliError({
|
|
154
|
+
code: 'dev_runtime_unavailable',
|
|
155
|
+
message: 'The local-only worktree runtime is stale or invalid',
|
|
156
|
+
exitCode: EXIT_CODES.network,
|
|
157
|
+
hints: [
|
|
158
|
+
{ message: 'Restart ./dev.sh in this worktree, then retry the command.' },
|
|
159
|
+
{ message: `Runtime lease: ${runtimePath}` },
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...runtime,
|
|
166
|
+
api_base: apiBase,
|
|
167
|
+
config_file: resolve(dirname(runtimePath), configFile),
|
|
168
|
+
runtime_path: runtimePath,
|
|
169
|
+
routing_path: routingPath,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function resolveConfigFile(runtime = null) {
|
|
174
|
+
if (runtime?.config_file) {
|
|
175
|
+
return runtime.config_file;
|
|
176
|
+
}
|
|
177
|
+
const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
|
|
178
|
+
if (envConfigFile) {
|
|
179
|
+
return resolve(envConfigFile);
|
|
180
|
+
}
|
|
181
|
+
return CONFIG_FILE;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function loadConfig(runtime = null) {
|
|
185
|
+
const configFile = resolveConfigFile(runtime);
|
|
186
|
+
if (!existsSync(configFile)) {
|
|
82
187
|
return normalizeConfig({});
|
|
83
188
|
}
|
|
84
189
|
|
|
85
|
-
return normalizeConfig(JSON.parse(readFileSync(
|
|
190
|
+
return normalizeConfig(JSON.parse(readFileSync(configFile, 'utf-8')));
|
|
86
191
|
}
|
|
87
192
|
|
|
88
|
-
export function saveConfig(config) {
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
export function saveConfig(config, runtime = null) {
|
|
194
|
+
const configFile = resolveConfigFile(runtime);
|
|
195
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
196
|
+
writeFileSync(configFile, JSON.stringify(normalizeConfig(config), null, 2), { mode: 0o600 });
|
|
91
197
|
}
|
|
92
198
|
|
|
93
199
|
export function getProfile(config, profileName) {
|
|
@@ -225,10 +331,26 @@ export function resolveRuntimeProfile(
|
|
|
225
331
|
globalOptions = {},
|
|
226
332
|
{ requireAuth = true, includeDebugEntitlementOverride = true } = {},
|
|
227
333
|
) {
|
|
228
|
-
const
|
|
334
|
+
const worktreeRuntime = resolveWorktreeRuntime();
|
|
335
|
+
const config = loadConfig(worktreeRuntime);
|
|
229
336
|
const profileName = getCurrentProfileName(config, globalOptions.profile);
|
|
230
|
-
const
|
|
231
|
-
|
|
337
|
+
const requestedApiBase = globalOptions.apiBase;
|
|
338
|
+
if (
|
|
339
|
+
worktreeRuntime &&
|
|
340
|
+
requestedApiBase &&
|
|
341
|
+
requestedApiBase.replace(/\/+$/, '') !== worktreeRuntime.api_base
|
|
342
|
+
) {
|
|
343
|
+
throw new CliError({
|
|
344
|
+
code: 'dev_runtime_route_mismatch',
|
|
345
|
+
message: `This local-only worktree cannot route to ${requestedApiBase}`,
|
|
346
|
+
exitCode: EXIT_CODES.usage,
|
|
347
|
+
hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const apiBase = worktreeRuntime
|
|
351
|
+
? worktreeRuntime.api_base
|
|
352
|
+
: getApiBase(config, profileName, globalOptions.apiBase);
|
|
353
|
+
const jwt = worktreeRuntime ? getProfile(config, profileName).jwt : getJwt(config, profileName);
|
|
232
354
|
const profile = getProfile(config, profileName);
|
|
233
355
|
const agentMode = isAgentMode(globalOptions);
|
|
234
356
|
const nonInteractive = isNonInteractive(globalOptions);
|
|
@@ -251,16 +373,30 @@ export function resolveRuntimeProfile(
|
|
|
251
373
|
hints: recovery.hints,
|
|
252
374
|
});
|
|
253
375
|
}
|
|
376
|
+
if (
|
|
377
|
+
worktreeRuntime?.expected_user_id &&
|
|
378
|
+
getJwtSubject(jwt) !== worktreeRuntime.expected_user_id
|
|
379
|
+
) {
|
|
380
|
+
throw new CliError({
|
|
381
|
+
code: 'dev_runtime_identity_mismatch',
|
|
382
|
+
message: 'The scoped dev credential does not belong to this worktree test user',
|
|
383
|
+
exitCode: EXIT_CODES.auth,
|
|
384
|
+
hints: [
|
|
385
|
+
{ message: 'Restart ./dev.sh to restore the approved worktree identity.' },
|
|
386
|
+
{ message: `Expected user: ${worktreeRuntime.expected_user_id}` },
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
}
|
|
254
390
|
|
|
255
391
|
// An explicit NOTIS_JWT is a complete credential override. Use it verbatim
|
|
256
392
|
// and never replace it with a token later synced by the desktop profile.
|
|
257
|
-
const usingEnvJwt = Boolean(process.env.NOTIS_JWT);
|
|
393
|
+
const usingEnvJwt = !worktreeRuntime && Boolean(process.env.NOTIS_JWT);
|
|
258
394
|
return {
|
|
259
395
|
config,
|
|
260
396
|
profileName,
|
|
261
397
|
apiBase,
|
|
262
398
|
jwt,
|
|
263
|
-
credentialSource: usingEnvJwt ? 'env' : 'profile',
|
|
399
|
+
credentialSource: worktreeRuntime ? 'worktree' : usingEnvJwt ? 'env' : 'profile',
|
|
264
400
|
desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
|
|
265
401
|
desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
|
|
266
402
|
agentMode,
|
|
@@ -268,6 +404,7 @@ export function resolveRuntimeProfile(
|
|
|
268
404
|
outputMode,
|
|
269
405
|
timeoutMs,
|
|
270
406
|
debugEntitlementOverride,
|
|
407
|
+
worktreeRuntime,
|
|
271
408
|
};
|
|
272
409
|
}
|
|
273
410
|
|
|
@@ -285,6 +422,20 @@ export function getJwtExpiration(jwt) {
|
|
|
285
422
|
}
|
|
286
423
|
}
|
|
287
424
|
|
|
425
|
+
export function getJwtSubject(jwt) {
|
|
426
|
+
if (typeof jwt !== 'string' || !jwt) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const parts = jwt.split('.');
|
|
431
|
+
if (parts.length !== 3) return null;
|
|
432
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
433
|
+
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null;
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
288
439
|
export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
289
440
|
const expiration = getJwtExpiration(jwt);
|
|
290
441
|
return expiration !== null && expiration <= nowSeconds;
|
package/src/runtime/transport.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CliError, EXIT_CODES } from './errors.js';
|
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_PROFILE,
|
|
6
6
|
getProfile,
|
|
7
|
+
getJwtSubject,
|
|
7
8
|
isJwtExpired,
|
|
8
9
|
loadConfig,
|
|
9
10
|
} from './profiles.js';
|
|
@@ -149,7 +150,7 @@ function reloadJwtFromConfig(runtime) {
|
|
|
149
150
|
const profileName = runtime.profileName || DEFAULT_PROFILE;
|
|
150
151
|
let profile;
|
|
151
152
|
try {
|
|
152
|
-
profile = getProfile(loadConfig(), profileName);
|
|
153
|
+
profile = getProfile(loadConfig(runtime.worktreeRuntime), profileName);
|
|
153
154
|
} catch {
|
|
154
155
|
return false;
|
|
155
156
|
}
|
|
@@ -157,6 +158,18 @@ function reloadJwtFromConfig(runtime) {
|
|
|
157
158
|
if (!nextJwt || nextJwt === runtime.jwt) {
|
|
158
159
|
return false;
|
|
159
160
|
}
|
|
161
|
+
const expectedUserId = runtime.worktreeRuntime?.expected_user_id;
|
|
162
|
+
if (expectedUserId && getJwtSubject(nextJwt) !== expectedUserId) {
|
|
163
|
+
throw new CliError({
|
|
164
|
+
code: 'dev_runtime_identity_mismatch',
|
|
165
|
+
message: 'The refreshed scoped dev credential does not belong to this worktree test user',
|
|
166
|
+
exitCode: EXIT_CODES.auth,
|
|
167
|
+
hints: [
|
|
168
|
+
{ message: 'Restart ./dev.sh to restore the approved worktree identity.' },
|
|
169
|
+
{ message: `Expected user: ${expectedUserId}` },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
160
173
|
runtime.jwt = nextJwt;
|
|
161
174
|
runtime.desktopAppName = profile.desktop_app_name;
|
|
162
175
|
runtime.desktopPid = profile.desktop_pid;
|