diff --git a/README.md b/README.md index 0114e27..b4c308e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Slack

-A CLI for helping tech writers. Right now it only detects the rhetorical and structural tells of LLM-generated prose and surfaces diagnostics for people to fix. +A CLI for technical writers: set up Promptless for your repositories, detect the rhetorical and structural tells of LLM-generated prose, and inspect documentation as an agent sees it. ## Install @@ -41,6 +41,15 @@ npm install npm run promptless -- slop-cop path/to/file.md ``` +Before the first npm release, run setup from this checkout: + +```sh +npm ci +npm run promptless -- setup --new-account +``` + +Omit `--new-account` to reuse an existing Promptless account. Use `--no-browser` to print the authorization link for another browser. + ## Usage ``` @@ -51,6 +60,49 @@ Run `promptless --help` for the command list and `promptless --help` f `slop-cop` exit codes: `0` clean, `1` violations found, `2` argument error. +### `setup` + +`promptless setup` connects a GitHub organization to Promptless and starts keeping one documentation repository current from merged pull requests. Run it in an interactive terminal, ideally from a clone of a repository whose merges should update the docs: + +```sh +npx -y @promptless/cli@latest setup +``` + +The command runs these steps in order: + +1. **Sign in.** Setup reuses the saved API key when it still works. Otherwise it opens the sign-in page in your browser and waits for the key, as `promptless login` does. +2. **Connect GitHub.** When the organization has no GitHub App installation, setup opens the install page and checks every 3 seconds until the installation arrives, for up to 30 minutes. Choose "I can't connect right now" to stop; nothing is changed. +3. **Find your documentation.** Setup reads the `origin` remote of the current directory, asks Promptless which installed repository holds the docs, and preselects the best match. +4. **Choose source repositories.** Pick the repositories whose merged pull requests update the docs. The current directory's repository is preselected. +5. **Review.** Setup shows the resulting `promptless.yaml` and applies it only after you choose Apply. Setup then completes onboarding, which starts the free trial. +6. **First suggestion.** Setup offers to run Promptless on the last 7 days of merged pull requests in the source repositories. It starts the run and does not wait for it. + +| Flag | Effect | +| --- | --- | +| `--org ` | Run against this organization. When the saved key belongs to another organization, setup signs in again with this organization preselected on the sign-in page. | +| `--docs-repo ` | Use this documentation repository instead of asking. | +| `--source-repo ` | Watch this repository instead of asking. Repeat the flag for several repositories. | +| `--trigger-on ` | The pull request events that update the docs: `opened`, `first_approval`, `merge`, or `updated`. Repeat the flag or separate events with commas. The default is `merge`. | +| `--backfill` | Run on the last 7 days of merged pull requests without asking. | +| `--new-account` | Open the sign-up page instead of the sign-in page. | +| `--no-browser` | Print links instead of opening them. | + +**What setup writes.** Setup writes nothing to the current repository. Promptless adds one doc collection and one `github_pr` trigger with an explicit repository list to `promptless.yaml` in your organization's Promptless knowledge-base repository. The only local file setup writes is the API key in the `promptless login` config file. + +**Running setup again.** Setup skips every step that is already done: a working saved key, an existing GitHub installation, an existing doc collection and pull request trigger, and completed onboarding. A second run on a finished organization changes nothing and prints the dashboard link. To watch more repositories, run it again with `--source-repo`. + +**Errors.** Setup exits `0` when it finishes, `1` when it stops, and `2` for an argument error. Every stop also prints one line on stderr that a script can parse: + +``` +promptless-error: {"code":"github_not_connected","message":"..."} +``` + +The codes are listed in `src/lib/errors.ts`. Codes are only ever added. Without an interactive terminal, setup exits `1` with the code `interactive_terminal_required`. + +`PROMPTLESS_API_BASE_URL` and `PROMPTLESS_APP_BASE_URL` point setup at a local runtime and dashboard. + +**Server requirements.** The CLI requires the setup status, docs-candidate, and apply routes, CLI organization creation for new users, and onboarding completion that starts the trial. These changes are tracked in [Promptless/promptless#5372](https://github.com/Promptless/promptless/pull/5372), [#5358](https://github.com/Promptless/promptless/pull/5358), and [#5373](https://github.com/Promptless/promptless/pull/5373). Optional backfill requires [#5371](https://github.com/Promptless/promptless/pull/5371). Deploy the backend changes before releasing this command. When the setup status route is missing, setup exits with `setup_unavailable` and directs you to the dashboard. + ## Releasing Pushing a `v*` tag whose version matches `package.json` runs `.github/workflows/release.yml`, which publishes to npm with provenance and creates a GitHub release. diff --git a/package.json b/package.json index 0dbbae6..ba1f916 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@promptless/cli", "version": "0.1.0", "license": "MPL-2.0", - "description": "CLI for helping tech writers. Right now it only detects the rhetorical and structural tells of LLM-generated prose and surfaces diagnostics for people to fix.", + "description": "CLI for technical writers: set up Promptless, detect LLM prose tells, and inspect documentation.", "type": "module", "repository": { "type": "git", diff --git a/src/commands/completion.ts b/src/commands/completion.ts index 6bef32e..8f1bb99 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -88,6 +88,15 @@ case $state in '(-h --help)'{-h,--help}'[show help]' ;; setup) + _arguments \\ + '--org[organization id]:org:' \\ + '--docs-repo[documentation repository]:owner/repo:' \\ + '*--source-repo[source repository]:owner/repo:' \\ + '*--trigger-on[pull request event]:event:(opened first_approval merge updated)' \\ + '--backfill[run on the last 7 days of merged pull requests]' \\ + '--new-account[open the sign-up page]' \\ + '--no-browser[do not open a browser; print URLs only]' \\ + '(-h --help)'{-h,--help}'[show help]' ;; esac ;; @@ -140,7 +149,15 @@ _promptless_complete() { fi ;; setup) - COMPREPLY=() + case "\${COMP_WORDS[COMP_CWORD-1]}" in + --trigger-on) + COMPREPLY=($(compgen -W "opened first_approval merge updated" -- "$cur")) + return + ;; + esac + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--org --docs-repo --source-repo --trigger-on --backfill --new-account --no-browser -h --help" -- "$cur")) + fi ;; esac } @@ -176,6 +193,14 @@ function fishScript(): string { "complete -c promptless -n '__fish_seen_subcommand_from logout' -s h -l help -d 'Show help'", "complete -c promptless -n '__fish_seen_subcommand_from whoami' -l json -d 'Output JSON'", "complete -c promptless -n '__fish_seen_subcommand_from whoami' -s h -l help -d 'Show help'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l org -x -d 'Organization id'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l docs-repo -x -d 'Documentation repository (owner/repo)'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l source-repo -x -d 'Source repository (owner/repo)'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l trigger-on -x -a 'opened first_approval merge updated' -d 'Pull request event'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l backfill -d 'Run on the last 7 days of merged pull requests'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l new-account -d 'Open the sign-up page'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -l no-browser -d 'Do not open a browser'", + "complete -c promptless -n '__fish_seen_subcommand_from setup' -s h -l help -d 'Show help'", '', ) return lines.join('\n') diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 1543734..e0292ca 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -15,7 +15,7 @@ export interface CommandEntry { export const COMMANDS: CommandEntry[] = [ { name: 'setup', - summary: 'Connect GitHub and start keeping your docs current (not available yet)', + summary: 'Connect GitHub and start keeping your docs current', run: runSetup, }, { diff --git a/src/commands/setup/args.test.ts b/src/commands/setup/args.test.ts new file mode 100644 index 0000000..9d03acc --- /dev/null +++ b/src/commands/setup/args.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parseSetupArgs } from './args' +import { SetupError } from './output' + +function flags(argv: string[]) { + const parsed = parseSetupArgs(argv) + assert.equal(parsed.kind, 'run') + return parsed.kind === 'run' ? parsed.flags : null +} + +function rejects(argv: string[]): void { + assert.throws( + () => parseSetupArgs(argv), + (err) => err instanceof SetupError && err.code === 'bad_arguments', + ) +} + +describe('parseSetupArgs', () => { + it('defaults to merge triggers, an opened browser, and no overrides', () => { + assert.deepEqual(flags([]), { + org: null, + docsRepo: null, + sourceRepos: [], + triggerOn: ['merge'], + newAccount: false, + openBrowser: true, + backfill: false, + }) + }) + + it('reads every flag in both value forms and repeats --source-repo', () => { + assert.deepEqual( + flags([ + '--org=org_1', + '--docs-repo', + 'acme/docs', + '--source-repo', + 'acme/api', + '--source-repo=acme/web', + '--trigger-on', + 'opened,merge', + '--trigger-on=merge', + '--new-account', + '--no-browser', + '--backfill', + ]), + { + org: 'org_1', + docsRepo: 'acme/docs', + sourceRepos: ['acme/api', 'acme/web'], + triggerOn: ['opened', 'merge'], + newAccount: true, + openBrowser: false, + backfill: true, + }, + ) + }) + + it('returns help for -h and --help', () => { + assert.deepEqual(parseSetupArgs(['--org', 'x', '-h']), { kind: 'help' }) + assert.deepEqual(parseSetupArgs(['--help']), { kind: 'help' }) + }) + + it('rejects unknown flags, missing values, bad repositories, and bad events', () => { + rejects(['--ci']) + rejects(['--org']) + rejects(['--org', '--backfill']) + rejects(['--docs-repo=']) + rejects(['--docs-repo', 'not-a-repo']) + rejects(['--source-repo', 'https://github.com/acme/api']) + rejects(['--trigger-on', 'closed']) + }) +}) diff --git a/src/commands/setup/args.ts b/src/commands/setup/args.ts new file mode 100644 index 0000000..ea63014 --- /dev/null +++ b/src/commands/setup/args.ts @@ -0,0 +1,105 @@ +import { PR_LIFECYCLE_EVENTS } from '../../lib/setup-api' +import type { PrLifecycleEvent } from '../../lib/setup-api' +import { SetupError } from './output' + +export interface SetupFlags { + org: string | null + docsRepo: string | null + sourceRepos: string[] + triggerOn: PrLifecycleEvent[] + newAccount: boolean + openBrowser: boolean + backfill: boolean +} + +export type ParsedArgs = { kind: 'help' } | { kind: 'run'; flags: SetupFlags } + +const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/ +const VALUE_FLAGS = new Set(['--org', '--docs-repo', '--source-repo', '--trigger-on']) + +function badArguments(message: string): SetupError { + return new SetupError('bad_arguments', message) +} + +function repoValue(flag: string, value: string): string { + if (!REPO_PATTERN.test(value)) throw badArguments(`${flag} expects owner/repo, got '${value}'`) + return value +} + +function lifecycleEvents(value: string): PrLifecycleEvent[] { + return value.split(',').map((part) => { + const event = PR_LIFECYCLE_EVENTS.find((candidate) => candidate === part.trim()) + if (event === undefined) { + throw badArguments(`--trigger-on expects ${PR_LIFECYCLE_EVENTS.join(', ')}; got '${part.trim()}'`) + } + return event + }) +} + +/** + * Parse `promptless setup` arguments. Value flags take `--flag value` or + * `--flag=value`; `--source-repo` and `--trigger-on` repeat, and + * `--trigger-on` also takes a comma-separated list. + * + * @throws SetupError with code `bad_arguments` for an unknown flag or a bad value. + */ +export function parseSetupArgs(argv: string[]): ParsedArgs { + const flags: SetupFlags = { + org: null, + docsRepo: null, + sourceRepos: [], + triggerOn: [], + newAccount: false, + openBrowser: true, + backfill: false, + } + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '-h' || arg === '--help') return { kind: 'help' } + if (arg === '--new-account') { + flags.newAccount = true + continue + } + if (arg === '--no-browser') { + flags.openBrowser = false + continue + } + if (arg === '--backfill') { + flags.backfill = true + continue + } + + const eq = arg.indexOf('=') + const name = eq >= 0 ? arg.slice(0, eq) : arg + if (!VALUE_FLAGS.has(name)) throw badArguments(`unknown option ${arg}`) + let value: string + if (eq >= 0) { + value = arg.slice(eq + 1) + } else { + const next = argv[i + 1] + if (next === undefined || next.startsWith('--')) throw badArguments(`${name} needs a value`) + value = next + i++ + } + if (value.length === 0) throw badArguments(`${name} needs a value`) + + switch (name) { + case '--org': + flags.org = value + break + case '--docs-repo': + flags.docsRepo = repoValue(name, value) + break + case '--source-repo': + flags.sourceRepos.push(repoValue(name, value)) + break + case '--trigger-on': + flags.triggerOn.push(...lifecycleEvents(value)) + break + } + } + + flags.triggerOn = flags.triggerOn.length === 0 ? ['merge'] : [...new Set(flags.triggerOn)] + return { kind: 'run', flags } +} diff --git a/src/commands/setup/backfill.ts b/src/commands/setup/backfill.ts new file mode 100644 index 0000000..77fa18a --- /dev/null +++ b/src/commands/setup/backfill.ts @@ -0,0 +1,90 @@ +import { ApiError } from '../../lib/api' +import { getBackfillStatus, previewBackfill, startBackfill } from '../../lib/setup-api' +import type { BackfillRequest } from '../../lib/setup-api' +import { selectOne } from '../../lib/ui/prompt' +import type { Session } from './login' +import { done, note, step, warn } from './output' + +// Screen 7: offer a backfill over the last week of merged pull requests, so a +// first suggestion arrives without waiting for the next merge. The CLI starts +// the run and does not wait for it. This step is optional: a failure here +// warns and setup still finishes. + +const LOOKBACK_DAYS = 7 +const BACKFILL_REQUEST: BackfillRequest = { lookback_days: LOOKBACK_DAYS, skip_processed: true } + +export interface BackfillOptions { + triggerKey: string + sourceRepos: string[] | 'all' + /** `--backfill`: start without asking. */ + skipQuestion: boolean +} + +function pullRequests(count: number, approximate: boolean): string { + return `${approximate ? 'about ' : ''}${count} pull ${count === 1 ? 'request' : 'requests'}` +} + +function apiMessage(err: ApiError): string { + const body = typeof err.body === 'object' && err.body !== null ? (err.body as Record) : {} + return typeof body.message === 'string' ? body.message : err.message +} + +async function describeActiveBackfill(session: Session, triggerKey: string): Promise { + try { + const job = (await getBackfillStatus(session.client, session.orgId, triggerKey)).latest_job + if (job !== null) { + return `A backfill is already running: ${job.dispatched_count} of ${job.matched_count ?? 'the matched'} pull requests dispatched.` + } + } catch (err) { + if (!(err instanceof ApiError)) throw err + warn(`Could not read the backfill progress: ${apiMessage(err)}`) + } + return 'A backfill is already running for this trigger.' +} + +/** + * Offer and start a backfill. Returns true when a backfill is running for the trigger. + * + * @throws Error only for failures other than an API error response, such as a network failure. + */ +export async function offerBackfill(session: Session, opts: BackfillOptions): Promise { + step(6, 'First suggestion') + const { client, orgId } = session + const repos = opts.sourceRepos === 'all' ? 'your repositories' : opts.sourceRepos.join(', ') + + try { + const preview = await previewBackfill(client, orgId, opts.triggerKey, BACKFILL_REQUEST) + if (preview.unsupported_reason !== null) { + note(`A backfill is not available for this trigger: ${preview.unsupported_reason}`) + return false + } + if (preview.count === 0) { + note(`No pull requests merged in ${repos} in the last ${LOOKBACK_DAYS} days.`) + return false + } + + const count = pullRequests(preview.count, preview.approximate) + if (!opts.skipQuestion) { + const choice = await selectOne<'skip' | 'run'>({ + message: `Run Promptless on the last ${LOOKBACK_DAYS} days of merged pull requests in ${repos}? (${count})`, + choices: [ + { value: 'skip', label: 'Not now' }, + { value: 'run', label: `Run on ${count}` }, + ], + }) + if (choice !== 'run') return false + } + + const started = await startBackfill(client, orgId, opts.triggerKey, BACKFILL_REQUEST) + done(`Backfill accepted for ${count} (job ${started.job_id}). Suggestions arrive as pull requests.`) + return true + } catch (err) { + if (!(err instanceof ApiError)) throw err + if (err.code === 'backfill_already_active') { + note(await describeActiveBackfill(session, opts.triggerKey)) + return true + } + warn(`Skipped the backfill: ${apiMessage(err)}`) + return false + } +} diff --git a/src/commands/setup/complete.ts b/src/commands/setup/complete.ts new file mode 100644 index 0000000..29101bc --- /dev/null +++ b/src/commands/setup/complete.ts @@ -0,0 +1,31 @@ +import { ApiError } from '../../lib/api' +import { completeOnboarding, getSetupStatus } from '../../lib/setup-api' +import type { SetupStatus } from '../../lib/setup-api' +import type { Session } from './login' +import { SetupError, done } from './output' + +// Screen 6: complete onboarding, which starts the free trial, unless the +// organization already completed it. Then re-read status for the trial end. + +/** + * Complete onboarding when `status` shows it is not complete, and return a fresh status. + * + * @throws SetupError when the runtime has no doc collection for the organization yet. + */ +export async function completeSetup(session: Session, status: SetupStatus): Promise { + if (!status.onboarding_completed) { + try { + await completeOnboarding(session.client, session.orgId) + } catch (err) { + if (err instanceof ApiError && err.code === 'doc_collection_required') { + throw new SetupError( + 'doc_collection_required', + 'Promptless has not recorded the docs repository yet. Run `promptless setup` again in a minute.', + ) + } + throw err + } + done('Setup complete') + } + return getSetupStatus(session.client, session.orgId) +} diff --git a/src/commands/setup/docs.ts b/src/commands/setup/docs.ts new file mode 100644 index 0000000..988666c --- /dev/null +++ b/src/commands/setup/docs.ts @@ -0,0 +1,168 @@ +import type { GitHubRepo } from '../../lib/git-remote' +import { installedRepos, postDocsCandidates } from '../../lib/setup-api' +import type { DocsCandidate, SetupDocCollection, SetupStatus } from '../../lib/setup-api' +import { selectOne } from '../../lib/ui/prompt' +import type { Choice } from '../../lib/ui/prompt' +import { SetupError, answered, done, note, step, warn } from './output' +import type { Session } from './login' + +// Screen 3: find the documentation repository. An existing doc collection is +// kept; otherwise the runtime ranks the installed repositories and the top +// candidate is preselected. + +export interface DocsChoice { + repo: string + framework: string | null + configPath: string | null + filter: string[] + /** True when the organization already has this doc collection. */ + existing: boolean +} + +const OTHER_REPO = Symbol('other repository') + +function sameRepo(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +/** A one-line summary of what the analysis found, for example `mintlify, docs/docs.json, 214 pages`. */ +export function describeCandidate(candidate: DocsCandidate): string { + const parts = [candidate.framework ?? 'no docs framework config'] + if (candidate.config_path !== null) parts.push(candidate.config_path) + parts.push(`${candidate.doc_count} ${candidate.doc_count === 1 ? 'page' : 'pages'}`) + return parts.join(', ') +} + +function fromCandidate(candidate: DocsCandidate): DocsChoice { + return { + repo: candidate.full_name, + framework: candidate.framework, + configPath: candidate.config_path, + filter: candidate.proposed_filter, + existing: false, + } +} + +function fromCollection(collection: SetupDocCollection): DocsChoice { + return { + repo: collection.repo, + framework: collection.docs_framework, + configPath: collection.config_file_path, + filter: collection.filter, + existing: true, + } +} + +function bareChoice(repo: string): DocsChoice { + return { repo, framework: null, configPath: null, filter: [], existing: false } +} + +async function pickInstalledRepo(status: SetupStatus): Promise { + const repos = installedRepos(status) + if (repos.length === 0) { + throw new SetupError( + 'repos_not_installed', + 'The GitHub App installation lists no repositories. Grant it your docs repository on GitHub, ' + + 'then run `promptless setup` again.', + ) + } + const repo = answered( + await selectOne({ + message: 'Which repository holds your documentation?', + choices: repos.map((r) => ({ value: r.full_name, label: r.full_name })), + }), + ) + return bareChoice(repo) +} + +async function useExistingCollection(status: SetupStatus): Promise { + const collections = status.doc_collections + const repo = + collections.length === 1 + ? collections[0].repo + : answered( + await selectOne({ + message: 'Which documentation repository should setup use?', + choices: collections.map((c) => ({ value: c.repo, label: c.repo, hint: c.docs_framework ?? undefined })), + }), + ) + return fromCollection(collections.find((c) => c.repo === repo) ?? collections[0]) +} + +function summarize(choice: DocsChoice): string { + const details = [choice.framework, choice.configPath].filter((part): part is string => part !== null) + if (choice.filter.length > 0) details.push(`filter: ${choice.filter.join(', ')}`) + const suffix = choice.existing ? ' (already set up)' : '' + return details.length > 0 + ? `Documentation: ${choice.repo} (${details.join(', ')})${suffix}` + : `Documentation: ${choice.repo}${suffix}` +} + +/** + * Screen 3: choose the documentation repository. + * + * @param docsRepoFlag `--docs-repo`, which skips the prompt. + * @param cwdRepo The github.com `origin` of the current directory, if any. + */ +export async function findDocsRepo( + session: Session, + status: SetupStatus, + docsRepoFlag: string | null, + cwdRepo: GitHubRepo | null, +): Promise { + step(3, 'Find your documentation') + + const flagged = + docsRepoFlag === null ? undefined : status.doc_collections.find((c) => sameRepo(c.repo, docsRepoFlag)) + if (flagged !== undefined) { + const choice = fromCollection(flagged) + done(summarize(choice)) + return choice + } + if (docsRepoFlag === null && status.doc_collections.length > 0) { + const choice = await useExistingCollection(status) + done(summarize(choice)) + return choice + } + + note( + cwdRepo === null + ? 'This directory has no github.com remote named origin.' + : `This directory is ${cwdRepo.fullName} (git remote origin).`, + ) + const response = await postDocsCandidates(session.client, session.orgId, cwdRepo?.fullName ?? null) + if (cwdRepo !== null && !response.cwd_repo_installed) { + warn(`The GitHub App cannot see ${cwdRepo.fullName}. Grant it on GitHub if its merges should update the docs.`) + } + const candidates = response.candidates + + let choice: DocsChoice + if (docsRepoFlag !== null) { + const candidate = candidates.find((c) => sameRepo(c.full_name, docsRepoFlag)) + choice = candidate !== undefined ? fromCandidate(candidate) : bareChoice(docsRepoFlag) + } else if (candidates.length === 0) { + note('No installed repository looks like documentation.') + choice = await pickInstalledRepo(status) + } else { + const top = candidates[0] + note(`${top.full_name} looks like your documentation: ${describeCandidate(top)}.`) + const choices: Choice[] = candidates.map((c) => ({ + value: c.full_name, + label: `Use ${c.full_name}`, + hint: describeCandidate(c), + })) + choices.push({ value: OTHER_REPO, label: 'Pick a different repository' }) + const picked = answered( + await selectOne({ + message: 'Which repository holds your documentation?', + choices, + initial: top.full_name, + }), + ) + const candidate = candidates.find((c) => c.full_name === picked) + choice = candidate !== undefined ? fromCandidate(candidate) : await pickInstalledRepo(status) + } + + done(summarize(choice)) + return choice +} diff --git a/src/commands/setup/exit.test.ts b/src/commands/setup/exit.test.ts new file mode 100644 index 0000000..52160ca --- /dev/null +++ b/src/commands/setup/exit.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { buildExitBlock, formatTrialEnd, renderExitBlock } from './exit' +import type { ExitBlockInput } from './exit' + +const APP = 'https://app.example.test' +const API = 'https://api.example.test' + +const BASE: ExitBlockInput = { + appBaseUrl: APP, + apiBaseUrl: API, + docsRepo: 'acme/docs', + sourceRepos: ['acme/api', 'acme/web'], + billing: null, + backfillTriggerKey: null, +} + +function kinds(input: ExitBlockInput): string[] { + return buildExitBlock(input).map((line) => line.kind) +} + +function lineOf(input: ExitBlockInput, kind: string): string { + const line = buildExitBlock(input).find((l) => l.kind === kind) + assert.ok(line, `no ${kind} line`) + return line.text +} + +describe('buildExitBlock', () => { + it('has a headline, the dashboard link, and next steps without a trial or backfill', () => { + assert.deepEqual(kinds(BASE), ['headline', 'dashboard', 'next_steps', 'next_step', 'next_step', 'next_step']) + assert.ok(lineOf(BASE, 'dashboard').endsWith(`${APP}/configuration`)) + }) + + it('adds the trial line while trialing', () => { + const input: ExitBlockInput = { ...BASE, billing: { status: 'trialing', trial_end: '2026-10-15T00:00:00+00:00' } } + assert.deepEqual(kinds(input).slice(0, 3), ['headline', 'dashboard', 'trial']) + const trial = lineOf(input, 'trial') + assert.ok(trial.includes(formatTrialEnd('2026-10-15T00:00:00+00:00'))) + assert.ok(trial.endsWith(`${APP}/settings/billing`)) + }) + + it('omits the trial line for every other billing state', () => { + for (const status of ['pending', 'active', 'exempt'] as const) { + const input: ExitBlockInput = { ...BASE, billing: { status, trial_end: '2026-10-15T00:00:00+00:00' } } + assert.equal(kinds(input).includes('trial'), false, status) + } + assert.equal(kinds({ ...BASE, billing: { status: 'trialing', trial_end: null } }).includes('trial'), false) + }) + + it('links backfill progress and drops the merge-a-PR step once a backfill started', () => { + const input: ExitBlockInput = { ...BASE, backfillTriggerKey: 'source-merges' } + assert.deepEqual(kinds(input), ['headline', 'dashboard', 'backfill', 'next_steps', 'next_step', 'next_step']) + assert.ok(lineOf(input, 'backfill').includes(`${APP}/configuration`)) + }) + + it('ends with the MCP handoff command on the API host', () => { + const lines = buildExitBlock(BASE) + assert.ok(lines[lines.length - 1].text.endsWith(`claude mcp add --transport http promptless ${API}/mcp`)) + }) + + it('names the docs repository and each source repository in the headline', () => { + const headline = lineOf(BASE, 'headline') + for (const repo of ['acme/docs', 'acme/api', 'acme/web']) assert.ok(headline.includes(repo), repo) + }) +}) + +describe('formatTrialEnd', () => { + it('formats the date in UTC', () => { + assert.equal(formatTrialEnd('2026-10-15T23:30:00-05:00'), '16 Oct 2026') + }) +}) + +describe('renderExitBlock', () => { + it('writes one line per entry after a blank line', () => { + const lines = buildExitBlock(BASE) + const text = renderExitBlock(lines) + assert.equal(text.split('\n').length, lines.length + 2) + assert.ok(text.startsWith('\n')) + }) +}) diff --git a/src/commands/setup/exit.ts b/src/commands/setup/exit.ts new file mode 100644 index 0000000..fe5126e --- /dev/null +++ b/src/commands/setup/exit.ts @@ -0,0 +1,76 @@ +import type { BillingStatus } from '../../lib/setup-api' + +// The exit block: a short summary printed to stdout in the normal scrollback, +// so it stays after the prompts are gone. Shape from PostHog's exit line (MIT, +// github.com/PostHog/wizard src/ui/tui/exit-line.ts). + +export interface ExitBlockInput { + appBaseUrl: string + apiBaseUrl: string + docsRepo: string + sourceRepos: string[] | 'all' + billing: BillingStatus | null + /** The trigger a backfill was started for, or null when none was started. */ + backfillTriggerKey: string | null +} + +export type ExitLineKind = 'headline' | 'dashboard' | 'trial' | 'backfill' | 'next_steps' | 'next_step' + +export interface ExitLine { + kind: ExitLineKind + text: string +} + +/** `15 Oct 2026`, in UTC so the date matches the stored trial end. */ +export function formatTrialEnd(iso: string): string { + return new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }).format(new Date(iso)) +} + +/** Assemble the exit block. The trial line appears only while the organization is trialing. */ +export function buildExitBlock(input: ExitBlockInput): ExitLine[] { + const watched = input.sourceRepos === 'all' ? 'every repository the GitHub App can see' : input.sourceRepos.join(', ') + const lines: ExitLine[] = [ + { + kind: 'headline', + text: `✔ Promptless is watching ${watched}. Documentation updates arrive as pull requests on ${input.docsRepo}.`, + }, + { kind: 'dashboard', text: `Dashboard: ${input.appBaseUrl}/configuration` }, + ] + + if (input.billing?.status === 'trialing' && input.billing.trial_end !== null) { + lines.push({ + kind: 'trial', + text: `Free trial ends ${formatTrialEnd(input.billing.trial_end)}. Add a card at ${input.appBaseUrl}/settings/billing`, + }) + } + if (input.backfillTriggerKey !== null) { + lines.push({ + kind: 'backfill', + text: `Backfill progress: ${input.appBaseUrl}/configuration (trigger ${input.backfillTriggerKey})`, + }) + } + + lines.push({ kind: 'next_steps', text: 'Next steps' }) + if (input.backfillTriggerKey === null) { + const where = input.sourceRepos === 'all' ? 'a watched repository' : input.sourceRepos.join(' or ') + lines.push({ kind: 'next_step', text: ` • Merge a pull request in ${where} to see a first suggestion.` }) + } + lines.push( + { kind: 'next_step', text: ` • Connect Slack, Jira, or Linear: ${input.appBaseUrl}/integrations` }, + { + kind: 'next_step', + text: ` • Let your coding agent use Promptless: claude mcp add --transport http promptless ${input.apiBaseUrl}/mcp`, + }, + ) + return lines +} + +/** Join the exit block into the text written to stdout, with a leading blank line. */ +export function renderExitBlock(lines: ExitLine[]): string { + return `\n${lines.map((line) => line.text).join('\n')}\n` +} diff --git a/src/commands/setup/github.test.ts b/src/commands/setup/github.test.ts new file mode 100644 index 0000000..bdc335f --- /dev/null +++ b/src/commands/setup/github.test.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { SetupStatus } from '../../lib/setup-api' +import { runGitHubGate } from './github' +import type { GateChoice, GateDeps, GateEvent } from './github' + +function status(connected: boolean, pending: string[] = []): SetupStatus { + return { + onboarding_completed: false, + billing: null, + github: { + connected, + installations: connected + ? [{ owner: 'acme', repository_selection: 'selected', repos: [{ full_name: 'acme/docs', default_branch: 'main' }] }] + : [], + pending_approval: pending.map((login) => ({ github_org_login: login, requested_at: '2026-10-01T00:00:00Z' })), + }, + doc_collections: [], + triggers: [], + } +} + +/** A prompt that stays open until the gate aborts it, like a user who never answers. */ +function idleAsk(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve(null) + signal.addEventListener('abort', () => resolve(null), { once: true }) + }) +} + +interface Harness { + deps: GateDeps + events: GateEvent[] + opened: string[] + calls: { status: number; begin: number; pull: number; ask: number } +} + +function harness(statuses: SetupStatus[], overrides: Partial = {}): Harness { + const events: GateEvent[] = [] + const opened: string[] = [] + const calls = { status: 0, begin: 0, pull: 0, ask: 0 } + const deps: GateDeps = { + fetchStatus: async () => statuses[Math.min(calls.status++, statuses.length - 1)], + begin: async () => { + calls.begin++ + return 'https://github.com/apps/promptless/installations/new' + }, + pull: async () => { + calls.pull++ + }, + openUrl: (url) => opened.push(url), + ask: (_opened, signal) => { + calls.ask++ + return idleAsk(signal) + }, + report: (event) => events.push(event), + openOnStart: true, + intervalMs: 2, + timeoutMs: 2_000, + pullEvery: 3, + ...overrides, + } + return { deps, events, opened, calls } +} + +describe('runGitHubGate', () => { + it('passes straight through when GitHub is already connected', async () => { + const h = harness([status(true)]) + const outcome = await runGitHubGate(h.deps) + assert.equal(outcome.kind, 'connected') + assert.equal(h.calls.begin, 0) + assert.equal(h.calls.ask, 0) + assert.deepEqual( + h.events.map((e) => e.kind), + ['already_connected'], + ) + }) + + it('opens the install page and polls until an installation appears', async () => { + const statuses = [status(false), ...Array.from({ length: 7 }, () => status(false)), status(true)] + const h = harness(statuses) + const outcome = await runGitHubGate(h.deps) + + assert.equal(outcome.kind, 'connected') + assert.deepEqual(h.opened, ['https://github.com/apps/promptless/installations/new']) + // One initial read, then poll attempts 1..8; attempts 3 and 6 also pull. + assert.equal(h.calls.status, 9) + assert.equal(h.calls.pull, 2) + assert.deepEqual( + h.events.map((e) => e.kind), + ['install_link', 'connected'], + ) + }) + + it('only prints the link without opening a browser when openOnStart is off', async () => { + const h = harness([status(false), status(true)], { openOnStart: false }) + await runGitHubGate(h.deps) + assert.deepEqual(h.opened, []) + assert.deepEqual(h.events[0], { + kind: 'install_link', + url: 'https://github.com/apps/promptless/installations/new', + opened: false, + }) + }) + + it('stops polling when the user declines', async () => { + const h = harness([status(false)], { ask: async () => 'decline' }) + const outcome = await runGitHubGate(h.deps) + assert.deepEqual(outcome, { kind: 'declined' }) + + const statusCallsAtDecline = h.calls.status + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.equal(h.calls.status, statusCallsAtDecline) + }) + + it('reports a cancelled prompt as cancelled', async () => { + const h = harness([status(false)], { ask: async () => null }) + assert.deepEqual(await runGitHubGate(h.deps), { kind: 'cancelled' }) + }) + + it('re-opens the install page when asked, then keeps waiting', async () => { + const answers: Array = ['open'] + const statuses = [status(false), ...Array.from({ length: 20 }, () => status(false)), status(true)] + const h = harness(statuses, { + ask: (opened, signal) => { + const next = answers.shift() + if (next !== undefined) { + assert.equal(opened, true) + return Promise.resolve(next) + } + return idleAsk(signal) + }, + }) + const outcome = await runGitHubGate(h.deps) + assert.equal(outcome.kind, 'connected') + assert.equal(h.opened.length, 2) + assert.deepEqual( + h.events.map((e) => (e.kind === 'install_link' ? `${e.kind}:${e.opened}` : e.kind)), + ['install_link:true', 'install_link:true', 'connected'], + ) + }) + + it('reports a pending admin approval once per change, between prompts', async () => { + const statuses = [ + status(false), + status(false, ['acme']), + status(false, ['acme']), + status(false, ['acme']), + status(false, ['acme', 'labs']), + status(true), + ] + let promptOpen = false + const h = harness(statuses, { + ask: (_opened, signal) => { + promptOpen = true + return idleAsk(signal).finally(() => { + promptOpen = false + }) + }, + }) + const report = h.deps.report + h.deps.report = (event) => { + assert.equal(promptOpen, false, 'events print only while no prompt is open') + report(event) + } + + const outcome = await runGitHubGate(h.deps) + assert.equal(outcome.kind, 'connected') + const pending = h.events.filter((e) => e.kind === 'pending_approval') + assert.deepEqual( + pending.map((e) => (e.kind === 'pending_approval' ? e.githubOrgs : [])), + [['acme'], ['acme', 'labs']], + ) + }) + + it('reports a pending approval that exists before polling starts', async () => { + const h = harness([status(false, ['acme']), status(false, ['acme']), status(true)]) + await runGitHubGate(h.deps) + assert.deepEqual( + h.events.map((e) => e.kind), + ['install_link', 'pending_approval', 'connected'], + ) + }) + + it('reports the first failed check once and keeps polling', async () => { + let calls = 0 + const h = harness([], { + fetchStatus: async () => { + calls++ + if (calls === 1) return status(false) + if (calls <= 4) throw new Error('connection reset') + return status(true) + }, + }) + const outcome = await runGitHubGate(h.deps) + assert.equal(outcome.kind, 'connected') + assert.deepEqual( + h.events.map((e) => e.kind), + ['install_link', 'check_failed', 'connected'], + ) + }) + + it('times out when no installation arrives', async () => { + const h = harness([status(false)], { timeoutMs: 20 }) + assert.deepEqual(await runGitHubGate(h.deps), { kind: 'timed_out' }) + }) +}) diff --git a/src/commands/setup/github.ts b/src/commands/setup/github.ts new file mode 100644 index 0000000..19064c0 --- /dev/null +++ b/src/commands/setup/github.ts @@ -0,0 +1,252 @@ +import { openInBrowser } from '../../lib/browser' +import { PollTimeoutError, pollUntil } from '../../lib/poll' +import { + beginGitHubInstall, + getSetupStatus, + installedRepos, + pullGitHubInstallations, +} from '../../lib/setup-api' +import type { SetupStatus } from '../../lib/setup-api' +import { link } from '../../lib/ui/link' +import { selectOne } from '../../lib/ui/prompt' +import { SetupError, done, note, step, warn } from './output' +import type { Session } from './login' + +// Screen 2: the GitHub App gate. Pass straight through when an installation +// exists; otherwise open the install page and poll `setup/status` until one +// appears, while a prompt offers to decline or re-open the page. The loop +// follows PostHog's GitHub gate (MIT, github.com/PostHog/wizard +// src/ui/tui/screens/SelfDrivingGitHubScreen.tsx). + +const POLL_INTERVAL_MS = 3_000 +const POLL_TIMEOUT_MS = 30 * 60 * 1000 +/** Every this many polls, ask the runtime to re-read installations from GitHub (covers admin approval). */ +const PULL_EVERY = 10 + +export type GateChoice = 'decline' | 'open' + +export type GateEvent = + | { kind: 'already_connected'; status: SetupStatus } + | { kind: 'install_link'; url: string; opened: boolean } + | { kind: 'pending_approval'; githubOrgs: string[] } + | { kind: 'check_failed'; error: unknown } + | { kind: 'connected'; status: SetupStatus } + +export type GateOutcome = + | { kind: 'connected'; status: SetupStatus } + | { kind: 'declined' } + | { kind: 'cancelled' } + | { kind: 'timed_out' } + +export interface GateDeps { + fetchStatus: () => Promise + /** Start an install and return the URL to open. */ + begin: () => Promise + pull: () => Promise + openUrl: (url: string) => void + /** + * Ask whether to decline or open the install page. Resolve null when the + * user cancels or when `signal` aborts; the gate aborts the prompt to print + * an event or to finish, and asks again when it is still waiting. + */ + ask: (installOpened: boolean, signal: AbortSignal) => Promise + /** Show an event. Called only while no prompt is open. */ + report: (event: GateEvent) => void + /** Open the install page before the first prompt. */ + openOnStart: boolean + intervalMs: number + timeoutMs: number + pullEvery: number +} + +function pendingOrgs(status: SetupStatus): string[] { + return status.github.pending_approval.map((request) => request.github_org_login).sort() +} + +/** + * Wait until the organization has a GitHub App installation, the user + * declines, the user cancels, or `timeoutMs` passes. + */ +export async function runGitHubGate(deps: GateDeps): Promise { + const initial = await deps.fetchStatus() + if (initial.github.connected) { + deps.report({ kind: 'already_connected', status: initial }) + return { kind: 'connected', status: initial } + } + + const url = await deps.begin() + let installOpened = false + if (deps.openOnStart) { + deps.openUrl(url) + installOpened = true + } + deps.report({ kind: 'install_link', url, opened: installOpened }) + + const queued: GateEvent[] = [] + let promptAbort = new AbortController() + const notify = (event: GateEvent): void => { + queued.push(event) + promptAbort.abort() + } + const flush = (): void => { + for (const event of queued.splice(0)) deps.report(event) + } + + let lastPending = pendingOrgs(initial).join(',') + if (lastPending.length > 0) queued.push({ kind: 'pending_approval', githubOrgs: pendingOrgs(initial) }) + + const pollAbort = new AbortController() + let pollFinished = false + const pollDone: Promise = pollUntil( + async (attempt) => { + if (attempt % deps.pullEvery === 0) await deps.pull() + const status = await deps.fetchStatus() + const pending = pendingOrgs(status) + if (pending.join(',') !== lastPending) { + lastPending = pending.join(',') + if (pending.length > 0 && !status.github.connected) notify({ kind: 'pending_approval', githubOrgs: pending }) + } + return status.github.connected ? status : null + }, + { + intervalMs: deps.intervalMs, + timeoutMs: deps.timeoutMs, + signal: pollAbort.signal, + onError: (error) => notify({ kind: 'check_failed', error }), + }, + ) + .then( + (status): GateOutcome => ({ kind: 'connected', status }), + (err: unknown): GateOutcome | null => { + if (err instanceof PollTimeoutError) return { kind: 'timed_out' } + if (pollAbort.signal.aborted) return null + throw err + }, + ) + .finally(() => { + pollFinished = true + promptAbort.abort() + }) + // Awaited below; this branch only keeps a rejection from being reported as unhandled meanwhile. + pollDone.catch(() => {}) + + const askLoop = async (): Promise => { + for (;;) { + flush() + if (pollFinished) return null + promptAbort = new AbortController() + const choice = await deps.ask(installOpened, promptAbort.signal) + if (promptAbort.signal.aborted) continue + if (choice === null) return { kind: 'cancelled' } + if (choice === 'decline') return { kind: 'declined' } + deps.openUrl(url) + installOpened = true + deps.report({ kind: 'install_link', url, opened: true }) + } + } + + const userOutcome = await askLoop() + if (userOutcome !== null) { + pollAbort.abort() + await pollDone + return userOutcome + } + + const outcome = (await pollDone) ?? { kind: 'timed_out' } + flush() + if (outcome.kind === 'connected') deps.report({ kind: 'connected', status: outcome.status }) + return outcome +} + +function describeConnection(status: SetupStatus): string { + const owners = status.github.installations + .map((installation) => installation.owner) + .filter((owner): owner is string => owner !== null) + const count = installedRepos(status).length + const repos = `${count} ${count === 1 ? 'repository' : 'repositories'}` + return owners.length > 0 ? `${owners.join(', ')}, ${repos}` : repos +} + +function reportEvent(event: GateEvent): void { + switch (event.kind) { + case 'already_connected': + case 'connected': + done(`GitHub connected: ${describeConnection(event.status)}`) + return + case 'install_link': + note( + event.opened + ? `Opened ${link(event.url)} in your browser.` + : `Open ${link(event.url)} to install the GitHub App.`, + ) + return + case 'pending_approval': + warn( + `The install is waiting for a GitHub admin of ${event.githubOrgs.join(', ')} to approve it. ` + + 'Setup continues once they approve.', + ) + return + case 'check_failed': { + const message = event.error instanceof Error ? event.error.message : String(event.error) + warn(`Could not check the installation (${message}). Still checking.`) + return + } + } +} + +async function askGateChoice(installOpened: boolean, signal: AbortSignal): Promise { + return selectOne({ + message: 'Waiting for the installation...', + choices: [ + { value: 'decline', label: "I can't connect right now" }, + { value: 'open', label: installOpened ? 'Re-open GitHub App install' : 'Open GitHub App install' }, + ], + signal, + }) +} + +/** + * Screen 2: make sure the GitHub App is installed, and return the status that shows it. + * + * @throws SetupError when the user declines or cancels, or the install does not arrive in 30 minutes. + */ +export async function connectGitHub(session: Session, openBrowser: boolean): Promise { + step(2, 'Connect GitHub') + const { client, orgId } = session + + const outcome = await runGitHubGate({ + fetchStatus: () => getSetupStatus(client, orgId), + begin: async () => { + note('Promptless needs the GitHub App on your docs repository and on the repositories whose merges') + note('should update it. Grant both when GitHub asks which repositories to include.') + return beginGitHubInstall(client, orgId) + }, + pull: () => pullGitHubInstallations(client, orgId), + openUrl: openInBrowser, + ask: askGateChoice, + report: reportEvent, + openOnStart: openBrowser, + intervalMs: POLL_INTERVAL_MS, + timeoutMs: POLL_TIMEOUT_MS, + pullEvery: PULL_EVERY, + }) + + switch (outcome.kind) { + case 'connected': + return outcome.status + case 'declined': + throw new SetupError( + 'github_not_connected', + 'Promptless needs the GitHub App to read your documentation repository. ' + + 'Nothing was changed. Run `promptless setup` again when you are ready.', + ) + case 'cancelled': + throw new SetupError('cancelled', 'Setup was cancelled. Nothing was changed.') + case 'timed_out': + throw new SetupError( + 'github_not_connected', + 'No GitHub App installation arrived within 30 minutes. ' + + 'Nothing was changed. Run `promptless setup` again when you are ready.', + ) + } +} diff --git a/src/commands/setup/index.ts b/src/commands/setup/index.ts index 65fa8c7..4ec6410 100644 --- a/src/commands/setup/index.ts +++ b/src/commands/setup/index.ts @@ -1,5 +1,159 @@ -// Placeholder so `setup` is wired into the command table and shell completion. -export function runSetup(_argv: string[]): never { - process.stderr.write('promptless setup: not available yet\n') - process.exit(2) +import { API_BASE_URL, APP_BASE_URL, ApiError, AuthError, NetworkError } from '../../lib/api' +import { emitError } from '../../lib/errors' +import type { ErrorLine } from '../../lib/errors' +import { readOriginGitHubRepo } from '../../lib/git-remote' +import { parseSetupArgs } from './args' +import type { SetupFlags } from './args' +import { offerBackfill } from './backfill' +import { completeSetup } from './complete' +import { findDocsRepo } from './docs' +import { buildExitBlock, renderExitBlock } from './exit' +import { connectGitHub } from './github' +import { signIn } from './login' +import { SetupError, done, step } from './output' +import { reviewAndApply } from './review' +import { chooseSourceRepos } from './sources' + +const HELP = `promptless setup — connect GitHub and keep your docs current from merged pull requests + +usage: + promptless setup [options] + +options: + --org Run against this organization. The sign-in page picks it + when the saved key belongs to another one. + --docs-repo Use this documentation repository instead of asking. + --source-repo + Update the docs when a pull request merges here. Repeat for + several repositories. Skips the repository prompt. + --trigger-on Pull request events that update the docs: opened, + first_approval, merge, updated. Repeat or comma-separate. + Default: merge. + --backfill Run Promptless on the last 7 days of merged pull requests + without asking. + --new-account Open the sign-up page instead of sign-in. + --no-browser Print links instead of opening them. + -h, --help Show this help + +description: + Signs in (reusing the saved API key when it still works), waits for the + Promptless GitHub App to be installed, finds your documentation repository, + and adds it to your organization's promptless.yaml together with a trigger + on the source repositories you choose. Completes onboarding, which starts the + free trial, and can run Promptless on recent merged pull requests. + + promptless.yaml lives in your organization's Promptless knowledge-base + repository and is written by Promptless. Setup writes nothing to the current + repository; the only local file it writes is the API key in the config file + that \`promptless login\` uses. + + Running setup again skips every step that is already done. + + Setup needs an interactive terminal. Every fatal error also prints one line + on stderr: promptless-error: {"code": "...", "message": "..."} + +exit codes: + 0 setup finished + 1 setup stopped: declined, cancelled, or failed + 2 argument error +` + +function errorLineFor(err: unknown): ErrorLine { + if (err instanceof SetupError) return { code: err.code, message: err.message } + if (err instanceof AuthError) return { code: 'auth_failed', message: err.message } + if (err instanceof ApiError && err.code === 'setup_unavailable') { + return { code: 'setup_unavailable', message: err.message } + } + if (err instanceof ApiError) return { code: 'api_error', message: err.message } + if (err instanceof NetworkError) return { code: 'network_error', message: err.message } + return { code: 'internal_error', message: err instanceof Error ? err.message : String(err) } +} + +function fail(err: unknown): never { + const line = errorLineFor(err) + process.stderr.write(`\n${line.message}\n`) + emitError(line) + process.exit(line.code === 'bad_arguments' ? 2 : 1) +} + +async function runScreens(flags: SetupFlags): Promise { + process.stderr.write( + 'Promptless setup\n' + + ' Keeps your documentation current from merged pull requests. About three minutes.\n' + + ' Nothing is written to this repository.\n', + ) + const cwdRepo = readOriginGitHubRepo() + + const session = await signIn({ org: flags.org, newAccount: flags.newAccount, openBrowser: flags.openBrowser }) + const status = await connectGitHub(session, flags.openBrowser) + const docs = await findDocsRepo(session, status, flags.docsRepo, cwdRepo) + const sources = await chooseSourceRepos(status, docs.repo, docs.existing, flags.sourceRepos, cwdRepo) + + let triggerKey: string + let triggerAdded: boolean + if (sources.kind === 'existing') { + step(5, 'Review') + done('promptless.yaml already has this collection and a pull request trigger. Nothing to change.') + triggerKey = sources.triggerKey + triggerAdded = false + } else { + const applied = await reviewAndApply(session, docs, sources.repos, flags.triggerOn) + triggerKey = applied.trigger.key + triggerAdded = applied.trigger.added + } + + const finalStatus = await completeSetup(session, status) + + let backfillTriggerKey: string | null = null + if (triggerAdded || flags.backfill) { + const started = await offerBackfill(session, { + triggerKey, + sourceRepos: sources.repos, + skipQuestion: flags.backfill, + }) + if (started) backfillTriggerKey = triggerKey + } + + process.stdout.write( + renderExitBlock( + buildExitBlock({ + appBaseUrl: APP_BASE_URL, + apiBaseUrl: API_BASE_URL, + docsRepo: docs.repo, + sourceRepos: sources.repos, + billing: finalStatus.billing, + backfillTriggerKey, + }), + ), + ) +} + +export function runSetup(argv: string[]): never { + let flags: SetupFlags + try { + const parsed = parseSetupArgs(argv) + if (parsed.kind === 'help') { + process.stdout.write(HELP) + process.exit(0) + } + flags = parsed.flags + } catch (err) { + fail(err) + } + + if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) { + fail( + new SetupError( + 'interactive_terminal_required', + 'promptless setup asks questions as it goes, so it needs an interactive terminal. ' + + 'Run it again in a terminal window.', + ), + ) + } + + void runScreens(flags).then( + () => process.exit(0), + (err: unknown) => fail(err), + ) + return undefined as never } diff --git a/src/commands/setup/login.test.ts b/src/commands/setup/login.test.ts new file mode 100644 index 0000000..6cefe77 --- /dev/null +++ b/src/commands/setup/login.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { constants, createPublicKey, publicEncrypt } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' + +const ORIGINAL_CONFIG = 'PROMPTLESS_CLI_API_SECRET=sk-pl-previous\nOTHER_SETTING=keep\n' +const NEW_KEY = 'sk-pl-new-test' +const IDENTITY = { + user_id: 'user_test', + email: 'test@example.com', + organization_id: 'org_expected', + organization_name: 'Test organization', +} + +interface LoginScenario { + responseStatus: number + responseBody: unknown + cancel?: boolean +} + +// Run the real sign-in flow and encrypted loopback callback against a local +// runtime. No production credentials, browser, or account is involved. +async function runLogin(scenario: LoginScenario) { + const directory = mkdtempSync(join(tmpdir(), 'promptless-setup-login-')) + const configPath = join(directory, 'env') + writeFileSync(configPath, ORIGINAL_CONFIG, { mode: 0o600 }) + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = [] + const server = createServer((req, res) => { + requests.push({ url: req.url, authorization: req.headers.authorization }) + res.writeHead(scenario.responseStatus, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(scenario.responseBody)) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const child = spawn(process.execPath, [ + '--import', 'tsx', '--input-type=module', '--eval', + `import { signIn } from ${JSON.stringify(new URL('./login.ts', import.meta.url).href)}; + try { + await signIn({ org: 'org_expected', newAccount: true, openBrowser: false }); + } catch (err) { + process.stderr.write(err.message + '\\n'); + process.exitCode = 1; + }`, + ], { + env: { + ...process.env, + PROMPTLESS_API_BASE_URL: baseUrl, + PROMPTLESS_APP_BASE_URL: baseUrl, + PROMPTLESS_CLI_DEVELOPER_CONFIG_FILE: configPath, + PROMPTLESS_CLI_API_SECRET: '', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stderr = '' + let stdout = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + const finished = new Promise((resolve, reject) => { + child.once('close', resolve) + child.once('error', reject) + }) + const deadline = setTimeout(() => child.kill('SIGKILL'), 10_000) + + try { + const signUpUrl = await new Promise((resolve, reject) => { + const inspect = () => { + const match = /Open (http\S+) in your browser\./.exec(stderr) + if (match) resolve(new URL(match[1])) + } + child.stderr.on('data', inspect) + void finished.then(() => reject(new Error(`Sign-in ended before printing its URL: ${stderr}`)), reject) + inspect() + }) + assert.equal(signUpUrl.pathname, '/sign-up') + const authUrl = new URL(signUpUrl.searchParams.get('redirect_url')!) + assert.equal(authUrl.pathname, '/cli/auth') + assert.equal(authUrl.searchParams.get('intent'), 'setup') + assert.equal(authUrl.searchParams.get('org_id'), 'org_expected') + const publicKey = createPublicKey({ + key: Buffer.from(authUrl.searchParams.get('public_key')!, 'base64url'), + type: 'spki', + format: 'der', + }) + const payload = scenario.cancel + ? { state: authUrl.searchParams.get('state'), error: 'user_cancelled', error_description: 'Cancelled' } + : { + state: authUrl.searchParams.get('state'), + key_type: 'v1', + encrypted_key: publicEncrypt({ + key: publicKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, Buffer.from(NEW_KEY)).toString('base64url'), + } + const callback = await fetch(authUrl.searchParams.get('redirect_uri')!, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify(payload), + }) + assert.equal(callback.status, 204) + const exitCode = await finished + assert.doesNotMatch(stdout + stderr, /sk-pl-(previous|new-test)/) + return { + exitCode, + stderr, + requests, + config: readFileSync(configPath, 'utf8'), + mode: statSync(configPath).mode & 0o777, + } + } finally { + clearTimeout(deadline) + child.kill('SIGKILL') + await finished + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('setup sign-in', () => { + it('verifies a new key before saving it and preserves other settings', async () => { + const result = await runLogin({ responseStatus: 200, responseBody: IDENTITY }) + assert.equal(result.exitCode, 0) + assert.equal(result.config, `PROMPTLESS_CLI_API_SECRET=${NEW_KEY}\nOTHER_SETTING=keep\n`) + assert.equal(result.mode, 0o600) + assert.deepEqual(result.requests, [{ url: '/v1/me', authorization: `Bearer ${NEW_KEY}` }]) + }) + + it('keeps the previous key when the browser authorizes a different organization', async () => { + const result = await runLogin({ + responseStatus: 200, + responseBody: { ...IDENTITY, organization_id: 'org_other' }, + }) + assert.equal(result.exitCode, 1) + assert.equal(result.config, ORIGINAL_CONFIG) + assert.match(result.stderr, /not org_expected/) + assert.doesNotMatch(result.stderr, /Saved the API key/) + }) + + it('keeps the previous key when identity verification rejects the new key', async () => { + const result = await runLogin({ responseStatus: 401, responseBody: { error: 'authentication_failed' } }) + assert.equal(result.exitCode, 1) + assert.equal(result.config, ORIGINAL_CONFIG) + assert.match(result.stderr, /authentication failed/) + }) + + it('does not save a key or call the API when authorization is cancelled', async () => { + const result = await runLogin({ responseStatus: 200, responseBody: IDENTITY, cancel: true }) + assert.equal(result.exitCode, 1) + assert.equal(result.config, ORIGINAL_CONFIG) + assert.deepEqual(result.requests, []) + assert.match(result.stderr, /Sign-in was cancelled/) + }) +}) diff --git a/src/commands/setup/login.ts b/src/commands/setup/login.ts new file mode 100644 index 0000000..8c42865 --- /dev/null +++ b/src/commands/setup/login.ts @@ -0,0 +1,107 @@ +import { APP_BASE_URL, AuthError, whoami } from '../../lib/api' +import type { WhoamiResponse } from '../../lib/api' +import { buildSignUpUrl, startLoopbackLogin } from '../../lib/auth/loopback' +import { openInBrowser } from '../../lib/browser' +import { getApiSecret, loadConfigFile, resolveConfigFilePath, saveConfigFile } from '../../lib/config' +import type { SetupClient } from '../../lib/setup-api' +import { link } from '../../lib/ui/link' +import { SetupError, done, note, step, warn } from './output' + +// Screen 1: sign in. Reuse the saved key when `/v1/me` accepts it, otherwise +// run the loopback login, and resolve the organization the key is bound to. + +const LOGIN_TIMEOUT_MS = 15 * 60 * 1000 + +export interface SignInOptions { + /** `--org`: the organization setup must run against. */ + org: string | null + /** `--new-account`: open the sign-up page first and skip the saved key. */ + newAccount: boolean + openBrowser: boolean +} + +export interface Session { + client: SetupClient + email: string + orgId: string + orgName: string +} + +async function whoamiOrNull(apiSecret: string): Promise { + try { + return await whoami(apiSecret) + } catch (err) { + if (err instanceof AuthError) return null + throw err + } +} + +async function loopbackSignIn(opts: SignInOptions): Promise { + const login = await startLoopbackLogin({ + timeoutMs: LOGIN_TIMEOUT_MS, + readPastedCode: true, + intent: 'setup', + orgId: opts.org ?? undefined, + onInvalidPaste: () => warn("That code didn't decrypt. Paste the full code, or wait for the browser."), + }) + const url = opts.newAccount ? buildSignUpUrl(APP_BASE_URL, login.authUrl) : login.authUrl + + if (opts.openBrowser) { + openInBrowser(url) + note(`Opened ${link(url)} in your browser.`) + } else { + note(`Open ${link(url)} in your browser.`) + } + if (!opts.newAccount) note('New to Promptless? Create an account on that page.') + note('If the page shows a code instead of returning here, paste the code and press Enter.') + + const result = await login.result + if (!result.ok) { + if (result.code === 'user_cancelled') { + throw new SetupError('cancelled', 'Sign-in was cancelled in the browser. Nothing was changed.') + } + throw new SetupError('login_failed', `Sign-in failed: ${result.error}`) + } + + return result.apiKey +} + +/** + * Sign in and return the session every later screen uses. + * + * @throws SetupError when sign-in fails or is cancelled, or when the new key + * belongs to an organization other than `--org`. + */ +export async function signIn(opts: SignInOptions): Promise { + step(1, 'Sign in') + + const saved = opts.newAccount ? undefined : getApiSecret() + if (saved) { + const me = await whoamiOrNull(saved) + if (me !== null && (opts.org === null || me.organization_id === opts.org)) return signedIn(saved, me) + if (me !== null) { + note(`The saved key belongs to ${me.organization_name ?? me.organization_id}. Sign in to choose ${opts.org}.`) + } + } + + const apiSecret = await loopbackSignIn(opts) + const me = await whoami(apiSecret) + if (opts.org !== null && me.organization_id !== opts.org) { + throw new SetupError( + 'bad_arguments', + `You signed in to ${me.organization_name ?? me.organization_id} (${me.organization_id}), not ${opts.org}. ` + + 'Run `promptless setup` again and choose that organization on the sign-in page.', + ) + } + const config = loadConfigFile() + config.PROMPTLESS_CLI_API_SECRET = apiSecret + saveConfigFile(config) + note(`Saved the API key to ${resolveConfigFilePath()}`) + return signedIn(apiSecret, me) +} + +function signedIn(apiSecret: string, me: WhoamiResponse): Session { + const orgName = me.organization_name ?? me.organization_id + done(`Signed in as ${me.email}, organization ${orgName}`) + return { client: { apiSecret }, email: me.email, orgId: me.organization_id, orgName } +} diff --git a/src/commands/setup/output.ts b/src/commands/setup/output.ts new file mode 100644 index 0000000..d6f5017 --- /dev/null +++ b/src/commands/setup/output.ts @@ -0,0 +1,41 @@ +import type { ErrorCode } from '../../lib/errors' + +// Progress output for `promptless setup`. Steps, notes, and prompts go to +// stderr; only the exit block goes to stdout. + +/** A failure that ends setup. `index.ts` reports it as one `promptless-error:` line and exits 1. */ +export class SetupError extends Error { + readonly code: ErrorCode + + constructor(code: ErrorCode, message: string) { + super(message) + this.name = 'SetupError' + this.code = code + } +} + +/** Print a numbered step heading. */ +export function step(number: number, title: string): void { + process.stderr.write(`\n${number} ${title}\n`) +} + +/** Print an indented note under the current step. */ +export function note(text: string): void { + process.stderr.write(` ${text}\n`) +} + +/** Print a completed item under the current step. */ +export function done(text: string): void { + process.stderr.write(` ✓ ${text}\n`) +} + +/** Print a warning under the current step. */ +export function warn(text: string): void { + process.stderr.write(` ! ${text}\n`) +} + +/** Return a prompt answer, or end setup when the user cancelled the prompt (Ctrl-C or Escape). */ +export function answered(answer: T | null): T { + if (answer === null) throw new SetupError('cancelled', 'Setup was cancelled.') + return answer +} diff --git a/src/commands/setup/review.test.ts b/src/commands/setup/review.test.ts new file mode 100644 index 0000000..b30a921 --- /dev/null +++ b/src/commands/setup/review.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { ApiError } from '../../lib/api' +import { buildApplyRequest, explainApplyError } from './review' + +describe('buildApplyRequest', () => { + it('maps the docs choice and sources to the apply body with no trigger directories', () => { + const body = buildApplyRequest( + { repo: 'acme/docs', framework: 'mintlify', configPath: 'docs/docs.json', filter: ['docs'], existing: false }, + ['acme/api'], + ['merge'], + true, + ) + assert.deepEqual(body, { + doc_collection: { repo: 'acme/docs', filter: ['docs'], docs_framework: 'mintlify', config_file_path: 'docs/docs.json' }, + trigger: { repos: ['acme/api'], trigger_on: ['merge'], trigger_directories: [] }, + dry_run: true, + }) + }) +}) + +describe('explainApplyError', () => { + it('names the missing repositories for repos_not_installed', () => { + const err = new ApiError(422, 'repos_not_installed', 'failed', { + error: 'repos_not_installed', + missing_repos: ['acme/api', 'acme/web'], + }) + const explained = explainApplyError(err) + assert.equal(explained?.code, 'repos_not_installed') + assert.ok(explained?.message.includes('acme/api, acme/web')) + }) + + it('lists each validation path for a promptless.yaml validation failure', () => { + const err = new ApiError(422, null, 'failed', { + error: 'Validation failed', + validation_errors: [ + { message: 'unknown framework', path: 'doc_collections.acme/docs.docs_framework', line: 3 }, + { message: 'bad trigger', path: 'triggers.source-merges', line: null }, + ], + }) + const explained = explainApplyError(err) + assert.equal(explained?.code, 'config_invalid') + assert.ok(explained?.message.includes('doc_collections.acme/docs.docs_framework')) + assert.ok(explained?.message.includes('triggers.source-merges')) + }) + + it('asks for an admin on 403', () => { + assert.equal(explainApplyError(new ApiError(403, null, 'failed', { detail: 'Forbidden' }))?.code, 'admin_required') + }) + + it('leaves other errors to the caller', () => { + assert.equal(explainApplyError(new ApiError(502, 'kb_write_failed', 'failed', { error: 'kb_write_failed' })), null) + assert.equal(explainApplyError(new Error('network')), null) + }) +}) diff --git a/src/commands/setup/review.ts b/src/commands/setup/review.ts new file mode 100644 index 0000000..387576a --- /dev/null +++ b/src/commands/setup/review.ts @@ -0,0 +1,128 @@ +import { ApiError } from '../../lib/api' +import { configValidationIssues, postApply, reposNotInstalled } from '../../lib/setup-api' +import type { ApplyRequest, ApplyResponse, PrLifecycleEvent } from '../../lib/setup-api' +import { selectOne } from '../../lib/ui/prompt' +import type { DocsChoice } from './docs' +import type { Session } from './login' +import { SetupError, answered, done, note, step } from './output' + +// Screen 5: preview the promptless.yaml change with a dry run, confirm it +// (Cancel listed first), and apply it. The runtime writes the file in the +// organization's knowledge-base repository; nothing is written locally. + +export function buildApplyRequest( + docs: DocsChoice, + sourceRepos: string[], + triggerOn: PrLifecycleEvent[], + dryRun: boolean, +): ApplyRequest { + return { + doc_collection: { + repo: docs.repo, + filter: docs.filter, + docs_framework: docs.framework, + config_file_path: docs.configPath, + }, + trigger: { repos: sourceRepos, trigger_on: triggerOn, trigger_directories: [] }, + dry_run: dryRun, + } +} + +/** + * Translate an `apply` failure the user can act on into the error that ends + * setup, or return null for any other error. + */ +export function explainApplyError(err: unknown): SetupError | null { + const missing = reposNotInstalled(err) + if (missing !== null) { + const repos = missing.join(', ') + return new SetupError( + 'repos_not_installed', + `The Promptless GitHub App cannot see ${repos}. Grant it access on GitHub, ` + + 'then run `promptless setup` again.', + ) + } + + const issues = configValidationIssues(err) + if (issues !== null) { + const lines = issues.map((issue) => { + const where = issue.path === null ? '' : `${issue.path}: ` + const line = issue.line === null ? '' : ` (line ${issue.line})` + return ` ${where}${issue.message}${line}` + }) + return new SetupError('config_invalid', ['promptless.yaml would not be valid:', ...lines].join('\n')) + } + + if (err instanceof ApiError && err.status === 403) { + return new SetupError( + 'admin_required', + 'Changing promptless.yaml needs an organization admin. Ask an admin to run `promptless setup`.', + ) + } + if (err instanceof ApiError && err.code === 'write_in_progress') { + return new SetupError( + 'api_error', + 'Another change to promptless.yaml is in progress. Run `promptless setup` again in a minute.', + ) + } + return null +} + +async function apply(session: Session, body: ApplyRequest): Promise { + try { + return await postApply(session.client, session.orgId, body) + } catch (err) { + throw explainApplyError(err) ?? err + } +} + +function indent(text: string): string { + return text + .trimEnd() + .split('\n') + .map((line) => ` ${line}`) + .join('\n') +} + +/** + * Screen 5: review and apply the doc collection and trigger. + * + * @throws SetupError when the user cancels or the runtime rejects the change. + */ +export async function reviewAndApply( + session: Session, + docs: DocsChoice, + sourceRepos: string[], + triggerOn: PrLifecycleEvent[], +): Promise { + step(5, 'Review') + + const preview = await apply(session, buildApplyRequest(docs, sourceRepos, triggerOn, true)) + if (!preview.changed) { + done('promptless.yaml already has this collection and trigger. Nothing to change.') + return preview + } + + note('promptless.yaml after this change:') + process.stderr.write(`${indent(preview.yaml)}\n`) + const choice = answered( + await selectOne<'cancel' | 'apply'>({ + message: 'Apply this change?', + choices: [ + { value: 'cancel', label: 'Cancel' }, + { value: 'apply', label: 'Apply' }, + ], + }), + ) + if (choice === 'cancel') { + throw new SetupError('cancelled', 'Nothing was changed. Run `promptless setup` again when you are ready.') + } + + const result = await apply(session, buildApplyRequest(docs, sourceRepos, triggerOn, false)) + done( + result.kb_commit_sha === null + ? 'Saved promptless.yaml' + : `Saved promptless.yaml (commit ${result.kb_commit_sha.slice(0, 7)})`, + ) + return result +} diff --git a/src/commands/setup/sources.test.ts b/src/commands/setup/sources.test.ts new file mode 100644 index 0000000..570f68e --- /dev/null +++ b/src/commands/setup/sources.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { defaultSourceRepos } from './sources' + +const INSTALLED = [ + { full_name: 'Acme/API', default_branch: 'main' }, + { full_name: 'acme/docs', default_branch: 'main' }, +] + +describe('defaultSourceRepos', () => { + it('preselects the installed cwd repository in inventory casing', () => { + assert.deepEqual(defaultSourceRepos(INSTALLED, 'acme/api', 'acme/docs'), ['Acme/API']) + }) + + it('preselects nothing when the cwd repository is the docs repository', () => { + assert.deepEqual(defaultSourceRepos(INSTALLED, 'acme/docs', 'Acme/Docs'), []) + }) + + it('preselects nothing without a cwd repository or when it is not installed', () => { + assert.deepEqual(defaultSourceRepos(INSTALLED, null, 'acme/docs'), []) + assert.deepEqual(defaultSourceRepos(INSTALLED, 'acme/web', 'acme/docs'), []) + }) +}) diff --git a/src/commands/setup/sources.ts b/src/commands/setup/sources.ts new file mode 100644 index 0000000..68abf90 --- /dev/null +++ b/src/commands/setup/sources.ts @@ -0,0 +1,82 @@ +import type { GitHubRepo } from '../../lib/git-remote' +import { installedRepos } from '../../lib/setup-api' +import type { InstalledRepo, SetupStatus } from '../../lib/setup-api' +import { selectMany } from '../../lib/ui/prompt' +import { SetupError, answered, done, step } from './output' + +// Screen 4: choose the source repositories whose merged pull requests update +// the docs. The list is always explicit, never `all`, so a later GitHub grant +// does not widen the trigger unnoticed. + +/** Either the repositories for a trigger setup adds, or an existing trigger setup keeps. */ +export type SourceChoice = + | { kind: 'new'; repos: string[] } + | { kind: 'existing'; triggerKey: string; repos: string[] | 'all' } + +/** + * The repositories preselected in the prompt: the current directory's + * repository, in inventory casing, when it is installed and is not the docs repository. + */ +export function defaultSourceRepos(installed: InstalledRepo[], cwdRepo: string | null, docsRepo: string): string[] { + if (cwdRepo === null || cwdRepo.toLowerCase() === docsRepo.toLowerCase()) return [] + const match = installed.find((repo) => repo.full_name.toLowerCase() === cwdRepo.toLowerCase()) + return match === undefined ? [] : [match.full_name] +} + +/** + * Screen 4: choose the source repositories. + * + * @param sourceRepoFlags `--source-repo` values, which skip the prompt. + * @param docsExisting True when the docs collection already exists; an existing + * `github_pr` trigger is then kept and the prompt is skipped. + */ +export async function chooseSourceRepos( + status: SetupStatus, + docsRepo: string, + docsExisting: boolean, + sourceRepoFlags: string[], + cwdRepo: GitHubRepo | null, +): Promise { + step(4, 'Choose source repositories') + + if (sourceRepoFlags.length > 0) { + done(`Update the docs when a pull request merges in: ${sourceRepoFlags.join(', ')}`) + return { kind: 'new', repos: sourceRepoFlags } + } + + const existingTrigger = docsExisting + ? status.triggers.find((trigger) => trigger.trigger_type === 'github_pr' && trigger.repos !== null) + : undefined + if (existingTrigger !== undefined && existingTrigger.repos !== null) { + const repos = existingTrigger.repos + done( + `Already watching ${repos === 'all' ? 'every repository the GitHub App can see' : repos.join(', ')} ` + + `(trigger ${existingTrigger.key}). Pass --source-repo to add more.`, + ) + return { kind: 'existing', triggerKey: existingTrigger.key, repos } + } + + const installed = installedRepos(status) + if (installed.length === 0) { + throw new SetupError( + 'repos_not_installed', + 'The GitHub App installation lists no repositories. Grant it your source repositories on GitHub, ' + + 'then run `promptless setup` again.', + ) + } + const cwd = cwdRepo?.fullName.toLowerCase() ?? null + const repos = answered( + await selectMany({ + message: 'Update the docs when a pull request merges in:', + choices: installed.map((repo) => ({ + value: repo.full_name, + label: repo.full_name, + hint: repo.full_name.toLowerCase() === cwd ? 'this directory' : undefined, + })), + initial: defaultSourceRepos(installed, cwdRepo?.fullName ?? null, docsRepo), + required: true, + }), + ) + done(`Update the docs when a pull request merges in: ${repos.join(', ')}`) + return { kind: 'new', repos } +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index c6f526b..2fca7e7 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -37,12 +37,8 @@ async function _run(argv: string[]): Promise { process.stdout.write(JSON.stringify(me, null, 2) + '\n') } else { process.stdout.write(`${me.email}${me.name ? ` (${me.name})` : ''}\n`) - if (me.organizations && me.organizations.length > 0) { - process.stdout.write('Organizations:\n') - for (const org of me.organizations) { - process.stdout.write(` - ${org.name} (${org.id})\n`) - } - } + const orgName = me.organization_name ?? me.organization_id + process.stdout.write(`Organization: ${orgName} (${me.organization_id})\n`) } process.exit(0) } catch (err) { diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 75b3c8b..083ca16 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -4,7 +4,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http' import type { AddressInfo } from 'node:net' import { after, before, describe, it } from 'node:test' -import { ApiError, AuthError, parseErrorBody, request } from './api' +import { ApiError, AuthError, NetworkError, parseErrorBody, request } from './api' describe('parseErrorBody', () => { it('reads a FastAPI string detail', () => { @@ -194,7 +194,7 @@ describe('request', () => { it('times out', async () => { await assert.rejects( request('GET', '/slow', { apiSecret: 'k', baseUrl, timeoutMs: 50 }), - (err) => err instanceof Error && !(err instanceof ApiError) && /timed out/.test(err.message), + (err) => err instanceof NetworkError && /timed out/.test(err.message), ) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 80b1e58..26cc675 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -38,6 +38,14 @@ export class AuthError extends ApiError { } } +/** The request never got an HTTP response: a connection failure or a timeout. */ +export class NetworkError extends Error { + constructor(message: string) { + super(message) + this.name = 'NetworkError' + } +} + export interface ParsedErrorBody { code: string | null message: string | null @@ -117,7 +125,7 @@ export interface RequestOptions { * * @throws AuthError on 401. * @throws ApiError on any other non-2xx status. - * @throws Error on a network failure or timeout. A caller's abort rethrows its signal's reason. + * @throws NetworkError on a connection failure or timeout. A caller's abort rethrows its signal's reason. */ export async function request(method: string, path: string, opts: RequestOptions): Promise { const baseUrl = opts.baseUrl ?? API_BASE_URL @@ -145,10 +153,10 @@ export async function request(method: string, path: string, opts: RequestOpti } catch (err) { if (opts.signal?.aborted) throw opts.signal.reason if (timeoutSignal.aborted) { - throw new Error(`${method} ${path} timed out after ${timeoutMs}ms contacting ${baseUrl}`) + throw new NetworkError(`${method} ${path} timed out after ${timeoutMs}ms contacting ${baseUrl}`) } const msg = err instanceof Error ? err.message : String(err) - throw new Error(`network error contacting ${baseUrl}: ${msg}`) + throw new NetworkError(`network error contacting ${baseUrl}: ${msg}`) } const body = parseJsonOrText(text) @@ -163,16 +171,18 @@ export async function request(method: string, path: string, opts: RequestOpti return (body ?? undefined) as T } +/** `GET /v1/me`: the key's owner and the one organization the key is bound to. */ export interface WhoamiResponse { user_id: string email: string - name?: string - organizations?: Array<{ id: string; name: string }> + name: string | null + organization_id: string + organization_name: string | null } -export async function whoami(apiSecret: string): Promise { +export async function whoami(apiSecret: string, baseUrl?: string): Promise { try { - return await request('GET', '/v1/me', { apiSecret }) + return await request('GET', '/v1/me', { apiSecret, baseUrl }) } catch (err) { // `/v1/me` answers 403 for a key bound to no user, which `promptless login` fixes. if (err instanceof ApiError && err.status === 403) { diff --git a/src/lib/auth/loopback.test.ts b/src/lib/auth/loopback.test.ts index 2634ed8..df33f40 100644 --- a/src/lib/auth/loopback.test.ts +++ b/src/lib/auth/loopback.test.ts @@ -3,7 +3,7 @@ import { constants as cryptoConstants, createPublicKey, publicEncrypt } from 'no import { request as httpRequest } from 'node:http' import { describe, it } from 'node:test' -import { CALLBACK_PATH, KEY_TYPE, buildAuthUrl, startLoopbackLogin } from './loopback' +import { CALLBACK_PATH, KEY_TYPE, buildAuthUrl, buildSignUpUrl, startLoopbackLogin } from './loopback' import type { LoopbackLogin } from './loopback' const APP = 'https://app.example.test' @@ -61,11 +61,25 @@ describe('buildAuthUrl', () => { assert.equal(url.searchParams.has('intent'), false) }) + it('omits org_id by default and adds it when given', () => { + assert.equal(new URL(buildAuthUrl(base)).searchParams.has('org_id'), false) + assert.equal(new URL(buildAuthUrl({ ...base, orgId: 'org_1' })).searchParams.get('org_id'), 'org_1') + }) + it('adds intent when given', () => { assert.equal(new URL(buildAuthUrl({ ...base, intent: 'setup' })).searchParams.get('intent'), 'setup') }) }) +describe('buildSignUpUrl', () => { + it('carries the full auth URL as redirect_url', () => { + const authUrl = `${APP}/cli/auth?public_key=pk&state=s&intent=setup` + const url = new URL(buildSignUpUrl(APP, authUrl)) + assert.equal(url.origin + url.pathname, `${APP}/sign-up`) + assert.equal(url.searchParams.get('redirect_url'), authUrl) + }) +}) + describe('startLoopbackLogin', () => { it('binds 127.0.0.1 on a free port and passes intent through', async () => { const login = await startLoopbackLogin({ @@ -183,4 +197,38 @@ describe('startLoopbackLogin', () => { assert.equal(result.ok, false) assert.match(result.ok ? '' : result.error, /timed out/) }) + + it('settles with the auth page error code on a cancel callback', async () => { + const login = await start() + const { redirectUri, state } = params(login) + const response = await post( + redirectUri, + JSON.stringify({ error: 'user_cancelled', error_description: 'User cancelled CLI authorization.', state }), + APP, + ) + assert.equal(response.status, 204) + assert.deepEqual(await login.result, { + ok: false, + error: 'User cancelled CLI authorization.', + code: 'user_cancelled', + }) + }) + + it('falls back to the error code when a cancel callback has no description', async () => { + const login = await start() + const { redirectUri, state } = params(login) + assert.equal((await post(redirectUri, JSON.stringify({ error: 'issue_failed', state }), APP)).status, 204) + assert.deepEqual(await login.result, { ok: false, error: 'issue_failed', code: 'issue_failed' }) + }) + + it('rejects a cancel callback with the wrong state', async () => { + const login = await start() + const { redirectUri } = params(login) + const response = await post(redirectUri, JSON.stringify({ error: 'user_cancelled', state: 'wrong' }), APP) + assert.equal(response.status, 400) + assert.equal(JSON.parse(response.body).error, 'state_mismatch') + const result = await login.result + assert.equal(result.ok, false) + assert.equal(result.ok ? 'ok' : result.code, null) + }) }) diff --git a/src/lib/auth/loopback.ts b/src/lib/auth/loopback.ts index 6dd0374..cbf683c 100644 --- a/src/lib/auth/loopback.ts +++ b/src/lib/auth/loopback.ts @@ -30,7 +30,13 @@ export const CALLBACK_PATH = '/auth/callback' const MAX_BODY_BYTES = 64 * 1024 -export type CallbackResult = { ok: true; apiKey: string } | { ok: false; error: string } +/** + * The login outcome. On failure, `code` is the error code the auth page sent + * (for example `user_cancelled`), or null when the CLI detected the failure itself. + */ +export type CallbackResult = + | { ok: true; apiKey: string } + | { ok: false; error: string; code: string | null } export interface LoopbackLoginOptions { /** Milliseconds to wait for a callback or pasted code before failing. */ @@ -41,6 +47,8 @@ export interface LoopbackLoginOptions { onInvalidPaste?: () => void /** Tells the auth page which flow started the login (for example `setup`). */ intent?: string + /** The organization the auth page preselects. The user can still choose another. */ + orgId?: string /** Defaults to `APP_BASE_URL`. The callback accepts only this origin. */ appBaseUrl?: string } @@ -104,6 +112,21 @@ interface AuthUrlParams { redirectUri: string state: string intent?: string + orgId?: string +} + +/** + * Wrap an auth URL in the sign-up page, which sends a new user on to the auth + * page once their account exists. + * + * @example + * buildSignUpUrl('https://app.example', 'https://app.example/cli/auth?state=s') + * // → 'https://app.example/sign-up?redirect_url=https%3A%2F%2Fapp.example%2Fcli%2Fauth%3Fstate%3Ds' + */ +export function buildSignUpUrl(appBaseUrl: string, authUrl: string): string { + const signUpUrl = new URL(`${appBaseUrl}/sign-up`) + signUpUrl.searchParams.set('redirect_url', authUrl) + return signUpUrl.toString() } export function buildAuthUrl(params: AuthUrlParams): string { @@ -113,6 +136,7 @@ export function buildAuthUrl(params: AuthUrlParams): string { authUrl.searchParams.set('redirect_uri', params.redirectUri) authUrl.searchParams.set('state', params.state) if (params.intent) authUrl.searchParams.set('intent', params.intent) + if (params.orgId) authUrl.searchParams.set('org_id', params.orgId) return authUrl.toString() } @@ -175,6 +199,7 @@ export async function startLoopbackLogin(opts: LoopbackLoginOptions): Promise @@ -274,6 +300,24 @@ export async function startLoopbackLogin(opts: LoopbackLoginOptions): Promise 0 + ? payload.error_description + : payload.error + applyCors(res, origin) + res.writeHead(204) + res.end() + finish({ ok: false, error: description, code: payload.error }) + return + } + if (receivedKeyType !== KEY_TYPE) { reject( { error: 'unsupported_key_type', expected: KEY_TYPE }, diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 7dcd7c1..7afdde8 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -17,10 +17,14 @@ export const ERROR_CODES = [ 'login_failed', 'network_error', 'api_error', + 'setup_unavailable', 'github_not_connected', 'cancelled', 'config_invalid', 'internal_error', + 'admin_required', + 'repos_not_installed', + 'doc_collection_required', ] as const export type ErrorCode = (typeof ERROR_CODES)[number] diff --git a/src/lib/setup-api.test.ts b/src/lib/setup-api.test.ts new file mode 100644 index 0000000..84cfad9 --- /dev/null +++ b/src/lib/setup-api.test.ts @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { after, before, beforeEach, describe, it } from 'node:test' + +import { ApiError } from './api' +import { + GITHUB_CONNECTED_RETURN_PATH, + beginGitHubInstall, + completeOnboarding, + configValidationIssues, + getBackfillStatus, + getSetupStatus, + installedRepos, + postApply, + postDocsCandidates, + previewBackfill, + pullGitHubInstallations, + reposNotInstalled, + startBackfill, +} from './setup-api' +import type { ApplyRequest, SetupClient, SetupStatus } from './setup-api' + +// Contract tests: each call against a local fake runtime that records the +// request and answers with the shapes the runtime routes document. + +interface Recorded { + method: string + path: string + authorization: string | undefined + body: unknown +} + +interface Reply { + status: number + body: unknown +} + +const ORG = 'org_abc' + +const STATUS: SetupStatus = { + onboarding_completed: false, + billing: { status: 'trialing', trial_end: '2026-10-15T00:00:00+00:00' }, + github: { + connected: true, + installations: [ + { + owner: 'acme', + repository_selection: 'selected', + repos: [ + { full_name: 'acme/docs', default_branch: 'main' }, + { full_name: 'acme/api', default_branch: 'main' }, + ], + }, + { owner: 'acme-labs', repository_selection: 'all', repos: [{ full_name: 'Acme/Docs', default_branch: 'main' }] }, + ], + pending_approval: [{ github_org_login: 'other', requested_at: '2026-10-01T12:00:00+00:00' }], + }, + doc_collections: [ + { repo: 'acme/docs', platform: 'github', docs_framework: 'mintlify', config_file_path: 'docs.json', filter: [] }, + ], + triggers: [{ key: 'source-merges', trigger_type: 'github_pr', repos: ['acme/api'] }], +} + +const APPLY_BODY: ApplyRequest = { + doc_collection: { repo: 'acme/docs', filter: ['docs'], docs_framework: 'mintlify', config_file_path: 'docs/docs.json' }, + trigger: { repos: ['acme/api'], trigger_on: ['merge'], trigger_directories: [] }, + dry_run: true, +} + +describe('setup API contract', () => { + let server: Server + let client: SetupClient + let recorded: Recorded[] = [] + let replies: Record = {} + + before(async () => { + server = createServer((req, res) => { + let raw = '' + req.on('data', (chunk: Buffer) => (raw += chunk.toString('utf8'))) + req.on('end', () => { + const key = `${req.method} ${req.url}` + recorded.push({ + method: req.method ?? '', + path: req.url ?? '', + authorization: req.headers.authorization, + body: raw.length > 0 ? JSON.parse(raw) : undefined, + }) + const reply = replies[key] ?? { status: 404, body: { error: 'not_found' } } + res.writeHead(reply.status, { 'Content-Type': 'application/json' }) + res.end(reply.body === undefined ? '' : JSON.stringify(reply.body)) + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + client = { apiSecret: 'sk-pl-test', baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` } + }) + + after(() => { + server.closeAllConnections() + server.close() + }) + + beforeEach(() => { + recorded = [] + replies = {} + }) + + function only(): Recorded { + assert.equal(recorded.length, 1) + return recorded[0] + } + + it('reads setup status with the bearer key', async () => { + replies[`GET /organizations/${ORG}/setup/status`] = { status: 200, body: STATUS } + assert.deepEqual(await getSetupStatus(client, ORG), STATUS) + assert.equal(only().authorization, 'Bearer sk-pl-test') + }) + + it('encodes the organization id in the path', async () => { + replies['GET /organizations/org%2Fodd/setup/status'] = { status: 200, body: STATUS } + await getSetupStatus(client, 'org/odd') + assert.equal(only().path, '/organizations/org%2Fodd/setup/status') + }) + + it('explains when the setup routes have not been deployed', async () => { + replies[`GET /organizations/${ORG}/setup/status`] = { status: 404, body: { detail: 'Not Found' } } + await assert.rejects(getSetupStatus(client, ORG), (err) => { + assert.ok(err instanceof ApiError) + assert.equal(err.code, 'setup_unavailable') + assert.match(err.message, /backend must be deployed/) + return true + }) + }) + + it('preserves a specific organization-not-found response', async () => { + replies[`GET /organizations/${ORG}/setup/status`] = { status: 404, body: { error: 'organization_not_found' } } + await assert.rejects(getSetupStatus(client, ORG), (err) => { + assert.ok(err instanceof ApiError) + assert.equal(err.code, 'organization_not_found') + return true + }) + }) + + it('sends cwd_repo to docs-candidates, including null', async () => { + const body = { + cwd_repo_installed: true, + candidates: [ + { + full_name: 'acme/docs', + default_branch: 'main', + framework: 'mintlify', + config_path: 'docs/docs.json', + doc_count: 214, + proposed_filter: ['docs'], + reason: 'name_match', + }, + ], + analysis_failures: [{ full_name: 'acme/empty', status_code: 409 }], + } + replies[`POST /organizations/${ORG}/setup/docs-candidates`] = { status: 200, body } + + assert.deepEqual(await postDocsCandidates(client, ORG, 'acme/api'), body) + assert.deepEqual(only().body, { cwd_repo: 'acme/api' }) + + recorded = [] + await postDocsCandidates(client, ORG, null) + assert.deepEqual(only().body, { cwd_repo: null }) + }) + + it('surfaces 409 github_not_connected from docs-candidates', async () => { + replies[`POST /organizations/${ORG}/setup/docs-candidates`] = { + status: 409, + body: { error: 'github_not_connected' }, + } + await assert.rejects(postDocsCandidates(client, ORG, null), (err) => { + assert.ok(err instanceof ApiError) + assert.equal(err.status, 409) + assert.equal(err.code, 'github_not_connected') + return true + }) + }) + + it('posts the apply body and returns the result', async () => { + const body = { + dry_run: true, + changed: true, + yaml: 'doc_collections:\n acme/docs: {}\n', + kb_commit_sha: null, + doc_collection: { key: 'acme/docs', added: true }, + trigger: { key: 'source-merges', added: true }, + } + replies[`POST /organizations/${ORG}/setup/apply`] = { status: 200, body } + assert.deepEqual(await postApply(client, ORG, APPLY_BODY), body) + assert.deepEqual(only().body, APPLY_BODY) + }) + + it('reads 422 repos_not_installed from apply', async () => { + replies[`POST /organizations/${ORG}/setup/apply`] = { + status: 422, + body: { error: 'repos_not_installed', missing_repos: ['acme/api', 'acme/web'] }, + } + const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught) + assert.deepEqual(reposNotInstalled(err), ['acme/api', 'acme/web']) + assert.equal(configValidationIssues(err), null) + }) + + it('reads 422 promptless.yaml validation errors from apply', async () => { + replies[`POST /organizations/${ORG}/setup/apply`] = { + status: 422, + body: { + error: 'Validation failed', + validation_errors: [ + { message: 'unknown framework', path: 'doc_collections.acme/docs.docs_framework', line: 3 }, + { message: 'whole config is invalid', path: null, line: null }, + ], + }, + } + const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught) + assert.equal(reposNotInstalled(err), null) + assert.deepEqual(configValidationIssues(err), [ + { message: 'unknown framework', path: 'doc_collections.acme/docs.docs_framework', line: 3 }, + { message: 'whole config is invalid', path: null, line: null }, + ]) + }) + + it('reads 422 request-schema errors from apply', async () => { + replies[`POST /organizations/${ORG}/setup/apply`] = { + status: 422, + body: { detail: [{ loc: ['body', 'trigger', 'repos'], msg: 'List should have at least 1 item', type: 'too_short' }] }, + } + const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught) + assert.deepEqual(configValidationIssues(err), [ + { message: 'List should have at least 1 item', path: 'body.trigger.repos', line: null }, + ]) + }) + + it('surfaces 409 write_in_progress from apply as an ApiError', async () => { + replies[`POST /organizations/${ORG}/setup/apply`] = { status: 409, body: { error: 'write_in_progress' } } + const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught) + assert.ok(err instanceof ApiError) + assert.equal(err.code, 'write_in_progress') + assert.equal(reposNotInstalled(err), null) + assert.equal(configValidationIssues(err), null) + }) + + it('begins a GitHub install with the CLI return page', async () => { + replies['POST /integrations/github/begin'] = { + status: 200, + body: { redirect_url: 'https://github.com/apps/promptless/installations/new?state=s' }, + } + assert.equal( + await beginGitHubInstall(client, ORG), + 'https://github.com/apps/promptless/installations/new?state=s', + ) + assert.deepEqual(only().body, { org_id: ORG, return_to: GITHUB_CONNECTED_RETURN_PATH }) + }) + + it('refreshes GitHub installations', async () => { + replies['POST /integrations/github/pull'] = { status: 200, body: { installations: [] } } + await pullGitHubInstallations(client, ORG) + assert.deepEqual(only().body, { org_id: ORG }) + }) + + it('completes onboarding with an empty body', async () => { + replies[`POST /organizations/${ORG}/onboarding/complete`] = { status: 200, body: { status: 'ok' } } + assert.deepEqual(await completeOnboarding(client, ORG), { status: 'ok' }) + assert.equal(only().body, undefined) + }) + + it('surfaces 409 doc_collection_required from onboarding completion', async () => { + replies[`POST /organizations/${ORG}/onboarding/complete`] = { + status: 409, + body: { error: 'doc_collection_required', message: 'Connect at least one docs repository.' }, + } + await assert.rejects(completeOnboarding(client, ORG), (err) => { + assert.ok(err instanceof ApiError) + assert.equal(err.code, 'doc_collection_required') + return true + }) + }) + + it('previews, starts, and reads a backfill with the trigger key encoded', async () => { + const base = `/organizations/${ORG}/promptless_config/triggers/source-merges%202` + const preview = { + count: 2, + approximate: false, + unsupported_reason: null, + prs: [ + { + repo: 'acme/api', + number: 7, + title: 'Add a flag', + url: 'https://github.com/acme/api/pull/7', + created_at: '2026-09-28T00:00:00+00:00', + merged_at: '2026-09-29T00:00:00+00:00', + status: 'merged', + }, + ], + } + const status = { + latest_job: { + id: 'job-1', + state: 'dispatching', + lookback_days: 7, + requested_by: 'dev@acme.com', + matched_count: 2, + dispatched_count: 1, + processed_count: 0, + failed_dispatch_count: 0, + error: null, + created_at: '2026-10-01T00:00:00+00:00', + dispatch_active: true, + }, + suggestion_activity: { total_count: 0, has_more: false, suggestions: [] }, + } + replies[`POST ${base}/backfill-preview`] = { status: 200, body: preview } + replies[`POST ${base}/backfill`] = { status: 202, body: { accepted: true, matched: null, job_id: 'job-1' } } + replies[`GET ${base}/backfill-status`] = { status: 200, body: status } + + const request = { lookback_days: 7, skip_processed: true } as const + assert.deepEqual(await previewBackfill(client, ORG, 'source-merges 2', request), preview) + assert.deepEqual(await startBackfill(client, ORG, 'source-merges 2', request), { + accepted: true, + matched: null, + job_id: 'job-1', + }) + assert.deepEqual(await getBackfillStatus(client, ORG, 'source-merges 2'), status) + assert.deepEqual( + recorded.map((r) => [r.method, r.path, r.body]), + [ + ['POST', `${base}/backfill-preview`, request], + ['POST', `${base}/backfill`, request], + ['GET', `${base}/backfill-status`, undefined], + ], + ) + }) + + it('surfaces 409 backfill_already_active and 402 trial_expired from a backfill start', async () => { + const path = `/organizations/${ORG}/promptless_config/triggers/source-merges/backfill` + const request = { lookback_days: 7, skip_processed: true } as const + + replies[`POST ${path}`] = { status: 409, body: { error: 'backfill_already_active' } } + await assert.rejects(startBackfill(client, ORG, 'source-merges', request), (err) => { + assert.ok(err instanceof ApiError) + assert.equal(err.code, 'backfill_already_active') + return true + }) + + replies[`POST ${path}`] = { status: 402, body: { error: 'trial_expired', message: 'Trial ended 15 Oct.' } } + await assert.rejects(startBackfill(client, ORG, 'source-merges', request), (err) => { + assert.ok(err instanceof ApiError) + assert.equal(err.status, 402) + assert.equal(err.code, 'trial_expired') + return true + }) + }) + + it('reads an empty backfill status', async () => { + replies[`GET /organizations/${ORG}/promptless_config/triggers/source-merges/backfill-status`] = { + status: 200, + body: { latest_job: null, suggestion_activity: null }, + } + assert.deepEqual(await getBackfillStatus(client, ORG, 'source-merges'), { + latest_job: null, + suggestion_activity: null, + }) + }) +}) + +describe('installedRepos', () => { + it('flattens installations and drops case-insensitive duplicates', () => { + assert.deepEqual( + installedRepos(STATUS).map((repo) => repo.full_name), + ['acme/docs', 'acme/api'], + ) + }) +}) diff --git a/src/lib/setup-api.ts b/src/lib/setup-api.ts new file mode 100644 index 0000000..90e9bc9 --- /dev/null +++ b/src/lib/setup-api.ts @@ -0,0 +1,334 @@ +// Typed calls for `promptless setup`. Every request and response shape the +// setup command depends on lives here, so a runtime contract change touches +// one file. The contracts are documented in the runtime repository: +// runtime/onboarding/setup/README.md setup/status, setup/docs-candidates, setup/apply +// runtime/cli/README.md GitHub begin/pull, onboarding/complete, backfill routes + +import { ApiError, request } from './api' + +/** The base URL and key every setup call sends. */ +export interface SetupClient { + apiSecret: string + /** Defaults to `API_BASE_URL`. */ + baseUrl?: string +} + +export type PrLifecycleEvent = 'opened' | 'first_approval' | 'merge' | 'updated' + +export const PR_LIFECYCLE_EVENTS: readonly PrLifecycleEvent[] = ['opened', 'first_approval', 'merge', 'updated'] + +export interface BillingStatus { + status: 'pending' | 'trialing' | 'active' | 'exempt' + /** ISO 8601 timestamp, or null when no trial was started. */ + trial_end: string | null +} + +export interface InstalledRepo { + full_name: string + default_branch: string | null +} + +export interface GitHubInstallation { + owner: string | null + repository_selection: 'all' | 'selected' | null + repos: InstalledRepo[] +} + +export interface PendingApproval { + github_org_login: string + requested_at: string +} + +export interface SetupDocCollection { + repo: string + platform: string + docs_framework: string | null + config_file_path: string | null + filter: string[] +} + +export interface SetupTrigger { + key: string + trigger_type: string + /** `"all"`, the repositories its clauses name, or null for a trigger type that names none. */ + repos: 'all' | string[] | null +} + +/** `GET /organizations/{org}/setup/status`. */ +export interface SetupStatus { + onboarding_completed: boolean + /** Null when the organization has no commerce row. */ + billing: BillingStatus | null + github: { + connected: boolean + installations: GitHubInstallation[] + pending_approval: PendingApproval[] + } + doc_collections: SetupDocCollection[] + triggers: SetupTrigger[] +} + +export type CandidateReason = 'cwd_repo' | 'only_installed_repo' | 'name_match' | 'markdown_count' + +export interface DocsCandidate { + full_name: string + default_branch: string | null + framework: string | null + config_path: string | null + doc_count: number + /** The directory that holds the framework config; empty when it is the root or none was found. */ + proposed_filter: string[] + reason: CandidateReason +} + +/** `POST /organizations/{org}/setup/docs-candidates`. Candidates are in rank order. */ +export interface DocsCandidatesResponse { + cwd_repo_installed: boolean + candidates: DocsCandidate[] + analysis_failures: Array<{ full_name: string; status_code: number }> +} + +export interface ApplyRequest { + doc_collection: { + repo: string + filter: string[] + docs_framework: string | null + config_file_path: string | null + } + trigger: { + repos: string[] + trigger_on: PrLifecycleEvent[] + trigger_directories: string[] + } + dry_run: boolean +} + +/** `POST /organizations/{org}/setup/apply`. */ +export interface ApplyResponse { + dry_run: boolean + /** False when the config already holds the collection and an equivalent trigger. */ + changed: boolean + yaml: string + /** Null for a dry run and for an apply that changed nothing. */ + kb_commit_sha: string | null + doc_collection: { key: string; added: boolean } + trigger: { key: string; added: boolean } +} + +/** One `promptless.yaml` validation failure from a 422. */ +export interface ConfigValidationIssue { + message: string + path: string | null + line: number | null +} + +/** `POST /organizations/{org}/onboarding/complete`. The trial it starts is read back from `setup/status`. */ +export interface OnboardingCompleteResponse { + status: 'ok' +} + +export type BackfillLookbackDays = 7 | 14 | 30 + +export interface BackfillRequest { + lookback_days: BackfillLookbackDays + skip_processed: boolean +} + +export interface BackfillPreviewPullRequest { + repo: string + number: number + title: string + url: string + created_at: string + merged_at: string | null + status: string +} + +/** `POST .../triggers/{key}/backfill-preview`. */ +export interface BackfillPreview { + count: number + approximate: boolean + /** Set, with `count` 0, when the trigger cannot be replayed as pull request history. */ + unsupported_reason: string | null + prs: BackfillPreviewPullRequest[] +} + +/** `POST .../triggers/{key}/backfill` (202). */ +export interface BackfillStarted { + accepted: boolean + matched: number | null + job_id: string +} + +export interface BackfillJob { + id: string + state: string + lookback_days: number + requested_by: string | null + matched_count: number | null + dispatched_count: number + processed_count: number + failed_dispatch_count: number + error: string | null + created_at: string + dispatch_active: boolean +} + +/** `GET .../triggers/{key}/backfill-status`. */ +export interface BackfillStatus { + latest_job: BackfillJob | null + suggestion_activity: { + total_count: number + has_more: boolean + suggestions: Array<{ + id: string + title: string + pr_status: string | null + created_at: string + created_by_backfill: boolean + }> + } | null +} + +/** The return page the GitHub install callback sends the browser to. */ +export const GITHUB_CONNECTED_RETURN_PATH = '/cli/setup/github-connected' + +function orgPath(orgId: string): string { + return `/organizations/${encodeURIComponent(orgId)}` +} + +function triggerPath(orgId: string, triggerKey: string): string { + return `${orgPath(orgId)}/promptless_config/triggers/${encodeURIComponent(triggerKey)}` +} + +export async function getSetupStatus(client: SetupClient, orgId: string): Promise { + try { + return await request('GET', `${orgPath(orgId)}/setup/status`, client) + } catch (err) { + if (err instanceof ApiError && err.status === 404 && recordOf(err.body)?.detail === 'Not Found') { + throw new ApiError( + err.status, + 'setup_unavailable', + 'This Promptless server does not provide CLI setup yet. ' + + 'The setup backend must be deployed before this command can continue. ' + + 'You can use the dashboard to finish onboarding.', + err.body, + ) + } + throw err + } +} + +export function postDocsCandidates( + client: SetupClient, + orgId: string, + cwdRepo: string | null, +): Promise { + return request('POST', `${orgPath(orgId)}/setup/docs-candidates`, { + ...client, + body: { cwd_repo: cwdRepo }, + }) +} + +export function postApply(client: SetupClient, orgId: string, body: ApplyRequest): Promise { + return request('POST', `${orgPath(orgId)}/setup/apply`, { ...client, body }) +} + +/** Start a GitHub App install and return the URL to open. */ +export async function beginGitHubInstall(client: SetupClient, orgId: string): Promise { + const response = await request<{ redirect_url: string }>('POST', '/integrations/github/begin', { + ...client, + body: { org_id: orgId, return_to: GITHUB_CONNECTED_RETURN_PATH }, + }) + return response.redirect_url +} + +/** Ask the runtime to re-read the organization's GitHub installations from GitHub. */ +export async function pullGitHubInstallations(client: SetupClient, orgId: string): Promise { + await request('POST', '/integrations/github/pull', { ...client, body: { org_id: orgId } }) +} + +export function completeOnboarding(client: SetupClient, orgId: string): Promise { + return request('POST', `${orgPath(orgId)}/onboarding/complete`, client) +} + +export function previewBackfill( + client: SetupClient, + orgId: string, + triggerKey: string, + body: BackfillRequest, +): Promise { + return request('POST', `${triggerPath(orgId, triggerKey)}/backfill-preview`, { ...client, body }) +} + +export function startBackfill( + client: SetupClient, + orgId: string, + triggerKey: string, + body: BackfillRequest, +): Promise { + return request('POST', `${triggerPath(orgId, triggerKey)}/backfill`, { ...client, body }) +} + +export function getBackfillStatus(client: SetupClient, orgId: string, triggerKey: string): Promise { + return request('GET', `${triggerPath(orgId, triggerKey)}/backfill-status`, client) +} + +function recordOf(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +/** The repositories a 422 `repos_not_installed` from `apply` names, or null for any other error. */ +export function reposNotInstalled(err: unknown): string[] | null { + if (!(err instanceof ApiError) || err.status !== 422 || err.code !== 'repos_not_installed') return null + const missing = recordOf(err.body)?.missing_repos + return Array.isArray(missing) ? missing.filter((repo): repo is string => typeof repo === 'string') : [] +} + +/** + * The validation failures a 422 from `apply` carries, or null for any other error. + * Reads both the `promptless.yaml` shape (`validation_errors[{message, path, line}]`) + * and the FastAPI request-schema shape (`detail[{loc, msg}]`). + */ +export function configValidationIssues(err: unknown): ConfigValidationIssue[] | null { + if (!(err instanceof ApiError) || err.status !== 422) return null + const body = recordOf(err.body) + if (body === null) return null + + if (Array.isArray(body.validation_errors)) { + return body.validation_errors.flatMap((item) => { + const issue = recordOf(item) + if (issue === null || typeof issue.message !== 'string') return [] + return [ + { + message: issue.message, + path: typeof issue.path === 'string' && issue.path.length > 0 ? issue.path : null, + line: typeof issue.line === 'number' ? issue.line : null, + }, + ] + }) + } + if (Array.isArray(body.detail)) { + return body.detail.flatMap((item) => { + const issue = recordOf(item) + if (issue === null || typeof issue.msg !== 'string') return [] + const loc = Array.isArray(issue.loc) ? issue.loc.map(String) : [] + return [{ message: issue.msg, path: loc.length > 0 ? loc.join('.') : null, line: null }] + }) + } + return null +} + +/** Every repository across the organization's installations, without duplicates, in inventory order. */ +export function installedRepos(status: SetupStatus): InstalledRepo[] { + const seen = new Map() + for (const installation of status.github.installations) { + for (const repo of installation.repos) { + const key = repo.full_name.toLowerCase() + if (!seen.has(key)) seen.set(key, repo) + } + } + return [...seen.values()] +}