@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.112.1",
3
+ "version": "0.2.0-beta.113.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
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 { resolveRuntimeProfile, workspacePath } from './runtime/profiles.js';
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({
@@ -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((hint) => ` ${hint.command} ${hint.reason}`);
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(` ${hint.command} ${hint.reason}\n`);
192
+ process.stderr.write(`${formatHint(hint)}\n`);
186
193
  }
187
194
  return error.exitCode || EXIT_CODES.unexpected;
188
195
  }
@@ -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
- export function loadConfig() {
81
- if (!existsSync(CONFIG_FILE)) {
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(CONFIG_FILE, 'utf-8')));
190
+ return normalizeConfig(JSON.parse(readFileSync(configFile, 'utf-8')));
86
191
  }
87
192
 
88
- export function saveConfig(config) {
89
- mkdirSync(CONFIG_DIR, { recursive: true });
90
- writeFileSync(CONFIG_FILE, JSON.stringify(normalizeConfig(config), null, 2));
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 config = loadConfig();
334
+ const worktreeRuntime = resolveWorktreeRuntime();
335
+ const config = loadConfig(worktreeRuntime);
229
336
  const profileName = getCurrentProfileName(config, globalOptions.profile);
230
- const apiBase = getApiBase(config, profileName, globalOptions.apiBase);
231
- const jwt = getJwt(config, profileName);
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;
@@ -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;