@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/README.md +134 -2
- package/package.json +21 -4
- package/resources/skills/pi-sync/SKILL.md +146 -0
- package/src/cli.js +2041 -0
- package/src/packages.js +188 -0
- package/src/prerequisites.js +224 -0
package/src/packages.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const MINIMUM_RELEASE_AGE_DAYS = 7;
|
|
7
|
+
function exactKeys(value, keys) {
|
|
8
|
+
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function run(executable, args) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
14
|
+
let stdout = '';
|
|
15
|
+
let stderr = '';
|
|
16
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
|
|
17
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
|
|
18
|
+
child.on('error', (error) => resolve({ status: null, stdout, stderr, error }));
|
|
19
|
+
child.on('close', (status) => resolve({ status, stdout, stderr, error: null }));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validatePackageState(manifest, lock, { allowMissingPins = false } = {}) {
|
|
24
|
+
if (!Array.isArray(manifest.dependencies) || !exactKeys(lock, ['schemaVersion', 'packages']) || lock.schemaVersion !== 1 || !Array.isArray(lock.packages)) {
|
|
25
|
+
throw new Error('Manifest dependencies and lockfile packages must be arrays (lockfile schemaVersion must be 1)');
|
|
26
|
+
}
|
|
27
|
+
const keys = new Set();
|
|
28
|
+
for (const [index, dep] of manifest.dependencies.entries()) {
|
|
29
|
+
if (exactKeys(dep, ['type', 'name']) && dep.type === 'npm' && typeof dep.name === 'string' && /^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i.test(dep.name)) {
|
|
30
|
+
if (keys.has(`npm:${dep.name}`)) throw new Error(`Duplicate dependency: npm:${dep.name}`);
|
|
31
|
+
keys.add(`npm:${dep.name}`);
|
|
32
|
+
} else if (exactKeys(dep, ['type', 'repository']) && dep.type === 'github' && typeof dep.repository === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(dep.repository)) {
|
|
33
|
+
if (keys.has(`github:${dep.repository}`)) throw new Error(`Duplicate dependency: github:${dep.repository}`);
|
|
34
|
+
keys.add(`github:${dep.repository}`);
|
|
35
|
+
} else throw new Error(`dependencies[${index}] must be {type:"npm",name} or {type:"github",repository}`);
|
|
36
|
+
}
|
|
37
|
+
const locked = new Set();
|
|
38
|
+
for (const [index, item] of lock.packages.entries()) {
|
|
39
|
+
const key = item?.type === 'npm' ? `npm:${item.name}` : item?.type === 'github' ? `github:${item.repository}` : null;
|
|
40
|
+
if (!key || !keys.has(key) || locked.has(key)) throw new Error(`lockfile packages[${index}] does not match exactly one declared dependency`);
|
|
41
|
+
if (item.type === 'npm') {
|
|
42
|
+
if (!exactKeys(item, ['type', 'name', 'version', 'releasedAt']) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(item.version) || !validDate(item.releasedAt)) throw new Error(`lockfile npm package ${item.name} requires an exact version and trustworthy releasedAt timestamp`);
|
|
43
|
+
} else if (!exactKeys(item, ['type', 'repository', 'commit', 'tag', 'releasedAt']) || !/^[0-9a-f]{40}$/i.test(item.commit) || typeof item.tag !== 'string' || item.tag.length === 0 || !validDate(item.releasedAt)) {
|
|
44
|
+
throw new Error(`lockfile GitHub package ${item.repository} requires an immutable 40-character commit, tag, and trustworthy releasedAt timestamp`);
|
|
45
|
+
}
|
|
46
|
+
locked.add(key);
|
|
47
|
+
}
|
|
48
|
+
const missing = [...keys].filter((key) => !locked.has(key));
|
|
49
|
+
if (missing.length && !allowMissingPins) throw new Error(`lockfile is missing exact pins for declared dependencies: ${missing.join(', ')}`);
|
|
50
|
+
return { declared: keys, locked };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validDate(value) { return typeof value === 'string' && Number.isFinite(Date.parse(value)); }
|
|
54
|
+
function packageKey(item) { return item.type === 'npm' ? `npm:${item.name}` : `github:${item.repository}`; }
|
|
55
|
+
function spec(item) { return item.type === 'npm' ? `npm:${item.name}@${item.version}` : `github:${item.repository}#${item.commit}`; }
|
|
56
|
+
function parseJson(output, source) {
|
|
57
|
+
try { return JSON.parse(output); } catch { throw new Error(`${source} metadata was not valid JSON; update stopped closed`); }
|
|
58
|
+
}
|
|
59
|
+
function compareVersions(a, b) {
|
|
60
|
+
const left = a.split('+', 1)[0].split(/[.-]/).map((part) => /^\d+$/.test(part) ? Number(part) : part);
|
|
61
|
+
const right = b.split('+', 1)[0].split(/[.-]/).map((part) => /^\d+$/.test(part) ? Number(part) : part);
|
|
62
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
63
|
+
if (left[i] === undefined) return right[i] === undefined ? 0 : (typeof right[i] === 'number' ? -1 : 1);
|
|
64
|
+
if (right[i] === undefined) return typeof left[i] === 'number' ? 1 : -1;
|
|
65
|
+
if (left[i] === right[i]) continue;
|
|
66
|
+
if (typeof left[i] === 'number' && typeof right[i] === 'number') return left[i] - right[i];
|
|
67
|
+
return String(left[i]).localeCompare(String(right[i]));
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function npmCandidate(dep, current, eligible) {
|
|
73
|
+
const [versionsResult, timesResult] = await Promise.all([
|
|
74
|
+
run('npm', ['view', dep.name, 'versions', '--json']), run('npm', ['view', dep.name, 'time', '--json']),
|
|
75
|
+
]);
|
|
76
|
+
if (versionsResult.status !== 0 || timesResult.status !== 0) throw new Error(`Could not retrieve npm release metadata for ${dep.name}; update stopped closed`);
|
|
77
|
+
let versions = parseJson(versionsResult.stdout, `npm ${dep.name} versions`);
|
|
78
|
+
const times = parseJson(timesResult.stdout, `npm ${dep.name} timestamps`);
|
|
79
|
+
if (typeof versions === 'string') versions = [versions];
|
|
80
|
+
if (!Array.isArray(versions) || !times || typeof times !== 'object') throw new Error(`npm release metadata for ${dep.name} is incomplete; update stopped closed`);
|
|
81
|
+
if (versions.some((version) => typeof version !== 'string' || !validDate(times[version]))) throw new Error(`npm release timestamps for ${dep.name} are incomplete; update stopped closed`);
|
|
82
|
+
const candidates = versions.filter((version) => eligible(times[version])).sort(compareVersions);
|
|
83
|
+
const selected = candidates.at(-1);
|
|
84
|
+
if (!selected || (current && compareVersions(selected, current.version) <= 0)) return null;
|
|
85
|
+
return { type: 'npm', name: dep.name, version: selected, releasedAt: new Date(Date.parse(times[selected])).toISOString() };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function githubCandidate(dep, current, eligible) {
|
|
89
|
+
const releasesResult = await run('gh', ['api', `repos/${dep.repository}/releases?per_page=100`]);
|
|
90
|
+
if (releasesResult.status !== 0) throw new Error(`Could not retrieve GitHub release metadata for ${dep.repository}; update stopped closed`);
|
|
91
|
+
const releases = parseJson(releasesResult.stdout, `GitHub ${dep.repository} releases`);
|
|
92
|
+
if (!Array.isArray(releases) || releases.length >= 100) throw new Error(`GitHub release metadata for ${dep.repository} is incomplete or possibly truncated; update stopped closed`);
|
|
93
|
+
const publishedReleases = releases.filter((release) => release && !release.draft && !release.prerelease);
|
|
94
|
+
if (publishedReleases.some((release) => typeof release.tag_name !== 'string' || release.tag_name.length === 0 || !validDate(release.published_at))) {
|
|
95
|
+
throw new Error(`GitHub release tags or timestamps for ${dep.repository} are incomplete; update stopped closed`);
|
|
96
|
+
}
|
|
97
|
+
const candidates = publishedReleases.filter((release) => eligible(release.published_at))
|
|
98
|
+
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at));
|
|
99
|
+
const release = candidates[0];
|
|
100
|
+
if (!release || (current && (release.tag_name === current.tag || (validDate(current.releasedAt) && Date.parse(release.published_at) <= Date.parse(current.releasedAt))))) return null;
|
|
101
|
+
const remote = `https://github.com/${dep.repository}.git`;
|
|
102
|
+
const refs = await run('git', ['ls-remote', remote, `refs/tags/${release.tag_name}`, `refs/tags/${release.tag_name}^{}`]);
|
|
103
|
+
if (refs.status !== 0) throw new Error(`Could not resolve immutable GitHub tag ${release.tag_name} for ${dep.repository}; update stopped closed`);
|
|
104
|
+
const refLines = refs.stdout.trim().split(/\r?\n/).map((line) => /^([0-9a-f]{40})\s+(\S+)$/.exec(line)).filter(Boolean);
|
|
105
|
+
const peeledRef = `refs/tags/${release.tag_name}^{}`;
|
|
106
|
+
const exactRef = `refs/tags/${release.tag_name}`;
|
|
107
|
+
const commit = refLines.find(([, , ref]) => ref === peeledRef)?.[1] ?? refLines.find(([, , ref]) => ref === exactRef)?.[1];
|
|
108
|
+
if (!commit) throw new Error(`GitHub tag ${release.tag_name} for ${dep.repository} has no resolvable commit SHA; update stopped closed`);
|
|
109
|
+
return { type: 'github', repository: dep.repository, commit, tag: release.tag_name, releasedAt: new Date(Date.parse(release.published_at)).toISOString() };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function discoverUpdates(manifest, lock, now = Date.now()) {
|
|
113
|
+
const minimumReleaseAgeDays = manifest.policy?.minimumReleaseAgeDays ?? MINIMUM_RELEASE_AGE_DAYS;
|
|
114
|
+
if (!Number.isInteger(minimumReleaseAgeDays) || minimumReleaseAgeDays < MINIMUM_RELEASE_AGE_DAYS) throw new Error('Repository policy minimumReleaseAgeDays must be an integer of at least seven days');
|
|
115
|
+
const ageCutoffMs = minimumReleaseAgeDays * 24 * 60 * 60 * 1000;
|
|
116
|
+
const eligible = (date) => validDate(date) && now - Date.parse(date) >= ageCutoffMs;
|
|
117
|
+
const current = new Map(lock.packages.map((item) => [packageKey(item), item]));
|
|
118
|
+
const updates = [];
|
|
119
|
+
for (const dep of manifest.dependencies) {
|
|
120
|
+
const old = current.get(packageKey(dep));
|
|
121
|
+
const next = dep.type === 'npm' ? await npmCandidate(dep, old, eligible) : await githubCandidate(dep, old, eligible);
|
|
122
|
+
if (next && (!old || packageKey(next) === packageKey(old))) updates.push({ from: old ?? null, to: next, installSpec: spec(next) });
|
|
123
|
+
}
|
|
124
|
+
return updates;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function previousSpec(key, revision) {
|
|
128
|
+
if (key.startsWith('npm:')) return `npm:${key.slice(4)}@${revision}`;
|
|
129
|
+
return `github:${key.slice(7)}#${revision}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function rollbackPinnedPackages(items, previousPins = {}) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
for (const item of [...items].reverse()) {
|
|
135
|
+
const key = packageKey(item);
|
|
136
|
+
const previous = previousPins[key];
|
|
137
|
+
const result = previous === undefined
|
|
138
|
+
? await run('pi', ['uninstall', spec(item)])
|
|
139
|
+
: await run('pi', ['install', previousSpec(key, previous)]);
|
|
140
|
+
if (result.status !== 0) errors.push({ package: key, action: previous === undefined ? 'uninstall' : 'restore-pin',
|
|
141
|
+
error: result.error?.message ?? result.stderr.trim() ?? `Pi exited ${result.status}` });
|
|
142
|
+
}
|
|
143
|
+
return errors;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function installPinnedPackages(lock, { approved = false, prompt = null, approvalRequired = () => true, previousPins = {} } = {}) {
|
|
147
|
+
const results = [];
|
|
148
|
+
const attempted = [];
|
|
149
|
+
for (const item of lock.packages) {
|
|
150
|
+
if (approvalRequired(item) && !approved && prompt && !await prompt(item)) throw Object.assign(new Error(`First-time installation approval was not granted for ${packageKey(item)}`), { errorCode: 'APPROVAL_REQUIRED' });
|
|
151
|
+
if (approvalRequired(item) && !approved && !prompt) throw Object.assign(new Error(`First-time installation requires explicit approval for ${packageKey(item)}; rerun with --approve`), { errorCode: 'APPROVAL_REQUIRED' });
|
|
152
|
+
attempted.push(item);
|
|
153
|
+
const result = await run('pi', ['install', spec(item)]);
|
|
154
|
+
if (result.status !== 0) {
|
|
155
|
+
const rollbackErrors = await rollbackPinnedPackages(attempted, previousPins);
|
|
156
|
+
const error = Object.assign(new Error(`Pi package installation failed for ${packageKey(item)} at recorded pin ${spec(item)}${result.stderr ? `: ${result.stderr.trim()}` : ''}${rollbackErrors.length ? '; package rollback was incomplete' : '; previously installed package pins were restored'}`), {
|
|
157
|
+
errorCode: 'PACKAGE_INSTALL_FAILED', packagesRolledBack: true,
|
|
158
|
+
});
|
|
159
|
+
if (rollbackErrors.length) error.details = { message: error.message, rollbackErrors };
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
results.push({ package: packageKey(item), pin: spec(item), action: 'installed' });
|
|
163
|
+
}
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function writeLockfile(repo, lock) {
|
|
168
|
+
const file = path.join(repo, 'pi-sync.lock.json');
|
|
169
|
+
const temporary = `${file}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
|
|
170
|
+
try {
|
|
171
|
+
await fs.writeFile(temporary, `${JSON.stringify(lock, null, 2)}\n`, { flag: 'wx' });
|
|
172
|
+
await fs.rename(temporary, file);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function lockWithUpdates(lock, updates) {
|
|
180
|
+
const next = structuredClone(lock);
|
|
181
|
+
for (const update of updates) {
|
|
182
|
+
const key = packageKey(update.to);
|
|
183
|
+
const index = next.packages.findIndex((item) => packageKey(item) === key);
|
|
184
|
+
if (index < 0) next.packages.push(update.to); else next.packages[index] = update.to;
|
|
185
|
+
}
|
|
186
|
+
next.packages.sort((a, b) => packageKey(a).localeCompare(packageKey(b)));
|
|
187
|
+
return next;
|
|
188
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import zlib from 'node:zlib';
|
|
5
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const GH_VERSION = '2.74.2';
|
|
9
|
+
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
const INSTALL_CATALOG = Object.freeze({ gh: 'github-cli' });
|
|
11
|
+
|
|
12
|
+
export function validateRequiredTools(requiredTools = []) {
|
|
13
|
+
if (!Array.isArray(requiredTools)) throw new Error('Manifest requiredTools must be an array');
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (const [index, name] of requiredTools.entries()) {
|
|
16
|
+
if (typeof name !== 'string' || !Object.hasOwn(INSTALL_CATALOG, name)) {
|
|
17
|
+
throw new Error(`Unsupported required tool in requiredTools[${index}]: ${String(name)}. Supported tools: ${Object.keys(INSTALL_CATALOG).join(', ')}`);
|
|
18
|
+
}
|
|
19
|
+
if (seen.has(name)) throw new Error(`Duplicate required tool: ${name}`);
|
|
20
|
+
seen.add(name);
|
|
21
|
+
}
|
|
22
|
+
return [...requiredTools];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RELEASE_SHA256 = Object.freeze({
|
|
26
|
+
'gh_2.74.2_linux_amd64.tar.gz': 'c421091ae5800390e6aef1f50bfda59cc1d4f2ef2200bcd4e1a662c05c28c444',
|
|
27
|
+
'gh_2.74.2_linux_arm64.tar.gz': 'f0b07f0aeaf00f137df1bd33a76e717b1945f4b83bd6a3296b365414d3eb413f',
|
|
28
|
+
'gh_2.74.2_macOS_amd64.zip': 'c716ecdd9d5e381e521d772caa4dea29d647d465b94b1c67df9154e71f33451e',
|
|
29
|
+
'gh_2.74.2_macOS_arm64.zip': '42dceea5beae957a8a9545e38043a6d75fa3b5505fdcea75a278f873f0845b26',
|
|
30
|
+
'gh_2.74.2_windows_amd64.zip': '3ac27af5ee8dd13b1b0002e4e4764163683889cea7f21231c9951e300c95eb29',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function githubCliReleaseAsset(platform, architecture) {
|
|
34
|
+
const arch = architecture === 'x64' ? 'amd64' : architecture === 'arm64' ? 'arm64' : null;
|
|
35
|
+
if (!arch) return null;
|
|
36
|
+
let file;
|
|
37
|
+
let binary;
|
|
38
|
+
let format;
|
|
39
|
+
if (platform === 'linux') [file, binary, format] = [`gh_${GH_VERSION}_linux_${arch}.tar.gz`, 'gh', 'tar.gz'];
|
|
40
|
+
else if (platform === 'darwin') [file, binary, format] = [`gh_${GH_VERSION}_macOS_${arch}.zip`, 'gh', 'zip'];
|
|
41
|
+
else if (platform === 'win32' && arch === 'amd64') [file, binary, format] = [`gh_${GH_VERSION}_windows_amd64.zip`, 'gh.exe', 'zip'];
|
|
42
|
+
else return null;
|
|
43
|
+
const sha256 = RELEASE_SHA256[file];
|
|
44
|
+
if (!sha256) return null;
|
|
45
|
+
return { file, binary, format, sha256 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runVersion(executable, env) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const child = spawn(executable, ['--version'], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
51
|
+
let output = '';
|
|
52
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => { if (output.length < 4096) output += chunk; });
|
|
53
|
+
child.stderr.on('data', () => {});
|
|
54
|
+
child.on('error', () => resolve(null));
|
|
55
|
+
child.on('close', (status) => {
|
|
56
|
+
if (status !== 0) return resolve(null);
|
|
57
|
+
const match = /\bversion\s+v?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i.exec(output);
|
|
58
|
+
resolve(match?.[1] ?? null);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isSafeArchivePath(name) {
|
|
64
|
+
return typeof name === 'string' && !name.startsWith('/') && !name.includes('\\') &&
|
|
65
|
+
!name.split('/').some((part) => part === '..' || part === '.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fromTarGzip(archive, expectedBinary) {
|
|
69
|
+
const tar = zlib.gunzipSync(archive, { maxOutputLength: MAX_ARCHIVE_BYTES * 4 });
|
|
70
|
+
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
71
|
+
const header = tar.subarray(offset, offset + 512);
|
|
72
|
+
if (header.every((byte) => byte === 0)) break;
|
|
73
|
+
const readString = (start, length) => header.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, '');
|
|
74
|
+
const name = readString(0, 100);
|
|
75
|
+
const prefix = readString(345, 155);
|
|
76
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
77
|
+
const sizeText = readString(124, 12).trim().replace(/\0.*$/s, '');
|
|
78
|
+
const size = Number.parseInt(sizeText || '0', 8);
|
|
79
|
+
const type = readString(156, 1);
|
|
80
|
+
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) throw new Error('Downloaded GitHub CLI archive has an invalid tar entry');
|
|
81
|
+
if (!isSafeArchivePath(fullName)) throw new Error('Downloaded GitHub CLI archive contains an unsafe path');
|
|
82
|
+
if (fullName.endsWith(`/bin/${expectedBinary}`) && (type === '' || type === '0')) {
|
|
83
|
+
return Buffer.from(tar.subarray(offset + 512, offset + 512 + size));
|
|
84
|
+
}
|
|
85
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Downloaded GitHub CLI archive did not contain bin/${expectedBinary}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fromZip(archive, expectedBinary) {
|
|
91
|
+
const searchStart = Math.max(0, archive.length - 65_557);
|
|
92
|
+
let endOffset = -1;
|
|
93
|
+
for (let cursor = archive.length - 22; cursor >= searchStart; cursor--) {
|
|
94
|
+
if (archive.readUInt32LE(cursor) === 0x06054b50) { endOffset = cursor; break; }
|
|
95
|
+
}
|
|
96
|
+
if (endOffset < 0) throw new Error('Downloaded GitHub CLI archive has no zip directory');
|
|
97
|
+
const entryCount = archive.readUInt16LE(endOffset + 10);
|
|
98
|
+
let offset = archive.readUInt32LE(endOffset + 16);
|
|
99
|
+
for (let entry = 0; entry < entryCount; entry++) {
|
|
100
|
+
if (offset + 46 > archive.length || archive.readUInt32LE(offset) !== 0x02014b50) throw new Error('Downloaded GitHub CLI archive has an invalid zip directory');
|
|
101
|
+
const method = archive.readUInt16LE(offset + 10);
|
|
102
|
+
const compressedSize = archive.readUInt32LE(offset + 20);
|
|
103
|
+
const uncompressedSize = archive.readUInt32LE(offset + 24);
|
|
104
|
+
const nameLength = archive.readUInt16LE(offset + 28);
|
|
105
|
+
const extraLength = archive.readUInt16LE(offset + 30);
|
|
106
|
+
const commentLength = archive.readUInt16LE(offset + 32);
|
|
107
|
+
const externalAttributes = archive.readUInt32LE(offset + 38);
|
|
108
|
+
const localOffset = archive.readUInt32LE(offset + 42);
|
|
109
|
+
if (offset + 46 + nameLength + extraLength + commentLength > archive.length) throw new Error('Downloaded GitHub CLI archive has an invalid zip entry');
|
|
110
|
+
const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
|
111
|
+
if (!isSafeArchivePath(name)) throw new Error('Downloaded GitHub CLI archive contains an unsafe path');
|
|
112
|
+
const unixMode = externalAttributes >>> 16;
|
|
113
|
+
if (name.endsWith(`/bin/${expectedBinary}`) && ((unixMode & 0o170000) === 0 || (unixMode & 0o170000) === 0o100000)) {
|
|
114
|
+
if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Downloaded GitHub CLI archive has an invalid zip entry');
|
|
115
|
+
const localNameLength = archive.readUInt16LE(localOffset + 26);
|
|
116
|
+
const localExtraLength = archive.readUInt16LE(localOffset + 28);
|
|
117
|
+
const start = localOffset + 30 + localNameLength + localExtraLength;
|
|
118
|
+
const end = start + compressedSize;
|
|
119
|
+
if (end > archive.length || uncompressedSize > MAX_ARCHIVE_BYTES) throw new Error('Downloaded GitHub CLI archive has an invalid zip entry size');
|
|
120
|
+
let contents;
|
|
121
|
+
if (method === 0) contents = Buffer.from(archive.subarray(start, end));
|
|
122
|
+
else if (method === 8) contents = zlib.inflateRawSync(archive.subarray(start, end), { maxOutputLength: MAX_ARCHIVE_BYTES });
|
|
123
|
+
else throw new Error(`Unsupported GitHub CLI zip compression method: ${method}`);
|
|
124
|
+
if (contents.length !== uncompressedSize) throw new Error('Downloaded GitHub CLI archive entry size did not match');
|
|
125
|
+
return contents;
|
|
126
|
+
}
|
|
127
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Downloaded GitHub CLI archive did not contain bin/${expectedBinary}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function extractGitHubCliBinary(archive, asset) {
|
|
133
|
+
if (!asset || !Buffer.isBuffer(archive)) throw new Error('A supported release asset and archive buffer are required');
|
|
134
|
+
return asset.format === 'tar.gz' ? fromTarGzip(archive, asset.binary) : fromZip(archive, asset.binary);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function downloadGitHubCli(asset, expectedSha256, fetchArtifact) {
|
|
138
|
+
if (typeof expectedSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedSha256)) {
|
|
139
|
+
throw new Error(`No trusted SHA-256 pin is available for official GitHub CLI artifact ${asset.file}`);
|
|
140
|
+
}
|
|
141
|
+
const url = `https://github.com/cli/cli/releases/download/v${GH_VERSION}/${asset.file}`;
|
|
142
|
+
const response = await fetchArtifact(url, { redirect: 'follow' });
|
|
143
|
+
if (!response.ok) throw new Error(`Official GitHub CLI download failed with HTTP ${response.status}`);
|
|
144
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
145
|
+
if (bytes.length === 0 || bytes.length > MAX_ARCHIVE_BYTES) throw new Error('Official GitHub CLI archive is empty or exceeds the 100 MiB safety limit');
|
|
146
|
+
const actual = createHash('sha256').update(bytes).digest();
|
|
147
|
+
const expected = Buffer.from(expectedSha256, 'hex');
|
|
148
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
149
|
+
throw new Error(`Official GitHub CLI archive SHA-256 mismatch for ${asset.file}; refusing to extract or execute it`);
|
|
150
|
+
}
|
|
151
|
+
return extractGitHubCliBinary(bytes, asset);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function installGitHubCli(binDir, platform, architecture, expectedSha256, fetchArtifact) {
|
|
155
|
+
const asset = githubCliReleaseAsset(platform, architecture);
|
|
156
|
+
if (!asset) return { ok: false, reason: `No supported official GitHub CLI installer for ${platform}/${architecture}`, action: 'Install GitHub CLI from https://cli.github.com/ using a user-writable method without sudo, then rerun Pi Sync.' };
|
|
157
|
+
const executable = path.join(binDir, asset.binary);
|
|
158
|
+
try {
|
|
159
|
+
const existing = await fs.lstat(executable).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
160
|
+
if (existing) return { ok: false, reason: `Refusing to overwrite existing path: ${executable}`, action: `Review ${executable}; move it only if it is not user data, then rerun Pi Sync.` };
|
|
161
|
+
const contents = await downloadGitHubCli(asset, expectedSha256, fetchArtifact);
|
|
162
|
+
await fs.mkdir(binDir, { recursive: true });
|
|
163
|
+
const temporary = `${executable}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
164
|
+
try {
|
|
165
|
+
await fs.writeFile(temporary, contents, { flag: 'wx', mode: 0o700 });
|
|
166
|
+
if (platform !== 'win32') await fs.chmod(temporary, 0o755);
|
|
167
|
+
await fs.link(temporary, executable);
|
|
168
|
+
await fs.rm(temporary);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, executable };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { ok: false, reason: error.code === 'EACCES' || error.code === 'EPERM'
|
|
176
|
+
? `Permission denied while installing into ${binDir}` : error.message,
|
|
177
|
+
action: `Install GitHub CLI from https://cli.github.com/ into a directory writable by this user (no sudo), then rerun Pi Sync.` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createPrerequisiteInspector({
|
|
182
|
+
expectedArtifactSha256 = (asset) => asset.sha256,
|
|
183
|
+
fetchArtifact = (...args) => globalThis.fetch(...args),
|
|
184
|
+
} = {}) {
|
|
185
|
+
return async function inspectRequiredTools(names, { binDir = path.join(os.homedir(), '.local', 'bin'), env = process.env,
|
|
186
|
+
platform = process.platform, architecture = process.arch, install = true } = {}) {
|
|
187
|
+
const tools = validateRequiredTools(names);
|
|
188
|
+
const bin = path.resolve(binDir);
|
|
189
|
+
const toolEnv = { ...env, PATH: `${bin}${path.delimiter}${env.PATH ?? ''}` };
|
|
190
|
+
const command = platform === 'win32' ? 'gh.exe' : 'gh';
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const name of tools) {
|
|
193
|
+
const localExecutable = path.join(bin, platform === 'win32' ? `${name}.exe` : name);
|
|
194
|
+
const localExists = await fs.lstat(localExecutable).then((info) => info.isFile() && !info.isSymbolicLink())
|
|
195
|
+
.catch((error) => ['ENOENT', 'ENOTDIR'].includes(error.code) ? false : Promise.reject(error));
|
|
196
|
+
const version = await runVersion(localExists ? localExecutable : command, toolEnv);
|
|
197
|
+
if (version) {
|
|
198
|
+
results.push({ name, required: true, status: 'available', version, ...(localExists ? { installPath: localExecutable } : {}) });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!install) {
|
|
202
|
+
results.push({ name, required: true, status: 'missing', version: null });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const asset = githubCliReleaseAsset(platform, architecture);
|
|
206
|
+
const result = await installGitHubCli(bin, platform, architecture, asset ? expectedArtifactSha256(asset) : null, fetchArtifact);
|
|
207
|
+
if (!result.ok) {
|
|
208
|
+
results.push({ name, required: true, status: 'failed', version: null, reason: result.reason, action: result.action });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const installedVersion = await runVersion(result.executable, toolEnv);
|
|
212
|
+
if (!installedVersion) {
|
|
213
|
+
results.push({ name, required: true, status: 'failed', version: null,
|
|
214
|
+
reason: 'GitHub CLI was downloaded but could not be executed or did not report a version',
|
|
215
|
+
action: `Check that ${result.executable} is compatible with this machine, then rerun Pi Sync.` });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
results.push({ name, required: true, status: 'installed', version: installedVersion, installPath: result.executable });
|
|
219
|
+
}
|
|
220
|
+
return { tools: results, ok: results.every((tool) => ['available', 'installed'].includes(tool.status)), binDir: bin };
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const inspectRequiredTools = createPrerequisiteInspector();
|