@codedealer/pi-sync 1.1.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 +249 -38
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) {
|
|
@@ -204,6 +206,9 @@ async function findLikelySecrets(root, relative) {
|
|
|
204
206
|
}
|
|
205
207
|
|
|
206
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__';
|
|
207
212
|
// LLM usage/limit parameters that contain "token" but never carry credentials.
|
|
208
213
|
const NON_CREDENTIAL_TOKEN_KEYS = new Set(['tokens', 'maxtokens', 'mintokens', 'tokencount', 'tokenlimit', 'numtokens']);
|
|
209
214
|
|
|
@@ -221,8 +226,8 @@ function validatePortableSettingValue(value, label) {
|
|
|
221
226
|
for (const [key, nested] of Object.entries(value)) {
|
|
222
227
|
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
223
228
|
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`);
|
|
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`);
|
|
226
231
|
}
|
|
227
232
|
continue;
|
|
228
233
|
}
|
|
@@ -902,7 +907,7 @@ function summarizeActions(actions) {
|
|
|
902
907
|
}
|
|
903
908
|
|
|
904
909
|
async function readSettingsFile(file) {
|
|
905
|
-
|
|
910
|
+
const readOnce = async () => {
|
|
906
911
|
const info = await fs.lstat(file);
|
|
907
912
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Managed settings target must be a regular file: ${file}`);
|
|
908
913
|
const text = await fs.readFile(file, 'utf8');
|
|
@@ -910,10 +915,22 @@ async function readSettingsFile(file) {
|
|
|
910
915
|
try { value = JSON.parse(text); } catch { throw new Error(`Managed settings file is not valid JSON: ${file}`); }
|
|
911
916
|
if (!isRecord(value)) throw new Error(`Managed settings file must contain a JSON object: ${file}`);
|
|
912
917
|
return { value, text, exists: true };
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
|
|
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
|
+
}
|
|
916
932
|
}
|
|
933
|
+
throw error;
|
|
917
934
|
}
|
|
918
935
|
|
|
919
936
|
async function buildPlan(repo, agentDir, declarations, state) {
|
|
@@ -966,6 +983,105 @@ async function buildPlan(repo, agentDir, declarations, state) {
|
|
|
966
983
|
return { resources, settings, stale };
|
|
967
984
|
}
|
|
968
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
|
+
|
|
969
1085
|
function outputAction(item, key) {
|
|
970
1086
|
return { type: item.type ?? 'setting', [key]: item[key], action: item.action, ...(item.reason ? { reason: item.reason } : {}),
|
|
971
1087
|
...(item.files?.length ? { files: item.files.map(({ path: file, action, reason }) => ({ path: file, action, ...(reason ? { reason } : {}) })) } : {}) };
|
|
@@ -1362,31 +1478,42 @@ function bootstrapResponse(ok, errorCode, error, extra = {}) {
|
|
|
1362
1478
|
return { apiVersion: 1, command: 'bootstrap', ok, errorCode, error, exitCode: ok ? 0 : OPERATION_ERROR, ...extra };
|
|
1363
1479
|
}
|
|
1364
1480
|
|
|
1365
|
-
async function
|
|
1481
|
+
async function installBundledResources(repo) {
|
|
1366
1482
|
const manifestPath = path.join(repo, MANIFEST_FILE);
|
|
1367
1483
|
const manifestText = await fs.readFile(manifestPath, 'utf8');
|
|
1368
1484
|
const manifest = JSON.parse(manifestText);
|
|
1369
1485
|
const declarations = await validateRepository(repo);
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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 = [];
|
|
1385
1506
|
try {
|
|
1386
|
-
for (const
|
|
1387
|
-
|
|
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
|
+
}
|
|
1388
1515
|
}
|
|
1389
|
-
const nextManifest = { ...manifest, resources: [...manifest.resources, { type
|
|
1516
|
+
const nextManifest = { ...manifest, resources: [...manifest.resources, ...toAdd.map(({ type, source, target }) => ({ type, source, target }))] };
|
|
1390
1517
|
const temporary = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
1391
1518
|
try {
|
|
1392
1519
|
const info = await fs.stat(manifestPath);
|
|
@@ -1398,11 +1525,11 @@ async function installBundledSkill(repo) {
|
|
|
1398
1525
|
throw error;
|
|
1399
1526
|
}
|
|
1400
1527
|
} catch (error) {
|
|
1401
|
-
await fs.rm(
|
|
1528
|
+
for (const directory of created) await fs.rm(directory, { recursive: true, force: true }).catch(() => {});
|
|
1402
1529
|
throw error;
|
|
1403
1530
|
}
|
|
1404
1531
|
await validateRepository(repo);
|
|
1405
|
-
return { ok: true, added: true };
|
|
1532
|
+
return { ok: true, added: true, addedResources: toAdd.map(({ type, source, target }) => ({ type, source, target })) };
|
|
1406
1533
|
}
|
|
1407
1534
|
|
|
1408
1535
|
async function runPiSync(command, repo, agentDir) {
|
|
@@ -1480,16 +1607,20 @@ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
|
|
|
1480
1607
|
missingPrerequisites: prerequisites.tools.filter((tool) => tool.status === 'failed'),
|
|
1481
1608
|
});
|
|
1482
1609
|
|
|
1483
|
-
const declaration = await
|
|
1484
|
-
if (!declaration.ok) return bootstrapResponse(false, 'RESOURCE_COLLISION', '
|
|
1485
|
-
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,
|
|
1486
1613
|
});
|
|
1487
1614
|
|
|
1488
1615
|
await validateRepository(checkout);
|
|
1489
1616
|
const plan = await runPiSync('plan', checkout, path.resolve(agentDir));
|
|
1490
1617
|
if (plan.status !== 0 || !plan.output.ok) {
|
|
1491
1618
|
return bootstrapResponse(false, 'APPLY_CONFLICT', 'Bootstrap plan found a collision or conflict; existing local content was left untouched', {
|
|
1492
|
-
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,
|
|
1493
1624
|
});
|
|
1494
1625
|
}
|
|
1495
1626
|
if (await fs.lstat(destination).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error))) {
|
|
@@ -1500,7 +1631,10 @@ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
|
|
|
1500
1631
|
return bootstrapResponse(applied.status === 0 && applied.output.ok, applied.status === 0 && applied.output.ok ? undefined : 'APPLY_CONFLICT',
|
|
1501
1632
|
applied.status === 0 && applied.output.ok ? undefined : 'Bootstrap apply did not complete cleanly; inspect the structured apply result and resume after resolving it', {
|
|
1502
1633
|
repository: destination, authenticationMethod, prerequisites: prerequisites.tools,
|
|
1503
|
-
|
|
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' })),
|
|
1504
1638
|
apply: applied.output,
|
|
1505
1639
|
});
|
|
1506
1640
|
} finally {
|
|
@@ -1531,6 +1665,51 @@ function managedGitPath(relative, resourceSources) {
|
|
|
1531
1665
|
return relative === MANIFEST_FILE || relative === LOCK_FILE || resourceSources.some((source) => relative === source || relative.startsWith(`${source}/`));
|
|
1532
1666
|
}
|
|
1533
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
|
+
|
|
1534
1713
|
// Returns the resource sources declared in the setup manifest at the given
|
|
1535
1714
|
// commit, or an empty list when that commit predates the manifest. Malformed
|
|
1536
1715
|
// manifests at ahead commits fail closed so they are reconciled manually.
|
|
@@ -1776,8 +1955,8 @@ async function managedPush(repo, declarations, publish = false) {
|
|
|
1776
1955
|
|
|
1777
1956
|
async function main() {
|
|
1778
1957
|
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]');
|
|
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]');
|
|
1781
1960
|
}
|
|
1782
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 };
|
|
1783
1962
|
for (let i = 3; i < process.argv.length; i++) {
|
|
@@ -1803,7 +1982,7 @@ async function main() {
|
|
|
1803
1982
|
}
|
|
1804
1983
|
if (options.confirm && command !== 'prune') throw new Error('--confirm is only supported by prune');
|
|
1805
1984
|
if (options.approve && !['apply', 'update'].includes(command)) throw new Error('--approve is only supported by apply or update');
|
|
1806
|
-
if (options.applyUpdate &&
|
|
1985
|
+
if (options.applyUpdate && !['update', 'sync'].includes(command)) throw new Error('--apply is only supported by update or sync');
|
|
1807
1986
|
if (options.pushUpdate && command !== 'update') throw new Error('--push is only supported by update');
|
|
1808
1987
|
if (options.pushUpdate && !options.applyUpdate) throw new Error('update --push requires --apply so package updates are explicitly approved');
|
|
1809
1988
|
if (options.publish && command !== 'push') throw new Error('--publish is only supported by push');
|
|
@@ -1918,6 +2097,21 @@ async function main() {
|
|
|
1918
2097
|
} finally { await releasePackageLock(); }
|
|
1919
2098
|
return;
|
|
1920
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
|
+
}
|
|
1921
2115
|
if (command === 'apply' && !agentInfo) {
|
|
1922
2116
|
await fs.mkdir(agentDir, { recursive: true });
|
|
1923
2117
|
agentInfo = await fs.lstat(agentDir);
|
|
@@ -1951,8 +2145,25 @@ async function main() {
|
|
|
1951
2145
|
const conflicts = actions.filter((item) => ['collision', 'conflict'].includes(item.action) || item.fields?.some((field) => ['collision', 'conflict'].includes(field.action)));
|
|
1952
2146
|
const staleResources = plan.stale.map(({ target, state: staleState, localSnapshot, overlaps }) => ({ target, state: staleState,
|
|
1953
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
|
+
}
|
|
1954
2163
|
const output = { apiVersion: 1, command, ok: conflicts.length === 0, exitCode: conflicts.length ? OPERATION_ERROR : 0,
|
|
1955
|
-
repository:
|
|
2164
|
+
...(reporting ? (publishState ? { repository: publishState } : {}) : { repository: repo }),
|
|
2165
|
+
agentDirectory: agentDir, actions, conflicts, staleResources,
|
|
2166
|
+
...(reporting ? { sync, summary } : {}),
|
|
1956
2167
|
packages: declarations.lock.packages.map((item) => ({ package: packageIdentity(item), pin: packageRevision(item), action: command === 'apply' ? 'pinned-install' : 'locked' })),
|
|
1957
2168
|
dependencyModel: 'exact top-level pins; Pi controls transitive dependencies, which Pi Sync does not lock or claim reproducible' };
|
|
1958
2169
|
|
|
@@ -2038,4 +2249,4 @@ async function main() {
|
|
|
2038
2249
|
}
|
|
2039
2250
|
}
|
|
2040
2251
|
|
|
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));
|
|
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));
|