diff --git a/.runseal/deno.json b/.runseal/deno.json index b23e181..2a16131 100644 --- a/.runseal/deno.json +++ b/.runseal/deno.json @@ -1,7 +1,6 @@ { "imports": { - "@/lib/": "./lib/", - "@std/cli/parse-args": "jsr:@std/cli@1.0.30/parse-args" + "@perish/harness": "jsr:@perish/harness@0.1.0-beta.2" }, "compilerOptions": { "strict": true diff --git a/.runseal/deno.lock b/.runseal/deno.lock index a898f56..75929fb 100644 --- a/.runseal/deno.lock +++ b/.runseal/deno.lock @@ -1,16 +1,23 @@ { "version": "5", "specifiers": { + "jsr:@perish/harness@0.1.0-beta.2": "0.1.0-beta.2", "jsr:@std/cli@1.0.30": "1.0.30" }, "jsr": { + "@perish/harness@0.1.0-beta.2": { + "integrity": "f65dcc9330bbee33136682f3d466ff5bc5976a9df8c64707253b12a70c355c03", + "dependencies": [ + "jsr:@std/cli" + ] + }, "@std/cli@1.0.30": { "integrity": "769446536522d0417d7127ebcabcafac1ab0ce6766d0eb3fca1d36326fe98d13" } }, "workspace": { "dependencies": [ - "jsr:@std/cli@1.0.30" + "jsr:@perish/harness@0.1.0-beta.2" ] } } diff --git a/.runseal/lib/cli.ts b/.runseal/lib/cli.ts deleted file mode 100644 index 1c3741f..0000000 --- a/.runseal/lib/cli.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { parseArgs as parseStdArgs } from "@std/cli/parse-args"; -import type { Args, ParseOptions } from "@std/cli/parse-args"; -import { io } from "@/lib/std/io.ts"; -type Options = Omit & { - unknownOptionMessage?: (arg: string) => string; -}; -function parse(args: string[], options: Options = {}): Args { - const { unknownOptionMessage, ...parseOptions } = options; - validate(args, Array.isArray(parseOptions.string) ? parseOptions.string : []); - return parseStdArgs(args, { - "--": true, - ...parseOptions, - unknown: (arg) => unknown(arg, unknownOptionMessage), - }); -} -function unknown(arg: string, message?: (arg: string) => string): boolean { - if (arg.startsWith("-")) { - io.fail(message?.(arg) ?? `unknown option: ${arg}`); - } - return true; -} -function validate(args: string[], names: string[]): void { - const expected = new Set(names); - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === "--") { - return; - } - if (!arg.startsWith("--")) { - continue; - } - const [name, value] = arg.slice(2).split("=", 2); - if (!expected.has(name) || value !== undefined) { - continue; - } - const next = args[index + 1]; - if (next === undefined || next.startsWith("-")) { - io.fail(`missing value for --${name}`); - } - } -} -export const cli = { parse }; - -export class Flags { - constructor(private readonly args: Args) {} - - help(): boolean { - return this.args.help === true || this.args.h === true || this.args._.includes("help"); - } - - positionals(context: string, options: { allowHelp?: boolean } = {}): void { - const extra = this.args._.find((arg) => !(options.allowHelp === true && arg === "help")); - if (extra !== undefined) { - io.fail(`${context}: unexpected argument: ${extra}`); - } - } - - string(name: string, fallback = ""): string { - const value = this.args[name]; - return typeof value === "string" ? value : fallback; - } - - boolean(name: string): boolean { - return this.args[name] === true; - } -} - -export function flags(args: Args): Flags { - return new Flags(args); -} diff --git a/.runseal/lib/negentropy.ts b/.runseal/lib/negentropy.ts deleted file mode 100644 index 503263f..0000000 --- a/.runseal/lib/negentropy.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { bin, exists } from "@/lib/std/cmd.ts"; -import { fs } from "@/lib/std/fs.ts"; -import { io } from "@/lib/std/io.ts"; - -const file = ".runseal/negentropy.version"; - -async function version(): Promise { - const value = (await fs.file.readTextIfExists(file)).trim(); - if (value === "") { - io.fail(`negentropy: missing pinned version in ${file}`); - } - return value; -} - -async function verify(): Promise { - if (!(await exists("negentropy"))) { - io.fail("missing required tool: negentropy"); - } - const expected = await version(); - const actual = await bin("negentropy").text(["--version"]); - if (actual !== `negentropy ${expected}`) { - io.fail(`negentropy: expected ${expected}, got ${actual}`); - } -} - -export const negentropy = { verify, version }; diff --git a/.runseal/lib/std/cmd.ts b/.runseal/lib/std/cmd.ts deleted file mode 100644 index dfb0033..0000000 --- a/.runseal/lib/std/cmd.ts +++ /dev/null @@ -1,125 +0,0 @@ -const decoder = new TextDecoder(); -const encoder = new TextEncoder(); -export type Options = { - cwd?: string; - env?: Record; - stdin?: "inherit" | "null" | "piped"; - stdout?: "inherit" | "null" | "piped"; - stderr?: "inherit" | "null" | "piped"; -}; -const blocked = new Set([ - "DYLD_FALLBACK_LIBRARY_PATH", - "DYLD_INSERT_LIBRARIES", - "DYLD_LIBRARY_PATH", - "LD_PRELOAD", - "LD_LIBRARY_PATH", -]); -class Inherited { - static present(): boolean { - for (const key of blocked) { - if (Deno.env.get(key) !== undefined) { - return true; - } - } - return false; - } - - static sanitize(extra: Record | undefined): Record { - const env = Deno.env.toObject(); - for (const key of blocked) { - delete env[key]; - } - return { ...env, ...(extra ?? {}) }; - } - - static options( - extra: Record | undefined, - ): Pick { - if (this.present()) { - return { clearEnv: true, env: this.sanitize(extra) }; - } - return extra === undefined ? {} : { env: extra }; - } -} -export async function exists(name: string): Promise { - try { - await new Deno.Command(name, { - args: ["--version"], - ...Inherited.options(undefined), - stdin: "null", - stdout: "null", - stderr: "null", - }).output(); - return true; - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - throw err; - } -} - -export class Bin { - constructor(private readonly command: string) {} - - async run(args: string[] = [], options: Options = {}) { - const code = await this.status(args, options); - if (code !== 0) { - Deno.exit(code); - } - } - - async status(args: string[] = [], options: Options = {}) { - const status = await new Deno.Command(this.command, { - args, - cwd: options.cwd, - ...Inherited.options(options.env), - stdin: options.stdin ?? "inherit", - stdout: options.stdout ?? "inherit", - stderr: options.stderr ?? "inherit", - }).spawn().status; - return status.code; - } - - async text(args: string[] = [], options: Omit = {}): Promise { - const output = await new Deno.Command(this.command, { - args, - cwd: options.cwd, - ...Inherited.options(options.env), - stdin: options.stdin ?? "null", - stdout: "piped", - stderr: options.stderr ?? "inherit", - }).output(); - if (!output.success) { - Deno.exit(output.code); - } - return decoder.decode(output.stdout).trimEnd(); - } - - async input( - args: string[], - input: string, - options: Omit = {}, - ): Promise { - const child = new Deno.Command(this.command, { - args, - cwd: options.cwd, - ...Inherited.options(options.env), - stdin: "piped", - stdout: options.stdout ?? "piped", - stderr: options.stderr ?? "inherit", - }).spawn(); - const writer = child.stdin.getWriter(); - await writer.write(encoder.encode(input)); - await writer.close(); - const output = await child.output(); - if (!output.success) { - Deno.exit(output.code); - } - return decoder.decode(output.stdout).trimEnd(); - } -} - -export function bin(command: string): Bin { - return new Bin(command); -} diff --git a/.runseal/lib/std/fs.ts b/.runseal/lib/std/fs.ts deleted file mode 100644 index f28df8a..0000000 --- a/.runseal/lib/std/fs.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { path as stdPath } from "@/lib/std/path.ts"; - -class File { - static async exists(path: string): Promise { - try { - const stat = await Deno.stat(path); - return stat.isFile; - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - throw err; - } - } - - static async chmod(path: string, mode?: string): Promise { - if (mode === undefined || Deno.build.os === "windows") { - return; - } - const parsed = Number.parseInt(mode.replace(/^0o/, ""), 8); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`invalid file mode: ${mode}`); - } - await Deno.chmod(path, parsed); - } - - static async write(path: string, text: string, mode?: string): Promise { - const parent = stdPath.dirname(path); - if (parent !== "") { - await Deno.mkdir(parent, { recursive: true }); - } - await Deno.writeTextFile(path, text); - await File.chmod(path, mode); - } - - static async contains(path: string, needles: string[]): Promise { - const text = await File.read(path); - return needles.some((needle) => text.includes(needle)); - } - - static async backup(path: string): Promise { - const backup = await Backup.next(path); - await Deno.rename(path, backup); - return backup; - } - - static async read(path: string): Promise { - try { - return await Deno.readTextFile(path); - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return ""; - } - throw err; - } - } -} - -class Dir { - static async exists(path: string): Promise { - try { - const stat = await Deno.stat(path); - return stat.isDirectory; - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - throw err; - } - } - - static async ensure(path: string, mode?: string): Promise { - await Deno.mkdir(path, { recursive: true }); - await File.chmod(path, mode); - } -} - -class Backup { - static async next(path: string): Promise { - const { dir, name } = Route.split(path); - const first = Route.join(dir, `${name}.bak`); - if (!(await Route.exists(first))) { - return first; - } - for (let index = 1; index < 1000; index += 1) { - const candidate = Route.join(dir, `${name}.bak.${index}`); - if (!(await Route.exists(candidate))) { - return candidate; - } - } - throw new Error(`too many existing backups for ${path}`); - } -} - -class Route { - static split(path: string): { dir: string; name: string } { - const trimmed = path.replace(/[\\/]+$/g, ""); - const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - const dir = slash < 0 ? "" : trimmed.slice(0, slash); - const name = slash < 0 ? trimmed : trimmed.slice(slash + 1); - if (name === "") { - throw new Error(`invalid path: ${path}`); - } - return { dir, name }; - } - - static join(dir: string, name: string): string { - return dir === "" ? name : stdPath.join(dir, name); - } - - static async exists(path: string): Promise { - try { - await Deno.stat(path); - return true; - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - throw err; - } - } -} - -export const fs = { - file: { - exists: File.exists, - writeText: File.write, - readTextIfExists: File.read, - containsAny: File.contains, - chmodIfUnix: File.chmod, - backup: { - numbered: File.backup, - }, - }, - dir: { - exists: Dir.exists, - ensure: Dir.ensure, - }, -}; diff --git a/.runseal/lib/std/io.ts b/.runseal/lib/std/io.ts deleted file mode 100644 index cc14a4e..0000000 --- a/.runseal/lib/std/io.ts +++ /dev/null @@ -1,18 +0,0 @@ -function print(value = ""): void { - console.log(value); -} - -function error(value: string): void { - console.error(value); -} - -function fail(message: string, code = 1): never { - error(message); - Deno.exit(code); -} - -export const io = { - print, - error, - fail, -}; diff --git a/.runseal/lib/std/json.ts b/.runseal/lib/std/json.ts deleted file mode 100644 index 7be8c32..0000000 --- a/.runseal/lib/std/json.ts +++ /dev/null @@ -1,179 +0,0 @@ -type Value = null | boolean | number | string | Value[] | { [key: string]: Value }; -type Picked = { - current: Value; - input: string; -}; -class Source { - static parse(json: string | Value): Value { - return typeof json === "string" ? JSON.parse(json) as Value : json; - } - - static array(json: string | Value): Value[] { - const value = this.parse(json); - if (!Array.isArray(value)) { - throw new Error("expected JSON array"); - } - return value; - } -} -class Field { - static string(value: Value, field: string): string | undefined { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const selected = value[field]; - if (selected === undefined) { - return undefined; - } - if (selected === null) { - return "null"; - } - if (typeof selected === "string") { - return selected; - } - if (typeof selected === "boolean" || typeof selected === "number") { - return String(selected); - } - return JSON.stringify(selected); - } -} -class Path { - static select(value: Value, path: string): Value { - let input = path.startsWith(".") ? path.slice(1) : path; - if (input === "") { - throw new Error("json path cannot be empty"); - } - let current = value; - while (input !== "") { - if (input.startsWith("[")) { - const picked = index(current, input, path); - current = picked.current; - input = picked.input; - continue; - } - const picked = field(current, input); - current = picked.current; - input = picked.input; - } - return current; - } -} -function index(current: Value, input: string, path: string): Picked { - const end = input.indexOf("]"); - if (end === -1) { - throw new Error(`unsupported json path: ${path}`); - } - const slot = Number(input.slice(1, end)); - if (!Number.isInteger(slot) || slot < 0) { - throw new Error(`invalid json path index: ${input.slice(1, end)}`); - } - if (!Array.isArray(current) || current[slot] === undefined) { - throw new Error("json path missing"); - } - return { current: current[slot], input: rest(input, end + 1) }; -} -function field(current: Value, input: string): Picked { - const dot = input.indexOf("."); - const bracket = input.indexOf("["); - const choices = [dot, bracket].filter((at) => at >= 0); - const end = choices.length === 0 ? input.length : Math.min(...choices); - const key = input.slice(0, end); - if (!/^[A-Za-z0-9_-]+$/.test(key)) { - throw new Error(`unsupported json path field: ${key}`); - } - if (current === null || typeof current !== "object" || Array.isArray(current)) { - throw new Error("json path missing"); - } - const selected = current[key]; - if (selected === undefined) { - throw new Error("json path missing"); - } - return { current: selected, input: rest(input, end) }; -} -function rest(input: string, end: number): string { - const next = input.slice(end); - return next.startsWith(".") ? next.slice(1) : next; -} - -export class Doc { - constructor(private readonly json: string | Value) {} - - get(path: string): string { - const selected = Path.select(Source.parse(this.json), path); - if (selected === null) { - return ""; - } - switch (typeof selected) { - case "string": - return selected; - case "boolean": - case "number": - return String(selected); - case "object": - return JSON.stringify(selected); - } - } - - has(path: string): boolean { - try { - Path.select(Source.parse(this.json), path); - return true; - } catch (err) { - if (err instanceof Error && err.message === "this.json path missing") { - return false; - } - throw err; - } - } - - empty(): boolean { - const value = Source.parse(this.json); - if (value === null) { - return true; - } - if (typeof value === "string" || Array.isArray(value)) { - return value.length === 0; - } - if (typeof value === "object") { - return Object.keys(value).length === 0; - } - return false; - } - - len(): number { - const value = Source.parse(this.json); - if (value === null) { - return 0; - } - if (typeof value === "string" || Array.isArray(value)) { - return value.length; - } - if (typeof value === "object") { - return Object.keys(value).length; - } - return 1; - } - - find(field: string, expected: string): string { - const array = Source.array(this.json); - const found = array.find((item) => Field.string(item, field) === expected); - return found === undefined ? "" : JSON.stringify(found); - } - - filter(field: string, expected: string[]): string { - const array = Source.array(this.json); - const filtered = array.filter((item) => { - const actual = Field.string(item, field); - return actual !== undefined && expected.includes(actual); - }); - return JSON.stringify(filtered); - } - - pretty(): string { - return JSON.stringify(Source.parse(this.json), null, 2); - } -} - -export function doc(json: string | Value): Doc { - return new Doc(json); -} diff --git a/.runseal/lib/std/path.ts b/.runseal/lib/std/path.ts deleted file mode 100644 index 351ede5..0000000 --- a/.runseal/lib/std/path.ts +++ /dev/null @@ -1,33 +0,0 @@ -function join(...parts: string[]): string { - const separator = Deno.build.os === "windows" ? "\\" : "/"; - const joined = parts - .filter((part) => part !== "") - .map((part, index) => - index === 0 ? part.replace(/[\\/]+$/g, "") : part.replace(/^[\\/]+|[\\/]+$/g, "") - ) - .filter((part) => part !== "") - .join(separator); - return joined === "" ? "." : joined; -} - -function dirname(path: string): string { - const trimmed = path.replace(/[\\/]+$/g, ""); - const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - if (slash < 0) { - return ""; - } - if (slash === 0) { - return trimmed.slice(0, 1); - } - return trimmed.slice(0, slash); -} - -function separator(): string { - return Deno.build.os === "windows" ? ";" : ":"; -} - -export const path = { - join, - dirname, - listSeparator: separator, -}; diff --git a/.runseal/wrappers/guard.ts b/.runseal/wrappers/guard.ts index 09d51f1..3152c5c 100644 --- a/.runseal/wrappers/guard.ts +++ b/.runseal/wrappers/guard.ts @@ -1,21 +1,30 @@ -import { cli, flags } from "@/lib/cli.ts"; -import { bin } from "@/lib/std/cmd.ts"; -import { io } from "@/lib/std/io.ts"; -import { negentropy } from "@/lib/negentropy.ts"; +import { cache } from "@perish/harness/cache"; +import { cli, flags } from "@perish/harness/cli"; +import { bin } from "@perish/harness/cmd"; +import { io } from "@perish/harness/io"; +import { negentropy } from "@perish/harness/negentropy"; function usage(): void { - io.print("Usage: runseal :guard"); + io.print("Usage: runseal :guard [--fresh]"); io.print(""); io.print("Run repository guard checks."); + io.print(""); + io.print(" --fresh ignore the guard cache and run the full gauntlet"); } -const args = cli.parse(Deno.args, { boolean: ["help", "h"] }); +const args = cli.parse(Deno.args, { boolean: ["help", "h", "fresh"] }); flags(args).positionals("guard", { allowHelp: true }); if (flags(args).help()) { usage(); Deno.exit(0); } +const mark = await cache.key(); +if (args.fresh !== true && (await cache.hit(mark))) { + io.print(`guard: clean (cached ${mark.slice(0, 12)})`); + Deno.exit(0); +} + io.print("==> cargo fmt"); await bin("cargo").run(["fmt", "--all", "--check"]); @@ -52,3 +61,5 @@ await bin("deno").run([ io.print("==> negentropy"); await negentropy.verify(); await bin("negentropy").run(["--strict", "."]); + +await cache.keep(mark); diff --git a/.runseal/wrappers/init.ts b/.runseal/wrappers/init.ts index 8a259b4..a3de175 100644 --- a/.runseal/wrappers/init.ts +++ b/.runseal/wrappers/init.ts @@ -1,9 +1,9 @@ -import { cli, flags } from "@/lib/cli.ts"; -import { bin, exists } from "@/lib/std/cmd.ts"; -import { fs } from "@/lib/std/fs.ts"; -import { io } from "@/lib/std/io.ts"; -import { negentropy } from "@/lib/negentropy.ts"; -import { path } from "@/lib/std/path.ts"; +import { cli, flags } from "@perish/harness/cli"; +import { bin, exists } from "@perish/harness/cmd"; +import { fs } from "@perish/harness/fs"; +import { io } from "@perish/harness/io"; +import { negentropy } from "@perish/harness/negentropy"; +import { path } from "@perish/harness/path"; class Check { static async tool(name: string): Promise { @@ -64,13 +64,6 @@ for ( ".runseal/deno.json", ".runseal/deno.lock", ".runseal/negentropy.version", - ".runseal/lib/cli.ts", - ".runseal/lib/negentropy.ts", - ".runseal/lib/std/cmd.ts", - ".runseal/lib/std/fs.ts", - ".runseal/lib/std/io.ts", - ".runseal/lib/std/json.ts", - ".runseal/lib/std/path.ts", ".runseal/wrappers/guard.ts", ".runseal/wrappers/init.ts", ".runseal/wrappers/land.ts", diff --git a/.runseal/wrappers/land.ts b/.runseal/wrappers/land.ts index 04be894..4eea492 100644 --- a/.runseal/wrappers/land.ts +++ b/.runseal/wrappers/land.ts @@ -1,7 +1,7 @@ -import { cli, flags } from "@/lib/cli.ts"; -import { bin } from "@/lib/std/cmd.ts"; -import { io } from "@/lib/std/io.ts"; -import { doc } from "@/lib/std/json.ts"; +import { cli, flags } from "@perish/harness/cli"; +import { bin } from "@perish/harness/cmd"; +import { io } from "@perish/harness/io"; +import { doc } from "@perish/harness/json"; type Options = { base: string;