@codedealer/pi-sync 0.0.0-stage → 1.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.
package/src/cli.js ADDED
@@ -0,0 +1,2041 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { inspectRequiredTools, validateRequiredTools } from './prerequisites.js';
9
+ import { discoverUpdates, installPinnedPackages, lockWithUpdates, rollbackPinnedPackages, validatePackageState, writeLockfile } from './packages.js';
10
+
11
+ const MANIFEST_FILE = 'pi-sync.json';
12
+ const LOCK_FILE = 'pi-sync.lock.json';
13
+ const STATE_DIR = '.pi-sync';
14
+ const STATE_FILE = 'state.json';
15
+ const VALIDATION_ERROR = 2;
16
+ const OPERATION_ERROR = 1;
17
+ const SETTINGS_FILES = new Set(['settings.json', 'models.json', 'keybindings.json', 'mcp.json']);
18
+ const RESOURCE_RULES = {
19
+ skill: { sourceRoot: 'resources/skills', targetRoot: 'skills', label: 'Skill', shape: 'directory' },
20
+ extension: { sourceRoot: 'resources/extensions', targetRoot: 'extensions', label: 'Extension', shape: 'any' },
21
+ prompt: { sourceRoot: 'resources/prompts', targetRoot: 'prompts', label: 'Prompt', shape: 'file', suffix: '.md' },
22
+ agent: { sourceRoot: 'resources/agents', targetRoot: 'agents', label: 'Agent', shape: 'file', suffix: '.md' },
23
+ role: { sourceRoot: 'resources/roles', targetRoot: 'roles', label: 'Role', shape: 'file', suffix: '.md' },
24
+ };
25
+ const RESOURCE_TARGET_ROOTS = Object.values(RESOURCE_RULES).map((rule) => `${rule.targetRoot}/`);
26
+ const BUNDLED_SKILL = fileURLToPath(new URL('../resources/skills/pi-sync', import.meta.url));
27
+
28
+ function fail(error, code = VALIDATION_ERROR) {
29
+ const command = process.argv[2] ?? null;
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ const errorCode = error?.errorCode ?? (code === OPERATION_ERROR ? 'OPERATION_FAILED' : 'VALIDATION_ERROR');
32
+ console.log(JSON.stringify({ apiVersion: 1, command, ok: false, error: message, errorCode,
33
+ details: error?.details ?? { message }, exitCode: code }, null, 2));
34
+ process.exitCode = code;
35
+ }
36
+
37
+ function isRecord(value) {
38
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
39
+ }
40
+
41
+ function hasExactKeys(value, keys) {
42
+ return isRecord(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
43
+ }
44
+
45
+ function validateRelativePath(value, label) {
46
+ if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || path.posix.isAbsolute(value)) {
47
+ throw new Error(`${label} must be a non-empty relative POSIX path`);
48
+ }
49
+ const segments = value.split('/');
50
+ if (segments.some((part) => part === '' || part === '.' || part === '..')) {
51
+ throw new Error(`${label} must not contain empty, '.', or '..' path segments`);
52
+ }
53
+ }
54
+
55
+ function pathsOverlap(left, right) {
56
+ // Treat target paths case-insensitively on every host so repositories remain
57
+ // safe and portable when applied on Windows filesystems.
58
+ const normalizedLeft = left.toLowerCase();
59
+ const normalizedRight = right.toLowerCase();
60
+ return normalizedLeft === normalizedRight || normalizedLeft.startsWith(`${normalizedRight}/`) || normalizedRight.startsWith(`${normalizedLeft}/`);
61
+ }
62
+
63
+ function resolveInside(root, relative, label) {
64
+ validateRelativePath(relative, label);
65
+ const resolved = path.resolve(root, ...relative.split('/'));
66
+ if (resolved === root || !(root === path.parse(root).root ? resolved.startsWith(root) : resolved.startsWith(`${root}${path.sep}`))) {
67
+ throw new Error(`${label} must remain inside its root directory`);
68
+ }
69
+ return resolved;
70
+ }
71
+
72
+ async function readJson(file, label, missingValue) {
73
+ let content;
74
+ try {
75
+ content = await fs.readFile(file, 'utf8');
76
+ } catch (error) {
77
+ if (error.code === 'ENOENT' && arguments.length === 3) return missingValue;
78
+ if (error.code === 'ENOENT') throw new Error(`Missing ${label}: ${file}`);
79
+ throw error;
80
+ }
81
+ try {
82
+ return JSON.parse(content);
83
+ } catch {
84
+ throw new Error(`${label} is not valid JSON`);
85
+ }
86
+ }
87
+
88
+ async function validateSourceTree(repo, source, relative) {
89
+ let component = repo;
90
+ for (const segment of relative.split('/')) {
91
+ component = path.join(component, segment);
92
+ const info = await fs.lstat(component).catch(() => null);
93
+ if (!info || info.isSymbolicLink() || !info.isDirectory()) {
94
+ throw new Error(`Resource source must be an existing directory without symbolic-link path components: ${relative}`);
95
+ }
96
+ }
97
+ async function visit(directory, displayPath) {
98
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
99
+ const entryPath = path.join(directory, entry.name);
100
+ if (entry.isSymbolicLink()) throw new Error(`Symbolic links are not supported in resources: ${displayPath}/${entry.name}`);
101
+ if (entry.isDirectory()) {
102
+ if (entry.name === 'node_modules') throw new Error(`Package-installed code is not managed content and is never imported: ${displayPath}/${entry.name}`);
103
+ await visit(entryPath, `${displayPath}/${entry.name}`);
104
+ } else if (!entry.isFile()) throw new Error(`Unsupported file type in resource: ${displayPath}/${entry.name}`);
105
+ }
106
+ }
107
+ await visit(source, relative);
108
+ }
109
+
110
+ async function validateSourceFile(root, relative) {
111
+ const segments = relative.split('/');
112
+ let component = root;
113
+ for (let i = 0; i < segments.length; i++) {
114
+ component = path.join(component, segments[i]);
115
+ if (segments[i] === 'node_modules') throw new Error(`Package-installed code is not managed content and is never imported: ${relative}`);
116
+ const info = await fs.lstat(component).catch(() => null);
117
+ if (!info) throw new Error(`Resource source does not exist: ${relative}`);
118
+ if (info.isSymbolicLink()) throw new Error(`Symbolic links are not supported in resources: ${relative}`);
119
+ const isFinal = i === segments.length - 1;
120
+ if (isFinal ? !info.isFile() : !info.isDirectory()) {
121
+ throw new Error(`Resource source must be an existing regular file without symbolic-link path components: ${relative}`);
122
+ }
123
+ }
124
+ }
125
+
126
+ async function validateResourceSource(repo, sourcePath, relative, rule) {
127
+ if (rule.shape === 'file') {
128
+ await validateSourceFile(repo, relative);
129
+ return 'file';
130
+ }
131
+ if (rule.shape === 'any') {
132
+ let info;
133
+ try {
134
+ info = await fs.lstat(sourcePath);
135
+ } catch (error) {
136
+ if (error.code !== 'ENOENT') throw error;
137
+ }
138
+ if (!info) throw new Error(`Resource source does not exist: ${relative}`);
139
+ if (info.isSymbolicLink()) throw new Error(`Symbolic links are not supported in resources: ${relative}`);
140
+ if (info.isDirectory()) {
141
+ await validateSourceTree(repo, sourcePath, relative);
142
+ return 'directory';
143
+ }
144
+ if (info.isFile()) {
145
+ await validateSourceFile(repo, relative);
146
+ return 'file';
147
+ }
148
+ throw new Error(`Resource source must be a regular file or directory without symbolic links: ${relative}`);
149
+ }
150
+ await validateSourceTree(repo, sourcePath, relative);
151
+ return 'directory';
152
+ }
153
+
154
+ const CREDENTIAL_PATH_SEGMENTS = new Set(['auth.json', 'oauth.json', 'credentials.json', 'session.json', 'sessions', 'credentials', 'auth']);
155
+ const SECRET_PATTERNS = [
156
+ ['private-key', /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/],
157
+ ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/],
158
+ ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
159
+ ['bearer token', /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/i],
160
+ ['credential assignment', /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?(?!your[_ -]|example|changeme|placeholder)[A-Za-z0-9_./+=-]{12,}/i],
161
+ ];
162
+
163
+ function findLikelySecretsInText(text, relative) {
164
+ return SECRET_PATTERNS.flatMap(([reason, pattern]) => pattern.test(text) ? [{ path: relative, reason }] : []);
165
+ }
166
+
167
+ async function scanFileForSecrets(file, relativePath, findings) {
168
+ const name = path.posix.basename(relativePath);
169
+ if (CREDENTIAL_PATH_SEGMENTS.has(name.toLowerCase())) {
170
+ findings.push({ path: relativePath, reason: 'known credential-store path' });
171
+ return;
172
+ }
173
+ if (/^(?:\.env(?:\..*)?|id_(?:rsa|dsa|ecdsa|ed25519)|.*\.(?:pem|p12|pfx|key))$/i.test(name)) {
174
+ findings.push({ path: relativePath, reason: 'sensitive filename' });
175
+ return;
176
+ }
177
+ const bytes = await fs.readFile(file);
178
+ if (bytes.includes(0)) return;
179
+ findings.push(...findLikelySecretsInText(bytes.toString('utf8'), relativePath));
180
+ }
181
+
182
+ async function findLikelySecrets(root, relative) {
183
+ const findings = [];
184
+ const info = await fs.lstat(root);
185
+ if (info.isFile()) {
186
+ await scanFileForSecrets(root, relative, findings);
187
+ return findings;
188
+ }
189
+ async function visit(directory, display) {
190
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
191
+ const file = path.join(directory, entry.name);
192
+ const name = display ? `${display}/${entry.name}` : entry.name;
193
+ if (entry.isDirectory()) {
194
+ if (CREDENTIAL_PATH_SEGMENTS.has(entry.name.toLowerCase())) {
195
+ findings.push({ path: `${relative}/${name}`, reason: 'known credential-store path' });
196
+ } else await visit(file, name);
197
+ } else {
198
+ await scanFileForSecrets(file, `${relative}/${name}`, findings);
199
+ }
200
+ }
201
+ }
202
+ await visit(root, '');
203
+ return findings;
204
+ }
205
+
206
+ const ENV_REFERENCE = /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
207
+ // LLM usage/limit parameters that contain "token" but never carry credentials.
208
+ const NON_CREDENTIAL_TOKEN_KEYS = new Set(['tokens', 'maxtokens', 'mintokens', 'tokencount', 'tokenlimit', 'numtokens']);
209
+
210
+ function isCredentialLikeKey(normalized) {
211
+ if (['apikey', 'password', 'passwd', 'secret', 'credential', 'privatekey'].some((part) => normalized.includes(part))) return true;
212
+ if (normalized.includes('token')) return !NON_CREDENTIAL_TOKEN_KEYS.has(normalized);
213
+ if (normalized.includes('auth')) return !normalized.startsWith('author');
214
+ return false;
215
+ }
216
+
217
+ function validatePortableSettingValue(value, label) {
218
+ if (Array.isArray(value)) {
219
+ value.forEach((item, index) => validatePortableSettingValue(item, `${label}[${index}]`));
220
+ } else if (isRecord(value)) {
221
+ for (const [key, nested] of Object.entries(value)) {
222
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
223
+ if (isCredentialLikeKey(normalized)) {
224
+ if (typeof nested !== 'string' || !ENV_REFERENCE.test(nested)) {
225
+ throw new Error(`${label} contains a credential-like key (${key}); only a $NAME or ${'$'}{NAME} environment reference may be managed`);
226
+ }
227
+ continue;
228
+ }
229
+ validatePortableSettingValue(nested, `${label}.${key}`);
230
+ }
231
+ }
232
+ }
233
+
234
+ function decodePointer(pointer, label) {
235
+ if (typeof pointer !== 'string' || !pointer.startsWith('/') || pointer === '/') {
236
+ throw new Error(`${label} must be a non-empty JSON Pointer (for example, /theme)`);
237
+ }
238
+ const segments = pointer.slice(1).split('/').map((part) => {
239
+ if (/~(?![01])/.test(part)) throw new Error(`${label} contains an invalid JSON Pointer escape`);
240
+ const decoded = part.replace(/~1/g, '/').replace(/~0/g, '~');
241
+ if (['__proto__', 'prototype', 'constructor'].includes(decoded)) throw new Error(`${label} contains a prohibited object key`);
242
+ return decoded;
243
+ });
244
+ return segments;
245
+ }
246
+
247
+ async function validateRepository(repo, { allowMissingPins = false } = {}) {
248
+ const manifest = await readJson(path.join(repo, MANIFEST_FILE), 'manifest');
249
+ const allowedManifestKeys = ['schemaVersion', 'resources', 'dependencies'];
250
+ if (Object.hasOwn(manifest ?? {}, 'policy')) {
251
+ allowedManifestKeys.push('policy');
252
+ if (!hasExactKeys(manifest.policy, ['minimumReleaseAgeDays']) || !Number.isInteger(manifest.policy.minimumReleaseAgeDays) || manifest.policy.minimumReleaseAgeDays < 7) {
253
+ throw new Error('Manifest policy.minimumReleaseAgeDays must be an integer of at least seven days');
254
+ }
255
+ }
256
+ if (Object.hasOwn(manifest ?? {}, 'settings')) allowedManifestKeys.push('settings');
257
+ if (Object.hasOwn(manifest ?? {}, 'requiredTools')) allowedManifestKeys.push('requiredTools');
258
+ if (!hasExactKeys(manifest, allowedManifestKeys) || manifest.schemaVersion !== 1 ||
259
+ !Array.isArray(manifest.resources) || !Array.isArray(manifest.dependencies) ||
260
+ (manifest.settings !== undefined && !Array.isArray(manifest.settings))) {
261
+ throw new Error('Manifest must have schemaVersion 1, resources[], dependencies[], and optional settings[] and requiredTools[] only');
262
+ }
263
+ const requiredTools = validateRequiredTools(manifest.requiredTools ?? []);
264
+
265
+ const lock = await readJson(path.join(repo, LOCK_FILE), 'lockfile');
266
+ const packageState = validatePackageState(manifest, lock, { allowMissingPins });
267
+
268
+ const resources = [];
269
+ const targets = new Set();
270
+ const sources = new Set();
271
+ for (const [index, item] of manifest.resources.entries()) {
272
+ if (!hasExactKeys(item, ['type', 'source', 'target'])) throw new Error(`resources[${index}] must contain exactly type, source, and target`);
273
+ const rule = RESOURCE_RULES[item.type];
274
+ if (!rule) throw new Error(`Unsupported resource type in resources[${index}]: ${String(item.type)}`);
275
+ validateRelativePath(item.source, `resources[${index}].source`);
276
+ validateRelativePath(item.target, `resources[${index}].target`);
277
+ if (!item.source.startsWith(`${rule.sourceRoot}/`) || !item.target.startsWith(`${rule.targetRoot}/`)) {
278
+ throw new Error(`${rule.label} resources must use ${rule.sourceRoot}/ sources and ${rule.targetRoot}/ targets (resources[${index}])`);
279
+ }
280
+ const sourceName = item.source.slice(rule.sourceRoot.length + 1);
281
+ const targetName = item.target.slice(rule.targetRoot.length + 1);
282
+ if (item.type !== 'skill' && sourceName !== targetName) {
283
+ throw new Error(`${rule.label} resource source and target must use the same name: ${item.source} vs ${item.target} (resources[${index}])`);
284
+ }
285
+ if (rule.shape === 'file') {
286
+ if (targetName !== path.posix.basename(targetName) || !targetName.endsWith(rule.suffix) || path.posix.basename(targetName).slice(0, -rule.suffix.length).length === 0) {
287
+ throw new Error(`${rule.label} resources must be a single ${rule.suffix} file directly under ${rule.targetRoot}/ (for example, ${rule.targetRoot}/example${rule.suffix}) (resources[${index}])`);
288
+ }
289
+ }
290
+ if (targets.has(item.target)) throw new Error(`Duplicate resource target: ${item.target}`);
291
+ for (const existingTarget of targets) {
292
+ if (pathsOverlap(item.target, existingTarget)) {
293
+ throw new Error(`Overlapping resource targets: ${existingTarget} and ${item.target}`);
294
+ }
295
+ }
296
+ if (sources.has(item.source)) throw new Error(`Duplicate resource source: ${item.source}`);
297
+ targets.add(item.target);
298
+ sources.add(item.source);
299
+ const sourcePath = resolveInside(repo, item.source, `resources[${index}].source`);
300
+ const sourceKind = await validateResourceSource(repo, sourcePath, item.source, rule);
301
+ resources.push({ ...item, sourcePath, sourceKind });
302
+ }
303
+
304
+ const settings = [];
305
+ const settingsTargets = new Set();
306
+ for (const [index, item] of (manifest.settings ?? []).entries()) {
307
+ if (!hasExactKeys(item, ['target', 'fields']) || !SETTINGS_FILES.has(item.target) || !Array.isArray(item.fields) || item.fields.length === 0) {
308
+ throw new Error(`settings[${index}] must contain an allowed target and a non-empty fields[] array`);
309
+ }
310
+ if (settingsTargets.has(item.target)) throw new Error(`Duplicate settings target: ${item.target}`);
311
+ settingsTargets.add(item.target);
312
+ const paths = [];
313
+ const fields = [];
314
+ for (const [fieldIndex, field] of item.fields.entries()) {
315
+ if (!hasExactKeys(field, ['path', 'value'])) throw new Error(`settings[${index}].fields[${fieldIndex}] must contain exactly path and value`);
316
+ const fieldLabel = `settings[${index}].fields[${fieldIndex}]`;
317
+ const segments = decodePointer(field.path, `${fieldLabel}.path`);
318
+ validatePortableSettingValue(field.value, `${fieldLabel}.value`);
319
+ for (const segment of segments) {
320
+ if (isCredentialLikeKey(segment.toLowerCase().replace(/[^a-z0-9]/g, ''))) {
321
+ throw new Error(`${fieldLabel}.path contains a credential-like key`);
322
+ }
323
+ }
324
+ if (paths.some((existing) => segments.slice(0, Math.min(existing.length, segments.length)).every((part, i) => part === existing[i]))) {
325
+ throw new Error(`Overlapping or duplicate managed settings path: ${field.path}`);
326
+ }
327
+ paths.push(segments);
328
+ fields.push({ pointer: field.path, segments, value: field.value });
329
+ }
330
+ settings.push({ target: item.target, fields });
331
+ }
332
+ return { resources, settings, requiredTools, lock, packageState };
333
+ }
334
+
335
+ async function ensureRealDirectory(root, relative, create) {
336
+ let current = root;
337
+ for (const segment of relative.split('/')) {
338
+ current = path.join(current, segment);
339
+ let info = await fs.lstat(current).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
340
+ if (!info && create) {
341
+ await fs.mkdir(current);
342
+ info = await fs.lstat(current);
343
+ }
344
+ if (info && (info.isSymbolicLink() || !info.isDirectory())) throw new Error(`Repository path must contain only real directories: ${path.relative(root, current)}`);
345
+ if (!info) return false;
346
+ }
347
+ return true;
348
+ }
349
+
350
+ const RESOURCE_ADD_RULES = {
351
+ skill: { localRoot: 'skills', sourceRoot: 'resources/skills', shape: 'directory', label: 'Skill',
352
+ pattern: /^skills\/[A-Za-z0-9._-]+$/, patternError: 'Skill source must be a direct child directory of skills/ (for example, skills/example)' },
353
+ extension: { localRoot: 'extensions', sourceRoot: 'resources/extensions', shape: 'any', label: 'Extension',
354
+ pattern: /^extensions\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/, patternError: 'Extension source must be a file or directory under extensions/ (for example, extensions/github-copilot-usage or extensions/subagent/config.json)' },
355
+ prompt: { localRoot: 'prompts', sourceRoot: 'resources/prompts', shape: 'file', label: 'Prompt',
356
+ pattern: /^prompts\/[A-Za-z0-9._-]+\.md$/, patternError: 'Prompt source must be a .md file directly under prompts/ (for example, prompts/deploy.md)' },
357
+ agent: { localRoot: 'agents', sourceRoot: 'resources/agents', shape: 'file', label: 'Agent',
358
+ pattern: /^agents\/[A-Za-z0-9._-]+\.md$/, patternError: 'Agent source must be a .md file directly under agents/ (for example, agents/reviewer.md)' },
359
+ role: { localRoot: 'roles', sourceRoot: 'resources/roles', shape: 'file', label: 'Role',
360
+ pattern: /^roles\/[A-Za-z0-9._-]+\.md$/, patternError: 'Role source must be a .md file directly under roles/ (for example, roles/moderator.md)' },
361
+ };
362
+
363
+ function normalizeResourceSource(type, source, agentDir) {
364
+ const rule = RESOURCE_ADD_RULES[type];
365
+ if (!rule) throw new Error(`Unsupported resource type: ${type}`);
366
+ if (typeof source !== 'string' || source.length === 0) throw new Error('--source must be a non-empty path');
367
+ let relative;
368
+ if (path.isAbsolute(source)) {
369
+ const absolute = path.resolve(source);
370
+ relative = path.relative(agentDir, absolute).split(path.sep).join('/');
371
+ if (relative === '' || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) {
372
+ throw new Error('--source must identify a resource inside the Pi agent directory');
373
+ }
374
+ } else {
375
+ if (source.includes('\\')) throw new Error('Relative --source paths must use POSIX separators');
376
+ relative = source;
377
+ }
378
+ validateRelativePath(relative, '--source');
379
+ if (!rule.pattern.test(relative)) throw new Error(rule.patternError);
380
+ return relative;
381
+ }
382
+
383
+ async function addDependency(repo, source) {
384
+ const match = /^(npm):(.+)$/.exec(source) ?? /^(github):(.+)$/.exec(source);
385
+ if (!match) throw new Error('Dependency source must be npm:PACKAGE or github:OWNER/REPOSITORY; Pi Sync resolves and writes the pin through update');
386
+ const dependency = match[1] === 'npm' ? { type: 'npm', name: match[2] } : { type: 'github', repository: match[2] };
387
+ if ((dependency.type === 'npm' && !/^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i.test(dependency.name)) ||
388
+ (dependency.type === 'github' && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(dependency.repository))) {
389
+ throw new Error('Dependency source has an invalid npm package name or GitHub owner/repository');
390
+ }
391
+ const manifestPath = path.join(repo, MANIFEST_FILE);
392
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
393
+ const manifest = JSON.parse(manifestText);
394
+ const identity = dependency.type === 'npm' ? `npm:${dependency.name}` : `github:${dependency.repository}`;
395
+ if (manifest.dependencies.some((item) => (item.type === 'npm' ? `npm:${item.name}` : `github:${item.repository}`) === identity)) {
396
+ const error = new Error(`Dependency is already declared: ${identity}`);
397
+ error.errorCode = 'DEPENDENCY_COLLISION';
398
+ throw error;
399
+ }
400
+ const next = { ...manifest, dependencies: [...manifest.dependencies, dependency] };
401
+ const temporary = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
402
+ await fs.writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { flag: 'wx' });
403
+ try {
404
+ if (await fs.readFile(manifestPath, 'utf8') !== manifestText) throw new Error('Manifest changed during add; retry without overwriting concurrent changes');
405
+ await fs.rename(temporary, manifestPath);
406
+ } catch (error) { await fs.rm(temporary, { force: true }); throw error; }
407
+ return { apiVersion: 1, command: 'add', ok: true, exitCode: 0, type: 'dependency', dependency, action: 'added', next: 'Run pi-sync update to preview an age-eligible exact pin' };
408
+ }
409
+
410
+ async function addResource(repo, agentDir, type, source) {
411
+ const rule = RESOURCE_ADD_RULES[type];
412
+ const relative = normalizeResourceSource(type, source, agentDir);
413
+ const sourcePath = resolveInside(agentDir, relative, '--source');
414
+ const info = await fs.lstat(sourcePath).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
415
+ if (!info || info.isSymbolicLink()) throw new Error(`Resource source does not exist or is a symbolic link: ${relative}`);
416
+ const isFile = info.isFile();
417
+ if (rule.shape === 'directory' && !info.isDirectory()) throw new Error(`${rule.label} source must be an existing directory: ${relative}`);
418
+ if (rule.shape === 'file' && !isFile) throw new Error(`${rule.label} source must be an existing regular file: ${relative}`);
419
+ if (rule.shape === 'any' && !isFile && !info.isDirectory()) throw new Error(`Extension source must be an existing file or directory: ${relative}`);
420
+ if (isFile) await validateSourceFile(agentDir, relative);
421
+ else await validateSourceTree(agentDir, sourcePath, relative);
422
+ const findings = await findLikelySecrets(sourcePath, relative);
423
+ if (findings.length) return {
424
+ apiVersion: 1, command: 'add', ok: false, errorCode: 'SECRET_REVIEW_REQUIRED', exitCode: VALIDATION_ERROR,
425
+ error: 'Import was not performed; review suspected secret material and remove it before retrying', reviewRequired: findings,
426
+ details: { reviewRequired: findings },
427
+ };
428
+
429
+ const manifestPath = path.join(repo, MANIFEST_FILE);
430
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
431
+ const manifest = JSON.parse(manifestText);
432
+ const declarations = await validateRepository(repo);
433
+ const target = relative;
434
+ const managedSource = `${rule.sourceRoot}/${path.posix.relative(rule.localRoot, relative)}`;
435
+ if (declarations.resources.some((item) => pathsOverlap(item.target, target) || pathsOverlap(item.source, managedSource))) {
436
+ const error = new Error(`Resource collision: ${target} or ${managedSource} is already declared`);
437
+ error.errorCode = 'RESOURCE_COLLISION';
438
+ error.details = { type, source: relative, target, repositorySource: managedSource };
439
+ throw error;
440
+ }
441
+ const destination = resolveInside(repo, managedSource, 'new resource source');
442
+ if (await fs.lstat(destination).then(() => true).catch((error) => error.code === 'ENOENT' ? false : Promise.reject(error))) {
443
+ const error = new Error(`Resource collision: setup repository path already exists: ${managedSource}`);
444
+ error.errorCode = 'RESOURCE_COLLISION';
445
+ error.details = { type, source: relative, target, repositorySource: managedSource };
446
+ throw error;
447
+ }
448
+ const parent = path.posix.dirname(managedSource);
449
+ const parentExists = await ensureRealDirectory(repo, parent, true);
450
+ if (!parentExists) throw new Error(`Could not create ${parent} in the setup repository`);
451
+ const before = await snapshotPath(sourcePath);
452
+ const temporary = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
453
+ let createdDestination = false;
454
+ try {
455
+ if (isFile) {
456
+ await fs.mkdir(path.dirname(destination), { recursive: true });
457
+ await fs.copyFile(sourcePath, destination);
458
+ } else {
459
+ await fs.mkdir(destination);
460
+ createdDestination = true;
461
+ for (const entry of await fs.readdir(sourcePath)) {
462
+ await fs.cp(path.join(sourcePath, entry), path.join(destination, entry), { recursive: true, errorOnExist: true, force: false });
463
+ }
464
+ }
465
+ const after = await snapshotPath(sourcePath);
466
+ const imported = await snapshotPath(destination);
467
+ if (!sameValue(before, after) || !sameValue(before, imported)) throw new Error(`Selected ${rule.label.toLowerCase()} changed during import; retry after it is stable`);
468
+ const nextManifest = { ...manifest, resources: [...manifest.resources, { type, source: managedSource, target }] };
469
+ const manifestInfo = await fs.stat(manifestPath);
470
+ await fs.writeFile(temporary, `${JSON.stringify(nextManifest, null, 2)}\n`, { flag: 'wx', mode: manifestInfo.mode & 0o777 });
471
+ const latestManifestText = await fs.readFile(manifestPath, 'utf8');
472
+ if (latestManifestText !== manifestText) throw new Error('Manifest changed during add; retry without overwriting concurrent changes');
473
+ await fs.rename(temporary, manifestPath);
474
+ } catch (error) {
475
+ await fs.rm(temporary, { force: true }).catch(() => {});
476
+ if (createdDestination) await fs.rm(destination, { recursive: true, force: true }).catch(() => {});
477
+ if (isFile) await fs.rm(destination, { force: true }).catch(() => {});
478
+ throw error;
479
+ }
480
+ return { apiVersion: 1, command: 'add', ok: true, exitCode: 0, type, source: relative, target, repositorySource: managedSource, action: 'added', reviewRequired: [] };
481
+ }
482
+
483
+ async function writeJsonFileAtomic(file, value, previousText) {
484
+ const info = await fs.stat(file);
485
+ const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
486
+ try {
487
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: info.mode & 0o777 });
488
+ if (await fs.readFile(file, 'utf8') !== previousText) {
489
+ throw new Error(`${path.basename(file)} changed during remove; retry without overwriting concurrent changes`);
490
+ }
491
+ await fs.rename(temporary, file);
492
+ } catch (error) {
493
+ await fs.rm(temporary, { force: true }).catch(() => {});
494
+ throw error;
495
+ }
496
+ }
497
+
498
+ // Restores a known previous file body through the same temp-file + rename
499
+ // pattern as the happy-path writes, so a mid-write failure can never leave
500
+ // the manifest or lockfile half-rewritten during a remove rollback.
501
+ async function restoreFileAtomic(file, text) {
502
+ const info = await fs.stat(file);
503
+ const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
504
+ try {
505
+ await fs.writeFile(temporary, text, { flag: 'wx', mode: info.mode & 0o777 });
506
+ await fs.rename(temporary, file);
507
+ } catch (error) {
508
+ await fs.rm(temporary, { force: true }).catch(() => {});
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ // Removal is repository-only by design: it deletes the manifest entry and the
514
+ // repository content, never touches the local Pi installation, and queues or
515
+ // schedules nothing for deletion. Removed resources surface as stale through
516
+ // the existing status/plan/apply reporting and stay installed until an
517
+ // explicit `prune --confirm` removes them (with its baseline-match and
518
+ // backup protections).
519
+ async function removeResource(repo, type, target) {
520
+ const rule = RESOURCE_RULES[type];
521
+ if (!rule) throw new Error(`Unsupported resource type: ${type}`);
522
+ validateRelativePath(target, '--target');
523
+ const declarations = await validateRepository(repo);
524
+ const entry = declarations.resources.find((item) => item.target === target);
525
+ if (!entry) {
526
+ const error = new Error(`No managed resource with target ${target} is declared in the manifest; remove only entries declared in the manifest`);
527
+ error.errorCode = 'REMOVE_TARGET_NOT_FOUND';
528
+ throw error;
529
+ }
530
+ if (entry.type !== type) {
531
+ const error = new Error(`Target ${target} is managed as ${entry.type}, not ${type}; use remove --type ${entry.type}`);
532
+ error.errorCode = 'REMOVE_TYPE_MISMATCH';
533
+ throw error;
534
+ }
535
+ const manifestPath = path.join(repo, MANIFEST_FILE);
536
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
537
+ const manifest = JSON.parse(manifestText);
538
+ const nextManifest = { ...manifest, resources: manifest.resources.filter((item) => item.target !== target) };
539
+ // Snapshot the repository content before deleting it so a failure after the
540
+ // manifest rewrite can leave the repository exactly as it was.
541
+ const contentBackup = await fs.mkdtemp(path.join(os.tmpdir(), 'pi-sync-remove-content-'));
542
+ try {
543
+ await fs.cp(entry.sourcePath, path.join(contentBackup, 'source'), { recursive: true, errorOnExist: true, force: false });
544
+ } catch (error) {
545
+ await fs.rm(contentBackup, { recursive: true, force: true }).catch(() => {});
546
+ throw new Error(`Could not snapshot managed resource content before removal: ${entry.source} (${error.message})`);
547
+ }
548
+ let manifestWritten = false;
549
+ try {
550
+ await writeJsonFileAtomic(manifestPath, nextManifest, manifestText);
551
+ manifestWritten = true;
552
+ try {
553
+ await fs.rm(entry.sourcePath, { recursive: true });
554
+ } catch (error) {
555
+ if (manifestWritten) await restoreFileAtomic(manifestPath, manifestText);
556
+ throw new Error(`Could not remove managed resource content: ${entry.source} (${error.message})`);
557
+ }
558
+ try {
559
+ await validateRepository(repo);
560
+ } catch (error) {
561
+ if (manifestWritten) await restoreFileAtomic(manifestPath, manifestText);
562
+ await fs.cp(path.join(contentBackup, 'source'), entry.sourcePath, { recursive: true, errorOnExist: true, force: false });
563
+ throw error;
564
+ }
565
+ } finally {
566
+ await fs.rm(contentBackup, { recursive: true, force: true }).catch(() => {});
567
+ }
568
+ return { apiVersion: 1, command: 'remove', ok: true, exitCode: 0, repository: repo,
569
+ removed: { type: entry.type, source: entry.source, target: entry.target },
570
+ stale: { target: entry.target, localInstallation: 'unchanged',
571
+ note: 'Removal is repository-only: the local installation is unchanged and nothing is queued or scheduled for deletion. status, plan, and apply now report this resource as stale until it is pruned with `pi-sync prune --confirm` or re-declared in the manifest.' } };
572
+ }
573
+
574
+ async function removeDependency(repo, source, agentDir, uninstall) {
575
+ const match = /^(npm):(.+)$/.exec(source) ?? /^(github):(.+)$/.exec(source);
576
+ if (!match) throw new Error('Dependency source must be npm:PACKAGE or github:OWNER/REPOSITORY');
577
+ const dependency = match[1] === 'npm' ? { type: 'npm', name: match[2] } : { type: 'github', repository: match[2] };
578
+ if ((dependency.type === 'npm' && !/^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i.test(dependency.name)) ||
579
+ (dependency.type === 'github' && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(dependency.repository))) {
580
+ throw new Error('Dependency source has an invalid npm package name or GitHub owner/repository');
581
+ }
582
+ // Missing pins are tolerated here so that removing a declared-but-unpinned
583
+ // dependency reaches the dedicated REMOVE_DEPENDENCY_NOT_PINNED error
584
+ // instead of the generic repository validation failure.
585
+ const declarations = await validateRepository(repo, { allowMissingPins: true });
586
+ const identity = packageIdentity(dependency);
587
+ const manifestPath = path.join(repo, MANIFEST_FILE);
588
+ const lockPath = path.join(repo, LOCK_FILE);
589
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
590
+ const lockText = await fs.readFile(lockPath, 'utf8');
591
+ const manifest = JSON.parse(manifestText);
592
+ const lock = JSON.parse(lockText);
593
+ if (!manifest.dependencies.some((item) => packageIdentity(item) === identity)) {
594
+ const error = new Error(`Dependency is not declared in the manifest: ${identity}; remove only entries declared in the manifest`);
595
+ error.errorCode = 'REMOVE_DEPENDENCY_NOT_FOUND';
596
+ throw error;
597
+ }
598
+ const pin = declarations.lock.packages.find((item) => packageIdentity(item) === identity);
599
+ if (!pin) {
600
+ const error = new Error(`Dependency has no exact pin in the lockfile: ${identity}; resolve the lockfile before removing it`);
601
+ error.errorCode = 'REMOVE_DEPENDENCY_NOT_PINNED';
602
+ throw error;
603
+ }
604
+ // --uninstall also modifies the local package pin bookkeeping, so that
605
+ // state must be readable and valid before any side effect. A corrupted
606
+ // pin file fails the command up front, before the repository change or
607
+ // the uninstall, instead of falsely failing after both have succeeded.
608
+ let localPins = null;
609
+ if (uninstall) {
610
+ const pinsFile = path.join(agentDir, STATE_DIR, 'packages.json');
611
+ const pinsInfo = await fs.lstat(pinsFile).catch((error) => (error.code === 'ENOENT' ? null : Promise.reject(error)));
612
+ if (pinsInfo && pinsInfo.isFile()) localPins = await loadPackagePins(agentDir);
613
+ }
614
+ const nextManifest = { ...manifest, dependencies: manifest.dependencies.filter((item) => packageIdentity(item) !== identity) };
615
+ const nextLock = { ...lock, packages: lock.packages.filter((item) => packageIdentity(item) !== identity) };
616
+ let manifestWritten = false;
617
+ let lockWritten = false;
618
+ try {
619
+ await writeJsonFileAtomic(manifestPath, nextManifest, manifestText);
620
+ manifestWritten = true;
621
+ await writeJsonFileAtomic(lockPath, nextLock, lockText);
622
+ lockWritten = true;
623
+ try {
624
+ await validateRepository(repo);
625
+ } catch (error) {
626
+ if (lockWritten) await restoreFileAtomic(lockPath, lockText);
627
+ if (manifestWritten) await restoreFileAtomic(manifestPath, manifestText);
628
+ throw error;
629
+ }
630
+ } catch (error) {
631
+ if (lockWritten) await restoreFileAtomic(lockPath, lockText).catch(() => {});
632
+ if (manifestWritten) await restoreFileAtomic(manifestPath, manifestText).catch(() => {});
633
+ throw error;
634
+ }
635
+ const spec = dependency.type === 'npm' ? `npm:${dependency.name}@${pin.version}` : `github:${dependency.repository}#${pin.commit}`;
636
+ const output = { apiVersion: 1, command: 'remove', ok: true, exitCode: 0, repository: repo,
637
+ removed: { dependency, pin },
638
+ localInstallation: 'unchanged',
639
+ note: 'Removal is repository-only: the package remains installed locally and nothing is queued or scheduled for deletion. Rerun with --uninstall to uninstall it explicitly.' };
640
+ if (!uninstall) return output;
641
+ // The repository change is complete at this point; an uninstall failure is
642
+ // reported without rolling back or altering it.
643
+ const uninstalled = await runCommand('pi', ['uninstall', spec]);
644
+ if (uninstalled.status !== 0 || uninstalled.error) {
645
+ const detail = uninstalled.error?.message ?? uninstalled.stderr.trim() ?? `Pi exited ${uninstalled.status}`;
646
+ output.ok = false;
647
+ output.exitCode = OPERATION_ERROR;
648
+ output.errorCode = 'UNINSTALL_FAILED';
649
+ output.error = `pi uninstall failed for ${spec}: ${detail}; the repository removal was completed and left in place`;
650
+ output.details = { message: output.error, package: identity, spec };
651
+ output.uninstall = { package: identity, spec, status: 'failed', error: detail };
652
+ output.localInstallation = 'unchanged';
653
+ output.note = 'The repository removal is in place, but the package is still installed locally; retry the uninstall when Pi can remove it.';
654
+ return output;
655
+ }
656
+ // Clear the local pin bookkeeping only when Pi Sync tracks it for this agent
657
+ // directory; the installed package itself is left to Pi. The pin state was
658
+ // already validated before the side effects, so this cleanup cannot throw
659
+ // from unreadable pin data.
660
+ if (localPins && Object.hasOwn(localPins.packages, identity)) {
661
+ const { [identity]: gone, ...remaining } = localPins.packages;
662
+ await writeLocalPackagePins(agentDir, remaining);
663
+ }
664
+ output.uninstalled = { package: identity, spec };
665
+ output.localInstallation = 'uninstalled';
666
+ output.note = 'The repository removal is in place and the package was uninstalled locally.';
667
+ return output;
668
+ }
669
+
670
+ function stableValue(value) {
671
+ if (Array.isArray(value)) return value.map(stableValue);
672
+ if (isRecord(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
673
+ return value;
674
+ }
675
+
676
+ function sameValue(left, right) {
677
+ return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right));
678
+ }
679
+
680
+ async function snapshotTree(root) {
681
+ const stat = await fs.lstat(root);
682
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { invalidType: true };
683
+ const snapshot = { directories: [], files: {} };
684
+ async function visit(directory, relative) {
685
+ const entries = await fs.readdir(directory, { withFileTypes: true });
686
+ for (const entry of entries) {
687
+ const entryPath = path.join(directory, entry.name);
688
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
689
+ if (entry.isSymbolicLink()) throw new Error(`Symbolic links are not supported in managed targets: ${child}`);
690
+ if (entry.isDirectory()) {
691
+ snapshot.directories.push(child);
692
+ await visit(entryPath, child);
693
+ } else if (entry.isFile()) {
694
+ const info = await fs.stat(entryPath);
695
+ snapshot.files[child] = { content: (await fs.readFile(entryPath)).toString('base64'), mode: info.mode & 0o777 };
696
+ } else throw new Error(`Unsupported file type in managed target: ${child}`);
697
+ }
698
+ }
699
+ await visit(root, '');
700
+ snapshot.directories.sort();
701
+ return snapshot;
702
+ }
703
+
704
+ async function snapshotPath(root) {
705
+ const stat = await fs.lstat(root);
706
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) return { invalidType: true };
707
+ if (stat.isFile()) {
708
+ const bytes = await fs.readFile(root);
709
+ return { directories: [], files: { [path.basename(root)]: { content: bytes.toString('base64'), mode: stat.mode & 0o777 } } };
710
+ }
711
+ return snapshotTree(root);
712
+ }
713
+
714
+ async function kindOf(target) {
715
+ const info = await fs.lstat(target).catch((error) => (error.code === 'ENOENT' ? null : Promise.reject(error)));
716
+ if (!info || info.isSymbolicLink()) return null;
717
+ if (info.isFile()) return 'file';
718
+ if (info.isDirectory()) return 'directory';
719
+ return null;
720
+ }
721
+
722
+ async function safeSnapshot(root) {
723
+ try { return await snapshotPath(root); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
724
+ }
725
+
726
+ async function ensureTargetParents(agentDir, relative) {
727
+ let parent = agentDir;
728
+ for (const segment of relative.split('/').slice(0, -1)) {
729
+ parent = path.join(parent, segment);
730
+ try {
731
+ const info = await fs.lstat(parent);
732
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Managed target parent must be a real directory, not a symbolic link: ${relative}`);
733
+ } catch (error) {
734
+ if (error.code !== 'ENOENT') throw error;
735
+ }
736
+ }
737
+ }
738
+
739
+ function statePaths(agentDir) {
740
+ const directory = path.join(agentDir, STATE_DIR);
741
+ return { directory, stateFile: path.join(directory, STATE_FILE), lockFile: path.join(directory, 'apply.lock'), backups: path.join(directory, 'backups') };
742
+ }
743
+
744
+ async function validateStateDirectory(directory) {
745
+ try {
746
+ const info = await fs.lstat(directory);
747
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Pi Sync state path must be a real directory: ${directory}`);
748
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
749
+ }
750
+
751
+ async function loadState(agentDir) {
752
+ const { directory, stateFile } = statePaths(agentDir);
753
+ await validateStateDirectory(directory);
754
+ const state = await readJson(stateFile, 'local Pi Sync state', { schemaVersion: 1, resources: {}, settings: {} });
755
+ if (!hasExactKeys(state, ['schemaVersion', 'resources', 'settings']) || state.schemaVersion !== 1 || !isRecord(state.resources) || !isRecord(state.settings)) {
756
+ throw new Error(`Local Pi Sync state is invalid: ${stateFile}`);
757
+ }
758
+ return state;
759
+ }
760
+
761
+ async function writeState(agentDir, state) {
762
+ const { directory, stateFile } = statePaths(agentDir);
763
+ await fs.mkdir(directory, { recursive: true });
764
+ await validateStateDirectory(directory);
765
+ const suffix = `${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
766
+ const temporary = `${stateFile}.${suffix}.tmp`;
767
+ const previous = `${stateFile}.${suffix}.old`;
768
+ await fs.writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
769
+ let hadState = false;
770
+ try {
771
+ hadState = await fs.lstat(stateFile).then(() => true).catch((error) => error.code === 'ENOENT' ? false : Promise.reject(error));
772
+ if (hadState) await fs.rename(stateFile, previous);
773
+ await fs.rename(temporary, stateFile);
774
+ if (hadState) await fs.rm(previous, { force: true });
775
+ } catch (error) {
776
+ await fs.rm(temporary, { force: true }).catch(() => {});
777
+ if (hadState) await fs.rename(previous, stateFile).catch(() => {});
778
+ throw error;
779
+ }
780
+ }
781
+
782
+ async function acquireLock(agentDir) {
783
+ const { directory, lockFile } = statePaths(agentDir);
784
+ await fs.mkdir(directory, { recursive: true });
785
+ await validateStateDirectory(directory);
786
+ const deadline = Date.now() + 2500;
787
+ while (Date.now() < deadline) {
788
+ const token = crypto.randomBytes(16).toString('hex');
789
+ try {
790
+ const handle = await fs.open(lockFile, 'wx', 0o600);
791
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token, startedAt: new Date().toISOString() }));
792
+ await handle.close();
793
+ return async () => {
794
+ try {
795
+ const owner = JSON.parse(await fs.readFile(lockFile, 'utf8'));
796
+ if (owner?.pid === process.pid && owner?.token === token) await fs.rm(lockFile);
797
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
798
+ };
799
+ } catch (error) {
800
+ if (error.code !== 'EEXIST') throw error;
801
+ let owner;
802
+ try { owner = JSON.parse(await fs.readFile(lockFile, 'utf8')); } catch { owner = null; }
803
+ if (Number.isInteger(owner?.pid) && owner.pid > 0) {
804
+ let alive = true;
805
+ try { process.kill(owner.pid, 0); } catch (probeError) { if (probeError.code === 'ESRCH') alive = false; }
806
+ if (!alive) {
807
+ throw new Error('A stale Pi Sync apply lock was found; remove .pi-sync/apply.lock only after confirming no apply process is running');
808
+ }
809
+ }
810
+ await new Promise((resolve) => setTimeout(resolve, 40));
811
+ }
812
+ }
813
+ throw new Error('Another Pi Sync apply operation holds the local lock; retry after it finishes');
814
+ }
815
+
816
+ function resourceFileAction(local, repository, baseline) {
817
+ const repositoryChanged = !sameValue(repository, baseline);
818
+ const localChanged = !sameValue(local, baseline);
819
+ if (!repositoryChanged && !localChanged) return { action: 'unchanged' };
820
+ if (repositoryChanged && !localChanged) return { action: repository === null ? 'delete' : 'update' };
821
+ if (!repositoryChanged && localChanged) return { action: 'local-divergence' };
822
+ if (sameValue(local, repository)) return { action: 'unchanged' };
823
+ return { action: 'conflict', reason: 'local and setup repository both changed this file since the last apply' };
824
+ }
825
+
826
+ function resourceAction(resource, local, baseline, localKind) {
827
+ if (!baseline) {
828
+ if (local === null) return { action: 'install' };
829
+ if (localKind === resource.kind && sameValue(local, resource.snapshot)) return { action: 'unchanged', rememberBaseline: true };
830
+ return { action: 'collision', reason: 'target exists but is not tracked by Pi Sync' };
831
+ }
832
+ const names = new Set([...Object.keys(baseline.files ?? {}), ...Object.keys(local?.files ?? {}), ...Object.keys(resource.snapshot.files)]);
833
+ const files = [...names].sort().map((relative) => ({
834
+ path: relative,
835
+ local: local?.files?.[relative] ?? null,
836
+ repository: resource.snapshot.files[relative] ?? null,
837
+ ...resourceFileAction(local?.files?.[relative] ?? null, resource.snapshot.files[relative] ?? null, baseline.files?.[relative] ?? null),
838
+ }));
839
+ // A tracked target must keep its managed kind. A replacement by the other
840
+ // kind (even with identical content and mode) is reported immediately
841
+ // instead of waiting for a repository change to surface it; apply refuses
842
+ // to touch the target until it is resolved manually.
843
+ if (localKind !== null && localKind !== resource.kind) {
844
+ return { action: 'conflict', files, reason: `managed target changed type since the last apply (expected ${resource.kind}); resolve manually`, kindMismatch: true };
845
+ }
846
+ const fileActions = files.map((file) => file.action);
847
+ let action = 'unchanged';
848
+ if (fileActions.includes('conflict')) action = 'conflict';
849
+ else if (fileActions.includes('update') || fileActions.includes('delete')) action = 'update';
850
+ else if (fileActions.includes('local-divergence')) action = 'local-divergence';
851
+ return { action, files, reason: action === 'conflict' ? 'local and setup repository both changed one or more files' : undefined };
852
+ }
853
+
854
+ function readManagedField(object, segments) {
855
+ let current = object;
856
+ for (const segment of segments) {
857
+ if (!isRecord(current)) return { valid: false, exists: false };
858
+ if (!Object.hasOwn(current, segment)) return { valid: true, exists: false };
859
+ current = current[segment];
860
+ }
861
+ return { valid: true, exists: true, value: current };
862
+ }
863
+
864
+ function setManagedField(object, segments, value) {
865
+ let current = object;
866
+ for (const segment of segments.slice(0, -1)) {
867
+ if (!Object.hasOwn(current, segment)) current[segment] = {};
868
+ if (!isRecord(current[segment])) throw new Error(`Settings field parent is not an object: /${segments.join('/')}`);
869
+ current = current[segment];
870
+ }
871
+ current[segments.at(-1)] = value;
872
+ }
873
+
874
+ function fieldState(value) {
875
+ return value.exists ? { exists: true, value: value.value } : { exists: false };
876
+ }
877
+
878
+ function sameField(left, right) {
879
+ return left?.exists === right?.exists && (!left?.exists || sameValue(left.value, right.value));
880
+ }
881
+
882
+ function settingFieldAction(field, local, baseline) {
883
+ if (!baseline) {
884
+ if (!local.exists) return { action: 'install' };
885
+ if (sameValue(local.value, field.value)) return { action: 'unchanged', rememberBaseline: true };
886
+ return { action: 'collision', reason: 'managed setting already exists but is not tracked by Pi Sync' };
887
+ }
888
+ const desired = { exists: true, value: field.value };
889
+ const localState = fieldState(local);
890
+ const repositoryChanged = !sameField(desired, baseline);
891
+ const localChanged = !sameField(localState, baseline);
892
+ if (!repositoryChanged && !localChanged) return { action: 'unchanged' };
893
+ if (repositoryChanged && !localChanged) return { action: 'update' };
894
+ if (!repositoryChanged && localChanged) return { action: 'local-divergence' };
895
+ if (sameField(localState, desired)) return { action: 'unchanged', rememberBaseline: true };
896
+ return { action: 'conflict', reason: 'local and setup repository both changed since the last apply' };
897
+ }
898
+
899
+ function summarizeActions(actions) {
900
+ const priority = ['conflict', 'collision', 'update', 'install', 'local-divergence', 'unchanged'];
901
+ return priority.find((name) => actions.includes(name)) ?? 'unchanged';
902
+ }
903
+
904
+ async function readSettingsFile(file) {
905
+ try {
906
+ const info = await fs.lstat(file);
907
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Managed settings target must be a regular file: ${file}`);
908
+ const text = await fs.readFile(file, 'utf8');
909
+ let value;
910
+ try { value = JSON.parse(text); } catch { throw new Error(`Managed settings file is not valid JSON: ${file}`); }
911
+ if (!isRecord(value)) throw new Error(`Managed settings file must contain a JSON object: ${file}`);
912
+ return { value, text, exists: true };
913
+ } catch (error) {
914
+ if (error.code === 'ENOENT') return { value: {}, text: null, exists: false };
915
+ throw error;
916
+ }
917
+ }
918
+
919
+ async function buildPlan(repo, agentDir, declarations, state) {
920
+ const resources = [];
921
+ for (const item of declarations.resources) {
922
+ const targetPath = resolveInside(agentDir, item.target, 'resource target');
923
+ await ensureTargetParents(agentDir, item.target);
924
+ const [snapshot, sourceSnapshot, localKind] = await Promise.all([safeSnapshot(targetPath), snapshotPath(item.sourcePath), kindOf(targetPath)]);
925
+ const resource = { ...item, targetPath, localSnapshot: snapshot, snapshot: sourceSnapshot, kind: item.sourceKind };
926
+ const decision = resourceAction(resource, snapshot, state.resources[item.target], localKind);
927
+ resource.action = decision.action;
928
+ resource.reason = decision.reason;
929
+ resource.files = decision.files ?? [];
930
+ resource.kindMismatch = decision.kindMismatch === true;
931
+ resource.rememberBaseline = decision.rememberBaseline;
932
+ resources.push(resource);
933
+ }
934
+
935
+ const settings = [];
936
+ for (const declaration of declarations.settings) {
937
+ const targetPath = path.join(agentDir, declaration.target);
938
+ const [file, info] = await Promise.all([readSettingsFile(targetPath), fs.lstat(agentDir).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error))]);
939
+ if (info?.isSymbolicLink() || (info && !info.isDirectory())) throw new Error(`Pi agent directory must be a real directory: ${agentDir}`);
940
+ const baselineForTarget = state.settings[declaration.target] ?? {};
941
+ const fields = declaration.fields.map((field) => {
942
+ const local = readManagedField(file.value, field.segments);
943
+ const baseline = baselineForTarget[field.pointer];
944
+ const decision = local.valid ? settingFieldAction(field, local, baseline) : { action: 'conflict', reason: 'managed setting path crosses a non-object value' };
945
+ return { ...field, action: decision.action, reason: decision.reason, local, baseline, rememberBaseline: decision.rememberBaseline };
946
+ });
947
+ settings.push({ target: declaration.target, targetPath, fields, fileText: file.text, fileExists: file.exists, value: file.value, action: summarizeActions(fields.map((field) => field.action)) });
948
+ }
949
+ const declaredTargets = new Set(declarations.resources.map((item) => item.target));
950
+ const stale = [];
951
+ for (const [target, baseline] of Object.entries(state.resources)) {
952
+ if (declaredTargets.has(target)) continue;
953
+ validateRelativePath(target, 'stale resource target');
954
+ if (!RESOURCE_TARGET_ROOTS.some((root) => target.startsWith(root)) || !isRecord(baseline) || !isRecord(baseline.files) || !Array.isArray(baseline.directories)) {
955
+ throw new Error(`Local Pi Sync state contains an invalid stale resource baseline: ${target}`);
956
+ }
957
+ const overlaps = [...declaredTargets].filter((declaredTarget) => pathsOverlap(target, declaredTarget));
958
+ const targetPath = resolveInside(agentDir, target, 'stale resource target');
959
+ await ensureTargetParents(agentDir, target);
960
+ const [localSnapshot, localKind] = await Promise.all([safeSnapshot(targetPath), kindOf(targetPath)]);
961
+ const baselineKind = baseline.kind === 'file' ? 'file' : 'directory';
962
+ const unchanged = localSnapshot === null || (localKind === baselineKind && sameValue(localSnapshot, { directories: baseline.directories, files: baseline.files }));
963
+ stale.push({ target, targetPath, localSnapshot, overlaps, eligible: overlaps.length === 0 && unchanged,
964
+ state: overlaps.length ? 'overlaps-current' : localSnapshot === null ? 'missing' : unchanged ? 'unchanged' : 'modified' });
965
+ }
966
+ return { resources, settings, stale };
967
+ }
968
+
969
+ function outputAction(item, key) {
970
+ return { type: item.type ?? 'setting', [key]: item[key], action: item.action, ...(item.reason ? { reason: item.reason } : {}),
971
+ ...(item.files?.length ? { files: item.files.map(({ path: file, action, reason }) => ({ path: file, action, ...(reason ? { reason } : {}) })) } : {}) };
972
+ }
973
+
974
+ async function createBackup(agentDir, relative, source, isDirectory) {
975
+ const { backups } = statePaths(agentDir);
976
+ const stamp = `${new Date().toISOString().replaceAll(':', '-')}-${crypto.randomBytes(4).toString('hex')}`;
977
+ const destination = path.join(backups, stamp, ...relative.split('/'));
978
+ await fs.mkdir(path.dirname(destination), { recursive: true });
979
+ if (isDirectory) await fs.cp(source, destination, { recursive: true, errorOnExist: true, force: false });
980
+ else await fs.copyFile(source, destination);
981
+ return destination;
982
+ }
983
+
984
+ async function materializeSnapshot(directory, snapshot) {
985
+ await fs.mkdir(directory, { recursive: true });
986
+ for (const relative of snapshot.directories) await fs.mkdir(path.join(directory, ...relative.split('/')), { recursive: true });
987
+ for (const [relative, file] of Object.entries(snapshot.files)) {
988
+ const destination = path.join(directory, ...relative.split('/'));
989
+ await fs.mkdir(path.dirname(destination), { recursive: true });
990
+ await fs.writeFile(destination, Buffer.from(file.content, 'base64'), { mode: file.mode });
991
+ await fs.chmod(destination, file.mode);
992
+ }
993
+ }
994
+
995
+ async function replaceDirectory(target, desiredSnapshot, expectedSnapshot, relative) {
996
+ const parent = path.dirname(target);
997
+ await fs.mkdir(parent, { recursive: true });
998
+ const prepared = path.join(parent, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.new`);
999
+ const previous = path.join(parent, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.old`);
1000
+ await materializeSnapshot(prepared, desiredSnapshot);
1001
+ const observed = await safeSnapshot(target);
1002
+ if (!sameValue(observed, expectedSnapshot)) {
1003
+ await fs.rm(prepared, { recursive: true, force: true });
1004
+ throw new Error(`Managed target changed during apply; refusing to overwrite: ${relative}`);
1005
+ }
1006
+ let originalMoved = false;
1007
+ let replacementInstalled = false;
1008
+ try {
1009
+ if (observed !== null) {
1010
+ await fs.rename(target, previous);
1011
+ originalMoved = true;
1012
+ }
1013
+ await fs.rename(prepared, target);
1014
+ replacementInstalled = true;
1015
+ if (originalMoved) await fs.rm(previous, { recursive: true, force: true });
1016
+ } catch (error) {
1017
+ if (replacementInstalled) await fs.rm(target, { recursive: true, force: true }).catch(() => {});
1018
+ if (originalMoved) await fs.rename(previous, target).catch(() => {});
1019
+ await fs.rm(prepared, { recursive: true, force: true }).catch(() => {});
1020
+ throw error;
1021
+ }
1022
+ }
1023
+
1024
+ async function applyDirectory(agentDir, resource) {
1025
+ const current = await safeSnapshot(resource.targetPath);
1026
+ if (!sameValue(current, resource.localSnapshot)) throw new Error(`Managed resource changed during apply; refusing to overwrite: ${resource.target}`);
1027
+ if (current !== null) await createBackup(agentDir, resource.target, resource.targetPath, true);
1028
+ const sourceNow = await snapshotPath(resource.sourcePath);
1029
+ if (!sameValue(sourceNow, resource.snapshot)) throw new Error(`Setup resource changed during apply; retry: ${resource.target}`);
1030
+ const desired = structuredClone(current ?? { directories: [], files: {} });
1031
+ if (resource.action === 'install') desired.files = structuredClone(resource.snapshot.files);
1032
+ for (const file of resource.files ?? []) {
1033
+ if (file.action === 'update') desired.files[file.path] = file.repository;
1034
+ if (file.action === 'delete') delete desired.files[file.path];
1035
+ }
1036
+ const directories = new Set([...(desired.directories ?? []), ...resource.snapshot.directories]);
1037
+ desired.directories = [...directories].sort();
1038
+ await replaceDirectory(resource.targetPath, desired, current, resource.target);
1039
+ }
1040
+
1041
+ async function applyFileResource(agentDir, resource) {
1042
+ const name = path.posix.basename(resource.target);
1043
+ const desiredFile = resource.action === 'install'
1044
+ ? resource.snapshot.files[name]
1045
+ : (resource.files.find((file) => file.action === 'update') ?? { repository: null }).repository;
1046
+ const current = await safeSnapshot(resource.targetPath);
1047
+ if (!sameValue(current, resource.localSnapshot)) throw new Error(`Managed resource changed during apply; refusing to overwrite: ${resource.target}`);
1048
+ if (current !== null) await createBackup(agentDir, resource.target, resource.targetPath, false);
1049
+ const sourceNow = await snapshotPath(resource.sourcePath);
1050
+ if (!sameValue(sourceNow, resource.snapshot)) throw new Error(`Setup resource changed during apply; retry: ${resource.target}`);
1051
+ if (desiredFile === undefined || desiredFile === null) {
1052
+ if (current !== null) await fs.rm(resource.targetPath);
1053
+ return;
1054
+ }
1055
+ await replaceFileResource(resource.targetPath, desiredFile, current, resource.target);
1056
+ }
1057
+
1058
+ async function atomicSwapFile(target, prepared, relative) {
1059
+ const previous = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.old`);
1060
+ let originalMoved = false;
1061
+ let replacementInstalled = false;
1062
+ try {
1063
+ const existed = await fs.lstat(target).then(() => true).catch((error) => (error.code === 'ENOENT' ? false : Promise.reject(error)));
1064
+ if (existed) {
1065
+ await fs.rename(target, previous);
1066
+ originalMoved = true;
1067
+ }
1068
+ await fs.rename(prepared, target);
1069
+ replacementInstalled = true;
1070
+ if (originalMoved) await fs.rm(previous, { force: true });
1071
+ } catch (error) {
1072
+ if (replacementInstalled) await fs.rm(target, { force: true }).catch(() => {});
1073
+ if (originalMoved) await fs.rename(previous, target).catch(() => {});
1074
+ await fs.rm(prepared, { force: true }).catch(() => {});
1075
+ throw new Error(`Could not atomically update ${relative}: ${error.message}`);
1076
+ }
1077
+ }
1078
+
1079
+ async function replaceFileResource(target, desiredFile, expectedSnapshot, relative) {
1080
+ const parent = path.dirname(target);
1081
+ await fs.mkdir(parent, { recursive: true });
1082
+ const prepared = path.join(parent, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.new`);
1083
+ await fs.writeFile(prepared, Buffer.from(desiredFile.content, 'base64'), { flag: 'wx', mode: desiredFile.mode });
1084
+ await fs.chmod(prepared, desiredFile.mode);
1085
+ const current = await safeSnapshot(target);
1086
+ if (!sameValue(current, expectedSnapshot)) {
1087
+ await fs.rm(prepared, { force: true });
1088
+ throw new Error(`Managed resource changed during apply; refusing to overwrite: ${relative}`);
1089
+ }
1090
+ await atomicSwapFile(target, prepared, relative);
1091
+ }
1092
+
1093
+ async function replaceFile(target, content, expectedText, relative) {
1094
+ const parent = path.dirname(target);
1095
+ await fs.mkdir(parent, { recursive: true });
1096
+ const prepared = path.join(parent, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.new`);
1097
+ const previous = path.join(parent, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.old`);
1098
+ await fs.writeFile(prepared, content, { flag: 'wx', mode: 0o600 });
1099
+ const current = await readSettingsFile(target);
1100
+ if (current.text !== expectedText) {
1101
+ await fs.rm(prepared, { force: true });
1102
+ throw new Error(`Settings file changed during apply; refusing to overwrite: ${relative}`);
1103
+ }
1104
+ const existed = current.exists;
1105
+ let originalMoved = false;
1106
+ let replacementInstalled = false;
1107
+ try {
1108
+ if (existed) {
1109
+ await fs.rename(target, previous);
1110
+ originalMoved = true;
1111
+ }
1112
+ await fs.rename(prepared, target);
1113
+ replacementInstalled = true;
1114
+ if (originalMoved) await fs.rm(previous, { force: true });
1115
+ } catch (error) {
1116
+ if (replacementInstalled) await fs.rm(target, { force: true }).catch(() => {});
1117
+ if (originalMoved) await fs.rename(previous, target).catch(() => {});
1118
+ await fs.rm(prepared, { force: true }).catch(() => {});
1119
+ throw new Error(`Could not atomically update ${relative}: ${error.message}`);
1120
+ }
1121
+ }
1122
+
1123
+ async function applySettings(agentDir, settings) {
1124
+ const current = await readSettingsFile(settings.targetPath);
1125
+ if (current.text !== settings.fileText) throw new Error(`Settings file changed during apply; refusing to overwrite: ${settings.target}`);
1126
+ const next = structuredClone(settings.value);
1127
+ for (const field of settings.fields) {
1128
+ if (field.action === 'install' || field.action === 'update') setManagedField(next, field.segments, field.value);
1129
+ }
1130
+ const content = `${JSON.stringify(next, null, 2)}\n`;
1131
+ if (settings.fileExists) await createBackup(agentDir, `settings/${settings.target}`, settings.targetPath, false);
1132
+ await replaceFile(settings.targetPath, content, current.text, settings.target);
1133
+ }
1134
+
1135
+ async function pruneStale(agentDir, state, stale, confirmed) {
1136
+ const results = [];
1137
+ const nextState = structuredClone(state);
1138
+ for (const item of stale) {
1139
+ if (item.overlaps?.length) {
1140
+ results.push({ target: item.target, action: 'retained-overlap', installed: item.localSnapshot !== null, overlaps: item.overlaps });
1141
+ continue;
1142
+ }
1143
+ if (!confirmed) {
1144
+ results.push({ target: item.target, action: item.eligible ? 'would-prune' : 'modified', installed: item.localSnapshot !== null });
1145
+ continue;
1146
+ }
1147
+ if (!item.eligible) {
1148
+ results.push({ target: item.target, action: 'retained-modified', installed: true });
1149
+ continue;
1150
+ }
1151
+ if (item.localSnapshot !== null) {
1152
+ const info = await fs.lstat(item.targetPath);
1153
+ await createBackup(agentDir, item.target, item.targetPath, info.isDirectory());
1154
+ const observed = await safeSnapshot(item.targetPath);
1155
+ if (!sameValue(observed, item.localSnapshot)) throw new Error(`Stale resource changed during prune; refusing to remove: ${item.target}`);
1156
+ const tombstone = path.join(path.dirname(item.targetPath), `.${path.basename(item.targetPath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.pruned`);
1157
+ await fs.rename(item.targetPath, tombstone);
1158
+ try { await fs.rm(tombstone, { recursive: true }); }
1159
+ catch (error) {
1160
+ await fs.rename(tombstone, item.targetPath).catch(() => {});
1161
+ throw error;
1162
+ }
1163
+ }
1164
+ delete nextState.resources[item.target];
1165
+ await writeState(agentDir, nextState);
1166
+ results.push({ target: item.target, action: item.localSnapshot === null ? 'forgotten-missing' : 'pruned', installed: false });
1167
+ }
1168
+ return results;
1169
+ }
1170
+
1171
+ async function loadPackagePins(agentDir) {
1172
+ const file = path.join(agentDir, STATE_DIR, 'packages.json');
1173
+ const value = await readJson(file, 'local package pin state', { schemaVersion: 1, packages: {} });
1174
+ if (!hasExactKeys(value, ['schemaVersion', 'packages']) || value.schemaVersion !== 1 || !isRecord(value.packages)) {
1175
+ throw new Error(`Local Pi Sync package state is invalid: ${file}`);
1176
+ }
1177
+ return value;
1178
+ }
1179
+
1180
+ async function writeLocalPackagePins(agentDir, packages) {
1181
+ const directory = path.join(agentDir, STATE_DIR);
1182
+ await fs.mkdir(directory, { recursive: true });
1183
+ const file = path.join(directory, 'packages.json');
1184
+ const temporary = `${file}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
1185
+ try {
1186
+ await fs.writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, packages }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
1187
+ await fs.rename(temporary, file);
1188
+ } catch (error) {
1189
+ await fs.rm(temporary, { force: true }).catch(() => {});
1190
+ throw error;
1191
+ }
1192
+ }
1193
+
1194
+ async function savePackagePins(agentDir, lock) {
1195
+ const packages = Object.fromEntries(lock.packages.map((item) => [item.type === 'npm' ? `npm:${item.name}` : `github:${item.repository}`, item.type === 'npm' ? item.version : item.commit]));
1196
+ await writeLocalPackagePins(agentDir, packages);
1197
+ }
1198
+
1199
+ function packageIdentity(item) { return item.type === 'npm' ? `npm:${item.name}` : `github:${item.repository}`; }
1200
+ function packageRevision(item) { return item.type === 'npm' ? item.version : item.commit; }
1201
+
1202
+ async function captureRawFile(file) {
1203
+ try {
1204
+ const info = await fs.lstat(file);
1205
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Transaction state must be a regular file: ${file}`);
1206
+ return { exists: true, content: await fs.readFile(file), mode: info.mode & 0o777 };
1207
+ } catch (error) { if (error.code === 'ENOENT') return { exists: false }; throw error; }
1208
+ }
1209
+
1210
+ async function restoreRawFile(file, snapshot) {
1211
+ if (!snapshot.exists) { await fs.rm(file, { force: true }); return; }
1212
+ await fs.mkdir(path.dirname(file), { recursive: true });
1213
+ const temporary = `${file}.pi-sync-rollback-${process.pid}-${crypto.randomBytes(5).toString('hex')}`;
1214
+ const previous = `${temporary}.old`;
1215
+ await fs.writeFile(temporary, snapshot.content, { flag: 'wx', mode: snapshot.mode });
1216
+ await fs.chmod(temporary, snapshot.mode);
1217
+ let moved = false;
1218
+ try {
1219
+ await fs.rename(file, previous);
1220
+ moved = true;
1221
+ } catch (error) { if (error.code !== 'ENOENT') { await fs.rm(temporary, { force: true }); throw error; } }
1222
+ try { await fs.rename(temporary, file); }
1223
+ catch (error) {
1224
+ if (moved) await fs.rename(previous, file).catch(() => {});
1225
+ await fs.rm(temporary, { force: true });
1226
+ throw error;
1227
+ }
1228
+ if (moved) await fs.rm(previous, { force: true });
1229
+ }
1230
+
1231
+ async function captureManagedFilesystem(agentDir, resources, settings) {
1232
+ const resourceSnapshots = [];
1233
+ for (const resource of resources) resourceSnapshots.push({ target: resource.target, targetPath: resource.targetPath, kind: resource.kind, snapshot: await safeSnapshot(resource.targetPath) });
1234
+ const settingsSnapshots = [];
1235
+ for (const item of settings) {
1236
+ const file = item.targetPath;
1237
+ const current = await readSettingsFile(file);
1238
+ let mode = 0o600;
1239
+ if (current.exists) mode = (await fs.stat(file)).mode & 0o777;
1240
+ settingsSnapshots.push({ target: item.target, file, exists: current.exists, text: current.text, mode });
1241
+ }
1242
+ return { resourceSnapshots, settingsSnapshots };
1243
+ }
1244
+
1245
+ async function restoreResourceSnapshot(agentDir, target, targetPath, kind, snapshot) {
1246
+ await ensureTargetParents(agentDir, target);
1247
+ if (snapshot === null) {
1248
+ await fs.rm(targetPath, { recursive: true, force: true });
1249
+ return;
1250
+ }
1251
+ if (snapshot.invalidType) throw new Error(`Cannot restore unsupported resource snapshot: ${target}`);
1252
+ if (kind === 'file') {
1253
+ const name = Object.keys(snapshot.files).find((file) => snapshot.files[file] !== undefined);
1254
+ const file = name ? snapshot.files[name] : null;
1255
+ if (!file) throw new Error(`Cannot restore empty file resource snapshot: ${target}`);
1256
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
1257
+ const prepared = `${targetPath}.pi-sync-rollback-${process.pid}-${crypto.randomBytes(5).toString('hex')}`;
1258
+ await fs.writeFile(prepared, Buffer.from(file.content, 'base64'), { flag: 'wx', mode: file.mode });
1259
+ await fs.chmod(prepared, file.mode);
1260
+ await atomicSwapFile(targetPath, prepared, target);
1261
+ return;
1262
+ }
1263
+ const temporary = `${targetPath}.pi-sync-rollback-${process.pid}-${crypto.randomBytes(5).toString('hex')}`;
1264
+ const previous = `${temporary}.old`;
1265
+ await fs.mkdir(temporary, { recursive: false });
1266
+ try {
1267
+ for (const directory of snapshot.directories) await fs.mkdir(path.join(temporary, ...directory.split('/')), { recursive: true });
1268
+ for (const [relative, file] of Object.entries(snapshot.files)) {
1269
+ const destination = path.join(temporary, ...relative.split('/'));
1270
+ await fs.mkdir(path.dirname(destination), { recursive: true });
1271
+ await fs.writeFile(destination, Buffer.from(file.content, 'base64'), { mode: file.mode });
1272
+ await fs.chmod(destination, file.mode);
1273
+ }
1274
+ let moved = false;
1275
+ try {
1276
+ await fs.rename(targetPath, previous);
1277
+ moved = true;
1278
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
1279
+ try { await fs.rename(temporary, targetPath); }
1280
+ catch (error) {
1281
+ if (moved) await fs.rename(previous, targetPath).catch(() => {});
1282
+ throw error;
1283
+ }
1284
+ if (moved) await fs.rm(previous, { recursive: true, force: true });
1285
+ } finally { await fs.rm(temporary, { recursive: true, force: true }).catch(() => {}); }
1286
+ }
1287
+
1288
+ async function restoreManagedFilesystem(agentDir, snapshots) {
1289
+ const errors = [];
1290
+ for (const item of [...snapshots.resourceSnapshots].reverse()) {
1291
+ try { await restoreResourceSnapshot(agentDir, item.target, item.targetPath, item.kind ?? 'directory', item.snapshot); }
1292
+ catch (error) { errors.push(`${item.target}: ${error.message}`); }
1293
+ }
1294
+ for (const item of [...snapshots.settingsSnapshots].reverse()) {
1295
+ try {
1296
+ await ensureTargetParents(agentDir, item.target);
1297
+ await restoreRawFile(item.file, { exists: item.exists, ...(item.exists ? { content: Buffer.from(item.text), mode: item.mode } : {}) });
1298
+ } catch (error) { errors.push(`${item.target}: ${error.message}`); }
1299
+ }
1300
+ return errors;
1301
+ }
1302
+
1303
+ async function obtainFirstInstallApproval(lock, pins, explicitlyApproved) {
1304
+ for (const item of lock.packages) {
1305
+ if (Object.hasOwn(pins.packages, packageIdentity(item))) continue;
1306
+ if (explicitlyApproved) continue;
1307
+ const prompt = approvalPrompt(item);
1308
+ if (!prompt) {
1309
+ const error = new Error(`First-time installation requires explicit approval for ${packageIdentity(item)}; rerun with --approve`);
1310
+ error.errorCode = 'APPROVAL_REQUIRED';
1311
+ throw error;
1312
+ }
1313
+ if (!await prompt) {
1314
+ const error = new Error(`First-time installation approval was not granted for ${packageIdentity(item)}`);
1315
+ error.errorCode = 'APPROVAL_REQUIRED';
1316
+ throw error;
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ function approvalPrompt(item) {
1322
+ if (!process.stdin.isTTY) return null;
1323
+ return new Promise((resolve) => {
1324
+ process.stdout.write(`Install pinned Pi package ${item.type === 'npm' ? `npm:${item.name}@${item.version}` : `github:${item.repository}#${item.commit}`}? [y/N] `);
1325
+ process.stdin.resume();
1326
+ process.stdin.once('data', (data) => {
1327
+ process.stdin.pause();
1328
+ resolve(/^y(?:es)?\s*$/i.test(String(data).trim()));
1329
+ });
1330
+ });
1331
+ }
1332
+
1333
+ function runCommand(executable, args, env = process.env) {
1334
+ return new Promise((resolve) => {
1335
+ const child = spawn(executable, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
1336
+ let stdout = '';
1337
+ let stderr = '';
1338
+ child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
1339
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
1340
+ child.on('error', (error) => resolve({ status: null, stdout, stderr, error }));
1341
+ child.on('close', (status) => resolve({ status, stdout, stderr, error: null }));
1342
+ });
1343
+ }
1344
+
1345
+ function validateBootstrapUrl(value) {
1346
+ if (typeof value !== 'string' || value.length === 0 || /[\r\n]/.test(value)) throw new Error('--repo-url must be a GitHub URL');
1347
+ const scp = /^git@github\.com:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(value);
1348
+ if (scp) return { value, githubSlug: scp[1] };
1349
+ let parsed;
1350
+ try { parsed = new URL(value); } catch { throw new Error('--repo-url must be an HTTPS, SSH, or local file Git URL'); }
1351
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error('--repo-url must not contain credentials, query parameters, or fragments');
1352
+ if (parsed.protocol === 'file:') return { value, githubSlug: null };
1353
+ if (!['https:', 'ssh:'].includes(parsed.protocol) || parsed.hostname.toLowerCase() !== 'github.com') {
1354
+ throw new Error('--repo-url must use github.com (or a local file URL for testing)');
1355
+ }
1356
+ const slug = parsed.pathname.replace(/^\//, '').replace(/\.git$/, '');
1357
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug)) throw new Error('--repo-url must identify a GitHub owner/repository');
1358
+ return { value, githubSlug: slug };
1359
+ }
1360
+
1361
+ function bootstrapResponse(ok, errorCode, error, extra = {}) {
1362
+ return { apiVersion: 1, command: 'bootstrap', ok, errorCode, error, exitCode: ok ? 0 : OPERATION_ERROR, ...extra };
1363
+ }
1364
+
1365
+ async function installBundledSkill(repo) {
1366
+ const manifestPath = path.join(repo, MANIFEST_FILE);
1367
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
1368
+ const manifest = JSON.parse(manifestText);
1369
+ const declarations = await validateRepository(repo);
1370
+ const source = 'resources/skills/pi-sync';
1371
+ const target = 'skills/pi-sync';
1372
+ const exact = declarations.resources.some((item) => item.source === source && item.target === target);
1373
+ if (exact) return { ok: true, added: false };
1374
+
1375
+ const collision = declarations.resources.find((item) => pathsOverlap(item.target, target) || pathsOverlap(item.source, source));
1376
+ if (collision) return { ok: false, collision: { source: collision.source, target: collision.target } };
1377
+ const sourcePath = path.join(repo, ...source.split('/'));
1378
+ const existingSource = await fs.lstat(sourcePath).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
1379
+ if (existingSource) return { ok: false, collision: { source, target, reason: 'setup repository source path already exists and is not declared as this resource' } };
1380
+
1381
+ const bundled = await fs.lstat(BUNDLED_SKILL).catch((error) => { throw new Error(`Bundled pi-sync skill is unavailable: ${error.message}`); });
1382
+ if (bundled.isSymbolicLink() || !bundled.isDirectory()) throw new Error('Bundled pi-sync skill is not a real directory');
1383
+ await ensureRealDirectory(repo, 'resources/skills', true);
1384
+ await fs.mkdir(sourcePath);
1385
+ try {
1386
+ for (const entry of await fs.readdir(BUNDLED_SKILL)) {
1387
+ await fs.cp(path.join(BUNDLED_SKILL, entry), path.join(sourcePath, entry), { recursive: true, errorOnExist: true, force: false });
1388
+ }
1389
+ const nextManifest = { ...manifest, resources: [...manifest.resources, { type: 'skill', source, target }] };
1390
+ const temporary = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1391
+ try {
1392
+ const info = await fs.stat(manifestPath);
1393
+ await fs.writeFile(temporary, `${JSON.stringify(nextManifest, null, 2)}\n`, { flag: 'wx', mode: info.mode & 0o777 });
1394
+ if (await fs.readFile(manifestPath, 'utf8') !== manifestText) throw new Error('Setup manifest changed during bootstrap; refusing to overwrite concurrent changes');
1395
+ await fs.rename(temporary, manifestPath);
1396
+ } catch (error) {
1397
+ await fs.rm(temporary, { force: true }).catch(() => {});
1398
+ throw error;
1399
+ }
1400
+ } catch (error) {
1401
+ await fs.rm(sourcePath, { recursive: true, force: true }).catch(() => {});
1402
+ throw error;
1403
+ }
1404
+ await validateRepository(repo);
1405
+ return { ok: true, added: true };
1406
+ }
1407
+
1408
+ async function runPiSync(command, repo, agentDir) {
1409
+ const result = await runCommand(process.execPath, [fileURLToPath(import.meta.url), command, '--repo', repo, '--agent-dir', agentDir]);
1410
+ let output;
1411
+ try { output = JSON.parse(result.stdout); } catch {
1412
+ return { status: result.status ?? 1, output: { apiVersion: 1, command, ok: false, errorCode: 'CLI_FAILED',
1413
+ error: 'Pi Sync command returned no valid JSON output', exitCode: OPERATION_ERROR } };
1414
+ }
1415
+ return { status: result.status ?? output.exitCode ?? 1, output };
1416
+ }
1417
+
1418
+ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
1419
+ const remote = validateBootstrapUrl(repoUrl);
1420
+ const destination = path.resolve(repoDir);
1421
+ const existing = await fs.lstat(destination).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
1422
+ if (existing) return bootstrapResponse(false, 'REPOSITORY_PATH_COLLISION', 'Setup repository destination already exists; choose an empty path', { repository: destination });
1423
+ const access = await runCommand('git', ['ls-remote', remote.value]);
1424
+ let authenticationMethod = 'git';
1425
+ const bin = path.resolve(binDir ?? path.join(os.homedir(), '.local', 'bin'));
1426
+ if (access.status !== 0) {
1427
+ let ghAuthenticated = false;
1428
+ let ghAvailable = false;
1429
+ let prerequisiteResult = null;
1430
+ if (remote.githubSlug) {
1431
+ prerequisiteResult = await inspectRequiredTools(['gh'], { binDir: bin });
1432
+ ghAvailable = prerequisiteResult.ok;
1433
+ if (!ghAvailable) return bootstrapResponse(false, 'PREREQUISITE_INSTALL_REQUIRED',
1434
+ 'GitHub CLI is required for this authentication handoff but could not be installed automatically.', {
1435
+ repository: destination, prerequisites: prerequisiteResult.tools,
1436
+ missingPrerequisites: prerequisiteResult.tools.filter((tool) => tool.status === 'failed'),
1437
+ });
1438
+ const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}` };
1439
+ const status = await runCommand('gh', ['auth', 'status', '--hostname', 'github.com'], env);
1440
+ ghAuthenticated = status.status === 0;
1441
+ }
1442
+ if (!ghAuthenticated) {
1443
+ const resumeArgs = ['bootstrap', '--repo-url', remote.value, '--repo-dir', destination, '--agent-dir', path.resolve(agentDir), '--bin-dir', bin];
1444
+ const ghTool = prerequisiteResult?.tools.find((tool) => tool.name === 'gh');
1445
+ const ghExecutable = ghTool?.installPath ?? 'gh';
1446
+ const authArgs = [ghExecutable, 'auth', 'login', '--hostname', 'github.com'];
1447
+ return bootstrapResponse(false, 'AUTH_REQUIRED', 'Private setup repository access is unavailable. Complete the GitHub authentication handoff, then resume bootstrap.', {
1448
+ repository: destination,
1449
+ prerequisites: prerequisiteResult?.tools ?? [],
1450
+ authentication: {
1451
+ required: true,
1452
+ cliAvailable: ghAvailable,
1453
+ command: authArgs.join(' '),
1454
+ commandArgs: authArgs,
1455
+ credentialHandling: 'Pi Sync does not request, print, or store credentials',
1456
+ },
1457
+ resumeArgs,
1458
+ });
1459
+ }
1460
+ authenticationMethod = 'github-cli';
1461
+ }
1462
+ const parent = path.dirname(destination);
1463
+ await fs.mkdir(parent, { recursive: true });
1464
+ const stagingRoot = await fs.mkdtemp(path.join(parent, '.pi-sync-bootstrap-'));
1465
+ const checkout = path.join(stagingRoot, 'checkout');
1466
+ try {
1467
+ const cloneEnv = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}` };
1468
+ const clone = authenticationMethod === 'git'
1469
+ ? await runCommand('git', ['clone', '--quiet', '--', remote.value, checkout], cloneEnv)
1470
+ : await runCommand('gh', ['repo', 'clone', remote.githubSlug, checkout], cloneEnv);
1471
+ if (clone.status !== 0) return bootstrapResponse(false, 'FETCH_FAILED',
1472
+ authenticationMethod === 'git' ? 'GitHub access was detected but cloning the setup repository failed' : 'GitHub CLI authentication was detected but it could not fetch the setup repository',
1473
+ { repository: destination });
1474
+
1475
+ const setup = await validateRepository(checkout);
1476
+ const prerequisites = await inspectRequiredTools(setup.requiredTools, { binDir: bin });
1477
+ if (!prerequisites.ok) return bootstrapResponse(false, 'PREREQUISITE_INSTALL_REQUIRED',
1478
+ 'One or more required setup tools are unavailable. Resolve the listed handoff before applying the setup.', {
1479
+ repository: destination, prerequisites: prerequisites.tools,
1480
+ missingPrerequisites: prerequisites.tools.filter((tool) => tool.status === 'failed'),
1481
+ });
1482
+
1483
+ const declaration = await installBundledSkill(checkout);
1484
+ if (!declaration.ok) return bootstrapResponse(false, 'RESOURCE_COLLISION', 'The bundled pi-sync skill conflicts with existing setup repository content; no installation was performed', {
1485
+ repository: destination, collision: declaration.collision, skill: { source: 'resources/skills/pi-sync', target: 'skills/pi-sync' },
1486
+ });
1487
+
1488
+ await validateRepository(checkout);
1489
+ const plan = await runPiSync('plan', checkout, path.resolve(agentDir));
1490
+ if (plan.status !== 0 || !plan.output.ok) {
1491
+ return bootstrapResponse(false, 'APPLY_CONFLICT', 'Bootstrap plan found a collision or conflict; existing local content was left untouched', {
1492
+ repository: destination, skill: { source: 'resources/skills/pi-sync', target: 'skills/pi-sync' }, plan: plan.output,
1493
+ });
1494
+ }
1495
+ if (await fs.lstat(destination).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error))) {
1496
+ return bootstrapResponse(false, 'REPOSITORY_PATH_COLLISION', 'Setup repository destination appeared during bootstrap; no content was replaced', { repository: destination });
1497
+ }
1498
+ await fs.rename(checkout, destination);
1499
+ const applied = await runPiSync('apply', destination, path.resolve(agentDir));
1500
+ return bootstrapResponse(applied.status === 0 && applied.output.ok, applied.status === 0 && applied.output.ok ? undefined : 'APPLY_CONFLICT',
1501
+ applied.status === 0 && applied.output.ok ? undefined : 'Bootstrap apply did not complete cleanly; inspect the structured apply result and resume after resolving it', {
1502
+ repository: destination, authenticationMethod, prerequisites: prerequisites.tools,
1503
+ skill: { source: 'resources/skills/pi-sync', target: 'skills/pi-sync', action: declaration.added ? 'declared-and-applied' : 'applied' },
1504
+ apply: applied.output,
1505
+ });
1506
+ } finally {
1507
+ await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {});
1508
+ }
1509
+ }
1510
+
1511
+ function gitFailure(message, details = {}) {
1512
+ const error = new Error(message);
1513
+ error.errorCode = 'PUSH_BLOCKED';
1514
+ error.details = { message, ...details };
1515
+ return error;
1516
+ }
1517
+
1518
+ async function gitResult(repo, args, extraEnv = {}) {
1519
+ const result = await runCommand('git', ['-C', repo, ...args], { ...process.env, ...extraEnv });
1520
+ if (result.status !== 0) return { ...result, output: result.stdout.trim(), errorText: result.stderr.trim() };
1521
+ return { ...result, output: result.stdout.trim(), errorText: result.stderr.trim() };
1522
+ }
1523
+
1524
+ async function requireGit(repo, args, message, extraEnv = {}) {
1525
+ const result = await gitResult(repo, args, extraEnv);
1526
+ if (result.status !== 0) throw gitFailure(message, { gitStatus: result.status });
1527
+ return result.output;
1528
+ }
1529
+
1530
+ function managedGitPath(relative, resourceSources) {
1531
+ return relative === MANIFEST_FILE || relative === LOCK_FILE || resourceSources.some((source) => relative === source || relative.startsWith(`${source}/`));
1532
+ }
1533
+
1534
+ // Returns the resource sources declared in the setup manifest at the given
1535
+ // commit, or an empty list when that commit predates the manifest. Malformed
1536
+ // manifests at ahead commits fail closed so they are reconciled manually.
1537
+ async function managedSourcesAtCommit(repo, ref) {
1538
+ const listed = await gitResult(repo, ['ls-tree', ref, '--', MANIFEST_FILE]);
1539
+ if (listed.status !== 0) throw gitFailure('Could not inspect the setup manifest at an ahead commit; push stopped safely', { ref, gitStatus: listed.status });
1540
+ if (!listed.output) return [];
1541
+ const shown = await gitResult(repo, ['show', `${ref}:${MANIFEST_FILE}`]);
1542
+ if (shown.status !== 0) throw gitFailure('Could not read the setup manifest at an ahead commit; push stopped safely', { ref, gitStatus: shown.status });
1543
+ let manifest;
1544
+ try { manifest = JSON.parse(shown.output); } catch {
1545
+ throw gitFailure('The setup manifest at an ahead commit is not valid JSON; reconcile the branch manually before pushing', { ref });
1546
+ }
1547
+ if (!isRecord(manifest) || !Array.isArray(manifest.resources)) {
1548
+ throw gitFailure('The setup manifest at an ahead commit is malformed; reconcile the branch manually before pushing', { ref });
1549
+ }
1550
+ return manifest.resources.filter((item) => isRecord(item) && typeof item.source === 'string').map((item) => item.source);
1551
+ }
1552
+
1553
+ function parseNameStatus(output) {
1554
+ const fields = output.split('\0');
1555
+ if (fields.at(-1) === '') fields.pop();
1556
+ const changes = [];
1557
+ for (let i = 0; i < fields.length; i += 2) changes.push({ status: fields[i], path: fields[i + 1] });
1558
+ return changes;
1559
+ }
1560
+
1561
+ // Validates the repository state as of a single commit: the manifest and
1562
+ // lockfile at that commit must form a valid, applyable setup repository and
1563
+ // every declared resource source must exist in that commit's tree with the
1564
+ // correct file/directory shape. This makes an intermediate ahead commit that
1565
+ // leaves the repository broken (for example, managed content deleted without
1566
+ // a matching manifest update) unpublishable even when a later commit repairs
1567
+ // the state and HEAD itself validates. Commits that predate the manifest are
1568
+ // accepted, mirroring managedSourcesAtCommit.
1569
+ async function validateAheadCommitTree(repo, commit) {
1570
+ // -t includes directory entries: without it the recursive listing contains
1571
+ // only blobs, so a valid directory resource source would look missing.
1572
+ const treeResult = await gitResult(repo, ['ls-tree', '-r', '-t', '-z', commit]);
1573
+ if (treeResult.status !== 0) throw gitFailure('Could not inspect the tree of a local commit ahead of upstream; push stopped safely', { commit, gitStatus: treeResult.status });
1574
+ const tree = new Map();
1575
+ for (const entry of treeResult.stdout.split('\0').filter(Boolean)) {
1576
+ const tab = entry.indexOf('\t');
1577
+ if (tab < 0) continue;
1578
+ const meta = entry.slice(0, tab).split(' ');
1579
+ tree.set(entry.slice(tab + 1), meta[0]);
1580
+ }
1581
+ if (!tree.has(MANIFEST_FILE)) return;
1582
+ if (!tree.has(LOCK_FILE)) throw gitFailure('The setup lockfile is missing at an ahead commit; reconcile the branch manually before pushing', { commit });
1583
+ const manifestText = (await gitResult(repo, ['show', `${commit}:${MANIFEST_FILE}`])).output;
1584
+ let manifest;
1585
+ try { manifest = JSON.parse(manifestText); } catch {
1586
+ throw gitFailure('The setup manifest at an ahead commit is not valid JSON; reconcile the branch manually before pushing', { commit });
1587
+ }
1588
+ if (!isRecord(manifest) || manifest.schemaVersion !== 1 || !Array.isArray(manifest.resources) || !Array.isArray(manifest.dependencies)) {
1589
+ throw gitFailure('The setup manifest at an ahead commit is invalid; reconcile the branch manually before pushing', { commit });
1590
+ }
1591
+ for (const [index, item] of manifest.resources.entries()) {
1592
+ if (!isRecord(item) || typeof item.type !== 'string' || typeof item.source !== 'string' || typeof item.target !== 'string') {
1593
+ throw gitFailure(`The setup manifest at an ahead commit has an invalid resource entry (resources[${index}]); reconcile the branch manually before pushing`, { commit });
1594
+ }
1595
+ const rule = RESOURCE_RULES[item.type];
1596
+ if (!rule) throw gitFailure(`The setup manifest at an ahead commit declares an unsupported resource type: ${item.type}`, { commit });
1597
+ const mode = tree.get(item.source);
1598
+ const isBlob = mode !== undefined && mode.startsWith('100');
1599
+ // A directory exists when the tree lists a directory entry at the exact
1600
+ // path, or when at least one blob lives under the path prefix.
1601
+ const isDirectory = mode === '040000' || [...tree.keys()].some((key) => key.startsWith(`${item.source}/`));
1602
+ if (!isBlob && !isDirectory) {
1603
+ throw gitFailure(`The setup manifest at an ahead commit declares a resource source that is missing from that commit's tree: ${item.source}`, { commit });
1604
+ }
1605
+ if (rule.shape === 'file' && !isBlob) throw gitFailure(`The resource source at an ahead commit must be a file: ${item.source}`, { commit });
1606
+ if (rule.shape === 'directory' && !isDirectory) throw gitFailure(`The resource source at an ahead commit must be a directory: ${item.source}`, { commit });
1607
+ }
1608
+ const lockText = (await gitResult(repo, ['show', `${commit}:${LOCK_FILE}`])).output;
1609
+ let lock;
1610
+ try { lock = JSON.parse(lockText); } catch {
1611
+ throw gitFailure('The setup lockfile at an ahead commit is not valid JSON; reconcile the branch manually before pushing', { commit });
1612
+ }
1613
+ if (!isRecord(lock) || lock.schemaVersion !== 1 || !Array.isArray(lock.packages)) {
1614
+ throw gitFailure('The setup lockfile at an ahead commit is invalid; reconcile the branch manually before pushing', { commit });
1615
+ }
1616
+ const dependencyIdentity = (item) => isRecord(item) && item.type === 'npm' && typeof item.name === 'string' ? `npm:${item.name}`
1617
+ : isRecord(item) && item.type === 'github' && typeof item.repository === 'string' ? `github:${item.repository}` : null;
1618
+ const declared = new Set();
1619
+ for (const [index, dep] of manifest.dependencies.entries()) {
1620
+ const identity = dependencyIdentity(dep);
1621
+ if (!identity || declared.has(identity)) {
1622
+ throw gitFailure(`The setup manifest at an ahead commit has an invalid or duplicate dependency (dependencies[${index}]); reconcile the branch manually before pushing`, { commit });
1623
+ }
1624
+ declared.add(identity);
1625
+ }
1626
+ const locked = new Set();
1627
+ for (const [index, item] of lock.packages.entries()) {
1628
+ const identity = dependencyIdentity(item);
1629
+ if (!identity || !declared.has(identity) || locked.has(identity)) {
1630
+ throw gitFailure(`The setup lockfile at an ahead commit does not match the declared dependencies (packages[${index}]); reconcile the branch manually before pushing`, { commit });
1631
+ }
1632
+ locked.add(identity);
1633
+ }
1634
+ const missing = [...declared].filter((key) => !locked.has(key));
1635
+ if (missing.length) throw gitFailure(`The setup lockfile at an ahead commit is missing exact pins for declared dependencies: ${missing.join(', ')}`, { commit });
1636
+ }
1637
+
1638
+ async function managedPush(repo, declarations, publish = false) {
1639
+ const resourceSources = declarations.resources.map((item) => item.source);
1640
+ for (const managedFile of [MANIFEST_FILE, LOCK_FILE]) {
1641
+ const bytes = await fs.readFile(path.join(repo, managedFile));
1642
+ if (!bytes.includes(0)) {
1643
+ const findings = findLikelySecretsInText(bytes.toString('utf8'), managedFile);
1644
+ if (findings.length) throw gitFailure('Managed setup metadata contains suspected credential material; review and remove it before push', { reviewRequired: findings });
1645
+ }
1646
+ }
1647
+ for (const resource of declarations.resources) {
1648
+ const findings = await findLikelySecrets(resource.sourcePath, resource.source);
1649
+ if (findings.length) throw gitFailure('Managed resources contain suspected credential material; review and remove it before push', { reviewRequired: findings });
1650
+ }
1651
+
1652
+ const top = await requireGit(repo, ['rev-parse', '--show-toplevel'], 'Push requires a Git working tree');
1653
+ if (path.resolve(top) !== path.resolve(repo)) throw gitFailure('The setup repository path must be the root of its Git working tree', { repository: repo, gitRoot: top });
1654
+ const branch = await requireGit(repo, ['symbolic-ref', '--quiet', '--short', 'HEAD'], 'Push requires a checked-out branch (detached HEAD is not supported)');
1655
+ const upstream = await requireGit(repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], 'Push requires a configured upstream branch');
1656
+ const slash = upstream.indexOf('/');
1657
+ if (slash < 1 || slash === upstream.length - 1) throw gitFailure('The configured upstream branch could not be resolved', { upstream });
1658
+ const remote = upstream.slice(0, slash);
1659
+
1660
+ const fetched = await gitResult(repo, ['fetch', '--quiet', remote]);
1661
+ if (fetched.status !== 0) throw gitFailure('Could not check the upstream branch; no commit or push was attempted', { upstream, gitStatus: fetched.status });
1662
+ const counts = await requireGit(repo, ['rev-list', '--left-right', '--count', `HEAD...${upstream}`], 'Could not compare the local branch with its upstream');
1663
+ const [ahead, behind] = counts.split(/\s+/).map(Number);
1664
+ if (!Number.isInteger(ahead) || !Number.isInteger(behind)) throw gitFailure('Git returned an invalid upstream comparison', { upstream });
1665
+ if (behind > 0) throw gitFailure('The upstream has advanced; reconcile the remote changes before pushing (Pi Sync will not merge or rewrite history)', { upstream, ahead, behind });
1666
+
1667
+ const allowed = (relative) => managedGitPath(relative, resourceSources);
1668
+ let existingAheadChanges = [];
1669
+ if (ahead > 0) {
1670
+ const commits = (await requireGit(repo, ['rev-list', '--reverse', `${upstream}..HEAD`], 'Could not inspect local commits ahead of upstream')).split(/\r?\n/).filter(Boolean);
1671
+ for (const commit of commits) {
1672
+ const parentFields = (await requireGit(repo, ['rev-list', '--parents', '-n', '1', commit], 'Could not inspect a local commit ahead of upstream')).trim().split(/\s+/);
1673
+ if (parentFields.length > 2) throw gitFailure('Merge commits ahead of upstream require manual reconciliation before pushing', { upstream, ahead, commit });
1674
+ const output = await requireGit(repo, ['diff-tree', '--root', '--no-commit-id', '--name-only', '--no-renames', '-r', '-z', commit], 'Could not inspect a local commit ahead of upstream');
1675
+ const paths = output.split('\0').filter(Boolean);
1676
+ if (!paths.length) throw gitFailure('An empty local commit is ahead of upstream; reconcile the branch before pushing', { upstream, ahead, commit });
1677
+ // Validate the commit against the manifest as of that commit, the
1678
+ // manifest before the commit (its parent), and the current managed set.
1679
+ // Together these cover commits that add, edit, or remove managed paths,
1680
+ // including removal commits whose deleted paths the current manifest no
1681
+ // longer manages, while genuinely unrelated paths still stop the push.
1682
+ const commitSources = await managedSourcesAtCommit(repo, commit);
1683
+ const parentSources = parentFields.length > 1 ? await managedSourcesAtCommit(repo, parentFields[1]) : [];
1684
+ const commitAllowed = (relative) => managedGitPath(relative, [...new Set([...resourceSources, ...commitSources, ...parentSources])]);
1685
+ const unrelated = paths.filter((item) => !commitAllowed(item));
1686
+ if (unrelated.length) throw gitFailure('The branch contains unrelated local commits; reconcile them before pushing', { upstream, ahead, commit, unrelatedPaths: unrelated });
1687
+ // The commit itself must be a valid repository state, not just a set of
1688
+ // managed paths: an intermediate commit that leaves the repository broken
1689
+ // (for example, managed content deleted without a manifest update) is
1690
+ // rejected even when a later commit repairs it.
1691
+ await validateAheadCommitTree(repo, commit);
1692
+ for (const item of paths) {
1693
+ const ignored = await gitResult(repo, ['check-ignore', '--no-index', '--quiet', '--', path.resolve(repo, ...item.split('/'))]);
1694
+ if (ignored.status === 0) throw gitFailure('A local commit contains an ignored path; reconcile it manually before pushing', { upstream, path: item, commit });
1695
+ if (ignored.status !== 1) throw gitFailure('Could not determine whether an ahead-commit path is ignored; push stopped safely', { path: item, commit });
1696
+ }
1697
+ existingAheadChanges.push(...paths.map((item) => ({ status: 'committed', path: item, commit })));
1698
+ }
1699
+ }
1700
+
1701
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pi-sync-push-index-'));
1702
+ const indexFile = path.join(tempDir, 'index');
1703
+ const hooksPath = path.join(tempDir, 'hooks');
1704
+ const env = { GIT_INDEX_FILE: indexFile };
1705
+ try {
1706
+ await fs.mkdir(hooksPath);
1707
+ await requireGit(repo, ['read-tree', 'HEAD'], 'Could not prepare an isolated Git index', env);
1708
+ // A removal is published as a deletion of a previously managed path: the
1709
+ // current manifest no longer lists the source, but HEAD still does. Track
1710
+ // those sources separately so deletions of them can be staged, while
1711
+ // untracked content is still only accepted for currently managed paths.
1712
+ const headSources = await managedSourcesAtCommit(repo, 'HEAD');
1713
+ const removedSources = headSources.filter((source) => !resourceSources.includes(source));
1714
+ const removedSourcePath = (relative) => removedSources.some((source) => relative === source || relative.startsWith(`${source}/`));
1715
+ const pathspecs = [MANIFEST_FILE, LOCK_FILE, ...resourceSources, ...removedSources].map((item) => `:(top,literal)${item}`);
1716
+ const currentPathspecs = [MANIFEST_FILE, LOCK_FILE, ...resourceSources].map((item) => `:(top,literal)${item}`);
1717
+ const tracked = await requireGit(repo, ['ls-files', '--cached', '-z', '--', ...pathspecs], 'Could not enumerate managed files', env);
1718
+ const untracked = await requireGit(repo, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...currentPathspecs], 'Could not enumerate managed files', env);
1719
+ const candidates = [...new Set([...tracked.split('\0'), ...untracked.split('\0')].filter(Boolean))];
1720
+ const stage = [];
1721
+ for (const item of candidates) {
1722
+ if (!allowed(item) && !removedSourcePath(item)) continue;
1723
+ const ignored = await gitResult(repo, ['check-ignore', '--no-index', '--quiet', '--', path.resolve(repo, ...item.split('/'))]);
1724
+ if (ignored.status === 0) continue;
1725
+ if (ignored.status !== 1) throw gitFailure('Could not determine whether a managed file is ignored; push stopped safely', { path: item });
1726
+ stage.push(item);
1727
+ }
1728
+ for (const item of stage) await requireGit(repo, ['add', '--all', '--', `:(top,literal)${item}`], `Could not stage managed path: ${item}`, env);
1729
+ const staged = await requireGit(repo, ['diff', '--cached', '--name-status', '--no-renames', '-z'], 'Could not preview the isolated commit', env);
1730
+ let changes = parseNameStatus(staged);
1731
+ // Previously managed paths are publishable only as deletions; re-adding
1732
+ // content under a removed source without re-declaring it is not.
1733
+ const stagedAllowed = (item) => allowed(item.path) || (item.status === 'D' && removedSourcePath(item.path));
1734
+ const unrelated = changes.filter((item) => !stagedAllowed(item)).map((item) => item.path);
1735
+ if (unrelated.length) throw gitFailure('The isolated commit contains a path outside the managed allowlist', { unrelatedPaths: unrelated });
1736
+ changes = [...existingAheadChanges, ...changes];
1737
+
1738
+ const cachedDiff = await gitResult(repo, ['diff', '--cached', '--quiet'], env);
1739
+ if (cachedDiff.status !== 0 && cachedDiff.status !== 1) throw gitFailure('Could not inspect staged managed changes', { gitStatus: cachedDiff.status });
1740
+ const commitRequired = cachedDiff.status === 1;
1741
+ const diffResult = await gitResult(repo, ['diff', '--cached', '--no-ext-diff', '--binary', upstream], env);
1742
+ if (diffResult.status !== 0) throw gitFailure('Could not render the managed-only commit preview', { gitStatus: diffResult.status });
1743
+ const patch = diffResult.stdout;
1744
+ const patchFindings = findLikelySecretsInText(patch, 'staged managed patch');
1745
+ if (patchFindings.length) throw gitFailure('Publishable managed changes contain suspected credential material; review and remove it before push', { reviewRequired: patchFindings });
1746
+ if (!publish) return { apiVersion: 1, command: 'push', ok: true, exitCode: 0, repository: repo, branch, upstream,
1747
+ mode: 'preview', changes, patch, commitRequired, publicationAuthorized: false,
1748
+ note: 'Only these managed paths can be committed; rerun with --publish to authorize commit and push.' };
1749
+ if (changes.length) process.stderr.write(`Pi Sync managed commit preview (before publication):\n${patch || '(no new working-tree diff; publishing already-committed managed changes)'}\n`);
1750
+
1751
+ if (commitRequired) {
1752
+ const committedPaths = changes.filter((item) => item.status !== 'committed').map((item) => item.path);
1753
+ const commitResult = await gitResult(repo, ['-c', `core.hooksPath=${hooksPath}`, 'commit', '-m', 'pi-sync: publish managed setup'], env);
1754
+ if (commitResult.status !== 0) throw gitFailure('Could not create the managed-only commit; working-tree and index changes were preserved', { changes, gitStatus: commitResult.status });
1755
+ // The alternate index isolates unrelated staging. Rebase only the committed paths
1756
+ // in the user's index onto the new HEAD, leaving every unrelated staged change intact.
1757
+ for (const item of committedPaths) {
1758
+ const reset = await gitResult(repo, ['reset', '--quiet', 'HEAD', '--', `:(top,literal)${item}`]);
1759
+ if (reset.status !== 0) throw gitFailure('Managed commit was created, but the normal Git index could not be refreshed; no local changes were discarded', { changes, gitStatus: reset.status });
1760
+ }
1761
+ }
1762
+ if (!changes.length) return { apiVersion: 1, command: 'push', ok: true, exitCode: 0, repository: repo, branch, upstream,
1763
+ mode: 'published', changes: [], commit: null, note: 'No managed changes or managed-only commits were available to push.' };
1764
+
1765
+ const pushed = await gitResult(repo, ['push', '--porcelain', remote, `HEAD:${upstream.slice(slash + 1)}`]);
1766
+ if (pushed.status !== 0) throw gitFailure('The ordinary fast-forward push was rejected; local managed changes and commits were preserved', {
1767
+ upstream, changes, gitStatus: pushed.status,
1768
+ });
1769
+ const commit = await requireGit(repo, ['rev-parse', 'HEAD'], 'Push succeeded but the local commit could not be identified');
1770
+ return { apiVersion: 1, command: 'push', ok: true, exitCode: 0, repository: repo, branch, upstream,
1771
+ mode: 'published', changes, patch, commit, forcePush: false };
1772
+ } finally {
1773
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
1774
+ }
1775
+ }
1776
+
1777
+ async function main() {
1778
+ const command = process.argv[2];
1779
+ if (!['status', 'plan', 'apply', 'add', 'remove', 'prune', 'bootstrap', 'update', 'push'].includes(command)) {
1780
+ throw new Error('Usage: pi-sync <status|plan|apply|add|remove|prune|update|push|bootstrap> [--repo PATH] [--repo-url URL --repo-dir PATH] [--agent-dir PATH] [--type skill|extension|prompt|agent|role|dependency --target VALUE | --source VALUE] [--confirm] [--approve] [--apply] [--push] [--publish] [--uninstall]');
1781
+ }
1782
+ const options = { repo: process.cwd(), repoDir: path.join(os.homedir(), '.pi-sync', 'setup'), agentDir: process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), '.pi', 'agent'), binDir: path.join(os.homedir(), '.local', 'bin'), confirm: false, approve: false, applyUpdate: false, pushUpdate: false, publish: false, uninstall: false };
1783
+ for (let i = 3; i < process.argv.length; i++) {
1784
+ const arg = process.argv[i];
1785
+ if (['--repo', '--repo-url', '--repo-dir', '--agent-dir', '--bin-dir', '--type', '--source', '--target'].includes(arg)) {
1786
+ const value = process.argv[++i];
1787
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
1788
+ if (arg === '--repo') options.repo = value;
1789
+ else if (arg === '--repo-url') options.repoUrl = value;
1790
+ else if (arg === '--repo-dir') options.repoDir = value;
1791
+ else if (arg === '--agent-dir') options.agentDir = value;
1792
+ else if (arg === '--bin-dir') options.binDir = value;
1793
+ else if (arg === '--type') options.type = value;
1794
+ else if (arg === '--target') options.target = value;
1795
+ else options.source = value;
1796
+ } else if (arg === '--confirm') options.confirm = true;
1797
+ else if (arg === '--approve') options.approve = true;
1798
+ else if (arg === '--apply') options.applyUpdate = true;
1799
+ else if (arg === '--push') options.pushUpdate = true;
1800
+ else if (arg === '--publish') options.publish = true;
1801
+ else if (arg === '--uninstall') options.uninstall = true;
1802
+ else throw new Error(`Unknown option: ${arg}`);
1803
+ }
1804
+ if (options.confirm && command !== 'prune') throw new Error('--confirm is only supported by prune');
1805
+ if (options.approve && !['apply', 'update'].includes(command)) throw new Error('--approve is only supported by apply or update');
1806
+ if (options.applyUpdate && command !== 'update') throw new Error('--apply is only supported by update');
1807
+ if (options.pushUpdate && command !== 'update') throw new Error('--push is only supported by update');
1808
+ if (options.pushUpdate && !options.applyUpdate) throw new Error('update --push requires --apply so package updates are explicitly approved');
1809
+ if (options.publish && command !== 'push') throw new Error('--publish is only supported by push');
1810
+ if (command === 'add' && (!options.type || !options.source)) throw new Error('add requires explicit --type and --source options');
1811
+ if (command === 'add' && !['skill', 'extension', 'prompt', 'agent', 'role', 'dependency'].includes(options.type)) throw new Error(`Unsupported resource type: ${options.type}`);
1812
+ if (command === 'remove' && !options.type) throw new Error('remove requires explicit --type and --target (or --source for dependencies)');
1813
+ if (command === 'remove' && !['skill', 'extension', 'prompt', 'agent', 'role', 'dependency'].includes(options.type)) throw new Error(`Unsupported resource type: ${options.type}`);
1814
+ if (command === 'remove' && options.type === 'dependency') {
1815
+ if (!options.source) throw new Error('remove --type dependency requires --source npm:PACKAGE or github:OWNER/REPOSITORY');
1816
+ if (options.target) throw new Error('--target is only supported for resource types; remove --type dependency uses --source');
1817
+ }
1818
+ if (command === 'remove' && options.type && options.type !== 'dependency') {
1819
+ if (!options.target) throw new Error('remove requires --target for resource types (for example, skills/NAME)');
1820
+ if (options.source) throw new Error('--source is only supported by remove --type dependency; resource removal uses --target');
1821
+ }
1822
+ if (options.uninstall && (command !== 'remove' || options.type !== 'dependency')) throw new Error('--uninstall is only supported by remove --type dependency');
1823
+ if (options.target !== undefined && !(command === 'remove' && options.type && options.type !== 'dependency')) throw new Error('--target is only supported by remove for resource types');
1824
+ if (command === 'bootstrap') {
1825
+ if (!options.repoUrl) throw new Error('bootstrap requires --repo-url');
1826
+ if (process.argv.includes('--repo')) throw new Error('bootstrap uses --repo-dir, not --repo');
1827
+ const output = await bootstrap(options.repoUrl, options.repoDir, options.agentDir, options.binDir);
1828
+ console.log(JSON.stringify(output, null, 2));
1829
+ if (!output.ok) process.exitCode = output.exitCode;
1830
+ return;
1831
+ }
1832
+
1833
+ const repo = path.resolve(options.repo);
1834
+ const agentDir = path.resolve(options.agentDir);
1835
+ // remove tolerates missing pins up front so that removing an unpinned
1836
+ // dependency reaches its dedicated REMOVE_DEPENDENCY_NOT_PINNED error
1837
+ // instead of the generic repository validation failure. removeResource
1838
+ // keeps its own strict repository validation for resource removals.
1839
+ const declarations = await validateRepository(repo, { allowMissingPins: command === 'update' || command === 'remove' });
1840
+ if (command === 'push') {
1841
+ const output = await managedPush(repo, declarations, options.publish);
1842
+ console.log(JSON.stringify(output, null, 2));
1843
+ return;
1844
+ }
1845
+ if (command === 'remove') {
1846
+ if (options.type === 'dependency') {
1847
+ const output = await removeDependency(repo, options.source, agentDir, options.uninstall);
1848
+ console.log(JSON.stringify(output, null, 2));
1849
+ if (!output.ok) process.exitCode = output.exitCode;
1850
+ return;
1851
+ }
1852
+ const output = await removeResource(repo, options.type, options.target);
1853
+ console.log(JSON.stringify(output, null, 2));
1854
+ return;
1855
+ }
1856
+ let agentInfo = await fs.lstat(agentDir).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
1857
+ if (agentInfo?.isSymbolicLink() || (agentInfo && !agentInfo.isDirectory())) throw new Error(`Pi agent directory must be a real directory: ${agentDir}`);
1858
+ if (command === 'add') {
1859
+ if (options.type === 'dependency') {
1860
+ const output = await addDependency(repo, options.source);
1861
+ console.log(JSON.stringify(output, null, 2));
1862
+ return;
1863
+ }
1864
+ if (!agentInfo) throw new Error(`Pi agent directory does not exist: ${agentDir}`);
1865
+ const output = await addResource(repo, agentDir, options.type, options.source);
1866
+ console.log(JSON.stringify(output, null, 2));
1867
+ if (output.error) process.exitCode = VALIDATION_ERROR;
1868
+ return;
1869
+ }
1870
+ if (command === 'update') {
1871
+ const manifest = await readJson(path.join(repo, MANIFEST_FILE), 'manifest');
1872
+ const updates = await discoverUpdates(manifest, declarations.lock);
1873
+ const minimumReleaseAgeDays = manifest.policy?.minimumReleaseAgeDays ?? 7;
1874
+ const nextLock = lockWithUpdates(declarations.lock, updates);
1875
+ if (!options.applyUpdate) {
1876
+ console.log(JSON.stringify({ apiVersion: 1, command, ok: true, exitCode: 0, repository: repo,
1877
+ minimumReleaseAgeDays, policySource: 'setup repository pi-sync.json', mode: 'preview', updates,
1878
+ proposedPins: nextLock.packages, note: 'No pins were changed and no package installation was performed.' }, null, 2));
1879
+ return;
1880
+ }
1881
+ if (!updates.length) {
1882
+ console.log(JSON.stringify({ apiVersion: 1, command, ok: true, exitCode: 0, repository: repo,
1883
+ minimumReleaseAgeDays, policySource: 'setup repository pi-sync.json', mode: 'applied', updates: [], pins: declarations.lock.packages,
1884
+ note: 'No eligible newer releases were found; no installs or pin changes were performed.' }, null, 2));
1885
+ return;
1886
+ }
1887
+ if (!agentInfo) { await fs.mkdir(agentDir, { recursive: true }); agentInfo = await fs.lstat(agentDir); }
1888
+ const releasePackageLock = await acquireLock(agentDir);
1889
+ try {
1890
+ const pins = await loadPackagePins(agentDir);
1891
+ await obtainFirstInstallApproval(nextLock, pins, options.approve);
1892
+ const lockPath = path.join(repo, LOCK_FILE);
1893
+ const packageStatePath = path.join(agentDir, STATE_DIR, 'packages.json');
1894
+ const lockBefore = await captureRawFile(lockPath);
1895
+ const packageStateBefore = await captureRawFile(packageStatePath);
1896
+ const installed = await installPinnedPackages(nextLock, { approved: true, previousPins: pins.packages });
1897
+ try {
1898
+ await writeLockfile(repo, nextLock);
1899
+ await savePackagePins(agentDir, nextLock);
1900
+ } catch (error) {
1901
+ const rollbackErrors = (await rollbackPinnedPackages(updates.map((update) => update.to), pins.packages))
1902
+ .map((item) => `${item.package}: ${item.error}`);
1903
+ try { await restoreRawFile(lockPath, lockBefore); }
1904
+ catch (restoreError) { rollbackErrors.push(`lockfile: ${restoreError.message}`); }
1905
+ try { await restoreRawFile(packageStatePath, packageStateBefore); }
1906
+ catch (restoreError) { rollbackErrors.push(`local package pin state: ${restoreError.message}`); }
1907
+ error.errorCode = 'UPDATE_TRANSACTION_FAILED';
1908
+ error.message = `Update package installation succeeded but pin persistence failed: ${error.message}; ${rollbackErrors.length
1909
+ ? `rollback was incomplete (${rollbackErrors.join('; ')})`
1910
+ : 'previous package pins, lockfile, and local pin state were restored'}`;
1911
+ error.details = { message: error.message, rollbackErrors };
1912
+ throw error;
1913
+ }
1914
+ const push = options.pushUpdate ? await managedPush(repo, await validateRepository(repo), true) : undefined;
1915
+ console.log(JSON.stringify({ apiVersion: 1, command, ok: true, exitCode: 0, repository: repo,
1916
+ minimumReleaseAgeDays, policySource: 'setup repository pi-sync.json', mode: 'applied', updates,
1917
+ pins: nextLock.packages, installed, ...(push ? { push } : {}), dependencyModel: 'exact top-level pins; transitive dependency tree is controlled by Pi and is not locked by Pi Sync' }, null, 2));
1918
+ } finally { await releasePackageLock(); }
1919
+ return;
1920
+ }
1921
+ if (command === 'apply' && !agentInfo) {
1922
+ await fs.mkdir(agentDir, { recursive: true });
1923
+ agentInfo = await fs.lstat(agentDir);
1924
+ if (agentInfo.isSymbolicLink() || !agentInfo.isDirectory()) throw new Error(`Pi agent directory must be a real directory: ${agentDir}`);
1925
+ }
1926
+ const state = await loadState(agentDir);
1927
+ const releaseLock = command === 'apply' || (command === 'prune' && options.confirm) ? await acquireLock(agentDir) : null;
1928
+ try {
1929
+ const currentState = releaseLock ? await loadState(agentDir) : state;
1930
+ let packagePins = null;
1931
+ if (command === 'apply' && declarations.lock.packages.length > 0) {
1932
+ packagePins = await loadPackagePins(agentDir);
1933
+ await obtainFirstInstallApproval(declarations.lock, packagePins, options.approve);
1934
+ }
1935
+ const plan = await buildPlan(repo, agentDir, declarations, currentState);
1936
+ if (command === 'prune') {
1937
+ const staleResources = await pruneStale(agentDir, currentState, plan.stale, options.confirm);
1938
+ const ok = !staleResources.some((item) => ['modified', 'retained-modified', 'retained-overlap'].includes(item.action));
1939
+ console.log(JSON.stringify({ apiVersion: 1, command, ok, exitCode: ok ? 0 : OPERATION_ERROR, repository: repo, agentDirectory: agentDir,
1940
+ mode: options.confirm ? 'confirmed' : 'preview', staleResources }, null, 2));
1941
+ if (!ok) process.exitCode = OPERATION_ERROR;
1942
+ return;
1943
+ }
1944
+ const actions = [
1945
+ ...plan.resources.map((item) => outputAction(item, 'target')),
1946
+ ...plan.settings.map((item) => ({ type: 'setting', target: item.target, action: item.action,
1947
+ fields: item.fields.map((field) => ({ path: field.pointer, action: field.action, ...(field.reason ? { reason: field.reason } : {}) })) })),
1948
+ ...plan.stale.map((item) => ({ type: 'resource', target: item.target, action: 'stale', installed: item.localSnapshot !== null,
1949
+ state: item.state, pruneAvailable: item.overlaps.length === 0, ...(item.overlaps.length ? { overlaps: item.overlaps } : {}) })),
1950
+ ];
1951
+ const conflicts = actions.filter((item) => ['collision', 'conflict'].includes(item.action) || item.fields?.some((field) => ['collision', 'conflict'].includes(field.action)));
1952
+ const staleResources = plan.stale.map(({ target, state: staleState, localSnapshot, overlaps }) => ({ target, state: staleState,
1953
+ installed: localSnapshot !== null, pruneAvailable: overlaps.length === 0, ...(overlaps.length ? { overlaps } : {}) }));
1954
+ const output = { apiVersion: 1, command, ok: conflicts.length === 0, exitCode: conflicts.length ? OPERATION_ERROR : 0,
1955
+ repository: repo, agentDirectory: agentDir, actions, conflicts, staleResources,
1956
+ packages: declarations.lock.packages.map((item) => ({ package: packageIdentity(item), pin: packageRevision(item), action: command === 'apply' ? 'pinned-install' : 'locked' })),
1957
+ dependencyModel: 'exact top-level pins; Pi controls transitive dependencies, which Pi Sync does not lock or claim reproducible' };
1958
+
1959
+ if (command === 'apply') {
1960
+ await fs.mkdir(agentDir, { recursive: true });
1961
+ const checkedAgent = await fs.lstat(agentDir);
1962
+ if (checkedAgent.isSymbolicLink() || !checkedAgent.isDirectory()) throw new Error(`Pi agent directory must be a real directory: ${agentDir}`);
1963
+ const mutableState = structuredClone(currentState);
1964
+ const changingResources = plan.resources.filter((resource) => resource.action !== 'collision' && !resource.kindMismatch &&
1965
+ (resource.action === 'install' || resource.files.some((file) => file.action === 'update' || file.action === 'delete')));
1966
+ const changingSettings = plan.settings.filter((item) => item.fields.some((field) => field.action === 'install' || field.action === 'update'));
1967
+ const snapshots = await captureManagedFilesystem(agentDir, changingResources, changingSettings);
1968
+ const packageStatePath = path.join(agentDir, STATE_DIR, 'packages.json');
1969
+ const packageStateBefore = await captureRawFile(packageStatePath);
1970
+ let packageInstallationCompleted = false;
1971
+ try {
1972
+ for (const resource of plan.resources) {
1973
+ // A kind-mismatch conflict means the tracked target is no longer the managed kind;
1974
+ // nothing may be applied or re-baselined until the user resolves it manually.
1975
+ if (resource.action === 'collision' || resource.kindMismatch) continue;
1976
+ if (resource.action === 'install' || resource.files.some((file) => file.action === 'update' || file.action === 'delete')) {
1977
+ if (resource.kind === 'file') await applyFileResource(agentDir, resource);
1978
+ else await applyDirectory(agentDir, resource);
1979
+ }
1980
+ const previous = mutableState.resources[resource.target];
1981
+ if (!previous) mutableState.resources[resource.target] = structuredClone({ kind: resource.kind, ...resource.snapshot });
1982
+ else {
1983
+ const baseline = structuredClone(previous);
1984
+ baseline.kind = resource.kind;
1985
+ baseline.files ??= {};
1986
+ baseline.directories ??= [];
1987
+ for (const file of resource.files) {
1988
+ if (file.action === 'conflict') continue;
1989
+ if (file.repository === null) delete baseline.files[file.path];
1990
+ else baseline.files[file.path] = file.repository;
1991
+ }
1992
+ baseline.directories = resource.snapshot.directories;
1993
+ mutableState.resources[resource.target] = baseline;
1994
+ }
1995
+ }
1996
+ for (const item of plan.settings) {
1997
+ if (item.fields.some((field) => field.action === 'install' || field.action === 'update')) await applySettings(agentDir, item);
1998
+ mutableState.settings[item.target] ??= {};
1999
+ for (const field of item.fields) {
2000
+ if (!['conflict', 'collision'].includes(field.action)) mutableState.settings[item.target][field.pointer] = { exists: true, value: field.value };
2001
+ }
2002
+ }
2003
+ if (declarations.lock.packages.length > 0 && conflicts.length === 0) {
2004
+ output.packageInstalls = await installPinnedPackages(declarations.lock, { approved: true, previousPins: packagePins?.packages ?? {} });
2005
+ packageInstallationCompleted = true;
2006
+ await savePackagePins(agentDir, declarations.lock);
2007
+ }
2008
+ if (plan.resources.some((resource) => resource.action !== 'collision') || plan.settings.some((item) => item.fields.some((field) => !['conflict', 'collision'].includes(field.action)))) {
2009
+ await writeState(agentDir, mutableState);
2010
+ }
2011
+ } catch (error) {
2012
+ const rollbackErrors = await restoreManagedFilesystem(agentDir, snapshots);
2013
+ if (packageInstallationCompleted) rollbackErrors.push(...(await rollbackPinnedPackages(declarations.lock.packages, packagePins?.packages ?? {})).map((item) => `${item.package}: ${item.error}`));
2014
+ try { await restoreRawFile(packageStatePath, packageStateBefore); }
2015
+ catch (restoreError) { rollbackErrors.push(`package pin state: ${restoreError.message}`); }
2016
+ if (rollbackErrors.length) {
2017
+ error.message += `; apply rollback was incomplete (${rollbackErrors.join('; ')})`;
2018
+ error.details = { ...(error.details ?? { message: error.message }), rollbackErrors };
2019
+ }
2020
+ throw error;
2021
+ }
2022
+ output.actions = output.actions.map((action) => {
2023
+ if (action.type === 'setting') {
2024
+ return { ...action,
2025
+ action: ['install', 'update'].includes(action.action) ? (action.action === 'install' ? 'installed' : 'updated') : action.action,
2026
+ fields: action.fields.map((field) => ['install', 'update'].includes(field.action)
2027
+ ? { ...field, action: field.action === 'install' ? 'installed' : 'updated' }
2028
+ : field),
2029
+ };
2030
+ }
2031
+ return ['install', 'update'].includes(action.action) ? { ...action, action: action.action === 'install' ? 'installed' : 'updated' } : action;
2032
+ });
2033
+ }
2034
+ console.log(JSON.stringify(output, null, 2));
2035
+ if (conflicts.length > 0) process.exitCode = OPERATION_ERROR;
2036
+ } finally {
2037
+ if (releaseLock) await releaseLock();
2038
+ }
2039
+ }
2040
+
2041
+ main().catch((error) => fail(error, ['PACKAGE_INSTALL_FAILED', 'APPROVAL_REQUIRED', 'UPDATE_TRANSACTION_FAILED', 'PUSH_BLOCKED'].includes(error?.errorCode) || /update stopped closed|^(Another Pi Sync|A stale Pi Sync)/.test(error.message) ? OPERATION_ERROR : VALIDATION_ERROR));