@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.
@@ -1,8 +1,39 @@
1
- import { lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
1
+ import { link, lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, unlink, writeFile, } from 'node:fs/promises';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
+ import { hash as hashing } from '@dokebi/core/libraries';
3
4
  import { debug, debugEnabled, describeError, reportError } from './diagnostics.js';
4
5
  import { CliError } from './errors.js';
5
- import { exists, fingerprint, json, securePath } from './filesystem.js';
6
+ import { exists, fingerprint, hash, json, securePath } from './filesystem.js';
7
+ /**
8
+ * What is at `path` now, without following a symlink: null when nothing is there, `file:<sha256>`
9
+ * for a file, `directory:<dev>:<ino>` for a folder (its identity: a folder's content is not
10
+ * hashed), `symlink` or `other`.
11
+ */
12
+ async function observe(path) {
13
+ let stat;
14
+ try {
15
+ stat = await lstat(path);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ return null;
20
+ throw error;
21
+ }
22
+ if (stat.isSymbolicLink())
23
+ return 'symlink';
24
+ if (stat.isDirectory())
25
+ return `directory:${stat.dev}:${stat.ino}`;
26
+ if (!stat.isFile())
27
+ return 'other';
28
+ return `file:${await hashing.sha256File(path)}`;
29
+ }
30
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EMLINK', 'EXDEV']);
31
+ /** Why `now` is not the `installed` fingerprint (both as `observe` writes them). */
32
+ function differs(installed, now) {
33
+ if (now === 'symlink')
34
+ return 'symlink';
35
+ return installed?.startsWith('file:') && now.startsWith('file:') ? 'modified' : 'replaced';
36
+ }
6
37
  export class Transaction {
7
38
  root;
8
39
  directory;
@@ -70,13 +101,17 @@ export class Transaction {
70
101
  async write(path, content) {
71
102
  const source = join(this.directory, `new-${this.#changes.length}`);
72
103
  await writeFile(source, content, { mode: 0o600 });
73
- await this.replace(path, source);
104
+ await this.#add(path, source, `file:${hash(content)}`);
74
105
  }
106
+ /** Puts `source` (a file or folder) at `path` on commit; without `source`, removes `path`. */
75
107
  async replace(path, source) {
108
+ await this.#add(path, source);
109
+ }
110
+ async #add(path, source, installs) {
76
111
  if (this.#changes.some((change) => change.path === path))
77
112
  throw new CliError('DUPLICATE_CHANGE', path);
78
113
  await this.capture(path);
79
- this.#changes.push(source === undefined ? { path } : { path, source });
114
+ this.#changes.push(source === undefined ? { path } : installs ? { path, source, installs } : { path, source });
80
115
  }
81
116
  get paths() {
82
117
  return this.#changes.map((change) => change.path);
@@ -84,6 +119,11 @@ export class Transaction {
84
119
  /**
85
120
  * Applies every change; `afterApply` runs once they are all in place, and when it fails the
86
121
  * changes are rolled back like a failed apply (D-0035: the workspace npm install).
122
+ *
123
+ * The rollback only undoes what is still this commit's own (ISSUE-0006 A06): `afterApply` can
124
+ * run for minutes while the user edits files. A path that is no longer what this commit put
125
+ * there (or, for a removed path, where something appeared) is left as it is, its original stays
126
+ * in the transaction folder, and the commit ends with RECOVERY_REQUIRED naming those paths.
87
127
  */
88
128
  async commit(beforeApply, afterApply) {
89
129
  for (const [path, expected] of this.#snapshots) {
@@ -91,13 +131,20 @@ export class Transaction {
91
131
  throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
92
132
  }
93
133
  }
94
- const journal = this.#changes.map((change, index) => ({
95
- ...change,
96
- backup: join(this.directory, `old-${index}`),
97
- existed: this.#snapshots.get(change.path) !== null,
98
- backedUp: false,
99
- installed: false,
100
- }));
134
+ const journal = [];
135
+ for (const [index, change] of this.#changes.entries()) {
136
+ // What this change installs, to recognise it again in a rollback: a written file's hash, a
137
+ // staged file's hash or a staged folder's identity (node_modules).
138
+ const installs = change.installs ?? (change.source ? await observe(change.source) : null) ?? undefined;
139
+ journal.push({
140
+ ...change,
141
+ ...(installs ? { installs } : {}),
142
+ backup: join(this.directory, `old-${index}`),
143
+ existed: this.#snapshots.get(change.path) !== null,
144
+ backedUp: false,
145
+ installed: false,
146
+ });
147
+ }
101
148
  await writeFile(join(this.directory, 'journal.json'), json(journal));
102
149
  try {
103
150
  for (const [index, change] of journal.entries()) {
@@ -134,39 +181,140 @@ export class Transaction {
134
181
  changes: journal.length,
135
182
  error: describeError(cause),
136
183
  });
137
- const failures = [];
138
- for (const change of [...journal].reverse()) {
184
+ await this.#rollback(journal, cause);
185
+ }
186
+ }
187
+ async #rollback(journal, cause) {
188
+ const failures = [];
189
+ const conflicts = [];
190
+ for (const [index, change] of [...journal.entries()].reverse()) {
191
+ try {
192
+ const conflict = await this.#undo(change, index);
193
+ if (conflict) {
194
+ debug('Rollback kept a path changed during the commit', { ...conflict });
195
+ conflicts.push(conflict);
196
+ }
197
+ }
198
+ catch (error) {
199
+ failures.push(error);
200
+ }
201
+ }
202
+ if (failures.length || conflicts.length) {
203
+ this.#keep = true;
204
+ const directory = relative(this.root, this.directory);
205
+ const kept = conflicts.length
206
+ ? `kept ${conflicts.length} path(s) changed during the operation: ${conflicts
207
+ .map((item) => `${item.path} (${item.reason})`)
208
+ .join(', ')}; `
209
+ : '';
210
+ const failed = failures.length ? `${failures.length} step(s) failed; ` : '';
211
+ throw new CliError('RECOVERY_REQUIRED', `Rollback ${kept}${failed}preserve ${directory} and .dokebi/lock`, {
212
+ cause: failures.length ? new AggregateError([cause, ...failures]) : cause,
213
+ phase: 'commit',
214
+ details: { conflicts, transaction: directory },
215
+ });
216
+ }
217
+ throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
218
+ }
219
+ /**
220
+ * Undoes one change unless the path is no longer this commit's own: then leaves it and says
221
+ * why. The look and the removal cannot be one atomic step; the window is kept to a rename
222
+ * (see `#undo`'s move-aside) and documented in docs/cli/README.md.
223
+ */
224
+ async #undo(change, index) {
225
+ const backup = change.backedUp ? { backup: relative(this.root, change.backup) } : {};
226
+ let target;
227
+ try {
228
+ // The parent folders, never following a symlink; the path itself is looked at below.
229
+ const parent = dirname(change.path);
230
+ const folder = parent === '.' ? this.root : await securePath(this.root, parent);
231
+ target = join(folder, basename(change.path));
232
+ }
233
+ catch (error) {
234
+ const code = error instanceof CliError ? error.code : undefined;
235
+ if (code !== 'UNSAFE_PATH' && code !== 'PATH_CONFLICT')
236
+ throw error;
237
+ return {
238
+ path: change.path,
239
+ reason: code === 'UNSAFE_PATH' ? 'unsafe-path' : 'path-conflict',
240
+ ...backup,
241
+ };
242
+ }
243
+ if (change.kind === 'directory') {
244
+ if (!change.installed)
245
+ return undefined;
246
+ try {
247
+ // rmdir removes only an empty folder and never follows a symlink.
248
+ await rmdir(target);
249
+ }
250
+ catch (error) {
251
+ const code = error.code;
252
+ if (code === 'ENOENT')
253
+ return undefined;
254
+ if (code === 'ENOTEMPTY' || code === 'EEXIST')
255
+ return { path: change.path, reason: 'not-empty' };
256
+ if (code === 'ENOTDIR')
257
+ return { path: change.path, reason: 'replaced' };
258
+ throw error;
259
+ }
260
+ return undefined;
261
+ }
262
+ if (change.installed) {
263
+ const now = await observe(target);
264
+ if (now !== null) {
265
+ if (now !== change.installs)
266
+ return { path: change.path, reason: differs(change.installs, now), ...backup };
267
+ // Move it aside (atomic), then look again: an edit between the look and the move is
268
+ // found here and put back, never deleted.
269
+ const aside = join(this.directory, `rollback-${index}`);
139
270
  try {
140
- const target = await securePath(this.root, change.path);
141
- if (change.kind === 'directory') {
142
- if (change.installed) {
143
- try {
144
- await rmdir(target);
145
- }
146
- catch (error) {
147
- if (error.code !== 'ENOENT')
148
- throw error;
149
- }
150
- }
151
- continue;
152
- }
153
- if (change.installed)
154
- await rm(target, { recursive: true, force: true });
155
- if (change.backedUp)
156
- await rename(change.backup, target);
271
+ await rename(target, aside);
157
272
  }
158
273
  catch (error) {
159
- failures.push(error);
274
+ if (error.code !== 'ENOENT')
275
+ throw error;
160
276
  }
277
+ const moved = await observe(aside);
278
+ if (moved !== null && moved !== change.installs) {
279
+ const restored = await this.#place(aside, target);
280
+ return {
281
+ path: change.path,
282
+ reason: differs(change.installs, moved),
283
+ ...backup,
284
+ ...(restored ? {} : { kept: relative(this.root, aside) }),
285
+ };
286
+ }
287
+ if (moved !== null)
288
+ await rm(aside, { recursive: true, force: true });
161
289
  }
162
- if (failures.length) {
163
- this.#keep = true;
164
- throw new CliError('RECOVERY_REQUIRED', `Rollback failed; preserve ${this.directory} and .dokebi/lock`, {
165
- cause: new AggregateError([cause, ...failures]),
166
- });
167
- }
168
- throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
169
290
  }
291
+ if (change.backedUp && !(await this.#place(change.backup, target)))
292
+ return { path: change.path, reason: 'created', ...backup };
293
+ return undefined;
294
+ }
295
+ /**
296
+ * Moves `from` to `to` without replacing anything at `to`: false when something is there. A
297
+ * file goes by link + unlink (link fails on an existing name); a folder (or a file system
298
+ * without hard links) by rename after checking `to` is free, which leaves a tiny window.
299
+ */
300
+ async #place(from, to) {
301
+ try {
302
+ await link(from, to);
303
+ await unlink(from);
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ const code = error.code ?? '';
308
+ if (code === 'EEXIST')
309
+ return false;
310
+ if (!LINK_UNSUPPORTED.has(code))
311
+ throw error;
312
+ debug('Rollback restores by rename (no hard link)', { path: to, code });
313
+ }
314
+ if ((await observe(to)) !== null)
315
+ return false;
316
+ await rename(from, to);
317
+ return true;
170
318
  }
171
319
  async close() {
172
320
  if (this.#keep)
package/dist/verify.d.ts CHANGED
@@ -46,17 +46,26 @@ export interface HttpProbe {
46
46
  readonly ok: boolean;
47
47
  /** `other`: an answer without this run's token header — another server on the port. */
48
48
  readonly instance?: 'ours' | 'other';
49
+ /**
50
+ * The identity probe (no readiness route and no app domain): `GET /` passes with any status
51
+ * below 500 that carries this run's header.
52
+ */
53
+ readonly identity?: true;
49
54
  readonly error?: string;
50
55
  }
51
56
  /**
52
57
  * The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
53
58
  * answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
54
59
  * started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
60
+ * With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
61
+ * runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
62
+ * status below 500 that carries the token, a 404 included (the app may have no route at `/`).
63
+ * Each request must complete within `timeoutMs` (ISSUE-0006 A09).
55
64
  */
56
65
  export declare function httpChecks(base: string, paths: {
57
66
  ready: string | null;
58
67
  domains: readonly string[];
59
- }, token: string, delayMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
68
+ }, token: string, delayMs?: number, timeoutMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
60
69
  export declare function verify(root: string, options?: {
61
70
  waitMs?: number;
62
71
  }): Promise<VerifyResult>;
package/dist/verify.js CHANGED
@@ -61,10 +61,31 @@ export async function npmScript(root, script, timeoutMs) {
61
61
  export const VERIFY_TOKEN_ENV = 'DOKEBI_VERIFY_TOKEN';
62
62
  /** The response header @dokebi/web answers with when that variable is set. */
63
63
  export const VERIFY_HEADER = 'x-dokebi-verify';
64
- /** GET with a Host header (fetch cannot set one). */
64
+ /**
65
+ * GET with a Host header (fetch cannot set one). Settles once: with the answer when the body
66
+ * ended, or with an error when the request fails, the response is aborted or its connection
67
+ * closes before the end (ISSUE-0006 A09), or nothing complete came within `timeoutMs` in all
68
+ * (the socket timeout alone never fires for a server that keeps trickling bytes).
69
+ */
65
70
  function get(url, host, timeoutMs = 5000) {
66
71
  return new Promise((resolve, reject) => {
67
72
  const target = new URL(url);
73
+ let settled = false;
74
+ const finish = (error, answer) => {
75
+ if (settled)
76
+ return;
77
+ settled = true;
78
+ clearTimeout(deadline);
79
+ if (error) {
80
+ // Frees the socket of a response that will never complete.
81
+ if (!req.destroyed)
82
+ req.destroy();
83
+ reject(error);
84
+ }
85
+ else
86
+ resolve(answer);
87
+ };
88
+ const failed = (error) => finish(error);
68
89
  const req = request({
69
90
  host: target.hostname.replace(/^\[|\]$/g, ''),
70
91
  port: target.port,
@@ -73,14 +94,23 @@ function get(url, host, timeoutMs = 5000) {
73
94
  timeout: timeoutMs,
74
95
  }, (response) => {
75
96
  response.resume();
76
- response.once('end', () => resolve({
97
+ response.once('end', () => finish(undefined, {
77
98
  status: response.statusCode ?? 0,
78
99
  type: String(response.headers['content-type'] ?? ''),
79
100
  instance: response.headers[VERIFY_HEADER],
80
101
  }));
102
+ response.once('aborted', () => finish(new Error('The response was aborted')));
103
+ // Error listeners stay after settling: a late error (the destroy below) is then ignored
104
+ // by `finish` instead of being an unhandled 'error' event.
105
+ response.on('error', failed);
106
+ response.once('close', () => {
107
+ if (!response.complete)
108
+ finish(new Error('The connection closed before the response ended'));
109
+ });
81
110
  });
111
+ const deadline = setTimeout(() => finish(new Error(`No complete answer within ${timeoutMs} ms`)), timeoutMs);
82
112
  req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
83
- req.once('error', reject);
113
+ req.on('error', failed);
84
114
  req.end();
85
115
  });
86
116
  }
@@ -123,18 +153,30 @@ async function settings(root) {
123
153
  * The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
124
154
  * answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
125
155
  * started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
156
+ * With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
157
+ * runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
158
+ * status below 500 that carries the token, a 404 included (the app may have no route at `/`).
159
+ * Each request must complete within `timeoutMs` (ISSUE-0006 A09).
126
160
  */
127
- export async function httpChecks(base, paths, token, delayMs = 250) {
161
+ export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs = 5000) {
128
162
  const requests = [];
129
- const probe = async (path, host, attempts) => {
163
+ const probe = async (path, host, attempts, identity = false) => {
130
164
  const url = `${base}${path}`;
165
+ const kind = identity ? { identity: true } : {};
131
166
  for (let attempt = 1;; attempt++) {
132
167
  try {
133
- const answer = await get(url, host);
168
+ const answer = await get(url, host, timeoutMs);
134
169
  const instance = answer.instance === token ? 'ours' : 'other';
135
- const ok = answer.status >= 200 && answer.status < 400 && instance === 'ours';
170
+ const ok = answer.status >= 200 && answer.status < (identity ? 500 : 400) && instance === 'ours';
136
171
  if (ok || attempt >= attempts) {
137
- requests.push({ url, ...(host ? { host } : {}), status: answer.status, ok, instance });
172
+ requests.push({
173
+ url,
174
+ ...(host ? { host } : {}),
175
+ status: answer.status,
176
+ ok,
177
+ instance,
178
+ ...kind,
179
+ });
138
180
  return;
139
181
  }
140
182
  if (instance === 'other')
@@ -151,6 +193,7 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
151
193
  status: 0,
152
194
  ok: false,
153
195
  error: error instanceof Error ? error.message : String(error),
196
+ ...kind,
154
197
  });
155
198
  return;
156
199
  }
@@ -163,6 +206,10 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
163
206
  await probe(paths.ready, undefined, 20);
164
207
  for (const domain of paths.domains)
165
208
  await probe('/', domain, 4);
209
+ if (!paths.ready && !paths.domains.length) {
210
+ debug('verify: no readiness route and no app domain; probing GET / for this run', { base });
211
+ await probe('/', undefined, 20, true);
212
+ }
166
213
  const failed = requests.filter((item) => !item.ok);
167
214
  const foreign = failed.filter((item) => item.instance === 'other');
168
215
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Project scaffolding, plugin catalog, recipes, install and verification CLI for Dokebi.",
5
5
  "keywords": [
6
6
  "dokebi",