@dokebi/cli 0.4.1 → 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 +5 -5
- package/README.md +1 -1
- package/catalog.json +18 -18
- package/dist/bin.js +9 -0
- 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.js +10 -3
- 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/npm-client.js
CHANGED
|
@@ -110,6 +110,7 @@ export class NpmClient {
|
|
|
110
110
|
timedOut: result.timedOut,
|
|
111
111
|
stderr: result.stderr,
|
|
112
112
|
stdout: result.stdout,
|
|
113
|
+
...result.notes,
|
|
113
114
|
},
|
|
114
115
|
});
|
|
115
116
|
}
|
|
@@ -135,8 +136,12 @@ export function runNpm(directory, npmArgs, options) {
|
|
|
135
136
|
// npm's own words are the cause of a failure: keep the end of both streams (bounded).
|
|
136
137
|
const stdout = new OutputTail();
|
|
137
138
|
const stderr = new OutputTail();
|
|
139
|
+
const notes = new NpmFailureNotes();
|
|
138
140
|
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
139
|
-
child.stderr.on('data', (chunk) =>
|
|
141
|
+
child.stderr.on('data', (chunk) => {
|
|
142
|
+
stderr.push(chunk);
|
|
143
|
+
notes.push(chunk);
|
|
144
|
+
});
|
|
140
145
|
let timedOut = false;
|
|
141
146
|
const timer = setTimeout(() => {
|
|
142
147
|
timedOut = true;
|
|
@@ -166,6 +171,7 @@ export function runNpm(directory, npmArgs, options) {
|
|
|
166
171
|
timedOut,
|
|
167
172
|
stderr: stderr.text(),
|
|
168
173
|
stdout: stdout.text(),
|
|
174
|
+
notes: notes.details(),
|
|
169
175
|
});
|
|
170
176
|
});
|
|
171
177
|
});
|
|
@@ -220,6 +226,67 @@ export class OutputTail {
|
|
|
220
226
|
return redactNpmOutput(this.bytes > this.#buffer.length ? `…${text}` : text);
|
|
221
227
|
}
|
|
222
228
|
}
|
|
229
|
+
/** Longest stderr line NpmFailureNotes keeps while waiting for its end. */
|
|
230
|
+
const NOTE_LINE_BYTES = 2048;
|
|
231
|
+
/**
|
|
232
|
+
* What npm says about a failure, read from its whole stderr (not only the bounded tail, which a
|
|
233
|
+
* long ERESOLVE explanation fills): the error code (`npm error code ERESOLVE`), the line after it
|
|
234
|
+
* (the summary), the debug log (`A complete log of this run can be found in: …`) and the report
|
|
235
|
+
* file (`For a full report see:`). Values are redacted; lines are bounded.
|
|
236
|
+
*/
|
|
237
|
+
export class NpmFailureNotes {
|
|
238
|
+
#pending = '';
|
|
239
|
+
#code;
|
|
240
|
+
#summary;
|
|
241
|
+
#logFile;
|
|
242
|
+
#reportFile;
|
|
243
|
+
#afterCode = false;
|
|
244
|
+
#reportNext = false;
|
|
245
|
+
push(chunk) {
|
|
246
|
+
const lines = (this.#pending + chunk.toString('utf8')).split('\n');
|
|
247
|
+
this.#pending = (lines.pop() ?? '').slice(-NOTE_LINE_BYTES);
|
|
248
|
+
for (const line of lines)
|
|
249
|
+
this.#line(line.slice(0, NOTE_LINE_BYTES));
|
|
250
|
+
}
|
|
251
|
+
#line(raw) {
|
|
252
|
+
const text = raw.replace(/^npm (?:error|ERR!) ?/, '').trim();
|
|
253
|
+
if (text === raw.trim())
|
|
254
|
+
return;
|
|
255
|
+
const code = /^code (\S+)$/.exec(text);
|
|
256
|
+
if (code?.[1] && this.#code === undefined) {
|
|
257
|
+
this.#code = code[1];
|
|
258
|
+
this.#afterCode = true;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (this.#afterCode && text) {
|
|
262
|
+
this.#summary = text;
|
|
263
|
+
this.#afterCode = false;
|
|
264
|
+
}
|
|
265
|
+
if (this.#reportNext && text) {
|
|
266
|
+
this.#reportFile = text;
|
|
267
|
+
this.#reportNext = false;
|
|
268
|
+
}
|
|
269
|
+
const log = /A complete log of this run can be found in:\s*(\S+)/.exec(text);
|
|
270
|
+
if (log?.[1])
|
|
271
|
+
this.#logFile = log[1];
|
|
272
|
+
if (/^For a full report see:$/.test(text))
|
|
273
|
+
this.#reportNext = true;
|
|
274
|
+
}
|
|
275
|
+
/** The notes found so far, as NPM_FAILED details (absent ones are left out). */
|
|
276
|
+
details() {
|
|
277
|
+
if (this.#pending) {
|
|
278
|
+
this.#line(this.#pending);
|
|
279
|
+
this.#pending = '';
|
|
280
|
+
}
|
|
281
|
+
const redacted = (value) => value === undefined ? undefined : redactNpmOutput(value);
|
|
282
|
+
return Object.fromEntries(Object.entries({
|
|
283
|
+
npmCode: this.#code,
|
|
284
|
+
npmSummary: redacted(this.#summary),
|
|
285
|
+
logFile: redacted(this.#logFile),
|
|
286
|
+
reportFile: redacted(this.#reportFile),
|
|
287
|
+
}).filter(([, value]) => value !== undefined));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
223
290
|
/**
|
|
224
291
|
* Removes credentials npm may print: `user:password@` in URLs, `_authToken=`/`_auth=`/
|
|
225
292
|
* `_password=` values (npmrc lines), bearer tokens and npm access tokens (`npm_…`).
|
package/dist/output.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.4.
|
|
1
|
+
export declare const CLI_VERSION = "0.4.2";
|
|
2
2
|
export declare function envelope(command: string, result: unknown, ok?: boolean): object;
|
|
3
3
|
export declare function failure(command: string, error: unknown, mutationStarted: boolean): object;
|
package/dist/output.js
CHANGED
package/dist/plugin-lifecycle.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { debug, debugEnabled, describeError } from './diagnostics.js';
|
|
3
2
|
import { CliError } from './errors.js';
|
|
4
3
|
import { object } from './filesystem.js';
|
|
5
|
-
import { InstallPlanningError, PHASE_MISSING, runInstallModule, } from './install-module.js';
|
|
4
|
+
import { InstallPlanningError, installReadRoots, PHASE_MISSING, runInstallModule, } from './install-module.js';
|
|
6
5
|
import { readInstallPlan } from './install-plan.js';
|
|
7
6
|
import { assertLayout } from './layout.js';
|
|
8
7
|
import { ID } from './manifest.js';
|
|
9
|
-
import {
|
|
8
|
+
import { packageDirectory, packageFile } from './workspace.js';
|
|
10
9
|
// Printable single line: no control characters (log/terminal injection) and a short bound.
|
|
11
10
|
const HINT = /^[^\p{Cc}]{1,200}$/u;
|
|
12
11
|
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
@@ -75,11 +74,12 @@ async function run(input, phase, extra = {}) {
|
|
|
75
74
|
const modulePath = input.plugin.installModule;
|
|
76
75
|
if (!modulePath)
|
|
77
76
|
return PHASE_MISSING;
|
|
77
|
+
const directory = await packageDirectory(input.base, input.plugin.packageName);
|
|
78
78
|
const path = await packageFile(input.base, input.plugin.packageName, modulePath);
|
|
79
|
-
// A workspace member's packages live anywhere below the workspace root (D-0035).
|
|
80
|
-
const workspace = await workspaceRootOf(input.base);
|
|
81
|
-
const nodeModules = await realpath(workspace ?? join(input.base, 'node_modules'));
|
|
82
79
|
try {
|
|
80
|
+
// Only the plugin's package and its dependencies are readable (R9), never the project or
|
|
81
|
+
// the workspace root with their .env files.
|
|
82
|
+
const readRoots = await installReadRoots(directory);
|
|
83
83
|
return await runInstallModule(path, phase, {
|
|
84
84
|
options: optionsOf(input.options, input.plugin.id),
|
|
85
85
|
plugins: input.plugins.map(({ id, version, packageName }) => ({
|
|
@@ -89,9 +89,16 @@ async function run(input, phase, extra = {}) {
|
|
|
89
89
|
})),
|
|
90
90
|
projectPackages: [...input.projectPackages],
|
|
91
91
|
...extra,
|
|
92
|
-
},
|
|
92
|
+
}, readRoots);
|
|
93
93
|
}
|
|
94
94
|
catch (error) {
|
|
95
|
+
// The caller sees only the code; debug keeps the cause of an unexpected failure.
|
|
96
|
+
if (!(error instanceof InstallPlanningError) && debugEnabled())
|
|
97
|
+
debug('Install module could not be run', {
|
|
98
|
+
plugin: input.plugin.id,
|
|
99
|
+
phase,
|
|
100
|
+
error: describeError(error),
|
|
101
|
+
});
|
|
95
102
|
throw new CliError('PLUGIN_INSTALL_FAILED', input.plugin.id +
|
|
96
103
|
': ' +
|
|
97
104
|
(error instanceof InstallPlanningError ? error.code : 'INSTALL_MODULE_FAILED'), { details: { plugin: input.plugin.id, phase } });
|
package/dist/project.js
CHANGED
|
@@ -1596,9 +1596,16 @@ export class Project {
|
|
|
1596
1596
|
await this.npm.installWorkspace(workspace.root);
|
|
1597
1597
|
}
|
|
1598
1598
|
catch (cause) {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1599
|
+
// A rollback that kept user changes (RECOVERY_REQUIRED) stays in the cause and message.
|
|
1600
|
+
const recovery = error instanceof CliError && error.code === 'RECOVERY_REQUIRED' ? error : undefined;
|
|
1601
|
+
throw new CliError('WORKSPACE_SYNC_FAILED', recovery
|
|
1602
|
+
? `Project rollback needs recovery (${recovery.message}); then run npm install at the workspace root`
|
|
1603
|
+
: 'Project files were rolled back; run npm install at the workspace root', {
|
|
1604
|
+
cause: new AggregateError(recovery ? [recovery, failure, cause] : [failure, cause]),
|
|
1605
|
+
details: {
|
|
1606
|
+
workspaceRoot: workspace.root,
|
|
1607
|
+
...(recovery ? { recovery: recovery.details } : {}),
|
|
1608
|
+
},
|
|
1602
1609
|
});
|
|
1603
1610
|
}
|
|
1604
1611
|
throw error instanceof CliError && error.code === 'RECOVERY_REQUIRED' ? error : failure;
|
package/dist/server.d.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface ProcessInfo {
|
|
|
75
75
|
* be alive, so callers never treat that as a stale pid file.
|
|
76
76
|
*/
|
|
77
77
|
export type ProcessReader = (pid: number) => Promise<ProcessInfo | undefined>;
|
|
78
|
+
/**
|
|
79
|
+
* Whether a /proc read failed because the process is gone: ENOENT (no /proc/<pid>), or ESRCH,
|
|
80
|
+
* which Linux returns when the process exits while one of its files is open. Anything else
|
|
81
|
+
* (permissions, a changed /proc) is a failure. ESRCH was found by the v0.4.1 tag pipeline: an
|
|
82
|
+
* app that stopped during `stop`'s check ended as PROCESS_INFO_FAILED.
|
|
83
|
+
*/
|
|
84
|
+
export declare function procGone(error: unknown): boolean;
|
|
78
85
|
export declare function status(root: string,
|
|
79
86
|
/** Tests replace how a process is read; the CLI uses ps (/proc on Linux). */
|
|
80
87
|
read?: ProcessReader,
|
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>;
|