@notis_ai/cli 0.2.11 → 0.2.13
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 +11 -0
- package/package.json +1 -1
- package/skills/notis-cli/SKILL.md +3 -1
- package/src/cli.js +158 -0
- package/src/command-specs/meta.js +53 -0
- package/src/runtime/auth-recovery.js +13 -3
- package/src/runtime/channel.js +133 -0
- package/src/runtime/oauth.js +73 -12
- package/src/runtime/profiles.js +17 -1
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
|
@@ -29,7 +29,9 @@ Use the registry-resolved published npm package everywhere:
|
|
|
29
29
|
|
|
30
30
|
- `npx --package @notis_ai/cli@latest -- notis ...`
|
|
31
31
|
|
|
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
|
|
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
|
+
|
|
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.
|
|
33
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
|
|
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
|
-
|
|
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(
|
|
23
|
-
const
|
|
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
|
+
}
|
package/src/runtime/oauth.js
CHANGED
|
@@ -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,
|
|
@@ -43,6 +48,7 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
|
|
|
43
48
|
// still have to sign up, verify an email, and consent before pasting the code.
|
|
44
49
|
const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
|
|
45
50
|
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
|
|
51
|
+
const RESPONSE_FLUSH_GRACE_MS = 2_000;
|
|
46
52
|
|
|
47
53
|
function oauthError(code, message, hints = null, details = {}) {
|
|
48
54
|
return new CliError({
|
|
@@ -136,6 +142,12 @@ export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
|
|
|
136
142
|
resource: protectedResource.resource,
|
|
137
143
|
clientId: protectedResource.notis_cli_client_id || 'notis_cli',
|
|
138
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,
|
|
139
151
|
authorizationEndpoint: authorizationServer.authorization_endpoint,
|
|
140
152
|
tokenEndpoint: authorizationServer.token_endpoint,
|
|
141
153
|
revocationEndpoint: authorizationServer.revocation_endpoint,
|
|
@@ -461,11 +473,22 @@ export async function createLoopbackReceiver({
|
|
|
461
473
|
let rejectCode;
|
|
462
474
|
let timeout;
|
|
463
475
|
let pendingResponse = null;
|
|
476
|
+
// Browsers routinely park speculative connections that never send a request.
|
|
477
|
+
// `server.close()` waits for every socket it accepted, so the sockets have to
|
|
478
|
+
// be tracked and dropped by hand or a finished login would keep waiting.
|
|
479
|
+
const sockets = new Set();
|
|
480
|
+
let responseFlushed = Promise.resolve();
|
|
464
481
|
const result = new Promise((resolve, reject) => {
|
|
465
482
|
resolveCode = resolve;
|
|
466
483
|
rejectCode = reject;
|
|
467
484
|
});
|
|
468
485
|
|
|
486
|
+
const endResponse = (response, body) => {
|
|
487
|
+
responseFlushed = new Promise((resolve) => {
|
|
488
|
+
response.end(body, resolve);
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
|
|
469
492
|
const server = createServer((request, response) => {
|
|
470
493
|
const address = server.address();
|
|
471
494
|
const expectedHost = address && typeof address === 'object'
|
|
@@ -478,18 +501,18 @@ export async function createLoopbackReceiver({
|
|
|
478
501
|
|
|
479
502
|
if (request.headers.host !== expectedHost) {
|
|
480
503
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
481
|
-
response
|
|
504
|
+
endResponse(response, callbackHtml('Invalid callback', 'The callback host was not accepted.'));
|
|
482
505
|
return;
|
|
483
506
|
}
|
|
484
507
|
const url = new URL(request.url || '/', `http://${expectedHost}`);
|
|
485
508
|
if (request.method !== 'GET' || url.pathname !== '/callback') {
|
|
486
509
|
response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
487
|
-
response
|
|
510
|
+
endResponse(response, callbackHtml('Not found', 'This callback path does not exist.'));
|
|
488
511
|
return;
|
|
489
512
|
}
|
|
490
513
|
if (consumed) {
|
|
491
514
|
response.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
492
|
-
response
|
|
515
|
+
endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
|
|
493
516
|
return;
|
|
494
517
|
}
|
|
495
518
|
consumed = true;
|
|
@@ -499,14 +522,14 @@ export async function createLoopbackReceiver({
|
|
|
499
522
|
const error = url.searchParams.get('error');
|
|
500
523
|
if (!stateMatches(state, returnedState)) {
|
|
501
524
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
502
|
-
response
|
|
525
|
+
endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
|
|
503
526
|
rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
|
|
504
527
|
return;
|
|
505
528
|
}
|
|
506
529
|
if (error || !code) {
|
|
507
530
|
const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
|
|
508
531
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
509
|
-
response
|
|
532
|
+
endResponse(response, callbackHtml('Authorization not completed', description));
|
|
510
533
|
rejectCode(oauthError(error || 'oauth_code_missing', description));
|
|
511
534
|
return;
|
|
512
535
|
}
|
|
@@ -517,6 +540,11 @@ export async function createLoopbackReceiver({
|
|
|
517
540
|
resolveCode(code);
|
|
518
541
|
});
|
|
519
542
|
|
|
543
|
+
server.on('connection', (socket) => {
|
|
544
|
+
sockets.add(socket);
|
|
545
|
+
socket.on('close', () => sockets.delete(socket));
|
|
546
|
+
});
|
|
547
|
+
|
|
520
548
|
await new Promise((resolve, reject) => {
|
|
521
549
|
server.once('error', reject);
|
|
522
550
|
server.listen(0, '127.0.0.1', () => {
|
|
@@ -539,12 +567,24 @@ export async function createLoopbackReceiver({
|
|
|
539
567
|
clearTimeout(timeout);
|
|
540
568
|
if (pendingResponse && !pendingResponse.writableEnded) {
|
|
541
569
|
pendingResponse.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
542
|
-
pendingResponse
|
|
570
|
+
endResponse(pendingResponse, callbackHtml(
|
|
543
571
|
'Authorization did not finish',
|
|
544
572
|
'Return to the terminal for details, then retry sign in.',
|
|
545
573
|
));
|
|
546
574
|
pendingResponse = null;
|
|
547
575
|
}
|
|
576
|
+
// The browser answer is already written; wait only for the kernel to take
|
|
577
|
+
// it so tearing the socket down cannot truncate the connected page.
|
|
578
|
+
await Promise.race([
|
|
579
|
+
responseFlushed,
|
|
580
|
+
new Promise((resolve) => { setTimeout(resolve, RESPONSE_FLUSH_GRACE_MS).unref?.(); }),
|
|
581
|
+
]);
|
|
582
|
+
// Chrome parks a speculative connection next to the one that carried the
|
|
583
|
+
// callback. It never sends a request, so Node counts it as active and
|
|
584
|
+
// `server.close()` waits for a socket only the browser will ever release:
|
|
585
|
+
// a login that already succeeded would sit in the terminal for minutes.
|
|
586
|
+
for (const socket of sockets) socket.destroy();
|
|
587
|
+
sockets.clear();
|
|
548
588
|
if (!server.listening) return;
|
|
549
589
|
await new Promise((resolve) => server.close(resolve));
|
|
550
590
|
};
|
|
@@ -563,13 +603,13 @@ export async function createLoopbackReceiver({
|
|
|
563
603
|
'Content-Type': 'text/html; charset=utf-8',
|
|
564
604
|
Location: connectedUrl.toString(),
|
|
565
605
|
});
|
|
566
|
-
pendingResponse
|
|
606
|
+
endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
|
|
567
607
|
pendingResponse = null;
|
|
568
608
|
},
|
|
569
609
|
fail: () => {
|
|
570
610
|
if (!pendingResponse || pendingResponse.writableEnded) return;
|
|
571
611
|
pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
572
|
-
pendingResponse
|
|
612
|
+
endResponse(pendingResponse, callbackHtml(
|
|
573
613
|
'Authorization failed',
|
|
574
614
|
'The CLI could not finish signing in. Return to the terminal for details.',
|
|
575
615
|
));
|
|
@@ -689,6 +729,13 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
|
|
|
689
729
|
// only endpoint the resulting token is accepted by.
|
|
690
730
|
api_base: oauthApiBase || profile.api_base,
|
|
691
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,
|
|
692
739
|
oauth_api_base: oauthApiBase || profile.oauth_api_base,
|
|
693
740
|
oauth_resource: metadata.resource,
|
|
694
741
|
oauth_access_token: tokenResponse.access_token,
|
|
@@ -755,14 +802,20 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
|
|
|
755
802
|
}
|
|
756
803
|
}
|
|
757
804
|
|
|
758
|
-
function redeemCommand(profileName) {
|
|
805
|
+
function redeemCommand(profileName, channel) {
|
|
759
806
|
return [
|
|
760
|
-
|
|
807
|
+
cliCommandForChannel(channel),
|
|
761
808
|
`--profile ${quoteShellArgument(profileName || 'default')}`,
|
|
762
809
|
'login --code <code>',
|
|
763
810
|
].join(' ');
|
|
764
811
|
}
|
|
765
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
|
+
|
|
766
819
|
function updateRuntimeFromOAuthProfile(runtime, profile) {
|
|
767
820
|
const oauthApiBase = getOAuthApiBase(profile);
|
|
768
821
|
runtime.jwt = profile.oauth_access_token;
|
|
@@ -846,6 +899,7 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
|
|
|
846
899
|
resource: pending.resource,
|
|
847
900
|
clientId: pending.client_id,
|
|
848
901
|
tokenEndpoint: pending.token_endpoint,
|
|
902
|
+
channel: pending.channel,
|
|
849
903
|
};
|
|
850
904
|
if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
|
|
851
905
|
throw oauthError(
|
|
@@ -932,7 +986,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
932
986
|
scopes: pendingScopes,
|
|
933
987
|
}),
|
|
934
988
|
expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
|
|
935
|
-
redeem_command: redeemCommand(
|
|
989
|
+
redeem_command: redeemCommand(
|
|
990
|
+
runtime.profileName,
|
|
991
|
+
authorizationChannel(metadata, runtime, pending),
|
|
992
|
+
),
|
|
936
993
|
},
|
|
937
994
|
};
|
|
938
995
|
}
|
|
@@ -981,6 +1038,7 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
981
1038
|
client_id: metadata.clientId,
|
|
982
1039
|
token_endpoint: metadata.tokenEndpoint,
|
|
983
1040
|
authorization_endpoint: metadata.authorizationEndpoint,
|
|
1041
|
+
channel: authorizationChannel(metadata, runtime),
|
|
984
1042
|
scopes,
|
|
985
1043
|
expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
|
|
986
1044
|
});
|
|
@@ -992,7 +1050,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
992
1050
|
agentAuthorization: {
|
|
993
1051
|
authorize_url: authorizeUrl,
|
|
994
1052
|
expires_in: PENDING_LOGIN_TTL_SECONDS,
|
|
995
|
-
redeem_command: redeemCommand(
|
|
1053
|
+
redeem_command: redeemCommand(
|
|
1054
|
+
runtime.profileName,
|
|
1055
|
+
authorizationChannel(metadata, runtime),
|
|
1056
|
+
),
|
|
996
1057
|
},
|
|
997
1058
|
};
|
|
998
1059
|
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -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
|
-
|
|
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,
|