@dokebi/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +17 -12
- package/README.md +20 -26
- package/dist/bin.js +198 -9
- package/dist/config-edit.d.ts +19 -0
- package/dist/config-edit.js +124 -0
- package/dist/configuration-scaffold.d.ts +1 -0
- package/dist/configuration-scaffold.js +150 -0
- package/dist/configuration.d.ts +19 -0
- package/dist/configuration.js +84 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -1
- package/dist/example-config.d.ts +11 -0
- package/dist/example-config.js +16 -0
- package/dist/filesystem.js +9 -7
- package/dist/generator.d.ts +12 -2
- package/dist/generator.js +152 -22
- package/dist/install-module.d.ts +23 -0
- package/dist/install-module.js +107 -0
- package/dist/install-plan.d.ts +12 -0
- package/dist/install-plan.js +90 -0
- package/dist/layout.d.ts +17 -0
- package/dist/layout.js +35 -0
- package/dist/manifest.d.ts +24 -2
- package/dist/manifest.js +103 -38
- package/dist/merge-install-defaults.d.ts +2 -0
- package/dist/merge-install-defaults.js +79 -0
- package/dist/npm-client.d.ts +6 -0
- package/dist/npm-client.js +34 -20
- package/dist/npm-paths.js +0 -2
- package/dist/output.d.ts +1 -1
- package/dist/output.js +9 -10
- package/dist/plugin-lifecycle.d.ts +43 -0
- package/dist/plugin-lifecycle.js +194 -0
- package/dist/plugin-options.d.ts +7 -0
- package/dist/plugin-options.js +23 -41
- package/dist/project-guide.d.ts +1 -1
- package/dist/project-guide.js +8 -3
- package/dist/project-inspector.d.ts +7 -1
- package/dist/project-inspector.js +84 -19
- package/dist/project-state.d.ts +16 -1
- package/dist/project-state.js +87 -13
- package/dist/project.d.ts +82 -3
- package/dist/project.js +1138 -111
- package/dist/required-plugins.d.ts +6 -0
- package/dist/required-plugins.js +45 -0
- package/dist/transaction.d.ts +6 -1
- package/dist/transaction.js +54 -5
- package/dist/workspace.d.ts +22 -0
- package/dist/workspace.js +97 -0
- package/package.json +7 -5
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { isMap, isNode, parseDocument } from 'yaml';
|
|
2
|
+
import { parseProjectConfig, renderConfig } from './configuration.js';
|
|
3
|
+
const SENSITIVE = /(?:password|passwd|secret|token|credential|api.?key|private.?key|connection.?string|url|uri|host|address|endpoint|servers?)/i;
|
|
4
|
+
const ADDRESS = /^(?:[a-z][a-z0-9+.-]*:\/\/|localhost(?::|$)|(?:\d{1,3}\.){3}\d{1,3}(?::|$))/i;
|
|
5
|
+
const FORBIDDEN = new Set(['__proto__', '<<']);
|
|
6
|
+
const COMPOSITE = [
|
|
7
|
+
'$ref',
|
|
8
|
+
'$dynamicRef',
|
|
9
|
+
'allOf',
|
|
10
|
+
'anyOf',
|
|
11
|
+
'oneOf',
|
|
12
|
+
'not',
|
|
13
|
+
'if',
|
|
14
|
+
'then',
|
|
15
|
+
'else',
|
|
16
|
+
'dependentSchemas',
|
|
17
|
+
];
|
|
18
|
+
const composite = (schema) => COMPOSITE.some((key) => Object.hasOwn(schema, key));
|
|
19
|
+
function record(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function description(value) {
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return '';
|
|
25
|
+
let result = '';
|
|
26
|
+
let inControlRun = false;
|
|
27
|
+
for (const character of value) {
|
|
28
|
+
const code = character.charCodeAt(0);
|
|
29
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
30
|
+
if (!inControlRun)
|
|
31
|
+
result += ' ';
|
|
32
|
+
inControlRun = true;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
result += character;
|
|
36
|
+
inControlRun = false;
|
|
37
|
+
}
|
|
38
|
+
if (result.length >= 240)
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return result.slice(0, 240);
|
|
42
|
+
}
|
|
43
|
+
function safeDefault(value, schema, path) {
|
|
44
|
+
if (composite(schema) ||
|
|
45
|
+
schema.writeOnly === true ||
|
|
46
|
+
path.some((key) => SENSITIVE.test(key) || FORBIDDEN.has(key)))
|
|
47
|
+
return false;
|
|
48
|
+
if (value === null || typeof value === 'boolean')
|
|
49
|
+
return true;
|
|
50
|
+
if (typeof value === 'number')
|
|
51
|
+
return Number.isFinite(value);
|
|
52
|
+
if (typeof value === 'string')
|
|
53
|
+
return value.length <= 256 && !ADDRESS.test(value);
|
|
54
|
+
if (Array.isArray(value))
|
|
55
|
+
return (value.length <= 100 &&
|
|
56
|
+
(value.length === 0 ||
|
|
57
|
+
(record(schema.items) &&
|
|
58
|
+
value.every((item) => safeDefault(item, schema.items, path)))));
|
|
59
|
+
if (record(value)) {
|
|
60
|
+
if (Object.keys(value).length > 100)
|
|
61
|
+
return false;
|
|
62
|
+
const properties = record(schema.properties) ? schema.properties : {};
|
|
63
|
+
return Object.entries(value).every(([key, child]) => record(properties[key]) &&
|
|
64
|
+
safeDefault(child, properties[key], [...path, key]));
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
export function scaffoldConfig(source, id, schema) {
|
|
69
|
+
parseProjectConfig(source);
|
|
70
|
+
const document = parseDocument(source, { version: '1.2' });
|
|
71
|
+
const base = ['options', id];
|
|
72
|
+
if (!document.hasIn(base))
|
|
73
|
+
document.setIn(base, document.createNode({}));
|
|
74
|
+
const lines = [
|
|
75
|
+
'Configuration for ' + id + '. Values marked required must be set before starting.',
|
|
76
|
+
];
|
|
77
|
+
let visited = 0;
|
|
78
|
+
const visit = (current, path, names, depth, allowDefaults) => {
|
|
79
|
+
if (depth > 12 || visited >= 200)
|
|
80
|
+
return;
|
|
81
|
+
const properties = record(current.properties) ? current.properties : {};
|
|
82
|
+
const required = new Set(Array.isArray(current.required)
|
|
83
|
+
? current.required.filter((value) => typeof value === 'string')
|
|
84
|
+
: []);
|
|
85
|
+
for (const [name, candidate] of Object.entries(properties)) {
|
|
86
|
+
if (++visited > 200) {
|
|
87
|
+
lines.push('Additional fields: see the installed options schema.');
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (!record(candidate))
|
|
91
|
+
continue;
|
|
92
|
+
const keys = [...path, name];
|
|
93
|
+
const labels = [...names, name];
|
|
94
|
+
const label = labels.map((key) => JSON.stringify(key)).join('.');
|
|
95
|
+
const type = typeof candidate.type === 'string'
|
|
96
|
+
? candidate.type
|
|
97
|
+
: Array.isArray(candidate.type)
|
|
98
|
+
? candidate.type.filter((item) => typeof item === 'string').join('|')
|
|
99
|
+
: 'schema-defined';
|
|
100
|
+
const note = description(candidate.description);
|
|
101
|
+
lines.push(label +
|
|
102
|
+
' (' +
|
|
103
|
+
type +
|
|
104
|
+
', ' +
|
|
105
|
+
(required.has(name) ? 'required' : 'optional') +
|
|
106
|
+
')' +
|
|
107
|
+
(note ? ': ' + note : ''));
|
|
108
|
+
const parent = document.getIn(path, true);
|
|
109
|
+
const writable = !document.hasIn(path) || isMap(parent);
|
|
110
|
+
const canDefault = allowDefaults &&
|
|
111
|
+
current.writeOnly !== true &&
|
|
112
|
+
candidate.writeOnly !== true &&
|
|
113
|
+
!composite(current) &&
|
|
114
|
+
!composite(candidate);
|
|
115
|
+
if (canDefault &&
|
|
116
|
+
writable &&
|
|
117
|
+
!document.hasIn(keys) &&
|
|
118
|
+
Object.hasOwn(candidate, 'default') &&
|
|
119
|
+
safeDefault(candidate.default, candidate, labels) &&
|
|
120
|
+
!FORBIDDEN.has(name)) {
|
|
121
|
+
for (let length = base.length + 1; length < keys.length; length++) {
|
|
122
|
+
const parentPath = keys.slice(0, length);
|
|
123
|
+
if (!document.hasIn(parentPath))
|
|
124
|
+
document.setIn(parentPath, document.createNode({}));
|
|
125
|
+
}
|
|
126
|
+
document.setIn(keys, document.createNode(candidate.default));
|
|
127
|
+
}
|
|
128
|
+
if (candidate.type === 'object' && record(candidate.properties)) {
|
|
129
|
+
const present = document.hasIn(keys);
|
|
130
|
+
const child = document.getIn(keys, true);
|
|
131
|
+
const canDescend = canDefault && (present ? isMap(child) : required.has(name));
|
|
132
|
+
visit(candidate, keys, labels, depth + 1, canDescend);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
if (schema)
|
|
137
|
+
visit(schema, base, [], 0, true);
|
|
138
|
+
else
|
|
139
|
+
lines.push('No options schema is installed; consult the plugin documentation.');
|
|
140
|
+
const node = document.getIn(base, true);
|
|
141
|
+
if (isNode(node)) {
|
|
142
|
+
const marker = '[dokebi:' + id + ']';
|
|
143
|
+
if (!node.commentBefore?.includes(marker))
|
|
144
|
+
node.commentBefore =
|
|
145
|
+
(node.commentBefore ? node.commentBefore + '\n' : '') + marker + '\n' + lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
const result = renderConfig(document);
|
|
148
|
+
parseProjectConfig(result);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Document } from 'yaml';
|
|
2
|
+
import { type ProjectConfig } from './project-state.js';
|
|
3
|
+
export declare const CONFIG_FILE = "dokebi.yaml";
|
|
4
|
+
export declare const LEGACY_CONFIG_FILE = "dokebi.json";
|
|
5
|
+
export declare const CONFIG_BACKUP = ".dokebi/dokebi.json.backup";
|
|
6
|
+
export declare const CONFIG_EXAMPLE = "# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions: {}\n";
|
|
7
|
+
/** The real dokebi.yaml written by init: same shape, its own header. */
|
|
8
|
+
export declare const CONFIG_TEMPLATE = "# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions: {}\n";
|
|
9
|
+
/**
|
|
10
|
+
* Serializes a configuration document edited by the CLI. Non-empty mappings and sequences under
|
|
11
|
+
* `options` are written in block style, so schema comments and values stay readable even when the
|
|
12
|
+
* file started from `options: {}`. Values are unchanged; only the layout is normalized.
|
|
13
|
+
*/
|
|
14
|
+
export declare function renderConfig(document: Document): string;
|
|
15
|
+
export declare const CONFIG_IGNORES: string[];
|
|
16
|
+
export declare function parseProjectConfig(source: string): ProjectConfig;
|
|
17
|
+
export declare function addConfigSection(source: string, id: string): string;
|
|
18
|
+
export declare function migrateConfig(source: string): string;
|
|
19
|
+
export declare function ignoreConfig(source: string): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ConfigError, parseConfig } from '@dokebi/config';
|
|
2
|
+
import { isMap, parseDocument, stringify, visit } from 'yaml';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { readConfig } from './project-state.js';
|
|
5
|
+
export const CONFIG_FILE = 'dokebi.yaml';
|
|
6
|
+
export const LEGACY_CONFIG_FILE = 'dokebi.json';
|
|
7
|
+
export const CONFIG_BACKUP = '.dokebi/dokebi.json.backup';
|
|
8
|
+
export const CONFIG_EXAMPLE = '# Copy to dokebi.yaml and keep actual credentials out of Git.\nschemaVersion: 1\noptions: {}\n';
|
|
9
|
+
/** The real dokebi.yaml written by init: same shape, its own header. */
|
|
10
|
+
export const CONFIG_TEMPLATE = '# Settings for this machine. Not committed to Git; dokebi.example.yaml is the shared sample.\nschemaVersion: 1\noptions: {}\n';
|
|
11
|
+
/**
|
|
12
|
+
* Serializes a configuration document edited by the CLI. Non-empty mappings and sequences under
|
|
13
|
+
* `options` are written in block style, so schema comments and values stay readable even when the
|
|
14
|
+
* file started from `options: {}`. Values are unchanged; only the layout is normalized.
|
|
15
|
+
*/
|
|
16
|
+
export function renderConfig(document) {
|
|
17
|
+
const options = document.get('options', true);
|
|
18
|
+
if (isMap(options)) {
|
|
19
|
+
visit(options, {
|
|
20
|
+
Map(_, node) {
|
|
21
|
+
if (node.items.length)
|
|
22
|
+
node.flow = false;
|
|
23
|
+
},
|
|
24
|
+
Seq(_, node) {
|
|
25
|
+
if (node.items.length)
|
|
26
|
+
node.flow = false;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
options.flow = false;
|
|
30
|
+
}
|
|
31
|
+
return document.toString();
|
|
32
|
+
}
|
|
33
|
+
export const CONFIG_IGNORES = ['dokebi.yaml', 'dokebi.json', '.dokebi/dokebi.json.backup'];
|
|
34
|
+
export function parseProjectConfig(source) {
|
|
35
|
+
try {
|
|
36
|
+
return readConfig(parseConfig(source));
|
|
37
|
+
}
|
|
38
|
+
catch (cause) {
|
|
39
|
+
if (cause instanceof ConfigError) {
|
|
40
|
+
throw new CliError('INVALID_CONFIG', 'Invalid dokebi.yaml; inspect configuration syntax and schema', {
|
|
41
|
+
details: {
|
|
42
|
+
code: cause.code,
|
|
43
|
+
line: cause.line ?? null,
|
|
44
|
+
column: cause.column ?? null,
|
|
45
|
+
issues: cause.issues,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
throw new CliError('INVALID_CONFIG', 'Invalid dokebi.yaml; inspect configuration syntax and schema');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Parse through the runtime boundary first; AST edits only add missing sections.
|
|
53
|
+
export function addConfigSection(source, id) {
|
|
54
|
+
const config = parseProjectConfig(source);
|
|
55
|
+
if (Object.hasOwn(config.options, id))
|
|
56
|
+
return source;
|
|
57
|
+
const document = parseDocument(source, { version: '1.2' });
|
|
58
|
+
document.setIn(['options', id], {});
|
|
59
|
+
const node = document.getIn(['options', id], true);
|
|
60
|
+
if (node && typeof node === 'object' && 'commentBefore' in node)
|
|
61
|
+
node.commentBefore =
|
|
62
|
+
' Configure this plugin using its installed options schema and documentation.';
|
|
63
|
+
return renderConfig(document);
|
|
64
|
+
}
|
|
65
|
+
export function migrateConfig(source) {
|
|
66
|
+
let value;
|
|
67
|
+
try {
|
|
68
|
+
value = JSON.parse(source);
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
throw new CliError('INVALID_CONFIG', 'Invalid legacy JSON configuration', { cause });
|
|
72
|
+
}
|
|
73
|
+
readConfig(value);
|
|
74
|
+
const yaml = stringify(value);
|
|
75
|
+
parseProjectConfig(yaml);
|
|
76
|
+
return yaml;
|
|
77
|
+
}
|
|
78
|
+
export function ignoreConfig(source) {
|
|
79
|
+
const lines = new Set(source.split(/\r?\n/));
|
|
80
|
+
const missing = CONFIG_IGNORES.filter((entry) => !lines.has(entry));
|
|
81
|
+
return missing.length
|
|
82
|
+
? source + (source && !source.endsWith('\n') ? '\n' : '') + missing.join('\n') + '\n'
|
|
83
|
+
: source;
|
|
84
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export interface CliErrorOptions extends ErrorOptions {
|
|
2
2
|
details?: unknown;
|
|
3
|
+
phase?: 'arguments' | 'install' | 'commit' | 'unknown';
|
|
3
4
|
}
|
|
4
5
|
export declare class CliError extends Error {
|
|
5
6
|
readonly code: string;
|
|
6
7
|
constructor(code: string, message: string, options?: CliErrorOptions);
|
|
7
8
|
readonly details: unknown;
|
|
9
|
+
readonly phase: CliErrorOptions['phase'];
|
|
8
10
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export class CliError extends Error {
|
|
2
2
|
code;
|
|
3
|
-
// [AI:수정] 저장소의 TypeScript 포맷과 import 순서 규칙을 적용한다.
|
|
4
3
|
constructor(code, message, options) {
|
|
5
4
|
super(message, options);
|
|
6
5
|
this.code = code;
|
|
7
6
|
this.name = 'CliError';
|
|
8
7
|
this.details = options?.details;
|
|
8
|
+
this.phase = options?.phase;
|
|
9
9
|
}
|
|
10
10
|
details;
|
|
11
|
+
phase;
|
|
11
12
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ConfigChange } from './plugin-lifecycle.js';
|
|
2
|
+
/**
|
|
3
|
+
* Updates dokebi.example.yaml for one plugin from its manifest defaults, configuration
|
|
4
|
+
* changes and schema guidance. Values from the real dokebi.yaml are never copied.
|
|
5
|
+
*/
|
|
6
|
+
export declare function syncExample(source: string, id: string, update: {
|
|
7
|
+
readonly defaults?: readonly Record<string, unknown>[];
|
|
8
|
+
readonly changes?: readonly ConfigChange[];
|
|
9
|
+
readonly schema?: Record<string, unknown> | null;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function markExampleRemoved(source: string, id: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { applyConfigChanges, markRemoved } from './config-edit.js';
|
|
2
|
+
import { scaffoldConfig } from './configuration-scaffold.js';
|
|
3
|
+
import { mergeInstallDefaults } from './merge-install-defaults.js';
|
|
4
|
+
/**
|
|
5
|
+
* Updates dokebi.example.yaml for one plugin from its manifest defaults, configuration
|
|
6
|
+
* changes and schema guidance. Values from the real dokebi.yaml are never copied.
|
|
7
|
+
*/
|
|
8
|
+
export function syncExample(source, id, update) {
|
|
9
|
+
let result = applyConfigChanges(source, id, update.changes ?? []);
|
|
10
|
+
for (const defaults of update.defaults ?? [])
|
|
11
|
+
result = mergeInstallDefaults(result, id, defaults);
|
|
12
|
+
return scaffoldConfig(result, id, update.schema ?? null);
|
|
13
|
+
}
|
|
14
|
+
export function markExampleRemoved(source, id) {
|
|
15
|
+
return markRemoved(source, id).source;
|
|
16
|
+
}
|
package/dist/filesystem.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
import { hash as hashing, path as paths } from '@dokebi/core/libraries';
|
|
3
|
-
// [AI:삭제] 타입 명시와 경로 제어 문자 검증을 유지하면서 저장소 검사 규칙을 충족한다.
|
|
4
|
-
// [AI:수정] 저장소의 TypeScript 포맷과 import 순서 규칙을 적용한다.
|
|
5
|
-
import { lstat, readFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
6
2
|
import { join } from 'node:path';
|
|
3
|
+
import { hash as hashing, path as paths } from '@dokebi/core/libraries';
|
|
7
4
|
import { CliError } from './errors.js';
|
|
8
5
|
export const hash = hashing.sha256;
|
|
9
6
|
export const json = (value) => JSON.stringify(value, null, 2) + '\n';
|
|
@@ -13,7 +10,12 @@ export function relativePath(path) {
|
|
|
13
10
|
}
|
|
14
11
|
export function templateTarget(path) {
|
|
15
12
|
relativePath(path);
|
|
16
|
-
|
|
13
|
+
const key = paths.portablePathKey(path);
|
|
14
|
+
if (!path.startsWith('src/') ||
|
|
15
|
+
key === 'src/main.ts' ||
|
|
16
|
+
key.startsWith('src/main.ts/') ||
|
|
17
|
+
key === 'src/dokebi.generated.ts' ||
|
|
18
|
+
key.startsWith('src/dokebi.generated.ts/')) {
|
|
17
19
|
throw new CliError('UNSAFE_PATH', `Template target is reserved: ${path}`);
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -59,7 +61,7 @@ export async function fingerprint(path) {
|
|
|
59
61
|
return `directory:${stat.ino}:${stat.mtimeMs}`;
|
|
60
62
|
if (!stat.isFile())
|
|
61
63
|
throw new CliError('UNSAFE_PATH', 'Only regular files are supported');
|
|
62
|
-
return hashing.
|
|
64
|
+
return hashing.sha256File(path);
|
|
63
65
|
}
|
|
64
66
|
export function object(value, label) {
|
|
65
67
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
package/dist/generator.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
import type { PluginManifest } from './manifest.js';
|
|
2
|
-
|
|
3
|
-
export declare
|
|
2
|
+
import type { Installation } from './project-state.js';
|
|
3
|
+
export declare function generateRegistration(plugins: PluginManifest[], installations?: Readonly<Record<string, Installation>>, apps?: readonly string[], modules?: readonly string[]): string;
|
|
4
|
+
export declare const LEGACY_MAIN_SOURCE = "import { Application } from '@dokebi/core';\nimport { registerPlugins } from './dokebi.generated.ts';\n\nconst app = new Application();\nregisterPlugins(app);\nawait app.start();\ntry {\n app.logger.log('info', 'Dokebi application started');\n // Run local work here. Long-running apps own their signal and shutdown policy.\n} finally {\n await app.stop();\n}\n";
|
|
5
|
+
/** The 0.2.0 YAML entry point before it became long-running; migrate still recognizes it. */
|
|
6
|
+
export declare const MAIN_SOURCE_V1 = "import { Application, loadConfig } from '@dokebi/core';\nimport { registerPlugins } from './dokebi.generated.ts';\n\nconst document = await loadConfig(new URL('../dokebi.yaml', import.meta.url));\nconst app = new Application({ config: document.options });\nawait registerPlugins(app);\nawait app.start();\ntry {\n app.logger.log('info', 'Dokebi application started');\n // Run local work here. Long-running apps own their signal and shutdown policy.\n} finally {\n await app.stop();\n}\n";
|
|
7
|
+
export declare const MAIN_SOURCE = "import { Application, loadConfig } from '@dokebi/core';\nimport { registerPlugins } from './dokebi.generated.ts';\n\nconst document = await loadConfig(new URL('../dokebi.yaml', import.meta.url));\nconst app = new Application({ config: document.options });\nawait registerPlugins(app);\nawait app.start();\napp.logger.log('info', 'Dokebi application started');\n\n// The app runs until SIGINT/SIGTERM (servers, consumers) or until nothing is left to run\n// (one-off work), then drains and cleans up once. A second Ctrl+C exits immediately.\nlet stopping: Promise<void> | undefined;\nconst stop = (reason: string) =>\n (stopping ??= app.stop().then(\n () => app.logger.log('info', 'Dokebi application stopped', { reason }),\n () => {\n process.exitCode = 1;\n app.logger.log('error', 'Dokebi application did not stop cleanly', { reason });\n },\n ));\nprocess.once('SIGINT', () => void stop('SIGINT'));\nprocess.once('SIGTERM', () => void stop('SIGTERM'));\nprocess.once('beforeExit', () => void stop('idle'));\n";
|
|
8
|
+
/**
|
|
9
|
+
* tsconfig.json written by init. `node src/main.ts` strips types without compiling, so the
|
|
10
|
+
* editor and `tsc` should reject syntax Node cannot run (erasableSyntaxOnly) and need explicit
|
|
11
|
+
* `.ts` imports and `import type` (allowImportingTsExtensions, verbatimModuleSyntax).
|
|
12
|
+
*/
|
|
13
|
+
export declare const TSCONFIG_SOURCE: string;
|
package/dist/generator.js
CHANGED
|
@@ -1,33 +1,120 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
/** Import specifier of a user entry file, relative to src/dokebi.generated.ts. */
|
|
2
|
+
function entrySpecifier(target) {
|
|
3
|
+
return './' + target.slice('src/'.length);
|
|
4
|
+
}
|
|
5
|
+
export function generateRegistration(plugins, installations = {}, apps = [], modules = []) {
|
|
6
|
+
const imports = [];
|
|
7
|
+
const factories = [];
|
|
8
|
+
plugins.forEach((plugin, index) => {
|
|
9
|
+
imports.push(`import { createPlugin as factory${index} } from ${JSON.stringify(plugin.packageName)};`);
|
|
10
|
+
const entry = installations[plugin.id]?.entry;
|
|
11
|
+
if (entry)
|
|
12
|
+
imports.push(`import * as entry${index} from ${JSON.stringify(entrySpecifier(entry))};`);
|
|
13
|
+
let entryCode = entry ? `, entry: entry${index}` : '';
|
|
14
|
+
// dokebi-app receives the apps recorded by `dokebi app add` (src/apps/<name>/app.ts).
|
|
15
|
+
if (plugin.id === 'dokebi-app') {
|
|
16
|
+
apps.forEach((name, appIndex) => {
|
|
17
|
+
imports.push(`import app${appIndex} from ${JSON.stringify(`./apps/${name}/app.ts`)};`);
|
|
18
|
+
});
|
|
19
|
+
entryCode = `, entry: { apps: { ${apps.map((name, appIndex) => `${JSON.stringify(name)}: app${appIndex}`).join(', ')} } }`;
|
|
20
|
+
}
|
|
21
|
+
// dokebi-module receives the modules recorded by `dokebi module add` (D-0032), after the
|
|
22
|
+
// list of an older entry file (src/shared/modules/index.ts) when the project still has one.
|
|
23
|
+
if (plugin.id === 'dokebi-module' && modules.length) {
|
|
24
|
+
modules.forEach((name, moduleIndex) => {
|
|
25
|
+
imports.push(`import module${moduleIndex} from ${JSON.stringify(`./shared/modules/${name}/module.ts`)};`);
|
|
26
|
+
});
|
|
27
|
+
const list = modules.map((_name, moduleIndex) => `module${moduleIndex}`);
|
|
28
|
+
entryCode = `, entry: { modules: [${[...(entry ? [`...entry${index}.modules`] : []), ...list].join(', ')}] }`;
|
|
29
|
+
}
|
|
30
|
+
factories.push(` factory${index}({ app: app.view, options: optionsOf(${JSON.stringify(plugin.id)})${entryCode} }),`);
|
|
13
31
|
});
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
// Plugins with an entry may receive user-chosen dependencies (for example web requires),
|
|
33
|
+
// so only plugins without an entry are held to their manifest dependency list.
|
|
34
|
+
const manifests = plugins.map((plugin) => ({
|
|
35
|
+
id: plugin.id,
|
|
36
|
+
version: plugin.version,
|
|
37
|
+
dependencies: plugin.dependencies.map((dependency) => dependency.name),
|
|
38
|
+
entry: Boolean(installations[plugin.id]?.entry) ||
|
|
39
|
+
plugin.id === 'dokebi-app' ||
|
|
40
|
+
(plugin.id === 'dokebi-module' && modules.length > 0),
|
|
41
|
+
}));
|
|
42
|
+
// Schemas are found through module resolution, so hoisted installs (npm workspaces,
|
|
43
|
+
// monorepos) work the same as a project-local node_modules.
|
|
44
|
+
const schemas = plugins
|
|
45
|
+
.filter((plugin) => plugin.optionsSchema)
|
|
46
|
+
.map((plugin) => ({
|
|
47
|
+
id: plugin.id,
|
|
48
|
+
packageName: plugin.packageName,
|
|
49
|
+
file: plugin.optionsSchema,
|
|
50
|
+
}));
|
|
51
|
+
return `// Generated by dokebi. Change dokebi.yaml options or plugin entry files instead of this file.
|
|
52
|
+
import { readFile } from 'node:fs/promises';
|
|
53
|
+
import { type Application, ConfigValidator, loadSchema, type Plugin } from '@dokebi/core';
|
|
17
54
|
${imports.join('\n')}
|
|
18
55
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
56
|
+
const MANIFESTS: readonly {
|
|
57
|
+
id: string;
|
|
58
|
+
version: string;
|
|
59
|
+
dependencies: readonly string[];
|
|
60
|
+
entry: boolean;
|
|
61
|
+
}[] = ${JSON.stringify(manifests)};
|
|
62
|
+
const SCHEMAS: readonly { id: string; packageName: string; file: string }[] = ${JSON.stringify(schemas)};
|
|
63
|
+
|
|
64
|
+
/** A file inside an installed package, found from the package entry the app resolves. */
|
|
65
|
+
async function packageFile(packageName: string, file: string): Promise<URL> {
|
|
66
|
+
let directory = new URL('.', import.meta.resolve(packageName));
|
|
67
|
+
for (;;) {
|
|
68
|
+
try {
|
|
69
|
+
const manifest: unknown = JSON.parse(await readFile(new URL('package.json', directory), 'utf8'));
|
|
70
|
+
if (manifest !== null && typeof manifest === 'object' && 'name' in manifest && manifest.name === packageName)
|
|
71
|
+
return new URL(file, directory);
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
if ((cause as { code?: unknown }).code !== 'ENOENT') throw cause;
|
|
74
|
+
}
|
|
75
|
+
const parent = new URL('..', directory);
|
|
76
|
+
if (parent.href === directory.href) throw new Error('Installed package not found: ' + packageName);
|
|
77
|
+
directory = parent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function registerPlugins(app: Application): Promise<void> {
|
|
82
|
+
const options: Record<string, unknown> = app.config;
|
|
83
|
+
const optionsOf = (id: string): unknown =>
|
|
84
|
+
Object.hasOwn(options, id) && options[id] !== null ? options[id] : {};
|
|
85
|
+
const validator = new ConfigValidator();
|
|
86
|
+
for (const schema of SCHEMAS) {
|
|
87
|
+
let loaded: Record<string, unknown>;
|
|
88
|
+
try {
|
|
89
|
+
loaded = await loadSchema(await packageFile(schema.packageName, schema.file));
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
throw new Error('Unable to load the options schema of plugin ' + schema.id, { cause });
|
|
92
|
+
}
|
|
93
|
+
await validator.validate(loaded, optionsOf(schema.id));
|
|
94
|
+
}
|
|
95
|
+
const plugins: Plugin<unknown>[] = [
|
|
96
|
+
${factories.join('\n')}
|
|
97
|
+
];
|
|
98
|
+
for (const [index, plugin] of plugins.entries()) {
|
|
99
|
+
const manifest = MANIFESTS[index];
|
|
100
|
+
if (!manifest || plugin.metadata.name !== manifest.id || plugin.metadata.version !== manifest.version)
|
|
101
|
+
throw new Error('Plugin runtime identity differs from installed manifest');
|
|
102
|
+
for (const dependency of plugin.metadata.dependencies ?? [])
|
|
103
|
+
if (!manifest.entry && !manifest.dependencies.includes(dependency.name))
|
|
104
|
+
throw new Error('Plugin runtime dependency is not declared in its manifest: ' + manifest.id);
|
|
105
|
+
app.register(plugin);
|
|
106
|
+
}
|
|
23
107
|
}
|
|
24
108
|
`;
|
|
25
109
|
}
|
|
26
|
-
export const
|
|
110
|
+
export const LEGACY_MAIN_SOURCE = "import { Application } from '@dokebi/core';\nimport { registerPlugins } from './dokebi.generated.ts';\n\nconst app = new Application();\nregisterPlugins(app);\nawait app.start();\ntry {\n app.logger.log('info', 'Dokebi application started');\n // Run local work here. Long-running apps own their signal and shutdown policy.\n} finally {\n await app.stop();\n}\n";
|
|
111
|
+
/** The 0.2.0 YAML entry point before it became long-running; migrate still recognizes it. */
|
|
112
|
+
export const MAIN_SOURCE_V1 = `import { Application, loadConfig } from '@dokebi/core';
|
|
27
113
|
import { registerPlugins } from './dokebi.generated.ts';
|
|
28
114
|
|
|
29
|
-
const
|
|
30
|
-
|
|
115
|
+
const document = await loadConfig(new URL('../dokebi.yaml', import.meta.url));
|
|
116
|
+
const app = new Application({ config: document.options });
|
|
117
|
+
await registerPlugins(app);
|
|
31
118
|
await app.start();
|
|
32
119
|
try {
|
|
33
120
|
app.logger.log('info', 'Dokebi application started');
|
|
@@ -36,3 +123,46 @@ try {
|
|
|
36
123
|
await app.stop();
|
|
37
124
|
}
|
|
38
125
|
`;
|
|
126
|
+
export const MAIN_SOURCE = `import { Application, loadConfig } from '@dokebi/core';
|
|
127
|
+
import { registerPlugins } from './dokebi.generated.ts';
|
|
128
|
+
|
|
129
|
+
const document = await loadConfig(new URL('../dokebi.yaml', import.meta.url));
|
|
130
|
+
const app = new Application({ config: document.options });
|
|
131
|
+
await registerPlugins(app);
|
|
132
|
+
await app.start();
|
|
133
|
+
app.logger.log('info', 'Dokebi application started');
|
|
134
|
+
|
|
135
|
+
// The app runs until SIGINT/SIGTERM (servers, consumers) or until nothing is left to run
|
|
136
|
+
// (one-off work), then drains and cleans up once. A second Ctrl+C exits immediately.
|
|
137
|
+
let stopping: Promise<void> | undefined;
|
|
138
|
+
const stop = (reason: string) =>
|
|
139
|
+
(stopping ??= app.stop().then(
|
|
140
|
+
() => app.logger.log('info', 'Dokebi application stopped', { reason }),
|
|
141
|
+
() => {
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
app.logger.log('error', 'Dokebi application did not stop cleanly', { reason });
|
|
144
|
+
},
|
|
145
|
+
));
|
|
146
|
+
process.once('SIGINT', () => void stop('SIGINT'));
|
|
147
|
+
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
148
|
+
process.once('beforeExit', () => void stop('idle'));
|
|
149
|
+
`;
|
|
150
|
+
/**
|
|
151
|
+
* tsconfig.json written by init. `node src/main.ts` strips types without compiling, so the
|
|
152
|
+
* editor and `tsc` should reject syntax Node cannot run (erasableSyntaxOnly) and need explicit
|
|
153
|
+
* `.ts` imports and `import type` (allowImportingTsExtensions, verbatimModuleSyntax).
|
|
154
|
+
*/
|
|
155
|
+
export const TSCONFIG_SOURCE = `${JSON.stringify({
|
|
156
|
+
compilerOptions: {
|
|
157
|
+
target: 'ES2023',
|
|
158
|
+
module: 'NodeNext',
|
|
159
|
+
moduleResolution: 'NodeNext',
|
|
160
|
+
strict: true,
|
|
161
|
+
noEmit: true,
|
|
162
|
+
allowImportingTsExtensions: true,
|
|
163
|
+
erasableSyntaxOnly: true,
|
|
164
|
+
verbatimModuleSyntax: true,
|
|
165
|
+
skipLibCheck: true,
|
|
166
|
+
},
|
|
167
|
+
include: ['src/**/*.ts'],
|
|
168
|
+
}, null, 2)}\n`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type InstallPhase = 'planInstall' | 'planUpgrade' | 'check';
|
|
2
|
+
export interface InstallModuleContext {
|
|
3
|
+
readonly options: Readonly<Record<string, unknown>>;
|
|
4
|
+
readonly plugins: readonly {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly version: string;
|
|
7
|
+
readonly packageName: string;
|
|
8
|
+
}[];
|
|
9
|
+
readonly [key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare class InstallPlanningError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
constructor(code: string);
|
|
14
|
+
}
|
|
15
|
+
/** Returned when the module does not export the requested phase. */
|
|
16
|
+
export declare const PHASE_MISSING: unique symbol;
|
|
17
|
+
/**
|
|
18
|
+
* Runs one lifecycle phase of a plugin installation module in a child process.
|
|
19
|
+
* The child uses the Node permission model: it may read only `readRoots`
|
|
20
|
+
* (real paths), cannot write files or spawn processes, and receives no
|
|
21
|
+
* parent environment variables. Node 24 does not restrict network access.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runInstallModule(modulePath: string, phase: InstallPhase, context: InstallModuleContext, readRoots?: readonly string[]): Promise<unknown>;
|