@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
@@ -1,18 +1,14 @@
1
- // [AI:생성] 설치 중 플러그인 코드를 실행하지 않고 실제 인스턴스로 의존 플러그인을 연결한다.
2
1
  import { spawn } from 'node:child_process';
3
- import { readFile, writeFile } from 'node:fs/promises';
2
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
4
3
  import { isAbsolute, resolve } from 'node:path';
5
4
  import { CliError } from './errors.js';
6
5
  import { json, object, parseObject } from './filesystem.js';
7
6
  import { PACKAGE_NAME } from './manifest.js';
8
- // [AI:수정] 패키지와 lockfile 구조에 맞는 경로 정규화로 데이터 변형 원인을 제거한다.
9
7
  import { normalizeLockPaths, normalizePackagePaths } from './npm-paths.js';
10
8
  export function packageSpec(spec, cwd) {
11
9
  if (spec.endsWith('.tgz'))
12
10
  return `file:${resolve(cwd, spec.replace(/^file:/, ''))}`;
13
- // [AI:수정] 저장소의 TypeScript 포맷과 import 순서 규칙을 적용한다.
14
11
  const match = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@([a-zA-Z0-9.*^~<>=| -]+))?$/.exec(spec);
15
- // [AI:수정] 타입 명시와 경로 제어 문자 검증을 유지하면서 저장소 검사 규칙을 충족한다.
16
12
  if (!match?.[1] || !PACKAGE_NAME.test(match[1]))
17
13
  throw new CliError('INVALID_SPEC', 'Use a registry package or local .tgz');
18
14
  return spec;
@@ -34,7 +30,6 @@ export function validateProjectPackage(pkg) {
34
30
  if (typeof value !== 'string' ||
35
31
  value.startsWith('workspace:') ||
36
32
  value.startsWith('link:') ||
37
- // [AI:수정] 문서의 지원 범위대로 git·URL·디렉터리 의존성을 변경 전에 거부한다.
38
33
  (value.startsWith('file:')
39
34
  ? !isAbsolute(value.slice(5)) || !value.endsWith('.tgz')
40
35
  : !/^[a-zA-Z0-9.*^~<>=| -]+$/.test(value))) {
@@ -44,11 +39,9 @@ export function validateProjectPackage(pkg) {
44
39
  }
45
40
  }
46
41
  export class NpmClient {
42
+ /** Installs into a standalone directory (the project or the CLI's staging). */
47
43
  async install(directory, spec) {
48
- const npmScript = process.env.npm_execpath;
49
- const command = npmScript?.endsWith('npm-cli.js') ? process.execPath : 'npm';
50
- const args = [
51
- ...(npmScript?.endsWith('npm-cli.js') ? [npmScript] : []),
44
+ await this.#run(directory, [
52
45
  'install',
53
46
  ...(spec ? [spec] : []),
54
47
  '--ignore-scripts',
@@ -57,9 +50,38 @@ export class NpmClient {
57
50
  '--no-fund',
58
51
  '--workspaces=false',
59
52
  '--package-lock=true',
60
- ];
53
+ ]);
54
+ for (const file of ['package.json', 'package-lock.json']) {
55
+ const content = parseObject(await readFile(resolve(directory, file), 'utf8'), file);
56
+ if (file === 'package.json')
57
+ normalizePackagePaths(content, directory);
58
+ else
59
+ normalizeLockPaths(content, directory);
60
+ await writeFile(resolve(directory, file), json(content));
61
+ }
62
+ }
63
+ /** `npm pack` of a workspace package into `destination`; returns the archive path (D-0035). */
64
+ async pack(directory, destination) {
65
+ const before = new Set(await readdir(destination));
66
+ await this.#run(directory, ['pack', '--ignore-scripts', '--pack-destination', destination]);
67
+ const created = (await readdir(destination)).filter((name) => !before.has(name) && name.endsWith('.tgz'));
68
+ if (created.length !== 1)
69
+ throw new CliError('NPM_FAILED', 'npm pack created no archive');
70
+ return resolve(destination, created[0]);
71
+ }
72
+ /** `npm install` at a workspace root: brings its lock and node_modules in line (D-0035). */
73
+ async installWorkspace(root) {
74
+ await this.#run(root, ['install', '--ignore-scripts', '--no-audit', '--no-fund']);
75
+ }
76
+ async #run(directory, npmArgs) {
77
+ const npmScript = process.env.npm_execpath;
78
+ const command = npmScript?.endsWith('npm-cli.js') ? process.execPath : 'npm';
79
+ const args = [...(npmScript?.endsWith('npm-cli.js') ? [npmScript] : []), ...npmArgs];
61
80
  await new Promise((resolvePromise, reject) => {
62
- const child = spawn(command, args, { cwd: directory, stdio: 'ignore' });
81
+ const child = spawn(command, args, {
82
+ cwd: directory,
83
+ stdio: 'ignore',
84
+ });
63
85
  let timedOut = false;
64
86
  const timer = setTimeout(() => {
65
87
  timedOut = true;
@@ -77,13 +99,5 @@ export class NpmClient {
77
99
  reject(new CliError('NPM_FAILED', timedOut ? 'npm timed out' : `npm failed with exit code ${code}`));
78
100
  });
79
101
  });
80
- for (const file of ['package.json', 'package-lock.json']) {
81
- const content = parseObject(await readFile(resolve(directory, file), 'utf8'), file);
82
- if (file === 'package.json')
83
- normalizePackagePaths(content, directory);
84
- else
85
- normalizeLockPaths(content, directory);
86
- await writeFile(resolve(directory, file), json(content));
87
- }
88
102
  }
89
103
  }
