@codedealer/pi-sync 1.0.0 → 1.4.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/README.md +29 -2
- package/package.json +5 -2
- package/resources/extensions/pi-sync/README.md +100 -0
- package/resources/extensions/pi-sync/index.ts +564 -0
- package/resources/extensions/pi-sync/status.js +217 -0
- package/resources/skills/pi-sync/SKILL.md +21 -1
- package/src/cli.js +264 -39
package/src/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ const RESOURCE_RULES = {
|
|
|
24
24
|
};
|
|
25
25
|
const RESOURCE_TARGET_ROOTS = Object.values(RESOURCE_RULES).map((rule) => `${rule.targetRoot}/`);
|
|
26
26
|
const BUNDLED_SKILL = fileURLToPath(new URL('../resources/skills/pi-sync', import.meta.url));
|
|
27
|
+
const BUNDLED_EXTENSION = fileURLToPath(new URL('../resources/extensions/pi-sync', import.meta.url));
|
|
27
28
|
|
|
28
29
|
function fail(error, code = VALIDATION_ERROR) {
|
|
29
30
|
const command = process.argv[2] ?? null;
|
|
@@ -157,7 +158,8 @@ const SECRET_PATTERNS = [
|
|
|
157
158
|
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/],
|
|
158
159
|
['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
|
|
159
160
|
['bearer token', /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/i],
|
|
160
|
-
|
|
161
|
+
// Keep the __placeholder__ exclusion in sync with SAFE_PLACEHOLDER below.
|
|
162
|
+
['credential assignment', /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?(?!your[_ -]|example|changeme|placeholder|__placeholder__)[A-Za-z0-9_./+=-]{12,}/i],
|
|
161
163
|
];
|
|
162
164
|
|
|
163
165
|
function findLikelySecretsInText(text, relative) {
|
|
@@ -203,14 +205,31 @@ async function findLikelySecrets(root, relative) {
|
|
|
203
205
|
return findings;
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
const ENV_REFERENCE = /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
|
|
209
|
+
// Safe literal for credential-like keys of providers that require no real API key
|
|
210
|
+
// (Pi's documented way to satisfy the mandatory apiKey field for local endpoints).
|
|
211
|
+
const SAFE_PLACEHOLDER = '__PLACEHOLDER__';
|
|
212
|
+
// LLM usage/limit parameters that contain "token" but never carry credentials.
|
|
213
|
+
const NON_CREDENTIAL_TOKEN_KEYS = new Set(['tokens', 'maxtokens', 'mintokens', 'tokencount', 'tokenlimit', 'numtokens']);
|
|
214
|
+
|
|
215
|
+
function isCredentialLikeKey(normalized) {
|
|
216
|
+
if (['apikey', 'password', 'passwd', 'secret', 'credential', 'privatekey'].some((part) => normalized.includes(part))) return true;
|
|
217
|
+
if (normalized.includes('token')) return !NON_CREDENTIAL_TOKEN_KEYS.has(normalized);
|
|
218
|
+
if (normalized.includes('auth')) return !normalized.startsWith('author');
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
function validatePortableSettingValue(value, label) {
|
|
207
223
|
if (Array.isArray(value)) {
|
|
208
224
|
value.forEach((item, index) => validatePortableSettingValue(item, `${label}[${index}]`));
|
|
209
225
|
} else if (isRecord(value)) {
|
|
210
226
|
for (const [key, nested] of Object.entries(value)) {
|
|
211
227
|
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
212
|
-
if (
|
|
213
|
-
|
|
228
|
+
if (isCredentialLikeKey(normalized)) {
|
|
229
|
+
if (typeof nested !== 'string' || !(nested === SAFE_PLACEHOLDER || ENV_REFERENCE.test(nested))) {
|
|
230
|
+
throw new Error(`${label} contains a credential-like key (${key}); only a $NAME or ${'$'}{NAME} environment reference or the ${SAFE_PLACEHOLDER} placeholder may be managed`);
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
214
233
|
}
|
|
215
234
|
validatePortableSettingValue(nested, `${label}.${key}`);
|
|
216
235
|
}
|
|
@@ -303,7 +322,7 @@ async function validateRepository(repo, { allowMissingPins = false } = {}) {
|
|
|
303
322
|
const segments = decodePointer(field.path, `${fieldLabel}.path`);
|
|
304
323
|
validatePortableSettingValue(field.value, `${fieldLabel}.value`);
|
|
305
324
|
for (const segment of segments) {
|
|
306
|
-
if (
|
|
325
|
+
if (isCredentialLikeKey(segment.toLowerCase().replace(/[^a-z0-9]/g, ''))) {
|
|
307
326
|
throw new Error(`${fieldLabel}.path contains a credential-like key`);
|
|
308
327
|
}
|
|
309
328
|
}
|
|
@@ -888,7 +907,7 @@ function summarizeActions(actions) {
|
|
|
888
907
|
}
|
|
889
908
|
|
|
890
909
|
async function readSettingsFile(file) {
|
|
891
|
-
|
|
910
|
+
const readOnce = async () => {
|
|
892
911
|
const info = await fs.lstat(file);
|
|
893
912
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Managed settings target must be a regular file: ${file}`);
|
|
894
913
|
const text = await fs.readFile(file, 'utf8');
|
|
@@ -896,10 +915,22 @@ async function readSettingsFile(file) {
|
|
|
896
915
|
try { value = JSON.parse(text); } catch { throw new Error(`Managed settings file is not valid JSON: ${file}`); }
|
|
897
916
|
if (!isRecord(value)) throw new Error(`Managed settings file must contain a JSON object: ${file}`);
|
|
898
917
|
return { value, text, exists: true };
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
|
|
918
|
+
};
|
|
919
|
+
let error;
|
|
920
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
921
|
+
try {
|
|
922
|
+
return await readOnce();
|
|
923
|
+
} catch (readError) {
|
|
924
|
+
if (readError.code === 'ENOENT') return { value: {}, text: null, exists: false };
|
|
925
|
+
error = readError;
|
|
926
|
+
// Pi persists settings with an in-place write, so a concurrent read can
|
|
927
|
+
// catch a partially written file for a few milliseconds. Retry once so a
|
|
928
|
+
// transient torn read does not fail the whole operation; a file that is
|
|
929
|
+
// genuinely invalid still fails on the second attempt.
|
|
930
|
+
if (attempt === 1) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
931
|
+
}
|
|
902
932
|
}
|
|
933
|
+
throw error;
|
|
903
934
|
}
|
|
904
935
|
|
|
905
936
|
async function buildPlan(repo, agentDir, declarations, state) {
|
|
@@ -952,6 +983,105 @@ async function buildPlan(repo, agentDir, declarations, state) {
|
|
|
952
983
|
return { resources, settings, stale };
|
|
953
984
|
}
|
|
954
985
|
|
|
986
|
+
// Read-only sync classification shared by the `sync` preview/apply and the
|
|
987
|
+
// status/plan `sync` block, so every consumer sees identical content:
|
|
988
|
+
// syncable fields (local-only and portable, with from/to), conflicts (changed
|
|
989
|
+
// in both places or without a baseline), rejected (non-portable local
|
|
990
|
+
// values), and review (locally removed managed fields). No manifest write,
|
|
991
|
+
// no re-baseline, no lock.
|
|
992
|
+
function syncClassification(plan) {
|
|
993
|
+
const syncable = [];
|
|
994
|
+
const conflicts = [];
|
|
995
|
+
const rejected = [];
|
|
996
|
+
const review = [];
|
|
997
|
+
for (const item of plan.settings) {
|
|
998
|
+
for (const field of item.fields) {
|
|
999
|
+
if (field.action === 'local-divergence') {
|
|
1000
|
+
if (!field.local.exists) {
|
|
1001
|
+
review.push({ target: item.target, path: field.pointer, reason: 'managed setting was removed locally; update the setup manifest manually' });
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
validatePortableSettingValue(field.local.value, `${item.target} ${field.pointer}`);
|
|
1006
|
+
syncable.push({ target: item.target, path: field.pointer, from: field.value, to: field.local.value });
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
rejected.push({ target: item.target, path: field.pointer, reason: error.message });
|
|
1009
|
+
}
|
|
1010
|
+
} else if (field.action === 'conflict' || field.action === 'collision') {
|
|
1011
|
+
conflicts.push({ target: item.target, path: field.pointer, action: field.action, ...(field.reason ? { reason: field.reason } : {}) });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return { syncable, conflicts, rejected, review };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Back-syncs local-only managed setting changes into the setup manifest.
|
|
1019
|
+
// A field is eligible only when the three-way comparison says the repository
|
|
1020
|
+
// is unchanged and the local installation diverged from the last applied
|
|
1021
|
+
// baseline. Conflicts, collisions (no baseline), and locally removed fields
|
|
1022
|
+
// are reported for manual review and never written. The manifest is the only
|
|
1023
|
+
// file this command modifies; it is rewritten with the same atomic
|
|
1024
|
+
// read-check-rename pattern as add/remove and re-validated after the write.
|
|
1025
|
+
async function syncSettings(repo, agentDir, declarations, state, applyChanges) {
|
|
1026
|
+
const plan = await buildPlan(repo, agentDir, declarations, state);
|
|
1027
|
+
const { syncable, conflicts, rejected, review } = syncClassification(plan);
|
|
1028
|
+
|
|
1029
|
+
if (!applyChanges) {
|
|
1030
|
+
return { apiVersion: 1, command: 'sync', ok: true, exitCode: 0, repository: repo, agentDirectory: agentDir, mode: 'preview',
|
|
1031
|
+
syncable, conflicts, rejected, review,
|
|
1032
|
+
note: syncable.length
|
|
1033
|
+
? 'Preview only: run `pi-sync sync --apply` to write the local values into the setup manifest. Pushing the change still requires an explicit `pi-sync push`.'
|
|
1034
|
+
: 'Nothing to sync: every managed setting field matches the setup manifest locally.' };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const output = { apiVersion: 1, command: 'sync', ok: true, exitCode: 0, repository: repo, agentDirectory: agentDir, mode: 'applied',
|
|
1038
|
+
synced: [], conflicts, rejected, review };
|
|
1039
|
+
if (!syncable.length) {
|
|
1040
|
+
output.note = conflicts.length || rejected.length || review.length
|
|
1041
|
+
? 'No eligible local-only changes to write; the reported fields need manual review.'
|
|
1042
|
+
: 'Nothing to sync: every managed setting field matches the setup manifest locally.';
|
|
1043
|
+
if (conflicts.length || rejected.length || review.length) { output.ok = false; output.exitCode = OPERATION_ERROR; }
|
|
1044
|
+
return output;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const manifestPath = path.join(repo, MANIFEST_FILE);
|
|
1048
|
+
const manifestText = await fs.readFile(manifestPath, 'utf8');
|
|
1049
|
+
const manifest = JSON.parse(manifestText);
|
|
1050
|
+
const nextManifest = structuredClone(manifest);
|
|
1051
|
+
for (const change of syncable) {
|
|
1052
|
+
const field = nextManifest.settings?.find((item) => item.target === change.target)?.fields?.find((item) => item.path === change.path);
|
|
1053
|
+
if (!field) throw new Error(`Manifest no longer declares ${change.target} ${change.path}; re-run sync`);
|
|
1054
|
+
field.value = change.to;
|
|
1055
|
+
}
|
|
1056
|
+
const findings = findLikelySecretsInText(`${JSON.stringify(nextManifest, null, 2)}\n`, MANIFEST_FILE);
|
|
1057
|
+
if (findings.length) {
|
|
1058
|
+
const error = new Error('Synced local setting values contain suspected credential material; nothing was written');
|
|
1059
|
+
error.errorCode = 'SYNC_BLOCKED';
|
|
1060
|
+
error.details = { reviewRequired: findings };
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
1063
|
+
await writeJsonFileAtomic(manifestPath, nextManifest, manifestText);
|
|
1064
|
+
try {
|
|
1065
|
+
await validateRepository(repo);
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
await restoreFileAtomic(manifestPath, manifestText);
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
1070
|
+
// Re-baseline the synced fields so a later local change is compared against
|
|
1071
|
+
// the value this sync just published, not against the pre-sync baseline
|
|
1072
|
+
// (which would misclassify the next local-only change as a both-sides conflict).
|
|
1073
|
+
const mutableState = structuredClone(state);
|
|
1074
|
+
for (const change of syncable) {
|
|
1075
|
+
mutableState.settings[change.target] ??= {};
|
|
1076
|
+
mutableState.settings[change.target][change.path] = { exists: true, value: change.to };
|
|
1077
|
+
}
|
|
1078
|
+
await writeState(agentDir, mutableState);
|
|
1079
|
+
output.synced = syncable;
|
|
1080
|
+
output.note = 'Local values were written to the setup manifest. The setup repository is not committed or pushed; publish with an explicit `pi-sync push`.';
|
|
1081
|
+
if (conflicts.length || rejected.length || review.length) { output.ok = false; output.exitCode = OPERATION_ERROR; }
|
|
1082
|
+
return output;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
955
1085
|
function outputAction(item, key) {
|
|
956
1086
|
return { type: item.type ?? 'setting', [key]: item[key], action: item.action, ...(item.reason ? { reason: item.reason } : {}),
|
|
957
1087
|
...(item.files?.length ? { files: item.files.map(({ path: file, action, reason }) => ({ path: file, action, ...(reason ? { reason } : {}) })) } : {}) };
|
|
@@ -1348,31 +1478,42 @@ function bootstrapResponse(ok, errorCode, error, extra = {}) {
|
|
|
1348
1478
|
return { apiVersion: 1, command: 'bootstrap', ok, errorCode, error, exitCode: ok ? 0 : OPERATION_ERROR, ...extra };
|
|
1349
1479
|
}
|
|
1350
1480
|
|
|
1351
|
-
async function
|
|
1481
|
+
async function installBundledResources(repo) {
|
|
1352
1482
|
const manifestPath = path.join(repo, MANIFEST_FILE);
|
|
1353
1483
|
const manifestText = await fs.readFile(manifestPath, 'utf8');
|
|
1354
1484
|
const manifest = JSON.parse(manifestText);
|
|
1355
1485
|
const declarations = await validateRepository(repo);
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
const
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1486
|
+
const bundled = [
|
|
1487
|
+
{ type: 'skill', source: 'resources/skills/pi-sync', target: 'skills/pi-sync', root: BUNDLED_SKILL },
|
|
1488
|
+
{ type: 'extension', source: 'resources/extensions/pi-sync', target: 'extensions/pi-sync', root: BUNDLED_EXTENSION },
|
|
1489
|
+
];
|
|
1490
|
+
// Fail before materializing anything: a collision on any bundled resource
|
|
1491
|
+
// must not leave partially declared repository content behind.
|
|
1492
|
+
const toAdd = [];
|
|
1493
|
+
for (const item of bundled) {
|
|
1494
|
+
if (declarations.resources.some((resource) => resource.source === item.source && resource.target === item.target)) continue;
|
|
1495
|
+
const collision = declarations.resources.find((resource) => pathsOverlap(resource.target, item.target) || pathsOverlap(resource.source, item.source));
|
|
1496
|
+
if (collision) return { ok: false, collision: { source: collision.source, target: collision.target, bundled: { type: item.type, source: item.source, target: item.target } } };
|
|
1497
|
+
const sourcePath = path.join(repo, ...item.source.split('/'));
|
|
1498
|
+
const existingSource = await fs.lstat(sourcePath).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
1499
|
+
if (existingSource) return { ok: false, collision: { source: item.source, target: item.target, bundled: { type: item.type, source: item.source, target: item.target }, reason: 'setup repository source path already exists and is not declared as this resource' } };
|
|
1500
|
+
const info = await fs.lstat(item.root).catch((error) => { throw new Error(`Bundled pi-sync ${item.type} is unavailable: ${error.message}`); });
|
|
1501
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Bundled pi-sync ${item.type} is not a real directory`);
|
|
1502
|
+
toAdd.push(item);
|
|
1503
|
+
}
|
|
1504
|
+
if (!toAdd.length) return { ok: true, added: false, addedResources: [] };
|
|
1505
|
+
const created = [];
|
|
1371
1506
|
try {
|
|
1372
|
-
for (const
|
|
1373
|
-
|
|
1507
|
+
for (const item of toAdd) {
|
|
1508
|
+
const sourcePath = path.join(repo, ...item.source.split('/'));
|
|
1509
|
+
await ensureRealDirectory(repo, path.posix.dirname(item.source), true);
|
|
1510
|
+
await fs.mkdir(sourcePath);
|
|
1511
|
+
created.push(sourcePath);
|
|
1512
|
+
for (const entry of await fs.readdir(item.root)) {
|
|
1513
|
+
await fs.cp(path.join(item.root, entry), path.join(sourcePath, entry), { recursive: true, errorOnExist: true, force: false });
|
|
1514
|
+
}
|
|
1374
1515
|
}
|
|
1375
|
-
const nextManifest = { ...manifest, resources: [...manifest.resources, { type
|
|
1516
|
+
const nextManifest = { ...manifest, resources: [...manifest.resources, ...toAdd.map(({ type, source, target }) => ({ type, source, target }))] };
|
|
1376
1517
|
const temporary = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
1377
1518
|
try {
|
|
1378
1519
|
const info = await fs.stat(manifestPath);
|
|
@@ -1384,11 +1525,11 @@ async function installBundledSkill(repo) {
|
|
|
1384
1525
|
throw error;
|
|
1385
1526
|
}
|
|
1386
1527
|
} catch (error) {
|
|
1387
|
-
await fs.rm(
|
|
1528
|
+
for (const directory of created) await fs.rm(directory, { recursive: true, force: true }).catch(() => {});
|
|
1388
1529
|
throw error;
|
|
1389
1530
|
}
|
|
1390
1531
|
await validateRepository(repo);
|
|
1391
|
-
return { ok: true, added: true };
|
|
1532
|
+
return { ok: true, added: true, addedResources: toAdd.map(({ type, source, target }) => ({ type, source, target })) };
|
|
1392
1533
|
}
|
|
1393
1534
|
|
|
1394
1535
|
async function runPiSync(command, repo, agentDir) {
|
|
@@ -1466,16 +1607,20 @@ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
|
|
|
1466
1607
|
missingPrerequisites: prerequisites.tools.filter((tool) => tool.status === 'failed'),
|
|
1467
1608
|
});
|
|
1468
1609
|
|
|
1469
|
-
const declaration = await
|
|
1470
|
-
if (!declaration.ok) return bootstrapResponse(false, 'RESOURCE_COLLISION', '
|
|
1471
|
-
repository: destination, collision: declaration.collision,
|
|
1610
|
+
const declaration = await installBundledResources(checkout);
|
|
1611
|
+
if (!declaration.ok) return bootstrapResponse(false, 'RESOURCE_COLLISION', 'A bundled pi-sync resource conflicts with existing setup repository content; no installation was performed', {
|
|
1612
|
+
repository: destination, collision: declaration.collision,
|
|
1472
1613
|
});
|
|
1473
1614
|
|
|
1474
1615
|
await validateRepository(checkout);
|
|
1475
1616
|
const plan = await runPiSync('plan', checkout, path.resolve(agentDir));
|
|
1476
1617
|
if (plan.status !== 0 || !plan.output.ok) {
|
|
1477
1618
|
return bootstrapResponse(false, 'APPLY_CONFLICT', 'Bootstrap plan found a collision or conflict; existing local content was left untouched', {
|
|
1478
|
-
repository: destination,
|
|
1619
|
+
repository: destination,
|
|
1620
|
+
bundledResources: [
|
|
1621
|
+
{ type: 'skill', source: 'resources/skills/pi-sync', target: 'skills/pi-sync' },
|
|
1622
|
+
{ type: 'extension', source: 'resources/extensions/pi-sync', target: 'extensions/pi-sync' },
|
|
1623
|
+
], plan: plan.output,
|
|
1479
1624
|
});
|
|
1480
1625
|
}
|
|
1481
1626
|
if (await fs.lstat(destination).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error))) {
|
|
@@ -1486,7 +1631,10 @@ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
|
|
|
1486
1631
|
return bootstrapResponse(applied.status === 0 && applied.output.ok, applied.status === 0 && applied.output.ok ? undefined : 'APPLY_CONFLICT',
|
|
1487
1632
|
applied.status === 0 && applied.output.ok ? undefined : 'Bootstrap apply did not complete cleanly; inspect the structured apply result and resume after resolving it', {
|
|
1488
1633
|
repository: destination, authenticationMethod, prerequisites: prerequisites.tools,
|
|
1489
|
-
|
|
1634
|
+
bundledResources: [
|
|
1635
|
+
{ type: 'skill', source: 'resources/skills/pi-sync', target: 'skills/pi-sync' },
|
|
1636
|
+
{ type: 'extension', source: 'resources/extensions/pi-sync', target: 'extensions/pi-sync' },
|
|
1637
|
+
].map((item) => ({ ...item, action: declaration.addedResources.some((added) => added.source === item.source) ? 'declared-and-applied' : 'applied' })),
|
|
1490
1638
|
apply: applied.output,
|
|
1491
1639
|
});
|
|
1492
1640
|
} finally {
|
|
@@ -1517,6 +1665,51 @@ function managedGitPath(relative, resourceSources) {
|
|
|
1517
1665
|
return relative === MANIFEST_FILE || relative === LOCK_FILE || resourceSources.some((source) => relative === source || relative.startsWith(`${source}/`));
|
|
1518
1666
|
}
|
|
1519
1667
|
|
|
1668
|
+
// Read-only publish state of the setup repository relative to its upstream,
|
|
1669
|
+
// computed with the same git primitives the managed push uses: working-tree
|
|
1670
|
+
// dirt from `git status --porcelain --untracked-files=no` (untracked files do
|
|
1671
|
+
// not count: managed-only push never stages them, so they are not
|
|
1672
|
+
// publishable changes) and ahead/behind from
|
|
1673
|
+
// `git rev-list --left-right --count HEAD...@{upstream}`. Returns null when
|
|
1674
|
+
// the setup directory is not the root of a git working tree (status still
|
|
1675
|
+
// works in that case for manifest validation), and null ahead/behind when no
|
|
1676
|
+
// upstream can be resolved (no remote, no upstream configured, detached HEAD,
|
|
1677
|
+
// or an upstream ref that cannot be compared, such as an unfetched one).
|
|
1678
|
+
async function repositoryPublishState(repo) {
|
|
1679
|
+
const top = await gitResult(repo, ['rev-parse', '--show-toplevel']);
|
|
1680
|
+
if (top.status !== 0) return null;
|
|
1681
|
+
if (path.resolve(top.output) !== path.resolve(repo)) return null;
|
|
1682
|
+
const dirty = await gitResult(repo, ['status', '--porcelain', '--untracked-files=no']);
|
|
1683
|
+
if (dirty.status !== 0) throw new Error(`Could not inspect the setup repository working tree: ${dirty.errorText || `git exited ${dirty.status}`}`);
|
|
1684
|
+
let ahead = null;
|
|
1685
|
+
let behind = null;
|
|
1686
|
+
const upstream = await gitResult(repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
1687
|
+
if (upstream.status === 0) {
|
|
1688
|
+
const counts = await gitResult(repo, ['rev-list', '--left-right', '--count', `HEAD...${upstream.output}`]);
|
|
1689
|
+
if (counts.status === 0) {
|
|
1690
|
+
const [left, right] = counts.output.split(/\s+/).map(Number);
|
|
1691
|
+
if (Number.isInteger(left) && Number.isInteger(right)) { ahead = left; behind = right; }
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return { dirty: dirty.output.length > 0, ahead, behind };
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Derives the footer-ready summary from the reporting blocks. Counts follow
|
|
1698
|
+
// the two-stage pipeline with no double counting (a change always lives in
|
|
1699
|
+
// exactly one stage): syncable fields, working-tree dirt, and ahead commits
|
|
1700
|
+
// are local-pending; behind commits and unapplied install/update actions are
|
|
1701
|
+
// remote-pending; conflict/collision actions, rejected and locally removed
|
|
1702
|
+
// fields, and locally diverged resources need review. syncable counts fields
|
|
1703
|
+
// (the sync unit); review counts manifest items (actions).
|
|
1704
|
+
function deriveStatusSummary(publishState, sync, conflicts, actions) {
|
|
1705
|
+
const localPending = sync.syncable.length + (publishState ? (publishState.dirty ? 1 : 0) + (publishState.ahead ?? 0) : 0);
|
|
1706
|
+
const remotePending = (publishState?.behind ?? 0) + actions.filter((item) => item.action === 'install' || item.action === 'update').length;
|
|
1707
|
+
const needsReview = conflicts.length + sync.rejected.length + sync.review.length +
|
|
1708
|
+
actions.filter((item) => item.type !== 'setting' && item.action === 'local-divergence').length;
|
|
1709
|
+
const clean = localPending === 0 && remotePending === 0 && needsReview === 0;
|
|
1710
|
+
return { localPending, remotePending, needsReview, clean };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1520
1713
|
// Returns the resource sources declared in the setup manifest at the given
|
|
1521
1714
|
// commit, or an empty list when that commit predates the manifest. Malformed
|
|
1522
1715
|
// manifests at ahead commits fail closed so they are reconciled manually.
|
|
@@ -1762,8 +1955,8 @@ async function managedPush(repo, declarations, publish = false) {
|
|
|
1762
1955
|
|
|
1763
1956
|
async function main() {
|
|
1764
1957
|
const command = process.argv[2];
|
|
1765
|
-
if (!['status', 'plan', 'apply', 'add', 'remove', 'prune', 'bootstrap', 'update', 'push'].includes(command)) {
|
|
1766
|
-
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]');
|
|
1958
|
+
if (!['status', 'plan', 'apply', 'add', 'remove', 'prune', 'bootstrap', 'update', 'push', 'sync'].includes(command)) {
|
|
1959
|
+
throw new Error('Usage: pi-sync <status|plan|apply|add|remove|prune|update|push|bootstrap|sync> [--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]');
|
|
1767
1960
|
}
|
|
1768
1961
|
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 };
|
|
1769
1962
|
for (let i = 3; i < process.argv.length; i++) {
|
|
@@ -1789,7 +1982,7 @@ async function main() {
|
|
|
1789
1982
|
}
|
|
1790
1983
|
if (options.confirm && command !== 'prune') throw new Error('--confirm is only supported by prune');
|
|
1791
1984
|
if (options.approve && !['apply', 'update'].includes(command)) throw new Error('--approve is only supported by apply or update');
|
|
1792
|
-
if (options.applyUpdate &&
|
|
1985
|
+
if (options.applyUpdate && !['update', 'sync'].includes(command)) throw new Error('--apply is only supported by update or sync');
|
|
1793
1986
|
if (options.pushUpdate && command !== 'update') throw new Error('--push is only supported by update');
|
|
1794
1987
|
if (options.pushUpdate && !options.applyUpdate) throw new Error('update --push requires --apply so package updates are explicitly approved');
|
|
1795
1988
|
if (options.publish && command !== 'push') throw new Error('--publish is only supported by push');
|
|
@@ -1904,6 +2097,21 @@ async function main() {
|
|
|
1904
2097
|
} finally { await releasePackageLock(); }
|
|
1905
2098
|
return;
|
|
1906
2099
|
}
|
|
2100
|
+
if (command === 'sync') {
|
|
2101
|
+
if (!agentInfo) throw new Error(`Pi agent directory does not exist: ${agentDir}`);
|
|
2102
|
+
// --apply writes the manifest and re-baselines synced fields, so it takes
|
|
2103
|
+
// the same agent lock as apply to serialize state.json updates.
|
|
2104
|
+
const releaseSyncLock = options.applyUpdate ? await acquireLock(agentDir) : null;
|
|
2105
|
+
try {
|
|
2106
|
+
const state = await loadState(agentDir);
|
|
2107
|
+
const output = await syncSettings(repo, agentDir, declarations, state, options.applyUpdate);
|
|
2108
|
+
console.log(JSON.stringify(output, null, 2));
|
|
2109
|
+
if (!output.ok) process.exitCode = output.exitCode;
|
|
2110
|
+
} finally {
|
|
2111
|
+
if (releaseSyncLock) await releaseSyncLock();
|
|
2112
|
+
}
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
1907
2115
|
if (command === 'apply' && !agentInfo) {
|
|
1908
2116
|
await fs.mkdir(agentDir, { recursive: true });
|
|
1909
2117
|
agentInfo = await fs.lstat(agentDir);
|
|
@@ -1937,8 +2145,25 @@ async function main() {
|
|
|
1937
2145
|
const conflicts = actions.filter((item) => ['collision', 'conflict'].includes(item.action) || item.fields?.some((field) => ['collision', 'conflict'].includes(field.action)));
|
|
1938
2146
|
const staleResources = plan.stale.map(({ target, state: staleState, localSnapshot, overlaps }) => ({ target, state: staleState,
|
|
1939
2147
|
installed: localSnapshot !== null, pruneAvailable: overlaps.length === 0, ...(overlaps.length ? { overlaps } : {}) }));
|
|
2148
|
+
// status and plan are read-only reporting commands: they gain the
|
|
2149
|
+
// repository publish-state block (omitted when the setup directory is not
|
|
2150
|
+
// a git working tree), the read-only sync classification (identical
|
|
2151
|
+
// content to a `sync` preview), and the derived summary. apply and prune
|
|
2152
|
+
// keep their existing contracts and take the agent lock as before; these
|
|
2153
|
+
// blocks never write and take no lock.
|
|
2154
|
+
const reporting = command === 'status' || command === 'plan';
|
|
2155
|
+
let publishState;
|
|
2156
|
+
let sync;
|
|
2157
|
+
let summary;
|
|
2158
|
+
if (reporting) {
|
|
2159
|
+
publishState = await repositoryPublishState(repo);
|
|
2160
|
+
sync = syncClassification(plan);
|
|
2161
|
+
summary = deriveStatusSummary(publishState, sync, conflicts, actions);
|
|
2162
|
+
}
|
|
1940
2163
|
const output = { apiVersion: 1, command, ok: conflicts.length === 0, exitCode: conflicts.length ? OPERATION_ERROR : 0,
|
|
1941
|
-
repository:
|
|
2164
|
+
...(reporting ? (publishState ? { repository: publishState } : {}) : { repository: repo }),
|
|
2165
|
+
agentDirectory: agentDir, actions, conflicts, staleResources,
|
|
2166
|
+
...(reporting ? { sync, summary } : {}),
|
|
1942
2167
|
packages: declarations.lock.packages.map((item) => ({ package: packageIdentity(item), pin: packageRevision(item), action: command === 'apply' ? 'pinned-install' : 'locked' })),
|
|
1943
2168
|
dependencyModel: 'exact top-level pins; Pi controls transitive dependencies, which Pi Sync does not lock or claim reproducible' };
|
|
1944
2169
|
|
|
@@ -2024,4 +2249,4 @@ async function main() {
|
|
|
2024
2249
|
}
|
|
2025
2250
|
}
|
|
2026
2251
|
|
|
2027
|
-
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));
|
|
2252
|
+
main().catch((error) => fail(error, ['PACKAGE_INSTALL_FAILED', 'APPROVAL_REQUIRED', 'UPDATE_TRANSACTION_FAILED', 'PUSH_BLOCKED', 'SYNC_BLOCKED'].includes(error?.errorCode) || /update stopped closed|^(Another Pi Sync|A stale Pi Sync)/.test(error.message) ? OPERATION_ERROR : VALIDATION_ERROR));
|