Skip to content

Commit 010c48a

Browse files
os-justinclaude
andauthored
fix(cli): os register requires a name, and the request-side as any that hid the mismatch is gone (#17455)
* fix(cli): make `os register` require a name, and drop the cast that hid it 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 <noreply@anthropic.com> * test(cli): widen the saved `process.exitCode` to the type Node declares `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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e758131 commit 010c48a

3 files changed

Lines changed: 187 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932)
6+
7+
`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.
8+
9+
So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along.
10+
11+
- the prompt now reads `Name: `;
12+
- 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;
13+
- the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin;
14+
- 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.
15+
16+
No behaviour change for anyone already passing a name, by flag or at the prompt.

packages/cli/src/commands/register.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Command, Flags } from '@oclif/core';
44
import { printHeader, printSuccess, printError, printKV, emitJson, errorCodeFields } from '../utils/format.js';
55
import { writeAuthConfig } from '../utils/auth-config.js';
66
import { ObjectStackClient } from '@objectstack/client';
7+
import type { RegisterRequest } from '@objectstack/client';
78
import * as readline from 'node:readline/promises';
89
import { stdin as input, stdout as output } from 'node:process';
910

@@ -112,20 +113,20 @@ export default class Register extends Command {
112113
email = await rl.question('Email: ');
113114
}
114115
if (!name) {
115-
name = await rl.question('Name (optional): ');
116+
name = await rl.question('Name: ');
116117
}
117118
rl.close();
118119
if (!password) {
119120
password = await promptPassword('Password: ');
120121
}
121122

122123
if (!email) throw new Error('Email is required');
124+
if (!name) throw new Error('Name is required');
123125
if (!password) throw new Error('Password is required');
124126

125127
const client = new ObjectStackClient({ baseUrl: flags.url });
126-
const registerPayload: { email: string; password: string; name?: string } = { email, password };
127-
if (name) registerPayload.name = name;
128-
const response = await client.auth.register(registerPayload as any);
128+
const registerPayload: RegisterRequest = { email, password, name };
129+
const response = await client.auth.register(registerPayload);
129130

