@dokebi/cli 0.1.1 → 0.2.2

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 +81 -14
  2. package/README.md +23 -26
  3. package/dist/bin.js +205 -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 +9 -1
  38. package/dist/project-inspector.js +89 -18
  39. package/dist/project-state.d.ts +16 -1
  40. package/dist/project-state.js +87 -13
  41. package/dist/project.d.ts +86 -3
  42. package/dist/project.js +1164 -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
package/dist/project.js CHANGED
@@ -1,25 +1,37 @@
1
- // [AI:수정] 빈 디렉터리에 맞는 제거 API를 사용하고 정리 실패를 숨기지 않는다.
2
- import { mkdir, mkdtemp, readFile, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
3
2
  import { basename, dirname, join, resolve } from 'node:path';
3
+ import lt from 'semver/functions/lt.js';
4
+ import satisfies from 'semver/functions/satisfies.js';
5
+ import validRange from 'semver/ranges/valid.js';
6
+ import { applyConfigChanges, applySetOptions, markRemoved, setWarnings, } from './config-edit.js';
7
+ import { CONFIG_BACKUP, CONFIG_EXAMPLE, CONFIG_FILE, CONFIG_TEMPLATE, ignoreConfig, LEGACY_CONFIG_FILE, migrateConfig, parseProjectConfig, } from './configuration.js';
8
+ import { scaffoldConfig } from './configuration-scaffold.js';
4
9
  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';
10
+ import { markExampleRemoved, syncExample } from './example-config.js';
11
+ import { exists, fingerprint, hash, json, object, parseObject, securePath, templateTarget, } from './filesystem.js';
12
+ import { generateRegistration, LEGACY_MAIN_SOURCE, MAIN_SOURCE, MAIN_SOURCE_V1, TSCONFIG_SOURCE, } from './generator.js';
13
+ import { orderPlugins, PACKAGE_NAME, readManifest, } from './manifest.js';
14
+ import { mergeInstallDefaults } from './merge-install-defaults.js';
8
15
  import { dependencyMap, NpmClient, packageSpec, validateProjectPackage } from './npm-client.js';
9
- // [AI:수정] CLI 진단·스키마·회귀 검증의 포맷과 import 순서를 맞춘다.
10
- import { validatePluginOptions } from './plugin-options.js';
11
- // [AI:수정] 새 프로젝트에 설치 버전 계약과 CLI 진단을 안내하는 짧은 AI 시작 문서를 생성한다.
16
+ import { checkAll, optionsOf, planLifecycle, } from './plugin-lifecycle.js';
17
+ import { PluginOptionsValidator, validatePluginOptions } from './plugin-options.js';
12
18
  import { PROJECT_GUIDE } from './project-guide.js';
13
- import { emptyState, readConfig, readState, } from './project-state.js';
19
+ import { APP_NAME, defaultInstallation, emptyState, readState, } from './project-state.js';
20
+ import { readRequiredPlugins } from './required-plugins.js';
14
21
  import { Transaction } from './transaction.js';
22
+ import { installedPath, packageFile, workspacePackages, workspaceRootOf, } from './workspace.js';
15
23
  const GENERATED = 'src/dokebi.generated.ts';
24
+ const EXAMPLE = 'dokebi.example.yaml';
25
+ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
26
+ function union(...lists) {
27
+ return [...new Set(lists.flat())];
28
+ }
16
29
  export class Project {
17
30
  npm;
18
31
  constructor(npm = new NpmClient()) {
19
32
  this.npm = npm;
20
33
  }
21
- // [AI:수정] 새 프로젝트의 기본 코어를 같은 패치 릴리스로 고정한다.
22
- async init(target, core = '@dokebi/core@0.1.1') {
34
+ async init(target, core = '@dokebi/core@0.2.2') {
23
35
  target = resolve(target);
24
36
  if (await exists(target))
25
37
  throw new CliError('PROJECT_EXISTS', target);
@@ -35,17 +47,20 @@ export class Project {
35
47
  private: true,
36
48
  type: 'module',
37
49
  scripts: { start: 'node src/main.ts' },
38
- engines: { node: '^24.21.0 || ^26.11.1' },
50
+ engines: { node: '>=24.0.0' },
39
51
  }));
40
52
  await this.npm.install(staging, packageSpec(core, process.cwd()));
41
53
  await this.coreVersion(staging);
54
+ await readRequiredPlugins(staging);
42
55
  const generated = generateRegistration([]);
43
56
  await writeFile(join(staging, GENERATED), generated);
44
57
  await writeFile(join(staging, 'src/main.ts'), MAIN_SOURCE);
45
58
  await writeFile(join(staging, 'AGENTS.md'), PROJECT_GUIDE);
46
- await writeFile(join(staging, 'dokebi.json'), json({ schemaVersion: 1, options: {} }));
59
+ await writeFile(join(staging, 'tsconfig.json'), TSCONFIG_SOURCE);
60
+ await writeFile(join(staging, CONFIG_FILE), CONFIG_TEMPLATE, { mode: 0o600 });
61
+ await writeFile(join(staging, EXAMPLE), CONFIG_EXAMPLE);
47
62
  await writeFile(join(staging, '.dokebi/state.json'), json(emptyState(hash(generated))));
48
- await writeFile(join(staging, '.gitignore'), 'node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n');
63
+ await writeFile(join(staging, '.gitignore'), ignoreConfig('node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n'));
49
64
  // Reserve the destination before moving anything so existing projects are never replaced.
50
65
  await mkdir(target);
51
66
  try {
@@ -56,7 +71,6 @@ export class Project {
56
71
  await rmdir(target);
57
72
  }
58
73
  catch (cleanup) {
59
- // [AI:수정] 초기화 실패 오류 보존 코드의 포맷을 맞춘다.
60
74
  throw new AggregateError([cause, cleanup], 'Initialization and destination cleanup failed');
61
75
  }
62
76
  throw cause;
@@ -67,123 +81,635 @@ export class Project {
67
81
  await rm(staging, { recursive: true, force: true });
68
82
  }
69
83
  }
70
- async add(root, spec, dryRun = false) {
71
- return this.change(root, { add: packageSpec(spec, root) }, dryRun);
84
+ async migrate(root, dryRun = false) {
85
+ root = await realpath(root);
86
+ await readFile(await securePath(root, '.dokebi/state.json'));
87
+ const tx = await Transaction.begin(root);
88
+ try {
89
+ for (const path of [CONFIG_FILE, CONFIG_BACKUP, EXAMPLE])
90
+ await tx.capture(path);
91
+ if (await exists(await securePath(root, CONFIG_FILE)))
92
+ throw new CliError('CONFIG_CONFLICT', 'dokebi.yaml already exists; no configuration files were merged');
93
+ if (await exists(await securePath(root, CONFIG_BACKUP)))
94
+ throw new CliError('BACKUP_EXISTS', 'Preserve the existing legacy configuration backup before migrating');
95
+ const state = readState(parseObject(await tx.read('.dokebi/state.json'), 'state'));
96
+ if (hash(await tx.read(GENERATED)) !== state.generatedHash)
97
+ throw new CliError('MODIFIED_REGISTRATION', GENERATED);
98
+ const main = await tx.read('src/main.ts');
99
+ if (main !== LEGACY_MAIN_SOURCE && main !== MAIN_SOURCE_V1 && main !== MAIN_SOURCE)
100
+ throw new CliError('MIGRATION_MAIN_REQUIRED', 'Automatic migration supports generated entry points only; migrate custom src/main.ts and configuration manually using the documented flow');
101
+ const original = await tx.read(LEGACY_CONFIG_FILE);
102
+ const source = migrateConfig(original);
103
+ const config = parseProjectConfig(source);
104
+ const version = await this.coreVersion(root);
105
+ const required = await readRequiredPlugins(root);
106
+ await validatePluginOptions(root, orderPlugins(state.plugins, version, required), config.options);
107
+ await tx.write(CONFIG_BACKUP, original);
108
+ await tx.replace(LEGACY_CONFIG_FILE);
109
+ await this.writeConfiguration(tx, state, source);
110
+ if (main === LEGACY_MAIN_SOURCE)
111
+ await tx.write('src/main.ts', MAIN_SOURCE);
112
+ if (!(await exists(await securePath(root, EXAMPLE))))
113
+ await tx.write(EXAMPLE, CONFIG_EXAMPLE);
114
+ const ignorePath = await securePath(root, '.gitignore');
115
+ const ignore = (await exists(ignorePath)) ? await tx.read('.gitignore') : '';
116
+ await tx.write('.gitignore', ignoreConfig(ignore));
117
+ const result = { changed: tx.paths, preserved: [CONFIG_BACKUP] };
118
+ if (!dryRun)
119
+ await tx.commit();
120
+ return result;
121
+ }
122
+ finally {
123
+ await tx.close();
124
+ }
125
+ }
126
+ async add(root, spec, dryRun = false, sets = []) {
127
+ const operation = packageSpec(spec, root);
128
+ const session = await this.#begin(root);
129
+ const { tx, state } = session;
130
+ try {
131
+ const dependencies = dependencyMap(session.pkg);
132
+ const staging = await this.#stage(session, session.pkg, dependencies, operation);
133
+ const after = await this.#installedDependencies(staging);
134
+ const name = operation.startsWith('file:')
135
+ ? Object.keys(after).find((key) => after[key] === operation)
136
+ : /^(@[^/]+\/[^@]+|[^@]+)/.exec(operation)?.[1];
137
+ if (!name || !Object.hasOwn(after, name))
138
+ throw new CliError('INVALID_PACKAGE', 'Cannot identify installed package');
139
+ const added = await this.#readPackageManifest(staging, name);
140
+ if (session.required.some((plugin) => plugin.id === added.id || plugin.packageName === added.packageName))
141
+ throw new CliError('REQUIRED_PLUGIN', 'Core already supplies this required plugin');
142
+ const existing = state.plugins.find((plugin) => plugin.id === added.id || plugin.packageName === added.packageName);
143
+ if (existing) {
144
+ if (existing.id === added.id &&
145
+ existing.packageName === added.packageName &&
146
+ existing.version === added.version)
147
+ return { changed: [], preserved: [] };
148
+ throw new CliError('UPGRADE_UNSUPPORTED', `Use dokebi upgrade ${added.id}`);
149
+ }
150
+ if (Object.hasOwn(dependencies, name))
151
+ throw new CliError('PACKAGE_CONFLICT', name);
152
+ // Required dependencies that name their npm package are installed in the same staging
153
+ // and transaction, before the requested plugin (D-0030 §9).
154
+ const automatic = await this.#automaticDependencies(staging, state, session, added, dependencies);
155
+ const installed = await this.#installedDependencies(staging);
156
+ await this.#verifyManifests(staging, state.plugins);
157
+ const ordered = await this.#order(staging, [...state.plugins, ...automatic, added], session.required);
158
+ const warnings = setWarnings(sets);
159
+ let configSource = sets.length
160
+ ? applySetOptions(session.configSource, added.id, sets)
161
+ : session.configSource;
162
+ let config = parseProjectConfig(configSource);
163
+ const projectPackages = Object.keys(installed);
164
+ const validator = new PluginOptionsValidator({ allErrors: true });
165
+ const installations = { ...state.installations };
166
+ const newIds = new Set([...automatic, added].map((plugin) => plugin.id));
167
+ const examples = [];
168
+ // Plans run in dependency order so each plugin sees the ones it builds on.
169
+ for (const plugin of ordered.filter((item) => newIds.has(item.id))) {
170
+ const plan = await planLifecycle({
171
+ base: staging,
172
+ plugin,
173
+ options: config.options,
174
+ plugins: ordered,
175
+ projectPackages,
176
+ }, 'planInstall');
177
+ for (const defaults of plan.defaults)
178
+ configSource = mergeInstallDefaults(configSource, plugin.id, defaults);
179
+ config = parseProjectConfig(configSource);
180
+ const initial = await validator.check(staging, plugin, optionsOf(config.options, plugin.id));
181
+ configSource = scaffoldConfig(configSource, plugin.id, initial.schema);
182
+ config = parseProjectConfig(configSource);
183
+ installations[plugin.id] = {
184
+ requires: plan.requires,
185
+ directories: plan.directories ?? [],
186
+ ...(plan.entry ? { entry: plan.entry.target } : {}),
187
+ };
188
+ for (const directory of plan.directories ?? [])
189
+ await tx.ensureDirectory(directory);
190
+ await this.#addFiles(tx, staging, state, plugin, this.#planFiles(plan));
191
+ examples.push({
192
+ id: plugin.id,
193
+ update: { defaults: plan.defaults, schema: initial.schema },
194
+ });
195
+ }
196
+ // The first app comes with the plugin, so a new multi-app project runs right away.
197
+ if (added.id === 'dokebi-app' && !(state.apps ?? []).length) {
198
+ const first = await this.#addApp(tx, staging, state, configSource, 'main', {
199
+ domains: ['localhost'],
200
+ });
201
+ configSource = first.configSource;
202
+ config = parseProjectConfig(configSource);
203
+ examples.push({ id: 'dokebi-app', update: { changes: first.changes } });
204
+ }
205
+ await this.#syncExamples(tx, session.root, warnings, examples);
206
+ const configRequired = [];
207
+ await this.#check(staging, ordered, config, installations, projectPackages, { id: added.id, requires: installations[added.id]?.requires ?? [] }, configRequired, warnings);
208
+ await this.#validateOptions(validator, staging, ordered, config, configRequired);
209
+ state.plugins = ordered;
210
+ state.installations = installations;
211
+ await this.writeConfiguration(tx, state, configSource);
212
+ await this.#replacePackages(session, staging, session.pkg);
213
+ const result = {
214
+ changed: tx.paths,
215
+ preserved: [],
216
+ ...(configRequired.length ? { configRequired } : {}),
217
+ ...(automatic.length
218
+ ? {
219
+ autoInstalled: automatic.map(({ id, packageName, version }) => ({
220
+ id,
221
+ packageName,
222
+ version,
223
+ })),
224
+ }
225
+ : {}),
226
+ ...(warnings.length ? { warnings } : {}),
227
+ };
228
+ return await this.#finish(session, result, dryRun);
229
+ }
230
+ finally {
231
+ await tx.close();
232
+ }
233
+ }
234
+ /**
235
+ * Installs, into the staging directory, every missing required dependency (recursively) whose
236
+ * manifest entry names an npm `package`. Returns their manifests; optional ones are skipped and
237
+ * required ones without `package` are left to the usual MISSING_REQUIRED_PLUGIN check.
238
+ */
239
+ async #automaticDependencies(staging, state, session, added, dependencies) {
240
+ const known = new Set([
241
+ ...state.plugins.map((plugin) => plugin.id),
242
+ ...session.required.map((plugin) => plugin.id),
243
+ added.id,
244
+ ]);
245
+ const result = [];
246
+ const pending = [added];
247
+ while (pending.length) {
248
+ const plugin = pending.shift();
249
+ for (const dependency of plugin.dependencies) {
250
+ if (dependency.optional || !dependency.package || known.has(dependency.name))
251
+ continue;
252
+ if (result.length >= 16)
253
+ throw new CliError('INVALID_MANIFEST', 'Too many automatic dependencies');
254
+ if (Object.hasOwn(dependencies, dependency.package))
255
+ throw new CliError('PACKAGE_CONFLICT', dependency.package);
256
+ await this.#stageInstall(session, staging, `${dependency.package}@${dependency.version}`);
257
+ const manifest = await this.#readPackageManifest(staging, dependency.package);
258
+ if (manifest.id !== dependency.name)
259
+ throw new CliError('INVALID_MANIFEST', `${dependency.package} is not ${dependency.name}`);
260
+ known.add(manifest.id);
261
+ result.push(manifest);
262
+ pending.push(manifest);
263
+ }
264
+ }
265
+ return result;
72
266
  }
