Skip to content

Commit a22bdfe

Browse files
authored
fix(cli): reject invalid/unknown-flag positionals in hand-rolled arg parsers (#222)
The CLI now rejects a mistyped/unknown flag instead of silently turning it into a project name, hostname, or scaffolded filename. ## The bug Three hand-rolled arg parsers captured an invalid leading-dash token as a positional instead of erroring — unlike every `parseArgs({strict})` parser and the sibling branches that already guard: - `parseProjectArgs` (`status`/`link`/`rollback`/`cancel`): `void project status --foo` returned `name: '--foo'`, a bogus slug then sent to the platform — even though the sibling `delete` branch already rejects unknown flags. - `parseDomainArgs`: an unknown flag was captured as the hostname (or silently dropped). - `parseMiddlewareArgs` (`void gen`): any name was accepted, so `void gen middleware --foo` scaffolded a file literally named `001.--foo.ts` — while the sibling gen parsers (model/migration/cron/queue) all validate the name. ## Expected-correct behavior A leading-dash / invalid token is not a valid positional → reject it, matching the contract the rest of the CLI already enforces. ## Minimal repro `parseProjectArgs(['status', '--foo'])` returned `{ subcommand: 'status', name: '--foo' }` (should throw `project: Unknown flag '--foo'.`). ## The fix Add the leading-dash guard the `delete` branch already uses to the four project branches and the domain parser loop, and give `parseMiddlewareArgs` the same name regex (`/^[a-z_][a-z0-9_-]*$/`) its gen siblings already use. No legitimate positional (project slug, `dpl_` id, DNS hostname, middleware name) starts with `-`. ## Tests Regression tests added (`project-args.test.ts`, new `domain-args.test.ts`, `gen-args.test.ts`): 6 unknown-flag rejections that fail on `origin/main` and pass after, plus happy-path/hyphenated-name guards. Local gate: the three arg test files 63/63; full `--project unit` zero-collateral vs a clean `origin/main` baseline (identical pre-existing adapter/init-migration failures, unrelated); `packages/void` typecheck + lint + fmt clean. I enumerated every CLI parser to confirm these were the only three hand-rolled ones still swallowing invalid positionals (the rest use strict `parseArgs`, a subcommand allowlist, or a name regex), so the bug class is fully covered. Prepared with AI assistance.
1 parent bf7f5d9 commit a22bdfe

6 files changed

Lines changed: 102 additions & 4 deletions

File tree

‎packages/void/src/cli/domain-args.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export function parseDomainArgs(args: Array<string>): DomainArgs {
3232
projectSlug = rest[++i];
3333
} else if (rest[i] === '--verbose') {
3434
verbose = true;
35+
} else if (rest[i].startsWith('-')) {
36+
throw new Error(`domain: Unknown flag '${rest[i]}'.`);
3537
} else if (!hostname) {
3638
hostname = rest[i];
3739
}

‎packages/void/src/cli/gen-args.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,16 @@ const COLUMN_TYPES = new Set(['string', 'text', 'datetime', 'integer', 'boolean'
5757

5858
const VALID_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']);
5959

60+
// Generated-file name validators (hoisted once); *_HYPHENS variant also allows hyphens (cron/queue/middleware).
61+
const NAME_RE = /^[a-z_][a-z0-9_]*$/;
62+
const NAME_WITH_HYPHENS_RE = /^[a-z_][a-z0-9_-]*$/;
63+
6064
function parseModelArgs(args: Array<string>): GenArgs {
6165
const [name, ...rest] = args;
6266
if (!name) {
6367
throw new Error('gen: Usage: void gen model <name> [columns...].');
6468
}
65-
if (!/^[a-z_][a-z0-9_]*$/.test(name)) {
69+
if (!NAME_RE.test(name)) {
6670
throw new Error(
6771
'gen: Model name must be lowercase alphanumeric with underscores and no leading digits.',
6872
);
@@ -94,7 +98,7 @@ function parseMigrationArgs(args: Array<string>): GenArgs {
9498
if (extra) {
9599
throw new Error(`gen: Unexpected argument '${extra}'.`);
96100
}
97-
if (!/^[a-z_][a-z0-9_]*$/.test(name)) {
101+
if (!NAME_RE.test(name)) {
98102
throw new Error(
99103
'gen: Migration name must be lowercase alphanumeric with underscores and no leading digits.',
100104
);
@@ -145,6 +149,11 @@ function parseMiddlewareArgs(args: Array<string>): GenArgs {
145149
if (extra) {
146150
throw new Error(`gen: Unexpected argument '${extra}'.`);
147151
}
152+
if (!NAME_WITH_HYPHENS_RE.test(name)) {
153+
throw new Error(
154+
'gen: Middleware name must be lowercase alphanumeric with underscores or hyphens and no leading digits.',
155+
);
156+
}
148157
return { subcommand: 'middleware', name };
149158
}
150159

@@ -181,7 +190,7 @@ function parseCronArgs(args: Array<string>): GenArgs {
181190
if (extra) {
182191
throw new Error(`gen: Unexpected argument '${extra}'.`);
183192
}
184-
if (!/^[a-z_][a-z0-9_-]*$/.test(name)) {
193+
if (!NAME_WITH_HYPHENS_RE.test(name)) {
185194
throw new Error(
186195
'gen: Cron name must be lowercase alphanumeric with underscores or hyphens and no leading digits.',
187196
);
@@ -197,7 +206,7 @@ function parseQueueArgs(args: Array<string>): GenArgs {
197206
if (extra) {
198207
throw new Error(`gen: Unexpected argument '${extra}'.`);
199208
}
200-
if (!/^[a-z_][a-z0-9_-]*$/.test(name)) {
209+
if (!NAME_WITH_HYPHENS_RE.test(name)) {
201210
throw new Error(
202211
'gen: Queue name must be lowercase alphanumeric with underscores or hyphens and no leading digits.',
203212
);

‎packages/void/src/cli/project-args.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export function parseProjectArgs(args: Array<string>): ProjectArgs {
3838

3939
if (subcommand === 'status') {
4040
const [name, extra] = rest;
41+
if (name?.startsWith('-')) {
42+
throw new Error(`project: Unknown flag '${name}'.`);
43+
}
4144
if (extra) {
4245
throw new Error(`project: Unexpected argument '${extra}'.`);
4346
}
@@ -46,6 +49,9 @@ export function parseProjectArgs(args: Array<string>): ProjectArgs {
4649

4750
if (subcommand === 'link') {
4851
const [name, extra] = rest;
52+
if (name?.startsWith('-')) {
53+
throw new Error(`project: Unknown flag '${name}'.`);
54+
}
4955
if (extra) {
5056
throw new Error(`project: Unexpected argument '${extra}'.`);
5157
}
@@ -88,6 +94,9 @@ export function parseProjectArgs(args: Array<string>): ProjectArgs {
8894

8995
if (subcommand === 'rollback') {
9096
const [deployId, extra] = rest;
97+
if (deployId?.startsWith('-')) {
98+
throw new Error(`project: Unknown flag '${deployId}'.`);
99+
}
91100
if (extra) {
92101
throw new Error(`project: Unexpected argument '${extra}'.`);
93102
}
@@ -96,6 +105,9 @@ export function parseProjectArgs(args: Array<string>): ProjectArgs {
96105

97106
if (subcommand === 'cancel') {
98107
const [deployId, extra] = rest;
108+
if (deployId?.startsWith('-')) {
109+
throw new Error(`project: Unknown flag '${deployId}'.`);
110+
}
99111
if (extra) {
100112
throw new Error(`project: Unexpected argument '${extra}'.`);
101113
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vite-plus/test';
2+
import { parseDomainArgs } from '../../src/cli/domain-args.ts';
3+
4+
describe('parseDomainArgs', () => {
5+
it('parses a hostname positional', () => {
6+
expect(parseDomainArgs(['add', 'example.com'])).toEqual({
7+
subcommand: 'add',
8+
hostname: 'example.com',
9+
projectSlug: undefined,
10+
verbose: false,
11+
});
12+
});
13+
14+
it('parses --project and --verbose', () => {
15+
expect(parseDomainArgs(['status', 'example.com', '--project', 'my-app', '--verbose'])).toEqual({
16+
subcommand: 'status',
17+
hostname: 'example.com',
18+
projectSlug: 'my-app',
19+
verbose: true,
20+
});
21+
});
22+
23+
it('rejects an unknown flag instead of capturing it as the hostname', () => {
24+
expect(() => parseDomainArgs(['add', '--foo', 'example.com'])).toThrow(
25+
"domain: Unknown flag '--foo'.",
26+
);
27+
});
28+
29+
it('rejects an unknown flag even when a hostname is already present', () => {
30+
expect(() => parseDomainArgs(['status', 'example.com', '--foo'])).toThrow(
31+
"domain: Unknown flag '--foo'.",
32+
);
33+
});
34+
});

‎packages/void/test/unit/gen-args.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@ describe('parseGenArgs', () => {
174174
"gen: Unexpected argument 'extra'.",
175175
);
176176
});
177+
178+
it('rejects an invalid name (e.g. a flag) instead of scaffolding a bogus file', () => {
179+
expect(() => parseGenArgs(['middleware', '--foo'])).toThrow(
180+
'gen: Middleware name must be lowercase alphanumeric with underscores or hyphens and no leading digits.',
181+
);
182+
});
183+
184+
it('allows hyphenated middleware names', () => {
185+
expect(parseGenArgs(['middleware', 'rate-limit'])).toEqual({
186+
subcommand: 'middleware',
187+
name: 'rate-limit',
188+
});
189+
});
177190
});
178191

179192
describe('ssr', () => {

‎packages/void/test/unit/project-args.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,32 @@ describe('parseProjectArgs', () => {
101101
);
102102
});
103103
});
104+
105+
describe('unknown-flag rejection', () => {
106+
// status/link/rollback/cancel previously captured a leading flag as the positional id.
107+
it('rejects an unknown flag for status instead of capturing it as the name', () => {
108+
expect(() => parseProjectArgs(['status', '--foo'])).toThrow("project: Unknown flag '--foo'.");
109+
});
110+
111+
it('rejects an unknown flag for link', () => {
112+
expect(() => parseProjectArgs(['link', '--foo'])).toThrow("project: Unknown flag '--foo'.");
113+
});
114+
115+
it('rejects an unknown flag for rollback', () => {
116+
expect(() => parseProjectArgs(['rollback', '--foo'])).toThrow(
117+
"project: Unknown flag '--foo'.",
118+
);
119+
});
120+
121+
it('rejects an unknown flag for cancel', () => {
122+
expect(() => parseProjectArgs(['cancel', '--foo'])).toThrow("project: Unknown flag '--foo'.");
123+
});
124+
125+
it('still accepts a valid positional id', () => {
126+
expect(parseProjectArgs(['status', 'my-app'])).toEqual({
127+
subcommand: 'status',
128+
name: 'my-app',
129+
});
130+
});
131+
});
104132
});

0 commit comments

Comments
 (0)