@haposoft/cafekit 0.9.7 → 0.10.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/bin/install.js +93 -1254
- package/bin/lib/backup.js +111 -0
- package/bin/lib/context.js +273 -0
- package/bin/lib/lock.js +86 -0
- package/bin/lib/managed-writer.js +167 -0
- package/bin/lib/manifest.js +182 -0
- package/bin/lib/ui.js +108 -0
- package/bin/phases/claude-runtime.js +132 -0
- package/bin/phases/claude-settings.js +130 -0
- package/bin/phases/copy-payload.js +220 -0
- package/bin/phases/opencode-runtime.js +38 -0
- package/bin/phases/post-install.js +141 -0
- package/bin/phases/report.js +66 -0
- package/bin/phases/root-config.js +49 -0
- package/bin/phases/select-platform.js +94 -0
- package/bin/phases/summary.js +54 -0
- package/bin/phases/write-metadata.js +60 -0
- package/package.json +5 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CafeKit install backup / rollback (P2)
|
|
3
|
+
*
|
|
4
|
+
* Snapshots platform folders before any writes so a mid-install crash can be
|
|
5
|
+
* rolled back to the pre-run state. Snapshots live under
|
|
6
|
+
* `.cafekit-backup/<runId>/` at the project root and are pruned to the most
|
|
7
|
+
* recent few on success.
|
|
8
|
+
*
|
|
9
|
+
* Pure module: no console output, no process control. The orchestrator decides
|
|
10
|
+
* when to snapshot, restore, and prune, and what to log.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const BACKUP_ROOT = '.cafekit-backup';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively copy a directory tree (verbatim, no transforms). Used for
|
|
20
|
+
* snapshot and restore. Skips if the source does not exist.
|
|
21
|
+
*/
|
|
22
|
+
function copyTree(src, dest) {
|
|
23
|
+
if (!fs.existsSync(src)) return;
|
|
24
|
+
const stat = fs.statSync(src);
|
|
25
|
+
if (stat.isDirectory()) {
|
|
26
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
27
|
+
for (const entry of fs.readdirSync(src)) {
|
|
28
|
+
copyTree(path.join(src, entry), path.join(dest, entry));
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
32
|
+
fs.copyFileSync(src, dest);
|
|
33
|
+
// Preserve restrictive perms (e.g. .env at 0o600) on the backup copy.
|
|
34
|
+
try {
|
|
35
|
+
fs.chmodSync(dest, stat.mode);
|
|
36
|
+
} catch {
|
|
37
|
+
/* best-effort: chmod may fail on some filesystems */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot the given platform folders (relative to cwd, e.g. ['.claude']).
|
|
44
|
+
* Only existing folders are captured. Returns the backup directory path, or
|
|
45
|
+
* null if nothing existed to back up (fresh install → no rollback target).
|
|
46
|
+
*
|
|
47
|
+
* @param {string[]} folders
|
|
48
|
+
* @param {string} runId unique id for this run (timestamp-based)
|
|
49
|
+
*/
|
|
50
|
+
function snapshot(folders, runId) {
|
|
51
|
+
const existing = folders.filter((f) => fs.existsSync(f));
|
|
52
|
+
if (existing.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
const backupDir = path.join(BACKUP_ROOT, runId);
|
|
55
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
for (const folder of existing) {
|
|
58
|
+
copyTree(folder, path.join(backupDir, folder));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return backupDir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Restore a snapshot back over the working tree. For each captured folder we
|
|
66
|
+
* remove the current (possibly half-written) copy and replace it with the
|
|
67
|
+
* snapshot, returning the tree to its pre-run state.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} backupDir path returned by snapshot()
|
|
70
|
+
*/
|
|
71
|
+
function restore(backupDir) {
|
|
72
|
+
if (!backupDir || !fs.existsSync(backupDir)) return;
|
|
73
|
+
|
|
74
|
+
for (const folder of fs.readdirSync(backupDir)) {
|
|
75
|
+
const target = folder; // backup mirrors cwd-relative folder names
|
|
76
|
+
const source = path.join(backupDir, folder);
|
|
77
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
78
|
+
copyTree(source, target);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Keep only the most recent `keepN` backups; delete older ones. Backups sort
|
|
84
|
+
* lexicographically by runId, which is timestamp-prefixed so newest sorts last.
|
|
85
|
+
*/
|
|
86
|
+
function prune(keepN = 3) {
|
|
87
|
+
if (!fs.existsSync(BACKUP_ROOT)) return;
|
|
88
|
+
|
|
89
|
+
const dirs = fs
|
|
90
|
+
.readdirSync(BACKUP_ROOT)
|
|
91
|
+
.filter((name) => {
|
|
92
|
+
try {
|
|
93
|
+
return fs.statSync(path.join(BACKUP_ROOT, name)).isDirectory();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
.sort();
|
|
99
|
+
|
|
100
|
+
const toRemove = dirs.slice(0, Math.max(0, dirs.length - keepN));
|
|
101
|
+
for (const name of toRemove) {
|
|
102
|
+
fs.rmSync(path.join(BACKUP_ROOT, name), { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
BACKUP_ROOT,
|
|
108
|
+
snapshot,
|
|
109
|
+
restore,
|
|
110
|
+
prune
|
|
111
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CafeKit installer shared context + constants.
|
|
3
|
+
*
|
|
4
|
+
* Holds the platform registry, version constants, migration-manifest loader,
|
|
5
|
+
* platform helper functions, CLI arg parsing, and the run-context factory.
|
|
6
|
+
* Phase handlers import what they need from here so each phase stays focused.
|
|
7
|
+
*
|
|
8
|
+
* The context object (`ctx`) is threaded through every phase: `(ctx) => ctx`.
|
|
9
|
+
* Phases mutate `ctx.results` counters and may set `ctx.cancelled` to stop the
|
|
10
|
+
* pipeline early.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const packageJson = require('../../package.json');
|
|
16
|
+
const { getOpenCodeCopyOptions } = require('./opencode-install');
|
|
17
|
+
const { isInteractive, createUI } = require('./ui');
|
|
18
|
+
|
|
19
|
+
const INSTALL_COMMAND = `npx ${packageJson.name}@${packageJson.version}`;
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════
|
|
22
|
+
// MIGRATION MANIFEST
|
|
23
|
+
// ═══════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
function validateManifestV2(manifest) {
|
|
26
|
+
if (!manifest || manifest.version !== 2) return false;
|
|
27
|
+
if (!manifest.runtime?.files || !Array.isArray(manifest.runtime.files)) return false;
|
|
28
|
+
if (!manifest.settings?.template) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadClaudeMigrationManifest() {
|
|
33
|
+
const manifestPath = path.join(__dirname, '../../src/claude/migration-manifest.json');
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(manifestPath)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
41
|
+
|
|
42
|
+
if (!validateManifestV2(manifest)) {
|
|
43
|
+
console.error('✗ Invalid manifest v2 schema - missing required fields');
|
|
44
|
+
console.error(' Expected: version=2, runtime.files[], settings.template');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return manifest;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.warn(`⚠ Failed to parse Claude migration manifest: ${error.message}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEPENDENCY_TEMPLATES = {
|
|
56
|
+
commands: {
|
|
57
|
+
claude: {},
|
|
58
|
+
opencode: {}
|
|
59
|
+
},
|
|
60
|
+
agents: {
|
|
61
|
+
claude: {},
|
|
62
|
+
opencode: {}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ═══════════════════════════════════════════════════════════
|
|
67
|
+
// PLATFORM REGISTRY - Add new platforms here
|
|
68
|
+
// ═══════════════════════════════════════════════════════════
|
|
69
|
+
const PLATFORMS = {
|
|
70
|
+
claude: {
|
|
71
|
+
id: 'claude',
|
|
72
|
+
name: 'Claude Code',
|
|
73
|
+
description: 'Anthropic\'s Claude Code CLI',
|
|
74
|
+
folder: '.claude',
|
|
75
|
+
commandsDir: '.claude/commands',
|
|
76
|
+
skillsDir: '.claude/skills',
|
|
77
|
+
agentsDir: '.claude/agents',
|
|
78
|
+
skillsRef: '.claude/skills',
|
|
79
|
+
commandPrefix: '/',
|
|
80
|
+
sourceDir: 'claude', // Maps to src/claude/
|
|
81
|
+
sourceSubdir: 'commands' // Source subfolder within src/claude/
|
|
82
|
+
},
|
|
83
|
+
opencode: {
|
|
84
|
+
id: 'opencode',
|
|
85
|
+
name: 'OpenCode',
|
|
86
|
+
description: 'OpenCode terminal AI coding agent',
|
|
87
|
+
folder: '.opencode',
|
|
88
|
+
detectFiles: ['.opencode', 'opencode.json', 'opencode.jsonc'],
|
|
89
|
+
commandsDir: '.opencode/commands',
|
|
90
|
+
skillsDir: '.opencode/skills',
|
|
91
|
+
agentsDir: '.opencode/agents',
|
|
92
|
+
skillsRef: '.opencode/skills',
|
|
93
|
+
commandPrefix: '/',
|
|
94
|
+
sourceDir: 'claude',
|
|
95
|
+
sourceSubdir: 'archive-command'
|
|
96
|
+
}
|
|
97
|
+
// Add new platforms here:
|
|
98
|
+
// cursor: {
|
|
99
|
+
// id: 'cursor',
|
|
100
|
+
// name: 'Cursor',
|
|
101
|
+
// description: 'Cursor IDE',
|
|
102
|
+
// folder: '.cursor',
|
|
103
|
+
// commandsDir: '.cursor/commands',
|
|
104
|
+
// sourceDir: 'cursor',
|
|
105
|
+
// sourceSubdir: 'commands'
|
|
106
|
+
// }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ═══════════════════════════════════════════════════════════
|
|
110
|
+
// DETECTION + PLATFORM HELPERS
|
|
111
|
+
// ═══════════════════════════════════════════════════════════
|
|
112
|
+
|
|
113
|
+
function detectPlatforms() {
|
|
114
|
+
const detected = [];
|
|
115
|
+
|
|
116
|
+
for (const [key, config] of Object.entries(PLATFORMS)) {
|
|
117
|
+
const markers = Array.isArray(config.detectFiles) ? config.detectFiles : [config.folder];
|
|
118
|
+
if (markers.some(marker => fs.existsSync(marker))) {
|
|
119
|
+
detected.push(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return detected;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatPlatformList() {
|
|
127
|
+
return Object.entries(PLATFORMS)
|
|
128
|
+
.map(([key, config], index) => {
|
|
129
|
+
return `${index + 1}) ${config.name} (${config.folder}/)\n ${config.description}`;
|
|
130
|
+
})
|
|
131
|
+
.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getPlatformKeys() {
|
|
135
|
+
return Object.keys(PLATFORMS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isClaudeCompatibleRuntime(platformKey) {
|
|
139
|
+
return platformKey === 'claude' || platformKey === 'opencode';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getRuntimeSupportTargetDir(platformKey, subdir) {
|
|
143
|
+
return path.join(PLATFORMS[platformKey].folder, subdir);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Warn opencode-only installs when a legacy .claude/ folder remains from
|
|
147
|
+
// pre-0.9.0 installs (when OpenCode shared the .claude/ runtime). We do not
|
|
148
|
+
// delete anything; user must remove it explicitly to opt in.
|
|
149
|
+
function warnLegacyClaudeFolder(platforms) {
|
|
150
|
+
if (!platforms.includes('opencode')) return;
|
|
151
|
+
if (platforms.includes('claude')) return;
|
|
152
|
+
if (!fs.existsSync('.claude')) return;
|
|
153
|
+
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log('⚠ Legacy .claude/ folder detected.');
|
|
156
|
+
console.log(' As of CafeKit 0.9.0, OpenCode installs are self-contained under .opencode/.');
|
|
157
|
+
console.log(' The .claude/ folder from older installs is no longer used by OpenCode.');
|
|
158
|
+
console.log(' After this install finishes you can remove it: rm -rf .claude');
|
|
159
|
+
console.log('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getCopyOptions(platformKey, baseOptions = {}) {
|
|
163
|
+
if (platformKey === 'opencode') {
|
|
164
|
+
return getOpenCodeCopyOptions(baseOptions);
|
|
165
|
+
}
|
|
166
|
+
return baseOptions;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ═══════════════════════════════════════════════════════════
|
|
170
|
+
// CLI ARGS + RUN CONTEXT
|
|
171
|
+
// ═══════════════════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse installer CLI flags.
|
|
175
|
+
* --upgrade / -u / --force / -f / --force-overwrite → forceOverwrite:true
|
|
176
|
+
* (overwrite user-modified managed files, backing up the user copy first)
|
|
177
|
+
* --dry-run → dryRun:true
|
|
178
|
+
*
|
|
179
|
+
* `upgrade` is kept as an alias of `forceOverwrite` for backward compatibility
|
|
180
|
+
* with existing docs and muscle memory.
|
|
181
|
+
*/
|
|
182
|
+
function parseInstallerArgs(argv) {
|
|
183
|
+
const args = {
|
|
184
|
+
forceOverwrite: false,
|
|
185
|
+
dryRun: false,
|
|
186
|
+
yes: false
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (let i = 2; i < argv.length; i++) {
|
|
190
|
+
const arg = argv[i];
|
|
191
|
+
|
|
192
|
+
if (arg === '--upgrade' || arg === '-u' || arg === '--force' || arg === '-f' || arg === '--force-overwrite') {
|
|
193
|
+
args.forceOverwrite = true;
|
|
194
|
+
} else if (arg === '--dry-run') {
|
|
195
|
+
args.dryRun = true;
|
|
196
|
+
} else if (arg === '--yes' || arg === '-y') {
|
|
197
|
+
args.yes = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Back-compat alias used throughout the phases.
|
|
202
|
+
args.upgrade = args.forceOverwrite;
|
|
203
|
+
return args;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Fresh results counters for a run.
|
|
208
|
+
* copied/updated/skipped/...: legacy counters (preserved for summary parity)
|
|
209
|
+
* preserved: user-modified/user-created files left untouched
|
|
210
|
+
* unchanged: files identical to payload, skipped by selective merge
|
|
211
|
+
* preservedFiles: paths reported in the summary
|
|
212
|
+
*/
|
|
213
|
+
function createResults() {
|
|
214
|
+
return {
|
|
215
|
+
copied: 0,
|
|
216
|
+
updated: 0,
|
|
217
|
+
skipped: 0,
|
|
218
|
+
unchanged: 0,
|
|
219
|
+
preserved: 0,
|
|
220
|
+
installedSkills: 0,
|
|
221
|
+
dependencyChecks: 0,
|
|
222
|
+
installedDependencies: 0,
|
|
223
|
+
missingDependencies: 0,
|
|
224
|
+
errors: 0,
|
|
225
|
+
targets: [],
|
|
226
|
+
preservedFiles: []
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the run context threaded through all phases.
|
|
232
|
+
*
|
|
233
|
+
* @param {string[]} argv process.argv
|
|
234
|
+
* @param {string} runId unique id for this run (timestamp-based)
|
|
235
|
+
*/
|
|
236
|
+
function buildContext(argv, runId) {
|
|
237
|
+
const options = parseInstallerArgs(argv);
|
|
238
|
+
// Interactive only on a real TTY and not when --yes/CI forces non-interactive.
|
|
239
|
+
const interactive = isInteractive() && !options.yes;
|
|
240
|
+
return {
|
|
241
|
+
argv,
|
|
242
|
+
runId,
|
|
243
|
+
options,
|
|
244
|
+
dryRun: options.dryRun,
|
|
245
|
+
interactive,
|
|
246
|
+
ui: createUI(interactive),
|
|
247
|
+
manifest: loadClaudeMigrationManifest(), // migration manifest (skills/agents/runtime lists)
|
|
248
|
+
platforms: [],
|
|
249
|
+
results: createResults(),
|
|
250
|
+
trackers: {}, // platformKey → ownership tracker, set per platform
|
|
251
|
+
ownership: {}, // platformFolder → ownership manifest read at run start
|
|
252
|
+
cancelled: false
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
packageJson,
|
|
258
|
+
INSTALL_COMMAND,
|
|
259
|
+
DEPENDENCY_TEMPLATES,
|
|
260
|
+
PLATFORMS,
|
|
261
|
+
validateManifestV2,
|
|
262
|
+
loadClaudeMigrationManifest,
|
|
263
|
+
detectPlatforms,
|
|
264
|
+
formatPlatformList,
|
|
265
|
+
getPlatformKeys,
|
|
266
|
+
isClaudeCompatibleRuntime,
|
|
267
|
+
getRuntimeSupportTargetDir,
|
|
268
|
+
warnLegacyClaudeFolder,
|
|
269
|
+
getCopyOptions,
|
|
270
|
+
parseInstallerArgs,
|
|
271
|
+
createResults,
|
|
272
|
+
buildContext
|
|
273
|
+
};
|
package/bin/lib/lock.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CafeKit install process lock (P5)
|
|
3
|
+
*
|
|
4
|
+
* Prevents two installer runs (or a run racing another writer) from clobbering
|
|
5
|
+
* the same project. A lock file at the project root holds the owning PID and a
|
|
6
|
+
* timestamp. If a live process owns it, a second run refuses; if the owner is
|
|
7
|
+
* dead (stale lock), it is reclaimed.
|
|
8
|
+
*
|
|
9
|
+
* Pure module: no console output, no process control. Returns structured
|
|
10
|
+
* results; the orchestrator decides what to log and whether to exit.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const LOCK_FILE = '.cafekit.lock';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether a PID belongs to a live process. `process.kill(pid, 0)` sends
|
|
20
|
+
* no signal but throws ESRCH if the process does not exist.
|
|
21
|
+
*/
|
|
22
|
+
function isProcessAlive(pid) {
|
|
23
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// EPERM → process exists but we can't signal it → still alive.
|
|
29
|
+
return err.code === 'EPERM';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attempt to acquire the lock.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} cwd project root (defaults to process.cwd())
|
|
37
|
+
* @returns {{acquired: boolean, reason?: string, pid?: number, since?: string, reclaimed?: boolean}}
|
|
38
|
+
* acquired:false + reason:'held' → another live process owns it (pid/since set)
|
|
39
|
+
* acquired:true, reclaimed:true → a stale lock was taken over
|
|
40
|
+
*/
|
|
41
|
+
function acquire(cwd = process.cwd()) {
|
|
42
|
+
const lockPath = path.join(cwd, LOCK_FILE);
|
|
43
|
+
let reclaimed = false;
|
|
44
|
+
|
|
45
|
+
if (fs.existsSync(lockPath)) {
|
|
46
|
+
let info = {};
|
|
47
|
+
try {
|
|
48
|
+
info = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
49
|
+
} catch {
|
|
50
|
+
info = {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (isProcessAlive(info.pid)) {
|
|
54
|
+
return { acquired: false, reason: 'held', pid: info.pid, since: info.since };
|
|
55
|
+
}
|
|
56
|
+
// Stale lock from a dead process — safe to reclaim.
|
|
57
|
+
reclaimed = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = { pid: process.pid, since: new Date().toISOString() };
|
|
61
|
+
fs.writeFileSync(lockPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
62
|
+
return { acquired: true, reclaimed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Release the lock if (and only if) this process owns it. Safe to call in a
|
|
67
|
+
* finally block even when acquire() failed.
|
|
68
|
+
*/
|
|
69
|
+
function release(cwd = process.cwd()) {
|
|
70
|
+
const lockPath = path.join(cwd, LOCK_FILE);
|
|
71
|
+
if (!fs.existsSync(lockPath)) return;
|
|
72
|
+
try {
|
|
73
|
+
const info = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
74
|
+
if (info.pid && info.pid !== process.pid) return; // not ours, leave it
|
|
75
|
+
} catch {
|
|
76
|
+
// Unreadable lock — fall through and remove it.
|
|
77
|
+
}
|
|
78
|
+
fs.rmSync(lockPath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
LOCK_FILE,
|
|
83
|
+
isProcessAlive,
|
|
84
|
+
acquire,
|
|
85
|
+
release
|
|
86
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership-aware file writer (P1 + P4 + P6).
|
|
3
|
+
*
|
|
4
|
+
* Central decision point for every managed file the installer writes. Instead
|
|
5
|
+
* of "exists? skip : overwrite", it hashes the incoming payload, classifies the
|
|
6
|
+
* destination against the recorded manifest, and decides:
|
|
7
|
+
*
|
|
8
|
+
* absent → write (created)
|
|
9
|
+
* pristine → write if payload differs (updated), else skip (unchanged)
|
|
10
|
+
* user-modified → keep by default (preserved); overwrite only with
|
|
11
|
+
* user-created forceOverwrite (the pre-run snapshot already holds the
|
|
12
|
+
* user's copy for rollback/recovery)
|
|
13
|
+
*
|
|
14
|
+
* Honors dry-run (classify + report, no writes). Records written/unchanged
|
|
15
|
+
* files into the run's manifest tracker so the baseline stays accurate.
|
|
16
|
+
*
|
|
17
|
+
* Pure-ish: performs the actual file write, but logging/counters are left to
|
|
18
|
+
* callers via the returned action so each call site keeps its own message flavor.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { isTextAsset } = require('./copy-utils');
|
|
24
|
+
const manifest = require('./manifest');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the payload bytes for a source file, applying a text transform when
|
|
28
|
+
* provided and the file is a text asset (mirrors copy-utils.copyRecursive).
|
|
29
|
+
* Returns { data, utf8 } where data is a string (utf8) or Buffer.
|
|
30
|
+
*/
|
|
31
|
+
function readPayload(src, transform) {
|
|
32
|
+
if (typeof transform === 'function' && isTextAsset(src)) {
|
|
33
|
+
return { data: transform(fs.readFileSync(src, 'utf8'), src), utf8: true };
|
|
34
|
+
}
|
|
35
|
+
return { data: fs.readFileSync(src), utf8: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Plan and (unless dry-run) perform the write for a single managed file.
|
|
40
|
+
*
|
|
41
|
+
* @returns {{action: string, state: string}}
|
|
42
|
+
* action: 'created' | 'updated' | 'unchanged' | 'preserved' | 'missing'
|
|
43
|
+
*/
|
|
44
|
+
function writeManagedFile(opts) {
|
|
45
|
+
const { src, dest, platformFolder, ctx, tracker, transform } = opts;
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(src)) {
|
|
48
|
+
return { action: 'missing', state: 'absent' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { data, utf8 } = readPayload(src, transform);
|
|
52
|
+
const payloadHash = manifest.sha256(data);
|
|
53
|
+
// Ownership manifest for this platform (read at run start), NOT the migration manifest.
|
|
54
|
+
const ownership = (ctx.ownership && ctx.ownership[platformFolder]) || { files: {} };
|
|
55
|
+
|
|
56
|
+
// A file already written earlier THIS run (e.g. a spec template copied as part
|
|
57
|
+
// of the specs/ tree, then revisited by the template-sync loop) is ours — treat
|
|
58
|
+
// it as pristine against what we just wrote, not as a user-created file.
|
|
59
|
+
const relForRun = path.relative(platformFolder, dest).replace(/\\/g, '/');
|
|
60
|
+
const writtenThisRun = tracker ? tracker.recorded(relForRun) : null;
|
|
61
|
+
|
|
62
|
+
let cls;
|
|
63
|
+
if (writtenThisRun !== null) {
|
|
64
|
+
cls = {
|
|
65
|
+
state: 'pristine',
|
|
66
|
+
diskHash: writtenThisRun,
|
|
67
|
+
recordedHash: writtenThisRun,
|
|
68
|
+
changed: writtenThisRun !== payloadHash
|
|
69
|
+
};
|
|
70
|
+
} else {
|
|
71
|
+
cls = manifest.classify(dest, platformFolder, ownership, payloadHash);
|
|
72
|
+
}
|
|
73
|
+
const forced = Boolean(ctx.options.forceOverwrite);
|
|
74
|
+
|
|
75
|
+
let action;
|
|
76
|
+
if (cls.state === 'absent') {
|
|
77
|
+
action = 'created';
|
|
78
|
+
} else if (cls.state === 'pristine') {
|
|
79
|
+
action = cls.changed ? 'updated' : 'unchanged';
|
|
80
|
+
} else {
|
|
81
|
+
// user-modified or user-created
|
|
82
|
+
if (forced) {
|
|
83
|
+
action = cls.changed ? 'updated' : 'unchanged';
|
|
84
|
+
} else {
|
|
85
|
+
action = 'preserved';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (action === 'preserved') {
|
|
90
|
+
ctx.results.preservedFiles.push(dest);
|
|
91
|
+
// Do not record: leave the prior baseline entry so the file stays
|
|
92
|
+
// classified as user-modified on the next run too.
|
|
93
|
+
return { action, state: cls.state };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const willWrite = action === 'created' || action === 'updated';
|
|
97
|
+
|
|
98
|
+
if (willWrite && !ctx.dryRun) {
|
|
99
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
100
|
+
if (utf8) {
|
|
101
|
+
fs.writeFileSync(dest, data, 'utf8');
|
|
102
|
+
} else {
|
|
103
|
+
fs.writeFileSync(dest, data);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Record created/updated/unchanged files so the manifest reflects reality.
|
|
108
|
+
// Skip recording in dry-run (nothing was written).
|
|
109
|
+
if (tracker && !ctx.dryRun) {
|
|
110
|
+
if (willWrite) {
|
|
111
|
+
tracker.record(dest);
|
|
112
|
+
} else {
|
|
113
|
+
// unchanged pristine — record current (== payload) hash to keep baseline fresh
|
|
114
|
+
tracker.record(dest);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { action, state: cls.state };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Walk a source directory tree and apply writeManagedFile to each file,
|
|
123
|
+
* mirroring copy-utils.copyRecursive's traversal (incl. gitignore → .gitignore).
|
|
124
|
+
*
|
|
125
|
+
* @returns {{created, updated, unchanged, preserved, missing}} aggregate counts
|
|
126
|
+
*/
|
|
127
|
+
function copyManagedTree(opts) {
|
|
128
|
+
const { src, dest, platformFolder, ctx, tracker, transform } = opts;
|
|
129
|
+
const agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
|
|
130
|
+
|
|
131
|
+
if (!fs.existsSync(src)) {
|
|
132
|
+
agg.missing++;
|
|
133
|
+
return agg;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const stats = fs.statSync(src);
|
|
137
|
+
if (stats.isDirectory()) {
|
|
138
|
+
for (const childName of fs.readdirSync(src)) {
|
|
139
|
+
const destName = childName === 'gitignore' ? '.gitignore' : childName;
|
|
140
|
+
const childAgg = copyManagedTree({
|
|
141
|
+
...opts,
|
|
142
|
+
src: path.join(src, childName),
|
|
143
|
+
dest: path.join(dest, destName)
|
|
144
|
+
});
|
|
145
|
+
for (const k of Object.keys(agg)) agg[k] += childAgg[k];
|
|
146
|
+
}
|
|
147
|
+
return agg;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { action } = writeManagedFile({ src, dest, platformFolder, ctx, tracker, transform });
|
|
151
|
+
if (agg[action] !== undefined) agg[action]++;
|
|
152
|
+
return agg;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Did a tree-copy aggregate touch the filesystem (created/updated anything)?
|
|
157
|
+
*/
|
|
158
|
+
function treeChanged(agg) {
|
|
159
|
+
return agg.created > 0 || agg.updated > 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
readPayload,
|
|
164
|
+
writeManagedFile,
|
|
165
|
+
copyManagedTree,
|
|
166
|
+
treeChanged
|
|
167
|
+
};
|