@notis_ai/cli 0.2.0-beta.132.1 → 0.2.0-beta.133.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 CHANGED
@@ -20,6 +20,17 @@ npx --package @notis_ai/cli@latest -- notis tools search "list Notis databases"
20
20
 
21
21
  Use `notis login --paste-code` for the HTTPS copy-paste fallback on a remote machine.
22
22
 
23
+ ## Release channels
24
+
25
+ `@latest` is the only tag worth documenting, for beta accounts too.
26
+
27
+ The npm tag has to be chosen before the CLI starts, and the CLI only learns which Notis it talks to once it reads the profile — so no single install command can be right for both environments on its own. Instead the deployment answers the question: `/.well-known/oauth-protected-resource/cli` reports its channel, `notis login` pins it on the profile, and any later run that finds itself on the wrong build hands the whole invocation to the right one before it does anything else.
28
+
29
+ - `notis doctor` reports `release_channel`, `cli_version`, and a `channel` check.
30
+ - `--api-base <url>` decides the build for that one run, so a one-off call against another environment uses the matching CLI.
31
+ - `./dev.sh` profiles and source checkouts are never re-executed: whatever you started stays in control.
32
+ - `NOTIS_CLI_AUTO_CHANNEL=0` disables the hand-off; `doctor` then reports the mismatch instead of correcting it.
33
+
23
34
  ## Profiles
24
35
 
25
36
  A profile is one account paired with one API endpoint. Every profile keeps its own credential, so switching between them never signs any of them out.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.132.1",
3
+ "version": "0.2.0-beta.133.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,8 @@ Use the registry-resolved published npm package everywhere:
31
31
 
32
32
  Always use this NPX command form so the agent runs the current published CLI. In hosted shells, the CLI is pre-authenticated through `NOTIS_JWT`. On a local machine the CLI holds its own OAuth grant: `notis login` authorizes one in the browser, and signing in to the Notis desktop app authorizes one automatically for that account. Either way the grant belongs to the CLI, which refreshes it without the desktop app running.
33
33
 
34
+ `@latest` is correct for every account, including beta ones. Each deployment reports which published build belongs to it, `notis login` pins that on the profile, and a later run that finds itself on the wrong build hands the invocation to the right one before doing anything. Never substitute a channel by hand: pinning `@beta` on a production profile is how a machine ends up running a build its API does not expect. `notis doctor` reports the active channel, and `NOTIS_CLI_AUTO_CHANNEL=0` turns the hand-off off for a run.
35
+
34
36
  This `notis-cli` skill is delivered through normal Notis skill sync for the signed-in user, alongside other curated skills.
35
37
 
36
38
  ## Profiles: accounts and endpoints
package/src/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -6,10 +7,17 @@ import { COMMAND_SPECS, GROUP_SUMMARIES } from './command-specs/index.js';
6
7
  import { OutputManager } from './runtime/output.js';
7
8
  import { asCliError } from './runtime/errors.js';
8
9
  import { reportCliCommand } from './runtime/telemetry.js';
10
+ import {
11
+ CHANNEL_SWITCH_ENV,
12
+ resolveChannelSwitch,
13
+ } from './runtime/channel.js';
9
14
  import {
10
15
  DEFAULT_PROFILE,
16
+ getProfile,
17
+ loadConfig,
11
18
  resolveOutputMode,
12
19
  resolveRuntimeProfile,
20
+ resolveWorktreeRuntime,
13
21
  workspacePath,
14
22
  } from './runtime/profiles.js';
15
23
 
@@ -232,7 +240,157 @@ export function createProgram() {
232
240
  return program;
233
241
  }
234
242
 
