@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.
@@ -10,14 +10,14 @@ import { dirname, join, parse, resolve } from 'node:path';
10
10
  import { CliError, EXIT_CODES } from './errors.js';
11
11
  import { withConfigFileWriteLock } from './config-lock.js';
12
12
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
13
- import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
13
+ import { channelFromProfile, cliCommandForChannel, pinnedReleaseChannel } from './channel.js';
14
14
  import { resolveRetryEnabled } from './retry.js';
15
15
 
16
16
  export const CONFIG_DIR = join(homedir(), '.notis');
17
17
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
18
18
  export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
19
19
  export const DEFAULT_API_BASE = 'https://api.notis.ai';
20
- export const BETA_API_BASE = 'https://api-beta.notis.ai';
20
+ export const NIGHTLY_API_BASE = 'https://api-nightly.notis.ai';
21
21
  export const DEFAULT_PROFILE = 'default';
22
22
  const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
23
  const LEGACY_DESKTOP_PROFILE_KEYS = [
@@ -32,7 +32,7 @@ const LEGACY_DESKTOP_PROFILE_KEYS = [
32
32
  const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
33
33
  const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
34
34
  const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
35
- const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
35
+ const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-nightly.notis.ai', 'api-beta.notis.ai']);
36
36
 
37
37
  function clone(value) {
38
38
  return JSON.parse(JSON.stringify(value));
@@ -86,11 +86,10 @@ function normalizeProfile(rawProfile = {}) {
86
86
  const raw = rawProfile && typeof rawProfile === 'object' ? rawProfile : {};
87
87
  return {
88
88
  api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
89
- beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
90
- // Which published CLI build this profile runs, as the deployment reported
91
- // it at login. Unknown values are dropped rather than trusted: this key
92
- // decides which code executes on the next run.
93
- channel: isReleaseChannel(raw.channel) ? raw.channel : undefined,
89
+ // Which published CLI build this profile runs (`stable` | `nightly`), as
90
+ // the account reported it at login. Unknown values are dropped rather
91
+ // than trusted: this key decides which code executes on the next run.
92
+ release_channel: pinnedReleaseChannel(raw) || undefined,
94
93
  label: typeof raw.label === 'string' ? raw.label : undefined,
95
94
  dev_access_token:
96
95
  typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
@@ -535,28 +534,15 @@ function isLiveApiBase(value) {
535
534
  /**
536
535
  * Pick the live Notis API for this profile.
537
536
  *
538
- * Beta users (`users.beta = true`, mirrored onto the profile when OAuth
539
- * authorizes against api-beta) hit api-beta.notis.ai; everyone else hits
540
- * api.notis.ai. Localhost is never a default — only a `./dev.sh` profile
541
- * backed by a live worktree lease may retarget the CLI at loopback.
537
+ * Nightly accounts (`users.release_channel = 'nightly'`, pinned on the profile
538
+ * at login) hit the nightly API; everyone else hits api.notis.ai. Localhost is
539
+ * never a default: only a `./dev.sh` profile backed by a live worktree lease
540
+ * may retarget the CLI at loopback.
542
541
  */
543
542
  export function resolveDefaultLiveApiBase(profile = {}) {
544
- if (profile.beta === true) {
545
- return BETA_API_BASE;
546
- }
547
- if (profile.beta === false) {
548
- return DEFAULT_API_BASE;
549
- }
550
- if (isLiveApiBase(profile.api_base)) {
551
- try {
552
- if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
553
- return BETA_API_BASE;
554
- }
555
- } catch {
556
- // fall through
557
- }
558
- }
559
- return DEFAULT_API_BASE;
543
+ const channel = pinnedReleaseChannel(profile)
544
+ || (isLiveApiBase(profile.api_base) ? channelFromProfile({ api_base: profile.api_base }) : null);
545
+ return channel === 'nightly' ? NIGHTLY_API_BASE : DEFAULT_API_BASE;
560
546
  }
561
547
 
562
548
  export function getApiBase(config, profileName, override) {
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Login follows the account's release channel.
3
+ *
4
+ * Every Notis account belongs to one release channel (`stable` | `nightly`),
5
+ * and each channel has its own API. After a login, the CLI asks the API it just
6
+ * authorized against which channel the account uses (`GET /release_channel`).
7
+ * When that API serves the other channel, the CLI signs the profile in again
8
+ * against the account's own API; the profile then pins `release_channel`, and
9
+ * the next run re-executes the matching npm build (resolveChannelSwitch).
10
+ *
11
+ * An explicit route always wins: `--api-base`, `NOTIS_API_BASE`, a `./dev.sh`
12
+ * worktree and any loopback API skip all of this, exactly as before.
13
+ */
14
+
15
+ import { CliError, EXIT_CODES } from './errors.js';
16
+ import { quoteShellArgument } from './auth-recovery.js';
17
+ import { cliCommandForChannel, normalizeReleaseChannel } from './channel.js';
18
+ import { updateConfig } from './profiles.js';
19
+
20
+ const CHANNEL_LABELS = { stable: 'Stable', nightly: 'Nightly' };
21
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
22
+ const RELEASE_CHANNEL_TIMEOUT_MS = 10_000;
23
+
24
+ export function releaseChannelLabel(channel) {
25
+ return CHANNEL_LABELS[channel] || channel;
26
+ }
27
+
28
+ function isLoopbackApiBase(apiBase) {
29
+ try {
30
+ return LOOPBACK_HOSTS.has(new URL(apiBase).hostname);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The API origin a channel answer may send the CLI to. Only an https Notis
38
+ * host qualifies: a login is about to hand this origin a fresh grant.
39
+ */
40
+ export function trustedApiOrigin(value) {
41
+ if (typeof value !== 'string' || !value) return null;
42
+ try {
43
+ const url = new URL(value);
44
+ const notisHost = url.hostname === 'notis.ai' || url.hostname.endsWith('.notis.ai');
45
+ return url.protocol === 'https:' && notisHost && !url.username && !url.password ? url.origin : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** True when this invocation routes by the account rather than explicitly. */
52
+ export function followsAccountReleaseChannel(runtime = {}, env = process.env) {
53
+ if (runtime.requestedApiBase || env.NOTIS_API_BASE) return false;
54
+ if (runtime.worktreeRuntime || runtime.credentialKind !== 'oauth' || !runtime.jwt) return false;
55
+ return Boolean(runtime.apiBase) && !isLoopbackApiBase(runtime.apiBase);
56
+ }
57
+
58
+ /**
59
+ * Normalizes a `/release_channel` answer, or a `release_channel_mismatch`
60
+ * error body, which carries the same fields.
61
+ */
62
+ export function parseReleaseChannelAnswer(payload) {
63
+ if (!payload || typeof payload !== 'object') return null;
64
+ const releaseChannel = normalizeReleaseChannel(payload.release_channel);
65
+ if (!releaseChannel) return null;
66
+ const deploymentChannel = normalizeReleaseChannel(payload.deployment_channel);
67
+ const origins = payload.origins && typeof payload.origins === 'object' ? payload.origins : {};
68
+ return {
69
+ releaseChannel,
70
+ deploymentChannel,
71
+ match: !deploymentChannel || payload.match === true || deploymentChannel === releaseChannel,
72
+ apiOrigin: trustedApiOrigin(origins.api),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Asks the API which channel the signed-in account uses. Any failure (an API
78
+ * that predates the endpoint, a network error) answers null: the login that
79
+ * just succeeded stands, and the 403 a mismatched API returns later explains
80
+ * the way out.
81
+ */
82
+ export async function fetchAccountReleaseChannel(runtime, fetchImpl = fetch) {
83
+ try {
84
+ const response = await fetchImpl(`${String(runtime.apiBase).replace(/\/+$/, '')}/release_channel`, {
85
+ method: 'GET',
86
+ headers: {
87
+ Accept: 'application/json',
88
+ Authorization: `Bearer ${runtime.jwt}`,
89
+ ...(runtime.cliVersion ? { 'X-Notis-CLI-Version': runtime.cliVersion } : {}),
90
+ },
91
+ signal: AbortSignal.timeout(RELEASE_CHANNEL_TIMEOUT_MS),
92
+ });
93
+ if (!response.ok) return null;
94
+ return parseReleaseChannelAnswer(await response.json());
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function persistReleaseChannel(runtime, releaseChannel) {
101
+ const config = updateConfig((latest) => {
102
+ const profile = latest.profiles[runtime.profileName];
103
+ if (profile) profile.release_channel = releaseChannel;
104
+ return latest;
105
+ });
106
+ runtime.channel = releaseChannel;
107
+ return config.profiles[runtime.profileName];
108
+ }
109
+
110
+ /**
111
+ * Checks a finished login against the account's channel and, on a mismatch,
112
+ * signs in again against the account's API.
113
+ *
114
+ * `result` is what loginWithOAuth returned; anything without a profile (a
115
+ * pending browser hand-off) is returned untouched. `login` is loginWithOAuth,
116
+ * injected so this module does not import the OAuth machinery.
117
+ */
118
+ export async function followAccountReleaseChannel(runtime, result, {
119
+ login,
120
+ options = {},
121
+ output,
122
+ fetchImpl = fetch,
123
+ env = process.env,
124
+ followed = false,
125
+ } = {}) {
126
+ if (!result?.profile || !followsAccountReleaseChannel(runtime, env)) return result;
127
+ const answer = await fetchAccountReleaseChannel(runtime, fetchImpl);
128
+ // No answer, or a development API with no channel: nothing to follow.
129
+ if (!answer?.deploymentChannel) return result;
130
+ if (answer.match) {
131
+ return { ...result, profile: persistReleaseChannel(runtime, answer.releaseChannel) || result.profile };
132
+ }
133
+ const label = releaseChannelLabel(answer.releaseChannel);
134
+ if (followed || !answer.apiOrigin || answer.apiOrigin === runtime.apiBase) {
135
+ // Following once more would loop; say so instead of failing the login.
136
+ output?.notice?.(
137
+ `Your account uses the ${label} channel, but this sign-in could not move there. `
138
+ + `Run: ${releaseChannelLoginCommand(runtime, answer)}`,
139
+ );
140
+ return result;
141
+ }
142
+ output?.notice?.(`Your account uses the ${label} channel, signing you in there.`);
143
+ runtime.apiBase = answer.apiOrigin;
144
+ // A code or a stored credential belongs to the login that just finished.
145
+ const { code: _code, reusePersistedCredential: _reuse, ...nextOptions } = options;
146
+ const next = await login(runtime, nextOptions, output);
147
+ if (!next?.profile) return next;
148
+ return followAccountReleaseChannel(runtime, next, {
149
+ login, options: nextOptions, output, fetchImpl, env, followed: true,
150
+ });
151
+ }
152
+
153
+ /** The exact command that signs this profile in on the account's channel. */
154
+ export function releaseChannelLoginCommand(runtime = {}, answer = {}) {
155
+ return [
156
+ cliCommandForChannel(answer.releaseChannel),
157
+ ...(runtime.profileName && runtime.profileName !== 'default'
158
+ ? [`--profile ${quoteShellArgument(runtime.profileName)}`]
159
+ : []),
160
+ ...(answer.apiOrigin ? [`--api-base ${quoteShellArgument(answer.apiOrigin)}`] : []),
161
+ 'login',
162
+ ].join(' ');
163
+ }
164
+
165
+ /**
166
+ * The error for an API that refuses this account because it serves the other
167
+ * release channel (`403 {"error": "release_channel_mismatch", ...}`).
168
+ */
169
+ export function releaseChannelMismatchError(payload, runtime = {}) {
170
+ const answer = parseReleaseChannelAnswer(payload) || {};
171
+ const label = releaseChannelLabel(answer.releaseChannel);
172
+ const message = (typeof payload?.message === 'string' && payload.message)
173
+ || (answer.releaseChannel
174
+ ? `Your account uses the ${label} channel, which this Notis API does not serve.`
175
+ : 'Your account uses another Notis release channel than this API serves.');
176
+ const command = releaseChannelLoginCommand(runtime, answer);
177
+ return new CliError({
178
+ code: 'release_channel_mismatch',
179
+ message: `${message} Sign in again with: ${command}`,
180
+ exitCode: EXIT_CODES.auth,
181
+ details: payload || {},
182
+ hints: [{ command, reason: `Sign this profile in on the ${label || 'right'} channel` }],
183
+ });
184
+ }
@@ -7,6 +7,9 @@ import { fileURLToPath } from 'node:url';
7
7
  import { isValidProfileName, loadConfig } from './profiles.js';
8
8
  import { withSkillSyncLock } from './sync-skills.js';
9
9
 
10
+ // Every live API, current names first, then the beta name nightly answered on.
11
+ const LIVE_API_BASES = ['https://api.notis.ai', 'https://api-nightly.notis.ai', 'https://api-beta.notis.ai'];
12
+
10
13
  export const SKILL_SYNC_SERVICE_LABEL = 'ai.notis.skills-sync';
11
14
  const bundlePath = fileURLToPath(new URL('../../dist/skill-sync-worker.mjs', import.meta.url));
12
15
 
@@ -28,7 +31,7 @@ export async function maybeInstallSkillSyncService(runtime, {
28
31
  } = {}) {
29
32
  if (process.platform !== 'darwin' || runtime.credentialKind !== 'oauth'
30
33
  || config.current_profile !== runtime.profileName
31
- || !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) return;
34
+ || !LIVE_API_BASES.includes(runtime.apiBase)) return;
32
35
  // Ordinary commands must not steal another explicitly bound account's job.
33
36
  const plist = join(homedir(), 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
34
37
  if (existsSync(plist)) {
@@ -47,7 +50,7 @@ export async function maybeInstallSkillSyncService(runtime, {
47
50
  export async function installSkillSyncService(runtime, options = {}) {
48
51
  if ((options.platform || process.platform) !== 'darwin') return { status: 'unsupported_platform' };
49
52
  if (runtime.credentialKind !== 'oauth' || !runtime.oauthUserId
50
- || !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
53
+ || !LIVE_API_BASES.includes(runtime.apiBase)) {
51
54
  return { status: 'skipped_non_personal_profile' };
52
55
  }
53
56
  // Registration upgrades must not kill a worker holding the filesystem lock,
@@ -65,7 +68,7 @@ async function installSkillSyncServiceLocked(runtime, {
65
68
  if (platform !== 'darwin') return { status: 'unsupported_platform' };
66
69
  if (runtime.credentialKind !== 'oauth' || runtime.envCredentialOverride
67
70
  || !isValidProfileName(runtime.profileName)
68
- || !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
71
+ || !LIVE_API_BASES.includes(runtime.apiBase)) {
69
72
  return { status: 'skipped_non_personal_profile' };
70
73
  }
71
74
  const root = join(home, '.notis', 'skills', 'service');
@@ -14,6 +14,7 @@ import {
14
14
  import { createExpiredAuthError, createInvalidAuthHints } from './auth-recovery.js';
15
15
  import { refreshOAuthCredential } from './oauth.js';
16
16
  import { findUpsell, renderUpsell } from './upsell.js';
17
+ import { releaseChannelMismatchError } from './release-channel.js';
17
18
  import {
18
19
  callCarriesToolRequestId,
19
20
  canReplayRequest,
@@ -143,6 +144,12 @@ export function normalizeBackendError(status, payload, runtime) {
143
144
  });
144
145
  }
145
146
 
147
+ if (status === 403 && backendError === 'release_channel_mismatch') {
148
+ // The account belongs to the other release channel's API. Nothing here
149
+ // can be retried; signing the profile in on its own channel fixes it.
150
+ return releaseChannelMismatchError(payload, runtime);
151
+ }
152
+
146
153
  if (status === 403) {
147
154
  const upsellBody = findUpsell(payload);
148
155
  if (upsellBody) {
@@ -12,7 +12,8 @@ const NESTED_KEYS = ['error', 'details', 'data', 'result', 'response', 'entitlem
12
12
 
13
13
  const PORTAL_ORIGINS = {
14
14
  'api.notis.ai': 'https://app.notis.ai',
15
- 'api-beta.notis.ai': 'https://beta.notis.ai',
15
+ 'api-nightly.notis.ai': 'https://app-nightly.notis.ai',
16
+ 'api-beta.notis.ai': 'https://app-nightly.notis.ai',
16
17
  };
17
18
 
18
19
  function isUpsellBody(value) {