73
267
  async remove(root, id, dryRun = false) {
74
- return this.change(root, { remove: id }, dryRun);
268
+ const session = await this.#begin(root);
269
+ const { tx, state } = session;
270
+ try {
271
+ if (id === 'dokebi-app' && (state.apps ?? []).length)
272
+ throw new CliError('APPS_PRESENT', 'Remove the apps first (dokebi app remove <name>)', {
273
+ details: { apps: state.apps },
274
+ });
275
+ if (id === 'dokebi-module' && (state.modules ?? []).length)
276
+ throw new CliError('MODULES_PRESENT', 'Remove the modules first (dokebi module remove <name>)', { details: { modules: state.modules } });
277
+ if (session.required.some((plugin) => plugin.id === id || plugin.packageName === id))
278
+ throw new CliError('REQUIRED_PLUGIN', 'Core-required plugins cannot be removed');
279
+ const removed = state.plugins.find((plugin) => plugin.id === id);
280
+ if (!removed)
281
+ return { changed: [], preserved: [] };
282
+ if (state.plugins.some((plugin) => plugin.dependencies.some((dep) => dep.name === removed.id && !dep.optional)))
283
+ throw new CliError('REVERSE_DEPENDENCY', removed.id);
284
+ const dependencies = dependencyMap(session.pkg);
285
+ delete dependencies[removed.packageName];
286
+ const staging = await this.#stage(session, session.pkg, dependencies, undefined);
287
+ const after = await this.#installedDependencies(staging);
288
+ const next = state.plugins.filter((plugin) => plugin.id !== removed.id);
289
+ await this.#verifyManifests(staging, next);
290
+ const ordered = await this.#order(staging, next, session.required);
291
+ let configSource = session.configSource;
292
+ const config = parseProjectConfig(configSource);
293
+ const installations = { ...state.installations };
294
+ delete installations[removed.id];
295
+ const checks = await this.#runChecks(staging, ordered, config, installations, Object.keys(after));
296
+ for (const plugin of ordered) {
297
+ if (checks.get(plugin.id)?.requires.includes(removed.id))
298
+ throw new CliError('REVERSE_DEPENDENCY', removed.id, {
299
+ details: { plugin: plugin.id, requires: [removed.id] },
300
+ });
301
+ }
302
+ for (const plugin of ordered)
303
+ installations[plugin.id] = {
304
+ ...(installations[plugin.id] ?? defaultInstallation(plugin)),
305
+ requires: checks.get(plugin.id)?.requires ?? [],
306
+ };
307
+ const validator = new PluginOptionsValidator({ allErrors: true });
308
+ // Remaining plugins that only miss required values do not block removal; they are
309
+ // reported in the result like add/upgrade do.
310
+ const configRequired = [];
311
+ await this.#validateOptions(validator, staging, ordered, config, configRequired);
312
+ const preserved = [];
313
+ for (const [path, file] of Object.entries(state.files)) {
314
+ if (!file.owners.includes(removed.id))
315
+ continue;
316
+ file.owners = file.owners.filter((owner) => owner !== removed.id);
317
+ if (file.owners.length)
318
+ continue;
319
+ await tx.capture(path);
320
+ const current = await fingerprint(await securePath(session.root, path));
321
+ if (current === file.hash)
322
+ await tx.replace(path);
323
+ else if (current !== null)
324
+ preserved.push(path);
325
+ delete state.files[path];
326
+ }
327
+ const marked = markRemoved(configSource, removed.id);
328
+ configSource = marked.source;
329
+ if (marked.marked)
330
+ preserved.push(`${CONFIG_FILE}#options.${removed.id}`);
331
+ for (const directory of state.installations[removed.id]?.directories ?? []) {
332
+ if (await exists(await securePath(session.root, directory)))
333
+ preserved.push(directory);
334
+ }
335
+ if (await exists(await securePath(session.root, EXAMPLE))) {
336
+ const example = await tx.read(EXAMPLE);
337
+ try {
338
+ const updated = markExampleRemoved(example, removed.id);
339
+ if (updated !== example)
340
+ await tx.write(EXAMPLE, updated);
341
+ }
342
+ catch {
343
+ /* An invalid example file is reported by doctor, not by remove. */
344
+ }
345
+ }
346
+ state.plugins = ordered;
347
+ state.installations = installations;
348
+ await this.writeConfiguration(tx, state, configSource);
349
+ await this.#replacePackages(session, staging, session.pkg);
350
+ const result = {
351
+ changed: tx.paths,
352
+ preserved,
353
+ ...(configRequired.length ? { configRequired } : {}),
354
+ };
355
+ return await this.#finish(session, result, dryRun);
356
+ }
357
+ finally {
358
+ await tx.close();
359
+ }
360
+ }
361
+ async upgrade(root, id, to, dryRun = false) {
362
+ const session = await this.#begin(root);
363
+ const { tx, state } = session;
364
+ try {
365
+ const current = state.plugins.find((plugin) => plugin.id === id);
366
+ if (!current)
367
+ throw new CliError('UNKNOWN_PLUGIN', id);
368
+ // A bare version or range applies to the installed package; otherwise use a full spec.
369
+ const operation = to === undefined
370
+ ? `${current.packageName}@latest`
371
+ : to !== 'latest' && !to.endsWith('.tgz') && validRange(to) !== null
372
+ ? packageSpec(`${current.packageName}@${to}`, session.root)
373
+ : packageSpec(to, session.root);
374
+ const dependencies = dependencyMap(session.pkg);
375
+ const staging = await this.#stage(session, session.pkg, dependencies, operation);
376
+ const after = await this.#installedDependencies(staging);
377
+ if (operation.startsWith('file:') &&
378
+ Object.keys(after).find((key) => after[key] === operation) !== current.packageName)
379
+ throw new CliError('UPGRADE_IDENTITY_CHANGED', id);
380
+ if (!operation.startsWith('file:') && !operation.startsWith(current.packageName + '@'))
381
+ throw new CliError('UPGRADE_IDENTITY_CHANGED', id);
382
+ const upgraded = await this.#readPackageManifest(staging, current.packageName);
383
+ if (upgraded.id !== current.id || upgraded.packageName !== current.packageName)
384
+ throw new CliError('UPGRADE_IDENTITY_CHANGED', id);
385
+ if (lt(upgraded.version, current.version))
386
+ throw new CliError('UPGRADE_DOWNGRADE', id);
387
+ // Same version and same manifest: nothing to do. Same version with a different manifest
388
+ // (a package changed without a version bump) is applied again so the project can follow
389
+ // what is installed (D-0037).
390
+ const sameVersion = upgraded.version === current.version;
391
+ if (sameVersion && json(upgraded) === json(current))
392
+ return { changed: [], preserved: [], from: current.version, to: upgraded.version };
393
+ const others = state.plugins.filter((plugin) => plugin.id !== id);
394
+ await this.#verifyManifests(staging, others);
395
+ const ordered = await this.#order(staging, state.plugins.map((plugin) => (plugin.id === id ? upgraded : plugin)), session.required);
396
+ const installation = state.installations[id] ?? defaultInstallation(current);
397
+ let configSource = session.configSource;
398
+ let config = parseProjectConfig(configSource);
399
+ const projectPackages = Object.keys(after);
400
+ const plan = await planLifecycle({
401
+ base: staging,
402
+ plugin: upgraded,
403
+ options: config.options,
404
+ plugins: ordered,
405
+ projectPackages,
406
+ installation,
407
+ }, 'planUpgrade', {
408
+ from: current.version,
409
+ to: upgraded.version,
410
+ installation: installation.entry ? { entry: installation.entry } : {},
411
+ });
412
+ if (plan.entry && installation.entry && plan.entry.target !== installation.entry)
413
+ throw new CliError('UPGRADE_ENTRY_MOVED', id);
414
+ configSource = applyConfigChanges(configSource, id, plan.configChanges);
415
+ for (const defaults of plan.defaults)
416
+ configSource = mergeInstallDefaults(configSource, id, defaults);
417
+ config = parseProjectConfig(configSource);
418
+ const validator = new PluginOptionsValidator({ allErrors: true });
419
+ const initial = await validator.check(staging, upgraded, optionsOf(config.options, id));
420
+ configSource = scaffoldConfig(configSource, id, initial.schema);
421
+ config = parseProjectConfig(configSource);
422
+ const installations = {
423
+ ...state.installations,
424
+ [id]: {
425
+ requires: plan.requires,
426
+ directories: union(installation.directories, plan.directories ?? []),
427
+ ...(plan.entry ? { entry: plan.entry.target } : {}),
428
+ },
429
+ };
430
+ const warnings = [];
431
+ const configRequired = [];
432
+ await this.#check(staging, ordered, config, installations, projectPackages, { id, requires: plan.requires }, configRequired, warnings);
433
+ await this.#validateOptions(validator, staging, ordered, config, configRequired);
434
+ for (const directory of plan.directories ?? [])
435
+ await tx.ensureDirectory(directory);
436
+ const details = await this.#reconcileFiles(tx, staging, state, upgraded, this.#planFiles(plan));
437
+ await this.#syncExample(tx, session.root, id, warnings, {
438
+ changes: plan.configChanges,
439
+ defaults: plan.defaults,
440
+ schema: initial.schema,
441
+ });
442
+ state.plugins = ordered;
443
+ state.installations = installations;
444
+ await this.writeConfiguration(tx, state, configSource);
445
+ await this.#replacePackages(session, staging, session.pkg);
446
+ const result = {
447
+ changed: tx.paths,
448
+ preserved: details.map((detail) => detail.path),
449
+ ...(details.length ? { preservedDetails: details } : {}),
450
+ ...(configRequired.length ? { configRequired } : {}),
451
+ ...(warnings.length ? { warnings } : {}),
452
+ ...(sameVersion ? { reason: 'MANIFEST_CHANGED' } : {}),
453
+ from: current.version,
454
+ to: upgraded.version,
455
+ };
456
+ return await this.#finish(session, result, dryRun);
457
+ }
458
+ finally {
459
+ await tx.close();
460
+ }
75
461
  }
