@dokebi/cli 0.1.1 → 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 (49) hide show
  1. package/AI.md +15 -14
  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.js +0 -3
  11. package/dist/example-config.d.ts +11 -0
  12. package/dist/example-config.js +16 -0
  13. package/dist/filesystem.js +9 -7
  14. package/dist/generator.d.ts +12 -2
  15. package/dist/generator.js +152 -22
  16. package/dist/install-module.d.ts +23 -0
  17. package/dist/install-module.js +107 -0
  18. package/dist/install-plan.d.ts +12 -0
  19. package/dist/install-plan.js +90 -0
  20. package/dist/layout.d.ts +17 -0
  21. package/dist/layout.js +35 -0
  22. package/dist/manifest.d.ts +24 -2
  23. package/dist/manifest.js +103 -43
  24. package/dist/merge-install-defaults.d.ts +2 -0
  25. package/dist/merge-install-defaults.js +79 -0
  26. package/dist/npm-client.d.ts +6 -0
  27. package/dist/npm-client.js +34 -20
  28. package/dist/npm-paths.js +0 -2
  29. package/dist/output.d.ts +1 -1
  30. package/dist/output.js +2 -7
  31. package/dist/plugin-lifecycle.d.ts +43 -0
  32. package/dist/plugin-lifecycle.js +194 -0
  33. package/dist/plugin-options.d.ts +3 -1
  34. package/dist/plugin-options.js +9 -38
  35. package/dist/project-guide.d.ts +1 -1
  36. package/dist/project-guide.js +8 -3
  37. package/dist/project-inspector.d.ts +7 -1
  38. package/dist/project-inspector.js +81 -17
  39. package/dist/project-state.d.ts +16 -1
  40. package/dist/project-state.js +87 -13
  41. package/dist/project.d.ts +82 -3
  42. package/dist/project.js +1138 -112
  43. package/dist/required-plugins.d.ts +6 -0
  44. package/dist/required-plugins.js +45 -0
  45. package/dist/transaction.d.ts +6 -1
  46. package/dist/transaction.js +51 -6
  47. package/dist/workspace.d.ts +22 -0
  48. package/dist/workspace.js +97 -0
  49. package/package.json +5 -4
@@ -1,23 +1,27 @@
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:수정] doctor도 호출마다 독립 검증기를 사용해 다중 플러그인 초기화 비용을 줄인다.
10
+ import { checkPlugin } from './plugin-lifecycle.js';
11
11
  import { PluginOptionsValidator } from './plugin-options.js';
12
- import { readConfig, readState } from './project-state.js';
13
- // Reads metadata only. Never imports plugin entry points or creates locks.
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
  };
@@ -36,17 +40,35 @@ export class ProjectInspector {
36
40
  : error instanceof Error && 'code' in error && typeof error.code === 'string'
37
41
  ? error.code
38
42
  : 'READ_FAILED';
39
- 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
+ });
40
52
  return null;
41
53
  }
42
54
  };
43
55
  const read = async (path) => {
44
- 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');
45
58
  originals.set(path, hash(text));
46
59
  return text;
47
60
  };
48
61
  const state = await check('.dokebi/state.json', async () => readState(parseObject(await read('.dokebi/state.json'), 'state')));
49
- 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
+ [];
50
72
  const pkg = await check('package.json', async () => {
51
73
  const value = parseObject(await read('package.json'), 'package');
52
74
  validateProjectPackage(value);
@@ -91,7 +113,6 @@ export class ProjectInspector {
91
113
  issue('MISSING_PACKAGE_DEPENDENCY', 'package.json', plugin.id);
92
114
  if (installed)
93
115
  matches(installed.packageName, installed.version, plugin.id);
94
- // [AI:수정] 추가한 버전·옵션 경계 검사를 저장소 포맷에 맞춘다.
95
116
  const options = config && Object.hasOwn(config.options, plugin.id)
96
117
  ? config.options[plugin.id]
97
118
  : undefined;
@@ -102,7 +123,7 @@ export class ProjectInspector {
102
123
  result.diagnostics.push({
103
124
  code: 'INVALID_PLUGIN_OPTIONS',
104
125
  severity: 'error',
105
- path: 'dokebi.json',
126
+ path: CONFIG_FILE,
106
127
  plugin: plugin.id,
107
128
  details: checked.errors,
108
129
  });
@@ -113,9 +134,56 @@ export class ProjectInspector {
113
134
  if (guide !== null)
114
135
  documentation = candidate;
115
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
+ }
116
183
  result.plugins.push({
117
184
  expected: plugin,
118
185
  installed,
186
+ installation,
119
187
  optionsSchema: checked?.schema ?? null,
120
188
  documentation,
121
189
  optionKeys: options && typeof options === 'object' && !Array.isArray(options)
@@ -124,20 +192,16 @@ export class ProjectInspector {
124
192
  });
125
193
  }
126
194
  if (core)
127
- await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core));
195
+ await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core, result.requiredPlugins));
128
196
  for (const [path, owned] of Object.entries(state.files)) {
129
197
  const actual = await check(path, async () => fingerprint(await securePath(root, path)));
130
198
  if (actual !== owned.hash)
131
199
  issue('MODIFIED_TEMPLATE', path, null, 'warning');
132
200
  }
133
- for (const id of Object.keys(config?.options ?? {})) {
134
- if (!state.plugins.some((plugin) => plugin.id === id))
135
- issue('ORPHAN_OPTIONS', 'dokebi.json', id, 'warning');
136
- }
137
201
  }
138
202
  for (const [path, expected] of originals) {
139
203
  await check(path, async () => {
140
- if (hash(await readFile(await securePath(root, path))) !== expected)
204
+ if (hash(await readFile(await installedPath(root, path))) !== expected)
141
205
  throw new CliError('CONCURRENT_CHANGE', 'Metadata changed during inspection');
142
206
  return true;
143
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
  }