@dokebi/cli 0.2.5 → 0.4.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 (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2199 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +5 -4
  31. package/dist/config-edit.js +9 -8
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +141 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +835 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +793 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
package/dist/project.js CHANGED
@@ -1,27 +1,117 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
2
3
  import { basename, dirname, join, resolve } from 'node:path';
4
+ import { parseEnv } from 'node:util';
3
5
  import lt from 'semver/functions/lt.js';
4
6
  import satisfies from 'semver/functions/satisfies.js';
5
7
  import validRange from 'semver/ranges/valid.js';
8
+ import { parse } from 'yaml';
9
+ import { HOST_CAPABILITY, loadCatalog, providersOf } from './catalog.js';
6
10
  import { applyConfigChanges, applySetOptions, markRemoved, removeAppSettings, setWarnings, } from './config-edit.js';
7
11
  import { CONFIG_BACKUP, CONFIG_EXAMPLE, CONFIG_FILE, CONFIG_TEMPLATE, ignoreConfig, LEGACY_CONFIG_FILE, migrateConfig, parseProjectConfig, } from './configuration.js';
8
12
  import { scaffoldConfig } from './configuration-scaffold.js';
13
+ import { COMPOSE_FILE, composeProject, composeText, connectionUrl, DB_SERVICE, ENV_FILE, envValue, FALLBACK_PROJECT_NAME, freePortAfter, generatePassword, PASSWORD_VARIABLE, portInUse, portQuestion, redactUrl, URL_PLACEHOLDER_PREFIX, withEnv, } from './dev-infra.js';
14
+ import { debug, describeError, isMissing } from './diagnostics.js';
15
+ import { checkEnvText } from './env-file.js';
9
16
  import { CliError } from './errors.js';
10
17
  import { markExampleRemoved, syncExample } from './example-config.js';
11
18
  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';
19
+ import { generateRegistration, LEGACY_MAIN_SOURCE, MAIN_SOURCE, MAIN_SOURCE_V1, PROJECT_DEV_DEPENDENCIES, TSCONFIG_SOURCE, TYPECHECK_SCRIPT, } from './generator.js';
20
+ import { findHost, HOST_ROLES, orderPlugins, PACKAGE_NAME, readManifest, } from './manifest.js';
14
21
  import { mergeInstallDefaults } from './merge-install-defaults.js';
15
- import { dependencyMap, NpmClient, packageSpec, validateProjectPackage } from './npm-client.js';
22
+ import { dependencyMap, NpmClient, packageSpec, stageNpmConfig, validateProjectPackage, } from './npm-client.js';
23
+ import { CLI_VERSION } from './output.js';
16
24
  import { checkAll, optionsOf, planLifecycle, } from './plugin-lifecycle.js';
17
25
  import { PluginOptionsValidator, validatePluginOptions } from './plugin-options.js';
18
- import { PROJECT_GUIDE } from './project-guide.js';
26
+ import { AGENTS_FILE, CLAUDE_FILE, CLAUDE_GUIDE, guideEntries, installedBlock, projectGuide, replaceInstalledBlock, } from './project-guide.js';
19
27
  import { APP_NAME, defaultInstallation, emptyState, readState, } from './project-state.js';
28
+ import { resolveVersion } from './registry.js';
20
29
  import { readRequiredPlugins } from './required-plugins.js';
21
30
  import { Transaction } from './transaction.js';
22
31
  import { installedPath, packageFile, workspacePackages, workspaceRootOf, } from './workspace.js';
23
32
  const GENERATED = 'src/dokebi.generated.ts';
24
33
  const EXAMPLE = 'dokebi.example.yaml';
34
+ /**
35
+ * Follow-ups after init (D-0065): read the guide, pick a recipe and ask its questions, find
36
+ * plugins, read the core contract, and verify before saying done.
37
+ */
38
+ export function initNextSteps(target) {
39
+ return [
40
+ {
41
+ command: `cd ${JSON.stringify(target)} && npx dokebi guide --json`,
42
+ why: 'read the AI flow first (in the project root): ask the recipe questions, install, verify',
43
+ },
44
+ {
45
+ command: 'npx dokebi recipe list --json',
46
+ why: 'the main path: pick a recipe (blog, api) for the request',
47
+ },
48
+ {
49
+ command: 'npx dokebi recipe <name> --json',
50
+ why: 'returns the questions to ask the user (installs nothing); answer with flags, --dry-run first, then apply',
51
+ },
52
+ {
53
+ command: 'npx dokebi plugins search <words> --json',
54
+ why: 'outside a recipe: find a plugin; `absent` entries are features Dokebi does not have (do not invent them)',
55
+ },
56
+ {
57
+ command: 'npx dokebi verify --json',
58
+ why: 'doctor, typecheck, build, start, HTTP checks and stop; say done only when ok is true',
59
+ },
60
+ ];
61
+ }
62
+ /**
63
+ * Entries `dokebi init` leaves in an existing folder (D-0065 §11, AI-M02: agents are often handed
64
+ * an empty work folder, sometimes with npm or editor files in it). Nothing here is written by init
65
+ * or is part of a Node project; `.npmrc` is also used for init's own npm install.
66
+ */
67
+ export const INIT_HARMLESS = [
68
+ '.git',
69
+ '.npmrc',
70
+ '.npm',
71
+ '.npm-cache',
72
+ '.DS_Store',
73
+ 'Thumbs.db',
74
+ '.idea',
75
+ '.vscode',
76
+ ];
77
+ function initRefused(target, entries, why) {
78
+ return new CliError('PROJECT_EXISTS', `${target} is not empty: ${entries.slice(0, 10).join(', ')}${entries.length > 10 ? ', …' : ''} ${why}`, {
79
+ phase: 'arguments',
80
+ details: {
81
+ target,
82
+ entries: entries.slice(0, 50),
83
+ allowed: INIT_HARMLESS,
84
+ fix: `init a new folder inside it (npx -y @dokebi/cli init <name>, then cd <name>), or empty it; only ${INIT_HARMLESS.join(', ')} may already be there`,
85
+ },
86
+ });
87
+ }
88
+ /**
89
+ * Whether `dokebi init` may use `target`: absent (created), or a folder that is empty or holds
90
+ * only INIT_HARMLESS entries (`kept`). Anything else (a file, other entries) is PROJECT_EXISTS.
91
+ */
92
+ export async function initFolder(target,
93
+ /** init's own staging folder inside `target` (checked again before the move). */
94
+ staging) {
95
+ let names;
96
+ try {
97
+ names = (await readdir(target)).filter((name) => name !== staging);
98
+ }
99
+ catch (error) {
100
+ const code = error.code;
101
+ // A file (or a path through one) is not a folder to create the project in.
102
+ if (code === 'ENOTDIR')
103
+ throw initRefused(target, [basename(target)], 'is a file');
104
+ if (code === 'ENOENT')
105
+ return { exists: false, kept: [] };
106
+ throw error;
107
+ }
108
+ const blocking = names.filter((name) => !INIT_HARMLESS.includes(name)).sort();
109
+ if (blocking.length)
110
+ throw initRefused(target, blocking, blocking.includes('package.json') || blocking.includes('.dokebi')
111
+ ? '(already a project: run npx dokebi … inside it instead of init)'
112
+ : '(dokebi init writes into an empty folder only)');
113
+ return { exists: true, kept: names.sort() };
114
+ }
25
115
  const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
26
116
  function union(...lists) {
27
117
  return [...new Set(lists.flat())];
@@ -31,36 +121,61 @@ export class Project {
31
121
  constructor(npm = new NpmClient()) {
32
122
  this.npm = npm;
33
123
  }
34
- async init(target, core = '@dokebi/core@^0.2.2') {
124
+ async init(target, core = '@dokebi/core@^0.4.0', dryRun = false) {
35
125
  target = resolve(target);
36
- if (await exists(target))
37
- throw new CliError('PROJECT_EXISTS', target);
38
- const parent = await realpath(dirname(target));
39
- const staging = await mkdtemp(join(parent, '.dokebi-init-'));
126
+ // D-0065 §11: an existing folder is accepted when it is empty or holds only entries that
127
+ // are not part of a Node project (INIT_HARMLESS); anything else is PROJECT_EXISTS.
128
+ const folder = await initFolder(target);
129
+ if (dryRun)
130
+ return {
131
+ changed: [target],
132
+ preserved: folder.kept.map((name) => join(target, name)),
133
+ nextSteps: initNextSteps(target),
134
+ };
135
+ // A new folder is staged next to it and renamed into place (atomic). An existing folder is
136
+ // staged inside itself (an agent sandbox may allow writes only there), then moved in.
137
+ const staging = folder.exists
138
+ ? await mkdtemp(join(await realpath(target), '.dokebi-init-'))
139
+ : await mkdtemp(join(await realpath(dirname(target)), '.dokebi-init-'));
40
140
  try {
141
+ // A project .npmrc already in the folder (registry, cache) applies to this install too.
142
+ if (folder.kept.includes('.npmrc'))
143
+ await stageNpmConfig(target, staging);
41
144
  await mkdir(join(staging, 'src'));
42
145
  await mkdir(join(staging, '.dokebi'));
43
146
  await writeFile(join(staging, 'package.json'), json({
44
147
  name: basename(target)
45
148
  .toLowerCase()
46
- .replace(/[^a-z0-9-]/g, '-') || 'dokebi-app',
149
+ .replace(/[^a-z0-9-]/g, '-') || FALLBACK_PROJECT_NAME,
47
150
  private: true,
48
151
  type: 'module',
49
- scripts: { start: 'node src/main.ts' },
152
+ scripts: { start: 'node src/main.ts', typecheck: TYPECHECK_SCRIPT },
50
153
  engines: { node: '>=24.0.0' },
154
+ // npm installs these together with core (one npm run); --save-exact keeps them pinned.
155
+ devDependencies: PROJECT_DEV_DEPENDENCIES,
51
156
  }));
157
+ debug('Initializing project', { target, core, devDependencies: PROJECT_DEV_DEPENDENCIES });
52
158
  await this.npm.install(staging, packageSpec(core, process.cwd()));
53
- await this.coreVersion(staging);
54
- await readRequiredPlugins(staging);
55
159
  const generated = generateRegistration([]);
56
160
  await writeFile(join(staging, GENERATED), generated);
57
161
  await writeFile(join(staging, 'src/main.ts'), MAIN_SOURCE);
58
- await writeFile(join(staging, 'AGENTS.md'), PROJECT_GUIDE);
162
+ const requiredPlugins = await readRequiredPlugins(staging);
163
+ await writeFile(join(staging, AGENTS_FILE), projectGuide(guideEntries(await this.coreVersion(staging), requiredPlugins, [], CLI_VERSION)));
164
+ await writeFile(join(staging, CLAUDE_FILE), CLAUDE_GUIDE);
59
165
  await writeFile(join(staging, 'tsconfig.json'), TSCONFIG_SOURCE);
60
166
  await writeFile(join(staging, CONFIG_FILE), CONFIG_TEMPLATE, { mode: 0o600 });
61
167
  await writeFile(join(staging, EXAMPLE), CONFIG_EXAMPLE);
62
168
  await writeFile(join(staging, '.dokebi/state.json'), json(emptyState(hash(generated))));
63
169
  await writeFile(join(staging, '.gitignore'), ignoreConfig('node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n'));
170
+ if (folder.exists) {
171
+ await this.#moveInto(staging, target, folder.kept);
172
+ debug('Initialized the project in an existing folder', { target, kept: folder.kept });
173
+ return {
174
+ changed: [target],
175
+ preserved: folder.kept.map((name) => join(target, name)),
176
+ nextSteps: initNextSteps(target),
177
+ };
178
+ }
64
179
  // Reserve the destination before moving anything so existing projects are never replaced.
65
180
  await mkdir(target);
66
181
  try {
@@ -75,12 +190,43 @@ export class Project {
75
190
  }
76
191
  throw cause;
77
192
  }
78
- return { changed: [target], preserved: [] };
193
+ return { changed: [target], preserved: [], nextSteps: initNextSteps(target) };
79
194
  }
80
195
  finally {
81
196
  await rm(staging, { recursive: true, force: true });
82
197
  }
83
198
  }
199
+ /**
200
+ * Moves the staged project into an existing folder entry by entry. The folder is checked again
201
+ * first (something may have appeared during npm install); a failure moves back what was moved,
202
+ * so the folder ends as it was.
203
+ */
204
+ async #moveInto(staging, target, kept) {
205
+ await initFolder(target, basename(staging));
206
+ // The staged .npmrc is a copy of the folder's own (kept), so it is not moved.
207
+ const staged = (await readdir(staging)).filter((name) => !kept.includes(name));
208
+ const moved = [];
209
+ try {
210
+ for (const name of staged) {
211
+ await rename(join(staging, name), join(target, name));
212
+ moved.push(name);
213
+ }
214
+ }
215
+ catch (cause) {
216
+ const failed = [cause];
217
+ for (const name of moved.reverse()) {
218
+ try {
219
+ await rename(join(target, name), join(staging, name));
220
+ }
221
+ catch (cleanup) {
222
+ failed.push(cleanup);
223
+ }
224
+ }
225
+ if (failed.length > 1)
226
+ throw new AggregateError(failed, 'Initialization and destination cleanup failed');
227
+ throw cause;
228
+ }
229
+ }
84
230
  async migrate(root, dryRun = false) {
85
231
  root = await realpath(root);
86
232
  await readFile(await securePath(root, '.dokebi/state.json'));
@@ -123,28 +269,79 @@ export class Project {
123
269
  await tx.close();
124
270
  }
125
271
  }
126
- async add(root, spec, dryRun = false, sets = []) {
127
- const operation = packageSpec(spec, root);
272
+ async add(root, spec, dryRun = false, sets = [], options = {}) {
273
+ let operation = packageSpec(spec, root);
128
274
  const session = await this.#begin(root);
129
275
  const { tx, state } = session;
130
276
  try {
277
+ const warnings = setWarnings(sets);
278
+ // No version: the highest one this project's core supports (D-0065).
279
+ const resolution = await this.#resolve(session, operation, warnings);
280
+ if (resolution)
281
+ operation = `${resolution.package}@${resolution.version}`;
131
282
  const dependencies = dependencyMap(session.pkg);
132
- const staging = await this.#stage(session, session.pkg, dependencies, operation);
283
+ let staging;
284
+ try {
285
+ staging = await this.#stage(session, session.pkg, dependencies, operation);
286
+ }
287
+ catch (error) {
288
+ // Offline: say the version came from the snapshot (CATALOG_OFFLINE) next to npm's error.
289
+ if (error instanceof CliError &&
290
+ error.code === 'NPM_FAILED' &&
291
+ warnings.some((warning) => warning.code === 'CATALOG_OFFLINE'))
292
+ throw new CliError(error.code, error.message, {
293
+ cause: error.cause ?? error,
294
+ details: {
295
+ ...error.details,
296
+ warnings,
297
+ ...(resolution ? { resolved: resolution } : {}),
298
+ },
299
+ phase: 'install',
300
+ });
301
+ throw error;
302
+ }
133
303
  const after = await this.#installedDependencies(staging);
134
304
  const name = operation.startsWith('file:')
135
305
  ? Object.keys(after).find((key) => after[key] === operation)
136
306
  : /^(@[^/]+\/[^@]+|[^@]+)/.exec(operation)?.[1];
137
307
  if (!name || !Object.hasOwn(after, name))
138
308
  throw new CliError('INVALID_PACKAGE', 'Cannot identify installed package');
309
+ // Only @dokebi packages are this framework's; anything else needs the user's consent.
310
+ if (!name.startsWith('@dokebi/'))
311
+ warnings.push({ code: 'THIRD_PARTY_PACKAGE', path: name });
139
312
  const added = await this.#readPackageManifest(staging, name);
140
313
  if (session.required.some((plugin) => plugin.id === added.id || plugin.packageName === added.packageName))
141
314
  throw new CliError('REQUIRED_PLUGIN', 'Core already supplies this required plugin');
315
+ if (options.adapter !== undefined && added.id !== 'dokebi-db')
316
+ throw new CliError('USAGE', '--adapter applies to @dokebi/db only');
317
+ const hostsApps = added.hosts?.includes('apps') === true;
318
+ if (options.firstApp === false && !hostsApps)
319
+ throw new CliError('USAGE', '--no-first-app applies only to the plugin that hosts apps (dokebi.hosts: ["apps"])');
142
320
  const existing = state.plugins.find((plugin) => plugin.id === added.id || plugin.packageName === added.packageName);
143
321
  if (existing) {
144
322
  if (existing.id === added.id &&
145
323
  existing.packageName === added.packageName &&
146
- existing.version === added.version)
147
- return { changed: [], preserved: [] };
324
+ existing.version === added.version) {
325
+ if (options.adapter === undefined)
326
+ return { changed: [], preserved: [] };
327
+ // An installed db gets its adapter now (adapter file, connection, driver).
328
+ const adapted = await this.#dbAdapter(session, staging, options.adapter, {
329
+ configSource: session.configSource,
330
+ fresh: false,
331
+ warnings,
332
+ });
333
+ await this.#syncExamples(tx, session.root, warnings, [
334
+ { id: 'dokebi-db', update: { changes: adapted.changes } },
335
+ ]);
336
+ await this.writeConfiguration(tx, state, adapted.configSource);
337
+ await this.#replacePackages(session, staging, session.pkg);
338
+ return await this.#finish(session, {
339
+ changed: tx.paths,
340
+ preserved: [],
341
+ ...(warnings.length ? { warnings } : {}),
342
+ nextSteps: adapted.nextSteps,
343
+ }, dryRun);
344
+ }
148
345
  throw new CliError('UPGRADE_UNSUPPORTED', `Use dokebi upgrade ${added.id}`);
