@dokebi/cli 0.1.0 → 0.2.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 (50) hide show
  1. package/AI.md +17 -12
  2. package/README.md +20 -26
  3. package/dist/bin.js +198 -9
  4. package/dist/config-edit.d.ts +19 -0
  5. package/dist/config-edit.js +124 -0
  6. package/dist/configuration-scaffold.d.ts +1 -0
  7. package/dist/configuration-scaffold.js +150 -0
  8. package/dist/configuration.d.ts +19 -0
  9. package/dist/configuration.js +84 -0
  10. package/dist/errors.d.ts +2 -0
  11. package/dist/errors.js +2 -1
  12. package/dist/example-config.d.ts +11 -0
  13. package/dist/example-config.js +16 -0
  14. package/dist/filesystem.js +9 -7
  15. package/dist/generator.d.ts +12 -2
  16. package/dist/generator.js +152 -22
  17. package/dist/install-module.d.ts +23 -0
  18. package/dist/install-module.js +107 -0
  19. package/dist/install-plan.d.ts +12 -0
  20. package/dist/install-plan.js +90 -0
  21. package/dist/layout.d.ts +17 -0
  22. package/dist/layout.js +35 -0
  23. package/dist/manifest.d.ts +24 -2
  24. package/dist/manifest.js +103 -38
  25. package/dist/merge-install-defaults.d.ts +2 -0
  26. package/dist/merge-install-defaults.js +79 -0
  27. package/dist/npm-client.d.ts +6 -0
  28. package/dist/npm-client.js +34 -20
  29. package/dist/npm-paths.js +0 -2
  30. package/dist/output.d.ts +1 -1
  31. package/dist/output.js +9 -10
  32. package/dist/plugin-lifecycle.d.ts +43 -0
  33. package/dist/plugin-lifecycle.js +194 -0
  34. package/dist/plugin-options.d.ts +7 -0
  35. package/dist/plugin-options.js +23 -41
  36. package/dist/project-guide.d.ts +1 -1
  37. package/dist/project-guide.js +8 -3
  38. package/dist/project-inspector.d.ts +7 -1
  39. package/dist/project-inspector.js +84 -19
  40. package/dist/project-state.d.ts +16 -1
  41. package/dist/project-state.js +87 -13
  42. package/dist/project.d.ts +82 -3
  43. package/dist/project.js +1138 -111
  44. package/dist/required-plugins.d.ts +6 -0
  45. package/dist/required-plugins.js +45 -0
  46. package/dist/transaction.d.ts +6 -1
  47. package/dist/transaction.js +54 -5
  48. package/dist/workspace.d.ts +22 -0
  49. package/dist/workspace.js +97 -0
  50. package/package.json +7 -5
