@dokebi/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,128 @@
1
+ // [AI:수정] 변경 코드와 회귀 테스트의 import 순서·포맷을 저장소 규칙에 맞춘다.
2
+ import { lstat, mkdir, mkdtemp, open, readdir, 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
+ // [AI:수정] 잠금 확보 후 복구 잔여물이 있으면 기존 백업을 보존하고 새 작업을 차단한다.
32
+ if ((await readdir(control)).some((name) => name.startsWith('transaction-'))) {
33
+ throw new CliError('RECOVERY_REQUIRED', 'Interrupted transaction found; preserve .dokebi/transaction-* for recovery', { phase: 'unknown' });
34
+ }
35
+ return new Transaction(root, await mkdtemp(join(control, 'transaction-')));
36
+ }
37
+ catch (cause) {
38
+ await handle.close().catch(() => { });
39
+ await rm(lock, { force: true });
40
+ throw cause;
41
+ }
42
+ }
43
+ async capture(path) {
44
+ if (!this.#snapshots.has(path))
45
+ this.#snapshots.set(path, await fingerprint(await securePath(this.root, path)));
46
+ }
47
+ async read(path) {
48
+ await this.capture(path);
49
+ return readFile(await securePath(this.root, path), 'utf8');
50
+ }
51
+ async write(path, content) {
52
+ const source = join(this.directory, `new-${this.#changes.length}`);
53
+ await writeFile(source, content);
54
+ await this.replace(path, source);
55
+ }
56
+ async replace(path, source) {
57
+ if (this.#changes.some((change) => change.path === path))
58
+ throw new CliError('DUPLICATE_CHANGE', path);
59
+ await this.capture(path);
60
+ this.#changes.push(source === undefined ? { path } : { path, source });
61
+ }
62
+ get paths() {
63
+ return this.#changes.map((change) => change.path);
64
+ }
65
+ async commit(beforeApply) {
66
+ for (const [path, expected] of this.#snapshots) {
67
+ if ((await fingerprint(await securePath(this.root, path))) !== expected) {
68
+ throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
69
+ }
70
+ }
71
+ const journal = this.#changes.map((change, index) => ({
72
+ ...change,
73
+ backup: join(this.directory, `old-${index}`),
74
+ existed: this.#snapshots.get(change.path) !== null,
75
+ backedUp: false,
76
+ installed: false,
77
+ }));
78
+ await writeFile(join(this.directory, 'journal.json'), json(journal));
79
+ try {
80
+ for (const [index, change] of journal.entries()) {
81
+ beforeApply?.(index);
82
+ const target = await securePath(this.root, change.path);
83
+ await mkdir(dirname(target), { recursive: true });
84
+ if (change.existed) {
85
+ await rename(target, change.backup);
86
+ change.backedUp = true;
87
+ }
88
+ if (change.source) {
89
+ await rename(change.source, target);
90
+ change.installed = true;
91
+ }
92
+ }
93
+ }
94
+ catch (cause) {
95
+ const failures = [];
96
+ for (const change of [...journal].reverse()) {
97
+ try {
98
+ const target = await securePath(this.root, change.path);
99
+ if (change.installed)
100
+ await rm(target, { recursive: true, force: true });
101
+ if (change.backedUp)
102
+ await rename(change.backup, target);
103
+ }
104
+ catch (error) {
105
+ failures.push(error);
106
+ }
107
+ }
108
+ if (failures.length) {
109
+ this.#keep = true;
110
+ throw new CliError('RECOVERY_REQUIRED', `Rollback failed; preserve ${this.directory} and .dokebi/lock`, {
111
+ cause: new AggregateError([cause, ...failures]),
112
+ });
113
+ }
114
+ throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
115
+ }
116
+ }
117
+ async close() {
118
+ if (this.#keep)
119
+ return;
120
+ if (await exists(this.directory)) {
121
+ const stat = await lstat(this.directory);
122
+ if (stat.isSymbolicLink())
123
+ throw new CliError('UNSAFE_PATH', 'Transaction directory was replaced');
124
+ await rm(this.directory, { recursive: true });
125
+ }
126
+ await rm(join(this.root, '.dokebi/lock'), { force: true });
127
+ }
128
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
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.1",
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.1",
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
+ "description": "Project scaffolding, plugin management and diagnostics for Dokebi."
40
+ }