@dokebi/cli 0.2.5 → 0.4.0

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.
Files changed (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2199 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +5 -4
  31. package/dist/config-edit.js +9 -8
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +141 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +835 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +793 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
@@ -8,9 +8,16 @@
8
8
  // The CLI listens on the IPC channel until the start is decided: { spawned: pid }, { ready: true }
9
9
  // when the start line passes, { exited: code, signal } and { failed: text }. The writer's own
10
10
  // errors go to its stderr, which the CLI points at `.dokebi/run/log-writer.log`.
11
+ //
12
+ // Before it starts the app it listens on a loopback control port (log-writer-control.ts, D-0073
13
+ // addendum) and tells the CLI `{ control: port }`, which goes into the pid file: a later
14
+ // `dokebi stop` that may not signal the app (an agent sandbox per command) asks the writer, its
15
+ // parent, to stop it. Both sides prove the app's DOKEBI_INSTANCE_TOKEN first.
11
16
  import { spawn } from 'node:child_process';
17
+ import { setTimeout as delay } from 'node:timers/promises';
12
18
  import { debug, describeError, reportError } from './diagnostics.js';
13
- import { LineBuffer, RotatingLog } from './rotating-log.js';
19
+ import { ControlServer } from './log-writer-control.js';
20
+ import { forwardOutput, LineBuffer, RotatingLog } from './rotating-log.js';
14
21
  /** Written by the generated `src/main.ts` once `app.start()` resolved. */
15
22
  const STARTED = 'Dokebi application started';
16
23
  /** How long the pipes may stay open after the app exited (a child it left holds them). */
@@ -43,8 +50,23 @@ if (!file ||
43
50
  send({ failed: text }, () => process.exit(2));
44
51
  }
45
52
  else
46
- run(file, maxBytes, keep, entry);
47
- function run(file, maxBytes, keep, entry) {
53
+ void openControl().then((control) => run(file, maxBytes, keep, entry, control));
54
+ /** The control port (D-0073 addendum); none without the instance token or when listen fails. */
55
+ async function openControl() {
56
+ const token = process.env.DOKEBI_INSTANCE_TOKEN;
57
+ if (!token) {
58
+ debug('No instance token; the control port is not opened');
59
+ return undefined;
60
+ }
61
+ const control = new ControlServer(token);
62
+ // Without the port (a sandbox that refuses it), stop needs a shell that may signal the app.
63
+ const port = await control.listen();
64
+ if (port === undefined)
65
+ return undefined;
66
+ send({ control: port });
67
+ return control;
68
+ }
69
+ function run(file, maxBytes, keep, entry, control) {
48
70
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
49
71
  process.on(signal, () => debug('Log writer ignores the signal; it exits when the app exits', { signal }));
50
72
  const log = new RotatingLog(file, maxBytes, keep);
@@ -58,7 +80,11 @@ function run(file, maxBytes, keep, entry) {
58
80
  }
59
81
  log.write(Buffer.from(`\n--- dokebi start ${new Date().toISOString()} ---\n`));
60
82
  const app = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
61
- let ready = false;
83
+ // `ready` goes to the CLI only after the start line is in app.log (forwardOutput).
84
+ const write = forwardOutput(log, STARTED, () => {
85
+ debug('Start line written to the app log', { pid: app.pid });
86
+ send({ ready: true });
87
+ });
62
88
  let exit;
63
89
  let open = 2;
64
90
  let finished = false;
@@ -71,7 +97,9 @@ function run(file, maxBytes, keep, entry) {
71
97
  log.write(Buffer.from(`--- dokebi: app exited (${how}) ---\n`));
72
98
  log.close();
73
99
  debug('Log writer finished', { pid: app.pid, code: exit.code, signal: exit.signal });
74
- send({ exited: exit.code, signal: exit.signal }, () => process.exit(0));
100
+ const { code, signal } = exit;
101
+ // A client waiting on the control port learns that the writer is done when it closes.
102
+ void (control?.close() ?? Promise.resolve()).then(() => send({ exited: code, signal }, () => process.exit(0)));
75
103
  };
76
104
  app.once('error', (error) => {
77
105
  // spawn failed (the app never ran): there is no exit event to wait for.
@@ -83,6 +111,8 @@ function run(file, maxBytes, keep, entry) {
83
111
  if (app.pid !== undefined) {
84
112
  debug('App process spawned by the log writer', { pid: app.pid, maxBytes, keep });
85
113
  send({ spawned: app.pid });
114
+ const exited = new Promise((resolve) => app.once('exit', () => resolve()));
115
+ control?.attach(controlled(app.pid, () => exit === undefined, exited));
86
116
  }
87
117
  app.once('exit', (code, signal) => {
88
118
  exit = { code, signal };
@@ -104,13 +134,6 @@ function run(file, maxBytes, keep, entry) {
104
134
  return;
105
135
  const lines = new LineBuffer();
106
136
  let timer;
107
- const write = (data) => {
108
- if (!ready && data.includes(STARTED)) {
109
- ready = true;
110
- send({ ready: true });
111
- }
112
- log.write(data);
113
- };
114
137
  stream.on('data', (chunk) => {
115
138
  const whole = lines.push(chunk);
116
139
  if (whole)
@@ -131,3 +154,21 @@ function run(file, maxBytes, keep, entry) {
131
154
  follow(app.stdout, 'stdout');
132
155
  follow(app.stderr, 'stderr');
133
156
  }
157
+ /** The app as the control port drives it: signals go to its pid, exits are awaited. */
158
+ function controlled(pid, running, exit) {
159
+ return {
160
+ pid,
161
+ running,
162
+ exited: (ms) => Promise.race([exit.then(() => true), delay(ms, false)]),
163
+ signal: (signal) => {
164
+ try {
165
+ process.kill(pid, signal);
166
+ }
167
+ catch (error) {
168
+ // ESRCH: the app exited meanwhile, which the stop sees as exited. Anything else is logged.
169
+ if (error.code !== 'ESRCH')
170
+ reportError('APP_SIGNAL_FAILED', 'Unable to signal the app', error);
171
+ }
172
+ },
173
+ };
174
+ }
@@ -1,3 +1,4 @@
1
+ import { type FileOwner } from './install-plan.js';
1
2
  export interface Dependency {
2
3
  name: string;
3
4
  version: string;
@@ -8,6 +9,8 @@ export interface Dependency {
8
9
  export interface Template {
9
10
  source: string;
10
11
  target: string;
12
+ /** Who edits the file (absent: `user`). */
13
+ owner?: FileOwner;
11
14
  }
12
15
  export interface PluginManifest {
13
16
  packageName: string;
@@ -22,9 +25,34 @@ export interface PluginManifest {
22
25
  optionsSchema?: string;
23
26
  documentation?: string;
24
27
  installModule?: string;
28
+ /** D-0070: contribution points the plugin contributes to (its `definePlugin` `contributes`). */
29
+ contributes?: string[];
30
+ /** D-0070: contribution points the plugin collects (its `definePlugin` `collects`). */
31
+ collects?: string[];
32
+ /** D-0070: CLI-managed units this plugin receives (`apps`, `modules`). */
33
+ hosts?: HostRole[];
25
34
  }
26
35
  export declare const PACKAGE_NAME: RegExp;
27
36
  export declare const ID: RegExp;
37
+ /** A contribution point name, `<owner>:<name>` (the same rule as core's definePoint). */
38
+ export declare const POINT: RegExp;
39
+ /**
40
+ * The install-time roles a manifest may declare in `dokebi.hosts` (D-0070), a closed list:
41
+ * - `apps`: receives the apps of `dokebi app add|link|unlink|remove` (src/apps/<name>/app.ts as
42
+ * `entry.apps`), owns `src/apps/`, keeps their domains and settings in its options section
43
+ * (`domains`, `apps`) and ships the app skeletons (`templates/app*`, `templates/inertia-build`).
44
+ * - `modules`: receives the modules of `dokebi module add|remove` (src/shared/modules/<name>/
45
+ * module.ts appended to `entry.modules`) and ships their skeletons (`templates/module*`).
46
+ */
47
+ export declare const HOST_ROLES: readonly ['apps', 'modules'];
48
+ export type HostRole = (typeof HOST_ROLES)[number];
49
+ /**
50
+ * The installed plugin that hosts `role` (D-0070). More than one is HOST_CONFLICT; none is
51
+ * HOST_MISSING, whose fix names the command that installs one.
52
+ */
53
+ export declare function hostOf(plugins: readonly PluginManifest[], role: HostRole): PluginManifest;
54
+ /** Like hostOf, but undefined when no installed plugin hosts `role`. */
55
+ export declare function findHost(plugins: readonly PluginManifest[], role: HostRole): PluginManifest | undefined;
28
56
  /**
29
57
  * Reads package.json `dokebi` metadata. Schema 1 keeps the legacy top-level files list.
30
58
  * Schema 2 moves files into `install` and adds optional dependencies, entry and defaults.
package/dist/manifest.js CHANGED
@@ -7,6 +7,68 @@ import { readInstallPlan } from './install-plan.js';
7
7
  import { assertLayout } from './layout.js';
8
8
  export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
9
9
  export const ID = /^[a-z0-9][a-z0-9._/-]*$/;
10
+ /** A contribution point name, `<owner>:<name>` (the same rule as core's definePoint). */
11
+ export const POINT = /^[a-z0-9][a-z0-9._/-]{0,95}:[a-z0-9][a-z0-9._-]{0,63}$/;
12
+ /**
13
+ * The install-time roles a manifest may declare in `dokebi.hosts` (D-0070), a closed list:
14
+ * - `apps`: receives the apps of `dokebi app add|link|unlink|remove` (src/apps/<name>/app.ts as
15
+ * `entry.apps`), owns `src/apps/`, keeps their domains and settings in its options section
16
+ * (`domains`, `apps`) and ships the app skeletons (`templates/app*`, `templates/inertia-build`).
17
+ * - `modules`: receives the modules of `dokebi module add|remove` (src/shared/modules/<name>/
18
+ * module.ts appended to `entry.modules`) and ships their skeletons (`templates/module*`).
19
+ */
20
+ export const HOST_ROLES = ['apps', 'modules'];
21
+ /** At most this many points per list (core's definePlugin limit). */
22
+ const MAX_POINTS = 64;
23
+ /**
24
+ * The installed plugin that hosts `role` (D-0070). More than one is HOST_CONFLICT; none is
25
+ * HOST_MISSING, whose fix names the command that installs one.
26
+ */
27
+ export function hostOf(plugins, role) {
28
+ const found = plugins.filter((plugin) => plugin.hosts?.includes(role));
29
+ if (found.length > 1)
30
+ throw new CliError('HOST_CONFLICT', `More than one installed plugin hosts ${role}: ${found.map((plugin) => plugin.id).join(', ')}`, { details: { role, plugins: found.map((plugin) => plugin.id) } });
31
+ const [host] = found;
32
+ if (!host)
33
+ throw new CliError('HOST_MISSING', `No installed plugin hosts ${role} (package.json dokebi.hosts, D-0070); install one with npx dokebi plugins search ${role}, or upgrade the plugin that provides ${role} to a version that declares it`, { details: { role } });
34
+ return host;
35
+ }
36
+ /** Like hostOf, but undefined when no installed plugin hosts `role`. */
37
+ export function findHost(plugins, role) {
38
+ return plugins.some((plugin) => plugin.hosts?.includes(role)) ? hostOf(plugins, role) : undefined;
39
+ }
40
+ /** `dokebi.contributes` / `dokebi.collects`: distinct point names (D-0070). */
41
+ function readPoints(value, label) {
42
+ if (value === undefined)
43
+ return undefined;
44
+ if (!Array.isArray(value) ||
45
+ value.length > MAX_POINTS ||
46
+ value.some((item) => typeof item !== 'string' || !POINT.test(item)) ||
47
+ new Set(value).size !== value.length)
48
+ throw new CliError('INVALID_MANIFEST', `dokebi.${label} must list distinct point names`);
49
+ return [...value];
50
+ }
51
+ /** `dokebi.hosts`: distinct roles from HOST_ROLES (D-0070). */
52
+ function readHosts(value) {
53
+ if (value === undefined)
54
+ return undefined;
55
+ if (!Array.isArray(value) ||
56
+ value.some((item) => !HOST_ROLES.includes(item)) ||
57
+ new Set(value).size !== value.length)
58
+ throw new CliError('INVALID_MANIFEST', `dokebi.hosts must list distinct roles from: ${HOST_ROLES.join(', ')}`);
59
+ return [...value];
60
+ }
61
+ /** The D-0070 fields of a manifest, only those present (a stored manifest round-trips). */
62
+ function readRoles(metadata) {
63
+ const contributes = readPoints(metadata.contributes, 'contributes');
64
+ const collects = readPoints(metadata.collects, 'collects');
65
+ const hosts = readHosts(metadata.hosts);
66
+ return {
67
+ ...(contributes ? { contributes } : {}),
68
+ ...(collects ? { collects } : {}),
69
+ ...(hosts ? { hosts } : {}),
70
+ };
71
+ }
10
72
  function field(value, label) {
11
73
  if (typeof value !== 'string' || !value)
12
74
  throw new CliError('INVALID_MANIFEST', label);
@@ -91,9 +153,19 @@ export function readManifest(value, options = {}) {
91
153
  }
92
154
  const legacyFiles = readInstallPlan({ files: legacy }, [], false).files;
93
155
  const install = readInstallPlan(metadata.install, legacyFiles, extended);
156
+ const roles = readRoles(metadata);
94
157
  if (options.checkLayout !== false)
95
- assertLayout(install, id);
96
- return { packageName, version, id, core, dependencies, ...install, ...readResources(metadata) };
158
+ assertLayout(install, id, roles.hosts ?? []);
159
+ return {
160
+ packageName,
161
+ version,
162
+ id,
163
+ core,
164
+ dependencies,
165
+ ...install,
166
+ ...readResources(metadata),
167
+ ...roles,
168
+ };
97
169
  }
98
170
  /** Reads a manifest previously normalized into .dokebi/state.json. */
99
171
  export function readStoredManifest(value) {
@@ -119,6 +191,9 @@ export function readStoredManifest(value) {
119
191
  ...(stored.optionsSchema === undefined ? {} : { optionsSchema: stored.optionsSchema }),
120
192
  ...(stored.documentation === undefined ? {} : { documentation: stored.documentation }),
121
193
  ...(stored.installModule === undefined ? {} : { installModule: stored.installModule }),
194
+ ...(stored.contributes === undefined ? {} : { contributes: stored.contributes }),
195
+ ...(stored.collects === undefined ? {} : { collects: stored.collects }),
196
+ ...(stored.hosts === undefined ? {} : { hosts: stored.hosts }),
122
197
  },
123
198
  }, { checkLayout: false });
124
199
  }
@@ -10,3 +10,52 @@ export declare class NpmClient {
10
10
  /** `npm install` at a workspace root: brings its lock and node_modules in line (D-0035). */
11
11
  installWorkspace(root: string): Promise<void>;
12
12
  }
13
+ export interface NpmRun {
14
+ readonly exitCode: number | null;
15
+ readonly signal: NodeJS.Signals | null;
16
+ readonly timedOut: boolean;
17
+ /** The last NPM_OUTPUT_TAIL_BYTES bytes of each stream, redacted. */
18
+ readonly stderr: string;
19
+ readonly stdout: string;
20
+ }
21
+ /**
22
+ * Runs npm in `directory` (the npm that started this CLI when there is one, else `npm` on PATH)
23
+ * and keeps the bounded, redacted end of both streams. Every npm call of the CLI goes through
24
+ * here. `--loglevel=warn` is always added: an inherited `loglevel=silent` (`npx -s` and
25
+ * `npm run -s` export `npm_config_loglevel=silent` to their child) makes npm print nothing, not
26
+ * even its error, and NPM_FAILED then had empty `stderr`/`stdout`. npm's debug log file keeps
27
+ * every level either way. Rejects (NPM_FAILED) only when npm cannot be started; the exit code is
28
+ * the caller's to judge.
29
+ */
30
+ export declare function runNpm(directory: string, npmArgs: readonly string[], options: {
31
+ readonly timeoutMs: number;
32
+ }): Promise<NpmRun>;
33
+ /**
34
+ * Copies the project's `.npmrc` into a staging folder. `configRoot` is the folder npm reads the
35
+ * project config from: the project, or the workspace root for a member. npm reads that file
36
+ * only at its local prefix, and a staging folder with its own package.json is one, so without the
37
+ * copy an install there ignores e.g. a project `@dokebi:registry` that version resolution
38
+ * honoured (registry.ts asks npm in the project folder).
39
+ */
40
+ export declare function stageNpmConfig(configRoot: string, staging: string): Promise<boolean>;
41
+ /** npm output that means the registry could not be reached (offline, DNS, proxy, timeout). */
42
+ export declare function networkFailure(output: string): boolean;
43
+ /** Bytes of npm output kept per stream for NPM_FAILED details. */
44
+ export declare const NPM_OUTPUT_TAIL_BYTES = 4096;
45
+ /**
46
+ * The last NPM_OUTPUT_TAIL_BYTES bytes of process output, as redacted text. The one helper for
47
+ * every child output the CLI puts in a result: npm (`runNpm`), project scripts that verify runs
48
+ * (typecheck, build) and recipe step commands, so a credential printed by a build never lands
49
+ * in `--json` (D-0047: only secrets are masked, nothing else is cut but the bound).
50
+ */
51
+ export declare class OutputTail {
52
+ #private;
53
+ bytes: number;
54
+ push(chunk: Buffer): void;
55
+ text(): string;
56
+ }
57
+ /**
58
+ * Removes credentials npm may print: `user:password@` in URLs, `_authToken=`/`_auth=`/
59
+ * `_password=` values (npmrc lines), bearer tokens and npm access tokens (`npm_…`).
60
+ */
61
+ export declare function redactNpmOutput(text: string): string;
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { isAbsolute, resolve } from 'node:path';
4
- import { debug } from './diagnostics.js';
4
+ import { debug, isMissing } from './diagnostics.js';
5
5
  import { CliError } from './errors.js';
6
6
  import { json, object, parseObject } from './filesystem.js';
7
7
  import { PACKAGE_NAME } from './manifest.js';
@@ -42,16 +42,40 @@ export function validateProjectPackage(pkg) {
42
42
  export class NpmClient {
43
43
  /** Installs into a standalone directory (the project or the CLI's staging). */
44
44
  async install(directory, spec) {
45
- await this.#run(directory, [
46
- 'install',
47
- ...(spec ? [spec] : []),
48
- '--ignore-scripts',
49
- '--save-exact',
50
- '--no-audit',
51
- '--no-fund',
52
- '--workspaces=false',
53
- '--package-lock=true',
54
- ]);
45
+ try {
46
+ await this.#run(directory, [
47
+ 'install',
48
+ ...(spec ? [spec] : []),
49
+ '--ignore-scripts',
50
+ '--save-exact',
51
+ '--no-audit',
52
+ '--no-fund',
53
+ '--workspaces=false',
54
+ '--package-lock=true',
55
+ ]);
56
+ }
57
+ catch (error) {
58
+ // Every install runs in a staging folder (or the new project before it is moved into
59
+ // place), so the project is untouched: `stage` lets the envelope say mutation none.
60
+ if (!(error instanceof CliError) || error.code !== 'NPM_FAILED')
61
+ throw error;
62
+ const details = (error.details ?? {});
63
+ const network = networkFailure(`${details.stderr ?? ''}\n${details.stdout ?? ''}`);
64
+ throw new CliError('NPM_FAILED', error.message, {
65
+ cause: error.cause ?? error,
66
+ details: {
67
+ ...details,
68
+ stage: 'staging',
69
+ ...(network
70
+ ? {
71
+ network: true,
72
+ hint: 'the registry was not reachable: retry when online, or with packages from the npm cache: npm_config_prefer_offline=true npx dokebi …',
73
+ }
74
+ : {}),
75
+ },
76
+ phase: 'install',
77
+ });
78
+ }
55
79
  for (const file of ['package.json', 'package-lock.json']) {
56
80
  const content = parseObject(await readFile(resolve(directory, file), 'utf8'), file);
57
81
  if (file === 'package.json')
@@ -75,38 +99,135 @@ export class NpmClient {
75
99
  await this.#run(root, ['install', '--ignore-scripts', '--no-audit', '--no-fund']);
76
100
  }
77
101
  async #run(directory, npmArgs) {
78
- const npmScript = process.env.npm_execpath;
79
- const command = npmScript?.endsWith('npm-cli.js') ? process.execPath : 'npm';
80
- const args = [...(npmScript?.endsWith('npm-cli.js') ? [npmScript] : []), ...npmArgs];
81
- const began = performance.now();
82
- debug('Running npm', { args: npmArgs.slice(0, 16), directory });
83
- await new Promise((resolvePromise, reject) => {
84
- const child = spawn(command, args, {
85
- cwd: directory,
86
- stdio: 'ignore',
87
- });
88
- let timedOut = false;
89
- const timer = setTimeout(() => {
90
- timedOut = true;
91
- child.kill('SIGKILL');
92
- }, 120000);
93
- child.on('error', (cause) => {
94
- clearTimeout(timer);
95
- reject(new CliError('NPM_FAILED', 'Could not execute npm', { cause }));
102
+ const result = await runNpm(directory, npmArgs, { timeoutMs: 120_000 });
103
+ if (result.exitCode === 0 && !result.timedOut)
104
+ return;
105
+ throw new CliError('NPM_FAILED', result.timedOut ? 'npm timed out' : `npm failed with exit code ${result.exitCode}`, {
106
+ details: {
107
+ command: npmArgs[0],
108
+ exitCode: result.exitCode,
109
+ signal: result.signal,
110
+ timedOut: result.timedOut,
111
+ stderr: result.stderr,
112
+ stdout: result.stdout,
113
+ },
114
+ });
115
+ }
116
+ }
117
+ /**
118
+ * Runs npm in `directory` (the npm that started this CLI when there is one, else `npm` on PATH)
119
+ * and keeps the bounded, redacted end of both streams. Every npm call of the CLI goes through
120
+ * here. `--loglevel=warn` is always added: an inherited `loglevel=silent` (`npx -s` and
121
+ * `npm run -s` export `npm_config_loglevel=silent` to their child) makes npm print nothing, not
122
+ * even its error, and NPM_FAILED then had empty `stderr`/`stdout`. npm's debug log file keeps
123
+ * every level either way. Rejects (NPM_FAILED) only when npm cannot be started; the exit code is
124
+ * the caller's to judge.
125
+ */
126
+ export function runNpm(directory, npmArgs, options) {
127
+ const npmScript = process.env.npm_execpath;
128
+ const viaNode = npmScript?.endsWith('npm-cli.js') === true;
129
+ const command = viaNode ? process.execPath : 'npm';
130
+ const args = [...(viaNode ? [npmScript] : []), ...npmArgs, '--loglevel=warn'];
131
+ const began = performance.now();
132
+ debug('Running npm', { args: npmArgs.slice(0, 16), directory });
133
+ return new Promise((resolvePromise, reject) => {
134
+ const child = spawn(command, args, { cwd: directory, stdio: ['ignore', 'pipe', 'pipe'] });
135
+ // npm's own words are the cause of a failure: keep the end of both streams (bounded).
136
+ const stdout = new OutputTail();
137
+ const stderr = new OutputTail();
138
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
139
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
140
+ let timedOut = false;
141
+ const timer = setTimeout(() => {
142
+ timedOut = true;
143
+ child.kill('SIGKILL');
144
+ }, options.timeoutMs);
145
+ child.on('error', (cause) => {
146
+ clearTimeout(timer);
147
+ reject(new CliError('NPM_FAILED', 'Could not execute npm', {
148
+ cause,
149
+ details: { command: npmArgs[0], directory },
150
+ }));
151
+ });
152
+ child.on('close', (code, signal) => {
153
+ clearTimeout(timer);
154
+ debug('npm finished', {
155
+ command: npmArgs[0],
156
+ exitCode: code,
157
+ signal,
158
+ timedOut,
159
+ ms: Math.round(performance.now() - began),
160
+ stdoutBytes: stdout.bytes,
161
+ stderrBytes: stderr.bytes,
96
162
  });
97
- child.on('close', (code) => {
98
- clearTimeout(timer);
99
- debug('npm finished', {
100
- command: npmArgs[0],
101
- exitCode: code,
102
- timedOut,
103
- ms: Math.round(performance.now() - began),
104
- });
105
- if (code === 0 && !timedOut)
106
- resolvePromise();
107
- else
108
- reject(new CliError('NPM_FAILED', timedOut ? 'npm timed out' : `npm failed with exit code ${code}`));
163
+ resolvePromise({
164
+ exitCode: code,
165
+ signal,
166
+ timedOut,
167
+ stderr: stderr.text(),
168
+ stdout: stdout.text(),
109
169
  });
110
170
  });
171
+ });
172
+ }
173
+ /**
174
+ * Copies the project's `.npmrc` into a staging folder. `configRoot` is the folder npm reads the
175
+ * project config from: the project, or the workspace root for a member. npm reads that file
176
+ * only at its local prefix, and a staging folder with its own package.json is one, so without the
177
+ * copy an install there ignores e.g. a project `@dokebi:registry` that version resolution
178
+ * honoured (registry.ts asks npm in the project folder).
179
+ */
180
+ export async function stageNpmConfig(configRoot, staging) {
181
+ let content;
182
+ try {
183
+ content = await readFile(resolve(configRoot, '.npmrc'));
184
+ }
185
+ catch (error) {
186
+ if (isMissing(error))
187
+ return false;
188
+ throw error;
111
189
  }
190
+ await writeFile(resolve(staging, '.npmrc'), content, { mode: 0o600 });
191
+ debug('Project npm config copied into staging', { from: configRoot, staging });
192
+ return true;
193
+ }
194
+ /** npm output that means the registry could not be reached (offline, DNS, proxy, timeout). */
195
+ export function networkFailure(output) {
196
+ return /\b(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|ENOTCACHED)\b|network request|fetch failed|request to \S+ failed/i.test(output);
197
+ }
198
+ /** Bytes of npm output kept per stream for NPM_FAILED details. */
199
+ export const NPM_OUTPUT_TAIL_BYTES = 4096;
200
+ /**
201
+ * The last NPM_OUTPUT_TAIL_BYTES bytes of process output, as redacted text. The one helper for
202
+ * every child output the CLI puts in a result: npm (`runNpm`), project scripts that verify runs
203
+ * (typecheck, build) and recipe step commands, so a credential printed by a build never lands
204
+ * in `--json` (D-0047: only secrets are masked, nothing else is cut but the bound).
205
+ */
206
+ export class OutputTail {
207
+ #buffer = Buffer.alloc(0);
208
+ bytes = 0;
209
+ push(chunk) {
210
+ this.bytes += chunk.length;
211
+ const joined = Buffer.concat([this.#buffer, chunk]);
212
+ this.#buffer =
213
+ joined.length > NPM_OUTPUT_TAIL_BYTES
214
+ ? joined.subarray(joined.length - NPM_OUTPUT_TAIL_BYTES)
215
+ : joined;
216
+ }
217
+ text() {
218
+ // A cut multi-byte character at the start decodes as U+FFFD; drop it.
219
+ const text = this.#buffer.toString('utf8').replace(/^\uFFFD+/, '');
220
+ return redactNpmOutput(this.bytes > this.#buffer.length ? `…${text}` : text);
221
+ }
222
+ }
223
+ /**
224
+ * Removes credentials npm may print: `user:password@` in URLs, `_authToken=`/`_auth=`/
225
+ * `_password=` values (npmrc lines), bearer tokens and npm access tokens (`npm_…`).
226
+ */
227
+ export function redactNpmOutput(text) {
228
+ return text
229
+ .replace(/\/\/[^\s/@]*@/g, '//***@')
230
+ .replace(/(_authToken|_auth|_password)(\s*[=:]\s*)("?)[^\s"]+/gi, '$1$2$3***')
231
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 ***')
232
+ .replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, 'npm_***');
112
233
  }
package/dist/output.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const CLI_VERSION = "0.2.5";
1
+ export declare const CLI_VERSION = "0.4.0";
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,17 +1,14 @@
1
1
  import { CliError } from './errors.js';
2
- export const CLI_VERSION = '0.2.5';
2
+ export const CLI_VERSION = '0.4.0';
3
3
  export function envelope(command, result, ok = true) {
4
4
  return { schemaVersion: 1, cliVersion: CLI_VERSION, command, ok, result, error: null };
5
5
  }
6
6
  export function failure(command, error, mutationStarted) {
7
7
  const code = error instanceof CliError ? error.code : 'INTERNAL_ERROR';
8
- const mutation = !mutationStarted
9
- ? 'none'
10
- : code === 'COMMIT_FAILED'
11
- ? 'rolled-back'
12
- : code === 'RECOVERY_REQUIRED'
13
- ? 'recovery-required'
14
- : 'unknown';
8
+ // npm failing in the staging folder never touched the project (D-0065, AI-M01).
9
+ const staged = code === 'NPM_FAILED' &&
10
+ error instanceof CliError &&
11
+ error.details?.stage === 'staging';
15
12
  const phase = (error instanceof CliError ? error.phase : undefined) ??
16
13
  (code === 'NPM_FAILED'
17
14
  ? 'install'
@@ -20,6 +17,15 @@ export function failure(command, error, mutationStarted) {
20
17
  : code === 'USAGE'
21
18
  ? 'arguments'
22
19
  : 'unknown');
20
+ // An error of the arguments phase is raised before anything is written (or before a
21
+ // transaction commits): the project is untouched.
22
+ const mutation = !mutationStarted || staged || phase === 'arguments'
23
+ ? 'none'
24
+ : code === 'COMMIT_FAILED'
25
+ ? 'rolled-back'
26
+ : code === 'RECOVERY_REQUIRED'
27
+ ? 'recovery-required'
28
+ : 'unknown';
23
29
  return {
24
30
  schemaVersion: 1,
25
31
  cliVersion: CLI_VERSION,
@@ -13,6 +13,11 @@ export interface LifecyclePlan extends Omit<InstallPlan, 'configDefaults'> {
13
13
  defaults: Record<string, unknown>[];
14
14
  requires: string[];
15
15
  configChanges: ConfigChange[];
16
+ /**
17
+ * planInstall only (D-0073): environment variables that must hold a secret. The CLI writes a
18
+ * generated value into the project's .env when neither the environment nor .env has one.
19
+ */
20
+ envSecrets: string[];
16
21
  }
17
22
  export interface CheckDiagnostic {
18
23
  code: string;
@@ -34,6 +39,8 @@ export interface LifecycleInput {
34
39
  readonly projectPackages: readonly string[];
35
40
  readonly installation?: Installation;
36
41
  }
42
+ /** An environment variable name a plan may ask a generated secret for. */
43
+ export declare const ENV_NAME: RegExp;
37
44
  export declare function optionsOf(options: Record<string, unknown>, id: string): Record<string, unknown>;
38
45
  export declare function optionPath(value: unknown): string[];
39
46
  /** Static `dokebi.install` merged with the module's planInstall or planUpgrade result. */