@notis_ai/cli 0.2.0-beta.196.1 → 0.2.0-beta.198.1
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 -11
- package/config/portal-route-roots.json +0 -1
- package/dist/agent-hooks/notis-agent-hook.mjs +760 -473
- package/dist/base-skills/notis-apps/SKILL.md +4 -3
- package/dist/base-skills/notis-apps/references/sdk.md +3 -1
- package/dist/base-skills/notis-apps/references/views.md +1 -1
- package/dist/base-skills/notis-cli/SKILL.md +7 -5
- package/dist/base-skills/notis-query/references/documents.md +4 -3
- package/dist/sdk/src/components/DocumentPage.tsx +6 -3
- package/dist/sdk/src/runtime.ts +10 -2
- package/dist/skill-sync/index.js +78 -12
- package/dist/skill-sync/index.js.map +3 -3
- package/dist/skill-sync-worker.mjs +391 -175
- package/dist/view-renderer/dist/host.js +86 -86
- package/dist/view-renderer/src/host.tsx +4 -2
- package/package.json +1 -1
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/command-specs/auth.js +2 -2
- package/src/command-specs/handover.js +45 -16
- package/src/command-specs/profile.js +42 -1
- package/src/command-specs/skills.js +13 -1
- package/src/runtime/computer-identity.d.ts +30 -0
- package/src/runtime/computer-identity.js +151 -0
- package/src/runtime/config-lock.js +135 -0
- package/src/runtime/oauth.js +18 -1
- package/src/runtime/profiles.js +5 -107
- package/src/runtime/skill-sync/index.ts +83 -7
- package/src/runtime/skill-sync/local-scanner.ts +7 -4
- package/src/runtime/skill-sync/native-plan.ts +14 -3
- package/src/runtime/sync-skills.d.ts +3 -1
- package/src/runtime/sync-skills.js +10 -2
- package/src/skill-sync-worker.js +14 -2
|
@@ -131,9 +131,11 @@ async function start() {
|
|
|
131
131
|
captureContextSelection() {}, registerTopBarSearch() {}, setTopBarSearchValue() {}, setTopBarSearchLoading() {},
|
|
132
132
|
publishActiveResource(value) { activeMarkdown = value?.snapshot?.format === 'markdown' && typeof value.snapshot.content === 'string' ? value.snapshot.content : null; },
|
|
133
133
|
// The document page renders as its read-only snapshot: no toolbar, live session or saves.
|
|
134
|
+
// A note opened with the HTML viewer shows its one stored HTML file, never the editor.
|
|
134
135
|
ui: { DocumentEditor, RecordProperties, HtmlFrame, ReportFrame, ShareControl: () => null,
|
|
135
|
-
DocumentPage: ({ recordKey, className, header, properties }) =>
|
|
136
|
-
|
|
136
|
+
DocumentPage: ({ recordKey, className, header, properties, viewer }) => viewer === 'html'
|
|
137
|
+
? <HtmlFrame recordKey={recordKey} className={className} />
|
|
138
|
+
: <DocumentEditor recordKey={recordKey} className={className} variant={header === false ? 'body' : 'full'} showProperties={properties !== 'hidden'} />,
|
|
137
139
|
MarkdownEditor: ({ value }) => <Markdown value={value} /> },
|
|
138
140
|
callAction(id, inputs = {}) {
|
|
139
141
|
if (!Object.hasOwn(authority.actions, id) || authority.actions[id].readOnly !== true) return denied();
|
package/package.json
CHANGED
package/skills/notis-apps/cli.md
CHANGED
|
@@ -20,7 +20,7 @@ or no-deploy requests stop at local artifacts without remote mutation. Store pub
|
|
|
20
20
|
|
|
21
21
|
## Reports and HTML
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Interactive reports are independent Space views. Save self-contained HTML pages and reports in Notis Notes with its Save HTML Skill; pages and reports saved before Spaces open there too. See the canonical product guidance for build, revision, native file and view-link rules.
|
|
24
24
|
|
|
25
25
|
## Commands
|
|
26
26
|
|
|
@@ -15,7 +15,7 @@ Relevant Notis long-term memory may be injected into a turn inside `<notis_relev
|
|
|
15
15
|
4. Execute: `notis tools exec <TOOL> --arguments '<json>'`; batch independent calls with `notis tools exec-parallel '<json-array>'`.
|
|
16
16
|
5. If an integration is not connected, `notis tools link <toolkit>` prints a connection URL to give the user.
|
|
17
17
|
|
|
18
|
-
Tool name shapes: `LOCAL_NOTIS_*` (native Notis), `LOCAL_NOTIS_DATABASE_*` (databases and notes), `
|
|
18
|
+
Tool name shapes: `LOCAL_NOTIS_*` (native Notis), `LOCAL_NOTIS_DATABASE_*` (databases and notes), `CUSTOM_*` (the user's remote MCP servers), `LOCAL_MCP_*` (MCP servers on the user's computer), and integration tools such as `GMAIL_*`, `NOTION_*`, and `GOOGLECALENDAR_*`.
|
|
19
19
|
|
|
20
20
|
### Main features
|
|
21
21
|
|
|
@@ -133,9 +133,9 @@ export const authCommandSpecs = [
|
|
|
133
133
|
},
|
|
134
134
|
{
|
|
135
135
|
command_path: ['logout'],
|
|
136
|
-
summary: '
|
|
136
|
+
summary: 'Sign this computer out of one CLI profile: revoke and remove its OAuth credential.',
|
|
137
137
|
when_to_use:
|
|
138
|
-
'Use this to disconnect a single account.
|
|
138
|
+
'Use this to disconnect a single account on this computer. Its sign-ins on other computers stay, and other profiles keep their credentials unless you pass --all-profiles.',
|
|
139
139
|
args_schema: {
|
|
140
140
|
arguments: [],
|
|
141
141
|
options: [
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* `notis handover` -- give the branch you are on to a Notis agent.
|
|
3
3
|
*
|
|
4
4
|
* The coding agent already sitting in the user's terminal is the caller here.
|
|
5
|
-
* It pushes the branch, then hands the task to
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
5
|
+
* It pushes the branch, then hands the task to Notis. By default (`auto`) Notis
|
|
6
|
+
* picks the agent itself; `--route notis|codex|claude` names one instead, and
|
|
7
|
+
* the server turns that into a plain sentence in the task ("Use Codex for this
|
|
8
|
+
* task."), so the user's choice wins like it does in a chat message.
|
|
8
9
|
*
|
|
9
10
|
* The CLI deliberately does not create the cloud workspace itself. Doing so
|
|
10
11
|
* would hard-code the Conductor app's database schema and script paths into the
|
|
@@ -28,7 +29,18 @@ import { fetchToolSchema, nextIdempotencyKey, runToolCommand } from './helpers.j
|
|
|
28
29
|
const HAND_OVER_TOOL = 'LOCAL_NOTIS_HAND_OVER';
|
|
29
30
|
const SEARCH_THREADS_TOOL = 'LOCAL_NOTIS_SEARCH_CODING_AGENT_THREADS';
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
// What --route accepts and advertises. `auto` lets Notis pick the agent.
|
|
33
|
+
const ROUTES = ['auto', 'notis', 'codex', 'claude'];
|
|
34
|
+
// Values from before Notis picked the agent itself. Still accepted, so scripts
|
|
35
|
+
// and agents that learned them keep working, but never shown in help, examples
|
|
36
|
+
// or errors. They are sent as typed; the server maps them to codex or claude.
|
|
37
|
+
const LEGACY_ROUTES = new Map([
|
|
38
|
+
['codex_cloud', 'codex'],
|
|
39
|
+
['codex_local', 'codex'],
|
|
40
|
+
['claude_cloud', 'claude'],
|
|
41
|
+
['claude_local', 'claude'],
|
|
42
|
+
]);
|
|
43
|
+
const ROUTE_LABELS = { notis: 'Notis', codex: 'Codex', claude: 'Claude' };
|
|
32
44
|
const BRANCH_MODES = ['new', 'same'];
|
|
33
45
|
|
|
34
46
|
// A cloud coding agent takes minutes to start, and the hand-over POST itself
|
|
@@ -44,6 +56,17 @@ const HANDOVER_TIMEOUT_MS = 120_000;
|
|
|
44
56
|
*/
|
|
45
57
|
const HANDOVER_UNAVAILABLE_CODES = new Set(['usage_error', 'forbidden']);
|
|
46
58
|
|
|
59
|
+
/** A legacy route as the agent it now means; anything else unchanged. */
|
|
60
|
+
function normalizeRoute(route) {
|
|
61
|
+
return LEGACY_ROUTES.get(route) || route;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Who the hand-over went to, in words, for progress lines and the summary. */
|
|
65
|
+
function describeRoute(route) {
|
|
66
|
+
if (route === 'auto') return 'Notis, which picks the agent';
|
|
67
|
+
return ROUTE_LABELS[route] || route;
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
/**
|
|
48
71
|
* Quote a value for the command line the receiving agent is told to run.
|
|
49
72
|
*
|
|
@@ -121,8 +144,8 @@ async function handoverStartHandler(ctx) {
|
|
|
121
144
|
throw usageError(`--branch-mode must be one of: ${BRANCH_MODES.join(', ')}`, { branchMode });
|
|
122
145
|
}
|
|
123
146
|
|
|
124
|
-
const route = ctx.options.route || '
|
|
125
|
-
if (!ROUTES.includes(route)) {
|
|
147
|
+
const route = ctx.options.route || 'auto';
|
|
148
|
+
if (!ROUTES.includes(route) && !LEGACY_ROUTES.has(route)) {
|
|
126
149
|
throw usageError(`--route must be one of: ${ROUTES.join(', ')}`, { route });
|
|
127
150
|
}
|
|
128
151
|
|
|
@@ -223,7 +246,10 @@ async function handoverStartHandler(ctx) {
|
|
|
223
246
|
repoSlug: ctx.options.repo || null,
|
|
224
247
|
});
|
|
225
248
|
|
|
226
|
-
ctx.output.emitProgress({
|
|
249
|
+
ctx.output.emitProgress({
|
|
250
|
+
phase: 'handover',
|
|
251
|
+
message: `Handing over to ${describeRoute(normalizeRoute(route))}`,
|
|
252
|
+
});
|
|
227
253
|
const result = await runToolCommand({
|
|
228
254
|
runtime: {
|
|
229
255
|
...ctx.runtime,
|
|
@@ -255,7 +281,9 @@ async function handoverStartHandler(ctx) {
|
|
|
255
281
|
branch_mode: branchMode,
|
|
256
282
|
head: repository.head,
|
|
257
283
|
wip_commit: wipCommit,
|
|
258
|
-
|
|
284
|
+
// The server answers with auto, notis, codex or claude. An older server
|
|
285
|
+
// may echo a legacy value back, so map it here too.
|
|
286
|
+
agent_routing: normalizeRoute(payload.agent_routing || route),
|
|
259
287
|
interaction_id: payload.interaction_id || null,
|
|
260
288
|
thread_id: payload.thread_id || null,
|
|
261
289
|
};
|
|
@@ -264,7 +292,7 @@ async function handoverStartHandler(ctx) {
|
|
|
264
292
|
command: 'handover start',
|
|
265
293
|
data,
|
|
266
294
|
humanSummary:
|
|
267
|
-
`Handed ${data.branch} over to ${data.agent_routing} ` +
|
|
295
|
+
`Handed ${data.branch} over to ${describeRoute(data.agent_routing)} ` +
|
|
268
296
|
`(${branchMode === 'same' ? 'continuing the branch' : 'new branch from it'}).`,
|
|
269
297
|
hints: [
|
|
270
298
|
{ command: 'notis handover status', reason: 'See what the agent is doing' },
|
|
@@ -313,8 +341,8 @@ export const handoverCommandSpecs = [
|
|
|
313
341
|
summary: 'Hand the current branch to a Notis agent and keep working.',
|
|
314
342
|
when_to_use:
|
|
315
343
|
'Use this when you want Notis to continue work on the branch you are on -- long refactors, ' +
|
|
316
|
-
'test fixing, or anything that should keep running after you close the laptop.
|
|
317
|
-
'with --route.',
|
|
344
|
+
'test fixing, or anything that should keep running after you close the laptop. Notis picks ' +
|
|
345
|
+
'the agent unless you name one with --route.',
|
|
318
346
|
args_schema: {
|
|
319
347
|
arguments: [{ token: '<task>', description: 'What the agent should do, in plain language.' }],
|
|
320
348
|
options: [
|
|
@@ -327,7 +355,7 @@ export const handoverCommandSpecs = [
|
|
|
327
355
|
{
|
|
328
356
|
flags: '--route <target>',
|
|
329
357
|
description:
|
|
330
|
-
'Which agent runs it:
|
|
358
|
+
'Which agent runs it: auto (default, Notis picks the agent), notis, codex or claude.',
|
|
331
359
|
},
|
|
332
360
|
{ flags: '--repo <slug>', description: 'Configured repository slug on the cloud computer, when you know it.' },
|
|
333
361
|
{
|
|
@@ -338,12 +366,13 @@ export const handoverCommandSpecs = [
|
|
|
338
366
|
},
|
|
339
367
|
examples: [
|
|
340
368
|
'notis handover start "fix the failing auth tests"',
|
|
341
|
-
'notis handover start "finish the migration" --branch-mode same --route
|
|
342
|
-
'notis handover start "add integration tests" --route
|
|
343
|
-
'notis handover start "review and clean up this branch" --route
|
|
369
|
+
'notis handover start "finish the migration" --branch-mode same --route codex',
|
|
370
|
+
'notis handover start "add integration tests" --route claude',
|
|
371
|
+
'notis handover start "review and clean up this branch" --route notis',
|
|
344
372
|
],
|
|
345
373
|
output_schema:
|
|
346
|
-
'Returns the branch, the
|
|
374
|
+
'Returns the branch, the agent routing (auto when Notis picks, otherwise notis, codex or claude), ' +
|
|
375
|
+
'and the interaction/thread ids of the started run.',
|
|
347
376
|
mutates: true,
|
|
348
377
|
idempotent: false,
|
|
349
378
|
require_auth: true,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { CHANNEL_SWITCH_ENV } from '../runtime/channel.js';
|
|
1
3
|
import { CliError, EXIT_CODES } from '../runtime/errors.js';
|
|
2
4
|
import { quoteShellArgument } from '../runtime/auth-recovery.js';
|
|
3
5
|
import {
|
|
@@ -114,6 +116,44 @@ async function listHandler(ctx) {
|
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
const SKILL_SYNC_API_BASE_PATTERN = /^https:\/\/api(?:-beta)?\.notis\.ai\/?$/;
|
|
120
|
+
|
|
121
|
+
// Overrides that belong to this `profile use` run. Inherited, a NOTIS_JWT would
|
|
122
|
+
// sync its own account into the agent folders under the named profile.
|
|
123
|
+
const RELINK_DROPPED_ENV = ['NOTIS_JWT', 'NOTIS_API_BASE', 'NOTIS_PROFILE', CHANNEL_SWITCH_ENV];
|
|
124
|
+
|
|
125
|
+
// The new default's skills take over the agent folders (Claude Code, Codex,
|
|
126
|
+
// Cursor) now rather than at the next automatic sync. Detached, so the switch
|
|
127
|
+
// itself stays instant; a failure waits for that next sync.
|
|
128
|
+
export function relinkSkillsInBackground(name, profile, {
|
|
129
|
+
env = process.env,
|
|
130
|
+
argv = process.argv,
|
|
131
|
+
execPath = process.execPath,
|
|
132
|
+
spawnImpl = spawn,
|
|
133
|
+
} = {}) {
|
|
134
|
+
// Never from a test run: a detached sync would outlive it and reach the real API.
|
|
135
|
+
if (env.NODE_ENV === 'test' || env.NODE_TEST_CONTEXT || !argv[1]) return false;
|
|
136
|
+
if (!profile?.oauth_access_token && !profile?.oauth_refresh_token) return false;
|
|
137
|
+
if (!SKILL_SYNC_API_BASE_PATTERN.test(String(profile.api_base || ''))) return false;
|
|
138
|
+
// Like the automatic sync, the relink runs as the named profile alone: its
|
|
139
|
+
// credential, its endpoint and the CLI build for its channel.
|
|
140
|
+
const childEnv = { ...env };
|
|
141
|
+
for (const key of RELINK_DROPPED_ENV) delete childEnv[key];
|
|
142
|
+
try {
|
|
143
|
+
const child = spawnImpl(
|
|
144
|
+
execPath,
|
|
145
|
+
[argv[1], 'skills', 'sync', '--json', '--profile', name],
|
|
146
|
+
{ detached: true, stdio: 'ignore', env: childEnv },
|
|
147
|
+
);
|
|
148
|
+
child.on('error', () => {});
|
|
149
|
+
child.unref();
|
|
150
|
+
return true;
|
|
151
|
+
} catch {
|
|
152
|
+
// The next automatic sync relinks.
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
117
157
|
async function useHandler(ctx) {
|
|
118
158
|
const requested = ctx.args.name;
|
|
119
159
|
const config = loadConfig();
|
|
@@ -132,6 +172,7 @@ async function useHandler(ctx) {
|
|
|
132
172
|
const worktreeOverride = Boolean(worktreeRuntime);
|
|
133
173
|
const worktreeBlocked = Boolean(worktreeUnavailable);
|
|
134
174
|
const authenticated = profileHasCredential(profile);
|
|
175
|
+
if (authenticated) relinkSkillsInBackground(requested, profile);
|
|
135
176
|
|
|
136
177
|
return ctx.output.emitSuccess({
|
|
137
178
|
command: ctx.spec.command_path.join(' '),
|
|
@@ -298,7 +339,7 @@ export const profileCommandSpecs = [
|
|
|
298
339
|
command_path: ['profile', 'use'],
|
|
299
340
|
summary: 'Switch the default profile without signing any profile out.',
|
|
300
341
|
when_to_use:
|
|
301
|
-
'Use this to change which account and API subsequent commands target. Every other profile keeps its credential.',
|
|
342
|
+
'Use this to change which account and API subsequent commands target. Every other profile keeps its credential. Its skills replace the previous default\'s in the agent folders (Claude Code, Codex, Cursor) straight away.',
|
|
302
343
|
args_schema: {
|
|
303
344
|
arguments: [{ token: '<name>', key: 'name', description: 'Profile to make active.' }],
|
|
304
345
|
options: [],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getJwtSubject } from '../runtime/profiles.js';
|
|
1
|
+
import { getCurrentProfileName, getJwtSubject, loadConfig } from '../runtime/profiles.js';
|
|
2
2
|
import { reconcileAllSkills } from '../runtime/sync-skills.js';
|
|
3
3
|
import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
|
|
4
4
|
import { installSkillSyncService } from '../runtime/skill-sync-service.js';
|
|
@@ -82,6 +82,15 @@ async function loadSkillSyncEngine() {
|
|
|
82
82
|
return import('../../dist/skill-sync/index.js');
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function cliDefaultOwnsAgentFolders(runtime) {
|
|
86
|
+
if (runtime.profileSource !== 'explicit' || !runtime.profileName) return true;
|
|
87
|
+
try {
|
|
88
|
+
return runtime.profileName === getCurrentProfileName(loadConfig());
|
|
89
|
+
} catch {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
export async function syncSkillsHandler(ctx, {
|
|
86
95
|
refresh = ensureFreshOAuthCredential, loadEngine = loadSkillSyncEngine,
|
|
87
96
|
reconcile = reconcileAllSkills, install = installSkillSyncService,
|
|
@@ -95,6 +104,9 @@ export async function syncSkillsHandler(ctx, {
|
|
|
95
104
|
jwt: ctx.runtime.jwt,
|
|
96
105
|
userId: settings.user_id || userId,
|
|
97
106
|
honorSyncEnabled: Boolean(ctx.options.electronRepeat),
|
|
107
|
+
// Only the CLI default's skills go into the agent folders; another profile
|
|
108
|
+
// syncs its own folder.
|
|
109
|
+
linkAgentFolders: cliDefaultOwnsAgentFolders(ctx.runtime),
|
|
98
110
|
runAccountSync: (serverUrl, jwt, dependencies, options) => runSkillSync(
|
|
99
111
|
serverUrl, jwt, { ...dependencies, fetchSyncSettings: async () => settings }, options,
|
|
100
112
|
),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ComputerIdentity {
|
|
2
|
+
/** Stable UUID v4 for this computer, shared by every account on it. */
|
|
3
|
+
computerId: string;
|
|
4
|
+
/** The name the user chose, or null to use the computer's own name. */
|
|
5
|
+
name: string | null;
|
|
6
|
+
/** The computer's own name (macOS Computer Name, else the host name). */
|
|
7
|
+
defaultName: string;
|
|
8
|
+
/** `name ?? defaultName`, what the server and the user see. */
|
|
9
|
+
displayName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ComputerIdentityOptions {
|
|
13
|
+
/** The CLI config file computer.json sits next to. Defaults to NOTIS_CLI_CONFIG_FILE or ~/.notis/config.json. */
|
|
14
|
+
configFile?: string;
|
|
15
|
+
platform?: NodeJS.Platform;
|
|
16
|
+
hostname?: () => string;
|
|
17
|
+
readMacComputerName?: () => string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const COMPUTER_IDENTITY_VERSION: number;
|
|
21
|
+
export const COMPUTER_NAME_MAX_LENGTH: number;
|
|
22
|
+
|
|
23
|
+
export function computerIdentityFile(configFile?: string): string;
|
|
24
|
+
export function normalizeComputerName(value: unknown): string | null;
|
|
25
|
+
export function defaultComputerName(options?: ComputerIdentityOptions): string;
|
|
26
|
+
export function readComputerIdentity(options?: ComputerIdentityOptions): ComputerIdentity;
|
|
27
|
+
export function setComputerName(
|
|
28
|
+
name: string | null,
|
|
29
|
+
options?: ComputerIdentityOptions,
|
|
30
|
+
): ComputerIdentity;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { hostname as osHostname } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { resolveCliConfigFile, withConfigFileWriteLock } from './config-lock.js';
|
|
7
|
+
|
|
8
|
+
// This computer's identity, shared by the CLI and Desktop: one stable id per
|
|
9
|
+
// computer (the server keeps one CLI sign-in and one local shell connection per
|
|
10
|
+
// computer) and the name the user sees for it. It lives next to the CLI config
|
|
11
|
+
// file, so NOTIS_CLI_CONFIG_FILE moves it too, and is written under the same
|
|
12
|
+
// lock as the config.
|
|
13
|
+
export const COMPUTER_IDENTITY_VERSION = 1;
|
|
14
|
+
export const COMPUTER_NAME_MAX_LENGTH = 64;
|
|
15
|
+
const COMPUTER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
16
|
+
const FALLBACK_COMPUTER_NAME = 'Computer';
|
|
17
|
+
|
|
18
|
+
export function computerIdentityFile(configFile = resolveCliConfigFile()) {
|
|
19
|
+
return join(dirname(configFile), 'computer.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Trim, collapse whitespace and cap at 64 characters. Returns null for a name
|
|
24
|
+
* that is empty after cleanup, which means "use the computer's own name".
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeComputerName(value) {
|
|
27
|
+
if (typeof value !== 'string') return null;
|
|
28
|
+
const collapsed = value.replace(/\s+/gu, ' ').trim();
|
|
29
|
+
if (!collapsed) return null;
|
|
30
|
+
// Count code points so a cut never splits a character in two.
|
|
31
|
+
const characters = Array.from(collapsed);
|
|
32
|
+
if (characters.length <= COMPUTER_NAME_MAX_LENGTH) return collapsed;
|
|
33
|
+
return characters.slice(0, COMPUTER_NAME_MAX_LENGTH).join('').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readMacComputerName() {
|
|
37
|
+
try {
|
|
38
|
+
return execFileSync('/usr/sbin/scutil', ['--get', 'ComputerName'], {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
41
|
+
timeout: 2_000,
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The computer's own name: the macOS Computer Name, else the host name without
|
|
50
|
+
* a trailing `.local`.
|
|
51
|
+
*/
|
|
52
|
+
export function defaultComputerName(options = {}) {
|
|
53
|
+
const platform = options.platform ?? process.platform;
|
|
54
|
+
if (platform === 'darwin') {
|
|
55
|
+
const macName = normalizeComputerName((options.readMacComputerName ?? readMacComputerName)());
|
|
56
|
+
if (macName) return macName;
|
|
57
|
+
}
|
|
58
|
+
let host = '';
|
|
59
|
+
try {
|
|
60
|
+
host = String((options.hostname ?? osHostname)() || '');
|
|
61
|
+
} catch {
|
|
62
|
+
host = '';
|
|
63
|
+
}
|
|
64
|
+
return normalizeComputerName(host.replace(/\.local\.?$/iu, '')) || FALLBACK_COMPUTER_NAME;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseIdentityFile(file) {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validStored(raw) {
|
|
79
|
+
if (!raw || raw.version !== COMPUTER_IDENTITY_VERSION) return null;
|
|
80
|
+
if (typeof raw.computer_id !== 'string' || !COMPUTER_ID_PATTERN.test(raw.computer_id)) return null;
|
|
81
|
+
return {
|
|
82
|
+
computerId: raw.computer_id.toLowerCase(),
|
|
83
|
+
name: normalizeComputerName(raw.name),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeIdentityFile(file, stored) {
|
|
88
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
89
|
+
const contents = {
|
|
90
|
+
version: COMPUTER_IDENTITY_VERSION,
|
|
91
|
+
computer_id: stored.computerId,
|
|
92
|
+
...(stored.name ? { name: stored.name } : {}),
|
|
93
|
+
};
|
|
94
|
+
const temporaryFile = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
95
|
+
writeFileSync(temporaryFile, `${JSON.stringify(contents, null, 2)}\n`, { mode: 0o600 });
|
|
96
|
+
renameSync(temporaryFile, file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withIdentity(stored, options) {
|
|
100
|
+
const defaultName = defaultComputerName(options);
|
|
101
|
+
return {
|
|
102
|
+
computerId: stored.computerId,
|
|
103
|
+
name: stored.name,
|
|
104
|
+
defaultName,
|
|
105
|
+
displayName: stored.name ?? defaultName,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Under the config lock: re-read so two first reads agree on one id, then
|
|
110
|
+
// create (or repair) the file only when it holds no valid identity.
|
|
111
|
+
function readOrCreateLocked(configFile, file) {
|
|
112
|
+
const raw = parseIdentityFile(file);
|
|
113
|
+
const existing = validStored(raw);
|
|
114
|
+
if (existing) return existing;
|
|
115
|
+
const created = {
|
|
116
|
+
computerId: randomUUID(),
|
|
117
|
+
// A file with a broken id keeps the name the user chose.
|
|
118
|
+
name: normalizeComputerName(raw?.name),
|
|
119
|
+
};
|
|
120
|
+
writeIdentityFile(file, created);
|
|
121
|
+
return created;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read this computer's identity, creating `computer.json` on first read.
|
|
126
|
+
*/
|
|
127
|
+
export function readComputerIdentity(options = {}) {
|
|
128
|
+
const configFile = options.configFile ?? resolveCliConfigFile();
|
|
129
|
+
const file = computerIdentityFile(configFile);
|
|
130
|
+
const stored = validStored(parseIdentityFile(file))
|
|
131
|
+
?? withConfigFileWriteLock(configFile, () => readOrCreateLocked(configFile, file));
|
|
132
|
+
return withIdentity(stored, options);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rename this computer for every account on it. An empty name goes back to the
|
|
137
|
+
* computer's own name.
|
|
138
|
+
*/
|
|
139
|
+
export function setComputerName(name, options = {}) {
|
|
140
|
+
const configFile = options.configFile ?? resolveCliConfigFile();
|
|
141
|
+
const file = computerIdentityFile(configFile);
|
|
142
|
+
const stored = withConfigFileWriteLock(configFile, () => {
|
|
143
|
+
const current = readOrCreateLocked(configFile, file);
|
|
144
|
+
const next = { computerId: current.computerId, name: normalizeComputerName(name) };
|
|
145
|
+
if (next.name !== current.name) {
|
|
146
|
+
writeIdentityFile(file, next);
|
|
147
|
+
}
|
|
148
|
+
return next;
|
|
149
|
+
});
|
|
150
|
+
return withIdentity(stored, options);
|
|
151
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
// Cross-process write lock over ~/.notis/config.json. Independent `notis`
|
|
15
|
+
// processes (a `login` in one terminal and a `tools exec` in another) and
|
|
16
|
+
// Desktop all rewrite the files in this directory, so the lock directory name
|
|
17
|
+
// and these three timings are the whole coordination protocol. Desktop keeps a
|
|
18
|
+
// TypeScript copy in electron/src/notis-config-file.ts. Guarded by the
|
|
19
|
+
// concurrency test in packages/cli/test/runtime-auth.test.js.
|
|
20
|
+
const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
21
|
+
const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
|
|
22
|
+
const CONFIG_WRITE_LOCK_POLL_MS = 10;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shared CLI config file. NOTIS_CLI_CONFIG_FILE moves it (and every file
|
|
26
|
+
* kept next to it, such as computer.json) for tests and isolated runtimes.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveCliConfigFile() {
|
|
29
|
+
const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
|
|
30
|
+
if (envConfigFile) {
|
|
31
|
+
return resolve(envConfigFile);
|
|
32
|
+
}
|
|
33
|
+
return join(homedir(), '.notis', 'config.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The lock records who holds it and when they took it. Reclaiming a stale lock
|
|
37
|
+
// by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
|
|
38
|
+
// one it is about to steal from a live writer, and the victim's own release
|
|
39
|
+
// would then delete the thief's lock and admit a third writer. Both sides are
|
|
40
|
+
// on the same host, so the stored timestamp and Date.now() share a clock.
|
|
41
|
+
function readLockOwner(lockDirectory) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lockDirectoryMtime(lockDirectory) {
|
|
50
|
+
try {
|
|
51
|
+
return statSync(lockDirectory).mtimeMs;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function publishConfigWriteLock(lockDirectory, ownerId) {
|
|
58
|
+
const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(candidateDirectory);
|
|
61
|
+
writeFileSync(
|
|
62
|
+
join(candidateDirectory, 'owner'),
|
|
63
|
+
JSON.stringify({ id: ownerId, at: Date.now() }),
|
|
64
|
+
{ mode: 0o600 },
|
|
65
|
+
);
|
|
66
|
+
try {
|
|
67
|
+
renameSync(candidateDirectory, lockDirectory);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!existsSync(lockDirectory)) throw error;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
rmSync(candidateDirectory, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run `callback(configFile)` while holding `<configFile>.write-lock`.
|
|
80
|
+
*/
|
|
81
|
+
export function withConfigFileWriteLock(configFile, callback) {
|
|
82
|
+
const lockDirectory = `${configFile}.write-lock`;
|
|
83
|
+
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
84
|
+
const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
|
|
85
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
86
|
+
for (;;) {
|
|
87
|
+
if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const owner = readLockOwner(lockDirectory);
|
|
92
|
+
const observedMtime = lockDirectoryMtime(lockDirectory);
|
|
93
|
+
const ownerIsStale =
|
|
94
|
+
owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
|
|
95
|
+
const ownerlessIsStale =
|
|
96
|
+
!owner
|
|
97
|
+
&& observedMtime !== null
|
|
98
|
+
&& Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
|
|
99
|
+
if (ownerIsStale || ownerlessIsStale) {
|
|
100
|
+
try {
|
|
101
|
+
const currentOwner = readLockOwner(lockDirectory);
|
|
102
|
+
const ownerUnchanged = owner?.id
|
|
103
|
+
? currentOwner?.id === owner.id
|
|
104
|
+
: !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
|
|
105
|
+
if (ownerUnchanged) {
|
|
106
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Losing the reclaim race is normal; fall through and keep waiting.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (Date.now() >= deadline) {
|
|
114
|
+
throw new Error(`Timed out waiting to update ${configFile}`);
|
|
115
|
+
}
|
|
116
|
+
Atomics.wait(
|
|
117
|
+
new Int32Array(new SharedArrayBuffer(4)),
|
|
118
|
+
0,
|
|
119
|
+
0,
|
|
120
|
+
CONFIG_WRITE_LOCK_POLL_MS,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return callback(configFile);
|
|
125
|
+
} finally {
|
|
126
|
+
try {
|
|
127
|
+
// Never release a lock that was reclaimed from us while we worked.
|
|
128
|
+
if (readLockOwner(lockDirectory)?.id === ownerId) {
|
|
129
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// The lock may already have been removed after an interrupted write.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/runtime/oauth.js
CHANGED
|
@@ -21,6 +21,7 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
21
21
|
import { createInterface } from 'node:readline/promises';
|
|
22
22
|
|
|
23
23
|
import { CliError, EXIT_CODES, usageError } from './errors.js';
|
|
24
|
+
import { readComputerIdentity } from './computer-identity.js';
|
|
24
25
|
import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
|
|
25
26
|
import {
|
|
26
27
|
channelFromProfile,
|
|
@@ -674,7 +675,19 @@ function storedOAuthMetadata(runtime, profile) {
|
|
|
674
675
|
};
|
|
675
676
|
}
|
|
676
677
|
|
|
677
|
-
|
|
678
|
+
// This computer's id and name, so the server keeps one CLI sign-in per
|
|
679
|
+
// computer and lists it as "Notis CLI on <computer>". Best effort: a sign-in
|
|
680
|
+
// without them still works and maps to the shared legacy grant.
|
|
681
|
+
function cliDeviceParams() {
|
|
682
|
+
try {
|
|
683
|
+
const identity = readComputerIdentity({ configFile: resolveConfigFile() });
|
|
684
|
+
return { device_id: identity.computerId, device_name: identity.displayName };
|
|
685
|
+
} catch {
|
|
686
|
+
return {};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function buildAuthorizeUrl(metadata, {
|
|
678
691
|
redirectUri,
|
|
679
692
|
challenge,
|
|
680
693
|
state,
|
|
@@ -689,6 +702,9 @@ function buildAuthorizeUrl(metadata, {
|
|
|
689
702
|
url.searchParams.set('code_challenge', challenge);
|
|
690
703
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
691
704
|
url.searchParams.set('state', state);
|
|
705
|
+
for (const [key, value] of Object.entries(cliDeviceParams())) {
|
|
706
|
+
url.searchParams.set(key, value);
|
|
707
|
+
}
|
|
692
708
|
return url.toString();
|
|
693
709
|
}
|
|
694
710
|
|
|
@@ -2473,6 +2489,7 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
|
2473
2489
|
refresh_token: profile.oauth_refresh_token,
|
|
2474
2490
|
client_id: profile.oauth_client_id,
|
|
2475
2491
|
resource: metadata.resource,
|
|
2492
|
+
...cliDeviceParams(),
|
|
2476
2493
|
}),
|
|
2477
2494
|
},
|
|
2478
2495
|
fetchImpl,
|