From 17a111377658187fb4ffe9deedafafb8cea97d67 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Thu, 18 Jun 2026 20:23:31 +0800 Subject: [PATCH 1/2] fix(cli): reject invalid/unknown-flag positionals in hand-rolled arg parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three hand-rolled CLI parsers swallowed an unknown flag (or other invalid token) as a positional instead of erroring, unlike the parseArgs(strict) parsers and the sibling branches that already guard: - parseProjectArgs status/link/rollback/cancel captured a leading flag as the project name / deployment id (`void project status --foo` returned name='--foo', a bogus slug sent to the platform), though the `delete` branch already rejects unknown flags. - parseDomainArgs captured an unknown flag as the hostname or dropped it. - parseMiddlewareArgs (gen) accepted any name, so `void gen middleware --foo` scaffolded a file literally named `001.--foo.ts` — the sibling gen parsers (model/migration/cron/queue) all validate the name and reject it. Add the leading-dash guard the `delete` branch uses to the four project branches and the domain parser loop, and give parseMiddlewareArgs the same name validation its gen siblings already use. --- packages/void/src/cli/domain-args.ts | 2 ++ packages/void/src/cli/gen-args.ts | 5 +++ packages/void/src/cli/project-args.ts | 12 +++++++ packages/void/test/unit/domain-args.test.ts | 34 ++++++++++++++++++++ packages/void/test/unit/gen-args.test.ts | 13 ++++++++ packages/void/test/unit/project-args.test.ts | 29 +++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 packages/void/test/unit/domain-args.test.ts diff --git a/packages/void/src/cli/domain-args.ts b/packages/void/src/cli/domain-args.ts index ab90c3241..c33bc8732 100644 --- a/packages/void/src/cli/domain-args.ts +++ b/packages/void/src/cli/domain-args.ts @@ -32,6 +32,8 @@ export function parseDomainArgs(args: Array): DomainArgs { projectSlug = rest[++i]; } else if (rest[i] === '--verbose') { verbose = true; + } else if (rest[i].startsWith('-')) { + throw new Error(`domain: Unknown flag '${rest[i]}'.`); } else if (!hostname) { hostname = rest[i]; } diff --git a/packages/void/src/cli/gen-args.ts b/packages/void/src/cli/gen-args.ts index 0879289dd..b4fd92113 100644 --- a/packages/void/src/cli/gen-args.ts +++ b/packages/void/src/cli/gen-args.ts @@ -145,6 +145,11 @@ function parseMiddlewareArgs(args: Array): GenArgs { if (extra) { throw new Error(`gen: Unexpected argument '${extra}'.`); } + if (!/^[a-z_][a-z0-9_-]*$/.test(name)) { + throw new Error( + 'gen: Middleware name must be lowercase alphanumeric with underscores or hyphens and no leading digits.', + ); + } return { subcommand: 'middleware', name }; } diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index 4336e2bf6..3688b1753 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -38,6 +38,9 @@ export function parseProjectArgs(args: Array): ProjectArgs { if (subcommand === 'status') { const [name, extra] = rest; + if (name?.startsWith('-')) { + throw new Error(`project: Unknown flag '${name}'.`); + } if (extra) { throw new Error(`project: Unexpected argument '${extra}'.`); } @@ -46,6 +49,9 @@ export function parseProjectArgs(args: Array): ProjectArgs { if (subcommand === 'link') { const [name, extra] = rest; + if (name?.startsWith('-')) { + throw new Error(`project: Unknown flag '${name}'.`); + } if (extra) { throw new Error(`project: Unexpected argument '${extra}'.`); } @@ -88,6 +94,9 @@ export function parseProjectArgs(args: Array): ProjectArgs { if (subcommand === 'rollback') { const [deployId, extra] = rest; + if (deployId?.startsWith('-')) { + throw new Error(`project: Unknown flag '${deployId}'.`); + } if (extra) { throw new Error(`project: Unexpected argument '${extra}'.`); } @@ -96,6 +105,9 @@ export function parseProjectArgs(args: Array): ProjectArgs { if (subcommand === 'cancel') { const [deployId, extra] = rest; + if (deployId?.startsWith('-')) { + throw new Error(`project: Unknown flag '${deployId}'.`); + } if (extra) { throw new Error(`project: Unexpected argument '${extra}'.`); } diff --git a/packages/void/test/unit/domain-args.test.ts b/packages/void/test/unit/domain-args.test.ts new file mode 100644 index 000000000..331f8c5f1 --- /dev/null +++ b/packages/void/test/unit/domain-args.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseDomainArgs } from '../../src/cli/domain-args.ts'; + +describe('parseDomainArgs', () => { + it('parses a hostname positional', () => { + expect(parseDomainArgs(['add', 'example.com'])).toEqual({ + subcommand: 'add', + hostname: 'example.com', + projectSlug: undefined, + verbose: false, + }); + }); + + it('parses --project and --verbose', () => { + expect(parseDomainArgs(['status', 'example.com', '--project', 'my-app', '--verbose'])).toEqual({ + subcommand: 'status', + hostname: 'example.com', + projectSlug: 'my-app', + verbose: true, + }); + }); + + it('rejects an unknown flag instead of capturing it as the hostname', () => { + expect(() => parseDomainArgs(['add', '--foo', 'example.com'])).toThrow( + "domain: Unknown flag '--foo'.", + ); + }); + + it('rejects an unknown flag even when a hostname is already present', () => { + expect(() => parseDomainArgs(['status', 'example.com', '--foo'])).toThrow( + "domain: Unknown flag '--foo'.", + ); + }); +}); diff --git a/packages/void/test/unit/gen-args.test.ts b/packages/void/test/unit/gen-args.test.ts index c6ee0b31d..4bf75fd50 100644 --- a/packages/void/test/unit/gen-args.test.ts +++ b/packages/void/test/unit/gen-args.test.ts @@ -174,6 +174,19 @@ describe('parseGenArgs', () => { "gen: Unexpected argument 'extra'.", ); }); + + it('rejects an invalid name (e.g. a flag) instead of scaffolding a bogus file', () => { + expect(() => parseGenArgs(['middleware', '--foo'])).toThrow( + 'gen: Middleware name must be lowercase alphanumeric with underscores or hyphens and no leading digits.', + ); + }); + + it('allows hyphenated middleware names', () => { + expect(parseGenArgs(['middleware', 'rate-limit'])).toEqual({ + subcommand: 'middleware', + name: 'rate-limit', + }); + }); }); describe('ssr', () => { diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 4e36833ff..8421302c2 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -101,4 +101,33 @@ describe('parseProjectArgs', () => { ); }); }); + + describe('unknown-flag rejection', () => { + // `delete`/`logs`/`purge-cache` already reject unknown flags; status/link/ + // rollback/cancel previously captured a leading flag as the positional id. + it('rejects an unknown flag for status instead of capturing it as the name', () => { + expect(() => parseProjectArgs(['status', '--foo'])).toThrow("project: Unknown flag '--foo'."); + }); + + it('rejects an unknown flag for link', () => { + expect(() => parseProjectArgs(['link', '--foo'])).toThrow("project: Unknown flag '--foo'."); + }); + + it('rejects an unknown flag for rollback', () => { + expect(() => parseProjectArgs(['rollback', '--foo'])).toThrow( + "project: Unknown flag '--foo'.", + ); + }); + + it('rejects an unknown flag for cancel', () => { + expect(() => parseProjectArgs(['cancel', '--foo'])).toThrow("project: Unknown flag '--foo'."); + }); + + it('still accepts a valid positional id', () => { + expect(parseProjectArgs(['status', 'my-app'])).toEqual({ + subcommand: 'status', + name: 'my-app', + }); + }); + }); }); From 19244d841c3f0d39795c633937e2b8e483f804de Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Thu, 18 Jun 2026 21:19:43 +0800 Subject: [PATCH 2/2] refactor(cli): hoist gen name validators to module constants The name-validation regexes in the gen arg parsers were inline literals, recreating a RegExp on every parser call and duplicating two identical patterns across five sites. Hoist them to two module-level constants (NAME_RE for model/migration, NAME_WITH_HYPHENS_RE for middleware/cron/ queue). Behavior is unchanged (anchored, non-global validators). --- packages/void/src/cli/gen-args.ts | 14 +++++++++----- packages/void/test/unit/project-args.test.ts | 3 +-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/void/src/cli/gen-args.ts b/packages/void/src/cli/gen-args.ts index b4fd92113..e763d4322 100644 --- a/packages/void/src/cli/gen-args.ts +++ b/packages/void/src/cli/gen-args.ts @@ -57,12 +57,16 @@ const COLUMN_TYPES = new Set(['string', 'text', 'datetime', 'integer', 'boolean' const VALID_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); +// Generated-file name validators (hoisted once); *_HYPHENS variant also allows hyphens (cron/queue/middleware). +const NAME_RE = /^[a-z_][a-z0-9_]*$/; +const NAME_WITH_HYPHENS_RE = /^[a-z_][a-z0-9_-]*$/; + function parseModelArgs(args: Array): GenArgs { const [name, ...rest] = args; if (!name) { throw new Error('gen: Usage: void gen model [columns...].'); } - if (!/^[a-z_][a-z0-9_]*$/.test(name)) { + if (!NAME_RE.test(name)) { throw new Error( 'gen: Model name must be lowercase alphanumeric with underscores and no leading digits.', ); @@ -94,7 +98,7 @@ function parseMigrationArgs(args: Array): GenArgs { if (extra) { throw new Error(`gen: Unexpected argument '${extra}'.`); } - if (!/^[a-z_][a-z0-9_]*$/.test(name)) { + if (!NAME_RE.test(name)) { throw new Error( 'gen: Migration name must be lowercase alphanumeric with underscores and no leading digits.', ); @@ -145,7 +149,7 @@ function parseMiddlewareArgs(args: Array): GenArgs { if (extra) { throw new Error(`gen: Unexpected argument '${extra}'.`); } - if (!/^[a-z_][a-z0-9_-]*$/.test(name)) { + if (!NAME_WITH_HYPHENS_RE.test(name)) { throw new Error( 'gen: Middleware name must be lowercase alphanumeric with underscores or hyphens and no leading digits.', ); @@ -186,7 +190,7 @@ function parseCronArgs(args: Array): GenArgs { if (extra) { throw new Error(`gen: Unexpected argument '${extra}'.`); } - if (!/^[a-z_][a-z0-9_-]*$/.test(name)) { + if (!NAME_WITH_HYPHENS_RE.test(name)) { throw new Error( 'gen: Cron name must be lowercase alphanumeric with underscores or hyphens and no leading digits.', ); @@ -202,7 +206,7 @@ function parseQueueArgs(args: Array): GenArgs { if (extra) { throw new Error(`gen: Unexpected argument '${extra}'.`); } - if (!/^[a-z_][a-z0-9_-]*$/.test(name)) { + if (!NAME_WITH_HYPHENS_RE.test(name)) { throw new Error( 'gen: Queue name must be lowercase alphanumeric with underscores or hyphens and no leading digits.', ); diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 8421302c2..81bda5a26 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -103,8 +103,7 @@ describe('parseProjectArgs', () => { }); describe('unknown-flag rejection', () => { - // `delete`/`logs`/`purge-cache` already reject unknown flags; status/link/ - // rollback/cancel previously captured a leading flag as the positional id. + // status/link/rollback/cancel previously captured a leading flag as the positional id. it('rejects an unknown flag for status instead of capturing it as the name', () => { expect(() => parseProjectArgs(['status', '--foo'])).toThrow("project: Unknown flag '--foo'."); });