copilot-operator 0.1.13 → 0.1.16

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 (78) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +42 -42
  3. package/dist/src/api/operator.service.js +743 -367
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +476 -163
  26. package/dist/src/plan/brief.js +37 -44
  27. package/dist/src/plan/importPlan.js +15 -7
  28. package/dist/src/plan/schema.js +64 -29
  29. package/dist/src/plan/script.js +0 -15
  30. package/dist/src/plan/systemGuide.js +33 -177
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +49 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/transport/copilotTransport.js +39 -1
  37. package/dist/src/vcs/scope.js +29 -6
  38. package/dist/src/vcs/taskVcs.js +78 -10
  39. package/dist/web/404.html +2 -2
  40. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  41. package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +1 -0
  42. package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +1 -0
  43. package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.js +1 -0
  44. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  45. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  46. package/dist/web/appearance.html +2 -2
  47. package/dist/web/appearance.txt +12 -12
  48. package/dist/web/defaults.html +2 -2
  49. package/dist/web/defaults.txt +12 -12
  50. package/dist/web/history.html +2 -2
  51. package/dist/web/history.txt +12 -12
  52. package/dist/web/import.html +2 -2
  53. package/dist/web/import.txt +12 -12
  54. package/dist/web/index.html +2 -2
  55. package/dist/web/index.txt +12 -12
  56. package/dist/web/level1.html +2 -2
  57. package/dist/web/level1.txt +12 -12
  58. package/dist/web/presets.html +2 -2
  59. package/dist/web/presets.txt +12 -12
  60. package/dist/web/sessions/view.html +2 -2
  61. package/dist/web/sessions/view.txt +12 -12
  62. package/dist/web/system.html +2 -2
  63. package/dist/web/system.txt +12 -12
  64. package/package.json +19 -10
  65. package/prompts/level1.md +2 -1
  66. package/prompts/organisation.bg.md +0 -1
  67. package/prompts/organisation.en.md +0 -1
  68. package/dist/src/context/contextFiles.js +0 -295
  69. package/dist/src/context/desktopMirror.js +0 -173
  70. package/dist/src/context/projectMirror.js +0 -432
  71. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  72. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  73. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  74. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +0 -1
  75. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  76. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +0 -1
  77. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_buildManifest.js +0 -0
  78. /package/dist/web/_next/static/{NosxirjkOgV-Boyq8dGPl → GhdY7nVcYi-WV3j7saVPK}/_ssgManifest.js +0 -0
@@ -29,7 +29,7 @@ import { spawn } from 'node:child_process';
29
29
  import { existsSync } from 'node:fs';
30
30
  import { mkdir, writeFile } from 'node:fs/promises';
31
31
  import { extname, join } from 'node:path';
32
- import { resolveDesktopDir } from '../context/contextFiles.js';
32
+ import { resolveDesktopDir } from '../context/desktopDir.js';
33
33
  /** The one folder under the Desktop that every saved log lands in. */
34
34
  export const LOGS_FOLDER = 'copilot-operator-logs';
35
35
  /** The folder every saved log goes into. */
@@ -33,15 +33,23 @@
33
33
  * make the start-up race harder to debug for no gain.
34
34
  *
35
35
  * The web interface, when this process serves it (an install from npm, `cop start`): its pages are
36
- * static files and answer without a token — the host check still applies, so a rebound domain gets
36
+ * static files and are read without a token — the host check still applies, so a rebound domain gets
37
37
  * nothing — and every page hands the browser the token as a cookie scoped to `/api`, HttpOnly (no
38
38
  * script reads it) and SameSite=Strict (no other site's page sends it). The page's own requests
39
39
  * then carry it by themselves, the stream and the downloads included, and no URL ever holds it.
40
+ * Only a read is a page: a request that writes needs the token whatever its path says, so a path the
41
+ * guard calls a page and the router calls a route can at worst show something, never start a run.
40
42
  *
41
43
  * The honest limit, since this is the file somebody will quote: none of this contains a process
42
44
  * already running as the operator. It raises the floor from "anything on this machine, and quite a
43
- * few things off it" to "something that can read a file in the install". The boundary is still the
44
- * account the runner runs in.
45
+ * few things off it" to "something that can read a file in the install" — and, wherever the
46
+ * interface is served, to anything on this machine that can open that port and ask for a page, since
47
+ * the page is how a browser is given the token. In a package install that is this process's port.
48
+ * In a clone it is the Next.js dev server `npm start` runs on 127.0.0.1:3210, which this guard does
49
+ * not stand in front of: the token is built into that page's script (`NEXT_PUBLIC_COP_TOKEN`, see
50
+ * scripts/dev.mjs), as plain text that anything running in the page can read, where a package's
51
+ * HttpOnly cookie is out of a script's reach. A clone's API port serving no page of its own closes
52
+ * one way to the token, not every way. The boundary is still the account the runner runs in.
45
53
  */
