@dokebi/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,124 @@
1
+ // [AI:생성] CLI 변경 경계를 검증하고 부분 실패 시 원본을 복구할 기반을 구현한다.
2
+ import { lstat, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { CliError } from './errors.js';
5
+ import { exists, fingerprint, json, securePath } from './filesystem.js';
6
+ export class Transaction {
7
+ root;
8
+ directory;
9
+ #snapshots = new Map();
10
+ #changes = [];
11
+ #keep = false;
12
+ constructor(root, directory) {
13
+ this.root = root;
14
+ this.directory = directory;
15
+ }
16
+ static async begin(root) {
17
+ const control = await securePath(root, '.dokebi');
18
+ await mkdir(control, { recursive: true });
19
+ const lock = join(control, 'lock');
20
+ // [AI:수정] 타입 명시와 경로 제어 문자 검증을 유지하면서 저장소 검사 규칙을 충족한다.
21
+ let handle;
22
+ try {
23
+ handle = await open(lock, 'wx');
24
+ }
25
+ catch (cause) {
26
+ throw new CliError('PROJECT_LOCKED', 'Another operation or interrupted transaction owns .dokebi/lock', { cause });
27
+ }
28
+ try {
29
+ await handle.writeFile(json({ pid: process.pid }));
30
+ await handle.close();
31
+ return new Transaction(root, await mkdtemp(join(control, 'transaction-')));
32
+ }
33
+ catch (cause) {
34
+ await handle.close().catch(() => { });
35
+ await rm(lock, { force: true });
36
+ throw cause;
37
+ }
38
+ }
39
+ async capture(path) {
40
+ if (!this.#snapshots.has(path))
41
+ this.#snapshots.set(path, await fingerprint(await securePath(this.root, path)));
42
+ }
43
+ async read(path) {
44
+ await this.capture(path);
45
+ return readFile(await securePath(this.root, path), 'utf8');
46
+ }
47
+ async write(path, content) {
48
+ const source = join(this.directory, `new-${this.#changes.length}`);
49
+ await writeFile(source, content);
50
+ await this.replace(path, source);
51
+ }
52
+ async replace(path, source) {
53
+ if (this.#changes.some((change) => change.path === path))
54
+ throw new CliError('DUPLICATE_CHANGE', path);
55
+ await this.capture(path);
56
+ this.#changes.push(source === undefined ? { path } : { path, source });
57
+ }
58
+ get paths() {
59
+ return this.#changes.map((change) => change.path);
60
+ }
61
+ async commit(beforeApply) {
62
+ for (const [path, expected] of this.#snapshots) {
63
+ if ((await fingerprint(await securePath(this.root, path))) !== expected) {
64
+ throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
65
+ }
66
+ }
67
+ const journal = this.#changes.map((change, index) => ({
68
+ ...change,
69
+ backup: join(this.directory, `old-${index}`),
70
+ existed: this.#snapshots.get(change.path) !== null,
71
+ backedUp: false,
72
+ installed: false,
73
+ }));
74
+ await writeFile(join(this.directory, 'journal.json'), json(journal));
75
+ try {
76
+ for (const [index, change] of journal.entries()) {
77
+ beforeApply?.(index);
78
+ const target = await securePath(this.root, change.path);
79
+ await mkdir(dirname(target), { recursive: true });
80
+ if (change.existed) {
81
+ await rename(target, change.backup);
82
+ change.backedUp = true;
83
+ }
84
+ if (change.source) {
85
+ await rename(change.source, target);
86
+ change.installed = true;
87
+ }
88
+ }
89
+ }
90
+ catch (cause) {
91
+ const failures = [];
92
+ for (const change of [...journal].reverse()) {
93
+ try {
94
+ const target = await securePath(this.root, change.path);
95
+ if (change.installed)
96
+ await rm(target, { recursive: true, force: true });
97
+ if (change.backedUp)
98
+ await rename(change.backup, target);
99
+ }
100
+ catch (error) {
101
+ failures.push(error);
102
+ }
103
+ }
104
+ if (failures.length) {
105
+ this.#keep = true;
106
+ throw new CliError('RECOVERY_REQUIRED', `Rollback failed; preserve ${this.directory} and .dokebi/lock`, {
107
+ cause: new AggregateError([cause, ...failures]),
108
+ });
109
+ }
110
+ throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
111
+ }
112
+ }
113
+ async close() {
114
+ if (this.#keep)
115
+ return;
116
+ if (await exists(this.directory)) {
117
+ const stat = await lstat(this.directory);
118
+ if (stat.isSymbolicLink())
119
+ throw new CliError('UNSAFE_PATH', 'Transaction directory was replaced');
120
+ await rm(this.directory, { recursive: true });
121
+ }
122
+ await rm(join(this.root, '.dokebi/lock'), { force: true });
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@dokebi/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/"
8
+ },
9
+ "type": "module",
10
+ "engines": {
11
+ "node": "^24.21.0 || ^26.11.1"
12
+ },
13
+ "bin": {
14
+ "dokebi": "./dist/bin.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "AI.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
23
+ "test": "tsc -p tsconfig.test.json && node --test \".tmp/tests/tests/*.test.js\""
24
+ },
25
+ "dependencies": {
26
+ "semver": "7.8.5",
27
+ "@dokebi/core": "0.1.0",
28
+ "ajv": "8.20.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/semver": "7.8.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://git.nyx-zone.com/dokebi/framework.git",
36
+ "directory": "packages/cli"
37
+ },
38
+ "license": "MIT"
39
+ }