papilot 0.7.0 → 0.8.0

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.
Files changed (63) hide show
  1. package/README.md +52 -30
  2. package/README.zh-CN.md +54 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +2 -0
  5. package/bin/installation.mjs +7 -1
  6. package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-D7ro6ecN.js} +54 -3
  7. package/dist/assets/index-Cc-awdNn.css +1 -0
  8. package/dist/assets/index-D_VX51no.js +265 -0
  9. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  10. package/dist/assets/{math-BsEl3u6P.js → math-V9uUaUzA.js} +1 -1
  11. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  12. package/dist/index.html +2 -2
  13. package/examples/papilot-paper/main.tex +1 -1
  14. package/examples/papilot-paper/sections/02-using.tex +3 -1
  15. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  16. package/package.json +10 -6
  17. package/skills/ai-review.md +18 -0
  18. package/skills/latex-warnings.md +1 -1
  19. package/src/conversation.ts +6 -0
  20. package/src/server/acp.ts +354 -0
  21. package/src/server/agents.ts +220 -43
  22. package/src/server/ai-review-routes.ts +72 -0
  23. package/src/server/ai-review.ts +38 -0
  24. package/src/server/app.ts +53 -11
  25. package/src/server/build-problems.ts +329 -0
  26. package/src/server/changes.ts +25 -2
  27. package/src/server/codex.ts +2 -1
  28. package/src/server/cursor.ts +153 -0
  29. package/src/server/latex-installation.ts +43 -0
  30. package/src/server/manuscript.ts +165 -23
  31. package/src/server/paths.ts +1 -1
  32. package/src/server/references.ts +70 -0
  33. package/src/server/review.ts +3 -3
  34. package/src/server/skills.ts +5 -8
  35. package/src/server/store.ts +20 -2
  36. package/src/server/update-install.ts +53 -2
  37. package/src/server/update-worker.ts +6 -6
  38. package/src/server/updates.ts +43 -14
  39. package/src/server/workspace.ts +183 -29
  40. package/src/shared.ts +78 -7
  41. package/src/skill-icons.ts +18 -0
  42. package/src/skill-metadata.ts +45 -0
  43. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  44. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  45. package/vendor/latex-review/dist/web/index.html +2 -2
  46. package/vendor/latex-review/src/core/bib.ts +692 -0
  47. package/vendor/latex-review/src/core/changes.ts +39 -0
  48. package/vendor/latex-review/src/core/review.ts +1 -0
  49. package/vendor/latex-review/src/core/verify.ts +784 -0
  50. package/vendor/latex-review/src/core/view.ts +204 -0
  51. package/vendor/latex-review/src/render/round.ts +11 -0
  52. package/vendor/latex-review/src/server.ts +65 -0
  53. package/vendor/latex-review/src/types.ts +125 -0
  54. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  55. package/vendor/latex-review/src/web/references.tsx +499 -0
  56. package/vendor/latex-review/src/web/review.tsx +259 -131
  57. package/vendor/latex-review/src/web/style.css +285 -0
  58. package/vendor/latex-review/src/web/zh-CN.ts +75 -0
  59. package/dist/assets/index-CIJEYlP7.js +0 -252
  60. package/dist/assets/index-hOJEruFc.css +0 -1
  61. package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
  62. package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
  63. package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
@@ -1,3 +1,4 @@
1
+ import type { ReviewSnapshot } from './ai-review.ts';
1
2
  import { DatabaseSync } from 'node:sqlite';
2
3
  import { EventEmitter } from 'node:events';
3
4
  import fs from 'node:fs';
@@ -16,6 +17,7 @@ export class Store extends EventEmitter {
16
17
  CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, request_key TEXT UNIQUE NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL);
17
18
  CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
18
19
  CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
20
+ CREATE TABLE IF NOT EXISTS ai_review_inputs(task_id TEXT PRIMARY KEY, data TEXT NOT NULL);
19
21
  CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
