@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.
Files changed (49) hide show
  1. package/AI.md +15 -14
  2. package/README.md +20 -26
  3. package/dist/bin.js +198 -9
  4. package/dist/config-edit.d.ts +19 -0
  5. package/dist/config-edit.js +124 -0
  6. package/dist/configuration-scaffold.d.ts +1 -0
  7. package/dist/configuration-scaffold.js +150 -0
  8. package/dist/configuration.d.ts +19 -0
  9. package/dist/configuration.js +84 -0
  10. package/dist/errors.js +0 -3
  11. package/dist/example-config.d.ts +11 -0
  12. package/dist/example-config.js +16 -0
  13. package/dist/filesystem.js +9 -7
  14. package/dist/generator.d.ts +12 -2
  15. package/dist/generator.js +152 -22
  16. package/dist/install-module.d.ts +23 -0
  17. package/dist/install-module.js +107 -0
  18. package/dist/install-plan.d.ts +12 -0
  19. package/dist/install-plan.js +90 -0
  20. package/dist/layout.d.ts +17 -0
  21. package/dist/layout.js +35 -0
  22. package/dist/manifest.d.ts +24 -2
  23. package/dist/manifest.js +103 -43
  24. package/dist/merge-install-defaults.d.ts +2 -0
  25. package/dist/merge-install-defaults.js +79 -0
  26. package/dist/npm-client.d.ts +6 -0
  27. package/dist/npm-client.js +34 -20
  28. package/dist/npm-paths.js +0 -2
  29. package/dist/output.d.ts +1 -1
  30. package/dist/output.js +2 -7
  31. package/dist/plugin-lifecycle.d.ts +43 -0
  32. package/dist/plugin-lifecycle.js +194 -0
  33. package/dist/plugin-options.d.ts +3 -1
  34. package/dist/plugin-options.js +9 -38
  35. package/dist/project-guide.d.ts +1 -1
  36. package/dist/project-guide.js +8 -3
  37. package/dist/project-inspector.d.ts +7 -1
  38. package/dist/project-inspector.js +81 -17
  39. package/dist/project-state.d.ts +16 -1
  40. package/dist/project-state.js +87 -13
  41. package/dist/project.d.ts +82 -3
  42. package/dist/project.js +1138 -112
  43. package/dist/required-plugins.d.ts +6 -0
  44. package/dist/required-plugins.js +45 -0
  45. package/dist/transaction.d.ts +6 -1
  46. package/dist/transaction.js +51 -6
  47. package/dist/workspace.d.ts +22 -0
  48. package/dist/workspace.js +97 -0
  49. package/package.json +5 -4
@@ -0,0 +1,6 @@
1
+ export interface RequiredPlugin {
2
+ id: string;
3
+ packageName: string;
4
+ version: string;
5
+ }
6
+ export declare function readRequiredPlugins(root: string): Promise<RequiredPlugin[]>;
@@ -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
+ }
@@ -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
- commit(beforeApply?: (index: number) => void): Promise<void>;
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
  }
@@ -1,5 +1,4 @@
1
- // [AI:수정] 변경 코드와 회귀 테스트의 import 순서·포맷을 저장소 규칙에 맞춘다.
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
- async commit(beforeApply) {
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.1.1",
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": "^24.21.0 || ^26.11.1"
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.1.1",
28
- "ajv": "8.20.0"
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"