Skip to content

Commit 0cf6bab

Browse files
authored
Merge pull request #259 from PaulJPhilp/codex/ep-admin-auth-dx-errors
fix(ep-admin): improve auth-gate DX and actionable auth errors
2 parents b89c7c6 + 7ed7299 commit 0cf6bab

2 files changed

Lines changed: 163 additions & 26 deletions

File tree

packages/ep-admin/src/__tests__/cli-auth-contract.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,29 @@ const createAuthEnv = async () => {
4141
};
4242

4343
describe.sequential("CLI auth and DX contract", () => {
44-
it("fails protected commands with actionable guidance when not logged in", async () => {
45-
const { tempDir, env } = await createAuthEnv();
46-
try {
47-
const result = runCli(["ops", "rotate-api-key", "--json"], env);
48-
expect(result.status).toBe(1);
49-
expect(result.stderr).toContain("ep-admin auth has not been initialized.");
50-
} finally {
51-
await rm(tempDir, { recursive: true, force: true });
52-
}
53-
});
44+
it("fails protected commands with actionable guidance when not logged in", async () => {
45+
const { tempDir, env } = await createAuthEnv();
46+
try {
47+
const result = runCli(["ops", "rotate-api-key", "--json"], env);
48+
expect(result.status).toBe(1);
49+
expect(result.stderr).toContain("ep-admin authentication is not initialized.");
50+
expect(result.stderr).toContain("Run: ep-admin auth init");
51+
} finally {
52+
await rm(tempDir, { recursive: true, force: true });
53+
}
54+
});
55+
56+
it("shows typo suggestion before auth gating for logged-out users", async () => {
57+
const { tempDir, env } = await createAuthEnv();
58+
try {
59+
const typo = runCli(["opz"], env);
60+
expect(typo.status).toBe(1);
61+
expect(typo.stderr).toContain("Did you mean: ep-admin ops");
62+
expect(typo.stderr).not.toContain("auth has not been initialized");
63+
} finally {
64+
await rm(tempDir, { recursive: true, force: true });
65+
}
66+
});
5467

5568
it("keeps --help and --version available without login", async () => {
5669
const { tempDir, env } = await createAuthEnv();

packages/ep-admin/src/index.ts

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,24 @@ const getCommandSuggestion = (argv: ReadonlyArray<string>): string | null => {
262262
return null;
263263
};
264264

265+
const getPreAuthCommandMismatchMessage = (argv: ReadonlyArray<string>): string | null => {
266+
const args = normalizeArgsForSuggestion(argv);
267+
const first = args[0];
268+
if (!first) return null;
269+
270+
if (!ROOT_COMMANDS.includes(first as (typeof ROOT_COMMANDS)[number])) {
271+
return getCommandSuggestion(argv) ?? "Need command help? Run 'ep-admin --help'.";
272+
}
273+
274+
const nested = NESTED_COMMANDS[first];
275+
const second = args[1];
276+
if (nested && second && !nested.includes(second)) {
277+
return getCommandSuggestion(argv) ?? "Need command help? Run 'ep-admin --help'.";
278+
}
279+
280+
return null;
281+
};
282+
265283
const isAuthExemptArgv = (argv: ReadonlyArray<string>): boolean => {
266284
const args = argv.slice(2);
267285
if (args.length === 0) return true;
@@ -318,51 +336,152 @@ const normalizeLegacyArgs = (
318336
return { argv: args, warnings };
319337
};
320338

339+
type TaggedAuthError = {
340+
readonly _tag: string;
341+
readonly message: string;
342+
readonly expectedUser?: string;
343+
readonly currentUser?: string;
344+
};
345+
346+
const isTaggedAuthError = (value: unknown): value is TaggedAuthError =>
347+
typeof value === "object" &&
348+
value !== null &&
349+
"_tag" in value &&
350+
typeof (value as { _tag?: unknown })._tag === "string" &&
351+
"message" in value &&
352+
typeof (value as { message?: unknown }).message === "string";
353+
354+
const extractFailureFromCause = (cause: unknown): unknown | undefined => {
355+
if (typeof cause !== "object" || cause === null) {
356+
return undefined;
357+
}
358+
359+
const taggedCause = cause as {
360+
readonly _tag?: string;
361+
readonly error?: unknown;
362+
readonly defect?: unknown;
363+
readonly failure?: unknown;
364+
readonly cause?: unknown;
365+
readonly left?: unknown;
366+
readonly right?: unknown;
367+
};
368+
369+
switch (taggedCause._tag) {
370+
case "Fail":
371+
return taggedCause.error ?? taggedCause.failure;
372+
case "Die":
373+
return taggedCause.defect ?? taggedCause.error ?? taggedCause.failure;
374+
case "Traced":
375+
return extractFailureFromCause(taggedCause.cause);
376+
case "Sequential":
377+
case "Parallel": {
378+
const left = extractFailureFromCause(taggedCause.left);
379+
if (left !== undefined) return left;
380+
return extractFailureFromCause(taggedCause.right);
381+
}
382+
default:
383+
return undefined;
384+
}
385+
};
386+
387+
const unwrapFiberFailure = (error: unknown): unknown => {
388+
if (typeof error !== "object" || error === null) {
389+
return error;
390+
}
391+
392+
for (const symbol of Object.getOwnPropertySymbols(error)) {
393+
if (!String(symbol).includes("FiberFailure/Cause")) {
394+
continue;
395+
}
396+
const cause = (error as Record<symbol, unknown>)[symbol];
397+
const failure = extractFailureFromCause(cause);
398+
if (failure !== undefined) {
399+
return failure;
400+
}
401+
}
402+
403+
return error;
404+
};
405+
321406
const extractErrorMessage = (error: unknown, argv: ReadonlyArray<string>): string | null => {
322-
if (error instanceof AuthNotInitializedError) {
407+
const resolvedError = unwrapFiberFailure(error);
408+
409+
if (resolvedError instanceof AuthNotInitializedError) {
323410
return [
324411
"ep-admin authentication is not initialized.",
325412
"Run: ep-admin auth init",
326413
`Docs: ${EP_ADMIN_DOCS_URL}`,
327414
].join("\n");
328415
}
329416

330-
if (error instanceof AuthInvalidCredentialsError) {
417+
if (resolvedError instanceof AuthInvalidCredentialsError) {
331418
return [
332419
"ep-admin login required.",
333420
"Run: ep-admin auth login",
334421
`Docs: ${EP_ADMIN_DOCS_URL}`,
335422
].join("\n");
336423
}
337424

338-
if (error instanceof AuthSessionExpiredError) {
425+
if (resolvedError instanceof AuthSessionExpiredError) {
339426
return [
340427
"ep-admin session expired.",
341428
"Run: ep-admin auth login",
342429
`Docs: ${EP_ADMIN_DOCS_URL}`,
343430
].join("\n");
344431
}
345432

346-
if (error instanceof AuthUnauthorizedUserError) {
433+
if (resolvedError instanceof AuthUnauthorizedUserError) {
347434
return [
348-
`Unauthorized OS user '${error.currentUser}'.`,
349-
`Authorized user: '${error.expectedUser}'.`,
435+
`Unauthorized OS user '${resolvedError.currentUser}'.`,
436+
`Authorized user: '${resolvedError.expectedUser}'.`,
350437
`Docs: ${EP_ADMIN_DOCS_URL}`,
351438
].join("\n");
352439
}
353440

354-
if (error instanceof AuthServiceTokenError) {
355-
return `${error.message}\nDocs: ${EP_ADMIN_DOCS_URL}`;
441+
if (resolvedError instanceof AuthServiceTokenError) {
442+
return `${resolvedError.message}\nDocs: ${EP_ADMIN_DOCS_URL}`;
356443
}
357444

358-
if (error instanceof AuthConfigurationError) {
359-
return `${error.message}\nDocs: ${EP_ADMIN_DOCS_URL}`;
445+
if (resolvedError instanceof AuthConfigurationError) {
446+
return `${resolvedError.message}\nDocs: ${EP_ADMIN_DOCS_URL}`;
360447
}
361448

362-
if (typeof error === "string") return error;
449+
if (isTaggedAuthError(resolvedError)) {
450+
switch (resolvedError._tag) {
451+
case "AuthNotInitializedError":
452+
return [
453+
"ep-admin authentication is not initialized.",
454+
"Run: ep-admin auth init",
455+
`Docs: ${EP_ADMIN_DOCS_URL}`,
456+
].join("\n");
457+
case "AuthInvalidCredentialsError":
458+
return [
459+
"ep-admin login required.",
460+
"Run: ep-admin auth login",
461+
`Docs: ${EP_ADMIN_DOCS_URL}`,
462+
].join("\n");
463+
case "AuthSessionExpiredError":
464+
return [
465+
"ep-admin session expired.",
466+
"Run: ep-admin auth login",
467+
`Docs: ${EP_ADMIN_DOCS_URL}`,
468+
].join("\n");
469+
case "AuthUnauthorizedUserError":
470+
return [
471+
`Unauthorized OS user '${resolvedError.currentUser ?? "unknown"}'.`,
472+
`Authorized user: '${resolvedError.expectedUser ?? "unknown"}'.`,
473+
`Docs: ${EP_ADMIN_DOCS_URL}`,
474+
].join("\n");
475+
case "AuthServiceTokenError":
476+
case "AuthConfigurationError":
477+
return `${resolvedError.message}\nDocs: ${EP_ADMIN_DOCS_URL}`;
478+
}
479+
}
480+
481+
if (typeof resolvedError === "string") return resolvedError;
363482

364-
if (error instanceof Error) {
365-
const combined = [error.message, error.stack].filter(Boolean).join("\n");
483+
if (resolvedError instanceof Error) {
484+
const combined = [resolvedError.message, resolvedError.stack].filter(Boolean).join("\n");
366485
if (combined.includes("CommandMismatch")) {
367486
const suggestion = getCommandSuggestion(argv);
368487
return [
@@ -371,12 +490,12 @@ const extractErrorMessage = (error: unknown, argv: ReadonlyArray<string>): strin
371490
].join("\n");
372491
}
373492

374-
if (error.message.trim()) {
375-
return `${error.message.trim()}\nDocs: ${EP_ADMIN_DOCS_URL}`;
493+
if (resolvedError.message.trim()) {
494+
return `${resolvedError.message.trim()}\nDocs: ${EP_ADMIN_DOCS_URL}`;
376495
}
377496
}
378497

379-
return String(error);
498+
return String(resolvedError);
380499
};
381500

382501
export const createAdminProgram = (
@@ -396,6 +515,11 @@ export const runCli = (
396515
yield* validateEnvironment;
397516

398517
if (!isAuthExemptArgv(prepared.argv)) {
518+
const commandMismatchMessage = getPreAuthCommandMismatchMessage(prepared.argv);
519+
if (commandMismatchMessage) {
520+
return yield* Effect.fail(new Error(commandMismatchMessage));
521+
}
522+
399523
const auth = yield* Auth;
400524
yield* auth.ensureAuthorized(process.env.EP_ADMIN_SERVICE_TOKEN);
401525
}

0 commit comments

Comments
 (0)