76
462
  async coreVersion(root) {
77
- const pkg = parseObject(await readFile(await securePath(root, 'node_modules/@dokebi/core/package.json'), 'utf8'), 'core');
78
- if (pkg.name !== '@dokebi/core' || typeof pkg.version !== 'string')
79
- throw new CliError('INVALID_CORE', 'Missing Dokebi core');
463
+ const pkg = parseObject(await readFile(await packageFile(root, '@dokebi/core', 'package.json'), 'utf8'), 'core');
464
+ if (pkg.name !== '@dokebi/core' ||
465
+ typeof pkg.version !== 'string' ||
466
+ !satisfies(pkg.version, '>=0.2.0'))
467
+ throw new CliError('CORE_UPGRADE_REQUIRED', 'Dokebi YAML projects require @dokebi/core 0.2.0 or newer');
80
468
  return pkg.version;
81
469
  }
82
- async change(root, operation, dryRun) {
470
+ /** Takes the project lock and reads the common state every mutation needs. */
471
+ async #begin(root) {
83
472
  root = await realpath(root);
84
473
  // Do not create control files in arbitrary non-Dokebi projects.
85
474
  await readFile(await securePath(root, '.dokebi/state.json'));
475
+ const workspaceRoot = await workspaceRootOf(root);
86
476
  const tx = await Transaction.begin(root);
87
477
  try {
88
478
  const state = readState(JSON.parse(await tx.read('.dokebi/state.json')));
89
- const config = readConfig(JSON.parse(await tx.read('dokebi.json')));
479
+ await tx.capture(LEGACY_CONFIG_FILE);
480
+ if (await exists(await securePath(root, LEGACY_CONFIG_FILE))) {
481
+ throw new CliError((await exists(await securePath(root, CONFIG_FILE)))
482
+ ? 'CONFIG_CONFLICT'
483
+ : 'MIGRATION_REQUIRED', 'Run dokebi migrate after resolving conflicting configuration files');
484
+ }
485
+ const configSource = await tx.read(CONFIG_FILE);
486
+ parseProjectConfig(configSource);
487
+ const required = await readRequiredPlugins(root);
90
488
  const pkg = parseObject(await tx.read('package.json'), 'package.json');
91
489
  validateProjectPackage(pkg);
92
490
  if (hash(await tx.read(GENERATED)) !== state.generatedHash)
93
491
  throw new CliError('MODIFIED_REGISTRATION', GENERATED);
94
- const dependencies = dependencyMap(pkg);
95
- const removed = 'remove' in operation
96
- ? state.plugins.find((plugin) => plugin.id === operation.remove)
97
- : undefined;
98
- if ('remove' in operation && !removed)
99
- return { changed: [], preserved: [] };
100
- if (removed &&
101
- state.plugins.some((plugin) => plugin.dependencies.some((dep) => dep.name === removed.id))) {
102
- throw new CliError('REVERSE_DEPENDENCY', removed.id);
103
- }
104
- const staging = join(tx.directory, 'npm');
105
- await mkdir(staging);
106
- if (removed)
107
- delete dependencies[removed.packageName];
108
- pkg.dependencies = dependencies;
109
- await writeFile(join(staging, 'package.json'), json(pkg));
110
- const lock = await securePath(root, 'package-lock.json');
492
+ if (!workspaceRoot)
493
+ return { root, tx, state, configSource, required, pkg };
494
+ const workspace = {
495
+ root: workspaceRoot,
496
+ packages: await workspacePackages(workspaceRoot),
497
+ lock: await readOptional(join(workspaceRoot, 'package-lock.json')),
498
+ };
499
+ return { root, tx, state, configSource, required, pkg, workspace };
500
+ }
501
+ catch (error) {
502
+ await tx.close();
503
+ throw error;
504
+ }
505
+ }
506
+ async #stage(session, pkg, dependencies, spec) {
507
+ const { tx, workspace } = session;
508
+ const staging = join(tx.directory, 'npm');
509
+ await mkdir(staging);
510
+ if (!workspace) {
511
+ await writeFile(join(staging, 'package.json'), json({ ...pkg, dependencies }));
512
+ const lock = await securePath(tx.root, 'package-lock.json');
111
513
  if (await exists(lock))
112
514
  await writeFile(join(staging, 'package-lock.json'), await tx.read('package-lock.json'));
113
515
  await tx.capture('node_modules');
114
- await this.npm.install(staging, 'add' in operation ? operation.add : undefined);
115
- const installed = parseObject(await readFile(join(staging, 'package.json'), 'utf8'), 'installed package');
116
- const after = dependencyMap(installed);
117
- let added;
118
- if ('add' in operation) {
119
- const name = operation.add.startsWith('file:')
120
- ? Object.keys(after).find((key) => after[key] === operation.add)
121
- : /^(@[^/]+\/[^@]+|[^@]+)/.exec(operation.add)?.[1];
122
- if (!name || !Object.hasOwn(after, name))
123
- throw new CliError('INVALID_PACKAGE', 'Cannot identify installed package');
124
- added = readManifest(parseObject(await readFile(await securePath(staging, 'node_modules/' + name + '/package.json'), 'utf8'), name));
125
- const existing = state.plugins.find((plugin) => plugin.id === added?.id || plugin.packageName === added?.packageName);
126
- if (existing) {
127
- if (existing.id === added.id &&
128
- existing.packageName === added.packageName &&
129
- existing.version === added.version)
130
- return { changed: [], preserved: [] };
131
- throw new CliError('UPGRADE_UNSUPPORTED', added.id);
132
- }
133
- if (Object.hasOwn(dependencies, name))
134
- throw new CliError('PACKAGE_CONFLICT', name);
135
- }
136
- const next = state.plugins.filter((plugin) => plugin.id !== removed?.id);
137
- if (added)
138
- next.push(added);
139
- // npm must not silently alter the identity/version of previously registered plugins.
140
- for (const plugin of next) {
141
- const actual = readManifest(JSON.parse(await readFile(await securePath(staging, 'node_modules/' + plugin.packageName + '/package.json'), 'utf8')));
142
- if (json(actual) !== json(plugin))
143
- throw new CliError('MANIFEST_CHANGED', plugin.id);
144
- }
145
- const ordered = orderPlugins(next, await this.coreVersion(staging));
146
- await validatePluginOptions(staging, ordered, config.options);
147
- const preserved = [];
148
- if (added)
149
- await this.addFiles(tx, staging, state, added);
150
- if (removed) {
151
- for (const [path, file] of Object.entries(state.files)) {
152
- if (!file.owners.includes(removed.id))
153
- continue;
154
- file.owners = file.owners.filter((owner) => owner !== removed.id);
155
- if (file.owners.length)
156
- continue;
157
- await tx.capture(path);
158
- const current = await fingerprint(await securePath(root, path));
159
- if (current === file.hash)
160
- await tx.replace(path);
161
- else if (current !== null)
162
- preserved.push(path);
163
- delete state.files[path];
164
- }
165
- delete config.options[removed.id];
516
+ await this.npm.install(staging, spec);
517
+ return staging;
518
+ }
519
+ // A standalone copy of the member. Workspace packages (maybe unpublished) come in as packed
520
+ // copies, directly and through overrides, so staging holds no links (D-0035 §3).
521
+ const names = [
522
+ ...Object.keys(dependencies),
523
+ ...DEPENDENCY_SECTIONS.slice(1).flatMap((section) => Object.keys(object(pkg[section] ?? {}, section))),
524
+ ];
525
+ const packs = await this.#packWorkspace(session, names);
526
+ const local = (section) => Object.fromEntries(Object.entries(section).map(([name, value]) => [name, packs.get(name) ?? value]));
527
+ const staged = { ...pkg, dependencies: local(dependencies) };
528
+ for (const section of DEPENDENCY_SECTIONS.slice(1))
529
+ if (pkg[section] !== undefined)
530
+ staged[section] = local(object(pkg[section], section));
531
+ staged.overrides = {
532
+ ...(pkg.overrides && typeof pkg.overrides === 'object' ? pkg.overrides : {}),
533
+ ...Object.fromEntries(packs),
534
+ };
535
+ await writeFile(join(staging, 'package.json'), json(staged));
536
+ await this.#stageInstall(session, staging, spec);
537
+ return staging;
538
+ }
539
+ /**
540
+ * Installs `spec` into staging. In a workspace member a workspace package is written into the
541
+ * staged package.json with the same archive its override names (npm refuses an override that
542
+ * differs from the direct dependency) and installed from there (D-0035 §3).
543
+ */
544
+ async #stageInstall(session, staging, spec) {
545
+ const workspace = session.workspace;
546
+ const local = workspace && spec !== undefined && this.#workspacePackage(workspace, spec);
547
+ if (!local)
548
+ return this.npm.install(staging, spec);
549
+ const packs = await this.#packWorkspace(session, [local.name]);
550
+ const path = join(staging, 'package.json');
551
+ const staged = parseObject(await readFile(path, 'utf8'), 'staged');
552
+ staged.dependencies = {
553
+ ...object(staged.dependencies ?? {}, 'dependencies'),
554
+ [local.name]: packs.get(local.name),
555
+ };
556
+ staged.overrides = {
557
+ ...object(staged.overrides ?? {}, 'overrides'),
558
+ ...Object.fromEntries(packs),
559
+ };
560
+ await writeFile(path, json(staged));
561
+ await this.npm.install(staging);
562
+ }
563
+ /**
564
+ * `file:` archives of the named workspace packages and every workspace package they depend on
565
+ * (dependencies, peers, optional), packed once per operation into the transaction directory.
566
+ * npm installs a packed archive like a published package; directory overrides with
567
+ * `--install-links` put the wrong package in place (npm 11, observed with @dokebi/config).
568
+ */
569
+ async #packWorkspace(session, names) {
570
+ const workspace = session.workspace;
571
+ workspace.packed ??= new Map();
572
+ const packed = workspace.packed;
573
+ const pending = [...names];
574
+ const wanted = [];
575
+ const seen = new Set(packed.keys());
576
+ while (pending.length) {
577
+ const name = pending.pop();
578
+ const found = workspace.packages.get(name);
579
+ if (!found || seen.has(name))
580
+ continue;
581
+ seen.add(name);
582
+ wanted.push(name);
583
+ const manifest = parseObject(await readFile(join(found.directory, 'package.json'), 'utf8'), name);
584
+ for (const section of ['dependencies', 'peerDependencies', 'optionalDependencies'])
585
+ pending.push(...Object.keys(object(manifest[section] ?? {}, section)));
586
+ }
587
+ const destination = join(session.tx.directory, 'packs');
588
+ await mkdir(destination, { recursive: true });
589
+ for (const name of wanted) {
590
+ const found = workspace.packages.get(name);
591
+ packed.set(name, `file:${await this.npm.pack(found.directory, destination)}`);
592
+ }
593
+ return packed;
594
+ }
595
+ /** A registry spec naming a workspace package: that package's folder (D-0035 §3). */
596
+ #workspacePackage(workspace, spec) {
597
+ if (spec.startsWith('file:'))
598
+ return undefined;
599
+ const match = /^(@[^/@]+\/[^@]+|[^@]+)(?:@(.+))?$/.exec(spec);
600
+ const found = match?.[1] ? workspace.packages.get(match[1]) : undefined;
601
+ if (!match?.[1] || !found)
602
+ return undefined;
603
+ const range = match[2];
604
+ if (range !== undefined && range !== 'latest' && !satisfies(found.version, range))
605
+ throw new CliError('WORKSPACE_VERSION_MISMATCH', `${match[1]} is ${found.version} in the workspace`, { details: { package: match[1], workspace: found.version, requested: range } });
606
+ return { name: match[1], spec: `file:${found.directory}` };
607
+ }
608
+ async #installedDependencies(staging) {
609
+ return dependencyMap(parseObject(await readFile(join(staging, 'package.json'), 'utf8'), 'installed package'));
610
+ }
611
+ async #readPackageManifest(staging, name) {
612
+ return readManifest(parseObject(await readFile(await packageFile(staging, name, 'package.json'), 'utf8'), name));
613
+ }
614
+ /** npm must not silently alter the identity or version of already registered plugins. */
615
+ async #verifyManifests(staging, plugins) {
616
+ for (const plugin of plugins) {
617
+ const actual = await this.#readPackageManifest(staging, plugin.packageName);
618
+ if (json(actual) !== json(plugin))
619
+ throw new CliError('MANIFEST_CHANGED', plugin.id);
620
+ }
621
+ }
622
+ async #order(staging, plugins, required) {
623
+ const stagedRequired = await readRequiredPlugins(staging);
624
+ if (json(stagedRequired) !== json(required))
625
+ throw new CliError('MANIFEST_CHANGED', 'Core-required plugins changed during dependency installation');
626
+ return orderPlugins(plugins, await this.coreVersion(staging), stagedRequired);
627
+ }
628
+ #runChecks(base, ordered, config, installations, projectPackages) {
629
+ return checkAll(ordered.map((plugin) => ({
630
+ base,
631
+ plugin,
632
+ options: config.options,
633
+ projectPackages,
634
+ ...(installations[plugin.id] ? { installation: installations[plugin.id] } : {}),
635
+ })), ordered);
636
+ }
637
+ /**
638
+ * Runs every plugin check after a change. Unsatisfied requirements and error diagnostics stop
639
+ * the change, except that plugins may report only missing configuration (CONFIG_REQUIRED).
640
+ */
641
+ async #check(base, ordered, config, installations, projectPackages, target, configRequired, warnings) {
642
+ const checks = await this.#runChecks(base, ordered, config, installations, projectPackages);
643
+ const installed = new Set(ordered.map((plugin) => plugin.id));
644
+ for (const plugin of ordered) {
645
+ const result = checks.get(plugin.id) ?? { requires: [], diagnostics: [] };
646
+ const requires = plugin.id === target.id ? union(target.requires, result.requires) : result.requires;
647
+ const missing = requires.filter((id) => !installed.has(id));
648
+ if (missing.length)
649
+ throw new CliError('MISSING_REQUIRED_PLUGIN', plugin.id, {
650
+ details: { plugin: plugin.id, requires: missing },
651
+ });
652
+ installations[plugin.id] = {
653
+ ...(installations[plugin.id] ?? defaultInstallation(plugin)),
654
+ requires,
655
+ };
656
+ const errors = result.diagnostics.filter((item) => item.severity === 'error');
657
+ for (const item of result.diagnostics)
658
+ if (item.severity === 'warning')
659
+ warnings.push({
660
+ code: item.code,
661
+ plugin: plugin.id,
662
+ ...(item.optionPath ? { path: item.optionPath.join('.') } : {}),
663
+ });
664
+ if (!errors.length)
665
+ continue;
666
+ if (errors.every((item) => item.code === 'CONFIG_REQUIRED')) {
667
+ configRequired.push({
668
+ plugin: plugin.id,
669
+ errors: errors.slice(0, 20).map((item) => ({
670
+ instancePath: '/' + (item.optionPath ?? []).join('/'),
671
+ keyword: 'required',
672
+ })),
673
+ errorCount: errors.length,
674
+ });
675
+ continue;
166
676
  }
167
- if (added && !Object.hasOwn(config.options, added.id))
168
- config.options[added.id] = {};
169
- state.plugins = ordered;
170
- await this.writeConfiguration(tx, state, config);
171
- await tx.replace('package.json', join(staging, 'package.json'));
172
- await tx.replace('package-lock.json', join(staging, 'package-lock.json'));
173
- await tx.replace('node_modules', join(staging, 'node_modules'));
174
- const result = { changed: tx.paths, preserved };
175
- if (!dryRun)
176
- await tx.commit();
177
- return result;
677
+ throw new CliError('PLUGIN_CHECK_FAILED', plugin.id, {
678
+ details: { plugin: plugin.id, diagnostics: errors.slice(0, 20) },
679
+ });
178
680
  }