@@ -0,0 +1,107 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ export class InstallPlanningError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super('Plugin installation planning failed (' + code + ').');
9
+ this.code = code;
10
+ this.name = 'InstallPlanningError';
11
+ }
12
+ }
13
+ /** Returned when the module does not export the requested phase. */
14
+ export const PHASE_MISSING = Symbol('dokebi.install.phase-missing');
15
+ const MAX_BYTES = 1024 * 1024;
16
+ const TIMEOUT_MS = 5000;
17
+ const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code});\n}\n";
18
+ /**
19
+ * Runs one lifecycle phase of a plugin installation module in a child process.
20
+ * The child uses the Node permission model: it may read only `readRoots`
21
+ * (real paths), cannot write files or spawn processes, and receives no
22
+ * parent environment variables. Node 24 does not restrict network access.
23
+ */
24
+ export async function runInstallModule(modulePath, phase, context, readRoots = [dirname(modulePath)]) {
25
+ const input = JSON.stringify(context);
26
+ if (Buffer.byteLength(input) > MAX_BYTES)
27
+ throw new InstallPlanningError('INSTALL_CONTEXT_LIMIT');
28
+ let roots;
29
+ let moduleUrl;
30
+ try {
31
+ roots = await Promise.all(readRoots.map((root) => realpath(root)));
32
+ moduleUrl = pathToFileURL(await realpath(modulePath)).href;
33
+ }
34
+ catch {
35
+ throw new InstallPlanningError('INSTALL_MODULE_MISSING');
36
+ }
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(process.execPath, [
39
+ '--permission',
40
+ ...roots.map((root) => `--allow-fs-read=${root}`),
41
+ '--input-type=module',
42
+ '--eval',
43
+ RUNNER,
44
+ moduleUrl,
45
+ phase,
46
+ ], { stdio: ['pipe', 'pipe', 'pipe'], env: {}, cwd: roots[0] });
47
+ const chunks = [];
48
+ let size = 0;
49
+ let failure;
50
+ const stop = (code) => {
51
+ failure ??= new InstallPlanningError(code);
52
+ child.kill('SIGKILL');
53
+ };
54
+ const timer = setTimeout(() => stop('INSTALL_TIMEOUT'), TIMEOUT_MS);
55
+ child.stdout.on('data', (chunk) => {
56
+ size += chunk.length;
57
+ if (size > MAX_BYTES)
58
+ stop('INSTALL_OUTPUT_LIMIT');
59
+ else if (!failure)
60
+ chunks.push(chunk);
61
+ });
62
+ child.stderr.on('data', (chunk) => {
63
+ size += chunk.length;
64
+ if (size > MAX_BYTES)
65
+ stop('INSTALL_OUTPUT_LIMIT');
66
+ });
67
+ child.on('error', () => stop('INSTALL_PROCESS_FAILED'));
68
+ child.stdin.on('error', () => stop('INSTALL_INPUT_FAILED'));
69
+ child.on('close', (code) => {
70
+ clearTimeout(timer);
71
+ if (failure) {
72
+ reject(failure);
73
+ return;
74
+ }
75
+ if (code !== 0) {
76
+ reject(new InstallPlanningError('INSTALL_PROCESS_FAILED'));
77
+ return;
78
+ }
79
+ try {
80
+ const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
81
+ if (typeof result !== 'object' || result === null || Array.isArray(result))
82
+ throw new Error();
83
+ const envelope = result;
84
+ if (envelope.ok === true && envelope.missing === true) {
85
+ resolve(PHASE_MISSING);
86
+ return;
87
+ }
88
+ if (envelope.ok === true && Object.hasOwn(envelope, 'plan')) {
89
+ resolve(envelope.plan);
90
+ return;
91
+ }
92
+ if (envelope.ok === false &&
93
+ typeof envelope.code === 'string' &&
94
+ /^[A-Z][A-Z0-9_]{0,63}$/.test(envelope.code)) {
95
+ reject(new InstallPlanningError(envelope.code));
96
+ return;
97
+ }
98
+ }
99
+ catch {
100
+ reject(new InstallPlanningError('INSTALL_OUTPUT_INVALID'));
101
+ return;
102
+ }
103
+ reject(new InstallPlanningError('INSTALL_OUTPUT_INVALID'));
104
+ });
105
+ child.stdin.end(input);
106
+ });
107
+ }
@@ -0,0 +1,12 @@
1
+ import type { Template } from './manifest.js';
2
+ export interface InstallPlan {
3
+ files: Template[];
4
+ directories?: string[];
5
+ entry?: Template;
6
+ configDefaults?: Record<string, unknown>;
7
+ }
8
+ /**
9
+ * Validates one installation plan and merges it after the given files.
10
+ * Extended plans (manifest v2 and installation modules) may declare an entry and defaults.
11
+ */
12
+ export declare function readInstallPlan(value: unknown, legacy: readonly Template[], extended?: boolean): InstallPlan;
@@ -0,0 +1,90 @@
1
+ import { path as paths } from '@dokebi/core/libraries';
2
+ import { CliError } from './errors.js';
3
+ import { object, relativePath, templateTarget } from './filesystem.js';
4
+ import { validateDefaults } from './merge-install-defaults.js';
5
+ const ENTRY_EXTENSION = /\.(?:ts|mts|js|mjs)$/;
6
+ function template(value, label) {
7
+ const file = object(value, label);
8
+ if (Object.keys(file).some((key) => key !== 'source' && key !== 'target') ||
9
+ typeof file.source !== 'string' ||
10
+ typeof file.target !== 'string')
11
+ throw new CliError('INVALID_MANIFEST', `Invalid ${label}`);
12
+ relativePath(file.source);
13
+ templateTarget(file.target);
14
+ return { source: file.source, target: file.target };
15
+ }
16
+ /**
17
+ * Validates one installation plan and merges it after the given files.
18
+ * Extended plans (manifest v2 and installation modules) may declare an entry and defaults.
19
+ */
20
+ export function readInstallPlan(value, legacy, extended = true) {
21
+ const files = [...legacy];
22
+ const directories = [];
23
+ let entry;
24
+ let configDefaults;
25
+ if (value !== undefined) {
26
+ const plan = object(value, 'dokebi.install');
27
+ const allowed = extended
28
+ ? ['files', 'directories', 'entry', 'configDefaults']
29
+ : ['files', 'directories'];
30
+ if (Object.keys(plan).some((key) => !allowed.includes(key)))
31
+ throw new CliError('INVALID_MANIFEST', 'Unknown install operation');
32
+ if (plan.files !== undefined) {
33
+ if (!Array.isArray(plan.files))
34
+ throw new CliError('INVALID_MANIFEST', 'Install files must be an array');
35
+ for (const item of plan.files)
36
+ files.push(template(item, 'install file'));
37
+ }
38
+ if (plan.directories !== undefined) {
39
+ if (!Array.isArray(plan.directories))
40
+ throw new CliError('INVALID_MANIFEST', 'Install directories must be an array');
41
+ for (const directory of plan.directories) {
42
+ if (typeof directory !== 'string')
43
+ throw new CliError('INVALID_MANIFEST', 'Invalid install directory');
44
+ templateTarget(directory);
45
+ directories.push(directory);
46
+ }
47
+ }
48
+ if (plan.entry !== undefined) {
49
+ entry = template(plan.entry, 'install entry');
50
+ if (!ENTRY_EXTENSION.test(entry.target))
51
+ throw new CliError('INVALID_MANIFEST', 'Install entry must be a .ts, .mts, .js or .mjs file');
52
+ }
53
+ if (plan.configDefaults !== undefined) {
54
+ try {
55
+ validateDefaults(plan.configDefaults);
56
+ }
57
+ catch {
58
+ throw new CliError('INVALID_MANIFEST', 'Invalid install configuration defaults');
59
+ }
60
+ configDefaults = plan.configDefaults;
61
+ }
62
+ }
63
+ const fileKeys = new Set();
64
+ for (const file of entry ? [...files, entry] : files) {
65
+ const key = paths.portablePathKey(file.target);
66
+ if (fileKeys.has(key))
67
+ throw new CliError('INVALID_MANIFEST', 'Duplicate install file');
68
+ fileKeys.add(key);
69
+ }
70
+ const directoryKeys = new Set();
71
+ for (const directory of directories) {
72
+ const key = paths.portablePathKey(directory);
73
+ if (directoryKeys.has(key) || fileKeys.has(key))
74
+ throw new CliError('INVALID_MANIFEST', 'Conflicting install directory');
75
+ directoryKeys.add(key);
76
+ }
77
+ for (const target of [...fileKeys, ...directoryKeys]) {
78
+ const parts = target.split('/');
79
+ for (let end = 1; end < parts.length; end++) {
80
+ if (fileKeys.has(parts.slice(0, end).join('/')))
81
+ throw new CliError('INVALID_MANIFEST', 'Install file cannot be a parent directory');
82
+ }
83
+ }
84
+ return {
85
+ files,
86
+ ...(directories.length ? { directories } : {}),
87
+ ...(entry ? { entry } : {}),
88
+ ...(configDefaults ? { configDefaults } : {}),
89
+ };
90
+ }
@@ -0,0 +1,17 @@
1
+ import type { InstallPlan } from './install-plan.js';
2
+ /**
3
+ * Project layout (D-0031, D-0032). Plugins write only into
4
+ * - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
5
+ * - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
6
+ * Files sit inside such a folder; plugins may also create the folder itself.
7
+ * The app plugin also prepares `src/apps/` (each app's folder is made by `dokebi app add`).
8
+ */
9
+ export declare function layoutAllowed(path: string, pluginId: string, kind?: 'file' | 'directory'): boolean;
10
+ /** Rejects plugin files and folders outside the layout with INVALID_MANIFEST. */
11
+ export declare function assertLayout(plan: Pick<InstallPlan, 'files' | 'directories'> & {
12
+ entry?: {
13
+ target: string;
14
+ } | undefined;
15
+ }, pluginId: string): void;
16
+ /** Installed entry paths from before D-0031 keep working; doctor reports them once. */
17
+ export declare function legacyLayout(path: string, pluginId: string): boolean;
package/dist/layout.js ADDED
@@ -0,0 +1,35 @@
1
+ import { path as paths } from '@dokebi/core/libraries';
2
+ import { CliError } from './errors.js';
3
+ /**
4
+ * Project layout (D-0031, D-0032). Plugins write only into
5
+ * - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
6
+ * - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
7
+ * Files sit inside such a folder; plugins may also create the folder itself.
8
+ * The app plugin also prepares `src/apps/` (each app's folder is made by `dokebi app add`).
9
+ */
10
+ export function layoutAllowed(path, pluginId, kind = 'file') {
11
+ const key = paths.portablePathKey(path);
12
+ // Files live inside a named folder: src/shared/<area>/… (models, modules, views — D-0032) or
13
+ // src/infra/<name>/…
14
+ const area = kind === 'file'
15
+ ? /^src\/(shared\/(models|modules|views)|infra\/[^/]+)\/.+$/
16
+ : /^src\/(shared\/(models|modules|views)|infra\/[^/]+)(\/.+)?$/;
17
+ if (area.test(key))
18
+ return true;
19
+ return pluginId === 'dokebi-app' && (key === 'src/apps' || key.startsWith('src/apps/'));
20
+ }
21
+ /** Rejects plugin files and folders outside the layout with INVALID_MANIFEST. */
22
+ export function assertLayout(plan, pluginId) {
23
+ const targets = [
24
+ ...plan.files.map((file) => [file.target, 'file']),
25
+ ...(plan.entry ? [[plan.entry.target, 'file']] : []),
26
+ ...(plan.directories ?? []).map((directory) => [directory, 'directory']),
27
+ ];
28
+ for (const [target, kind] of targets)
29
+ if (!layoutAllowed(target, pluginId, kind))
30
+ throw new CliError('INVALID_MANIFEST', `Plugin ${pluginId} writes outside src/shared/{models,modules,views}/ and src/infra/<name>/: ${target}`);
31
+ }
32
+ /** Installed entry paths from before D-0031 keep working; doctor reports them once. */
33
+ export function legacyLayout(path, pluginId) {
34
+ return !layoutAllowed(path, pluginId);
35
+ }
@@ -1,6 +1,9 @@
1
1
  export interface Dependency {
2
2
  name: string;
3
3
  version: string;
4
+ optional?: true;
5
+ /** npm package that provides it; `dokebi add` installs missing required ones (D-0030). */
6
+ package?: string;
4
7
  }
