@dokebi/cli 0.4.1 → 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 +527 -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 +29 -3
- package/dist/install-module.js +142 -13
- 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 +106 -27
- 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 +59 -4
- package/dist/npm-client.js +220 -16
- 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 +206 -20
- 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 +3016 -336
- 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 +50 -4
- package/dist/server.js +790 -103
- 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 +67 -0
- package/dist/transaction.js +428 -46
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +113 -4
- package/dist/verify.js +326 -28
- 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/install-module.js
CHANGED
|
@@ -1,27 +1,143 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { realpath } from 'node:fs/promises';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, sep } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { debug, debugEnabled, describeError } from './diagnostics.js';
|
|
5
|
+
import { debug, debugEnabled, describeError, isMissing } from './diagnostics.js';
|
|
6
|
+
import { redactNpmOutput } from './npm-client.js';
|
|
6
7
|
export class InstallPlanningError extends Error {
|
|
7
8
|
code;
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* What the module said: the message of the error it threw, or the end of its stderr when the
|
|
11
|
+
* process failed (at most DETAIL_CHARS, `user:password@` and tokens masked). Without it a
|
|
12
|
+
* failing planInstall was only INSTALL_MODULE_FAILED, and its author could not see why.
|
|
13
|
+
*/
|
|
14
|
+
detail;
|
|
15
|
+
constructor(code, options) {
|
|
16
|
+
super(`Plugin installation planning failed (${code}).`, options);
|
|
10
17
|
this.code = code;
|
|
11
18
|
this.name = 'InstallPlanningError';
|
|
19
|
+
// Masked before it is cut (R4CB-7): a cut inside a password left its first part without the
|
|
20
|
+
// `@` the mask looks for.
|
|
21
|
+
const detail = options?.detail ? redactNpmOutput(options.detail).trim() : undefined;
|
|
22
|
+
this.detail = detail ? detail.slice(0, DETAIL_CHARS) : undefined;
|
|
12
23
|
}
|
|
13
24
|
}
|
|
25
|
+
const DETAIL_CHARS = 2000;
|
|
26
|
+
/** The end of stderr kept for the detail: more than it shows, so the mask sees whole values. */
|
|
27
|
+
const STDERR_CHARS = DETAIL_CHARS * 2;
|
|
14
28
|
/** Returned when the module does not export the requested phase. */
|
|
15
29
|
export const PHASE_MISSING = Symbol('dokebi.install.phase-missing');
|
|
16
30
|
const MAX_BYTES = 1024 * 1024;
|
|
17
31
|
const TIMEOUT_MS = 5000;
|
|
18
|
-
const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code});\n}\n";
|
|
32
|
+
const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const failure = (error) => ({ok:false,code:error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED',message:String(error && error.stack || error).slice(0, 4000)});\n // D-0162: one child checks the options of every listed plugin (context.entries: {url, options}) with\n // the main entry's createPlugin.checkOptions; each plugin's result or failure is its own.\n if (phase === 'checkOptions') {\n const results = [];\n for (const entry of context.entries) {\n try {\n const factory = (await import(entry.url)).createPlugin;\n results.push(typeof factory?.checkOptions === 'function' ? {ok:true,result:factory.checkOptions(entry.options)} : {ok:true,missing:true});\n } catch (error) { results.push(failure(error)); }\n }\n finish({ok:true,plan:results});\n } else {\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code,message:String(error && error.stack || error).slice(0, 4000)});\n}\n";
|
|
33
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
|
|
34
|
+
/** Packages an install module may read (its own and its dependencies, transitively). */
|
|
35
|
+
const MAX_READ_PACKAGES = 512;
|
|
36
|
+
async function dependencyNames(directory) {
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
text = await readFile(join(directory, 'package.json'), 'utf8');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// A folder without package.json (a bare install module) declares no dependencies.
|
|
43
|
+
if (!isMissing(error))
|
|
44
|
+
throw error;
|
|
45
|
+
debug('Install module package has no package.json', { directory });
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const pkg = JSON.parse(text);
|
|
49
|
+
const names = new Set();
|
|
50
|
+
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
51
|
+
const value = pkg[field];
|
|
52
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
53
|
+
for (const name of Object.keys(value))
|
|
54
|
+
if (PACKAGE_NAME.test(name))
|
|
55
|
+
names.add(name);
|
|
56
|
+
}
|
|
57
|
+
return [...names];
|
|
58
|
+
}
|
|
59
|
+
/** Where Node finds `name` from `directory`: `node_modules/<name>` in it or a parent folder. */
|
|
60
|
+
async function findPackage(directory, name) {
|
|
61
|
+
for (let current = directory;; current = dirname(current)) {
|
|
62
|
+
if (basename(current) !== 'node_modules') {
|
|
63
|
+
const candidate = join(current, 'node_modules', name);
|
|
64
|
+
try {
|
|
65
|
+
await stat(candidate);
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!isMissing(error))
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (dirname(current) === current)
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The folders an install module may read (R9): its own package and the packages it depends on
|
|
79
|
+
* (`dependencies`, `optionalDependencies`, `peerDependencies`, transitively), each found the way
|
|
80
|
+
* Node resolves it from the importer's real folder, and allowed both where Node finds it (a
|
|
81
|
+
* `node_modules/<name>` link) and at its real path. Never the project, the workspace root or
|
|
82
|
+
* the rest of `node_modules`, so `.env` files and other projects' files stay unreadable.
|
|
83
|
+
*/
|
|
84
|
+
export async function installReadRoots(packageDirectory) {
|
|
85
|
+
const own = await realpath(packageDirectory);
|
|
86
|
+
const roots = new Set([own]);
|
|
87
|
+
const visited = new Set([own]);
|
|
88
|
+
const queue = [own];
|
|
89
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
90
|
+
for (const name of await dependencyNames(next)) {
|
|
91
|
+
const found = await findPackage(next, name);
|
|
92
|
+
if (!found) {
|
|
93
|
+
// Optional and peer dependencies may be absent; an import of them fails on its own.
|
|
94
|
+
debug('Install module dependency not found', { package: next, dependency: name });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const real = await realpath(found);
|
|
98
|
+
roots.add(found);
|
|
99
|
+
roots.add(real);
|
|
100
|
+
if (visited.has(real))
|
|
101
|
+
continue;
|
|
102
|
+
visited.add(real);
|
|
103
|
+
if (visited.size > MAX_READ_PACKAGES)
|
|
104
|
+
throw new InstallPlanningError('INSTALL_DEPENDENCIES_LIMIT');
|
|
105
|
+
queue.push(real);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// A folder inside another allowed folder adds nothing; the own package stays first (cwd).
|
|
109
|
+
const sorted = [...roots].sort();
|
|
110
|
+
const result = [own];
|
|
111
|
+
for (const root of sorted)
|
|
112
|
+
if (root !== own &&
|
|
113
|
+
!sorted.some((other) => other !== root && root.startsWith(other + sep)) &&
|
|
114
|
+
!root.startsWith(own + sep))
|
|
115
|
+
result.push(root);
|
|
116
|
+
debug('Install module read roots', { package: own, roots: result });
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
19
119
|
/**
|
|
20
120
|
* Runs one lifecycle phase of a plugin installation module in a child process.
|
|
21
121
|
* The child uses the Node permission model: it may read only `readRoots`
|
|
22
|
-
* (real paths), cannot write files or spawn processes, and receives no
|
|
122
|
+
* (at their real and link paths), cannot write files or spawn processes, and receives no
|
|
23
123
|
* parent environment variables. Node 24 does not restrict network access.
|
|
24
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* The `--allow-fs-read` flags of the restricted child. Node 24.15's permission model denies a
|
|
127
|
+
* granted folder when a sibling folder whose name starts with it is granted too (with
|
|
128
|
+
* `node_modules/@dokebi/web` and `node_modules/@dokebi/websocket`, `realpath` of the first is
|
|
129
|
+
* ERR_ACCESS_DENIED, so `import('@dokebi/web')` fails: seen with D-0162's options check of
|
|
130
|
+
* dokebi-websocket). Such a folder is granted as `<folder>*`, which Node handles; it adds only
|
|
131
|
+
* the siblings that start with the same name.
|
|
132
|
+
*/
|
|
133
|
+
export function readPermissions(roots) {
|
|
134
|
+
return roots.map((root) => {
|
|
135
|
+
const parent = dirname(root);
|
|
136
|
+
const name = basename(root);
|
|
137
|
+
const shadowed = roots.some((other) => other !== root && dirname(other) === parent && basename(other).startsWith(name));
|
|
138
|
+
return `--allow-fs-read=${root}${shadowed ? '*' : ''}`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
25
141
|
export async function runInstallModule(modulePath, phase, context, readRoots = [dirname(modulePath)]) {
|
|
26
142
|
const input = JSON.stringify(context);
|
|
27
143
|
if (Buffer.byteLength(input) > MAX_BYTES)
|
|
@@ -29,16 +145,22 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
29
145
|
let roots;
|
|
30
146
|
let moduleUrl;
|
|
31
147
|
try {
|
|
32
|
-
|
|
148
|
+
// Each root at its real path and, when it is a link (a package in node_modules), also at the
|
|
149
|
+
// link: Node checks the path it resolves through (node_modules/<name>/package.json) as well.
|
|
150
|
+
const resolved = await Promise.all(readRoots.map(async (root) => [
|
|
151
|
+
await realpath(root),
|
|
152
|
+
join(await realpath(dirname(root)), basename(root)),
|
|
153
|
+
]));
|
|
154
|
+
roots = [...new Set(resolved.flat())];
|
|
33
155
|
moduleUrl = pathToFileURL(await realpath(modulePath)).href;
|
|
34
156
|
}
|
|
35
|
-
catch {
|
|
36
|
-
throw new InstallPlanningError('INSTALL_MODULE_MISSING');
|
|
157
|
+
catch (cause) {
|
|
158
|
+
throw new InstallPlanningError('INSTALL_MODULE_MISSING', { cause });
|
|
37
159
|
}
|
|
38
160
|
return new Promise((resolve, reject) => {
|
|
39
161
|
const child = spawn(process.execPath, [
|
|
40
162
|
'--permission',
|
|
41
|
-
...roots
|
|
163
|
+
...readPermissions(roots),
|
|
42
164
|
'--input-type=module',
|
|
43
165
|
'--eval',
|
|
44
166
|
RUNNER,
|
|
@@ -46,6 +168,7 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
46
168
|
phase,
|
|
47
169
|
], { stdio: ['pipe', 'pipe', 'pipe'], env: {}, cwd: roots[0] });
|
|
48
170
|
const chunks = [];
|
|
171
|
+
let stderr = '';
|
|
49
172
|
let size = 0;
|
|
50
173
|
let failure;
|
|
51
174
|
const stop = (code) => {
|
|
@@ -64,6 +187,8 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
64
187
|
size += chunk.length;
|
|
65
188
|
if (size > MAX_BYTES)
|
|
66
189
|
stop('INSTALL_OUTPUT_LIMIT');
|
|
190
|
+
else
|
|
191
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_CHARS);
|
|
67
192
|
});
|
|
68
193
|
// Both become the typed InstallPlanningError the caller receives; debug keeps the cause.
|
|
69
194
|
child.on('error', (error) => {
|
|
@@ -84,7 +209,9 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
84
209
|
return;
|
|
85
210
|
}
|
|
86
211
|
if (code !== 0) {
|
|
87
|
-
|
|
212
|
+
// The end of stderr, masked while whole (R4CB-7).
|
|
213
|
+
const tail = redactNpmOutput(stderr).trim().slice(-DETAIL_CHARS);
|
|
214
|
+
reject(new InstallPlanningError('INSTALL_PROCESS_FAILED', { detail: tail }));
|
|
88
215
|
return;
|
|
89
216
|
}
|
|
90
217
|
try {
|
|
@@ -103,7 +230,9 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
103
230
|
if (envelope.ok === false &&
|
|
104
231
|
typeof envelope.code === 'string' &&
|
|
105
232
|
/^[A-Z][A-Z0-9_]{0,63}$/.test(envelope.code)) {
|
|
106
|
-
reject(new InstallPlanningError(envelope.code
|
|
233
|
+
reject(new InstallPlanningError(envelope.code, {
|
|
234
|
+
detail: typeof envelope.message === 'string' ? envelope.message : undefined,
|
|
235
|
+
}));
|
|
107
236
|
return;
|
|
108
237
|
}
|
|
109
238
|
}
|
package/dist/install-plan.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { path as paths } from '@dokebi/core/libraries';
|
|
2
|
+
import { validateDefaults } from './config-values.js';
|
|
2
3
|
import { CliError } from './errors.js';
|
|
3
4
|
import { object, relativePath, templateTarget } from './filesystem.js';
|
|
4
|
-
import { validateDefaults } from './merge-install-defaults.js';
|
|
5
5
|
/**
|
|
6
6
|
* Who edits a written file (D-0065 §11): `user` — a scaffold written once for the developer to
|
|
7
7
|
* change (layouts, views, examples); doctor never reports an edit, upgrade keeps an edited copy.
|
|
@@ -71,8 +71,10 @@ export function readInstallPlan(value, legacy, extended = true) {
|
|
|
71
71
|
try {
|
|
72
72
|
validateDefaults(plan.configDefaults);
|
|
73
73
|
}
|
|
74
|
-
catch {
|
|
75
|
-
throw new CliError('INVALID_MANIFEST', 'Invalid install configuration defaults'
|
|
74
|
+
catch (cause) {
|
|
75
|
+
throw new CliError('INVALID_MANIFEST', 'Invalid install configuration defaults', {
|
|
76
|
+
cause,
|
|
77
|
+
});
|
|
76
78
|
}
|
|
77
79
|
configDefaults = plan.configDefaults;
|
|
78
80
|
}
|
package/dist/layout.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { InstallPlan } from './install-plan.js';
|
|
2
2
|
/**
|
|
3
3
|
* Project layout (D-0031, D-0032). Plugins write only into
|
|
4
|
-
* - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
|
|
4
|
+
* - `src/shared/<area>/…` — shared business code: models, services, modules (D-0032), views,
|
|
5
|
+
* mail templates and locales (the consumer-facing rule is "Files and folders" in
|
|
6
|
+
* capability-guide.ts, D-0092 §3)
|
|
5
7
|
* - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
|
|
6
8
|
* Files sit inside such a folder; plugins may also create the folder itself.
|
|
7
9
|
* The plugin that hosts apps (manifest `dokebi.hosts: ["apps"]`, D-0070) also prepares `src/apps/`
|
|
@@ -16,3 +18,12 @@ export declare function assertLayout(plan: Pick<InstallPlan, 'files' | 'director
|
|
|
16
18
|
}, pluginId: string, hosts?: readonly string[]): void;
|
|
17
19
|
/** Installed entry paths from before D-0031 keep working; doctor reports them once. */
|
|
18
20
|
export declare function legacyLayout(path: string, hosts?: readonly string[]): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Source files older recipes wrote outside the "Files and folders" rule (the src root holds only
|
|
23
|
+
* main.ts and dokebi.generated.ts), mapped to where the current recipe writes them. Such a file
|
|
24
|
+
* keeps working where it is: doctor reports it as a LEGACY_LAYOUT warning that names the new
|
|
25
|
+
* place, and a re-run of the recipe keeps using the old file instead of writing a second copy.
|
|
26
|
+
* Moving it stays the developer's step because the file is user code (its relative imports and
|
|
27
|
+
* the import in src/main.ts change with it).
|
|
28
|
+
*/
|
|
29
|
+
export declare const MOVED_RECIPE_FILES: Readonly<Record<string, string>>;
|
package/dist/layout.js
CHANGED
|
@@ -2,7 +2,9 @@ import { path as paths } from '@dokebi/core/libraries';
|
|
|
2
2
|
import { CliError } from './errors.js';
|
|
3
3
|
/**
|
|
4
4
|
* Project layout (D-0031, D-0032). Plugins write only into
|
|
5
|
-
* - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
|
|
5
|
+
* - `src/shared/<area>/…` — shared business code: models, services, modules (D-0032), views,
|
|
6
|
+
* mail templates and locales (the consumer-facing rule is "Files and folders" in
|
|
7
|
+
* capability-guide.ts, D-0092 §3)
|
|
6
8
|
* - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
|
|
7
9
|
* Files sit inside such a folder; plugins may also create the folder itself.
|
|
8
10
|
* The plugin that hosts apps (manifest `dokebi.hosts: ["apps"]`, D-0070) also prepares `src/apps/`
|
|
@@ -10,11 +12,11 @@ import { CliError } from './errors.js';
|
|
|
10
12
|
*/
|
|
11
13
|
export function layoutAllowed(path, hosts = [], kind = 'file') {
|
|
12
14
|
const key = paths.portablePathKey(path);
|
|
13
|
-
// Files live inside a named folder: src/shared/<area>/… (models, modules, views
|
|
14
|
-
// src/infra/<name>/…
|
|
15
|
+
// Files live inside a named folder: src/shared/<area>/… (models, services, modules, views, mail,
|
|
16
|
+
// locales — D-0032, D-0092) or src/infra/<name>/…
|
|
15
17
|
const area = kind === 'file'
|
|
16
|
-
? /^src\/(shared\/(models|modules|views)|infra\/[^/]+)\/.+$/
|
|
17
|
-
: /^src\/(shared\/(models|modules|views)|infra\/[^/]+)(\/.+)?$/;
|
|
18
|
+
? /^src\/(shared\/(models|services|modules|views|mail|locales)|infra\/[^/]+)\/.+$/
|
|
19
|
+
: /^src\/(shared\/(models|services|modules|views|mail|locales)|infra\/[^/]+)(\/.+)?$/;
|
|
18
20
|
if (area.test(key))
|
|
19
21
|
return true;
|
|
20
22
|
return hosts.includes('apps') && (key === 'src/apps' || key.startsWith('src/apps/'));
|
|
@@ -28,9 +30,20 @@ export function assertLayout(plan, pluginId, hosts = []) {
|
|
|
28
30
|
];
|
|
29
31
|
for (const [target, kind] of targets)
|
|
30
32
|
if (!layoutAllowed(target, hosts, kind))
|
|
31
|
-
throw new CliError('INVALID_MANIFEST', `Plugin ${pluginId} writes outside src/shared/{models,modules,views}/ and src/infra/<name>/: ${target}`);
|
|
33
|
+
throw new CliError('INVALID_MANIFEST', `Plugin ${pluginId} writes outside src/shared/{models,services,modules,views,mail,locales}/ and src/infra/<name>/: ${target}`);
|
|
32
34
|
}
|
|
33
35
|
/** Installed entry paths from before D-0031 keep working; doctor reports them once. */
|
|
34
36
|
export function legacyLayout(path, hosts = []) {
|
|
35
37
|
return !layoutAllowed(path, hosts);
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Source files older recipes wrote outside the "Files and folders" rule (the src root holds only
|
|
41
|
+
* main.ts and dokebi.generated.ts), mapped to where the current recipe writes them. Such a file
|
|
42
|
+
* keeps working where it is: doctor reports it as a LEGACY_LAYOUT warning that names the new
|
|
43
|
+
* place, and a re-run of the recipe keeps using the old file instead of writing a second copy.
|
|
44
|
+
* Moving it stays the developer's step because the file is user code (its relative imports and
|
|
45
|
+
* the import in src/main.ts change with it).
|
|
46
|
+
*/
|
|
47
|
+
export const MOVED_RECIPE_FILES = Object.freeze({
|
|
48
|
+
'src/site-worker.ts': 'src/infra/runtime/site-worker.ts',
|
|
49
|
+
});
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
// client → { challenge: C, answer: HMAC(token, 'dokebi-log-writer:client:' + W) }
|
|
14
14
|
// writer → { ok: false, code: 'UNAUTHORIZED' } and closes, or
|
|
15
15
|
// { ok: true, answer: HMAC(token, 'dokebi-log-writer:writer:' + C), writer, app }
|
|
16
|
-
// client → { op: 'status' } | { op: 'stop', timeoutMs, force }
|
|
16
|
+
// client → { op: 'status' } | { op: 'stop', timeoutMs, force } | { op: 'reload' }
|
|
17
|
+
// (only after checking answer)
|
|
17
18
|
// writer → status: { ok: true, app: { pid, running } }
|
|
18
19
|
// stop: { ok: true, stopped, forced, ms } | { ok: false, code: 'STOP_TIMEOUT', message }
|
|
19
|
-
//
|
|
20
|
+
// reload: { ok: true, signalled } (D-0154: SIGHUP to the app; it reads dokebi.yaml again)
|
|
21
|
+
// Only `status`, `stop` and `reload` exist. The writer closes the connection when it exits, so a client
|
|
20
22
|
// that asked for a stop knows the writer finished (its log closed) when the connection ends.
|
|
21
23
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
22
24
|
import { createConnection, createServer } from 'node:net';
|
|
@@ -26,8 +28,13 @@ import { debug, describeError, reportError } from './diagnostics.js';
|
|
|
26
28
|
export const CONTROL_HOST = '127.0.0.1';
|
|
27
29
|
const HELLO = 'dokebi-log-writer';
|
|
28
30
|
const VERSION = 1;
|
|
29
|
-
/** A longer line is not this protocol. */
|
|
31
|
+
/** A longer line (in bytes, without its newline) is not this protocol. */
|
|
30
32
|
const MAX_LINE = 4096;
|
|
33
|
+
/** Messages (and their bytes) received but not yet read; the protocol needs one at a time. */
|
|
34
|
+
const MAX_QUEUED = 2;
|
|
35
|
+
const MAX_QUEUED_BYTES = MAX_QUEUED * MAX_LINE;
|
|
36
|
+
const NEWLINE = 0x0a;
|
|
37
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
31
38
|
/** An unauthenticated connection is closed after this long. */
|
|
32
39
|
const HANDSHAKE_MS = 5000;
|
|
33
40
|
/** The longest stop a client may ask for (one day). */
|
|
@@ -43,17 +50,30 @@ function matches(given, expected) {
|
|
|
43
50
|
const b = Buffer.from(expected);
|
|
44
51
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
45
52
|
}
|
|
46
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Newline-delimited JSON over a socket: read one message at a time, write messages.
|
|
55
|
+
*
|
|
56
|
+
* ISSUE-0006 A05: the limits hold whatever the TCP chunking. Bytes are split on `\n` as they
|
|
57
|
+
* come; every line (complete or still open) is held to MAX_LINE bytes before it is decoded or
|
|
58
|
+
* parsed, and a line is taken only while the peer may send one: `expected` messages at first,
|
|
59
|
+
* then the `replies` each `send` asks for. Anything more is a protocol violation: the queue is
|
|
60
|
+
* dropped (nothing queued is acted on), next() rejects and the connection is destroyed.
|
|
61
|
+
*/
|
|
47
62
|
class LineSocket {
|
|
48
63
|
socket;
|
|
49
|
-
|
|
64
|
+
/** The bytes of the line still open (no newline yet). */
|
|
65
|
+
#pending = [];
|
|
66
|
+
#pendingBytes = 0;
|
|
50
67
|
#queue = [];
|
|
68
|
+
#queuedBytes = 0;
|
|
69
|
+
/** How many more messages the peer may send at this step of the protocol. */
|
|
70
|
+
#allowance;
|
|
51
71
|
#waiting;
|
|
52
72
|
#failure;
|
|
53
73
|
closed;
|
|
54
|
-
constructor(socket) {
|
|
74
|
+
constructor(socket, expected) {
|
|
55
75
|
this.socket = socket;
|
|
56
|
-
|
|
76
|
+
this.#allowance = expected;
|
|
57
77
|
socket.on('data', (chunk) => this.#data(chunk));
|
|
58
78
|
// Reported to whoever reads next (next() rejects); the socket closes after an error.
|
|
59
79
|
socket.on('error', (error) => this.#fail(error));
|
|
@@ -61,28 +81,64 @@ class LineSocket {
|
|
|
61
81
|
void this.closed.then(() => this.#fail(new Error('connection closed')));
|
|
62
82
|
}
|
|
63
83
|
#data(chunk) {
|
|
64
|
-
this.#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
84
|
+
if (this.#failure)
|
|
85
|
+
return;
|
|
86
|
+
let start = 0;
|
|
87
|
+
for (let end = chunk.indexOf(NEWLINE); end >= 0; end = chunk.indexOf(NEWLINE, start)) {
|
|
88
|
+
const piece = chunk.subarray(start, end);
|
|
89
|
+
start = end + 1;
|
|
90
|
+
const bytes = this.#pendingBytes + piece.length;
|
|
91
|
+
if (!this.#acceptable(bytes))
|
|
92
|
+
return;
|
|
93
|
+
const line = this.#pendingBytes ? Buffer.concat([...this.#pending, piece]) : piece;
|
|
94
|
+
this.#pending = [];
|
|
95
|
+
this.#pendingBytes = 0;
|
|
96
|
+
if (this.#queue.length >= MAX_QUEUED || this.#queuedBytes + bytes > MAX_QUEUED_BYTES) {
|
|
97
|
+
this.#violation('too many messages waiting');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
68
100
|
let message;
|
|
69
101
|
try {
|
|
70
|
-
message = JSON.parse(line);
|
|
102
|
+
message = JSON.parse(UTF8.decode(line));
|
|
71
103
|
}
|
|
72
104
|
catch (error) {
|
|
73
|
-
this.#
|
|
74
|
-
this.socket.destroy();
|
|
105
|
+
this.#violation(`not a protocol message: ${describeError(error).split('\n')[0]}`);
|
|
75
106
|
return;
|
|
76
107
|
}
|
|
77
|
-
this.#
|
|
108
|
+
this.#allowance--;
|
|
109
|
+
this.#queue.push({ message, bytes });
|
|
110
|
+
this.#queuedBytes += bytes;
|
|
78
111
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
82
|
-
|
|
112
|
+
const rest = chunk.subarray(start);
|
|
113
|
+
if (rest.length) {
|
|
114
|
+
if (!this.#acceptable(this.#pendingBytes + rest.length))
|
|
115
|
+
return;
|
|
116
|
+
this.#pending.push(rest);
|
|
117
|
+
this.#pendingBytes += rest.length;
|
|
83
118
|
}
|
|
84
119
|
this.#deliver();
|
|
85
120
|
}
|
|
121
|
+
/** A line of `bytes` so far may go on: a message is expected and the line is short enough. */
|
|
122
|
+
#acceptable(bytes) {
|
|
123
|
+
if (this.#allowance <= 0) {
|
|
124
|
+
this.#violation('a message the protocol does not expect at this step');
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (bytes > MAX_LINE) {
|
|
128
|
+
this.#violation(`a line longer than ${MAX_LINE} bytes`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
#violation(reason) {
|
|
134
|
+
debug('Control connection broke the protocol', { reason });
|
|
135
|
+
this.#queue = [];
|
|
136
|
+
this.#queuedBytes = 0;
|
|
137
|
+
this.#pending = [];
|
|
138
|
+
this.#pendingBytes = 0;
|
|
139
|
+
this.#fail(new Error(reason));
|
|
140
|
+
this.socket.destroy();
|
|
141
|
+
}
|
|
86
142
|
#fail(error) {
|
|
87
143
|
this.#failure ??= error;
|
|
88
144
|
this.#deliver();
|
|
@@ -92,7 +148,13 @@ class LineSocket {
|
|
|
92
148
|
if (!waiting || (!this.#queue.length && !this.#failure))
|
|
93
149
|
return;
|
|
94
150
|
this.#waiting = undefined;
|
|
95
|
-
|
|
151
|
+
const next = this.#queue.shift();
|
|
152
|
+
if (next) {
|
|
153
|
+
this.#queuedBytes -= next.bytes;
|
|
154
|
+
waiting(next.message);
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
waiting(this.#failure);
|
|
96
158
|
}
|
|
97
159
|
/** The next message; rejects when the connection fails or closes first, or after `ms`. */
|
|
98
160
|
next(ms) {
|
|
@@ -113,7 +175,9 @@ class LineSocket {
|
|
|
113
175
|
this.#deliver();
|
|
114
176
|
});
|
|
115
177
|
}
|
|
116
|
-
send(
|
|
178
|
+
/** Sends `message`; the peer may then send `replies` messages (the protocol's next step). */
|
|
179
|
+
send(message, replies = 0) {
|
|
180
|
+
this.#allowance += replies;
|
|
117
181
|
if (!this.socket.destroyed)
|
|
118
182
|
this.socket.write(`${JSON.stringify(message)}\n`);
|
|
119
183
|
}
|
|
@@ -178,7 +242,8 @@ export class ControlServer {
|
|
|
178
242
|
#connection(socket) {
|
|
179
243
|
this.#sockets.add(socket);
|
|
180
244
|
socket.once('close', () => this.#sockets.delete(socket));
|
|
181
|
-
|
|
245
|
+
// The client speaks only after the hello (which allows its proof).
|
|
246
|
+
const lines = new LineSocket(socket, 0);
|
|
182
247
|
void this.#serve(lines).catch((error) => {
|
|
183
248
|
// A client that broke the protocol or went silent: nothing was done for it.
|
|
184
249
|
reportError('CONTROL_REQUEST_FAILED', 'A control request failed', error, 5000);
|
|
@@ -187,7 +252,7 @@ export class ControlServer {
|
|
|
187
252
|
}
|
|
188
253
|
async #serve(lines) {
|
|
189
254
|
const challenge = randomBytes(24).toString('base64url');
|
|
190
|
-
lines.send({ hello: HELLO, version: VERSION, challenge });
|
|
255
|
+
lines.send({ hello: HELLO, version: VERSION, challenge }, 1);
|
|
191
256
|
const hello = await lines.next(HANDSHAKE_MS);
|
|
192
257
|
if (!matches(hello.answer, controlAnswer(this.token, 'client', challenge))) {
|
|
193
258
|
debug('Control client refused: wrong instance proof');
|
|
@@ -208,13 +273,26 @@ export class ControlServer {
|
|
|
208
273
|
answer: controlAnswer(this.token, 'writer', hello.challenge),
|
|
209
274
|
writer: process.pid,
|
|
210
275
|
app: { pid: app.pid, running: app.running() },
|
|
211
|
-
});
|
|
276
|
+
}, 1);
|
|
212
277
|
const request = await lines.next(HANDSHAKE_MS);
|
|
213
278
|
if (request.op === 'status') {
|
|
214
279
|
lines.send({ ok: true, app: { pid: app.pid, running: app.running() } });
|
|
215
280
|
lines.socket.end();
|
|
216
281
|
return;
|
|
217
282
|
}
|
|
283
|
+
if (request.op === 'reload') {
|
|
284
|
+
// D-0154: the CLI checked that the app reads dokebi.yaml again on SIGHUP before asking.
|
|
285
|
+
const running = app.running();
|
|
286
|
+
if (running)
|
|
287
|
+
app.signal('SIGHUP');
|
|
288
|
+
debug('Signalled the app to reload its configuration on a control request', {
|
|
289
|
+
pid: app.pid,
|
|
290
|
+
running,
|
|
291
|
+
});
|
|
292
|
+
lines.send({ ok: true, signalled: running });
|
|
293
|
+
lines.socket.end();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
218
296
|
const timeoutMs = request.timeoutMs;
|
|
219
297
|
if (request.op !== 'stop' ||
|
|
220
298
|
!Number.isSafeInteger(timeoutMs) ||
|
|
@@ -266,7 +344,8 @@ export async function askLogWriter(port, token, request,
|
|
|
266
344
|
stepMs = 3000) {
|
|
267
345
|
const path = `${CONTROL_HOST}:${port}`;
|
|
268
346
|
const socket = createConnection({ host: CONTROL_HOST, port });
|
|
269
|
-
|
|
347
|
+
// The writer greets first: one message expected.
|
|
348
|
+
const lines = new LineSocket(socket, 1);
|
|
270
349
|
const unreached = (reason) => {
|
|
271
350
|
socket.destroy();
|
|
272
351
|
debug('Log writer not reached', { path, reason });
|
|
@@ -291,7 +370,7 @@ stepMs = 3000) {
|
|
|
291
370
|
if (hello.hello !== HELLO || typeof hello.challenge !== 'string')
|
|
292
371
|
return unreached(`${path} is not a dokebi log writer`);
|
|
293
372
|
const challenge = randomBytes(24).toString('base64url');
|
|
294
|
-
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) });
|
|
373
|
+
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) }, 1);
|
|
295
374
|
const proof = await lines.next(stepMs);
|
|
296
375
|
if (proof.code === 'NOT_READY')
|
|
297
376
|
return unreached('the log writer has not started the app yet');
|
|
@@ -303,7 +382,7 @@ stepMs = 3000) {
|
|
|
303
382
|
if (typeof app?.pid !== 'number' || typeof proof.writer !== 'number')
|
|
304
383
|
return unreached('the log writer sent an unexpected answer');
|
|
305
384
|
debug('Log writer proved the instance token', { path, writer: proof.writer, op: request.op });
|
|
306
|
-
lines.send(request);
|
|
385
|
+
lines.send(request, 1);
|
|
307
386
|
const replyMs = request.op === 'stop' ? request.timeoutMs + (request.force ? 5000 : 0) + stepMs : stepMs;
|
|
308
387
|
const reply = await lines.next(replyMs);
|
|
309
388
|
return {
|