@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/AI.md +7 -7
- package/README.md +1 -1
- package/catalog.json +24 -20
- package/dist/bin.js +9 -0
- package/dist/guide.js +2 -2
- package/dist/install-module.d.ts +9 -1
- package/dist/install-module.js +97 -5
- package/dist/log-writer-control.js +89 -25
- package/dist/npm-client.d.ts +19 -0
- package/dist/npm-client.js +68 -1
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/plugin-lifecycle.js +15 -8
- package/dist/project-guide.js +2 -1
- package/dist/project.js +10 -3
- package/dist/recipe.js +10 -2
- package/dist/server.d.ts +7 -0
- package/dist/server.js +189 -51
- package/dist/transaction.d.ts +20 -0
- package/dist/transaction.js +187 -39
- package/dist/verify.d.ts +10 -1
- package/dist/verify.js +55 -8
- package/package.json +1 -1
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
930
|
-
|
|
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;
|
package/dist/transaction.d.ts
CHANGED
|
@@ -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>;
|
package/dist/transaction.js
CHANGED
|
@@ -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
|
|
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 =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|