664 lines
21 KiB
JavaScript
Executable File
664 lines
21 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { constants } from "node:fs";
|
|
import {
|
|
access,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
readlink,
|
|
realpath,
|
|
symlink,
|
|
unlink,
|
|
} from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const SCRIPT_ROOT = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
);
|
|
const MANIFEST_PATH = "config/pi-links.json";
|
|
const COMMANDS = new Set(["check", "sync", "prune"]);
|
|
|
|
function usage() {
|
|
return (
|
|
`Usage: node scripts/pi-links.mjs <check|sync|prune> [--dry-run] [--migrate-from /old/repo]\n\n` +
|
|
` check Report missing, stale, or conflicting exposure without writing\n` +
|
|
` sync Create or repair manifest-declared links and local package entries\n` +
|
|
` prune Remove repository-owned links and local package entries not in the manifest\n` +
|
|
` --dry-run Print sync/prune changes without applying them\n` +
|
|
` --migrate-from Explicit old root for exact manifest-link repairs (check/sync only)`
|
|
);
|
|
}
|
|
|
|
function isInside(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return (
|
|
relative === "" ||
|
|
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
);
|
|
}
|
|
|
|
async function pathExists(target) {
|
|
try {
|
|
await access(target, constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function statOrUndefined(target) {
|
|
try {
|
|
return await lstat(target);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function canonical(target) {
|
|
try {
|
|
return await realpath(target);
|
|
} catch {
|
|
return path.resolve(target);
|
|
}
|
|
}
|
|
|
|
function parseSkillName(contents, skillFile) {
|
|
const frontmatter = contents.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
if (!frontmatter) throw new Error(`${skillFile}: missing YAML frontmatter`);
|
|
const match = frontmatter[1].match(
|
|
/^name:\s*(?:"([^"]+)"|'([^']+)'|([^#\r\n]+))\s*$/m,
|
|
);
|
|
const name = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim();
|
|
if (!name) throw new Error(`${skillFile}: missing frontmatter name`);
|
|
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) {
|
|
throw new Error(`${skillFile}: unsafe skill name ${JSON.stringify(name)}`);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
async function discoverSkills(root) {
|
|
const found = [];
|
|
|
|
async function visit(directory) {
|
|
const skillFile = path.join(directory, "SKILL.md");
|
|
if (await pathExists(skillFile)) {
|
|
const name = parseSkillName(await readFile(skillFile, "utf8"), skillFile);
|
|
found.push({ name, source: directory });
|
|
return;
|
|
}
|
|
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
if (
|
|
!entry.isDirectory() ||
|
|
entry.name.startsWith(".") ||
|
|
entry.name === "node_modules"
|
|
)
|
|
continue;
|
|
await visit(path.join(directory, entry.name));
|
|
}
|
|
}
|
|
|
|
await visit(root);
|
|
return found;
|
|
}
|
|
|
|
async function readJson(file) {
|
|
const contents = await readFile(file, "utf8");
|
|
try {
|
|
return JSON.parse(contents);
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`${file}: invalid JSON: ${reason}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
function assertRecord(value, label) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`${label} must be an object`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertStringArray(value, label) {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.some((entry) => typeof entry !== "string")
|
|
) {
|
|
throw new Error(`${label} must be an array of strings`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function loadManifest(repoRoot) {
|
|
const manifestFile = path.join(repoRoot, MANIFEST_PATH);
|
|
const manifest = await readJson(manifestFile);
|
|
if (manifest.version !== 1)
|
|
throw new Error(`${manifestFile}: unsupported version`);
|
|
|
|
const skills = assertRecord(manifest.skills, "skills");
|
|
const extensions = assertRecord(manifest.extensions, "extensions");
|
|
const instructions = assertRecord(manifest.instructions, "instructions");
|
|
assertRecord(skills.projectOnly, "skills.projectOnly");
|
|
assertStringArray(skills.excluded, "skills.excluded");
|
|
assertRecord(extensions.globalSymlinks, "extensions.globalSymlinks");
|
|
assertRecord(
|
|
extensions.globalLocalPackages,
|
|
"extensions.globalLocalPackages",
|
|
);
|
|
assertRecord(instructions.globalSymlinks, "instructions.globalSymlinks");
|
|
if (typeof skills.root !== "string")
|
|
throw new Error("skills.root must be a string");
|
|
|
|
return manifest;
|
|
}
|
|
|
|
async function validateSource(repoRoot, relativeSource, kind) {
|
|
const source = path.resolve(repoRoot, relativeSource);
|
|
if (!isInside(repoRoot, source))
|
|
throw new Error(`${relativeSource}: source escapes repository`);
|
|
const stat = await statOrUndefined(source);
|
|
if (!stat) throw new Error(`${relativeSource}: source does not exist`);
|
|
|
|
if (kind === "skill") {
|
|
if (
|
|
!stat.isDirectory() ||
|
|
!(await pathExists(path.join(source, "SKILL.md")))
|
|
) {
|
|
throw new Error(`${relativeSource}: skill source must contain SKILL.md`);
|
|
}
|
|
} else if (kind === "instruction") {
|
|
if (!stat.isFile() || path.extname(source).toLowerCase() !== ".md") {
|
|
throw new Error(
|
|
`${relativeSource}: instruction source must be a Markdown file`,
|
|
);
|
|
}
|
|
} else if (stat.isDirectory()) {
|
|
if (
|
|
!(await pathExists(path.join(source, "index.ts"))) &&
|
|
!(await pathExists(path.join(source, "index.js")))
|
|
) {
|
|
throw new Error(
|
|
`${relativeSource}: extension directory must contain index.ts or index.js`,
|
|
);
|
|
}
|
|
} else if (!/\.[cm]?[jt]s$/i.test(source)) {
|
|
throw new Error(
|
|
`${relativeSource}: extension file must be JavaScript or TypeScript`,
|
|
);
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
async function resolveDesiredState(options = {}) {
|
|
const repoRoot = path.resolve(options.repoRoot ?? SCRIPT_ROOT);
|
|
const homeDir = path.resolve(options.homeDir ?? os.homedir());
|
|
const agentDir = path.resolve(
|
|
options.agentDir ??
|
|
process.env.PI_CODING_AGENT_DIR ??
|
|
path.join(homeDir, ".pi", "agent"),
|
|
);
|
|
const globalSkillsDir = path.resolve(
|
|
options.globalSkillsDir ?? path.join(homeDir, ".agents", "skills"),
|
|
);
|
|
const globalExtensionsDir = path.resolve(
|
|
options.globalExtensionsDir ?? path.join(agentDir, "extensions"),
|
|
);
|
|
const projectSkillsDir = path.join(repoRoot, ".pi", "skills");
|
|
const settingsFile = path.resolve(
|
|
options.settingsFile ?? path.join(agentDir, "settings.json"),
|
|
);
|
|
const manifest = await loadManifest(repoRoot);
|
|
const instructionDestinations = {
|
|
pi: path.join(agentDir, "AGENTS.md"),
|
|
codex: path.join(homeDir, ".codex", "AGENTS.md"),
|
|
claude: path.join(homeDir, ".claude", "CLAUDE.md"),
|
|
};
|
|
|
|
const skillsRoot = await validateSource(
|
|
repoRoot,
|
|
manifest.skills.root,
|
|
"skill-root",
|
|
).catch(async (error) => {
|
|
const source = path.resolve(repoRoot, manifest.skills.root);
|
|
const stat = await statOrUndefined(source);
|
|
if (!stat?.isDirectory()) throw error;
|
|
return source;
|
|
});
|
|
const discovered = await discoverSkills(skillsRoot);
|
|
const byName = new Map();
|
|
for (const skill of discovered) {
|
|
const previous = byName.get(skill.name);
|
|
if (previous) {
|
|
throw new Error(
|
|
`duplicate skill name ${skill.name}: ${previous.source} and ${skill.source}`,
|
|
);
|
|
}
|
|
byName.set(skill.name, skill);
|
|
}
|
|
|
|
const projectOnly = new Map(Object.entries(manifest.skills.projectOnly));
|
|
const excluded = new Set(manifest.skills.excluded);
|
|
for (const name of [...projectOnly.keys(), ...excluded]) {
|
|
if (!byName.has(name))
|
|
throw new Error(`manifest references unknown skill ${name}`);
|
|
}
|
|
for (const name of projectOnly.keys()) {
|
|
if (excluded.has(name))
|
|
throw new Error(`skill ${name} cannot be both project-only and excluded`);
|
|
}
|
|
|
|
const links = [];
|
|
for (const skill of discovered) {
|
|
if (excluded.has(skill.name)) continue;
|
|
const declaredProjectSource = projectOnly.get(skill.name);
|
|
if (declaredProjectSource !== undefined) {
|
|
const source = await validateSource(
|
|
repoRoot,
|
|
declaredProjectSource,
|
|
"skill",
|
|
);
|
|
if ((await canonical(source)) !== (await canonical(skill.source))) {
|
|
throw new Error(
|
|
`project-only skill ${skill.name} source does not match discovered source`,
|
|
);
|
|
}
|
|
links.push({
|
|
kind: "skill",
|
|
scope: "project",
|
|
name: skill.name,
|
|
source,
|
|
destination: path.join(projectSkillsDir, skill.name),
|
|
relative: true,
|
|
});
|
|
continue;
|
|
}
|
|
links.push({
|
|
kind: "skill",
|
|
scope: "global",
|
|
name: skill.name,
|
|
source: skill.source,
|
|
destination: path.join(globalSkillsDir, skill.name),
|
|
relative: false,
|
|
});
|
|
}
|
|
|
|
const extensionSources = new Set();
|
|
for (const [name, relativeSource] of Object.entries(
|
|
manifest.extensions.globalSymlinks,
|
|
)) {
|
|
const source = await validateSource(repoRoot, relativeSource, "extension");
|
|
const sourceKey = await canonical(source);
|
|
if (extensionSources.has(sourceKey))
|
|
throw new Error(
|
|
`extension source listed more than once: ${relativeSource}`,
|
|
);
|
|
extensionSources.add(sourceKey);
|
|
links.push({
|
|
kind: "extension",
|
|
scope: "global",
|
|
name,
|
|
source,
|
|
destination: path.join(globalExtensionsDir, name),
|
|
relative: false,
|
|
});
|
|
}
|
|
|
|
for (const [name, relativeSource] of Object.entries(
|
|
manifest.instructions.globalSymlinks,
|
|
)) {
|
|
const destination = instructionDestinations[name];
|
|
if (!destination)
|
|
throw new Error(`unknown global instruction target ${name}`);
|
|
const source = await validateSource(
|
|
repoRoot,
|
|
relativeSource,
|
|
"instruction",
|
|
);
|
|
links.push({
|
|
kind: "instruction",
|
|
scope: "global",
|
|
name,
|
|
source,
|
|
destination,
|
|
relative: false,
|
|
type: "file",
|
|
});
|
|
}
|
|
|
|
const packages = [];
|
|
for (const [name, relativeSource] of Object.entries(
|
|
manifest.extensions.globalLocalPackages,
|
|
)) {
|
|
const source = await validateSource(repoRoot, relativeSource, "extension");
|
|
const sourceKey = await canonical(source);
|
|
if (extensionSources.has(sourceKey)) {
|
|
throw new Error(
|
|
`extension ${name} is exposed as both a symlink and local package`,
|
|
);
|
|
}
|
|
extensionSources.add(sourceKey);
|
|
packages.push({ kind: "extension", scope: "global", name, source });
|
|
}
|
|
|
|
links.sort((a, b) => a.destination.localeCompare(b.destination));
|
|
packages.sort((a, b) => a.source.localeCompare(b.source));
|
|
return {
|
|
repoRoot,
|
|
settingsFile,
|
|
managedDirectories: [
|
|
globalSkillsDir,
|
|
globalExtensionsDir,
|
|
projectSkillsDir,
|
|
],
|
|
managedFiles: Object.values(instructionDestinations),
|
|
links,
|
|
packages,
|
|
};
|
|
}
|
|
|
|
function localPackagePath(source, settingsFile) {
|
|
if (typeof source !== "string") return undefined;
|
|
if (/^(?:npm:|git:|https?:|ssh:|git\+|file:)/i.test(source)) return undefined;
|
|
if (!path.isAbsolute(source) && !source.startsWith(".")) return undefined;
|
|
return path.resolve(path.dirname(settingsFile), source);
|
|
}
|
|
|
|
async function readLocalPackages(settingsFile) {
|
|
if (!(await pathExists(settingsFile))) return [];
|
|
const settings = await readJson(settingsFile);
|
|
if (!Array.isArray(settings.packages)) return [];
|
|
return settings.packages
|
|
.map((entry) => (typeof entry === "string" ? entry : entry?.source))
|
|
.map((source) => ({
|
|
source,
|
|
resolved: localPackagePath(source, settingsFile),
|
|
}))
|
|
.filter((entry) => entry.resolved);
|
|
}
|
|
|
|
async function inspectDesiredLink(link, repoRoot, migrateFrom) {
|
|
const stat = await statOrUndefined(link.destination);
|
|
if (!stat) return { type: "create-link", link };
|
|
if (!stat.isSymbolicLink()) {
|
|
return {
|
|
type: "conflict",
|
|
path: link.destination,
|
|
reason: "destination is not a symlink",
|
|
};
|
|
}
|
|
|
|
const rawTarget = await readlink(link.destination);
|
|
const resolvedTarget = path.resolve(
|
|
path.dirname(link.destination),
|
|
rawTarget,
|
|
);
|
|
if ((await canonical(resolvedTarget)) === (await canonical(link.source)))
|
|
return undefined;
|
|
const expectedOldTarget = migrateFrom
|
|
? path.join(migrateFrom, path.relative(repoRoot, link.source))
|
|
: undefined;
|
|
if (isInside(repoRoot, resolvedTarget) || resolvedTarget === expectedOldTarget)
|
|
return { type: "replace-link", link, previousTarget: resolvedTarget };
|
|
return {
|
|
type: "conflict",
|
|
path: link.destination,
|
|
reason: `symlink points outside this repository: ${rawTarget}`,
|
|
};
|
|
}
|
|
|
|
async function discoverStaleLinks(state, desiredDestinations) {
|
|
const stale = [];
|
|
for (const directory of state.managedDirectories) {
|
|
const stat = await statOrUndefined(directory);
|
|
if (!stat?.isDirectory()) continue;
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isSymbolicLink()) continue;
|
|
const destination = path.join(directory, entry.name);
|
|
if (desiredDestinations.has(destination)) continue;
|
|
const rawTarget = await readlink(destination);
|
|
const resolvedTarget = path.resolve(directory, rawTarget);
|
|
if (isInside(state.repoRoot, resolvedTarget)) {
|
|
stale.push({
|
|
type: "remove-link",
|
|
destination,
|
|
previousTarget: resolvedTarget,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const destination of state.managedFiles) {
|
|
if (desiredDestinations.has(destination)) continue;
|
|
const stat = await statOrUndefined(destination);
|
|
if (!stat?.isSymbolicLink()) continue;
|
|
const rawTarget = await readlink(destination);
|
|
const resolvedTarget = path.resolve(path.dirname(destination), rawTarget);
|
|
if (isInside(state.repoRoot, resolvedTarget)) {
|
|
stale.push({
|
|
type: "remove-link",
|
|
destination,
|
|
previousTarget: resolvedTarget,
|
|
});
|
|
}
|
|
}
|
|
return stale;
|
|
}
|
|
|
|
export async function buildPlan(options = {}) {
|
|
const state = await resolveDesiredState(options);
|
|
let migrateFrom;
|
|
if (options.migrateFrom !== undefined) {
|
|
if (typeof options.migrateFrom !== "string" || !path.isAbsolute(options.migrateFrom))
|
|
throw new Error("migrate-from must be an absolute old repository root");
|
|
migrateFrom = path.resolve(options.migrateFrom);
|
|
if (isInside(migrateFrom, state.repoRoot) || isInside(state.repoRoot, migrateFrom))
|
|
throw new Error("migrate-from must be separate from the current repository root");
|
|
}
|
|
const desiredDestinations = new Set(
|
|
state.links.map((link) => link.destination),
|
|
);
|
|
const desiredPackageSources = new Set(
|
|
await Promise.all(state.packages.map((entry) => canonical(entry.source))),
|
|
);
|
|
const operations = [];
|
|
const conflicts = [];
|
|
|
|
for (const link of state.links) {
|
|
const result = await inspectDesiredLink(link, state.repoRoot, migrateFrom);
|
|
if (result?.type === "conflict") conflicts.push(result);
|
|
else if (result) operations.push(result);
|
|
}
|
|
|
|
operations.push(...(await discoverStaleLinks(state, desiredDestinations)));
|
|
|
|
const installedPackages = await readLocalPackages(state.settingsFile);
|
|
const installedCounts = new Map();
|
|
for (const entry of installedPackages) {
|
|
const key = await canonical(entry.resolved);
|
|
installedCounts.set(key, (installedCounts.get(key) ?? 0) + 1);
|
|
}
|
|
for (const [source, count] of installedCounts) {
|
|
if (count > 1 && isInside(state.repoRoot, source)) {
|
|
conflicts.push({
|
|
type: "conflict",
|
|
path: state.settingsFile,
|
|
reason: `local package appears ${count} times: ${source}`,
|
|
});
|
|
}
|
|
}
|
|
for (const pkg of state.packages) {
|
|
const source = await canonical(pkg.source);
|
|
if (!installedCounts.has(source))
|
|
operations.push({ type: "install-package", package: pkg });
|
|
}
|
|
for (const entry of installedPackages) {
|
|
const source = await canonical(entry.resolved);
|
|
if (
|
|
isInside(state.repoRoot, source) &&
|
|
!desiredPackageSources.has(source)
|
|
) {
|
|
operations.push({ type: "remove-package", source });
|
|
}
|
|
}
|
|
|
|
operations.sort((a, b) =>
|
|
describeOperation(a).localeCompare(describeOperation(b)),
|
|
);
|
|
conflicts.sort((a, b) =>
|
|
`${a.path}: ${a.reason}`.localeCompare(`${b.path}: ${b.reason}`),
|
|
);
|
|
return { state, operations, conflicts };
|
|
}
|
|
|
|
function describeOperation(operation) {
|
|
switch (operation.type) {
|
|
case "create-link":
|
|
return `create ${operation.link.destination} -> ${operation.link.source}`;
|
|
case "replace-link":
|
|
return `repair ${operation.link.destination} -> ${operation.link.source}`;
|
|
case "remove-link":
|
|
return `remove stale link ${operation.destination} -> ${operation.previousTarget}`;
|
|
case "install-package":
|
|
return `install local package ${operation.package.source}`;
|
|
case "remove-package":
|
|
return `remove stale local package ${operation.source}`;
|
|
default:
|
|
return `${operation.type}`;
|
|
}
|
|
}
|
|
|
|
function selectOperations(command, operations) {
|
|
if (command === "sync") {
|
|
return operations.filter(
|
|
(operation) =>
|
|
operation.type === "create-link" ||
|
|
operation.type === "replace-link" ||
|
|
operation.type === "install-package",
|
|
);
|
|
}
|
|
if (command === "prune") {
|
|
return operations.filter(
|
|
(operation) =>
|
|
operation.type === "remove-link" || operation.type === "remove-package",
|
|
);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async function createOrReplaceLink(operation) {
|
|
const { link } = operation;
|
|
await mkdir(path.dirname(link.destination), { recursive: true });
|
|
if (operation.type === "replace-link") {
|
|
const current = await statOrUndefined(link.destination);
|
|
if (!current?.isSymbolicLink() ||
|
|
path.resolve(path.dirname(link.destination), await readlink(link.destination)) !== operation.previousTarget)
|
|
throw new Error(`${link.destination}: destination changed after planning; refusing replacement`);
|
|
await unlink(link.destination);
|
|
}
|
|
const target = link.relative
|
|
? path.relative(path.dirname(link.destination), link.source)
|
|
: link.source;
|
|
await symlink(target, link.destination, link.type ?? "dir");
|
|
}
|
|
|
|
function runPiPackage(command, source) {
|
|
const result = spawnSync("pi", [command, source], { stdio: "inherit" });
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0)
|
|
throw new Error(
|
|
`pi ${command} failed for ${source} with exit code ${result.status}`,
|
|
);
|
|
}
|
|
|
|
async function applyOperations(operations) {
|
|
for (const operation of operations) {
|
|
if (operation.type === "create-link" || operation.type === "replace-link") {
|
|
await createOrReplaceLink(operation);
|
|
} else if (operation.type === "remove-link") {
|
|
await unlink(operation.destination);
|
|
} else if (operation.type === "install-package") {
|
|
runPiPackage("install", operation.package.source);
|
|
} else if (operation.type === "remove-package") {
|
|
runPiPackage("remove", operation.source);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function run(command, options = {}) {
|
|
if (!COMMANDS.has(command)) throw new Error("unknown link-manager command");
|
|
if (command === "prune" && options.migrateFrom !== undefined)
|
|
throw new Error("migrate-from is only supported for check/sync; it never authorizes pruning");
|
|
const plan = await buildPlan(options);
|
|
for (const conflict of plan.conflicts)
|
|
console.error(`CONFLICT ${conflict.path}: ${conflict.reason}`);
|
|
if (plan.conflicts.length > 0) return 2;
|
|
|
|
if (command === "check") {
|
|
if (plan.operations.length === 0) {
|
|
console.log(
|
|
`OK ${plan.state.links.length} links and ${plan.state.packages.length} local package entries match ${MANIFEST_PATH}`,
|
|
);
|
|
return 0;
|
|
}
|
|
for (const operation of plan.operations)
|
|
console.log(`DRIFT ${describeOperation(operation)}`);
|
|
return 1;
|
|
}
|
|
|
|
const selected = selectOperations(command, plan.operations);
|
|
if (selected.length === 0) {
|
|
console.log(`OK nothing to ${command}`);
|
|
return 0;
|
|
}
|
|
for (const operation of selected)
|
|
console.log(
|
|
`${options.dryRun ? "WOULD" : "APPLY"} ${describeOperation(operation)}`,
|
|
);
|
|
if (!options.dryRun) await applyOperations(selected);
|
|
return 0;
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
const [command, ...args] = argv;
|
|
const options = {};
|
|
let invalid = !COMMANDS.has(command);
|
|
for (let index = 0; index < args.length; index++) {
|
|
const arg = args[index];
|
|
if (arg === "--dry-run" && options.dryRun === undefined) options.dryRun = true;
|
|
else if (arg === "--migrate-from" && options.migrateFrom === undefined &&
|
|
args[index + 1] && !args[index + 1].startsWith("--"))
|
|
options.migrateFrom = args[++index];
|
|
else invalid = true;
|
|
}
|
|
if (invalid) {
|
|
console.error(usage());
|
|
return 2;
|
|
}
|
|
return run(command, options);
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
|
) {
|
|
main()
|
|
.then((code) => {
|
|
process.exitCode = code;
|
|
})
|
|
.catch((error) => {
|
|
console.error(
|
|
`ERROR ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exitCode = 2;
|
|
});
|
|
}
|