@haposoft/cafekit 0.9.6 → 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.
@@ -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
+ };
@@ -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
+ };