@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
|
@@ -153,32 +153,46 @@ function withConfigFileWriteLock(configFile, callback) {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
// src/runtime/channel.js
|
|
156
|
-
var RELEASE_CHANNELS = ["stable", "
|
|
157
|
-
var CHANNEL_TAGS = { stable: "latest",
|
|
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
|
|
169
|
-
if (
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
55
|
+
"sourceSha": "a7010008e66c7091ebc496a82627bd42a1c5ead6",
|
|
56
56
|
"channel": "stable"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
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-
|
|
274
|
-
// profile is a deliberate one-off
|
|
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]:
|
|
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
|
|
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
|
|
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',
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/runtime/channel.js
CHANGED
|
@@ -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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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', '
|
|
14
|
-
|
|
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
|
|
37
|
-
* (`0.2.0-
|
|
38
|
-
* the manifest version is the only
|
|
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('-') ? '
|
|
73
|
+
return String(version || '').includes('-') ? 'nightly' : 'stable';
|
|
43
74
|
}
|
|
44
75
|
|
|
45
76
|
/**
|
|
46
|
-
* The channel a profile
|
|
77
|
+
* The channel a stored profile's fields pin it to, or null.
|
|
47
78
|
*
|
|
48
|
-
*
|
|
49
|
-
* `beta` flag and
|
|
50
|
-
*
|
|
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
|
|
53
|
-
if (
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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');
|
package/src/runtime/oauth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
413
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
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
|
};
|