@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,27 +1,32 @@
1
- // [AI:수정] CLI 진단·스키마·회귀 검증의 포맷과 import 순서를 맞춘다.
2
1
  import { readdir, readFile, realpath } from 'node:fs/promises';
3
- // [AI:수정] 설치 버전과 package.json의 요청 범위가 다른 경우도 진단한다.
4
2
  import satisfies from 'semver/functions/satisfies.js';
5
3
  import valid from 'semver/functions/valid.js';
4
+ import { CONFIG_FILE, LEGACY_CONFIG_FILE, parseProjectConfig } from './configuration.js';
6
5
  import { CliError } from './errors.js';
7
6
  import { exists, fingerprint, hash, json, parseObject, securePath } from './filesystem.js';
7
+ import { legacyLayout } from './layout.js';
8
8
  import { orderPlugins, readManifest } from './manifest.js';
9
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.
10
+ import { checkPlugin } from './plugin-lifecycle.js';
11
+ import { PluginOptionsValidator } from './plugin-options.js';
12
+ import { readState } from './project-state.js';
13
+ import { readRequiredPlugins } from './required-plugins.js';
14
+ import { installedPath } from './workspace.js';
15
+ // Reads metadata only and never creates locks. With runChecks (doctor), each plugin's
16
+ // installation module check runs in the permission-restricted child process.
14
17
  export class ProjectInspector {
15
- async inspect(directory) {
18
+ async inspect(directory, settings = {}) {
16
19
  const root = await realpath(directory);
17
20
  const result = {
18
21
  root,
19
22
  healthy: false,
20
23
  coreVersion: null,
24
+ requiredPlugins: [],
21
25
  plugins: [],
22
26
  diagnostics: [],
23
27
  };
24
28
  const originals = new Map();
29
+ const optionsValidator = new PluginOptionsValidator();
25
30
  const issue = (code, path, plugin = null, severity = 'error') => {
26
31
  result.diagnostics.push({ code, path, plugin, severity });
27
32
  };
@@ -35,17 +40,35 @@ export class ProjectInspector {
35
40
  : error instanceof Error && 'code' in error && typeof error.code === 'string'
36
41
  ? error.code
37
42
  : 'READ_FAILED';
38
- issue(code, path, plugin);
43
+ result.diagnostics.push({
44
+ code,
45
+ path,
46
+ plugin,
47
+ severity: 'error',
48
+ ...(error instanceof CliError && error.details !== undefined
49
+ ? { details: error.details }
50
+ : {}),
51
+ });
39
52
  return null;
40
53
  }
41
54
  };
42
55
  const read = async (path) => {
43
- const text = await readFile(await securePath(root, path), 'utf8');
56
+ // node_modules paths resolve like Node in a workspace member (D-0035).
57
+ const text = await readFile(await installedPath(root, path), 'utf8');
44
58
  originals.set(path, hash(text));
45
59
  return text;
46
60
  };
47
61
  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')));
62
+ const config = await check(CONFIG_FILE, async () => {
63
+ if (await exists(await securePath(root, LEGACY_CONFIG_FILE)))
64
+ throw new CliError((await exists(await securePath(root, CONFIG_FILE)))
65
+ ? 'CONFIG_CONFLICT'
66
+ : 'MIGRATION_REQUIRED', 'Explicit YAML migration is required');
67
+ return parseProjectConfig(await read(CONFIG_FILE));
68
+ });
69
+ result.requiredPlugins =
70
+ (await check('node_modules/@dokebi/core/package.json', () => readRequiredPlugins(root))) ??
71
+ [];
49
72
  const pkg = await check('package.json', async () => {
50
73
  const value = parseObject(await read('package.json'), 'package');
51
74
  validateProjectPackage(value);
@@ -90,18 +113,17 @@ export class ProjectInspector {
90
113
  issue('MISSING_PACKAGE_DEPENDENCY', 'package.json', plugin.id);
91
114
  if (installed)
92
115
  matches(installed.packageName, installed.version, plugin.id);
93
- // [AI:수정] 추가한 버전·옵션 경계 검사를 저장소 포맷에 맞춘다.
94
116
  const options = config && Object.hasOwn(config.options, plugin.id)
95
117
  ? config.options[plugin.id]
96
118
  : undefined;
97
119
  const checked = installed
98
- ? await check(path, async () => checkPluginOptions(root, installed, options ?? {}), plugin.id)
120
+ ? await check(path, async () => optionsValidator.check(root, installed, options ?? {}), plugin.id)
99
121
  : null;
100
122
  if (checked?.errors.length)
101
123
  result.diagnostics.push({
102
124
  code: 'INVALID_PLUGIN_OPTIONS',
103
125
  severity: 'error',
104
- path: 'dokebi.json',
126
+ path: CONFIG_FILE,
105
127
  plugin: plugin.id,
106
128
  details: checked.errors,
107
129
  });
@@ -112,9 +134,56 @@ export class ProjectInspector {
112
134
  if (guide !== null)
113
135
  documentation = candidate;
114
136
  }
137
+ const installation = state.installations[plugin.id] ?? null;
138
+ if (installation?.entry) {
139
+ const entry = installation.entry;
140
+ const present = await check(entry, async () => exists(await securePath(root, entry)));
141
+ if (present === false)
142
+ issue('ENTRY_MISSING', entry, plugin.id);
143
+ // Installed before the D-0031 layout: it keeps working where it is.
144
+ if (legacyLayout(entry, plugin.id))
145
+ issue('LEGACY_LAYOUT', entry, plugin.id, 'warning');
146
+ }
147
+ if (settings.runChecks && installed && config && pkg) {
148
+ const verdict = await check(path, () => checkPlugin({
149
+ base: root,
150
+ plugin: installed,
151
+ options: config.options,
152
+ plugins: state.plugins,
153
+ projectPackages: Object.keys(pkg),
154
+ ...(installation ? { installation } : {}),
155
+ }), plugin.id);
156
+ if (verdict) {
157
+ const ids = new Set(state.plugins.map((item) => item.id));
158
+ const missing = verdict.requires.filter((id) => !ids.has(id));
159
+ if (missing.length)
160
+ result.diagnostics.push({
161
+ code: 'MISSING_REQUIRED_PLUGIN',
162
+ severity: 'error',
163
+ path: CONFIG_FILE,
164
+ plugin: plugin.id,
165
+ details: { requires: missing },
166
+ });
167
+ const recorded = [...(installation?.requires ?? [])].sort();
168
+ if (json(recorded) !== json([...verdict.requires].sort()))
169
+ issue('STALE_REQUIREMENTS', '.dokebi/state.json', plugin.id, 'warning');
170
+ for (const item of verdict.diagnostics)
171
+ result.diagnostics.push({
172
+ code: 'PLUGIN_CHECK',
173
+ severity: item.severity,
174
+ path: CONFIG_FILE,
175
+ plugin: plugin.id,
176
+ details: {
177
+ code: item.code,
178
+ ...(item.optionPath ? { optionPath: item.optionPath } : {}),
179
+ },
180
+ });
181
+ }
182
+ }
115
183
  result.plugins.push({
116
184
  expected: plugin,
117
185
  installed,
186
+ installation,
118
187
  optionsSchema: checked?.schema ?? null,
119
188
  documentation,
120
189
  optionKeys: options && typeof options === 'object' && !Array.isArray(options)
@@ -123,20 +192,16 @@ export class ProjectInspector {
123
192
  });
124
193
  }
125
194
  if (core)
126
- await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core));
195
+ await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core, result.requiredPlugins));
127
196
  for (const [path, owned] of Object.entries(state.files)) {
128
197
  const actual = await check(path, async () => fingerprint(await securePath(root, path)));
129
198
  if (actual !== owned.hash)
130
199
  issue('MODIFIED_TEMPLATE', path, null, 'warning');
131
200
  }
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
201
  }