179
- finally {
180
- await tx.close();
681
+ }
682
+ async #validateOptions(validator, staging, ordered, config, configRequired) {
683
+ for (const plugin of ordered) {
684
+ const checked = await validator.check(staging, plugin, optionsOf(config.options, plugin.id));
685
+ if (!checked.errors.length)
686
+ continue;
687
+ // Only missing required values (this plugin or one installed earlier) do not block the
688
+ // change; they are reported together so the developer can fill them in later.
689
+ if (checked.errors.every((error) => error.keyword === 'required')) {
690
+ configRequired.push({
691
+ plugin: plugin.id,
692
+ errors: checked.errors.slice(0, 20),
693
+ errorCount: checked.errors.length,
694
+ });
695
+ }
696
+ else {
697
+ throw new CliError('INVALID_PLUGIN_OPTIONS', plugin.id, {
698
+ details: {
699
+ plugin: plugin.id,
700
+ errors: checked.errors.slice(0, 20),
701
+ errorCount: checked.errors.length,
702
+ },
703
+ });
704
+ }
181
705
  }
182
706
  }
183
- async addFiles(tx, staging, state, plugin) {
184
- for (const file of plugin.files) {
185
- const source = await securePath(staging, 'node_modules/' + plugin.packageName + '/' + file.source);
186
- const content = await readFile(source);
707
+ #planFiles(plan) {
708
+ return plan.entry ? [...plan.files, plan.entry] : plan.files;
709
+ }
710
+ async #addFiles(tx, staging, state, plugin, files) {
711
+ for (const file of files) {
712
+ const content = await readFile(await packageFile(staging, plugin.packageName, file.source));
187
713
  const digest = hash(content);
188
714
  await tx.capture(file.target);
189
715
  const current = await fingerprint(await securePath(tx.root, file.target));
@@ -201,11 +727,537 @@ export class Project {
201
727
  }
202
728
  }
203
729
  }
