@notis_ai/cli 0.2.23 → 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.
@@ -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) {