149
346
  }
150
347
  if (Object.hasOwn(dependencies, name))
@@ -152,10 +349,15 @@ export class Project {
152
349
  // Required dependencies that name their npm package are installed in the same staging
153
350
  // and transaction, before the requested plugin (D-0030 §9).
154
351
  const automatic = await this.#automaticDependencies(staging, state, session, added, dependencies);
352
+ // The adapter's driver joins the same staging install before the checks run.
353
+ const adapter = options.adapter === undefined
354
+ ? undefined
355
+ : await this.#adapterVariant(staging, options.adapter);
356
+ if (adapter)
357
+ await this.#installDriver(session, staging, adapter.variant, dependencies);
155
358
  const installed = await this.#installedDependencies(staging);
156
359
  await this.#verifyManifests(staging, state.plugins);
157
360
  const ordered = await this.#order(staging, [...state.plugins, ...automatic, added], session.required);
158
- const warnings = setWarnings(sets);
159
361
  let configSource = sets.length
160
362
  ? applySetOptions(session.configSource, added.id, sets)
161
363
  : session.configSource;
@@ -165,6 +367,8 @@ export class Project {
165
367
  const installations = { ...state.installations };
166
368
  const newIds = new Set([...automatic, added].map((plugin) => plugin.id));
167
369
  const examples = [];
370
+ const nextSteps = [];
371
+ const envSecrets = [];
168
372
  // Plans run in dependency order so each plugin sees the ones it builds on.
169
373
  for (const plugin of ordered.filter((item) => newIds.has(item.id))) {
170
374
  const plan = await planLifecycle({
@@ -185,22 +389,41 @@ export class Project {
185
389
  directories: plan.directories ?? [],
186
390
  ...(plan.entry ? { entry: plan.entry.target } : {}),
187
391
  };
392
+ for (const variable of plan.envSecrets)
393
+ envSecrets.push({ plugin: plugin.id, variable });
188
394
  for (const directory of plan.directories ?? [])
189
395
  await tx.ensureDirectory(directory);
190
- await this.#addFiles(tx, staging, state, plugin, this.#planFiles(plan));
396
+ // `--adapter`: the db entry file is written with the adapter's connection instead of the
397
+ // empty template (the template's hash is recorded, so upgrades keep this file).
398
+ const override = new Map();
399
+ if (adapter && plugin.id === 'dokebi-db' && plan.entry) {
400
+ const adapted = await this.#dbAdapter(session, staging, adapter.name, {
401
+ configSource,
402
+ fresh: true,
403
+ warnings,
404
+ entry: plan.entry.target,
405
+ });
406
+ configSource = adapted.configSource;
407
+ config = parseProjectConfig(configSource);
408
+ if (adapted.entryContent !== undefined)
409
+ override.set(plan.entry.target, adapted.entryContent);
410
+ examples.push({ id: plugin.id, update: { changes: adapted.changes } });
411
+ nextSteps.push(...adapted.nextSteps);
412
+ }
413
+ await this.#addFiles(tx, staging, state, plugin, this.#planFiles(plan), override);
191
414
  examples.push({
192
415
  id: plugin.id,
193
416
  update: { defaults: plan.defaults, schema: initial.schema },
194
417
  });
195
418
  }
196
419
  // 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', {
420
+ if (hostsApps && !(state.apps ?? []).length && options.firstApp !== false) {
421
+ const first = await this.#addApp(tx, staging, added, state, configSource, 'main', {
199
422
  domains: ['localhost'],
200
423
  });
201
424
  configSource = first.configSource;
202
425
  config = parseProjectConfig(configSource);
203
- examples.push({ id: 'dokebi-app', update: { changes: first.changes } });
426
+ examples.push({ id: added.id, update: { changes: first.changes } });
204
427
  }