243
+ /**
244
+ * Read the two global flags that decide which build should serve this run.
245
+ *
246
+ * Commander cannot help here: the decision has to be made before the program
247
+ * parses, because the answer may be to hand the whole invocation to a
248
+ * different process. Only `--profile` and `--api-base` matter, and both are
249
+ * plain `--flag value` pairs.
250
+ */
251
+ export function readChannelRelevantFlags(args = []) {
252
+ const flags = {};
253
+ for (const [index, token] of args.entries()) {
254
+ for (const [flag, key] of [['--profile', 'profile'], ['--api-base', 'apiBase']]) {
255
+ if (token === flag) flags[key] = args[index + 1];
256
+ else if (token.startsWith(`${flag}=`)) flags[key] = token.slice(flag.length + 1);
257
+ }
258
+ }
259
+ return flags;
260
+ }
261
+
262
+ export function channelProfileForArgs(
263
+ args = [],
264
+ env = process.env,
265
+ { config: suppliedConfig, worktreeRuntime: suppliedWorktreeRuntime } = {},
266
+ ) {
267
+ // An explicit endpoint overrides the stored profile for this run, so it also
268
+ // decides the build: `--api-base https://api-beta.notis.ai` on a production
269
+ // profile is a deliberate one-off beta call.
270
+ const { profile: profileName, apiBase } = readChannelRelevantFlags(args);
271
+ if (apiBase) {
272
+ return { api_base: apiBase };
273
+ }
274
+ if (env.NOTIS_API_BASE) {
275
+ return { api_base: env.NOTIS_API_BASE };
276
+ }
277
+ const config = suppliedConfig || loadConfig();
278
+ const explicitProfile = profileName || env.NOTIS_PROFILE;
279
+ if (explicitProfile) {
280
+ return getProfile(config, explicitProfile);
281
+ }
282
+ const resolvedWorktree = suppliedWorktreeRuntime === undefined
283
+ ? resolveWorktreeRuntime()
284
+ : suppliedWorktreeRuntime;
285
+ // A stopped local-only worktree must reach the normal routing error on the
286
+ // current build. Switching based on an unrelated shared profile would escape
287
+ // the worktree boundary before that fail-closed check runs.
288
+ if (resolvedWorktree?.unavailable) {
289
+ return {};
290
+ }
291
+ if (resolvedWorktree?.profile) {
292
+ return resolvedWorktree;
293
+ }
294
+ // NOTIS_JWT replaces the credential, not the route. Unless NOTIS_API_BASE
295
+ // was explicit above, the selected/current profile still owns the channel.
296
+ return getProfile(config, config.current_profile || DEFAULT_PROFILE);
297
+ }
298
+
299
+ function interruptedExitCode(signal) {
300
+ if (signal === 'SIGINT') return 130;
301
+ if (signal === 'SIGTERM') return 143;
302
+ return 1;
303
+ }
304
+
305
+ export function executeChannelSwitch(
306
+ decision,
307
+ args,
308
+ env,
309
+ { spawn = spawnSync } = {},
310
+ ) {
311
+ const childEnv = { ...env, [CHANNEL_SWITCH_ENV]: '1' };
312
+ // First make npm resolve and boot the target package without executing the
313
+ // requested command. That distinguishes an unavailable tag/network from a
314
+ // legitimate non-zero exit of the handed-off CLI, which must be propagated
315
+ // rather than retried locally after a possible mutation.
316
+ const probe = spawn(decision.command, [...decision.args, '--version'], {
317
+ stdio: 'ignore',
318
+ env: childEnv,
319
+ });
320
+ if (probe.signal) {
321
+ return {
322
+ ...decision,
323
+ exitCode: interruptedExitCode(probe.signal),
324
+ reason: 'switch_interrupted',
325
+ };
326
+ }
327
+ if (probe.error || probe.status !== 0) {
328
+ return { ...decision, switch: false, reason: 'switch_unavailable' };
329
+ }
330
+
331
+ const result = spawn(decision.command, [...decision.args, ...args], {
332
+ stdio: 'inherit',
333
+ env: childEnv,
334
+ });
335
+ if (result.signal) {
336
+ return {
337
+ ...decision,
338
+ exitCode: interruptedExitCode(result.signal),
339
+ reason: 'switch_interrupted',
340
+ };
341
+ }
342
+ if (result.error || typeof result.status !== 'number') {
343
+ return { ...decision, switch: false, reason: 'switch_failed' };
344
+ }
345
+ return { ...decision, exitCode: result.status };
346
+ }
347
+
348
+ /**
349
+ * Hand this invocation to the published build the profile is pinned to.
350
+ *
351
+ * Returns the decision rather than exiting so tests can assert on it. Any
352
+ * failure to reach the other build is non-fatal: running the wrong channel is
353
+ * a much smaller problem than refusing to run at all.
354
+ */
355
+ export function switchChannelIfNeeded(
356
+ argv = process.argv,
357
+ env = process.env,
358
+ { spawn = spawnSync, platform = process.platform, moduleDirectory } = {},
359
+ ) {
360
+ const args = argv.slice(2);
361
+ let profile;
362
+ try {
363
+ profile = channelProfileForArgs(args, env);
364
+ } catch {
365
+ return { switch: false, reason: 'profile_unreadable' };
366
+ }
367
+ const decision = resolveChannelSwitch({
368
+ runningVersion: CLI_VERSION,
369
+ profile,
370
+ moduleDirectory: moduleDirectory || dirname(fileURLToPath(import.meta.url)),
371
+ env,
372
+ platform,
373
+ });
374
+ if (!decision.switch) {
375
+ return decision;
376
+ }
377
+
378
+ const result = executeChannelSwitch(decision, args, env, { spawn });
379
+ if (!result.switch) {
380
+ process.stderr.write(
381
+ `Notis CLI could not start the ${decision.targetChannel} build for this profile; `
382
+ + `continuing on ${decision.runningChannel}.\n`,
383
+ );
384
+ }
385
+ return result;
386
+ }
387
+
235
388
  export async function run(argv = process.argv) {
389
+ const switched = switchChannelIfNeeded(argv);
390
+ if (switched.switch) {
391
+ process.exitCode = switched.exitCode;
392
+ return;
393
+ }
236
394
  const program = createProgram();
237
395
  await program.parseAsync(argv);
238
396
  }