204
- async writeConfiguration(tx, state, config) {
205
- const generated = generateRegistration(state.plugins);
730
+ /** Applies a new template set while preserving files the user changed or deleted. */
731
+ async #reconcileFiles(tx, staging, state, plugin, files) {
732
+ const planned = new Map(files.map((file) => [file.target, file.source]));
733
+ const details = [];
734
+ for (const [path, owned] of Object.entries(state.files)) {
735
+ if (!owned.owners.includes(plugin.id))
736
+ continue;
737
+ const source = planned.get(path);
738
+ planned.delete(path);
739
+ await tx.capture(path);
740
+ const current = await fingerprint(await securePath(tx.root, path));
741
+ const update = source === undefined ? undefined : 'node_modules/' + plugin.packageName + '/' + source;
742
+ if (current === null) {
743
+ owned.owners = owned.owners.filter((owner) => owner !== plugin.id);
744
+ if (!owned.owners.length)
745
+ delete state.files[path];
746
+ details.push({ path, reason: 'DELETED' });
747
+ continue;
748
+ }
749
+ if (current !== owned.hash) {
750
+ details.push({ path, reason: 'MODIFIED', ...(update ? { update } : {}) });
751
+ continue;
752
+ }
753
+ if (owned.owners.length > 1) {
754
+ if (source === undefined)
755
+ owned.owners = owned.owners.filter((owner) => owner !== plugin.id);
756
+ else
757
+ details.push({ path, reason: 'SHARED', ...(update ? { update } : {}) });
758
+ continue;
759
+ }
760
+ if (source === undefined) {
761
+ await tx.replace(path);
762
+ delete state.files[path];
763
+ continue;
764
+ }
765
+ const content = await readFile(await installedPath(staging, update));
766
+ const digest = hash(content);
767
+ if (digest !== owned.hash) {
768
+ await tx.write(path, content);
769
+ owned.hash = digest;
770
+ }
771
+ }
772
+ await this.#addFiles(tx, staging, state, plugin, [...planned].map(([target, source]) => ({ source, target })));
773
+ return details;
774
+ }
775
+ async #syncExample(tx, root, id, warnings, update) {
776
+ await this.#syncExamples(tx, root, warnings, [{ id, update }]);
777
+ }
778
+ /** Applies several plugin updates to dokebi.example.yaml and writes it once. */
779
+ async #syncExamples(tx, root, warnings, updates) {
780
+ if (!(await exists(await securePath(root, EXAMPLE)))) {
781
+ warnings.push({ code: 'EXAMPLE_MISSING', path: EXAMPLE });
782
+ return;
783
+ }
784
+ const source = await tx.read(EXAMPLE);
785
+ let next = source;
786
+ try {
787
+ for (const { id, update } of updates)
788
+ next = syncExample(next, id, update);
789
+ }
790
+ catch {
791
+ warnings.push({ code: 'EXAMPLE_INVALID', path: EXAMPLE });
792
+ return;
793
+ }
794
+ if (next !== source)
795
+ await tx.write(EXAMPLE, next);
796
+ }
797
+ async #replacePackages(session, staging, pkg) {
798
+ const { tx, workspace } = session;
799
+ if (!workspace) {
800
+ await tx.replace('package.json', join(staging, 'package.json'));
801
+ await tx.replace('package-lock.json', join(staging, 'package-lock.json'));
802
+ await tx.replace('node_modules', join(staging, 'node_modules'));
803
+ return;
804
+ }
805
+ // Only the member's package.json changes here; the workspace root's npm install brings the
806
+ // lock and node_modules in line after the project files are in (D-0035 §4).
807
+ const staged = parseObject(await readFile(join(staging, 'package.json'), 'utf8'), 'staged');
808
+ const next = structuredClone(pkg);
809
+ for (const section of DEPENDENCY_SECTIONS) {
810
+ if (staged[section] === undefined) {
811
+ delete next[section];
812
+ continue;
813
+ }
814
+ // A workspace package keeps the member's own range while the workspace version meets it;
815
+ // otherwise (new or upgraded) it gets that exact version. npm keeps the lists sorted.
816
+ const before = object(pkg[section] ?? {}, section);
817
+ next[section] = Object.fromEntries(Object.entries(object(staged[section], section))
818
+ .map(([name, value]) => {
819
+ const found = workspace.packages.get(name);
820
+ if (!found || !String(value).startsWith('file:'))
821
+ return [name, value];
822
+ const range = before[name];
823
+ return [
824
+ name,
825
+ typeof range === 'string' && satisfies(found.version, range) ? range : found.version,
826
+ ];
827
+ })
828
+ .sort(([a], [b]) => String(a).localeCompare(String(b))));
829
+ }
830
+ await tx.write('package.json', json(next));
831
+ workspace.install = { core: await this.coreVersion(staging) };
832
+ }
833
+ /** Commits the project files; a workspace member then runs npm install at the root. */
834
+ async #finish(session, result, dryRun = false) {
835
+ const workspace = session.workspace;
836
+ if (workspace?.install)
837
+ result.workspaceInstall = workspace.root;
838
+ if (dryRun)
839
+ return result;
840
+ if (!workspace?.install) {
841
+ await session.tx.commit();
842
+ return result;
843
+ }
844
+ const lockPath = join(workspace.root, 'package-lock.json');
845
+ let failure;
846
+ let installed = false;
847
+ try {
848
+ await session.tx.commit(undefined, async () => {
849
+ try {
850
+ if ((await readOptional(lockPath)) !== workspace.lock)
851
+ throw new CliError('CONCURRENT_CHANGE', 'Workspace package-lock.json changed');
852
+ installed = true;
853
+ await this.npm.installWorkspace(workspace.root);
854
+ // What npm put in the workspace must be what was checked in staging.
855
+ await this.#verifyManifests(session.root, session.state.plugins);
856
+ if ((await this.coreVersion(session.root)) !== workspace.install?.core)
857
+ throw new CliError('MANIFEST_CHANGED', '@dokebi/core');
858
+ }
859
+ catch (error) {
860
+ failure = error;
861
+ throw error;
862
+ }
863
+ });
864
+ }
865
+ catch (error) {
866
+ if (failure === undefined || !installed)
867
+ throw failure ?? error;
868
+ // The project files are back (or RECOVERY_REQUIRED); bring the root back to its lock.
869
+ try {
870
+ if (workspace.lock === null)
871
+ await rm(lockPath, { force: true });
872
+ else
873
+ await writeFile(lockPath, workspace.lock);
874
+ await this.npm.installWorkspace(workspace.root);
875
+ }
876
+ catch (cause) {
877
+ throw new CliError('WORKSPACE_SYNC_FAILED', 'Project files were rolled back; run npm install at the workspace root', {
878
+ cause: new AggregateError([failure, cause]),
879
+ details: { workspaceRoot: workspace.root },
880
+ });
881
+ }
882
+ throw error instanceof CliError && error.code === 'RECOVERY_REQUIRED' ? error : failure;
883
+ }
884
+ return result;
885
+ }
886
+ /**
887
+ * `dokebi app add <name>`: copies the app skeleton of @dokebi/app to src/apps/<name>, maps the
888
+ * given domains to it in dokebi.yaml and registers it in the generated code (D-0030 §7).
889
+ */
890
+ async appAdd(root, name, options = {}) {
891
+ const session = await this.#begin(root);
892
+ const { tx, state } = session;
893
+ try {
894
+ this.#requireAppPlugin(state);
895
+ const added = await this.#addApp(tx, session.root, state, session.configSource, name, options);
896
+ const warnings = [];
897
+ if (options.inertia &&
898
+ !state.plugins.some((plugin) => plugin.id === 'dokebi-template-inertia'))
899
+ warnings.push({ code: 'INERTIA_PLUGIN_MISSING', path: `src/apps/${name}` });
900
+ if (options.ssr && !options.inertia)
901
+ throw new CliError('USAGE', 'Server rendering (--ssr) needs --inertia');
902
+ if (options.inertia)
903
+ await this.#inertiaBuild(tx, session, warnings, options.ssr === true);
904
+ // Domains are not secrets: the shared sample keeps them so `setup` scripts see them too.
905
+ await this.#syncExamples(tx, session.root, warnings, [
906
+ { id: 'dokebi-app', update: { changes: added.changes } },
907
+ ]);
908
+ await this.writeConfiguration(tx, state, parseAndRender(added.configSource));
909
+ const result = {
910
+ changed: tx.paths,
911
+ preserved: [],
912
+ ...(warnings.length ? { warnings } : {}),
913
+ ...(options.inertia ? { nextSteps: ['npm run build'] } : {}),
914
+ };
915
+ return await this.#finish(session, result, options.dryRun);
916
+ }
917
+ finally {
918
+ await tx.close();
919
+ }
920
+ }
921
+ /** `dokebi app link <app> <domain> [--template t]`: maps one more domain to an existing app. */
922
+ async appLink(root, name, domain, options = {}) {
923
+ const session = await this.#begin(root);
924
+ const { tx, state } = session;
925
+ try {
926
+ this.#requireAppPlugin(state);
927
+ if (!(state.apps ?? []).includes(name))
928
+ throw new CliError('UNKNOWN_APP', name);
929
+ const template = options.template ?? 'default';
930
+ if (!APP_NAME.test(template))
931
+ throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
932
+ if (!HOST.test(domain) || domain.length > 253)
933
+ throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
934
+ const current = domainTarget(parseProjectConfig(session.configSource).options, domain);
935
+ if (current && current !== name)
936
+ throw new CliError('DOMAIN_IN_USE', domain, { details: { domain, app: current } });
937
+ const views = `src/apps/${name}/views/${template}`;
938
+ if (template !== 'default' && !(await exists(await securePath(tx.root, views))))
939
+ await tx.ensureDirectory(views);
940
+ const changes = domainChanges([domain], template === 'default' ? { app: name } : { app: name, template });
941
+ const warnings = [];
942
+ await this.#syncExamples(tx, session.root, warnings, [
943
+ { id: 'dokebi-app', update: { changes } },
944
+ ]);
945
+ await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, 'dokebi-app', changes));
946
+ const result = {
947
+ changed: tx.paths,
948
+ preserved: [],
949
+ ...(warnings.length ? { warnings } : {}),
950
+ };
951
+ return await this.#finish(session, result, options.dryRun);
952
+ }
953
+ finally {
954
+ await tx.close();
955
+ }
956
+ }
957
+ /** `dokebi app unlink <domain>`: removes one domain mapping from dokebi.yaml and the sample. */
958
+ async appUnlink(root, domain, dryRun = false) {
959
+ const session = await this.#begin(root);
960
+ const { tx, state } = session;
961
+ try {
962
+ this.#requireAppPlugin(state);
963
+ if (!domainTarget(parseProjectConfig(session.configSource).options, domain))
964
+ throw new CliError('UNKNOWN_DOMAIN', domain);
965
+ const changes = [{ op: 'remove', path: ['domains', domain] }];
966
+ const warnings = [];
967
+ await this.#syncExamples(tx, session.root, warnings, [
968
+ { id: 'dokebi-app', update: { changes } },
969
+ ]);
970
+ await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, 'dokebi-app', changes));
971
+ const result = {
972
+ changed: tx.paths,
973
+ preserved: [],
974
+ ...(warnings.length ? { warnings } : {}),
975
+ };
976
+ return await this.#finish(session, result, dryRun);
977
+ }
978
+ finally {
979
+ await tx.close();
980
+ }
981
+ }
982
+ /** `dokebi app remove <name>`: unregisters the app; its folder is user code and stays. */
983
+ async appRemove(root, name, dryRun = false) {
984
+ const session = await this.#begin(root);
985
+ const { tx, state } = session;
986
+ try {
987
+ this.#requireAppPlugin(state);
988
+ if (!(state.apps ?? []).includes(name))
989
+ throw new CliError('UNKNOWN_APP', name);
990
+ const domains = appDomains(parseProjectConfig(session.configSource).options, name);
991
+ if (domains.length)
992
+ throw new CliError('APP_IN_USE', name, { details: { app: name, domains } });
993
+ state.apps = (state.apps ?? []).filter((app) => app !== name);
994
+ if (!state.apps.length)
995
+ delete state.apps;
996
+ await this.writeConfiguration(tx, state, session.configSource);
997
+ const result = { changed: tx.paths, preserved: [`src/apps/${name}`] };
998
+ return await this.#finish(session, result, dryRun);
999
+ }
1000
+ finally {
1001
+ await tx.close();
1002
+ }
1003
+ }
1004
+ /** `dokebi app list`: apps with the domains and templates that show them. */
1005
+ async appList(root) {
1006
+ const state = readState(parseObject(await readFile(await securePath(root, '.dokebi/state.json'), 'utf8'), 'state'));
1007
+ const config = parseProjectConfig(await readFile(await securePath(root, CONFIG_FILE), 'utf8')).options;
1008
+ return {
1009
+ apps: (state.apps ?? []).map((name) => ({ name, domains: appDomains(config, name) })),
1010
+ };
1011
+ }
1012
+ /**
1013
+ * `dokebi module add <name>` (D-0032): copies the module skeleton of @dokebi/module to
1014
+ * src/shared/modules/<name> and registers it in the generated code. `views: false` copies the
1015
+ * skeleton without views (routes answer JSON).
1016
+ */
1017
+ async moduleAdd(root, name, options = {}) {
1018
+ const session = await this.#begin(root);
1019
+ const { tx, state } = session;
1020
+ try {
1021
+ if (!state.plugins.some((plugin) => plugin.id === 'dokebi-module'))
1022
+ throw new CliError('MODULE_PLUGIN_MISSING', 'Run dokebi add @dokebi/module first');
1023
+ if (!APP_NAME.test(name))
1024
+ throw new CliError('INVALID_MODULE', 'Module names match ' + APP_NAME.source);
1025
+ if ((state.modules ?? []).includes(name))
1026
+ throw new CliError('MODULE_EXISTS', name);
1027
+ const folder = `src/shared/modules/${name}`;
1028
+ templateTarget(folder + '/module.ts');
1029
+ if (await exists(await securePath(tx.root, folder)))
1030
+ throw new CliError('FILE_CONFLICT', folder);
1031
+ const skeleton = options.views === false ? 'module-api' : 'module';
1032
+ const source = await packageFile(session.root, '@dokebi/module', `templates/${skeleton}`);
1033
+ if (!(await exists(source)))
1034
+ throw new CliError('INVALID_MANIFEST', 'Upgrade @dokebi/module (no module skeleton)');
1035
+ for (const file of await listFiles(source)) {
1036
+ const target = `${folder}/${file}`;
1037
+ templateTarget(target);
1038
+ const content = await readFile(join(source, file));
1039
+ await tx.write(target, /\.(?:ts|js|ejs|md|json)$/.test(file)
1040
+ ? content.toString('utf8').replaceAll('{{module}}', name)
1041
+ : content);
1042
+ }
1043
+ state.modules = [...(state.modules ?? []), name];
1044
+ await this.writeConfiguration(tx, state, session.configSource);
1045
+ const result = {
1046
+ changed: tx.paths,
1047
+ preserved: [],
1048
+ nextSteps: [
1049
+ `mount it in an app: modules: { '/${name}': <import of src/shared/modules/${name}/module.ts> }`,
1050
+ ],
1051
+ };
1052
+ return await this.#finish(session, result, options.dryRun);
1053
+ }
1054
+ finally {
1055
+ await tx.close();
1056
+ }
1057
+ }
1058
+ /** `dokebi module remove <name>`: unregisters the module; its folder is user code and stays. */
1059
+ async moduleRemove(root, name, dryRun = false) {
1060
+ const session = await this.#begin(root);
1061
+ const { tx, state } = session;
1062
+ try {
1063
+ if (!(state.modules ?? []).includes(name))
1064
+ throw new CliError('UNKNOWN_MODULE', name);
1065
+ state.modules = (state.modules ?? []).filter((module) => module !== name);
1066
+ if (!state.modules.length)
1067
+ delete state.modules;
1068
+ await this.writeConfiguration(tx, state, session.configSource);
1069
+ const result = {
1070
+ changed: tx.paths,
1071
+ preserved: [`src/shared/modules/${name}`],
1072
+ };
1073
+ return await this.#finish(session, result, dryRun);
1074
+ }
1075
+ finally {
1076
+ await tx.close();
1077
+ }
1078
+ }
1079
+ /** `dokebi module list`: the registered modules in registration order. */
1080
+ async moduleList(root) {
1081
+ const state = readState(parseObject(await readFile(await securePath(root, '.dokebi/state.json'), 'utf8'), 'state'));
1082
+ return {
1083
+ modules: (state.modules ?? []).map((name) => ({
1084
+ name,
1085
+ path: `src/shared/modules/${name}`,
1086
+ })),
1087
+ };
1088
+ }
1089
+ /**
1090
+ * What an Inertia app needs to be built (D-0031 supplement): one project-wide vite.config.ts
1091
+ * that finds every src/apps/<app>/views/app.js, a `build` script, the Vite and Vue packages as
1092
+ * dev dependencies, and dist/ in .gitignore. Existing files, scripts and versions are kept.
1093
+ * With server rendering (D-0039) also vite.ssr.config.ts, and `build` runs both builds.
1094
+ */
1095
+ async #inertiaBuild(tx, session, warnings, ssr) {
1096
+ const source = await packageFile(session.root, '@dokebi/app', 'templates/inertia-build');
1097
+ const build = parseObject(await readFile(join(source, 'build.json'), 'utf8'), 'build.json');
1098
+ const wanted = object(build.devDependencies ?? {}, 'devDependencies');
1099
+ const { build: browserScript, buildSsr: ssrScript } = object(build.scripts ?? {}, 'scripts');
1100
+ const pkg = structuredClone(session.pkg);
1101
+ const scripts = object(pkg.scripts ?? {}, 'scripts');
1102
+ if (typeof browserScript !== 'string' || typeof ssrScript !== 'string')
1103
+ throw new CliError('INVALID_MANIFEST', 'build.json scripts');
1104
+ const script = ssr ? ssrScript : browserScript;
1105
+ // The browser-only script of an earlier `app add --inertia` grows into the SSR one; a script
1106
+ // the developer wrote is kept, and the warning says to add the server build by hand.
1107
+ if (scripts.build === undefined || (ssr && scripts.build === browserScript))
1108
+ scripts.build = script;
1109
+ else if (scripts.build !== script && !(!ssr && scripts.build === ssrScript))
1110
+ warnings.push({ code: 'BUILD_SCRIPT_KEPT', path: 'package.json' });
1111
+ pkg.scripts = scripts;
1112
+ const devDependencies = object(pkg.devDependencies ?? {}, 'devDependencies');
1113
+ const dependencies = dependencyMap(pkg);
1114
+ let install = false;
1115
+ for (const [name, version] of Object.entries(wanted)) {
1116
+ if (!PACKAGE_NAME.test(name) ||
1117
+ typeof version !== 'string' ||
1118
+ !/^\d+\.\d+\.\d+$/.test(version))
1119
+ throw new CliError('INVALID_MANIFEST', 'build.json devDependencies');
1120
+ if (dependencies[name] !== undefined || devDependencies[name] !== undefined)
1121
+ continue;
1122
+ devDependencies[name] = version;
1123
+ install = true;
1124
+ }
1125
+ pkg.devDependencies = Object.fromEntries(Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b)));
1126
+ if (install)
1127
+ await this.#replacePackages(session, await this.#stage(session, pkg, dependencies, undefined), pkg);
1128
+ else if (json(pkg) !== json(session.pkg))
1129
+ await tx.write('package.json', json(pkg));
1130
+ const configs = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'];
1131
+ let configured = false;
1132
+ for (const file of configs)
1133
+ configured ||= await exists(await securePath(session.root, file));
1134
+ if (!configured)
1135
+ await tx.write('vite.config.ts', await readFile(join(source, 'vite.config.ts'), 'utf8'));
1136
+ if (ssr) {
1137
+ let ssrConfigured = false;
1138
+ for (const file of configs.map((name) => name.replace('vite.config', 'vite.ssr.config')))
1139
+ ssrConfigured ||= await exists(await securePath(session.root, file));
1140
+ if (!ssrConfigured)
1141
+ await tx.write('vite.ssr.config.ts', await readFile(join(source, 'vite.ssr.config.ts'), 'utf8'));
1142
+ }
1143
+ const ignorePath = await securePath(session.root, '.gitignore');
1144
+ const ignore = (await exists(ignorePath)) ? await tx.read('.gitignore') : '';
1145
+ if (!ignore.split(/\r?\n/).some((line) => ['dist', 'dist/', '/dist', '/dist/'].includes(line)))
1146
+ await tx.write('.gitignore', `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}dist/\n`);
1147
+ }
1148
+ #requireAppPlugin(state) {
1149
+ if (!state.plugins.some((plugin) => plugin.id === 'dokebi-app'))
1150
+ throw new CliError('APP_PLUGIN_MISSING', 'Run dokebi add @dokebi/app first');
1151
+ }
1152
+ /** Shared by `app add` and the first app created with `dokebi add @dokebi/app`. */
1153
+ async #addApp(tx, packagesRoot, state, configSource, name, options) {
1154
+ if (!APP_NAME.test(name))
1155
+ throw new CliError('INVALID_APP', 'App names match ' + APP_NAME.source);
1156
+ const template = options.template ?? 'default';
1157
+ if (!APP_NAME.test(template))
1158
+ throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
1159
+ if ((state.apps ?? []).includes(name))
1160
+ throw new CliError('APP_EXISTS', name);
1161
+ const folder = `src/apps/${name}`;
1162
+ templateTarget(folder + '/app.ts');
1163
+ if (await exists(await securePath(tx.root, folder)))
1164
+ throw new CliError('FILE_CONFLICT', folder);
1165
+ const domains = [...new Set(options.domains ?? [])];
1166
+ const config = parseProjectConfig(configSource).options;
1167
+ for (const domain of domains) {
1168
+ if (!HOST.test(domain) || domain.length > 253)
1169
+ throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
1170
+ const current = domainTarget(config, domain);
1171
+ if (current && current !== name)
1172
+ throw new CliError('DOMAIN_IN_USE', domain, { details: { domain, app: current } });
1173
+ }
1174
+ // Directories first: commit creates them in order, before files create parents themselves.
1175
+ if (template !== 'default')
1176
+ await tx.ensureDirectory(`${folder}/views/${template}`);
1177
+ // EJS or Inertia skeleton; `{{app}}` in text files becomes the app name (paths to its views).
1178
+ // Server rendering lays app-inertia-ssr (views/ssr.js, an app.ts naming the bundle) on top.
1179
+ const skeletons = options.inertia
1180
+ ? ['app-inertia', ...(options.ssr ? ['app-inertia-ssr'] : [])]
1181
+ : ['app'];
1182
+ const files = new Map();
1183
+ for (const skeleton of skeletons) {
1184
+ const source = await packageFile(packagesRoot, '@dokebi/app', `templates/${skeleton}`);
1185
+ for (const file of await listFiles(source))
1186
+ files.set(file, join(source, file));
1187
+ }
1188
+ for (const [file, path] of files) {
1189
+ const target = `${folder}/${file}`;
1190
+ templateTarget(target);
1191
+ const content = await readFile(path);
1192
+ await tx.write(target, /\.(?:ts|js|vue|ejs|md|json)$/.test(file)
1193
+ ? content.toString('utf8').replaceAll('{{app}}', name)
1194
+ : content);
1195
+ }
1196
+ const changes = domainChanges(domains, template === 'default' ? { app: name } : { app: name, template });
1197
+ state.apps = [...(state.apps ?? []), name];
1198
+ return { configSource: applyConfigChanges(configSource, 'dokebi-app', changes), changes };
1199
+ }
1200
+ async writeConfiguration(tx, state, configSource) {
1201
+ const generated = generateRegistration(state.plugins, state.installations, state.apps ?? [], state.modules ?? []);
206
1202
  state.generatedHash = hash(generated);
207
1203
  await tx.write(GENERATED, generated);
208
- await tx.write('dokebi.json', json(config));
1204
+ await tx.write(CONFIG_FILE, configSource);
209
1205
  await tx.write('.dokebi/state.json', json(state));
210
1206
  }
