Files
software-mansion__argent/packages/argent-cli/test/parse-link-flags.test.ts
filip131311 26405e68c7 refactor(cli): one declarative option parser for every hand-written subcommand (#953)
Follows #952 (merged); rebased onto main.

## Why

`argent-cli` had **seven** copies of the same argv loop — `server
start`, `link`/`unlink`, `flow run`, `lens`, `config`,
`enable`/`disable`, and (from #952) `telemetry` — each hand-rolling `--x
value` / `--x=value` / `-x` / unknown-flag / missing-value handling with
slightly different messages and gaps (e.g. `lens` silently ignored
unknown flags). Adding an option meant another `if (tok === "--x") …
else if (tok.startsWith("--x="))` pair.

## What

`packages/argent-cli/src/command-args.ts` (introduced in #952) now
covers the whole subcommand surface, and every command declares its
options as a spec:

```ts
const START_OPTIONS = {
  help: { kind: "boolean", alias: "h" },
  port: { kind: "value", alias: "p" },
  …
} as const satisfies OptionSpecs;
```

Parser features: `--name value`, `--name=value`, single-letter aliases,
boolean switches, `--` end-of-options, `choices` validation, "don't
swallow a following flag as a value", and the guard against a stray
`true`/`false` word after a switch (previously duplicated in `flow` and
`lens`). Value *semantics* (port ranges, host wildcards, flag-name
charset, link targets) stay in each command — the parser only shapes
argv.

`flag-parser.ts` is untouched: it is the schema-driven `argent run
<tool>` payload builder (JSON Schema → tool args, `--args`/stdin
hatches), a different job.

## Behavior changes (bad-input paths only)

| Command | Before | After |
|---|---|---|
| `lens --bogus` / `lens extra` | silently ignored | `lens: Unknown
flag: --bogus`, exit 2 |
| `lens --terminal x` | `--terminal expects "iterm" or "terminal", got
"x"` | `--terminal must be "iterm" or "terminal", got "x"` |
| `flow run --platfrom=ios` | `unknown flag --platfrom=ios` | `Unknown
flag: --platfrom=ios` |
| `unlink foo` | `Unknown flag: foo` | `Unexpected argument "foo"` |
| `config … --scope x` | `--scope must be "global" or "project", got
"x"` | unchanged wording |
| `enable/disable`, `server start`, `link` | — | unchanged wording
(tests pinned) |

Happy paths are byte-identical; `server`/`link` still throw
`StartFlagError`, `flow` still throws `FlagParseException` (wrapped from
`UsageError`), so callers and tests keep their contracts.

## Tests

`argent-cli` 489/489 (26 files): rewritten `command-args.test.ts`
(aliases, `-` positional, empty-token value, true/false guard, choices
message), pinned-string updates only where the table above says the
wording changed (`flow.test.ts` ×4, `parse-link-flags.test.ts` ×1). `tsc
--build`, `typecheck:tests`, eslint, prettier, knip green. Smoke-tested
every command's error path through the built
`packages/argent/dist/cli.js` (see the "Behavior changes" table — all
exit 2 with usage where the command prints one).

## Docs

No docs update needed: the pages document flags and their meaning, not
error strings; no flag was added, removed or renamed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BkkAihA8Kp26hHS4uhkNGu
2026-08-25 13:32:36 +02:00

198 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { parseLinkFlags, parseUnlinkFlags } from "../src/link.js";
import { StartFlagError } from "../src/server.js";
describe("parseLinkFlags — defaults", () => {
it("returns documented defaults for an empty argv", () => {
expect(parseLinkFlags([])).toEqual({
host: null,
port: null,
token: null,
url: null,
yes: false,
noVerify: false,
help: false,
});
});
});
describe("parseLinkFlags — flag forms", () => {
it("parses --host in space and equals form", () => {
expect(parseLinkFlags(["--host", "10.0.0.42"]).host).toBe("10.0.0.42");
expect(parseLinkFlags(["--host=10.0.0.42"]).host).toBe("10.0.0.42");
});
it("parses --port/-p in space and equals form", () => {
expect(parseLinkFlags(["--port", "4000"]).port).toBe(4000);
expect(parseLinkFlags(["-p", "4000"]).port).toBe(4000);
expect(parseLinkFlags(["--port=4000"]).port).toBe(4000);
});
it("parses boolean flags --yes/-y, --no-verify, --help/-h", () => {
expect(parseLinkFlags(["--yes"]).yes).toBe(true);
expect(parseLinkFlags(["-y"]).yes).toBe(true);
expect(parseLinkFlags(["--no-verify"]).noVerify).toBe(true);
expect(parseLinkFlags(["--help"]).help).toBe(true);
expect(parseLinkFlags(["-h"]).help).toBe(true);
});
it("combines multiple flags in a single invocation", () => {
const flags = parseLinkFlags(["--host", "10.0.0.42", "--port=4567", "--yes", "--no-verify"]);
expect(flags).toEqual({
host: "10.0.0.42",
port: 4567,
token: null,
url: null,
yes: true,
noVerify: true,
help: false,
});
});
it("parses --token in space and equals form", () => {
expect(parseLinkFlags(["--token", "ab12cd"]).token).toBe("ab12cd");
expect(parseLinkFlags(["--token=ab12cd"]).token).toBe("ab12cd");
});
it("parses an argent:// connection string into host, port, token, and url", () => {
const flags = parseLinkFlags(["argent://tok_abc@10.0.0.42:3001"]);
expect(flags.host).toBe("10.0.0.42");
expect(flags.port).toBe(3001);
expect(flags.token).toBe("tok_abc");
expect(flags.url).toBe("http://10.0.0.42:3001");
});
it("parses a full https:// URL into url + derived host/port", () => {
const flags = parseLinkFlags(["https://argent.example.com"]);
expect(flags.url).toBe("https://argent.example.com");
expect(flags.host).toBe("argent.example.com");
expect(flags.port).toBe(443);
});
it("preserves an explicit port and path on a full URL, and reads a userinfo token", () => {
const flags = parseLinkFlags(["https://tok_xyz@proxy.example.com:8443/argent"]);
expect(flags.url).toBe("https://proxy.example.com:8443/argent");
expect(flags.host).toBe("proxy.example.com");
expect(flags.port).toBe(8443);
expect(flags.token).toBe("tok_xyz");
});
it("parses an argent:// connection string without a token", () => {
const flags = parseLinkFlags(["argent://10.0.0.42:3001"]);
expect(flags.host).toBe("10.0.0.42");
expect(flags.port).toBe(3001);
expect(flags.token).toBeNull();
});
it("rejects an argent:// string whose host is a wildcard bind address", () => {
expect(() => parseLinkFlags(["argent://0.0.0.0:3001"])).toThrow(StartFlagError);
});
it("rejects a non-argent positional argument", () => {
expect(() => parseLinkFlags(["10.0.0.42"])).toThrow(StartFlagError);
});
});
describe("parseLinkFlags — error paths", () => {
it("throws StartFlagError on unknown flags", () => {
expect(() => parseLinkFlags(["--bogus"])).toThrow(StartFlagError);
expect(() => parseLinkFlags(["--bogus"])).toThrow(/Unknown flag: --bogus/);
});
it("throws when a value-taking flag is missing its value", () => {
expect(() => parseLinkFlags(["--host"])).toThrow(/--host requires a value/);
expect(() => parseLinkFlags(["--port"])).toThrow(/--port requires a value/);
expect(() => parseLinkFlags(["-p"])).toThrow(/--port requires a value/);
});
it("does not consume the next token as a value for boolean flags", () => {
// `--yes` must NOT swallow the next token; here `--no-verify` is a separate flag.
expect(parseLinkFlags(["--yes", "--no-verify"])).toMatchObject({
yes: true,
noVerify: true,
});
});
});
describe("parseLinkFlags — host validation (wildcard rejection)", () => {
// The four documented wildcards: empty, 0.0.0.0, ::, ::0.
// These are bind addresses, not connect targets — link() refuses them so
// users don't persist a target they can't reach.
it.each([["0.0.0.0"], ["::"], ["::0"], [""]])("rejects wildcard host %p", (wildcard) => {
expect(() => parseLinkFlags(["--host", wildcard])).toThrow(StartFlagError);
expect(() => parseLinkFlags(["--host", wildcard])).toThrow(
/bind address, not a connect address/
);
});
it("rejects wildcards in --host=value form too", () => {
expect(() => parseLinkFlags(["--host=0.0.0.0"])).toThrow(StartFlagError);
expect(() => parseLinkFlags(["--host=::"])).toThrow(StartFlagError);
});
it("trims whitespace before validating (so ' 0.0.0.0 ' is still rejected)", () => {
expect(() => parseLinkFlags(["--host", " 0.0.0.0 "])).toThrow(
/bind address, not a connect address/
);
});
it("trims whitespace from valid hosts and stores the trimmed value", () => {
expect(parseLinkFlags(["--host", " 10.0.0.42 "]).host).toBe("10.0.0.42");
});
it("accepts normal loopback and routable hosts", () => {
expect(parseLinkFlags(["--host", "127.0.0.1"]).host).toBe("127.0.0.1");
expect(parseLinkFlags(["--host", "localhost"]).host).toBe("localhost");
expect(parseLinkFlags(["--host", "::1"]).host).toBe("::1");
expect(parseLinkFlags(["--host", "tools.example.com"]).host).toBe("tools.example.com");
expect(parseLinkFlags(["--host", "10.0.0.42"]).host).toBe("10.0.0.42");
});
});
describe("parseLinkFlags — port validation (connect-target rules)", () => {
// validateConnectPort intentionally diverges from parseStartFlags' parsePort:
// `server start --port 0` means "pick a free port", but you can never
// *connect* to port 0, so `link --port 0` must be rejected.
it("rejects port 0 (the bind-time 'pick a free port' sentinel)", () => {
expect(() => parseLinkFlags(["--port", "0"])).toThrow(StartFlagError);
expect(() => parseLinkFlags(["--port", "0"])).toThrow(/1\.\.65535/);
expect(() => parseLinkFlags(["--port=0"])).toThrow(StartFlagError);
});
it("accepts 1..65535", () => {
expect(parseLinkFlags(["--port", "1"]).port).toBe(1);
expect(parseLinkFlags(["--port", "3001"]).port).toBe(3001);
expect(parseLinkFlags(["--port", "65535"]).port).toBe(65535);
});
it("rejects out-of-range values (inherited from parsePort)", () => {
expect(() => parseLinkFlags(["--port", "65536"])).toThrow(/0\.\.65535/);
expect(() => parseLinkFlags(["--port", "-1"])).toThrow(StartFlagError);
});
it("rejects garbage values that parseInt would silently truncate", () => {
expect(() => parseLinkFlags(["--port", "123abc"])).toThrow(/got "123abc"/);
expect(() => parseLinkFlags(["--port", "3.14"])).toThrow(StartFlagError);
});
});
describe("parseUnlinkFlags", () => {
it("returns documented defaults for an empty argv", () => {
expect(parseUnlinkFlags([])).toEqual({ yes: false, help: false });
});
it("parses --yes/-y and --help/-h", () => {
expect(parseUnlinkFlags(["--yes"]).yes).toBe(true);
expect(parseUnlinkFlags(["-y"]).yes).toBe(true);
expect(parseUnlinkFlags(["--help"]).help).toBe(true);
expect(parseUnlinkFlags(["-h"]).help).toBe(true);
});
it("throws StartFlagError on unknown flags (no positional args allowed)", () => {
expect(() => parseUnlinkFlags(["--bogus"])).toThrow(StartFlagError);
expect(() => parseUnlinkFlags(["--bogus"])).toThrow(/Unknown flag: --bogus/);
// Unlink takes no positional args either — anything not --yes/--help fails.
expect(() => parseUnlinkFlags(["foo"])).toThrow(/Unexpected argument "foo"/);
});
});