@dokebi/cli 0.2.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +181 -87
- package/README.md +55 -24
- package/catalog.json +2203 -0
- package/dist/app-urls.d.ts +21 -0
- package/dist/app-urls.js +94 -0
- package/dist/banner-art.d.ts +4 -0
- package/dist/banner-art.js +31 -0
- package/dist/banner.d.ts +25 -0
- package/dist/banner.js +202 -0
- package/dist/bin.d.ts +2 -1
- package/dist/bin.js +475 -46
- package/dist/catalog.d.ts +161 -0
- package/dist/catalog.js +110 -0
- package/dist/check/form-rules.d.ts +13 -0
- package/dist/check/form-rules.js +224 -0
- package/dist/check/index.d.ts +23 -0
- package/dist/check/index.js +70 -0
- package/dist/check/packages.d.ts +17 -0
- package/dist/check/packages.js +161 -0
- package/dist/check/problems.d.ts +19 -0
- package/dist/check/problems.js +22 -0
- package/dist/check/project-rules.d.ts +10 -0
- package/dist/check/project-rules.js +142 -0
- package/dist/check/source-rules.d.ts +28 -0
- package/dist/check/source-rules.js +273 -0
- package/dist/check/typescript.d.ts +46 -0
- package/dist/check/typescript.js +168 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +372 -0
- package/dist/config-edit.d.ts +5 -4
- package/dist/config-edit.js +9 -8
- package/dist/configuration.d.ts +1 -0
- package/dist/configuration.js +2 -1
- package/dist/dev-infra.d.ts +62 -0
- package/dist/dev-infra.js +215 -0
- package/dist/env-file.d.ts +24 -0
- package/dist/env-file.js +100 -0
- package/dist/generator.d.ts +24 -1
- package/dist/generator.js +57 -47
- package/dist/guide.d.ts +29 -0
- package/dist/guide.js +90 -0
- package/dist/install-plan.d.ts +8 -0
- package/dist/install-plan.js +21 -4
- package/dist/layout.d.ts +5 -4
- package/dist/layout.js +8 -7
- package/dist/log-writer-control.d.ts +55 -0
- package/dist/log-writer-control.js +327 -0
- package/dist/log-writer.js +53 -12
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +77 -2
- package/dist/npm-client.d.ts +49 -0
- package/dist/npm-client.js +162 -41
- package/dist/output.d.ts +1 -1
- package/dist/output.js +14 -8
- package/dist/plugin-lifecycle.d.ts +7 -0
- package/dist/plugin-lifecycle.js +21 -3
- package/dist/plugins-command.d.ts +47 -0
- package/dist/plugins-command.js +189 -0
- package/dist/project-guide.d.ts +36 -1
- package/dist/project-guide.js +142 -18
- package/dist/project-inspector.d.ts +10 -0
- package/dist/project-inspector.js +51 -2
- package/dist/project-state.d.ts +13 -2
- package/dist/project-state.js +8 -6
- package/dist/project.d.ts +89 -7
- package/dist/project.js +835 -85
- package/dist/recipe-record.d.ts +24 -0
- package/dist/recipe-record.js +67 -0
- package/dist/recipe.d.ts +120 -0
- package/dist/recipe.js +801 -0
- package/dist/registry.d.ts +79 -0
- package/dist/registry.js +212 -0
- package/dist/rotating-log.d.ts +12 -0
- package/dist/rotating-log.js +19 -0
- package/dist/server.d.ts +94 -4
- package/dist/server.js +624 -64
- package/dist/verify.d.ts +62 -0
- package/dist/verify.js +380 -0
- package/dist/vite-host-process.d.ts +20 -0
- package/dist/vite-host-process.js +117 -0
- 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 {
|
|
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.
|
|
124
|
+
async init(target, core = '@dokebi/core@^0.4.0', dryRun = false) {
|
|
35
125
|
target = resolve(target);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
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, '-') ||
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.#
|
|
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:
|
|
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
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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:
|
|
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) => ({
|
|
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
|
|
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
|
-
|
|
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,
|
|
1782
|
+
const source = await packageFile(session.root, host.packageName, `templates/${skeleton}`);
|
|
1058
1783
|
if (!(await exists(source)))
|
|
1059
|
-
throw new CliError('INVALID_MANIFEST',
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
-
/**
|
|
1178
|
-
async #
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1235
|
-
|
|
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
|
+
}
|