46
54
  import { randomBytes, timingSafeEqual } from 'node:crypto';
47
55
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
@@ -111,9 +119,30 @@ export function tokenMatches(presented, expected) {
111
119
  }
112
120
  /** The cookie the served interface is given the token in. */
113
121
  export const TOKEN_COOKIE = 'cop_token';
114
- /** Whether a path is the web interface rather than the API. */
122
+ /**
123
+ * Whether a path is the web interface rather than the API.
124
+ *
125
+ * Compared in lower case, because that is how the router compares it: Express matches a route
126
+ * without regard to case, so `/API/sessions` is the sessions route. Compared as written, it was a
127
+ * page to this test and needed no token, and a request with none listed the sessions and started
128
+ * a run (found on 2026-10-01).
129
+ */
115
130
  export function isInterfacePath(path) {
116
- return path !== '/api' && !path.startsWith('/api/');
131
+ const p = path.toLowerCase();
132
+ return p !== '/api' && !p.startsWith('/api/');
133
+ }
134
+ /**
135
+ * Whether a request is a read of the served interface: a page or one of its files.
136
+ *
137
+ * The interface is static files, and static files are only ever read, so a request that writes is
138
+ * not the interface whatever its path is. Keeping the no-token exemption to GET and HEAD means the
139
+ * next disagreement between this guard and the router about what a path is — a spelling, an
140
+ * encoding, a prefix nobody thought of — can at worst answer a read, never start a run.
141
+ * An absent method is a GET, as it is for any request that names none.
142
+ */
143
+ export function isInterfaceRequest(method, path) {
144
+ const m = (method ?? 'GET').toUpperCase();
145
+ return (m === 'GET' || m === 'HEAD') && isInterfacePath(path);
117
146
  }
118
147
  /** The `Set-Cookie` value that hands a served page the token. */
