@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.
@@ -0,0 +1,358 @@
1
+ import { CliError, EXIT_CODES } from '../runtime/errors.js';
2
+ import { quoteShellArgument } from '../runtime/auth-recovery.js';
3
+ import {
4
+ DEFAULT_PROFILE,
5
+ getProfile,
6
+ listProfiles,
7
+ loadConfig,
8
+ normalizeConfig,
9
+ profileHasCredential,
10
+ profileExists,
11
+ resolveWorktreeRuntime,
12
+ updateConfig,
13
+ } from '../runtime/profiles.js';
14
+
15
+ function describeProfile(config, name, worktreeRuntime) {
16
+ const entry = listProfiles(config).find((candidate) => candidate.name === name);
17
+ if (worktreeRuntime?.profile === name) {
18
+ return {
19
+ name,
20
+ active: false,
21
+ api_base: worktreeRuntime.api_base,
22
+ label: './dev.sh worktree',
23
+ credential_kind: 'dev',
24
+ user_id: worktreeRuntime.expected_user_id || null,
25
+ authenticated: true,
26
+ dev_runtime_live: true,
27
+ };
28
+ }
29
+ if (!entry) return null;
30
+ return { ...entry, dev_runtime_live: false };
31
+ }
32
+
33
+ function unknownProfileError(name, config) {
34
+ return new CliError({
35
+ code: 'profile_unknown',
36
+ message: `No CLI profile named "${name}"`,
37
+ exitCode: EXIT_CODES.usage,
38
+ details: { known_profiles: Object.keys(normalizeConfig(config).profiles) },
39
+ hints: [
40
+ { command: 'notis profile list', reason: 'See which profiles this machine has' },
41
+ { command: `notis login --profile ${quoteShellArgument(name)}`, reason: 'Authorize a new account under this name' },
42
+ ],
43
+ });
44
+ }
45
+
46
+ function currentWorktreeState() {
47
+ const resolved = resolveWorktreeRuntime();
48
+ return resolved?.unavailable
49
+ ? { runtime: null, unavailable: resolved.unavailable }
50
+ : { runtime: resolved, unavailable: null };
51
+ }
52
+
53
+ function unavailableForEffectiveRoute(ctx, unavailable) {
54
+ return ctx.runtime.profileSource === 'explicit' ? null : unavailable;
55
+ }
56
+
57
+ async function listHandler(ctx) {
58
+ const config = loadConfig();
59
+ const { runtime: worktreeRuntime, unavailable: worktreeUnavailable } = currentWorktreeState();
60
+ const effectiveWorktreeUnavailable = unavailableForEffectiveRoute(ctx, worktreeUnavailable);
61
+ const profiles = listProfiles(config).map((entry) =>
62
+ describeProfile(config, entry.name, worktreeRuntime));
63
+ if (
64
+ worktreeRuntime
65
+ && !profiles.some((entry) => entry.name === worktreeRuntime.profile)
66
+ ) {
67
+ profiles.unshift(describeProfile(config, worktreeRuntime.profile, worktreeRuntime));
68
+ }
69
+ // In a hosted shell every profile reads as signed out while commands work
70
+ // fine, because NOTIS_JWT overrides all of them. Say so rather than letting
71
+ // an agent conclude it needs to authorize something.
72
+ const envOverride = ctx.runtime.credentialKind === 'env';
73
+
74
+ return ctx.output.emitSuccess({
75
+ command: ctx.spec.command_path.join(' '),
76
+ data: {
77
+ active_profile: normalizeConfig(config).current_profile,
78
+ effective_profile: effectiveWorktreeUnavailable ? null : ctx.runtime.profileName,
79
+ effective_profile_source: effectiveWorktreeUnavailable
80
+ ? 'worktree-unavailable'
81
+ : ctx.runtime.profileSource,
82
+ worktree_runtime_unavailable: Boolean(effectiveWorktreeUnavailable),
83
+ effective_credential_kind: ctx.runtime.credentialKind || null,
84
+ env_credential_override: envOverride,
85
+ profiles,
86
+ },
87
+ humanSummary: effectiveWorktreeUnavailable
88
+ ? `${profiles.length} stored CLI profile${profiles.length === 1 ? '' : 's'}; this local-only worktree is stopped`
89
+ : envOverride
90
+ ? `NOTIS_JWT overrides all ${profiles.length} stored profile${profiles.length === 1 ? '' : 's'}`
91
+ : `${profiles.length} CLI profile${profiles.length === 1 ? '' : 's'} on this machine`,
92
+ renderHuman: () =>
93
+ [
94
+ ...(envOverride
95
+ ? ['NOTIS_JWT is set and takes precedence over every profile below.', '']
96
+ : []),
97
+ ...profiles.map((entry) => {
98
+ const marker = !envOverride && entry.name === ctx.runtime.profileName ? '*' : ' ';
99
+ const auth = entry.authenticated ? entry.credential_kind : 'signed out';
100
+ const live = entry.dev_runtime_live ? ' (dev.sh running)' : '';
101
+ return `${marker} ${entry.name.padEnd(16)} ${String(entry.api_base).padEnd(32)} ${auth}${live}`;
102
+ }),
103
+ ].join('\n'),
104
+ hints: [
105
+ ...(effectiveWorktreeUnavailable
106
+ ? [{
107
+ command: 'notis --profile <name> <command>',
108
+ reason: 'Explicitly escape the stopped local-only worktree for one command',
109
+ }]
110
+ : []),
111
+ { command: 'notis profile use <name>', reason: 'Switch the default account outside this worktree' },
112
+ { command: 'notis login --profile <name>', reason: 'Add another account without signing this one out' },
113
+ ],
114
+ });
115
+ }
116
+
117
+ async function useHandler(ctx) {
118
+ const requested = ctx.args.name;
119
+ const config = loadConfig();
120
+ if (!profileExists(config, requested)) {
121
+ throw unknownProfileError(requested, config);
122
+ }
123
+
124
+ // Switching only moves the pointer. Every profile keeps its own credential so
125
+ // going back is another `profile use`, never another browser authorization.
126
+ const next = updateConfig((latest) => {
127
+ latest.current_profile = requested;
128
+ return latest;
129
+ });
130
+ const { runtime: worktreeRuntime, unavailable: worktreeUnavailable } = currentWorktreeState();
131
+ const profile = getProfile(next, requested);
132
+ const worktreeOverride = Boolean(worktreeRuntime);
133
+ const worktreeBlocked = Boolean(worktreeUnavailable);
134
+ const authenticated = profileHasCredential(profile);
135
+
136
+ return ctx.output.emitSuccess({
137
+ command: ctx.spec.command_path.join(' '),
138
+ data: {
139
+ active_profile: requested,
140
+ ...describeProfile(next, requested, worktreeRuntime),
141
+ effective_profile: worktreeBlocked
142
+ ? null
143
+ : worktreeOverride ? worktreeRuntime.profile : requested,
144
+ effective_profile_source: worktreeBlocked
145
+ ? 'worktree-unavailable'
146
+ : worktreeOverride ? 'worktree' : 'current',
147
+ worktree_runtime_unavailable: worktreeBlocked,
148
+ },
149
+ humanSummary: worktreeBlocked
150
+ ? `Saved profile "${requested}" as the default outside this worktree; this local-only checkout is stopped, so use --profile explicitly or restart ./dev.sh.`
151
+ : worktreeOverride
152
+ ? `Saved profile "${requested}" as the default outside this worktree; this checkout still uses "${worktreeRuntime.profile}" unless --profile is explicit.`
153
+ : authenticated
154
+ ? `Switched to profile "${requested}".`
155
+ : `Switched to profile "${requested}", which has no credential yet.`,
156
+ hints: worktreeBlocked
157
+ ? [
158
+ {
159
+ command: `notis --profile ${quoteShellArgument(requested)} whoami`,
160
+ reason: 'Explicitly use and confirm this account while the local worktree is stopped',
161
+ },
162
+ { message: 'Restart ./dev.sh to restore the worktree test identity.' },
163
+ ]
164
+ : worktreeOverride
165
+ ? [
166
+ {
167
+ command: `notis --profile ${quoteShellArgument(requested)} whoami`,
168
+ reason: 'Use and confirm this account explicitly inside the active worktree',
169
+ },
170
+ ...(!authenticated
171
+ ? [{
172
+ command: `notis login --profile ${quoteShellArgument(requested)}`,
173
+ reason: 'Authorize an account for this profile',
174
+ }]
175
+ : []),
176
+ ]
177
+ : authenticated
178
+ ? [{ command: 'notis whoami', reason: 'Confirm the account and API this profile targets' }]
179
+ : [{ command: `notis login --profile ${quoteShellArgument(requested)}`, reason: 'Authorize an account for this profile' }],
180
+ });
181
+ }
182
+
183
+ async function showHandler(ctx) {
184
+ const config = loadConfig();
185
+ const name = ctx.args.name || ctx.runtime.profileName;
186
+ const { runtime: worktreeRuntime, unavailable: worktreeUnavailable } = currentWorktreeState();
187
+ const effectiveWorktreeUnavailable = unavailableForEffectiveRoute(ctx, worktreeUnavailable);
188
+ const described = describeProfile(config, name, worktreeRuntime);
189
+ if (!described) {
190
+ throw unknownProfileError(name, config);
191
+ }
192
+ const profile = getProfile(config, name);
193
+ const envOverride = ctx.runtime.credentialKind === 'env';
194
+
195
+ return ctx.output.emitSuccess({
196
+ command: ctx.spec.command_path.join(' '),
197
+ data: {
198
+ ...described,
199
+ env_credential_override: envOverride,
200
+ worktree_runtime_unavailable: Boolean(effectiveWorktreeUnavailable),
201
+ oauth_scopes: profile.oauth_scopes || [],
202
+ oauth_access_expires_at: profile.oauth_access_expires_at || null,
203
+ oauth_refresh_expires_at: profile.oauth_refresh_expires_at || null,
204
+ dev_workspace_root:
205
+ profile.dev_workspace_root || (
206
+ worktreeRuntime?.profile === name
207
+ ? worktreeRuntime.workspace_root || null
208
+ : null
209
+ ),
210
+ },
211
+ humanSummary: `Profile "${name}" targets ${described.api_base}`,
212
+ renderHuman: () =>
213
+ [
214
+ `Profile: ${name}`,
215
+ `API: ${described.api_base}`,
216
+ `User: ${described.user_id || 'unknown'}`,
217
+ `Credential:${described.credential_kind ? ` ${described.credential_kind}` : ' none'}`,
218
+ `Active: ${described.active ? 'yes' : 'no'}`,
219
+ ...(envOverride
220
+ ? ['', 'NOTIS_JWT is set and overrides this profile\'s credential.']
221
+ : []),
222
+ ].join('\n'),
223
+ hints: effectiveWorktreeUnavailable
224
+ ? [{
225
+ command: `notis --profile ${quoteShellArgument(name)} whoami`,
226
+ reason: 'Explicitly use this profile while the local-only worktree is stopped',
227
+ }]
228
+ : [],
229
+ });
230
+ }
231
+
232
+ async function removeHandler(ctx) {
233
+ const requested = ctx.args.name;
234
+ const config = loadConfig();
235
+ if (!profileExists(config, requested)) {
236
+ throw unknownProfileError(requested, config);
237
+ }
238
+ if (requested === DEFAULT_PROFILE) {
239
+ throw new CliError({
240
+ code: 'profile_not_removable',
241
+ message: 'The "default" profile cannot be removed',
242
+ exitCode: EXIT_CODES.usage,
243
+ hints: [{ command: 'notis logout', reason: 'Clear its credential instead of removing the profile' }],
244
+ });
245
+ }
246
+ if (profileHasCredential(getProfile(config, requested)) && !ctx.options.force) {
247
+ throw new CliError({
248
+ code: 'profile_still_authorized',
249
+ message: `Profile "${requested}" still holds a credential`,
250
+ exitCode: EXIT_CODES.usage,
251
+ hints: [
252
+ {
253
+ command: `notis logout --profile ${quoteShellArgument(requested)}`,
254
+ reason: 'Revoke the grant server-side before discarding it locally',
255
+ },
256
+ { command: `notis profile remove ${quoteShellArgument(requested)} --force`, reason: 'Discard the local credential without revoking it' },
257
+ ],
258
+ });
259
+ }
260
+
261
+ const next = updateConfig((latest) => {
262
+ delete latest.profiles[requested];
263
+ if (latest.current_profile === requested) {
264
+ latest.current_profile = DEFAULT_PROFILE;
265
+ }
266
+ return latest;
267
+ });
268
+
269
+ return ctx.output.emitSuccess({
270
+ command: ctx.spec.command_path.join(' '),
271
+ data: {
272
+ removed_profile: requested,
273
+ active_profile: normalizeConfig(next).current_profile,
274
+ },
275
+ humanSummary: `Removed profile "${requested}".`,
276
+ });
277
+ }
278
+
279
+ export const profileCommandSpecs = [
280
+ {
281
+ command_path: ['profile', 'list'],
282
+ summary: 'List every CLI profile with its account, API endpoint, and credential state.',
283
+ when_to_use:
284
+ 'Use this to see which accounts and environments this machine can reach before choosing one.',
285
+ args_schema: { arguments: [], options: [] },
286
+ examples: ['notis profile list', 'notis profile list --json'],
287
+ output_schema:
288
+ 'Returns active_profile, effective_profile, and a profiles array of {name, api_base, credential_kind, user_id, authenticated, dev_runtime_live}.',
289
+ mutates: false,
290
+ idempotent: true,
291
+ require_auth: false,
292
+ allow_unknown_profile: true,
293
+ related_commands: ['notis profile use', 'notis login', 'notis whoami'],
294
+ backend_call: { type: 'local_config' },
295
+ handler: listHandler,
296
+ },
297
+ {
298
+ command_path: ['profile', 'use'],
299
+ summary: 'Switch the default profile without signing any profile out.',
300
+ when_to_use:
301
+ 'Use this to change which account and API subsequent commands target. Every other profile keeps its credential.',
302
+ args_schema: {
303
+ arguments: [{ token: '<name>', key: 'name', description: 'Profile to make active.' }],
304
+ options: [],
305
+ },
306
+ examples: ['notis profile use work', 'notis profile use default'],
307
+ output_schema: 'Returns the newly active profile with its api_base, user_id, and credential kind.',
308
+ mutates: true,
309
+ idempotent: true,
310
+ require_auth: false,
311
+ allow_unknown_profile: true,
312
+ related_commands: ['notis profile list', 'notis login'],
313
+ backend_call: { type: 'local_config' },
314
+ handler: useHandler,
315
+ },
316
+ {
317
+ command_path: ['profile', 'show'],
318
+ summary: 'Show one profile in detail, including scopes and credential expiry.',
319
+ when_to_use: 'Use this to inspect exactly which account and endpoint a profile resolves to.',
320
+ args_schema: {
321
+ arguments: [
322
+ { token: '[name]', key: 'name', description: 'Profile to inspect; defaults to the active one.' },
323
+ ],
324
+ options: [],
325
+ },
326
+ examples: ['notis profile show', 'notis profile show work --json'],
327
+ output_schema:
328
+ 'Returns name, api_base, user_id, credential_kind, oauth scopes and expiries, and dev runtime state.',
329
+ mutates: false,
330
+ idempotent: true,
331
+ require_auth: false,
332
+ allow_unknown_profile: true,
333
+ related_commands: ['notis profile list', 'notis whoami'],
334
+ backend_call: { type: 'local_config' },
335
+ handler: showHandler,
336
+ },
337
+ {
338
+ command_path: ['profile', 'remove'],
339
+ summary: 'Delete a CLI profile from this machine.',
340
+ when_to_use:
341
+ 'Use this after logging a profile out. Removing a still-authorized profile requires --force and leaves the grant live server-side.',
342
+ args_schema: {
343
+ arguments: [{ token: '<name>', key: 'name', description: 'Profile to delete.' }],
344
+ options: [
345
+ { flags: '--force', description: 'Discard a profile that still holds a credential.' },
346
+ ],
347
+ },
348
+ examples: ['notis profile remove old-work', 'notis profile remove old-work --force'],
349
+ output_schema: 'Returns removed_profile and the resulting active_profile.',
350
+ mutates: true,
351
+ idempotent: true,
352
+ require_auth: false,
353
+ allow_unknown_profile: true,
354
+ related_commands: ['notis logout', 'notis profile list'],
355
+ backend_call: { type: 'local_config' },
356
+ handler: removeHandler,
357
+ },
358
+ ];
@@ -24,7 +24,7 @@ const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
24
24
  const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
