From a37f85e385b6d485decb325c76cff3793ec9a368 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Jul 2026 11:18:38 -0300 Subject: [PATCH] Add alias command for creating extra CLI names - Add `commit alias` with an interactive hub for listing, creating, and deleting aliases, plus scriptable `list`, `add `, and `remove ` subcommands. - Bind each alias to a subcommand and install it as a POSIX shim under `~/.commit-tools/bin`, so reinstalling or removing the npm package never touches user aliases. - Introduce a branded `AliasName` in `src/domain/alias/alias.ts` whose private constructor makes an unsafe filesystem path unrepresentable in `shimPath` and `removeShim`. - Keep the registry in `aliases.json` separate from `config.json` so aliases work before `commit setup` has run, validating names and rejecting duplicates at the load boundary. - Offer a one-time managed `PATH` block in `.zshrc`, `.bashrc`, or `config.fish`, written only after an explicit confirmation and idempotent on repeat runs. - Warn before an alias shadows an existing binary on `PATH`, since the alias bin dir is prepended. - Quote interpreter and entry-script paths with POSIX single-quote escaping so a `$` or `'` in the path cannot break the generated shim. - Reject `commit alias` on Windows with a clear message rather than writing an unusable `sh` shim. - Cover the domain, storage, shim, `PATH` setup, CLI, and parser behavior with unit tests. --- README.md | 28 ++- index.ts | 5 +- src/cli/alias.ts | 255 ++++++++++++++++++++++++++++ src/cli/parser.ts | 37 +++- src/domain/alias/alias.ts | 73 ++++++++ src/infra/alias/path-setup.ts | 55 ++++++ src/infra/alias/shims.ts | 60 +++++++ src/infra/storage/aliases.ts | 67 ++++++++ test/cli/alias.test.ts | 119 +++++++++++++ test/cli/parser.test.ts | 36 ++++ test/domain/alias/alias.test.ts | 79 +++++++++ test/infra/alias/path-setup.test.ts | 90 ++++++++++ test/infra/alias/shims.test.ts | 92 ++++++++++ test/infra/storage/aliases.test.ts | 68 ++++++++ 14 files changed, 1061 insertions(+), 3 deletions(-) create mode 100644 src/cli/alias.ts create mode 100644 src/domain/alias/alias.ts create mode 100644 src/infra/alias/path-setup.ts create mode 100644 src/infra/alias/shims.ts create mode 100644 src/infra/storage/aliases.ts create mode 100644 test/cli/alias.test.ts create mode 100644 test/domain/alias/alias.test.ts create mode 100644 test/infra/alias/path-setup.test.ts create mode 100644 test/infra/alias/shims.test.ts create mode 100644 test/infra/storage/aliases.test.ts diff --git a/README.md b/README.md index 7f60f4d..f647ab2 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,32 @@ This flow also lets you adjust the reasoning effort for the chosen model. If the commit effort ``` -### 3. Generate a Commit +### 3. Create Aliases (optional) + +`commit` is the built-in name, but you can add your own short names bound to any subcommand: + +```bash +commit alias +``` + +This opens an interactive hub that lists your aliases and lets you create or delete them. Or do it directly: + +```bash +commit alias add cb branch # `cb` now runs `commit branch` +commit alias add cm generate # `cm` now runs `commit generate` +commit alias list +commit alias remove cb +``` + +Aliases are small shell scripts in `~/.commit-tools/bin`, so uninstalling or reinstalling the npm package never touches them. The first time you create one, the tool offers to add that directory to your `PATH` in your shell profile (`.zshrc`, `.bashrc`, or `config.fish`) inside a clearly marked block — you can also add it yourself: + +```bash +export PATH="$HOME/.commit-tools/bin:$PATH" +``` + +Extra arguments are forwarded, so `cb --help` behaves like `commit branch --help`. Aliases are POSIX-only for now; `commit alias` is not yet supported on Windows. + +### 4. Generate a Commit Stage your changes, then run: @@ -177,6 +202,7 @@ commit --help | `commit doctor` | Check installation and environment | | `commit model` | Select a different AI model | | `commit effort` | Adjust the reasoning effort for the current model | +| `commit alias` | List, create, and delete extra CLI names | | `commit update` | Install the latest version from npm | | `commit --version`, `-v` | Show version | | `commit --help`, `-h` | Show help | diff --git a/index.ts b/index.ts index 12ecf8c..f2bfe8a 100755 --- a/index.ts +++ b/index.ts @@ -4,6 +4,7 @@ import { Setup } from "@/cli/setup"; import { Doctor } from "@/cli/doctor"; import { ModelCommand } from "@/cli/model"; import { EffortCommand } from "@/cli/effort"; +import { AliasCommand } from "@/cli/alias"; import { Update } from "@/cli/update"; import { type CliCommand, parseArgs, showHelp, showVersion } from "@/cli/parser"; import { Future } from "@/libs/future"; @@ -12,7 +13,7 @@ import { checkUpdate } from "@/cli/update"; import color from "picocolors"; -const NOTIFIER_COMMANDS = new Set(["generate", "setup", "doctor", "model", "effort", "branch"]); +const NOTIFIER_COMMANDS = new Set(["generate", "setup", "doctor", "model", "effort", "branch", "alias"]); const main = () => { const args = process.argv.slice(2); @@ -38,6 +39,8 @@ const main = () => { return EffortCommand.create().chain((e) => e.run()); case "branch": return Branch.create().chain((b) => b.run()); + case "alias": + return AliasCommand.create(command.action).chain((a) => a.run()); case "update": return Update.create().run(); case "version": diff --git a/src/cli/alias.ts b/src/cli/alias.ts new file mode 100644 index 0000000..2a17057 --- /dev/null +++ b/src/cli/alias.ts @@ -0,0 +1,255 @@ +export { AliasCommand }; + +import * as p from "@clack/prompts"; + +import { Future } from "@/libs/future"; +import { Just } from "@/libs/maybe"; +import { absurd } from "@/libs/types"; +import { ALIAS_TARGETS, AliasName, addAlias, describeTarget, removeAlias, type Alias, type AliasTarget } from "@/domain/alias/alias"; +import { type AliasAction } from "@/cli/parser"; +import { loadAliases, saveAliases } from "@/infra/storage/aliases"; +import { aliasBinDir, findConflictingBinary, reconcileShims, removeShim, shimPath, writeShim } from "@/infra/alias/shims"; +import { detectProfile, ensureBinDirOnPath, isBinDirOnPath, pathExportLine } from "@/infra/alias/path-setup"; + +import color from "picocolors"; +import Table from "cli-table3"; + +const cancelled = (): Error => new Error("Cancelled"); + +const parseName = (raw: string): Future => + AliasName.parse(raw).either( + (msg) => Future.reject(new Error(msg)), + (name) => Future.resolve(name) + ); + +class AliasCommand { + private constructor( + private readonly action: AliasAction, + private readonly initial: readonly Alias[] + ) {} + + /** Unlike ModelCommand, this needs no config — aliases work before `commit setup` has ever run. */ + static create(action: AliasAction): Future { + return loadAliases().map((aliases) => new AliasCommand(action, aliases)); + } + + run(): Future { + // index.ts exits without printing a rejection, so every user-facing error goes through here. + return this.dispatch().mapRej((e) => { + p.log.error(color.red(e.message)); + return e; + }); + } + + private dispatch(): Future { + if (process.platform === "win32") { + return Future.reject(new Error("`commit alias` is not supported on Windows yet — it writes POSIX shell shims.")); + } + + // Bound to a const so the narrowing survives into the callbacks below. + const action = this.action; + + switch (action.type) { + case "list": + return Future.resolve(this.renderTable(this.initial)); + case "add": + return parseName(action.name) + .chain((name) => this.createAlias(this.initial, name, action.target)) + .map(() => undefined); + case "remove": + return parseName(action.name) + .chain((name) => this.deleteAlias(this.initial, name)) + .map(() => undefined); + case "hub": + return reconcileShims(this.initial).chain(() => { + p.intro(color.bgCyan(color.black(" Aliases "))); + return this.hub(this.initial).map(() => p.outro(color.green("Done!"))); + }); + default: + return absurd(action, "AliasAction"); + } + } + + /** The registry is threaded through the loop rather than read from `this`, so the table never goes stale. */ + private hub(aliases: readonly Alias[]): Future { + this.renderTable(aliases); + + return Future.attemptP(async () => { + const choice = await p.select({ + message: "What next?", + options: [ + { value: "create" as const, label: "Create alias" }, + { value: "delete" as const, label: "Delete alias", disabled: aliases.length === 0 }, + { value: "done" as const, label: "Done" } + ], + initialValue: "create" as const + }); + + if (p.isCancel(choice)) throw cancelled(); + return choice; + }).chain((choice) => { + switch (choice) { + case "create": + return this.createAlias(aliases).chain((next) => this.hub(next)); + case "delete": + return this.deleteAlias(aliases).chain((next) => this.hub(next)); + case "done": + return Future.resolve(undefined); + default: + return absurd(choice, "HubChoice"); + } + }); + } + + private createAlias(aliases: readonly Alias[], presetName?: AliasName, presetTarget?: AliasTarget): Future { + return this.resolveNewAlias(presetName, presetTarget) + .chain((alias) => + addAlias(aliases, alias).either( + (msg) => Future.reject(new Error(msg)), + (next) => + writeShim(alias) + .chain(() => saveAliases(next)) + .map(() => next) + ) + ) + .chain((next) => this.reportCreated(next).map(() => next)); + } + + private resolveNewAlias(presetName?: AliasName, presetTarget?: AliasTarget): Future { + const name = + presetName ? + Future.resolve(presetName) + : Future.attemptP(async () => { + const raw = await p.text({ + message: "Alias name:", + placeholder: "cb", + validate: (value) => + AliasName.parse(value ?? "").either( + (msg) => msg, + () => undefined + ) + }); + if (p.isCancel(raw)) throw cancelled(); + return raw; + }).chain(parseName); + + const target = (chosen?: AliasTarget): Future => + chosen ? + Future.resolve(chosen) + : Future.attemptP(async () => { + const value = await p.select({ + message: "Runs which command?", + options: ALIAS_TARGETS.map((t) => ({ value: t, label: `commit ${t}`, hint: describeTarget(t) })), + initialValue: "generate" as const + }); + if (p.isCancel(value)) throw cancelled(); + return value; + }); + + return name.chain((n) => this.confirmShadowing(n).map(() => n)).chain((n) => target(presetTarget).map((t): Alias => ({ name: n, target: t }))); + } + + /** The bin dir is prepended to PATH, so a name that already resolves elsewhere would be shadowed. */ + private confirmShadowing(name: AliasName): Future { + return findConflictingBinary(name).chain((conflict) => { + if (!(conflict instanceof Just)) return Future.resolve(undefined); + + if (!process.stdout.isTTY) { + p.log.warn(color.yellow(`'${name.value}' already exists at ${conflict.value} — the alias will shadow it.`)); + return Future.resolve(undefined); + } + + return Future.attemptP(async () => { + const ok = await p.confirm({ message: `'${name.value}' already exists at ${conflict.value}. Shadow it?`, initialValue: false }); + if (p.isCancel(ok) || !ok) throw cancelled(); + }); + }); + } + + private reportCreated(aliases: readonly Alias[]): Future { + const created = aliases[aliases.length - 1]; + if (!created) return Future.resolve(undefined); + + p.log.success(`Created ${color.cyan(created.name.value)} -> ${color.dim(`commit ${created.target}`)} (${shimPath(created.name)})`); + return isBinDirOnPath() ? Future.resolve(undefined) : this.offerPathSetup(); + } + + private offerPathSetup(): Future { + const profile = detectProfile(); + + if (!(profile instanceof Just) || !process.stdout.isTTY) { + const shell = profile instanceof Just ? profile.value.shell : "bash"; + p.note(pathExportLine(shell), `Add ${aliasBinDir()} to your PATH`); + return Future.resolve(undefined); + } + + const { file, shell } = profile.value; + + return Future.attemptP(async () => { + const ok = await p.confirm({ message: `Add ${aliasBinDir()} to your PATH in ${file}?`, initialValue: true }); + return !p.isCancel(ok) && ok; + }).chain((ok) => { + if (!ok) { + p.note(pathExportLine(shell), `Add ${aliasBinDir()} to your PATH`); + return Future.resolve(undefined); + } + + return ensureBinDirOnPath(profile.value).map((outcome) => { + if (outcome === "added") p.note(`source ${file}`, "Run this to use the alias in this shell"); + }); + }); + } + + private deleteAlias(aliases: readonly Alias[], preset?: AliasName): Future { + // Only the interactive picker needs something to pick from. A named delete must still + // reach `removeAlias`, so an unknown name fails instead of reporting a silent success. + if (aliases.length === 0 && !preset) { + p.log.info("No custom aliases to delete."); + return Future.resolve(aliases); + } + + return this.resolveNameToDelete(aliases, preset).chain((name) => + removeAlias(aliases, name).either( + (msg) => Future.reject(new Error(msg)), + (next) => + removeShim(name) + .chain(() => saveAliases(next)) + .map(() => { + p.log.success(`Deleted ${color.cyan(name.value)}`); + return next; + }) + ) + ); + } + + private resolveNameToDelete(aliases: readonly Alias[], preset?: AliasName): Future { + if (preset) return Future.resolve(preset); + + return Future.attemptP(async () => { + const value = await p.select({ + message: "Delete which alias?", + options: aliases.map((a) => ({ value: a.name.value, label: a.name.value, hint: `commit ${a.target}` })) + }); + if (p.isCancel(value)) throw cancelled(); + + const confirmed = await p.confirm({ message: `Delete '${value}'?`, initialValue: false }); + if (p.isCancel(confirmed) || !confirmed) throw cancelled(); + + return value; + }).chain(parseName); + } + + private renderTable(aliases: readonly Alias[]): void { + const table = new Table({ + head: [color.cyan("Alias"), color.cyan("Runs"), color.cyan("Source")], + colWidths: [16, 24, 12] + }); + + for (const alias of aliases) { + table.push([alias.name.value, `commit ${alias.target}`, color.green("custom")]); + } + table.push(["commit", "commit generate", color.gray("built-in")]); + + process.stdout.write("\n" + table.toString() + "\n\n"); + } +} diff --git a/src/cli/parser.ts b/src/cli/parser.ts index fda9be6..2e80b31 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -1,10 +1,13 @@ -export { type CliCommand, parseArgs, showHelp, showVersion }; +export { type AliasAction, type CliCommand, parseArgs, showHelp, showVersion }; import * as D from "@/libs/json/decoder"; +import { ALIAS_TARGETS, type AliasTarget } from "@/domain/alias/alias"; import { Result } from "@/libs/result"; import { version as packageVersion } from "@/package.json"; +type AliasAction = { type: "hub" } | { type: "list" } | { type: "add"; name: string; target: AliasTarget } | { type: "remove"; name: string }; + type CliCommand = | { type: "generate" } | { type: "setup" } @@ -12,10 +15,38 @@ type CliCommand = | { type: "model" } | { type: "effort" } | { type: "branch" } + | { type: "alias"; action: AliasAction } | { type: "update" } | { type: "version" } | { type: "help" }; +const isAliasTarget = (value: string): value is AliasTarget => (ALIAS_TARGETS as readonly string[]).includes(value); + +// The name stays a raw string here; AliasCommand turns it into an AliasName so both +// the interactive and the scripted entry points report the same validation message. +const parseAliasAction = (args: string[]): D.Decoder => { + const [sub, name, target] = [args[1], args[2], args[3]]; + + switch (sub) { + case undefined: + return D.succeed({ type: "alias", action: { type: "hub" } }); + case "list": + case "ls": + return D.succeed({ type: "alias", action: { type: "list" } }); + case "add": + case "new": + if (!name || !target) return D.fail(`Usage: commit alias add . Targets: ${ALIAS_TARGETS.join(", ")}`); + if (!isAliasTarget(target)) return D.fail(`Alias target must be one of: ${ALIAS_TARGETS.join(", ")}`); + return D.succeed({ type: "alias", action: { type: "add", name, target } }); + case "remove": + case "rm": + if (!name) return D.fail("Usage: commit alias remove "); + return D.succeed({ type: "alias", action: { type: "remove", name } }); + default: + return D.fail(`Unknown alias subcommand: ${sub}`); + } +}; + const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) => { const cmd = args[0] || "generate"; @@ -34,6 +65,9 @@ const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) case "branch": case "new-branch": return D.succeed({ type: "branch" }); + case "alias": + case "aliases": + return parseAliasAction(args); case "update": return D.succeed({ type: "update" }); case "--version": @@ -62,6 +96,7 @@ Commands: doctor Check installation and environment model Select a different AI model effort Adjust the reasoning effort for the current model + alias Manage extra CLI names (list, add , remove ) update Install the latest version from npm --version, -v Show version --help, -h Show help diff --git a/src/domain/alias/alias.ts b/src/domain/alias/alias.ts new file mode 100644 index 0000000..3d530a6 --- /dev/null +++ b/src/domain/alias/alias.ts @@ -0,0 +1,73 @@ +export { type Alias, type AliasTarget, type StoredAlias, ALIAS_TARGETS, AliasName, addAlias, describeTarget, findAlias, removeAlias, schema_StoredAlias }; + +import * as s from "@/libs/json/schema"; + +import { Failure, Success, type Result } from "@/libs/result"; +import { fromOptional, Just, type Maybe } from "@/libs/maybe"; +import { absurd } from "@/libs/types"; + +const ALIAS_TARGETS = ["generate", "branch", "setup", "doctor", "model", "effort", "update"] as const; +type AliasTarget = (typeof ALIAS_TARGETS)[number]; + +const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/; +const RESERVED = ["commit", "commit-tools"]; + +/** + * An alias name proven safe to use as a filename inside the alias bin dir. + * + * The constructor is private so the only way to obtain one is {@link AliasName.parse}. + * Everything downstream — `shimPath`, `removeShim` — can treat it as proof that the + * value has no path separators, no traversal segments, and is not a reserved name. + */ +class AliasName { + // @ts-expect-error _tag's existence prevents structural comparison + private readonly _tag: null = null; + private constructor(readonly value: string) {} + + static parse(raw: string): Result { + const trimmed = raw.trim(); + if (!NAME_PATTERN.test(trimmed)) return Failure("Use 1-32 characters: letters, digits, '-' or '_', starting with a letter."); + if (RESERVED.includes(trimmed)) return Failure(`'${trimmed}' is reserved by commit-tools.`); + return Success(new AliasName(trimmed)); + } +} + +/** Trusted domain value: name is proven, target is a closed union. */ +type Alias = { readonly name: AliasName; readonly target: AliasTarget }; + +/** + * On-disk representation. Decoding stays total — no throwing inside a schema — and + * `loadAliases` promotes a `StoredAlias` into an {@link Alias} via {@link AliasName.parse}. + */ +const schema_StoredAlias = s.object({ name: s.string, target: s.stringEnum([...ALIAS_TARGETS]) }); +type StoredAlias = s.Infer; + +const findAlias = (aliases: readonly Alias[], name: AliasName): Maybe => fromOptional(aliases.find((a) => a.name.value === name.value)); + +/** Uniqueness is a property of the registry, not of a name — so it is checked here, not in `parse`. */ +const addAlias = (aliases: readonly Alias[], alias: Alias): Result => + findAlias(aliases, alias.name) instanceof Just ? Failure(`Alias '${alias.name.value}' already exists. Delete it first.`) : Success([...aliases, alias]); + +const removeAlias = (aliases: readonly Alias[], name: AliasName): Result => + findAlias(aliases, name) instanceof Just ? Success(aliases.filter((a) => a.name.value !== name.value)) : Failure(`No alias named '${name.value}'.`); + +const describeTarget = (target: AliasTarget): string => { + switch (target) { + case "generate": + return "Generate a commit message"; + case "branch": + return "Suggest branch names and create one"; + case "setup": + return "Configure authentication and conventions"; + case "doctor": + return "Check installation and environment"; + case "model": + return "Select a different AI model"; + case "effort": + return "Adjust the reasoning effort"; + case "update": + return "Install the latest version from npm"; + default: + return absurd(target, "AliasTarget"); + } +}; diff --git a/src/infra/alias/path-setup.ts b/src/infra/alias/path-setup.ts new file mode 100644 index 0000000..516338a --- /dev/null +++ b/src/infra/alias/path-setup.ts @@ -0,0 +1,55 @@ +export { detectProfile, ensureBinDirOnPath, isBinDirOnPath, pathExportLine, type PathSetupOutcome, type ShellProfile }; + +import { Future } from "@/libs/future"; +import { fromOptional, type Maybe } from "@/libs/maybe"; +import { aliasBinDir } from "@/infra/alias/shims"; +import { readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { delimiter, resolve } from "node:path"; + +type Shell = "zsh" | "bash" | "fish"; +type ShellProfile = { readonly shell: Shell; readonly file: string }; +type PathSetupOutcome = "added" | "already-present"; + +const MARKER_START = "# >>> commit-tools >>>"; +const MARKER_END = "# <<< commit-tools <<<"; + +const profileFor = (shell: Shell): string => { + switch (shell) { + case "zsh": + return resolve(homedir(), ".zshrc"); + case "bash": + return resolve(homedir(), ".bashrc"); + case "fish": + return resolve(homedir(), ".config", "fish", "config.fish"); + } +}; + +const shellFromPath = (shellPath: string): Maybe => { + const name = shellPath.split("/").pop() ?? ""; + return fromOptional((["zsh", "bash", "fish"] as const).find((s) => s === name)); +}; + +const detectProfile = (): Maybe => shellFromPath(process.env["SHELL"] ?? "").map((shell) => ({ shell, file: profileFor(shell) })); + +const isBinDirOnPath = (): boolean => (process.env["PATH"] ?? "").split(delimiter).some((dir) => dir && resolve(dir) === aliasBinDir()); + +const pathExportLine = (shell: Shell): string => (shell === "fish" ? `fish_add_path ${aliasBinDir()}` : `export PATH="${aliasBinDir()}:$PATH"`); + +const managedBlock = (shell: Shell): string => ["", MARKER_START, pathExportLine(shell), MARKER_END, ""].join("\n"); + +const readProfile = async (file: string): Promise => + readFile(file, "utf-8").catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return ""; + throw err; + }); + +/** Idempotent: an existing managed block is left untouched. Only ever called after an explicit confirmation. */ +const ensureBinDirOnPath = (profile: ShellProfile): Future => + Future.attemptP(async () => { + const current = await readProfile(profile.file); + if (current.includes(MARKER_START)) return "already-present" as const; + + await writeFile(profile.file, current + managedBlock(profile.shell), "utf-8"); + return "added" as const; + }); diff --git a/src/infra/alias/shims.ts b/src/infra/alias/shims.ts new file mode 100644 index 0000000..875a67a --- /dev/null +++ b/src/infra/alias/shims.ts @@ -0,0 +1,60 @@ +export { aliasBinDir, findConflictingBinary, reconcileShims, removeShim, shimPath, writeShim }; + +import { Future } from "@/libs/future"; +import { fromOptional, type Maybe } from "@/libs/maybe"; +import { type Alias, type AliasName } from "@/domain/alias/alias"; +import { configDir } from "@/infra/storage/config"; +import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { delimiter, resolve } from "node:path"; + +const aliasBinDir = (): string => resolve(configDir(), "bin"); + +/** Safe to join: `AliasName.parse` already excluded separators and traversal segments. */ +const shimPath = (name: AliasName): string => resolve(aliasBinDir(), name.value); + +/** POSIX single-quote escaping. JSON quoting would leave a `$` in the path expandable by the shell. */ +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +/** Absolute node + absolute entry script: no dependency on `commit` being on PATH, and no risk of recursion. */ +const shimSource = (alias: Alias): string => + ["#!/bin/sh", `exec ${shellQuote(process.execPath)} ${shellQuote(process.argv[1] ?? "")} ${alias.target} "$@"`, ""].join("\n"); + +const writeShim = (alias: Alias): Future => + Future.attemptP(async () => { + const path = shimPath(alias.name); + await mkdir(aliasBinDir(), { recursive: true }); + await writeFile(path, shimSource(alias), "utf-8"); + // writeFile's `mode` is ignored when the file already exists, so set it explicitly. + await chmod(path, 0o755); + }); + +const removeShim = (name: AliasName): Future => Future.attemptP(() => rm(shimPath(name), { force: true })); + +/** Rewrites every shim from the registry, so stale node or entry-script paths heal on the next mutation. */ +const reconcileShims = (aliases: readonly Alias[]): Future => Future.parallel(4, aliases.map(writeShim)).map(() => undefined); + +const isExecutable = async (path: string): Promise => + access(path, constants.X_OK).then( + () => true, + () => false + ); + +/** + * First executable named `name` on PATH outside our own bin dir. + * + * The bin dir is prepended to PATH, so an alias named after an existing binary would + * shadow it — the create flow uses this to warn before that happens. + */ +const findConflictingBinary = (name: AliasName): Future> => + Future.attemptP(async () => { + const ownDir = aliasBinDir(); + const dirs = (process.env["PATH"] ?? "").split(delimiter).filter((dir) => dir && resolve(dir) !== ownDir); + + for (const dir of dirs) { + const candidate = resolve(dir, name.value); + if (await isExecutable(candidate)) return candidate; + } + + return undefined; + }).map(fromOptional); diff --git a/src/infra/storage/aliases.ts b/src/infra/storage/aliases.ts new file mode 100644 index 0000000..0e25dce --- /dev/null +++ b/src/infra/storage/aliases.ts @@ -0,0 +1,67 @@ +export { aliasesFile, loadAliases, saveAliases }; + +import * as s from "@/libs/json/schema"; + +import { Future } from "@/libs/future"; +import { Failure, Success, traverse_, type Result } from "@/libs/result"; +import { AliasName, schema_StoredAlias, type Alias, type StoredAlias } from "@/domain/alias/alias"; +import { configDir } from "@/infra/storage/config"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +const AliasFile = s.object({ aliases: s.array(schema_StoredAlias) }); + +const aliasesFile = (): string => resolve(configDir(), "aliases.json"); + +const isMissingFile = (err: unknown): boolean => (err as NodeJS.ErrnoException | null)?.code === "ENOENT"; + +/** Promote stored rows into trusted values: every name is proven and duplicates are rejected. */ +const toDomain = (stored: readonly StoredAlias[]): Result => + traverse_([...stored], (row) => + AliasName.parse(row.name) + .mapFailure((msg) => new Error(`Invalid alias name '${row.name}' in ${aliasesFile()}: ${msg}`)) + .map((name): Alias => ({ name, target: row.target })) + ).chain((aliases) => + new Set(aliases.map((a) => a.name.value)).size === aliases.length ? + Success(aliases) + : Failure(new Error(`Duplicate alias names in ${aliasesFile()}. Fix the file or delete it to start over.`)) + ); + +const parseAliasesJson = (raw: string): Result => { + try { + return Success(JSON.parse(raw)); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + return Failure(new Error(`Aliases file is not valid JSON (${aliasesFile()}): ${detail}. Fix the file or delete it to start over.`)); + } +}; + +const parseAliasFile = (raw: string): Result => + parseAliasesJson(raw) + .chain((json) => s.decode(AliasFile, json).mapFailure((err) => new Error(`Invalid aliases file (${aliasesFile()}): ${err}`))) + .chain((file) => toDomain(file.aliases)); + +/** A missing file is the empty registry; a corrupt one rejects so aliases are never silently dropped. */ +const loadAliases = (): Future => + Future.attemptP(async () => { + try { + return await readFile(aliasesFile(), "utf-8"); + } catch (err) { + if (isMissingFile(err)) return undefined; + throw err; + } + }).chain((raw) => + raw === undefined ? + Future.resolve([]) + : parseAliasFile(raw).either( + (err) => Future.reject(err), + (aliases) => Future.resolve(aliases) + ) + ); + +const saveAliases = (aliases: readonly Alias[]): Future => + Future.attemptP(async () => { + const stored = aliases.map((a): StoredAlias => ({ name: a.name.value, target: a.target })); + await mkdir(configDir(), { recursive: true }); + await writeFile(aliasesFile(), JSON.stringify(s.encode(AliasFile, { aliases: stored }), null, 2), "utf-8"); + }); diff --git a/test/cli/alias.test.ts b/test/cli/alias.test.ts new file mode 100644 index 0000000..42c0e71 --- /dev/null +++ b/test/cli/alias.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Future } from "@/libs/future"; +import { Nothing } from "@/libs/maybe"; + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), + text: vi.fn(), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() } +})); + +vi.mock("@/infra/storage/aliases", () => ({ + loadAliases: vi.fn(), + saveAliases: vi.fn(() => Future.resolve(undefined)) +})); + +vi.mock("@/infra/alias/shims", () => ({ + aliasBinDir: vi.fn(() => "/home/dev/.commit-tools/bin"), + shimPath: vi.fn((name: { value: string }) => `/home/dev/.commit-tools/bin/${name.value}`), + writeShim: vi.fn(() => Future.resolve(undefined)), + removeShim: vi.fn(() => Future.resolve(undefined)), + reconcileShims: vi.fn(() => Future.resolve(undefined)), + findConflictingBinary: vi.fn(() => Future.resolve(Nothing())) +})); + +vi.mock("@/infra/alias/path-setup", () => ({ + detectProfile: vi.fn(() => Nothing()), + ensureBinDirOnPath: vi.fn(() => Future.resolve("added")), + isBinDirOnPath: vi.fn(() => true), + pathExportLine: vi.fn(() => "export PATH=...") +})); + +import { AliasCommand } from "@/cli/alias"; +import { AliasName, type Alias } from "@/domain/alias/alias"; +import { Failure } from "@/libs/result"; +import { loadAliases, saveAliases } from "@/infra/storage/aliases"; +import { reconcileShims, removeShim, writeShim } from "@/infra/alias/shims"; +import { runFuture } from "@test/helpers/run-future"; + +const alias = (raw: string, target: Alias["target"]): Alias => { + const parsed = AliasName.parse(raw); + if (parsed instanceof Failure) throw new Error(`fixture name ${raw} is invalid`); + return { name: parsed.value, target }; +}; + +const withRegistry = (aliases: readonly Alias[]): void => { + vi.mocked(loadAliases).mockReturnValue(Future.resolve(aliases)); +}; + +const run = (action: Parameters[0]) => runFuture(AliasCommand.create(action).chain((c) => c.run())); + +beforeEach(() => { + vi.clearAllMocks(); + withRegistry([]); +}); + +describe("alias list", () => { + it("stays read-only — it writes no shims", async () => { + withRegistry([alias("cb", "branch")]); + await run({ type: "list" }); + + expect(writeShim).not.toHaveBeenCalled(); + expect(reconcileShims).not.toHaveBeenCalled(); + expect(saveAliases).not.toHaveBeenCalled(); + }); +}); + +describe("alias add", () => { + it("writes the shim and persists the registry", async () => { + await run({ type: "add", name: "cb", target: "branch" }); + + expect(vi.mocked(writeShim).mock.calls[0]?.[0]).toMatchObject({ target: "branch" }); + expect(vi.mocked(saveAliases).mock.calls[0]?.[0]).toHaveLength(1); + }); + + it("rejects a duplicate without touching the filesystem", async () => { + withRegistry([alias("cb", "branch")]); + await expect(run({ type: "add", name: "cb", target: "setup" })).rejects.toThrow(/already exists/); + + expect(writeShim).not.toHaveBeenCalled(); + expect(saveAliases).not.toHaveBeenCalled(); + }); + + it("rejects an unsafe name before it reaches a filesystem path", async () => { + await expect(run({ type: "add", name: "../../evil", target: "generate" })).rejects.toThrow(/letters, digits/); + expect(writeShim).not.toHaveBeenCalled(); + }); + + it("rejects a reserved name", async () => { + await expect(run({ type: "add", name: "commit", target: "generate" })).rejects.toThrow(/reserved/); + }); +}); + +describe("alias remove", () => { + it("deletes the shim and persists the registry", async () => { + withRegistry([alias("cb", "branch"), alias("cm", "generate")]); + await run({ type: "remove", name: "cb" }); + + expect(vi.mocked(removeShim).mock.calls[0]?.[0]).toMatchObject({ value: "cb" }); + expect(vi.mocked(saveAliases).mock.calls[0]?.[0]?.map((a) => a.name.value)).toEqual(["cm"]); + }); + + it("fails loudly on an unknown name", async () => { + withRegistry([alias("cb", "branch")]); + await expect(run({ type: "remove", name: "ghost" })).rejects.toThrow(/No alias named 'ghost'/); + + expect(removeShim).not.toHaveBeenCalled(); + expect(saveAliases).not.toHaveBeenCalled(); + }); + + it("fails loudly when the registry is empty rather than reporting nothing to do", async () => { + withRegistry([]); + await expect(run({ type: "remove", name: "cb" })).rejects.toThrow(/No alias named 'cb'/); + }); +}); diff --git a/test/cli/parser.test.ts b/test/cli/parser.test.ts index e40de79..b6a4992 100644 --- a/test/cli/parser.test.ts +++ b/test/cli/parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { parseArgs } from "@/cli/parser"; +import { ALIAS_TARGETS } from "@/domain/alias/alias"; import { Failure, Success } from "@/libs/result"; describe("parseArgs", () => { @@ -13,6 +14,10 @@ describe("parseArgs", () => { [["model"], "model"], [["effort"], "effort"], [["update"], "update"], + [["alias"], "alias"], + [["aliases"], "alias"], + [["alias", "list"], "alias"], + [["alias", "ls"], "alias"], [["-v"], "version"], [["--version"], "version"], [["-h"], "help"], @@ -35,3 +40,34 @@ describe("parseArgs", () => { if (result instanceof Success) expect(result.value.type).toBe("generate"); }); }); + +describe("parseArgs alias", () => { + // A shim passes its bound target straight back as argv[0], so every target must parse as a command. + it.each(ALIAS_TARGETS)("target %s parses as a command", (target) => { + expect(parseArgs([target]).isSuccess()).toBe(true); + }); + + it.each([ + [["alias"], { type: "hub" }], + [["alias", "list"], { type: "list" }], + [["alias", "add", "cb", "branch"], { type: "add", name: "cb", target: "branch" }], + [["alias", "new", "cm", "generate"], { type: "add", name: "cm", target: "generate" }], + [["alias", "remove", "cb"], { type: "remove", name: "cb" }], + [["alias", "rm", "cb"], { type: "remove", name: "cb" }] + ] as const)("maps %j to %j", (argv, action) => { + const result = parseArgs([...argv]); + expect(result.isSuccess()).toBe(true); + if (result instanceof Success && result.value.type === "alias") expect(result.value.action).toEqual(action); + }); + + it.each([ + [["alias", "add", "cb", "nope"], /must be one of/], + [["alias", "add", "cb"], /Usage: commit alias add/], + [["alias", "remove"], /Usage: commit alias remove/], + [["alias", "wat"], /Unknown alias subcommand/] + ] as const)("rejects %j", (argv, message) => { + const result = parseArgs([...argv]); + expect(result.isFailure()).toBe(true); + if (result instanceof Failure) expect(result.error.message).toMatch(message); + }); +}); diff --git a/test/domain/alias/alias.test.ts b/test/domain/alias/alias.test.ts new file mode 100644 index 0000000..7cf7ae5 --- /dev/null +++ b/test/domain/alias/alias.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { ALIAS_TARGETS, AliasName, addAlias, describeTarget, findAlias, removeAlias, type Alias } from "@/domain/alias/alias"; +import { Failure, Success } from "@/libs/result"; +import { Just, Nothing } from "@/libs/maybe"; + +const name = (raw: string): AliasName => { + const parsed = AliasName.parse(raw); + if (parsed instanceof Failure) throw new Error(`fixture name ${raw} is invalid: ${parsed.error}`); + return parsed.value; +}; + +const alias = (raw: string, target: Alias["target"] = "generate"): Alias => ({ name: name(raw), target }); + +describe("AliasName.parse", () => { + it.each([["cb"], ["c"], ["gen-msg"], ["my_alias"], ["A1"], ["a".repeat(32)]])("accepts %s", (raw) => { + expect(AliasName.parse(raw).isSuccess()).toBe(true); + }); + + it("trims surrounding whitespace", () => { + const parsed = AliasName.parse(" cb "); + expect(parsed instanceof Success && parsed.value.value).toBe("cb"); + }); + + it.each([ + ["", "empty"], + ["1cb", "leading digit"], + ["../etc/passwd", "path traversal"], + ["a/b", "path separator"], + ["a b", "space"], + ["a".repeat(33), "too long"], + ["rm -rf", "shell metacharacters"] + ])("rejects %s (%s)", (raw) => { + expect(AliasName.parse(raw).isFailure()).toBe(true); + }); + + it.each([["commit"], ["commit-tools"]])("rejects reserved name %s", (raw) => { + const parsed = AliasName.parse(raw); + expect(parsed instanceof Failure && parsed.error).toContain("reserved"); + }); +}); + +describe("addAlias", () => { + it("appends when the name is free", () => { + const result = addAlias([alias("cb", "branch")], alias("cm")); + expect(result instanceof Success && result.value.map((a) => a.name.value)).toEqual(["cb", "cm"]); + }); + + it("rejects a duplicate instead of overwriting", () => { + const existing = [alias("cb", "branch")]; + const result = addAlias(existing, alias("cb", "setup")); + expect(result instanceof Failure && result.error).toContain("already exists"); + expect(existing[0]?.target).toBe("branch"); + }); +}); + +describe("removeAlias", () => { + it("removes an existing alias", () => { + const result = removeAlias([alias("cb"), alias("cm")], name("cb")); + expect(result instanceof Success && result.value.map((a) => a.name.value)).toEqual(["cm"]); + }); + + it("fails on an unknown name rather than reporting a silent success", () => { + const result = removeAlias([alias("cb")], name("nope")); + expect(result instanceof Failure && result.error).toContain("No alias named 'nope'"); + }); +}); + +describe("findAlias", () => { + it("matches by value, not identity", () => { + expect(findAlias([alias("cb")], name("cb"))).toBeInstanceOf(Just); + expect(findAlias([alias("cb")], name("cm"))).toBeInstanceOf(Nothing); + }); +}); + +describe("describeTarget", () => { + it("describes every target", () => { + for (const target of ALIAS_TARGETS) expect(describeTarget(target).length).toBeGreaterThan(0); + }); +}); diff --git a/test/infra/alias/path-setup.test.ts b/test/infra/alias/path-setup.test.ts new file mode 100644 index 0000000..9ccfc07 --- /dev/null +++ b/test/infra/alias/path-setup.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Guards the real ~/.zshrc: every profile path is resolved against this fake home. +const fakeHome = { path: "" }; +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => fakeHome.path }; +}); + +import { detectProfile, ensureBinDirOnPath, isBinDirOnPath, pathExportLine, type ShellProfile } from "@/infra/alias/path-setup"; +import { aliasBinDir } from "@/infra/alias/shims"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; + +beforeEach(() => { + fakeHome.path = mkdtempSync(join(tmpdir(), "commit-tools-home-")); +}); + +const profile = (shell: ShellProfile["shell"]): ShellProfile => ({ shell, file: join(fakeHome.path, `.${shell}rc`) }); + +describe("detectProfile", () => { + it.each([ + ["/bin/zsh", "zsh"], + ["/usr/local/bin/bash", "bash"], + ["/opt/homebrew/bin/fish", "fish"] + ])("maps %s to %s", (shellPath, shell) => { + process.env["SHELL"] = shellPath; + const detected = detectProfile(); + expect(detected instanceof Just && detected.value.shell).toBe(shell); + }); + + it("is Nothing for an unrecognised shell", () => { + process.env["SHELL"] = "/bin/ksh"; + expect(detectProfile()).toBeInstanceOf(Nothing); + }); +}); + +describe("pathExportLine", () => { + it("uses fish_add_path for fish and an export for posix shells", () => { + expect(pathExportLine("fish")).toBe(`fish_add_path ${aliasBinDir()}`); + expect(pathExportLine("zsh")).toBe(`export PATH="${aliasBinDir()}:$PATH"`); + }); +}); + +describe("isBinDirOnPath", () => { + it("detects the bin dir regardless of trailing separators", () => { + const previous = process.env["PATH"]; + try { + process.env["PATH"] = `/usr/bin:${aliasBinDir()}/`; + expect(isBinDirOnPath()).toBe(true); + process.env["PATH"] = "/usr/bin"; + expect(isBinDirOnPath()).toBe(false); + } finally { + process.env["PATH"] = previous; + } + }); +}); + +describe("ensureBinDirOnPath", () => { + it("creates the profile with a managed block when it does not exist", async () => { + const target = profile("zsh"); + expect(await runFuture(ensureBinDirOnPath(target))).toBe("added"); + + const contents = await readFile(target.file, "utf-8"); + expect(contents).toContain("# >>> commit-tools >>>"); + expect(contents).toContain(pathExportLine("zsh")); + expect(contents).toContain("# <<< commit-tools <<<"); + }); + + it("preserves existing content", async () => { + const target = profile("zsh"); + await writeFile(target.file, "export EDITOR=vim\n", "utf-8"); + await runFuture(ensureBinDirOnPath(target)); + + expect(await readFile(target.file, "utf-8")).toContain("export EDITOR=vim"); + }); + + it("is idempotent — a second run leaves the file byte-identical", async () => { + const target = profile("zsh"); + await runFuture(ensureBinDirOnPath(target)); + const afterFirst = await readFile(target.file, "utf-8"); + + expect(await runFuture(ensureBinDirOnPath(target))).toBe("already-present"); + expect(await readFile(target.file, "utf-8")).toBe(afterFirst); + }); +}); diff --git a/test/infra/alias/shims.test.ts b/test/infra/alias/shims.test.ts new file mode 100644 index 0000000..385ec6b --- /dev/null +++ b/test/infra/alias/shims.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { readFile, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { aliasBinDir, findConflictingBinary, reconcileShims, removeShim, shimPath, writeShim } from "@/infra/alias/shims"; +import { AliasName, type Alias } from "@/domain/alias/alias"; +import { Failure } from "@/libs/result"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; + +const name = (raw: string): AliasName => { + const parsed = AliasName.parse(raw); + if (parsed instanceof Failure) throw new Error(`fixture name ${raw} is invalid`); + return parsed.value; +}; + +const alias = (raw: string, target: Alias["target"] = "generate"): Alias => ({ name: name(raw), target }); + +const originalEntry = process.argv[1] ?? ""; + +afterEach(() => { + process.argv[1] = originalEntry; +}); + +describe("writeShim", () => { + it("creates an executable shim that runs the bound target", async () => { + await runFuture(writeShim(alias("cb", "branch"))); + + const path = shimPath(name("cb")); + const contents = await readFile(path, "utf-8"); + + expect(contents.startsWith("#!/bin/sh\n")).toBe(true); + expect(contents).toContain(process.execPath); + expect(contents).toContain(' branch "$@"'); + expect((await stat(path)).mode & 0o777).toBe(0o755); + }); + + it("quotes an entry path containing a quote or a dollar sign", async () => { + process.argv[1] = "/tmp/we'ird/$path/index.js"; + await runFuture(writeShim(alias("cb"))); + + const contents = await readFile(shimPath(name("cb")), "utf-8"); + expect(contents).toContain("'/tmp/we'\\''ird/$path/index.js'"); + }); + + it("resets the mode when overwriting an existing shim", async () => { + await runFuture(writeShim(alias("cb"))); + const { chmod } = await import("node:fs/promises"); + await chmod(shimPath(name("cb")), 0o600); + + await runFuture(writeShim(alias("cb"))); + expect((await stat(shimPath(name("cb")))).mode & 0o777).toBe(0o755); + }); +}); + +describe("removeShim", () => { + it("deletes the shim", async () => { + await runFuture(writeShim(alias("cb"))); + await runFuture(removeShim(name("cb"))); + expect(existsSync(shimPath(name("cb")))).toBe(false); + }); + + it("is a no-op when the shim is absent", async () => { + await expect(runFuture(removeShim(name("ghost")))).resolves.toBeUndefined(); + }); +}); + +describe("reconcileShims", () => { + it("rewrites every registered shim", async () => { + await runFuture(reconcileShims([alias("cb", "branch"), alias("cm", "generate")])); + + expect(await readFile(shimPath(name("cb")), "utf-8")).toContain(' branch "$@"'); + expect(await readFile(shimPath(name("cm")), "utf-8")).toContain(' generate "$@"'); + }); +}); + +describe("findConflictingBinary", () => { + it("finds an executable already on PATH", async () => { + expect(await runFuture(findConflictingBinary(name("sh")))).toBeInstanceOf(Just); + }); + + it("ignores our own bin dir so an existing alias is not reported as a conflict", async () => { + await runFuture(writeShim(alias("cb"))); + + const previous = process.env["PATH"]; + process.env["PATH"] = aliasBinDir(); + try { + expect(await runFuture(findConflictingBinary(name("cb")))).toBeInstanceOf(Nothing); + } finally { + process.env["PATH"] = previous; + } + }); +}); diff --git a/test/infra/storage/aliases.test.ts b/test/infra/storage/aliases.test.ts new file mode 100644 index 0000000..1c453ce --- /dev/null +++ b/test/infra/storage/aliases.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { aliasesFile, loadAliases, saveAliases } from "@/infra/storage/aliases"; +import { AliasName, type Alias } from "@/domain/alias/alias"; +import { Failure } from "@/libs/result"; +import { runFuture } from "@test/helpers/run-future"; + +const alias = (raw: string, target: Alias["target"]): Alias => { + const parsed = AliasName.parse(raw); + if (parsed instanceof Failure) throw new Error(`fixture name ${raw} is invalid`); + return { name: parsed.value, target }; +}; + +const writeRaw = async (contents: string): Promise => { + await mkdir(dirname(aliasesFile()), { recursive: true }); + await writeFile(aliasesFile(), contents, "utf-8"); +}; + +describe("alias storage", () => { + it("treats a missing file as the empty registry", async () => { + expect(await runFuture(loadAliases())).toEqual([]); + }); + + it("round-trips aliases through disk", async () => { + await runFuture(saveAliases([alias("cb", "branch"), alias("cm", "generate")])); + const loaded = await runFuture(loadAliases()); + + expect(loaded.map((a) => [a.name.value, a.target])).toEqual([ + ["cb", "branch"], + ["cm", "generate"] + ]); + }); + + it("writes a readable object shape", async () => { + await runFuture(saveAliases([alias("cb", "branch")])); + const { readFile } = await import("node:fs/promises"); + expect(JSON.parse(await readFile(aliasesFile(), "utf-8"))).toEqual({ aliases: [{ name: "cb", target: "branch" }] }); + }); + + it("rejects invalid JSON with a path-bearing message", async () => { + await writeRaw("{ invalid"); + await expect(runFuture(loadAliases())).rejects.toThrow(/not valid JSON/); + await expect(runFuture(loadAliases())).rejects.toThrow(aliasesFile()); + }); + + it("rejects an unknown target", async () => { + await writeRaw(JSON.stringify({ aliases: [{ name: "cb", target: "nope" }] })); + await expect(runFuture(loadAliases())).rejects.toThrow(/Invalid aliases file/); + }); + + it("rejects a stored name that would escape the bin dir", async () => { + await writeRaw(JSON.stringify({ aliases: [{ name: "../../evil", target: "generate" }] })); + await expect(runFuture(loadAliases())).rejects.toThrow(/Invalid alias name/); + }); + + it("rejects duplicate names instead of silently keeping one", async () => { + await writeRaw( + JSON.stringify({ + aliases: [ + { name: "cb", target: "branch" }, + { name: "cb", target: "setup" } + ] + }) + ); + await expect(runFuture(loadAliases())).rejects.toThrow(/Duplicate alias names/); + }); +});