@dokebi/cli 0.2.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +181 -87
- package/README.md +55 -24
- package/catalog.json +2203 -0
- package/dist/app-urls.d.ts +21 -0
- package/dist/app-urls.js +94 -0
- package/dist/banner-art.d.ts +4 -0
- package/dist/banner-art.js +31 -0
- package/dist/banner.d.ts +25 -0
- package/dist/banner.js +202 -0
- package/dist/bin.d.ts +2 -1
- package/dist/bin.js +475 -46
- package/dist/catalog.d.ts +161 -0
- package/dist/catalog.js +110 -0
- package/dist/check/form-rules.d.ts +13 -0
- package/dist/check/form-rules.js +224 -0
- package/dist/check/index.d.ts +23 -0
- package/dist/check/index.js +70 -0
- package/dist/check/packages.d.ts +17 -0
- package/dist/check/packages.js +161 -0
- package/dist/check/problems.d.ts +19 -0
- package/dist/check/problems.js +22 -0
- package/dist/check/project-rules.d.ts +10 -0
- package/dist/check/project-rules.js +142 -0
- package/dist/check/source-rules.d.ts +28 -0
- package/dist/check/source-rules.js +273 -0
- package/dist/check/typescript.d.ts +46 -0
- package/dist/check/typescript.js +168 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +372 -0
- package/dist/config-edit.d.ts +5 -4
- package/dist/config-edit.js +9 -8
- package/dist/configuration.d.ts +1 -0
- package/dist/configuration.js +2 -1
- package/dist/dev-infra.d.ts +62 -0
- package/dist/dev-infra.js +215 -0
- package/dist/env-file.d.ts +24 -0
- package/dist/env-file.js +100 -0
- package/dist/generator.d.ts +24 -1
- package/dist/generator.js +57 -47
- package/dist/guide.d.ts +29 -0
- package/dist/guide.js +90 -0
- package/dist/install-plan.d.ts +8 -0
- package/dist/install-plan.js +21 -4
- package/dist/layout.d.ts +5 -4
- package/dist/layout.js +8 -7
- package/dist/log-writer-control.d.ts +55 -0
- package/dist/log-writer-control.js +327 -0
- package/dist/log-writer.js +53 -12
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +77 -2
- package/dist/npm-client.d.ts +49 -0
- package/dist/npm-client.js +162 -41
- package/dist/output.d.ts +1 -1
- package/dist/output.js +14 -8
- package/dist/plugin-lifecycle.d.ts +7 -0
- package/dist/plugin-lifecycle.js +21 -3
- package/dist/plugins-command.d.ts +47 -0
- package/dist/plugins-command.js +189 -0
- package/dist/project-guide.d.ts +36 -1
- package/dist/project-guide.js +142 -18
- package/dist/project-inspector.d.ts +10 -0
- package/dist/project-inspector.js +51 -2
- package/dist/project-state.d.ts +13 -2
- package/dist/project-state.js +8 -6
- package/dist/project.d.ts +89 -7
- package/dist/project.js +835 -85
- package/dist/recipe-record.d.ts +24 -0
- package/dist/recipe-record.js +67 -0
- package/dist/recipe.d.ts +120 -0
- package/dist/recipe.js +801 -0
- package/dist/registry.d.ts +79 -0
- package/dist/registry.js +212 -0
- package/dist/rotating-log.d.ts +12 -0
- package/dist/rotating-log.js +19 -0
- package/dist/server.d.ts +94 -4
- package/dist/server.js +624 -64
- package/dist/verify.d.ts +62 -0
- package/dist/verify.js +380 -0
- package/dist/vite-host-process.d.ts +20 -0
- package/dist/vite-host-process.js +117 -0
- package/package.json +30 -6
package/dist/guide.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { COMMANDS } from './commands.js';
|
|
2
|
+
import { CLI_VERSION } from './output.js';
|
|
3
|
+
/** The short pointer printed at the top of `dokebi --help`. */
|
|
4
|
+
export const HELP_POINTER = 'AI and automation: read `dokebi guide` (--json) first. Ask the user every choice and end your turn until they answer; finish with `dokebi verify` and say done only when it is ok. Every command takes --json and changing commands take --dry-run. Outside a project run npx -y @dokebi/cli (inside, npx dokebi uses the project copy); the npm package named "dokebi" is not this CLI\n';
|
|
5
|
+
export const GUIDE_STEPS = [
|
|
6
|
+
{
|
|
7
|
+
command: 'npx -y @dokebi/cli init <folder> && cd <folder>',
|
|
8
|
+
why: 'Only when there is no project yet: creates core, config, this CLI, TypeScript and AGENTS.md (never npx -y dokebi: that npm package belongs to someone else). If the work folder you were given is empty, init . inside it works too',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
command: 'npx dokebi recipe list --json',
|
|
12
|
+
why: 'Pick the recipe that fits the request (a blog → blog, a JSON API → api). If none fits, use plugins search',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
command: 'npx dokebi recipe <name> --json',
|
|
16
|
+
why: 'Without answers it installs nothing and returns questions[] (waitForUser: true). Show the user every question with its options, pros/cons and default, then **end your turn**. Ask these questions and end your turn; do not run recipe/add/config until the user answers. Only use --defaults if the user said to decide.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
command: 'npx dokebi recipe <name> --view <v> --db <d> --dev-infra <i> --admin <a> --answers-from user --dry-run --json',
|
|
20
|
+
why: 'Only after the user answered: see the plan (plan.steps) and the preflight for those answers. Ask the user about a preflight error (never switch the database or a port behind their back)',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
command: 'npx dokebi recipe <name> --view <v> --db <d> --dev-infra <i> --admin <a> --answers-from user --json',
|
|
24
|
+
why: 'Apply the plan. Applying needs the source of the answers: --answers-from user (the user answered) or --defaults (the user said to decide); without one it is ANSWERS_SOURCE_REQUIRED. Tell the user the report of the result. Each step runs an existing transactional command; a failure stops with completedSteps, and the same command resumes',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
command: 'npx dokebi plugins doc <id> --json',
|
|
28
|
+
why: 'Read the AI.md of the installed version of every installed plugin (AGENTS.md lists the paths under "Installed packages")',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
command: '(write the app code) src/apps/<app>, src/shared/modules/<module>',
|
|
32
|
+
why: "Follow the nextSteps of the recipe result: attach the module to the app and write the features. Build what Dokebi does not have (absent) in app code, as its instead says. Errors and forms (D-0074): refuse input with throw validationError({ field: 'message' }) from @dokebi/web (422 with errors by field, like a refused schema); Inertia form submits pass only (the props they change) and show form.errors; EJS forms show data.errors.<field> and data.old.<field> (with @dokebi/session a refused form comes back to its page, 303); EJS apps may add views/<template>/errors/404.ejs, errors/422.ejs and errors/error.ejs",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
command: 'npx dokebi check --json',
|
|
36
|
+
why: 'Convention check while you write code (empty catch, promise without await, syntax Node cannot strip, private @dokebi paths, missing packages, edited generated files, secrets that would be committed). Every problem has file, line and fix',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
command: 'npx dokebi verify --json',
|
|
40
|
+
why: "doctor, check, typecheck, build, start, HTTP checks and stop. Tell the user it is done only when ok: true. Your final message to the user must include verify's tellUser lines (the recipe's choices with who made them, defaults with their reasons, and notices such as no CSRF protection)",
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
export const GUIDE_RULES = [
|
|
44
|
+
'Never decide choices such as the view, the database, the development database or an admin area yourself. Ask the user the questions[] of the recipe and end your turn. Run no recipe, add or config before they answer (never "I will go on with the defaults while you choose")',
|
|
45
|
+
'Use --defaults only when the user left the choice to you ("you decide"), and report the chosen values with their defaultReason afterwards. When the user answered, use --answers-from user',
|
|
46
|
+
'Never invent a plugin, a flag or an API for something Dokebi does not have (absent: auth, csrf, upload, mail, markdown, sqlite, React and more)',
|
|
47
|
+
'Never install or run a package outside @dokebi/ (THIRD_PARTY_PACKAGE) without the permission of the user',
|
|
48
|
+
'Never pass a secret with --set or config set (the shell history keeps it). Write it into dokebi.yaml or use the value dev-infra generated',
|
|
49
|
+
'Preview every changing command with --dry-run --json first. Never blindly re-run a failure, and never delete the lock or a backup',
|
|
50
|
+
'Only src/dokebi.generated.ts, .dokebi/state.json and the part of AGENTS.md between the dokebi:installed markers are rewritten by the CLI: never edit them by hand. Every other file the CLI or a plugin created (the shared layout src/shared/views/layouts/main.ejs, entry files, app and module files) is user code: edit it where it is (do not move it)',
|
|
51
|
+
"Never say done before verify returns ok: true. Your final message to the user must include verify's tellUser lines",
|
|
52
|
+
'Errors (D-0074): Inertia, fetch/XHR, Bearer and JSON-body requests get JSON errors and only plain browser pages get HTML, so read errors from the JSON. Refuse input with validationError({ field: message }) (422 VALIDATION_FAILED, fields and errors), never a hand-made 422; give every Inertia form submit an only option; an EJS write form shows data.errors.<field> and data.old.<field> (add @dokebi/session so a refused form comes back to its page)',
|
|
53
|
+
];
|
|
54
|
+
export function buildGuide(catalog) {
|
|
55
|
+
const recipes = catalog.recipes.map((recipe) => ({
|
|
56
|
+
name: recipe.name,
|
|
57
|
+
summary: recipe.summary,
|
|
58
|
+
questions: recipe.questions,
|
|
59
|
+
}));
|
|
60
|
+
const absent = catalog.absent.map((entry) => entry.capability);
|
|
61
|
+
const text = [
|
|
62
|
+
`# Dokebi CLI ${CLI_VERSION} — AI first page`,
|
|
63
|
+
'',
|
|
64
|
+
'When the user asks in one line, such as "make me a blog with the Dokebi framework (@dokebi/cli)", follow these steps in order.',
|
|
65
|
+
'',
|
|
66
|
+
...GUIDE_STEPS.map((step, index) => `${index + 1}. ${step.command}\n ${step.why}`),
|
|
67
|
+
'',
|
|
68
|
+
'## Rules',
|
|
69
|
+
...GUIDE_RULES.map((rule) => `- ${rule}`),
|
|
70
|
+
'',
|
|
71
|
+
'## Recipes',
|
|
72
|
+
...recipes.map((recipe) => `- ${recipe.name}: ${recipe.summary} (questions: ${recipe.questions.join(', ')})`),
|
|
73
|
+
'',
|
|
74
|
+
`## What Dokebi does not have (absent): ${absent.join(', ')}`,
|
|
75
|
+
'Details: npx dokebi plugins search <word> --json',
|
|
76
|
+
'',
|
|
77
|
+
'## Commands',
|
|
78
|
+
...COMMANDS.map((item) => `${item.usage}\n ${item.summary}`),
|
|
79
|
+
'',
|
|
80
|
+
].join('\n');
|
|
81
|
+
return {
|
|
82
|
+
cliVersion: CLI_VERSION,
|
|
83
|
+
steps: GUIDE_STEPS,
|
|
84
|
+
rules: GUIDE_RULES,
|
|
85
|
+
recipes,
|
|
86
|
+
absent,
|
|
87
|
+
commands: COMMANDS.map(({ usage, summary }) => ({ usage, summary })),
|
|
88
|
+
text,
|
|
89
|
+
};
|
|
90
|
+
}
|
package/dist/install-plan.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { Template } from './manifest.js';
|
|
2
|
+
/**
|
|
3
|
+
* Who edits a written file (D-0065 §11): `user` — a scaffold written once for the developer to
|
|
4
|
+
* change (layouts, views, examples); doctor never reports an edit, upgrade keeps an edited copy.
|
|
5
|
+
* `cli` — a file the CLI or plugin regenerates; an edit is doctor MODIFIED_TEMPLATE (warning) and
|
|
6
|
+
* a `dokebi check` generated-file problem. Absent means `user`; entry files are always `user`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const FILE_OWNERS: readonly ['user', 'cli'];
|
|
9
|
+
export type FileOwner = (typeof FILE_OWNERS)[number];
|
|
2
10
|
export interface InstallPlan {
|
|
3
11
|
files: Template[];
|
|
4
12
|
directories?: string[];
|
package/dist/install-plan.js
CHANGED
|
@@ -2,16 +2,30 @@ import { path as paths } from '@dokebi/core/libraries';
|
|
|
2
2
|
import { CliError } from './errors.js';
|
|
3
3
|
import { object, relativePath, templateTarget } from './filesystem.js';
|
|
4
4
|
import { validateDefaults } from './merge-install-defaults.js';
|
|
5
|
+
/**
|
|
6
|
+
* Who edits a written file (D-0065 §11): `user` — a scaffold written once for the developer to
|
|
7
|
+
* change (layouts, views, examples); doctor never reports an edit, upgrade keeps an edited copy.
|
|
8
|
+
* `cli` — a file the CLI or plugin regenerates; an edit is doctor MODIFIED_TEMPLATE (warning) and
|
|
9
|
+
* a `dokebi check` generated-file problem. Absent means `user`; entry files are always `user`.
|
|
10
|
+
*/
|
|
11
|
+
export const FILE_OWNERS = ['user', 'cli'];
|
|
5
12
|
const ENTRY_EXTENSION = /\.(?:ts|mts|js|mjs)$/;
|
|
6
13
|
function template(value, label) {
|
|
7
14
|
const file = object(value, label);
|
|
8
|
-
if (Object.keys(file).some((key) => key !== 'source' && key !== 'target') ||
|
|
15
|
+
if (Object.keys(file).some((key) => key !== 'source' && key !== 'target' && key !== 'owner') ||
|
|
9
16
|
typeof file.source !== 'string' ||
|
|
10
|
-
typeof file.target !== 'string'
|
|
11
|
-
|
|
17
|
+
typeof file.target !== 'string' ||
|
|
18
|
+
(file.owner !== undefined && !FILE_OWNERS.includes(file.owner)))
|
|
19
|
+
throw new CliError('INVALID_MANIFEST', `Invalid ${label} (keys: source, target, owner: ${FILE_OWNERS.join(' | ')})`);
|
|
12
20
|
relativePath(file.source);
|
|
13
21
|
templateTarget(file.target);
|
|
14
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
source: file.source,
|
|
24
|
+
target: file.target,
|
|
25
|
+
// `user` is the default: kept implicit so a manifest that spells it out records the same as
|
|
26
|
+
// one from before the field (otherwise doctor reports MANIFEST_CHANGED for equal manifests).
|
|
27
|
+
...(file.owner === 'cli' ? { owner: 'cli' } : {}),
|
|
28
|
+
};
|
|
15
29
|
}
|
|
16
30
|
/**
|
|
17
31
|
* Validates one installation plan and merges it after the given files.
|
|
@@ -47,6 +61,9 @@ export function readInstallPlan(value, legacy, extended = true) {
|
|
|
47
61
|
}
|
|
48
62
|
if (plan.entry !== undefined) {
|
|
49
63
|
entry = template(plan.entry, 'install entry');
|
|
64
|
+
// The entry is the developer's code (D-0064): it is never a CLI-owned file.
|
|
65
|
+
if (entry.owner === 'cli')
|
|
66
|
+
throw new CliError('INVALID_MANIFEST', 'Install entry is user-owned (owner: "cli" is not allowed)');
|
|
50
67
|
if (!ENTRY_EXTENSION.test(entry.target))
|
|
51
68
|
throw new CliError('INVALID_MANIFEST', 'Install entry must be a .ts, .mts, .js or .mjs file');
|
|
52
69
|
}
|
package/dist/layout.d.ts
CHANGED
|
@@ -4,14 +4,15 @@ import type { InstallPlan } from './install-plan.js';
|
|
|
4
4
|
* - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
|
|
5
5
|
* - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
|
|
6
6
|
* Files sit inside such a folder; plugins may also create the folder itself.
|
|
7
|
-
* The
|
|
7
|
+
* The plugin that hosts apps (manifest `dokebi.hosts: ["apps"]`, D-0070) also prepares `src/apps/`
|
|
8
|
+
* (each app's folder is made by `dokebi app add`).
|
|
8
9
|
*/
|
|
9
|
-
export declare function layoutAllowed(path: string,
|
|
10
|
+
export declare function layoutAllowed(path: string, hosts?: readonly string[], kind?: 'file' | 'directory'): boolean;
|
|
10
11
|
/** Rejects plugin files and folders outside the layout with INVALID_MANIFEST. */
|
|
11
12
|
export declare function assertLayout(plan: Pick<InstallPlan, 'files' | 'directories'> & {
|
|
12
13
|
entry?: {
|
|
13
14
|
target: string;
|
|
14
15
|
} | undefined;
|
|
15
|
-
}, pluginId: string): void;
|
|
16
|
+
}, pluginId: string, hosts?: readonly string[]): void;
|
|
16
17
|
/** Installed entry paths from before D-0031 keep working; doctor reports them once. */
|
|
17
|
-
export declare function legacyLayout(path: string,
|
|
18
|
+
export declare function legacyLayout(path: string, hosts?: readonly string[]): boolean;
|
package/dist/layout.js
CHANGED
|
@@ -5,9 +5,10 @@ import { CliError } from './errors.js';
|
|
|
5
5
|
* - `src/shared/<area>/…` — shared business code: models, modules (D-0032), views
|
|
6
6
|
* - `src/infra/<name>/…` — code that wires a plugin up (connections, types, shared props …)
|
|
7
7
|
* Files sit inside such a folder; plugins may also create the folder itself.
|
|
8
|
-
* The
|
|
8
|
+
* The plugin that hosts apps (manifest `dokebi.hosts: ["apps"]`, D-0070) also prepares `src/apps/`
|
|
9
|
+
* (each app's folder is made by `dokebi app add`).
|
|
9
10
|
*/
|
|
10
|
-
export function layoutAllowed(path,
|
|
11
|
+
export function layoutAllowed(path, hosts = [], kind = 'file') {
|
|
11
12
|
const key = paths.portablePathKey(path);
|
|
12
13
|
// Files live inside a named folder: src/shared/<area>/… (models, modules, views — D-0032) or
|
|
13
14
|
// src/infra/<name>/…
|
|
@@ -16,20 +17,20 @@ export function layoutAllowed(path, pluginId, kind = 'file') {
|
|
|
16
17
|
: /^src\/(shared\/(models|modules|views)|infra\/[^/]+)(\/.+)?$/;
|
|
17
18
|
if (area.test(key))
|
|
18
19
|
return true;
|
|
19
|
-
return
|
|
20
|
+
return hosts.includes('apps') && (key === 'src/apps' || key.startsWith('src/apps/'));
|
|
20
21
|
}
|
|
21
22
|
/** Rejects plugin files and folders outside the layout with INVALID_MANIFEST. */
|
|
22
|
-
export function assertLayout(plan, pluginId) {
|
|
23
|
+
export function assertLayout(plan, pluginId, hosts = []) {
|
|
23
24
|
const targets = [
|
|
24
25
|
...plan.files.map((file) => [file.target, 'file']),
|
|
25
26
|
...(plan.entry ? [[plan.entry.target, 'file']] : []),
|
|
26
27
|
...(plan.directories ?? []).map((directory) => [directory, 'directory']),
|
|
27
28
|
];
|
|
28
29
|
for (const [target, kind] of targets)
|
|
29
|
-
if (!layoutAllowed(target,
|
|
30
|
+
if (!layoutAllowed(target, hosts, kind))
|
|
30
31
|
throw new CliError('INVALID_MANIFEST', `Plugin ${pluginId} writes outside src/shared/{models,modules,views}/ and src/infra/<name>/: ${target}`);
|
|
31
32
|
}
|
|
32
33
|
/** Installed entry paths from before D-0031 keep working; doctor reports them once. */
|
|
33
|
-
export function legacyLayout(path,
|
|
34
|
-
return !layoutAllowed(path,
|
|
34
|
+
export function legacyLayout(path, hosts = []) {
|
|
35
|
+
return !layoutAllowed(path, hosts);
|
|
35
36
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** The control endpoint listens on loopback only. */
|
|
2
|
+
export declare const CONTROL_HOST = "127.0.0.1";
|
|
3
|
+
/** The proof one side gives for the other side's challenge. */
|
|
4
|
+
export declare function controlAnswer(token: string, role: 'client' | 'writer', challenge: string): string;
|
|
5
|
+
/** What the writer controls: the app it started. */
|
|
6
|
+
export interface ControlledApp {
|
|
7
|
+
readonly pid: number;
|
|
8
|
+
running(): boolean;
|
|
9
|
+
/** Resolves true once the app exited, false when it still runs after `ms`. */
|
|
10
|
+
exited(ms: number): Promise<boolean>;
|
|
11
|
+
signal(signal: NodeJS.Signals): void;
|
|
12
|
+
}
|
|
13
|
+
/** The writer's side: listens, authenticates and drain-stops the app on request. */
|
|
14
|
+
export declare class ControlServer {
|
|
15
|
+
#private;
|
|
16
|
+
readonly token: string;
|
|
17
|
+
constructor(token: string);
|
|
18
|
+
/** The app the writer started; requests before it are answered with NOT_READY. */
|
|
19
|
+
attach(app: ControlledApp): void;
|
|
20
|
+
/** Listens on a random loopback port: the port, or undefined (reported) when it cannot. */
|
|
21
|
+
listen(): Promise<number | undefined>;
|
|
22
|
+
/** Lets pending replies go out, then ends every connection (the writer is done). */
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export type ControlRequest = {
|
|
26
|
+
op: 'status';
|
|
27
|
+
} | {
|
|
28
|
+
op: 'stop';
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
force: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** The writer answered: its proven identity and its reply to the request. */
|
|
33
|
+
export interface ControlReached {
|
|
34
|
+
readonly reached: true;
|
|
35
|
+
readonly writer: number;
|
|
36
|
+
readonly app: {
|
|
37
|
+
readonly pid: number;
|
|
38
|
+
readonly running: boolean;
|
|
39
|
+
};
|
|
40
|
+
readonly reply: Record<string, unknown>;
|
|
41
|
+
/** True once the writer closed the connection (it exited) within `ms`. */
|
|
42
|
+
closed(ms: number): Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
/** The writer could not be asked, or did not prove the token; `reason` says why. */
|
|
45
|
+
export interface ControlUnreached {
|
|
46
|
+
readonly reached: false;
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Asks the log writer listening on the loopback `port` to do `request`, after both sides proved
|
|
51
|
+
* `token`. Never throws: an unreachable or unproven writer is `reached: false`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function askLogWriter(port: number, token: string, request: ControlRequest,
|
|
54
|
+
/** How long each step of the handshake may take; a stop's reply waits for the stop itself. */
|
|
55
|
+
stepMs?: number): Promise<ControlReached | ControlUnreached>;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// The control endpoint of the log writer (D-0073 addendum, AI-M03). Agent sandboxes such as
|
|
2
|
+
// Codex CLI run every shell command in its own sandbox instance and refuse signals across
|
|
3
|
+
// instances: a later `dokebi stop` cannot even `kill(pid, 0)` the app a previous `dokebi start`
|
|
4
|
+
// left running. The log writer was started in that same instance (it is the app's parent) and
|
|
5
|
+
// can signal it, so it listens on a loopback TCP port (127.0.0.1, a random port that `start`
|
|
6
|
+
// records in the 0600 pid file as `control`) and stops the app on request. Loopback TCP rather
|
|
7
|
+
// than a Unix socket: agent sandboxes allow loopback connections when they allow the app's own
|
|
8
|
+
// HTTP port, but may refuse Unix sockets that are not on their allow list (Claude Code).
|
|
9
|
+
//
|
|
10
|
+
// Both sides prove the per-start instance token (DOKEBI_INSTANCE_TOKEN, kept in the pid file)
|
|
11
|
+
// before anything is done; the token never goes on the wire. Newline-delimited JSON:
|
|
12
|
+
// writer → { hello: 'dokebi-log-writer', version: 1, challenge: W }
|
|
13
|
+
// client → { challenge: C, answer: HMAC(token, 'dokebi-log-writer:client:' + W) }
|
|
14
|
+
// writer → { ok: false, code: 'UNAUTHORIZED' } and closes, or
|
|
15
|
+
// { ok: true, answer: HMAC(token, 'dokebi-log-writer:writer:' + C), writer, app }
|
|
16
|
+
// client → { op: 'status' } | { op: 'stop', timeoutMs, force } (only after checking answer)
|
|
17
|
+
// writer → status: { ok: true, app: { pid, running } }
|
|
18
|
+
// stop: { ok: true, stopped, forced, ms } | { ok: false, code: 'STOP_TIMEOUT', message }
|
|
19
|
+
// Only `status` and `stop` exist. The writer closes the connection when it exits, so a client
|
|
20
|
+
// that asked for a stop knows the writer finished (its log closed) when the connection ends.
|
|
21
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
22
|
+
import { createConnection, createServer } from 'node:net';
|
|
23
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
24
|
+
import { debug, describeError, reportError } from './diagnostics.js';
|
|
25
|
+
/** The control endpoint listens on loopback only. */
|
|
26
|
+
export const CONTROL_HOST = '127.0.0.1';
|
|
27
|
+
const HELLO = 'dokebi-log-writer';
|
|
28
|
+
const VERSION = 1;
|
|
29
|
+
/** A longer line is not this protocol. */
|
|
30
|
+
const MAX_LINE = 4096;
|
|
31
|
+
/** An unauthenticated connection is closed after this long. */
|
|
32
|
+
const HANDSHAKE_MS = 5000;
|
|
33
|
+
/** The longest stop a client may ask for (one day). */
|
|
34
|
+
const MAX_TIMEOUT_MS = 86_400_000;
|
|
35
|
+
/** The proof one side gives for the other side's challenge. */
|
|
36
|
+
export function controlAnswer(token, role, challenge) {
|
|
37
|
+
return createHmac('sha256', token).update(`${HELLO}:${role}:${challenge}`).digest('base64url');
|
|
38
|
+
}
|
|
39
|
+
function matches(given, expected) {
|
|
40
|
+
if (typeof given !== 'string')
|
|
41
|
+
return false;
|
|
42
|
+
const a = Buffer.from(given);
|
|
43
|
+
const b = Buffer.from(expected);
|
|
44
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
45
|
+
}
|
|
46
|
+
/** Newline-delimited JSON over a socket: read one message at a time, write messages. */
|
|
47
|
+
class LineSocket {
|
|
48
|
+
socket;
|
|
49
|
+
#buffer = '';
|
|
50
|
+
#queue = [];
|
|
51
|
+
#waiting;
|
|
52
|
+
#failure;
|
|
53
|
+
closed;
|
|
54
|
+
constructor(socket) {
|
|
55
|
+
this.socket = socket;
|
|
56
|
+
socket.setEncoding('utf8');
|
|
57
|
+
socket.on('data', (chunk) => this.#data(chunk));
|
|
58
|
+
// Reported to whoever reads next (next() rejects); the socket closes after an error.
|
|
59
|
+
socket.on('error', (error) => this.#fail(error));
|
|
60
|
+
this.closed = new Promise((resolve) => socket.once('close', () => resolve()));
|
|
61
|
+
void this.closed.then(() => this.#fail(new Error('connection closed')));
|
|
62
|
+
}
|
|
63
|
+
#data(chunk) {
|
|
64
|
+
this.#buffer += chunk;
|
|
65
|
+
for (let end = this.#buffer.indexOf('\n'); end >= 0; end = this.#buffer.indexOf('\n')) {
|
|
66
|
+
const line = this.#buffer.slice(0, end);
|
|
67
|
+
this.#buffer = this.#buffer.slice(end + 1);
|
|
68
|
+
let message;
|
|
69
|
+
try {
|
|
70
|
+
message = JSON.parse(line);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.#fail(new Error(`not a protocol message: ${describeError(error).split('\n')[0]}`));
|
|
74
|
+
this.socket.destroy();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.#queue.push(message);
|
|
78
|
+
}
|
|
79
|
+
if (this.#buffer.length > MAX_LINE) {
|
|
80
|
+
this.#fail(new Error(`a line longer than ${MAX_LINE} characters`));
|
|
81
|
+
this.socket.destroy();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.#deliver();
|
|
85
|
+
}
|
|
86
|
+
#fail(error) {
|
|
87
|
+
this.#failure ??= error;
|
|
88
|
+
this.#deliver();
|
|
89
|
+
}
|
|
90
|
+
#deliver() {
|
|
91
|
+
const waiting = this.#waiting;
|
|
92
|
+
if (!waiting || (!this.#queue.length && !this.#failure))
|
|
93
|
+
return;
|
|
94
|
+
this.#waiting = undefined;
|
|
95
|
+
waiting(this.#queue.length ? this.#queue.shift() : this.#failure);
|
|
96
|
+
}
|
|
97
|
+
/** The next message; rejects when the connection fails or closes first, or after `ms`. */
|
|
98
|
+
next(ms) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
this.#waiting = undefined;
|
|
102
|
+
reject(new Error(`no answer within ${ms} ms`));
|
|
103
|
+
}, ms);
|
|
104
|
+
this.#waiting = (value) => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
if (value instanceof Error)
|
|
107
|
+
reject(value);
|
|
108
|
+
else if (value && typeof value === 'object')
|
|
109
|
+
resolve(value);
|
|
110
|
+
else
|
|
111
|
+
reject(new Error('not a protocol message'));
|
|
112
|
+
};
|
|
113
|
+
this.#deliver();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
send(message) {
|
|
117
|
+
if (!this.socket.destroyed)
|
|
118
|
+
this.socket.write(`${JSON.stringify(message)}\n`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** The writer's side: listens, authenticates and drain-stops the app on request. */
|
|
122
|
+
export class ControlServer {
|
|
123
|
+
token;
|
|
124
|
+
#server;
|
|
125
|
+
#sockets = new Set();
|
|
126
|
+
/** Stops under way: close() lets their reply go out first. */
|
|
127
|
+
#pending = new Set();
|
|
128
|
+
#app;
|
|
129
|
+
#port;
|
|
130
|
+
constructor(token) {
|
|
131
|
+
this.token = token;
|
|
132
|
+
this.#server = createServer((socket) => this.#connection(socket));
|
|
133
|
+
this.#server.maxConnections = 16;
|
|
134
|
+
}
|
|
135
|
+
/** The app the writer started; requests before it are answered with NOT_READY. */
|
|
136
|
+
attach(app) {
|
|
137
|
+
this.#app = app;
|
|
138
|
+
}
|
|
139
|
+
/** Listens on a random loopback port: the port, or undefined (reported) when it cannot. */
|
|
140
|
+
listen() {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const failed = (error) => {
|
|
143
|
+
// A sandbox without network access refuses listen (EPERM): stop then needs a shell
|
|
144
|
+
// that may signal the app, as before.
|
|
145
|
+
reportError('CONTROL_LISTEN_FAILED', 'Unable to open the control port', error);
|
|
146
|
+
resolve(undefined);
|
|
147
|
+
};
|
|
148
|
+
this.#server.once('error', failed);
|
|
149
|
+
this.#server.listen(0, CONTROL_HOST, () => {
|
|
150
|
+
this.#server.off('error', failed);
|
|
151
|
+
this.#server.on('error', (error) => reportError('CONTROL_SOCKET_FAILED', 'The control port failed', error));
|
|
152
|
+
this.#port = this.#server.address().port;
|
|
153
|
+
debug('Control port listening', { host: CONTROL_HOST, port: this.#port });
|
|
154
|
+
resolve(this.#port);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Lets pending replies go out, then ends every connection (the writer is done). */
|
|
159
|
+
async close() {
|
|
160
|
+
if (this.#port === undefined)
|
|
161
|
+
return;
|
|
162
|
+
this.#port = undefined;
|
|
163
|
+
await Promise.race([Promise.allSettled(this.#pending), delay(1000)]);
|
|
164
|
+
const open = [...this.#sockets].map((socket) => new Promise((resolve) => socket.once('close', () => resolve())));
|
|
165
|
+
for (const socket of this.#sockets)
|
|
166
|
+
socket.end();
|
|
167
|
+
// Stops accepting now; the callback waits for the connections.
|
|
168
|
+
this.#server.close((error) => {
|
|
169
|
+
if (error)
|
|
170
|
+
reportError('CONTROL_SOCKET_FAILED', 'Unable to close the control port', error);
|
|
171
|
+
});
|
|
172
|
+
// Give the ended connections a moment to flush before the process exits.
|
|
173
|
+
await Promise.race([Promise.all(open), delay(1000)]);
|
|
174
|
+
for (const socket of this.#sockets)
|
|
175
|
+
socket.destroy();
|
|
176
|
+
debug('Control port closed');
|
|
177
|
+
}
|
|
178
|
+
#connection(socket) {
|
|
179
|
+
this.#sockets.add(socket);
|
|
180
|
+
socket.once('close', () => this.#sockets.delete(socket));
|
|
181
|
+
const lines = new LineSocket(socket);
|
|
182
|
+
void this.#serve(lines).catch((error) => {
|
|
183
|
+
// A client that broke the protocol or went silent: nothing was done for it.
|
|
184
|
+
reportError('CONTROL_REQUEST_FAILED', 'A control request failed', error, 5000);
|
|
185
|
+
socket.destroy();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async #serve(lines) {
|
|
189
|
+
const challenge = randomBytes(24).toString('base64url');
|
|
190
|
+
lines.send({ hello: HELLO, version: VERSION, challenge });
|
|
191
|
+
const hello = await lines.next(HANDSHAKE_MS);
|
|
192
|
+
if (!matches(hello.answer, controlAnswer(this.token, 'client', challenge))) {
|
|
193
|
+
debug('Control client refused: wrong instance proof');
|
|
194
|
+
lines.send({ ok: false, code: 'UNAUTHORIZED' });
|
|
195
|
+
lines.socket.end();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (typeof hello.challenge !== 'string' || hello.challenge.length < 16)
|
|
199
|
+
throw new Error('the client sent no challenge');
|
|
200
|
+
const app = this.#app;
|
|
201
|
+
if (!app) {
|
|
202
|
+
lines.send({ ok: false, code: 'NOT_READY' });
|
|
203
|
+
lines.socket.end();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
lines.send({
|
|
207
|
+
ok: true,
|
|
208
|
+
answer: controlAnswer(this.token, 'writer', hello.challenge),
|
|
209
|
+
writer: process.pid,
|
|
210
|
+
app: { pid: app.pid, running: app.running() },
|
|
211
|
+
});
|
|
212
|
+
const request = await lines.next(HANDSHAKE_MS);
|
|
213
|
+
if (request.op === 'status') {
|
|
214
|
+
lines.send({ ok: true, app: { pid: app.pid, running: app.running() } });
|
|
215
|
+
lines.socket.end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const timeoutMs = request.timeoutMs;
|
|
219
|
+
if (request.op !== 'stop' ||
|
|
220
|
+
!Number.isSafeInteger(timeoutMs) ||
|
|
221
|
+
timeoutMs < 0 ||
|
|
222
|
+
timeoutMs > MAX_TIMEOUT_MS) {
|
|
223
|
+
lines.send({ ok: false, code: 'USAGE', message: 'unknown request' });
|
|
224
|
+
lines.socket.end();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const work = this.#stop(app, timeoutMs, request.force === true).then((reply) => lines.send(reply));
|
|
228
|
+
this.#pending.add(work);
|
|
229
|
+
try {
|
|
230
|
+
await work;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
this.#pending.delete(work);
|
|
234
|
+
}
|
|
235
|
+
// The connection stays open until the writer exits (close()): the client waits for that.
|
|
236
|
+
}
|
|
237
|
+
async #stop(app, timeoutMs, force) {
|
|
238
|
+
const { pid } = app;
|
|
239
|
+
if (!app.running())
|
|
240
|
+
return { ok: true, stopped: false };
|
|
241
|
+
const began = performance.now();
|
|
242
|
+
const ms = () => Math.round(performance.now() - began);
|
|
243
|
+
debug('Stopping the app on a control request', { pid, timeoutMs, force });
|
|
244
|
+
app.signal('SIGTERM');
|
|
245
|
+
if (await app.exited(timeoutMs))
|
|
246
|
+
return { ok: true, stopped: true, forced: false, ms: ms() };
|
|
247
|
+
if (!force)
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
code: 'STOP_TIMEOUT',
|
|
251
|
+
message: `pid ${pid} did not stop within ${timeoutMs} ms; it is still draining or stuck (use --force)`,
|
|
252
|
+
};
|
|
253
|
+
debug('Stop timed out; sending SIGKILL', { pid });
|
|
254
|
+
app.signal('SIGKILL');
|
|
255
|
+
if (await app.exited(5000))
|
|
256
|
+
return { ok: true, stopped: true, forced: true, ms: ms() };
|
|
257
|
+
return { ok: false, code: 'STOP_TIMEOUT', message: `pid ${pid} survived SIGKILL` };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Asks the log writer listening on the loopback `port` to do `request`, after both sides proved
|
|
262
|
+
* `token`. Never throws: an unreachable or unproven writer is `reached: false`.
|
|
263
|
+
*/
|
|
264
|
+
export async function askLogWriter(port, token, request,
|
|
265
|
+
/** How long each step of the handshake may take; a stop's reply waits for the stop itself. */
|
|
266
|
+
stepMs = 3000) {
|
|
267
|
+
const path = `${CONTROL_HOST}:${port}`;
|
|
268
|
+
const socket = createConnection({ host: CONTROL_HOST, port });
|
|
269
|
+
const lines = new LineSocket(socket);
|
|
270
|
+
const unreached = (reason) => {
|
|
271
|
+
socket.destroy();
|
|
272
|
+
debug('Log writer not reached', { path, reason });
|
|
273
|
+
return { reached: false, reason };
|
|
274
|
+
};
|
|
275
|
+
try {
|
|
276
|
+
await new Promise((resolve, reject) => {
|
|
277
|
+
socket.once('connect', resolve);
|
|
278
|
+
socket.once('error', reject);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const code = error.code;
|
|
283
|
+
return unreached(code === 'ECONNREFUSED'
|
|
284
|
+
? `the log writer is gone (nothing listens on ${path})`
|
|
285
|
+
: code === 'EPERM' || code === 'EACCES'
|
|
286
|
+
? `connecting to ${path} was refused (${code}: a sandbox without local network access?)`
|
|
287
|
+
: `unable to connect to ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const hello = await lines.next(stepMs);
|
|
291
|
+
if (hello.hello !== HELLO || typeof hello.challenge !== 'string')
|
|
292
|
+
return unreached(`${path} is not a dokebi log writer`);
|
|
293
|
+
const challenge = randomBytes(24).toString('base64url');
|
|
294
|
+
lines.send({ challenge, answer: controlAnswer(token, 'client', hello.challenge) });
|
|
295
|
+
const proof = await lines.next(stepMs);
|
|
296
|
+
if (proof.code === 'NOT_READY')
|
|
297
|
+
return unreached('the log writer has not started the app yet');
|
|
298
|
+
if (proof.ok !== true)
|
|
299
|
+
return unreached(`the log writer refused the instance proof (${String(proof.code ?? 'no code')}): the pid file's token is not the one this writer was started with`);
|
|
300
|
+
if (!matches(proof.answer, controlAnswer(token, 'writer', challenge)))
|
|
301
|
+
return unreached('the process at the control socket did not prove the instance token; nothing was asked');
|
|
302
|
+
const app = proof.app;
|
|
303
|
+
if (typeof app?.pid !== 'number' || typeof proof.writer !== 'number')
|
|
304
|
+
return unreached('the log writer sent an unexpected answer');
|
|
305
|
+
debug('Log writer proved the instance token', { path, writer: proof.writer, op: request.op });
|
|
306
|
+
lines.send(request);
|
|
307
|
+
const replyMs = request.op === 'stop' ? request.timeoutMs + (request.force ? 5000 : 0) + stepMs : stepMs;
|
|
308
|
+
const reply = await lines.next(replyMs);
|
|
309
|
+
return {
|
|
310
|
+
reached: true,
|
|
311
|
+
writer: proof.writer,
|
|
312
|
+
app: { pid: app.pid, running: app.running === true },
|
|
313
|
+
reply,
|
|
314
|
+
closed: async (ms) => {
|
|
315
|
+
const done = await Promise.race([
|
|
316
|
+
lines.closed.then(() => true),
|
|
317
|
+
delay(ms, false, { ref: false }),
|
|
318
|
+
]);
|
|
319
|
+
socket.destroy();
|
|
320
|
+
return done;
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
return unreached(`the log writer did not complete the request: ${error instanceof Error ? error.message : String(error)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|