5
8
  export interface Template {
6
9
  source: string;
@@ -13,9 +16,28 @@ export interface PluginManifest {
13
16
  core: string;
14
17
  dependencies: Dependency[];
15
18
  files: Template[];
19
+ directories?: string[];
20
+ entry?: Template;
21
+ configDefaults?: Record<string, unknown>;
16
22
  optionsSchema?: string;
17
23
  documentation?: string;
24
+ installModule?: string;
18
25
  }
19
26
  export declare const PACKAGE_NAME: RegExp;
20
- export declare function readManifest(value: unknown): PluginManifest;
21
- export declare function orderPlugins(plugins: PluginManifest[], coreVersion: string): PluginManifest[];
27
+ export declare const ID: RegExp;
28
+ /**
29
+ * Reads package.json `dokebi` metadata. Schema 1 keeps the legacy top-level files list.
30
+ * Schema 2 moves files into `install` and adds optional dependencies, entry and defaults.
31
+ */
32
+ /**
33
+ * Reads a package manifest. New packages must follow the project layout (D-0031); manifests
34
+ * already stored in .dokebi/state.json are read with `checkLayout: false` so older installs work.
35
+ */
36
+ export declare function readManifest(value: unknown, options?: {
37
+ checkLayout?: boolean;
38
+ }): PluginManifest;
39
+ /** Reads a manifest previously normalized into .dokebi/state.json. */
40
+ export declare function readStoredManifest(value: unknown): PluginManifest;
41
+ export declare function orderPlugins(plugins: PluginManifest[], coreVersion: string, requiredPlugins?: ReadonlyArray<{
42
+ id: string;
43
+ }>): PluginManifest[];
package/dist/manifest.js CHANGED
@@ -1,68 +1,128 @@
1
- // [AI:생성] CLI 변경 경계를 검증하고 부분 실패 시 원본을 복구할 기반을 구현한다.
2
1
  import satisfies from 'semver/functions/satisfies.js';
3
2
  import valid from 'semver/functions/valid.js';
4
3
  import validRange from 'semver/ranges/valid.js';
5
4
  import { CliError } from './errors.js';
6
- import { object, relativePath, templateTarget } from './filesystem.js';
5
+ import { object, relativePath } from './filesystem.js';
6
+ import { readInstallPlan } from './install-plan.js';
7
+ import { assertLayout } from './layout.js';
7
8
  export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
8
- const ID = /^[a-z0-9][a-z0-9._/-]*$/;
9
+ export const ID = /^[a-z0-9][a-z0-9._/-]*$/;
9
10
  function field(value, label) {
10
11
  if (typeof value !== 'string' || !value)
11
12
  throw new CliError('INVALID_MANIFEST', label);
12
13
  return value;
13
14
  }
14
- export function readManifest(value) {
15
- const pkg = object(value, 'package');
16
- const metadata = object(pkg.dokebi, 'dokebi');
17
- const packageName = field(pkg.name, 'package.name');
18
- const version = field(pkg.version, 'package.version');
19
- const id = field(metadata.id, 'dokebi.id');
20
- const core = field(metadata.core, 'dokebi.core');
21
- if (!PACKAGE_NAME.test(packageName) ||
22
- !ID.test(id) ||
23
- !valid(version) ||
24
- !core.trim() ||
25
- !validRange(core) ||
26
- metadata.schemaVersion !== 1) {
27
- throw new CliError('INVALID_MANIFEST', 'Invalid plugin identity, version or schema');
28
- }
15
+ function readDependencies(value, extended) {
16
+ if (!Array.isArray(value))
17
+ throw new CliError('INVALID_MANIFEST', 'dependencies must be an array');
29
18
  const dependencies = [];
30
- const files = [];
31
- if (!Array.isArray(metadata.dependencies) || !Array.isArray(metadata.files))
32
- throw new CliError('INVALID_MANIFEST', 'dependencies and files must be arrays');
33
- for (const item of metadata.dependencies) {
19
+ for (const item of value) {
34
20
  const dep = object(item, 'dependency');
35
21
  const name = field(dep.name, 'dependency.name');
36
22
  const range = field(dep.version, 'dependency.version');
37
- if (!ID.test(name) ||
23
+ const keys = extended ? ['name', 'version', 'optional', 'package'] : ['name', 'version'];
24
+ if (Object.keys(dep).some((key) => !keys.includes(key)) ||
25
+ (dep.optional !== undefined && dep.optional !== true) ||
26
+ (dep.package !== undefined &&
27
+ (typeof dep.package !== 'string' || !PACKAGE_NAME.test(dep.package))) ||
28
+ !ID.test(name) ||
38
29
  !range.trim() ||
39
30
  !validRange(range) ||
40
31
  dependencies.some((entry) => entry.name === name)) {
41
32
  throw new CliError('INVALID_MANIFEST', 'Invalid or duplicate dependency');
42
33
  }
43
- dependencies.push({ name, version: range });
44
- }
45
- for (const item of metadata.files) {
46
- const file = object(item, 'template');
47
- const source = field(file.source, 'source');
48
- const target = field(file.target, 'target');
49
- relativePath(source);
50
- templateTarget(target);
51
- if (files.some((entry) => entry.target === target))
52
- throw new CliError('INVALID_MANIFEST', 'Duplicate template target');
53
- files.push({ source, target });
34
+ dependencies.push({
35
+ name,
36
+ version: range,
37
+ ...(dep.optional ? { optional: true } : {}),
38
+ ...(typeof dep.package === 'string' ? { package: dep.package } : {}),
39
+ });
54
40
  }
41
+ return dependencies;
42
+ }
43
+ function readResources(metadata) {
55
44
  const resources = {};
56
- for (const key of ['optionsSchema', 'documentation']) {
45
+ for (const key of ['optionsSchema', 'documentation', 'installModule']) {
57
46
  if (metadata[key] !== undefined) {
58
47
  const path = field(metadata[key], key);
59
48
  relativePath(path);
60
49
  resources[key] = path;
61
50
  }
62
51
  }
63
- return { packageName, version, id, core, dependencies, files, ...resources };
52
+ return resources;
53
+ }
54
+ /**
55
+ * Reads package.json `dokebi` metadata. Schema 1 keeps the legacy top-level files list.
56
+ * Schema 2 moves files into `install` and adds optional dependencies, entry and defaults.
57
+ */
58
+ /**
59
+ * Reads a package manifest. New packages must follow the project layout (D-0031); manifests
60
+ * already stored in .dokebi/state.json are read with `checkLayout: false` so older installs work.
61
+ */
62
+ export function readManifest(value, options = {}) {
63
+ const pkg = object(value, 'package');
64
+ if (pkg.dokebi === undefined)
65
+ throw new CliError('INVALID_MANIFEST', 'Package is not a Dokebi plugin (no dokebi field)');
66
+ const metadata = object(pkg.dokebi, 'dokebi');
67
+ const packageName = field(pkg.name, 'package.name');
68
+ const version = field(pkg.version, 'package.version');
69
+ const id = field(metadata.id, 'dokebi.id');
70
+ const core = field(metadata.core, 'dokebi.core');
71
+ const schemaVersion = metadata.schemaVersion;
72
+ if (!PACKAGE_NAME.test(packageName) ||
73
+ !ID.test(id) ||
74
+ !valid(version) ||
75
+ !core.trim() ||
76
+ !validRange(core) ||
77
+ (schemaVersion !== 1 && schemaVersion !== 2)) {
78
+ throw new CliError('INVALID_MANIFEST', 'Invalid plugin identity, version or schema');
79
+ }
80
+ const extended = schemaVersion === 2;
81
+ const dependencies = readDependencies(metadata.dependencies, extended);
82
+ let legacy = [];
83
+ if (extended) {
84
+ if (metadata.files !== undefined)
85
+ throw new CliError('INVALID_MANIFEST', 'Schema 2 declares files under dokebi.install');
86
+ }
87
+ else {
88
+ if (!Array.isArray(metadata.files))
89
+ throw new CliError('INVALID_MANIFEST', 'dependencies and files must be arrays');
90
+ legacy = metadata.files;
91
+ }
92
+ const legacyFiles = readInstallPlan({ files: legacy }, [], false).files;
93
+ const install = readInstallPlan(metadata.install, legacyFiles, extended);
94
+ if (options.checkLayout !== false)
95
+ assertLayout(install, id);
96
+ return { packageName, version, id, core, dependencies, ...install, ...readResources(metadata) };
97
+ }
98
+ /** Reads a manifest previously normalized into .dokebi/state.json. */
99
+ export function readStoredManifest(value) {
100
+ const stored = object(value, 'plugin');
101
+ const install = {};
102
+ if (stored.files !== undefined)
103
+ install.files = stored.files;
104
+ if (stored.directories !== undefined)
105
+ install.directories = stored.directories;
106
+ if (stored.entry !== undefined)
107
+ install.entry = stored.entry;
108
+ if (stored.configDefaults !== undefined)
109
+ install.configDefaults = stored.configDefaults;
110
+ return readManifest({
111
+ name: stored.packageName,
112
+ version: stored.version,
113
+ dokebi: {
114
+ schemaVersion: 2,
115
+ id: stored.id,
116
+ core: stored.core,
117
+ dependencies: stored.dependencies,
118
+ install,
119
+ ...(stored.optionsSchema === undefined ? {} : { optionsSchema: stored.optionsSchema }),
120
+ ...(stored.documentation === undefined ? {} : { documentation: stored.documentation }),
121
+ ...(stored.installModule === undefined ? {} : { installModule: stored.installModule }),
122
+ },
123
+ }, { checkLayout: false });
64
124
  }
65
- export function orderPlugins(plugins, coreVersion) {
125
+ export function orderPlugins(plugins, coreVersion, requiredPlugins = []) {
66
126
  const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
67
127
  if (byId.size !== plugins.length)
68
128
  throw new CliError('DUPLICATE_PLUGIN', 'Duplicate plugin ID');
@@ -78,9 +138,14 @@ export function orderPlugins(plugins, coreVersion) {
78
138
  throw new CliError('INCOMPATIBLE_VERSION', plugin.id);
79
139
  visiting.add(plugin.id);
80
140
  for (const dep of plugin.dependencies) {
141
+ if (requiredPlugins.some((required) => required.id === dep.name))
142
+ throw new CliError('REQUIRED_PLUGIN_DEPENDENCY', 'Use app.config for core configuration; required plugins are not factory dependency references');
81
143
  const provider = byId.get(dep.name);
82
- if (!provider)
144
+ if (!provider) {
145
+ if (dep.optional)
146
+ continue;
83
147
  throw new CliError('MISSING_DEPENDENCY', dep.name);
148
+ }
84
149
  if (!satisfies(provider.version, dep.version))
85
150
  throw new CliError('INCOMPATIBLE_VERSION', dep.name);
86
151
  visit(provider);
@@ -0,0 +1,2 @@
1
+ export declare function validateDefaults(value: unknown): asserts value is Record<string, unknown>;
2
+ export declare function mergeInstallDefaults(source: string, id: string, defaults: unknown): string;
@@ -0,0 +1,79 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { isMap, parseDocument } from 'yaml';
3
+ import { parseProjectConfig, renderConfig } from './configuration.js';
4
+ import { CliError } from './errors.js';
5
+ function invalid() {
6
+ throw new CliError('INVALID_INSTALL_DEFAULTS', 'Plugin defaults must be bounded JSON values within their own configuration namespace.');
7
+ }
8
+ function isRecord(value) {
9
+ return (typeof value === 'object' &&
10
+ value !== null &&
11
+ !Array.isArray(value) &&
12
+ Object.getPrototypeOf(value) === Object.prototype);
13
+ }
14
+ export function validateDefaults(value) {
15
+ if (!isRecord(value))
16
+ invalid();
17
+ let count = 0;
18
+ const seen = new WeakSet();
19
+ const visit = (item, depth) => {
20
+ count += 1;
21
+ if (count > 2000 || depth > 16)
22
+ invalid();
23
+ if (item === null || typeof item === 'string' || typeof item === 'boolean')
24
+ return;
25
+ if (typeof item === 'number' && Number.isFinite(item))
26
+ return;
27
+ if (typeof item !== 'object' || item === null || seen.has(item))
28
+ invalid();
29
+ seen.add(item);
30
+ if (Array.isArray(item)) {
31
+ for (const child of item)
32
+ visit(child, depth + 1);
33
+ return;
34
+ }
35
+ if (!isRecord(item))
36
+ invalid();
37
+ for (const [key, child] of Object.entries(item)) {
38
+ if (['__proto__', 'constructor', 'prototype', '<<'].includes(key))
39
+ invalid();
40
+ visit(child, depth + 1);
41
+ }
42
+ };
43
+ visit(value, 0);
44
+ if (Buffer.byteLength(JSON.stringify(value)) > 1024 * 1024)
45
+ invalid();
46
+ }
47
+ export function mergeInstallDefaults(source, id, defaults) {
48
+ validateDefaults(defaults);
49
+ const before = parseProjectConfig(source);
50
+ if (Object.keys(defaults).length === 0)
51
+ return source;
52
+ const document = parseDocument(source);
53
+ const merge = (target, values) => {
54
+ for (const [key, value] of Object.entries(values)) {
55
+ if (!target.has(key))
56
+ target.set(key, document.createNode(value));
57
+ else {
58
+ const current = target.get(key, true);
59
+ if (isMap(current) && isRecord(value))
60
+ merge(current, value);
61
+ }
62
+ }
63
+ };
64
+ if (!document.hasIn(['options']))
65
+ document.setIn(['options'], document.createNode({}));
66
+ if (!document.hasIn(['options', id]))
67
+ document.setIn(['options', id], document.createNode(defaults));
68
+ else {
69
+ const target = document.getIn(['options', id], true);
70
+ if (isMap(target))
71
+ merge(target, defaults);
72
+ }
73
+ const result = renderConfig(document);
74
+ const after = parseProjectConfig(result);
75
+ for (const [key, value] of Object.entries(before.options))
76
+ if (key !== id && !isDeepStrictEqual(value, after.options[key]))
77
+ invalid();
78
+ return result;
79
+ }
@@ -2,5 +2,11 @@ export declare function packageSpec(spec: string, cwd: string): string;
2
2
  export declare function dependencyMap(pkg: Record<string, unknown>): Record<string, string>;
3
3
  export declare function validateProjectPackage(pkg: Record<string, unknown>): void;
4
4
  export declare class NpmClient {
5
+ #private;
6
+ /** Installs into a standalone directory (the project or the CLI's staging). */
5
7
  install(directory: string, spec?: string): Promise<void>;
8
+ /** `npm pack` of a workspace package into `destination`; returns the archive path (D-0035). */
9
+ pack(directory: string, destination: string): Promise<string>;
10
+ /** `npm install` at a workspace root: brings its lock and node_modules in line (D-0035). */
11
+ installWorkspace(root: string): Promise<void>;
6
12
  }