@notis_ai/cli 0.2.10 → 0.2.12
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 +73 -9
- package/dist/scaffolds.json +1 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +1 -1
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +56 -16
- package/skills/notis-onboarding/BRIEF.md +24 -5
- package/skills/notis-query/cli.md +1 -1
- package/src/cli.js +27 -2
- package/src/command-specs/apps.js +7 -29
- package/src/command-specs/auth.js +15 -20
- package/src/command-specs/index.js +3 -0
- package/src/command-specs/meta.js +29 -19
- package/src/command-specs/onboarding.js +122 -200
- package/src/command-specs/profile.js +358 -0
- package/src/runtime/app-platform.js +5 -5
- package/src/runtime/auth-recovery.js +100 -0
- package/src/runtime/oauth.js +148 -44
- package/src/runtime/profiles.js +395 -221
- package/src/runtime/transport.js +84 -29
- package/src/runtime/desktop-auth.js +0 -162
package/src/runtime/profiles.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, join, parse, resolve } from 'node:path';
|
|
13
13
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
14
|
-
import {
|
|
14
|
+
import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
|
|
15
15
|
|
|
16
16
|
export const CONFIG_DIR = join(homedir(), '.notis');
|
|
17
17
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
@@ -19,16 +19,25 @@ export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
|
|
|
19
19
|
export const DEFAULT_API_BASE = 'https://api.notis.ai';
|
|
20
20
|
export const BETA_API_BASE = 'https://api-beta.notis.ai';
|
|
21
21
|
export const DEFAULT_PROFILE = 'default';
|
|
22
|
+
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
23
|
+
const LEGACY_DESKTOP_PROFILE_KEYS = [
|
|
24
|
+
'jwt',
|
|
25
|
+
'auth_mode',
|
|
26
|
+
'refresh_token',
|
|
27
|
+
'access_expires_at',
|
|
28
|
+
'refresh_expires_at',
|
|
29
|
+
'desktop_app_name',
|
|
30
|
+
'desktop_pid',
|
|
31
|
+
];
|
|
22
32
|
const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
|
|
23
33
|
const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
|
|
24
34
|
const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
25
35
|
const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
|
|
26
|
-
// Cross-process write lock over ~/.notis/config.json.
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// test in packages/cli/test/runtime-auth.test.js.
|
|
36
|
+
// Cross-process write lock over ~/.notis/config.json. Independent `notis`
|
|
37
|
+
// processes (a `login` in one terminal and a `tools exec` in another) all
|
|
38
|
+
// rewrite this file, so the lock directory name and these three timings are the
|
|
39
|
+
// whole coordination protocol. Guarded by the concurrency test in
|
|
40
|
+
// packages/cli/test/runtime-auth.test.js.
|
|
32
41
|
const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
33
42
|
const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
|
|
34
43
|
const CONFIG_WRITE_LOCK_POLL_MS = 10;
|
|
@@ -37,61 +46,107 @@ function clone(value) {
|
|
|
37
46
|
return JSON.parse(JSON.stringify(value));
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
export function isValidProfileName(profileName) {
|
|
50
|
+
return typeof profileName === 'string'
|
|
51
|
+
&& PROFILE_NAME_PATTERN.test(profileName)
|
|
52
|
+
&& !Object.hasOwn(Object.prototype, profileName);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSafeStoredProfileName(profileName) {
|
|
56
|
+
return typeof profileName === 'string'
|
|
57
|
+
&& profileName.length > 0
|
|
58
|
+
&& !Object.hasOwn(Object.prototype, profileName);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function profileExists(config, profileName) {
|
|
62
|
+
const normalized = normalizeConfig(config);
|
|
63
|
+
return isSafeStoredProfileName(profileName)
|
|
64
|
+
&& Object.hasOwn(normalized.profiles, profileName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertValidProfileName(profileName) {
|
|
68
|
+
if (isValidProfileName(profileName)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
throw new CliError({
|
|
72
|
+
code: 'profile_name_invalid',
|
|
73
|
+
message: 'CLI profile names must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens (maximum 64 characters)',
|
|
74
|
+
exitCode: EXIT_CODES.usage,
|
|
75
|
+
hints: [
|
|
76
|
+
{ command: 'notis profile list', reason: 'See the valid profiles already on this machine' },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A profile is one account paired with one API endpoint.
|
|
83
|
+
*
|
|
84
|
+
* Only two credential shapes survive normalization: the browser-authorized
|
|
85
|
+
* OAuth grant (`oauth_*`) that owns every real account, and the loopback
|
|
86
|
+
* credential `./dev.sh` mints for its worktree test user (`dev_*`). Notis
|
|
87
|
+
* Desktop used to write a Supabase access token here as `jwt`, plus the
|
|
88
|
+
* `desktop_*` liveness hints the CLI used to tell people which app to reopen.
|
|
89
|
+
* Dropping those keys on read is what actually retires that path: a config
|
|
90
|
+
* left behind by an older Desktop build cannot silently keep authenticating
|
|
91
|
+
* the CLI with a credential nothing renews anymore.
|
|
92
|
+
*/
|
|
93
|
+
function normalizeProfile(rawProfile = {}) {
|
|
94
|
+
const raw = rawProfile && typeof rawProfile === 'object' ? rawProfile : {};
|
|
95
|
+
return {
|
|
96
|
+
api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
|
|
97
|
+
beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
|
|
98
|
+
label: typeof raw.label === 'string' ? raw.label : undefined,
|
|
99
|
+
dev_access_token:
|
|
100
|
+
typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
|
|
101
|
+
dev_access_expires_at:
|
|
102
|
+
typeof raw.dev_access_expires_at === 'number' ? raw.dev_access_expires_at : undefined,
|
|
103
|
+
dev_user_id: typeof raw.dev_user_id === 'string' ? raw.dev_user_id : undefined,
|
|
104
|
+
dev_workspace_root:
|
|
105
|
+
typeof raw.dev_workspace_root === 'string' ? raw.dev_workspace_root : undefined,
|
|
106
|
+
oauth_access_token:
|
|
107
|
+
typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
|
|
108
|
+
oauth_refresh_token:
|
|
109
|
+
typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
|
|
110
|
+
oauth_access_expires_at:
|
|
111
|
+
typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
|
|
112
|
+
oauth_refresh_expires_at:
|
|
113
|
+
typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
|
|
114
|
+
oauth_client_id:
|
|
115
|
+
typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
|
|
116
|
+
oauth_issuer: typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
|
|
117
|
+
oauth_api_base: typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
|
|
118
|
+
oauth_resource: typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
|
|
119
|
+
oauth_scopes:
|
|
120
|
+
Array.isArray(raw.oauth_scopes)
|
|
121
|
+
? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
|
|
122
|
+
: undefined,
|
|
123
|
+
oauth_user_id: typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
40
127
|
export function normalizeConfig(rawConfig = {}) {
|
|
41
128
|
const raw = rawConfig && typeof rawConfig === 'object' ? clone(rawConfig) : {};
|
|
42
129
|
|
|
43
130
|
if (raw.profiles && typeof raw.profiles === 'object') {
|
|
44
131
|
const profiles = {};
|
|
45
132
|
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
46
|
-
|
|
133
|
+
// Older CLI releases accepted arbitrary profile names. Keep every safe
|
|
134
|
+
// own-property name readable; the stricter grammar applies only when a
|
|
135
|
+
// command creates a new profile.
|
|
136
|
+
if (!isSafeStoredProfileName(name) || !profile || typeof profile !== 'object') {
|
|
47
137
|
continue;
|
|
48
138
|
}
|
|
49
|
-
profiles[name] =
|
|
50
|
-
jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
|
|
51
|
-
api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
|
|
52
|
-
beta: typeof profile.beta === 'boolean' ? profile.beta : undefined,
|
|
53
|
-
auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
|
|
54
|
-
refresh_token:
|
|
55
|
-
typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
|
|
56
|
-
access_expires_at:
|
|
57
|
-
typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
|
|
58
|
-
refresh_expires_at:
|
|
59
|
-
typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
|
|
60
|
-
desktop_app_name:
|
|
61
|
-
typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
|
|
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,
|
|
85
|
-
};
|
|
139
|
+
profiles[name] = normalizeProfile(profile);
|
|
86
140
|
}
|
|
87
141
|
|
|
88
|
-
if (!profiles
|
|
142
|
+
if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
|
|
89
143
|
profiles[DEFAULT_PROFILE] = {};
|
|
90
144
|
}
|
|
91
145
|
|
|
92
146
|
return {
|
|
93
147
|
current_profile:
|
|
94
|
-
typeof raw.current_profile === 'string'
|
|
148
|
+
typeof raw.current_profile === 'string'
|
|
149
|
+
&& Object.hasOwn(profiles, raw.current_profile)
|
|
95
150
|
? raw.current_profile
|
|
96
151
|
: DEFAULT_PROFILE,
|
|
97
152
|
profiles,
|
|
@@ -100,47 +155,44 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
100
155
|
|
|
101
156
|
return {
|
|
102
157
|
current_profile: DEFAULT_PROFILE,
|
|
103
|
-
profiles: {
|
|
104
|
-
[DEFAULT_PROFILE]: {
|
|
105
|
-
jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
|
|
106
|
-
api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
|
|
107
|
-
beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
|
|
108
|
-
auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
|
|
109
|
-
refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
|
|
110
|
-
access_expires_at:
|
|
111
|
-
typeof raw.access_expires_at === 'number' ? raw.access_expires_at : undefined,
|
|
112
|
-
refresh_expires_at:
|
|
113
|
-
typeof raw.refresh_expires_at === 'number' ? raw.refresh_expires_at : undefined,
|
|
114
|
-
desktop_app_name:
|
|
115
|
-
typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
|
|
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,
|
|
139
|
-
},
|
|
140
|
-
},
|
|
158
|
+
profiles: { [DEFAULT_PROFILE]: normalizeProfile(raw) },
|
|
141
159
|
};
|
|
142
160
|
}
|
|
143
161
|
|
|
162
|
+
export function profileHasCredential(profile = {}) {
|
|
163
|
+
return Boolean(profile.oauth_access_token || profile.dev_access_token);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Names every profile a switch can land on, newest config order preserved.
|
|
168
|
+
* `notis profile use` and `--profile` both validate against this.
|
|
169
|
+
*/
|
|
170
|
+
export function listProfiles(config) {
|
|
171
|
+
const normalized = normalizeConfig(config);
|
|
172
|
+
return Object.entries(normalized.profiles).map(([name, profile]) => ({
|
|
173
|
+
name,
|
|
174
|
+
active: name === normalized.current_profile,
|
|
175
|
+
// Report the endpoint the profile was created against, including the
|
|
176
|
+
// loopback address of a `./dev.sh` profile. getApiBase resolves a stale
|
|
177
|
+
// loopback value to the live API for routing; showing that here would
|
|
178
|
+
// claim a dev profile targets production, which it must never do.
|
|
179
|
+
// OAuth metadata owns the route for an OAuth profile. An older Desktop
|
|
180
|
+
// release may have left a conflicting api_base behind, but commands ignore
|
|
181
|
+
// that legacy value and so must profile inspection.
|
|
182
|
+
api_base: profile.oauth_access_token
|
|
183
|
+
? getOAuthApiBase(profile) || resolveDefaultLiveApiBase(profile)
|
|
184
|
+
: profile.api_base || resolveDefaultLiveApiBase(profile),
|
|
185
|
+
label: profile.label || null,
|
|
186
|
+
credential_kind: profile.oauth_access_token
|
|
187
|
+
? 'oauth'
|
|
188
|
+
: profile.dev_access_token
|
|
189
|
+
? 'dev'
|
|
190
|
+
: null,
|
|
191
|
+
user_id: profile.oauth_user_id || profile.dev_user_id || null,
|
|
192
|
+
authenticated: profileHasCredential(profile),
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
144
196
|
function readJsonFile(path) {
|
|
145
197
|
try {
|
|
146
198
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
@@ -189,22 +241,27 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
189
241
|
|
|
190
242
|
if (!runtimePath) {
|
|
191
243
|
if (routing?.mode === 'local-only') {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
244
|
+
return {
|
|
245
|
+
unavailable: new CliError({
|
|
246
|
+
code: 'dev_runtime_unavailable',
|
|
247
|
+
message: 'This worktree is local-only, but its dev.sh runtime is not active',
|
|
248
|
+
exitCode: EXIT_CODES.network,
|
|
249
|
+
hints: [
|
|
250
|
+
{ message: 'Start ./dev.sh in this worktree, then retry the command.' },
|
|
251
|
+
{ command: 'notis profile list', reason: 'Run against a live account profile instead' },
|
|
252
|
+
{ message: `Routing policy: ${routingPath}` },
|
|
253
|
+
],
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
201
256
|
}
|
|
202
257
|
return null;
|
|
203
258
|
}
|
|
204
259
|
|
|
205
260
|
const runtime = readJsonFile(runtimePath);
|
|
206
261
|
const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
|
|
207
|
-
const
|
|
262
|
+
const profile = typeof runtime?.profile === 'string' ? runtime.profile.trim() : '';
|
|
263
|
+
const devAccessToken =
|
|
264
|
+
typeof runtime?.dev_access_token === 'string' ? runtime.dev_access_token.trim() : '';
|
|
208
265
|
const appDevSessionsFile =
|
|
209
266
|
typeof runtime?.app_dev_sessions_file === 'string' && runtime.app_dev_sessions_file.trim()
|
|
210
267
|
? runtime.app_dev_sessions_file.trim()
|
|
@@ -217,24 +274,33 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
217
274
|
if (
|
|
218
275
|
runtime?.mode !== 'local-only' ||
|
|
219
276
|
!isLocalApiBase(apiBase) ||
|
|
220
|
-
!
|
|
277
|
+
!profile ||
|
|
278
|
+
!devAccessToken ||
|
|
221
279
|
!processIsAlive(pid)
|
|
222
280
|
) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
281
|
+
// Reported rather than thrown: an unusable lease must not stop someone
|
|
282
|
+
// from listing profiles or switching to a live account from inside the
|
|
283
|
+
// same checkout. resolveRuntimeProfile raises it only when a command is
|
|
284
|
+
// about to route through the dead local backend.
|
|
285
|
+
return {
|
|
286
|
+
unavailable: new CliError({
|
|
287
|
+
code: 'dev_runtime_unavailable',
|
|
288
|
+
message: 'The local-only worktree runtime is stale or invalid',
|
|
289
|
+
exitCode: EXIT_CODES.network,
|
|
290
|
+
hints: [
|
|
291
|
+
{ message: 'Restart ./dev.sh in this worktree, then retry the command.' },
|
|
292
|
+
{ command: 'notis profile list', reason: 'Run against a live account profile instead' },
|
|
293
|
+
{ message: `Runtime lease: ${runtimePath}` },
|
|
294
|
+
],
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
232
297
|
}
|
|
233
298
|
|
|
234
299
|
return {
|
|
235
300
|
...runtime,
|
|
236
301
|
api_base: apiBase,
|
|
237
|
-
|
|
302
|
+
profile,
|
|
303
|
+
dev_access_token: devAccessToken,
|
|
238
304
|
app_dev_sessions_file: resolve(dirname(runtimePath), appDevSessionsFile),
|
|
239
305
|
desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
|
|
240
306
|
runtime_path: runtimePath,
|
|
@@ -242,10 +308,12 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
|
242
308
|
};
|
|
243
309
|
}
|
|
244
310
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Real account profiles live in one shared file. A `./dev.sh` worktree keeps
|
|
313
|
+
* its synthetic profile credential in the worktree-owned runtime lease, so an
|
|
314
|
+
* older CLI process cannot normalize it away while rewriting this file.
|
|
315
|
+
*/
|
|
316
|
+
export function resolveConfigFile() {
|
|
249
317
|
const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
|
|
250
318
|
if (envConfigFile) {
|
|
251
319
|
return resolve(envConfigFile);
|
|
@@ -253,13 +321,13 @@ export function resolveConfigFile(runtime = null) {
|
|
|
253
321
|
return CONFIG_FILE;
|
|
254
322
|
}
|
|
255
323
|
|
|
256
|
-
export function loadConfig(
|
|
257
|
-
const configFile = resolveConfigFile(
|
|
324
|
+
export function loadConfig() {
|
|
325
|
+
const configFile = resolveConfigFile();
|
|
258
326
|
if (!existsSync(configFile)) {
|
|
259
327
|
return normalizeConfig({});
|
|
260
328
|
}
|
|
261
329
|
|
|
262
|
-
//
|
|
330
|
+
// Concurrent `notis` processes rewrite this file while others read it, so a
|
|
263
331
|
// torn or corrupt read is expected rather than exceptional. Degrade to
|
|
264
332
|
// "unauthenticated", which surfaces the actionable auth_missing error instead
|
|
265
333
|
// of a raw SyntaxError from deep inside the runtime.
|
|
@@ -271,9 +339,33 @@ export function loadConfig(runtime = null) {
|
|
|
271
339
|
}
|
|
272
340
|
|
|
273
341
|
function writeConfig(configFile, config) {
|
|
342
|
+
let rawConfig = null;
|
|
343
|
+
try {
|
|
344
|
+
rawConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
345
|
+
} catch {
|
|
346
|
+
// A missing or corrupt file has no upgrade fields to preserve.
|
|
347
|
+
}
|
|
348
|
+
const normalized = normalizeConfig(config);
|
|
349
|
+
const persisted = clone(normalized);
|
|
350
|
+
const rawProfiles = rawConfig?.profiles && typeof rawConfig.profiles === 'object'
|
|
351
|
+
? rawConfig.profiles
|
|
352
|
+
: { [DEFAULT_PROFILE]: rawConfig };
|
|
353
|
+
for (const [name, profile] of Object.entries(persisted.profiles)) {
|
|
354
|
+
const rawProfile = Object.hasOwn(rawProfiles || {}, name) ? rawProfiles[name] : null;
|
|
355
|
+
if (!rawProfile || typeof rawProfile !== 'object') continue;
|
|
356
|
+
for (const key of LEGACY_DESKTOP_PROFILE_KEYS) {
|
|
357
|
+
if (Object.hasOwn(rawProfile, key)) {
|
|
358
|
+
profile[key] = rawProfile[key];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
writeRawConfig(configFile, persisted);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function writeRawConfig(configFile, config) {
|
|
274
366
|
mkdirSync(dirname(configFile), { recursive: true });
|
|
275
367
|
const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
|
|
276
|
-
writeFileSync(temporaryFile, JSON.stringify(
|
|
368
|
+
writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
277
369
|
renameSync(temporaryFile, configFile);
|
|
278
370
|
}
|
|
279
371
|
|
|
@@ -319,8 +411,8 @@ function publishConfigWriteLock(lockDirectory, ownerId) {
|
|
|
319
411
|
}
|
|
320
412
|
}
|
|
321
413
|
|
|
322
|
-
function withConfigWriteLock(
|
|
323
|
-
const configFile = resolveConfigFile(
|
|
414
|
+
function withConfigWriteLock(callback) {
|
|
415
|
+
const configFile = resolveConfigFile();
|
|
324
416
|
const lockDirectory = `${configFile}.write-lock`;
|
|
325
417
|
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
326
418
|
const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
|
|
@@ -376,15 +468,15 @@ function withConfigWriteLock(runtime, callback) {
|
|
|
376
468
|
}
|
|
377
469
|
}
|
|
378
470
|
|
|
379
|
-
export function saveConfig(config
|
|
380
|
-
return withConfigWriteLock(
|
|
471
|
+
export function saveConfig(config) {
|
|
472
|
+
return withConfigWriteLock((configFile) => {
|
|
381
473
|
writeConfig(configFile, config);
|
|
382
474
|
});
|
|
383
475
|
}
|
|
384
476
|
|
|
385
|
-
export function updateConfig(updater
|
|
386
|
-
return withConfigWriteLock(
|
|
387
|
-
const current = loadConfig(
|
|
477
|
+
export function updateConfig(updater) {
|
|
478
|
+
return withConfigWriteLock((configFile) => {
|
|
479
|
+
const current = loadConfig();
|
|
388
480
|
const updated = updater(normalizeConfig(current));
|
|
389
481
|
const next = normalizeConfig(updated ?? current);
|
|
390
482
|
writeConfig(configFile, next);
|
|
@@ -392,22 +484,112 @@ export function updateConfig(updater, runtime = null) {
|
|
|
392
484
|
});
|
|
393
485
|
}
|
|
394
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Remove only worktree-owned profiles without normalizing the rest of the
|
|
489
|
+
* shared file. Archive cleanup can run before a packaged Desktop upgrade has
|
|
490
|
+
* migrated its legacy `jwt`; preserving unknown/raw fields here keeps that
|
|
491
|
+
* migrate-then-strip handoff intact.
|
|
492
|
+
*/
|
|
493
|
+
export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
|
|
494
|
+
return withConfigWriteLock((configFile) => {
|
|
495
|
+
let raw;
|
|
496
|
+
try {
|
|
497
|
+
raw = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
498
|
+
} catch {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
if (!raw || typeof raw !== 'object' || !raw.profiles || typeof raw.profiles !== 'object') {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const removed = [];
|
|
506
|
+
for (const name of profileNames) {
|
|
507
|
+
const profile = Object.hasOwn(raw.profiles, name) ? raw.profiles[name] : null;
|
|
508
|
+
if (
|
|
509
|
+
!profile
|
|
510
|
+
|| typeof profile !== 'object'
|
|
511
|
+
|| profile.dev_workspace_root !== workspaceRoot
|
|
512
|
+
) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
delete raw.profiles[name];
|
|
516
|
+
removed.push(name);
|
|
517
|
+
if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
|
|
518
|
+
}
|
|
519
|
+
if (removed.length > 0) writeRawConfig(configFile, raw);
|
|
520
|
+
return removed;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
395
524
|
export function getProfile(config, profileName) {
|
|
396
525
|
const normalized = normalizeConfig(config);
|
|
397
|
-
return normalized.profiles
|
|
526
|
+
return Object.hasOwn(normalized.profiles, profileName)
|
|
527
|
+
? normalized.profiles[profileName]
|
|
528
|
+
: {};
|
|
398
529
|
}
|
|
399
530
|
|
|
400
531
|
export function getCurrentProfileName(config, preferredName) {
|
|
401
532
|
const normalized = normalizeConfig(config);
|
|
402
|
-
if (preferredName && normalized.profiles
|
|
533
|
+
if (preferredName && Object.hasOwn(normalized.profiles, preferredName)) {
|
|
403
534
|
return preferredName;
|
|
404
535
|
}
|
|
405
536
|
return normalized.current_profile || DEFAULT_PROFILE;
|
|
406
537
|
}
|
|
407
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Resolve which profile this invocation runs as.
|
|
541
|
+
*
|
|
542
|
+
* An explicit `--profile` / `NOTIS_PROFILE` always wins, including inside a
|
|
543
|
+
* `./dev.sh` worktree: naming another account is the documented way to reach
|
|
544
|
+
* production from a checkout whose local backend is wedged. With nothing
|
|
545
|
+
* explicit, a live worktree lease selects its own dev profile, and otherwise
|
|
546
|
+
* the switch persisted by `notis profile use` applies.
|
|
547
|
+
*/
|
|
548
|
+
export function resolveProfileSelection(
|
|
549
|
+
globalOptions = {},
|
|
550
|
+
worktreeRuntime = null,
|
|
551
|
+
config,
|
|
552
|
+
{ allowUnknownProfile = false } = {},
|
|
553
|
+
) {
|
|
554
|
+
const normalized = normalizeConfig(config);
|
|
555
|
+
const requested = globalOptions.profile || process.env.NOTIS_PROFILE || '';
|
|
556
|
+
if (requested) {
|
|
557
|
+
const existingProfile = Object.hasOwn(normalized.profiles, requested);
|
|
558
|
+
if (!existingProfile) {
|
|
559
|
+
// Existing profiles may have names accepted by earlier releases. Only a
|
|
560
|
+
// new profile created by login/start must satisfy today's grammar.
|
|
561
|
+
assertValidProfileName(requested);
|
|
562
|
+
}
|
|
563
|
+
if (!existingProfile && !allowUnknownProfile) {
|
|
564
|
+
throw new CliError({
|
|
565
|
+
code: 'profile_unknown',
|
|
566
|
+
message: `No CLI profile named "${requested}"`,
|
|
567
|
+
exitCode: EXIT_CODES.usage,
|
|
568
|
+
details: { known_profiles: Object.keys(normalized.profiles) },
|
|
569
|
+
hints: [
|
|
570
|
+
{ command: 'notis profile list', reason: 'See which profiles this machine has' },
|
|
571
|
+
{
|
|
572
|
+
command: `notis login --profile ${quoteShellArgument(requested)}`,
|
|
573
|
+
reason: 'Authorize a new account under this profile name',
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return { profileName: requested, source: 'explicit' };
|
|
579
|
+
}
|
|
580
|
+
if (worktreeRuntime?.profile) {
|
|
581
|
+
return { profileName: worktreeRuntime.profile, source: 'worktree' };
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
profileName: normalized.current_profile || DEFAULT_PROFILE,
|
|
585
|
+
source: 'current',
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
408
589
|
export function ensureProfile(config, profileName) {
|
|
409
590
|
const normalized = normalizeConfig(config);
|
|
410
|
-
if (!normalized.profiles
|
|
591
|
+
if (!Object.hasOwn(normalized.profiles, profileName)) {
|
|
592
|
+
assertValidProfileName(profileName);
|
|
411
593
|
normalized.profiles[profileName] = {};
|
|
412
594
|
}
|
|
413
595
|
return normalized;
|
|
@@ -440,10 +622,10 @@ function isLiveApiBase(value) {
|
|
|
440
622
|
/**
|
|
441
623
|
* Pick the live Notis API for this profile.
|
|
442
624
|
*
|
|
443
|
-
* Beta users (`users.beta = true`, mirrored onto the
|
|
444
|
-
*
|
|
445
|
-
* api.notis.ai. Localhost is never a default — only
|
|
446
|
-
*
|
|
625
|
+
* Beta users (`users.beta = true`, mirrored onto the profile when OAuth
|
|
626
|
+
* authorizes against api-beta) hit api-beta.notis.ai; everyone else hits
|
|
627
|
+
* api.notis.ai. Localhost is never a default — only a `./dev.sh` profile
|
|
628
|
+
* backed by a live worktree lease may retarget the CLI at loopback.
|
|
447
629
|
*/
|
|
448
630
|
export function resolveDefaultLiveApiBase(profile = {}) {
|
|
449
631
|
if (profile.beta === true) {
|
|
@@ -452,9 +634,6 @@ export function resolveDefaultLiveApiBase(profile = {}) {
|
|
|
452
634
|
if (profile.beta === false) {
|
|
453
635
|
return DEFAULT_API_BASE;
|
|
454
636
|
}
|
|
455
|
-
if (profile.desktop_app_name === 'Notis Beta') {
|
|
456
|
-
return BETA_API_BASE;
|
|
457
|
-
}
|
|
458
637
|
if (isLiveApiBase(profile.api_base)) {
|
|
459
638
|
try {
|
|
460
639
|
if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
|
|
@@ -478,10 +657,10 @@ export function getApiBase(config, profileName, override) {
|
|
|
478
657
|
const profile = getProfile(config, profileName);
|
|
479
658
|
const profileApiBase = profile.api_base;
|
|
480
659
|
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
660
|
+
// A loopback api_base is only meaningful while the `./dev.sh` that wrote it
|
|
661
|
+
// is still running, and resolveRuntimeProfile checks that lease before it
|
|
662
|
+
// routes anywhere. Here — with no lease in hand — a leftover localhost value
|
|
663
|
+
// resolves to the live API rather than to a port nothing is listening on.
|
|
485
664
|
if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
|
|
486
665
|
return profileApiBase.replace(/\/+$/, '');
|
|
487
666
|
}
|
|
@@ -489,15 +668,6 @@ export function getApiBase(config, profileName, override) {
|
|
|
489
668
|
return resolveDefaultLiveApiBase(profile);
|
|
490
669
|
}
|
|
491
670
|
|
|
492
|
-
export function getJwt(config, profileName) {
|
|
493
|
-
const env = process.env.NOTIS_JWT;
|
|
494
|
-
if (env) {
|
|
495
|
-
return env;
|
|
496
|
-
}
|
|
497
|
-
const profile = getProfile(config, profileName);
|
|
498
|
-
return profile.jwt;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
671
|
export function isAgentMode(globalOptions = {}) {
|
|
502
672
|
return process.env.NOTIS_AGENT === '1' || Boolean(globalOptions.agentMode);
|
|
503
673
|
}
|
|
@@ -594,62 +764,95 @@ export function getOAuthApiBase(profile = {}) {
|
|
|
594
764
|
|
|
595
765
|
export function resolveRuntimeProfile(
|
|
596
766
|
globalOptions = {},
|
|
597
|
-
{
|
|
767
|
+
{
|
|
768
|
+
requireAuth = true,
|
|
769
|
+
includeDebugEntitlementOverride = true,
|
|
770
|
+
allowUnknownProfile = false,
|
|
771
|
+
allowUnavailableWorktree = false,
|
|
772
|
+
} = {},
|
|
598
773
|
) {
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
const
|
|
774
|
+
const resolvedWorktree = resolveWorktreeRuntime();
|
|
775
|
+
const worktreeUnavailable = resolvedWorktree?.unavailable || null;
|
|
776
|
+
const worktreeRuntime = worktreeUnavailable ? null : resolvedWorktree;
|
|
777
|
+
const config = loadConfig();
|
|
778
|
+
const { profileName, source: profileSource } = resolveProfileSelection(
|
|
779
|
+
globalOptions,
|
|
780
|
+
worktreeRuntime,
|
|
781
|
+
config,
|
|
782
|
+
{ allowUnknownProfile },
|
|
783
|
+
);
|
|
784
|
+
// A dead lease is an error for every command that can leave the machine,
|
|
785
|
+
// including unauthenticated health and OAuth calls. Only command specs that
|
|
786
|
+
// are explicitly local may continue inside a stopped worktree.
|
|
787
|
+
if (worktreeUnavailable && !allowUnavailableWorktree && profileSource !== 'explicit') {
|
|
788
|
+
throw worktreeUnavailable;
|
|
789
|
+
}
|
|
790
|
+
// Only the synthetic profile exposed by `./dev.sh` is pinned to its loopback
|
|
791
|
+
// backend.
|
|
792
|
+
// Naming any other profile opts out of the worktree entirely, which is how a
|
|
793
|
+
// developer reaches production from a checkout whose local API is down.
|
|
794
|
+
const devRuntime =
|
|
795
|
+
worktreeRuntime && worktreeRuntime.profile === profileName ? worktreeRuntime : null;
|
|
602
796
|
const requestedApiBase = globalOptions.apiBase;
|
|
603
797
|
if (
|
|
604
|
-
|
|
798
|
+
devRuntime &&
|
|
605
799
|
requestedApiBase &&
|
|
606
|
-
requestedApiBase.replace(/\/+$/, '') !==
|
|
800
|
+
requestedApiBase.replace(/\/+$/, '') !== devRuntime.api_base
|
|
607
801
|
) {
|
|
608
802
|
throw new CliError({
|
|
609
803
|
code: 'dev_runtime_route_mismatch',
|
|
610
|
-
message: `
|
|
804
|
+
message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
|
|
611
805
|
exitCode: EXIT_CODES.usage,
|
|
612
|
-
hints: [
|
|
806
|
+
hints: [
|
|
807
|
+
{ message: `Expected local API: ${devRuntime.api_base}` },
|
|
808
|
+
{ command: 'notis profile list', reason: 'Switch to a profile that targets that API instead' },
|
|
809
|
+
],
|
|
613
810
|
});
|
|
614
811
|
}
|
|
615
|
-
let apiBase =
|
|
616
|
-
?
|
|
812
|
+
let apiBase = devRuntime
|
|
813
|
+
? devRuntime.api_base
|
|
617
814
|
: getApiBase(config, profileName, globalOptions.apiBase);
|
|
618
815
|
const profile = getProfile(config, profileName);
|
|
619
|
-
const envJwt = !
|
|
620
|
-
const
|
|
816
|
+
const envJwt = !devRuntime ? process.env.NOTIS_JWT : undefined;
|
|
817
|
+
const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
|
|
621
818
|
const oauthJwt = profile.oauth_access_token;
|
|
622
819
|
let jwt;
|
|
623
820
|
let credentialKind;
|
|
624
821
|
|
|
625
|
-
|
|
626
|
-
|
|
822
|
+
// A dev credential is a real Supabase token for the worktree's test user. It
|
|
823
|
+
// is only ever spendable against the loopback backend that minted it, so a
|
|
824
|
+
// profile holding one is unusable without its live lease rather than falling
|
|
825
|
+
// through to the live API and authenticating there as the test user.
|
|
826
|
+
if (requireAuth && !devRuntime && devJwt && !oauthJwt && !process.env.NOTIS_JWT) {
|
|
827
|
+
throw new CliError({
|
|
828
|
+
code: 'dev_runtime_unavailable',
|
|
829
|
+
message: `Profile "${profileName}" is a ./dev.sh profile and its local runtime is not active`,
|
|
830
|
+
exitCode: EXIT_CODES.network,
|
|
831
|
+
details: { workspace_root: profile.dev_workspace_root || null },
|
|
832
|
+
hints: [
|
|
833
|
+
profile.dev_workspace_root
|
|
834
|
+
? { message: `Start ./dev.sh in ${profile.dev_workspace_root}, then retry.` }
|
|
835
|
+
: { message: 'Start ./dev.sh in the worktree that owns this profile, then retry.' },
|
|
836
|
+
{ command: 'notis profile list', reason: 'Switch to a live account profile instead' },
|
|
837
|
+
],
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (devRuntime && devJwt) {
|
|
842
|
+
jwt = devJwt;
|
|
627
843
|
credentialKind = 'worktree';
|
|
628
844
|
} else if (envJwt) {
|
|
629
845
|
jwt = envJwt;
|
|
630
846
|
credentialKind = 'env';
|
|
631
|
-
} else if (
|
|
632
|
-
desktopJwt
|
|
633
|
-
&& !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
|
|
634
|
-
) {
|
|
635
|
-
jwt = desktopJwt;
|
|
636
|
-
credentialKind = 'desktop';
|
|
637
847
|
} else if (
|
|
638
848
|
oauthJwt
|
|
639
849
|
&& !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
|
|
640
850
|
) {
|
|
641
851
|
jwt = oauthJwt;
|
|
642
852
|
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
853
|
} else if (oauthJwt) {
|
|
652
|
-
//
|
|
854
|
+
// A lapsed access token is still usable through its rotating refresh token,
|
|
855
|
+
// so preserve it and let transport refresh before the first request.
|
|
653
856
|
jwt = oauthJwt;
|
|
654
857
|
credentialKind = 'oauth';
|
|
655
858
|
}
|
|
@@ -665,7 +868,6 @@ export function resolveRuntimeProfile(
|
|
|
665
868
|
&& oauthApiBase
|
|
666
869
|
&& normalizedRequestedApiBase !== oauthApiBase
|
|
667
870
|
) {
|
|
668
|
-
const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
669
871
|
throw new CliError({
|
|
670
872
|
code: 'oauth_api_target_mismatch',
|
|
671
873
|
message: (
|
|
@@ -678,7 +880,7 @@ export function resolveRuntimeProfile(
|
|
|
678
880
|
'npx --package @notis_ai/cli@latest -- notis',
|
|
679
881
|
`--profile ${quoteShellArgument(profileName)}`,
|
|
680
882
|
`--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
|
|
681
|
-
'login
|
|
883
|
+
'login',
|
|
682
884
|
].join(' '),
|
|
683
885
|
reason: 'Authorize a separate OAuth grant for the requested Notis environment',
|
|
684
886
|
}],
|
|
@@ -696,51 +898,42 @@ export function resolveRuntimeProfile(
|
|
|
696
898
|
: null;
|
|
697
899
|
|
|
698
900
|
if (requireAuth && !jwt) {
|
|
699
|
-
const recovery = getDesktopAuthRecovery(
|
|
700
|
-
{
|
|
701
|
-
apiBase,
|
|
702
|
-
desktopAppName: profile.desktop_app_name,
|
|
703
|
-
desktopPid: profile.desktop_pid,
|
|
704
|
-
},
|
|
705
|
-
{ mode: 'missing' },
|
|
706
|
-
);
|
|
707
901
|
throw new CliError({
|
|
708
902
|
code: 'auth_missing',
|
|
709
|
-
message: `
|
|
903
|
+
message: `Profile "${profileName}" has no Notis credential`,
|
|
710
904
|
exitCode: EXIT_CODES.auth,
|
|
711
|
-
hints:
|
|
905
|
+
hints: getAuthRecovery({ profileName, apiBase }, { mode: 'missing' }).hints,
|
|
712
906
|
});
|
|
713
907
|
}
|
|
714
908
|
if (
|
|
715
|
-
|
|
909
|
+
devRuntime?.expected_user_id &&
|
|
716
910
|
(
|
|
717
911
|
credentialKind === 'oauth'
|
|
718
912
|
? profile.oauth_user_id
|
|
719
913
|
: getJwtSubject(jwt)
|
|
720
|
-
) !==
|
|
914
|
+
) !== devRuntime.expected_user_id
|
|
721
915
|
) {
|
|
722
916
|
throw new CliError({
|
|
723
917
|
code: 'dev_runtime_identity_mismatch',
|
|
724
|
-
message:
|
|
918
|
+
message: `Profile "${profileName}" no longer holds this worktree's test identity`,
|
|
725
919
|
exitCode: EXIT_CODES.auth,
|
|
726
920
|
hints: [
|
|
727
921
|
{ message: 'Restart ./dev.sh to restore the approved worktree identity.' },
|
|
728
|
-
{ message: `Expected user: ${
|
|
922
|
+
{ message: `Expected user: ${devRuntime.expected_user_id}` },
|
|
729
923
|
],
|
|
730
924
|
});
|
|
731
925
|
}
|
|
732
926
|
|
|
733
|
-
// An explicit NOTIS_JWT is a complete credential override. Use it verbatim
|
|
734
|
-
// and never replace it with a token later synced by the desktop profile.
|
|
735
|
-
const usingEnvJwt = credentialKind === 'env';
|
|
736
927
|
return {
|
|
737
928
|
config,
|
|
738
929
|
profileName,
|
|
930
|
+
profileSource,
|
|
931
|
+
profileLabel: profile.label,
|
|
739
932
|
apiBase,
|
|
740
933
|
requestedApiBase: normalizedRequestedApiBase,
|
|
741
934
|
jwt,
|
|
742
935
|
credentialKind,
|
|
743
|
-
credentialSource: credentialKind
|
|
936
|
+
credentialSource: credentialKind,
|
|
744
937
|
oauthAccessToken: profile.oauth_access_token,
|
|
745
938
|
oauthRefreshToken: profile.oauth_refresh_token,
|
|
746
939
|
oauthAccessExpiresAt: profile.oauth_access_expires_at,
|
|
@@ -751,14 +944,14 @@ export function resolveRuntimeProfile(
|
|
|
751
944
|
oauthResource,
|
|
752
945
|
oauthScopes: profile.oauth_scopes || [],
|
|
753
946
|
oauthUserId: profile.oauth_user_id,
|
|
754
|
-
desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
|
|
755
|
-
desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
|
|
756
947
|
agentMode,
|
|
757
948
|
nonInteractive,
|
|
758
949
|
outputMode,
|
|
759
950
|
timeoutMs,
|
|
760
951
|
debugEntitlementOverride,
|
|
761
|
-
worktreeRuntime,
|
|
952
|
+
worktreeRuntime: devRuntime,
|
|
953
|
+
detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
|
|
954
|
+
worktreeRuntimeUnavailable: worktreeUnavailable,
|
|
762
955
|
};
|
|
763
956
|
}
|
|
764
957
|
|
|
@@ -790,23 +983,6 @@ export function getJwtSubject(jwt) {
|
|
|
790
983
|
}
|
|
791
984
|
}
|
|
792
985
|
|
|
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
|
-
|
|
810
986
|
export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
811
987
|
const expiration = getJwtExpiration(jwt);
|
|
812
988
|
return expiration !== null && expiration <= nowSeconds;
|
|
@@ -817,11 +993,10 @@ export function credentialIsExpired(
|
|
|
817
993
|
profile = {},
|
|
818
994
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
819
995
|
) {
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
// explicitly so a scoped token can never be mistaken for a Supabase JWT.
|
|
996
|
+
// A credential with no declared kind is not one this CLI knows how to keep
|
|
997
|
+
// alive, so it fails closed rather than defaulting to a permissive shape.
|
|
823
998
|
const credentialKind = runtime?.credentialKind
|
|
824
|
-
|| (runtime?.credentialSource === 'env' ? 'env' :
|
|
999
|
+
|| (runtime?.credentialSource === 'env' ? 'env' : null);
|
|
825
1000
|
switch (credentialKind) {
|
|
826
1001
|
case 'oauth': {
|
|
827
1002
|
const expiration = Number(profile.oauth_access_expires_at);
|
|
@@ -829,16 +1004,15 @@ export function credentialIsExpired(
|
|
|
829
1004
|
}
|
|
830
1005
|
case 'env':
|
|
831
1006
|
// NOTIS_JWT is a complete override. Never combine it with expiry
|
|
832
|
-
// metadata
|
|
1007
|
+
// metadata belonging to a different credential in the same profile.
|
|
833
1008
|
// A token with no readable `exp` is a personal API key, which never
|
|
834
1009
|
// expires, so let the server rather than the CLI reject it.
|
|
835
1010
|
{
|
|
836
1011
|
const expiration = getJwtExpiration(runtime?.jwt);
|
|
837
1012
|
return expiration !== null && expiration <= nowSeconds;
|
|
838
1013
|
}
|
|
839
|
-
case 'worktree':
|
|
840
|
-
|
|
841
|
-
const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
1014
|
+
case 'worktree': {
|
|
1015
|
+
const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
842
1016
|
// Every selected credential must carry an independently verifiable
|
|
843
1017
|
// expiry. Missing or malformed expiry metadata fails closed.
|
|
844
1018
|
if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
|