@andorra-labs/code 0.1.4 → 0.1.5

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/dist/src/cli.js CHANGED
@@ -299,7 +299,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
299
299
  if (argv[0] === 'update') {
300
300
  if (argv.length > 2 || (argv.length === 2 && argv[1] !== '--break-lock'))
301
301
  throw new Error('Usage: andorra update [--break-lock]');
302
- process.stdout.write('Updating Andorra from GitHub main...\n');
302
+ process.stdout.write('Checking npm for the latest Andorra release...\n');
303
303
  const notify = (message) => process.stdout.write(safeTerminal(message) + '\n');
304
304
  process.stdout.write(safeTerminal(await updateInstallation({ breakLock: argv[1] === '--break-lock', notify })) + '\n');
305
305
  return 0;
@@ -1,19 +1,25 @@
1
- export type UpdateRunner = (program: string, args: string[], cwd: string) => Promise<string>;
2
- /** Outcome of deciding whether the installed dependency tree can be replaced in place. */
1
+ /** The package `andorra update` installs. */
2
+ export declare const PACKAGE_NAME = "@andorra-labs/code";
3
+ export interface RunOptions {
4
+ /** Show the command's output while it runs instead of only capturing it; npm install uses this. */
5
+ stream?: boolean;
6
+ }
7
+ export type UpdateRunner = (program: string, args: string[], cwd: string, options?: RunOptions) => Promise<string>;
8
+ /** Outcome of deciding whether the installed package can be replaced in place. */
3
9
  export interface LockProbe {
4
10
  state: 'clear' | 'locked' | 'unknown';
5
- /** The file or directory npm could not replace, when the probe identified one. */
11
+ /** The file or directory that could not be replaced, when the probe identified one. */
6
12
  path?: string;
7
13
  detail: string;
8
14
  }
9
- export type ReinstallProbe = (checkout: string) => Promise<LockProbe>;
15
+ export type ReinstallProbe = (packageRoot: string) => Promise<LockProbe>;
10
16
  export interface UpdateOptions {
11
17
  /** Injectable only for embedding/tests; the CLI uses this module's installed location. */
12
18
  moduleUrl?: string | URL;
13
19
  run?: UpdateRunner;
14
- /** Injectable only for tests; the CLI probes the real tree by renaming within it. */
20
+ /** Injectable only for tests; the CLI probes the real package by renaming within it. */
15
21
  probe?: ReinstallProbe;
16
- /** Injectable only for tests; the CLI waits between bounded reinstall attempts. */
22
+ /** Injectable only for tests; the CLI waits between bounded install attempts. */
17
23
  sleep?: (ms: number) => Promise<void>;
18
24
  /** Replace an existing updater lock even when its recorded process still appears to run. */
19
25
  breakLock?: boolean;
@@ -21,16 +27,11 @@ export interface UpdateOptions {
21
27
  isRunning?: (pid: number) => boolean;
22
28
  /** Receives progress notes the caller should show, such as a reclaimed stale lock. */
23
29
  notify?: (message: string) => void;
30
+ /** Injectable only for tests; the CLI keeps its lock in the Andorra home directory. */
31
+ lockDirectory?: string;
32
+ /** Injectable only for tests; the CLI runs the npm that ships beside the Node.js running it. */
33
+ npm?: string;
24
34
  }
25
- /**
26
- * True when the installed tree is provably complete for the lockfile the checkout carries: both the
27
- * root lockfile and npm's own hidden lockfile parse, every package npm recorded still matches the
28
- * version and hash the root lockfile pins, and every direct dependency the package declares is
29
- * present. The hidden lockfile is written only by a completed install, and it omits the root entry,
30
- * so a legitimate tree has one fewer entry than the lockfile — it is never compared by count, and it
31
- * is never trusted on its own, because a failed install can leave a stale one behind.
32
- */
33
- export declare function dependenciesSatisfied(checkout: string): Promise<boolean>;
34
35
  export interface UpdateLockOptions {
35
36
  breakLock?: boolean;
36
37
  isRunning?: (pid: number) => boolean;
@@ -44,5 +45,14 @@ export interface UpdateLockOptions {
44
45
  * a process id can be reused.
45
46
  */
46
47
  export declare function acquireUpdateLock(lockPath: string, options?: UpdateLockOptions): Promise<string>;
47
- /** Update the linked installation checkout, never the caller's working directory. */
48
+ /**
49
+ * Replace the global npm installation this command runs from with the latest npm release.
50
+ *
51
+ * Only a global npm installation is updated. A source checkout — one that `npm link` exposes as the
52
+ * global command, or one started directly — is refused without changing anything, and so is any
53
+ * other copy (a project's own dependency, the desktop client's engine): each is updated the way it
54
+ * was installed. The release is resolved from the `latest` dist-tag once and installed by its exact
55
+ * version, so a tag that moves mid-update cannot install something other than what was reported,
56
+ * and a tag that points below the installed version is never followed down.
57
+ */
48
58
  export declare function updateInstallation(options?: UpdateOptions): Promise<string>;
@@ -1,15 +1,23 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { link, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
4
- import { delimiter, dirname, join, resolve } from 'node:path';
3
+ import { link, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { promisify } from 'node:util';
7
+ import semver from 'semver';
8
+ import { configDirectory } from './config.js';
7
9
  const executeFile = promisify(execFile);
10
+ /** The package `andorra update` installs. */
11
+ export const PACKAGE_NAME = '@andorra-labs/code';
12
+ /** The npm dist-tag the update follows. */
13
+ const RELEASE_TAG = 'latest';
8
14
  /** Bounded install attempts; only a failure that still looks transient is retried. */
9
15
  const INSTALL_ATTEMPTS = 3;
10
16
  const INSTALL_RETRY_DELAY_MS = 1_500;
11
17
  /** npm reports a Windows sharing violation as EPERM, EBUSY or errno -4048. */
12
18
  const LOCK_FAILURE = /EPERM|EBUSY|resource busy or locked|-4048/;
19
+ /** How much of npm's error output a failure keeps for its message. */
20
+ const OUTPUT_TAIL = 6_000;
13
21
  /** Bounded walk: a Windows sharing violation matters on a native module or a launcher. */
14
22
  const LOCK_PROBE_SCAN_LIMIT = 10_000;
15
23
  /**
@@ -17,36 +25,42 @@ const LOCK_PROBE_SCAN_LIMIT = 10_000;
17
25
  * build/Release/*.node, so a shallow limit would miss exactly the file this guard exists for.
18
26
  */
19
27
  const LOCK_PROBE_DEPTH = 6;
20
- const runCommand = async (program, args, cwd) => {
21
- try {
22
- const pathKey = process.platform === 'win32' ? Object.keys(process.env).find(key => key.toLowerCase() === 'path') ?? 'PATH' : 'PATH';
23
- const env = { ...process.env, [pathKey]: dirname(process.execPath) + delimiter + (process.env[pathKey] ?? '') };
24
- if (program === process.execPath) {
25
- // npm lifecycle scripts can outlive npm if a timeout kills only its parent.
26
- // Stream their output and retain the updater lock until npm actually closes.
27
- return await new Promise((resolve, reject) => {
28
- const child = spawn(program, args, { cwd, env, windowsHide: true, stdio: 'inherit' });
29
- child.once('error', reject);
30
- child.once('close', (code, signal) => {
31
- if (code === 0 && !signal)
32
- resolve('');
33
- else
34
- reject(new Error(`npm exited with ${signal ?? `code ${code}`}; see the command output above.`));
35
- });
28
+ const runCommand = async (program, args, cwd, options = {}) => {
29
+ const pathKey = process.platform === 'win32' ? Object.keys(process.env).find(key => key.toLowerCase() === 'path') ?? 'PATH' : 'PATH';
30
+ const env = { ...process.env, [pathKey]: dirname(process.execPath) + delimiter + (process.env[pathKey] ?? '') };
31
+ const command = `${program} ${args.join(' ')}`;
32
+ if (options.stream) {
33
+ // npm lifecycle scripts can outlive npm if a timeout kills only its parent, so there is no
34
+ // timeout here: the updater lock is held until npm actually closes. Its error output is shown
35
+ // as it arrives and kept, so a failure can still name the file Windows refused to replace.
36
+ return new Promise((resolvePromise, reject) => {
37
+ const child = spawn(program, args, { cwd, env, windowsHide: true, stdio: ['inherit', 'inherit', 'pipe'] });
38
+ let errors = '';
39
+ child.stderr?.on('data', (chunk) => {
40
+ process.stderr.write(chunk);
41
+ errors = (errors + chunk.toString('utf8')).slice(-OUTPUT_TAIL);
36
42
  });
37
- }
43
+ child.once('error', error => reject(new Error(`${command} could not start: ${error.message}`)));
44
+ child.once('close', (code, signal) => {
45
+ if (code === 0 && !signal) {
46
+ resolvePromise('');
47
+ return;
48
+ }
49
+ const detail = errors.trim();
50
+ reject(new Error(`${command} exited with ${signal ?? `code ${code}`}${detail ? `:\n${detail}` : '; see the npm output above.'}`));
51
+ });
52
+ });
53
+ }
54
+ try {
38
55
  const result = await executeFile(program, args, { cwd, env, windowsHide: true, timeout: 600_000, maxBuffer: 8_388_608 });
39
56
  return result.stdout;
40
57
  }
41
58
  catch (error) {
42
59
  const failure = error;
43
- const detail = (failure.stderr || failure.stdout || failure.message).trim().slice(-6_000);
44
- throw new Error(`${program} ${args.join(' ')} failed${detail ? `:\n${detail}` : ''}`);
60
+ const detail = (failure.stderr || failure.stdout || failure.message).trim().slice(-OUTPUT_TAIL);
61
+ throw new Error(`${command} failed${detail ? `:\n${detail}` : ''}`);
45
62
  }
46
63
  };
47
- function canonicalOrigin(origin) {
48
- return /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)Andorra-Labs\/Andorra-Code(?:\.git)?\/?$/i.test(origin);
49
- }
50
64
  async function npmCli() {
51
65
  const nodeDirectory = dirname(await realpath(process.execPath));
52
66
  const candidates = [
@@ -68,119 +82,52 @@ function containsPath(text, path) {
68
82
  const target = resolve(path);
69
83
  return normalizePathText(text).includes(normalizePathText(target));
70
84
  }
71
- /**
72
- * The dependency fingerprint: everything an install materializes from. When the fetched revision
73
- * leaves both files byte-identical, the installed tree already satisfies this revision.
74
- */
75
- async function dependencyFingerprint(checkout) {
76
- const parts = [];
77
- for (const name of ['package.json', 'package-lock.json']) {
78
- const value = normalizePathText(await readFile(join(checkout, name), 'utf8').then(text => text, () => ''));
79
- parts.push(`${name}:${value.length}:${value}`);
80
- }
81
- return parts.join('\n');
85
+ /** Paths compare case-insensitively on Windows, where the filesystem does. */
86
+ function samePath(a, b) {
87
+ const left = resolve(a);
88
+ const right = resolve(b);
89
+ return process.platform === 'win32' ? normalizePathText(left) === normalizePathText(right) : left === right;
82
90
  }
83
- /** npm's hidden lockfile is written by npm itself, only after an install completed. */
84
- async function readLockfile(path) {
91
+ const exists = (path) => stat(path).then(() => true, () => false);
92
+ const isDirectory = (path) => stat(path).then(info => info.isDirectory(), () => false);
93
+ /** The name and version a package directory declares, or undefined when it holds no readable manifest. */
94
+ async function readManifest(directory) {
85
95
  try {
86
- const value = JSON.parse(await readFile(path, 'utf8'));
87
- if (!value || typeof value !== 'object')
88
- return undefined;
89
- const { lockfileVersion, packages } = value;
90
- if (typeof lockfileVersion !== 'number' || lockfileVersion < 2)
91
- return undefined;
92
- if (!packages || typeof packages !== 'object')
93
- return undefined;
94
- return packages;
96
+ const value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
97
+ return value && typeof value === 'object' ? value : undefined;
95
98
  }
96
99
  catch {
97
100
  return undefined;
98
101
  }
99
102
  }
100
- /**
101
- * True when the installed tree is provably complete for the lockfile the checkout carries: both the
102
- * root lockfile and npm's own hidden lockfile parse, every package npm recorded still matches the
103
- * version and hash the root lockfile pins, and every direct dependency the package declares is
104
- * present. The hidden lockfile is written only by a completed install, and it omits the root entry,
105
- * so a legitimate tree has one fewer entry than the lockfile — it is never compared by count, and it
106
- * is never trusted on its own, because a failed install can leave a stale one behind.
107
- */
108
- export async function dependenciesSatisfied(checkout) {
109
- const modules = join(checkout, 'node_modules');
110
- const topLevel = await readLockfile(join(checkout, 'package-lock.json'));
111
- const installed = await readLockfile(join(modules, '.package-lock.json'));
112
- if (!topLevel || !installed)
113
- return false;
114
- let packages = 0;
115
- for (const [key, raw] of Object.entries(installed)) {
116
- if (key === '')
117
- continue;
118
- if (!key.startsWith('node_modules/'))
119
- return false;
120
- if (!raw || typeof raw !== 'object')
121
- return false;
122
- const entry = raw;
123
- if (typeof entry.link === 'boolean') {
124
- const local = typeof entry.resolved === 'string' ? resolve(modules, entry.resolved) : undefined;
125
- if (!local || !(await stat(local).then(info => info.isDirectory(), () => false)))
126
- return false;
127
- }
128
- else {
129
- if (typeof entry.version !== 'string')
130
- return false;
131
- const pinned = topLevel[key];
132
- if (!pinned || typeof pinned !== 'object')
133
- return false;
134
- const expected = pinned;
135
- if (typeof expected.version !== 'string' || expected.version !== entry.version)
136
- return false;
137
- if (typeof expected.integrity === 'string' && expected.integrity !== entry.integrity)
138
- return false;
139
- }
140
- packages += 1;
141
- }
142
- if (packages === 0)
143
- return false;
144
- return directDependenciesInstalled(checkout, topLevel, installed);
103
+ /** The installed release's version, when the directory holds this package. */
104
+ async function installedVersion(directory) {
105
+ const manifest = await readManifest(directory);
106
+ return manifest?.name === PACKAGE_NAME && typeof manifest.version === 'string' ? manifest.version : undefined;
145
107
  }
146
- /**
147
- * Every dependency the package declares must be in the lockfile the hidden lockfile agreed with.
148
- * A package that declares none has nothing to check, which is not a reason to reinstall it.
149
- */
150
- async function directDependenciesInstalled(checkout, topLevel, installed) {
151
- let manifest;
152
- try {
153
- manifest = JSON.parse(await readFile(join(checkout, 'package.json'), 'utf8'));
154
- }
155
- catch {
156
- return false;
157
- }
158
- const fields = ['dependencies', 'devDependencies', 'optionalDependencies'];
159
- for (const field of fields) {
160
- const declared = manifest[field];
161
- if (!declared || typeof declared !== 'object')
162
- continue;
163
- for (const [name, spec] of Object.entries(declared)) {
164
- const key = `node_modules/${name}`;
165
- if (!topLevel[key] || !installed[key])
166
- return false;
167
- // An aliased range pins a different package name, so only its presence can be checked.
168
- if (typeof spec !== 'string' || spec.startsWith('npm:'))
169
- continue;
170
- const expected = topLevel[key];
171
- const actual = installed[key];
172
- if (typeof expected.version !== 'string' || expected.version !== actual.version)
173
- return false;
174
- }
175
- }
176
- return true;
108
+ /** npm prints the answer to `root` and `view` on its last line; anything before it is a notice. */
109
+ function lastLine(output) {
110
+ return output.split(/\r?\n/).map(line => line.trim()).filter(Boolean).at(-1) ?? '';
111
+ }
112
+ function checkoutRefusal(packageRoot) {
113
+ return new Error([
114
+ `andorra update installs releases from npm, but this andorra runs from a source checkout at ${packageRoot}, so nothing was changed.`,
115
+ `To switch to the npm release, run: npm install -g ${PACKAGE_NAME}@latest`,
116
+ 'That replaces an andorra command linked with npm link, and leaves the checkout itself as it is.',
117
+ 'To keep using the checkout, update it from the checkout instead: git pull --ff-only, then npm ci and npm run build.',
118
+ ].join('\n'));
119
+ }
120
+ function notGlobalRefusal(packageRoot, entry) {
121
+ return new Error([
122
+ `andorra update replaces the global npm installation of ${PACKAGE_NAME} (${entry}), but this andorra runs from ${packageRoot}, so nothing was changed.`,
123
+ `Update that copy the way it was installed, or install the CLI globally with: npm install -g ${PACKAGE_NAME}@latest`,
124
+ ].join('\n'));
177
125
  }
178
126
  /**
179
127
  * Walk each installed package directory — the top level and its scope directories — for the files
180
128
  * npm materializes wholesale and Windows can hold open: native modules, which a live process loads
181
- * as a DLL, and launchers, which a running build holds. A scope directory does not consume depth,
182
- * so a scoped package like @scope/pkg/... is reached. Symlinks are skipped so a link into a
183
- * workspace package is never probed twice.
129
+ * as a DLL. A scope directory does not consume depth, so a scoped package like @scope/pkg/... is
130
+ * reached. Symlinks are skipped so a linked package is never probed twice.
184
131
  */
185
132
  async function lockProbeTargets(modules) {
186
133
  const targets = [];
@@ -215,61 +162,87 @@ async function lockProbeTargets(modules) {
215
162
  /** Suffix of the same-directory rename the probe performs and then undoes. */
216
163
  const REPLACEABLE_SUFFIX = '.andorra-replaceable';
217
164
  /**
218
- * Decide whether the installed dependency tree can be replaced in place before an install touches
219
- * it. npm ci deletes the whole tree first, so a single file held open by a live process turns a
220
- * routine update into a broken installation. A same-directory rename is exactly the operation that
221
- * fails with EPERM or EBUSY while a file is in Windows' pending-delete state, and it changes nothing.
165
+ * Decide whether npm can replace the installed package before it tries. npm replaces a global
166
+ * package by moving its directory aside and deleting it afterwards. On Windows the move fails while
167
+ * something inside is open without delete sharing — a process whose working directory is inside the
168
+ * package, or a file already waiting to be deleted. A native module a live process has loaded does
169
+ * not stop the move, only the delete afterwards; sweepRetired deals with that copy. A same-directory
170
+ * rename is exactly the operation that fails, and it changes nothing. Each native module is tried
171
+ * first, so a refusal can name the file.
222
172
  */
223
- async function probeReinstall(checkout) {
224
- const modules = join(checkout, 'node_modules');
225
- if (!(await stat(modules).then(info => info.isDirectory(), () => false))) {
226
- return { state: 'clear', detail: 'nothing is installed yet, so there is nothing to replace' };
227
- }
228
- for (const target of await lockProbeTargets(modules)) {
229
- const probe = `${target}${REPLACEABLE_SUFFIX}`;
230
- try {
231
- await rename(target, probe);
232
- await rename(probe, target);
233
- }
234
- catch (error) {
235
- if (error.code === 'ENOENT')
236
- continue;
237
- const held = await stat(target).then(() => true, () => false);
238
- if (!held)
239
- continue;
240
- return { state: 'locked', path: target, detail: error.message };
173
+ async function probeReplace(packageRoot) {
174
+ const modules = join(packageRoot, 'node_modules');
175
+ if (await isDirectory(modules)) {
176
+ for (const target of await lockProbeTargets(modules)) {
177
+ const probe = `${target}${REPLACEABLE_SUFFIX}`;
178
+ try {
179
+ await rename(target, probe);
180
+ await rename(probe, target);
181
+ }
182
+ catch (error) {
183
+ if (error.code === 'ENOENT')
184
+ continue;
185
+ if (!(await exists(target)))
186
+ continue;
187
+ return { state: 'locked', path: target, detail: error.message };
188
+ }
241
189
  }
242
190
  }
243
191
  try {
244
- const probe = `${modules}${REPLACEABLE_SUFFIX}`;
245
- await rename(modules, probe);
192
+ const probe = `${packageRoot}${REPLACEABLE_SUFFIX}`;
193
+ await rename(packageRoot, probe);
246
194
  try {
247
- await rename(probe, modules);
195
+ await rename(probe, packageRoot);
248
196
  }
249
197
  catch (error) {
250
- throw new Error(`this update moved ${modules} to ${probe} and could not move it back (${error.message}); rename the directory back to node_modules before retrying.`);
198
+ throw new Error(`this update moved ${packageRoot} to ${probe} and could not move it back (${error.message}); rename the directory back before retrying.`);
251
199
  }
252
200
  }
253
201
  catch (error) {
254
- const stillThere = await stat(modules).then(() => true, () => false);
255
- if (stillThere)
256
- return { state: 'locked', path: modules, detail: error.message };
257
- return { state: 'unknown', detail: error.message, path: modules };
202
+ if (await exists(packageRoot))
203
+ return { state: 'locked', path: packageRoot, detail: error.message };
204
+ return { state: 'unknown', detail: error.message, path: packageRoot };
258
205
  }
259
- return { state: 'clear', detail: 'every installed native module and launcher could be replaced' };
206
+ return { state: 'clear', detail: 'the installed package and every native module in it could be replaced' };
260
207
  }
261
- function probeRefusal(checkout, probe) {
262
- const target = probe.path ?? join(checkout, 'node_modules');
263
- const where = normalizePathText(target) === normalizePathText(join(checkout, 'node_modules'))
264
- ? `the installed dependency directory itself (${target})`
265
- : `the installed file ${target}`;
208
+ function probeRefusal(packageRoot, probe) {
209
+ const target = probe.path ?? packageRoot;
210
+ const where = samePath(target, packageRoot) ? `the installed package directory itself (${target})` : `the installed file ${target}`;
266
211
  const guidance = probe.state === 'locked'
267
- ? `A running process holds it open, and Windows keeps it in a pending-delete state until that process exits or unloads the module. CodeGraph holds the codegraph-kernel native module that way. Stop that process — a plain retry cannot release the file — then run andorra update again.`
268
- : `The installed tree could not be inspected or replaced in place. Close any process working in this checkout and check the permissions on ${target}, then run andorra update again.`;
269
- return new Error(`Refusing to reinstall dependencies in ${checkout}: ${where} cannot be replaced.\n${guidance}\nNothing was changed: the existing installation is untouched and the source checkout has not been advanced.`);
212
+ ? 'Something has it open in a way Windows will not let it be moved: most often a terminal or program whose working directory is inside the package, or a file that is still waiting to be deleted while a process holds it. Close that program — a plain retry cannot release it — then run andorra update again.'
213
+ : `The installed package could not be inspected or moved. Close any process using it and check the permissions on ${target}, then run andorra update again.`;
214
+ return new Error(`Refusing to update ${packageRoot}: ${where} cannot be replaced.\n${guidance}\nNothing was changed: the installed Andorra is untouched.`);
215
+ }
216
+ /**
217
+ * npm moves the release it replaces aside, as `.<name>-<random>` beside the package, and deletes it
218
+ * once the new release is in place. On Windows a native module that a running process still has
219
+ * loaded — another Andorra session, CodeGraph — survives that delete, so the copy stays behind and npm
220
+ * only warns. Every update deletes whatever such copies it can, its own and earlier ones, and returns
221
+ * the ones a process still holds. Only directories, never links, and only that exact name pattern.
222
+ */
223
+ async function sweepRetired(entry) {
224
+ const scope = dirname(entry);
225
+ const retired = new RegExp(`^\\.${basename(entry).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-[A-Za-z0-9_-]+$`);
226
+ const held = [];
227
+ for (const item of await readdir(scope, { withFileTypes: true }).catch(() => [])) {
228
+ if (!item.isDirectory() || !retired.test(item.name))
229
+ continue;
230
+ const path = join(scope, item.name);
231
+ try {
232
+ await rm(path, { recursive: true, force: true });
233
+ }
234
+ catch {
235
+ held.push(path);
236
+ }
237
+ }
238
+ return held;
239
+ }
240
+ function heldNote(held) {
241
+ const one = held.length === 1;
242
+ return `npm could not delete ${one ? 'a replaced copy' : `${held.length} replaced copies`} of Andorra because a running process still has a file in ${one ? 'it' : 'them'} loaded: ${held.join(', ')}. The andorra command no longer uses ${one ? 'it' : 'them'}; andorra update deletes ${one ? 'it' : 'them'} once that process has exited.`;
270
243
  }
271
244
  /** Recover the concrete locked path from the failure npm actually reported, when it named one. */
272
- function reportedLockedFile(message, checkout) {
245
+ function reportedLockedFile(message, packageRoot) {
273
246
  const patterns = [
274
247
  /npm error syscall[^\n]*\n\s*npm error (?:path|dest)\s+(.+)/,
275
248
  /npm error path\s+(.+)/,
@@ -285,61 +258,47 @@ function reportedLockedFile(message, checkout) {
285
258
  candidates.push(match[0].trim().replace(/[.,;:)\]]+$/, ''));
286
259
  for (const candidate of candidates) {
287
260
  const normalized = candidate.replace(/[.,;:)\]]+$/, '');
288
- if (normalized.includes('node_modules') && (containsPath(normalized, checkout) || !/^[A-Za-z]:[\\/]/.test(normalized)))
261
+ if (containsPath(normalized, packageRoot) || containsPath(normalized, dirname(packageRoot)))
289
262
  return normalized;
290
263
  }
291
264
  return undefined;
292
265
  }
293
266
  /**
294
- * Describe a failed dependency install from what is actually on disk afterwards. The wording must
295
- * not promise that a plain retry recovers the installation: a locked file stays locked until the
296
- * process holding it exits, and any part of the tree npm already removed is still missing.
267
+ * Describe a failed install from what is actually on disk afterwards. npm moves the previous
268
+ * release back when an install fails, so the old version usually still runs — but the message says
269
+ * so only after reading it, and never promises that a plain retry releases a locked file.
297
270
  */
298
- function installFailureMessage(checkout, outcome, cause) {
299
- const front = `Source checkout was updated, but reconciling dependencies with npm install failed in ${checkout}; the update is incomplete.`;
271
+ function installFailureMessage(entry, version, outcome, cause) {
272
+ const front = `npm install --global ${PACKAGE_NAME}@${version} failed, so the update did not complete.`;
300
273
  const blocked = outcome.locked
301
- ? `\nThe locked path npm reported is ${outcome.locked}. A running process holds it open, and Windows keeps it in a pending-delete state until that process exits or unloads the module — CodeGraph holds the codegraph-kernel native module that way. Stop that process; a plain retry cannot release the file.`
274
+ ? `\nThe locked path npm reported is ${outcome.locked}. Windows refused to replace it while a process holds it open. Stop that process; a plain retry cannot release the file.`
302
275
  : '';
303
- const damage = outcome.destroyed
304
- ? `\nThe dependency directory is missing, so the andorra CLI will not run and dist/ may be missing too. Free the lock first, then run andorra update again to install dependencies and rebuild the CLI.`
305
- : `\nThe installed dependency tree survived this attempt, so the existing andorra CLI still runs, but this checkout now needs a dependency installation that did not complete. Fix the reported problem and run andorra update again.`;
306
- return `${front}${blocked}${damage}\n${cause.message}`;
276
+ const state = outcome.installed
277
+ ? `\nThe installed Andorra at ${entry} is still version ${outcome.installed}, so the andorra command still runs. Fix the reported problem and run andorra update again.`
278
+ : `\nThe installed Andorra at ${entry} is missing or unreadable, so the andorra command may not run. Fix the reported problem, then reinstall with npm install -g ${PACKAGE_NAME}@latest.`;
279
+ return `${front}${blocked}${state}\n${cause.message}`;
307
280
  }
308
281
  /**
309
- * Reconcile the installed dependencies with npm install, never npm ci.
310
- *
311
- * npm ci deletes node_modules in its first phase and installs afterwards, so one file it cannot
312
- * delete — on Windows a native module a live process has open — destroys the installation: the tree
313
- * is already gone when npm reports the error, and the CLI no longer runs. npm install reconciles the
314
- * tree in place instead, leaving a dependency that already satisfies package-lock.json untouched, so
315
- * a locked native module inside it is not fatal. Do not "simplify" this back to npm ci: the
316
- * destructive first phase is precisely what broke a working installation.
317
- *
318
- * The probe still runs first, so a tree that genuinely cannot be replaced is refused before npm
319
- * touches anything rather than failing half way through.
282
+ * Install one exact release globally. A failure that left the previous release in place and still
283
+ * looks like a transient Windows sharing violation is retried a bounded number of times, after
284
+ * checking that the package can be moved again; anything else is reported at once.
320
285
  */
321
- async function installDependencies(run, sleep, probe, npm, checkout) {
322
- const before = await probe(checkout);
323
- if (before.state !== 'clear')
324
- throw probeRefusal(checkout, before);
286
+ async function installRelease(run, sleep, probe, npm, cwd, packageRoot, entry, version, previous) {
325
287
  for (let attempt = 1; attempt <= INSTALL_ATTEMPTS; attempt += 1) {
326
288
  try {
327
- await run(process.execPath, [npm, 'install', '--include=dev'], checkout);
289
+ await run(process.execPath, [npm, 'install', '--global', `${PACKAGE_NAME}@${version}`, '--prefer-online', '--no-audit', '--no-fund'], cwd, { stream: true });
328
290
  return { ok: true };
329
291
  }
330
292
  catch (error) {
331
293
  const cause = error;
332
- const after = await probe(checkout);
333
- const locked = after.state === 'clear'
334
- ? undefined
335
- : after.path ?? `the installed dependency directory ${join(checkout, 'node_modules')} itself`;
336
- const named = reportedLockedFile(cause.message, checkout);
337
- const destroyed = !(await stat(join(checkout, 'node_modules')).then(info => info.isDirectory(), () => false));
338
- // Retry only a failure that left a complete tree behind: it is transient by definition, and a
339
- // retry can no longer compound damage now that the install reconciles instead of deleting.
340
- if (attempt === INSTALL_ATTEMPTS || destroyed || locked || !LOCK_FAILURE.test(cause.message)) {
341
- return { ok: false, cause, locked: named ?? locked, destroyed };
294
+ const installed = await installedVersion(entry);
295
+ const named = reportedLockedFile(cause.message, packageRoot);
296
+ if (attempt === INSTALL_ATTEMPTS || installed !== previous || !LOCK_FAILURE.test(cause.message)) {
297
+ return { ok: false, cause, locked: named, installed };
342
298
  }
299
+ const again = await probe(packageRoot);
300
+ if (again.state !== 'clear')
301
+ return { ok: false, cause, locked: named ?? again.path, installed };
343
302
  await sleep(INSTALL_RETRY_DELAY_MS);
344
303
  }
345
304
  }
@@ -520,93 +479,91 @@ async function releaseUpdateLock(lockPath, record, sleep) {
520
479
  await unlink(guard).catch(() => { });
521
480
  }
522
481
  }
523
- /** Update the linked installation checkout, never the caller's working directory. */
482
+ /**
483
+ * Replace the global npm installation this command runs from with the latest npm release.
484
+ *
485
+ * Only a global npm installation is updated. A source checkout — one that `npm link` exposes as the
486
+ * global command, or one started directly — is refused without changing anything, and so is any
487
+ * other copy (a project's own dependency, the desktop client's engine): each is updated the way it
488
+ * was installed. The release is resolved from the `latest` dist-tag once and installed by its exact
489
+ * version, so a tag that moves mid-update cannot install something other than what was reported,
490
+ * and a tag that points below the installed version is never followed down.
491
+ */
524
492
  export async function updateInstallation(options = {}) {
525
493
  const run = options.run ?? runCommand;
526
494
  const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
527
- const probe = options.probe ?? probeReinstall;
495
+ const probe = options.probe ?? probeReplace;
528
496
  const modulePath = await realpath(fileURLToPath(options.moduleUrl ?? import.meta.url));
529
- const checkout = await realpath(resolve(dirname(modulePath), '..', '..'));
530
- let packageName;
497
+ const packageRoot = await realpath(resolve(dirname(modulePath), '..', '..'));
498
+ const manifest = await readManifest(packageRoot);
499
+ if (!manifest)
500
+ throw new Error(`This andorra does not run from an installed ${PACKAGE_NAME} package (no readable package.json in ${packageRoot}), so andorra update cannot update it.`);
501
+ if (manifest.name !== PACKAGE_NAME)
502
+ throw new Error(`Refusing to update an unrelated package: expected ${PACKAGE_NAME} in ${packageRoot}.`);
503
+ const current = typeof manifest.version === 'string' ? semver.valid(manifest.version) : null;
504
+ if (!current)
505
+ throw new Error(`The installed ${PACKAGE_NAME} at ${packageRoot} declares no valid version, so andorra update cannot compare it with npm's release. Reinstall it with npm install -g ${PACKAGE_NAME}@latest.`);
506
+ // A checkout is refused before npm is even located: nothing about it is this updater's to change.
507
+ if (await exists(join(packageRoot, '.git')))
508
+ throw checkoutRefusal(packageRoot);
509
+ const npm = options.npm ?? await npmCli();
510
+ const home = options.lockDirectory ?? configDirectory();
511
+ await mkdir(home, { recursive: true });
512
+ // npm runs outside the package it replaces: a working directory inside it would keep Windows from
513
+ // moving it, and a project's .npmrc in the caller's directory has no say over a global install.
514
+ const cwd = home;
515
+ let globalRoot;
531
516
  try {
532
- packageName = JSON.parse(await readFile(join(checkout, 'package.json'), 'utf8')).name;
533
- }
534
- catch {
535
- throw new Error('This installation is not an Andorra source checkout. Link a clone of Andorra-Labs/Andorra-Code to use andorra update.');
536
- }
537
- if (packageName !== '@andorra-labs/code')
538
- throw new Error('Refusing to update an unrelated package: expected @andorra-labs/code.');
539
- let gitRoot;
540
- try {
541
- gitRoot = await realpath((await run('git', ['rev-parse', '--show-toplevel'], checkout)).trim());
542
- }
543
- catch {
544
- throw new Error('This installation is not a Git source checkout. Link a clone of Andorra-Labs/Andorra-Code to use andorra update.');
517
+ globalRoot = lastLine(await run(process.execPath, [npm, 'root', '--global'], cwd));
545
518
  }
546
- if (gitRoot !== checkout)
547
- throw new Error('Refusing to update: the installation package is not at its Git repository root.');
548
- const lockPath = resolve(checkout, (await run('git', ['rev-parse', '--git-path', 'andorra-update.lock'], checkout)).trim());
519
+ catch (error) {
520
+ throw new Error(`Could not ask npm where global packages are installed, so nothing was changed.\n${error.message}`);
521
+ }
522
+ if (!globalRoot)
523
+ throw new Error('npm did not report where global packages are installed, so nothing was changed.');
524
+ const entry = join(globalRoot, ...PACKAGE_NAME.split('/'));
525
+ const linked = await lstat(entry).then(info => info.isSymbolicLink(), () => false);
526
+ const resolvedEntry = await realpath(entry).catch(() => undefined);
527
+ const isEntry = resolvedEntry !== undefined && samePath(resolvedEntry, packageRoot);
528
+ if (isEntry && linked)
529
+ throw checkoutRefusal(packageRoot);
530
+ if (!isEntry)
531
+ throw notGlobalRefusal(packageRoot, entry);
532
+ const lockPath = join(home, 'update.lock');
549
533
  const lock = await acquireUpdateLock(lockPath, { breakLock: options.breakLock, isRunning: options.isRunning, notify: options.notify, sleep });
550
534
  try {
551
- const branch = (await run('git', ['branch', '--show-current'], checkout)).trim();
552
- if (branch !== 'main')
553
- throw new Error(`Refusing to update branch ${branch || '(detached HEAD)'}. Switch the installation checkout to main yourself, then retry: ${checkout}`);
554
- if ((await run('git', ['status', '--porcelain', '--untracked-files=all'], checkout)).trim()) {
555
- throw new Error(`Refusing to update a dirty installation checkout. Commit, stash, or move your changes first: ${checkout}`);
556
- }
557
- const origin = (await run('git', ['remote', 'get-url', 'origin'], checkout)).trim();
558
- if (!canonicalOrigin(origin))
559
- throw new Error('Refusing to update: origin must be the canonical GitHub HTTPS or SSH URL for Andorra-Labs/Andorra-Code.');
560
- // Resolve prerequisites before advancing the checkout, avoiding a preventable partial update.
561
- const npm = await npmCli();
562
- // Record the dependency fingerprint before the merge: if the fetched revision leaves it
563
- // identical, the installed tree already satisfies it and no install is needed.
564
- const dependenciesBefore = await dependencyFingerprint(checkout);
565
- // Before the checkout is advanced, refuse an installation whose dependency tree cannot be
566
- // replaced at all. Nothing has changed yet at this point, so the refusal costs the user nothing.
567
- const reinstall = await probe(checkout);
568
- if (reinstall.state !== 'clear')
569
- throw probeRefusal(checkout, reinstall);
570
- await run('git', ['fetch', 'origin', 'main'], checkout);
571
- const fetched = (await run('git', ['rev-parse', 'FETCH_HEAD'], checkout)).trim();
572
- if (!/^[a-f0-9]{40,64}$/i.test(fetched))
573
- throw new Error('Git did not return a valid fetched commit; update stopped.');
574
- // A concurrent user edit during fetch must not be carried into the installation update.
575
- if ((await run('git', ['branch', '--show-current'], checkout)).trim() !== 'main'
576
- || (await run('git', ['status', '--porcelain', '--untracked-files=all'], checkout)).trim()) {
577
- throw new Error('The installation branch or files changed during fetch; update stopped without merging.');
578
- }
535
+ let reported;
579
536
  try {
580
- await run('git', ['merge-base', '--is-ancestor', 'HEAD', fetched], checkout);
581
- await run('git', ['merge', '--ff-only', fetched], checkout);
537
+ reported = lastLine(await run(process.execPath, [npm, 'view', `${PACKAGE_NAME}@${RELEASE_TAG}`, 'version', '--prefer-online'], cwd));
582
538
  }
583
539
  catch (error) {
584
- throw new Error(`Cannot fast-forward the installation checkout. Resolve any local divergence on main yourself; no reset or branch switch was performed.\n${error.message}`);
540
+ throw new Error(`Could not look up the latest ${PACKAGE_NAME} release on npm, so nothing was changed. Check the network connection and npm registry settings, then run andorra update again.\n${error.message}`);
585
541
  }
586
- if ((await run('git', ['rev-parse', 'HEAD'], checkout)).trim() !== fetched)
587
- throw new Error('Installation HEAD changed during update; dependencies were not installed. Inspect the checkout before retrying.');
588
- const dependenciesChanged = dependenciesBefore !== await dependencyFingerprint(checkout);
589
- // When package.json and package-lock.json are byte-identical across the update, the installed
590
- // tree already satisfies the fetched revision, so no install is needed at all. This is the case
591
- // that broke: an ordinary source-only update reinstalled everything for no reason.
592
- const reused = !dependenciesChanged && await dependenciesSatisfied(checkout);
593
- if (!reused) {
594
- // The pre-fetch probe proved the tree was replaceable then; re-check it now, because the
595
- // fetch and merge are exactly the window in which a process can take a new lock on it.
596
- const outcome = await installDependencies(run, sleep, probe, npm, checkout);
597
- if (!outcome.ok) {
598
- throw new Error(installFailureMessage(checkout, { locked: outcome.locked, destroyed: outcome.destroyed }, outcome.cause));
599
- }
542
+ const latest = semver.valid(reported);
543
+ if (!latest)
544
+ throw new Error(`npm reported "${reported}" as the latest ${PACKAGE_NAME} release, which is not a version, so nothing was changed.`);
545
+ // Copies an earlier update could not delete are swept whenever the installation ends up current,
546
+ // so they go on the first run after the process holding them has exited.
547
+ const withSweep = async (summary) => {
548
+ const held = await sweepRetired(entry);
549
+ return held.length ? `${summary}\n${heldNote(held)}` : summary;
550
+ };
551
+ if (semver.eq(latest, current))
552
+ return withSweep(`Andorra ${current} is already the latest release on npm; nothing was installed.`);
553
+ if (semver.lt(latest, current)) {
554
+ return `The latest ${PACKAGE_NAME} release on npm is ${latest}, older than the installed ${current}. andorra update never downgrades, so nothing was changed.`;
600
555
  }
601
- try {
602
- await run(process.execPath, [npm, 'run', 'build'], checkout);
603
- }
604
- catch (error) {
605
- const cause = error;
606
- throw new Error(`Source checkout was updated but the CLI build failed. The update is incomplete; fix the reported problem and run andorra update again.\n${cause.message}`);
556
+ const replaceable = await probe(packageRoot);
557
+ if (replaceable.state !== 'clear')
558
+ throw probeRefusal(packageRoot, replaceable);
559
+ const outcome = await installRelease(run, sleep, probe, npm, cwd, packageRoot, entry, latest, current);
560
+ if (!outcome.ok)
561
+ throw new Error(installFailureMessage(entry, latest, { locked: outcome.locked, installed: outcome.installed }, outcome.cause));
562
+ const installed = await installedVersion(entry);
563
+ if (installed !== latest) {
564
+ throw new Error(`npm reported success, but ${entry} now holds ${installed ? `version ${installed}` : 'no readable release'} instead of ${latest}. Check it with andorra --version, then run andorra update again.`);
607
565
  }
608
- const revision = (await run('git', ['rev-parse', '--short=12', 'HEAD'], checkout)).trim();
609
- return `Updated Andorra at ${checkout}\nRevision: ${revision}\nDependencies ${reused ? 'already satisfied this revision, so nothing was installed' : 'reconciled with npm install'} and CLI build completed.`;
566
+ return withSweep(`Updated Andorra from ${current} to ${latest}.\nInstalled ${PACKAGE_NAME}@${latest} from npm into ${entry}.`);
610
567
  }
611
568
  finally {
612
569
  await releaseUpdateLock(lockPath, lock, sleep);
@@ -2,33 +2,27 @@
2
2
 
3
3
  If you use another AI coding agent (Codex, Claude Code, and so on), paste the block below into it. The agent can then install Andorra Code on your machine, configure a provider, and verify the install without further decisions from you.
4
4
 
5
- Before pasting, fill in the two bracketed values: which provider to configure, and where to clone the repository. Everything else is deterministic.
5
+ Before pasting, fill in the bracketed value: which provider to configure. Everything else is deterministic.
6
6
 
7
7
  ````markdown
8
8
  Install Andorra Code (a terminal-first coding agent) on this machine and verify it works.
9
9
 
10
10
  Prerequisites you must check first:
11
11
  - Node.js 22.19 or newer (`node --version`). If missing or older, install the current LTS
12
- from nodejs.org (or via the system's package manager) before continuing.
13
- - git on PATH (`git --version`).
14
- - Network access to github.com and to the model provider endpoint below.
12
+ from nodejs.org (or via the system's package manager) before continuing. npm ships with it.
13
+ - Network access to registry.npmjs.org and to the model provider endpoint below.
15
14
  - If the provider preset below is "Codex": the official OpenAI Codex CLI must already be
16
15
  installed and signed in with ChatGPT (install @openai/codex and run `codex login`), or
17
16
  ANDORRA_CODEX_PATH must point at its executable. Without it, configuring the Codex
18
- provider fails — check this before step 7 when that preset is chosen.
17
+ provider fails — check this before step 3 when that preset is chosen.
19
18
 
20
19
  Steps (run verbatim, stopping to report if any step fails):
21
- 1. git clone https://github.com/Andorra-Labs/Andorra-Code "<clone path, e.g. C:\dev\Andorra-Code>"
22
- 2. cd "<clone path>"
23
- 3. npm ci
24
- 4. npm run build
25
- 5. npm link
26
- 6. Verify: `andorra --version` must print a version number (currently 0.1.0). If `andorra`
27
- is not found, the npm global bin directory is not on PATH — report that; do not edit
28
- shell profiles silently.
20
+ 1. npm install -g @andorra-labs/code
21
+ 2. Verify: `andorra --version` must print a version number. If `andorra` is not found, the
22
+ npm global bin directory is not on PATH — report that; do not edit shell profiles silently.
29
23
 
30
24
  Provider configuration (interactive, needs the human once):
31
- 7. Tell the user to run `andorra` in any folder, type `/providers`, press Enter, choose
25
+ 3. Tell the user to run `andorra` in any folder, type `/providers`, press Enter, choose
32
26
  "Add provider", select the "<provider preset: DeepSeek | Kimi | Kimi For Coding | GLM Coding Plan | OpenAI | Anthropic | local | Codex>"
33
27
  preset, and provide credentials one of these two ways:
34
28
  - Paste their API key into the masked **API key** field (saved locally), or
@@ -38,18 +32,15 @@ Provider configuration (interactive, needs the human once):
38
32
  Settings save only after choosing "Save changes".
39
33
 
40
34
  Verification (optional, non-interactive):
41
- 8. `andorra --version`
42
- 9. `andorra auth status` — reports the Andorra account session (signed out is fine;
43
- accounts are optional and only needed for hosted Jev and paid graph features).
35
+ 4. `andorra --version`
36
+ 5. `andorra auth status` — reports the Andorra account session (signed out is fine;
37
+ accounts are optional and only needed for hosted Jev and paid graph features).
44
38
 
45
39
  Notes:
46
- - The npm package @andorra-labs/code is not published to npm yet; source install is the
47
- only supported path.
48
- - To update later: run `andorra update` from any directory (requires the checkout on
49
- branch main with a clean tree).
40
+ - To update later: run `andorra update` from any directory. It installs the latest npm
41
+ release of @andorra-labs/code and never downgrades.
50
42
  - Configuration lives in ~/.andorracode (override with ANDORRACODE_HOME). Saved API keys
51
43
  are plaintext in ~/.andorracode/config.json — never commit or share that file.
52
- - Do not modify anything under the clone's working tree other than what these commands do.
53
44
  ````
54
45
 
55
46
  ## What the human still has to do
@@ -59,6 +50,6 @@ Two things cannot be automated away:
59
50
  1. **The API key.** Provider keys are secrets; paste them yourself in the masked field, or export the environment variable yourself.
60
51
  2. **The optional account.** Run `andorra login` yourself if you want hosted Jev routing or paid graph features — the browser sign-in is interactive. See [Accounts](../getting-started/accounts.md).
61
52
 
62
- ## Why source install
53
+ ## Installing from source instead
63
54
 
64
- `@andorra-labs/code` is not yet on the public npm registry, so `npm install -g` does not work. The clone-and-link procedure above is exactly what the human-facing [Getting started](../getting-started/README.md) describes. When the package is published, this page gains a one-line `npm install -g @andorra-labs/code` alternative.
55
+ To run unreleased changes, clone and link the repository as [Getting started](../getting-started/README.md#install-from-source) describes. `andorra update` does not update a source checkout: pull and rebuild it yourself, or switch to the npm release with `npm install -g @andorra-labs/code@latest`.
@@ -1,6 +1,6 @@
1
1
  # Getting started
2
2
 
3
- This page installs Andorra Code from source, configures one model provider, and runs your first task. Total time is a few minutes on a machine that already has Node.js.
3
+ This page installs Andorra Code from npm, configures one model provider, and runs your first task. Total time is a few minutes on a machine that already has Node.js.
4
4
 
5
5
  If you want another AI coding agent to do this for you, skip to [Give these instructions to an AI agent](../agents/install-for-agents.md).
6
6
 
@@ -10,12 +10,20 @@ If you want another AI coding agent to do this for you, skip to [Give these inst
10
10
  | --- | --- |
11
11
  | Node.js 22.19 or newer | The engine declares `node >=22.19.0`. Check with `node --version`. |
12
12
  | npm | Ships with Node.js. |
13
- | git | Used to clone the repository; Andorra also reads Git state for its status displays. |
13
+ | git | Andorra reads Git state for its status displays; also needed to install from source. |
14
14
  | A model provider | An API key for an OpenAI-compatible or Anthropic endpoint, a ChatGPT subscription (Codex provider), or a local server such as LM Studio. See [Configure providers](configure-providers.md). |
15
15
 
16
- ## Install from source
16
+ ## Install
17
17
 
18
- `@andorra-labs/code` is not yet published to npm. Install from the GitHub repository:
18
+ ```sh
19
+ npm install -g @andorra-labs/code
20
+ ```
21
+
22
+ This installs two global commands, `andorra` and the alias `andorracode`. Keep them current with `andorra update`, which installs the latest npm release; see [Update](../guides/update.md).
23
+
24
+ ### Install from source
25
+
26
+ To run unreleased changes, build the GitHub repository instead:
19
27
 
20
28
  ```sh
21
29
  git clone https://github.com/Andorra-Labs/Andorra-Code
@@ -25,7 +33,7 @@ npm run build
25
33
  npm link
26
34
  ```
27
35
 
28
- This installs two global commands, `andorra` and the alias `andorracode`, both pointing at this checkout.
36
+ `npm link` points `andorra` and `andorracode` at this checkout. `andorra update` does not update a checkout; pull and rebuild it yourself (`git pull --ff-only`, `npm ci`, `npm run build`), or switch to the npm release with `npm install -g @andorra-labs/code@latest`.
29
37
 
30
38
  Alternatives without linking:
31
39
 
@@ -71,4 +79,4 @@ An Andorra account is optional: signing in enables hosted Jev decision routing a
71
79
  - [Concepts](../concepts/README.md) — how the graph, agents, and context routing fit together.
72
80
  - [Guides](../guides/README.md) — the TUI in depth, the Graph Builder, headless automation, skills/MCP/plugins.
73
81
  - [Reference](../reference/README.md) — every CLI flag, slash command, and configuration file.
74
- - [Update an existing installation](../guides/update.md) — `andorra update`.
82
+ - [Update an existing installation](../guides/update.md) — `andorra update` installs the latest npm release.
@@ -9,4 +9,4 @@ Task-oriented how-tos for every client and mode.
9
9
  - [Browser workspace](browser.md) — the `/browser` local web client.
10
10
  - [Desktop client](desktop-app.md) — the Tauri desktop application.
11
11
  - [Windows code signing](windows-code-signing.md) — Authenticode signing of the desktop bundles with Azure Artifact Signing, and how to verify a signature.
12
- - [Update](update.md) — keeping a source installation current with `andorra update`.
12
+ - [Update](update.md) — keeping the npm installation current with `andorra update`.
@@ -10,7 +10,7 @@ records, and the door to the graph editor. It does not reimplement any of them:
10
10
  | Requirement | Notes |
11
11
  | --- | --- |
12
12
  | Node.js 22.19 or newer | Needed to build from source or use a custom engine. Installed releases include their own supported Node runtime. |
13
- | The Andorra CLI | Installed releases include the engine built for that desktop version, with production dependencies. Source development links `@andorra-labs/code` from the checkout. Explicit custom engines must support `app-server --stdio`. |
13
+ | The Andorra CLI | Installed releases include the engine built for that desktop version, with production dependencies. Source development installs the latest npm release of `@andorra-labs/code` into `desktop/node_modules`. Explicit custom engines must support `app-server --stdio`. |
14
14
  | Rust 1.90+ and the Tauri CLI | Only to build the shell. The Tauri CLI is installed by `npm install` in `desktop/`. |
15
15
  | Platform webview | WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux. |
16
16
 
@@ -27,13 +27,42 @@ engine and remain your responsibility to update. The **Node executable** setting
27
27
  overrides the interpreter used for a JavaScript entry point, and `ANDORRA_NODE` applies when that
28
28
  field is empty.
29
29
 
30
- Source-development builds without a staged payload retain discovery of the checkout dependency,
31
- global npm installations, then `andorra` on `PATH`.
30
+ Source-development builds without a staged payload start the client's own dependency,
31
+ `desktop/node_modules/@andorra-labs/code`, then global npm installations, then `andorra` on `PATH`.
32
+ That dependency is the latest npm release, not the checkout: every `npm install` or `npm ci` in
33
+ `desktop/` ends by asking npm which release the `latest` tag names and, when it is newer than the
34
+ installed one, installing it with `--no-save`, so `package.json` and `package-lock.json` stay as
35
+ committed. The step never fails the install and never downgrades; offline, it keeps the installed
36
+ engine and says so. Set `ANDORRA_DESKTOP_ENGINE_LATEST=0` to keep the lockfile's engine for a
37
+ reproducible build. Installers are unaffected: `npm run desktop:build` still packages the
38
+ checkout's own engine at the desktop's version.
39
+
40
+ To drive this checkout's engine from a development window instead — to try engine changes in the
41
+ window — build it in the repository root (`npm install`, `npm run build`) and select its
42
+ `dist/src/cli.js` under **Settings → Appearance → Engine**, or set `ANDORRA_CLI` to that path.
32
43
 
33
44
  The engine the window resolved — interpreter and entry point — is shown as **Resolved command**
34
45
  under Settings → Appearance → Engine, which is how a window driving a checkout is told apart from
35
46
  one driving an installed CLI.
36
47
 
48
+ ### The terminal's `andorra`
49
+
50
+ The desktop and a global npm installation of the CLI share `~/.andorracode`, but updating the
51
+ desktop leaves the global installation where it was. When the global `andorra` is older than the
52
+ engine this desktop version ships, **Settings → Appearance & engine** shows a note, **Terminal CLI
53
+ is out of date**, with both versions and a copyable command to run in a terminal: `andorra update`
54
+ (see [Update](update.md)) for 0.1.5 and later, or `npm install -g @andorra-labs/code@latest` for an
55
+ older release, whose `andorra update` only updates a Git checkout and refuses an npm installation.
56
+ The window never runs npm or the global CLI itself: it reads the version from the installation's
57
+ `package.json`.
58
+
59
+ The note compares the first global installation found, in the order a source build searches them
60
+ (`%APPDATA%\npm`, `%LOCALAPPDATA%\npm`, `NPM_CONFIG_PREFIX`, `/usr/local`, `/usr/lib`,
61
+ `/opt/homebrew`, then nvm's default Node version and the rest newest first). It shows nothing when
62
+ there is no global installation, when that installation is the same version or newer, or when it
63
+ is a source checkout linked with `npm link`, whose `package.json` says nothing about the code that
64
+ was last built. The check runs each time the page opens.
65
+
37
66
  A checkout's own `cli.js` is never executed unless you select it: the launch directory is not
38
67
  searched, so starting the window from an untrusted checkout cannot run that checkout's engine. Set
39
68
  the **Entry point** setting, or `ANDORRA_CLI`, to point at a build you trust.
@@ -45,13 +74,13 @@ installation.
45
74
  ## Run it
46
75
 
47
76
  ```bash
48
- npm install # in the repository root: the engine's own dependencies
49
- npm run build # in the repository root: the built CLI, the client's engine
50
77
  cd desktop
51
- npm install # links @andorra-labs/code -> the checkout root, and the shell toolchain
78
+ npm install # the shell toolchain, and the latest npm release of @andorra-labs/code as the client's engine
52
79
  npm run desktop:dev # builds the shell and opens the window with the dev server
53
80
  ```
54
81
 
82
+ Run `npm install` in `desktop/` again to move the client's engine to a newer npm release.
83
+
55
84
  Other scripts:
56
85
 
57
86
  ```bash
@@ -10,18 +10,46 @@ Run from any directory:
10
10
  andorra update
11
11
  ```
12
12
 
13
- This fetches GitHub `main`, fast-forwards the installation checkout, installs locked dependencies, and rebuilds the CLI. It updates the checkout that provides the command, regardless of your current project directory.
13
+ This asks npm which release the `latest` tag of `@andorra-labs/code` names and, when it is newer than the installed version, installs exactly that version globally with `npm install -g @andorra-labs/code@<version>`. It uses the npm that ships with the Node.js running Andorra, and it updates the global installation that provides the command, regardless of your current project directory.
14
+
15
+ - When the installed version is already the latest, nothing is installed: `Andorra 0.1.4 is already the latest release on npm; nothing was installed.`
16
+ - It never downgrades. If the `latest` tag points at an older release than the one installed, nothing changes and the message says so.
17
+ - If npm cannot be reached, nothing changes; fix the network or registry settings and run it again.
14
18
 
15
19
  Requirements:
16
20
 
17
- - The installation checkout must be on `main` with no local changes.
18
- - If installation or compilation fails, fix the reported problem and rerun `andorra update`; it also rebuilds when the source is already current.
21
+ - The `andorra` command must be the global npm installation, `npm install -g @andorra-labs/code`.
22
+ - It refuses, without changing anything, when `andorra` runs from a source checkout — one linked with `npm link`, or started as `node dist/src/cli.js` — and when it runs from any other copy, such as a project's own dependency or the desktop client's engine. The message names the path and the command to use instead.
23
+
24
+ ### Switching from a source checkout
25
+
26
+ An installation made by cloning the repository and running `npm link` is not updated by `andorra update`. To move it to the npm release:
27
+
28
+ ```sh
29
+ npm install -g @andorra-labs/code@latest
30
+ ```
31
+
32
+ That replaces the linked global command with the published package. The checkout itself is left as it is. To keep running the checkout instead, update it yourself from the checkout:
33
+
34
+ ```sh
35
+ git pull --ff-only
36
+ npm ci
37
+ npm run build
38
+ ```
39
+
40
+ ## A failed or blocked update
41
+
42
+ Before npm touches anything, the update checks that the installed package can be moved, because npm replaces it by moving it aside. On Windows a terminal or program whose working directory is inside the package keeps it in place; the update then refuses, names the path, and changes nothing. Close that program and run `andorra update` again.
43
+
44
+ Another Andorra session or CodeGraph that has a native module of the old release loaded does not block the update: npm installs the new release, but cannot delete the old copy it moved aside (`@andorra-labs/.code-<random>` beside the package) while that process runs, and warns about it. The update names that copy. It is no longer used by the `andorra` command, and the next `andorra update` deletes it once the process holding it has exited.
45
+
46
+ When `npm install` itself fails, npm moves the previous release back, and the message says which version is still installed. A failure that looks like a brief Windows sharing violation is retried a few times first.
19
47
 
20
48
  ## An interrupted update
21
49
 
22
- While it runs, `andorra update` holds a lock file, `.git/andorra-update.lock` in the installation checkout, so two updates cannot run at once. The lock records the updater's process id. If an update is cancelled with Ctrl+C, its terminal is closed, or it crashes, the lock stays behind.
50
+ While it runs, `andorra update` holds a lock file, `update.lock` in the Andorra home directory (`~/.andorracode`, or `ANDORRACODE_HOME` when set), so two updates cannot run at once. The lock records the updater's process id. If an update is cancelled with Ctrl+C, its terminal is closed, or it crashes, the lock stays behind.
23
51
 
24
- Run `andorra update` again. When the process the lock names has exited, the update removes the stale lock, prints `Removed a stale update lock left by process …`, and continues. Rerunning also finishes whatever the interrupted update left half done, because it rebuilds even when the source is already current.
52
+ Run `andorra update` again. When the process the lock names has exited, the update removes the stale lock, prints `Removed a stale update lock left by process …`, and continues.
25
53
 
26
54
  The update refuses to start when the lock names a process that is still running, or when the lock is empty or unreadable. If you are sure no update is running (a process id can be reused by an unrelated program), break the lock:
27
55
 
@@ -29,19 +57,12 @@ The update refuses to start when the lock names a process that is still running,
29
57
  andorra update --break-lock
30
58
  ```
31
59
 
32
- ## Older installations
33
-
34
- For an installation without the update command, run once from the installation checkout:
60
+ ## The desktop client's engine
35
61
 
36
- ```sh
37
- git pull --ff-only
38
- npm ci
39
- npm run build
40
- npm link
41
- ```
62
+ A source build of the desktop client has an engine of its own, `desktop/node_modules/@andorra-labs/code`, which `andorra update` does not touch. Every `npm install` or `npm ci` in `desktop/` moves it to the latest npm release; see [Desktop client](desktop-app.md#which-engine-the-window-starts).
42
63
 
43
- If you previously linked a checkout, run `npm link` again after major changes to refresh the global command.
64
+ The reverse holds too: updating the desktop app does not update the global `andorra`. When the global installation is older than the desktop's engine, the desktop says so under **Settings → Appearance & engine** and offers `andorra update` to copy, or `npm install -g @andorra-labs/code@latest` for a release before 0.1.5, whose updater predates npm releases; see [Desktop client](desktop-app.md#the-terminals-andorra).
44
65
 
45
66
  ## Version
46
67
 
47
- `andorra --version` prints the installed version, read from the package manifest of the checkout that provides the command.
68
+ `andorra --version` prints the installed version, read from the package manifest of the installation that provides the command.
@@ -54,7 +54,7 @@ andorra auth status
54
54
  | `andorra mcp-server --stdio` | Run the local stdio MCP server: the supported surface for an agent to author and edit graphs, plus session monitoring. See [Agent MCP server](../agents/mcp-server.md). |
55
55
  | `andorra app-server --stdio` | Run the JSON-lines app server that drives the desktop client. Not for humans; see [Desktop client](../guides/desktop-app.md). |
56
56
  | `andorra remote <subcommand>` | Remote workspace pairing and sharing — `serve`, `status`, `code`, `devices`, `revoke <device-id>`, `share <path>`, `unshare <share-id>`, `pair <pairing-code>`, `workspaces`, `unpair <workspace-id>`, `rotate-cert`, with the flags shown in the usage above. See [Remote workspace](../guides/desktop-app.md#remote-workspace). |
57
- | `andorra update [--break-lock]` | Update the linked source installation from GitHub `main`. `--break-lock` replaces a leftover updater lock; see [Update](../guides/update.md#an-interrupted-update). |
57
+ | `andorra update [--break-lock]` | Install the latest npm release of `@andorra-labs/code` into the global npm installation; a source checkout is refused. `--break-lock` replaces a leftover updater lock; see [Update](../guides/update.md#an-interrupted-update). |
58
58
  | `andorra login` | Open the browser to sign in to (or create) an Andorra account. |
59
59
  | `andorra logout` | Remove this machine's saved Andorra account session and revoke its refresh token. |
60
60
  | `andorra auth status` | Show the saved Andorra account session and membership. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andorra-labs/code",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Terminal-first coding agent with a ReAct graph runtime",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",