137
202
  for (const [path, expected] of originals) {
138
203
  await check(path, async () => {
139
- if (hash(await readFile(await securePath(root, path))) !== expected)
204
+ if (hash(await readFile(await installedPath(root, path))) !== expected)
140
205
  throw new CliError('CONCURRENT_CHANGE', 'Metadata changed during inspection');
141
206
  return true;
142
207
  });
@@ -3,16 +3,31 @@ export interface OwnedFile {
3
3
  hash: string;
4
4
  owners: string[];
5
5
  }
6
+ /** Per-plugin results of the install lifecycle that are not part of the package manifest. */
7
+ export interface Installation {
8
+ entry?: string;
9
+ requires: string[];
10
+ directories: string[];
11
+ }
6
12
  export interface ProjectState {
7
- schemaVersion: 1;
13
+ schemaVersion: 2;
8
14
  plugins: PluginManifest[];
15
+ installations: Record<string, Installation>;
9
16
  files: Record<string, OwnedFile>;
10
17
  generatedHash: string;
18
+ /** Apps of @dokebi/app in registration order (src/apps/<name>/app.ts), D-0030. */
19
+ apps?: string[];
20
+ /** Modules of @dokebi/module in registration order (src/shared/modules/<name>), D-0032. */
21
+ modules?: string[];
11
22
  }
23
+ /** App and template names: lower-case letters, digits and hyphens, starting with a letter. */
24
+ export declare const APP_NAME: RegExp;
12
25
  export interface ProjectConfig {
13
26
  schemaVersion: 1;
14
27
  options: Record<string, unknown>;
15
28
  }
16
29
  export declare function emptyState(generatedHash: string): ProjectState;
30
+ export declare function defaultInstallation(plugin: PluginManifest): Installation;
31
+ /** Reads schema 1 (pre contract v2) and schema 2 state; callers always write schema 2. */
17
32
  export declare function readState(value: unknown): ProjectState;
18
33
  export declare function readConfig(value: unknown): ProjectConfig;
@@ -1,24 +1,40 @@
1
- // [AI:생성] 설치 중 플러그인 코드를 실행하지 않고 실제 인스턴스로 의존 플러그인을 연결한다.
2
1
  import { CliError } from './errors.js';
3
2
  import { object, templateTarget } from './filesystem.js';
4
- import { readManifest } from './manifest.js';
3
+ import { ID, readStoredManifest } from './manifest.js';
4
+ /** App and template names: lower-case letters, digits and hyphens, starting with a letter. */
5
+ export const APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
5
6
  export function emptyState(generatedHash) {
6
- return { schemaVersion: 1, plugins: [], files: Object.create(null), generatedHash };
7
+ return {
8
+ schemaVersion: 2,
9
+ plugins: [],
10
+ installations: Object.create(null),
11
+ files: Object.create(null),
12
+ generatedHash,
13
+ };
7
14
  }
15
+ export function defaultInstallation(plugin) {
16
+ return { requires: [], directories: [...(plugin.directories ?? [])] };
17
+ }
18
+ function stringList(value, check) {
19
+ if (!Array.isArray(value))
20
+ throw new CliError('INVALID_STATE', 'Invalid installation list');
21
+ const result = [];
22
+ for (const item of value) {
23
+ if (typeof item !== 'string' || result.includes(item))
24
+ throw new CliError('INVALID_STATE', 'Invalid installation list');
25
+ check(item);
26
+ result.push(item);
27
+ }
28
+ return result;
29
+ }
30
+ /** Reads schema 1 (pre contract v2) and schema 2 state; callers always write schema 2. */
8
31
  export function readState(value) {
9
32
  const input = object(value, 'state');
10
- if (input.schemaVersion !== 1 ||
33
+ if ((input.schemaVersion !== 1 && input.schemaVersion !== 2) ||
11
34
  !Array.isArray(input.plugins) ||
12
35
  typeof input.generatedHash !== 'string')
13
36
  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
- });
37
+ const plugins = input.plugins.map((entry) => readStoredManifest(entry));
22
38
  if (new Set(plugins.map((plugin) => plugin.id)).size !== plugins.length ||
23
39
  new Set(plugins.map((plugin) => plugin.packageName)).size !== plugins.length)
24
40
  throw new CliError('INVALID_STATE', 'Duplicate installed plugin');
@@ -35,7 +51,65 @@ export function readState(value) {
35
51
  throw new CliError('INVALID_STATE', 'Invalid file ownership');
36
52
  files[path] = { hash: file.hash, owners: file.owners };
37
53
  }
38
- return { schemaVersion: 1, plugins, files, generatedHash: input.generatedHash };
54
+ const installations = Object.create(null);
55
+ if (input.schemaVersion === 1) {
56
+ for (const plugin of plugins)
57
+ installations[plugin.id] = defaultInstallation(plugin);
58
+ }
59
+ else {
60
+ const stored = object(input.installations, 'installations');
61
+ const ids = new Set(plugins.map((plugin) => plugin.id));
62
+ if (Object.keys(stored).length !== ids.size || Object.keys(stored).some((id) => !ids.has(id)))
63
+ throw new CliError('INVALID_STATE', 'Installations must match installed plugins');
64
+ for (const [id, raw] of Object.entries(stored)) {
65
+ const item = object(raw, 'installation');
66
+ if (Object.keys(item).some((key) => !['entry', 'requires', 'directories'].includes(key)))
67
+ throw new CliError('INVALID_STATE', 'Unknown installation field');
68
+ const installation = {
69
+ requires: stringList(item.requires, (name) => {
70
+ if (!ID.test(name) || name === id)
71
+ throw new CliError('INVALID_STATE', 'Invalid plugin requirement');
72
+ }),
73
+ directories: stringList(item.directories, templateTarget),
74
+ };
75
+ if (item.entry !== undefined) {
76
+ if (typeof item.entry !== 'string')
77
+ throw new CliError('INVALID_STATE', 'Invalid entry');
78
+ templateTarget(item.entry);
79
+ if (!files[item.entry]?.owners.includes(id))
80
+ throw new CliError('INVALID_STATE', 'Entry file must be owned by its plugin');
81
+ installation.entry = item.entry;
82
+ }
83
+ installations[id] = installation;
84
+ }
85
+ }
86
+ let apps;
87
+ if (input.apps !== undefined) {
88
+ apps = stringList(input.apps, (name) => {
89
+ if (!APP_NAME.test(name))
90
+ throw new CliError('INVALID_STATE', 'Invalid app name');
91
+ });
92
+ if (apps.length && !plugins.some((plugin) => plugin.id === 'dokebi-app'))
93
+ throw new CliError('INVALID_STATE', 'Apps need the dokebi-app plugin');
94
+ }
95
+ let modules;
96
+ if (input.modules !== undefined) {
97
+ modules = stringList(input.modules, (name) => {
98
+ if (!APP_NAME.test(name))
99
+ throw new CliError('INVALID_STATE', 'Invalid module name');
100
+ });
101
+ if (modules.length && !plugins.some((plugin) => plugin.id === 'dokebi-module'))
102
+ throw new CliError('INVALID_STATE', 'Modules need the dokebi-module plugin');
103
+ }
104
+ return {
105
+ schemaVersion: 2,
106
+ plugins,
107
+ installations,
108
+ files,
109
+ generatedHash: input.generatedHash,
110
+ ...(apps?.length ? { apps } : {}),
111
+ ...(modules?.length ? { modules } : {}),
112
+ };
39
113
  }
