diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9ecf2c69d..049e19675 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -618,6 +618,8 @@ Connect a GitHub repository to a Void project for automatic deploys. On every pu The `--workflow` path is the workflow file the GitHub OIDC exchange authorizes to mint this project's deploy token. It defaults to the scaffolded `.github/workflows/void-deploy.yml`; set it only if your deploy step lives in a different workflow file (it must be under `.github/workflows/` and end in `.yml`/`.yaml`). Because the exchange is secretless, **only this one workflow** can mint a deploy token — any other workflow in the repo (including an unsafe `pull_request_target`) is rejected. Pointing it at a broad, multi-purpose workflow widens that trust, so prefer a dedicated file. +Interactively (TTY), after resolving the repository and branch, `void github connect` prompts for the **build executor** (defaulting to `container`). If you pick `github_actions`, it then prompts for the **deploy workflow file**, defaulting to `.github/workflows/void-deploy.yml` and validated locally before it is sent. Pass `--executor` and/or `--workflow` to skip the respective prompt. The workflow prompt is skipped entirely for the `container` executor, which does not use a workflow file. + **Non-interactive use (CI)** When stdin is not a TTY, `void github connect` never prompts — it fails closed and names any flag it needs. `--branch` is always required. `--project` must be resolvable (positional / `--project` / `VOID_PROJECT` / linked `.void/project.json`). `--installation` is required only when your account has more than one installation; otherwise the sole installation is used. `--repo` is required when the installation grants access to all repos or to more than one selected repo; when it grants exactly one repo that repo is used automatically. Use `void github installations` to discover the `installation_id`. @@ -648,7 +650,7 @@ Update an existing connection's deploy **branch**, **build executor**, and/or au | `--executor ` | New build executor: `container` or `github_actions` | | `--workflow ` | New authorized deploy workflow file (under `.github/workflows/`) | -Interactively (TTY), `void github update` shows the current branch and executor and prompts for new values, defaulting each to the current setting. `--workflow` is flag-only — it is never prompted for; pass it explicitly to change the authorized workflow file. When stdin is not a TTY, it never prompts: pass at least one of `--branch` / `--executor` / `--workflow`, or it fails closed. If nothing actually changes, the command reports "no changes" and does not call the API. +Interactively (TTY), `void github update` shows the current branch and executor and prompts for new values, defaulting each to the current setting. If the resulting executor is `github_actions`, it then prompts for the **deploy workflow file**, defaulting to the current path and validated locally; pass `--workflow` to skip that prompt. The workflow prompt is skipped when the executor is `container`, which does not use a workflow file. When stdin is not a TTY, it never prompts: pass at least one of `--branch` / `--executor` / `--workflow`, or it fails closed. If nothing actually changes, the command reports "no changes" and does not call the API. ``` void github update my-app --executor github_actions @@ -656,6 +658,26 @@ void github update my-app --executor github_actions **Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). +### `void github status` + +``` +void github status [project] +``` + +Show a project's current GitHub connection: the connected **repository**, the deploy **branch**, the **build executor** (`container` or `github_actions`), and the authorized **deploy workflow file**. Read-only — it never changes anything. The workflow file is the OIDC pin used only for `github_actions` builds; on a `container` connection it is still shown but marked unused. The project must already be connected (run `void github connect` first, otherwise it reports that and exits). + +**Options** + +| Flag | Description | +| ------------------ | ------------------------------------------------ | +| `--project ` | Project name (alias for the positional argument) | + +``` +void github status my-app +``` + +**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). + ## Build Inspect Deploy-on-GitHub builds. diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 3b0498946..620f9e977 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -206,7 +206,7 @@ if (helpPath) { }, { name: 'github', - hint: 'install | installations | connect | update | link', + hint: 'install | installations | connect | update | status | link', description: 'Connect a GitHub repository for automatic deploys', }, { diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts index 7ba44d092..9fe051c87 100644 --- a/packages/void/src/cli/github-args.ts +++ b/packages/void/src/cli/github-args.ts @@ -13,6 +13,11 @@ export const githubSubcommands: Array = [ arg: '[project]', description: "Update an existing connection's branch, build executor, or deploy workflow", }, + { + name: 'status', + arg: '[project]', + description: "Show a project's GitHub connection (repo, branch, executor, workflow)", + }, { name: 'link', description: 'Link your GitHub account to Void' }, ]; @@ -35,6 +40,10 @@ export type GithubArgs = branch?: string; executor?: 'container' | 'github_actions'; workflowPath?: string; + } + | { + subcommand: 'status'; + projectName: string; }; export function parseGithubArgs(args: Array): GithubArgs { @@ -209,5 +218,34 @@ export function parseGithubArgs(args: Array): GithubArgs { return { subcommand, projectName, branch, executor, workflowPath }; } - throw new Error('github: Usage: void github .'); + if (subcommand === 'status') { + let projectName = ''; + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === '--project') { + if (i + 1 >= rest.length) { + throw new Error('github: --project requires a value.'); + } + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --project requires a value.'); + } + if (projectName) { + throw new Error(`github: Unexpected argument '${value}'.`); + } + projectName = value; + } else if (arg.startsWith('-')) { + throw new Error(`github: Unexpected argument '${arg}'.`); + } else if (projectName) { + throw new Error(`github: Unexpected argument '${arg}'.`); + } else { + projectName = arg; + } + } + + return { subcommand, projectName }; + } + + throw new Error('github: Usage: void github .'); } diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index 7f00804a2..8f4941969 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -24,6 +24,27 @@ import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project // Pure helpers (exported for unit tests) // --------------------------------------------------------------------------- +/** + * The workflow file `void init --github` scaffolds and the platform default for + * a connection's OIDC deploy pin. Kept in sync with the server default + * (`github_connections.workflow_path`) and the `init.ts` scaffolder. + */ +export const DEFAULT_WORKFLOW_PATH = '.github/workflows/void-deploy.yml'; + +/** + * Client-side mirror of the server's `isValidWorkflowPath` (packages/api + * routes/github.ts). Used to reject a bad path at the interactive prompt before + * an API round-trip. The server re-validates, so this is DX only, not security. + */ +export function isValidWorkflowPath(path: string): boolean { + return ( + path.length > 0 && + path.startsWith('.github/workflows/') && + (path.endsWith('.yml') || path.endsWith('.yaml')) && + !path.includes('..') + ); +} + /** * Map a PlatformApiError status + error code string to a friendly user message. * Returns null for unrecognised cases (caller should re-throw). @@ -111,6 +132,26 @@ export function formatInstallations(installations: Array): s .join('\n\n'); } +/** + * Render a GitHub connection as an aligned key/value block for `void github + * status`. The workflow file is the OIDC deploy pin — it is only read for + * github_actions builds (container builds never use it), so it is annotated as + * unused when the executor is container to avoid implying it is active. + */ +export function formatConnection(connection: GithubConnection): string { + const row = (label: string, value: string): string => ` ${label.padEnd(16)}${value}`; + const workflow = + connection.build_executor === 'github_actions' + ? connection.workflow_path + : `${connection.workflow_path} (unused for container builds)`; + return [ + row('Repository', connection.repo_full_name), + row('Branch', connection.branch), + row('Build executor', connection.build_executor), + row('Deploy workflow', workflow), + ].join('\n'); +} + // --------------------------------------------------------------------------- // Main command // --------------------------------------------------------------------------- @@ -132,6 +173,8 @@ export async function runGithubCommand(root: string, args: GithubArgs): Promise< return runConnect(root, client, args); case 'update': return runGithubUpdate(root, client, args); + case 'status': + return runStatus(root, client, args); case 'link': return runGithubLink(root, client); } @@ -451,12 +494,67 @@ async function runConnect( } // ------------------------------------------------------------------------- - // 5. Executor + // 5. Executor (build backend). Non-interactive keeps the historical silent + // default of `container`; TTY prompts so a first-time connect is explicit. // ------------------------------------------------------------------------- - const executor = args.executor ?? 'container'; + let executor: 'container' | 'github_actions'; + if (args.executor !== undefined) { + executor = args.executor; + } else if (!isTTY) { + executor = 'container'; + } else { + const picked = await select({ + message: 'Build executor:', + options: [ + { value: 'container', label: 'Cloudflare Container', hint: 'build on Void (default)' }, + { value: 'github_actions', label: 'GitHub Actions', hint: "build in your repo's runner" }, + ], + initialValue: 'container', + }); + if (isCancel(picked)) { + cancel('Cancelled.'); + process.exit(0); + } + executor = picked as 'container' | 'github_actions'; + } // ------------------------------------------------------------------------- - // 6. Connect + // 6. Workflow file (github_actions only — this is the per-connection OIDC + // deploy pin; container builds never read it). Flag wins; otherwise TTY + // prompts, defaulting to the scaffolded workflow. + // + // Only carry a prompted value forward when it DIFFERS from the default, so + // accepting the default leaves `workflowPath` undefined → the field is not + // sent → the server applies its own (identical) default. This keeps the + // fail-closed echo check below from firing against an older platform that + // silently drops the field and returns its default. + // ------------------------------------------------------------------------- + let workflowPath = args.workflowPath; + if (executor === 'github_actions' && args.workflowPath === undefined && isTTY) { + const input = await text({ + message: 'Deploy workflow file:', + placeholder: DEFAULT_WORKFLOW_PATH, + initialValue: DEFAULT_WORKFLOW_PATH, + validate(value) { + const v = (value ?? '').trim(); + if (v.length === 0 || isValidWorkflowPath(v)) { + return; + } + return 'Must be under .github/workflows/, end in .yml/.yaml, and not contain "..".'; + }, + }); + if (isCancel(input)) { + cancel('Cancelled.'); + process.exit(0); + } + const chosen = (input as string).trim() || DEFAULT_WORKFLOW_PATH; + if (chosen !== DEFAULT_WORKFLOW_PATH) { + workflowPath = chosen; + } + } + + // ------------------------------------------------------------------------- + // 7. Connect // ------------------------------------------------------------------------- let connection: GithubConnection; try { @@ -465,7 +563,7 @@ async function runConnect( repoFullName, branch, buildExecutor: executor, - workflowPath: args.workflowPath, + workflowPath, }); } catch (err) { if (err instanceof PlatformApiError) { @@ -481,13 +579,15 @@ async function runConnect( throw err; } - // Fail closed when an explicit --workflow was not applied. An older platform - // that predates workflow_path silently drops the field and returns success - // while staying pinned to the default, so the user would wrongly believe their - // custom workflow is authorized to deploy. Verify the server echoed it back. - if (args.workflowPath !== undefined && connection.workflow_path !== args.workflowPath) { + // Fail closed when a custom workflow path (from --workflow or the prompt) was + // not applied. An older platform that predates workflow_path silently drops + // the field and returns success while staying pinned to the default, so the + // user would wrongly believe their custom workflow is authorized to deploy. + // Verify the server echoed it back. (Accepting the default leaves workflowPath + // undefined, so this check is skipped — see step 6.) + if (workflowPath !== undefined && connection.workflow_path !== workflowPath) { log.error( - `connect: server did not apply --workflow ${args.workflowPath} (got ${connection.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, + `connect: server did not apply workflow path ${workflowPath} (got ${connection.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, ); process.exit(1); } @@ -591,6 +691,9 @@ export async function runGithubUpdate( // ------------------------------------------------------------------------- let branch: string; let executor: 'container' | 'github_actions'; + // Effective workflow path — the flag wins; a github_actions TTY prompt (below) + // can override it; otherwise it stays the current value (no change). + let workflowPath = args.workflowPath ?? current.workflow_path; if (isTTY) { if (args.branch !== undefined) { @@ -625,9 +728,32 @@ export async function runGithubUpdate( } executor = picked as 'container' | 'github_actions'; } + + // Prompt for the deploy workflow file when the resulting executor is + // github_actions and --workflow was not passed. Defaults to the current + // path; container builds never read workflow_path so there is nothing to ask. + if (executor === 'github_actions' && args.workflowPath === undefined) { + const input = await text({ + message: 'Deploy workflow file:', + placeholder: current.workflow_path, + initialValue: current.workflow_path, + validate(value) { + const v = (value ?? '').trim(); + if (v.length === 0 || isValidWorkflowPath(v)) { + return; + } + return 'Must be under .github/workflows/, end in .yml/.yaml, and not contain "..".'; + }, + }); + if (isCancel(input)) { + cancel('Cancelled.'); + process.exit(0); + } + workflowPath = (input as string).trim() || current.workflow_path; + } } else { // The missing-flags guard at the top of this function already guaranteed at - // least one of --branch / --executor is present in non-TTY mode. + // least one of --branch / --executor / --workflow is present in non-TTY mode. branch = args.branch ?? current.branch; executor = args.executor ?? current.build_executor; } @@ -646,10 +772,11 @@ export async function runGithubUpdate( if (executor !== current.build_executor) { patch.buildExecutor = executor; } - // workflow_path is flag-only (no interactive prompt): change it only when - // --workflow was passed AND it differs from the stored value. - if (args.workflowPath !== undefined && args.workflowPath !== current.workflow_path) { - patch.workflowPath = args.workflowPath; + // Change workflow_path only when the effective value (from --workflow or the + // github_actions prompt above) differs from the stored value. Accepting the + // current value at the prompt leaves it unchanged, so nothing is sent. + if (workflowPath !== current.workflow_path) { + patch.workflowPath = workflowPath; } // ------------------------------------------------------------------------- @@ -686,12 +813,13 @@ export async function runGithubUpdate( throw err; } - // Fail closed when an explicit --workflow was not applied — same old-platform - // compatibility hazard as connect: the server could return success while - // ignoring the unknown field, leaving the workflow pin unchanged. + // Fail closed when a custom workflow path (from --workflow or the prompt) was + // not applied — same old-platform compatibility hazard as connect: the server + // could return success while ignoring the unknown field, leaving the workflow + // pin unchanged. if (patch.workflowPath !== undefined && updated.workflow_path !== patch.workflowPath) { log.error( - `update: server did not apply --workflow ${patch.workflowPath} (got ${updated.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, + `update: server did not apply workflow path ${patch.workflowPath} (got ${updated.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, ); process.exit(1); } @@ -700,3 +828,86 @@ export async function runGithubUpdate( `Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}, workflow ${updated.workflow_path}.`, ); } + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +/** + * `void github status [project]` — print the current GitHub connection (repo, + * branch, build executor, deploy workflow). Read-only; never mutates. Project + * resolution mirrors connect/update; a 404 means the project was never + * connected and points the user at `connect`. + */ +export async function runStatus( + root: string, + client: PlatformClient, + args: Extract, +): Promise { + const isTTY = process.stdin.isTTY === true; + + // ------------------------------------------------------------------------- + // 1. Resolve project (identical resolution order to connect/update). + // ------------------------------------------------------------------------- + let projectId: string; + + const requestedSlug = getRequestedProjectSlug({ projectSlug: args.projectName || undefined }); + + if (requestedSlug) { + const projects = await client.listProjects(); + const resolved = resolveProjectBySlug(projects, requestedSlug); + if (!resolved) { + log.error(`status: No project found with slug '${requestedSlug}'.`); + process.exit(1); + } + projectId = resolved.projectId; + } else { + const linked = readProjectConfig(root); + if (linked) { + projectId = linked.projectId; + } else if (!isTTY) { + log.error( + 'status: No project specified. Pass a project name or --project , set VOID_PROJECT, or link one with `void project link`.', + ); + process.exit(1); + } else { + const projects = await client.listProjects(); + if (projects.length === 0) { + log.error('No projects found. Create one first with `void deploy`.'); + process.exit(1); + } + const picked = await select({ + message: 'Select a project:', + options: projects.map((p) => ({ value: p.id, label: p.slug })), + }); + if (isCancel(picked)) { + cancel('Cancelled.'); + process.exit(0); + } + projectId = picked as string; + } + } + + // ------------------------------------------------------------------------- + // 2. Fetch and display the connection. A missing connection (404) means the + // project was never connected — reuse the update 404 mapping to point the + // user at `connect` instead of a raw error. + // ------------------------------------------------------------------------- + let connection: GithubConnection; + try { + connection = await client.getGithubConnection(projectId); + } catch (err) { + if (err instanceof PlatformApiError) { + const body = parsePlatformErrorBody(err.body); + const code = body?.error ?? body?.message ?? ''; + const message = describeUpdateError(err.status, code); + if (message !== null) { + log.error(`status: ${message}`); + process.exit(1); + } + } + throw err; + } + + log.message(formatConnection(connection)); +} diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 629eacb3f..a7cb380d2 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -204,6 +204,10 @@ const HELP_TREE: HelpTree = { command: 'update [project]', description: "Update an existing connection's branch, build executor, or deploy workflow", }, + { + command: 'status [project]', + description: "Show a project's GitHub connection (repo, branch, executor, workflow)", + }, { command: 'link', description: 'Link your GitHub account to Void' }, ], children: { @@ -229,12 +233,13 @@ const HELP_TREE: HelpTree = { }, { command: '--executor ', - description: 'Build executor: container (default) | github_actions', + description: + 'Build executor: container (default) | github_actions; prompted interactively when omitted', }, { command: '--workflow ', description: - 'Authorized deploy workflow file (default .github/workflows/void-deploy.yml)', + 'Authorized deploy workflow file (default .github/workflows/void-deploy.yml); prompted interactively when using github_actions', }, ], examples: [ @@ -246,6 +251,11 @@ const HELP_TREE: HelpTree = { command: 'void github connect my-app --installation 42 --repo owner/repo --branch main', description: 'Non-interactive connect (all flags explicit)', }, + { + command: + 'void github connect my-app --repo owner/repo --branch main --executor github_actions', + description: 'Non-interactive connect using the GitHub Actions executor', + }, ], }, update: { @@ -256,11 +266,13 @@ const HELP_TREE: HelpTree = { { command: '--branch ', description: 'New branch to deploy from' }, { command: '--executor ', - description: 'New build executor: container | github_actions', + description: + 'New build executor: container | github_actions; prompted interactively when omitted', }, { command: '--workflow ', - description: 'New authorized deploy workflow file (under .github/workflows/)', + description: + 'New authorized deploy workflow file under .github/workflows/; prompted interactively when using github_actions', }, ], examples: [ @@ -274,6 +286,23 @@ const HELP_TREE: HelpTree = { }, ], }, + status: { + summary: "Show a project's GitHub connection settings.", + usage: ['void github status [project] [options]'], + options: [ + { command: '--project ', description: 'Project name (alias for positional)' }, + ], + examples: [ + { + command: 'void github status', + description: "Show the linked project's connection", + }, + { + command: 'void github status my-app', + description: 'Show a specific project connection', + }, + ], + }, }, }, build: { diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index 263913363..f9fba8d32 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -57,6 +57,7 @@ describe('CLI help resolver', () => { expect(getHelpPath(['help', 'github', 'installations'])).toEqual(['github', 'installations']); expect(getHelpPath(['help', 'github', 'connect'])).toEqual(['github', 'connect']); expect(getHelpPath(['github', 'connect', '--help'])).toEqual(['github', 'connect']); + expect(getHelpPath(['github', 'status', '--help'])).toEqual(['github', 'status']); }); it('tracks known github help pages', () => { @@ -64,6 +65,7 @@ describe('CLI help resolver', () => { expect(hasHelpPage(['github', 'install'])).toBe(true); expect(hasHelpPage(['github', 'installations'])).toBe(true); expect(hasHelpPage(['github', 'connect'])).toBe(true); + expect(hasHelpPage(['github', 'status'])).toBe(true); expect(hasHelpPage(['github', 'missing'])).toBe(false); }); }); diff --git a/packages/void/test/unit/github-args.test.ts b/packages/void/test/unit/github-args.test.ts index df2be5438..98c8f8fbf 100644 --- a/packages/void/test/unit/github-args.test.ts +++ b/packages/void/test/unit/github-args.test.ts @@ -463,6 +463,59 @@ describe('parseGithubArgs', () => { }); }); + describe('status', () => { + it('parses status with no args', () => { + expect(parseGithubArgs(['status'])).toEqual({ + subcommand: 'status', + projectName: '', + }); + }); + + it('parses status with a project name', () => { + expect(parseGithubArgs(['status', 'my-project'])).toEqual({ + subcommand: 'status', + projectName: 'my-project', + }); + }); + + it('parses --project flag', () => { + expect(parseGithubArgs(['status', '--project', 'my-project'])).toEqual({ + subcommand: 'status', + projectName: 'my-project', + }); + }); + + it('--project flag is an alias for the positional', () => { + const positional = parseGithubArgs(['status', 'my-project']); + const flag = parseGithubArgs(['status', '--project', 'my-project']); + expect(flag).toEqual(positional); + }); + + it('rejects --project when positional already set', () => { + expect(() => parseGithubArgs(['status', 'my-project', '--project', 'other'])).toThrow( + "github: Unexpected argument 'other'.", + ); + }); + + it('rejects --project with no value', () => { + expect(() => parseGithubArgs(['status', '--project'])).toThrow( + 'github: --project requires a value.', + ); + }); + + it('rejects an unknown flag', () => { + expect(() => parseGithubArgs(['status', '--nope'])).toThrow( + "github: Unexpected argument '--nope'.", + ); + }); + + it('rejects an extra positional after project name', () => { + expect(() => parseGithubArgs(['status', 'my-project', 'extra'])).toThrow( + "github: Unexpected argument 'extra'.", + ); + }); + }); + describe('link', () => { it('parses link with no args', () => { expect(parseGithubArgs(['link'])).toEqual({ subcommand: 'link' }); @@ -484,13 +537,13 @@ describe('parseGithubArgs', () => { describe('unknown subcommand', () => { it('throws on empty args', () => { expect(() => parseGithubArgs([])).toThrow( - 'github: Usage: void github .', + 'github: Usage: void github .', ); }); it('throws on unknown subcommand', () => { expect(() => parseGithubArgs(['unknown'])).toThrow( - 'github: Usage: void github .', + 'github: Usage: void github .', ); }); }); diff --git a/packages/void/test/unit/github-cmd.test.ts b/packages/void/test/unit/github-cmd.test.ts index c8e6ed5de..d7dc704ac 100644 --- a/packages/void/test/unit/github-cmd.test.ts +++ b/packages/void/test/unit/github-cmd.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vite-plus/test'; -import type { GithubInstallation } from '../../src/cli/client.ts'; +import type { GithubConnection, GithubInstallation } from '../../src/cli/client.ts'; import { + DEFAULT_WORKFLOW_PATH, describeConnectError, describeUpdateError, + formatConnection, formatInstallations, + isValidWorkflowPath, parseGithubRepoFullName, } from '../../src/cli/github-cmd.ts'; @@ -72,6 +75,80 @@ describe('describeUpdateError', () => { }); }); +describe('isValidWorkflowPath', () => { + it('accepts the scaffolded default path', () => { + expect(isValidWorkflowPath(DEFAULT_WORKFLOW_PATH)).toBe(true); + }); + + it('accepts a custom .yml path under .github/workflows/', () => { + expect(isValidWorkflowPath('.github/workflows/deploy-prod.yml')).toBe(true); + }); + + it('accepts a .yaml extension', () => { + expect(isValidWorkflowPath('.github/workflows/ci.yaml')).toBe(true); + }); + + it('rejects an empty string', () => { + expect(isValidWorkflowPath('')).toBe(false); + }); + + it('rejects a path outside .github/workflows/', () => { + expect(isValidWorkflowPath('scripts/deploy.yml')).toBe(false); + }); + + it('rejects a non-yaml extension', () => { + expect(isValidWorkflowPath('.github/workflows/deploy.txt')).toBe(false); + }); + + it('rejects a path containing ".."', () => { + expect(isValidWorkflowPath('.github/workflows/../../evil.yml')).toBe(false); + }); +}); + +function makeConnection(overrides: Partial = {}): GithubConnection { + return { + id: 'conn_1', + project_id: 'proj_1', + installation_id: '42', + repo_full_name: 'acme/my-app', + repository_id: '99', + branch: 'main', + build_executor: 'github_actions', + workflow_path: '.github/workflows/void-deploy.yml', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('formatConnection', () => { + it('shows repo, branch, executor, and workflow path', () => { + const out = formatConnection(makeConnection()); + expect(out).toContain('acme/my-app'); + expect(out).toContain('main'); + expect(out).toContain('github_actions'); + expect(out).toContain('.github/workflows/void-deploy.yml'); + }); + + it('does not mark the workflow unused for github_actions', () => { + expect(formatConnection(makeConnection())).not.toContain('unused'); + }); + + it('marks the workflow unused for the container executor', () => { + const out = formatConnection(makeConnection({ build_executor: 'container' })); + expect(out).toContain('container'); + expect(out).toContain('unused for container builds'); + }); + + it('labels each field', () => { + const out = formatConnection(makeConnection()); + expect(out).toContain('Repository'); + expect(out).toContain('Branch'); + expect(out).toContain('Build executor'); + expect(out).toContain('Deploy workflow'); + }); +}); + describe('parseGithubRepoFullName', () => { it('extracts owner/repo from a plain https GitHub URL', () => { expect(parseGithubRepoFullName('https://github.com/acme/my-app')).toBe('acme/my-app');