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