@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
|
@@ -26,8 +26,13 @@ import { debug, describeError, reportError } from './diagnostics.js';
|
|
|
26
26
|
export const CONTROL_HOST = '127.0.0.1';
|
|
27
27
|
const HELLO = 'dokebi-log-writer';
|
|
28
28
|
const VERSION = 1;
|
|
29
|
-
/** A longer line is not this protocol. */
|
|
29
|
+
/** A longer line (in bytes, without its newline) is not this protocol. */
|
|
30
30
|
const MAX_LINE = 4096;
|
|
31
|
+
/** Messages (and their bytes) received but not yet read; the protocol needs one at a time. */
|
|
32
|
+
const MAX_QUEUED = 2;
|
|
33
|
+
const MAX_QUEUED_BYTES = MAX_QUEUED * MAX_LINE;
|
|
34
|
+
const NEWLINE = 0x0a;
|
|
35
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
31
36
|
/** An unauthenticated connection is closed after this long. */
|
|
32
37
|
const HANDSHAKE_MS = 5000;
|
|
33
38
|
/** The longest stop a client may ask for (one day). */
|
|
@@ -43,17 +48,30 @@ function matches(given, expected) {
|
|
|
43
48
|
const b = Buffer.from(expected);
|
|
44
49
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
45
50
|
}
|
|
46
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Newline-delimited JSON over a socket: read one message at a time, write messages.
|
|
53
|
+
*
|
|
54
|
+
* ISSUE-0006 A05: the limits hold whatever the TCP chunking. Bytes are split on `\n` as they
|
|
55
|
+
* come; every line (complete or still open) is held to MAX_LINE bytes before it is decoded or
|
|
56
|
+
* parsed, and a line is taken only while the peer may send one: `expected` messages at first,
|
|
57
|
+
* then the `replies` each `send` asks for. Anything more is a protocol violation: the queue is
|
|
58
|
+
* dropped (nothing queued is acted on), next() rejects and the connection is destroyed.
|
|
59
|
+
*/
|
|
47
60
|
class LineSocket {
|
|
48
61
|
socket;
|
|
49
|
-
|
|
62
|
+
/** The bytes of the line still open (no newline yet). */
|
|
63
|
+
#pending = [];
|
|
64
|
+
#pendingBytes = 0;
|
|
50
65
|
#queue = [];
|
|
66
|
+
#queuedBytes = 0;
|
|
67
|
+
/** How many more messages the peer may send at this step of the protocol. */
|
|
68
|
+
#allowance;
|
|
51
69
|
#waiting;
|
|
52
70
|
#failure;
|
|
53
71
|
closed;
|
|
54
|
-
constructor(socket) {
|
|
72
|
+
constructor(socket, expected) {
|
|
55
73
|
this.socket = socket;
|
|
56
|
-
|
|
74
|
+
this.#allowance = expected;
|
|
57
75
|
socket.on('data', (chunk) => this.#data(chunk));
|
|
58
76
|
// Reported to whoever reads next (next() rejects); the socket closes after an error.
|
|
59
77
|
socket.on('error', (error) => this.#fail(error));
|
|
@@ -61,28 +79,64 @@ class LineSocket {
|
|
|
61
79
|
void this.closed.then(() => this.#fail(new Error('connection closed')));
|
|
62
80
|
}
|
|
63
81
|
#data(chunk) {
|
|
64
|
-
this.#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
if (this.#failure)
|
|
83
|
+
return;
|
|
84
|
+
let start = 0;
|
|
85
|
+
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
|
|
86
|
+
const piece = chunk.subarray(start, end);
|
|
87
|
+
start = end + 1;
|
|
88
|
+
const bytes = this.#pendingBytes + piece.length;
|
|
89
|
+
if (!this.#acceptable(bytes))
|
|
90
|
+
return;
|
|
91
|
+
const line = this.#pendingBytes ? Buffer.concat([...this.#pending, piece]) : piece;
|
|
92
|
+
this.#pending = [];
|
|
93
|
+
this.#pendingBytes = 0;
|
|
94
|
+
if (this.#queue.length >= MAX_QUEUED || this.#queuedBytes + bytes > MAX_QUEUED_BYTES) {
|
|
95
|
+
this.#violation('too many messages waiting');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
68
98
|
let message;
|
|
69
99
|
try {
|
|
70
|
-
message = JSON.parse(line);
|
|
100
|
+
message = JSON.parse(UTF8.decode(line));
|
|
71
101
|
}
|
|
72
102
|
catch (error) {
|
|
73
|
-
this.#
|
|
74
|
-
this.socket.destroy();
|
|
103
|
+
this.#violation(`not a protocol message: ${describeError(error).split('\n')[0]}`);
|
|
75
104
|
return;
|
|
76
105
|
}
|
|
77
|
-
this.#
|
|
106
|
+
this.#allowance--;
|
|
107
|
+
this.#queue.push({ message, bytes });
|
|
108
|
+
this.#queuedBytes += bytes;
|
|
78
109
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
82
|
-
|
|
110
|
+
const rest = chunk.subarray(start);
|
|
111
|
+
if (rest.length) {
|
|
112
|
+
if (!this.#acceptable(this.#pendingBytes + rest.length))
|
|
113
|
+
return;
|
|
114
|
+
this.#pending.push(rest);
|
|
115
|
+
this.#pendingBytes += rest.length;
|
|
83
116
|
}
|
|
84
117
|
this.#deliver();
|
|
85
118
|
}
|
|
119
|
+
/** A line of `bytes` so far may go on: a message is expected and the line is short enough. */
|
|
120
|
+
#acceptable(bytes) {
|
|
121
|
+
if (this.#allowance <= 0) {
|
|
122
|
+
this.#violation('a message the protocol does not expect at this step');
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (bytes > MAX_LINE) {
|
|
126
|
+
this.#violation(`a line longer than ${MAX_LINE} bytes`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
#violation(reason) {
|
|
132
|
+
debug('Control connection broke the protocol', { reason });
|
|
133
|
+
this.#queue = [];
|
|
134
|
+
this.#queuedBytes = 0;
|
|
135
|
+
this.#pending = [];
|
|
136
|
+
this.#pendingBytes = 0;
|
|
137
|
+
this.#fail(new Error(reason));
|
|
138
|
+
this.socket.destroy();
|
|
139
|
+
}
|
|
86
140
|
#fail(error) {
|
|
87
141
|
this.#failure ??= error;
|
|
88
142
|
this.#deliver();
|
|
@@ -92,7 +146,13 @@ class LineSocket {
|
|
|
92
146
|
if (!waiting || (!this.#queue.length && !this.#failure))
|
|
93
147
|
return;
|
|
94
148
|
this.#waiting = undefined;
|
|
95
|
-
|
|
149
|
+
const next = this.#queue.shift();
|
|
150
|
+
if (next) {
|
|
151
|
+
this.#queuedBytes -= next.bytes;
|
|
152
|
+
waiting(next.message);
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
waiting(this.#failure);
|
|
96
156
|
}
|
|
97
157
|
/** The next message; rejects when the connection fails or closes first, or after `ms`. */
|
|
98
158
|
next(ms) {
|
|
@@ -113,7 +173,9 @@ class LineSocket {
|
|
|
113
173
|
this.#deliver();
|
|
114
174
|
});
|
|
115
175
|
}
|
|
116
|
-
send(
|
|
176
|
+
/** Sends `message`; the peer may then send `replies` messages (the protocol's next step). */
|
|
177
|
+
send(message, replies = 0) {
|
|
178
|
+
this.#allowance += replies;
|
|
117
179
|
if (!this.socket.destroyed)
|
|
118
180
|
this.socket.write(`${JSON.stringify(message)}\n`);
|
|
119
181
|
}
|
|
@@ -178,7 +240,8 @@ export class ControlServer {
|
|
|
178
240
|
#connection(socket) {
|
|
179
241
|
this.#sockets.add(socket);
|
|
180
242
|
socket.once('close', () => this.#sockets.delete(socket));
|
|
181
|
-
|
|
243
|
+
// The client speaks only after the hello (which allows its proof).
|
|
244
|
+
const lines = new LineSocket(socket, 0);
|
|
182
245
|
void this.#serve(lines).catch((error) => {
|
|
183
246
|
// A client that broke the protocol or went silent: nothing was done for it.
|
|
184
247
|
reportError('CONTROL_REQUEST_FAILED', 'A control request failed', error, 5000);
|
|
@@ -187,7 +250,7 @@ export class ControlServer {
|
|
|
187
250
|
}
|
|
188
251
|
async #serve(lines) {
|
|
189
252
|
const challenge = randomBytes(24).toString('base64url');
|
|
190
|
-
lines.send({ hello: HELLO, version: VERSION, challenge });
|
|
253
|
+
lines.send({ hello: HELLO, version: VERSION, challenge }, 1);
|
|
191
254
|
const hello = await lines.next(HANDSHAKE_MS);
|
|
192
255
|
if (!matches(hello.answer, controlAnswer(this.token, 'client', challenge))) {
|
|
193
256
|
debug('Control client refused: wrong instance proof');
|
|
@@ -208,7 +271,7 @@ export class ControlServer {
|
|
|
208
271
|
answer: controlAnswer(this.token, 'writer', hello.challenge),
|
|
209
272
|
writer: process.pid,
|
|
210
273
|
app: { pid: app.pid, running: app.running() },
|
|
211
|
-
});
|
|
274
|
+
}, 1);
|
|
212
275
|
const request = await lines.next(HANDSHAKE_MS);
|
|
213
276
|
if (request.op === 'status') {
|
|
214
277
|
lines.send({ ok: true, app: { pid: app.pid, running: app.running() } });
|
|
@@ -266,7 +329,8 @@ export async function askLogWriter(port, token, request,
|
|
|
266
329
|
stepMs = 3000) {
|
|
267
330
|
const path = `${CONTROL_HOST}:${port}`;
|
|
268
331
|
const socket = createConnection({ host: CONTROL_HOST, port });
|
|
269
|
-
|
|
332
|
+
// The writer greets first: one message expected.
|
|
333
|
+
const lines = new LineSocket(socket, 1);
|
|
270
334
|
const unreached = (reason) => {
|
|
271
335
|
socket.destroy();
|
|
272
336
|
debug('Log writer not reached', { path, reason });
|
|
@@ -291,7 +355,7 @@ stepMs = 3000) {
|
|
|
291
355
|
if (hello.hello !== HELLO || typeof hello.challenge !== 'string')
|
|
292
356
|
return unreached(`${path} is not a dokebi log writer`);
|
|
293
357
|
const challenge = randomBytes(24).toString('base64url');
|
|
294
|
-
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) });
|
|
358
|
+
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) }, 1);
|
|
295
359
|
const proof = await lines.next(stepMs);
|
|
296
360
|
if (proof.code === 'NOT_READY')
|
|
297
361
|
return unreached('the log writer has not started the app yet');
|
|
@@ -303,7 +367,7 @@ stepMs = 3000) {
|
|
|
303
367
|
if (typeof app?.pid !== 'number' || typeof proof.writer !== 'number')
|
|
304
368
|
return unreached('the log writer sent an unexpected answer');
|
|
305
369
|
debug('Log writer proved the instance token', { path, writer: proof.writer, op: request.op });
|
|
306
|
-
lines.send(request);
|
|
370
|
+
lines.send(request, 1);
|
|
307
371
|
const replyMs = request.op === 'stop' ? request.timeoutMs + (request.force ? 5000 : 0) + stepMs : stepMs;
|
|
308
372
|
const reply = await lines.next(replyMs);
|
|
309
373
|
return {
|
package/dist/npm-client.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface NpmRun {
|
|
|
17
17
|
/** The last NPM_OUTPUT_TAIL_BYTES bytes of each stream, redacted. */
|
|
18
18
|
readonly stderr: string;
|
|
19
19
|
readonly stdout: string;
|
|
20
|
+
/** npm's error code, summary, log file and report file from the whole stderr. */
|
|
21
|
+
readonly notes: ReturnType<NpmFailureNotes['details']>;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Runs npm in `directory` (the npm that started this CLI when there is one, else `npm` on PATH)
|
|
@@ -54,6 +56,23 @@ export declare class OutputTail {
|
|
|
54
56
|
push(chunk: Buffer): void;
|
|
55
57
|
text(): string;
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* What npm says about a failure, read from its whole stderr (not only the bounded tail, which a
|
|
61
|
+
* long ERESOLVE explanation fills): the error code (`npm error code ERESOLVE`), the line after it
|
|
62
|
+
* (the summary), the debug log (`A complete log of this run can be found in: …`) and the report
|
|
63
|
+
* file (`For a full report see:`). Values are redacted; lines are bounded.
|
|
64
|
+
*/
|
|
65
|
+
export declare class NpmFailureNotes {
|
|
66
|
+
#private;
|
|
67
|
+
push(chunk: Buffer): void;
|
|
68
|
+
/** The notes found so far, as NPM_FAILED details (absent ones are left out). */
|
|
69
|
+
details(): {
|
|
70
|
+
npmCode?: string;
|
|
71
|
+
npmSummary?: string;
|
|
72
|
+
logFile?: string;
|
|
73
|
+
reportFile?: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
57
76
|
/**
|
|
58
77
|
* Removes credentials npm may print: `user:password@` in URLs, `_authToken=`/`_auth=`/
|
|
59
78
|
* `_password=` values (npmrc lines), bearer tokens and npm access tokens (`npm_…`).
|
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-guide.js
CHANGED
|
@@ -133,7 +133,8 @@ Run the command-line tools installed in this project by their bin names from the
|
|
|
133
133
|
- **Refuse input with \`throw validationError({ field: 'message' })\`** (\`import { validationError } from '@dokebi/web'\`), not with your own \`reply.code(422).send(...)\`. Clients then get exactly what a refused route schema gives them: 422 \`VALIDATION_FAILED\` with \`fields: [{ field, message }]\` and \`errors: { field: message }\`. Other refusals throw a \`DokebiError\` with a \`status\` (403, 404, 409…).
|
|
134
134
|
- **JSON or HTML is decided for you**: Inertia requests, \`fetch\`/XHR, Bearer API clients and JSON bodies get JSON errors; only plain browser pages (a navigation, an HTML form post) get an HTML page. Read errors from the JSON (\`body.errors.name\`); do not parse HTML.
|
|
135
135
|
- **Inertia forms** (\`useForm().post/put/patch/delete\`, \`router.post/…\`): pass \`only\` with the props the submit changes, for example \`form.post(url, { only: ['items'] })\`. With @dokebi/session a validation error goes back (303) with the messages in \`form.errors.<field>\` (\`errors\` always comes along); without \`only\` the GET after the redirect sends every prop of the page again. \`npx dokebi check\` warns about a submit without \`only\` (\`inertia-form-only\`).
|
|
136
|
-
- **
|
|
136
|
+
- **EJS (HTML) forms come back with their errors**: with @dokebi/session (\`npx dokebi add @dokebi/session\`) an HTML form post refused with 422 goes back (303) to the page it came from, and every EJS page has \`data.errors\` (\`{ field: message }\`), \`data.old\` (the typed values, never passwords) and \`data.flash\` (other flash, for example \`data.flash.notice\`), always objects. Write each field as \`<input name="title" value="<%= data.old.title ?? '' %>">\` with \`<% if (data.errors.title) { %><small><%= data.errors.title %></small><% } %>\`. Do not call \`request.session.takeFlash()\` in a route that renders a page (the page takes the flash). Without a session the refused form shows \`errors/422.ejs\` (else \`errors/error.ejs\`) with \`fields\`, \`errors\` and \`old\`. HTML forms need \`dokebi-web.formBody: true\`.
|
|
137
|
+
- **Error pages**: an EJS app shows \`views/<template>/errors/<status>.ejs\` (then \`errors/error.ejs\`) for browser pages, with \`status\`, \`error\`, \`message\`, \`hint\`, \`fields\`, \`errors\` and \`old\`; an Inertia app may set \`inertia: { errorPage: 'Error' }\` to render its \`Error\` page component.
|
|
137
138
|
|
|
138
139
|
## Installed packages
|
|
139
140
|
|
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/recipe.js
CHANGED
|
@@ -602,9 +602,17 @@ function nextSteps(catalog, recipe, answers, plan, port) {
|
|
|
602
602
|
why: 'a refused field comes back to the form (422 → 303 back with errors, @dokebi/session); only keeps the GET after the redirect to the props that changed (npx dokebi check warns: inertia-form-only)',
|
|
603
603
|
}
|
|
604
604
|
: {
|
|
605
|
-
command:
|
|
606
|
-
why:
|
|
605
|
+
command: 'edit: every write form — <form method="post">, each field value="<%= data.old.<field> ?? \'\' %>" and its message from data.errors.<field>; in the handler throw validationError({ <field>: \'<message>\' }) from @dokebi/web',
|
|
606
|
+
why: plan.some((step) => step.id === 'session')
|
|
607
|
+
? 'a refused route schema and the app check give the same 422 VALIDATION_FAILED (D-0074); with @dokebi/session (in this plan) the refused form comes back to its page (303) with data.errors and data.old; never send a hand-made 422'
|
|
608
|
+
: 'a refused route schema and the app check give the same 422 VALIDATION_FAILED (D-0074); never send a hand-made 422. Without a session the refused form shows views/default/errors/422.ejs (else errors/error.ejs) with fields, errors and old',
|
|
607
609
|
});
|
|
610
|
+
// D-0074 addendum 1: an EJS write form comes back to its page only with a session.
|
|
611
|
+
if (recipe.web.forms && !plan.some((step) => step.id === 'view-plugin' || step.id === 'session'))
|
|
612
|
+
result.push({
|
|
613
|
+
command: 'npx dokebi add @dokebi/session',
|
|
614
|
+
why: 'so a refused HTML form goes back to its page with the typed values and the field messages (D-0074 addendum 1) instead of the 422 page; ask the user first (it writes DOKEBI_SESSION_SECRETS into .env and keeps sessions in memory until another store is configured)',
|
|
615
|
+
});
|
|
608
616
|
const absent = catalog.absent.map((entry) => entry.capability).join(', ');
|
|
609
617
|
result.push({
|
|
610
618
|
command: 'npx dokebi plugins search <feature> --json',
|
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,
|