25
25
  const METADATA_DIR = 'metadata';
26
26
  const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
27
- const MONOREPO_APPS_DIR = resolve(CLI_ROOT, '../..', 'apps');
27
+ const MONOREPO_SCAFFOLDS_DIR = resolve(CLI_ROOT, '../..', 'scaffolds');
28
28
  const DIST_DIR = join(CLI_ROOT, 'dist');
29
29
  const SCAFFOLD_CATALOG_FILE = join(DIST_DIR, 'scaffolds.json');
30
30
  const SCAFFOLD_SOURCE_DIR = join(DIST_DIR, 'scaffolds');
@@ -1075,7 +1075,7 @@ function resolveScaffoldSourceDir(fromSlug) {
1075
1075
  if (existsSync(bundledDir)) {
1076
1076
  return bundledDir;
1077
1077
  }
1078
- const monorepoDir = join(MONOREPO_APPS_DIR, fromSlug);
1078
+ const monorepoDir = join(MONOREPO_SCAFFOLDS_DIR, fromSlug);
1079
1079
  if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
1080
1080
  return monorepoDir;
1081
1081
  }
@@ -1083,15 +1083,15 @@ function resolveScaffoldSourceDir(fromSlug) {
1083
1083
  }
1084
1084
 
1085
1085
  function loadMonorepoScaffoldCatalog() {
1086
- if (!existsSync(MONOREPO_APPS_DIR)) {
1086
+ if (!existsSync(MONOREPO_SCAFFOLDS_DIR)) {
1087
1087
  return [];
1088
1088
  }
1089
1089
  const scaffolds = [];
1090
- for (const entry of readdirSync(MONOREPO_APPS_DIR, { withFileTypes: true })) {
1090
+ for (const entry of readdirSync(MONOREPO_SCAFFOLDS_DIR, { withFileTypes: true })) {
1091
1091
  if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
1092
1092
  continue;
1093
1093
  }
1094
- const configPath = join(MONOREPO_APPS_DIR, entry.name, 'notis.config.ts');
1094
+ const configPath = join(MONOREPO_SCAFFOLDS_DIR, entry.name, 'notis.config.ts');
1095
1095
  if (!existsSync(configPath)) {
1096
1096
  continue;
1097
1097
  }
@@ -0,0 +1,100 @@
1
+ import { CliError, EXIT_CODES } from './errors.js';
2
+
3
+ const CLI_NPX = 'npx --package @notis_ai/cli@latest -- notis';
4
+
5
+ export function quoteShellArgument(value) {
6
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
7
+ }
8
+
9
+ function profileSuffix(profileName) {
10
+ return profileName && profileName !== 'default'
11
+ ? ` --profile ${quoteShellArgument(profileName)}`
12
+ : '';
13
+ }
14
+
15
+ /**
16
+ * How to get an unusable profile back to a working credential.
17
+ *
18
+ * `mode` distinguishes the two cases a caller has to act on differently:
19
+ * "expired" means the profile holds a grant the browser can renew, while
20
+ * "missing" means this profile has never been authorized at all.
21
+ */
22
+ export function getAuthRecovery({ profileName } = {}, { mode = 'expired' } = {}) {
23
+ const suffix = profileSuffix(profileName);
24
+ const hints = [
25
+ {
26
+ command: `${CLI_NPX} login${suffix}`,
27
+ reason: mode === 'missing'
28
+ ? 'Sign in or create an account in the browser and authorize this machine'
29
+ : 'Authorize a fresh scoped CLI credential for this profile',
30
+ },
31
+ {
32
+ command: `${CLI_NPX} profile list`,
33
+ reason: 'Check whether another profile on this machine is already signed in',
34
+ },
35
+ {
36
+ command: `${CLI_NPX} doctor${suffix}`,
37
+ reason: 'Retry the auth and API checks once authorization completes',
38
+ },
39
+ ];
40
+ return { hints };
41
+ }
42
+
43
+ export function createExpiredAuthError(runtime) {
44
+ if (runtime?.credentialKind === 'worktree') {
45
+ return new CliError({
46
+ code: 'auth_expired',
47
+ message: `The ./dev.sh credential for profile "${runtime.profileName}" has expired`,
48
+ exitCode: EXIT_CODES.auth,
49
+ details: { credential_source: 'worktree' },
50
+ hints: [
51
+ { message: 'Restart ./dev.sh in this worktree to mint a fresh dev credential.' },
52
+ {
53
+ command: `${CLI_NPX} profile list`,
54
+ reason: 'Switch to a live account profile instead of the dev one',
55
+ },
56
+ ],
57
+ });
58
+ }
59
+ if (runtime?.credentialSource === 'env') {
60
+ return new CliError({
61
+ code: 'auth_expired',
62
+ message: 'NOTIS_JWT is expired',
63
+ exitCode: EXIT_CODES.auth,
64
+ details: { credential_source: 'env' },
65
+ hints: [
66
+ {
67
+ command: 'Set NOTIS_JWT to a fresh token',
68
+ reason: 'The explicit environment credential overrides every stored profile',
69
+ },
70
+ ],
71
+ });
72
+ }
73
+
74
+ return new CliError({
75
+ code: 'auth_expired',
76
+ message: `Notis CLI authorization for profile "${runtime?.profileName || 'default'}" has expired`,
77
+ exitCode: EXIT_CODES.auth,
78
+ details: { credential_source: 'oauth' },
79
+ hints: getAuthRecovery(runtime).hints,
80
+ });
81
+ }
82
+
83
+ export function createInvalidAuthHints(runtime) {
84
+ if (runtime?.credentialKind === 'worktree') {
85
+ return [
86
+ {
87
+ message: 'Restart ./dev.sh in this worktree to mint a fresh dev credential.',
88
+ },
89
+ ];
90
+ }
91
+ if (runtime?.credentialSource === 'env') {
92
+ return [
93
+ {
94
+ command: 'Set NOTIS_JWT to a fresh token',
95
+ reason: 'The explicit environment credential was rejected',
96
+ },
97
+ ];
98
+ }
99
+ return getAuthRecovery(runtime || {}).hints;
100
+ }
@@ -1,4 +1,5 @@
1
1
  // Auto-generated on npm publish.
2
- // Committed value is 'local' so in-repo runs target localhost.
3
- // scripts/set-cli-mode.js rewrites this to 'published' before the npm publish step.
4
- export const MODE = 'local';
2
+ // Committed value is 'published' so the CLI defaults to the live Notis API.
3
+ // Localhost overrides come only from the worktree test lease (`./dev.sh`).
4
+ // scripts/set-cli-mode.js can rewrite this for publish/labeling experiments.
5
+ export const MODE = 'published';
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * CLI mode detection for `notis apps dev`.
3
3
  *
4
- * The repo-local CLI package targets localhost; the published npm CLI package
5
- * targets app.notis.ai. Mode is baked into `cli-mode.generated.js` at publish
6
- * timeno runtime flag is needed (and none is exposed to end users, so a
7
- * crafted env var can't trick a published CLI into opening a localhost portal
8
- * that isn't running).
4
+ * The published npm CLI and the repo-local checkout both default to the live
5
+ * Notis API (`api.notis.ai` / `api-beta.notis.ai`). Localhost is not a CLI
6
+ * defaultthe `/notis-tests` worktree lease (`./dev.sh`) is the only
7
+ * supported loopback override for Notis developers.
9
8
  *
10
- * The `NOTIS_CLI_MODE` env var is honored for internal testing only.
9
+ * Mode is baked into `cli-mode.generated.js` at publish time. The
10
+ * `NOTIS_CLI_MODE` env var is honored for internal labeling only (e.g. the
11
+ * `apps` auto-dev loop inside `./dev.sh`).
11
12
  */
12
13
 
13
14
  import { MODE as BAKED_MODE } from './cli-mode.generated.js';
@@ -20,8 +21,12 @@ export function getCliMode() {
20
21
  return BAKED_MODE === 'published' ? 'published' : 'local';
21
22
  }
22
23
 
23
- export function getDefaultApiBase(mode = getCliMode()) {
24
- return mode === 'local' ? 'http://localhost:3001' : 'https://api.notis.ai';
24
+ export function getDefaultApiBase(mode = getCliMode(), { beta = false } = {}) {
25
+ // Mode no longer switches the default API to localhost. Local loopback is
26
+ // reserved for the worktree test lease; `mode` only affects portal origin
27
+ // labeling for the in-repo apps-dev helper.
28
+ void mode;
29
+ return beta ? 'https://api-beta.notis.ai' : 'https://api.notis.ai';
25
30
  }
26
31
 
27
32
  export function getDefaultPortalOrigin(mode = getCliMode()) {