@dokebi/cli 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +15 -14
- package/README.md +20 -26
- package/dist/bin.js +198 -9
- package/dist/config-edit.d.ts +19 -0
- package/dist/config-edit.js +124 -0
- package/dist/configuration-scaffold.d.ts +1 -0
- package/dist/configuration-scaffold.js +150 -0
- package/dist/configuration.d.ts +19 -0
- package/dist/configuration.js +84 -0
- package/dist/errors.js +0 -3
- package/dist/example-config.d.ts +11 -0
- package/dist/example-config.js +16 -0
- package/dist/filesystem.js +9 -7
- package/dist/generator.d.ts +12 -2
- package/dist/generator.js +152 -22
- package/dist/install-module.d.ts +23 -0
- package/dist/install-module.js +107 -0
- package/dist/install-plan.d.ts +12 -0
- package/dist/install-plan.js +90 -0
- package/dist/layout.d.ts +17 -0
- package/dist/layout.js +35 -0
- package/dist/manifest.d.ts +24 -2
- package/dist/manifest.js +103 -43
- package/dist/merge-install-defaults.d.ts +2 -0
- package/dist/merge-install-defaults.js +79 -0
- package/dist/npm-client.d.ts +6 -0
- package/dist/npm-client.js +34 -20
- package/dist/npm-paths.js +0 -2
- package/dist/output.d.ts +1 -1
- package/dist/output.js +2 -7
- package/dist/plugin-lifecycle.d.ts +43 -0
- package/dist/plugin-lifecycle.js +194 -0
- package/dist/plugin-options.d.ts +3 -1
- package/dist/plugin-options.js +9 -38
- package/dist/project-guide.d.ts +1 -1
- package/dist/project-guide.js +8 -3
- package/dist/project-inspector.d.ts +7 -1
- package/dist/project-inspector.js +81 -17
- package/dist/project-state.d.ts +16 -1
- package/dist/project-state.js +87 -13
- package/dist/project.d.ts +82 -3
- package/dist/project.js +1138 -112
- package/dist/required-plugins.d.ts +6 -0
- package/dist/required-plugins.js +45 -0
- package/dist/transaction.d.ts +6 -1
- package/dist/transaction.js +51 -6
- package/dist/workspace.d.ts +22 -0
- package/dist/workspace.js +97 -0
- package/package.json +5 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import valid from 'semver/functions/valid.js';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { object, parseObject } from './filesystem.js';
|
|
5
|
+
import { PACKAGE_NAME } from './manifest.js';
|
|
6
|
+
import { packageFile } from './workspace.js';
|
|
7
|
+
export async function readRequiredPlugins(root) {
|
|
8
|
+
const core = parseObject(await readFile(await packageFile(root, '@dokebi/core', 'package.json'), 'utf8'), 'core');
|
|
9
|
+
if (core.name !== '@dokebi/core')
|
|
10
|
+
throw new CliError('INVALID_CORE', 'Invalid core identity');
|
|
11
|
+
const metadata = object(core.dokebi, 'core metadata');
|
|
12
|
+
const dependencies = object(core.dependencies, 'core dependencies');
|
|
13
|
+
const entries = metadata.requiredPlugins;
|
|
14
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
15
|
+
throw new CliError('INVALID_CORE', 'Core must declare its required plugins');
|
|
16
|
+
const result = [];
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const item = object(entry, 'required plugin');
|
|
19
|
+
if (typeof item.id !== 'string' ||
|
|
20
|
+
!/^[a-z0-9][a-z0-9._/-]*$/.test(item.id) ||
|
|
21
|
+
typeof item.packageName !== 'string' ||
|
|
22
|
+
!PACKAGE_NAME.test(item.packageName) ||
|
|
23
|
+
typeof item.version !== 'string' ||
|
|
24
|
+
!valid(item.version) ||
|
|
25
|
+
!Object.hasOwn(dependencies, item.packageName) ||
|
|
26
|
+
dependencies[item.packageName] !== item.version ||
|
|
27
|
+
result.some((previous) => previous.id === item.id || previous.packageName === item.packageName))
|
|
28
|
+
throw new CliError('INVALID_CORE', 'Invalid required plugin metadata or mandatory dependency');
|
|
29
|
+
const path = await packageFile(root, item.packageName, 'package.json');
|
|
30
|
+
let source;
|
|
31
|
+
try {
|
|
32
|
+
source = await readFile(path, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
36
|
+
throw new CliError('REQUIRED_PLUGIN_MISSING', 'A core-required plugin is missing');
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
const installed = parseObject(source, 'required plugin');
|
|
40
|
+
if (installed.name !== item.packageName || installed.version !== item.version)
|
|
41
|
+
throw new CliError('REQUIRED_PLUGIN_MISMATCH', 'Installed required plugin differs from core metadata');
|
|
42
|
+
result.push({ id: item.id, packageName: item.packageName, version: item.version });
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
package/dist/transaction.d.ts
CHANGED
|
@@ -5,10 +5,15 @@ export declare class Transaction {
|
|
|
5
5
|
private constructor();
|
|
6
6
|
static begin(root: string): Promise<Transaction>;
|
|
7
7
|
capture(path: string): Promise<void>;
|
|
8
|
+
ensureDirectory(path: string): Promise<void>;
|
|
8
9
|
read(path: string): Promise<string>;
|
|
9
10
|
write(path: string, content: string | Uint8Array): Promise<void>;
|
|
10
11
|
replace(path: string, source?: string): Promise<void>;
|
|
11
12
|
get paths(): string[];
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Applies every change; `afterApply` runs once they are all in place, and when it fails the
|
|
15
|
+
* changes are rolled back like a failed apply (D-0035: the workspace npm install).
|
|
16
|
+
*/
|
|
17
|
+
commit(beforeApply?: (index: number) => void, afterApply?: () => Promise<void>): Promise<void>;
|
|
13
18
|
close(): Promise<void>;
|
|
14
19
|
}
|
package/dist/transaction.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, writeFile, } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
|
|
3
2
|
import { dirname, join } from 'node:path';
|
|
4
3
|
import { CliError } from './errors.js';
|
|
5
4
|
import { exists, fingerprint, json, securePath } from './filesystem.js';
|
|
@@ -17,7 +16,6 @@ export class Transaction {
|
|
|
17
16
|
const control = await securePath(root, '.dokebi');
|
|
18
17
|
await mkdir(control, { recursive: true });
|
|
19
18
|
const lock = join(control, 'lock');
|
|
20
|
-
// [AI:수정] 타입 명시와 경로 제어 문자 검증을 유지하면서 저장소 검사 규칙을 충족한다.
|
|
21
19
|
let handle;
|
|
22
20
|
try {
|
|
23
21
|
handle = await open(lock, 'wx');
|
|
@@ -28,7 +26,6 @@ export class Transaction {
|
|
|
28
26
|
try {
|
|
29
27
|
await handle.writeFile(json({ pid: process.pid }));
|
|
30
28
|
await handle.close();
|
|
31
|
-
// [AI:수정] 잠금 확보 후 복구 잔여물이 있으면 기존 백업을 보존하고 새 작업을 차단한다.
|
|
32
29
|
if ((await readdir(control)).some((name) => name.startsWith('transaction-'))) {
|
|
33
30
|
throw new CliError('RECOVERY_REQUIRED', 'Interrupted transaction found; preserve .dokebi/transaction-* for recovery', { phase: 'unknown' });
|
|
34
31
|
}
|
|
@@ -44,13 +41,31 @@ export class Transaction {
|
|
|
44
41
|
if (!this.#snapshots.has(path))
|
|
45
42
|
this.#snapshots.set(path, await fingerprint(await securePath(this.root, path)));
|
|
46
43
|
}
|
|
44
|
+
async ensureDirectory(path) {
|
|
45
|
+
const parts = path.split('/');
|
|
46
|
+
for (let end = 1; end <= parts.length; end++) {
|
|
47
|
+
const current = parts.slice(0, end).join('/');
|
|
48
|
+
const planned = this.#changes.find((change) => change.path === current);
|
|
49
|
+
if (planned) {
|
|
50
|
+
if (planned.kind !== 'directory')
|
|
51
|
+
throw new CliError('DUPLICATE_CHANGE', current);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
await this.capture(current);
|
|
55
|
+
const expected = this.#snapshots.get(current);
|
|
56
|
+
if (expected === null)
|
|
57
|
+
this.#changes.push({ path: current, kind: 'directory' });
|
|
58
|
+
else if (!expected?.startsWith('directory:'))
|
|
59
|
+
throw new CliError('PATH_CONFLICT', current);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
47
62
|
async read(path) {
|
|
48
63
|
await this.capture(path);
|
|
49
64
|
return readFile(await securePath(this.root, path), 'utf8');
|
|
50
65
|
}
|
|
51
66
|
async write(path, content) {
|
|
52
67
|
const source = join(this.directory, `new-${this.#changes.length}`);
|
|
53
|
-
await writeFile(source, content);
|
|
68
|
+
await writeFile(source, content, { mode: 0o600 });
|
|
54
69
|
await this.replace(path, source);
|
|
55
70
|
}
|
|
56
71
|
async replace(path, source) {
|
|
@@ -62,7 +77,11 @@ export class Transaction {
|
|
|
62
77
|
get paths() {
|
|
63
78
|
return this.#changes.map((change) => change.path);
|
|
64
79
|
}
|
|
65
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Applies every change; `afterApply` runs once they are all in place, and when it fails the
|
|
82
|
+
* changes are rolled back like a failed apply (D-0035: the workspace npm install).
|
|
83
|
+
*/
|
|
84
|
+
async commit(beforeApply, afterApply) {
|
|
66
85
|
for (const [path, expected] of this.#snapshots) {
|
|
67
86
|
if ((await fingerprint(await securePath(this.root, path))) !== expected) {
|
|
68
87
|
throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
|
|
@@ -80,6 +99,19 @@ export class Transaction {
|
|
|
80
99
|
for (const [index, change] of journal.entries()) {
|
|
81
100
|
beforeApply?.(index);
|
|
82
101
|
const target = await securePath(this.root, change.path);
|
|
102
|
+
if (change.kind === 'directory') {
|
|
103
|
+
try {
|
|
104
|
+
await mkdir(target);
|
|
105
|
+
change.installed = true;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// A file written earlier in this commit may already have created it as a parent.
|
|
109
|
+
if (error.code !== 'EEXIST' ||
|
|
110
|
+
!(await lstat(target)).isDirectory())
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
83
115
|
await mkdir(dirname(target), { recursive: true });
|
|
84
116
|
if (change.existed) {
|
|
85
117
|
await rename(target, change.backup);
|
|
@@ -90,12 +122,25 @@ export class Transaction {
|
|
|
90
122
|
change.installed = true;
|
|
91
123
|
}
|
|
92
124
|
}
|
|
125
|
+
await afterApply?.();
|
|
93
126
|
}
|
|
94
127
|
catch (cause) {
|
|
95
128
|
const failures = [];
|
|
96
129
|
for (const change of [...journal].reverse()) {
|
|
97
130
|
try {
|
|
98
131
|
const target = await securePath(this.root, change.path);
|
|
132
|
+
if (change.kind === 'directory') {
|
|
133
|
+
if (change.installed) {
|
|
134
|
+
try {
|
|
135
|
+
await rmdir(target);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error.code !== 'ENOENT')
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
99
144
|
if (change.installed)
|
|
100
145
|
await rm(target, { recursive: true, force: true });
|
|
101
146
|
if (change.backedUp)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root of an npm workspace that lists `project` as a member, or undefined. In a member the
|
|
3
|
+
* workspace root owns node_modules and the lock; packages are hoisted or linked there (D-0035).
|
|
4
|
+
*/
|
|
5
|
+
export declare function workspaceRootOf(project: string): Promise<string | undefined>;
|
|
6
|
+
export interface WorkspacePackage {
|
|
7
|
+
directory: string;
|
|
8
|
+
version: string;
|
|
9
|
+
}
|
|
10
|
+
/** The packages of the workspace at `root` by name: their real directories and versions. */
|
|
11
|
+
export declare function workspacePackages(root: string): Promise<Map<string, WorkspacePackage>>;
|
|
12
|
+
/**
|
|
13
|
+
* Directory of the installed package `name` as seen from `base`. A standalone project (and the
|
|
14
|
+
* CLI's staging directory) reads only its own node_modules and never follows a symlink. A
|
|
15
|
+
* workspace member resolves like Node, from its folder up to the workspace root, and follows a
|
|
16
|
+
* package symlink only when its real path stays inside the workspace root (D-0035).
|
|
17
|
+
*/
|
|
18
|
+
export declare function packageDirectory(base: string, name: string): Promise<string>;
|
|
19
|
+
/** A file inside an installed package; files inside packages are never symlinks. */
|
|
20
|
+
export declare function packageFile(base: string, name: string, file: string): Promise<string>;
|
|
21
|
+
/** `node_modules/<name>/<file>` → its real location (see packageDirectory). */
|
|
22
|
+
export declare function installedPath(base: string, path: string): Promise<string>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { glob, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { exists, relativePath, securePath } from './filesystem.js';
|
|
5
|
+
/** `*` matches one path segment, `**` any number; patterns are npm `workspaces` entries. */
|
|
6
|
+
function matches(pattern, path) {
|
|
7
|
+
const source = pattern
|
|
8
|
+
.replace(/\/+$/, '')
|
|
9
|
+
.split('/')
|
|
10
|
+
.map((part) => part === '**' ? '.*' : part.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'))
|
|
11
|
+
.join('/');
|
|
12
|
+
return new RegExp(`^${source}$`).test(path);
|
|
13
|
+
}
|
|
14
|
+
function patternsOf(pkg) {
|
|
15
|
+
const list = Array.isArray(pkg.workspaces)
|
|
16
|
+
? pkg.workspaces
|
|
17
|
+
: pkg.workspaces?.packages;
|
|
18
|
+
return Array.isArray(list) ? list.filter((item) => typeof item === 'string') : [];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The root of an npm workspace that lists `project` as a member, or undefined. In a member the
|
|
22
|
+
* workspace root owns node_modules and the lock; packages are hoisted or linked there (D-0035).
|
|
23
|
+
*/
|
|
24
|
+
export async function workspaceRootOf(project) {
|
|
25
|
+
for (let directory = dirname(project);; directory = dirname(directory)) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
|
|
28
|
+
const member = relative(directory, project).split(sep).join('/');
|
|
29
|
+
// Like npm: hidden folders and node_modules are never workspace members.
|
|
30
|
+
const hidden = member
|
|
31
|
+
.split('/')
|
|
32
|
+
.some((part) => part.startsWith('.') || part === 'node_modules');
|
|
33
|
+
if (!hidden && patternsOf(pkg).some((item) => matches(item, member)))
|
|
34
|
+
return directory;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* no or unreadable package.json: keep walking up */
|
|
38
|
+
}
|
|
39
|
+
if (dirname(directory) === directory)
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The packages of the workspace at `root` by name: their real directories and versions. */
|
|
44
|
+
export async function workspacePackages(root) {
|
|
45
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
46
|
+
const result = new Map();
|
|
47
|
+
for (const pattern of patternsOf(pkg)) {
|
|
48
|
+
for await (const file of glob(`${pattern.replace(/\/+$/, '')}/package.json`, {
|
|
49
|
+
cwd: root,
|
|
50
|
+
exclude: (name) => name === 'node_modules',
|
|
51
|
+
})) {
|
|
52
|
+
const member = JSON.parse(await readFile(join(root, file), 'utf8'));
|
|
53
|
+
if (typeof member.name === 'string' && typeof member.version === 'string')
|
|
54
|
+
result.set(member.name, {
|
|
55
|
+
directory: await realpath(join(root, dirname(file))),
|
|
56
|
+
version: member.version,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Directory of the installed package `name` as seen from `base`. A standalone project (and the
|
|
64
|
+
* CLI's staging directory) reads only its own node_modules and never follows a symlink. A
|
|
65
|
+
* workspace member resolves like Node, from its folder up to the workspace root, and follows a
|
|
66
|
+
* package symlink only when its real path stays inside the workspace root (D-0035).
|
|
67
|
+
*/
|
|
68
|
+
export async function packageDirectory(base, name) {
|
|
69
|
+
relativePath('node_modules/' + name);
|
|
70
|
+
const workspace = await workspaceRootOf(base);
|
|
71
|
+
if (!workspace)
|
|
72
|
+
return securePath(base, 'node_modules/' + name);
|
|
73
|
+
const boundary = await realpath(workspace);
|
|
74
|
+
for (let directory = base;; directory = dirname(directory)) {
|
|
75
|
+
const candidate = join(directory, 'node_modules', name);
|
|
76
|
+
if (await exists(candidate)) {
|
|
77
|
+
const real = await realpath(candidate);
|
|
78
|
+
if (real !== boundary && !real.startsWith(boundary + sep))
|
|
79
|
+
throw new CliError('UNSAFE_PATH', `Package link leaves the workspace: ${name}`);
|
|
80
|
+
return real;
|
|
81
|
+
}
|
|
82
|
+
if (directory === workspace || dirname(directory) === directory)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
return join(workspace, 'node_modules', name);
|
|
86
|
+
}
|
|
87
|
+
/** A file inside an installed package; files inside packages are never symlinks. */
|
|
88
|
+
export async function packageFile(base, name, file) {
|
|
89
|
+
return securePath(await packageDirectory(base, name), file);
|
|
90
|
+
}
|
|
91
|
+
/** `node_modules/<name>/<file>` → its real location (see packageDirectory). */
|
|
92
|
+
export async function installedPath(base, path) {
|
|
93
|
+
const match = /^node_modules\/((?:@[^/]+\/)?[^/]+)\/(.+)$/.exec(path);
|
|
94
|
+
if (!match?.[1] || !match[2])
|
|
95
|
+
return securePath(base, path);
|
|
96
|
+
return packageFile(base, match[1], match[2]);
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"engines": {
|
|
11
|
-
"node": "
|
|
11
|
+
"node": ">=24.0.0"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
14
|
"dokebi": "./dist/bin.js"
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"semver": "7.8.5",
|
|
27
|
-
"@dokebi/core": "0.
|
|
28
|
-
"
|
|
27
|
+
"@dokebi/core": "0.2.0",
|
|
28
|
+
"@dokebi/config": "0.2.0",
|
|
29
|
+
"yaml": "2.9.1"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@types/semver": "7.8.0"
|