@notis_ai/cli 0.2.22 → 0.2.24

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.
@@ -153,32 +153,46 @@ function withConfigFileWriteLock(configFile, callback) {
153
153
  }
154
154
 
155
155
  // src/runtime/channel.js
156
- var RELEASE_CHANNELS = ["stable", "beta"];
157
- var CHANNEL_TAGS = { stable: "latest", beta: "beta" };
156
+ var RELEASE_CHANNELS = ["stable", "nightly"];
157
+ var CHANNEL_TAGS = { stable: "latest", nightly: "nightly" };
158
+ var API_HOST_CHANNELS = {
159
+ "api.notis.ai": "stable",
160
+ "api-beta.notis.ai": "nightly",
161
+ "api-nightly.notis.ai": "nightly"
162
+ };
158
163
  var CLI_PACKAGE_NAME = "@notis_ai/cli";
159
164
  function isReleaseChannel(value) {
160
165
  return RELEASE_CHANNELS.includes(value);
161
166
  }
167
+ function normalizeReleaseChannel(value) {
168
+ if (value === "beta") return "nightly";
169
+ return isReleaseChannel(value) ? value : null;
170
+ }
171
+ function channelFromApiBase(apiBase) {
172
+ if (typeof apiBase !== "string" || !apiBase) return null;
173
+ try {
174
+ return API_HOST_CHANNELS[new URL(apiBase).hostname] || null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
162
179
  function packageTagForChannel(channel) {
163
180
  return CHANNEL_TAGS[channel] || CHANNEL_TAGS.stable;
164
181
  }
165
182
  function cliCommandForChannel(channel) {
166
183
  return `npx --package ${CLI_PACKAGE_NAME}@${packageTagForChannel(channel)} -- notis`;
167
184
  }
168
- function channelFromProfile(profile = {}) {
169
- if (isReleaseChannel(profile.channel)) {
170
- return profile.channel;
171
- }
172
- if (profile.beta === true) return "beta";
185
+ function pinnedReleaseChannel(profile = {}) {
186
+ if (profile.beta === true) return "nightly";
173
187
  if (profile.beta === false) return "stable";
188
+ return normalizeReleaseChannel(profile.channel) || normalizeReleaseChannel(profile.release_channel);
189
+ }
190
+ function channelFromProfile(profile = {}) {
191
+ const pinned = pinnedReleaseChannel(profile);
192
+ if (pinned) return pinned;
174
193
  for (const candidate of [profile.oauth_api_base, profile.api_base]) {
175
- if (typeof candidate !== "string" || !candidate) continue;
176
- try {
177
- const { hostname } = new URL(candidate);
178
- if (hostname === "api-beta.notis.ai") return "beta";
179
- if (hostname === "api.notis.ai") return "stable";
180
- } catch {
181
- }
194
+ const channel = channelFromApiBase(candidate);
195
+ if (channel) return channel;
182
196
  }
183
197
  return null;
184
198
  }
@@ -241,7 +255,7 @@ var CONFIG_DIR = join2(homedir2(), ".notis");
241
255
  var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
242
256
  var WORKSPACE_DIR = join2(CONFIG_DIR, "workspace");
243
257
  var DEFAULT_API_BASE = "https://api.notis.ai";
244
- var BETA_API_BASE = "https://api-beta.notis.ai";
258
+ var NIGHTLY_API_BASE = "https://api-nightly.notis.ai";
245
259
  var DEFAULT_PROFILE = "default";
246
260
  var PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
247
261
  var LEGACY_DESKTOP_PROFILE_KEYS = [
@@ -256,7 +270,7 @@ var LEGACY_DESKTOP_PROFILE_KEYS = [
256
270
  var WORKTREE_RUNTIME_FILENAME = join2(".context", "notis-runtime.json");
257
271
  var WORKTREE_ROUTING_FILENAME = join2(".context", "notis-routing.json");
258
272
  var LOCAL_API_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
259
- var LIVE_API_HOSTS = /* @__PURE__ */ new Set(["api.notis.ai", "api-beta.notis.ai"]);
273
+ var LIVE_API_HOSTS = /* @__PURE__ */ new Set(["api.notis.ai", "api-nightly.notis.ai", "api-beta.notis.ai"]);
260
274
  function clone(value) {
261
275
  return JSON.parse(JSON.stringify(value));
262
276
  }
@@ -283,11 +297,10 @@ function normalizeProfile(rawProfile = {}) {
283
297
  const raw = rawProfile && typeof rawProfile === "object" ? rawProfile : {};
284
298
  return {
285
299
  api_base: typeof raw.api_base === "string" ? raw.api_base : void 0,
286
- beta: typeof raw.beta === "boolean" ? raw.beta : void 0,
287
- // Which published CLI build this profile runs, as the deployment reported
288
- // it at login. Unknown values are dropped rather than trusted: this key
289
- // decides which code executes on the next run.
290
- channel: isReleaseChannel(raw.channel) ? raw.channel : void 0,
300
+ // Which published CLI build this profile runs (`stable` | `nightly`), as
301
+ // the account reported it at login. Unknown values are dropped rather
302
+ // than trusted: this key decides which code executes on the next run.
303
+ release_channel: pinnedReleaseChannel(raw) || void 0,
291
304
  label: typeof raw.label === "string" ? raw.label : void 0,
292
305
  dev_access_token: typeof raw.dev_access_token === "string" ? raw.dev_access_token : void 0,
293
306
  dev_access_expires_at: typeof raw.dev_access_expires_at === "number" ? raw.dev_access_expires_at : void 0,
@@ -546,21 +559,8 @@ function isLiveApiBase(value) {
546
559
  }
547
560
  }
548
561
  function resolveDefaultLiveApiBase(profile = {}) {
549
- if (profile.beta === true) {
550
- return BETA_API_BASE;
551
- }
552
- if (profile.beta === false) {
553
- return DEFAULT_API_BASE;
554
- }
555
- if (isLiveApiBase(profile.api_base)) {
556
- try {
557
- if (new URL(profile.api_base).hostname === "api-beta.notis.ai") {
558
- return BETA_API_BASE;
559
- }
560
- } catch {
561
- }
562
- }
563
- return DEFAULT_API_BASE;
562
+ const channel = pinnedReleaseChannel(profile) || (isLiveApiBase(profile.api_base) ? channelFromProfile({ api_base: profile.api_base }) : null);
563
+ return channel === "nightly" ? NIGHTLY_API_BASE : DEFAULT_API_BASE;
564
564
  }
565
565
  function getApiBase(config, profileName, override) {
566
566
  if (override) {
@@ -1238,14 +1238,6 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
1238
1238
  const now = Math.floor(Date.now() / 1e3);
1239
1239
  const payload = decodeJwtPayload(tokenResponse.access_token);
1240
1240
  const oauthApiBase = (metadata.apiBase || runtime.apiBase || "").replace(/\/+$/, "");
1241
- let beta;
1242
- try {
1243
- const hostname = new URL(oauthApiBase).hostname;
1244
- if (hostname === "api-beta.notis.ai") beta = true;
1245
- else if (hostname === "api.notis.ai") beta = false;
1246
- } catch {
1247
- beta = void 0;
1248
- }
1249
1241
  const config = updateConfig((latest) => {
1250
1242
  const next = ensureProfile(latest, runtime.profileName);
1251
1243
  const profile = next.profiles[runtime.profileName];
@@ -1255,11 +1247,12 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
1255
1247
  // one API, and the environment the user just authorized against is the
1256
1248
  // only endpoint the resulting token is accepted by.
1257
1249
  api_base: oauthApiBase || profile.api_base,
1258
- beta: beta ?? profile.beta,
1259
1250
  // The deployment that just authorized this profile also names the
1260
1251
  // published build that belongs to it. Pinning it here is what lets the
1261
1252
  // next run correct itself without the user knowing a channel exists.
1262
- channel: isReleaseChannel(metadata.channel) ? metadata.channel : channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase }) ?? profile.channel,
1253
+ // followAccountReleaseChannel then replaces it with the account's own
1254
+ // answer once the new grant can ask for it.
1255
+ release_channel: metadata.channel || channelFromApiBase(oauthApiBase) || profile.release_channel,
1263
1256
  oauth_api_base: oauthApiBase || profile.oauth_api_base,
1264
1257
  oauth_resource: metadata.resource,
1265
1258
  oauth_access_token: tokenResponse.access_token,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "Agent-first Notis CLI for Spaces, Skills and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,8 +52,8 @@
52
52
  "react-dom": "19.3.0"
53
53
  },
54
54
  "notisRelease": {
55
- "sourceSha": "82af83a450ae64171067fd28ff951e7392320a12",
55
+ "sourceSha": "a7010008e66c7091ebc496a82627bd42a1c5ead6",
56
56
  "channel": "stable"
57
57
  },
58
- "gitHead": "82af83a450ae64171067fd28ff951e7392320a12"
58
+ "gitHead": "a7010008e66c7091ebc496a82627bd42a1c5ead6"
59
59
  }
package/src/cli.js CHANGED
@@ -13,6 +13,7 @@ import { withSkillSyncLock } from './runtime/sync-skills.js';
13
13
  import { maybeInstallSkillSyncService } from './runtime/skill-sync-service.js';
14
14
  import {
15
15
  CHANNEL_SWITCH_ENV,
16
+ CHANNEL_SWITCHED,
16
17
  resolveChannelSwitch,
17
18
  } from './runtime/channel.js';
18
19
  import {
@@ -270,8 +271,8 @@ export function channelProfileForArgs(
270
271
  { config: suppliedConfig, worktreeRuntime: suppliedWorktreeRuntime } = {},
271
272
  ) {
272
273
  // An explicit endpoint overrides the stored profile for this run, so it also
273
- // decides the build: `--api-base https://api-beta.notis.ai` on a production
274
- // profile is a deliberate one-off beta call.
274
+ // decides the build: `--api-base https://api-nightly.notis.ai` on a stable
275
+ // profile is a deliberate one-off nightly call.
275
276
  const { profile: profileName, apiBase } = readChannelRelevantFlags(args);
276
277
  if (apiBase) {
277
278
  return { api_base: apiBase };
@@ -313,7 +314,7 @@ export function executeChannelSwitch(
313
314
  env,
314
315
  { spawn = spawnSync } = {},
315
316
  ) {
316
- const childEnv = { ...env, [CHANNEL_SWITCH_ENV]: '1' };
317
+ const childEnv = { ...env, [CHANNEL_SWITCH_ENV]: CHANNEL_SWITCHED };
317
318
  // First make npm resolve and boot the target package without executing the
318
319
  // requested command. That distinguishes an unavailable tag/network from a
319
320
  // legitimate non-zero exit of the handed-off CLI, which must be propagated
@@ -1,8 +1,13 @@
1
1
  import { loginWithOAuth, logoutOAuth } from '../runtime/oauth.js';
2
+ import { followAccountReleaseChannel } from '../runtime/release-channel.js';
2
3
  import { installLocalAgentContext } from './agents.js';
3
4
 
4
5
  async function loginHandler(ctx) {
5
- const result = await loginWithOAuth(ctx.runtime, ctx.options, ctx.output);
6
+ const result = await followAccountReleaseChannel(
7
+ ctx.runtime,
8
+ await loginWithOAuth(ctx.runtime, ctx.options, ctx.output),
9
+ { login: loginWithOAuth, options: ctx.options, output: ctx.output },
10
+ );
6
11
  if (result.agentAuthorization) {
7
12
  const presentation = loginAgentAuthorizationPresentation(result.agentAuthorization);
8
13
  return ctx.output.emitSuccess({
@@ -115,7 +120,7 @@ export const authCommandSpecs = [
115
120
  examples: [
116
121
  'notis login',
117
122
  'notis login --profile work',
118
- 'notis login --profile beta --api-base https://api-beta.notis.ai',
123
+ 'notis login --profile nightly --api-base https://api-nightly.notis.ai',
119
124
  'notis login --no-browser --print-url',
120
125
  'notis login --mode browser',
121
126
  'notis login --mode code',
@@ -28,7 +28,7 @@ export function doctorChannelSummary(
28
28
  const decision = resolveChannelSwitch({
29
29
  runningVersion: runtime.cliVersion,
30
30
  profile: {
31
- channel: runtime.channel,
31
+ release_channel: runtime.channel,
32
32
  api_base: runtime.apiBase,
33
33
  },
34
34
  moduleDirectory,
@@ -10,6 +10,7 @@ import {
10
10
  loadConfig,
11
11
  } from '../runtime/profiles.js';
12
12
  import { ensureFreshOAuthCredential, loginWithOAuth } from '../runtime/oauth.js';
13
+ import { followAccountReleaseChannel } from '../runtime/release-channel.js';
13
14
  import { runToolCommand } from './helpers.js';
14
15
  import { loginAgentAuthorizationPresentation } from './auth.js';
15
16
  import { installLocalAgentContext } from './agents.js';
@@ -207,8 +208,30 @@ async function startHandler(ctx) {
207
208
  }
208
209
  }
209
210
 
211
+ // An agent confirms a browser sign-in by re-running start, so a profile
212
+ // that is already signed in still has to land on its account's channel.
213
+ const followChannel = (result) => followAccountReleaseChannel(runtime, result, {
214
+ login: loginWithOAuth,
215
+ options: { browser: true, mode: options.mode },
216
+ output,
217
+ });
218
+ const authorizationResult = (authorization) => {
219
+ const presentation = loginAgentAuthorizationPresentation(authorization.agentAuthorization);
220
+ return output.emitSuccess({
221
+ command: 'start',
222
+ data: {
223
+ authenticated: false,
224
+ profile: runtime.profileName,
225
+ ...authorization.agentAuthorization,
226
+ },
227
+ ...presentation,
228
+ });
229
+ };
230
+
210
231
  // Safe to re-run: an already-authorized profile skips straight to the brief.
211
232
  if (isAuthenticated(runtime)) {
233
+ const followed = await followChannel({ profile: getProfile(loadConfig(), runtime.profileName) });
234
+ if (followed?.agentAuthorization) return authorizationResult(followed);
212
235
  return authenticatedResult(ctx);
213
236
  }
214
237
 
@@ -224,22 +247,13 @@ async function startHandler(ctx) {
224
247
  // Browser authorization is the whole signup path: it creates the account when
225
248
  // the address is new and authorizes this machine either way. There is nothing
226
249
  // for the CLI to collect up front, and nothing to wait on afterwards.
227
- const authorization = await loginWithOAuth(
250
+ const authorization = await followChannel(await loginWithOAuth(
228
251
  runtime,
229
252
  { browser: true, mode: options.mode, reusePersistedCredential: true },
230
253
  output,
231
- );
254
+ ));
232
255
  if (authorization?.agentAuthorization) {
233
- const presentation = loginAgentAuthorizationPresentation(authorization.agentAuthorization);
234
- return output.emitSuccess({
235
- command: 'start',
236
- data: {
237
- authenticated: false,
238
- profile: runtime.profileName,
239
- ...authorization.agentAuthorization,
240
- },
241
- ...presentation,
242
- });
256
+ return authorizationResult(authorization);
243
257
  }
244
258
 
245
259
  // A failed authorization leaves whatever stale credential got us here in
@@ -26,6 +26,23 @@ import {
26
26
  validateArguments,
27
27
  } from './helpers.js';
28
28
 
29
+ // The backend's META_BRIDGE_TOOL_NAMES are router helpers, not batch targets.
30
+ const ROUTER_HELPERS = new Set([
31
+ COMPOSIO_SEARCH_TOOLS,
32
+ COMPOSIO_GET_TOOL_SCHEMAS,
33
+ COMPOSIO_MULTI_EXECUTE_TOOL,
34
+ 'COMPOSIO_MANAGE_CONNECTIONS',
35
+ 'COMPOSIO_WAIT_FOR_CONNECTIONS',
36
+ 'COMPOSIO_REMOTE_WORKBENCH',
37
+ 'COMPOSIO_REMOTE_BASH_TOOL',
38
+ ]);
39
+
40
+ function executionRequest(toolName, arguments_) {
41
+ return ROUTER_HELPERS.has(toolName)
42
+ ? { toolName, arguments_ }
43
+ : { toolName: COMPOSIO_MULTI_EXECUTE_TOOL, arguments_: { tools: [{ tool_slug: toolName, arguments: arguments_ }] } };
44
+ }
45
+
29
46
  export async function resolveJsonInput(value, label) {
30
47
  if (!value) return undefined;
31
48
 
@@ -464,10 +481,7 @@ async function toolsExecHandler(ctx) {
464
481
 
465
482
  const result = await runToolCommand({
466
483
  runtime: ctx.runtime,
467
- toolName: COMPOSIO_MULTI_EXECUTE_TOOL,
468
- arguments_: {
469
- tools: [{ tool_slug: requestedToolName, arguments: args }],
470
- },
484
+ ...executionRequest(requestedToolName, args),
471
485
  // The server is authoritative about provider/MCP effects. A tool name that
472
486
  // looks like a read is not proof of read-only behavior, so generic
473
487
  // executions always carry a key and use mutation-safe transport retries.
@@ -558,8 +572,7 @@ async function confirmAndApply(ctx, { requestedToolName, args, preview, result,
558
572
  ctx.output.emitProgress({ phase: 'execute', message: `Applying ${requestedToolName}` });
559
573
  const applied = await runToolCommand({
560
574
  runtime: ctx.runtime,
561
- toolName: COMPOSIO_MULTI_EXECUTE_TOOL,
562
- arguments_: { tools: [{ tool_slug: requestedToolName, arguments: decision.arguments }] },
575
+ ...executionRequest(requestedToolName, decision.arguments),
563
576
  mutating: true,
564
577
  idempotencyKey: confirmKey,
565
578
  fileBindings,
@@ -594,10 +607,7 @@ async function toolsExecParallelHandler(ctx) {
594
607
  calls.map((call) =>
595
608
  runToolCommand({
596
609
  runtime: ctx.runtime,
597
- toolName: COMPOSIO_MULTI_EXECUTE_TOOL,
598
- arguments_: {
599
- tools: [{ tool_slug: localNotisToolSlug(call.tool_name), arguments: call.arguments || {} }],
600
- },
610
+ ...executionRequest(localNotisToolSlug(call.tool_name), call.arguments || {}),
601
611
  mutating: true,
602
612
  idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
603
613
  })
@@ -3,25 +3,56 @@
3
3
  *
4
4
  * The npm tag has to be chosen before the CLI starts, and the CLI only learns
5
5
  * which Notis it talks to after it reads the profile — so a single documented
6
- * install command can never be right for both environments on its own. The
7
- * deployment answers the question at login (`notis_cli_channel` in the CLI
8
- * protected-resource metadata), the answer is pinned on the profile, and every
9
- * later run re-executes the matching build. `@notis_ai/cli@latest` therefore
10
- * stays the one command worth documenting, including for beta accounts.
6
+ * install command can never be right for both environments on its own. Login
7
+ * asks the account which release channel it belongs to (`GET /release_channel`),
8
+ * the answer is pinned on the profile as `release_channel`, and every later run
9
+ * re-executes the matching build. `@notis_ai/cli@latest` therefore stays the
10
+ * one command worth documenting, including for nightly accounts.
11
11
  */
12
12
 
13
- export const RELEASE_CHANNELS = ['stable', 'beta'];
14
- const CHANNEL_TAGS = { stable: 'latest', beta: 'beta' };
13
+ export const RELEASE_CHANNELS = ['stable', 'nightly'];
14
+ // Keep equal to the dist-tags in scripts/release-channels.cjs (a test checks).
15
+ const CHANNEL_TAGS = { stable: 'latest', nightly: 'nightly' };
16
+ // Live API hosts and the channel each one serves.
17
+ const API_HOST_CHANNELS = {
18
+ 'api.notis.ai': 'stable',
19
+ 'api-beta.notis.ai': 'nightly',
20
+ 'api-nightly.notis.ai': 'nightly',
21
+ };
15
22
  export const CLI_PACKAGE_NAME = '@notis_ai/cli';
16
23
  // Set on the child so a build that disagrees about its own channel — a bad
17
24
  // version string, a half-published tag — cannot bounce the process forever.
25
+ // Builds from before the nightly channel wrote `1` and resolved channels from
26
+ // the old profile fields, so a child that sees `1` may correct them once more.
18
27
  export const CHANNEL_SWITCH_ENV = 'NOTIS_CLI_CHANNEL_SWITCHED';
28
+ export const CHANNEL_SWITCHED = '2';
29
+ const LEGACY_CHANNEL_SWITCHED = '1';
19
30
  export const CHANNEL_DISABLE_ENV = 'NOTIS_CLI_AUTO_CHANNEL';
20
31
 
21
32
  export function isReleaseChannel(value) {
22
33
  return RELEASE_CHANNELS.includes(value);
23
34
  }
24
35
 
36
+ /**
37
+ * A channel name as any Notis component may still spell it. `beta` is what
38
+ * deployments, profiles and builds called the nightly channel before it was
39
+ * renamed, so it reads as nightly; anything else unknown is dropped.
40
+ */
41
+ export function normalizeReleaseChannel(value) {
42
+ if (value === 'beta') return 'nightly';
43
+ return isReleaseChannel(value) ? value : null;
44
+ }
45
+
46
+ export function channelFromApiBase(apiBase) {
47
+ if (typeof apiBase !== 'string' || !apiBase) return null;
48
+ try {
49
+ return API_HOST_CHANNELS[new URL(apiBase).hostname] || null;
50
+ } catch {
51
+ // A malformed endpoint says nothing about the channel.
52
+ return null;
53
+ }
54
+ }
55
+
25
56
  export function packageTagForChannel(channel) {
26
57
  return CHANNEL_TAGS[channel] || CHANNEL_TAGS.stable;
27
58
  }
@@ -33,37 +64,42 @@ export function cliCommandForChannel(channel) {
33
64
  /**
34
65
  * The channel of the build that is currently running.
35
66
  *
36
- * The publish pipeline stamps beta releases as prereleases
37
- * (`0.2.0-beta.129.1`) and production releases as plain semver (`0.2.10`), so
38
- * the manifest version is the only channel marker that cannot drift from what
39
- * npm actually served.
67
+ * The publish pipeline stamps nightly releases as prereleases
68
+ * (`0.2.0-nightly.129.1`, `0.2.0-beta.129.1` before the rename) and stable
69
+ * releases as plain semver (`0.2.10`), so the manifest version is the only
70
+ * channel marker that cannot drift from what npm actually served.
40
71
  */
41
72
  export function channelFromVersion(version) {
42
- return String(version || '').includes('-') ? 'beta' : 'stable';
73
+ return String(version || '').includes('-') ? 'nightly' : 'stable';
43
74
  }
44
75
 
45
76
  /**
46
- * The channel a profile is pinned to.
77
+ * The channel a stored profile's fields pin it to, or null.
47
78
  *
48
- * `channel` is written at login from the deployment's own metadata. The
49
- * `beta` flag and the endpoint host are the fallbacks that let a profile
50
- * authorized by an older CLI resolve without a second login.
79
+ * This CLI writes only `release_channel`. Notis Desktop and older CLIs wrote a
80
+ * `beta` flag and a `channel` pin (`stable` | `beta`) instead; normalization
81
+ * drops those on every write, so when one is present it was written after this
82
+ * CLI last touched the profile and is the fresher answer. Desktop always sets
83
+ * `beta` from the API it just authorized, which makes it the most reliable.
51
84
  */
52
- export function channelFromProfile(profile = {}) {
53
- if (isReleaseChannel(profile.channel)) {
54
- return profile.channel;
55
- }
56
- if (profile.beta === true) return 'beta';
85
+ export function pinnedReleaseChannel(profile = {}) {
86
+ if (profile.beta === true) return 'nightly';
57
87
  if (profile.beta === false) return 'stable';
88
+ return normalizeReleaseChannel(profile.channel)
89
+ || normalizeReleaseChannel(profile.release_channel);
90
+ }
91
+
92
+ /**
93
+ * The channel a profile belongs to: its pin, else the live API it talks to.
94
+ * The host fallback lets a profile authorized before channels existed resolve
95
+ * without a second login.
96
+ */
97
+ export function channelFromProfile(profile = {}) {
98
+ const pinned = pinnedReleaseChannel(profile);
99
+ if (pinned) return pinned;
58
100
  for (const candidate of [profile.oauth_api_base, profile.api_base]) {
59
- if (typeof candidate !== 'string' || !candidate) continue;
60
- try {
61
- const { hostname } = new URL(candidate);
62
- if (hostname === 'api-beta.notis.ai') return 'beta';
63
- if (hostname === 'api.notis.ai') return 'stable';
64
- } catch {
65
- // A malformed endpoint says nothing about the channel.
66
- }
101
+ const channel = channelFromApiBase(candidate);
102
+ if (channel) return channel;
67
103
  }
68
104
  return null;
69
105
  }
@@ -115,7 +151,11 @@ export function resolveChannelSwitch({
115
151
  const targetChannel = channelFromProfile(profile);
116
152
  const stay = (reason) => ({ switch: false, reason, runningChannel, targetChannel });
117
153
 
118
- if (env[CHANNEL_SWITCH_ENV] === '1') return stay('already_switched');
154
+ // A pre-nightly build may have handed over on its own reading of the
155
+ // profile (`1`); that hop may be corrected once, never more.
156
+ if (env[CHANNEL_SWITCH_ENV] && env[CHANNEL_SWITCH_ENV] !== LEGACY_CHANNEL_SWITCHED) {
157
+ return stay('already_switched');
158
+ }
119
159
  if (env[CHANNEL_DISABLE_ENV] === '0') return stay('disabled');
120
160
  if (!targetChannel) return stay('profile_channel_unknown');
121
161
  if (targetChannel === runningChannel) return stay('channel_matches');
@@ -26,9 +26,10 @@ import { CliError, EXIT_CODES, usageError } from './errors.js';
26
26
  import { readComputerIdentity } from './computer-identity.js';
27
27
  import { getAuthRecovery, getRetryRecovery, quoteShellArgument } from './auth-recovery.js';
28
28
  import {
29
+ channelFromApiBase,
29
30
  channelFromProfile,
30
31
  cliCommandForChannel,
31
- isReleaseChannel,
32
+ normalizeReleaseChannel,
32
33
  } from './channel.js';
33
34
  import {
34
35
  credentialIsExpired,
@@ -408,10 +409,9 @@ export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
408
409
  copyPasteRedirectUri: protectedResource.notis_cli_copy_paste_redirect_uri,
409
410
  // A deployment that predates channel advertising, or a local one with no
410
411
  // published build, leaves this null and the profile keeps resolving its
411
- // channel from the endpoint it authorized against.
412
- channel: isReleaseChannel(protectedResource.notis_cli_channel)
413
- ? protectedResource.notis_cli_channel
414
- : null,
412
+ // channel from the endpoint it authorized against. Deployments from before
413
+ // the rename still answer `beta`, which is the nightly channel.
414
+ channel: normalizeReleaseChannel(protectedResource.notis_cli_channel),
415
415
  authorizationEndpoint: authorizationServer.authorization_endpoint,
416
416
  tokenEndpoint: authorizationServer.token_endpoint,
417
417
  revocationEndpoint: authorizationServer.revocation_endpoint,
@@ -995,14 +995,6 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
995
995
  const now = Math.floor(Date.now() / 1000);
996
996
  const payload = decodeJwtPayload(tokenResponse.access_token);
997
997
  const oauthApiBase = (metadata.apiBase || runtime.apiBase || '').replace(/\/+$/, '');
998
- let beta;
999
- try {
1000
- const hostname = new URL(oauthApiBase).hostname;
1001
- if (hostname === 'api-beta.notis.ai') beta = true;
1002
- else if (hostname === 'api.notis.ai') beta = false;
1003
- } catch {
1004
- beta = undefined;
1005
- }
1006
998
  const config = updateConfig((latest) => {
1007
999
  const next = ensureProfile(latest, runtime.profileName);
1008
1000
  const profile = next.profiles[runtime.profileName];
@@ -1012,14 +1004,14 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
1012
1004
  // one API, and the environment the user just authorized against is the
1013
1005
  // only endpoint the resulting token is accepted by.
1014
1006
  api_base: oauthApiBase || profile.api_base,
1015
- beta: beta ?? profile.beta,
1016
1007
  // The deployment that just authorized this profile also names the
1017
1008
  // published build that belongs to it. Pinning it here is what lets the
1018
1009
  // next run correct itself without the user knowing a channel exists.
1019
- channel: isReleaseChannel(metadata.channel)
1020
- ? metadata.channel
1021
- : channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase })
1022
- ?? profile.channel,
1010
+ // followAccountReleaseChannel then replaces it with the account's own
1011
+ // answer once the new grant can ask for it.
1012
+ release_channel: metadata.channel
1013
+ || channelFromApiBase(oauthApiBase)
1014
+ || profile.release_channel,
1023
1015
  oauth_api_base: oauthApiBase || profile.oauth_api_base,
1024
1016
  oauth_resource: metadata.resource,
1025
1017
  oauth_access_token: tokenResponse.access_token,
@@ -1968,14 +1960,16 @@ function confirmCommand(profileName, channel, apiBase) {
1968
1960
  return [
1969
1961
  cliCommandForChannel(channel),
1970
1962
  `--profile ${quoteShellArgument(profileName || 'default')}`,
1971
- `--api-base ${quoteShellArgument(apiBase)}`,
1963
+ // Only an explicit route is repeated: without one, confirming must stay
1964
+ // free to follow the account to its release channel.
1965
+ ...(apiBase ? [`--api-base ${quoteShellArgument(apiBase)}`] : []),
1972
1966
  'start',
1973
1967
  ].join(' ');
1974
1968
  }
1975
1969
 
1976
1970
  function authorizationChannel(metadata, runtime, pending = null) {
1977
1971
  return metadata.channel
1978
- || pending?.channel
1972
+ || normalizeReleaseChannel(pending?.channel)
1979
1973
  || channelFromProfile({ api_base: pending?.api_base || runtime.apiBase });
1980
1974
  }
1981
1975
 
@@ -2063,6 +2057,11 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
2063
2057
  // repeating --api-base must not persist a token under a different backend.
2064
2058
  if (pending.api_base) {
2065
2059
  runtime.apiBase = pending.api_base;
2060
+ // An explicit --api-base login stays explicit through its redemption,
2061
+ // so the account's release channel does not reroute it either.
2062
+ if (pending.api_base_explicit && !runtime.requestedApiBase) {
2063
+ runtime.requestedApiBase = pending.api_base;
2064
+ }
2066
2065
  }
2067
2066
  metadata = {
2068
2067
  apiBase: pending.api_base,
@@ -2072,7 +2071,7 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
2072
2071
  tokenEndpoint: pending.token_endpoint,
2073
2072
  revocationEndpoint: pending.revocation_endpoint
2074
2073
  || `${String(pending.issuer || '').replace(/\/+$/, '')}/oauth/revoke`,
2075
- channel: pending.channel,
2074
+ channel: normalizeReleaseChannel(pending.channel),
2076
2075
  };
2077
2076
  if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
2078
2077
  throw oauthError(
@@ -2337,7 +2336,7 @@ export async function loginWithOAuth(
2337
2336
  confirm_command: confirmCommand(
2338
2337
  runtime.profileName,
2339
2338
  authorizationChannel(metadata, runtime),
2340
- live.api_base,
2339
+ runtime.requestedApiBase ? live.api_base : null,
2341
2340
  ),
2342
2341
  },
2343
2342
  };
@@ -2471,6 +2470,7 @@ export async function loginWithOAuth(
2471
2470
  savePendingAuthorization(runtime, {
2472
2471
  profile: runtime.profileName,
2473
2472
  api_base: runtime.apiBase,
2473
+ api_base_explicit: Boolean(runtime.requestedApiBase),
2474
2474
  verifier,
2475
2475
  state,
2476
2476
  redirect_uri: redirectUri,
@@ -2543,7 +2543,7 @@ export async function loginWithOAuth(
2543
2543
  confirm_command: confirmCommand(
2544
2544
  runtime.profileName,
2545
2545
  authorizationChannel(metadata, runtime),
2546
- runtime.apiBase,
2546
+ runtime.requestedApiBase ? runtime.apiBase : null,
2547
2547
  ),
2548
2548
  },
2549
2549
  };