119
148
  export function tokenCookie(token) {
@@ -181,11 +210,12 @@ export function judgeRequest(req, opts) {
181
210
  if (!originAllowed(origin, opts.allowedOrigins)) {
182
211
  return { ok: false, status: 403, reason: 'origin', detail: `requests from ${origin} are not accepted by this API` };
183
212
  }
184
- if (OPEN_PATHS.includes(req.path))
213
+ // In lower case, as the router matches it: `/API/health` is the health route too.
214
+ if (OPEN_PATHS.includes(req.path.toLowerCase()))
185
215
  return { ok: true };
186
- // The interface's own files: answered to the right host with no token, since they are how the
216
+ // The interface's own files: read from the right host with no token, since they are how the
187
217
  // browser gets one. Only when this process serves them; see `localApiGuard`.
188
- if (opts.servesInterface && isInterfacePath(req.path))
218
+ if (opts.servesInterface && isInterfaceRequest(req.method, req.path))
189
219
  return { ok: true };
190
220
  const presented = presentedToken({ headers: req.headers, query: (req.query ?? {}) });
191
221
  if (!tokenMatches(presented, opts.token)) {
@@ -213,9 +243,9 @@ export function localApiGuard(opts) {
213
243
  next();
214
244
  return;
215
245
  }
216
- const verdict = judgeRequest({ path: req.path, headers: req.headers, query: req.query }, opts);
246
+ const verdict = judgeRequest({ method: req.method, path: req.path, headers: req.headers, query: req.query }, opts);
217
247
  if (verdict.ok) {
218
- if (opts.servesInterface && isInterfacePath(req.path))
248
+ if (opts.servesInterface && isInterfaceRequest(req.method, req.path))
219
249
  res.setHeader('Set-Cookie', tokenCookie(opts.token));
220
250
  next();
221
251
  return;
@@ -1,18 +1,45 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
1
7
  /**
2
8
  * The API server: routes, the guard in front of them, the data folder's permissions, recovery of
3
9
  * whatever the previous process left running. See `main.ts` for the process around it.
4
10
  */
5
11
  import 'reflect-metadata';
6
- import { NestFactory } from '@nestjs/core';
12
+ import { Catch, ConflictException } from '@nestjs/common';
13
+ import { BaseExceptionFilter, NestFactory } from '@nestjs/core';
7
14
  import { join } from 'node:path';
8
15
  import { AppModule } from './app.module.js';
9
16
  import { OperatorService } from './operator.service.js';
17
+ import { SettingsUnusableError } from './settings.js';
18
+ import { PolicyLockUnusableError } from '../config/lockedPolicy.js';
10
19
  import { ensureApiToken, localApiGuard } from './security.js';
11
20
  import { secureDataDir } from './dataAcl.js';
12
21
  import { pruneRuns } from '../session/retention.js';
13
22
  import { mkdir, writeFile } from 'node:fs/promises';
14
23
  import { existsSync } from 'node:fs';
15
24
  import { installLayout } from '../config/layout.js';
25
+ /**
26
+ * A settings file, or a policy lock, that cannot be used, answered the same way on every route: 409,
27
+ * with the message that names the file and says what to do (see `SettingsUnusableError` and
28
+ * `PolicyLockUnusableError`).
29
+ *
30
+ * Here, once, because the routes that meet it are most of them: every one whose work reads the
31
+ * settings, and reading the settings reads the locks. Left to the default it was "Internal server
32
+ * error" wherever a route did not wrap its errors, which included the Defaults page, the Project
33
+ * page and the model picker; a broken lock was that on the System page as well, until 2026-10-01.
34
+ */
35
+ let SettingsUnusableFilter = class SettingsUnusableFilter extends BaseExceptionFilter {
36
+ catch(e, host) {
37
+ super.catch(new ConflictException(e.message), host);
38
+ }
39
+ };
40
+ SettingsUnusableFilter = __decorate([
41
+ Catch(SettingsUnusableError, PolicyLockUnusableError)
42
+ ], SettingsUnusableFilter);
16
43
  /**
17
44
  * Everything `main` used to do, as a function that returns the running server instead of owning the
18
45
  * process. `main.ts` calls it and turns a failure into an exit code; the end-to-end checks call it
@@ -43,6 +70,7 @@ export async function startApi(opts = {}) {
43
70
  exposedHeaders: ['content-disposition'],
44
71
  });
45
72
  app.setGlobalPrefix('api');
73
+ app.useGlobalFilters(new SettingsUnusableFilter(app.getHttpAdapter()));
46
74
  /*
47
75
  * Who may drive this. Mounted before routing so it covers every route including the SSE stream,
48
76
  * and so that no controller has to remember. See `security.ts` for why "it is only on localhost"
@@ -76,17 +104,26 @@ export async function startApi(opts = {}) {
76
104
  // The prebuilt interface, from the package: one process, one port, no dev server.
77
105
  app.useStaticAssets(webDir, { extensions: ['html'], index: 'index.html' });
78
106
  }
107
+ /*
108
+ * The settings, read before the port is open and before anything the last process left is
109
+ * recovered. A file the API cannot use stops it here, with the reason; read after the recovery,
110
+ * it stopped the API once the tasks were already marked aborted but not yet settled (their record
111
+ * goes in the runs folder the file names), and nothing comes back to settle them.
112
+ */
113
+ const ops = app.get(OperatorService);
114
+ const cfg = await ops.settings.load().catch(async (e) => {
115
+ await app.close();
116
+ throw e;
117
+ });
79
118
  await app.listen(PORT, '127.0.0.1');
80
119
  // Close whatever the previous process left open before anyone can look at it, rather than
81
120
  // waiting for the first request to trigger it.
82
- const ops = app.get(OperatorService);
83
121
  await ops.bootstrap();
84
122
  /*
85
123
  * runs/ holds every step's raw output and, on a failure, a screenshot of the signed-in page —
86
124
  * as much the bot's records as data/ is, and until 2026-09-27 left with whatever permissions its
87
125
  * parent folder had. Narrowed the same way, and pruned to the configured retention.
88
126
  */
89
- const cfg = await ops.settings.load();
90
127
  await mkdir(cfg.resolved.runsDir, { recursive: true });
91
128
  const runsAcl = secureDataDir(cfg.resolved.runsDir);
92
129
  if (!runsAcl.ok) {
@@ -101,14 +138,40 @@ export async function startApi(opts = {}) {
101
138
  const pruned = await pruneRuns(cfg.resolved.runsDir, cfg.runsRetentionDays);
102
139
  if (pruned.length > 0)
103
140
  log(` removed ${pruned.length} run folder(s) older than ${cfg.runsRetentionDays} days (runsRetentionDays)`);
104
- if (layout.webDir) {
105
- log(`copilot-operator is running for ${layout.projectRoot}`);
106
- log(` open http://127.0.0.1:${PORT}/ in your browser`);
107
- log(` records are kept in ${layout.homeDir}`);
108
- }
109
- else {
110
- log(`copilot-operator api listening on http://127.0.0.1:${PORT}/api (web origin ${WEB_ORIGIN})`);
111
- log(` requests need the token in ${join(dataDir, 'api-token')}; npm start hands it to the UI`);
112
- }
141
+ const lines = startupLines({
142
+ served: webDir !== null,
143
+ mode: layout.mode,
144
+ port: PORT,
145
+ webOrigin: WEB_ORIGIN,
146
+ projectRoot: layout.projectRoot,
147
+ homeDir: layout.homeDir,
148
+ tokenFile: join(dataDir, 'api-token'),
149
+ });
150
+ for (const line of lines)
151
+ log(line);
113
152
  return { app, port: PORT, token, dataDir, close: () => app.close() };
114
153
  }
154
+ /**
155
+ * What the start-up tells the operator to open. Said by what is served, not by what the layout
156
+ * found, so the lines match the guard's choice in `startApi`.
157
+ *
158
+ * A clone's API serves no page, and `cop start` run in a clone ends up here as well as `npm start`
159
+ * does, so a clone's lines name the dev server that holds its interface. Without that, the only
160
+ * address in sight was this port, which answers a browser with a 401.
161
+ */
162
+ export function startupLines(o) {
163
+ if (o.served) {
164
+ return [
165
+ `copilot-operator is running for ${o.projectRoot}`,
166
+ ` open http://127.0.0.1:${o.port}/ in your browser`,
167
+ ` records are kept in ${o.homeDir}`,
168
+ ];
169
+ }
170
+ const lines = [
171
+ `copilot-operator api listening on http://127.0.0.1:${o.port}/api (web origin ${o.webOrigin})`,
172
+ ` requests need the token in ${o.tokenFile}; npm start hands it to the UI`,
173
+ ];
174
+ if (o.mode === 'checkout')
175
+ lines.push(` this port serves no web interface: a clone's is the dev server npm start runs, at ${o.webOrigin}`);
176
+ return lines;
177
+ }
@@ -4,10 +4,83 @@
4
4
  * The UI edits this file through the API. Anything not present falls back to the schema
5
5
  * defaults, so an empty file is a valid, safe configuration.
6
6
  */
7
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
8
- import { existsSync } from 'node:fs';
7
+ import { mkdir } from 'node:fs/promises';
9
8
  import { join, dirname } from 'node:path';
10
- import { loadConfigObject } from '../config/schema.js';
9
+ import { loadConfigObject, RunConfigSchema } from '../config/schema.js';
10
+ import { readHandWritten } from '../config/handWritten.js';
11
+ import { writeFileAtomically } from '../session/store.js';
12
+ /**
13
+ * A settings file that is there and cannot be used: it cannot be read, it is not JSON, it is not an
14
+ * object of settings, or the schema refuses what it holds.
15
+ *
16
+ * Its own class because it is not the fault of the request that meets it, and every request that
17
+ * reads the settings meets it, not only the saves. As a plain error it was "Internal server error"
18
+ * on the Defaults page, the Project page and the model picker, the very pages the operator opens to
19
+ * find out what is wrong. The server answers this one the same way on every route, with this
20
+ * message (see `server.ts`), and `cop doctor` says it too, since the API will not start on it.
21
+ */
22
+ export class SettingsUnusableError extends Error {
23
+ path;
24
+ problem;
25
+ constructor(path,
26
+ /** What is wrong, said of the file: "is not valid JSON (…)". */
27
+ problem) {
28
+ // What every refusal says, so the way out is in the same message.
29
+ super(`${path} ${problem}. Mend it, or delete it to start again from the defaults; nothing has been saved over it.`);
30
+ this.path = path;
31
+ this.problem = problem;
32
+ this.name = 'SettingsUnusableError';
33
+ }
34
+ }
35
+ /**
36
+ * The text of a settings file as settings: `{}` when it is empty, an error when it is not an object
37
+ * of settings. The one reading of the file, for the API and for `cop doctor`, so the two cannot
38
+ * disagree about whether it is broken.
39
+ *
40
+ * A file that is there but cannot be read as settings is an error, never `{}`. Read as empty, it
41
+ * was written over by the next save of one setting — choosing a default model rewrote it as
42
+ * `{copilot: {defaultModel}}` — and everything else the operator had set was gone, without a word
43
+ * or a copy, while the program ran on the defaults in the meantime: another runs folder, no project,
44
+ * the stock limits.
45
+ */
46
+ export function parseSettings(text, path) {
47
+ // A file saved by hand in Notepad or Windows PowerShell may start with a byte-order mark, which
48
+ // JSON.parse refuses; it is not part of the settings, and the file is not broken. Read through
49
+ // `readHandWritten`, the mark has gone already and said how the file is encoded, UTF-16 included;
50
+ // a text handed here with the mark still on it is read the same.
51
+ const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
52
+ if (body.trim() === '')
53
+ return {};
54
+ let value;
55
+ try {
56
+ value = JSON.parse(body);
57
+ }
58
+ catch (e) {
59
+ throw new SettingsUnusableError(path, `is not valid JSON (${e.message})`);
60
+ }
61
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
62
+ throw new SettingsUnusableError(path, 'does not hold an object of settings');
63
+ }
64
+ return value;
65
+ }
66
+ /**
67
+ * Refuses settings the schema does not allow, as a problem of the file they were read from. For
68
+ * what is on disk, never for a request's own edit, which is the request's problem (see `save`).
69
+ */
70
+ export function checkSettings(value, path) {
71
+ const parsed = RunConfigSchema.safeParse(value);
72
+ if (parsed.success)
73
+ return;
74
+ const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
75
+ throw new SettingsUnusableError(path, `holds settings that are not allowed (${issues})`);
76
+ }
77
+ /**
78
+ * The settings of this install, with the defaults its layout implies: the one way they are built,
79
+ * for the API and for `cop doctor`, so the doctor reads them as the API that it vouches for will.
80
+ */
81
+ export function settingsOf(layout) {
82
+ return new Settings(layout.projectRoot, layout.dataDir, layout.mode === 'package' ? { runsDir: layout.runsDir, level1File: join(layout.promptsDir, 'level1.md') } : {});
83
+ }
11
84
  export class Settings {
12
85
  projectRoot;
13
86
  dataDir;
@@ -25,27 +98,95 @@ export class Settings {
25
98
  this.installDefaults = installDefaults;
26
99
  this.path = join(dataDir, 'settings.json');
27
100
  }
28
- /** The raw object as saved, or `{}` when there is no file yet. */
101
+ /**
102
+ * The raw object as saved, or `{}` when there is no file yet (or an empty one). A file that is
103
+ * there and is not settings is refused by name (see `parseSettings`) until the operator mends it
104
+ * or deletes it.
105
+ */
29
106
  async raw() {
30
- if (!existsSync(this.path))
31
- return {};
107
+ let text;
32
108
  try {
33
- return JSON.parse(await readFile(this.path, 'utf8'));
109
+ text = await readHandWritten(this.path);
34
110
  }
35
- catch {
36
- return {};
111
+ catch (e) {
112
+ if (e.code === 'ENOENT')
113
+ return {};
114
+ throw new SettingsUnusableError(this.path, `could not be read (${e.message})`);
37
115
  }
116
+ return parseSettings(text, this.path);
38
117
  }
39
- /** Parsed, defaulted and resolved against the project root. */
118
+ /**
119
+ * Parsed, defaulted and resolved against the project root.
120
+ *
121
+ * A file whose values the schema refuses stops here with the reason, as it always has; it is the
122
+ * file's problem, not the request's, so it is refused as one (see `SettingsUnusableError`).
123
+ * Anything else that stops the load is not this file's, and goes on as it is: a policy lock that
124
+ * does not parse is a `PolicyLockUnusableError`, answered by name on every route as this one is.
125
+ */
40
126
  async load() {
41
- const raw = await this.raw();
42
- return await loadConfigObject({ ...this.installDefaults, ...raw, dataDir: this.dataDir }, this.projectRoot, this.path);
127
+ const value = { ...this.installDefaults, ...(await this.raw()), dataDir: this.dataDir };
128
+ try {
129
+ return await loadConfigObject(value, this.projectRoot, this.path);
130
+ }
131
+ catch (e) {
132
+ checkSettings(value, this.path);
133
+ throw e;
134
+ }
43
135
  }
44
- /** Validates before saving, so a bad edit is rejected rather than stored. */
136
+ /**
137
+ * Validates before saving, so a bad edit is rejected rather than stored.
138
+ *
139
+ * A file on disk that cannot be read is not written over either, even by a whole set of
140
+ * settings: the page that sends one built it on a copy read before the file broke, and whatever
141
+ * the operator has put in the file since, by hand, would go without a word, which is the silent
142
+ * replacement `raw` refuses, by another route.
143
+ */
45
144
  async save(value) {
46
- const resolved = await loadConfigObject({ ...this.installDefaults, ...value, dataDir: this.dataDir }, this.projectRoot, this.path);
145
+ return await this.write(() => value, 'replaces');
146
+ }
147
+ /**
148
+ * Changes part of the settings: `change` is given what the file holds and returns all of it, as
149
+ * it is to be saved.
150
+ *
151
+ * For a save of one setting (the default model, the review model, the project), which must keep
152
+ * every other. The read and the write are one turn on the file, so two such saves at once each
153
+ * change what the other left: read before the turn, both read the same file, and the one written
154
+ * second put back the setting the first had just changed, though both answered 200. `change` is
155
+ * synchronous so it cannot wait for the file's turn from inside it; it may throw to refuse, and
156
+ * then nothing is written.
157
+ */
158
+ async update(change) {
159
+ return await this.write(change, 'keeps');
160
+ }
161
+ /**
162
+ * The one way the file is written: read, changed, validated and replaced in the file's turn (see
163
+ * `writeFileAtomically`). The read refuses a file that cannot be read, so nothing is ever saved
164
+ * over one. The write is atomic, because a crash in the middle of a plain write is one way a
165
+ * settings file stops parsing.
166
+ */
167
+ async write(next, rest) {
47
168
  await mkdir(dirname(this.path), { recursive: true });
48
- await writeFile(this.path, JSON.stringify(value, null, 2), 'utf8');
169
+ let resolved;
170
+ await writeFileAtomically(this.path, async () => {
171
+ const raw = await this.raw();
172
+ const value = next(raw);
173
+ try {
174
+ resolved = await loadConfigObject({ ...this.installDefaults, ...value, dataDir: this.dataDir }, this.projectRoot, this.path);
175
+ }
176
+ catch (e) {
177
+ /*
178
+ * A save of one setting carries every other over from the file, so a value the schema
179
+ * refuses that was in the file already is refused again here. That is the file's problem,
180
+ * as `load` says of it, not the request's: it was answered as a bad request that blamed the
181
+ * new default model, without the way out, while a read of the same file named it. A whole
182
+ * set replaces what the file holds, and what the schema refuses in it is the request's own.
183
+ */
184
+ if (rest === 'keeps')
185
+ checkSettings({ ...this.installDefaults, ...raw, dataDir: this.dataDir }, this.path);
186
+ throw e;
187
+ }
188
+ return JSON.stringify(value, null, 2);
189
+ });
49
190
  return resolved;
50
191
  }
51
192
  /** The schema defaults, for the UI to show what "not set" means. */