@notis_ai/cli 0.2.9 → 0.2.11
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 +3 -3
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +62 -19
- 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 +18 -10
- package/src/command-specs/auth.js +15 -20
- package/src/command-specs/diagnostics.js +6 -1
- package/src/command-specs/helpers.js +7 -1
- 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/cli-mode.generated.js +4 -3
- package/src/runtime/cli-mode.js +13 -8
- package/src/runtime/oauth.js +121 -30
- package/src/runtime/profiles.js +435 -223
- package/src/runtime/transport.js +84 -29
- package/src/runtime/desktop-auth.js +0 -162
package/src/runtime/profiles.js
CHANGED
|
@@ -11,26 +11,33 @@ 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');
|
|
18
18
|
export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
|
|
19
19
|
export const DEFAULT_API_BASE = 'https://api.notis.ai';
|
|
20
|
+
export const BETA_API_BASE = 'https://api-beta.notis.ai';
|
|
20
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
|
+
];
|
|
21
32
|
const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
|
|
22
33
|
const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
|
|
23
|
-
const LOCAL_DEFAULT_API_BASES = new Set([
|
|
24
|
-
'http://localhost:3001',
|
|
25
|
-
'http://127.0.0.1:3001',
|
|
26
|
-
]);
|
|
27
34
|
const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
35
|
+
const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
|
|
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.
|
|
34
41
|
const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
35
42
|
const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
|
|
36
43
|
const CONFIG_WRITE_LOCK_POLL_MS = 10;
|
|
@@ -39,60 +46,107 @@ function clone(value) {
|
|
|
39
46
|
return JSON.parse(JSON.stringify(value));
|
|
40
47
|
}
|
|
41
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
|
+
|
|
42
127
|
export function normalizeConfig(rawConfig = {}) {
|
|
43
128
|
const raw = rawConfig && typeof rawConfig === 'object' ? clone(rawConfig) : {};
|
|
44
129
|
|
|
45
130
|
if (raw.profiles && typeof raw.profiles === 'object') {
|
|
46
131
|
const profiles = {};
|
|
47
132
|
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
48
|
-
|
|
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') {
|
|
49
137
|
continue;
|
|
50
138
|
}
|
|
51
|
-
profiles[name] =
|
|
52
|
-
jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
|
|
53
|
-
api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
|
|
54
|
-
auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
|
|
55
|
-
refresh_token:
|
|
56
|
-
typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
|
|
57
|
-
access_expires_at:
|
|
58
|
-
typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
|
|
59
|
-
refresh_expires_at:
|
|
60
|
-
typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
|
|
61
|
-
desktop_app_name:
|
|
62
|
-
typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
|
|
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,
|
|
86
|
-
};
|
|
139
|
+
profiles[name] = normalizeProfile(profile);
|
|
87
140
|
}
|
|
88
141
|
|
|
89
|
-
if (!profiles
|
|
142
|
+
if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
|
|
90
143
|
profiles[DEFAULT_PROFILE] = {};
|
|
91
144
|
}
|
|
92
145
|
|
|
93
146
|
return {
|
|
94
147
|
current_profile:
|
|
95
|
-
typeof raw.current_profile === 'string'
|
|
148
|
+
typeof raw.current_profile === 'string'
|
|
149
|
+
&& Object.hasOwn(profiles, raw.current_profile)
|
|
96
150
|
? raw.current_profile
|
|
97
151
|
: DEFAULT_PROFILE,
|
|
98
152
|
profiles,
|
|
@@ -101,46 +155,44 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
101
155
|
|
|
102
156
|
return {
|
|
103
157
|
current_profile: DEFAULT_PROFILE,
|
|
104
|
-
profiles: {
|
|
105
|
-
[DEFAULT_PROFILE]: {
|
|
106
|
-
jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
|
|
107
|
-
api_base: typeof raw.api_base === 'string' ? raw.api_base : 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;
|
|
@@ -425,6 +607,45 @@ function isLocalApiBase(value) {
|
|
|
425
607
|
}
|
|
426
608
|
}
|
|
427
609
|
|
|
610
|
+
function isLiveApiBase(value) {
|
|
611
|
+
if (typeof value !== 'string' || !value) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
const parsed = new URL(value);
|
|
616
|
+
return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
|
|
617
|
+
} catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Pick the live Notis API for this profile.
|
|
624
|
+
*
|
|
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.
|
|
629
|
+
*/
|
|
630
|
+
export function resolveDefaultLiveApiBase(profile = {}) {
|
|
631
|
+
if (profile.beta === true) {
|
|
632
|
+
return BETA_API_BASE;
|
|
633
|
+
}
|
|
634
|
+
if (profile.beta === false) {
|
|
635
|
+
return DEFAULT_API_BASE;
|
|
636
|
+
}
|
|
637
|
+
if (isLiveApiBase(profile.api_base)) {
|
|
638
|
+
try {
|
|
639
|
+
if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
|
|
640
|
+
return BETA_API_BASE;
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
// fall through
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return DEFAULT_API_BASE;
|
|
647
|
+
}
|
|
648
|
+
|
|
428
649
|
export function getApiBase(config, profileName, override) {
|
|
429
650
|
if (override) {
|
|
430
651
|
return override;
|
|
@@ -435,29 +656,16 @@ export function getApiBase(config, profileName, override) {
|
|
|
435
656
|
}
|
|
436
657
|
const profile = getProfile(config, profileName);
|
|
437
658
|
const profileApiBase = profile.api_base;
|
|
438
|
-
const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
|
|
439
659
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
(!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
|
|
447
|
-
) {
|
|
448
|
-
return `http://localhost:${conductorPort + 1}`;
|
|
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.
|
|
664
|
+
if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
|
|
665
|
+
return profileApiBase.replace(/\/+$/, '');
|
|
449
666
|
}
|
|
450
667
|
|
|
451
|
-
return
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
export function getJwt(config, profileName) {
|
|
455
|
-
const env = process.env.NOTIS_JWT;
|
|
456
|
-
if (env) {
|
|
457
|
-
return env;
|
|
458
|
-
}
|
|
459
|
-
const profile = getProfile(config, profileName);
|
|
460
|
-
return profile.jwt;
|
|
668
|
+
return resolveDefaultLiveApiBase(profile);
|
|
461
669
|
}
|
|
462
670
|
|
|
463
671
|
export function isAgentMode(globalOptions = {}) {
|
|
@@ -556,62 +764,95 @@ export function getOAuthApiBase(profile = {}) {
|
|
|
556
764
|
|
|
557
765
|
export function resolveRuntimeProfile(
|
|
558
766
|
globalOptions = {},
|
|
559
|
-
{
|
|
767
|
+
{
|
|
768
|
+
requireAuth = true,
|
|
769
|
+
includeDebugEntitlementOverride = true,
|
|
770
|
+
allowUnknownProfile = false,
|
|
771
|
+
allowUnavailableWorktree = false,
|
|
772
|
+
} = {},
|
|
560
773
|
) {
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
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;
|
|
564
796
|
const requestedApiBase = globalOptions.apiBase;
|
|
565
797
|
if (
|
|
566
|
-
|
|
798
|
+
devRuntime &&
|
|
567
799
|
requestedApiBase &&
|
|
568
|
-
requestedApiBase.replace(/\/+$/, '') !==
|
|
800
|
+
requestedApiBase.replace(/\/+$/, '') !== devRuntime.api_base
|
|
569
801
|
) {
|
|
570
802
|
throw new CliError({
|
|
571
803
|
code: 'dev_runtime_route_mismatch',
|
|
572
|
-
message: `
|
|
804
|
+
message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
|
|
573
805
|
exitCode: EXIT_CODES.usage,
|
|
574
|
-
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
|
+
],
|
|
575
810
|
});
|
|
576
811
|
}
|
|
577
|
-
let apiBase =
|
|
578
|
-
?
|
|
812
|
+
let apiBase = devRuntime
|
|
813
|
+
? devRuntime.api_base
|
|
579
814
|
: getApiBase(config, profileName, globalOptions.apiBase);
|
|
580
815
|
const profile = getProfile(config, profileName);
|
|
581
|
-
const envJwt = !
|
|
582
|
-
const
|
|
816
|
+
const envJwt = !devRuntime ? process.env.NOTIS_JWT : undefined;
|
|
817
|
+
const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
|
|
583
818
|
const oauthJwt = profile.oauth_access_token;
|
|
584
819
|
let jwt;
|
|
585
820
|
let credentialKind;
|
|
586
821
|
|
|
587
|
-
|
|
588
|
-
|
|
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;
|
|
589
843
|
credentialKind = 'worktree';
|
|
590
844
|
} else if (envJwt) {
|
|
591
845
|
jwt = envJwt;
|
|
592
846
|
credentialKind = 'env';
|
|
593
|
-
} else if (
|
|
594
|
-
desktopJwt
|
|
595
|
-
&& !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
|
|
596
|
-
) {
|
|
597
|
-
jwt = desktopJwt;
|
|
598
|
-
credentialKind = 'desktop';
|
|
599
847
|
} else if (
|
|
600
848
|
oauthJwt
|
|
601
849
|
&& !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
|
|
602
850
|
) {
|
|
603
851
|
jwt = oauthJwt;
|
|
604
852
|
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
853
|
} else if (oauthJwt) {
|
|
614
|
-
//
|
|
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.
|
|
615
856
|
jwt = oauthJwt;
|
|
616
857
|
credentialKind = 'oauth';
|
|
617
858
|
}
|
|
@@ -627,7 +868,6 @@ export function resolveRuntimeProfile(
|
|
|
627
868
|
&& oauthApiBase
|
|
628
869
|
&& normalizedRequestedApiBase !== oauthApiBase
|
|
629
870
|
) {
|
|
630
|
-
const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
631
871
|
throw new CliError({
|
|
632
872
|
code: 'oauth_api_target_mismatch',
|
|
633
873
|
message: (
|
|
@@ -640,7 +880,7 @@ export function resolveRuntimeProfile(
|
|
|
640
880
|
'npx --package @notis_ai/cli@latest -- notis',
|
|
641
881
|
`--profile ${quoteShellArgument(profileName)}`,
|
|
642
882
|
`--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
|
|
643
|
-
'login
|
|
883
|
+
'login',
|
|
644
884
|
].join(' '),
|
|
645
885
|
reason: 'Authorize a separate OAuth grant for the requested Notis environment',
|
|
646
886
|
}],
|
|
@@ -658,51 +898,42 @@ export function resolveRuntimeProfile(
|
|
|
658
898
|
: null;
|
|
659
899
|
|
|
660
900
|
if (requireAuth && !jwt) {
|
|
661
|
-
const recovery = getDesktopAuthRecovery(
|
|
662
|
-
{
|
|
663
|
-
apiBase,
|
|
664
|
-
desktopAppName: profile.desktop_app_name,
|
|
665
|
-
desktopPid: profile.desktop_pid,
|
|
666
|
-
},
|
|
667
|
-
{ mode: 'missing' },
|
|
668
|
-
);
|
|
669
901
|
throw new CliError({
|
|
670
902
|
code: 'auth_missing',
|
|
671
|
-
message: `
|
|
903
|
+
message: `Profile "${profileName}" has no Notis credential`,
|
|
672
904
|
exitCode: EXIT_CODES.auth,
|
|
673
|
-
hints:
|
|
905
|
+
hints: getAuthRecovery({ profileName, apiBase }, { mode: 'missing' }).hints,
|
|
674
906
|
});
|
|
675
907
|
}
|
|
676
908
|
if (
|
|
677
|
-
|
|
909
|
+
devRuntime?.expected_user_id &&
|
|
678
910
|
(
|
|
679
911
|
credentialKind === 'oauth'
|
|
680
912
|
? profile.oauth_user_id
|
|
681
913
|
: getJwtSubject(jwt)
|
|
682
|
-
) !==
|
|
914
|
+
) !== devRuntime.expected_user_id
|
|
683
915
|
) {
|
|
684
916
|
throw new CliError({
|
|
685
917
|
code: 'dev_runtime_identity_mismatch',
|
|
686
|
-
message:
|
|
918
|
+
message: `Profile "${profileName}" no longer holds this worktree's test identity`,
|
|
687
919
|
exitCode: EXIT_CODES.auth,
|
|
688
920
|
hints: [
|
|
689
921
|
{ message: 'Restart ./dev.sh to restore the approved worktree identity.' },
|
|
690
|
-
{ message: `Expected user: ${
|
|
922
|
+
{ message: `Expected user: ${devRuntime.expected_user_id}` },
|
|
691
923
|
],
|
|
692
924
|
});
|
|
693
925
|
}
|
|
694
926
|
|
|
695
|
-
// An explicit NOTIS_JWT is a complete credential override. Use it verbatim
|
|
696
|
-
// and never replace it with a token later synced by the desktop profile.
|
|
697
|
-
const usingEnvJwt = credentialKind === 'env';
|
|
698
927
|
return {
|
|
699
928
|
config,
|
|
700
929
|
profileName,
|
|
930
|
+
profileSource,
|
|
931
|
+
profileLabel: profile.label,
|
|
701
932
|
apiBase,
|
|
702
933
|
requestedApiBase: normalizedRequestedApiBase,
|
|
703
934
|
jwt,
|
|
704
935
|
credentialKind,
|
|
705
|
-
credentialSource: credentialKind
|
|
936
|
+
credentialSource: credentialKind,
|
|
706
937
|
oauthAccessToken: profile.oauth_access_token,
|
|
707
938
|
oauthRefreshToken: profile.oauth_refresh_token,
|
|
708
939
|
oauthAccessExpiresAt: profile.oauth_access_expires_at,
|
|
@@ -713,14 +944,14 @@ export function resolveRuntimeProfile(
|
|
|
713
944
|
oauthResource,
|
|
714
945
|
oauthScopes: profile.oauth_scopes || [],
|
|
715
946
|
oauthUserId: profile.oauth_user_id,
|
|
716
|
-
desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
|
|
717
|
-
desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
|
|
718
947
|
agentMode,
|
|
719
948
|
nonInteractive,
|
|
720
949
|
outputMode,
|
|
721
950
|
timeoutMs,
|
|
722
951
|
debugEntitlementOverride,
|
|
723
|
-
worktreeRuntime,
|
|
952
|
+
worktreeRuntime: devRuntime,
|
|
953
|
+
detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
|
|
954
|
+
worktreeRuntimeUnavailable: worktreeUnavailable,
|
|
724
955
|
};
|
|
725
956
|
}
|
|
726
957
|
|
|
@@ -752,23 +983,6 @@ export function getJwtSubject(jwt) {
|
|
|
752
983
|
}
|
|
753
984
|
}
|
|
754
985
|
|
|
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
|
-
|
|
772
986
|
export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
773
987
|
const expiration = getJwtExpiration(jwt);
|
|
774
988
|
return expiration !== null && expiration <= nowSeconds;
|
|
@@ -779,11 +993,10 @@ export function credentialIsExpired(
|
|
|
779
993
|
profile = {},
|
|
780
994
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
781
995
|
) {
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
// 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.
|
|
785
998
|
const credentialKind = runtime?.credentialKind
|
|
786
|
-
|| (runtime?.credentialSource === 'env' ? 'env' :
|
|
999
|
+
|| (runtime?.credentialSource === 'env' ? 'env' : null);
|
|
787
1000
|
switch (credentialKind) {
|
|
788
1001
|
case 'oauth': {
|
|
789
1002
|
const expiration = Number(profile.oauth_access_expires_at);
|
|
@@ -791,16 +1004,15 @@ export function credentialIsExpired(
|
|
|
791
1004
|
}
|
|
792
1005
|
case 'env':
|
|
793
1006
|
// NOTIS_JWT is a complete override. Never combine it with expiry
|
|
794
|
-
// metadata
|
|
1007
|
+
// metadata belonging to a different credential in the same profile.
|
|
795
1008
|
// A token with no readable `exp` is a personal API key, which never
|
|
796
1009
|
// expires, so let the server rather than the CLI reject it.
|
|
797
1010
|
{
|
|
798
1011
|
const expiration = getJwtExpiration(runtime?.jwt);
|
|
799
1012
|
return expiration !== null && expiration <= nowSeconds;
|
|
800
1013
|
}
|
|
801
|
-
case 'worktree':
|
|
802
|
-
|
|
803
|
-
const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
1014
|
+
case 'worktree': {
|
|
1015
|
+
const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
804
1016
|
// Every selected credential must carry an independently verifiable
|
|
805
1017
|
// expiry. Missing or malformed expiry metadata fails closed.
|
|
806
1018
|
if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
|