@notis_ai/cli 0.2.8 → 0.2.10
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/README.md +41 -2
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +10 -21
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +11 -5
- package/skills/notis-query/cli.md +1 -1
- package/src/command-specs/apps.js +188 -23
- package/src/command-specs/auth.js +107 -0
- package/src/command-specs/diagnostics.js +6 -1
- package/src/command-specs/helpers.js +7 -1
- package/src/command-specs/index.js +2 -0
- package/src/command-specs/meta.js +68 -6
- package/src/command-specs/onboarding.js +94 -6
- package/src/runtime/app-dev-server.js +3 -2
- package/src/runtime/app-dev-sessions.js +14 -40
- package/src/runtime/cli-mode.generated.js +4 -3
- package/src/runtime/cli-mode.js +13 -8
- package/src/runtime/desktop-auth.js +22 -2
- package/src/runtime/oauth.js +1121 -0
- package/src/runtime/output.js +7 -0
- package/src/runtime/profiles.js +423 -23
- package/src/runtime/transport.js +41 -11
package/src/runtime/output.js
CHANGED
|
@@ -101,6 +101,13 @@ export class OutputManager {
|
|
|
101
101
|
process.stderr.write(`${message}\n`);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
note(message) {
|
|
105
|
+
if (this.isMachineMode()) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
process.stderr.write(`${message}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
emitProgress({ phase, message, requestId = null }) {
|
|
105
112
|
if (this.runtime.quiet) {
|
|
106
113
|
return;
|
package/src/runtime/profiles.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
2
11
|
import { homedir } from 'node:os';
|
|
3
12
|
import { dirname, join, parse, resolve } from 'node:path';
|
|
4
13
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
@@ -8,14 +17,21 @@ export const CONFIG_DIR = join(homedir(), '.notis');
|
|
|
8
17
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
9
18
|
export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
|
|
10
19
|
export const DEFAULT_API_BASE = 'https://api.notis.ai';
|
|
20
|
+
export const BETA_API_BASE = 'https://api-beta.notis.ai';
|
|
11
21
|
export const DEFAULT_PROFILE = 'default';
|
|
12
22
|
const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
|
|
13
23
|
const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
|
|
14
|
-
const LOCAL_DEFAULT_API_BASES = new Set([
|
|
15
|
-
'http://localhost:3001',
|
|
16
|
-
'http://127.0.0.1:3001',
|
|
17
|
-
]);
|
|
18
24
|
const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
25
|
+
const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
|
|
26
|
+
// Cross-process write lock over ~/.notis/config.json. Notis Desktop implements
|
|
27
|
+
// the same protocol independently in electron/src/cli-auth.ts (updateConfig);
|
|
28
|
+
// the `${configFile}.write-lock` directory name and these three timings must
|
|
29
|
+
// stay identical on both sides or `notis login` and desktop syncAuth stop
|
|
30
|
+
// excluding each other and clobber stored OAuth tokens. Guarded by the drift
|
|
31
|
+
// test in packages/cli/test/runtime-auth.test.js.
|
|
32
|
+
const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
33
|
+
const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
|
|
34
|
+
const CONFIG_WRITE_LOCK_POLL_MS = 10;
|
|
19
35
|
|
|
20
36
|
function clone(value) {
|
|
21
37
|
return JSON.parse(JSON.stringify(value));
|
|
@@ -33,6 +49,7 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
33
49
|
profiles[name] = {
|
|
34
50
|
jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
|
|
35
51
|
api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
|
|
52
|
+
beta: typeof profile.beta === 'boolean' ? profile.beta : undefined,
|
|
36
53
|
auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
|
|
37
54
|
refresh_token:
|
|
38
55
|
typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
|
|
@@ -43,6 +60,28 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
43
60
|
desktop_app_name:
|
|
44
61
|
typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
|
|
45
62
|
desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
|
|
63
|
+
oauth_access_token:
|
|
64
|
+
typeof profile.oauth_access_token === 'string' ? profile.oauth_access_token : undefined,
|
|
65
|
+
oauth_refresh_token:
|
|
66
|
+
typeof profile.oauth_refresh_token === 'string' ? profile.oauth_refresh_token : undefined,
|
|
67
|
+
oauth_access_expires_at:
|
|
68
|
+
typeof profile.oauth_access_expires_at === 'number' ? profile.oauth_access_expires_at : undefined,
|
|
69
|
+
oauth_refresh_expires_at:
|
|
70
|
+
typeof profile.oauth_refresh_expires_at === 'number' ? profile.oauth_refresh_expires_at : undefined,
|
|
71
|
+
oauth_client_id:
|
|
72
|
+
typeof profile.oauth_client_id === 'string' ? profile.oauth_client_id : undefined,
|
|
73
|
+
oauth_issuer:
|
|
74
|
+
typeof profile.oauth_issuer === 'string' ? profile.oauth_issuer : undefined,
|
|
75
|
+
oauth_api_base:
|
|
76
|
+
typeof profile.oauth_api_base === 'string' ? profile.oauth_api_base : undefined,
|
|
77
|
+
oauth_resource:
|
|
78
|
+
typeof profile.oauth_resource === 'string' ? profile.oauth_resource : undefined,
|
|
79
|
+
oauth_scopes:
|
|
80
|
+
Array.isArray(profile.oauth_scopes)
|
|
81
|
+
? profile.oauth_scopes.filter((scope) => typeof scope === 'string')
|
|
82
|
+
: undefined,
|
|
83
|
+
oauth_user_id:
|
|
84
|
+
typeof profile.oauth_user_id === 'string' ? profile.oauth_user_id : undefined,
|
|
46
85
|
};
|
|
47
86
|
}
|
|
48
87
|
|
|
@@ -65,6 +104,7 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
65
104
|
[DEFAULT_PROFILE]: {
|
|
66
105
|
jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
|
|
67
106
|
api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
|
|
107
|
+
beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
|
|
68
108
|
auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
|
|
69
109
|
refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
|
|
70
110
|
access_expires_at:
|
|
@@ -74,6 +114,28 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
74
114
|
desktop_app_name:
|
|
75
115
|
typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
|
|
76
116
|
desktop_pid: typeof raw.desktop_pid === 'number' ? raw.desktop_pid : undefined,
|
|
117
|
+
oauth_access_token:
|
|
118
|
+
typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
|
|
119
|
+
oauth_refresh_token:
|
|
120
|
+
typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
|
|
121
|
+
oauth_access_expires_at:
|
|
122
|
+
typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
|
|
123
|
+
oauth_refresh_expires_at:
|
|
124
|
+
typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
|
|
125
|
+
oauth_client_id:
|
|
126
|
+
typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
|
|
127
|
+
oauth_issuer:
|
|
128
|
+
typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
|
|
129
|
+
oauth_api_base:
|
|
130
|
+
typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
|
|
131
|
+
oauth_resource:
|
|
132
|
+
typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
|
|
133
|
+
oauth_scopes:
|
|
134
|
+
Array.isArray(raw.oauth_scopes)
|
|
135
|
+
? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
|
|
136
|
+
: undefined,
|
|
137
|
+
oauth_user_id:
|
|
138
|
+
typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
|
|
77
139
|
},
|
|
78
140
|
},
|
|
79
141
|
};
|
|
@@ -143,6 +205,14 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
143
205
|
const runtime = readJsonFile(runtimePath);
|
|
144
206
|
const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
|
|
145
207
|
const configFile = typeof runtime?.config_file === 'string' ? runtime.config_file : '';
|
|
208
|
+
const appDevSessionsFile =
|
|
209
|
+
typeof runtime?.app_dev_sessions_file === 'string' && runtime.app_dev_sessions_file.trim()
|
|
210
|
+
? runtime.app_dev_sessions_file.trim()
|
|
211
|
+
: join(dirname(runtimePath), 'app-dev-sessions.json');
|
|
212
|
+
const desktopDeepLinkScheme =
|
|
213
|
+
typeof runtime?.desktop_deep_link_scheme === 'string'
|
|
214
|
+
? runtime.desktop_deep_link_scheme.trim()
|
|
215
|
+
: '';
|
|
146
216
|
const pid = Number(runtime?.dev_pid);
|
|
147
217
|
if (
|
|
148
218
|
runtime?.mode !== 'local-only' ||
|
|
@@ -165,6 +235,8 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
165
235
|
...runtime,
|
|
166
236
|
api_base: apiBase,
|
|
167
237
|
config_file: resolve(dirname(runtimePath), configFile),
|
|
238
|
+
app_dev_sessions_file: resolve(dirname(runtimePath), appDevSessionsFile),
|
|
239
|
+
desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
|
|
168
240
|
runtime_path: runtimePath,
|
|
169
241
|
routing_path: routingPath,
|
|
170
242
|
};
|
|
@@ -198,10 +270,126 @@ export function loadConfig(runtime = null) {
|
|
|
198
270
|
}
|
|
199
271
|
}
|
|
200
272
|
|
|
201
|
-
|
|
273
|
+
function writeConfig(configFile, config) {
|
|
274
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
275
|
+
const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
|
|
276
|
+
writeFileSync(temporaryFile, JSON.stringify(normalizeConfig(config), null, 2), { mode: 0o600 });
|
|
277
|
+
renameSync(temporaryFile, configFile);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The lock records who holds it and when they took it. Reclaiming a stale lock
|
|
281
|
+
// by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
|
|
282
|
+
// one it is about to steal from a live writer, and the victim's own release
|
|
283
|
+
// would then delete the thief's lock and admit a third writer. Both sides are
|
|
284
|
+
// on the same host, so the stored timestamp and Date.now() share a clock.
|
|
285
|
+
function readLockOwner(lockDirectory) {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function lockDirectoryMtime(lockDirectory) {
|
|
294
|
+
try {
|
|
295
|
+
return statSync(lockDirectory).mtimeMs;
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function publishConfigWriteLock(lockDirectory, ownerId) {
|
|
302
|
+
const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
|
|
303
|
+
try {
|
|
304
|
+
mkdirSync(candidateDirectory);
|
|
305
|
+
writeFileSync(
|
|
306
|
+
join(candidateDirectory, 'owner'),
|
|
307
|
+
JSON.stringify({ id: ownerId, at: Date.now() }),
|
|
308
|
+
{ mode: 0o600 },
|
|
309
|
+
);
|
|
310
|
+
try {
|
|
311
|
+
renameSync(candidateDirectory, lockDirectory);
|
|
312
|
+
return true;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (!existsSync(lockDirectory)) throw error;
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
rmSync(candidateDirectory, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function withConfigWriteLock(runtime, callback) {
|
|
202
323
|
const configFile = resolveConfigFile(runtime);
|
|
324
|
+
const lockDirectory = `${configFile}.write-lock`;
|
|
325
|
+
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
326
|
+
const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
|
|
203
327
|
mkdirSync(dirname(configFile), { recursive: true });
|
|
204
|
-
|
|
328
|
+
for (;;) {
|
|
329
|
+
if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const owner = readLockOwner(lockDirectory);
|
|
334
|
+
const observedMtime = lockDirectoryMtime(lockDirectory);
|
|
335
|
+
const ownerIsStale =
|
|
336
|
+
owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
|
|
337
|
+
const ownerlessIsStale =
|
|
338
|
+
!owner
|
|
339
|
+
&& observedMtime !== null
|
|
340
|
+
&& Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
|
|
341
|
+
if (ownerIsStale || ownerlessIsStale) {
|
|
342
|
+
try {
|
|
343
|
+
const currentOwner = readLockOwner(lockDirectory);
|
|
344
|
+
const ownerUnchanged = owner?.id
|
|
345
|
+
? currentOwner?.id === owner.id
|
|
346
|
+
: !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
|
|
347
|
+
if (ownerUnchanged) {
|
|
348
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// Losing the reclaim race is normal; fall through and keep waiting.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (Date.now() >= deadline) {
|
|
356
|
+
throw new Error(`Timed out waiting to update ${configFile}`);
|
|
357
|
+
}
|
|
358
|
+
Atomics.wait(
|
|
359
|
+
new Int32Array(new SharedArrayBuffer(4)),
|
|
360
|
+
0,
|
|
361
|
+
0,
|
|
362
|
+
CONFIG_WRITE_LOCK_POLL_MS,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return callback(configFile);
|
|
367
|
+
} finally {
|
|
368
|
+
try {
|
|
369
|
+
// Never release a lock that was reclaimed from us while we worked.
|
|
370
|
+
if (readLockOwner(lockDirectory)?.id === ownerId) {
|
|
371
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
// The lock may already have been removed after an interrupted write.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function saveConfig(config, runtime = null) {
|
|
380
|
+
return withConfigWriteLock(runtime, (configFile) => {
|
|
381
|
+
writeConfig(configFile, config);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function updateConfig(updater, runtime = null) {
|
|
386
|
+
return withConfigWriteLock(runtime, (configFile) => {
|
|
387
|
+
const current = loadConfig(runtime);
|
|
388
|
+
const updated = updater(normalizeConfig(current));
|
|
389
|
+
const next = normalizeConfig(updated ?? current);
|
|
390
|
+
writeConfig(configFile, next);
|
|
391
|
+
return next;
|
|
392
|
+
});
|
|
205
393
|
}
|
|
206
394
|
|
|
207
395
|
export function getProfile(config, profileName) {
|
|
@@ -237,6 +425,48 @@ function isLocalApiBase(value) {
|
|
|
237
425
|
}
|
|
238
426
|
}
|
|
239
427
|
|
|
428
|
+
function isLiveApiBase(value) {
|
|
429
|
+
if (typeof value !== 'string' || !value) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const parsed = new URL(value);
|
|
434
|
+
return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Pick the live Notis API for this profile.
|
|
442
|
+
*
|
|
443
|
+
* Beta users (`users.beta = true`, mirrored onto the CLI profile by Desktop
|
|
444
|
+
* sync / OAuth against api-beta) hit api-beta.notis.ai; everyone else hits
|
|
445
|
+
* api.notis.ai. Localhost is never a default — only the worktree test lease
|
|
446
|
+
* (`./dev.sh` / `/notis-tests`) may retarget the CLI at loopback.
|
|
447
|
+
*/
|
|
448
|
+
export function resolveDefaultLiveApiBase(profile = {}) {
|
|
449
|
+
if (profile.beta === true) {
|
|
450
|
+
return BETA_API_BASE;
|
|
451
|
+
}
|
|
452
|
+
if (profile.beta === false) {
|
|
453
|
+
return DEFAULT_API_BASE;
|
|
454
|
+
}
|
|
455
|
+
if (profile.desktop_app_name === 'Notis Beta') {
|
|
456
|
+
return BETA_API_BASE;
|
|
457
|
+
}
|
|
458
|
+
if (isLiveApiBase(profile.api_base)) {
|
|
459
|
+
try {
|
|
460
|
+
if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
|
|
461
|
+
return BETA_API_BASE;
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// fall through
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return DEFAULT_API_BASE;
|
|
468
|
+
}
|
|
469
|
+
|
|
240
470
|
export function getApiBase(config, profileName, override) {
|
|
241
471
|
if (override) {
|
|
242
472
|
return override;
|
|
@@ -247,20 +477,16 @@ export function getApiBase(config, profileName, override) {
|
|
|
247
477
|
}
|
|
248
478
|
const profile = getProfile(config, profileName);
|
|
249
479
|
const profileApiBase = profile.api_base;
|
|
250
|
-
const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
|
|
251
480
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
(!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
|
|
259
|
-
) {
|
|
260
|
-
return `http://localhost:${conductorPort + 1}`;
|
|
481
|
+
// Prefer an explicit non-loopback API stored on the profile (Desktop sync /
|
|
482
|
+
// OAuth / custom overrides). Ignore stale localhost values — loopback
|
|
483
|
+
// routing is owned exclusively by the worktree runtime lease under
|
|
484
|
+
// `/notis-tests`, not by CONDUCTOR_PORT or leftover local profile state.
|
|
485
|
+
if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
|
|
486
|
+
return profileApiBase.replace(/\/+$/, '');
|
|
261
487
|
}
|
|
262
488
|
|
|
263
|
-
return
|
|
489
|
+
return resolveDefaultLiveApiBase(profile);
|
|
264
490
|
}
|
|
265
491
|
|
|
266
492
|
export function getJwt(config, profileName) {
|
|
@@ -335,6 +561,37 @@ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_EN
|
|
|
335
561
|
});
|
|
336
562
|
}
|
|
337
563
|
|
|
564
|
+
export function getOAuthResource(profile = {}) {
|
|
565
|
+
if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
|
|
566
|
+
return profile.oauth_resource.replace(/\/+$/, '');
|
|
567
|
+
}
|
|
568
|
+
if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
|
|
569
|
+
try {
|
|
570
|
+
const parts = profile.oauth_access_token.split('.');
|
|
571
|
+
if (parts.length === 3) {
|
|
572
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
573
|
+
if (typeof payload.aud === 'string' && payload.aud) {
|
|
574
|
+
return payload.aud.replace(/\/+$/, '');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
// Legacy tokens without a readable audience fall back below.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function getOAuthApiBase(profile = {}) {
|
|
585
|
+
if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
|
|
586
|
+
return profile.oauth_api_base.replace(/\/+$/, '');
|
|
587
|
+
}
|
|
588
|
+
const resource = getOAuthResource(profile);
|
|
589
|
+
if (resource?.endsWith('/cli')) {
|
|
590
|
+
return resource.slice(0, -'/cli'.length);
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
338
595
|
export function resolveRuntimeProfile(
|
|
339
596
|
globalOptions = {},
|
|
340
597
|
{ requireAuth = true, includeDebugEntitlementOverride = true } = {},
|
|
@@ -355,11 +612,81 @@ export function resolveRuntimeProfile(
|
|
|
355
612
|
hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
|
|
356
613
|
});
|
|
357
614
|
}
|
|
358
|
-
|
|
615
|
+
let apiBase = worktreeRuntime
|
|
359
616
|
? worktreeRuntime.api_base
|
|
360
617
|
: getApiBase(config, profileName, globalOptions.apiBase);
|
|
361
|
-
const jwt = worktreeRuntime ? getProfile(config, profileName).jwt : getJwt(config, profileName);
|
|
362
618
|
const profile = getProfile(config, profileName);
|
|
619
|
+
const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
|
|
620
|
+
const desktopJwt = profile.jwt;
|
|
621
|
+
const oauthJwt = profile.oauth_access_token;
|
|
622
|
+
let jwt;
|
|
623
|
+
let credentialKind;
|
|
624
|
+
|
|
625
|
+
if (worktreeRuntime && desktopJwt) {
|
|
626
|
+
jwt = desktopJwt;
|
|
627
|
+
credentialKind = 'worktree';
|
|
628
|
+
} else if (envJwt) {
|
|
629
|
+
jwt = envJwt;
|
|
630
|
+
credentialKind = 'env';
|
|
631
|
+
} else if (
|
|
632
|
+
desktopJwt
|
|
633
|
+
&& !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
|
|
634
|
+
) {
|
|
635
|
+
jwt = desktopJwt;
|
|
636
|
+
credentialKind = 'desktop';
|
|
637
|
+
} else if (
|
|
638
|
+
oauthJwt
|
|
639
|
+
&& !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
|
|
640
|
+
) {
|
|
641
|
+
jwt = oauthJwt;
|
|
642
|
+
credentialKind = 'oauth';
|
|
643
|
+
} else if (oauthJwt && profile.oauth_refresh_token) {
|
|
644
|
+
// A lapsed OAuth access token remains usable through its rotating refresh
|
|
645
|
+
// token and must outrank an abandoned, expired Desktop credential.
|
|
646
|
+
jwt = oauthJwt;
|
|
647
|
+
credentialKind = 'oauth';
|
|
648
|
+
} else if (desktopJwt) {
|
|
649
|
+
jwt = desktopJwt;
|
|
650
|
+
credentialKind = 'desktop';
|
|
651
|
+
} else if (oauthJwt) {
|
|
652
|
+
// Preserve the OAuth credential so transport can refresh it before use.
|
|
653
|
+
jwt = oauthJwt;
|
|
654
|
+
credentialKind = 'oauth';
|
|
655
|
+
}
|
|
656
|
+
const oauthApiBase = getOAuthApiBase(profile);
|
|
657
|
+
const oauthResource = getOAuthResource(profile);
|
|
658
|
+
const normalizedRequestedApiBase = requestedApiBase
|
|
659
|
+
? requestedApiBase.replace(/\/+$/, '')
|
|
660
|
+
: null;
|
|
661
|
+
if (
|
|
662
|
+
requireAuth
|
|
663
|
+
&& credentialKind === 'oauth'
|
|
664
|
+
&& normalizedRequestedApiBase
|
|
665
|
+
&& oauthApiBase
|
|
666
|
+
&& normalizedRequestedApiBase !== oauthApiBase
|
|
667
|
+
) {
|
|
668
|
+
const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
669
|
+
throw new CliError({
|
|
670
|
+
code: 'oauth_api_target_mismatch',
|
|
671
|
+
message: (
|
|
672
|
+
`The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
|
|
673
|
+
+ `not ${normalizedRequestedApiBase}`
|
|
674
|
+
),
|
|
675
|
+
exitCode: EXIT_CODES.auth,
|
|
676
|
+
hints: [{
|
|
677
|
+
command: [
|
|
678
|
+
'npx --package @notis_ai/cli@latest -- notis',
|
|
679
|
+
`--profile ${quoteShellArgument(profileName)}`,
|
|
680
|
+
`--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
|
|
681
|
+
'login --force',
|
|
682
|
+
].join(' '),
|
|
683
|
+
reason: 'Authorize a separate OAuth grant for the requested Notis environment',
|
|
684
|
+
}],
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
|
|
688
|
+
apiBase = oauthApiBase;
|
|
689
|
+
}
|
|
363
690
|
const agentMode = isAgentMode(globalOptions);
|
|
364
691
|
const nonInteractive = isNonInteractive(globalOptions);
|
|
365
692
|
const outputMode = resolveOutputMode(globalOptions);
|
|
@@ -386,7 +713,11 @@ export function resolveRuntimeProfile(
|
|
|
386
713
|
}
|
|
387
714
|
if (
|
|
388
715
|
worktreeRuntime?.expected_user_id &&
|
|
389
|
-
|
|
716
|
+
(
|
|
717
|
+
credentialKind === 'oauth'
|
|
718
|
+
? profile.oauth_user_id
|
|
719
|
+
: getJwtSubject(jwt)
|
|
720
|
+
) !== worktreeRuntime.expected_user_id
|
|
390
721
|
) {
|
|
391
722
|
throw new CliError({
|
|
392
723
|
code: 'dev_runtime_identity_mismatch',
|
|
@@ -401,13 +732,25 @@ export function resolveRuntimeProfile(
|
|
|
401
732
|
|
|
402
733
|
// An explicit NOTIS_JWT is a complete credential override. Use it verbatim
|
|
403
734
|
// and never replace it with a token later synced by the desktop profile.
|
|
404
|
-
const usingEnvJwt =
|
|
735
|
+
const usingEnvJwt = credentialKind === 'env';
|
|
405
736
|
return {
|
|
406
737
|
config,
|
|
407
738
|
profileName,
|
|
408
739
|
apiBase,
|
|
740
|
+
requestedApiBase: normalizedRequestedApiBase,
|
|
409
741
|
jwt,
|
|
410
|
-
|
|
742
|
+
credentialKind,
|
|
743
|
+
credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
|
|
744
|
+
oauthAccessToken: profile.oauth_access_token,
|
|
745
|
+
oauthRefreshToken: profile.oauth_refresh_token,
|
|
746
|
+
oauthAccessExpiresAt: profile.oauth_access_expires_at,
|
|
747
|
+
oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
|
|
748
|
+
oauthClientId: profile.oauth_client_id,
|
|
749
|
+
oauthIssuer: profile.oauth_issuer,
|
|
750
|
+
oauthApiBase,
|
|
751
|
+
oauthResource,
|
|
752
|
+
oauthScopes: profile.oauth_scopes || [],
|
|
753
|
+
oauthUserId: profile.oauth_user_id,
|
|
411
754
|
desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
|
|
412
755
|
desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
|
|
413
756
|
agentMode,
|
|
@@ -447,11 +790,68 @@ export function getJwtSubject(jwt) {
|
|
|
447
790
|
}
|
|
448
791
|
}
|
|
449
792
|
|
|
793
|
+
export function getJwtCanonicalUserId(jwt) {
|
|
794
|
+
if (typeof jwt !== 'string' || !jwt) {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const parts = jwt.split('.');
|
|
799
|
+
if (parts.length !== 3) return null;
|
|
800
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
801
|
+
const candidate = payload?.app_metadata?.app_user_id
|
|
802
|
+
|| payload?.user_metadata?.notis_user_id
|
|
803
|
+
|| payload?.notis_user_id;
|
|
804
|
+
return typeof candidate === 'string' && candidate ? candidate : null;
|
|
805
|
+
} catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
450
810
|
export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
451
811
|
const expiration = getJwtExpiration(jwt);
|
|
452
812
|
return expiration !== null && expiration <= nowSeconds;
|
|
453
813
|
}
|
|
454
814
|
|
|
815
|
+
export function credentialIsExpired(
|
|
816
|
+
runtime,
|
|
817
|
+
profile = {},
|
|
818
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
819
|
+
) {
|
|
820
|
+
// Older callers and focused transport tests predate `credentialKind`.
|
|
821
|
+
// Infer only the legacy desktop/env shapes; OAuth must always opt in
|
|
822
|
+
// explicitly so a scoped token can never be mistaken for a Supabase JWT.
|
|
823
|
+
const credentialKind = runtime?.credentialKind
|
|
824
|
+
|| (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
|
|
825
|
+
switch (credentialKind) {
|
|
826
|
+
case 'oauth': {
|
|
827
|
+
const expiration = Number(profile.oauth_access_expires_at);
|
|
828
|
+
return !Number.isFinite(expiration) || expiration <= nowSeconds;
|
|
829
|
+
}
|
|
830
|
+
case 'env':
|
|
831
|
+
// NOTIS_JWT is a complete override. Never combine it with expiry
|
|
832
|
+
// metadata left behind by a desktop credential in the same profile.
|
|
833
|
+
// A token with no readable `exp` is a personal API key, which never
|
|
834
|
+
// expires, so let the server rather than the CLI reject it.
|
|
835
|
+
{
|
|
836
|
+
const expiration = getJwtExpiration(runtime?.jwt);
|
|
837
|
+
return expiration !== null && expiration <= nowSeconds;
|
|
838
|
+
}
|
|
839
|
+
case 'worktree':
|
|
840
|
+
case 'desktop': {
|
|
841
|
+
const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
842
|
+
// Every selected credential must carry an independently verifiable
|
|
843
|
+
// expiry. Missing or malformed expiry metadata fails closed.
|
|
844
|
+
if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
const expiration = Number(rawExpiration);
|
|
848
|
+
return !Number.isFinite(expiration) || expiration <= nowSeconds;
|
|
849
|
+
}
|
|
850
|
+
default:
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
455
855
|
export function workspacePath(appId) {
|
|
456
856
|
return join(WORKSPACE_DIR, appId);
|
|
457
857
|
}
|
package/src/runtime/transport.js
CHANGED
|
@@ -3,12 +3,14 @@ import { createReadStream } from 'node:fs';
|
|
|
3
3
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_PROFILE,
|
|
6
|
+
credentialIsExpired,
|
|
6
7
|
getProfile,
|
|
7
8
|
getJwtSubject,
|
|
8
9
|
isJwtExpired,
|
|
9
10
|
loadConfig,
|
|
10
11
|
} from './profiles.js';
|
|
11
12
|
import { createExpiredAuthError, createInvalidAuthHints } from './desktop-auth.js';
|
|
13
|
+
import { refreshOAuthCredential } from './oauth.js';
|
|
12
14
|
|
|
13
15
|
function escapeMultipartHeaderValue(value) {
|
|
14
16
|
return String(value ?? '')
|
|
@@ -155,16 +157,18 @@ function normalizeBackendError(status, payload, runtime) {
|
|
|
155
157
|
});
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
function reloadJwtFromConfig(runtime) {
|
|
159
|
-
if (runtime.
|
|
160
|
+
function reloadJwtFromConfig(runtime, loadedProfile = null) {
|
|
161
|
+
if (runtime.credentialKind === 'env' || runtime.credentialKind === 'oauth') {
|
|
160
162
|
return false;
|
|
161
163
|
}
|
|
162
164
|
const profileName = runtime.profileName || DEFAULT_PROFILE;
|
|
163
|
-
let profile;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
let profile = loadedProfile;
|
|
166
|
+
if (!profile) {
|
|
167
|
+
try {
|
|
168
|
+
profile = getProfile(loadConfig(runtime.worktreeRuntime), profileName);
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
168
172
|
}
|
|
169
173
|
const nextJwt = typeof profile.jwt === 'string' && profile.jwt ? profile.jwt : null;
|
|
170
174
|
if (!nextJwt || nextJwt === runtime.jwt) {
|
|
@@ -183,6 +187,7 @@ function reloadJwtFromConfig(runtime) {
|
|
|
183
187
|
});
|
|
184
188
|
}
|
|
185
189
|
runtime.jwt = nextJwt;
|
|
190
|
+
runtime.credentialKind = runtime.worktreeRuntime ? 'worktree' : 'desktop';
|
|
186
191
|
runtime.desktopAppName = profile.desktop_app_name;
|
|
187
192
|
runtime.desktopPid = profile.desktop_pid;
|
|
188
193
|
return true;
|
|
@@ -200,8 +205,25 @@ export async function httpRequest({
|
|
|
200
205
|
// token. Each CLI request only consumes the newest access token it synced to
|
|
201
206
|
// disk, avoiding refresh-token races between independent CLI processes and
|
|
202
207
|
// the running desktop session.
|
|
203
|
-
|
|
204
|
-
if (
|
|
208
|
+
let currentProfile;
|
|
209
|
+
if (runtime.credentialKind === 'oauth') {
|
|
210
|
+
if (requireAuth && credentialIsExpired(runtime, {
|
|
211
|
+
oauth_access_expires_at: runtime.oauthAccessExpiresAt,
|
|
212
|
+
})) {
|
|
213
|
+
await refreshOAuthCredential(runtime);
|
|
214
|
+
}
|
|
215
|
+
currentProfile = getProfile(
|
|
216
|
+
loadConfig(runtime.worktreeRuntime),
|
|
217
|
+
runtime.profileName,
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
currentProfile = getProfile(
|
|
221
|
+
loadConfig(runtime.worktreeRuntime),
|
|
222
|
+
runtime.profileName,
|
|
223
|
+
);
|
|
224
|
+
reloadJwtFromConfig(runtime, currentProfile);
|
|
225
|
+
}
|
|
226
|
+
if (requireAuth && credentialIsExpired(runtime, currentProfile)) {
|
|
205
227
|
throw createExpiredAuthError(runtime);
|
|
206
228
|
}
|
|
207
229
|
|
|
@@ -260,7 +282,9 @@ export async function httpRequest({
|
|
|
260
282
|
}
|
|
261
283
|
|
|
262
284
|
if (response.status === 401) {
|
|
263
|
-
const refreshed =
|
|
285
|
+
const refreshed = runtime.credentialKind === 'oauth'
|
|
286
|
+
? await refreshOAuthCredential(runtime)
|
|
287
|
+
: reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
|
|
264
288
|
if (refreshed) {
|
|
265
289
|
if (requireAuth && runtime.jwt) {
|
|
266
290
|
headers.Authorization = `Bearer ${runtime.jwt}`;
|
|
@@ -285,7 +309,13 @@ export async function httpRequest({
|
|
|
285
309
|
clearTimeout(timeout);
|
|
286
310
|
|
|
287
311
|
if (!response.ok) {
|
|
288
|
-
if (
|
|
312
|
+
if (
|
|
313
|
+
response.status === 401
|
|
314
|
+
&& credentialIsExpired(
|
|
315
|
+
runtime,
|
|
316
|
+
getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName),
|
|
317
|
+
)
|
|
318
|
+
) {
|
|
289
319
|
throw createExpiredAuthError(runtime);
|
|
290
320
|
}
|
|
291
321
|
throw normalizeBackendError(response.status, payload, runtime);
|