From 91a849409e6910c338bb0def026f5ebdfcd0b674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 10 Jul 2026 23:10:21 +0200 Subject: [PATCH 1/2] feat(cli): add create-project command Add `argos create-project --account `, backed by the `POST /projects` API endpoint. Supports `--token`, `--json`, and the `ARGOS_ACCOUNT` / `ARGOS_TOKEN` environment variables. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/schema.ts | 85 +++++++++++++++++++++ packages/cli/src/commands/create-project.ts | 42 ++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/lib/format.test.ts | 18 +++++ packages/cli/src/lib/format.ts | 11 +++ packages/cli/src/options.ts | 6 ++ 6 files changed, 164 insertions(+) create mode 100644 packages/cli/src/commands/create-project.ts diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 9ad0fe56..4cb4a020 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -264,6 +264,26 @@ export interface paths { patch?: never; trace?: never; }; + "/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a project + * @description Create a new project in an account you administer. The authenticated personal access token must be scoped to the target account, and the acting user must be an administrator of it. + */ + post: operations["createProject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/projects/{owner}/{project}": { parameters: { query?: never; @@ -2066,6 +2086,71 @@ export interface operations { }; }; }; + createProject: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the project to create. Must be unique within the account (case-insensitive) and cannot be a reserved name. */ + name: string; + /** @description Slug of the account (personal or team) that will own the project. The personal access token must be scoped to this account and the acting user must be one of its administrators. */ + accountSlug: string; + }; + }; + }; + responses: { + /** @description Project created successfully — returns the project */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Project"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getProject: { parameters: { query?: never; diff --git a/packages/cli/src/commands/create-project.ts b/packages/cli/src/commands/create-project.ts new file mode 100644 index 00000000..33592fbd --- /dev/null +++ b/packages/cli/src/commands/create-project.ts @@ -0,0 +1,42 @@ +import type { Command } from "commander"; +import { createApiClient, unwrap } from "../lib/api"; +import { fail } from "../lib/cli-error"; +import { formatProject } from "../lib/format"; +import { handleCliError, output } from "../lib/run"; +import { resolveToken } from "../lib/target"; +import { accountOption, jsonOption, tokenOption } from "../options"; + +type CreateProjectOptions = { + account?: string | undefined; + token?: string | undefined; + json?: boolean | undefined; +}; + +export function createProjectCommand(program: Command) { + program + .command("create-project") + .argument("", "Name of the project to create") + .description("Create a new project in an account you administer") + .addOption(accountOption) + .addOption(tokenOption) + .addOption(jsonOption) + .action(async (name: string, options: CreateProjectOptions) => { + try { + const accountSlug = options.account; + if (!accountSlug) { + fail( + "An account is required. Use --account or set ARGOS_ACCOUNT.", + ); + } + const client = createApiClient(await resolveToken(options)); + const project = unwrap( + await client.POST("/projects", { + body: { name, accountSlug }, + }), + ); + output(project, options, formatProject); + } catch (error) { + handleCliError(error, "user"); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 77b4827a..4f934eff 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,7 @@ import { loginCommand } from "./commands/login"; import { logoutCommand } from "./commands/logout"; import { deployCommand } from "./commands/deploy"; import { whoamiCommand } from "./commands/whoami"; +import { createProjectCommand } from "./commands/create-project"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -35,6 +36,7 @@ loginCommand(program); logoutCommand(program); deployCommand(program); whoamiCommand(program); +createProjectCommand(program); if (!process.argv.slice(2).length) { program.outputHelp(); diff --git a/packages/cli/src/lib/format.test.ts b/packages/cli/src/lib/format.test.ts index 3c95c966..3d02b9d4 100644 --- a/packages/cli/src/lib/format.test.ts +++ b/packages/cli/src/lib/format.test.ts @@ -4,6 +4,7 @@ import { formatBuild, formatComment, formatComments, + formatProject, formatReview, formatReviews, formatSnapshotSummary, @@ -49,6 +50,23 @@ describe("formatValue", () => { }); }); +describe("formatProject", () => { + it("summarizes the created project", () => { + const project = { + id: "project-1", + name: "my-app", + account: { id: "account-1", slug: "acme" }, + defaultBaseBranch: "main", + hasRemoteContentAccess: true, + } as ArgosAPISchema.components["schemas"]["Project"]; + const output = formatProject(project); + expect(output).toContain("Created project acme/my-app."); + expect(output).toContain("ID: project-1"); + expect(output).toContain("Account: acme"); + expect(output).toContain("Default base branch: main"); + }); +}); + describe("formatStats", () => { it("renders a dash when stats are missing", () => { expect(formatStats(null)).toBe("-"); diff --git a/packages/cli/src/lib/format.ts b/packages/cli/src/lib/format.ts index 3d962d58..4893500b 100644 --- a/packages/cli/src/lib/format.ts +++ b/packages/cli/src/lib/format.ts @@ -6,6 +6,7 @@ type SnapshotDiffStatus = SnapshotDiff["status"]; type BuildReview = ArgosAPISchema.components["schemas"]["BuildReview"]; type Comment = ArgosAPISchema.components["schemas"]["Comment"]; type User = ArgosAPISchema.components["schemas"]["User"]; +type Project = ArgosAPISchema.components["schemas"]["Project"]; /** Render a scalar, using `-` for empty values. */ export function formatValue(value: string | number | null | undefined): string { @@ -30,6 +31,16 @@ export function formatMe(user: User): string { ].join("\n"); } +export function formatProject(project: Project): string { + return [ + `Created project ${project.account.slug}/${project.name}.`, + `ID: ${project.id}`, + `Name: ${project.name}`, + `Account: ${project.account.slug}`, + `Default base branch: ${formatValue(project.defaultBaseBranch)}`, + ].join("\n"); +} + export function formatStats(stats: Build["stats"]): string { if (!stats) { return "-"; diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 72f2efbb..7008bd67 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -24,6 +24,12 @@ export const buildNameOption = new Option( "Name of the build, in case you want to run multiple Argos builds in a single CI job", ).env("ARGOS_BUILD_NAME"); +export type AccountOption = { account?: string | undefined }; +export const accountOption = new Option( + "--account ", + "Slug of the account (personal or team) that will own the project", +).env("ARGOS_ACCOUNT"); + export type JsonOption = { json?: boolean | undefined }; export const jsonOption = new Option( "--json", From 6ce6e01fb2e6a1cdb37f3183f1bfd618dc19c9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 10 Jul 2026 23:10:55 +0200 Subject: [PATCH 2/2] refactor(cli): drop commander env bindings, resolve env in config layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all commander `.env()` bindings from the CLI. Environment variables are now resolved in the config/target layer instead of by commander: - `ARGOS_PROJECT` for build-scoped commands is resolved in `target.ts` (alongside the existing `ARGOS_TOKEN` fallback). - `ARGOS_ACCOUNT` for `create-project` is resolved in the command. - Load `threshold` in `readConfig` so `ARGOS_THRESHOLD` (and `--threshold`) are honored again — it was in the schema but missing from the load block. Env vars for the upload/finalize/skip/deploy paths were already handled by core `readConfig`. Resolves ARG-456 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/create-project.ts | 2 +- packages/cli/src/commands/upload.ts | 13 ++++++------- packages/cli/src/lib/target.ts | 5 +++-- packages/cli/src/options.ts | 15 ++++++--------- packages/core/src/config.test.ts | 13 +++++++++++++ packages/core/src/config.ts | 1 + 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/create-project.ts b/packages/cli/src/commands/create-project.ts index 33592fbd..0dca2354 100644 --- a/packages/cli/src/commands/create-project.ts +++ b/packages/cli/src/commands/create-project.ts @@ -22,7 +22,7 @@ export function createProjectCommand(program: Command) { .addOption(jsonOption) .action(async (name: string, options: CreateProjectOptions) => { try { - const accountSlug = options.account; + const accountSlug = options.account || process.env["ARGOS_ACCOUNT"]; if (!accountSlug) { fail( "An account is required. Use --account or set ARGOS_ACCOUNT.", diff --git a/packages/cli/src/commands/upload.ts b/packages/cli/src/commands/upload.ts index 376c24c8..cddb5c1d 100644 --- a/packages/cli/src/commands/upload.ts +++ b/packages/cli/src/commands/upload.ts @@ -52,8 +52,7 @@ export function uploadCommand(program: Command) { "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.", ) .default("ci") - .choices(["ci", "monitoring"]) - .env("ARGOS_MODE"), + .choices(["ci", "monitoring"]), ) .addOption( new Option( @@ -65,32 +64,32 @@ export function uploadCommand(program: Command) { new Option( "--parallel-total ", "The number of parallel nodes being ran", - ).env("ARGOS_PARALLEL_TOTAL"), + ), ) .addOption(parallelNonceOption) .addOption( new Option( "--parallel-index ", "The index of the parallel node being ran (must be at least 1)", - ).env("ARGOS_PARALLEL_INDEX"), + ), ) .addOption( new Option( "--reference-branch ", "Branch used as baseline for screenshot comparison", - ).env("ARGOS_REFERENCE_BRANCH"), + ), ) .addOption( new Option( "--reference-commit ", "Commit used as baseline for screenshot comparison", - ).env("ARGOS_REFERENCE_COMMIT"), + ), ) .addOption( new Option( "--threshold ", "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5", - ).env("ARGOS_THRESHOLD"), + ), ) .addOption( new Option( diff --git a/packages/cli/src/lib/target.ts b/packages/cli/src/lib/target.ts index 72f75788..1085ef4c 100644 --- a/packages/cli/src/lib/target.ts +++ b/packages/cli/src/lib/target.ts @@ -55,8 +55,9 @@ function explicitProjectPath( if (reference.owner && reference.project) { return { owner: reference.owner, project: reference.project }; } - if (options.project) { - return parseProjectPathOrFail(options.project); + const project = options.project || process.env["ARGOS_PROJECT"]; + if (project) { + return parseProjectPathOrFail(project); } return null; } diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 7008bd67..dfbb839c 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -4,31 +4,28 @@ export type ParallelNonceOption = { parallelNonce?: string | undefined }; export const parallelNonceOption = new Option( "--parallel-nonce ", "A unique ID for this parallel build", -).env("ARGOS_PARALLEL_NONCE"); +); export type TokenOption = { token?: string | undefined }; -export const tokenOption = new Option( - "--token ", - "Repository token", -).env("ARGOS_TOKEN"); +export const tokenOption = new Option("--token ", "Repository token"); export type ProjectOption = { project?: string | undefined }; export const projectOption = new Option( "--project ", "Argos project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository", -).env("ARGOS_PROJECT"); +); export type BuildNameOption = { buildName?: string | undefined }; export const buildNameOption = new Option( "--build-name ", "Name of the build, in case you want to run multiple Argos builds in a single CI job", -).env("ARGOS_BUILD_NAME"); +); export type AccountOption = { account?: string | undefined }; export const accountOption = new Option( "--account ", "Slug of the account (personal or team) that will own the project", -).env("ARGOS_ACCOUNT"); +); export type JsonOption = { json?: boolean | undefined }; export const jsonOption = new Option( @@ -40,4 +37,4 @@ export type ProjectPathOption = { project?: string | undefined }; export const projectPathOption = new Option( "--project ", "Project path in owner/project format. Required for build-number references on review and comment commands", -).env("ARGOS_PROJECT"); +); diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 6010a6c8..b2830185 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -129,6 +129,19 @@ describe("#readConfig", () => { delete process.env.ARGOS_PARALLEL; }); + it("reads threshold from env", async () => { + process.env.ARGOS_THRESHOLD = "0.8"; + expect((await readDummyConfig()).threshold).toBe(0.8); + delete process.env.ARGOS_THRESHOLD; + }); + + it("threshold passed as argument takes priority over env variable", async () => { + process.env.ARGOS_THRESHOLD = "0.8"; + const config = await readDummyConfig({ threshold: 0.2 }); + expect(config.threshold).toBe(0.2); + delete process.env.ARGOS_THRESHOLD; + }); + it("reads merge queue pr numbers from env", async () => { process.env.ARGOS_MERGE_QUEUE_PRS = "101,102"; const config = await readDummyConfig(); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4c539240..c6c35ea5 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -457,6 +457,7 @@ export async function readConfig(options: Partial = {}) { options.referenceBranch || defaultConfig.referenceBranch || null, referenceCommit: options.referenceCommit || defaultConfig.referenceCommit || null, + threshold: options.threshold ?? defaultConfig.threshold ?? null, repository: ciEnv?.repository || null, originalRepository: ciEnv?.originalRepository || null, jobId: ciEnv?.jobId || null,