@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +433 -133
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
- package/dist/base-skills/notis-apps/SKILL.md +70 -0
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +62 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +140 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +1626 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +2990 -0
- package/package.json +16 -6
- package/skills/notis-apps/cli.md +313 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1470 -202
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +716 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +84 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +86 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +632 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2633 -210
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +697 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +110 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +143 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +430 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/layout.tsx +5 -2
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +4137 -0
- package/template/package.json +5 -5
- package/template/packages/{notis-sdk → sdk}/package.json +13 -3
- package/template/packages/sdk/src/agentContext.ts +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +257 -0
- package/template/packages/sdk/src/documents.ts +256 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useTool.ts +65 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +161 -0
- package/template/packages/sdk/src/interactions/actions.ts +59 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +45 -0
- package/template/packages/sdk/src/provider.tsx +44 -0
- package/template/packages/sdk/src/queryCache.ts +170 -0
- package/template/packages/sdk/src/runtime.ts +451 -0
- package/template/packages/sdk/src/styles.css +213 -0
- package/template/packages/sdk/src/tailwind.ts +56 -0
- package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
- package/template/tailwind.config.ts +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
package/src/runtime/profiles.js
CHANGED
|
@@ -1,44 +1,157 @@
|
|
|
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
|
-
import { join } from 'node:path';
|
|
12
|
+
import { dirname, join, parse, resolve } from 'node:path';
|
|
4
13
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
14
|
+
import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
|
|
15
|
+
import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
|
|
5
16
|
|
|
6
17
|
export const CONFIG_DIR = join(homedir(), '.notis');
|
|
7
18
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
8
19
|
export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
|
|
9
20
|
export const DEFAULT_API_BASE = 'https://api.notis.ai';
|
|
21
|
+
export const BETA_API_BASE = 'https://api-beta.notis.ai';
|
|
10
22
|
export const DEFAULT_PROFILE = 'default';
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
'
|
|
14
|
-
|
|
23
|
+
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
24
|
+
const LEGACY_DESKTOP_PROFILE_KEYS = [
|
|
25
|
+
'jwt',
|
|
26
|
+
'auth_mode',
|
|
27
|
+
'refresh_token',
|
|
28
|
+
'access_expires_at',
|
|
29
|
+
'refresh_expires_at',
|
|
30
|
+
'desktop_app_name',
|
|
31
|
+
'desktop_pid',
|
|
32
|
+
];
|
|
33
|
+
const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
|
|
34
|
+
const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
|
|
35
|
+
const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
36
|
+
const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
|
|
37
|
+
// Cross-process write lock over ~/.notis/config.json. Independent `notis`
|
|
38
|
+
// processes (a `login` in one terminal and a `tools exec` in another) all
|
|
39
|
+
// rewrite this file, so the lock directory name and these three timings are the
|
|
40
|
+
// whole coordination protocol. Guarded by the concurrency test in
|
|
41
|
+
// packages/cli/test/runtime-auth.test.js.
|
|
42
|
+
const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
43
|
+
const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
|
|
44
|
+
const CONFIG_WRITE_LOCK_POLL_MS = 10;
|
|
15
45
|
|
|
16
46
|
function clone(value) {
|
|
17
47
|
return JSON.parse(JSON.stringify(value));
|
|
18
48
|
}
|
|
19
49
|
|
|
50
|
+
export function isValidProfileName(profileName) {
|
|
51
|
+
return typeof profileName === 'string'
|
|
52
|
+
&& PROFILE_NAME_PATTERN.test(profileName)
|
|
53
|
+
&& !Object.hasOwn(Object.prototype, profileName);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSafeStoredProfileName(profileName) {
|
|
57
|
+
return typeof profileName === 'string'
|
|
58
|
+
&& profileName.length > 0
|
|
59
|
+
&& !Object.hasOwn(Object.prototype, profileName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function profileExists(config, profileName) {
|
|
63
|
+
const normalized = normalizeConfig(config);
|
|
64
|
+
return isSafeStoredProfileName(profileName)
|
|
65
|
+
&& Object.hasOwn(normalized.profiles, profileName);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertValidProfileName(profileName) {
|
|
69
|
+
if (isValidProfileName(profileName)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw new CliError({
|
|
73
|
+
code: 'profile_name_invalid',
|
|
74
|
+
message: 'CLI profile names must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens (maximum 64 characters)',
|
|
75
|
+
exitCode: EXIT_CODES.usage,
|
|
76
|
+
hints: [
|
|
77
|
+
{ command: 'notis profile list', reason: 'See the valid profiles already on this machine' },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A profile is one account paired with one API endpoint.
|
|
84
|
+
*
|
|
85
|
+
* Only two credential shapes survive normalization: the browser-authorized
|
|
86
|
+
* OAuth grant (`oauth_*`) that owns every real account, and the loopback
|
|
87
|
+
* credential `./dev.sh` mints for its worktree test user (`dev_*`). Notis
|
|
88
|
+
* Desktop used to write a Supabase access token here as `jwt`, plus the
|
|
89
|
+
* `desktop_*` liveness hints the CLI used to tell people which app to reopen.
|
|
90
|
+
* Dropping those keys on read is what actually retires that path: a config
|
|
91
|
+
* left behind by an older Desktop build cannot silently keep authenticating
|
|
92
|
+
* the CLI with a credential nothing renews anymore.
|
|
93
|
+
*/
|
|
94
|
+
function normalizeProfile(rawProfile = {}) {
|
|
95
|
+
const raw = rawProfile && typeof rawProfile === 'object' ? rawProfile : {};
|
|
96
|
+
return {
|
|
97
|
+
api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
|
|
98
|
+
beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
|
|
99
|
+
// Which published CLI build this profile runs, as the deployment reported
|
|
100
|
+
// it at login. Unknown values are dropped rather than trusted: this key
|
|
101
|
+
// decides which code executes on the next run.
|
|
102
|
+
channel: isReleaseChannel(raw.channel) ? raw.channel : undefined,
|
|
103
|
+
label: typeof raw.label === 'string' ? raw.label : undefined,
|
|
104
|
+
dev_access_token:
|
|
105
|
+
typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
|
|
106
|
+
dev_access_expires_at:
|
|
107
|
+
typeof raw.dev_access_expires_at === 'number' ? raw.dev_access_expires_at : undefined,
|
|
108
|
+
dev_user_id: typeof raw.dev_user_id === 'string' ? raw.dev_user_id : undefined,
|
|
109
|
+
dev_workspace_root:
|
|
110
|
+
typeof raw.dev_workspace_root === 'string' ? raw.dev_workspace_root : undefined,
|
|
111
|
+
oauth_access_token:
|
|
112
|
+
typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
|
|
113
|
+
oauth_refresh_token:
|
|
114
|
+
typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
|
|
115
|
+
oauth_access_expires_at:
|
|
116
|
+
typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
|
|
117
|
+
oauth_refresh_expires_at:
|
|
118
|
+
typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
|
|
119
|
+
oauth_client_id:
|
|
120
|
+
typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
|
|
121
|
+
oauth_issuer: typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
|
|
122
|
+
oauth_api_base: typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
|
|
123
|
+
oauth_resource: typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
|
|
124
|
+
oauth_scopes:
|
|
125
|
+
Array.isArray(raw.oauth_scopes)
|
|
126
|
+
? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
|
|
127
|
+
: undefined,
|
|
128
|
+
oauth_user_id: typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
20
132
|
export function normalizeConfig(rawConfig = {}) {
|
|
21
133
|
const raw = rawConfig && typeof rawConfig === 'object' ? clone(rawConfig) : {};
|
|
22
134
|
|
|
23
135
|
if (raw.profiles && typeof raw.profiles === 'object') {
|
|
24
136
|
const profiles = {};
|
|
25
137
|
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
26
|
-
|
|
138
|
+
// Older CLI releases accepted arbitrary profile names. Keep every safe
|
|
139
|
+
// own-property name readable; the stricter grammar applies only when a
|
|
140
|
+
// command creates a new profile.
|
|
141
|
+
if (!isSafeStoredProfileName(name) || !profile || typeof profile !== 'object') {
|
|
27
142
|
continue;
|
|
28
143
|
}
|
|
29
|
-
profiles[name] =
|
|
30
|
-
jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
|
|
31
|
-
api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
|
|
32
|
-
};
|
|
144
|
+
profiles[name] = normalizeProfile(profile);
|
|
33
145
|
}
|
|
34
146
|
|
|
35
|
-
if (!profiles
|
|
147
|
+
if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
|
|
36
148
|
profiles[DEFAULT_PROFILE] = {};
|
|
37
149
|
}
|
|
38
150
|
|
|
39
151
|
return {
|
|
40
152
|
current_profile:
|
|
41
|
-
typeof raw.current_profile === 'string'
|
|
153
|
+
typeof raw.current_profile === 'string'
|
|
154
|
+
&& Object.hasOwn(profiles, raw.current_profile)
|
|
42
155
|
? raw.current_profile
|
|
43
156
|
: DEFAULT_PROFILE,
|
|
44
157
|
profiles,
|
|
@@ -47,49 +160,503 @@ export function normalizeConfig(rawConfig = {}) {
|
|
|
47
160
|
|
|
48
161
|
return {
|
|
49
162
|
current_profile: DEFAULT_PROFILE,
|
|
50
|
-
profiles: {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
163
|
+
profiles: { [DEFAULT_PROFILE]: normalizeProfile(raw) },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function profileHasCredential(profile = {}) {
|
|
168
|
+
return Boolean(profile.oauth_access_token || profile.dev_access_token);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Names every profile a switch can land on, newest config order preserved.
|
|
173
|
+
* `notis profile use` and `--profile` both validate against this.
|
|
174
|
+
*/
|
|
175
|
+
export function listProfiles(config) {
|
|
176
|
+
const normalized = normalizeConfig(config);
|
|
177
|
+
return Object.entries(normalized.profiles).map(([name, profile]) => ({
|
|
178
|
+
name,
|
|
179
|
+
active: name === normalized.current_profile,
|
|
180
|
+
// Report the endpoint the profile was created against, including the
|
|
181
|
+
// loopback address of a `./dev.sh` profile. getApiBase resolves a stale
|
|
182
|
+
// loopback value to the live API for routing; showing that here would
|
|
183
|
+
// claim a dev profile targets production, which it must never do.
|
|
184
|
+
// OAuth metadata owns the route for an OAuth profile. An older Desktop
|
|
185
|
+
// release may have left a conflicting api_base behind, but commands ignore
|
|
186
|
+
// that legacy value and so must profile inspection.
|
|
187
|
+
api_base: profile.oauth_access_token
|
|
188
|
+
? getOAuthApiBase(profile) || resolveDefaultLiveApiBase(profile)
|
|
189
|
+
: profile.api_base || resolveDefaultLiveApiBase(profile),
|
|
190
|
+
label: profile.label || null,
|
|
191
|
+
credential_kind: profile.oauth_access_token
|
|
192
|
+
? 'oauth'
|
|
193
|
+
: profile.dev_access_token
|
|
194
|
+
? 'dev'
|
|
195
|
+
: null,
|
|
196
|
+
user_id: profile.oauth_user_id || profile.dev_user_id || null,
|
|
197
|
+
authenticated: profileHasCredential(profile),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readJsonFile(path) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function findUp(filename, startDir = process.cwd()) {
|
|
210
|
+
let current = resolve(startDir);
|
|
211
|
+
const root = parse(current).root;
|
|
212
|
+
while (true) {
|
|
213
|
+
const candidate = join(current, filename);
|
|
214
|
+
if (existsSync(candidate)) {
|
|
215
|
+
return candidate;
|
|
216
|
+
}
|
|
217
|
+
if (current === root) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
current = dirname(current);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function processIsAlive(pid) {
|
|
225
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 0);
|
|
230
|
+
return true;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// Sandboxed children may be allowed to observe the worktree lease but not
|
|
233
|
+
// signal its dev.sh supervisor. POSIX EPERM proves that the PID exists;
|
|
234
|
+
// ESRCH (and every other probe failure) does not.
|
|
235
|
+
return error?.code === 'EPERM';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function resolveWorktreeRuntime(startDir = process.cwd()) {
|
|
240
|
+
if (
|
|
241
|
+
process.env.NODE_ENV === 'test' &&
|
|
242
|
+
process.env.NOTIS_TEST_DISABLE_WORKTREE_ROUTING === '1'
|
|
243
|
+
) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const runtimePath = findUp(WORKTREE_RUNTIME_FILENAME, startDir);
|
|
247
|
+
const routingPath = findUp(WORKTREE_ROUTING_FILENAME, startDir);
|
|
248
|
+
const routing = routingPath ? readJsonFile(routingPath) : null;
|
|
249
|
+
|
|
250
|
+
if (!runtimePath) {
|
|
251
|
+
if (routing?.mode === 'local-only') {
|
|
252
|
+
return {
|
|
253
|
+
unavailable: new CliError({
|
|
254
|
+
code: 'dev_runtime_unavailable',
|
|
255
|
+
message: 'This worktree is local-only, but its dev.sh runtime is not active',
|
|
256
|
+
exitCode: EXIT_CODES.network,
|
|
257
|
+
hints: [
|
|
258
|
+
{ message: 'Start ./dev.sh in this worktree, then retry the command.' },
|
|
259
|
+
{ command: 'notis profile list', reason: 'Run against a live account profile instead' },
|
|
260
|
+
{ message: `Routing policy: ${routingPath}` },
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const runtime = readJsonFile(runtimePath);
|
|
269
|
+
const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
|
|
270
|
+
const profile = typeof runtime?.profile === 'string' ? runtime.profile.trim() : '';
|
|
271
|
+
const devAccessToken =
|
|
272
|
+
typeof runtime?.dev_access_token === 'string' ? runtime.dev_access_token.trim() : '';
|
|
273
|
+
const desktopDeepLinkScheme =
|
|
274
|
+
typeof runtime?.desktop_deep_link_scheme === 'string'
|
|
275
|
+
? runtime.desktop_deep_link_scheme.trim()
|
|
276
|
+
: '';
|
|
277
|
+
const pid = Number(runtime?.dev_pid);
|
|
278
|
+
if (
|
|
279
|
+
runtime?.mode !== 'local-only' ||
|
|
280
|
+
!isLocalApiBase(apiBase) ||
|
|
281
|
+
!profile ||
|
|
282
|
+
!devAccessToken ||
|
|
283
|
+
!processIsAlive(pid)
|
|
284
|
+
) {
|
|
285
|
+
// Reported rather than thrown: an unusable lease must not stop someone
|
|
286
|
+
// from listing profiles or switching to a live account from inside the
|
|
287
|
+
// same checkout. resolveRuntimeProfile raises it only when a command is
|
|
288
|
+
// about to route through the dead local backend.
|
|
289
|
+
return {
|
|
290
|
+
unavailable: new CliError({
|
|
291
|
+
code: 'dev_runtime_unavailable',
|
|
292
|
+
message: 'The local-only worktree runtime is stale or invalid',
|
|
293
|
+
exitCode: EXIT_CODES.network,
|
|
294
|
+
hints: [
|
|
295
|
+
{ message: 'Restart ./dev.sh in this worktree, then retry the command.' },
|
|
296
|
+
{ command: 'notis profile list', reason: 'Run against a live account profile instead' },
|
|
297
|
+
{ message: `Runtime lease: ${runtimePath}` },
|
|
298
|
+
],
|
|
299
|
+
}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
...runtime,
|
|
305
|
+
api_base: apiBase,
|
|
306
|
+
profile,
|
|
307
|
+
dev_access_token: devAccessToken,
|
|
308
|
+
desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
|
|
309
|
+
runtime_path: runtimePath,
|
|
310
|
+
routing_path: routingPath,
|
|
56
311
|
};
|
|
57
312
|
}
|
|
58
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Real account profiles live in one shared file. A `./dev.sh` worktree keeps
|
|
316
|
+
* its synthetic profile credential in the worktree-owned runtime lease, so an
|
|
317
|
+
* older CLI process cannot normalize it away while rewriting this file.
|
|
318
|
+
*/
|
|
319
|
+
export function resolveConfigFile() {
|
|
320
|
+
const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
|
|
321
|
+
if (envConfigFile) {
|
|
322
|
+
return resolve(envConfigFile);
|
|
323
|
+
}
|
|
324
|
+
return CONFIG_FILE;
|
|
325
|
+
}
|
|
326
|
+
|
|
59
327
|
export function loadConfig() {
|
|
60
|
-
|
|
328
|
+
const configFile = resolveConfigFile();
|
|
329
|
+
if (!existsSync(configFile)) {
|
|
61
330
|
return normalizeConfig({});
|
|
62
331
|
}
|
|
63
332
|
|
|
64
|
-
|
|
333
|
+
// Concurrent `notis` processes rewrite this file while others read it, so a
|
|
334
|
+
// torn or corrupt read is expected rather than exceptional. Degrade to
|
|
335
|
+
// "unauthenticated", which surfaces the actionable auth_missing error instead
|
|
336
|
+
// of a raw SyntaxError from deep inside the runtime.
|
|
337
|
+
try {
|
|
338
|
+
return normalizeConfig(JSON.parse(readFileSync(configFile, 'utf-8')));
|
|
339
|
+
} catch {
|
|
340
|
+
return normalizeConfig({});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function writeConfig(configFile, config) {
|
|
345
|
+
let rawConfig = null;
|
|
346
|
+
try {
|
|
347
|
+
rawConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
348
|
+
} catch {
|
|
349
|
+
// A missing or corrupt file has no upgrade fields to preserve.
|
|
350
|
+
}
|
|
351
|
+
const normalized = normalizeConfig(config);
|
|
352
|
+
const persisted = clone(normalized);
|
|
353
|
+
const rawProfiles = rawConfig?.profiles && typeof rawConfig.profiles === 'object'
|
|
354
|
+
? rawConfig.profiles
|
|
355
|
+
: { [DEFAULT_PROFILE]: rawConfig };
|
|
356
|
+
for (const [name, profile] of Object.entries(persisted.profiles)) {
|
|
357
|
+
const rawProfile = Object.hasOwn(rawProfiles || {}, name) ? rawProfiles[name] : null;
|
|
358
|
+
if (!rawProfile || typeof rawProfile !== 'object') continue;
|
|
359
|
+
for (const key of LEGACY_DESKTOP_PROFILE_KEYS) {
|
|
360
|
+
if (Object.hasOwn(rawProfile, key)) {
|
|
361
|
+
profile[key] = rawProfile[key];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
writeRawConfig(configFile, persisted);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function writeRawConfig(configFile, config) {
|
|
369
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
370
|
+
const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
|
|
371
|
+
writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
372
|
+
renameSync(temporaryFile, configFile);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The lock records who holds it and when they took it. Reclaiming a stale lock
|
|
376
|
+
// by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
|
|
377
|
+
// one it is about to steal from a live writer, and the victim's own release
|
|
378
|
+
// would then delete the thief's lock and admit a third writer. Both sides are
|
|
379
|
+
// on the same host, so the stored timestamp and Date.now() share a clock.
|
|
380
|
+
function readLockOwner(lockDirectory) {
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function lockDirectoryMtime(lockDirectory) {
|
|
389
|
+
try {
|
|
390
|
+
return statSync(lockDirectory).mtimeMs;
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function publishConfigWriteLock(lockDirectory, ownerId) {
|
|
397
|
+
const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
|
|
398
|
+
try {
|
|
399
|
+
mkdirSync(candidateDirectory);
|
|
400
|
+
writeFileSync(
|
|
401
|
+
join(candidateDirectory, 'owner'),
|
|
402
|
+
JSON.stringify({ id: ownerId, at: Date.now() }),
|
|
403
|
+
{ mode: 0o600 },
|
|
404
|
+
);
|
|
405
|
+
try {
|
|
406
|
+
renameSync(candidateDirectory, lockDirectory);
|
|
407
|
+
return true;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (!existsSync(lockDirectory)) throw error;
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(candidateDirectory, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function withConfigWriteLock(callback) {
|
|
418
|
+
const configFile = resolveConfigFile();
|
|
419
|
+
const lockDirectory = `${configFile}.write-lock`;
|
|
420
|
+
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
421
|
+
const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
|
|
422
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
423
|
+
for (;;) {
|
|
424
|
+
if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const owner = readLockOwner(lockDirectory);
|
|
429
|
+
const observedMtime = lockDirectoryMtime(lockDirectory);
|
|
430
|
+
const ownerIsStale =
|
|
431
|
+
owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
|
|
432
|
+
const ownerlessIsStale =
|
|
433
|
+
!owner
|
|
434
|
+
&& observedMtime !== null
|
|
435
|
+
&& Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
|
|
436
|
+
if (ownerIsStale || ownerlessIsStale) {
|
|
437
|
+
try {
|
|
438
|
+
const currentOwner = readLockOwner(lockDirectory);
|
|
439
|
+
const ownerUnchanged = owner?.id
|
|
440
|
+
? currentOwner?.id === owner.id
|
|
441
|
+
: !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
|
|
442
|
+
if (ownerUnchanged) {
|
|
443
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
// Losing the reclaim race is normal; fall through and keep waiting.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (Date.now() >= deadline) {
|
|
451
|
+
throw new Error(`Timed out waiting to update ${configFile}`);
|
|
452
|
+
}
|
|
453
|
+
Atomics.wait(
|
|
454
|
+
new Int32Array(new SharedArrayBuffer(4)),
|
|
455
|
+
0,
|
|
456
|
+
0,
|
|
457
|
+
CONFIG_WRITE_LOCK_POLL_MS,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
return callback(configFile);
|
|
462
|
+
} finally {
|
|
463
|
+
try {
|
|
464
|
+
// Never release a lock that was reclaimed from us while we worked.
|
|
465
|
+
if (readLockOwner(lockDirectory)?.id === ownerId) {
|
|
466
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
// The lock may already have been removed after an interrupted write.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
65
472
|
}
|
|
66
473
|
|
|
67
474
|
export function saveConfig(config) {
|
|
68
|
-
|
|
69
|
-
|
|
475
|
+
return withConfigWriteLock((configFile) => {
|
|
476
|
+
writeConfig(configFile, config);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function updateConfig(updater) {
|
|
481
|
+
return withConfigWriteLock((configFile) => {
|
|
482
|
+
const current = loadConfig();
|
|
483
|
+
const updated = updater(normalizeConfig(current));
|
|
484
|
+
const next = normalizeConfig(updated ?? current);
|
|
485
|
+
writeConfig(configFile, next);
|
|
486
|
+
return next;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Remove every profile owned by one worktree, plus stale localhost stubs that
|
|
492
|
+
* use one of that worktree's generated profile names. Older builds sometimes
|
|
493
|
+
* lost `dev_workspace_root`, so requiring the ownership marker alone leaves a
|
|
494
|
+
* dead local profile behind after archive.
|
|
495
|
+
*
|
|
496
|
+
* Do this without normalizing the rest of the shared file. Archive cleanup can
|
|
497
|
+
* run before a packaged Desktop upgrade has migrated its legacy `jwt`;
|
|
498
|
+
* preserving unknown/raw fields here keeps that migrate-then-strip handoff
|
|
499
|
+
* intact.
|
|
500
|
+
*/
|
|
501
|
+
export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
|
|
502
|
+
return withConfigWriteLock((configFile) => {
|
|
503
|
+
let raw;
|
|
504
|
+
try {
|
|
505
|
+
raw = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
506
|
+
} catch {
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
if (!raw || typeof raw !== 'object' || !raw.profiles || typeof raw.profiles !== 'object') {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const generatedNames = new Set(profileNames);
|
|
514
|
+
const removed = [];
|
|
515
|
+
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
516
|
+
if (!profile || typeof profile !== 'object') {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const ownedByWorkspace = profile.dev_workspace_root === workspaceRoot;
|
|
521
|
+
const staleGeneratedLocalProfile = generatedNames.has(name)
|
|
522
|
+
&& !profile.dev_workspace_root
|
|
523
|
+
&& isLocalApiBase(profile.api_base);
|
|
524
|
+
if (!ownedByWorkspace && !staleGeneratedLocalProfile) continue;
|
|
525
|
+
|
|
526
|
+
delete raw.profiles[name];
|
|
527
|
+
removed.push(name);
|
|
528
|
+
if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
|
|
529
|
+
}
|
|
530
|
+
if (removed.length > 0) writeRawConfig(configFile, raw);
|
|
531
|
+
return removed;
|
|
532
|
+
});
|
|
70
533
|
}
|
|
71
534
|
|
|
72
535
|
export function getProfile(config, profileName) {
|
|
73
536
|
const normalized = normalizeConfig(config);
|
|
74
|
-
return normalized.profiles
|
|
537
|
+
return Object.hasOwn(normalized.profiles, profileName)
|
|
538
|
+
? normalized.profiles[profileName]
|
|
539
|
+
: {};
|
|
75
540
|
}
|
|
76
541
|
|
|
77
542
|
export function getCurrentProfileName(config, preferredName) {
|
|
78
543
|
const normalized = normalizeConfig(config);
|
|
79
|
-
if (preferredName && normalized.profiles
|
|
544
|
+
if (preferredName && Object.hasOwn(normalized.profiles, preferredName)) {
|
|
80
545
|
return preferredName;
|
|
81
546
|
}
|
|
82
547
|
return normalized.current_profile || DEFAULT_PROFILE;
|
|
83
548
|
}
|
|
84
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Resolve which profile this invocation runs as.
|
|
552
|
+
*
|
|
553
|
+
* An explicit `--profile` / `NOTIS_PROFILE` always wins, including inside a
|
|
554
|
+
* `./dev.sh` worktree: naming another account is the documented way to reach
|
|
555
|
+
* production from a checkout whose local backend is wedged. With nothing
|
|
556
|
+
* explicit, a live worktree lease selects its own dev profile, and otherwise
|
|
557
|
+
* the switch persisted by `notis profile use` applies.
|
|
558
|
+
*/
|
|
559
|
+
export function resolveProfileSelection(
|
|
560
|
+
globalOptions = {},
|
|
561
|
+
worktreeRuntime = null,
|
|
562
|
+
config,
|
|
563
|
+
{ allowUnknownProfile = false } = {},
|
|
564
|
+
) {
|
|
565
|
+
const normalized = normalizeConfig(config);
|
|
566
|
+
const requested = globalOptions.profile || process.env.NOTIS_PROFILE || '';
|
|
567
|
+
if (requested) {
|
|
568
|
+
const existingProfile = Object.hasOwn(normalized.profiles, requested);
|
|
569
|
+
if (!existingProfile) {
|
|
570
|
+
// Existing profiles may have names accepted by earlier releases. Only a
|
|
571
|
+
// new profile created by login/start must satisfy today's grammar.
|
|
572
|
+
assertValidProfileName(requested);
|
|
573
|
+
}
|
|
574
|
+
if (!existingProfile && !allowUnknownProfile) {
|
|
575
|
+
throw new CliError({
|
|
576
|
+
code: 'profile_unknown',
|
|
577
|
+
message: `No CLI profile named "${requested}"`,
|
|
578
|
+
exitCode: EXIT_CODES.usage,
|
|
579
|
+
details: { known_profiles: Object.keys(normalized.profiles) },
|
|
580
|
+
hints: [
|
|
581
|
+
{ command: 'notis profile list', reason: 'See which profiles this machine has' },
|
|
582
|
+
{
|
|
583
|
+
command: `notis login --profile ${quoteShellArgument(requested)}`,
|
|
584
|
+
reason: 'Authorize a new account under this profile name',
|
|
585
|
+
},
|
|
586
|
+
],
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return { profileName: requested, source: 'explicit' };
|
|
590
|
+
}
|
|
591
|
+
if (worktreeRuntime?.profile) {
|
|
592
|
+
return { profileName: worktreeRuntime.profile, source: 'worktree' };
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
profileName: normalized.current_profile || DEFAULT_PROFILE,
|
|
596
|
+
source: 'current',
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
85
600
|
export function ensureProfile(config, profileName) {
|
|
86
601
|
const normalized = normalizeConfig(config);
|
|
87
|
-
if (!normalized.profiles
|
|
602
|
+
if (!Object.hasOwn(normalized.profiles, profileName)) {
|
|
603
|
+
assertValidProfileName(profileName);
|
|
88
604
|
normalized.profiles[profileName] = {};
|
|
89
605
|
}
|
|
90
606
|
return normalized;
|
|
91
607
|
}
|
|
92
608
|
|
|
609
|
+
function isLocalApiBase(value) {
|
|
610
|
+
if (typeof value !== 'string' || !value) {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const parsed = new URL(value);
|
|
615
|
+
return ['http:', 'https:'].includes(parsed.protocol) && LOCAL_API_HOSTS.has(parsed.hostname);
|
|
616
|
+
} catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function isLiveApiBase(value) {
|
|
622
|
+
if (typeof value !== 'string' || !value) {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const parsed = new URL(value);
|
|
627
|
+
return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Pick the live Notis API for this profile.
|
|
635
|
+
*
|
|
636
|
+
* Beta users (`users.beta = true`, mirrored onto the profile when OAuth
|
|
637
|
+
* authorizes against api-beta) hit api-beta.notis.ai; everyone else hits
|
|
638
|
+
* api.notis.ai. Localhost is never a default — only a `./dev.sh` profile
|
|
639
|
+
* backed by a live worktree lease may retarget the CLI at loopback.
|
|
640
|
+
*/
|
|
641
|
+
export function resolveDefaultLiveApiBase(profile = {}) {
|
|
642
|
+
if (profile.beta === true) {
|
|
643
|
+
return BETA_API_BASE;
|
|
644
|
+
}
|
|
645
|
+
if (profile.beta === false) {
|
|
646
|
+
return DEFAULT_API_BASE;
|
|
647
|
+
}
|
|
648
|
+
if (isLiveApiBase(profile.api_base)) {
|
|
649
|
+
try {
|
|
650
|
+
if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
|
|
651
|
+
return BETA_API_BASE;
|
|
652
|
+
}
|
|
653
|
+
} catch {
|
|
654
|
+
// fall through
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return DEFAULT_API_BASE;
|
|
658
|
+
}
|
|
659
|
+
|
|
93
660
|
export function getApiBase(config, profileName, override) {
|
|
94
661
|
if (override) {
|
|
95
662
|
return override;
|
|
@@ -100,29 +667,16 @@ export function getApiBase(config, profileName, override) {
|
|
|
100
667
|
}
|
|
101
668
|
const profile = getProfile(config, profileName);
|
|
102
669
|
const profileApiBase = profile.api_base;
|
|
103
|
-
const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
|
|
104
670
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
(!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase))
|
|
112
|
-
) {
|
|
113
|
-
return `http://localhost:${conductorPort + 1}`;
|
|
671
|
+
// A loopback api_base is only meaningful while the `./dev.sh` that wrote it
|
|
672
|
+
// is still running, and resolveRuntimeProfile checks that lease before it
|
|
673
|
+
// routes anywhere. Here — with no lease in hand — a leftover localhost value
|
|
674
|
+
// resolves to the live API rather than to a port nothing is listening on.
|
|
675
|
+
if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
|
|
676
|
+
return profileApiBase.replace(/\/+$/, '');
|
|
114
677
|
}
|
|
115
678
|
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function getJwt(config, profileName) {
|
|
120
|
-
const env = process.env.NOTIS_JWT;
|
|
121
|
-
if (env) {
|
|
122
|
-
return env;
|
|
123
|
-
}
|
|
124
|
-
const profile = getProfile(config, profileName);
|
|
125
|
-
return profile.jwt;
|
|
679
|
+
return resolveDefaultLiveApiBase(profile);
|
|
126
680
|
}
|
|
127
681
|
|
|
128
682
|
export function isAgentMode(globalOptions = {}) {
|
|
@@ -161,26 +715,222 @@ export function resolveTimeoutMs(globalOptions = {}) {
|
|
|
161
715
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000;
|
|
162
716
|
}
|
|
163
717
|
|
|
164
|
-
export function
|
|
718
|
+
export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_ENTITLEMENT_OVERRIDE) {
|
|
719
|
+
if (!value) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
const candidates = [value];
|
|
723
|
+
try {
|
|
724
|
+
candidates.push(Buffer.from(value, 'base64url').toString('utf-8'));
|
|
725
|
+
} catch {
|
|
726
|
+
// The plain JSON candidate below will provide the actionable error.
|
|
727
|
+
}
|
|
728
|
+
for (const candidate of candidates) {
|
|
729
|
+
try {
|
|
730
|
+
const parsed = JSON.parse(candidate);
|
|
731
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
732
|
+
return parsed;
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
// Try the next supported encoding.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
throw new CliError({
|
|
739
|
+
code: 'debug_entitlement_override_invalid',
|
|
740
|
+
message: 'NOTIS_DEBUG_ENTITLEMENT_OVERRIDE must be a JSON object or base64url-encoded JSON object.',
|
|
741
|
+
exitCode: EXIT_CODES.usage,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export function getOAuthResource(profile = {}) {
|
|
746
|
+
if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
|
|
747
|
+
return profile.oauth_resource.replace(/\/+$/, '');
|
|
748
|
+
}
|
|
749
|
+
if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
|
|
750
|
+
try {
|
|
751
|
+
const parts = profile.oauth_access_token.split('.');
|
|
752
|
+
if (parts.length === 3) {
|
|
753
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
754
|
+
if (typeof payload.aud === 'string' && payload.aud) {
|
|
755
|
+
return payload.aud.replace(/\/+$/, '');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
// Legacy tokens without a readable audience fall back below.
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function getOAuthApiBase(profile = {}) {
|
|
766
|
+
if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
|
|
767
|
+
return profile.oauth_api_base.replace(/\/+$/, '');
|
|
768
|
+
}
|
|
769
|
+
const resource = getOAuthResource(profile);
|
|
770
|
+
if (resource?.endsWith('/cli')) {
|
|
771
|
+
return resource.slice(0, -'/cli'.length);
|
|
772
|
+
}
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function resolveRuntimeProfile(
|
|
777
|
+
globalOptions = {},
|
|
778
|
+
{
|
|
779
|
+
requireAuth = true,
|
|
780
|
+
includeDebugEntitlementOverride = true,
|
|
781
|
+
allowUnknownProfile = false,
|
|
782
|
+
allowUnavailableWorktree = false,
|
|
783
|
+
} = {},
|
|
784
|
+
) {
|
|
785
|
+
const resolvedWorktree = resolveWorktreeRuntime();
|
|
786
|
+
const worktreeUnavailable = resolvedWorktree?.unavailable || null;
|
|
787
|
+
const worktreeRuntime = worktreeUnavailable ? null : resolvedWorktree;
|
|
165
788
|
const config = loadConfig();
|
|
166
|
-
const profileName =
|
|
167
|
-
|
|
168
|
-
|
|
789
|
+
const { profileName, source: profileSource } = resolveProfileSelection(
|
|
790
|
+
globalOptions,
|
|
791
|
+
worktreeRuntime,
|
|
792
|
+
config,
|
|
793
|
+
{ allowUnknownProfile },
|
|
794
|
+
);
|
|
795
|
+
// A dead lease is an error for every command that can leave the machine,
|
|
796
|
+
// including unauthenticated health and OAuth calls. Only command specs that
|
|
797
|
+
// are explicitly local may continue inside a stopped worktree.
|
|
798
|
+
if (worktreeUnavailable && !allowUnavailableWorktree && profileSource !== 'explicit') {
|
|
799
|
+
throw worktreeUnavailable;
|
|
800
|
+
}
|
|
801
|
+
// Only the synthetic profile exposed by `./dev.sh` is pinned to its loopback
|
|
802
|
+
// backend.
|
|
803
|
+
// Naming any other profile opts out of the worktree entirely, which is how a
|
|
804
|
+
// developer reaches production from a checkout whose local API is down.
|
|
805
|
+
const devRuntime =
|
|
806
|
+
worktreeRuntime && worktreeRuntime.profile === profileName ? worktreeRuntime : null;
|
|
807
|
+
const requestedApiBase = globalOptions.apiBase;
|
|
808
|
+
if (
|
|
809
|
+
devRuntime &&
|
|
810
|
+
requestedApiBase &&
|
|
811
|
+
requestedApiBase.replace(/\/+$/, '') !== devRuntime.api_base
|
|
812
|
+
) {
|
|
813
|
+
throw new CliError({
|
|
814
|
+
code: 'dev_runtime_route_mismatch',
|
|
815
|
+
message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
|
|
816
|
+
exitCode: EXIT_CODES.usage,
|
|
817
|
+
hints: [
|
|
818
|
+
{ message: `Expected local API: ${devRuntime.api_base}` },
|
|
819
|
+
{ command: 'notis profile list', reason: 'Switch to a profile that targets that API instead' },
|
|
820
|
+
],
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
let apiBase = devRuntime
|
|
824
|
+
? devRuntime.api_base
|
|
825
|
+
: getApiBase(config, profileName, globalOptions.apiBase);
|
|
826
|
+
const profile = getProfile(config, profileName);
|
|
827
|
+
const envJwt = !devRuntime ? process.env.NOTIS_JWT : undefined;
|
|
828
|
+
const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
|
|
829
|
+
const oauthJwt = profile.oauth_access_token;
|
|
830
|
+
let jwt;
|
|
831
|
+
let credentialKind;
|
|
832
|
+
|
|
833
|
+
// A dev credential is a real Supabase token for the worktree's test user. It
|
|
834
|
+
// is only ever spendable against the loopback backend that minted it, so a
|
|
835
|
+
// profile holding one is unusable without its live lease rather than falling
|
|
836
|
+
// through to the live API and authenticating there as the test user.
|
|
837
|
+
if (requireAuth && !devRuntime && devJwt && !oauthJwt && !process.env.NOTIS_JWT) {
|
|
838
|
+
throw new CliError({
|
|
839
|
+
code: 'dev_runtime_unavailable',
|
|
840
|
+
message: `Profile "${profileName}" is a ./dev.sh profile and its local runtime is not active`,
|
|
841
|
+
exitCode: EXIT_CODES.network,
|
|
842
|
+
details: { workspace_root: profile.dev_workspace_root || null },
|
|
843
|
+
hints: [
|
|
844
|
+
profile.dev_workspace_root
|
|
845
|
+
? { message: `Start ./dev.sh in ${profile.dev_workspace_root}, then retry.` }
|
|
846
|
+
: { message: 'Start ./dev.sh in the worktree that owns this profile, then retry.' },
|
|
847
|
+
{ command: 'notis profile list', reason: 'Switch to a live account profile instead' },
|
|
848
|
+
],
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (devRuntime && devJwt) {
|
|
853
|
+
jwt = devJwt;
|
|
854
|
+
credentialKind = 'worktree';
|
|
855
|
+
} else if (envJwt) {
|
|
856
|
+
jwt = envJwt;
|
|
857
|
+
credentialKind = 'env';
|
|
858
|
+
} else if (
|
|
859
|
+
oauthJwt
|
|
860
|
+
&& !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
|
|
861
|
+
) {
|
|
862
|
+
jwt = oauthJwt;
|
|
863
|
+
credentialKind = 'oauth';
|
|
864
|
+
} else if (oauthJwt) {
|
|
865
|
+
// A lapsed access token is still usable through its rotating refresh token,
|
|
866
|
+
// so preserve it and let transport refresh before the first request.
|
|
867
|
+
jwt = oauthJwt;
|
|
868
|
+
credentialKind = 'oauth';
|
|
869
|
+
}
|
|
870
|
+
const oauthApiBase = getOAuthApiBase(profile);
|
|
871
|
+
const oauthResource = getOAuthResource(profile);
|
|
872
|
+
const normalizedRequestedApiBase = requestedApiBase
|
|
873
|
+
? requestedApiBase.replace(/\/+$/, '')
|
|
874
|
+
: null;
|
|
875
|
+
if (
|
|
876
|
+
requireAuth
|
|
877
|
+
&& credentialKind === 'oauth'
|
|
878
|
+
&& normalizedRequestedApiBase
|
|
879
|
+
&& oauthApiBase
|
|
880
|
+
&& normalizedRequestedApiBase !== oauthApiBase
|
|
881
|
+
) {
|
|
882
|
+
throw new CliError({
|
|
883
|
+
code: 'oauth_api_target_mismatch',
|
|
884
|
+
message: (
|
|
885
|
+
`The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
|
|
886
|
+
+ `not ${normalizedRequestedApiBase}`
|
|
887
|
+
),
|
|
888
|
+
exitCode: EXIT_CODES.auth,
|
|
889
|
+
hints: [{
|
|
890
|
+
command: [
|
|
891
|
+
cliCommandForChannel(channelFromProfile({ api_base: normalizedRequestedApiBase })),
|
|
892
|
+
`--profile ${quoteShellArgument(profileName)}`,
|
|
893
|
+
`--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
|
|
894
|
+
'login',
|
|
895
|
+
].join(' '),
|
|
896
|
+
reason: 'Authorize a separate OAuth grant for the requested Notis environment',
|
|
897
|
+
}],
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
|
|
901
|
+
apiBase = oauthApiBase;
|
|
902
|
+
}
|
|
169
903
|
const agentMode = isAgentMode(globalOptions);
|
|
170
904
|
const nonInteractive = isNonInteractive(globalOptions);
|
|
171
905
|
const outputMode = resolveOutputMode(globalOptions);
|
|
172
906
|
const timeoutMs = resolveTimeoutMs(globalOptions);
|
|
907
|
+
const debugEntitlementOverride = includeDebugEntitlementOverride
|
|
908
|
+
? parseDebugEntitlementOverride()
|
|
909
|
+
: null;
|
|
173
910
|
|
|
174
911
|
if (requireAuth && !jwt) {
|
|
175
912
|
throw new CliError({
|
|
176
913
|
code: 'auth_missing',
|
|
177
|
-
message: `
|
|
914
|
+
message: `Profile "${profileName}" has no Notis credential`,
|
|
915
|
+
exitCode: EXIT_CODES.auth,
|
|
916
|
+
hints: getAuthRecovery({ profileName, apiBase }, { mode: 'missing' }).hints,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
if (
|
|
920
|
+
devRuntime?.expected_user_id &&
|
|
921
|
+
(
|
|
922
|
+
credentialKind === 'oauth'
|
|
923
|
+
? profile.oauth_user_id
|
|
924
|
+
: getJwtSubject(jwt)
|
|
925
|
+
) !== devRuntime.expected_user_id
|
|
926
|
+
) {
|
|
927
|
+
throw new CliError({
|
|
928
|
+
code: 'dev_runtime_identity_mismatch',
|
|
929
|
+
message: `Profile "${profileName}" no longer holds this worktree's test identity`,
|
|
178
930
|
exitCode: EXIT_CODES.auth,
|
|
179
931
|
hints: [
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
reason: 'Configure a token for non-interactive use',
|
|
183
|
-
},
|
|
932
|
+
{ message: 'Restart ./dev.sh to restore the approved worktree identity.' },
|
|
933
|
+
{ message: `Expected user: ${devRuntime.expected_user_id}` },
|
|
184
934
|
],
|
|
185
935
|
});
|
|
186
936
|
}
|
|
@@ -188,15 +938,116 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
|
|
|
188
938
|
return {
|
|
189
939
|
config,
|
|
190
940
|
profileName,
|
|
941
|
+
profileSource,
|
|
942
|
+
profileLabel: profile.label,
|
|
943
|
+
// Which published build serves this profile. Carried on the runtime so
|
|
944
|
+
// every recovery hint prints the command that will actually run.
|
|
945
|
+
channel: devRuntime
|
|
946
|
+
? null
|
|
947
|
+
: channelFromProfile(
|
|
948
|
+
(globalOptions.apiBase || process.env.NOTIS_API_BASE)
|
|
949
|
+
// An explicit route owns this invocation even when the stored
|
|
950
|
+
// profile is pinned to the opposite release channel.
|
|
951
|
+
? { api_base: apiBase }
|
|
952
|
+
: { ...profile, api_base: apiBase },
|
|
953
|
+
),
|
|
191
954
|
apiBase,
|
|
955
|
+
requestedApiBase: normalizedRequestedApiBase,
|
|
192
956
|
jwt,
|
|
957
|
+
credentialKind,
|
|
958
|
+
credentialSource: credentialKind,
|
|
959
|
+
oauthAccessToken: profile.oauth_access_token,
|
|
960
|
+
oauthRefreshToken: profile.oauth_refresh_token,
|
|
961
|
+
oauthAccessExpiresAt: profile.oauth_access_expires_at,
|
|
962
|
+
oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
|
|
963
|
+
oauthClientId: profile.oauth_client_id,
|
|
964
|
+
oauthIssuer: profile.oauth_issuer,
|
|
965
|
+
oauthApiBase,
|
|
966
|
+
oauthResource,
|
|
967
|
+
oauthScopes: profile.oauth_scopes || [],
|
|
968
|
+
oauthUserId: profile.oauth_user_id,
|
|
193
969
|
agentMode,
|
|
194
970
|
nonInteractive,
|
|
195
971
|
outputMode,
|
|
196
972
|
timeoutMs,
|
|
973
|
+
debugEntitlementOverride,
|
|
974
|
+
worktreeRuntime: devRuntime,
|
|
975
|
+
detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
|
|
976
|
+
worktreeRuntimeUnavailable: worktreeUnavailable,
|
|
197
977
|
};
|
|
198
978
|
}
|
|
199
979
|
|
|
980
|
+
export function getJwtExpiration(jwt) {
|
|
981
|
+
if (typeof jwt !== 'string' || !jwt) {
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
try {
|
|
985
|
+
const parts = jwt.split('.');
|
|
986
|
+
if (parts.length !== 3) return null;
|
|
987
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
988
|
+
return typeof payload.exp === 'number' ? payload.exp : null;
|
|
989
|
+
} catch {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function getJwtSubject(jwt) {
|
|
995
|
+
if (typeof jwt !== 'string' || !jwt) {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
const parts = jwt.split('.');
|
|
1000
|
+
if (parts.length !== 3) return null;
|
|
1001
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
1002
|
+
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
1009
|
+
const expiration = getJwtExpiration(jwt);
|
|
1010
|
+
return expiration !== null && expiration <= nowSeconds;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export function credentialIsExpired(
|
|
1014
|
+
runtime,
|
|
1015
|
+
profile = {},
|
|
1016
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
1017
|
+
) {
|
|
1018
|
+
// A credential with no declared kind is not one this CLI knows how to keep
|
|
1019
|
+
// alive, so it fails closed rather than defaulting to a permissive shape.
|
|
1020
|
+
const credentialKind = runtime?.credentialKind
|
|
1021
|
+
|| (runtime?.credentialSource === 'env' ? 'env' : null);
|
|
1022
|
+
switch (credentialKind) {
|
|
1023
|
+
case 'oauth': {
|
|
1024
|
+
const expiration = Number(profile.oauth_access_expires_at);
|
|
1025
|
+
return !Number.isFinite(expiration) || expiration <= nowSeconds;
|
|
1026
|
+
}
|
|
1027
|
+
case 'env':
|
|
1028
|
+
// NOTIS_JWT is a complete override. Never combine it with expiry
|
|
1029
|
+
// metadata belonging to a different credential in the same profile.
|
|
1030
|
+
// A token with no readable `exp` is a personal API key, which never
|
|
1031
|
+
// expires, so let the server rather than the CLI reject it.
|
|
1032
|
+
{
|
|
1033
|
+
const expiration = getJwtExpiration(runtime?.jwt);
|
|
1034
|
+
return expiration !== null && expiration <= nowSeconds;
|
|
1035
|
+
}
|
|
1036
|
+
case 'worktree': {
|
|
1037
|
+
const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
|
|
1038
|
+
// Every selected credential must carry an independently verifiable
|
|
1039
|
+
// expiry. Missing or malformed expiry metadata fails closed.
|
|
1040
|
+
if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
|
|
1041
|
+
return true;
|
|
1042
|
+
}
|
|
1043
|
+
const expiration = Number(rawExpiration);
|
|
1044
|
+
return !Number.isFinite(expiration) || expiration <= nowSeconds;
|
|
1045
|
+
}
|
|
1046
|
+
default:
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
200
1051
|
export function workspacePath(appId) {
|
|
201
1052
|
return join(WORKSPACE_DIR, appId);
|
|
202
1053
|
}
|