@@ -1,6 +1,10 @@
1
+ import { dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
1
4
  import { COMPOSIO_SEARCH_TOOLS, healthCheck, probeAuth } from './helpers.js';
2
5
  import { findCommandSpec, formatDescribe } from '../runtime/help.js';
3
6
  import { createExpiredAuthError, getAuthRecovery } from '../runtime/auth-recovery.js';
7
+ import { cliCommandForChannel, resolveChannelSwitch } from '../runtime/channel.js';
4
8
  import {
5
9
  credentialIsExpired,
6
10
  getProfile,
@@ -17,10 +21,40 @@ export function doctorToolRoundtripRuntime(runtime) {
17
21
  };
18
22
  }
19
23
 
24
+ export function doctorChannelSummary(
25
+ runtime,
26
+ moduleDirectory = dirname(fileURLToPath(import.meta.url)),
27
+ ) {
28
+ const decision = resolveChannelSwitch({
29
+ runningVersion: runtime.cliVersion,
30
+ profile: {
31
+ channel: runtime.channel,
32
+ api_base: runtime.apiBase,
33
+ },
34
+ moduleDirectory,
35
+ });
36
+ const mismatch = Boolean(
37
+ decision.targetChannel
38
+ && decision.targetChannel !== decision.runningChannel,
39
+ );
40
+ const releaseChannel = runtime.worktreeRuntime ? 'dev' : runtime.channel;
41
+ return {
42
+ decision,
43
+ mismatch,
44
+ releaseChannel,
45
+ status: runtime.worktreeRuntime
46
+ ? 'dev'
47
+ : mismatch
48
+ ? `mismatch:${decision.reason}`
49
+ : decision.runningChannel,
50
+ };
51
+ }
52
+
20
53
  async function doctorHandler(ctx) {
21
54
  const checks = {
22
55
  config: 'ok',
23
56
  auth: 'missing',
57
+ channel: 'unknown',
24
58
  routing: 'ok',
25
59
  health: 'unknown',
26
60
  tool_roundtrip: 'unknown',
@@ -76,7 +110,24 @@ async function doctorHandler(ctx) {
76
110
  }
77
111
  }
78
112
 
113
+ // A mismatch here means the automatic hand-off could not happen: a source
114
+ // checkout, an explicit opt-out, or a switch that could not reach npm. The
115
+ // profile still routes to the right API, so this reports rather than fails.
116
+ const {
117
+ decision: channelDecision,
118
+ mismatch: channelMismatch,
119
+ releaseChannel,
120
+ status: channelStatus,
121
+ } = doctorChannelSummary(ctx.runtime);
122
+ checks.channel = channelStatus;
123
+
79
124
  const hints = [];
125
+ if (channelMismatch) {
126
+ hints.push({
127
+ command: `${cliCommandForChannel(channelDecision.targetChannel)} doctor`,
128
+ reason: `Profile "${ctx.runtime.profileName}" belongs to the ${channelDecision.targetChannel} CLI channel`,
129
+ });
130
+ }
80
131
  if (checks.auth === 'missing') {
81
132
  hints.push(...getAuthRecovery(ctx.runtime, { mode: 'missing' }).hints);
82
133
  } else if (checks.auth === 'expired') {
@@ -104,6 +155,8 @@ async function doctorHandler(ctx) {
104
155
  profile: ctx.runtime.profileName,
105
156
  profile_source: ctx.runtime.profileSource,
106
157
  api_base: ctx.runtime.apiBase,
158
+ release_channel: releaseChannel || null,
159
+ cli_version: ctx.runtime.cliVersion || null,
107
160
  credential_source: ctx.runtime.credentialKind || null,
108
161
  ...(ctx.runtime.credentialKind === 'oauth'
109
162
  ? {
@@ -1,6 +1,14 @@
1
1
  import { CliError, EXIT_CODES } from './errors.js';
2
+ import { channelFromProfile, cliCommandForChannel } from './channel.js';
2
3
 
3
- const CLI_NPX = 'npx --package @notis_ai/cli@latest -- notis';
4
+ // Recovery is only useful when the printed command is the one that will run:
5
+ // telling a beta profile to reinstall `@latest` sends it back to the build it
6
+ // just failed on.
7
+ function cliNpx(runtime = {}) {
8
+ return cliCommandForChannel(
9
+ runtime.channel || channelFromProfile({ api_base: runtime.apiBase }),
10
+ );
11
+ }
4
12
 
5
13
  export function quoteShellArgument(value) {
6
14
  return `'${String(value).replace(/'/g, `'"'"'`)}'`;
@@ -19,8 +27,9 @@ function profileSuffix(profileName) {
19
27
  * "expired" means the profile holds a grant the browser can renew, while
20
28
  * "missing" means this profile has never been authorized at all.
21
29
  */
22
- export function getAuthRecovery({ profileName } = {}, { mode = 'expired' } = {}) {
23
- const suffix = profileSuffix(profileName);
30
+ export function getAuthRecovery(runtime = {}, { mode = 'expired' } = {}) {
31
+ const CLI_NPX = cliNpx(runtime);
32
+ const suffix = profileSuffix(runtime.profileName);
24
33
  const hints = [
25
34
  {
26
35
  command: `${CLI_NPX} login${suffix}`,
@@ -41,6 +50,7 @@ export function getAuthRecovery({ profileName } = {}, { mode = 'expired' } = {})
41
50
  }
42
51
 
43
52
  export function createExpiredAuthError(runtime) {
53
+ const CLI_NPX = cliNpx(runtime || {});
44
54
  if (runtime?.credentialKind === 'worktree') {
45
55
  return new CliError({
46
56
  code: 'auth_expired',
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Which published CLI build a profile should run.
3
+ *
4
+ * The npm tag has to be chosen before the CLI starts, and the CLI only learns
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.
11
+ */
12
+
13
+ export const RELEASE_CHANNELS = ['stable', 'beta'];
14
+ const CHANNEL_TAGS = { stable: 'latest', beta: 'beta' };
15
+ export const CLI_PACKAGE_NAME = '@notis_ai/cli';
16
+ // Set on the child so a build that disagrees about its own channel — a bad
17
+ // version string, a half-published tag — cannot bounce the process forever.
18
+ export const CHANNEL_SWITCH_ENV = 'NOTIS_CLI_CHANNEL_SWITCHED';
19
+ export const CHANNEL_DISABLE_ENV = 'NOTIS_CLI_AUTO_CHANNEL';
20
+
21
+ export function isReleaseChannel(value) {
22
+ return RELEASE_CHANNELS.includes(value);
23
+ }
24
+
25
+ export function packageTagForChannel(channel) {
26
+ return CHANNEL_TAGS[channel] || CHANNEL_TAGS.stable;
27
+ }
28
+
29
+ export function cliCommandForChannel(channel) {
30
+ return `npx --package ${CLI_PACKAGE_NAME}@${packageTagForChannel(channel)} -- notis`;
31
+ }
32
+
33
+ /**
34
+ * The channel of the build that is currently running.
35
+ *
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.
40
+ */
41
+ export function channelFromVersion(version) {
42
+ return String(version || '').includes('-') ? 'beta' : 'stable';
43
+ }
44
+
45
+ /**
46
+ * The channel a profile is pinned to.
47
+ *
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.
51
+ */
52
+ export function channelFromProfile(profile = {}) {
53
+ if (isReleaseChannel(profile.channel)) {
54
+ return profile.channel;
55
+ }
56
+ if (profile.beta === true) return 'beta';
57
+ if (profile.beta === false) return 'stable';
58
+ 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
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * A profile served by `./dev.sh` runs the CLI from that worktree on purpose.
73
+ * Re-executing it into a published build would swap both the code under test
74
+ * and the credential the worktree minted.
75
+ */
76
+ export function isDevManagedProfile(profile = {}) {
77
+ if (profile.dev_access_token || profile.dev_workspace_root) {
78
+ return true;
79
+ }
80
+ if (typeof profile.api_base !== 'string' || !profile.api_base) {
81
+ return false;
82
+ }
83
+ try {
84
+ const { hostname } = new URL(profile.api_base);
85
+ return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * A checkout run through `node bin/notis.js` is someone testing this source
93
+ * tree. Only an installed copy — one that npm placed under node_modules — may
94
+ * hand its invocation to a different published build.
95
+ */
96
+ export function isInstalledPackage(moduleDirectory) {
97
+ return String(moduleDirectory || '').split(/[\\/]/).includes('node_modules');
98
+ }
99
+
100
+ /**
101
+ * Decide whether this process should hand over to another published build.
102
+ *
103
+ * Returns the reason in every case: the caller reports it under `--verbose`
104
+ * and the tests assert on it, so a switch that silently does not happen is
105
+ * still explainable.
106
+ */
107
+ export function resolveChannelSwitch({
108
+ runningVersion,
109
+ profile = {},
110
+ moduleDirectory = '',
111
+ env = process.env,
112
+ platform = process.platform,
113
+ } = {}) {
114
+ const runningChannel = channelFromVersion(runningVersion);
115
+ const targetChannel = channelFromProfile(profile);
116
+ const stay = (reason) => ({ switch: false, reason, runningChannel, targetChannel });
117
+
118
+ if (env[CHANNEL_SWITCH_ENV] === '1') return stay('already_switched');
119
+ if (env[CHANNEL_DISABLE_ENV] === '0') return stay('disabled');
120
+ if (!targetChannel) return stay('profile_channel_unknown');
121
+ if (targetChannel === runningChannel) return stay('channel_matches');
122
+ if (isDevManagedProfile(profile)) return stay('dev_managed_profile');
123
+ if (!isInstalledPackage(moduleDirectory)) return stay('source_checkout');
124
+
125
+ return {
126
+ switch: true,
127
+ reason: 'channel_mismatch',
128
+ runningChannel,
129
+ targetChannel,
130
+ command: platform === 'win32' ? 'npx.cmd' : 'npx',
131
+ args: ['--yes', '--package', `${CLI_PACKAGE_NAME}@${packageTagForChannel(targetChannel)}`, '--', 'notis'],
132
+ };
133
+ }
@@ -19,6 +19,11 @@ import { createInterface } from 'node:readline/promises';
19
19
 
20
20
  import { CliError, EXIT_CODES } from './errors.js';
21
21
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
22
+ import {
23
+ channelFromProfile,
24
+ cliCommandForChannel,
25
+ isReleaseChannel,
26
+ } from './channel.js';
22
27
  import {
23
28
  credentialIsExpired,
24
29
  ensureProfile,
@@ -137,6 +142,12 @@ export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
137
142
  resource: protectedResource.resource,
138
143
  clientId: protectedResource.notis_cli_client_id || 'notis_cli',
139
144
  copyPasteRedirectUri: protectedResource.notis_cli_copy_paste_redirect_uri,
145
+ // A deployment that predates channel advertising, or a local one with no
146
+ // published build, leaves this null and the profile keeps resolving its
147
+ // channel from the endpoint it authorized against.
148
+ channel: isReleaseChannel(protectedResource.notis_cli_channel)
149
+ ? protectedResource.notis_cli_channel
150
+ : null,
140
151
  authorizationEndpoint: authorizationServer.authorization_endpoint,
141
152
  tokenEndpoint: authorizationServer.token_endpoint,
142
153
  revocationEndpoint: authorizationServer.revocation_endpoint,
@@ -718,6 +729,13 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
718
729
  // only endpoint the resulting token is accepted by.
719
730
  api_base: oauthApiBase || profile.api_base,
720
731
  beta: beta ?? profile.beta,
732
+ // The deployment that just authorized this profile also names the
733
+ // published build that belongs to it. Pinning it here is what lets the
734
+ // next run correct itself without the user knowing a channel exists.
735
+ channel: isReleaseChannel(metadata.channel)
736
+ ? metadata.channel
737
+ : channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase })
738
+ ?? profile.channel,
721
739
  oauth_api_base: oauthApiBase || profile.oauth_api_base,
722
740
  oauth_resource: metadata.resource,
723
741
  oauth_access_token: tokenResponse.access_token,
@@ -784,14 +802,20 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
784
802
  }
785
803
  }
786
804
 
787
- function redeemCommand(profileName) {
805
+ function redeemCommand(profileName, channel) {
788
806
  return [
789
- 'npx --package @notis_ai/cli@latest -- notis',
807
+ cliCommandForChannel(channel),
790
808
  `--profile ${quoteShellArgument(profileName || 'default')}`,
791
809
  'login --code <code>',
792
810
  ].join(' ');
793
811
  }
794
812
 
813
+ function authorizationChannel(metadata, runtime, pending = null) {
814
+ return metadata.channel
815
+ || pending?.channel
816
+ || channelFromProfile({ api_base: pending?.api_base || runtime.apiBase });
817
+ }
818
+
795
819
  function updateRuntimeFromOAuthProfile(runtime, profile) {
796
820
  const oauthApiBase = getOAuthApiBase(profile);
797
821
  runtime.jwt = profile.oauth_access_token;
@@ -875,6 +899,7 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
875
899
  resource: pending.resource,
876
900
  clientId: pending.client_id,
877
901
  tokenEndpoint: pending.token_endpoint,
902
+ channel: pending.channel,
878
903
  };
879
904
  if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
880
905
  throw oauthError(
@@ -961,7 +986,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
961
986
  scopes: pendingScopes,
962
987
  }),
963
988
  expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
964
- redeem_command: redeemCommand(runtime.profileName),
989
+ redeem_command: redeemCommand(
990
+ runtime.profileName,
991
+ authorizationChannel(metadata, runtime, pending),
992
+ ),
965
993
  },
966
994
  };
967
995
  }
@@ -1010,6 +1038,7 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1010
1038
  client_id: metadata.clientId,
1011
1039
  token_endpoint: metadata.tokenEndpoint,
1012
1040
  authorization_endpoint: metadata.authorizationEndpoint,
1041
+ channel: authorizationChannel(metadata, runtime),
1013
1042
  scopes,
1014
1043
  expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
1015
1044
  });
@@ -1021,7 +1050,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1021
1050
  agentAuthorization: {
1022
1051
  authorize_url: authorizeUrl,
1023
1052
  expires_in: PENDING_LOGIN_TTL_SECONDS,
1024
- redeem_command: redeemCommand(runtime.profileName),
1053
+ redeem_command: redeemCommand(
1054
+ runtime.profileName,
1055
+ authorizationChannel(metadata, runtime),
1056
+ ),
1025
1057
  },
1026
1058
  };
1027
1059
  }
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
12
12
  import { dirname, join, parse, resolve } from 'node:path';
13
13
  import { CliError, EXIT_CODES } from './errors.js';
14
14
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
15
+ import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
15
16
 
16
17
  export const CONFIG_DIR = join(homedir(), '.notis');
17
18
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
@@ -95,6 +96,10 @@ function normalizeProfile(rawProfile = {}) {
95
96
  return {
96
97
  api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
97
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,
98
103
  label: typeof raw.label === 'string' ? raw.label : undefined,
99
104
  dev_access_token:
100
105
  typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
@@ -877,7 +882,7 @@ export function resolveRuntimeProfile(
877
882
  exitCode: EXIT_CODES.auth,
878
883
  hints: [{
879
884
  command: [
880
- 'npx --package @notis_ai/cli@latest -- notis',
885
+ cliCommandForChannel(channelFromProfile({ api_base: normalizedRequestedApiBase })),
881
886
  `--profile ${quoteShellArgument(profileName)}`,
882
887
  `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
883
888
  'login',
@@ -929,6 +934,17 @@ export function resolveRuntimeProfile(
929
934
  profileName,
930
935
  profileSource,
931
936
  profileLabel: profile.label,
937
+ // Which published build serves this profile. Carried on the runtime so
938
+ // every recovery hint prints the command that will actually run.
939
+ channel: devRuntime
940
+ ? null
941
+ : channelFromProfile(
942
+ (globalOptions.apiBase || process.env.NOTIS_API_BASE)
943
+ // An explicit route owns this invocation even when the stored
944
+ // profile is pinned to the opposite release channel.
945
+ ? { api_base: apiBase }
946
+ : { ...profile, api_base: apiBase },
947
+ ),
932
948
  apiBase,
933
949
  requestedApiBase: normalizedRequestedApiBase,
934
950
  jwt,