@notis_ai/cli 0.2.0-beta.197.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.
@@ -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 }) => <DocumentEditor recordKey={recordKey} className={className}
136
- variant={header === false ? 'body' : 'full'} showProperties={properties !== 'hidden'} />,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.197.1",
3
+ "version": "0.2.0-beta.198.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- New reports are independent Space views. Existing database report payloads render inside their database's main view. Use the installed HTML Space's saving Skill for self-contained HTML. See the canonical product guidance for build, revision, native file and view-link rules.
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
 
@@ -133,9 +133,9 @@ export const authCommandSpecs = [
133
133
  },
134
134
  {
135
135
  command_path: ['logout'],
136
- summary: 'Revoke and remove the OAuth credential for one CLI profile.',
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. Other profiles keep their credentials unless you pass --all-profiles.',
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: [
@@ -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
+ }
@@ -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
- function buildAuthorizeUrl(metadata, {
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,
@@ -1,16 +1,14 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import {
3
2
  existsSync,
4
3
  mkdirSync,
5
4
  readFileSync,
6
5
  renameSync,
7
- rmSync,
8
- statSync,
9
6
  writeFileSync,
10
7
  } from 'node:fs';
11
8
  import { homedir } from 'node:os';
12
9
  import { dirname, join, parse, resolve } from 'node:path';
13
10
  import { CliError, EXIT_CODES } from './errors.js';
11
+ import { withConfigFileWriteLock } from './config-lock.js';
14
12
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
15
13
  import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
16
14
 
@@ -34,14 +32,6 @@ const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
34
32
  const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
35
33
  const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
36
34
  const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
37
- // Cross-process write lock over ~/.notis/config.json. Independent `notis`
38
- // processes (a `login` in one terminal and a `tools exec` in another) all
39
- // rewrite this file, so the lock directory name and these three timings are the
40
- // whole coordination protocol. Guarded by the concurrency test in
41
- // packages/cli/test/runtime-auth.test.js.
42
- const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
43
- const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
44
- const CONFIG_WRITE_LOCK_POLL_MS = 10;
45
35
 
46
36
  function clone(value) {
47
37
  return JSON.parse(JSON.stringify(value));
@@ -372,103 +362,11 @@ function writeRawConfig(configFile, config) {
372
362
  renameSync(temporaryFile, configFile);
373
363
  }
374
364
 
375
- // The lock records who holds it and when they took it. Reclaiming a stale lock
376
- // by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
377
- // one it is about to steal from a live writer, and the victim's own release
378
- // would then delete the thief's lock and admit a third writer. Both sides are
379
- // on the same host, so the stored timestamp and Date.now() share a clock.
380
- function readLockOwner(lockDirectory) {
381
- try {
382
- return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
383
- } catch {
384
- return null;
385
- }
386
- }
387
-
388
- function lockDirectoryMtime(lockDirectory) {
389
- try {
390
- return statSync(lockDirectory).mtimeMs;
391
- } catch {
392
- return null;
393
- }
394
- }
395
-
396
- function publishConfigWriteLock(lockDirectory, ownerId) {
397
- const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
398
- try {
399
- mkdirSync(candidateDirectory);
400
- writeFileSync(
401
- join(candidateDirectory, 'owner'),
402
- JSON.stringify({ id: ownerId, at: Date.now() }),
403
- { mode: 0o600 },
404
- );
405
- try {
406
- renameSync(candidateDirectory, lockDirectory);
407
- return true;
408
- } catch (error) {
409
- if (!existsSync(lockDirectory)) throw error;
410
- return false;
411
- }
412
- } finally {
413
- rmSync(candidateDirectory, { recursive: true, force: true });
414
- }
415
- }
416
-
365
+ // Independent `notis` processes and Desktop all rewrite this file. The lock
366
+ // protocol lives in config-lock.js, shared with computer.json and mirrored by
367
+ // Desktop.
417
368
  function withConfigWriteLock(callback) {
418
- const configFile = resolveConfigFile();
419
- const lockDirectory = `${configFile}.write-lock`;
420
- const ownerId = `${process.pid}.${randomUUID()}`;
421
- const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
422
- mkdirSync(dirname(configFile), { recursive: true });
423
- for (;;) {
424
- if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
425
- break;
426
- }
427
-
428
- const owner = readLockOwner(lockDirectory);
429
- const observedMtime = lockDirectoryMtime(lockDirectory);
430
- const ownerIsStale =
431
- owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
432
- const ownerlessIsStale =
433
- !owner
434
- && observedMtime !== null
435
- && Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
436
- if (ownerIsStale || ownerlessIsStale) {
437
- try {
438
- const currentOwner = readLockOwner(lockDirectory);
439
- const ownerUnchanged = owner?.id
440
- ? currentOwner?.id === owner.id
441
- : !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
442
- if (ownerUnchanged) {
443
- rmSync(lockDirectory, { recursive: true, force: true });
444
- continue;
445
- }
446
- } catch {
447
- // Losing the reclaim race is normal; fall through and keep waiting.
448
- }
449
- }
450
- if (Date.now() >= deadline) {
451
- throw new Error(`Timed out waiting to update ${configFile}`);
452
- }
453
- Atomics.wait(
454
- new Int32Array(new SharedArrayBuffer(4)),
455
- 0,
456
- 0,
457
- CONFIG_WRITE_LOCK_POLL_MS,
458
- );
459
- }
460
- try {
461
- return callback(configFile);
462
- } finally {
463
- try {
464
- // Never release a lock that was reclaimed from us while we worked.
465
- if (readLockOwner(lockDirectory)?.id === ownerId) {
466
- rmSync(lockDirectory, { recursive: true, force: true });
467
- }
468
- } catch {
469
- // The lock may already have been removed after an interrupted write.
470
- }
471
- }
369
+ return withConfigFileWriteLock(resolveConfigFile(), callback);
472
370
  }
473
371
 
474
372
  export function saveConfig(config) {