@dokebi/cli 0.2.3 → 0.2.4

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/dist/output.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CliError } from './errors.js';
2
- export const CLI_VERSION = '0.2.3';
2
+ export const CLI_VERSION = '0.2.4';
3
3
  export function envelope(command, result, ok = true) {
4
4
  return { schemaVersion: 1, cliVersion: CLI_VERSION, command, ok, result, error: null };
5
5
  }
@@ -5,6 +5,7 @@ import { CONFIG_FILE, LEGACY_CONFIG_FILE, parseProjectConfig } from './configura
5
5
  import { CliError } from './errors.js';
6
6
  import { exists, fingerprint, hash, json, parseObject, securePath } from './filesystem.js';
7
7
  import { legacyLayout } from './layout.js';
8
+ import { loggingDiagnostics } from './logging-config.js';
8
9
  import { orderPlugins, readManifest } from './manifest.js';
9
10
  import { dependencyMap, validateProjectPackage } from './npm-client.js';
10
11
  import { checkPlugin } from './plugin-lifecycle.js';
@@ -93,6 +94,14 @@ export class ProjectInspector {
93
94
  matches('@dokebi/core', core, null);
94
95
  if (pkg && !Object.hasOwn(pkg, '@dokebi/core'))
95
96
  issue('MISSING_CORE_DEPENDENCY', 'package.json');
97
+ if (config) {
98
+ // options.logging is the logger configuration (D-0047), not a plugin's options.
99
+ const known = new Set([
100
+ ...(state?.plugins.map((plugin) => plugin.id) ?? []),
101
+ ...result.requiredPlugins.map((plugin) => plugin.id),
102
+ ]);
103
+ result.diagnostics.push(...loggingDiagnostics(config.options, known));
104
+ }
96
105
  const generated = await check('src/dokebi.generated.ts', () => read('src/dokebi.generated.ts'));
97
106
  if (state && generated !== null && hash(generated) !== state.generatedHash)
98
107
  issue('MODIFIED_REGISTRATION', 'src/dokebi.generated.ts');
package/dist/project.js CHANGED
@@ -332,16 +332,19 @@ export class Project {
332
332
  if (await exists(await securePath(session.root, directory)))
333
333
  preserved.push(directory);
334
334
  }
335
+ const warnings = [];
335
336
  if (await exists(await securePath(session.root, EXAMPLE))) {
336
337
  const example = await tx.read(EXAMPLE);
338
+ let updated;
337
339
  try {
338
- const updated = markExampleRemoved(example, removed.id);
339
- if (updated !== example)
340
- await tx.write(EXAMPLE, updated);
340
+ updated = markExampleRemoved(example, removed.id);
341
341
  }
342
342
  catch {
343
- /* An invalid example file is reported by doctor, not by remove. */
343
+ // The removal still proceeds; the caller sees why the example was left as it was.
344
+ warnings.push({ code: 'EXAMPLE_INVALID', path: EXAMPLE });
344
345
  }
346
+ if (updated !== undefined && updated !== example)
347
+ await tx.write(EXAMPLE, updated);
345
348
  }
346
349
  state.plugins = ordered;
347
350
  state.installations = installations;
@@ -351,6 +354,7 @@ export class Project {
351
354
  changed: tx.paths,
352
355
  preserved,
353
356
  ...(configRequired.length ? { configRequired } : {}),
357
+ ...(warnings.length ? { warnings } : {}),
354
358
  };
355
359
  return await this.#finish(session, result, dryRun);
356
360
  }
