mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
Merge branch 'main' into async-file-tools
This commit is contained in:
@@ -57,7 +57,7 @@ if [[ -n "$PLATFORM" ]]; then
|
||||
fi
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
|
||||
if [[ "$SKIP_DEPS" == "false" ]]; then
|
||||
echo "==> Installing cross-platform native bindings..."
|
||||
@@ -65,7 +65,7 @@ if [[ "$SKIP_DEPS" == "false" ]]; then
|
||||
# We need all platform bindings for bun cross-compilation
|
||||
# Use --force to bypass platform checks (os/cpu restrictions in package.json)
|
||||
# Install all in one command to avoid npm removing packages from previous installs
|
||||
npm install --no-save --force --ignore-scripts \
|
||||
npm install --no-save --package-lock=false --force --ignore-scripts \
|
||||
@mariozechner/clipboard-darwin-arm64@0.3.2 \
|
||||
@mariozechner/clipboard-darwin-x64@0.3.2 \
|
||||
@mariozechner/clipboard-linux-x64-gnu@0.3.2 \
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const allowValue = process.env.PI_ALLOW_LOCKFILE_CHANGE;
|
||||
const allowed = allowValue === "1" || allowValue === "true" || allowValue === "yes";
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function readJsonFromGit(ref) {
|
||||
try {
|
||||
return JSON.parse(git(["show", ref]));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function packageNameFromLockPath(lockPath) {
|
||||
const marker = "node_modules/";
|
||||
const index = lockPath.lastIndexOf(marker);
|
||||
if (index === -1) return lockPath || "<root>";
|
||||
const parts = lockPath.slice(index + marker.length).split("/");
|
||||
return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
||||
}
|
||||
|
||||
function packageLabel(lockPath, entry) {
|
||||
const name = entry?.name ?? packageNameFromLockPath(lockPath);
|
||||
return entry?.version ? `${name}@${entry.version}` : name;
|
||||
}
|
||||
|
||||
function summarizeLockfileChange() {
|
||||
const before = readJsonFromGit("HEAD:package-lock.json");
|
||||
const after = readJsonFromGit(":package-lock.json");
|
||||
if (!before?.packages || !after?.packages) return [];
|
||||
|
||||
const changes = [];
|
||||
const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]);
|
||||
for (const lockPath of [...paths].sort()) {
|
||||
if (!lockPath.includes("node_modules/")) continue;
|
||||
const oldEntry = before.packages[lockPath];
|
||||
const newEntry = after.packages[lockPath];
|
||||
if (!oldEntry && newEntry) {
|
||||
changes.push(`added ${packageLabel(lockPath, newEntry)}`);
|
||||
} else if (oldEntry && !newEntry) {
|
||||
changes.push(`removed ${packageLabel(lockPath, oldEntry)}`);
|
||||
} else if (oldEntry?.version !== newEntry?.version) {
|
||||
changes.push(
|
||||
`changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? "<none>"} -> ${newEntry?.version ?? "<none>"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
const stagedFiles = git(["diff", "--cached", "--name-only"])
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!stagedFiles.includes("package-lock.json")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
console.error("package-lock.json is staged; PI_ALLOW_LOCKFILE_CHANGE is set, allowing commit.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("package-lock.json is staged.");
|
||||
console.error("");
|
||||
console.error("Review lockfile changes before committing:");
|
||||
console.error(" - confirm every new/updated package is intentional");
|
||||
console.error(" - confirm npm age gates were active for resolution");
|
||||
console.error(" - review any new lifecycle scripts in the dependency tree");
|
||||
console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed");
|
||||
|
||||
const changes = summarizeLockfileChange();
|
||||
if (changes.length > 0) {
|
||||
console.error("");
|
||||
console.error("Detected package version changes:");
|
||||
for (const change of changes.slice(0, 40)) {
|
||||
console.error(` - ${change}`);
|
||||
}
|
||||
if (changes.length > 40) {
|
||||
console.error(` ... ${changes.length - 40} more`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("");
|
||||
console.error("If this lockfile change is intentional, commit with:");
|
||||
console.error(" PI_ALLOW_LOCKFILE_CHANGE=1 git commit ...");
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"];
|
||||
const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const ignoredDirectories = new Set([".git", "dist", "node_modules"]);
|
||||
const packageJsonFiles = [];
|
||||
|
||||
function collectPackageJsonFiles(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(entry.name)) {
|
||||
collectPackageJsonFiles(join(directory, entry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === "package.json") {
|
||||
packageJsonFiles.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalWorkspaceDependency(name) {
|
||||
return name.startsWith("@earendil-works/pi-");
|
||||
}
|
||||
|
||||
function isNonRegistrySpecifier(specifier) {
|
||||
return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier);
|
||||
}
|
||||
|
||||
function getVersionSpecifier(specifier) {
|
||||
if (!specifier.startsWith("npm:")) return specifier;
|
||||
const aliasTarget = specifier.slice("npm:".length);
|
||||
const versionSeparator = aliasTarget.lastIndexOf("@");
|
||||
if (versionSeparator <= 0) return specifier;
|
||||
return aliasTarget.slice(versionSeparator + 1);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
collectPackageJsonFiles(".");
|
||||
|
||||
for (const file of packageJsonFiles.sort()) {
|
||||
const packageJson = JSON.parse(readFileSync(file, "utf8"));
|
||||
|
||||
for (const section of dependencySections) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies) continue;
|
||||
|
||||
for (const [name, specifier] of Object.entries(dependencies)) {
|
||||
if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue;
|
||||
if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue;
|
||||
failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Direct external dependencies must use exact versions:");
|
||||
for (const failure of failures) console.error(` ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]);
|
||||
const files = [];
|
||||
|
||||
function collectTypescriptFiles(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(entry.name)) {
|
||||
collectTypescriptFiles(join(directory, entry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
|
||||
files.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRelativeJavaScriptSpecifier(specifier) {
|
||||
return /^\.\.?\//.test(specifier) && /\.js(?:[?#].*)?$/.test(specifier);
|
||||
}
|
||||
|
||||
function getImportTypeSpecifier(node) {
|
||||
if (!ts.isLiteralTypeNode(node.argument)) return undefined;
|
||||
if (!ts.isStringLiteralLike(node.argument.literal)) return undefined;
|
||||
return node.argument.literal;
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
collectTypescriptFiles(".");
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const sourceText = readFileSync(file, "utf8");
|
||||
const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true);
|
||||
|
||||
function checkSpecifier(node) {
|
||||
if (!isRelativeJavaScriptSpecifier(node.text)) return;
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
||||
failures.push(`${file}:${line + 1}:${character + 1}: ${node.text}`);
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||
checkSpecifier(node.moduleSpecifier);
|
||||
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||
checkSpecifier(node.moduleSpecifier);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments[0] &&
|
||||
ts.isStringLiteralLike(node.arguments[0])
|
||||
) {
|
||||
checkSpecifier(node.arguments[0]);
|
||||
} else if (ts.isImportTypeNode(node)) {
|
||||
const specifier = getImportTypeSpecifier(node);
|
||||
if (specifier) checkSpecifier(specifier);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Relative .js imports are not allowed in non-declaration .ts files:");
|
||||
for (const failure of failures) console.error(` ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env node
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, posix, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "..");
|
||||
const codingAgentDir = join(repoRoot, "packages/coding-agent");
|
||||
const rootLockfilePath = join(repoRoot, "package-lock.json");
|
||||
const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json");
|
||||
const internalPackagePrefix = "@earendil-works/pi-";
|
||||
const allowedInstallScriptPackages = new Map([
|
||||
["@google/genai@1.52.0", "preinstall is a no-op in the published package"],
|
||||
["koffi@2.16.2", "optional native package ships prebuilt modules used without install scripts"],
|
||||
["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"],
|
||||
]);
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const checkOnly = args.has("--check");
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg !== "--check") {
|
||||
console.error(`Unknown argument: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function packageDependencies(entry) {
|
||||
return {
|
||||
...(entry.dependencies ?? {}),
|
||||
...(entry.optionalDependencies ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sortedObject(object) {
|
||||
return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function sortedPackageEntry(entry) {
|
||||
const fieldOrder = [
|
||||
"name",
|
||||
"version",
|
||||
"resolved",
|
||||
"integrity",
|
||||
"license",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
"bin",
|
||||
"engines",
|
||||
"os",
|
||||
"cpu",
|
||||
"libc",
|
||||
"optional",
|
||||
"hasInstallScript",
|
||||
"deprecated",
|
||||
"funding",
|
||||
];
|
||||
const sorted = {};
|
||||
|
||||
for (const field of fieldOrder) {
|
||||
if (entry[field] !== undefined) {
|
||||
sorted[field] = entry[field];
|
||||
}
|
||||
}
|
||||
for (const [field, value] of Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (sorted[field] === undefined) {
|
||||
sorted[field] = value;
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function copyLockEntry(entry) {
|
||||
const copied = { ...entry };
|
||||
delete copied.dev;
|
||||
delete copied.devOptional;
|
||||
delete copied.extraneous;
|
||||
delete copied.link;
|
||||
return sortedPackageEntry(copied);
|
||||
}
|
||||
|
||||
function copyPackageJsonEntry(packageJson, options) {
|
||||
const entry = options.includeName
|
||||
? { name: packageJson.name, version: packageJson.version }
|
||||
: { version: packageJson.version };
|
||||
|
||||
for (const field of [
|
||||
"license",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
"bin",
|
||||
"engines",
|
||||
"os",
|
||||
"cpu",
|
||||
"libc",
|
||||
]) {
|
||||
if (packageJson[field] !== undefined) {
|
||||
entry[field] = packageJson[field];
|
||||
}
|
||||
}
|
||||
|
||||
return sortedPackageEntry(entry);
|
||||
}
|
||||
|
||||
function packageNameFromLockPath(lockPath) {
|
||||
const marker = "node_modules/";
|
||||
const index = lockPath.lastIndexOf(marker);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = lockPath.slice(index + marker.length).split("/");
|
||||
if (parts[0]?.startsWith("@")) {
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
function registryTarballUrl(packageName, version) {
|
||||
const tarballName = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
|
||||
return `https://registry.npmjs.org/${packageName}/-/${tarballName}-${version}.tgz`;
|
||||
}
|
||||
|
||||
function getInternalWorkspaces(lockPackages) {
|
||||
const workspaces = new Map();
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(lockPackages)) {
|
||||
if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.startsWith(internalPackagePrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
workspaces.set(entry.name, {
|
||||
lockPath,
|
||||
packageJson: readJson(join(repoRoot, lockPath, "package.json")),
|
||||
});
|
||||
}
|
||||
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
function resolveExternalDependency(lockPackages, packageName, fromLockPath) {
|
||||
const candidateDirs = [];
|
||||
let current = fromLockPath;
|
||||
|
||||
while (current) {
|
||||
candidateDirs.push(current);
|
||||
const parent = posix.dirname(current);
|
||||
if (parent === "." || parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
candidateDirs.push("");
|
||||
|
||||
const tried = new Set();
|
||||
for (const directory of candidateDirs) {
|
||||
const candidate = directory ? `${directory}/node_modules/${packageName}` : `node_modules/${packageName}`;
|
||||
if (tried.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
tried.add(candidate);
|
||||
|
||||
const entry = lockPackages[candidate];
|
||||
if (entry && !entry.link) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = `node_modules/${packageName}`;
|
||||
const matches = Object.entries(lockPackages)
|
||||
.filter(([lockPath, entry]) => !entry.link && (lockPath === suffix || lockPath.endsWith(`/${suffix}`)))
|
||||
.map(([lockPath]) => lockPath);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cannot resolve ${packageName} from ${fromLockPath || "root"}. ` +
|
||||
(matches.length > 1 ? `Matches: ${matches.join(", ")}` : "No matching lockfile entry found."),
|
||||
);
|
||||
}
|
||||
|
||||
function addInternalWorkspace(shrinkwrapPackages, addedPaths, queue, name, workspace) {
|
||||
const packageJson = workspace.packageJson;
|
||||
const outputPath = `node_modules/${name}`;
|
||||
const entry = copyPackageJsonEntry(packageJson, { includeName: false });
|
||||
entry.resolved = registryTarballUrl(name, packageJson.version);
|
||||
|
||||
shrinkwrapPackages[outputPath] = sortedPackageEntry(entry);
|
||||
addedPaths.add(outputPath);
|
||||
|
||||
for (const dependencyName of Object.keys(packageDependencies(packageJson))) {
|
||||
queue.push({ name: dependencyName, from: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
function addExternalPackage(lockPackages, shrinkwrapPackages, addedPaths, queue, name, from) {
|
||||
const lockPath = resolveExternalDependency(lockPackages, name, from);
|
||||
if (addedPaths.has(lockPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = lockPackages[lockPath];
|
||||
shrinkwrapPackages[lockPath] = copyLockEntry(entry);
|
||||
addedPaths.add(lockPath);
|
||||
|
||||
for (const dependencyName of Object.keys(packageDependencies(entry))) {
|
||||
queue.push({ name: dependencyName, from: lockPath });
|
||||
}
|
||||
}
|
||||
|
||||
function validateShrinkwrap(shrinkwrap, internalNames) {
|
||||
const errors = [];
|
||||
const includedPaths = new Set(Object.keys(shrinkwrap.packages));
|
||||
const includedPackageNames = new Set();
|
||||
const seenAllowedInstallScriptPackages = new Set();
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) {
|
||||
const packageName = packageNameFromLockPath(lockPath);
|
||||
if (packageName) {
|
||||
includedPackageNames.add(packageName);
|
||||
}
|
||||
if (entry.link) {
|
||||
errors.push(`${lockPath} is a link entry`);
|
||||
}
|
||||
if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) {
|
||||
errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`);
|
||||
}
|
||||
if (entry.hasInstallScript) {
|
||||
if (!packageName || !entry.version) {
|
||||
errors.push(`${lockPath || "root"} has install scripts but no package name/version`);
|
||||
} else {
|
||||
const packageId = `${packageName}@${entry.version}`;
|
||||
if (allowedInstallScriptPackages.has(packageId)) {
|
||||
seenAllowedInstallScriptPackages.add(packageId);
|
||||
} else {
|
||||
errors.push(
|
||||
`${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageId of allowedInstallScriptPackages.keys()) {
|
||||
if (!seenAllowedInstallScriptPackages.has(packageId)) {
|
||||
errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of internalNames) {
|
||||
if (!includedPackageNames.has(name)) {
|
||||
errors.push(`internal dependency ${name} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) {
|
||||
for (const dependencyName of Object.keys(packageDependencies(entry))) {
|
||||
const dependencyIncluded = [...includedPaths].some(
|
||||
(candidate) => candidate === `node_modules/${dependencyName}` || candidate.endsWith(`/node_modules/${dependencyName}`),
|
||||
);
|
||||
if (!dependencyIncluded) {
|
||||
errors.push(`${lockPath || "root"} dependency ${dependencyName} is missing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length;
|
||||
if (platformPackageCount === 0) {
|
||||
errors.push("no platform-specific optional dependency entries found");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Generated shrinkwrap failed validation:\n${errors.map((error) => ` - ${error}`).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateShrinkwrap() {
|
||||
const rootLock = readJson(rootLockfilePath);
|
||||
if (rootLock.lockfileVersion !== 3 || !rootLock.packages) {
|
||||
throw new Error("package-lock.json must be lockfileVersion 3 and contain a packages map");
|
||||
}
|
||||
|
||||
const lockPackages = rootLock.packages;
|
||||
const codingAgentPackage = readJson(join(codingAgentDir, "package.json"));
|
||||
const internalWorkspaces = getInternalWorkspaces(lockPackages);
|
||||
const shrinkwrapPackages = {
|
||||
"": copyPackageJsonEntry(codingAgentPackage, { includeName: true }),
|
||||
};
|
||||
const addedPaths = new Set([""]);
|
||||
const internalNames = new Set();
|
||||
const queue = Object.keys(packageDependencies(codingAgentPackage)).map((name) => ({ name, from: "" }));
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
|
||||
const workspace = internalWorkspaces.get(item.name);
|
||||
if (workspace) {
|
||||
const outputPath = `node_modules/${item.name}`;
|
||||
internalNames.add(item.name);
|
||||
if (!addedPaths.has(outputPath)) {
|
||||
addInternalWorkspace(shrinkwrapPackages, addedPaths, queue, item.name, workspace);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
addExternalPackage(lockPackages, shrinkwrapPackages, addedPaths, queue, item.name, item.from);
|
||||
}
|
||||
|
||||
const shrinkwrap = {
|
||||
name: codingAgentPackage.name,
|
||||
version: codingAgentPackage.version,
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: sortedObject(shrinkwrapPackages),
|
||||
};
|
||||
|
||||
validateShrinkwrap(shrinkwrap, internalNames);
|
||||
return shrinkwrap;
|
||||
}
|
||||
|
||||
try {
|
||||
const shrinkwrap = generateShrinkwrap();
|
||||
const content = `${JSON.stringify(shrinkwrap, null, "\t")}\n`;
|
||||
|
||||
if (checkOnly) {
|
||||
if (!existsSync(shrinkwrapPath)) {
|
||||
console.error("packages/coding-agent/npm-shrinkwrap.json is missing.");
|
||||
console.error("Run: npm run shrinkwrap:coding-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
const current = readFileSync(shrinkwrapPath, "utf8");
|
||||
if (current !== content) {
|
||||
console.error("packages/coding-agent/npm-shrinkwrap.json is out of date.");
|
||||
console.error("Run: npm run shrinkwrap:coding-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("packages/coding-agent/npm-shrinkwrap.json is up to date.");
|
||||
} else {
|
||||
writeFileSync(shrinkwrapPath, content);
|
||||
const packageCount = Object.keys(shrinkwrap.packages).length - 1;
|
||||
const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length;
|
||||
console.log(
|
||||
`Wrote packages/coding-agent/npm-shrinkwrap.json (${packageCount} packages, ${platformPackageCount} platform-specific).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const packages = [
|
||||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||
];
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node scripts/local-release.mjs [options]
|
||||
|
||||
Builds and packs the publishable packages, then installs the tarballs into an
|
||||
isolated directory outside the repository for local release testing.
|
||||
|
||||
Options:
|
||||
--out <dir> Output directory. Defaults to a new directory under ${tmpdir()}
|
||||
--force Remove --out first if it already exists
|
||||
--skip-check Do not run npm run check before building
|
||||
--skip-install Only create tarballs; do not create isolated installs
|
||||
--skip-bun-install Do not create the isolated Bun install
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const options = { force: false, outDir: undefined, skipBunInstall: false, skipCheck: false, skipInstall: false };
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--force") {
|
||||
options.force = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-check") {
|
||||
options.skipCheck = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-install") {
|
||||
options.skipInstall = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-bun-install") {
|
||||
options.skipBunInstall = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out") {
|
||||
const value = args[++i];
|
||||
if (!value) {
|
||||
throw new Error("--out requires a directory");
|
||||
}
|
||||
options.outDir = value;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
console.log(`$ ${[command, ...args].join(" ")}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: options.capture ? ["inherit", "pipe", "inherit"] : "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${[command, ...args].join(" ")}`);
|
||||
}
|
||||
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function readPackageJson(directory) {
|
||||
return JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
return spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
function isInsidePath(child, parent) {
|
||||
const relativePath = relative(parent, child);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function prepareOutputDirectory(options, repoRoot) {
|
||||
if (!options.outDir) {
|
||||
return mkdtempSync(join(tmpdir(), "pi-local-release-"));
|
||||
}
|
||||
|
||||
const outDir = resolve(options.outDir);
|
||||
|
||||
if (isInsidePath(outDir, repoRoot)) {
|
||||
throw new Error(`Output directory must be outside the repository: ${outDir}`);
|
||||
}
|
||||
|
||||
if (existsSync(outDir)) {
|
||||
if (!options.force) {
|
||||
throw new Error(`Output directory already exists. Use --force to replace it: ${outDir}`);
|
||||
}
|
||||
rmSync(outDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
return outDir;
|
||||
}
|
||||
|
||||
function fileSpecifier(fromDirectory, file) {
|
||||
const relativePath = relative(fromDirectory, file).replaceAll("\\", "/");
|
||||
return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`;
|
||||
}
|
||||
|
||||
function packPackage(pkg, tarballDirectory) {
|
||||
const packageJson = readPackageJson(pkg.directory);
|
||||
if (packageJson.name !== pkg.name) {
|
||||
throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`);
|
||||
}
|
||||
|
||||
const output = run("npm", ["pack", "--json", "--pack-destination", tarballDirectory], {
|
||||
capture: true,
|
||||
cwd: pkg.directory,
|
||||
});
|
||||
const packed = JSON.parse(output)[0];
|
||||
return join(tarballDirectory, packed.filename);
|
||||
}
|
||||
|
||||
const options = parseArgs();
|
||||
const repoRoot = process.cwd();
|
||||
const rootPackageJson = readPackageJson(repoRoot);
|
||||
|
||||
if (rootPackageJson.name !== "pi-monorepo") {
|
||||
throw new Error("Run this script from the repository root");
|
||||
}
|
||||
|
||||
const outDir = prepareOutputDirectory(options, repoRoot);
|
||||
const tarballDirectory = join(outDir, "tarballs");
|
||||
const nodeInstallDirectory = join(outDir, "node");
|
||||
const bunInstallDirectory = join(outDir, "bun");
|
||||
mkdirSync(tarballDirectory, { recursive: true });
|
||||
|
||||
if (!options.skipCheck) {
|
||||
run("npm", ["run", "check"], { cwd: repoRoot });
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
run("npm", ["run", "clean"], { cwd: pkg.directory });
|
||||
run("npm", ["run", "build"], { cwd: pkg.directory });
|
||||
}
|
||||
|
||||
const tarballs = new Map();
|
||||
for (const pkg of packages) {
|
||||
const tarball = packPackage(pkg, tarballDirectory);
|
||||
tarballs.set(pkg.name, tarball);
|
||||
}
|
||||
|
||||
if (!options.skipInstall) {
|
||||
mkdirSync(nodeInstallDirectory, { recursive: true });
|
||||
const dependencies = Object.fromEntries(
|
||||
packages.map((pkg) => [pkg.name, fileSpecifier(nodeInstallDirectory, tarballs.get(pkg.name))]),
|
||||
);
|
||||
const installPackageJson = `${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`;
|
||||
writeFileSync(join(nodeInstallDirectory, "package.json"), installPackageJson);
|
||||
|
||||
run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: nodeInstallDirectory });
|
||||
symlinkSync(join("node_modules", ".bin", "pi"), join(nodeInstallDirectory, "pi"));
|
||||
|
||||
if (!options.skipBunInstall) {
|
||||
if (!commandExists("bun")) {
|
||||
throw new Error("Bun is required for the isolated Bun install. Use --skip-bun-install to skip it.");
|
||||
}
|
||||
mkdirSync(bunInstallDirectory, { recursive: true });
|
||||
const bunDependencies = Object.fromEntries(
|
||||
packages.map((pkg) => [pkg.name, fileSpecifier(bunInstallDirectory, tarballs.get(pkg.name))]),
|
||||
);
|
||||
writeFileSync(join(bunInstallDirectory, "package.json"), `${JSON.stringify({ private: true, dependencies: bunDependencies }, undefined, "\t")}\n`);
|
||||
run("bun", ["install", "--production", "--ignore-scripts"], { cwd: bunInstallDirectory });
|
||||
symlinkSync(join("node_modules", ".bin", "pi"), join(bunInstallDirectory, "pi"));
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nLocal release artifacts created:");
|
||||
console.log(` ${outDir}`);
|
||||
console.log("\nTarballs:");
|
||||
for (const tarball of tarballs.values()) {
|
||||
console.log(` ${tarball}`);
|
||||
}
|
||||
|
||||
if (!options.skipInstall) {
|
||||
console.log("\nIsolated npm install:");
|
||||
console.log(` ${nodeInstallDirectory}`);
|
||||
console.log("\nRun the locally packed npm CLI from outside the repository:");
|
||||
console.log(` ${join(nodeInstallDirectory, "pi")} --help`);
|
||||
|
||||
if (!options.skipBunInstall) {
|
||||
console.log("\nIsolated Bun install:");
|
||||
console.log(` ${bunInstallDirectory}`);
|
||||
console.log("\nRun the locally packed Bun CLI from outside the repository:");
|
||||
console.log(` ${join(bunInstallDirectory, "pi")} --help`);
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -10,10 +10,11 @@
|
||||
* 1. Check for uncommitted changes
|
||||
* 2. Bump version via npm run version:xxx or set an explicit version
|
||||
* 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date
|
||||
* 4. Commit and tag
|
||||
* 5. Publish to npm
|
||||
* 6. Add new [Unreleased] section to changelogs
|
||||
* 7. Commit
|
||||
* 4. Generate the coding-agent npm-shrinkwrap.json
|
||||
* 5. Commit and tag
|
||||
* 6. Publish to npm
|
||||
* 7. Add new [Unreleased] section to changelogs
|
||||
* 8. Commit
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
@@ -90,9 +91,7 @@ function bumpOrSetVersion(target) {
|
||||
}
|
||||
|
||||
console.log(`Setting explicit version (${target})...`);
|
||||
run(
|
||||
`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npx shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install`,
|
||||
);
|
||||
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only`);
|
||||
return getVersion();
|
||||
}
|
||||
|
||||
@@ -164,30 +163,35 @@ console.log("Updating CHANGELOG.md files...");
|
||||
updateChangelogsForRelease(version);
|
||||
console.log();
|
||||
|
||||
// 4. Commit and tag
|
||||
// 4. Generate publish shrinkwrap
|
||||
console.log("Generating coding-agent shrinkwrap...");
|
||||
run("npm run shrinkwrap:coding-agent");
|
||||
console.log();
|
||||
|
||||
// 5. Commit and tag
|
||||
console.log("Committing and tagging...");
|
||||
stageChangedFiles();
|
||||
run(`git commit -m "Release v${version}"`);
|
||||
run(`git tag v${version}`);
|
||||
console.log();
|
||||
|
||||
// 5. Publish
|
||||
// 6. Publish
|
||||
console.log("Publishing to npm...");
|
||||
run("npm run publish");
|
||||
console.log();
|
||||
|
||||
// 6. Add new [Unreleased] sections
|
||||
// 7. Add new [Unreleased] sections
|
||||
console.log("Adding [Unreleased] sections for next cycle...");
|
||||
addUnreleasedSection();
|
||||
console.log();
|
||||
|
||||
// 7. Commit
|
||||
// 8. Commit
|
||||
console.log("Committing changelog updates...");
|
||||
stageChangedFiles();
|
||||
run(`git commit -m "Add [Unreleased] section for next cycle"`);
|
||||
console.log();
|
||||
|
||||
// 8. Push
|
||||
// 9. Push
|
||||
console.log("Pushing to remote...");
|
||||
run("git push origin main");
|
||||
run(`git push origin v${version}`);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extracts session transcripts for a given cwd, splits into context-sized files,
|
||||
* optionally spawns subagents to analyze patterns.
|
||||
*
|
||||
* Usage: npx tsx scripts/session-transcripts.ts [--analyze] [--output <dir>] [cwd]
|
||||
* Usage: node scripts/session-transcripts.ts [--analyze] [--output <dir>] [cwd]
|
||||
* --analyze Spawn pi subagents to analyze each transcript file
|
||||
* --output <dir> Output directory for transcript files (defaults to ./session-transcripts)
|
||||
* cwd Working directory to extract sessions for (defaults to current)
|
||||
@@ -14,7 +14,7 @@ import { spawn } from "child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { homedir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
import { parseSessionEntries, type SessionMessageEntry } from "../packages/coding-agent/src/core/session-manager.js";
|
||||
import { parseSessionEntries, type SessionMessageEntry } from "../packages/coding-agent/src/core/session-manager.ts";
|
||||
import chalk from "chalk";
|
||||
|
||||
const MAX_CHARS_PER_FILE = 100_000; // ~20k tokens, leaving room for prompt + analysis + output
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
|
||||
Reference in New Issue
Block a user