@dokebi/cli 0.4.2 → 0.4.26
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 +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +518 -35
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +2 -0
- package/dist/check/shared-state-rules.js +114 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +307 -31
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +20 -2
- package/dist/install-module.js +45 -8
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +17 -2
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +40 -4
- package/dist/npm-client.js +152 -15
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +195 -16
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +481 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3006 -333
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/server.d.ts +43 -4
- package/dist/server.js +601 -52
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +47 -0
- package/dist/transaction.js +241 -7
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +103 -3
- package/dist/verify.js +272 -21
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
package/dist/project.js
CHANGED
|
@@ -1,36 +1,310 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
-
import {
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { RELOAD_OPTIONS_KEY, reloadOptions } from '@dokebi/core';
|
|
5
5
|
import lt from 'semver/functions/lt.js';
|
|
6
6
|
import satisfies from 'semver/functions/satisfies.js';
|
|
7
|
+
import validVersion from 'semver/functions/valid.js';
|
|
7
8
|
import validRange from 'semver/ranges/valid.js';
|
|
8
9
|
import { parse } from 'yaml';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
10
|
+
import { apiAppFiles } from './api-app.js';
|
|
11
|
+
import { capabilityBlock, syncCapabilityBlock } from './capability-guide.js';
|
|
12
|
+
import { findPackage, HOST_CAPABILITY, loadCatalog, providersOf, } from './catalog.js';
|
|
13
|
+
import { sampleAdditions } from './config-diff.js';
|
|
14
|
+
import { applyConfigChanges, applySetOptions, applyUnsetOptions, markRemoved, namedItems, optionAtPath, removeAppSettings, } from './config-edit.js';
|
|
15
|
+
import { isPlaceholder, issuePathOf, placeholderPaths, secretInSchema } from './config-init.js';
|
|
16
|
+
import { filePaths, outsideRequested, requestedPath } from './config-paths.js';
|
|
17
|
+
import { commandLineSecret } from './config-show.js';
|
|
18
|
+
import { addLoggingTargets, CONFIG_BACKUP, CONFIG_EXAMPLE, CONFIG_FILE, CONFIG_TEMPLATE, configOutsideProject, configTarget, FRESH_COPY_FIX, ignoreConfig, LEGACY_CONFIG_FILE, migrateConfig, parseProjectConfig, readProjectSettings, } from './configuration.js';
|
|
19
|
+
import { aliasConflicts, followDefaults, pruneUndeclared, scaffoldConfig, } from './configuration-scaffold.js';
|
|
20
|
+
import { DEPLOYMENT_FILES } from './deployment.js';
|
|
21
|
+
import { connectionSecrets } from './dev-compose.js';
|
|
22
|
+
import { AUTO_DOMAIN, devHost, projectHostLabel } from './dev-host.js';
|
|
23
|
+
import { COMPOSE_FILE, composeProject, composePublishedPort, composeText, composeUnsetVariables, composeVariablesHint, connectionUrl, DB_SERVICE, ENV_FILE, envKeys, envValue, FALLBACK_PROJECT_NAME, freePortAfter, generatePassword, PASSWORD_VARIABLE, portInUse, portQuestion, redactUrl, replicaSetOf, runsReplicaSet, URL_PLACEHOLDER_PREFIX, withComposePort, withEnv, } from './dev-infra.js';
|
|
14
24
|
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
15
|
-
import {
|
|
25
|
+
import { envFileWarnings, parseEnvText } from './env-file.js';
|
|
16
26
|
import { CliError } from './errors.js';
|
|
17
27
|
import { markExampleRemoved, syncExample } from './example-config.js';
|
|
18
28
|
import { exists, fingerprint, hash, json, object, parseObject, securePath, templateTarget, } from './filesystem.js';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
29
|
+
import { adminSections } from './first-admin.js';
|
|
30
|
+
import { biomeConfigText, biomeMajor, formatterFix, formatterUses, GENERATED_FILE, migrationsFolder, PRETTIER_IGNORE, prettierIgnoreText, } from './formatter-ignore.js';
|
|
31
|
+
import { generateRegistration, LEGACY_MAIN_SOURCE, MAIN_SOURCE, MAIN_SOURCE_V1, MAIN_SOURCE_V2, MAIN_SOURCE_V3, MAIN_SOURCE_V4, MAIN_SOURCE_V5, PROJECT_DEV_DEPENDENCIES, TEST_SCRIPT, TSCONFIG_SOURCE, TYPECHECK_SCRIPT, } from './generator.js';
|
|
32
|
+
import { findHost, HOST_ROLES, missingDependency, optionsKey, orderPlugins, PACKAGE_NAME, readManifest, } from './manifest.js';
|
|
21
33
|
import { mergeInstallDefaults } from './merge-install-defaults.js';
|
|
22
|
-
import { dependencyMap, NpmClient, packageSpec, stageNpmConfig, validateProjectPackage, } from './npm-client.js';
|
|
34
|
+
import { dependencyMap, NpmClient, packageSpec, projectTarball, stageNpmConfig, validateProjectPackage, } from './npm-client.js';
|
|
35
|
+
import { optionsFix, unknownOptionPaths } from './option-fixes.js';
|
|
23
36
|
import { CLI_VERSION } from './output.js';
|
|
24
|
-
import { checkAll, optionsOf, planLifecycle, } from './plugin-lifecycle.js';
|
|
37
|
+
import { checkAll, checkPluginsOptions, optionsOf, planLifecycle, } from './plugin-lifecycle.js';
|
|
25
38
|
import { PluginOptionsValidator, validatePluginOptions } from './plugin-options.js';
|
|
26
|
-
import { AGENTS_FILE, CLAUDE_FILE, CLAUDE_GUIDE, guideEntries, installedBlock, projectGuide, replaceInstalledBlock, } from './project-guide.js';
|
|
39
|
+
import { AGENTS_FILE, CLAUDE_FILE, CLAUDE_GUIDE, guideEntries, installedBlock, projectBlockState, projectGuide, replaceInstalledBlock, withProjectBlock, } from './project-guide.js';
|
|
27
40
|
import { APP_NAME, defaultInstallation, emptyState, readState, } from './project-state.js';
|
|
28
|
-
import { resolveVersion } from './registry.js';
|
|
41
|
+
import { companionVersions, conflictFix, describeConflicts, fetchPackument, missingOnRegistry, resolveVersion, versionConflicts, } from './registry.js';
|
|
42
|
+
import { markReloadable, reloadSchemas } from './reloadable.js';
|
|
29
43
|
import { readRequiredPlugins } from './required-plugins.js';
|
|
30
|
-
import {
|
|
44
|
+
import { CONVENTION_SECTIONS, catalogPackageOf, RESERVED_SECTIONS, renderSettingsSchema, renderSettingsSchemaFrom, SETTINGS_FOLDER, SETTINGS_SECTION, settingsFile, settingsIdentifier, settingsSchemaFile, settingsSource, undeclaredSection, undeclaredSectionError, } from './settings.js';
|
|
45
|
+
import { templateFixes } from './template-fixes.js';
|
|
46
|
+
import { Interruption, interruptedError, Transaction } from './transaction.js';
|
|
47
|
+
import { appViewValue, NO_VIEW, projectView } from './view-choice.js';
|
|
31
48
|
import { installedPath, packageFile, workspacePackages, workspaceRootOf, } from './workspace.js';
|
|
32
49
|
const GENERATED = 'src/dokebi.generated.ts';
|
|
33
50
|
const EXAMPLE = 'dokebi.example.yaml';
|
|
51
|
+
/** Previous secrets `config secret --rotate` keeps after the new one in a list (AI-M05). */
|
|
52
|
+
/** An option key that holds an address (`url`, `uri`, `databaseUrl`, `endpointURI`). */
|
|
53
|
+
const URL_KEY = /^(url|uri)$|(Url|URL|Uri|URI)$/;
|
|
54
|
+
export const ROTATE_KEEP = 2;
|
|
55
|
+
/** The generated registration differs from what the CLI wrote (D-0116: a sentence and a fix). */
|
|
56
|
+
function modifiedRegistration() {
|
|
57
|
+
return new CliError('MODIFIED_REGISTRATION', `${GENERATED} was edited by hand; the CLI writes it and does not change a project whose registration differs`, {
|
|
58
|
+
details: {
|
|
59
|
+
path: GENERATED,
|
|
60
|
+
fix: `undo the edits to ${GENERATED} (for example from Git) and put your own code in src/main.ts or a plugin entry file, then repeat this command`,
|
|
61
|
+
},
|
|
62
|
+
phase: 'plan',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* PACKAGE_CONFLICT (D-0116): package.json already depends on the npm package of a plugin the
|
|
67
|
+
* project has not registered, so the CLI does not take it over silently.
|
|
68
|
+
*/
|
|
69
|
+
function packageConflict(name, range, requiredBy) {
|
|
70
|
+
return new CliError('PACKAGE_CONFLICT', `package.json already depends on ${name} (${range}), but it is not a registered plugin of this project${requiredBy ? `; ${requiredBy} requires it` : ''}`, {
|
|
71
|
+
details: {
|
|
72
|
+
package: name,
|
|
73
|
+
range,
|
|
74
|
+
...(requiredBy ? { requiredBy } : {}),
|
|
75
|
+
fix: `npm uninstall ${name}, then npx dokebi add ${name} (the CLI installs and registers it in one transaction)`,
|
|
76
|
+
},
|
|
77
|
+
phase: 'plan',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** REVERSE_DEPENDENCY (D-0116): `dependents` still require the plugin `remove` was asked for. */
|
|
81
|
+
function reverseDependency(id, dependents) {
|
|
82
|
+
return new CliError('REVERSE_DEPENDENCY', `${id} cannot be removed: ${dependents.join(', ')} ${dependents.length === 1 ? 'requires' : 'require'} it`, {
|
|
83
|
+
details: {
|
|
84
|
+
plugin: dependents[0],
|
|
85
|
+
dependents,
|
|
86
|
+
requires: [id],
|
|
87
|
+
fix: `first run ${dependents.map((item) => `npx dokebi remove ${item}`).join(', ')}, or keep ${id}`,
|
|
88
|
+
},
|
|
89
|
+
phase: 'plan',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* MANIFEST_CHANGED during a change (D-0116): npm put another manifest of a registered plugin into
|
|
94
|
+
* the staged install than the one the project records.
|
|
95
|
+
*/
|
|
96
|
+
function stagedManifestChanged(plugin, actual) {
|
|
97
|
+
return new CliError('MANIFEST_CHANGED', `npm installed ${plugin.packageName} ${actual.version} for this change, but the project records ${plugin.id} ${plugin.version} with another manifest`, {
|
|
98
|
+
details: {
|
|
99
|
+
plugin: plugin.id,
|
|
100
|
+
recorded: plugin.version,
|
|
101
|
+
staged: actual.version,
|
|
102
|
+
fix: `npx dokebi upgrade ${plugin.id} first (or pin ${plugin.packageName} to ${plugin.version} in package.json), then repeat this command`,
|
|
103
|
+
},
|
|
104
|
+
phase: 'plan',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** INVALID_PLUGIN_OPTIONS (D-0116): options of a plugin break its schema (not only missing). */
|
|
108
|
+
function invalidPluginOptions(id, errors, phase,
|
|
109
|
+
/** The section of dokebi.yaml (options.logging for the logger host); `id` names the plugin. */
|
|
110
|
+
section = id,
|
|
111
|
+
/** D-0162: `rules` for the plugin's own checks (the schema passed); their message is said. */
|
|
112
|
+
broken = 'schema',
|
|
113
|
+
/** The options schema: the fix says what the refused field expects (community request D3). */
|
|
114
|
+
schema) {
|
|
115
|
+
const first = errors[0];
|
|
116
|
+
const where = first
|
|
117
|
+
? `options.${section}${first.instancePath.replaceAll('/', '.')} (${broken === 'rules' && first.message ? first.message : first.keyword})`
|
|
118
|
+
: `options.${section}`;
|
|
119
|
+
return new CliError('INVALID_PLUGIN_OPTIONS', `The options of ${id} do not match its ${broken === 'rules' ? 'rules (the app refuses them at start)' : 'schema'}: ${where}${errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''}`, {
|
|
120
|
+
details: {
|
|
121
|
+
plugin: id,
|
|
122
|
+
errors: errors.slice(0, 20),
|
|
123
|
+
errorCount: errors.length,
|
|
124
|
+
// D-0153: unknown keys get `config unset`, moved ones where they went.
|
|
125
|
+
fix: optionsFix(id, errors, `correct options.${section} in dokebi.yaml (npx dokebi config set ${section}.<path>=<value>; npx dokebi plugins info ${id} --json lists the option keys)`, section, schema),
|
|
126
|
+
},
|
|
127
|
+
phase,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* INVALID_PLUGIN_OPTIONS for an old option name set together with the field it now sets (D-0071):
|
|
132
|
+
* the schema takes both, the plugin's options parser refuses them at start (`conflict`).
|
|
133
|
+
*/
|
|
134
|
+
function aliasConflictError(id, section, conflicts) {
|
|
135
|
+
const [first] = conflicts;
|
|
136
|
+
return new CliError('INVALID_PLUGIN_OPTIONS', `options.${section}.${first?.alias} is the old name of ${first?.field}; ${id} refuses both set at start${conflicts.length > 1 ? ` (and ${conflicts.length - 1} more)` : ''}`, {
|
|
137
|
+
details: {
|
|
138
|
+
plugin: id,
|
|
139
|
+
errors: conflicts.map((conflict) => ({
|
|
140
|
+
instancePath: `/${conflict.alias.replaceAll('.', '/')}`,
|
|
141
|
+
keyword: 'conflict',
|
|
142
|
+
field: `${section}.${conflict.field}`,
|
|
143
|
+
})),
|
|
144
|
+
errorCount: conflicts.length,
|
|
145
|
+
fix: `keep one name: npx dokebi config unset ${conflicts.map((conflict) => `${section}.${conflict.alias}`).join(' ')} (the old name; its value then comes from the new one, so set that first if it should stay)`,
|
|
146
|
+
},
|
|
147
|
+
phase: 'arguments',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* AI-M49 D2: `settings sync` with options the new schema refuses (a value set by hand above a
|
|
152
|
+
* lowered maximum): the code doctor gives them, with the paths and a fix that works before the
|
|
153
|
+
* sync (config set checks the old schema until then); nothing is written.
|
|
154
|
+
*/
|
|
155
|
+
function settingsStillInvalid(section, errors, configFile) {
|
|
156
|
+
const paths = [
|
|
157
|
+
...new Set(errors.map((error) => [section, ...error.instancePath.split('/').slice(1), error.additionalProperty ?? '']
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.join('.'))),
|
|
160
|
+
];
|
|
161
|
+
const first = errors[0];
|
|
162
|
+
return new CliError('INVALID_PLUGIN_OPTIONS', `The new declaration of ${section} refuses what ${configFile} holds: options.${paths[0] ?? section}${first ? ` (${first.keyword})` : ''}${paths.length > 1 ? ` (and ${paths.length - 1} more)` : ''}; nothing was written`, {
|
|
163
|
+
details: {
|
|
164
|
+
plugin: section,
|
|
165
|
+
errors: errors.slice(0, 20),
|
|
166
|
+
errorCount: errors.length,
|
|
167
|
+
paths,
|
|
168
|
+
fix: `bring each value within the new declaration: npx dokebi config unset ${paths.join(' ')} (the new default then applies), or correct it in ${configFile} by hand; then npx dokebi settings sync again`,
|
|
169
|
+
},
|
|
170
|
+
phase: 'plan',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** UNKNOWN_APP (D-0116): the app command names an app the project does not register. */
|
|
174
|
+
function unknownApp(name, apps) {
|
|
175
|
+
return new CliError('UNKNOWN_APP', `The project has no app named ${name}`, {
|
|
176
|
+
details: {
|
|
177
|
+
app: name,
|
|
178
|
+
apps,
|
|
179
|
+
fix: apps.length
|
|
180
|
+
? `use one of ${apps.join(', ')}, or create it with npx dokebi app add ${name} --domain auto`
|
|
181
|
+
: `create it with npx dokebi app add ${name} --domain auto`,
|
|
182
|
+
},
|
|
183
|
+
phase: 'arguments',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** DOMAIN_IN_USE (D-0116): another app already serves the domain. */
|
|
187
|
+
function domainInUse(domain, app) {
|
|
188
|
+
return new CliError('DOMAIN_IN_USE', `The domain ${domain} already belongs to the app ${app}`, {
|
|
189
|
+
details: {
|
|
190
|
+
domain,
|
|
191
|
+
app,
|
|
192
|
+
fix: `choose another domain, or first npx dokebi app unlink ${domain}`,
|
|
193
|
+
},
|
|
194
|
+
phase: 'arguments',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** A path of the change that holds something else (D-0116: a sentence, details and a fix). */
|
|
198
|
+
function fileConflict(path, why, fix, extra = {}) {
|
|
199
|
+
return new CliError('FILE_CONFLICT', `${path} ${why}`, {
|
|
200
|
+
details: { path, ...extra, fix },
|
|
201
|
+
phase: 'plan',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** The plugin among `plugins` that declares `id` as a required dependency (D-0116). */
|
|
205
|
+
function requirerOf(plugins, id) {
|
|
206
|
+
const found = plugins.find((plugin) => plugin.dependencies.some((dependency) => dependency.name === id && !dependency.optional));
|
|
207
|
+
if (!found)
|
|
208
|
+
throw new Error(`No plugin of the change requires ${id}`);
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
/** The dependency entry by which a plugin of `plugins` requires `id` (D-0116). */
|
|
212
|
+
function declaredDependency(plugins, id) {
|
|
213
|
+
return requirerOf(plugins, id).dependencies.find((dependency) => dependency.name === id && !dependency.optional);
|
|
214
|
+
}
|
|
215
|
+
/** The value at `options.<id>.<path…>` of a dokebi.yaml source, or undefined. */
|
|
216
|
+
/** Levels core reads in options.logging (D-0047): `level` and the values of `plugins`. */
|
|
217
|
+
const LOGGING_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
|
|
218
|
+
/**
|
|
219
|
+
* The sections something reads — doctor's rule: core's (logging, reload), the conventions
|
|
220
|
+
* (application-runtime, …), a first admin's section, each installed plugin's section, the
|
|
221
|
+
* required plugins and the application's settings. A plugin id that is not its section (the
|
|
222
|
+
* logger's old dokebi-logger) is not read.
|
|
223
|
+
*/
|
|
224
|
+
function readSections(state, options) {
|
|
225
|
+
return new Set([
|
|
226
|
+
'logging',
|
|
227
|
+
RELOAD_OPTIONS_KEY,
|
|
228
|
+
...CONVENTION_SECTIONS,
|
|
229
|
+
...options.flatMap((item) => adminSections(item)),
|
|
230
|
+
...state.plugins.map((plugin) => optionsKey(plugin)),
|
|
231
|
+
...(state.settings ?? []),
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The installed plugin whose options section `section` names: `options.<id>`, or
|
|
236
|
+
* `options.logging` for the logger host (D-0151 follow-up: `config set logging.level=debug`,
|
|
237
|
+
* `config secret logging.targets.studio.token --stdin`).
|
|
238
|
+
*/
|
|
239
|
+
function sectionPlugin(plugins, section) {
|
|
240
|
+
return (plugins.find((item) => item.id === section) ??
|
|
241
|
+
plugins.find((item) => optionsKey(item) === section));
|
|
242
|
+
}
|
|
243
|
+
/** The section of dokebi.yaml of a plugin (options.logging for the logger host) or settings. */
|
|
244
|
+
function targetSection(target) {
|
|
245
|
+
return 'settingsSection' in target ? target.settingsSection : optionsKey(target);
|
|
246
|
+
}
|
|
247
|
+
/** Checks a section against the plugin's schema, or the settings schema file (D-0155). */
|
|
248
|
+
function checkTarget(validator, root, target, options) {
|
|
249
|
+
return 'settingsSection' in target
|
|
250
|
+
? validator.checkSettings(root, target.settingsSection, options)
|
|
251
|
+
: validator.check(root, target, options);
|
|
252
|
+
}
|
|
253
|
+
/** options.reload (D-0154): core's declaration decides; a refused value names the paths only. */
|
|
254
|
+
function checkCoreReload(section) {
|
|
255
|
+
const checked = reloadOptions.check(section);
|
|
256
|
+
if (checked.ok)
|
|
257
|
+
return;
|
|
258
|
+
const paths = checked.issues.map((issue) => `reload.${issue.path}`);
|
|
259
|
+
throw new CliError('INVALID_SET', `${paths.join(', ')} is not a valid setting of options.reload`, {
|
|
260
|
+
details: {
|
|
261
|
+
paths,
|
|
262
|
+
issues: checked.issues.map((issue) => ({
|
|
263
|
+
path: `reload.${issue.path}`,
|
|
264
|
+
message: issue.message,
|
|
265
|
+
})),
|
|
266
|
+
allowed: 'reload.watch and reload.signal (true or false), reload.debounceMs (50–60000), reload.prepareTimeoutMs (1000–600000), reload.watchRetryMs (1000–3600000)',
|
|
267
|
+
},
|
|
268
|
+
phase: 'arguments',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* options.logging without a logger plugin: core reads `level` and `plugins` only (D-0047), so
|
|
273
|
+
* `config set` takes those with core's levels and refuses the rest with the command that adds
|
|
274
|
+
* the logger.
|
|
275
|
+
*/
|
|
276
|
+
function checkCoreLogging(sets) {
|
|
277
|
+
const level = (value) => typeof value === 'string' && LOGGING_LEVELS.includes(value);
|
|
278
|
+
for (const set of sets) {
|
|
279
|
+
const [first, name, ...rest] = set.path;
|
|
280
|
+
const valid = (first === 'level' && name === undefined && level(set.value)) ||
|
|
281
|
+
(first === 'plugins' &&
|
|
282
|
+
((name === undefined &&
|
|
283
|
+
set.value !== null &&
|
|
284
|
+
typeof set.value === 'object' &&
|
|
285
|
+
!Array.isArray(set.value) &&
|
|
286
|
+
Object.values(set.value).every(level)) ||
|
|
287
|
+
(name !== undefined && !rest.length && level(set.value))));
|
|
288
|
+
if (!valid)
|
|
289
|
+
throw new CliError('INVALID_SET', `logging.${set.path.join('.')} is not a setting of options.logging without @dokebi/logger`, {
|
|
290
|
+
details: {
|
|
291
|
+
path: `logging.${set.path.join('.')}`,
|
|
292
|
+
allowed: `logging.level and logging.plugins.<plugin> (${LOGGING_LEVELS.join(', ')})`,
|
|
293
|
+
fix: `npx dokebi add ${LOGGER_PACKAGE} adds the logger whose options (targets, redact, …) live in options.logging too`,
|
|
294
|
+
},
|
|
295
|
+
phase: 'arguments',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function optionAt(source, id, path) {
|
|
300
|
+
let current = parseProjectConfig(source).options[id];
|
|
301
|
+
for (const key of path)
|
|
302
|
+
current =
|
|
303
|
+
current && typeof current === 'object' && Object.hasOwn(current, key)
|
|
304
|
+
? current[key]
|
|
305
|
+
: undefined;
|
|
306
|
+
return current;
|
|
307
|
+
}
|
|
34
308
|
/**
|
|
35
309
|
* Follow-ups after init (D-0065): read the guide, pick a recipe and ask its questions, find
|
|
36
310
|
* plugins, read the core contract, and verify before saying done.
|
|
@@ -53,9 +327,14 @@ export function initNextSteps(target) {
|
|
|
53
327
|
command: 'npx dokebi plugins search <words> --json',
|
|
54
328
|
why: 'outside a recipe: find a plugin; `absent` entries are features Dokebi does not have (do not invent them)',
|
|
55
329
|
},
|
|
330
|
+
{
|
|
331
|
+
// AI-M04 F7: the exact path (agents guessed `plugins doc dokebi-core`).
|
|
332
|
+
command: 'read node_modules/@dokebi/core/AI.md',
|
|
333
|
+
why: 'the core guide before writing app code: entry points, lifecycle, configuration, logging (the same text: npx dokebi plugins doc @dokebi/core --json)',
|
|
334
|
+
},
|
|
56
335
|
{
|
|
57
336
|
command: 'npx dokebi verify --json',
|
|
58
|
-
why:
|
|
337
|
+
why: "doctor, check, typecheck, build, the project's tests, start, HTTP checks and stop; say done only when ok is true",
|
|
59
338
|
},
|
|
60
339
|
];
|
|
61
340
|
}
|
|
@@ -116,21 +395,165 @@ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDepende
|
|
|
116
395
|
function union(...lists) {
|
|
117
396
|
return [...new Set(lists.flat())];
|
|
118
397
|
}
|
|
398
|
+
/** D-0151: the app logger every new project gets. */
|
|
399
|
+
export const LOGGER_PACKAGE = '@dokebi/logger';
|
|
400
|
+
/**
|
|
401
|
+
* R4CA-3: what the project's .env sets that the app does not get from it (DOKEBI_CONFIG), as the
|
|
402
|
+
* warnings of the config commands: they work on the file the environment names.
|
|
403
|
+
*/
|
|
404
|
+
function envWarnings(root) {
|
|
405
|
+
return envFileWarnings(root).map(({ code, path, hint }) => ({ code, path, hint }));
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* A `file:<absolute>.tgz` spec of a tarball inside the project (`dokebi add ./vendor/x.tgz`,
|
|
409
|
+
* `upgrade --to vendor/x.tgz`) as the project-relative `file:vendor/x.tgz`, so npm writes it
|
|
410
|
+
* relative into package.json and package-lock.json like the tarballs the project lists (it was
|
|
411
|
+
* written absolute, R5C-3); any other spec as it is. Links are resolved first, and the relative
|
|
412
|
+
* path has none, so the staging copy (securePath) takes it; a tarball outside stays absolute.
|
|
413
|
+
*/
|
|
414
|
+
async function projectSpec(root, spec) {
|
|
415
|
+
if (!spec.startsWith('file:') || !spec.endsWith('.tgz'))
|
|
416
|
+
return spec;
|
|
417
|
+
const file = spec.slice(5);
|
|
418
|
+
// A missing tarball is npm's error to give, as before.
|
|
419
|
+
if (!(await exists(file)))
|
|
420
|
+
return spec;
|
|
421
|
+
const real = await realpath(file);
|
|
422
|
+
const path = projectTarball(`file:${relative(await realpath(root), real)
|
|
423
|
+
.split(sep)
|
|
424
|
+
.join('/')}`);
|
|
425
|
+
if (path === undefined)
|
|
426
|
+
return spec;
|
|
427
|
+
debug('A tarball inside the project is installed by its relative path', { path });
|
|
428
|
+
return `file:${path}`;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* A command run outside a Dokebi project (no .dokebi/state.json or dokebi.yaml) is NOT_A_PROJECT
|
|
432
|
+
* with the file and a fix, never an INTERNAL_ERROR with a bare ENOENT (R1: add, upgrade and remove
|
|
433
|
+
* in the wrong folder).
|
|
434
|
+
*/
|
|
435
|
+
function notAProject(root, file) {
|
|
436
|
+
return new CliError('NOT_A_PROJECT', `No ${file}: run this in a Dokebi project (the folder with package.json, dokebi.yaml and .dokebi/state.json)`, {
|
|
437
|
+
phase: 'arguments',
|
|
438
|
+
details: {
|
|
439
|
+
root,
|
|
440
|
+
file,
|
|
441
|
+
fix: file === CONFIG_FILE
|
|
442
|
+
? FRESH_COPY_FIX
|
|
443
|
+
: 'run the command in the project folder, or make a project first with npx -y @dokebi/cli init <name>; .dokebi/state.json is written by the CLI and belongs in version control',
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** The text of .dokebi/state.json; a missing file is NOT_A_PROJECT. */
|
|
448
|
+
async function projectStateText(root) {
|
|
449
|
+
const path = await securePath(root, '.dokebi/state.json');
|
|
450
|
+
try {
|
|
451
|
+
return await readFile(path, 'utf8');
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (isMissing(error))
|
|
455
|
+
throw notAProject(root, '.dokebi/state.json');
|
|
456
|
+
throw new CliError('READ_FAILED', 'Cannot read .dokebi/state.json', { cause: error });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
119
459
|
export class Project {
|
|
120
460
|
npm;
|
|
121
461
|
constructor(npm = new NpmClient()) {
|
|
122
462
|
this.npm = npm;
|
|
123
463
|
}
|
|
124
|
-
|
|
464
|
+
/**
|
|
465
|
+
* A new project (D-0065). D-0151: every new project gets @dokebi/logger, added by `add` once
|
|
466
|
+
* the project exists (stdout JSON, pretty output in dokebi dev, a file target to turn on for
|
|
467
|
+
* production). When that install fails (offline, a registry without it) the project stays on
|
|
468
|
+
* core's console logger and the result says so (warning LOGGER_NOT_INSTALLED and the command).
|
|
469
|
+
*/
|
|
470
|
+
async init(target, core = '@dokebi/core@^0.4.0', dryRun = false, deploy = false, logger = true) {
|
|
471
|
+
// R4CB-3: a termination signal before the project is in place cancels init (INTERRUPTED,
|
|
472
|
+
// the staging folder removed); one that came while it was moved in skips the logger.
|
|
473
|
+
const interruption = dryRun ? undefined : Interruption.open();
|
|
474
|
+
let created;
|
|
475
|
+
try {
|
|
476
|
+
created = await this.#init(target, core, dryRun, deploy, interruption);
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
interruption?.close();
|
|
480
|
+
}
|
|
481
|
+
if (dryRun || !logger)
|
|
482
|
+
return created;
|
|
483
|
+
if (interruption?.interrupted)
|
|
484
|
+
return {
|
|
485
|
+
...created,
|
|
486
|
+
warnings: [
|
|
487
|
+
...(created.warnings ?? []),
|
|
488
|
+
{
|
|
489
|
+
code: 'LOGGER_NOT_INSTALLED',
|
|
490
|
+
path: LOGGER_PACKAGE,
|
|
491
|
+
hint: `INTERRUPTED: npx dokebi add ${LOGGER_PACKAGE} installs it later`,
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
nextSteps: [
|
|
495
|
+
{
|
|
496
|
+
command: `npx dokebi add ${LOGGER_PACKAGE}`,
|
|
497
|
+
why: 'install the default app logger that init skipped after the termination signal',
|
|
498
|
+
},
|
|
499
|
+
...created.nextSteps,
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
try {
|
|
503
|
+
const added = await this.add(resolve(target), LOGGER_PACKAGE);
|
|
504
|
+
debug('Added the default logger to the new project', {
|
|
505
|
+
target,
|
|
506
|
+
changed: added.changed.length,
|
|
507
|
+
resolved: added.resolved?.version ?? null,
|
|
508
|
+
});
|
|
509
|
+
return added.warnings?.length
|
|
510
|
+
? { ...created, warnings: [...(created.warnings ?? []), ...added.warnings] }
|
|
511
|
+
: created;
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
// The project exists and runs with core's console logger; the caller sees why and how.
|
|
515
|
+
debug('The default logger could not be added; the project keeps the console logger', {
|
|
516
|
+
target,
|
|
517
|
+
error: describeError(error),
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
...created,
|
|
521
|
+
warnings: [
|
|
522
|
+
...(created.warnings ?? []),
|
|
523
|
+
{
|
|
524
|
+
code: 'LOGGER_NOT_INSTALLED',
|
|
525
|
+
path: LOGGER_PACKAGE,
|
|
526
|
+
hint: `${error instanceof CliError ? error.code : 'ADD_FAILED'}: npx dokebi add ${LOGGER_PACKAGE} installs it later`,
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
nextSteps: [
|
|
530
|
+
{
|
|
531
|
+
command: `npx dokebi add ${LOGGER_PACKAGE}`,
|
|
532
|
+
why: 'install the default app logger (rotating files, redaction, pretty dev output) that init could not add',
|
|
533
|
+
},
|
|
534
|
+
...created.nextSteps,
|
|
535
|
+
],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async #init(target, core, dryRun, deploy, interruption) {
|
|
125
540
|
target = resolve(target);
|
|
126
541
|
// D-0065 §11: an existing folder is accepted when it is empty or holds only entries that
|
|
127
542
|
// are not part of a Node project (INIT_HARMLESS); anything else is PROJECT_EXISTS.
|
|
128
543
|
const folder = await initFolder(target);
|
|
544
|
+
const deployment = deploy ? { files: Object.keys(DEPLOYMENT_FILES) } : undefined;
|
|
545
|
+
const nextSteps = initNextSteps(target);
|
|
546
|
+
if (deploy)
|
|
547
|
+
nextSteps.push({
|
|
548
|
+
command: 'read DEPLOY.md',
|
|
549
|
+
why: 'configure deployment, runtime credentials and infrastructure before building or starting containers',
|
|
550
|
+
});
|
|
129
551
|
if (dryRun)
|
|
130
552
|
return {
|
|
131
553
|
changed: [target],
|
|
132
554
|
preserved: folder.kept.map((name) => join(target, name)),
|
|
133
|
-
nextSteps
|
|
555
|
+
nextSteps,
|
|
556
|
+
...(deployment ? { deployment } : {}),
|
|
134
557
|
};
|
|
135
558
|
// A new folder is staged next to it and renamed into place (atomic). An existing folder is
|
|
136
559
|
// staged inside itself (an agent sandbox may allow writes only there), then moved in.
|
|
@@ -142,6 +565,7 @@ export class Project {
|
|
|
142
565
|
if (folder.kept.includes('.npmrc'))
|
|
143
566
|
await stageNpmConfig(target, staging);
|
|
144
567
|
await mkdir(join(staging, 'src'));
|
|
568
|
+
await mkdir(join(staging, 'tests'));
|
|
145
569
|
await mkdir(join(staging, '.dokebi'));
|
|
146
570
|
await writeFile(join(staging, 'package.json'), json({
|
|
147
571
|
name: basename(target)
|
|
@@ -149,7 +573,7 @@ export class Project {
|
|
|
149
573
|
.replace(/[^a-z0-9-]/g, '-') || FALLBACK_PROJECT_NAME,
|
|
150
574
|
private: true,
|
|
151
575
|
type: 'module',
|
|
152
|
-
scripts: { start: 'node src/main.ts', typecheck: TYPECHECK_SCRIPT },
|
|
576
|
+
scripts: { start: 'node src/main.ts', typecheck: TYPECHECK_SCRIPT, test: TEST_SCRIPT },
|
|
153
577
|
engines: { node: '>=24.0.0' },
|
|
154
578
|
// npm installs these together with core (one npm run); --save-exact keeps them pinned.
|
|
155
579
|
devDependencies: PROJECT_DEV_DEPENDENCIES,
|
|
@@ -160,20 +584,30 @@ export class Project {
|
|
|
160
584
|
await writeFile(join(staging, GENERATED), generated);
|
|
161
585
|
await writeFile(join(staging, 'src/main.ts'), MAIN_SOURCE);
|
|
162
586
|
const requiredPlugins = await readRequiredPlugins(staging);
|
|
163
|
-
await writeFile(join(staging, AGENTS_FILE), projectGuide(guideEntries(await this.coreVersion(staging), requiredPlugins, [], CLI_VERSION)));
|
|
587
|
+
await writeFile(join(staging, AGENTS_FILE), projectGuide(guideEntries(await this.coreVersion(staging), requiredPlugins, [], CLI_VERSION), await loadCatalog()));
|
|
164
588
|
await writeFile(join(staging, CLAUDE_FILE), CLAUDE_GUIDE);
|
|
165
589
|
await writeFile(join(staging, 'tsconfig.json'), TSCONFIG_SOURCE);
|
|
166
590
|
await writeFile(join(staging, CONFIG_FILE), CONFIG_TEMPLATE, { mode: 0o600 });
|
|
167
591
|
await writeFile(join(staging, EXAMPLE), CONFIG_EXAMPLE);
|
|
168
592
|
await writeFile(join(staging, '.dokebi/state.json'), json(emptyState(hash(generated))));
|
|
169
|
-
await writeFile(join(staging, '.gitignore'),
|
|
593
|
+
await writeFile(join(staging, '.gitignore'),
|
|
594
|
+
// `*.mmdb`: IP geolocation databases (@dokebi/geoip) are licensed data, never committed.
|
|
595
|
+
ignoreConfig('node_modules/\n.dokebi/lock\n.dokebi/transaction-*/\n*.mmdb\n'));
|
|
596
|
+
if (deploy) {
|
|
597
|
+
debug('Writing deployment skeleton', { files: Object.keys(DEPLOYMENT_FILES) });
|
|
598
|
+
for (const [file, content] of Object.entries(DEPLOYMENT_FILES))
|
|
599
|
+
await writeFile(join(staging, file), content);
|
|
600
|
+
}
|
|
601
|
+
// The move below makes the project visible: a signal before it cancels init.
|
|
602
|
+
interruption?.check();
|
|
170
603
|
if (folder.exists) {
|
|
171
604
|
await this.#moveInto(staging, target, folder.kept);
|
|
172
605
|
debug('Initialized the project in an existing folder', { target, kept: folder.kept });
|
|
173
606
|
return {
|
|
174
607
|
changed: [target],
|
|
175
608
|
preserved: folder.kept.map((name) => join(target, name)),
|
|
176
|
-
nextSteps
|
|
609
|
+
nextSteps,
|
|
610
|
+
...(deployment ? { deployment } : {}),
|
|
177
611
|
};
|
|
178
612
|
}
|
|
179
613
|
// Reserve the destination before moving anything so existing projects are never replaced.
|
|
@@ -190,7 +624,11 @@ export class Project {
|
|
|
190
624
|
}
|
|
191
625
|
throw cause;
|
|
192
626
|
}
|
|
193
|
-
return { changed: [target], preserved: [], nextSteps:
|
|
627
|
+
return { changed: [target], preserved: [], nextSteps, ...(deployment ? { deployment } : {}) };
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
// npm got the signal from its forwarder and failed (or check() above): nothing is in place.
|
|
631
|
+
throw interruption ? interruption.failure(error) : error;
|
|
194
632
|
}
|
|
195
633
|
finally {
|
|
196
634
|
await rm(staging, { recursive: true, force: true });
|
|
@@ -229,7 +667,7 @@ export class Project {
|
|
|
229
667
|
}
|
|
230
668
|
async migrate(root, dryRun = false) {
|
|
231
669
|
root = await realpath(root);
|
|
232
|
-
await
|
|
670
|
+
await projectStateText(root);
|
|
233
671
|
const tx = await Transaction.begin(root);
|
|
234
672
|
try {
|
|
235
673
|
for (const path of [CONFIG_FILE, CONFIG_BACKUP, EXAMPLE])
|
|
@@ -240,9 +678,15 @@ export class Project {
|
|
|
240
678
|
throw new CliError('BACKUP_EXISTS', 'Preserve the existing legacy configuration backup before migrating');
|
|
241
679
|
const state = readState(parseObject(await tx.read('.dokebi/state.json'), 'state'));
|
|
242
680
|
if (hash(await tx.read(GENERATED)) !== state.generatedHash)
|
|
243
|
-
throw
|
|
681
|
+
throw modifiedRegistration();
|
|
244
682
|
const main = await tx.read('src/main.ts');
|
|
245
|
-
if (main !== LEGACY_MAIN_SOURCE &&
|
|
683
|
+
if (main !== LEGACY_MAIN_SOURCE &&
|
|
684
|
+
main !== MAIN_SOURCE_V1 &&
|
|
685
|
+
main !== MAIN_SOURCE_V2 &&
|
|
686
|
+
main !== MAIN_SOURCE_V3 &&
|
|
687
|
+
main !== MAIN_SOURCE_V4 &&
|
|
688
|
+
main !== MAIN_SOURCE_V5 &&
|
|
689
|
+
main !== MAIN_SOURCE)
|
|
246
690
|
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');
|
|
247
691
|
const original = await tx.read(LEGACY_CONFIG_FILE);
|
|
248
692
|
const source = migrateConfig(original);
|
|
@@ -252,7 +696,7 @@ export class Project {
|
|
|
252
696
|
await validatePluginOptions(root, orderPlugins(state.plugins, version, required), config.options);
|
|
253
697
|
await tx.write(CONFIG_BACKUP, original);
|
|
254
698
|
await tx.replace(LEGACY_CONFIG_FILE);
|
|
255
|
-
await this.writeConfiguration(tx, state, source);
|
|
699
|
+
await this.writeConfiguration(tx, state, source, CONFIG_FILE);
|
|
256
700
|
if (main === LEGACY_MAIN_SOURCE)
|
|
257
701
|
await tx.write('src/main.ts', MAIN_SOURCE);
|
|
258
702
|
if (!(await exists(await securePath(root, EXAMPLE))))
|
|
@@ -265,6 +709,10 @@ export class Project {
|
|
|
265
709
|
await tx.commit();
|
|
266
710
|
return result;
|
|
267
711
|
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
// R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
|
|
714
|
+
throw tx.failure(error);
|
|
715
|
+
}
|
|
268
716
|
finally {
|
|
269
717
|
await tx.close();
|
|
270
718
|
}
|
|
@@ -274,15 +722,17 @@ export class Project {
|
|
|
274
722
|
const session = await this.#begin(root);
|
|
275
723
|
const { tx, state } = session;
|
|
276
724
|
try {
|
|
277
|
-
|
|
725
|
+
if (!session.workspace)
|
|
726
|
+
operation = await projectSpec(session.root, operation);
|
|
727
|
+
const warnings = [];
|
|
278
728
|
// No version: the highest one this project's core supports (D-0065).
|
|
279
|
-
const resolution = await this.#resolve(session, operation, warnings);
|
|
729
|
+
const { resolution, companions } = await this.#resolve(session, operation, warnings);
|
|
280
730
|
if (resolution)
|
|
281
731
|
operation = `${resolution.package}@${resolution.version}`;
|
|
282
732
|
const dependencies = dependencyMap(session.pkg);
|
|
283
733
|
let staging;
|
|
284
734
|
try {
|
|
285
|
-
staging = await this.#stage(session, session.pkg, dependencies, operation);
|
|
735
|
+
staging = await this.#stage(session, session.pkg, dependencies, operation, companions);
|
|
286
736
|
}
|
|
287
737
|
catch (error) {
|
|
288
738
|
// Offline: say the version came from the snapshot (CATALOG_OFFLINE) next to npm's error.
|
|
@@ -312,8 +762,20 @@ export class Project {
|
|
|
312
762
|
const added = await this.#readPackageManifest(staging, name);
|
|
313
763
|
if (session.required.some((plugin) => plugin.id === added.id || plugin.packageName === added.packageName))
|
|
314
764
|
throw new CliError('REQUIRED_PLUGIN', 'Core already supplies this required plugin');
|
|
765
|
+
// D-0155: the section of the plugin may not be an application settings section.
|
|
766
|
+
if ((state.settings ?? []).includes(optionsKey(added)))
|
|
767
|
+
throw new CliError('SETTINGS_SECTION_CONFLICT', `options.${optionsKey(added)} is the settings section of the application; ${added.packageName} reads the same section`, {
|
|
768
|
+
details: {
|
|
769
|
+
section: optionsKey(added),
|
|
770
|
+
plugin: added.id,
|
|
771
|
+
fix: `give the application settings another section (rename src/shared/settings/${optionsKey(added)}.ts, its defineSettings section and options.${optionsKey(added)}; npx dokebi settings remove ${optionsKey(added)}, then settings add <new>), then add the plugin`,
|
|
772
|
+
},
|
|
773
|
+
phase: 'plan',
|
|
774
|
+
});
|
|
315
775
|
if (options.adapter !== undefined && added.id !== 'dokebi-db')
|
|
316
776
|
throw new CliError('USAGE', '--adapter applies to @dokebi/db only');
|
|
777
|
+
if (options.startup !== undefined && options.adapter === undefined)
|
|
778
|
+
throw new CliError('USAGE', '--startup applies to @dokebi/db with --adapter only');
|
|
317
779
|
const hostsApps = added.hosts?.includes('apps') === true;
|
|
318
780
|
if (options.firstApp === false && !hostsApps)
|
|
319
781
|
throw new CliError('USAGE', '--no-first-app applies only to the plugin that hosts apps (dokebi.hosts: ["apps"])');
|
|
@@ -322,6 +784,20 @@ export class Project {
|
|
|
322
784
|
if (existing.id === added.id &&
|
|
323
785
|
existing.packageName === added.packageName &&
|
|
324
786
|
existing.version === added.version) {
|
|
787
|
+
// R4CB-5: the plugin is installed, so add writes no options: --set was dropped and the
|
|
788
|
+
// result said nothing. config set is the command that changes an installed plugin's.
|
|
789
|
+
if (sets.length) {
|
|
790
|
+
const paths = sets.map((set) => `${optionsKey(added)}.${set.path.join('.')}`);
|
|
791
|
+
debug('add --set refused: the plugin is installed', { plugin: added.id, paths });
|
|
792
|
+
throw new CliError('INVALID_SET', `${added.id} ${existing.version} is installed: add does not change its options, so --set would be dropped`, {
|
|
793
|
+
details: {
|
|
794
|
+
plugin: added.id,
|
|
795
|
+
paths,
|
|
796
|
+
fix: `npx dokebi config set ${paths.map((path) => `${path}=<value>`).join(' ')} (secrets: npx dokebi config secret <path>)`,
|
|
797
|
+
},
|
|
798
|
+
phase: 'arguments',
|
|
799
|
+
});
|
|
800
|
+
}
|
|
325
801
|
if (options.adapter === undefined)
|
|
326
802
|
return { changed: [], preserved: [] };
|
|
327
803
|
// An installed db gets its adapter now (adapter file, connection, driver).
|
|
@@ -329,11 +805,12 @@ export class Project {
|
|
|
329
805
|
configSource: session.configSource,
|
|
330
806
|
fresh: false,
|
|
331
807
|
warnings,
|
|
808
|
+
...(options.startup === undefined ? {} : { startup: options.startup }),
|
|
332
809
|
});
|
|
333
810
|
await this.#syncExamples(tx, session.root, warnings, [
|
|
334
811
|
{ id: 'dokebi-db', update: { changes: adapted.changes } },
|
|
335
812
|
]);
|
|
336
|
-
await this.writeConfiguration(tx, state, adapted.configSource);
|
|
813
|
+
await this.writeConfiguration(tx, state, adapted.configSource, session.configFile);
|
|
337
814
|
await this.#replacePackages(session, staging, session.pkg);
|
|
338
815
|
return await this.#finish(session, {
|
|
339
816
|
changed: tx.paths,
|
|
@@ -342,10 +819,18 @@ export class Project {
|
|
|
342
819
|
nextSteps: adapted.nextSteps,
|
|
343
820
|
}, dryRun);
|
|
344
821
|
}
|
|
345
|
-
throw new CliError('UPGRADE_UNSUPPORTED',
|
|
822
|
+
throw new CliError('UPGRADE_UNSUPPORTED', `${added.id} ${existing.version} is installed; add does not change its version (use dokebi upgrade ${added.id})`, {
|
|
823
|
+
details: {
|
|
824
|
+
plugin: added.id,
|
|
825
|
+
installed: existing.version,
|
|
826
|
+
requested: added.version,
|
|
827
|
+
fix: `npx dokebi upgrade ${added.id} --to ${added.version}`,
|
|
828
|
+
},
|
|
829
|
+
phase: 'plan',
|
|
830
|
+
});
|
|
346
831
|
}
|
|
347
832
|
if (Object.hasOwn(dependencies, name))
|
|
348
|
-
throw
|
|
833
|
+
throw packageConflict(name, dependencies[name]);
|
|
349
834
|
// Required dependencies that name their npm package are installed in the same staging
|
|
350
835
|
// and transaction, before the requested plugin (D-0030 §9).
|
|
351
836
|
const automatic = await this.#automaticDependencies(staging, state, session, added, dependencies);
|
|
@@ -358,72 +843,65 @@ export class Project {
|
|
|
358
843
|
const installed = await this.#installedDependencies(staging);
|
|
359
844
|
await this.#verifyManifests(staging, state.plugins);
|
|
360
845
|
const ordered = await this.#order(staging, [...state.plugins, ...automatic, added], session.required);
|
|
361
|
-
let configSource = sets.length
|
|
362
|
-
? applySetOptions(session.configSource, added.id, sets)
|
|
363
|
-
: session.configSource;
|
|
364
|
-
let config = parseProjectConfig(configSource);
|
|
365
846
|
const projectPackages = Object.keys(installed);
|
|
366
847
|
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
367
|
-
const installations = { ...state.installations };
|
|
368
848
|
const newIds = new Set([...automatic, added].map((plugin) => plugin.id));
|
|
369
|
-
const examples = [];
|
|
370
849
|
const nextSteps = [];
|
|
371
|
-
const
|
|
850
|
+
const pending = {
|
|
851
|
+
configSource: sets.length
|
|
852
|
+
? applySetOptions(session.configSource, added.id, sets)
|
|
853
|
+
: session.configSource,
|
|
854
|
+
installations: { ...state.installations },
|
|
855
|
+
examples: [],
|
|
856
|
+
envSecrets: [],
|
|
857
|
+
generatedSecrets: [],
|
|
858
|
+
warnings,
|
|
859
|
+
};
|
|
372
860
|
// Plans run in dependency order so each plugin sees the ones it builds on.
|
|
373
|
-
for (const plugin of ordered.filter((item) => newIds.has(item.id)))
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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);
|
|
414
|
-
examples.push({
|
|
415
|
-
id: plugin.id,
|
|
416
|
-
update: { defaults: plan.defaults, schema: initial.schema },
|
|
861
|
+
for (const plugin of ordered.filter((item) => newIds.has(item.id)))
|
|
862
|
+
await this.#planInstall(session, staging, plugin, ordered, projectPackages, validator, pending, {
|
|
863
|
+
...(plugin.id === added.id && sets.length ? { sets } : {}),
|
|
864
|
+
// `--adapter`: the db entry file is written with the adapter's connection instead of
|
|
865
|
+
// the empty template (the template's hash is recorded, so upgrades keep this file).
|
|
866
|
+
...(adapter && plugin.id === 'dokebi-db'
|
|
867
|
+
? {
|
|
868
|
+
entry: async (target, source) => {
|
|
869
|
+
const adapted = await this.#dbAdapter(session, staging, adapter.name, {
|
|
870
|
+
configSource: source,
|
|
871
|
+
fresh: true,
|
|
872
|
+
warnings,
|
|
873
|
+
entry: target,
|
|
874
|
+
...(options.startup === undefined ? {} : { startup: options.startup }),
|
|
875
|
+
});
|
|
876
|
+
pending.examples.push({ id: plugin.id, update: { changes: adapted.changes } });
|
|
877
|
+
nextSteps.push(...adapted.nextSteps);
|
|
878
|
+
return {
|
|
879
|
+
configSource: adapted.configSource,
|
|
880
|
+
...(adapted.entryContent !== undefined
|
|
881
|
+
? { content: adapted.entryContent }
|
|
882
|
+
: {}),
|
|
883
|
+
};
|
|
884
|
+
},
|
|
885
|
+
}
|
|
886
|
+
: {}),
|
|
417
887
|
});
|
|
418
|
-
|
|
888
|
+
let configSource = pending.configSource;
|
|
889
|
+
let config = parseProjectConfig(configSource);
|
|
890
|
+
const { installations, examples, envSecrets, generatedSecrets } = pending;
|
|
419
891
|
// The first app comes with the plugin, so a new multi-app project runs right away.
|
|
892
|
+
let firstApp;
|
|
420
893
|
if (hostsApps && !(state.apps ?? []).length && options.firstApp !== false) {
|
|
421
|
-
|
|
422
|
-
|
|
894
|
+
firstApp = 'main';
|
|
895
|
+
// D-0102: a host of its own per project, so cookies of two projects never collide.
|
|
896
|
+
const domain = devHost('main', projectHostLabel(session.pkg.name, session.root));
|
|
897
|
+
const first = await this.#addApp(tx, staging, added, ordered, state, configSource, 'main', {
|
|
898
|
+
domains: [domain],
|
|
423
899
|
});
|
|
424
900
|
configSource = first.configSource;
|
|
425
901
|
config = parseProjectConfig(configSource);
|
|
426
902
|
examples.push({ id: added.id, update: { changes: first.changes } });
|
|
903
|
+
if (first.layout)
|
|
904
|
+
examples.push({ id: first.layout.engine, update: { changes: first.layout.changes } });
|
|
427
905
|
}
|
|
428
906
|
await this.#syncExamples(tx, session.root, warnings, examples);
|
|
429
907
|
const configRequired = [];
|
|
@@ -431,8 +909,11 @@ export class Project {
|
|
|
431
909
|
await this.#validateOptions(validator, staging, ordered, config, configRequired);
|
|
432
910
|
state.plugins = ordered;
|
|
433
911
|
state.installations = installations;
|
|
434
|
-
await this.writeConfiguration(tx, state, configSource);
|
|
435
|
-
|
|
912
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
913
|
+
if (added.hosts?.includes('logger'))
|
|
914
|
+
await this.#wireLogger(tx, session.root, added, warnings, nextSteps);
|
|
915
|
+
generatedSecrets.push(...(await this.#generateEnvSecrets(session, envSecrets)));
|
|
916
|
+
await this.#protectMigrations(session, ordered, warnings);
|
|
436
917
|
await this.#syncGuide(session, staging, ordered, warnings);
|
|
437
918
|
await this.#replacePackages(session, staging, session.pkg);
|
|
438
919
|
// Read the installed guide of every plugin this command added before using it.
|
|
@@ -444,7 +925,7 @@ export class Project {
|
|
|
444
925
|
});
|
|
445
926
|
if (hostsApps && options.firstApp === false)
|
|
446
927
|
nextSteps.push({
|
|
447
|
-
command: 'npx dokebi app add <name> --domain
|
|
928
|
+
command: 'npx dokebi app add <name> --domain auto [--inertia [--ssr]]',
|
|
448
929
|
why: `--no-first-app: no app exists yet; ${added.id} needs one before the project starts`,
|
|
449
930
|
});
|
|
450
931
|
const result = {
|
|
@@ -463,28 +944,90 @@ export class Project {
|
|
|
463
944
|
: {}),
|
|
464
945
|
...(warnings.length ? { warnings } : {}),
|
|
465
946
|
...(generatedSecrets.length ? { generatedSecrets } : {}),
|
|
947
|
+
...(firstApp ? { firstApp } : {}),
|
|
466
948
|
nextSteps,
|
|
467
949
|
};
|
|
468
950
|
return await this.#finish(session, result, dryRun);
|
|
469
951
|
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
// R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
|
|
954
|
+
throw tx.failure(error);
|
|
955
|
+
}
|
|
470
956
|
finally {
|
|
471
957
|
await tx.close();
|
|
472
958
|
}
|
|
473
959
|
}
|
|
960
|
+
/**
|
|
961
|
+
* D-0151: the logger host writes only when src/main.ts passes its logger to the Application.
|
|
962
|
+
* An unchanged generated main.ts of before D-0151 (or of before D-0154, without configFile)
|
|
963
|
+
* is rewritten to the current one; a main.ts
|
|
964
|
+
* that already calls createLogger stays; any other gets warning LOGGER_MAIN_MANUAL and the
|
|
965
|
+
* line to write (the plugin also warns LOGGER_NOT_WIRED at startup until it is there).
|
|
966
|
+
*/
|
|
967
|
+
async #wireLogger(tx, root, plugin, warnings, nextSteps) {
|
|
968
|
+
const path = 'src/main.ts';
|
|
969
|
+
if (!(await exists(await securePath(root, path)))) {
|
|
970
|
+
debug('No src/main.ts to wire the logger into', { plugin: plugin.id });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const main = await tx.read(path);
|
|
974
|
+
if (main === MAIN_SOURCE_V2 || main === MAIN_SOURCE_V3) {
|
|
975
|
+
await tx.write(path, MAIN_SOURCE);
|
|
976
|
+
debug('Rewrote the generated src/main.ts to pass the app logger', { plugin: plugin.id });
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (/\bcreateLogger\s*\(/.test(main)) {
|
|
980
|
+
debug('src/main.ts already passes the app logger', { plugin: plugin.id });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
warnings.push({
|
|
984
|
+
code: 'LOGGER_MAIN_MANUAL',
|
|
985
|
+
path,
|
|
986
|
+
plugin: plugin.id,
|
|
987
|
+
hint: "src/main.ts is your own: import { createLogger } from './dokebi.generated.ts' and create the app with new Application({ config: document.options, logger: createLogger(document.options) })",
|
|
988
|
+
});
|
|
989
|
+
nextSteps.push({
|
|
990
|
+
command: 'edit src/main.ts',
|
|
991
|
+
why: `pass createLogger(document.options) from ./dokebi.generated.ts as the Application logger, so ${plugin.packageName} writes the app's records (until then it warns LOGGER_NOT_WIRED)`,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
474
994
|
/**
|
|
475
995
|
* The version for a registry spec without one (D-0065): highest compatible with the project
|
|
476
996
|
* core, from the registry, else the CLI snapshot. Workspace packages and local archives keep
|
|
477
997
|
* their own resolution (D-0035).
|
|
478
998
|
*/
|
|
479
|
-
async #resolve(session, operation, warnings
|
|
999
|
+
async #resolve(session, operation, warnings, command = {
|
|
1000
|
+
next: `npx dokebi add ${operation}`,
|
|
1001
|
+
at: (version) => `npx dokebi add ${operation}@${version}`,
|
|
1002
|
+
}) {
|
|
480
1003
|
if (operation.startsWith('file:'))
|
|
481
|
-
return
|
|
1004
|
+
return { companions: [] };
|
|
482
1005
|
const bare = /^(@[^/@]+\/[^@]+|[^@]+)$/.exec(operation)?.[1];
|
|
483
1006
|
if (!bare || session.workspace?.packages.has(bare))
|
|
484
|
-
return
|
|
1007
|
+
return { companions: [] };
|
|
485
1008
|
const core = await this.coreVersion(session.root);
|
|
1009
|
+
// The version must fit what is installed: its peers accept the installed versions, and the
|
|
1010
|
+
// installed plugins' dependency ranges accept it (else npm stops with ERESOLVE).
|
|
1011
|
+
const constraints = this.#constraints(session, core, bare);
|
|
486
1012
|
// 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
|
|
1013
|
+
const found = await resolveVersion(bare, core, await loadCatalog(), session.root, {
|
|
1014
|
+
constraints,
|
|
1015
|
+
next: command.next,
|
|
1016
|
+
});
|
|
1017
|
+
if (found.heldBack && found.resolution) {
|
|
1018
|
+
const { version, conflicts } = found.heldBack;
|
|
1019
|
+
warnings.push({
|
|
1020
|
+
code: 'NEWER_VERSION_HELD_BACK',
|
|
1021
|
+
path: `${bare}@${version}`,
|
|
1022
|
+
hint: `${describeConflicts(bare, version, conflicts)}, so ${found.resolution.version} is used. For ${version}: ${conflictFix(conflicts, constraints, command.at(version))}`,
|
|
1023
|
+
});
|
|
1024
|
+
debug('A newer version does not fit the installed packages', {
|
|
1025
|
+
package: bare,
|
|
1026
|
+
heldBack: version,
|
|
1027
|
+
resolved: found.resolution.version,
|
|
1028
|
+
conflicts,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
488
1031
|
if (found.offline !== undefined) {
|
|
489
1032
|
warnings.push({ code: 'CATALOG_OFFLINE', path: bare });
|
|
490
1033
|
debug('Registry unreachable while resolving a version', {
|
|
@@ -500,7 +1043,39 @@ export class Project {
|
|
|
500
1043
|
});
|
|
501
1044
|
if (found.resolution)
|
|
502
1045
|
debug('Resolved a version for add', { ...found.resolution });
|
|
503
|
-
|
|
1046
|
+
// The required plugins it brings, at versions that fit too (npm would take the newest).
|
|
1047
|
+
const companions = found.manifest
|
|
1048
|
+
? await companionVersions(found.manifest, core, constraints, session.root, command.next, (name) => session.workspace?.packages.has(name) === true)
|
|
1049
|
+
: [];
|
|
1050
|
+
return { ...(found.resolution ? { resolution: found.resolution } : {}), companions };
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* What the installed project asks of a version of `name` (registry.ts ProjectConstraints): the
|
|
1054
|
+
* exact versions package.json pins and the registered plugins' versions (core's own), and the
|
|
1055
|
+
* ranges the registered plugins declare for each plugin id. `name` itself is left out, so an
|
|
1056
|
+
* upgrade is not held back by the version it replaces.
|
|
1057
|
+
*/
|
|
1058
|
+
#constraints(session, core, name) {
|
|
1059
|
+
const installed = {};
|
|
1060
|
+
for (const [dependency, spec] of Object.entries(dependencyMap(session.pkg)))
|
|
1061
|
+
if (validVersion(spec))
|
|
1062
|
+
installed[dependency] = spec;
|
|
1063
|
+
const ids = {};
|
|
1064
|
+
const required = {};
|
|
1065
|
+
for (const plugin of session.state.plugins) {
|
|
1066
|
+
installed[plugin.packageName] = plugin.version;
|
|
1067
|
+
ids[plugin.packageName] = plugin.id;
|
|
1068
|
+
if (plugin.packageName === name)
|
|
1069
|
+
continue;
|
|
1070
|
+
for (const dependency of plugin.dependencies) {
|
|
1071
|
+
const list = required[dependency.name] ?? [];
|
|
1072
|
+
list.push({ plugin: plugin.id, range: dependency.version });
|
|
1073
|
+
required[dependency.name] = list;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
installed['@dokebi/core'] = core;
|
|
1077
|
+
delete installed[name];
|
|
1078
|
+
return { installed, ids, required };
|
|
504
1079
|
}
|
|
505
1080
|
/** The db variant (from the staged @dokebi/db, else the CLI snapshot) for `--adapter`. */
|
|
506
1081
|
async #adapterVariant(base, adapter) {
|
|
@@ -560,8 +1135,10 @@ export class Project {
|
|
|
560
1135
|
/**
|
|
561
1136
|
* `add @dokebi/db --adapter <id>`: the adapter file (src/infra/db/adapters/<id>.ts, user code),
|
|
562
1137
|
* the entry file content with a `main` connection, and options.dokebi-db.connections.main in
|
|
563
|
-
* dokebi.yaml and the sample
|
|
564
|
-
*
|
|
1138
|
+
* dokebi.yaml and the sample (the URL and the template's pool limits with their defaults,
|
|
1139
|
+
* `connectionOptions` of the variant, D-0112). The address is a placeholder until
|
|
1140
|
+
* `dokebi dev-infra` (docker) or the developer (url) writes it; secrets never come from the
|
|
1141
|
+
* command line.
|
|
565
1142
|
*/
|
|
566
1143
|
async #dbAdapter(session, staging, adapter, input) {
|
|
567
1144
|
const { tx, state } = session;
|
|
@@ -617,13 +1194,19 @@ export class Project {
|
|
|
617
1194
|
{
|
|
618
1195
|
op: 'default',
|
|
619
1196
|
path: ['connections', 'main'],
|
|
620
|
-
|
|
1197
|
+
// D-0112: the template's limits with their defaults, so dokebi.yaml shows what to size.
|
|
1198
|
+
value: {
|
|
1199
|
+
adapter,
|
|
1200
|
+
// D-0127: only when --startup is given (required is the default and stays implicit).
|
|
1201
|
+
...(input.startup === undefined ? {} : { startup: input.startup }),
|
|
1202
|
+
options: { url: placeholder, ...variant.connectionOptions },
|
|
1203
|
+
},
|
|
621
1204
|
},
|
|
622
1205
|
];
|
|
623
1206
|
if (connection && connection.adapter !== adapter)
|
|
624
1207
|
input.warnings.push({
|
|
625
1208
|
code: 'CONNECTION_KEPT',
|
|
626
|
-
path:
|
|
1209
|
+
path: `${session.configFile}#options.dokebi-db.connections.main`,
|
|
627
1210
|
});
|
|
628
1211
|
const configSource = applyConfigChanges(input.configSource, 'dokebi-db', changes);
|
|
629
1212
|
if (!connection)
|
|
@@ -632,8 +1215,8 @@ export class Project {
|
|
|
632
1215
|
? 'npx dokebi dev-infra --json'
|
|
633
1216
|
: `edit dokebi.yaml: options.dokebi-db.connections.main.options.url`,
|
|
634
1217
|
why: variant.devInfra?.docker
|
|
635
|
-
? `writes compose.yaml
|
|
636
|
-
: 'write the database URL (with its password) in dokebi.yaml; never
|
|
1218
|
+
? `writes compose.yaml and the ${variant.name} URL with a generated password only in dokebi.yaml (or write your own URL in dokebi.yaml with your file editor; never put it on a command line such as --set, sed or echo)`
|
|
1219
|
+
: 'write the database URL (with its password) in dokebi.yaml with your file editor; never put it on a command line such as --set, sed or echo',
|
|
637
1220
|
});
|
|
638
1221
|
return {
|
|
639
1222
|
configSource,
|
|
@@ -642,6 +1225,140 @@ export class Project {
|
|
|
642
1225
|
nextSteps,
|
|
643
1226
|
};
|
|
644
1227
|
}
|
|
1228
|
+
/**
|
|
1229
|
+
* The install plan of one plugin this command installs (add: the requested plugin and its
|
|
1230
|
+
* automatic dependencies; upgrade: the automatic dependencies its new version brings, D-0116):
|
|
1231
|
+
* install defaults, the options scaffold, generated secrets, directories and files, written
|
|
1232
|
+
* into the transaction and `pending`. Plugins are planned in dependency order.
|
|
1233
|
+
*/
|
|
1234
|
+
async #planInstall(session, staging, plugin, ordered, projectPackages, validator, pending, hooks = {}) {
|
|
1235
|
+
const { tx, state } = session;
|
|
1236
|
+
let configSource = pending.configSource;
|
|
1237
|
+
const plan = await planLifecycle({
|
|
1238
|
+
base: staging,
|
|
1239
|
+
plugin,
|
|
1240
|
+
options: parseProjectConfig(configSource).options,
|
|
1241
|
+
plugins: ordered,
|
|
1242
|
+
projectPackages,
|
|
1243
|
+
}, 'planInstall');
|
|
1244
|
+
// D-0151: the logger host's options are options.logging (optionsKey), not options.<id>.
|
|
1245
|
+
const key = optionsKey(plugin);
|
|
1246
|
+
for (const defaults of plan.defaults)
|
|
1247
|
+
configSource = mergeInstallDefaults(configSource, key, defaults);
|
|
1248
|
+
if (plugin.hosts?.includes('logger'))
|
|
1249
|
+
configSource = addLoggingTargets(configSource);
|
|
1250
|
+
const initial = await validator.check(staging, plugin, optionsOf(parseProjectConfig(configSource).options, key));
|
|
1251
|
+
configSource = scaffoldConfig(configSource, key, initial.schema);
|
|
1252
|
+
if (initial.schema)
|
|
1253
|
+
session.schemas.set(key, initial.schema);
|
|
1254
|
+
// D-0073 addendum 2: generated secrets go into dokebi.yaml (0600, kept out of Git by init).
|
|
1255
|
+
for (const path of plan.configSecrets) {
|
|
1256
|
+
const option = `${plugin.id}.${path.join('.')}`;
|
|
1257
|
+
if (optionAt(configSource, key, path) !== undefined) {
|
|
1258
|
+
debug('Secret option already set; nothing generated', { option });
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const written = await this.#withGeneratedSecret(staging, validator, plugin, configSource, path);
|
|
1262
|
+
configSource = written.source;
|
|
1263
|
+
pending.generatedSecrets.push({ plugin: plugin.id, option, file: session.configFile });
|
|
1264
|
+
debug('Generated a secret option', { option, list: written.list });
|
|
1265
|
+
}
|
|
1266
|
+
if (plan.envSecrets.length) {
|
|
1267
|
+
// Kept for plugins published before configSecrets (session 0.4.1); removed in 0.5.
|
|
1268
|
+
debug('Install plan uses the deprecated envSecrets; secrets belong in dokebi.yaml', {
|
|
1269
|
+
plugin: plugin.id,
|
|
1270
|
+
variables: plan.envSecrets,
|
|
1271
|
+
});
|
|
1272
|
+
pending.warnings.push({
|
|
1273
|
+
code: 'DEPRECATED_PLAN_FIELD',
|
|
1274
|
+
plugin: plugin.id,
|
|
1275
|
+
deprecated: 'envSecrets',
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
pending.installations[plugin.id] = {
|
|
1279
|
+
requires: plan.requires,
|
|
1280
|
+
directories: plan.directories ?? [],
|
|
1281
|
+
...(plan.entry ? { entry: plan.entry.target } : {}),
|
|
1282
|
+
};
|
|
1283
|
+
for (const variable of plan.envSecrets)
|
|
1284
|
+
pending.envSecrets.push({ plugin: plugin.id, variable });
|
|
1285
|
+
for (const directory of plan.directories ?? [])
|
|
1286
|
+
await tx.ensureDirectory(directory);
|
|
1287
|
+
const override = new Map();
|
|
1288
|
+
if (hooks.entry && plan.entry) {
|
|
1289
|
+
const written = await hooks.entry(plan.entry.target, configSource);
|
|
1290
|
+
configSource = written.configSource;
|
|
1291
|
+
if (written.content !== undefined)
|
|
1292
|
+
override.set(plan.entry.target, written.content);
|
|
1293
|
+
}
|
|
1294
|
+
await this.#addFiles(tx, staging, state, plugin, this.#planFiles(plan), override);
|
|
1295
|
+
// The example's section is the same as dokebi.yaml's: options.logging for the logger host.
|
|
1296
|
+
pending.examples.push({
|
|
1297
|
+
id: key,
|
|
1298
|
+
update: {
|
|
1299
|
+
defaults: plan.defaults,
|
|
1300
|
+
schema: initial.schema,
|
|
1301
|
+
// The targets addLoggingTargets wrote into dokebi.yaml above (no secret in them).
|
|
1302
|
+
...(plugin.hosts?.includes('logger') ? { loggingTargets: true } : {}),
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
pending.configSource = configSource;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* D-0116: a required dependency of `plugins` that neither the project nor the change has and
|
|
1309
|
+
* whose manifest names no npm package cannot be installed by upgrade: MISSING_DEPENDENCY with
|
|
1310
|
+
* the catalog's package in `details.fix`, before anything is written.
|
|
1311
|
+
*/
|
|
1312
|
+
async #refuseUnnamedDependencies(session, plugins, rerun) {
|
|
1313
|
+
const known = new Set([
|
|
1314
|
+
...session.state.plugins.map((plugin) => plugin.id),
|
|
1315
|
+
...session.required.map((plugin) => plugin.id),
|
|
1316
|
+
...plugins.map((plugin) => plugin.id),
|
|
1317
|
+
]);
|
|
1318
|
+
for (const plugin of plugins)
|
|
1319
|
+
for (const dependency of plugin.dependencies) {
|
|
1320
|
+
if (dependency.optional || dependency.package || known.has(dependency.name))
|
|
1321
|
+
continue;
|
|
1322
|
+
const listed = findPackage(await loadCatalog(), dependency.name);
|
|
1323
|
+
debug('Upgrade refused: a new required dependency names no package', {
|
|
1324
|
+
plugin: plugin.id,
|
|
1325
|
+
dependency: dependency.name,
|
|
1326
|
+
catalog: listed?.package ?? null,
|
|
1327
|
+
});
|
|
1328
|
+
throw missingDependency(plugin, dependency, {
|
|
1329
|
+
reason: 'no-package',
|
|
1330
|
+
...(listed?.package ? { package: listed.package } : {}),
|
|
1331
|
+
why: 'its manifest names no npm package for it, so the upgrade cannot install it',
|
|
1332
|
+
afterwards: rerun,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* D-0116: an automatic dependency with more than one install variant (a choice such as the db
|
|
1338
|
+
* adapter) is the user's to choose: MISSING_DEPENDENCY, before anything is written.
|
|
1339
|
+
*/
|
|
1340
|
+
async #refuseChoiceDependencies(staging, plugins, automatic, rerun) {
|
|
1341
|
+
for (const dependency of automatic) {
|
|
1342
|
+
const pkg = parseObject(await readFile(await packageFile(staging, dependency.packageName, 'package.json'), 'utf8'), dependency.packageName);
|
|
1343
|
+
const catalog = pkg.dokebiCatalog;
|
|
1344
|
+
const variants = Array.isArray(catalog?.variants)
|
|
1345
|
+
? catalog.variants.map((variant) => String(variant.name))
|
|
1346
|
+
: [];
|
|
1347
|
+
if (variants.length < 2)
|
|
1348
|
+
continue;
|
|
1349
|
+
debug('Upgrade refused: a new required dependency needs a choice', {
|
|
1350
|
+
dependency: dependency.id,
|
|
1351
|
+
variants,
|
|
1352
|
+
});
|
|
1353
|
+
throw missingDependency(requirerOf(plugins, dependency.id), declaredDependency(plugins, dependency.id), {
|
|
1354
|
+
reason: 'needs-choice',
|
|
1355
|
+
package: dependency.packageName,
|
|
1356
|
+
why: `${dependency.id} needs a choice (${variants.join(', ')}), so the upgrade does not install it`,
|
|
1357
|
+
afterwards: rerun,
|
|
1358
|
+
extra: { variants },
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
645
1362
|
/**
|
|
646
1363
|
* Installs, into the staging directory, every missing required dependency (recursively) whose
|
|
647
1364
|
* manifest entry names an npm `package`. Returns their manifests; optional ones are skipped and
|
|
@@ -654,23 +1371,63 @@ export class Project {
|
|
|
654
1371
|
added.id,
|
|
655
1372
|
]);
|
|
656
1373
|
const result = [];
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1374
|
+
// One npm run per dependency level, not per dependency (D-0116: @dokebi/app's three
|
|
1375
|
+
// automatic dependencies were three more full npm runs in staging).
|
|
1376
|
+
let level = [added];
|
|
1377
|
+
while (level.length) {
|
|
1378
|
+
const wanted = [];
|
|
1379
|
+
for (const plugin of level)
|
|
1380
|
+
for (const dependency of plugin.dependencies) {
|
|
1381
|
+
if (dependency.optional || !dependency.package || known.has(dependency.name))
|
|
1382
|
+
continue;
|
|
1383
|
+
if (result.length + wanted.length >= 16)
|
|
1384
|
+
throw new CliError('INVALID_MANIFEST', `${added.id} brings more than 16 automatic dependencies; the CLI installs at most 16 at once`, {
|
|
1385
|
+
details: {
|
|
1386
|
+
plugin: added.id,
|
|
1387
|
+
automatic: [
|
|
1388
|
+
...result.map((item) => item.id),
|
|
1389
|
+
...wanted.map((item) => item.dependency.name),
|
|
1390
|
+
],
|
|
1391
|
+
fix: 'add some of these plugins first with npx dokebi add <package>, then repeat this command',
|
|
1392
|
+
},
|
|
1393
|
+
phase: 'plan',
|
|
1394
|
+
});
|
|
1395
|
+
if (Object.hasOwn(dependencies, dependency.package))
|
|
1396
|
+
throw packageConflict(dependency.package, dependencies[dependency.package], plugin.id);
|
|
1397
|
+
known.add(dependency.name);
|
|
1398
|
+
wanted.push({ plugin, dependency: dependency });
|
|
1399
|
+
}
|
|
1400
|
+
if (!wanted.length)
|
|
1401
|
+
break;
|
|
1402
|
+
// Already a direct dependency of the staged install (picked with the package itself by
|
|
1403
|
+
// companionVersions): installing the range again would let npm take the newest one.
|
|
1404
|
+
const staged = await this.#installedDependencies(staging);
|
|
1405
|
+
const install = wanted
|
|
1406
|
+
.filter(({ dependency }) => !Object.hasOwn(staged, dependency.package))
|
|
1407
|
+
.map(({ dependency }) => `${dependency.package}@${dependency.version}`);
|
|
1408
|
+
debug('Installing automatic dependencies into staging', {
|
|
1409
|
+
plugin: added.id,
|
|
1410
|
+
packages: install,
|
|
1411
|
+
staged: wanted.length - install.length,
|
|
1412
|
+
});
|
|
1413
|
+
if (install.length)
|
|
1414
|
+
await this.#stageInstall(session, staging, install);
|
|
1415
|
+
level = [];
|
|
1416
|
+
for (const { plugin, dependency } of wanted) {
|
|
668
1417
|
const manifest = await this.#readPackageManifest(staging, dependency.package);
|
|
669
1418
|
if (manifest.id !== dependency.name)
|
|
670
|
-
throw new CliError('INVALID_MANIFEST', `${dependency.package}
|
|
671
|
-
|
|
1419
|
+
throw new CliError('INVALID_MANIFEST', `${plugin.id} names ${dependency.package} as the package of ${dependency.name}, but that package is the plugin ${manifest.id}`, {
|
|
1420
|
+
details: {
|
|
1421
|
+
plugin: plugin.id,
|
|
1422
|
+
dependency: dependency.name,
|
|
1423
|
+
package: dependency.package,
|
|
1424
|
+
actual: manifest.id,
|
|
1425
|
+
fix: `the manifest of ${plugin.packageName} ${plugin.version} is wrong: use another version, or report it to its authors`,
|
|
1426
|
+
},
|
|
1427
|
+
phase: 'plan',
|
|
1428
|
+
});
|
|
672
1429
|
result.push(manifest);
|
|
673
|
-
|
|
1430
|
+
level.push(manifest);
|
|
674
1431
|
}
|
|
675
1432
|
}
|
|
676
1433
|
return result;
|
|
@@ -690,10 +1447,28 @@ export class Project {
|
|
|
690
1447
|
if (session.required.some((plugin) => plugin.id === id || plugin.packageName === id))
|
|
691
1448
|
throw new CliError('REQUIRED_PLUGIN', 'Core-required plugins cannot be removed');
|
|
692
1449
|
const removed = state.plugins.find((plugin) => plugin.id === id);
|
|
1450
|
+
// R4CB-10: remove takes the plugin id. A package name of an installed plugin was "not
|
|
1451
|
+
// installed", so `remove @dokebi/web` answered ok with nothing changed and web stayed.
|
|
1452
|
+
const byPackage = removed
|
|
1453
|
+
? undefined
|
|
1454
|
+
: state.plugins.find((plugin) => plugin.packageName === id);
|
|
1455
|
+
if (byPackage)
|
|
1456
|
+
throw new CliError('UNKNOWN_PLUGIN', `${id} is the package of the plugin ${byPackage.id}; remove takes the plugin id`, {
|
|
1457
|
+
details: {
|
|
1458
|
+
plugin: id,
|
|
1459
|
+
package: byPackage.packageName,
|
|
1460
|
+
installed: state.plugins.map((plugin) => plugin.id),
|
|
1461
|
+
fix: `npx dokebi remove ${byPackage.id}`,
|
|
1462
|
+
},
|
|
1463
|
+
phase: 'arguments',
|
|
1464
|
+
});
|
|
693
1465
|
if (!removed)
|
|
694
1466
|
return { changed: [], preserved: [] };
|
|
695
|
-
|
|
696
|
-
|
|
1467
|
+
const dependents = state.plugins
|
|
1468
|
+
.filter((plugin) => plugin.dependencies.some((dep) => dep.name === removed.id && !dep.optional))
|
|
1469
|
+
.map((plugin) => plugin.id);
|
|
1470
|
+
if (dependents.length)
|
|
1471
|
+
throw reverseDependency(removed.id, dependents);
|
|
697
1472
|
const dependencies = dependencyMap(session.pkg);
|
|
698
1473
|
delete dependencies[removed.packageName];
|
|
699
1474
|
const staging = await this.#stage(session, session.pkg, dependencies, undefined);
|
|
@@ -708,9 +1483,7 @@ export class Project {
|
|
|
708
1483
|
const checks = await this.#runChecks(staging, ordered, config, installations, Object.keys(after));
|
|
709
1484
|
for (const plugin of ordered) {
|
|
710
1485
|
if (checks.get(plugin.id)?.requires.includes(removed.id))
|
|
711
|
-
throw
|
|
712
|
-
details: { plugin: plugin.id, requires: [removed.id] },
|
|
713
|
-
});
|
|
1486
|
+
throw reverseDependency(removed.id, [plugin.id]);
|
|
714
1487
|
}
|
|
715
1488
|
for (const plugin of ordered)
|
|
716
1489
|
installations[plugin.id] = {
|
|
@@ -737,10 +1510,11 @@ export class Project {
|
|
|
737
1510
|
preserved.push(path);
|
|
738
1511
|
delete state.files[path];
|
|
739
1512
|
}
|
|
740
|
-
|
|
1513
|
+
// The plugin's section, as the sample's mark below (options.logging for the logger host).
|
|
1514
|
+
const marked = markRemoved(configSource, optionsKey(removed));
|
|
741
1515
|
configSource = marked.source;
|
|
742
1516
|
if (marked.marked)
|
|
743
|
-
preserved.push(`${
|
|
1517
|
+
preserved.push(`${session.configFile}#options.${optionsKey(removed)}`);
|
|
744
1518
|
for (const directory of state.installations[removed.id]?.directories ?? []) {
|
|
745
1519
|
if (await exists(await securePath(session.root, directory)))
|
|
746
1520
|
preserved.push(directory);
|
|
@@ -750,7 +1524,7 @@ export class Project {
|
|
|
750
1524
|
const example = await tx.read(EXAMPLE);
|
|
751
1525
|
let updated;
|
|
752
1526
|
try {
|
|
753
|
-
updated = markExampleRemoved(example, removed
|
|
1527
|
+
updated = markExampleRemoved(example, optionsKey(removed));
|
|
754
1528
|
}
|
|
755
1529
|
catch {
|
|
756
1530
|
// The removal still proceeds; the caller sees why the example was left as it was.
|
|
@@ -761,7 +1535,7 @@ export class Project {
|
|
|
761
1535
|
}
|
|
762
1536
|
state.plugins = ordered;
|
|
763
1537
|
state.installations = installations;
|
|
764
|
-
await this.writeConfiguration(tx, state, configSource);
|
|
1538
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
765
1539
|
await this.#syncGuide(session, staging, ordered, warnings);
|
|
766
1540
|
await this.#replacePackages(session, staging, session.pkg);
|
|
767
1541
|
const result = {
|
|
@@ -772,6 +1546,10 @@ export class Project {
|
|
|
772
1546
|
};
|
|
773
1547
|
return await this.#finish(session, result, dryRun);
|
|
774
1548
|
}
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
// R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
|
|
1551
|
+
throw tx.failure(error);
|
|
1552
|
+
}
|
|
775
1553
|
finally {
|
|
776
1554
|
await tx.close();
|
|
777
1555
|
}
|
|
@@ -782,39 +1560,122 @@ export class Project {
|
|
|
782
1560
|
try {
|
|
783
1561
|
const current = state.plugins.find((plugin) => plugin.id === id);
|
|
784
1562
|
if (!current)
|
|
785
|
-
throw new CliError('UNKNOWN_PLUGIN', id
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1563
|
+
throw new CliError('UNKNOWN_PLUGIN', `${id} is not a registered plugin of this project`, {
|
|
1564
|
+
details: {
|
|
1565
|
+
plugin: id,
|
|
1566
|
+
installed: state.plugins.map((plugin) => plugin.id),
|
|
1567
|
+
fix: 'upgrade takes a plugin id from details.installed; install a new plugin with npx dokebi add <package>',
|
|
1568
|
+
},
|
|
1569
|
+
phase: 'arguments',
|
|
1570
|
+
});
|
|
1571
|
+
const identityChanged = (actual) => new CliError('UPGRADE_IDENTITY_CHANGED', `${to ?? 'latest'} is ${actual}, not ${current.packageName} (${id}); upgrade keeps the package of a plugin`, {
|
|
1572
|
+
details: {
|
|
1573
|
+
plugin: id,
|
|
1574
|
+
package: current.packageName,
|
|
1575
|
+
requested: to ?? 'latest',
|
|
1576
|
+
actual,
|
|
1577
|
+
fix: `name a version or archive of ${current.packageName}; to switch packages, npx dokebi remove ${id} and npx dokebi add <package>`,
|
|
1578
|
+
},
|
|
1579
|
+
phase: 'plan',
|
|
1580
|
+
});
|
|
1581
|
+
// Without --to: the highest version that fits the core and the installed packages, as add
|
|
1582
|
+
// picks (a newer one they hold back is NEWER_VERSION_HELD_BACK), else npm's latest
|
|
1583
|
+
// (workspace packages, an unreachable registry).
|
|
1584
|
+
const resolveWarnings = [];
|
|
1585
|
+
const found = to === undefined
|
|
1586
|
+
? await this.#resolve(session, current.packageName, resolveWarnings, {
|
|
1587
|
+
next: `npx dokebi upgrade ${id}`,
|
|
1588
|
+
at: (version) => `npx dokebi upgrade ${id} --to ${version}`,
|
|
1589
|
+
})
|
|
1590
|
+
: undefined;
|
|
1591
|
+
const resolved = found?.resolution;
|
|
1592
|
+
const picked = resolved?.source === 'registry' && !lt(resolved.version, current.version)
|
|
1593
|
+
? resolved.version
|
|
1594
|
+
: undefined;
|
|
1595
|
+
// A bare version or range applies to the installed package; otherwise use a full spec.
|
|
1596
|
+
const requested = to === undefined
|
|
1597
|
+
? `${current.packageName}@${picked ?? 'latest'}`
|
|
789
1598
|
: to !== 'latest' && !to.endsWith('.tgz') && validRange(to) !== null
|
|
790
1599
|
? packageSpec(`${current.packageName}@${to}`, session.root)
|
|
791
1600
|
: packageSpec(to, session.root);
|
|
1601
|
+
const operation = session.workspace ? requested : await projectSpec(session.root, requested);
|
|
792
1602
|
const dependencies = dependencyMap(session.pkg);
|
|
793
|
-
const staging = await this.#stage(session, session.pkg, dependencies, operation);
|
|
1603
|
+
const staging = await this.#stage(session, session.pkg, dependencies, operation, picked ? (found?.companions ?? []) : []);
|
|
794
1604
|
const after = await this.#installedDependencies(staging);
|
|
795
|
-
if (operation.startsWith('file:')
|
|
796
|
-
Object.keys(after).find((key) => after[key] === operation)
|
|
797
|
-
|
|
1605
|
+
if (operation.startsWith('file:')) {
|
|
1606
|
+
const archive = Object.keys(after).find((key) => after[key] === operation);
|
|
1607
|
+
if (archive !== current.packageName)
|
|
1608
|
+
throw identityChanged(archive ?? operation);
|
|
1609
|
+
}
|
|
798
1610
|
if (!operation.startsWith('file:') && !operation.startsWith(current.packageName + '@'))
|
|
799
|
-
throw
|
|
1611
|
+
throw identityChanged(operation);
|
|
800
1612
|
const upgraded = await this.#readPackageManifest(staging, current.packageName);
|
|
801
1613
|
if (upgraded.id !== current.id || upgraded.packageName !== current.packageName)
|
|
802
|
-
throw
|
|
1614
|
+
throw identityChanged(`${upgraded.packageName} (${upgraded.id})`);
|
|
803
1615
|
if (lt(upgraded.version, current.version))
|
|
804
|
-
throw new CliError('UPGRADE_DOWNGRADE', id
|
|
1616
|
+
throw new CliError('UPGRADE_DOWNGRADE', `${id} ${current.version} is installed and ${to ?? 'latest'} is the older ${upgraded.version}; upgrade does not go back`, {
|
|
1617
|
+
details: {
|
|
1618
|
+
plugin: id,
|
|
1619
|
+
from: current.version,
|
|
1620
|
+
to: upgraded.version,
|
|
1621
|
+
fix: `name a version of ${current.packageName} at or above ${current.version}; to go back, npx dokebi remove ${id} and npx dokebi add ${current.packageName}@${upgraded.version}`,
|
|
1622
|
+
},
|
|
1623
|
+
phase: 'plan',
|
|
1624
|
+
});
|
|
805
1625
|
// Same version and same manifest: nothing to do. Same version with a different manifest
|
|
806
1626
|
// (a package changed without a version bump) is applied again so the project can follow
|
|
807
1627
|
// what is installed (D-0037).
|
|
808
1628
|
const sameVersion = upgraded.version === current.version;
|
|
809
|
-
|
|
810
|
-
|
|
1629
|
+
// An unpublished version keeps its number while its code changes (AGENTS.md): a rebuilt
|
|
1630
|
+
// tarball of the same version and manifest is still new code. The lock's integrity of the
|
|
1631
|
+
// package tells it apart (2026-10-28: web 0.4.3 from a tarball with a fix was a no-op).
|
|
1632
|
+
const contentChanged = sameVersion &&
|
|
1633
|
+
json(upgraded) === json(current) &&
|
|
1634
|
+
(await this.#contentChanged(session, staging, current.packageName));
|
|
1635
|
+
if (sameVersion && json(upgraded) === json(current) && !contentChanged)
|
|
1636
|
+
return {
|
|
1637
|
+
changed: [],
|
|
1638
|
+
preserved: [],
|
|
1639
|
+
...(resolveWarnings.length ? { warnings: resolveWarnings } : {}),
|
|
1640
|
+
from: current.version,
|
|
1641
|
+
to: upgraded.version,
|
|
1642
|
+
};
|
|
811
1643
|
const others = state.plugins.filter((plugin) => plugin.id !== id);
|
|
812
|
-
|
|
813
|
-
|
|
1644
|
+
const warnings = [...resolveWarnings];
|
|
1645
|
+
// D-0116: a required dependency the new version declares and the project lacks is
|
|
1646
|
+
// installed with the upgrade, as `add` would (same staging, same transaction), when its
|
|
1647
|
+
// manifest names the package and it needs no choice or settings; otherwise the upgrade is
|
|
1648
|
+
// refused before anything is written, with the add command to run first.
|
|
1649
|
+
const rerun = `npx dokebi upgrade ${id}${to === undefined ? '' : ` --to ${to}`}`;
|
|
1650
|
+
await this.#refuseUnnamedDependencies(session, [upgraded], rerun);
|
|
1651
|
+
const automatic = await this.#automaticDependencies(staging, state, session, upgraded, dependencies);
|
|
1652
|
+
if (automatic.length) {
|
|
1653
|
+
await this.#refuseUnnamedDependencies(session, [upgraded, ...automatic], rerun);
|
|
1654
|
+
await this.#refuseChoiceDependencies(staging, [upgraded, ...automatic], automatic, rerun);
|
|
1655
|
+
debug('Upgrade installs new required dependencies', {
|
|
1656
|
+
plugin: id,
|
|
1657
|
+
to: upgraded.version,
|
|
1658
|
+
dependencies: automatic.map((plugin) => `${plugin.id}@${plugin.version}`),
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
await this.#verifyUpgradeOthers(session, staging, others, warnings);
|
|
1662
|
+
const ordered = await this.#order(staging, [...state.plugins.map((plugin) => (plugin.id === id ? upgraded : plugin)), ...automatic], session.required);
|
|
814
1663
|
const installation = state.installations[id] ?? defaultInstallation(current);
|
|
815
|
-
|
|
1664
|
+
const projectPackages = Object.keys(await this.#installedDependencies(staging));
|
|
1665
|
+
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
1666
|
+
const autoIds = new Set(automatic.map((plugin) => plugin.id));
|
|
1667
|
+
const pending = {
|
|
1668
|
+
configSource: session.configSource,
|
|
1669
|
+
installations: { ...state.installations },
|
|
1670
|
+
examples: [],
|
|
1671
|
+
envSecrets: [],
|
|
1672
|
+
generatedSecrets: [],
|
|
1673
|
+
warnings,
|
|
1674
|
+
};
|
|
1675
|
+
for (const plugin of ordered.filter((item) => autoIds.has(item.id)))
|
|
1676
|
+
await this.#planInstall(session, staging, plugin, ordered, projectPackages, validator, pending);
|
|
1677
|
+
let configSource = pending.configSource;
|
|
816
1678
|
let config = parseProjectConfig(configSource);
|
|
817
|
-
const projectPackages = Object.keys(after);
|
|
818
1679
|
const plan = await planLifecycle({
|
|
819
1680
|
base: staging,
|
|
820
1681
|
plugin: upgraded,
|
|
@@ -828,59 +1689,154 @@ export class Project {
|
|
|
828
1689
|
installation: installation.entry ? { entry: installation.entry } : {},
|
|
829
1690
|
});
|
|
830
1691
|
if (plan.entry && installation.entry && plan.entry.target !== installation.entry)
|
|
831
|
-
throw new CliError('UPGRADE_ENTRY_MOVED', id
|
|
832
|
-
|
|
1692
|
+
throw new CliError('UPGRADE_ENTRY_MOVED', `${id} ${upgraded.version} moves its entry file from ${installation.entry} to ${plan.entry.target}; upgrade does not move user files`, {
|
|
1693
|
+
details: {
|
|
1694
|
+
plugin: id,
|
|
1695
|
+
from: current.version,
|
|
1696
|
+
to: upgraded.version,
|
|
1697
|
+
entry: installation.entry,
|
|
1698
|
+
target: plan.entry.target,
|
|
1699
|
+
fix: `move ${installation.entry} to ${plan.entry.target} yourself (it is your code), or stay on ${current.version}`,
|
|
1700
|
+
},
|
|
1701
|
+
phase: 'plan',
|
|
1702
|
+
});
|
|
1703
|
+
// The plugin's section: options.logging for the logger host (D-0151), else options.<id>.
|
|
1704
|
+
const key = optionsKey(upgraded);
|
|
1705
|
+
configSource = applyConfigChanges(configSource, key, plan.configChanges);
|
|
833
1706
|
for (const defaults of plan.defaults)
|
|
834
|
-
configSource = mergeInstallDefaults(configSource,
|
|
1707
|
+
configSource = mergeInstallDefaults(configSource, key, defaults);
|
|
835
1708
|
config = parseProjectConfig(configSource);
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
1709
|
+
const initial = await validator.check(staging, upgraded, optionsOf(config.options, key));
|
|
1710
|
+
configSource = scaffoldConfig(configSource, key, initial.schema);
|
|
1711
|
+
if (initial.schema)
|
|
1712
|
+
session.schemas.set(key, initial.schema);
|
|
839
1713
|
config = parseProjectConfig(configSource);
|
|
840
1714
|
const installations = {
|
|
841
|
-
...
|
|
1715
|
+
...pending.installations,
|
|
842
1716
|
[id]: {
|
|
843
1717
|
requires: plan.requires,
|
|
844
1718
|
directories: union(installation.directories, plan.directories ?? []),
|
|
845
1719
|
...(plan.entry ? { entry: plan.entry.target } : {}),
|
|
846
1720
|
},
|
|
847
1721
|
};
|
|
848
|
-
const warnings = [];
|
|
849
1722
|
const configRequired = [];
|
|
850
1723
|
await this.#check(staging, ordered, config, installations, projectPackages, { id, requires: plan.requires }, configRequired, warnings);
|
|
851
1724
|
await this.#validateOptions(validator, staging, ordered, config, configRequired);
|
|
1725
|
+
// A new dependency that would be left without its required values is the user's to set up:
|
|
1726
|
+
// nothing is written, `add` reports the values (D-0116).
|
|
1727
|
+
const unset = configRequired.filter((item) => autoIds.has(item.plugin));
|
|
1728
|
+
if (unset.length) {
|
|
1729
|
+
const first = unset[0];
|
|
1730
|
+
const dependency = automatic.find((plugin) => plugin.id === first.plugin);
|
|
1731
|
+
throw missingDependency(requirerOf([upgraded, ...automatic], dependency.id), declaredDependency([upgraded, ...automatic], dependency.id), {
|
|
1732
|
+
reason: 'needs-settings',
|
|
1733
|
+
package: dependency.packageName,
|
|
1734
|
+
why: `${dependency.id} needs settings of its own (${unset.map((item) => item.plugin).join(', ')}), so the upgrade does not install it`,
|
|
1735
|
+
afterwards: rerun,
|
|
1736
|
+
extra: { configRequired: unset },
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
852
1739
|
for (const directory of plan.directories ?? [])
|
|
853
1740
|
await tx.ensureDirectory(directory);
|
|
854
1741
|
const details = await this.#reconcileFiles(tx, staging, state, upgraded, this.#planFiles(plan));
|
|
855
|
-
await this.#
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1742
|
+
await this.#syncExamples(tx, session.root, warnings, [
|
|
1743
|
+
...pending.examples,
|
|
1744
|
+
{
|
|
1745
|
+
id: optionsKey(upgraded),
|
|
1746
|
+
update: {
|
|
1747
|
+
changes: plan.configChanges,
|
|
1748
|
+
defaults: plan.defaults,
|
|
1749
|
+
schema: initial.schema,
|
|
1750
|
+
},
|
|
1751
|
+
},
|
|
1752
|
+
]);
|
|
860
1753
|
state.plugins = ordered;
|
|
861
1754
|
state.installations = installations;
|
|
862
|
-
await this.writeConfiguration(tx, state, configSource);
|
|
1755
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
1756
|
+
const generatedSecrets = [
|
|
1757
|
+
...pending.generatedSecrets,
|
|
1758
|
+
...(await this.#generateEnvSecrets(session, pending.envSecrets)),
|
|
1759
|
+
];
|
|
863
1760
|
await this.#syncGuide(session, staging, ordered, warnings);
|
|
1761
|
+
// S-c: copies of this plugin's templates (owner: user) keep what they had; the fixes the
|
|
1762
|
+
// new templates carry and the copies lack are named, as doctor will (TEMPLATE_FIX_MISSING).
|
|
1763
|
+
const touched = new Set([id, ...autoIds]);
|
|
1764
|
+
const appsHost = ordered.find((plugin) => plugin.hosts?.includes('apps'));
|
|
1765
|
+
for (const item of await templateFixes(session.root, staging, {
|
|
1766
|
+
db: ordered.find((plugin) => plugin.id === 'dokebi-db')?.packageName,
|
|
1767
|
+
apps: appsHost ? { id: appsHost.id, packageName: appsHost.packageName } : undefined,
|
|
1768
|
+
inertia: ordered.some((plugin) => plugin.id === 'dokebi-template-inertia'),
|
|
1769
|
+
}))
|
|
1770
|
+
if (touched.has(item.plugin ?? '') ||
|
|
1771
|
+
(item.path.startsWith('vite.') && touched.has('dokebi-template-inertia')))
|
|
1772
|
+
warnings.push({
|
|
1773
|
+
code: item.code,
|
|
1774
|
+
path: item.path,
|
|
1775
|
+
...(item.plugin ? { plugin: item.plugin } : {}),
|
|
1776
|
+
...(item.hint ? { hint: item.hint } : {}),
|
|
1777
|
+
});
|
|
864
1778
|
await this.#replacePackages(session, staging, session.pkg);
|
|
1779
|
+
const nextSteps = automatic
|
|
1780
|
+
.filter((plugin) => plugin.documentation)
|
|
1781
|
+
.map((plugin) => ({
|
|
1782
|
+
command: `npx dokebi plugins doc ${plugin.id} --json`,
|
|
1783
|
+
why: `${id} ${upgraded.version} requires it, so the upgrade installed it: read node_modules/${plugin.packageName}/${plugin.documentation} (the installed ${plugin.version} contract)`,
|
|
1784
|
+
}));
|
|
865
1785
|
const result = {
|
|
866
1786
|
changed: tx.paths,
|
|
867
1787
|
preserved: details.map((detail) => detail.path),
|
|
868
1788
|
...(details.length ? { preservedDetails: details } : {}),
|
|
869
1789
|
...(configRequired.length ? { configRequired } : {}),
|
|
1790
|
+
...(automatic.length
|
|
1791
|
+
? {
|
|
1792
|
+
autoInstalled: automatic.map(({ id, packageName, version }) => ({
|
|
1793
|
+
id,
|
|
1794
|
+
packageName,
|
|
1795
|
+
version,
|
|
1796
|
+
})),
|
|
1797
|
+
}
|
|
1798
|
+
: {}),
|
|
870
1799
|
...(warnings.length ? { warnings } : {}),
|
|
871
|
-
...(
|
|
1800
|
+
...(generatedSecrets.length ? { generatedSecrets } : {}),
|
|
1801
|
+
...(sameVersion
|
|
1802
|
+
? {
|
|
1803
|
+
reason: contentChanged ? 'PACKAGE_CHANGED' : 'MANIFEST_CHANGED',
|
|
1804
|
+
}
|
|
1805
|
+
: {}),
|
|
872
1806
|
from: current.version,
|
|
873
1807
|
to: upgraded.version,
|
|
1808
|
+
...(nextSteps.length ? { nextSteps } : {}),
|
|
874
1809
|
};
|
|
875
1810
|
return await this.#finish(session, result, dryRun);
|
|
876
1811
|
}
|
|
1812
|
+
catch (error) {
|
|
1813
|
+
// R4CB-4: after a termination signal, whatever failed before the commit is INTERRUPTED.
|
|
1814
|
+
throw tx.failure(error);
|
|
1815
|
+
}
|
|
877
1816
|
finally {
|
|
878
1817
|
await tx.close();
|
|
879
1818
|
}
|
|
880
1819
|
}
|
|
1820
|
+
/** Refresh only maintained AGENTS blocks; project instructions remain byte-for-byte intact. */
|
|
1821
|
+
async syncGuide(root, dryRun = false) {
|
|
1822
|
+
const session = await this.#begin(root);
|
|
1823
|
+
try {
|
|
1824
|
+
const warnings = [];
|
|
1825
|
+
if (!(await exists(await securePath(session.root, AGENTS_FILE))))
|
|
1826
|
+
throw new CliError('GUIDE_BLOCK_MISSING', 'AGENTS.md is missing; restore its project instructions before syncing');
|
|
1827
|
+
await this.#syncGuide(session, session.root, session.state.plugins, warnings);
|
|
1828
|
+
return await this.#finish(session, { changed: session.tx.paths, preserved: [], ...(warnings.length ? { warnings } : {}) }, dryRun);
|
|
1829
|
+
}
|
|
1830
|
+
finally {
|
|
1831
|
+
await session.tx.close();
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
881
1834
|
/**
|
|
882
1835
|
* 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.
|
|
1836
|
+
* transaction (D-0065). Missing markers leave the file alone with GUIDE_BLOCK_MISSING. The
|
|
1837
|
+
* project block (dokebi:project markers, D-0077 G) is carried over as it is: only the installed
|
|
1838
|
+
* list is replaced, an older file without the block gets an empty one at the end, and broken
|
|
1839
|
+
* project markers leave the whole file alone with GUIDE_PROJECT_BLOCK_INVALID.
|
|
884
1840
|
*/
|
|
885
1841
|
async #syncGuide(session, base, plugins, warnings) {
|
|
886
1842
|
if (!(await exists(await securePath(session.root, AGENTS_FILE))))
|
|
@@ -889,11 +1845,26 @@ export class Project {
|
|
|
889
1845
|
const devDependencies = object(session.pkg.devDependencies ?? {}, 'devDependencies');
|
|
890
1846
|
const cli = devDependencies['@dokebi/cli'];
|
|
891
1847
|
const block = installedBlock(guideEntries(await this.coreVersion(base), session.required, plugins, typeof cli === 'string' && /^\d+\.\d+\.\d+/.test(cli) ? cli : CLI_VERSION));
|
|
892
|
-
const
|
|
893
|
-
if (
|
|
1848
|
+
const replaced = replaceInstalledBlock(text, block);
|
|
1849
|
+
if (replaced === undefined) {
|
|
894
1850
|
warnings.push({ code: 'GUIDE_BLOCK_MISSING', path: AGENTS_FILE });
|
|
895
1851
|
return;
|
|
896
1852
|
}
|
|
1853
|
+
const project = projectBlockState(text);
|
|
1854
|
+
if (project === 'invalid') {
|
|
1855
|
+
// Where the project's text begins and ends is not known: writing could cut into it.
|
|
1856
|
+
debug('AGENTS.md left alone: project block markers are broken', { path: AGENTS_FILE });
|
|
1857
|
+
warnings.push({ code: 'GUIDE_PROJECT_BLOCK_INVALID', path: AGENTS_FILE });
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
const updated = syncCapabilityBlock(replaced, capabilityBlock(await loadCatalog()));
|
|
1861
|
+
if (updated === undefined) {
|
|
1862
|
+
warnings.push({ code: 'GUIDE_CAPABILITIES_BLOCK_INVALID', path: AGENTS_FILE });
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
const next = project === 'missing' ? withProjectBlock(updated) : updated;
|
|
1866
|
+
if (project === 'missing')
|
|
1867
|
+
debug('AGENTS.md project block added', { path: AGENTS_FILE });
|
|
897
1868
|
if (next !== text)
|
|
898
1869
|
await session.tx.write(AGENTS_FILE, next);
|
|
899
1870
|
}
|
|
@@ -906,22 +1877,43 @@ export class Project {
|
|
|
906
1877
|
const { tx, state } = session;
|
|
907
1878
|
try {
|
|
908
1879
|
let configSource = session.configSource;
|
|
909
|
-
const warnings =
|
|
1880
|
+
const warnings = envWarnings(session.root);
|
|
910
1881
|
const byPlugin = new Map();
|
|
911
1882
|
for (const { plugin, set } of assignments)
|
|
912
1883
|
byPlugin.set(plugin, [...(byPlugin.get(plugin) ?? []), set]);
|
|
913
1884
|
const configRequired = [];
|
|
914
1885
|
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
915
1886
|
for (const [id, sets] of byPlugin) {
|
|
916
|
-
const plugin = state.plugins
|
|
917
|
-
if (!plugin)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1887
|
+
const plugin = sectionPlugin(state.plugins, id);
|
|
1888
|
+
if (!plugin && id === 'logging') {
|
|
1889
|
+
// options.logging without the logger: core's level and plugins only (D-0047).
|
|
1890
|
+
checkCoreLogging(sets);
|
|
1891
|
+
configSource = applySetOptions(configSource, id, sets);
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
if (!plugin && id === RELOAD_OPTIONS_KEY) {
|
|
1895
|
+
// options.reload: core's reload settings (D-0154), checked by core's declaration.
|
|
1896
|
+
configSource = applySetOptions(configSource, id, sets);
|
|
1897
|
+
checkCoreReload(optionsOf(parseProjectConfig(configSource).options, id));
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
const settings = !plugin && (state.settings ?? []).includes(id);
|
|
1901
|
+
// D-0155: a section nobody declares cannot be checked, so nothing is written to it.
|
|
1902
|
+
if (!plugin && !settings)
|
|
1903
|
+
throw undeclaredSectionError(undeclaredSection(id, await catalogPackageOf(), state.plugins.map((item) => item.id), state.settings ?? []));
|
|
1904
|
+
// The section of the plugin: options.<id>, options.logging for the logger (D-0151); an
|
|
1905
|
+
// application settings section is its own name (D-0155).
|
|
1906
|
+
const section = plugin ? optionsKey(plugin) : id;
|
|
1907
|
+
configSource = applySetOptions(configSource, section, sets);
|
|
1908
|
+
const value = optionsOf(parseProjectConfig(configSource).options, section);
|
|
1909
|
+
const checked = plugin
|
|
1910
|
+
? await validator.check(session.root, plugin, value)
|
|
1911
|
+
: await validator.checkSettings(session.root, section, value);
|
|
1912
|
+
// An old name (D-0071 alias) and its field both set: valid by the schema, refused by the
|
|
1913
|
+
// plugin at start (INVALID_OPTIONS conflict), so never written.
|
|
1914
|
+
const conflicts = checked.schema ? aliasConflicts(value, checked.schema) : [];
|
|
1915
|
+
if (conflicts.length)
|
|
1916
|
+
throw aliasConflictError(plugin?.id ?? id, section, conflicts);
|
|
925
1917
|
if (checked.errors.every((error) => error.keyword === 'required')) {
|
|
926
1918
|
if (checked.errors.length)
|
|
927
1919
|
configRequired.push({
|
|
@@ -931,28 +1923,486 @@ export class Project {
|
|
|
931
1923
|
});
|
|
932
1924
|
}
|
|
933
1925
|
else
|
|
934
|
-
throw
|
|
935
|
-
|
|
1926
|
+
throw invalidPluginOptions(plugin?.id ?? id, checked.errors, 'arguments', section, 'schema', checked.schema);
|
|
1927
|
+
// D-0162: the plugin's own rules (cross-field checks) once the schema passes, so a value
|
|
1928
|
+
// the app would refuse at start is never written (R2 Claude T4: unknownDomain = an app name).
|
|
1929
|
+
if (plugin && !checked.errors.length) {
|
|
1930
|
+
const verdict = (await checkPluginsOptions(session.root, [plugin], parseProjectConfig(configSource).options)).get(plugin.id);
|
|
1931
|
+
if (verdict && 'failed' in verdict)
|
|
1932
|
+
warnings.push({
|
|
1933
|
+
code: 'PLUGIN_OPTIONS_CHECK_FAILED',
|
|
936
1934
|
plugin: id,
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1935
|
+
hint: `the plugin's own option rules could not run here (${verdict.failed}); npx dokebi start checks them too`,
|
|
1936
|
+
});
|
|
1937
|
+
else if (verdict?.issues.length)
|
|
1938
|
+
throw invalidPluginOptions(plugin.id, verdict.issues, 'arguments', section, 'rules');
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const installations = { ...state.installations };
|
|
1942
|
+
await this.#check(session.root, state.plugins, parseProjectConfig(configSource), installations, Object.keys(dependencyMap(session.pkg)), { id: '', requires: [] }, configRequired, warnings);
|
|
1943
|
+
state.installations = installations;
|
|
1944
|
+
const paths = filePaths([[session.configFile, session.configSource, configSource]]);
|
|
1945
|
+
this.#checkPaths(paths, assignments.map(({ plugin, set }) => requestedPath(session.configSource, plugin, set.path)), dryRun, warnings);
|
|
1946
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
1947
|
+
debug('Options set', { plugins: [...byPlugin.keys()], dryRun, file: session.configFile });
|
|
1948
|
+
return {
|
|
1949
|
+
...(await this.#finish(session, {
|
|
1950
|
+
changed: tx.paths,
|
|
1951
|
+
preserved: [],
|
|
1952
|
+
paths,
|
|
1953
|
+
...(configRequired.length ? { configRequired } : {}),
|
|
1954
|
+
...(warnings.length ? { warnings } : {}),
|
|
1955
|
+
}, dryRun)),
|
|
1956
|
+
file: session.configFile,
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
catch (error) {
|
|
1960
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
1961
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
1962
|
+
throw tx.failure(error);
|
|
1963
|
+
}
|
|
1964
|
+
finally {
|
|
1965
|
+
await tx.close();
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* `dokebi config unset <plugin>.<path>…` (D-0153): removes options from dokebi.yaml, and from
|
|
1970
|
+
* dokebi.example.yaml where the sample has them, in one transaction. A path that is not set is
|
|
1971
|
+
* a warning (OPTION_NOT_SET), not an error, so a repeated call changes nothing. Values are never
|
|
1972
|
+
* returned or logged; removing a secret is allowed and warned (SECRET_REMOVED: what it signed
|
|
1973
|
+
* no longer verifies). The plugin need not be installed (a section kept by `dokebi remove`).
|
|
1974
|
+
* Removing may only leave issues the options already had, or missing required values
|
|
1975
|
+
* (configRequired); issues that remain are a warning that carries the next fix.
|
|
1976
|
+
*/
|
|
1977
|
+
async configUnset(root, targets, dryRun = false) {
|
|
1978
|
+
const session = await this.#begin(root);
|
|
1979
|
+
const { tx, state } = session;
|
|
1980
|
+
try {
|
|
1981
|
+
let configSource = session.configSource;
|
|
1982
|
+
const warnings = envWarnings(session.root);
|
|
1983
|
+
const configRequired = [];
|
|
1984
|
+
const removed = [];
|
|
1985
|
+
const byPlugin = new Map();
|
|
1986
|
+
for (const { plugin, path } of targets)
|
|
1987
|
+
byPlugin.set(plugin, [...(byPlugin.get(plugin) ?? []), [...path]]);
|
|
1988
|
+
const examplePath = await securePath(session.root, EXAMPLE);
|
|
1989
|
+
const exampleSource = (await exists(examplePath)) ? await tx.read(EXAMPLE) : undefined;
|
|
1990
|
+
let example = exampleSource;
|
|
1991
|
+
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
1992
|
+
const key = (error) => `${error.keyword} ${error.instancePath} ${error.additionalProperty ?? ''}`;
|
|
1993
|
+
const exampleHas = (name) => example !== undefined && Object.hasOwn(parseProjectConfig(example).options, name);
|
|
1994
|
+
// The section each typed name means. Names that mean one section (the logger's plugin id
|
|
1995
|
+
// and options.logging) are one group: their list indexes are all of the file as it is, so
|
|
1996
|
+
// one removal never moves the item another index names.
|
|
1997
|
+
const bySection = new Map();
|
|
1998
|
+
for (const [section, paths] of byPlugin) {
|
|
1999
|
+
let plugin = sectionPlugin(state.plugins, section);
|
|
2000
|
+
let notRead;
|
|
2001
|
+
// A section typed by its plugin id that a file still holds as written (the sample's old
|
|
2002
|
+
// options.dokebi-logger, now options.logging): that section is meant, never the
|
|
2003
|
+
// plugin's live options.
|
|
2004
|
+
if (plugin &&
|
|
2005
|
+
optionsKey(plugin) !== section &&
|
|
2006
|
+
(Object.hasOwn(parseProjectConfig(configSource).options, section) || exampleHas(section))) {
|
|
2007
|
+
// Said once something of it is removed (below), never for a path it does not have.
|
|
2008
|
+
notRead = {
|
|
2009
|
+
code: 'SECTION_NOT_READ',
|
|
2010
|
+
plugin: section,
|
|
2011
|
+
hint: `options.${section} is not read: ${plugin.packageName} reads options.${optionsKey(plugin)}; removed as written (npx dokebi config unset ${optionsKey(plugin)}.<path> changes the live options)`,
|
|
2012
|
+
};
|
|
2013
|
+
plugin = undefined;
|
|
2014
|
+
}
|
|
2015
|
+
// The section of the plugin (options.logging for the logger, D-0151), else as typed.
|
|
2016
|
+
const id = plugin ? optionsKey(plugin) : section;
|
|
2017
|
+
const group = bySection.get(id);
|
|
2018
|
+
if (group)
|
|
2019
|
+
group.paths.push(...paths);
|
|
2020
|
+
else
|
|
2021
|
+
bySection.set(id, { plugin, notRead, paths: [...paths] });
|
|
2022
|
+
}
|
|
2023
|
+
for (const [id, { plugin, notRead, paths: asked }] of bySection) {
|
|
2024
|
+
// The same path typed twice through two names is one path.
|
|
2025
|
+
const paths = [...new Map(asked.map((path) => [path.join('.'), path])).values()];
|
|
2026
|
+
const before = parseProjectConfig(configSource);
|
|
2027
|
+
// D-0155: an application settings section is checked against its schema file too.
|
|
2028
|
+
const settings = !plugin && (state.settings ?? []).includes(id);
|
|
2029
|
+
const core = !plugin && (id === 'logging' || id === RELOAD_OPTIONS_KEY);
|
|
2030
|
+
// A whole section only when nothing reads it: a plugin's, the application's settings or
|
|
2031
|
+
// core's (logging, reload) are live options.
|
|
2032
|
+
const read = plugin ||
|
|
2033
|
+
settings ||
|
|
2034
|
+
core ||
|
|
2035
|
+
readSections(state, [
|
|
2036
|
+
before.options,
|
|
2037
|
+
example === undefined ? {} : parseProjectConfig(example).options,
|
|
2038
|
+
]).has(id);
|
|
2039
|
+
if (paths.some((path) => path.length === 0) && read)
|
|
2040
|
+
throw new CliError('INVALID_SET', plugin
|
|
2041
|
+
? `options.${id} is read by ${plugin.packageName}: name the options to remove, or remove the plugin (npx dokebi remove ${plugin.packageName})`
|
|
2042
|
+
: settings
|
|
2043
|
+
? `options.${id} is the application's settings section: name the options to remove, or remove the section (npx dokebi settings remove ${id})`
|
|
2044
|
+
: core
|
|
2045
|
+
? `options.${id} is read by core: name the options to remove`
|
|
2046
|
+
: `options.${id} is read by the application (a convention section such as application-runtime, or the first admin's): name the options to remove`, { phase: 'arguments', details: { section: id } });
|
|
2047
|
+
if (!plugin && !settings && !core) {
|
|
2048
|
+
// A section nobody declares: removing what is there needs no schema (the section a
|
|
2049
|
+
// removed plugin left, D-0153), so it proceeds with the code config set would refuse.
|
|
2050
|
+
const found = undeclaredSection(id, await catalogPackageOf(), state.plugins.map((item) => item.id), state.settings ?? []);
|
|
2051
|
+
if (!Object.hasOwn(before.options, id) && !exampleHas(id))
|
|
2052
|
+
throw undeclaredSectionError(found);
|
|
2053
|
+
if (!notRead)
|
|
2054
|
+
warnings.push({
|
|
2055
|
+
code: found.code,
|
|
2056
|
+
plugin: id,
|
|
2057
|
+
hint: `${found.message}; ${found.details.fix}`,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
const checkSection = (options) => plugin
|
|
2061
|
+
? validator.check(session.root, plugin, optionsOf(options, id))
|
|
2062
|
+
: validator.checkSettings(session.root, id, optionsOf(options, id));
|
|
2063
|
+
const checkedBefore = plugin || settings ? await checkSection(before.options) : undefined;
|
|
2064
|
+
const edited = applyUnsetOptions(configSource, id, paths);
|
|
2065
|
+
// Secret or not is decided on the values being removed; nothing of them is kept.
|
|
2066
|
+
for (const path of edited.removed) {
|
|
2067
|
+
const value = optionAtPath(configSource, id, path);
|
|
2068
|
+
if (commandLineSecret(path, value, checkedBefore?.schema ?? undefined))
|
|
2069
|
+
warnings.push({
|
|
2070
|
+
code: 'SECRET_REMOVED',
|
|
2071
|
+
plugin: id,
|
|
2072
|
+
path: [id, ...path].join('.'),
|
|
2073
|
+
hint: `what this secret signed no longer verifies once the app restarts; to replace a secret instead use npx dokebi config secret ${[id, ...path].join('.')} --rotate`,
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
configSource = edited.source;
|
|
2077
|
+
let inExample = [];
|
|
2078
|
+
if (example !== undefined) {
|
|
2079
|
+
// A list item named by its index in dokebi.yaml is the same item in the sample only by
|
|
2080
|
+
// its name (or type): the two lists may have another order. One without either is not
|
|
2081
|
+
// touched in the sample (SAMPLE_ITEM_UNKNOWN).
|
|
2082
|
+
const pairs = [];
|
|
2083
|
+
for (const path of paths) {
|
|
2084
|
+
const named = namedItems(session.configSource, id, path);
|
|
2085
|
+
if (named)
|
|
2086
|
+
pairs.push({ asked: path, sample: named });
|
|
2087
|
+
else
|
|
2088
|
+
warnings.push({
|
|
2089
|
+
code: 'SAMPLE_ITEM_UNKNOWN',
|
|
2090
|
+
plugin: id,
|
|
2091
|
+
path: [id, ...path].join('.'),
|
|
2092
|
+
hint: 'the list item has no name or type, so the same item cannot be found in dokebi.example.yaml; it was left there (npx dokebi config diff names what differs)',
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
// One item named two ways (`targets.studio`, `targets.3`) is one path in the sample.
|
|
2096
|
+
const sample = applyUnsetOptions(example, id, [
|
|
2097
|
+
...new Map(pairs.map((pair) => [pair.sample.join('.'), pair.sample])).values(),
|
|
2098
|
+
]);
|
|
2099
|
+
example = sample.source;
|
|
2100
|
+
// Reported as asked (the index of dokebi.yaml), whatever the sample's order.
|
|
2101
|
+
const gone = new Set(sample.removed.map((removedPath) => removedPath.join('.')));
|
|
2102
|
+
inExample = pairs
|
|
2103
|
+
.filter((pair) => gone.has(pair.sample.join('.')))
|
|
2104
|
+
.map((pair) => pair.asked);
|
|
2105
|
+
}
|
|
2106
|
+
if (notRead && (edited.removed.length || inExample.length))
|
|
2107
|
+
warnings.push(notRead);
|
|
2108
|
+
for (const path of paths) {
|
|
2109
|
+
const option = [id, ...path].join('.');
|
|
2110
|
+
const files = [
|
|
2111
|
+
...(edited.removed.some((item) => item.join('.') === path.join('.'))
|
|
2112
|
+
? [session.configFile]
|
|
2113
|
+
: []),
|
|
2114
|
+
...(inExample.some((item) => item.join('.') === path.join('.')) ? [EXAMPLE] : []),
|
|
2115
|
+
];
|
|
2116
|
+
if (files.length)
|
|
2117
|
+
removed.push({ option, files });
|
|
2118
|
+
else
|
|
2119
|
+
warnings.push({ code: 'OPTION_NOT_SET', plugin: id, path: option });
|
|
2120
|
+
}
|
|
2121
|
+
if (!checkedBefore)
|
|
2122
|
+
continue;
|
|
2123
|
+
const checked = await checkSection(parseProjectConfig(configSource).options);
|
|
2124
|
+
const had = new Set(checkedBefore.errors.map(key));
|
|
2125
|
+
const added = checked.errors.filter((error) => error.keyword !== 'required' && !had.has(key(error)));
|
|
2126
|
+
if (added.length)
|
|
2127
|
+
throw invalidPluginOptions(plugin?.id ?? id, added, 'arguments', id, 'schema', checked.schema);
|
|
2128
|
+
const required = checked.errors.filter((error) => error.keyword === 'required');
|
|
2129
|
+
if (required.length)
|
|
2130
|
+
configRequired.push({
|
|
2131
|
+
plugin: id,
|
|
2132
|
+
errors: required.slice(0, 20),
|
|
2133
|
+
errorCount: required.length,
|
|
2134
|
+
});
|
|
2135
|
+
const left = checked.errors.filter((error) => error.keyword !== 'required');
|
|
2136
|
+
if (left.length)
|
|
2137
|
+
warnings.push({
|
|
2138
|
+
code: 'OPTIONS_STILL_INVALID',
|
|
2139
|
+
plugin: id,
|
|
2140
|
+
hint: optionsFix(plugin?.id ?? id, left, `correct options.${id} in dokebi.yaml (npx dokebi plugins info ${plugin?.id ?? id} --json lists the option keys)`, id, checked.schema),
|
|
940
2141
|
});
|
|
941
2142
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}, dryRun);
|
|
2143
|
+
const installations = { ...state.installations };
|
|
2144
|
+
await this.#check(session.root, state.plugins, parseProjectConfig(configSource), installations, Object.keys(dependencyMap(session.pkg)), { id: '', requires: [] }, configRequired, warnings);
|
|
2145
|
+
const requirementsChanged = json(installations) !== json(state.installations);
|
|
2146
|
+
state.installations = installations;
|
|
2147
|
+
const paths = filePaths([
|
|
2148
|
+
[session.configFile, session.configSource, configSource],
|
|
2149
|
+
[EXAMPLE, exampleSource, example],
|
|
2150
|
+
]);
|
|
2151
|
+
this.#checkPaths(paths, targets.map(({ plugin, path }) => requestedPath(session.configSource, plugin, path)), dryRun, warnings);
|
|
2152
|
+
// Nothing set: nothing written (a requirement refreshed by the checks is written too).
|
|
2153
|
+
if (configSource !== session.configSource || requirementsChanged)
|
|
2154
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
2155
|
+
if (example !== undefined && example !== exampleSource)
|
|
2156
|
+
await tx.write(EXAMPLE, example);
|
|
2157
|
+
debug('Options unset', {
|
|
2158
|
+
plugins: [...byPlugin.keys()],
|
|
2159
|
+
removed: removed.length,
|
|
2160
|
+
dryRun,
|
|
2161
|
+
});
|
|
2162
|
+
return {
|
|
2163
|
+
...(await this.#finish(session, {
|
|
2164
|
+
changed: tx.paths,
|
|
2165
|
+
preserved: [],
|
|
2166
|
+
paths,
|
|
2167
|
+
...(configRequired.length ? { configRequired } : {}),
|
|
2168
|
+
...(warnings.length ? { warnings } : {}),
|
|
2169
|
+
}, dryRun)),
|
|
2170
|
+
file: session.configFile,
|
|
2171
|
+
removed,
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
catch (error) {
|
|
2175
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
2176
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
2177
|
+
throw tx.failure(error);
|
|
2178
|
+
}
|
|
2179
|
+
finally {
|
|
2180
|
+
await tx.close();
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* A config command may change only what it was asked for: a changed option path outside the
|
|
2185
|
+
* requested ones (an alias that reaches another section, a renamed or old section) is refused
|
|
2186
|
+
* before anything is written, or warned in a dry run so the plan shows it (PATH_MISMATCH).
|
|
2187
|
+
*/
|
|
2188
|
+
#checkPaths(paths, requested, dryRun, warnings) {
|
|
2189
|
+
const outside = outsideRequested(paths, requested);
|
|
2190
|
+
if (!outside.length)
|
|
2191
|
+
return;
|
|
2192
|
+
const asked = requested.map((path) => path.join('.'));
|
|
2193
|
+
const message = `The command would change ${outside.join(', ')}, which it was not asked for (asked: ${asked.join(', ')})`;
|
|
2194
|
+
const fix = 'name the options as the files spell them (npx dokebi config show lists them; the logger options are logging.<path>), then run it again';
|
|
2195
|
+
debug('Config change reaches options it was not asked for', { outside, asked, dryRun });
|
|
2196
|
+
if (!dryRun)
|
|
2197
|
+
throw new CliError('PATH_MISMATCH', message, {
|
|
2198
|
+
phase: 'plan',
|
|
2199
|
+
details: { requested: asked, outside, paths, fix },
|
|
2200
|
+
});
|
|
2201
|
+
warnings.push({ code: 'PATH_MISMATCH', hint: `${message}; ${fix}` });
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* `dokebi config diff [<id>] --write` (AI-M46): copies the options only in dokebi.yaml into
|
|
2205
|
+
* dokebi.example.yaml without their secrets, in one transaction. Paths both files set keep the
|
|
2206
|
+
* sample's value (a sample may differ) unless `values` (`--values`, Studio v2) writes
|
|
2207
|
+
* dokebi.yaml's; secrets inside a copied path are left out. The written sample is compared
|
|
2208
|
+
* again and refused if it would hold a secret (fail closed).
|
|
2209
|
+
*/
|
|
2210
|
+
async configSample(root, section, dryRun = false, prune = false, values = false) {
|
|
2211
|
+
const session = await this.#begin(root);
|
|
2212
|
+
const { tx } = session;
|
|
2213
|
+
try {
|
|
2214
|
+
const examplePath = await securePath(session.root, EXAMPLE);
|
|
2215
|
+
if (!(await exists(examplePath)))
|
|
2216
|
+
throw new CliError('EXAMPLE_MISSING', `No ${EXAMPLE} to write (it is the sample of dokebi.yaml without secrets)`, { phase: 'arguments', details: { root: session.root, file: EXAMPLE } });
|
|
2217
|
+
const exampleSource = await tx.read(EXAMPLE);
|
|
2218
|
+
const found = await sampleAdditions(session.root, session.configSource, exampleSource, section, session.configFile);
|
|
2219
|
+
let example = exampleSource;
|
|
2220
|
+
const bySection = new Map();
|
|
2221
|
+
const queue = (op, items) => {
|
|
2222
|
+
for (const { path, value } of items) {
|
|
2223
|
+
const [id, ...rest] = path;
|
|
2224
|
+
if (id === undefined)
|
|
2225
|
+
continue;
|
|
2226
|
+
// A whole section the sample lacks goes in by its keys: edit creates options.<id>
|
|
2227
|
+
// first, so a default at the section itself found it there and wrote nothing.
|
|
2228
|
+
const entries = rest.length === 0 &&
|
|
2229
|
+
value !== null &&
|
|
2230
|
+
typeof value === 'object' &&
|
|
2231
|
+
!Array.isArray(value)
|
|
2232
|
+
? Object.entries(value).map(([key, item]) => ({ op, path: [key], value: item }))
|
|
2233
|
+
: [{ op, path: rest, value }];
|
|
2234
|
+
bySection.set(id, [...(bySection.get(id) ?? []), ...entries]);
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
queue('default', found.additions);
|
|
2238
|
+
if (values)
|
|
2239
|
+
queue('set', found.changes);
|
|
2240
|
+
// A section the sample lacks is created (edit makes options.<id> a mapping first).
|
|
2241
|
+
for (const [id, changes] of bySection)
|
|
2242
|
+
example = applyConfigChanges(example, id, changes);
|
|
2243
|
+
// --prune removes from the sample only what nothing reads — a section no installed plugin,
|
|
2244
|
+
// settings declaration or core reads (an old options.dokebi-logger), and the keys a schema
|
|
2245
|
+
// does not declare (an option a plugin dropped) — and secrets with a value in it. A
|
|
2246
|
+
// declared option only the sample shows stays (a documented value is the sample's job).
|
|
2247
|
+
// dokebi.yaml is never touched.
|
|
2248
|
+
const pruned = [];
|
|
2249
|
+
if (prune) {
|
|
2250
|
+
const targets = new Map();
|
|
2251
|
+
const add = (id, path) => targets.set(id, [...(targets.get(id) ?? []), path]);
|
|
2252
|
+
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
2253
|
+
const options = parseProjectConfig(example).options;
|
|
2254
|
+
const read = readSections(session.state, [
|
|
2255
|
+
parseProjectConfig(session.configSource).options,
|
|
2256
|
+
options,
|
|
2257
|
+
]);
|
|
2258
|
+
const schemas = new Map();
|
|
2259
|
+
/** Keys of the sample the schema refuses as unknown (`additionalProperties`), dotted. */
|
|
2260
|
+
const unknownKeys = new Map();
|
|
2261
|
+
const schemaOf = async (id) => {
|
|
2262
|
+
if (schemas.has(id))
|
|
2263
|
+
return schemas.get(id);
|
|
2264
|
+
const plugin = session.state.plugins.find((item) => optionsKey(item) === id);
|
|
2265
|
+
const settings = (session.state.settings ?? []).includes(id);
|
|
2266
|
+
const checked = plugin
|
|
2267
|
+
? await validator.check(session.root, plugin, optionsOf(options, id))
|
|
2268
|
+
: settings
|
|
2269
|
+
? await validator.checkSettings(session.root, id, optionsOf(options, id))
|
|
2270
|
+
: undefined;
|
|
2271
|
+
const schema = checked?.schema && typeof checked.schema === 'object'
|
|
2272
|
+
? checked.schema
|
|
2273
|
+
: undefined;
|
|
2274
|
+
schemas.set(id, schema);
|
|
2275
|
+
unknownKeys.set(id, new Set(unknownOptionPaths(id, checked?.errors ?? []).paths));
|
|
2276
|
+
return schema;
|
|
2277
|
+
};
|
|
2278
|
+
// Leaked secrets: what config diff calls one (a schema mark, a name — unless the schema
|
|
2279
|
+
// marks it plain — or a URL with a password).
|
|
2280
|
+
for (const [id, ...rest] of found.prunable)
|
|
2281
|
+
if (id !== undefined)
|
|
2282
|
+
add(id, rest);
|
|
2283
|
+
for (const id of Object.keys(options)) {
|
|
2284
|
+
if (section !== undefined && id !== section)
|
|
2285
|
+
continue;
|
|
2286
|
+
if (!read.has(id)) {
|
|
2287
|
+
add(id, []);
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
const schema = await schemaOf(id);
|
|
2291
|
+
// Only a schema that lists its keys at the top can say which key it does not declare.
|
|
2292
|
+
if (!schema ||
|
|
2293
|
+
typeof schema.properties !== 'object' ||
|
|
2294
|
+
['$ref', 'allOf', 'anyOf', 'oneOf'].some((key) => key in schema))
|
|
2295
|
+
continue;
|
|
2296
|
+
// A key no property declares is pruned only when the schema also refuses it: an object
|
|
2297
|
+
// that leaves additionalProperties out takes any key (JSON Schema), so it is an option.
|
|
2298
|
+
for (const path of pruneUndeclared(example, id, null, schema).kept)
|
|
2299
|
+
if (unknownKeys.get(id)?.has(`${id}.${path}`))
|
|
2300
|
+
add(id, path.split('.'));
|
|
2301
|
+
}
|
|
2302
|
+
// Never what this same call copied in.
|
|
2303
|
+
const copied = new Set([...found.additions, ...(values ? found.changes : [])].map((item) => item.path.join('.')));
|
|
2304
|
+
for (const [id, paths] of targets)
|
|
2305
|
+
targets.set(id, paths.filter((path) => {
|
|
2306
|
+
const at = [id, ...path].join('.');
|
|
2307
|
+
return ![...copied].some((written) => written === at || written.startsWith(`${at}.`) || at.startsWith(`${written}.`));
|
|
2308
|
+
}));
|
|
2309
|
+
for (const [id, paths] of targets) {
|
|
2310
|
+
// A whole section first covers whatever was named inside it.
|
|
2311
|
+
const unique = paths.some((path) => path.length === 0)
|
|
2312
|
+
? [[]]
|
|
2313
|
+
: [...new Map(paths.map((path) => [path.join('.'), path])).values()];
|
|
2314
|
+
const removed = applyUnsetOptions(example, id, unique);
|
|
2315
|
+
example = removed.source;
|
|
2316
|
+
for (const path of removed.removed)
|
|
2317
|
+
pruned.push([id, ...path].join('.'));
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
const after = await sampleAdditions(session.root, session.configSource, example, section, session.configFile);
|
|
2321
|
+
if (after.result.exampleSecrets.length > (prune ? 0 : found.result.exampleSecrets.length))
|
|
2322
|
+
throw new CliError('SAMPLE_SECRET', `The copy would put a secret into ${EXAMPLE}; nothing was written`, { details: { paths: after.result.exampleSecrets } });
|
|
2323
|
+
if (example !== exampleSource)
|
|
2324
|
+
await tx.write(EXAMPLE, example);
|
|
2325
|
+
const paths = filePaths([[EXAMPLE, exampleSource, example]]);
|
|
2326
|
+
const written = found.additions.map((item) => item.path.join('.'));
|
|
2327
|
+
const valuesWritten = values ? found.changes.map((item) => item.path.join('.')) : undefined;
|
|
2328
|
+
debug('Sample settings written', {
|
|
2329
|
+
section: section ?? null,
|
|
2330
|
+
written: written.length,
|
|
2331
|
+
...(valuesWritten ? { valuesWritten: valuesWritten.length } : {}),
|
|
2332
|
+
dryRun,
|
|
2333
|
+
});
|
|
2334
|
+
const warnings = envWarnings(session.root);
|
|
2335
|
+
return {
|
|
2336
|
+
...(await this.#finish(session, { changed: tx.paths, preserved: [], paths, ...(warnings.length ? { warnings } : {}) }, dryRun)),
|
|
2337
|
+
file: session.configFile,
|
|
2338
|
+
written,
|
|
2339
|
+
pruned,
|
|
2340
|
+
...(valuesWritten ? { valuesWritten } : {}),
|
|
2341
|
+
diff: after.result,
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
catch (error) {
|
|
2345
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
2346
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
2347
|
+
throw tx.failure(error);
|
|
951
2348
|
}
|
|
952
2349
|
finally {
|
|
953
2350
|
await tx.close();
|
|
954
2351
|
}
|
|
955
2352
|
}
|
|
2353
|
+
/**
|
|
2354
|
+
* D-0145: keeps the migrations folder out of the project's code formatters, so an applied
|
|
2355
|
+
* migration keeps its bytes: a CLI-owned entry in .prettierignore, `!<folder>` in biome.json
|
|
2356
|
+
* files.includes. A formatter the CLI cannot configure (biome.jsonc with comments, an ESLint
|
|
2357
|
+
* config with formatting rules) is a warning with the fix. Only for a project with migrations
|
|
2358
|
+
* (dokebi-migrate installed, or the folder present).
|
|
2359
|
+
*/
|
|
2360
|
+
async #protectMigrations(session, plugins, warnings) {
|
|
2361
|
+
const { root, tx } = session;
|
|
2362
|
+
const migrations = await migrationsFolder(root);
|
|
2363
|
+
// R1 measurement: the registration the CLI writes, too (a reformatted copy is
|
|
2364
|
+
// MODIFIED_REGISTRATION); the migrations folder only in a project with migrations.
|
|
2365
|
+
const targets = [GENERATED_FILE];
|
|
2366
|
+
if (plugins.some((plugin) => plugin.id === 'dokebi-migrate') ||
|
|
2367
|
+
(await exists(await securePath(root, migrations))))
|
|
2368
|
+
targets.push(migrations);
|
|
2369
|
+
// Each config file is written once (the transaction refuses a second write of one path): the
|
|
2370
|
+
// texts are built over every target first.
|
|
2371
|
+
const texts = new Map();
|
|
2372
|
+
const text = async (path, fallback) => {
|
|
2373
|
+
const known = texts.get(path);
|
|
2374
|
+
if (known !== undefined)
|
|
2375
|
+
return known;
|
|
2376
|
+
return (await exists(await securePath(root, path))) ? await tx.read(path) : fallback;
|
|
2377
|
+
};
|
|
2378
|
+
for (const folder of targets)
|
|
2379
|
+
for (const use of await formatterUses(root, folder)) {
|
|
2380
|
+
if (use.covered)
|
|
2381
|
+
continue;
|
|
2382
|
+
if (use.tool === 'prettier') {
|
|
2383
|
+
texts.set(PRETTIER_IGNORE, prettierIgnoreText(await text(PRETTIER_IGNORE, ''), folder));
|
|
2384
|
+
debug('Kept out of prettier', { folder, config: use.config });
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
if (use.tool === 'biome') {
|
|
2388
|
+
const edited = biomeConfigText(await text(use.config, ''), folder, biomeMajor(session.pkg) === 1);
|
|
2389
|
+
if (edited !== undefined) {
|
|
2390
|
+
texts.set(use.config, edited);
|
|
2391
|
+
debug('Kept out of biome', { folder, config: use.config });
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
warnings.push({
|
|
2396
|
+
code: folder === GENERATED_FILE
|
|
2397
|
+
? 'FORMATTER_FORMATS_GENERATED'
|
|
2398
|
+
: 'FORMATTER_FORMATS_MIGRATIONS',
|
|
2399
|
+
path: use.config,
|
|
2400
|
+
hint: formatterFix(use, folder),
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
for (const [path, content] of texts)
|
|
2404
|
+
await tx.write(path, content);
|
|
2405
|
+
}
|
|
956
2406
|
/** Adds `.env` to .gitignore unless a line already ignores it (D-0065, D-0073). */
|
|
957
2407
|
async #ignoreEnv(session) {
|
|
958
2408
|
const { tx } = session;
|
|
@@ -962,9 +2412,10 @@ export class Project {
|
|
|
962
2412
|
await tx.write('.gitignore', `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}${ENV_FILE}\n`);
|
|
963
2413
|
}
|
|
964
2414
|
/**
|
|
965
|
-
* D-0073: a generated secret (48 random bytes,
|
|
966
|
-
* plan names (`envSecrets`), unless the CLI's
|
|
967
|
-
* never returned, printed or logged; .env is
|
|
2415
|
+
* D-0073 §2, deprecated (addendum 2; removed in 0.5): a generated secret (48 random bytes,
|
|
2416
|
+
* base64url) in .env for each variable an install plan names (`envSecrets`), unless the CLI's
|
|
2417
|
+
* environment or .env already has it. The value is never returned, printed or logged; .env is
|
|
2418
|
+
* git-ignored and read by dev, start and verify.
|
|
968
2419
|
*/
|
|
969
2420
|
async #generateEnvSecrets(session, requests) {
|
|
970
2421
|
if (!requests.length)
|
|
@@ -972,9 +2423,9 @@ export class Project {
|
|
|
972
2423
|
const { tx } = session;
|
|
973
2424
|
const envPath = await securePath(session.root, ENV_FILE);
|
|
974
2425
|
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
|
-
|
|
977
|
-
const present =
|
|
2426
|
+
// Never append to a file the apps could not read (ENV_FILE_INVALID names the line); read as
|
|
2427
|
+
// the apps read it (R4CA-11: a byte order mark is not part of the first name).
|
|
2428
|
+
const present = parseEnvText(original);
|
|
978
2429
|
let text = original;
|
|
979
2430
|
const generated = [];
|
|
980
2431
|
for (const { plugin, variable } of requests) {
|
|
@@ -998,9 +2449,10 @@ export class Project {
|
|
|
998
2449
|
return generated;
|
|
999
2450
|
}
|
|
1000
2451
|
/**
|
|
1001
|
-
* `dokebi dev-infra` (D-0065): compose.yaml for the database of connection `main
|
|
1002
|
-
* password in .
|
|
1003
|
-
* with a `db` service and an existing
|
|
2452
|
+
* `dokebi dev-infra` (D-0065): compose.yaml for the database of connection `main` and the URL
|
|
2453
|
+
* with a generated password in dokebi.yaml (no .env is written; a legacy .env password is reused
|
|
2454
|
+
* only when the YAML has no URL). An existing compose.yaml with a `db` service and an existing
|
|
2455
|
+
* password are kept; nothing is started here.
|
|
1004
2456
|
*/
|
|
1005
2457
|
async devInfra(root, options = {}) {
|
|
1006
2458
|
const session = await this.#begin(root);
|
|
@@ -1022,17 +2474,105 @@ export class Project {
|
|
|
1022
2474
|
});
|
|
1023
2475
|
const warnings = [];
|
|
1024
2476
|
let port = options.dbPort ?? docker.port;
|
|
2477
|
+
// D-0103: a MongoDB is generated as a single-node replica set; a kept file decides.
|
|
2478
|
+
let replicaSet = replicaSetOf(docker);
|
|
1025
2479
|
const composePath = await securePath(session.root, COMPOSE_FILE);
|
|
2480
|
+
let composeUnset = [];
|
|
1026
2481
|
if (await exists(composePath)) {
|
|
1027
|
-
const
|
|
2482
|
+
const composeSource = await tx.read(COMPOSE_FILE);
|
|
2483
|
+
// R2 Codex T2: a kept file may need variables dev-infra up does not set; the plan says
|
|
2484
|
+
// so before it names dev-infra up as the next step.
|
|
2485
|
+
const envPath = await securePath(session.root, ENV_FILE);
|
|
2486
|
+
composeUnset = composeUnsetVariables(composeSource, [
|
|
2487
|
+
// D-0165: the passwords of dokebi.yaml that dev-infra up passes.
|
|
2488
|
+
...Object.keys(connectionSecrets(parseProjectConfig(session.configSource).options)),
|
|
2489
|
+
...((await exists(envPath)) ? envKeys(await readFile(envPath, 'utf8')) : []),
|
|
2490
|
+
]);
|
|
2491
|
+
if (composeUnset.length)
|
|
2492
|
+
warnings.push({
|
|
2493
|
+
code: 'COMPOSE_VARIABLES_UNSET',
|
|
2494
|
+
path: COMPOSE_FILE,
|
|
2495
|
+
hint: composeVariablesHint(composeUnset),
|
|
2496
|
+
});
|
|
2497
|
+
const existing = parse(composeSource);
|
|
1028
2498
|
const service = existing?.services?.[DB_SERVICE];
|
|
1029
2499
|
if (!service)
|
|
1030
2500
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
2501
|
+
// The kept file decides the published port, unless --db-port changes it: then only that
|
|
2502
|
+
// port entry is rewritten (the rest of the user's file stays as it is).
|
|
2503
|
+
const published = composePublishedPort(composeSource);
|
|
2504
|
+
const portFix = `set the first ports entry of services.${DB_SERVICE} in ${COMPOSE_FILE} to "127.0.0.1:<host port>:${docker.port}" yourself, then run npx dokebi dev-infra --json again`;
|
|
2505
|
+
if (options.dbPort !== undefined && options.dbPort !== published) {
|
|
2506
|
+
const rewritten = withComposePort(composeSource, options.dbPort);
|
|
2507
|
+
if (rewritten === undefined)
|
|
2508
|
+
throw new CliError('COMPOSE_PORT_UNKNOWN', `--db-port ${options.dbPort} was not applied: ${COMPOSE_FILE} publishes the ${DB_SERVICE} port in a form the CLI does not rewrite`, { phase: 'arguments', details: { file: COMPOSE_FILE, fix: portFix } });
|
|
2509
|
+
if (await portInUse(options.dbPort)) {
|
|
2510
|
+
const alternative = await freePortAfter(options.dbPort);
|
|
2511
|
+
throw new CliError('PORT_IN_USE', `Port ${options.dbPort} on 127.0.0.1 is in use`, {
|
|
2512
|
+
phase: 'arguments',
|
|
2513
|
+
details: {
|
|
2514
|
+
port: options.dbPort,
|
|
2515
|
+
...(alternative ? { alternative } : {}),
|
|
2516
|
+
hint: `run again with --db-port ${alternative ?? '<free port>'}; compose.yaml and dokebi.yaml get the same port`,
|
|
2517
|
+
...portQuestion({
|
|
2518
|
+
what: 'Database',
|
|
2519
|
+
port: options.dbPort,
|
|
2520
|
+
alternative,
|
|
2521
|
+
flag: '--db-port',
|
|
2522
|
+
delegated: false,
|
|
2523
|
+
}),
|
|
2524
|
+
},
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
await tx.write(COMPOSE_FILE, rewritten);
|
|
2528
|
+
port = options.dbPort;
|
|
2529
|
+
debug('Kept compose.yaml gets another published port', { from: published, to: port });
|
|
2530
|
+
warnings.push({
|
|
2531
|
+
code: 'COMPOSE_PORT_CHANGED',
|
|
2532
|
+
path: COMPOSE_FILE,
|
|
2533
|
+
hint: `only the published port of services.${DB_SERVICE} changed (${published} to ${port}); npx dokebi dev-infra up recreates a running container on the new port and keeps its data volume`,
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
else if (published !== undefined) {
|
|
2537
|
+
port = published;
|
|
2538
|
+
warnings.push({ code: 'COMPOSE_KEPT', path: COMPOSE_FILE });
|
|
2539
|
+
}
|
|
2540
|
+
else {
|
|
2541
|
+
// Unreadable (a variable, a range): the port of the URL already in dokebi.yaml stays
|
|
2542
|
+
// (the catalog port for a first URL), and the warning says which one.
|
|
2543
|
+
const configured = main.options?.url;
|
|
2544
|
+
if (typeof configured === 'string' && !configured.startsWith(URL_PLACEHOLDER_PREFIX)) {
|
|
2545
|
+
let kept;
|
|
2546
|
+
try {
|
|
2547
|
+
kept = Number(new URL(configured).port) || undefined;
|
|
2548
|
+
}
|
|
2549
|
+
catch (error) {
|
|
2550
|
+
// The password-free reason only; the URL check below refuses it with its code.
|
|
2551
|
+
debug('The main database URL could not be read for its port', {
|
|
2552
|
+
code: error.code,
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
if (kept)
|
|
2556
|
+
port = kept;
|
|
2557
|
+
}
|
|
2558
|
+
warnings.push({ code: 'COMPOSE_KEPT', path: COMPOSE_FILE });
|
|
2559
|
+
warnings.push({
|
|
2560
|
+
code: 'COMPOSE_PORT_UNKNOWN',
|
|
2561
|
+
path: COMPOSE_FILE,
|
|
2562
|
+
hint: `the published port of services.${DB_SERVICE} could not be read, so the URL in dokebi.yaml uses port ${port}; if the file publishes another one, ${portFix}`,
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
if (replicaSet && !runsReplicaSet(service)) {
|
|
2566
|
+
// Written before D-0103: the URL stays a standalone one, or the driver would wait for
|
|
2567
|
+
// a replica set that is not there. Transactions need the regenerated file.
|
|
2568
|
+
debug('Kept compose.yaml runs a standalone MongoDB', { file: COMPOSE_FILE });
|
|
2569
|
+
replicaSet = undefined;
|
|
2570
|
+
warnings.push({
|
|
2571
|
+
code: 'DEV_INFRA_STANDALONE',
|
|
2572
|
+
path: COMPOSE_FILE,
|
|
2573
|
+
hint: `${COMPOSE_FILE} runs a standalone MongoDB, which refuses multi-document transactions. To make it the single-node replica set ${replicaSetOf(docker)}: npx dokebi dev-infra down, move ${COMPOSE_FILE} away (carry over your own changes later), npx dokebi dev-infra (a new file on the same port with --db-port ${port}; the password and the data volume are kept), npx dokebi dev-infra up`,
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
1036
2576
|
}
|
|
1037
2577
|
else {
|
|
1038
2578
|
// The container would not start on a taken port: refuse, and name a free one.
|
|
@@ -1057,42 +2597,65 @@ export class Project {
|
|
|
1057
2597
|
}
|
|
1058
2598
|
await tx.write(COMPOSE_FILE, composeText(composeProject(String(session.pkg.name ?? '')), variant.name, docker, port));
|
|
1059
2599
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
2600
|
+
// Reuse the YAML credential on reruns. Read legacy .env only when no YAML URL exists;
|
|
2601
|
+
// never rotate an existing database password or rewrite the user's legacy file.
|
|
2602
|
+
const currentUrl = main.options?.url;
|
|
2603
|
+
let password;
|
|
2604
|
+
if (typeof currentUrl === 'string' && !currentUrl.startsWith(URL_PLACEHOLDER_PREFIX)) {
|
|
2605
|
+
try {
|
|
2606
|
+
password = decodeURIComponent(new URL(currentUrl).password);
|
|
2607
|
+
}
|
|
2608
|
+
catch {
|
|
2609
|
+
// dokebi-check: allow missing-cause the URL error carries its input, which holds the database password
|
|
2610
|
+
throw new CliError('DEV_INFRA_CONFIG_INVALID', 'The main database URL is invalid');
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
else {
|
|
2614
|
+
const envPath = await securePath(session.root, ENV_FILE);
|
|
2615
|
+
if (await exists(envPath))
|
|
2616
|
+
password = envValue(await tx.read(ENV_FILE), PASSWORD_VARIABLE);
|
|
1066
2617
|
}
|
|
1067
|
-
|
|
2618
|
+
password ??= generatePassword();
|
|
2619
|
+
const url = connectionUrl(docker, password, port, replicaSet);
|
|
1068
2620
|
const current = main.options?.url;
|
|
1069
2621
|
if (current !== url) {
|
|
1070
2622
|
if (typeof current === 'string' && !current.startsWith(URL_PLACEHOLDER_PREFIX))
|
|
1071
2623
|
warnings.push({
|
|
1072
2624
|
code: 'URL_REPLACED',
|
|
1073
|
-
path:
|
|
2625
|
+
path: `${session.configFile}#options.dokebi-db.connections.main`,
|
|
1074
2626
|
});
|
|
1075
|
-
await tx.write(
|
|
2627
|
+
await tx.write(session.configFile, applyConfigChanges(session.configSource, 'dokebi-db', [
|
|
1076
2628
|
{ op: 'remove', path: ['connections', 'main', 'options', 'url'] },
|
|
1077
2629
|
{ op: 'default', path: ['connections', 'main', 'options', 'url'], value: url },
|
|
1078
2630
|
]));
|
|
1079
2631
|
}
|
|
1080
2632
|
await this.#ignoreEnv(session);
|
|
1081
|
-
debug('Development database prepared', {
|
|
2633
|
+
debug('Development database prepared', {
|
|
2634
|
+
variant: variant.name,
|
|
2635
|
+
port,
|
|
2636
|
+
image: docker.image,
|
|
2637
|
+
replicaSet: replicaSet ?? null,
|
|
2638
|
+
});
|
|
1082
2639
|
return {
|
|
1083
2640
|
...(await this.#finish(session, {
|
|
1084
2641
|
changed: tx.paths,
|
|
1085
|
-
|
|
2642
|
+
// What was kept as it is: a compose.yaml the run did not touch (a replaced URL or a
|
|
2643
|
+
// rewritten port is a change, not a kept file).
|
|
2644
|
+
preserved: warnings.some((warning) => warning.code === 'COMPOSE_KEPT')
|
|
2645
|
+
? [COMPOSE_FILE]
|
|
2646
|
+
: [],
|
|
1086
2647
|
...(warnings.length ? { warnings } : {}),
|
|
1087
2648
|
nextSteps: [
|
|
1088
2649
|
{
|
|
1089
|
-
command: '
|
|
1090
|
-
why:
|
|
2650
|
+
command: 'npx dokebi dev-infra up --json',
|
|
2651
|
+
why: composeUnset.length
|
|
2652
|
+
? `first give ${composeUnset.join(', ')} a default in ${COMPOSE_FILE} or a value in ${ENV_FILE} (COMPOSE_VARIABLES_UNSET); then it starts the ${variant.name} container (${docker.image}) on 127.0.0.1:${port}`
|
|
2653
|
+
: `start the ${variant.name} container (${docker.image}) on 127.0.0.1:${port}`,
|
|
1091
2654
|
},
|
|
1092
2655
|
{ command: 'npx dokebi verify --json', why: 'start the app against it and check it' },
|
|
1093
2656
|
{
|
|
1094
|
-
command: '
|
|
1095
|
-
why: 'stop it when you are done (
|
|
2657
|
+
command: 'npx dokebi dev-infra down --json',
|
|
2658
|
+
why: 'stop it when you are done (data volumes are retained)',
|
|
1096
2659
|
},
|
|
1097
2660
|
],
|
|
1098
2661
|
}, options.dryRun)),
|
|
@@ -1101,9 +2664,10 @@ export class Project {
|
|
|
1101
2664
|
variant: variant.name,
|
|
1102
2665
|
image: docker.image,
|
|
1103
2666
|
port,
|
|
2667
|
+
...(replicaSet ? { replicaSet } : {}),
|
|
1104
2668
|
url: redactUrl(url),
|
|
1105
2669
|
compose: COMPOSE_FILE,
|
|
1106
|
-
passwordIn:
|
|
2670
|
+
passwordIn: session.configFile,
|
|
1107
2671
|
},
|
|
1108
2672
|
};
|
|
1109
2673
|
}
|
|
@@ -1111,55 +2675,395 @@ export class Project {
|
|
|
1111
2675
|
await tx.close();
|
|
1112
2676
|
}
|
|
1113
2677
|
}
|
|
2678
|
+
/**
|
|
2679
|
+
* dokebi.yaml source with a generated random secret (32 bytes, base64url) at `<id>.<path>`: a
|
|
2680
|
+
* string, or a one-item list when the schema wants a list. Shared by `config secret` and the
|
|
2681
|
+
* `configSecrets` of `add`. Schema errors the source already had do not count against a shape.
|
|
2682
|
+
*/
|
|
2683
|
+
async #withGeneratedSecret(root, validator, plugin, source, path, secret = randomBytes(32).toString('base64url')) {
|
|
2684
|
+
// The plugin's section: options.<id>, options.logging for the logger host (D-0151).
|
|
2685
|
+
const id = targetSection(plugin);
|
|
2686
|
+
const where = `${id}.${path.join('.')}`;
|
|
2687
|
+
const key = (error) => `${error.keyword} ${error.instancePath}`;
|
|
2688
|
+
const options = (text) => optionsOf(parseProjectConfig(text).options, id);
|
|
2689
|
+
const before = new Set((await checkTarget(validator, root, plugin, options(source))).errors.map(key));
|
|
2690
|
+
for (const value of [secret, [secret]]) {
|
|
2691
|
+
const candidate = applySetOptions(source, id, [{ path: [...path], value }]);
|
|
2692
|
+
const checked = await checkTarget(validator, root, plugin, options(candidate));
|
|
2693
|
+
if (checked.errors.every((error) => error.keyword === 'required' || before.has(key(error))))
|
|
2694
|
+
return { source: candidate, list: Array.isArray(value) };
|
|
2695
|
+
}
|
|
2696
|
+
throw new CliError('INVALID_PLUGIN_OPTIONS', `${where} does not take a secret string`, {
|
|
2697
|
+
details: { plugin: id, path: where },
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
1114
2700
|
/**
|
|
1115
2701
|
* `dokebi config secret <plugin>.<path>`: a generated random secret (32 bytes, base64url) in
|
|
1116
2702
|
* 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)
|
|
2703
|
+
* logged; an existing value is kept (SECRET_KEPT) unless `rotate` (`--rotate`, AI-M05): a string
|
|
2704
|
+
* is replaced; a list gets the new secret first (it signs) and keeps the previous ones after it,
|
|
2705
|
+
* at most ROTATE_KEEP of them (older ones are dropped: SECRETS_DROPPED), so what the old ones
|
|
2706
|
+
* signed still verifies during the rotation. Secrets never pass the command line. `value`
|
|
2707
|
+
* (`--stdin`, D-0151 follow-up): a secret someone else issued, such as the token of a log
|
|
2708
|
+
* receiver (`logging.targets.studio.token`), written instead of a generated one; never returned
|
|
2709
|
+
* or logged either. A list item is named by its `name` (else `type`) or its index.
|
|
1118
2710
|
*/
|
|
1119
|
-
async configSecret(root, id, path, dryRun = false) {
|
|
2711
|
+
async configSecret(root, id, path, dryRun = false, rotate = false, value) {
|
|
1120
2712
|
const session = await this.#begin(root);
|
|
1121
2713
|
const { tx, state } = session;
|
|
1122
2714
|
try {
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
2715
|
+
const installed = sectionPlugin(state.plugins, id);
|
|
2716
|
+
// D-0155: an application settings section takes secrets like a plugin's.
|
|
2717
|
+
const plugin = installed ?? ((state.settings ?? []).includes(id) ? { settingsSection: id } : undefined);
|
|
2718
|
+
if (!plugin && id === RELOAD_OPTIONS_KEY)
|
|
2719
|
+
throw new CliError('INVALID_SET', 'options.reload holds no secrets', {
|
|
2720
|
+
details: { section: id, fix: 'set its values with npx dokebi config set reload.<key>=…' },
|
|
1127
2721
|
phase: 'arguments',
|
|
1128
2722
|
});
|
|
2723
|
+
// D-0155: a section nobody declares cannot say which value is a secret: refused.
|
|
2724
|
+
if (!plugin)
|
|
2725
|
+
throw undeclaredSectionError(undeclaredSection(id, await catalogPackageOf(), state.plugins.map((item) => item.id), state.settings ?? []));
|
|
1129
2726
|
const where = `${id}.${path.join('.')}`;
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
2727
|
+
const section = targetSection(plugin);
|
|
2728
|
+
// A path through a list must name an item that is there (INVALID_SET with the names).
|
|
2729
|
+
applySetOptions(session.configSource, section, [{ path: [...path], value: '' }]);
|
|
2730
|
+
// R4CA-5: an empty value (`token:`, `token: ''`, `secrets: []`) holds no secret: it is
|
|
2731
|
+
// filled, never kept (SECRET_KEPT) or rotated. R5 Codex T4 review: nor does the sample's
|
|
2732
|
+
// placeholder (`<set the postgres URL here …>`, what `config init` lists as placeholders).
|
|
2733
|
+
const found = optionAtPath(session.configSource, section, path);
|
|
2734
|
+
const current = found === null ||
|
|
2735
|
+
found === '' ||
|
|
2736
|
+
isPlaceholder(found) ||
|
|
2737
|
+
(Array.isArray(found) && (found.length === 0 || found.every(isPlaceholder)))
|
|
2738
|
+
? undefined
|
|
2739
|
+
: found;
|
|
2740
|
+
if (current !== undefined && !rotate)
|
|
1137
2741
|
return {
|
|
1138
|
-
...(await this.#finish(session, {
|
|
2742
|
+
...(await this.#finish(session, {
|
|
2743
|
+
changed: [],
|
|
2744
|
+
preserved: [],
|
|
2745
|
+
warnings: [
|
|
2746
|
+
...envWarnings(session.root),
|
|
2747
|
+
{
|
|
2748
|
+
code: 'SECRET_KEPT',
|
|
2749
|
+
path: where,
|
|
2750
|
+
hint: `npx dokebi config secret ${where} --rotate replaces it (a list keeps the previous ones after the new one)`,
|
|
2751
|
+
},
|
|
2752
|
+
],
|
|
2753
|
+
}, dryRun)),
|
|
2754
|
+
file: session.configFile,
|
|
1139
2755
|
secret: { path: where, list: Array.isArray(current), generated: false },
|
|
1140
2756
|
};
|
|
1141
|
-
|
|
2757
|
+
// A random string is never a working address: a URL (a placeholder or a rotated one) is
|
|
2758
|
+
// the user's, piped in (R5 Codex T4 review: filling the placeholder opened this path).
|
|
2759
|
+
if (value === undefined && URL_KEY.test(path.at(-1) ?? ''))
|
|
2760
|
+
throw new CliError('INVALID_SET', `${where} is an address, not a generated secret`, {
|
|
2761
|
+
details: {
|
|
2762
|
+
path: where,
|
|
2763
|
+
fix: `npx dokebi config secret ${where} --stdin < <file with the URL>`,
|
|
2764
|
+
},
|
|
2765
|
+
phase: 'arguments',
|
|
2766
|
+
});
|
|
1142
2767
|
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
2768
|
+
const rotated = current !== undefined && Array.isArray(current)
|
|
2769
|
+
? await this.#rotatedList(session.root, validator, plugin, session.configSource, path, current, ...(value === undefined ? [] : [value]))
|
|
2770
|
+
: undefined;
|
|
2771
|
+
const { source: chosen, list } = rotated ??
|
|
2772
|
+
(await this.#withGeneratedSecret(session.root, validator, plugin, session.configSource, path, ...(value === undefined ? [] : [value])));
|
|
2773
|
+
// R4CA-6: a list no kept-secrets list fits (the schema then took one new string or a
|
|
2774
|
+
// one-item list) lost every previous secret: said like any other drop.
|
|
2775
|
+
const dropped = rotated?.dropped ?? (Array.isArray(current) ? current.length : 0);
|
|
2776
|
+
const warnings = [
|
|
2777
|
+
...envWarnings(session.root),
|
|
2778
|
+
...(dropped ? [{ code: 'SECRETS_DROPPED', path: where, count: dropped }] : []),
|
|
2779
|
+
];
|
|
2780
|
+
const paths = filePaths([[session.configFile, session.configSource, chosen]]);
|
|
2781
|
+
this.#checkPaths(paths, [requestedPath(session.configSource, id, path)], dryRun, warnings);
|
|
2782
|
+
await tx.write(session.configFile, chosen);
|
|
2783
|
+
const outcome = {
|
|
2784
|
+
path: where,
|
|
2785
|
+
list,
|
|
2786
|
+
generated: value === undefined,
|
|
2787
|
+
...(value === undefined ? {} : { stdin: true }),
|
|
2788
|
+
...(current !== undefined ? { rotated: true, kept: rotated?.kept ?? 0, dropped } : {}),
|
|
2789
|
+
};
|
|
2790
|
+
debug(current === undefined ? 'Wrote a secret option' : 'Rotated a secret option', {
|
|
2791
|
+
path: where,
|
|
2792
|
+
list,
|
|
2793
|
+
source: value === undefined ? 'generated' : 'stdin',
|
|
2794
|
+
...(current !== undefined ? { kept: outcome.kept, dropped: outcome.dropped } : {}),
|
|
2795
|
+
});
|
|
2796
|
+
return {
|
|
2797
|
+
...(await this.#finish(session, {
|
|
2798
|
+
changed: tx.paths,
|
|
2799
|
+
preserved: [],
|
|
2800
|
+
paths,
|
|
2801
|
+
...(warnings.length ? { warnings } : {}),
|
|
2802
|
+
...(current !== undefined
|
|
2803
|
+
? {
|
|
2804
|
+
nextSteps: [
|
|
2805
|
+
{
|
|
2806
|
+
command: 'npx dokebi verify --json',
|
|
2807
|
+
why: `restart the app (dev restarts by itself on a dokebi.yaml change; a started app needs npx dokebi stop and start): it uses the new secret from then on. ${list
|
|
2808
|
+
? `What the previous secret${outcome.kept === 1 ? '' : 's'} signed keeps verifying while ${outcome.kept === 1 ? 'it stays' : 'they stay'} in the list`
|
|
2809
|
+
: 'What the old value signed or opened no longer works'}; the plugin AI.md says what a rotation changes. An environment override of this option, where the plugin has one, still wins`,
|
|
2810
|
+
},
|
|
2811
|
+
],
|
|
2812
|
+
}
|
|
2813
|
+
: {}),
|
|
2814
|
+
}, dryRun)),
|
|
2815
|
+
file: session.configFile,
|
|
2816
|
+
secret: outcome,
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
catch (error) {
|
|
2820
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
2821
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
2822
|
+
throw tx.failure(error);
|
|
2823
|
+
}
|
|
2824
|
+
finally {
|
|
2825
|
+
await tx.close();
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* `dokebi config init` (D-0163, R3 Claude T4 review): a fresh copy of a project (a clone, a CI
|
|
2830
|
+
* checkout) has no dokebi.yaml, which stays out of version control, and no command made one:
|
|
2831
|
+
* `dev-infra up` failed with INTERNAL_ERROR. Writes it from dokebi.example.yaml with a generated
|
|
2832
|
+
* secret for every required secret option the sample leaves out (the sample holds none), and
|
|
2833
|
+
* names the placeholders (`<set … here>`) still to fill. Never replaces an existing file.
|
|
2834
|
+
*/
|
|
2835
|
+
async configInit(root, dryRun = false) {
|
|
2836
|
+
root = await realpath(root);
|
|
2837
|
+
await projectStateText(root);
|
|
2838
|
+
const target = await configTarget(root);
|
|
2839
|
+
if (!target.inside)
|
|
2840
|
+
throw configOutsideProject(target);
|
|
2841
|
+
if (await exists(target.path))
|
|
2842
|
+
throw new CliError('CONFIG_EXISTS', `${target.file} already exists; config init writes a new one only`, {
|
|
2843
|
+
phase: 'arguments',
|
|
2844
|
+
details: {
|
|
2845
|
+
file: target.file,
|
|
2846
|
+
fix: 'nothing to do: npx dokebi config diff compares it with dokebi.example.yaml, npx dokebi config secret <id>.<path> fills a missing secret',
|
|
2847
|
+
},
|
|
2848
|
+
});
|
|
2849
|
+
let sample;
|
|
2850
|
+
try {
|
|
2851
|
+
sample = await readFile(await securePath(root, EXAMPLE), 'utf8');
|
|
2852
|
+
}
|
|
2853
|
+
catch (error) {
|
|
2854
|
+
if (!isMissing(error))
|
|
2855
|
+
throw new CliError('READ_FAILED', `Cannot read ${EXAMPLE}`, { cause: error });
|
|
2856
|
+
throw new CliError('EXAMPLE_MISSING', `No ${EXAMPLE} to start ${target.file} from`, {
|
|
2857
|
+
phase: 'arguments',
|
|
2858
|
+
details: {
|
|
2859
|
+
file: EXAMPLE,
|
|
2860
|
+
fix: `restore ${EXAMPLE} from version control (the CLI writes it and it belongs there)`,
|
|
2861
|
+
},
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
const session = await this.#begin(root, sample);
|
|
2865
|
+
const { tx, state } = session;
|
|
2866
|
+
try {
|
|
2867
|
+
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
2868
|
+
let source = session.configSource;
|
|
2869
|
+
const generatedSecrets = [];
|
|
2870
|
+
const generate = async (item, path) => {
|
|
2871
|
+
const section = targetSection(item);
|
|
2872
|
+
// As config secret (R4CA-5): an empty value (`token:`, `''`, `[]`) holds no secret.
|
|
2873
|
+
const found = optionAtPath(source, section, path);
|
|
2874
|
+
if (found !== undefined &&
|
|
2875
|
+
found !== null &&
|
|
2876
|
+
found !== '' &&
|
|
2877
|
+
!(Array.isArray(found) && !found.length))
|
|
2878
|
+
return;
|
|
2879
|
+
source = (await this.#withGeneratedSecret(session.root, validator, item, source, path))
|
|
2880
|
+
.source;
|
|
2881
|
+
const option = `${section}.${path.join('.')}`;
|
|
2882
|
+
generatedSecrets.push({
|
|
2883
|
+
plugin: 'settingsSection' in item ? item.settingsSection : item.id,
|
|
2884
|
+
option,
|
|
2885
|
+
file: session.configFile,
|
|
2886
|
+
});
|
|
2887
|
+
debug('config init generated a secret option', { option });
|
|
2888
|
+
};
|
|
2889
|
+
// A plugin's secrets are the ones `add` generates: its install plan's configSecrets (a
|
|
2890
|
+
// secret the user supplies, such as an SMTP password, is never made up).
|
|
2891
|
+
const projectPackages = Object.keys(await this.#installedDependencies(session.root));
|
|
2892
|
+
for (const plugin of state.plugins) {
|
|
2893
|
+
const plan = await planLifecycle({
|
|
2894
|
+
base: session.root,
|
|
2895
|
+
plugin,
|
|
2896
|
+
options: parseProjectConfig(source).options,
|
|
2897
|
+
plugins: state.plugins,
|
|
2898
|
+
projectPackages,
|
|
2899
|
+
}, 'planInstall');
|
|
2900
|
+
for (const path of plan.configSecrets)
|
|
2901
|
+
await generate(plugin, path);
|
|
2902
|
+
}
|
|
2903
|
+
// An application settings section (D-0155) has no install plan: its required options that
|
|
2904
|
+
// are secrets by schema mark or name (as `config show` hides them).
|
|
2905
|
+
for (const settingsSection of state.settings ?? []) {
|
|
2906
|
+
const item = { settingsSection };
|
|
2907
|
+
const checked = await checkTarget(validator, session.root, item, optionsOf(parseProjectConfig(source).options, settingsSection));
|
|
2908
|
+
for (const issue of checked.errors)
|
|
2909
|
+
if (issue.keyword === 'required' && issue.missingProperty) {
|
|
2910
|
+
const path = [...issuePathOf(issue.instancePath), issue.missingProperty];
|
|
2911
|
+
if (secretInSchema(checked.schema, path))
|
|
2912
|
+
await generate(item, path);
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
const placeholders = placeholderPaths(parseProjectConfig(source).options);
|
|
2916
|
+
debug('config init writes the settings file from the sample', {
|
|
2917
|
+
file: session.configFile,
|
|
2918
|
+
secrets: generatedSecrets.length,
|
|
2919
|
+
placeholders: placeholders.length,
|
|
2920
|
+
});
|
|
2921
|
+
await tx.write(session.configFile, source);
|
|
2922
|
+
const databaseUrl = placeholders.some((path) => /^dokebi-db\..*\.url$/.test(path));
|
|
2923
|
+
const nextSteps = [
|
|
2924
|
+
...(databaseUrl
|
|
2925
|
+
? [
|
|
2926
|
+
{
|
|
2927
|
+
command: 'npx dokebi dev-infra --json',
|
|
2928
|
+
why: 'writes the development database URL into the placeholder (and compose.yaml when the project has none); then npx dokebi dev-infra up starts the database',
|
|
2929
|
+
},
|
|
2930
|
+
]
|
|
2931
|
+
: []),
|
|
2932
|
+
...(placeholders.some((path) => !(databaseUrl && /^dokebi-db\..*\.url$/.test(path)))
|
|
2933
|
+
? [
|
|
2934
|
+
{
|
|
2935
|
+
command: 'npx dokebi config show --json',
|
|
2936
|
+
why: `fill the other placeholders with your file editor or ask the user for them: ${placeholders.filter((path) => !/^dokebi-db\..*\.url$/.test(path) || !databaseUrl).join(', ')}`,
|
|
2937
|
+
},
|
|
2938
|
+
]
|
|
2939
|
+
: []),
|
|
2940
|
+
{
|
|
2941
|
+
command: 'npx dokebi doctor --json',
|
|
2942
|
+
why: "checks the new file against every plugin; the project's README says how its database is migrated and seeded",
|
|
2943
|
+
},
|
|
2944
|
+
];
|
|
2945
|
+
return {
|
|
2946
|
+
...(await this.#finish(session, {
|
|
2947
|
+
changed: tx.paths,
|
|
2948
|
+
preserved: [],
|
|
2949
|
+
...(generatedSecrets.length ? { generatedSecrets } : {}),
|
|
2950
|
+
nextSteps,
|
|
2951
|
+
}, dryRun)),
|
|
2952
|
+
file: session.configFile,
|
|
2953
|
+
generatedSecrets,
|
|
2954
|
+
placeholders,
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
catch (error) {
|
|
2958
|
+
throw tx.failure(error);
|
|
2959
|
+
}
|
|
2960
|
+
finally {
|
|
2961
|
+
await tx.close();
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* dokebi.yaml source with a new generated secret first in the list at `<id>.<path>` and the
|
|
2966
|
+
* previous ones after it (at most ROTATE_KEEP; fewer when the schema allows fewer items).
|
|
2967
|
+
* Undefined when no such list passes the schema (the caller then writes a single new secret).
|
|
2968
|
+
*/
|
|
2969
|
+
async #rotatedList(root, validator, plugin, source, path, current, secret = randomBytes(32).toString('base64url')) {
|
|
2970
|
+
const id = targetSection(plugin);
|
|
2971
|
+
const key = (error) => `${error.keyword} ${error.instancePath}`;
|
|
2972
|
+
const options = (text) => optionsOf(parseProjectConfig(text).options, id);
|
|
2973
|
+
const before = new Set((await checkTarget(validator, root, plugin, options(source))).errors.map(key));
|
|
2974
|
+
for (let keep = Math.min(current.length, ROTATE_KEEP); keep >= 0; keep--) {
|
|
2975
|
+
const candidate = applySetOptions(source, id, [
|
|
2976
|
+
{ path: [...path], value: [secret, ...current.slice(0, keep)] },
|
|
2977
|
+
]);
|
|
2978
|
+
const checked = await checkTarget(validator, root, plugin, options(candidate));
|
|
2979
|
+
if (checked.errors.every((error) => error.keyword === 'required' || before.has(key(error))))
|
|
2980
|
+
return { source: candidate, list: true, kept: keep, dropped: current.length - keep };
|
|
2981
|
+
debug('Rotated secret list refused by the schema; keeping fewer', {
|
|
2982
|
+
path: `${id}.${path.join('.')}`,
|
|
2983
|
+
keep,
|
|
2984
|
+
errors: checked.errors.map(key),
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
return undefined;
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* User code a recipe writes (D-0076 §6, `recipe blog --admin login`), in one transaction under
|
|
2991
|
+
* the project lock. A new file is written; a file with the same content is left alone; a file
|
|
2992
|
+
* marked `replaceTemplate` is replaced only while it still is the untouched template its plugin
|
|
2993
|
+
* installed (the hash `state.files` recorded); anything else is RECIPE_FILE_CONFLICT. `edits`
|
|
2994
|
+
* change existing files (they return the text unchanged when already applied). `config` sets
|
|
2995
|
+
* values of a dokebi.yaml section no plugin owns; a `secret` value is generated (never returned
|
|
2996
|
+
* or logged) and an existing one is kept. Nothing is written when nothing changes.
|
|
2997
|
+
*/
|
|
2998
|
+
async writeRecipeFiles(root, request) {
|
|
2999
|
+
const session = await this.#begin(root);
|
|
3000
|
+
const { tx, state } = session;
|
|
3001
|
+
try {
|
|
3002
|
+
const written = [];
|
|
3003
|
+
const kept = [];
|
|
3004
|
+
for (const file of request.files) {
|
|
3005
|
+
await tx.capture(file.path);
|
|
3006
|
+
const target = await securePath(session.root, file.path);
|
|
3007
|
+
const current = await fingerprint(target);
|
|
3008
|
+
if (current === hash(file.content)) {
|
|
3009
|
+
kept.push(file.path);
|
|
3010
|
+
continue;
|
|
3011
|
+
}
|
|
3012
|
+
if (current !== null && !(file.replaceTemplate && current === state.files[file.path]?.hash))
|
|
3013
|
+
throw new CliError('RECIPE_FILE_CONFLICT', `${file.path} exists and is not what the recipe writes`, {
|
|
3014
|
+
details: {
|
|
3015
|
+
path: file.path,
|
|
3016
|
+
fix: `${file.path} was edited or made elsewhere: the recipe never overwrites it. Move it away and run the same recipe command again, or write the change yourself`,
|
|
3017
|
+
},
|
|
3018
|
+
phase: 'plan',
|
|
3019
|
+
});
|
|
3020
|
+
const directory = dirname(file.path);
|
|
3021
|
+
if (directory !== '.')
|
|
3022
|
+
await tx.ensureDirectory(directory);
|
|
3023
|
+
await tx.write(file.path, file.content);
|
|
3024
|
+
written.push(file.path);
|
|
3025
|
+
}
|
|
3026
|
+
for (const change of request.edits ?? []) {
|
|
3027
|
+
const before = await tx.read(change.path);
|
|
3028
|
+
const after = change.edit(before);
|
|
3029
|
+
if (after === before)
|
|
3030
|
+
kept.push(change.path);
|
|
3031
|
+
else {
|
|
3032
|
+
await tx.write(change.path, after);
|
|
3033
|
+
written.push(change.path);
|
|
1152
3034
|
}
|
|
1153
3035
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
|
|
3036
|
+
let configSource = session.configSource;
|
|
3037
|
+
const generatedSecrets = [];
|
|
3038
|
+
if (request.config) {
|
|
3039
|
+
const { section, values } = request.config;
|
|
3040
|
+
if (state.plugins.some((plugin) => plugin.id === section))
|
|
3041
|
+
throw new CliError('INVALID_SET', `${section} is a plugin's options, not a recipe section`);
|
|
3042
|
+
for (const { key, value } of values) {
|
|
3043
|
+
if (optionAt(configSource, section, [key]) !== undefined)
|
|
3044
|
+
continue;
|
|
3045
|
+
// A value of undefined is a secret: generated here, never returned or logged.
|
|
3046
|
+
configSource = applySetOptions(configSource, section, [
|
|
3047
|
+
{ path: [key], value: value ?? generatePassword() },
|
|
3048
|
+
]);
|
|
3049
|
+
if (value === undefined)
|
|
3050
|
+
generatedSecrets.push({
|
|
3051
|
+
plugin: section,
|
|
3052
|
+
option: `${section}.${key}`,
|
|
3053
|
+
file: session.configFile,
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
if (configSource !== session.configSource) {
|
|
3057
|
+
await tx.write(session.configFile, configSource);
|
|
3058
|
+
written.push(session.configFile);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
debug('Recipe files written', { written, kept, secrets: generatedSecrets.length });
|
|
1160
3062
|
return {
|
|
1161
|
-
...(await this.#finish(session, { changed: tx.paths, preserved: [] }
|
|
1162
|
-
|
|
3063
|
+
...(await this.#finish(session, { changed: tx.paths, preserved: [] })),
|
|
3064
|
+
...(generatedSecrets.length ? { generatedSecrets } : {}),
|
|
3065
|
+
written,
|
|
3066
|
+
kept,
|
|
1163
3067
|
};
|
|
1164
3068
|
}
|
|
1165
3069
|
finally {
|
|
@@ -1174,11 +3078,32 @@ export class Project {
|
|
|
1174
3078
|
throw new CliError('CORE_UPGRADE_REQUIRED', 'Dokebi YAML projects require @dokebi/core 0.2.0 or newer');
|
|
1175
3079
|
return pkg.version;
|
|
1176
3080
|
}
|
|
1177
|
-
/**
|
|
1178
|
-
|
|
3081
|
+
/**
|
|
3082
|
+
* Takes the project lock and reads the common state every mutation needs. `fresh`: the text a
|
|
3083
|
+
* missing settings file starts from (config init, D-0163); every other command needs the file.
|
|
3084
|
+
*/
|
|
3085
|
+
async #begin(root, fresh) {
|
|
1179
3086
|
root = await realpath(root);
|
|
1180
3087
|
// Do not create control files in arbitrary non-Dokebi projects.
|
|
1181
|
-
await
|
|
3088
|
+
await projectStateText(root);
|
|
3089
|
+
// M50: every changing command works on the settings file the app reads — DOKEBI_CONFIG when
|
|
3090
|
+
// set (from the project folder), else dokebi.yaml. Never a file outside the project
|
|
3091
|
+
// (UNSAFE_PATH, as a linked dokebi.yaml), never dokebi.yaml in place of a missing named file.
|
|
3092
|
+
const settings = await configTarget(root);
|
|
3093
|
+
if (!settings.inside)
|
|
3094
|
+
throw configOutsideProject(settings);
|
|
3095
|
+
if (settings.fromEnvironment && !(await exists(settings.path)))
|
|
3096
|
+
throw new CliError('READ_FAILED', `Cannot read ${settings.file}, the settings file DOKEBI_CONFIG names`, {
|
|
3097
|
+
phase: 'arguments',
|
|
3098
|
+
details: {
|
|
3099
|
+
file: settings.file,
|
|
3100
|
+
fix: 'point DOKEBI_CONFIG at the settings file the app reads, or unset it to use dokebi.yaml',
|
|
3101
|
+
},
|
|
3102
|
+
});
|
|
3103
|
+
const missing = !settings.fromEnvironment && !(await exists(settings.path));
|
|
3104
|
+
if (missing && fresh === undefined)
|
|
3105
|
+
throw notAProject(root, CONFIG_FILE);
|
|
3106
|
+
const configFile = settings.file;
|
|
1182
3107
|
const workspaceRoot = await workspaceRootOf(root);
|
|
1183
3108
|
const tx = await Transaction.begin(root);
|
|
1184
3109
|
try {
|
|
@@ -1189,40 +3114,210 @@ export class Project {
|
|
|
1189
3114
|
? 'CONFIG_CONFLICT'
|
|
1190
3115
|
: 'MIGRATION_REQUIRED', 'Run dokebi migrate after resolving conflicting configuration files');
|
|
1191
3116
|
}
|
|
1192
|
-
|
|
3117
|
+
let configSource;
|
|
3118
|
+
if (missing && fresh !== undefined) {
|
|
3119
|
+
await tx.capture(configFile);
|
|
3120
|
+
configSource = fresh;
|
|
3121
|
+
}
|
|
3122
|
+
else
|
|
3123
|
+
configSource = await tx.read(configFile);
|
|
1193
3124
|
parseProjectConfig(configSource);
|
|
3125
|
+
// D-0154: what the CLI writes into dokebi.yaml and the example marks reloadable options:
|
|
3126
|
+
// with the schemas of the installed plugins and those staged in this session.
|
|
3127
|
+
const schemas = new Map();
|
|
3128
|
+
tx.transformText = async (path, text) => {
|
|
3129
|
+
if (path !== CONFIG_FILE && path !== configFile && path !== EXAMPLE)
|
|
3130
|
+
return text;
|
|
3131
|
+
const known = await reloadSchemas(root, state.plugins, state.settings ?? []);
|
|
3132
|
+
for (const [section, schema] of schemas)
|
|
3133
|
+
known.set(section, schema);
|
|
3134
|
+
return markReloadable(text, known);
|
|
3135
|
+
};
|
|
1194
3136
|
const required = await readRequiredPlugins(root);
|
|
1195
3137
|
const pkg = parseObject(await tx.read('package.json'), 'package.json');
|
|
1196
3138
|
validateProjectPackage(pkg);
|
|
1197
3139
|
if (hash(await tx.read(GENERATED)) !== state.generatedHash)
|
|
1198
|
-
throw
|
|
3140
|
+
throw modifiedRegistration();
|
|
1199
3141
|
if (!workspaceRoot)
|
|
1200
|
-
return { root, tx, state, configSource, required, pkg };
|
|
3142
|
+
return { root, tx, schemas, state, configSource, configFile, required, pkg };
|
|
1201
3143
|
const workspace = {
|
|
1202
3144
|
root: workspaceRoot,
|
|
1203
3145
|
packages: await workspacePackages(workspaceRoot),
|
|
1204
3146
|
lock: await readOptional(join(workspaceRoot, 'package-lock.json')),
|
|
1205
3147
|
};
|
|
1206
|
-
return { root, tx, state, configSource, required, pkg, workspace };
|
|
3148
|
+
return { root, tx, schemas, state, configSource, configFile, required, pkg, workspace };
|
|
1207
3149
|
}
|
|
1208
3150
|
catch (error) {
|
|
1209
3151
|
await tx.close();
|
|
1210
3152
|
throw error;
|
|
1211
3153
|
}
|
|
1212
3154
|
}
|
|
1213
|
-
async #stage(session, pkg, dependencies, spec) {
|
|
3155
|
+
async #stage(session, pkg, dependencies, spec, companions = []) {
|
|
3156
|
+
try {
|
|
3157
|
+
return await this.#stageProject(session, pkg, dependencies, spec, companions);
|
|
3158
|
+
}
|
|
3159
|
+
catch (error) {
|
|
3160
|
+
// Ctrl+C during the staging install: npm got the signal and failed; nothing was applied.
|
|
3161
|
+
if (session.tx.interrupted)
|
|
3162
|
+
throw interruptedError(session.tx.interrupted, error);
|
|
3163
|
+
const mismatch = await this.#registryMismatch(session, error);
|
|
3164
|
+
throw mismatch === error ? await this.#peerConflict(session, spec, error) : mismatch;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* npm's ERESOLVE for a named version (`add <package>@<version>`, `upgrade --to <version>`)
|
|
3169
|
+
* whose peers do not accept what the project has installed (a session that needs a newer
|
|
3170
|
+
* @dokebi/web): the error keeps npm's words and gets `details.conflicts` and a `fix` that
|
|
3171
|
+
* upgrades the installed package first. Anything else is returned as it was.
|
|
3172
|
+
*/
|
|
3173
|
+
async #peerConflict(session, spec, error) {
|
|
3174
|
+
if (!(error instanceof CliError) || error.code !== 'NPM_FAILED' || !spec)
|
|
3175
|
+
return error;
|
|
3176
|
+
const details = (error.details ?? {});
|
|
3177
|
+
const match = /^((?:@[^/@]+\/)?[^@]+)@(.+)$/.exec(spec);
|
|
3178
|
+
if (details.npmCode !== 'ERESOLVE' || !match?.[1] || !match[2] || !validVersion(match[2]))
|
|
3179
|
+
return error;
|
|
3180
|
+
const [, name, version] = match;
|
|
3181
|
+
const found = await fetchPackument(name, session.root);
|
|
3182
|
+
const manifest = found.status === 'found' ? found.versions[version] : undefined;
|
|
3183
|
+
if (!manifest)
|
|
3184
|
+
return error;
|
|
3185
|
+
const constraints = this.#constraints(session, await this.coreVersion(session.root), name);
|
|
3186
|
+
const conflicts = versionConflicts(version, manifest, constraints);
|
|
3187
|
+
debug('Peer ranges of the named version checked after ERESOLVE', { spec, conflicts });
|
|
3188
|
+
if (!conflicts.length)
|
|
3189
|
+
return error;
|
|
3190
|
+
return new CliError('NPM_FAILED', `${error.message}: ${describeConflicts(name, version, conflicts)}`, {
|
|
3191
|
+
cause: error,
|
|
3192
|
+
details: {
|
|
3193
|
+
...Object.fromEntries(Object.entries(details).filter(([key]) => key !== 'hint')),
|
|
3194
|
+
conflicts,
|
|
3195
|
+
fix: conflictFix(conflicts, constraints, 'repeat this command'),
|
|
3196
|
+
},
|
|
3197
|
+
...(error.phase ? { phase: error.phase } : {}),
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* D-0138: npm failed in staging. When packages this project has installed (exact registry
|
|
3202
|
+
* versions) are missing from the registry npm uses now, that is the cause, whatever npm says
|
|
3203
|
+
* (ERESOLVE "Found: @dokebi/core@undefined" in AI-M27): REGISTRY_MISMATCH names them, the
|
|
3204
|
+
* registry and where the lock says they came from, so nobody retries with --legacy-peer-deps
|
|
3205
|
+
* (which installed @dokebi/module 0.4.0 from the public registry next to the run's 0.4.2 core).
|
|
3206
|
+
* Any other failure is returned as it was.
|
|
3207
|
+
*/
|
|
3208
|
+
async #registryMismatch(session, error) {
|
|
3209
|
+
if (!(error instanceof CliError) || error.code !== 'NPM_FAILED')
|
|
3210
|
+
return error;
|
|
3211
|
+
const details = (error.details ?? {});
|
|
3212
|
+
if (details.network === true || details.timedOut === true)
|
|
3213
|
+
return error;
|
|
3214
|
+
let lock = {};
|
|
3215
|
+
const lockText = session.workspace
|
|
3216
|
+
? session.workspace.lock
|
|
3217
|
+
: (await exists(await securePath(session.tx.root, 'package-lock.json')))
|
|
3218
|
+
? await session.tx.read('package-lock.json')
|
|
3219
|
+
: undefined;
|
|
3220
|
+
if (typeof lockText === 'string') {
|
|
3221
|
+
try {
|
|
3222
|
+
lock = object(JSON.parse(lockText).packages ?? {}, 'lock packages');
|
|
3223
|
+
}
|
|
3224
|
+
catch (cause) {
|
|
3225
|
+
debug('package-lock.json not readable; installed origins unknown', {
|
|
3226
|
+
error: describeError(cause),
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
const installed = DEPENDENCY_SECTIONS.flatMap((section) => Object.entries(object(session.pkg[section] ?? {}, section)).flatMap(([name, spec]) => {
|
|
3231
|
+
if (!name.startsWith('@dokebi/') || session.workspace?.packages.has(name))
|
|
3232
|
+
return [];
|
|
3233
|
+
if (typeof spec !== 'string' || validVersion(spec) !== spec)
|
|
3234
|
+
return [];
|
|
3235
|
+
const entry = lock[`node_modules/${name}`];
|
|
3236
|
+
const resolved = typeof entry?.resolved === 'string' ? entry.resolved : undefined;
|
|
3237
|
+
return [{ package: name, version: spec, ...(resolved ? { resolved } : {}) }];
|
|
3238
|
+
}));
|
|
3239
|
+
const missing = await missingOnRegistry(installed, session.root);
|
|
3240
|
+
debug('Installed packages checked against the registry after an npm failure', {
|
|
3241
|
+
checked: installed.length,
|
|
3242
|
+
missing,
|
|
3243
|
+
});
|
|
3244
|
+
if (!missing.length)
|
|
3245
|
+
return error;
|
|
3246
|
+
// The core first (every add picks its version for it), then at most two more by name.
|
|
3247
|
+
const named = [...missing].sort((a, b) => a.package === '@dokebi/core' ? -1 : b.package === '@dokebi/core' ? 1 : 0);
|
|
3248
|
+
const list = `${named
|
|
3249
|
+
.slice(0, 3)
|
|
3250
|
+
.map((item) => `${item.package} ${item.version}`)
|
|
3251
|
+
.join(', ')}${named.length > 3 ? ` and ${named.length - 3} more (details.missing)` : ''}`;
|
|
3252
|
+
const registries = [...new Set(missing.map((item) => item.registry))].join(', ');
|
|
3253
|
+
const origins = [
|
|
3254
|
+
...new Set(named.flatMap((item) => (item.installedFrom ? [item.installedFrom] : []))),
|
|
3255
|
+
];
|
|
3256
|
+
return new CliError('REGISTRY_MISMATCH', `npm now installs from ${registries}, which does not have ${list} that this project has installed${origins.length ? ` (package-lock.json: ${origins[0]})` : ''}`, {
|
|
3257
|
+
cause: error,
|
|
3258
|
+
details: {
|
|
3259
|
+
// npm's words stay; its generic ERESOLVE hint gives way to the fix below.
|
|
3260
|
+
...Object.fromEntries(Object.entries(details).filter(([key]) => key !== 'hint')),
|
|
3261
|
+
stage: 'staging',
|
|
3262
|
+
missing,
|
|
3263
|
+
fix: `run dokebi with the npm configuration that installed this project: npm config get @dokebi:registry in the project folder must name the registry in package-lock.json's resolved URLs (the project .npmrc, npm_config_userconfig or npm_config_@dokebi:registry), then run the same command again. Do not retry with --legacy-peer-deps or --force: npm would install packages from ${registries} next to a core that registry does not have.`,
|
|
3264
|
+
},
|
|
3265
|
+
phase: 'install',
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
async #stageProject(session, pkg, dependencies, spec, companions = []) {
|
|
1214
3269
|
const { tx, workspace } = session;
|
|
1215
3270
|
const staging = join(tx.directory, 'npm');
|
|
1216
3271
|
await mkdir(staging);
|
|
1217
3272
|
// npm in staging reads the project's .npmrc too (the registry resolution did, D-0065).
|
|
1218
3273
|
await stageNpmConfig(workspace?.root ?? session.root, staging);
|
|
1219
3274
|
if (!workspace) {
|
|
3275
|
+
// Tarballs the project names by a relative path (`file:vendor/x.tgz`): the same path in
|
|
3276
|
+
// staging, so npm resolves them there and the files it writes back keep them relative.
|
|
3277
|
+
const copy = async (path) => {
|
|
3278
|
+
// Checked before anything is created: no folder is made for a path that is refused.
|
|
3279
|
+
const source = await securePath(tx.root, path);
|
|
3280
|
+
// One the folder lacks (an ignored vendor/): npm in staging names it with NPM_FAILED, as
|
|
3281
|
+
// it does an absolute path (R6-cli-2: a bare ENOENT of the copy was INTERNAL_ERROR).
|
|
3282
|
+
if (!(await exists(source))) {
|
|
3283
|
+
debug('A project tarball is missing; the staging install reports it', { path });
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3286
|
+
// A folder (or another non-file) at the tarball's path: npm cannot install it either, and
|
|
3287
|
+
// the copy failed with a bare EISDIR or ENOTSUP, an INTERNAL_ERROR (R7-cli-2). securePath
|
|
3288
|
+
// has refused a symbolic link already.
|
|
3289
|
+
if (!(await lstat(source)).isFile()) {
|
|
3290
|
+
debug('A project tarball path is not a file; refused', { path });
|
|
3291
|
+
throw new CliError('UNSUPPORTED_PROJECT', `A file: tarball is not a file: ${path}`, {
|
|
3292
|
+
details: {
|
|
3293
|
+
path,
|
|
3294
|
+
fix: `put the package's .tgz at ${path}, or change its dependency in package.json`,
|
|
3295
|
+
},
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
await mkdir(dirname(join(staging, path)), { recursive: true });
|
|
3299
|
+
await copyFile(source, join(staging, path));
|
|
3300
|
+
};
|
|
3301
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
3302
|
+
const listed = (section === 'dependencies' ? dependencies : pkg[section]) ?? {};
|
|
3303
|
+
for (const value of Object.values(listed)) {
|
|
3304
|
+
const path = typeof value === 'string' ? projectTarball(value) : undefined;
|
|
3305
|
+
if (path === undefined)
|
|
3306
|
+
continue;
|
|
3307
|
+
await copy(path);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
// A tarball named on the command line inside the project, made relative by projectSpec.
|
|
3311
|
+
const named = spec === undefined ? undefined : projectTarball(spec);
|
|
3312
|
+
if (named !== undefined)
|
|
3313
|
+
await copy(named);
|
|
1220
3314
|
await writeFile(join(staging, 'package.json'), json({ ...pkg, dependencies }));
|
|
1221
3315
|
const lock = await securePath(tx.root, 'package-lock.json');
|
|
1222
3316
|
if (await exists(lock))
|
|
1223
3317
|
await writeFile(join(staging, 'package-lock.json'), await tx.read('package-lock.json'));
|
|
1224
3318
|
await tx.capture('node_modules');
|
|
1225
|
-
|
|
3319
|
+
// Required plugins the package brings, pinned in the same run (companionVersions).
|
|
3320
|
+
await this.npm.install(staging, companions.length ? [spec ?? '', ...companions].filter(Boolean) : spec);
|
|
1226
3321
|
return staging;
|
|
1227
3322
|
}
|
|
1228
3323
|
// A standalone copy of the member. Workspace packages (maybe unpublished) come in as packed
|
|
@@ -1242,7 +3337,7 @@ export class Project {
|
|
|
1242
3337
|
...Object.fromEntries(packs),
|
|
1243
3338
|
};
|
|
1244
3339
|
await writeFile(join(staging, 'package.json'), json(staged));
|
|
1245
|
-
await this.#stageInstall(session, staging, spec);
|
|
3340
|
+
await this.#stageInstall(session, staging, companions.length ? [spec ?? '', ...companions].filter(Boolean) : spec);
|
|
1246
3341
|
return staging;
|
|
1247
3342
|
}
|
|
1248
3343
|
/**
|
|
@@ -1251,23 +3346,31 @@ export class Project {
|
|
|
1251
3346
|
* differs from the direct dependency) and installed from there (D-0035 §3).
|
|
1252
3347
|
*/
|
|
1253
3348
|
async #stageInstall(session, staging, spec) {
|
|
3349
|
+
const specs = spec === undefined ? [] : typeof spec === 'string' ? [spec] : [...spec];
|
|
1254
3350
|
const workspace = session.workspace;
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
3351
|
+
const locals = workspace
|
|
3352
|
+
? specs.flatMap((item) => {
|
|
3353
|
+
const found = this.#workspacePackage(workspace, item);
|
|
3354
|
+
return found ? [{ spec: item, name: found.name }] : [];
|
|
3355
|
+
})
|
|
3356
|
+
: [];
|
|
3357
|
+
// One npm run for every spec (D-0116: a dependency level of add or upgrade at once).
|
|
3358
|
+
const remote = specs.filter((item) => !locals.some((local) => local.spec === item));
|
|
3359
|
+
if (!locals.length)
|
|
3360
|
+
return this.npm.install(staging, remote.length ? remote : undefined);
|
|
3361
|
+
const packs = await this.#packWorkspace(session, locals.map((local) => local.name));
|
|
1259
3362
|
const path = join(staging, 'package.json');
|
|
1260
3363
|
const staged = parseObject(await readFile(path, 'utf8'), 'staged');
|
|
1261
3364
|
staged.dependencies = {
|
|
1262
3365
|
...object(staged.dependencies ?? {}, 'dependencies'),
|
|
1263
|
-
[local.name
|
|
3366
|
+
...Object.fromEntries(locals.map((local) => [local.name, packs.get(local.name)])),
|
|
1264
3367
|
};
|
|
1265
3368
|
staged.overrides = {
|
|
1266
3369
|
...object(staged.overrides ?? {}, 'overrides'),
|
|
1267
3370
|
...Object.fromEntries(packs),
|
|
1268
3371
|
};
|
|
1269
3372
|
await writeFile(path, json(staged));
|
|
1270
|
-
await this.npm.install(staging);
|
|
3373
|
+
await this.npm.install(staging, remote.length ? remote : undefined);
|
|
1271
3374
|
}
|
|
1272
3375
|
/**
|
|
1273
3376
|
* `file:` archives of the named workspace packages and every workspace package they depend on
|
|
@@ -1314,6 +3417,28 @@ export class Project {
|
|
|
1314
3417
|
throw new CliError('WORKSPACE_VERSION_MISMATCH', `${match[1]} is ${found.version} in the workspace`, { details: { package: match[1], workspace: found.version, requested: range } });
|
|
1315
3418
|
return { name: match[1], spec: `file:${found.directory}` };
|
|
1316
3419
|
}
|
|
3420
|
+
/**
|
|
3421
|
+
* Whether the staged install holds other content of `name` than the project's: the
|
|
3422
|
+
* package-lock integrity of `node_modules/<name>` differs (both must be known; a workspace
|
|
3423
|
+
* member's lock lives at the workspace root and is not compared).
|
|
3424
|
+
*/
|
|
3425
|
+
async #contentChanged(session, staging, name) {
|
|
3426
|
+
if (session.workspace)
|
|
3427
|
+
return false;
|
|
3428
|
+
const integrity = (text) => {
|
|
3429
|
+
if (text === undefined || text === null)
|
|
3430
|
+
return undefined;
|
|
3431
|
+
const packages = (parseObject(text, 'package-lock.json').packages ?? {});
|
|
3432
|
+
const value = packages[`node_modules/${name}`]?.integrity;
|
|
3433
|
+
return typeof value === 'string' ? value : undefined;
|
|
3434
|
+
};
|
|
3435
|
+
const lock = await securePath(session.tx.root, 'package-lock.json');
|
|
3436
|
+
const before = integrity((await exists(lock)) ? await session.tx.read('package-lock.json') : undefined);
|
|
3437
|
+
const after = integrity(await readOptional(join(staging, 'package-lock.json')));
|
|
3438
|
+
const changed = before !== undefined && after !== undefined && before !== after;
|
|
3439
|
+
debug('Same-version upgrade compares the package content', { name, changed });
|
|
3440
|
+
return changed;
|
|
3441
|
+
}
|
|
1317
3442
|
async #installedDependencies(staging) {
|
|
1318
3443
|
return dependencyMap(parseObject(await readFile(join(staging, 'package.json'), 'utf8'), 'installed package'));
|
|
1319
3444
|
}
|
|
@@ -1325,7 +3450,38 @@ export class Project {
|
|
|
1325
3450
|
for (const plugin of plugins) {
|
|
1326
3451
|
const actual = await this.#readPackageManifest(staging, plugin.packageName);
|
|
1327
3452
|
if (json(actual) !== json(plugin))
|
|
1328
|
-
throw
|
|
3453
|
+
throw stagedManifestChanged(plugin, actual);
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
/**
|
|
3457
|
+
* The other registered plugins of an upgrade come out of staging as recorded, or as the project
|
|
3458
|
+
* has them installed already. The second case is a package.json that moved several plugins at
|
|
3459
|
+
* once (`npm install`/`npm update`, doctor MANIFEST_CHANGED for each): such a plugin stays
|
|
3460
|
+
* recorded as it was and is reported with its own `dokebi upgrade`, so the plugins can be
|
|
3461
|
+
* upgraded one after another. A plugin that npm changes in staging is still MANIFEST_CHANGED.
|
|
3462
|
+
*/
|
|
3463
|
+
async #verifyUpgradeOthers(session, staging, others, warnings) {
|
|
3464
|
+
for (const plugin of others) {
|
|
3465
|
+
const actual = await this.#readPackageManifest(staging, plugin.packageName);
|
|
3466
|
+
if (json(actual) === json(plugin))
|
|
3467
|
+
continue;
|
|
3468
|
+
const file = await packageFile(session.root, plugin.packageName, 'package.json');
|
|
3469
|
+
const installed = (await exists(file))
|
|
3470
|
+
? readManifest(parseObject(await readFile(file, 'utf8'), plugin.packageName))
|
|
3471
|
+
: undefined;
|
|
3472
|
+
if (!installed || json(installed) !== json(actual))
|
|
3473
|
+
throw stagedManifestChanged(plugin, actual);
|
|
3474
|
+
debug('Upgrade leaves a plugin whose installed package already changed', {
|
|
3475
|
+
plugin: plugin.id,
|
|
3476
|
+
recorded: plugin.version,
|
|
3477
|
+
installed: installed.version,
|
|
3478
|
+
});
|
|
3479
|
+
warnings.push({
|
|
3480
|
+
code: 'MANIFEST_CHANGED',
|
|
3481
|
+
path: `node_modules/${plugin.packageName}/package.json`,
|
|
3482
|
+
plugin: plugin.id,
|
|
3483
|
+
hint: `dokebi upgrade ${plugin.id}`,
|
|
3484
|
+
});
|
|
1329
3485
|
}
|
|
1330
3486
|
}
|
|
1331
3487
|
async #order(staging, plugins, required) {
|
|
@@ -1358,10 +3514,19 @@ export class Project {
|
|
|
1358
3514
|
const result = checks.get(plugin.id) ?? { requires: [], diagnostics: [] };
|
|
1359
3515
|
const requires = plugin.id === target.id ? union(target.requires, result.requires) : result.requires;
|
|
1360
3516
|
const missing = requires.filter((id) => !installed.has(id));
|
|
1361
|
-
if (missing.length)
|
|
1362
|
-
|
|
1363
|
-
|
|
3517
|
+
if (missing.length) {
|
|
3518
|
+
const catalog = await loadCatalog();
|
|
3519
|
+
const packages = missing.map((id) => findPackage(catalog, id)?.package ?? id);
|
|
3520
|
+
throw new CliError('MISSING_REQUIRED_PLUGIN', `${plugin.id} ${plugin.version} requires ${missing.join(', ')} with the current settings, and ${missing.length === 1 ? 'it is' : 'they are'} not installed`, {
|
|
3521
|
+
details: {
|
|
3522
|
+
plugin: plugin.id,
|
|
3523
|
+
version: plugin.version,
|
|
3524
|
+
requires: missing,
|
|
3525
|
+
fix: `${packages.map((name) => `npx dokebi add ${name}`).join(', then ')}, or change the options of ${plugin.id} that need ${missing.length === 1 ? 'it' : 'them'}`,
|
|
3526
|
+
},
|
|
3527
|
+
phase: 'plan',
|
|
1364
3528
|
});
|
|
3529
|
+
}
|
|
1365
3530
|
installations[plugin.id] = {
|
|
1366
3531
|
...(installations[plugin.id] ?? defaultInstallation(plugin)),
|
|
1367
3532
|
requires,
|
|
@@ -1387,14 +3552,22 @@ export class Project {
|
|
|
1387
3552
|
});
|
|
1388
3553
|
continue;
|
|
1389
3554
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
3555
|
+
const first = errors[0];
|
|
3556
|
+
throw new CliError('PLUGIN_CHECK_FAILED', `The check of ${plugin.id} ${plugin.version} failed: ${first.code}${first.optionPath?.length ? ` at options.${optionsKey(plugin)}.${first.optionPath.join('.')}` : ''}${errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''}`, {
|
|
3557
|
+
details: {
|
|
3558
|
+
plugin: plugin.id,
|
|
3559
|
+
diagnostics: errors.slice(0, 20),
|
|
3560
|
+
fix: typeof first.hint === 'string'
|
|
3561
|
+
? first.hint
|
|
3562
|
+
: `correct what details.diagnostics names (npx dokebi plugins doc ${plugin.id} --json), then repeat this command`,
|
|
3563
|
+
},
|
|
3564
|
+
phase: 'plan',
|
|
1392
3565
|
});
|
|
1393
3566
|
}
|
|
1394
3567
|
}
|
|
1395
3568
|
async #validateOptions(validator, staging, ordered, config, configRequired) {
|
|
1396
3569
|
for (const plugin of ordered) {
|
|
1397
|
-
const checked = await validator.check(staging, plugin, optionsOf(config.options, plugin
|
|
3570
|
+
const checked = await validator.check(staging, plugin, optionsOf(config.options, optionsKey(plugin)));
|
|
1398
3571
|
if (!checked.errors.length)
|
|
1399
3572
|
continue;
|
|
1400
3573
|
// Only missing required values (this plugin or one installed earlier) do not block the
|
|
@@ -1406,15 +3579,8 @@ export class Project {
|
|
|
1406
3579
|
errorCount: checked.errors.length,
|
|
1407
3580
|
});
|
|
1408
3581
|
}
|
|
1409
|
-
else
|
|
1410
|
-
throw
|
|
1411
|
-
details: {
|
|
1412
|
-
plugin: plugin.id,
|
|
1413
|
-
errors: checked.errors.slice(0, 20),
|
|
1414
|
-
errorCount: checked.errors.length,
|
|
1415
|
-
},
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
3582
|
+
else
|
|
3583
|
+
throw invalidPluginOptions(plugin.id, checked.errors, 'plan', optionsKey(plugin), 'schema', checked.schema);
|
|
1418
3584
|
}
|
|
1419
3585
|
}
|
|
1420
3586
|
#planFiles(plan) {
|
|
@@ -1434,12 +3600,12 @@ export class Project {
|
|
|
1434
3600
|
if (owned) {
|
|
1435
3601
|
// A shared file needs the same content and the same owner class from every plugin.
|
|
1436
3602
|
if (current !== digest || owned.hash !== digest || owned.owner !== owner)
|
|
1437
|
-
throw
|
|
3603
|
+
throw fileConflict(file.target, `is shared with ${owned.owners.join(', ')}, and ${plugin.id} ${plugin.version} brings other content or another owner for it`, `the packages disagree about this file: install versions that agree, or report it to the authors of ${plugin.packageName}`, { plugin: plugin.id, owners: owned.owners });
|
|
1438
3604
|
owned.owners.push(plugin.id);
|
|
1439
3605
|
}
|
|
1440
3606
|
else {
|
|
1441
3607
|
if (current !== null)
|
|
1442
|
-
throw
|
|
3608
|
+
throw fileConflict(file.target, `already exists and ${plugin.id} ${plugin.version} would write it`, `move ${file.target} away (it is not the CLI's), then repeat this command; put your content back into the new file afterwards`, { plugin: plugin.id });
|
|
1443
3609
|
await tx.write(file.target, override.get(file.target) ?? content);
|
|
1444
3610
|
state.files[file.target] = { hash: digest, owners: [plugin.id], owner };
|
|
1445
3611
|
}
|
|
@@ -1479,6 +3645,12 @@ export class Project {
|
|
|
1479
3645
|
details.push({ path, reason: 'SHARED', ...(update ? { update } : {}) });
|
|
1480
3646
|
continue;
|
|
1481
3647
|
}
|
|
3648
|
+
if (source === undefined && owned.owner === 'user') {
|
|
3649
|
+
// Retiring a scaffold is not authorization to delete the user's resource.
|
|
3650
|
+
details.push({ path, reason: 'RETIRED_USER_FILE' });
|
|
3651
|
+
delete state.files[path];
|
|
3652
|
+
continue;
|
|
3653
|
+
}
|
|
1482
3654
|
if (source === undefined) {
|
|
1483
3655
|
await tx.replace(path);
|
|
1484
3656
|
delete state.files[path];
|
|
@@ -1494,11 +3666,8 @@ export class Project {
|
|
|
1494
3666
|
await this.#addFiles(tx, staging, state, plugin, [...planned.values()]);
|
|
1495
3667
|
return details;
|
|
1496
3668
|
}
|
|
1497
|
-
async #syncExample(tx, root, id, warnings, update) {
|
|
1498
|
-
await this.#syncExamples(tx, root, warnings, [{ id, update }]);
|
|
1499
|
-
}
|
|
1500
3669
|
/** Applies several plugin updates to dokebi.example.yaml and writes it once. */
|
|
1501
|
-
async #syncExamples(tx, root, warnings, updates) {
|
|
3670
|
+
async #syncExamples(tx, root, warnings, updates, before = (source) => source) {
|
|
1502
3671
|
if (!(await exists(await securePath(root, EXAMPLE)))) {
|
|
1503
3672
|
warnings.push({ code: 'EXAMPLE_MISSING', path: EXAMPLE });
|
|
1504
3673
|
return;
|
|
@@ -1506,6 +3675,7 @@ export class Project {
|
|
|
1506
3675
|
const source = await tx.read(EXAMPLE);
|
|
1507
3676
|
let next = source;
|
|
1508
3677
|
try {
|
|
3678
|
+
next = before(next);
|
|
1509
3679
|
for (const { id, update } of updates)
|
|
1510
3680
|
next = syncExample(next, id, update);
|
|
1511
3681
|
}
|
|
@@ -1608,7 +3778,21 @@ export class Project {
|
|
|
1608
3778
|
},
|
|
1609
3779
|
});
|
|
1610
3780
|
}
|
|
1611
|
-
|
|
3781
|
+
if (error instanceof CliError && error.code === 'RECOVERY_REQUIRED')
|
|
3782
|
+
throw error;
|
|
3783
|
+
// R4CB-9: the project files, the root lock and node_modules are back, which is what
|
|
3784
|
+
// COMMIT_FAILED says (mutation rolled-back); the root install's own error was thrown
|
|
3785
|
+
// before, and an envelope then said mutation unknown although nothing stayed changed.
|
|
3786
|
+
const failed = failure instanceof CliError ? failure : undefined;
|
|
3787
|
+
throw new CliError('COMMIT_FAILED', `Changes were rolled back: ${failed?.message ?? describeError(failure).split('\n')[0]}`, {
|
|
3788
|
+
cause: failure,
|
|
3789
|
+
details: {
|
|
3790
|
+
failed: failed?.code ?? 'INTERNAL_ERROR',
|
|
3791
|
+
...(failed?.details && typeof failed.details === 'object' ? failed.details : {}),
|
|
3792
|
+
workspaceRoot: workspace.root,
|
|
3793
|
+
},
|
|
3794
|
+
phase: 'commit',
|
|
3795
|
+
});
|
|
1612
3796
|
}
|
|
1613
3797
|
return result;
|
|
1614
3798
|
}
|
|
@@ -1620,24 +3804,70 @@ export class Project {
|
|
|
1620
3804
|
const session = await this.#begin(root);
|
|
1621
3805
|
const { tx, state } = session;
|
|
1622
3806
|
try {
|
|
3807
|
+
if (options.view !== undefined && options.inertia)
|
|
3808
|
+
throw new CliError('USAGE', '--view and --inertia both choose the view: give one', {
|
|
3809
|
+
details: {
|
|
3810
|
+
fix: 'an Inertia app: --inertia; EJS pages: --view ejs; no views: --view none',
|
|
3811
|
+
},
|
|
3812
|
+
phase: 'arguments',
|
|
3813
|
+
});
|
|
3814
|
+
// D-0098: an app of a project without screens is a JSON API too, unless asked otherwise.
|
|
3815
|
+
const recipeView = options.inertia || options.view ? undefined : await projectView(session.root);
|
|
3816
|
+
const view = options.inertia
|
|
3817
|
+
? undefined
|
|
3818
|
+
: (options.view ?? (recipeView === NO_VIEW ? 'none' : 'ejs'));
|
|
3819
|
+
if (view === 'none' && (options.template ?? 'default') !== 'default')
|
|
3820
|
+
throw new CliError('USAGE', 'An app without views (--view none) has no templates (--template)', {
|
|
3821
|
+
details: { fix: 'leave out --template, or choose EJS pages with --view ejs' },
|
|
3822
|
+
phase: 'arguments',
|
|
3823
|
+
});
|
|
3824
|
+
debug('app add: the view of the app', {
|
|
3825
|
+
app: name,
|
|
3826
|
+
view: view ?? 'inertia',
|
|
3827
|
+
from: options.view === undefined && !options.inertia ? 'recipe' : 'flags',
|
|
3828
|
+
});
|
|
1623
3829
|
const host = await this.#requireHost(state, 'apps');
|
|
1624
|
-
|
|
3830
|
+
// D-0102: `--domain auto` is this project's development host `<app>.<project>.localhost`.
|
|
3831
|
+
const label = projectHostLabel(session.pkg.name, session.root);
|
|
3832
|
+
const domains = options.domains?.map((domain) => domain === AUTO_DOMAIN ? devHost(name, label) : domain);
|
|
3833
|
+
const added = await this.#addApp(tx, session.root, host, state.plugins, state, session.configSource, name, {
|
|
3834
|
+
...options,
|
|
3835
|
+
...(domains ? { domains } : {}),
|
|
3836
|
+
...(view ? { view } : {}),
|
|
3837
|
+
});
|
|
1625
3838
|
const warnings = [];
|
|
1626
3839
|
if (options.inertia && !(await this.#providesInstalled(state, 'view.inertia')))
|
|
1627
3840
|
warnings.push({ code: 'INERTIA_PLUGIN_MISSING', path: `src/apps/${name}` });
|
|
3841
|
+
// A sealed domain without the provider fails the start (SEAL_PROVIDER_MISSING).
|
|
3842
|
+
if (options.seal && !(await this.#providesInstalled(state, 'security.seal')))
|
|
3843
|
+
warnings.push({ code: 'SEAL_PLUGIN_MISSING', path: `src/apps/${name}` });
|
|
1628
3844
|
if (options.ssr && !options.inertia)
|
|
1629
3845
|
throw new CliError('USAGE', 'Server rendering (--ssr) needs --inertia');
|
|
3846
|
+
// D-0062 §5: SSR writes the page props into plain HTML, so @dokebi/template-inertia refuses
|
|
3847
|
+
// to start an SSR scope on a seal host. Refuse the pair here instead of at the first start.
|
|
3848
|
+
if (options.ssr && options.seal)
|
|
3849
|
+
throw new CliError('USAGE', 'Server rendering (--ssr) cannot be sealed (--seal): SSR writes the page props into the first HTML as plain text, so the start fails with SEAL_SSR_CONFLICT. Choose one with the user.');
|
|
1630
3850
|
if (options.inertia)
|
|
1631
3851
|
await this.#inertiaBuild(tx, session, host, warnings, options.ssr === true);
|
|
1632
3852
|
// Domains are not secrets: the shared sample keeps them so `setup` scripts see them too.
|
|
1633
3853
|
await this.#syncExamples(tx, session.root, warnings, [
|
|
1634
3854
|
{ id: host.id, update: { changes: added.changes } },
|
|
3855
|
+
...(added.layout
|
|
3856
|
+
? [{ id: added.layout.engine, update: { changes: added.layout.changes } }]
|
|
3857
|
+
: []),
|
|
1635
3858
|
]);
|
|
1636
|
-
await this.writeConfiguration(tx, state, parseAndRender(added.configSource));
|
|
3859
|
+
await this.writeConfiguration(tx, state, parseAndRender(added.configSource), session.configFile);
|
|
1637
3860
|
const result = {
|
|
1638
3861
|
changed: tx.paths,
|
|
1639
3862
|
preserved: [],
|
|
1640
3863
|
...(warnings.length ? { warnings } : {}),
|
|
3864
|
+
// What verify's tellUser will say about this app's view (view-choice.ts recordAppView).
|
|
3865
|
+
appView: {
|
|
3866
|
+
value: appViewValue(options.inertia === true, options.ssr === true, view === 'none'),
|
|
3867
|
+
from: options.view === undefined && !options.inertia && recipeView === NO_VIEW
|
|
3868
|
+
? 'recipe'
|
|
3869
|
+
: 'flags',
|
|
3870
|
+
},
|
|
1641
3871
|
...(options.inertia
|
|
1642
3872
|
? {
|
|
1643
3873
|
nextSteps: [
|
|
@@ -1662,7 +3892,7 @@ export class Project {
|
|
|
1662
3892
|
try {
|
|
1663
3893
|
const host = await this.#requireHost(state, 'apps');
|
|
1664
3894
|
if (!(state.apps ?? []).includes(name))
|
|
1665
|
-
throw
|
|
3895
|
+
throw unknownApp(name, state.apps ?? []);
|
|
1666
3896
|
const template = options.template ?? 'default';
|
|
1667
3897
|
if (!APP_NAME.test(template))
|
|
1668
3898
|
throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
|
|
@@ -1670,14 +3900,16 @@ export class Project {
|
|
|
1670
3900
|
throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
|
|
1671
3901
|
const current = domainTarget(parseProjectConfig(session.configSource).options, host.id, domain);
|
|
1672
3902
|
if (current && current !== name)
|
|
1673
|
-
throw
|
|
3903
|
+
throw domainInUse(domain, current);
|
|
1674
3904
|
const views = `src/apps/${name}/views/${template}`;
|
|
1675
3905
|
if (template !== 'default' && !(await exists(await securePath(tx.root, views))))
|
|
1676
3906
|
await tx.ensureDirectory(views);
|
|
1677
|
-
const changes = domainChanges([domain],
|
|
3907
|
+
const changes = domainChanges([domain], domainTargetOf(name, template, options.seal));
|
|
1678
3908
|
const warnings = [];
|
|
3909
|
+
if (options.seal && !(await this.#providesInstalled(state, 'security.seal')))
|
|
3910
|
+
warnings.push({ code: 'SEAL_PLUGIN_MISSING', path: `src/apps/${name}` });
|
|
1679
3911
|
await this.#syncExamples(tx, session.root, warnings, [{ id: host.id, update: { changes } }]);
|
|
1680
|
-
await tx.write(
|
|
3912
|
+
await tx.write(session.configFile, applyConfigChanges(session.configSource, host.id, changes));
|
|
1681
3913
|
const result = {
|
|
1682
3914
|
changed: tx.paths,
|
|
1683
3915
|
preserved: [],
|
|
@@ -1696,11 +3928,17 @@ export class Project {
|
|
|
1696
3928
|
try {
|
|
1697
3929
|
const host = await this.#requireHost(state, 'apps');
|
|
1698
3930
|
if (!domainTarget(parseProjectConfig(session.configSource).options, host.id, domain))
|
|
1699
|
-
throw new CliError('UNKNOWN_DOMAIN', domain
|
|
3931
|
+
throw new CliError('UNKNOWN_DOMAIN', `No app is linked to the domain ${domain}`, {
|
|
3932
|
+
details: {
|
|
3933
|
+
domain,
|
|
3934
|
+
fix: 'npx dokebi app list --json lists the linked domains; unlink one of those',
|
|
3935
|
+
},
|
|
3936
|
+
phase: 'arguments',
|
|
3937
|
+
});
|
|
1700
3938
|
const changes = [{ op: 'remove', path: ['domains', domain] }];
|
|
1701
3939
|
const warnings = [];
|
|
1702
3940
|
await this.#syncExamples(tx, session.root, warnings, [{ id: host.id, update: { changes } }]);
|
|
1703
|
-
await tx.write(
|
|
3941
|
+
await tx.write(session.configFile, applyConfigChanges(session.configSource, host.id, changes));
|
|
1704
3942
|
const result = {
|
|
1705
3943
|
changed: tx.paths,
|
|
1706
3944
|
preserved: [],
|
|
@@ -1719,10 +3957,17 @@ export class Project {
|
|
|
1719
3957
|
try {
|
|
1720
3958
|
const host = await this.#requireHost(state, 'apps');
|
|
1721
3959
|
if (!(state.apps ?? []).includes(name))
|
|
1722
|
-
throw
|
|
3960
|
+
throw unknownApp(name, state.apps ?? []);
|
|
1723
3961
|
const domains = appDomains(parseProjectConfig(session.configSource).options, host.id, name);
|
|
1724
3962
|
if (domains.length)
|
|
1725
|
-
throw new CliError('APP_IN_USE',
|
|
3963
|
+
throw new CliError('APP_IN_USE', `The app ${name} still serves ${domains.map((entry) => entry.domain).join(', ')}; remove its domains first`, {
|
|
3964
|
+
details: {
|
|
3965
|
+
app: name,
|
|
3966
|
+
domains,
|
|
3967
|
+
fix: `${domains.map((entry) => `npx dokebi app unlink ${entry.domain}`).join(', then ')}, then repeat this command`,
|
|
3968
|
+
},
|
|
3969
|
+
phase: 'arguments',
|
|
3970
|
+
});
|
|
1726
3971
|
state.apps = (state.apps ?? []).filter((app) => app !== name);
|
|
1727
3972
|
if (!state.apps.length)
|
|
1728
3973
|
delete state.apps;
|
|
@@ -1743,13 +3988,26 @@ export class Project {
|
|
|
1743
3988
|
if (next !== example)
|
|
1744
3989
|
await tx.write(EXAMPLE, next);
|
|
1745
3990
|
}
|
|
1746
|
-
await this.writeConfiguration(tx, state, removeAppSettings(session.configSource, host.id, name));
|
|
3991
|
+
await this.writeConfiguration(tx, state, removeAppSettings(session.configSource, host.id, name), session.configFile);
|
|
3992
|
+
// The folder is user code and stays; say so (studio request Q: an `ok` alone read as if
|
|
3993
|
+
// everything went).
|
|
3994
|
+
const folder = `src/apps/${name}`;
|
|
3995
|
+
if (await exists(await securePath(session.root, folder)))
|
|
3996
|
+
warnings.push({ code: 'APP_FOLDER_KEPT', path: folder });
|
|
1747
3997
|
const result = {
|
|
1748
3998
|
changed: tx.paths,
|
|
1749
|
-
preserved: [
|
|
3999
|
+
preserved: [folder],
|
|
1750
4000
|
...(warnings.length ? { warnings } : {}),
|
|
1751
4001
|
};
|
|
1752
|
-
|
|
4002
|
+
const finished = await this.#finish(session, result, dryRun);
|
|
4003
|
+
// The view choice recorded for the app (D-0098) goes too, so verify's tellUser never
|
|
4004
|
+
// relays a decision of an app that no longer exists.
|
|
4005
|
+
if (!dryRun) {
|
|
4006
|
+
const { forgetAppChoices } = await import('./recipe-record.js');
|
|
4007
|
+
if (await forgetAppChoices(session.root, name))
|
|
4008
|
+
return { ...finished, changed: [...finished.changed, '.dokebi/recipe.json'] };
|
|
4009
|
+
}
|
|
4010
|
+
return finished;
|
|
1753
4011
|
}
|
|
1754
4012
|
finally {
|
|
1755
4013
|
await tx.close();
|
|
@@ -1757,8 +4015,9 @@ export class Project {
|
|
|
1757
4015
|
}
|
|
1758
4016
|
/** `dokebi app list`: apps with the domains and templates that show them. */
|
|
1759
4017
|
async appList(root) {
|
|
1760
|
-
const state = readState(parseObject(await
|
|
1761
|
-
|
|
4018
|
+
const state = readState(parseObject(await projectStateText(root), 'state'));
|
|
4019
|
+
// M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml).
|
|
4020
|
+
const config = parseProjectConfig((await readProjectSettings(root)).source).options;
|
|
1762
4021
|
const host = findHost(state.plugins, 'apps');
|
|
1763
4022
|
return {
|
|
1764
4023
|
apps: (state.apps ?? []).map((name) => ({
|
|
@@ -1780,11 +4039,17 @@ export class Project {
|
|
|
1780
4039
|
if (!APP_NAME.test(name))
|
|
1781
4040
|
throw new CliError('INVALID_MODULE', 'Module names match ' + APP_NAME.source);
|
|
1782
4041
|
if ((state.modules ?? []).includes(name))
|
|
1783
|
-
throw new CliError('MODULE_EXISTS', name
|
|
4042
|
+
throw new CliError('MODULE_EXISTS', `The project already has a module named ${name}`, {
|
|
4043
|
+
details: {
|
|
4044
|
+
module: name,
|
|
4045
|
+
fix: 'choose another name; npx dokebi module list --json lists the modules',
|
|
4046
|
+
},
|
|
4047
|
+
phase: 'arguments',
|
|
4048
|
+
});
|
|
1784
4049
|
const folder = `src/shared/modules/${name}`;
|
|
1785
4050
|
templateTarget(folder + '/module.ts');
|
|
1786
4051
|
if (await exists(await securePath(tx.root, folder)))
|
|
1787
|
-
throw
|
|
4052
|
+
throw fileConflict(folder, `already exists, so module add does not write the module ${name} there`, `choose another name, or move ${folder} away and repeat this command`, { module: name });
|
|
1788
4053
|
const skeleton = options.views === false ? 'module-api' : 'module';
|
|
1789
4054
|
const source = await packageFile(session.root, host.packageName, `templates/${skeleton}`);
|
|
1790
4055
|
if (!(await exists(source)))
|
|
@@ -1798,7 +4063,7 @@ export class Project {
|
|
|
1798
4063
|
: content);
|
|
1799
4064
|
}
|
|
1800
4065
|
state.modules = [...(state.modules ?? []), name];
|
|
1801
|
-
await this.writeConfiguration(tx, state, session.configSource);
|
|
4066
|
+
await this.writeConfiguration(tx, state, session.configSource, session.configFile);
|
|
1802
4067
|
const result = {
|
|
1803
4068
|
changed: tx.paths,
|
|
1804
4069
|
preserved: [],
|
|
@@ -1821,11 +4086,18 @@ export class Project {
|
|
|
1821
4086
|
const { tx, state } = session;
|
|
1822
4087
|
try {
|
|
1823
4088
|
if (!(state.modules ?? []).includes(name))
|
|
1824
|
-
throw new CliError('UNKNOWN_MODULE', name
|
|
4089
|
+
throw new CliError('UNKNOWN_MODULE', `The project has no module named ${name}`, {
|
|
4090
|
+
details: {
|
|
4091
|
+
module: name,
|
|
4092
|
+
modules: state.modules ?? [],
|
|
4093
|
+
fix: 'name one of details.modules (npx dokebi module list --json)',
|
|
4094
|
+
},
|
|
4095
|
+
phase: 'arguments',
|
|
4096
|
+
});
|
|
1825
4097
|
state.modules = (state.modules ?? []).filter((module) => module !== name);
|
|
1826
4098
|
if (!state.modules.length)
|
|
1827
4099
|
delete state.modules;
|
|
1828
|
-
await this.writeConfiguration(tx, state, session.configSource);
|
|
4100
|
+
await this.writeConfiguration(tx, state, session.configSource, session.configFile);
|
|
1829
4101
|
const result = {
|
|
1830
4102
|
changed: tx.paths,
|
|
1831
4103
|
preserved: [`src/shared/modules/${name}`],
|
|
@@ -1838,7 +4110,7 @@ export class Project {
|
|
|
1838
4110
|
}
|
|
1839
4111
|
/** `dokebi module list`: the registered modules in registration order. */
|
|
1840
4112
|
async moduleList(root) {
|
|
1841
|
-
const state = readState(parseObject(await
|
|
4113
|
+
const state = readState(parseObject(await projectStateText(root), 'state'));
|
|
1842
4114
|
return {
|
|
1843
4115
|
modules: (state.modules ?? []).map((name) => ({
|
|
1844
4116
|
name,
|
|
@@ -1846,21 +4118,297 @@ export class Project {
|
|
|
1846
4118
|
})),
|
|
1847
4119
|
};
|
|
1848
4120
|
}
|
|
4121
|
+
/**
|
|
4122
|
+
* D-0155: `dokebi settings add <section>`: the settings of the application in options.<section>.
|
|
4123
|
+
* Writes src/shared/settings/<section>.ts (or keeps one that is already there and default-exports
|
|
4124
|
+
* defineSettings for this section), its schema file from the declaration, the defaults of the
|
|
4125
|
+
* section in dokebi.yaml and the example (with the reloadable marks), and registers it so the
|
|
4126
|
+
* app checks the section at start. A plugin's section, logging or reload is refused.
|
|
4127
|
+
*/
|
|
4128
|
+
async settingsAdd(root, section, options = {}) {
|
|
4129
|
+
const session = await this.#begin(root);
|
|
4130
|
+
const { tx, state } = session;
|
|
4131
|
+
try {
|
|
4132
|
+
if (!SETTINGS_SECTION.test(section))
|
|
4133
|
+
throw new CliError('INVALID_SETTINGS', `Settings sections match ${SETTINGS_SECTION.source}`, {
|
|
4134
|
+
details: { section },
|
|
4135
|
+
phase: 'arguments',
|
|
4136
|
+
});
|
|
4137
|
+
const owner = state.plugins.find((plugin) => plugin.id === section || optionsKey(plugin) === section)
|
|
4138
|
+
?.id ?? session.required.find((plugin) => plugin.id === section)?.id;
|
|
4139
|
+
if (owner !== undefined || RESERVED_SECTIONS.has(section))
|
|
4140
|
+
throw new CliError('SETTINGS_SECTION_CONFLICT', `options.${section} belongs to ${owner ?? 'core'}, not to the application`, {
|
|
4141
|
+
details: {
|
|
4142
|
+
section,
|
|
4143
|
+
owner: owner ?? 'core',
|
|
4144
|
+
fix: 'choose another section name for the settings of the application',
|
|
4145
|
+
},
|
|
4146
|
+
phase: 'arguments',
|
|
4147
|
+
});
|
|
4148
|
+
if ((state.settings ?? []).includes(section))
|
|
4149
|
+
throw new CliError('SETTINGS_EXISTS', `The application already has settings ${section}`, {
|
|
4150
|
+
details: {
|
|
4151
|
+
section,
|
|
4152
|
+
fix: 'edit the declaration, then npx dokebi settings sync; npx dokebi settings list lists them',
|
|
4153
|
+
},
|
|
4154
|
+
phase: 'arguments',
|
|
4155
|
+
});
|
|
4156
|
+
const file = settingsFile(section);
|
|
4157
|
+
templateTarget(file);
|
|
4158
|
+
const existing = await exists(await securePath(tx.root, file));
|
|
4159
|
+
let schemaText;
|
|
4160
|
+
if (existing)
|
|
4161
|
+
schemaText = await renderSettingsSchema(session.root, section);
|
|
4162
|
+
else {
|
|
4163
|
+
// The declaration is read from a copy beside where it goes (same imports), then written.
|
|
4164
|
+
const draft = join(session.root, SETTINGS_FOLDER, `.${section}.${process.pid}.draft.ts`);
|
|
4165
|
+
// R4CA-8: the folders made for the draft go with it (a dry run applies nothing; the
|
|
4166
|
+
// transaction makes them again for the real files).
|
|
4167
|
+
const made = await mkdir(dirname(draft), { recursive: true });
|
|
4168
|
+
try {
|
|
4169
|
+
await writeFile(draft, settingsSource(section));
|
|
4170
|
+
schemaText = await renderSettingsSchemaFrom(session.root, section, draft);
|
|
4171
|
+
}
|
|
4172
|
+
finally {
|
|
4173
|
+
await rm(draft, { force: true });
|
|
4174
|
+
if (made !== undefined)
|
|
4175
|
+
await rm(made, { recursive: true, force: true });
|
|
4176
|
+
}
|
|
4177
|
+
await tx.write(file, settingsSource(section));
|
|
4178
|
+
}
|
|
4179
|
+
await tx.write(settingsSchemaFile(section), schemaText);
|
|
4180
|
+
const schema = JSON.parse(schemaText);
|
|
4181
|
+
session.schemas.set(section, schema);
|
|
4182
|
+
const configSource = scaffoldConfig(session.configSource, section, schema);
|
|
4183
|
+
const warnings = [];
|
|
4184
|
+
await this.#syncExamples(tx, session.root, warnings, [{ id: section, update: { schema } }]);
|
|
4185
|
+
state.settings = [...(state.settings ?? []), section];
|
|
4186
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
4187
|
+
const identifier = settingsIdentifier(section);
|
|
4188
|
+
const result = {
|
|
4189
|
+
changed: tx.paths,
|
|
4190
|
+
preserved: existing ? [file] : [],
|
|
4191
|
+
...(warnings.length ? { warnings } : {}),
|
|
4192
|
+
nextSteps: [
|
|
4193
|
+
{
|
|
4194
|
+
command: `edit ${file}`,
|
|
4195
|
+
why: `declare the options of options.${section} (every count, size and time with minimum, maximum and default; reloadable: true where a running app may take a change), then npx dokebi settings sync`,
|
|
4196
|
+
},
|
|
4197
|
+
{
|
|
4198
|
+
command: `import ${identifier} from '<relative path>/shared/settings/${section}.ts' and read ${identifier}.read(context.app.config)`,
|
|
4199
|
+
why: 'typed settings, cached per configuration and new after a reload; never read options.<section> by hand',
|
|
4200
|
+
},
|
|
4201
|
+
],
|
|
4202
|
+
};
|
|
4203
|
+
return await this.#finish(session, result, options.dryRun);
|
|
4204
|
+
}
|
|
4205
|
+
catch (error) {
|
|
4206
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
4207
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
4208
|
+
throw tx.failure(error);
|
|
4209
|
+
}
|
|
4210
|
+
finally {
|
|
4211
|
+
await tx.close();
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
/**
|
|
4215
|
+
* D-0155: `dokebi settings sync`: writes the schema file of every settings section again from
|
|
4216
|
+
* its declaration and adds the defaults of new options to dokebi.yaml and the example (with the
|
|
4217
|
+
* reloadable marks). Nothing is written when every schema is current. AI-M49 D2: a value that
|
|
4218
|
+
* is still the old default (the CLI wrote it) follows a changed default (`followed`), and the
|
|
4219
|
+
* options are checked against the new schema as doctor checks them: a missing required value
|
|
4220
|
+
* is `configRequired`, anything else INVALID_PLUGIN_OPTIONS and nothing is written.
|
|
4221
|
+
*/
|
|
4222
|
+
async settingsSync(root, dryRun = false) {
|
|
4223
|
+
const session = await this.#begin(root);
|
|
4224
|
+
const { tx, state } = session;
|
|
4225
|
+
try {
|
|
4226
|
+
const sections = [];
|
|
4227
|
+
const validator = new PluginOptionsValidator({ allErrors: true });
|
|
4228
|
+
const configRequired = [];
|
|
4229
|
+
let configSource = session.configSource;
|
|
4230
|
+
const examples = [];
|
|
4231
|
+
const warnings = [];
|
|
4232
|
+
/** The schema before this sync, per section: what the CLI wrote as defaults (S-b). */
|
|
4233
|
+
const previous = new Map();
|
|
4234
|
+
for (const section of state.settings ?? []) {
|
|
4235
|
+
const text = await renderSettingsSchema(session.root, section);
|
|
4236
|
+
const path = settingsSchemaFile(section);
|
|
4237
|
+
const current = (await exists(await securePath(tx.root, path)))
|
|
4238
|
+
? await tx.read(path)
|
|
4239
|
+
: undefined;
|
|
4240
|
+
const changed = current !== text;
|
|
4241
|
+
if (changed)
|
|
4242
|
+
await tx.write(path, text);
|
|
4243
|
+
const schema = JSON.parse(text);
|
|
4244
|
+
session.schemas.set(section, schema);
|
|
4245
|
+
const before = current === undefined ? null : JSON.parse(current);
|
|
4246
|
+
previous.set(section, before);
|
|
4247
|
+
// Options the declaration dropped: defaults the CLI wrote go, values set by hand are named.
|
|
4248
|
+
const pruned = pruneUndeclared(configSource, section, before, schema);
|
|
4249
|
+
// A default the CLI wrote follows the new default; a value set by hand stays (D2).
|
|
4250
|
+
const followed = followDefaults(pruned.source, section, before, schema);
|
|
4251
|
+
configSource = scaffoldConfig(followed.source, section, schema);
|
|
4252
|
+
for (const key of pruned.kept)
|
|
4253
|
+
warnings.push({
|
|
4254
|
+
code: 'SETTINGS_KEY_UNDECLARED',
|
|
4255
|
+
path: `${session.configFile}#options.${section}.${key}`,
|
|
4256
|
+
hint: `${settingsFile(section)} no longer declares it and its value was set by hand: npx dokebi config unset ${section}.${key}, or declare it again`,
|
|
4257
|
+
});
|
|
4258
|
+
// What doctor would refuse is never answered ok (D2: a lowered maximum under a value set
|
|
4259
|
+
// by hand). A missing required value is reported, as add and config set report it.
|
|
4260
|
+
const checked = await validator.checkSettingsWith(section, schema, optionsOf(parseProjectConfig(configSource).options, section));
|
|
4261
|
+
const required = checked.errors.filter((error) => error.keyword === 'required');
|
|
4262
|
+
const invalid = checked.errors.filter((error) => error.keyword !== 'required');
|
|
4263
|
+
if (invalid.length)
|
|
4264
|
+
throw settingsStillInvalid(section, invalid, session.configFile);
|
|
4265
|
+
if (required.length)
|
|
4266
|
+
configRequired.push({
|
|
4267
|
+
plugin: section,
|
|
4268
|
+
errors: required.slice(0, 20),
|
|
4269
|
+
errorCount: required.length,
|
|
4270
|
+
});
|
|
4271
|
+
if (followed.updated.length)
|
|
4272
|
+
debug('Settings defaults the CLI wrote follow the new declaration', {
|
|
4273
|
+
section,
|
|
4274
|
+
paths: followed.updated,
|
|
4275
|
+
});
|
|
4276
|
+
examples.push({ id: section, update: { schema } });
|
|
4277
|
+
sections.push({
|
|
4278
|
+
section,
|
|
4279
|
+
changed,
|
|
4280
|
+
...(followed.updated.length
|
|
4281
|
+
? { followed: followed.updated.map((key) => `${section}.${key}`) }
|
|
4282
|
+
: {}),
|
|
4283
|
+
});
|
|
4284
|
+
}
|
|
4285
|
+
// The example's values of dropped options go too (they are the old defaults), in the same
|
|
4286
|
+
// write as the rest of its sync.
|
|
4287
|
+
if (examples.length)
|
|
4288
|
+
await this.#syncExamples(tx, session.root, warnings, examples, (source) => examples.reduce((next, { id, update }) => {
|
|
4289
|
+
const pruned = pruneUndeclared(next, id, previous.get(id) ?? null, update.schema);
|
|
4290
|
+
// R10: a value of a dropped option the example keeps (not the old default) is named too.
|
|
4291
|
+
for (const key of pruned.kept)
|
|
4292
|
+
warnings.push({
|
|
4293
|
+
code: 'SETTINGS_KEY_UNDECLARED',
|
|
4294
|
+
path: `${EXAMPLE}#options.${id}.${key}`,
|
|
4295
|
+
hint: `the declaration no longer has it: remove it from ${EXAMPLE}`,
|
|
4296
|
+
});
|
|
4297
|
+
// D2: the sample's copies of the CLI's defaults follow too.
|
|
4298
|
+
return followDefaults(pruned.source, id, previous.get(id) ?? null, update.schema)
|
|
4299
|
+
.source;
|
|
4300
|
+
}, source));
|
|
4301
|
+
if (configSource !== session.configSource || sections.some((item) => item.changed))
|
|
4302
|
+
await this.writeConfiguration(tx, state, configSource, session.configFile);
|
|
4303
|
+
debug('Settings schemas synchronized', {
|
|
4304
|
+
sections: sections.length,
|
|
4305
|
+
changed: sections.filter((item) => item.changed).length,
|
|
4306
|
+
});
|
|
4307
|
+
const result = await this.#finish(session, {
|
|
4308
|
+
changed: tx.paths,
|
|
4309
|
+
preserved: [],
|
|
4310
|
+
...(configRequired.length ? { configRequired } : {}),
|
|
4311
|
+
...(warnings.length ? { warnings } : {}),
|
|
4312
|
+
}, dryRun);
|
|
4313
|
+
return { ...result, sections };
|
|
4314
|
+
}
|
|
4315
|
+
catch (error) {
|
|
4316
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
4317
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
4318
|
+
throw tx.failure(error);
|
|
4319
|
+
}
|
|
4320
|
+
finally {
|
|
4321
|
+
await tx.close();
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
/** `dokebi settings remove <section>`: unregisters it; its files and its options stay. */
|
|
4325
|
+
async settingsRemove(root, section, dryRun = false) {
|
|
4326
|
+
const session = await this.#begin(root);
|
|
4327
|
+
const { tx, state } = session;
|
|
4328
|
+
try {
|
|
4329
|
+
if (!(state.settings ?? []).includes(section))
|
|
4330
|
+
throw new CliError('UNKNOWN_SETTINGS', `The application has no settings ${section}`, {
|
|
4331
|
+
details: {
|
|
4332
|
+
section,
|
|
4333
|
+
settings: state.settings ?? [],
|
|
4334
|
+
fix: 'name one of details.settings (npx dokebi settings list --json)',
|
|
4335
|
+
},
|
|
4336
|
+
phase: 'arguments',
|
|
4337
|
+
});
|
|
4338
|
+
state.settings = (state.settings ?? []).filter((item) => item !== section);
|
|
4339
|
+
if (!state.settings.length)
|
|
4340
|
+
delete state.settings;
|
|
4341
|
+
await this.writeConfiguration(tx, state, session.configSource, session.configFile);
|
|
4342
|
+
return await this.#finish(session, {
|
|
4343
|
+
changed: tx.paths,
|
|
4344
|
+
preserved: [
|
|
4345
|
+
settingsFile(section),
|
|
4346
|
+
settingsSchemaFile(section),
|
|
4347
|
+
`${session.configFile}#options.${section}`,
|
|
4348
|
+
],
|
|
4349
|
+
}, dryRun);
|
|
4350
|
+
}
|
|
4351
|
+
catch (error) {
|
|
4352
|
+
// R4CA-12: after a termination signal, whatever failed before the commit (a plugin check
|
|
4353
|
+
// or a settings declaration the terminal's Ctrl+C reached too) is INTERRUPTED.
|
|
4354
|
+
throw tx.failure(error);
|
|
4355
|
+
}
|
|
4356
|
+
finally {
|
|
4357
|
+
await tx.close();
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
/** `dokebi settings list`: the settings sections with their declaration and schema files. */
|
|
4361
|
+
async settingsList(root) {
|
|
4362
|
+
const state = readState(parseObject(await projectStateText(root), 'state'));
|
|
4363
|
+
return {
|
|
4364
|
+
settings: (state.settings ?? []).map((section) => ({
|
|
4365
|
+
section,
|
|
4366
|
+
file: settingsFile(section),
|
|
4367
|
+
schema: settingsSchemaFile(section),
|
|
4368
|
+
})),
|
|
4369
|
+
};
|
|
4370
|
+
}
|
|
1849
4371
|
/**
|
|
1850
4372
|
* What an Inertia app needs to be built (D-0031 supplement): one project-wide vite.config.ts
|
|
1851
4373
|
* that finds every src/apps/<app>/views/app.js, a `build` script, the Vite and Vue packages as
|
|
1852
4374
|
* dev dependencies, and dist/ in .gitignore. Existing files, scripts and versions are kept.
|
|
1853
4375
|
* With server rendering (D-0039) also vite.ssr.config.ts, and `build` runs both builds.
|
|
4376
|
+
* The Vue pages are type-checked too (AI-M23): `typecheck` runs typecheck-vue.mjs (vue-tsc on
|
|
4377
|
+
* the TypeScript 6 API of @typescript/typescript6, since TypeScript 7 has no JavaScript API)
|
|
4378
|
+
* with tsconfig.vue.json after tsc, and src/infra/inertia/page.d.ts types the shared props.
|
|
1854
4379
|
*/
|
|
1855
4380
|
async #inertiaBuild(tx, session, host, warnings, ssr) {
|
|
1856
4381
|
const source = await packageFile(session.root, host.packageName, 'templates/inertia-build');
|
|
4382
|
+
// R4CC-6: an @dokebi/app older than this CLI (0.4.2 and before) has no Vue type check in its
|
|
4383
|
+
// build template; that is a refusal of the plan before anything is written, with the upgrade.
|
|
4384
|
+
const outdated = (what) => new CliError('INVALID_MANIFEST', `${host.packageName} ${host.version} has an Inertia build template without ${what}, which app add --inertia of this CLI writes`, {
|
|
4385
|
+
phase: 'plan',
|
|
4386
|
+
details: {
|
|
4387
|
+
package: host.packageName,
|
|
4388
|
+
version: host.version,
|
|
4389
|
+
missing: what,
|
|
4390
|
+
fix: `npx dokebi upgrade ${host.id}, then run the same app add again`,
|
|
4391
|
+
},
|
|
4392
|
+
});
|
|
4393
|
+
for (const file of ['build.json', 'tsconfig.vue.json', 'typecheck-vue.mjs', 'page.d.ts'])
|
|
4394
|
+
if (!(await exists(join(source, file))))
|
|
4395
|
+
throw outdated(`templates/inertia-build/${file}`);
|
|
1857
4396
|
const build = parseObject(await readFile(join(source, 'build.json'), 'utf8'), 'build.json');
|
|
1858
4397
|
const wanted = object(build.devDependencies ?? {}, 'devDependencies');
|
|
1859
|
-
const { build: browserScript, buildSsr: ssrScript } = object(build.scripts ?? {}, 'scripts');
|
|
4398
|
+
const { build: browserScript, buildSsr: ssrScript, typecheck: typecheckScript, } = object(build.scripts ?? {}, 'scripts');
|
|
1860
4399
|
const pkg = structuredClone(session.pkg);
|
|
1861
4400
|
const scripts = object(pkg.scripts ?? {}, 'scripts');
|
|
1862
|
-
if (typeof browserScript !== 'string' ||
|
|
1863
|
-
|
|
4401
|
+
if (typeof browserScript !== 'string' ||
|
|
4402
|
+
typeof ssrScript !== 'string' ||
|
|
4403
|
+
typeof typecheckScript !== 'string')
|
|
4404
|
+
throw outdated(`the ${[
|
|
4405
|
+
['build', browserScript],
|
|
4406
|
+
['buildSsr', ssrScript],
|
|
4407
|
+
['typecheck', typecheckScript],
|
|
4408
|
+
]
|
|
4409
|
+
.filter(([, value]) => typeof value !== 'string')
|
|
4410
|
+
.map(([name]) => name)
|
|
4411
|
+
.join(', ')} script of build.json`);
|
|
1864
4412
|
const script = ssr ? ssrScript : browserScript;
|
|
1865
4413
|
// The browser-only script of an earlier `app add --inertia` grows into the SSR one; a script
|
|
1866
4414
|
// the developer wrote is kept, and the warning says to add the server build by hand.
|
|
@@ -1868,6 +4416,14 @@ export class Project {
|
|
|
1868
4416
|
scripts.build = script;
|
|
1869
4417
|
else if (scripts.build !== script && !(!ssr && scripts.build === ssrScript))
|
|
1870
4418
|
warnings.push({ code: 'BUILD_SCRIPT_KEPT', path: 'package.json' });
|
|
4419
|
+
// The server-only check init wrote grows into one that checks the .vue files too; a script
|
|
4420
|
+
// the developer wrote is kept, and the warning says to add the Vue check by hand.
|
|
4421
|
+
if (scripts.typecheck === undefined || scripts.typecheck === TYPECHECK_SCRIPT)
|
|
4422
|
+
scripts.typecheck = typecheckScript;
|
|
4423
|
+
else if (scripts.typecheck !== typecheckScript) {
|
|
4424
|
+
debug('Kept the typecheck script of the project', { script: scripts.typecheck });
|
|
4425
|
+
warnings.push({ code: 'TYPECHECK_SCRIPT_KEPT', path: 'package.json' });
|
|
4426
|
+
}
|
|
1871
4427
|
pkg.scripts = scripts;
|
|
1872
4428
|
const devDependencies = object(pkg.devDependencies ?? {}, 'devDependencies');
|
|
1873
4429
|
const dependencies = dependencyMap(pkg);
|
|
@@ -1888,11 +4444,31 @@ export class Project {
|
|
|
1888
4444
|
else if (json(pkg) !== json(session.pkg))
|
|
1889
4445
|
await tx.write('package.json', json(pkg));
|
|
1890
4446
|
const configs = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'];
|
|
1891
|
-
let configured
|
|
4447
|
+
let configured;
|
|
1892
4448
|
for (const file of configs)
|
|
1893
|
-
configured
|
|
4449
|
+
if (!configured && (await exists(await securePath(session.root, file))))
|
|
4450
|
+
configured = file;
|
|
1894
4451
|
if (!configured)
|
|
1895
4452
|
await tx.write('vite.config.ts', await readFile(join(source, 'vite.config.ts'), 'utf8'));
|
|
4453
|
+
// D-0062 addendum (2026-10-20): views/app.js imports '@dokebi/seal/http', which the
|
|
4454
|
+
// template-inertia Vite plugin maps to the module the app serves. A config of the project's
|
|
4455
|
+
// own without it would bundle a second seal client (or fail to resolve the package).
|
|
4456
|
+
else if (!(await readFile(await securePath(session.root, configured), 'utf8')).includes('dokebiInertia')) {
|
|
4457
|
+
debug('Kept a Vite config without the Inertia plugin', { file: configured });
|
|
4458
|
+
warnings.push({ code: 'VITE_PLUGIN_MISSING', path: configured });
|
|
4459
|
+
}
|
|
4460
|
+
// The Vue type check: its tsconfig, its runner and the shared page props, each written once.
|
|
4461
|
+
for (const [file, target] of [
|
|
4462
|
+
['tsconfig.vue.json', 'tsconfig.vue.json'],
|
|
4463
|
+
['typecheck-vue.mjs', 'typecheck-vue.mjs'],
|
|
4464
|
+
['page.d.ts', 'src/infra/inertia/page.d.ts'],
|
|
4465
|
+
])
|
|
4466
|
+
if (!(await exists(await securePath(session.root, target)))) {
|
|
4467
|
+
const directory = dirname(target);
|
|
4468
|
+
if (directory !== '.')
|
|
4469
|
+
await tx.ensureDirectory(directory);
|
|
4470
|
+
await tx.write(target, await readFile(join(source, file), 'utf8'));
|
|
4471
|
+
}
|
|
1896
4472
|
if (ssr) {
|
|
1897
4473
|
let ssrConfigured = false;
|
|
1898
4474
|
for (const file of configs.map((name) => name.replace('vite.config', 'vite.ssr.config')))
|
|
@@ -1926,19 +4502,50 @@ export class Project {
|
|
|
1926
4502
|
return catalog.packages.some((item) => installed.has(item.package) &&
|
|
1927
4503
|
item.provides.some((capability) => capability.startsWith(prefix)));
|
|
1928
4504
|
}
|
|
1929
|
-
/**
|
|
1930
|
-
|
|
4505
|
+
/**
|
|
4506
|
+
* The one path of every new app: `app add` and the first app created when the apps host is
|
|
4507
|
+
* added. `plugins` are the plugins installed once the command is done (`add` passes the new
|
|
4508
|
+
* list, which state does not hold yet). `layout` names the EJS view engine whose options the
|
|
4509
|
+
* shared layout changed, for the caller to write into the shared sample too.
|
|
4510
|
+
*/
|
|
4511
|
+
async #addApp(tx, packagesRoot, host, plugins, state, configSource, name, options) {
|
|
1931
4512
|
if (!APP_NAME.test(name))
|
|
1932
4513
|
throw new CliError('INVALID_APP', 'App names match ' + APP_NAME.source);
|
|
1933
4514
|
const template = options.template ?? 'default';
|
|
1934
4515
|
if (!APP_NAME.test(template))
|
|
1935
4516
|
throw new CliError('INVALID_APP', 'Template names match ' + APP_NAME.source);
|
|
1936
4517
|
if ((state.apps ?? []).includes(name))
|
|
1937
|
-
throw new CliError('APP_EXISTS', name
|
|
4518
|
+
throw new CliError('APP_EXISTS', `The project already has an app named ${name}`, {
|
|
4519
|
+
details: {
|
|
4520
|
+
app: name,
|
|
4521
|
+
fix: `choose another name, or give ${name} another domain with npx dokebi app link ${name} <domain>`,
|
|
4522
|
+
},
|
|
4523
|
+
phase: 'arguments',
|
|
4524
|
+
});
|
|
1938
4525
|
const folder = `src/apps/${name}`;
|
|
1939
4526
|
templateTarget(folder + '/app.ts');
|
|
1940
4527
|
if (await exists(await securePath(tx.root, folder)))
|
|
1941
|
-
throw
|
|
4528
|
+
throw fileConflict(folder, `already exists, so app add does not write the app ${name} there`, `choose another name, or move ${folder} away and repeat this command`, { app: name });
|
|
4529
|
+
// EJS or Inertia skeleton; `{{app}}` in text files becomes the app name (paths to its views).
|
|
4530
|
+
// Server rendering lays app-inertia-ssr (views/ssr.js, an app.ts naming the bundle) on top.
|
|
4531
|
+
const skeletons = options.inertia
|
|
4532
|
+
? ['app-inertia', ...(options.ssr ? ['app-inertia-ssr'] : [])]
|
|
4533
|
+
: options.view === 'none'
|
|
4534
|
+
? []
|
|
4535
|
+
: ['app'];
|
|
4536
|
+
const files = new Map();
|
|
4537
|
+
for (const skeleton of skeletons) {
|
|
4538
|
+
const source = await packageFile(packagesRoot, host.packageName, `templates/${skeleton}`);
|
|
4539
|
+
for (const file of await listFiles(source))
|
|
4540
|
+
files.set(file, join(source, file));
|
|
4541
|
+
}
|
|
4542
|
+
// No views: two files of the CLI's own text instead of the host's EJS skeleton.
|
|
4543
|
+
const texts = options.view === 'none' ? apiAppFiles(name) : new Map();
|
|
4544
|
+
const layout = await this.#sharedLayout(tx, packagesRoot, plugins, configSource, [
|
|
4545
|
+
...files.keys(),
|
|
4546
|
+
...texts.keys(),
|
|
4547
|
+
]);
|
|
4548
|
+
configSource = layout.configSource;
|
|
1942
4549
|
const domains = [...new Set(options.domains ?? [])];
|
|
1943
4550
|
const config = parseProjectConfig(configSource).options;
|
|
1944
4551
|
for (const domain of domains) {
|
|
@@ -1946,22 +4553,11 @@ export class Project {
|
|
|
1946
4553
|
throw new CliError('INVALID_APP', 'Domains are lower-case host names without a port');
|
|
1947
4554
|
const current = domainTarget(config, host.id, domain);
|
|
1948
4555
|
if (current && current !== name)
|
|
1949
|
-
throw
|
|
4556
|
+
throw domainInUse(domain, current);
|
|
1950
4557
|
}
|
|
1951
4558
|
// Directories first: commit creates them in order, before files create parents themselves.
|
|
1952
4559
|
if (template !== 'default')
|
|
1953
4560
|
await tx.ensureDirectory(`${folder}/views/${template}`);
|
|
1954
|
-
// EJS or Inertia skeleton; `{{app}}` in text files becomes the app name (paths to its views).
|
|
1955
|
-
// Server rendering lays app-inertia-ssr (views/ssr.js, an app.ts naming the bundle) on top.
|
|
1956
|
-
const skeletons = options.inertia
|
|
1957
|
-
? ['app-inertia', ...(options.ssr ? ['app-inertia-ssr'] : [])]
|
|
1958
|
-
: ['app'];
|
|
1959
|
-
const files = new Map();
|
|
1960
|
-
for (const skeleton of skeletons) {
|
|
1961
|
-
const source = await packageFile(packagesRoot, host.packageName, `templates/${skeleton}`);
|
|
1962
|
-
for (const file of await listFiles(source))
|
|
1963
|
-
files.set(file, join(source, file));
|
|
1964
|
-
}
|
|
1965
4561
|
for (const [file, path] of files) {
|
|
1966
4562
|
const target = `${folder}/${file}`;
|
|
1967
4563
|
templateTarget(target);
|
|
@@ -1970,18 +4566,87 @@ export class Project {
|
|
|
1970
4566
|
? content.toString('utf8').replaceAll('{{app}}', name)
|
|
1971
4567
|
: content);
|
|
1972
4568
|
}
|
|
1973
|
-
const
|
|
4569
|
+
for (const [file, text] of texts) {
|
|
4570
|
+
templateTarget(`${folder}/${file}`);
|
|
4571
|
+
await tx.write(`${folder}/${file}`, text);
|
|
4572
|
+
}
|
|
4573
|
+
const changes = domainChanges(domains, domainTargetOf(name, template, options.seal));
|
|
1974
4574
|
state.apps = [...(state.apps ?? []), name];
|
|
1975
|
-
return {
|
|
4575
|
+
return {
|
|
4576
|
+
configSource: applyConfigChanges(configSource, host.id, changes),
|
|
4577
|
+
changes,
|
|
4578
|
+
...(layout.engine ? { layout: { engine: layout.engine, changes: layout.changes } } : {}),
|
|
4579
|
+
};
|
|
1976
4580
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
4581
|
+
/**
|
|
4582
|
+
* The shared layout of an app whose skeleton has EJS views (D-0110 §3). Its view engine is the
|
|
4583
|
+
* installed plugin that provides the catalog capability `view.ejs`, found by capability as
|
|
4584
|
+
* D-0070 finds hosts, never by package name: that plugin's `templates/layouts/main.ejs` goes to
|
|
4585
|
+
* src/shared/views/layouts/main.ejs (kept when it is there) and its `defaultLayout` becomes
|
|
4586
|
+
* `layouts/main`, once. The engine installs no page scaffolds itself, so a project of Inertia
|
|
4587
|
+
* apps only keeps the engine entry (mail templates, error pages) without unused HTML.
|
|
4588
|
+
*/
|
|
4589
|
+
async #sharedLayout(tx, packagesRoot, plugins, configSource, skeletonFiles) {
|
|
4590
|
+
if (!skeletonFiles.some((file) => file.endsWith('.ejs'))) {
|
|
4591
|
+
debug('App skeleton has no EJS views; no shared EJS layout');
|
|
4592
|
+
return { configSource, changes: [] };
|
|
4593
|
+
}
|
|
4594
|
+
const installed = new Map(plugins.map((plugin) => [plugin.packageName, plugin]));
|
|
4595
|
+
const providers = providersOf(await loadCatalog(), EJS_VIEW)
|
|
4596
|
+
.map((item) => installed.get(item.package))
|
|
4597
|
+
.filter((plugin) => plugin !== undefined);
|
|
4598
|
+
const engine = providers[0];
|
|
4599
|
+
if (!engine) {
|
|
4600
|
+
debug('No installed plugin provides the EJS view; the app has no shared layout', {
|
|
4601
|
+
capability: EJS_VIEW,
|
|
4602
|
+
});
|
|
4603
|
+
return { configSource, changes: [] };
|
|
4604
|
+
}
|
|
4605
|
+
if (providers.length > 1)
|
|
4606
|
+
debug('More than one installed plugin provides the EJS view; the first in the catalog', {
|
|
4607
|
+
capability: EJS_VIEW,
|
|
4608
|
+
plugins: providers.map((plugin) => plugin.id),
|
|
4609
|
+
engine: engine.id,
|
|
4610
|
+
});
|
|
4611
|
+
if (optionAt(configSource, engine.id, [EJS_LAYOUT.option]) !== undefined) {
|
|
4612
|
+
debug('The EJS view engine already has a default layout', { engine: engine.id });
|
|
4613
|
+
return { configSource, changes: [] };
|
|
4614
|
+
}
|
|
4615
|
+
if (await exists(await securePath(tx.root, EJS_LAYOUT.target)))
|
|
4616
|
+
debug('Shared layout kept', { path: EJS_LAYOUT.target });
|
|
4617
|
+
else {
|
|
4618
|
+
const source = await packageFile(packagesRoot, engine.packageName, EJS_LAYOUT.source);
|
|
4619
|
+
await tx.write(EJS_LAYOUT.target, await readFile(source));
|
|
4620
|
+
debug('Shared layout written', { path: EJS_LAYOUT.target, engine: engine.id });
|
|
4621
|
+
}
|
|
4622
|
+
const changes = [
|
|
4623
|
+
{ op: 'default', path: [EJS_LAYOUT.option], value: EJS_LAYOUT.value },
|
|
4624
|
+
];
|
|
4625
|
+
return {
|
|
4626
|
+
configSource: applyConfigChanges(configSource, engine.id, changes),
|
|
4627
|
+
engine: engine.id,
|
|
4628
|
+
changes,
|
|
4629
|
+
};
|
|
4630
|
+
}
|
|
4631
|
+
async writeConfiguration(tx, state, configSource,
|
|
4632
|
+
/** The settings file (M50: the one DOKEBI_CONFIG names for the config commands). */
|
|
4633
|
+
configFile) {
|
|
4634
|
+
const generated = generateRegistration(state.plugins, state.installations, state.apps ?? [], state.modules ?? [], state.settings ?? []);
|
|
1979
4635
|
state.generatedHash = hash(generated);
|
|
1980
4636
|
await tx.write(GENERATED, generated);
|
|
1981
|
-
await tx.write(
|
|
4637
|
+
await tx.write(configFile, configSource);
|
|
1982
4638
|
await tx.write('.dokebi/state.json', json(state));
|
|
1983
4639
|
}
|
|
1984
4640
|
}
|
|
4641
|
+
/** The catalog capability of the view engine of EJS app skeletons (D-0110 §3). */
|
|
4642
|
+
const EJS_VIEW = 'view.ejs';
|
|
4643
|
+
/** What a `view.ejs` provider ships for a new EJS app: its shared layout and the option naming it. */
|
|
4644
|
+
const EJS_LAYOUT = {
|
|
4645
|
+
source: 'templates/layouts/main.ejs',
|
|
4646
|
+
target: 'src/shared/views/layouts/main.ejs',
|
|
4647
|
+
option: 'defaultLayout',
|
|
4648
|
+
value: 'layouts/main',
|
|
4649
|
+
};
|
|
1985
4650
|
const HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
1986
4651
|
/** `options.<apps host>.domains` (D-0030): domain → { app, template? }. */
|
|
1987
4652
|
function domainsOf(options, host) {
|
|
@@ -2023,6 +4688,14 @@ function parseAndRender(source) {
|
|
|
2023
4688
|
return source;
|
|
2024
4689
|
}
|
|
2025
4690
|
/** Replaces each domain mapping (remove, then set) so a re-link changes app or template. */
|
|
4691
|
+
/** One `domains.<host>` entry: the app, its template unless default, and `seal: true` if asked. */
|
|
4692
|
+
function domainTargetOf(app, template, seal) {
|
|
4693
|
+
return {
|
|
4694
|
+
app,
|
|
4695
|
+
...(template === 'default' ? {} : { template }),
|
|
4696
|
+
...(seal ? { seal: true } : {}),
|
|
4697
|
+
};
|
|
4698
|
+
}
|
|
2026
4699
|
function domainChanges(domains, target) {
|
|
2027
4700
|
return domains.flatMap((domain) => [
|
|
2028
4701
|
{ op: 'remove', path: ['domains', domain] },
|