130131
const token = response.data?.token ?? (response as any).token;
131132
const user = response.data?.user ?? (response as any).user;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os register` and the door it actually knocks on must agree about `name`.
5+
*
6+
* MEASURED, not inferred (a fresh showcase environment with no human user yet,
7+
* so the audience gate's bootstrap bypass admits the sign-up and the only
8+
* judge left is the route's own validation):
9+
*
10+
* empty Name answer -> POST /api/v1/auth/sign-up/email
11+
* 400 VALIDATION_ERROR
12+
* "[body.name] Invalid input: expected string, received undefined"
13+
* same run, name supplied (positive control, same environment, same route)
14+
* -> 200, account created
15+
*
16+
* So the route REQUIRES `name`, `RegisterRequestSchema` declares it correctly,
17+
* and the command was advertising a field as "(optional)" that the first-use
18+
* path cannot omit. The request-side `as any` at the call site is what kept
19+
* that disagreement off the compiler: with it gone the payload is annotated
20+
* with the declared `RegisterRequest`, so a future divergence is a type error
21+
* instead of a runtime 400 a user meets on their first command.
22+
*
23+
* Both halves are pinned here, and neither is optional:
24+
*
25+
* REFUSAL — an empty answer is refused by the CLI, BEFORE any request is
26+
* made (the route was never called). A test that only asserted
27+
* "it fails" would pass just as well on the pre-fix command,
28+
* which also failed — one HTTP round trip later, with the
29+
* server's field-path message instead of the CLI's own.
30+
* PRESERVATION— when a name IS supplied, the wire body is exactly the three
31+
* declared members. This is what stops the refusal from being
32+
* "fixed" by sending an empty string, which the route's
33+
* `z.string()` accepts and which would create an account whose
34+
* display name is blank.
35+
*
36+
* The prompt text is pinned alongside them: the false promise lived in that
37+
* string, and nothing else in the tree carries it.
38+
*/
39+
40+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
41+
import Register from '../src/commands/register.js';
42+
43+
/** Answers handed to `rl.question`, in prompt order. */
44+
let answers: string[] = [];
45+
/** Every prompt string the command actually asked. */
46+
let asked: string[] = [];
47+
48+
vi.mock('node:readline/promises', () => ({
49+
createInterface: () => ({
50+
question: async (prompt: string) => {
51+
asked.push(prompt);
52+
return answers.shift() ?? '';
53+
},
54+
close: () => {},
55+
}),
56+
}));
57+
58+
vi.mock('../src/utils/auth-config.js', () => ({
59+
writeAuthConfig: vi.fn(async () => {}),
60+
}));
61+
62+
const URL_FLAG = 'http://route.test';
63+
const EMAIL = 'first-run@example.com';
64+
const PASSWORD = 'Passw0rd123';
65+
66+
/** A `fetch` stub that records its calls and answers like the real route. */
67+
function stubFetch(): ReturnType<typeof vi.fn> {
68+
const impl = vi.fn(async (_url: string, _init?: RequestInit) => ({
69+
ok: true,
70+
status: 200,
71+
statusText: 'OK',
72+
headers: { get: () => null },
73+
json: async () => ({ token: 'tok_1', user: { id: 'usr_1', email: EMAIL } }),
74+
}) as any);
75+
vi.stubGlobal('fetch', impl);
76+
return impl;
77+
}
78+
79+
/** Run the command, capturing everything it printed. */
80+
async function runRegister(argv: string[]): Promise<{ output: string; threw: unknown }> {
81+
const lines: string[] = [];
82+
const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); };
83+
vi.spyOn(console, 'log').mockImplementation(capture);
84+
vi.spyOn(console, 'error').mockImplementation(capture);
85+
let threw: unknown;
86+
try {
87+
await Register.run(argv);
88+
} catch (error) {
89+
threw = error;
90+
}
91+
return { output: lines.join('\n'), threw };
92+
}
93+
94+
describe('os register — the prompt, the payload and the route agree about `name`', () => {
95+
let exitCode: typeof process.exitCode;
96+
97+
beforeEach(() => {
98+
answers = [];
99+
asked = [];
100+
exitCode = process.exitCode;
101+
});
102+
103+
afterEach(() => {
104+
vi.unstubAllGlobals();
105+
vi.restoreAllMocks();
106+
// oclif's default `catch` sets process.exitCode on the refusal case; leaving
107+
// it set would fail the whole vitest run on a suite that passed.
108+
process.exitCode = exitCode;
109+
});
110+
111+
it('refuses an empty name WITHOUT calling the route', async () => {
112+
const fetchImpl = stubFetch();
113+
answers = ['']; // the Name prompt, answered with a bare Enter
114+
115+
const { output, threw } = await runRegister([
116+
'--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD,
117+
]);
118+
119+
expect(threw).toBeDefined();
120+
expect(output).toContain('Name is required');
121+
// The half that distinguishes this fix from the defect: no request went out.
122+
expect(fetchImpl).not.toHaveBeenCalled();
123+
});
124+
125+
it('no longer advertises the field as optional', async () => {
126+
stubFetch();
127+
answers = ['Jane Doe'];
128+
129+
await runRegister(['--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD]);
130+
131+
expect(asked).toContain('Name: ');
132+
expect(asked.join('\n')).not.toMatch(/optional/i);
133+
});
134+
135+
it('sends exactly the declared request members when a name is supplied', async () => {
136+
const fetchImpl = stubFetch();
137+
answers = ['Jane Doe'];
138+
139+
const { threw } = await runRegister([
140+
'--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD,
141+
]);
142+
143+
expect(threw).toBeUndefined();
144+
expect(fetchImpl).toHaveBeenCalledTimes(1);
145+
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
146+
expect(url).toBe(`${URL_FLAG}/api/v1/auth/sign-up/email`);
147+
expect(init.method).toBe('POST');
148+
expect(JSON.parse(String(init.body))).toEqual({
149+
email: EMAIL,
150+
password: PASSWORD,
151+
name: 'Jane Doe',
152+
});
153+
});
154+
155+
it('accepts the name from the flag without prompting for it', async () => {
156+
const fetchImpl = stubFetch();
157+
158+
await runRegister([
159+
'--url', URL_FLAG, '--email', EMAIL, '--password', PASSWORD, '--name', 'Flagged Name',
160+
]);
161+
162+
expect(asked).toEqual([]);
163+
const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
164+
expect(JSON.parse(String(init.body)).name).toBe('Flagged Name');
165+
});
166+
});

0 commit comments

Comments
 (0)