@dokebi/cli 0.4.0 → 0.4.2

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/server.js CHANGED
@@ -27,7 +27,7 @@
27
27
  import { execFile, spawn } from 'node:child_process';
28
28
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
29
29
  import { existsSync, readFileSync, watch } from 'node:fs';
30
- import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
30
+ import { mkdir, open, readdir, readFile, rename, rm, stat, utimes, writeFile, } from 'node:fs/promises';
31
31
  import { request as httpRequest } from 'node:http';
32
32
  import { request as httpsRequest } from 'node:https';
33
33
  import { basename, dirname, join } from 'node:path';
@@ -70,6 +70,145 @@ function daemonOnly() {
70
70
  if (process.platform === 'win32')
71
71
  throw new CliError('UNSUPPORTED_PLATFORM', 'Background start and stop need POSIX signals; on Windows use dokebi dev or dokebi start --foreground');
72
72
  }
73
+ /** A start lock not refreshed for this long belongs to a start that died. */
74
+ const LOCK_STALE_MS = 60_000;
75
+ /** A start in progress refreshes its lock this often, however long it waits. */
76
+ const LOCK_REFRESH_MS = 10_000;
77
+ /** Breaking a stale lock takes milliseconds; a guard this old belongs to a process that died. */
78
+ const BREAK_STALE_MS = 10_000;
79
+ /**
80
+ * The start lock `.dokebi/run/start.lock` (R3). One start at a time holds it from before it
81
+ * reads the running state until its pid file is written, and `status`/`stop` take it to remove a
82
+ * stale pid file, so a pid file is only ever replaced or removed by the lock holder. It is
83
+ * created with `wx`; the holder refreshes it while it waits, so a lock older than LOCK_STALE_MS
84
+ * belongs to a start that died and is broken (see breakStale) before trying again.
85
+ */
86
+ class StartLock {
87
+ file;
88
+ timer;
89
+ constructor(file) {
90
+ this.file = file;
91
+ this.timer = setInterval(() => {
92
+ const now = new Date();
93
+ utimes(file, now, now).catch((error) => {
94
+ reportError('START_LOCK_REFRESH_FAILED', 'Unable to refresh the start lock', error);
95
+ });
96
+ }, LOCK_REFRESH_MS);
97
+ this.timer.unref();
98
+ }
99
+ /** The lock, or undefined while another start holds it. */
100
+ static async acquire(file) {
101
+ for (let attempt = 0; attempt < 5; attempt++) {
102
+ try {
103
+ await writeFile(file, String(process.pid), { flag: 'wx' });
104
+ debug('Start lock acquired', { file: basename(file) });
105
+ return new StartLock(file);
106
+ }
107
+ catch (error) {
108
+ if (error.code !== 'EEXIST')
109
+ throw error;
110
+ }
111
+ let held;
112
+ try {
113
+ held = await stat(file);
114
+ }
115
+ catch (error) {
116
+ if (!isMissing(error))
117
+ throw error;
118
+ debug('Start lock released meanwhile; trying again', { attempt });
119
+ continue;
120
+ }
121
+ if (Date.now() - held.mtimeMs < LOCK_STALE_MS)
122
+ return undefined;
123
+ if (!(await StartLock.breakStale(file, held)))
124
+ return undefined;
125
+ }
126
+ debug('Start lock still contended after several attempts');
127
+ return undefined;
128
+ }
129
+ /**
130
+ * Removes the stale lock `held` under the guard `<lock>.break` (created with `wx`), so only one
131
+ * process breaks it and never a lock another start created after `held` was read (same inode
132
+ * and time, checked under the guard). False when another process is breaking it right now.
133
+ * A guard older than BREAK_STALE_MS is left by a process that died within these few steps.
134
+ */
135
+ static async breakStale(file, held) {
136
+ const guard = `${file}.break`;
137
+ try {
138
+ await writeFile(guard, String(process.pid), { flag: 'wx' });
139
+ }
140
+ catch (error) {
141
+ if (error.code !== 'EEXIST')
142
+ throw error;
143
+ let since;
144
+ try {
145
+ since = Date.now() - (await stat(guard)).mtimeMs;
146
+ }
147
+ catch (cause) {
148
+ if (!isMissing(cause))
149
+ throw cause;
150
+ debug('The stale start lock was broken meanwhile; trying again');
151
+ return true;
152
+ }
153
+ if (since < BREAK_STALE_MS)
154
+ return false;
155
+ debug('Removing the guard of a lock break that died', { ageMs: Math.round(since) });
156
+ await rm(guard, { force: true });
157
+ return true;
158
+ }
159
+ try {
160
+ let now;
161
+ try {
162
+ now = await stat(file);
163
+ }
164
+ catch (error) {
165
+ if (!isMissing(error))
166
+ throw error;
167
+ return true;
168
+ }
169
+ if (now.ino === held.ino && now.mtimeMs === held.mtimeMs) {
170
+ await rm(file, { force: true });
171
+ debug('Removed a stale start lock', { ageMs: Math.round(Date.now() - now.mtimeMs) });
172
+ }
173
+ return true;
174
+ }
175
+ finally {
176
+ await rm(guard, { force: true });
177
+ }
178
+ }
179
+ async release() {
180
+ clearInterval(this.timer);
181
+ await rm(this.file, { force: true });
182
+ debug('Start lock released', { file: basename(this.file) });
183
+ }
184
+ }
185
+ /**
186
+ * Removes the pid file when it still holds `record` (R3): under the start lock, so a start that
187
+ * wrote a newer record meanwhile keeps it. While a start holds the lock it is left to that start,
188
+ * which replaces the file anyway. `locked` is true when the caller holds the lock already.
189
+ */
190
+ async function removeRecord(root, record, locked = false) {
191
+ const files = paths(root);
192
+ const lock = locked ? undefined : await StartLock.acquire(files.lock);
193
+ if (!locked && !lock) {
194
+ debug('A start holds the lock; leaving the pid file to it', { pid: record.pid });
195
+ return;
196
+ }
197
+ try {
198
+ const current = await readRecord(root);
199
+ if (current &&
200
+ (current.pid !== record.pid ||
201
+ current.startedAt !== record.startedAt ||
202
+ current.token !== record.token)) {
203
+ debug('The pid file names a newer start; keeping it', { pid: record.pid, now: current.pid });
204
+ return;
205
+ }
206
+ await rm(files.pid, { force: true });
207
+ }
208
+ finally {
209
+ await lock?.release();
210
+ }
211
+ }
73
212
  async function readRecord(root) {
74
213
  let text;
75
214
  try {
@@ -118,6 +257,9 @@ async function procInfo(pid) {
118
257
  ]);
119
258
  // Fields after the parenthesised command name: state is field 3, starttime field 22.
120
259
  const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
260
+ // A zombie has exited and only waits for its parent to reap it: gone for every caller here.
261
+ if (fields[0] === 'Z')
262
+ return undefined;
121
263
  const ticks = Number(fields[19]);
122
264
  const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
123
265
  if (!Number.isFinite(ticks) || !Number.isFinite(boot))
@@ -129,14 +271,22 @@ async function procInfo(pid) {
129
271
  };
130
272
  }