205
428
  await this.#syncExamples(tx, session.root, warnings, examples);
206
429
  const configRequired = [];
@@ -209,10 +432,25 @@ export class Project {
209
432
  state.plugins = ordered;
210
433
  state.installations = installations;
211
434
  await this.writeConfiguration(tx, state, configSource);
435
+ const generatedSecrets = await this.#generateEnvSecrets(session, envSecrets);
436
+ await this.#syncGuide(session, staging, ordered, warnings);
212
437
  await this.#replacePackages(session, staging, session.pkg);
438
+ // Read the installed guide of every plugin this command added before using it.
439
+ for (const plugin of ordered.filter((item) => newIds.has(item.id)))
440
+ if (plugin.documentation)
441
+ nextSteps.push({
442
+ command: `npx dokebi plugins doc ${plugin.id} --json`,
443
+ why: `read node_modules/${plugin.packageName}/${plugin.documentation} (the installed ${plugin.version} contract) before using ${plugin.id}`,
444
+ });
445
+ if (hostsApps && options.firstApp === false)
446
+ nextSteps.push({
447
+ command: 'npx dokebi app add <name> --domain localhost [--inertia [--ssr]]',
448
+ why: `--no-first-app: no app exists yet; ${added.id} needs one before the project starts`,
449
+ });
213
450
  const result = {
214
451
  changed: tx.paths,
215
452
  preserved: [],
453
+ ...(resolution ? { resolved: resolution } : {}),
216
454
  ...(configRequired.length ? { configRequired } : {}),
217
455
  ...(automatic.length
218
456
  ? {
@@ -224,6 +462,8 @@ export class Project {
224
462
  }
225
463
  : {}),
226
464
  ...(warnings.length ? { warnings } : {}),
465
+ ...(generatedSecrets.length ? { generatedSecrets } : {}),
466
+ nextSteps,
227
467
  };
228
468
  return await this.#finish(session, result, dryRun);
229
469
  }
@@ -231,6 +471,177 @@ export class Project {
231
471
  await tx.close();
232
472
  }
233
473
  }
