From c65cd53d46be7852191d4c4daf16c80dec4a2f76 Mon Sep 17 00:00:00 2001 From: os-justin Date: Thu, 10 Sep 2026 14:45:46 +0000 Subject: [PATCH 1/2] fix(cli): make `os register` require a name, and drop the cast that hid it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live route refuses a sign-up without `name`: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers 400 VALIDATION_ERROR "[body.name] Invalid input: expected string, received undefined". The same run with a name supplied answers 200 and creates the account. So `RegisterRequestSchema` declares `name` correctly and the command was the side that disagreed: it prompted "Name (optional)", typed its own payload with `name?`, guarded email and password but not name, and cast the payload with `as any` at the call site — which is the only reason that disagreement compiled. - prompt: "Name (optional): " -> "Name: " - guard: `if (!name) throw new Error('Name is required')`, beside email/password - payload: annotated with the declared `RegisterRequest`, no local twin - call site: the `as any` is gone, so the next divergence is a compile error Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude --- packages/cli/src/commands/register.ts | 9 +- .../cli/test/register-requires-name.test.ts | 166 ++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 packages/cli/test/register-requires-name.test.ts diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index 90ac1b68ac..751ecd4225 100644 --- a/packages/cli/src/commands/register.ts +++ b/packages/cli/src/commands/register.ts @@ -4,6 +4,7 @@ import { Command, Flags } from '@oclif/core'; import { printHeader, printSuccess, printError, printKV, emitJson, errorCodeFields } from '../utils/format.js'; import { writeAuthConfig } from '../utils/auth-config.js'; import { ObjectStackClient } from '@objectstack/client'; +import type { RegisterRequest } from '@objectstack/client'; import * as readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; @@ -112,7 +113,7 @@ export default class Register extends Command { email = await rl.question('Email: '); } if (!name) { - name = await rl.question('Name (optional): '); + name = await rl.question('Name: '); } rl.close(); if (!password) { @@ -120,12 +121,12 @@ export default class Register extends Command { } if (!email) throw new Error('Email is required'); + if (!name) throw new Error('Name is required'); if (!password) throw new Error('Password is required'); const client = new ObjectStackClient({ baseUrl: flags.url }); - const registerPayload: { email: string; password: string; name?: string } = { email, password }; - if (name) registerPayload.name = name; - const response = await client.auth.register(registerPayload as any); + const registerPayload: RegisterRequest = { email, password, name }; + const response = await client.auth.register(registerPayload); const token = response.data?.token ?? (response as any).token; const user = response.data?.user ?? (response as any).user; diff --git a/packages/cli/test/register-requires-name.test.ts b/packages/cli/test/register-requires-name.test.ts new file mode 100644 index 0000000000..90db20ce3c --- /dev/null +++ b/packages/cli/test/register-requires-name.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os register` and the door it actually knocks on must agree about `name`. + * + * MEASURED, not inferred (a fresh showcase environment with no human user yet, + * so the audience gate's bootstrap bypass admits the sign-up and the only + * judge left is the route's own validation): + * + * empty Name answer -> POST /api/v1/auth/sign-up/email + * 400 VALIDATION_ERROR + * "[body.name] Invalid input: expected string, received undefined" + * same run, name supplied (positive control, same environment, same route) + * -> 200, account created + * + * So the route REQUIRES `name`, `RegisterRequestSchema` declares it correctly, + * and the command was advertising a field as "(optional)" that the first-use + * path cannot omit. The request-side `as any` at the call site is what kept + * that disagreement off the compiler: with it gone the payload is annotated + * with the declared `RegisterRequest`, so a future divergence is a type error + * instead of a runtime 400 a user meets on their first command. + * + * Both halves are pinned here, and neither is optional: + * + * REFUSAL — an empty answer is refused by the CLI, BEFORE any request is + * made (the route was never called). A test that only asserted + * "it fails" would pass just as well on the pre-fix command, + * which also failed — one HTTP round trip later, with the + * server's field-path message instead of the CLI's own. + * PRESERVATION— when a name IS supplied, the wire body is exactly the three + * declared members. This is what stops the refusal from being + * "fixed" by sending an empty string, which the route's + * `z.string()` accepts and which would create an account whose + * display name is blank. + * + * The prompt text is pinned alongside them: the false promise lived in that + * string, and nothing else in the tree carries it. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Register from '../src/commands/register.js'; + +/** Answers handed to `rl.question`, in prompt order. */ +let answers: string[] = []; +/** Every prompt string the command actually asked. */ +let asked: string[] = []; + +vi.mock('node:readline/promises', () => ({ + createInterface: () => ({ + question: async (prompt: string) => { + asked.push(prompt); + return answers.shift() ?? ''; + }, + close: () => {}, + }), +})); + +vi.mock('../src/utils/auth-config.js', () => ({ + writeAuthConfig: vi.fn(async () => {}), +})); + +const URL_FLAG = 'http://route.test'; +const EMAIL = 'first-run@example.com'; +const PASSWORD = 'Passw0rd123'; + +/** A `fetch` stub that records its calls and answers like the real route. */ +function stubFetch(): ReturnType { + const impl = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: async () => ({ token: 'tok_1', user: { id: 'usr_1', email: EMAIL } }), + }) as any); + vi.stubGlobal('fetch', impl); + return impl; +} + +/** Run the command, capturing everything it printed. */ +async function runRegister(argv: string[]): Promise<{ output: string; threw: unknown }> { + const lines: string[] = []; + const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + let threw: unknown; + try { + await Register.run(argv); + } catch (error) { + threw = error; + } + return { output: lines.join('\n'), threw }; +} + +describe('os register — the prompt, the payload and the route agree about `name`', () => { + let exitCode: number | undefined; + + beforeEach(() => { + answers = []; + asked = []; + exitCode = process.exitCode; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + // oclif's default `catch` sets process.exitCode on the refusal case; leaving + // it set would fail the whole vitest run on a suite that passed. + process.exitCode = exitCode; + }); + + it('refuses an empty name WITHOUT calling the route', async () => { + const fetchImpl = stubFetch(); + answers = ['']; // the Name prompt, answered with a bare Enter + + const { output, threw } = await runRegister([ + '--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD, + ]); + + expect(threw).toBeDefined(); + expect(output).toContain('Name is required'); + // The half that distinguishes this fix from the defect: no request went out. + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('no longer advertises the field as optional', async () => { + stubFetch(); + answers = ['Jane Doe']; + + await runRegister(['--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD]); + + expect(asked).toContain('Name: '); + expect(asked.join('\n')).not.toMatch(/optional/i); + }); + + it('sends exactly the declared request members when a name is supplied', async () => { + const fetchImpl = stubFetch(); + answers = ['Jane Doe']; + + const { threw } = await runRegister([ + '--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD, + ]); + + expect(threw).toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${URL_FLAG}/api/v1/auth/sign-up/email`); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ + email: EMAIL, + password: PASSWORD, + name: 'Jane Doe', + }); + }); + + it('accepts the name from the flag without prompting for it', async () => { + const fetchImpl = stubFetch(); + + await runRegister([ + '--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD, '--name', 'Flagged Name', + ]); + + expect(asked).toEqual([]); + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body)).name).toBe('Flagged Name'); + }); +}); From 88e25d2985588d06ea6209c31446fcb8f9e43b0b Mon Sep 17 00:00:00 2001 From: os-justin Date: Thu, 10 Sep 2026 15:18:40 +0000 Subject: [PATCH 2/2] test(cli): widen the saved `process.exitCode` to the type Node declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig.test.json` covers this file, and `process.exitCode` is `string | number | null | undefined` there — the narrower annotation was a TS2322 the source-layer `tsc --noEmit` never sees. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude --- .changeset/cli-register-requires-name.md | 16 ++++++++++++++++ packages/cli/test/register-requires-name.test.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-register-requires-name.md diff --git a/.changeset/cli-register-requires-name.md b/.changeset/cli-register-requires-name.md new file mode 100644 index 0000000000..0a72c1b94c --- /dev/null +++ b/.changeset/cli-register-requires-name.md @@ -0,0 +1,16 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) + +`os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. + +So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. + +- the prompt now reads `Name: `; +- an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; +- the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; +- the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. + +No behaviour change for anyone already passing a name, by flag or at the prompt. diff --git a/packages/cli/test/register-requires-name.test.ts b/packages/cli/test/register-requires-name.test.ts index 90db20ce3c..f6e9242c16 100644 --- a/packages/cli/test/register-requires-name.test.ts +++ b/packages/cli/test/register-requires-name.test.ts @@ -92,7 +92,7 @@ async function runRegister(argv: string[]): Promise<{ output: string; threw: unk } describe('os register — the prompt, the payload and the route agree about `name`', () => { - let exitCode: number | undefined; + let exitCode: typeof process.exitCode; beforeEach(() => { answers = [];