131
273
  catch (error) {
132
- // ENOENT: no such process. Anything else (permissions, a changed /proc) is a failure.
133
274
  if (error instanceof CliError)
134
275
  throw error;
135
- if (isMissing(error))
276
+ if (procGone(error))
136
277
  return undefined;
137
278
  throw processInfoFailed(pid, '/proc', error);
138
279
  }
139
280
  }
281
+ /**
282
+ * Whether a /proc read failed because the process is gone: ENOENT (no /proc/<pid>), or ESRCH,
283
+ * which Linux returns when the process exits while one of its files is open. Anything else
284
+ * (permissions, a changed /proc) is a failure. ESRCH was found by the v0.4.1 tag pipeline: an
285
+ * app that stopped during `stop`'s check ended as PROCESS_INFO_FAILED.
286
+ */
287
+ export function procGone(error) {
288
+ return isMissing(error) || error?.code === 'ESRCH';
289
+ }
140
290
  const readProcess = async (pid) => {
141
291
  if (process.platform === 'linux')
142
292
  return procInfo(pid);
@@ -210,7 +360,11 @@ export async function status(root,
210
360
  read = readProcess,
211
361
  /** Tests replace how a process is signalled; the CLI uses process.kill. */
212
362
  kill = signalProcess) {
213
- const { pid, log } = paths(root);
363
+ return statusOf(root, read, kill, false);
364
+ }
365
+ /** `status`; `locked` when the caller (start) holds the start lock. */
366
+ async function statusOf(root, read, kill, locked) {
367
+ const { log } = paths(root);
214
368
  const record = await readRecord(root);
215
369
  const logs = await logInfo(root);
216
370
  if (!record)
@@ -228,7 +382,7 @@ kill = signalProcess) {
228
382
  proof = found;
229
383
  }
230
384
  if (!info && proof === 'process') {
231
- await rm(pid, { force: true });
385
+ await removeRecord(root, record, locked);
232
386
  return {
233
387
  running: false,
234
388
  stale: true,
@@ -528,48 +682,34 @@ check = checkProject) {
528
682
  const waitMs = options.waitMs ?? 10_000;
529
683
  const files = paths(root);
530
684
  const began = performance.now();
531
- const current = await status(root);
532
- if (current.running)
533
- throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
534
- details: current,
535
- });
536
- const entry = await check(root);
537
- // The project's .env under the CLI's environment (D-0073); a malformed file stops here.
538
- const token = randomBytes(24).toString('base64url');
539
- const env = appEnvironment(root, 'production', {
540
- ...options.env,
541
- [INSTANCE_TOKEN_ENV]: token,
542
- });
543
685
  await mkdir(files.run, { recursive: true });
544
686
  await writeFile(join(files.run, '.gitignore'), '*\n');
545
- // Lines this start writes begin here (the web listening line is parsed from them).
546
- const logOffset = await stat(files.log).then((info) => info.size, (error) => {
547
- if (!isMissing(error))
548
- throw error;
549
- return 0;
550
- });
551
- // One start at a time; a lock left by a start that died more than a minute ago is stale.
552
- try {
553
- await writeFile(files.lock, String(process.pid), { flag: 'wx' });
554
- }
555
- catch (error) {
556
- if (error.code !== 'EEXIST')
557
- throw error;
558
- const age = Date.now() -
559
- (await stat(files.lock).catch((cause) => {
560
- // The other start removed its lock meanwhile: the lock counts as stale.
561
- if (!isMissing(cause))
562
- throw cause;
563
- return { mtimeMs: 0 };
564
- })).mtimeMs;
565
- if (age < 60_000)
566
- throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
567
- debug('Replacing a stale start lock', { ageMs: age });
568
- await writeFile(files.lock, String(process.pid));
569
- }
687
+ // One start at a time (R3): the lock is taken before the running state is read, so a start
688
+ // that waited (in its checks) while another start finished sees that start's app.
689
+ const lock = await StartLock.acquire(files.lock);
690
+ if (!lock)
691
+ throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
570
692
  const warnings = [];
571
693
  let writer;
572
694
  try {
695
+ const current = await statusOf(root, readProcess, signalProcess, true);
696
+ if (current.running)
697
+ throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
698
+ details: current,
699
+ });
700
+ const entry = await check(root);
701
+ // The project's .env under the CLI's environment (D-0073); a malformed file stops here.
702
+ const token = randomBytes(24).toString('base64url');
703
+ const env = appEnvironment(root, 'production', {
704
+ ...options.env,
705
+ [INSTANCE_TOKEN_ENV]: token,
706
+ });
707
+ // Lines this start writes begin here (the web listening line is parsed from them).
708
+ const logOffset = await stat(files.log).then((info) => info.size, (error) => {
709
+ if (!isMissing(error))
710
+ throw error;
711
+ return 0;
712
+ });
573
713
  // The writer's own diagnostics (it has no app logger) go to log-writer.log.
