@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.
- package/README.md +1 -1
- package/dist/agent-hooks/notis-agent-hook.mjs +244 -86
- package/dist/base-skills/notis-cli/.notis-base-skill.json +1 -1
- package/dist/base-skills/notis-cli/SKILL.md +2 -2
- package/dist/skill-sync-worker.mjs +39 -46
- package/package.json +3 -3
- package/src/cli.js +4 -3
- package/src/command-specs/auth.js +7 -2
- package/src/command-specs/meta.js +1 -1
- package/src/command-specs/onboarding.js +26 -12
- package/src/command-specs/tools.js +20 -10
- package/src/runtime/channel.js +70 -30
- package/src/runtime/oauth.js +23 -23
- package/src/runtime/profiles.js +14 -28
- package/src/runtime/release-channel.js +184 -0
- package/src/runtime/skill-sync-service.js +6 -3
- package/src/runtime/transport.js +7 -0
- package/src/runtime/upsell.js +2 -1
package/src/runtime/profiles.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|| !
|
|
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
|
-
|| !
|
|
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
|
-
|| !
|
|
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');
|
package/src/runtime/transport.js
CHANGED
|
@@ -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) {
|
package/src/runtime/upsell.js
CHANGED
|
@@ -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-
|
|
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) {
|