package/dist/npm-paths.js CHANGED
@@ -1,8 +1,6 @@
1
- // [AI:생성] 사용자 데이터를 재귀 순회하지 않고 npm 경로 필드만 보정한다.
2
1
  import { resolve } from 'node:path';
3
2
  import { object } from './filesystem.js';
4
3
  const DEPENDENCY_FIELDS = [
5
- // [AI:수정] 데이터 보존 수정과 회귀 테스트에 저장소 포맷을 적용한다.
6
4
  'dependencies',
7
5
  'devDependencies',
8
6
  'optionalDependencies',
package/dist/output.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const CLI_VERSION = "0.1.0";
1
+ export declare const CLI_VERSION = "0.2.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,12 +1,10 @@
1
- // [AI:생성] JSON 응답 버전과 오류·변경 상태를 안정적인 계약으로 제공한다.
2
1
  import { CliError } from './errors.js';
3
- export const CLI_VERSION = '0.1.0';
2
+ export const CLI_VERSION = '0.2.0';
4
3
  export function envelope(command, result, ok = true) {
5
4
  return { schemaVersion: 1, cliVersion: CLI_VERSION, command, ok, result, error: null };
6
5
  }
7
6
  export function failure(command, error, mutationStarted) {
8
7
  const code = error instanceof CliError ? error.code : 'INTERNAL_ERROR';
9
- // [AI:수정] CLI 진단·스키마·회귀 검증의 포맷과 import 순서를 맞춘다.
10
8
  const mutation = !mutationStarted
11
9
  ? 'none'
12
10
  : code === 'COMMIT_FAILED'
@@ -14,13 +12,14 @@ export function failure(command, error, mutationStarted) {
14
12
  : code === 'RECOVERY_REQUIRED'
15
13
  ? 'recovery-required'
16
14
  : 'unknown';
17
- const phase = code === 'NPM_FAILED'
18
- ? 'install'
19
- : ['COMMIT_FAILED', 'RECOVERY_REQUIRED', 'CONCURRENT_CHANGE'].includes(code)
20
- ? 'commit'
21
- : code === 'USAGE'
22
- ? 'arguments'
23
- : 'unknown';
15
+ const phase = (error instanceof CliError ? error.phase : undefined) ??
16
+ (code === 'NPM_FAILED'
17
+ ? 'install'
18
+ : ['COMMIT_FAILED', 'RECOVERY_REQUIRED', 'CONCURRENT_CHANGE'].includes(code)
19
+ ? 'commit'
20
+ : code === 'USAGE'
21
+ ? 'arguments'
22
+ : 'unknown');
24
23
  return {
25
24
  schemaVersion: 1,
26
25
  cliVersion: CLI_VERSION,
@@ -0,0 +1,43 @@
1
+ import { type InstallPlan } from './install-plan.js';
2
+ import { type PluginManifest } from './manifest.js';
3
+ import type { Installation } from './project-state.js';
4
+ export interface ConfigChange {
5
+ op: 'rename' | 'remove' | 'default';
6
+ from?: string[];
7
+ to?: string[];
8
+ path?: string[];
9
+ value?: unknown;
10
+ }
11
+ export interface LifecyclePlan extends Omit<InstallPlan, 'configDefaults'> {
12
+ /** Static defaults first, then module defaults; each only fills missing values. */
13
+ defaults: Record<string, unknown>[];
14
+ requires: string[];
15
+ configChanges: ConfigChange[];
16
+ }
17
+ export interface CheckDiagnostic {
18
+ code: string;
19
+ severity: 'error' | 'warning';
20
+ optionPath?: string[];
21
+ /** One-line next step for the developer, such as an install command. */
22
+ hint?: string;
23
+ }
24
+ export interface CheckResult {
25
+ requires: string[];
26
+ diagnostics: CheckDiagnostic[];
27
+ }
28
+ export interface LifecycleInput {
29
+ /** Directory whose node_modules contains the plugin package (project root or staging). */
30
+ readonly base: string;
31
+ readonly plugin: PluginManifest;
32
+ readonly options: Record<string, unknown>;
33
+ readonly plugins: readonly PluginManifest[];
34
+ readonly projectPackages: readonly string[];
35
+ readonly installation?: Installation;
36
+ }
37
+ export declare function optionsOf(options: Record<string, unknown>, id: string): Record<string, unknown>;
38
+ export declare function optionPath(value: unknown): string[];
39
+ /** Static `dokebi.install` merged with the module's planInstall or planUpgrade result. */
40
+ export declare function planLifecycle(input: LifecycleInput, phase: 'planInstall' | 'planUpgrade', extra?: Record<string, unknown>): Promise<LifecyclePlan>;
41
+ export declare function checkPlugin(input: LifecycleInput): Promise<CheckResult>;
42
+ /** Runs check for every plugin and returns results by plugin id. */
43
+ export declare function checkAll(inputs: readonly Omit<LifecycleInput, 'plugins'>[], plugins: readonly PluginManifest[]): Promise<Map<string, CheckResult>>;
@@ -0,0 +1,194 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CliError } from './errors.js';
4
+ import { object } from './filesystem.js';
5
+ import { InstallPlanningError, PHASE_MISSING, runInstallModule, } from './install-module.js';
6
+ import { readInstallPlan } from './install-plan.js';
7
+ import { assertLayout } from './layout.js';
8
+ import { ID } from './manifest.js';
9
+ import { packageFile, workspaceRootOf } from './workspace.js';
10
+ // Printable single line: no control characters (log/terminal injection) and a short bound.
11
+ const HINT = /^[^\p{Cc}]{1,200}$/u;
12
+ const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
13
+ const KEY = /^[A-Za-z0-9_-]{1,64}$/;
14
+ export function optionsOf(options, id) {
15
+ const value = Object.hasOwn(options, id) ? options[id] : undefined;
16
+ return value === undefined || value === null ? {} : object(value, 'plugin options');
17
+ }
18
+ function requirements(value, self) {
19
+ if (value === undefined)
20
+ return [];
21
+ if (!Array.isArray(value))
22
+ throw new Error('requires');
23
+ const result = [];
24
+ for (const item of value) {
25
+ if (typeof item !== 'string' || !ID.test(item) || item === self || result.includes(item))
26
+ throw new Error('requires');
27
+ result.push(item);
28
+ }
29
+ return result;
30
+ }
31
+ export function optionPath(value) {
32
+ if (!Array.isArray(value) || value.length === 0 || value.length > 16)
33
+ throw new Error('path');
34
+ for (const key of value) {
35
+ if (typeof key !== 'string' || !KEY.test(key))
36
+ throw new Error('path');
37
+ if (['__proto__', 'constructor', 'prototype'].includes(key))
38
+ throw new Error('path');
39
+ }
40
+ return [...value];
41
+ }
42
+ function configChanges(value) {
43
+ if (value === undefined)
44
+ return [];
45
+ if (!Array.isArray(value))
46
+ throw new Error('configChanges');
47
+ return value.map((raw) => {
48
+ const change = object(raw, 'config change');
49
+ const keys = Object.keys(change);
50
+ if (change.op === 'rename' && keys.every((key) => ['op', 'from', 'to'].includes(key)))
51
+ return { op: 'rename', from: optionPath(change.from), to: optionPath(change.to) };
52
+ if (change.op === 'remove' && keys.every((key) => ['op', 'path'].includes(key)))
53
+ return { op: 'remove', path: optionPath(change.path) };
54
+ if (change.op === 'default' && keys.every((key) => ['op', 'path', 'value'].includes(key)))
55
+ return { op: 'default', path: optionPath(change.path), value: change.value };
56
+ throw new Error('configChanges');
57
+ });
58
+ }
59
+ async function run(input, phase, extra = {}) {
60
+ const modulePath = input.plugin.installModule;
61
+ if (!modulePath)
62
+ return PHASE_MISSING;
63
+ const path = await packageFile(input.base, input.plugin.packageName, modulePath);
64
+ // A workspace member's packages live anywhere below the workspace root (D-0035).
65
+ const workspace = await workspaceRootOf(input.base);
66
+ const nodeModules = await realpath(workspace ?? join(input.base, 'node_modules'));
67
+ try {
68
+ return await runInstallModule(path, phase, {
69
+ options: optionsOf(input.options, input.plugin.id),
70
+ plugins: input.plugins.map(({ id, version, packageName }) => ({
71
+ id,
72
+ version,
73
+ packageName,
74
+ })),
75
+ projectPackages: [...input.projectPackages],
76
+ ...extra,
77
+ }, [nodeModules]);
78
+ }
79
+ catch (error) {
80
+ throw new CliError('PLUGIN_INSTALL_FAILED', input.plugin.id +
81
+ ': ' +
82
+ (error instanceof InstallPlanningError ? error.code : 'INSTALL_MODULE_FAILED'), { details: { plugin: input.plugin.id, phase } });
83
+ }
84
+ }
85
+ function invalidPlan(id) {
86
+ return new CliError('INVALID_MANIFEST', 'Invalid installation plan: ' + id);
87
+ }
88
+ /** Static `dokebi.install` merged with the module's planInstall or planUpgrade result. */
89
+ export async function planLifecycle(input, phase, extra = {}) {
90
+ const raw = await run(input, phase, extra);
91
+ const plugin = input.plugin;
92
+ const base = {
93
+ files: plugin.files,
94
+ ...(plugin.directories ? { directories: plugin.directories } : {}),
95
+ ...(plugin.entry ? { entry: plugin.entry } : {}),
96
+ ...(plugin.configDefaults ? { configDefaults: plugin.configDefaults } : {}),
97
+ };
98
+ const staticDefaults = base.configDefaults ? [base.configDefaults] : [];
99
+ if (raw === PHASE_MISSING) {
100
+ const { configDefaults: _ignored, ...rest } = base;
101
+ return { ...rest, defaults: staticDefaults, requires: [], configChanges: [] };
102
+ }
103
+ let plan;
104
+ let requires;
105
+ let changes;
106
+ try {
107
+ plan = object(raw, 'installation plan');
108
+ const allowed = phase === 'planInstall'
109
+ ? ['directories', 'files', 'entry', 'configDefaults', 'requires']
110
+ : ['directories', 'files', 'entry', 'configChanges', 'requires'];
111
+ if (Object.keys(plan).some((key) => !allowed.includes(key)))
112
+ throw new Error('keys');
113
+ requires = requirements(plan.requires, plugin.id);
114
+ changes = configChanges(plan.configChanges);
115
+ }
116
+ catch {
117
+ throw invalidPlan(plugin.id);
118
+ }
119
+ if (plan.entry !== undefined && base.entry !== undefined)
120
+ throw invalidPlan(plugin.id);
121
+ const dynamic = readInstallPlan({
122
+ ...(plan.files === undefined ? {} : { files: plan.files }),
123
+ directories: [...(base.directories ?? []), ...(plan.directories ?? [])],
124
+ ...(plan.entry !== undefined
125
+ ? { entry: plan.entry }
126
+ : base.entry
127
+ ? { entry: base.entry }
128
+ : {}),
129
+ ...(plan.configDefaults === undefined ? {} : { configDefaults: plan.configDefaults }),
130
+ }, base.files);
131
+ // Files and folders an install module adds follow the same layout as the manifest (D-0031).
132
+ try {
133
+ assertLayout({
134
+ files: dynamic.files.slice(base.files.length),
135
+ directories: plan.directories ?? [],
136
+ ...(plan.entry !== undefined ? { entry: dynamic.entry } : {}),
137
+ }, plugin.id);
138
+ }
139
+ catch {
140
+ throw invalidPlan(plugin.id);
141
+ }
142
+ const { configDefaults, ...files } = dynamic;
143
+ return {
144
+ ...files,
145
+ defaults: configDefaults ? [...staticDefaults, configDefaults] : staticDefaults,
146
+ requires,
147
+ configChanges: changes,
148
+ };
149
+ }
150
+ export async function checkPlugin(input) {
151
+ const raw = await run(input, 'check', {
152
+ installation: input.installation?.entry ? { entry: input.installation.entry } : {},
153
+ });
154
+ if (raw === PHASE_MISSING)
155
+ return { requires: [], diagnostics: [] };
156
+ try {
157
+ const result = object(raw, 'check result');
158
+ if (Object.keys(result).some((key) => key !== 'requires' && key !== 'diagnostics'))
159
+ throw new Error('keys');
160
+ const diagnostics = [];
161
+ if (result.diagnostics !== undefined) {
162
+ if (!Array.isArray(result.diagnostics) || result.diagnostics.length > 100)
163
+ throw new Error('diagnostics');
164
+ for (const raw of result.diagnostics) {
165
+ const item = object(raw, 'diagnostic');
166
+ if (Object.keys(item).some((key) => !['code', 'severity', 'optionPath', 'hint'].includes(key)) ||
167
+ typeof item.code !== 'string' ||
168
+ !CODE.test(item.code) ||
169
+ (item.severity !== 'error' && item.severity !== 'warning') ||
170
+ (item.hint !== undefined && (typeof item.hint !== 'string' || !HINT.test(item.hint))))
171
+ throw new Error('diagnostic');
172
+ diagnostics.push({
173
+ code: item.code,
174
+ severity: item.severity,
175
+ ...(item.optionPath === undefined ? {} : { optionPath: optionPath(item.optionPath) }),
176
+ ...(item.hint === undefined ? {} : { hint: item.hint }),
177
+ });
178
+ }
179
+ }
180
+ return { requires: requirements(result.requires, input.plugin.id), diagnostics };
181
+ }
182
+ catch (error) {
183
+ if (error instanceof CliError)
184
+ throw error;
185
+ throw new CliError('INVALID_MANIFEST', 'Invalid check result: ' + input.plugin.id);
186
+ }
187
+ }
188
+ /** Runs check for every plugin and returns results by plugin id. */
189
+ export async function checkAll(inputs, plugins) {
190
+ const results = new Map();
191
+ for (const input of inputs)
192
+ results.set(input.plugin.id, await checkPlugin({ ...input, plugins }));
193
+ return results;
194
+ }
@@ -6,5 +6,12 @@ export interface OptionsCheck {
6
6
  keyword: string;
7
7
  }>;
8
8
  }
9
+ export declare class PluginOptionsValidator {
10
+ #private;
11
+ constructor(options?: {
12
+ allErrors?: boolean;
13
+ });
14
+ check(root: string, plugin: PluginManifest, options: unknown): Promise<OptionsCheck>;
15
+ }
9
16
  export declare function checkPluginOptions(root: string, plugin: PluginManifest, options: unknown): Promise<OptionsCheck>;
10
17
  export declare function validatePluginOptions(root: string, plugins: PluginManifest[], options: Record<string, unknown>): Promise<void>;
@@ -1,50 +1,32 @@
1
- // [AI:생성] 설치 코드 실행 없이 표준 JSON Schema로 옵션을 검증하고 설정을 그대로 보존한다.
2
- import { readFile } from 'node:fs/promises';
1
+ import { ConfigValidator, loadSchema } from '@dokebi/config';
3
2
  import { CliError } from './errors.js';
4
- import { parseObject, securePath } from './filesystem.js';
5
- // [AI:수정] CLI 진단·스키마·회귀 검증의 포맷과 import 순서를 맞춘다.
6
- export async function checkPluginOptions(root, plugin, options) {
7
- if (!plugin.optionsSchema)
8
- return { schema: null, errors: [] };
9
- const path = 'node_modules/' + plugin.packageName + '/' + plugin.optionsSchema;
10
- const text = await readFile(await securePath(root, path), 'utf8');
11
- if (Buffer.byteLength(text) > 131072)
12
- throw new CliError('INVALID_OPTIONS_SCHEMA', plugin.id);
13
- let schema;
14
- try {
15
- schema = parseObject(text, 'options schema');
16
- if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema' ||
17
- schema.type !== 'object')
18
- throw new Error('Expected draft 2020-12 object schema');
19
- const { Ajv2020 } = await import('ajv/dist/2020.js');
20
- const ajv = new Ajv2020({
21
- strict: true,
22
- allErrors: false,
23
- coerceTypes: false,
24
- useDefaults: false,
25
- removeAdditional: false,
26
- ownProperties: true,
27
- });
28
- const validate = ajv.compile(schema);
29
- // [AI:수정] Ajv의 동기 검증 함수 타입을 유지하면서 비동기 스키마를 명시적으로 거부한다.
30
- if ('$async' in validate && validate.$async)
31
- throw new Error('Async schemas are unsupported');
32
- const valid = validate(options);
33
- return {
34
- schema,
35
- errors: valid
36
- ? []
37
- : (validate.errors ?? []).map(({ instancePath, keyword }) => ({ instancePath, keyword })),
38
- };
3
+ import { packageFile } from './workspace.js';
4
+ export class PluginOptionsValidator {
5
+ #validator;
6
+ constructor(options = {}) {
7
+ this.#validator = new ConfigValidator(options);
39
8
  }
40
- catch (cause) {
41
- throw new CliError('INVALID_OPTIONS_SCHEMA', plugin.id, { cause });
9
+ async check(root, plugin, options) {
10
+ if (!plugin.optionsSchema)
11
+ return { schema: null, errors: [] };
12
+ const path = await packageFile(root, plugin.packageName, plugin.optionsSchema);
13
+ try {
14
+ const schema = await loadSchema(path);
15
+ const checked = await this.#validator.check(schema, options);
16
+ return { schema, errors: checked.errors };
17
+ }
18
+ catch (cause) {
19
+ throw new CliError('INVALID_OPTIONS_SCHEMA', plugin.id, { cause });
20
+ }
42
21
  }
43
22
  }
23
+ export async function checkPluginOptions(root, plugin, options) {
24
+ return new PluginOptionsValidator().check(root, plugin, options);
25
+ }
44
26
  export async function validatePluginOptions(root, plugins, options) {
27
+ const validator = new PluginOptionsValidator();
45
28
  for (const plugin of plugins) {
46
- // [AI:수정] 추가한 버전·옵션 경계 검사를 저장소 포맷에 맞춘다.
47
- const result = await checkPluginOptions(root, plugin, Object.hasOwn(options, plugin.id) ? (options[plugin.id] ?? {}) : {});
29
+ const result = await validator.check(root, plugin, Object.hasOwn(options, plugin.id) ? (options[plugin.id] ?? {}) : {});
48
30
  if (result.errors.length)
49
31
  throw new CliError('INVALID_PLUGIN_OPTIONS', plugin.id, {
50
32
  details: { plugin: plugin.id, errors: result.errors },
@@ -1 +1 @@
1
- export declare const PROJECT_GUIDE = "# Dokebi \uD504\uB85C\uC81D\uD2B8 \uC791\uC5C5 \uC548\uB0B4\n\n\uC124\uCE58\uB41C \uCF54\uC5B4 \uACC4\uC57D\uC740 [\uCF54\uC5B4 AI \uC0AC\uC6A9 \uC548\uB0B4](node_modules/@dokebi/core/AI.md)\uB97C \uC77D\uB294\uB2E4.\n\uD328\uD0A4\uC9C0 \uBC84\uC804\uC740 package.json\uACFC \uC2E4\uC81C node_modules\uB97C \uD568\uAED8 \uD655\uC778\uD55C\uB2E4.\n\n- \uD504\uB85C\uC81D\uD2B8 \uB8E8\uD2B8\uC5D0\uC11C \uC124\uCE58\uB41C CLI\uB85C dokebi inspect --json, dokebi doctor --json\uC744 \uC2E4\uD589\uD55C\uB2E4.\n- inspect\uC5D0 \uB098\uC628 \uD50C\uB7EC\uADF8\uC778 documentation\uACFC optionsSchema\uB97C \uD655\uC778\uD55C\uB2E4.\n- src/main.ts\uB294 \uC0AC\uC6A9\uC790 \uC9C4\uC785\uC810, dokebi.json\uC740 \uC0AC\uC6A9\uC790 \uC635\uC158\uC774\uB2E4.\n- src/dokebi.generated.ts\uC640 .dokebi/state.json\uC740 \uC9C1\uC811 \uD3B8\uC9D1\uD558\uC9C0 \uC54A\uB294\uB2E4.\n- add\uB294 \uD328\uD0A4\uC9C0 \uC9C0\uC815, remove\uB294 \uD50C\uB7EC\uADF8\uC778 ID\uB97C \uBC1B\uB294\uB2E4. \uBA3C\uC800 --dry-run\uC73C\uB85C \uAC80\uD1A0\uD55C\uB2E4.\n- doctor\uB294 \uC790\uB3D9 \uBCF5\uAD6C\uD558\uC9C0 \uC54A\uB294\uB2E4. \uC7A0\uAE08\uC774\uB098 \uBC31\uC5C5\uC744 \uC784\uC758 \uC0AD\uC81C\uD558\uAC70\uB098 \uC2E4\uD328\uB97C \uBB34\uC870\uAC74 \uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4.\n- \uBCC0\uACBD \uD6C4 npm start\uC640 \uD504\uB85C\uC81D\uD2B8\uBCC4 \uD14C\uC2A4\uD2B8\uB97C \uC2E4\uD589\uD55C\uB2E4.\n";
1
+ export declare const PROJECT_GUIDE = "# Dokebi \uD504\uB85C\uC81D\uD2B8 \uC791\uC5C5 \uC548\uB0B4\n\n\uC124\uCE58\uB41C \uCF54\uC5B4 \uACC4\uC57D\uC740 [\uCF54\uC5B4 AI \uC0AC\uC6A9 \uC548\uB0B4](node_modules/@dokebi/core/AI.md)\uB97C \uC77D\uB294\uB2E4.\n\uD328\uD0A4\uC9C0 \uBC84\uC804\uC740 package.json\uACFC \uC2E4\uC81C node_modules\uB97C \uD568\uAED8 \uD655\uC778\uD55C\uB2E4.\n\n- \uD504\uB85C\uC81D\uD2B8 \uB8E8\uD2B8\uC5D0\uC11C \uC124\uCE58\uB41C CLI\uB85C dokebi inspect --json, dokebi doctor --json\uC744 \uC2E4\uD589\uD55C\uB2E4.\n- inspect\uC5D0 \uB098\uC628 \uD50C\uB7EC\uADF8\uC778 documentation\uACFC optionsSchema\uB97C \uD655\uC778\uD55C\uB2E4.\n- src/main.ts\uB294 \uC0AC\uC6A9\uC790 \uC9C4\uC785\uC810, dokebi.yaml\uC740 \uC0AC\uC6A9\uC790 \uC635\uC158\uC774\uB2E4.\n- \uD3F4\uB354 \uAD6C\uC870(D-0031\u00B7D-0032): src/apps/<\uC571>(\uC571 \uD558\uB098\uC758 app.ts\u00B7routes.ts\u00B7controllers\u00B7models\u00B7views\u00B7sockets), src/shared/modules/<\uBAA8\uB4C8>(\uAE30\uB2A5 \uD558\uB098\uC758 module.ts\u00B7routes.ts\u00B7controllers\u00B7models\u00B7views, dokebi module add\uB85C \uB9CC\uB4E4\uACE0 \uC571\uC758 modules: { '/\uC811\uB450\uC0AC': \uBAA8\uB4C8 }\uB85C \uBD99\uC778\uB2E4), src/shared/models(\uACF5\uD1B5 \uC21C\uC218 \uCF54\uB4DC), src/shared/views(\uACF5\uD1B5 \uB808\uC774\uC544\uC6C3\u00B7\uBD80\uBD84 \uD15C\uD50C\uB9BF), src/infra/<\uC774\uB984>(\uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0 \uC124\uC815: db/connections.ts, events/types.ts, inertia/shared.ts, web/requires.ts, worker/tasks.ts), src/plugins/<\uC774\uB984>(\uC9C1\uC811 \uB9CC\uB4E0 \uD50C\uB7EC\uADF8\uC778). \uC5C5\uBB34 \uCF54\uB4DC\uB294 apps\u00B7shared/modules\uC5D0\uB9CC, \uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0 \uCF54\uB4DC\uB294 infra\uC5D0\uB9CC \uB454\uB2E4. \uCEE8\uD2B8\uB864\uB7EC\uB294 \uC571\uC774\uB098 \uBAA8\uB4C8\uC5D0 \uC18D\uD55C\uB2E4.\n- \uC2E4\uC81C dokebi.yaml\uC740 Git\uC5D0 \uB123\uC9C0 \uC54A\uB294\uB2E4. dokebi.example.yaml\uC740 \uBE44\uBC00\uAC12 \uC5C6\uB294 \uCD5C\uCD08 \uC124\uC815 \uACAC\uBCF8\uC774\uB2E4.\n- JSON \uD504\uB85C\uC81D\uD2B8\uB294 core 0.2.x \uC124\uCE58 \uD6C4 dokebi migrate --dry-run\uC73C\uB85C \uC774\uC804 \uACC4\uD68D\uBD80\uD130 \uD655\uC778\uD55C\uB2E4.\n- config\uB294 \uCF54\uC5B4 \uD544\uC218 \uAD6C\uC131\uC73C\uB85C \uC790\uB3D9 \uC124\uCE58\uB418\uBA70 remove\uD560 \uC218 \uC5C6\uB2E4.\n- src/dokebi.generated.ts\uC640 .dokebi/state.json\uC740 \uC9C1\uC811 \uD3B8\uC9D1\uD558\uC9C0 \uC54A\uB294\uB2E4.\n- add\uB294 \uD328\uD0A4\uC9C0 \uC9C0\uC815, upgrade\u00B7remove\uB294 \uD50C\uB7EC\uADF8\uC778 ID\uB97C \uBC1B\uB294\uB2E4. \uBA3C\uC800 --dry-run\uC73C\uB85C \uAC80\uD1A0\uD55C\uB2E4. \uC124\uCE58 \uC804 \uC124\uC815\uC740 add --set path=value\uB85C \uB118\uAE38 \uC218 \uC788\uB2E4.\n- \uD50C\uB7EC\uADF8\uC778\uC774 \uB9CC\uB4E0 \uC9C4\uC785 \uD30C\uC77C(\uC608: src/db/connections.ts)\uC740 \uC0AC\uC6A9\uC790 \uCF54\uB4DC\uB2E4. \uC9C1\uC811 \uACE0\uCE58\uACE0, \uC9C0\uC6B0\uC9C0 \uC54A\uB294\uB2E4.\n- doctor\uB294 \uC790\uB3D9 \uBCF5\uAD6C\uD558\uC9C0 \uC54A\uB294\uB2E4. \uC7A0\uAE08\uC774\uB098 \uBC31\uC5C5\uC744 \uC784\uC758 \uC0AD\uC81C\uD558\uAC70\uB098 \uC2E4\uD328\uB97C \uBB34\uC870\uAC74 \uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4.\n- src\uC758 .ts\uB294 Node\uAC00 \uD0C0\uC785\uB9CC \uC9C0\uC6B0\uACE0 \uC2E4\uD589\uD55C\uB2E4. \uC0DD\uC131\uC790 \uB9E4\uAC1C\uBCC0\uC218 \uC18D\uC131\u00B7enum\u00B7namespace\uCC98\uB7FC \uC9C0\uC6B8 \uC218 \uC5C6\uB294 \uBB38\uBC95\uC740 \uC4F0\uC9C0 \uC54A\uB294\uB2E4(tsconfig.json\uC758 erasableSyntaxOnly\uAC00 \uD3B8\uC9D1\uAE30\uC640 npx tsc -p tsconfig.json\uC5D0\uC11C \uC54C\uB824 \uC900\uB2E4).\n- \uBCC0\uACBD \uD6C4 npm start\uC640 \uD504\uB85C\uC81D\uD2B8\uBCC4 \uD14C\uC2A4\uD2B8\uB97C \uC2E4\uD589\uD55C\uB2E4.\n";
@@ -1,4 +1,3 @@
1
- // [AI:생성] 플러그인과 설치 버전의 사용 계약을 AI가 추측 없이 확인하게 한다.
2
1
  export const PROJECT_GUIDE = `# Dokebi 프로젝트 작업 안내
3
2
 
4
3
  설치된 코어 계약은 [코어 AI 사용 안내](node_modules/@dokebi/core/AI.md)를 읽는다.
@@ -6,9 +5,15 @@ export const PROJECT_GUIDE = `# Dokebi 프로젝트 작업 안내
6
5
 
7
6
  - 프로젝트 루트에서 설치된 CLI로 dokebi inspect --json, dokebi doctor --json을 실행한다.
8
7
  - inspect에 나온 플러그인 documentation과 optionsSchema를 확인한다.
9
- - src/main.ts는 사용자 진입점, dokebi.json은 사용자 옵션이다.
8
+ - src/main.ts는 사용자 진입점, dokebi.yaml은 사용자 옵션이다.
9
+ - 폴더 구조(D-0031·D-0032): src/apps/<앱>(앱 하나의 app.ts·routes.ts·controllers·models·views·sockets), src/shared/modules/<모듈>(기능 하나의 module.ts·routes.ts·controllers·models·views, dokebi module add로 만들고 앱의 modules: { '/접두사': 모듈 }로 붙인다), src/shared/models(공통 순수 코드), src/shared/views(공통 레이아웃·부분 템플릿), src/infra/<이름>(플러그인 연결 설정: db/connections.ts, events/types.ts, inertia/shared.ts, web/requires.ts, worker/tasks.ts), src/plugins/<이름>(직접 만든 플러그인). 업무 코드는 apps·shared/modules에만, 플러그인 연결 코드는 infra에만 둔다. 컨트롤러는 앱이나 모듈에 속한다.
10
+ - 실제 dokebi.yaml은 Git에 넣지 않는다. dokebi.example.yaml은 비밀값 없는 최초 설정 견본이다.
11
+ - JSON 프로젝트는 core 0.2.x 설치 후 dokebi migrate --dry-run으로 이전 계획부터 확인한다.
12
+ - config는 코어 필수 구성으로 자동 설치되며 remove할 수 없다.
10
13
  - src/dokebi.generated.ts와 .dokebi/state.json은 직접 편집하지 않는다.
11
- - add는 패키지 지정, remove는 플러그인 ID를 받는다. 먼저 --dry-run으로 검토한다.
14
+ - add는 패키지 지정, upgrade·remove는 플러그인 ID를 받는다. 먼저 --dry-run으로 검토한다. 설치 전 설정은 add --set path=value로 넘길 수 있다.
15
+ - 플러그인이 만든 진입 파일(예: src/db/connections.ts)은 사용자 코드다. 직접 고치고, 지우지 않는다.
12
16
  - doctor는 자동 복구하지 않는다. 잠금이나 백업을 임의 삭제하거나 실패를 무조건 재시도하지 않는다.
17
+ - src의 .ts는 Node가 타입만 지우고 실행한다. 생성자 매개변수 속성·enum·namespace처럼 지울 수 없는 문법은 쓰지 않는다(tsconfig.json의 erasableSyntaxOnly가 편집기와 npx tsc -p tsconfig.json에서 알려 준다).
13
18
  - 변경 후 npm start와 프로젝트별 테스트를 실행한다.
14
19
  `;
@@ -1,4 +1,6 @@
1
1
  import { type PluginManifest } from './manifest.js';
2
+ import { type Installation } from './project-state.js';
3
+ import { type RequiredPlugin } from './required-plugins.js';
2
4
  export interface Diagnostic {
3
5
  code: string;
4
6
  severity: 'error' | 'warning';
@@ -10,9 +12,11 @@ export interface Inspection {
10
12
  root: string;
11
13
  healthy: boolean;
12
14
  coreVersion: string | null;
15
+ requiredPlugins: RequiredPlugin[];
13
16
  plugins: Array<{
14
17
  expected: PluginManifest;
15
18
  installed: PluginManifest | null;
19
+ installation: Installation | null;
16
20
  optionKeys: string[];
17
21
  optionsSchema: Record<string, unknown> | null;
18
22
  documentation: string | null;
@@ -20,5 +24,7 @@ export interface Inspection {
20
24
  diagnostics: Diagnostic[];
21
25
  }
22
26
  export declare class ProjectInspector {
23
- inspect(directory: string): Promise<Inspection>;
27
+ inspect(directory: string, settings?: {
28
+ runChecks?: boolean;
29
+ }): Promise<Inspection>;
24
30
  }