574
714
  const writerLog = await open(files.writerLog, 'a');
575
715
  try {
@@ -690,7 +830,7 @@ check = checkProject) {
690
830
  if (writer?.connected)
691
831
  writer.disconnect();
692
832
  writer?.unref();
693
- await rm(files.lock, { force: true });
833
+ await lock.release();
694
834
  }
695
835
  }
696
836
  /**
@@ -763,7 +903,6 @@ read = readProcess,
763
903
  /** Tests replace how a process is signalled; the CLI uses process.kill. */
764
904
  kill = signalProcess) {
765
905
  daemonOnly();
766
- const files = paths(root);
767
906
  const record = await readRecord(root);
768
907
  if (!record)
769
908
  return { stopped: false };
@@ -785,7 +924,7 @@ kill = signalProcess) {
785
924
  }
786
925
  if (!found) {
787
926
  debug('Removing a stale pid file', { pid: record.pid });
788
- await rm(files.pid, { force: true });
927
+ await removeRecord(root, record);
789
928
  return { stopped: false, pid: record.pid, stale: true };
790
929
  }
791
930
  if (proof === 'log-writer')
@@ -829,7 +968,7 @@ kill = signalProcess) {
829
968
  if (!(await gone(app, 5000)))
830
969
  throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
831
970
  }
