@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.
@@ -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
- /** Newline-delimited JSON over a socket: read one message at a time, write messages. */
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
- #buffer = '';
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
- socket.setEncoding('utf8');
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.#buffer += chunk;
65
- for (let end = this.#buffer.indexOf('\n'); end >= 0; end = this.#buffer.indexOf('\n')) {
66
- const line = this.#buffer.slice(0, end);
67
- this.#buffer = this.#buffer.slice(end + 1);
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.#fail(new Error(`not a protocol message: ${describeError(error).split('\n')[0]}`));
74
- this.socket.destroy();
103
+ this.#violation(`not a protocol message: ${describeError(error).split('\n')[0]}`);
75
104
  return;
76
105
  }
77
- this.#queue.push(message);
106
+ this.#allowance--;
107
+ this.#queue.push({ message, bytes });
108
+ this.#queuedBytes += bytes;
78
109
  }
79
- if (this.#buffer.length > MAX_LINE) {
80
- this.#fail(new Error(`a line longer than ${MAX_LINE} characters`));
81
- this.socket.destroy();
82
- return;
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
- waiting(this.#queue.length ? this.#queue.shift() : this.#failure);
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(message) {
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
- const lines = new LineSocket(socket);
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
- const lines = new LineSocket(socket);
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 {
@@ -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_…`).
@@ -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) => stderr.push(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.0";
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
@@ -1,5 +1,5 @@
1
1
  import { CliError } from './errors.js';
2
- export const CLI_VERSION = '0.4.0';
2
+ export const CLI_VERSION = '0.4.2';
3
3
  export function envelope(command, result, ok = true) {
4
4
  return { schemaVersion: 1, cliVersion: CLI_VERSION, command, ok, result, error: null };
5
5
  }
@@ -1,12 +1,11 @@
1
- import { realpath } from 'node:fs/promises';
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 { packageFile, workspaceRootOf } from './workspace.js';
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
- }, [nodeModules]);
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 } });
@@ -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
- - **Error pages**: an EJS app shows \`views/<template>/errors/<status>.ejs\` (then \`errors/error.ejs\`) for browser pages, with \`status\`, \`error\`, \`message\`, \`hint\` and \`fields\`; an Inertia app may set \`inertia: { errorPage: 'Error' }\` to render its \`Error\` page component.
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
- throw new CliError('WORKSPACE_SYNC_FAILED', 'Project files were rolled back; run npm install at the workspace root', {
1600
- cause: new AggregateError([failure, cause]),
1601
- details: { workspaceRoot: workspace.root },
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: "edit: every write form — in the handler throw validationError({ <field>: '<message>' }) from @dokebi/web; the app's views/default/errors/error.ejs lists the fields for an HTML form post, a fetch reads body.errors.<field>",
606
- why: 'a refused route schema and the app check give the same 422 VALIDATION_FAILED with fields and errors (D-0074); never send a hand-made 422',
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,