@notis_ai/cli 0.2.8 → 0.2.9
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 +8 -19
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +3 -0
- package/skills/notis-query/cli.md +1 -1
- package/src/command-specs/apps.js +155 -20
- package/src/command-specs/auth.js +107 -0
- 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/desktop-auth.js +22 -2
- package/src/runtime/oauth.js +1105 -0
- package/src/runtime/output.js +7 -0
- package/src/runtime/profiles.js +370 -8
- 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';
|
|
@@ -16,6 +25,15 @@ const LOCAL_DEFAULT_API_BASES = new Set([
|
|
|
16
25
|
'http://127.0.0.1:3001',
|
|
17
26
|
]);
|
|
18
27
|
const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
28
|
+
// Cross-process write lock over ~/.notis/config.json. Notis Desktop implements
|
|
29
|
+
// the same protocol independently in electron/src/cli-auth.ts (updateConfig);
|
|
30
|
+
// the `${configFile}.write-lock` directory name and these three timings must
|
|
31
|
+
// stay identical on both sides or `notis login` and desktop syncAuth stop
|
|
32
|
+
// excluding each other and clobber stored OAuth tokens. Guarded by the drift
|
|
33
|
+
// test in packages/cli/test/runtime-auth.test.js.
|
|
34
|
+
const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
35
|
+
const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
|
|
36
|
+
const CONFIG_WRITE_LOCK_POLL_MS = 10;
|
|
19
37
|
|
|
20
38
|
function clone(value) {
|
|
21
39
|
return JSON.parse(JSON.stringify(value));
|
|
@@ -43,6 +61,28 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
43
61
|
desktop_app_name:
|
|
44
62
|
typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
|
|
45
63
|
desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
|
|
64
|
+
oauth_access_token:
|
|
65
|
+
typeof profile.oauth_access_token === 'string' ? profile.oauth_access_token : undefined,
|
|
66
|
+
oauth_refresh_token:
|
|
67
|
+
typeof profile.oauth_refresh_token === 'string' ? profile.oauth_refresh_token : undefined,
|
|
68
|
+
oauth_access_expires_at:
|
|
69
|
+
typeof profile.oauth_access_expires_at === 'number' ? profile.oauth_access_expires_at : undefined,
|
|
70
|
+
oauth_refresh_expires_at:
|
|
71
|
+
typeof profile.oauth_refresh_expires_at === 'number' ? profile.oauth_refresh_expires_at : undefined,
|
|
72
|
+
oauth_client_id:
|
|
73
|
+
typeof profile.oauth_client_id === 'string' ? profile.oauth_client_id : undefined,
|
|
74
|
+
oauth_issuer:
|
|
75
|
+
typeof profile.oauth_issuer === 'string' ? profile.oauth_issuer : undefined,
|
|
76
|
+
oauth_api_base:
|
|
77
|
+
typeof profile.oauth_api_base === 'string' ? profile.oauth_api_base : undefined,
|
|
78
|
+
oauth_resource:
|
|
79
|
+
typeof profile.oauth_resource === 'string' ? profile.oauth_resource : undefined,
|
|
80
|
+
oauth_scopes:
|
|
81
|
+
Array.isArray(profile.oauth_scopes)
|
|
82
|
+
? profile.oauth_scopes.filter((scope) => typeof scope === 'string')
|
|
83
|
+
: undefined,
|
|
84
|
+
oauth_user_id:
|
|
85
|
+
typeof profile.oauth_user_id === 'string' ? profile.oauth_user_id : undefined,
|
|
46
86
|
};
|
|
47
87
|
}
|
|
48
88
|
|
|
@@ -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) {
|
|
@@ -335,6 +523,37 @@ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_EN
|
|
|
335
523
|
});
|
|
336
524
|
}
|
|
337
525
|
|
|
526
|
+
export function getOAuthResource(profile = {}) {
|
|
527
|
+
if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
|
|
528
|
+
return profile.oauth_resource.replace(/\/+$/, '');
|
|
529
|
+
}
|
|
530
|
+
if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
|
|
531
|
+
try {
|
|
532
|
+
const parts = profile.oauth_access_token.split('.');
|
|
533
|
+
if (parts.length === 3) {
|
|
534
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
535
|
+
if (typeof payload.aud === 'string' && payload.aud) {
|
|
536
|
+
return payload.aud.replace(/\/+$/, '');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// Legacy tokens without a readable audience fall back below.
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function getOAuthApiBase(profile = {}) {
|
|
547
|
+
if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
|
|
548
|
+
return profile.oauth_api_base.replace(/\/+$/, '');
|
|
549
|
+
}
|
|
550
|
+
const resource = getOAuthResource(profile);
|
|
551
|
+
if (resource?.endsWith('/cli')) {
|
|
552
|
+
return resource.slice(0, -'/cli'.length);
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
338
557
|
export function resolveRuntimeProfile(
|
|
339
558
|
globalOptions = {},
|
|
340
559
|
{ requireAuth = true, includeDebugEntitlementOverride = true } = {},
|
|
@@ -355,11 +574,81 @@ export function resolveRuntimeProfile(
|
|
|
355
574
|
hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
|
|
356
575
|
});
|
|
357
576
|
}
|
|
358
|
-
|
|
577
|
+
let apiBase = worktreeRuntime
|
|
359
578
|
? worktreeRuntime.api_base
|
|
360
579
|
: getApiBase(config, profileName, globalOptions.apiBase);
|
|
361
|
-
const jwt = worktreeRuntime ? getProfile(config, profileName).jwt : getJwt(config, profileName);
|
|
362
580
|
const profile = getProfile(config, profileName);
|
|
581
|
+
const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
|
|
582
|
+
const desktopJwt = profile.jwt;
|
|
583
|
+
const oauthJwt = profile.oauth_access_token;
|
|
584
|
+
let jwt;
|
|
585
|
+
let credentialKind;
|
|
586
|
+
|
|
587
|
+
if (worktreeRuntime && desktopJwt) {
|
|
588
|
+
jwt = desktopJwt;
|
|
589
|
+
credentialKind = 'worktree';
|
|
590
|
+
} else if (envJwt) {
|
|
591
|
+
jwt = envJwt;
|
|
592
|
+
credentialKind = 'env';
|
|
593
|
+
} else if (
|
|
594
|
+
desktopJwt
|
|
595
|
+
&& !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
|
|
596
|
+
) {
|
|
597
|
+
jwt = desktopJwt;
|
|
598
|
+
credentialKind = 'desktop';
|
|
599
|
+
} else if (
|
|
600
|
+
oauthJwt
|
|
601
|
+
&& !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
|
|
602
|
+
) {
|
|
603
|
+
jwt = oauthJwt;
|
|
604
|
+
credentialKind = 'oauth';
|
|
605
|
+
} else if (oauthJwt && profile.oauth_refresh_token) {
|
|
606
|
+
// A lapsed OAuth access token remains usable through its rotating refresh
|
|
607
|
+
// token and must outrank an abandoned, expired Desktop credential.
|
|
608
|
+
jwt = oauthJwt;
|
|
609
|
+
credentialKind = 'oauth';
|
|
610
|
+
} else if (desktopJwt) {
|
|
611
|
+
jwt = desktopJwt;
|
|
612
|
+
credentialKind = 'desktop';
|
|
613
|
+
} else if (oauthJwt) {
|
|
614
|
+
// Preserve the OAuth credential so transport can refresh it before use.
|
|
615
|
+
jwt = oauthJwt;
|
|
616
|
+
credentialKind = 'oauth';
|
|
617
|
+
}
|
|
618
|
+
const oauthApiBase = getOAuthApiBase(profile);
|
|
619
|
+
const oauthResource = getOAuthResource(profile);
|
|
620
|
+
const normalizedRequestedApiBase = requestedApiBase
|
|
621
|
+
? requestedApiBase.replace(/\/+$/, '')
|
|
622
|
+
: null;
|
|
623
|
+
if (
|
|
624
|
+
requireAuth
|
|
625
|
+
&& credentialKind === 'oauth'
|
|
626
|
+
&& normalizedRequestedApiBase
|
|
627
|
+
&& oauthApiBase
|
|
628
|
+
&& normalizedRequestedApiBase !== oauthApiBase
|
|
629
|
+
) {
|
|
630
|
+
const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
631
|
+
throw new CliError({
|
|
632
|
+
code: 'oauth_api_target_mismatch',
|
|
633
|
+
message: (
|
|
634
|
+
`The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
|
|
635
|
+
+ `not ${normalizedRequestedApiBase}`
|
|
636
|
+
),
|
|
637
|
+
exitCode: EXIT_CODES.auth,
|
|
638
|
+
hints: [{
|
|
639
|
+
command: [
|
|
640
|
+
'npx --package @notis_ai/cli@latest -- notis',
|
|
641
|
+
`--profile ${quoteShellArgument(profileName)}`,
|
|
642
|
+
`--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
|
|
643
|
+
'login --force',
|
|
644
|
+
].join(' '),
|
|
645
|
+
reason: 'Authorize a separate OAuth grant for the requested Notis environment',
|
|
646
|
+
}],
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
|
|
650
|
+
apiBase = oauthApiBase;
|
|
651
|
+
}
|
|
363
652
|
const agentMode = isAgentMode(globalOptions);
|
|
364
653
|
const nonInteractive = isNonInteractive(globalOptions);
|
|
365
654
|
const outputMode = resolveOutputMode(globalOptions);
|
|
@@ -386,7 +675,11 @@ export function resolveRuntimeProfile(
|
|
|
386
675
|
}
|
|
387
676
|
if (
|
|
388
677
|
worktreeRuntime?.expected_user_id &&
|
|
389
|
-
|
|
678
|
+
(
|
|
679
|
+
credentialKind === 'oauth'
|
|
680
|
+
? profile.oauth_user_id
|
|
681
|
+
: getJwtSubject(jwt)
|
|
682
|
+
) !== worktreeRuntime.expected_user_id
|
|
390
683
|
) {
|
|
391
684
|
throw new CliError({
|
|
392
685
|
code: 'dev_runtime_identity_mismatch',
|
|
@@ -401,13 +694,25 @@ export function resolveRuntimeProfile(
|
|
|
401
694
|
|
|
402
695
|
// An explicit NOTIS_JWT is a complete credential override. Use it verbatim
|
|
403
696
|
// and never replace it with a token later synced by the desktop profile.
|
|
404
|
-
const usingEnvJwt =
|
|
697
|
+
const usingEnvJwt = credentialKind === 'env';
|
|
405
698
|
return {
|
|
406
699
|
config,
|
|
407
700
|
profileName,
|
|
408
701
|
apiBase,
|
|
702
|
+
requestedApiBase: normalizedRequestedApiBase,
|
|
409
703
|
jwt,
|
|
410
|
-
|
|
704
|
+
credentialKind,
|
|
705
|
+
credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
|
|
706
|
+
oauthAccessToken: profile.oauth_access_token,
|
|
707
|
+
oauthRefreshToken: profile.oauth_refresh_token,
|
|
708
|
+
oauthAccessExpiresAt: profile.oauth_access_expires_at,
|
|
709
|
+
oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
|
|
710
|
+
oauthClientId: profile.oauth_client_id,
|
|
711
|
+
oauthIssuer: profile.oauth_issuer,
|
|
712
|
+
oauthApiBase,
|
|
713
|
+
oauthResource,
|
|
714
|
+
oauthScopes: profile.oauth_scopes || [],
|
|
715
|
+
oauthUserId: profile.oauth_user_id,
|
|
411
716
|
desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
|
|
412
717
|
desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
|
|
413
718
|
agentMode,
|
|
@@ -447,11 +752,68 @@ export function getJwtSubject(jwt) {
|
|
|
447
752
|
}
|
|
448
753
|
}
|
|
449
754
|
|
|
755
|
+
export function getJwtCanonicalUserId(jwt) {
|
|
756
|
+
if (typeof jwt !== 'string' || !jwt) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
const parts = jwt.split('.');
|
|
761
|
+
if (parts.length !== 3) return null;
|
|
762
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
763
|
+
const candidate = payload?.app_metadata?.app_user_id
|
|
764
|
+
|| payload?.user_metadata?.notis_user_id
|
|
765
|
+
|| payload?.notis_user_id;
|
|
766
|
+
return typeof candidate === 'string' && candidate ? candidate : null;
|
|
767
|
+
} catch {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
450
772
|
export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
451
773
|
const expiration = getJwtExpiration(jwt);
|
|
452
774
|
return expiration !== null && expiration <= nowSeconds;
|
|
453
775
|
}
|
|
454
776
|
|
|
777
|
+
export function credentialIsExpired(
|
|
778
|
+
runtime,
|
|
779
|
+
profile = {},
|
|
780
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
781
|
+
) {
|
|
782
|
+
// Older callers and focused transport tests predate `credentialKind`.
|
|
783
|
+
// Infer only the legacy desktop/env shapes; OAuth must always opt in
|
|
784
|
+
// explicitly so a scoped token can never be mistaken for a Supabase JWT.
|
|
785
|
+
const credentialKind = runtime?.credentialKind
|
|
786
|
+
|| (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
|
|
787
|
+
switch (credentialKind) {
|
|
788
|
+
case 'oauth': {
|
|
789
|
+
const expiration = Number(profile.oauth_access_expires_at);
|
|
790
|
+
return !Number.isFinite(expiration) || expiration <= nowSeconds;
|
|
791
|
+
}
|
|
792
|
+
case 'env':
|
|
793
|
+
// NOTIS_JWT is a complete override. Never combine it with expiry
|
|
794
|
+
// metadata left behind by a desktop credential in the same profile.
|
|
795
|
+
// A token with no readable `exp` is a personal API key, which never
|
|
796
|
+
// expires, so let the server rather than the CLI reject it.
|
|
797
|
+
{
|
|
798
|
+
const expiration = getJwtExpiration(runtime?.jwt);
|
|
799
|
+
return expiration !== null && expiration <= nowSeconds;
|
|
800
|
+
}
|
|
801
|
+
case 'worktree':
|
|
802
|
+
case 'desktop': {
|
|
803
|
+
const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
804
|
+
// Every selected credential must carry an independently verifiable
|
|
805
|
+
// expiry. Missing or malformed expiry metadata fails closed.
|
|
806
|
+
if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
|
|
807
|
+
return true;
|
|
808
|
+
}
|
|
809
|
+
const expiration = Number(rawExpiration);
|
|
810
|
+
return !Number.isFinite(expiration) || expiration <= nowSeconds;
|
|
811
|
+
}
|
|
812
|
+
default:
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
455
817
|
export function workspacePath(appId) {
|
|
456
818
|
return join(WORKSPACE_DIR, appId);
|
|
457
819
|
}
|
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);
|