@dokebi/cli 0.0.0-stage → 0.1.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.
- package/AI.md +18 -0
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +64 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +11 -0
- package/dist/filesystem.d.ts +10 -0
- package/dist/filesystem.js +76 -0
- package/dist/generator.d.ts +3 -0
- package/dist/generator.js +38 -0
- package/dist/manifest.d.ts +21 -0
- package/dist/manifest.js +95 -0
- package/dist/npm-client.d.ts +6 -0
- package/dist/npm-client.js +89 -0
- package/dist/npm-paths.d.ts +2 -0
- package/dist/npm-paths.js +34 -0
- package/dist/output.d.ts +3 -0
- package/dist/output.js +38 -0
- package/dist/plugin-options.d.ts +10 -0
- package/dist/plugin-options.js +53 -0
- package/dist/project-guide.d.ts +1 -0
- package/dist/project-guide.js +14 -0
- package/dist/project-inspector.d.ts +24 -0
- package/dist/project-inspector.js +153 -0
- package/dist/project-state.d.ts +18 -0
- package/dist/project-state.js +45 -0
- package/dist/project.d.ts +16 -0
- package/dist/project.js +210 -0
- package/dist/transaction.d.ts +14 -0
- package/dist/transaction.js +124 -0
- package/package.json +37 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// [AI:생성] 설치 코드 실행 없이 표준 JSON Schema로 옵션을 검증하고 설정을 그대로 보존한다.
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
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
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throw new CliError('INVALID_OPTIONS_SCHEMA', plugin.id, { cause });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function validatePluginOptions(root, plugins, options) {
|
|
45
|
+
for (const plugin of plugins) {
|
|
46
|
+
// [AI:수정] 추가한 버전·옵션 경계 검사를 저장소 포맷에 맞춘다.
|
|
47
|
+
const result = await checkPluginOptions(root, plugin, Object.hasOwn(options, plugin.id) ? (options[plugin.id] ?? {}) : {});
|
|
48
|
+
if (result.errors.length)
|
|
49
|
+
throw new CliError('INVALID_PLUGIN_OPTIONS', plugin.id, {
|
|
50
|
+
details: { plugin: plugin.id, errors: result.errors },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +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";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// [AI:생성] 플러그인과 설치 버전의 사용 계약을 AI가 추측 없이 확인하게 한다.
|
|
2
|
+
export const PROJECT_GUIDE = `# Dokebi 프로젝트 작업 안내
|
|
3
|
+
|
|
4
|
+
설치된 코어 계약은 [코어 AI 사용 안내](node_modules/@dokebi/core/AI.md)를 읽는다.
|
|
5
|
+
패키지 버전은 package.json과 실제 node_modules를 함께 확인한다.
|
|
6
|
+
|
|
7
|
+
- 프로젝트 루트에서 설치된 CLI로 dokebi inspect --json, dokebi doctor --json을 실행한다.
|
|
8
|
+
- inspect에 나온 플러그인 documentation과 optionsSchema를 확인한다.
|
|
9
|
+
- src/main.ts는 사용자 진입점, dokebi.json은 사용자 옵션이다.
|
|
10
|
+
- src/dokebi.generated.ts와 .dokebi/state.json은 직접 편집하지 않는다.
|
|
11
|
+
- add는 패키지 지정, remove는 플러그인 ID를 받는다. 먼저 --dry-run으로 검토한다.
|
|
12
|
+
- doctor는 자동 복구하지 않는다. 잠금이나 백업을 임의 삭제하거나 실패를 무조건 재시도하지 않는다.
|
|
13
|
+
- 변경 후 npm start와 프로젝트별 테스트를 실행한다.
|
|
14
|
+
`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type PluginManifest } from './manifest.js';
|
|
2
|
+
export interface Diagnostic {
|
|
3
|
+
code: string;
|
|
4
|
+
severity: 'error' | 'warning';
|
|
5
|
+
path: string;
|
|
6
|
+
plugin: string | null;
|
|
7
|
+
details?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface Inspection {
|
|
10
|
+
root: string;
|
|
11
|
+
healthy: boolean;
|
|
12
|
+
coreVersion: string | null;
|
|
13
|
+
plugins: Array<{
|
|
14
|
+
expected: PluginManifest;
|
|
15
|
+
installed: PluginManifest | null;
|
|
16
|
+
optionKeys: string[];
|
|
17
|
+
optionsSchema: Record<string, unknown> | null;
|
|
18
|
+
documentation: string | null;
|
|
19
|
+
}>;
|
|
20
|
+
diagnostics: Diagnostic[];
|
|
21
|
+
}
|
|
22
|
+
export declare class ProjectInspector {
|
|
23
|
+
inspect(directory: string): Promise<Inspection>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// [AI:수정] CLI 진단·스키마·회귀 검증의 포맷과 import 순서를 맞춘다.
|
|
2
|
+
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
// [AI:수정] 설치 버전과 package.json의 요청 범위가 다른 경우도 진단한다.
|
|
4
|
+
import satisfies from 'semver/functions/satisfies.js';
|
|
5
|
+
import valid from 'semver/functions/valid.js';
|
|
6
|
+
import { CliError } from './errors.js';
|
|
7
|
+
import { exists, fingerprint, hash, json, parseObject, securePath } from './filesystem.js';
|
|
8
|
+
import { orderPlugins, readManifest } from './manifest.js';
|
|
9
|
+
import { dependencyMap, validateProjectPackage } from './npm-client.js';
|
|
10
|
+
// [AI:수정] 설치 버전의 옵션 스키마와 문서 위치를 공개하고 옵션 오류를 진단한다.
|
|
11
|
+
import { checkPluginOptions } from './plugin-options.js';
|
|
12
|
+
import { readConfig, readState } from './project-state.js';
|
|
13
|
+
// Reads metadata only. Never imports plugin entry points or creates locks.
|
|
14
|
+
export class ProjectInspector {
|
|
15
|
+
async inspect(directory) {
|
|
16
|
+
const root = await realpath(directory);
|
|
17
|
+
const result = {
|
|
18
|
+
root,
|
|
19
|
+
healthy: false,
|
|
20
|
+
coreVersion: null,
|
|
21
|
+
plugins: [],
|
|
22
|
+
diagnostics: [],
|
|
23
|
+
};
|
|
24
|
+
const originals = new Map();
|
|
25
|
+
const issue = (code, path, plugin = null, severity = 'error') => {
|
|
26
|
+
result.diagnostics.push({ code, path, plugin, severity });
|
|
27
|
+
};
|
|
28
|
+
const check = async (path, action, plugin = null) => {
|
|
29
|
+
try {
|
|
30
|
+
return await action();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const code = error instanceof CliError
|
|
34
|
+
? error.code
|
|
35
|
+
: error instanceof Error && 'code' in error && typeof error.code === 'string'
|
|
36
|
+
? error.code
|
|
37
|
+
: 'READ_FAILED';
|
|
38
|
+
issue(code, path, plugin);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const read = async (path) => {
|
|
43
|
+
const text = await readFile(await securePath(root, path), 'utf8');
|
|
44
|
+
originals.set(path, hash(text));
|
|
45
|
+
return text;
|
|
46
|
+
};
|
|
47
|
+
const state = await check('.dokebi/state.json', async () => readState(parseObject(await read('.dokebi/state.json'), 'state')));
|
|
48
|
+
const config = await check('dokebi.json', async () => readConfig(parseObject(await read('dokebi.json'), 'config')));
|
|
49
|
+
const pkg = await check('package.json', async () => {
|
|
50
|
+
const value = parseObject(await read('package.json'), 'package');
|
|
51
|
+
validateProjectPackage(value);
|
|
52
|
+
return dependencyMap(value);
|
|
53
|
+
});
|
|
54
|
+
const corePath = 'node_modules/@dokebi/core/package.json';
|
|
55
|
+
const core = await check(corePath, async () => {
|
|
56
|
+
const value = parseObject(await read(corePath), 'core');
|
|
57
|
+
if (value.name !== '@dokebi/core' ||
|
|
58
|
+
typeof value.version !== 'string' ||
|
|
59
|
+
!valid(value.version))
|
|
60
|
+
throw new CliError('INVALID_CORE', 'Invalid core identity');
|
|
61
|
+
return value.version;
|
|
62
|
+
});
|
|
63
|
+
result.coreVersion = core;
|
|
64
|
+
const matches = (name, version, plugin) => {
|
|
65
|
+
const spec = pkg?.[name];
|
|
66
|
+
if (spec && !spec.startsWith('file:') && !satisfies(version, spec))
|
|
67
|
+
issue('PACKAGE_VERSION_MISMATCH', 'package.json', plugin);
|
|
68
|
+
};
|
|
69
|
+
if (core)
|
|
70
|
+
matches('@dokebi/core', core, null);
|
|
71
|
+
if (pkg && !Object.hasOwn(pkg, '@dokebi/core'))
|
|
72
|
+
issue('MISSING_CORE_DEPENDENCY', 'package.json');
|
|
73
|
+
const generated = await check('src/dokebi.generated.ts', () => read('src/dokebi.generated.ts'));
|
|
74
|
+
if (state && generated !== null && hash(generated) !== state.generatedHash)
|
|
75
|
+
issue('MODIFIED_REGISTRATION', 'src/dokebi.generated.ts');
|
|
76
|
+
const control = await check('.dokebi', async () => readdir(await securePath(root, '.dokebi')));
|
|
77
|
+
if (control?.includes('lock'))
|
|
78
|
+
issue('PROJECT_LOCKED', '.dokebi/lock');
|
|
79
|
+
for (const name of control ?? []) {
|
|
80
|
+
if (name.startsWith('transaction-'))
|
|
81
|
+
issue('RECOVERY_REQUIRED', '.dokebi/' + name);
|
|
82
|
+
}
|
|
83
|
+
if (state) {
|
|
84
|
+
for (const plugin of state.plugins) {
|
|
85
|
+
const path = 'node_modules/' + plugin.packageName + '/package.json';
|
|
86
|
+
const installed = await check(path, async () => readManifest(parseObject(await read(path), 'plugin')), plugin.id);
|
|
87
|
+
if (installed && json(installed) !== json(plugin))
|
|
88
|
+
issue('MANIFEST_CHANGED', path, plugin.id);
|
|
89
|
+
if (pkg && !Object.hasOwn(pkg, plugin.packageName))
|
|
90
|
+
issue('MISSING_PACKAGE_DEPENDENCY', 'package.json', plugin.id);
|
|
91
|
+
if (installed)
|
|
92
|
+
matches(installed.packageName, installed.version, plugin.id);
|
|
93
|
+
// [AI:수정] 추가한 버전·옵션 경계 검사를 저장소 포맷에 맞춘다.
|
|
94
|
+
const options = config && Object.hasOwn(config.options, plugin.id)
|
|
95
|
+
? config.options[plugin.id]
|
|
96
|
+
: undefined;
|
|
97
|
+
const checked = installed
|
|
98
|
+
? await check(path, async () => checkPluginOptions(root, installed, options ?? {}), plugin.id)
|
|
99
|
+
: null;
|
|
100
|
+
if (checked?.errors.length)
|
|
101
|
+
result.diagnostics.push({
|
|
102
|
+
code: 'INVALID_PLUGIN_OPTIONS',
|
|
103
|
+
severity: 'error',
|
|
104
|
+
path: 'dokebi.json',
|
|
105
|
+
plugin: plugin.id,
|
|
106
|
+
details: checked.errors,
|
|
107
|
+
});
|
|
108
|
+
let documentation = null;
|
|
109
|
+
if (installed?.documentation) {
|
|
110
|
+
const candidate = 'node_modules/' + installed.packageName + '/' + installed.documentation;
|
|
111
|
+
const guide = await check(candidate, () => read(candidate), plugin.id);
|
|
112
|
+
if (guide !== null)
|
|
113
|
+
documentation = candidate;
|
|
114
|
+
}
|
|
115
|
+
result.plugins.push({
|
|
116
|
+
expected: plugin,
|
|
117
|
+
installed,
|
|
118
|
+
optionsSchema: checked?.schema ?? null,
|
|
119
|
+
documentation,
|
|
120
|
+
optionKeys: options && typeof options === 'object' && !Array.isArray(options)
|
|
121
|
+
? Object.keys(options)
|
|
122
|
+
: [],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (core)
|
|
126
|
+
await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core));
|
|
127
|
+
for (const [path, owned] of Object.entries(state.files)) {
|
|
128
|
+
const actual = await check(path, async () => fingerprint(await securePath(root, path)));
|
|
129
|
+
if (actual !== owned.hash)
|
|
130
|
+
issue('MODIFIED_TEMPLATE', path, null, 'warning');
|
|
131
|
+
}
|
|
132
|
+
for (const id of Object.keys(config?.options ?? {})) {
|
|
133
|
+
if (!state.plugins.some((plugin) => plugin.id === id))
|
|
134
|
+
issue('ORPHAN_OPTIONS', 'dokebi.json', id, 'warning');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [path, expected] of originals) {
|
|
138
|
+
await check(path, async () => {
|
|
139
|
+
if (hash(await readFile(await securePath(root, path))) !== expected)
|
|
140
|
+
throw new CliError('CONCURRENT_CHANGE', 'Metadata changed during inspection');
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
await check('.dokebi/lock', async () => {
|
|
145
|
+
if ((await exists(await securePath(root, '.dokebi/lock'))) &&
|
|
146
|
+
!result.diagnostics.some((item) => item.code === 'PROJECT_LOCKED'))
|
|
147
|
+
issue('PROJECT_LOCKED', '.dokebi/lock');
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
result.healthy = !result.diagnostics.some((item) => item.severity === 'error');
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type PluginManifest } from './manifest.js';
|
|
2
|
+
export interface OwnedFile {
|
|
3
|
+
hash: string;
|
|
4
|
+
owners: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface ProjectState {
|
|
7
|
+
schemaVersion: 1;
|
|
8
|
+
plugins: PluginManifest[];
|
|
9
|
+
files: Record<string, OwnedFile>;
|
|
10
|
+
generatedHash: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProjectConfig {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
options: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export declare function emptyState(generatedHash: string): ProjectState;
|
|
17
|
+
export declare function readState(value: unknown): ProjectState;
|
|
18
|
+
export declare function readConfig(value: unknown): ProjectConfig;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// [AI:생성] 설치 중 플러그인 코드를 실행하지 않고 실제 인스턴스로 의존 플러그인을 연결한다.
|
|
2
|
+
import { CliError } from './errors.js';
|
|
3
|
+
import { object, templateTarget } from './filesystem.js';
|
|
4
|
+
import { readManifest } from './manifest.js';
|
|
5
|
+
export function emptyState(generatedHash) {
|
|
6
|
+
return { schemaVersion: 1, plugins: [], files: Object.create(null), generatedHash };
|
|
7
|
+
}
|
|
8
|
+
export function readState(value) {
|
|
9
|
+
const input = object(value, 'state');
|
|
10
|
+
if (input.schemaVersion !== 1 ||
|
|
11
|
+
!Array.isArray(input.plugins) ||
|
|
12
|
+
typeof input.generatedHash !== 'string')
|
|
13
|
+
throw new CliError('INVALID_STATE', 'Unsupported project state');
|
|
14
|
+
const plugins = input.plugins.map((entry) => {
|
|
15
|
+
const plugin = object(entry, 'plugin');
|
|
16
|
+
return readManifest({
|
|
17
|
+
name: plugin.packageName,
|
|
18
|
+
version: plugin.version,
|
|
19
|
+
dokebi: { ...plugin, schemaVersion: 1 },
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
if (new Set(plugins.map((plugin) => plugin.id)).size !== plugins.length ||
|
|
23
|
+
new Set(plugins.map((plugin) => plugin.packageName)).size !== plugins.length)
|
|
24
|
+
throw new CliError('INVALID_STATE', 'Duplicate installed plugin');
|
|
25
|
+
const files = Object.create(null);
|
|
26
|
+
for (const [path, value] of Object.entries(object(input.files, 'files'))) {
|
|
27
|
+
templateTarget(path);
|
|
28
|
+
const file = object(value, 'file');
|
|
29
|
+
if (typeof file.hash !== 'string' ||
|
|
30
|
+
!/^[a-f0-9]{64}$/.test(file.hash) ||
|
|
31
|
+
!Array.isArray(file.owners) ||
|
|
32
|
+
!file.owners.length ||
|
|
33
|
+
!file.owners.every((owner) => typeof owner === 'string' && plugins.some((plugin) => plugin.id === owner)) ||
|
|
34
|
+
new Set(file.owners).size !== file.owners.length)
|
|
35
|
+
throw new CliError('INVALID_STATE', 'Invalid file ownership');
|
|
36
|
+
files[path] = { hash: file.hash, owners: file.owners };
|
|
37
|
+
}
|
|
38
|
+
return { schemaVersion: 1, plugins, files, generatedHash: input.generatedHash };
|
|
39
|
+
}
|
|
40
|
+
export function readConfig(value) {
|
|
41
|
+
const config = object(value, 'config');
|
|
42
|
+
if (config.schemaVersion !== 1)
|
|
43
|
+
throw new CliError('INVALID_CONFIG', 'Unsupported config');
|
|
44
|
+
return { schemaVersion: 1, options: object(config.options, 'options') };
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NpmClient } from './npm-client.js';
|
|
2
|
+
export interface OperationResult {
|
|
3
|
+
changed: string[];
|
|
4
|
+
preserved: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare class Project {
|
|
7
|
+
private readonly npm;
|
|
8
|
+
constructor(npm?: NpmClient);
|
|
9
|
+
init(target: string, core?: string): Promise<OperationResult>;
|
|
10
|
+
add(root: string, spec: string, dryRun?: boolean): Promise<OperationResult>;
|
|
11
|
+
remove(root: string, id: string, dryRun?: boolean): Promise<OperationResult>;
|
|
12
|
+
private coreVersion;
|
|
13
|
+
private change;
|
|
14
|
+
private addFiles;
|
|
15
|
+
private writeConfiguration;
|
|
16
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// [AI:수정] 빈 디렉터리에 맞는 제거 API를 사용하고 정리 실패를 숨기지 않는다.
|
|
2
|
+
import { mkdir, mkdtemp, readFile, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { CliError } from './errors.js';
|
|
5
|
+
import { exists, fingerprint, hash, json, parseObject, securePath } from './filesystem.js';
|
|
6
|
+
import { generateRegistration, MAIN_SOURCE } from './generator.js';
|
|
7
|
+
import { orderPlugins, readManifest } from './manifest.js';
|
|
8
|
+
import { dependencyMap, NpmClient, packageSpec, validateProjectPackage } from './npm-client.js';
|
|
9
|
+
// [AI:수정] CLI 진단·스키마·회귀 검증의 포맷과 import 순서를 맞춘다.
|
|
10
|
+
import { validatePluginOptions } from './plugin-options.js';
|
|
11
|
+
// [AI:수정] 새 프로젝트에 설치 버전 계약과 CLI 진단을 안내하는 짧은 AI 시작 문서를 생성한다.
|
|
12
|
+
import { PROJECT_GUIDE } from './project-guide.js';
|
|
13
|
+
import { emptyState, readConfig, readState, } from './project-state.js';
|
|
14
|
+
import { Transaction } from './transaction.js';
|
|
15
|
+
const GENERATED = 'src/dokebi.generated.ts';
|
|
16
|
+
export class Project {
|
|
17
|
+
npm;
|
|
18
|
+
constructor(npm = new NpmClient()) {
|
|
19
|
+
this.npm = npm;
|
|
20
|
+
}
|
|
21
|
+
async init(target, core = '@dokebi/core@0.1.0') {
|
|
22
|
+
target = resolve(target);
|
|
23
|
+
if (await exists(target))
|
|
24
|
+
throw new CliError('PROJECT_EXISTS', target);
|
|
25
|
+
const parent = await realpath(dirname(target));
|
|
26
|
+
const staging = await mkdtemp(join(parent, '.dokebi-init-'));
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(join(staging, 'src'));
|
|
29
|
+
await mkdir(join(staging, '.dokebi'));
|
|
30
|
+
await writeFile(join(staging, 'package.json'), json({
|
|
31
|
+
name: basename(target)
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9-]/g, '-') || 'dokebi-app',
|
|
34
|
+
private: true,
|
|
35
|
+
type: 'module',
|
|
36
|
+
scripts: { start: 'node src/main.ts' },
|
|
37
|
+
engines: { node: '^24.21.0 || ^26.11.1' },
|
|
38
|
+
}));
|
|
39
|
+
await this.npm.install(staging, packageSpec(core, process.cwd()));
|
|
40
|
+
await this.coreVersion(staging);
|
|
41
|
+
const generated = generateRegistration([]);
|
|
42
|
+
await writeFile(join(staging, GENERATED), generated);
|
|
43
|
+
await writeFile(join(staging, 'src/main.ts'), MAIN_SOURCE);
|
|
44
|
+
await writeFile(join(staging, 'AGENTS.md'), PROJECT_GUIDE);
|
|
45
|
+
await writeFile(join(staging, 'dokebi.json'), json({ schemaVersion: 1, options: {} }));
|
|
46
|
+
await writeFile(join(staging, '.dokebi/state.json'), json(emptyState(hash(generated))));
|
|
47
|
+
await writeFile(join(staging, '.gitignore'), 'node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n');
|
|
48
|
+
// Reserve the destination before moving anything so existing projects are never replaced.
|
|
49
|
+
await mkdir(target);
|
|
50
|
+
try {
|
|
51
|
+
await rename(staging, target);
|
|
52
|
+
}
|
|
53
|
+
catch (cause) {
|
|
54
|
+
try {
|
|
55
|
+
await rmdir(target);
|
|
56
|
+
}
|
|
57
|
+
catch (cleanup) {
|
|
58
|
+
// [AI:수정] 초기화 실패 오류 보존 코드의 포맷을 맞춘다.
|
|
59
|
+
throw new AggregateError([cause, cleanup], 'Initialization and destination cleanup failed');
|
|
60
|
+
}
|
|
61
|
+
throw cause;
|
|
62
|
+
}
|
|
63
|
+
return { changed: [target], preserved: [] };
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await rm(staging, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async add(root, spec, dryRun = false) {
|
|
70
|
+
return this.change(root, { add: packageSpec(spec, root) }, dryRun);
|
|
71
|
+
}
|
|
72
|
+
async remove(root, id, dryRun = false) {
|
|
73
|
+
return this.change(root, { remove: id }, dryRun);
|
|
74
|
+
}
|
|
75
|
+
async coreVersion(root) {
|
|
76
|
+
const pkg = parseObject(await readFile(await securePath(root, 'node_modules/@dokebi/core/package.json'), 'utf8'), 'core');
|
|
77
|
+
if (pkg.name !== '@dokebi/core' || typeof pkg.version !== 'string')
|
|
78
|
+
throw new CliError('INVALID_CORE', 'Missing Dokebi core');
|
|
79
|
+
return pkg.version;
|
|
80
|
+
}
|
|
81
|
+
async change(root, operation, dryRun) {
|
|
82
|
+
root = await realpath(root);
|
|
83
|
+
// Do not create control files in arbitrary non-Dokebi projects.
|
|
84
|
+
await readFile(await securePath(root, '.dokebi/state.json'));
|
|
85
|
+
const tx = await Transaction.begin(root);
|
|
86
|
+
try {
|
|
87
|
+
const state = readState(JSON.parse(await tx.read('.dokebi/state.json')));
|
|
88
|
+
const config = readConfig(JSON.parse(await tx.read('dokebi.json')));
|
|
89
|
+
const pkg = parseObject(await tx.read('package.json'), 'package.json');
|
|
90
|
+
validateProjectPackage(pkg);
|
|
91
|
+
if (hash(await tx.read(GENERATED)) !== state.generatedHash)
|
|
92
|
+
throw new CliError('MODIFIED_REGISTRATION', GENERATED);
|
|
93
|
+
const dependencies = dependencyMap(pkg);
|
|
94
|
+
const removed = 'remove' in operation
|
|
95
|
+
? state.plugins.find((plugin) => plugin.id === operation.remove)
|
|
96
|
+
: undefined;
|
|
97
|
+
if ('remove' in operation && !removed)
|
|
98
|
+
return { changed: [], preserved: [] };
|
|
99
|
+
if (removed &&
|
|
100
|
+
state.plugins.some((plugin) => plugin.dependencies.some((dep) => dep.name === removed.id))) {
|
|
101
|
+
throw new CliError('REVERSE_DEPENDENCY', removed.id);
|
|
102
|
+
}
|
|
103
|
+
const staging = join(tx.directory, 'npm');
|
|
104
|
+
await mkdir(staging);
|
|
105
|
+
if (removed)
|
|
106
|
+
delete dependencies[removed.packageName];
|
|
107
|
+
pkg.dependencies = dependencies;
|
|
108
|
+
await writeFile(join(staging, 'package.json'), json(pkg));
|
|
109
|
+
const lock = await securePath(root, 'package-lock.json');
|
|
110
|
+
if (await exists(lock))
|
|
111
|
+
await writeFile(join(staging, 'package-lock.json'), await tx.read('package-lock.json'));
|
|
112
|
+
await tx.capture('node_modules');
|
|
113
|
+
await this.npm.install(staging, 'add' in operation ? operation.add : undefined);
|
|
114
|
+
const installed = parseObject(await readFile(join(staging, 'package.json'), 'utf8'), 'installed package');
|
|
115
|
+
const after = dependencyMap(installed);
|
|
116
|
+
let added;
|
|
117
|
+
if ('add' in operation) {
|
|
118
|
+
const name = operation.add.startsWith('file:')
|
|
119
|
+
? Object.keys(after).find((key) => after[key] === operation.add)
|
|
120
|
+
: /^(@[^/]+\/[^@]+|[^@]+)/.exec(operation.add)?.[1];
|
|
121
|
+
if (!name || !Object.hasOwn(after, name))
|
|
122
|
+
throw new CliError('INVALID_PACKAGE', 'Cannot identify installed package');
|
|
123
|
+
added = readManifest(parseObject(await readFile(await securePath(staging, 'node_modules/' + name + '/package.json'), 'utf8'), name));
|
|
124
|
+
const existing = state.plugins.find((plugin) => plugin.id === added?.id || plugin.packageName === added?.packageName);
|
|
125
|
+
if (existing) {
|
|
126
|
+
if (existing.id === added.id &&
|
|
127
|
+
existing.packageName === added.packageName &&
|
|
128
|
+
existing.version === added.version)
|
|
129
|
+
return { changed: [], preserved: [] };
|
|
130
|
+
throw new CliError('UPGRADE_UNSUPPORTED', added.id);
|
|
131
|
+
}
|
|
132
|
+
if (Object.hasOwn(dependencies, name))
|
|
133
|
+
throw new CliError('PACKAGE_CONFLICT', name);
|
|
134
|
+
}
|
|
135
|
+
const next = state.plugins.filter((plugin) => plugin.id !== removed?.id);
|
|
136
|
+
if (added)
|
|
137
|
+
next.push(added);
|
|
138
|
+
// npm must not silently alter the identity/version of previously registered plugins.
|
|
139
|
+
for (const plugin of next) {
|
|
140
|
+
const actual = readManifest(JSON.parse(await readFile(await securePath(staging, 'node_modules/' + plugin.packageName + '/package.json'), 'utf8')));
|
|
141
|
+
if (json(actual) !== json(plugin))
|
|
142
|
+
throw new CliError('MANIFEST_CHANGED', plugin.id);
|
|
143
|
+
}
|
|
144
|
+
const ordered = orderPlugins(next, await this.coreVersion(staging));
|
|
145
|
+
await validatePluginOptions(staging, ordered, config.options);
|
|
146
|
+
const preserved = [];
|
|
147
|
+
if (added)
|
|
148
|
+
await this.addFiles(tx, staging, state, added);
|
|
149
|
+
if (removed) {
|
|
150
|
+
for (const [path, file] of Object.entries(state.files)) {
|
|
151
|
+
if (!file.owners.includes(removed.id))
|
|
152
|
+
continue;
|
|
153
|
+
file.owners = file.owners.filter((owner) => owner !== removed.id);
|
|
154
|
+
if (file.owners.length)
|
|
155
|
+
continue;
|
|
156
|
+
await tx.capture(path);
|
|
157
|
+
const current = await fingerprint(await securePath(root, path));
|
|
158
|
+
if (current === file.hash)
|
|
159
|
+
await tx.replace(path);
|
|
160
|
+
else if (current !== null)
|
|
161
|
+
preserved.push(path);
|
|
162
|
+
delete state.files[path];
|
|
163
|
+
}
|
|
164
|
+
delete config.options[removed.id];
|
|
165
|
+
}
|
|
166
|
+
if (added && !Object.hasOwn(config.options, added.id))
|
|
167
|
+
config.options[added.id] = {};
|
|
168
|
+
state.plugins = ordered;
|
|
169
|
+
await this.writeConfiguration(tx, state, config);
|
|
170
|
+
await tx.replace('package.json', join(staging, 'package.json'));
|
|
171
|
+
await tx.replace('package-lock.json', join(staging, 'package-lock.json'));
|
|
172
|
+
await tx.replace('node_modules', join(staging, 'node_modules'));
|
|
173
|
+
const result = { changed: tx.paths, preserved };
|
|
174
|
+
if (!dryRun)
|
|
175
|
+
await tx.commit();
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await tx.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async addFiles(tx, staging, state, plugin) {
|
|
183
|
+
for (const file of plugin.files) {
|
|
184
|
+
const source = await securePath(staging, 'node_modules/' + plugin.packageName + '/' + file.source);
|
|
185
|
+
const content = await readFile(source);
|
|
186
|
+
const digest = hash(content);
|
|
187
|
+
await tx.capture(file.target);
|
|
188
|
+
const current = await fingerprint(await securePath(tx.root, file.target));
|
|
189
|
+
const owned = state.files[file.target];
|
|
190
|
+
if (owned) {
|
|
191
|
+
if (current !== digest || owned.hash !== digest)
|
|
192
|
+
throw new CliError('FILE_CONFLICT', file.target);
|
|
193
|
+
owned.owners.push(plugin.id);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if (current !== null)
|
|
197
|
+
throw new CliError('FILE_CONFLICT', file.target);
|
|
198
|
+
await tx.write(file.target, content);
|
|
199
|
+
state.files[file.target] = { hash: digest, owners: [plugin.id] };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async writeConfiguration(tx, state, config) {
|
|
204
|
+
const generated = generateRegistration(state.plugins);
|
|
205
|
+
state.generatedHash = hash(generated);
|
|
206
|
+
await tx.write(GENERATED, generated);
|
|
207
|
+
await tx.write('dokebi.json', json(config));
|
|
208
|
+
await tx.write('.dokebi/state.json', json(state));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class Transaction {
|
|
2
|
+
#private;
|
|
3
|
+
readonly root: string;
|
|
4
|
+
readonly directory: string;
|
|
5
|
+
private constructor();
|
|
6
|
+
static begin(root: string): Promise<Transaction>;
|
|
7
|
+
capture(path: string): Promise<void>;
|
|
8
|
+
read(path: string): Promise<string>;
|
|
9
|
+
write(path: string, content: string | Uint8Array): Promise<void>;
|
|
10
|
+
replace(path: string, source?: string): Promise<void>;
|
|
11
|
+
get paths(): string[];
|
|
12
|
+
commit(beforeApply?: (index: number) => void): Promise<void>;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|