832
- await rm(files.pid, { force: true });
971
+ await removeRecord(root, record);
833
972
  const warnings = [];
834
973
  // The app is stopped and its pid file removed: a failure to read the writer is a warning.
835
974
  const writerGone = await gone(writer, 5000).catch((error) => {
@@ -857,7 +996,6 @@ kill = signalProcess) {
857
996
  * the connection). An unreachable or unproven writer leaves everything as it was.
858
997
  */
859
998
  async function stopThroughWriter(root, record, timeoutMs, force, proof, refused) {
860
- const files = paths(root);
861
999
  const began = performance.now();
862
1000
  const asked = await askWriter(record, { op: 'stop', timeoutMs, force });
863
1001
  if (!asked.reached)
@@ -877,7 +1015,7 @@ async function stopThroughWriter(root, record, timeoutMs, force, proof, refused)
877
1015
  ? reply.message
878
1016
  : `The log writer could not stop pid ${record.pid} (${String(reply.code)})`);
879
1017
  }
880
- await rm(files.pid, { force: true });
1018
+ await removeRecord(root, record);
881
1019
  if (reply.stopped !== true) {
882
1020
  debug('The log writer reports that the app had already exited', { pid: record.pid });
883
1021
  await asked.closed(0);
@@ -923,11 +1061,11 @@ function pidExists(pid, kill = signalProcess) {
923
1061
  */
924
1062
  export async function stopStarted(root, started, timeoutMs = 30_000, force = false) {
925
1063
  daemonOnly();
926
- const files = paths(root);
927
1064
  const forget = async () => {
928
1065
  // Only the pid file of this start: another start may have replaced it meanwhile.
929
- if ((await readRecord(root))?.pid === started.pid)
930
- await rm(files.pid, { force: true });
1066
+ const current = await readRecord(root);
1067
+ if (current?.pid === started.pid)
1068
+ await removeRecord(root, current);
931
1069
  };
932
1070
  const gone = async (pid, limit) => {
933
1071
  const until = performance.now() + limit;
@@ -1,3 +1,17 @@
1
+ /**
2
+ * A path the rollback left as it found it, because it is no longer what this commit put there
3
+ * (ISSUE-0006 A06): `modified` (other content), `replaced` (a directory or special file now),
4
+ * `symlink`, `created` (something appeared where the original goes back), `not-empty` (a folder
5
+ * this commit created now holds files), `unsafe-path` / `path-conflict` (a parent folder became a
6
+ * symlink or a file). `backup` is the original, kept in the transaction folder; `kept` is where a
7
+ * file changed during the rollback itself was left.
8
+ */
9
+ export interface RollbackConflict {
10
+ readonly path: string;
11
+ readonly reason: 'modified' | 'replaced' | 'symlink' | 'created' | 'not-empty' | 'unsafe-path' | 'path-conflict';
12
+ readonly backup?: string;
13
+ readonly kept?: string;
14
+ }
1
15
  export declare class Transaction {
2
16
  #private;
3
17
  readonly root: string;
@@ -8,11 +22,17 @@ export declare class Transaction {
8
22
  ensureDirectory(path: string): Promise<void>;
9
23
  read(path: string): Promise<string>;
10
24
  write(path: string, content: string | Uint8Array): Promise<void>;
25
+ /** Puts `source` (a file or folder) at `path` on commit; without `source`, removes `path`. */
11
26
  replace(path: string, source?: string): Promise<void>;
12
27
  get paths(): string[];
13
28
  /**
14
29
  * Applies every change; `afterApply` runs once they are all in place, and when it fails the
15
30
  * changes are rolled back like a failed apply (D-0035: the workspace npm install).
31
+ *
32
+ * The rollback only undoes what is still this commit's own (ISSUE-0006 A06): `afterApply` can
33
+ * run for minutes while the user edits files. A path that is no longer what this commit put
34
+ * there (or, for a removed path, where something appeared) is left as it is, its original stays
35
+ * in the transaction folder, and the commit ends with RECOVERY_REQUIRED naming those paths.
16
36
  */
17
37
  commit(beforeApply?: (index: number) => void, afterApply?: () => Promise<void>): Promise<void>;
18
38
  close(): Promise<void>;
@@ -1,8 +1,39 @@
1
- import { lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
1
+ import { link, lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, unlink, writeFile, } from 'node:fs/promises';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
+ import { hash as hashing } from '@dokebi/core/libraries';
3
4
  import { debug, debugEnabled, describeError, reportError } from './diagnostics.js';
4
5
  import { CliError } from './errors.js';
5
- import { exists, fingerprint, json, securePath } from './filesystem.js';
6
+ import { exists, fingerprint, hash, json, securePath } from './filesystem.js';
7
+ /**
8
+ * What is at `path` now, without following a symlink: null when nothing is there, `file:<sha256>`
9
+ * for a file, `directory:<dev>:<ino>` for a folder (its identity: a folder's content is not
10
+ * hashed), `symlink` or `other`.
11
+ */
12
+ async function observe(path) {
13
+ let stat;
14
+ try {
15
+ stat = await lstat(path);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ return null;
20
+ throw error;
21
+ }
22
+ if (stat.isSymbolicLink())
23
+ return 'symlink';
24
+ if (stat.isDirectory())
25
+ return `directory:${stat.dev}:${stat.ino}`;
26
+ if (!stat.isFile())
27
+ return 'other';
28
+ return `file:${await hashing.sha256File(path)}`;
29
+ }
30
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EMLINK', 'EXDEV']);
31
+ /** Why `now` is not the `installed` fingerprint (both as `observe` writes them). */
32
+ function differs(installed, now) {
33
+ if (now === 'symlink')
34
+ return 'symlink';
35
+ return installed?.startsWith('file:') && now.startsWith('file:') ? 'modified' : 'replaced';
36
+ }
6
37
  export class Transaction {
7
38
  root;
8
39
  directory;
@@ -70,13 +101,17 @@ export class Transaction {
70
101
  async write(path, content) {
71
102
  const source = join(this.directory, `new-${this.#changes.length}`);
72
103
  await writeFile(source, content, { mode: 0o600 });
73
- await this.replace(path, source);
104
+ await this.#add(path, source, `file:${hash(content)}`);
74
105
  }
106
+ /** Puts `source` (a file or folder) at `path` on commit; without `source`, removes `path`. */
75
107
  async replace(path, source) {
108
+ await this.#add(path, source);
109
+ }
110
+ async #add(path, source, installs) {
76
111
  if (this.#changes.some((change) => change.path === path))
77
112
  throw new CliError('DUPLICATE_CHANGE', path);
78
113
  await this.capture(path);
79
- this.#changes.push(source === undefined ? { path } : { path, source });
114
+ this.#changes.push(source === undefined ? { path } : installs ? { path, source, installs } : { path, source });
80
115
  }
81
116
  get paths() {
82
117
  return this.#changes.map((change) => change.path);
@@ -84,6 +119,11 @@ export class Transaction {
84
119
  /**
85
120
  * Applies every change; `afterApply` runs once they are all in place, and when it fails the
86
121
  * changes are rolled back like a failed apply (D-0035: the workspace npm install).
122
+ *
123
+ * The rollback only undoes what is still this commit's own (ISSUE-0006 A06): `afterApply` can
124
+ * run for minutes while the user edits files. A path that is no longer what this commit put
125
+ * there (or, for a removed path, where something appeared) is left as it is, its original stays
126
+ * in the transaction folder, and the commit ends with RECOVERY_REQUIRED naming those paths.
87
127
  */
88
128
  async commit(beforeApply, afterApply) {
89
129
  for (const [path, expected] of this.#snapshots) {
@@ -91,13 +131,20 @@ export class Transaction {
91
131
  throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
92
132
  }
93
133
  }
94
- const journal = this.#changes.map((change, index) => ({
95
- ...change,
96
- backup: join(this.directory, `old-${index}`),
97
- existed: this.#snapshots.get(change.path) !== null,
98
- backedUp: false,
99
- installed: false,
100
- }));
134
+ const journal = [];
135
+ for (const [index, change] of this.#changes.entries()) {
136
+ // What this change installs, to recognise it again in a rollback: a written file's hash, a
137
+ // staged file's hash or a staged folder's identity (node_modules).
138
+ const installs = change.installs ?? (change.source ? await observe(change.source) : null) ?? undefined;
139
+ journal.push({
140
+ ...change,
141
+ ...(installs ? { installs } : {}),
142
+ backup: join(this.directory, `old-${index}`),
143
+ existed: this.#snapshots.get(change.path) !== null,
144
+ backedUp: false,
145
+ installed: false,
146
+ });
147
+ }
101
148
  await writeFile(join(this.directory, 'journal.json'), json(journal));
102
149
  try {
103
150
  for (const [index, change] of journal.entries()) {
@@ -134,39 +181,140 @@ export class Transaction {
134
181
  changes: journal.length,
135
182
  error: describeError(cause),
136
183
  });
137
- const failures = [];
138
- for (const change of [...journal].reverse()) {
184
+ await this.#rollback(journal, cause);
185
+ }
186
+ }
187
+ async #rollback(journal, cause) {
188
+ const failures = [];
189
+ const conflicts = [];
190
+ for (const [index, change] of [...journal.entries()].reverse()) {
191
+ try {
192
+ const conflict = await this.#undo(change, index);
193
+ if (conflict) {
194
+ debug('Rollback kept a path changed during the commit', { ...conflict });
195
+ conflicts.push(conflict);
196
+ }
197
+ }
198
+ catch (error) {
199
+ failures.push(error);
200
+ }
201
+ }
202
+ if (failures.length || conflicts.length) {
203
+ this.#keep = true;
204
+ const directory = relative(this.root, this.directory);
205
+ const kept = conflicts.length
206
+ ? `kept ${conflicts.length} path(s) changed during the operation: ${conflicts
207
+ .map((item) => `${item.path} (${item.reason})`)
208
+ .join(', ')}; `
209
+ : '';
210
+ const failed = failures.length ? `${failures.length} step(s) failed; ` : '';
211
+ throw new CliError('RECOVERY_REQUIRED', `Rollback ${kept}${failed}preserve ${directory} and .dokebi/lock`, {
212
+ cause: failures.length ? new AggregateError([cause, ...failures]) : cause,
213
+ phase: 'commit',
214
+ details: { conflicts, transaction: directory },
215
+ });
216
+ }
217
+ throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
218
+ }
219
+ /**
220
+ * Undoes one change unless the path is no longer this commit's own: then leaves it and says
221
+ * why. The look and the removal cannot be one atomic step; the window is kept to a rename
222
+ * (see `#undo`'s move-aside) and documented in docs/cli/README.md.
223
+ */
224
+ async #undo(change, index) {
225
+ const backup = change.backedUp ? { backup: relative(this.root, change.backup) } : {};
226
+ let target;
227
+ try {
228
+ // The parent folders, never following a symlink; the path itself is looked at below.
229
+ const parent = dirname(change.path);
230
+ const folder = parent === '.' ? this.root : await securePath(this.root, parent);
231
+ target = join(folder, basename(change.path));
232
+ }
233
+ catch (error) {
234
+ const code = error instanceof CliError ? error.code : undefined;
235
+ if (code !== 'UNSAFE_PATH' && code !== 'PATH_CONFLICT')
236
+ throw error;
237
+ return {
238
+ path: change.path,
239
+ reason: code === 'UNSAFE_PATH' ? 'unsafe-path' : 'path-conflict',
240
+ ...backup,
241
+ };
242
+ }
243
+ if (change.kind === 'directory') {
244
+ if (!change.installed)
245
+ return undefined;
246
+ try {
247
+ // rmdir removes only an empty folder and never follows a symlink.
248
+ await rmdir(target);
249
+ }
250
+ catch (error) {
251
+ const code = error.code;
252
+ if (code === 'ENOENT')
253
+ return undefined;
254
+ if (code === 'ENOTEMPTY' || code === 'EEXIST')
255
+ return { path: change.path, reason: 'not-empty' };
256
+ if (code === 'ENOTDIR')
257
+ return { path: change.path, reason: 'replaced' };
258
+ throw error;
259
+ }
260
+ return undefined;
261
+ }
262
+ if (change.installed) {
263
+ const now = await observe(target);
264
+ if (now !== null) {
265
+ if (now !== change.installs)
266
+ return { path: change.path, reason: differs(change.installs, now), ...backup };
267
+ // Move it aside (atomic), then look again: an edit between the look and the move is
268
+ // found here and put back, never deleted.
269
+ const aside = join(this.directory, `rollback-${index}`);
139
270
  try {
140
- const target = await securePath(this.root, change.path);
141
- if (change.kind === 'directory') {
142
- if (change.installed) {
143
- try {
144
- await rmdir(target);
145
- }
146
- catch (error) {
147
- if (error.code !== 'ENOENT')
148
- throw error;
149
- }
150
- }
151
- continue;
152
- }
153
- if (change.installed)
154
- await rm(target, { recursive: true, force: true });
155
- if (change.backedUp)
156
- await rename(change.backup, target);
271
+ await rename(target, aside);
157
272
  }
158
273
  catch (error) {
159
- failures.push(error);
274
+ if (error.code !== 'ENOENT')
275
+ throw error;
160
276
  }
277
+ const moved = await observe(aside);
278
+ if (moved !== null && moved !== change.installs) {
279
+ const restored = await this.#place(aside, target);
280
+ return {
281
+ path: change.path,
282
+ reason: differs(change.installs, moved),
283
+ ...backup,
284
+ ...(restored ? {} : { kept: relative(this.root, aside) }),
285
+ };
286
+ }
287
+ if (moved !== null)
288
+ await rm(aside, { recursive: true, force: true });
161
289
  }
162
- if (failures.length) {
163
- this.#keep = true;
164
- throw new CliError('RECOVERY_REQUIRED', `Rollback failed; preserve ${this.directory} and .dokebi/lock`, {
165
- cause: new AggregateError([cause, ...failures]),
166
- });
167
- }
168
- throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
169
290
  }
291
+ if (change.backedUp && !(await this.#place(change.backup, target)))
292
+ return { path: change.path, reason: 'created', ...backup };
293
+ return undefined;
294
+ }
295
+ /**
296
+ * Moves `from` to `to` without replacing anything at `to`: false when something is there. A
297
+ * file goes by link + unlink (link fails on an existing name); a folder (or a file system
298
+ * without hard links) by rename after checking `to` is free, which leaves a tiny window.
299
+ */
300
+ async #place(from, to) {
301
+ try {
302
+ await link(from, to);
303
+ await unlink(from);
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ const code = error.code ?? '';
308
+ if (code === 'EEXIST')
309
+ return false;
310
+ if (!LINK_UNSUPPORTED.has(code))
311
+ throw error;
312
+ debug('Rollback restores by rename (no hard link)', { path: to, code });
313
+ }
314
+ if ((await observe(to)) !== null)
315
+ return false;
316
+ await rename(from, to);
317
+ return true;
170
318
  }
171
319
  async close() {
172
320
  if (this.#keep)
package/dist/verify.d.ts CHANGED
@@ -46,17 +46,26 @@ export interface HttpProbe {
46
46
  readonly ok: boolean;
47
47
  /** `other`: an answer without this run's token header — another server on the port. */
48
48
  readonly instance?: 'ours' | 'other';
49
+ /**
50
+ * The identity probe (no readiness route and no app domain): `GET /` passes with any status
51
+ * below 500 that carries this run's header.
52
+ */
53
+ readonly identity?: true;
49
54
  readonly error?: string;
50
55
  }
51
56
  /**
52
57
  * The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
53
58
  * answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
54
59
  * started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
60
+ * With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
61
+ * runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
62
+ * status below 500 that carries the token, a 404 included (the app may have no route at `/`).
63
+ * Each request must complete within `timeoutMs` (ISSUE-0006 A09).
55
64
  */
56
65
  export declare function httpChecks(base: string, paths: {
57
66
  ready: string | null;
58
67
  domains: readonly string[];
59
- }, token: string, delayMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
68
+ }, token: string, delayMs?: number, timeoutMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
60
69
  export declare function verify(root: string, options?: {
61
70
  waitMs?: number;
62
71
  }): Promise<VerifyResult>;