diff --git a/.runseal/deno.json b/.runseal/deno.json index 7bb762d..9a8150c 100644 --- a/.runseal/deno.json +++ b/.runseal/deno.json @@ -1,4 +1,9 @@ { + "imports": { + "@/lib/": "./lib/", + "@deno.land/": "https://deno.land/", + "@std/cli/parse-args": "jsr:@std/cli@1.0.30/parse-args" + }, "compilerOptions": { "strict": true }, diff --git a/.runseal/hooks/pre-commit b/.runseal/hooks/pre-commit index 1521564..d489718 100644 --- a/.runseal/hooks/pre-commit +++ b/.runseal/hooks/pre-commit @@ -4,4 +4,11 @@ set -eu root=$(git rev-parse --show-toplevel) cd "$root" +branch=$(git symbolic-ref --quiet --short HEAD || true) +if [ "$branch" = "main" ]; then + printf '%s\n' "runseal pre-commit: refusing to commit directly on main" >&2 + printf '%s\n' "create a feature branch first, then commit again" >&2 + exit 1 +fi + runseal :guard diff --git a/.runseal/lib/cli.ts b/.runseal/lib/cli.ts new file mode 100644 index 0000000..311e46f --- /dev/null +++ b/.runseal/lib/cli.ts @@ -0,0 +1,68 @@ +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 CliParseOptions = Omit & { + unknownOptionMessage?: (arg: string) => string; +}; + +export function parseArgs(args: string[], options: CliParseOptions = {}): Args { + const { unknownOptionMessage, ...parseOptions } = options; + requireStringValues(args, Array.isArray(parseOptions.string) ? parseOptions.string : []); + return parseStdArgs(args, { + "--": true, + ...parseOptions, + unknown: (arg) => { + if (arg.startsWith("-")) { + io.fail(unknownOptionMessage?.(arg) ?? `unknown option: ${arg}`); + } + return true; + }, + }); +} + +function requireStringValues(args: string[], names: string[]): void { + const stringOptions = 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 (!stringOptions.has(name) || value !== undefined) { + continue; + } + const next = args[index + 1]; + if (next === undefined || next.startsWith("-")) { + io.fail(`missing value for --${name}`); + } + } +} + +export function helpRequested(args: Args): boolean { + return args.help === true || args.h === true || args._.includes("help"); +} + +export function requireNoPositionals( + args: Args, + context: string, + options: { allowHelp?: boolean } = {}, +): void { + const extra = args._.find((arg) => !(options.allowHelp === true && arg === "help")); + if (extra !== undefined) { + io.fail(`${context}: unexpected argument: ${extra}`); + } +} + +export function stringOption(args: Args, name: string, fallback = ""): string { + const value = args[name]; + return typeof value === "string" ? value : fallback; +} + +export function booleanOption(args: Args, name: string): boolean { + return args[name] === true; +} diff --git a/.runseal/lib/hash.ts b/.runseal/lib/hash.ts new file mode 100644 index 0000000..c91b90d --- /dev/null +++ b/.runseal/lib/hash.ts @@ -0,0 +1,77 @@ +const encoder = new TextEncoder(); + +type TreeEntry = { + label: string; + file: string; +}; + +export async function treeHash(paths: string[]): Promise { + if (paths.length === 0) { + throw new Error("treeHash requires at least one path"); + } + const entries: TreeEntry[] = []; + for (const path of paths) { + await collectTree(path, path, entries); + } + entries.sort((left, right) => left.label.localeCompare(right.label)); + + const zero = new Uint8Array([0]); + const parts: Uint8Array[] = []; + for (const entry of entries) { + parts.push( + encoder.encode(normalizePath(entry.label)), + zero, + await Deno.readFile(entry.file), + zero, + ); + } + const payload = concatBytes(parts); + const digestInput = new ArrayBuffer(payload.byteLength); + new Uint8Array(digestInput).set(payload); + const digest = await crypto.subtle.digest("SHA-256", digestInput); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function collectTree(path: string, label: string, entries: TreeEntry[]): Promise { + const stat = await Deno.stat(path); + if (stat.isFile) { + entries.push({ label, file: path }); + return; + } + if (stat.isDirectory) { + for await (const entry of Deno.readDir(path)) { + await collectTree(pathJoin(path, entry.name), pathJoin(label, entry.name), entries); + } + return; + } + throw new Error(`unsupported path for treeHash: ${path}`); +} + +function pathJoin(...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 normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+/g, "/"); +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return output; +} diff --git a/.runseal/lib/runseal.ts b/.runseal/lib/runseal.ts deleted file mode 100644 index 9a809b2..0000000 --- a/.runseal/lib/runseal.ts +++ /dev/null @@ -1,201 +0,0 @@ -const decoder = new TextDecoder(); -const encoder = new TextEncoder(); - -export type CommandOptions = { - cwd?: string; - env?: Record; - stdin?: "inherit" | "null" | "piped"; - stdout?: "inherit" | "null" | "piped"; - stderr?: "inherit" | "null" | "piped"; -}; - -const blockedInheritedEnv = new Set([ - "DYLD_FALLBACK_LIBRARY_PATH", - "DYLD_INSERT_LIBRARIES", - "DYLD_LIBRARY_PATH", - "LD_PRELOAD", - "LD_LIBRARY_PATH", -]); - -function hasBlockedInheritedEnv(): boolean { - for (const key of blockedInheritedEnv) { - if (Deno.env.get(key) !== undefined) { - return true; - } - } - return false; -} - -function sanitizedEnv(extra: Record | undefined): Record { - const env = Deno.env.toObject(); - for (const key of blockedInheritedEnv) { - delete env[key]; - } - return { ...env, ...(extra ?? {}) }; -} - -function commandEnvOptions( - extra: Record | undefined, -): Pick { - if (hasBlockedInheritedEnv()) { - return { clearEnv: true, env: sanitizedEnv(extra) }; - } - return extra === undefined ? {} : { env: extra }; -} - -export function print(value = ""): void { - console.log(value); -} - -export function error(value: string): void { - console.error(value); -} - -export function fail(message: string, code = 1): never { - error(message); - Deno.exit(code); -} - -export function env(name: string, fallback = ""): string { - return Deno.env.get(name) ?? fallback; -} - -export function requireEnv(name: string): string { - const value = Deno.env.get(name); - if (value === undefined || value === "") { - fail(`missing required env: ${name}`); - } - return value; -} - -export function isHelp(args: string[]): boolean { - return args.length === 1 && ["-h", "--help", "help"].includes(args[0]); -} - -export async function run(command: string, args: string[] = [], options: CommandOptions = {}) { - const status = await new Deno.Command(command, { - args, - cwd: options.cwd, - ...commandEnvOptions(options.env), - stdin: options.stdin ?? "inherit", - stdout: options.stdout ?? "inherit", - stderr: options.stderr ?? "inherit", - }).spawn().status; - if (!status.success) { - Deno.exit(status.code); - } -} - -export async function runText( - command: string, - args: string[] = [], - options: Omit = {}, -): Promise { - const output = await new Deno.Command(command, { - args, - cwd: options.cwd, - ...commandEnvOptions(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(); -} - -export async function runInput( - command: string, - args: string[], - input: string, - options: Omit = {}, -): Promise { - const child = new Deno.Command(command, { - args, - cwd: options.cwd, - ...commandEnvOptions(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 async function runsealText(args: string[]): Promise { - return await runText("runseal", args); -} - -export async function runseal(args: string[]): Promise { - await run("runseal", args); -} - -export async function commandExists(name: string): Promise { - return (await runsealText(["@tool", "process", "exists", name])) === "true"; -} - -export async function jsonGet(json: string, path: string): Promise { - return await runsealText(["@tool", "json", "get", json, path]); -} - -export async function jsonEmpty(json: string): Promise { - return (await runsealText(["@tool", "json", "empty", json])) === "true"; -} - -export async function fileExists(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; - } -} - -export async function dirExists(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; - } -} - -export function pathJoin(...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; -} - -export function pathListSeparator(): string { - return Deno.build.os === "windows" ? ";" : ":"; -} - -export async function readTextIfExists(path: string): Promise { - try { - return await Deno.readTextFile(path); - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return ""; - } - throw err; - } -} diff --git a/.runseal/lib/std/cmd.ts b/.runseal/lib/std/cmd.ts new file mode 100644 index 0000000..8fdac2f --- /dev/null +++ b/.runseal/lib/std/cmd.ts @@ -0,0 +1,126 @@ +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +export type CommandOptions = { + cwd?: string; + env?: Record; + stdin?: "inherit" | "null" | "piped"; + stdout?: "inherit" | "null" | "piped"; + stderr?: "inherit" | "null" | "piped"; +}; + +const blockedInheritedEnv = new Set([ + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_PRELOAD", + "LD_LIBRARY_PATH", +]); + +function hasBlockedInheritedEnv(): boolean { + for (const key of blockedInheritedEnv) { + if (Deno.env.get(key) !== undefined) { + return true; + } + } + return false; +} + +function sanitizedEnv(extra: Record | undefined): Record { + const env = Deno.env.toObject(); + for (const key of blockedInheritedEnv) { + delete env[key]; + } + return { ...env, ...(extra ?? {}) }; +} + +function envOptions( + extra: Record | undefined, +): Pick { + if (hasBlockedInheritedEnv()) { + return { clearEnv: true, env: sanitizedEnv(extra) }; + } + return extra === undefined ? {} : { env: extra }; +} + +async function run(command: string, args: string[] = [], options: CommandOptions = {}) { + const status = await new Deno.Command(command, { + args, + cwd: options.cwd, + ...envOptions(options.env), + stdin: options.stdin ?? "inherit", + stdout: options.stdout ?? "inherit", + stderr: options.stderr ?? "inherit", + }).spawn().status; + if (!status.success) { + Deno.exit(status.code); + } +} + +async function text( + command: string, + args: string[] = [], + options: Omit = {}, +): Promise { + const output = await new Deno.Command(command, { + args, + cwd: options.cwd, + ...envOptions(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 function input( + command: string, + args: string[], + input: string, + options: Omit = {}, +): Promise { + const child = new Deno.Command(command, { + args, + cwd: options.cwd, + ...envOptions(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(); +} + +async function exists(name: string): Promise { + try { + await new Deno.Command(name, { + args: ["--version"], + ...envOptions(undefined), + stdin: "null", + stdout: "null", + stderr: "null", + }).output(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return false; + } + throw err; + } +} + +export const cmd = { + run, + text, + input, + exists, +}; diff --git a/.runseal/lib/std/env.ts b/.runseal/lib/std/env.ts new file mode 100644 index 0000000..0e2eb68 --- /dev/null +++ b/.runseal/lib/std/env.ts @@ -0,0 +1,18 @@ +import { io } from "@/lib/std/io.ts"; + +function get(name: string, fallback = ""): string { + return Deno.env.get(name) ?? fallback; +} + +function requireValue(name: string): string { + const value = Deno.env.get(name); + if (value === undefined || value === "") { + return io.fail(`missing required env: ${name}`); + } + return value; +} + +export const env = { + get, + require: requireValue, +}; diff --git a/.runseal/lib/std/fs.ts b/.runseal/lib/std/fs.ts new file mode 100644 index 0000000..f701d46 --- /dev/null +++ b/.runseal/lib/std/fs.ts @@ -0,0 +1,131 @@ +import { path as stdPath } from "@/lib/std/path.ts"; + +async function fileExists(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; + } +} + +async function dirExists(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; + } +} + +async function dirEnsure(path: string, mode?: string): Promise { + await Deno.mkdir(path, { recursive: true }); + await chmodIfUnix(path, mode); +} + +async function chmodIfUnix(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); +} + +async function writeText(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 chmodIfUnix(path, mode); +} + +async function containsAny(path: string, needles: string[]): Promise { + const text = await readTextIfExists(path); + return needles.some((needle) => text.includes(needle)); +} + +async function backupNumbered(path: string): Promise { + const backup = await nextBackupPath(path); + await Deno.rename(path, backup); + return backup; +} + +async function readTextIfExists(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return ""; + } + throw err; + } +} + +async function nextBackupPath(path: string): Promise { + const { dir, name } = splitPath(path); + const first = pathWithFileName(dir, `${name}.bak`); + if (!(await pathExists(first))) { + return first; + } + for (let index = 1; index < 1000; index += 1) { + const candidate = pathWithFileName(dir, `${name}.bak.${index}`); + if (!(await pathExists(candidate))) { + return candidate; + } + } + throw new Error(`too many existing backups for ${path}`); +} + +function splitPath(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 }; +} + +function pathWithFileName(dir: string, name: string): string { + return dir === "" ? name : stdPath.join(dir, name); +} + +async function pathExists(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: fileExists, + writeText, + readTextIfExists, + containsAny, + chmodIfUnix, + backup: { + numbered: backupNumbered, + }, + }, + dir: { + exists: dirExists, + ensure: dirEnsure, + }, +}; diff --git a/.runseal/lib/std/io.ts b/.runseal/lib/std/io.ts new file mode 100644 index 0000000..cc14a4e --- /dev/null +++ b/.runseal/lib/std/io.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..f01aa14 --- /dev/null +++ b/.runseal/lib/std/json.ts @@ -0,0 +1,168 @@ +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +function parseInput(json: string | JsonValue): JsonValue { + return typeof json === "string" ? JSON.parse(json) as JsonValue : json; +} + +function get(json: string | JsonValue, path: string): string { + const selected = selectPath(parseInput(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); + } +} + +function has(json: string | JsonValue, path: string): boolean { + try { + selectPath(parseInput(json), path); + return true; + } catch (err) { + if (err instanceof Error && err.message === "json path missing") { + return false; + } + throw err; + } +} + +function empty(json: string | JsonValue): boolean { + const value = parseInput(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; +} + +function len(json: string | JsonValue): number { + const value = parseInput(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; +} + +function find(json: string | JsonValue, field: string, expected: string): string { + const array = parseArray(json); + const found = array.find((item) => fieldString(item, field) === expected); + return found === undefined ? "" : JSON.stringify(found); +} + +function filter(json: string | JsonValue, field: string, expected: string[]): string { + const array = parseArray(json); + const filtered = array.filter((item) => { + const actual = fieldString(item, field); + return actual !== undefined && expected.includes(actual); + }); + return JSON.stringify(filtered); +} + +function pretty(json: string | JsonValue): string { + return JSON.stringify(parseInput(json), null, 2); +} + +function parseArray(json: string | JsonValue): JsonValue[] { + const value = parseInput(json); + if (!Array.isArray(value)) { + throw new Error("expected JSON array"); + } + return value; +} + +function fieldString(value: JsonValue, field: string): string | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const fieldValue = value[field]; + if (fieldValue === undefined) { + return undefined; + } + if (fieldValue === null) { + return "null"; + } + if (typeof fieldValue === "string") { + return fieldValue; + } + if (typeof fieldValue === "boolean" || typeof fieldValue === "number") { + return String(fieldValue); + } + return JSON.stringify(fieldValue); +} + +function selectPath(value: JsonValue, path: string): JsonValue { + 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 end = input.indexOf("]"); + if (end === -1) { + throw new Error(`unsupported json path: ${path}`); + } + const index = Number(input.slice(1, end)); + if (!Number.isInteger(index) || index < 0) { + throw new Error(`invalid json path index: ${input.slice(1, end)}`); + } + if (!Array.isArray(current) || current[index] === undefined) { + throw new Error("json path missing"); + } + current = current[index]; + input = input.slice(end + 1); + if (input.startsWith(".")) { + input = input.slice(1); + } + continue; + } + const dot = input.indexOf("."); + const bracket = input.indexOf("["); + const candidates = [dot, bracket].filter((index) => index >= 0); + const end = candidates.length === 0 ? input.length : Math.min(...candidates); + const field = input.slice(0, end); + if (!/^[A-Za-z0-9_-]+$/.test(field)) { + throw new Error(`unsupported json path field: ${field}`); + } + if (current === null || typeof current !== "object" || Array.isArray(current)) { + throw new Error("json path missing"); + } + const selected = current[field]; + if (selected === undefined) { + throw new Error("json path missing"); + } + current = selected; + input = input.slice(end); + if (input.startsWith(".")) { + input = input.slice(1); + } + } + return current; +} + +export const json = { + get, + has, + empty, + len, + find, + filter, + pretty, +}; diff --git a/.runseal/lib/std/path.ts b/.runseal/lib/std/path.ts new file mode 100644 index 0000000..3157395 --- /dev/null +++ b/.runseal/lib/std/path.ts @@ -0,0 +1,33 @@ +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 listSeparator(): string { + return Deno.build.os === "windows" ? ";" : ":"; +} + +export const path = { + join, + dirname, + listSeparator, +}; diff --git a/.runseal/lib/std/runseal.ts b/.runseal/lib/std/runseal.ts new file mode 100644 index 0000000..5a953ff --- /dev/null +++ b/.runseal/lib/std/runseal.ts @@ -0,0 +1,14 @@ +import { cmd } from "@/lib/std/cmd.ts"; + +async function run(args: string[]): Promise { + await cmd.run("runseal", args); +} + +async function text(args: string[]): Promise { + return await cmd.text("runseal", args); +} + +export const runseal = { + run, + text, +}; diff --git a/.runseal/lib/version.ts b/.runseal/lib/version.ts new file mode 100644 index 0000000..d1dfcc3 --- /dev/null +++ b/.runseal/lib/version.ts @@ -0,0 +1,34 @@ +export type StableVersion = { + major: number; + minor: number; + patch: number; +}; + +export function parseStableVersion(version: string): StableVersion { + const value = version.startsWith("v") ? version.slice(1) : version; + const parts = value.split("."); + if (parts.length !== 3) { + throw new Error(`expected stable semantic version, got ${version}`); + } + const [major, minor, patch] = parts.map((part) => { + if (!/^[0-9]+$/.test(part)) { + throw new Error(`invalid stable semantic version, got ${version}`); + } + return Number(part); + }); + return { major, minor, patch }; +} + +export function compareStableVersion(left: string, right: string): "lt" | "eq" | "gt" { + const leftParsed = parseStableVersion(left); + const rightParsed = parseStableVersion(right); + for (const key of ["major", "minor", "patch"] as const) { + if (leftParsed[key] < rightParsed[key]) { + return "lt"; + } + if (leftParsed[key] > rightParsed[key]) { + return "gt"; + } + } + return "eq"; +} diff --git a/.runseal/templates/cloudflare.env b/.runseal/templates/cloudflare.env new file mode 100644 index 0000000..50b54f6 --- /dev/null +++ b/.runseal/templates/cloudflare.env @@ -0,0 +1,8 @@ +# Repo-local Cloudflare credentials for runseal support commands. +# Fill these values manually. This file stays local and gitignored. +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ZONE_NAME=perish.uk +CLOUDFLARE_MANAGE_HOST=runseal.perish.uk +CLOUDFLARE_MANAGE_ORIGIN_HOST=releases.runseal.perish.uk +CLOUDFLARE_MANAGE_REDIRECT_PREFIX= diff --git a/.runseal/wrappers/cloudflare.ts b/.runseal/wrappers/cloudflare.ts index 6500e2c..7917e6c 100644 --- a/.runseal/wrappers/cloudflare.ts +++ b/.runseal/wrappers/cloudflare.ts @@ -1,27 +1,34 @@ -import { env, fail, fileExists, jsonGet, print, runseal, runsealText } from "../lib/runseal.ts"; +import { booleanOption, parseArgs, requireNoPositionals } from "@/lib/cli.ts"; +import { env } from "@/lib/std/env.ts"; +import { fs } from "@/lib/std/fs.ts"; +import { io } from "@/lib/std/io.ts"; +import { json } from "@/lib/std/json.ts"; +import { runseal } from "@/lib/std/runseal.ts"; function usage(): void { - print("Usage: runseal :cloudflare [args]"); - print(""); - print("Commands:"); - print(" init create repo-local .local/secrets/cloudflare.env template"); - print(" check validate repo-local credentials and probe core account APIs"); - print(" manage-plan print the desired manage redirect rule shape"); - print(" manage-inspect inspect current dynamic redirect ruleset for manage rules"); - print( + io.print("Usage: runseal :cloudflare [args]"); + io.print(""); + io.print("Commands:"); + io.print(" init create repo-local .local/secrets/cloudflare.env template"); + io.print( + " check validate repo-local credentials and probe core account APIs", + ); + io.print(" manage-plan print the desired manage redirect rule shape"); + io.print(" manage-inspect inspect current dynamic redirect ruleset for manage rules"); + io.print( " manage-ensure-redirect create/update exact-path manage redirects (use --dry-run first)", ); - print( + io.print( " api use: runseal @tool cloudflare api request ...", ); - print(""); - print("Credentials:"); - print(" .local/secrets/cloudflare.env"); + io.print(""); + io.print("Credentials:"); + io.print(" .local/secrets/cloudflare.env"); } function rejectExtraArg(value: string | undefined, message: string): void { if (value !== undefined && value !== "") { - fail(message); + io.fail(message); } } @@ -34,16 +41,16 @@ type ManageRules = { }; async function loadManageRedirectRules(): Promise { - const zoneName = await runsealText(["@tool", "cloudflare", "config", "get", "zone_name"]); - const requestHost = await runsealText(["@tool", "cloudflare", "config", "get", "manage_host"]); - const redirectHost = await runsealText([ + const zoneName = await runseal.text(["@tool", "cloudflare", "config", "get", "zone_name"]); + const requestHost = await runseal.text(["@tool", "cloudflare", "config", "get", "manage_host"]); + const redirectHost = await runseal.text([ "@tool", "cloudflare", "config", "get", "manage_origin_host", ]); - const prefix = await runsealText([ + const prefix = await runseal.text([ "@tool", "cloudflare", "config", @@ -56,7 +63,7 @@ async function loadManageRedirectRules(): Promise { const targetPs1 = prefix === "" ? `https://${redirectHost}/manage.ps1` : `https://${redirectHost}/${prefix}/manage.ps1`; - const ruleSh = await runsealText([ + const ruleSh = await runseal.text([ "@tool", "cloudflare", "redirect-rule", @@ -72,7 +79,7 @@ async function loadManageRedirectRules(): Promise { "--target-url", targetSh, ]); - const rulePs1 = await runsealText([ + const rulePs1 = await runseal.text([ "@tool", "cloudflare", "redirect-rule", @@ -92,356 +99,319 @@ async function loadManageRedirectRules(): Promise { } async function printManageRedirectPlan(rules: ManageRules, zoneId?: string): Promise { - const prettySh = await runsealText(["@tool", "json", "pretty", "value", rules.ruleSh]); - const prettyPs1 = await runsealText(["@tool", "json", "pretty", "value", rules.rulePs1]); - print("manage redirect plan"); - print(`zone: ${rules.zoneName}`); + const prettySh = json.pretty(rules.ruleSh); + const prettyPs1 = json.pretty(rules.rulePs1); + io.print("manage redirect plan"); + io.print(`zone: ${rules.zoneName}`); if (zoneId !== undefined) { - print(`zone id: ${zoneId}`); + io.print(`zone id: ${zoneId}`); } - print(`request host: ${rules.requestHost}`); - print(`redirect host: ${rules.redirectHost}`); - print("phase: http_request_dynamic_redirect"); - print("rules:"); - print(prettySh); - print(prettyPs1); + io.print(`request host: ${rules.requestHost}`); + io.print(`redirect host: ${rules.redirectHost}`); + io.print("phase: http_request_dynamic_redirect"); + io.print("rules:"); + io.print(prettySh); + io.print(prettyPs1); } -const [command, ...rest] = Deno.args; -if (command === undefined || command === "help" || command === "--help") { - usage(); - Deno.exit(0); +async function initCommand(rest: string[]): Promise { + rejectExtraArg(rest[0], "cloudflare: init does not accept arguments"); + const localDir = env.get("RUNSEAL_REPO_LOCAL_DIR", ".local"); + const secretsDir = env.get("RUNSEAL_REPO_SECRETS_DIR", ".local/secrets"); + const tmpDir = env.get("RUNSEAL_REPO_TMP_DIR", ".local/tmp"); + const tokenFile = `${secretsDir}/cloudflare.env`; + await fs.dir.ensure(localDir, "700"); + await fs.dir.ensure(secretsDir, "700"); + await fs.dir.ensure(tmpDir, "700"); + if (await fs.file.exists(tokenFile)) { + io.print(`exists ${tokenFile}`); + return; + } + const template = await Deno.readTextFile(".runseal/templates/cloudflare.env"); + await fs.file.writeText(tokenFile, template, "600"); + await fs.file.chmodIfUnix(tokenFile, "600"); + io.print(`created ${tokenFile}`); } -switch (command) { - case "init": { - rejectExtraArg(rest[0], "cloudflare: init does not accept arguments"); - const localDir = env("RUNSEAL_REPO_LOCAL_DIR", ".local"); - const secretsDir = env("RUNSEAL_REPO_SECRETS_DIR", ".local/secrets"); - const tmpDir = env("RUNSEAL_REPO_TMP_DIR", ".local/tmp"); - const tokenFile = `${secretsDir}/cloudflare.env`; - await runseal(["@tool", "fs", "mkdir", localDir, "700"]); - await runseal(["@tool", "fs", "mkdir", secretsDir, "700"]); - await runseal(["@tool", "fs", "mkdir", tmpDir, "700"]); - if (await fileExists(tokenFile)) { - print(`exists ${tokenFile}`); - } else { - await runseal([ - "@tool", - "fs", - "write-base64", - tokenFile, - "IyBSZXBvLWxvY2FsIENsb3VkZmxhcmUgY3JlZGVudGlhbHMgZm9yIHJ1bnNlYWwgc3VwcG9ydCBjb21tYW5kcy4KIyBGaWxsIHRoZXNlIHZhbHVlcyBtYW51YWxseS4gVGhpcyBmaWxlIHN0YXlzIGxvY2FsIGFuZCBnaXRpZ25vcmVkLgpDTE9VREZMQVJFX0FDQ09VTlRfSUQ9CkNMT1VERkxBUkVfQVBJX1RPS0VOPQpDTE9VREZMQVJFX1pPTkVfTkFNRT1wZXJpc2gudWsKQ0xPVURGTEFSRV9NQU5BR0VfSE9TVD1ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9PUklHSU5fSE9TVD1yZWxlYXNlcy5ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9SRURJUkVDVF9QUkVGSVg9Cg==", - ]); - await runseal(["@tool", "fs", "chmod", tokenFile, "600"]); - print(`created ${tokenFile}`); - } - break; - } - case "check": { - rejectExtraArg(rest[0], "cloudflare: check does not accept arguments"); - const accountId = await runsealText(["@tool", "cloudflare", "config", "get", "account_id"]); - const zoneName = await runsealText(["@tool", "cloudflare", "config", "get", "zone_name"]); - const zone = await runsealText(["@tool", "cloudflare", "zone", "get", "--name", zoneName]); - const zoneId = await jsonGet(zone, ".id"); - const rulesets = await runsealText([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "list", - "--zone-id", - zoneId, - ]); - const rulesetCount = await runsealText(["@tool", "json", "len", rulesets]); - const zonesPayload = await runsealText([ - "@tool", - "cloudflare", - "api", - "request", - "GET", - "/zones", - "--query", - `account.id=${accountId}`, - "--query", - "per_page=50", - ]); - const zones = await jsonGet(zonesPayload, ".result"); - const zonesPretty = await runsealText(["@tool", "json", "pretty", "value", zones]); - const account = await runsealText([ - "@tool", - "cloudflare", - "account", - "get", - "--account-id", - accountId, - ]); - const accountName = await jsonGet(account, ".name"); - const buckets = await runsealText([ - "@tool", - "cloudflare", - "account", - "r2", - "bucket", - "list", - "--account-id", - accountId, - ]); - const bucketsPretty = await runsealText(["@tool", "json", "pretty", "value", buckets]); - print("cloudflare check: ok"); - print(`account id: ${accountId}`); - print(`account name: ${accountName}`); - print(`manage zone: ${zoneName} (${zoneId})`); - print(`zone rulesets: ${rulesetCount}`); - print("zones:"); - print(zonesPretty); - print("r2 buckets:"); - print(bucketsPretty); - break; +async function checkCommand(rest: string[]): Promise { + rejectExtraArg(rest[0], "cloudflare: check does not accept arguments"); + const accountId = await runseal.text(["@tool", "cloudflare", "config", "get", "account_id"]); + const zoneName = await runseal.text(["@tool", "cloudflare", "config", "get", "zone_name"]); + const zone = await runseal.text(["@tool", "cloudflare", "zone", "get", "--name", zoneName]); + const zoneId = json.get(zone, ".id"); + const rulesets = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "list", + "--zone-id", + zoneId, + ]); + const rulesetCount = json.len(rulesets); + const zonesPayload = await runseal.text([ + "@tool", + "cloudflare", + "api", + "request", + "GET", + "/zones", + "--query", + `account.id=${accountId}`, + "--query", + "per_page=50", + ]); + const zones = json.get(zonesPayload, ".result"); + const zonesPretty = json.pretty(zones); + const account = await runseal.text([ + "@tool", + "cloudflare", + "account", + "get", + "--account-id", + accountId, + ]); + const accountName = json.get(account, ".name"); + const buckets = await runseal.text([ + "@tool", + "cloudflare", + "account", + "r2", + "bucket", + "list", + "--account-id", + accountId, + ]); + const bucketsPretty = json.pretty(buckets); + io.print("cloudflare check: ok"); + io.print(`account id: ${accountId}`); + io.print(`account name: ${accountName}`); + io.print(`manage zone: ${zoneName} (${zoneId})`); + io.print(`zone rulesets: ${rulesetCount}`); + io.print("zones:"); + io.print(zonesPretty); + io.print("r2 buckets:"); + io.print(bucketsPretty); +} + +async function managePlanCommand(rest: string[]): Promise { + rejectExtraArg(rest[0], "cloudflare: manage-plan does not accept arguments"); + await printManageRedirectPlan(await loadManageRedirectRules()); +} + +async function manageInspectCommand(rest: string[]): Promise { + rejectExtraArg(rest[0], "cloudflare: manage-inspect does not accept arguments"); + const zoneName = await runseal.text(["@tool", "cloudflare", "config", "get", "zone_name"]); + const zone = await runseal.text(["@tool", "cloudflare", "zone", "get", "--name", zoneName]); + const zoneId = json.get(zone, ".id"); + const rulesets = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "list", + "--zone-id", + zoneId, + ]); + const ruleset = json.find(rulesets, "phase", "http_request_dynamic_redirect"); + if (ruleset === "") { + io.print("manage inspect: no http_request_dynamic_redirect zone ruleset found"); + return; } - case "manage-plan": { - rejectExtraArg(rest[0], "cloudflare: manage-plan does not accept arguments"); - await printManageRedirectPlan(await loadManageRedirectRules()); - break; + const rulesetId = json.get(ruleset, ".id"); + const fullRuleset = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "get", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + ]); + const rulesetName = json.get(fullRuleset, ".name"); + const rules = json.get(fullRuleset, ".rules"); + const matched = json.filter(rules, "ref", [ + "runseal_manage_sh_redirect", + "runseal_manage_ps1_redirect", + ]); + const matchedCount = json.len(matched); + io.print(`zone id: ${zoneId}`); + io.print(`ruleset id: ${rulesetId}`); + io.print(`ruleset name: ${rulesetName}`); + if (matchedCount === 0) { + io.print("manage inspect: no manage redirect rules found"); + return; } - case "manage-inspect": { - rejectExtraArg(rest[0], "cloudflare: manage-inspect does not accept arguments"); - const zoneName = await runsealText(["@tool", "cloudflare", "config", "get", "zone_name"]); - const zone = await runsealText(["@tool", "cloudflare", "zone", "get", "--name", zoneName]); - const zoneId = await jsonGet(zone, ".id"); - const rulesets = await runsealText([ + const pretty = json.pretty(matched); + io.print("manage rules:"); + io.print(pretty); +} + +async function resolveManageRedirectRuleset(zoneId: string): Promise { + const rulesets = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "list", + "--zone-id", + zoneId, + ]); + let ruleset = json.find(rulesets, "phase", "http_request_dynamic_redirect"); + if (ruleset === "") { + return await runseal.text([ "@tool", "cloudflare", "zone", "ruleset", - "list", + "create", "--zone-id", zoneId, - ]); - const ruleset = await runsealText([ - "@tool", - "json", - "find", - rulesets, - "phase", + "--phase", "http_request_dynamic_redirect", + "--name", + "Single Redirects ruleset", ]); - if (ruleset === "") { - print("manage inspect: no http_request_dynamic_redirect zone ruleset found"); - break; - } - const rulesetId = await jsonGet(ruleset, ".id"); - const fullRuleset = await runsealText([ + } + const rulesetId = json.get(ruleset, ".id"); + ruleset = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "get", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + ]); + return ruleset; +} + +async function upsertRedirectRule( + zoneId: string, + rulesetId: string, + current: string, + ref: string, + payload: string, +): Promise { + if (current === "") { + await runseal.run([ "@tool", "cloudflare", "zone", "ruleset", - "get", + "rule", + "add", "--zone-id", zoneId, "--ruleset-id", rulesetId, + "--json", + payload, ]); - const rulesetName = await jsonGet(fullRuleset, ".name"); - const rules = await jsonGet(fullRuleset, ".rules"); - const matched = await runsealText([ - "@tool", - "json", - "filter", - rules, - "ref", - "runseal_manage_sh_redirect", - "runseal_manage_ps1_redirect", - ]); - const matchedCount = await runsealText(["@tool", "json", "len", matched]); - print(`zone id: ${zoneId}`); - print(`ruleset id: ${rulesetId}`); - print(`ruleset name: ${rulesetName}`); - if (matchedCount === "0") { - print("manage inspect: no manage redirect rules found"); - break; - } - const pretty = await runsealText(["@tool", "json", "pretty", "value", matched]); - print("manage rules:"); - print(pretty); - break; + return `created ${ref}`; } - case "manage-ensure-redirect": { - let dryRun = false; - if (rest[0] === "--dry-run") { - dryRun = true; - if (rest[1] !== undefined) { - fail(`cloudflare: unknown manage-ensure-redirect argument: ${rest[1]}`); - } - } else if (rest[0] !== undefined) { - fail(`cloudflare: unknown manage-ensure-redirect argument: ${rest[0]}`); - } - const rules = await loadManageRedirectRules(); - const zone = await runsealText([ - "@tool", - "cloudflare", - "zone", - "get", - "--name", - rules.zoneName, - ]); - const zoneId = await jsonGet(zone, ".id"); - if (dryRun) { - await printManageRedirectPlan(rules, zoneId); - break; - } - const rulesets = await runsealText([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "list", - "--zone-id", - zoneId, - ]); - let ruleset = await runsealText([ - "@tool", - "json", - "find", - rulesets, - "phase", - "http_request_dynamic_redirect", - ]); - if (ruleset === "") { - ruleset = await runsealText([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "create", - "--zone-id", - zoneId, - "--phase", - "http_request_dynamic_redirect", - "--name", - "Single Redirects ruleset", - ]); - } else { - const rulesetId = await jsonGet(ruleset, ".id"); - ruleset = await runsealText([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "get", - "--zone-id", - zoneId, - "--ruleset-id", - rulesetId, - ]); - } - const rulesetId = await jsonGet(ruleset, ".id"); - const existingRules = await jsonGet(ruleset, ".rules"); - const currentSh = await runsealText([ - "@tool", - "json", - "find", - existingRules, - "ref", - "runseal_manage_sh_redirect", - ]); - const currentPs1 = await runsealText([ - "@tool", - "json", - "find", - existingRules, - "ref", - "runseal_manage_ps1_redirect", - ]); - let changedSh: string; - if (currentSh === "") { - await runseal([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "rule", - "add", - "--zone-id", - zoneId, - "--ruleset-id", - rulesetId, - "--json", - rules.ruleSh, - ]); - changedSh = "created runseal_manage_sh_redirect"; - } else { - const ruleId = await jsonGet(currentSh, ".id"); - await runseal([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "rule", - "update", - "--zone-id", - zoneId, - "--ruleset-id", - rulesetId, - "--rule-id", - ruleId, - "--json", - rules.ruleSh, - ]); - changedSh = "updated runseal_manage_sh_redirect"; - } - let changedPs1: string; - if (currentPs1 === "") { - await runseal([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "rule", - "add", - "--zone-id", - zoneId, - "--ruleset-id", - rulesetId, - "--json", - rules.rulePs1, - ]); - changedPs1 = "created runseal_manage_ps1_redirect"; - } else { - const ruleId = await jsonGet(currentPs1, ".id"); - await runseal([ - "@tool", - "cloudflare", - "zone", - "ruleset", - "rule", - "update", - "--zone-id", - zoneId, - "--ruleset-id", - rulesetId, - "--rule-id", - ruleId, - "--json", - rules.rulePs1, - ]); - changedPs1 = "updated runseal_manage_ps1_redirect"; - } - print("manage ensure redirect: ok"); - print(` - ${changedSh}`); - print(` - ${changedPs1}`); - break; + const ruleId = json.get(current, ".id"); + await runseal.run([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "rule", + "update", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + "--rule-id", + ruleId, + "--json", + payload, + ]); + return `updated ${ref}`; +} + +async function manageEnsureRedirectCommand(rest: string[]): Promise { + const args = parseArgs(rest, { + boolean: ["dry-run"], + unknownOptionMessage: (arg) => `cloudflare: unknown manage-ensure-redirect argument: ${arg}`, + }); + requireNoPositionals(args, "cloudflare: manage-ensure-redirect"); + const dryRun = booleanOption(args, "dry-run"); + const rules = await loadManageRedirectRules(); + const zone = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "get", + "--name", + rules.zoneName, + ]); + const zoneId = json.get(zone, ".id"); + if (dryRun) { + await printManageRedirectPlan(rules, zoneId); + return; } - case "api": { - if (rest[0] === undefined) { - fail("cloudflare: api requires a method"); - } - if (rest[1] === undefined) { - fail("cloudflare: api requires a path"); - } - await runseal(["@tool", "cloudflare", "api", "request", ...rest]); - break; + const ruleset = await resolveManageRedirectRuleset(zoneId); + const rulesetId = json.get(ruleset, ".id"); + const existingRules = json.get(ruleset, ".rules"); + const changedSh = await upsertRedirectRule( + zoneId, + rulesetId, + json.find(existingRules, "ref", "runseal_manage_sh_redirect"), + "runseal_manage_sh_redirect", + rules.ruleSh, + ); + const changedPs1 = await upsertRedirectRule( + zoneId, + rulesetId, + json.find(existingRules, "ref", "runseal_manage_ps1_redirect"), + "runseal_manage_ps1_redirect", + rules.rulePs1, + ); + io.print("manage ensure redirect: ok"); + io.print(` - ${changedSh}`); + io.print(` - ${changedPs1}`); +} + +async function apiCommand(rest: string[]): Promise { + if (rest[0] === undefined) { + io.fail("cloudflare: api requires a method"); + } + if (rest[1] === undefined) { + io.fail("cloudflare: api requires a path"); } + await runseal.run(["@tool", "cloudflare", "api", "request", ...rest]); +} + +const [command, ...rest] = Deno.args; +if (command === undefined || command === "help" || command === "--help") { + usage(); + Deno.exit(0); +} + +switch (command) { + case "init": + await initCommand(rest); + break; + case "check": + await checkCommand(rest); + break; + case "manage-plan": + await managePlanCommand(rest); + break; + case "manage-inspect": + await manageInspectCommand(rest); + break; + case "manage-ensure-redirect": + await manageEnsureRedirectCommand(rest); + break; + case "api": + await apiCommand(rest); + break; default: - fail(`cloudflare: unknown command: ${command}`); + io.fail(`cloudflare: unknown command: ${command}`); } diff --git a/.runseal/wrappers/guard.ts b/.runseal/wrappers/guard.ts index 3a139e7..ec35622 100644 --- a/.runseal/wrappers/guard.ts +++ b/.runseal/wrappers/guard.ts @@ -1,19 +1,30 @@ -import { env, fail, jsonGet, print, run, runsealText, runText } from "../lib/runseal.ts"; +import { helpRequested, parseArgs, requireNoPositionals } from "@/lib/cli.ts"; +import { cmd } from "@/lib/std/cmd.ts"; +import { env } from "@/lib/std/env.ts"; +import { io } from "@/lib/std/io.ts"; +import { json } from "@/lib/std/json.ts"; +import { treeHash } from "@/lib/hash.ts"; +import { compareStableVersion, parseStableVersion } from "@/lib/version.ts"; function usage(): void { - print("Usage: runseal :guard [version-check|version-hash]"); - print(""); - print("Run repository guard checks or one explicit version-policy helper."); - print(""); - print("Commands:"); - print(" version-check validate version policy against stable metadata"); - print(" version-hash print the current guard.version.hash value"); + io.print("Usage: runseal :guard [version-check|version-hash]"); + io.print(""); + io.print("Run repository guard checks or one explicit version-policy helper."); + io.print(""); + io.print("Commands:"); + io.print(" version-check validate version policy against stable metadata"); + io.print(" version-hash print the current guard.version.hash value"); } let mode = "full"; -const args = [...Deno.args]; -if (args.length > 0) { - const arg = args.shift()!; +const args = parseArgs(Deno.args, { boolean: ["help", "h"] }); +if (helpRequested(args)) { + requireNoPositionals(args, "guard", { allowHelp: true }); + usage(); + Deno.exit(0); +} +if (args._.length > 0) { + const arg = args._.shift()!; switch (arg) { case "version-check": mode = "version-check"; @@ -21,111 +32,93 @@ if (args.length > 0) { case "version-hash": mode = "version-hash"; break; - case "-h": - case "--help": - case "help": - usage(); - Deno.exit(0); default: - fail(`guard: unknown command: ${arg}`); + io.fail(`guard: unknown command: ${arg}`); } } -if (args.length > 0) { - fail("guard: unexpected arguments"); +if (args._.length > 0) { + io.fail("guard: unexpected arguments"); } async function currentHash(): Promise { - return await runsealText(["@tool", "hash", "tree", "app/tests"]); + return await treeHash(["app/tests"]); } async function versionPolicy(): Promise { - const publicUrl = env("RUNSEAL_RELEASES_PUBLIC_URL", "https://releases.runseal.perish.uk"); - const metadataUrl = env( + const publicUrl = env.get("RUNSEAL_RELEASES_PUBLIC_URL", "https://releases.runseal.perish.uk"); + const metadataUrl = env.get( "RUNSEAL_STABLE_METADATA_URL", `${publicUrl}/stable/latest/metadata.json`, ); - const cargoMetadata = await runText("cargo", ["metadata", "--no-deps", "--format-version", "1"]); - const currentVersion = await jsonGet(cargoMetadata, ".packages[0].version"); + const cargoMetadata = await cmd.text("cargo", ["metadata", "--no-deps", "--format-version", "1"]); + const currentVersion = json.get(cargoMetadata, ".packages[0].version"); const hash = await currentHash(); const response = await fetch(`${metadataUrl}?version=${encodeURIComponent(currentVersion)}`); if (response.status === 404) { - print("guard version policy: no stable metadata; skipping"); + io.print("guard version policy: no stable metadata; skipping"); return; } if (response.status !== 200) { - fail(`guard version policy: failed to fetch stable metadata: HTTP ${response.status}`); + io.fail(`guard version policy: failed to fetch stable metadata: HTTP ${response.status}`); } const metadata = await response.text(); - const hasPriorHash = await runsealText(["@tool", "json", "has", metadata, ".guard.version.hash"]); - const priorHash = hasPriorHash === "true" ? await jsonGet(metadata, ".guard.version.hash") : ""; + const hasPriorHash = json.has(metadata, ".guard.version.hash"); + const priorHash = hasPriorHash ? json.get(metadata, ".guard.version.hash") : ""; if (priorHash === "") { - print("guard version policy: stable metadata has no guard.version.hash; skipping"); + io.print("guard version policy: stable metadata has no guard.version.hash; skipping"); return; } - const hasStableVersion = await runsealText(["@tool", "json", "has", metadata, ".stableVersion"]); - let priorVersion = hasStableVersion === "true" ? await jsonGet(metadata, ".stableVersion") : ""; + const hasStableVersion = json.has(metadata, ".stableVersion"); + let priorVersion = hasStableVersion ? json.get(metadata, ".stableVersion") : ""; if (priorVersion === "") { - const hasReleaseVersion = await runsealText([ - "@tool", - "json", - "has", - metadata, - ".releaseVersion", - ]); - if (hasReleaseVersion === "true") { - priorVersion = await jsonGet(metadata, ".releaseVersion"); + const hasReleaseVersion = json.has(metadata, ".releaseVersion"); + if (hasReleaseVersion) { + priorVersion = json.get(metadata, ".releaseVersion"); } } if (priorVersion === "") { - fail("guard version policy: stable metadata is missing stableVersion/releaseVersion"); + io.fail("guard version policy: stable metadata is missing stableVersion/releaseVersion"); } - const currentOrder = await runsealText([ - "@tool", - "version", - "compare", - currentVersion, - priorVersion, - ]); - const priorMajor = await runsealText(["@tool", "version", "part", priorVersion, "major"]); - const priorMinor = await runsealText(["@tool", "version", "part", priorVersion, "minor"]); - const currentMajor = await runsealText(["@tool", "version", "part", currentVersion, "major"]); - const currentMinor = await runsealText(["@tool", "version", "part", currentVersion, "minor"]); - const sameMinorLineage = currentMajor === priorMajor && currentMinor === priorMinor; + const currentOrder = compareStableVersion(currentVersion, priorVersion); + const priorParsed = parseStableVersion(priorVersion); + const currentParsed = parseStableVersion(currentVersion); + const sameMinorLineage = currentParsed.major === priorParsed.major && + currentParsed.minor === priorParsed.minor; if (currentOrder === "lt") { - fail(`guard version policy: version regressed below prior stable ${priorVersion}`); + io.fail(`guard version policy: version regressed below prior stable ${priorVersion}`); } if (currentOrder === "eq") { - fail(`guard version policy: version matches prior stable ${priorVersion}`); + io.fail(`guard version policy: version matches prior stable ${priorVersion}`); } if (hash === priorHash) { if (!sameMinorLineage) { - fail( + io.fail( `guard version policy: unchanged guard.version.hash requires a patch-only bump above ${priorVersion}`, ); } - print( + io.print( `guard version policy: hash unchanged -> patch bump ok (${priorVersion} -> ${currentVersion})`, ); } else { if (sameMinorLineage) { - fail( + io.fail( `guard version policy: changed guard.version.hash requires a minor-or-higher bump above ${priorVersion}`, ); } - print( + io.print( `guard version policy: hash changed -> minor-or-higher bump ok (${priorVersion} -> ${currentVersion})`, ); } } if (mode === "version-hash") { - print(await currentHash()); + io.print(await currentHash()); Deno.exit(0); } @@ -134,20 +127,28 @@ if (mode === "version-check") { Deno.exit(0); } -print("==> cargo fmt"); -await run("cargo", ["fmt", "--all", "--check"]); - -print("==> cargo clippy"); -await run("cargo", ["clippy", "--locked", "--workspace", "--all-targets", "--", "-D", "warnings"]); +io.print("==> cargo fmt"); +await cmd.run("cargo", ["fmt", "--all", "--check"]); + +io.print("==> cargo clippy"); +await cmd.run("cargo", [ + "clippy", + "--locked", + "--workspace", + "--all-targets", + "--", + "-D", + "warnings", +]); -print("==> cargo test"); -await run("cargo", ["test", "--locked", "--workspace"]); +io.print("==> cargo test"); +await cmd.run("cargo", ["test", "--locked", "--workspace"]); -print("==> deno fmt"); -await run("deno", ["fmt", "--check", ".runseal"]); +io.print("==> deno fmt"); +await cmd.run("deno", ["fmt", "--check", ".runseal"]); -print("==> deno check"); -await run("deno", [ +io.print("==> deno check"); +await cmd.run("deno", [ "check", "--config", ".runseal/deno.json", @@ -161,10 +162,10 @@ await run("deno", [ ".runseal/wrappers/release.ts", ]); -print("==> flavor self-check"); -await run("flavor", ["check", "--root", ".", "--config", "flavor.toml"]); +io.print("==> flavor self-check"); +await cmd.run("flavor", ["check", "--root", ".", "--config", "flavor.toml"]); -print("==> shell syntax"); +io.print("==> shell syntax"); for ( const [command, script] of [ ["sh", "manage.sh"], @@ -179,34 +180,34 @@ for ( ["sh", ".github/scripts/release/smoke/smoke.sh"], ] ) { - await run(command, ["-n", script]); + await cmd.run(command, ["-n", script]); } -print("==> python syntax"); -await run("python3", ["-m", "py_compile", ".github/scripts/release/metadata/beta.py"]); -await run("python3", ["-m", "py_compile", ".github/scripts/release/metadata/stable.py"]); +io.print("==> python syntax"); +await cmd.run("python3", ["-m", "py_compile", ".github/scripts/release/metadata/beta.py"]); +await cmd.run("python3", ["-m", "py_compile", ".github/scripts/release/metadata/stable.py"]); -const hasPwsh = await runsealText(["@tool", "process", "exists", "pwsh"]); -print("==> PowerShell syntax"); -if (hasPwsh === "true") { - await run("pwsh", [ +const hasPwsh = await cmd.exists("pwsh"); +io.print("==> PowerShell syntax"); +if (hasPwsh) { + await cmd.run("pwsh", [ "-NoProfile", "-NonInteractive", "-Command", "[scriptblock]::Create((Get-Content -Raw 'manage.ps1')) | Out-Null", ]); - await run("pwsh", [ + await cmd.run("pwsh", [ "-NoProfile", "-NonInteractive", "-Command", "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/assets/package.ps1')) | Out-Null", ]); - await run("pwsh", [ + await cmd.run("pwsh", [ "-NoProfile", "-NonInteractive", "-Command", "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/smoke/smoke.ps1')) | Out-Null", ]); } else { - print("skip: pwsh not found"); + io.print("skip: pwsh not found"); } diff --git a/.runseal/wrappers/init.ts b/.runseal/wrappers/init.ts index 2f21a5d..725e5dc 100644 --- a/.runseal/wrappers/init.ts +++ b/.runseal/wrappers/init.ts @@ -1,96 +1,67 @@ -import { - commandExists, - fail, - fileExists, - pathJoin, - print, - run, - runseal, - runsealText, - runText, -} from "../lib/runseal.ts"; - -let force = false; -let help = false; -const args = [...Deno.args]; -while (args.length > 0) { - const arg = args.shift()!; - switch (arg) { - case "--force": - force = true; - break; - case "--": - args.length = 0; - break; - case "-h": - case "--help": - case "help": - help = true; - break; - default: - fail(`unknown option: ${arg}`); - } -} +import { booleanOption, helpRequested, parseArgs, requireNoPositionals } from "@/lib/cli.ts"; +import { cmd } from "@/lib/std/cmd.ts"; +import { fs } from "@/lib/std/fs.ts"; +import { io } from "@/lib/std/io.ts"; +import { path } from "@/lib/std/path.ts"; function usage(): void { - print("Usage: runseal :init [--force]"); - print(""); - print("Validate the repository and install generated git hooks."); - print(""); - print("Options:"); - print(" --force back up custom hooks before replacing them"); + io.print("Usage: runseal :init [--force]"); + io.print(""); + io.print("Validate the repository and install generated git hooks."); + io.print(""); + io.print("Options:"); + io.print(" --force back up custom hooks before replacing them"); } async function requireTool(name: string): Promise { - if (!(await commandExists(name))) { - fail(`init: missing required tool: ${name}`); + if (!(await cmd.exists(name))) { + io.fail(`init: missing required tool: ${name}`); } } -async function requirePath(root: string, path: string): Promise { - if (!(await fileExists(pathJoin(root, path)))) { - fail(`init: missing required path: ${path}`); +async function requirePath(root: string, relPath: string): Promise { + if (!(await fs.file.exists(path.join(root, relPath)))) { + io.fail(`init: missing required path: ${relPath}`); } } async function prepareHook(path: string): Promise { - if (!(await fileExists(path))) { + if (!(await fs.file.exists(path))) { return; } - const generated = await runsealText([ - "@tool", - "fs", - "contains-any", - path, + const generated = await fs.file.containsAny(path, [ "runseal init hook", "runseal bootstrap hook", ]); - if (generated === "true") { + if (generated) { return; } if (!force) { - fail( + io.fail( `init: ${path} already exists and was not generated by runseal init; rerun with --force to back it up and replace it`, ); } - const backup = await runsealText(["@tool", "fs", "backup-numbered", path]); - print(`backed up existing hook to ${backup}`); + const backup = await fs.file.backup.numbered(path); + io.print(`backed up existing hook to ${backup}`); } -if (help) { +const args = parseArgs(Deno.args, { boolean: ["force", "help", "h"] }); +requireNoPositionals(args, "init", { allowHelp: true }); +if (helpRequested(args)) { usage(); Deno.exit(0); } +const force = booleanOption(args, "force"); -print("==> resolving repository"); -const root = await runText("git", ["rev-parse", "--show-toplevel"]); -const gitDir = await runText("git", ["rev-parse", "--absolute-git-dir"]); -const hooksDir = pathJoin(gitDir, "hooks"); -const preCommit = pathJoin(hooksDir, "pre-commit"); -const commitMsg = pathJoin(hooksDir, "commit-msg"); -print(`repository: ${root}`); +io.print("==> resolving repository"); +const root = await cmd.text("git", ["rev-parse", "--show-toplevel"]); +const gitDir = await cmd.text("git", ["rev-parse", "--absolute-git-dir"]); +const hooksDir = path.join(gitDir, "hooks"); +const preCommit = path.join(hooksDir, "pre-commit"); +const commitMsg = path.join(hooksDir, "commit-msg"); +io.print(`repository: ${root}`); -print("==> checking required tools"); +io.print("==> checking required tools"); for ( const tool of [ "git", @@ -107,13 +78,14 @@ for ( ) { await requireTool(tool); } -print("ok: git, deno, python3, cargo, runseal, flavor, sh, bash, sed, grep"); +io.print("ok: git, deno, python3, cargo, runseal, flavor, sh, bash, sed, grep"); -print("==> checking repository entrypoints"); +io.print("==> checking repository entrypoints"); for ( const path of [ "Cargo.toml", "Cargo.lock", + "deno.lock", "flavor.toml", "manage.sh", "manage.ps1", @@ -121,7 +93,17 @@ for ( ".runseal/deno.json", ".runseal/hooks/pre-commit", ".runseal/hooks/commit-msg", - ".runseal/lib/runseal.ts", + ".runseal/lib/cli.ts", + ".runseal/lib/hash.ts", + ".runseal/lib/std/cmd.ts", + ".runseal/lib/std/env.ts", + ".runseal/lib/std/fs.ts", + ".runseal/lib/std/io.ts", + ".runseal/lib/std/json.ts", + ".runseal/lib/std/path.ts", + ".runseal/lib/std/runseal.ts", + ".runseal/lib/version.ts", + ".runseal/templates/cloudflare.env", ".runseal/wrappers/cloudflare.ts", ".runseal/wrappers/guard.ts", ".runseal/wrappers/init.ts", @@ -147,20 +129,20 @@ for ( ) { await requirePath(root, path); } -print("ok: repository entrypoints"); +io.print("ok: repository entrypoints"); -print("==> installing git hooks"); -await runseal(["@tool", "fs", "mkdir", hooksDir, "700"]); +io.print("==> installing git hooks"); +await fs.dir.ensure(hooksDir, "700"); await prepareHook(preCommit); await Deno.copyFile(".runseal/hooks/pre-commit", preCommit); -await runseal(["@tool", "fs", "chmod", preCommit, "755"]); -print(`installed ${preCommit}`); +await fs.file.chmodIfUnix(preCommit, "755"); +io.print(`installed ${preCommit}`); await prepareHook(commitMsg); await Deno.copyFile(".runseal/hooks/commit-msg", commitMsg); -await runseal(["@tool", "fs", "chmod", commitMsg, "755"]); -print(`installed ${commitMsg}`); +await fs.file.chmodIfUnix(commitMsg, "755"); +io.print(`installed ${commitMsg}`); -await run("deno", ["--version"], { stdout: "null" }); -print("development environment ready"); +await cmd.run("deno", ["--version"], { stdout: "null" }); +io.print("development environment ready"); diff --git a/.runseal/wrappers/pr.ts b/.runseal/wrappers/pr.ts index 9ca9abe..ddda293 100644 --- a/.runseal/wrappers/pr.ts +++ b/.runseal/wrappers/pr.ts @@ -1,4 +1,14 @@ -import { fail, jsonEmpty, jsonGet, print, run, runsealText, runText } from "../lib/runseal.ts"; +import { + booleanOption, + helpRequested, + parseArgs as parseCliArgs, + requireNoPositionals, + stringOption, +} from "@/lib/cli.ts"; +import { cmd } from "@/lib/std/cmd.ts"; +import { io } from "@/lib/std/io.ts"; +import { json } from "@/lib/std/json.ts"; +import { runseal } from "@/lib/std/runseal.ts"; type Options = { base: string; @@ -12,109 +22,57 @@ type Options = { }; function usage(): void { - print("Usage: runseal :pr [options]"); - print(""); - print("Create or update, watch, and squash-merge the GitHub PR for the current branch."); - print(""); - print("Options:"); - print(" --base PR base branch (default: main)"); - print(" --title title when creating a new PR"); - print(" --body-file <path> body file when creating a new PR"); - print(" --draft create the PR as draft and require --no-merge"); - print(" --no-watch do not watch PR checks"); - print(" --no-merge do not squash-merge after checks"); - print(" --no-push do not push the current branch first"); - print(" --dry-run print planned actions without changing remote state"); + io.print("Usage: runseal :pr [options]"); + io.print(""); + io.print("Create or update, watch, and squash-merge the GitHub PR for the current branch."); + io.print( + "By default this pushes the branch, creates or reuses a PR, marks it ready, watches checks, and squash-merges after checks pass.", + ); + io.print(""); + io.print("Options:"); + io.print(" --base <branch> PR base branch (default: main)"); + io.print(" --title <title> title when creating a new PR"); + io.print(" --body-file <path> body file when creating a new PR"); + io.print(" --draft create the PR as draft and require --no-merge"); + io.print(" --no-watch do not watch PR checks"); + io.print(" --no-merge do not squash-merge after checks"); + io.print(" --no-push do not push the current branch first"); + io.print(" --dry-run print planned actions without changing remote state"); } function parseArgs(args: string[]): Options & { help: boolean } { - const options = { - base: "main", - title: "", - bodyFile: "", - draft: false, - noWatch: false, - noMerge: false, - noPush: false, - dryRun: false, - help: false, + const parsed = parseCliArgs(args, { + string: ["base", "title", "body-file"], + boolean: ["draft", "no-watch", "no-merge", "no-push", "dry-run", "help", "h"], + }); + requireNoPositionals(parsed, "pr", { allowHelp: true }); + return { + base: stringOption(parsed, "base", "main"), + title: stringOption(parsed, "title"), + bodyFile: stringOption(parsed, "body-file"), + draft: booleanOption(parsed, "draft"), + noWatch: booleanOption(parsed, "no-watch"), + noMerge: booleanOption(parsed, "no-merge"), + noPush: booleanOption(parsed, "no-push"), + dryRun: booleanOption(parsed, "dry-run"), + help: helpRequested(parsed), }; - while (args.length > 0) { - const arg = args.shift()!; - if (arg === "--") { - break; - } - if (arg === "-h" || arg === "--help" || arg === "help") { - options.help = true; - continue; - } - if (arg === "--draft") { - options.draft = true; - continue; - } - if (arg === "--no-watch") { - options.noWatch = true; - continue; - } - if (arg === "--no-merge") { - options.noMerge = true; - continue; - } - if (arg === "--no-push") { - options.noPush = true; - continue; - } - if (arg === "--dry-run") { - options.dryRun = true; - continue; - } - for ( - const [name, key] of [ - ["--base", "base"], - ["--title", "title"], - ["--body-file", "bodyFile"], - ] as const - ) { - if (arg === name) { - const value = args.shift(); - if (value === undefined) { - fail(`missing value for ${name}`); - } - options[key] = value; - continue; - } - const prefix = `${name}=`; - if (arg.startsWith(prefix)) { - options[key] = arg.slice(prefix.length); - continue; - } - } - if ( - arg === "--base" || arg.startsWith("--base=") || - arg === "--title" || arg.startsWith("--title=") || - arg === "--body-file" || arg.startsWith("--body-file=") - ) { - continue; - } - fail(`unknown option: ${arg}`); - } - return options; } function printDryRun(options: Options, branch: string): void { - print(`branch: ${branch}`); - print(`base: ${options.base}`); - print(`push: ${options.noPush ? "False" : "True"}`); - print("pr: create if missing, otherwise reuse existing"); + io.print(`branch: ${branch}`); + io.print(`base: ${options.base}`); + io.print(`push: ${options.noPush ? "False" : "True"}`); + io.print("pr: create if missing, otherwise reuse existing"); if (options.draft) { - print("draft: True"); - print("ready: False"); + io.print("draft: True"); + io.print("ready: False"); } else { - print("draft: False"); - print("ready: True"); + io.print("draft: False"); + io.print("ready: True"); } - print(`watch: ${options.noWatch ? "False" : "True"}`); - print(`squash_merge: ${options.noMerge ? "False" : "True"}`); + io.print(`watch: ${options.noWatch ? "False" : "True"}`); + io.print(`squash_merge: ${options.noMerge ? "False" : "True"}`); } async function createPr(options: Options, branch: string): Promise<void> { @@ -131,13 +89,13 @@ async function createPr(options: Options, branch: string): Promise<void> { } else { args.push("--fill"); } - await run("gh", args); + await cmd.run("gh", args); } async function watchChecks(number: string): Promise<void> { let checksSeen = false; for (let attempt = 0; attempt < 12; attempt += 1) { - checksSeen = (await runsealText(["@tool", "github", "pr", "checks", "probe", number])) === + checksSeen = (await runseal.text(["@tool", "github", "pr", "checks", "probe", number])) === "true"; if (checksSeen) { break; @@ -145,9 +103,9 @@ async function watchChecks(number: string): Promise<void> { await new Promise((resolve) => setTimeout(resolve, 5000)); } if (!checksSeen) { - print(`no checks reported on PR #${number}; skipping watch`); + io.print(`no checks reported on PR #${number}; skipping watch`); } else { - await run("gh", ["pr", "checks", number, "--watch", "--interval", "10"]); + await cmd.run("gh", ["pr", "checks", number, "--watch", "--interval", "10"]); } } @@ -157,19 +115,19 @@ if (options.help) { Deno.exit(0); } -await run("git", ["--version"]); -await run("gh", ["--version"]); -await run("gh", ["auth", "status"]); +await cmd.run("git", ["--version"]); +await cmd.run("gh", ["--version"]); +await cmd.run("gh", ["auth", "status"]); -const branch = await runText("git", ["branch", "--show-current"]); +const branch = await cmd.text("git", ["branch", "--show-current"]); if (branch === "") { - fail("pr: not on a branch"); + io.fail("pr: not on a branch"); } if (branch === options.base || branch === "main" || branch === "master") { - fail(`pr: refusing to open a PR from base branch: ${branch}`); + io.fail(`pr: refusing to open a PR from base branch: ${branch}`); } if (options.draft && !options.noMerge) { - fail("pr: --draft requires --no-merge"); + io.fail("pr: --draft requires --no-merge"); } if (options.dryRun) { @@ -178,11 +136,11 @@ if (options.dryRun) { } if (!options.noPush) { - await run("git", ["push", "-u", "origin", branch]); + await cmd.run("git", ["push", "-u", "origin", branch]); } let created = false; -let prRaw = await runText("gh", [ +let prRaw = await cmd.text("gh", [ "pr", "list", "--head", @@ -190,10 +148,10 @@ let prRaw = await runText("gh", [ "--json", "number,title,state,url,isDraft", ]); -if (await jsonEmpty(prRaw)) { +if (json.empty(prRaw)) { await createPr(options, branch); created = true; - prRaw = await runText("gh", [ + prRaw = await cmd.text("gh", [ "pr", "list", "--head", @@ -201,24 +159,24 @@ if (await jsonEmpty(prRaw)) { "--json", "number,title,state,url,isDraft", ]); - if (await jsonEmpty(prRaw)) { - fail(`pr: created PR for ${branch}, but could not find it afterward`); + if (json.empty(prRaw)) { + io.fail(`pr: created PR for ${branch}, but could not find it afterward`); } } -const number = await jsonGet(prRaw, ".[0].number"); -const url = await jsonGet(prRaw, ".[0].url"); -const isDraft = await jsonGet(prRaw, ".[0].isDraft"); +const number = json.get(prRaw, ".[0].number"); +const url = json.get(prRaw, ".[0].url"); +const isDraft = json.get(prRaw, ".[0].isDraft"); if (created) { - print(`created PR #${number}: ${url}`); + io.print(`created PR #${number}: ${url}`); } else { - print(`found PR #${number}: ${url}`); + io.print(`found PR #${number}: ${url}`); } if (isDraft === "true" && !options.draft) { - await run("gh", ["pr", "ready", number]); - print(`marked PR #${number} ready`); + await cmd.run("gh", ["pr", "ready", number]); + io.print(`marked PR #${number} ready`); } if (!options.noWatch) { @@ -226,6 +184,6 @@ if (!options.noWatch) { } if (!options.noMerge) { - await run("gh", ["pr", "merge", number, "--squash", "--delete-branch"]); - print(`squash-merged PR #${number}`); + await cmd.run("gh", ["pr", "merge", number, "--squash", "--delete-branch"]); + io.print(`squash-merged PR #${number}`); } diff --git a/.runseal/wrappers/release.ts b/.runseal/wrappers/release.ts index 00a58ed..1906dab 100644 --- a/.runseal/wrappers/release.ts +++ b/.runseal/wrappers/release.ts @@ -1,4 +1,13 @@ -import { fail, jsonEmpty, jsonGet, print, run, runsealText, runText } from "../lib/runseal.ts"; +import { + booleanOption, + helpRequested, + parseArgs as parseCliArgs, + requireNoPositionals, + stringOption, +} from "@/lib/cli.ts"; +import { cmd } from "@/lib/std/cmd.ts"; +import { io } from "@/lib/std/io.ts"; +import { json } from "@/lib/std/json.ts"; type Options = { channel: string; @@ -8,77 +17,46 @@ type Options = { dryRun: boolean; }; +function workflowForChannel(channel: string): string { + switch (channel) { + case "stable": + return "release-stable.yml"; + case "beta": + return "release-beta.yml"; + default: + return io.fail(`invalid choice: ${channel}`, 2); + } +} + function usage(): void { - print("Usage: runseal :release --channel=stable|beta [options]"); - print(""); - print("Trigger a release workflow."); - print(""); - print("Options:"); - print(" --channel <name> release channel: stable or beta"); - print(" --ref <ref> git ref passed to the workflow (default: main)"); - print(" --version <version> optional workflow version_override"); - print(" --watch watch the triggered workflow run"); - print(" --dry-run print planned action without triggering a workflow"); + io.print("Usage: runseal :release --channel=stable|beta [options]"); + io.print(""); + io.print("Trigger one GitHub release workflow for the selected channel."); + io.print("Use --ref for branch beta runs; the default ref is main."); + io.print(""); + io.print("Options:"); + io.print(" --channel <name> release channel: stable or beta"); + io.print(" --ref <ref> git ref passed to the workflow (default: main)"); + io.print(" --version <version> optional release version override, e.g. v0.9.0-beta.2"); + io.print(" --watch watch the triggered workflow run"); + io.print(" --dry-run print planned action without triggering a workflow"); } function parseArgs(args: string[]): Options & { help: boolean; argc: number } { - const options = { - channel: "", - ref: "main", - version: "", - watch: false, - dryRun: false, - help: false, + const parsed = parseCliArgs(args, { + string: ["channel", "ref", "version"], + boolean: ["watch", "dry-run", "help", "h"], + }); + requireNoPositionals(parsed, "release", { allowHelp: true }); + return { + channel: stringOption(parsed, "channel"), + ref: stringOption(parsed, "ref", "main"), + version: stringOption(parsed, "version"), + watch: booleanOption(parsed, "watch"), + dryRun: booleanOption(parsed, "dry-run"), + help: helpRequested(parsed), argc: args.length, }; - while (args.length > 0) { - const arg = args.shift()!; - if (arg === "--") { - break; - } - if (arg === "-h" || arg === "--help" || arg === "help") { - options.help = true; - continue; - } - if (arg === "--watch") { - options.watch = true; - continue; - } - if (arg === "--dry-run") { - options.dryRun = true; - continue; - } - for ( - const [name, key] of [ - ["--channel", "channel"], - ["--ref", "ref"], - ["--version", "version"], - ] as const - ) { - if (arg === name) { - const value = args.shift(); - if (value === undefined) { - fail(`missing value for ${name}`); - } - options[key] = value; - continue; - } - const prefix = `${name}=`; - if (arg.startsWith(prefix)) { - options[key] = arg.slice(prefix.length); - continue; - } - } - if ( - arg === "--channel" || arg.startsWith("--channel=") || - arg === "--ref" || arg.startsWith("--ref=") || - arg === "--version" || arg.startsWith("--version=") - ) { - continue; - } - fail(`unknown option: ${arg}`); - } - return options; } const options = parseArgs([...Deno.args]); @@ -87,32 +65,22 @@ if (options.argc === 0 || options.help) { Deno.exit(0); } if (options.channel === "") { - fail("release: --channel is required"); + io.fail("release: --channel is required"); } -let workflow: string; -switch (options.channel) { - case "stable": - workflow = "release-stable.yml"; - break; - case "beta": - workflow = "release-beta.yml"; - break; - default: - fail(`invalid choice: ${options.channel}`, 2); -} +const workflow = workflowForChannel(options.channel); const dryRunCommand = `gh workflow run ${workflow} --ref ${options.ref} -f ref=${options.ref} -f version_override=${options.version}`; if (options.dryRun) { - print(dryRunCommand); + io.print(dryRunCommand); Deno.exit(0); } -await run("gh", ["--version"]); -await run("gh", ["auth", "status"]); -const refSha = await runText("git", ["rev-parse", options.ref]); -const triggerOutput = await runText("gh", [ +await cmd.run("gh", ["--version"]); +await cmd.run("gh", ["auth", "status"]); +const refSha = await cmd.text("git", ["rev-parse", options.ref]); +const triggerOutput = await cmd.text("gh", [ "workflow", "run", workflow, @@ -124,23 +92,16 @@ const triggerOutput = await runText("gh", [ `version_override=${options.version}`, ]); if (triggerOutput !== "") { - print(triggerOutput); + io.print(triggerOutput); } -print(`triggered ${workflow} for ref ${options.ref}`); +io.print(`triggered ${workflow} for ref ${options.ref}`); if (options.watch) { - let runId = await runsealText([ - "@tool", - "regex", - "capture", - triggerOutput, - "/actions/runs/([0-9]+)", - "1", - ]); + let runId = triggerOutput.match(/\/actions\/runs\/([0-9]+)/)?.[1] ?? ""; if (runId === "") { let raw = "[]"; for (let attempt = 0; attempt < 6; attempt += 1) { - raw = await runText("gh", [ + raw = await cmd.text("gh", [ "run", "list", "--workflow", @@ -156,15 +117,15 @@ if (options.watch) { "--json", "databaseId", ]); - if (!(await jsonEmpty(raw))) { - runId = await jsonGet(raw, ".[0].databaseId"); + if (!json.empty(raw)) { + runId = json.get(raw, ".[0].databaseId"); break; } await new Promise((resolve) => setTimeout(resolve, 2000)); } } if (runId === "") { - fail(`release: could not find a recent run for ${workflow} on ${options.ref}`); + io.fail(`release: could not find a recent run for ${workflow} on ${options.ref}`); } - await run("gh", ["run", "watch", runId, "--interval", "10"]); + await cmd.run("gh", ["run", "watch", runId, "--interval", "10"]); } diff --git a/AGENTS.md b/AGENTS.md index 00f0b27..e557873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,27 +98,38 @@ local guides over repeating their detail here. Normal workflow: -1. Work on a feature branch. +1. Work on a dedicated feature branch for every substantive change. 2. Keep changes scoped to the current product boundary. -3. Validate locally before PR. -4. Use repo wrappers for recurring operator flows when they already encode the +3. Prefer `git commit` as the default validation trigger; the repo-installed + pre-commit hook runs `runseal :guard`. +4. Use standalone lint, format, or test commands only for focused diagnosis or + repair after the repo guard reports a failure. +5. Use repo wrappers for recurring operator flows when they already encode the intended path. -Common validation commands: +Branch safety: + +- The generated pre-commit hook rejects direct commits on `main`; create a + feature branch before committing. +- Treat `--no-verify` as an explicit exception only. If used, record why in the + surrounding handoff or final note. + +Default validation path: ```bash -cargo fmt --check -cargo test --locked --workspace -flavor check +git commit ``` +Use `runseal :guard` only when an explicit manual guard run is needed before a +commit or while diagnosing hook failures. + Common repo workflow commands: ```bash runseal :init runseal :cloudflare runseal :pr -runseal :release beta +runseal :release --channel beta --ref <branch> --watch ``` Manager install/update path: diff --git a/Cargo.lock b/Cargo.lock index c7eab5f..1a4d3c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "1.0.0" @@ -100,15 +91,6 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bstr" version = "1.12.1" @@ -200,41 +182,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "dirs" version = "6.0.0" @@ -359,16 +312,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -975,34 +918,11 @@ dependencies = [ "thiserror", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -1060,18 +980,15 @@ dependencies = [ [[package]] name = "runseal" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "assert_cmd", - "base64", "clap", "path-absolutize", - "regex", "reqwest", "serde", "serde_json", - "sha2", "shellexpand", "tempfile", "toml", @@ -1214,17 +1131,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shellexpand" version = "3.1.2" @@ -1510,12 +1416,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -1558,12 +1458,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/README.md b/README.md index 37cc4d4..19f3d23 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Run an external command or a named wrapper inside the profile: ```bash runseal bash -lc 'echo "$APP_SSH_CONFIG"' -runseal :ssh host --run ./probe.sh +runseal :deploy --dry-run ``` ## Inspect What Runseal Sees @@ -49,9 +49,8 @@ Runseal inspection commands are read-only and do not run profile injections: runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config -runseal @tool json get '{"releaseVersion":"v0.6.0"}' '.releaseVersion' runseal @wrappers -runseal @which :ssh +runseal @which :deploy ``` These commands answer the first debugging questions: which profile was selected, @@ -129,7 +128,7 @@ Install a beta or one explicit version: ```bash curl -fsSL https://runseal.perish.uk/manage.sh | sh -s -- install --channel beta -curl -fsSL https://runseal.perish.uk/manage.sh | sh -s -- install --version v0.1.0-beta.10 +curl -fsSL https://runseal.perish.uk/manage.sh | sh -s -- install --version vX.Y.Z-beta.N ``` Uninstall: @@ -236,7 +235,7 @@ If the command token starts with `:`, runseal resolves it as a wrapper executable instead of a literal program name: ```bash -runseal :ssh host --run ./probe.sh -- arg +runseal :deploy --dry-run ``` Wrapper lookup order is: @@ -289,10 +288,30 @@ The wrapper still receives `RUNSEAL_WRAPPER_NAME`, `RUNSEAL_WRAPPER_FILE`, `RUNSEAL_PROFILE_PATH`, `RUNSEAL_HOME`, `RUNSEAL_PROFILE_HOME`, and `RUNSEAL_WRAPPER_PATH`. -Keep reusable domain atoms in `@tool`, such as SSH config inspection, stdin -script execution, path-list joining, branch slugging, GitHub/Gitee PR API -calls, Cloudflare helpers, and encrypted local archive round trips. Use the -wrapper to bind repo-local policy, defaults, and flow around those atoms. +Wrapper helper modules live in the repo under `.runseal/lib/`. Import them +directly through the repo import map; do not treat generic helpers as public +Rust `@tool` namespaces: + +```ts +import { json } from "@/lib/std/json.ts"; +import { runseal } from "@/lib/std/runseal.ts"; + +const zone = await runseal.text([ + "@tool", + "cloudflare", + "zone", + "get", + "--name", + "perish.uk", +]); +console.log(json.get(zone, ".id")); +``` + +Keep reusable domain atoms in `@tool` only when they remain product-domain +operations that are not better expressed in Deno wrapper code. The current +built-in tool surface is intentionally small: GitHub helpers and Cloudflare +helpers. Use wrappers to bind repo-local policy, defaults, and flow around those +atoms. Prefer visible repo or local artifacts under `.runseal/` or `.local/` for multi-line config and payload text. The wrapper should usually validate @@ -309,7 +328,7 @@ runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config runseal @wrappers -runseal @which :ssh +runseal @which :deploy ``` Runseal-owned commands do not run profile injections. Inspection commands are @@ -320,10 +339,10 @@ read-only; `@tool` is the explicit atomic tool runtime. - `@resources` prints the resolved resource root. - `@resolve resource://...` prints resolved absolute resource paths, one per argument. -- `@tool <namespace> <command> ...` runs an atomic runseal tool command. Cold - start supports JSON, string, regex, integer, process, filesystem, archive, - SSH config, GitHub, Gitee, and Cloudflare helpers. Run `runseal @tool --help` - for the complete tool index. Tools are reusable atoms: they may read generic +- `@tool <namespace> <command> ...` runs an atomic runseal tool command. The + current public namespaces are `github` and `cloudflare`. Run + `runseal @tool --help` for the complete tool index. Tools are reusable atoms: + they may read generic defaults such as service tokens, but profile-specific paths and env names should be supplied by the calling wrapper. - `@wrappers` lists the effective wrappers visible to the current profile. @@ -366,15 +385,15 @@ Initialize local development hooks: runseal :init ``` +Commit-driven validation is the default path: + ```bash -cargo fmt --check -cargo test +git commit ``` -Repo-local operator commands use runseal itself: +The generated pre-commit hook runs `runseal :guard`. For explicit manual +validation before committing or while diagnosing a failure: ```bash -runseal :cloudflare manage-inspect -runseal :pr --dry-run -runseal :release --channel beta --dry-run +runseal :guard ``` diff --git a/app/Cargo.toml b/app/Cargo.toml index 8dc7b3a..fa35f64 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runseal" -version = "0.8.0" +version = "0.9.0" edition = "2024" [dependencies] @@ -8,15 +8,12 @@ anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -regex = "1.11" shellexpand = "3.1" path-absolutize = "3.1" toml = "0.8" yaml_serde = "0.10" -tempfile = "3.12" -base64 = "0.22" -sha2 = "0.10" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } [dev-dependencies] assert_cmd = "2.0" +tempfile = "3.12" diff --git a/app/src/core/tool/archive/mod.rs b/app/src/core/tool/archive/mod.rs deleted file mode 100644 index 69e16b3..0000000 --- a/app/src/core/tool/archive/mod.rs +++ /dev/null @@ -1,379 +0,0 @@ -use std::{ - ffi::OsStr, - path::{Component, Path, PathBuf}, - process::{Command, Stdio}, -}; - -use anyhow::{Context, Result, bail}; -use tempfile::TempDir; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "local" => local(args), - _ => bail!("unknown tool command: archive {command}"), - } -} - -fn local(args: &[String]) -> Result<Option<String>> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool archive local export|import ..."); - }; - match command.as_str() { - "export" => export_local(rest), - "import" => import_local(rest), - _ => bail!("usage: runseal @tool archive local export|import ..."), - } -} - -fn export_local(args: &[String]) -> Result<Option<String>> { - let options = LocalOptions::parse(args, false)?; - let source = options.source()?; - if !source.is_dir() { - bail!("archive source is not a directory: {}", source.display()); - } - let archive = options.archive()?; - if archive.exists() { - bail!( - "refusing to overwrite existing archive: {}", - archive.display() - ); - } - if let Some(parent) = archive.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - - let password = options.password()?; - let temp = TempDir::new().context("failed to create temporary archive directory")?; - let tar_path = temp.path().join("local.tar.gz"); - let source_parent = source - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let source_name = source - .file_name() - .ok_or_else(|| anyhow::anyhow!("invalid source path: {}", source.display()))?; - - run( - Command::new("tar") - .arg("-czf") - .arg(&tar_path) - .arg("-C") - .arg(source_parent) - .arg(source_name), - "tar export", - )?; - openssl_encrypt(&tar_path, &archive, &password)?; - chmod_path(&archive, 0o600)?; - Ok(None) -} - -fn import_local(args: &[String]) -> Result<Option<String>> { - let options = LocalOptions::parse(args, true)?; - let dest = options.source()?; - let archive = options.archive()?; - if !archive.is_file() { - bail!("archive not found: {}", archive.display()); - } - if dest.exists() && !options.force { - bail!("refusing to replace existing local directory without --force"); - } - let password = options.password()?; - let temp = TempDir::new().context("failed to create temporary archive directory")?; - let tar_path = temp.path().join("local.tar.gz"); - openssl_decrypt(&archive, &tar_path, &password)?; - validate_tar_entries(&tar_path)?; - - let extract_dir = temp.path().join("extract"); - std::fs::create_dir_all(&extract_dir) - .with_context(|| format!("failed to create {}", extract_dir.display()))?; - run( - Command::new("tar") - .arg("-xzf") - .arg(&tar_path) - .arg("-C") - .arg(&extract_dir), - "tar import", - )?; - - let restored = extract_dir.join( - dest.file_name() - .ok_or_else(|| anyhow::anyhow!("invalid destination path: {}", dest.display()))?, - ); - if !restored.is_dir() { - bail!("archive does not contain {}", restored.display()); - } - if dest.exists() { - std::fs::remove_dir_all(&dest) - .with_context(|| format!("failed to remove {}", dest.display()))?; - } - copy_dir_all(&restored, &dest)?; - fix_local_permissions(&dest)?; - Ok(None) -} - -#[derive(Default)] -struct LocalOptions { - source: Option<String>, - archive: Option<String>, - password: Option<String>, - password_env: Option<String>, - force: bool, -} - -impl LocalOptions { - fn parse(args: &[String], allow_force: bool) -> Result<Self> { - let mut options = LocalOptions::default(); - let mut rest = args; - while let Some((flag, tail)) = rest.split_first() { - match flag.as_str() { - "--source" => { - let Some((value, next)) = tail.split_first() else { - bail!("missing value for --source"); - }; - options.source = Some(value.clone()); - rest = next; - } - "--archive" => { - let Some((value, next)) = tail.split_first() else { - bail!("missing value for --archive"); - }; - options.archive = Some(value.clone()); - rest = next; - } - "--password" => { - let Some((value, next)) = tail.split_first() else { - bail!("missing value for --password"); - }; - options.password = Some(value.clone()); - rest = next; - } - "--password-env" => { - let Some((value, next)) = tail.split_first() else { - bail!("missing value for --password-env"); - }; - options.password_env = Some(value.clone()); - rest = next; - } - "--force" if allow_force => { - options.force = true; - rest = tail; - } - _ => bail!( - "usage: runseal @tool archive local export|import --source <dir> --archive <path> (--password <text>|--password-env <name>) [--force]" - ), - } - } - Ok(options) - } - - fn source(&self) -> Result<PathBuf> { - self.source - .as_ref() - .map(PathBuf::from) - .ok_or_else(|| anyhow::anyhow!("missing --source")) - } - - fn archive(&self) -> Result<PathBuf> { - self.archive - .as_ref() - .map(PathBuf::from) - .ok_or_else(|| anyhow::anyhow!("missing --archive")) - } - - fn password(&self) -> Result<String> { - match (&self.password, &self.password_env) { - (Some(password), None) if !password.is_empty() => Ok(password.clone()), - (None, Some(name)) => { - let value = std::env::var(name) - .with_context(|| format!("missing password env var: {name}"))?; - if value.is_empty() { - bail!("password env var is empty: {name}"); - } - Ok(value) - } - (Some(_), Some(_)) => bail!("use only one of --password or --password-env"), - _ => bail!("missing --password or --password-env"), - } - } -} - -fn openssl_encrypt(input: &Path, output: &Path, password: &str) -> Result<()> { - let mut child = Command::new("openssl") - .args(["enc", "-aes-256-cbc", "-salt", "-pbkdf2", "-out"]) - .arg(output) - .arg("-pass") - .arg("stdin") - .arg("-in") - .arg(input) - .stdin(Stdio::piped()) - .spawn() - .context("failed to start openssl encryption")?; - write_password(&mut child, password)?; - let status = child.wait().context("failed to wait for openssl")?; - if !status.success() { - bail!("openssl encryption failed"); - } - Ok(()) -} - -fn openssl_decrypt(input: &Path, output: &Path, password: &str) -> Result<()> { - let mut child = Command::new("openssl") - .args(["enc", "-d", "-aes-256-cbc", "-pbkdf2", "-in"]) - .arg(input) - .arg("-pass") - .arg("stdin") - .arg("-out") - .arg(output) - .stdin(Stdio::piped()) - .spawn() - .context("failed to start openssl decryption")?; - write_password(&mut child, password)?; - let status = child.wait().context("failed to wait for openssl")?; - if !status.success() { - bail!("openssl decryption failed"); - } - Ok(()) -} - -fn write_password(child: &mut std::process::Child, password: &str) -> Result<()> { - use std::io::Write; - - let mut stdin = child - .stdin - .take() - .ok_or_else(|| anyhow::anyhow!("failed to open openssl stdin"))?; - stdin - .write_all(password.as_bytes()) - .context("failed to write openssl password")?; - stdin - .write_all(b"\n") - .context("failed to finish openssl password")?; - Ok(()) -} - -fn validate_tar_entries(path: &Path) -> Result<()> { - let output = Command::new("tar") - .arg("-tzf") - .arg(path) - .output() - .context("failed to list tar archive")?; - if !output.status.success() { - bail!("failed to list tar archive"); - } - let stdout = String::from_utf8(output.stdout).context("tar listing was not UTF-8")?; - for line in stdout.lines() { - validate_relative_entry(line)?; - } - Ok(()) -} - -fn validate_relative_entry(entry: &str) -> Result<()> { - let path = Path::new(entry); - if path.is_absolute() { - bail!("unsafe archive path: {entry}"); - } - let mut components = path.components(); - let Some(Component::Normal(first)) = components.next() else { - bail!("unsafe archive path: {entry}"); - }; - if first != OsStr::new(".local") { - bail!("archive entry must be under .local/: {entry}"); - } - for component in components { - match component { - Component::Normal(_) => {} - _ => bail!("unsafe archive path: {entry}"), - } - } - Ok(()) -} - -fn run(command: &mut Command, label: &str) -> Result<()> { - let status = command - .status() - .with_context(|| format!("failed to execute {label}"))?; - if !status.success() { - bail!("{label} failed"); - } - Ok(()) -} - -fn copy_dir_all(source: &Path, dest: &Path) -> Result<()> { - std::fs::create_dir_all(dest) - .with_context(|| format!("failed to create {}", dest.display()))?; - for entry in - std::fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))? - { - let entry = entry.with_context(|| format!("failed to read {}", source.display()))?; - let file_type = entry - .file_type() - .with_context(|| format!("failed to read file type: {}", entry.path().display()))?; - let target = dest.join(entry.file_name()); - if file_type.is_dir() { - copy_dir_all(&entry.path(), &target)?; - } else if file_type.is_file() { - std::fs::copy(entry.path(), &target) - .with_context(|| format!("failed to copy {}", entry.path().display()))?; - } - } - Ok(()) -} - -fn fix_local_permissions(local: &Path) -> Result<()> { - if local.is_dir() { - chmod_path(local, 0o700)?; - } - for name in ["ssh", "kube", "secrets", "tmp"] { - let path = local.join(name); - if path.is_dir() { - chmod_path(&path, 0o700)?; - } - } - for name in ["ssh", "kube", "secrets"] { - let path = local.join(name); - if path.is_dir() { - chmod_files_recursive(&path, 0o600)?; - } - } - Ok(()) -} - -fn chmod_files_recursive(path: &Path, mode: u32) -> Result<()> { - for entry in - std::fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? - { - let entry = entry.with_context(|| format!("failed to read {}", path.display()))?; - let file_type = entry - .file_type() - .with_context(|| format!("failed to read file type: {}", entry.path().display()))?; - if file_type.is_dir() { - chmod_files_recursive(&entry.path(), mode)?; - } else if file_type.is_file() { - chmod_path(&entry.path(), mode)?; - } - } - Ok(()) -} - -#[cfg(unix)] -fn chmod_path(path: &Path, mode: u32) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let mut permissions = std::fs::metadata(path) - .with_context(|| format!("failed to read metadata: {}", path.display()))? - .permissions(); - permissions.set_mode(mode); - std::fs::set_permissions(path, permissions) - .with_context(|| format!("failed to chmod {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn chmod_path(path: &Path, _mode: u32) -> Result<()> { - std::fs::metadata(path) - .with_context(|| format!("failed to read metadata: {}", path.display()))?; - Ok(()) -} diff --git a/app/src/core/tool/fs.rs b/app/src/core/tool/fs.rs deleted file mode 100644 index 1bd85ac..0000000 --- a/app/src/core/tool/fs.rs +++ /dev/null @@ -1,296 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use base64::Engine; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "mkdir" => mkdir(args), - "write" => write(args), - "write-base64" => write_base64(args), - "chmod" => chmod(args), - "mode" => mode(args), - "touch" => touch(args), - "list" => list(args), - "contains-any" => contains_any(args), - "backup-numbered" => backup_numbered(args), - _ => bail!("unknown tool command: fs {command}"), - } -} - -fn mkdir(args: &[String]) -> Result<Option<String>> { - let (path, mode) = match args { - [path] => (path, None), - [path, mode] => (path, Some(mode)), - _ => bail!("usage: runseal @tool fs mkdir <path> [mode]"), - }; - std::fs::create_dir_all(path).with_context(|| format!("failed to create directory: {path}"))?; - if let Some(mode) = mode { - chmod_path(Path::new(path), mode)?; - } - Ok(None) -} - -fn write(args: &[String]) -> Result<Option<String>> { - let (path, text, mode) = match args { - [path, text] => (path, text, None), - [path, text, mode] => (path, text, Some(mode)), - _ => bail!("usage: runseal @tool fs write <path> <text> [mode]"), - }; - if let Some(parent) = Path::new(path).parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - std::fs::write(path, text).with_context(|| format!("failed to write file: {path}"))?; - if let Some(mode) = mode { - chmod_path(Path::new(path), mode)?; - } - Ok(None) -} - -fn write_base64(args: &[String]) -> Result<Option<String>> { - let [path, encoded] = args else { - bail!("usage: runseal @tool fs write-base64 <path> <base64>"); - }; - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .context("invalid base64 content")?; - if let Some(parent) = Path::new(path).parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - std::fs::write(path, bytes).with_context(|| format!("failed to write file: {path}"))?; - Ok(None) -} - -fn chmod(args: &[String]) -> Result<Option<String>> { - let [path, mode] = args else { - bail!("usage: runseal @tool fs chmod <path> <mode>"); - }; - chmod_path(Path::new(path), mode)?; - Ok(None) -} - -fn mode(args: &[String]) -> Result<Option<String>> { - let [path] = args else { - bail!("usage: runseal @tool fs mode <path>"); - }; - mode_path(Path::new(path)) -} - -fn touch(args: &[String]) -> Result<Option<String>> { - let (path, mode) = match args { - [path] => (path, None), - [path, mode] => (path, Some(mode)), - _ => bail!("usage: runseal @tool fs touch <path> [mode]"), - }; - let path = Path::new(path); - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("failed to touch {}", path.display()))?; - if let Some(mode) = mode { - chmod_path(path, mode)?; - } - Ok(None) -} - -fn list(args: &[String]) -> Result<Option<String>> { - let [dir, rest @ ..] = args else { - bail!( - "usage: runseal @tool fs list <path> [--glob <pattern>] [--files] [--dirs] [--require-nonempty]" - ); - }; - let mut glob = "*".to_string(); - let mut files = false; - let mut dirs = false; - let mut require_nonempty = false; - let mut index = 0; - while index < rest.len() { - match rest[index].as_str() { - "--glob" => { - let Some(value) = rest.get(index + 1) else { - bail!("--glob requires a value"); - }; - glob = value.clone(); - index += 2; - } - "--files" => { - files = true; - index += 1; - } - "--dirs" => { - dirs = true; - index += 1; - } - "--require-nonempty" => { - require_nonempty = true; - index += 1; - } - other => bail!("unknown fs list argument: {other}"), - } - } - if !files && !dirs { - files = true; - dirs = true; - } - let dir_path = Path::new(dir); - if !dir_path.is_dir() { - bail!("fs list path is not a directory: {}", dir_path.display()); - } - let mut matches = Vec::new(); - for entry in std::fs::read_dir(dir_path) - .with_context(|| format!("failed to read directory: {}", dir_path.display()))? - { - let entry = - entry.with_context(|| format!("failed to read entry in {}", dir_path.display()))?; - let file_name = entry.file_name(); - let Some(name) = file_name.to_str() else { - continue; - }; - if !glob_match(&glob, name) { - continue; - } - let file_type = entry - .file_type() - .with_context(|| format!("failed to read file type: {}", entry.path().display()))?; - if (file_type.is_file() && files) || (file_type.is_dir() && dirs) { - let path = entry - .path() - .canonicalize() - .with_context(|| format!("failed to canonicalize {}", entry.path().display()))?; - matches.push(path.to_string_lossy().into_owned()); - } - } - matches.sort(); - if require_nonempty && matches.is_empty() { - bail!("fs list found no matches in {}", dir_path.display()); - } - Ok(Some(serde_json::to_string(&matches)?)) -} - -fn contains_any(args: &[String]) -> Result<Option<String>> { - let [path, needles @ ..] = args else { - bail!("usage: runseal @tool fs contains-any <path> <text>..."); - }; - if needles.is_empty() { - bail!("fs contains-any requires at least one text argument"); - } - let text = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => return Err(err).with_context(|| format!("failed to read file: {path}")), - }; - Ok(Some( - needles - .iter() - .any(|needle| text.contains(needle)) - .to_string(), - )) -} - -fn backup_numbered(args: &[String]) -> Result<Option<String>> { - let [path] = args else { - bail!("usage: runseal @tool fs backup-numbered <path>"); - }; - let path = PathBuf::from(path); - let backup = next_backup_path(&path)?; - std::fs::rename(&path, &backup) - .with_context(|| format!("failed to move {} to {}", path.display(), backup.display()))?; - Ok(Some(backup.to_string_lossy().into_owned())) -} - -fn next_backup_path(path: &Path) -> Result<PathBuf> { - let backup = path.with_file_name(format!( - "{}.bak", - path.file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow::anyhow!("invalid path: {}", path.display()))? - )); - if !backup.exists() { - return Ok(backup); - } - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow::anyhow!("invalid path: {}", path.display()))?; - for index in 1..1000 { - let candidate = path.with_file_name(format!("{file_name}.bak.{index}")); - if !candidate.exists() { - return Ok(candidate); - } - } - bail!("too many existing backups for {}", path.display()) -} - -fn glob_match(pattern: &str, value: &str) -> bool { - glob_match_inner(pattern.as_bytes(), value.as_bytes()) -} - -fn glob_match_inner(pattern: &[u8], value: &[u8]) -> bool { - match (pattern.split_first(), value.split_first()) { - (None, None) => true, - (None, Some(_)) => false, - (Some((&b'*', rest)), _) => { - glob_match_inner(rest, value) - || value - .split_first() - .is_some_and(|(_, tail)| glob_match_inner(pattern, tail)) - } - (Some((&b'?', rest)), Some((_, tail))) => glob_match_inner(rest, tail), - (Some((&expected, rest)), Some((&actual, tail))) if expected == actual => { - glob_match_inner(rest, tail) - } - _ => false, - } -} - -#[cfg(unix)] -fn chmod_path(path: &Path, mode: &str) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let mode = u32::from_str_radix(mode.trim_start_matches("0o"), 8) - .with_context(|| format!("invalid file mode: {mode}"))?; - let mut permissions = std::fs::metadata(path) - .with_context(|| format!("failed to read metadata: {}", path.display()))? - .permissions(); - permissions.set_mode(mode); - std::fs::set_permissions(path, permissions) - .with_context(|| format!("failed to chmod {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn chmod_path(_path: &Path, _mode: &str) -> Result<()> { - Ok(()) -} - -#[cfg(unix)] -fn mode_path(path: &Path) -> Result<Option<String>> { - use std::os::unix::fs::PermissionsExt; - - let mode = std::fs::metadata(path) - .with_context(|| format!("failed to read metadata: {}", path.display()))? - .permissions() - .mode() - & 0o777; - Ok(Some(format!("{mode:03o}"))) -} - -#[cfg(not(unix))] -fn mode_path(path: &Path) -> Result<Option<String>> { - std::fs::metadata(path) - .with_context(|| format!("failed to read metadata: {}", path.display()))?; - Ok(Some("".to_string())) -} diff --git a/app/src/core/tool/gitee/mod.rs b/app/src/core/tool/gitee/mod.rs deleted file mode 100644 index f8b5dc2..0000000 --- a/app/src/core/tool/gitee/mod.rs +++ /dev/null @@ -1,341 +0,0 @@ -use std::{collections::BTreeMap, path::Path, time::Duration}; - -use anyhow::{Context, Result, bail}; -use serde_json::Value as JsonValue; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "pr" => pr(args), - "repo" => repo(args), - _ => bail!("unknown tool command: gitee {command}"), - } -} - -fn repo(args: &[String]) -> Result<Option<String>> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool gitee repo parse-origin <url>"); - }; - match command.as_str() { - "parse-origin" => repo_parse_origin(rest), - _ => bail!("usage: runseal @tool gitee repo parse-origin <url>"), - } -} - -fn repo_parse_origin(args: &[String]) -> Result<Option<String>> { - let [url] = args else { - bail!("usage: runseal @tool gitee repo parse-origin <url>"); - }; - let (owner, repo) = parse_origin(url)?; - Ok(Some(serde_json::to_string(&serde_json::json!({ - "owner": owner, - "repo": repo, - }))?)) -} - -fn pr(args: &[String]) -> Result<Option<String>> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool gitee pr find|create|pass-gates|merge ..."); - }; - match command.as_str() { - "find" => pr_find(rest), - "create" => pr_create(rest), - "pass-gates" => pr_pass_gates(rest), - "merge" => pr_merge(rest), - _ => bail!("usage: runseal @tool gitee pr find|create|pass-gates|merge ..."), - } -} - -fn pr_find(args: &[String]) -> Result<Option<String>> { - let owner = required_option(args, "--owner")?; - let repo = required_option(args, "--repo")?; - let token = token(args)?; - let head = required_option(args, "--head")?; - let base = optional_option(args, "--base"); - let state = optional_option(args, "--state").unwrap_or_else(|| "open".to_string()); - if !matches!(state.as_str(), "open" | "merged" | "closed" | "all") { - bail!("--state must be one of: open, merged, closed, all"); - } - - let mut query = vec![ - ("head".to_string(), head.clone()), - ("state".to_string(), state), - ]; - if let Some(base) = &base { - query.push(("base".to_string(), base.clone())); - } - - let raw = request_json( - "GET", - &format!("/repos/{owner}/{repo}/pulls"), - &token, - None, - &query, - )?; - let Some(items) = raw.as_array() else { - bail!("Gitee API returned non-array JSON for pull request listing"); - }; - - let matches = items - .iter() - .filter(|item| pr_matches(item, &head, base.as_deref())) - .cloned() - .collect::<Vec<_>>(); - match matches.len() { - 0 => Ok(Some("null".to_string())), - 1 => Ok(Some(serde_json::to_string(&matches[0])?)), - count => bail!( - "Gitee PR find is ambiguous for head `{head}`{}: found {count} matches", - base.as_deref() - .map(|value| format!(" and base `{value}`")) - .unwrap_or_default() - ), - } -} - -fn pr_create(args: &[String]) -> Result<Option<String>> { - let owner = required_option(args, "--owner")?; - let repo = required_option(args, "--repo")?; - let token = token(args)?; - let base = required_option(args, "--base")?; - let head = required_option(args, "--head")?; - let title = required_option(args, "--title")?; - let body = required_option(args, "--body")?; - request( - "POST", - &format!("/repos/{owner}/{repo}/pulls"), - &token, - serde_json::json!({ - "title": title, - "head": head, - "base": base, - "body": body, - }), - ) -} - -fn pr_pass_gates(args: &[String]) -> Result<Option<String>> { - let owner = required_option(args, "--owner")?; - let repo = required_option(args, "--repo")?; - let token = token(args)?; - let number = required_option(args, "--number")?; - let mut result = serde_json::Map::new(); - for op in ["review", "test"] { - let passed = request( - "POST", - &format!("/repos/{owner}/{repo}/pulls/{number}/{op}"), - &token, - serde_json::json!({}), - ) - .is_ok(); - result.insert(op.to_string(), JsonValue::Bool(passed)); - } - Ok(Some(serde_json::to_string(&JsonValue::Object(result))?)) -} - -fn pr_merge(args: &[String]) -> Result<Option<String>> { - let owner = required_option(args, "--owner")?; - let repo = required_option(args, "--repo")?; - let token = token(args)?; - let number = required_option(args, "--number")?; - let method = optional_option(args, "--method").unwrap_or_else(|| "squash".to_string()); - request( - "PUT", - &format!("/repos/{owner}/{repo}/pulls/{number}/merge"), - &token, - serde_json::json!({ - "merge_method": method, - }), - ) -} - -fn request(method: &str, path: &str, token: &str, body: JsonValue) -> Result<Option<String>> { - let payload = request_json(method, path, token, Some(body), &[])?; - Ok(Some(serde_json::to_string(&payload)?)) -} - -fn request_json( - method: &str, - path: &str, - token: &str, - body: Option<JsonValue>, - query: &[(String, String)], -) -> Result<JsonValue> { - let base = std::env::var("RUNSEAL_GITEE_API_BASE") - .unwrap_or_else(|_| "https://gitee.com/api/v5".to_string()); - let path = if path.starts_with('/') { - path.to_string() - } else { - format!("/{path}") - }; - let url = format!("{base}{path}"); - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - let method = method - .parse::<reqwest::Method>() - .with_context(|| format!("invalid HTTP method: {method}"))?; - let response = if let Some(mut body) = body { - let Some(object) = body.as_object_mut() else { - bail!("Gitee request body must be a JSON object"); - }; - object.insert( - "access_token".to_string(), - JsonValue::String(token.to_string()), - ); - client - .request(method.clone(), &url) - .header(reqwest::header::ACCEPT, "application/json") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header(reqwest::header::AUTHORIZATION, format!("token {token}")) - .query(query) - .json(&body) - .send() - .with_context(|| format!("Gitee API {method} {path} unreachable"))? - } else { - client - .request(method.clone(), &url) - .header(reqwest::header::ACCEPT, "application/json") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header(reqwest::header::AUTHORIZATION, format!("token {token}")) - .query(query) - .send() - .with_context(|| format!("Gitee API {method} {path} unreachable"))? - }; - let status = response.status(); - let raw = response - .text() - .with_context(|| format!("Gitee API {method} {path} returned unreadable body"))?; - if !status.is_success() { - bail!("Gitee API {method} {path} -> {}: {raw}", status.as_u16()); - } - if raw.trim().is_empty() { - return Ok(JsonValue::Object(Default::default())); - } - serde_json::from_str(&raw) - .with_context(|| format!("Gitee API returned invalid JSON for {path}")) -} - -fn pr_matches(item: &JsonValue, head: &str, base: Option<&str>) -> bool { - let Some(item_head) = pr_head_branch(item) else { - return false; - }; - if item_head != head { - return false; - } - match base { - Some(expected) => pr_base_branch(item).is_some_and(|value| value == expected), - None => true, - } -} - -fn pr_head_branch(item: &JsonValue) -> Option<&str> { - item.get("head") - .and_then(|value| value.get("ref").or_else(|| value.get("label"))) - .and_then(JsonValue::as_str) - .or_else(|| item.get("head").and_then(JsonValue::as_str)) - .map(|value| value.rsplit(':').next().unwrap_or(value)) -} - -fn pr_base_branch(item: &JsonValue) -> Option<&str> { - item.get("base") - .and_then(|value| value.get("ref").or_else(|| value.get("label"))) - .and_then(JsonValue::as_str) - .or_else(|| item.get("base").and_then(JsonValue::as_str)) - .map(|value| value.rsplit(':').next().unwrap_or(value)) -} - -fn token(args: &[String]) -> Result<String> { - if let Some(token) = optional_option(args, "--token") - && !token.is_empty() - { - return Ok(token); - } - if let Some(path) = optional_option(args, "--token-file") { - let values = parse_env_file(Path::new(&path))?; - if let Some(token) = values.get("GITEE_TOKEN").filter(|value| !value.is_empty()) { - return Ok(token.clone()); - } - bail!("GITEE_TOKEN not set in {path}"); - } - if let Some(name) = optional_option(args, "--token-env") { - let token = std::env::var(&name) - .with_context(|| format!("environment variable not set: {name}"))?; - if token.is_empty() { - bail!("environment variable is empty: {name}"); - } - return Ok(token); - } - if let Ok(token) = std::env::var("GITEE_TOKEN") - && !token.is_empty() - { - return Ok(token); - } - bail!("missing Gitee token: pass --token, --token-file, --token-env, or set GITEE_TOKEN") -} - -fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> { - let text = std::fs::read_to_string(path) - .with_context(|| format!("failed to read token file: {}", path.display()))?; - let mut values = BTreeMap::new(); - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - bail!("invalid line in {}: {line}", path.display()); - }; - values.insert( - key.trim().to_string(), - value - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(), - ); - } - Ok(values) -} - -fn required_option(args: &[String], name: &str) -> Result<String> { - optional_option(args, name).ok_or_else(|| anyhow::anyhow!("{name} is required")) -} - -fn optional_option(args: &[String], name: &str) -> Option<String> { - let prefix = format!("{name}="); - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == name { - return args.get(index + 1).cloned(); - } - if let Some(value) = arg.strip_prefix(&prefix) { - return Some(value.to_string()); - } - index += 1; - } - None -} - -fn parse_origin(url: &str) -> Result<(String, String)> { - let Some(after_host) = url - .strip_prefix("git@gitee.com:") - .or_else(|| url.strip_prefix("ssh://git@gitee.com/")) - .or_else(|| url.strip_prefix("https://gitee.com/")) - .or_else(|| url.strip_prefix("http://gitee.com/")) - else { - bail!("cannot parse Gitee owner/repo from origin url: {url}"); - }; - let path = after_host.trim_end_matches(".git"); - let mut parts = path.split('/'); - let Some(owner) = parts.next().filter(|value| !value.is_empty()) else { - bail!("cannot parse Gitee owner/repo from origin url: {url}"); - }; - let Some(repo) = parts.next().filter(|value| !value.is_empty()) else { - bail!("cannot parse Gitee owner/repo from origin url: {url}"); - }; - if parts.next().is_some() { - bail!("cannot parse Gitee owner/repo from origin url: {url}"); - } - Ok((owner.to_string(), repo.to_string())) -} diff --git a/app/src/core/tool/hash/mod.rs b/app/src/core/tool/hash/mod.rs deleted file mode 100644 index 7831b09..0000000 --- a/app/src/core/tool/hash/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::{ - fs, - io::Read, - path::{Path, PathBuf}, -}; - -use anyhow::{Context, Result, bail}; -use sha2::{Digest, Sha256}; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "tree" => tree(args), - _ => bail!("unknown tool command: hash {command}"), - } -} - -fn tree(args: &[String]) -> Result<Option<String>> { - if args.is_empty() { - bail!("usage: runseal @tool hash tree <path>..."); - } - let mut entries = Vec::new(); - for input in args { - let path = Path::new(input); - collect(path, PathBuf::from(input), &mut entries)?; - } - entries.sort_by(|left, right| left.0.cmp(&right.0)); - - let mut hasher = Sha256::new(); - for (label, file) in entries { - hasher.update(label.as_bytes()); - hasher.update([0]); - let mut handle = fs::File::open(&file) - .with_context(|| format!("failed to open file: {}", file.display()))?; - let mut buffer = Vec::new(); - handle - .read_to_end(&mut buffer) - .with_context(|| format!("failed to read file: {}", file.display()))?; - hasher.update(&buffer); - hasher.update([0]); - } - - Ok(Some(format!("{:x}", hasher.finalize()))) -} - -fn collect(path: &Path, label: PathBuf, entries: &mut Vec<(String, PathBuf)>) -> Result<()> { - let metadata = - fs::metadata(path).with_context(|| format!("path not found: {}", path.display()))?; - if metadata.is_file() { - entries.push((normalize(&label), path.to_path_buf())); - return Ok(()); - } - if metadata.is_dir() { - for entry in fs::read_dir(path) - .with_context(|| format!("failed to read directory: {}", path.display()))? - { - let entry = - entry.with_context(|| format!("failed to read entry: {}", path.display()))?; - let name = entry.file_name(); - let child_path = entry.path(); - let child_label = label.join(&name); - collect(&child_path, child_label, entries)?; - } - return Ok(()); - } - bail!("unsupported path for hash tree: {}", path.display()) -} - -fn normalize(path: &Path) -> String { - path.components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::<Vec<_>>() - .join("/") -} diff --git a/app/src/core/tool/help/basic.rs b/app/src/core/tool/help/basic.rs deleted file mode 100644 index 5bb8f39..0000000 --- a/app/src/core/tool/help/basic.rs +++ /dev/null @@ -1,437 +0,0 @@ -use super::{Entry, Section}; - -pub const STRING: Entry = Entry { - key: "string", - usage: "runseal @tool string <command> [args]", - about: None, - sections: &[Section { - title: "String helpers", - items: &[ - ("trim <value>", "trim leading and trailing whitespace"), - ( - "join <json-array> --separator <text>", - "join a JSON string array", - ), - ("slug <value>", "normalize text for branch/file slugs"), - ], - }], - examples: &[], -}; - -pub const STRING_TRIM: Entry = Entry { - key: "string.trim", - usage: "runseal @tool string trim <value>", - about: None, - sections: &[], - examples: &[], -}; - -pub const STRING_JOIN: Entry = Entry { - key: "string.join", - usage: "runseal @tool string join <json-array> --separator <text|path>", - about: None, - sections: &[], - examples: &[], -}; - -pub const STRING_SLUG: Entry = Entry { - key: "string.slug", - usage: "runseal @tool string slug <value> [--max-len <n>] [--fallback <text>]", - about: None, - sections: &[], - examples: &[], -}; - -pub const REGEX: Entry = Entry { - key: "regex", - usage: "runseal @tool regex <command> [args]", - about: None, - sections: &[Section { - title: "Regex helpers", - items: &[( - "capture <value> <pattern> <group>", - "print regex capture group n, or empty", - )], - }], - examples: &[], -}; - -pub const REGEX_CAPTURE: Entry = Entry { - key: "regex.capture", - usage: "runseal @tool regex capture <value> <pattern> <group>", - about: None, - sections: &[], - examples: &[], -}; - -pub const INT: Entry = Entry { - key: "int", - usage: "runseal @tool int <command> [args]", - about: None, - sections: &[Section { - title: "Integer helpers", - items: &[("add <left> <right>", "print integer sum")], - }], - examples: &[], -}; - -pub const INT_ADD: Entry = Entry { - key: "int.add", - usage: "runseal @tool int add <left> <right>", - about: None, - sections: &[], - examples: &[], -}; - -pub const PROCESS: Entry = Entry { - key: "process", - usage: "runseal @tool process <command> [args]", - about: None, - sections: &[Section { - title: "Process helpers", - items: &[ - ("exists <name>", "print true when command exists on PATH"), - ( - "write <stdout|stderr> <path> [--append] -- <command> [args...]", - "run one command and write one stream to a file", - ), - ], - }], - examples: &[], -}; - -pub const PROCESS_EXISTS: Entry = Entry { - key: "process.exists", - usage: "runseal @tool process exists <name>", - about: None, - sections: &[], - examples: &[], -}; - -pub const PROCESS_WRITE: Entry = Entry { - key: "process.write", - usage: "runseal @tool process write <stdout|stderr> <path> [--append] -- <command> [args...]", - about: Some( - "Run one command, write one selected stream to a file, and pass the other stream through.", - ), - sections: &[Section { - title: "Flags", - items: &[("--append", "append instead of overwriting the target file")], - }], - examples: &[ - "runseal @tool process write stdout openapi.json -- cargo run -- export-openapi", - "runseal @tool process write stderr build.log --append -- cargo build", - ], -}; - -pub const ARCHIVE: Entry = Entry { - key: "archive", - usage: "runseal @tool archive <scope> <command> [args]", - about: None, - sections: &[Section { - title: "Archive helpers", - items: &[ - ("local export", "encrypt a .local-style directory archive"), - ("local import", "decrypt and restore a .local-style archive"), - ], - }], - examples: &[], -}; - -pub const ARCHIVE_LOCAL: Entry = Entry { - key: "archive.local", - usage: "runseal @tool archive local <command> [args]", - about: None, - sections: &[Section { - title: "Archive local helpers", - items: &[ - ( - "export --source <dir> --archive <path> (--password <text>|--password-env <name>) [--force]", - "encrypt one source directory", - ), - ( - "import --source <dir> --archive <path> (--password <text>|--password-env <name>) [--force]", - "decrypt one archive into the source directory", - ), - ], - }], - examples: &[], -}; - -pub const ARCHIVE_LOCAL_EXPORT: Entry = Entry { - key: "archive.local.export", - usage: "runseal @tool archive local export --source <dir> --archive <path> (--password <text>|--password-env <name>)", - about: Some("Encrypt one .local-style directory archive."), - sections: &[Section { - title: "Flags", - items: &[ - ( - "--source <dir>", - "source directory to export or import into", - ), - ("--archive <path>", "archive file to write or read"), - ("--password <text>", "explicit archive password"), - ( - "--password-env <name>", - "read the password from one environment variable", - ), - ], - }], - examples: &[ - "runseal @tool archive local export --source .local --archive backup.local.archive --password-env ESTATE_LOCAL_PASSWORD", - ], -}; - -pub const ARCHIVE_LOCAL_IMPORT: Entry = Entry { - key: "archive.local.import", - usage: "runseal @tool archive local import --source <dir> --archive <path> (--password <text>|--password-env <name>) [--force]", - about: Some("Decrypt one .local-style directory archive into the source directory."), - sections: &[Section { - title: "Flags", - items: &[ - ("--source <dir>", "destination directory to restore into"), - ("--archive <path>", "archive file to read"), - ("--password <text>", "explicit archive password"), - ( - "--password-env <name>", - "read the password from one environment variable", - ), - ( - "--force", - "allow replacing an existing destination directory", - ), - ], - }], - examples: &[ - "runseal @tool archive local import --source .local --archive backup.local.archive --password-env ESTATE_LOCAL_PASSWORD --force", - ], -}; - -pub const FS: Entry = Entry { - key: "fs", - usage: "runseal @tool fs <command> [args]", - about: None, - sections: &[Section { - title: "Filesystem helpers", - items: &[ - ("mkdir <path> [mode]", "create a directory and parents"), - ("write <path> <text> [mode]", "write text to a file"), - ( - "write-base64 <path> <base64>", - "write decoded bytes to a file", - ), - ("chmod <path> <mode>", "set a file mode on Unix"), - ("mode <path>", "print file mode on Unix"), - ("touch <path> [mode]", "create a file without truncating it"), - ( - "list <path> [--glob <pattern>]", - "list matching direct children as JSON", - ), - ( - "contains-any <path> <text>...", - "print true when file contains any text", - ), - ( - "backup-numbered <path>", - "move path to .bak or .bak.N and print it", - ), - ], - }], - examples: &[], -}; - -pub const FS_LIST: Entry = Entry { - key: "fs.list", - usage: "runseal @tool fs list <path> [--glob <pattern>] [--files] [--dirs] [--require-nonempty]", - about: Some("List matching direct children and print canonical paths as a JSON array."), - sections: &[Section { - title: "Flags", - items: &[ - ( - "--glob <pattern>", - "match file names with a simple glob; default `*`", - ), - ("--files", "include files"), - ("--dirs", "include directories"), - ("--require-nonempty", "fail when the match set is empty"), - ], - }], - examples: &["runseal @tool fs list .local/secrets --glob '*.env' --files"], -}; - -pub const GITEE: Entry = Entry { - key: "gitee", - usage: "runseal @tool gitee <scope> <command> [args]", - about: None, - sections: &[Section { - title: "Gitee helpers", - items: &[ - ( - "repo parse-origin <url>", - "parse owner/repo from origin URL", - ), - ("pr find", "find one pull request by branch filters"), - ("pr create", "create a pull request"), - ("pr pass-gates", "best-effort pass PR gates"), - ("pr merge", "merge a pull request"), - ], - }], - examples: &[], -}; - -pub const GITEE_REPO: Entry = Entry { - key: "gitee.repo", - usage: "runseal @tool gitee repo <command> [args]", - about: None, - sections: &[Section { - title: "Gitee repo helpers", - items: &[( - "parse-origin <url>", - "parse owner/repo from a Gitee origin URL", - )], - }], - examples: &[], -}; - -pub const GITEE_REPO_PARSE_ORIGIN: Entry = Entry { - key: "gitee.repo.parse-origin", - usage: "runseal @tool gitee repo parse-origin <url>", - about: None, - sections: &[], - examples: &[], -}; - -pub const GITEE_PR: Entry = Entry { - key: "gitee.pr", - usage: "runseal @tool gitee pr <command> [args]", - about: None, - sections: &[Section { - title: "Gitee PR helpers", - items: &[ - ("find", "find one Gitee pull request"), - ("create", "create a Gitee pull request"), - ("pass-gates", "best-effort pass PR gates"), - ("merge", "merge a Gitee pull request"), - ], - }], - examples: &[], -}; - -pub const GITEE_PR_FIND: Entry = Entry { - key: "gitee.pr.find", - usage: "runseal @tool gitee pr find --owner <name> --repo <name> --head <branch> [--base <branch>] [--state <open|merged|closed|all>] [--token <text>|--token-file <path>|--token-env <name>]", - about: Some( - "Find one Gitee pull request by branch filters. Prints the PR JSON object or `null` when no match exists.", - ), - sections: &[Section { - title: "Flags", - items: &[ - ("--owner <name>", "Gitee repository owner"), - ("--repo <name>", "Gitee repository name"), - ("--head <branch>", "source branch to match"), - ("--base <branch>", "optional target branch filter"), - ( - "--state <open|merged|closed|all>", - "PR state filter; default `open`", - ), - ("--token <text>", "explicit Gitee token"), - ( - "--token-file <path>", - "env-style file containing `GITEE_TOKEN`", - ), - ( - "--token-env <name>", - "read the token from one named environment variable; default fallback is `GITEE_TOKEN`", - ), - ], - }], - examples: &[ - "runseal @tool gitee pr find --owner perishme --repo perish.top --head auto/demo", - "runseal @tool gitee pr find --owner perishme --repo perish.top --head feat/resume --base main --state open", - ], -}; - -pub const GITEE_PR_CREATE: Entry = Entry { - key: "gitee.pr.create", - usage: "runseal @tool gitee pr create --owner <name> --repo <name> --base <branch> --head <branch> --title <text> --body <text> [--token <text>|--token-file <path>|--token-env <name>]", - about: Some("Create a Gitee pull request and print the API response JSON."), - sections: &[Section { - title: "Flags", - items: &[ - ("--owner <name>", "Gitee repository owner"), - ("--repo <name>", "Gitee repository name"), - ("--base <branch>", "target branch"), - ("--head <branch>", "source branch"), - ("--title <text>", "pull request title"), - ("--body <text>", "pull request body"), - ("--token <text>", "explicit Gitee token"), - ( - "--token-file <path>", - "env-style file containing `GITEE_TOKEN`", - ), - ( - "--token-env <name>", - "read the token from one named environment variable; default fallback is `GITEE_TOKEN`", - ), - ], - }], - examples: &[ - "runseal @tool gitee pr create --owner perishme --repo perish.top --base main --head auto/demo --title demo --body demo", - ], -}; - -pub const GITEE_PR_PASS_GATES: Entry = Entry { - key: "gitee.pr.pass-gates", - usage: "runseal @tool gitee pr pass-gates --owner <name> --repo <name> --number <n> [--token <text>|--token-file <path>|--token-env <name>]", - about: Some("Best-effort pass available Gitee PR gates and print the result JSON."), - sections: &[Section { - title: "Flags", - items: &[ - ("--owner <name>", "Gitee repository owner"), - ("--repo <name>", "Gitee repository name"), - ("--number <n>", "pull request number"), - ("--token <text>", "explicit Gitee token"), - ( - "--token-file <path>", - "env-style file containing `GITEE_TOKEN`", - ), - ( - "--token-env <name>", - "read the token from one named environment variable; default fallback is `GITEE_TOKEN`", - ), - ], - }], - examples: &[ - "runseal @tool gitee pr pass-gates --owner perishme --repo perish.top --number 123", - ], -}; - -pub const GITEE_PR_MERGE: Entry = Entry { - key: "gitee.pr.merge", - usage: "runseal @tool gitee pr merge --owner <name> --repo <name> --number <n> [--method <merge|rebase|squash>] [--token <text>|--token-file <path>|--token-env <name>]", - about: Some("Merge a Gitee pull request and print the API response JSON."), - sections: &[Section { - title: "Flags", - items: &[ - ("--owner <name>", "Gitee repository owner"), - ("--repo <name>", "Gitee repository name"), - ("--number <n>", "pull request number"), - ( - "--method <merge|rebase|squash>", - "merge method; default `squash`", - ), - ("--token <text>", "explicit Gitee token"), - ( - "--token-file <path>", - "env-style file containing `GITEE_TOKEN`", - ), - ( - "--token-env <name>", - "read the token from one named environment variable; default fallback is `GITEE_TOKEN`", - ), - ], - }], - examples: &[ - "runseal @tool gitee pr merge --owner perishme --repo perish.top --number 123 --method squash", - ], -}; diff --git a/app/src/core/tool/help/cloudflare.rs b/app/src/core/tool/help/cloudflare.rs index 6fe16c5..e4f7f2b 100644 --- a/app/src/core/tool/help/cloudflare.rs +++ b/app/src/core/tool/help/cloudflare.rs @@ -266,6 +266,39 @@ pub const CLOUDFLARE_ZONE_DNS_RECORD: Entry = Entry { examples: &[], }; +pub const CLOUDFLARE_ZONE_DNS_RECORD_LIST: Entry = Entry { + key: "cloudflare.zone.dns-record.list", + usage: "runseal @tool cloudflare zone dns-record list --zone-id <id> [--name <name>]", + about: Some("Print DNS records in one zone as a JSON array."), + sections: &[Section { + title: "Flags", + items: &[ + ("--zone-id <id>", "target zone identifier"), + ("--name <name>", "optional exact DNS record name filter"), + ], + }], + examples: &[ + "runseal @tool cloudflare zone dns-record list --zone-id ZONE_ID", + "runseal @tool cloudflare zone dns-record list --zone-id ZONE_ID --name runseal.perish.uk", + ], +}; + +pub const CLOUDFLARE_ZONE_DNS_RECORD_CREATE: Entry = Entry { + key: "cloudflare.zone.dns-record.create", + usage: "runseal @tool cloudflare zone dns-record create --zone-id <id> --json <json>", + about: Some("Create one DNS record from the JSON payload and print the created record."), + sections: &[Section { + title: "Flags", + items: &[ + ("--zone-id <id>", "target zone identifier"), + ("--json <json>", "Cloudflare DNS record payload"), + ], + }], + examples: &[ + "runseal @tool cloudflare zone dns-record create --zone-id ZONE_ID --json '{\"type\":\"CNAME\",\"name\":\"runseal\",\"content\":\"example.com\",\"proxied\":true}'", + ], +}; + pub const CLOUDFLARE_ZONE_DNS_RECORD_UPDATE: Entry = Entry { key: "cloudflare.zone.dns-record.update", usage: "runseal @tool cloudflare zone dns-record update --zone-id <id> --record-id <id> --json <json>", diff --git a/app/src/core/tool/help/github.rs b/app/src/core/tool/help/github.rs index c85610d..87c02b6 100644 --- a/app/src/core/tool/help/github.rs +++ b/app/src/core/tool/help/github.rs @@ -19,6 +19,10 @@ pub const GITHUB: Entry = Entry { "issue body update", "update one issue-style body; also applies to top-level PR bodies", ), + ( + "pr checks probe", + "probe whether a PR head has checks before watching", + ), ], }], examples: &[], @@ -173,3 +177,56 @@ pub const GITHUB_ISSUE_BODY_UPDATE: Entry = Entry { "runseal @tool github issue body update --repo PerishCode/runseal --number 46 --body-file body.md --prefix-enable=true", ], }; + +pub const GITHUB_PR: Entry = Entry { + key: "github.pr", + usage: "runseal @tool github pr <scope> <command> [args]", + about: Some( + "GitHub pull request helper atoms. These are intentionally narrow and are usually called from wrappers.", + ), + sections: &[Section { + title: "GitHub PR helpers", + items: &[( + "checks probe <number>", + "print whether a PR head already has check runs or commit statuses", + )], + }], + examples: &[], +}; + +pub const GITHUB_PR_CHECKS: Entry = Entry { + key: "github.pr.checks", + usage: "runseal @tool github pr checks <command> [args]", + about: None, + sections: &[Section { + title: "GitHub PR check helpers", + items: &[( + "probe <number>", + "print whether a PR head already has check runs or commit statuses", + )], + }], + examples: &[], +}; + +pub const GITHUB_PR_CHECKS_PROBE: Entry = Entry { + key: "github.pr.checks.probe", + usage: "runseal @tool github pr checks probe <number> [--token <text>|--token-file <path>|--token-env <name>]", + about: Some( + "Probe the current repository PR head commit for check runs or commit statuses. Prints `true` or `false`; on API probe failure, prints `true` so wrapper flows do not incorrectly skip check watching.", + ), + sections: &[Section { + title: "Flags", + items: &[ + ("--token <text>", "explicit GitHub token"), + ( + "--token-file <path>", + "env-style file containing `GITHUB_TOKEN`", + ), + ( + "--token-env <name>", + "read the token from one named environment variable; default fallback is `GITHUB_TOKEN`", + ), + ], + }], + examples: &["runseal @tool github pr checks probe 46 --token-env RUNSEAL_GITHUB_TOKEN"], +}; diff --git a/app/src/core/tool/help/hash_version.rs b/app/src/core/tool/help/hash_version.rs deleted file mode 100644 index ecac6c5..0000000 --- a/app/src/core/tool/help/hash_version.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::{Entry, Section}; - -pub const HASH: Entry = Entry { - key: "hash", - usage: "runseal @tool hash <command> [args]", - about: None, - sections: &[Section { - title: "Hash helpers", - items: &[( - "tree <path>...", - "hash one or more file trees deterministically", - )], - }], - examples: &[], -}; - -pub const HASH_TREE: Entry = Entry { - key: "hash.tree", - usage: "runseal @tool hash tree <path>...", - about: Some("Hash one or more file trees using stable path and content ordering."), - sections: &[], - examples: &["runseal @tool hash tree app/tests .runseal/wrappers"], -}; - -pub const VERSION: Entry = Entry { - key: "version", - usage: "runseal @tool version <command> [args]", - about: None, - sections: &[Section { - title: "Version helpers", - items: &[ - ( - "part <version> <major|minor|patch>", - "print one stable semantic version part", - ), - ( - "compare <left> <right>", - "compare two stable semantic versions", - ), - ], - }], - examples: &[], -}; - -pub const VERSION_PART: Entry = Entry { - key: "version.part", - usage: "runseal @tool version part <version> <major|minor|patch>", - about: Some("Print one numeric part from a stable semantic version, with optional `v` prefix."), - sections: &[], - examples: &["runseal @tool version part v0.7.0 minor"], -}; - -pub const VERSION_COMPARE: Entry = Entry { - key: "version.compare", - usage: "runseal @tool version compare <left> <right>", - about: Some("Compare two stable semantic versions and print `lt`, `eq`, or `gt`."), - sections: &[], - examples: &["runseal @tool version compare 0.6.1 0.6.0"], -}; diff --git a/app/src/core/tool/help/json.rs b/app/src/core/tool/help/json.rs deleted file mode 100644 index f371918..0000000 --- a/app/src/core/tool/help/json.rs +++ /dev/null @@ -1,140 +0,0 @@ -use super::{Entry, Section}; - -pub const JSON: Entry = Entry { - key: "json", - usage: "runseal @tool json <command> [args]", - about: None, - sections: &[Section { - title: "JSON helpers", - items: &[ - ("get <json> <path>", "print one JSON value"), - ("has <json> <path>", "print true when the JSON path exists"), - ("empty <json>", "print true when JSON length is zero"), - ("len <json>", "print JSON array/object/string length"), - ("pretty <mode> ...", "print formatted JSON"), - ( - "find <array> <field> <value>", - "print first object with field=value", - ), - ( - "filter <array> <field> <value>...", - "print objects with field matching values", - ), - ], - }], - examples: &[], -}; - -pub const JSON_GET: Entry = Entry { - key: "json.get", - usage: "runseal @tool json get <json> <path>", - about: Some("Print one JSON value selected by the path expression."), - sections: &[Section { - title: "Arguments", - items: &[ - ("<json>", "input JSON text"), - ("<path>", "path expression such as `.[0].databaseId`"), - ], - }], - examples: &["runseal @tool json get '[{\"databaseId\":123}]' '.[0].databaseId'"], -}; - -pub const JSON_HAS: Entry = Entry { - key: "json.has", - usage: "runseal @tool json has <json> <path>", - about: Some("Print `true` when the JSON path exists, otherwise `false`."), - sections: &[Section { - title: "Arguments", - items: &[ - ("<json>", "input JSON text"), - ("<path>", "path expression such as `.guard.version.hash`"), - ], - }], - examples: &[ - "runseal @tool json has '{\"guard\":{\"version\":{\"hash\":\"x\"}}}' .guard.version.hash", - ], -}; - -pub const JSON_EMPTY: Entry = Entry { - key: "json.empty", - usage: "runseal @tool json empty <json>", - about: Some("Print `true` when the JSON array, object, or string length is zero."), - sections: &[], - examples: &["runseal @tool json empty '[]'"], -}; - -pub const JSON_LEN: Entry = Entry { - key: "json.len", - usage: "runseal @tool json len <json>", - about: Some("Print the JSON array, object, or string length as an integer."), - sections: &[], - examples: &["runseal @tool json len '[1,2,3]'"], -}; - -pub const JSON_PRETTY: Entry = Entry { - key: "json.pretty", - usage: "runseal @tool json pretty <mode> [args]", - about: Some("Print formatted JSON with explicit input mode selection."), - sections: &[Section { - title: "Modes", - items: &[ - ("value <json>", "pretty-print one JSON argument"), - ("stdin", "read JSON from stdin and print formatted output"), - ( - "file <input> <output>", - "read one file and write formatted JSON", - ), - ], - }], - examples: &[ - "runseal @tool json pretty value '{\"a\":1}'", - "echo '{\"a\":1}' | runseal @tool json pretty stdin", - "runseal @tool json pretty file input.json output.json", - ], -}; - -pub const JSON_PRETTY_VALUE: Entry = Entry { - key: "json.pretty.value", - usage: "runseal @tool json pretty value <json>", - about: Some("Pretty-print one JSON argument with indentation."), - sections: &[], - examples: &["runseal @tool json pretty value '{\"a\":1}'"], -}; - -pub const JSON_PRETTY_STDIN: Entry = Entry { - key: "json.pretty.stdin", - usage: "runseal @tool json pretty stdin", - about: Some("Read JSON from stdin and print formatted JSON."), - sections: &[], - examples: &["echo '{\"a\":1}' | runseal @tool json pretty stdin"], -}; - -pub const JSON_PRETTY_FILE: Entry = Entry { - key: "json.pretty.file", - usage: "runseal @tool json pretty file <input> <output>", - about: Some("Read one JSON file and write formatted JSON to the output path."), - sections: &[Section { - title: "Arguments", - items: &[ - ("<input>", "source JSON file path"), - ("<output>", "destination file path"), - ], - }], - examples: &["runseal @tool json pretty file input.json output.json"], -}; - -pub const JSON_FIND: Entry = Entry { - key: "json.find", - usage: "runseal @tool json find <array> <field> <value>", - about: Some("Print the first object in the JSON array with `<field> == <value>`."), - sections: &[], - examples: &["runseal @tool json find '[{\"id\":1}]' id 1"], -}; - -pub const JSON_FILTER: Entry = Entry { - key: "json.filter", - usage: "runseal @tool json filter <array> <field> <value>...", - about: Some("Print objects in the JSON array whose `<field>` matches any provided value."), - sections: &[], - examples: &["runseal @tool json filter '[{\"env\":\"dev\"},{\"env\":\"prod\"}]' env dev prod"], -}; diff --git a/app/src/core/tool/help/mod.rs b/app/src/core/tool/help/mod.rs index 860de87..64742a9 100644 --- a/app/src/core/tool/help/mod.rs +++ b/app/src/core/tool/help/mod.rs @@ -1,9 +1,5 @@ -mod basic; mod cloudflare; mod github; -mod hash_version; -mod json; -mod ssh; #[derive(Clone, Copy)] pub struct Entry { @@ -21,47 +17,6 @@ pub struct Section { } const ENTRIES: &[Entry] = &[ - json::JSON, - json::JSON_GET, - json::JSON_HAS, - json::JSON_EMPTY, - json::JSON_LEN, - json::JSON_PRETTY, - json::JSON_PRETTY_VALUE, - json::JSON_PRETTY_STDIN, - json::JSON_PRETTY_FILE, - json::JSON_FIND, - json::JSON_FILTER, - hash_version::HASH, - hash_version::HASH_TREE, - basic::STRING, - basic::STRING_TRIM, - basic::STRING_JOIN, - basic::STRING_SLUG, - basic::REGEX, - basic::REGEX_CAPTURE, - basic::INT, - basic::INT_ADD, - basic::PROCESS, - basic::PROCESS_EXISTS, - basic::PROCESS_WRITE, - basic::ARCHIVE, - basic::ARCHIVE_LOCAL, - basic::ARCHIVE_LOCAL_EXPORT, - basic::ARCHIVE_LOCAL_IMPORT, - basic::FS, - basic::FS_LIST, - basic::GITEE, - basic::GITEE_REPO, - basic::GITEE_REPO_PARSE_ORIGIN, - basic::GITEE_PR, - basic::GITEE_PR_FIND, - basic::GITEE_PR_CREATE, - basic::GITEE_PR_PASS_GATES, - basic::GITEE_PR_MERGE, - hash_version::VERSION, - hash_version::VERSION_PART, - hash_version::VERSION_COMPARE, github::GITHUB, github::GITHUB_ISSUE, github::GITHUB_ISSUE_CREATE, @@ -69,13 +24,9 @@ const ENTRIES: &[Entry] = &[ github::GITHUB_ISSUE_COMMENT_CREATE, github::GITHUB_ISSUE_BODY, github::GITHUB_ISSUE_BODY_UPDATE, - ssh::SSH, - ssh::SSH_CONFIG, - ssh::SSH_CONFIG_HOST, - ssh::SSH_CONFIG_IDENTITIES, - ssh::SSH_SCRIPT, - ssh::SSH_SCRIPT_RUN, - ssh::SSH_SCRIPT_CAPTURE, + github::GITHUB_PR, + github::GITHUB_PR_CHECKS, + github::GITHUB_PR_CHECKS_PROBE, cloudflare::CLOUDFLARE, cloudflare::CLOUDFLARE_CONFIG, cloudflare::CLOUDFLARE_CONFIG_GET, @@ -92,6 +43,8 @@ const ENTRIES: &[Entry] = &[ cloudflare::CLOUDFLARE_ZONE_RULESET_RULE_ADD, cloudflare::CLOUDFLARE_ZONE_RULESET_RULE_UPDATE, cloudflare::CLOUDFLARE_ZONE_DNS_RECORD, + cloudflare::CLOUDFLARE_ZONE_DNS_RECORD_LIST, + cloudflare::CLOUDFLARE_ZONE_DNS_RECORD_CREATE, cloudflare::CLOUDFLARE_ZONE_DNS_RECORD_UPDATE, cloudflare::CLOUDFLARE_ACCOUNT, cloudflare::CLOUDFLARE_ACCOUNT_GET, @@ -109,17 +62,6 @@ Usage: runseal @tool <namespace> <command> [args] Run an atomic runseal tool command. Tools: - json ... JSON helpers - hash ... hash helpers - string ... string helpers - regex ... regex helpers - int ... integer helpers - process ... process helpers - archive ... archive helpers - fs ... filesystem helpers - version ... version helpers - gitee ... gitee helpers - ssh ... ssh helpers github ... github helpers cloudflare ... cloudflare helpers " diff --git a/app/src/core/tool/help/ssh.rs b/app/src/core/tool/help/ssh.rs deleted file mode 100644 index 7990fed..0000000 --- a/app/src/core/tool/help/ssh.rs +++ /dev/null @@ -1,146 +0,0 @@ -use super::{Entry, Section}; - -pub const SSH: Entry = Entry { - key: "ssh", - usage: "runseal @tool ssh <scope> <command> [args]", - about: None, - sections: &[Section { - title: "SSH helpers", - items: &[ - ( - "config host <host> --config <path>", - "print true when Host patterns match", - ), - ( - "config identities --config <path>", - "print IdentityFile paths as JSON", - ), - ( - "script run --config <path>", - "run a local script on an SSH host", - ), - ( - "script capture --config <path>", - "run a local script and print stdout", - ), - ], - }], - examples: &[], -}; - -pub const SSH_CONFIG: Entry = Entry { - key: "ssh.config", - usage: "runseal @tool ssh config <command> [args]", - about: None, - sections: &[Section { - title: "SSH config helpers", - items: &[ - ( - "host <host> --config <path>", - "print true when any Host pattern matches", - ), - ( - "identities --config <path> [--base <path>]", - "print IdentityFile paths as JSON", - ), - ], - }], - examples: &[ - "runseal @tool ssh config host bandwagon --config ~/.ssh/config", - "runseal @tool ssh config identities --config ~/.ssh/config", - ], -}; - -pub const SSH_CONFIG_IDENTITIES: Entry = Entry { - key: "ssh.config.identities", - usage: "runseal @tool ssh config identities --config <path> [--base <path>]", - about: Some( - "Print IdentityFile entries as a JSON string array. Relative IdentityFile paths resolve from `--base`, or from the config directory by default.", - ), - sections: &[Section { - title: "Flags", - items: &[ - ("--config <path>", "SSH config file to inspect"), - ( - "--base <path>", - "resolve relative IdentityFile paths from this base path", - ), - ], - }], - examples: &["runseal @tool ssh config identities --config .local/ssh/config --base .local/ssh"], -}; - -pub const SSH_CONFIG_HOST: Entry = Entry { - key: "ssh.config.host", - usage: "runseal @tool ssh config host <host> --config <path>", - about: Some( - "Print `true` when `<host>` matches any declared `Host` pattern in the SSH config.", - ), - sections: &[Section { - title: "Flags", - items: &[("--config <path>", "SSH config file to inspect")], - }], - examples: &["runseal @tool ssh config host 10m.hk.zxi --config .local/ssh/config"], -}; - -pub const SSH_SCRIPT: Entry = Entry { - key: "ssh.script", - usage: "runseal @tool ssh script <command> [options] -- [args...]", - about: None, - sections: &[Section { - title: "SSH script helpers", - items: &[ - ( - "run --config <path> --host <host> --file <path> -- [args...]", - "send a local script to `ssh host bash -s -- ...`", - ), - ( - "capture --config <path> --host <host> --file <path> -- [args...]", - "run the script and print stdout", - ), - ], - }], - examples: &[ - "runseal @tool ssh script run --config .local/ssh/config --host 10m.hk.zxi --file scripts/check.sh -- arg1 arg2", - ], -}; - -pub const SSH_SCRIPT_RUN: Entry = Entry { - key: "ssh.script.run", - usage: "runseal @tool ssh script run --config <path> --host <host> --file <path> -- [args...]", - about: Some("Send one local script file to `ssh -F <config> <host> bash -s -- ...`."), - sections: &[Section { - title: "Flags", - items: &[ - ("--config <path>", "SSH config file to use"), - ( - "--host <host>", - "target host; must match a declared Host pattern", - ), - ("--file <path>", "local script file to stream over stdin"), - ], - }], - examples: &[ - "runseal @tool ssh script run --config .local/ssh/config --host 10m.hk.zxi --file scripts/check.sh -- arg1 arg2", - ], -}; - -pub const SSH_SCRIPT_CAPTURE: Entry = Entry { - key: "ssh.script.capture", - usage: "runseal @tool ssh script capture --config <path> --host <host> --file <path> -- [args...]", - about: Some("Run one local script on the SSH host and print stdout to the caller."), - sections: &[Section { - title: "Flags", - items: &[ - ("--config <path>", "SSH config file to use"), - ( - "--host <host>", - "target host; must match a declared Host pattern", - ), - ("--file <path>", "local script file to stream over stdin"), - ], - }], - examples: &[ - "runseal @tool ssh script capture --config .local/ssh/config --host 10m.hk.zxi --file scripts/uptime.sh", - ], -}; diff --git a/app/src/core/tool/int.rs b/app/src/core/tool/int.rs deleted file mode 100644 index dffa13e..0000000 --- a/app/src/core/tool/int.rs +++ /dev/null @@ -1,21 +0,0 @@ -use anyhow::{Context, Result, bail}; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "add" => add(args), - _ => bail!("unknown tool command: int {command}"), - } -} - -fn add(args: &[String]) -> Result<Option<String>> { - let [left, right] = args else { - bail!("usage: runseal @tool int add <left> <right>"); - }; - let left = left - .parse::<i64>() - .with_context(|| format!("invalid integer: {left}"))?; - let right = right - .parse::<i64>() - .with_context(|| format!("invalid integer: {right}"))?; - Ok(Some((left + right).to_string())) -} diff --git a/app/src/core/tool/json.rs b/app/src/core/tool/json.rs deleted file mode 100644 index 1d23b71..0000000 --- a/app/src/core/tool/json.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::io::Read; - -use anyhow::{Context, Result, bail}; -use serde_json::Value as JsonValue; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "get" => get(args), - "has" => has(args), - "empty" => empty(args), - "len" => len(args), - "pretty" => pretty(args), - "find" => find(args), - "filter" => filter(args), - _ => bail!("unknown tool command: json {command}"), - } -} - -fn get(args: &[String]) -> Result<Option<String>> { - let [json, path] = args else { - bail!("usage: runseal @tool json get <json> <path>"); - }; - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - let selected = select_path(&value, path)?; - let output = match selected { - JsonValue::Null => None, - JsonValue::String(value) => Some(value.clone()), - JsonValue::Bool(value) => Some(value.to_string()), - JsonValue::Number(value) => Some(value.to_string()), - JsonValue::Array(_) | JsonValue::Object(_) => Some(serde_json::to_string(selected)?), - }; - Ok(output) -} - -fn has(args: &[String]) -> Result<Option<String>> { - let [json, path] = args else { - bail!("usage: runseal @tool json has <json> <path>"); - }; - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - Ok(Some(path_exists(&value, path).to_string())) -} - -fn empty(args: &[String]) -> Result<Option<String>> { - let [json] = args else { - bail!("usage: runseal @tool json empty <json>"); - }; - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - Ok(Some(value_is_empty(&value).to_string())) -} - -fn len(args: &[String]) -> Result<Option<String>> { - let [json] = args else { - bail!("usage: runseal @tool json len <json>"); - }; - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - let len = match value { - JsonValue::Null => 0, - JsonValue::String(value) => value.len(), - JsonValue::Array(value) => value.len(), - JsonValue::Object(value) => value.len(), - JsonValue::Bool(_) | JsonValue::Number(_) => 1, - }; - Ok(Some(len.to_string())) -} - -fn pretty(args: &[String]) -> Result<Option<String>> { - let [mode, rest @ ..] = args else { - bail!("usage: runseal @tool json pretty value|stdin|file ..."); - }; - match mode.as_str() { - "value" => pretty_value(rest), - "stdin" => pretty_stdin(rest), - "file" => pretty_file(rest), - _ => bail!("usage: runseal @tool json pretty value|stdin|file ..."), - } -} - -fn pretty_value(args: &[String]) -> Result<Option<String>> { - let [json] = args else { - bail!("usage: runseal @tool json pretty value <json>"); - }; - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - Ok(Some(render_pretty(&value)?)) -} - -fn pretty_stdin(args: &[String]) -> Result<Option<String>> { - if !args.is_empty() { - bail!("usage: runseal @tool json pretty stdin"); - } - let mut input = String::new(); - std::io::stdin() - .read_to_string(&mut input) - .context("failed to read JSON from stdin")?; - let value: JsonValue = serde_json::from_str(&input).context("invalid JSON input")?; - Ok(Some(render_pretty(&value)?)) -} - -fn pretty_file(args: &[String]) -> Result<Option<String>> { - let [input_path, output_path] = args else { - bail!("usage: runseal @tool json pretty file <input> <output>"); - }; - let input = std::fs::read_to_string(input_path) - .with_context(|| format!("failed to read JSON file: {input_path}"))?; - let value: JsonValue = serde_json::from_str(&input).context("invalid JSON input")?; - let mut pretty = render_pretty(&value)?; - pretty.push('\n'); - std::fs::write(output_path, pretty) - .with_context(|| format!("failed to write JSON file: {output_path}"))?; - Ok(None) -} - -fn render_pretty(value: &JsonValue) -> Result<String> { - serde_json::to_string_pretty(value).map_err(Into::into) -} - -fn find(args: &[String]) -> Result<Option<String>> { - let [json, field, expected] = args else { - bail!("usage: runseal @tool json find <array> <field> <value>"); - }; - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - let Some(found) = json_array(&value)? - .iter() - .find(|item| field_string(item, field).as_deref() == Some(expected.as_str())) - else { - return Ok(None); - }; - Ok(Some(serde_json::to_string(found)?)) -} - -fn filter(args: &[String]) -> Result<Option<String>> { - let [json, field, expected @ ..] = args else { - bail!("usage: runseal @tool json filter <array> <field> <value>..."); - }; - if expected.is_empty() { - bail!("json filter requires at least one expected value"); - } - let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - let filtered = json_array(&value)? - .iter() - .filter(|item| { - field_string(item, field) - .as_deref() - .is_some_and(|actual| expected.iter().any(|value| value == actual)) - }) - .cloned() - .collect::<Vec<_>>(); - Ok(Some(serde_json::to_string(&filtered)?)) -} - -fn json_array(value: &JsonValue) -> Result<&[JsonValue]> { - let JsonValue::Array(values) = value else { - bail!("expected JSON array"); - }; - Ok(values) -} - -fn field_string(value: &JsonValue, field: &str) -> Option<String> { - value.get(field).map(|value| match value { - JsonValue::String(value) => value.clone(), - JsonValue::Bool(value) => value.to_string(), - JsonValue::Number(value) => value.to_string(), - JsonValue::Null | JsonValue::Array(_) | JsonValue::Object(_) => { - serde_json::to_string(value).unwrap_or_default() - } - }) -} - -fn value_is_empty(value: &JsonValue) -> bool { - match value { - JsonValue::Null => true, - JsonValue::String(value) => value.is_empty(), - JsonValue::Array(value) => value.is_empty(), - JsonValue::Object(value) => value.is_empty(), - JsonValue::Bool(_) | JsonValue::Number(_) => false, - } -} - -fn select_path<'a>(value: &'a JsonValue, path: &str) -> Result<&'a JsonValue> { - select_path_impl(value, path, true) -} - -fn path_exists(value: &JsonValue, path: &str) -> bool { - select_path_impl(value, path, false).is_ok() -} - -fn select_path_impl<'a>( - value: &'a JsonValue, - path: &str, - error_on_missing: bool, -) -> Result<&'a JsonValue> { - let mut input = path.strip_prefix('.').unwrap_or(path); - if input.is_empty() { - bail!("json path cannot be empty"); - } - let mut current = value; - while !input.is_empty() { - if let Some(rest) = input.strip_prefix('[') { - let Some((index, rest)) = rest.split_once(']') else { - bail!("unsupported json path: {path}"); - }; - let index = index - .parse::<usize>() - .with_context(|| format!("invalid json path index: {index}"))?; - current = current.get(index).ok_or_else(|| { - if error_on_missing { - anyhow::anyhow!("json path not found: {path}") - } else { - anyhow::anyhow!("missing") - } - })?; - input = rest.strip_prefix('.').unwrap_or(rest); - continue; - } - let end = input.find(['.', '[']).unwrap_or(input.len()); - let field = &input[..end]; - validate_field(field)?; - current = current.get(field).ok_or_else(|| { - if error_on_missing { - anyhow::anyhow!("json path not found: {path}") - } else { - anyhow::anyhow!("missing") - } - })?; - input = input[end..].strip_prefix('.').unwrap_or(&input[end..]); - } - Ok(current) -} - -fn validate_field(field: &str) -> Result<()> { - let mut bytes = field.bytes(); - let valid = matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); - if !valid { - bail!("invalid json path field: {field}"); - } - Ok(()) -} diff --git a/app/src/core/tool/mod.rs b/app/src/core/tool/mod.rs index 231c95f..d665a00 100644 --- a/app/src/core/tool/mod.rs +++ b/app/src/core/tool/mod.rs @@ -1,19 +1,8 @@ use anyhow::{Result, bail}; -mod archive; mod cloudflare; -mod fs; -mod gitee; mod github; -mod hash; mod help; -mod int; -mod json; -mod process; -mod regex; -mod ssh; -mod string; -mod version; pub fn help() -> &'static str { help::top() @@ -35,17 +24,6 @@ pub fn eval(args: &[String]) -> Result<Option<String>> { return Ok(Some(help)); } match args { - [namespace, command, rest @ ..] if namespace == "json" => json::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "string" => string::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "regex" => regex::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "int" => int::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "process" => process::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "archive" => archive::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "fs" => fs::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "gitee" => gitee::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "hash" => hash::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "ssh" => ssh::eval(command, rest), - [namespace, command, rest @ ..] if namespace == "version" => version::eval(command, rest), [namespace, command, rest @ ..] if namespace == "github" => github::eval(command, rest), [namespace, command, rest @ ..] if namespace == "cloudflare" => { cloudflare::eval(command, rest) diff --git a/app/src/core/tool/process.rs b/app/src/core/tool/process.rs deleted file mode 100644 index a973545..0000000 --- a/app/src/core/tool/process.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use anyhow::{Context, Result, bail}; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "exists" => exists(args), - "write" => write(args), - _ => bail!("unknown tool command: process {command}"), - } -} - -fn exists(args: &[String]) -> Result<Option<String>> { - let [name] = args else { - bail!("usage: runseal @tool process exists <name>"); - }; - Ok(Some(command_exists(name).to_string())) -} - -fn write(args: &[String]) -> Result<Option<String>> { - let [stream, path, rest @ ..] = args else { - bail!( - "usage: runseal @tool process write <stdout|stderr> <path> [--append] -- <command> [args...]" - ); - }; - let stream = parse_stream(stream)?; - let mut append = false; - let mut index = 0; - while index < rest.len() { - match rest[index].as_str() { - "--append" => { - append = true; - index += 1; - } - "--" => { - index += 1; - break; - } - other => bail!("unknown process write argument: {other}"), - } - } - let command_argv = &rest[index..]; - let Some((program, command_args)) = command_argv.split_first() else { - bail!("process write requires one command after `--`"); - }; - let output = Command::new(program) - .args(command_args) - .output() - .with_context(|| format!("failed to execute command: {program}"))?; - let captured = match stream { - Stream::Stdout => &output.stdout, - Stream::Stderr => &output.stderr, - }; - let passthrough = match stream { - Stream::Stdout => &output.stderr, - Stream::Stderr => &output.stdout, - }; - if let Some(parent) = Path::new(path).parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - if append { - use std::io::Write; - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("failed to append file: {path}"))?; - file.write_all(captured) - .with_context(|| format!("failed to append file: {path}"))?; - } else { - std::fs::write(path, captured).with_context(|| format!("failed to write file: {path}"))?; - } - match stream { - Stream::Stdout => { - use std::io::Write; - std::io::stderr() - .write_all(passthrough) - .context("failed to write stderr")?; - } - Stream::Stderr => { - use std::io::Write; - std::io::stdout() - .write_all(passthrough) - .context("failed to write stdout")?; - } - } - if output.status.success() { - return Ok(None); - } - std::process::exit(output.status.code().unwrap_or(1)); -} - -enum Stream { - Stdout, - Stderr, -} - -fn parse_stream(value: &str) -> Result<Stream> { - match value { - "stdout" => Ok(Stream::Stdout), - "stderr" => Ok(Stream::Stderr), - _ => bail!("process write stream must be stdout or stderr"), - } -} - -fn command_exists(name: &str) -> bool { - let Some(path) = std::env::var_os("PATH") else { - return false; - }; - std::env::split_paths(&path).any(|dir| dir.join(name).is_file()) -} diff --git a/app/src/core/tool/regex.rs b/app/src/core/tool/regex.rs deleted file mode 100644 index 0221e05..0000000 --- a/app/src/core/tool/regex.rs +++ /dev/null @@ -1,28 +0,0 @@ -use anyhow::{Context, Result, bail}; -use regex::Regex; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "capture" => capture(args), - _ => bail!("unknown tool command: regex {command}"), - } -} - -fn capture(args: &[String]) -> Result<Option<String>> { - let [value, pattern, group] = args else { - bail!("usage: runseal @tool regex capture <value> <pattern> <group>"); - }; - let group = group - .parse::<usize>() - .with_context(|| format!("invalid regex capture group: {group}"))?; - if !(1..=9).contains(&group) { - bail!("regex capture group must be between 1 and 9"); - } - let regex = Regex::new(pattern).context("invalid regex pattern")?; - let captured = regex - .captures(value) - .and_then(|captures| captures.get(group)) - .map(|capture| capture.as_str()) - .unwrap_or(""); - Ok(Some(captured.to_string())) -} diff --git a/app/src/core/tool/ssh/mod.rs b/app/src/core/tool/ssh/mod.rs deleted file mode 100644 index 6613092..0000000 --- a/app/src/core/tool/ssh/mod.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::{ - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -use anyhow::{Context, Result, bail}; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "config" => config(args), - "script" => script(args), - _ => bail!("unknown tool command: ssh {command}"), - } -} - -fn config(args: &[String]) -> Result<Option<String>> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool ssh config host|identities ..."); - }; - match command.as_str() { - "host" => config_host(rest), - "identities" => config_identities(rest), - _ => bail!("usage: runseal @tool ssh config host|identities ..."), - } -} - -fn script(args: &[String]) -> Result<Option<String>> { - let [command, rest @ ..] = args else { - bail!( - "usage: runseal @tool ssh script run|capture --config <path> --host <host> --file <path> -- <args...>" - ); - }; - match command.as_str() { - "run" => script_run(rest, false), - "capture" => script_run(rest, true), - _ => bail!( - "usage: runseal @tool ssh script run|capture --config <path> --host <host> --file <path> -- <args...>" - ), - } -} - -fn script_run(args: &[String], capture: bool) -> Result<Option<String>> { - let (options, script_args) = split_options(args); - let config = required_option(options, "--config")?; - let host = required_option(options, "--host")?; - let file = required_option(options, "--file")?; - if !Path::new(&file).is_file() { - bail!("script not found: {file}"); - } - let patterns = read_hosts(Path::new(&config))?; - if !host_allowed(&host, &patterns) { - bail!("host is not declared in {config}: {host}"); - } - let input = std::fs::read(&file).with_context(|| format!("failed to read script: {file}"))?; - let mut command = Command::new("ssh"); - command - .arg("-F") - .arg(&config) - .arg(&host) - .arg("bash") - .arg("-s") - .arg("--") - .args(script_args) - .stdin(Stdio::piped()); - if capture { - command.stdout(Stdio::piped()); - } - let mut child = command - .spawn() - .with_context(|| format!("failed to execute ssh for host: {host}"))?; - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - stdin - .write_all(&input) - .with_context(|| format!("failed to send script to ssh for host: {host}"))?; - } - let output = child - .wait_with_output() - .with_context(|| format!("failed to wait for ssh script on host: {host}"))?; - if !output.status.success() { - let code = output.status.code().unwrap_or(1); - bail!("ssh script failed with exit code {code}"); - } - if capture { - return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned())); - } - Ok(None) -} - -fn split_options(args: &[String]) -> (&[String], &[String]) { - if let Some(index) = args.iter().position(|arg| arg == "--") { - (&args[..index], &args[index + 1..]) - } else { - (args, &[]) - } -} - -fn config_host(args: &[String]) -> Result<Option<String>> { - let [host, rest @ ..] = args else { - bail!("usage: runseal @tool ssh config host <host> --config <path>"); - }; - let config = required_option(rest, "--config")?; - let patterns = read_hosts(Path::new(&config))?; - Ok(Some(host_allowed(host, &patterns).to_string())) -} - -fn config_identities(args: &[String]) -> Result<Option<String>> { - let config = required_option(args, "--config")?; - let config_path = Path::new(&config); - let base = optional_option(args, "--base") - .map(PathBuf::from) - .unwrap_or_else(|| { - config_path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")) - }); - let identities = read_identity_files(config_path, &base)?; - Ok(Some(serde_json::to_string(&identities)?)) -} - -fn read_hosts(config: &Path) -> Result<Vec<String>> { - let text = std::fs::read_to_string(config) - .with_context(|| format!("failed to read ssh config: {}", config.display()))?; - Ok(text - .lines() - .filter_map(config_words) - .filter(|words| { - words - .first() - .is_some_and(|word| word.eq_ignore_ascii_case("host")) - }) - .flat_map(|words| words.into_iter().skip(1)) - .collect()) -} - -fn read_identity_files(config: &Path, base: &Path) -> Result<Vec<String>> { - let text = std::fs::read_to_string(config) - .with_context(|| format!("failed to read ssh config: {}", config.display()))?; - let mut files = Vec::new(); - for words in text.lines().filter_map(config_words) { - if words - .first() - .is_some_and(|word| word.eq_ignore_ascii_case("identityfile")) - && let Some(value) = words.get(1) - { - files.push( - resolve_identity_file(value, base) - .to_string_lossy() - .into_owned(), - ); - } - } - Ok(files) -} - -fn config_words(line: &str) -> Option<Vec<String>> { - let line = line.split_once('#').map_or(line, |(before, _)| before); - let words = line - .split_whitespace() - .map(str::to_string) - .collect::<Vec<_>>(); - (!words.is_empty()).then_some(words) -} - -fn host_allowed(host: &str, patterns: &[String]) -> bool { - let mut matched = false; - for pattern in patterns { - if let Some(negative) = pattern.strip_prefix('!') { - if glob_match(negative, host) { - return false; - } - } else if glob_match(pattern, host) { - matched = true; - } - } - matched -} - -fn resolve_identity_file(value: &str, base: &Path) -> PathBuf { - if value == "~" { - return home_dir().unwrap_or_else(|| PathBuf::from(value)); - } - if let Some(rest) = value.strip_prefix("~/") - && let Some(home) = home_dir() - { - return home.join(rest); - } - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else { - base.join(path) - } -} - -fn home_dir() -> Option<PathBuf> { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - -fn required_option(args: &[String], name: &str) -> Result<String> { - optional_option(args, name).ok_or_else(|| anyhow::anyhow!("{name} is required")) -} - -fn optional_option(args: &[String], name: &str) -> Option<String> { - let prefix = format!("{name}="); - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == name { - return args.get(index + 1).cloned(); - } - if let Some(value) = arg.strip_prefix(&prefix) { - return Some(value.to_string()); - } - index += 1; - } - None -} - -fn glob_match(pattern: &str, value: &str) -> bool { - glob_match_inner(pattern.as_bytes(), value.as_bytes()) -} - -fn glob_match_inner(pattern: &[u8], value: &[u8]) -> bool { - match (pattern.split_first(), value.split_first()) { - (None, None) => true, - (None, Some(_)) => false, - (Some((&b'*', rest)), _) => { - glob_match_inner(rest, value) - || value - .split_first() - .is_some_and(|(_, tail)| glob_match_inner(pattern, tail)) - } - (Some((&b'?', rest)), Some((_, tail))) => glob_match_inner(rest, tail), - (Some((&expected, rest)), Some((&actual, tail))) if expected == actual => { - glob_match_inner(rest, tail) - } - _ => false, - } -} diff --git a/app/src/core/tool/string.rs b/app/src/core/tool/string.rs deleted file mode 100644 index 908801f..0000000 --- a/app/src/core/tool/string.rs +++ /dev/null @@ -1,106 +0,0 @@ -use anyhow::{Context, Result, bail}; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "trim" => trim(args), - "join" => join(args), - "slug" => slug(args), - _ => bail!("unknown tool command: string {command}"), - } -} - -fn trim(args: &[String]) -> Result<Option<String>> { - let [value] = args else { - bail!("usage: runseal @tool string trim <value>"); - }; - Ok(Some(value.trim().to_string())) -} - -fn join(args: &[String]) -> Result<Option<String>> { - let [json, separator_flag, separator] = args else { - bail!("usage: runseal @tool string join <json-array> --separator <text|path>"); - }; - if separator_flag != "--separator" { - bail!("usage: runseal @tool string join <json-array> --separator <text|path>"); - } - let values: Vec<String> = serde_json::from_str(json).context("invalid string array JSON")?; - if separator == "path" { - let joined = - std::env::join_paths(values.iter()).context("failed to join path-list values")?; - return Ok(Some(joined.to_string_lossy().into_owned())); - } - Ok(Some(values.join(separator))) -} - -fn slug(args: &[String]) -> Result<Option<String>> { - let Some(value) = args.first() else { - bail!("usage: runseal @tool string slug <value> [--max-len <n>] [--fallback <text>]"); - }; - - let mut max_len: Option<usize> = None; - let mut fallback = "value".to_string(); - let mut rest = &args[1..]; - while let Some((flag, tail)) = rest.split_first() { - match flag.as_str() { - "--max-len" => { - let Some((raw, next)) = tail.split_first() else { - bail!("missing value for --max-len"); - }; - let parsed = raw.parse::<usize>().context("invalid --max-len value")?; - if parsed == 0 { - bail!("--max-len must be greater than zero"); - } - max_len = Some(parsed); - rest = next; - } - "--fallback" => { - let Some((raw, next)) = tail.split_first() else { - bail!("missing value for --fallback"); - }; - fallback = raw.clone(); - rest = next; - } - _ => bail!( - "usage: runseal @tool string slug <value> [--max-len <n>] [--fallback <text>]" - ), - } - } - - let mut output = slugify(value); - if let Some(limit) = max_len { - output.truncate(limit); - output = trim_hyphens(&output); - } - if output.is_empty() { - output = slugify(&fallback); - if let Some(limit) = max_len { - output.truncate(limit); - output = trim_hyphens(&output); - } - } - if output.is_empty() { - output = "value".to_string(); - } - Ok(Some(output)) -} - -fn slugify(value: &str) -> String { - let mut output = String::new(); - let mut pending_dash = false; - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - if pending_dash && !output.is_empty() { - output.push('-'); - } - output.push(ch.to_ascii_lowercase()); - pending_dash = false; - } else { - pending_dash = true; - } - } - output -} - -fn trim_hyphens(value: &str) -> String { - value.trim_matches('-').to_string() -} diff --git a/app/src/core/tool/version/mod.rs b/app/src/core/tool/version/mod.rs deleted file mode 100644 index e84a5db..0000000 --- a/app/src/core/tool/version/mod.rs +++ /dev/null @@ -1,60 +0,0 @@ -use anyhow::{Result, bail}; - -pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { - match command { - "part" => part(args), - "compare" => compare(args), - _ => bail!("unknown tool command: version {command}"), - } -} - -fn part(args: &[String]) -> Result<Option<String>> { - let [version, name] = args else { - bail!("usage: runseal @tool version part <version> <major|minor|patch>"); - }; - let parsed = parse(version)?; - let output = match name.as_str() { - "major" => parsed.0.to_string(), - "minor" => parsed.1.to_string(), - "patch" => parsed.2.to_string(), - _ => bail!("usage: runseal @tool version part <version> <major|minor|patch>"), - }; - Ok(Some(output)) -} - -fn compare(args: &[String]) -> Result<Option<String>> { - let [left, right] = args else { - bail!("usage: runseal @tool version compare <left> <right>"); - }; - let left = parse(left)?; - let right = parse(right)?; - let output = if left < right { - "lt" - } else if left > right { - "gt" - } else { - "eq" - }; - Ok(Some(output.to_string())) -} - -fn parse(version: &str) -> Result<(u64, u64, u64)> { - let value = version.strip_prefix('v').unwrap_or(version); - let mut parts = value.split('.'); - let major = parse_part(parts.next(), version, "major")?; - let minor = parse_part(parts.next(), version, "minor")?; - let patch = parse_part(parts.next(), version, "patch")?; - if parts.next().is_some() { - bail!("expected stable semantic version, got {version}"); - } - Ok((major, minor, patch)) -} - -fn parse_part(value: Option<&str>, version: &str, name: &str) -> Result<u64> { - let Some(value) = value else { - bail!("expected stable semantic version, got {version}"); - }; - value - .parse::<u64>() - .map_err(|_| anyhow::anyhow!("invalid {name} version part in {version}")) -} diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs index fa93981..1628e9c 100644 --- a/app/tests/internal_tool.rs +++ b/app/tests/internal_tool.rs @@ -1,25 +1,8 @@ -#[path = "internal_tool/archive.rs"] -#[cfg(unix)] -mod archive; -#[path = "internal_tool/gitee.rs"] -mod gitee; #[path = "internal_tool/github.rs"] #[cfg(unix)] mod github; -#[path = "internal_tool/hash_version.rs"] -mod hash_version; -#[path = "internal_tool/process.rs"] -mod process; -#[path = "internal_tool/ssh.rs"] -mod ssh; -#[path = "internal_tool/string.rs"] -mod string; -use std::{ - io::Write, - path::PathBuf, - process::{Command, Stdio}, -}; +use std::process::Command; use tempfile::TempDir; @@ -28,134 +11,49 @@ fn bin() -> Command { } #[test] -fn tool_runs_without_profile() { +fn tool_help_is_progressive() { let temp = TempDir::new().expect("temp dir should be created"); let cwd = temp.path().join("empty"); std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); for (args, expected) in [ ( - vec!["@tool", "json", "pretty", "value", r#"{"a":1}"#], - "{\n \"a\": 1\n}\n", + vec!["@tool", "github", "issue", "comment", "create", "--help"], + "--prefix-enable=<true|false>", ), ( - vec![ - "@tool", - "json", - "get", - r#"[{"databaseId":123}]"#, - ".[0].databaseId", - ], - "123\n", + vec!["@tool", "cloudflare", "config", "--help"], + "Cloudflare config helpers:", ), ( - vec![ - "@tool", - "json", - "has", - r#"{"guard":{"version":{"hash":"x"}}}"#, - ".guard.version.hash", - ], - "true\n", + vec!["@tool", "cloudflare", "zone", "dns-record", "--help"], + "Usage: runseal @tool cloudflare zone dns-record <command> [args]", ), - (vec!["@tool", "string", "trim", " value "], "value\n"), ( vec![ "@tool", - "string", - "slug", - "Land Change: ship deno!", - "--max-len", - "48", - "--fallback", - "change", + "cloudflare", + "zone", + "dns-record", + "list", + "--help", ], - "land-change-ship-deno\n", + "--name <name>", ), ( vec![ "@tool", - "regex", - "capture", - "https://github.test/actions/runs/456", - "/actions/runs/([0-9]+)", - "1", + "cloudflare", + "zone", + "dns-record", + "create", + "--help", ], - "456\n", + "Create one DNS record", ), - (vec!["@tool", "int", "add", "2", "3"], "5\n"), ( - vec!["@tool", "process", "exists", "definitely-not-runseal-tool"], - "false\n", - ), - ] { - let output = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(args.clone()) - .output() - .expect("runseal should run"); - - assert!(output.status.success(), "{args:?} should succeed"); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, expected, "{args:?} stdout should match"); - } -} - -#[test] -fn tool_help_is_progressive() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - for (args, expected) in [ - ( - vec!["@tool", "json", "--help"], - "Usage: runseal @tool json <command> [args]", - ), - ( - vec!["@tool", "json", "get", "--help"], - "Usage: runseal @tool json get <json> <path>", - ), - ( - vec!["@tool", "json", "has", "--help"], - "Usage: runseal @tool json has <json> <path>", - ), - ( - vec!["@tool", "json", "pretty", "--help"], - "Usage: runseal @tool json pretty <mode> [args]", - ), - ( - vec!["@tool", "json", "pretty", "value", "--help"], - "Usage: runseal @tool json pretty value <json>", - ), - ( - vec!["@tool", "json", "pretty", "stdin", "--help"], - "Usage: runseal @tool json pretty stdin", - ), - ( - vec!["@tool", "json", "pretty", "file", "--help"], - "Usage: runseal @tool json pretty file <input> <output>", - ), - ( - vec!["@tool", "string", "--help"], - "Usage: runseal @tool string <command> [args]", - ), - ( - vec!["@tool", "archive", "local", "--help"], - "Usage: runseal @tool archive local <command> [args]", - ), - ( - vec!["@tool", "cloudflare", "config", "--help"], - "Cloudflare config helpers:", - ), - ( - vec!["@tool", "ssh", "script", "--help"], - "Usage: runseal @tool ssh script <command> [options] -- [args...]", - ), - ( - vec!["@tool", "cloudflare", "zone", "dns-record", "--help"], - "Usage: runseal @tool cloudflare zone dns-record <command> [args]", + vec!["@tool", "github", "pr", "checks", "probe", "--help"], + "on API probe failure", ), ] { let output = bin() @@ -181,18 +79,6 @@ fn richer_help() { std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); for (args, expected) in [ - ( - vec!["@tool", "ssh", "config", "--help"], - "identities --config <path> [--base <path>]", - ), - ( - vec!["@tool", "ssh", "config", "identities", "--help"], - "Relative IdentityFile paths resolve from `--base`", - ), - ( - vec!["@tool", "ssh", "script", "capture", "--help"], - "Run one local script on the SSH host and print stdout to the caller.", - ), ( vec!["@tool", "cloudflare", "zone", "--help"], "Cloudflare zone helpers:", @@ -212,14 +98,6 @@ fn richer_help() { ], "--record-id <id> --json <json>", ), - ( - vec!["@tool", "fs", "list", "--help"], - "[--require-nonempty]", - ), - ( - vec!["@tool", "gitee", "repo", "--help"], - "Gitee repo helpers:", - ), ( vec!["@tool", "cloudflare", "redirect-rule", "exact", "--help"], "Build one exact-match redirect rule payload as JSON.", @@ -228,18 +106,6 @@ fn richer_help() { vec!["@tool", "github", "issue", "comment", "create", "--help"], "--prefix-enable=<true|false>", ), - ( - vec!["@tool", "gitee", "pr", "merge", "--help"], - "Merge a Gitee pull request and print the API response JSON.", - ), - ( - vec!["@tool", "archive", "local", "import", "--help"], - "Decrypt one .local-style directory archive into the source directory.", - ), - ( - vec!["@tool", "process", "write", "--help"], - "<stdout|stderr> <path> [--append] -- <command> [args...]", - ), ] { let output = bin() .current_dir(&cwd) @@ -258,224 +124,17 @@ fn richer_help() { } #[test] -fn fs_runs_without_profile() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let file = cwd.join("hook"); - let nested = cwd.join("nested"); - - let mkdir = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "fs", "mkdir", nested.to_str().unwrap(), "700"]) - .output() - .expect("runseal should run"); - assert!(mkdir.status.success()); - assert!(nested.is_dir()); - - let write = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "fs", - "write-base64", - file.to_str().unwrap(), - "c2VhbCBtYXJrZXIK", - ]) - .output() - .expect("runseal should run"); - assert!(write.status.success()); - assert_eq!( - std::fs::read_to_string(&file).expect("file should be readable"), - "seal marker\n" - ); - - let text_file = cwd.join("text"); - let write_text = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "fs", - "write", - text_file.to_str().unwrap(), - "plain text", - "600", - ]) - .output() - .expect("runseal should run"); - assert!(write_text.status.success()); - assert_eq!( - std::fs::read_to_string(&text_file).expect("file should be readable"), - "plain text" - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&text_file) - .expect("metadata should be readable") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } - - let contains = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "fs", - "contains-any", - file.to_str().unwrap(), - "missing", - "seal marker", - ]) - .output() - .expect("runseal should run"); - assert!(contains.status.success()); - assert_eq!(String::from_utf8(contains.stdout).unwrap(), "true\n"); - - let backup = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "fs", "backup-numbered", file.to_str().unwrap()]) - .output() - .expect("runseal should run"); - assert!(backup.status.success()); - assert!(!file.exists()); - let backup_path = PathBuf::from(String::from_utf8(backup.stdout).unwrap().trim()); - assert!(backup_path.is_file()); -} - -#[test] -fn fs_mode_touch_list() { +fn helper_namespaces_are_unknown() { let temp = TempDir::new().expect("temp dir should be created"); let cwd = temp.path().join("empty"); std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let dir = cwd.join("kube"); - let first = dir.join("b.yaml"); - let second = dir.join("a.yaml"); - let ignored = dir.join("notes.txt"); - let touch = bin() + let output = bin() .current_dir(&cwd) .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "fs", "touch", first.to_str().unwrap(), "600"]) + .args(["@tool", "json", "pretty", "value", r#"{"a":1}"#]) .output() .expect("runseal should run"); - assert!(touch.status.success()); - std::fs::write(&first, "existing").expect("file should be writable"); - - let touch_again = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "fs", "touch", first.to_str().unwrap(), "600"]) - .output() - .expect("runseal should run"); - assert!(touch_again.status.success()); - assert_eq!( - std::fs::read_to_string(&first).expect("file should be readable"), - "existing" - ); - - std::fs::write(&second, "").expect("file should be written"); - std::fs::write(&ignored, "").expect("file should be written"); - - let mode = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "fs", "mode", first.to_str().unwrap()]) - .output() - .expect("runseal should run"); - assert!(mode.status.success()); - #[cfg(unix)] - assert_eq!(String::from_utf8(mode.stdout).unwrap(), "600\n"); - - let list = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "fs", - "list", - dir.to_str().unwrap(), - "--glob", - "*.yaml", - "--files", - "--require-nonempty", - ]) - .output() - .expect("runseal should run"); - assert!(list.status.success()); - let paths: Vec<String> = - serde_json::from_slice(&list.stdout).expect("stdout should be JSON array"); - assert_eq!(paths.len(), 2); - assert!(PathBuf::from(&paths[0]).is_absolute()); - assert!(paths[0].ends_with("a.yaml")); - assert!(paths[1].ends_with("b.yaml")); -} - -#[test] -fn json_pretty_stdin() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - let mut child = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "json", "pretty", "stdin"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("runseal should start"); - - child - .stdin - .as_mut() - .expect("stdin should be piped") - .write_all(br#"{"a":1,"b":[2]}"#) - .expect("stdin write should succeed"); - - let output = child.wait_with_output().expect("runseal should finish"); - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).expect("stdout should be UTF-8"), - "{\n \"a\": 1,\n \"b\": [\n 2\n ]\n}\n" - ); -} - -#[test] -fn json_pretty_file() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let input = cwd.join("input.json"); - let output = cwd.join("output.json"); - std::fs::write(&input, r#"{"a":1,"b":[2]}"#).expect("input file should be written"); - - let result = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "json", - "pretty", - "file", - input.to_str().expect("input path should be UTF-8"), - output.to_str().expect("output path should be UTF-8"), - ]) - .output() - .expect("runseal should run"); - - assert!(result.status.success()); - assert_eq!( - std::fs::read_to_string(&output).expect("output file should be readable"), - "{\n \"a\": 1,\n \"b\": [\n 2\n ]\n}\n" - ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("unknown tool command: json pretty")); } diff --git a/app/tests/internal_tool/archive.rs b/app/tests/internal_tool/archive.rs deleted file mode 100644 index 961429e..0000000 --- a/app/tests/internal_tool/archive.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -#[test] -fn archive_roundtrip() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let local = cwd.join(".local"); - let ssh = local.join("ssh"); - let kube = local.join("kube"); - std::fs::create_dir_all(&ssh).expect("ssh dir should exist"); - std::fs::create_dir_all(&kube).expect("kube dir should exist"); - std::fs::write(ssh.join("config"), "Host test\n").expect("ssh config should be written"); - std::fs::write(kube.join("test.yaml"), "kube").expect("kube config should be written"); - let archive = cwd.join("local.enc"); - - let export = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_TEST_PASSWORD", "secret") - .args([ - "@tool", - "archive", - "local", - "export", - "--source", - local.to_str().unwrap(), - "--archive", - archive.to_str().unwrap(), - "--password-env", - "RUNSEAL_TEST_PASSWORD", - ]) - .output() - .expect("runseal should run"); - assert!( - export.status.success(), - "stderr: {}", - String::from_utf8_lossy(&export.stderr) - ); - assert!(archive.is_file()); - - let overwrite = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_TEST_PASSWORD", "secret") - .args([ - "@tool", - "archive", - "local", - "export", - "--source", - local.to_str().unwrap(), - "--archive", - archive.to_str().unwrap(), - "--password-env", - "RUNSEAL_TEST_PASSWORD", - ]) - .output() - .expect("runseal should run"); - assert!(!overwrite.status.success()); - - std::fs::remove_dir_all(&local).expect("local dir should be removable"); - let import = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_TEST_PASSWORD", "secret") - .args([ - "@tool", - "archive", - "local", - "import", - "--source", - local.to_str().unwrap(), - "--archive", - archive.to_str().unwrap(), - "--password-env", - "RUNSEAL_TEST_PASSWORD", - "--force", - ]) - .output() - .expect("runseal should run"); - assert!( - import.status.success(), - "stderr: {}", - String::from_utf8_lossy(&import.stderr) - ); - assert_eq!( - std::fs::read_to_string(ssh.join("config")).expect("ssh config should be restored"), - "Host test\n" - ); - assert_eq!( - std::fs::read_to_string(kube.join("test.yaml")).expect("kube config should be restored"), - "kube" - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&local) - .expect("local metadata should exist") - .permissions() - .mode() - & 0o777, - 0o700 - ); - assert_eq!( - std::fs::metadata(ssh.join("config")) - .expect("ssh config metadata should exist") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } -} diff --git a/app/tests/internal_tool/gitee.rs b/app/tests/internal_tool/gitee.rs deleted file mode 100644 index 95b5e8d..0000000 --- a/app/tests/internal_tool/gitee.rs +++ /dev/null @@ -1,295 +0,0 @@ -use std::{ - io::{Read, Write}, - net::TcpListener, - process::Command, - thread, -}; - -use tempfile::TempDir; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -#[test] -fn gitee_pr() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let token_file = cwd.join("gitee.env"); - std::fs::write(&token_file, "GITEE_TOKEN=file-token\n").expect("token file should be written"); - - let (api_base, handle) = mock_gitee( - |request| { - assert!(request.starts_with("POST /repos/perishme/perish.top/pulls ")); - assert!(request.contains("authorization: token file-token")); - assert!(request.contains("Land change")); - assert!(request.contains("file-token")); - }, - r#"{"number":42,"html_url":"https://gitee.test/pr/42"}"#, - ); - let create = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_GITEE_API_BASE", api_base) - .args([ - "@tool", - "gitee", - "pr", - "create", - "--owner", - "perishme", - "--repo", - "perish.top", - "--token-file", - token_file.to_str().unwrap(), - "--base", - "main", - "--head", - "feat/seal", - "--title", - "Land change", - "--body", - "Body", - ]) - .output() - .expect("runseal should run"); - assert!(create.status.success()); - handle.join().expect("mock server should finish"); - let payload: serde_json::Value = - serde_json::from_slice(&create.stdout).expect("stdout should be JSON"); - assert_eq!(payload["number"], 42); - - let (api_base, handle) = mock_gitee( - |request| { - assert!(request.starts_with("PUT /repos/perishme/perish.top/pulls/42/merge ")); - assert!(request.contains("authorization: token env-token")); - assert!(request.contains("squash")); - assert!(request.contains("env-token")); - }, - r#"{"merged":true}"#, - ); - let merge = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_GITEE_API_BASE", api_base) - .env("GITEE_TOKEN", "env-token") - .args([ - "@tool", - "gitee", - "pr", - "merge", - "--owner", - "perishme", - "--repo", - "perish.top", - "--number", - "42", - "--method", - "squash", - ]) - .output() - .expect("runseal should run"); - assert!(merge.status.success()); - handle.join().expect("mock server should finish"); - let payload: serde_json::Value = - serde_json::from_slice(&merge.stdout).expect("stdout should be JSON"); - assert_eq!(payload["merged"], true); - - let (api_base, handle) = mock_gitee( - |request| { - assert!(request.starts_with( - "GET /repos/perishme/perish.top/pulls?head=feat%2Fseal&state=open&base=main " - )); - assert!(request.contains("authorization: token env-token")); - }, - r#"[{"number":42,"head":{"ref":"feat/seal"},"base":{"ref":"main"},"html_url":"https://gitee.test/pr/42"}]"#, - ); - let find = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_GITEE_API_BASE", api_base) - .env("GITEE_TOKEN", "env-token") - .args([ - "@tool", - "gitee", - "pr", - "find", - "--owner", - "perishme", - "--repo", - "perish.top", - "--head", - "feat/seal", - "--base", - "main", - ]) - .output() - .expect("runseal should run"); - assert!(find.status.success()); - handle.join().expect("mock server should finish"); - let payload: serde_json::Value = - serde_json::from_slice(&find.stdout).expect("stdout should be JSON"); - assert_eq!(payload["number"], 42); - - let (api_base, handle) = mock_gitee( - |request| { - assert!(request.starts_with( - "GET /repos/perishme/perish.top/pulls?head=feat%2Fmissing&state=open " - )); - assert!(request.contains("authorization: token env-token")); - }, - r#"[]"#, - ); - let find_none = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_GITEE_API_BASE", api_base) - .env("GITEE_TOKEN", "env-token") - .args([ - "@tool", - "gitee", - "pr", - "find", - "--owner", - "perishme", - "--repo", - "perish.top", - "--head", - "feat/missing", - ]) - .output() - .expect("runseal should run"); - assert!(find_none.status.success()); - handle.join().expect("mock server should finish"); - let payload: serde_json::Value = - serde_json::from_slice(&find_none.stdout).expect("stdout should be JSON"); - assert!(payload.is_null()); - - let (api_base, handle) = mock_gitee( - |request| { - assert!( - request.starts_with( - "GET /repos/perishme/perish.top/pulls?head=feat%2Fdup&state=open " - ) - ); - assert!(request.contains("authorization: token env-token")); - }, - r#"[{"number":42,"head":{"ref":"feat/dup"},"base":{"ref":"main"}},{"number":43,"head":{"label":"perishme:feat/dup"},"base":{"label":"perishme:main"}}]"#, - ); - let find_ambiguous = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_GITEE_API_BASE", api_base) - .env("GITEE_TOKEN", "env-token") - .args([ - "@tool", - "gitee", - "pr", - "find", - "--owner", - "perishme", - "--repo", - "perish.top", - "--head", - "feat/dup", - ]) - .output() - .expect("runseal should run"); - assert!(!find_ambiguous.status.success()); - handle.join().expect("mock server should finish"); - assert!( - String::from_utf8_lossy(&find_ambiguous.stderr) - .contains("Gitee PR find is ambiguous for head `feat/dup`: found 2 matches") - ); - - let (api_base, handle) = mock_gitee( - |request| { - assert!(request.starts_with("POST /repos/perishme/perish.top/pulls ")); - assert!(request.contains("authorization: token custom-env-token")); - assert!(request.contains("Env override")); - }, - r#"{"number":77,"html_url":"https://gitee.test/pr/77"}"#, - ); - let create_with_token_env = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("RUNSEAL_GITEE_API_BASE", api_base) - .env("CUSTOM_GITEE_TOKEN", "custom-env-token") - .args([ - "@tool", - "gitee", - "pr", - "create", - "--owner", - "perishme", - "--repo", - "perish.top", - "--token-env", - "CUSTOM_GITEE_TOKEN", - "--base", - "main", - "--head", - "feat/env", - "--title", - "Env override", - "--body", - "Body", - ]) - .output() - .expect("runseal should run"); - assert!(create_with_token_env.status.success()); - handle.join().expect("mock server should finish"); -} - -#[test] -fn gitee_origin() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - for url in [ - "git@gitee.com:perishme/perish.top.git", - "https://gitee.com/perishme/perish.top.git", - "ssh://git@gitee.com/perishme/perish.top", - ] { - let output = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "gitee", "repo", "parse-origin", url]) - .output() - .expect("runseal should run"); - assert!(output.status.success()); - let payload: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("stdout should be JSON"); - assert_eq!(payload["owner"], "perishme"); - assert_eq!(payload["repo"], "perish.top"); - } -} - -fn mock_gitee<F>(assert_request: F, body: &'static str) -> (String, thread::JoinHandle<()>) -where - F: FnOnce(&str) + Send + 'static, -{ - let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); - let address = server - .local_addr() - .expect("mock server address should exist"); - let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); - let mut request = [0_u8; 4096]; - let read = stream - .read(&mut request) - .expect("request should be readable"); - let request = String::from_utf8_lossy(&request[..read]); - assert_request(&request); - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - body.len(), - body - ) - .expect("response should be written"); - }); - (format!("http://{address}"), handle) -} diff --git a/app/tests/internal_tool/github.rs b/app/tests/internal_tool/github.rs index 42000a6..6d7803b 100644 --- a/app/tests/internal_tool/github.rs +++ b/app/tests/internal_tool/github.rs @@ -7,6 +7,7 @@ use std::{ path::{Path, PathBuf}, process::Command, thread, + time::{Duration, Instant}, }; use tempfile::TempDir; @@ -419,7 +420,7 @@ where .local_addr() .expect("mock server address should exist"); let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut stream = accept_with_timeout(&server); let mut request = [0_u8; 8192]; let read = stream .read(&mut request) @@ -436,3 +437,22 @@ where }); (format!("http://{address}"), handle) } + +fn accept_with_timeout(server: &TcpListener) -> std::net::TcpStream { + server + .set_nonblocking(true) + .expect("mock server should become nonblocking"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match server.accept() { + Ok((stream, _)) => return stream, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + panic!("mock request did not arrive within 5 seconds"); + } + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("mock accept failed: {err}"), + } + } +} diff --git a/app/tests/internal_tool/hash_version.rs b/app/tests/internal_tool/hash_version.rs deleted file mode 100644 index 335e1ee..0000000 --- a/app/tests/internal_tool/hash_version.rs +++ /dev/null @@ -1,103 +0,0 @@ -use tempfile::TempDir; - -use super::bin; - -#[test] -fn version_atoms() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - for (args, expected) in [ - (vec!["@tool", "version", "part", "v1.2.3", "minor"], "2\n"), - ( - vec!["@tool", "version", "compare", "0.6.1", "0.6.0"], - "gt\n", - ), - ] { - let output = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(args.clone()) - .output() - .expect("runseal should run"); - - assert!(output.status.success(), "{args:?} should succeed"); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, expected, "{args:?} stdout should match"); - } -} - -#[test] -fn help() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - for (args, expected) in [ - ( - vec!["@tool", "hash", "--help"], - "Usage: runseal @tool hash <command> [args]", - ), - ( - vec!["@tool", "hash", "tree", "--help"], - "Usage: runseal @tool hash tree <path>...", - ), - ( - vec!["@tool", "version", "--help"], - "Usage: runseal @tool version <command> [args]", - ), - ( - vec!["@tool", "version", "part", "--help"], - "Usage: runseal @tool version part <version> <major|minor|patch>", - ), - ( - vec!["@tool", "version", "compare", "--help"], - "Usage: runseal @tool version compare <left> <right>", - ), - ] { - let output = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(args.clone()) - .output() - .expect("runseal should run"); - - assert!(output.status.success(), "{args:?} should succeed"); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!( - stdout.contains(expected), - "{args:?} should contain {expected:?}, got {stdout:?}" - ); - } -} - -#[test] -fn hash_tree() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("hash"); - std::fs::create_dir_all(cwd.join("nested")).expect("tree should be created"); - std::fs::write(cwd.join("a.txt"), "alpha\n").expect("a.txt should be written"); - std::fs::write(cwd.join("nested/b.txt"), "beta\n").expect("b.txt should be written"); - - let first = bin() - .current_dir(temp.path()) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "hash", "tree", cwd.to_str().unwrap()]) - .output() - .expect("runseal should run"); - assert!(first.status.success()); - - let second = bin() - .current_dir(temp.path()) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "hash", "tree", cwd.to_str().unwrap()]) - .output() - .expect("runseal should run"); - assert!(second.status.success()); - - let first_stdout = String::from_utf8(first.stdout).expect("stdout should be UTF-8"); - let second_stdout = String::from_utf8(second.stdout).expect("stdout should be UTF-8"); - assert_eq!(first_stdout, second_stdout); - assert_eq!(first_stdout.trim().len(), 64); -} diff --git a/app/tests/internal_tool/process.rs b/app/tests/internal_tool/process.rs deleted file mode 100644 index 4ed8395..0000000 --- a/app/tests/internal_tool/process.rs +++ /dev/null @@ -1,73 +0,0 @@ -use tempfile::TempDir; - -use super::bin; - -#[test] -fn process_write_routes_streams() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let output_path = cwd.join("logs").join("stderr.txt"); - - let output = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "process", - "write", - "stderr", - output_path.to_str().expect("path should be utf-8"), - "--", - "sh", - "-c", - "printf 'out\\n'; printf 'err\\n' >&2", - ]) - .output() - .expect("runseal should run"); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8(output.stdout).unwrap(), "out\n"); - assert_eq!(std::fs::read_to_string(&output_path).unwrap(), "err\n"); -} - -#[test] -fn process_write_appends() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - let output_path = cwd.join("stdout.txt"); - - for value in ["one", "two"] { - let mut args = vec![ - "@tool".to_string(), - "process".to_string(), - "write".to_string(), - "stdout".to_string(), - output_path - .to_str() - .expect("path should be utf-8") - .to_string(), - ]; - if value == "two" { - args.push("--append".to_string()); - } - args.push("--".to_string()); - args.push("printf".to_string()); - args.push(format!("{value}\\n")); - - let output = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(&args) - .output() - .expect("runseal should run"); - assert!(output.status.success(), "{args:?} should succeed"); - } - - assert_eq!(std::fs::read_to_string(&output_path).unwrap(), "one\ntwo\n"); -} diff --git a/app/tests/internal_tool/ssh.rs b/app/tests/internal_tool/ssh.rs deleted file mode 100644 index b187ac4..0000000 --- a/app/tests/internal_tool/ssh.rs +++ /dev/null @@ -1,241 +0,0 @@ -use std::{path::PathBuf, process::Command}; - -use tempfile::TempDir; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -#[test] -fn ssh_config() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - let base = cwd.join("ssh"); - std::fs::create_dir_all(&base).expect("ssh dir should be created"); - let config = base.join("config"); - std::fs::write( - &config, - r#" -Host 10m.hk.zxi *.lisa !blocked.lisa - IdentityFile id_root - IdentityFile ~/.ssh/id_extra -"#, - ) - .expect("config should be written"); - - let exact = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "ssh", - "config", - "host", - "10m.hk.zxi", - "--config", - config.to_str().unwrap(), - ]) - .output() - .expect("runseal should run"); - assert!(exact.status.success()); - assert_eq!(String::from_utf8(exact.stdout).unwrap(), "true\n"); - - let wildcard = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "ssh", - "config", - "host", - "ny.lisa", - "--config", - config.to_str().unwrap(), - ]) - .output() - .expect("runseal should run"); - assert!(wildcard.status.success()); - assert_eq!(String::from_utf8(wildcard.stdout).unwrap(), "true\n"); - - let blocked = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "ssh", - "config", - "host", - "blocked.lisa", - "--config", - config.to_str().unwrap(), - ]) - .output() - .expect("runseal should run"); - assert!(blocked.status.success()); - assert_eq!(String::from_utf8(blocked.stdout).unwrap(), "false\n"); - - let identities = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "ssh", - "config", - "identities", - "--config", - config.to_str().unwrap(), - ]) - .output() - .expect("runseal should run"); - assert!(identities.status.success()); - let files: Vec<String> = - serde_json::from_slice(&identities.stdout).expect("stdout should be JSON array"); - assert_eq!(files.len(), 2); - assert_eq!(PathBuf::from(&files[0]), base.join("id_root")); - assert!(files[1].ends_with(".ssh/id_extra")); - - let override_base = cwd.join("repo"); - let identities = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "ssh", - "config", - "identities", - "--config", - config.to_str().unwrap(), - "--base", - override_base.to_str().unwrap(), - ]) - .output() - .expect("runseal should run"); - assert!(identities.status.success()); - let files: Vec<String> = - serde_json::from_slice(&identities.stdout).expect("stdout should be JSON array"); - assert_eq!(PathBuf::from(&files[0]), override_base.join("id_root")); -} - -#[test] -#[cfg(unix)] -fn ssh_script() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - let bin_dir = temp.path().join("bin"); - let ssh_dir = cwd.join("ssh"); - std::fs::create_dir_all(&bin_dir).expect("bin dir should be created"); - std::fs::create_dir_all(&ssh_dir).expect("ssh dir should be created"); - let log = cwd.join("ssh.log"); - let stdin_file = cwd.join("stdin.txt"); - write_ssh_tool_stub(&bin_dir.join("ssh"), &log, &stdin_file); - - let config = ssh_dir.join("config"); - std::fs::write(&config, "Host demo.host\n HostName 127.0.0.1\n") - .expect("config should be written"); - let script = cwd.join("probe.sh"); - std::fs::write(&script, "echo from-script\n").expect("script should be written"); - - let run = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("PATH", prepend_path_for_test(&bin_dir)) - .args([ - "@tool", - "ssh", - "script", - "run", - "--config", - config.to_str().unwrap(), - "--host", - "demo.host", - "--file", - script.to_str().unwrap(), - "--", - "one", - "two", - ]) - .output() - .expect("runseal should run"); - assert!( - run.status.success(), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - assert_eq!( - std::fs::read_to_string(&log).expect("log should be readable"), - format!("ssh|-F|{}|demo.host|bash|-s|--|one|two\n", config.display()) - ); - assert_eq!( - std::fs::read_to_string(&stdin_file).expect("stdin should be readable"), - "echo from-script\n" - ); - - let capture = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .env("PATH", prepend_path_for_test(&bin_dir)) - .args([ - "@tool", - "ssh", - "script", - "capture", - "--config", - config.to_str().unwrap(), - "--host", - "demo.host", - "--file", - script.to_str().unwrap(), - ]) - .output() - .expect("runseal should run"); - assert!( - capture.status.success(), - "stderr: {}", - String::from_utf8_lossy(&capture.stderr) - ); - assert_eq!(String::from_utf8(capture.stdout).unwrap(), "captured\n\n"); -} - -#[cfg(unix)] -fn write_ssh_tool_stub( - path: &std::path::Path, - log: &std::path::Path, - stdin_file: &std::path::Path, -) { - use std::os::unix::fs::PermissionsExt; - - std::fs::write( - path, - format!( - r#"#!/usr/bin/env sh -set -eu -printf 'ssh' > '{}' -for arg in "$@"; do - printf '|%s' "$arg" >> '{}' -done -printf '\n' >> '{}' -cat > '{}' -printf 'captured\n' -"#, - log.display(), - log.display(), - log.display(), - stdin_file.display() - ), - ) - .expect("ssh stub should be written"); - let mut permissions = std::fs::metadata(path) - .expect("ssh stub metadata should be readable") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).expect("ssh stub should be executable"); -} - -#[cfg(unix)] -fn prepend_path_for_test(first: &std::path::Path) -> std::ffi::OsString { - let mut paths = vec![first.to_path_buf()]; - if let Some(existing) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(paths).expect("PATH should be joinable") -} diff --git a/app/tests/internal_tool/string.rs b/app/tests/internal_tool/string.rs deleted file mode 100644 index d415e20..0000000 --- a/app/tests/internal_tool/string.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -#[test] -fn string_join() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - let literal = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "string", - "join", - r#"["left","right"]"#, - "--separator", - ",", - ]) - .output() - .expect("runseal should run"); - assert!(literal.status.success()); - assert_eq!(String::from_utf8(literal.stdout).unwrap(), "left,right\n"); - - let path = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args([ - "@tool", - "string", - "join", - r#"["left","right"]"#, - "--separator", - "path", - ]) - .output() - .expect("runseal should run"); - assert!(path.status.success()); - let expected = std::env::join_paths(["left", "right"]) - .expect("paths should join") - .to_string_lossy() - .into_owned() - + "\n"; - assert_eq!(String::from_utf8(path.stdout).unwrap(), expected); -} - -#[test] -fn string_slug() { - let temp = TempDir::new().expect("temp dir should be created"); - let cwd = temp.path().join("empty"); - std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); - - let limited = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "string", "slug", "One Two Three", "--max-len", "7"]) - .output() - .expect("runseal should run"); - assert!(limited.status.success()); - assert_eq!(String::from_utf8(limited.stdout).unwrap(), "one-two\n"); - - let fallback = bin() - .current_dir(&cwd) - .env("RUNSEAL_HOME", temp.path().join("home")) - .args(["@tool", "string", "slug", "!!!", "--fallback", "change"]) - .output() - .expect("runseal should run"); - assert!(fallback.status.success()); - assert_eq!(String::from_utf8(fallback.stdout).unwrap(), "change\n"); -} diff --git a/app/tests/operator/cloudflare.rs b/app/tests/operator/cloudflare.rs index f42aba3..7985a6d 100644 --- a/app/tests/operator/cloudflare.rs +++ b/app/tests/operator/cloudflare.rs @@ -7,6 +7,7 @@ use std::{ path::{Path, PathBuf}, process::Command, thread, + time::{Duration, Instant}, }; use tempfile::TempDir; @@ -22,6 +23,10 @@ fn fixture() -> Fixture { std::fs::create_dir_all(project.join(".runseal/wrappers")) .expect("wrapper dir should be created"); std::fs::create_dir_all(project.join(".runseal/lib")).expect("lib dir should be created"); + std::fs::create_dir_all(project.join(".runseal/lib/std")) + .expect("std lib dir should be created"); + std::fs::create_dir_all(project.join(".runseal/templates")) + .expect("template dir should be created"); std::fs::write( project.join("runseal.toml"), r#" @@ -47,20 +52,46 @@ RUNSEAL_REPO_TMP_DIR = "resource://tmp" "#, ) .expect("profile should be written"); - std::fs::write(project.join(".runseal/deno.json"), "{}\n") - .expect("deno config should be written"); std::fs::write( - project.join(".runseal/lib/runseal.ts"), - std::fs::read_to_string(repo_root().join(".runseal/lib/runseal.ts")) - .expect("repo deno helper should be readable"), + project.join(".runseal/deno.json"), + std::fs::read_to_string(repo_root().join(".runseal/deno.json")) + .expect("repo deno config should be readable"), ) - .expect("deno helper should be copied"); + .expect("deno config should be copied"); + std::fs::write( + project.join(".runseal/lib/cli.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/cli.ts")) + .expect("repo cli helper should be readable"), + ) + .expect("cli helper should be copied"); + for path in [ + ".runseal/lib/std/cmd.ts", + ".runseal/lib/std/env.ts", + ".runseal/lib/std/fs.ts", + ".runseal/lib/std/io.ts", + ".runseal/lib/std/json.ts", + ".runseal/lib/std/path.ts", + ".runseal/lib/std/runseal.ts", + ] { + std::fs::write( + project.join(path), + std::fs::read_to_string(repo_root().join(path)) + .expect("repo std helper should be readable"), + ) + .expect("std helper should be copied"); + } std::fs::write( project.join(".runseal/wrappers/cloudflare.ts"), std::fs::read_to_string(repo_root().join(".runseal/wrappers/cloudflare.ts")) .expect("repo cloudflare wrapper should be readable"), ) .expect("cloudflare wrapper should be copied"); + std::fs::write( + project.join(".runseal/templates/cloudflare.env"), + std::fs::read_to_string(repo_root().join(".runseal/templates/cloudflare.env")) + .expect("repo cloudflare template should be readable"), + ) + .expect("cloudflare template should be copied"); Fixture { _temp: temp, project, @@ -158,7 +189,7 @@ where .local_addr() .expect("mock server address should exist"); let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut stream = accept_with_timeout(&server); let mut request = [0_u8; 4096]; let read = stream .read(&mut request) @@ -176,6 +207,25 @@ where (format!("http://{address}"), handle) } +fn accept_with_timeout(server: &TcpListener) -> std::net::TcpStream { + server + .set_nonblocking(true) + .expect("mock server should become nonblocking"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match server.accept() { + Ok((stream, _)) => return stream, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + panic!("mock request did not arrive within 5 seconds"); + } + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("mock accept failed: {err}"), + } + } +} + fn stdout(output: &std::process::Output) -> String { String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8") } @@ -415,7 +465,7 @@ fn api_passthrough_uses_tool() { .local_addr() .expect("mock server address should exist"); let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut stream = accept_with_timeout(&server); let mut request = [0_u8; 2048]; let read = stream .read(&mut request) diff --git a/app/tests/operator/estate/pr.rs b/app/tests/operator/estate/pr.rs deleted file mode 100644 index 61b1929..0000000 --- a/app/tests/operator/estate/pr.rs +++ /dev/null @@ -1,327 +0,0 @@ -use std::{ - io::{Read, Write}, - net::TcpListener, - thread, -}; - -use super::{fixture, log, run_wrapper, run_wrapper_env}; - -#[test] -fn api_flow() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/seal", true, false); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/seal", "--body", "Body", "Land change"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert!(String::from_utf8_lossy(&output.stdout).contains("https://gitee.test/pr/42")); - assert_eq!( - log(&fx), - "git|checkout|-b|feat/seal\ngit|add|-A\ngit|commit|-m|Land change\ngit|push|-u|origin|feat/seal\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|feat/seal\ngit|branch|-D|feat/seal\n" - ); -} - -#[test] -fn default_branch() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("auto/land-change", true, false); - - let output = run_wrapper_env( - &fx, - "pr", - &["Land Change"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|checkout|-b|auto/land-change\ngit|add|-A\ngit|commit|-m|Land Change\ngit|push|-u|origin|auto/land-change\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|auto/land-change\ngit|branch|-D|auto/land-change\n" - ); -} - -#[test] -fn dry_run() { - let fx = fixture(); - - let output = run_wrapper( - &fx, - "pr", - &["--branch", "feat/seal", "--dry-run", "Land change"], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("branch: feat/seal")); - assert!(stdout.contains("owner: perishme")); - assert!(stdout.contains("repo: perish.top")); - assert_eq!(log(&fx), ""); -} - -#[test] -fn no_merge() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/no-merge", false, false); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/no-merge", "--no-merge", "No merge"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|checkout|-b|feat/no-merge\ngit|add|-A\ngit|commit|-m|No merge\ngit|push|-u|origin|feat/no-merge\ngit|checkout|main\n" - ); -} - -#[test] -fn reuse_existing_pr() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/reuse", false, true); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/reuse", "--no-merge", "Reuse existing"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert!(String::from_utf8_lossy(&output.stdout).contains("https://gitee.test/pr/42")); - assert_eq!( - log(&fx), - "git|checkout|-b|feat/reuse\ngit|add|-A\ngit|commit|-m|Reuse existing\ngit|push|-u|origin|feat/reuse\ngit|checkout|main\n" - ); -} - -#[test] -fn resume_local() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/resume", true, true); - - let output = run_wrapper_env( - &fx, - "pr", - &["--resume", "Resume change"], - &[ - ("RUNSEAL_GITEE_API_BASE", api_base), - ("RUNSEAL_TEST_BRANCH", "feat/resume".to_string()), - ("RUNSEAL_TEST_STATUS", "".to_string()), - ], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|push|-u|origin|feat/resume\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|feat/resume\ngit|branch|-D|feat/resume\n" - ); -} - -#[test] -fn resume_remote() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/remote", true, true); - - let output = run_wrapper_env( - &fx, - "pr", - &["--resume", "--branch", "feat/remote", "Resume remote"], - &[ - ("RUNSEAL_GITEE_API_BASE", api_base), - ("RUNSEAL_TEST_BRANCH", "main".to_string()), - ("RUNSEAL_TEST_CHECKOUT_FAIL", "feat/remote".to_string()), - ("RUNSEAL_TEST_STATUS", "".to_string()), - ], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|checkout|feat/remote\ngit|fetch|origin|feat/remote\ngit|checkout|-B|feat/remote|origin/feat/remote\ngit|push|-u|origin|feat/remote\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|feat/remote\ngit|branch|-D|feat/remote\n" - ); -} - -#[test] -fn clean_start() { - let fx = fixture(); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/empty", "Empty change"], - &[("RUNSEAL_TEST_STATUS", "".to_string())], - ); - - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("pr: no local changes to land")); - assert_eq!(log(&fx), ""); -} - -#[test] -fn resume_clean() { - let fx = fixture(); - - let output = run_wrapper_env( - &fx, - "pr", - &["--resume", "Resume dirty"], - &[ - ("RUNSEAL_TEST_BRANCH", "feat/resume".to_string()), - ("RUNSEAL_TEST_STATUS", " M docs.md".to_string()), - ], - ); - - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("pr: --resume requires a clean topic branch") - ); - assert_eq!(log(&fx), ""); -} - -fn mock_gitee_sequence( - expected_head: &'static str, - merge: bool, - existing_pr: bool, -) -> (String, thread::JoinHandle<()>) { - let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); - let address = server - .local_addr() - .expect("mock server address should exist"); - let handle = thread::spawn(move || { - let mut expected = vec![("GET", "/repos/perishme/perish.top/pulls")]; - if !existing_pr { - expected.push(("POST", "/repos/perishme/perish.top/pulls")); - } - if merge { - expected.push(("POST", "/repos/perishme/perish.top/pulls/42/review")); - expected.push(("POST", "/repos/perishme/perish.top/pulls/42/test")); - expected.push(("PUT", "/repos/perishme/perish.top/pulls/42/merge")); - } - for (index, expected) in expected.into_iter().enumerate() { - let (mut stream, _) = server.accept().expect("mock request should arrive"); - let mut request = [0_u8; 4096]; - let read = stream - .read(&mut request) - .expect("request should be readable"); - let request = String::from_utf8_lossy(&request[..read]); - if expected.0 == "GET" { - assert!( - request.starts_with(&format!("{} {}?", expected.0, expected.1)), - "unexpected request: {request}" - ); - } else { - assert!( - request.starts_with(&format!("{} {} ", expected.0, expected.1)), - "unexpected request: {request}" - ); - } - assert!(request.contains("authorization: token test-token")); - let body = if index == 0 { - assert!(request.contains(&format!("head={}", expected_head.replace('/', "%2F")))); - assert!(request.contains("state=open")); - if request.contains("base=main") { - } else { - panic!("expected base=main filter in request: {request}"); - } - if existing_pr { - Box::leak( - format!( - r#"[{{"number":42,"head":{{"ref":"{expected_head}"}},"base":{{"ref":"main"}},"html_url":"https://gitee.test/pr/42"}}]"# - ) - .into_boxed_str(), - ) - } else { - r#"[]"# - } - } else if !existing_pr && index == 1 { - assert!(request.contains("test-token")); - assert!(request.contains(expected_head)); - r#"{"number":42,"html_url":"https://gitee.test/pr/42"}"# - } else { - r#"{}"# - }; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - body.len(), - body - ) - .expect("response should be written"); - } - }); - (format!("http://{address}"), handle) -} diff --git a/app/tests/operator/guard.rs b/app/tests/operator/guard.rs index f251283..11bf812 100644 --- a/app/tests/operator/guard.rs +++ b/app/tests/operator/guard.rs @@ -7,6 +7,7 @@ use std::{ path::{Path, PathBuf}, process::Command, thread, + time::{Duration, Instant}, }; use tempfile::TempDir; @@ -42,15 +43,49 @@ permissions = [ "#, ) .expect("profile should be written"); - std::fs::write(project.join(".runseal/deno.json"), "{}\n") - .expect("deno config should be written"); + std::fs::write( + project.join(".runseal/deno.json"), + std::fs::read_to_string(repo_root().join(".runseal/deno.json")) + .expect("repo deno config should be readable"), + ) + .expect("deno config should be copied"); std::fs::create_dir_all(project.join(".runseal/lib")).expect("lib dir should be created"); + std::fs::create_dir_all(project.join(".runseal/lib/std")) + .expect("std lib dir should be created"); + std::fs::write( + project.join(".runseal/lib/cli.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/cli.ts")) + .expect("repo cli helper should be readable"), + ) + .expect("cli helper should be copied"); + std::fs::write( + project.join(".runseal/lib/hash.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/hash.ts")) + .expect("repo hash helper should be readable"), + ) + .expect("hash helper should be copied"); + for path in [ + ".runseal/lib/std/cmd.ts", + ".runseal/lib/std/env.ts", + ".runseal/lib/std/fs.ts", + ".runseal/lib/std/io.ts", + ".runseal/lib/std/json.ts", + ".runseal/lib/std/path.ts", + ".runseal/lib/std/runseal.ts", + ] { + std::fs::write( + project.join(path), + std::fs::read_to_string(repo_root().join(path)) + .expect("repo std helper should be readable"), + ) + .expect("std helper should be copied"); + } std::fs::write( - project.join(".runseal/lib/runseal.ts"), - std::fs::read_to_string(repo_root().join(".runseal/lib/runseal.ts")) - .expect("repo deno helper should be readable"), + project.join(".runseal/lib/version.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/version.ts")) + .expect("repo version helper should be readable"), ) - .expect("deno helper should be copied"); + .expect("version helper should be copied"); std::fs::write( project.join(".runseal/wrappers/guard.ts"), std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.ts")) @@ -128,7 +163,7 @@ fn mock_metadata(status: u16, body: &'static str) -> (String, thread::JoinHandle .local_addr() .expect("mock server address should exist"); let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut stream = accept_with_timeout(&server); let mut request = [0_u8; 2048]; let read = stream .read(&mut request) @@ -146,6 +181,25 @@ fn mock_metadata(status: u16, body: &'static str) -> (String, thread::JoinHandle (format!("http://{address}/metadata.json"), handle) } +fn accept_with_timeout(server: &TcpListener) -> std::net::TcpStream { + server + .set_nonblocking(true) + .expect("mock server should become nonblocking"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match server.accept() { + Ok((stream, _)) => return stream, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + panic!("mock request did not arrive within 5 seconds"); + } + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("mock accept failed: {err}"), + } + } +} + fn run_guard(fx: &Fixture, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output { let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); command @@ -172,17 +226,9 @@ fn version_hash() { "stderr: {}", String::from_utf8_lossy(&wrapper.stderr) ); - - let tool = Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.project) - .env("PATH", prepend_path(&fx.bin)) - .env("RUNSEAL_HOME", fx.project.join(".home")) - .args(["@tool", "hash", "tree", "app/tests"]) - .output() - .expect("hash tool should run"); - assert!(tool.status.success()); - - assert_eq!(wrapper.stdout, tool.stdout); + let stdout = String::from_utf8(wrapper.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout.trim().len(), 64); + assert!(stdout.trim().chars().all(|ch| ch.is_ascii_hexdigit())); } #[test] diff --git a/app/tests/operator/init.rs b/app/tests/operator/init.rs index b2a98c1..6628d21 100644 --- a/app/tests/operator/init.rs +++ b/app/tests/operator/init.rs @@ -44,6 +44,7 @@ fn write_required_files(project: &Path) { for path in [ "Cargo.toml", "Cargo.lock", + "deno.lock", "flavor.toml", "manage.sh", "manage.ps1", @@ -51,7 +52,17 @@ fn write_required_files(project: &Path) { ".runseal/deno.json", ".runseal/hooks/pre-commit", ".runseal/hooks/commit-msg", - ".runseal/lib/runseal.ts", + ".runseal/lib/cli.ts", + ".runseal/lib/hash.ts", + ".runseal/lib/std/cmd.ts", + ".runseal/lib/std/env.ts", + ".runseal/lib/std/fs.ts", + ".runseal/lib/std/io.ts", + ".runseal/lib/std/json.ts", + ".runseal/lib/std/path.ts", + ".runseal/lib/std/runseal.ts", + ".runseal/lib/version.ts", + ".runseal/templates/cloudflare.env", ".runseal/wrappers/cloudflare.ts", ".runseal/wrappers/guard.ts", ".runseal/wrappers/init.ts", @@ -79,6 +90,12 @@ fn write_required_files(project: &Path) { .expect("parent should be created"); std::fs::write(&file, "").expect("required file should be written"); } + std::fs::write( + project.join("deno.lock"), + std::fs::read_to_string(repo_root().join("deno.lock")) + .expect("repo deno lock should be readable"), + ) + .expect("deno lock should be copied"); std::fs::write( project.join(".runseal/wrappers/init.ts"), std::fs::read_to_string(repo_root().join(".runseal/wrappers/init.ts")) @@ -92,11 +109,39 @@ fn write_required_files(project: &Path) { ) .expect("guard wrapper should be copied"); std::fs::write( - project.join(".runseal/lib/runseal.ts"), - std::fs::read_to_string(repo_root().join(".runseal/lib/runseal.ts")) - .expect("repo deno helper should be readable"), + project.join(".runseal/lib/cli.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/cli.ts")) + .expect("repo cli helper should be readable"), ) - .expect("deno helper should be copied"); + .expect("cli helper should be copied"); + for path in [ + ".runseal/lib/std/cmd.ts", + ".runseal/lib/std/env.ts", + ".runseal/lib/std/fs.ts", + ".runseal/lib/std/io.ts", + ".runseal/lib/std/json.ts", + ".runseal/lib/std/path.ts", + ".runseal/lib/std/runseal.ts", + ] { + std::fs::write( + project.join(path), + std::fs::read_to_string(repo_root().join(path)) + .expect("repo std helper should be readable"), + ) + .expect("std helper should be copied"); + } + std::fs::write( + project.join(".runseal/lib/hash.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/hash.ts")) + .expect("repo hash helper should be readable"), + ) + .expect("hash helper should be copied"); + std::fs::write( + project.join(".runseal/lib/version.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/version.ts")) + .expect("repo version helper should be readable"), + ) + .expect("version helper should be copied"); std::fs::write( project.join(".runseal/deno.json"), std::fs::read_to_string(repo_root().join(".runseal/deno.json")) @@ -115,6 +160,12 @@ fn write_required_files(project: &Path) { .expect("repo commit-msg hook should be readable"), ) .expect("commit-msg hook should be copied"); + std::fs::write( + project.join(".runseal/templates/cloudflare.env"), + std::fs::read_to_string(repo_root().join(".runseal/templates/cloudflare.env")) + .expect("repo cloudflare template should be readable"), + ) + .expect("cloudflare template should be copied"); std::fs::write( project.join("runseal.toml"), r#" @@ -136,7 +187,7 @@ permissions = [ fn write_stub(path: &Path) { use std::os::unix::fs::PermissionsExt; - std::fs::write(path, "#!/usr/bin/env sh\nexit 0\n").expect("stub should be written"); + std::fs::write(path, "#!/bin/sh\nexit 0\n").expect("stub should be written"); let mut permissions = std::fs::metadata(path) .expect("stub metadata should be readable") .permissions(); @@ -190,6 +241,7 @@ fn init_installs_generated_hooks() { let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); let commit_msg_text = std::fs::read_to_string(&commit_msg).expect("commit-msg should exist"); assert!(pre_commit_text.contains("runseal init hook")); + assert!(pre_commit_text.contains("refusing to commit directly on main")); assert!(pre_commit_text.contains("runseal :guard")); assert!(commit_msg_text.contains("runseal init hook")); } @@ -229,4 +281,5 @@ fn force_backs_up_hook() { assert!(fx.project.join(".git/hooks/pre-commit.bak").is_file()); let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); assert!(pre_commit_text.contains("runseal init hook")); + assert!(pre_commit_text.contains("refusing to commit directly on main")); } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..a898f56 --- /dev/null +++ b/deno.lock @@ -0,0 +1,16 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/cli@1.0.30": "1.0.30" + }, + "jsr": { + "@std/cli@1.0.30": { + "integrity": "769446536522d0417d7127ebcabcafac1ab0ce6766d0eb3fca1d36326fe98d13" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/cli@1.0.30" + ] + } +} diff --git a/docs/examples/README.md b/docs/examples/README.md index c3c9c26..555d0ab 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -4,6 +4,7 @@ These examples capture canonical runseal-owned shapes that are valid but easy to get wrong from plain CLI intuition alone. - [GitHub tool examples](./tools/github.md) +- [Cloudflare tool examples](./tools/cloudflare.md) Use these as the repository-owned reference when a live wrapper or operator flow needs the exact runseal tool or wrapper boundary. diff --git a/docs/examples/tools/cloudflare.md b/docs/examples/tools/cloudflare.md new file mode 100644 index 0000000..d110e69 --- /dev/null +++ b/docs/examples/tools/cloudflare.md @@ -0,0 +1,121 @@ +# Cloudflare Tool Examples + +These examples show the intended `runseal @tool cloudflare ...` atom shapes and +how repo wrappers can bind local policy around them. + +## Credential file + +Cloudflare helpers read repo-local credentials from: + +```text +.local/secrets/cloudflare.env +``` + +Create the template with: + +```bash +runseal :cloudflare init +``` + +The template is local operator state. Do not commit filled credential files. + +## Config + +Inspect the configured defaults that wrappers will use: + +```bash +runseal @tool cloudflare config json +runseal @tool cloudflare config get zone_name +runseal @tool cloudflare config get manage_host +``` + +## Generic API Request + +Use `api request` for a single authenticated Cloudflare API request when no +more specific atom exists: + +```bash +runseal @tool cloudflare api request GET /zones --query name=perish.uk +``` + +Send a JSON request body with `--json`: + +```bash +runseal @tool cloudflare api request PATCH /zones/ZONE_ID/dns_records/RECORD_ID \ + --json '{"ttl":120}' +``` + +## Zone And DNS Records + +Fetch one zone by exact name: + +```bash +runseal @tool cloudflare zone get --name perish.uk +``` + +List DNS records: + +```bash +runseal @tool cloudflare zone dns-record list --zone-id ZONE_ID +runseal @tool cloudflare zone dns-record list --zone-id ZONE_ID --name runseal.perish.uk +``` + +Create or update one DNS record from explicit JSON: + +```bash +runseal @tool cloudflare zone dns-record create --zone-id ZONE_ID \ + --json '{"type":"CNAME","name":"runseal","content":"example.com","proxied":true}' + +runseal @tool cloudflare zone dns-record update --zone-id ZONE_ID --record-id RECORD_ID \ + --json '{"ttl":120}' +``` + +## Rulesets And Redirect Rules + +List and fetch rulesets: + +```bash +runseal @tool cloudflare zone ruleset list --zone-id ZONE_ID +runseal @tool cloudflare zone ruleset get --zone-id ZONE_ID --ruleset-id RULESET_ID +``` + +Build one exact redirect rule payload locally: + +```bash +runseal @tool cloudflare redirect-rule exact \ + --ref runseal_manage_sh_redirect \ + --description "Redirect runseal manage.sh to releases bucket asset" \ + --host runseal.perish.uk \ + --path /manage.sh \ + --target-url https://releases.runseal.perish.uk/manage.sh +``` + +Add or update the rule with the ruleset atoms: + +```bash +runseal @tool cloudflare zone ruleset rule add \ + --zone-id ZONE_ID \ + --ruleset-id RULESET_ID \ + --json '{"action":"redirect"}' + +runseal @tool cloudflare zone ruleset rule update \ + --zone-id ZONE_ID \ + --ruleset-id RULESET_ID \ + --rule-id RULE_ID \ + --json '{"description":"updated"}' +``` + +## Wrapper Boundary + +Prefer `runseal :cloudflare ...` for repo-owned operator flows such as manager +redirect planning and reconciliation: + +```bash +runseal :cloudflare manage-plan +runseal :cloudflare manage-inspect +runseal :cloudflare manage-ensure-redirect --dry-run +``` + +Use direct `@tool cloudflare ...` calls for one atomic API operation. Use the +wrapper when the flow needs repo defaults, repeated calls, JSON shaping, or +operator policy. diff --git a/docs/examples/tools/github.md b/docs/examples/tools/github.md index ebdab90..9f5f7da 100644 --- a/docs/examples/tools/github.md +++ b/docs/examples/tools/github.md @@ -14,8 +14,9 @@ Priority: 3. `--token-env` 4. default `GITHUB_TOKEN` -So the normal path is to inject `GITHUB_TOKEN` through runseal env/profile -mechanics and keep the call site clean. +For repo-owned automation, prefer injecting a dedicated token through the +profile and passing it explicitly with `--token-env <name>`. Plain +`GITHUB_TOKEN` remains the default fallback for small local calls. ## Issue comment create diff --git a/flavor.toml b/flavor.toml index 85fcdd8..7c8a57d 100644 --- a/flavor.toml +++ b/flavor.toml @@ -1,5 +1,6 @@ [scan] include = [ + ".runseal/**", "app/src/**/*.rs", "app/tests/**/*.rs", ] diff --git a/manage.ps1 b/manage.ps1 index a6e96e4..bd05ff8 100644 --- a/manage.ps1 +++ b/manage.ps1 @@ -6,8 +6,11 @@ $remaining = if ($args.Length -gt 1) { $args[1..($args.Length - 1)] } else { @() $channel = if ($env:RUNSEAL_CHANNEL) { $env:RUNSEAL_CHANNEL } else { 'stable' } $version = if ($env:RUNSEAL_VERSION) { $env:RUNSEAL_VERSION } else { '' } $publicUrl = if ($env:RUNSEAL_RELEASES_PUBLIC_URL) { $env:RUNSEAL_RELEASES_PUBLIC_URL } else { 'https://releases.runseal.perish.uk' } -$installRoot = if ($env:RUNSEAL_INSTALL_ROOT) { $env:RUNSEAL_INSTALL_ROOT } else { Join-Path $env:LOCALAPPDATA 'runseal' } -$localBinDir = if ($env:RUNSEAL_LOCAL_BIN_DIR) { $env:RUNSEAL_LOCAL_BIN_DIR } else { Join-Path $env:USERPROFILE '.local\bin' } +$defaultInstallBase = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } elseif ($env:HOME) { Join-Path $env:HOME '.local/share' } else { '.' } +$defaultBinBase = if ($env:USERPROFILE) { $env:USERPROFILE } elseif ($env:HOME) { $env:HOME } else { '.' } +$defaultBinLeaf = if ($env:USERPROFILE) { '.local\bin' } else { '.local/bin' } +$installRoot = if ($env:RUNSEAL_INSTALL_ROOT) { $env:RUNSEAL_INSTALL_ROOT } else { Join-Path $defaultInstallBase 'runseal' } +$localBinDir = if ($env:RUNSEAL_LOCAL_BIN_DIR) { $env:RUNSEAL_LOCAL_BIN_DIR } else { Join-Path $defaultBinBase $defaultBinLeaf } $retain = if ($env:RUNSEAL_RETAIN) { $env:RUNSEAL_RETAIN } else { '' } for ($i = 0; $i -lt $remaining.Length; $i++) { @@ -33,6 +36,11 @@ Usage: manage.ps1 install [--channel stable|beta] [--version vX.Y.Z] [--retain[=true|false]] manage.ps1 uninstall [--version vX.Y.Z] +Options: + --public-url <url> release metadata and artifact base URL + --install-root <path> versioned install root + --bin-dir <path> directory for the runseal executable + Environment: RUNSEAL_RELEASES_PUBLIC_URL # default: https://releases.runseal.perish.uk RUNSEAL_CHANNEL @@ -185,6 +193,11 @@ Usage: manage.ps1 install [--channel stable|beta] [--version vX.Y.Z] [--retain[=true|false]] manage.ps1 uninstall [--version vX.Y.Z] +Options: + --public-url <url> release metadata and artifact base URL + --install-root <path> versioned install root + --bin-dir <path> directory for the runseal executable + Environment: RUNSEAL_RELEASES_PUBLIC_URL # default: https://releases.runseal.perish.uk RUNSEAL_CHANNEL diff --git a/manage.sh b/manage.sh index acb12dd..ced1cfd 100755 --- a/manage.sh +++ b/manage.sh @@ -74,6 +74,11 @@ Usage: manage.sh install [--channel stable|beta] [--version vX.Y.Z] [--retain[=true|false]] manage.sh uninstall [--version vX.Y.Z] +Options: + --public-url <url> release metadata and artifact base URL + --install-root <path> versioned install root + --bin-dir <path> directory for the runseal shim/link + Environment: RUNSEAL_RELEASES_PUBLIC_URL # default: https://releases.runseal.perish.uk RUNSEAL_CHANNEL @@ -236,6 +241,11 @@ Usage: manage.sh install [--channel stable|beta] [--version vX.Y.Z] [--retain[=true|false]] manage.sh uninstall [--version vX.Y.Z] +Options: + --public-url <url> release metadata and artifact base URL + --install-root <path> versioned install root + --bin-dir <path> directory for the runseal shim/link + Environment: RUNSEAL_RELEASES_PUBLIC_URL # default: https://releases.runseal.perish.uk RUNSEAL_CHANNEL diff --git a/runseal.toml b/runseal.toml index eb174bb..2893cc8 100644 --- a/runseal.toml +++ b/runseal.toml @@ -9,7 +9,7 @@ permissions = [ "--allow-write=.", "--allow-env", "--allow-net", - "--allow-run=git,gh,cargo,flavor,sh,bash,pwsh,python3,deno,runseal", + "--allow-run=git,gh,cargo,flavor,sh,bash,pwsh,python3,deno,runseal,sed,grep", ] [[injections]]