20
22
  CREATE TABLE IF NOT EXISTS attachments(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
21
23
  CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
@@ -23,6 +25,7 @@ export class Store extends EventEmitter {
23
25
  CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
24
26
  CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
25
27
  CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
28
+ CREATE TABLE IF NOT EXISTS migrations(key TEXT PRIMARY KEY, done INTEGER NOT NULL);
26
29
  CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
27
30
  CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
28
31
  }
@@ -39,7 +42,7 @@ export class Store extends EventEmitter {
39
42
  this.transaction(() => {
40
43
  // Only Papilot records are removed. Never finish review tasks or touch their files.
41
44
  this.db.prepare('INSERT OR IGNORE INTO deleted_tasks SELECT id, ? FROM tasks WHERE project_id=?').run(Date.now(), id);
42
- for (const table of ['items', 'runs']) this.db.prepare(`DELETE FROM ${table} WHERE task_id IN (SELECT id FROM tasks WHERE project_id=?)`).run(id);
45
+ for (const table of ['items', 'runs', 'ai_review_inputs']) this.db.prepare(`DELETE FROM ${table} WHERE task_id IN (SELECT id FROM tasks WHERE project_id=?)`).run(id);
43
46
  for (const table of ['tasks', 'annotations', 'members']) this.db.prepare(`DELETE FROM ${table} WHERE project_id=?`).run(id);
44
47
  this.db.prepare('DELETE FROM projects WHERE id=?').run(id);
45
48
  });
@@ -51,6 +54,12 @@ export class Store extends EventEmitter {
51
54
  runByKey(key: string) { const row = this.db.prepare('SELECT data FROM runs WHERE request_key=?').get(key) as { data: string } | undefined; return row ? JSON.parse(row.data) as Run : undefined; }
52
55
  items(taskId: string) { return (this.db.prepare('SELECT data FROM items WHERE task_id=? ORDER BY created,rowid').all(taskId) as { data: string }[]).map(r => JSON.parse(r.data) as TimelineItem); }
53
56
  saveItem(item: TimelineItem) { this.db.prepare('INSERT INTO items VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(item.id, item.taskId, item.createdAt, JSON.stringify(item)); this.emit('change'); }
57
+ saveReviewInput(taskId: string, snapshot: ReviewSnapshot) { this.db.prepare('INSERT INTO ai_review_inputs VALUES(?,?) ON CONFLICT(task_id) DO UPDATE SET data=excluded.data').run(taskId, JSON.stringify(snapshot)); }
58
+ reviewInput(taskId: string): ReviewSnapshot {
59
+ const row = this.db.prepare('SELECT data FROM ai_review_inputs WHERE task_id=?').get(taskId) as { data: string } | undefined;
60
+ if (!row) throw new Error('The PDF snapshot for this review is unavailable. Start a new AI review.');
61
+ return JSON.parse(row.data);
62
+ }
54
63
  annotations(projectId: string) { return (this.db.prepare('SELECT data FROM annotations WHERE project_id=? ORDER BY rowid').all(projectId) as { data: string }[]).map(r => JSON.parse(r.data) as Annotation).filter(note => note.deletedAt === undefined); }
55
64
  annotation(projectId: string, id: string) {
56
65
  const row = this.db.prepare('SELECT data FROM annotations WHERE id=? AND project_id=?').get(id, projectId) as { data: string } | undefined;
@@ -69,10 +78,19 @@ export class Store extends EventEmitter {
69
78
  this.db.prepare('INSERT INTO deleted_tasks VALUES(?,?) ON CONFLICT(id) DO NOTHING').run(id, Date.now());
70
79
  this.db.prepare('DELETE FROM items WHERE task_id=?').run(id);
71
80
  this.db.prepare('DELETE FROM runs WHERE task_id=?').run(id);
81
+ this.db.prepare('DELETE FROM ai_review_inputs WHERE task_id=?').run(id);
72
82
  this.db.prepare('DELETE FROM tasks WHERE id=?').run(id);
73
83
  });
74
84
  this.emit('change');
75
85
  }
86
+ // Runs a one-off change the first time this database sees its key.
87
+ migrate(key: string, fn: () => void) {
88
+ this.transaction(() => {
89
+ if (this.db.prepare('SELECT 1 FROM migrations WHERE key=?').get(key)) return;
90
+ fn();
91
+ this.db.prepare('INSERT INTO migrations VALUES(?,?)').run(key, Date.now());
92
+ });
93
+ }
76
94
  transaction<T>(fn: () => T): T { this.db.exec('BEGIN IMMEDIATE'); try { const result = fn(); this.db.exec('COMMIT'); return result; } catch (e) { this.db.exec('ROLLBACK'); throw e; } }
77
95
  recover() {
78
96
  this.transaction(() => {
@@ -80,7 +98,7 @@ export class Store extends EventEmitter {
80
98
  for (const t of this.tasks().filter(t => t.status === 'deleting')) this.updateTask(t.id, { error: 'The server restarted during deletion. Retry deletion to confirm cleanup.' });
81
99
  for (const t of this.tasks().filter(t => t.pendingReview)) this.updateTask(t.id, { status: 'uncertain', error: 'The server restarted before a review action was confirmed. Select Check status before continuing.' });
82
100
  for (const r of this.runs().filter(r => r.status === 'running')) this.saveRun({ ...r, status: 'interrupted', finishedAt: Date.now() });
83
- for (const t of this.tasks().filter(t => ['running', 'waiting_input'].includes(t.status))) this.updateTask(t.id, { status: 'interrupted', turnId: null, error: 'The server restarted and interrupted the previous turn. Continue in the same conversation.' });
101
+ for (const t of this.tasks().filter(t => ['running', 'waiting_input'].includes(t.status) || (t.mode === 'review' && t.status === 'building'))) this.updateTask(t.id, { status: 'interrupted', turnId: null, error: 'The server restarted and interrupted the previous turn. Continue in the same conversation.' });
84
102
  });
85
103
  }
86
104
  close() { this.db.close(); }
@@ -1,9 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import net from 'node:net';
4
- import { spawn } from 'node:child_process';
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import { promisify } from 'node:util';
5
6
  import { createRequire } from 'node:module';
6
- import { readJson, stableVersion } from '../../bin/installation.mjs';
7
+ import { devRevision, readJson, stableVersion } from '../../bin/installation.mjs';
8
+
9
+ export const DEV_REPOSITORY = 'https://github.com/PapilotAI/papilot.git';
10
+ const git = async (args: string[], cwd: string) => (await promisify(execFile)('git', args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })).stdout.trim();
7
11
 
8
12
  export function npmCli(env = process.env, executable = process.execPath) {
9
13
  const candidates = [env.npm_execpath, path.join(path.dirname(executable), '../lib/node_modules/npm/bin/npm-cli.js'), path.join(path.dirname(executable), 'node_modules/npm/bin/npm-cli.js'),
@@ -52,7 +56,54 @@ export async function probeInstallation(root: string, version: string, stage: st
52
56
  }
53
57
  }
54
58
  }
59
+ // Resolve main during discovery, then fetch that exact commit. Building in a
60
+ // managed directory keeps the running installation and any source checkout intact.
61
+ export async function prepareDev(root: string, version: string, log: number, run = runInstall, checkout = git) {
62
+ const revision = devRevision(version);
63
+ if (!revision) throw new Error('Invalid development version.');
64
+ await checkout(['init', '--quiet'], root);
65
+ await checkout(['-c', 'core.hooksPath=/dev/null', 'fetch', '--depth=1', DEV_REPOSITORY, revision], root);
66
+ await checkout(['-c', 'core.hooksPath=/dev/null', 'checkout', '--detach', 'FETCH_HEAD'], root);
67
+ if (await checkout(['rev-parse', 'HEAD'], root) !== revision) throw new Error('The development checkout does not match the requested commit.');
68
+ const pkg = readJson(path.join(root, 'package.json'));
69
+ if (pkg?.name !== 'papilot' || !stableVersion(pkg.version) || `${pkg.version}-dev.${revision}` !== version) throw new Error('The development checkout does not match the requested version.');
70
+ const flags = ['--ignore-scripts', '--no-audit', '--no-fund'];
71
+ // Test-only tools can exclude a Node version that supports the application.
72
+ // Build first, then enforce engine compatibility for the retained runtime.
73
+ await run(['ci', '--include=dev', '--engine-strict=false', ...flags], root, log);
74
+ const engine = path.join(root, 'vendor/latex-review');
75
+ await run(['ci', '--include=dev', '--engine-strict=false', ...flags], engine, log);
76
+ // Only this installed snapshot gets a dev version; the repository stays unchanged.
77
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n');
78
+ await run(['run', 'build'], engine, log);
79
+ await run(['run', 'build'], root, log);
80
+ for (const cwd of [root, engine]) {
81
+ // npm 10 checks even omitted dev dependencies under --engine-strict, so they
82
+ // are hidden while pruning. The manifest is then restored: the launcher and
83
+ // the agents read the tested Codex and Claude SDK versions from it.
84
+ const manifest = path.join(cwd, 'package.json'), original = fs.readFileSync(manifest, 'utf8');
85
+ const { devDependencies: _, ...runtime } = JSON.parse(original);
86
+ fs.writeFileSync(manifest, JSON.stringify(runtime, null, 2) + '\n');
87
+ try { await run(['prune', '--omit=dev', '--engine-strict', ...flags], cwd, log); }
88
+ finally { fs.writeFileSync(manifest, original); }
89
+ }
90
+ }
91
+ async function installDev(data: string, version: string) {
92
+ const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
93
+ const space = fs.statfsSync(releases);
94
+ if (space.bavail * space.bsize < 2 * 1024 ** 3) throw new Error('The dev build needs at least 2 GB of free disk space. Free some space and retry.');
95
+ const stage = fs.mkdtempSync(path.join(releases, 'dev-')), root = path.join(stage, 'papilot');
96
+ fs.mkdirSync(root);
97
+ const log = fs.openSync(path.join(data, 'updates/install.log'), 'a', 0o600);
98
+ try {
99
+ await prepareDev(root, version, log);
100
+ await probeInstallation(root, version, stage, log);
101
+ return root;
102
+ } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
103
+ finally { fs.closeSync(log); }
104
+ }
55
105
  export async function installRelease(data: string, version: string) {
106
+ if (devRevision(version)) return installDev(data, version);
56
107
  if (!stableVersion(version)) throw new Error('Invalid release version.');
57
108
  const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
58
109
  const stage = fs.mkdtempSync(path.join(releases, version + '-')), root = path.join(stage, 'node_modules/papilot');
@@ -5,10 +5,10 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
8
- import { readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
8
+ import { devRevision, readJson, releaseVersion, writeJson } from '../../bin/installation.mjs';
9
9
  import { health, loaderFor, sleep } from './update-install.ts';
10
10
 
11
- export async function restartRelease(job: { id: string; root: string; version: string; previousRoot: string; previousVersion: string; data: string; port: number; parent: number; service: boolean }, ready = () => process.send?.('ready'), attempts = 100) {
11
+ export async function restartRelease(job: { id: string; root: string; version: string; channel?: 'stable' | 'dev'; previousRoot: string; previousVersion: string; data: string; port: number; parent: number; service: boolean }, ready = () => process.send?.('ready'), attempts = 100) {
12
12
  const folder = path.join(job.data, 'updates'), activeFile = path.join(folder, 'active.json'), statusFile = path.join(folder, 'status.json'), lock = path.join(folder, 'install.lock');
13
13
  const previous = readJson(activeFile), status = readJson(statusFile) || {};
14
14
  const database = path.join(job.data, 'papilot.sqlite'), backup = path.join(folder, 'before-update.sqlite');
@@ -32,10 +32,10 @@ export async function restartRelease(job: { id: string; root: string; version: s
32
32
  }
33
33
  };
34
34
  try {
35
- if (!stableVersion(job.version) || !fs.realpathSync(job.root).startsWith(fs.realpathSync(path.join(folder, 'releases')) + path.sep) || readJson(path.join(job.root, 'package.json'))?.version !== job.version) throw new Error('Invalid update activation.');
35
+ if (!releaseVersion(job.version) || (job.channel && (job.channel === 'dev') !== Boolean(devRevision(job.version))) || !fs.realpathSync(job.root).startsWith(fs.realpathSync(path.join(folder, 'releases')) + path.sep) || readJson(path.join(job.root, 'package.json'))?.version !== job.version) throw new Error('Invalid update activation.');
36
36
  writeJson(lock, { pid: process.pid });
37
37
  writeJson(statusFile, { ...status, phase: 'restarting', pid: process.pid });
38
- writeJson(activeFile, { root: job.root, version: job.version, id: job.id, phase: 'pending', pid: process.pid, previous });
38
+ writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'pending', pid: process.pid, previous });
39
39
  ready();
40
40
  for (let i = 0; i < 150 && alive(job.parent); i++) await sleep(100);
41
41
  if (alive(job.parent)) throw new Error('Papilot did not finish shutting down.');
@@ -47,11 +47,11 @@ export async function restartRelease(job: { id: string; root: string; version: s
47
47
  if (fs.existsSync(database + '-wal')) fs.copyFileSync(database + '-wal', backup + '-wal');
48
48
  else fs.rmSync(backup + '-wal', { force: true });
49
49
  }
50
- writeJson(activeFile, { root: job.root, version: job.version, id: job.id, phase: 'starting', pid: process.pid, previous });
50
+ writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'starting', pid: process.pid, previous });
51
51
  launch(job.root);
52
52
  for (let i = 0; i < attempts; i++) {
53
53
  if (await health(job.port, job.version, job.id)) {
54
- writeJson(activeFile, { root: job.root, version: job.version, id: job.id, phase: 'active' });
54
+ writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'active' });
55
55
  writeJson(statusFile, { ...status, phase: 'updated', currentVersion: job.version, error: null, pid: process.pid });
56
56
  return;
57
57
  }
@@ -2,25 +2,39 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
5
+ import { devRevision, newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
6
6
  import { installRelease, loaderFor, npmCli } from './update-install.ts';
7
- import type { UpdateStatus } from '../shared.ts';
7
+ import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
8
 
9
9
  const activePhases = new Set(['installing', 'waiting', 'restarting']);
10
10
  export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
11
- export async function latestRelease(): Promise<string> {
12
- const response = await fetch('https://registry.npmjs.org/papilot/latest', { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json' } });
11
+ async function releaseInfo(url: string, fetcher: typeof fetch) {
12
+ const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
13
13
  if (!response.ok) throw new Error('Could not check for updates.');
14
14
  const text = await response.text();
15
15
  if (text.length > 256000) throw new Error('Invalid release information.');
16
- const pkg = JSON.parse(text);
16
+ return JSON.parse(text);
17
+ }
18
+ export async function latestRelease(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<string> {
19
+ if (channel === 'dev') {
20
+ const commit = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/git/ref/heads/main', fetcher);
21
+ const revision = commit.object?.sha;
22
+ if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Invalid development revision.');
23
+ const pkg = await releaseInfo(`https://raw.githubusercontent.com/PapilotAI/papilot/${revision}/package.json`, fetcher);
24
+ if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid development version.');
25
+ return `${pkg.version}-dev.${revision}`;
26
+ }
27
+ const pkg = await releaseInfo('https://registry.npmjs.org/papilot/latest', fetcher);
17
28
  if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid release information.');
18
29
  return pkg.version;
19
30
  }
31
+ const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
32
+ const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
33
+ && (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
20
34
  interface Options {
21
35
  root: string; data: string; version: string; port: number;
22
36
  busy: () => boolean; shutdown: () => void;
23
- latest?: () => Promise<string>; install?: (data: string, version: string) => Promise<string>;
37
+ latest?: (channel: UpdateChannel) => Promise<string>; install?: (data: string, version: string) => Promise<string>;
24
38
  restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
25
39
  }
26
40
  // Both HTTP listeners share one updater. Downloads do not interrupt editing;
@@ -35,8 +49,11 @@ export class Updates {
35
49
  private closed = false;
36
50
  private file: string;
37
51
  private lock: string;
52
+ private channel: UpdateChannel;
38
53
  constructor(private options: Options) {
39
54
  this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
55
+ const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
56
+ this.channel = preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
40
57
  let supported = options.supported ?? true;
41
58
  if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
42
59
  this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
@@ -48,7 +65,7 @@ export class Updates {
48
65
  const saved = readJson(this.file);
49
66
  if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
50
67
  const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state;
51
- return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate };
68
+ return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
52
69
  }
53
70
  get restarting() { return this.snapshot().phase === 'restarting'; }
54
71
  enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
@@ -64,24 +81,36 @@ export class Updates {
64
81
  this.state = { ...this.state, ...patch };
65
82
  writeJson(this.file, { ...this.state, pid: process.pid });
66
83
  }
84
+ setChannel(channel: unknown) {
85
+ if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
86
+ if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
87
+ if (channel === this.channel) return this.check();
88
+ this.channel = channel;
89
+ writeJson(path.join(this.options.data, 'updates/channel.json'), { channel });
90
+ this.checking = null;
91
+ this.change({ latestVersion: null, checkedAt: null, phase: 'idle', error: null });
92
+ return this.check();
93
+ }
67
94
  check(): Promise<UpdateStatus> {
68
95
  if (this.checking) return this.checking;
69
96
  if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
70
97
  this.change({ phase: 'checking' });
71
- this.checking = (this.options.latest || latestRelease)().then(version => {
72
- if (!stableVersion(version)) throw new Error('Invalid release version.');
73
- if (!this.closed && !activePhases.has(this.state.phase)) this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newerVersion(version, this.options.version) ? 'available' : 'idle' });
98
+ const channel = this.channel;
99
+ const checking = (this.options.latest || latestRelease)(channel).then(version => {
100
+ if (!validTarget(version, channel)) throw new Error('Invalid release version.');
101
+ if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: available(version, this.options.version, channel) ? 'available' : 'idle' });
74
102
  return this.snapshot();
75
103
  }).catch(() => {
76
- if (!this.closed && !activePhases.has(this.state.phase)) this.change({ phase: this.state.latestVersion && newerVersion(this.state.latestVersion, this.options.version) ? 'available' : 'idle', checkedAt: Date.now(), error: 'Could not check for updates. Try again when connected.' });
104
+ if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ phase: this.state.latestVersion && available(this.state.latestVersion, this.options.version, channel) ? 'available' : 'idle', checkedAt: Date.now(), error: 'Could not check for updates. Try again when connected.' });
77
105
  return this.snapshot();
78
- }).finally(() => { this.checking = null; });
106
+ }).finally(() => { if (this.checking === checking) this.checking = null; });
107
+ this.checking = checking;
79
108
  return this.checking;
80
109
  }
81
110
  install(version: unknown) {
82
111
  if (activePhases.has(this.state.phase)) return this.snapshot();
83
112
  if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
84
- if (!stableVersion(version) || version !== this.state.latestVersion || !newerVersion(version, this.options.version)) throw new Error('Check for a newer release before updating.');
113
+ if (!validTarget(version, this.channel) || version !== this.state.latestVersion || !available(version, this.options.version, this.channel)) throw new Error('Check for a newer release before updating.');
85
114
  fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
86
115
  const previous = readJson(this.lock);
87
116
  if (previous && alive(previous.pid)) throw new Error('Another Papilot instance is already updating.');
@@ -105,7 +134,7 @@ export class Updates {
105
134
  private release() { const lock = readJson(this.lock); if (lock?.pid === process.pid) fs.rmSync(this.lock, { force: true }); }
106
135
  private restart(root: string, version: string) {
107
136
  const folder = path.dirname(this.file), job = path.join(folder, 'restart.json');
108
- writeJson(job, { id: randomUUID(), root, version, previousRoot: this.options.root, previousVersion: this.options.version, data: this.options.data, port: this.options.port, parent: process.pid,
137
+ writeJson(job, { id: randomUUID(), root, version, channel: this.channel, previousRoot: this.options.root, previousVersion: this.options.version, data: this.options.data, port: this.options.port, parent: process.pid,
109
138
  service: process.env.PAPILOT_SERVICE_MANAGER === 'launchd' || process.env.XPC_SERVICE_NAME === 'local.papilot' });
110
139
  const log = fs.openSync(path.join(folder, 'restart.log'), 'a', 0o600);
111
140
  const child = spawn(process.execPath, ['--import', loaderFor(this.options.root), path.join(this.options.root, 'src/server/update-worker.ts'), job], { cwd: this.options.root, env: process.env, detached: true, stdio: ['ignore', log, log, 'ipc'] });