Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion schema/Identity.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@-]*$"
},
"defaultConfigProfile": {
"type": "string",
Expand Down
3 changes: 2 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ export const IdentitySchema = z.strictObject({
name: z
.string()
.min(1)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
// `@` is allowed in the body so an email address names its own identity directly (joseph.mearman@exadev.io), but the first character stays strictly alphanumeric: a leading `@` would collide with the `@name` selector syntax's first-`@` split and make an unconventional directory name under identities/.
.regex(/^[A-Za-z0-9][A-Za-z0-9._@-]*$/),
defaultConfigProfile: z.string().min(1).optional(),
allowAmbientCredential: z.boolean().default(false),
});
Expand Down
8 changes: 8 additions & 0 deletions src/configure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ describe("validateProfileName", () => {
expect(validateProfileName("client-acme")).toBeUndefined();
});

it("accepts an at sign in the body, matching the identity-name shape the wizard defaults to for a new identity", () => {
expect(validateProfileName("joseph.mearman@exadev.io")).toBeUndefined();
});

it("rejects a name starting with an at sign", () => {
expect(validateProfileName("@exadev")).toContain("must start with a letter or digit");
});

it("rejects a duplicate when existingNames is given", () => {
expect(validateProfileName("base", ["base", "other"])).toContain("already exists");
});
Expand Down
6 changes: 3 additions & 3 deletions src/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,16 +460,16 @@ const CATEGORY_LABELS: Record<"secret" | OverridableCategory, string> = {
/* runProfileWizard: the unified create-or-edit flow. */
/* -------------------------------------------------------------------------------------------------- */

/** A profile name must match the same shape as an identity name (`IdentitySchema`): start alphanumeric, then alphanumerics/dots/dashes/underscores. */
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** A profile name must match the same shape as an identity name (`IdentitySchema`): start alphanumeric, then alphanumerics/dots/dashes/underscores/at signs. The `@` allowance moves in lockstep with the identity schema because `runIdentityWizard` offers the identity's own name as the default profile name — an email-shaped identity would otherwise suggest a profile name its own validator rejects. */
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/;

/** Validates a candidate profile name for the wizard's text prompt: non-empty, matches the allowed shape, and (only when `existingNames` is given) not already taken. */
export function validateProfileName(value: string, existingNames?: readonly string[]): string | undefined {
if (value.length === 0) {
return "A name is required.";
}
if (!PROFILE_NAME_RE.test(value)) {
return "Names must start with a letter or digit, and contain only letters, digits, dots, dashes, and underscores.";
return "Names must start with a letter or digit, and contain only letters, digits, dots, dashes, underscores, and at signs.";
}
if (existingNames?.includes(value)) {
return `A configuration profile named "${value}" already exists.`;
Expand Down
29 changes: 24 additions & 5 deletions src/identityManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,14 @@ describe("identityManager", () => {
expect(() => addIdentity(paths, "-bad-start")).toThrow(InvalidIdentityNameError);
});

it("throws InvalidIdentityNameError for an email-shaped name (the `@` shortcut's own name portion can be anything, including an email address)", () => {
expect(() => addIdentity(paths, "joseph.mearman@exadev.io")).toThrow(InvalidIdentityNameError);
it("creates an identity for an email-shaped name — `@` is valid in the body, so the `@name` shortcut's first-`@` split lands here intact", () => {
const identity = addIdentity(paths, "joseph.mearman@exadev.io");
expect(identity.name).toBe("joseph.mearman@exadev.io");
expect(readIdentity(paths, "joseph.mearman@exadev.io")?.name).toBe("joseph.mearman@exadev.io");
});

it("throws InvalidIdentityNameError for a leading `@` — the first character stays strictly alphanumeric so the `@name` selector syntax's first-`@` split stays unambiguous", () => {
expect(() => addIdentity(paths, "@exadev")).toThrow(InvalidIdentityNameError);
});
});

Expand Down Expand Up @@ -133,6 +139,12 @@ describe("identityManager", () => {
expect(readActiveIdentity(paths)).toBe("exadev");
});

it("splits an @<name> token at the first `@` only, so an email-shaped identity name survives intact", async () => {
addIdentity(paths, "joseph.mearman@exadev.io");
expect(await tryRunAtIdentityShortcut(paths, ["@joseph.mearman@exadev.io"])).toBe(true);
expect(readActiveIdentity(paths)).toBe("joseph.mearman@exadev.io");
});

it("propagates IdentityNotFoundError for an unknown @<name> in a non-interactive context", async () => {
await expect(tryRunAtIdentityShortcut(paths, ["@ghost"])).rejects.toThrow(IdentityNotFoundError);
});
Expand Down Expand Up @@ -194,9 +206,16 @@ describe("identityManager", () => {

it("throws InvalidIdentityNameError before any prompt appears, not a raw ZodError after the user confirms, when the name fails IdentitySchema's own naming rule", async () => {
const prompts = scriptedIdentityPrompts([]);
await expect(runIdentityWizard(prompts, paths, "joseph.mearman@exadev.io")).rejects.toThrow(
InvalidIdentityNameError,
);
await expect(runIdentityWizard(prompts, paths, "@exadev")).rejects.toThrow(InvalidIdentityNameError);
});

it("offers the wizard for an email-shaped name, whose `@` is valid in the body", async () => {
const prompts = scriptedIdentityPrompts(["create", "skip"]);
const result = await runIdentityWizard(prompts, paths, "joseph.mearman@exadev.io");

expect(result).toBe(true);
expect(readIdentity(paths, "joseph.mearman@exadev.io")).toBeDefined();
expect(readActiveIdentity(paths)).toBe("joseph.mearman@exadev.io");
});
});

Expand Down
6 changes: 3 additions & 3 deletions src/identityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export class IdentityAlreadyExistsError extends CliError {
}
}

/** Raised by `addIdentity` when `name` fails `IdentitySchema`'s own naming rule — it must start with a letter or number and contain only letters, numbers, dots, hyphens, and underscores (this excludes `@`, so an email address is not a valid identity name on its own). */
/** Raised by `addIdentity` when `name` fails `IdentitySchema`'s own naming rule — it must start with a letter or number and may then contain letters, numbers, dots, hyphens, underscores, and at signs, so an email address names an identity directly while a *leading* `@` stays invalid (it would collide with the `@name` selector syntax's first-`@` split). */
export class InvalidIdentityNameError extends CliError {
constructor(readonly attemptedName: string) {
super(
`"${attemptedName}" is not a valid identity name — identity names must start with a letter or number and may only contain letters, numbers, dots, hyphens, and underscores.`,
`"${attemptedName}" is not a valid identity name — identity names must start with a letter or number and may then contain letters, numbers, dots, hyphens, underscores, and at signs.`,
);
this.name = "InvalidIdentityNameError";
}
Expand Down Expand Up @@ -82,7 +82,7 @@ export function useIdentity(paths: LayoutPaths, name: string): void {
/**
* The interactive setup wizard for a new identity, offered by the `@<name>` shortcut and `identity use` when the identity doesn't exist yet and stdin is a real terminal.
*
* Validates `name` against `IdentitySchema`'s own naming rule before any prompt appears — offering "Create it now?" for a name that could never validate (an email address, say, whose `@` the shortcut passes through verbatim) just to fail on confirm is a broken interaction, so an invalid name throws `InvalidIdentityNameError` immediately instead.
* Validates `name` against `IdentitySchema`'s own naming rule before any prompt appears — offering "Create it now?" for a name that could never validate (one with a leading `@`, say, or any other character the schema rejects) just to fail on confirm is a broken interaction, so an invalid name throws `InvalidIdentityNameError` immediately instead.
*
* Confirms the user wants to create the identity, then optionally creates a default configuration profile (reusing `runProfileWizard`), links them, and sets the identity as active. A cancel at any step writes nothing beyond what was already committed — the identity is only created after the first confirm, and the profile wizard's own cancel handling means a profile-only cancellation still leaves the identity usable. Returns `true` when the identity was created and set active; `false` when the user declined at the initial confirm.
*
Expand Down
Loading