@@ -0,0 +1,27 @@
1
+ /** `app.log`, then `app.log.1` … (newest first), for the files that exist without a gap. */
2
+ export declare function logFiles(file: string, limit?: number): string[];
3
+ /** Cuts a byte stream into whole lines; the unfinished end waits for the next chunk. */
4
+ export declare class LineBuffer {
5
+ #private;
6
+ /** The whole lines of what was received so far (undefined when there is none yet). */
7
+ push(chunk: Buffer): Buffer | undefined;
8
+ get pending(): boolean;
9
+ /** The unfinished end (a line without its newline yet), emptied. */
10
+ take(): Buffer;
11
+ }
12
+ export declare class RotatingLog {
13
+ #private;
14
+ readonly file: string;
15
+ readonly maxBytes: number;
16
+ readonly keep: number;
17
+ /** Bytes that could not be written (disk full, removed folder); reported, never silent. */
18
+ dropped: number;
19
+ constructor(file: string, maxBytes: number, keep: number);
20
+ /** Opens (appends to) the log; a file already over the limit is rotated first. */
21
+ open(): void;
22
+ /** Appends whole lines, rotating between lines when the limit would be passed. */
23
+ write(data: Buffer): void;
24
+ /** app.log → app.log.1, app.log.1 → app.log.2 …; the oldest beyond `keep` is removed. */
25
+ rotate(): void;
26
+ close(): void;
27
+ }
@@ -0,0 +1,166 @@
1
+ // Size-based rotation of the background app log (D-0053). The log writer process owns the file:
2
+ // `app.log` is written until the next lines would pass the limit, then it becomes `app.log.1`
3
+ // (older files shift up to `app.log.<keep>`, anything older is removed) and a new `app.log`
4
+ // starts. Files are cut between lines, so a line is never split across two files.
5
+ import { closeSync, existsSync, fstatSync, openSync, renameSync, rmSync, writeSync } from 'node:fs';
6
+ import { debug, isMissing, reportError } from './diagnostics.js';
7
+ /** `app.log`, then `app.log.1` … (newest first), for the files that exist without a gap. */
8
+ export function logFiles(file, limit = 1000) {
9
+ const files = [];
10
+ if (existsSync(file))
11
+ files.push(file);
12
+ for (let index = 1; index <= limit && existsSync(`${file}.${index}`); index++)
13
+ files.push(`${file}.${index}`);
14
+ return files;
15
+ }
16
+ const NEWLINE = 0x0a;
17
+ /** Partial lines longer than this are written without waiting for their end. */
18
+ const MAX_PARTIAL = 64 * 1024;
19
+ /** Cuts a byte stream into whole lines; the unfinished end waits for the next chunk. */
20
+ export class LineBuffer {
21
+ #partial = Buffer.alloc(0);
22
+ /** The whole lines of what was received so far (undefined when there is none yet). */
23
+ push(chunk) {
24
+ const data = this.#partial.length ? Buffer.concat([this.#partial, chunk]) : chunk;
25
+ const end = data.lastIndexOf(NEWLINE);
26
+ if (end < 0 && data.length < MAX_PARTIAL) {
27
+ this.#partial = data;
28
+ return undefined;
29
+ }
30
+ const cut = end < 0 ? data.length : end + 1;
31
+ this.#partial = data.subarray(cut);
32
+ return data.subarray(0, cut);
33
+ }
34
+ get pending() {
35
+ return this.#partial.length > 0;
36
+ }
37
+ /** The unfinished end (a line without its newline yet), emptied. */
38
+ take() {
39
+ const rest = this.#partial;
40
+ this.#partial = Buffer.alloc(0);
41
+ return rest;
42
+ }
43
+ }
44
+ export class RotatingLog {
45
+ file;
46
+ maxBytes;
47
+ keep;
48
+ #fd;
49
+ #size = 0;
50
+ #rotateAfter = 0;
51
+ /** Bytes that could not be written (disk full, removed folder); reported, never silent. */
52
+ dropped = 0;
53
+ constructor(file, maxBytes, keep) {
54
+ this.file = file;
55
+ this.maxBytes = maxBytes;
56
+ this.keep = keep;
57
+ }
58
+ /** Opens (appends to) the log; a file already over the limit is rotated first. */
59
+ open() {
60
+ this.#fd = openSync(this.file, 'a');
61
+ this.#size = fstatSync(this.#fd).size;
62
+ debug('App log opened', { file: this.file, bytes: this.#size, maxBytes: this.maxBytes });
63
+ if (this.#size >= this.maxBytes)
64
+ this.rotate();
65
+ }
66
+ /** Appends whole lines, rotating between lines when the limit would be passed. */
67
+ write(data) {
68
+ let rest = data;
69
+ while (rest.length) {
70
+ if (this.#size + rest.length <= this.maxBytes || Date.now() < this.#rotateAfter) {
71
+ this.#append(rest);
72
+ return;
73
+ }
74
+ const room = this.maxBytes - this.#size;
75
+ const cut = room > 0 ? rest.lastIndexOf(NEWLINE, room - 1) : -1;
76
+ if (cut >= 0) {
77
+ this.#append(rest.subarray(0, cut + 1));
78
+ rest = rest.subarray(cut + 1);
79
+ }
80
+ else if (this.#size === 0) {
81
+ // A single line longer than the limit fills a file by itself.
82
+ const end = rest.indexOf(NEWLINE);
83
+ const length = end < 0 ? rest.length : end + 1;
84
+ this.#append(rest.subarray(0, length));
85
+ rest = rest.subarray(length);
86
+ }
87
+ if (!rest.length)
88
+ return;
89
+ this.rotate();
90
+ }
91
+ }
92
+ /** app.log → app.log.1, app.log.1 → app.log.2 …; the oldest beyond `keep` is removed. */
93
+ rotate() {
94
+ const began = performance.now();
95
+ try {
96
+ this.#close();
97
+ for (let index = this.keep - 1; index >= 1; index--)
98
+ this.#move(index, index + 1);
99
+ renameSync(this.file, `${this.file}.1`);
100
+ for (let index = this.keep + 1; existsSync(`${this.file}.${index}`); index++)
101
+ rmSync(`${this.file}.${index}`, { force: true });
102
+ this.#fd = openSync(this.file, 'a');
103
+ debug('App log rotated', {
104
+ file: this.file,
105
+ bytes: this.#size,
106
+ keep: this.keep,
107
+ ms: Math.round(performance.now() - began),
108
+ });
109
+ this.#size = 0;
110
+ }
111
+ catch (error) {
112
+ // Keep writing to the current file; try rotating again in 5 s.
113
+ reportError('LOG_ROTATE_FAILED', 'Unable to rotate the app log', error, 5000);
114
+ this.#rotateAfter = Date.now() + 5000;
115
+ this.#reopen();
116
+ }
117
+ }
118
+ close() {
119
+ this.#close();
120
+ if (this.dropped)
121
+ reportError('LOG_LINES_DROPPED', 'Some app output could not be written to the log', new Error(`${this.dropped} bytes were not written to ${this.file}`));
122
+ }
123
+ #move(from, to) {
124
+ try {
125
+ renameSync(`${this.file}.${from}`, `${this.file}.${to}`);
126
+ }
127
+ catch (error) {
128
+ // Fewer rotated files than `keep` exist yet: nothing to shift.
129
+ if (!isMissing(error))
130
+ throw error;
131
+ }
132
+ }
133
+ #close() {
134
+ if (this.#fd === undefined)
135
+ return;
136
+ const fd = this.#fd;
137
+ this.#fd = undefined;
138
+ closeSync(fd);
139
+ }
140
+ #reopen() {
141
+ if (this.#fd !== undefined)
142
+ return;
143
+ try {
144
+ this.#fd = openSync(this.file, 'a');
145
+ this.#size = fstatSync(this.#fd).size;
146
+ }
147
+ catch (error) {
148
+ reportError('LOG_OPEN_FAILED', 'Unable to open the app log', error, 5000);
149
+ }
150
+ }
151
+ #append(data) {
152
+ this.#reopen();
153
+ let offset = 0;
154
+ try {
155
+ if (this.#fd === undefined)
156
+ throw new Error(`${this.file} is not open`);
157
+ while (offset < data.length)
158
+ offset += writeSync(this.#fd, data, offset);
159
+ }
160
+ catch (error) {
161
+ this.dropped += data.length - offset;
162
+ reportError('LOG_WRITE_FAILED', 'Unable to write the app log; output is lost', error, 5000);
163
+ }
164
+ this.#size += offset;
165
+ }
166
+ }
package/dist/server.d.ts CHANGED
@@ -1,8 +1,29 @@
1
+ /** Rotation defaults and limits (D-0053): `--log-max-mb`, `--log-files`. */
2
+ export declare const LOG_LIMITS: {
3
+ readonly maxMb: {
4
+ readonly default: 50;
5
+ readonly min: 1;
6
+ readonly max: 10240;
7
+ };
8
+ readonly files: {
9
+ readonly default: 5;
10
+ readonly min: 1;
11
+ readonly max: 100;
12
+ };
13
+ };
1
14
  export interface PidRecord {
2
15
  readonly pid: number;
3
16
  readonly startedAt: string;
4
17
  readonly entry: string;
5
18
  readonly node: string;
19
+ /** The log writer process (D-0053); absent in pid files of older CLIs. */
20
+ readonly logWriter?: number;
21
+ readonly logMaxBytes?: number;
22
+ readonly logFiles?: number;
23
+ }
24
+ export interface LogFile {
25
+ readonly file: string;
26
+ readonly bytes: number;
6
27
  }
7
28
  export interface ServerStatus {
8
29
  readonly running: boolean;
@@ -13,17 +34,40 @@ export interface ServerStatus {
13
34
  /** A pid file was left by a process that is gone (crashed or killed); it was removed. */
14
35
  readonly stale?: boolean;
15
36
  readonly log: string;
37
+ /** app.log and its rotated files, newest first. */
38
+ readonly logFiles: LogFile[];
39
+ /** The log writer of the running app and whether it is alive. */
40
+ readonly logWriter?: {
41
+ readonly pid: number;
42
+ readonly running: boolean;
43
+ };
44
+ /** The writer's own diagnostics, when it wrote any. */
45
+ readonly logWriterLog?: string;
16
46
  }
17
47
  export declare function status(root: string): Promise<ServerStatus>;
48
+ export interface LogOptions {
49
+ /** Rotate app.log when it would pass this size (default 50 MB). */
50
+ readonly logMaxBytes?: number;
51
+ /** Rotated files to keep, app.log.1 … app.log.<n> (default 5). */
52
+ readonly logFiles?: number;
53
+ }
18
54
  export interface StartResult {
19
55
  readonly pid: number;
20
56
  readonly log: string;
57
+ /** The detached process that writes and rotates the log (D-0053). */
58
+ readonly logWriter: number;
59
+ readonly logMaxBytes: number;
60
+ readonly logFiles: number;
21
61
  /** False when the process is alive but never logged the start line within the wait. */
22
62
  readonly ready: boolean;
23
63
  readonly ms: number;
24
64
  readonly warnings: string[];
25
65
  }
26
- export declare function start(root: string, waitMs?: number,
66
+ export interface StartOptions extends LogOptions {
67
+ /** How long to wait for the start line (default 10000 ms). */
68
+ readonly waitMs?: number;
69
+ }
70
+ export declare function start(root: string, options?: StartOptions,
27
71
  /** Tests replace the project inspection; the CLI always inspects. */
28
72
  check?: (root: string) => Promise<string>): Promise<StartResult>;
29
73
  export interface StopResult {
@@ -32,10 +76,18 @@ export interface StopResult {
32
76
  readonly ms?: number;
33
77
  readonly forced?: boolean;
34
78
  readonly stale?: boolean;
79
+ /** Set when the log writer was still running 5 s after the app stopped. */
80
+ readonly warnings?: string[];
35
81
  }
36
82
  export declare function stop(root: string, timeoutMs?: number, force?: boolean): Promise<StopResult>;
37
- /** Prints the last `lines` lines; with `follow`, keeps printing what is appended until SIGINT. */
38
- export declare function logs(root: string, lines?: number, follow?: boolean, write?: (text: string) => void): Promise<void>;
83
+ /**
84
+ * Prints the last `lines` lines (across app.log and its rotated files); with `follow`, keeps
85
+ * printing what is appended until SIGINT (or `signal`), following app.log across rotations.
86
+ * The follower remembers the inode of the file it reads. When app.log has become another file,
87
+ * it finishes its file, then reads each rotated file newer than it (found by inode, oldest
88
+ * first) before following the new app.log, so several rotations between two polls skip no line.
89
+ */
90
+ export declare function logs(root: string, lines?: number, follow?: boolean, write?: (text: string) => void, signal?: AbortSignal): Promise<void>;
39
91
  /** Runs the app in the foreground and forwards SIGINT/SIGTERM; resolves with its exit code. */
40
92
  export declare function foreground(root: string): Promise<number>;
41
93
  /**