474
+ /**
475
+ * The version for a registry spec without one (D-0065): highest compatible with the project
476
+ * core, from the registry, else the CLI snapshot. Workspace packages and local archives keep
477
+ * their own resolution (D-0035).
478
+ */
479
+ async #resolve(session, operation, warnings) {
480
+ if (operation.startsWith('file:'))
481
+ return undefined;
482
+ const bare = /^(@[^/@]+\/[^@]+|[^@]+)$/.exec(operation)?.[1];
483
+ if (!bare || session.workspace?.packages.has(bare))
484
+ return undefined;
485
+ const core = await this.coreVersion(session.root);
486
+ // npm's registry for this package as npm resolves it in the project (scoped .npmrc, env).
487
+ const found = await resolveVersion(bare, core, await loadCatalog(), session.root);
488
+ if (found.offline !== undefined) {
489
+ warnings.push({ code: 'CATALOG_OFFLINE', path: bare });
490
+ debug('Registry unreachable while resolving a version', {
491
+ package: bare,
492
+ error: found.offline,
493
+ snapshot: found.resolution?.version ?? null,
494
+ });
495
+ }
496
+ if (found.resolution?.deprecated)
497
+ warnings.push({
498
+ code: 'DEPRECATED_VERSION',
499
+ path: `${found.resolution.package}@${found.resolution.version}`,
500
+ });
501
+ if (found.resolution)
502
+ debug('Resolved a version for add', { ...found.resolution });
503
+ return found.resolution;
504
+ }
505
+ /** The db variant (from the staged @dokebi/db, else the CLI snapshot) for `--adapter`. */
506
+ async #adapterVariant(base, adapter) {
507
+ let variants = [];
508
+ try {
509
+ const pkg = parseObject(await readFile(await packageFile(base, '@dokebi/db', 'package.json'), 'utf8'), '@dokebi/db');
510
+ variants = pkg.dokebiCatalog?.variants ?? [];
511
+ }
512
+ catch (error) {
513
+ if (!(error instanceof CliError) && !isMissing(error))
514
+ throw error;
515
+ debug('Installed @dokebi/db has no readable catalog; using the CLI snapshot', {
516
+ error: describeError(error),
517
+ });
518
+ }
519
+ if (!variants.length)
520
+ variants =
521
+ (await loadCatalog()).packages.find((item) => item.package === '@dokebi/db')?.variants ??
522
+ [];
523
+ const variant = variants.find((item) => item.adapter === adapter);
524
+ if (!variant)
525
+ throw new CliError('UNKNOWN_ADAPTER', `No database adapter "${adapter}"`, {
526
+ details: {
527
+ adapter,
528
+ adapters: variants.map((item) => item.adapter).filter(Boolean),
529
+ },
530
+ phase: 'arguments',
531
+ });
532
+ return { name: adapter, variant };
533
+ }
534
+ /** Adds the variant's driver (and its types) to the staged project and installs them. */
535
+ async #installDriver(session, staging, variant, dependencies) {
536
+ const path = join(staging, 'package.json');
537
+ const staged = parseObject(await readFile(path, 'utf8'), 'staged');
538
+ let changed = false;
539
+ if (variant.driver && !Object.hasOwn(dependencies, variant.driver.package)) {
540
+ staged.dependencies = {
541
+ ...object(staged.dependencies ?? {}, 'dependencies'),
542
+ [variant.driver.package]: variant.driver.version,
543
+ };
544
+ changed = true;
545
+ }
546
+ const dev = object(staged.devDependencies ?? {}, 'devDependencies');
547
+ if (variant.types && !Object.hasOwn(dev, variant.types.package)) {
548
+ staged.devDependencies = { ...dev, [variant.types.package]: variant.types.version };
549
+ changed = true;
550
+ }
551
+ if (!changed)
552
+ return;
553
+ debug('Installing the database driver', {
554
+ driver: variant.driver?.package ?? null,
555
+ types: variant.types?.package ?? null,
556
+ });
557
+ await writeFile(path, json(staged));
558
+ await this.#stageInstall(session, staging, undefined);
559
+ }
560
+ /**
561
+ * `add @dokebi/db --adapter <id>`: the adapter file (src/infra/db/adapters/<id>.ts, user code),
562
+ * the entry file content with a `main` connection, and options.dokebi-db.connections.main in
563
+ * dokebi.yaml and the sample. The address is a placeholder until `dokebi dev-infra` (docker)
564
+ * or the developer (url) writes it; secrets never come from the command line.
565
+ */
566
+ async #dbAdapter(session, staging, adapter, input) {
567
+ const { tx, state } = session;
568
+ const { variant } = await this.#adapterVariant(staging, adapter);
569
+ if (!input.fresh) {
570
+ await this.#installDriver(session, staging, variant, dependencyMap(session.pkg));
571
+ }
572
+ const templates = 'templates/adapters';
573
+ const adapterSource = await packageFile(staging, '@dokebi/db', `${templates}/${adapter}.ts`);
574
+ const connectionsSource = await packageFile(staging, '@dokebi/db', `${templates}/connections.ts`);
575
+ if (!(await exists(adapterSource)) || !(await exists(connectionsSource)))
576
+ throw new CliError('ADAPTER_TEMPLATE_MISSING', 'This @dokebi/db has no adapter templates', {
577
+ details: { adapter },
578
+ });
579
+ const adapterText = await readFile(adapterSource, 'utf8');
580
+ const exportName = /export const (\w+)/.exec(adapterText)?.[1];
581
+ if (!exportName)
582
+ throw new CliError('INVALID_MANIFEST', `${templates}/${adapter}.ts`);
583
+ const adapterTarget = `src/infra/db/adapters/${adapter}.ts`;
584
+ templateTarget(adapterTarget);
585
+ const nextSteps = [];
586
+ const current = await fingerprint(await securePath(session.root, adapterTarget));
587
+ if (current === null) {
588
+ await tx.ensureDirectory('src/infra/db/adapters');
589
+ await tx.write(adapterTarget, adapterText);
590
+ }
591
+ else if (current !== hash(adapterText))
592
+ input.warnings.push({ code: 'ADAPTER_FILE_KEPT', path: adapterTarget });
593
+ const entryContent = (await readFile(connectionsSource, 'utf8'))
594
+ .replaceAll('__ADAPTER_ID__', adapter)
595
+ .replaceAll('__ADAPTER__', exportName);
596
+ const entry = input.entry ?? state.installations['dokebi-db']?.entry ?? 'src/infra/db/connections.ts';
597
+ let written;
598
+ if (input.fresh)
599
+ written = entryContent;
600
+ else {
601
+ // An entry file still equal to the installed template is replaced; an edited one is kept.
602
+ const owned = state.files[entry];
603
+ const now = await fingerprint(await securePath(session.root, entry));
604
+ if (owned && now === owned.hash)
605
+ await tx.write(entry, entryContent);
606
+ else {
607
+ input.warnings.push({ code: 'CONNECTIONS_KEPT', path: entry });
608
+ nextSteps.push({
609
+ command: `edit ${entry}`,
610
+ why: `add: import { ${exportName} } from './adapters/${adapter}.ts'; export const main = defineConnection('main', ${exportName}); and put main in connections`,
611
+ });
612
+ }
613
+ }
614
+ const placeholder = `${URL_PLACEHOLDER_PREFIX}set the ${variant.name} URL here or run npx dokebi dev-infra>`;
615
+ const connection = parseProjectConfig(input.configSource).options['dokebi-db']?.connections?.main;
616
+ const changes = [
617
+ {
618
+ op: 'default',
619
+ path: ['connections', 'main'],
620
+ value: { adapter, options: { url: placeholder } },
621
+ },
622
+ ];
623
+ if (connection && connection.adapter !== adapter)
624
+ input.warnings.push({
625
+ code: 'CONNECTION_KEPT',
626
+ path: 'dokebi.yaml#options.dokebi-db.connections.main',
627
+ });
628
+ const configSource = applyConfigChanges(input.configSource, 'dokebi-db', changes);
629
+ if (!connection)
630
+ nextSteps.push({
631
+ command: variant.devInfra?.docker
632
+ ? 'npx dokebi dev-infra --json'
633
+ : `edit dokebi.yaml: options.dokebi-db.connections.main.options.url`,
634
+ why: variant.devInfra?.docker
635
+ ? `writes compose.yaml, a generated password in .env and the ${variant.name} URL in dokebi.yaml (or write your own URL in dokebi.yaml; never pass it with --set)`
636
+ : 'write the database URL (with its password) in dokebi.yaml; never pass it with --set',
637
+ });
638
+ return {
639
+ configSource,
640
+ changes,
641
+ ...(written !== undefined ? { entryContent: written } : {}),
642
+ nextSteps,
643
+ };
644
+ }
234
645
  /**
235
646
  * Installs, into the staging directory, every missing required dependency (recursively) whose
236
647
  * manifest entry names an npm `package`. Returns their manifests; optional ones are skipped and
@@ -268,11 +679,13 @@ export class Project {
268
679
  const session = await this.#begin(root);
269
680
  const { tx, state } = session;
270
681
  try {
271
- if (id === 'dokebi-app' && (state.apps ?? []).length)
682
+ // The host of apps or modules (D-0070) stays while its units are registered.
683
+ const hosts = state.plugins.find((plugin) => plugin.id === id)?.hosts ?? [];
684
+ if (hosts.includes('apps') && (state.apps ?? []).length)
272
685
  throw new CliError('APPS_PRESENT', 'Remove the apps first (dokebi app remove <name>)', {
273
686
  details: { apps: state.apps },
274
687
  });
275
- if (id === 'dokebi-module' && (state.modules ?? []).length)
688
+ if (hosts.includes('modules') && (state.modules ?? []).length)
276
689
  throw new CliError('MODULES_PRESENT', 'Remove the modules first (dokebi module remove <name>)', { details: { modules: state.modules } });
277
690
  if (session.required.some((plugin) => plugin.id === id || plugin.packageName === id))
278
691
  throw new CliError('REQUIRED_PLUGIN', 'Core-required plugins cannot be removed');
@@ -349,6 +762,7 @@ export class Project {
349
762
  state.plugins = ordered;
350
763
  state.installations = installations;
351
764
  await this.writeConfiguration(tx, state, configSource);
765
+ await this.#syncGuide(session, staging, ordered, warnings);
352
766
  await this.#replacePackages(session, staging, session.pkg);
353
767
  const result = {
354
768
  changed: tx.paths,
@@ -446,6 +860,7 @@ export class Project {
446
860
  state.plugins = ordered;
447
861
  state.installations = installations;
448
862
  await this.writeConfiguration(tx, state, configSource);
863
+ await this.#syncGuide(session, staging, ordered, warnings);
449
864
  await this.#replacePackages(session, staging, session.pkg);
450
865
  const result = {
451
866
  changed: tx.paths,
@@ -463,6 +878,294 @@ export class Project {
463
878
  await tx.close();
464
879
  }
465
880
  }
881
+ /**
882
+ * Rewrites the installed list in AGENTS.md (between the dokebi:installed markers) in the same
883
+ * transaction (D-0065). Missing markers leave the file alone with GUIDE_BLOCK_MISSING.
884
+ */
885
+ async #syncGuide(session, base, plugins, warnings) {
886
+ if (!(await exists(await securePath(session.root, AGENTS_FILE))))
887
+ return;
888
+ const text = await session.tx.read(AGENTS_FILE);
889
+ const devDependencies = object(session.pkg.devDependencies ?? {}, 'devDependencies');
890
+ const cli = devDependencies['@dokebi/cli'];
891
+ const block = installedBlock(guideEntries(await this.coreVersion(base), session.required, plugins, typeof cli === 'string' && /^\d+\.\d+\.\d+/.test(cli) ? cli : CLI_VERSION));
892
+ const next = replaceInstalledBlock(text, block);
893
+ if (next === undefined) {
894
+ warnings.push({ code: 'GUIDE_BLOCK_MISSING', path: AGENTS_FILE });
895
+ return;
896
+ }
897
+ if (next !== text)
898
+ await session.tx.write(AGENTS_FILE, next);
899
+ }
900
+ /**
901
+ * `dokebi config set <plugin>.<path>=<value>…`: non-secret options in dokebi.yaml, checked
902
+ * against that plugin's schema (only missing required values are tolerated and reported).
903
+ */
904
+ async configSet(root, assignments, dryRun = false) {
905
+ const session = await this.#begin(root);
906
+ const { tx, state } = session;
907
+ try {
908
+ let configSource = session.configSource;
909
+ const warnings = [];
910
+ const byPlugin = new Map();
911
+ for (const { plugin, set } of assignments)
912
+ byPlugin.set(plugin, [...(byPlugin.get(plugin) ?? []), set]);
913
+ const configRequired = [];
914
+ const validator = new PluginOptionsValidator({ allErrors: true });
915
+ for (const [id, sets] of byPlugin) {
916
+ const plugin = state.plugins.find((item) => item.id === id);
917
+ if (!plugin)
918
+ throw new CliError('UNKNOWN_PLUGIN', `${id} is not installed`, {
919
+ details: { plugin: id, installed: state.plugins.map((item) => item.id) },
920
+ phase: 'arguments',
921
+ });
922
+ configSource = applySetOptions(configSource, id, sets);
923
+ warnings.push(...setWarnings(sets).map((warning) => ({ ...warning, plugin: id })));
924
+ const checked = await validator.check(session.root, plugin, optionsOf(parseProjectConfig(configSource).options, id));
925
+ if (checked.errors.every((error) => error.keyword === 'required')) {
926
+ if (checked.errors.length)
927
+ configRequired.push({
928
+ plugin: id,
929
+ errors: checked.errors.slice(0, 20),
930
+ errorCount: checked.errors.length,
931
+ });
932
+ }
933
+ else
934
+ throw new CliError('INVALID_PLUGIN_OPTIONS', id, {
935
+ details: {
936
+ plugin: id,
937
+ errors: checked.errors.slice(0, 20),
938
+ errorCount: checked.errors.length,
939
+ },
940
+ });
941
+ }
942
+ if (configSource !== session.configSource)
943
+ await tx.write(CONFIG_FILE, configSource);
944
+ debug('Options set', { plugins: [...byPlugin.keys()], dryRun });
945
+ return await this.#finish(session, {
946
+ changed: tx.paths,
947
+ preserved: [],
948
+ ...(configRequired.length ? { configRequired } : {}),
949
+ ...(warnings.length ? { warnings } : {}),
950
+ }, dryRun);
951
+ }
952
+ finally {
953
+ await tx.close();
954
+ }
955
+ }
956
+ /** Adds `.env` to .gitignore unless a line already ignores it (D-0065, D-0073). */
957
+ async #ignoreEnv(session) {
958
+ const { tx } = session;
959
+ const ignorePath = await securePath(session.root, '.gitignore');
960
+ const ignore = (await exists(ignorePath)) ? await tx.read('.gitignore') : '';
961
+ if (!ignore.split(/\r?\n/).some((line) => [ENV_FILE, `/${ENV_FILE}`].includes(line.trim())))
962
+ await tx.write('.gitignore', `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}${ENV_FILE}\n`);
963
+ }
964
+ /**
965
+ * D-0073: a generated secret (48 random bytes, base64url) in .env for each variable an install
966
+ * plan names (`envSecrets`), unless the CLI's environment or .env already has it. The value is
967
+ * never returned, printed or logged; .env is git-ignored and read by dev, start and verify.
968
+ */
969
+ async #generateEnvSecrets(session, requests) {
970
+ if (!requests.length)
971
+ return [];
972
+ const { tx } = session;
973
+ const envPath = await securePath(session.root, ENV_FILE);
974
+ const original = (await exists(envPath)) ? await tx.read(ENV_FILE) : '';
975
+ // Never append to a file the apps could not read (ENV_FILE_INVALID names the line).
976
+ checkEnvText(original);
977
+ const present = parseEnv(original);
978
+ let text = original;
979
+ const generated = [];
980
+ for (const { plugin, variable } of requests) {
981
+ const kept = process.env[variable] !== undefined
982
+ ? 'environment'
983
+ : Object.hasOwn(present, variable) || generated.some((item) => item.variable === variable)
984
+ ? ENV_FILE
985
+ : undefined;
986
+ if (kept) {
987
+ debug('Secret variable already set; nothing generated', { plugin, variable, in: kept });
988
+ continue;
989
+ }
990
+ text = withEnv(text, variable, randomBytes(48).toString('base64url'), `${plugin} secret, generated by dokebi add; not committed (the environment wins over this file)`);
991
+ generated.push({ variable, file: ENV_FILE, plugin });
992
+ debug('Generated a secret into .env', { plugin, variable });
993
+ }
994
+ if (!generated.length)
995
+ return [];
996
+ await tx.write(ENV_FILE, text);
997
+ await this.#ignoreEnv(session);
998
+ return generated;
999
+ }
1000
+ /**
1001
+ * `dokebi dev-infra` (D-0065): compose.yaml for the database of connection `main`, a generated
1002
+ * password in .env (git-ignored) and the matching URL in dokebi.yaml. An existing compose.yaml
1003
+ * with a `db` service and an existing password are kept; nothing is started here.
1004
+ */
1005
+ async devInfra(root, options = {}) {
1006
+ const session = await this.#begin(root);
1007
+ const { tx, state } = session;
1008
+ try {
1009
+ if (!state.plugins.some((plugin) => plugin.id === 'dokebi-db'))
1010
+ throw new CliError('DB_PLUGIN_MISSING', 'Run dokebi add @dokebi/db --adapter <id> first', {
1011
+ phase: 'arguments',
1012
+ });
1013
+ const connections = parseProjectConfig(session.configSource).options['dokebi-db']?.connections;
1014
+ const main = connections?.main;
1015
+ if (typeof main?.adapter !== 'string')
1016
+ throw new CliError('CONNECTION_MISSING', 'dokebi.yaml has no options.dokebi-db.connections.main; run dokebi add @dokebi/db --adapter <id>', { phase: 'arguments' });
1017
+ const { variant } = await this.#adapterVariant(session.root, main.adapter);
1018
+ const docker = variant.devInfra?.docker;
1019
+ if (!docker)
1020
+ throw new CliError('DEV_INFRA_UNSUPPORTED', `No Docker setup for ${variant.name}`, {
1021
+ details: { adapter: main.adapter },
1022
+ });
1023
+ const warnings = [];
1024
+ let port = options.dbPort ?? docker.port;
1025
+ const composePath = await securePath(session.root, COMPOSE_FILE);
1026
+ if (await exists(composePath)) {
1027
+ const existing = parse(await tx.read(COMPOSE_FILE));
1028
+ const service = existing?.services?.[DB_SERVICE];
1029
+ if (!service)
1030
+ throw new CliError('COMPOSE_CONFLICT', `${COMPOSE_FILE} exists without a "${DB_SERVICE}" service; add the service yourself or move the file`, { details: { file: COMPOSE_FILE } });
1031
+ // The kept file decides the published port.
1032
+ const published = String(service.ports?.[0] ?? '').match(/(?:^|:)(\d+):\d+$/)?.[1];
1033
+ if (published)
1034
+ port = Number(published);
1035
+ warnings.push({ code: 'COMPOSE_KEPT', path: COMPOSE_FILE });
1036
+ }
1037
+ else {
1038
+ // The container would not start on a taken port: refuse, and name a free one.
1039
+ if (await portInUse(port)) {
1040
+ const alternative = await freePortAfter(port);
1041
+ throw new CliError('PORT_IN_USE', `Port ${port} on 127.0.0.1 is in use`, {
1042
+ phase: 'arguments',
1043
+ details: {
1044
+ port,
1045
+ ...(alternative ? { alternative } : {}),
1046
+ hint: `run again with --db-port ${alternative ?? '<free port>'}; compose.yaml and dokebi.yaml get the same port`,
1047
+ // AI-M03: the port is the user's call (dev-infra has no --defaults).
1048
+ ...portQuestion({
1049
+ what: 'Database',
1050
+ port,
1051
+ alternative,
1052
+ flag: '--db-port',
1053
+ delegated: false,
1054
+ }),
1055
+ },
1056
+ });
1057
+ }
1058
+ await tx.write(COMPOSE_FILE, composeText(composeProject(String(session.pkg.name ?? '')), variant.name, docker, port));
1059
+ }
1060
+ const envPath = await securePath(session.root, ENV_FILE);
1061
+ const envText = (await exists(envPath)) ? await tx.read(ENV_FILE) : '';
1062
+ let password = envValue(envText, PASSWORD_VARIABLE);
1063
+ if (!password) {
1064
+ password = generatePassword();
1065
+ await tx.write(ENV_FILE, withEnv(envText, PASSWORD_VARIABLE, password, 'development database password (dokebi dev-infra); not committed'));
1066
+ }
1067
+ const url = connectionUrl(docker, password, port);
1068
+ const current = main.options?.url;
1069
+ if (current !== url) {
1070
+ if (typeof current === 'string' && !current.startsWith(URL_PLACEHOLDER_PREFIX))
1071
+ warnings.push({
1072
+ code: 'URL_REPLACED',
1073
+ path: 'dokebi.yaml#options.dokebi-db.connections.main',
1074
+ });
1075
+ await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, 'dokebi-db', [
1076
+ { op: 'remove', path: ['connections', 'main', 'options', 'url'] },
1077
+ { op: 'default', path: ['connections', 'main', 'options', 'url'], value: url },
1078
+ ]));
1079
+ }
1080
+ await this.#ignoreEnv(session);
1081
+ debug('Development database prepared', { variant: variant.name, port, image: docker.image });
1082
+ return {
1083
+ ...(await this.#finish(session, {
1084
+ changed: tx.paths,
1085
+ preserved: warnings.map((warning) => warning.path ?? '').filter(Boolean),
1086
+ ...(warnings.length ? { warnings } : {}),
1087
+ nextSteps: [
1088
+ {
1089
+ command: 'docker compose up -d --wait',
1090
+ why: `start the ${variant.name} container (${docker.image}) on 127.0.0.1:${port}`,
1091
+ },
1092
+ { command: 'npx dokebi verify --json', why: 'start the app against it and check it' },
1093
+ {
1094
+ command: 'docker compose down',
1095
+ why: 'stop it when you are done (add -v to delete the data volume)',
1096
+ },
1097
+ ],
1098
+ }, options.dryRun)),
1099
+ service: {
1100
+ name: DB_SERVICE,
1101
+ variant: variant.name,
1102
+ image: docker.image,
1103
+ port,
1104
+ url: redactUrl(url),
1105
+ compose: COMPOSE_FILE,
1106
+ passwordIn: ENV_FILE,
1107
+ },
1108
+ };
1109
+ }
1110
+ finally {
1111
+ await tx.close();
1112
+ }
1113
+ }
1114
+ /**
1115
+ * `dokebi config secret <plugin>.<path>`: a generated random secret (32 bytes, base64url) in
1116
+ * dokebi.yaml, as a one-item list when the schema wants a list. The value is never returned or
1117
+ * logged; an existing value is kept (SECRET_KEPT). Secrets never pass the command line.
1118
+ */
1119
+ async configSecret(root, id, path, dryRun = false) {
1120
+ const session = await this.#begin(root);
1121
+ const { tx, state } = session;
1122
+ try {
1123
+ const plugin = state.plugins.find((item) => item.id === id);
1124
+ if (!plugin)
1125
+ throw new CliError('UNKNOWN_PLUGIN', `${id} is not installed`, {
1126
+ details: { plugin: id, installed: state.plugins.map((item) => item.id) },
1127
+ phase: 'arguments',
1128
+ });
1129
+ const where = `${id}.${path.join('.')}`;
1130
+ let current = parseProjectConfig(session.configSource).options[id];
1131
+ for (const key of path)
1132
+ current =
1133
+ current && typeof current === 'object'
1134
+ ? current[key]
1135
+ : undefined;
1136
+ if (current !== undefined)
1137
+ return {
1138
+ ...(await this.#finish(session, { changed: [], preserved: [], warnings: [{ code: 'SECRET_KEPT', path: where }] }, dryRun)),
1139
+ secret: { path: where, list: Array.isArray(current), generated: false },
1140
+ };
1141
+ const secret = randomBytes(32).toString('base64url');
1142
+ const validator = new PluginOptionsValidator({ allErrors: true });
1143
+ let chosen;
1144
+ let list = false;
1145
+ for (const value of [secret, [secret]]) {
1146
+ const source = applySetOptions(session.configSource, id, [{ path: [...path], value }]);
1147
+ const checked = await validator.check(session.root, plugin, optionsOf(parseProjectConfig(source).options, id));
1148
+ if (checked.errors.every((error) => error.keyword === 'required')) {
1149
+ chosen = source;
1150
+ list = Array.isArray(value);
1151
+ break;
1152
+ }
1153
+ }
1154
+ if (chosen === undefined)
1155
+ throw new CliError('INVALID_PLUGIN_OPTIONS', `${where} does not take a secret string`, {
1156
+ details: { plugin: id, path: where },
1157
+ });
1158
+ await tx.write(CONFIG_FILE, chosen);
1159
+ debug('Generated a secret option', { path: where, list });
1160
+ return {
1161
+ ...(await this.#finish(session, { changed: tx.paths, preserved: [] }, dryRun)),
1162
+ secret: { path: where, list, generated: true },
1163
+ };
1164
+ }
1165
+ finally {
1166
+ await tx.close();
1167
+ }
1168
+ }
466
1169
  async coreVersion(root) {
467
1170
  const pkg = parseObject(await readFile(await packageFile(root, '@dokebi/core', 'package.json'), 'utf8'), 'core');
468
1171
  if (pkg.name !== '@dokebi/core' ||
@@ -511,6 +1214,8 @@ export class Project {
511
1214
  const { tx, workspace } = session;
512
1215
  const staging = join(tx.directory, 'npm');
513
1216
  await mkdir(staging);
1217
+ // npm in staging reads the project's .npmrc too (the registry resolution did, D-0065).
1218
+ await stageNpmConfig(workspace?.root ?? session.root, staging);
514
1219
  if (!workspace) {
515
1220
  await writeFile(join(staging, 'package.json'), json({ ...pkg, dependencies }));
516
1221
  const lock = await securePath(tx.root, 'package-lock.json');
@@ -627,7 +1332,11 @@ export class Project {
627
1332
  const stagedRequired = await readRequiredPlugins(staging);
628
1333
  if (json(stagedRequired) !== json(required))
629
1334
  throw new CliError('MANIFEST_CHANGED', 'Core-required plugins changed during dependency installation');
630
- return orderPlugins(plugins, await this.coreVersion(staging), stagedRequired);
1335
+ const ordered = orderPlugins(plugins, await this.coreVersion(staging), stagedRequired);
1336
+ // One host per role (D-0070): a second plugin that hosts apps or modules is HOST_CONFLICT.
1337
+ for (const role of HOST_ROLES)
1338
+ findHost(ordered, role);
1339
+ return ordered;
631
1340
  }
632
1341
  #runChecks(base, ordered, config, installations, projectPackages) {
633
1342
  return checkAll(ordered.map((plugin) => ({
@@ -709,37 +1418,46 @@ export class Project {
709
1418
  }
710
1419
  }
711
1420
  #planFiles(plan) {
712
- return plan.entry ? [...plan.files, plan.entry] : plan.files;
1421
+ // The entry is the developer's code (D-0064): always user-owned.
1422
+ return plan.entry ? [...plan.files, { ...plan.entry, owner: 'user' }] : plan.files;
713
1423
  }
714
- async #addFiles(tx, staging, state, plugin, files) {
1424
+ async #addFiles(tx, staging, state, plugin, files,
1425
+ /** Content to write instead of a template (its template hash is still recorded). */
1426
+ override = new Map()) {
715
1427
  for (const file of files) {
716
1428
  const content = await readFile(await packageFile(staging, plugin.packageName, file.source));
717
1429
  const digest = hash(content);
718
1430
  await tx.capture(file.target);
719
1431
  const current = await fingerprint(await securePath(tx.root, file.target));
720
1432
  const owned = state.files[file.target];
1433
+ const owner = file.owner ?? 'user';
721
1434
  if (owned) {
722
- if (current !== digest || owned.hash !== digest)
1435
+ // A shared file needs the same content and the same owner class from every plugin.
1436
+ if (current !== digest || owned.hash !== digest || owned.owner !== owner)
723
1437
  throw new CliError('FILE_CONFLICT', file.target);
724
1438
  owned.owners.push(plugin.id);
725
1439
  }
726
1440
  else {
727
1441
  if (current !== null)
728
1442
  throw new CliError('FILE_CONFLICT', file.target);
729
- await tx.write(file.target, content);
730
- state.files[file.target] = { hash: digest, owners: [plugin.id] };
1443
+ await tx.write(file.target, override.get(file.target) ?? content);
1444
+ state.files[file.target] = { hash: digest, owners: [plugin.id], owner };
731
1445
  }
732
1446
  }
733
1447
  }
734
1448
  /** Applies a new template set while preserving files the user changed or deleted. */
735
1449
  async #reconcileFiles(tx, staging, state, plugin, files) {
736
- const planned = new Map(files.map((file) => [file.target, file.source]));
1450
+ const planned = new Map(files.map((file) => [file.target, file]));
737
1451
  const details = [];
738
1452
  for (const [path, owned] of Object.entries(state.files)) {
739
1453
  if (!owned.owners.includes(plugin.id))
740
1454
  continue;
741
- const source = planned.get(path);
1455
+ const template = planned.get(path);
1456
+ const source = template?.source;
742
1457
  planned.delete(path);
1458
+ // The new version decides who edits the file (a single owner only: shared stays as is).
1459
+ if (template && owned.owners.length === 1)
1460
+ owned.owner = template.owner ?? 'user';
743
1461
  await tx.capture(path);
744
1462
  const current = await fingerprint(await securePath(tx.root, path));
745
1463
  const update = source === undefined ? undefined : 'node_modules/' + plugin.packageName + '/' + source;
@@ -773,7 +1491,7 @@ export class Project {
773
1491
  owned.hash = digest;
774
1492
  }
775
1493
  }
776
- await this.#addFiles(tx, staging, state, plugin, [...planned].map(([target, source]) => ({ source, target })));
1494
+ await this.#addFiles(tx, staging, state, plugin, [...planned.values()]);
777
1495
  return details;
778
1496
  }
779
1497
  async #syncExample(tx, root, id, warnings, update) {
@@ -888,33 +1606,41 @@ export class Project {
888
1606
  return result;
889
1607
  }
890
1608
  /**
891
- * `dokebi app add <name>`: copies the app skeleton of @dokebi/app to src/apps/<name>, maps the
892
- * given domains to it in dokebi.yaml and registers it in the generated code (D-0030 §7).
1609
+ * `dokebi app add <name>`: copies the app skeleton of the apps host (D-0070) to src/apps/<name>,
1610
+ * maps the given domains to it in dokebi.yaml and registers it in the generated code (D-0030 §7).
893
1611
  */
894
1612
  async appAdd(root, name, options = {}) {
895
1613
  const session = await this.#begin(root);
896
1614
  const { tx, state } = session;
897
1615
  try {
898
- this.#requireAppPlugin(state);
899
- const added = await this.#addApp(tx, session.root, state, session.configSource, name, options);
1616
+ const host = await this.#requireHost(state, 'apps');
1617
+ const added = await this.#addApp(tx, session.root, host, state, session.configSource, name, options);
900
1618
  const warnings = [];
901
- if (options.inertia &&
902
- !state.plugins.some((plugin) => plugin.id === 'dokebi-template-inertia'))
1619
+ if (options.inertia && !(await this.#providesInstalled(state, 'view.inertia')))
903
1620
  warnings.push({ code: 'INERTIA_PLUGIN_MISSING', path: `src/apps/${name}` });
904
1621
  if (options.ssr && !options.inertia)
905
1622
  throw new CliError('USAGE', 'Server rendering (--ssr) needs --inertia');
906
1623
  if (options.inertia)
907
- await this.#inertiaBuild(tx, session, warnings, options.ssr === true);
1624
+ await this.#inertiaBuild(tx, session, host, warnings, options.ssr === true);
908
1625
  // Domains are not secrets: the shared sample keeps them so `setup` scripts see them too.
909
1626
  await this.#syncExamples(tx, session.root, warnings, [
910
- { id: 'dokebi-app', update: { changes: added.changes } },
1627
+ { id: host.id, update: { changes: added.changes } },
911
1628
  ]);
912
1629
  await this.writeConfiguration(tx, state, parseAndRender(added.configSource));
913
1630
  const result = {
914
1631
  changed: tx.paths,
915
1632
  preserved: [],
916
1633
  ...(warnings.length ? { warnings } : {}),
917
- ...(options.inertia ? { nextSteps: ['npm run build'] } : {}),
1634
+ ...(options.inertia
1635
+ ? {
1636
+ nextSteps: [
1637
+ {
1638
+ command: 'npm run build',
1639
+ why: 'build the Vue pages with Vite (dist/); `npx dokebi dev` serves them with hot reload instead',
1640
+ },
1641
+ ],
1642
+ }
1643
+ : {}),
918
1644
  };
919
1645
  return await this.#finish(session, result, options.dryRun);
920
1646
  }
@@ -927,7 +1653,7 @@ export class Project {
927
1653
  const session = await this.#begin(root);
928
1654
  const { tx, state } = session;
929
1655
  try {
930
- this.#requireAppPlugin(state);
1656
+ const host = await this.#requireHost(state, 'apps');
931
1657
  if (!(state.apps ?? []).includes(name))
932
1658
  throw new CliError('UNKNOWN_APP', name);
933
1659
  const template = options.template ?? 'default';
@@ -935,7 +1661,7 @@ export class Project {
935
1661
  throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
936
1662
  if (!HOST.test(domain) || domain.length > 253)
937
1663
  throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
938
- const current = domainTarget(parseProjectConfig(session.configSource).options, domain);
1664
+ const current = domainTarget(parseProjectConfig(session.configSource).options, host.id, domain);
939
1665
  if (current && current !== name)
940
1666
  throw new CliError('DOMAIN_IN_USE', domain, { details: { domain, app: current } });
941
1667
  const views = `src/apps/${name}/views/${template}`;
@@ -943,10 +1669,8 @@ export class Project {
943
1669
  await tx.ensureDirectory(views);
944
1670
  const changes = domainChanges([domain], template === 'default' ? { app: name } : { app: name, template });
945
1671
  const warnings = [];
946
- await this.#syncExamples(tx, session.root, warnings, [
947
- { id: 'dokebi-app', update: { changes } },
948
- ]);
949
- await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, 'dokebi-app', changes));
1672
+ await this.#syncExamples(tx, session.root, warnings, [{ id: host.id, update: { changes } }]);
1673
+ await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, host.id, changes));
950
1674
  const result = {
951
1675
  changed: tx.paths,
952
1676
  preserved: [],
@@ -963,15 +1687,13 @@ export class Project {
963
1687
  const session = await this.#begin(root);
964
1688
  const { tx, state } = session;
965
1689
  try {
966
- this.#requireAppPlugin(state);
967
- if (!domainTarget(parseProjectConfig(session.configSource).options, domain))
1690
+ const host = await this.#requireHost(state, 'apps');
1691
+ if (!domainTarget(parseProjectConfig(session.configSource).options, host.id, domain))
968
1692
  throw new CliError('UNKNOWN_DOMAIN', domain);
969
1693
  const changes = [{ op: 'remove', path: ['domains', domain] }];
970
1694
  const warnings = [];
971
- await this.#syncExamples(tx, session.root, warnings, [
972
- { id: 'dokebi-app', update: { changes } },
973
- ]);
974
- await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, 'dokebi-app', changes));
1695
+ await this.#syncExamples(tx, session.root, warnings, [{ id: host.id, update: { changes } }]);
1696
+ await tx.write(CONFIG_FILE, applyConfigChanges(session.configSource, host.id, changes));
975
1697
  const result = {
976
1698
  changed: tx.paths,
977
1699
  preserved: [],
@@ -988,23 +1710,23 @@ export class Project {
988
1710
  const session = await this.#begin(root);
989
1711
  const { tx, state } = session;
990
1712
  try {
991
- this.#requireAppPlugin(state);
1713
+ const host = await this.#requireHost(state, 'apps');
992
1714
  if (!(state.apps ?? []).includes(name))
993
1715
  throw new CliError('UNKNOWN_APP', name);
994
- const domains = appDomains(parseProjectConfig(session.configSource).options, name);
1716
+ const domains = appDomains(parseProjectConfig(session.configSource).options, host.id, name);
995
1717
  if (domains.length)
996
1718
  throw new CliError('APP_IN_USE', name, { details: { app: name, domains } });
997
1719
  state.apps = (state.apps ?? []).filter((app) => app !== name);
998
1720
  if (!state.apps.length)
999
1721
  delete state.apps;
1000
- // Its `apps.<name>` settings (D-0061) go too: dokebi-app refuses an entry for an app that
1722
+ // Its `apps.<name>` settings (D-0061) go too: the host refuses an entry for an app that
1001
1723
  // is not defined (UNKNOWN_APP). The sample keeps the same shape when it has the entry.
1002
1724
  const warnings = [];
1003
1725
  if (await exists(await securePath(session.root, EXAMPLE))) {
1004
1726
  const example = await tx.read(EXAMPLE);
1005
1727
  let next = example;
1006
1728
  try {
1007
- next = removeAppSettings(example, name);
1729
+ next = removeAppSettings(example, host.id, name);
1008
1730
  }
1009
1731
  catch (error) {
1010
1732
  if (!(error instanceof CliError))
@@ -1014,7 +1736,7 @@ export class Project {
1014
1736
  if (next !== example)
1015
1737
  await tx.write(EXAMPLE, next);
1016
1738
  }
1017
- await this.writeConfiguration(tx, state, removeAppSettings(session.configSource, name));
1739
+ await this.writeConfiguration(tx, state, removeAppSettings(session.configSource, host.id, name));
1018
1740
  const result = {
1019
1741
  changed: tx.paths,
1020
1742
  preserved: [`src/apps/${name}`],
@@ -1030,12 +1752,16 @@ export class Project {
1030
1752
  async appList(root) {
1031
1753
  const state = readState(parseObject(await readFile(await securePath(root, '.dokebi/state.json'), 'utf8'), 'state'));
1032
1754
  const config = parseProjectConfig(await readFile(await securePath(root, CONFIG_FILE), 'utf8')).options;
1755
+ const host = findHost(state.plugins, 'apps');
1033
1756
  return {
1034
- apps: (state.apps ?? []).map((name) => ({ name, domains: appDomains(config, name) })),
1757
+ apps: (state.apps ?? []).map((name) => ({
1758
+ name,
1759
+ domains: host ? appDomains(config, host.id, name) : [],
1760
+ })),
1035
1761
  };
1036
1762
  }
1037
1763
  /**
1038
- * `dokebi module add <name>` (D-0032): copies the module skeleton of @dokebi/module to
1764
+ * `dokebi module add <name>` (D-0032): copies the module skeleton of the modules host to
1039
1765
  * src/shared/modules/<name> and registers it in the generated code. `views: false` copies the
1040
1766
  * skeleton without views (routes answer JSON).
1041
1767
  */
@@ -1043,8 +1769,7 @@ export class Project {
1043
1769
  const session = await this.#begin(root);
1044
1770
  const { tx, state } = session;
1045
1771
  try {
1046
- if (!state.plugins.some((plugin) => plugin.id === 'dokebi-module'))
1047
- throw new CliError('MODULE_PLUGIN_MISSING', 'Run dokebi add @dokebi/module first');
1772
+ const host = await this.#requireHost(state, 'modules');
1048
1773
  if (!APP_NAME.test(name))
1049
1774
  throw new CliError('INVALID_MODULE', 'Module names match ' + APP_NAME.source);
1050
1775
  if ((state.modules ?? []).includes(name))
@@ -1054,9 +1779,9 @@ export class Project {
1054
1779
  if (await exists(await securePath(tx.root, folder)))
1055
1780
  throw new CliError('FILE_CONFLICT', folder);
1056
1781
  const skeleton = options.views === false ? 'module-api' : 'module';
1057
- const source = await packageFile(session.root, '@dokebi/module', `templates/${skeleton}`);
1782
+ const source = await packageFile(session.root, host.packageName, `templates/${skeleton}`);
1058
1783
  if (!(await exists(source)))
1059
- throw new CliError('INVALID_MANIFEST', 'Upgrade @dokebi/module (no module skeleton)');
1784
+ throw new CliError('INVALID_MANIFEST', `Upgrade ${host.packageName} (no module skeleton templates/${skeleton})`);
1060
1785
  for (const file of await listFiles(source)) {
1061
1786
  const target = `${folder}/${file}`;
1062
1787
  templateTarget(target);
@@ -1071,7 +1796,10 @@ export class Project {
1071
1796
  changed: tx.paths,
1072
1797
  preserved: [],
1073
1798
  nextSteps: [
1074
- `mount it in an app: modules: { '/${name}': <import of src/shared/modules/${name}/module.ts> }`,
1799
+ {
1800
+ command: `edit src/apps/<app>/app.ts: import ${camel(name)} from '../../shared/modules/${name}/module.ts' and add modules: { '/${name}': ${camel(name)} }`,
1801
+ why: "a module answers only where an app mounts it, under that prefix and on that app's domains",
1802
+ },
1075
1803
  ],
1076
1804
  };
1077
1805
  return await this.#finish(session, result, options.dryRun);
@@ -1117,8 +1845,8 @@ export class Project {
1117
1845
  * dev dependencies, and dist/ in .gitignore. Existing files, scripts and versions are kept.
1118
1846
  * With server rendering (D-0039) also vite.ssr.config.ts, and `build` runs both builds.
1119
1847
  */
1120
- async #inertiaBuild(tx, session, warnings, ssr) {
1121
- const source = await packageFile(session.root, '@dokebi/app', 'templates/inertia-build');
1848
+ async #inertiaBuild(tx, session, host, warnings, ssr) {
1849
+ const source = await packageFile(session.root, host.packageName, 'templates/inertia-build');
1122
1850
  const build = parseObject(await readFile(join(source, 'build.json'), 'utf8'), 'build.json');
1123
1851
  const wanted = object(build.devDependencies ?? {}, 'devDependencies');
1124
1852
  const { build: browserScript, buildSsr: ssrScript } = object(build.scripts ?? {}, 'scripts');
@@ -1170,12 +1898,29 @@ export class Project {
1170
1898
  if (!ignore.split(/\r?\n/).some((line) => ['dist', 'dist/', '/dist', '/dist/'].includes(line)))
1171
1899
  await tx.write('.gitignore', `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}dist/\n`);
1172
1900
  }
1173
- #requireAppPlugin(state) {
1174
- if (!state.plugins.some((plugin) => plugin.id === 'dokebi-app'))
1175
- throw new CliError('APP_PLUGIN_MISSING', 'Run dokebi add @dokebi/app first');
1901
+ /**
1902
+ * The installed plugin that hosts `role` (D-0070). None is APP_PLUGIN_MISSING /
1903
+ * MODULE_PLUGIN_MISSING, whose fix names the catalog plugin that provides the role.
1904
+ */
1905
+ async #requireHost(state, role) {
1906
+ const host = findHost(state.plugins, role);
1907
+ if (host) {
1908
+ debug('Host plugin found', { role, plugin: host.id, package: host.packageName });
1909
+ return host;
1910
+ }
1911
+ const capability = HOST_CAPABILITY[role];
1912
+ const suggested = providersOf(await loadCatalog(), capability).find((item) => item.kind === 'plugin')?.package;
1913
+ throw new CliError(role === 'apps' ? 'APP_PLUGIN_MISSING' : 'MODULE_PLUGIN_MISSING', `No installed plugin hosts ${role}; run dokebi add ${suggested ?? `<a plugin that provides ${capability}>`} first`, { details: { role, capability, ...(suggested ? { package: suggested } : {}) } });
1176
1914
  }
1177
- /** Shared by `app add` and the first app created with `dokebi add @dokebi/app`. */
1178
- async #addApp(tx, packagesRoot, state, configSource, name, options) {
1915
+ /** Whether an installed plugin provides a catalog capability starting with `prefix`. */
1916
+ async #providesInstalled(state, prefix) {
1917
+ const catalog = await loadCatalog();
1918
+ const installed = new Set(state.plugins.map((plugin) => plugin.packageName));
1919
+ return catalog.packages.some((item) => installed.has(item.package) &&
1920
+ item.provides.some((capability) => capability.startsWith(prefix)));
1921
+ }
1922
+ /** Shared by `app add` and the first app created when the apps host is added. */
1923
+ async #addApp(tx, packagesRoot, host, state, configSource, name, options) {
1179
1924
  if (!APP_NAME.test(name))
1180
1925
  throw new CliError('INVALID_APP', 'App names match ' + APP_NAME.source);
1181
1926
  const template = options.template ?? 'default';
@@ -1192,7 +1937,7 @@ export class Project {
1192
1937
  for (const domain of domains) {
1193
1938
  if (!HOST.test(domain) || domain.length > 253)
1194
1939
  throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
1195
- const current = domainTarget(config, domain);
1940
+ const current = domainTarget(config, host.id, domain);
1196
1941
  if (current && current !== name)
1197
1942
  throw new CliError('DOMAIN_IN_USE', domain, { details: { domain, app: current } });
1198
1943
  }
@@ -1206,7 +1951,7 @@ export class Project {
1206
1951
  : ['app'];
1207
1952
  const files = new Map();
1208
1953
  for (const skeleton of skeletons) {
1209
- const source = await packageFile(packagesRoot, '@dokebi/app', `templates/${skeleton}`);
1954
+ const source = await packageFile(packagesRoot, host.packageName, `templates/${skeleton}`);
1210
1955
  for (const file of await listFiles(source))
1211
1956
  files.set(file, join(source, file));
1212
1957
  }
@@ -1220,7 +1965,7 @@ export class Project {
1220
1965
  }
1221
1966
  const changes = domainChanges(domains, template === 'default' ? { app: name } : { app: name, template });
1222
1967
  state.apps = [...(state.apps ?? []), name];
1223
- return { configSource: applyConfigChanges(configSource, 'dokebi-app', changes), changes };
1968
+ return { configSource: applyConfigChanges(configSource, host.id, changes), changes };
1224
1969
  }
1225
1970
  async writeConfiguration(tx, state, configSource) {
1226
1971
  const generated = generateRegistration(state.plugins, state.installations, state.apps ?? [], state.modules ?? []);
@@ -1231,20 +1976,21 @@ export class Project {
1231
1976
  }
1232
1977
  }
1233
1978
  const HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
1234
- function domainsOf(options) {
1235
- const section = options['dokebi-app'];
1979
+ /** `options.<apps host>.domains` (D-0030): domain → { app, template? }. */
1980
+ function domainsOf(options, host) {
1981
+ const section = options[host];
1236
1982
  const domains = section && typeof section === 'object'
1237
1983
  ? section.domains
1238
1984
  : undefined;
1239
1985
  return domains && typeof domains === 'object' ? domains : {};
1240
1986
  }
1241
- function domainTarget(options, domain) {
1242
- const target = domainsOf(options)[domain];
1987
+ function domainTarget(options, host, domain) {
1988
+ const target = domainsOf(options, host)[domain];
1243
1989
  const app = target && typeof target === 'object' ? target.app : undefined;
1244
1990
  return typeof app === 'string' ? app : undefined;
1245
1991
  }
1246
- function appDomains(options, name) {
1247
- return Object.entries(domainsOf(options))
1992
+ function appDomains(options, host, name) {
1993
+ return Object.entries(domainsOf(options, host))
1248
1994
  .filter(([, target]) => target?.app === name)
1249
1995
  .map(([domain, target]) => ({
1250
1996
  domain,
@@ -1286,3 +2032,7 @@ async function readOptional(path) {
1286
2032
  throw error;
1287
2033
  }
1288
2034
  }
2035
+ /** `board-items` → `boardItems`: a module's default import name. */
2036
+ function camel(name) {
2037
+ return name.replace(/-([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
2038
+ }