40
114
  export function readConfig(value) {
41
115
  const config = object(value, 'config');
package/dist/project.d.ts CHANGED
@@ -1,16 +1,95 @@
1
+ import { type SetOption, type Warning } from './config-edit.js';
1
2
  import { NpmClient } from './npm-client.js';
3
+ export interface ConfigRequirement {
4
+ plugin: string;
5
+ errors: Array<{
6
+ instancePath: string;
7
+ keyword: string;
8
+ }>;
9
+ errorCount: number;
10
+ }
11
+ export interface PreservedDetail {
12
+ path: string;
13
+ reason: 'MODIFIED' | 'DELETED' | 'SHARED';
14
+ update?: string;
15
+ }
2
16
  export interface OperationResult {
3
17
  changed: string[];
4
18
  preserved: string[];
19
+ preservedDetails?: PreservedDetail[];
20
+ configRequired?: ConfigRequirement[];
21
+ /** Required plugins installed together with the requested one (manifest `package`). */
22
+ autoInstalled?: Array<{
23
+ id: string;
24
+ packageName: string;
25
+ version: string;
26
+ }>;
27
+ warnings?: Array<Warning & {
28
+ plugin?: string;
29
+ }>;
30
+ /** Commands to run after this one (for example the Vite build of a new Inertia app). */
31
+ nextSteps?: string[];
32
+ from?: string;
33
+ to?: string;
34
+ /** npm workspace root where `npm install` ran (or, in a dry run, would run) (D-0035). */
35
+ workspaceInstall?: string;
5
36
  }
6
37
  export declare class Project {
38
+ #private;
7
39
  private readonly npm;
8
40
  constructor(npm?: NpmClient);
9
41
  init(target: string, core?: string): Promise<OperationResult>;
10
- add(root: string, spec: string, dryRun?: boolean): Promise<OperationResult>;
42
+ migrate(root: string, dryRun?: boolean): Promise<OperationResult>;
43
+ add(root: string, spec: string, dryRun?: boolean, sets?: readonly SetOption[]): Promise<OperationResult>;
11
44
  remove(root: string, id: string, dryRun?: boolean): Promise<OperationResult>;
45
+ upgrade(root: string, id: string, to?: string, dryRun?: boolean): Promise<OperationResult>;
12
46
  private coreVersion;
13
- private change;
14
- private addFiles;
47
+ /**
48
+ * `dokebi app add <name>`: copies the app skeleton of @dokebi/app to src/apps/<name>, maps the
49
+ * given domains to it in dokebi.yaml and registers it in the generated code (D-0030 §7).
50
+ */
51
+ appAdd(root: string, name: string, options?: {
52
+ domains?: readonly string[];
53
+ template?: string;
54
+ dryRun?: boolean;
55
+ inertia?: boolean;
56
+ }): Promise<OperationResult>;
57
+ /** `dokebi app link <app> <domain> [--template t]`: maps one more domain to an existing app. */
58
+ appLink(root: string, name: string, domain: string, options?: {
59
+ template?: string;
60
+ dryRun?: boolean;
61
+ }): Promise<OperationResult>;
62
+ /** `dokebi app unlink <domain>`: removes one domain mapping from dokebi.yaml and the sample. */
63
+ appUnlink(root: string, domain: string, dryRun?: boolean): Promise<OperationResult>;
64
+ /** `dokebi app remove <name>`: unregisters the app; its folder is user code and stays. */
65
+ appRemove(root: string, name: string, dryRun?: boolean): Promise<OperationResult>;
66
+ /** `dokebi app list`: apps with the domains and templates that show them. */
67
+ appList(root: string): Promise<{
68
+ apps: {
69
+ name: string;
70
+ domains: {
71
+ domain: string;
72
+ template: string;
73
+ }[];
74
+ }[];
75
+ }>;
76
+ /**
77
+ * `dokebi module add <name>` (D-0032): copies the module skeleton of @dokebi/module to
78
+ * src/shared/modules/<name> and registers it in the generated code. `views: false` copies the
79
+ * skeleton without views (routes answer JSON).
80
+ */
81
+ moduleAdd(root: string, name: string, options?: {
82
+ views?: boolean;
83
+ dryRun?: boolean;
84
+ }): Promise<OperationResult>;
85
+ /** `dokebi module remove <name>`: unregisters the module; its folder is user code and stays. */
86
+ moduleRemove(root: string, name: string, dryRun?: boolean): Promise<OperationResult>;
87
+ /** `dokebi module list`: the registered modules in registration order. */
88
+ moduleList(root: string): Promise<{
89
+ modules: {
90
+ name: string;
91
+ path: string;
92
+ }[];
93
+ }>;
15
94
  private writeConfiguration;
16
95
  }