211
1207
  }
1208
+ const HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
1209
+ function domainsOf(options) {
1210
+ const section = options['dokebi-app'];
1211
+ const domains = section && typeof section === 'object'
1212
+ ? section.domains
1213
+ : undefined;
1214
+ return domains && typeof domains === 'object' ? domains : {};
1215
+ }
1216
+ function domainTarget(options, domain) {
1217
+ const target = domainsOf(options)[domain];
1218
+ const app = target && typeof target === 'object' ? target.app : undefined;
1219
+ return typeof app === 'string' ? app : undefined;
1220
+ }
1221
+ function appDomains(options, name) {
1222
+ return Object.entries(domainsOf(options))
1223
+ .filter(([, target]) => target?.app === name)
1224
+ .map(([domain, target]) => ({
1225
+ domain,
1226
+ template: String(target.template ?? 'default'),
1227
+ }));
1228
+ }
1229
+ /** Relative paths of the regular files below `directory`, in a stable order. */
1230
+ async function listFiles(directory, prefix = '') {
1231
+ const result = [];
1232
+ for (const entry of (await readdir(join(directory, prefix), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
1233
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
1234
+ if (entry.isDirectory())
1235
+ result.push(...(await listFiles(directory, path)));
1236
+ else if (entry.isFile())
1237
+ result.push(path);
1238
+ else
1239
+ throw new CliError('INVALID_MANIFEST', 'App templates must be regular files');
1240
+ }
1241
+ return result;
1242
+ }
1243
+ function parseAndRender(source) {
1244
+ parseProjectConfig(source);
1245
+ return source;
1246
+ }
1247
+ /** Replaces each domain mapping (remove, then set) so a re-link changes app or template. */
1248
+ function domainChanges(domains, target) {
1249
+ return domains.flatMap((domain) => [
1250
+ { op: 'remove', path: ['domains', domain] },
1251
+ { op: 'default', path: ['domains', domain], value: target },
1252
+ ]);
1253
+ }
1254
+ async function readOptional(path) {
1255
+ try {
1256
+ return await readFile(path, 'utf8');
1257
+ }
1258
+ catch (error) {
1259
+ if (error.code === 'ENOENT')
1260
+ return null;
1261
+ throw error;
1262
+ }
1263
+ }