diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..46e0da3dd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Vite bundles are generated, minified artifacts; inspect their Svelte sources instead. +build/web/assets/*.js -diff -whitespace diff --git a/README.md b/README.md index 4c1e3a05b..6c4964c10 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ pnpm add --global @vypdev/copilot copilot --version cd /path/to/your/repository copilot setup +# Optional local visual assistant, in the same repository: +copilot setup --web ``` `@vypdev/copilot` contains both the `copilot` CLI and the compiled GitHub Action. @@ -60,10 +62,22 @@ on the exact existing branch and push normal commits, but do not create, rename, delete, replace, or force-push managed branches. The setup PAT entered by the operator is separate from the workflow `PAT` Secret. +Interactive setup can guide creation of both via GitHub's prefilled PAT form: +picks the permission-affecting setup options first, then the operator creates a temporary setup token, and the bot account creates the +persistent workflow token. GitHub handles account switching, 2FA, repository +selection, and final creation; Copilot never creates or revokes either token. Use `copilot setup --dry-run` to inspect the plan before making local or remote changes. See the complete [How to use](https://docs.page/vypdev/copilot/how-to-use) guide and [Authentication](https://docs.page/vypdev/copilot/authentication). +`--web` opens an ephemeral, loopback-only setup page with a six-stage progress +rail, plan review, separate masked inputs for the two PAT roles, and a +System/Light/Dark theme control. If the browser does not open, use the local +URL printed in the terminal. The page does not create PATs: GitHub owns the +form, account switch, 2FA, and token issuance. You must explicitly approve +the plan and Apply; `--web` cannot be combined with unattended approval or +secret-bearing command-line flags. The terminal wizard remains the default. + ### Manual workflow integration (advanced) You can integrate the Action manually when the CLI setup flow is not suitable: diff --git a/build/cli/index.js b/build/cli/index.js index efe4de95c..154091691 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -39178,6 +39178,25 @@ function runAtApplicationErrorBoundary(operation) { } +/***/ }), + +/***/ 38313: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SetupInteractionCancelledError = void 0; +/** Shared cancellation signal for terminal and browser setup presenters. */ +class SetupInteractionCancelledError extends Error { + constructor() { + super('Setup input was cancelled.'); + this.name = 'SetupInteractionCancelledError'; + } +} +exports.SetupInteractionCancelledError = SetupInteractionCancelledError; + + /***/ }), /***/ 79966: @@ -44266,6 +44285,42 @@ function boundedMergeQueueDiagnostic(value) { } +/***/ }), + +/***/ 39267: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.mergeSetupOverrides = mergeSetupOverrides; +/** Explicit CLI flags override only their fields; file-only settings remain intact. */ +function mergeSetupOverrides(fileOverrides, flagOverrides) { + return { + ...fileOverrides, + ...flagOverrides, + features: { ...fileOverrides.features, ...flagOverrides.features }, + agents: { ...fileOverrides.agents, ...flagOverrides.agents }, + repository: { ...fileOverrides.repository, ...flagOverrides.repository }, + ai: { ...fileOverrides.ai, ...flagOverrides.ai }, + pullRequestApproval: { + ...fileOverrides.pullRequestApproval, + ...flagOverrides.pullRequestApproval, + coverage: { ...fileOverrides.pullRequestApproval?.coverage, ...flagOverrides.pullRequestApproval?.coverage }, + }, + projects: { ...fileOverrides.projects, ...flagOverrides.projects }, + issueWorkflows: { ...fileOverrides.issueWorkflows, ...flagOverrides.issueWorkflows }, + repositoryAgentGuidance: { ...fileOverrides.repositoryAgentGuidance, ...flagOverrides.repositoryAgentGuidance }, + storage: { + ...fileOverrides.storage, + ...flagOverrides.storage, + secrets: { ...fileOverrides.storage?.secrets, ...flagOverrides.storage?.secrets, overrides: { ...fileOverrides.storage?.secrets?.overrides, ...flagOverrides.storage?.secrets?.overrides } }, + variables: { ...fileOverrides.storage?.variables, ...flagOverrides.storage?.variables, overrides: { ...fileOverrides.storage?.variables?.overrides, ...flagOverrides.storage?.variables?.overrides } }, + }, + }; +} + + /***/ }), /***/ 97890: @@ -46321,6 +46376,7 @@ function normalizeSetupConfigurationLocales(configuration) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildSetupCredentialRequirements = void 0; exports.buildSetupPlan = buildSetupPlan; +exports.setupPlanGuardPaths = setupPlanGuardPaths; exports.buildSetupRepositoryVariables = buildSetupRepositoryVariables; exports.buildSetupActionInputs = buildSetupActionInputs; const pull_request_description_1 = __nccwpck_require__(45315); @@ -46332,6 +46388,7 @@ Object.defineProperty(exports, "buildSetupCredentialRequirements", ({ enumerable const locale_1 = __nccwpck_require__(15386); const issue_workflow_profile_1 = __nccwpck_require__(26744); const setup_issue_workflow_policy_1 = __nccwpck_require__(81182); +const repository_agent_guidance_policy_1 = __nccwpck_require__(67402); function buildSetupPlan(configuration, mergeQueueReadiness = [], approvalReadiness = []) { const workflowFiles = (0, setup_workflow_catalog_1.enabledSetupWorkflowFiles)((0, setup_issue_workflow_policy_1.effectiveIssueWorkflowFeatures)(configuration)) .filter(file => file !== 'copilot_pull_request_approval.yml' || configuration.pullRequestApproval.mode !== 'off'); @@ -46370,6 +46427,40 @@ function buildSetupPlan(configuration, mergeQueueReadiness = [], approvalReadine warnings: buildSetupWarnings(configuration), }; } +/** Actual checkout destinations covered by a web Apply drift check. + * The presentation plan uses package-source labels for workflows/forms; + * comparing those labels as checkout paths would silently miss local edits. + */ +function setupPlanGuardPaths(plan) { + const selected = plan.selectedFiles.map(file => { + if (file.startsWith('workflows/')) + return `.github/${file}`; + if (file.startsWith('ISSUE_TEMPLATE/')) + return `.github/${file}`; + if (file === 'pull_request_template.md') + return '.github/pull_request_template.md'; + if (file === 'AGENTS.md (managed pointer only)') + return 'AGENTS.md'; + return file; + }); + // Deselected managed assets can be retired to setup-backups during Apply. + const retiredCandidates = [ + ...['config.yml', ...issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS.map(kind => issue_workflow_profile_1.ISSUE_WORKFLOW_CATALOG[kind].formFile)] + .map(file => `.github/ISSUE_TEMPLATE/${file}`), + ...['release_workflow.yml', 'hotfix_workflow.yml', 'copilot_deployment_orchestration.yml'] + .map(file => `.github/workflows/${file}`), + ]; + // The manifest can authorize retirement even when guidance is disabled and + // its artifacts are absent from the presentation plan. + const guidanceCandidates = [ + repository_agent_guidance_policy_1.REPOSITORY_AGENT_MANIFEST_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_PROFILE_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_GUIDE_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_SKILL_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_POINTER_PATH, + ]; + return [...new Set([...selected, ...retiredCandidates, ...guidanceCandidates])].sort(); +} function buildSetupRepositoryVariables(configuration) { const variables = []; const add = (name, value) => { @@ -46566,7 +46657,7 @@ function buildSetupWarnings(configuration) { warnings.push('Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.'); } if (configuration.projects.ids.trim()) { - warnings.push('Project IDs must be accessible to the PAT and use the expected project column names.'); + warnings.push('Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.'); } if ((0, setup_configuration_defaults_1.setupAgentTasksForFeatures)(configuration).some(task => configuration.agents[task].provider === 'cursor')) { warnings.push('Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.'); @@ -46860,8 +46951,20 @@ const locale_1 = __nccwpck_require__(15386); const issue_workflow_profile_1 = __nccwpck_require__(26744); const setup_issue_workflow_policy_1 = __nccwpck_require__(81182); const pull_request_approval_policy_1 = __nccwpck_require__(98820); +const setup_project_selection_policy_1 = __nccwpck_require__(73750); function validateSetupConfiguration(configuration, options = {}) { const errors = []; + const projectSelection = (0, setup_project_selection_policy_1.parseSetupProjectSelection)(configuration.projects.ids); + if ('error' in projectSelection || projectSelection.value !== configuration.projects.ids) { + errors.push('Project IDs must be a comma-separated list of 1–10 distinct positive Project URL numbers; PVT_ node IDs are not accepted.'); + } + if (configuration.projects.ids) { + for (const [name, value] of Object.entries(configuration.projects).filter(([name]) => name.endsWith('Column'))) { + if (typeof value !== 'string' || !value.trim() || value.length > 100 || /[\p{Cc}\p{Cf}]/u.test(value)) { + errors.push(`Project ${name} must name one existing single-line Status option (1–100 characters).`); + } + } + } errors.push(...(0, pull_request_approval_policy_1.validatePullRequestApprovalPolicy)(configuration.pullRequestApproval, options.allowIncompleteApproval === true)); if (configuration.actionInputs['pr-approval-policy'] !== undefined) { errors.push('pr-approval-policy cannot be overridden through actionInputs.'); @@ -47703,6 +47806,1336 @@ function effectiveIssueFormLabels(configuration) { } +/***/ }), + +/***/ 53289: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SETUP_JOURNEY_STAGES = void 0; +exports.buildSetupJourneyView = buildSetupJourneyView; +exports.SETUP_JOURNEY_STAGES = [ + 'repository', 'choices', 'setup-pat', 'plan', 'credentials', 'apply', +]; +const labels = { + repository: 'Repository', + choices: 'Setup choices', + 'setup-pat': 'Setup PAT', + plan: 'Plan', + credentials: 'Bot PAT & credentials', + apply: 'Apply', +}; +function buildSetupJourneyView(repository, stage, mutationStarted, outcome, choiceReviewPass = 1) { + const position = exports.SETUP_JOURNEY_STAGES.indexOf(stage); + return { + repository: [...repository].map(character => { + const codePoint = character.codePointAt(0); + return codePoint < 32 || (codePoint >= 127 && codePoint <= 159) ? '?' : character; + }).join('').slice(0, 120), + position: position + 1, + total: exports.SETUP_JOURNEY_STAGES.length, + current: labels[stage], + complete: exports.SETUP_JOURNEY_STAGES.slice(0, position).map(item => labels[item]), + pending: exports.SETUP_JOURNEY_STAGES.slice(position + 1).map(item => labels[item]), + ...(outcome ? { outcome } : {}), + mutationStarted, + choiceReviewPass, + }; +} + + +/***/ }), + +/***/ 54718: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.UnsupportedSetupPatLinkError = void 0; +exports.buildSetupPatCreationUrl = buildSetupPatCreationUrl; +const PAT_FORM = 'https://github.com/settings/personal-access-tokens/new'; +const QUERY_PERMISSIONS = { + repository: { + Metadata: 'metadata', + Contents: 'contents', + Secrets: 'secrets', + Variables: 'actions_variables', + Issues: 'issues', + Actions: 'actions', + Administration: 'administration', + Checks: 'checks', + Workflows: 'workflows', + 'Pull requests': 'pull_requests', + }, + organization: { + Secrets: 'organization_secrets', + Variables: 'organization_actions_variables', + 'Issue Types': 'issue_types', + Projects: 'organization_projects', + // GitHub's PAT form documents this organization permission as "members". + Members: 'members', + }, +}; +class UnsupportedSetupPatLinkError extends Error { + constructor(permissions) { + super(`GitHub's fine-grained PAT form cannot prefill: ${permissions.join(', ')}.`); + this.permissions = permissions; + this.name = 'UnsupportedSetupPatLinkError'; + } +} +exports.UnsupportedSetupPatLinkError = UnsupportedSetupPatLinkError; +/** Builds known GitHub form fields; Checks is accepted by the form but omitted from the published URL table. Never accepts credential material. */ +function buildSetupPatCreationUrl(input) { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(input.owner) + || !/^[A-Za-z0-9._-]{1,100}$/.test(input.repository) + || !Number.isInteger(input.expiresIn) + || input.expiresIn < 1 + || input.expiresIn > 366) { + throw new Error('Invalid PAT form owner, repository, or expiration.'); + } + const grants = new Map(); + const unsupported = []; + for (const item of input.requirements) { + if (item.role !== input.role) + throw new Error('PAT permission role does not match the requested form.'); + if (item.applicability !== 'required') + continue; + const key = QUERY_PERMISSIONS[item.scope][item.permission]; + if (!key || (key === 'metadata' && item.level !== 'read') + || (key === 'workflows' && item.level !== 'write')) { + unsupported.push(`${item.scope} ${item.permission} ${item.level}`); + continue; + } + if (grants.get(key) !== 'write') + grants.set(key, item.level); + } + if (unsupported.length > 0) + throw new UnsupportedSetupPatLinkError(unsupported); + const url = new URL(PAT_FORM); + url.searchParams.set('name', `Copilot ${input.role === 'setup' ? 'setup' : 'bot'} ${input.repository}`.slice(0, 40)); + url.searchParams.set('description', `Copilot ${input.role === 'setup' ? 'repository setup' : 'GitHub Action'} for ${input.owner}/${input.repository}`); + url.searchParams.set('target_name', input.owner); + url.searchParams.set('expires_in', String(input.expiresIn)); + for (const [key, level] of [...grants].sort(([left], [right]) => left.localeCompare(right))) { + url.searchParams.set(key, level); + } + // Owner/repository lengths and the finite permission map bound this URL well below terminal limits. + return url.toString(); +} + + +/***/ }), + +/***/ 30748: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.fixedSetupPatIntentQuestionIds = fixedSetupPatIntentQuestionIds; +exports.setupPatIntentNeedsOwnerKind = setupPatIntentNeedsOwnerKind; +exports.setupPatIntentOwnerConflict = setupPatIntentOwnerConflict; +const setup_token_permission_policy_1 = __nccwpck_require__(99590); +/** Local inputs with explicit precedence are decisions, not questions. */ +function fixedSetupPatIntentQuestionIds(overrides, skipVariables, skipSecrets) { + const fixed = []; + for (const feature of ['issues', 'pullRequests', 'release', 'hotfix']) { + if (overrides.features?.[feature] !== undefined) + fixed.push(`features.${feature}`); + } + if (overrides.issueWorkflows?.enabled !== undefined) + fixed.push('issueWorkflows.enabled'); + if (overrides.pullRequestApproval?.mode !== undefined) + fixed.push('pullRequestApproval.mode'); + if (overrides.projects?.ids !== undefined) + fixed.push('projects.enabled', 'projects.ids'); + if (overrides.createInitialTag !== undefined) + fixed.push('createInitialTag'); + if (skipVariables || overrides.manageRepositoryVariables !== undefined) + fixed.push('manageRepositoryVariables'); + if (skipSecrets || overrides.manageRepositorySecrets !== undefined) + fixed.push('manageRepositorySecrets'); + for (const kind of ['variables', 'secrets']) { + if (overrides.storage?.[kind]?.defaultScope !== undefined) + fixed.push(`storage.${kind}.defaultScope`); + if (overrides.storage?.[kind]?.preserveExisting !== undefined) + fixed.push(`storage.${kind}.preserveExisting`); + } + return fixed; +} +function setupPatIntentNeedsOwnerKind(configuration, projectsWanted = configuration.projects.ids.trim().length > 0) { + return (0, setup_token_permission_policy_1.buildSetupPatIntentPermissionRequirements)(configuration, 'Organization', projectsWanted) + .some(requirement => requirement.scope === 'organization') + || (configuration.manageRepositorySecrets && configuration.storage.secrets.preserveExisting) + || (configuration.manageRepositoryVariables && configuration.storage.variables.preserveExisting); +} +function setupPatIntentOwnerConflict(configuration, ownerKind, projectsWanted = configuration.projects.ids.trim().length > 0) { + return ownerKind === 'User' && ((configuration.manageRepositorySecrets && (configuration.storage.secrets.defaultScope === 'organization' + || Object.values(configuration.storage.secrets.overrides).includes('organization'))) + || (configuration.manageRepositoryVariables && (configuration.storage.variables.defaultScope === 'organization' + || Object.values(configuration.storage.variables.overrides).includes('organization'))) + || projectsWanted); +} + + +/***/ }), + +/***/ 10267: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.summarizeSetupPermissions = summarizeSetupPermissions; +/** A lossless required-grant view of the same requirements used for URL creation. */ +function summarizeSetupPermissions(requirements) { + return { + required: requirements.filter(item => item.applicability === 'required') + .map(item => `${item.permission} ${item.level} (${item.scope})`), + conditionalCount: requirements.filter(item => item.applicability === 'conditional').length, + }; +} + + +/***/ }), + +/***/ 73750: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.parseSetupProjectSelection = parseSetupProjectSelection; +exports.sharedProjectStatusOptions = sharedProjectStatusOptions; +exports.validateDiscoveredProjectStatuses = validateDiscoveredProjectStatuses; +function parseSetupProjectSelection(raw, owner) { + const input = raw.normalize('NFKC').trim(); + if (!input || input.toLowerCase() === 'none') + return { value: '' }; + const parts = input.split(',').map(part => part.trim()); + if (parts.length > 10 || parts.some(part => !part)) + return { error: 'Choose at most 10 Projects; separate numbers or URLs with commas.' }; + const numbers = []; + for (const part of parts) { + let numberText = part; + if (part.startsWith('https://')) { + if (!owner) + return { error: 'A Project URL needs a known repository owner; enter its positive number instead.' }; + try { + const url = new URL(part); + const match = url.pathname.match(/^\/(?:orgs|users)\/([^/]+)\/projects\/([1-9]\d*)\/?$/u); + if (url.origin !== 'https://github.com' || url.search || url.hash || url.username || url.password + || !match || decodeURIComponent(match[1]).toLowerCase() !== owner.toLowerCase()) { + return { error: `Use a GitHub Project URL belonging to ${owner}, without query parameters.` }; + } + numberText = match[2]; + } + catch { + return { error: 'Enter a valid GitHub Project URL or positive Project number.' }; + } + } + if (!/^[1-9]\d*$/u.test(numberText)) + return { error: 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.' }; + const number = Number(numberText); + if (!Number.isSafeInteger(number) || number > 2147483647) + return { error: 'Project numbers must be positive integers at most 2147483647.' }; + if (numbers.includes(number)) + return { error: `Project ${number} was selected more than once.` }; + numbers.push(number); + } + return { value: numbers.join(',') }; +} +function sharedProjectStatusOptions(projectNumbers, projects) { + const numbers = projectNumbers.split(',').map(Number).filter(Boolean); + if (!numbers.length) + return { state: 'unavailable', options: [] }; + const selected = numbers.map(number => projects.find(project => project.number === number)); + if (selected.some(project => !project?.statusOptions?.length)) + return { state: 'unavailable', options: [] }; + const [first, ...rest] = selected; + const common = first.statusOptions.filter(option => rest.every(project => project.statusOptions.includes(option))); + return common.length ? { state: 'observed', options: common } : { state: 'incompatible', options: [] }; +} +/** A discovered mismatch is unsafe even if values arrived through --config rather than the interactive selector. */ +function validateDiscoveredProjectStatuses(configuration, discovery) { + if (!configuration.projects.ids || !discovery || discovery.status !== 'observed') + return []; + const common = sharedProjectStatusOptions(configuration.projects.ids, discovery.candidates); + if (common.state === 'incompatible') + return ['Selected Projects have no common Status option. Choose compatible Projects.']; + if (common.state !== 'observed') + return []; + const names = [configuration.projects.issueCreatedColumn, configuration.projects.pullRequestCreatedColumn, + configuration.projects.issueInProgressColumn, configuration.projects.pullRequestInProgressColumn]; + return names.filter(name => !common.options.includes(name)).map(name => `Status value "${name}" is not available in every selected Project.`); +} + + +/***/ }), + +/***/ 75280: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.setupQuestionDocumentation = setupQuestionDocumentation; +const docs = { + features: { title: 'Copilot features and workflow triggers', url: 'https://docs.page/vypdev/copilot/features' }, + issueWorkflows: { title: 'Copilot issue workflow setup', url: 'https://docs.page/vypdev/copilot/issues/workflow-setup' }, + branchManagement: { title: 'Issue branch management', url: 'https://docs.page/vypdev/copilot/issues/branch-management' }, + preBranchSdd: { title: 'Pre-branch design documents', url: 'https://docs.page/vypdev/copilot/issues/pre-branch-sdds' }, + issueLifecycle: { title: 'Issue notifications and automatic closure', url: 'https://docs.page/vypdev/copilot/issues/notifications-and-auto-close' }, + assignments: { title: 'Assignees and GitHub Projects', url: 'https://docs.page/vypdev/copilot/issues/assignees-and-projects' }, + pullRequestWorkflows: { title: 'Pull-request workflow setup', url: 'https://docs.page/vypdev/copilot/pull-requests/workflow-setup' }, + pullRequestDescription: { title: 'AI pull-request descriptions', url: 'https://docs.page/vypdev/copilot/pull-requests/ai-description' }, + repositoryGuidance: { title: 'Repository guidance for agents', url: 'https://docs.page/vypdev/copilot/agents/repository-collaboration' }, + runtime: { title: 'Agent runtime selection', url: 'https://docs.page/vypdev/copilot/agents/runtime-selection' }, + model: { title: 'Agent model selection', url: 'https://docs.page/vypdev/copilot/agents/model-selection' }, + command: { title: 'Agent CLI configuration', url: 'https://docs.page/vypdev/copilot/agents/cli-configuration' }, + repository: { title: 'Copilot repository configuration', url: 'https://docs.page/vypdev/copilot/configuration' }, + deployment: { title: 'Release and hotfix orchestration', url: 'https://docs.page/vypdev/copilot/issues/deployment-orchestration' }, + bugbot: { title: 'Bugbot configuration', url: 'https://docs.page/vypdev/copilot/bugbot/configuration' }, + bugbotVerification: { title: 'Bugbot autofix verification commands', url: 'https://docs.page/vypdev/copilot/bugbot/verification-commands' }, + approval: { title: 'Guarded pull-request approval', url: 'https://docs.page/vypdev/copilot/pull-requests/guarded-approval' }, + githubStatusChecks: { title: 'GitHub: status checks and required checks', url: 'https://docs.github.com/en/pull-requests/reference/status-checks' }, + projects: { title: 'Assignees and GitHub Projects', url: 'https://docs.page/vypdev/copilot/issues/assignees-and-projects' }, + githubProjects: { title: 'GitHub: About Projects', url: 'https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects' }, + githubStatus: { title: 'GitHub: About single-select fields', url: 'https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields/about-single-select-fields' }, + provisioning: { title: 'Copilot setup and provisioning', url: 'https://docs.page/vypdev/copilot/how-to-use' }, + storage: { title: 'GitHub Actions Secrets and Variables', url: 'https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets' }, +}; +/** Links are selected from source-controlled constants, never derived from answers or remote text. */ +function setupQuestionDocumentation(question) { + const id = question.id; + if (id === 'issueWorkflows.enabled') + return docs.issueWorkflows; + if (id === 'features.issues') + return docs.issueWorkflows; + if (id === 'features.pullRequests') + return docs.pullRequestWorkflows; + if (id === 'repository.issueManagedBranches' || /^repository\.(feature|bugfix|hotfix|release|docs|chore)Tree$/u.test(id)) + return docs.branchManagement; + if (id === 'repository.preBranchSdd') + return docs.preBranchSdd; + if (id === 'repository.inactivityThresholdHours' || id === 'features.inactiveIssueClosure') + return docs.issueLifecycle; + if (id === 'repository.desiredAssigneesCount' || id === 'repository.desiredReviewersCount') + return docs.assignments; + if (id === 'ai.pullRequestDescriptionMode') + return docs.pullRequestDescription; + if (id === 'ai.bugbotFixVerifyCommands') + return docs.bugbotVerification; + if (id === 'projects.ids') + return docs.githubProjects; + if (id === 'pullRequestApproval.testChecks') + return docs.githubStatusChecks; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(id) || id === 'projects.statusVerified') + return docs.githubStatus; + if (id.startsWith('repositoryAgentGuidance.')) + return docs.repositoryGuidance; + if (id.startsWith('agents.')) { + if (id.endsWith('.provider')) + return docs.runtime; + if (id.endsWith('.executable')) + return docs.command; + return docs.model; + } + if (id === 'ai.provisioningMode') + return docs.command; + const byState = { + capabilities: docs.features, + 'agent-runtime': docs.runtime, + 'agent-model-defaults': docs.model, + 'agent-role-overrides': docs.model, + repository: docs.repository, + deployment: docs.deployment, + bugbot: docs.bugbot, + 'pull-request-approval': docs.approval, + projects: docs.projects, + provisioning: docs.provisioning, + storage: docs.storage, + }; + return byState[question.stateId]; +} + + +/***/ }), + +/***/ 49513: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.frenchQuestionExplanation = frenchQuestionExplanation; +const setup_question_labels_fr_pt_1 = __nccwpck_require__(28247); +const setup_question_purpose_fr_pt_1 = __nccwpck_require__(98807); +const where = { + capabilities: 'La configuration écrit les workflows choisis dans ce dépôt et ne demande que les autorisations GitHub nécessaires.', + 'agent-runtime': 'Les workflows GitHub Actions générés lancent cet agent sur leur runner ; rien n’est installé sur cet ordinateur.', + 'agent-model-defaults': 'Le modèle et la commande communs sont enregistrés dans la configuration du dépôt lue par les workflows.', + 'agent-role-overrides': 'Cette exception propre à une tâche est enregistrée dans le dépôt et lue uniquement lorsque cette tâche s’exécute.', + repository: 'Le profil du dépôt et les workflows générés utilisent cette valeur pour les futurs événements de branches, tickets et pull requests.', + deployment: 'Le profil du dépôt commande les futurs workflows de version et de correctif urgent ; répondre ne publie rien.', + bugbot: 'Le workflow généré lit ce réglage dans la configuration du dépôt ou les Variables GitHub Actions sélectionnées.', + 'pull-request-approval': 'L’approbation encadrée utilise les identités exactes des producteurs CI et les preuves des exécutions GitHub.', + projects: 'Les Projects choisis et leurs valeurs du champ Status seront utilisés par l’automatisation future des tickets et pull requests.', + provisioning: 'Après la confirmation finale, la configuration peut créer ou mettre à jour les fichiers et ressources GitHub Actions choisis.', + storage: 'GitHub Actions stocke ces ressources au niveau du dépôt ou de l’organisation ; ce choix change leur visibilité et les autorisations du PAT.', +}; +const section = { + capabilities: { summary: 'Choisissez les automatisations que Copilot installera.', when: 'Ce choix influence les workflows, les autorisations GitHub et les questions suivantes.', example: 'Désactivez une fonction que vous ne prévoyez pas d’utiliser.', effect: 'Seules les fonctions sélectionnées figureront dans le plan.', verify: 'Examinez le plan avant d’appliquer les changements.' }, + 'agent-runtime': { summary: 'Choisissez l’agent CLI pour cette tâche.', when: 'Il sera utilisé lorsque la fonction sélectionnée s’exécutera dans GitHub Actions.', example: 'Codex est lancé avec la commande codex.', effect: 'L’Action lance le fournisseur choisi, sans solution de remplacement implicite.', verify: 'Vérifiez que le runner dispose du CLI et des identifiants nécessaires.' }, + 'agent-model-defaults': { summary: 'Définissez les modèles utilisés par défaut pour les tâches de l’agent.', when: 'Ils s’appliquent sauf si vous configurez chaque tâche séparément.', example: 'Gardez le modèle proposé si vous n’avez pas de besoin particulier.', effect: 'L’Action transmet ces valeurs au CLI sélectionné.', verify: 'Examinez le plan et les modèles autorisés sur le runner.' }, + 'agent-role-overrides': { summary: 'Personnalisez cette tâche de l’agent.', when: 'Uniquement si vous avez activé la configuration indépendante des tâches.', example: 'Utilisez un modèle différent pour la revue et la planification.', effect: 'Seule cette tâche utilise cette exception.', verify: 'Examinez les valeurs de chaque tâche dans le plan.' }, + repository: { summary: 'Définissez comment Copilot traite votre dépôt.', when: 'Ce réglage agit sur les workflows et futurs événements de tickets ou pull requests.', example: 'Indiquez le véritable nom de votre branche de développement.', effect: 'L’automatisation future suivra les branches et règles choisies.', verify: 'Examinez les fichiers prévus et le profil du dépôt.' }, + deployment: { summary: 'Définissez le comportement des versions et correctifs urgents.', when: 'Ce réglage n’importe que si ces workflows sont activés.', example: 'Gardez la stratégie par défaut sauf si votre organisation des branches diffère.', effect: 'Il modifie la gestion des branches et pull requests de réconciliation.', verify: 'Examinez la partie versions et correctifs du plan.' }, + bugbot: { summary: 'Définissez comment Bugbot analyse et signale les changements.', when: 'Ce réglage sert lorsque les fonctions de revue IA s’exécutent.', example: 'Par défaut, les résultats admissibles sont publiés sans bloquer toutes les pull requests.', effect: 'Il change les futures publications et diagnostics de revue.', verify: 'Examinez les Variables Bugbot du plan et les résultats de revue.' }, + 'pull-request-approval': { summary: 'Choisissez les preuves exigées avant que le bot recommande ou soumette une approbation.', when: 'Ce réglage ne s’applique que si l’automatisation des pull requests est activée.', example: '« Recommend » informe une personne ; « guarded » peut approuver sur GitHub.', effect: 'Une vérification verte affichée ici ne suffit jamais à approuver une pull request.', verify: 'Inspectez les preuves CI, Bugbot et les règles de branche.' }, + projects: { summary: 'Choisissez une valeur Status existante pour une transition de ticket ou PR.', when: 'Seulement si vous intégrez des Projects.', example: 'Todo à la création ; In Progress au début du travail.', effect: 'L’automatisation modifiera le champ Status, pas une colonne visuelle.', verify: 'Vérifiez les options Status de chaque Project choisi.' }, + provisioning: { summary: 'Choisissez les ressources GitHub Actions gérées par la configuration.', when: 'Cela influence les autorisations du PAT et les écritures prévues.', example: 'Gardez les Secrets activés si le PAT du bot doit être installé.', effect: 'Les ressources sélectionnées pourront être créées ou mises à jour après approbation.', verify: 'Examinez les noms exacts des ressources dans le plan.' }, + storage: { summary: 'Choisissez où résident les Variables et Secrets GitHub Actions.', when: 'Ce réglage s’applique quand leur création est activée.', example: 'Le dépôt est le périmètre par défaut le plus simple.', effect: 'Il change la visibilité, les autorisations et l’ordre de priorité.', verify: 'Examinez le périmètre et les avertissements de masquage dans le plan.' }, +}; +const special = { + 'agents.findings.executable': { summary: 'Choisissez la commande de l’agent sur le runner GitHub Actions, pas sur cet ordinateur.', when: 'Ne la changez que si un agent personnalisé est délibérément installé sur le runner.', example: 'Laissez vide pour codex, opencode ou agent selon le fournisseur.', effect: 'Le chemin personnalisé est utilisé pour les tâches choisies et n’est jamais installé automatiquement.', verify: 'Vérifiez que le runner possède exactement cet exécutable avant d’activer le workflow.' }, + 'ai.includeReasoning': { summary: 'Demandez des explications supplémentaires si la réponse du fournisseur les contient.', when: 'Réservé aux diagnostics avancés ; le parcours CLI actuel ne fournit pas de parties de raisonnement séparées.', example: 'Laissez désactivé pour une configuration normale.', effect: 'Cela peut ajouter du texte du fournisseur, sans garantir des métadonnées brèves.', verify: 'Inspectez une réponse structurée contrôlée ; ne supposez pas que l’option a produit plus de texte.' }, + 'ai.bugbotDryRun': { summary: 'Gardez Bugbot en mode analyse seule pour ses futures exécutions.', when: 'Utile pour une évaluation ; incompatible avec les preuves nécessaires à l’approbation.', example: 'Choisissez Non pour publier les revues normales.', effect: 'Bugbot analyse sans publier de résultat ni modifier le dépôt. Ce n’est pas setup --dry-run.', verify: 'Inspectez le résultat du workflow Bugbot : le mode analyse seule ne publie ni revue ni vérification.' }, + 'ai.bugbotOrganizationRules': { summary: 'Définissez des consignes générales pour Bugbot, une règle par ligne.', when: 'Utile si l’équipe partage des critères de revue dans le dépôt configuré.', example: 'Signaler les changements qui contournent l’isolation des clients.', effect: 'Ces règles précèdent celles du dépôt ; le périmètre de la Variable détermine le stockage.', verify: 'Inspectez la Variable configurée et activez le traçage des sources de règles.' }, + 'ai.provisioningMode': { summary: 'Décidez comment l’Action trouve ou installe l’agent CLI.', when: 'Ce choix s’applique sur le runner au démarrage d’une tâche IA activée.', example: 'Auto réutilise un CLI installé ou installe une version fixée de Codex/OpenCode.', effect: 'Always réinstalle les versions examinées ; Disabled exige un CLI préinstallé. Cursor doit être préinstallé.', verify: 'Inspectez l’étape de préparation et la version du binaire rapportée par le runner.' }, + 'pullRequestApproval.testChecks': { summary: 'Choisissez les jobs CI que le bot peut considérer comme preuve de tests indépendante.', when: 'Obligatoire pour les modes Recommend et Guarded.', example: 'Sélectionnez le job Tests exact, son ID d’App GitHub et son workflow dans une exécution récente.', effect: 'Seules les identités exactes listées satisfont la condition d’approbation.', verify: 'Ouvrez l’exécution liée et vérifiez le job, l’App et le résultat pour le commit courant.' }, + 'pullRequestApproval.producerAttested': { summary: 'Confirmez avoir inspecté le producteur CI exact et son étape obligatoire de couverture.', when: 'Obligatoire avant que le mode Guarded puisse approuver.', example: 'Vérifiez que le job Tests échoue si le seuil de couverture n’est pas atteint.', effect: 'Votre confirmation est enregistrée ; Copilot ne la déduit pas d’une vérification verte.', verify: 'Inspectez le fichier du workflow et une exécution réelle avant de répondre Oui.' }, + 'pullRequestApproval.coverage.mode': { summary: 'Choisissez comment prouver la couverture exigée du code modifié.', when: 'Ce choix s’applique lorsque l’approbation de PR est activée.', example: 'Check : le CI impose le seuil. Numeric : un workflow fiable publie des décomptes limités.', effect: 'Check fait confiance au garde CI ; Numeric lit copilot-diff-coverage-v1 et compare un seuil.', verify: 'Inspectez respectivement la condition d’échec du CI ou l’artefact du rapporteur.' }, + 'pullRequestApproval.coverage.checkName': { summary: 'Sélectionnez la vérification fiable qui échoue sous le seuil de couverture.', when: 'Obligatoire dans les deux modes de preuve.', example: 'Utilisez le même job Tests exact que dans l’étape précédente.', effect: 'Le succès d’une autre vérification ou App ne remplace pas ce garde.', verify: 'Vérifiez que l’étape de couverture est obligatoire, pas seulement informative.' }, + 'projects.enabled': { summary: 'Décidez si les futurs tickets et PR doivent utiliser des Projects GitHub existants.', when: 'Avant de créer le PAT de configuration pour prévoir le droit de lecture des Projects.', example: 'Oui si l’équipe utilise un Project de l’organisation ; Non pour ignorer cette intégration.', effect: 'Oui prévoit Projects: read de l’organisation si nécessaire. Aucun Project n’est modifié maintenant.', verify: 'Vérifiez les droits du PAT ; les Projects précis seront choisis après son autorisation.' }, + 'projects.ids': { summary: 'Choisissez les Projects existants que Copilot pourra actualiser plus tard.', when: 'Après la vérification du PAT ; si la liste est inaccessible, utilisez la saisie manuelle.', example: 'Pour https://github.com/orgs/acme/projects/5, choisissez la carte ou saisissez 5, jamais PVT_…', effect: 'Leurs numéros seront enregistrés ; aucun élément Project n’est modifié maintenant.', verify: 'Ouvrez chaque Project et vérifiez propriétaire et numéro avant de confirmer le plan.' }, +}; +function howToChoose(question) { + if (question.id === 'pullRequestApproval.coverage.checkName') + return 'Choisissez l’une des vérifications fiables ci-dessus. Ouvrez son exécution et son workflow : l’étape de couverture doit faire échouer le job si le seuil n’est pas atteint. Un résultat vert ne suffit pas.'; + if (question.id === 'pullRequestApproval.producerAttested') + return 'Répondez Oui uniquement après avoir vérifié chaque nom, ID d’App et workflow choisis, ainsi que l’étape de couverture obligatoire du check retenu. Sinon, répondez Non et restez en mode recommandation.'; + if (question.id === 'pullRequestApproval.coverage.artifactWorkflowName') + return 'Saisissez le nom exact d’un workflow fiable choisi qui publie copilot-diff-coverage-v1 pour cette PR et ses commits de base et de tête. Ne devinez pas le nom du workflow.'; + if (question.id === 'projects.statusVerified') + return 'Ouvrez chaque Project choisi sur GitHub, inspectez son champ Status et comparez les quatre valeurs exactes ci-dessus. Répondez Oui uniquement si toutes existent dans chaque Project ; Non revient au choix des Projects.'; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) + return 'Choisissez une option du champ Status présente dans tous les Projects sélectionnés. Si les options ne sont pas lisibles, ouvrez chaque Project sur GitHub et saisissez la même valeur existante ; des valeurs différentes par Project ne sont pas prises en charge.'; + switch (question.kind) { + case 'boolean': return 'Choisissez Oui pour activer ou Non pour désactiver ; la réponse suggérée apparaît plus bas.'; + case 'producer-select': return 'Inspectez chaque exécution candidate sur GitHub, puis choisissez le job, l’ID d’App et le workflow exacts. Ne saisissez manuellement que si aucun candidat vérifié n’apparaît.'; + case 'project-select': return 'Choisissez par titre et URL. Saisissez le numéro positif ou l’URL GitHub exacte si un Project manque ; les ID PVT_ sont invalides.'; + case 'scope-overrides': return 'Sélectionnez uniquement les noms hérités à remplacer volontairement dans le dépôt. Laissez vide pour conserver les valeurs de l’organisation.'; + case 'multi-select': return 'Cochez les workflows que vous utiliserez. Vous pouvez en choisir plusieurs ; vérifiez leurs autorisations avant de créer un PAT.'; + case 'choice': return 'Choisissez une valeur après avoir lu ses conséquences ; la valeur enregistrée n’est pas traduite.'; + case 'number': return 'Saisissez un entier dans la plage indiquée ; gardez la valeur suggérée en cas de doute.'; + default: return 'Saisissez la valeur exacte utilisée par votre dépôt ou runner ; ne laissez vide que si la question le permet.'; + } +} +function frenchQuestionExplanation(question, documentation) { + const copy = special[question.id] ?? section[question.stateId]; + return { + label: (0, setup_question_labels_fr_pt_1.translatedQuestionLabel)(question, 'fr'), + ...copy, + summary: special[question.id] ? copy.summary : ((0, setup_question_purpose_fr_pt_1.setupQuestionPurposeFrPt)(question, 'fr') ?? copy.summary), + where: where[question.stateId], + how: howToChoose(question), + why: `Cette décision permet d’accorder le plan, les autorisations du PAT et l’automatisation future avant d’appliquer des changements. ${copy.when}`, + documentation: { title: 'Documentation de cette option', url: documentation.url }, + }; +} + + +/***/ }), + +/***/ 42775: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.setupQuestionPresentation = setupQuestionPresentation; +const setup_question_translations_1 = __nccwpck_require__(3927); +const setup_question_documentation_policy_1 = __nccwpck_require__(75280); +const setup_question_purpose_policy_1 = __nccwpck_require__(77947); +const setup_question_guidance_fr_1 = __nccwpck_require__(49513); +const setup_question_guidance_pt_1 = __nccwpck_require__(14440); +const location = { + capabilities: { en: 'Setup writes the selected workflow files into this repository and requests only the GitHub permissions those workflows need.', es: 'Setup escribe los workflows seleccionados en este repositorio y solicita solo los permisos de GitHub necesarios.' }, + 'agent-runtime': { en: 'The generated GitHub Actions workflows invoke this agent on their runner; this does not install an agent on your computer.', es: 'Los workflows de GitHub Actions invocan este agente en su runner; no se instala en tu ordenador.' }, + 'agent-model-defaults': { en: 'The shared model and command defaults are stored in the repository configuration consumed by the generated workflows.', es: 'Los valores comunes de modelo y comando se guardan en la configuración del repositorio que usan los workflows.' }, + 'agent-role-overrides': { en: 'This task-specific override is stored in the repository configuration and read only when that task runs.', es: 'Esta excepción por tarea se guarda en la configuración del repositorio y se lee cuando se ejecuta esa tarea.' }, + repository: { en: 'The repository profile and generated workflows use this value for future branch, issue and pull-request events.', es: 'El perfil del repositorio y los workflows generados usan este valor en futuros eventos de ramas, issues y pull requests.' }, + deployment: { en: 'The repository profile controls later release and hotfix workflows; nothing is released by answering this question.', es: 'El perfil del repositorio controla los futuros workflows de release y hotfix; responder no publica ninguna versión.' }, + bugbot: { en: 'The generated workflow reads this setting from repository configuration or selected GitHub Actions Variables when Bugbot runs.', es: 'El workflow lee este ajuste de la configuración o las Variables de GitHub Actions seleccionadas al ejecutar Bugbot.' }, + 'pull-request-approval': { en: 'The guarded-approval configuration uses exact CI producer identities and evidence from GitHub pull-request runs.', es: 'La aprobación protegida usa identidades exactas de los productores de CI y pruebas de las ejecuciones de PR en GitHub.' }, + projects: { en: 'Future issue and pull-request automation uses the selected GitHub Projects and their Status field values.', es: 'La automatización futura de issues y pull requests usa los GitHub Projects y los valores de su campo Status.' }, + provisioning: { en: 'After the final Apply confirmation, setup may create or update the selected files and GitHub Actions resources.', es: 'Tras confirmar Aplicar, setup podrá crear o actualizar los archivos y recursos de GitHub Actions elegidos.' }, + storage: { en: 'GitHub Actions stores these resources at repository or organization scope; the scope changes visibility and required PAT grants.', es: 'GitHub Actions guarda estos recursos en el repositorio o la organización; el ámbito cambia la visibilidad y los permisos del PAT.' }, +}; +const special = { + 'agents.findings.executable': { + en: { summary: 'Choose the agent command used on the GitHub Actions runner, not on this computer.', when: 'Only change this for a runner with a deliberately installed custom agent binary.', example: 'Leave empty for codex, opencode, or agent according to the provider.', effect: 'A custom path is shared unless a task has its own override; it is never installed automatically.', verify: 'Check the runner has this exact executable before enabling the workflow.' }, + es: { summary: 'Elige el comando del agente en el runner de GitHub Actions, no en este ordenador.', when: 'Cámbialo solo si el runner tiene instalado expresamente otro binario.', example: 'Déjalo vacío para usar codex, opencode o agent según el proveedor.', effect: 'La ruta personalizada se comparte salvo que una tarea tenga su propia excepción; nunca se instala automáticamente.', verify: 'Comprueba que el runner tiene exactamente ese ejecutable.' }, + }, + 'ai.includeReasoning': { + en: { summary: 'Ask for additional provider reasoning when the agent response exposes it.', when: 'Advanced diagnostics only; the current string-only CLI path does not provide separate reasoning parts.', example: 'Keep this off for normal setup.', effect: 'May add provider-produced explanation text, not guaranteed concise metadata.', verify: 'Inspect a controlled structured response; do not assume this toggle produced extra text.' }, + es: { summary: 'Solicita razonamiento adicional si la respuesta del proveedor lo ofrece.', when: 'Solo para diagnósticos avanzados; el CLI actual devuelve texto sin partes de razonamiento separadas.', example: 'Déjalo desactivado en una configuración normal.', effect: 'Podría añadir texto del proveedor; no garantiza metadatos breves.', verify: 'Comprueba una respuesta estructurada controlada; no presupongas que la opción tuvo efecto.' }, + }, + 'ai.bugbotDryRun': { + en: { summary: 'Keep Bugbot in analysis-only mode for future runs.', when: 'Useful during evaluation; incompatible with PR approval evidence.', example: 'Choose No to publish normal reviews.', effect: 'Bugbot analyzes but does not publish findings or make SCM changes. This is not setup --dry-run.', verify: 'Inspect the Bugbot workflow result; no published review or Check should appear from dry-run.' }, + es: { summary: 'Mantiene Bugbot en modo solo análisis para las futuras ejecuciones.', when: 'Útil durante una evaluación; incompatible con la evidencia de aprobación de PR.', example: 'Elige No para publicar revisiones normalmente.', effect: 'Bugbot analiza pero no publica hallazgos ni modifica el repositorio. No es setup --dry-run.', verify: 'Revisa el resultado de Bugbot; el modo ensayo no publica revisión ni Check.' }, + }, + 'ai.bugbotOrganizationRules': { + en: { summary: 'Set broad Bugbot review instructions, one rule per line.', when: 'Use when your team needs review criteria shared across its configured repository.', example: 'Flag changes that bypass tenant isolation.', effect: 'These rules run before repository rules; the selected Variable scope determines storage, not the title.', verify: 'Inspect the configured Variable and enable rule-source tracing for a review.' }, + es: { summary: 'Define criterios generales de revisión para Bugbot, una regla por línea.', when: 'Úsalo si el equipo necesita criterios comunes en el repositorio configurado.', example: 'Señala cambios que omitan el aislamiento entre clientes.', effect: 'Se aplican antes que las reglas del repositorio; el ámbito de la Variable determina dónde se guardan.', verify: 'Revisa la Variable configurada y activa el rastreo de fuentes de reglas.' }, + }, + 'ai.provisioningMode': { + en: { summary: 'Decide how the Action finds or installs the selected agent CLI.', when: 'Applies on the runner when an enabled AI task starts.', example: 'Auto reuses an installed CLI or installs pinned Codex/OpenCode when missing.', effect: 'Always reinstalls reviewed defaults; Disabled requires a preinstalled CLI. Cursor must be preinstalled.', verify: 'Inspect the runner provisioning step and its reported binary version.' }, + es: { summary: 'Decide cómo encuentra o instala la Action el agente CLI.', when: 'Se aplica en el runner cuando empieza una tarea de IA.', example: 'Auto reutiliza el CLI existente o instala una versión fijada de Codex/OpenCode si falta.', effect: 'Always reinstala versiones fijadas; Disabled exige instalación previa. Cursor debe estar preinstalado en el runner.', verify: 'Revisa el paso de preparación y la versión del binario en el runner.' }, + }, + 'pullRequestApproval.testChecks': { + en: { summary: 'Choose CI jobs the approval bot may trust as independent test evidence.', when: 'Required for recommend or guarded approval.', example: 'Select the exact Tests job, its GitHub App ID, and parent workflow from a recent run.', effect: 'Only the listed exact producer identities can satisfy the approval gate.', verify: 'Open the linked workflow run and confirm the job, App, and current-head result.' }, + es: { summary: 'Selecciona los jobs de CI que el bot puede considerar pruebas fiables.', when: 'Obligatorio para las aprobaciones recomendadas o protegidas.', example: 'Elige el job Tests, su ID de GitHub App y el workflow de una ejecución reciente.', effect: 'Solo esas identidades exactas podrán satisfacer la condición de aprobación.', verify: 'Abre la ejecución vinculada y comprueba job, App y resultado para el commit actual.' }, + }, + 'pullRequestApproval.producerAttested': { + en: { summary: 'Confirm that you inspected the exact CI producer and its coverage-enforcing step.', when: 'Required before guarded mode can ever submit an approval.', example: 'Verify the selected Tests job fails when the coverage budget fails.', effect: 'Your assertion is recorded; Copilot does not infer it from a green check.', verify: 'Inspect the workflow file and an actual CI run before selecting Yes.' }, + es: { summary: 'Confirma que comprobaste el productor exacto de CI y su paso obligatorio de cobertura.', when: 'Necesario antes de que el modo protegido pueda aprobar.', example: 'Comprueba que el job Tests falla cuando no se alcanza la cobertura mínima.', effect: 'Se registra tu confirmación; Copilot no la deduce de un check verde.', verify: 'Revisa el workflow y una ejecución real antes de elegir Sí.' }, + }, + 'pullRequestApproval.coverage.mode': { + en: { summary: 'Choose how approval proves the changed-code coverage requirement.', when: 'Applies when PR approval is enabled.', example: 'Check: CI enforces the budget. Numeric: a trusted workflow publishes bounded counts.', effect: 'Check mode trusts a selected CI gate; numeric mode reads copilot-diff-coverage-v1 and compares a threshold.', verify: 'Inspect the CI failure condition or the reporter artifact, respectively.' }, + es: { summary: 'Elige cómo se demuestra la cobertura del código modificado.', when: 'Se aplica si habilitas la aprobación de PR.', example: 'Check: CI exige el mínimo. Numeric: un workflow fiable publica recuentos de líneas.', effect: 'Check confía en una condición de CI; numeric lee copilot-diff-coverage-v1 y compara un umbral.', verify: 'Comprueba la condición de fallo del CI o el artefacto del reporter.' }, + }, + 'pullRequestApproval.coverage.checkName': { + en: { summary: 'Select the trusted check that fails when coverage is below budget.', when: 'Required for both coverage evidence modes.', example: 'Use the same exact Tests check selected in the previous step.', effect: 'A success from another check or App cannot substitute for this gate.', verify: 'Inspect the selected job and confirm its coverage step is mandatory, not advisory.' }, + es: { summary: 'Selecciona el check fiable que falla si no se alcanza la cobertura mínima.', when: 'Obligatorio en ambos modos de evidencia.', example: 'Usa el mismo check Tests elegido en el paso anterior.', effect: 'Un éxito de otro check o App no sustituye esta condición.', verify: 'Comprueba que el paso de cobertura es obligatorio, no solo informativo.' }, + }, + 'projects.enabled': { + en: { summary: 'Decide whether future issue and PR automation should use existing GitHub Projects.', when: 'Ask now, before creating the setup PAT, so its Project read permission can be scoped correctly.', example: 'Choose Yes if your team already tracks work in an organization Project; choose No to skip it.', effect: 'Yes includes organization Projects: read in the setup PAT when applicable. No Project is changed now.', verify: 'Review the PAT permission table; exact Projects are selected after GitHub authorizes the PAT.' }, + es: { summary: 'Decide si la automatización futura de issues y PR usará Projects existentes.', when: 'Se pregunta antes de crear el PAT de configuración para ajustar el permiso de lectura de Projects.', example: 'Elige Sí si tu equipo usa un Project de la organización; No para omitirlo.', effect: 'Sí incluye Projects: read de la organización en el PAT cuando aplica. Ahora no se modifica ningún Project.', verify: 'Revisa los permisos del PAT; elegirás los Projects concretos tras autorizarlo en GitHub.' }, + }, + 'projects.ids': { + en: { summary: 'Choose the existing Projects that Copilot may update in future issue and PR workflows.', when: 'After the setup PAT is checked, GitHub may list accessible organization Projects. Personal Projects or unavailable lists need manual entry.', example: 'For https://github.com/orgs/acme/projects/5, select the project card or enter 5; never enter PVT_…', effect: 'Setup stores Project numbers in repository configuration; it does not create or edit Project items now.', verify: 'Open each linked Project and check its owner and URL number before approving the plan.' }, + es: { summary: 'Elige los Projects existentes que Copilot podrá actualizar en futuros flujos de issues y PR.', when: 'Después de comprobar el PAT, GitHub puede listar Projects accesibles de la organización. Para Projects personales o fallos de consulta, introdúcelos manualmente.', example: 'Para https://github.com/orgs/acme/projects/5, marca la tarjeta o escribe 5; nunca PVT_…', effect: 'Setup guarda números de Project en la configuración; ahora no crea ni edita elementos.', verify: 'Abre cada Project enlazado y comprueba el dueño y número de la URL antes de aprobar el plan.' }, + }, +}; +const section = { + capabilities: { en: { summary: 'Choose which automation Copilot will install.', when: 'This affects workflows, GitHub permissions, and later questions.', example: 'Disable a feature you do not plan to use.', effect: 'Only selected capabilities are planned.', verify: 'Review the generated setup plan before Apply.' }, es: { summary: 'Elige qué automatizaciones instalará Copilot.', when: 'Afecta a workflows, permisos de GitHub y preguntas posteriores.', example: 'Desactiva una función que no vayas a usar.', effect: 'Solo se planifican las funciones seleccionadas.', verify: 'Revisa el plan antes de aplicar cambios.' } }, + 'agent-runtime': { en: { summary: 'Choose the agent CLI for this task.', when: 'Applies when the selected feature runs in GitHub Actions.', example: 'Codex runs through the codex CLI.', effect: 'The Action invokes the selected provider, never an implicit fallback.', verify: 'Check the runner has the selected CLI and credentials.' }, es: { summary: 'Elige el agente CLI para esta tarea.', when: 'Se aplica al ejecutar la función elegida en GitHub Actions.', example: 'Codex usa el CLI codex.', effect: 'La Action usa ese proveedor, sin sustitución implícita.', verify: 'Comprueba el CLI y las credenciales del runner.' } }, + 'agent-model-defaults': { en: { summary: 'Set the model defaults shared by agent tasks.', when: 'Used unless you configure each task separately.', example: 'Keep the reviewed model by accepting the suggested value.', effect: 'The Action passes these values to the selected CLI.', verify: 'Check the plan and runner model allowlist.' }, es: { summary: 'Define el modelo común para las tareas del agente.', when: 'Se usa salvo que configures cada tarea por separado.', example: 'Acepta el modelo revisado que aparece como sugerencia.', effect: 'La Action pasa estos valores al CLI elegido.', verify: 'Revisa el plan y la lista de modelos permitidos.' } }, + 'agent-role-overrides': { en: { summary: 'Override this one agent task.', when: 'Only when independent task configuration is enabled.', example: 'Use a different model for review than for planning.', effect: 'Only this task uses the override.', verify: 'Inspect the per-task plan values.' }, es: { summary: 'Personaliza esta tarea del agente.', when: 'Solo si activaste la configuración independiente por tarea.', example: 'Usa un modelo distinto para revisión y planificación.', effect: 'Solo esta tarea usa el valor personalizado.', verify: 'Revisa los valores de cada tarea en el plan.' } }, + repository: { en: { summary: 'Set how Copilot treats your repository.', when: 'Applies to generated workflows and future issue/PR events.', example: 'Use your actual development branch name.', effect: 'Future automation follows the chosen branch and workflow rules.', verify: 'Review the planned files and repository profile.' }, es: { summary: 'Define cómo Copilot tratará tu repositorio.', when: 'Se aplica a los workflows y futuros eventos de issues/PR.', example: 'Indica el nombre real de tu rama de desarrollo.', effect: 'La automatización seguirá las ramas y reglas elegidas.', verify: 'Revisa los archivos del plan y el perfil del repositorio.' } }, + deployment: { en: { summary: 'Choose release and hotfix behavior.', when: 'Only matters when those workflows are enabled.', example: 'Keep the default strategy unless your branching policy differs.', effect: 'Changes how release branches and reconciliation PRs are managed.', verify: 'Inspect the release/hotfix section of the plan.' }, es: { summary: 'Define el comportamiento de releases y hotfixes.', when: 'Importa si activaste esos workflows.', example: 'Conserva la estrategia predeterminada salvo que tus ramas funcionen distinto.', effect: 'Cambia la gestión de ramas y PR de reconciliación.', verify: 'Revisa la sección de releases y hotfixes del plan.' } }, + bugbot: { en: { summary: 'Choose how Bugbot analyzes and reports code changes.', when: 'Used when AI review features run.', example: 'The default publishes eligible findings without blocking all PRs.', effect: 'Changes future review publication and diagnostics.', verify: 'Inspect the Bugbot Variables in the plan and later review results.' }, es: { summary: 'Define cómo Bugbot analiza y comunica cambios de código.', when: 'Se usa cuando se ejecutan funciones de revisión con IA.', example: 'Por defecto publica hallazgos aptos sin bloquear todos los PR.', effect: 'Cambia futuras revisiones y diagnósticos.', verify: 'Revisa las Variables de Bugbot en el plan y sus resultados.' } }, + 'pull-request-approval': { en: { summary: 'Choose evidence required before the bot recommends or submits PR approval.', when: 'Only applies if PR automation is enabled.', example: 'Recommend informs a human; guarded may submit a native approval.', effect: 'No PR is approved solely because this page shows green checks.', verify: 'Inspect the trusted CI, Bugbot, and branch-rule evidence.' }, es: { summary: 'Elige las pruebas necesarias para recomendar o aprobar un PR.', when: 'Solo se aplica si activaste la automatización de PR.', example: 'Recommend informa a una persona; guarded puede publicar una aprobación.', effect: 'Ningún PR se aprueba solo porque esta pantalla muestre checks verdes.', verify: 'Revisa CI, Bugbot y las reglas de rama.' } }, + projects: { en: { summary: 'Choose an existing Project Status value for an issue or PR transition.', when: 'Only when Projects integration is selected.', example: 'Todo when an issue is created; In Progress when work starts.', effect: 'Future automation updates the Status field, not a visual board column.', verify: 'Open each selected Project and inspect its Status field options.' }, es: { summary: 'Elige un valor Status existente para una transición de issue o PR.', when: 'Solo si elegiste integrar Projects.', example: 'Todo al crear un issue; In Progress al empezar el trabajo.', effect: 'La automatización futura actualiza el campo Status, no una columna visual.', verify: 'Abre cada Project y revisa las opciones de su campo Status.' } }, + provisioning: { en: { summary: 'Choose which GitHub Actions resources setup manages.', when: 'Affects PAT grants and setup writes.', example: 'Keep Secrets enabled if the bot PAT must be installed.', effect: 'Selected resources may be created or updated after approval.', verify: 'Inspect exact resource names in the plan.' }, es: { summary: 'Elige qué recursos de GitHub Actions gestionará setup.', when: 'Afecta a permisos del PAT y cambios de configuración.', example: 'Mantén Secrets si hay que instalar el PAT del bot.', effect: 'Los recursos seleccionados podrán crearse o actualizarse tras aprobar.', verify: 'Revisa los nombres exactos en el plan.' } }, + storage: { en: { summary: 'Choose where GitHub Actions Variables and Secrets live.', when: 'Applies when provisioning is enabled.', example: 'Repository scope is the simplest default.', effect: 'Affects visibility, permission grants, and precedence.', verify: 'Check the selected scope and shadow warnings in the plan.' }, es: { summary: 'Elige dónde se guardan Variables y Secrets de GitHub Actions.', when: 'Se aplica si activaste su configuración.', example: 'El ámbito de repositorio es el predeterminado más sencillo.', effect: 'Afecta a visibilidad, permisos y precedencia.', verify: 'Revisa el ámbito y los avisos de superposición en el plan.' } }, +}; +function setupQuestionPresentation(question) { + const copy = special[question.id] ?? section[question.stateId]; + const purpose = (0, setup_question_purpose_policy_1.setupQuestionPurpose)(question); + const documentation = (0, setup_question_documentation_policy_1.setupQuestionDocumentation)(question); + const genericHow = question.kind === 'boolean' + ? { en: 'Choose Yes to enable this behavior or No to leave it off; the suggested answer appears below.', es: 'Elige Sí para activarlo o No para dejarlo desactivado; abajo verás la respuesta sugerida.' } + : question.kind === 'producer-select' + ? { en: 'Inspect each candidate run on GitHub, then select its exact job, source App ID and workflow. A listed run is observed, not proof of a required coverage gate; use manual entry for a missing producer.', es: 'Abre cada ejecución candidata en GitHub y comprueba el job, la App y el workflow exactos. Una ejecución listada es observada, no prueba que exija cobertura; usa la entrada manual si falta un productor.' } + : question.kind === 'project-select' + ? { en: 'Select Projects by title and URL. If one is missing, enter its positive URL number or exact GitHub URL; PVT_ IDs are not valid.', es: 'Marca Projects por título y URL. Si falta uno, introduce su número positivo o URL exacta de GitHub; los IDs PVT_ no valen.' } + : question.kind === 'scope-overrides' + ? { en: 'Select only inherited names you deliberately want to replace at repository scope. Leave empty to keep organization values.', es: 'Selecciona solo los nombres heredados que quieras sustituir en el repositorio. Vacío conserva los valores de la organización.' } + : question.kind === 'multi-select' + ? { en: 'Toggle the listed workflows you intend to use. You can select more than one; review their GitHub permissions before creating a PAT.', es: 'Marca los workflows que usarás. Puedes elegir varios; revisa sus permisos de GitHub antes de crear el PAT.' } + : question.kind === 'choice' + ? { en: 'Select one of the listed values after reading its consequence; the stored value is not translated.', es: 'Elige una de las opciones tras revisar sus consecuencias; el valor guardado no se traduce.' } + : question.kind === 'number' + ? { en: 'Enter a whole number within the range described in the question; accept the suggested value when unsure.', es: 'Introduce un número entero dentro del intervalo indicado; acepta el sugerido si tienes dudas.' } + : { en: 'Enter the exact value used by your repository or runner; leave it empty only when the question says empty is allowed.', es: 'Introduce el valor exacto de tu repositorio o runner; déjalo vacío solo si la pregunta lo permite.' }; + const howById = { + 'pullRequestApproval.coverage.checkName': { + en: 'Select one of the trusted checks above. Open its linked run and workflow file; the coverage step must fail this job when the budget fails. A green result alone is not proof.', + es: 'Elige uno de los checks fiables anteriores. Abre su ejecución y workflow; el paso de cobertura debe hacer fallar el job si no se alcanza el mínimo. Un resultado verde no basta.', + }, + 'pullRequestApproval.producerAttested': { + en: 'Answer Yes only after inspecting every selected name, App ID and workflow, plus the coverage-enforcing step of the check you just chose. Otherwise answer No and stay in recommendation mode.', + es: 'Responde Sí solo tras comprobar cada nombre, ID de App y workflow, además del paso obligatorio de cobertura del check elegido. Si no, responde No y mantén el modo recomendación.', + }, + 'pullRequestApproval.coverage.artifactWorkflowName': { + en: 'Enter the exact name of a trusted selected workflow that publishes copilot-diff-coverage-v1 for this PR/head/base. Do not enter an artifact filename or a guessed workflow name.', + es: 'Escribe el nombre exacto de un workflow fiable seleccionado que publique copilot-diff-coverage-v1 para este PR/head/base. No pongas un archivo ni un nombre supuesto.', + }, + 'projects.statusVerified': { + en: 'Open every selected Project in GitHub, inspect its Status field, and compare the exact four values shown above. Choose Yes only when all four exist in every Project; No returns to Project selection.', + es: 'Abre cada Project elegido en GitHub, revisa su campo Status y compara los cuatro valores exactos anteriores. Elige Sí solo si todos existen en cada Project; No vuelve a la selección de Projects.', + }, + }; + const statusHow = /^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id) + ? { en: 'Choose an option shown in every selected Project’s Status field. If options cannot be read, open each Project in GitHub and enter the same exact existing option; different names per Project are not supported.', + es: 'Elige una opción del campo Status de todos los Projects seleccionados. Si no se pueden consultar, abre cada Project y escribe el mismo valor existente; no se admiten nombres distintos por Project.' } + : undefined; + const how = howById[question.id] ?? statusHow ?? genericHow; + const where = location[question.stateId]; + return { + en: { label: question.label.replace(' (Space toggles, Enter confirms)', ''), ...copy.en, + summary: special[question.id] ? copy.en.summary : (purpose?.en ?? copy.en.summary), + where: where.en, how: how.en, why: `This choice is requested now so the plan, token permissions and future automation agree. ${copy.en.when}`, documentation }, + es: { label: (0, setup_question_translations_1.spanishQuestionLabel)(question), ...copy.es, + summary: special[question.id] ? copy.es.summary : (purpose?.es ?? copy.es.summary), + where: where.es, how: how.es, why: `Esta elección permite ajustar el plan, los permisos del PAT y la automatización futura antes de aplicar cambios. ${copy.es.when}`, documentation }, + fr: (0, setup_question_guidance_fr_1.frenchQuestionExplanation)(question, documentation), + pt: (0, setup_question_guidance_pt_1.portugueseQuestionExplanation)(question, documentation), + }; +} + + +/***/ }), + +/***/ 14440: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.portugueseQuestionExplanation = portugueseQuestionExplanation; +const setup_question_labels_fr_pt_1 = __nccwpck_require__(28247); +const setup_question_purpose_fr_pt_1 = __nccwpck_require__(98807); +const where = { + capabilities: 'A configuração escreve os fluxos escolhidos neste repositório e pede apenas as permissões GitHub necessárias.', + 'agent-runtime': 'Os fluxos GitHub Actions gerados executam este agente no respetivo runner; nada é instalado neste computador.', + 'agent-model-defaults': 'O modelo e o comando comuns são guardados na configuração do repositório usada pelos fluxos gerados.', + 'agent-role-overrides': 'Esta exceção para uma tarefa é guardada no repositório e lida apenas quando essa tarefa é executada.', + repository: 'O perfil do repositório e os fluxos gerados usam este valor em futuros eventos de ramos, questões e pull requests.', + deployment: 'O perfil do repositório controla futuros fluxos de release e hotfix; responder não publica nada.', + bugbot: 'O fluxo gerado lê esta definição da configuração ou das Variables do GitHub Actions selecionadas.', + 'pull-request-approval': 'A aprovação protegida usa identidades exatas dos produtores CI e provas das execuções de pull requests no GitHub.', + projects: 'Os Projects escolhidos e os valores do campo Status serão usados pela futura automatização de questões e pull requests.', + provisioning: 'Após a confirmação final, a configuração pode criar ou atualizar os ficheiros e recursos do GitHub Actions escolhidos.', + storage: 'O GitHub Actions guarda estes recursos no repositório ou na organização; o âmbito altera a visibilidade e as permissões do PAT.', +}; +const section = { + capabilities: { summary: 'Escolha as automatizações que o Copilot irá instalar.', when: 'Isto afeta os fluxos, as permissões GitHub e as perguntas seguintes.', example: 'Desative uma função que não pretende utilizar.', effect: 'Só as funções selecionadas entram no plano.', verify: 'Reveja o plano antes de aplicar alterações.' }, + 'agent-runtime': { summary: 'Escolha o agente CLI para esta tarefa.', when: 'Aplica-se quando a função selecionada é executada no GitHub Actions.', example: 'O Codex é executado através do comando codex.', effect: 'A Action executa o fornecedor escolhido, sem substituição implícita.', verify: 'Confirme que o runner tem o CLI e as credenciais necessárias.' }, + 'agent-model-defaults': { summary: 'Defina os modelos predefinidos comuns às tarefas do agente.', when: 'Usam-se salvo se configurar cada tarefa separadamente.', example: 'Mantenha o modelo sugerido se não tiver uma necessidade específica.', effect: 'A Action passa estes valores ao CLI escolhido.', verify: 'Reveja o plano e os modelos permitidos no runner.' }, + 'agent-role-overrides': { summary: 'Personalize esta tarefa do agente.', when: 'Apenas se tiver ativado a configuração independente por tarefa.', example: 'Use um modelo diferente para revisão e planeamento.', effect: 'A exceção só se aplica a esta tarefa.', verify: 'Reveja os valores de cada tarefa no plano.' }, + repository: { summary: 'Defina como o Copilot trata o seu repositório.', when: 'Aplica-se aos fluxos gerados e a futuros eventos de questões ou pull requests.', example: 'Indique o nome real do ramo de desenvolvimento.', effect: 'A futura automatização segue os ramos e as regras escolhidos.', verify: 'Reveja os ficheiros planeados e o perfil do repositório.' }, + deployment: { summary: 'Defina o comportamento de releases e hotfixes.', when: 'Só importa se esses fluxos estiverem ativados.', example: 'Mantenha a estratégia predefinida salvo se a política de ramos for diferente.', effect: 'Altera a gestão de ramos e pull requests de reconciliação.', verify: 'Reveja a secção de releases e hotfixes do plano.' }, + bugbot: { summary: 'Defina como o Bugbot analisa e comunica alterações.', when: 'Usa-se quando as funções de revisão por IA são executadas.', example: 'Por predefinição, publica resultados elegíveis sem bloquear todas as pull requests.', effect: 'Altera futuras publicações e diagnósticos de revisão.', verify: 'Reveja as Variables do Bugbot no plano e os resultados posteriores.' }, + 'pull-request-approval': { summary: 'Escolha as provas exigidas antes de o bot recomendar ou submeter uma aprovação.', when: 'Só se aplica se a automatização de pull requests estiver ativa.', example: '«Recommend» informa uma pessoa; «guarded» pode aprovar no GitHub.', effect: 'Uma verificação verde nesta página nunca aprova uma pull request por si só.', verify: 'Inspecione as provas CI, o Bugbot e as regras de ramos.' }, + projects: { summary: 'Escolha um valor Status existente para uma transição de questão ou PR.', when: 'Apenas se integrar Projects.', example: 'Todo na criação; In Progress no início do trabalho.', effect: 'A automatização atualiza o campo Status, não uma coluna visual.', verify: 'Verifique as opções Status de cada Project escolhido.' }, + provisioning: { summary: 'Escolha os recursos do GitHub Actions geridos pela configuração.', when: 'Isto afeta as permissões do PAT e as alterações previstas.', example: 'Mantenha os Secrets ativos se for necessário instalar o PAT do bot.', effect: 'Os recursos selecionados poderão ser criados ou atualizados após aprovação.', verify: 'Reveja os nomes exatos dos recursos no plano.' }, + storage: { summary: 'Escolha onde ficam as Variables e Secrets do GitHub Actions.', when: 'Aplica-se quando a sua criação está ativa.', example: 'O âmbito do repositório é a opção predefinida mais simples.', effect: 'Altera visibilidade, permissões e precedência.', verify: 'Confirme o âmbito e os avisos de sobreposição no plano.' }, +}; +const special = { + 'agents.findings.executable': { summary: 'Escolha o comando do agente no runner GitHub Actions, não neste computador.', when: 'Altere-o apenas se tiver instalado deliberadamente outro agente no runner.', example: 'Deixe vazio para codex, opencode ou agent, conforme o fornecedor.', effect: 'O caminho personalizado é usado pelas tarefas escolhidas e nunca é instalado automaticamente.', verify: 'Confirme que o runner tem exatamente este executável antes de ativar o fluxo.' }, + 'ai.includeReasoning': { summary: 'Peça explicações adicionais se a resposta do fornecedor as disponibilizar.', when: 'Só para diagnóstico avançado; o percurso CLI atual não fornece partes de raciocínio separadas.', example: 'Mantenha desativado numa configuração normal.', effect: 'Pode acrescentar texto do fornecedor, sem garantir metadados breves.', verify: 'Inspecione uma resposta estruturada controlada; não presuma que a opção produziu texto adicional.' }, + 'ai.bugbotDryRun': { summary: 'Mantenha o Bugbot em modo apenas de análise nas próximas execuções.', when: 'Útil numa avaliação; incompatível com provas de aprovação.', example: 'Escolha Não para publicar revisões normais.', effect: 'O Bugbot analisa sem publicar resultados nem alterar o repositório. Não é setup --dry-run.', verify: 'Inspecione o resultado do fluxo Bugbot: a simulação não publica revisão nem verificação.' }, + 'ai.bugbotOrganizationRules': { summary: 'Defina instruções gerais para o Bugbot, uma regra por linha.', when: 'Use se a equipa precisar de critérios de revisão partilhados no repositório configurado.', example: 'Assinalar alterações que contornem o isolamento entre clientes.', effect: 'Estas regras precedem as do repositório; o âmbito da Variable determina o armazenamento.', verify: 'Inspecione a Variable configurada e ative o rastreio das fontes das regras.' }, + 'ai.provisioningMode': { summary: 'Decida como a Action encontra ou instala o agente CLI.', when: 'Aplica-se no runner quando começa uma tarefa de IA ativa.', example: 'Auto reutiliza um CLI instalado ou instala uma versão fixa de Codex/OpenCode.', effect: 'Always reinstala as versões revistas; Disabled exige um CLI pré-instalado. Cursor tem de estar pré-instalado.', verify: 'Inspecione a etapa de preparação e a versão do binário comunicada pelo runner.' }, + 'pullRequestApproval.testChecks': { summary: 'Escolha os jobs CI que o bot pode aceitar como prova independente de testes.', when: 'Obrigatório para os modos Recommend e Guarded.', example: 'Selecione o job Tests exato, o ID da App GitHub e o workflow de uma execução recente.', effect: 'Só as identidades exatas listadas satisfazem a condição de aprovação.', verify: 'Abra a execução associada e confirme job, App e resultado do commit atual.' }, + 'pullRequestApproval.producerAttested': { summary: 'Confirme que inspecionou o produtor CI exato e a sua etapa obrigatória de cobertura.', when: 'Obrigatório antes de o modo Guarded poder aprovar.', example: 'Confirme que o job Tests falha se o limite de cobertura não for atingido.', effect: 'A sua confirmação fica registada; o Copilot não a deduz de uma verificação verde.', verify: 'Inspecione o ficheiro do workflow e uma execução real antes de escolher Sim.' }, + 'pullRequestApproval.coverage.mode': { summary: 'Escolha como comprovar a cobertura exigida do código alterado.', when: 'Aplica-se quando a aprovação de PR está ativa.', example: 'Check: o CI exige o limite. Numeric: um workflow fiável publica contagens limitadas.', effect: 'Check confia numa condição CI; Numeric lê copilot-diff-coverage-v1 e compara o limite.', verify: 'Inspecione, respetivamente, a condição de falha CI ou o artefacto do relatório.' }, + 'pullRequestApproval.coverage.checkName': { summary: 'Selecione a verificação fiável que falha abaixo do limite de cobertura.', when: 'Obrigatório nos dois modos de prova.', example: 'Use o mesmo job Tests exato da etapa anterior.', effect: 'O sucesso de outra verificação ou App não substitui esta condição.', verify: 'Confirme que a etapa de cobertura é obrigatória e não apenas informativa.' }, + 'projects.enabled': { summary: 'Decida se futuras questões e PR devem usar Projects GitHub existentes.', when: 'Antes de criar o PAT de configuração para prever o acesso de leitura a Projects.', example: 'Sim se a equipa usa um Project da organização; Não para ignorar.', effect: 'Sim inclui Projects: read da organização quando necessário. Nenhum Project é alterado agora.', verify: 'Reveja as permissões do PAT; escolherá os Projects concretos depois de o autorizar.' }, + 'projects.ids': { summary: 'Selecione os Projects existentes que o Copilot poderá atualizar futuramente.', when: 'Após verificar o PAT; se a lista não estiver disponível, introduza os dados manualmente.', example: 'Para https://github.com/orgs/acme/projects/5, marque o cartão ou introduza 5, nunca PVT_…', effect: 'Os números ficam guardados; nenhum item de Project é alterado agora.', verify: 'Abra cada Project e confirme proprietário e número antes de aprovar o plano.' }, +}; +function howToChoose(question) { + if (question.id === 'pullRequestApproval.coverage.checkName') + return 'Escolha uma das verificações fiáveis acima. Abra a execução e o workflow: o passo de cobertura tem de fazer falhar o job quando o limite não é atingido. Um resultado verde não basta.'; + if (question.id === 'pullRequestApproval.producerAttested') + return 'Responda Sim apenas depois de verificar cada nome, ID da App e workflow escolhido, bem como o passo obrigatório de cobertura do check selecionado. Caso contrário, responda Não e mantenha o modo de recomendação.'; + if (question.id === 'pullRequestApproval.coverage.artifactWorkflowName') + return 'Introduza o nome exato de um workflow fiável selecionado que publique copilot-diff-coverage-v1 para este PR e os seus commits base e head. Não adivinhe o nome do workflow.'; + if (question.id === 'projects.statusVerified') + return 'Abra cada Project escolhido no GitHub, inspecione o campo Status e compare os quatro valores exatos acima. Responda Sim apenas se todos existirem em cada Project; Não regressa à seleção de Projects.'; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) + return 'Escolha uma opção do campo Status presente em todos os Projects selecionados. Se não conseguir consultar as opções, abra cada Project no GitHub e introduza o mesmo valor existente; valores diferentes por Project não são suportados.'; + switch (question.kind) { + case 'boolean': return 'Escolha Sim para ativar ou Não para desativar; a resposta sugerida aparece abaixo.'; + case 'producer-select': return 'Inspecione cada execução candidata no GitHub e escolha o job, ID da App e workflow exatos. Introduza manualmente apenas se não houver candidato verificado.'; + case 'project-select': return 'Selecione pelo título e URL. Se faltar um Project, introduza o número positivo ou URL exato do GitHub; IDs PVT_ não são válidos.'; + case 'scope-overrides': return 'Selecione apenas os nomes herdados que pretende substituir no repositório. Deixe vazio para conservar os valores da organização.'; + case 'multi-select': return 'Assinale os fluxos que pretende usar. Pode escolher vários; reveja as permissões antes de criar um PAT.'; + case 'choice': return 'Escolha um valor após ler as consequências; o valor guardado não é traduzido.'; + case 'number': return 'Introduza um número inteiro no intervalo indicado; mantenha o valor sugerido se tiver dúvidas.'; + default: return 'Introduza o valor exato usado pelo repositório ou runner; deixe vazio apenas se a pergunta o permitir.'; + } +} +function portugueseQuestionExplanation(question, documentation) { + const copy = special[question.id] ?? section[question.stateId]; + return { + label: (0, setup_question_labels_fr_pt_1.translatedQuestionLabel)(question, 'pt'), + ...copy, + summary: special[question.id] ? copy.summary : ((0, setup_question_purpose_fr_pt_1.setupQuestionPurposeFrPt)(question, 'pt') ?? copy.summary), + where: where[question.stateId], + how: howToChoose(question), + why: `Esta decisão permite alinhar o plano, as permissões do PAT e a futura automatização antes de aplicar alterações. ${copy.when}`, + documentation: { title: 'Documentação desta opção', url: documentation.url }, + }; +} + + +/***/ }), + +/***/ 55765: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.questionLabelsFr = void 0; +/** French presentation labels; semantic question IDs remain unchanged. */ +exports.questionLabelsFr = { + 'features.issues': 'Automatiser les tickets : branches, étiquettes, projets et cycle de vie', + 'features.pullRequests': 'Automatiser les pull requests : revue, description et cycle de vie', + 'features.commits': 'Automatiser les commits : progression, taille et analyse Bugbot', + 'features.issueComments': 'Répondre aux commentaires des tickets et permettre les corrections Bugbot', + 'features.pullRequestComments': 'Répondre aux commentaires des pull requests et permettre les corrections Bugbot', + 'features.agentProvisioning': 'Vérifier l’installation des agents CLI dans GitHub Actions', + 'features.credentialHealth': 'Vérifier l’état des identifiants distants', + 'features.inactiveIssueClosure': 'Fermer les tickets inactifs après le délai défini', + 'features.issueTemplates': 'Installer les modèles de ticket', + 'features.pullRequestTemplate': 'Installer le modèle de pull request', + 'issueWorkflows.enabled': 'Types de workflows de ticket à activer', + 'repositoryAgentGuidance.enabled': 'Générer des instructions pour les agents dans le dépôt ?', + 'repositoryAgentGuidance.agentsPointer': 'Comment trouver les instructions depuis AGENTS.md', + 'agents.findings.modelProvider': 'Fournisseur de modèle partagé (sauf réglage propre à une tâche)', + 'agents.findings.model': 'Modèle partagé (sauf réglage propre à une tâche)', + 'agents.findings.effort': 'Effort de raisonnement partagé (les réglages par tâche sont conservés)', + 'agents.findings.executable': 'Exécutable partagé validé (les réglages par tâche sont conservés)', + 'agents.configureIndependently': 'Configurer le modèle et la commande séparément pour chaque tâche ?', + 'repository.mainBranch': 'Branche de production', + 'repository.developmentBranch': 'Branche de développement', + 'repository.featureTree': 'Préfixe des branches de fonctionnalité', + 'repository.bugfixTree': 'Préfixe des branches de correction', + 'repository.hotfixTree': 'Préfixe des branches de correctif urgent', + 'repository.releaseTree': 'Préfixe des branches de version', + 'repository.docsTree': 'Préfixe des branches de documentation', + 'repository.choreTree': 'Préfixe des branches de maintenance', + 'repository.issueManagedBranches': 'L’Action peut-elle créer des branches liées aux tickets ?', + 'repository.preBranchSdd': 'Exiger un SDD avant de créer certaines branches ?', + 'repository.reopenIssueOnPush': 'Rouvrir un ticket fermé quand sa branche reçoit des commits ?', + 'repository.desiredAssigneesCount': 'Nombre souhaité de responsables par ticket', + 'repository.desiredReviewersCount': 'Nombre souhaité de réviseurs par pull request', + 'repository.inactivityThresholdHours': 'Heures d’inactivité avant la fermeture d’un ticket en attente', + 'repository.repositoryLocale': 'Langue des messages du dépôt', + 'repository.issueLocale': 'Langue des tickets (vide : hériter)', + 'repository.pullRequestLocale': 'Langue des pull requests (vide : hériter)', + 'repository.commitPrefixTransforms': 'Transformation des préfixes de commit', + 'repository.releaseReconciliationStrategy': 'Stratégie de réconciliation des versions', + 'repository.hotfixReconciliationStrategy': 'Stratégie de réconciliation des correctifs urgents', + 'repository.reconciliationPullRequestMode': 'Mode des pull requests de réconciliation', + 'repository.reconciliationBackmergeMode': 'Mode de fusion de retour', + 'repository.hotfixActiveReleasePolicy': 'Destination du correctif pendant une version active', + 'repository.reconciliationTree': 'Préfixe des branches de réconciliation', + 'repository.reconciliationCleanup': 'Nettoyage des branches après réconciliation', + 'repository.reconciliationIssueCompletion': 'Sort du ticket après réconciliation', + 'repository.orchestrationPresentationMode': 'Niveau de détail du centre de contrôle des versions', + 'repository.orchestrationDiagrams': 'Afficher des diagrammes accessibles pour les versions ?', + 'repository.orchestrationCommentMode': 'Comment publier les commentaires du cycle de version', + 'ai.pullRequestDescriptionMode': 'Comment mettre à jour la description des pull requests', + 'ai.ignoreFiles': 'Fichiers que l’IA doit ignorer', + 'ai.membersOnly': 'Limiter le traitement par IA aux membres du dépôt ?', + 'ai.includeReasoning': 'Inclure des explications supplémentaires du fournisseur ?', + 'ai.bugbotSeverity': 'Gravité minimale des résultats publiés par Bugbot', + 'ai.bugbotCommentLimit': 'Nombre maximal de commentaires Bugbot par exécution', + 'ai.bugbotFixVerifyCommands': 'Commandes de vérification des corrections Bugbot', + 'ai.bugbotDryRun': 'Analyser avec Bugbot sans publier de changements ?', + 'ai.bugbotEffort': 'Profondeur de l’analyse Bugbot', + 'ai.bugbotReviewDrafts': 'Analyser les pull requests en brouillon ?', + 'ai.bugbotTraceRules': 'Indiquer quelles sources de règles ont été appliquées ?', + 'ai.bugbotSuggestedChanges': 'Publier des suggestions de modification sûres ?', + 'ai.bugbotTelemetry': 'Enregistrer des métriques Bugbot sans contenu ?', + 'ai.bugbotFailOnUnresolved': 'Faire échouer la vérification si des résultats restent ouverts ?', + 'ai.bugbotOrganizationRules': 'Règles Bugbot communes, une par ligne', + 'ai.provisioningMode': 'Comment préparer l’agent CLI sur le runner', + 'pullRequestApproval.mode': 'Que peut faire le bot pour approuver les pull requests ?', + 'pullRequestApproval.testChecks': 'Quelles vérifications CI sont fiables pour approuver ?', + 'pullRequestApproval.producerAttested': 'Avez-vous vérifié le job, l’App et l’étape obligatoire de couverture ?', + 'pullRequestApproval.coverage.mode': 'Comment prouver la couverture requise', + 'pullRequestApproval.coverage.checkName': 'Vérification fiable imposant la couverture', + 'pullRequestApproval.coverage.minDiffPercent': 'Couverture minimale des lignes modifiées (0–100)', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Workflow publiant copilot-diff-coverage-v1', + 'pullRequestApproval.coverage.reporterAttested': 'Avez-vous vérifié l’installation du rapporteur numérique ?', + 'projects.enabled': 'Intégrer des Projects GitHub existants ?', + 'projects.ids': 'Choisir des Projects existants ou saisir leurs numéros d’URL', + 'projects.statusVerified': 'Avez-vous vérifié sur GitHub les quatre valeurs Status exactes de chaque Project choisi ?', + 'projects.issueCreatedColumn': 'Valeur Status des nouveaux tickets', + 'projects.pullRequestCreatedColumn': 'Valeur Status des nouvelles pull requests', + 'projects.issueInProgressColumn': 'Valeur Status des tickets en cours', + 'projects.pullRequestInProgressColumn': 'Valeur Status des pull requests en cours', + createInitialTag: 'Créer v1.0.0 si aucune étiquette de version n’existe ?', + manageRepositoryVariables: 'Créer ou mettre à jour les Variables GitHub Actions ?', + manageRepositorySecrets: 'Valider et configurer les Secrets GitHub Actions ?', +}; + + +/***/ }), + +/***/ 6958: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.questionLabelsPt = void 0; +/** Portuguese presentation labels; semantic question IDs remain unchanged. */ +exports.questionLabelsPt = { + 'features.issues': 'Automatizar questões: ramos, etiquetas, projetos e ciclo de vida', + 'features.pullRequests': 'Automatizar pull requests: revisão, descrição e ciclo de vida', + 'features.commits': 'Automatizar commits: progresso, tamanho e análise do Bugbot', + 'features.issueComments': 'Responder a comentários de questões e permitir correções do Bugbot', + 'features.pullRequestComments': 'Responder a comentários de pull requests e permitir correções do Bugbot', + 'features.agentProvisioning': 'Verificar a instalação dos agentes CLI no GitHub Actions', + 'features.credentialHealth': 'Verificar o estado das credenciais remotas', + 'features.inactiveIssueClosure': 'Fechar questões inativas após o prazo definido', + 'features.issueTemplates': 'Instalar modelos de questão', + 'features.pullRequestTemplate': 'Instalar o modelo de pull request', + 'issueWorkflows.enabled': 'Tipos de fluxo de questões a ativar', + 'repositoryAgentGuidance.enabled': 'Gerar instruções para agentes no repositório?', + 'repositoryAgentGuidance.agentsPointer': 'Como encontrar as instruções a partir de AGENTS.md', + 'agents.findings.modelProvider': 'Fornecedor de modelo partilhado (salvo definição própria de uma tarefa)', + 'agents.findings.model': 'Modelo partilhado (salvo definição própria de uma tarefa)', + 'agents.findings.effort': 'Esforço de raciocínio partilhado (as definições por tarefa são preservadas)', + 'agents.findings.executable': 'Executável partilhado validado (as definições por tarefa são preservadas)', + 'agents.configureIndependently': 'Configurar modelo e comando separadamente para cada tarefa?', + 'repository.mainBranch': 'Ramo de produção', + 'repository.developmentBranch': 'Ramo de desenvolvimento', + 'repository.featureTree': 'Prefixo dos ramos de funcionalidade', + 'repository.bugfixTree': 'Prefixo dos ramos de correção', + 'repository.hotfixTree': 'Prefixo dos ramos de hotfix', + 'repository.releaseTree': 'Prefixo dos ramos de release', + 'repository.docsTree': 'Prefixo dos ramos de documentação', + 'repository.choreTree': 'Prefixo dos ramos de manutenção', + 'repository.issueManagedBranches': 'A Action pode criar ramos associados a questões?', + 'repository.preBranchSdd': 'Exigir um SDD antes de criar determinados ramos?', + 'repository.reopenIssueOnPush': 'Reabrir uma questão fechada quando o seu ramo recebe commits?', + 'repository.desiredAssigneesCount': 'Número pretendido de responsáveis por questão', + 'repository.desiredReviewersCount': 'Número pretendido de revisores por pull request', + 'repository.inactivityThresholdHours': 'Horas de inatividade antes de fechar uma questão em espera', + 'repository.repositoryLocale': 'Idioma das mensagens do repositório', + 'repository.issueLocale': 'Idioma das questões (vazio: herdar)', + 'repository.pullRequestLocale': 'Idioma das pull requests (vazio: herdar)', + 'repository.commitPrefixTransforms': 'Transformação dos prefixos dos commits', + 'repository.releaseReconciliationStrategy': 'Estratégia de reconciliação de releases', + 'repository.hotfixReconciliationStrategy': 'Estratégia de reconciliação de hotfixes', + 'repository.reconciliationPullRequestMode': 'Modo das pull requests de reconciliação', + 'repository.reconciliationBackmergeMode': 'Modo da fusão de retorno', + 'repository.hotfixActiveReleasePolicy': 'Destino do hotfix durante uma release ativa', + 'repository.reconciliationTree': 'Prefixo dos ramos de reconciliação', + 'repository.reconciliationCleanup': 'Limpeza de ramos após a reconciliação', + 'repository.reconciliationIssueCompletion': 'O que fazer à questão após a reconciliação', + 'repository.orchestrationPresentationMode': 'Nível de detalhe do centro de controlo de releases', + 'repository.orchestrationDiagrams': 'Mostrar diagramas acessíveis para releases?', + 'repository.orchestrationCommentMode': 'Como publicar os comentários do ciclo de release', + 'ai.pullRequestDescriptionMode': 'Como atualizar a descrição das pull requests', + 'ai.ignoreFiles': 'Ficheiros que a IA deve ignorar', + 'ai.membersOnly': 'Limitar o processamento por IA aos membros do repositório?', + 'ai.includeReasoning': 'Incluir explicações adicionais do fornecedor?', + 'ai.bugbotSeverity': 'Gravidade mínima dos resultados publicados pelo Bugbot', + 'ai.bugbotCommentLimit': 'Número máximo de comentários do Bugbot por execução', + 'ai.bugbotFixVerifyCommands': 'Comandos de verificação das correções do Bugbot', + 'ai.bugbotDryRun': 'Analisar com o Bugbot sem publicar alterações?', + 'ai.bugbotEffort': 'Profundidade da análise do Bugbot', + 'ai.bugbotReviewDrafts': 'Rever pull requests em rascunho?', + 'ai.bugbotTraceRules': 'Indicar que fontes de regras foram aplicadas?', + 'ai.bugbotSuggestedChanges': 'Publicar sugestões de alteração seguras?', + 'ai.bugbotTelemetry': 'Registar métricas do Bugbot sem conteúdo?', + 'ai.bugbotFailOnUnresolved': 'Fazer falhar a verificação se houver resultados por resolver?', + 'ai.bugbotOrganizationRules': 'Regras Bugbot partilhadas, uma por linha', + 'ai.provisioningMode': 'Como preparar o agente CLI no runner', + 'pullRequestApproval.mode': 'O que pode o bot fazer na aprovação de pull requests?', + 'pullRequestApproval.testChecks': 'Que verificações CI são fiáveis para aprovar?', + 'pullRequestApproval.producerAttested': 'Verificou o job, a App e a etapa obrigatória de cobertura?', + 'pullRequestApproval.coverage.mode': 'Como comprovar a cobertura exigida', + 'pullRequestApproval.coverage.checkName': 'Verificação fiável que exige cobertura', + 'pullRequestApproval.coverage.minDiffPercent': 'Cobertura mínima das linhas alteradas (0–100)', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Workflow que publica copilot-diff-coverage-v1', + 'pullRequestApproval.coverage.reporterAttested': 'Verificou a instalação do relatório numérico?', + 'projects.enabled': 'Integrar Projects GitHub existentes?', + 'projects.ids': 'Selecionar Projects existentes ou introduzir os números dos URL', + 'projects.statusVerified': 'Confirmou no GitHub os quatro valores Status exatos de cada Project escolhido?', + 'projects.issueCreatedColumn': 'Valor Status das novas questões', + 'projects.pullRequestCreatedColumn': 'Valor Status das novas pull requests', + 'projects.issueInProgressColumn': 'Valor Status das questões em curso', + 'projects.pullRequestInProgressColumn': 'Valor Status das pull requests em curso', + createInitialTag: 'Criar v1.0.0 se ainda não existir uma etiqueta de versão?', + manageRepositoryVariables: 'Criar ou atualizar as Variables do GitHub Actions?', + manageRepositorySecrets: 'Validar e configurar os Secrets do GitHub Actions?', +}; + + +/***/ }), + +/***/ 28247: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.questionLabelsFrPt = void 0; +exports.translatedQuestionLabel = translatedQuestionLabel; +const setup_question_translations_1 = __nccwpck_require__(3927); +const fr_1 = __nccwpck_require__(55765); +const pt_1 = __nccwpck_require__(6958); +exports.questionLabelsFrPt = { fr: fr_1.questionLabelsFr, pt: pt_1.questionLabelsPt }; +const roleNames = { + fr: { planner: 'Planification', findings: 'Résultats', reviewer: 'Revue', fixer: 'Correction', tester: 'Tests' }, + pt: { planner: 'Planeamento', findings: 'Resultados', reviewer: 'Revisão', fixer: 'Correção', tester: 'Testes' }, +}; +function translatedQuestionLabel(question, locale) { + if (locale === 'en') + return question.label.replace(' (Space toggles, Enter confirms)', ''); + if (locale === 'es') + return (0, setup_question_translations_1.spanishQuestionLabel)(question); + const exact = exports.questionLabelsFrPt[locale][question.id]; + if (exact) + return exact; + const agent = question.id.match(/^agents\.(planner|findings|reviewer|fixer|tester)\.(provider|modelProvider|model|effort|executable)$/u); + if (agent) { + const fields = { + fr: { provider: 'agent CLI', modelProvider: 'fournisseur du modèle', model: 'modèle', effort: 'effort de raisonnement', executable: 'commande exécutable' }, + pt: { provider: 'agente CLI', modelProvider: 'fornecedor do modelo', model: 'modelo', effort: 'esforço de raciocínio', executable: 'comando executável' }, + }; + return `${roleNames[locale][agent[1]]} : ${fields[locale][agent[2]]}`; + } + const storage = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storage) { + const resources = { fr: { variables: 'Variables', secrets: 'Secrets' }, pt: { variables: 'Variables', secrets: 'Secrets' } }; + const fields = { + fr: { defaultScope: 'périmètre par défaut', organizationVisibility: 'visibilité dans l’organisation', preserveExisting: 'conserver les ressources existantes', overrides: 'exceptions de périmètre' }, + pt: { defaultScope: 'âmbito predefinido', organizationVisibility: 'visibilidade na organização', preserveExisting: 'conservar recursos existentes', overrides: 'exceções de âmbito' }, + }; + return `${resources[locale][storage[1]]} : ${fields[locale][storage[2]]}`; + } + return question.label; +} + + +/***/ }), + +/***/ 92139: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.purposesFr = void 0; +/** French question-specific purposes. */ +exports.purposesFr = { + 'issueWorkflows.enabled': 'Choisissez les workflows de ticket que Copilot pourra exécuter ; chacun agit différemment sur les branches, étiquettes et automatisations.', + 'repositoryAgentGuidance.enabled': 'Générez des instructions pour aider les agents IA à travailler en sécurité dans ce projet.', + 'repositoryAgentGuidance.agentsPointer': 'Décidez si le fichier AGENTS.md racine renvoie aux instructions générées, est créé s’il manque, ou reste intact.', + 'agents.configureIndependently': 'Attribuez des fournisseurs et modèles distincts à la planification, aux résultats, à la revue, à la correction et aux tests.', + 'repository.mainBranch': 'Indiquez la branche de production utilisée comme référence par les versions et correctifs urgents.', + 'repository.developmentBranch': 'Indiquez la branche d’intégration habituelle visée par la création de branches et la réconciliation.', + 'repository.issueManagedBranches': 'Autorisez l’Action à créer une branche liée lorsqu’un ticket passe en cours.', + 'repository.preBranchSdd': 'Exigez un document de conception approuvé avant certaines branches de fonctionnalité ou de changement de contrat.', + 'repository.reopenIssueOnPush': 'Rouvrez un ticket terminé quand de nouveaux commits arrivent sur sa branche liée.', + 'repository.desiredAssigneesCount': 'Définissez combien de personnes Copilot affecte à un nouveau ticket ; zéro désactive l’affectation automatique.', + 'repository.desiredReviewersCount': 'Définissez combien de réviseurs Copilot sollicite pour une pull request ; zéro désactive les demandes automatiques.', + 'repository.inactivityThresholdHours': 'Définissez combien de temps un ticket reste sans activité avant que le workflow activé puisse le fermer.', + 'repository.repositoryLocale': 'Choisissez la balise de langue BCP-47 des messages Copilot sur GitHub ; elle ne change pas la langue de cette page.', + 'repository.issueLocale': 'Changez la langue des messages GitHub pour les tickets ; laissez vide pour hériter de la langue du dépôt.', + 'repository.pullRequestLocale': 'Changez la langue des messages GitHub pour les pull requests ; laissez vide pour hériter de la langue du dépôt.', + 'repository.commitPrefixTransforms': 'Définissez les substitutions de préfixes de commit ; laissez vide si vos conventions n’en ont pas besoin.', + 'repository.releaseReconciliationStrategy': 'Choisissez comment les changements d’une version terminée reviennent dans le développement sans perdre leur filiation.', + 'repository.hotfixReconciliationStrategy': 'Choisissez comment un correctif urgent de production est reporté sur les branches en cours.', + 'repository.reconciliationPullRequestMode': 'Choisissez si les pull requests de réconciliation sont créées, fusionnées, mises en file ou laissées à une personne.', + 'repository.reconciliationBackmergeMode': 'Choisissez une fusion de retour directe ou passant par une branche de synchronisation.', + 'repository.hotfixActiveReleasePolicy': 'Choisissez où propager un correctif urgent lorsqu’une branche de version est déjà active.', + 'repository.reconciliationCleanup': 'Choisissez les branches temporaires à supprimer après une réconciliation réussie.', + 'repository.reconciliationIssueCompletion': 'Choisissez si le ticket à l’origine de la réconciliation se ferme ou reste ouvert pour suivi.', + 'repository.orchestrationPresentationMode': 'Choisissez le niveau de progression et de détail affiché dans le centre de contrôle GitHub des versions.', + 'repository.orchestrationDiagrams': 'Incluez des diagrammes Mermaid accessibles dans les informations de version.', + 'repository.orchestrationCommentMode': 'Choisissez si les commentaires de version sont mis à jour ou publiés à chaque étape importante.', + 'ai.pullRequestDescriptionMode': 'Choisissez si l’IA remplace, complète, préserve ou ne modifie jamais les descriptions des pull requests.', + 'ai.ignoreFiles': 'Indiquez les motifs de fichiers à exclure de la revue IA ; ces fichiers restent visibles sur GitHub.', + 'ai.membersOnly': 'N’autorisez le traitement IA que pour les demandes des membres du dépôt, pas pour tous les contributeurs externes.', + 'ai.bugbotSeverity': 'Fixez la gravité minimale publiée par Bugbot ; les résultats moins graves restent non publiés.', + 'ai.bugbotCommentLimit': 'Limitez les commentaires Bugbot par exécution pour ne pas submerger une pull request.', + 'ai.bugbotFixVerifyCommands': 'Indiquez les commandes qui doivent réussir avant qu’une correction automatique Bugbot soit considérée comme vérifiée.', + 'ai.bugbotEffort': 'Choisissez la profondeur des revues Bugbot ; un effort supérieur peut durer et consommer davantage.', + 'ai.bugbotReviewDrafts': 'Décidez si Bugbot analyse les pull requests en brouillon avant qu’elles soient prêtes.', + 'ai.bugbotTraceRules': 'Ajoutez l’origine de chaque règle de revue appliquée dans les résumés Bugbot pour faciliter l’audit.', + 'ai.bugbotSuggestedChanges': 'Autorisez Bugbot à joindre des suggestions de code sûres aux résultats publiés.', + 'ai.bugbotTelemetry': 'Enregistrez des métriques opérationnelles Bugbot sans stocker le contenu du dépôt.', + 'ai.bugbotFailOnUnresolved': 'Faites échouer la vérification Bugbot tant que des résultats exploitables restent ouverts.', + 'pullRequestApproval.mode': 'Choisissez si le bot recommande une approbation, peut approuver GitHub sous garde, ou n’intervient pas.', + 'pullRequestApproval.coverage.minDiffPercent': 'Définissez le pourcentage minimal de lignes modifiées couvertes qu’un rapporteur numérique fiable doit prouver.', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Indiquez le workflow fiable exact qui publie l’artefact copilot-diff-coverage-v1.', + 'pullRequestApproval.coverage.reporterAttested': 'Confirmez avoir inspecté le rapporteur numérique dans ce workflow exact, et non seulement sa vérification verte.', + 'projects.enabled': 'Décidez si Copilot doit ajouter tickets et pull requests à des Projects existants ; le PAT servira ensuite à lister ceux de l’organisation.', + 'projects.ids': 'Choisissez des Projects existants par leur titre ou saisissez le numéro positif de leur URL ; les ID PVT_ ne conviennent pas.', + 'projects.statusVerified': 'Confirmez que les quatre options Status choisies existent dans chaque Project lorsque GitHub n’a pas pu vérifier leurs champs.', + createInitialTag: 'Créez v1.0.0 seulement si le dépôt n’a encore aucune étiquette de version.', + manageRepositoryVariables: 'Autorisez la création ou mise à jour des Variables GitHub Actions nécessaires aux workflows choisis.', + manageRepositorySecrets: 'Autorisez la validation et l’installation des Secrets GitHub Actions requis, dont le PAT du bot si nécessaire.', +}; + + +/***/ }), + +/***/ 48284: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.purposesPt = void 0; +/** Portuguese question-specific purposes. */ +exports.purposesPt = { + 'issueWorkflows.enabled': 'Escolha os fluxos de questões que o Copilot poderá executar; cada um afeta de forma diferente ramos, etiquetas e automatizações.', + 'repositoryAgentGuidance.enabled': 'Gere instruções para ajudar os agentes de IA a trabalhar com segurança neste projeto.', + 'repositoryAgentGuidance.agentsPointer': 'Decida se o AGENTS.md da raiz aponta para as instruções geradas, é criado se faltar ou permanece intacto.', + 'agents.configureIndependently': 'Defina fornecedores e modelos distintos para planeamento, resultados, revisão, correção e testes.', + 'repository.mainBranch': 'Indique o ramo de produção usado como referência por releases e hotfixes.', + 'repository.developmentBranch': 'Indique o ramo de integração habitual usado na criação de ramos e na reconciliação.', + 'repository.issueManagedBranches': 'Permita que a Action crie um ramo associado quando uma questão passa a estar em curso.', + 'repository.preBranchSdd': 'Exija um documento de desenho aprovado antes de determinados ramos de funcionalidade ou de alteração de contratos.', + 'repository.reopenIssueOnPush': 'Reabra uma questão concluída quando forem enviados novos commits para o ramo associado.', + 'repository.desiredAssigneesCount': 'Defina quantas pessoas o Copilot atribui a uma nova questão; zero desativa a atribuição automática.', + 'repository.desiredReviewersCount': 'Defina quantos revisores o Copilot solicita para uma pull request; zero desativa os pedidos automáticos.', + 'repository.inactivityThresholdHours': 'Defina quanto tempo uma questão fica sem atividade antes de o fluxo ativado a poder fechar.', + 'repository.repositoryLocale': 'Escolha a etiqueta BCP-47 das mensagens do Copilot no GitHub; não altera o idioma desta página.', + 'repository.issueLocale': 'Altere o idioma das mensagens GitHub para questões; deixe vazio para herdar o idioma do repositório.', + 'repository.pullRequestLocale': 'Altere o idioma das mensagens GitHub para pull requests; deixe vazio para herdar o idioma do repositório.', + 'repository.commitPrefixTransforms': 'Defina substituições dos prefixos dos commits; deixe vazio se as suas convenções não precisarem delas.', + 'repository.releaseReconciliationStrategy': 'Escolha como as alterações de uma release concluída regressam ao desenvolvimento sem perder a sua origem.', + 'repository.hotfixReconciliationStrategy': 'Escolha como um hotfix de produção é propagado para os ramos em curso.', + 'repository.reconciliationPullRequestMode': 'Escolha se as pull requests de reconciliação são criadas, integradas, colocadas em fila ou deixadas a uma pessoa.', + 'repository.reconciliationBackmergeMode': 'Escolha uma fusão de retorno direta ou através de um ramo de sincronização.', + 'repository.hotfixActiveReleasePolicy': 'Escolha para onde propagar um hotfix quando já existe um ramo de release ativo.', + 'repository.reconciliationCleanup': 'Escolha que ramos temporários serão eliminados após uma reconciliação bem-sucedida.', + 'repository.reconciliationIssueCompletion': 'Escolha se a questão que iniciou a reconciliação é fechada ou fica aberta para acompanhamento.', + 'repository.orchestrationPresentationMode': 'Escolha o nível de progresso e detalhe apresentado no centro de controlo GitHub das releases.', + 'repository.orchestrationDiagrams': 'Inclua diagramas Mermaid acessíveis na informação sobre releases.', + 'repository.orchestrationCommentMode': 'Escolha se os comentários da release são atualizados ou publicados em cada marco.', + 'ai.pullRequestDescriptionMode': 'Escolha se a IA substitui, acrescenta, preserva ou nunca altera as descrições das pull requests.', + 'ai.ignoreFiles': 'Indique padrões de ficheiros a excluir da revisão por IA; continuam visíveis no GitHub.', + 'ai.membersOnly': 'Permita o processamento por IA apenas para pedidos de membros do repositório, não de quaisquer colaboradores externos.', + 'ai.bugbotSeverity': 'Defina a gravidade mínima publicada pelo Bugbot; resultados menos graves não são publicados.', + 'ai.bugbotCommentLimit': 'Limite os comentários do Bugbot por execução para não sobrecarregar uma pull request.', + 'ai.bugbotFixVerifyCommands': 'Indique os comandos que têm de passar antes de uma correção automática do Bugbot ser considerada verificada.', + 'ai.bugbotEffort': 'Escolha a profundidade das revisões do Bugbot; mais esforço pode demorar e consumir mais recursos.', + 'ai.bugbotReviewDrafts': 'Decida se o Bugbot revê pull requests em rascunho antes de estarem prontas.', + 'ai.bugbotTraceRules': 'Inclua a origem de cada regra de revisão aplicada nos resumos do Bugbot para facilitar auditorias.', + 'ai.bugbotSuggestedChanges': 'Permita ao Bugbot anexar sugestões de código seguras aos resultados publicados.', + 'ai.bugbotTelemetry': 'Registe métricas operacionais do Bugbot sem guardar conteúdo do repositório.', + 'ai.bugbotFailOnUnresolved': 'Faça falhar a verificação do Bugbot enquanto existirem resultados acionáveis por resolver.', + 'pullRequestApproval.mode': 'Escolha se o bot recomenda aprovação, pode aprovar no GitHub sob condições ou não intervém.', + 'pullRequestApproval.coverage.minDiffPercent': 'Defina a percentagem mínima de linhas alteradas cobertas que um relatório numérico fiável tem de provar.', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Indique o workflow fiável exato que publica o artefacto copilot-diff-coverage-v1.', + 'pullRequestApproval.coverage.reporterAttested': 'Confirme que inspecionou o relatório numérico nesse workflow exato, e não apenas uma verificação verde.', + 'projects.enabled': 'Decida se o Copilot deve adicionar questões e pull requests a Projects existentes; o PAT será usado depois para listar os da organização.', + 'projects.ids': 'Selecione Projects existentes pelo título ou introduza o número positivo do URL; IDs PVT_ não são usados.', + 'projects.statusVerified': 'Confirme que as quatro opções Status escolhidas existem em todos os Projects quando o GitHub não conseguiu verificar os campos.', + createInitialTag: 'Crie v1.0.0 apenas se o repositório ainda não tiver uma etiqueta de versão.', + manageRepositoryVariables: 'Permita criar ou atualizar as Variables do GitHub Actions necessárias aos fluxos escolhidos.', + manageRepositorySecrets: 'Permita validar e instalar os Secrets do GitHub Actions necessários, incluindo o PAT do bot quando aplicável.', +}; + + +/***/ }), + +/***/ 98807: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.purposesFrPt = void 0; +exports.setupQuestionPurposeFrPt = setupQuestionPurposeFrPt; +const fr_1 = __nccwpck_require__(92139); +const pt_1 = __nccwpck_require__(48284); +exports.purposesFrPt = { fr: fr_1.purposesFr, pt: pt_1.purposesPt }; +function setupQuestionPurposeFrPt(question, locale) { + const exact = exports.purposesFrPt[locale][question.id]; + if (exact) + return exact; + if (question.id.startsWith('features.')) + return locale === 'fr' + ? 'Activez ou désactivez cette fonction. Si vous la désactivez, cette configuration n’installera ni son automatisation ni ses autorisations conditionnelles.' + : 'Ative ou desative esta função. Se a desativar, esta configuração não instalará a automatização nem pedirá as permissões condicionais correspondentes.'; + if (/^agents\.[^.]+\.provider$/u.test(question.id)) + return locale === 'fr' + ? 'Choisissez l’agent CLI de cette tâche dans GitHub Actions ; ce fournisseur détermine la commande et les identifiants du runner.' + : 'Escolha o agente CLI desta tarefa no GitHub Actions; o fornecedor determina o comando e as credenciais do runner.'; + const setting = question.id.match(/^agents\.[^.]+\.(modelProvider|model|effort|executable)$/u)?.[1]; + if (setting) { + const fields = { + fr: { modelProvider: 'le service fournissant le modèle et ses identifiants', model: 'le nom exact du modèle autorisé par le fournisseur', effort: 'l’effort de raisonnement (ou vide pour la valeur du fournisseur)', executable: 'la commande présente sur le runner GitHub Actions, pas sur cet ordinateur' }, + pt: { modelProvider: 'o serviço que fornece o modelo e as suas credenciais', model: 'o nome exato do modelo permitido pelo fornecedor', effort: 'o esforço de raciocínio (ou vazio para usar a predefinição do fornecedor)', executable: 'o comando disponível no runner GitHub Actions, não neste computador' }, + }; + const scope = question.stateId === 'agent-model-defaults' + ? (locale === 'fr' ? 'pour toutes les tâches actives' : 'para todas as tarefas ativas') + : (locale === 'fr' ? 'pour cette tâche' : 'para esta tarefa'); + return `${locale === 'fr' ? 'Définissez' : 'Defina'} ${fields[locale][setting]} ${scope}.`; + } + if (/^repository\.(feature|bugfix|hotfix|release|docs|chore|reconciliation)Tree$/u.test(question.id)) + return locale === 'fr' + ? 'Définissez le préfixe des branches créées par Copilot pour ce type de travail ; il doit suivre votre convention de nommage.' + : 'Defina o prefixo dos ramos criados pelo Copilot para este tipo de trabalho; deve seguir as suas regras de nomes.'; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) + return locale === 'fr' + ? 'Choisissez la valeur du champ Status appliquée à la création ou au début du travail ; ce n’est pas le nom d’une colonne visuelle.' + : 'Escolha o valor do campo Status aplicado na criação ou no início do trabalho; não é o nome de uma coluna visual.'; + const storage = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storage) { + const resource = storage[1] === 'variables' ? 'Variables' : 'Secrets'; + const field = storage[2]; + if (field === 'defaultScope') + return locale === 'fr' + ? `Choisissez si les nouvelles ${resource} sont stockées dans le dépôt ou l’organisation ; ce dernier périmètre peut exiger davantage d’autorisations du PAT.` + : `Escolha se as novas ${resource} ficam no repositório ou na organização; este último âmbito pode exigir mais permissões do PAT.`; + if (field === 'organizationVisibility') + return locale === 'fr' + ? `Choisissez les dépôts pouvant utiliser les ${resource} de l’organisation ; « selected » est l’accès le plus restreint.` + : `Escolha os repositórios que podem usar as ${resource} da organização; «selected» é a visibilidade mais restrita.`; + if (field === 'preserveExisting') + return locale === 'fr' + ? `Conservez les ${resource} existantes déjà applicables au lieu de les écraser pendant la configuration.` + : `Conserve as ${resource} existentes e aplicáveis em vez de as substituir durante a configuração.`; + return locale === 'fr' + ? `Sélectionnez les ${resource} héritées de l’organisation à définir plutôt dans le dépôt.` + : `Selecione as ${resource} herdadas da organização que pretende definir no repositório.`; + } + return undefined; +} + + +/***/ }), + +/***/ 77947: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.setupQuestionPurposes = void 0; +exports.setupQuestionPurpose = setupQuestionPurpose; +/** Field-specific meaning for choices whose label alone is easy to misinterpret. */ +exports.setupQuestionPurposes = { + 'issueWorkflows.enabled': { en: 'Choose the issue workflows Copilot may run; each type has different branch, label and automation effects.', es: 'Elige los flujos de issues que podrá ejecutar Copilot; cada tipo tiene efectos distintos sobre ramas, etiquetas y automatización.' }, + 'repositoryAgentGuidance.enabled': { en: 'Generate repository instructions that tell AI agents how to work safely in this project.', es: 'Genera instrucciones para que los agentes de IA trabajen con seguridad en este proyecto.' }, + 'repositoryAgentGuidance.agentsPointer': { en: 'Choose whether the root AGENTS.md points to generated instructions, is created if missing, or stays untouched.', es: 'Elige si el AGENTS.md raíz apunta a las instrucciones generadas, se crea si falta o permanece intacto.' }, + 'agents.configureIndependently': { en: 'Give planning, findings, review, fixing and testing separate provider and model settings instead of shared defaults.', es: 'Da a planificación, hallazgos, revisión, corrección y pruebas ajustes distintos de proveedor y modelo.' }, + 'repository.mainBranch': { en: 'Name the production branch; release and hotfix automation use it as their production reference.', es: 'Indica la rama de producción; las automatizaciones de release y hotfix la usan como referencia.' }, + 'repository.developmentBranch': { en: 'Name the normal integration branch; branch creation and reconciliation target it.', es: 'Indica la rama de integración habitual; la creación de ramas y la reconciliación la usan.' }, + 'repository.issueManagedBranches': { en: 'Allow the Action to create a linked branch when an issue enters an in-progress state.', es: 'Permite a la Action crear una rama vinculada cuando un issue pasa a «en curso».' }, + 'repository.preBranchSdd': { en: 'Require an approved design document before feature or contract-changing branches are created.', es: 'Exige un diseño aprobado antes de crear ramas de funcionalidad o cambios de contrato.' }, + 'repository.reopenIssueOnPush': { en: 'Reopen a completed issue when someone pushes more work to its linked branch.', es: 'Reabre un issue completado si alguien añade cambios a su rama vinculada.' }, + 'repository.desiredAssigneesCount': { en: 'Set how many people Copilot assigns to a new issue; zero disables automatic assignment.', es: 'Define cuántas personas asigna Copilot a un issue nuevo; cero desactiva la asignación automática.' }, + 'repository.desiredReviewersCount': { en: 'Set how many reviewers Copilot requests for a pull request; zero disables automatic requests.', es: 'Define cuántos revisores solicita Copilot para un pull request; cero desactiva la solicitud automática.' }, + 'repository.inactivityThresholdHours': { en: 'Set how long an issue waits without activity before the enabled inactivity workflow may close it.', es: 'Define cuánto tiempo espera sin actividad un issue antes de que el flujo habilitado pueda cerrarlo.' }, + 'repository.repositoryLocale': { en: 'Choose the BCP-47 language tag for Copilot messages on GitHub; this does not change the setup page language.', es: 'Elige la etiqueta BCP-47 de los mensajes de Copilot en GitHub; no cambia el idioma de esta página.' }, + 'repository.issueLocale': { en: 'Override the GitHub message language for issues; leave empty to inherit the repository language.', es: 'Cambia el idioma de los mensajes de issues; vacío hereda el idioma del repositorio.' }, + 'repository.pullRequestLocale': { en: 'Override the GitHub message language for pull requests; leave empty to inherit the repository language.', es: 'Cambia el idioma de los mensajes de pull requests; vacío hereda el idioma del repositorio.' }, + 'repository.commitPrefixTransforms': { en: 'Define commit-prefix rewrites used by commit automation; leave empty if your conventions need no mapping.', es: 'Define sustituciones de prefijos de commits; déjalo vacío si tus convenciones no necesitan cambios.' }, + 'repository.releaseReconciliationStrategy': { en: 'Choose how completed release changes return to development without losing production lineage.', es: 'Elige cómo vuelven los cambios de una release a desarrollo sin perder su relación con producción.' }, + 'repository.hotfixReconciliationStrategy': { en: 'Choose how an emergency production fix is carried back to ongoing branches.', es: 'Elige cómo se incorpora un arreglo urgente de producción a las demás ramas activas.' }, + 'repository.reconciliationPullRequestMode': { en: 'Choose whether reconciliation PRs are created, merged automatically, queued, or left for a human.', es: 'Elige si los PR de reconciliación se crean, fusionan automáticamente, encolan o quedan para una persona.' }, + 'repository.reconciliationBackmergeMode': { en: 'Choose whether the return merge is direct or goes through a synchronization branch.', es: 'Elige si la integración de vuelta es directa o pasa por una rama de sincronización.' }, + 'repository.hotfixActiveReleasePolicy': { en: 'Choose where a hotfix propagates when a release branch is already active.', es: 'Elige a dónde se propaga un hotfix si ya hay una rama de release activa.' }, + 'repository.reconciliationCleanup': { en: 'Choose which temporary branches are deleted after successful reconciliation.', es: 'Elige qué ramas temporales se eliminan tras una reconciliación correcta.' }, + 'repository.reconciliationIssueCompletion': { en: 'Choose whether the issue that launched reconciliation closes or stays open for follow-up.', es: 'Elige si el issue que inició la reconciliación se cierra o sigue abierto.' }, + 'repository.orchestrationPresentationMode': { en: 'Choose how much release progress and detail appears in the GitHub control-center view.', es: 'Elige cuánto progreso y detalle muestra el centro de control de releases en GitHub.' }, + 'repository.orchestrationDiagrams': { en: 'Include accessible Mermaid diagrams in release status information.', es: 'Incluye diagramas Mermaid accesibles en la información de releases.' }, + 'repository.orchestrationCommentMode': { en: 'Choose whether release lifecycle comments update in place or are posted at milestones.', es: 'Elige si los comentarios de la release se actualizan o se publican en cada hito.' }, + 'ai.pullRequestDescriptionMode': { en: 'Choose whether AI replaces, appends to, preserves, or never edits pull-request descriptions.', es: 'Elige si la IA sustituye, amplía, conserva o nunca modifica las descripciones de pull requests.' }, + 'ai.ignoreFiles': { en: 'List file patterns the AI review should skip; this does not hide those files on GitHub.', es: 'Indica patrones de archivos que la revisión con IA debe omitir; no los oculta en GitHub.' }, + 'ai.membersOnly': { en: 'Allow AI processing only for requests from repository members, not arbitrary external contributors.', es: 'Permite el procesamiento con IA solo para miembros del repositorio, no para colaboradores externos.' }, + 'ai.bugbotSeverity': { en: 'Set the lowest severity Bugbot publishes; lower-severity findings remain unpublished.', es: 'Define la gravedad mínima que publica Bugbot; los hallazgos menores no se publican.' }, + 'ai.bugbotCommentLimit': { en: 'Cap the number of Bugbot review comments in one run to avoid overwhelming a pull request.', es: 'Limita los comentarios de Bugbot por ejecución para no saturar un pull request.' }, + 'ai.bugbotFixVerifyCommands': { en: 'Specify commands that must pass before Bugbot considers an automatic fix verified.', es: 'Indica los comandos que deben pasar antes de considerar verificada una corrección de Bugbot.' }, + 'ai.bugbotEffort': { en: 'Choose the depth of Bugbot reviews; higher effort can take longer and use more model capacity.', es: 'Elige la profundidad de las revisiones de Bugbot; más esfuerzo puede tardar y consumir más.' }, + 'ai.bugbotReviewDrafts': { en: 'Decide whether Bugbot reviews draft pull requests before they are marked ready.', es: 'Decide si Bugbot revisa pull requests en borrador antes de que estén listos.' }, + 'ai.bugbotTraceRules': { en: 'Include the source of each applied review rule in Bugbot summaries for auditability.', es: 'Incluye la procedencia de las reglas aplicadas en los resúmenes de Bugbot para facilitar auditorías.' }, + 'ai.bugbotSuggestedChanges': { en: 'Allow Bugbot to attach safe inline code suggestions to published findings.', es: 'Permite a Bugbot adjuntar sugerencias de código seguras a los hallazgos publicados.' }, + 'ai.bugbotTelemetry': { en: 'Record operational Bugbot metrics without recording repository content.', es: 'Registra métricas operativas de Bugbot sin guardar contenido del repositorio.' }, + 'ai.bugbotFailOnUnresolved': { en: 'Make the Bugbot workflow check fail while actionable findings remain unresolved.', es: 'Hace fallar el check de Bugbot mientras queden hallazgos accionables sin resolver.' }, + 'pullRequestApproval.mode': { en: 'Choose whether the bot recommends approval, may submit a guarded GitHub approval, or does neither.', es: 'Elige si el bot recomienda aprobar, puede publicar una aprobación protegida o no interviene.' }, + 'pullRequestApproval.coverage.minDiffPercent': { en: 'Set the minimum percentage of changed lines that a trusted numeric reporter must prove are covered.', es: 'Define el porcentaje mínimo de líneas modificadas cubiertas que debe acreditar un reporter numérico fiable.' }, + 'pullRequestApproval.coverage.artifactWorkflowName': { en: 'Name the exact trusted workflow that publishes the copilot-diff-coverage-v1 artifact.', es: 'Indica el workflow fiable exacto que publica el artefacto copilot-diff-coverage-v1.' }, + 'pullRequestApproval.coverage.reporterAttested': { en: 'Confirm you inspected the numeric coverage reporter in that exact workflow, not just its green check.', es: 'Confirma que revisaste el reporter numérico en ese workflow exacto, no solo su check verde.' }, + 'projects.enabled': { en: 'Decide whether Copilot should add issues and pull requests to existing GitHub Projects; the setup PAT is needed to list private organization Projects later.', es: 'Decide si Copilot debe añadir issues y pull requests a Projects existentes; el PAT de setup hará falta después para consultar Projects privados de la organización.' }, + 'projects.ids': { en: 'Choose existing Projects by title after PAT verification, or enter the positive number in each Project URL; PVT_ node IDs are not used.', es: 'Elige Projects existentes por título tras verificar el PAT o introduce el número positivo de cada URL; no se usan IDs de nodo PVT_.' }, + 'projects.statusVerified': { en: 'Confirm that all four chosen Status options actually exist in every selected Project when GitHub could not verify their fields.', es: 'Confirma que las cuatro opciones Status existen en todos los Projects elegidos cuando GitHub no pudo comprobar sus campos.' }, + createInitialTag: { en: 'Create v1.0.0 only if this repository has no version tag yet.', es: 'Crea v1.0.0 solo si este repositorio todavía no tiene un tag de versión.' }, + manageRepositoryVariables: { en: 'Allow setup to create or update GitHub Actions Variables required by selected workflows.', es: 'Permite a setup crear o actualizar Variables de GitHub Actions necesarias para los workflows elegidos.' }, + manageRepositorySecrets: { en: 'Allow setup to validate and install required GitHub Actions Secrets, including the bot PAT when needed.', es: 'Permite a setup validar e instalar Secrets de GitHub Actions, incluido el PAT del bot cuando haga falta.' }, +}; +function setupQuestionPurpose(question) { + const exact = exports.setupQuestionPurposes[question.id]; + if (exact) + return exact; + if (question.id.startsWith('features.')) + return { + en: `Enable or disable ${question.label.toLowerCase()}. Disabling it removes its automation and conditional permission needs from this setup.`, + es: 'Activa o desactiva esta función. Si la desactivas, setup no instalará su automatización ni solicitará sus permisos condicionales.', + }; + if (/^agents\.[^.]+\.provider$/u.test(question.id)) + return { + en: 'Choose the agent CLI for this task in GitHub Actions; the provider determines the runner command and credentials.', + es: 'Elige el agente CLI de esta tarea en GitHub Actions; determina el comando y las credenciales del runner.', + }; + const agentSetting = question.id.match(/^agents\.[^.]+\.(modelProvider|model|effort|executable)$/u)?.[1]; + if (agentSetting) { + const shared = question.stateId === 'agent-model-defaults'; + const scope = shared ? { en: 'enabled agent tasks without a per-role override', es: 'las tareas activas del agente sin una excepción propia' } + : { en: 'this agent task', es: 'esta tarea del agente' }; + const setting = { + modelProvider: { en: 'the service that supplies the model and its credentials', es: 'el servicio que proporciona el modelo y sus credenciales' }, + model: { en: 'the exact model name allowed by the selected provider', es: 'el nombre exacto del modelo permitido por el proveedor elegido' }, + effort: { en: 'the reasoning-effort level, or leave empty for the provider default', es: 'el nivel de razonamiento, o vacío para usar el valor del proveedor' }, + executable: { en: 'the executable available on the GitHub Actions runner, not this computer', es: 'el ejecutable disponible en el runner de GitHub Actions, no en este ordenador' }, + }; + return { + en: `Set ${setting[agentSetting].en} for ${scope.en}.`, + es: `Define ${setting[agentSetting].es} para ${scope.es}.`, + }; + } + if (/^repository\.(feature|bugfix|hotfix|release|docs|chore|reconciliation)Tree$/u.test(question.id)) + return { + en: 'Set the prefix of branches Copilot creates for this work type; it must match your naming policy.', + es: 'Define el prefijo de las ramas que Copilot crea para este tipo de trabajo; debe seguir tus reglas de nombres.', + }; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) + return { + en: 'Choose the existing Status field option applied when this issue or pull request is created or enters progress; it is not a board-view column name.', + es: 'Elige la opción existente del campo Status al crear este issue o pull request o pasarlo a «en curso»; no es el nombre de una columna visual.', + }; + const storageSetting = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storageSetting) { + const resource = storageSetting[1] === 'variables' ? 'Variables' : 'Secrets'; + const setting = { + defaultScope: { en: `Choose whether new ${resource} live in the repository or organization; organization storage can need extra PAT grants.`, es: `Elige si los ${resource} nuevos se guardan en el repositorio o la organización; este último ámbito puede exigir más permisos del PAT.` }, + organizationVisibility: { en: `Choose which repositories can use organization ${resource}; selected is the narrowest visibility.`, es: `Elige qué repositorios pueden usar los ${resource} de la organización; «selected» es la visibilidad más restringida.` }, + preserveExisting: { en: `Keep effective existing ${resource} instead of overwriting them during setup.`, es: `Conserva los ${resource} existentes que ya se aplican, en lugar de sobrescribirlos durante setup.` }, + overrides: { en: `Select inherited organization ${resource} that should instead be set at repository scope.`, es: `Selecciona los ${resource} heredados de la organización que quieras definir en el repositorio.` }, + }; + return setting[storageSetting[2]]; + } + return undefined; +} + + +/***/ }), + +/***/ 3927: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.spanishQuestionLabels = void 0; +exports.spanishQuestionLabel = spanishQuestionLabel; +exports.spanishQuestionLabels = { + 'features.issues': 'Automatizar issues: ramas, etiquetas, proyectos y ciclo de vida', + 'features.pullRequests': 'Automatizar pull requests: revisión, descripción y ciclo de vida', + 'features.commits': 'Automatizar commits: progreso, tamaño y análisis de Bugbot', + 'features.issueComments': 'Responder a comentarios de issues y permitir correcciones de Bugbot', + 'features.pullRequestComments': 'Responder a comentarios de pull requests y permitir correcciones de Bugbot', + 'features.agentProvisioning': 'Comprobar la instalación de agentes CLI en GitHub Actions', + 'features.credentialHealth': 'Comprobar el estado de las credenciales remotas', + 'features.inactiveIssueClosure': 'Cerrar issues sin actividad tras el plazo configurado', + 'features.issueTemplates': 'Instalar plantillas de issues', + 'features.pullRequestTemplate': 'Instalar plantilla de pull request', + 'issueWorkflows.enabled': 'Tipos de flujo de issues que quieres activar', + 'repositoryAgentGuidance.enabled': '¿Generar instrucciones para agentes en el repositorio?', + 'repositoryAgentGuidance.agentsPointer': 'Cómo descubrir las instrucciones desde AGENTS.md', + 'agents.findings.modelProvider': 'Proveedor de modelo compartido (salvo configuración propia de una tarea)', + 'agents.findings.model': 'Modelo compartido (salvo configuración propia de una tarea)', + 'agents.findings.effort': 'Esfuerzo de razonamiento compartido (los ajustes por tarea se conservan)', + 'agents.findings.executable': 'Ejecutable compartido validado (los ajustes por tarea se conservan)', + 'agents.configureIndependently': '¿Configurar modelo y comando por tarea?', + 'repository.mainBranch': 'Rama de producción', + 'repository.developmentBranch': 'Rama de desarrollo', + 'repository.featureTree': 'Prefijo de ramas de funcionalidad', + 'repository.bugfixTree': 'Prefijo de ramas de corrección', + 'repository.hotfixTree': 'Prefijo de ramas de hotfix', + 'repository.releaseTree': 'Prefijo de ramas de release', + 'repository.docsTree': 'Prefijo de ramas de documentación', + 'repository.choreTree': 'Prefijo de ramas de mantenimiento', + 'repository.issueManagedBranches': '¿Puede la Action crear ramas vinculadas a issues?', + 'repository.preBranchSdd': '¿Exigir un SDD antes de crear ciertas ramas?', + 'repository.reopenIssueOnPush': '¿Reabrir issues cerrados al actualizar su rama?', + 'repository.desiredAssigneesCount': 'Número deseado de personas asignadas a issues', + 'repository.desiredReviewersCount': 'Número deseado de revisores de pull requests', + 'repository.inactivityThresholdHours': 'Horas sin actividad antes de cerrar un issue en espera', + 'repository.repositoryLocale': 'Idioma de los mensajes del repositorio', + 'repository.issueLocale': 'Idioma de los issues (vacío: heredar)', + 'repository.pullRequestLocale': 'Idioma de los pull requests (vacío: heredar)', + 'repository.commitPrefixTransforms': 'Transformación de prefijos de commits', + 'repository.releaseReconciliationStrategy': 'Estrategia para reconciliar releases', + 'repository.hotfixReconciliationStrategy': 'Estrategia para reconciliar hotfixes', + 'repository.reconciliationPullRequestMode': 'Modo de pull requests de reconciliación', + 'repository.reconciliationBackmergeMode': 'Modo de integración de vuelta', + 'repository.hotfixActiveReleasePolicy': 'Destino del hotfix durante una release activa', + 'repository.reconciliationTree': 'Prefijo de ramas de reconciliación', + 'repository.reconciliationCleanup': 'Limpieza de ramas tras reconciliar', + 'repository.reconciliationIssueCompletion': 'Qué hacer con el issue al terminar', + 'repository.orchestrationPresentationMode': 'Nivel de detalle del centro de control de releases', + 'repository.orchestrationDiagrams': '¿Mostrar diagramas accesibles de releases?', + 'repository.orchestrationCommentMode': 'Cómo publicar comentarios del ciclo de release', + 'ai.pullRequestDescriptionMode': 'Cómo actualizar la descripción de los pull requests', + 'ai.ignoreFiles': 'Archivos que la IA debe ignorar', + 'ai.membersOnly': '¿Limitar el procesamiento de IA a miembros del repositorio?', + 'ai.includeReasoning': '¿Incluir el razonamiento adicional del proveedor?', + 'ai.bugbotSeverity': 'Gravedad mínima para publicar hallazgos de Bugbot', + 'ai.bugbotCommentLimit': 'Máximo de comentarios de Bugbot por ejecución', + 'ai.bugbotFixVerifyCommands': 'Comandos para verificar correcciones de Bugbot', + 'ai.bugbotDryRun': '¿Analizar sin publicar cambios de Bugbot?', + 'ai.bugbotEffort': 'Profundidad del análisis de Bugbot', + 'ai.bugbotReviewDrafts': '¿Revisar pull requests en borrador?', + 'ai.bugbotTraceRules': '¿Indicar qué fuentes de reglas se aplicaron?', + 'ai.bugbotSuggestedChanges': '¿Publicar sugerencias de cambio seguras?', + 'ai.bugbotTelemetry': '¿Registrar métricas de Bugbot sin contenido?', + 'ai.bugbotFailOnUnresolved': '¿Bloquear el check si quedan hallazgos sin resolver?', + 'ai.bugbotOrganizationRules': 'Reglas generales de Bugbot, una por línea', + 'ai.provisioningMode': 'Cómo preparar el agente CLI en el runner', + 'pullRequestApproval.mode': '¿Qué puede hacer el bot con las aprobaciones de PR?', + 'pullRequestApproval.testChecks': '¿Qué checks de CI son fiables para aprobar PRs?', + 'pullRequestApproval.producerAttested': '¿Has comprobado el job, la App y el paso obligatorio de cobertura?', + 'pullRequestApproval.coverage.mode': 'Cómo demostrar que se cumple la cobertura', + 'pullRequestApproval.coverage.checkName': 'Check fiable que exige la cobertura', + 'pullRequestApproval.coverage.minDiffPercent': 'Cobertura mínima de líneas modificadas (0–100)', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Workflow que publica copilot-diff-coverage-v1', + 'pullRequestApproval.coverage.reporterAttested': '¿Has comprobado que el reporter numérico está instalado?', + 'projects.enabled': '¿Quieres integrar Projects de GitHub?', + 'projects.ids': 'Selecciona Projects existentes o indica los números de sus URL', + 'projects.statusVerified': '¿Has comprobado en GitHub los cuatro valores Status exactos de cada Project elegido?', + 'projects.issueCreatedColumn': 'Estado Status de nuevos issues', + 'projects.pullRequestCreatedColumn': 'Estado Status de nuevos pull requests', + 'projects.issueInProgressColumn': 'Estado Status de issues en curso', + 'projects.pullRequestInProgressColumn': 'Estado Status de pull requests en curso', + createInitialTag: '¿Crear v1.0.0 si todavía no existe ningún tag?', + manageRepositoryVariables: '¿Crear o actualizar Variables de GitHub Actions?', + manageRepositorySecrets: '¿Validar y configurar Secrets de GitHub Actions?', +}; +const roleNames = { + planner: 'Planificación', findings: 'Hallazgos', reviewer: 'Revisión', fixer: 'Corrección', tester: 'Pruebas', +}; +function spanishQuestionLabel(question) { + if (exports.spanishQuestionLabels[question.id]) + return exports.spanishQuestionLabels[question.id]; + const agent = question.id.match(/^agents\.(planner|findings|reviewer|fixer|tester)\.(provider|modelProvider|model|effort|executable)$/u); + if (agent) { + const field = { provider: 'agente CLI', modelProvider: 'proveedor del modelo', model: 'modelo', effort: 'esfuerzo', executable: 'comando ejecutable' }; + return `${roleNames[agent[1]]}: ${field[agent[2]]}`; + } + const storage = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storage) { + const resource = storage[1] === 'variables' ? 'Variables' : 'Secrets'; + const field = { defaultScope: 'ámbito predeterminado', organizationVisibility: 'visibilidad en la organización', preserveExisting: 'conservar los existentes', overrides: 'excepciones de ámbito' }; + return `${resource}: ${field[storage[2]]}`; + } + return question.label; +} + + /***/ }), /***/ 6009: @@ -47712,20 +49145,51 @@ function effectiveIssueFormLabels(configuration) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.createSetupQuestionnaire = createSetupQuestionnaire; +exports.createSetupPermissionIntentQuestionnaire = createSetupPermissionIntentQuestionnaire; exports.createSetupReviewState = createSetupReviewState; +exports.refreshSetupQuestionnaireQuestion = refreshSetupQuestionnaireQuestion; +exports.setupQuestionnaireProgress = setupQuestionnaireProgress; +exports.reopenSetupQuestionnaireGroup = reopenSetupQuestionnaireGroup; +exports.setupQuestionIdsForGroup = setupQuestionIdsForGroup; +exports.setupBasicSkippedQuestionIds = setupBasicSkippedQuestionIds; +exports.setupEditableGroups = setupEditableGroups; exports.transitionSetupQuestionnaire = transitionSetupQuestionnaire; exports.enterSetupConfirmation = enterSetupConfirmation; exports.finishSetupQuestionnaire = finishSetupQuestionnaire; exports.setupQuestionnaireStateLabel = setupQuestionnaireStateLabel; +exports.setupQuestionContentInventory = setupQuestionContentInventory; const setup_configuration_clone_policy_1 = __nccwpck_require__(85881); const setup_configuration_defaults_1 = __nccwpck_require__(23381); const issue_workflow_profile_1 = __nccwpck_require__(26744); +const setup_project_selection_policy_1 = __nccwpck_require__(73750); const AGENT_PROVIDERS = ['codex', 'opencode', 'cursor']; const MODEL_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter', 'opencode', 'local']; +const PERMISSION_INTENT_QUESTION_IDS = new Set([ + 'features.issues', 'features.pullRequests', 'issueWorkflows.enabled', + 'pullRequestApproval.mode', 'projects.enabled', 'createInitialTag', + 'manageRepositoryVariables', 'manageRepositorySecrets', + 'storage.variables.defaultScope', 'storage.variables.preserveExisting', + 'storage.secrets.defaultScope', 'storage.secrets.preserveExisting', +]); function createSetupQuestionnaire(configuration, context = {}) { const draft = (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration); - const question = questions(draft, false, context)[0]; - return { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false }; + const independently = hasIndependentAgentSettings(draft); + const question = questions(draft, independently, context, 'full')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: independently, phase: 'full' } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: independently, phase: 'full' }; +} +function hasIndependentAgentSettings(draft) { + const shared = draft.agents.findings; + return setup_configuration_defaults_1.SETUP_AGENT_TASKS.filter(task => task !== 'findings').some(task => ['modelProvider', 'model', 'effort', 'executable'].some(field => draft.agents[task][field] !== shared[field])); +} +function createSetupPermissionIntentQuestionnaire(configuration, context = {}) { + const draft = (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration); + const projectsWanted = context.projectsWanted ?? Boolean(draft.projects.ids.trim()); + const question = questions(draft, false, context, 'permission-intent')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [], projectsWanted } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [], projectsWanted }; } function createSetupReviewState(configuration) { return { @@ -47735,6 +49199,68 @@ function createSetupReviewState(configuration) { configureIndependently: false, }; } +/** Re-project the current question after a read-only discovery without replaying answers. */ +function refreshSetupQuestionnaireQuestion(state, context) { + if (state.terminal !== 'collecting' || !state.question) + return state; + const question = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full') + .find(candidate => candidate.id === state.question?.id); + return question ? { ...state, question, validation: undefined } : state; +} +/** The denominator follows the currently applicable, unskipped questions. */ +function setupQuestionnaireProgress(state, context) { + if (state.terminal !== 'collecting' || !state.question) + return undefined; + const visible = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full'); + const index = visible.findIndex(item => item.id === state.question?.id); + if (index < 0) + return undefined; + const group = state.question.stateId; + const groupQuestions = visible.filter(item => item.stateId === group); + return { position: index + 1, total: visible.length, groupPosition: groupQuestions.findIndex(item => item.id === state.question?.id) + 1, + groupTotal: groupQuestions.length, group }; +} +/** Reopen an already answered group for final-plan correction without clearing unrelated values. */ +function reopenSetupQuestionnaireGroup(state, group, context) { + if (state.terminal !== 'review') + return undefined; + const first = questions(state.draft, state.configureIndependently, context, 'full').find(item => item.stateId === group); + return first ? { ...state, stateId: first.stateId, terminal: 'collecting', question: first, validation: undefined, + phase: 'full', answeredQuestionIds: [] } : undefined; +} +function setupQuestionIdsForGroup(group) { + return definitions().filter(item => item.stateId === group).map(item => item.id); +} +/** Basic changes presentation only: security- and permission-driving decisions stay visible. */ +function setupBasicSkippedQuestionIds(configuration) { + const defaults = configuration ? (0, setup_configuration_defaults_1.createDefaultSetupConfiguration)() : undefined; + const advancedRepository = new Set([ + 'featureTree', 'bugfixTree', 'hotfixTree', 'releaseTree', 'docsTree', 'choreTree', + 'reconciliationTree', 'reopenIssueOnPush', 'inactivityThresholdHours', + 'issueLocale', 'pullRequestLocale', 'commitPrefixTransforms', + ]); + const advancedBugbot = new Set([ + 'pullRequestDescriptionMode', 'ignoreFiles', 'includeReasoning', 'bugbotCommentLimit', + 'bugbotFixVerifyCommands', 'bugbotEffort', 'bugbotReviewDrafts', 'bugbotTraceRules', + 'bugbotSuggestedChanges', 'bugbotOrganizationRules', + ]); + return definitions().filter(definition => definition.id === 'agents.findings.effort' + || definition.id === 'agents.findings.executable' + || (definition.id.startsWith('agents.') && definition.id.endsWith('.provider') && definition.id !== 'agents.findings.provider') + || (definition.id.startsWith('repository.') && advancedRepository.has(definition.id.slice('repository.'.length))) + || (definition.id.startsWith('ai.') && advancedBugbot.has(definition.id.slice('ai.'.length)))).filter(definition => !configuration || JSON.stringify(valueAtPath(configuration, definition.id)) + === JSON.stringify(valueAtPath(defaults, definition.id))).map(definition => definition.id); +} +function valueAtPath(value, path) { + return path.split('.').reduce((current, key) => current && typeof current === 'object' + ? current[key] : undefined, value); +} +function setupEditableGroups(configuration) { + // Projects can be enabled at review even if the operator declined it before + // the PAT handoff. The re-run audits any newly required grant before Apply. + const visible = questions(configuration, hasIndependentAgentSettings(configuration), { projectsWanted: true }, 'full'); + return [...new Set(visible.map(item => item.stateId))]; +} function transitionSetupQuestionnaire(state, event, context = {}) { if (state.terminal !== 'collecting' || !state.question) return state; @@ -47744,8 +49270,28 @@ function transitionSetupQuestionnaire(state, event, context = {}) { draft: (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(state.draft), terminal: 'cancelled', configureIndependently: state.configureIndependently, + phase: state.phase, + answeredQuestionIds: state.answeredQuestionIds, + projectsWanted: state.projectsWanted, }; } + if (event.kind === 'back') { + const visible = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full'); + const index = visible.findIndex(item => item.id === state.question?.id); + if (index <= 0) + return { ...state, validation: 'This is the first question in this pass. Review it or cancel setup.' }; + const previous = visible[index - 1]; + return { ...state, stateId: previous.stateId, question: previous, validation: undefined, + answeredQuestionIds: state.answeredQuestionIds?.filter(id => visible.findIndex(item => item.id === id) < index - 1) }; + } + if (state.question.id === 'projects.statusVerified' && ['n', 'no', 'false', '0'].includes(event.value.normalize('NFKC').trim().toLowerCase())) { + const selection = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full') + .find(question => question.id === 'projects.ids'); + if (selection) + return { ...state, question: selection, stateId: 'projects', + validation: 'Status values were not confirmed. Choose compatible Projects, then review their Status options again.', + answeredQuestionIds: state.answeredQuestionIds?.filter(id => id !== 'projects.ids' && !id.startsWith('projects.')) }; + } const parsed = parseAnswer(state.question, event.value); if ('error' in parsed) { return { @@ -47754,13 +49300,32 @@ function transitionSetupQuestionnaire(state, event, context = {}) { validation: parsed.error, }; } + if (state.question.id === 'features.issues' && parsed.value === false + && (context.fixedWorkflowFeatures?.release === true || context.fixedWorkflowFeatures?.hotfix === true)) { + return { ...state, validation: 'Issue automation is required by an explicit release or hotfix override. Keep Issues enabled or edit your configuration.' }; + } + if (state.question.id === 'issueWorkflows.enabled') { + const selected = new Set(String(parsed.value).split(',')); + for (const kind of ['release', 'hotfix']) { + const fixed = context.fixedWorkflowFeatures?.[kind]; + if (fixed !== undefined && selected.has(kind) !== fixed) { + return { ...state, validation: `The ${kind} workflow must ${fixed ? 'remain enabled' : 'remain disabled'} because it is fixed by your configuration. Match that choice or edit your configuration.` }; + } + } + } const configureIndependently = state.question.id === 'agents.configureIndependently' ? Boolean(parsed.value) : state.configureIndependently; - const draft = applyAnswer(state.draft, state.question, parsed.value); - const nextQuestions = questions(draft, configureIndependently, context); - const nextIndex = nextQuestions.findIndex((question) => question.id === state.question?.id); - const next = nextQuestions[nextIndex + 1]; + const draft = applyAnswer(state.draft, state.question, parsed.value, state.configureIndependently); + const projectsWanted = state.question.id === 'projects.enabled' ? Boolean(parsed.value) : state.projectsWanted; + const answeredQuestionIds = [...(state.answeredQuestionIds ?? []), state.question.id]; + const nextQuestions = questions(draft, configureIndependently, context, state.phase ?? 'full'); + // A just-answered question may become inapplicable (for example, clearing + // Projects removes its dependent fields). Advance by canonical definition + // order; indexing the new visible list at -1 would restart the wizard. + const definitionOrder = definitions().map(definition => definition.id); + const currentOrder = definitionOrder.indexOf(state.question.id); + const next = nextQuestions.find(question => definitionOrder.indexOf(question.id) > currentOrder); return next ? { stateId: next.stateId, @@ -47768,8 +49333,11 @@ function transitionSetupQuestionnaire(state, event, context = {}) { question: next, terminal: 'collecting', configureIndependently, + phase: state.phase, + answeredQuestionIds, + projectsWanted, } - : { stateId: 'review', draft, terminal: 'review', configureIndependently }; + : { stateId: 'review', draft, terminal: 'review', configureIndependently, phase: state.phase, answeredQuestionIds, projectsWanted }; } function enterSetupConfirmation(state) { if (state.terminal !== 'review') @@ -47805,8 +49373,10 @@ function setupQuestionnaireStateLabel(stateId) { cancelled: 'Cancelled', })[stateId]; } -function questions(draft, independently, context) { - return definitions().filter((definition) => definition.applies?.(draft, independently, context) ?? true) +function questions(draft, independently, context, phase) { + return definitions().filter((definition) => (phase === 'full' ? definition.id !== 'projects.enabled' : PERMISSION_INTENT_QUESTION_IDS.has(definition.id)) + && !context.skipQuestionIds?.includes(definition.id) + && (definition.applies?.(draft, independently, context) ?? true)) .map((definition) => toQuestion(definition, draft, context)); } function definitions() { @@ -47842,20 +49412,30 @@ function definitions() { ...setup_configuration_defaults_1.SETUP_AGENT_TASKS.map((task) => ({ stateId: 'agent-runtime', id: `agents.${task}.provider`, label: `${formatTask(task)} runtime`, kind: 'choice', choices: AGENT_PROVIDERS, })), - { stateId: 'agent-model-defaults', id: 'agents.findings.modelProvider', label: 'Model provider for all tasks', kind: 'choice', choices: MODEL_PROVIDERS }, - { stateId: 'agent-model-defaults', id: 'agents.findings.model', label: 'Model name for all tasks', kind: 'text' }, - { stateId: 'agent-model-defaults', id: 'agents.findings.effort', label: 'Reasoning effort for all tasks (empty uses provider default)', kind: 'text' }, - { stateId: 'agent-model-defaults', id: 'agents.findings.executable', label: 'Validated executable for all tasks (empty uses the manifest basename)', kind: 'text' }, - { stateId: 'agent-model-defaults', id: 'agents.configureIndependently', label: 'Configure model provider, model, effort, and executable independently for every task?', kind: 'boolean', read: () => false }, + { stateId: 'agent-model-defaults', id: 'agents.findings.modelProvider', label: 'Shared model provider (unless a role has its own setting)', kind: 'choice', choices: MODEL_PROVIDERS }, + { stateId: 'agent-model-defaults', id: 'agents.findings.model', label: 'Shared model name (unless a role has its own setting)', kind: 'text' }, + { stateId: 'agent-model-defaults', id: 'agents.findings.effort', label: 'Shared reasoning effort (empty uses provider default; per-role overrides stay separate)', kind: 'text' }, + { stateId: 'agent-model-defaults', id: 'agents.findings.executable', label: 'Shared validated executable (empty uses manifest basename; per-role overrides stay separate)', kind: 'text' }, + { stateId: 'agent-model-defaults', id: 'agents.configureIndependently', label: 'Configure model provider, model, effort, and executable independently for every task?', kind: 'boolean', read: draft => hasIndependentAgentSettings(draft) }, ...setup_configuration_defaults_1.SETUP_AGENT_TASKS.filter((task) => task !== 'findings').flatMap((task) => agentOverrideQuestions(task)), ...repositoryQuestions(), ...deploymentQuestions(), ...bugbotQuestions(), ...approvalQuestions(), - { stateId: 'projects', id: 'projects.ids', label: 'GitHub Project IDs (comma-separated, empty skips integration)', kind: 'text' }, + { stateId: 'projects', id: 'projects.enabled', label: 'Integrate existing GitHub Projects with issue and pull-request automation?', kind: 'boolean', + read: (draft, context) => context.projectsWanted ?? Boolean(draft.projects.ids.trim()), + applies: draft => draft.features.issues !== false || draft.features.pullRequests !== false }, + { stateId: 'projects', id: 'projects.ids', label: 'Select existing GitHub Projects (or enter Project numbers from their URLs)', kind: 'text', + applies: (draft, _independent, context) => (draft.features.issues !== false || draft.features.pullRequests !== false) && context.projectsWanted !== false }, ...['issueCreatedColumn', 'pullRequestCreatedColumn', 'issueInProgressColumn', 'pullRequestInProgressColumn'].map((field) => ({ stateId: 'projects', id: `projects.${field}`, label: projectLabel(field), kind: 'text', applies: (config) => Boolean(config.projects.ids.trim()), })), + { stateId: 'projects', id: 'projects.statusVerified', + label: 'Have you checked every selected Project in GitHub and confirmed all four exact Status values?', + kind: 'boolean', read: () => false, + applies: (draft, _independently, context) => Boolean(draft.projects.ids.trim()) + && (0, setup_project_selection_policy_1.sharedProjectStatusOptions)(draft.projects.ids, context.projectDiscovery?.candidates ?? []).state === 'unavailable', + }, { stateId: 'provisioning', id: 'createInitialTag', label: 'Create v1.0.0 when no version tag exists?', kind: 'boolean' }, { stateId: 'provisioning', id: 'manageRepositoryVariables', label: 'Create/update GitHub Actions Variables?', kind: 'boolean' }, { stateId: 'provisioning', id: 'manageRepositorySecrets', label: 'Validate and provision required GitHub Actions Secrets?', kind: 'boolean' }, @@ -47863,6 +49443,12 @@ function definitions() { ...storageQuestions('secrets'), ]; } +/** Stable content inventory for documentation and localization audits; never answers questions. */ +function setupQuestionContentInventory() { + return definitions().map(({ stateId, id, label, kind, choices }) => ({ + stateId, id, label, kind, choices, defaultValue: '', + })); +} function agentOverrideQuestions(task) { const applies = (_draft, independently) => independently; return [ @@ -47944,12 +49530,6 @@ function approvalQuestions() { read: draft => draft.pullRequestApproval.testChecks.map(check => `${check.name}|${check.sourceAppId}|${check.workflowName}`).join(';'), applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', }, - { - stateId: 'pull-request-approval', id: 'pullRequestApproval.producerAttested', - label: 'Have you verified each exact check, source App ID, workflow, and coverage-enforcing CI step?', - kind: 'boolean', - applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', - }, { stateId: 'pull-request-approval', id: 'pullRequestApproval.coverage.mode', label: 'Coverage evidence mode', kind: 'choice', choices: ['check', 'numeric'], @@ -47958,7 +49538,7 @@ function approvalQuestions() { { stateId: 'pull-request-approval', id: 'pullRequestApproval.coverage.checkName', label: 'Exact trusted check that enforces the coverage budget (no inferred percentage)', - kind: 'text', + kind: 'choice', applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', }, { @@ -47985,6 +49565,12 @@ function approvalQuestions() { applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off' && draft.pullRequestApproval.coverage.mode === 'numeric', }, + { + stateId: 'pull-request-approval', id: 'pullRequestApproval.producerAttested', + label: 'Have you verified each exact check, source App ID, workflow, and coverage-enforcing CI step?', + kind: 'boolean', + applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', + }, ]; } function storageQuestions(kind) { @@ -48012,17 +49598,56 @@ function choice(field, label, choices) { return { stateId: 'deployment', id: `repository.${field}`, label, kind: 'choice', choices }; } function toQuestion(definition, draft, context) { + const branchScopedCandidates = context.approvalCheckCandidates?.map(candidate => candidate.requiredByRuleset?.branch !== draft.repository.developmentBranch + ? { ...candidate, requiredByRuleset: undefined } : candidate); + const producerCandidates = definition.id === 'pullRequestApproval.testChecks' ? branchScopedCandidates + : definition.id === 'pullRequestApproval.coverage.checkName' ? branchScopedCandidates?.filter(candidate => draft.pullRequestApproval.testChecks.some(check => check.name === candidate.name + && check.sourceAppId === candidate.sourceAppId && check.workflowName === candidate.workflowName)) : undefined; + const coverageChoices = definition.id === 'pullRequestApproval.coverage.checkName' + ? [...new Set(draft.pullRequestApproval.testChecks.map(check => check.name))] : undefined; const allowedNames = definition.kind === 'scope-overrides' ? inheritedNames(definition.id.includes('.variables.') ? 'variables' : 'secrets', draft, context) : undefined; + const projectQuestion = definition.id === 'projects.ids'; + const statusQuestion = /^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(definition.id); + const projectCandidates = context.projectDiscovery?.candidates ?? []; + const projectStatus = statusQuestion + ? (0, setup_project_selection_policy_1.sharedProjectStatusOptions)(draft.projects.ids, projectCandidates) : undefined; return { stateId: definition.stateId, id: definition.id, label: definition.label, - kind: definition.kind, - defaultValue: definition.read?.(draft) ?? readPath(draft, definition.id, allowedNames), - ...(definition.choices ? { choices: definition.choices } : {}), + kind: definition.id === 'pullRequestApproval.testChecks' ? 'producer-select' + : projectQuestion ? 'project-select' + : statusQuestion && projectStatus?.state === 'observed' ? 'choice' : definition.kind, + defaultValue: definition.read?.(draft, context) ?? readPath(draft, definition.id, allowedNames), + ...(coverageChoices ? { choices: coverageChoices } : projectStatus?.state === 'observed' + ? { choices: projectStatus.options } : definition.choices ? { choices: definition.choices } : {}), ...(allowedNames ? { allowedNames } : {}), + ...(producerCandidates?.length ? { producerCandidates } : {}), + ...(definition.id === 'pullRequestApproval.coverage.checkName' + ? { trustedProducers: draft.pullRequestApproval.testChecks } : {}), + ...(definition.id === 'pullRequestApproval.testChecks' && context.approvalCheckDiscoveryStatus + ? { discoveryStatus: context.approvalCheckDiscoveryStatus, discoveryTruncated: context.approvalCheckDiscoveryTruncated, + discoveryRetryRemaining: context.discoveryRetryRemaining?.checks ?? 0 } : {}), + ...(projectQuestion ? { discoveryStatus: context.projectDiscovery?.status ?? 'unavailable', + discoveryTruncated: context.projectDiscovery?.truncated, + ...(context.projectDiscovery && context.projectDiscovery.status !== 'unsupported' && context.discoveryRetryRemaining + ? { discoveryRetryRemaining: context.discoveryRetryRemaining.projects } : {}), + projectCandidates, projectOwner: context.projectOwner } : {}), + ...(statusQuestion && projectStatus ? { statusOptionState: projectStatus.state } : {}), + ...(definition.id === 'projects.statusVerified' ? { projectStatusValues: [ + { transition: 'issueCreated', value: draft.projects.issueCreatedColumn }, + { transition: 'pullRequestCreated', value: draft.projects.pullRequestCreatedColumn }, + { transition: 'issueInProgress', value: draft.projects.issueInProgressColumn }, + { transition: 'pullRequestInProgress', value: draft.projects.pullRequestInProgressColumn }, + ] } : {}), + ...(definition.id === 'repository.mainBranch' && context.branchSources + ? { suggestionSource: context.branchSources.main } : {}), + ...(definition.id === 'repository.developmentBranch' && context.branchSources + ? { suggestionSource: context.branchSources.development } : {}), + ...((definition.id === 'issueWorkflows.enabled' || definition.id === 'features.issues') && context.fixedWorkflowFeatures + ? { fixedWorkflowFeatures: context.fixedWorkflowFeatures } : {}), }; } function readPath(configuration, path, allowedNames) { @@ -48035,6 +49660,39 @@ function readPath(configuration, path, allowedNames) { } function parseAnswer(question, raw) { const input = raw.normalize('NFKC').trim(); + if (question.id === 'projects.statusVerified') + return ['y', 'yes', 'true', '1'].includes(input.toLowerCase()) + ? { value: true } : { error: 'Open every selected Project in GitHub and confirm that all four exact Status values exist. Answer Yes after checking, or No to choose Projects again.' }; + if (question.id === 'projects.ids') { + const parsed = (0, setup_project_selection_policy_1.parseSetupProjectSelection)(input || String(question.defaultValue), question.projectOwner); + if ('error' in parsed) + return parsed; + const status = (0, setup_project_selection_policy_1.sharedProjectStatusOptions)(parsed.value, question.projectCandidates ?? []); + if (status.state === 'incompatible') + return { error: 'Selected Projects have no common Status option. Choose compatible Projects or configure them separately.' }; + return parsed; + } + if (question.id === 'pullRequestApproval.testChecks') { + const entries = (input || String(question.defaultValue)).split(';').map(item => item.trim()).filter(Boolean) + .flatMap(item => item.split(',').map(value => value.trim()).filter(Boolean)) + .map(item => { + const index = Number(item) - 1; + const candidate = Number.isSafeInteger(index) && /^[1-9]\d*$/u.test(item) ? question.producerCandidates?.[index] : undefined; + return candidate ? `${candidate.name}|${candidate.sourceAppId}|${candidate.workflowName}` : item; + }); + if (entries.length < 1 || entries.length > 8 || entries.some(entry => !/^[^|;\r\n]{1,100}\|[1-9][0-9]*\|[^|;\r\n]{1,100}$/u.test(entry))) { + return { error: 'Select 1–8 observed checks or enter exact name|App ID|workflow tuples.' }; + } + if (new Set(entries).size !== entries.length) + return { error: 'A trusted check was selected more than once.' }; + const names = entries.map(entry => entry.split('|', 1)[0]); + if (new Set(names).size !== names.length) + return { error: 'Two trusted producers use the same check name. Coverage stores only one name; choose one producer or rename the CI jobs before continuing.' }; + return { value: entries.join(';') }; + } + if (!input && question.statusOptionState === 'observed' && !question.choices?.includes(String(question.defaultValue))) { + return { error: 'The saved Status value is not available in every selected Project. Choose a listed Status option.' }; + } if (!input && question.kind !== 'scope-overrides') return { value: question.defaultValue }; if (question.kind === 'text') @@ -48075,10 +49733,31 @@ function parseAnswer(question, raw) { return { error: `Unknown inherited resource name(s): ${unknown.join(', ')}.` }; return { value: Object.fromEntries(requested.map((name) => [name, 'repository'])) }; } -function applyAnswer(configuration, question, value) { +function applyAnswer(configuration, question, value, independently) { const draft = (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration); - if (question.id === 'agents.configureIndependently') + if (question.id === 'agents.configureIndependently') { + if (!value) + for (const task of setup_configuration_defaults_1.SETUP_AGENT_TASKS.filter(task => task !== 'findings')) { + draft.agents[task] = { ...draft.agents[task], modelProvider: draft.agents.findings.modelProvider, + model: draft.agents.findings.model, effort: draft.agents.findings.effort, + executable: draft.agents.findings.executable }; + } + return draft; + } + if (question.id === 'projects.enabled') { + if (!value) + draft.projects.ids = ''; return draft; + } + if (question.id === 'projects.statusVerified') + return draft; + if (question.id === 'features.issues' && value === false) { + draft.features.issues = false; + draft.features.release = false; + draft.features.hotfix = false; + draft.issueWorkflows = (0, issue_workflow_profile_1.createIssueWorkflowProfile)([]); + return draft; + } if (question.id === 'features.pullRequests' && value === false) { draft.features.pullRequests = false; draft.pullRequestApproval = { ...draft.pullRequestApproval, mode: 'off' }; @@ -48119,8 +49798,9 @@ function applyAnswer(configuration, question, value) { } if (['agents.findings.modelProvider', 'agents.findings.model', 'agents.findings.effort', 'agents.findings.executable'].includes(question.id)) { const field = question.id.split('.')[2]; - for (const task of setup_configuration_defaults_1.SETUP_AGENT_TASKS) + for (const task of independently ? ['findings'] : setup_configuration_defaults_1.SETUP_AGENT_TASKS) { draft.agents[task] = { ...draft.agents[task], [field]: value }; + } return draft; } const parts = question.id.split('.'); @@ -48134,6 +49814,8 @@ function applyAnswer(configuration, question, value) { } function parseWorkflowSelection(raw) { const normalized = raw.trim().toLowerCase(); + if (normalized === 'none') + return { value: [] }; if (!normalized || normalized === 'all') return { value: [...issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS] }; const requested = normalized.split(',').map(item => item.trim()).filter(Boolean) @@ -48183,6 +49865,53 @@ function projectLabel(field) { } +/***/ }), + +/***/ 92567: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.sameSetupRemoteFacts = sameSetupRemoteFacts; +/** Compare the semantic GitHub facts used by setup, not object/response ordering. */ +function sameSetupRemoteFacts(left, right) { + const variables = (items) => items + .map(item => JSON.stringify([item.name, item.value])).sort(); + const normalize = (facts) => ({ + ownerType: facts.ownerType, + defaultBranch: facts.defaultBranch, + repositoryId: facts.repositoryId, + repositoryVisibility: facts.repositoryVisibility, + repositorySecrets: [...facts.repositorySecrets].sort(), + repositorySecretsAccess: facts.repositorySecretsAccess, + organizationSecrets: [...facts.organizationSecrets].sort(), + repositoryVariables: variables(facts.repositoryVariables), + repositoryVariablesAccess: facts.repositoryVariablesAccess, + organizationVariables: variables(facts.organizationVariables), + organizationAccess: facts.organizationAccess, + organizationSecretsAccess: facts.organizationSecretsAccess, + organizationVariablesAccess: facts.organizationVariablesAccess, + credentialHealthWorkflow: facts.credentialHealthWorkflow, + }); + return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)); +} + + +/***/ }), + +/***/ 64662: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.safeTerminalChoiceText = safeTerminalChoiceText; +function safeTerminalChoiceText(value) { + return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ''); +} + + /***/ }), /***/ 65640: @@ -48270,6 +49999,9 @@ function unverifiable(requirement, message) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildSetupPatPermissionRequirements = buildSetupPatPermissionRequirements; exports.buildConfiguredSetupPatPermissionRequirements = buildConfiguredSetupPatPermissionRequirements; +exports.buildSetupPatIntentPermissionRequirements = buildSetupPatIntentPermissionRequirements; +exports.buildSetupPatIntentUncertainty = buildSetupPatIntentUncertainty; +exports.requiredSetupPatPermissionDelta = requiredSetupPatPermissionDelta; exports.buildWorkflowPatPermissionRequirements = buildWorkflowPatPermissionRequirements; exports.normalizePermissionRequirements = normalizePermissionRequirements; const setup_configuration_plan_1 = __nccwpck_require__(87770); @@ -48286,20 +50018,24 @@ const requirement = (input) => ({ * interactive configuration does not exist before the setup PAT prompt. */ function buildSetupPatPermissionRequirements() { - return normalizePermissionRequirements([ + // Keep conditional read and write paths separate here: collapsing Actions + // into one write row would hide the approval-only read requirement. + return [ requirement({ role: 'setup', scope: 'repository', permission: 'Metadata', level: 'read', reason: 'Resolve repository identity and visibility.', probe: 'metadata' }), requirement({ role: 'setup', scope: 'repository', permission: 'Contents', level: 'read', reason: 'Inspect installed workflows and repository files.', probe: 'contents' }), requirement({ role: 'setup', scope: 'repository', permission: 'Secrets', level: 'write', applicability: 'conditional', condition: 'Secret provisioning enabled', reason: 'Inspect and provision selected GitHub Actions Secrets.', probe: 'secrets' }), requirement({ role: 'setup', scope: 'repository', permission: 'Variables', level: 'write', applicability: 'conditional', condition: 'Variable provisioning enabled', reason: 'Inspect and provision selected GitHub Actions Variables.', probe: 'variables' }), requirement({ role: 'setup', scope: 'repository', permission: 'Issues', level: 'write', applicability: 'conditional', condition: 'Issue workflows enabled', reason: 'Provision labels and issue resources.', probe: 'issues' }), requirement({ role: 'setup', scope: 'repository', permission: 'Actions', level: 'write', applicability: 'conditional', condition: 'Credential health enabled', reason: 'Inspect and dispatch credential-health workflows.', probe: 'actions' }), + requirement({ role: 'setup', scope: 'repository', permission: 'Actions', level: 'read', applicability: 'conditional', condition: 'Pull-request approval enabled', reason: 'Inspect CI workflow runs and jobs for exact producer identities.', probe: 'actions' }), + requirement({ role: 'setup', scope: 'repository', permission: 'Checks', level: 'read', applicability: 'conditional', condition: 'Pull-request approval enabled', reason: 'Discover exact CI check and producer identities.', probe: 'checks' }), requirement({ role: 'setup', scope: 'repository', permission: 'Administration', level: 'read', applicability: 'conditional', condition: 'Release, hotfix, or guarded approval enabled', reason: 'Inspect branch protection and rulesets.', probe: 'administration' }), requirement({ role: 'setup', scope: 'repository', permission: 'Workflows', level: 'write', applicability: 'conditional', condition: 'Temporary health workflow required', reason: 'Bootstrap a missing credential-health workflow.', probe: 'workflows' }), requirement({ role: 'setup', scope: 'organization', permission: 'Secrets', level: 'write', applicability: 'conditional', condition: 'Organization Secret storage selected', reason: 'Inspect and provision organization Actions Secrets.', probe: 'secrets' }), requirement({ role: 'setup', scope: 'organization', permission: 'Variables', level: 'write', applicability: 'conditional', condition: 'Organization Variable storage selected', reason: 'Inspect and provision organization Actions Variables.', probe: 'variables' }), requirement({ role: 'setup', scope: 'organization', permission: 'Issue Types', level: 'write', applicability: 'conditional', condition: 'Issue type automation enabled', reason: 'Provision and assign configured issue types.', probe: 'issue-types' }), - requirement({ role: 'setup', scope: 'organization', permission: 'Projects', level: 'write', applicability: 'conditional', condition: 'Organization Projects selected', reason: 'Inspect and configure selected Projects.', probe: 'projects' }), - ]); + requirement({ role: 'setup', scope: 'organization', permission: 'Projects', level: 'read', applicability: 'conditional', condition: 'Organization Projects selected', reason: 'Inspect selected Projects and their Status options; setup does not edit Project items.', probe: 'projects' }), + ]; } /** * Recomputes setup-PAT permissions after the operator has approved the final @@ -48307,6 +50043,39 @@ function buildSetupPatPermissionRequirements() { * selected setup operation or its read-only preflight. */ function buildConfiguredSetupPatPermissionRequirements(configuration, remote) { + // Unknown is not evidence of a personal owner: keep possible organization + // grants visible until the final audit can verify the actual owner type. + return buildSetupPatRequirements(configuration, remote?.ownerType === 'Organization' || remote?.ownerType === 'Unknown', remote); +} +/** Grants justified by local choices alone; remote-only conditions stay unresolved. */ +function buildSetupPatIntentPermissionRequirements(configuration, ownerKind, projectsWanted = configuration.projects.ids.trim().length > 0) { + return buildSetupPatRequirements(configuration, ownerKind === 'Organization', undefined, projectsWanted); +} +function buildSetupPatIntentUncertainty(configuration, ownerKind) { + const unknown = []; + if (configuration.manageRepositorySecrets) { + unknown.push('Existing managed Secrets may require repository Actions write for credential-health checks. A confirmed missing health workflow may also require repository Contents write and Workflows write.'); + } + if (ownerKind === 'Organization') { + for (const kind of ['secrets', 'variables']) { + const managed = kind === 'secrets' ? configuration.manageRepositorySecrets : configuration.manageRepositoryVariables; + if (managed && configuration.storage[kind].preserveExisting && configuration.storage[kind].defaultScope === 'repository') { + unknown.push(`Inherited organization ${kind} may require organization ${kind === 'secrets' ? 'Secrets' : 'Variables'} write after inventory inspection.`); + } + } + } + return unknown; +} +/** Required grants newly introduced (or upgraded) after the provisional review. */ +function requiredSetupPatPermissionDelta(before, after) { + const previous = new Map(before.filter(item => item.applicability === 'required') + .map(item => [`${item.scope}:${item.permission.toLowerCase()}`, item.level])); + return after.filter(item => item.applicability === 'required' + && (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === undefined + || (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === 'read' && item.level === 'write'))) + .map(item => `${item.scope} ${item.permission} ${item.level}`); +} +function buildSetupPatRequirements(configuration, organization, remote, projectsWanted = configuration.projects.ids.trim().length > 0) { const repositorySecretNames = (0, setup_credential_requirement_policy_1.buildSetupCredentialRequirements)(configuration) .map(credential => credential.name); const repositoryVariableNames = (0, setup_configuration_plan_1.buildSetupRepositoryVariables)(configuration) @@ -48323,11 +50092,11 @@ function buildConfiguredSetupPatPermissionRequirements(configuration, remote) { || configuration.features.hotfix || enabledIssueWorkflowKinds.some(kind => kind === 'release' || kind === 'hotfix'); const guardedApproval = configuration.pullRequestApproval.mode === 'guarded'; + const approvalEnabled = configuration.pullRequestApproval.mode !== 'off'; const hasExistingCredential = repositorySecretNames.some(name => remote?.repositorySecrets.includes(name) || remote?.organizationSecrets.includes(name)); const needsCredentialHealth = configuration.manageRepositorySecrets && hasExistingCredential; const needsCredentialHealthBootstrap = needsCredentialHealth && remote?.credentialHealthWorkflow === 'missing'; - const organization = remote?.ownerType === 'Organization'; return normalizePermissionRequirements([ requirement({ role: 'setup', scope: 'repository', permission: 'Metadata', level: 'read', reason: 'Resolve repository identity and visibility.', probe: 'metadata' }), requirement({ role: 'setup', scope: 'repository', permission: 'Contents', level: 'read', reason: 'Inspect installed workflows and repository files.', probe: 'contents' }), @@ -48351,6 +50120,10 @@ function buildConfiguredSetupPatPermissionRequirements(configuration, remote) { role: 'setup', scope: 'repository', permission: 'Actions', level: 'write', reason: 'Dispatch credential-health checks for existing Secrets.', probe: 'actions', })] : []), + ...(approvalEnabled ? [ + requirement({ role: 'setup', scope: 'repository', permission: 'Actions', level: 'read', reason: 'Inspect CI workflow runs and jobs for approval evidence.', probe: 'actions' }), + requirement({ role: 'setup', scope: 'repository', permission: 'Checks', level: 'read', reason: 'Discover exact CI check and producer identities.', probe: 'checks' }), + ] : []), ...(needsCredentialHealthBootstrap ? [ requirement({ role: 'setup', scope: 'repository', permission: 'Contents', level: 'write', reason: 'Temporarily install credential health when its workflow is not confirmed installed.', probe: 'contents' }), requirement({ role: 'setup', scope: 'repository', permission: 'Workflows', level: 'write', reason: 'Temporarily install credential health when its workflow is not confirmed installed.', probe: 'workflows' }), @@ -48371,9 +50144,9 @@ function buildConfiguredSetupPatPermissionRequirements(configuration, remote) { role: 'setup', scope: 'organization', permission: 'Issue Types', level: 'write', reason: 'Provision native issue types for the selected workflows.', probe: 'issue-types', })] : []), - ...(organization && configuration.projects.ids.trim().length > 0 ? [requirement({ - role: 'setup', scope: 'organization', permission: 'Projects', level: 'write', - reason: 'Inspect and configure the selected organization Projects.', probe: 'projects', + ...(organization && projectsWanted ? [requirement({ + role: 'setup', scope: 'organization', permission: 'Projects', level: 'read', + reason: 'Inspect selected Projects and their Status options; setup does not edit Project items.', probe: 'projects', })] : []), ]); } @@ -50441,20 +52214,46 @@ async function runInitialSetupWorkflow(request, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(TASK_ID)} Executing ${TASK_ID}.`); const steps = []; const errors = []; + const configuration = request.setupConfiguration; + const effects = [ + { id: 'files', state: 'not-started', scope: 'local' }, + { id: 'secrets', state: 'not-started', scope: resourceScope(configuration, 'secrets') }, + { id: 'labels', state: 'not-started', scope: 'repository' }, + { id: 'issue-types', state: 'not-started', scope: 'repository' }, + { id: 'variables', state: 'not-started', scope: resourceScope(configuration, 'variables') }, + { id: 'initial-tag', state: 'not-started', scope: 'repository' }, + ]; + const mark = (id, state) => { + const index = effects.findIndex(effect => effect.id === id); + effects[index] = { ...effects[index], state }; + }; + const receipt = () => buildResult(errors, steps, effects); try { const setupConfiguration = request.setupConfiguration; if (!dependencies.setupWorkspacePort.hasValidToken()) { (0, logging_ports_1.logInfo)(' 🛑 Setup requires the setup PAT provided for this command with a valid token.'); errors.push(new application_error_1.ApplicationError('authorization.credential-invalid', 'A valid setup PAT must be provided to run setup. It is separate from the workflow PAT Secret.')); - return [buildResult(errors, steps)]; + return [receipt()]; } (0, logging_ports_1.logInfo)('🔐 Checking GitHub access...'); const githubAccess = await verifyGitHubAccess(request, dependencies.authenticatedUserPort); if (!githubAccess.success) { errors.push(...githubAccess.errors); - return [buildResult(errors, steps)]; + return [receipt()]; } steps.push(`✅ GitHub access verified: ${githubAccess.user}`); + const secretValues = Number(Boolean(request.setupCredentials?.workflowPat)) + (request.setupCredentials?.apiKeys.length ?? 0); + const missingProvisioningPorts = []; + if (setupConfiguration?.manageRepositorySecrets && secretValues > 0 && !dependencies.setupRepositorySecretsPort) { + missingProvisioningPorts.push(new application_error_1.ApplicationError('provider.unavailable', setup_resource_provisioning_1.SECRET_PROVISIONING_UNAVAILABLE)); + } + if (setupConfiguration?.manageRepositoryVariables && !dependencies.setupRepositoryVariablesPort) { + missingProvisioningPorts.push(new application_error_1.ApplicationError('provider.unavailable', setup_resource_provisioning_1.VARIABLE_PROVISIONING_UNAVAILABLE)); + } + if (missingProvisioningPorts.length > 0) { + errors.push(...missingProvisioningPorts); + return [receipt()]; + } const remoteConfigurationErrors = []; const remoteConfiguration = await (0, setup_resource_provisioning_1.resolveRemoteConfiguration)(request, dependencies, setupConfiguration, remoteConfigurationErrors); errors.push(...fromMessages(remoteConfigurationErrors, 'provider.unavailable')); @@ -50463,7 +52262,7 @@ async function runInitialSetupWorkflow(request, dependencies) { if (remoteConfigurationErrors.length === 0) { errors.push(new application_error_1.ApplicationError('provider.unavailable', 'Could not inspect existing GitHub Actions resource scopes. Restore inventory access and rerun setup.')); } - return [buildResult(errors, steps)]; + return [receipt()]; } const inventoryErrors = [ ...(0, setup_configuration_policy_1.validateSetupStorageAgainstRemote)(setupConfiguration, remoteConfiguration), @@ -50474,7 +52273,7 @@ async function runInitialSetupWorkflow(request, dependencies) { ]; if (inventoryErrors.length > 0) { errors.push(...fromMessages(inventoryErrors, 'provider.unavailable')); - return [buildResult(errors, steps)]; + return [receipt()]; } } (0, logging_ports_1.logInfo)('📋 Ensuring .github and copying setup files...'); @@ -50486,15 +52285,23 @@ async function runInitialSetupWorkflow(request, dependencies) { approvedWorkflowFiles: request.workflowUpdates, } : {}), }; + mark('files', 'needs-inspection'); const filesResult = dependencies.setupWorkspacePort.prepare(workspaceSelection); + mark('files', filesResult.copied > 0 ? 'completed' : 'skipped'); steps.push(`✅ Setup files: ${filesResult.copied} copied, ${filesResult.skipped} already existed`); + if (setupConfiguration?.manageRepositorySecrets && secretValues > 0) + mark('secrets', 'needs-inspection'); const secrets = await (0, setup_resource_provisioning_1.ensureRepositorySecrets)(request, dependencies, setupConfiguration, remoteConfiguration); + mark('secrets', secrets.errors.length ? 'needs-inspection' : secrets.writes > 0 ? 'completed' : 'skipped'); if (secrets.step) steps.push(secrets.step); if (secrets.errors.length > 0) errors.push(...fromMessages(secrets.errors, 'authorization.credential-invalid')); (0, logging_ports_1.logInfo)('🏷️ Checking configured and progress labels...'); + mark('labels', 'needs-inspection'); const labels = await ensureInitialLabels(request, dependencies.initialLabelProvisioningPort, setupConfiguration); + mark('labels', !labels.completed || labels.configured.errors.length || labels.progress.errors.length + ? 'needs-inspection' : labels.configured.created + labels.progress.created > 0 ? 'completed' : 'skipped'); if (!labels.completed) { errors.push(labels.error); } @@ -50503,30 +52310,39 @@ async function runInitialSetupWorkflow(request, dependencies) { appendLabelSummary(steps, errors, labels.progress, 'Progress labels'); } (0, logging_ports_1.logInfo)('📋 Checking issue types...'); + mark('issue-types', 'needs-inspection'); const issueTypes = await ensureIssueTypes(request, dependencies.issueTypeProvisioningPort, setupConfiguration); + mark('issue-types', !issueTypes.success ? 'needs-inspection' : issueTypes.created > 0 ? 'completed' : 'skipped'); if (!issueTypes.success) { errors.push(...fromMessages(issueTypes.errors, 'provider.unavailable')); } else { steps.push(`✅ Issue types checked: ${issueTypes.created} created, ${issueTypes.existing} already existed`); } + if (setupConfiguration?.manageRepositoryVariables) + mark('variables', 'needs-inspection'); const variables = await (0, setup_resource_provisioning_1.ensureRepositoryVariables)(request, dependencies, setupConfiguration, remoteConfiguration); + mark('variables', variables.errors.length ? 'needs-inspection' : variables.writes > 0 ? 'completed' : 'skipped'); if (variables.step) steps.push(variables.step); if (variables.errors.length > 0) errors.push(...fromMessages(variables.errors, 'provider.unavailable')); + if (setupConfiguration?.createInitialTag !== false) + mark('initial-tag', 'needs-inspection'); const defaultVersion = await ensureDefaultVersion(request, dependencies, setupConfiguration); + mark('initial-tag', defaultVersion.error ? 'needs-inspection' + : defaultVersion.step?.includes('created on branch') ? 'completed' : 'skipped'); if (defaultVersion.step) steps.push(defaultVersion.step); if (defaultVersion.error) errors.push(defaultVersion.error); - return [buildResult(errors, steps)]; + return [receipt()]; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'workflow.failed', 'Error running initial setup.'); (0, logging_ports_1.logError)(semanticError); errors.push(semanticError); - return [buildResult(errors, steps)]; + return [receipt()]; } } async function verifyGitHubAccess(_request, repository) { @@ -50604,15 +52420,22 @@ function appendLabelSummary(steps, errors, summary, labelType) { steps.push(`✅ ${labelType} checked: ${summary.created} created, ${summary.existing} already existed`); } } -function buildResult(errors, steps) { +function buildResult(errors, steps, effects) { return new result_1.Result({ id: TASK_ID, success: errors.length === 0, executed: true, steps, + payload: { setupReceipt: { version: 1, effects: effects.map(effect => ({ ...effect })) } }, errors: errors.length > 0 ? errors : undefined, }); } +function resourceScope(configuration, kind) { + const policy = configuration?.storage[kind]; + if (!policy) + return 'repository'; + return Object.values(policy.overrides).some(scope => scope !== policy.defaultScope) ? 'mixed' : policy.defaultScope; +} function fromMessages(messages, code) { return messages.map(message => new application_error_1.ApplicationError(code, message)); } @@ -51457,6 +53280,7 @@ function failure(taskId, message, code) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SECRET_PROVISIONING_UNAVAILABLE = exports.VARIABLE_PROVISIONING_UNAVAILABLE = void 0; exports.ensureRepositoryVariables = ensureRepositoryVariables; exports.ensureRepositorySecrets = ensureRepositorySecrets; exports.resolveRemoteConfiguration = resolveRemoteConfiguration; @@ -51464,55 +53288,71 @@ exports.groupSetupResources = groupSetupResources; const setup_configuration_policy_1 = __nccwpck_require__(56637); const logging_ports_1 = __nccwpck_require__(6152); const application_error_1 = __nccwpck_require__(75999); +exports.VARIABLE_PROVISIONING_UNAVAILABLE = 'GitHub Actions Variable provisioning is unavailable; no Variables were changed.'; +exports.SECRET_PROVISIONING_UNAVAILABLE = 'GitHub Actions Secret provisioning is unavailable; no Secrets were changed.'; async function ensureRepositoryVariables(context, dependencies, setupConfiguration, remoteConfiguration) { - if (!setupConfiguration?.manageRepositoryVariables || !dependencies.setupRepositoryVariablesPort) { - return { errors: [] }; + if (!setupConfiguration?.manageRepositoryVariables) { + return { errors: [], writes: 0 }; + } + if (!dependencies.setupRepositoryVariablesPort) { + return { errors: [exports.VARIABLE_PROVISIONING_UNAVAILABLE], writes: 0 }; } try { const desired = (0, setup_configuration_policy_1.buildSetupRepositoryVariables)(setupConfiguration); const groups = groupSetupResources(desired, 'variable', setupConfiguration, remoteConfiguration); const result = await upsertVariableGroups(context, dependencies.setupRepositoryVariablesPort, groups); + const writes = result.created + result.updated; if (result.errors.length > 0) - return { errors: result.errors }; + return { errors: result.errors, writes }; return { - step: `✅ GitHub Actions Variables: ${result.created} created, ${result.updated} updated; existing effective values preserved when no override was selected.`, + step: writes > 0 + ? `✅ GitHub Actions Variables: ${result.created} created, ${result.updated} updated; existing effective values preserved when no override was selected.` + : '✅ GitHub Actions Variables kept unchanged; no values were created or updated.', errors: [], + writes, }; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to configure GitHub Actions Variables.'); (0, logging_ports_1.logError)(semanticError); - return { errors: [semanticError.message] }; + return { errors: [semanticError.message], writes: 0 }; } } async function ensureRepositorySecrets(context, dependencies, setupConfiguration, remoteConfiguration) { - if (!setupConfiguration?.manageRepositorySecrets || !dependencies.setupRepositorySecretsPort) { - return { errors: [] }; + if (!setupConfiguration?.manageRepositorySecrets) { + return { errors: [], writes: 0 }; } const credentials = context.setupCredentials; if (!credentials) { - return { step: '⚠️ Repository Secrets were not changed: run interactive setup to validate and provide credentials.', errors: [] }; + return { step: '⚠️ Repository Secrets were not changed: run interactive setup to validate and provide credentials.', errors: [], writes: 0 }; } const values = [ ...(credentials.workflowPat ? [credentials.workflowPat] : []), ...credentials.apiKeys, ]; if (values.length === 0) - return { step: '✅ Existing Repository Secrets kept unchanged.', errors: [] }; + return { step: '✅ Existing Repository Secrets kept unchanged.', errors: [], writes: 0 }; + if (!dependencies.setupRepositorySecretsPort) { + return { errors: [exports.SECRET_PROVISIONING_UNAVAILABLE], writes: 0 }; + } try { const groups = groupSetupResources(values, 'secret', setupConfiguration, remoteConfiguration); const result = await upsertSecretGroups(context, dependencies.setupRepositorySecretsPort, groups); + const writes = result.created + result.updated; if (result.errors.length > 0) - return { errors: result.errors }; + return { errors: result.errors, writes }; return { - step: `✅ GitHub Actions Secrets: ${result.created} created, ${result.updated} updated; existing effective values kept when no replacement was selected.`, + step: writes > 0 + ? `✅ GitHub Actions Secrets: ${result.created} created, ${result.updated} updated; existing effective values kept when no replacement was selected.` + : '✅ Existing GitHub Actions Secrets kept unchanged; no values were created or updated.', errors: [], + writes, }; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to configure GitHub Actions Secrets.'); (0, logging_ports_1.logError)(semanticError); - return { errors: [semanticError.message] }; + return { errors: [semanticError.message], writes: 0 }; } } async function resolveRemoteConfiguration(context, dependencies, setupConfiguration, errors) { @@ -54557,6 +56397,69 @@ function buildDraftPrompt(context, snapshot, plan, answers, currentSdd) { } +/***/ }), + +/***/ 60830: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.AuditConfiguredSetupPatUseCase = void 0; +const setup_pat_creation_url_policy_1 = __nccwpck_require__(54718); +const setup_token_permission_policy_1 = __nccwpck_require__(99590); +/** Rechecks the final plan without granting permission based on the browser preview. */ +class AuditConfiguredSetupPatUseCase { + constructor(context, ports) { + this.context = context; + this.ports = ports; + } + async audit(configuration, remote) { + const required = (0, setup_token_permission_policy_1.buildConfiguredSetupPatPermissionRequirements)(configuration, remote); + this.ports.presenter.showRequirements('setup', required); + if (this.context.token && (!remote || remote.ownerType === 'Unknown')) { + return { status: 'blocked', errors: [ + 'GitHub could not verify whether this repository is owned by an organization or a user. Retry remote inspection before applying setup; the pre-PAT owner selection is not authorization evidence.', + ] }; + } + if (this.context.assertedOwnerKind && remote && remote.ownerType !== 'Unknown' + && remote.ownerType !== this.context.assertedOwnerKind) { + this.ports.showOwnerMismatch(this.context.assertedOwnerKind, remote.ownerType); + this.showCorrectedLink(required); + return { status: 'blocked', errors: ['Repository owner type differs from the pre-PAT selection. Rerun setup with the correct owner type and PAT.'] }; + } + if (this.context.guided) { + const removed = (0, setup_token_permission_policy_1.requiredSetupPatPermissionDelta)(required, this.context.provisionalRequirements); + if (removed.length) + this.ports.showExcessGrants(removed); + } + if (!this.context.token) + return { status: 'accepted' }; + const report = await this.ports.permissions.inspect({ + role: 'setup', owner: this.context.owner, repository: this.context.repository, + token: this.context.token, requirements: required, + }); + this.ports.presenter.showReport(report); + const accepted = report.ready || (report.confirmationRequired && await this.ports.confirmUnverifiable(report)); + if (!accepted || report.identityStatus !== 'valid') { + if (this.context.guided) + this.showCorrectedLink(required); + return { status: 'blocked', errors: [ + 'The setup PAT has missing or unconfirmed access required by the approved setup plan. Grant or explicitly confirm the permissions shown above and retry.', + ] }; + } + return { status: 'accepted' }; + } + showCorrectedLink(required) { + this.ports.showUpdatedLink((0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: this.context.owner, repository: this.context.repository, + expiresIn: 1, requirements: required, + }), (0, setup_token_permission_policy_1.requiredSetupPatPermissionDelta)(this.context.provisionalRequirements, required)); + } +} +exports.AuditConfiguredSetupPatUseCase = AuditConfiguredSetupPatUseCase; + + /***/ }), /***/ 87328: @@ -54733,7 +56636,7 @@ class SetupDoctorUseCase { const remoteSecrets = new Set([...remote.repositorySecrets, ...remote.organizationSecrets]); const present = requirements.filter((requirement) => remoteSecrets.has(requirement.name)); let health; - if (present.length > 0) { + if (present.length > 0 && !request.readOnly) { try { health = await this.dependencies.remoteHealth.validateExisting(request.owner, request.repository, request.setupToken, request.configuration.repository.mainBranch, present); } @@ -55151,6 +57054,106 @@ function uniqueTargets(targets) { } +/***/ }), + +/***/ 69277: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PrepareSetupPatIntentUseCase = void 0; +const application_error_1 = __nccwpck_require__(75999); +const setup_interaction_cancelled_error_1 = __nccwpck_require__(38313); +const setup_configuration_policy_1 = __nccwpck_require__(56637); +const setup_questionnaire_policy_1 = __nccwpck_require__(6009); +const setup_pat_intent_policy_1 = __nccwpck_require__(30748); +const setup_token_permission_policy_1 = __nccwpck_require__(99590); +const setup_pat_creation_url_policy_1 = __nccwpck_require__(54718); +const setup_wizard_use_case_1 = __nccwpck_require__(43433); +/** Frontend-neutral preparation; GitHub still issues the PAT in its own UI. */ +class PrepareSetupPatIntentUseCase { + constructor(ports) { + this.ports = ports; + } + async execute(request) { + const fixedQuestionIds = (0, setup_pat_intent_policy_1.fixedSetupPatIntentQuestionIds)(request.overrides, request.skipRepositoryVariables, request.skipRepositorySecrets); + let draft = (0, setup_wizard_use_case_1.buildInitialSetupConfiguration)({ + mode: 'interactive', overrides: request.overrides, + skipRepositoryVariables: request.skipRepositoryVariables, + skipRepositorySecrets: request.skipRepositorySecrets, + }); + let pass = 1; + let projectsWanted = Boolean(draft.projects.ids.trim()); + while (true) { + const context = { skipQuestionIds: fixedQuestionIds, projectsWanted, + fixedWorkflowFeatures: { release: request.overrides.features?.release, hotfix: request.overrides.features?.hotfix } }; + const intent = await this.ports.collect((0, setup_questionnaire_policy_1.createSetupPermissionIntentQuestionnaire)(draft, context), context, pass); + if (intent.terminal === 'cancelled') + throw new setup_interaction_cancelled_error_1.SetupInteractionCancelledError(); + draft = intent.draft; + projectsWanted = intent.projectsWanted ?? Boolean(draft.projects.ids.trim()); + if (!projectsWanted && draft.projects.ids.trim()) { + draft = { ...draft, projects: { ...draft.projects, ids: '' } }; + } + const ownerKind = (0, setup_pat_intent_policy_1.setupPatIntentNeedsOwnerKind)(draft, projectsWanted) ? await this.ports.chooseOwnerKind() : 'User'; + if (ownerKind === 'unknown') { + this.ports.onManual('owner-unknown'); + return { kind: 'manual' }; + } + const ownerConflict = (0, setup_pat_intent_policy_1.setupPatIntentOwnerConflict)(draft, ownerKind, projectsWanted); + const errors = (0, setup_configuration_policy_1.validateSetupConfiguration)(draft, { allowIncompleteApproval: true }); + const requirements = (0, setup_token_permission_policy_1.buildSetupPatIntentPermissionRequirements)(draft, ownerKind, projectsWanted); + this.ports.advanceToSetupPat(); + this.ports.showPreview({ + draft, requirements, uncertain: (0, setup_token_permission_policy_1.buildSetupPatIntentUncertainty)(draft, ownerKind), + ownerConflict, errors, pass, projectsWanted, + }); + let decision; + do { + decision = await this.ports.review(); + if (decision === 'details') + this.ports.showDetails(requirements); + } while (decision === 'details'); + if (decision === 'manual') { + this.ports.onManual('chosen'); + return { kind: 'manual' }; + } + if (decision === 'revise') { + pass = this.ports.revisitChoices(); + continue; + } + if (ownerConflict || errors.length > 0) { + throw new application_error_1.ApplicationError('configuration.invalid', 'Correct the reported setup intent or local --config/flags, then retry guided setup. No PAT was requested.'); + } + try { + return { + kind: 'guided', + url: (0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: request.owner, repository: request.repository, expiresIn: 1, + requirements, + }), + requirements, + ownerKind, + permissionIntent: { + draft, + projectsWanted, + answeredQuestionIds: [...new Set([...fixedQuestionIds, ...(intent.answeredQuestionIds ?? [])])], + }, + }; + } + catch (error) { + if (!(error instanceof setup_pat_creation_url_policy_1.UnsupportedSetupPatLinkError)) + throw error; + this.ports.onManual('unsupported'); + return { kind: 'manual' }; + } + } + } +} +exports.PrepareSetupPatIntentUseCase = PrepareSetupPatIntentUseCase; + + /***/ }), /***/ 67438: @@ -55382,6 +57385,74 @@ function isAcceptedCredentialCheck(requirement, check) { } +/***/ }), + +/***/ 8419: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SetupJourneyUseCase = void 0; +const setup_journey_policy_1 = __nccwpck_require__(53289); +/** Tracks semantic milestones, independently of the CLI's rendering. */ +class SetupJourneyUseCase { + constructor(repository, presenter) { + this.repository = repository; + this.presenter = presenter; + this.stage = 'repository'; + this.mutationStarted = false; + this.choiceReviewPass = 1; + } + advance(stage) { + if (this.outcome) + throw new Error('Cannot advance a finished setup journey.'); + const next = setup_journey_policy_1.SETUP_JOURNEY_STAGES.indexOf(stage); + if (next < setup_journey_policy_1.SETUP_JOURNEY_STAGES.indexOf(this.stage)) + throw new Error('Setup journey cannot move backwards.'); + if (next === setup_journey_policy_1.SETUP_JOURNEY_STAGES.indexOf(this.stage)) + return; + this.stage = stage; + this.present(); + } + /** The only deliberate backwards transition: revisit local choices before PAT entry. */ + revisitChoices() { + if (this.stage !== 'setup-pat' || this.outcome || this.mutationStarted) { + throw new Error('Setup choices can be revisited only from pre-PAT review.'); + } + this.choiceReviewPass += 1; + this.stage = 'choices'; + this.present(); + return this.choiceReviewPass; + } + markMutationStarted() { + if ((this.stage !== 'credentials' && this.stage !== 'apply') || this.outcome) { + throw new Error('Setup mutation can start only during credential validation or apply.'); + } + if (this.mutationStarted) + return; + this.mutationStarted = true; + this.present(); + } + finish(outcome) { + if (this.outcome) + return; + if (outcome === 'complete' && (this.stage !== 'apply' || !this.mutationStarted)) { + throw new Error('Setup cannot be complete before applying the plan.'); + } + if (outcome === 'partial' && !this.mutationStarted) { + throw new Error('Setup cannot be partial before mutation starts.'); + } + this.outcome = outcome; + this.present(); + } + present() { + this.presenter.present((0, setup_journey_policy_1.buildSetupJourneyView)(this.repository, this.stage, this.mutationStarted, this.outcome, this.choiceReviewPass)); + } +} +exports.SetupJourneyUseCase = SetupJourneyUseCase; + + /***/ }), /***/ 41644: @@ -55393,17 +57464,20 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupQuestionnaireController = void 0; const setup_questionnaire_policy_1 = __nccwpck_require__(6009); const application_error_1 = __nccwpck_require__(75999); +const setup_terminal_choice_policy_1 = __nccwpck_require__(64662); class SetupQuestionnaireController { constructor(terminal, renderer) { this.terminal = terminal; this.renderer = renderer; } - async collect(initial, context) { + async collect(initial, context, discoveryRefresh) { if (!this.terminal.isInteractive()) { throw new application_error_1.ApplicationError('configuration.invalid', 'Interactive setup requires an interactive terminal. Use --non-interactive with explicit configuration.'); } this.renderer.showIntroduction(); let state = initial; + let currentContext = context; + let pendingProjectSelection; let visibleState; while (state.terminal === 'collecting' && state.question) { if (visibleState !== state.stateId) { @@ -55412,10 +57486,56 @@ class SetupQuestionnaireController { } if (state.validation) this.renderer.showValidation(state.validation); - const input = state.question.kind === 'multi-select' && this.terminal.readMultiSelect - ? await this.terminal.readMultiSelect(this.renderer.renderPrompt(state.question), state.question.choices ?? [], parseSelectedDefaults(state.question.defaultValue)) - : await this.terminal.readText(this.renderer.renderPrompt(state.question)); - state = (0, setup_questionnaire_policy_1.transitionSetupQuestionnaire)(state, toEvent(input), context); + const question = state.question; + const prompt = this.renderer.renderPrompt(question, (0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(state, currentContext)); + const selectable = question.kind === 'multi-select' || question.kind === 'project-select'; + const choices = selectable ? choicesForQuestion(question) : []; + const selected = question.kind === 'project-select' && pendingProjectSelection + ? pendingProjectSelection : parseSelectedDefaults(question.defaultValue); + let input = selectable && this.terminal.readMultiSelect + ? await this.terminal.readMultiSelect(prompt, choices, selected, this.renderer.renderHelp(question)) + : await this.terminal.readText(selectable ? textChoicePrompt(prompt, choices, selected) : prompt); + if (selectable && input.kind === 'value' && !input.value.trim()) { + input = { kind: 'value', value: selected.join(',') || 'none' }; + } + const selectionTokens = selectable && input.kind === 'value' + ? input.value.split(',').map(value => value.trim()).filter(Boolean) : []; + const hasRetry = selectionTokens.some(value => value.toLowerCase() === 'retry'); + if (state.question.kind === 'project-select' && input.kind === 'value' + && selectionTokens.some(value => value.toLowerCase() === 'manual') && !hasRetry) { + const manual = await this.terminal.readText('Enter additional Project numbers or GitHub URLs, comma-separated (empty adds none): '); + input = manual.kind === 'value' + ? { kind: 'value', value: [selectionTokens.filter(value => value.toLowerCase() !== 'manual').join(','), manual.value] + .filter(value => value && value !== 'none').join(',') || 'none' } : manual; + } + if (input.kind === 'value' && input.value.trim() === '?') { + this.renderer.showHelp(state.question); + continue; + } + if (input.kind === 'value' && input.value.trim().toLowerCase() === ':back') { + pendingProjectSelection = undefined; + state = (0, setup_questionnaire_policy_1.transitionSetupQuestionnaire)(state, { kind: 'back' }, currentContext); + continue; + } + const kind = state.question.id === 'projects.ids' ? 'projects' + : state.question.id === 'pullRequestApproval.testChecks' ? 'checks' : undefined; + if (kind && input.kind === 'value' && (input.value.trim().toLowerCase() === 'r' + || hasRetry)) { + if (kind === 'projects') + pendingProjectSelection = selectionTokens.filter(value => !['retry', 'r'].includes(value.toLowerCase())); + if (!state.question.discoveryRetryRemaining || !discoveryRefresh) { + this.renderer.showValidation('No discovery retries remain. Use the manual option or continue.'); + continue; + } + const refreshed = await discoveryRefresh.refresh(kind); + if (refreshed) { + currentContext = refreshed; + state = (0, setup_questionnaire_policy_1.refreshSetupQuestionnaireQuestion)(state, currentContext); + } + continue; + } + pendingProjectSelection = undefined; + state = (0, setup_questionnaire_policy_1.transitionSetupQuestionnaire)(state, toEvent(input), currentContext); } if (state.terminal === 'cancelled') this.renderer.showCancelled(); @@ -55423,6 +57543,19 @@ class SetupQuestionnaireController { } } exports.SetupQuestionnaireController = SetupQuestionnaireController; +function choicesForQuestion(question) { + return question.kind === 'project-select' + ? [...(question.projectCandidates ?? []).map(candidate => `${candidate.number} — ${(0, setup_terminal_choice_policy_1.safeTerminalChoiceText)(candidate.title)} (${(0, setup_terminal_choice_policy_1.safeTerminalChoiceText)(candidate.url)})`), + 'manual — Enter Project number or URL', + ...(question.discoveryRetryRemaining ? ['retry — Retry GitHub Project discovery'] : [])] + : question.choices ?? []; +} +function textChoicePrompt(prompt, choices, selected) { + return [prompt, 'Available IDs:', ...choices.map(choice => ` ${(0, setup_terminal_choice_policy_1.safeTerminalChoiceText)(choice)}`), + `Current selection: ${(0, setup_terminal_choice_policy_1.safeTerminalChoiceText)(selected.join(', ') || 'none')}`, + 'Enter IDs shown before “—”, separated by commas; use manual or retry when offered, none to clear, or Enter to keep the default: ', + ].join('\n'); +} function parseSelectedDefaults(value) { return typeof value === 'string' ? value.split(',').map(item => item.trim()).filter(Boolean) : []; } @@ -55504,35 +57637,24 @@ exports.SetupTokenPermissionsUseCase = SetupTokenPermissionsUseCase; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupWizardUseCase = void 0; +exports.buildInitialSetupConfiguration = buildInitialSetupConfiguration; const application_error_1 = __nccwpck_require__(75999); const setup_configuration_policy_1 = __nccwpck_require__(56637); const setup_questionnaire_policy_1 = __nccwpck_require__(6009); const setup_configuration_clone_policy_1 = __nccwpck_require__(85881); const setup_doctor_message_catalog_1 = __nccwpck_require__(80226); const pull_request_approval_policy_1 = __nccwpck_require__(98820); +const setup_project_selection_policy_1 = __nccwpck_require__(73750); class SetupWizardUseCase { constructor(dependencies) { this.dependencies = dependencies; } async execute(request) { - const effectiveOverrides = request.mode === 'non-interactive' - && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined - ? { - ...request.overrides, - repositoryAgentGuidance: { - ...request.overrides?.repositoryAgentGuidance, - agentsPointer: 'create-if-missing', - }, - } - : request.overrides; - const defaults = (0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.createDefaultSetupConfiguration)(), { pullRequestApproval: pull_request_approval_policy_1.DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), { - ...effectiveOverrides, - ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), - ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), - }); - if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { - defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; - } + const defaults = buildInitialSetupConfiguration(request); + const effectiveOverrides = request.overrides; + const initial = request.permissionIntent ? (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(request.permissionIntent.draft) : defaults; + const basicSkippedQuestionIds = request.presentationMode === 'basic' + ? request.basicSkippedQuestionIds ?? (0, setup_questionnaire_policy_1.setupBasicSkippedQuestionIds)(initial) : []; let remoteConfiguration; if (request.remoteTarget) { try { @@ -55542,18 +57664,72 @@ class SetupWizardUseCase { remoteConfiguration = unavailableRemoteConfiguration(); } } - const defaultValidationErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(defaults, { allowIncompleteApproval: true }); + const projectOwnerType = remoteConfiguration?.ownerType ?? 'Unknown'; + const explicitMainBranch = request.overrides?.repository?.mainBranch !== undefined; + if (!explicitMainBranch && remoteConfiguration?.defaultBranch) { + initial.repository.mainBranch = remoteConfiguration.defaultBranch; + } + const defaultValidationErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(initial, { allowIncompleteApproval: true }); if (defaultValidationErrors.length > 0) { throw new application_error_1.ApplicationError('configuration.invalid', `Invalid setup configuration:\n${defaultValidationErrors.map((error) => `- ${error}`).join('\n')}`); } - const context = { + let approvalDiscovery = request.mode === 'interactive' && initial.pullRequestApproval.mode !== 'off' + && request.remoteTarget && this.dependencies.approvalCheckDiscovery + ? await this.dependencies.approvalCheckDiscovery.discover(request.remoteTarget.owner, request.remoteTarget.repository, request.remoteTarget.token, initial.repository.developmentBranch).catch(() => ({ status: 'unavailable', candidates: [], truncated: false })) : undefined; + let projectDiscovery = request.mode === 'interactive' + && (request.permissionIntent?.projectsWanted !== false || request.revision?.group === 'projects') + && request.remoteTarget && this.dependencies.projectDiscovery + ? await this.dependencies.projectDiscovery.discover(request.remoteTarget.owner, projectOwnerType, request.remoteTarget.token).catch(() => ({ status: 'unavailable', candidates: [] })) : undefined; + let context = { ...(remoteConfiguration ? { remote: remoteConfiguration } : {}), - variableNames: (0, setup_configuration_policy_1.buildSetupRepositoryVariables)(defaults).map((variable) => variable.name), - secretNames: (0, setup_configuration_policy_1.buildSetupCredentialRequirements)(defaults).map((requirement) => requirement.name), + branchSources: { main: explicitMainBranch ? 'configuration' : remoteConfiguration?.defaultBranch ? 'github' : 'default', + development: request.overrides?.repository?.developmentBranch !== undefined ? 'configuration' + : request.developmentBranchObservedLocally ? 'local' : 'default' }, + variableNames: (0, setup_configuration_policy_1.buildSetupRepositoryVariables)(initial).map((variable) => variable.name), + secretNames: (0, setup_configuration_policy_1.buildSetupCredentialRequirements)(initial).map((requirement) => requirement.name), + ...(request.revision ? { skipQuestionIds: [...new Set([ + ...request.revision.answeredQuestionIds, + ...basicSkippedQuestionIds, + ])].filter(id => !(0, setup_questionnaire_policy_1.setupQuestionIdsForGroup)(request.revision.group).includes(id)), + projectsWanted: request.revision.group === 'projects' || Boolean(initial.projects.ids.trim()) } : {}), + ...(!request.revision ? { skipQuestionIds: [...new Set([ + ...(request.permissionIntent?.answeredQuestionIds ?? []), + ...(request.permissionIntent?.projectsWanted === false ? ['projects.ids'] : []), + ...basicSkippedQuestionIds, + ])], ...(request.permissionIntent ? { projectsWanted: request.permissionIntent.projectsWanted } : {}) } : {}), + ...(approvalDiscovery ? { approvalCheckCandidates: approvalDiscovery.candidates, + approvalCheckDiscoveryStatus: approvalDiscovery.status, + approvalCheckDiscoveryTruncated: approvalDiscovery.truncated } : {}), + ...(projectDiscovery ? { projectDiscovery } : {}), + ...(request.remoteTarget ? { projectOwner: request.remoteTarget.owner } : {}), + discoveryRetryRemaining: { checks: approvalDiscovery ? 2 : 0, + projects: projectDiscovery && projectDiscovery.status !== 'unsupported' ? 2 : 0 }, + }; + const discoveryRefresh = { + refresh: async (kind) => { + const target = request.remoteTarget; + // This context always owns both retry budgets; the selected adapter was present when its budget was issued. + const remaining = context.discoveryRetryRemaining[kind]; + if (!target || remaining <= 0) + return undefined; + if (kind === 'checks') { + approvalDiscovery = await this.dependencies.approvalCheckDiscovery.discover(target.owner, target.repository, target.token, initial.repository.developmentBranch).catch(() => ({ status: 'unavailable', candidates: [], truncated: false })); + context = { ...context, approvalCheckCandidates: approvalDiscovery.candidates, + approvalCheckDiscoveryStatus: approvalDiscovery.status, + approvalCheckDiscoveryTruncated: approvalDiscovery.truncated, + discoveryRetryRemaining: { ...context.discoveryRetryRemaining, checks: remaining - 1 } }; + } + else { + projectDiscovery = await this.dependencies.projectDiscovery.discover(target.owner, projectOwnerType, target.token).catch(() => ({ status: 'unavailable', candidates: [] })); + context = { ...context, projectDiscovery, + discoveryRetryRemaining: { ...context.discoveryRetryRemaining, projects: remaining - 1 } }; + } + return context; + }, }; const questionnaire = request.mode === 'interactive' - ? await this.collectInteractive(defaults, context) - : (0, setup_questionnaire_policy_1.createSetupReviewState)(defaults); + ? await this.collectInteractive(initial, context, discoveryRefresh) + : (0, setup_questionnaire_policy_1.createSetupReviewState)(initial); if (questionnaire.terminal === 'cancelled') { return { status: 'cancelled', @@ -55569,7 +57745,10 @@ class SetupWizardUseCase { // default must not outlive an explicit decision to disable PR automation. collectedConfiguration.pullRequestApproval = { ...collectedConfiguration.pullRequestApproval, mode: 'off' }; } - const validationErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(collectedConfiguration, { allowIncompleteApproval: request.previewOnly === true }); + const validationErrors = [ + ...(0, setup_configuration_policy_1.validateSetupConfiguration)(collectedConfiguration, { allowIncompleteApproval: request.previewOnly === true }), + ...(0, setup_project_selection_policy_1.validateDiscoveredProjectStatuses)(collectedConfiguration, projectDiscovery), + ]; if (validationErrors.length > 0) { throw new application_error_1.ApplicationError('configuration.invalid', `Invalid setup configuration:\n${validationErrors.map((error) => `- ${error}`).join('\n')}`); } @@ -55666,9 +57845,30 @@ class SetupWizardUseCase { } } const plan = (0, setup_configuration_policy_1.buildSetupPlan)(configuration, readiness, approvalReadiness); + if (basicSkippedQuestionIds.length) { + const byGroup = new Map(); + for (const item of (0, setup_questionnaire_policy_1.setupQuestionContentInventory)()) { + if (basicSkippedQuestionIds.includes(item.id) && !request.reviewedGroups?.includes(item.stateId) + && request.revision?.group !== item.stateId) + byGroup.set(item.stateId, (byGroup.get(item.stateId) ?? 0) + 1); + } + plan.presentationDefaults = [...byGroup].map(([group, count]) => ({ group, count })); + } this.dependencies.planPresenter.present(plan); const confirmation = (0, setup_questionnaire_policy_1.enterSetupConfirmation)(questionnaire); const decision = await this.dependencies.confirmation.confirm(plan); + if (decision.kind === 'revise') { + if (request.mode !== 'interactive') + throw new application_error_1.ApplicationError('configuration.invalid', 'Plan editing requires interactive setup.'); + const answeredQuestionIds = [...new Set([ + ...(request.revision?.answeredQuestionIds ?? []), + ...(request.permissionIntent?.answeredQuestionIds ?? []), + ...(questionnaire.answeredQuestionIds ?? []), + ])]; + return this.execute({ ...request, overrides: (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration), permissionIntent: undefined, + basicSkippedQuestionIds, reviewedGroups: [...new Set([...(request.reviewedGroups ?? []), decision.group])], + revision: { group: decision.group, answeredQuestionIds } }); + } const completed = (0, setup_questionnaire_policy_1.finishSetupQuestionnaire)(confirmation, decision.kind === 'approved'); if (completed.terminal === 'cancelled') { return { @@ -55686,14 +57886,35 @@ class SetupWizardUseCase { ...(remoteConfiguration ? { remoteConfiguration } : {}), }; } - collectInteractive(defaults, context) { + collectInteractive(defaults, context, discoveryRefresh) { if (!this.dependencies.collector) { throw new application_error_1.ApplicationError('configuration.invalid', 'Interactive setup requires a questionnaire collector.'); } - return this.dependencies.collector.collect((0, setup_questionnaire_policy_1.createSetupQuestionnaire)(defaults, context), context); + return this.dependencies.collector.collect((0, setup_questionnaire_policy_1.createSetupQuestionnaire)(defaults, context), context, discoveryRefresh); } } exports.SetupWizardUseCase = SetupWizardUseCase; +function buildInitialSetupConfiguration(request) { + const effectiveOverrides = request.mode === 'non-interactive' + && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined + ? { + ...request.overrides, + repositoryAgentGuidance: { + ...request.overrides?.repositoryAgentGuidance, + agentsPointer: 'create-if-missing', + }, + } + : request.overrides; + const defaults = (0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.createDefaultSetupConfiguration)(), { pullRequestApproval: pull_request_approval_policy_1.DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), { + ...effectiveOverrides, + ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), + ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), + }); + if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { + defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; + } + return defaults; +} /** An unavailable read is explicit, never an authoritative empty inventory. */ function unavailableRemoteConfiguration() { return { @@ -55707,6 +57928,144 @@ function unavailableRemoteConfiguration() { } +/***/ }), + +/***/ 35697: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.VerifyGuidedWorkflowPatIdentityUseCase = void 0; +const application_error_1 = __nccwpck_require__(75999); +/** Binds a guided runtime PAT to the bot account chosen before token entry. */ +class VerifyGuidedWorkflowPatIdentityUseCase { + constructor(identities) { + this.identities = identities; + } + async execute(expected, workflowToken) { + const actual = await this.identities.identify(workflowToken); + if (actual.id !== expected.id) { + throw new application_error_1.ApplicationError('authorization.credential-invalid', `The workflow PAT belongs to @${actual.login}, not the selected bot @${expected.login}. No Secret was written. Delete the unintended PAT in GitHub and create one as @${expected.login}.`); + } + return expected; + } +} +exports.VerifyGuidedWorkflowPatIdentityUseCase = VerifyGuidedWorkflowPatIdentityUseCase; + + +/***/ }), + +/***/ 23388: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.VerifySetupPatBootstrapUseCase = void 0; +const application_error_1 = __nccwpck_require__(75999); +const setup_pat_creation_url_policy_1 = __nccwpck_require__(54718); +/** Initial read-only gate shared by terminal and browser setup presentations. */ +class VerifySetupPatBootstrapUseCase { + constructor(ports) { + this.ports = ports; + } + async execute(request) { + const report = await this.ports.permissions.inspect({ + role: 'setup', owner: request.owner, repository: request.repository, + token: request.token, requirements: request.requirements, + }); + this.ports.presenter.showReport(report); + const accepted = report.ready + || (report.confirmationRequired && await this.ports.confirmUnverifiable(report)); + if (!accepted || report.identityStatus !== 'valid') { + if (request.guided) + this.ports.showCorrectedLink((0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: request.owner, repository: request.repository, + expiresIn: 1, requirements: request.requirements, + })); + throw new application_error_1.ApplicationError('authorization.credential-invalid', 'The setup PAT has missing or unconfirmed required access. Grant or explicitly confirm the permissions shown above and retry.'); + } + if (!await this.ports.confirmAccount(report.account)) { + throw new application_error_1.ApplicationError('authorization.credential-invalid', 'The setup PAT belongs to an unintended account. Revoke it in GitHub and retry with the correct account.'); + } + return report.account; + } +} +exports.VerifySetupPatBootstrapUseCase = VerifySetupPatBootstrapUseCase; + + +/***/ }), + +/***/ 5303: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.VerifyWebSetupApplyUseCase = void 0; +const application_error_1 = __nccwpck_require__(75999); +const setup_interaction_cancelled_error_1 = __nccwpck_require__(38313); +const setup_remote_facts_policy_1 = __nccwpck_require__(92567); +/** Authorizes one web Apply against the facts the operator actually reviewed. */ +class VerifyWebSetupApplyUseCase { + constructor(ports) { + this.ports = ports; + } + async execute(request) { + const decision = await this.ports.confirm(); + if (decision === undefined) + return 'cancelled'; + if (decision === 'stop') + return 'declined'; + this.assertActive(); + this.assertLocalSnapshot(request); + let remote = await this.ports.remote.inspect(request.repository.owner, request.repository.repository, request.setupToken); + this.assertActive(); + let credentialHealthWorkflow = 'unavailable'; + try { + credentialHealthWorkflow = await this.ports.remote.inspectCredentialHealthWorkflow?.(request.repository.owner, request.repository.repository, request.setupToken, request.configuration.repository.mainBranch) ?? 'unavailable'; + } + catch { /* Unknown selected-ref state must not inherit a provisional value. */ } + this.assertActive(); + remote = { ...remote, credentialHealthWorkflow }; + if (!(0, setup_remote_facts_policy_1.sameSetupRemoteFacts)(remote, request.approvedRemote)) { + throw new application_error_1.ApplicationError('configuration.invalid', 'GitHub repository facts changed since plan review. No mutation started; restart and review a new plan.'); + } + const audit = await this.ports.permissionAudit.audit(request.configuration, remote); + this.assertActive(); + if (audit.status === 'blocked') { + throw new application_error_1.ApplicationError('authorization.credential-invalid', 'Setup PAT access changed since plan review. No mutation started; correct the PAT and review a new plan.'); + } + // The remote reads above can take time. Close that window before the caller applies. + this.assertLocalSnapshot(request); + return 'approved'; + } + assertLocalSnapshot(request) { + const current = this.ports.readRepositoryFacts(); + const expected = request.repository; + if (!current || current.owner !== expected.owner || current.repository !== expected.repository + || current.checkoutRoot !== expected.checkoutRoot || current.branch !== expected.branch + || current.head !== expected.head) { + throw new application_error_1.ApplicationError('configuration.invalid', 'The repository identity changed during setup. No mutation started; restart and review a new plan.'); + } + if (!this.ports.fileSnapshotMatches(expected.checkoutRoot, request.selectedFiles, request.fileSnapshot)) { + throw new application_error_1.ApplicationError('configuration.invalid', 'Selected repository files changed since plan review. No mutation started; restart and review a new plan.'); + } + } + assertActive() { + const state = this.ports.sessionState(); + if (state === 'cancelled') { + throw new setup_interaction_cancelled_error_1.SetupInteractionCancelledError(); + } + if (state === 'ended') { + throw new application_error_1.ApplicationError('configuration.invalid', 'The local setup session expired during final checks. No mutation started; start a new run and review a fresh plan.'); + } + } +} +exports.VerifyWebSetupApplyUseCase = VerifyWebSetupApplyUseCase; + + /***/ }), /***/ 73572: @@ -64840,9 +67199,10 @@ const setup_credential_prompt_adapter_1 = __nccwpck_require__(93232); function registerDoctorCommand(program) { program .command('doctor') - .description('Verify Copilot workflows, Variables, Secrets, and setup PAT without changing repository configuration') + .description('Verify Copilot resources; use --read-only to avoid dispatching credential-health Actions') .option('-t, --token ', 'Setup PAT (or PERSONAL_ACCESS_TOKEN from the environment)') .option('--config ', 'YAML or JSON setup configuration used as the expected contract') + .option('--read-only', 'Inspect metadata and installed resources without dispatching credential-health Actions', false) .option('--non-interactive', 'Do not prompt; use --token or PERSONAL_ACCESS_TOKEN', false) .action(async (options) => { const terminal = options.nonInteractive ? undefined : (0, setup_terminal_driver_1.createInteractiveTerminalDriver)(); @@ -64870,6 +67230,7 @@ function registerDoctorCommand(program) { repository: gitInfo.repo, setupToken: token, configuration: expected, + readOnly: Boolean(options.readOnly), }); new setup_doctor_presenter_1.SetupDoctorPresenter(diagnosis.catalog).present(diagnosis.report); if (!diagnosis.report.healthy) @@ -65100,15 +67461,20 @@ const setup_files_1 = __nccwpck_require__(59126); const logger_1 = __nccwpck_require__(91151); const cli_context_1 = __nccwpck_require__(21307); const setup_policy_1 = __nccwpck_require__(28732); -const setup_config_file_1 = __nccwpck_require__(11196); +const setup_command_options_1 = __nccwpck_require__(99254); const setup_1 = __nccwpck_require__(36888); +const setup_configuration_plan_1 = __nccwpck_require__(87770); +const prepare_setup_pat_intent_use_case_1 = __nccwpck_require__(69277); +const audit_configured_setup_pat_use_case_1 = __nccwpck_require__(60830); +const verify_setup_pat_bootstrap_use_case_1 = __nccwpck_require__(23388); const setup_configuration_policy_1 = __nccwpck_require__(56637); const setup_token_permission_policy_1 = __nccwpck_require__(99590); const setup_credentials_composition_root_1 = __nccwpck_require__(69084); const setup_doctor_composition_root_1 = __nccwpck_require__(56360); const setup_workspace_adapter_1 = __nccwpck_require__(5729); const setup_approval_readiness_adapter_1 = __nccwpck_require__(78572); -const issue_workflow_profile_1 = __nccwpck_require__(26744); +const github_setup_approval_check_discovery_adapter_1 = __nccwpck_require__(42294); +const github_setup_project_discovery_adapter_1 = __nccwpck_require__(29564); const application_error_1 = __nccwpck_require__(75999); const setup_terminal_driver_1 = __nccwpck_require__(5462); const setup_question_renderer_1 = __nccwpck_require__(89481); @@ -65118,6 +67484,19 @@ const setup_credential_prompt_adapter_1 = __nccwpck_require__(93232); const setup_workflow_update_prompt_adapter_1 = __nccwpck_require__(84473); const setup_token_permission_presenter_1 = __nccwpck_require__(63206); const setup_token_permissions_composition_root_1 = __nccwpck_require__(64132); +const setup_pat_creation_url_policy_1 = __nccwpck_require__(54718); +const setup_github_identity_query_adapter_1 = __nccwpck_require__(56098); +const verify_guided_workflow_pat_identity_use_case_1 = __nccwpck_require__(35697); +const verify_web_setup_apply_use_case_1 = __nccwpck_require__(5303); +const setup_journey_use_case_1 = __nccwpck_require__(8419); +const setup_journey_policy_1 = __nccwpck_require__(53289); +const setup_journey_presenter_1 = __nccwpck_require__(20462); +const web_setup_bridge_1 = __nccwpck_require__(21518); +const setup_apply_snapshot_1 = __nccwpck_require__(84136); +const setup_session_guard_1 = __nccwpck_require__(53104); +const web_setup_server_1 = __nccwpck_require__(63080); +const web_setup_adapters_1 = __nccwpck_require__(60574); +const setup_result_receipt_1 = __nccwpck_require__(44132); function registerSetupCommand(program) { program .command('setup') @@ -65130,10 +67509,11 @@ function registerSetupCommand(program) { .option('--agent-guidance ', 'Generated agent guidance mode (prompt|create-if-missing|disabled)') .option('--config ', 'YAML or JSON file with setup overrides') .option('--pr-approval-mode ', 'PR bot approval: recommend (new setup default), guarded, or off') - .option('--pr-approval-check ', 'Exact test producer name|source-App-ID|workflow-name; repeat for multiple checks', collectApprovalCheck, []) + .option('--pr-approval-check ', 'Exact test producer name|source-App-ID|workflow-name; repeat for multiple checks', setup_command_options_1.collectApprovalCheck, []) .option('--pr-approval-coverage-check ', 'Exact selected check that enforces the coverage budget') .option('--pr-approval-attest-producer', 'Confirm exact check/App/workflow identity and a coverage-enforcing CI step', false) .option('--non-interactive', 'Use defaults and config-file values without prompting', false) + .option('--web', 'Run the optional local browser setup assistant (127.0.0.1 only)', false) .option('--yes', 'Apply the plan without the final confirmation prompt', false) .option('--confirm-unverifiable-write-permissions', 'Confirm that required PAT write permissions shown as Unverifiable were configured exactly as displayed', false) .option('--dry-run', 'Show the setup plan without changing files or GitHub', false) @@ -65143,26 +67523,32 @@ function registerSetupCommand(program) { .option('--secrets-scope ', 'Default Secret scope (repository|organization)') .option('--variables-visibility ', 'Organization Variable visibility (selected|private|all)') .option('--secrets-visibility ', 'Organization Secret visibility (selected|private|all)') - .option('--variable-scope ', 'Per-variable scope override; repeat as needed', collectScope, {}) - .option('--secret-scope ', 'Per-secret scope override; repeat as needed', collectScope, {}) + .option('--variable-scope ', 'Per-variable scope override; repeat as needed', setup_command_options_1.collectScope, {}) + .option('--secret-scope ', 'Per-secret scope override; repeat as needed', setup_command_options_1.collectScope, {}) .option('--update-workflows', 'Allow setup-managed workflows already in the repository to be updated', false) .option('--workflow-pat ', 'Workflow PAT for the bot account (prefer the hidden interactive prompt)') - .option('--secret ', 'Secret value for non-interactive setup; repeat for each API key', collectSecret, {}) + .option('--secret ', 'Secret value for non-interactive setup; repeat for each API key', setup_command_options_1.collectSecret, {}) .action(async (options) => { - const terminal = options.nonInteractive ? undefined : (0, setup_terminal_driver_1.createInteractiveTerminalDriver)(); - const credentialPrompt = new setup_credential_prompt_adapter_1.SetupCredentialPromptAdapter(terminal, { + const terminal = options.nonInteractive || options.web ? undefined : (0, setup_terminal_driver_1.createInteractiveTerminalDriver)(); + const webBridge = options.web ? new web_setup_bridge_1.WebSetupBridge('Resolving repository…') : undefined; + let webServer; + const credentialPrompt = webBridge ? new web_setup_adapters_1.WebSetupCredentialPrompt(webBridge) : new setup_credential_prompt_adapter_1.SetupCredentialPromptAdapter(terminal, { ...(options.workflowPat ? { PAT: options.workflowPat } : {}), ...options.secret, }, Boolean(options.confirmUnverifiableWritePermissions)); - const permissionPresenter = new setup_token_permission_presenter_1.ConsoleSetupTokenPermissionPresenter(); + const permissionPresenter = webBridge ? new web_setup_adapters_1.WebSetupPermissionPresenter(webBridge) + : new setup_token_permission_presenter_1.ConsoleSetupTokenPermissionPresenter(options.nonInteractive ? 'full' : 'summary'); const tokenPermissions = (0, setup_token_permissions_composition_root_1.createSetupTokenPermissionsUseCase)(); - const workflowPrompt = new setup_workflow_update_prompt_adapter_1.SetupWorkflowUpdatePromptAdapter(terminal); + const workflowPrompt = webBridge ? new web_setup_adapters_1.WebSetupWorkflowUpdatePrompt(webBridge) : new setup_workflow_update_prompt_adapter_1.SetupWorkflowUpdatePromptAdapter(terminal); const cwd = process.cwd(); + let setupMutationStarted = false; + let setupApplyStarted = false; + let releaseSetupGuard; + let journey; try { - if (!options.nonInteractive && !terminal) { - (0, logger_1.logError)('Interactive setup requires a terminal. Use --non-interactive with explicit configuration.'); - process.exitCode = 1; - return; + if (options.web && (options.nonInteractive || options.yes || options.token || options.workflowPat + || Object.keys(options.secret ?? {}).length || options.confirmUnverifiableWritePermissions)) { + throw new application_error_1.ApplicationError('configuration.invalid', '--web cannot be combined with --non-interactive, --yes, --token, --workflow-pat, --secret, or --confirm-unverifiable-write-permissions. Use the browser for these decisions or run copilot setup in the terminal.'); } (0, logger_1.logInfo)('🔍 Checking we are inside a git repository...'); if (!(0, cli_context_1.isInsideGitRepo)(cwd)) { @@ -65179,9 +67565,134 @@ function registerSetupCommand(program) { return; } (0, logger_1.logInfo)(`📦 Repository: ${gitInfo.owner}/${gitInfo.repo}`); - const setupPatPermissions = (0, setup_token_permission_policy_1.buildSetupPatPermissionRequirements)(); - permissionPresenter.showRequirements('setup', setupPatPermissions); + const checkoutRoot = webBridge ? (0, cli_context_1.getGitRepositoryRoot)(cwd) : cwd; + if (webBridge && !(0, cli_context_1.isGitRepositoryRoot)(cwd)) { + throw new application_error_1.ApplicationError('configuration.invalid', `Web setup must start from the repository root (${checkoutRoot}). Change to that directory and rerun before creating PATs. No local setup session started.`); + } + releaseSetupGuard = (0, setup_session_guard_1.acquireSetupSessionGuard)(cwd); + const initialBranch = webBridge ? (0, cli_context_1.getCurrentAttachedBranch)(cwd) : undefined; + const initialHead = webBridge ? (0, cli_context_1.getCurrentHeadSha)() : undefined; + if (webBridge && (!initialBranch || !initialHead)) { + throw new application_error_1.ApplicationError('configuration.invalid', 'An attached Git branch and revision are required for web setup. Check out a branch before creating PATs. No local setup session started.'); + } + if (webBridge) { + webBridge.setRepository(`${gitInfo.owner}/${gitInfo.repo}`); + webBridge.setJourney((0, setup_journey_policy_1.buildSetupJourneyView)(`${gitInfo.owner}/${gitInfo.repo}`, 'repository', false)); + webServer = await (0, web_setup_server_1.startWebSetupServer)(webBridge); + (0, logger_1.logInfo)(`🌐 Local setup assistant: ${webServer.url}`); + (0, logger_1.logInfo)(`🔑 Browser pairing code: ${webServer.pairingCode}`, false, undefined, true); + (0, logger_1.logInfo)('If the browser does not open, copy this URL into a browser on this computer, then enter the pairing code shown above. The terminal setup remains available with copilot setup.'); + (0, web_setup_server_1.openWebSetupBrowser)(webServer.url); + } + if (!options.nonInteractive) { + journey = new setup_journey_use_case_1.SetupJourneyUseCase(`${gitInfo.owner}/${gitInfo.repo}`, webBridge ? new web_setup_adapters_1.WebSetupJourneyPresenter(webBridge) : new setup_journey_presenter_1.ConsoleSetupJourneyPresenter()); + if (webBridge) { + const target = await webBridge.ask({ kind: 'confirm', title: 'Confirm this repository', copyId: 'repository.confirm', copyValues: { repository: `${gitInfo.owner}/${gitInfo.repo}`, branch: initialBranch ?? '' }, + description: `This local checkout resolves to ${gitInfo.owner}/${gitInfo.repo} on branch ${initialBranch}. Confirm the target before configuring PAT access or files.`, + choices: ['Yes, this is my repository', 'Stop and choose another checkout'] }); + if (target === undefined) + throw new setup_credential_prompt_adapter_1.SetupTerminalCancelledError(); + if (target !== 'Yes, this is my repository') { + journey.finish('cancelled'); + return; + } + } + journey.advance('choices'); + } + const overrides = (0, setup_command_options_1.loadSetupOverrides)(options); + let presentationMode = 'custom'; + if (!options.nonInteractive && !options.dryRun) { + if (webBridge) { + const depth = await webBridge.ask({ kind: 'choice', title: 'Choose setup detail', copyId: 'setup.depth', + choices: ['Basic guided setup', 'Customize every setting'], defaultValue: 'Basic guided setup' }); + if (depth === undefined) + throw new setup_credential_prompt_adapter_1.SetupTerminalCancelledError(); + presentationMode = depth === 'Basic guided setup' ? 'basic' : 'custom'; + } + else if (credentialPrompt instanceof setup_credential_prompt_adapter_1.SetupCredentialPromptAdapter) { + presentationMode = await credentialPrompt.chooseSetupPresentationMode(); + } + } + let setupPatPermissions = (0, setup_token_permission_policy_1.buildSetupPatPermissionRequirements)(); let token = (0, setup_files_1.getSetupToken)(cwd, options.token); + if (webBridge && token) { + const choice = await webBridge.ask({ kind: 'choice', title: 'An environment setup PAT is available', copyId: 'setup.environmentPat', + description: 'Its value stays in the CLI process and is never sent to this page. Exiting Copilot cannot unset your parent shell variable.', + choices: ['Use the environment PAT', 'Create or enter a different PAT'] }); + if (choice === undefined) + throw new setup_credential_prompt_adapter_1.SetupTerminalCancelledError(); + if (choice !== 'Use the environment PAT') + token = undefined; + } + if (token || options.nonInteractive) + permissionPresenter.showDetailedRequirements('setup', setupPatPermissions); + else + permissionPresenter.showRequirements('setup', setupPatPermissions); + let setupPatAccount; + let permissionIntent; + let assertedOwnerKind; + if (!token && !options.nonInteractive && !options.dryRun) { + if (await credentialPrompt.chooseSetupPatMethod() === 'guided') { + const prepared = await new prepare_setup_pat_intent_use_case_1.PrepareSetupPatIntentUseCase({ + collect: (initial, context, pass) => (webBridge + ? new web_setup_adapters_1.WebSetupQuestionnaireCollector(webBridge, pass) + : new setup_1.SetupQuestionnaireController(terminal, new setup_question_renderer_1.ConsoleSetupQuestionRenderer('permission-intent', pass))) + .collect(initial, context), + chooseOwnerKind: () => credentialPrompt.chooseSetupOwnerKind(), + review: () => credentialPrompt.reviewSetupPatIntent(), + showPreview: ({ draft, requirements, uncertain, ownerConflict, errors, pass, projectsWanted }) => { + if (ownerConflict) + (0, logger_1.logInfo)('This plan selects organization storage or Projects, but the owner was declared a personal account. Revise the choices or use the manual PAT path.'); + if (errors.length) + (0, logger_1.logInfo)(`The selected local configuration needs correction before a guided link can be generated:\n${errors.map(item => ` - ${item}`).join('\n')}`); + if (pass > 1) + (0, logger_1.logInfo)('Choice review complete. Returning to setup PAT permission review.'); + (0, logger_1.logInfo)('Permission intent:'); + (0, logger_1.logInfo)(` Initial tag: ${draft.createInitialTag ? 'yes' : 'no'}; issue workflows: ${draft.features.issues ? draft.issueWorkflows.enabled.join(', ') || 'none' : 'disabled'}; PR approval: ${draft.pullRequestApproval.mode}`); + (0, logger_1.logInfo)(` Secrets: ${draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off'}; Variables: ${draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off'}; Projects: ${projectsWanted ? 'yes (choose exact Projects after PAT)' : 'none'}`); + webBridge?.message(`Permission preview: issue workflows ${draft.features.issues ? draft.issueWorkflows.enabled.join(', ') || 'none' : 'disabled'}; PR approval ${draft.pullRequestApproval.mode}; Secrets ${draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off'}; Variables ${draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off'}; Projects ${projectsWanted ? 'yes (choose after PAT)' : 'none'}.`, 'info', undefined, 'permission.preview', { + issues: draft.features.issues ? draft.issueWorkflows.enabled.join('|') || 'none' : 'disabled', + approval: draft.pullRequestApproval.mode, + secrets: draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off', + variables: draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off', + projects: projectsWanted ? 'yes' : 'none', + }); + permissionPresenter.showRequirements('setup', requirements); + if (uncertain.length) + (0, logger_1.logInfo)(`May need after GitHub inspection:\n${uncertain.map(item => ` - ${item}`).join('\n')}`); + }, + showDetails: requirements => permissionPresenter.showDetailedRequirements('setup', requirements), + onManual: reason => { + if (reason === 'owner-unknown') + (0, logger_1.logInfo)('Owner type was not confirmed. Use the manual PAT table, or check whether the GitHub owner is an organization before retrying guided setup.'); + if (reason === 'unsupported') + (0, logger_1.logInfo)('A guided setup PAT link is unavailable for this owner or permission set. Enter a manually created PAT using the table above.'); + credentialPrompt.useManualSetupPat(); + permissionPresenter.showDetailedRequirements('setup', setupPatPermissions); + }, + advanceToSetupPat: () => { journey?.advance('setup-pat'); }, + revisitChoices: () => journey.revisitChoices(), + }).execute({ + owner: gitInfo.owner, repository: gitInfo.repo, overrides, + skipRepositoryVariables: Boolean(options.skipVariables), + skipRepositorySecrets: Boolean(options.skipSecrets), + }); + if (prepared.kind === 'guided') { + credentialPrompt.configureSetupPatGuide(prepared.url); + setupPatPermissions = [...prepared.requirements]; + assertedOwnerKind = prepared.ownerKind; + permissionIntent = prepared.permissionIntent; + } + } + else { + journey?.advance('setup-pat'); + permissionPresenter.showDetailedRequirements('setup', setupPatPermissions); + } + } + if (options.dryRun && !token && !webBridge) + journey?.advance('plan'); + if (!token && !options.dryRun) + journey?.advance('setup-pat'); if (!token && !options.nonInteractive && !options.dryRun) token = await credentialPrompt.requestSetupPat(); if (!token && !options.dryRun) { @@ -65193,66 +67704,61 @@ function registerSetupCommand(program) { return; } if (token) { - const permissionReport = await tokenPermissions.inspect({ - role: 'setup', - owner: gitInfo.owner, - repository: gitInfo.repo, - token, - requirements: setupPatPermissions, - }); - permissionPresenter.showReport(permissionReport); - const permissionAccepted = permissionReport.ready - || (permissionReport.confirmationRequired - && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); - if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { - throw new application_error_1.ApplicationError('authorization.credential-invalid', 'The setup PAT has missing or unconfirmed required access. Grant or explicitly confirm the permissions shown above and retry.'); - } + journey?.advance('setup-pat'); + setupPatAccount = await new verify_setup_pat_bootstrap_use_case_1.VerifySetupPatBootstrapUseCase({ + permissions: tokenPermissions, + presenter: permissionPresenter, + confirmUnverifiable: report => credentialPrompt.confirmUnverifiableTokenPermissions(report), + confirmAccount: account => credentialPrompt.confirmGuidedSetupAccount(account), + showCorrectedLink: url => credentialPrompt.showUpdatedSetupPatLink(url, 'bootstrap'), + }).execute({ owner: gitInfo.owner, repository: gitInfo.repo, token, + requirements: setupPatPermissions, guided: credentialPrompt.usedGuidedSetupPat }); + journey?.advance('plan'); } (0, logger_1.logInfo)(options.dryRun ? '🧭 Building a dry-run setup plan...' : '🧭 Building your setup plan...'); - const auditConfiguredSetupPat = async (configuration, remoteConfiguration) => { - const configuredSetupPatPermissions = (0, setup_token_permission_policy_1.buildConfiguredSetupPatPermissionRequirements)(configuration, remoteConfiguration); - permissionPresenter.showRequirements('setup', configuredSetupPatPermissions); - if (!token) - return { status: 'accepted' }; - const permissionReport = await tokenPermissions.inspect({ - role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, token, - requirements: configuredSetupPatPermissions, - }); - permissionPresenter.showReport(permissionReport); - const permissionAccepted = permissionReport.ready - || (permissionReport.confirmationRequired - && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); - if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { - return { status: 'blocked', errors: [ - 'The setup PAT has missing or unconfirmed access required by the approved setup plan. Grant or explicitly confirm the permissions shown above and retry.', - ] }; - } - return { status: 'accepted' }; - }; + const auditConfiguredSetupPat = new audit_configured_setup_pat_use_case_1.AuditConfiguredSetupPatUseCase({ + owner: gitInfo.owner, repository: gitInfo.repo, token, + provisionalRequirements: setupPatPermissions, assertedOwnerKind, + guided: credentialPrompt.usedGuidedSetupPat, + }, { + permissions: tokenPermissions, + presenter: permissionPresenter, + confirmUnverifiable: report => credentialPrompt.confirmUnverifiableTokenPermissions(report), + showOwnerMismatch: (asserted, actual) => (0, logger_1.logInfo)(`The owner was declared ${asserted}, but GitHub reports ${actual}. The guided link is no longer valid for this plan.`), + showExcessGrants: grants => (0, logger_1.logInfo)(`The final plan no longer requires grants suggested earlier: ${grants.join(', ')}. Your PAT may have excess access; replace it in GitHub if least privilege is required.`), + showUpdatedLink: (url, grants) => credentialPrompt.showUpdatedSetupPatLink(url, 'final', grants), + }); const remoteConfigurationReader = (0, setup_credentials_composition_root_1.createSetupRemoteConfigurationReadPort)(); const wizard = new setup_1.SetupWizardUseCase({ - ...(terminal ? { - collector: new setup_1.SetupQuestionnaireController(terminal, new setup_question_renderer_1.ConsoleSetupQuestionRenderer()), + ...(terminal || webBridge ? { + collector: webBridge ? new web_setup_adapters_1.WebSetupQuestionnaireCollector(webBridge) + : new setup_1.SetupQuestionnaireController(terminal, new setup_question_renderer_1.ConsoleSetupQuestionRenderer()), } : {}), - planPresenter: new setup_plan_presenter_1.ConsoleSetupPlanPresenter(), + planPresenter: webBridge ? new web_setup_adapters_1.WebSetupPlanPresenter(webBridge) : new setup_plan_presenter_1.ConsoleSetupPlanPresenter(), confirmation: options.dryRun ? new setup_confirmation_adapter_1.DryRunSetupPlanConfirmation() - : new setup_confirmation_adapter_1.SetupPlanConfirmationAdapter(terminal, Boolean(options.yes)), - finalPermissionAudit: { audit: auditConfiguredSetupPat }, + : webBridge ? new web_setup_adapters_1.WebSetupPlanConfirmation(webBridge) + : new setup_confirmation_adapter_1.SetupPlanConfirmationAdapter(terminal, Boolean(options.yes)), + finalPermissionAudit: auditConfiguredSetupPat, remoteConfiguration: remoteConfigurationReader, mergeQueueReadiness: (0, setup_doctor_composition_root_1.createSetupMergeQueueReadinessUseCase)(), approvalReadiness: new setup_approval_readiness_adapter_1.GithubSetupApprovalReadinessAdapter(), + approvalCheckDiscovery: new github_setup_approval_check_discovery_adapter_1.GithubSetupApprovalCheckDiscoveryAdapter(), + projectDiscovery: new github_setup_project_discovery_adapter_1.GithubSetupProjectDiscoveryAdapter(), }); - const overrides = loadSetupOverrides(options); const result = await wizard.execute({ mode: options.nonInteractive ? 'non-interactive' : 'interactive', overrides, + ...(permissionIntent ? { permissionIntent } : {}), skipRepositoryVariables: Boolean(options.skipVariables), skipRepositorySecrets: Boolean(options.skipSecrets), previewOnly: Boolean(options.dryRun), + presentationMode, + developmentBranchObservedLocally: (0, cli_context_1.hasLocalOrTrackedGitBranch)(cwd, overrides.repository?.developmentBranch ?? 'develop'), ...(token ? { remoteTarget: { owner: gitInfo.owner, repository: gitInfo.repo, token } } : {}), }); if (result.status === 'cancelled') { + journey?.finish('cancelled'); if (result.reason !== 'questionnaire-cancelled') { (0, logger_1.logInfo)('⏭️ Setup cancelled. No changes were applied.'); } @@ -65261,6 +67767,8 @@ function registerSetupCommand(program) { return; } if (result.status === 'blocked') { + journey?.finish('blocked'); + webBridge?.resultReason(result.reason === 'setup-permissions-unavailable' ? 'permissions' : 'storage'); (0, logger_1.logError)(new application_error_1.ApplicationError(result.reason === 'setup-permissions-unavailable' ? 'authorization.credential-invalid' : 'provider.unavailable', `${result.reason === 'setup-permissions-unavailable' ? 'Setup is blocked by missing or unconfirmed PAT permissions:' : 'Setup is blocked by unavailable remote storage:'}\n${result.errors.map(error => `- ${error}`).join('\n')}`)); @@ -65268,6 +67776,8 @@ function registerSetupCommand(program) { return; } const { configuration, remoteConfiguration } = result; + const guardedFiles = webBridge ? (0, setup_configuration_plan_1.setupPlanGuardPaths)(result.plan) : undefined; + const webApplySnapshot = guardedFiles ? (0, setup_apply_snapshot_1.captureSetupApplySnapshot)(checkoutRoot, guardedFiles) : undefined; const credentialRequirements = (0, setup_configuration_policy_1.buildSetupCredentialRequirements)(configuration); const workflowComparisons = new setup_workspace_adapter_1.SetupDoctorWorkspaceQueryAdapter().compareWorkflows((0, setup_configuration_policy_1.effectiveIssueWorkflowFeatures)(configuration), configuration); const updateWorkflows = await workflowPrompt.confirmWorkflowUpdates(workflowComparisons, Boolean(options.updateWorkflows)); @@ -65275,10 +67785,36 @@ function registerSetupCommand(program) { ? workflowComparisons.filter(comparison => comparison.status === 'changed').map(comparison => comparison.file) : []; if (options.dryRun) { + if (webBridge) + journey?.advance('plan'); + journey?.finish('dry-run'); (0, logger_1.logInfo)('✅ Dry run complete. No files or GitHub resources were changed.'); return; } - const credentials = await (0, setup_credentials_composition_root_1.createSetupCredentialsUseCase)(credentialPrompt, permissionPresenter).collect({ + journey?.advance('credentials'); + const workflowTokenPermissions = (0, setup_token_permission_policy_1.buildWorkflowPatPermissionRequirements)(configuration, remoteConfiguration); + const githubIdentities = new setup_github_identity_query_adapter_1.SetupGithubIdentityQueryAdapter(); + if (!options.nonInteractive && !options.workflowPat && !options.secret?.PAT) { + try { + const workflowPatGuide = (0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'workflow', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 90, + requirements: workflowTokenPermissions, + }); + credentialPrompt.configureWorkflowPatGuide(workflowPatGuide, login => githubIdentities.resolve(login, token), workflowTokenPermissions); + } + catch (error) { + if (!(error instanceof setup_pat_creation_url_policy_1.UnsupportedSetupPatLinkError)) + throw error; + (0, logger_1.logInfo)('A guided fine-grained bot PAT link is unavailable for one or more required permissions. Use the permission table and manual path; review whether a classic PAT is required for this plan.'); + permissionPresenter.showDetailedRequirements('workflow', workflowTokenPermissions); + } + } + const credentials = await (0, setup_credentials_composition_root_1.createSetupCredentialsUseCase)(credentialPrompt, permissionPresenter, webBridge ? { allowPreApplyHealthWorkflow: false } : { + onTemporaryWorkflowMutationAttempt: () => { + setupMutationStarted = true; + journey?.markMutationStarted(); + }, + }).collect({ owner: gitInfo.owner, repository: gitInfo.repo, setupToken: token ?? '', @@ -65287,17 +67823,99 @@ function registerSetupCommand(program) { secretStoragePolicy: configuration.storage.secrets, ref: configuration.repository.mainBranch, remoteConfiguration, - workflowTokenPermissions: (0, setup_token_permission_policy_1.buildWorkflowPatPermissionRequirements)(configuration, remoteConfiguration), + workflowTokenPermissions, }); + const guidedBotIdentity = credentialPrompt.guidedWorkflowBotIdentity; + if (guidedBotIdentity && credentials.collection.workflowPat) { + const verifiedBot = await new verify_guided_workflow_pat_identity_use_case_1.VerifyGuidedWorkflowPatIdentityUseCase(githubIdentities) + .execute(guidedBotIdentity, credentials.collection.workflowPat.value); + (0, logger_1.logInfo)(`✅ Workflow PAT owner verified as @${verifiedBot.login} (GitHub account ID ${verifiedBot.id}).`); + if (setupPatAccount?.toLowerCase() === verifiedBot.login.toLowerCase()) { + (0, logger_1.logInfo)('The workflow PAT and setup PAT use the same GitHub account. If this account authors PRs, bot-generated events and guarded self-approval may not behave as intended; use a dedicated bot account where required.'); + } + } + if (webBridge) { + if (!remoteConfiguration || !guardedFiles || !webApplySnapshot || !initialBranch || !initialHead || !token) { + throw new application_error_1.ApplicationError('configuration.invalid', 'The approved setup evidence is incomplete. No mutation started; restart and review a new plan.'); + } + const authorization = await new verify_web_setup_apply_use_case_1.VerifyWebSetupApplyUseCase({ + confirm: async () => { + const answer = await webBridge.ask({ kind: 'confirm', title: 'Apply this setup now?', copyId: 'apply.confirm', + description: 'This is the final approval. Local files and selected GitHub resources may change. A partial result may require inspection before retrying.', + choices: ['Apply setup', 'Stop without applying'] }); + return answer === undefined ? undefined : answer === 'Apply setup' ? 'apply' : 'stop'; + }, + readRepositoryFacts: () => { + const current = (0, cli_context_1.getGitInfo)(); + return 'error' in current ? undefined : { + owner: current.owner, repository: current.repo, checkoutRoot: (0, cli_context_1.getGitRepositoryRoot)(cwd), + branch: (0, cli_context_1.getCurrentAttachedBranch)(cwd) ?? '', head: (0, cli_context_1.getCurrentHeadSha)() ?? '', + }; + }, + fileSnapshotMatches: setup_apply_snapshot_1.setupApplySnapshotMatches, + remote: remoteConfigurationReader, + permissionAudit: auditConfiguredSetupPat, + sessionState: () => webBridge.snapshot().outcome === 'cancelled' ? 'cancelled' + : webBridge.snapshot().outcome ? 'ended' : 'active', + }).execute({ + repository: { owner: gitInfo.owner, repository: gitInfo.repo, checkoutRoot, + branch: initialBranch, head: initialHead }, + selectedFiles: guardedFiles, fileSnapshot: webApplySnapshot, approvedRemote: remoteConfiguration, + configuration, setupToken: token, + }); + if (authorization === 'cancelled') + throw new setup_credential_prompt_adapter_1.SetupTerminalCancelledError(); + if (authorization === 'declined') { + journey?.finish('cancelled'); + return; + } + } (0, logger_1.logInfo)('⚙️ Applying the approved setup plan...'); + journey?.advance('apply'); const params = (0, setup_policy_1.buildSetupParams)(options, gitInfo, token ?? '', configuration, credentials.collection, approvedWorkflowFiles, remoteConfiguration); - if (!params) - return; - await (0, local_action_1.runLocalAction)(params); + setupMutationStarted = true; + journey?.markMutationStarted(); + setupApplyStarted = true; + const actionResults = await (0, local_action_1.runLocalAction)(params); + webBridge?.effects((0, setup_result_receipt_1.setupResultEffects)(actionResults)); + if (actionResults.some(actionResult => !actionResult.success || actionResult.errors.length > 0)) { + const failure = (0, setup_result_receipt_1.setupActionResultFailure)(actionResults); + if (failure) + webBridge?.resultReason(failure.reasonCode, failure.diagnosticRef); + journey?.finish('partial'); + (0, logger_1.logInfo)('Setup reported failures or partial completion. If a bot PAT was supplied, its Secret may already have been written; inspect the result and GitHub Secret name/scope before retrying or revoking it.'); + process.exitCode = 1; + } + else { + if (webBridge && token) { + const doctorToken = token; + webBridge.configureReadOnlyDoctor(async () => { + const diagnosis = await (0, setup_doctor_composition_root_1.createSetupDoctorUseCase)().execute({ owner: gitInfo.owner, + repository: gitInfo.repo, setupToken: doctorToken, configuration, readOnly: true }); + return { healthy: diagnosis.report.healthy, ...diagnosis.report.totals }; + }); + } + journey?.finish('complete'); + } } catch (error) { + journey?.finish(setupMutationStarted ? 'partial' : error instanceof setup_credential_prompt_adapter_1.SetupTerminalCancelledError ? 'cancelled' : 'blocked'); + const normalizedError = error instanceof setup_credential_prompt_adapter_1.SetupTerminalCancelledError ? undefined + : (0, application_error_1.toApplicationError)(error, 'workflow.failed', 'Setup failed.'); + webBridge?.resultReason(error instanceof setup_credential_prompt_adapter_1.SetupTerminalCancelledError ? 'cancelled' + : (0, setup_result_receipt_1.setupResultReason)(normalizedError.code), normalizedError?.correlationId); + if (setupMutationStarted && !setupApplyStarted) { + (0, logger_1.logInfo)('A temporary credential-health workflow create was attempted before Apply. Inspect the selected branch and GitHub workflow history before retrying; a failed request may still have reached GitHub.'); + } + if (credentialPrompt.guidedWorkflowBotIdentity) { + (0, logger_1.logInfo)(setupApplyStarted + ? 'Setup may be partially applied. Inspect the GitHub Secret before deleting or replacing the bot PAT.' + : 'No bot Secret write started. If you generated an unused bot PAT in GitHub, delete it there; Copilot cannot revoke it.'); + } if (error instanceof setup_credential_prompt_adapter_1.SetupTerminalCancelledError) { - (0, logger_1.logInfo)('Setup cancelled. No changes were applied.'); + (0, logger_1.logInfo)(setupMutationStarted + ? 'Setup stopped after a possible credential-health workflow change. Inspect the selected branch and GitHub workflow history before retrying.' + : 'Setup cancelled. No changes were applied.'); process.exitCode = 130; return; } @@ -65305,141 +67923,22 @@ function registerSetupCommand(program) { process.exitCode = 1; } finally { + credentialPrompt.showSetupPatCleanupReminder(); terminal?.close(); + if (webBridge && webServer) { + const outcome = webBridge.snapshot().journey?.outcome ?? (process.exitCode ? 'blocked' : 'cancelled'); + webBridge.finish(outcome, outcome === 'complete' + ? 'Setup completed. Delete the temporary setup PAT in GitHub; keep the bot PAT while its Secret is in use.' + : outcome === 'dry-run' ? 'Dry run complete. No files or GitHub resources changed.' + : outcome === 'partial' ? 'Setup may be partial. Inspect GitHub resources and run copilot doctor --read-only before retrying.' + : 'No further setup changes will be applied. Any PAT already created in GitHub still exists until you delete it there.'); + (0, logger_1.logInfo)('The local browser page shows the result. Choose “Close local session” there, or stop this command with Ctrl+C.'); + await webServer.closed; + } + releaseSetupGuard?.(); } }); } -function collectSecret(value, previous) { - const separator = value.indexOf('='); - if (separator <= 0) - throw new Error('--secret must use NAME=VALUE syntax.'); - const name = value.slice(0, separator).trim(); - const secret = value.slice(separator + 1); - if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !secret) - throw new Error('--secret must use a non-empty NAME=VALUE with an uppercase secret name.'); - return { ...previous, [name]: secret }; -} -function collectApprovalCheck(value, previous) { - return [...previous, value]; -} -function loadSetupOverrides(options) { - const fromFile = options.config ? (0, setup_config_file_1.loadSetupConfigurationOverrides)(options.config) : {}; - const fromFlags = {}; - if (options.prApprovalMode || options.prApprovalCheck?.length || options.prApprovalCoverageCheck || options.prApprovalAttestProducer) { - if (options.prApprovalMode && !['off', 'recommend', 'guarded'].includes(options.prApprovalMode)) { - throw new Error('--pr-approval-mode must be guarded, recommend, or off.'); - } - const checks = options.prApprovalCheck?.map(value => { - const [name, appId, workflowName] = value.split('|').map(item => item.trim()); - return { name, sourceAppId: Number(appId), workflowName }; - }); - fromFlags.pullRequestApproval = { - ...(options.prApprovalMode ? { mode: options.prApprovalMode } : {}), - ...(checks?.length ? { testChecks: checks } : {}), - ...(options.prApprovalAttestProducer ? { producerAttested: true } : {}), - ...(options.prApprovalCoverageCheck ? { coverage: { mode: 'check', checkName: options.prApprovalCoverageCheck } } : {}), - }; - } - if (options.agent) { - if (!['codex', 'opencode', 'cursor'].includes(options.agent)) { - throw new Error('--agent must be one of: codex, opencode, cursor.'); - } - fromFlags.agents = Object.fromEntries(['planner', 'findings', 'reviewer', 'fixer', 'tester'].map(task => [task, { provider: options.agent }])); - } - if (options.features) { - if (options.features.trim().toLowerCase() === 'all') { - fromFlags.features = Object.fromEntries(Object.keys(setup_configuration_policy_1.SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, true])); - } - else { - const requested = options.features.split(',').map(feature => feature.trim()).filter(Boolean); - const unknown = requested.filter(feature => !Object.prototype.hasOwnProperty.call(setup_configuration_policy_1.SETUP_FEATURE_DESCRIPTIONS, feature)); - if (unknown.length > 0) - throw new Error(`Unknown setup feature(s): ${unknown.join(', ')}.`); - fromFlags.features = Object.fromEntries(Object.keys(setup_configuration_policy_1.SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, requested.includes(feature)])); - } - } - if (options.issueWorkflows) { - const raw = options.issueWorkflows.trim().toLowerCase(); - const requested = raw === 'all' ? [...issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS] : raw.split(',').map(item => item.trim()).filter(Boolean); - const unknown = requested.filter(item => !issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS.includes(item)); - if (unknown.length > 0) - throw new Error(`Unknown issue workflow(s): ${unknown.join(', ')}.`); - if (new Set(requested).size !== requested.length) - throw new Error('Issue workflow selection cannot contain duplicates.'); - fromFlags.issueWorkflows = { enabled: requested }; - } - if (options.agentGuidance) { - const mode = options.agentGuidance.trim().toLowerCase(); - if (!['prompt', 'create-if-missing', 'disabled'].includes(mode)) - throw new Error('--agent-guidance must be prompt, create-if-missing, or disabled.'); - fromFlags.repositoryAgentGuidance = { agentsPointer: mode, enabled: mode !== 'disabled' }; - } - const storage = {}; - if (options.variablesScope || options.variablesVisibility || Object.keys(options.variableScope ?? {}).length > 0) { - storage.variables = { - ...(options.variablesScope ? { defaultScope: parseScope(options.variablesScope, '--variables-scope') } : {}), - ...(options.variablesVisibility ? { organizationVisibility: parseVisibility(options.variablesVisibility, '--variables-visibility') } : {}), - ...(Object.keys(options.variableScope ?? {}).length > 0 ? { overrides: options.variableScope } : {}), - }; - } - if (options.secretsScope || options.secretsVisibility || Object.keys(options.secretScope ?? {}).length > 0) { - storage.secrets = { - ...(options.secretsScope ? { defaultScope: parseScope(options.secretsScope, '--secrets-scope') } : {}), - ...(options.secretsVisibility ? { organizationVisibility: parseVisibility(options.secretsVisibility, '--secrets-visibility') } : {}), - ...(Object.keys(options.secretScope ?? {}).length > 0 ? { overrides: options.secretScope } : {}), - }; - } - if (Object.keys(storage).length > 0) - fromFlags.storage = storage; - return mergeSetupOverrides(fromFile, fromFlags); -} -function mergeSetupOverrides(fileOverrides, flagOverrides) { - return { - ...fileOverrides, - ...flagOverrides, - features: { ...fileOverrides.features, ...flagOverrides.features }, - agents: { ...fileOverrides.agents, ...flagOverrides.agents }, - repository: { ...fileOverrides.repository, ...flagOverrides.repository }, - ai: { ...fileOverrides.ai, ...flagOverrides.ai }, - pullRequestApproval: { - ...fileOverrides.pullRequestApproval, - ...flagOverrides.pullRequestApproval, - coverage: { ...fileOverrides.pullRequestApproval?.coverage, ...flagOverrides.pullRequestApproval?.coverage }, - }, - projects: { ...fileOverrides.projects, ...flagOverrides.projects }, - issueWorkflows: { ...fileOverrides.issueWorkflows, ...flagOverrides.issueWorkflows }, - repositoryAgentGuidance: { ...fileOverrides.repositoryAgentGuidance, ...flagOverrides.repositoryAgentGuidance }, - storage: { - ...fileOverrides.storage, - ...flagOverrides.storage, - secrets: { ...fileOverrides.storage?.secrets, ...flagOverrides.storage?.secrets, overrides: { ...fileOverrides.storage?.secrets?.overrides, ...flagOverrides.storage?.secrets?.overrides } }, - variables: { ...fileOverrides.storage?.variables, ...flagOverrides.storage?.variables, overrides: { ...fileOverrides.storage?.variables?.overrides, ...flagOverrides.storage?.variables?.overrides } }, - }, - }; -} -function collectScope(value, previous) { - const separator = value.indexOf('='); - if (separator <= 0) - throw new Error('Scope overrides must use NAME=repository or NAME=organization syntax.'); - const name = value.slice(0, separator).trim(); - const scope = value.slice(separator + 1).trim().toLowerCase(); - if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !['repository', 'organization'].includes(scope)) { - throw new Error('Scope overrides must use an uppercase NAME and repository or organization scope.'); - } - return { ...previous, [name]: scope }; -} -function parseScope(value, flag) { - const normalized = value.trim().toLowerCase(); - if (normalized !== 'repository' && normalized !== 'organization') - throw new Error(`${flag} must be repository or organization.`); - return normalized; -} -function parseVisibility(value, flag) { - const normalized = value.trim().toLowerCase(); - if (!['all', 'private', 'selected'].includes(normalized)) - throw new Error(`${flag} must be selected, private, or all.`); - return normalized; -} /***/ }), @@ -65455,8 +67954,6 @@ const action_types_1 = __nccwpck_require__(19625); const input_keys_1 = __nccwpck_require__(88539); const setup_configuration_policy_1 = __nccwpck_require__(56637); function buildSetupParams(options, gitInfo, token, configuration, credentials, approvedWorkflowFiles = [], remoteConfiguration) { - if ('error' in gitInfo) - return undefined; return { ...(configuration ? (0, setup_configuration_policy_1.buildSetupActionInputs)(configuration) : {}), [input_keys_1.INPUT_KEYS.DEBUG]: options.debug?.toString() ?? 'false', @@ -65597,6 +68094,191 @@ function registerUpgradeCommand(program) { } +/***/ }), + +/***/ 84136: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.captureSetupApplySnapshot = captureSetupApplySnapshot; +exports.setupApplySnapshotMatches = setupApplySnapshotMatches; +const node_crypto_1 = __nccwpck_require__(6005); +const node_fs_1 = __nccwpck_require__(87561); +const node_path_1 = __nccwpck_require__(49411); +/** Captures only the selected setup paths. Missing files are part of the snapshot. */ +function captureSetupApplySnapshot(repositoryRoot, selectedFiles) { + const root = (0, node_path_1.resolve)(repositoryRoot); + const result = {}; + for (const name of [...new Set(selectedFiles)].sort()) { + const path = (0, node_path_1.resolve)(root, name); + const inside = (0, node_path_1.relative)(root, path); + if ((0, node_path_1.isAbsolute)(name) || !inside || inside === '..' || inside.startsWith(`..${node_path_1.sep}`)) { + throw new Error('The setup plan contains a path outside the repository.'); + } + // A lexically in-repository path can still escape through a parent symlink. + let prefix = root; + for (const segment of inside.split(node_path_1.sep)) { + prefix = (0, node_path_1.resolve)(prefix, segment); + try { + if ((0, node_fs_1.lstatSync)(prefix).isSymbolicLink()) + throw new Error(`Setup path ${name} traverses a symbolic link.`); + } + catch (cause) { + if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 'ENOENT') + break; + throw cause; + } + } + try { + const stat = (0, node_fs_1.lstatSync)(path); + if (stat.isFile() && stat.size <= 5 * 1024 * 1024) { + result[name] = `file:${(0, node_crypto_1.createHash)('sha256').update((0, node_fs_1.readFileSync)(path)).digest('hex')}`; + } + else + throw new Error(`Cannot safely snapshot setup file ${name}.`); + } + catch (cause) { + if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 'ENOENT') + result[name] = 'missing'; + else + throw cause; + } + } + return result; +} +function setupApplySnapshotMatches(repositoryRoot, selectedFiles, expected) { + const current = captureSetupApplySnapshot(repositoryRoot, selectedFiles); + return JSON.stringify(current) === JSON.stringify(expected); +} + + +/***/ }), + +/***/ 99254: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.collectSecret = collectSecret; +exports.collectApprovalCheck = collectApprovalCheck; +exports.loadSetupOverrides = loadSetupOverrides; +exports.collectScope = collectScope; +const setup_config_file_1 = __nccwpck_require__(11196); +const setup_configuration_policy_1 = __nccwpck_require__(56637); +const merge_setup_overrides_policy_1 = __nccwpck_require__(39267); +const issue_workflow_profile_1 = __nccwpck_require__(26744); +function collectSecret(value, previous) { + const separator = value.indexOf('='); + if (separator <= 0) + throw new Error('--secret must use NAME=VALUE syntax.'); + const name = value.slice(0, separator).trim(); + const secret = value.slice(separator + 1); + if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !secret) + throw new Error('--secret must use a non-empty NAME=VALUE with an uppercase secret name.'); + return { ...previous, [name]: secret }; +} +function collectApprovalCheck(value, previous) { + return [...previous, value]; +} +function loadSetupOverrides(options) { + const fromFile = options.config ? (0, setup_config_file_1.loadSetupConfigurationOverrides)(options.config) : {}; + const fromFlags = {}; + if (options.prApprovalMode || options.prApprovalCheck?.length || options.prApprovalCoverageCheck || options.prApprovalAttestProducer) { + if (options.prApprovalMode && !['off', 'recommend', 'guarded'].includes(options.prApprovalMode)) { + throw new Error('--pr-approval-mode must be guarded, recommend, or off.'); + } + const checks = options.prApprovalCheck?.map(value => { + const [name, appId, workflowName] = value.split('|').map(item => item.trim()); + return { name, sourceAppId: Number(appId), workflowName }; + }); + fromFlags.pullRequestApproval = { + ...(options.prApprovalMode ? { mode: options.prApprovalMode } : {}), + ...(checks?.length ? { testChecks: checks } : {}), + ...(options.prApprovalAttestProducer ? { producerAttested: true } : {}), + ...(options.prApprovalCoverageCheck ? { coverage: { mode: 'check', checkName: options.prApprovalCoverageCheck } } : {}), + }; + } + if (options.agent) { + if (!['codex', 'opencode', 'cursor'].includes(options.agent)) { + throw new Error('--agent must be one of: codex, opencode, cursor.'); + } + fromFlags.agents = Object.fromEntries(['planner', 'findings', 'reviewer', 'fixer', 'tester'].map(task => [task, { provider: options.agent }])); + } + if (options.features) { + if (options.features.trim().toLowerCase() === 'all') { + fromFlags.features = Object.fromEntries(Object.keys(setup_configuration_policy_1.SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, true])); + } + else { + const requested = options.features.split(',').map(feature => feature.trim()).filter(Boolean); + const unknown = requested.filter(feature => !Object.prototype.hasOwnProperty.call(setup_configuration_policy_1.SETUP_FEATURE_DESCRIPTIONS, feature)); + if (unknown.length > 0) + throw new Error(`Unknown setup feature(s): ${unknown.join(', ')}.`); + fromFlags.features = Object.fromEntries(Object.keys(setup_configuration_policy_1.SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, requested.includes(feature)])); + } + } + if (options.issueWorkflows) { + const raw = options.issueWorkflows.trim().toLowerCase(); + const requested = raw === 'all' ? [...issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS] : raw.split(',').map(item => item.trim()).filter(Boolean); + const unknown = requested.filter(item => !issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS.includes(item)); + if (unknown.length > 0) + throw new Error(`Unknown issue workflow(s): ${unknown.join(', ')}.`); + if (new Set(requested).size !== requested.length) + throw new Error('Issue workflow selection cannot contain duplicates.'); + fromFlags.issueWorkflows = { enabled: requested }; + } + if (options.agentGuidance) { + const mode = options.agentGuidance.trim().toLowerCase(); + if (!['prompt', 'create-if-missing', 'disabled'].includes(mode)) + throw new Error('--agent-guidance must be prompt, create-if-missing, or disabled.'); + fromFlags.repositoryAgentGuidance = { agentsPointer: mode, enabled: mode !== 'disabled' }; + } + const storage = {}; + if (options.variablesScope || options.variablesVisibility || Object.keys(options.variableScope ?? {}).length > 0) { + storage.variables = { + ...(options.variablesScope ? { defaultScope: parseScope(options.variablesScope, '--variables-scope') } : {}), + ...(options.variablesVisibility ? { organizationVisibility: parseVisibility(options.variablesVisibility, '--variables-visibility') } : {}), + ...(Object.keys(options.variableScope ?? {}).length > 0 ? { overrides: options.variableScope } : {}), + }; + } + if (options.secretsScope || options.secretsVisibility || Object.keys(options.secretScope ?? {}).length > 0) { + storage.secrets = { + ...(options.secretsScope ? { defaultScope: parseScope(options.secretsScope, '--secrets-scope') } : {}), + ...(options.secretsVisibility ? { organizationVisibility: parseVisibility(options.secretsVisibility, '--secrets-visibility') } : {}), + ...(Object.keys(options.secretScope ?? {}).length > 0 ? { overrides: options.secretScope } : {}), + }; + } + if (Object.keys(storage).length > 0) + fromFlags.storage = storage; + return (0, merge_setup_overrides_policy_1.mergeSetupOverrides)(fromFile, fromFlags); +} +function collectScope(value, previous) { + const separator = value.indexOf('='); + if (separator <= 0) + throw new Error('Scope overrides must use NAME=repository or NAME=organization syntax.'); + const name = value.slice(0, separator).trim(); + const scope = value.slice(separator + 1).trim().toLowerCase(); + if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !['repository', 'organization'].includes(scope)) { + throw new Error('Scope overrides must use an uppercase NAME and repository or organization scope.'); + } + return { ...previous, [name]: scope }; +} +function parseScope(value, flag) { + const normalized = value.trim().toLowerCase(); + if (normalized !== 'repository' && normalized !== 'organization') + throw new Error(`${flag} must be repository or organization.`); + return normalized; +} +function parseVisibility(value, flag) { + const normalized = value.trim().toLowerCase(); + if (!['all', 'private', 'selected'].includes(normalized)) + throw new Error(`${flag} must be selected, private, or all.`); + return normalized; +} + + /***/ }), /***/ 11196: @@ -65907,6 +68589,7 @@ function containsCredentialMaterial(value, insideStorage = false) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DryRunSetupPlanConfirmation = exports.SetupPlanConfirmationAdapter = void 0; const setup_prompt_rendering_1 = __nccwpck_require__(83434); +const setup_questionnaire_policy_1 = __nccwpck_require__(6009); class SetupPlanConfirmationAdapter { constructor(terminal, assumeYes) { this.terminal = terminal; @@ -65917,14 +68600,38 @@ class SetupPlanConfirmationAdapter { return { kind: 'approved' }; if (!this.terminal) return { kind: 'declined' }; - const target = plan.configuration.manageRepositoryVariables - ? 'the repository and GitHub Variables' - : 'the repository'; + const target = plan.configuration.manageRepositoryVariables || plan.configuration.manageRepositorySecrets + ? 'repository files and selected GitHub Actions resources' + : 'repository files'; + const groups = (0, setup_questionnaire_policy_1.setupEditableGroups)(plan.configuration); while (true) { - const result = await this.terminal.readText(`Apply this setup plan to ${target}? ${(0, setup_prompt_rendering_1.color)('[N]', 90)}: `); + const result = await this.terminal.readText(`Apply this setup plan to ${target}? Type ? for details or :edit to change an answer. ${(0, setup_prompt_rendering_1.color)('[N]', 90)}: `); if (result.kind !== 'value') return { kind: 'cancelled' }; const value = result.value.normalize('NFKC').trim().toLowerCase(); + if (value === '?') { + console.log((0, setup_prompt_rendering_1.renderBox)([ + `This is the final approval. The plan lists ${plan.selectedFiles.length} file(s), ${plan.variables.length} Variable(s), and ${plan.requiredSecrets.length} Secret name(s).`, + 'Yes starts the listed local and GitHub setup writes. No leaves the plan unapplied.', + 'A failure after writes begin may leave partial changes; inspect the result and run copilot doctor --read-only before retrying.', + 'PATs created on GitHub are not deleted automatically if you decline or cancel.', + 'Read more: https://docs.page/vypdev/copilot/how-to-use', + ].join('\n'), 'Before applying setup')); + continue; + } + if (value === ':edit') { + console.log(groups.map((group, index) => ` ${index + 1}) ${(0, setup_questionnaire_policy_1.setupQuestionnaireStateLabel)(group)}`).join('\n')); + const selected = await this.terminal.readText('Choose a section number (empty returns to the plan): '); + if (selected.kind !== 'value') + return { kind: 'cancelled' }; + const index = Number(selected.value.trim()) - 1; + if (/^[1-9]\d*$/u.test(selected.value.trim()) && Number.isSafeInteger(index) && groups[index]) { + return { kind: 'revise', group: groups[index] }; + } + if (selected.value.trim()) + console.log((0, setup_prompt_rendering_1.color)('Choose one of the listed section numbers.', 33)); + continue; + } if (!value || ['n', 'no', 'false', '0'].includes(value)) return { kind: 'declined' }; if (['y', 'yes', 'true', '1'].includes(value)) @@ -65952,23 +68659,102 @@ exports.DryRunSetupPlanConfirmation = DryRunSetupPlanConfirmation; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupCredentialPromptAdapter = exports.SetupTerminalCancelledError = void 0; const setup_prompt_rendering_1 = __nccwpck_require__(83434); -class SetupTerminalCancelledError extends Error { - constructor() { - super('Setup input was cancelled.'); - this.name = 'SetupTerminalCancelledError'; - } -} -exports.SetupTerminalCancelledError = SetupTerminalCancelledError; +const setup_token_permission_presenter_1 = __nccwpck_require__(63206); +const setup_interaction_cancelled_error_1 = __nccwpck_require__(38313); +/** @deprecated Use the presentation-neutral cancellation signal in new adapters. */ +exports.SetupTerminalCancelledError = setup_interaction_cancelled_error_1.SetupInteractionCancelledError; +const AUTHENTICATION_GUIDE = 'https://docs.page/vypdev/copilot/authentication'; +const GITHUB_PAT_SETTINGS = 'https://github.com/settings/personal-access-tokens'; class SetupCredentialPromptAdapter { constructor(terminal, credentialValues, confirmUnverifiableWritePermissions = false) { this.terminal = terminal; this.credentialValues = credentialValues; this.confirmUnverifiableWritePermissions = confirmUnverifiableWritePermissions; + this.guidedSetup = false; + this.setupMethodChosen = false; + } + configureSetupPatGuide(url) { this.setupPatGuide = url; } + get usedGuidedSetupPat() { return this.guidedSetup; } + async chooseSetupPresentationMode() { + if (!this.terminal) + return 'custom'; + const choice = await this.readChoice('How much configuration detail would you like to review now?', ['Basic guided setup', 'Customize every setting'], 'Basic guided setup', 'Basic keeps every permission, security, branch-role, Projects, approval, and storage decision visible. It uses existing defaults for selected advanced agent, branch-prefix, and Bugbot settings. The final plan shows their consequences and lets you edit any section before Apply. Customize asks every applicable question. Neither path changes GitHub before your final approval.'); + return choice === 'Basic guided setup' ? 'basic' : 'custom'; + } + async chooseSetupPatMethod() { + if (!this.terminal) + return 'manual'; + this.setupMethodChosen = true; + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link', `Guided opens GitHub's official fine-grained PAT form with proposed grants. Manual means you create the PAT yourself and enter it here. In either case GitHub handles account sign-in and 2FA; Copilot never revokes the token automatically.\nRead more: ${AUTHENTICATION_GUIDE}`)) === 'guided link'; + return this.guidedSetup ? 'guided' : 'manual'; + } + useManualSetupPat() { this.guidedSetup = false; this.setupPatGuide = undefined; this.setupMethodChosen = true; } + async chooseSetupOwnerKind() { + if (!this.terminal) + return 'unknown'; + const choice = await this.readChoice('Is the GitHub repository owner an organization or a personal account?', ['organization', 'personal account', 'not sure'], undefined, `The owner is the name before / in owner/repository. Organization-owned repositories can require organization-level grants or SSO approval; a personal account cannot. Check the repository header on GitHub if unsure.\nRead more: ${AUTHENTICATION_GUIDE}`); + return choice === 'organization' ? 'Organization' : choice === 'personal account' ? 'User' : 'unknown'; + } + async reviewSetupPatIntent() { + if (!this.terminal) + return 'manual'; + const choice = await this.readChoice('Review these intended grants before opening GitHub. What would you like to do?', ['continue to GitHub', 'review all setup choices again', 'view full permission table', 'enter a PAT manually'], undefined, `These grants are provisional: your choices and GitHub visibility determine the final least-privilege PAT permissions. Reviewing choices does not restart this setup run or apply changes.\nRead more: ${AUTHENTICATION_GUIDE}`); + if (choice === 'review all setup choices again') + return 'revise'; + if (choice === 'view full permission table') + return 'details'; + if (choice === 'enter a PAT manually') + return 'manual'; + return 'continue'; + } + configureWorkflowPatGuide(url, resolveIdentity, requirements) { + this.workflowPatGuide = url; + this.resolveBotIdentity = resolveIdentity; + this.workflowPatRequirements = requirements; + } + get guidedWorkflowBotIdentity() { return this.guidedBotIdentity; } + async confirmGuidedSetupAccount(account) { + if (!this.guidedSetup || !this.terminal) + return true; + if (!account || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(account)) + return false; + console.log(`GitHub authenticated the setup PAT as @${account}.`); + return (await this.readChoice('Is this the account you intended to configure with?', ['yes', 'no'], 'yes', `Use the operator account that is authorized to configure this repository and organization. A different account's PAT may have different access even if the form looked correct. Select no to stop safely.\nRead more: ${AUTHENTICATION_GUIDE}`)) === 'yes'; + } + showSetupPatCleanupReminder() { + if (!this.guidedSetup || !this.setupPatGuide) + return; + console.log((0, setup_prompt_rendering_1.renderBox)('The setup PAT was not revoked automatically. After setup finishes or is cancelled, delete it in GitHub → Settings → Developer settings → Personal access tokens. Ending this process does not remove the token from GitHub.', 'Revoke temporary setup PAT', 33)); + console.log('https://github.com/settings/personal-access-tokens'); + } + showUpdatedSetupPatLink(url, stage, delta) { + if (!this.guidedSetup) + return; + console.log((0, setup_prompt_rendering_1.renderBox)(stage === 'bootstrap' + ? 'The setup PAT did not pass the initial access check; no setup plan has been applied. Review its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.' + : 'The selected plan requires access this PAT did not prove; no plan mutation has started. Update its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.', stage === 'bootstrap' ? 'Setup PAT access needs attention' : 'Setup PAT permissions changed', 33)); + if (delta?.length) + console.log(delta.map(item => ` - ${item}`).join('\n')); + console.log(url); } async requestSetupPat() { if (!this.terminal) return undefined; + if (this.setupPatGuide && !this.setupMethodChosen) { + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link', `Guided opens GitHub's official form; manual uses a PAT you made yourself. Both are entered only into this local command.\nRead more: ${AUTHENTICATION_GUIDE}`)) === 'guided link'; + if (this.guidedSetup) { + console.log((0, setup_prompt_rendering_1.renderBox)('Provisional link: Open this GitHub link in your browser, sign in as the account configuring this repository, complete any 2FA or SSO, and review the prefilled fine-grained permissions. GitHub owns token creation; Copilot never handles your web session. Change All repositories to Only select repositories and select ONLY this repository. Remote inspection may require a corrected token later.', 'Create setup PAT in GitHub', 33)); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } + } + if (this.setupMethodChosen && this.guidedSetup && this.setupPatGuide) { + console.log((0, setup_prompt_rendering_1.renderBox)('Open this GitHub link as the account configuring this repository; complete any 2FA or SSO. Review the prefilled grants. Change All repositories to Only select repositories and select ONLY this repository. GitHub creates the PAT; Copilot does not handle your browser session. Remote inspection may require a corrected token later.', 'Create setup PAT in GitHub', 33)); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } console.log((0, setup_prompt_rendering_1.renderBox)('Enter a GitHub setup PAT. It is used in memory for this run only and is never stored. The workflow PAT is a different bot-account token and is requested separately.', 'Setup PAT', 33)); + console.log(`PAT creation and cleanup: ${AUTHENTICATION_GUIDE}\nGitHub PAT settings: ${GITHUB_PAT_SETTINGS}`); return this.readSecret('Setup PAT'); } async confirmUnverifiableTokenPermissions(report) { @@ -65992,7 +68778,7 @@ class SetupCredentialPromptAdapter { `Confirm that the PAT was configured exactly as shown above? ${(0, setup_prompt_rendering_1.color)('[N]', 90)}: `, ].join('\n')); if (result.kind !== 'value') - throw new SetupTerminalCancelledError(); + throw new exports.SetupTerminalCancelledError(); const value = result.value.normalize('NFKC').trim().toLowerCase(); if (!value || ['n', 'no', 'false', '0'].includes(value)) return false; @@ -66006,10 +68792,48 @@ class SetupCredentialPromptAdapter { return; console.log((0, setup_prompt_rendering_1.renderBox)('The workflow PAT is not the setup PAT. Runtime credentials are stored remotely as GitHub Actions Secrets. GitHub never reveals existing Secret values; health is checked through the repository workflow.', 'Workflow credentials', 33)); console.log(`Credential options: ${requirements.map((requirement) => requirement.name).join(', ')}`); + console.log(`Why these credentials are separate: ${AUTHENTICATION_GUIDE}`); } - requestWorkflowPat(requirement, current) { + async requestWorkflowPat(requirement, current) { + if (this.terminal && !this.credentialValues[requirement.name]?.trim() && this.workflowPatGuide) { + let choice; + do { + choice = await this.readChoice('How would you like to provide the bot workflow PAT?', ['guided link', 'manual PAT', 'view full permission table'], 'guided link', `Use a PAT from the dedicated bot account, not the operator setup PAT. Guided opens GitHub's form; manual keeps the permission table visible. The bot PAT is installed as an Actions Secret only after Apply.\nRead more: ${AUTHENTICATION_GUIDE}`); + if (choice === 'view full permission table' && this.workflowPatRequirements) { + console.log((0, setup_token_permission_presenter_1.renderSetupTokenPermissionRequirements)('workflow', this.workflowPatRequirements)); + } + } while (choice === 'view full permission table'); + const guided = choice === 'guided link'; + if (guided) { + const login = await this.readBotLogin(); + const identity = await this.resolveBotIdentity(login); + this.guidedBotIdentity = identity; + console.log(`Expected bot account resolved: @${identity.login} (GitHub account ID ${identity.id}).`); + console.log((0, setup_prompt_rendering_1.renderBox)(`Open this link in a separate/private browser session, sign in as @${login} (the bot account), and complete its 2FA or SSO. Review every grant and select ONLY the intended repository manually. GitHub creates the PAT; Copilot does not store bot web credentials. The suggested expiry is 90 days—renew the token and update the Actions Secret before then.`, 'Create bot PAT in GitHub', 33)); + console.log(this.workflowPatGuide); + console.log('Copy the one-time bot token and paste it below. It will be validated before any Secret is written.'); + } + else if (this.workflowPatRequirements) { + console.log((0, setup_token_permission_presenter_1.renderSetupTokenPermissionRequirements)('workflow', this.workflowPatRequirements)); + } + } return this.requestSecretForRequirement(requirement, current, 'workflow PAT owned by the bot account'); } + async readBotLogin() { + while (true) { + const result = await this.terminal.readText('Expected GitHub bot login (without @; type ? for help): '); + if (result.kind !== 'value') + throw new exports.SetupTerminalCancelledError(); + const login = result.value.trim(); + if (login === '?') { + console.log((0, setup_prompt_rendering_1.renderBox)(`Enter the exact GitHub username of the separate bot account, without @. Copilot resolves its numeric account ID and compares it with the PAT before any Secret is written. It does not sign in as the bot or store its web credentials.\nRead more: ${AUTHENTICATION_GUIDE}`, 'About the bot account')); + continue; + } + if (/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) + return login; + console.log((0, setup_prompt_rendering_1.color)('Enter a valid GitHub account login.', 33)); + } + } requestApiKey(requirement, current) { return this.requestSecretForRequirement(requirement, current, `${requirement.provider ?? 'provider'} API key`); } @@ -66019,7 +68843,7 @@ class SetupCredentialPromptAdapter { if (!this.terminal) return 'keep'; console.log(`Existing ${requirement.name}: ${check.status}. ${check.message}`); - return this.readChoice(`How should Copilot handle the existing ${requirement.name}?`, ['keep', 'replace', 'skip'], check.status === 'valid' ? 'keep' : 'replace'); + return this.readChoice(`How should Copilot handle the existing ${requirement.name}?`, ['keep', 'replace', 'skip'], check.status === 'valid' ? 'keep' : 'replace', `Keep retains the existing Secret; GitHub does not reveal its value for inspection. Replace asks for a new credential and may update the Secret after Apply. Skip leaves this optional credential unconfigured. Check the plan before approving writes.\nRead more: ${AUTHENTICATION_GUIDE}`); } showCredentialChecks(checks) { if (checks.length === 0) @@ -66040,20 +68864,25 @@ class SetupCredentialPromptAdapter { async readSecret(label) { const result = await this.terminal.readSecret(label); if (result.kind !== 'value') - throw new SetupTerminalCancelledError(); + throw new exports.SetupTerminalCancelledError(); return result.value.trim(); } - async readChoice(label, choices, defaultValue) { + async readChoice(label, choices, defaultValue, help) { while (true) { const lines = choices.map((choice, index) => ` ${index + 1}) ${choice}${choice === defaultValue ? (0, setup_prompt_rendering_1.color)(' (default)', 90) : ''}`); const result = await this.terminal.readText([ label, ...lines, - `Select 1-${choices.length} ${(0, setup_prompt_rendering_1.color)(`[${choices.indexOf(defaultValue) + 1}]`, 90)}: `, + ...(help ? ['Type ? for more detail without selecting an answer.'] : []), + `Select 1-${choices.length}${defaultValue ? ` ${(0, setup_prompt_rendering_1.color)(`[${choices.indexOf(defaultValue) + 1}]`, 90)}` : ''}: `, ].join('\n')); if (result.kind !== 'value') - throw new SetupTerminalCancelledError(); - if (!result.value.trim()) + throw new exports.SetupTerminalCancelledError(); + if (result.value.trim() === '?' && help) { + console.log((0, setup_prompt_rendering_1.renderBox)(help, 'About this credential choice')); + continue; + } + if (!result.value.trim() && defaultValue) return defaultValue; const index = Number(result.value) - 1; if (Number.isInteger(index) && choices[index]) @@ -66150,6 +68979,45 @@ function doctorCheckLabel(id, catalog = (0, setup_doctor_message_catalog_1.resol } +/***/ }), + +/***/ 20462: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ConsoleSetupJourneyPresenter = void 0; +exports.renderSetupJourney = renderSetupJourney; +const setup_prompt_rendering_1 = __nccwpck_require__(83434); +class ConsoleSetupJourneyPresenter { + present(view) { + console.log(renderSetupJourney(view)); + } +} +exports.ConsoleSetupJourneyPresenter = ConsoleSetupJourneyPresenter; +function renderSetupJourney(view, maximumWidth) { + const revisitingChoices = view.current === 'Setup choices' && view.choiceReviewPass > 1; + const state = view.outcome === 'complete' ? 'Complete: setup applied successfully.' + : view.outcome === 'dry-run' ? 'Complete: dry run only; no changes were applied.' + : view.outcome === 'partial' ? 'Partial: changes may exist; inspect the branch and GitHub resources before retrying.' + : view.outcome === 'blocked' ? 'Blocked: setup cannot continue.' + : view.outcome === 'cancelled' ? 'Cancelled: setup stopped.' + : view.mutationStarted ? view.current === 'Bot PAT & credentials' + ? 'Checking credentials; a temporary GitHub workflow change may exist.' + : 'Applying the approved plan; changes may already exist.' + : 'No changes have been applied.'; + return (0, setup_prompt_rendering_1.renderBox)([ + `Repository: ${view.repository}`, + `Stage ${view.position}/${view.total} · ${view.current}${revisitingChoices ? ` · review pass ${view.choiceReviewPass}` : ''}`, + `Complete: ${view.complete.join(' → ') || 'none'}`, + `Now: ${revisitingChoices ? 'reviewing saved setup choices' : view.current}`, + `Next: ${view.pending.join(' → ') || 'none'}`, + state, + ].join('\n'), 'Copilot setup', 36, maximumWidth); +} + + /***/ }), /***/ 33441: @@ -66195,6 +69063,8 @@ function renderSetupPlan(plan) { ` Outcome: ${approval.mode === 'off' ? 'disabled' : approval.mode === 'recommend' ? 'recommendation only' : 'eligible PRs may be approved after default-branch installation and live evidence'}`, ' Native approval still requires readable stale-dismissal rules and a distinct runtime PAT bot.', '', (0, setup_prompt_rendering_1.color)('Repository changes', 36), + ` Production/development branches: ${plan.configuration.repository.mainBranch} / ${plan.configuration.repository.developmentBranch}`, + ` Projects: ${plan.configuration.projects.ids || '(none)'}`, ` Files selected: ${plan.selectedFiles.length}`, ` Variables to upsert: ${plan.configuration.manageRepositoryVariables ? plan.variables.length : 0}`, ` Secret options to validate/provision: ${plan.configuration.manageRepositorySecrets ? plan.credentialRequirements.length : 0}`, @@ -66202,6 +69072,8 @@ function renderSetupPlan(plan) { ` Secret storage: ${storageLabel(plan.configuration.storage.secrets)}`, ' Labels and issue types: always checked by Copilot setup', ` Initial tag: ${plan.configuration.createInitialTag ? 'v1.0.0 when no version tag exists' : 'disabled'}`, '', + ...(plan.presentationDefaults?.length ? [(0, setup_prompt_rendering_1.color)('Advanced defaults retained in basic setup', 36), + ...plan.presentationDefaults.map(item => ` ${item.group}: ${item.count} settings not asked; use :edit at plan confirmation to review or change.`), ''] : []), ...(plan.mergeQueueReadiness.length > 0 ? [ (0, setup_prompt_rendering_1.color)('Merge queue readiness', 36), ...plan.mergeQueueReadiness.map((check) => ` ${(0, setup_prompt_rendering_1.doctorIcon)(check.status)} ${check.id}: ${check.summary}`), @@ -66380,34 +69252,99 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ConsoleSetupQuestionRenderer = void 0; const setup_prompt_rendering_1 = __nccwpck_require__(83434); const setup_questionnaire_policy_1 = __nccwpck_require__(6009); +const setup_question_guidance_policy_1 = __nccwpck_require__(42775); class ConsoleSetupQuestionRenderer { + constructor(phase = 'full', choiceReviewPass = 1) { + this.phase = phase; + this.choiceReviewPass = choiceReviewPass; + } showIntroduction() { + if (this.phase === 'permission-intent') { + console.log((0, setup_prompt_rendering_1.renderBox)(this.choiceReviewPass > 1 + ? [ + `Reviewing your setup choices again (pass ${this.choiceReviewPass}).`, + 'This is the same setup run. Your answers are saved as defaults.', + 'Press Enter to keep each answer, or enter a new value.', + 'After this pass you return to the setup PAT permission review.', + 'No setup changes have been applied.', + ].join('\n') + : [ + 'First, choose the setup options that affect your temporary PAT permissions.', + 'These answers carry into the later full wizard and are not asked there again', + 'unless you choose to review them here. No GitHub changes happen in this step.', + ].join('\n'), this.choiceReviewPass > 1 ? 'Review saved setup choices' : 'Setup PAT permission intent')); + return; + } console.log((0, setup_prompt_rendering_1.renderBox)('This wizard configures repository workflows, GitHub Actions resources, AI agents, and operational defaults.\n\nThe setup PAT is used in memory only. Runtime credentials are collected separately after the plan is approved.', 'Copilot Setup')); } showState(stateId) { console.log((0, setup_prompt_rendering_1.color)(`\n${(0, setup_questionnaire_policy_1.setupQuestionnaireStateLabel)(stateId)}\n`, 36)); } - renderPrompt(question) { + renderPrompt(question, progress) { + const help = (0, setup_question_guidance_policy_1.setupQuestionPresentation)(question).en; + const step = progress ? `${(0, setup_questionnaire_policy_1.setupQuestionnaireStateLabel)(progress.group)} — question ${progress.groupPosition} of ${progress.groupTotal} (overall ${progress.position} of ${progress.total}).\n` : ''; + const source = question.suggestionSource === 'github' ? ' (observed from authenticated GitHub repository metadata)' + : question.suggestionSource === 'local' ? ' (observed in this local checkout; confirm it exists on GitHub)' + : question.suggestionSource === 'configuration' ? ' (provided by your configuration)' + : question.suggestionSource === 'default' ? ' (product default; not verified against GitHub)' : ''; + const fixedWorkflowNote = question.id === 'features.issues' && (question.fixedWorkflowFeatures?.release || question.fixedWorkflowFeatures?.hotfix) + ? ' Configuration explicitly enables release or hotfix; keep Issues enabled unless you edit --config/flags.' + : ['release', 'hotfix'].flatMap(kind => { + const fixed = question.fixedWorkflowFeatures?.[kind]; + return fixed === undefined ? [] : [` Configuration fixes features.${kind}=${fixed}; ${fixed ? 'keep' : 'leave'} ${kind} ${fixed ? 'selected' : 'unselected'} unless you edit --config/flags.`]; + }).join('\n'); + const heading = `${step}${question.label}\n ${help.summary}\n Suggested: ${formatDefault(question.defaultValue)}${source}. ${help.documentation.title}: ${help.documentation.url}\n Type ? for detailed help; type :back to return to the previous question without clearing saved answers.${fixedWorkflowNote ? `\n${fixedWorkflowNote}` : ''}${discoveryNote(question)}`; + const reviewedStatuses = question.projectStatusValues?.map(item => ` ${item.transition}: ${item.value}`).join('\n'); const fallback = formatDefault(question.defaultValue); if (question.kind === 'choice') { const choices = question.choices ?? []; - const lines = choices.map((choice, index) => ` ${index + 1}) ${choice}${choice === question.defaultValue ? (0, setup_prompt_rendering_1.color)(' (default)', 90) : ''}`); - return [question.label, ...lines, `Select 1-${choices.length} ${(0, setup_prompt_rendering_1.color)(`[${choices.indexOf(String(question.defaultValue)) + 1}]`, 90)}: `].join('\n'); + const lines = choices.map((choice, index) => ` ${index + 1}) ${choice}${question.id === 'pullRequestApproval.coverage.checkName' + ? (() => { + const producer = question.trustedProducers?.find(item => item.name === choice); + return producer ? ` — ${producer.workflowName} · App ${producer.sourceAppId}` : ''; + })() : ''}${choice === question.defaultValue ? (0, setup_prompt_rendering_1.color)(' (default)', 90) : ''}`); + return [heading, ...lines, `Select 1-${choices.length} ${(0, setup_prompt_rendering_1.color)(`[${choices.indexOf(String(question.defaultValue)) + 1}]`, 90)}: `].join('\n'); } if (question.kind === 'multi-select') { - const selected = new Set(formatDefault(question.defaultValue).split(',').map(item => item.trim()).filter(Boolean)); - const choices = question.choices ?? []; - const lines = choices.map((choice, index) => { - const workflowId = choice === 'All' ? 'all' : choice.split(' — ')[0]; - const checked = selected.has('all') || selected.has(workflowId) ? '●' : '○'; - return ` ${checked} ${index === 0 ? 'All' : choice}`; - }); - return [question.label, ...lines, 'Use ↑/↓ and Space to toggle; Enter to confirm.'].join('\n'); + return [heading, 'Choose from the options below. In a selector use ↑/↓ and Space; in text mode enter IDs separated by commas. Enter confirms; ? shows help and B goes back.'].join('\n'); + } + if (question.kind === 'producer-select') { + const choices = question.producerCandidates ?? []; + const lines = choices.map((candidate, index) => ` ${index + 1}) ${candidate.name} · App ${candidate.sourceAppId} · ${candidate.workflowName} · ${candidate.conclusion} · ${candidate.headSha.slice(0, 7)} · ${candidate.observedAt ?? 'date unavailable'}\n ${candidate.runUrl}\n ${candidate.requiredByRuleset ? `Required on ${candidate.requiredByRuleset.branch} by active ruleset: ${candidate.requiredByRuleset.sourceUrl}` : 'Required by branch rule: not checked'}`); + return [heading, ...lines, 'Enter check numbers separated by commas (for example 1,2), or exact name|App ID|workflow tuples separated by semicolons.', + 'A listed ruleset proves only the exact required check/App pair on that target branch. "Not checked" is not evidence that the check is optional; inspect branch protection too.', + 'A suggested check is not proof of coverage. Inspect its workflow and required step before attesting.', + ` ${(0, setup_prompt_rendering_1.color)(`[${fallback}]`, 90)}: `].join('\n'); + } + if (question.kind === 'project-select') { + return [heading, + 'Choose Projects from the list below. In a selector use ↑/↓ and Space; in text mode enter their URL numbers separated by commas. B returns to the previous question.', + 'Project numbers come from GitHub URLs, not PVT_ node IDs. Use manual if a Project is missing, or retry to query GitHub again without restarting setup.', + 'All selected Projects must share each chosen Status value; this setup cannot map different values per Project.'].join('\n'); } if (question.kind === 'scope-overrides' && question.allowedNames?.length) { - return `${question.label}\n Available: ${question.allowedNames.join(', ')}; enter "none" to inherit all\n ${(0, setup_prompt_rendering_1.color)(`[${fallback}]`, 90)}: `; + return `${heading}\n Available: ${question.allowedNames.join(', ')}; enter "none" to inherit all\n ${(0, setup_prompt_rendering_1.color)(`[${fallback}]`, 90)}: `; } - return `${question.label} ${(0, setup_prompt_rendering_1.color)(`[${fallback}]`, 90)}: `; + return `${heading}${reviewedStatuses ? `\n Verify these exact Status values in every selected Project:\n${reviewedStatuses}` : ''}${question.statusOptionState === 'unavailable' + ? '\n Status options could not be verified for every Project. Check the exact existing value in every selected Project before continuing.' : ''}${question.statusOptionState === 'incompatible' + ? '\n Selected Projects have no common Status values. Return to Project selection and choose compatible Projects.' : ''}\n ${(0, setup_prompt_rendering_1.color)(`[${fallback}]`, 90)}: `; + } + renderHelp(question) { + const help = (0, setup_question_guidance_policy_1.setupQuestionPresentation)(question).en; + return [ + `What: ${help.summary}`, + `When: ${help.when}`, + `Where: ${help.where}`, + `How: ${help.how}`, + `Why: ${help.why}`, + `Example: ${help.example}`, + `Effect: ${help.effect}`, + `Verify: ${help.verify}`, + `Read more — ${help.documentation.title}: ${help.documentation.url}`, + ].join('\n'); + } + showHelp(question) { + console.log((0, setup_prompt_rendering_1.renderBox)(this.renderHelp(question), 'About this setup choice')); } showValidation(message) { console.log((0, setup_prompt_rendering_1.color)(message, 33)); @@ -66422,6 +69359,194 @@ function formatDefault(value) { return value ? 'Y' : 'N'; return String(value) || 'none'; } +function discoveryNote(question) { + const status = question.discoveryStatus; + if (!status) + return ''; + const check = { + observed: 'Recent CI jobs were found. Inspect the linked runs before trusting a producer.', + 'no-recent-runs': 'No recent PR CI runs were found. Run normal CI or enter an exact producer manually.', + 'no-verifiable-checks': 'Recent runs exist, but exact job/App identity could not be verified. Use manual entry after inspecting GitHub.', + 'permission-denied': 'GitHub denied CI discovery. Give the setup PAT Actions: read and Checks: read, or enter a verified producer manually.', + unavailable: 'CI discovery failed; this does not mean there are no checks. Retry or use verified manual entry.', + }; + const project = { + observed: 'Existing organization Projects are listed below. Inspect each GitHub URL before selecting it.', + empty: 'The bounded GitHub query returned no open, accessible Projects; this does not prove none exist. Check organization access or enter a verified number manually.', + 'permission-denied': 'GitHub denied Project discovery. Check organization Projects: read on the setup PAT, or enter numbers manually.', + unavailable: 'Project discovery failed; this does not mean no Projects exist. Use a verified number or retry.', + unsupported: 'Fine-grained PATs cannot list personal Projects through this GitHub API. Use the number in an existing Project URL.', + }; + const note = question.id === 'projects.ids' ? project[status] : check[status]; + const sample = status === 'observed' || status === 'empty' || status === 'no-recent-runs' || status === 'no-verifiable-checks' + ? question.id === 'projects.ids' + ? '\n Search scope: at most 30 open, accessible organization Projects from two pages; up to 100 fields per Project. Closed Projects are excluded.' + : '\n Search scope: up to 20 recent PR workflow runs; at most 15 runs and 100 checks per commit are inspected.' + : ''; + return note ? `\n ${note}${sample}${question.discoveryTruncated ? '\n Only a bounded sample was inspected; use manual entry for missing items.' : ''}${question.discoveryRetryRemaining ? `\n Type r to retry GitHub discovery (${question.discoveryRetryRemaining} read-only attempts left).` : ''}` : ''; +} + + +/***/ }), + +/***/ 44132: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.setupResultReason = setupResultReason; +exports.setupResultEffects = setupResultEffects; +exports.setupActionResultFailure = setupActionResultFailure; +const result_1 = __nccwpck_require__(73817); +function setupResultReason(code) { + if (!code) + return 'unknown'; + if (code.startsWith('authorization.')) + return 'permissions'; + if (code.startsWith('configuration.') || code === 'validation.invalid-input') + return 'configuration'; + if (code === 'provider.rate-limited') + return 'rate-limit'; + if (code.startsWith('provider.') || code === 'timeout') + return 'provider'; + return 'unknown'; +} +/** Provider errors are never serialized; failed steps remain potentially applied. */ +function setupResultEffects(results) { + for (const result of results) { + const receipt = (0, result_1.getResultPayload)((0, result_1.getResultPayload)(result.payload)?.setupReceipt); + if (receipt?.version !== 1 || !Array.isArray(receipt.effects)) + continue; + const parsed = receipt.effects.map(parseEffect); + if (parsed.length === EFFECT_IDS.length && parsed.every(Boolean) + && EFFECT_IDS.every(id => parsed.some(effect => effect?.id === id))) + return parsed; + } + return results.map((result, index) => ({ + id: safeEffectId(result.id, index), + state: !result.success || result.errors.length > 0 ? 'needs-inspection' + : result.executed ? 'completed' : 'skipped', + })); +} +const EFFECT_IDS = ['files', 'secrets', 'labels', 'issue-types', 'variables', 'initial-tag']; +const EFFECT_STATES = ['completed', 'skipped', 'needs-inspection', 'not-started']; +const EFFECT_SCOPES = ['local', 'repository', 'organization', 'mixed']; +function parseEffect(value) { + const effect = (0, result_1.getResultPayload)(value); + if (!effect || !EFFECT_IDS.includes(effect.id) + || !EFFECT_STATES.includes(effect.state) + || !EFFECT_SCOPES.includes(effect.scope)) + return undefined; + return { id: effect.id, state: effect.state, scope: effect.scope }; +} +function setupActionResultFailure(results) { + const first = results.flatMap(result => result.errors)[0]; + if (!first) + return results.some(result => !result.success) ? { reasonCode: 'unknown' } : undefined; + if (typeof first !== 'object') + return { reasonCode: 'unknown' }; + return { reasonCode: setupResultReason(first.code), + ...(first.correlationId ? { diagnosticRef: first.correlationId } : {}) }; +} +function safeEffectId(value, index) { + return EFFECT_IDS.includes(value) ? value + : value === 'InitialSetupUseCase' ? 'setup-workflow' : `step-${index + 1}`; +} + + +/***/ }), + +/***/ 53104: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.acquireSetupSessionGuard = acquireSetupSessionGuard; +const node_crypto_1 = __nccwpck_require__(6005); +const node_child_process_1 = __nccwpck_require__(17718); +const node_fs_1 = __nccwpck_require__(87561); +const node_os_1 = __nccwpck_require__(70612); +const node_path_1 = __nccwpck_require__(49411); +/** One cooperative setup process per canonical checkout; no credential is stored in the lock. */ +function acquireSetupSessionGuard(cwd) { + const top = (0, node_child_process_1.execFileSync)('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + const repository = (0, node_fs_1.realpathSync)(top); + const hash = (0, node_crypto_1.createHash)('sha256').update(repository).digest('hex').slice(0, 32); + const lockPath = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `copilot-setup-${hash}.lock`); + const record = { pid: process.pid, nonce: (0, node_crypto_1.randomBytes)(16).toString('hex'), repository }; + const stagedPath = `${lockPath}.${record.nonce}.tmp`; + writeStagedLock(stagedPath, record); + let published = false; + let collision; + try { + (0, node_fs_1.linkSync)(stagedPath, lockPath); // Atomic publication of a fully written record. + published = true; + } + catch (cause) { + collision = cause; + } + finally { + try { + (0, node_fs_1.unlinkSync)(stagedPath); + } + catch { /* A staging-file cleanup failure does not invalidate the published lock. */ } + } + if (!published) { + if (!collision || typeof collision !== 'object' || !('code' in collision) || collision.code !== 'EEXIST') + throw collision; + let existing; + try { + existing = JSON.parse((0, node_fs_1.readFileSync)(lockPath, 'utf8')); + } + catch { + throw new Error('A setup lock exists but cannot be verified. Inspect it before retrying.'); + } + if (!Number.isSafeInteger(existing.pid) || existing.pid <= 0 || existing.repository !== repository || !existing.nonce) { + throw setupLockError('A setup lock has unexpected contents. Inspect it before retrying.', collision); + } + try { + process.kill(existing.pid, 0); + throw setupLockError(`Another setup process (${existing.pid}) is active for this checkout. Finish or stop it before starting a second setup.`, collision); + } + catch (checkError) { + if (!checkError || typeof checkError !== 'object' || !('code' in checkError) || checkError.code !== 'ESRCH') + throw checkError; + } + // Filesystem reads and unlink are not atomic. Never remove a dead owner's lock here. + throw setupLockError(`A setup lock for a stopped process (${existing.pid}) remains at ${lockPath}. Verify no setup is running, remove only that file manually, then retry.`, collision); + } + return () => { + try { + const current = JSON.parse((0, node_fs_1.readFileSync)(lockPath, 'utf8')); + if (current.pid === record.pid && current.nonce === record.nonce && current.repository === record.repository) + (0, node_fs_1.unlinkSync)(lockPath); + } + catch { /* Missing or replaced lock is not ours to remove. */ } + }; +} +function writeStagedLock(path, record) { + const fd = (0, node_fs_1.openSync)(path, 'wx', 0o600); + try { + (0, node_fs_1.writeFileSync)(fd, JSON.stringify(record)); + (0, node_fs_1.closeSync)(fd); + } + catch (cause) { + try { + (0, node_fs_1.closeSync)(fd); + } + catch { /* Already closed or unavailable. */ } + try { + (0, node_fs_1.unlinkSync)(path); + } + catch { /* Preserve the write failure. */ } + throw cause; + } +} +function setupLockError(message, cause) { + return Object.assign(new Error(message), { cause }); +} /***/ }), @@ -66440,6 +69565,10 @@ const node_process_1 = __nccwpck_require__(97742); function interactiveTerminalAvailable() { return Boolean(node_process_1.stdin.isTTY && node_process_1.stdout.isTTY && !process.env.JEST_WORKER_ID); } +// Keep this boundary safe even when choices are not constructed by the setup controller. +function safeTerminalChoiceText(value) { + return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ''); +} function createInteractiveTerminalDriver() { return interactiveTerminalAvailable() ? new NodeTerminalDriver() : undefined; } @@ -66449,7 +69578,6 @@ class NodeTerminalDriver { if (!interactiveTerminalAvailable()) { throw new Error('An interactive terminal is required.'); } - this.readline = (0, promises_1.createInterface)({ input: node_process_1.stdin, output: node_process_1.stdout }); } isInteractive() { return !this.closed; @@ -66457,6 +69585,8 @@ class NodeTerminalDriver { async readText(prompt) { if (this.closed) return { kind: 'end-of-input' }; + const readline = (0, promises_1.createInterface)({ input: node_process_1.stdin, output: node_process_1.stdout }); + this.readline = readline; const abort = new AbortController(); let interrupted = false; let ended = false; @@ -66468,10 +69598,10 @@ class NodeTerminalDriver { ended = true; abort.abort(); }; - this.readline.once('SIGINT', onInterrupt); - this.readline.once('close', onClose); + readline.once('SIGINT', onInterrupt); + readline.once('close', onClose); try { - return { kind: 'value', value: await this.readline.question(prompt, { signal: abort.signal }) }; + return { kind: 'value', value: await readline.question(prompt, { signal: abort.signal }) }; } catch (error) { if (interrupted) @@ -66481,8 +69611,11 @@ class NodeTerminalDriver { throw error; } finally { - this.readline.off('SIGINT', onInterrupt); - this.readline.off('close', onClose); + readline.off('SIGINT', onInterrupt); + readline.off('close', onClose); + readline.close(); + if (this.readline === readline) + this.readline = undefined; } } async readSecret(prompt) { @@ -66528,12 +69661,16 @@ class NodeTerminalDriver { input.once('end', onEnd); }); } - async readMultiSelect(prompt, choices, selected) { + async readMultiSelect(prompt, choices, selected, helpText) { if (this.closed) return { kind: 'end-of-input' }; const input = node_process_1.stdin; if (!input.setRawMode) { - return this.readText(`${prompt}\nEnter comma-separated IDs (or "all"): `); + return this.readText([prompt, 'Available IDs:', + ...choices.map(choice => ` ${safeTerminalChoiceText(choice)}`), + `Current selection: ${safeTerminalChoiceText(selected.join(', ') || 'none')}`, + 'Enter IDs shown before “—”, separated by commas; use manual or retry when offered, none to clear, or Enter to keep the default: ', + ].join('\n')); } node_process_1.stdout.write(`${prompt}\n`); input.setRawMode(true); @@ -66547,7 +69684,7 @@ class NodeTerminalDriver { const lines = choices.map((choice, choiceIndex) => { const id = choice === 'All' ? 'all' : choice.split(' — ')[0]; const checked = id === 'all' ? value.size === choices.length - 1 : value.has(id); - return `${choiceIndex === index ? '❯' : ' '} ${checked ? '●' : '○'} ${choice}`; + return `${choiceIndex === index ? '❯' : ' '} ${checked ? '●' : '○'} ${safeTerminalChoiceText(choice)}`; }); node_process_1.stdout.write(`${rendered ? `\x1b[${choices.length}A\x1b[0J` : ''}${lines.join('\n')}\n`); rendered = true; @@ -66589,6 +69726,16 @@ class NodeTerminalDriver { finish({ kind: 'end-of-input' }); return; } + if (character === 'b' || character === 'B') { + finish({ kind: 'value', value: ':back' }); + return; + } + if (character === '?' && helpText) { + node_process_1.stdout.write(`\n${helpText}\n\n`); + rendered = false; + render(); + continue; + } if (character === ' ') { const id = choices[index] === 'All' ? 'all' : choices[index].split(' — ')[0]; if (id === 'all') @@ -66600,7 +69747,7 @@ class NodeTerminalDriver { render(); } else if (character === '\r' || character === '\n') { - finish({ kind: 'value', value: [...value].join(',') }); + finish({ kind: 'value', value: value.size === 0 ? 'none' : [...value].join(',') }); return; } } @@ -66614,7 +69761,7 @@ class NodeTerminalDriver { if (this.closed) return; this.closed = true; - this.readline.close(); + this.readline?.close(); } } exports.NodeTerminalDriver = NodeTerminalDriver; @@ -66632,12 +69779,22 @@ function isAbortError(error) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ConsoleSetupTokenPermissionPresenter = void 0; +exports.renderSetupTokenPermissionSummary = renderSetupTokenPermissionSummary; exports.renderSetupTokenPermissionRequirements = renderSetupTokenPermissionRequirements; exports.renderSetupTokenPermissionReport = renderSetupTokenPermissionReport; const node_process_1 = __nccwpck_require__(97742); const setup_prompt_rendering_1 = __nccwpck_require__(83434); +const setup_permission_summary_policy_1 = __nccwpck_require__(10267); class ConsoleSetupTokenPermissionPresenter { + constructor(mode = 'full') { + this.mode = mode; + } showRequirements(role, requirements) { + console.log(this.mode === 'summary' + ? renderSetupTokenPermissionSummary(role, requirements) + : renderSetupTokenPermissionRequirements(role, requirements)); + } + showDetailedRequirements(role, requirements) { console.log(renderSetupTokenPermissionRequirements(role, requirements)); } showReport(report) { @@ -66645,6 +69802,13 @@ class ConsoleSetupTokenPermissionPresenter { } } exports.ConsoleSetupTokenPermissionPresenter = ConsoleSetupTokenPermissionPresenter; +function renderSetupTokenPermissionSummary(role, requirements, maximumWidth = node_process_1.stdout.columns ?? 120) { + const summary = (0, setup_permission_summary_policy_1.summarizeSetupPermissions)(requirements); + return (0, setup_prompt_rendering_1.renderBox)([ + `Required now: ${summary.required.join(' · ') || 'none'}`, + `Conditional permissions: ${summary.conditionalCount}. View the full table for reasons and triggers.`, + ].join('\n'), `${roleTitle(role)} PAT permission summary`, 36, maximumWidth); +} function renderSetupTokenPermissionRequirements(role, requirements, maximumWidth = node_process_1.stdout.columns ?? 120) { const rows = maximumWidth >= 88 ? renderWideRequirements(requirements) @@ -66776,6 +69940,916 @@ class SetupWorkflowUpdatePromptAdapter { exports.SetupWorkflowUpdatePromptAdapter = SetupWorkflowUpdatePromptAdapter; +/***/ }), + +/***/ 60574: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.WebSetupCredentialPrompt = exports.WebSetupJourneyPresenter = exports.WebSetupPermissionPresenter = exports.WebSetupWorkflowUpdatePrompt = exports.WebSetupPlanConfirmation = exports.WebSetupPlanPresenter = exports.WebSetupQuestionnaireCollector = void 0; +exports.validationCopy = validationCopy; +const setup_questionnaire_policy_1 = __nccwpck_require__(6009); +const setup_question_guidance_policy_1 = __nccwpck_require__(42775); +const setup_interaction_cancelled_error_1 = __nccwpck_require__(38313); +const web_setup_bridge_1 = __nccwpck_require__(21518); +class WebSetupQuestionnaireCollector { + constructor(bridge, pass = 1) { + this.bridge = bridge; + this.pass = pass; + } + async collect(initial, context, discoveryRefresh) { + let state = initial; + let currentContext = context; + while (state.terminal === 'collecting' && state.question) { + if (state.validation) { + const copy = validationCopy(state.validation) ?? { id: 'validation.unknown' }; + this.bridge.message(state.validation, 'warning', undefined, copy.id, copy.values); + } + const value = await this.bridge.ask({ + kind: 'question', title: (0, setup_questionnaire_policy_1.setupQuestionnaireStateLabel)(state.stateId), + question: state.question, presentation: (0, setup_question_guidance_policy_1.setupQuestionPresentation)(state.question), phase: state.phase ?? 'full', pass: this.pass, + progress: (0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(state, currentContext), canGoBack: ((0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(state, currentContext)?.position ?? 1) > 1, + }, discoveryRefresh && state.question.discoveryRetryRemaining ? async () => { + const kind = state.question?.id === 'projects.ids' ? 'projects' + : state.question?.id === 'pullRequestApproval.testChecks' ? 'checks' : undefined; + if (!kind) + return undefined; + const refreshed = await discoveryRefresh.refresh(kind); + if (!refreshed) + return undefined; + const refreshedState = (0, setup_questionnaire_policy_1.refreshSetupQuestionnaireQuestion)(state, refreshed); + return refreshedState.question ? { prompt: { kind: 'question', + title: (0, setup_questionnaire_policy_1.setupQuestionnaireStateLabel)(refreshedState.stateId), + question: refreshedState.question, presentation: (0, setup_question_guidance_policy_1.setupQuestionPresentation)(refreshedState.question), + phase: refreshedState.phase ?? 'full', pass: this.pass, + progress: (0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(refreshedState, refreshed), + canGoBack: ((0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(refreshedState, refreshed)?.position ?? 1) > 1 }, + commit: () => { currentContext = refreshed; state = refreshedState; } } : undefined; + } : undefined, () => { + const previous = (0, setup_questionnaire_policy_1.transitionSetupQuestionnaire)(state, { kind: 'back' }, currentContext); + if (previous.question?.id === state.question?.id || !previous.question) + return undefined; + return { prompt: { kind: 'question', title: (0, setup_questionnaire_policy_1.setupQuestionnaireStateLabel)(previous.stateId), + question: previous.question, presentation: (0, setup_question_guidance_policy_1.setupQuestionPresentation)(previous.question), + phase: previous.phase ?? 'full', pass: this.pass, progress: (0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(previous, currentContext), + canGoBack: ((0, setup_questionnaire_policy_1.setupQuestionnaireProgress)(previous, currentContext)?.position ?? 1) > 1 }, + commit: () => { state = previous; } }; + }); + state = (0, setup_questionnaire_policy_1.transitionSetupQuestionnaire)(state, value === undefined ? { kind: 'cancel' } : { kind: 'answer', value }, currentContext); + } + return state; + } +} +exports.WebSetupQuestionnaireCollector = WebSetupQuestionnaireCollector; +class WebSetupPlanPresenter { + constructor(bridge) { + this.bridge = bridge; + } + present(plan) { + this.bridge.message(`Plan ready: ${plan.selectedFiles.length} files, ${plan.variables.length} Variables and ${plan.requiredSecrets.length} Secret names. Review it before continuing.`, 'info', undefined, 'plan.ready', { files: String(plan.selectedFiles.length), variables: String(plan.variables.length), secrets: String(plan.requiredSecrets.length) }); + } +} +exports.WebSetupPlanPresenter = WebSetupPlanPresenter; +class WebSetupPlanConfirmation { + constructor(bridge) { + this.bridge = bridge; + } + async confirm(plan) { + const groups = (0, setup_questionnaire_policy_1.setupEditableGroups)(plan.configuration); + const response = await this.bridge.ask({ kind: 'plan', title: 'Review your setup plan', copyId: 'plan.review', plan: (0, web_setup_bridge_1.toWebSetupPlan)(plan), editGroups: groups }); + if (response?.startsWith('revise:')) { + const group = response.slice('revise:'.length); + if (groups.includes(group)) + return { kind: 'revise', group }; + throw new Error('Invalid setup section.'); + } + return { kind: response === undefined ? 'cancelled' : response === 'approve' ? 'approved' : 'declined' }; + } +} +exports.WebSetupPlanConfirmation = WebSetupPlanConfirmation; +class WebSetupWorkflowUpdatePrompt { + constructor(bridge) { + this.bridge = bridge; + } + async confirmWorkflowUpdates(comparisons, forcedByFlag) { + const changed = comparisons.filter(item => item.status === 'changed' || item.status === 'unmanaged'); + if (!changed.length) + return false; + if (forcedByFlag) + return true; + const answer = await this.bridge.ask({ + kind: 'confirm', title: 'Update existing workflows?', + description: changed.map(item => `${item.destination} (${item.status})`).join('\n'), + choices: ['Keep existing', 'Update setup-managed workflows'], + copyId: 'workflow.update', copyValues: { files: changed.map(item => item.destination).join(', ') }, + }); + if (answer === undefined) + throw new setup_interaction_cancelled_error_1.SetupInteractionCancelledError(); + return answer === 'Update setup-managed workflows'; + } +} +exports.WebSetupWorkflowUpdatePrompt = WebSetupWorkflowUpdatePrompt; +class WebSetupPermissionPresenter { + constructor(bridge) { + this.bridge = bridge; + } + showRequirements(role, requirements) { this.bridge.requirements(role, requirements); } + showDetailedRequirements(role, requirements) { this.bridge.requirements(role, requirements); } + showReport(report) { this.bridge.report(report); } +} +exports.WebSetupPermissionPresenter = WebSetupPermissionPresenter; +class WebSetupJourneyPresenter { + constructor(bridge) { + this.bridge = bridge; + } + present(view) { this.bridge.setJourney(view); } +} +exports.WebSetupJourneyPresenter = WebSetupJourneyPresenter; +class WebSetupCredentialPrompt { + constructor(bridge) { + this.bridge = bridge; + this.guidedSetup = false; + } + get usedGuidedSetupPat() { return this.guidedSetup; } + get guidedWorkflowBotIdentity() { return this.botIdentity; } + configureSetupPatGuide(url) { this.setupGuide = url; } + useManualSetupPat() { this.guidedSetup = false; this.setupGuide = undefined; } + async chooseSetupPatMethod() { + this.guidedSetup = await this.choice('How will you provide your setup PAT?', ['Guided GitHub link', 'Manual PAT'], undefined, 'setupPat.method') === 'Guided GitHub link'; + return this.guidedSetup ? 'guided' : 'manual'; + } + async chooseSetupOwnerKind() { + const answer = await this.choice('What kind of GitHub account owns this repository?', ['Organization', 'Personal account', 'Not sure'], undefined, 'setupPat.ownerKind'); + return answer === 'Organization' ? 'Organization' : answer === 'Personal account' ? 'User' : 'unknown'; + } + async reviewSetupPatIntent() { + const answer = await this.choice('Review these provisional setup PAT grants', ['Continue to GitHub', 'Review setup choices again', 'View full permission table', 'Enter a PAT manually'], undefined, 'setupPat.review'); + return answer === 'Review setup choices again' ? 'revise' : answer === 'View full permission table' ? 'details' + : answer === 'Enter a PAT manually' ? 'manual' : 'continue'; + } + async requestSetupPat() { + return this.secret('Temporary setup PAT', 'Use the operator account in GitHub. Complete 2FA there, switch to Only select repositories, select this repository, and copy the generated token here. This token is for this run only; delete it in GitHub afterwards.', this.guidedSetup ? this.setupGuide : undefined, false, 'setupPat.entry'); + } + async confirmGuidedSetupAccount(account) { + if (!this.guidedSetup) + return true; + if (!account) + return false; + return await this.choice(`GitHub authenticated the setup PAT as @${account}. Is that the intended operator account?`, ['Yes, continue', 'No, stop'], undefined, 'setupPat.confirmAccount', { account }) === 'Yes, continue'; + } + showUpdatedSetupPatLink(url, stage, delta) { + this.bridge.message(`Setup PAT ${stage === 'final' ? 'permissions changed' : 'access failed'}. No setup mutation started. ${delta?.join(', ') ?? ''} Create a corrected PAT using the updated GitHub link.`, 'warning', url, stage === 'final' ? 'setupPat.corrected.final' : 'setupPat.corrected.bootstrap', { grants: delta?.join(', ') ?? '' }); + } + showSetupPatCleanupReminder() { + if (this.guidedSetup) + this.bridge.message('Delete the temporary setup PAT in GitHub Settings after this run. Closing Copilot does not revoke it.', 'warning', 'https://github.com/settings/personal-access-tokens', 'setupPat.cleanup'); + } + async confirmUnverifiableTokenPermissions(report) { + const writes = report.checks.filter(item => item.applicability === 'required' && item.level === 'write' && item.status === 'unverifiable'); + if (!report.confirmationRequired || writes.length === 0) + return false; + return await this.choice('GitHub cannot safely prove these write grants without a mutation. Confirm they are configured exactly as shown.', ['No, stop', 'Yes, I checked them'], undefined, 'setupPat.confirmWrites') === 'Yes, I checked them'; + } + configureWorkflowPatGuide(url, resolveIdentity, requirements) { + this.workflowGuide = url; + this.resolveBot = resolveIdentity; + this.workflowRequirements = requirements; + } + explainCredentialSeparation(requirements) { + this.bridge.message(`The bot PAT is separate from your setup PAT. Runtime credentials (${requirements.map(item => item.name).join(', ')}) become GitHub Actions Secrets; existing Secret values cannot be read back. This browser flow will not dispatch or install a credential-health workflow before Apply. Re-enter an existing bot PAT so its grants can be audited.`, 'info', undefined, 'botPat.separation', { names: requirements.map(item => item.name).join(', ') }); + } + async requestWorkflowPat(requirement, current) { + let guide; + let botInfo = ''; + if (this.workflowGuide) { + const method = await this.choice('How will you provide the bot PAT?', ['Guided GitHub link', 'Manual PAT'], undefined, 'botPat.method'); + if (method === 'Guided GitHub link') { + const login = await this.text('Expected GitHub bot login', 'Enter the bot account login, without @. We will verify its numeric account ID against the token.', 'botPat.login'); + if (!login || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) + throw new Error('Enter a valid GitHub bot login.'); + this.botIdentity = await this.resolveBot(login); + guide = this.workflowGuide; + botInfo = `Expected bot account: @${this.botIdentity.login} (GitHub ID ${this.botIdentity.id}). Open GitHub as this account, not the setup operator. `; + } + else if (this.workflowRequirements) + this.bridge.requirements('workflow', this.workflowRequirements); + } + const value = await this.secret(`${requirement.name} — bot account PAT`, `${botInfo}Use the bot account, select only the intended repository and review all grants. Suggested expiry is 90 days. ${current ? `Existing Secret: ${current.status}; its value cannot be read back.` : ''}`, guide, false, guide ? 'botPat.entry.guided' : 'botPat.entry.manual', { name: requirement.name, account: this.botIdentity?.login ?? '', accountId: String(this.botIdentity?.id ?? ''), existing: current?.status ?? '' }); + return value ? { name: requirement.name, value } : undefined; + } + async requestApiKey(requirement, current) { + const value = await this.secret(`${requirement.name} — ${requirement.provider ?? 'provider'} API key`, current?.message, undefined, Boolean(requirement.alternativeGroups?.length), 'credential.apiKey', { name: requirement.name, provider: requirement.provider ?? 'provider' }); + return value ? { name: requirement.name, value } : undefined; + } + async chooseExistingCredential(requirement, check) { + const answer = await this.choice(`Existing ${requirement.name}: ${check.status}`, ['keep', 'replace', 'skip'], check.message, 'credential.existing', { name: requirement.name, status: check.status }); + return answer; + } + showCredentialChecks(checks) { + this.bridge.message(checks.map(item => `${item.name}: ${item.status} — ${item.message}`).join('\n'), checks.some(item => item.status === 'invalid') ? 'warning' : 'success', undefined, 'credential.checks', { names: checks.map(item => item.name).join(', '), count: String(checks.length) }, checks.map(item => ({ name: item.name, status: item.status }))); + } + async choice(title, choices, description, copyId, copyValues) { + const answer = await this.bridge.ask({ kind: 'choice', title, choices, description, copyId, copyValues }); + if (answer === undefined) + throw new setup_interaction_cancelled_error_1.SetupInteractionCancelledError(); + if (!choices.includes(answer)) + throw new Error('Invalid setup choice.'); + return answer; + } + async text(title, description, copyId) { + const answer = await this.bridge.ask({ kind: 'text', title, description, copyId }); + if (answer === undefined) + throw new setup_interaction_cancelled_error_1.SetupInteractionCancelledError(); + return answer.trim(); + } + async secret(title, description, link, optional = false, copyId, copyValues) { + const answer = await this.bridge.ask({ kind: 'secret', title, description, optional, link, copyId, copyValues }); + if (answer === undefined) + throw new setup_interaction_cancelled_error_1.SetupInteractionCancelledError(); + return answer.trim(); + } +} +exports.WebSetupCredentialPrompt = WebSetupCredentialPrompt; +function validationCopy(message) { + const fixed = { + 'Select 1–8 observed checks or enter exact name|App ID|workflow tuples.': 'validation.producers', + 'Two trusted producers use the same check name. Coverage stores only one name; choose one producer or rename the CI jobs before continuing.': 'validation.duplicateNames', + 'Open every selected Project in GitHub and confirm that all four exact Status values exist. Answer Yes after checking, or No to choose Projects again.': 'validation.projectStatusVerified', + 'Status values were not confirmed. Choose compatible Projects, then review their Status options again.': 'validation.projectStatusRedo', + 'Enter a non-negative whole number.': 'validation.number', + 'Enter yes or no.': 'validation.boolean', + 'Select one of the listed options.': 'validation.choice', + 'This is the first question in this pass. Review it or cancel setup.': 'validation.firstQuestion', + 'A trusted check was selected more than once.': 'validation.duplicateProducer', + 'The saved Status value is not available in every selected Project. Choose a listed Status option.': 'validation.savedStatus', + 'Selected Projects have no common Status option. Choose compatible Projects or configure them separately.': 'validation.projectIncompatible', + 'Choose at most 10 Projects; separate numbers or URLs with commas.': 'validation.projectLimit', + 'A Project URL needs a known repository owner; enter its positive number instead.': 'validation.projectOwnerNeeded', + 'Enter a valid GitHub Project URL or positive Project number.': 'validation.projectUrl', + 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.': 'validation.projectNumber', + 'Project numbers must be positive integers at most 2147483647.': 'validation.projectNumberRange', + 'Issue automation is required by an explicit release or hotfix override. Keep Issues enabled or edit your configuration.': 'validation.fixedIssues', + }; + if (fixed[message]) + return { id: fixed[message] }; + const fixedWorkflow = message.match(/^The (release|hotfix) workflow must (remain enabled|remain disabled) because it is fixed by your configuration\. Match that choice or edit your configuration\.$/u); + if (fixedWorkflow) + return { id: fixedWorkflow[2] === 'remain enabled' ? 'validation.fixedWorkflowEnabled' : 'validation.fixedWorkflowDisabled', + values: { kind: fixedWorkflow[1] } }; + const inherited = message.match(/^Unknown inherited resource name\(s\): (.+)\.$/u); + if (inherited) + return { id: 'validation.unknownResource', values: { names: inherited[1] } }; + const workflows = message.match(/^Unknown issue workflow\(s\): (.+)\.$/u); + if (workflows) + return { id: 'validation.unknownWorkflow', values: { names: workflows[1] } }; + const owner = message.match(/^Use a GitHub Project URL belonging to ([^,]+), without query parameters\.$/u); + if (owner) + return { id: 'validation.projectOwnerMismatch', values: { owner: owner[1] } }; + const duplicate = message.match(/^Project ([1-9]\d*) was selected more than once\.$/u); + if (duplicate) + return { id: 'validation.projectDuplicate', values: { number: duplicate[1] } }; + return undefined; +} + + +/***/ }), + +/***/ 21518: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.WebSetupBridge = void 0; +exports.toWebSetupPlan = toWebSetupPlan; +const node_crypto_1 = __nccwpck_require__(6005); +/** A one-run, in-memory handoff. Values submitted by the browser are never part of a view. */ +class WebSetupBridge { + constructor(repository) { + this.revision = 0; + this.subscribers = new Set(); + this.doctorAttempts = 0; + this.bootstrapped = false; + this.view = { revision: 0, repository }; + } + snapshot() { return this.view; } + setRepository(repository) { this.publish({ repository }); } + subscribe(listener) { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + bootstrap() { + if (!this.controller) + this.controller = (0, node_crypto_1.randomBytes)(32).toString('hex'); + // A second tab starts read-only. Its explicit takeover rotates the controller capability. + const first = !this.bootstrapped; + this.bootstrapped = true; + return { controller: first, ...(first ? { capability: this.controller } : {}) }; + } + takeOver() { + this.controller = (0, node_crypto_1.randomBytes)(32).toString('hex'); + this.publish({ message: { tone: 'info', text: 'Control moved to this tab. The previous tab is now read-only.', copyId: 'session.controlMoved' } }); + return this.controller; + } + isController(capability) { + return Boolean(this.controller && sameCapability(capability, this.controller)); + } + async ask(prompt, refresh, navigateBack) { + if (this.pending || this.view.outcome) + throw new Error('A setup decision is already pending or the session has ended.'); + const revision = this.revision + 1; + this.publish({ prompt, promptRevision: revision }); + return new Promise(resolve => { this.pending = { revision, resolve, refresh, navigateBack }; }); + } + back(revision) { + const pending = this.pending; + if (!pending || pending.revision !== revision || this.view.outcome) + return 'stale'; + if (!pending.navigateBack || pending.refreshing) + return 'unavailable'; + const result = pending.navigateBack(); + if (!result) + return 'unavailable'; + result.commit(); + pending.revision = this.revision + 1; + this.publish({ prompt: result.prompt, promptRevision: pending.revision, message: undefined }); + return 'updated'; + } + async retryDiscovery(revision) { + const pending = this.pending; + if (!pending || pending.revision !== revision || this.view.outcome) + return 'stale'; + if (!pending.refresh || pending.refreshing) + return 'unavailable'; + const controller = this.controller; + pending.refreshing = true; + try { + const result = await pending.refresh(); + if (this.pending !== pending || this.view.outcome || controller !== this.controller) + return 'stale'; + if (!result) + return 'unavailable'; + result.commit(); + // Keep promptRevision stable so the browser retains unsent manual and checkbox input. + this.publish({ prompt: result.prompt }); + return 'updated'; + } + finally { + pending.refreshing = false; + } + } + answer(revision, value) { + if (!this.pending || this.pending.revision !== revision || this.pending.refreshing || this.view.outcome) + return false; + const prompt = this.view.prompt; + if (prompt && (prompt.kind === 'choice' || prompt.kind === 'confirm') && !prompt.choices.includes(value)) + return false; + if (prompt?.kind === 'plan' && value !== 'approve' && value !== 'decline' + && !prompt.editGroups?.some(group => value === `revise:${group}`)) + return false; + const pending = this.pending; + this.pending = undefined; + this.lastAnsweredRevision = revision; + this.publish({ prompt: undefined, promptRevision: undefined }); + pending.resolve(value); + return true; + } + wasAnswered(revision) { return this.lastAnsweredRevision === revision; } + configureReadOnlyDoctor(run) { + this.readOnlyDoctor = run; + } + async runReadOnlyDoctor() { + if (this.view.outcome !== 'complete' || !this.readOnlyDoctor) + return 'unavailable'; + if (this.view.doctor?.status === 'running') + return 'busy'; + if (this.view.doctor?.status === 'complete') + return 'complete'; + if (this.doctorAttempts >= 2) + return 'unavailable'; + this.doctorAttempts += 1; + this.publish({ doctor: { status: 'running' } }); + try { + const summary = await this.readOnlyDoctor(); + const counts = [summary.pass, summary.warn, summary.fail, summary.skipped]; + if (counts.some(value => !Number.isSafeInteger(value) || value < 0)) + throw new Error('Invalid doctor summary.'); + this.publish({ doctor: { status: 'complete', healthy: summary.healthy === true, + pass: summary.pass, warn: summary.warn, fail: summary.fail, skipped: summary.skipped } }); + return 'complete'; + } + catch { + this.publish({ doctor: { status: 'failed' } }); + return 'failed'; + } + } + cancel() { + if (this.view.journey?.mutationStarted || this.view.outcome) + return false; + const pending = this.pending; + this.pending = undefined; + this.publish({ prompt: undefined, promptRevision: undefined, outcome: 'cancelled', resultDetail: { + reasonCode: 'cancelled', stoppedStage: this.view.journey?.current ?? 'Preparation', mutationStarted: false, + }, message: { tone: 'warning', text: 'Setup cancelled before applying further changes. Any PAT created at GitHub still exists until you delete it there.', copyId: 'session.cancelled' } }); + pending?.resolve(undefined); + return true; + } + setJourney(journey) { this.publish({ journey }); } + message(text, tone = 'info', link, copyId, copyValues, credentialChecks) { + this.publish({ message: { tone, text, ...(link ? { link } : {}), copyId, copyValues, credentialChecks } }); + } + requirements(role, requirements) { + this.publish({ permissions: { role, requirements, report: undefined } }); + } + report(report) { + this.publish({ permissions: { role: report.role, requirements: this.view.permissions?.requirements, report } }); + } + resultReason(reasonCode, diagnosticRef) { + if (this.view.outcome) + return; + this.publish({ resultDetail: { + reasonCode, + stoppedStage: this.view.journey?.current ?? 'Preparation', + mutationStarted: this.view.journey?.mutationStarted === true, + ...(this.view.resultDetail?.effects ? { effects: this.view.resultDetail.effects } : {}), + ...(diagnosticRef && /^[0-9a-f-]{36}$/u.test(diagnosticRef) ? { diagnosticRef } : {}), + } }); + } + effects(effects) { + if (this.view.outcome) + return; + this.publish({ resultDetail: { reasonCode: this.view.resultDetail?.reasonCode ?? 'unknown', + stoppedStage: this.view.journey?.current ?? 'Preparation', + mutationStarted: this.view.journey?.mutationStarted === true, effects, + ...(this.view.resultDetail?.diagnosticRef ? { diagnosticRef: this.view.resultDetail.diagnosticRef } : {}) } }); + } + finish(outcome, text) { + if (this.view.outcome) + return; + this.pending?.resolve(undefined); + this.pending = undefined; + this.publish({ prompt: undefined, promptRevision: undefined, outcome, message: { tone: outcome === 'complete' || outcome === 'dry-run' ? 'success' : 'warning', text }, + ...(this.view.resultDetail ? {} : { resultDetail: { + reasonCode: outcome === 'cancelled' ? 'cancelled' : outcome === 'blocked' ? 'unknown' : 'unknown', + stoppedStage: this.view.journey?.current ?? 'Preparation', + mutationStarted: this.view.journey?.mutationStarted === true, + } }), + }); + } + publish(change) { + this.revision += 1; + this.view = { ...this.view, ...change, revision: this.revision }; + for (const listener of this.subscribers) { + try { + listener(this.view); + } + catch { + this.subscribers.delete(listener); /* Observers cannot abort a setup decision. */ + } + } + } +} +exports.WebSetupBridge = WebSetupBridge; +function sameCapability(provided, expected) { + if (!/^[a-f0-9]{64}$/.test(provided)) + return false; + return (0, node_crypto_1.timingSafeEqual)(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex')); +} +function toWebSetupPlan(plan) { + return { + presentationDefaults: plan.presentationDefaults ?? [], + decisions: { + enabledCapabilities: Object.entries(plan.configuration.features).filter(([, enabled]) => enabled).map(([name]) => name), + agentRouting: Object.entries(plan.configuration.agents).map(([role, agent]) => ({ role, + provider: agent.provider, modelProvider: agent.modelProvider, model: agent.model })), + issueWorkflows: plan.configuration.features.issues ? plan.configuration.issueWorkflows.enabled : [], + productionBranch: plan.configuration.repository.mainBranch, + developmentBranch: plan.configuration.repository.developmentBranch, + approvalMode: plan.configuration.pullRequestApproval.mode, + trustedChecks: plan.configuration.pullRequestApproval.testChecks.map(check => ({ name: check.name, + sourceAppId: check.sourceAppId, workflowName: check.workflowName })), + producerAttested: plan.configuration.pullRequestApproval.producerAttested, + coverageMode: plan.configuration.pullRequestApproval.coverage.mode, + coverageCheck: plan.configuration.pullRequestApproval.coverage.checkName, + ...(plan.configuration.pullRequestApproval.coverage.mode === 'numeric' ? { + coverageMinimum: plan.configuration.pullRequestApproval.coverage.minDiffPercent, + coverageArtifactWorkflow: plan.configuration.pullRequestApproval.coverage.artifactWorkflowName, + coverageReporterAttested: plan.configuration.pullRequestApproval.coverage.reporterAttested, + } : {}), + projectNumbers: plan.configuration.projects.ids.split(',').filter(Boolean), + projectStatuses: [ + { transition: 'issueCreated', value: plan.configuration.projects.issueCreatedColumn }, + { transition: 'pullRequestCreated', value: plan.configuration.projects.pullRequestCreatedColumn }, + { transition: 'issueInProgress', value: plan.configuration.projects.issueInProgressColumn }, + { transition: 'pullRequestInProgress', value: plan.configuration.projects.pullRequestInProgressColumn }, + ], + variableScope: plan.configuration.manageRepositoryVariables ? plan.configuration.storage.variables.defaultScope : 'disabled', + secretScope: plan.configuration.manageRepositorySecrets ? plan.configuration.storage.secrets.defaultScope : 'disabled', + initialTag: plan.configuration.createInitialTag, + }, + files: plan.selectedFiles, + workflows: plan.workflowFiles, + variables: plan.variables.map(variable => variable.name), + secrets: plan.requiredSecrets, + warnings: plan.warnings, + }; +} + + +/***/ }), + +/***/ 63080: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.startWebSetupServer = startWebSetupServer; +exports.openWebSetupBrowser = openWebSetupBrowser; +const node_http_1 = __nccwpck_require__(88849); +const promises_1 = __nccwpck_require__(93977); +const node_path_1 = __nccwpck_require__(49411); +const node_child_process_1 = __nccwpck_require__(17718); +const node_crypto_1 = __nccwpck_require__(6005); +const MAX_BODY_BYTES = 8192; +const MAX_ANSWER_LENGTH = 4096; +const PAIRING_COOLDOWN_MS = 30000; +const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; +/** Transport only: setup policy and credential decisions live behind the bridge. */ +async function startWebSetupServer(bridge, assets = (0, node_path_1.join)(__dirname, '..', 'web')) { + const sessionKey = (0, node_crypto_1.randomBytes)(32); + const pairingCode = (0, node_crypto_1.randomBytes)(8); + let failedPairings = 0; + let pairingLockedUntil = 0; + const pairingCoolingDown = () => { + if (pairingLockedUntil && Date.now() >= pairingLockedUntil) { + pairingLockedUntil = 0; + failedPairings = 0; + } + return pairingLockedUntil > Date.now(); + }; + const recordInvalidPairing = () => { + failedPairings += 1; + if (failedPairings >= 5) + pairingLockedUntil = Date.now() + PAIRING_COOLDOWN_MS; + }; + const assetRoot = await (0, promises_1.realpath)(assets); + if (!(await (0, promises_1.realpath)((0, node_path_1.join)(assetRoot, 'index.html'))).startsWith(`${assetRoot}${node_path_1.sep}`)) { + throw new Error('Local setup index must be inside its packaged asset directory.'); + } + const indexHtml = (await (0, promises_1.readFile)((0, node_path_1.join)(assetRoot, 'index.html'))).toString('utf8'); + const allowedAssets = new Set([...indexHtml.matchAll(/(?:\.\/)?(assets\/[A-Za-z0-9._-]+\.(?:js|css))/g)] + .map(match => match[1])); + if (allowedAssets.size < 2) + throw new Error('Local setup web assets are incomplete. Reinstall Copilot or use terminal setup.'); + for (const asset of allowedAssets) { + const packagedPath = await (0, promises_1.realpath)((0, node_path_1.join)(assetRoot, asset)); + if (!packagedPath.startsWith(`${assetRoot}${node_path_1.sep}`)) + throw new Error('Local setup asset escapes its packaged directory.'); + await (0, promises_1.readFile)(packagedPath); + } + let closeResolver = () => undefined; + const closed = new Promise(resolveClosed => { closeResolver = resolveClosed; }); + let closing = false; + let idleTimer; + let resultTimer; + const armIdle = () => { + if (idleTimer) + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (!bridge.snapshot().journey?.mutationStarted && !bridge.snapshot().outcome) { + bridge.resultReason('session-expired'); + bridge.finish('blocked', 'This local setup session expired after 30 minutes without a decision. Start a new setup run; GitHub PATs are not revoked automatically.'); + } + }, 30 * 60 * 1000); + }; + const server = (0, node_http_1.createServer)(async (request, response) => { + const address = server.address(); + const origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const host = `127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + response.setHeader('Content-Security-Policy', CSP); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Referrer-Policy', 'no-referrer'); + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + response.setHeader('X-Frame-Options', 'DENY'); + try { + if (request.headers.host !== host || request.headers['x-forwarded-host'] || request.headers.forwarded + || request.headers['x-forwarded-proto'] || request.headers['sec-fetch-site'] === 'cross-site') { + respond(response, 403, { error: 'Invalid local host or request context.' }); + return; + } + if (request.method === 'POST' && (request.headers.origin !== origin + || (request.headers.referer && !request.headers.referer.startsWith(`${origin}/`)))) { + respond(response, 403, { error: 'Invalid request origin.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/pair') { + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + if (pairingCoolingDown()) { + respond(response, 429, { error: 'Too many pairing attempts. Wait 30 seconds and retry.' }); + return; + } + const body = await readJson(request); + if (!matchesHexSecret(body.code, pairingCode)) { + recordInvalidPairing(); + respond(response, 403, { error: 'Incorrect pairing code. Check the terminal.' }); + return; + } + failedPairings = 0; + respond(response, 200, { sessionKey: sessionKey.toString('hex') }); + return; + } + if (request.url?.startsWith('/api/') && !matchesHexSecret(request.headers['x-setup-session-key'], sessionKey)) { + respond(response, 403, { error: 'Pair this browser using the code printed by the CLI.' }); + return; + } + if (request.method === 'GET' && request.url === '/api/bootstrap') { + respond(response, 200, bridge.bootstrap()); + return; + } + if (request.method === 'GET' && request.url === '/api/state') { + respond(response, 200, bridge.snapshot()); + return; + } + if (request.method === 'POST' && request.url === '/api/takeover') { + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + const body = await readJson(request); + if (pairingCoolingDown()) { + respond(response, 429, { error: 'Too many pairing attempts. Wait 30 seconds and retry.' }); + return; + } + if (!matchesHexSecret(body.code, pairingCode)) { + recordInvalidPairing(); + respond(response, 403, { error: 'Incorrect pairing code. Check the launching output.' }); + return; + } + failedPairings = 0; + const capability = bridge.takeOver(); + armIdle(); + respond(response, 200, { capability }); + return; + } + if (request.method === 'POST' && request.url === '/api/answer') { + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'This tab is read-only.' }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + const body = await readJson(request); + if (!Number.isSafeInteger(body.revision) || body.revision <= 0 || typeof body.value !== 'string' || body.value.length > MAX_ANSWER_LENGTH) { + respond(response, 400, { error: 'Invalid answer.' }); + return; + } + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + const accepted = bridge.answer(body.revision, body.value); + const duplicate = !accepted && bridge.wasAnswered(body.revision); + if (accepted) + armIdle(); + respond(response, accepted || duplicate ? 200 : 409, accepted || duplicate ? { accepted: true, ...(duplicate ? { duplicate: true } : {}) } + : { error: 'This question changed. Refresh the current state.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/retry-discovery') { + const capability = String(request.headers['x-setup-capability'] ?? ''); + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'This tab is read-only.' }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + const body = await readJson(request); + if (!Number.isSafeInteger(body.revision) || body.revision <= 0) { + respond(response, 400, { error: 'Invalid question revision.' }); + return; + } + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + const result = await bridge.retryDiscovery(body.revision); + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + if (result === 'updated') + armIdle(); + respond(response, result === 'updated' ? 200 : 409, result === 'updated' + ? { updated: true } : { error: result === 'stale' ? 'This question changed. Refresh the current state.' + : 'Discovery cannot be retried here. Use the manual option.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/back') { + const capability = String(request.headers['x-setup-capability'] ?? ''); + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'This tab is read-only.' }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + const body = await readJson(request); + if (!Number.isSafeInteger(body.revision) || body.revision <= 0) { + respond(response, 400, { error: 'Invalid question revision.' }); + return; + } + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + const result = bridge.back(body.revision); + if (result === 'updated') + armIdle(); + respond(response, result === 'updated' ? 200 : 409, result === 'updated' + ? { updated: true } : { error: result === 'stale' ? 'This question changed. Refresh the current state.' + : 'No earlier question is available here.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/doctor') { + const capability = String(request.headers['x-setup-capability'] ?? ''); + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'This tab is read-only.' }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + await readJson(request); + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + const result = await bridge.runReadOnlyDoctor(); + if (!bridge.isController(capability)) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + respond(response, result === 'complete' ? 200 : 409, result === 'complete' + ? { checked: true } : { error: result === 'failed' ? 'Read-only verification failed. Check the terminal.' + : 'Read-only verification is unavailable or already running.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/cancel') { + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'This tab is read-only.' }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + await readJson(request); + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + const cancelled = bridge.cancel(); + respond(response, cancelled ? 200 : 409, cancelled ? { cancelled: true } : { error: 'This setup has already started applying or ended.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/close') { + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? '')) || !bridge.snapshot().outcome) { + respond(response, 403, { error: 'Only the controller can close a finished session.' }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + respond(response, 415, { error: 'JSON required.' }); + return; + } + await readJson(request); + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'Control moved to another tab.' }); + return; + } + respond(response, 200, { closed: true }); + setImmediate(() => void close()); + return; + } + if (request.method !== 'GET') { + respond(response, 405, { error: 'Method not allowed.' }); + return; + } + const pathname = request.url ?? ''; + if (pathname !== '/' && !/^\/assets\/[A-Za-z0-9._-]+$/.test(pathname)) { + respond(response, 404, { error: 'Not found.' }); + return; + } + const relative = pathname === '/' ? 'index.html' : pathname.slice(1); + if (relative !== 'index.html' && !allowedAssets.has(relative)) { + respond(response, 404, { error: 'Not found.' }); + return; + } + const file = (0, node_path_1.resolve)(assetRoot, relative); + const realFile = await (0, promises_1.realpath)(file); + if (!realFile.startsWith(`${assetRoot}${node_path_1.sep}`)) { + respond(response, 404, { error: 'Not found.' }); + return; + } + const content = await (0, promises_1.readFile)(realFile); + const contentType = file.endsWith('.js') ? 'text/javascript; charset=utf-8' + : file.endsWith('.css') ? 'text/css; charset=utf-8' + : 'text/html; charset=utf-8'; + response.writeHead(200, { 'Content-Type': contentType }); + response.end(content); + } + catch { + if (!response.headersSent) + respond(response, 400, { error: 'Invalid local request.' }); + else + response.end(); + } + }); + server.requestTimeout = 15000; + server.headersTimeout = 15000; + server.maxRequestsPerSocket = 250; + server.maxConnections = 16; + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolveListen(); }); + }); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Unable to bind local setup server.'); + const url = `http://127.0.0.1:${address.port}/`; + const close = async () => { + if (closing) + return closed; + closing = true; + if (idleTimer) + clearTimeout(idleTimer); + if (hardTimer) + clearTimeout(hardTimer); + if (resultTimer) + clearTimeout(resultTimer); + unsubscribe?.(); + bridge.cancel(); + server.closeAllConnections(); + await new Promise(resolveClose => server.close(() => resolveClose())); + closeResolver(); + }; + armIdle(); + const hardTimer = setTimeout(() => { + if (!bridge.snapshot().journey?.mutationStarted && !bridge.snapshot().outcome) { + bridge.resultReason('session-expired'); + bridge.finish('blocked', 'This local setup session reached its four-hour limit. Start a new run; no prior approval can be replayed.'); + } + }, 4 * 60 * 60 * 1000); + const unsubscribe = bridge.subscribe(view => { + if (view.outcome && !resultTimer) + resultTimer = setTimeout(() => void close(), 10 * 60 * 1000); + }); + return { url, pairingCode: pairingCode.toString('hex'), closed, close }; +} +function matchesHexSecret(value, expected) { + return typeof value === 'string' && value.length === expected.length * 2 && /^[a-f0-9]+$/.test(value) + && (0, node_crypto_1.timingSafeEqual)(Buffer.from(value, 'hex'), expected); +} +function respond(response, status, body) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(body)); +} +async function readJson(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) + throw new Error('Body too large.'); + chunks.push(buffer); + } + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') + throw new Error('JSON object required.'); + return parsed; +} +function openWebSetupBrowser(url) { + const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = (0, node_child_process_1.spawn)(command, args, { stdio: 'ignore', detached: true, windowsHide: true }); + child.on('error', () => { }); + child.unref(); +} + + /***/ }), /***/ 21307: @@ -66787,8 +70861,11 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.cleanCliArg = cleanCliArg; exports.getGitInfo = getGitInfo; exports.getCurrentBranch = getCurrentBranch; +exports.getCurrentAttachedBranch = getCurrentAttachedBranch; +exports.hasLocalOrTrackedGitBranch = hasLocalOrTrackedGitBranch; exports.getCurrentHeadSha = getCurrentHeadSha; exports.isInsideGitRepo = isInsideGitRepo; +exports.getGitRepositoryRoot = getGitRepositoryRoot; exports.isGitRepositoryRoot = isGitRepositoryRoot; const child_process_1 = __nccwpck_require__(32081); const node_fs_1 = __nccwpck_require__(87561); @@ -66820,6 +70897,29 @@ function getCurrentBranch() { return 'main'; } } +/** A verified branch name for web setup; detached HEAD and failed git reads are not guessed. */ +function getCurrentAttachedBranch(cwd) { + try { + const branch = (0, child_process_1.execSync)('git symbolic-ref --quiet --short HEAD', { cwd }).toString().trim(); + return branch && branch !== 'HEAD' ? branch : undefined; + } + catch { + return undefined; + } +} +/** Positive local evidence only; a missing ref says nothing about remote branches. */ +function hasLocalOrTrackedGitBranch(cwd, branch) { + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/u.test(branch) || branch.includes('..') || branch.endsWith('.lock')) + return false; + for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) { + try { + (0, child_process_1.execFileSync)('git', ['show-ref', '--verify', '--quiet', ref], { cwd, stdio: 'pipe' }); + return true; + } + catch { /* Try the other explicit ref. */ } + } + return false; +} /** Returns the canonical object ID for the workspace revision being analyzed. */ function getCurrentHeadSha() { try { @@ -66838,10 +70938,14 @@ function isInsideGitRepo(cwd) { return false; } } +/** Canonical checkout root for plans whose file paths are repository-relative. */ +function getGitRepositoryRoot(cwd) { + const root = (0, child_process_1.execSync)('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }).toString().trim(); + return (0, node_fs_1.realpathSync)(root); +} function isGitRepositoryRoot(cwd) { try { - const root = (0, child_process_1.execSync)('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }).toString().trim(); - return (0, node_fs_1.realpathSync)(root) === (0, node_fs_1.realpathSync)(cwd); + return getGitRepositoryRoot(cwd) === (0, node_fs_1.realpathSync)(cwd); } catch { return false; @@ -75296,6 +79400,7 @@ const github_error_policy_1 = __nccwpck_require__(58791); const credential_health_workflow_visibility_1 = __nccwpck_require__(57628); const tweetnacl_1 = __importDefault(__nccwpck_require__(24258)); const node_crypto_1 = __nccwpck_require__(6005); +const deployment_configuration_1 = __nccwpck_require__(22495); class GithubActionsResourceTransport { constructor(githubClient) { this.githubClient = githubClient; @@ -75331,6 +79436,8 @@ class GithubActionsResourceTransport { const credentialHealthWorkflow = await this.inspectDefaultCredentialHealthWorkflow(client, owner, repository); return { ownerType, + ...(typeof metadata.default_branch === 'string' && (0, deployment_configuration_1.isSafeBranchTree)(metadata.default_branch) + ? { defaultBranch: metadata.default_branch } : {}), repositoryId: metadata.id, repositoryVisibility, repositorySecrets: repositorySecretsResult.resources, @@ -78608,6 +82715,7 @@ function validatePullRequestApprovalPolicy(value, allowIncomplete = false) { } else { const identities = new Set(); + const names = new Set(); for (const item of value.testChecks) { if (!isRecord(item)) { errors.push('Each test check must be an object.'); @@ -78621,6 +82729,9 @@ function validatePullRequestApprovalPolicy(value, allowIncomplete = false) { if (identities.has(identity)) errors.push('Test checks cannot contain duplicate producer identities.'); identities.add(identity); + if (value.mode !== 'off' && !allowIncomplete && names.has(String(item.name))) + errors.push('Trusted check names must be unique because coverage stores only a check name.'); + names.add(String(item.name)); } } if (typeof value.producerAttested !== 'boolean') @@ -81034,9 +85145,13 @@ const github_identity_client_factory_1 = __nccwpck_require__(93081); const setup_remote_credential_health_adapter_1 = __nccwpck_require__(1489); const octokit_credential_health_adapter_1 = __nccwpck_require__(41760); const setup_token_permissions_composition_root_1 = __nccwpck_require__(64132); -function createSetupCredentialsUseCase(prompt, permissionPresenter) { +function createSetupCredentialsUseCase(prompt, permissionPresenter, options = {}) { const secretNames = new repository_variables_repository_1.RepositorySecretNamesQueryRepository((0, github_identity_client_factory_1.createRepositoryVariablesClient)()); - return new setup_credentials_use_case_1.SetupCredentialsUseCase(prompt, new setup_credential_validation_adapter_1.SetupCredentialValidationAdapter(), secretNames, new setup_remote_credential_health_adapter_1.SetupRemoteCredentialHealthBootstrapAdapter(new octokit_credential_health_adapter_1.OctokitCredentialHealthClientAdapter()), (0, setup_token_permissions_composition_root_1.createSetupTokenPermissionsUseCase)(), permissionPresenter); + return new setup_credentials_use_case_1.SetupCredentialsUseCase(prompt, new setup_credential_validation_adapter_1.SetupCredentialValidationAdapter(), secretNames, options.allowPreApplyHealthWorkflow === false + ? undefined + : new setup_remote_credential_health_adapter_1.SetupRemoteCredentialHealthBootstrapAdapter(new octokit_credential_health_adapter_1.OctokitCredentialHealthClientAdapter(), { + onTemporaryWorkflowMutationAttempt: options.onTemporaryWorkflowMutationAttempt, + }), (0, setup_token_permissions_composition_root_1.createSetupTokenPermissionsUseCase)(), permissionPresenter); } function createSetupRemoteConfigurationReadPort() { return new repository_variables_repository_1.SetupRemoteConfigurationQueryRepository((0, github_identity_client_factory_1.createRepositoryVariablesClient)()); @@ -81589,242 +85704,515 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getOctokitClient = getOctokitClient; +exports.getOctokitClient = getOctokitClient; +const github = __importStar(__nccwpck_require__(78227)); +function getOctokitClient(token) { + return github.getOctokit(token); +} + + +/***/ }), + +/***/ 41760: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitCredentialHealthClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitCredentialHealthClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitCredentialHealthClientAdapter = OctokitCredentialHealthClientAdapter; + + +/***/ }), + +/***/ 46819: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitDeploymentClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitDeploymentClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitDeploymentClientAdapter = OctokitDeploymentClientAdapter; + + +/***/ }), + +/***/ 29996: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitOwnerTypeClientAdapter = exports.OctokitOrganizationMembersClientAdapter = exports.OctokitActorAuthorizationClientAdapter = exports.OctokitAuthenticatedUserClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitAuthenticatedUserClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitAuthenticatedUserClientAdapter = OctokitAuthenticatedUserClientAdapter; +class OctokitActorAuthorizationClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitActorAuthorizationClientAdapter = OctokitActorAuthorizationClientAdapter; +class OctokitOrganizationMembersClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitOrganizationMembersClientAdapter = OctokitOrganizationMembersClientAdapter; +class OctokitOwnerTypeClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitOwnerTypeClientAdapter = OctokitOwnerTypeClientAdapter; + + +/***/ }), + +/***/ 77179: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitIssueTitleClientAdapter = exports.OctokitIssueMetadataClientAdapter = exports.OctokitIssueInactivityClientAdapter = exports.OctokitIssueLifecycleClientAdapter = exports.OctokitIssueLabelsClientAdapter = exports.OctokitIssueLabelProvisioningClientAdapter = exports.OctokitIssueContentClientAdapter = exports.OctokitIssueAssignmentClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitIssueAssignmentClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueAssignmentClientAdapter = OctokitIssueAssignmentClientAdapter; +class OctokitIssueContentClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueContentClientAdapter = OctokitIssueContentClientAdapter; +class OctokitIssueLabelProvisioningClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueLabelProvisioningClientAdapter = OctokitIssueLabelProvisioningClientAdapter; +class OctokitIssueLabelsClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueLabelsClientAdapter = OctokitIssueLabelsClientAdapter; +class OctokitIssueLifecycleClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueLifecycleClientAdapter = OctokitIssueLifecycleClientAdapter; +class OctokitIssueInactivityClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueInactivityClientAdapter = OctokitIssueInactivityClientAdapter; +class OctokitIssueMetadataClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueMetadataClientAdapter = OctokitIssueMetadataClientAdapter; +class OctokitIssueTitleClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitIssueTitleClientAdapter = OctokitIssueTitleClientAdapter; + + +/***/ }), + +/***/ 68505: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitGraphqlTransportClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitGraphqlTransportClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitGraphqlTransportClientAdapter = OctokitGraphqlTransportClientAdapter; + + +/***/ }), + +/***/ 1397: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitPullRequestReviewCommentClientAdapter = exports.OctokitPullRequestReviewerClientAdapter = exports.OctokitPullRequestLifecycleClientAdapter = exports.OctokitPullRequestChangesClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitPullRequestChangesClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitPullRequestChangesClientAdapter = OctokitPullRequestChangesClientAdapter; +class OctokitPullRequestLifecycleClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitPullRequestLifecycleClientAdapter = OctokitPullRequestLifecycleClientAdapter; +class OctokitPullRequestReviewerClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitPullRequestReviewerClientAdapter = OctokitPullRequestReviewerClientAdapter; +class OctokitPullRequestReviewCommentClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitPullRequestReviewCommentClientAdapter = OctokitPullRequestReviewCommentClientAdapter; + + +/***/ }), + +/***/ 5334: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitReleaseClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitReleaseClientAdapter { + getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } +} +exports.OctokitReleaseClientAdapter = OctokitReleaseClientAdapter; + + +/***/ }), + +/***/ 81329: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitRepositoryVariablesClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitRepositoryVariablesClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitRepositoryVariablesClientAdapter = OctokitRepositoryVariablesClientAdapter; + + +/***/ }), + +/***/ 86719: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OctokitWorkflowDispatchClientAdapter = exports.OctokitWorkflowRunsClientAdapter = void 0; +const octokit_client_resolver_1 = __nccwpck_require__(54047); +class OctokitWorkflowRunsClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitWorkflowRunsClientAdapter = OctokitWorkflowRunsClientAdapter; +class OctokitWorkflowDispatchClientAdapter { + getClient(token) { + return (0, octokit_client_resolver_1.getOctokitClient)(token); + } +} +exports.OctokitWorkflowDispatchClientAdapter = OctokitWorkflowDispatchClientAdapter; + + +/***/ }), + +/***/ 96997: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PROJECT_BOARD_ITEM_PAGE_LIMIT = void 0; +// GitHub Projects currently permits up to 50,000 items per project. +exports.PROJECT_BOARD_ITEM_PAGE_LIMIT = 500; + + +/***/ }), + +/***/ 42294: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GithubSetupApprovalCheckDiscoveryAdapter = void 0; +const github = __importStar(__nccwpck_require__(78227)); +/** Bounded, read-only GitHub evidence. Unavailable permissions yield no suggestions, never invented identities. */ +class GithubSetupApprovalCheckDiscoveryAdapter { + async discover(owner, repository, token, targetBranch) { + const octokit = github.getOctokit(token); + // Active rules need only Metadata: read. Only repository-owned rulesets + // get an exact, safe detail link; inherited rules remain unverified here. + let required = new Map(); + if (targetBranch && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/u.test(targetBranch)) { + try { + const response = await octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { + owner, repo: repository, branch: targetBranch, per_page: 100, + }); + for (const rule of response.data) { + if (rule.type !== 'required_status_checks' || rule.ruleset_source_type !== 'Repository' + || rule.ruleset_source?.toLowerCase() !== `${owner}/${repository}`.toLowerCase() + || !Number.isSafeInteger(rule.ruleset_id) || rule.ruleset_id <= 0) + continue; + for (const check of rule.parameters?.required_status_checks ?? []) { + if (safeProducerName(check.context ?? '') && Number.isSafeInteger(check.integration_id) && check.integration_id > 0) { + required.set(`${check.context}\u0000${check.integration_id}`, `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/rules/${rule.ruleset_id}`); + } + } + } + } + catch { + required = new Map(); + } + } + let recent; + try { + recent = await octokit.rest.actions.listWorkflowRunsForRepo({ owner, repo: repository, event: 'pull_request', per_page: 20 }); + } + catch (error) { + return { status: discoveryFailure(error), candidates: [] }; + } + if (recent.data.workflow_runs.length === 0) + return { status: 'no-recent-runs', candidates: [] }; + const candidates = new Map(); + const checksByHead = new Map(); + try { + for (const run of recent.data.workflow_runs.slice(0, 15)) { + const headSha = run.head_sha; + if (!/^[a-f0-9]{40}$/iu.test(headSha)) + continue; + if (!run.name || run.name.startsWith('Copilot -') || run.status !== 'completed') + continue; + let checks = checksByHead.get(headSha); + if (!checks) { + const response = await octokit.rest.checks.listForRef({ owner, repo: repository, ref: headSha, filter: 'all', per_page: 100 }); + checks = response.data.check_runs; + checksByHead.set(headSha, checks); + } + const jobs = await octokit.rest.actions.listJobsForWorkflowRunAttempt({ + owner, repo: repository, run_id: run.id, attempt_number: run.run_attempt, per_page: 100, + }); + for (const job of jobs.data.jobs) { + const id = Number(job.check_run_url?.match(/\/check-runs\/(\d+)$/u)?.[1]); + const check = checks.find(item => item.id === id && item.head_sha === run.head_sha); + if (!check?.app?.id || !Number.isSafeInteger(check.app.id) + || !safeProducerName(check.name) || !safeProducerName(run.name) + || check.name === 'Copilot / Approval') + continue; + const identity = `${check.name}\u0000${check.app.id}\u0000${run.name}`; + if (!candidates.has(identity)) + candidates.set(identity, { + name: check.name, sourceAppId: check.app.id, workflowName: run.name, + ...(check.app.name && safeProducerName(check.app.name) ? { sourceAppName: check.app.name } : {}), + runUrl: `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/runs/${run.id}`, + headSha, conclusion: check.conclusion ?? 'unknown', + ...(run.created_at && Number.isFinite(Date.parse(run.created_at)) ? { observedAt: run.created_at } : {}), + ...(required.has(`${check.name}\u0000${check.app.id}`) ? { requiredByRuleset: { + branch: targetBranch, sourceUrl: required.get(`${check.name}\u0000${check.app.id}`), + } } : {}), + }); + if (candidates.size >= 30) + return { status: 'observed', candidates: [...candidates.values()], truncated: true }; + } + } + } + catch (error) { + return { status: discoveryFailure(error), candidates: [] }; + } + return { status: candidates.size > 0 ? 'observed' : 'no-verifiable-checks', candidates: [...candidates.values()], + ...(recent.data.workflow_runs.length > 15 ? { truncated: true } : {}) }; + } +} +exports.GithubSetupApprovalCheckDiscoveryAdapter = GithubSetupApprovalCheckDiscoveryAdapter; +function discoveryFailure(error) { + const status = typeof error === 'object' && error !== null && 'status' in error ? Number(error.status) : undefined; + return status === 401 || status === 403 ? 'permission-denied' : 'unavailable'; +} +function safeProducerName(value) { + return typeof value === 'string' && value.trim() === value && /^[^\p{Cc}\p{Cf}${}<>|;]{1,100}$/u.test(value); +} + + +/***/ }), + +/***/ 29564: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GithubSetupProjectDiscoveryAdapter = void 0; const github = __importStar(__nccwpck_require__(78227)); -function getOctokitClient(token) { - return github.getOctokit(token); -} - - -/***/ }), - -/***/ 41760: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitCredentialHealthClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitCredentialHealthClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); - } -} -exports.OctokitCredentialHealthClientAdapter = OctokitCredentialHealthClientAdapter; - - -/***/ }), - -/***/ 46819: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitDeploymentClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitDeploymentClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); - } -} -exports.OctokitDeploymentClientAdapter = OctokitDeploymentClientAdapter; - - -/***/ }), - -/***/ 29996: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitOwnerTypeClientAdapter = exports.OctokitOrganizationMembersClientAdapter = exports.OctokitActorAuthorizationClientAdapter = exports.OctokitAuthenticatedUserClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitAuthenticatedUserClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitAuthenticatedUserClientAdapter = OctokitAuthenticatedUserClientAdapter; -class OctokitActorAuthorizationClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitActorAuthorizationClientAdapter = OctokitActorAuthorizationClientAdapter; -class OctokitOrganizationMembersClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitOrganizationMembersClientAdapter = OctokitOrganizationMembersClientAdapter; -class OctokitOwnerTypeClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitOwnerTypeClientAdapter = OctokitOwnerTypeClientAdapter; - - -/***/ }), - -/***/ 77179: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitIssueTitleClientAdapter = exports.OctokitIssueMetadataClientAdapter = exports.OctokitIssueInactivityClientAdapter = exports.OctokitIssueLifecycleClientAdapter = exports.OctokitIssueLabelsClientAdapter = exports.OctokitIssueLabelProvisioningClientAdapter = exports.OctokitIssueContentClientAdapter = exports.OctokitIssueAssignmentClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitIssueAssignmentClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueAssignmentClientAdapter = OctokitIssueAssignmentClientAdapter; -class OctokitIssueContentClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueContentClientAdapter = OctokitIssueContentClientAdapter; -class OctokitIssueLabelProvisioningClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueLabelProvisioningClientAdapter = OctokitIssueLabelProvisioningClientAdapter; -class OctokitIssueLabelsClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueLabelsClientAdapter = OctokitIssueLabelsClientAdapter; -class OctokitIssueLifecycleClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueLifecycleClientAdapter = OctokitIssueLifecycleClientAdapter; -class OctokitIssueInactivityClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueInactivityClientAdapter = OctokitIssueInactivityClientAdapter; -class OctokitIssueMetadataClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueMetadataClientAdapter = OctokitIssueMetadataClientAdapter; -class OctokitIssueTitleClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitIssueTitleClientAdapter = OctokitIssueTitleClientAdapter; - - -/***/ }), - -/***/ 68505: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitGraphqlTransportClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitGraphqlTransportClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitGraphqlTransportClientAdapter = OctokitGraphqlTransportClientAdapter; - - -/***/ }), - -/***/ 1397: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitPullRequestReviewCommentClientAdapter = exports.OctokitPullRequestReviewerClientAdapter = exports.OctokitPullRequestLifecycleClientAdapter = exports.OctokitPullRequestChangesClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitPullRequestChangesClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); - } -} -exports.OctokitPullRequestChangesClientAdapter = OctokitPullRequestChangesClientAdapter; -class OctokitPullRequestLifecycleClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); - } -} -exports.OctokitPullRequestLifecycleClientAdapter = OctokitPullRequestLifecycleClientAdapter; -class OctokitPullRequestReviewerClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); +/** Bounded, read-only organization Project inventory; personal fine-grained PATs cannot use GitHub's user REST listing. */ +class GithubSetupProjectDiscoveryAdapter { + async discover(owner, ownerType, token) { + if (ownerType === 'User') + return { status: 'unsupported', candidates: [] }; + if (ownerType !== 'Organization') + return { status: 'unavailable', candidates: [] }; + const octokit = github.getOctokit(token); + const candidates = []; + let nextPage; + let truncated = false; + try { + for (let page = 0; page < 2; page += 1) { + const response = await octokit.request('GET /orgs/{org}/projectsV2', { + org: owner, per_page: 50, ...(nextPage ?? {}), + }); + for (const row of response.data) { + if (!Number.isSafeInteger(row.number) || Number(row.number) < 1 || row.state === 'closed' || row.closed_at != null + || typeof row.title !== 'string' || !safeDisplayText(row.title)) + continue; + const number = Number(row.number); + candidates.push({ number, title: row.title, owner, + url: `https://github.com/orgs/${encodeURIComponent(owner)}/projects/${number}` }); + } + nextPage = nextPagination(response.headers.link); + if (!nextPage) + break; + if (candidates.length >= 30) { + truncated = true; + break; + } + if (page === 1) + truncated = true; + } + } + catch (error) { + return { status: discoveryFailure(error), candidates: [] }; + } + const unique = [...new Map(candidates.map(candidate => [candidate.number, candidate])).values()]; + if (unique.length > 30) + truncated = true; + const inspected = await Promise.all(unique.slice(0, 30).map(async (candidate) => { + try { + const response = await octokit.request('GET /orgs/{org}/projectsV2/{project_number}/fields', { + org: owner, project_number: candidate.number, per_page: 100, + }); + if (nextPagination(response.headers.link)) + return candidate; + const status = response.data.find(field => field.name === 'Status' && field.data_type === 'single_select'); + const options = status?.options?.map(option => typeof option.name === 'string' ? option.name : option.name?.raw) + .filter((name) => typeof name === 'string' && safeDisplayText(name)); + return options?.length ? { ...candidate, statusOptions: [...new Set(options)] } : candidate; + } + catch { + return candidate; + } + })); + return { status: inspected.length ? 'observed' : 'empty', candidates: inspected, ...(truncated ? { truncated: true } : {}) }; } } -exports.OctokitPullRequestReviewerClientAdapter = OctokitPullRequestReviewerClientAdapter; -class OctokitPullRequestReviewCommentClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); +exports.GithubSetupProjectDiscoveryAdapter = GithubSetupProjectDiscoveryAdapter; +function nextPagination(link) { + if (typeof link !== 'string') + return undefined; + const next = link.split(',').find(part => /;\s*rel="next"/u.test(part)); + const urlText = next?.match(/<([^>]+)>/u)?.[1]; + if (!urlText) + return undefined; + try { + const url = new URL(urlText); + if (url.protocol !== 'https:' || url.hostname !== 'api.github.com' || url.username || url.password) + return undefined; + const cursor = url.searchParams.get('after'); + if (cursor) + return cursor.length <= 200 ? { after: cursor } : undefined; + const page = url.searchParams.get('page'); + return page && /^[1-9]\d{0,5}$/u.test(page) ? { page: Number(page) } : undefined; } -} -exports.OctokitPullRequestReviewCommentClientAdapter = OctokitPullRequestReviewCommentClientAdapter; - - -/***/ }), - -/***/ 5334: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitReleaseClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitReleaseClientAdapter { - getClient(token) { return (0, octokit_client_resolver_1.getOctokitClient)(token); } -} -exports.OctokitReleaseClientAdapter = OctokitReleaseClientAdapter; - - -/***/ }), - -/***/ 81329: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitRepositoryVariablesClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitRepositoryVariablesClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); + catch { + return undefined; } } -exports.OctokitRepositoryVariablesClientAdapter = OctokitRepositoryVariablesClientAdapter; - - -/***/ }), - -/***/ 86719: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OctokitWorkflowDispatchClientAdapter = exports.OctokitWorkflowRunsClientAdapter = void 0; -const octokit_client_resolver_1 = __nccwpck_require__(54047); -class OctokitWorkflowRunsClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); - } +function safeDisplayText(value) { + return value.trim() === value && value.length > 0 && value.length <= 120 && !/[\p{Cc}\p{Cf}<>]/u.test(value); } -exports.OctokitWorkflowRunsClientAdapter = OctokitWorkflowRunsClientAdapter; -class OctokitWorkflowDispatchClientAdapter { - getClient(token) { - return (0, octokit_client_resolver_1.getOctokitClient)(token); - } +function discoveryFailure(error) { + const status = typeof error === 'object' && error !== null && 'status' in error ? Number(error.status) : undefined; + return status === 401 || status === 403 ? 'permission-denied' : 'unavailable'; } -exports.OctokitWorkflowDispatchClientAdapter = OctokitWorkflowDispatchClientAdapter; - - -/***/ }), - -/***/ 96997: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PROJECT_BOARD_ITEM_PAGE_LIMIT = void 0; -// GitHub Projects currently permits up to 50,000 items per project. -exports.PROJECT_BOARD_ITEM_PAGE_LIMIT = 500; /***/ }), @@ -82549,6 +86937,66 @@ function safeMessage(error) { } +/***/ }), + +/***/ 56098: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SetupGithubIdentityQueryAdapter = void 0; +const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +class SetupGithubIdentityQueryAdapter { + constructor(fetcher = fetch, timeoutMs = 10000) { + this.fetcher = fetcher; + this.timeoutMs = timeoutMs; + } + async resolve(login, setupToken) { + if (!LOGIN_PATTERN.test(login)) + throw new Error('Enter a valid GitHub bot account login.'); + return this.request(`https://api.github.com/users/${encodeURIComponent(login)}`, setupToken); + } + identify(token) { + return this.request('https://api.github.com/user', token); + } + async request(url, token) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetcher(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + if (!response.ok) + throw new Error('GitHub could not verify the selected bot account or token identity. No Secret was written.'); + const body = await response.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new Error('GitHub returned an invalid identity. No Secret was written.'); + const { id, login } = body; + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0 || typeof login !== 'string' || !LOGIN_PATTERN.test(login)) { + throw new Error('GitHub returned an invalid identity. No Secret was written.'); + } + return { id, login }; + } + catch (error) { + if (error instanceof Error && error.message.includes('No Secret was written.')) + throw error; + throw Object.assign(new Error('GitHub identity verification failed. Check network access and retry; no Secret was written.'), { cause: error }); + } + finally { + clearTimeout(timeout); + } + } +} +exports.SetupGithubIdentityQueryAdapter = SetupGithubIdentityQueryAdapter; + + /***/ }), /***/ 1489: @@ -82643,6 +87091,7 @@ class SetupRemoteCredentialHealthBootstrapAdapter { this.githubClient = githubClient; this.options = resolveOptions(options); this.workflowContent = options.workflowContent ?? readHealthWorkflow(); + this.onTemporaryWorkflowMutationAttempt = options.onTemporaryWorkflowMutationAttempt; } async validateExisting(owner, repository, token, ref, requirements) { const client = this.githubClient.getClient(token); @@ -82668,6 +87117,7 @@ class SetupRemoteCredentialHealthBootstrapAdapter { if (!this.workflowContent) throw new Error('Credential health workflow template is unavailable.'); let created; + this.onTemporaryWorkflowMutationAttempt?.(); try { created = await client.repos.createOrUpdateFileContents({ owner, @@ -90294,8 +94744,8 @@ function isInside(root, candidate) { function matchesFieldBoundary(field, relativePath) { if (field === 'specs') return /^specs\/(?!README\.md$|_template\.md$|CATALOG\.md$).+\.md$/.test(relativePath); if (field === 'workflows') return /^(?:\.github|setup)\/workflows\/.+\.ya?ml$/.test(relativePath); - if (field === 'entrypoints') return /^(?:src\/.+|action\.yml|package\.json)$/.test(relativePath); - if (field === 'code') return /^(?:src|scripts)\//.test(relativePath); + if (field === 'entrypoints') return /^(?:src\/.+|web\/src\/main\.ts|action\.yml|package\.json)$/.test(relativePath); + if (field === 'code') return /^(?:(?:src|scripts)\/|web\/src\/.+\.(?:ts|svelte|css)$)/.test(relativePath); if (field === 'tests') return /^src\/.*(?:__tests__\/.*\.test\.ts|\.test\.ts)$/.test(relativePath); if (field === 'documentation') return /^(?:docs\/.*\.(?:md|mdx)|README\.md|CONTRIBUTING\.md)$/.test(relativePath); return false; diff --git a/build/github_action/index.js b/build/github_action/index.js index 920afe545..da21e04f3 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -49091,6 +49091,7 @@ function normalizeSetupConfigurationLocales(configuration) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildSetupCredentialRequirements = void 0; exports.buildSetupPlan = buildSetupPlan; +exports.setupPlanGuardPaths = setupPlanGuardPaths; exports.buildSetupRepositoryVariables = buildSetupRepositoryVariables; exports.buildSetupActionInputs = buildSetupActionInputs; const pull_request_description_1 = __nccwpck_require__(45315); @@ -49102,6 +49103,7 @@ Object.defineProperty(exports, "buildSetupCredentialRequirements", ({ enumerable const locale_1 = __nccwpck_require__(15386); const issue_workflow_profile_1 = __nccwpck_require__(26744); const setup_issue_workflow_policy_1 = __nccwpck_require__(81182); +const repository_agent_guidance_policy_1 = __nccwpck_require__(67402); function buildSetupPlan(configuration, mergeQueueReadiness = [], approvalReadiness = []) { const workflowFiles = (0, setup_workflow_catalog_1.enabledSetupWorkflowFiles)((0, setup_issue_workflow_policy_1.effectiveIssueWorkflowFeatures)(configuration)) .filter(file => file !== 'copilot_pull_request_approval.yml' || configuration.pullRequestApproval.mode !== 'off'); @@ -49140,6 +49142,40 @@ function buildSetupPlan(configuration, mergeQueueReadiness = [], approvalReadine warnings: buildSetupWarnings(configuration), }; } +/** Actual checkout destinations covered by a web Apply drift check. + * The presentation plan uses package-source labels for workflows/forms; + * comparing those labels as checkout paths would silently miss local edits. + */ +function setupPlanGuardPaths(plan) { + const selected = plan.selectedFiles.map(file => { + if (file.startsWith('workflows/')) + return `.github/${file}`; + if (file.startsWith('ISSUE_TEMPLATE/')) + return `.github/${file}`; + if (file === 'pull_request_template.md') + return '.github/pull_request_template.md'; + if (file === 'AGENTS.md (managed pointer only)') + return 'AGENTS.md'; + return file; + }); + // Deselected managed assets can be retired to setup-backups during Apply. + const retiredCandidates = [ + ...['config.yml', ...issue_workflow_profile_1.ISSUE_WORKFLOW_KINDS.map(kind => issue_workflow_profile_1.ISSUE_WORKFLOW_CATALOG[kind].formFile)] + .map(file => `.github/ISSUE_TEMPLATE/${file}`), + ...['release_workflow.yml', 'hotfix_workflow.yml', 'copilot_deployment_orchestration.yml'] + .map(file => `.github/workflows/${file}`), + ]; + // The manifest can authorize retirement even when guidance is disabled and + // its artifacts are absent from the presentation plan. + const guidanceCandidates = [ + repository_agent_guidance_policy_1.REPOSITORY_AGENT_MANIFEST_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_PROFILE_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_GUIDE_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_SKILL_PATH, + repository_agent_guidance_policy_1.REPOSITORY_AGENT_POINTER_PATH, + ]; + return [...new Set([...selected, ...retiredCandidates, ...guidanceCandidates])].sort(); +} function buildSetupRepositoryVariables(configuration) { const variables = []; const add = (name, value) => { @@ -49336,7 +49372,7 @@ function buildSetupWarnings(configuration) { warnings.push('Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.'); } if (configuration.projects.ids.trim()) { - warnings.push('Project IDs must be accessible to the PAT and use the expected project column names.'); + warnings.push('Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.'); } if ((0, setup_configuration_defaults_1.setupAgentTasksForFeatures)(configuration).some(task => configuration.agents[task].provider === 'cursor')) { warnings.push('Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.'); @@ -49630,8 +49666,20 @@ const locale_1 = __nccwpck_require__(15386); const issue_workflow_profile_1 = __nccwpck_require__(26744); const setup_issue_workflow_policy_1 = __nccwpck_require__(81182); const pull_request_approval_policy_1 = __nccwpck_require__(98820); +const setup_project_selection_policy_1 = __nccwpck_require__(73750); function validateSetupConfiguration(configuration, options = {}) { const errors = []; + const projectSelection = (0, setup_project_selection_policy_1.parseSetupProjectSelection)(configuration.projects.ids); + if ('error' in projectSelection || projectSelection.value !== configuration.projects.ids) { + errors.push('Project IDs must be a comma-separated list of 1–10 distinct positive Project URL numbers; PVT_ node IDs are not accepted.'); + } + if (configuration.projects.ids) { + for (const [name, value] of Object.entries(configuration.projects).filter(([name]) => name.endsWith('Column'))) { + if (typeof value !== 'string' || !value.trim() || value.length > 100 || /[\p{Cc}\p{Cf}]/u.test(value)) { + errors.push(`Project ${name} must name one existing single-line Status option (1–100 characters).`); + } + } + } errors.push(...(0, pull_request_approval_policy_1.validatePullRequestApprovalPolicy)(configuration.pullRequestApproval, options.allowIncompleteApproval === true)); if (configuration.actionInputs['pr-approval-policy'] !== undefined) { errors.push('pr-approval-policy cannot be overridden through actionInputs.'); @@ -50030,6 +50078,80 @@ function effectiveIssueFormLabels(configuration) { } +/***/ }), + +/***/ 73750: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.parseSetupProjectSelection = parseSetupProjectSelection; +exports.sharedProjectStatusOptions = sharedProjectStatusOptions; +exports.validateDiscoveredProjectStatuses = validateDiscoveredProjectStatuses; +function parseSetupProjectSelection(raw, owner) { + const input = raw.normalize('NFKC').trim(); + if (!input || input.toLowerCase() === 'none') + return { value: '' }; + const parts = input.split(',').map(part => part.trim()); + if (parts.length > 10 || parts.some(part => !part)) + return { error: 'Choose at most 10 Projects; separate numbers or URLs with commas.' }; + const numbers = []; + for (const part of parts) { + let numberText = part; + if (part.startsWith('https://')) { + if (!owner) + return { error: 'A Project URL needs a known repository owner; enter its positive number instead.' }; + try { + const url = new URL(part); + const match = url.pathname.match(/^\/(?:orgs|users)\/([^/]+)\/projects\/([1-9]\d*)\/?$/u); + if (url.origin !== 'https://github.com' || url.search || url.hash || url.username || url.password + || !match || decodeURIComponent(match[1]).toLowerCase() !== owner.toLowerCase()) { + return { error: `Use a GitHub Project URL belonging to ${owner}, without query parameters.` }; + } + numberText = match[2]; + } + catch { + return { error: 'Enter a valid GitHub Project URL or positive Project number.' }; + } + } + if (!/^[1-9]\d*$/u.test(numberText)) + return { error: 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.' }; + const number = Number(numberText); + if (!Number.isSafeInteger(number) || number > 2147483647) + return { error: 'Project numbers must be positive integers at most 2147483647.' }; + if (numbers.includes(number)) + return { error: `Project ${number} was selected more than once.` }; + numbers.push(number); + } + return { value: numbers.join(',') }; +} +function sharedProjectStatusOptions(projectNumbers, projects) { + const numbers = projectNumbers.split(',').map(Number).filter(Boolean); + if (!numbers.length) + return { state: 'unavailable', options: [] }; + const selected = numbers.map(number => projects.find(project => project.number === number)); + if (selected.some(project => !project?.statusOptions?.length)) + return { state: 'unavailable', options: [] }; + const [first, ...rest] = selected; + const common = first.statusOptions.filter(option => rest.every(project => project.statusOptions.includes(option))); + return common.length ? { state: 'observed', options: common } : { state: 'incompatible', options: [] }; +} +/** A discovered mismatch is unsafe even if values arrived through --config rather than the interactive selector. */ +function validateDiscoveredProjectStatuses(configuration, discovery) { + if (!configuration.projects.ids || !discovery || discovery.status !== 'observed') + return []; + const common = sharedProjectStatusOptions(configuration.projects.ids, discovery.candidates); + if (common.state === 'incompatible') + return ['Selected Projects have no common Status option. Choose compatible Projects.']; + if (common.state !== 'observed') + return []; + const names = [configuration.projects.issueCreatedColumn, configuration.projects.pullRequestCreatedColumn, + configuration.projects.issueInProgressColumn, configuration.projects.pullRequestInProgressColumn]; + return names.filter(name => !common.options.includes(name)).map(name => `Status value "${name}" is not available in every selected Project.`); +} + + /***/ }), /***/ 3449: @@ -52000,20 +52122,46 @@ async function runInitialSetupWorkflow(request, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(TASK_ID)} Executing ${TASK_ID}.`); const steps = []; const errors = []; + const configuration = request.setupConfiguration; + const effects = [ + { id: 'files', state: 'not-started', scope: 'local' }, + { id: 'secrets', state: 'not-started', scope: resourceScope(configuration, 'secrets') }, + { id: 'labels', state: 'not-started', scope: 'repository' }, + { id: 'issue-types', state: 'not-started', scope: 'repository' }, + { id: 'variables', state: 'not-started', scope: resourceScope(configuration, 'variables') }, + { id: 'initial-tag', state: 'not-started', scope: 'repository' }, + ]; + const mark = (id, state) => { + const index = effects.findIndex(effect => effect.id === id); + effects[index] = { ...effects[index], state }; + }; + const receipt = () => buildResult(errors, steps, effects); try { const setupConfiguration = request.setupConfiguration; if (!dependencies.setupWorkspacePort.hasValidToken()) { (0, logging_ports_1.logInfo)(' 🛑 Setup requires the setup PAT provided for this command with a valid token.'); errors.push(new application_error_1.ApplicationError('authorization.credential-invalid', 'A valid setup PAT must be provided to run setup. It is separate from the workflow PAT Secret.')); - return [buildResult(errors, steps)]; + return [receipt()]; } (0, logging_ports_1.logInfo)('🔐 Checking GitHub access...'); const githubAccess = await verifyGitHubAccess(request, dependencies.authenticatedUserPort); if (!githubAccess.success) { errors.push(...githubAccess.errors); - return [buildResult(errors, steps)]; + return [receipt()]; } steps.push(`✅ GitHub access verified: ${githubAccess.user}`); + const secretValues = Number(Boolean(request.setupCredentials?.workflowPat)) + (request.setupCredentials?.apiKeys.length ?? 0); + const missingProvisioningPorts = []; + if (setupConfiguration?.manageRepositorySecrets && secretValues > 0 && !dependencies.setupRepositorySecretsPort) { + missingProvisioningPorts.push(new application_error_1.ApplicationError('provider.unavailable', setup_resource_provisioning_1.SECRET_PROVISIONING_UNAVAILABLE)); + } + if (setupConfiguration?.manageRepositoryVariables && !dependencies.setupRepositoryVariablesPort) { + missingProvisioningPorts.push(new application_error_1.ApplicationError('provider.unavailable', setup_resource_provisioning_1.VARIABLE_PROVISIONING_UNAVAILABLE)); + } + if (missingProvisioningPorts.length > 0) { + errors.push(...missingProvisioningPorts); + return [receipt()]; + } const remoteConfigurationErrors = []; const remoteConfiguration = await (0, setup_resource_provisioning_1.resolveRemoteConfiguration)(request, dependencies, setupConfiguration, remoteConfigurationErrors); errors.push(...fromMessages(remoteConfigurationErrors, 'provider.unavailable')); @@ -52022,7 +52170,7 @@ async function runInitialSetupWorkflow(request, dependencies) { if (remoteConfigurationErrors.length === 0) { errors.push(new application_error_1.ApplicationError('provider.unavailable', 'Could not inspect existing GitHub Actions resource scopes. Restore inventory access and rerun setup.')); } - return [buildResult(errors, steps)]; + return [receipt()]; } const inventoryErrors = [ ...(0, setup_configuration_policy_1.validateSetupStorageAgainstRemote)(setupConfiguration, remoteConfiguration), @@ -52033,7 +52181,7 @@ async function runInitialSetupWorkflow(request, dependencies) { ]; if (inventoryErrors.length > 0) { errors.push(...fromMessages(inventoryErrors, 'provider.unavailable')); - return [buildResult(errors, steps)]; + return [receipt()]; } } (0, logging_ports_1.logInfo)('📋 Ensuring .github and copying setup files...'); @@ -52045,15 +52193,23 @@ async function runInitialSetupWorkflow(request, dependencies) { approvedWorkflowFiles: request.workflowUpdates, } : {}), }; + mark('files', 'needs-inspection'); const filesResult = dependencies.setupWorkspacePort.prepare(workspaceSelection); + mark('files', filesResult.copied > 0 ? 'completed' : 'skipped'); steps.push(`✅ Setup files: ${filesResult.copied} copied, ${filesResult.skipped} already existed`); + if (setupConfiguration?.manageRepositorySecrets && secretValues > 0) + mark('secrets', 'needs-inspection'); const secrets = await (0, setup_resource_provisioning_1.ensureRepositorySecrets)(request, dependencies, setupConfiguration, remoteConfiguration); + mark('secrets', secrets.errors.length ? 'needs-inspection' : secrets.writes > 0 ? 'completed' : 'skipped'); if (secrets.step) steps.push(secrets.step); if (secrets.errors.length > 0) errors.push(...fromMessages(secrets.errors, 'authorization.credential-invalid')); (0, logging_ports_1.logInfo)('🏷️ Checking configured and progress labels...'); + mark('labels', 'needs-inspection'); const labels = await ensureInitialLabels(request, dependencies.initialLabelProvisioningPort, setupConfiguration); + mark('labels', !labels.completed || labels.configured.errors.length || labels.progress.errors.length + ? 'needs-inspection' : labels.configured.created + labels.progress.created > 0 ? 'completed' : 'skipped'); if (!labels.completed) { errors.push(labels.error); } @@ -52062,30 +52218,39 @@ async function runInitialSetupWorkflow(request, dependencies) { appendLabelSummary(steps, errors, labels.progress, 'Progress labels'); } (0, logging_ports_1.logInfo)('📋 Checking issue types...'); + mark('issue-types', 'needs-inspection'); const issueTypes = await ensureIssueTypes(request, dependencies.issueTypeProvisioningPort, setupConfiguration); + mark('issue-types', !issueTypes.success ? 'needs-inspection' : issueTypes.created > 0 ? 'completed' : 'skipped'); if (!issueTypes.success) { errors.push(...fromMessages(issueTypes.errors, 'provider.unavailable')); } else { steps.push(`✅ Issue types checked: ${issueTypes.created} created, ${issueTypes.existing} already existed`); } + if (setupConfiguration?.manageRepositoryVariables) + mark('variables', 'needs-inspection'); const variables = await (0, setup_resource_provisioning_1.ensureRepositoryVariables)(request, dependencies, setupConfiguration, remoteConfiguration); + mark('variables', variables.errors.length ? 'needs-inspection' : variables.writes > 0 ? 'completed' : 'skipped'); if (variables.step) steps.push(variables.step); if (variables.errors.length > 0) errors.push(...fromMessages(variables.errors, 'provider.unavailable')); + if (setupConfiguration?.createInitialTag !== false) + mark('initial-tag', 'needs-inspection'); const defaultVersion = await ensureDefaultVersion(request, dependencies, setupConfiguration); + mark('initial-tag', defaultVersion.error ? 'needs-inspection' + : defaultVersion.step?.includes('created on branch') ? 'completed' : 'skipped'); if (defaultVersion.step) steps.push(defaultVersion.step); if (defaultVersion.error) errors.push(defaultVersion.error); - return [buildResult(errors, steps)]; + return [receipt()]; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'workflow.failed', 'Error running initial setup.'); (0, logging_ports_1.logError)(semanticError); errors.push(semanticError); - return [buildResult(errors, steps)]; + return [receipt()]; } } async function verifyGitHubAccess(_request, repository) { @@ -52163,15 +52328,22 @@ function appendLabelSummary(steps, errors, summary, labelType) { steps.push(`✅ ${labelType} checked: ${summary.created} created, ${summary.existing} already existed`); } } -function buildResult(errors, steps) { +function buildResult(errors, steps, effects) { return new result_1.Result({ id: TASK_ID, success: errors.length === 0, executed: true, steps, + payload: { setupReceipt: { version: 1, effects: effects.map(effect => ({ ...effect })) } }, errors: errors.length > 0 ? errors : undefined, }); } +function resourceScope(configuration, kind) { + const policy = configuration?.storage[kind]; + if (!policy) + return 'repository'; + return Object.values(policy.overrides).some(scope => scope !== policy.defaultScope) ? 'mixed' : policy.defaultScope; +} function fromMessages(messages, code) { return messages.map(message => new application_error_1.ApplicationError(code, message)); } @@ -53016,6 +53188,7 @@ function failure(taskId, message, code) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SECRET_PROVISIONING_UNAVAILABLE = exports.VARIABLE_PROVISIONING_UNAVAILABLE = void 0; exports.ensureRepositoryVariables = ensureRepositoryVariables; exports.ensureRepositorySecrets = ensureRepositorySecrets; exports.resolveRemoteConfiguration = resolveRemoteConfiguration; @@ -53023,55 +53196,71 @@ exports.groupSetupResources = groupSetupResources; const setup_configuration_policy_1 = __nccwpck_require__(56637); const logging_ports_1 = __nccwpck_require__(6152); const application_error_1 = __nccwpck_require__(75999); +exports.VARIABLE_PROVISIONING_UNAVAILABLE = 'GitHub Actions Variable provisioning is unavailable; no Variables were changed.'; +exports.SECRET_PROVISIONING_UNAVAILABLE = 'GitHub Actions Secret provisioning is unavailable; no Secrets were changed.'; async function ensureRepositoryVariables(context, dependencies, setupConfiguration, remoteConfiguration) { - if (!setupConfiguration?.manageRepositoryVariables || !dependencies.setupRepositoryVariablesPort) { - return { errors: [] }; + if (!setupConfiguration?.manageRepositoryVariables) { + return { errors: [], writes: 0 }; + } + if (!dependencies.setupRepositoryVariablesPort) { + return { errors: [exports.VARIABLE_PROVISIONING_UNAVAILABLE], writes: 0 }; } try { const desired = (0, setup_configuration_policy_1.buildSetupRepositoryVariables)(setupConfiguration); const groups = groupSetupResources(desired, 'variable', setupConfiguration, remoteConfiguration); const result = await upsertVariableGroups(context, dependencies.setupRepositoryVariablesPort, groups); + const writes = result.created + result.updated; if (result.errors.length > 0) - return { errors: result.errors }; + return { errors: result.errors, writes }; return { - step: `✅ GitHub Actions Variables: ${result.created} created, ${result.updated} updated; existing effective values preserved when no override was selected.`, + step: writes > 0 + ? `✅ GitHub Actions Variables: ${result.created} created, ${result.updated} updated; existing effective values preserved when no override was selected.` + : '✅ GitHub Actions Variables kept unchanged; no values were created or updated.', errors: [], + writes, }; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to configure GitHub Actions Variables.'); (0, logging_ports_1.logError)(semanticError); - return { errors: [semanticError.message] }; + return { errors: [semanticError.message], writes: 0 }; } } async function ensureRepositorySecrets(context, dependencies, setupConfiguration, remoteConfiguration) { - if (!setupConfiguration?.manageRepositorySecrets || !dependencies.setupRepositorySecretsPort) { - return { errors: [] }; + if (!setupConfiguration?.manageRepositorySecrets) { + return { errors: [], writes: 0 }; } const credentials = context.setupCredentials; if (!credentials) { - return { step: '⚠️ Repository Secrets were not changed: run interactive setup to validate and provide credentials.', errors: [] }; + return { step: '⚠️ Repository Secrets were not changed: run interactive setup to validate and provide credentials.', errors: [], writes: 0 }; } const values = [ ...(credentials.workflowPat ? [credentials.workflowPat] : []), ...credentials.apiKeys, ]; if (values.length === 0) - return { step: '✅ Existing Repository Secrets kept unchanged.', errors: [] }; + return { step: '✅ Existing Repository Secrets kept unchanged.', errors: [], writes: 0 }; + if (!dependencies.setupRepositorySecretsPort) { + return { errors: [exports.SECRET_PROVISIONING_UNAVAILABLE], writes: 0 }; + } try { const groups = groupSetupResources(values, 'secret', setupConfiguration, remoteConfiguration); const result = await upsertSecretGroups(context, dependencies.setupRepositorySecretsPort, groups); + const writes = result.created + result.updated; if (result.errors.length > 0) - return { errors: result.errors }; + return { errors: result.errors, writes }; return { - step: `✅ GitHub Actions Secrets: ${result.created} created, ${result.updated} updated; existing effective values kept when no replacement was selected.`, + step: writes > 0 + ? `✅ GitHub Actions Secrets: ${result.created} created, ${result.updated} updated; existing effective values kept when no replacement was selected.` + : '✅ Existing GitHub Actions Secrets kept unchanged; no values were created or updated.', errors: [], + writes, }; } catch (error) { const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to configure GitHub Actions Secrets.'); (0, logging_ports_1.logError)(semanticError); - return { errors: [semanticError.message] }; + return { errors: [semanticError.message], writes: 0 }; } } async function resolveRemoteConfiguration(context, dependencies, setupConfiguration, errors) { @@ -65209,8 +65398,11 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.cleanCliArg = cleanCliArg; exports.getGitInfo = getGitInfo; exports.getCurrentBranch = getCurrentBranch; +exports.getCurrentAttachedBranch = getCurrentAttachedBranch; +exports.hasLocalOrTrackedGitBranch = hasLocalOrTrackedGitBranch; exports.getCurrentHeadSha = getCurrentHeadSha; exports.isInsideGitRepo = isInsideGitRepo; +exports.getGitRepositoryRoot = getGitRepositoryRoot; exports.isGitRepositoryRoot = isGitRepositoryRoot; const child_process_1 = __nccwpck_require__(32081); const node_fs_1 = __nccwpck_require__(87561); @@ -65242,6 +65434,29 @@ function getCurrentBranch() { return 'main'; } } +/** A verified branch name for web setup; detached HEAD and failed git reads are not guessed. */ +function getCurrentAttachedBranch(cwd) { + try { + const branch = (0, child_process_1.execSync)('git symbolic-ref --quiet --short HEAD', { cwd }).toString().trim(); + return branch && branch !== 'HEAD' ? branch : undefined; + } + catch { + return undefined; + } +} +/** Positive local evidence only; a missing ref says nothing about remote branches. */ +function hasLocalOrTrackedGitBranch(cwd, branch) { + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/u.test(branch) || branch.includes('..') || branch.endsWith('.lock')) + return false; + for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) { + try { + (0, child_process_1.execFileSync)('git', ['show-ref', '--verify', '--quiet', ref], { cwd, stdio: 'pipe' }); + return true; + } + catch { /* Try the other explicit ref. */ } + } + return false; +} /** Returns the canonical object ID for the workspace revision being analyzed. */ function getCurrentHeadSha() { try { @@ -65260,10 +65475,14 @@ function isInsideGitRepo(cwd) { return false; } } +/** Canonical checkout root for plans whose file paths are repository-relative. */ +function getGitRepositoryRoot(cwd) { + const root = (0, child_process_1.execSync)('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }).toString().trim(); + return (0, node_fs_1.realpathSync)(root); +} function isGitRepositoryRoot(cwd) { try { - const root = (0, child_process_1.execSync)('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }).toString().trim(); - return (0, node_fs_1.realpathSync)(root) === (0, node_fs_1.realpathSync)(cwd); + return getGitRepositoryRoot(cwd) === (0, node_fs_1.realpathSync)(cwd); } catch { return false; @@ -74329,6 +74548,7 @@ const github_error_policy_1 = __nccwpck_require__(58791); const credential_health_workflow_visibility_1 = __nccwpck_require__(57628); const tweetnacl_1 = __importDefault(__nccwpck_require__(24258)); const node_crypto_1 = __nccwpck_require__(6005); +const deployment_configuration_1 = __nccwpck_require__(22495); class GithubActionsResourceTransport { constructor(githubClient) { this.githubClient = githubClient; @@ -74364,6 +74584,8 @@ class GithubActionsResourceTransport { const credentialHealthWorkflow = await this.inspectDefaultCredentialHealthWorkflow(client, owner, repository); return { ownerType, + ...(typeof metadata.default_branch === 'string' && (0, deployment_configuration_1.isSafeBranchTree)(metadata.default_branch) + ? { defaultBranch: metadata.default_branch } : {}), repositoryId: metadata.id, repositoryVisibility, repositorySecrets: repositorySecretsResult.resources, @@ -77710,6 +77932,7 @@ function validatePullRequestApprovalPolicy(value, allowIncomplete = false) { } else { const identities = new Set(); + const names = new Set(); for (const item of value.testChecks) { if (!isRecord(item)) { errors.push('Each test check must be an object.'); @@ -77723,6 +77946,9 @@ function validatePullRequestApprovalPolicy(value, allowIncomplete = false) { if (identities.has(identity)) errors.push('Test checks cannot contain duplicate producer identities.'); identities.add(identity); + if (value.mode !== 'off' && !allowIncomplete && names.has(String(item.name))) + errors.push('Trusted check names must be unique because coverage stores only a check name.'); + names.add(String(item.name)); } } if (typeof value.producerAttested !== 'boolean') @@ -84025,8 +84251,8 @@ function isInside(root, candidate) { function matchesFieldBoundary(field, relativePath) { if (field === 'specs') return /^specs\/(?!README\.md$|_template\.md$|CATALOG\.md$).+\.md$/.test(relativePath); if (field === 'workflows') return /^(?:\.github|setup)\/workflows\/.+\.ya?ml$/.test(relativePath); - if (field === 'entrypoints') return /^(?:src\/.+|action\.yml|package\.json)$/.test(relativePath); - if (field === 'code') return /^(?:src|scripts)\//.test(relativePath); + if (field === 'entrypoints') return /^(?:src\/.+|web\/src\/main\.ts|action\.yml|package\.json)$/.test(relativePath); + if (field === 'code') return /^(?:(?:src|scripts)\/|web\/src\/.+\.(?:ts|svelte|css)$)/.test(relativePath); if (field === 'tests') return /^src\/.*(?:__tests__\/.*\.test\.ts|\.test\.ts)$/.test(relativePath); if (field === 'documentation') return /^(?:docs\/.*\.(?:md|mdx)|README\.md|CONTRIBUTING\.md)$/.test(relativePath); return false; diff --git a/build/web/assets/index-7TY0kYbf.css b/build/web/assets/index-7TY0kYbf.css new file mode 100644 index 000000000..51d26e612 --- /dev/null +++ b/build/web/assets/index-7TY0kYbf.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--page:#f6f8f7;--side:#102423;--side-line:#31504b;--side-text:#e8f5f0;--surface:#fff;--surface-soft:#f3f7f5;--line:#d7e3df;--control-line:#748b82;--text:#18312c;--muted:#58736b;--accent:#176e5e;--accent-strong:#075743;--accent-tint:#dff4e9;--focus:#966000;--warn:#76510d;--warn-bg:#fff6df;--error:#a73434;--error-bg:#fff0ec;--shadow:0 18px 50px #1e403414;font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}:root:not([data-theme=light]){--page:#0d1716;--side:#10201d;--side-line:#294a41;--side-text:#eaf7f1;--surface:#182722;--surface-soft:#20342d;--line:#355247;--control-line:#688f7f;--text:#eaf5ee;--muted:#adccbc;--accent:#7ed6ac;--accent-strong:#a4edc2;--accent-tint:#234b38;--focus:#ffca6a;--warn:#ffdd8a;--warn-bg:#463b21;--error:#ffc0b7;--error-bg:#4a2b2a;--shadow:0 18px 50px #00000024}}:root[data-theme=dark]{--page:#0d1716;--side:#10201d;--side-line:#294a41;--side-text:#eaf7f1;--surface:#182722;--surface-soft:#20342d;--line:#355247;--control-line:#688f7f;--text:#eaf5ee;--muted:#adccbc;--accent:#7ed6ac;--accent-strong:#a4edc2;--accent-tint:#234b38;--focus:#ffca6a;--warn:#ffdd8a;--warn-bg:#463b21;--error:#ffc0b7;--error-bg:#4a2b2a;--shadow:0 18px 50px #00000024}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}body{background:var(--page);color:var(--text);margin:0}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.5}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.visually-hidden{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}a{color:var(--accent-strong);text-underline-offset:3px}.card,.context-card{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:15px}.primary{border:1px solid var(--accent-strong);background:var(--accent-strong);color:var(--side);border-radius:8px;min-height:43px;padding:12px 18px;font-size:12px;font-weight:800}:root[data-theme=light] .primary,:root:not([data-theme=dark]) .primary{color:#fff}@media (prefers-color-scheme:dark){:root:not([data-theme=light]) .primary{color:#0d2419}}.primary span{margin-left:18px}.primary:hover:not(:disabled){filter:brightness(1.1)}.secondary{border:1px solid var(--control-line);color:var(--text);background:var(--surface-soft);border-radius:8px;padding:11px 16px;font-size:12px;font-weight:700}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}.shell{grid-template-columns:minmax(250px,288px) minmax(0,1fr);min-height:100vh;display:grid}.sidebar{background:var(--side);color:var(--side-text);flex-direction:column;height:100vh;padding:34px 28px;display:flex;position:sticky;top:0}.brand{letter-spacing:-.035em;align-items:center;gap:13px;display:flex}.brand-mark{color:#0c3021;background:#75d9a0;border-radius:11px;place-items:center;width:37px;height:37px;font-size:26px;line-height:1;display:grid}.brand strong{font-size:23px;line-height:1;display:block}.brand small{letter-spacing:.23em;color:#aac8bd;margin-top:5px;font-size:9px;font-weight:800;display:block}.rail-caption{color:#9dbab0;letter-spacing:.18em;margin-block:78px 22px;margin-inline-start:7px;font-size:10px;font-weight:800}.steps{margin:0;padding:0;list-style:none;position:relative}.steps:before{content:"";top:22px;bottom:22px;background:var(--side-line);width:1px;position:absolute;inset-inline-start:19px}.steps li{color:#a6c2b7;border-radius:10px;align-items:center;gap:16px;min-height:55px;padding-block:8px;padding-inline:1px 12px;font-size:13px;font-weight:600;display:flex;position:relative}.steps li.current{color:#fff;background:#25443b}.steps li.completed{color:#dbf2e4}.step-index{border:1px solid var(--side-line);background:var(--side);letter-spacing:.04em;border-radius:50%;flex:0 0 37px;place-items:center;height:37px;font-size:11px;font-weight:800;display:grid}.steps .current .step-index{color:#102b1d;background:#80dba8;border-color:#80dba8}.steps .completed .step-index{color:#b9f8c9;background:#204c37;border-color:#45966b;font-size:15px}.sidebar-note{border:1px solid var(--side-line);background:#ffffff09;border-radius:13px;gap:13px;margin-top:auto;padding:19px 16px;display:flex}.sidebar-note>span{color:#8fe1ae;font-size:20px}.sidebar-note strong{font-size:12px}.sidebar-note p{color:#afcabe;margin:6px 0 0;font-size:11px;line-height:1.6}.main{min-width:0}.topbar{border-bottom:1px solid var(--line);background:var(--surface);justify-content:space-between;align-items:center;gap:16px;height:80px;padding:0 clamp(24px,4vw,70px);display:flex}.breadcrumb{align-items:center;gap:12px;min-width:0;font-size:12px;display:flex}.breadcrumb span:first-child{color:var(--muted);letter-spacing:.14em;font-size:10px;font-weight:800}.breadcrumb span:nth-child(2){color:var(--muted)}.breadcrumb strong{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.top-actions{flex-shrink:0;align-items:center;gap:18px;display:flex}.local-pill{color:var(--accent-strong);background:var(--accent-tint);letter-spacing:.08em;white-space:nowrap;border-radius:6px;padding:8px 11px;font-size:10px;font-weight:800}.pulse-dot{background:currentColor;border-radius:50%;width:6px;height:6px;margin-right:5px;display:inline-block}.theme-switch{border:1px solid var(--line);background:var(--surface-soft);border-radius:8px;padding:3px;display:flex}.theme-switch button{color:var(--muted);background:0 0;border:0;border-radius:5px;min-width:31px;height:27px;font-size:11px}.theme-switch button.active{background:var(--surface);color:var(--text);font-weight:800;box-shadow:0 1px 4px #0000001f}.content{max-width:1320px;margin:0 auto;padding:52px clamp(24px,4vw,70px) 35px}.eyebrow{color:var(--accent-strong);letter-spacing:.18em;align-items:center;gap:10px;font-size:10px;font-weight:900;display:flex}.eyebrow-line{background:var(--accent);width:21px;height:2px}.eyebrow-count{color:var(--muted);letter-spacing:.09em;margin-inline-start:8px}h1{letter-spacing:-.045em;max-width:860px;margin:15px 0 13px;font-size:clamp(30px,3vw,45px);line-height:1.15}.lede{color:var(--muted);max-width:700px;margin:0 0 30px;font-size:14px;line-height:1.65}.workspace-grid{grid-template-columns:minmax(0,1.65fr) minmax(230px,.8fr);align-items:start;gap:19px;display:grid}.context-column{gap:17px;display:grid}.context-card{box-shadow:none;padding:25px}.context-icon{background:var(--accent-tint);width:32px;height:32px;color:var(--accent-strong);border-radius:8px;place-items:center;font-size:19px;display:grid}.context-card h2{letter-spacing:-.015em;margin:17px 0 7px;font-size:14px}.context-card p,.context-card>small{color:var(--muted);margin:0 0 12px;font-size:12px;line-height:1.65;display:block}.context-card code{background:var(--surface-soft);overflow-wrap:anywhere;border-radius:6px;padding:10px;font-size:11px;display:block}.permissions ul{max-height:270px;margin:8px 0 13px;padding:0;list-style:none;overflow:auto}.permissions li{border-bottom:1px solid var(--line);justify-content:space-between;gap:10px;padding:9px 0;font-size:11px;display:flex}.permissions li small{color:var(--muted);margin-top:3px;display:block}.permissions li strong{color:var(--accent-strong);text-transform:uppercase;font-size:9px}footer{color:var(--muted);opacity:.85;letter-spacing:.11em;margin-top:40px;font-size:9px;font-weight:700}footer span{margin:0 8px}[dir=rtl] .context-card code,[dir=rtl] .result-links code,[dir=rtl] .producer-option strong,[dir=rtl] .producer-option small,[dir=rtl] input[type=password]{direction:ltr;unicode-bidi:isolate}.decision-card{min-height:360px;padding:clamp(25px,3vw,40px)}.card-header{justify-content:space-between;align-items:center;gap:12px;margin-bottom:29px;display:flex}.card-kicker{color:var(--accent-strong);letter-spacing:.17em;font-size:10px;font-weight:900}.revision{color:var(--muted);letter-spacing:.08em;font-size:10px}.description{white-space:pre-line;color:var(--muted);margin-top:0;font-size:13px;line-height:1.65}.question-heading{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;margin-bottom:15px;display:flex}.question-heading h2,.decision-card>label{margin:0 0 10px;font-size:15px;font-weight:700;line-height:1.4;display:block}.phase-tag{color:var(--accent-strong);background:var(--accent-tint);letter-spacing:.07em;border-radius:5px;padding:6px 8px;font-size:9px;font-weight:900}input[type=text],input[type=password],input[type=number],select{border:1px solid var(--control-line);background:var(--surface-soft);width:100%;min-height:46px;color:var(--text);border-radius:8px;padding:10px 13px}textarea{resize:vertical;border:1px solid var(--control-line);background:var(--surface-soft);width:100%;min-height:120px;color:var(--text);font:inherit;border-radius:8px;padding:12px 13px;line-height:1.5}.language-switch{color:var(--muted);white-space:nowrap;align-items:center;gap:7px;font-size:11px;font-weight:700;display:flex}.language-switch select{width:auto;max-width:140px;min-height:32px;padding:5px 8px;font-size:11px}.question-details{border:1px solid var(--line);background:var(--surface-soft);border-radius:8px;margin-bottom:20px;padding:13px 15px;font-size:12px}.question-details summary{cursor:pointer;color:var(--accent-strong);font-weight:800}.question-details dl{grid-template-columns:minmax(90px,130px) minmax(0,1fr);gap:10px 14px;margin:14px 0 0;line-height:1.55;display:grid}.question-details dt{color:var(--text);font-weight:750}.question-details dd{color:var(--muted);margin:0}.question-help-link{margin:-12px 0 18px;font-size:12px;font-weight:700}.discovery-actions{flex-wrap:wrap;align-items:center;gap:8px 14px;margin:4px 0 16px;display:flex}.discovery-actions .field-help{margin:0}.secondary-button{border:1px solid var(--control-line);background:var(--surface-soft);color:var(--accent-strong);font:inherit;border-radius:8px;padding:9px 13px;font-size:12px;font-weight:750}.secondary-button:hover:not(:disabled),.secondary-button:focus-visible{border-color:var(--accent);background:var(--accent-tint)}.secondary-button:disabled{opacity:.55;cursor:not-allowed}.status-review-list{color:var(--muted);margin:6px 0 18px;padding-inline-start:20px;font-size:12px;line-height:1.7}.status-review-list strong{color:var(--text)}.producer-grid{max-height:330px;margin-bottom:15px}.producer-option{cursor:pointer;align-items:flex-start}.producer-option>span{overflow-wrap:anywhere;gap:5px;min-width:0;display:grid}.producer-option small{color:var(--muted);font-size:10px;line-height:1.5}.producer-option a{font-size:11px}input[type=checkbox]{accent-color:var(--accent);width:17px;height:17px}.field-help{color:var(--muted);margin:14px 0 24px;font-size:12px;line-height:1.6}.segmented{gap:9px;display:flex}.segmented button{border:1px solid var(--control-line);color:var(--text);background:var(--surface-soft);border-radius:8px;flex:1;padding:13px;font-weight:700}.segmented button.selected{border-color:var(--accent);background:var(--accent-tint);color:var(--accent-strong)}.check-grid{gap:7px;max-height:280px;display:grid;overflow-y:auto}.check-option{background:var(--surface-soft);border:1px solid var(--control-line);border-radius:7px;align-items:center;gap:10px;padding:10px 13px;font-size:12px;display:flex}.choice-list{gap:9px;display:grid}.choice-card{text-align:left;background:var(--surface-soft);width:100%;min-height:52px;color:var(--text);border:1px solid var(--control-line);border-radius:8px;justify-content:space-between;align-items:center;padding:13px 15px;font-size:13px;font-weight:650;display:flex}.choice-card:hover:not(:disabled){border-color:var(--accent);background:var(--accent-tint)}.github-link{background:var(--accent-tint);border:1px solid var(--accent);border-radius:8px;margin:0 0 12px;padding:14px;font-size:13px;font-weight:800;text-decoration:none;display:block}.github-link span{float:right}.plan-sections{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.plan-decisions{background:var(--surface-soft);border:1px solid var(--line);border-radius:8px;margin-bottom:15px;padding:16px}.plan-decisions h3{margin:0 0 10px;font-size:13px}.plan-decisions dl{gap:8px;margin:0;display:grid}.plan-decisions dl>div{grid-template-columns:minmax(130px,35%) 1fr;gap:12px;font-size:12px;display:grid}.plan-decisions dt{color:var(--muted)}.plan-decisions dd{overflow-wrap:anywhere;margin:0}.plan-sections>div,.plan-warnings{background:var(--surface-soft);border:1px solid var(--line);border-radius:8px;padding:13px}.plan-sections h3,.plan-warnings h3{justify-content:space-between;margin:0 0 8px;font-size:12px;display:flex}.plan-sections h3 span{color:var(--accent-strong)}.plan-sections ul,.plan-warnings ul{overflow-wrap:anywhere;max-height:120px;margin:0;padding-inline-start:18px;font-size:11px;line-height:1.7;overflow:auto}.plan-warnings{color:var(--warn);background:var(--warn-bg);margin-top:10px}.plan-edit{border-top:1px solid var(--line);margin-top:20px;padding-top:14px}.plan-edit h3{margin:0 0 4px;font-size:14px}.plan-edit-actions{flex-wrap:wrap;gap:8px;display:flex}.button-row{justify-content:space-between;gap:10px;margin-top:18px;display:flex}.review-pass,.banner{white-space:pre-line;border-radius:8px;margin:0 0 20px;padding:14px 18px;font-size:12px;line-height:1.5}.review-pass{color:var(--accent-strong);background:var(--accent-tint);border:1px solid var(--accent)}.review-pass span{margin-inline-end:8px;font-weight:800}.banner{background:var(--surface-soft);border:1px solid var(--line)}.banner p{margin:5px 0 0}.banner.warning{color:var(--warn);background:var(--warn-bg);border-color:var(--warn)}.banner.error{color:var(--error);background:var(--error-bg);border-color:var(--error)}.banner.success{color:var(--accent-strong);background:var(--accent-tint);border-color:var(--accent)}.banner button{margin-top:12px}.cancel-link{color:var(--muted);background:0 0;border:0;margin-top:18px;padding:5px 0;font-size:12px;text-decoration:underline}.result-card,.waiting-card{max-width:750px;padding:36px}.result-icon{background:var(--accent-tint);width:43px;height:43px;color:var(--accent-strong);border-radius:50%;place-items:center;font-size:22px;display:grid}.result-card h2,.waiting-card h2{margin:18px 0 10px;font-size:21px}.result-card p,.waiting-card p{color:var(--muted);font-size:13px;line-height:1.6}.result-facts{border:1px solid var(--line);background:var(--surface-soft);border-radius:8px;margin:20px 0;padding:8px 17px}.result-facts p{margin:8px 0}.result-facts strong{color:var(--text)}.result-effects{border:1px solid var(--line);background:var(--surface-soft);border-radius:8px;margin:20px 0;padding:14px 17px}.result-effects h3{color:var(--text);margin:0 0 10px;font-size:13px}.result-effects ul{margin:0;padding-left:20px}.result-effects li{overflow-wrap:anywhere;padding:3px 0;font-size:12px}.result-links{flex-wrap:wrap;align-items:center;gap:20px;margin:22px 0;font-size:12px;display:flex}.result-links code{background:var(--surface-soft);border-radius:5px;padding:8px}.spinner{border:3px solid var(--line);border-top-color:var(--accent);border-radius:50%;width:25px;height:25px;animation:1s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=1100px){.workspace-grid{grid-template-columns:1fr}.context-column{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width<=780px){.shell{display:block}.sidebar{height:auto;padding:16px 20px;position:static}.rail-caption,.sidebar-note{display:none}.steps{gap:4px;margin-top:18px;display:flex;overflow-x:auto}.steps:before{display:none}.steps li{flex:none;gap:6px;min-height:37px;padding:4px 7px;font-size:11px}.step-index{flex-basis:26px;width:26px;height:26px}.topbar{flex-wrap:wrap;height:auto;min-height:65px;padding:12px 20px}.content{padding:28px 20px}}@media (width<=540px){.context-column,.plan-sections{grid-template-columns:1fr}.top-actions{justify-content:space-between;width:100%}.decision-card{padding:22px}.breadcrumb{max-width:100%}h1{font-size:29px}.plan-decisions dl>div{grid-template-columns:1fr;gap:2px}.question-details dl{grid-template-columns:1fr;gap:3px}.question-details dd{margin-bottom:9px}.language-switch{margin-inline-start:auto}} diff --git a/build/web/assets/index-CcOfHgj1.js b/build/web/assets/index-CcOfHgj1.js new file mode 100644 index 000000000..1476328a1 --- /dev/null +++ b/build/web/assets/index-CcOfHgj1.js @@ -0,0 +1,3 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})(),typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);var e=!1;function t(){e=!0}t();var n={},r=Symbol(`uninitialized`),i=`http://www.w3.org/1999/xhtml`,a=Array.isArray,o=Array.prototype.indexOf,s=Array.prototype.includes,c=Array.from,l=Object.defineProperty,u=Object.getOwnPropertyDescriptor,d=Object.getOwnPropertyDescriptors,f=Object.prototype,p=Array.prototype,m=Object.getPrototypeOf,h=Object.isExtensible,g=()=>{};function _(e){return e()}function v(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function b(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var x=1024,S=2048,C=4096,ee=8192,te=16384,ne=32768,re=1<<25,ie=65536,ae=1<<19,oe=1<<20,se=1<<25,ce=1<<21,le=1<<22,ue=1<<23,de=Symbol(`$state`),fe=Symbol(`component`),pe=Symbol(`legacy props`),me=Symbol(``),he=Symbol(`attributes`),ge=Symbol(`class`),_e=Symbol(`style`),ve=Symbol(`text`),ye=Symbol(`form reset`),be=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},xe=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`);function Se(){console.warn(`https://svelte.dev/e/derived_inert`)}function Ce(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function we(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function Te(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var w=!1;function Ee(e){w=e}var T;function De(e){if(e===null)throw Ce(),n;return T=e}function Oe(){return De(cn(T))}function E(e){if(w){if(cn(T)!==null)throw Ce(),n;T=e}}function ke(e=1){if(w){for(var t=e,n=T;t--;)n=cn(n);T=n}}function Ae(e=!0){for(var t=0,n=T;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=cn(n);e&&n.remove(),n=i}}function je(e){if(!e||e.nodeType!==8)throw Ce(),n;return e.data}function Me(e){return e===this.v}function Ne(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Pe(e){return!Ne(e,this.v)}function Fe(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function Ie(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function Le(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Re(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function ze(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Be(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Ve(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function He(e){throw Error(`https://svelte.dev/e/props_invalid_value`)}function Ue(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function We(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function Ge(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function Ke(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var D=null;function qe(e){D=e}function O(t,n=!1,r){D={p:D,i:!1,c:null,e:null,s:t,x:null,r:B,l:e&&!n?{s:null,u:null,$:[]}:null}}function k(e){var t=D,n=t.e;if(n!==null){t.e=null;for(var r of n)xn(r)}return e!==void 0&&(t.x=e),t.i=!0,D=t.p,Je(e)}function Je(e={}){return l(e,fe,{value:!0}),e}function Ye(){return!e||D!==null&&D.l===null}var Xe=[];function Ze(){var e=Xe;Xe=[],v(e)}function Qe(e){if(Xe.length===0&&!Et){var t=Xe;queueMicrotask(()=>{t===Xe&&Ze()})}Xe.push(e)}function $e(){for(;Xe.length>0;)Ze()}var et=~(S|C|x);function tt(e,t){e.f=e.f&et|t}function nt(e){e.f&512||e.deps===null?tt(e,x):tt(e,C)}function rt(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),tt(e,x)}function it(e){w&&sn(e)!==null&&ln(e)}var at=!1;function ot(){at||(at=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[ye]?.()})},{capture:!0}))}function st(e){var t=z,n=B;qn(null),Jn(null);try{return e()}finally{qn(t),Jn(n)}}function ct(e,t,n,r=n){e.addEventListener(t,()=>st(n));let i=e[ye];e[ye]=i?()=>{i(),r(!0)}:()=>r(!0),ot()}function lt(e,t,n,r){let i=Ye()?pt:A;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=B,c=ut(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){mn(e,s)}dt()}}var d=ft();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>ht(e))).then(u).catch(e=>mn(e,s)).finally(d)}l?l.then(()=>{c(),f(),dt()}):f()}function ut(){var e=B,t=z,n=D,r=j;return function(i=!0){Jn(e),qn(t),qe(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function dt(e=!0){Jn(null),qn(null),qe(null),e&&j?.deactivate()}function ft(){var e=B,t=e.b,n=j,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function pt(e){var t=2|S;return B!==null&&(B.f|=ae),{ctx:D,deps:null,effects:null,equals:Me,f:t,fn:e,reactions:null,rv:0,v:r,wv:0,parent:B,ac:null}}var mt=Symbol(`obsolete`);function ht(e,t,n){let i=B;i===null&&Ie();var a=void 0,o=Ut(r),s=!z,c=new Set;return Dn(()=>{var t=B,n=y();a=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==be&&n.reject(e)}).finally(dt)}catch(e){n.reject(e),dt()}var r=j;if(s){if(t.f&32768)var l=ft();if(i.b?.is_rendered())r.async_deriveds.get(t)?.reject(mt);else for(let e of c.values())e.reject(mt);c.add(n),r.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),c.delete(n),t!==mt&&(r.activate(),t?(o.f|=ue,qt(o,t)):(o.f&8388608&&(o.f^=ue),qt(o,e)),r.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),yn(()=>{for(let e of c)e.reject(mt)}),new Promise(e=>{function t(n){function r(){n===a?e(o):t(a)}n.then(r,r)}t(a)})}function gt(e){let t=pt(e);return Xn(t),t}function A(e){let t=pt(e);return t.equals=Pe,t}function _t(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(be),t.ac=null}),t.fn!==null&&(t.teardown=g),dr(t,0),Mn(t))}function xt(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&fr(t)}var St=null,j=null,Ct=null,wt=null,Tt=null,Et=!1,Dt=!1,Ot=null,kt=null,At=0,jt=1,Mt=class e{id=jt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){St===null?St=this:(St.#n=this,this.#t=St),St=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)tt(r,S),t(r);for(r of n.m)tt(r,C),t(r)}this.#p.add(e)}#g(){var e=[];for(let i of this.#c)if(!(i.f&16384||!(i.f&6144))){for(var t=i,n=!1;t.parent!==null;){t=t.parent;var r=t.f;if(r&96){if(!(r&1024)){n=!0;break}t.f^=x}}n||e.push(t)}return this.#c=[],e}#_(){this.#e=!0;for(let e of this.#u)this.#d.delete(e),tt(e,S),this.schedule(e);for(let e of this.#d)tt(e,C),this.schedule(e);this.apply();for(var t=Ot=[],n=[],r=kt=[];this.#c.length>0;){At++>1e3&&(this.#S(),Pt());for(let e of this.#g())try{this.#v(e,t,n)}catch(t){throw zt(e),this.#h()||this.discard(),t}}if(j=null,r.length>0){var i=e.ensure();for(let e of r)i.schedule(e)}if(Ot=null,kt=null,this.#h()){this.#x(n),this.#x(t);for(let[e,t]of this.#f)Rt(e,t);r.length>0&&j.#_();return}let a=this.#y();if(a){this.#x(n),this.#x(t),a.#b(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),Ct=this,It(n),It(t),Ct=null,this.#s?.resolve();var o=j;if(this.#a===0&&(this.#c.length===0||o!==null)&&this.#S(),this.#c.length>0){if(o!==null){for(let e of this.#c)o.#c.push(e);this.#c=[]}else o=this}o!==null&&(Vt.clear(),o.#_())}#v(e,t,n){e.f^=x;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=x:i&4?t.push(r):or(r)&&(i&16&&this.#d.add(r),fr(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#y(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#b(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),tt(i,S),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#S(),j=this,this.#_()}#x(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=y()).promise}static ensure(){if(j===null){let t=j=new e;!Dt&&!Et&&Qe(()=>{t.#e||t.flush()})}return j}apply(){wt=null}schedule(e){if(Tt=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)){e.b.defer_effect(e);return}this.#c.push(e)}#S(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?St=e:t.#t=e,this.linked=!1}}};function Nt(e){var t=Et;Et=!0;try{var n;for(e&&(j!==null&&!j.is_fork&&j.flush(),n=e());;){if($e(),j===null)return n;j.flush()}}finally{Et=t}}function Pt(){try{Ve()}catch(e){mn(e,Tt)}}var Ft=null;function It(e){var t=e.length;if(t!==0){for(var n=0;n0)){Vt.clear();for(let e of Ft){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)Ft.has(n)&&(Ft.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||fr(n)}}Ft.clear()}}Ft=null}}function Lt(e){j.schedule(e)}function Rt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),tt(e,x);for(var n=e.first;n!==null;)Rt(n,t),n=n.next}}function zt(e){tt(e,x);for(var t=e.first;t!==null;)zt(t),t=t.next}var Bt=new Set,Vt=new Map,Ht=!1;function Ut(e,t){return{f:0,v:e,reactions:null,equals:Me,rv:0,wv:0}}function Wt(e,t){let n=Ut(e,t);return Xn(n),n}function M(t,n=!1,r=!0){let i=Ut(t);return n||(i.equals=Pe),e&&r&&D!==null&&D.l!==null&&(D.l.s??=[]).push(i),i}function N(e,t,n=!1){return z!==null&&(!Kn||z.f&131072)&&Ye()&&z.f&4325394&&(Yn===null||!Yn.has(e))&&Ge(),qt(e,n?Zt(t):t,kt)}var Gt=null,Kt=0;function qt(e,t,n=null){if(!e.equals(t)){Wn?Vt.set(e,t):Vt.has(e)||Vt.set(e,e.v);var r=Mt.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&vt(t),wt===null&&nt(t)}e.wv=ar(),Gt=null,Kt=0,Xt(e,S,n),Gt=null,Ye()&&B!==null&&B.f&1024&&!(B.f&96)&&($n===null?er([e]):$n.push(e)),!r.is_fork&&Bt.size>0&&!Ht&&Jt()}return t}function Jt(){Ht=!1;for(let e of Bt){e.f&1024&&tt(e,C);let t;try{t=or(e)}catch{t=!0}t&&fr(e)}Bt.clear()}function Yt(e){N(e,e.v+1)}function Xt(e,t,n){var r=e.reactions;if(r!==null){var i=Ye(),a=r.length;if(Kt+=a,Kt>1e5&&Gt===null&&(Gt=new Set),Gt!==null){if(Gt.has(e))return;Gt.add(e)}for(var o=0;o{if(rr===c)return e();var t=z,n=rr;qn(null),ir(c);var r=e();return qn(t),ir(n),r};return i&&n.set(`length`,Wt(e.length,s)),new Proxy(e,{defineProperty(e,t,r){(!(`value`in r)||r.configurable===!1||r.enumerable===!1||r.writable===!1)&&Ue();var i=n.get(t);return i===void 0?l(()=>{var e=Wt(r.value,s);return n.set(t,e),e}):N(i,r.value,!0),!0},deleteProperty(e,t){var i=n.get(t);if(i===void 0){if(t in e){let e=l(()=>Wt(r,s));n.set(t,e),Yt(o)}}else N(i,r),Yt(o);return!0},get(t,i,a){if(i===de)return e;var o=n.get(i),c=i in t;if(o===void 0&&(!c||u(t,i)?.writable)&&(o=l(()=>Wt(Zt(c?t[i]:r),s)),n.set(i,o)),o!==void 0){var d=V(o);return d===r?void 0:d}return Reflect.get(t,i,a)},getOwnPropertyDescriptor(e,t){this.has?.(e,t);var i=Reflect.getOwnPropertyDescriptor(e,t),a=n.get(t);if(a!==void 0){var o=V(a);if(o===r)return;if(i&&`value`in i)i.value=o;else return{enumerable:!0,configurable:!0,value:o,writable:!0}}return i},has(e,t){if(t===de)return!0;var i=n.get(t),a=i!==void 0&&i.v!==r||Reflect.has(e,t);return(i!==void 0||B!==null&&(!a||u(e,t)?.writable))&&(i===void 0&&(i=l(()=>Wt(a?Zt(e[t]):r,s)),n.set(t,i)),V(i)===r)?!1:a},set(e,t,a,c){var d=n.get(t),f=t in e;if(i&&t===`length`)for(var p=a;pWt(r,s)),n.set(p+``,m)):N(m,r)}if(d===void 0)(!f||u(e,t)?.writable)&&(d=l(()=>Wt(void 0,s)),N(d,Zt(a)),n.set(t,d));else{f=d.v!==r;var h=l(()=>Zt(a));N(d,h)}var g=Reflect.getOwnPropertyDescriptor(e,t);if(g?.set&&g.set.call(c,a),!f){if(i&&typeof t==`string`){var _=n.get(`length`),v=Number(t);Number.isInteger(v)&&v>=_.v&&N(_,v+1)}Yt(o)}return!0},ownKeys(e){V(o);var t=Reflect.ownKeys(e).filter(e=>{var t=n.get(e);return t===void 0||t.v!==r});for(var[i,a]of n)a.v!==r&&!(i in e)&&t.push(i);return t},setPrototypeOf(){We()}})}function Qt(e){try{if(typeof e==`object`&&e&&de in e)return e[de]}catch{}return e}function $t(e,t){return Object.is(Qt(e),Qt(t))}var en,tn,nn,rn;function an(){if(en===void 0){en=window,tn=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;nn=u(t,`firstChild`).get,rn=u(t,`nextSibling`).get,h(e)&&(e[ge]=void 0,e[he]=null,e[_e]=void 0,e.__e=void 0),h(n)&&(n[ve]=void 0)}}function on(e=``){return document.createTextNode(e)}function sn(e){return nn.call(e)}function cn(e){return rn.call(e)}function P(e,t){if(!w)return sn(e);var n=sn(T);if(n===null)n=T.appendChild(on());else if(t&&n.nodeType!==3){var r=on();return n?.before(r),De(r),r}return t&&fn(n),De(n),n}function F(e,t=!1){if(!w){var n=sn(e);return n instanceof Comment&&n.data===``?cn(n):n}if(t){if(T?.nodeType!==3){var r=on();return T?.before(r),De(r),r}fn(T)}return T}function I(e,t=!1){if(!w)return sn(e);var n=P(e,t);return E(e),n}function L(e,t=1,n=!1){let r=w?T:e;for(var i;t--;)i=r,r=cn(r);if(!w)return r;if(n){if(r?.nodeType!==3){var a=on();return r===null?i?.after(a):r.before(a),De(a),a}fn(r)}return De(r),r}function ln(e){e.textContent=``}function un(){return!1}function dn(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function fn(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function pn(e){var t=B;if(t===null)return z.f|=ue,e;if(!(t.f&32768)&&!(t.f&4))throw e;mn(e,t)}function mn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function hn(e){B===null&&(z===null&&Be(e),ze()),Wn&&Re(e)}function gn(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function _n(e,t){var n=B;n!==null&&n.f&8192&&(e|=ee);var r={ctx:D,deps:null,nodes:null,f:e|S|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};j?.register_created_effect(r);var i=r;if(e&4)Ot===null?Mt.ensure().schedule(r):Ot.push(r);else if(t!==null){try{fr(r)}catch(e){throw Pn(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=ie))}if(i!==null&&(i.parent=n,n!==null&&gn(i,n),z!==null&&z.f&2&&!(e&64))){var a=z;(a.effects??=[]).push(i)}return r}function vn(){return z!==null&&!Kn}function yn(e){let t=_n(8,null);return tt(t,x),t.teardown=e,t}function bn(e){hn(`$effect`);var t=B.f;if(!z&&t&32&&D!==null&&!D.i){var n=D;(n.e??=[]).push(e)}else return xn(e)}function xn(e){return _n(4|oe,e)}function Sn(e){return hn(`$effect.pre`),_n(8|oe,e)}function Cn(e){Mt.ensure();let t=_n(64|ae,e);return(e={})=>new Promise(n=>{e.outro?Ln(t,()=>{Pn(t),n(void 0)}):(Pn(t),n(void 0))})}function wn(e){return _n(4,e)}function Tn(e,t){var n=D,r={effect:null,ran:!1,deps:e};n.l.$.push(r),r.effect=On(()=>{if(e(),!r.ran){r.ran=!0;var n=B;try{Jn(n.parent),H(t)}finally{Jn(n)}}})}function En(){var e=D;On(()=>{for(var t of e.l.$){t.deps();var n=t.effect;n.f&1024&&n.deps!==null&&tt(n,C),or(n)&&fr(n),t.ran=!1}})}function Dn(e){return _n(le|ae,e)}function On(e,t=0){return _n(8|t,e)}function R(e,t=[],n=[],r=[]){lt(r,t,n,t=>{_n(8,()=>{e(...t.map(V))})})}function kn(e,t=0){return _n(16|t,e)}function An(e){return _n(32|ae,e)}function jn(e){var t=e.teardown;if(t!==null){let n=Wn,r=z;Gn(!0),qn(null);try{t.call(null)}catch(t){mn(t,e.parent)}finally{Gn(n),qn(r)}}}function Mn(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&st(()=>{e.abort(be)});var r=n.next;n.f&64?n.parent=null:Pn(n,t),n=r}}function Nn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||Pn(t),t=n}}function Pn(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Fn(e.nodes.start,e.nodes.end),n=!0),e.f|=re,Mn(e,t&&!n),dr(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();jn(e),e.f^=re,e.f|=te;var i=e.parent;i!==null&&i.first!==null&&In(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Fn(e,t){for(;e!==null;){var n=e===t?null:cn(e);e.remove(),e=n}}function In(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Ln(e,t,n=!0){var r=[];e.f|=256,Rn(e,r,!0);var i=()=>{n&&Pn(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function Rn(e,t,n){if(!(e.f&8192)){e.f^=ee;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);Rn(i,t,o?n:!1)}i=a}}}function zn(e){e.f&=-257,Bn(e,!0)}function Bn(e,t){if(!(e.f&256)&&e.f&8192){e.f^=ee,e.f&1024||(tt(e,S),Mt.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);Bn(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function Vn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:cn(n);t.append(n),n=i}}var Hn=null,Un=!1,Wn=!1;function Gn(e){Wn=e}var z=null,Kn=!1;function qn(e){z=e}var B=null;function Jn(e){B=e}var Yn=null;function Xn(e){z!==null&&(z.f&2097152||z.f&2)&&(Yn??=new Set).add(e)}var Zn=null,Qn=0,$n=null;function er(e){$n=e}var tr=1,nr=0,rr=nr;function ir(e){rr=e}function ar(){return++tr}function or(e){var t=e.f;if(t&2048)return!0;if(t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&wt===null&&tt(e,x)}return!1}function sr(e,t,n=!0){var r=e.reactions;if(r!==null&&!(Yn!==null&&Yn.has(e)))for(var i=0;i{e.ac.abort(be)}),e.ac=null);try{e.f|=ce;var u=e.fn,d=u();e.f|=ne;var f=lr(e);if(Ye()&&$n!==null&&!Kn&&f!==null&&!(e.f&6146))for(var p=0;p<$n.length;p++)sr($n[p],e);if(i!==null&&i!==e){if(nr++,i.deps!==null)for(let e=0;e0)for(t.length=Qn+Zn.length,r=0;r{c.ac.abort(be),c.ac=null,tt(c,S)}),bt(c),dr(c,0)}}function dr(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?(i.__removed=!1,Qe(()=>{i.__removed||t.addEventListener(e,i,r)})):t.addEventListener(e,i,r),i}function xr(e,t,n,r,i){var a={capture:r,passive:i},o=br(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&yn(()=>{o.__removed=!0,t.removeEventListener(e,o,a)})}function Sr(e,t,n){(t[_r]??={})[e]=n}function Cr(e){for(var t=0;t{Tr=!1,wr=null}));var o=0,s=wr===e&&e[_r];if(s){var c=i.indexOf(s);if(c!==-1&&(t===document||t===window)){e[_r]=t;return}var u=i.indexOf(t);if(u===-1)return;c<=u&&(o=c)}if(a=i[o]||e.target,a!==t){l(e,`currentTarget`,{configurable:!0,get(){return a||n}});var d=z,f=B;qn(null),Jn(null);try{for(var p,m=[];a!==null&&a!==t;){try{var h=a[_r]?.[r];h!=null&&(!a.disabled||e.target===a)&&h.call(a,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;o++,a=o{throw e});throw p}}finally{e[_r]=t,delete e.currentTarget,qn(d),Jn(f)}}}var Dr=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function Or(e){return Dr?.createHTML(e)??e}function kr(e){var t=dn(`template`);return t.innerHTML=Or(e.replaceAll(``,``)),t.content}function Ar(e,t){var n=B;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function W(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(w)return Ar(T,null),T;i===void 0&&(i=kr(a?e:``+e),n||(i=sn(i)));var t=r||tn?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=sn(t),s=t.lastChild;Ar(o,s)}else Ar(t,t);return t}}function jr(e=``){if(!w){var t=on(e+``);return Ar(t,t),t}var n=T;return n.nodeType===3?fn(n):(n.before(n=on()),De(n)),Ar(n,n),n}function Mr(){if(w)return Ar(T,null),T;var e=document.createDocumentFragment(),t=document.createComment(``),n=on();return e.append(t,n),Ar(t,n),e}function G(e,t){if(w){var n=B;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=T),Oe();return}e!==null&&e.before(t)}[...`allowfullscreen.async.autofocus.autoplay.checked.controls.default.disabled.formnovalidate.indeterminate.inert.ismap.loop.multiple.muted.nomodule.novalidate.open.playsinline.readonly.required.reversed.seamless.selected.webkitdirectory.defer.disablepictureinpicture.disableremoteplayback`.split(`.`)];var Nr=[`touchstart`,`touchmove`];function Pr(e){return Nr.includes(e)}function Fr(e){let t=0,n=Ut(0),r;return()=>{vn()&&(V(n),On(()=>(t===0&&(r=H(()=>e(()=>Yt(n)))),t+=1,()=>{Qe(()=>{--t,t===0&&(r?.(),r=void 0,Yt(n))})})))}}var Ir=ie|ae;function Lr(e,t,n,r){new Rr(e,t,n,r)}var Rr=class{parent;is_pending=!1;transform_error;#e;#t=w?T:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=Fr(()=>(this.#m=Ut(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=B;t.b=this,t.f|=128,n(e)},this.parent=B.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=kn(()=>{if(w){let e=this.#t;Oe();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},Ir),w&&(this.#e=T)}#g(){try{this.#a=An(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);Qe(r),t&&(this.#s=An(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{if(t){Te();return}t=!0,n&&Ke(),this.#s!==null&&Ln(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()})};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){mn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=An(()=>e(this.#e)),Qe(()=>{var e=this.#c=document.createDocumentFragment(),t=on(),n=!1;if(e.append(t),this.#a=this.#S(()=>{try{return An(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){mn(e,this.#i.parent)}return null}}),this.#a===null){this.#c=null,n&&this.#x(j);return}this.#u===0&&(this.#e.before(e),this.#c=null,Ln(this.#o,()=>{this.#o=null}),this.#x(j))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=An(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();Vn(this.#a,e);let t=this.#n.pending;this.#o=An(()=>t(this.#e))}else this.#x(j)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){rt(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=B,n=z,r=D;Jn(this.#i),qn(this.#i),qe(this.#i.ctx);try{return Mt.ensure(),e()}finally{Jn(t),qn(n),qe(r)}}#C(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#C(e,t);return}this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Ln(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Qe(()=>{this.#d=!1,this.#m&&qt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),V(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;j?.is_fork?(this.#a&&j.skip_effect(this.#a),this.#o&&j.skip_effect(this.#o),this.#s&&j.skip_effect(this.#s),j.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(Pn(this.#a),null),this.#o&&=(Pn(this.#o),null),this.#s&&=(Pn(this.#s),null),w&&(De(this.#t),ke(),De(Ae()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return An(()=>{var r=B;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return mn(e,this.#i.parent),null}}))};Qe(()=>{var t;try{t=this.transform_error(e)}catch(e){mn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>mn(e,this.#i&&this.#i.parent)):n(t)})}};function K(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[ve]??=e.nodeValue)&&(e[ve]=n,e.nodeValue=`${n}`)}function zr(e,t){return Vr(e,t)}var Br=new Map;function Vr(e,{target:t,anchor:r,props:i={},events:a,context:o,intro:s=!0,transformError:l}){an();var u=void 0,d=Cn(()=>{var s=r??t.appendChild(on());Lr(s,{pending:()=>{}},t=>{O({});var r=D;if(o&&(r.c=o),a&&(i.$$events=a),w&&Ar(t,null),u=e(t,i)||Je(),w&&(B.nodes.end=T,T===null||T.nodeType!==8||T.data!==`]`))throw Ce(),n;k()},l);var d=new Set,f=e=>{for(var n=0;n{for(var e of d)for(let r of[t,document]){var n=Br.get(r),i=n.get(e);--i==0?(r.removeEventListener(e,Er),n.delete(e),n.size===0&&Br.delete(r)):n.set(e,i)}yr.delete(f),s!==r&&s.parentNode?.removeChild(s)}});return Hr.set(u,d),u}var Hr=new WeakMap,Ur=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)zn(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(zn(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(Pn(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();Vn(r,t),t.append(on()),this.#n.set(e,{effect:r,fragment:t})}else Pn(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Ln(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(Pn(n.effect),this.#n.delete(e))};ensure(e,t){var n=j,r=un();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=on();i.append(a),this.#n.set(e,{effect:An(()=>t(a)),fragment:i})}else this.#t.set(e,An(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else w&&(this.anchor=T),this.#a(n)}};function Wr(t){D===null&&Fe(`onMount`),e&&D.l!==null?Gr(D).m.push(t):bn(()=>{let e=H(t);if(typeof e==`function`)return e})}function Gr(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}function q(e,t,n=!1){var r;w&&(r=T,Oe());var i=new Ur(e),a=n?ie:0;function o(e,t){if(w){var n=je(r);if(e!==parseInt(n.substring(1))){var a=Ae();De(a),i.anchor=a,Ee(!1),i.ensure(e,t),Ee(!0);return}}i.ensure(e,t)}kn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Kr=Symbol(`NaN`);function qr(e,t,n){w&&Oe();var r=new Ur(e),i=!Ye();kn(()=>{var e=t();e!==e&&(e=Kr),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function Jr(e,t){return t}function Yr(e,t,n){for(var r=[],i=t.length,a,o=t.length,s=0;s{if(a){if(a.pending.delete(n),a.done.add(n),a.pending.size===0){var t=e.outrogroups;Xr(e,c(a.done)),t.delete(a),t.size===0&&(e.outrogroups=null)}}else--o},!1)}if(o===0){var l=r.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;ln(d),d.append(u),e.items.clear()}Xr(e,t,!l)}else a={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(a)}function Xr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var e=n();return a(e)?e:e==null?[]:c(e)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,ei(v,p,s,t,r),d!==null&&(p.length===0?d.f&33554432?(d.f^=se,ni(d,null,s)):zn(d):Ln(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:kn(()=>{p=V(f);var e=p.length;let a=!1;w&&je(s)===`[!`!=(e===0)&&(s=Ae(),De(s),Ee(!1),a=!0);for(var c=new Set,u=j,v=un(),y=0;yo(s)):(d=An(()=>o(Zr??=on())),d.f|=se)),e>c.size&&Le(``,``,``),w&&e>0&&De(Ae()),!h){if(m.set(u,c),v){for(let[e,t]of l)c.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}a&&Ee(!0),V(f)}),flags:t,items:l,pending:m,outrogroups:null,fallback:d};h=!1,w&&(s=T)}function $r(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function ei(e,t,n,r,i){var a=!!(r&8),o=t.length,s=e.items,l=$r(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(a)for(v=0;v0){var ne=r&4&&o===0?n:null;if(a){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function ti(e,t,n,r,i,a,o,s){var c=o&1?o&16?Ut(n):M(n,!1,!1):null,l=o&2?Ut(i):null;return{v:c,i:l,e:An(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function ni(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=cn(r);if(a.before(r),r===i)return;r=o}}function ri(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}function ii(e,t,n){wn(()=>{var r=H(()=>t(e,n?.())||{});if(n&&r?.update){var i=!1,a={};On(()=>{var e=n();U(e),i&&Ne(a,e)&&(a=e,r.update(e))}),i=!0}if(r?.destroy)return()=>r.destroy()})}function ai(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`){if(Array.isArray(e)){var i=e.length;for(t=0;t=0;){var s=o+a;(o===0||ci.includes(r[o-1]))&&(s===r.length||ci.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function ui(e,t,n,r,i,a){var o=e[ge];if(w||o!==n||o===void 0){var s=li(n,r,a);(!w||s!==e.getAttribute(`class`))&&(s==null?e.removeAttribute(`class`):t?e.className=s:e.setAttribute(`class`,s)),e[ge]=n}else if(a&&i!==a)for(var c in a){var l=!!a[c];(i==null||l!==!!i[c])&&e.classList.toggle(c,l)}return a}function di(e,t){t?e.hasAttribute(`selected`)||e.setAttribute(`selected`,``):e.removeAttribute(`selected`)}function fi(e,t){var n=e.__defaultValue,r=e.multiple,i=r?n??[]:null;if(!r||a(i)){var o=e.selectedIndex,s=t&&r?new Set(e.selectedOptions):null;for(var c of e.options){var l=gi(c);di(c,r?i.includes(l):$t(l,n))}if(t){if(s!==null)for(c of e.options){var u=s.has(c);c.selected!==u&&(c.selected=u)}else e.selectedIndex!==o&&(e.selectedIndex=o)}}}function pi(e,t,n=!1){if(e.multiple){if(t==null)return;if(!a(t))return we();for(var r of e.options)r.selected=t.includes(gi(r));return}for(r of e.options)if($t(gi(r),t)){r.selected=!0;return}(!n||t!==void 0)&&(e.selectedIndex=-1)}function mi(e){var t=new MutationObserver(t=>{t.every(_i)||(`__defaultValue`in e&&fi(e,!1),`__value`in e&&pi(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),yn(()=>{t.disconnect()})}function hi(e,t,n=t){var r=new WeakSet,i=!0;ct(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),gi);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&gi(o)}n(a),e.__value=a,j!==null&&r.add(j)}),wn(()=>{var a=t();if(e===document.activeElement){var o=j;if(r.has(o))return}if(pi(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=gi(s),n(a))}e.__value=a,i=!1})}function gi(e){return`__value`in e?e.__value:e.value}function _i(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var vi=Symbol(`is custom element`),yi=Symbol(`is html`),bi=xe?`link`:`LINK`;function xi(e){if(w){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;J(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;J(e,`checked`,null),e.checked=r}}};e[ye]=n,Qe(n),ot()}}function Si(e,t){var n=Ci(e);n.checked!==(n.checked=t??void 0)&&(e.checked=t)}function J(e,t,n,r){var i=Ci(e);w&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===bi)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[me]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&Ti(e).has(t)?e[t]=n:e.setAttribute(t,n))}function Ci(e){return e[he]??={[vi]:e.nodeName.includes(`-`),[yi]:e.namespaceURI===i}}var wi=new Map;function Ti(e){var t=e.getAttribute(`is`)||e.nodeName,n=wi.get(t);if(n)return n;wi.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var o in r=d(i),r)r[o].set&&o!==`innerHTML`&&o!==`textContent`&&o!==`innerText`&&n.add(o);i=m(i)}return n}function Ei(e,t,n=t){var r=new WeakSet;ct(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=Di(e)?Oi(a):a,n(a),j!==null&&r.add(j),await pr(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(w&&e.defaultValue!==e.value||H(t)==null&&e.value)&&(n(Di(e)?Oi(e.value):e.value),j!==null&&r.add(j)),On(()=>{var n=t();if(e===document.activeElement){var i=j;if(r.has(i))return}Di(e)&&n===Oi(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}function Di(e){var t=e.type;return t===`number`||t===`range`}function Oi(e){return e===``?null:+e}function Y(e=!1){let t=D,n=t.l.u;if(!n)return;let r=()=>U(t.s);if(e){let e=0,n={},i=pt(()=>{let r=!1,i=t.s;for(let e in i)i[e]!==n[e]&&(n[e]=i[e],r=!0);return r&&e++,e});r=()=>V(i)}n.b.length&&Sn(()=>{ki(t,r),v(n.b)}),bn(()=>{let e=H(()=>n.m.map(_));return()=>{for(let t of e)typeof t==`function`&&t()}}),n.a.length&&bn(()=>{ki(t,r),v(n.a)})}function ki(e,t){if(e.l.s)for(let t of e.l.s)V(t);t()}function Ai(e,t,n){if(e==null)return t(void 0),n&&n(void 0),g;let r=H(()=>e.subscribe(t,n));return r.unsubscribe?()=>r.unsubscribe():r}var ji=[];function Mi(e,t=g){let n=null,r=new Set;function i(t){if(Ne(e,t)&&(e=t,n)){let t=!ji.length;for(let t of r)t[1](),ji.push(t,e);if(t){for(let e=0;e{r.delete(c),r.size===0&&n&&(n(),n=null)}}return{set:i,update:a,subscribe:o}}function Ni(e){let t;return Ai(e,e=>t=e)(),t}var Pi=!1,Fi=Symbol(`unmounted`);function X(e,t,n){let r=n[t]??={store:null,source:M(void 0),unsubscribe:g};if(r.store!==e&&!(Fi in n)){if(r.unsubscribe(),r.store=e??null,e==null)r.source.v=void 0,r.unsubscribe=g;else{var i=!0;r.unsubscribe=Ai(e,e=>{i?r.source.v=e:N(r.source,e)}),i=!1}}return e&&Fi in n?Ni(e):V(r.source)}function Ii(){let e={};function t(){yn(()=>{for(var t in e)e[t].unsubscribe();l(e,Fi,{enumerable:!1,value:!0})})}return[e,t]}function Li(e){var t=Pi;try{return Pi=!1,[e(),Pi]}finally{Pi=t}}function Z(t,n,r,i){var a=!e||!!(r&2),o=!!(r&8),s=!!(r&16),c=i,l=!0,d=void 0,f=()=>s&&a?(d??=pt(i),V(d)):(l&&(l=!1,c=s?H(i):i),c);let p;if(o){var m=de in t||pe in t;p=u(t,n)?.set??(m&&n in t?e=>t[n]=e:void 0)}var h,g=!1;o?[h,g]=Li(()=>t[n]):h=t[n],h===void 0&&i!==void 0&&(h=f(),p&&(a&&He(n),p(h)));var _=a?()=>{var e=t[n];return e===void 0?f():(l=!0,e)}:()=>{var e=t[n];return e!==void 0&&(c=void 0),e===void 0?c:e};if(a&&!(r&4))return _;if(p){var v=t.$$legacy;return(function(e,t){return arguments.length>0?((!a||!t||v||g)&&p(t?_():e),e):_()})}var y=!1,b=(r&1?pt:A)(()=>(y=!1,_()));o&&V(b);var x=B;return(function(e,t){if(arguments.length>0){let n=t?V(b):a&&o?Zt(e):e;return N(b,n),y=!0,c!==void 0&&(c=n),e}return Wn&&y||x.f&16384?b.v:V(b)})}function Ri(e){let t=Mi({paired:!!e,controller:!1,busy:!1,error:``}),n=e,r,i={paired:!!e,controller:!1,busy:!1,error:``},a=!1;function o(e){i={...i,...e},t.set(i)}async function s(e=!1){if(!a&&n){a=!0;try{let t=await fetch(`/api/state`,{cache:`no-store`,headers:{"X-Setup-Session-Key":n}});if(!t.ok)throw Error(`The local setup session is unavailable.`);o({view:await t.json(),...e?{}:{error:``}})}catch{o({view:void 0,error:`Connection lost. The CLI may have stopped. Check the terminal before trying again.`})}finally{a=!1}}}let c=()=>s(!0);async function l(){if(n)try{let e=await fetch(`/api/bootstrap`,{cache:`no-store`,headers:{"X-Setup-Session-Key":n}});if(!e.ok)throw Error(`Could not join this local session.`);let t=await e.json();r=t.capability,o({controller:t.controller,error:``}),await s()}catch{n=void 0,r=void 0,o({view:void 0,paired:!1,controller:!1,error:`Could not connect to the local setup session. Check the terminal and pair again.`})}}async function u(e){if(!(i.busy||i.paired)){o({busy:!0,error:``});try{let t=await fetch(`/api/pair`,{method:`POST`,cache:`no-store`,headers:{"Content-Type":`application/json`},body:JSON.stringify({code:e.trim().toLowerCase()})}),r=await t.json();if(!t.ok)throw Error(String(r.error??`Pairing was rejected.`));if(typeof r.sessionKey!=`string`||!/^[a-f0-9]{64}$/.test(r.sessionKey))throw Error(`Invalid local pairing response.`);n=r.sessionKey,o({paired:!0,error:``}),await l()}catch(e){o({error:e instanceof Error?e.message:`Could not pair this browser.`})}finally{o({busy:!1})}}}async function d(e,t,i=!0){let a=await fetch(e,{method:`POST`,cache:`no-store`,headers:{"Content-Type":`application/json`,"X-Setup-Session-Key":n,...i&&r?{"X-Setup-Capability":r}:{}},body:JSON.stringify(t)}),o=await a.json();if(!a.ok)throw Error(String(o.error??`The request was rejected.`));return o}async function f(e,t){let n=e instanceof Error?e.message:t;o({error:n}),/read-only|Control moved/.test(n)?await l():await s(!0)}async function p(e,t){if(!i.busy&&i.controller&&i.view?.promptRevision===e){o({busy:!0,error:``});try{await d(`/api/answer`,{revision:e,value:t}),await s()}catch(e){await f(e,`Could not submit this answer.`)}finally{o({busy:!1})}}}async function m(e){if(!i.busy&&i.controller&&i.view?.promptRevision===e){o({busy:!0,error:``});try{await d(`/api/retry-discovery`,{revision:e}),await s()}catch(e){await f(e,`Could not retry discovery.`)}finally{o({busy:!1})}}}async function h(e){if(!i.busy&&i.controller&&i.view?.promptRevision===e){o({busy:!0,error:``});try{await d(`/api/back`,{revision:e}),await s()}catch(e){await f(e,`Could not return to the previous question.`)}finally{o({busy:!1})}}}async function g(){if(i.controller&&!i.busy){o({busy:!0,error:``});try{await d(`/api/cancel`,{}),await s()}catch(e){let t=e instanceof Error?e.message:`Cancellation failed.`;o({error:t}),/read-only|Control moved/.test(t)&&await l()}finally{o({busy:!1})}}}async function _(e){if(!(i.busy||!i.paired||i.controller)){o({busy:!0,error:``});try{let t=await d(`/api/takeover`,{code:e.trim().toLowerCase()},!1);r=String(t.capability),o({controller:!0,error:``}),await s()}catch(e){o({error:e instanceof Error?e.message:`Takeover failed.`}),await s(!0)}finally{o({busy:!1})}}}async function v(){try{await d(`/api/close`,{})}catch{}}async function y(){if(!i.busy&&i.controller&&i.view?.outcome===`complete`){o({busy:!0,error:``,view:{...i.view,doctor:{status:`running`}}});try{await d(`/api/doctor`,{})}catch(e){o({error:e instanceof Error?e.message:`Read-only verification failed.`})}finally{await s(!0),o({busy:!1})}}}return{subscribe:t.subscribe,pair:u,connect:l,refresh:s,poll:c,submit:p,retryDiscovery:m,back:h,cancel:g,takeOver:_,close:v,runDoctor:y}}var zi={language:`Language`,english:`English`,spanish:`Español`,setup:`SETUP`,connecting:`Connecting…`,localSession:`LOCAL SESSION`,progress:`Setup progress`,studio:`SETUP STUDIO`,journey:`YOUR SETUP JOURNEY`,repository:`Repository`,choices:`Setup choices`,setupPat:`Setup PAT`,plan:`Plan`,botPat:`Bot PAT & credentials`,apply:`Apply`,localDesign:`Local by design`,localDesignBody:`This page runs on your computer. GitHub creates both PATs in its own browser tabs.`,preparing:`Preparing your setup…`,completeTitle:`Setup complete.`,previewTitle:`Preview complete.`,cancelledTitle:`Setup cancelled.`,blockedTitle:`Setup needs attention.`,gettingReady:`GETTING READY`,activeLede:`One decision at a time. Your choices shape the permissions, plan and credentials needed for this repository.`,resultLede:`Review the result and next action below. The terminal has additional technical detail.`,readOnly:`Read-only tab`,readOnlyBody:`Another tab controls this session. You can review progress here or explicitly take over.`,takeOver:`Take control in this tab`,attention:`Needs attention`,checked:`Checked`,pleaseNote:`Please note`,progressUpdate:`Progress update`,reviewPass:`Reviewing saved choices — pass {pass}. This is the same setup run, not a restart.`,cancelSetup:`Cancel setup`,cancelConfirm:`Cancel this local setup session? PATs already created in GitHub will still exist.`,footerLocal:`LOCALHOST ONLY`,footerCloud:`NO CLOUD SETUP ACCOUNT`,footerGithub:`GITHUB OWNS PAT ISSUANCE`,currentDecision:`CURRENT DECISION`,session:`SESSION`,continue:`Continue`,previousQuestion:`Previous question`,questionProgress:`Question {current} of {total} in this group · {overall} of {all} overall`,yes:`Yes`,no:`No`,permissionPreview:`PERMISSION PREVIEW`,changeAnswersTitle:`Change your answers`,changeAnswersHelp:`Return to a section without losing other answers. The plan and required PAT grants will be checked again.`,changeSection:`Change {section}`,editCapabilities:`Capabilities`,editRuntimes:`Agent runtimes`,editModels:`Agent models`,editRoleModels:`Per-role models`,editRepository:`Repository behavior`,editDeployment:`Release and hotfix`,editBugbot:`Bugbot`,editApproval:`PR approval`,editProjects:`Projects`,editProvisioning:`Provisioning`,editStorage:`Secrets and Variables`,suggested:`Suggested answer: {answer}. You can review choices again before creating your setup PAT.`,none:`none`,sourceGithub:`Observed from authenticated GitHub repository metadata.`,sourceConfig:`Provided by your configuration; GitHub has not replaced it.`,sourceDefault:`Product default; not verified against this repository.`,sourceLocal:`Observed in this local checkout; confirm this branch exists on GitHub before applying.`,whyMatters:`Why this matters`,whenApplies:`When it applies`,whereConfigured:`Where it is configured`,howToChoose:`How to choose`,whyRecommendation:`Why this matters`,example:`Example`,effect:`What changes`,verify:`How to check`,learnMore:`Read documentation about this setting`,resultApplied:`Your configuration was applied`,resultNoChanges:`No changes were made`,resultStopped:`No setup changes started`,resultPartial:`Check partial changes before retrying`,resultCompleteBody:`The temporary setup PAT is not revoked automatically. Delete it in GitHub after confirming your setup. Keep the bot PAT until you rotate the installed Secret.`,resultPartialBody:`A bot Secret write or another change may have succeeded. Inspect GitHub and run copilot doctor --read-only before replacing or deleting its PAT.`,resultNoChangesBody:`Copilot did not begin applying setup changes. PATs you created in GitHub still exist until you delete them there.`,whatHappened:`What happened`,alreadyChanged:`Already changed`,noChanges:`No repository or GitHub setup changes were started in this session.`,nextAction:`Next action`,reasonPermissions:`The setup PAT lacked or could not confirm required permissions.`,nextPermissions:`Check the displayed grants, correct the PAT in GitHub, then start a fresh setup run.`,reasonStorage:`The selected GitHub Actions storage could not be used safely.`,nextStorage:`Inspect the Variables/Secrets scope and existing resources, then retry.`,reasonConfiguration:`The chosen configuration could not be validated.`,nextConfiguration:`Review the terminal validation details, correct the choices, and retry.`,reasonExpired:`The local setup session expired before applying changes.`,nextExpired:`Start a new setup session; the old approval cannot be replayed.`,reasonCancelled:`This setup was cancelled before applying changes.`,nextCancelled:`Start a new run if you still want to configure this repository.`,reasonUnknown:`Setup stopped before Apply. The browser could not determine the exact cause.`,nextUnknown:`Check the final terminal error before retrying; do not assume a PAT was revoked.`,reasonProvider:`GitHub or another provider did not complete the requested operation.`,nextProvider:`Use the diagnostic reference to inspect the terminal result, check provider availability and access, then retry only after reviewing possible partial changes.`,reasonRateLimit:`GitHub temporarily limited the requests needed for setup.`,nextRateLimit:`Wait for the limit to reset. Inspect any completed changes before starting another setup run.`,diagnosticReference:`Diagnostic reference`,resourceReceipt:`Setup operation receipt`,effectCompleted:`Reported completed`,effectSkipped:`Reported skipped`,effectInspect:`Outcome needs inspection`,effectNotStarted:`Not started`,scopeLocal:`Local checkout`,scopeMixed:`Repository and organization`,receiptFiles:`Setup files`,receiptSecrets:`GitHub Actions Secrets`,receiptLabels:`Issue labels`,receiptIssueTypes:`Issue types`,receiptVariables:`GitHub Actions Variables`,receiptInitialTag:`Initial version tag`,inspectPartial:`Some setup changes may already be active. Inspect GitHub and the terminal result before retrying.`,patSettings:`Open GitHub PAT settings ↗`,closeSession:`Close local session`,doctorHelp:`After success, you can verify installed resources here without dispatching Actions. Or run copilot doctor --read-only from the repository root with the temporary setup PAT and the same non-secret --config file, if used.`,doctorRun:`Verify installed setup (read-only)`,doctorRunning:`Checking installed resources without dispatching Actions…`,doctorPassed:`Read-only verification found no failing checks.`,doctorWarnings:`Read-only verification needs attention.`,doctorFailed:`Read-only verification did not finish. Check the terminal or run the command below.`,doctorCounts:`{pass} passed · {warn} warnings · {fail} failed · {skipped} skipped.`,doctorSecretLimit:`Secret values cannot be verified in this mode.`,botRenewal:`The bot PAT stays in the selected GitHub Actions Secret for future runs. Its actual expiry is not verified here; record it in GitHub and rotate the Secret before expiry.`,working:`Working on the next step`,workingBody:`The local process is checking your answers and preparing the next decision. Keep this page open.`,repoFocus:`Repository in focus`,repoFocusBody:`All decisions in this session target only:`,access:`access`,readOnlyCheck:`Read-only access check`,provisionalGrants:`Provisional least-privilege grants`,conditionalGrants:`Conditional grants depend on the choices and GitHub facts. A final audit runs before setup changes.`,permissionUnknown:`This permission needs review. Check its exact grant and technical explanation in the terminal before continuing.`,permissionsFollow:`Permissions follow your choices`,permissionsFollowBody:`We’ll show the exact grants before you create each PAT. Nothing is created just by opening this page.`,files:`Files`,workflows:`Workflows`,variables:`Variables`,secretNames:`Secret names`,planBody:`Review exactly what this run may change. The bot PAT and any additional credentials are collected next.`,planChoices:`Your key decisions`,planEnabledCapabilities:`Enabled capabilities`,planBranchRoles:`Production / development branch`,planApprovalMode:`Pull-request approval`,planVariableScope:`Variables scope`,planSecretScope:`Secrets scope`,planInitialTag:`Create initial tag`,planAgentRouting:`Agent and model for each task`,planIssueWorkflows:`Issue workflows`,planTrustedChecks:`Trusted CI producers`,planCoverage:`Coverage evidence`,planProjectStatuses:`Project Status transitions`,planIssueResources:`Issue labels and issue types`,planIssueResourcesValue:`Checked or provisioned during Apply`,planProducerAttested:`CI identity and enforcing step verified by you`,planCoverageThreshold:`Minimum changed-line coverage`,planCoverageReporter:`Artifact-publishing workflow`,planReporterAttested:`Coverage reporter verified by you`,planAdvancedDefaults:`The plan also includes defaults for settings you did not change. Use Change below to inspect any section before approving.`,planUnknownWarning:`An additional plan warning could not be displayed here. Read the terminal warning before approving.`,planBasicDefaultsIntro:`Basic setup retained these advanced defaults. Open a section below to review or change them:`,scopeRepository:`Repository`,scopeOrganization:`Organization`,scopeDisabled:`Not provisioned`,approvalOff:`Off`,approvalRecommend:`Recommendation only`,approvalGuarded:`Guarded approval`,beforeContinue:`Before you continue`,stopHere:`Stop here`,approvePlan:`Approve this plan`,githubLink:`Open GitHub link ↗`,githubForm:`Open the official GitHub PAT form`,githubFormHelp:`Check the signed-in account, select Only select repositories, then select this repository. GitHub handles 2FA and creates the PAT.`,pasteHere:`Paste the value here`,yourAnswer:`Your answer`,hiddenAfter:`Hidden after submission`,typeAnswer:`Type your answer`,secretHelp:`Sent only to this local process. It will not be shown again or saved in browser storage.`,pairTitle:`Pair this browser`,pairLabel:`Pairing code from terminal`,pairPlaceholder:`16 hexadecimal characters`,pairBody:`Find the 16-character pairing code in the terminal that started copilot setup --web. Enter it here to view or control this setup. The code is never placed in the browser URL or stored after this page closes.`,pairHelp:`Keep the code private. After refreshing this page, enter it again to reconnect.`,pairButton:`Connect to local setup`,privateSession:`PRIVATE LOCAL SESSION`,theme:`Color theme`,themeAuto:`Auto`,themeSystem:`Follow system theme`,themeLight:`Light theme`,themeDark:`Dark theme`,selectOne:`Select one`,ciRun:`Open CI run on GitHub ↗`,manualCheck:`Check not listed? Enter exact name|App ID|workflow, separated by semicolons.`,checksObserved:`Recent CI jobs were found. Open each run and verify the exact job, App and enforcing coverage step before trusting it.`,checksNoRecent:`No recent pull-request workflow runs were found. Run your normal CI on a real pull request, or enter an exact producer manually.`,checksNoVerifiable:`Recent runs exist, but no job could be linked to an exact Check Run and App identity. Inspect GitHub or enter an exact producer manually.`,checksDenied:`GitHub did not allow CI discovery. Give the setup PAT Actions: read and Checks: read, or enter an exact producer manually.`,checksUnavailable:`GitHub CI discovery could not complete. This is not evidence that the repository has no checks. Retry setup or enter an exact producer manually.`,projectsObserved:`These existing Projects belong to the repository owner. Select only Projects this automation should update.`,projectsEmpty:`This bounded GitHub query returned no open, accessible organization Projects; it does not prove none exist. Check access or enter a verified Project number.`,projectsDenied:`GitHub did not allow Project discovery. Check organization Projects: read on the setup PAT, or enter Project numbers manually.`,projectsUnavailable:`GitHub Project discovery could not complete. This is not evidence that no Projects exist. Enter a verified Project number manually or retry.`,projectsUnsupported:`GitHub does not support listing personal Projects with a fine-grained PAT through this endpoint. Enter an existing Project number from its URL.`,discoveryTruncated:`Only a bounded sample of accessible Projects or recent checks was inspected. Use manual entry for an item not shown.`,checksDiscoveryScope:`Search scope: up to 20 recent pull-request workflow runs; at most 15 runs and 100 checks per commit are inspected.`,projectsDiscoveryScope:`Search scope: at most 30 open, accessible organization Projects from two pages; up to 100 fields are inspected per Project. Closed Projects are excluded.`,retryDiscovery:`Retry GitHub discovery`,retryRemaining:`{count} read-only retries left. Your answers stay here.`,retryExhausted:`No discovery retries remain. Inspect GitHub and use the manual option if the item is missing.`,observationTimeUnknown:`observation time unavailable`,branchRequirementUnknown:`Required by branch rule: not checked`,branchRequirementObserved:`Required on {branch} by an active ruleset for this exact check and App.`,ciRule:`Open required-check ruleset ↗`,projectSharedStatus:`All selected Projects must share each chosen Status value. This setup cannot assign a different Status vocabulary per Project.`,fixedWorkflowEnabled:`Your configuration fixes features.{kind}=true. Keep {kind} selected; edit --config or flags to change this.`,fixedWorkflowDisabled:`Your configuration fixes features.{kind}=false. Leave {kind} unselected; edit --config or flags to change this.`,fixedIssuesRequired:`Your configuration explicitly enables release or hotfix automation. Keep Issues enabled, or change --config or flags first.`,projectSelectionNotObserved:`Previously selected Project numbers are retained, but no longer appear in this GitHub result. Verify each in GitHub or remove it.`,removeSelection:`Remove selection`,projectTransitionIssueCreated:`New issue`,projectTransitionPullRequestCreated:`New pull request`,projectTransitionIssueInProgress:`Issue in progress`,projectTransitionPullRequestInProgress:`Pull request in progress`,projectUrl:`Open Project on GitHub ↗`,projectManual:`Project not listed? Enter its positive number or exact GitHub URL. PVT_ IDs are not accepted.`,projectStatusUnavailable:`Status options could not be verified for every selected Project. Enter the exact existing Status value after checking each Project in GitHub.`,projectStatusIncompatible:`Selected Projects have no shared Status values. Choose compatible Projects before continuing.`,producerName:`Check/job name`,producerAppId:`Source GitHub App ID`,producerWorkflow:`Workflow name`,producerAdd:`Add exact check`,producerRemove:`Remove check`,producerManualHelp:`Use the exact identity shown on GitHub. Adding it does not attest that it enforces coverage.`,producerManualInvalid:`Enter a check name, positive numeric App ID and workflow name. Do not use | or ; in names.`,translationPreviewTitle:`Translation review in progress`,translationPreviewBody:`First-party setup questions are translated. Some dynamic GitHub or provider diagnostics may still appear in English while localization review finishes. Changing language does not change your answers.`,unknownLocalError:`An unexpected local setup error occurred. Check the terminal for details, then refresh or restart setup.`},Bi={language:`Idioma`,english:`English`,spanish:`Español`,setup:`CONFIGURACIÓN`,connecting:`Conectando…`,localSession:`SESIÓN LOCAL`,progress:`Progreso de la configuración`,studio:`ASISTENTE DE CONFIGURACIÓN`,journey:`TU RECORRIDO`,repository:`Repositorio`,choices:`Opciones de configuración`,setupPat:`PAT de configuración`,plan:`Plan`,botPat:`PAT del bot y credenciales`,apply:`Aplicar`,localDesign:`Local por diseño`,localDesignBody:`Esta página se ejecuta en tu ordenador. GitHub crea ambos PAT en sus propias pestañas.`,preparing:`Preparando la configuración…`,completeTitle:`Configuración completada.`,previewTitle:`Vista previa completada.`,cancelledTitle:`Configuración cancelada.`,blockedTitle:`La configuración necesita atención.`,gettingReady:`PREPARANDO`,activeLede:`Una decisión cada vez. Tus elecciones determinan los permisos, el plan y las credenciales que necesita este repositorio.`,resultLede:`Revisa abajo el resultado y el siguiente paso. La terminal contiene más detalles técnicos.`,readOnly:`Pestaña de solo lectura`,readOnlyBody:`Otra pestaña controla esta sesión. Puedes ver el progreso aquí o tomar el control explícitamente.`,takeOver:`Tomar el control en esta pestaña`,attention:`Necesita atención`,checked:`Comprobado`,pleaseNote:`Ten en cuenta`,progressUpdate:`Actualización de progreso`,reviewPass:`Revisando las opciones guardadas — pasada {pass}. Es la misma ejecución, no un reinicio.`,cancelSetup:`Cancelar configuración`,cancelConfirm:`¿Cancelar esta sesión local? Los PAT ya creados en GitHub seguirán existiendo.`,footerLocal:`SOLO LOCALHOST`,footerCloud:`SIN CUENTA DE CONFIGURACIÓN EN LA NUBE`,footerGithub:`GITHUB EMITE LOS PAT`,currentDecision:`DECISIÓN ACTUAL`,session:`SESIÓN`,continue:`Continuar`,previousQuestion:`Pregunta anterior`,questionProgress:`Pregunta {current} de {total} en este grupo · {overall} de {all} en total`,yes:`Sí`,no:`No`,permissionPreview:`VISTA PREVIA DE PERMISOS`,changeAnswersTitle:`Cambiar respuestas`,changeAnswersHelp:`Vuelve a una sección sin perder las demás respuestas. Se comprobarán de nuevo el plan y los permisos necesarios del PAT.`,changeSection:`Cambiar {section}`,editCapabilities:`Funciones`,editRuntimes:`Agentes`,editModels:`Modelos de agentes`,editRoleModels:`Modelos por tarea`,editRepository:`Comportamiento del repositorio`,editDeployment:`Releases y hotfixes`,editBugbot:`Bugbot`,editApproval:`Aprobación de PR`,editProjects:`Projects`,editProvisioning:`Aprovisionamiento`,editStorage:`Secrets y Variables`,suggested:`Respuesta sugerida: {answer}. Podrás revisar las opciones antes de crear el PAT de configuración.`,none:`ninguna`,sourceGithub:`Consultado en los metadatos autenticados de este repositorio en GitHub.`,sourceConfig:`Valor de tu configuración; GitHub no lo ha sustituido.`,sourceDefault:`Valor predeterminado del producto; no verificado en este repositorio.`,sourceLocal:`Observado en este checkout local; confirma que la rama existe en GitHub antes de aplicar cambios.`,whyMatters:`Por qué importa`,whenApplies:`Cuándo se aplica`,whereConfigured:`Dónde se configura`,howToChoose:`Cómo elegir`,whyRecommendation:`Por qué importa`,example:`Ejemplo`,effect:`Qué cambia`,verify:`Cómo comprobarlo`,learnMore:`Leer documentación de esta opción`,resultApplied:`Tu configuración se ha aplicado`,resultNoChanges:`No se hicieron cambios`,resultStopped:`No se empezó a aplicar la configuración`,resultPartial:`Revisa los cambios parciales antes de reintentar`,resultCompleteBody:`El PAT temporal de configuración no se revoca automáticamente. Elimínalo en GitHub tras comprobar el resultado. Conserva el PAT del bot hasta que rotes el Secret instalado.`,resultPartialBody:`Puede haberse escrito el Secret del bot u otro recurso. Revisa GitHub y ejecuta copilot doctor --read-only antes de reemplazar o eliminar ese PAT.`,resultNoChangesBody:`Copilot no empezó a aplicar cambios de configuración. Los PAT creados en GitHub siguen existiendo hasta que los elimines allí.`,whatHappened:`Qué ha pasado`,alreadyChanged:`Qué ha cambiado`,noChanges:`No se iniciaron cambios de configuración en el repositorio ni en GitHub durante esta sesión.`,nextAction:`Siguiente paso`,reasonPermissions:`Faltan permisos del PAT de configuración o no pudieron confirmarse.`,nextPermissions:`Comprueba los permisos mostrados, corrige el PAT en GitHub e inicia una nueva sesión.`,reasonStorage:`No se pudo usar de forma segura el almacenamiento elegido de GitHub Actions.`,nextStorage:`Revisa el ámbito de Variables/Secrets y los recursos existentes; después, reinténtalo.`,reasonConfiguration:`No se pudo validar la configuración elegida.`,nextConfiguration:`Revisa el detalle de validación en la terminal, corrige las opciones y reinténtalo.`,reasonExpired:`La sesión local caducó antes de aplicar cambios.`,nextExpired:`Inicia una sesión nueva; la aprobación anterior no puede reutilizarse.`,reasonCancelled:`La sesión se canceló antes de aplicar cambios.`,nextCancelled:`Inicia una nueva ejecución si todavía quieres configurar este repositorio.`,reasonUnknown:`La configuración se detuvo antes de aplicar cambios. La web no pudo determinar la causa exacta.`,nextUnknown:`Consulta el último error de la terminal antes de reintentar; no supongas que se revocó ningún PAT.`,reasonProvider:`GitHub u otro proveedor no completó la operación solicitada.`,nextProvider:`Usa la referencia de diagnóstico para consultar la terminal, comprueba la disponibilidad y el acceso al proveedor y revisa posibles cambios parciales antes de reintentar.`,reasonRateLimit:`GitHub ha limitado temporalmente las solicitudes necesarias para la configuración.`,nextRateLimit:`Espera a que se restablezca el límite. Inspecciona los cambios completados antes de iniciar otra configuración.`,diagnosticReference:`Referencia de diagnóstico`,resourceReceipt:`Registro de operaciones de configuración`,effectCompleted:`Marcada como completada`,effectSkipped:`Marcada como omitida`,effectInspect:`Resultado pendiente de inspección`,effectNotStarted:`No iniciada`,scopeLocal:`Checkout local`,scopeMixed:`Repositorio y organización`,receiptFiles:`Archivos de configuración`,receiptSecrets:`Secrets de GitHub Actions`,receiptLabels:`Etiquetas de issues`,receiptIssueTypes:`Tipos de issue`,receiptVariables:`Variables de GitHub Actions`,receiptInitialTag:`Etiqueta de versión inicial`,inspectPartial:`Algunos cambios podrían estar activos. Revisa GitHub y el resultado de la terminal antes de reintentar.`,patSettings:`Abrir ajustes de PAT en GitHub ↗`,closeSession:`Cerrar sesión local`,doctorHelp:`Tras completar el setup, puedes comprobar aquí los recursos instalados sin lanzar Actions. También puedes ejecutar copilot doctor --read-only desde la raíz del repositorio con el PAT temporal y el mismo archivo --config no secreto, si lo usaste.`,doctorRun:`Comprobar instalación (solo lectura)`,doctorRunning:`Comprobando los recursos instalados sin lanzar Actions…`,doctorPassed:`La comprobación de solo lectura no encontró fallos.`,doctorWarnings:`La comprobación de solo lectura requiere atención.`,doctorFailed:`La comprobación de solo lectura no terminó. Revisa la terminal o ejecuta el comando indicado abajo.`,doctorCounts:`{pass} correctas · {warn} avisos · {fail} fallos · {skipped} omitidas.`,doctorSecretLimit:`Este modo no puede verificar los valores de Secrets.`,botRenewal:`El PAT del bot permanece en el Secret de GitHub Actions seleccionado para futuras ejecuciones. Su fecha real de caducidad no se verifica aquí; anótala en GitHub y rota el Secret antes de que venza.`,working:`Preparando el siguiente paso`,workingBody:`El proceso local comprueba tus respuestas y prepara la siguiente decisión. Mantén esta página abierta.`,repoFocus:`Repositorio seleccionado`,repoFocusBody:`Todas las decisiones de esta sesión afectan solo a:`,access:`acceso`,readOnlyCheck:`Comprobación de acceso de solo lectura`,provisionalGrants:`Permisos provisionales de mínimo privilegio`,conditionalGrants:`Los permisos condicionales dependen de tus elecciones y de GitHub. Se hará una auditoría final antes de cualquier cambio.`,permissionUnknown:`Este permiso requiere revisión. Comprueba en la terminal el permiso exacto y su explicación técnica antes de continuar.`,permissionsFollow:`Los permisos dependen de tus elecciones`,permissionsFollowBody:`Mostraremos los permisos exactos antes de crear cada PAT. Abrir esta página no crea nada.`,files:`Archivos`,workflows:`Workflows`,variables:`Variables`,secretNames:`Nombres de Secrets`,planBody:`Revisa exactamente lo que podría cambiar. Después se pedirán el PAT del bot y las demás credenciales.`,planChoices:`Tus decisiones principales`,planEnabledCapabilities:`Funciones activadas`,planBranchRoles:`Rama de producción / desarrollo`,planApprovalMode:`Aprobación de pull requests`,planVariableScope:`Ámbito de Variables`,planSecretScope:`Ámbito de Secrets`,planInitialTag:`Crear etiqueta inicial`,planAgentRouting:`Agente y modelo por tarea`,planIssueWorkflows:`Flujos de issues`,planTrustedChecks:`Productores de CI fiables`,planCoverage:`Prueba de cobertura`,planProjectStatuses:`Transiciones de Status en Projects`,planIssueResources:`Etiquetas y tipos de issue`,planIssueResourcesValue:`Se comprobarán o crearán al aplicar`,planProducerAttested:`Identidad de CI y paso obligatorio comprobados por ti`,planCoverageThreshold:`Cobertura mínima de líneas modificadas`,planCoverageReporter:`Workflow que publica el artefacto`,planReporterAttested:`Generador de cobertura comprobado por ti`,planAdvancedDefaults:`El plan también incluye valores predeterminados de ajustes que no cambiaste. Usa Cambiar más abajo para revisar cualquier sección antes de aprobar.`,planUnknownWarning:`Hay un aviso adicional del plan que no se puede mostrar aquí. Léelo en la terminal antes de aprobar.`,planBasicDefaultsIntro:`El modo básico conservó estos ajustes avanzados. Abre una sección más abajo para revisarlos o cambiarlos:`,scopeRepository:`Repositorio`,scopeOrganization:`Organización`,scopeDisabled:`Sin aprovisionar`,approvalOff:`Desactivada`,approvalRecommend:`Solo recomendaciones`,approvalGuarded:`Aprobación protegida`,beforeContinue:`Antes de continuar`,stopHere:`Detener aquí`,approvePlan:`Aprobar este plan`,githubLink:`Abrir enlace de GitHub ↗`,githubForm:`Abrir el formulario oficial de PAT de GitHub`,githubFormHelp:`Comprueba la cuenta activa, elige Only select repositories y selecciona este repositorio. GitHub gestiona el 2FA y crea el PAT.`,pasteHere:`Pega aquí el valor`,yourAnswer:`Tu respuesta`,hiddenAfter:`Oculto tras enviarlo`,typeAnswer:`Escribe tu respuesta`,secretHelp:`Se envía solo a este proceso local. No volverá a mostrarse ni se guardará en el almacenamiento del navegador.`,pairTitle:`Vincula este navegador`,pairLabel:`Código de vinculación de la terminal`,pairPlaceholder:`16 caracteres hexadecimales`,pairBody:`Busca el código de 16 caracteres en la terminal donde ejecutaste copilot setup --web. Introdúcelo para ver o controlar la sesión. No aparece en la URL ni se almacena al cerrar la página.`,pairHelp:`Mantén el código en privado. Tras actualizar la página, introdúcelo de nuevo para reconectar.`,pairButton:`Conectar a la configuración local`,privateSession:`SESIÓN LOCAL PRIVADA`,theme:`Tema de color`,themeAuto:`Sistema`,themeSystem:`Seguir tema del sistema`,themeLight:`Tema claro`,themeDark:`Tema oscuro`,selectOne:`Selecciona una opción`,ciRun:`Abrir ejecución de CI en GitHub ↗`,manualCheck:`¿No aparece el check? Introduce nombre|ID de App|workflow exactos, separados por punto y coma.`,checksObserved:`Se encontraron jobs recientes de CI. Abre cada ejecución y comprueba el job, la App y el paso obligatorio de cobertura antes de confiar en él.`,checksNoRecent:`No hay ejecuciones recientes de workflows de pull request. Ejecuta tu CI habitual en un PR real o introduce manualmente un productor exacto.`,checksNoVerifiable:`Hay ejecuciones recientes, pero ningún job pudo vincularse a un Check Run y una App concretos. Revisa GitHub o introduce el productor manualmente.`,checksDenied:`GitHub no permitió consultar los checks. Concede Actions: read y Checks: read al PAT de configuración, o introduce el productor manualmente.`,checksUnavailable:`No se pudo completar la consulta de CI. Esto no significa que el repositorio no tenga checks. Reintenta o introduce el productor manualmente.`,projectsObserved:`Estos Projects existentes pertenecen al dueño del repositorio. Selecciona solo los que deba actualizar la automatización.`,projectsEmpty:`Esta consulta limitada no devolvió Projects abiertos y accesibles; eso no demuestra que no existan. Comprueba el acceso o introduce un número verificado.`,projectsDenied:`GitHub no permitió consultar Projects. Comprueba Projects: read de la organización en el PAT o introduce los números manualmente.`,projectsUnavailable:`No se pudo consultar Projects. Eso no demuestra que no existan. Introduce un número verificado o reintenta.`,projectsUnsupported:`GitHub no permite listar Projects personales con un PAT de permisos precisos mediante esta API. Introduce el número de la URL de un Project existente.`,discoveryTruncated:`Solo se inspeccionó una muestra limitada de Projects accesibles o checks recientes. Usa la entrada manual si falta uno.`,checksDiscoveryScope:`Alcance: hasta 20 ejecuciones recientes de workflows de PR; se inspeccionan como máximo 15 ejecuciones y 100 checks por commit.`,projectsDiscoveryScope:`Alcance: hasta 30 Projects abiertos y accesibles de la organización en dos páginas; se inspeccionan hasta 100 campos por Project. Se excluyen los Projects cerrados.`,retryDiscovery:`Reintentar búsqueda en GitHub`,retryRemaining:`Quedan {count} reintentos de solo lectura. Tus respuestas se conservan.`,retryExhausted:`No quedan reintentos. Consulta GitHub e introduce manualmente lo que falte.`,observationTimeUnknown:`fecha de observación no disponible`,branchRequirementUnknown:`Obligatorio según la regla de rama: no comprobado`,branchRequirementObserved:`Obligatorio en {branch} por un ruleset activo para este check y esta App exactos.`,ciRule:`Abrir ruleset del check obligatorio ↗`,projectSharedStatus:`Todos los Projects elegidos deben compartir cada valor Status. Este setup no asigna valores diferentes por Project.`,fixedWorkflowEnabled:`Tu configuración fija features.{kind}=true. Mantén {kind} seleccionado; cambia --config o los flags si quieres modificarlo.`,fixedWorkflowDisabled:`Tu configuración fija features.{kind}=false. Deja {kind} sin seleccionar; cambia --config o los flags si quieres modificarlo.`,fixedIssuesRequired:`Tu configuración activa expresamente release o hotfix. Mantén Issues activado o cambia antes --config o los flags.`,projectSelectionNotObserved:`Se conservan los números de Project elegidos antes, aunque ya no figuren en esta consulta. Compruébalos en GitHub o quítalos.`,removeSelection:`Quitar selección`,projectTransitionIssueCreated:`Issue nuevo`,projectTransitionPullRequestCreated:`Pull request nuevo`,projectTransitionIssueInProgress:`Issue en curso`,projectTransitionPullRequestInProgress:`Pull request en curso`,projectUrl:`Abrir Project en GitHub ↗`,projectManual:`¿Falta un Project? Introduce su número positivo o URL exacta de GitHub. No se aceptan IDs PVT_.`,projectStatusUnavailable:`No se pudieron verificar las opciones Status de todos los Projects elegidos. Comprueba cada Project en GitHub e introduce el valor exacto.`,projectStatusIncompatible:`Los Projects elegidos no comparten valores Status. Elige Projects compatibles antes de continuar.`,producerName:`Nombre del check/job`,producerAppId:`ID de la App de GitHub`,producerWorkflow:`Nombre del workflow`,producerAdd:`Añadir check exacto`,producerRemove:`Quitar check`,producerManualHelp:`Usa la identidad exacta de GitHub. Añadirla no acredita que exija la cobertura.`,producerManualInvalid:`Indica nombre, ID numérico positivo de App y workflow. No uses | ni ; en los nombres.`,translationPreviewTitle:`Revisión de la traducción en curso`,translationPreviewBody:`Las preguntas propias de la configuración ya están traducidas. Algunos diagnósticos dinámicos de GitHub o del proveedor aún pueden aparecer en inglés mientras termina la revisión. Cambiar de idioma no modifica tus respuestas.`,unknownLocalError:`Se ha producido un error inesperado en la configuración local. Consulta la terminal y actualiza la página o reinicia la configuración.`},Vi={language:`Langue`,english:`Anglais`,spanish:`Espagnol`,setup:`CONFIGURATION`,connecting:`Connexion…`,localSession:`SESSION LOCALE`,progress:`Progression`,studio:`ASSISTANT DE CONFIGURATION`,journey:`VOTRE PARCOURS`,repository:`Dépôt`,choices:`Choix`,setupPat:`PAT de configuration`,plan:`Plan`,botPat:`PAT du bot et identifiants`,apply:`Appliquer`,localDesign:`Local par conception`,localDesignBody:`Cette page fonctionne sur votre ordinateur. GitHub crée les deux PAT dans ses propres onglets.`,preparing:`Préparation…`,completeTitle:`Configuration terminée.`,previewTitle:`Aperçu terminé.`,cancelledTitle:`Configuration annulée.`,blockedTitle:`Une intervention est nécessaire.`,gettingReady:`PRÉPARATION`,activeLede:`Une décision à la fois. Vos choix déterminent les droits, le plan et les identifiants nécessaires.`,resultLede:`Consultez le résultat et la prochaine étape ci-dessous. Le terminal fournit les détails techniques.`,readOnly:`Onglet en lecture seule`,readOnlyBody:`Un autre onglet contrôle cette session. Vous pouvez suivre la progression ou prendre le contrôle.`,takeOver:`Prendre le contrôle`,attention:`Action nécessaire`,checked:`Vérifié`,pleaseNote:`À noter`,progressUpdate:`Mise à jour`,reviewPass:`Révision des choix enregistrés — passage {pass}. C’est la même session, pas un redémarrage.`,cancelSetup:`Annuler`,cancelConfirm:`Annuler cette session locale ? Les PAT créés sur GitHub continueront d’exister.`,footerLocal:`LOCALHOST UNIQUEMENT`,footerCloud:`AUCUN COMPTE CLOUD`,footerGithub:`GITHUB ÉMET LES PAT`,currentDecision:`DÉCISION ACTUELLE`,session:`SESSION`,continue:`Continuer`,previousQuestion:`Question précédente`,questionProgress:`Question {current} sur {total} dans ce groupe · {overall} sur {all} au total`,yes:`Oui`,no:`Non`,permissionPreview:`APERÇU DES DROITS`,changeAnswersTitle:`Modifier vos réponses`,changeAnswersHelp:`Revenez à une section sans perdre les autres réponses. Le plan et les droits PAT requis seront vérifiés à nouveau.`,changeSection:`Modifier {section}`,editCapabilities:`Fonctionnalités`,editRuntimes:`Agents`,editModels:`Modèles des agents`,editRoleModels:`Modèles par tâche`,editRepository:`Comportement du dépôt`,editDeployment:`Releases et correctifs`,editBugbot:`Bugbot`,editApproval:`Approbation des PR`,editProjects:`Projects`,editProvisioning:`Provisionnement`,editStorage:`Secrets et Variables`,suggested:`Réponse suggérée : {answer}. Vous pourrez revoir vos choix avant de créer le PAT.`,none:`aucune`,sourceGithub:`Observé dans les métadonnées authentifiées de ce dépôt GitHub.`,sourceConfig:`Fourni par votre configuration ; GitHub ne l’a pas remplacé.`,sourceDefault:`Valeur par défaut du produit ; non vérifiée dans ce dépôt.`,sourceLocal:`Observé dans ce dossier local ; vérifiez que cette branche existe sur GitHub avant d’appliquer les changements.`,whyMatters:`Pourquoi c’est important`,whenApplies:`Quand cela s’applique`,whereConfigured:`Où se fait le réglage`,howToChoose:`Comment choisir`,whyRecommendation:`Pourquoi c’est important`,example:`Exemple`,effect:`Ce qui change`,verify:`Comment vérifier`,learnMore:`Lire la documentation de ce réglage`,resultApplied:`Configuration appliquée`,resultNoChanges:`Aucun changement effectué`,resultStopped:`Aucun changement commencé`,resultPartial:`Vérifiez les changements partiels`,resultCompleteBody:`Le PAT temporaire n’est pas révoqué automatiquement. Supprimez-le sur GitHub après vérification. Gardez le PAT du bot jusqu’à rotation du Secret.`,resultPartialBody:`Un Secret ou une autre ressource a peut-être été modifié. Vérifiez GitHub et exécutez copilot doctor --read-only avant de remplacer ou supprimer un PAT.`,resultNoChangesBody:`Copilot n’a pas commencé à appliquer la configuration. Les PAT créés sur GitHub restent actifs jusqu’à leur suppression là-bas.`,whatHappened:`Ce qui s’est passé`,alreadyChanged:`Déjà modifié`,noChanges:`Aucun changement du dépôt ou de GitHub n’a commencé pendant cette session.`,nextAction:`Prochaine étape`,reasonPermissions:`Les droits nécessaires du PAT de configuration manquent ou n’ont pas pu être confirmés.`,nextPermissions:`Vérifiez les droits affichés, corrigez le PAT sur GitHub, puis recommencez.`,reasonStorage:`Le stockage GitHub Actions choisi ne peut pas être utilisé en sécurité.`,nextStorage:`Vérifiez la portée des Variables/Secrets et les ressources existantes, puis réessayez.`,reasonConfiguration:`La configuration choisie n’a pas pu être validée.`,nextConfiguration:`Lisez les détails du terminal, corrigez les choix et réessayez.`,reasonExpired:`La session locale a expiré avant toute modification.`,nextExpired:`Démarrez une nouvelle session ; l’ancienne approbation ne peut pas être réutilisée.`,reasonCancelled:`La configuration a été annulée avant toute modification.`,nextCancelled:`Démarrez une nouvelle session si vous souhaitez encore configurer ce dépôt.`,reasonUnknown:`La configuration s’est arrêtée avant l’application ; la cause exacte est inconnue.`,nextUnknown:`Consultez la dernière erreur du terminal avant de réessayer. Ne supposez pas qu’un PAT a été révoqué.`,reasonProvider:`GitHub ou un autre fournisseur n’a pas terminé l’opération demandée.`,nextProvider:`Utilisez la référence de diagnostic pour consulter le terminal, vérifiez la disponibilité et les accès, puis examinez tout changement partiel avant de réessayer.`,reasonRateLimit:`GitHub a temporairement limité les requêtes nécessaires à la configuration.`,nextRateLimit:`Attendez la réinitialisation de la limite. Examinez les changements effectués avant de recommencer.`,diagnosticReference:`Référence de diagnostic`,resourceReceipt:`Reçu des opérations de configuration`,effectCompleted:`Signalée comme terminée`,effectSkipped:`Signalée comme ignorée`,effectInspect:`Résultat à vérifier`,effectNotStarted:`Non commencée`,scopeLocal:`Dossier local`,scopeMixed:`Dépôt et organisation`,receiptFiles:`Fichiers de configuration`,receiptSecrets:`Secrets GitHub Actions`,receiptLabels:`Étiquettes des tickets`,receiptIssueTypes:`Types de ticket`,receiptVariables:`Variables GitHub Actions`,receiptInitialTag:`Première étiquette de version`,inspectPartial:`Des changements peuvent déjà être actifs. Vérifiez GitHub et le terminal avant de réessayer.`,patSettings:`Ouvrir les paramètres PAT GitHub ↗`,closeSession:`Fermer la session locale`,doctorHelp:`Après la configuration, vérifiez ici les ressources installées sans déclencher d’Actions. Vous pouvez aussi exécuter copilot doctor --read-only à la racine du dépôt avec le PAT temporaire et le même fichier --config non secret, le cas échéant.`,doctorRun:`Vérifier l’installation (lecture seule)`,doctorRunning:`Vérification des ressources installées sans déclencher d’Actions…`,doctorPassed:`La vérification en lecture seule n’a trouvé aucun échec.`,doctorWarnings:`La vérification en lecture seule demande une attention particulière.`,doctorFailed:`La vérification en lecture seule ne s’est pas terminée. Consultez le terminal ou exécutez la commande ci-dessous.`,doctorCounts:`{pass} réussis · {warn} avertissements · {fail} échecs · {skipped} ignorés.`,doctorSecretLimit:`Ce mode ne peut pas vérifier les valeurs des Secrets.`,botRenewal:`Le PAT du bot reste dans le secret GitHub Actions choisi pour les futures exécutions. Sa date d’expiration réelle n’est pas vérifiée ici ; notez-la sur GitHub et remplacez le secret avant cette date.`,working:`Préparation de l’étape suivante`,workingBody:`Le processus local vérifie vos réponses. Gardez cette page ouverte.`,repoFocus:`Dépôt concerné`,repoFocusBody:`Toutes les décisions de cette session concernent uniquement :`,access:`accès`,readOnlyCheck:`Vérification en lecture seule`,provisionalGrants:`Droits provisoires minimaux`,conditionalGrants:`Les droits conditionnels dépendent de vos choix et de GitHub. Un contrôle final précède toute modification.`,permissionUnknown:`Ce droit exige une vérification. Consultez le droit exact et son explication technique dans le terminal avant de continuer.`,permissionsFollow:`Les droits suivent vos choix`,permissionsFollowBody:`Les droits exacts sont affichés avant chaque PAT. Ouvrir cette page ne crée rien.`,files:`Fichiers`,workflows:`Workflows`,variables:`Variables`,secretNames:`Noms des Secrets`,planBody:`Vérifiez précisément ce qui pourrait changer. Le PAT du bot et les autres identifiants seront demandés ensuite.`,planChoices:`Vos décisions principales`,planEnabledCapabilities:`Fonctionnalités activées`,planBranchRoles:`Branche de production / développement`,planApprovalMode:`Approbation des pull requests`,planVariableScope:`Portée des variables`,planSecretScope:`Portée des secrets`,planInitialTag:`Créer la première étiquette`,planAgentRouting:`Agent et modèle par tâche`,planIssueWorkflows:`Workflows de tickets`,planTrustedChecks:`Producteurs CI de confiance`,planCoverage:`Preuve de couverture`,planProjectStatuses:`Transitions Status des Projects`,planIssueResources:`Étiquettes et types de ticket`,planIssueResourcesValue:`Vérifiés ou créés lors de l’application`,planProducerAttested:`Identité CI et étape obligatoire vérifiées par vous`,planCoverageThreshold:`Couverture minimale des lignes modifiées`,planCoverageReporter:`Workflow publiant l’artefact`,planReporterAttested:`Producteur du rapport de couverture vérifié par vous`,planAdvancedDefaults:`Le plan comprend aussi les valeurs par défaut des réglages non modifiés. Utilisez Modifier ci-dessous pour examiner une section avant de valider.`,planUnknownWarning:`Un avertissement supplémentaire du plan ne peut pas être affiché ici. Lisez-le dans le terminal avant de valider.`,planBasicDefaultsIntro:`Le parcours de base a conservé ces réglages avancés. Ouvrez une section ci-dessous pour les vérifier ou les modifier :`,scopeRepository:`Dépôt`,scopeOrganization:`Organisation`,scopeDisabled:`Non provisionné`,approvalOff:`Désactivée`,approvalRecommend:`Recommandation uniquement`,approvalGuarded:`Approbation protégée`,beforeContinue:`Avant de continuer`,stopHere:`Arrêter ici`,approvePlan:`Approuver ce plan`,githubLink:`Ouvrir le lien GitHub ↗`,githubForm:`Ouvrir le formulaire PAT officiel de GitHub`,githubFormHelp:`Vérifiez le compte connecté, choisissez Only select repositories et ce dépôt. GitHub gère la 2FA et crée le PAT.`,pasteHere:`Collez la valeur ici`,yourAnswer:`Votre réponse`,hiddenAfter:`Masquée après envoi`,typeAnswer:`Saisissez votre réponse`,secretHelp:`Envoyé uniquement au processus local. Ni réaffiché ni enregistré dans le navigateur.`,pairTitle:`Associer ce navigateur`,pairLabel:`Code du terminal`,pairPlaceholder:`16 caractères hexadécimaux`,pairBody:`Trouvez le code à 16 caractères dans le terminal qui a lancé copilot setup --web. Il ne figure pas dans l’URL et n’est pas conservé après fermeture.`,pairHelp:`Gardez ce code secret. Après actualisation, saisissez-le de nouveau.`,pairButton:`Se connecter à la configuration locale`,privateSession:`SESSION LOCALE PRIVÉE`,theme:`Thème`,themeAuto:`Auto`,themeSystem:`Suivre le système`,themeLight:`Thème clair`,themeDark:`Thème sombre`,selectOne:`Choisissez une option`,ciRun:`Voir l’exécution CI sur GitHub ↗`,manualCheck:`Check absent ? Saisissez nom|ID App|workflow exacts, séparés par des points-virgules.`,checksObserved:`Des jobs CI récents ont été trouvés. Ouvrez chaque exécution et vérifiez le job, l’App et l’étape obligatoire de couverture avant de lui faire confiance.`,checksNoRecent:`Aucune exécution récente de workflow de pull request. Lancez le CI habituel sur une vraie PR ou saisissez un producteur exact.`,checksNoVerifiable:`Des exécutions récentes existent, mais aucun job ne correspond à une identité exacte de Check Run et d’App. Vérifiez GitHub ou saisissez-la.`,checksDenied:`GitHub a refusé la découverte CI. Accordez Actions: read et Checks: read au PAT de configuration, ou saisissez un producteur exact.`,checksUnavailable:`La découverte CI a échoué. Cela ne prouve pas l’absence de checks. Réessayez ou saisissez un producteur exact.`,projectsObserved:`Ces Projects existants appartiennent au propriétaire du dépôt. Ne choisissez que ceux que l’automatisation doit modifier.`,projectsEmpty:`Cette requête GitHub limitée n’a renvoyé aucun Project ouvert et accessible ; elle ne prouve pas leur absence. Vérifiez l’accès ou saisissez un numéro vérifié.`,projectsDenied:`GitHub a refusé la découverte des Projects. Vérifiez Projects: read au niveau organisation sur le PAT, ou saisissez les numéros.`,projectsUnavailable:`La découverte des Projects a échoué. Cela ne prouve pas leur absence. Saisissez un numéro vérifié ou réessayez.`,projectsUnsupported:`Cette API GitHub ne liste pas les Projects personnels avec un PAT à permissions fines. Saisissez le numéro figurant dans l’URL.`,discoveryTruncated:`Seul un échantillon limité de Projects accessibles ou checks récents a été inspecté. Saisissez manuellement un élément absent.`,checksDiscoveryScope:`Périmètre : jusqu’à 20 exécutions récentes de workflows de PR ; au plus 15 exécutions et 100 checks par commit sont inspectés.`,projectsDiscoveryScope:`Périmètre : au plus 30 Projects ouverts et accessibles de l’organisation sur deux pages ; jusqu’à 100 champs sont inspectés par Project. Les Projects fermés sont exclus.`,retryDiscovery:`Relancer la recherche GitHub`,retryRemaining:`Il reste {count} essais en lecture seule. Vos réponses sont conservées.`,retryExhausted:`Plus aucun essai disponible. Vérifiez GitHub et saisissez manuellement tout élément manquant.`,observationTimeUnknown:`date d’observation indisponible`,branchRequirementUnknown:`Exigé par la règle de branche : non vérifié`,branchRequirementObserved:`Exigé sur {branch} par un ruleset actif pour ce contrôle et cette application précis.`,ciRule:`Ouvrir le ruleset du contrôle obligatoire ↗`,projectSharedStatus:`Tous les Projects choisis doivent partager chaque valeur Status. Cette configuration ne définit pas de valeurs différentes par Project.`,fixedWorkflowEnabled:`Votre configuration fixe features.{kind}=true. Gardez {kind} sélectionné ; modifiez --config ou les options pour changer ce choix.`,fixedWorkflowDisabled:`Votre configuration fixe features.{kind}=false. Ne sélectionnez pas {kind} ; modifiez --config ou les options pour changer ce choix.`,fixedIssuesRequired:`Votre configuration active explicitement release ou hotfix. Gardez Issues activé ou modifiez d’abord --config ou les options.`,projectSelectionNotObserved:`Les numéros de Projects choisis auparavant sont conservés, mais n’apparaissent plus dans ce résultat GitHub. Vérifiez-les sur GitHub ou retirez-les.`,removeSelection:`Retirer la sélection`,projectTransitionIssueCreated:`Nouveau ticket`,projectTransitionPullRequestCreated:`Nouvelle pull request`,projectTransitionIssueInProgress:`Ticket en cours`,projectTransitionPullRequestInProgress:`Pull request en cours`,projectUrl:`Ouvrir le Project sur GitHub ↗`,projectManual:`Project absent ? Saisissez son numéro positif ou son URL GitHub exacte. Les ID PVT_ sont refusés.`,projectStatusUnavailable:`Les options Status n’ont pas pu être vérifiées pour tous les Projects. Vérifiez-les sur GitHub et saisissez la valeur exacte.`,projectStatusIncompatible:`Les Projects choisis ne partagent aucun Status. Choisissez des Projects compatibles avant de continuer.`,producerName:`Nom du check/job`,producerAppId:`ID de l’App GitHub source`,producerWorkflow:`Nom du workflow`,producerAdd:`Ajouter le check exact`,producerRemove:`Retirer le check`,producerManualHelp:`Utilisez l’identité exacte affichée sur GitHub. L’ajouter ne prouve pas que la couverture est imposée.`,producerManualInvalid:`Indiquez le nom, un ID numérique positif d’App et le workflow. N’utilisez ni | ni ; dans les noms.`,translationPreviewTitle:`Révision de la traduction en cours`,translationPreviewBody:`Les questions propres à la configuration sont traduites. Certains diagnostics dynamiques de GitHub ou du fournisseur peuvent encore apparaître en anglais pendant la révision. Changer de langue ne modifie pas vos réponses.`,unknownLocalError:`Une erreur inattendue est survenue pendant la configuration locale. Consultez le terminal, puis actualisez la page ou relancez la configuration.`},Hi={language:`Idioma`,english:`Inglês`,spanish:`Espanhol`,setup:`CONFIGURAÇÃO`,connecting:`A ligar…`,localSession:`SESSÃO LOCAL`,progress:`Progresso`,studio:`ASSISTENTE DE CONFIGURAÇÃO`,journey:`O SEU PERCURSO`,repository:`Repositório`,choices:`Opções`,setupPat:`PAT de configuração`,plan:`Plano`,botPat:`PAT do bot e credenciais`,apply:`Aplicar`,localDesign:`Local por conceção`,localDesignBody:`Esta página funciona no seu computador. O GitHub cria ambos os PAT nos seus próprios separadores.`,preparing:`A preparar a configuração…`,completeTitle:`Configuração concluída.`,previewTitle:`Pré-visualização concluída.`,cancelledTitle:`Configuração cancelada.`,blockedTitle:`A configuração requer atenção.`,gettingReady:`A PREPARAR`,activeLede:`Uma decisão de cada vez. As suas escolhas determinam permissões, plano e credenciais.`,resultLede:`Consulte abaixo o resultado e o próximo passo. O terminal contém mais detalhes técnicos.`,readOnly:`Separador só de leitura`,readOnlyBody:`Outro separador controla esta sessão. Pode acompanhar o progresso ou assumir o controlo.`,takeOver:`Assumir o controlo`,attention:`Requer atenção`,checked:`Verificado`,pleaseNote:`Atenção`,progressUpdate:`Atualização de progresso`,reviewPass:`A rever as escolhas guardadas — passagem {pass}. É a mesma execução, não um reinício.`,cancelSetup:`Cancelar configuração`,cancelConfirm:`Cancelar esta sessão local? Os PAT já criados no GitHub continuarão a existir.`,footerLocal:`APENAS LOCALHOST`,footerCloud:`SEM CONTA DE CONFIGURAÇÃO NA NUVEM`,footerGithub:`O GITHUB EMITE OS PAT`,currentDecision:`DECISÃO ATUAL`,session:`SESSÃO`,continue:`Continuar`,previousQuestion:`Pergunta anterior`,questionProgress:`Pergunta {current} de {total} neste grupo · {overall} de {all} no total`,yes:`Sim`,no:`Não`,permissionPreview:`PRÉ-VISUALIZAÇÃO DAS PERMISSÕES`,changeAnswersTitle:`Alterar respostas`,changeAnswersHelp:`Volte a uma secção sem perder as outras respostas. O plano e as permissões PAT necessárias serão verificados de novo.`,changeSection:`Alterar {section}`,editCapabilities:`Funcionalidades`,editRuntimes:`Agentes`,editModels:`Modelos dos agentes`,editRoleModels:`Modelos por tarefa`,editRepository:`Comportamento do repositório`,editDeployment:`Releases e correções`,editBugbot:`Bugbot`,editApproval:`Aprovação de PR`,editProjects:`Projects`,editProvisioning:`Provisionamento`,editStorage:`Secrets e Variables`,suggested:`Resposta sugerida: {answer}. Pode rever as opções antes de criar o PAT.`,none:`nenhuma`,sourceGithub:`Observado nos metadados autenticados deste repositório no GitHub.`,sourceConfig:`Fornecido pela sua configuração; o GitHub não o substituiu.`,sourceDefault:`Valor predefinido do produto; não verificado neste repositório.`,sourceLocal:`Observado neste checkout local; confirme que o ramo existe no GitHub antes de aplicar alterações.`,whyMatters:`Porque importa`,whenApplies:`Quando se aplica`,whereConfigured:`Onde se configura`,howToChoose:`Como escolher`,whyRecommendation:`Porque importa`,example:`Exemplo`,effect:`O que muda`,verify:`Como verificar`,learnMore:`Ler a documentação desta opção`,resultApplied:`A configuração foi aplicada`,resultNoChanges:`Nenhuma alteração efetuada`,resultStopped:`Nenhuma alteração iniciada`,resultPartial:`Verifique as alterações parciais`,resultCompleteBody:`O PAT temporário não é revogado automaticamente. Elimine-o no GitHub após verificar. Guarde o PAT do bot até rodar o Secret.`,resultPartialBody:`Um Secret ou outro recurso pode ter sido alterado. Verifique o GitHub e execute copilot doctor --read-only antes de substituir ou eliminar o PAT.`,resultNoChangesBody:`O Copilot não começou a aplicar alterações. Os PAT criados no GitHub permanecem até serem eliminados lá.`,whatHappened:`O que aconteceu`,alreadyChanged:`O que mudou`,noChanges:`Não foram iniciadas alterações no repositório nem no GitHub nesta sessão.`,nextAction:`Próximo passo`,reasonPermissions:`Faltam permissões do PAT de configuração ou não foi possível confirmá-las.`,nextPermissions:`Verifique as permissões, corrija o PAT no GitHub e inicie uma nova sessão.`,reasonStorage:`O armazenamento GitHub Actions escolhido não pôde ser usado com segurança.`,nextStorage:`Verifique o âmbito das Variables/Secrets e os recursos existentes; depois tente novamente.`,reasonConfiguration:`Não foi possível validar a configuração escolhida.`,nextConfiguration:`Leia os detalhes no terminal, corrija as opções e tente novamente.`,reasonExpired:`A sessão local expirou antes de aplicar alterações.`,nextExpired:`Inicie uma nova sessão; a aprovação anterior não pode ser reutilizada.`,reasonCancelled:`A configuração foi cancelada antes de aplicar alterações.`,nextCancelled:`Inicie outra execução se ainda quiser configurar o repositório.`,reasonUnknown:`A configuração parou antes de aplicar; a causa exata é desconhecida.`,nextUnknown:`Leia o último erro no terminal antes de tentar novamente. Não presuma que um PAT foi revogado.`,reasonProvider:`O GitHub ou outro fornecedor não concluiu a operação solicitada.`,nextProvider:`Use a referência de diagnóstico para consultar o terminal, verifique a disponibilidade e o acesso e reveja possíveis alterações parciais antes de tentar novamente.`,reasonRateLimit:`O GitHub limitou temporariamente os pedidos necessários para a configuração.`,nextRateLimit:`Aguarde a reposição do limite. Inspecione as alterações concluídas antes de iniciar outra configuração.`,diagnosticReference:`Referência de diagnóstico`,resourceReceipt:`Registo das operações de configuração`,effectCompleted:`Indicada como concluída`,effectSkipped:`Indicada como ignorada`,effectInspect:`Resultado por inspecionar`,effectNotStarted:`Não iniciada`,scopeLocal:`Checkout local`,scopeMixed:`Repositório e organização`,receiptFiles:`Ficheiros de configuração`,receiptSecrets:`Secrets do GitHub Actions`,receiptLabels:`Etiquetas das questões`,receiptIssueTypes:`Tipos de questão`,receiptVariables:`Variables do GitHub Actions`,receiptInitialTag:`Etiqueta de versão inicial`,inspectPartial:`Algumas alterações podem estar ativas. Verifique o GitHub e o terminal antes de tentar novamente.`,patSettings:`Abrir definições de PAT no GitHub ↗`,closeSession:`Fechar sessão local`,doctorHelp:`Depois de concluir a configuração, pode verificar aqui os recursos instalados sem iniciar Actions. Também pode executar copilot doctor --read-only na raiz do repositório com o PAT temporário e o mesmo ficheiro --config não secreto, caso o tenha usado.`,doctorRun:`Verificar instalação (só de leitura)`,doctorRunning:`A verificar os recursos instalados sem iniciar Actions…`,doctorPassed:`A verificação só de leitura não encontrou falhas.`,doctorWarnings:`A verificação só de leitura requer atenção.`,doctorFailed:`A verificação só de leitura não terminou. Consulte o terminal ou execute o comando abaixo.`,doctorCounts:`{pass} aprovados · {warn} avisos · {fail} falhas · {skipped} ignorados.`,doctorSecretLimit:`Este modo não consegue verificar os valores dos Secrets.`,botRenewal:`O PAT do bot permanece no Secret do GitHub Actions escolhido para execuções futuras. A data de validade real não é verificada aqui; registe-a no GitHub e substitua o Secret antes de expirar.`,working:`A preparar o próximo passo`,workingBody:`O processo local está a verificar as respostas. Mantenha esta página aberta.`,repoFocus:`Repositório em foco`,repoFocusBody:`Todas as decisões desta sessão afetam apenas:`,access:`acesso`,readOnlyCheck:`Verificação de acesso só de leitura`,provisionalGrants:`Permissões provisórias mínimas`,conditionalGrants:`As permissões condicionais dependem das escolhas e do GitHub. Haverá uma auditoria final antes das alterações.`,permissionUnknown:`Esta permissão exige revisão. Consulte a permissão exata e a explicação técnica no terminal antes de continuar.`,permissionsFollow:`As permissões seguem as suas escolhas`,permissionsFollowBody:`Mostramos as permissões exatas antes de criar cada PAT. Abrir esta página não cria nada.`,files:`Ficheiros`,workflows:`Workflows`,variables:`Variables`,secretNames:`Nomes dos Secrets`,planBody:`Reveja exatamente o que pode mudar. O PAT do bot e outras credenciais serão pedidos a seguir.`,planChoices:`As suas decisões principais`,planEnabledCapabilities:`Funcionalidades ativadas`,planBranchRoles:`Ramo de produção / desenvolvimento`,planApprovalMode:`Aprovação de pull requests`,planVariableScope:`Âmbito das Variables`,planSecretScope:`Âmbito dos Secrets`,planInitialTag:`Criar etiqueta inicial`,planAgentRouting:`Agente e modelo por tarefa`,planIssueWorkflows:`Fluxos de questões`,planTrustedChecks:`Produtores CI de confiança`,planCoverage:`Evidência de cobertura`,planProjectStatuses:`Transições Status dos Projects`,planIssueResources:`Etiquetas e tipos de questão`,planIssueResourcesValue:`Verificados ou criados ao aplicar`,planProducerAttested:`Identidade CI e passo obrigatório verificados por si`,planCoverageThreshold:`Cobertura mínima das linhas alteradas`,planCoverageReporter:`Workflow que publica o artefacto`,planReporterAttested:`Produtor do relatório de cobertura verificado por si`,planAdvancedDefaults:`O plano também inclui valores predefinidos para definições que não alterou. Use Alterar abaixo para rever qualquer secção antes de aprovar.`,planUnknownWarning:`Um aviso adicional do plano não pode ser apresentado aqui. Leia-o no terminal antes de aprovar.`,planBasicDefaultsIntro:`O percurso básico manteve estas definições avançadas. Abra uma secção abaixo para as rever ou alterar:`,scopeRepository:`Repositório`,scopeOrganization:`Organização`,scopeDisabled:`Sem aprovisionamento`,approvalOff:`Desativada`,approvalRecommend:`Apenas recomendações`,approvalGuarded:`Aprovação protegida`,beforeContinue:`Antes de continuar`,stopHere:`Parar aqui`,approvePlan:`Aprovar este plano`,githubLink:`Abrir ligação do GitHub ↗`,githubForm:`Abrir formulário oficial de PAT no GitHub`,githubFormHelp:`Confirme a conta ligada, escolha Only select repositories e este repositório. O GitHub gere 2FA e cria o PAT.`,pasteHere:`Cole o valor aqui`,yourAnswer:`A sua resposta`,hiddenAfter:`Oculto após envio`,typeAnswer:`Escreva a sua resposta`,secretHelp:`Enviado apenas ao processo local. Não será mostrado de novo nem guardado no navegador.`,pairTitle:`Emparelhar este navegador`,pairLabel:`Código do terminal`,pairPlaceholder:`16 caracteres hexadecimais`,pairBody:`Encontre o código de 16 caracteres no terminal onde iniciou copilot setup --web. Não aparece no URL nem fica guardado após fechar a página.`,pairHelp:`Mantenha o código privado. Após atualizar a página, introduza-o novamente.`,pairButton:`Ligar à configuração local`,privateSession:`SESSÃO LOCAL PRIVADA`,theme:`Tema`,themeAuto:`Automático`,themeSystem:`Seguir sistema`,themeLight:`Tema claro`,themeDark:`Tema escuro`,selectOne:`Selecione uma opção`,ciRun:`Abrir execução de CI no GitHub ↗`,manualCheck:`Check não listado? Indique nome|ID da App|workflow exatos, separados por ponto e vírgula.`,checksObserved:`Foram encontrados jobs recentes de CI. Abra cada execução e confirme o job, a App e o passo obrigatório de cobertura antes de confiar nele.`,checksNoRecent:`Não há execuções recentes de workflows de pull request. Execute o CI habitual numa PR real ou introduza um produtor exato.`,checksNoVerifiable:`Há execuções recentes, mas nenhum job foi ligado a um Check Run e App exatos. Consulte o GitHub ou introduza o produtor manualmente.`,checksDenied:`O GitHub recusou a consulta do CI. Conceda Actions: read e Checks: read ao PAT de configuração ou introduza um produtor exato.`,checksUnavailable:`A consulta do CI falhou. Isto não prova que o repositório não tenha checks. Tente novamente ou introduza um produtor exato.`,projectsObserved:`Estes Projects existentes pertencem ao proprietário do repositório. Selecione só os que a automatização deve atualizar.`,projectsEmpty:`Esta consulta limitada ao GitHub não devolveu Projects abertos e acessíveis; isso não prova que não existam. Verifique o acesso ou introduza um número confirmado.`,projectsDenied:`O GitHub recusou a consulta de Projects. Verifique Projects: read da organização no PAT ou introduza os números.`,projectsUnavailable:`Não foi possível consultar Projects. Isto não prova que não existam. Introduza um número verificado ou tente novamente.`,projectsUnsupported:`Esta API do GitHub não lista Projects pessoais com um PAT de permissões precisas. Introduza o número do URL de um Project existente.`,discoveryTruncated:`Só foi inspecionada uma amostra limitada de Projects acessíveis ou checks recentes. Introduza manualmente um elemento em falta.`,checksDiscoveryScope:`Âmbito: até 20 execuções recentes de workflows de PR; são inspecionadas no máximo 15 execuções e 100 checks por commit.`,projectsDiscoveryScope:`Âmbito: no máximo 30 Projects abertos e acessíveis da organização em duas páginas; são inspecionados até 100 campos por Project. Os Projects fechados são excluídos.`,retryDiscovery:`Repetir pesquisa no GitHub`,retryRemaining:`Restam {count} tentativas só de leitura. As suas respostas são mantidas.`,retryExhausted:`Não restam tentativas. Verifique o GitHub e introduza manualmente os itens em falta.`,observationTimeUnknown:`data de observação indisponível`,branchRequirementUnknown:`Exigido pela regra do ramo: não verificado`,branchRequirementObserved:`Exigido em {branch} por um ruleset ativo para esta verificação e esta App específicas.`,ciRule:`Abrir ruleset da verificação obrigatória ↗`,projectSharedStatus:`Todos os Projects escolhidos devem partilhar cada valor Status. Esta configuração não atribui valores diferentes por Project.`,fixedWorkflowEnabled:`A sua configuração fixa features.{kind}=true. Mantenha {kind} selecionado; altere --config ou as opções para mudar esta escolha.`,fixedWorkflowDisabled:`A sua configuração fixa features.{kind}=false. Não selecione {kind}; altere --config ou as opções para mudar esta escolha.`,fixedIssuesRequired:`A sua configuração ativa explicitamente release ou hotfix. Mantenha Issues ativo ou altere primeiro --config ou as opções.`,projectSelectionNotObserved:`Os números de Projects escolhidos anteriormente são mantidos, mas já não aparecem neste resultado do GitHub. Confirme-os no GitHub ou remova-os.`,removeSelection:`Remover seleção`,projectTransitionIssueCreated:`Nova questão`,projectTransitionPullRequestCreated:`Novo pull request`,projectTransitionIssueInProgress:`Questão em curso`,projectTransitionPullRequestInProgress:`Pull request em curso`,projectUrl:`Abrir Project no GitHub ↗`,projectManual:`Project em falta? Introduza o número positivo ou o URL exato do GitHub. IDs PVT_ não são aceites.`,projectStatusUnavailable:`Não foi possível verificar as opções Status de todos os Projects. Consulte cada um no GitHub e introduza o valor exato.`,projectStatusIncompatible:`Os Projects escolhidos não partilham valores Status. Selecione Projects compatíveis antes de continuar.`,producerName:`Nome do check/job`,producerAppId:`ID da App GitHub de origem`,producerWorkflow:`Nome do workflow`,producerAdd:`Adicionar check exato`,producerRemove:`Remover check`,producerManualHelp:`Use a identidade exata apresentada no GitHub. Adicioná-la não prova que exige cobertura.`,producerManualInvalid:`Indique nome, ID numérico positivo da App e workflow. Não use | ou ; nos nomes.`,translationPreviewTitle:`Revisão da tradução em curso`,translationPreviewBody:`As perguntas próprias da configuração já estão traduzidas. Alguns diagnósticos dinâmicos do GitHub ou do fornecedor ainda podem aparecer em inglês durante a revisão. Mudar de idioma não altera as suas respostas.`,unknownLocalError:`Ocorreu um erro inesperado na configuração local. Consulte o terminal e atualize a página ou reinicie a configuração.`},Ui=[`en`,`es`,`fr`,`pt`],Wi={en:`English`,es:`Español`,fr:`Français`,pt:`Português`},Gi={en:zi,es:Bi,fr:Vi,pt:Hi};function Q(e,t,n={}){return(Gi[t]??Gi.en)[e].replace(/\{(\w+)\}/gu,(e,t)=>n[t]??``)}var Ki={Repository:`repository`,"Setup choices":`choices`,"Setup PAT":`setupPat`,Plan:`plan`,"Bot PAT & credentials":`botPat`,Apply:`apply`,Preparation:`gettingReady`};function qi(e,t){return Q(Ki[e??``]??`gettingReady`,t)}var $=Mi(`en`),Ji=W(`
  • `),Yi=W(``);function Xi(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`journey`,8),o=[`repository`,`choices`,`setupPat`,`plan`,`botPat`,`apply`];Y();var s=Yi(),c=P(s),l=L(P(c)),u=I(L(P(l)),!0);E(l),E(c);var d=L(c,2),f=I(d,!0),p=L(d,2);Qr(p,5,()=>o,Jr,(e,t,r)=>{var i=Ji();let o;var s=P(i),c=I(s,!0),l=I(L(s,2),!0);E(i),R((e,t)=>{J(i,`aria-current`,(U(a()),H(()=>a()?.position===r+1?`step`:void 0))),o=ui(i,1,``,null,o,{current:a()?.position===r+1,completed:(a()?.position??0)>r+1}),K(c,e),K(l,t)},[()=>(U(a()),H(()=>(a()?.position??0)>r+1?`✓`:String(r+1).padStart(2,`0`))),()=>(U(Q),V(t),n(),H(()=>Q(V(t),n())))]),G(e,i)}),E(p);var m=L(p,2),h=L(P(m)),g=P(h),_=I(g,!0),v=I(L(g),!0);E(h),E(m),E(s),R((e,t,n,r,i)=>{J(s,`aria-label`,e),K(u,t),K(f,n),K(_,r),K(v,i)},[()=>(U(Q),n(),H(()=>Q(`progress`,n()))),()=>(U(Q),n(),H(()=>Q(`studio`,n()))),()=>(U(Q),n(),H(()=>Q(`journey`,n()))),()=>(U(Q),n(),H(()=>Q(`localDesign`,n()))),()=>(U(Q),n(),H(()=>Q(`localDesignBody`,n())))]),G(e,s),k(),i()}var Zi=W(`
    `);function Qi(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(`system`);Tn(()=>V(a),()=>{document.documentElement.dataset.theme=V(a)}),En(),Y();var o=Zi(),s=P(o);let c;var l=I(s,!0),u=L(s,2);let d;var f=L(u,2);let p;E(o),R((e,t,n,r,i,m,h,g)=>{J(o,`aria-label`,e),J(s,`aria-pressed`,V(a)===`system`),J(s,`aria-label`,t),J(s,`title`,n),c=ui(s,1,``,null,c,{active:V(a)===`system`}),K(l,r),J(u,`aria-pressed`,V(a)===`light`),J(u,`aria-label`,i),J(u,`title`,m),d=ui(u,1,``,null,d,{active:V(a)===`light`}),J(f,`aria-pressed`,V(a)===`dark`),J(f,`aria-label`,h),J(f,`title`,g),p=ui(f,1,``,null,p,{active:V(a)===`dark`})},[()=>(U(Q),n(),H(()=>Q(`theme`,n()))),()=>(U(Q),n(),H(()=>Q(`themeSystem`,n()))),()=>(U(Q),n(),H(()=>Q(`themeSystem`,n()))),()=>(U(Q),n(),H(()=>Q(`themeAuto`,n()))),()=>(U(Q),n(),H(()=>Q(`themeLight`,n()))),()=>(U(Q),n(),H(()=>Q(`themeLight`,n()))),()=>(U(Q),n(),H(()=>Q(`themeDark`,n()))),()=>(U(Q),n(),H(()=>Q(`themeDark`,n())))]),Sr(`click`,s,()=>N(a,`system`)),Sr(`click`,u,()=>N(a,`light`)),Sr(`click`,f,()=>N(a,`dark`)),G(e,o),k(),i()}Cr([`click`]);var $i=W(``),ea=W(` `,1);function ta(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii();function a(e){let t=e.currentTarget.value;Ui.includes(t)&&$.set(t)}Y();var o=ea(),s=F(o),c=P(s),l=L(c);Qr(l,5,()=>Ui,Jr,(e,t)=>{var n=$i(),r=I(n,!0),i={};R(()=>{K(r,Wi[V(t)]),i!==(i=V(t))&&(n.value=(n.__value=i)??``)}),G(e,n)}),E(l);var u;mi(l),E(s);var d=I(L(s,2),!0);R((e,t)=>{K(c,`${e??``} `),J(l,`aria-label`,t),u!==(u=n())&&(l.value=(l.__value=u)??``,pi(l,u)),K(d,Wi[n()])},[()=>Q(`language`,n()),()=>Q(`language`,n())]),Sr(`change`,l,a),G(e,o),k(),i()}Cr([`change`]);var na=W(`
    `);function ra(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`repository`,8);Y();var o=na(),s=P(o),c=P(s),l=I(c,!0),u=L(c,2),d=I(P(u),!0);E(u),E(s);var f=L(s,2),p=P(f),m=L(P(p));E(p);var h=L(p);ta(h,{}),Qi(L(h),{}),E(f),E(o),R((e,t,n)=>{K(l,e),K(d,t),K(m,` ${n??``}`)},[()=>(U(Q),n(),H(()=>Q(`setup`,n()))),()=>(U(a()),U(Q),n(),H(()=>a()??Q(`connecting`,n()))),()=>(U(Q),n(),H(()=>Q(`localSession`,n())))]),G(e,o),k(),i()}var ia={"repository.confirm":{title:`Confirm this repository`,description:`This checkout points to {repository} on branch {branch}. Confirm the target before configuring PAT access or files.`,choices:[`Yes, this is my repository`,`Stop and choose another checkout`]},"setup.depth":{title:`Choose how much detail to review`,description:`Basic still asks about permissions, security, branch roles, Projects, approval and storage. Selected advanced agent, branch-prefix and Bugbot settings keep their defaults; inspect and edit them in the final plan. Custom asks every applicable question. Neither path applies changes now.`,choices:[`Basic guided setup (recommended)`,`Customize every setting`]},"setup.environmentPat":{title:`An environment setup PAT is available`,description:`Its value stays in the CLI process and is never sent to this page. Exiting Copilot cannot unset your parent shell variable.`,choices:[`Use the environment PAT`,`Create or enter a different PAT`]},"plan.review":{title:`Review your setup plan`,description:`Inspect the affected files, workflows, Variables and Secret names before approving. No setup changes start by opening this plan.`},"workflow.update":{title:`Update existing workflows?`,description:`These existing workflow files differ from the setup plan: {files}. Keeping them may leave some new settings inactive.`,choices:[`Keep existing`,`Update setup-managed workflows`]},"setupPat.method":{title:`How will you provide your setup PAT?`,description:`This temporary PAT authorizes this one setup run. GitHub creates it under your operator account; Copilot cannot revoke it for you.`,choices:[`Guided GitHub link`,`Manual PAT`]},"setupPat.ownerKind":{title:`Who owns this repository on GitHub?`,description:`The owner determines which organization permissions may be needed. Check the repository header on GitHub if unsure.`,choices:[`Organization`,`Personal account`,`Not sure`]},"setupPat.review":{title:`Review provisional setup PAT permissions`,description:`These grants follow your choices so far. GitHub inspection may add a requirement; you will review any change before setup starts.`,choices:[`Continue to GitHub`,`Review setup choices again`,`View full permission table`,`Enter a PAT manually`]},"setupPat.entry":{title:`Temporary setup PAT`,description:`Open GitHub as your operator account, complete 2FA, choose “Only select repositories”, select this repository and copy the generated PAT here. This PAT is for this run only; delete it on GitHub afterwards.`},"setupPat.confirmAccount":{title:`GitHub authenticated the setup PAT as @{account}`,description:`Is this the operator account that should configure this repository? A wrong account must stop before any setup change.`,choices:[`Yes, continue`,`No, stop`]},"setupPat.confirmWrites":{title:`Confirm write permissions that GitHub cannot safely test`,description:`Some required write grants cannot be proven without a mutation. Check them in GitHub against the displayed permission table before confirming.`,choices:[`No, stop`,`Yes, I checked them`]},"botPat.method":{title:`How will you provide the bot PAT?`,description:`The bot PAT is separate from the setup PAT. It belongs to the account that will run future GitHub Actions and is stored as a Secret after approval.`,choices:[`Guided GitHub link`,`Manual PAT`]},"botPat.login":{title:`Expected GitHub bot login`,description:`Enter the bot account login without @. Copilot resolves its numeric GitHub ID and compares it with the PAT owner.`},"botPat.entry.guided":{title:`{name} — bot account PAT`,description:`Open GitHub as @{account} (account ID {accountId}), not as the setup operator. Select only this repository, review all grants and paste the PAT here. Suggested expiry: 90 days. An existing Secret value cannot be read back.`},"botPat.entry.manual":{title:`{name} — bot account PAT`,description:`Use the bot account, select only the intended repository and review all grants before pasting its PAT. Suggested expiry: 90 days. An existing Secret value cannot be read back.`},"credential.apiKey":{title:`{name} — {provider} API key`,description:`Paste the API key for {provider}. It is sent only to this local setup process and, if approved, installed as a GitHub Actions Secret. Existing Secret values cannot be read back.`},"credential.existing":{title:`Existing {name}: {status}`,description:`GitHub cannot reveal the current Secret value. Keep it only if you deliberately accept that its health cannot be verified here; replace or skip it otherwise.`,choices:[`Keep existing`,`Replace it`,`Skip this credential`]},"apply.confirm":{title:`Apply this setup now?`,description:`This is the final approval. Local files and selected GitHub resources may change. A partial result may require inspection before retrying.`,choices:[`Apply setup`,`Stop without applying`]}},aa={"repository.confirm":{title:`Confirma el repositorio`,description:`Este checkout apunta a {repository} en la rama {branch}. Comprueba el destino antes de configurar permisos del PAT o archivos.`,choices:[`Sí, es mi repositorio`,`Detener y elegir otro checkout`]},"setup.depth":{title:`Elige cuánto detalle quieres revisar`,description:`El modo básico sigue preguntando por permisos, seguridad, ramas, Projects, aprobación y almacenamiento. Algunos ajustes avanzados de agentes, prefijos de rama y Bugbot conservan sus valores predeterminados; podrás revisarlos y editarlos en el plan final. El modo personalizado pregunta por todos los ajustes aplicables. Ninguno aplica cambios todavía.`,choices:[`Configuración básica guiada (recomendada)`,`Personalizar todos los ajustes`]},"setup.environmentPat":{title:`Hay un PAT de configuración en el entorno`,description:`Su valor permanece en el proceso CLI y no se envía a esta página. Cerrar Copilot no elimina la variable de la terminal original.`,choices:[`Usar el PAT del entorno`,`Crear o introducir otro PAT`]},"plan.review":{title:`Revisa el plan de configuración`,description:`Comprueba los archivos, workflows, Variables y nombres de Secrets antes de aprobar. Abrir el plan no inicia ningún cambio.`},"workflow.update":{title:`¿Actualizar los workflows existentes?`,description:`Estos archivos difieren del plan: {files}. Si los conservas, algunos ajustes nuevos podrían no activarse.`,choices:[`Conservar los existentes`,`Actualizar los workflows gestionados`]},"setupPat.method":{title:`¿Cómo proporcionarás el PAT de configuración?`,description:`Este PAT temporal autoriza una sola ejecución. GitHub lo crea con tu cuenta de operador; Copilot no puede revocarlo por ti.`,choices:[`Enlace guiado de GitHub`,`Introducir PAT manualmente`]},"setupPat.ownerKind":{title:`¿Quién es propietario de este repositorio en GitHub?`,description:`El propietario determina qué permisos de organización pueden hacer falta. Consulta la cabecera del repositorio en GitHub si dudas.`,choices:[`Organización`,`Cuenta personal`,`No lo sé`]},"setupPat.review":{title:`Revisa los permisos provisionales del PAT de configuración`,description:`Estos permisos dependen de las opciones elegidas. Al inspeccionar GitHub podría aparecer otro requisito; lo revisarás antes de aplicar cambios.`,choices:[`Continuar en GitHub`,`Revisar de nuevo las opciones`,`Ver la tabla completa de permisos`,`Introducir un PAT manualmente`]},"setupPat.entry":{title:`PAT temporal de configuración`,description:`Abre GitHub con tu cuenta de operador, completa el 2FA, elige «Only select repositories», selecciona este repositorio y pega aquí el PAT generado. Es solo para esta ejecución; elimínalo en GitHub después.`},"setupPat.confirmAccount":{title:`GitHub autenticó el PAT de configuración como @{account}`,description:`¿Es la cuenta de operador que debe configurar este repositorio? Una cuenta incorrecta debe detener el proceso antes de cualquier cambio.`,choices:[`Sí, continuar`,`No, detener`]},"setupPat.confirmWrites":{title:`Confirma permisos de escritura que GitHub no puede probar sin cambios`,description:`Algunos permisos de escritura no pueden verificarse sin modificar recursos. Compruébalos en GitHub frente a la tabla mostrada antes de confirmar.`,choices:[`No, detener`,`Sí, los he comprobado`]},"botPat.method":{title:`¿Cómo proporcionarás el PAT del bot?`,description:`Es distinto del PAT de configuración. Pertenece a la cuenta que ejecutará las futuras GitHub Actions y se instalará como Secret tras la aprobación.`,choices:[`Enlace guiado de GitHub`,`Introducir PAT manualmente`]},"botPat.login":{title:`Usuario previsto del bot en GitHub`,description:`Introduce el nombre de usuario del bot sin @. Copilot consultará su ID numérico de GitHub y lo comparará con el propietario del PAT.`},"botPat.entry.guided":{title:`{name} — PAT de la cuenta bot`,description:`Abre GitHub como @{account} (ID {accountId}), no como operador de setup. Selecciona solo este repositorio, revisa todos los permisos y pega aquí el PAT. Caducidad sugerida: 90 días. El valor de un Secret existente no puede leerse.`},"botPat.entry.manual":{title:`{name} — PAT de la cuenta bot`,description:`Usa la cuenta bot, selecciona solo el repositorio previsto y revisa todos los permisos antes de pegar el PAT. Caducidad sugerida: 90 días. El valor de un Secret existente no puede leerse.`},"credential.apiKey":{title:`{name} — clave API de {provider}`,description:`Pega la clave API de {provider}. Solo se envía a este proceso local y, si apruebas el plan, se instala como Secret de GitHub Actions. Los valores existentes no pueden leerse.`},"credential.existing":{title:`{name} existente: {status}`,description:`GitHub no muestra el valor actual del Secret. Consérvalo solo si aceptas expresamente que aquí no se puede comprobar su estado; de lo contrario, sustitúyelo u omítelo.`,choices:[`Conservar`,`Sustituir`,`Omitir esta credencial`]},"apply.confirm":{title:`¿Aplicar la configuración ahora?`,description:`Esta es la aprobación final. Pueden cambiar archivos locales y recursos de GitHub seleccionados. Si el resultado es parcial, revísalo antes de reintentar.`,choices:[`Aplicar configuración`,`Detener sin aplicar`]}},oa={"repository.confirm":{title:`Confirmez ce dépôt`,description:`Ce dossier pointe vers {repository}, branche {branch}. Vérifiez la cible avant de configurer les accès PAT ou les fichiers.`,choices:[`Oui, c’est mon dépôt`,`Arrêter et choisir un autre dossier`]},"setup.depth":{title:`Choisissez le niveau de détail`,description:`Le parcours de base demande toujours les autorisations, la sécurité, les branches, Projects, l’approbation et le stockage. Certains réglages avancés des agents, préfixes de branche et Bugbot conservent leurs valeurs par défaut ; vous pourrez les examiner et les modifier dans le plan final. Le parcours personnalisé pose toutes les questions applicables. Aucun changement n’est appliqué à ce stade.`,choices:[`Configuration de base guidée (recommandée)`,`Personnaliser tous les réglages`]},"setup.environmentPat":{title:`Un PAT de configuration est disponible dans l’environnement`,description:`Sa valeur reste dans le processus CLI et n’est jamais envoyée à cette page. Quitter Copilot ne supprime pas la variable du shell parent.`,choices:[`Utiliser le PAT de l’environnement`,`Créer ou saisir un autre PAT`]},"plan.review":{title:`Examinez le plan de configuration`,description:`Vérifiez les fichiers, workflows, Variables et noms de Secrets avant d’approuver. Ouvrir le plan ne lance aucun changement.`},"workflow.update":{title:`Mettre à jour les workflows existants ?`,description:`Ces fichiers diffèrent du plan : {files}. Les conserver peut laisser certains nouveaux réglages inactifs.`,choices:[`Conserver les fichiers existants`,`Mettre à jour les workflows gérés`]},"setupPat.method":{title:`Comment fournirez-vous le PAT de configuration ?`,description:`Ce PAT temporaire autorise une seule exécution. GitHub le crée avec votre compte opérateur ; Copilot ne peut pas le révoquer à votre place.`,choices:[`Lien GitHub guidé`,`Saisir un PAT manuellement`]},"setupPat.ownerKind":{title:`À qui appartient ce dépôt sur GitHub ?`,description:`Le propriétaire détermine les éventuelles autorisations d’organisation. Consultez l’en-tête du dépôt sur GitHub en cas de doute.`,choices:[`Organisation`,`Compte personnel`,`Je ne sais pas`]},"setupPat.review":{title:`Examinez les autorisations provisoires du PAT de configuration`,description:`Ces autorisations suivent vos choix. L’inspection de GitHub peut révéler un besoin supplémentaire ; vous le reverrez avant tout changement.`,choices:[`Continuer sur GitHub`,`Revoir les choix de configuration`,`Voir toutes les autorisations`,`Saisir un PAT manuellement`]},"setupPat.entry":{title:`PAT temporaire de configuration`,description:`Ouvrez GitHub avec votre compte opérateur, effectuez la 2FA, choisissez « Only select repositories », sélectionnez ce dépôt puis collez ici le PAT généré. Il ne sert qu’à cette exécution ; supprimez-le sur GitHub ensuite.`},"setupPat.confirmAccount":{title:`GitHub a authentifié le PAT de configuration comme @{account}`,description:`Est-ce bien le compte opérateur autorisé à configurer ce dépôt ? Un autre compte doit arrêter le processus avant tout changement.`,choices:[`Oui, continuer`,`Non, arrêter`]},"setupPat.confirmWrites":{title:`Confirmez les droits d’écriture que GitHub ne peut pas vérifier sans changement`,description:`Certains droits d’écriture ne peuvent être prouvés sans modifier des ressources. Comparez-les au tableau affiché sur GitHub avant de confirmer.`,choices:[`Non, arrêter`,`Oui, je les ai vérifiés`]},"botPat.method":{title:`Comment fournirez-vous le PAT du bot ?`,description:`Il est distinct du PAT de configuration. Il appartient au compte qui exécutera les futures GitHub Actions et sera installé comme Secret après approbation.`,choices:[`Lien GitHub guidé`,`Saisir un PAT manuellement`]},"botPat.login":{title:`Identifiant GitHub attendu pour le bot`,description:`Saisissez l’identifiant du bot sans @. Copilot récupère son ID numérique GitHub et le compare au propriétaire du PAT.`},"botPat.entry.guided":{title:`{name} — PAT du compte bot`,description:`Ouvrez GitHub en tant que @{account} (ID {accountId}), pas en tant qu’opérateur. Sélectionnez uniquement ce dépôt, vérifiez tous les droits et collez le PAT ici. Expiration suggérée : 90 jours. La valeur d’un Secret existant ne peut pas être relue.`},"botPat.entry.manual":{title:`{name} — PAT du compte bot`,description:`Utilisez le compte bot, sélectionnez uniquement le dépôt voulu et vérifiez tous les droits avant de coller le PAT. Expiration suggérée : 90 jours. La valeur d’un Secret existant ne peut pas être relue.`},"credential.apiKey":{title:`{name} — clé API de {provider}`,description:`Collez la clé API de {provider}. Elle est envoyée uniquement au processus local puis, si vous approuvez le plan, installée comme Secret GitHub Actions. Les valeurs existantes ne peuvent pas être relues.`},"credential.existing":{title:`{name} existant : {status}`,description:`GitHub ne révèle pas la valeur actuelle du Secret. Ne la conservez que si vous acceptez explicitement de ne pas pouvoir vérifier son état ici ; sinon, remplacez-la ou ignorez-la.`,choices:[`Conserver`,`Remplacer`,`Ignorer cet identifiant`]},"apply.confirm":{title:`Appliquer la configuration maintenant ?`,description:`C’est l’approbation finale. Des fichiers locaux et ressources GitHub choisis peuvent changer. Un résultat partiel doit être inspecté avant une nouvelle tentative.`,choices:[`Appliquer la configuration`,`Arrêter sans appliquer`]}},sa={"repository.confirm":{title:`Confirme este repositório`,description:`Esta pasta aponta para {repository}, no ramo {branch}. Confirme o destino antes de configurar acessos PAT ou ficheiros.`,choices:[`Sim, é o meu repositório`,`Parar e escolher outra pasta`]},"setup.depth":{title:`Escolha o nível de detalhe`,description:`O percurso básico continua a perguntar sobre permissões, segurança, ramos, Projects, aprovação e armazenamento. Algumas definições avançadas de agentes, prefixos de ramos e Bugbot mantêm os valores predefinidos; poderá revê-las e editá-las no plano final. O percurso personalizado pergunta por todas as definições aplicáveis. Nenhum dos percursos aplica alterações nesta fase.`,choices:[`Configuração básica guiada (recomendada)`,`Personalizar todas as definições`]},"setup.environmentPat":{title:`Existe um PAT de configuração no ambiente`,description:`O valor permanece no processo CLI e nunca é enviado para esta página. Sair do Copilot não remove a variável da shell original.`,choices:[`Usar o PAT do ambiente`,`Criar ou introduzir outro PAT`]},"plan.review":{title:`Reveja o plano de configuração`,description:`Confirme ficheiros, fluxos, Variables e nomes de Secrets antes de aprovar. Abrir o plano não inicia alterações.`,choices:void 0},"workflow.update":{title:`Atualizar os fluxos existentes?`,description:`Estes ficheiros diferem do plano: {files}. Conservá-los pode deixar algumas definições novas inativas.`,choices:[`Conservar os existentes`,`Atualizar os fluxos geridos`]},"setupPat.method":{title:`Como irá fornecer o PAT de configuração?`,description:`Este PAT temporário autoriza uma única execução. O GitHub cria-o na sua conta de operador; o Copilot não pode revogá-lo por si.`,choices:[`Ligação guiada do GitHub`,`Introduzir PAT manualmente`]},"setupPat.ownerKind":{title:`Quem é o proprietário deste repositório no GitHub?`,description:`O proprietário determina as permissões de organização necessárias. Consulte o cabeçalho do repositório no GitHub se tiver dúvidas.`,choices:[`Organização`,`Conta pessoal`,`Não tenho a certeza`]},"setupPat.review":{title:`Reveja as permissões provisórias do PAT de configuração`,description:`Estas permissões seguem as suas escolhas. A inspeção do GitHub pode revelar outro requisito; irá revê-lo antes de qualquer alteração.`,choices:[`Continuar no GitHub`,`Rever as escolhas`,`Ver a tabela completa de permissões`,`Introduzir um PAT manualmente`]},"setupPat.entry":{title:`PAT temporário de configuração`,description:`Abra o GitHub com a sua conta de operador, conclua a 2FA, escolha «Only select repositories», selecione este repositório e cole aqui o PAT gerado. É apenas para esta execução; elimine-o no GitHub depois.`},"setupPat.confirmAccount":{title:`O GitHub autenticou o PAT de configuração como @{account}`,description:`É esta a conta de operador que deve configurar o repositório? Uma conta errada tem de parar o processo antes de qualquer alteração.`,choices:[`Sim, continuar`,`Não, parar`]},"setupPat.confirmWrites":{title:`Confirme permissões de escrita que o GitHub não pode testar sem alterações`,description:`Algumas permissões de escrita não podem ser provadas sem alterar recursos. Compare-as com a tabela no GitHub antes de confirmar.`,choices:[`Não, parar`,`Sim, já as verifiquei`]},"botPat.method":{title:`Como irá fornecer o PAT do bot?`,description:`É diferente do PAT de configuração. Pertence à conta que executará as futuras GitHub Actions e será instalado como Secret após aprovação.`,choices:[`Ligação guiada do GitHub`,`Introduzir PAT manualmente`]},"botPat.login":{title:`Utilizador GitHub esperado para o bot`,description:`Introduza o nome de utilizador do bot sem @. O Copilot consulta o ID numérico do GitHub e compara-o com o proprietário do PAT.`},"botPat.entry.guided":{title:`{name} — PAT da conta bot`,description:`Abra o GitHub como @{account} (ID {accountId}), não como operador. Selecione apenas este repositório, reveja todas as permissões e cole aqui o PAT. Validade sugerida: 90 dias. Não é possível voltar a ler o valor de um Secret existente.`},"botPat.entry.manual":{title:`{name} — PAT da conta bot`,description:`Use a conta bot, selecione apenas o repositório pretendido e reveja todas as permissões antes de colar o PAT. Validade sugerida: 90 dias. Não é possível voltar a ler o valor de um Secret existente.`},"credential.apiKey":{title:`{name} — chave API de {provider}`,description:`Cole a chave API de {provider}. Só é enviada para este processo local e, se aprovar o plano, instalada como Secret do GitHub Actions. Os valores existentes não podem voltar a ser lidos.`},"credential.existing":{title:`{name} existente: {status}`,description:`O GitHub não revela o valor atual do Secret. Conserve-o apenas se aceitar expressamente que aqui não se pode verificar o seu estado; caso contrário, substitua-o ou ignore-o.`,choices:[`Conservar`,`Substituir`,`Ignorar esta credencial`]},"apply.confirm":{title:`Aplicar a configuração agora?`,description:`Esta é a aprovação final. Ficheiros locais e recursos GitHub selecionados podem mudar. Um resultado parcial exige inspeção antes de tentar novamente.`,choices:[`Aplicar configuração`,`Parar sem aplicar`]}},ca={"session.controlMoved":`Control moved to this tab. The previous tab is now read-only.`,"session.cancelled":`Setup stopped before applying further changes. Any PAT created on GitHub still exists until you delete it there.`,"plan.ready":`Plan ready: {files} files, {variables} Variables and {secrets} Secret names. Review it before continuing.`,"permission.preview":`Permission preview: issue workflows {issues}; PR approval {approval}; Secrets {secrets}; Variables {variables}; Projects {projects}. Review the exact grants before creating the setup PAT.`,"setupPat.corrected.bootstrap":`The setup PAT could not access the repository. No setup changes started. Required grants: {grants}. Create a corrected PAT using the updated GitHub link.`,"setupPat.corrected.final":`Required setup PAT permissions changed after inspection. No setup changes started. New grants: {grants}. Create a corrected PAT using the updated GitHub link.`,"setupPat.cleanup":`Delete the temporary setup PAT in GitHub Settings after this run. Closing Copilot does not revoke it.`,"botPat.separation":`The bot PAT is separate from your setup PAT. These credentials become GitHub Actions Secrets: {names}. Existing Secret values cannot be read back. Re-enter an existing bot PAT so its grants can be checked. No credential-health workflow runs before Apply.`,"credential.checks":`Credential checks finished for {count} items. Each result is shown below. The terminal has technical details; an existing Secret value cannot be read back.`,"credential.status.valid":`Valid`,"credential.status.invalid":`Invalid`,"credential.status.missing":`Missing`,"credential.status.unverifiable":`Cannot be verified without a new value`,"credential.status.not_required":`Not required`,"validation.producers":`Select 1–8 observed checks or enter exact name|App ID|workflow tuples.`,"validation.duplicateNames":`Two trusted producers use the same check name. Coverage stores only the name: choose one producer or rename the CI jobs.`,"validation.projectStatusVerified":`Open every selected Project in GitHub and confirm that all four exact Status values exist. Answer Yes after checking, or No to choose Projects again.`,"validation.projectStatusRedo":`Status values were not confirmed. Choose compatible Projects, then review their Status options again.`,"validation.number":`Enter a non-negative whole number.`,"validation.boolean":`Enter yes or no.`,"validation.choice":`Select one of the listed options.`,"validation.unknownResource":`Unknown inherited resource names: {names}. Choose only names shown in the inherited list.`,"validation.unknownWorkflow":`Unknown issue workflows: {names}. Choose only the listed workflow types.`,"validation.firstQuestion":`This is the first question in this pass. Review it or cancel setup.`,"validation.duplicateProducer":`This trusted check was selected twice. Remove the duplicate selection.`,"validation.savedStatus":`The saved Status value is not available in every selected Project. Choose a listed option.`,"validation.projectIncompatible":`The selected Projects have no common Status option. Choose compatible Projects or configure them separately.`,"validation.projectLimit":`Choose at most 10 Projects; separate their numbers or URLs with commas.`,"validation.projectOwnerNeeded":`A Project URL needs a known repository owner; enter its positive number instead.`,"validation.projectOwnerMismatch":`Use a GitHub Project URL belonging to {owner}, without query parameters.`,"validation.projectUrl":`Enter a valid GitHub Project URL or positive Project number.`,"validation.projectNumber":`Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.`,"validation.projectNumberRange":`Project numbers must be positive integers at most 2147483647.`,"validation.projectDuplicate":`Project {number} was selected more than once.`,"validation.fixedIssues":`Your configuration explicitly enables release or hotfix automation. Keep Issues enabled or edit that configuration first.`,"validation.fixedWorkflowEnabled":`Your configuration enables {kind}. Keep that workflow selected or edit the configuration first.`,"validation.fixedWorkflowDisabled":`Your configuration disables {kind}. Deselect that workflow or edit the configuration first.`,"validation.unknown":`This answer could not be accepted. Review the question and its help; the terminal has the technical detail.`},la={"session.controlMoved":`El control ha pasado a esta pestaña. La anterior ahora es de solo lectura.`,"session.cancelled":`La configuración se detuvo antes de aplicar más cambios. Los PAT creados en GitHub siguen existiendo hasta que los elimines allí.`,"plan.ready":`Plan listo: {files} archivos, {variables} Variables y {secrets} nombres de Secrets. Revísalo antes de continuar.`,"permission.preview":`Vista previa de permisos: flujos de incidencias {issues}; aprobación de PR {approval}; Secrets {secrets}; Variables {variables}; Projects {projects}. Revisa los permisos exactos antes de crear el PAT de configuración.`,"setupPat.corrected.bootstrap":`El PAT de configuración no pudo acceder al repositorio. No se iniciaron cambios. Permisos necesarios: {grants}. Crea un PAT corregido desde el enlace actualizado de GitHub.`,"setupPat.corrected.final":`Los permisos necesarios del PAT cambiaron tras la inspección. No se iniciaron cambios. Permisos nuevos: {grants}. Crea un PAT corregido desde el enlace actualizado.`,"setupPat.cleanup":`Elimina el PAT temporal de configuración en los ajustes de GitHub después de esta ejecución. Cerrar Copilot no lo revoca.`,"botPat.separation":`El PAT del bot es distinto del de configuración. Estas credenciales se instalarán como Secrets de GitHub Actions: {names}. Los valores existentes no pueden leerse. Vuelve a introducir un PAT del bot para comprobar sus permisos. Antes de Aplicar no se ejecuta ningún workflow de comprobación.`,"credential.checks":`Se han comprobado {count} credenciales. Abajo aparece el resultado de cada una. La terminal contiene los detalles técnicos; el valor de un Secret existente no puede leerse.`,"credential.status.valid":`Válida`,"credential.status.invalid":`No válida`,"credential.status.missing":`Falta`,"credential.status.unverifiable":`No verificable sin un valor nuevo`,"credential.status.not_required":`No necesaria`,"validation.producers":`Selecciona entre 1 y 8 checks observados o introduce las tuplas exactas nombre|ID de App|workflow.`,"validation.duplicateNames":`Dos productores fiables tienen el mismo nombre de check. La cobertura solo guarda el nombre: elige uno o cambia el nombre de los jobs de CI.`,"validation.projectStatusVerified":`Abre cada Project elegido en GitHub y confirma los cuatro valores Status exactos. Responde Sí tras comprobarlo, o No para elegir Projects de nuevo.`,"validation.projectStatusRedo":`No se confirmaron los valores Status. Elige Projects compatibles y vuelve a revisar sus opciones Status.`,"validation.number":`Introduce un número entero no negativo.`,"validation.boolean":`Elige Sí o No.`,"validation.choice":`Elige una de las opciones mostradas.`,"validation.unknownResource":`Nombres de recursos heredados desconocidos: {names}. Elige solo nombres de la lista heredada.`,"validation.unknownWorkflow":`Flujos de issues desconocidos: {names}. Elige solo los tipos indicados.`,"validation.firstQuestion":`Esta es la primera pregunta de esta pasada. Revísala o cancela la configuración.`,"validation.duplicateProducer":`Has seleccionado dos veces el mismo check fiable. Quita la selección duplicada.`,"validation.savedStatus":`El valor Status guardado no existe en todos los Projects seleccionados. Elige una opción de la lista.`,"validation.projectIncompatible":`Los Projects elegidos no comparten ningún valor Status. Elige Projects compatibles o configúralos por separado.`,"validation.projectLimit":`Elige como máximo 10 Projects; separa sus números o URL con comas.`,"validation.projectOwnerNeeded":`Para usar la URL de un Project hay que conocer el propietario del repositorio; introduce su número positivo.`,"validation.projectOwnerMismatch":`Usa la URL de un Project de GitHub que pertenezca a {owner}, sin parámetros.`,"validation.projectUrl":`Introduce una URL válida de un Project de GitHub o su número positivo.`,"validation.projectNumber":`Introduce el número positivo del Project que aparece en su URL, no un ID GraphQL PVT_.`,"validation.projectNumberRange":`El número del Project debe ser un entero positivo no mayor de 2147483647.`,"validation.projectDuplicate":`Has seleccionado el Project {number} más de una vez.`,"validation.fixedIssues":`Tu configuración activa expresamente release o hotfix. Mantén Issues activado o cambia antes la configuración.`,"validation.fixedWorkflowEnabled":`Tu configuración activa {kind}. Mantén ese flujo seleccionado o cambia antes la configuración.`,"validation.fixedWorkflowDisabled":`Tu configuración desactiva {kind}. Desmarca ese flujo o cambia antes la configuración.`,"validation.unknown":`No se pudo aceptar esta respuesta. Revisa la pregunta y su ayuda; la terminal contiene el detalle técnico.`},ua={"session.controlMoved":`Le contrôle est passé à cet onglet. L’onglet précédent est maintenant en lecture seule.`,"session.cancelled":`La configuration s’est arrêtée avant de nouveaux changements. Tout PAT créé sur GitHub existe encore jusqu’à sa suppression là-bas.`,"plan.ready":`Plan prêt : {files} fichiers, {variables} Variables et {secrets} noms de Secrets. Examinez-le avant de continuer.`,"permission.preview":`Aperçu des droits : flux de tickets {issues} ; approbation des PR {approval} ; Secrets {secrets} ; Variables {variables} ; Projects {projects}. Vérifiez les droits exacts avant de créer le PAT de configuration.`,"setupPat.corrected.bootstrap":`Le PAT de configuration n’a pas pu accéder au dépôt. Aucun changement n’a commencé. Droits requis : {grants}. Créez un PAT corrigé avec le lien GitHub mis à jour.`,"setupPat.corrected.final":`Les droits requis du PAT ont changé après inspection. Aucun changement n’a commencé. Nouveaux droits : {grants}. Créez un PAT corrigé avec le lien mis à jour.`,"setupPat.cleanup":`Supprimez le PAT temporaire de configuration dans les paramètres GitHub après cette exécution. Fermer Copilot ne le révoque pas.`,"botPat.separation":`Le PAT du bot est distinct du PAT de configuration. Ces identifiants deviendront des Secrets GitHub Actions : {names}. Les valeurs existantes ne peuvent pas être relues. Saisissez à nouveau un PAT du bot pour vérifier ses droits. Aucun workflow de vérification ne démarre avant Appliquer.`,"credential.checks":`Vérification terminée pour {count} identifiants. Le résultat de chacun figure ci-dessous. Le terminal contient les détails techniques ; la valeur d’un Secret existant ne peut pas être relue.`,"credential.status.valid":`Valide`,"credential.status.invalid":`Invalide`,"credential.status.missing":`Manquant`,"credential.status.unverifiable":`Invérifiable sans nouvelle valeur`,"credential.status.not_required":`Non requis`,"validation.producers":`Choisissez 1 à 8 vérifications observées ou saisissez les triplets exacts nom|ID d’App|workflow.`,"validation.duplicateNames":`Deux producteurs fiables portent le même nom de vérification. La couverture ne stocke que ce nom : choisissez-en un ou renommez les jobs CI.`,"validation.projectStatusVerified":`Ouvrez chaque Project choisi sur GitHub et confirmez les quatre valeurs Status exactes. Répondez Oui après vérification, ou Non pour choisir à nouveau les Projects.`,"validation.projectStatusRedo":`Les valeurs Status n’ont pas été confirmées. Choisissez des Projects compatibles, puis revérifiez leurs options Status.`,"validation.number":`Saisissez un entier positif ou nul.`,"validation.boolean":`Choisissez Oui ou Non.`,"validation.choice":`Choisissez une des options affichées.`,"validation.unknownResource":`Noms de ressources héritées inconnus : {names}. Choisissez seulement les noms de la liste héritée.`,"validation.unknownWorkflow":`Workflows de ticket inconnus : {names}. Choisissez seulement les types proposés.`,"validation.firstQuestion":`C’est la première question de cette passe. Vérifiez-la ou annulez la configuration.`,"validation.duplicateProducer":`Cette vérification fiable a été choisie deux fois. Retirez le doublon.`,"validation.savedStatus":`La valeur Status enregistrée n’existe pas dans tous les Projects choisis. Sélectionnez une option proposée.`,"validation.projectIncompatible":`Les Projects choisis ne partagent aucune valeur Status. Choisissez des Projects compatibles ou configurez-les séparément.`,"validation.projectLimit":`Choisissez au plus 10 Projects ; séparez leurs numéros ou URL par des virgules.`,"validation.projectOwnerNeeded":`Une URL de Project exige de connaître le propriétaire du dépôt ; saisissez plutôt son numéro positif.`,"validation.projectOwnerMismatch":`Utilisez une URL de Project GitHub appartenant à {owner}, sans paramètres.`,"validation.projectUrl":`Saisissez une URL valide de Project GitHub ou son numéro positif.`,"validation.projectNumber":`Saisissez le numéro positif figurant dans l’URL du Project, pas un ID GraphQL PVT_.`,"validation.projectNumberRange":`Le numéro du Project doit être un entier positif inférieur ou égal à 2147483647.`,"validation.projectDuplicate":`Le Project {number} a été choisi plusieurs fois.`,"validation.fixedIssues":`Votre configuration active explicitement release ou hotfix. Gardez Issues activé ou modifiez d’abord la configuration.`,"validation.fixedWorkflowEnabled":`Votre configuration active {kind}. Gardez ce flux sélectionné ou modifiez d’abord la configuration.`,"validation.fixedWorkflowDisabled":`Votre configuration désactive {kind}. Désélectionnez ce flux ou modifiez d’abord la configuration.`,"validation.unknown":`Cette réponse n’a pas été acceptée. Vérifiez la question et son aide ; le terminal contient le détail technique.`},da={"session.controlMoved":`O controlo passou para este separador. O separador anterior é agora apenas de leitura.`,"session.cancelled":`A configuração parou antes de novas alterações. Os PAT criados no GitHub continuam a existir até os eliminar lá.`,"plan.ready":`Plano pronto: {files} ficheiros, {variables} Variables e {secrets} nomes de Secrets. Reveja-o antes de continuar.`,"permission.preview":`Pré-visualização das permissões: fluxos de issues {issues}; aprovação de PR {approval}; Secrets {secrets}; Variables {variables}; Projects {projects}. Reveja as permissões exatas antes de criar o PAT de configuração.`,"setupPat.corrected.bootstrap":`O PAT de configuração não conseguiu aceder ao repositório. Não começaram alterações. Permissões necessárias: {grants}. Crie um PAT corrigido com a ligação GitHub atualizada.`,"setupPat.corrected.final":`As permissões necessárias do PAT mudaram após inspeção. Não começaram alterações. Novas permissões: {grants}. Crie um PAT corrigido com a ligação atualizada.`,"setupPat.cleanup":`Elimine o PAT temporário de configuração nas definições do GitHub após esta execução. Fechar o Copilot não o revoga.`,"botPat.separation":`O PAT do bot é distinto do PAT de configuração. Estas credenciais serão Secrets do GitHub Actions: {names}. Os valores existentes não podem voltar a ser lidos. Reintroduza um PAT do bot para verificar as permissões. Nenhum fluxo de verificação é executado antes de Aplicar.`,"credential.checks":`Verificação concluída para {count} credenciais. O resultado de cada uma aparece abaixo. O terminal contém os detalhes técnicos; o valor de um Secret existente não pode voltar a ser lido.`,"credential.status.valid":`Válida`,"credential.status.invalid":`Inválida`,"credential.status.missing":`Em falta`,"credential.status.unverifiable":`Não verificável sem um novo valor`,"credential.status.not_required":`Não necessária`,"validation.producers":`Selecione 1 a 8 verificações observadas ou introduza os triplos exatos nome|ID da App|workflow.`,"validation.duplicateNames":`Dois produtores fiáveis têm o mesmo nome de check. A cobertura guarda apenas o nome: escolha um ou altere o nome dos jobs de CI.`,"validation.projectStatusVerified":`Abra cada Project escolhido no GitHub e confirme os quatro valores Status exatos. Responda Sim após verificar, ou Não para voltar a escolher Projects.`,"validation.projectStatusRedo":`Os valores Status não foram confirmados. Escolha Projects compatíveis e volte a verificar as opções Status.`,"validation.number":`Introduza um número inteiro não negativo.`,"validation.boolean":`Escolha Sim ou Não.`,"validation.choice":`Escolha uma das opções apresentadas.`,"validation.unknownResource":`Nomes de recursos herdados desconhecidos: {names}. Escolha apenas nomes da lista herdada.`,"validation.unknownWorkflow":`Fluxos de questões desconhecidos: {names}. Escolha apenas os tipos indicados.`,"validation.firstQuestion":`Esta é a primeira pergunta desta ronda. Reveja-a ou cancele a configuração.`,"validation.duplicateProducer":`Esta verificação de confiança foi selecionada duas vezes. Remova a seleção duplicada.`,"validation.savedStatus":`O valor Status guardado não existe em todos os Projects escolhidos. Selecione uma opção da lista.`,"validation.projectIncompatible":`Os Projects escolhidos não partilham qualquer valor Status. Escolha Projects compatíveis ou configure-os separadamente.`,"validation.projectLimit":`Escolha no máximo 10 Projects; separe os números ou URL por vírgulas.`,"validation.projectOwnerNeeded":`Uma URL de Project exige que se conheça o proprietário do repositório; introduza antes o número positivo.`,"validation.projectOwnerMismatch":`Use uma URL de Project do GitHub pertencente a {owner}, sem parâmetros.`,"validation.projectUrl":`Introduza uma URL válida de Project do GitHub ou o respetivo número positivo.`,"validation.projectNumber":`Introduza o número positivo apresentado na URL do Project, não um ID GraphQL PVT_.`,"validation.projectNumberRange":`O número do Project tem de ser um inteiro positivo até 2147483647.`,"validation.projectDuplicate":`O Project {number} foi selecionado mais do que uma vez.`,"validation.fixedIssues":`A sua configuração ativa explicitamente release ou hotfix. Mantenha Issues ativo ou altere primeiro a configuração.`,"validation.fixedWorkflowEnabled":`A sua configuração ativa {kind}. Mantenha esse fluxo selecionado ou altere primeiro a configuração.`,"validation.fixedWorkflowDisabled":`A sua configuração desativa {kind}. Desmarque esse fluxo ou altere primeiro a configuração.`,"validation.unknown":`Esta resposta não foi aceite. Reveja a pergunta e a ajuda; o terminal contém o detalhe técnico.`},fa={es:{All:`Todos`,prompt:`Preguntar antes de crear el enlace`,"create-if-missing":`Crear el enlace si falta`,disabled:`Desactivado`,replace:`Sustituir`,append:`Añadir`,preserve:`Conservar`,info:`Informativa`,low:`Baja`,medium:`Media`,high:`Alta`,smart:`Adaptativa`,default:`Predeterminado`,auto:`Automático`,always:`Reinstalar siempre`,recommend:`Recomendar aprobación`,guarded:`Aprobar solo con garantías`,off:`Desactivado`,check:`Check de CI que exige la cobertura`,numeric:`Informe numérico verificable`,repository:`Repositorio`,organization:`Organización`,selected:`Repositorios seleccionados`,private:`Repositorios privados`,all:`Todos los repositorios`,"production-lineage":`Conservar el linaje de producción`,"canonical-gitflow":`Git-Flow canónico`,manual:`Manual`,"auto-merge":`Fusionar automáticamente`,"merge-queue":`Cola de integración`,"create-only":`Solo crear PR`,direct:`Integración directa`,"sync-branch":`Mediante rama de sincronización`,"prefer-release":`Priorizar la release`,development:`Desarrollo`,both:`Ambos destinos`,"source-only":`Solo rama de origen`,"sync-only":`Solo rama de sincronización`,none:`Ninguna`,close:`Cerrar el issue`,"keep-open":`Mantener abierto`,guided:`Guiado`,compact:`Compacto`,quiet:`Mínimo`,update:`Actualizar el comentario`,milestones:`Publicar en hitos`,feature:`Funcionalidad`,bugfix:`Corrección`,documentation:`Documentación`,chore:`Mantenimiento`,help:`Ayuda o pregunta`,hotfix:`Arreglo urgente`,release:`Release`},fr:{All:`Tous`,prompt:`Demander avant de créer le renvoi`,"create-if-missing":`Créer le renvoi s’il manque`,disabled:`Désactivé`,replace:`Remplacer`,append:`Ajouter`,preserve:`Conserver`,info:`Information`,low:`Faible`,medium:`Moyenne`,high:`Élevée`,smart:`Adaptatif`,default:`Par défaut`,auto:`Automatique`,always:`Toujours réinstaller`,recommend:`Recommander une approbation`,guarded:`Approuver sous garde`,off:`Désactivé`,check:`Vérification CI imposant la couverture`,numeric:`Rapport numérique vérifiable`,repository:`Dépôt`,organization:`Organisation`,selected:`Dépôts sélectionnés`,private:`Dépôts privés`,all:`Tous les dépôts`,"production-lineage":`Préserver la filiation de production`,"canonical-gitflow":`Git-Flow canonique`,manual:`Manuel`,"auto-merge":`Fusionner automatiquement`,"merge-queue":`File de fusion`,"create-only":`Créer la PR uniquement`,direct:`Fusion directe`,"sync-branch":`Par branche de synchronisation`,"prefer-release":`Privilégier la version`,development:`Développement`,both:`Les deux destinations`,"source-only":`Branche source seulement`,"sync-only":`Branche de synchronisation seulement`,none:`Aucune`,close:`Fermer le ticket`,"keep-open":`Garder ouvert`,guided:`Guidé`,compact:`Compact`,quiet:`Minimal`,update:`Mettre le commentaire à jour`,milestones:`Publier aux étapes clés`,feature:`Fonctionnalité`,bugfix:`Correction`,documentation:`Documentation`,chore:`Maintenance`,help:`Aide ou question`,hotfix:`Correctif urgent`,release:`Version`},pt:{All:`Todos`,prompt:`Perguntar antes de criar o apontador`,"create-if-missing":`Criar o apontador se faltar`,disabled:`Desativado`,replace:`Substituir`,append:`Acrescentar`,preserve:`Conservar`,info:`Informação`,low:`Baixa`,medium:`Média`,high:`Alta`,smart:`Adaptativo`,default:`Predefinido`,auto:`Automático`,always:`Reinstalar sempre`,recommend:`Recomendar aprovação`,guarded:`Aprovar sob condições`,off:`Desativado`,check:`Verificação CI que exige cobertura`,numeric:`Relatório numérico verificável`,repository:`Repositório`,organization:`Organização`,selected:`Repositórios selecionados`,private:`Repositórios privados`,all:`Todos os repositórios`,"production-lineage":`Preservar a linhagem de produção`,"canonical-gitflow":`Git-Flow canónico`,manual:`Manual`,"auto-merge":`Integrar automaticamente`,"merge-queue":`Fila de integração`,"create-only":`Criar apenas a PR`,direct:`Integração direta`,"sync-branch":`Através de ramo de sincronização`,"prefer-release":`Priorizar a release`,development:`Desenvolvimento`,both:`Ambos os destinos`,"source-only":`Apenas ramo de origem`,"sync-only":`Apenas ramo de sincronização`,none:`Nenhum`,close:`Fechar a questão`,"keep-open":`Manter aberta`,guided:`Guiado`,compact:`Compacto`,quiet:`Mínimo`,update:`Atualizar o comentário`,milestones:`Publicar nos marcos`,feature:`Funcionalidade`,bugfix:`Correção`,documentation:`Documentação`,chore:`Manutenção`,help:`Ajuda ou pergunta`,hotfix:`Hotfix`,release:`Release`}},pa=new Set([`codex`,`opencode`,`cursor`,`openai`,`anthropic`,`google`,`openrouter`,`local`]);function ma(e,t,n){if(n===`en`||pa.has(t))return t;let r=fa[n];if(e===`issueWorkflows.enabled`&&t.includes(` — `)){let e=t.split(` — `,1)[0];if(r[e])return`${e} — ${r[e]}`}return e===`repository.reconciliationCleanup`&&t===`all`?{es:`Todas las ramas temporales`,fr:`Toutes les branches temporaires`,pt:`Todos os ramos temporários`}[n]:r[t]??t}var ha={en:ca,es:la,fr:ua,pt:da};function ga(e,t){if(!e.copyId)return t===`en`?e.text:Q(`unknownLocalError`,t);let n=ha[t][e.copyId],r=e.copyValues??{},i=e.copyId===`permission.preview`?{...r,issues:(r.issues??``).split(`|`).map(e=>ma(`issueWorkflows.enabled`,e,t)).join(`, `),approval:ma(`pullRequestApproval.mode`,r.approval??``,t),secrets:ma(`storage.secrets.defaultScope`,r.secrets??``,t),variables:ma(`storage.variables.defaultScope`,r.variables??``,t),projects:ma(`projects.ids`,r.projects??``,t)}:r,a=n.replace(/\{([a-zA-Z]\w*)\}/gu,(e,t)=>i[t]??``);return e.copyId!==`credential.checks`||!e.credentialChecks?.length?a:`${a}\n${e.credentialChecks.map(e=>{let n=`credential.status.${e.status}`;return`${e.name}: ${ha[t][n]}`}).join(` +`)}`}var _a={en:ia,es:aa,fr:oa,pt:sa};function va(e,t){return e.replace(/\{([a-zA-Z]\w*)\}/gu,(e,n)=>t[n]??``)}function ya(e,t){if(!e.copyId)return;let n=_a[t][e.copyId],r={...e.copyValues??{}};if(e.copyId===`credential.existing`&&r.status&&[`valid`,`invalid`,`missing`,`unverifiable`,`not_required`].includes(r.status)){let e=`credential.status.${r.status}`;r.status=ha[t][e]}return{title:va(n.title,r),description:va(n.description,r),...n.choices?{choices:n.choices}:{}}}function ba(e,t,n){return ya(e,t)?.choices?.[n]??e.choices[n]}var xa=W(`

    `,1);function Sa(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=Z(t,`view`,8);Tn(()=>(U(o()),n(),ya),()=>{N(a,o()?.outcome===`complete`?Q(`completeTitle`,n()):o()?.outcome===`dry-run`?Q(`previewTitle`,n()):o()?.outcome===`cancelled`?Q(`cancelledTitle`,n()):o()?.outcome?Q(`blockedTitle`,n()):o()?.prompt?.kind===`question`?Q(`choices`,n()):o()?.prompt?.kind===`plan`?Q(`plan`,n()):o()?.prompt?ya(o().prompt,n())?.title??o().prompt.title:Q(`preparing`,n()))}),En(),Y();var s=xa(),c=F(s),l=L(P(c)),u=I(L(l));E(c);var d=L(c,2),f=I(d,!0),p=I(L(d,2),!0);R((e,t,n)=>{K(l,` ${e??``} `),K(u,`${t??``} / 06`),K(f,V(a)),K(p,n)},[()=>(U(qi),U(o()),n(),H(()=>qi(o()?.journey?.current,n()).toUpperCase())),()=>(U(o()),H(()=>String(o()?.journey?.position??1).padStart(2,`0`))),()=>(U(o()),U(Q),n(),H(()=>o()?.outcome?Q(`resultLede`,n()):Q(`activeLede`,n())))]),G(e,s),k(),i()}function Ca(e){try{let t=new URL(e??``);return t.protocol===`https:`&&t.hostname===`github.com`&&(t.pathname===`/settings/personal-access-tokens`||t.pathname.startsWith(`/settings/personal-access-tokens/`))?t.toString():void 0}catch{return}}function wa(e){try{let t=new URL(e??``);return t.protocol===`https:`&&t.hostname===`github.com`&&!t.username&&!t.password&&!t.search&&!t.hash&&/^\/[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}\/actions\/runs\/[1-9][0-9]*$/u.test(t.pathname)?t.toString():void 0}catch{return}}function Ta(e){try{let t=new URL(e??``);return t.protocol===`https:`&&t.hostname===`github.com`&&!t.username&&!t.password&&!t.search&&!t.hash&&/^\/[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}\/rules\/[1-9][0-9]*$/u.test(t.pathname)?t.toString():void 0}catch{return}}function Ea(e){try{let t=new URL(e??``);return t.protocol===`https:`&&t.hostname===`github.com`&&!t.username&&!t.password&&!t.search&&!t.hash&&/^\/(?:orgs|users)\/[A-Za-z0-9-]{1,39}\/projects\/[1-9][0-9]*$/u.test(t.pathname)?t.toString():void 0}catch{return}}var Da=W(``),Oa=W(``);function ka(e,t){let n=Z(t,`label`,8),r=Z(t,`variant`,8,`primary`),i=Z(t,`disabled`,8,!1),a=Z(t,`arrow`,8,!1),o=Z(t,`onClick`,8);var s=Oa(),c=P(s,!0),l=L(c),u=e=>{G(e,Da())};q(l,e=>{a()&&e(u)}),E(s),R(()=>{ui(s,1,si(r())),s.disabled=i(),K(c,n())}),Sr(`click`,s,function(...e){o()?.apply(this,e)}),G(e,s)}Cr([`click`]);var Aa=W(` `),ja=W(`

    `);function Ma(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=Z(t,`tone`,8,`info`),s=Z(t,`title`,8),c=Z(t,`message`,8),l=Z(t,`link`,8,void 0),u=Z(t,`actionLabel`,8,void 0),d=Z(t,`onAction`,8,void 0);Tn(()=>U(l()),()=>{N(a,Ca(l()))}),En(),Y();var f=ja(),p=P(f),m=I(p,!0),h=L(p),g=I(h,!0),_=L(h,2),v=e=>{var t=Aa(),r=I(t,!0);R(e=>{J(t,`href`,V(a)),K(r,e)},[()=>(U(Q),n(),H(()=>Q(`githubLink`,n())))]),G(e,t)};q(_,e=>{V(a)&&e(v)});var y=L(_,2),b=e=>{ka(e,{get label(){return u()},variant:`secondary`,get onClick(){return d()}})};q(y,e=>{u()&&d()&&e(b)}),E(f),R(()=>{ui(f,1,`banner ${o()??``}`),J(f,`role`,o()===`error`?`alert`:`status`),K(m,s()),K(g,c())}),G(e,f),k(),i()}function Na(e){let t=String(e.question.defaultValue),n=t.split(`,`).map(e=>e.trim()).filter(Boolean),r=e.question.kind===`multi-select`?n.includes(`All`)&&e.question.choices?.includes(`All`)?[`All`]:(e.question.choices??[]).filter(e=>e!==`All`&&n.includes(e.split(` — `)[0])):e.question.kind===`scope-overrides`?n:e.question.kind===`producer-select`?t.split(`;`).map(e=>e.trim()).filter(Boolean):e.question.kind===`project-select`?n.filter(t=>e.question.projectCandidates?.some(e=>String(e.number)===t)):[],i=e.question.kind===`project-select`?n.filter(e=>!r.includes(e)).join(`,`):t;return{value:e.question.kind===`producer-select`?``:i,selected:r}}function Pa(e,t){return t===`All`?e.includes(`All`)?[]:[`All`]:e.includes(t)?e.filter(e=>e!==t):[...e.filter(e=>e!==`All`),t]}function Fa(e,t,n){return e.question.kind===`scope-overrides`||e.question.kind===`multi-select`?n.length?n.join(`,`):`none`:e.question.kind===`producer-select`?[...n,...t.split(`;`).map(e=>e.trim()).filter(Boolean)].join(`;`):e.question.kind===`project-select`?[...n,...t.split(`,`).map(e=>e.trim()).filter(Boolean)].join(`,`)||`none`:t}var Ia={issueCreated:`projectTransitionIssueCreated`,pullRequestCreated:`projectTransitionPullRequestCreated`,issueInProgress:`projectTransitionIssueInProgress`,pullRequestInProgress:`projectTransitionPullRequestInProgress`};function La(e){if(e)try{let t=new URL(e);if(t.protocol!==`https:`||t.username||t.password||t.search)return;if(t.hostname===`docs.page`&&t.port===``&&t.pathname.startsWith(`/vypdev/copilot/`)||t.hostname===`docs.github.com`&&t.port===``&&t.pathname.startsWith(`/en/`))return t.href}catch{return}}var Ra=W(`

    `),za=W(`

    `),Ba=W(` `,1);function Va(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=Z(t,`kind`,8),s=Z(t,`status`,8),c=Z(t,`truncated`,8,!1),l={observed:`checksObserved`,"no-recent-runs":`checksNoRecent`,"no-verifiable-checks":`checksNoVerifiable`,"permission-denied":`checksDenied`,unavailable:`checksUnavailable`},u={observed:`projectsObserved`,empty:`projectsEmpty`,"permission-denied":`projectsDenied`,unavailable:`projectsUnavailable`,unsupported:`projectsUnsupported`};Tn(()=>(U(s()),U(o())),()=>{N(a,s()?o()===`checks`?l[s()]:u[s()]:void 0)}),En(),Y();var d=Ba(),f=F(d),p=e=>{var t=Ra(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),V(a),n(),H(()=>Q(V(a),n())))]),G(e,t)};q(f,e=>{V(a)&&e(p)});var m=L(f,2),h=e=>{var t=za(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),U(o()),n(),H(()=>Q(o()===`checks`?`checksDiscoveryScope`:`projectsDiscoveryScope`,n())))]),G(e,t)};q(m,e=>{(s()===`observed`||s()===`empty`||s()===`no-recent-runs`||s()===`no-verifiable-checks`)&&e(h)});var g=L(m,2),_=e=>{var t=za(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`discoveryTruncated`,n())))]),G(e,t)};q(g,e=>{c()&&e(_)}),G(e,d),k(),i()}var Ha={en:{success:`Passed`,failure:`Failed`,cancelled:`Cancelled`,neutral:`Neutral`,skipped:`Skipped`,timed_out:`Timed out`,action_required:`Action required`,stale:`Stale`,startup_failure:`Failed to start`,unknown:`Unknown outcome`},es:{success:`Correcto`,failure:`Falló`,cancelled:`Cancelado`,neutral:`Neutral`,skipped:`Omitido`,timed_out:`Agotó el tiempo`,action_required:`Requiere intervención`,stale:`Obsoleto`,startup_failure:`Falló al iniciar`,unknown:`Resultado desconocido`},fr:{success:`Réussi`,failure:`Échoué`,cancelled:`Annulé`,neutral:`Neutre`,skipped:`Ignoré`,timed_out:`Délai dépassé`,action_required:`Action nécessaire`,stale:`Obsolète`,startup_failure:`Échec au démarrage`,unknown:`Résultat inconnu`},pt:{success:`Concluído`,failure:`Falhou`,cancelled:`Cancelado`,neutral:`Neutro`,skipped:`Ignorado`,timed_out:`Tempo esgotado`,action_required:`Ação necessária`,stale:`Obsoleto`,startup_failure:`Falha ao iniciar`,unknown:`Resultado desconhecido`}};function Ua(e,t){return Ha[t][e]??Ha[t].unknown}function Wa(e,t,n){let r=e.trim(),i=n.trim(),a=String(t??``).trim(),o=Number(a);if(!(!r||!i||r.length>100||i.length>100||/[|;\r\n]/u.test(r+i)||!/^[1-9]\d*$/u.test(a)||!Number.isSafeInteger(o)))return`${r}|${o}|${i}`}var Ga=W(` `),Ka=W(`
    `),qa=W(`
  • `),Ja=W(`
      `),Ya=W(``),Xa=W(`

      `,1);function Za(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=M(),s=Z(t,`candidates`,8),c=Z(t,`selected`,12),l=Z(t,`controller`,8),u=M(``),d=M(``),f=M(``),p=M(!1);function m(){let e=Wa(V(u),V(d),V(f));if(!e||c().length>=8){N(p,!0);return}N(p,!1),c().includes(e)||c([...c(),e]),N(u,``),N(d,``),N(f,``)}Tn(()=>U(s()),()=>{N(a,new Set(s().map(e=>`${e.name}|${e.sourceAppId}|${e.workflowName}`)))}),Tn(()=>(U(c()),V(a)),()=>{N(o,c().filter(e=>!V(a).has(e)))}),En(),Y();var h=Xa(),g=F(h);Qr(g,5,s,Jr,(e,t)=>{let r=A(()=>(V(t),H(()=>`${V(t).name}|${V(t).sourceAppId}|${V(t).workflowName}`)));var i=Ka(),a=P(i),o=P(a);xi(o);var s=L(o,2),u=P(s),d=I(P(u),!0);E(u);var f=L(u),p=I(P(f));E(f);var m=I(L(f),!0);E(s),E(a);var h=L(a,2),g=e=>{var r=Ga(),i=I(r,!0);R((e,t)=>{J(r,`href`,e),K(i,t)},[()=>(U(wa),V(t),H(()=>wa(V(t).runUrl))),()=>(U(Q),n(),H(()=>Q(`ciRun`,n())))]),G(e,r)},_=gt(()=>(U(wa),V(t),H(()=>wa(V(t).runUrl))));q(h,e=>{V(_)&&e(g)});var v=L(h,2),y=e=>{var r=Ga(),i=I(r,!0);R((e,t)=>{J(r,`href`,e),K(i,t)},[()=>(U(Ta),V(t),H(()=>Ta(V(t).requiredByRuleset?.sourceUrl))),()=>(U(Q),n(),H(()=>Q(`ciRule`,n())))]),G(e,r)},b=gt(()=>(U(Ta),V(t),H(()=>Ta(V(t).requiredByRuleset?.sourceUrl))));q(v,e=>{V(b)&&e(y)}),E(i),R((e,n,r,i,a,s)=>{Si(o,e),o.disabled=n,K(d,(V(t),H(()=>V(t).name))),K(p,`${V(t),H(()=>V(t).workflowName)??``} · ${V(t),H(()=>V(t).sourceAppName??`GitHub App`)??``} ${V(t),H(()=>V(t).sourceAppId)??``} · ${r??``} · ${i??``} · ${a??``}`),K(m,s)},[()=>(U(c()),U(V(r)),H(()=>c().includes(V(r)))),()=>(U(l()),U(c()),U(V(r)),H(()=>!l()||c().length>=8&&!c().includes(V(r)))),()=>(U(Ua),V(t),n(),H(()=>Ua(V(t).conclusion,n()))),()=>(V(t),H(()=>V(t).headSha.slice(0,7))),()=>(V(t),U(Q),n(),H(()=>V(t).observedAt??Q(`observationTimeUnknown`,n()))),()=>(V(t),U(Q),n(),H(()=>V(t).requiredByRuleset?Q(`branchRequirementObserved`,n(),{branch:V(t).requiredByRuleset.branch}):Q(`branchRequirementUnknown`,n())))]),Sr(`change`,o,()=>c(Pa(c(),V(r)))),G(e,i)}),E(g);var _=L(g,2),v=e=>{var t=Ja();Qr(t,5,()=>V(o),Jr,(e,t)=>{var r=qa(),i=P(r),a=I(i,!0),o=L(i,2),s=I(o,!0);E(r),R(e=>{K(a,V(t)),o.disabled=!l(),K(s,e)},[()=>(U(Q),n(),H(()=>Q(`producerRemove`,n())))]),Sr(`click`,o,()=>c(Pa(c(),V(t)))),G(e,r)}),E(t),G(e,t)};q(_,e=>{V(o),H(()=>V(o).length)&&e(v)});var y=L(_,2),b=I(y,!0),x=L(y,2),S=P(x),C=P(S,!0),ee=L(C);xi(ee),E(S);var te=L(S,2),ne=P(te,!0),re=L(ne);xi(re),E(te);var ie=L(te,2),ae=P(ie,!0),oe=L(ae);xi(oe),E(ie),E(x);var se=L(x,2),ce=I(se,!0),le=L(se,2),ue=e=>{var t=Ya(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`producerManualInvalid`,n())))]),G(e,t)};q(le,e=>{V(p)&&e(ue)}),R((e,t,n,r,i,a)=>{J(g,`aria-label`,e),K(b,t),K(C,n),ee.disabled=!l(),K(ne,r),re.disabled=!l(),K(ae,i),oe.disabled=!l(),se.disabled=!l(),K(ce,a)},[()=>(U(Q),n(),H(()=>Q(`checksObserved`,n()))),()=>(U(Q),n(),H(()=>Q(`producerManualHelp`,n()))),()=>(U(Q),n(),H(()=>Q(`producerName`,n()))),()=>(U(Q),n(),H(()=>Q(`producerAppId`,n()))),()=>(U(Q),n(),H(()=>Q(`producerWorkflow`,n()))),()=>(U(Q),n(),H(()=>Q(`producerAdd`,n())))]),Ei(ee,()=>V(u),e=>N(u,e)),Ei(re,()=>V(d),e=>N(d,e)),Ei(oe,()=>V(f),e=>N(f,e)),Sr(`click`,se,m),G(e,h),k(),i()}Cr([`change`,`click`]);var Qa=W(` `),$a=W(`
      `),eo=W(`
    • `),to=W(`

        `,1),no=W(`
        `,1);function ro(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=M(),s=Z(t,`candidates`,8),c=Z(t,`selected`,12),l=Z(t,`value`,12),u=Z(t,`controller`,8);Tn(()=>U(s()),()=>{N(a,new Set(s().map(e=>String(e.number))))}),Tn(()=>(U(c()),V(a)),()=>{N(o,c().filter(e=>!V(a).has(e)))}),En(),Y();var d=no(),f=F(d);Qr(f,5,s,Jr,(e,t)=>{var r=$a(),i=P(r),a=P(i);xi(a);var o=L(a,2),s=P(o),l=I(P(s),!0);E(s);var d=L(s),f=I(P(d));E(d),E(o),E(i);var p=L(i,2),m=e=>{var r=Qa(),i=I(r,!0);R((e,t)=>{J(r,`href`,e),K(i,t)},[()=>(U(Ea),V(t),H(()=>Ea(V(t).url))),()=>(U(Q),n(),H(()=>Q(`projectUrl`,n())))]),G(e,r)},h=gt(()=>(U(Ea),V(t),H(()=>Ea(V(t).url))));q(p,e=>{V(h)&&e(m)}),E(r),R((e,n)=>{Si(a,e),a.disabled=n,K(l,(V(t),H(()=>V(t).title))),K(f,`${V(t),H(()=>V(t).owner)??``} · #${V(t),H(()=>V(t).number)??``}`)},[()=>(U(c()),V(t),H(()=>c().includes(String(V(t).number)))),()=>(U(u()),U(c()),V(t),H(()=>!u()||c().length>=10&&!c().includes(String(V(t).number))))]),Sr(`change`,a,()=>c(Pa(c(),String(V(t).number)))),G(e,r)}),E(f);var p=L(f,2),m=e=>{var t=to(),r=F(t),i=I(r,!0),a=L(r,2);Qr(a,5,()=>V(o),Jr,(e,t)=>{var r=eo(),i=P(r),a=I(i),o=L(i,2),s=I(o,!0);E(r),R(e=>{K(a,`#${V(t)??``}`),o.disabled=!u(),K(s,e)},[()=>(U(Q),n(),H(()=>Q(`removeSelection`,n())))]),Sr(`click`,o,()=>c(Pa(c(),V(t)))),G(e,r)}),E(a),R(e=>K(i,e),[()=>(U(Q),n(),H(()=>Q(`projectSelectionNotObserved`,n())))]),G(e,t)};q(p,e=>{V(o),H(()=>V(o).length)&&e(m)});var h=L(p,2),g=I(h,!0),_=L(h,2);xi(_),R((e,t)=>{J(f,`aria-label`,e),K(g,t),_.disabled=!u()},[()=>(U(Q),n(),H(()=>Q(`projectsObserved`,n()))),()=>(U(Q),n(),H(()=>Q(`projectManual`,n())))]),Ei(_,l),G(e,d),k(),i()}Cr([`change`,`click`]);var io=W(``),ao=W(`
        `),oo=W(` `,1);function so(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`explanation`,8),o=Z(t,`helpUrl`,8);Y();var s=oo(),c=F(s),l=e=>{var t=io(),r=P(t),i=I(r);E(t),R(e=>{J(r,`href`,o()),K(i,`${e??``} ↗`)},[()=>(U(Q),n(),H(()=>Q(`learnMore`,n())))]),G(e,t)};q(c,e=>{o()&&e(l)});var u=L(c,2),d=e=>{var t=ao(),r=P(t),i=I(r,!0),o=L(r),s=P(o),c=I(s,!0),l=L(s),u=I(l,!0),d=L(l,2),f=I(d,!0),p=L(d),m=I(p,!0),h=L(p,2),g=I(h,!0),_=L(h),v=I(_,!0),y=L(_,2),b=I(y,!0),x=L(y),S=I(x,!0),C=L(x,2),ee=I(C,!0),te=L(C),ne=I(te,!0),re=L(te,2),ie=I(re,!0),ae=L(re),oe=I(ae,!0),se=L(ae,2),ce=I(se,!0),le=I(L(se),!0);E(o),E(t),R((e,t,n,r,o,s,l,d)=>{K(i,e),K(c,t),K(u,(U(a()),H(()=>a().when))),K(f,n),K(m,(U(a()),H(()=>a().where))),K(g,r),K(v,(U(a()),H(()=>a().how))),K(b,o),K(S,(U(a()),H(()=>a().why))),K(ee,s),K(ne,(U(a()),H(()=>a().example))),K(ie,l),K(oe,(U(a()),H(()=>a().effect))),K(ce,d),K(le,(U(a()),H(()=>a().verify)))},[()=>(U(Q),n(),H(()=>Q(`whyMatters`,n()))),()=>(U(Q),n(),H(()=>Q(`whenApplies`,n()))),()=>(U(Q),n(),H(()=>Q(`whereConfigured`,n()))),()=>(U(Q),n(),H(()=>Q(`howToChoose`,n()))),()=>(U(Q),n(),H(()=>Q(`whyRecommendation`,n()))),()=>(U(Q),n(),H(()=>Q(`example`,n()))),()=>(U(Q),n(),H(()=>Q(`effect`,n()))),()=>(U(Q),n(),H(()=>Q(`verify`,n())))]),G(e,t)};q(u,e=>{a()&&e(d)}),G(e,s),k(),i()}var co=W(`· `,1),lo=W(`

        `,1);function uo(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`candidate`,8);Y();var o=lo(),s=F(o),c=P(s),l=I(c),u=L(c,2),d=e=>{var t=co(),r=L(F(t)),i=I(r,!0);R((e,t)=>{J(r,`href`,e),K(i,t)},[()=>(U(wa),U(a()),H(()=>wa(a().runUrl))),()=>(U(Q),n(),H(()=>Q(`ciRun`,n())))]),G(e,t)},f=gt(()=>(U(wa),U(a()),H(()=>wa(a().runUrl))));q(u,e=>{V(f)&&e(d)}),E(s);var p=L(s,2),m=P(p),h=I(m),g=L(m),_=L(g),v=e=>{var t=co(),r=L(F(t)),i=I(r,!0);R((e,t)=>{J(r,`href`,e),K(i,t)},[()=>(U(Ta),U(a()),H(()=>Ta(a().requiredByRuleset?.sourceUrl))),()=>(U(Q),n(),H(()=>Q(`ciRule`,n())))]),G(e,t)},y=gt(()=>(U(Ta),U(a()),H(()=>Ta(a().requiredByRuleset?.sourceUrl))));q(_,e=>{V(y)&&e(v)}),E(p),R((e,t,n,r)=>{K(l,`${U(a()),H(()=>a().workflowName)??``} · GitHub App ${U(a()),H(()=>a().sourceAppId)??``}`),K(h,`${e??``} · ${t??``} · ${n??``}`),K(g,` · ${r??``} `)},[()=>(U(Ua),U(a()),n(),H(()=>Ua(a().conclusion,n()))),()=>(U(a()),H(()=>a().headSha.slice(0,7))),()=>(U(a()),U(Q),n(),H(()=>a().observedAt??Q(`observationTimeUnknown`,n()))),()=>(U(a()),U(Q),n(),H(()=>a().requiredByRuleset?Q(`branchRequirementObserved`,n(),{branch:a().requiredByRuleset.branch}):Q(`branchRequirementUnknown`,n())))]),G(e,o),k(),i()}var fo=W(`

        `);function po(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`question`,8);Y();var o=Mr(),s=F(o),c=e=>{var t=fo(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`fixedIssuesRequired`,n())))]),G(e,t)},l=e=>{var t=Mr();Qr(F(t),1,()=>(U(a()),H(()=>Object.entries(a().fixedWorkflowFeatures).filter(([,e])=>e!==void 0))),Jr,(e,t)=>{var r=gt(()=>b(V(t),2));let i=()=>V(r)[0],a=()=>V(r)[1];var o=fo(),s=I(o,!0);R(e=>K(s,e),[()=>(U(Q),a(),n(),i(),H(()=>Q(a()?`fixedWorkflowEnabled`:`fixedWorkflowDisabled`,n(),{kind:i()})))]),G(e,o)}),G(e,t)};q(s,e=>{U(a()),H(()=>a().id===`features.issues`&&(a().fixedWorkflowFeatures?.release||a().fixedWorkflowFeatures?.hotfix))?e(c):(U(a()),H(()=>a().fixedWorkflowFeatures)&&e(l,1))}),G(e,o),k(),i()}var mo=W(` `),ho=W(`

        `),go=W(`

        `),_o=W(` `,1),vo=W(`

        `),yo=W(`
        `),bo=W(`

        `),xo=W(``),So=W(`
      • `),Co=W(`
          `),wo=W(`
          `),To=W(``),Eo=W(``),Do=W(` `,1),Oo=W(``),ko=W(`
          `),Ao=W(``),jo=W(``),Mo=W(`

          `,1);function No(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=M(),s=M(),c=Z(t,`prompt`,8),l=Z(t,`controller`,8),u=Z(t,`busy`,8),d=Z(t,`onSubmit`,8),f=Z(t,`onRetryDiscovery`,8),p=Z(t,`onBack`,8),m=Na(c()),h=M(m.value),g=M(m.selected);Tn(()=>(U(c()),n()),()=>{N(a,c().presentation?.[n()])}),Tn(()=>V(a),()=>{N(o,La(V(a)?.documentation?.url))}),Tn(()=>(U(c()),n(),ma),()=>{N(s,typeof c().question.defaultValue==`boolean`?Q(c().question.defaultValue?`yes`:`no`,n()):c().question.defaultValue===``?Q(`none`,n()):c().question.kind===`choice`||c().question.kind===`multi-select`?ma(c().question.id,String(c().question.defaultValue),n()):String(c().question.defaultValue))}),En(),Y();var _=Mo(),v=F(_),y=P(v),b=I(y,!0),x=L(y),S=e=>{var t=mo(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`permissionPreview`,n())))]),G(e,t)};q(x,e=>{U(c()),H(()=>c().phase===`permission-intent`)&&e(S)}),E(v);var C=L(v,2),ee=e=>{var t=ho(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),U(c()),H(()=>Q(`questionProgress`,n(),{current:String(c().progress.groupPosition),total:String(c().progress.groupTotal),overall:String(c().progress.position),all:String(c().progress.total)})))]),G(e,t)};q(C,e=>{U(c()),H(()=>c().progress)&&e(ee)});var te=L(C,2),ne=e=>{var t=go(),n=I(t,!0);R(()=>K(n,(V(a),H(()=>V(a).summary)))),G(e,t)};q(te,e=>{V(a)&&e(ne)});var re=L(te,2),ie=e=>{Va(e,{kind:`checks`,get status(){return U(c()),H(()=>c().question.discoveryStatus)},get truncated(){return U(c()),H(()=>c().question.discoveryTruncated)}})};q(re,e=>{U(c()),H(()=>c().question.id===`pullRequestApproval.testChecks`)&&e(ie)});var ae=L(re,2),oe=e=>{Va(e,{kind:`projects`,get status(){return U(c()),H(()=>c().question.discoveryStatus)},get truncated(){return U(c()),H(()=>c().question.discoveryTruncated)}})};q(ae,e=>{U(c()),H(()=>c().question.id===`projects.ids`)&&e(oe)});var se=L(ae,2),ce=e=>{var t=yo(),r=P(t),i=e=>{var t=_o(),r=F(t),i=I(r,!0),a=I(L(r,2),!0);R((e,t)=>{r.disabled=!l()||u(),K(i,e),K(a,t)},[()=>(U(Q),n(),H(()=>Q(`retryDiscovery`,n()))),()=>(U(Q),n(),U(c()),H(()=>Q(`retryRemaining`,n(),{count:String(c().question.discoveryRetryRemaining)})))]),Sr(`click`,r,function(...e){f()?.apply(this,e)}),G(e,t)},a=e=>{var t=vo(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`retryExhausted`,n())))]),G(e,t)};q(r,e=>{U(c()),H(()=>c().question.discoveryRetryRemaining>0)?e(i):e(a,-1)}),E(t),G(e,t)};q(se,e=>{U(c()),H(()=>c().question.discoveryRetryRemaining!==void 0)&&e(ce)});var le=L(se,2),ue=e=>{var t=bo(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`projectStatusUnavailable`,n())))]),G(e,t)};q(le,e=>{U(c()),H(()=>c().question.statusOptionState===`unavailable`)&&e(ue)});var de=L(le,2),fe=e=>{var t=xo(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`projectStatusIncompatible`,n())))]),G(e,t)};q(de,e=>{U(c()),H(()=>c().question.statusOptionState===`incompatible`)&&e(fe)});var pe=L(de,2),me=e=>{var t=vo(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`projectSharedStatus`,n())))]),G(e,t)};q(pe,e=>{U(c()),H(()=>c().question.id===`projects.ids`)&&e(me)});var he=L(pe,2);po(he,{get question(){return U(c()),H(()=>c().question)}});var ge=L(he,2),_e=e=>{var t=Co();Qr(t,5,()=>(U(c()),H(()=>c().question.projectStatusValues)),Jr,(e,t)=>{var r=So(),i=P(r),a=L(i),o=I(P(a),!0);E(a),E(r),R(e=>{K(i,`${e??``}: `),K(o,(V(t),H(()=>V(t).value)))},[()=>(U(Q),U(Ia),V(t),n(),H(()=>Q(Ia[V(t).transition],n())))]),G(e,r)}),E(t),G(e,t)};q(ge,e=>{U(c()),H(()=>c().question.projectStatusValues)&&e(_e)});var ve=L(ge,2),ye=e=>{var t=wo(),r=P(t);let i;var o=I(r,!0),s=L(r);let u;var d=I(s,!0);E(t),R((e,n)=>{J(t,`aria-label`,(V(a),U(c()),H(()=>V(a)?.label??c().question.label))),J(r,`aria-pressed`,V(h)===`yes`||V(h)===`true`),r.disabled=!l(),i=ui(r,1,``,null,i,{selected:V(h)===`yes`||V(h)===`true`}),K(o,e),J(s,`aria-pressed`,V(h)===`no`||V(h)===`false`),s.disabled=!l(),u=ui(s,1,``,null,u,{selected:V(h)===`no`||V(h)===`false`}),K(d,n)},[()=>(U(Q),n(),H(()=>Q(`yes`,n()))),()=>(U(Q),n(),H(()=>Q(`no`,n())))]),Sr(`click`,r,()=>N(h,`yes`)),Sr(`click`,s,()=>N(h,`no`)),G(e,t)},be=e=>{var t=Do(),r=F(t),i=P(r),o=e=>{var t=To(),r=I(t,!0);t.value=t.__value=``,R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`selectOne`,n())))]),G(e,t)};q(i,e=>{U(c()),H(()=>!c().question.defaultValue)&&e(o)}),Qr(L(i),1,()=>(U(c()),H(()=>c().question.choices??[])),Jr,(e,t)=>{let r=A(()=>(U(c()),V(t),H(()=>c().question.id===`pullRequestApproval.coverage.checkName`?c().question.trustedProducers?.find(e=>e.name===V(t)):void 0)));var i=Eo(),a=I(i,!0),o={};R(e=>{K(a,e),o!==(o=V(t))&&(i.value=(i.__value=o)??``)},[()=>(U(V(r)),V(t),U(ma),U(c()),n(),H(()=>V(r)?`${V(t)} — ${V(r).workflowName} · App ${V(r).sourceAppId}`:ma(c().question.id,V(t),n())))]),G(e,i)}),E(r),mi(r);var s=L(r,2),u=e=>{var t=Mr();Qr(F(t),1,()=>(U(c()),V(h),H(()=>(c().question.producerCandidates??[]).filter(e=>e.name===V(h)))),Jr,(e,t)=>{uo(e,{get candidate(){return V(t)}})}),G(e,t)};q(s,e=>{U(c()),H(()=>c().question.id===`pullRequestApproval.coverage.checkName`)&&e(u)}),R(()=>{J(r,`aria-label`,(V(a),U(c()),H(()=>V(a)?.label??c().question.label))),r.disabled=!l()}),hi(r,()=>V(h),e=>N(h,e)),G(e,t)},xe=e=>{{let t=A(()=>(U(c()),H(()=>c().question.producerCandidates??[])));Za(e,{get candidates(){return V(t)},get controller(){return l()},get selected(){return V(g)},set selected(e){N(g,e)},$$legacy:!0})}},Se=e=>{{let t=A(()=>(U(c()),H(()=>c().question.projectCandidates??[])));ro(e,{get candidates(){return V(t)},get controller(){return l()},get selected(){return V(g)},set selected(e){N(g,e)},get value(){return V(h)},set value(e){N(h,e)},$$legacy:!0})}},Ce=e=>{var t=ko();Qr(t,5,()=>(U(c()),H(()=>c().question.kind===`multi-select`?c().question.choices??[]:c().question.allowedNames??[])),Jr,(e,t)=>{var r=Oo(),i=P(r);xi(i);var a=I(L(i),!0);E(r),R((e,t)=>{Si(i,e),i.disabled=!l(),K(a,t)},[()=>(V(g),V(t),H(()=>V(g).includes(V(t)))),()=>(U(c()),U(ma),V(t),n(),H(()=>c().question.kind===`multi-select`?ma(c().question.id,V(t),n()):V(t)))]),Sr(`change`,i,()=>N(g,Pa(V(g),V(t)))),G(e,r)}),E(t),R(()=>J(t,`aria-label`,(V(a),U(c()),H(()=>V(a)?.label??c().question.label)))),G(e,t)},we=e=>{var t=Mr(),n=F(t),r=e=>{var t=Ao();it(t),R(()=>{J(t,`aria-label`,(V(a),U(c()),H(()=>V(a)?.label??c().question.label))),t.disabled=!l()}),Ei(t,()=>V(h),e=>N(h,e)),G(e,t)},i=e=>{var t=jo();xi(t),R(()=>{J(t,`aria-label`,(V(a),U(c()),H(()=>V(a)?.label??c().question.label))),J(t,`type`,(U(c()),H(()=>c().question.kind===`number`?`number`:`text`))),J(t,`min`,(U(c()),H(()=>c().question.kind===`number`?0:void 0))),t.disabled=!l()}),Ei(t,()=>V(h),e=>N(h,e)),G(e,t)};q(n,e=>{U(c()),H(()=>c().question.id===`ai.bugbotOrganizationRules`)?e(r):e(i,-1)}),G(e,t)};q(ve,e=>{U(c()),H(()=>c().question.kind===`boolean`)?e(ye):(U(c()),H(()=>c().question.kind===`choice`)?e(be,1):(U(c()),H(()=>c().question.kind===`producer-select`)?e(xe,2):(U(c()),H(()=>c().question.kind===`project-select`)?e(Se,3):(U(c()),H(()=>c().question.kind===`multi-select`||c().question.kind===`scope-overrides`)?e(Ce,4):e(we,-1)))))});var Te=L(ve,2),w=I(Te,!0),Ee=L(Te,2),T=e=>{var t=ho(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),U(c()),n(),H(()=>Q(c().question.suggestionSource===`github`?`sourceGithub`:c().question.suggestionSource===`local`?`sourceLocal`:c().question.suggestionSource===`configuration`?`sourceConfig`:`sourceDefault`,n())))]),G(e,t)};q(Ee,e=>{U(c()),H(()=>c().question.suggestionSource)&&e(T)});var De=L(Ee,2);so(De,{get explanation(){return V(a)},get helpUrl(){return V(o)}});var Oe=L(De,2),ke=P(Oe),Ae=e=>{{let t=A(()=>(U(Q),n(),H(()=>Q(`previousQuestion`,n())))),r=A(()=>!l()||u());ka(e,{get label(){return V(t)},variant:`secondary`,get onClick(){return p()},get disabled(){return V(r)}})}};q(ke,e=>{U(c()),H(()=>c().canGoBack)&&e(Ae)});var je=L(ke,2);{let e=A(()=>(U(Q),n(),H(()=>Q(`continue`,n())))),t=A(()=>!l()||u());ka(je,{get label(){return V(e)},arrow:!0,onClick:()=>d()(Fa(c(),V(h),V(g))),get disabled(){return V(t)}})}E(Oe),R((e,t)=>{K(b,e),K(w,t)},[()=>(V(a),U(c()),H(()=>V(a)?.label??c().question.label.replace(` (Space toggles, Enter confirms)`,``))),()=>(U(Q),n(),V(s),H(()=>Q(`suggested`,n(),{answer:V(s)})))]),G(e,_),k(),i()}Cr([`click`,`change`]);var Po=W(``),Fo=W(`
          `);function Io(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`prompt`,8),o=Z(t,`controller`,8),s=Z(t,`busy`,8),c=Z(t,`onSubmit`,8);Y();var l=Fo();Qr(l,5,()=>(U(a()),H(()=>a().choices)),Jr,(e,t,r)=>{var i=Po(),l=I(P(i),!0);ke(),E(i),R(e=>{i.disabled=!o()||s(),K(l,e)},[()=>(U(ba),U(a()),n(),H(()=>ba(a(),n(),r)))]),Sr(`click`,i,()=>c()(V(t))),G(e,i)}),E(l),G(e,l),k(),i()}Cr([`click`]);var Lo=W(`

          `,1),Ro=W(`

          `),zo=W(` `,1);function Bo(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=Z(t,`prompt`,8),s=Z(t,`controller`,8),c=Z(t,`busy`,8),l=Z(t,`onSubmit`,8),u=M(``);function d(){let e=V(u);o().kind===`secret`&&N(u,``),l()(e)}Tn(()=>U(o()),()=>{N(a,Ca(o().link))}),En(),Y();var f=zo(),p=F(f),m=e=>{var t=Lo(),r=F(t),i=P(r);ke(),E(r);var o=I(L(r),!0);R((e,t)=>{J(r,`href`,V(a)),K(i,`${e??``} `),K(o,t)},[()=>(U(Q),n(),H(()=>Q(`githubForm`,n()))),()=>(U(Q),n(),H(()=>Q(`githubFormHelp`,n())))]),G(e,t)};q(p,e=>{V(a)&&e(m)});var h=L(p,2),g=I(h,!0),_=L(h,2);xi(_);var v=L(_,2),y=e=>{var t=Ro(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`secretHelp`,n())))]),G(e,t)};q(v,e=>{U(o()),H(()=>o().kind===`secret`)&&e(y)});var b=L(v,2);{let e=A(()=>(U(Q),n(),H(()=>Q(`continue`,n())))),t=A(()=>(U(s()),U(c()),U(o()),V(u),H(()=>!s()||c()||!o().optional&&!V(u).trim())));ka(b,{get label(){return V(e)},arrow:!0,onClick:d,get disabled(){return V(t)}})}R((e,t)=>{K(g,e),J(_,`type`,(U(o()),H(()=>o().kind===`secret`?`password`:`text`))),_.disabled=!s(),J(_,`placeholder`,t)},[()=>(U(Q),U(o()),n(),H(()=>Q(o().kind===`secret`?`pasteHere`:`yourAnswer`,n()))),()=>(U(Q),U(o()),n(),H(()=>Q(o().kind===`secret`?`hiddenAfter`:`typeAnswer`,n())))]),Ei(_,()=>V(u),e=>N(u,e)),G(e,f),k(),i()}var Vo={"No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.":`No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.`,"Help / question issues remain branchless even when issue-managed-branches is enabled.":`Help / question issues remain branchless even when issue-managed-branches is enabled.`,"Release automation is installed, but release issue events are disabled by the selected issue workflow profile.":`Release automation is installed, but release issue events are disabled by the selected issue workflow profile.`,"Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.":`Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.`,"Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.":`Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.`,"Release and hotfix workflows require the workflow PAT Secret and a writable token.":`Release and hotfix workflows require the workflow PAT Secret and a writable token.`,"Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.":`Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.`,"Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.":`Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.`,"Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.":`Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.`,"Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.":`Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.`,"Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.":`Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.`,"Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.":`Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.`},Ho={en:Vo,es:{"No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.":`No has activado ningún tipo de flujo de incidencias. Sus eventos no se gestionarán hasta que actives un formulario de incidencia y su entrada en el perfil.`,"Help / question issues remain branchless even when issue-managed-branches is enabled.":`Las incidencias de ayuda y consulta no crean ramas, aunque actives las ramas gestionadas por incidencias.`,"Release automation is installed, but release issue events are disabled by the selected issue workflow profile.":`La automatización de versiones ya está instalada, pero el perfil elegido desactiva los eventos de incidencias de versiones.`,"Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.":`La automatización de correcciones urgentes ya está instalada, pero el perfil elegido desactiva los eventos de sus incidencias.`,"Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.":`Has desactivado la generación de instrucciones para agentes del repositorio. Los colaboradores no recibirán el perfil ni la guía del flujo de trabajo generados.`,"Release and hotfix workflows require the workflow PAT Secret and a writable token.":`Los flujos de versiones y correcciones urgentes requieren el Secret con el PAT de la Action y un token con permisos de escritura.`,"Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.":`La cola de merge bloqueará la operación si cada productor obligatorio no se verifica automáticamente o no dispone de una certificación exacta revisada.`,"Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.":`El modo de provisión permanente solo reinstala los entornos predeterminados de Codex y OpenCode desde paquetes fijados en el manifiesto. No sustituye ejecutables explícitos; Cursor debe estar instalado previamente.`,"Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.":`Está activado el cierre de incidencias inactivas. Las que estén en espera se cerrarán tras el plazo configurado y podrán reabrirse con un comentario.`,"Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.":`El PAT del bot debe tener acceso a los Projects seleccionados, y los cuatro valores de Status configurados deben existir en cada uno de ellos.`,"Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.":`Cursor es un entorno experimental en Copilot. Requiere una CLI compatible ya instalada y CURSOR_API_KEY; Copilot no instala Cursor automáticamente.`,"Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.":`Los Secrets y Variables de organización requieren permisos en ella. Limitar el acceso a los repositorios elegidos es la opción más segura; los valores del repositorio tienen prioridad.`},fr:{"No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.":`Aucun type de flux de travail pour les tickets n’est activé. Leurs événements ne seront pas gérés tant qu’un formulaire de ticket pris en charge et son entrée de profil ne seront pas activés.`,"Help / question issues remain branchless even when issue-managed-branches is enabled.":`Les tickets d’aide ou de question ne créent pas de branche, même lorsque les branches gérées par les tickets sont activées.`,"Release automation is installed, but release issue events are disabled by the selected issue workflow profile.":`L’automatisation des versions est déjà installée, mais le profil choisi désactive les événements des tickets de version.`,"Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.":`L’automatisation des correctifs urgents est déjà installée, mais le profil choisi désactive les événements des tickets correspondants.`,"Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.":`La génération des consignes pour les agents du dépôt est désactivée. Les collaborateurs ne recevront ni le profil ni le guide de flux de travail générés.`,"Release and hotfix workflows require the workflow PAT Secret and a writable token.":`Les flux de version et de correctif urgent nécessitent le Secret contenant le PAT de l’Action et un jeton autorisé à écrire.`,"Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.":`La file de fusion bloque l’opération si chaque producteur requis n’est pas vérifié automatiquement ou couvert par une attestation exacte et examinée.`,"Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.":`Le mode de provisionnement permanent ne réinstalle que les environnements Codex et OpenCode par défaut depuis les paquets verrouillés du manifeste. Il ne remplace jamais les exécutables indiqués explicitement ; Cursor doit être préinstallé.`,"Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.":`La fermeture des tickets inactifs est activée. Les tickets en attente seront fermés après le délai configuré et pourront être rouverts par un nouveau commentaire.`,"Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.":`Le PAT du bot doit avoir accès aux Projects sélectionnés, et les quatre valeurs Status configurées doivent exister dans chacun de ces Projects.`,"Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.":`Cursor est un environnement expérimental dans Copilot. Il nécessite une CLI compatible déjà installée et CURSOR_API_KEY ; Copilot ne l’installe pas automatiquement.`,"Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.":`Les Secrets et Variables d’organisation nécessitent des droits sur celle-ci. Limiter l’accès aux dépôts sélectionnés est le choix le plus sûr ; les valeurs du dépôt prévalent.`},pt:{"No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.":`Não está ativo nenhum tipo de fluxo de trabalho para issues. Os respetivos eventos não serão geridos até ativar um formulário de issue suportado e a entrada correspondente no perfil.`,"Help / question issues remain branchless even when issue-managed-branches is enabled.":`As issues de ajuda ou perguntas não criam ramos, mesmo com os ramos geridos por issues ativados.`,"Release automation is installed, but release issue events are disabled by the selected issue workflow profile.":`A automação de versões já está instalada, mas o perfil escolhido desativa os eventos das issues de versão.`,"Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.":`A automação de correções urgentes já está instalada, mas o perfil escolhido desativa os eventos das respetivas issues.`,"Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.":`A criação de orientações para agentes do repositório está desativada. Os colaboradores não receberão o perfil nem o guia do fluxo de trabalho gerados.`,"Release and hotfix workflows require the workflow PAT Secret and a writable token.":`Os fluxos de versão e correção urgente exigem o Secret com o PAT da Action e um token com permissão de escrita.`,"Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.":`A fila de integração bloqueia a operação se cada produtor obrigatório não for verificado automaticamente ou coberto por uma declaração exata e revista.`,"Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.":`O modo de aprovisionamento permanente reinstala apenas os ambientes padrão de Codex e OpenCode a partir de pacotes fixados no manifesto. Nunca substitui executáveis indicados explicitamente; o Cursor tem de estar pré-instalado.`,"Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.":`O fecho de issues inativas está ativado. As que aguardam resposta serão fechadas após o prazo configurado e poderão ser reabertas com um novo comentário.`,"Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.":`O PAT do bot tem de ter acesso aos Projects selecionados, e os quatro valores de Status configurados têm de existir em cada Project.`,"Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.":`O Cursor é um ambiente experimental no Copilot. Exige uma CLI compatível já instalada e CURSOR_API_KEY; o Copilot não instala o Cursor automaticamente.`,"Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.":`Os Secrets e Variables da organização exigem permissões nessa organização. Restringir o acesso aos repositórios selecionados é a opção mais segura; os valores do repositório prevalecem.`}};function Uo(e,t){let n=Ho[t]??Vo;return Object.prototype.hasOwnProperty.call(n,e)?n[e]:t===`en`?e:Q(`planUnknownWarning`,t)}var Wo={en:{issues:`Issues`,pullRequests:`Pull requests`,commits:`Commits`,issueComments:`Issue comments`,pullRequestComments:`Pull-request comments`,release:`Releases`,hotfix:`Hotfixes`,agentProvisioning:`Agent provisioning`,credentialHealth:`Credential health`,inactiveIssueClosure:`Inactive issue closure`,issueTemplates:`Issue templates`,pullRequestTemplate:`Pull-request template`},es:{issues:`Issues`,pullRequests:`Pull requests`,commits:`Commits`,issueComments:`Comentarios en issues`,pullRequestComments:`Comentarios en pull requests`,release:`Releases`,hotfix:`Correcciones urgentes`,agentProvisioning:`Instalación de agentes`,credentialHealth:`Estado de credenciales`,inactiveIssueClosure:`Cierre de issues inactivos`,issueTemplates:`Plantillas de issues`,pullRequestTemplate:`Plantilla de pull requests`},fr:{issues:`Tickets`,pullRequests:`Pull requests`,commits:`Commits`,issueComments:`Commentaires des tickets`,pullRequestComments:`Commentaires des pull requests`,release:`Versions`,hotfix:`Correctifs urgents`,agentProvisioning:`Installation des agents`,credentialHealth:`État des identifiants`,inactiveIssueClosure:`Fermeture des tickets inactifs`,issueTemplates:`Modèles de tickets`,pullRequestTemplate:`Modèle de pull request`},pt:{issues:`Questões`,pullRequests:`Pull requests`,commits:`Commits`,issueComments:`Comentários nas questões`,pullRequestComments:`Comentários nas pull requests`,release:`Versões`,hotfix:`Correções urgentes`,agentProvisioning:`Instalação de agentes`,credentialHealth:`Estado das credenciais`,inactiveIssueClosure:`Fecho de questões inativas`,issueTemplates:`Modelos de questões`,pullRequestTemplate:`Modelo de pull request`}};function Go(e,t){return Wo[t][e]??e}var Ko={en:{planner:`Planner`,findings:`Findings analyst`,reviewer:`Reviewer`,fixer:`Fixer`,tester:`Tester`},es:{planner:`Planificador`,findings:`Analista de hallazgos`,reviewer:`Revisor`,fixer:`Corrector`,tester:`Probador`},fr:{planner:`Planificateur`,findings:`Analyste des problèmes`,reviewer:`Réviseur`,fixer:`Correcteur`,tester:`Testeur`},pt:{planner:`Planeador`,findings:`Analista de problemas`,reviewer:`Revisor`,fixer:`Corretor`,tester:`Testador`}};function qo(e,t){return Ko[t][e]??e}var Jo=W(`
        • `),Yo=W(`
        • `),Xo=W(`
            `),Zo=W(`· `,1),Qo=W(`
            `,1),$o=W(`
            `,1),es=W(`
              `),ts=W(`

                /

                `);function ns(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`decisions`,8);function o(e){return Q(e===`repository`?`scopeRepository`:e===`organization`?`scopeOrganization`:`scopeDisabled`,n())}function s(e){return Q(e===`guarded`?`approvalGuarded`:e===`recommend`?`approvalRecommend`:`approvalOff`,n())}Y();var c=ts(),l=P(c),u=I(l,!0),d=L(l,2),f=P(d),p=P(f),m=I(p,!0),h=I(L(p),!0);E(f);var g=L(f,2),_=P(g),v=I(_,!0),y=I(L(_),!0);E(g);var b=L(g,2),x=P(b),S=I(x,!0),C=L(x),ee=P(C);Qr(ee,5,()=>(U(a()),H(()=>a().agentRouting)),Jr,(e,t)=>{var r=Jo(),i=P(r),a=I(L(i));E(r),R(e=>{K(i,`${e??``}: `),K(a,`${V(t),H(()=>V(t).provider)??``} · ${V(t),H(()=>V(t).modelProvider)??``}/${V(t),H(()=>V(t).model)??``}`)},[()=>(U(qo),V(t),n(),H(()=>qo(V(t).role,n())))]),G(e,r)}),E(ee),E(C),E(b);var te=L(b,2),ne=P(te),re=I(ne,!0),ie=L(ne),ae=P(ie),oe=I(ae,!0),se=I(L(ae,2),!0);E(ie),E(te);var ce=L(te,2),le=P(ce),ue=I(le,!0),de=I(L(le),!0);E(ce);var fe=L(ce,2),pe=e=>{var t=$o(),r=F(t),i=P(r),o=I(i,!0),s=L(i),c=P(s),l=e=>{var t=Xo();Qr(t,5,()=>(U(a()),H(()=>a().trustedChecks)),Jr,(e,t)=>{var r=Yo(),i=P(r),a=I(i,!0),o=L(i),s=I(L(o),!0);E(r),R(e=>{K(a,(V(t),H(()=>V(t).name))),K(o,` · ${e??``} ${V(t),H(()=>V(t).sourceAppId)??``} · `),K(s,(V(t),H(()=>V(t).workflowName)))},[()=>(U(Q),n(),H(()=>Q(`producerAppId`,n())))]),G(e,r)}),E(t),G(e,t)},u=e=>{var t=jr();R(e=>K(t,e),[()=>(U(Q),n(),H(()=>Q(`none`,n())))]),G(e,t)};q(c,e=>{U(a()),H(()=>a().trustedChecks.length)?e(l):e(u,-1)}),E(s),E(r);var d=L(r,2),f=P(d),p=I(f,!0),m=I(L(f),!0);E(d);var h=L(d,2),g=P(h),_=I(g,!0),v=L(g),y=P(v,!0),b=L(y),x=e=>{var t=Zo(),n=I(L(F(t)),!0);R(()=>K(n,(U(a()),H(()=>a().coverageCheck)))),G(e,t)};q(b,e=>{U(a()),H(()=>a().coverageCheck)&&e(x)}),E(v),E(h);var S=L(h,2),C=e=>{var t=Qo(),r=F(t),i=P(r),o=I(i,!0),s=I(L(i),!0);E(r);var c=L(r,2),l=P(c),u=I(l,!0),d=L(l),f=I(P(d),!0);E(d),E(c);var p=L(c,2),m=P(p),h=I(m,!0),g=I(L(m),!0);E(p),R((e,t,n,r,i,a)=>{K(o,e),K(s,t),K(u,n),K(f,r),K(h,i),K(g,a)},[()=>(U(Q),n(),H(()=>Q(`planCoverageThreshold`,n()))),()=>(U(a()),U(Q),n(),H(()=>a().coverageMinimum===void 0?Q(`none`,n()):`${a().coverageMinimum}%`)),()=>(U(Q),n(),H(()=>Q(`planCoverageReporter`,n()))),()=>(U(a()),U(Q),n(),H(()=>a().coverageArtifactWorkflow||Q(`none`,n()))),()=>(U(Q),n(),H(()=>Q(`planReporterAttested`,n()))),()=>(U(Q),U(a()),n(),H(()=>Q(a().coverageReporterAttested?`yes`:`no`,n())))]),G(e,t)};q(S,e=>{U(a()),H(()=>a().coverageMode===`numeric`)&&e(C)}),R((e,t,n,r,i)=>{K(o,e),K(p,t),K(m,n),K(_,r),K(y,i)},[()=>(U(Q),n(),H(()=>Q(`planTrustedChecks`,n()))),()=>(U(Q),n(),H(()=>Q(`planProducerAttested`,n()))),()=>(U(Q),U(a()),n(),H(()=>Q(a().producerAttested?`yes`:`no`,n()))),()=>(U(Q),n(),H(()=>Q(`planCoverage`,n()))),()=>(U(ma),U(a()),n(),H(()=>ma(`pullRequestApproval.coverage.mode`,a().coverageMode,n())))]),G(e,t)};q(fe,e=>{U(a()),H(()=>a().approvalMode!==`off`)&&e(pe)});var me=L(fe,2),he=P(me),ge=I(he,!0),_e=I(L(he),!0);E(me);var ve=L(me,2),ye=e=>{var t=es(),r=P(t),i=I(r,!0),o=L(r),s=P(o);Qr(s,5,()=>(U(a()),H(()=>a().projectStatuses)),Jr,(e,t)=>{var r=Jo(),i=P(r),a=I(L(i),!0);E(r),R(e=>{K(i,`${e??``}: `),K(a,(V(t),H(()=>V(t).value)))},[()=>(U(Q),U(Ia),V(t),n(),H(()=>Q(Ia[V(t).transition],n())))]),G(e,r)}),E(s),E(o),E(t),R(e=>K(i,e),[()=>(U(Q),n(),H(()=>Q(`planProjectStatuses`,n())))]),G(e,t)};q(ve,e=>{U(a()),H(()=>a().projectNumbers.length)&&e(ye)});var be=L(ve,2),xe=P(be),Se=I(xe,!0),Ce=I(L(xe),!0);E(be);var we=L(be,2),Te=P(we),w=I(Te,!0),Ee=I(L(Te),!0);E(we);var T=L(we,2),De=P(T),Oe=I(De,!0),ke=I(L(De),!0);E(T);var Ae=L(T,2),je=P(Ae),Me=I(je,!0),Ne=I(L(je),!0);E(Ae),E(d);var Pe=I(L(d,2),!0);E(c),R((e,t,n,r,i,o,s,c,l,d,f,p,g,_,b,x,C,ee,te,ne)=>{K(u,e),K(m,t),K(h,n),K(v,r),K(y,i),K(S,o),K(re,s),K(oe,(U(a()),H(()=>a().productionBranch))),K(se,(U(a()),H(()=>a().developmentBranch))),K(ue,c),K(de,l),K(ge,d),K(_e,f),K(Se,p),K(Ce,g),K(w,_),K(Ee,b),K(Oe,x),K(ke,C),K(Me,ee),K(Ne,te),K(Pe,ne)},[()=>(U(Q),n(),H(()=>Q(`planChoices`,n()))),()=>(U(Q),n(),H(()=>Q(`planEnabledCapabilities`,n()))),()=>(U(a()),U(Go),n(),U(Q),H(()=>a().enabledCapabilities.length?a().enabledCapabilities.map(e=>Go(e,n())).join(`, `):Q(`none`,n()))),()=>(U(Q),n(),H(()=>Q(`planIssueWorkflows`,n()))),()=>(U(a()),U(ma),n(),U(Q),H(()=>a().issueWorkflows.length?a().issueWorkflows.map(e=>ma(`issueWorkflows.enabled`,e,n())).join(`, `):Q(`none`,n()))),()=>(U(Q),n(),H(()=>Q(`planAgentRouting`,n()))),()=>(U(Q),n(),H(()=>Q(`planBranchRoles`,n()))),()=>(U(Q),n(),H(()=>Q(`planApprovalMode`,n()))),()=>(U(a()),H(()=>s(a().approvalMode))),()=>(U(Q),n(),H(()=>Q(`editProjects`,n()))),()=>(U(a()),U(Q),n(),H(()=>a().projectNumbers.length?a().projectNumbers.map(e=>`#${e}`).join(`, `):Q(`none`,n()))),()=>(U(Q),n(),H(()=>Q(`planVariableScope`,n()))),()=>(U(a()),H(()=>o(a().variableScope))),()=>(U(Q),n(),H(()=>Q(`planSecretScope`,n()))),()=>(U(a()),H(()=>o(a().secretScope))),()=>(U(Q),n(),H(()=>Q(`planIssueResources`,n()))),()=>(U(Q),n(),H(()=>Q(`planIssueResourcesValue`,n()))),()=>(U(Q),n(),H(()=>Q(`planInitialTag`,n()))),()=>(U(Q),U(a()),n(),H(()=>Q(a().initialTag?`yes`:`no`,n()))),()=>(U(Q),n(),H(()=>Q(`planAdvancedDefaults`,n())))]),G(e,c),k(),i()}var rs=W(`
              • `),is=W(`

                  `),as=W(`
                • `),os=W(`

                    `),ss=W(`

                      `),cs=W(``),ls=W(`

                      `),us=W(`

                      `,1);function ds(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=Z(t,`prompt`,8),s=Z(t,`controller`,8),c=Z(t,`busy`,8),l=Z(t,`onSubmit`,8),u={capabilities:`editCapabilities`,"agent-runtime":`editRuntimes`,"agent-model-defaults":`editModels`,"agent-role-overrides":`editRoleModels`,repository:`editRepository`,deployment:`editDeployment`,bugbot:`editBugbot`,"pull-request-approval":`editApproval`,projects:`editProjects`,provisioning:`editProvisioning`,storage:`editStorage`};Tn(()=>(n(),U(o())),()=>{N(a,[{title:Q(`files`,n()),items:o().plan.files},{title:Q(`workflows`,n()),items:o().plan.workflows},{title:Q(`variables`,n()),items:o().plan.variables},{title:Q(`secretNames`,n()),items:o().plan.secrets}])}),En(),Y();var d=us(),f=F(d),p=I(f,!0),m=L(f,2);ns(m,{get decisions(){return U(o()),H(()=>o().plan.decisions)}});var h=L(m,2),g=e=>{var t=is(),r=P(t),i=I(r,!0),a=L(r,2);Qr(a,5,()=>(U(o()),H(()=>o().plan.presentationDefaults)),Jr,(e,t)=>{var r=rs(),i=I(r);R(e=>K(i,`${e??``}: ${V(t),H(()=>V(t).count)??``}`),[()=>(V(t),U(Q),n(),H(()=>u[V(t).group]?Q(u[V(t).group],n()):V(t).group))]),G(e,r)}),E(a),E(t),R(e=>K(i,e),[()=>(U(Q),n(),H(()=>Q(`planBasicDefaultsIntro`,n())))]),G(e,t)};q(h,e=>{U(o()),H(()=>o().plan.presentationDefaults.length)&&e(g)});var _=L(h,2);Qr(_,5,()=>V(a),Jr,(e,t)=>{var n=os(),r=P(n),i=P(r),a=I(L(i),!0);E(r);var o=L(r);Qr(o,5,()=>(V(t),H(()=>V(t).items)),Jr,(e,t)=>{var n=as(),r=I(P(n),!0);E(n),R(()=>K(r,V(t))),G(e,n)}),E(o),E(n),R(()=>{K(i,`${V(t),H(()=>V(t).title)??``} `),K(a,(V(t),H(()=>V(t).items.length)))}),G(e,n)}),E(_);var v=L(_,2),y=e=>{var t=ss(),r=P(t),i=I(r,!0),a=L(r);Qr(a,5,()=>(U(o()),H(()=>o().plan.warnings)),Jr,(e,t)=>{var r=rs(),i=I(r,!0);R(e=>K(i,e),[()=>(U(Uo),V(t),n(),H(()=>Uo(V(t),n())))]),G(e,r)}),E(a),E(t),R(e=>K(i,e),[()=>(U(Q),n(),H(()=>Q(`beforeContinue`,n())))]),G(e,t)};q(v,e=>{U(o()),H(()=>o().plan.warnings.length)&&e(y)});var b=L(v,2),x=e=>{var t=ls(),r=P(t),i=I(r,!0),a=L(r),d=I(a,!0),f=L(a,2);Qr(f,5,()=>(U(o()),H(()=>o().editGroups)),Jr,(e,t)=>{var r=cs(),i=I(r,!0);R(e=>{r.disabled=!s()||c(),K(i,e)},[()=>(U(Q),n(),V(t),H(()=>Q(`changeSection`,n(),{section:Q(u[V(t)],n())})))]),Sr(`click`,r,()=>l()(`revise:${V(t)}`)),G(e,r)}),E(f),E(t),R((e,t)=>{K(i,e),K(d,t)},[()=>(U(Q),n(),H(()=>Q(`changeAnswersTitle`,n()))),()=>(U(Q),n(),H(()=>Q(`changeAnswersHelp`,n())))]),G(e,t)};q(b,e=>{U(o()),H(()=>o().editGroups?.length)&&e(x)});var S=L(b,2),C=P(S);{let e=A(()=>(U(Q),n(),H(()=>Q(`stopHere`,n())))),t=A(()=>!s()||c());ka(C,{get label(){return V(e)},variant:`secondary`,onClick:()=>l()(`decline`),get disabled(){return V(t)}})}var ee=L(C);{let e=A(()=>(U(Q),n(),H(()=>Q(`approvePlan`,n())))),t=A(()=>!s()||c());ka(ee,{get label(){return V(e)},arrow:!0,onClick:()=>l()(`approve`),get disabled(){return V(t)}})}E(S),R(e=>K(p,e),[()=>(U(Q),n(),H(()=>Q(`planBody`,n())))]),G(e,d),k(),i()}Cr([`click`]);function fs(e,t){let n=t,r=!0,i=()=>queueMicrotask(()=>{r&&e.isConnected&&e.focus({preventScroll:!0})});return i(),{update(e){e!==n&&(n=e,i())},destroy(){r=!1}}}var ps=W(`

                      `),ms=W(`

                      `),hs=W(`
                      `);function gs(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=Z(t,`prompt`,8),s=Z(t,`revision`,8),c=Z(t,`promptRevision`,8),l=Z(t,`controller`,8),u=Z(t,`busy`,8),d=Z(t,`onSubmit`,8),f=Z(t,`onRetryDiscovery`,8),p=Z(t,`onBack`,8);Tn(()=>(U(o()),n()),()=>{N(a,ya(o(),n()))}),En(),Y();var m=hs(),h=P(m),g=P(h),_=I(g,!0),v=I(L(g));E(h);var y=L(h,2),b=e=>{var t=ps(),n=I(t,!0);R(()=>K(n,(V(a),U(o()),H(()=>V(a)?.title??o().title)))),G(e,t)};q(y,e=>{U(o()),H(()=>o().kind!==`question`)&&e(b)});var x=L(y,2),S=e=>{var t=ms(),n=I(t,!0);R(()=>K(n,(V(a),U(o()),H(()=>V(a)?.description??(`description`in o()?o().description:``))))),G(e,t)};q(x,e=>{V(a),U(o()),H(()=>V(a)?.description||`description`in o()&&o().description)&&e(S)}),qr(L(x,2),c,e=>{var t=Mr(),n=F(t),r=e=>{No(e,{get prompt(){return o()},get controller(){return l()},get busy(){return u()},get onSubmit(){return d()},get onRetryDiscovery(){return f()},get onBack(){return p()}})},i=e=>{Io(e,{get prompt(){return o()},get controller(){return l()},get busy(){return u()},get onSubmit(){return d()}})},a=e=>{Bo(e,{get prompt(){return o()},get controller(){return l()},get busy(){return u()},get onSubmit(){return d()}})},s=e=>{ds(e,{get prompt(){return o()},get controller(){return l()},get busy(){return u()},get onSubmit(){return d()}})};q(n,e=>{U(o()),H(()=>o().kind===`question`)?e(r):(U(o()),H(()=>o().kind===`choice`||o().kind===`confirm`)?e(i,1):(U(o()),H(()=>o().kind===`text`||o().kind===`secret`)?e(a,2):(U(o()),H(()=>o().kind===`plan`)&&e(s,3))))}),G(e,t)}),E(m),ii(m,(e,t)=>fs?.(e,t),c),R((e,t,n)=>{J(m,`aria-label`,e),K(_,t),K(v,`${n??``} ${s()??``}`)},[()=>(U(Q),n(),H(()=>Q(`currentDecision`,n()))),()=>(U(Q),n(),H(()=>Q(`currentDecision`,n()))),()=>(U(Q),n(),H(()=>Q(`session`,n())))]),G(e,m),k(),i()}var _s={Metadata:`Metadata`,Contents:`Contents`,Secrets:`Secrets`,Variables:`Variables`,Issues:`Issues`,Actions:`Actions`,Checks:`Checks`,Administration:`Administration`,Workflows:`Workflows`,"Issue Types":`Issue Types`,Projects:`Projects`,"Pull requests":`Pull requests`,Members:`Members`},vs={repository:`repository`,organization:`organization`,read:`Read`,write:`Write`,required:`Required`,conditional:`Conditional`,verified:`Verified`,missing:`Missing`,unverifiable:`Unverifiable`},ys={Metadata:`Metadatos`,Contents:`Contenido`,Secrets:`Secretos`,Variables:`Variables`,Issues:`Incidencias`,Actions:`Acciones`,Checks:`Comprobaciones`,Administration:`Administración`,Workflows:`Flujos de trabajo`,"Issue Types":`Tipos de incidencia`,Projects:`Proyectos`,"Pull requests":`Solicitudes de cambio`,Members:`Miembros`},bs={repository:`repositorio`,organization:`organización`,read:`Lectura`,write:`Escritura`,required:`Obligatorio`,conditional:`Condicional`,verified:`Verificado`,missing:`Faltante`,unverifiable:`No verificable`},xs={Metadata:`Métadonnées`,Contents:`Contenu`,Secrets:`Secrets`,Variables:`Variables`,Issues:`Tickets`,Actions:`Actions`,Checks:`Vérifications`,Administration:`Administration`,Workflows:`Flux de travail`,"Issue Types":`Types de ticket`,Projects:`Projets`,"Pull requests":`Demandes de tirage`,Members:`Membres`},Ss={repository:`dépôt`,organization:`organisation`,read:`Lecture`,write:`Écriture`,required:`Obligatoire`,conditional:`Conditionnel`,verified:`Vérifié`,missing:`Manquant`,unverifiable:`Non vérifiable`},Cs={Metadata:`Metadados`,Contents:`Conteúdo`,Secrets:`Segredos`,Variables:`Variáveis`,Issues:`Questões`,Actions:`Ações`,Checks:`Verificações`,Administration:`Administração`,Workflows:`Fluxos de trabalho`,"Issue Types":`Tipos de questão`,Projects:`Projetos`,"Pull requests":`Pedidos de alteração`,Members:`Membros`},ws={repository:`repositório`,organization:`organização`,read:`Leitura`,write:`Escrita`,required:`Obrigatório`,conditional:`Condicional`,verified:`Verificado`,missing:`Em falta`,unverifiable:`Não verificável`},Ts={en:_s,es:ys,fr:xs,pt:Cs},Es={en:vs,es:bs,fr:Ss,pt:ws};function Ds(e,t){return Es[e][t]}function Os(e,t){return Object.prototype.hasOwnProperty.call(Ts[e],t)?Ts[e][t]:t}function ks(e,t){if(t===`verified`||t===`missing`||t===`unverifiable`)return Es[e][t]}var As=`Resolve repository identity and visibility.(Inspect installed workflows and repository files.(Inspect and provision selected GitHub Actions Secrets.(Inspect and provision selected GitHub Actions Variables.(Provision labels and issue resources.(Inspect and dispatch credential-health workflows.(Inspect CI workflow runs and jobs for exact producer identities.(Discover exact CI check and producer identities.(Inspect branch protection and rulesets.(Bootstrap a missing credential-health workflow.(Inspect and provision organization Actions Secrets.(Inspect and provision organization Actions Variables.(Provision and assign configured issue types.(Inspect selected Projects and their Status options; setup does not edit Project items.(Create the initial repository tag when no version tag exists.(Inspect and provision selected repository Actions Secrets.(Inspect and provision selected repository Actions Variables.(Provision labels for the selected issue workflows.(Dispatch credential-health checks for existing Secrets.(Inspect CI workflow runs and jobs for approval evidence.(Temporarily install credential health when its workflow is not confirmed installed.(Inspect branch protection and effective rulesets.(Inspect and provision selected organization Actions Secrets.(Inspect and provision selected organization Actions Variables.(Provision native issue types for the selected workflows.(Resolve repository and collaborator metadata.(Dispatch selected release or hotfix workflows and check previous runs.(Check previous workflow runs before executing an enabled route.(Create managed branches, edit files, or merge selected release/hotfix changes.(Manage selected issue lifecycles, comments, and progress.(Manage selected pull request workflows, reviews, or autofix.(Verify current-head required checks and producer identities.(Load the guarded approval policy.(Select or authorize organization members for enabled workflows.(Assign configured organization issue types.(Update selected organization Projects.(Load the organization-scoped approval policy.(Secret provisioning enabled(Variable provisioning enabled(Issue workflows enabled(Credential health enabled(Pull-request approval enabled(Release, hotfix, or guarded approval enabled(Temporary health workflow required(Organization Secret storage selected(Organization Variable storage selected(Issue type automation enabled(Organization Projects selected`.split(`(`),js={"Resolve repository identity and visibility.":`Comprobar la identidad y visibilidad del repositorio.`,"Inspect installed workflows and repository files.":`Examinar los workflows instalados y los archivos del repositorio.`,"Inspect and provision selected GitHub Actions Secrets.":`Examinar y configurar los Secrets de GitHub Actions seleccionados.`,"Inspect and provision selected GitHub Actions Variables.":`Examinar y configurar las Variables de GitHub Actions seleccionadas.`,"Provision labels and issue resources.":`Crear etiquetas y recursos de issues.`,"Inspect and dispatch credential-health workflows.":`Examinar y ejecutar los workflows de comprobación de credenciales.`,"Inspect CI workflow runs and jobs for exact producer identities.":`Examinar ejecuciones y jobs de CI para identificar sus productores exactos.`,"Discover exact CI check and producer identities.":`Identificar los checks de CI y sus productores exactos.`,"Inspect branch protection and rulesets.":`Examinar la protección de ramas y sus reglas.`,"Bootstrap a missing credential-health workflow.":`Instalar provisionalmente el workflow de comprobación de credenciales que falta.`,"Inspect and provision organization Actions Secrets.":`Examinar y configurar Secrets de Actions en la organización.`,"Inspect and provision organization Actions Variables.":`Examinar y configurar Variables de Actions en la organización.`,"Provision and assign configured issue types.":`Crear y asignar los tipos de issue configurados.`,"Inspect selected Projects and their Status options; setup does not edit Project items.":`Consultar los Projects seleccionados y sus opciones de Status; el setup no modifica los elementos de los Projects.`,"Create the initial repository tag when no version tag exists.":`Crear el tag inicial si el repositorio aún no tiene ninguno de versión.`,"Inspect and provision selected repository Actions Secrets.":`Examinar y configurar los Secrets de Actions seleccionados en el repositorio.`,"Inspect and provision selected repository Actions Variables.":`Examinar y configurar las Variables de Actions seleccionadas en el repositorio.`,"Provision labels for the selected issue workflows.":`Crear las etiquetas de los flujos de issues seleccionados.`,"Dispatch credential-health checks for existing Secrets.":`Ejecutar comprobaciones de credenciales para los Secrets existentes.`,"Inspect CI workflow runs and jobs for approval evidence.":`Examinar ejecuciones y jobs de CI como prueba para la aprobación.`,"Temporarily install credential health when its workflow is not confirmed installed.":`Instalar temporalmente la comprobación de credenciales si su workflow no está confirmado.`,"Inspect branch protection and effective rulesets.":`Examinar la protección de ramas y las reglas efectivas.`,"Inspect and provision selected organization Actions Secrets.":`Examinar y configurar los Secrets de Actions seleccionados en la organización.`,"Inspect and provision selected organization Actions Variables.":`Examinar y configurar las Variables de Actions seleccionadas en la organización.`,"Provision native issue types for the selected workflows.":`Crear tipos de issue nativos para los flujos seleccionados.`,"Resolve repository and collaborator metadata.":`Consultar los metadatos del repositorio y sus colaboradores.`,"Dispatch selected release or hotfix workflows and check previous runs.":`Ejecutar los flujos de release o hotfix seleccionados y comprobar ejecuciones anteriores.`,"Check previous workflow runs before executing an enabled route.":`Comprobar ejecuciones anteriores antes de iniciar un flujo habilitado.`,"Create managed branches, edit files, or merge selected release/hotfix changes.":`Crear ramas gestionadas, editar archivos o integrar cambios de release y hotfix.`,"Manage selected issue lifecycles, comments, and progress.":`Gestionar el ciclo de vida, los comentarios y el progreso de los issues seleccionados.`,"Manage selected pull request workflows, reviews, or autofix.":`Gestionar flujos de pull requests, revisiones o correcciones automáticas.`,"Verify current-head required checks and producer identities.":`Verificar los checks obligatorios y productores del commit actual.`,"Load the guarded approval policy.":`Leer la política de aprobación protegida.`,"Select or authorize organization members for enabled workflows.":`Seleccionar o autorizar miembros de la organización para los flujos activos.`,"Assign configured organization issue types.":`Asignar los tipos de issue configurados en la organización.`,"Update selected organization Projects.":`Actualizar los Projects seleccionados de la organización.`,"Load the organization-scoped approval policy.":`Leer la política de aprobación guardada en la organización.`,"Secret provisioning enabled":`Configuración de Secrets activada`,"Variable provisioning enabled":`Configuración de Variables activada`,"Issue workflows enabled":`Flujos de issues activados`,"Credential health enabled":`Comprobación de credenciales activada`,"Pull-request approval enabled":`Aprobación de pull requests activada`,"Release, hotfix, or guarded approval enabled":`Release, hotfix o aprobación protegida activados`,"Temporary health workflow required":`Hace falta un workflow temporal de comprobación`,"Organization Secret storage selected":`Se eligió guardar Secrets en la organización`,"Organization Variable storage selected":`Se eligió guardar Variables en la organización`,"Issue type automation enabled":`Automatización de tipos de issue activada`,"Organization Projects selected":`Se seleccionaron Projects de la organización`},Ms={"Resolve repository identity and visibility.":`Vérifier l’identité et la visibilité du dépôt.`,"Inspect installed workflows and repository files.":`Examiner les workflows installés et les fichiers du dépôt.`,"Inspect and provision selected GitHub Actions Secrets.":`Examiner et configurer les Secrets GitHub Actions choisis.`,"Inspect and provision selected GitHub Actions Variables.":`Examiner et configurer les Variables GitHub Actions choisies.`,"Provision labels and issue resources.":`Créer des étiquettes et des ressources pour les tickets.`,"Inspect and dispatch credential-health workflows.":`Examiner et lancer les workflows de vérification des identifiants.`,"Inspect CI workflow runs and jobs for exact producer identities.":`Examiner les exécutions et jobs CI pour identifier exactement leurs producteurs.`,"Discover exact CI check and producer identities.":`Identifier précisément les vérifications CI et leurs producteurs.`,"Inspect branch protection and rulesets.":`Examiner la protection des branches et les ensembles de règles.`,"Bootstrap a missing credential-health workflow.":`Installer provisoirement le workflow de vérification des identifiants manquant.`,"Inspect and provision organization Actions Secrets.":`Examiner et configurer les Secrets Actions de l’organisation.`,"Inspect and provision organization Actions Variables.":`Examiner et configurer les Variables Actions de l’organisation.`,"Provision and assign configured issue types.":`Créer et attribuer les types de ticket configurés.`,"Inspect selected Projects and their Status options; setup does not edit Project items.":`Consulter les Projects sélectionnés et leurs options Status ; la configuration ne modifie aucun élément de Project.`,"Create the initial repository tag when no version tag exists.":`Créer le tag initial si le dépôt ne possède encore aucun tag de version.`,"Inspect and provision selected repository Actions Secrets.":`Examiner et configurer les Secrets Actions choisis dans le dépôt.`,"Inspect and provision selected repository Actions Variables.":`Examiner et configurer les Variables Actions choisies dans le dépôt.`,"Provision labels for the selected issue workflows.":`Créer les étiquettes des workflows de ticket choisis.`,"Dispatch credential-health checks for existing Secrets.":`Lancer des vérifications d’identifiants pour les Secrets existants.`,"Inspect CI workflow runs and jobs for approval evidence.":`Examiner les exécutions et jobs CI comme preuve pour l’approbation.`,"Temporarily install credential health when its workflow is not confirmed installed.":`Installer provisoirement la vérification des identifiants si son workflow n’est pas confirmé.`,"Inspect branch protection and effective rulesets.":`Examiner la protection des branches et les règles applicables.`,"Inspect and provision selected organization Actions Secrets.":`Examiner et configurer les Secrets Actions choisis dans l’organisation.`,"Inspect and provision selected organization Actions Variables.":`Examiner et configurer les Variables Actions choisies dans l’organisation.`,"Provision native issue types for the selected workflows.":`Créer des types de ticket natifs pour les workflows choisis.`,"Resolve repository and collaborator metadata.":`Consulter les métadonnées du dépôt et de ses collaborateurs.`,"Dispatch selected release or hotfix workflows and check previous runs.":`Lancer les workflows de version ou correctif urgent choisis et vérifier les exécutions précédentes.`,"Check previous workflow runs before executing an enabled route.":`Vérifier les exécutions précédentes avant de lancer un parcours activé.`,"Create managed branches, edit files, or merge selected release/hotfix changes.":`Créer des branches gérées, modifier des fichiers ou fusionner les changements de version et correctif.`,"Manage selected issue lifecycles, comments, and progress.":`Gérer le cycle de vie, les commentaires et la progression des tickets choisis.`,"Manage selected pull request workflows, reviews, or autofix.":`Gérer les workflows de pull request, les revues ou les corrections automatiques.`,"Verify current-head required checks and producer identities.":`Vérifier les contrôles requis et leurs producteurs pour le commit courant.`,"Load the guarded approval policy.":`Lire la politique d’approbation encadrée.`,"Select or authorize organization members for enabled workflows.":`Sélectionner ou autoriser des membres de l’organisation pour les workflows activés.`,"Assign configured organization issue types.":`Attribuer les types de ticket configurés dans l’organisation.`,"Update selected organization Projects.":`Mettre à jour les projets de l’organisation choisis.`,"Load the organization-scoped approval policy.":`Lire la politique d’approbation stockée dans l’organisation.`,"Secret provisioning enabled":`Configuration des Secrets activée`,"Variable provisioning enabled":`Configuration des Variables activée`,"Issue workflows enabled":`Workflows de ticket activés`,"Credential health enabled":`Vérification des identifiants activée`,"Pull-request approval enabled":`Approbation des pull requests activée`,"Release, hotfix, or guarded approval enabled":`Version, correctif urgent ou approbation encadrée activés`,"Temporary health workflow required":`Workflow temporaire de vérification requis`,"Organization Secret storage selected":`Stockage des Secrets dans l’organisation choisi`,"Organization Variable storage selected":`Stockage des Variables dans l’organisation choisi`,"Issue type automation enabled":`Automatisation des types de ticket activée`,"Organization Projects selected":`Projets de l’organisation choisis`},Ns={"Resolve repository identity and visibility.":`Verificar a identidade e visibilidade do repositório.`,"Inspect installed workflows and repository files.":`Inspecionar os fluxos instalados e os ficheiros do repositório.`,"Inspect and provision selected GitHub Actions Secrets.":`Inspecionar e configurar os Secrets do GitHub Actions selecionados.`,"Inspect and provision selected GitHub Actions Variables.":`Inspecionar e configurar as Variables do GitHub Actions selecionadas.`,"Provision labels and issue resources.":`Criar etiquetas e recursos para questões.`,"Inspect and dispatch credential-health workflows.":`Inspecionar e executar os fluxos de verificação de credenciais.`,"Inspect CI workflow runs and jobs for exact producer identities.":`Inspecionar execuções e jobs CI para identificar exatamente os seus produtores.`,"Discover exact CI check and producer identities.":`Identificar as verificações CI e os seus produtores exatos.`,"Inspect branch protection and rulesets.":`Inspecionar a proteção de ramos e os conjuntos de regras.`,"Bootstrap a missing credential-health workflow.":`Instalar temporariamente o fluxo de verificação de credenciais em falta.`,"Inspect and provision organization Actions Secrets.":`Inspecionar e configurar os Secrets de Actions da organização.`,"Inspect and provision organization Actions Variables.":`Inspecionar e configurar as Variables de Actions da organização.`,"Provision and assign configured issue types.":`Criar e atribuir os tipos de questão configurados.`,"Inspect selected Projects and their Status options; setup does not edit Project items.":`Consultar os Projects selecionados e as suas opções de Status; a configuração não altera os itens dos Projects.`,"Create the initial repository tag when no version tag exists.":`Criar a etiqueta inicial se o repositório ainda não tiver etiquetas de versão.`,"Inspect and provision selected repository Actions Secrets.":`Inspecionar e configurar os Secrets de Actions selecionados no repositório.`,"Inspect and provision selected repository Actions Variables.":`Inspecionar e configurar as Variables de Actions selecionadas no repositório.`,"Provision labels for the selected issue workflows.":`Criar etiquetas para os fluxos de questões selecionados.`,"Dispatch credential-health checks for existing Secrets.":`Executar verificações de credenciais para os Secrets existentes.`,"Inspect CI workflow runs and jobs for approval evidence.":`Inspecionar execuções e jobs CI como prova para aprovação.`,"Temporarily install credential health when its workflow is not confirmed installed.":`Instalar temporariamente a verificação de credenciais se o fluxo não estiver confirmado.`,"Inspect branch protection and effective rulesets.":`Inspecionar a proteção de ramos e as regras aplicáveis.`,"Inspect and provision selected organization Actions Secrets.":`Inspecionar e configurar os Secrets de Actions selecionados na organização.`,"Inspect and provision selected organization Actions Variables.":`Inspecionar e configurar as Variables de Actions selecionadas na organização.`,"Provision native issue types for the selected workflows.":`Criar tipos de questão nativos para os fluxos selecionados.`,"Resolve repository and collaborator metadata.":`Consultar os metadados do repositório e dos colaboradores.`,"Dispatch selected release or hotfix workflows and check previous runs.":`Executar os fluxos de release ou hotfix selecionados e verificar execuções anteriores.`,"Check previous workflow runs before executing an enabled route.":`Verificar execuções anteriores antes de iniciar um percurso ativo.`,"Create managed branches, edit files, or merge selected release/hotfix changes.":`Criar ramos geridos, editar ficheiros ou integrar alterações de release e hotfix.`,"Manage selected issue lifecycles, comments, and progress.":`Gerir o ciclo de vida, os comentários e o progresso das questões selecionadas.`,"Manage selected pull request workflows, reviews, or autofix.":`Gerir fluxos de pull requests, revisões ou correções automáticas.`,"Verify current-head required checks and producer identities.":`Verificar as verificações obrigatórias e os produtores do commit atual.`,"Load the guarded approval policy.":`Ler a política de aprovação protegida.`,"Select or authorize organization members for enabled workflows.":`Selecionar ou autorizar membros da organização para os fluxos ativos.`,"Assign configured organization issue types.":`Atribuir os tipos de questão configurados na organização.`,"Update selected organization Projects.":`Atualizar os Projetos selecionados da organização.`,"Load the organization-scoped approval policy.":`Ler a política de aprovação guardada na organização.`,"Secret provisioning enabled":`Configuração de Secrets ativa`,"Variable provisioning enabled":`Configuração de Variables ativa`,"Issue workflows enabled":`Fluxos de questões ativos`,"Credential health enabled":`Verificação de credenciais ativa`,"Pull-request approval enabled":`Aprovação de pull requests ativa`,"Release, hotfix, or guarded approval enabled":`Release, hotfix ou aprovação protegida ativos`,"Temporary health workflow required":`É necessário um fluxo temporário de verificação`,"Organization Secret storage selected":`Armazenamento dos Secrets na organização selecionado`,"Organization Variable storage selected":`Armazenamento das Variables na organização selecionado`,"Issue type automation enabled":`Automatização de tipos de questão ativa`,"Organization Projects selected":`Projetos da organização selecionados`},Ps=new Set(As),Fs={es:js,fr:Ms,pt:Ns};function Is(e,t){return e===`en`?t:Ps.has(t)?Fs[e][t]:Q(`permissionUnknown`,e)}var Ls=W(` `),Rs=W(` `),zs=W(`
                    • `),Bs=W(`

                        `),Vs=W(`

                        `),Hs=W(``);function Us(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`view`,8);Y();var o=Hs(),s=P(o),c=L(P(s)),l=I(c,!0),u=L(c),d=I(u,!0),f=L(u),p=I(P(f),!0);E(f),E(s);var m=L(s,2),h=e=>{var t=Bs(),r=L(P(t)),i=I(r),o=L(r),s=I(o,!0),c=L(o,2);Qr(c,5,()=>(U(a()),H(()=>a().permissions.report?.checks??a().permissions.requirements??[])),Jr,(e,t)=>{var r=zs(),i=P(r),a=P(i,!0),o=L(a),s=e=>{var n=Ls(),r=I(P(n));E(n),R(()=>K(r,`GitHub · ${V(t),H(()=>V(t).permission)??``}`)),G(e,n)},c=gt(()=>(n(),U(Os),V(t),H(()=>n()!==`en`&&Os(n(),V(t).permission)!==V(t).permission)));q(o,e=>{V(c)&&e(s)});var l=L(o),u=I(l),d=L(l),f=I(d,!0),p=L(d),m=e=>{var r=Rs(),i=I(r,!0);R(e=>K(i,e),[()=>(U(Is),n(),V(t),H(()=>Is(n(),V(t).condition)))]),G(e,r)};q(p,e=>{V(t),H(()=>V(t).condition)&&e(m)}),E(i);var h=L(i),g=P(h,!0),_=L(g),v=e=>{var r=Rs(),i=I(r,!0);R(e=>K(i,e),[()=>(U(ks),n(),V(t),H(()=>ks(n(),V(t).status)))]),G(e,r)},y=gt(()=>(V(t),U(ks),n(),H(()=>`status`in V(t)&&ks(n(),V(t).status))));q(_,e=>{V(y)&&e(v)}),E(h),E(r),R((e,t,n,r,i)=>{K(a,e),K(u,`${t??``} · ${n??``}`),K(f,r),K(g,i)},[()=>(U(Os),n(),V(t),H(()=>Os(n(),V(t).permission))),()=>(U(Ds),n(),V(t),H(()=>Ds(n(),V(t).scope))),()=>(U(Ds),n(),V(t),H(()=>Ds(n(),V(t).applicability))),()=>(U(Is),n(),V(t),H(()=>Is(n(),V(t).reason))),()=>(U(Ds),n(),V(t),H(()=>Ds(n(),V(t).level)))]),G(e,r)}),E(c);var l=I(L(c,2),!0);E(t),R((e,t,n,r)=>{K(i,`${e??``} ${t??``}`),K(s,n),K(l,r)},[()=>(U(a()),U(Q),n(),H(()=>a().permissions.role===`setup`?Q(`setupPat`,n()):Q(`botPat`,n()))),()=>(U(Q),n(),H(()=>Q(`access`,n()))),()=>(U(a()),U(Q),n(),H(()=>a().permissions.report?Q(`readOnlyCheck`,n()):Q(`provisionalGrants`,n()))),()=>(U(Q),n(),H(()=>Q(`conditionalGrants`,n())))]),G(e,t)},g=e=>{var t=Vs(),r=L(P(t)),i=I(r,!0),a=I(L(r),!0);E(t),R((e,t)=>{K(i,e),K(a,t)},[()=>(U(Q),n(),H(()=>Q(`permissionsFollow`,n()))),()=>(U(Q),n(),H(()=>Q(`permissionsFollowBody`,n())))]),G(e,t)};q(m,e=>{U(a()),H(()=>a().permissions)?e(h):e(g,-1)}),E(o),R((e,t,n)=>{J(o,`aria-label`,e),K(l,t),K(d,n),K(p,(U(a()),H(()=>a().repository)))},[()=>(U(Q),n(),H(()=>Q(`setup`,n()))),()=>(U(Q),n(),H(()=>Q(`repoFocus`,n()))),()=>(U(Q),n(),H(()=>Q(`repoFocusBody`,n())))]),G(e,o),k(),i()}var Ws=W(`

                        `),Gs=W(`

                        `),Ks=W(`

                        `),qs=W(`
                      • `),Js=W(`

                          `),Ys=W(`

                          `),Xs=W(`
                          `),Zs=W(`

                          `);function Qs(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=M(),o=M(),s=Z(t,`outcome`,8),c=Z(t,`controller`,8),l=Z(t,`onClose`,8),u=Z(t,`onDoctor`,8,async()=>void 0),d=Z(t,`doctor`,8,void 0),f=Z(t,`detail`,8,void 0),p={permissions:[`reasonPermissions`,`nextPermissions`],storage:[`reasonStorage`,`nextStorage`],configuration:[`reasonConfiguration`,`nextConfiguration`],"session-expired":[`reasonExpired`,`nextExpired`],cancelled:[`reasonCancelled`,`nextCancelled`],provider:[`reasonProvider`,`nextProvider`],"rate-limit":[`reasonRateLimit`,`nextRateLimit`],unknown:[`reasonUnknown`,`nextUnknown`]},m={files:`receiptFiles`,secrets:`receiptSecrets`,labels:`receiptLabels`,"issue-types":`receiptIssueTypes`,variables:`receiptVariables`,"initial-tag":`receiptInitialTag`};Tn(()=>(U(s()),n()),()=>{N(a,s()===`complete`?Q(`resultApplied`,n()):s()===`dry-run`?Q(`resultNoChanges`,n()):s()===`cancelled`||s()===`blocked`?Q(`resultStopped`,n()):Q(`resultPartial`,n()))}),Tn(()=>(U(s()),n()),()=>{N(o,s()===`complete`?Q(`resultCompleteBody`,n()):s()===`partial`?Q(`resultPartialBody`,n()):Q(`resultNoChangesBody`,n()))}),En(),Y();var h=Zs(),g=P(h),_=I(g,!0),v=L(g),y=I(v,!0),b=L(v,2),x=e=>{var t=Ks(),r=P(t),i=P(r),a=I(i),o=L(i);E(r);var c=L(r,2),l=e=>{var t=Ws(),r=P(t),i=I(r),a=L(r);E(t),R((e,t)=>{K(i,`${e??``}:`),K(a,` ${t??``}`)},[()=>(U(Q),n(),H(()=>Q(`progress`,n()))),()=>(U(qi),U(f()),n(),H(()=>qi(f().stoppedStage,n())))]),G(e,t)};q(c,e=>{U(f()),H(()=>f()?.stoppedStage)&&e(l)});var u=L(c,2),d=P(u),m=I(d),h=L(d);E(u);var g=L(u,2),_=P(g),v=I(_),y=L(_);E(g);var b=L(g,2),x=e=>{var t=Gs(),r=P(t),i=I(r),a=I(L(r,2),!0);E(t),R(e=>{K(i,`${e??``}:`),K(a,(U(f()),H(()=>f().diagnosticRef)))},[()=>(U(Q),n(),H(()=>Q(`diagnosticReference`,n())))]),G(e,t)};q(b,e=>{U(f()),H(()=>f()?.diagnosticRef)&&e(x)}),E(t),R((e,t,n,r,i,s)=>{K(a,`${e??``}:`),K(o,` ${t??``}`),K(m,`${n??``}:`),K(h,` ${r??``}`),K(v,`${i??``}:`),K(y,` ${s??``}`)},[()=>(U(Q),n(),H(()=>Q(`whatHappened`,n()))),()=>(U(Q),U(f()),n(),H(()=>Q(p[f()?.reasonCode??`unknown`][0],n()))),()=>(U(Q),n(),H(()=>Q(`alreadyChanged`,n()))),()=>(U(f()),U(s()),U(Q),n(),H(()=>f()?.mutationStarted||s()===`partial`?Q(`inspectPartial`,n()):Q(`noChanges`,n()))),()=>(U(Q),n(),H(()=>Q(`nextAction`,n()))),()=>(U(s()),U(Q),n(),U(f()),H(()=>s()===`partial`?`${Q(`inspectPartial`,n())} ${Q(p[f()?.reasonCode??`unknown`][1],n())}`:Q(p[f()?.reasonCode??`unknown`][1],n())))]),G(e,t)};q(b,e=>{(s()===`blocked`||s()===`cancelled`||s()===`partial`)&&e(x)});var S=L(b,2),C=e=>{var t=Js(),r=P(t),i=I(r,!0),a=L(r,2);Qr(a,5,()=>(U(f()),H(()=>f().effects)),Jr,(e,t)=>{var r=qs(),i=P(r),a=I(i,!0),o=L(i),s=L(o),c=e=>{var r=jr();R(e=>K(r,`· ${e??``}`),[()=>(U(Q),V(t),n(),H(()=>Q(V(t).scope===`local`?`scopeLocal`:V(t).scope===`organization`?`scopeOrganization`:V(t).scope===`mixed`?`scopeMixed`:`scopeRepository`,n())))]),G(e,r)};q(s,e=>{V(t),H(()=>V(t).scope)&&e(c)}),E(r),R((e,t)=>{K(a,e),K(o,` — ${t??``}`)},[()=>(V(t),U(Q),n(),H(()=>m[V(t).id]?Q(m[V(t).id],n()):V(t).id)),()=>(U(Q),V(t),n(),H(()=>Q(V(t).state===`completed`?`effectCompleted`:V(t).state===`skipped`?`effectSkipped`:V(t).state===`not-started`?`effectNotStarted`:`effectInspect`,n())))]),G(e,r)}),E(a),E(t),R(e=>K(i,e),[()=>(U(Q),n(),H(()=>Q(`resourceReceipt`,n())))]),G(e,t)};q(S,e=>{U(f()),H(()=>f()?.effects?.length)&&e(C)});var ee=L(S,2),te=I(ee,!0),ne=L(ee,2),re=e=>{var t=Ys(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`botRenewal`,n())))]),G(e,t)};q(ne,e=>{s()===`complete`&&e(re)});var ie=L(ne,2),ae=I(ie,!0),oe=L(ie,2),se=e=>{var t=Xs(),r=P(t),i=e=>{var t=Ys(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`doctorRunning`,n())))]),G(e,t)},a=e=>{var t=Ys(),r=I(t);R((e,t,n)=>K(r,`${e??``} ${t??``} ${n??``}`),[()=>(U(Q),U(d()),n(),H(()=>Q(d().healthy?`doctorPassed`:`doctorWarnings`,n()))),()=>(U(Q),n(),U(d()),H(()=>Q(`doctorCounts`,n(),{pass:String(d().pass??0),warn:String(d().warn??0),fail:String(d().fail??0),skipped:String(d().skipped??0)}))),()=>(U(Q),n(),H(()=>Q(`doctorSecretLimit`,n())))]),G(e,t)},o=e=>{var t=Ys(),r=I(t,!0);R(e=>K(r,e),[()=>(U(Q),n(),H(()=>Q(`doctorFailed`,n())))]),G(e,t)};q(r,e=>{U(d()),H(()=>d()?.status===`running`)?e(i):(U(d()),H(()=>d()?.status===`complete`)?e(a,1):(U(d()),H(()=>d()?.status===`failed`)&&e(o,2)))});var s=L(r,2),l=e=>{{let t=A(()=>(U(Q),n(),H(()=>Q(`doctorRun`,n()))));ka(e,{get label(){return V(t)},variant:`secondary`,get onClick(){return u()}})}};q(s,e=>{U(c()),U(d()),H(()=>c()&&d()?.status!==`running`&&d()?.status!==`complete`)&&e(l)}),E(t),G(e,t)};q(oe,e=>{s()===`complete`&&e(se)});var ce=L(oe,2),le=I(P(ce),!0);ke(),E(ce);var ue=L(ce),de=e=>{{let t=A(()=>(U(Q),n(),H(()=>Q(`closeSession`,n()))));ka(e,{get label(){return V(t)},get onClick(){return l()}})}};q(ue,e=>{c()&&e(de)}),E(h),ii(h,(e,t)=>fs?.(e,t),()=>1),R((e,t)=>{K(_,s()===`complete`?`✓`:`!`),K(y,V(a)),K(te,V(o)),K(ae,e),K(le,t)},[()=>(U(Q),n(),H(()=>Q(`doctorHelp`,n()))),()=>(U(Q),n(),H(()=>Q(`patSettings`,n())))]),G(e,h),k(),i()}var $s=W(`

                          `);function ec(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii();Y();var a=$s(),o=L(P(a)),s=I(o,!0),c=I(L(o),!0);E(a),R((e,t)=>{K(s,e),K(c,t)},[()=>Q(`working`,n()),()=>Q(`workingBody`,n())]),G(e,a),k(),i()}function tc(e,t){return!t&&/^[A-Fa-f0-9]{16}$/u.test(e.trim())}var nc=W(`

                          `);function rc(e,t){O(t,!1);let n=()=>X($,`$setupLocale`,r),[r,i]=Ii(),a=Z(t,`busy`,8),o=Z(t,`onPair`,8),s=Z(t,`mode`,8,`pair`),c=M(``);function l(){if(!tc(V(c),a()))return;let e=V(c).trim();N(c,``),o()(e)}Y();var u=nc(),d=P(u),f=I(P(d),!0);E(d);var p=L(d,2),m=I(P(p),!0);E(p);var h=L(p,2),g=I(h,!0),_=L(h,2),v=P(_),y=I(v,!0),b=L(v,2);xi(b);var x=L(b,2),S=I(x,!0),C=L(x,2);{let e=A(()=>(U(Q),U(s()),n(),H(()=>Q(s()===`pair`?`pairButton`:`takeOver`,n())))),t=A(()=>(U(tc),V(c),U(a()),H(()=>!tc(V(c),a()))));ka(C,{get label(){return V(e)},arrow:!0,onClick:l,get disabled(){return V(t)}})}E(_),E(u),R((e,t,n,r,i,o,s)=>{J(u,`aria-label`,e),K(f,t),K(m,n),K(g,r),K(y,i),b.disabled=a(),J(b,`placeholder`,o),K(S,s)},[()=>(U(Q),U(s()),n(),H(()=>Q(s()===`pair`?`pairTitle`:`takeOver`,n()))),()=>(U(Q),n(),H(()=>Q(`privateSession`,n()))),()=>(U(Q),U(s()),n(),H(()=>Q(s()===`pair`?`pairTitle`:`takeOver`,n()))),()=>(U(Q),U(s()),n(),H(()=>Q(s()===`pair`?`pairBody`:`readOnlyBody`,n()))),()=>(U(Q),n(),H(()=>Q(`pairLabel`,n()))),()=>(U(Q),n(),H(()=>Q(`pairPlaceholder`,n()))),()=>(U(Q),n(),H(()=>Q(`pairHelp`,n())))]),xr(`submit`,_,e=>{e.preventDefault(),l()}),Ei(b,()=>V(c),e=>N(c,e)),G(e,u),k(),i()}var ic={"The local setup session is unavailable.":`The local setup session is unavailable. Check the terminal; you may need to restart setup.`,"Connection lost. The CLI may have stopped. Check the terminal before trying again.":`Connection lost. The CLI may have stopped. Check the terminal before trying again.`,"Could not join this local session.":`Could not join this local session. Check the terminal and pair again.`,"Could not connect to the local setup session. Check the terminal and pair again.":`Could not connect to the local setup session. Check the terminal and pair again.`,"Pairing was rejected.":`Pairing was rejected. Check the code in the launching terminal.`,"Invalid local pairing response.":`The local pairing response was invalid. Restart setup from the terminal.`,"Could not pair this browser.":`Could not pair this browser. Check the terminal and try again.`,"The request was rejected.":`The local request was rejected. Refresh the page and check the terminal.`,"Could not submit this answer.":`Could not submit this answer. Refresh the page and try again.`,"Cancellation failed.":`Cancellation failed. Check whether setup has already started applying before closing.`,"Takeover failed.":`Could not take control. Re-enter the pairing code from the launching terminal.`,"Invalid local host or request context.":`The local request context was rejected. Open the exact URL printed in the terminal.`,"Invalid request origin.":`The request came from another origin. Open the exact local URL printed in the terminal.`,"JSON required.":`The local request format was invalid. Refresh the page and try again.`,"Too many pairing attempts. Restart setup.":`Too many incorrect pairing attempts. Restart setup from the terminal.`,"Too many pairing attempts. Wait 30 seconds and retry.":`Too many incorrect pairing attempts. Wait 30 seconds and try the code again; terminal setup remains available.`,"Incorrect pairing code. Check the terminal.":`Incorrect pairing code. Check the launching terminal.`,"Incorrect pairing code. Check the launching output.":`Incorrect pairing code. Check the launching output.`,"Pair this browser using the code printed by the CLI.":`Pair this browser using the code printed by the CLI.`,"This tab is read-only.":`This tab is read-only. Enter the pairing code again to take control.`,"Invalid answer.":`The answer was invalid. Review the current question and try again.`,"Control moved to another tab.":`Control moved to another tab. This tab is now read-only.`,"This question changed. Refresh the current state.":`This question changed. Refresh the page before answering again.`,"This setup has already started applying or ended.":`Setup has already started applying or ended. Inspect the current result before retrying.`,"Only the controller can close a finished session.":`Only the controlling tab can close this finished session.`,"Method not allowed.":`This local request is not allowed. Refresh the page.`,"Not found.":`The requested local page was not found. Open the URL printed in the terminal.`,"Invalid local request.":`The local request was invalid. Refresh the page and try again.`,"Body too large.":`The submitted answer is too large. Shorten it and try again.`,"JSON object required.":`The local request format was invalid. Refresh the page and try again.`,"Could not retry discovery.":`Could not refresh the GitHub suggestions. Retry or use verified manual entry.`,"Could not return to the previous question.":`Could not return to the previous question. Refresh the page and try again.`,"Invalid question revision.":`The question changed. Refresh the page before going back.`,"No earlier question is available here.":`There is no earlier question in this stage. Continue or return to the plan review.`,"Read-only verification failed.":`Read-only verification failed. Check the terminal and retry once.`,"Read-only verification failed. Check the terminal.":`Read-only verification failed. Check the terminal and retry once.`,"Read-only verification is unavailable or already running.":`Read-only verification is unavailable or already running. Check the current result before retrying.`},ac={en:ic,es:{"The local setup session is unavailable.":`La sesión local no está disponible. Revisa la terminal; quizá debas reiniciar la configuración.`,"Connection lost. The CLI may have stopped. Check the terminal before trying again.":`Se perdió la conexión. Es posible que el CLI se haya detenido. Revisa la terminal antes de reintentar.`,"Could not join this local session.":`No se pudo acceder a esta sesión local. Revisa la terminal y vuelve a vincular el navegador.`,"Could not connect to the local setup session. Check the terminal and pair again.":`No se pudo conectar con la sesión local. Revisa la terminal y vuelve a vincular el navegador.`,"Pairing was rejected.":`Se rechazó la vinculación. Comprueba el código en la terminal que inició el proceso.`,"Invalid local pairing response.":`La respuesta de vinculación local no es válida. Reinicia la configuración desde la terminal.`,"Could not pair this browser.":`No se pudo vincular este navegador. Revisa la terminal y vuelve a intentarlo.`,"The request was rejected.":`Se rechazó la petición local. Actualiza la página y revisa la terminal.`,"Could not submit this answer.":`No se pudo enviar la respuesta. Actualiza la página y vuelve a intentarlo.`,"Cancellation failed.":`No se pudo cancelar. Comprueba si ya se han empezado a aplicar cambios antes de cerrar.`,"Takeover failed.":`No se pudo tomar el control. Vuelve a introducir el código de la terminal inicial.`,"Invalid local host or request context.":`Se rechazó el contexto de la petición local. Abre la URL exacta que aparece en la terminal.`,"Invalid request origin.":`La petición llegó desde otro origen. Abre la URL local exacta de la terminal.`,"JSON required.":`El formato de la petición local no es válido. Actualiza la página y reinténtalo.`,"Too many pairing attempts. Restart setup.":`Demasiados intentos de vinculación fallidos. Reinicia la configuración desde la terminal.`,"Too many pairing attempts. Wait 30 seconds and retry.":`Demasiados intentos de vinculación fallidos. Espera 30 segundos y vuelve a probar el código; puedes seguir con el setup en la terminal.`,"Incorrect pairing code. Check the terminal.":`Código de vinculación incorrecto. Comprueba la terminal inicial.`,"Incorrect pairing code. Check the launching output.":`Código de vinculación incorrecto. Comprueba la salida de inicio.`,"Pair this browser using the code printed by the CLI.":`Vincula este navegador con el código mostrado por el CLI.`,"This tab is read-only.":`Esta pestaña es de solo lectura. Introduce otra vez el código para tomar el control.`,"Invalid answer.":`La respuesta no es válida. Revisa la pregunta actual e inténtalo de nuevo.`,"Control moved to another tab.":`El control pasó a otra pestaña. Esta ahora es de solo lectura.`,"This question changed. Refresh the current state.":`La pregunta cambió. Actualiza la página antes de volver a responder.`,"This setup has already started applying or ended.":`La configuración ya empezó a aplicarse o terminó. Revisa el resultado antes de reintentar.`,"Only the controller can close a finished session.":`Solo la pestaña que tiene el control puede cerrar esta sesión finalizada.`,"Method not allowed.":`Esta petición local no está permitida. Actualiza la página.`,"Not found.":`No se encontró la página local solicitada. Abre la URL de la terminal.`,"Invalid local request.":`La petición local no es válida. Actualiza la página y reinténtalo.`,"Body too large.":`La respuesta enviada es demasiado larga. Acórtala y reinténtalo.`,"JSON object required.":`El formato de la petición local no es válido. Actualiza la página y reinténtalo.`,"Could not retry discovery.":`No se pudieron actualizar las sugerencias de GitHub. Reinténtalo o introduce datos verificados manualmente.`,"Could not return to the previous question.":`No se pudo volver a la pregunta anterior. Actualiza la página y reinténtalo.`,"Invalid question revision.":`La pregunta cambió. Actualiza la página antes de volver atrás.`,"No earlier question is available here.":`No hay una pregunta anterior en esta etapa. Continúa o vuelve a revisar el plan.`,"Read-only verification failed.":`Falló la comprobación de solo lectura. Revisa la terminal y vuelve a intentarlo una vez.`,"Read-only verification failed. Check the terminal.":`Falló la comprobación de solo lectura. Revisa la terminal y vuelve a intentarlo una vez.`,"Read-only verification is unavailable or already running.":`La comprobación de solo lectura no está disponible o ya se está ejecutando. Revisa el resultado antes de reintentar.`},fr:{"The local setup session is unavailable.":`La session locale est indisponible. Vérifiez le terminal ; vous devrez peut-être relancer la configuration.`,"Connection lost. The CLI may have stopped. Check the terminal before trying again.":`Connexion perdue. Le CLI s’est peut-être arrêté. Vérifiez le terminal avant de réessayer.`,"Could not join this local session.":`Impossible de rejoindre cette session locale. Vérifiez le terminal et associez de nouveau le navigateur.`,"Could not connect to the local setup session. Check the terminal and pair again.":`Impossible de se connecter à la session locale. Vérifiez le terminal et associez de nouveau le navigateur.`,"Pairing was rejected.":`L’association a été refusée. Vérifiez le code dans le terminal de lancement.`,"Invalid local pairing response.":`La réponse d’association locale est invalide. Relancez la configuration depuis le terminal.`,"Could not pair this browser.":`Impossible d’associer ce navigateur. Vérifiez le terminal et réessayez.`,"The request was rejected.":`La demande locale a été refusée. Actualisez la page et vérifiez le terminal.`,"Could not submit this answer.":`Impossible d’envoyer cette réponse. Actualisez la page et réessayez.`,"Cancellation failed.":`Annulation impossible. Vérifiez si des changements ont déjà commencé avant de fermer.`,"Takeover failed.":`Impossible de prendre le contrôle. Ressaisissez le code du terminal de lancement.`,"Invalid local host or request context.":`Le contexte de la demande locale a été refusé. Ouvrez l’URL exacte affichée dans le terminal.`,"Invalid request origin.":`La demande vient d’une autre origine. Ouvrez l’URL locale exacte du terminal.`,"JSON required.":`Le format de la demande locale est invalide. Actualisez la page et réessayez.`,"Too many pairing attempts. Restart setup.":`Trop de tentatives d’association incorrectes. Relancez la configuration depuis le terminal.`,"Too many pairing attempts. Wait 30 seconds and retry.":`Trop de tentatives d’association incorrectes. Attendez 30 secondes puis ressaisissez le code ; la configuration par terminal reste disponible.`,"Incorrect pairing code. Check the terminal.":`Code d’association incorrect. Vérifiez le terminal de lancement.`,"Incorrect pairing code. Check the launching output.":`Code d’association incorrect. Vérifiez la sortie de lancement.`,"Pair this browser using the code printed by the CLI.":`Associez ce navigateur avec le code affiché par le CLI.`,"This tab is read-only.":`Cet onglet est en lecture seule. Ressaisissez le code pour prendre le contrôle.`,"Invalid answer.":`La réponse est invalide. Revoyez la question actuelle et réessayez.`,"Control moved to another tab.":`Le contrôle est passé à un autre onglet. Celui-ci est maintenant en lecture seule.`,"This question changed. Refresh the current state.":`La question a changé. Actualisez la page avant de répondre de nouveau.`,"This setup has already started applying or ended.":`La configuration est déjà en cours d’application ou terminée. Inspectez le résultat avant de réessayer.`,"Only the controller can close a finished session.":`Seul l’onglet qui détient le contrôle peut fermer cette session terminée.`,"Method not allowed.":`Cette demande locale n’est pas autorisée. Actualisez la page.`,"Not found.":`La page locale demandée est introuvable. Ouvrez l’URL affichée dans le terminal.`,"Invalid local request.":`La demande locale est invalide. Actualisez la page et réessayez.`,"Body too large.":`La réponse envoyée est trop longue. Raccourcissez-la et réessayez.`,"JSON object required.":`Le format de la demande locale est invalide. Actualisez la page et réessayez.`,"Could not retry discovery.":`Impossible d’actualiser les suggestions GitHub. Réessayez ou saisissez des données vérifiées manuellement.`,"Could not return to the previous question.":`Impossible de revenir à la question précédente. Actualisez la page et réessayez.`,"Invalid question revision.":`La question a changé. Actualisez la page avant de revenir en arrière.`,"No earlier question is available here.":`Il n’y a pas de question précédente à cette étape. Continuez ou revenez à la révision du plan.`,"Read-only verification failed.":`La vérification en lecture seule a échoué. Consultez le terminal et réessayez une fois.`,"Read-only verification failed. Check the terminal.":`La vérification en lecture seule a échoué. Consultez le terminal et réessayez une fois.`,"Read-only verification is unavailable or already running.":`La vérification en lecture seule est indisponible ou déjà en cours. Consultez le résultat avant de réessayer.`},pt:{"The local setup session is unavailable.":`A sessão local não está disponível. Verifique o terminal; poderá ter de reiniciar a configuração.`,"Connection lost. The CLI may have stopped. Check the terminal before trying again.":`Ligação perdida. O CLI pode ter parado. Verifique o terminal antes de tentar novamente.`,"Could not join this local session.":`Não foi possível entrar nesta sessão local. Verifique o terminal e volte a emparelhar o navegador.`,"Could not connect to the local setup session. Check the terminal and pair again.":`Não foi possível ligar à sessão local. Verifique o terminal e volte a emparelhar o navegador.`,"Pairing was rejected.":`O emparelhamento foi recusado. Confirme o código no terminal de lançamento.`,"Invalid local pairing response.":`A resposta de emparelhamento local é inválida. Reinicie a configuração a partir do terminal.`,"Could not pair this browser.":`Não foi possível emparelhar este navegador. Verifique o terminal e tente novamente.`,"The request was rejected.":`O pedido local foi recusado. Atualize a página e verifique o terminal.`,"Could not submit this answer.":`Não foi possível enviar a resposta. Atualize a página e tente novamente.`,"Cancellation failed.":`Não foi possível cancelar. Confirme se a aplicação das alterações já começou antes de fechar.`,"Takeover failed.":`Não foi possível assumir o controlo. Volte a introduzir o código do terminal de lançamento.`,"Invalid local host or request context.":`O contexto do pedido local foi recusado. Abra o URL exato apresentado no terminal.`,"Invalid request origin.":`O pedido veio de outra origem. Abra o URL local exato do terminal.`,"JSON required.":`O formato do pedido local é inválido. Atualize a página e tente novamente.`,"Too many pairing attempts. Restart setup.":`Demasiadas tentativas incorretas de emparelhamento. Reinicie a configuração no terminal.`,"Too many pairing attempts. Wait 30 seconds and retry.":`Demasiadas tentativas incorretas de emparelhamento. Aguarde 30 segundos e volte a introduzir o código; a configuração no terminal continua disponível.`,"Incorrect pairing code. Check the terminal.":`Código de emparelhamento incorreto. Verifique o terminal de lançamento.`,"Incorrect pairing code. Check the launching output.":`Código de emparelhamento incorreto. Verifique a saída de lançamento.`,"Pair this browser using the code printed by the CLI.":`Emparelhe este navegador com o código apresentado pelo CLI.`,"This tab is read-only.":`Este separador é apenas de leitura. Reintroduza o código para assumir o controlo.`,"Invalid answer.":`A resposta é inválida. Reveja a pergunta atual e tente novamente.`,"Control moved to another tab.":`O controlo passou para outro separador. Este é agora apenas de leitura.`,"This question changed. Refresh the current state.":`A pergunta mudou. Atualize a página antes de voltar a responder.`,"This setup has already started applying or ended.":`A configuração já começou a ser aplicada ou terminou. Inspecione o resultado antes de tentar novamente.`,"Only the controller can close a finished session.":`Só o separador que detém o controlo pode fechar esta sessão terminada.`,"Method not allowed.":`Este pedido local não é permitido. Atualize a página.`,"Not found.":`A página local pedida não foi encontrada. Abra o URL apresentado no terminal.`,"Invalid local request.":`O pedido local é inválido. Atualize a página e tente novamente.`,"Body too large.":`A resposta enviada é demasiado longa. Encurte-a e tente novamente.`,"JSON object required.":`O formato do pedido local é inválido. Atualize a página e tente novamente.`,"Could not retry discovery.":`Não foi possível atualizar as sugestões do GitHub. Tente novamente ou introduza dados verificados manualmente.`,"Could not return to the previous question.":`Não foi possível voltar à pergunta anterior. Atualize a página e tente novamente.`,"Invalid question revision.":`A pergunta mudou. Atualize a página antes de voltar atrás.`,"No earlier question is available here.":`Não há uma pergunta anterior nesta etapa. Continue ou volte à revisão do plano.`,"Read-only verification failed.":`A verificação só de leitura falhou. Consulte o terminal e tente mais uma vez.`,"Read-only verification failed. Check the terminal.":`A verificação só de leitura falhou. Consulte o terminal e tente mais uma vez.`,"Read-only verification is unavailable or already running.":`A verificação só de leitura não está disponível ou já está em curso. Consulte o resultado antes de tentar novamente.`}};function oc(e,t){let n=ac[t]??ic;return Object.prototype.hasOwnProperty.call(n,e)?n[e]:Q(`unknownLocalError`,t)}var sc=W(`
                          `),cc=W(` `,1),lc=W(``),uc=W(`
                          `,1),dc=W(`
                          `);function fc(e,t){O(t,!1);let n=()=>X(o,`$session`,i),r=()=>X($,`$setupLocale`,i),[i,a]=Ii(),o=Ri();Wr(()=>{let e=$.subscribe(e=>{document.documentElement.lang=e,document.documentElement.dir=`ltr`,document.title=`Copilot · ${Q(`studio`,e)}`}),t=window.setInterval(()=>{n().paired&&!n().view?.outcome&&o.poll()},900);return()=>{window.clearInterval(t),e()}});async function s(){window.confirm(Q(`cancelConfirm`,r()))&&await o.cancel()}async function c(e){let t=n().view?.promptRevision;t!==void 0&&await o.submit(t,e)}async function l(){let e=n().view?.promptRevision;e!==void 0&&await o.retryDiscovery(e)}async function u(){let e=n().view?.promptRevision;e!==void 0&&await o.back(e)}Y();var d=dc(),f=P(d);{let e=A(()=>n().view?.journey);Xi(f,{get journey(){return V(e)}})}var p=L(f,2),m=P(p);{let e=A(()=>n().view?.repository);ra(m,{get repository(){return V(e)}})}var h=L(m,2),g=P(h),_=e=>{{let t=A(()=>Q(`translationPreviewTitle`,r())),n=A(()=>Q(`translationPreviewBody`,r()));Ma(e,{tone:`warning`,get title(){return V(t)},get message(){return V(n)}})}};q(g,e=>{r()!==`en`&&e(_)});var v=L(g,2),y=e=>{Sa(e,{get view(){return n().view}})};q(v,e=>{n().paired&&e(y)});var b=L(v,2),x=e=>{var t=sc(),i=L(P(t));E(t),R(e=>K(i,` ${e??``}`),[()=>Q(`reviewPass`,r(),{pass:String(n().view.journey.choiceReviewPass)})]),G(e,t)};q(b,e=>{n().view?.journey?.choiceReviewPass&&n().view.journey.choiceReviewPass>1&&!n().view.outcome&&e(x)});var S=L(b,2),C=e=>{var t=cc(),i=F(t);{let e=A(()=>Q(`readOnly`,r())),t=A(()=>Q(`readOnlyBody`,r()));Ma(i,{tone:`warning`,get title(){return V(e)},get message(){return V(t)}})}rc(L(i,2),{mode:`takeover`,get busy(){return n().busy},get onPair(){return o.takeOver}}),G(e,t)};q(S,e=>{!n().controller&&n().view&&e(C)});var ee=L(S,2),te=e=>{{let t=A(()=>Q(`attention`,r())),i=A(()=>oc(n().error,r()));Ma(e,{tone:`error`,get title(){return V(t)},get message(){return V(i)}})}};q(ee,e=>{n().error&&e(te)});var ne=L(ee,2),re=e=>{{let t=A(()=>Q(n().view.message.tone===`success`?`checked`:n().view.message.tone===`warning`?`pleaseNote`:n().view.message.tone===`error`?`attention`:`progressUpdate`,r())),i=A(()=>ga(n().view.message,r()));Ma(e,{get tone(){return n().view.message.tone},get title(){return V(t)},get message(){return V(i)},get link(){return n().view.message.link}})}};q(ne,e=>{n().view?.message&&!n().view.outcome&&e(re)});var ie=L(ne,2),ae=e=>{rc(e,{get busy(){return n().busy},get onPair(){return o.pair}})},oe=e=>{Qs(e,{get outcome(){return n().view.outcome},get detail(){return n().view.resultDetail},get doctor(){return n().view.doctor},get controller(){return n().controller},get onDoctor(){return o.runDoctor},get onClose(){return o.close}})},se=e=>{var t=uc(),i=F(t),a=P(i);gs(a,{get prompt(){return n().view.prompt},get revision(){return n().view.revision},get promptRevision(){return n().view.promptRevision},get controller(){return n().controller},get busy(){return n().busy},onSubmit:c,onRetryDiscovery:l,onBack:u}),Us(L(a,2),{get view(){return n().view}}),E(i);var o=L(i,2),d=e=>{var t=lc(),i=I(t,!0);R(e=>{t.disabled=n().busy,K(i,e)},[()=>Q(`cancelSetup`,r())]),Sr(`click`,t,s),G(e,t)};q(o,e=>{n().controller&&n().view.journey?.current!==`Apply`&&e(d)}),G(e,t)},ce=e=>{ec(e,{})};q(ie,e=>{n().paired?n().view?.outcome?e(oe,1):n().view?.prompt?e(se,2):e(ce,-1):e(ae)});var le=L(ie,2),ue=P(le),de=L(ue,2),fe=L(de,2);E(le),E(h),E(p),E(d),R((e,t,n)=>{K(ue,`${e??``} `),K(de,` ${t??``} `),K(fe,` ${n??``}`)},[()=>Q(`footerLocal`,r()),()=>Q(`footerCloud`,r()),()=>Q(`footerGithub`,r())]),G(e,d),k(),a()}Cr([`click`]),zr(fc,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/build/web/index.html b/build/web/index.html new file mode 100644 index 000000000..bd5bb0dc5 --- /dev/null +++ b/build/web/index.html @@ -0,0 +1,14 @@ + + + + + + + Copilot · Setup studio + + + + +
                          + + diff --git a/docs/authentication.mdx b/docs/authentication.mdx index f9f7e7d9e..86d7e1372 100644 --- a/docs/authentication.mdx +++ b/docs/authentication.mdx @@ -12,6 +12,90 @@ For [guarded PR approval](/pull-requests/guarded-approval), this same runtime PA The setup PAT and workflow PAT may have different owners and permissions. Do not paste the workflow PAT into the setup prompt unless you intentionally want the same token to perform both roles. +`copilot setup --web` offers the same guided or manual PAT choices in a local +browser page. It has separate masked inputs for the temporary operator PAT and +the bot PAT, displays the relevant grants next to each step, and verifies +identity and access through the same setup use cases as the terminal. The +browser never calls GitHub with a PAT directly or stores PAT values. GitHub's +own tab handles account switching, 2FA, repository selection, and creation. +The local page cannot prove that a browser extension or another process under +your OS user cannot see the value while you paste it. Afterward, delete the +temporary setup PAT in GitHub yourself; do not delete the bot PAT while the +installed Actions Secret still depends on it. + +The web assistant does not dispatch or temporarily install a credential-health +workflow before you press **Apply setup**. Existing Secret values cannot be +read back: re-enter the bot PAT to check its grants, and treat any preserved +optional provider Secret marked `unverifiable` as unknown until a later +`copilot doctor`/workflow check. The terminal setup retains its existing +credential-health behavior. + +## Assisted creation in the terminal + +When `copilot setup` needs a PAT interactively, it offers a guided link (the +default) or manual entry. In guided mode it first asks the setup choices that +determine PAT permissions: issue workflows, initial tag, Secret and Variable +management and storage scope, PR approval mode, and Projects. Choices already +fixed by flags or `--config` are not asked. You review the resulting grants +before the link appears; these answers carry into the full wizard without being +asked twice. The terminal first shows a short summary of required grants; +choose **view full permission table** at review to inspect every grant, reason, +and condition, then return to the same review without repeating setup choices. +Choosing manual PAT entry shows the full table directly. The later bot PAT +prompt has its own full-table option based on the finalized workflow grants, +not on the temporary setup PAT. The link is still **provisional** for facts that require GitHub +inspection, such as existing Secrets, inherited organization resources, and a +missing credential-health workflow. If the final plan needs +additional grants, setup stops before applying it and prints a corrected link. +Update the PAT in GitHub or create a replacement, then rerun setup. Guided +setup shows the account returned by GitHub and asks you to confirm it. + +For Projects, this early choice is only **whether you want the integration**. +Listing private organization Projects requires authorization, so the assistant +cannot reliably ask you to select Project numbers before the setup PAT exists. +If you opt in, the guided setup PAT requests organization **Projects: read**. +After you enter that PAT, the assistant lists the accessible Projects and lets +you choose their numeric Project numbers and, when available, their Status +options. You can enter a Project URL or number manually if discovery is +unavailable, and must verify that the bot account can access each chosen +Project. The separate bot PAT needs **Projects: read and write** to add and +update items when the workflows run. Fine-grained PATs cannot use the same +personal-Projects listing endpoint as organization Projects; personal-owner +setups use the documented manual path rather than pretending to discover them. + +If you choose **Review all setup choices again** at the permission preview, +the terminal clearly marks a second pass over your saved answers. Press Enter +to keep an answer, or change it; afterward you return to the same PAT review +with permissions recalculated. No PAT link has been accepted and no setup +mutation has started merely because you revisited these choices. + +After the plan, the bot link uses the selected workflow permissions. Enter the +expected bot login first: setup resolves its GitHub numeric ID, then checks the +PAT's own `/user` identity against that ID before any Secret write. A manual or +non-interactive PAT retains the existing permission audit but does **not** gain +this extra identity binding. A wrong bot account blocks installation. + +Both links use GitHub's [documented fine-grained PAT form](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). +They do not sign you in, complete 2FA, generate or revoke a token, or choose +an individual repository. Check the active browser account, change **All +repositories** to **Only select repositories**, select only the +target repository, and review the final GitHub form. A guided setup PAT uses a +one-day suggested expiry; delete it yourself in [GitHub PAT Settings](https://github.com/settings/personal-access-tokens) +afterward. The bot PAT uses a 90-day suggested expiry, may be shortened by +organization policy, and remains in Actions Secret `PAT`; arrange renewal +before it expires. GitHub's documentation is inconsistent: its [PAT limitations +list](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens-limitations) +calls out the Checks API, while the [check-runs endpoint +reference](https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference) +lists fine-grained PATs with `Checks: read`. The published [PAT URL-permission +table](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#repository-permissions) +does not list `checks`, although the form may currently accept `checks=read`. +The guided setup and bot links include it when required, but you must inspect +the final GitHub form and permission audit. If the form ignores that field, +select `Checks: read` manually or use a compatible credential. Setup never +assumes that a prefilled URL proves API access. Existing command-line token flags also remain, but putting +a PAT in a command can expose it in shell history or process listings. + ## Permission tables in `copilot setup` Immediately before each hidden PAT prompt, interactive setup prints a @@ -122,7 +206,21 @@ cannot obtain an authoritative remote snapshot or required selected inventory access, it stops before Secrets, Variables, labels, issue types, and tag writes with bounded recovery guidance, including for a repository-scope default. -GitHub does not reveal Secret values through its API. Copilot validates new credentials with provider metadata requests and validates existing remote Secrets through the read-only `copilot_credential_health.yml` workflow. Setup proves the exact file on the selected main ref and dispatches that path only when the workflow is also registered or installed on GitHub's default branch; doctor remains query-only and expects the normally indexed installed workflow. The health workflow reports each requested credential independently, but that bounded reachability result is not a permission audit. If `PAT` already exists, interactive setup asks you to re-enter it and runs the complete workflow-PAT permission matrix before provisioning; unattended setup must supply `PAT` again or stops before mutation. Temporary workflow bootstrap is available only during setup. A preauthenticated Codex session is runner state, not a Secret: it is accepted only when the runtime preflight can execute `codex login status` successfully. +GitHub does not reveal Secret values through its API. Copilot validates new credentials with provider metadata requests and may validate existing remote Secrets by dispatching the installed `copilot_credential_health.yml` workflow. Setup proves the exact file on the selected main ref and dispatches that path only when the workflow is also registered or installed on GitHub's default branch. Plain `copilot doctor` may create an Actions run; `copilot doctor --read-only` never dispatches it and leaves Secret values unverified. The health workflow reports each requested credential independently, but that bounded reachability result is not a permission audit. If `PAT` already exists, interactive setup asks you to re-enter it and runs the complete workflow-PAT permission matrix before provisioning; unattended setup must supply `PAT` again or stops before mutation. Temporary workflow bootstrap is available only during setup. A preauthenticated Codex session is runner state, not a Secret: it is accepted only when the runtime preflight can execute `codex login status` successfully. + +Terminal setup may attempt a temporary credential-health workflow creation before +final Apply when checking existing Secrets. That request can create Git commits +even if the workflow is later removed. If setup stops after this attempt, it +reports a partial outcome: inspect the selected branch and GitHub workflow +history before retrying. An ambiguous create failure is treated conservatively +as a possible remote change. The browser setup path does not perform this +pre-Apply bootstrap. + +The final setup-PAT audit requires GitHub to verify whether the repository +owner is a person or an organization. If that remote owner type is unknown or +unavailable, setup stops before provisioning and asks you to retry inspection; +the owner type you selected earlier for a guided link is only provisional. +Potential organization grants remain visible in the permission preview. For other existing credentials, choosing `keep` works only when the selected storage policy preserves the Secret in its current repository or organization @@ -166,7 +264,7 @@ For comment-driven assistance, read-only commands are available to anyone who ca - The person running setup needs a separate fine-grained PAT. Give it only the permissions required by the selected setup features: repository Metadata read and Contents read for inspecting repository files and installed workflows; Administration read when release/hotfix setup or doctor must inspect classic branch protection; Issues write for labels; Variables write for Repository Variables; Secrets read/write when provisioning Secrets; Actions read/write when checking or dispatching credential health; and organization Issue Types or Projects permissions only when those integrations are selected. There is no separate Workflows read permission for inspection. If setup will use organization-level Actions Secrets or Variables, the token also needs the corresponding organization Actions Secrets/Variables read and write permissions. Organization scope is valid only for repositories owned by an organization; setup detects personal repositories and stops before attempting organization writes. For existing Secrets, an installed `copilot_credential_health.yml` requires Actions write for dispatch but does not require Contents or Workflows write. Workflows write and Contents write appear only when the workflow is independently confirmed missing and temporary bootstrap is required; unavailable or unknown workflow state never authorizes temporary creation. Contents write can also be required for an initial tag or another explicitly selected repository mutation. + The person running setup needs a separate fine-grained PAT. Give it only the permissions required by the selected setup features: repository Metadata read and Contents read for inspecting repository files and installed workflows; Administration read when release/hotfix setup or doctor must inspect classic branch protection; Issues write for labels; Variables write for Repository Variables; Secrets read/write when provisioning Secrets; Actions read/write when checking or dispatching credential health; and organization Issue Types or Projects permissions only when those integrations are selected. Setup uses organization Projects **read**, not write, to list Projects after the PAT is entered. There is no separate Workflows read permission for inspection. If setup will use organization-level Actions Secrets or Variables, the token also needs the corresponding organization Actions Secrets/Variables read and write permissions. Organization scope is valid only for repositories owned by an organization; setup detects personal repositories and stops before attempting organization writes. For existing Secrets, an installed `copilot_credential_health.yml` requires Actions write for dispatch but does not require Contents or Workflows write. Workflows write and Contents write appear only when the workflow is independently confirmed missing and temporary bootstrap is required; unavailable or unknown workflow state never authorizes temporary creation. Contents write can also be required for an initial tag or another explicitly selected repository mutation. Enter it in the hidden prompt, or use `--token`/`PERSONAL_ACCESS_TOKEN` for automation. It remains in memory for the command and is not written to `.env`, a config file, or the `PAT` Secret. diff --git a/docs/configuration-checklist.mdx b/docs/configuration-checklist.mdx index 96e80c7ad..fce6dd71b 100644 --- a/docs/configuration-checklist.mdx +++ b/docs/configuration-checklist.mdx @@ -20,7 +20,10 @@ If guarded PR approval is selected, confirm the exact test/coverage producer tup ## Credentials -- [ ] Before entering each PAT, the setup terminal table matches the intended repository/organization target, access level, selected features, and storage scope. +- [ ] If using `copilot setup --web`, the repository shown in the local page is the intended checkout, the signed-in GitHub account and single selected repository are checked separately on each PAT form, and the final plan plus Apply step are explicitly reviewed. +- [ ] System/Light/Dark mode is readable in the current browser; theme choice does not change permission, credential, or setup policy. + +- [ ] Before entering each PAT, the setup terminal table or local web permission panel matches the intended repository/organization target, access level, selected features, and storage scope. - [ ] After entry, every `❌ Missing` required permission has been corrected; required unverifiable reads have been retried; every `? Unverifiable` required write has been compared manually with the PAT settings and explicitly acknowledged without treating it as a pass. - [ ] A publicly readable endpoint has not been mistaken for PAT evidence. After valid identity, only the exact successful public-repository read may be operationally usable while still shown as `Unverifiable`; public organization Members, Issue Types and writes never gain that exception. Members read needs a verified, active self-membership response for the selected organization. - [ ] If the `PAT` Secret already exists, its value has been re-entered (or supplied again to unattended setup) and the full workflow-PAT permission report has completed; credential-health success alone is not treated as permission evidence. @@ -57,7 +60,7 @@ If guarded PR approval is selected, confirm the exact test/coverage producer tup - [ ] Setup PAT and workflow PAT permission reports contain only stable permission IDs, target scopes, access levels, semantic statuses, and bounded reasons; tokens and raw provider responses are absent. - [ ] `copilot doctor` reports stable check IDs with `pass`, `warn`, `fail`, or dependency-blocked `skipped` and exits non-zero only for `fail`. - [ ] Invalid setup-PAT diagnosis still returns independent local checks; remote dependants identify their blocking check. -- [ ] Doctor is wired only to query ports. Temporary credential-health workflow bootstrap remains setup-only. +- [ ] `copilot doctor --read-only` uses metadata/resource queries only and marks Secret values unverified; plain doctor may dispatch the installed credential-health workflow but never bootstraps one. Temporary workflow bootstrap remains setup-only. - [ ] No legacy setup prompt adapter, compatibility result, dual reader/writer, state alias, or transitional flag is present. ## Release and hotfix orchestration diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 010db4746..33cd43b61 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -185,10 +185,27 @@ storage: OPENAI_API_KEY: organization ``` -The immutable questionnaire first inspects the repository and reports repository-scoped resources, organization resources available to that repository, repository visibility, and access errors. It then asks separately about Secret and Variable storage. Each accepted answer creates a fresh configuration snapshot; defaults, overrides, prior answers, and the result do not share mutable nested references. Repository resources take precedence over organization resources. With `preserveExisting: true`, an effective organization resource is inherited instead of being shadowed by a new repository value; add a name under `storage.secrets.overrides` or `storage.variables.overrides` when a repository-specific value is intentional. +Guided setup asks the permission-affecting Secret and Variable management and default-scope questions before the setup PAT is created. After token authentication, the remaining questionnaire inspects the repository and reports repository-scoped resources, organization resources available to that repository, visibility, and access errors. Remote-dependent inherited-resource overrides are asked then; they may require a corrected PAT. Each accepted answer creates a fresh configuration snapshot; defaults, overrides, prior answers, and the result do not share mutable nested references. Repository resources take precedence over organization resources. With `preserveExisting: true`, an effective organization resource is inherited instead of being shadowed by a new repository value; add a name under `storage.secrets.overrides` or `storage.variables.overrides` when a repository-specific value is intentional. Organization storage is available only for organization-owned repositories and requires organization Actions permissions on the setup PAT. If only one class should be global, set that class to `organization` and leave the other at `repository`. `--skip-secrets` and `--skip-variables` disable their respective setup operations without changing the other class. +Interactive PAT guidance does not add configuration keys: it is a one-run +choice at each hidden prompt. The setup-PAT form link uses the same grant +policy whether shown in the terminal or through `copilot setup --web`. Web +mode is also a one-run presentation choice: it adds no stored +theme, browser account, host, port, or credential setting. Non-secret flags +and `--config` keep their normal precedence; fields fixed by them are skipped +by the questionnaire. A supplied environment setup PAT is never consumed +silently in the web mode; the browser asks whether to use it without seeing +its value. `--web` rejects unattended approval and secret-bearing flags. +The link reflects reviewed local intent; remote-only conditions are disclosed separately +and may require correction after inspection. The bot-PAT +link is built from the final workflow permission policy and suggests a 90-day +expiry; the bot account owner must renew it and replace Secret `PAT` before +expiration. Manual and non-interactive token inputs keep their existing +precedence and validation. See [authentication](/authentication) for the +GitHub-owned creation, account verification, and deletion steps. + `--non-interactive` constructs no terminal and resolves only defaults, config, flags, and explicit external inputs. `--yes` approves the final plan but never invents a missing token, credential, target, or storage prerequisite. There is diff --git a/docs/dependency-rules.md b/docs/dependency-rules.md index 4874e20e3..f4f67e62b 100644 --- a/docs/dependency-rules.md +++ b/docs/dependency-rules.md @@ -19,6 +19,17 @@ entrypoint Inner behavior reaches outer details only through contracts owned by the appropriate inner boundary. +The local setup browser is an outer presentation adapter. Its Svelte files +may import type-only redacted view contracts from `src/application/contracts` +but cannot import provider adapters, mutation use cases, Node HTTP, or PAT +permission tables. `src/cli/web_setup_server.ts` owns loopback transport and +static assets; `src/cli/web_setup_adapters.ts` maps semantic browser decisions +to the existing application ports. Domain/application policy modules must not +import or re-export the browser, Vite, Node HTTP, or terminal renderers. The +web setup boundary test follows imports, re-exports, literal `require()` and +dynamic `import()` calls, including type-only forms, so a barrel or lazy load +cannot hide an outer-layer dependency. + ## Current physical layers ### Pure model and policy subset diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index 89f35034c..10c7327b2 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -8,6 +8,13 @@ Guarded PR approval uses a separate trusted observer entrypoint, a pure domain d The repository separates semantic application ports from provider-specific adapters. +Interactive setup milestones are tracked by the application-layer +`SetupJourneyUseCase`; its pure stage view model is rendered by the terminal +adapter. The terminal does not decide whether a PAT is valid or which grant is +required. Compact summaries project the same requirement objects used for the +guided URL and the full permission table. The setup command advances stages +only at existing audited boundaries; non-interactive setup keeps its own output. + GitHub conversation output crosses a closed publication boundary. Capabilities produce typed `none`, `reply`, `status`, `transition`, or `inline-finding` intents; the publication layer maps only reviewed semantic payloads and can @@ -152,6 +159,7 @@ The setup policy is intentionally split by responsibility: resolution and effective-resource preservation. - `setup_configuration_clone_policy.ts` owns reference-isolated configuration copies. - `setup_questionnaire_policy.ts` owns setup states, questions, transitions, and answers. +- `setup_pat_intent_policy.ts` identifies setup choices fixed by local inputs and owner-kind conflicts. - `setup_configuration_plan.ts` owns the reviewable provisioning plan. - `setup_token_permission_policy.ts` owns the setup/workflow PAT permission catalogs, conditional capability projection, strongest-level normalization, @@ -160,6 +168,53 @@ The setup policy is intentionally split by responsibility: - `setup_resource_provisioning.ts` owns grouping and port calls for Variables and Secrets. +Assisted PAT creation uses the existing permission policy as its only grant +source. Guided setup runs a permission-intent phase of the same questionnaire +before token entry, projects local choices through the setup permission policy, +and carries the draft and answered IDs into the remaining questionnaire. The +projection leaves remote-only grants unresolved for the final audit. The pure +`setup_pat_creation_url_policy.ts` maps required grants to +documented GitHub form parameters and rejects unsupported grants; it never +receives token material. `setup.ts` wires the terminal choice and hidden input +to that policy. In guided bot mode, the identity query adapter resolves the +chosen login through GitHub and identifies the supplied PAT through `/user`; +the application use case compares immutable numeric IDs before local setup +can write Secrets. GitHub owns the browser session, 2FA, token generation, and +deletion. Manual and unattended inputs retain the prior audit without the new +bot-ID assertion. + +The optional `setup --web` presentation compiles Svelte/Vite into packaged +`build/web` assets. The CLI serves those assets from a loopback-only Node HTTP +server; the browser sends bounded semantic answers to an in-memory bridge. +The bridge exposes the existing questionnaire policy state, credential ports, +`SetupWizardUseCase`, permission audits, and local Action application, rather +than parsing terminal output. It returns redacted views only: token values are +never serialized to the page or persisted in browser storage. The server +checks exact Host/Origin, a per-controller capability, prompt revision, +content type and body size, sets a restrictive CSP, and serves only bundled +asset paths. Before web Apply, the CLI rechecks repository identity, selected +file hashes, remote facts and final setup PAT access. The terminal path remains +the default and neither Action nor Bugbot API bundles import Svelte runtime. +The shared pre-PAT intent, initial and configured-PAT audits, and final web Apply decisions +live in application use cases with semantic ports; the command wires concrete +presenters, Git/remote readers, and the mutation entrypoint. CLI flag/file +merging uses a pure application policy behind a CLI parsing adapter. Import +graph tests reject transitive dependencies from these use cases into CLI, +infrastructure, Action, or browser code, and browser components can import +only redacted application contracts as types. + +Inside `web/src`, `App.svelte` is only the page shell. The session client owns +same-origin bootstrap, polling and revision-bound commands; it holds no pasted +PAT in a store. Presenters in `components/` render progress, prompts, context, +status and results from redacted views and callbacks, without network or +provider imports. A new prompt kind goes in its presenter, not the shell. +`style.css` imports layered palette, foundation, layout, controls, feedback +and responsive styles; shared card/button/banner patterns and semantic color +tokens cover both light and dark themes. The browser architecture test guards +these dependencies and file-size budgets. The application-level setup-session +coordinator extraction and full web acceptance budget remain open per the +[web setup SDD](../../specs/local-web-setup-assistant.md). + PAT permission validation follows the same dependency rule. The application `SetupTokenPermissionsUseCase` validates identity before invoking the narrow `SetupTokenPermissionQueryPort`; the infrastructure adapter performs only safe diff --git a/docs/how-to-use.mdx b/docs/how-to-use.mdx index f56f11790..fe2a90ce4 100644 --- a/docs/how-to-use.mdx +++ b/docs/how-to-use.mdx @@ -38,6 +38,199 @@ If the checkout does not include the compiled `build/` folder (e.g. it is gitign Once installed, the `copilot` command is available globally. Repository-dependent commands such as `copilot setup`, `copilot doctor`, `copilot check-progress`, `copilot think`, and `copilot do` must be run **from the root of the target repository**. The `copilot upgrade`, `copilot --version`, and help flows can run from any directory. Commands that access GitHub accept `--token` or `PERSONAL_ACCESS_TOKEN` from the environment. `copilot setup` and `copilot doctor` securely prompt for the setup PAT when run interactively; no `.env` file is read or created. `copilot setup --dry-run` is the only setup mode that can run without a token. See [CLI commands](/single-actions/workflow-and-cli). +Prefer a visual walkthrough? Run `copilot setup --web` from the repository +root on an attached Git branch. A detached HEAD is rejected before credentials +are requested; check out a branch first. Running from a subdirectory is also +rejected before the browser opens or a PAT is requested; change to the +repository root printed in the error and rerun. The CLI starts a short-lived page on +`127.0.0.1` and opens your browser; +if opening fails, paste the printed local URL into a browser on this computer. +Enter the 16-character pairing code shown in the terminal before setup state +appears. The URL contains no secret; keep the pairing code private. Loopback +by itself does not restrict access to your OS account, and anyone on this +computer with the code could join the session. After a page refresh, enter +the code again. Neither the code nor the session key is sent to GitHub. +After five incorrect codes, pairing and takeover pause for 30 seconds; the +session is not permanently locked. Wait and retry the code shown in the +launching terminal. Terminal setup remains available. +An input TTY is not required for `--web`, but you must be able to read the +launcher's stdout (or a privately captured task log) to obtain the code. If +that output was discarded, stop and relaunch from a readable terminal; there +is no code-recovery link in the browser. Treat any captured output as private. +The page shows the same six setup stages, keeps the review pass visibly part +of the current run, presents permissions and the final plan, and asks for a +separate final **Apply setup** approval. Use the System/Light/Dark control in +the top bar to choose a readable palette; the choice lasts only in that tab. +After a successful Apply, **Verify installed setup (read-only)** performs a +separate metadata inspection in the same local session and shows only summary +counts. It does not dispatch an Action or read Secret values; if you have +already deleted the setup PAT in GitHub, run `copilot doctor --read-only` +with a valid setup PAT instead. A failed or partial Apply does +not offer this button: inspect the itemized result and use the CLI command +after resolving the uncertain resources. +The language selector starts in English and offers Spanish, French, and +Portuguese. Switching language does not change the repository locale or reset +your answers. Question headings, explanations, options, permission details, +plan warnings, local errors, and credential status summaries are translated. +A persistent review notice remains in non-English modes while rare dynamic +GitHub/provider diagnostics and the end-to-end language review are completed. +GitHub's own PAT form uses the language +configured on GitHub, independently of this page. +After confirming the checkout, choose **Basic guided setup** (recommended) or +**Customize every setting**. Basic retains explicit permission, security, +branch-role, Project, approval, and storage decisions; only selected advanced +agent, branch-prefix, and Bugbot values stay at their defaults. Explicit +non-default configuration is still asked. Each section shows question-level +progress and has a Previous question control; the final plan summarizes key +decisions and names sections whose advanced defaults were retained. Use its +Change controls to reopen any section and recheck the plan and setup PAT +permissions before approving. A return to a previous section is the same run, +not a new setup attempt. No GitHub change starts by reviewing or editing. +PAT values are submitted to the local process through masked fields and are +not restored after refreshing the page. A new setup question starts with its +own suggested answer; status updates do not erase an answer you are typing. +A second tab is read-only until you explicitly re-enter the launcher's pairing +code there to take control; this invalidates the first tab's control. The +pairing code authorizes this takeover, so share it only with someone you trust +to control the setup. The terminal remains the default with +`copilot setup`. + +In the terminal wizard, each question shows a short explanation, the suggested +answer, and a related documentation URL. Type `?` at a text, number, or +single-choice prompt to open detailed **what/when/where/how/why/example/effect/ +verification** guidance without recording an answer. At a multi-selection +prompt, press `?` to see the same guidance without losing the current selection; +press `B` to revisit the previous question, and press Enter only when you are +ready to confirm it. At text, number and single-choice prompts, enter `:back` +to return without clearing other answers. At the final plan, enter `:edit` +and select a section to correct it before approving. Documentation opens only if +you choose to follow its URL; setup does not automatically browse to those +pages. The setup/bot PAT method, repository-owner and bot-login prompts also +accept `?` for English help without making a choice. The setup PAT prompt prints +both the authentication guide and GitHub PAT settings for manual cleanup. At +the final Apply confirmation, `?` explains which resources may change and how +to inspect a partial failure; it never approves the plan. + +The web mode still opens GitHub's official form in a separate tab for each +PAT; it cannot pick your GitHub account, complete 2FA, select an individual +repository, generate a PAT, or revoke it. Confirm the account and repository +on GitHub yourself. If `PERSONAL_ACCESS_TOKEN` is available in your +environment, the web page asks whether to use it without revealing its value; +closing Copilot cannot unset the parent shell variable. `--web` cannot be +combined with `--non-interactive`, `--yes`, `--token`, `--workflow-pat`, +`--secret`, or `--confirm-unverifiable-write-permissions`: provide those +decisions in the browser or use the terminal setup instead. `--dry-run --web` +shows a no-change preview. + +Before its final Apply approval, the web mode performs read-only GitHub +inspection and PAT checks; it does not dispatch or temporarily install a +credential-health workflow. Re-enter an existing bot PAT for its grant audit. +An optional existing provider Secret that you choose to keep may remain +`unverifiable`; check runtime health with `copilot doctor` after installation. +Before Apply, the assistant also checks for changes to managed guidance files +and their ownership manifest. This includes files that may be retired when +repository agent guidance is turned off; if they changed after plan approval, +setup stops before modifying them and asks you to review a fresh plan. +In the browser questionnaire, an issue-workflow choice defaulted to **All** +stays selected if you continue unchanged; clearing that selection submits +**none** explicitly. + +If PR approval is enabled, the web assistant reads recent completed PR CI +runs and proposes the exact check name, GitHub App ID, and producing workflow +when the setup PAT has `Actions: read` and `Checks: read`. Open the linked run +and verify the job and its mandatory coverage step before confirming the +producer attestation. A green check is not evidence of a coverage threshold. +If no reliable candidates can be read, enter the exact tuple manually; the +assistant does not guess an App ID. The coverage check selector only offers +checks you selected as trusted producers. `check` mode trusts a CI job that +fails below your coverage budget; `numeric` mode additionally needs the +`copilot-diff-coverage-v1` reporter artifact and explicit verification. +When an active repository-owned ruleset for the configured development branch identifies an +exact check/App pair as required, the assistant links that ruleset. Otherwise +it says **not checked**; this does not mean the check is optional, because +legacy branch protection, inherited organization rulesets, or inaccessible rules may also apply. The ruleset +read uses repository Metadata access and does not add a broader PAT grant. + +If the assistant stops, the result page distinguishes cancellation, expired +session, permission or storage blockers, and a potentially partial Apply. +It shows the stopped stage and a next action. After Apply it lists setup files, +Secrets, labels, issue types, Variables, and the initial tag as completed, +skipped, not started, or needing inspection, without +revealing PATs or provider error payloads. A diagnostic reference lets you +find more detail in the terminal. A "no setup changes started" +message does not mean a PAT created separately on GitHub was deleted. If +the browser reports an unknown cause, inspect the terminal's final error +before retrying. If a requested Secrets or Variables writer is unavailable, +setup stops before changing any setup resource and marks operations **not +started**. An attempted write that fails is marked **needs inspection**, since +GitHub may have applied it before reporting the error. +Secrets and Variables are **completed** only when GitHub reports a created or +updated value; if every requested value was kept unchanged, they are **skipped**. +An error shown after a rejected browser action stays visible during background +updates; a successful next action clears it. + +Interactive `copilot setup` offers a guided GitHub link or manual entry for each +PAT. The first link prepares a short-lived **setup PAT** for the person +configuring the repository. Before showing it, guided setup asks only the local +choices that affect its grants, shows an exact permission preview and notes +what remains unknown until GitHub is inspected. The later questionnaire reuses +those answers. The second link, after the setup plan, prepares the +**workflow PAT** for the bot account. + +Projects are a two-stage choice: before creating the setup PAT, answer only +whether you intend to use Projects so the link can include **Projects: read**. +After entering the PAT, select accessible organization Projects from the +browser checkboxes or terminal multi-select. The displayed number is the +numeric Project number used by `project-ids`, not a GraphQL `PVT_` node ID. +If GitHub cannot return a verifiable list, the assistant explains why and +offers manual entry of a Project URL or number; it does not silently treat an +empty list as proof that no Projects exist. Configure the exact **Status field +options** (not visual board columns) used for newly created and in-progress +items. Multiple selected Projects currently must share the selected Status +values; if they use different Status vocabularies, choose compatible Projects +or configure them separately. The assistant does not claim that a single value +works in every Project when discovery shows otherwise. The bot PAT needs +Project write access later, when the workflow runs. + +Explicit `features.release` and `features.hotfix` values from `--config` or +CLI flags remain fixed during the pre-PAT permission review. The issue-workflow +question shows those fixed choices before you answer and rejects a selection +that contradicts them; it never quietly changes the configured feature or +the PAT grant. If either is explicitly enabled, Issues must stay enabled. +Edit the configuration or flags and restart if you intend to change that +policy. + +The interactive terminal also shows your current phase: Repository → Setup +choices → Setup PAT → Plan → Bot PAT & credentials → Apply. These are milestones, +not a percentage or a fixed number of questions. Before a remote mutation +attempt, it says that no changes have been made. Checking existing Secrets may +attempt a temporary credential-health workflow before final Apply; from that +point a failed run is marked partial, with branch-inspection guidance. After +Apply starts, a failed run is likewise marked partial so +you can inspect what was installed. The initial and guided PAT views show a +compact list of required grants. Choose **view full permission table** during +review to see reasons and conditional grants without restarting the questions; +manual PAT entry shows that table directly. Supplied-token and unattended paths +retain the full table. This display never includes token values. + +At the setup PAT permission review, **Review all setup choices again** starts +another pass over the same in-memory answers. The terminal marks it as a review +pass, temporarily returns to the Setup choices phase, and explains that Enter +keeps each previous answer. When the pass ends, you return to the setup PAT +permission summary with any changed grants recalculated. This is not a new +setup run; no repository changes occur during these passes. The later full +wizard still reuses these answers instead of asking them again. + +Open each link in the appropriate GitHub +account, complete GitHub's sign-in/2FA, change **All repositories** to **Only +select repositories**, and **select only the intended repository** +on the form, review the grants, and paste the generated value into the hidden +terminal prompt. The links prefill fields; they neither create a PAT nor select +an individual repository. The setup PAT is suggested for one day and must be +deleted by you in GitHub after the run. The bot PAT is suggested for 90 days, +remains active as Actions Secret `PAT`, and needs renewal before expiry. See +[authentication](/authentication) for permission and recovery details. + If you previously installed Copilot from a local checkout, installing the published package with pnpm switches the same `copilot` command to the published package. Check which executable and package are active: @@ -112,14 +305,14 @@ The complete command reference, including every supported option, is in [Workflo copilot setup ``` - Before applying the plan, the wizard securely asks for the setup PAT. For automation, pass it explicitly or through the environment: + In guided interactive setup, answer the short permission-intent questions and review the proposed grants before creating the setup PAT in GitHub. The wizard then securely asks for the token and continues with the remaining plan questions. For automation, pass it explicitly or through the environment: ```bash PERSONAL_ACCESS_TOKEN=your_setup_pat copilot setup --non-interactive --yes --skip-secrets # or: copilot setup --token your_setup_pat ``` - The wizard shows a reviewable plan and asks for confirmation. Its forward-only questionnaire keeps defaults and every answer immutable; cancel and rerun if you need to revise an earlier stage. `Ctrl-C` or end-of-input exits 130 with no writes, while declining the final plan exits 0 with no writes. Use `copilot setup --dry-run` to inspect the plan without a token or changes. + The wizard shows a reviewable plan and asks for confirmation. During either questionnaire, use `:back` in the terminal or **Previous question** in the browser to correct an answer; the final plan also offers **Change section** (terminal: `:edit`) without discarding other answers. Revisions recompute dependent questions and PAT grants, and a newly required grant must pass another audit before Apply. The web plan lists agent/model routes, issue workflows, exact trusted check/App/workflow identities, coverage evidence, Project Status transitions, scopes, files, Variables, and Secret names. Declining the plan makes no setup changes. The web assistant does not start writes before final Apply, but terminal credential-health verification can attempt a temporary workflow before then; heed any partial-change warning. `Ctrl-C` or end-of-input exits 130, while declining the final plan exits 0. Use `copilot setup --dry-run` to inspect the plan without a token or changes. In automation, `--non-interactive` creates no terminal. `--yes` approves only the final plan: it does not supply a missing setup PAT, workflow credential, provider credential, target, organization prerequisite, or permission acknowledgement. If every required read is verified or positively operationally usable but safe probes cannot prove required writes, inspect the PAT settings first and pass the separate `--confirm-unverifiable-write-permissions` flag. It never bypasses missing or unusable unverifiable read access. @@ -153,7 +346,7 @@ The complete command reference, including every supported option, is in [Workflo **Optional but keep coherence:** - **Labels**: If you change any label input (e.g. `feature-label`, `bugfix-label`, `deploy-label`), use the **same** label names in your issue templates (`labels:` in each `.yml`) and when labeling issues manually. Otherwise the action will not recognize the type or flow. - **Branch name prefixes**: The action uses inputs like `feature-tree`, `bugfix-tree`, `release-tree`, `hotfix-tree` (defaults: `feature`, `bugfix`, `release`, `hotfix`) to create branch names. If you change them, branch names will follow the new prefixes; keep templates and docs in sync. - - **Project columns**: Default column names are "Todo" and "In Progress". If you rename columns in GitHub Projects, set the corresponding action inputs (`project-column-issue-created`, `project-column-issue-in-progress`, etc.) so the action moves issues/PRs to the correct columns. + - **Project Status options**: Default values are "Todo" and "In Progress". If you rename the options in the Project's **Status** single-select field, set the corresponding action inputs (`project-column-issue-created`, `project-column-issue-in-progress`, etc.) to the exact option names. These legacy input names say `column`, but ProjectV2 updates the Status field, not a visual board column. **Bugbot autofix (issue/PR comments):** Workflows that run on `issue_comment` or `pull_request_review_comment` (so users can ask the bot to fix reported findings) must grant **`contents: write`** so the action can commit and push. On **issue_comment**, the action resolves the branch from an open PR that references the issue and checks out that branch before applying fixes and pushing. See [Bugbot autofix](/bugbot/autofix) and [Troubleshooting](/security-operations/operations/troubleshooting). @@ -363,7 +556,13 @@ installation itself validates the pinned provisioning inputs. The `copilot_crede read-only with respect to repository configuration: it executes provider-specific health checks and reports only whether each requested credential is usable. GitHub does not expose Secret values through its API, so `copilot doctor` dispatches this -workflow when it is available on the repository's default branch. +workflow when it is available on the repository's default branch. For a +non-mutating inspection after a partial setup, use `copilot doctor --read-only`: +it checks installed files, metadata, names, Variables and permissions but does +**not** dispatch the credential-health Action. Secret values are therefore +reported as unverified rather than healthy. The browser assistant recommends +this mode; running plain `copilot doctor` is a separate, explicit choice when +you want the workflow-based credential check. When Repository Variables are enabled, setup creates the common `AGENT_*` contract, the provider/model/effort variables for each configured `FINDINGS_*`, `FIXER_*`, @@ -444,7 +643,7 @@ After the tutorial and file customization, you can: - Set **repository or organization variables** for the agent CLI contract (`AGENT_PROVIDER`, `AGENT_MODEL_PROVIDER`, `AGENT_MODEL`, `AGENT_EFFORT`, `AGENT_ALLOWED_MODEL_PROVIDERS`, and `AGENT_ALLOWED_MODELS`) and, when needed, independent task overrides for `FINDINGS_*`, `REVIEWER_*`, `PLANNER_*`, `FIXER_*`, and `TESTER_*`. The supplied Copilot workflow templates forward these values to the action. Keep `CURSOR_API_KEY` available only when one of the configured task providers is Cursor. - Enable the `inactiveIssueClosure` setup feature when you want the scheduled cleanup, and adjust `INACTIVITY_THRESHOLD_HOURS` (1–8760 hours) to match your retention policy. This feature is disabled by default because it closes GitHub issues. -- Adjust **project column names** and **branch names** via action inputs so the action moves issues/PRs to the right columns and uses your branch naming. +- Adjust **Project Status option names** and **branch names** via action inputs so the action sets the intended Status and uses your branch naming. - Customize **issue templates** (copy, add fields, change labels) while keeping label and workflow names consistent as above. - Add or modify **release/hotfix** workflow steps (e.g. build, deploy) while keeping the workflow **filenames** and the action inputs `release-workflow` and `hotfix-workflow` in sync. diff --git a/docs/issues/assignees-and-projects.mdx b/docs/issues/assignees-and-projects.mdx index 7daf26bb5..81d7e5713 100644 --- a/docs/issues/assignees-and-projects.mdx +++ b/docs/issues/assignees-and-projects.mdx @@ -41,8 +41,34 @@ Linking issues to **GitHub Project** boards requires a **Personal Access Token ( ### project-ids -- **Format:** Comma-separated list of **project IDs** (numeric). You find the project ID in the project URL or via the API; it is **not** the project name. -- **Effect:** When the action runs (e.g. on issue opened or edited), it **links the issue** to each of these projects and can **move the issue** to a column (e.g. "Todo", "In Progress") based on **`project-column-issue-created`** and **`project-column-issue-in-progress`** (see [Configuration](/configuration)). +- **Format:** Comma-separated list of numeric **Project numbers**. For example, `https://github.com/orgs/ACME/projects/12` has Project number `12`. This is not the Project title or its GraphQL `PVT_` node ID. +- **Effect:** When the action runs (e.g. on issue opened or edited), it **links the issue** to each of these projects and can set its **Status** field (e.g. "Todo", "In Progress") based on **`project-column-issue-created`** and **`project-column-issue-in-progress`** (see [Configuration](/configuration)). Despite their legacy `column` names, these inputs name Status single-select options, not visual board columns. + +During guided setup, first choose whether Projects integration is needed. That +lets the setup PAT request organization **Projects: read**; the assistant can +then list open, accessible Projects for selection; closed Projects are excluded. +If discovery is unavailable, enter +the Project URL or number manually and confirm the account and Status options +in GitHub. The workflow's bot PAT separately needs Projects **read and write**. +Use **Retry GitHub discovery** (or `r` in the CLI) to query again without +restarting setup; this is read-only and does not ask for another PAT. A list +with no entries means no *open, accessible* Projects were returned by the bounded +query, not that the organization definitely has none. Check the setup PAT's +organization `Projects: read` grant and the Project owner, then use a verified +number or URL if the Project still does not appear. Personal-owner Projects +cannot be listed through this fine-grained-PAT endpoint, so enter their URL +number manually. See [GitHub's Projects API](https://docs.github.com/en/rest/projects/projects). +Discovery reads at most two pages; if the list is marked truncated, a Project +not shown may still be available. Enter its verified URL or number manually. + +All selected Projects must contain each Status value you configure for issue +creation, PR creation and the two in-progress transitions. The wizard proposes +common options when GitHub lets it inspect them. It cannot map different +Status vocabularies per Project; choose compatible Projects or configure them +separately. For manually entered Projects, check each option in GitHub before +you apply the plan. The interactive wizard asks you to confirm all four exact +values after inspecting each Project; accepting a suggestion is not +verification. See [GitHub Project fields](https://docs.github.com/en/rest/projects/fields). The column update uses the exact ProjectV2 item returned by GitHub when the issue is added. It does not wait and re-list the board, so normal Project diff --git a/docs/issues/configurable-workflows.mdx b/docs/issues/configurable-workflows.mdx index 980a8079e..a47592daf 100644 --- a/docs/issues/configurable-workflows.mdx +++ b/docs/issues/configurable-workflows.mdx @@ -7,6 +7,8 @@ description: Select Issue Forms and understand live runtime admission. `copilot setup` uses one ordered workflow catalog for Issue Forms, routing labels, native Issue Types, branch roles, release dependencies, runtime admission, and generated agent guidance. Interactive setup starts with **All** selected: use Space to toggle a kind and Enter to confirm. +In the terminal, Enter without toggling keeps **All**. Pressing Space while **All** is selected clears every kind; Enter then submits that empty selection explicitly instead of restoring the default. If issue automation is still enabled, setup stops before applying changes and explains that you must select at least one kind. To configure no issue workflows, turn off issue automation at the earlier question. In the text-only fallback, type `none` to submit an empty selection explicitly. + For non-interactive setup, pass stable IDs: ```bash diff --git a/docs/pull-requests/guarded-approval.mdx b/docs/pull-requests/guarded-approval.mdx index 7bbc2079b..6653f7031 100644 --- a/docs/pull-requests/guarded-approval.mdx +++ b/docs/pull-requests/guarded-approval.mdx @@ -19,6 +19,24 @@ New `copilot setup` runs offer `recommend` by default: Copilot assesses evidence Use `copilot setup` interactively to select an exact CI check, its GitHub App ID, and the workflow that produces it. The coverage check must enforce your repository's coverage budget; a successful advisory upload does not prove a percentage. Setup displays the selected names, target branches, Bugbot prerequisites, and branch-rule readiness before confirmation. The generated `copilot_pull_request_approval.yml` must reach the repository default branch before its `workflow_run` wakeups operate. +With `copilot setup --web`, the setup PAT can request conditional `Actions: +read` and `Checks: read` grants so the assistant can offer candidates from +recent completed pull-request runs. Each candidate includes an exact job, +source App ID, workflow name, observed result, and link to its run. Inspect +the job's workflow and coverage-enforcing step yourself before attesting; +Copilot never infers a coverage budget from a green check or job name. If +GitHub cannot provide candidates, use the manual exact tuple entry. +The coverage check dropdown is limited to your chosen trusted checks. +You can retry the read-only GitHub lookup twice without restarting setup or +re-entering the setup PAT. No recent runs, missing permissions and an API +failure are distinct states; none proves that your repository has no CI. +Every suggested producer shows the job, workflow, App ID, result, commit, +observation time and run link. The wizard has **not** checked whether a branch +rule requires that producer; confirm this in GitHub. If two selected producers +share a check name, setup stops before coverage selection because the stored +coverage setting has only one name. Choose one producer or give the CI jobs +distinct names. See [GitHub status checks](https://docs.github.com/en/pull-requests/reference/status-checks). + When testing this repository itself before a new package release, the source repository has a reviewed observer variant that checks out only its trusted default-branch revision and invokes `./`. Local setup preserves that variant instead of replacing it with the consumer template, whose `v3` reference cannot run unreleased changes. Consumer repositories need a published Action version containing this feature before enabling the generated observer. For non-interactive setup, supply the producer explicitly: diff --git a/docs/security-operations/operations/provisioning.mdx b/docs/security-operations/operations/provisioning.mdx index f870d88dc..6392a3a44 100644 --- a/docs/security-operations/operations/provisioning.mdx +++ b/docs/security-operations/operations/provisioning.mdx @@ -4,6 +4,12 @@ description: Pinned installation and verification of the selected agent CLI. --- # CLI provisioning +For local repository onboarding, `copilot setup --web` serves the precompiled +assistant from the installed CLI package on `127.0.0.1`; it does not start a +Vite development server, download browser assets, or provision an agent CLI. +If the browser opener is unavailable, use the loopback URL printed in the +terminal. The default `copilot setup` remains a fully terminal-driven path. + When running in GitHub Actions, the Action provisions and verifies only the selected runtime. `AGENT_PROVISIONING=auto` reuses any available operator-owned executable without replacing it. If the default Codex or OpenCode executable is diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index decf97f22..ec39ba44a 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -9,6 +9,41 @@ If guarded PR approval is missing, run `copilot doctor` and inspect the ordered This guide helps you resolve common issues you might encounter while using Copilot. Expand the section that matches your problem. +If the guided setup PAT belongs to the wrong account, decline the account +confirmation, delete the unintended PAT in [GitHub PAT Settings](https://github.com/settings/personal-access-tokens), +and rerun setup in the correct browser account. If the final permission table +requires more than the reviewed local-intent link, inspect the named grant +delta, use the corrected link printed by setup, and rerun; no approved setup +mutation has started. If the final plan removes grants, your existing PAT may +have excess access; replace it for strict least privilege. A guided bot PAT +created while signed into another account fails the numeric-ID check before +the Secret is written. Delete that unused PAT and create one as the chosen bot. +If setup fails after applying changes, inspect the Actions Secret name and +scope before revoking or replacing the bot PAT: it may already be active. +Cancelling setup never revokes either PAT. Delete an unused setup PAT in GitHub; +renew an installed bot PAT before its suggested 90-day expiry. + +For `copilot setup --web`, an inaccessible page or failed browser launch does +not imply a setup failure: open the exact `127.0.0.1` URL printed by the CLI. +Enter the terminal's 16-character pairing code in the page; after a refresh, +enter it again. Do not share the code. TCP loopback can be reached by other +local OS users; the code, not the public URL or OS-user identification, grants +access to this setup session. After five wrong codes, restart setup. +If the local bind or bundled assets fail, stop and use `copilot setup` in the +terminal. A second browser tab is read-only until you select **Take control +in this tab**; the former tab then cannot submit decisions. A rejected stale +answer means the page should refresh to the current decision, not replay it. +If the page closes during Apply, inspect the terminal result and run +`copilot doctor` before retrying; do not assume that completed local or remote +writes were rolled back. No local session shutdown revokes a PAT in GitHub. + +If setup reports a lock belonging to a stopped process, it will not delete +that lock automatically: another setup could have acquired the same path in +the meantime. Verify that no setup process for this checkout is running, +then remove **only the exact lock file path printed in the error** and retry. +Never remove a lock for an active process or clear the entire temporary +directory. The lock contains no PAT or other credential. + **Setup cancellation:** `Ctrl-C` or end-of-input intentionally exits 130 and @@ -124,6 +159,16 @@ This guide helps you resolve common issues you might encounter while using Copil If another actor creates or changes the temporary workflow, setup does not overwrite or remove that actor's file; inspect the selected branch before retrying a reported cleanup failure. + Terminal setup marks the run partial as soon as it attempts a temporary + credential-health workflow create, even if the request fails ambiguously or + the file is later removed. Inspect the selected branch and GitHub workflow + history before retrying: the create/delete commits remain in history. + Browser setup never performs this bootstrap before final Apply. + + If the final PAT audit says GitHub could not verify the repository owner + type, do not treat the earlier guided owner selection as proof. Confirm the + repository owner in GitHub, restore API access, and retry setup. No local + provisioning or bot Secret write should begin from that blocked audit. Expected missing or unconfirmed final PAT permissions return a blocked result with the chosen configuration and stop before storage validation or any mutation. diff --git a/docs/single-actions/workflow-and-cli.mdx b/docs/single-actions/workflow-and-cli.mdx index 5605c8df1..98cb2ce69 100644 --- a/docs/single-actions/workflow-and-cli.mdx +++ b/docs/single-actions/workflow-and-cli.mdx @@ -302,10 +302,11 @@ Checks: 1 pass, 0 warn, 1 fail, 0 skipped. No repository configuration was changed. ``` -Doctor's composition contains query ports only: it never creates, updates, deletes, or overwrites repository configuration. The credential health query may dispatch the already-installed `copilot_credential_health.yml` workflow, which creates an Actions run but does not modify repository configuration or expose Secret values. Only setup owns the separate temporary health-workflow bootstrap capability. +Doctor never creates, updates, deletes, or overwrites repository configuration. Plain `copilot doctor` may dispatch the already-installed `copilot_credential_health.yml` workflow, creating an Actions run to check otherwise unreadable Secret values. For metadata-only inspection without any Action dispatch, use `--read-only`; Secret values are then reported as unverified. Only setup owns the separate temporary health-workflow bootstrap capability. ```bash copilot doctor +copilot doctor --read-only copilot doctor --token "$SETUP_PAT" copilot doctor --config .copilot-setup.yml --non-interactive ``` diff --git a/jest.config.js b/jest.config.js index 5d58ecb88..893695b04 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,8 @@ module.exports = { passWithNoTests: true, collectCoverageFrom: [ 'src/**/*.ts', + 'web/src/**/*.ts', + '!web/src/main.ts', '!src/**/*.d.ts', '!src/**/__tests__/**', '!src/**/*.test.ts' @@ -16,6 +18,12 @@ module.exports = { statements: 90, functions: 88, branches: 82 + }, + './web/src/': { + lines: 98, + statements: 95, + functions: 100, + branches: 85 } }, coverageDirectory: 'coverage', diff --git a/package.json b/package.json index a9bfc72db..337d56ffa 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "files": [ "action.yml", "build/cli/index.js", + "build/web/", "build/github_action/index.js", "build/api/index.js", "build/api/src/", @@ -48,7 +49,9 @@ "node": ">=24" }, "scripts": { - "build": "node scripts/clean-generated-bundles.cjs && ncc build src/actions/github_action.ts -o build/github_action && ncc build src/cli.ts -o build/cli && ncc build src/api.ts -o build/api && node scripts/prepare-generated-bundles.cjs && chmod +x build/cli/index.js", + "build": "node scripts/clean-generated-bundles.cjs && pnpm run build:web && ncc build src/actions/github_action.ts -o build/github_action && ncc build src/cli.ts -o build/cli && ncc build src/api.ts -o build/api && node scripts/prepare-generated-bundles.cjs && chmod +x build/cli/index.js", + "build:web": "vite build --config web/vite.config.mts", + "check:web": "svelte-check --tsconfig web/tsconfig.json && vite build --config web/vite.config.mts", "validate:build": "node scripts/validate-build.cjs", "validate:npm-package": "node scripts/validate-npm-package.cjs", "smoke:npm-package": "node scripts/smoke-test-npm-package.cjs", @@ -84,13 +87,17 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@sveltejs/vite-plugin-svelte": "^7.3.1", "@types/jest": "^30.0.0", "@types/node": "^22.9.1", "@vercel/ncc": "^0.36.1", "eslint": "^10.10.0", "jest": "^30.5.1", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", "ts-jest": "^29.4.5", "typescript": "^5.2.2", - "typescript-eslint": "^8.70.0" + "typescript-eslint": "^8.70.0", + "vite": "^8.3.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77da4790a..6cb3a48dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.10.0) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.1 + version: 7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.0))(vite@8.3.1(@types/node@22.20.1)) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -57,6 +60,12 @@ importers: jest: specifier: ^30.5.1 version: 30.5.1(@types/node@22.20.1) + svelte: + specifier: ^5.57.1 + version: 5.57.1(@typescript-eslint/types@8.70.0) + svelte-check: + specifier: ^4.7.6 + version: 4.7.6(picomatch@4.0.5)(svelte@5.57.1(@typescript-eslint/types@8.70.0))(typescript@5.9.3) ts-jest: specifier: ^29.4.5 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(jest-util@30.5.1)(jest@30.5.1(@types/node@22.20.1))(typescript@5.9.3) @@ -66,6 +75,9 @@ importers: typescript-eslint: specifier: ^8.70.0 version: 8.70.0(eslint@10.10.0)(typescript@5.9.3) + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@22.20.1) packages: @@ -454,6 +466,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -521,6 +536,9 @@ packages: '@octokit/types@18.0.0': resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@parcel/watcher-android-arm64@2.6.0': resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} @@ -605,6 +623,99 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sinclair/typebox@0.34.52': resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} @@ -614,6 +725,22 @@ packages: '@sinonjs/fake-timers@15.4.0': resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.3.1': + resolution: {integrity: sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -888,6 +1015,14 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + babel-jest@30.5.1: resolution: {integrity: sha512-ge1xUVZS91ml09YRMgRGgeKJ4YJpcOiuwteAxFBYLugQyp7cRw+hHej6Ho0vPjvLrjq60bb7JPHH9LAMA1/krA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -985,6 +1120,10 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1000,6 +1139,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -1061,6 +1204,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1120,6 +1266,9 @@ packages: jiti: optional: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@11.2.0: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1133,6 +1282,14 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} + esrap@2.4.0: + resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -1203,6 +1360,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1301,6 +1463,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -1518,9 +1683,82 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -1542,6 +1780,12 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1571,9 +1815,18 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1603,6 +1856,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -1665,6 +1922,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -1673,6 +1934,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1702,6 +1967,10 @@ packages: react-is@19.3.0: resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1714,6 +1983,15 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1746,6 +2024,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -1801,6 +2083,18 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.57.1: + resolution: {integrity: sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==} + engines: {node: '>=18'} + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1915,6 +2209,57 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1966,6 +2311,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + snapshots: '@actions/core@2.0.3': @@ -2500,6 +2848,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -2578,6 +2928,8 @@ snapshots: dependencies: '@octokit/openapi-types': 29.0.1 + '@oxc-project/types@0.151.0': {} + '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -2639,6 +2991,53 @@ snapshots: '@pkgr/core@0.3.6': {} + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@sinclair/typebox@0.34.52': {} '@sinonjs/commons@3.0.1': @@ -2649,6 +3048,21 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.1(@typescript-eslint/types@8.70.0))(vite@8.3.1(@types/node@22.20.1))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.4.2 + obug: 2.2.1 + svelte: 5.57.1(@typescript-eslint/types@8.70.0) + vite: 8.3.1(@types/node@22.20.1) + vitefu: 1.1.3(vite@8.3.1(@types/node@22.20.1)) + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -2917,6 +3331,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + babel-jest@30.5.1(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -3039,6 +3457,10 @@ snapshots: char-regex@1.0.2: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + ci-info@4.4.0: {} cjs-module-lexer@2.2.1: {} @@ -3051,6 +3473,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -3087,6 +3511,8 @@ snapshots: detect-newline@3.1.0: {} + devalue@5.9.4: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.406: {} @@ -3157,6 +3583,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@11.2.0: dependencies: acorn: 8.18.0 @@ -3169,6 +3597,12 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.4.0(@typescript-eslint/types@8.70.0): + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + optionalDependencies: + '@typescript-eslint/types': 8.70.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -3250,6 +3684,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.3: + optional: true + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -3327,6 +3764,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + is-stream@2.0.1: {} isexe@2.0.0: {} @@ -3753,8 +4194,59 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -3773,6 +4265,14 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + make-dir@4.0.0: dependencies: semver: 7.8.5 @@ -3795,8 +4295,12 @@ snapshots: minipass@7.1.3: {} + mri@1.2.0: {} + ms@2.1.3: {} + nanoid@3.3.19: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -3815,6 +4319,8 @@ snapshots: dependencies: path-key: 3.1.1 + obug@2.2.1: {} + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -3875,12 +4381,20 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + pirates@4.0.7: {} pkg-dir@4.2.0: dependencies: find-up: 4.1.0 + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} pretty-format@30.4.1: @@ -3909,6 +4423,8 @@ snapshots: react-is@19.3.0: {} + readdirp@4.1.2: {} + require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -3917,6 +4433,31 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + semver@6.3.1: {} semver@7.8.5: {} @@ -3935,6 +4476,8 @@ snapshots: slash@3.0.0: {} + source-map-js@1.2.1: {} + source-map@0.6.1: {} sprintf-js@1.0.3: {} @@ -3988,6 +4531,39 @@ snapshots: dependencies: has-flag: 4.0.0 + svelte-check@4.7.6(picomatch@4.0.5)(svelte@5.57.1(@typescript-eslint/types@8.70.0))(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.57.1(@typescript-eslint/types@8.70.0) + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.57.1(@typescript-eslint/types@8.70.0): + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@types/estree': 1.0.9 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.4 + esm-env: 1.2.2 + esrap: 2.4.0(@typescript-eslint/types@8.70.0) + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 @@ -4109,6 +4685,21 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + vite@8.3.1(@types/node@22.20.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@8.3.1(@types/node@22.20.1)): + optionalDependencies: + vite: 8.3.1(@types/node@22.20.1) + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4161,3 +4752,5 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zimmerframe@1.1.5: {} diff --git a/scripts/render-web-setup-component.cjs b/scripts/render-web-setup-component.cjs new file mode 100644 index 000000000..0507eaf0e --- /dev/null +++ b/scripts/render-web-setup-component.cjs @@ -0,0 +1,30 @@ +const { resolve } = require('node:path'); + +/** Test-only semantic render of the configured Svelte/Vite component tree. */ +async function main() { + const [name, encodedProps, locale = 'en'] = process.argv.slice(2); + if (!/^[A-Z][A-Za-z]+$/.test(name) || !encodedProps) { + throw new Error('A named component and props are required.'); + } + const { createServer } = await import('vite'); + const server = await createServer({ + configFile: resolve(__dirname, '../web/vite.config.mts'), + server: { middlewareMode: true }, appType: 'custom', logLevel: 'silent', + }); + try { + const catalog = await server.ssrLoadModule('/src/i18n/catalog.ts'); + if (!catalog.setupLocales.includes(locale)) throw new Error('Unsupported test locale.'); + const { setupLocale } = await server.ssrLoadModule('/src/i18n/localeStore.ts'); + setupLocale.set(locale); + const component = await server.ssrLoadModule(`/src/components/${name}.svelte`); + // Use the same Svelte SSR runtime as the Vite-transformed component. + const { render } = await server.ssrLoadModule('svelte/server'); + const props = JSON.parse(encodedProps); + for (const key of ['onSubmit', 'onRetryDiscovery', 'onClose', 'onAction', 'onPair']) props[key] = async () => undefined; + process.stdout.write(render(component.default, { props }).body); + } finally { + await server.close(); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/smoke-test-npm-package.cjs b/scripts/smoke-test-npm-package.cjs index 2c7f13f1c..ef9ea1b5d 100644 --- a/scripts/smoke-test-npm-package.cjs +++ b/scripts/smoke-test-npm-package.cjs @@ -35,11 +35,18 @@ try { const packageRoot = path.join(extractedDirectory, 'package'); const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); const cliPath = path.join(packageRoot, 'build', 'cli', 'index.js'); + const webIndexPath = path.join(packageRoot, 'build', 'web', 'index.html'); const bugbotApiPath = path.join(packageRoot, 'build', 'api', 'index.js'); const version = execFileSync(process.execPath, [cliPath, '--version'], { encoding: 'utf8' }).trim(); const help = execFileSync(process.execPath, [cliPath, '--help'], { encoding: 'utf8' }); const bugbotApi = require(bugbotApiPath); const cliBundle = fs.readFileSync(cliPath, 'utf8'); + for (const runtime of ['github_action', 'api']) { + const bundle = fs.readFileSync(path.join(packageRoot, 'build', runtime, 'index.js'), 'utf8'); + if (bundle.includes('Local setup web assets are incomplete') || bundle.includes('Control moved to another tab.')) { + throw new Error(`Packaged ${runtime} runtime must not include the local web setup server.`); + } + } if (packageJson.name !== '@vypdev/copilot') { throw new Error(`packaged name is ${packageJson.name}, expected @vypdev/copilot.`); @@ -47,6 +54,9 @@ try { if (!fs.existsSync(path.join(packageRoot, 'build', 'api', 'src', 'api.d.ts'))) { throw new Error('packaged Bugbot API is missing its TypeScript declarations.'); } + if (!fs.existsSync(webIndexPath) || !fs.readFileSync(webIndexPath, 'utf8').includes('/assets/')) { + throw new Error('Packaged local web setup assets are missing or incomplete.'); + } if (version !== packageJson.version) { throw new Error(`CLI reported ${version}, expected ${packageJson.version}.`); } @@ -54,7 +64,7 @@ try { throw new Error('packaged CLI help does not expose the copilot executable.'); } const setupHelp = execFileSync(process.execPath, [cliPath, 'setup', '--help'], { encoding: 'utf8' }); - for (const option of ['--issue-workflows ', '--agent-guidance ', '--non-interactive']) { + for (const option of ['--issue-workflows ', '--agent-guidance ', '--non-interactive', '--web']) { if (!setupHelp.includes(option)) throw new Error(`packaged setup CLI is missing ${option}.`); } for (const publicExport of ['BugbotReviewService', 'evaluateBugbotFindings', 'buildBugbotAnalytics']) { diff --git a/scripts/validate-build.cjs b/scripts/validate-build.cjs index cf722515a..8df3b8f62 100644 --- a/scripts/validate-build.cjs +++ b/scripts/validate-build.cjs @@ -1,6 +1,6 @@ const { spawnSync } = require('node:child_process'); -const buildPaths = ['build/cli', 'build/github_action', 'build/api']; +const buildPaths = ['build/cli', 'build/github_action', 'build/api', 'build/web']; function runGit(args) { const result = spawnSync('git', args, { encoding: 'utf8' }); diff --git a/scripts/validate-npm-package.cjs b/scripts/validate-npm-package.cjs index 545449114..20c557fc3 100644 --- a/scripts/validate-npm-package.cjs +++ b/scripts/validate-npm-package.cjs @@ -34,6 +34,7 @@ if (packageJson.exports?.['./bugbot']?.default !== './build/api/index.js' const requiredPackageFiles = [ 'action.yml', 'build/cli/index.js', + 'build/web/', 'build/github_action/index.js', 'build/api/index.js', 'build/api/src/', @@ -52,6 +53,7 @@ for (const requiredFile of requiredPackageFiles) { const requiredRepositoryFiles = [ 'action.yml', 'build/cli/index.js', + 'build/web/index.html', 'build/github_action/index.js', 'build/api/index.js', 'build/api/src/api.d.ts', @@ -106,6 +108,17 @@ try { } } + const webIndex = fs.readFileSync(path.join(repositoryRoot, 'build/web/index.html'), 'utf8'); + const referencedAssets = [...webIndex.matchAll(/(?:\.\/)?(assets\/[A-Za-z0-9._-]+\.(?:js|css))/g)] + .map(match => `build/web/${match[1]}`); + if (referencedAssets.length < 2) error('local web setup index must reference packaged JS and CSS assets.'); + for (const asset of referencedAssets) { + if (!packageFiles.has(asset)) error(`npm package is missing referenced web asset ${asset}.`); + } + if ([...packageFiles].some(file => file.startsWith('build/web/') && file.endsWith('.map'))) { + error('npm package must not include web source maps.'); + } + const forbiddenFile = [...packageFiles].find((file) => { const normalized = file.toLowerCase(); return normalized === '.env' diff --git a/specs/CATALOG.md b/specs/CATALOG.md index f04bee2e9..8a433520d 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -16,7 +16,10 @@ debt or convert unknown historic intent into a design decision. | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 57 paths · 2026-09-24 | | `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 84 paths · 2026-09-16 | | `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 72 paths · 2026-09-16 | -| `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 2 companion | 83 paths · 2026-09-24 | +| `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 2 companion | 90 paths · 2026-09-28 | +| `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes | [Local web setup assistant](./local-web-setup-assistant.md) | 121 paths · 2026-09-29 | +| `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT using GitHub's official form, verify bot identity and grants, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 25 paths · 2026-09-25 | +| `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 28 paths · 2026-09-25 | | `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 1 companion | 51 paths · 2026-09-17 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 31 paths · 2026-09-17 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 61 paths · 2026-09-21 | @@ -25,7 +28,7 @@ debt or convert unknown historic intent into a design decision. | `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 48 paths · 2026-09-16 | | `agent-runtime` | Implemented | Resolve, provision, authenticate, authorize, and execute only the agent roles reachable by a run | [Agent runtime, provider, model, and role routing](./agent-runtime-provider-and-model-routing.md) + 1 companion | 54 paths · 2026-09-24 | | `cli-and-single-actions` | As-built baseline | Expose bounded local commands and workflow-dispatched operations through the shared application core | [CLI and single-action execution](./cli-and-single-action-execution.md) | 33 paths · 2026-09-16 | -| `configurable-issue-workflows` | Implemented | Select one canonical set of issue workflows and enforce its forms, dependencies, branch policy, runtime admission, migration, and diagnosis | [Configurable issue workflows and fail-closed admission](./configurable-issue-workflows-and-admission.md) | 88 paths · 2026-09-23 | +| `configurable-issue-workflows` | Implemented | Select one canonical set of issue workflows and enforce its forms, dependencies, branch policy, runtime admission, migration, and diagnosis | [Configurable issue workflows and fail-closed admission](./configurable-issue-workflows-and-admission.md) | 88 paths · 2026-09-29 | | `repository-agent-collaboration` | Implemented | Generate safe repository-local profiles, guidance, and a skill for agents contributing through configured issues, Action-managed branches, pull requests, and deployment boundaries | [Repository agent collaboration contract](./repository-agent-collaboration-contract.md) | 39 paths · 2026-09-16 | | `guarded-pull-request-approval` | Proposed | Specify and locally implement revision-bound, evidence-gated native bot approvals for eligible human pull requests, with safe setup defaults, read-only doctor checks, and explainable recovery | [Guarded pull-request approval and setup readiness](./guarded-pull-request-approval.md) + 1 companion | 57 paths · 2026-09-17 | @@ -100,14 +103,47 @@ debt or convert unknown historic intent into a design decision. ### `setup-and-doctor` — Setup, configuration, credentials, and doctor - Owner: Copilot maintainers -- Last verified: 2026-09-24 +- Last verified: 2026-09-28 - Specifications: [`specs/setup-configuration-credentials-and-doctor.md`](./setup-configuration-credentials-and-doctor.md) · [`specs/setup-doctor-architecture-hardening.md`](./setup-doctor-architecture-hardening.md) · [`specs/setup-pat-permission-guidance-and-verification.md`](./setup-pat-permission-guidance-and-verification.md) - Workflows: [`setup/workflows/agent-cli-provisioning.yml`](../setup/workflows/agent-cli-provisioning.yml) · [`setup/workflows/copilot_credential_health.yml`](../setup/workflows/copilot_credential_health.yml) - Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) · [`src/cli/commands/doctor.ts`](../src/cli/commands/doctor.ts) -- Core code: [`src/domain/setup.ts`](../src/domain/setup.ts) · [`src/domain/setup_questionnaire.ts`](../src/domain/setup_questionnaire.ts) · [`src/domain/setup_token_permissions.ts`](../src/domain/setup_token_permissions.ts) · [`src/application/ports/setup_terminal_ports.ts`](../src/application/ports/setup_terminal_ports.ts) · [`src/application/ports/setup_wizard_ports.ts`](../src/application/ports/setup_wizard_ports.ts) · [`src/application/ports/setup_token_permission_ports.ts`](../src/application/ports/setup_token_permission_ports.ts) · [`src/application/policies/setup_token_permission_evidence_policy.ts`](../src/application/policies/setup_token_permission_evidence_policy.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/policies/setup_configuration_plan.ts`](../src/application/policies/setup_configuration_plan.ts) · [`src/application/policies/setup_configuration_storage_policy.ts`](../src/application/policies/setup_configuration_storage_policy.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_questionnaire_controller.ts`](../src/application/usecases/setup/setup_questionnaire_controller.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_use_case.ts) · [`src/application/usecases/setup/setup_token_permissions_use_case.ts`](../src/application/usecases/setup/setup_token_permissions_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/application/usecases/actions/initial_setup_workflow.ts`](../src/application/usecases/actions/initial_setup_workflow.ts) · [`src/application/usecases/actions/setup_resource_provisioning.ts`](../src/application/usecases/actions/setup_resource_provisioning.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/policies/setup_configuration_validation.ts`](../src/application/policies/setup_configuration_validation.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/data/repository/repository_variables_repository.ts`](../src/data/repository/repository_variables_repository.ts) · [`src/infrastructure/github/ports/github_repository_variables_protocol.ts`](../src/infrastructure/github/ports/github_repository_variables_protocol.ts) · [`src/infrastructure/setup_remote_credential_health_adapter.ts`](../src/infrastructure/setup_remote_credential_health_adapter.ts) · [`src/infrastructure/setup_credential_validation_adapter.ts`](../src/infrastructure/setup_credential_validation_adapter.ts) · [`src/infrastructure/setup_token_permission_query_adapter.ts`](../src/infrastructure/setup_token_permission_query_adapter.ts) · [`src/cli/setup_terminal_driver.ts`](../src/cli/setup_terminal_driver.ts) · [`src/cli/setup_question_renderer.ts`](../src/cli/setup_question_renderer.ts) · [`src/cli/setup_plan_presenter.ts`](../src/cli/setup_plan_presenter.ts) · [`src/cli/setup_doctor_presenter.ts`](../src/cli/setup_doctor_presenter.ts) · [`src/cli/setup_prompt_rendering.ts`](../src/cli/setup_prompt_rendering.ts) · [`src/cli/setup_credential_prompt_adapter.ts`](../src/cli/setup_credential_prompt_adapter.ts) · [`src/cli/setup_token_permission_presenter.ts`](../src/cli/setup_token_permission_presenter.ts) · [`src/infrastructure/composition/setup_credentials_composition_root.ts`](../src/infrastructure/composition/setup_credentials_composition_root.ts) · [`src/infrastructure/composition/setup_token_permissions_composition_root.ts`](../src/infrastructure/composition/setup_token_permissions_composition_root.ts) · [`src/infrastructure/composition/setup_doctor_composition_root.ts`](../src/infrastructure/composition/setup_doctor_composition_root.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/documentation_pat_exception_policy.cjs`](../scripts/documentation_pat_exception_policy.cjs) · [`scripts/validate-documentation-contract.cjs`](../scripts/validate-documentation-contract.cjs) -- Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/setup_configuration_policy.test.ts`](../src/application/policies/__tests__/setup_configuration_policy.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts`](../src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts`](../src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts) · [`src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts`](../src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts) · [`src/infrastructure/__tests__/setup_workspace_adapter.test.ts`](../src/infrastructure/__tests__/setup_workspace_adapter.test.ts) · [`src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts`](../src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts) · [`src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts) · [`src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts`](../src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_prompt_rendering.test.ts`](../src/cli/__tests__/setup_prompt_rendering.test.ts) · [`src/cli/__tests__/setup_token_permission_presenter.test.ts`](../src/cli/__tests__/setup_token_permission_presenter.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/__tests__/setup_terminal_driver.test.ts`](../src/cli/__tests__/setup_terminal_driver.test.ts) · [`src/architecture/__tests__/setup_doctor_boundaries.test.ts`](../src/architecture/__tests__/setup_doctor_boundaries.test.ts) · [`src/tooling/__tests__/documentation_pat_exception_policy.test.ts`](../src/tooling/__tests__/documentation_pat_exception_policy.test.ts) +- Core code: [`src/domain/setup.ts`](../src/domain/setup.ts) · [`src/domain/setup_questionnaire.ts`](../src/domain/setup_questionnaire.ts) · [`src/domain/setup_token_permissions.ts`](../src/domain/setup_token_permissions.ts) · [`src/application/ports/setup_terminal_ports.ts`](../src/application/ports/setup_terminal_ports.ts) · [`src/application/ports/setup_wizard_ports.ts`](../src/application/ports/setup_wizard_ports.ts) · [`src/application/ports/setup_token_permission_ports.ts`](../src/application/ports/setup_token_permission_ports.ts) · [`src/application/policies/setup_token_permission_evidence_policy.ts`](../src/application/policies/setup_token_permission_evidence_policy.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/policies/setup_configuration_plan.ts`](../src/application/policies/setup_configuration_plan.ts) · [`src/application/policies/setup_configuration_storage_policy.ts`](../src/application/policies/setup_configuration_storage_policy.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/application/policies/setup_doctor_report_policy.ts`](../src/application/policies/setup_doctor_report_policy.ts) · [`src/application/policies/setup_journey_policy.ts`](../src/application/policies/setup_journey_policy.ts) · [`src/application/policies/setup_permission_summary_policy.ts`](../src/application/policies/setup_permission_summary_policy.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_journey_use_case.ts`](../src/application/usecases/setup/setup_journey_use_case.ts) · [`src/application/usecases/setup/setup_questionnaire_controller.ts`](../src/application/usecases/setup/setup_questionnaire_controller.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_use_case.ts) · [`src/application/usecases/setup/setup_token_permissions_use_case.ts`](../src/application/usecases/setup/setup_token_permissions_use_case.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/usecases/setup/merge_queue_readiness_use_case.ts`](../src/application/usecases/setup/merge_queue_readiness_use_case.ts) · [`src/application/usecases/actions/initial_setup_workflow.ts`](../src/application/usecases/actions/initial_setup_workflow.ts) · [`src/application/usecases/actions/setup_resource_provisioning.ts`](../src/application/usecases/actions/setup_resource_provisioning.ts) · [`src/application/ports/message_catalog_ports.ts`](../src/application/ports/message_catalog_ports.ts) · [`src/application/usecases/localization/resolve_message_catalog_use_case.ts`](../src/application/usecases/localization/resolve_message_catalog_use_case.ts) · [`src/application/policies/setup_configuration_validation.ts`](../src/application/policies/setup_configuration_validation.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/data/repository/repository_variables_repository.ts`](../src/data/repository/repository_variables_repository.ts) · [`src/infrastructure/github/ports/github_repository_variables_protocol.ts`](../src/infrastructure/github/ports/github_repository_variables_protocol.ts) · [`src/infrastructure/setup_remote_credential_health_adapter.ts`](../src/infrastructure/setup_remote_credential_health_adapter.ts) · [`src/infrastructure/setup_credential_validation_adapter.ts`](../src/infrastructure/setup_credential_validation_adapter.ts) · [`src/infrastructure/setup_token_permission_query_adapter.ts`](../src/infrastructure/setup_token_permission_query_adapter.ts) · [`src/cli/setup_terminal_driver.ts`](../src/cli/setup_terminal_driver.ts) · [`src/cli/setup_question_renderer.ts`](../src/cli/setup_question_renderer.ts) · [`src/cli/setup_journey_presenter.ts`](../src/cli/setup_journey_presenter.ts) · [`src/cli/setup_plan_presenter.ts`](../src/cli/setup_plan_presenter.ts) · [`src/cli/setup_doctor_presenter.ts`](../src/cli/setup_doctor_presenter.ts) · [`src/cli/setup_prompt_rendering.ts`](../src/cli/setup_prompt_rendering.ts) · [`src/cli/setup_credential_prompt_adapter.ts`](../src/cli/setup_credential_prompt_adapter.ts) · [`src/cli/setup_token_permission_presenter.ts`](../src/cli/setup_token_permission_presenter.ts) · [`src/infrastructure/composition/setup_credentials_composition_root.ts`](../src/infrastructure/composition/setup_credentials_composition_root.ts) · [`src/infrastructure/composition/setup_token_permissions_composition_root.ts`](../src/infrastructure/composition/setup_token_permissions_composition_root.ts) · [`src/infrastructure/composition/setup_doctor_composition_root.ts`](../src/infrastructure/composition/setup_doctor_composition_root.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/documentation_pat_exception_policy.cjs`](../scripts/documentation_pat_exception_policy.cjs) · [`scripts/validate-documentation-contract.cjs`](../scripts/validate-documentation-contract.cjs) +- Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/setup_configuration_policy.test.ts`](../src/application/policies/__tests__/setup_configuration_policy.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_remote_facts_policy.test.ts`](../src/application/policies/__tests__/setup_remote_facts_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts`](../src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts`](../src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts) · [`src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts`](../src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts) · [`src/infrastructure/__tests__/setup_workspace_adapter.test.ts`](../src/infrastructure/__tests__/setup_workspace_adapter.test.ts) · [`src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts`](../src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts) · [`src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts) · [`src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts`](../src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_journey_presenter.test.ts`](../src/cli/__tests__/setup_journey_presenter.test.ts) · [`src/cli/__tests__/setup_prompt_rendering.test.ts`](../src/cli/__tests__/setup_prompt_rendering.test.ts) · [`src/cli/__tests__/setup_token_permission_presenter.test.ts`](../src/cli/__tests__/setup_token_permission_presenter.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/__tests__/setup_terminal_driver.test.ts`](../src/cli/__tests__/setup_terminal_driver.test.ts) · [`src/architecture/__tests__/setup_doctor_boundaries.test.ts`](../src/architecture/__tests__/setup_doctor_boundaries.test.ts) · [`src/tooling/__tests__/documentation_pat_exception_policy.test.ts`](../src/tooling/__tests__/documentation_pat_exception_policy.test.ts) - User documentation: [`README.md`](../README.md) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/provisioning.mdx`](../docs/security-operations/operations/provisioning.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/security-operations/security/credentials.mdx`](../docs/security-operations/security/credentials.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) · [`docs/security-operations/operations/verification.mdx`](../docs/security-operations/operations/verification.mdx) +### `local-web-setup-assistant` — Local web setup assistant + +- Owner: Copilot maintainers +- Last verified: 2026-09-29 +- Specifications: [`specs/local-web-setup-assistant.md`](./local-web-setup-assistant.md) +- Workflows: Not applicable for this capability. +- Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) · [`package.json`](../package.json) · [`web/src/main.ts`](../web/src/main.ts) +- Core code: [`web/src/App.svelte`](../web/src/App.svelte) · [`web/src/session/setupSession.ts`](../web/src/session/setupSession.ts) · [`web/src/components/ActionButton.svelte`](../web/src/components/ActionButton.svelte) · [`web/src/components/PairingPanel.svelte`](../web/src/components/PairingPanel.svelte) · [`web/src/lib/pairingCode.ts`](../web/src/lib/pairingCode.ts) · [`web/src/components/ContextPanel.svelte`](../web/src/components/ContextPanel.svelte) · [`web/src/components/PromptCard.svelte`](../web/src/components/PromptCard.svelte) · [`web/src/components/ResultPanel.svelte`](../web/src/components/ResultPanel.svelte) · [`web/src/components/PlanPrompt.svelte`](../web/src/components/PlanPrompt.svelte) · [`web/src/components/PlanDecisionSummary.svelte`](../web/src/components/PlanDecisionSummary.svelte) · [`web/src/components/QuestionPrompt.svelte`](../web/src/components/QuestionPrompt.svelte) · [`web/src/components/FixedWorkflowNotice.svelte`](../web/src/components/FixedWorkflowNotice.svelte) · [`web/src/components/QuestionGuidance.svelte`](../web/src/components/QuestionGuidance.svelte) · [`web/src/components/DiscoveryNotice.svelte`](../web/src/components/DiscoveryNotice.svelte) · [`web/src/components/ProducerSelector.svelte`](../web/src/components/ProducerSelector.svelte) · [`web/src/lib/manualProducerIdentity.ts`](../web/src/lib/manualProducerIdentity.ts) · [`web/src/components/CoverageCheckEvidence.svelte`](../web/src/components/CoverageCheckEvidence.svelte) · [`web/src/components/ProjectSelector.svelte`](../web/src/components/ProjectSelector.svelte) · [`web/src/i18n/catalog.ts`](../web/src/i18n/catalog.ts) · [`web/src/i18n/projectTransitions.ts`](../web/src/i18n/projectTransitions.ts) · [`web/src/i18n/permissionTerms.ts`](../web/src/i18n/permissionTerms.ts) · [`web/src/components/StatusBanner.svelte`](../web/src/components/StatusBanner.svelte) · [`web/src/lib/questionAnswer.ts`](../web/src/lib/questionAnswer.ts) · [`web/src/lib/githubLink.ts`](../web/src/lib/githubLink.ts) · [`web/src/lib/focusOnRevision.ts`](../web/src/lib/focusOnRevision.ts) · [`web/src/i18n/featureNames.ts`](../web/src/i18n/featureNames.ts) · [`web/src/i18n/agentRoleNames.ts`](../web/src/i18n/agentRoleNames.ts) · [`web/src/i18n/checkEvidence.ts`](../web/src/i18n/checkEvidence.ts) · [`web/src/styles/controls.css`](../web/src/styles/controls.css) · [`web/src/styles/tokens.css`](../web/src/styles/tokens.css) · [`web/src/style.css`](../web/src/style.css) · [`src/application/contracts/web_setup_view.ts`](../src/application/contracts/web_setup_view.ts) · [`src/application/ports/setup_terminal_ports.ts`](../src/application/ports/setup_terminal_ports.ts) · [`src/application/errors/setup_interaction_cancelled_error.ts`](../src/application/errors/setup_interaction_cancelled_error.ts) · [`src/application/policies/merge_setup_overrides_policy.ts`](../src/application/policies/merge_setup_overrides_policy.ts) · [`src/application/policies/setup_remote_facts_policy.ts`](../src/application/policies/setup_remote_facts_policy.ts) · [`src/application/usecases/setup/prepare_setup_pat_intent_use_case.ts`](../src/application/usecases/setup/prepare_setup_pat_intent_use_case.ts) · [`src/application/usecases/setup/audit_configured_setup_pat_use_case.ts`](../src/application/usecases/setup/audit_configured_setup_pat_use_case.ts) · [`src/application/usecases/setup/verify_web_setup_apply_use_case.ts`](../src/application/usecases/setup/verify_web_setup_apply_use_case.ts) · [`src/application/usecases/setup/verify_setup_pat_bootstrap_use_case.ts`](../src/application/usecases/setup/verify_setup_pat_bootstrap_use_case.ts) · [`src/cli_context.ts`](../src/cli_context.ts) · [`src/cli/setup_command_options.ts`](../src/cli/setup_command_options.ts) · [`src/cli/web_setup_bridge.ts`](../src/cli/web_setup_bridge.ts) · [`src/cli/web_setup_adapters.ts`](../src/cli/web_setup_adapters.ts) · [`src/cli/web_setup_server.ts`](../src/cli/web_setup_server.ts) · [`src/cli/setup_apply_snapshot.ts`](../src/cli/setup_apply_snapshot.ts) · [`src/cli/setup_result_receipt.ts`](../src/cli/setup_result_receipt.ts) · [`src/cli/setup_session_guard.ts`](../src/cli/setup_session_guard.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_questionnaire_controller.ts`](../src/application/usecases/setup/setup_questionnaire_controller.ts) · [`src/application/policies/setup_terminal_choice_policy.ts`](../src/application/policies/setup_terminal_choice_policy.ts) · [`src/cli/setup_terminal_driver.ts`](../src/cli/setup_terminal_driver.ts) · [`src/cli/setup_question_renderer.ts`](../src/cli/setup_question_renderer.ts) · [`src/application/usecases/setup/setup_journey_use_case.ts`](../src/application/usecases/setup/setup_journey_use_case.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_use_case.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/policies/setup_project_selection_policy.ts`](../src/application/policies/setup_project_selection_policy.ts) · [`src/application/ports/setup_project_discovery_port.ts`](../src/application/ports/setup_project_discovery_port.ts) · [`src/application/ports/setup_approval_check_discovery_port.ts`](../src/application/ports/setup_approval_check_discovery_port.ts) · [`src/infrastructure/github_setup_project_discovery_adapter.ts`](../src/infrastructure/github_setup_project_discovery_adapter.ts) · [`src/infrastructure/github_setup_approval_check_discovery_adapter.ts`](../src/infrastructure/github_setup_approval_check_discovery_adapter.ts) · [`src/application/policies/setup_question_documentation_policy.ts`](../src/application/policies/setup_question_documentation_policy.ts) · [`src/application/policies/setup_question_guidance_policy.ts`](../src/application/policies/setup_question_guidance_policy.ts) · [`src/application/policies/setup_question_purpose_policy.ts`](../src/application/policies/setup_question_purpose_policy.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_configuration_plan.ts`](../src/application/policies/setup_configuration_plan.ts) · [`src/application/policies/setup_pat_creation_url_policy.ts`](../src/application/policies/setup_pat_creation_url_policy.ts) · [`src/infrastructure/composition/setup_credentials_composition_root.ts`](../src/infrastructure/composition/setup_credentials_composition_root.ts) · [`scripts/validate-npm-package.cjs`](../scripts/validate-npm-package.cjs) · [`scripts/render-web-setup-component.cjs`](../scripts/render-web-setup-component.cjs) +- Tests: [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/__tests__/cli_context_branch.test.ts`](../src/__tests__/cli_context_branch.test.ts) · [`src/__tests__/cli_context_root.test.ts`](../src/__tests__/cli_context_root.test.ts) · [`src/cli/__tests__/web_setup_bridge.test.ts`](../src/cli/__tests__/web_setup_bridge.test.ts) · [`src/cli/__tests__/web_setup_adapters.test.ts`](../src/cli/__tests__/web_setup_adapters.test.ts) · [`src/cli/__tests__/web_setup_palette.test.ts`](../src/cli/__tests__/web_setup_palette.test.ts) · [`src/cli/__tests__/web_setup_ui_helpers.test.ts`](../src/cli/__tests__/web_setup_ui_helpers.test.ts) · [`src/cli/__tests__/web_setup_browser_session.test.ts`](../src/cli/__tests__/web_setup_browser_session.test.ts) · [`src/cli/__tests__/web_setup_server.test.ts`](../src/cli/__tests__/web_setup_server.test.ts) · [`src/cli/__tests__/web_setup_browser_open.test.ts`](../src/cli/__tests__/web_setup_browser_open.test.ts) · [`src/cli/__tests__/setup_apply_snapshot.test.ts`](../src/cli/__tests__/setup_apply_snapshot.test.ts) · [`src/cli/__tests__/setup_result_receipt.test.ts`](../src/cli/__tests__/setup_result_receipt.test.ts) · [`src/cli/__tests__/setup_session_guard.test.ts`](../src/cli/__tests__/setup_session_guard.test.ts) · [`src/cli/__tests__/setup_command_options.test.ts`](../src/cli/__tests__/setup_command_options.test.ts) · [`src/cli/__tests__/web_setup_components.test.ts`](../src/cli/__tests__/web_setup_components.test.ts) · [`src/cli/__tests__/setup_terminal_driver.test.ts`](../src/cli/__tests__/setup_terminal_driver.test.ts) · [`src/cli/__tests__/setup_terminal_driver_stream_integration.test.ts`](../src/cli/__tests__/setup_terminal_driver_stream_integration.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/web_setup_catalog.test.ts`](../src/cli/__tests__/web_setup_catalog.test.ts) · [`src/architecture/__tests__/web_setup_boundaries.test.ts`](../src/architecture/__tests__/web_setup_boundaries.test.ts) · [`src/application/usecases/setup/__tests__/prepare_setup_pat_intent_use_case.test.ts`](../src/application/usecases/setup/__tests__/prepare_setup_pat_intent_use_case.test.ts) · [`src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts`](../src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts) · [`src/application/usecases/setup/__tests__/verify_web_setup_apply_use_case.test.ts`](../src/application/usecases/setup/__tests__/verify_web_setup_apply_use_case.test.ts) · [`src/application/policies/__tests__/setup_remote_facts_policy.test.ts`](../src/application/policies/__tests__/setup_remote_facts_policy.test.ts) · [`src/application/usecases/setup/__tests__/verify_setup_pat_bootstrap_use_case.test.ts`](../src/application/usecases/setup/__tests__/verify_setup_pat_bootstrap_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts`](../src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts) · [`src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts) · [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/setup_project_selection_policy.test.ts`](../src/application/policies/__tests__/setup_project_selection_policy.test.ts) · [`src/infrastructure/__tests__/github_setup_project_discovery_adapter.test.ts`](../src/infrastructure/__tests__/github_setup_project_discovery_adapter.test.ts) · [`src/infrastructure/__tests__/github_setup_approval_check_discovery_adapter.test.ts`](../src/infrastructure/__tests__/github_setup_approval_check_discovery_adapter.test.ts) · [`src/application/policies/__tests__/setup_question_documentation_policy.test.ts`](../src/application/policies/__tests__/setup_question_documentation_policy.test.ts) · [`src/application/policies/__tests__/setup_question_purpose_policy.test.ts`](../src/application/policies/__tests__/setup_question_purpose_policy.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/architecture/__tests__/setup_doctor_boundaries.test.ts`](../src/architecture/__tests__/setup_doctor_boundaries.test.ts) +- User documentation: [`README.md`](../README.md) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/pull-requests/guarded-approval.mdx`](../docs/pull-requests/guarded-approval.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/dependency-rules.md`](../docs/dependency-rules.md) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/security-operations/operations/provisioning.mdx`](../docs/security-operations/operations/provisioning.mdx) + +### `guided-bot-pat-onboarding` — Guided bot PAT onboarding + +- Owner: Copilot maintainers +- Last verified: 2026-09-25 +- Specifications: [`specs/guided-bot-pat-onboarding.md`](./guided-bot-pat-onboarding.md) +- Workflows: Not applicable for this capability. +- Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) +- Core code: [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_permission_summary_policy.ts`](../src/application/policies/setup_permission_summary_policy.ts) · [`src/application/policies/setup_pat_creation_url_policy.ts`](../src/application/policies/setup_pat_creation_url_policy.ts) · [`src/application/ports/setup_pat_identity_ports.ts`](../src/application/ports/setup_pat_identity_ports.ts) · [`src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts`](../src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts) · [`src/infrastructure/setup_github_identity_query_adapter.ts`](../src/infrastructure/setup_github_identity_query_adapter.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_use_case.ts) · [`src/cli/setup_credential_prompt_adapter.ts`](../src/cli/setup_credential_prompt_adapter.ts) · [`src/cli/setup_journey_presenter.ts`](../src/cli/setup_journey_presenter.ts) · [`src/data/repository/repository_variables_repository.ts`](../src/data/repository/repository_variables_repository.ts) +- Tests: [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts`](../src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts) · [`src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts`](../src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts) · [`src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_journey_presenter.test.ts`](../src/cli/__tests__/setup_journey_presenter.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) +- User documentation: [`README.md`](../README.md) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) + +### `temporary-setup-operator-authorization` — Assisted setup PAT creation + +- Owner: Copilot maintainers +- Last verified: 2026-09-25 +- Specifications: [`specs/temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md) +- Workflows: Not applicable for this capability. +- Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) +- Core code: [`src/cli/setup_credential_prompt_adapter.ts`](../src/cli/setup_credential_prompt_adapter.ts) · [`src/cli/setup_journey_presenter.ts`](../src/cli/setup_journey_presenter.ts) · [`src/application/policies/setup_pat_intent_policy.ts`](../src/application/policies/setup_pat_intent_policy.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_pat_creation_url_policy.ts`](../src/application/policies/setup_pat_creation_url_policy.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_token_permissions_use_case.ts`](../src/application/usecases/setup/setup_token_permissions_use_case.ts) · [`src/infrastructure/setup_token_permission_query_adapter.ts`](../src/infrastructure/setup_token_permission_query_adapter.ts) · [`src/utils/setup_files.ts`](../src/utils/setup_files.ts) +- Tests: [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_journey_presenter.test.ts`](../src/cli/__tests__/setup_journey_presenter.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/application/policies/__tests__/setup_pat_intent_policy.test.ts`](../src/application/policies/__tests__/setup_pat_intent_policy.test.ts) · [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts`](../src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts) · [`src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts) · [`src/utils/__tests__/setup_files.test.ts`](../src/utils/__tests__/setup_files.test.ts) +- User documentation: [`README.md`](../README.md) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) + ### `issue-start-and-sdd-readiness` — Uniform issue start and pre-branch SDD readiness - Owner: Copilot maintainers @@ -199,7 +235,7 @@ debt or convert unknown historic intent into a design decision. ### `configurable-issue-workflows` — Configurable issue workflows and fail-closed admission - Owner: Copilot maintainers -- Last verified: 2026-09-23 +- Last verified: 2026-09-29 - Specifications: [`specs/configurable-issue-workflows-and-admission.md`](./configurable-issue-workflows-and-admission.md) - Workflows: [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_deployment_orchestration.yml`](../.github/workflows/copilot_deployment_orchestration.yml) · [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_pull_request_review_state.yml`](../.github/workflows/copilot_pull_request_review_state.yml) · [`.github/workflows/hotfix_workflow.yml`](../.github/workflows/hotfix_workflow.yml) · [`.github/workflows/release_workflow.yml`](../.github/workflows/release_workflow.yml) · [`setup/workflows/copilot_commit.yml`](../setup/workflows/copilot_commit.yml) · [`setup/workflows/copilot_deployment_orchestration.yml`](../setup/workflows/copilot_deployment_orchestration.yml) · [`setup/workflows/copilot_issue.yml`](../setup/workflows/copilot_issue.yml) · [`setup/workflows/copilot_issue_comment.yml`](../setup/workflows/copilot_issue_comment.yml) · [`setup/workflows/copilot_pull_request.yml`](../setup/workflows/copilot_pull_request.yml) · [`setup/workflows/copilot_pull_request_comment.yml`](../setup/workflows/copilot_pull_request_comment.yml) · [`setup/workflows/copilot_pull_request_review_state.yml`](../setup/workflows/copilot_pull_request_review_state.yml) · [`setup/workflows/hotfix_workflow.yml`](../setup/workflows/hotfix_workflow.yml) · [`setup/workflows/release_workflow.yml`](../setup/workflows/release_workflow.yml) - Entrypoints: [`action.yml`](../action.yml) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) · [`src/cli/commands/doctor.ts`](../src/cli/commands/doctor.ts) diff --git a/specs/catalog.json b/specs/catalog.json index cd808f8d8..a079d543f 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -644,7 +644,7 @@ "status": "implemented", "scope": "Plan, validate, provision, and audit a repository installation without exposing credentials", "owner": "Copilot maintainers", - "lastVerified": "2026-09-24", + "lastVerified": "2026-09-28", "specs": [ "specs/setup-configuration-credentials-and-doctor.md", "specs/setup-doctor-architecture-hardening.md", @@ -672,7 +672,10 @@ "src/application/policies/setup_configuration_storage_policy.ts", "src/application/policies/setup_doctor_message_catalog.ts", "src/application/policies/setup_doctor_report_policy.ts", + "src/application/policies/setup_journey_policy.ts", + "src/application/policies/setup_permission_summary_policy.ts", "src/application/usecases/setup/setup_wizard_use_case.ts", + "src/application/usecases/setup/setup_journey_use_case.ts", "src/application/usecases/setup/setup_questionnaire_controller.ts", "src/application/usecases/setup/setup_credentials_use_case.ts", "src/application/usecases/setup/setup_token_permissions_use_case.ts", @@ -691,6 +694,7 @@ "src/infrastructure/setup_token_permission_query_adapter.ts", "src/cli/setup_terminal_driver.ts", "src/cli/setup_question_renderer.ts", + "src/cli/setup_journey_presenter.ts", "src/cli/setup_plan_presenter.ts", "src/cli/setup_doctor_presenter.ts", "src/cli/setup_prompt_rendering.ts", @@ -707,10 +711,12 @@ "src/application/policies/__tests__/setup_questionnaire_policy.test.ts", "src/application/policies/__tests__/setup_configuration_policy.test.ts", "src/application/policies/__tests__/setup_token_permission_policy.test.ts", + "src/application/policies/__tests__/setup_remote_facts_policy.test.ts", "src/application/policies/__tests__/setup_doctor_message_catalog.test.ts", "src/application/policies/__tests__/setup_doctor_report_policy.test.ts", "src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts", "src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts", + "src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts", "src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts", "src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts", "src/application/usecases/setup/__tests__/doctor_use_case.test.ts", @@ -723,6 +729,7 @@ "src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts", "src/data/repository/__tests__/repository_variables_repository.test.ts", "src/cli/__tests__/setup_presenters.test.ts", + "src/cli/__tests__/setup_journey_presenter.test.ts", "src/cli/__tests__/setup_prompt_rendering.test.ts", "src/cli/__tests__/setup_token_permission_presenter.test.ts", "src/__tests__/cli.test.ts", @@ -744,6 +751,240 @@ "docs/security-operations/operations/verification.mdx" ] }, + { + "id": "local-web-setup-assistant", + "title": "Local web setup assistant", + "status": "proposed", + "scope": "Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes", + "owner": "Copilot maintainers", + "lastVerified": "2026-09-29", + "specs": [ + "specs/local-web-setup-assistant.md" + ], + "workflows": [], + "entrypoints": [ + "src/cli/commands/setup.ts", + "package.json", + "web/src/main.ts" + ], + "code": [ + "web/src/App.svelte", + "web/src/session/setupSession.ts", + "web/src/components/ActionButton.svelte", + "web/src/components/PairingPanel.svelte", + "web/src/lib/pairingCode.ts", + "web/src/components/ContextPanel.svelte", + "web/src/components/PromptCard.svelte", + "web/src/components/ResultPanel.svelte", + "web/src/components/PlanPrompt.svelte", + "web/src/components/PlanDecisionSummary.svelte", + "web/src/components/QuestionPrompt.svelte", + "web/src/components/FixedWorkflowNotice.svelte", + "web/src/components/QuestionGuidance.svelte", + "web/src/components/DiscoveryNotice.svelte", + "web/src/components/ProducerSelector.svelte", + "web/src/lib/manualProducerIdentity.ts", + "web/src/components/CoverageCheckEvidence.svelte", + "web/src/components/ProjectSelector.svelte", + "web/src/i18n/catalog.ts", + "web/src/i18n/projectTransitions.ts", + "web/src/i18n/permissionTerms.ts", + "web/src/components/StatusBanner.svelte", + "web/src/lib/questionAnswer.ts", + "web/src/lib/githubLink.ts", + "web/src/lib/focusOnRevision.ts", + "web/src/i18n/featureNames.ts", + "web/src/i18n/agentRoleNames.ts", + "web/src/i18n/checkEvidence.ts", + "web/src/styles/controls.css", + "web/src/styles/tokens.css", + "web/src/style.css", + "src/application/contracts/web_setup_view.ts", + "src/application/ports/setup_terminal_ports.ts", + "src/application/errors/setup_interaction_cancelled_error.ts", + "src/application/policies/merge_setup_overrides_policy.ts", + "src/application/policies/setup_remote_facts_policy.ts", + "src/application/usecases/setup/prepare_setup_pat_intent_use_case.ts", + "src/application/usecases/setup/audit_configured_setup_pat_use_case.ts", + "src/application/usecases/setup/verify_web_setup_apply_use_case.ts", + "src/application/usecases/setup/verify_setup_pat_bootstrap_use_case.ts", + "src/cli_context.ts", + "src/cli/setup_command_options.ts", + "src/cli/web_setup_bridge.ts", + "src/cli/web_setup_adapters.ts", + "src/cli/web_setup_server.ts", + "src/cli/setup_apply_snapshot.ts", + "src/cli/setup_result_receipt.ts", + "src/cli/setup_session_guard.ts", + "src/application/usecases/setup/setup_wizard_use_case.ts", + "src/application/usecases/setup/setup_questionnaire_controller.ts", + "src/application/policies/setup_terminal_choice_policy.ts", + "src/cli/setup_terminal_driver.ts", + "src/cli/setup_question_renderer.ts", + "src/application/usecases/setup/setup_journey_use_case.ts", + "src/application/usecases/setup/setup_credentials_use_case.ts", + "src/application/policies/setup_questionnaire_policy.ts", + "src/application/policies/setup_project_selection_policy.ts", + "src/application/ports/setup_project_discovery_port.ts", + "src/application/ports/setup_approval_check_discovery_port.ts", + "src/infrastructure/github_setup_project_discovery_adapter.ts", + "src/infrastructure/github_setup_approval_check_discovery_adapter.ts", + "src/application/policies/setup_question_documentation_policy.ts", + "src/application/policies/setup_question_guidance_policy.ts", + "src/application/policies/setup_question_purpose_policy.ts", + "src/application/policies/setup_token_permission_policy.ts", + "src/application/policies/setup_configuration_plan.ts", + "src/application/policies/setup_pat_creation_url_policy.ts", + "src/infrastructure/composition/setup_credentials_composition_root.ts", + "scripts/validate-npm-package.cjs", + "scripts/render-web-setup-component.cjs" + ], + "tests": [ + "src/__tests__/cli.test.ts", + "src/__tests__/cli_context_branch.test.ts", + "src/__tests__/cli_context_root.test.ts", + "src/cli/__tests__/web_setup_bridge.test.ts", + "src/cli/__tests__/web_setup_adapters.test.ts", + "src/cli/__tests__/web_setup_palette.test.ts", + "src/cli/__tests__/web_setup_ui_helpers.test.ts", + "src/cli/__tests__/web_setup_browser_session.test.ts", + "src/cli/__tests__/web_setup_server.test.ts", + "src/cli/__tests__/web_setup_browser_open.test.ts", + "src/cli/__tests__/setup_apply_snapshot.test.ts", + "src/cli/__tests__/setup_result_receipt.test.ts", + "src/cli/__tests__/setup_session_guard.test.ts", + "src/cli/__tests__/setup_command_options.test.ts", + "src/cli/__tests__/web_setup_components.test.ts", + "src/cli/__tests__/setup_terminal_driver.test.ts", + "src/cli/__tests__/setup_terminal_driver_stream_integration.test.ts", + "src/cli/__tests__/setup_presenters.test.ts", + "src/cli/__tests__/web_setup_catalog.test.ts", + "src/architecture/__tests__/web_setup_boundaries.test.ts", + "src/application/usecases/setup/__tests__/prepare_setup_pat_intent_use_case.test.ts", + "src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts", + "src/application/usecases/setup/__tests__/verify_web_setup_apply_use_case.test.ts", + "src/application/policies/__tests__/setup_remote_facts_policy.test.ts", + "src/application/usecases/setup/__tests__/verify_setup_pat_bootstrap_use_case.test.ts", + "src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts", + "src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts", + "src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts", + "src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts", + "src/application/policies/__tests__/setup_questionnaire_policy.test.ts", + "src/application/policies/__tests__/setup_project_selection_policy.test.ts", + "src/infrastructure/__tests__/github_setup_project_discovery_adapter.test.ts", + "src/infrastructure/__tests__/github_setup_approval_check_discovery_adapter.test.ts", + "src/application/policies/__tests__/setup_question_documentation_policy.test.ts", + "src/application/policies/__tests__/setup_question_purpose_policy.test.ts", + "src/application/policies/__tests__/setup_token_permission_policy.test.ts", + "src/architecture/__tests__/setup_doctor_boundaries.test.ts" + ], + "documentation": [ + "README.md", + "docs/how-to-use.mdx", + "docs/authentication.mdx", + "docs/issues/assignees-and-projects.mdx", + "docs/pull-requests/guarded-approval.mdx", + "docs/configuration.mdx", + "docs/configuration-checklist.mdx", + "docs/development/architecture.mdx", + "docs/dependency-rules.md", + "docs/security-operations/operations/troubleshooting.mdx", + "docs/security-operations/operations/provisioning.mdx" + ] + }, + { + "id": "guided-bot-pat-onboarding", + "title": "Guided bot PAT onboarding", + "status": "proposed", + "scope": "Guide creation of the persistent workflow PAT using GitHub's official form, verify bot identity and grants, and install the approved Actions Secret", + "owner": "Copilot maintainers", + "lastVerified": "2026-09-25", + "specs": [ + "specs/guided-bot-pat-onboarding.md" + ], + "workflows": [], + "entrypoints": [ + "src/cli/commands/setup.ts" + ], + "code": [ + "src/application/policies/setup_token_permission_policy.ts", + "src/application/policies/setup_permission_summary_policy.ts", + "src/application/policies/setup_pat_creation_url_policy.ts", + "src/application/ports/setup_pat_identity_ports.ts", + "src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts", + "src/infrastructure/setup_github_identity_query_adapter.ts", + "src/application/usecases/setup/setup_credentials_use_case.ts", + "src/cli/setup_credential_prompt_adapter.ts", + "src/cli/setup_journey_presenter.ts", + "src/data/repository/repository_variables_repository.ts" + ], + "tests": [ + "src/application/policies/__tests__/setup_token_permission_policy.test.ts", + "src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts", + "src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts", + "src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts", + "src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts", + "src/cli/__tests__/setup_presenters.test.ts", + "src/cli/__tests__/setup_journey_presenter.test.ts", + "src/data/repository/__tests__/repository_variables_repository.test.ts" + ], + "documentation": [ + "README.md", + "docs/authentication.mdx", + "docs/how-to-use.mdx", + "docs/configuration.mdx", + "docs/development/architecture.mdx", + "docs/security-operations/operations/troubleshooting.mdx" + ] + }, + { + "id": "temporary-setup-operator-authorization", + "title": "Assisted setup PAT creation", + "status": "proposed", + "scope": "Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately", + "owner": "Copilot maintainers", + "lastVerified": "2026-09-25", + "specs": [ + "specs/temporary-setup-operator-authorization.md" + ], + "workflows": [], + "entrypoints": [ + "src/cli/commands/setup.ts" + ], + "code": [ + "src/cli/setup_credential_prompt_adapter.ts", + "src/cli/setup_journey_presenter.ts", + "src/application/policies/setup_pat_intent_policy.ts", + "src/application/policies/setup_questionnaire_policy.ts", + "src/application/policies/setup_token_permission_policy.ts", + "src/application/policies/setup_pat_creation_url_policy.ts", + "src/application/usecases/setup/setup_wizard_use_case.ts", + "src/application/usecases/setup/setup_token_permissions_use_case.ts", + "src/infrastructure/setup_token_permission_query_adapter.ts", + "src/utils/setup_files.ts" + ], + "tests": [ + "src/cli/__tests__/setup_presenters.test.ts", + "src/cli/__tests__/setup_journey_presenter.test.ts", + "src/__tests__/cli.test.ts", + "src/application/policies/__tests__/setup_pat_intent_policy.test.ts", + "src/application/policies/__tests__/setup_questionnaire_policy.test.ts", + "src/application/policies/__tests__/setup_token_permission_policy.test.ts", + "src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts", + "src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts", + "src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts", + "src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts", + "src/utils/__tests__/setup_files.test.ts" + ], + "documentation": [ + "README.md", + "docs/how-to-use.mdx", + "docs/authentication.mdx", + "docs/configuration.mdx", + "docs/development/architecture.mdx", + "docs/security-operations/operations/troubleshooting.mdx" + ] + }, { "id": "issue-start-and-sdd-readiness", "title": "Uniform issue start and pre-branch SDD readiness", @@ -1305,7 +1546,7 @@ "status": "implemented", "scope": "Select one canonical set of issue workflows and enforce its forms, dependencies, branch policy, runtime admission, migration, and diagnosis", "owner": "Copilot maintainers", - "lastVerified": "2026-09-23", + "lastVerified": "2026-09-29", "specs": [ "specs/configurable-issue-workflows-and-admission.md" ], diff --git a/specs/configurable-issue-workflows-and-admission.md b/specs/configurable-issue-workflows-and-admission.md index 94553bcf0..1db1903d8 100644 --- a/specs/configurable-issue-workflows-and-admission.md +++ b/specs/configurable-issue-workflows-and-admission.md @@ -3,7 +3,7 @@ - Status: Implemented — automated local gates complete; controlled live GitHub UX evidence remains pending - Date: 2026-09-16 - Catalog capability ID: `configurable-issue-workflows` -- Last verified: 2026-09-16 +- Last verified: 2026-09-29 - Owners: Copilot maintainers - Scope: one selectable issue-workflow catalog that drives setup assets, runtime admission, branch behavior, release dependencies, diagnosis, and user guidance - Related issues/PRs: none yet; related SDDs are listed in section 20 @@ -281,7 +281,12 @@ domain changes. 2. When issue handling is enabled, the terminal presents the multi-select. Up and Down move, Space toggles, and Enter confirms. Selecting `All` checks every kind; toggling it while all are checked clears them. A child toggle - recomputes the `All` state. + recomputes the `All` state. Enter without changing the default confirms all + kinds. After explicitly clearing every kind, Enter submits the `none` + sentinel, not an empty answer that would restore the default. If issue + automation remains enabled, validation explains that at least one kind is + required and no setup changes start; disabling issue automation permits an + empty selection. 3. The catalog expands the selection into Issue Forms, effective configured labels, native Issue Type projections, required workflow files, branch roles, body schemas, runtime routes, and documentation/guidance facts. @@ -558,6 +563,10 @@ Runtime profile: COPILOT_ISSUE_WORKFLOW_PROFILE (schema 1) TTY rendering MUST remain usable without color and at 80 columns. Cursor state uses both `❯` and text/checkbox state; color is never the only indicator. +Clearing `All` and pressing Enter must visibly retain the empty selection and +surface the existing cross-field validation message while issues are enabled; +an untouched Enter must retain the default selection. The text-input fallback +accepts `none` for the same explicit empty selection. ### 9.3 Representative runtime views @@ -731,12 +740,12 @@ rows count only when they assert a distinct decision branch. | Area | Minimum distinct cases | Behaviors/risks covered | |---|---:|---| | Catalog, profile, configuration, classifier | 26 | seven kinds, aliases, all/empty/unknown/duplicate/schema cases, zero/one/multiple groups, no fallback, cross-field rules | -| Setup planning, selection, rendering, reconciliation | 24 | Space/Enter/All, fallback input, cancel/EOF, dependencies, effective labels, managed/unmanaged drift, retire/backup, idempotency | +| Setup planning, selection, rendering, reconciliation | 27 | unchanged Enter/default All, Space clearing All then Enter/explicit none, empty-selection validation, fallback input, cancel/EOF, dependencies, effective labels, managed/unmanaged drift, retire/backup, idempotency | | Runtime admission, state, replay, continuation | 32 | passive/explicit matrix, queue/live state, disabled/unmanaged/conflict, body validation, legacy, continuation, durable operations, unlinked PR, zero-count assignment before target validation, deferred members-only lookup for every requested provider task, denied/failing authorization with fail-closed task configuration | | Adapters and provider contracts | 12 | Variable, issue snapshot, state, labels, org/no-org Issue Types, permission/rate-limit/error mapping | | Workflows, packaging, doctor, architecture | 16 | all workflow inputs, package contents, npm smoke, query-only doctor, mutation reachability, single catalog, parser/form contract | | UI, localization, security, integration, migration | 18 | five UI states, no-color/narrow, sanitization, comment budget, no secrets, old config/profile migration, dogfood and rollback | -| **Total** | **128** | No double counting | +| **Total** | **131** | No double counting | The issue-workflow domain and setup/rendering decision policies named by the `Configurable issue workflows and repository agent guidance` coverage budget @@ -771,6 +780,11 @@ validated against setup forms and profile fixtures. 1. Given a fresh interactive setup, when the selector opens, then all seven kinds are checked and Space/Enter produces the canonical ordered selection. +1a. Given the default selection, when the owner presses Enter without toggling, + then all seven kinds remain selected. Given the owner toggles `All` off and + presses Enter, then the terminal submits explicit `none`, never the default; + with issue automation still enabled, setup explains that at least one kind + is required before applying changes. 2. Given release and hotfix are deselected, when setup is confirmed, then their forms/workflows/resources are absent or safely retired, the explicit profile omits them, and all other selected forms remain. @@ -823,7 +837,7 @@ validated against setup forms and profile fixtures. | Requirement | Policy/use case/adapter/presentation | Test or evidence | Documentation | |---|---|---|---| | single seven-kind catalog | domain catalog | catalog completeness and uniqueness tests | configurable workflows page | -| multi-select default All | questionnaire policy + terminal adapter | key-sequence, fallback, cancel tests | setup guide | +| multi-select default All and explicit empty | questionnaire policy + terminal adapter + validation | unchanged Enter, Space/Enter clear-all, `none` parsing and empty-selection validation, fallback, cancel tests | setup guide and configurable workflows page | | deterministic setup expansion | planning/reconciliation use cases | plan, effective-label, drift tests | setup and config pages | | optional native Issue Types | capability adapter | org/no-org/permission tests | permissions section | | pre-mutation admission | admission use case + composition | zero-reachable-mutation and deferred-authorization integration tests | operator decision tree | diff --git a/specs/guarded-pull-request-approval-setup-and-doctor.md b/specs/guarded-pull-request-approval-setup-and-doctor.md index 60afa9223..7e77005c4 100644 --- a/specs/guarded-pull-request-approval-setup-and-doctor.md +++ b/specs/guarded-pull-request-approval-setup-and-doctor.md @@ -102,6 +102,27 @@ Not applicable: prospective capability. The observed setup/doctor baseline is no `S5` Add a conditional `pull-request-approval` questionnaire stage after `bugbot` and before `projects`. Skip it when `features.pullRequests=false`; set policy `off` in that case. The recommended answers are `recommend`, development target, selected routine branch kinds, distinct linked issue required, fixed protected-path exclusions, and no acceptance of dismissed findings. Setup asks for exact producer tuples and an explicit operator attestation of their App identity and coverage-enforcing step; it does not silently infer coverage from a green workflow. In non-interactive mode missing explicit producer data is an error with no writes, not an invented default. The plan states that a freshly copied observer will not run until committed on the default branch. +For the web presentation, replace raw `name|App ID|workflow` entry with +read-only discovery of *observed GitHub Actions job check runs* linked to +workflow run attempts and source App IDs. Offer 1–8 checkboxes with exact +identities, recency, required-by-branch evidence and links. The operator may +enter an exact tuple manually when discovery is absent or fails. The operator +can explicitly re-run the read-only query twice without restarting setup or +re-entering the PAT; no result means no *accessible observed* run, not proof +that CI is absent. A selected coverage check is one of those entries, not a +second free-text name. Because `coverage.checkName` stores only the name, the +trusted producer list must not contain two checks with the same name; setup +rejects this ambiguity and directs the operator to select one or rename the +jobs. The UI shows exact workflow/App identity and the run link, and calls +branch-required status `not checked` unless inspected separately. Neither +an observed green check nor a workflow file containing `coverage` sets +`producerAttested` or `reporterAttested`: the UI must show what the human +still needs to inspect and confirm. The current observer does not consume +legacy commit statuses as test producers; do not propose them as selectable +equivalents. Private-repository discovery may require conditional setup PAT +`Checks: read` and `Actions: read`, disclosed before PAT creation; refusal +keeps the manual path without falsely identifying an App. + The setup plan MUST display, in order: configured mode; PR scope and fixed exclusions; selected test/coverage producer names and source IDs; Bugbot review floor and path exclusions; effective branch-rule/stale-dismissal readiness for each selected target; workflow files and event names; runtime PAT identity/permission status; Secret/Variable names only; and a one-line outcome (`can approve after installation`, `installed but recommendation only`, or `setup blocked`). `--dry-run` performs no writes and needs no token for local-only preview, marking remote facts `unverified` rather than passing them. `--non-interactive --yes` approves only the complete plan and cannot choose an ambiguous producer or credential. ### 6.2 Doctor sequence @@ -235,6 +256,17 @@ AST tests reject provider imports in the policy/doctor layers and mutation-port ### 9.1 Setup plan and doctor hierarchy +The beginner-facing web card explains `recommend`, `guarded`, and `off` in +terms of whether Copilot only advises or can submit a native approval. For +each producer it shows the exact job, workflow, App, and evidence link before +requesting attestation. Coverage mode explains `check` as CI-enforced pass/fail +and `numeric` as the reviewed `copilot-diff-coverage-v1` artifact plus a +threshold. All these instructions are localized in the four supported web setup +catalogs (English, Spanish, French, and Portuguese) defined by the local-web SDD; stable producer names, IDs, workflow names, and +policy values are never translated. A blocked result must identify the +failed prerequisite and mutation facts in the browser, not only in terminal +output. See [local web setup assistant](./local-web-setup-assistant.md#93-first-time-comprehension-and-progressive-disclosure). + Setup's first view says what will be enabled, which PRs could receive a native review, and which prerequisites remain. It then shows changed files/Variables/Secret names, one confirmation, and technical details. Doctor's first line says whether native approvals can occur now; each check has stable ID, status, safe evidence, one action, and a link. Setup stays in English while creating the repository profile; doctor uses `repository-locale` (reviewed English/Spanish, complete dynamic catalog or atomic English fallback), consistent with [existing setup/doctor behavior](./setup-configuration-credentials-and-doctor.md). Representative plan/doctor content, with examples rather than fixed repository names: diff --git a/specs/guided-bot-pat-onboarding.md b/specs/guided-bot-pat-onboarding.md new file mode 100644 index 000000000..ba50c6f45 --- /dev/null +++ b/specs/guided-bot-pat-onboarding.md @@ -0,0 +1,608 @@ +# Guided Bot PAT Onboarding + +- Status: Draft — guided implementation in progress; controlled GitHub UX and full test budget remain unverified +- Date: 2026-09-25 +- Catalog capability ID: `guided-bot-pat-onboarding` +- Last verified: Not applicable; prospective change +- Owners: Copilot maintainers and setup operators +- Scope: guide creation and installation of the workflow/bot PAT when operator and bot are different GitHub accounts +- Related issues/PRs: [PR #402](https://github.com/vypdev/copilot/pull/402); no Action dogfooding for this design +- Required review gates: product UX, architecture, testing, documentation, credential security, GitHub form compatibility +- Open decisions blocking readiness: controlled GitHub UX, organization approval evidence, non-interactive identity extension, and full test-budget evidence + +## 1. Executive summary + +`copilot setup` already asks for two separate credentials: an operator setup PAT +and a workflow PAT owned by the Action's bot account. After the operator PAT +and setup plan are accepted, the proposed guided path builds an official GitHub +fine-grained PAT creation URL from the final **bot** permission plan. The user +reviews the browser account and repository, generates a PAT, and enters it in +Copilot's masked prompt. Copilot checks its identity and grants. The operator +credential installs it as GitHub Actions Secret `PAT`; it remains active for +future workflow runs. + +This uses [GitHub's documented PAT URL parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#pre-filling-fine-grained-personal-access-token-details-using-url-parameters). +The URL does **not** choose an individual repository or press Generate for the +user. It also cannot switch the browser's GitHub account. Therefore the UI +calls this **guided creation**, not automatic PAT issuance. There is no local +account manager or browser credential collection in this scope. The new +pre-PAT permission-intent questions in the [operator SDD](./temporary-setup-operator-authorization.md) +are for the *setup* PAT only: they seed the same setup plan that later produces +the bot's exact runtime permission set. They MUST NOT cause the bot URL or PAT +to be requested before the final plan and remote facts are known. + +```text +operator PAT: local intent -> guided link -> user creates PAT -> verify -> final setup plan +bot PAT: final runtime grants -> guided link -> user switches browser to bot + -> user selects repository and creates PAT -> verify bot -> Secret PAT +``` + +Text equivalent: the operator and bot each create their own PAT on GitHub; +Copilot checks the returned credential's identity and permissions and uses +each one only for its defined role. + +## 2. Problem, current behavior, evidence, and feasibility + +### 2.1 Problem + +The setup owner often has a work or personal GitHub account, while the Action +uses a separate service account. The existing terminal permission table helps +configure both PATs but leaves the user to navigate GitHub's form and enter +many grants. A browser signed into the wrong account can produce a syntactically +valid PAT for the wrong identity. The bot PAT is especially consequential +because it persists as `PAT` for Actions rather than expiring at the end of +setup. + +### 2.2 Observed repository behavior + +1. `src/cli/commands/setup.ts` resolves the operator PAT before running the + wizard, shows permission requirements, and later collects the separate + workflow PAT. +2. `src/application/policies/setup_token_permission_policy.ts` derives the + workflow PAT grants from the final selected features and storage policy. +3. `src/application/usecases/setup/setup_credentials_use_case.ts` requires a + supplied or re-entered workflow PAT for permission audit, even if remote + Secret `PAT` already exists. GitHub Secrets cannot reveal stored values. +4. `src/data/repository/repository_variables_repository.ts` writes validated + Secret values to the selected repository or organization scope. +5. `docs/authentication.mdx` recommends a dedicated bot account for an + organization and explains bot/self-event behavior and runtime grants. + +### 2.3 External primary evidence + +| Question | GitHub source | Contract consequence | +|---|---|---| +| What can a PAT URL prefill? | [PAT management: supported query parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#supported-query-parameters) | `name`, `description`, `target_name`, `expires_in`, and permission levels. `expires_in` is 1–366 days or `none`, subject to owner policy. | +| Can it select the repository or browser identity? | [PAT creation steps](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) and [supported query parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#supported-query-parameters) | The documented URL has no individual-repository selector. The user selects repository access and confirms the active account in GitHub. | +| Can a user switch browser accounts? | [GitHub account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts) | Yes. The browser may prompt for an account when following a link. CLI identity selection does not set that browser state. | +| Can Copilot create or delete the PAT through an owner API? | [PAT management](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), [organization PAT API](https://docs.github.com/en/rest/orgs/personal-access-tokens) | No documented owner PAT creation/deletion API found. Organization access management is not a user PAT minting flow. | + +### 2.4 Viability conclusion + +The official URL provides a supported, useful first release for **both** PAT +roles. It removes repetitive form entry and preserves GitHub's login, 2FA, +account switching, and final consent. It does not provide fully automatic PAT +creation or revocation. Replaying private GitHub web requests or sharing user +cookies is not required for this release and is not a stable product contract. +The temporary operator authorization proposal is in +[`temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md); +this SDD covers the bot's persistent runtime PAT. + +### 2.5 Retrospective classification + +Not applicable: this SDD specifies a proposed user journey, with current +behavior identified above. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Setup operator | configure repo/org and install Secret | `copilot setup` | permission tables, links, plan, result | +| Bot account owner | issue runtime PAT | GitHub PAT form | account switcher, permission form, PAT value | +| Organization admin | approve PAT/org resources if policy requires | GitHub Settings | pending/approved state | +| GitHub Action | use bot PAT after setup | workflows | jobs, PRs, issues | + +**Operator PAT** is the setup-only token, preferably short lived. **Bot PAT** +is a fine-grained personal access token issued by the bot account and stored as +Secret `PAT`. **Expected bot identity** is a GitHub account resolved to its +immutable numeric user ID before accepting a PAT. **Guided link** is a URL to +GitHub's own form, never a bearer credential. **Installed** means the Secret +API accepted the value; it does not prove a later workflow has run. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +1. Setup MUST generate the bot PAT form link from the final runtime permission + plan and clearly distinguish it from the earlier operator link. +2. Interactive setup MUST offer guided creation (recommended) or the existing + manual PAT input at the workflow credential step. +3. In guided mode, the bot PAT MUST be checked against an explicitly chosen + expected bot user ID, repository/organization access, and selected-feature + permissions before Secret `PAT` is written. +4. Setup MUST report bot PAT installation separately from local disposal and + later workflow health. +5. The existing manual and non-interactive PAT inputs MUST remain available. + +### 4.2 Non-goals + +1. Managing several GitHub accounts or credential stores on the device. +2. Switching a GitHub browser, Git, or `gh` account automatically. +3. Creating or revoking a PAT through undocumented website requests. +4. Replacing the Action's PAT with an App installation token, or deleting the + bot PAT after setup; it is needed by later Action runs. +5. Automatically opening a browser, managing the clipboard, or adding a local + account store in the first release. + +### 4.3 Fixed invariants + +1. The generated URL contains no PAT, cookie, session key, 2FA data, or secret. + Only documented query parameters and a fixed GitHub host are allowed. +2. The user MUST select the individual repository in GitHub and confirm the + active browser account; URL `target_name` sets only resource owner. +3. Neither the browser login nor a typed bot username proves the PAT's owner. + In guided mode, Copilot MUST call GitHub `/user` with the supplied bot PAT + and compare the immutable user ID to the expected account before any + Secret write. Legacy manual/unattended inputs retain their current audit + until a separately reviewed compatibility migration extends this binding. +4. Operator PAT and bot PAT are never interchanged. The bot PAT never becomes + a local setup authority; the operator PAT never becomes Secret `PAT`. +5. A successfully installed bot PAT remains active. Local memory disposal is + not described as remote revocation. No PAT value enters config, files, + logs, URLs, or account metadata. +6. `--yes` cannot select the bot identity, accept missing grants, or generate + a PAT on behalf of the user. +7. The guided URL is printed in full as plain terminal text outside a bordered + box; no URL shortener or browser opener is required. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User effect | +|---|---|---|---| +| Operator access | permission table + masked prompt | local permission-intent review, then official prefilled link + existing prompt | setup grants are prepared before GitHub | +| Bot identity | implicit in docs and PAT value | ask expected bot login; resolve and display ID | wrong account detected | +| Bot grants | exact table before prompt | table + guided/manual choice and link from same policy result | fewer transcription errors | +| Bot browser | no explicit check | tell user to select bot in GitHub account switcher | handles separate accounts | +| Secret installation | validate and write `PAT` | same, with identity and scope result | clear Action owner | +| Completion | setup summary | setup summary distinguishes Secret from local PAT | accurate lifecycle | + +```mermaid +sequenceDiagram + participant U as Setup operator + participant C as Copilot CLI + participant G as GitHub + participant S as Actions Secret + C->>U: Ask setup permission intent, then show operator PAT link and masked prompt + U->>G: Create operator PAT in GitHub + U->>C: Enter operator PAT + C->>G: Verify operator and build final setup plan + C->>U: Show bot account, runtime grants, and link + U->>G: Switch to bot account and complete 2FA if asked + U->>G: Select repository and generate bot PAT + U->>C: Enter bot PAT in masked prompt + C->>G: Verify bot ID and grants with bot PAT + C->>S: Store PAT using operator authority + C->>U: Report Secret result and active bot identity +``` + +Text equivalent: each user-owned PAT is generated inside GitHub. Copilot checks +the resulting identities, uses the operator PAT to configure the repository, +and stores the bot PAT in the selected Actions Secret scope. + +## 6. Functional behavior and state model + +### 6.1 Normal path + +1. Complete the separate [operator PAT journey](./temporary-setup-operator-authorization.md), + including its local preflight, reuse of early answers, authenticated remote + inspection, and final permission audit. If that audit requires a corrected + operator PAT, resolve it before presenting the bot link. Do not reuse the + operator token as the Action credential. +2. Once setup choices and remote targets are final, use the existing workflow + permission policy to build the bot PAT link. Do not project the setup PAT's + preflight grants into the bot role: identical feature answers can imply + different setup and runtime levels. Its name/description identify + purpose and repository; `target_name` is the repository owner; `expires_in` + is a reviewed runtime expiration; each permission uses GitHub's documented + query name and level. +3. At the existing workflow PAT collection point, present the final runtime + permission table and offer `Create with GitHub guidance` (recommended) or + `I already have a PAT`. In guided mode, ask for the expected bot login, + resolve its immutable GitHub ID, and display both. Print the full URL on + its own line outside `renderBox`; do not auto-open a browser. The user + switches to that account in GitHub, selects the target repository, reviews + the form, generates the PAT, and pastes it into the masked prompt. +4. Verify `/user` with the exact supplied PAT, compare immutable IDs, and run + the existing role-specific permission audit. An `Unverifiable` write stays + `Unverifiable` and follows the established acknowledgement policy. +5. After plan confirmation, use the operator credential to install the bot + PAT as repository or organization Secret `PAT`. Print Secret scope, expected + bot identity, successful setup facts, and any remaining health checks. +6. Release the local PAT value after use. The bot PAT remains in GitHub for + future Action runs and needs an owner-managed renewal before its expiration. + +### 6.2 Alternatives + +- Choosing `Enter PAT manually` keeps the existing masked prompts, permission + checks, and Secret provisioning. The current path does not bind a bot ID; + the CLI must not claim that it does. +- A user may decline the guided link and use the docs directly. Setup still + runs the existing permission audit without claiming bot identity binding. +- Non-interactive setup keeps supplied values and existing validation for the + first release. It never opens a browser or infers the bot from the operator + token. A later, explicit expected-bot-ID input and migration are required + before identity binding can become mandatory there. +- `--dry-run` generates a plan without a PAT; it may display an example link + only when its permission set is complete and clearly marked provisional. +- Revising a pre-PAT setup answer after operator authorization invalidates + the operator link and requires its final audit before the bot URL is shown; + it never silently reuses an earlier bot URL. +- If organization policy requires approval, setup stops before writing Secret + `PAT` until target access is verified. It reports `pending approval` only + when GitHub provides explicit evidence; otherwise it reports unavailable + access and says approval is one possible cause. +- If the bot is the same account as the operator, explain event self-suppression + and enforce the existing guarded-approval identity restriction. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Next | Owner | +|---|---|---|---|---| +| `operator-pat-needed` | bootstrap grants known | create/paste operator PAT | `operator-verified`, `cancelled` | user | +| `operator-verified` | identity/grants accepted | reuse preflight intent, finish plan and resolve any operator grant correction | `bot-pat-needed`, `blocked` | CLI/user | +| `bot-pat-needed` | final grants and expected bot ID known | open GitHub as bot, create PAT | `bot-pat-verified`, `blocked`, `cancelled` | bot user | +| `bot-pat-verified` | ID and grants accepted | Secret ready to write after approval | `secret-writing` | operator | +| `secret-writing` | remote call started | provisioning | `installed`, `partial`, `blocked` | CLI | +| `installed` | Secret API accepted | runtime PAT is stored | terminal | operator | +| `partial` | Secret write succeeded but later setup failed | PAT may be active | inspect/retry | operator | + +Retries do not generate or store a second PAT automatically. A changed final +permission plan invalidates the previously shown URL and requires a new link. +If setup is canceled before Secret write, a PAT the user already generated may +remain active at GitHub; the CLI instructs the user to delete it. A crash after +Secret write requires read-first reconciliation of Secret **name and scope**; +GitHub cannot return the stored value. This is not a reason to claim failure +or to overwrite the Secret without a new approved value. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values | Scope/persistence | +|---|---|---|---|---| +| PAT creation help | interactive choice | guided | `guided`, `manual` | one setup run; not saved | +| Operator PAT expiry | integer days | `1` for a one-run token | defined by companion operator SDD | link only | +| Bot PAT expiry | integer days | `90`, with bot owner responsible for renewal before expiry | 1–366 per GitHub, no `none` in generated link | link only; not a new config Secret | +| Expected bot | GitHub login and resolved ID | explicit selection | one valid GitHub user | one run; non-secret display | +| Secret scope | existing setup storage policy | repository | repository or organization as already supported | approved setup plan | + +The final runtime permission policy is the sole source of URL grants. The +operator preflight's temporary intent snapshot is consumed by the final setup +configuration, not a second bot-specific questionnaire or persistent account +profile. Any later change to a permission-driving choice requires both +role-specific plans to be recalculated before the relevant link is used. The +existing `--workflow-pat`/`--secret PAT=...` values override interactive input +and retain their current permission validation; they cannot silently enter +guided mode without an expected bot identity. The bot account owner must renew +the suggested 90-day PAT and replace Secret `PAT` before expiration; the +organization may impose a shorter limit. No link may select `none` silently. Invalid owner, +permission name/level, URL length, account, or scope blocks link generation. +There is no migration of existing PAT Secrets or account state. Role separation, +no embedded secret, and exact identity checking within guided mode are not +configurable. + +Recommended example: use `copilot setup`, follow the earlier operator guidance, +then choose guided bot creation after the approved plan. Alternative: create +the bot PAT manually from GitHub Settings and enter it into the existing prompt. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and dependency direction + +| Boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | role, owner, expiry, grant-to-URL mapping and identity match | HTTP, browser, terminal, tokens | +| Application | guide role-specific creation, verify supplied PAT, hand off bot PAT | GitHub DTOs, process opening | +| Ports | resolve GitHub identity, inspect permissions, write Secret | private web sessions | +| Adapters | official URL encoder, GitHub identity/permission queries, existing Secret API | product decisions | +| Composition | wire current setup stages and conditional guidance | duplicate permission rules | +| Presentation | permission table, link, masked prompt, state summary | PAT mutation | + +```mermaid +flowchart LR + C[Setup CLI] --> U[Guided PAT use case] + U --> P[Existing permission policy] + U --> L[Official URL builder] + U --> I[GitHub identity port] + U --> A[Existing permission audit] + U --> S[Existing Secret writer] +``` + +Text equivalent: setup presents the guide; a pure builder encodes the existing +permission plan into GitHub's URL; application logic verifies the token's +identity and grants; the existing Secret writer installs the runtime PAT. + +### 8.2 Contracts, state, and trust boundaries + +- `PatRole` is `operator` or `workflow-bot`; each has a separate permission + plan, expected identity, lifetime guidance, and cleanup owner. +- URL builder accepts only normalized `{name, description, targetName, + expiresIn, permissions}` and returns a URL pinned to + `https://github.com/settings/personal-access-tokens/new`. +- URL mapping is versioned against GitHub's documented parameter vocabulary. + `target_name` is an owner slug; the repository name is descriptive only and + cannot be misrepresented as selected repository access. +- In guided mode, bot token identity comes from GitHub `/user` using that + token. Compare numeric user IDs and verify repository access, then invoke + the existing permission audit. +- The bot URL builder consumes only the finalized workflow-role grant set. A + contract test compares that set with the link after preflight choices are + reused and remote facts are incorporated; no setup-only Secret, Variable, + or health-workflow grant can leak into the bot link by role confusion. +- No durable local state is introduced. The remote Secret is the only durable + bot PAT copy Copilot creates. Browser state is owned by GitHub, not Copilot. + +### 8.3 Executable architecture constraints + +Pure URL builder imports no HTTP, filesystem, terminal, or Secret adapter. +Contract tests MUST compare each emitted permission query name to GitHub's +documented set and reject unknown grants. Setup architecture tests MUST show +that no generated URL contains a token or cookie, no guided Secret write +precedes bot identity verification, and no operator token is used as runtime +`PAT`. + +## 9. UI/UX and content contract + +The [setup journey presentation contract](./setup-configuration-credentials-and-doctor.md#9-uiux-and-content-contract) +also covers the later `Bot PAT & credentials` phase. Show a compact, +role-labelled runtime-grant summary before asking how to obtain the bot PAT; +the user can view the full policy table on demand in guided mode, while manual +mode retains the complete table. The summary and detailed rows MUST use the +same final workflow-role permission objects, never the operator preview. +The phase remains active until credential collection and identity validation +finish; successful Secret installation is only reported after the apply step. +If a later apply fails after a Secret write, report partial state rather than +claiming the credential was discarded or the setup was complete. No bot token +value appears in the phase view. Add three bot-specific cases to the journey +budget: final role separation, detail-to-review without repeated identity +input, and partial Secret-write wording. The six-stage model and terminal +accessibility tests are shared with the setup baseline, not counted twice. + +The CLI first shows the role, expected account, repository, permission purpose, +remaining action, and cleanup ownership. The following English example matches +the current CLI language; it is illustrative. The URL is plain, complete, and +outside the bordered permission summary so it remains copyable. + +```text +Workflow PAT · 2 of 2 Repository: vypdev/copilot +The setup PAT is not the Action's runtime credential. +Choose how to provide the bot PAT: + 1) Create with GitHub guidance (recommended) + 2) I already have a PAT +Select [1]: +Expected bot account: vypbot +Expected account resolved: @vypbot (GitHub ID 5678) + +Action required: In GitHub, switch to vypbot and complete 2FA if asked. +GitHub may initially select All repositories: change to Only select repositories +and select vypdev/copilot; review the prepared permissions, then Generate. +PAT creation URL: +https://github.com/settings/personal-access-tokens/new?name=...&target_name=vypdev&expires_in=...&... +Workflow PAT (hidden): +``` + +| State | Representative first visible text | Primary action | +|---|---|---| +| Pending | `Waiting for a PAT created by vypbot. No Secret has been written.` | open PAT link | +| Action required | `Before generating, check that GitHub is using vypbot. Select only vypdev/copilot, then generate the PAT.` | check browser account | +| Blocked | `The supplied PAT belongs to efrain, not vypbot. No Secret was written. Delete that PAT in GitHub and create one while signed in as vypbot.` | create correct PAT | +| Partial | `Secret PAT was updated, but later setup steps failed. The bot PAT may already be active. Inspect the setup report before retrying.` | inspect report | +| Complete | `Secret PAT is installed for vypdev/copilot. Verified owner: vypbot (ID 5678). The local value was discarded; the GitHub Secret remains active.` | run doctor/health check | + +Error content follows impact, cause, action, retained state. Do not say a +typed account name or URL proves the browser account. No Github issue, PR, +check, comment, or label is created solely for this flow. English follows the +current setup CLI; later locales use the message catalog. Text status does not +depend on color or emoji; tables and instructions wrap on narrow terminals, +while the raw URL stays complete on its own line. Limit to +one guided block per role and one final summary; no polling notifications. +Escape untrusted usernames and descriptions in terminal and URL content. + +## 10. Failure, recovery, and cleanup + +| Failure | Impact | Retained facts | Retry | Action | Cleanup | +|---|---|---|---|---|---| +| Wrong browser account | PAT belongs to another user | no Secret write | new PAT | switch account in GitHub | user deletes wrong PAT | +| Wrong resource owner/repo | PAT lacks target access | no Secret write | correct form | select target repo and owner | user deletes wrong PAT | +| Org PAT pending or inaccessible | Action cannot use it yet | no Secret write | after access is verified | inspect approval with org admin or choose permitted account; label pending only with evidence | user owns token | +| Missing/unverifiable grant | setup blocked by established audit policy | permission table | corrected PAT | inspect settings/acknowledge only where allowed | user deletes obsolete PAT | +| Secret write fails | Action keeps prior Secret or none | known Secret name/scope | setup retry | repair operator rights | local value discarded after run | +| Secret write succeeds, later step fails | bot PAT may be active | Secret name/scope and completed steps | idempotent retry | inspect report | do not delete runtime PAT | +| User cancels after PAT creation | PAT may remain active in GitHub | no local value retained | new setup | delete unused PAT | user-owned deletion | + +GitHub's Secret API cannot return the previous value, so a failed update cannot +be rolled back by reading it. `copilot doctor`/health inspection is separate +from Secret-write success. The terminal states these facts without exposing +the PAT. + +## 11. Security, permissions, and privacy + +1. GitHub owns password, 2FA, browser account selection, PAT generation, and + organization approval. Copilot handles only the resulting PAT value entered + in a masked prompt. +2. URL grants are derived from current permission policy, not from CLI prose. + Strongest required level wins; no unrelated grant is added for convenience. +3. For guided mode, verify bot numeric user ID with the bot PAT itself; + compare it to a separately resolved expected identity. A login string or + claimed role is insufficient. Do not describe legacy validation as this + stronger identity check. +4. Operator and bot PATs remain separate in memory and at API boundaries. Bot + PAT is written only as Secret `PAT`, never to a Variable or local file. +5. The new guided path never puts a PAT in command arguments, stdout, URL, + logs, telemetry, diagnostics, or fixtures. Existing command-line PAT flags + remain for compatibility but should warn about process-list/history exposure. + Discarding process memory is not revocation. +6. The runtime PAT requires a renewal plan before expiry. No one-run cleanup + may revoke it while the Action depends on it. + +## 12. Observability and operational UX + +Record role, GitHub user ID/login, target repo, permission result statuses, +Secret name/scope, and setup outcome only. No token value or raw provider +response is recorded. Confirmed `pending approval` is distinct from a failed +PAT; unknown access must not be mislabeled as pending. +The CLI reports after identity verification and after Secret write, without +repeated prompts. A user can inspect the GitHub Secret **name** and later Action +health but not the Secret value. Failures include an actionable GitHub Settings +link and the retained state. Rate-limited identity or permission checks yield +a bounded retry message, never a false success. + +## 13. Compatibility, migration, rollout, and rollback + +Existing `--token`, `PERSONAL_ACCESS_TOKEN`, `--workflow-pat`, and +`--secret PAT=...` remain accepted with current precedence. Existing Secrets +are not modified by the link feature alone; a new value is installed only +after the normal plan confirmation and audit. Initial rollout adds guided +links and bot ID binding in interactive guided setup. Manual and unattended +inputs keep current behavior, with an explicit warning that bot identity is +not bound. Non-interactive bot ID binding follows only after an explicit input +contract and migration policy are settled. Rollback hides the links +and returns to current manual instructions; already installed bot PATs remain +active until their owner rotates or revokes them. + +## 14. Testing strategy and numeric budget + +The implementation floor is **36 distinct bot-specific cases**, derived from +the runtime permission plan, wrong-account and wrong-repository states, Secret +write outcomes, and compatibility modes. Shared URL builder cases in the +operator SDD are not counted again here. + +| Area | Minimum cases | Key behavior | +|---|---:|---| +| Domain/configuration/pure URL mapping | 8 | bot-only permission keys/levels, owner, expiry, invalid grants, no setup-only grant leakage after preflight | +| State/application/idempotency | 7 | plan change, cancellation, re-entry, retry, Secret partial state | +| Provider adapters/contracts | 5 | token `/user`, ID mismatch, repository access, Secret response | +| Setup/permissions/schema | 5 | operator/bot role separation, final policy, org scope, existing Secret | +| UI/accessibility/localization | 5 | pending/action/blocked/partial/complete and narrow output | +| Integration/security/migration | 6 | no URL secrets, no wrong-token write, manual/non-interactive compatibility | +| **Total** | **36** | No double counting | + +Repository-wide thresholds remain; the new pure mapping and identity policy +target 100% branch coverage, and changed setup modules target at least 95% +lines/statements and 90% branches/functions. Use deterministic GitHub fakes, +not live PAT creation in CI. Contract fixtures assert parsed URL parameters +and semantic CLI copy, not snapshots alone. Manual acceptance evidence must +include two browser accounts, 2FA handled by GitHub, wrong-account rejection, +repository selection, Secret installation, and post-write partial failure. +Test PATs are deleted by their owners after the controlled exercise. + +## 15. Documentation and discoverability + +| Audience | Artifact | Required content | Validation | +|---|---|---|---| +| New user | `README.md`, `docs/how-to-use.mdx` | two PAT roles, guided links, browser account choice | navigation/link test | +| Setup operator | `docs/authentication.mdx`, `docs/configuration.mdx` | exact grants, repo selection, expiry, Secret scope | permission fixture | +| Operator | `docs/security-operations/operations/troubleshooting.mdx` | wrong account, org approval, cleanup, partial Secret write, renewal | recovery fixture | +| Contributor | `docs/development/architecture.mdx`, this SDD | URL builder, identity binding, trust boundary | architecture test | + +Docs must explicitly say the link does not select a repository or create the +PAT, and the bot PAT remains active after setup. + +## 16. Acceptance scenarios + +1. Given the selected setup features, each role receives a documented PAT URL + with exactly its own needed permission levels and no token material. +2. Given pre-PAT local intent choices, only the operator link is generated + before authorization; the answers are reused in the final plan, and only + then is the bot runtime link generated from final grants and remote facts. +3. Given a bot account chosen by name, Copilot resolves and displays its + immutable ID before accepting the bot PAT. +4. Given the browser uses another account, a PAT created there fails `/user` + ID comparison and no Secret write occurs. +5. Given the correct bot account but wrong repository selection, setup blocks + before Secret write and names the correction. +6. Given an organization PAT pending approval or otherwise unable to reach + the target, setup does not present the Action as ready; it names pending + approval only when provider evidence supports it. +7. Given accepted bot identity and grants, operator authority installs Secret + `PAT` at the approved scope; the final report says it remains active. +8. Given an existing Secret, the CLI does not claim to know its value and + requests re-entry for the existing permission audit. +9. Given Secret write success followed by another setup failure, output + reports the partial result and does not revoke the bot PAT. +10. Given cancellation after GitHub created a PAT but before Secret write, + the CLI instructs the user to delete the unused PAT in GitHub. +11. Given a legacy manual or non-interactive PAT input, current setup continues + to work with its existing permission audit; no bot identity verification + is claimed until the planned migration is implemented. +12. Pending, action, blocked, partial, and complete CLI states are readable + without color and accurately distinguish local disposal from GitHub Secret. + +## 17. Requirements traceability + +| Requirement | Owner | Verification | Documentation | +|---|---|---|---| +| Role-specific link (§4.1, §6.1) | policy + URL builder | scenarios 1–2 | how-to-use/authentication | +| Setup-preflight handoff (§1, §6.1) | wizard + role-specific permission policies | scenario 2 and no cross-role-grant fixture | how-to-use/architecture | +| Bot ID and grants (§4.3, §6.1) | identity port + existing audit | scenarios 3–6 | authentication | +| Secret lifecycle (§4.3, §10) | existing credential/Secret use cases | scenarios 7–10 | setup/troubleshooting | +| Compatibility (§6.2, §13) | setup CLI | scenario 11 | CLI guide | +| Truthful UX (§9) | presenter | scenario 12 | how-to-use | + +## 18. Implementation sequence + +1. Reuse the operator SDD's local pre-auth planning and preserve bot PAT + generation after the final setup audit. Validate the official + permission-key mapping with GitHub's current documentation. +2. Implement a pure, role-specific URL builder and contract tests using the + existing permission policy; keep URL generation separate from token input. +3. Add expected bot ID resolution and exact-token `/user` comparison before + Secret collection/provisioning. +4. Integrate guided links and state messages with the existing CLI prompts and + setup plan; preserve manual/non-interactive paths. +5. Add Secret partial-state tests, docs, architecture checks, controlled + two-account UX evidence, coverage, and specification validation. + +## 19. Definition of Done + +- [ ] Open decisions are resolved before Ready for implementation. +- [ ] Every MUST has an acceptance scenario and test or evidence. +- [ ] URLs use only documented GitHub parameters and never contain secrets. +- [ ] Guided operator and bot IDs/grants are verified with their actual + credentials; legacy paths are labeled accurately. +- [ ] Secret scope, persistence, partial writes, and cleanup are truthful. +- [ ] Architecture checks, 36-case budget, and coverage targets pass. +- [ ] CLI primary states pass accessibility, sanitization, and locale review. +- [ ] User, setup, operator, and contributor docs are complete and linked. +- [ ] Catalog evidence and generated `specs/CATALOG.md` are current and + `pnpm run validate:specifications` passes. +- [ ] Controlled two-account acceptance evidence is recorded without PATs. + +## 20. References and decisions + +- Related specs: [temporary setup operator authorization](./temporary-setup-operator-authorization.md), + [setup baseline](./setup-configuration-credentials-and-doctor.md), and + [PAT permission guidance](./setup-pat-permission-guidance-and-verification.md). +- Future presentation: [local web setup assistant](./local-web-setup-assistant.md) + must preserve final-plan bot grants, numeric-ID verification, and the + installed Secret's persistent lifecycle. This SDD does not claim a web + implementation today. +- Primary sources: [PAT form and URL templates](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), + [browser account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts), + [organization PAT endpoints](https://docs.github.com/en/rest/orgs/personal-access-tokens). +- Decision: use GitHub's documented guided form for both PATs. Local account + profiles are unnecessary for this flow; verify each guided PAT's actual + owner rather than trusting the browser or local CLI account. Legacy paths + keep their current checks pending a migration. Automatic bot PAT creation + needs a future supported GitHub API and is not claimed here. +- Implementation snapshot (2026-09-24): guided/manual bot prompt, final-plan + URL, expected login resolution before token entry, numeric-ID comparison + before Secret mutation, and a 90-day suggested expiry are implemented + locally. Unsupported `Checks` in guarded plans suppresses the fine-grained + link and directs users to manual credential compatibility review. Human + two-account/2FA acceptance, organization approval evidence, and the full + numeric test budget remain open review gates. +- Implementation update (2026-09-25): the operator permission-intent preflight + now seeds the same final setup draft, but the bot URL is still built only + after final plan review and setup-PAT audit. The bot URL continues to use + its separate workflow-role policy; no bot-account browser session or token + is created by Copilot. Controlled browser acceptance and the full numeric + test budget remain open gates. diff --git a/specs/local-web-setup-assistant.md b/specs/local-web-setup-assistant.md new file mode 100644 index 000000000..4aa015a6b --- /dev/null +++ b/specs/local-web-setup-assistant.md @@ -0,0 +1,1710 @@ +# Local Web Setup Assistant + +- Status: Implementation in progress — target contract, not yet release acceptance +- Date: 2026-09-28 +- Catalog capability ID: `local-web-setup-assistant` +- Last verified: 2026-09-29 (source/build tests and screenshot review; no live GitHub setup or dogfooding) +- Owners: Copilot maintainers; product, security, and accessibility reviewers +- Scope: optional, local Svelte-based presentation of the existing repository setup journey, sharing its policy, credential, and application engine with the terminal +- Related issues/PRs: [PR #402](https://github.com/vypdev/copilot/pull/402) carries this implementation alongside the earlier guided PAT work; no test issue or Action is created +- Required review gates: product UX, Clean Architecture, browser/loopback security, credential handling, packaging, cross-platform operation, accessibility, testing, documentation +- Open decisions blocking readiness: none at the product-contract level; implementation MUST still pass the security and packaged-install review gates below + +## 1. Executive summary + +The default `copilot setup` remains the terminal wizard. `copilot setup --web` +starts a short-lived web assistant on this machine and opens the default +browser. It presents repository identity, setup choices, the temporary setup +PAT, the reviewed plan, the bot PAT and other credentials, and final application +as one clearly advancing journey. A failed browser launch prints a local URL +and a terminal fallback. It never creates a hosted account, proxies setup +through an external service, or treats the browser as a GitHub login session. + +Svelte + TypeScript + Vite is the presentation/build choice; the packaged +Node CLI serves compiled local assets and owns the setup session. Both UIs +MUST call the **same application decisions and provider ports**. The browser +MUST NOT run GitHub mutations, read repository files directly, or reproduce +the permission/questionnaire policy. Before adding the web adapter, the +orchestration currently embedded in `src/cli/commands/setup.ts` MUST be +extracted into frontend-neutral application contracts. Existing terminal +behavior remains the compatibility baseline. + +```text +copilot setup --web + -> local browser: Repository -> Setup choices -> Setup PAT + -> reviewed plan -> Bot PAT & credentials -> Apply -> Result/cleanup + -> GitHub form in a separate tab for each PAT, when guided creation is chosen +``` + +Text equivalent: one local setup session has two optional presentation +adapters. GitHub still authenticates the operator/bot accounts, performs 2FA, +issues PATs, and lets their owners delete or rotate them. Copilot validates +each supplied token and applies only a newly approved plan. + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +The six-stage terminal journey is functional but has many conditional choices, +two account roles, provisional permissions, a plan, and partial outcomes. A +first-time operator can benefit from persistent visual context, progressive +explanations, and a review screen without sacrificing the CLI or creating a +second implementation of setup rules. + +### 2.2 Verified baseline before this implementation + +1. `src/cli/commands/setup.ts` owns command flags, repository resolution, + setup-PAT intent, the initial and final permission audits, wizard + composition, bot-credential collection, and the `runLocalAction` call. + At the start of this work, the web entrypoint did not exist; the current + implementation status and remaining release gates are recorded in §18. +2. `SetupJourneyUseCase`, `SetupQuestionnaireController`, + `SetupWizardUseCase`, `SetupCredentialsUseCase`, the permission policies, + and existing GitHub/workspace adapters already separate parts of the + behavior. They are not yet one frontend-neutral setup-session coordinator. +3. The terminal supports guided and manual PAT entry. GitHub's official + fine-grained PAT form URL pre-fills documented fields but cannot select an + individual repository or authenticate a browser account. The setup PAT is + one-run authority; the bot PAT is a distinct runtime Secret `PAT`. +4. At baseline, `package.json` published `build/cli/index.js` and selected + other bundles but no web asset directory. The new Vite/npm-pack contract + is described in §8.3 and its current evidence in §18. +5. The existing setup contract includes manual/unattended/dry-run paths, + final grant re-audit, storage-shadow checks, backups, partial mutation + reporting, and no claim of remote PAT deletion. + +### 2.3 Evidence and limits + +- Repository sources: [setup baseline](./setup-configuration-credentials-and-doctor.md), + [operator PAT](./temporary-setup-operator-authorization.md), + [bot PAT](./guided-bot-pat-onboarding.md), + [permission evidence](./setup-pat-permission-guidance-and-verification.md), + `package.json`, `src/cli/commands/setup.ts`, and setup use cases/tests. +- [GitHub's PAT form](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#pre-filling-fine-grained-personal-access-token-details-using-url-parameters) + supports documented prefill fields, not issuance, account selection, or + individual-repository selection; [GitHub's account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts) + remains browser-owned. +- [Svelte](https://svelte.dev/docs/svelte/overview) compiles UI components; + [Vite](https://vite.dev/guide/) supports a `svelte-ts` application and + produces static assets. SvelteKit/SSR is unnecessary for this local flow. +- [OWASP CSRF guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html), + [CSP guidance](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html), + and [browser-storage guidance](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html) + inform the local-server threat model. These are security controls, not a + claim that loopback or a frontend framework makes secret input risk-free. +- Unknown until implementation: the exact packaged asset manifest, browser + opening support on each OS, and human usability of the final wireframes. + These are measured in the acceptance gates, not assumed from dev mode. + +### 2.4 Retrospective classification + +Not applicable: this is a prospective mode, not an as-built baseline. The +verified CLI behavior above remains the baseline and is not relabeled as +already web-capable. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry/surface | +|---|---|---| +| Setup operator | Configure one local checkout and understand what changes | shell launch, local browser wizard, final terminal result | +| Operator PAT owner | Authorize setup only | GitHub form/account switcher/2FA, then local masked web input | +| Bot PAT owner | Issue an Action runtime credential | separate GitHub form/account switcher/2FA, then local masked web input | +| Organization admin | Approve access when required by policy | GitHub's own approval UI; not Copilot's local page | +| Maintainer | Diagnose partial setup and renewal | result, `copilot doctor`, GitHub resources, docs | + +**Web session** is one ephemeral CLI-owned run bound to one canonical local +repository. **Plan revision** identifies the exact validated configuration, +repository/workspace facts, remote evidence, grant sets, and file decisions +reviewed by the operator. **Browser controller** is the one tab allowed to +submit actions at a time. **Secret value** is never part of a page view model. +**Installed** means a GitHub Secret write succeeded, not that an Action ran +or that the stored value can be read back. **Discarded locally** does not mean +deleted at GitHub or cryptographically erased from browser/process memory. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +1. `--web` MUST be an optional interactive presentation of the same setup + engine, with feature, grant, plan, and mutation parity with terminal setup. +2. The web journey MUST explain current/completed/next states, show reasons + for conditional choices, retain answers while reviewing, and make every + change to required PAT grants visible before the relevant PAT is used. +3. The web journey MUST guide both PAT roles separately, verify actual + identity/access/grants under the existing policies, and show truthful + cleanup/renewal after success, failure, or cancellation. +4. Application MUST require an explicit, current, single-use final approval; + stale pages, duplicate clicks, and changed repository/remote facts MUST + NOT apply a plan that was not just reviewed. +5. A packaged global npm install MUST launch the same bundled UI without + Vite, source files, dev dependencies, network asset fetches, or a writable + checkout of Copilot. + +### 4.2 Non-goals + +1. Hosted SaaS, remote access, LAN sharing, Docker-facing binding, mobile + access to another computer, or simultaneous multi-user setup. +2. Browser automation of GitHub, scraping private requests/cookies, + capturing 2FA, automatic PAT minting/revocation, or local account profiles. +3. Replacing the bot PAT with a GitHub App, changing Action runtime + authentication, or changing current CLI defaults/flags outside `--web`. +4. Persistent drafts or crash-resume containing credentials. A future + resumable setup needs a separate storage/security design. +5. New GitHub issues, PRs, Actions, or dogfooding as part of this spec task. + +### 4.3 Non-configurable safety invariants + +1. Only `127.0.0.1` on an OS-assigned ephemeral port is bound; no public + `--host`/`--port`, reverse proxy, external asset, CDN, telemetry endpoint, + or permissive CORS is added. The exact origin is checked on requests. +2. Browser-originated events are untrusted. No GET or asset request mutates + repository/GitHub state. API calls are schema-validated, revision-bound, + method-limited, size-limited, and CSRF-protected. +3. PATs, provider keys, session capabilities, cookies, and 2FA codes never + enter URLs, browser history/storage, service workers, config, logs, error + payloads, plans, snapshots, or client-side analytics. No value is returned + to the browser after submission. +4. The setup PAT and bot PAT never exchange roles. The operator token is not + Secret `PAT`; the bot token is not authority for setup writes. GitHub owns + each token's issuance, approval, expiration, and revocation. +5. The existing permission, identity, repository, storage-shadow, + confirmation, backup, and partial-result gates apply equally to both UIs. + A visual indicator or typed assertion is never authorization evidence. +6. Applying starts at most once per approved plan revision. No automatic + destructive rollback of completed local or remote changes is claimed. + +## 5. Current versus proposed journey + +| Stage | Terminal today | Web proposal | Operator effect | +|---|---|---|---| +| Launch/repository | `copilot setup` resolves git remote | `copilot setup --web` shows canonical checkout, owner/repo, branch and scope; asks confirmation if ambiguous | see target before entering a PAT | +| Setup choices | linear conditional questions; explicit review pass possible | grouped cards and explanations, saved answers, source-locked values, live grant implications | understand what a choice enables | +| Setup PAT | terminal permission summary + GitHub form link or manual input | role-labelled summary, remote unknowns, official link, account/repo checklist, masked local paste and audit | no inferred browser identity | +| Plan | terminal file/resource preview and confirmation | inspectable grouped diff/Secret names/scopes/warnings, single approved revision | know local and remote effects | +| Bot PAT & credentials | guided/manual bot link and secret prompts | separate bot identity/grants/renewal step, masked values, no cross-role reuse | know persistent credential owner | +| Apply/result | CLI executes and reports | explicit Apply, live semantic progress, partial facts, recovery and cleanup | no false reset or success claim | + +```mermaid +flowchart LR + CLI[copilot setup --web] --> S[Ephemeral local setup session] + S --> R[Repository and choices] + R --> O[Setup PAT at GitHub + local audit] + O --> P[Final plan and grant audit] + P --> B[Bot PAT at GitHub + local audit] + B --> A[Explicit Apply] + A --> Z[Result, doctor, PAT cleanup/renewal] +``` + +Text equivalent: the CLI owns one session. The browser only collects +operator decisions; GitHub handles each PAT in its own tab. Copilot checks +the supplied PAT and current plan before performing setup, then reports +exactly what completed and what remains. + +## 6. Functional behavior and session state + +### 6.1 Launch and normal path + +1. Validate `--web` combinations and canonicalize the checkout before + starting HTTP. Resolve owner/repository from the existing git policy; + ambiguous/missing remotes block rather than guessing. Acquire a per-checkout + local setup-session guard shared by terminal and web modes so a second + setup process cannot apply to the same checkout concurrently. A lock has + no credentials and is released on normal exit. If the recorded owner is + dead, the CLI MUST fail closed, print the exact lock path, and require an + operator to verify no setup process is running before removing that one + file manually. It MUST NOT unlink a stale lock automatically: another + process can replace it between a read and an unlink. Publish a fully + written lock record atomically; failed writes MUST NOT leave a blocking + empty lock. Web + mode does not require an input TTY: the browser is the interactive surface, + but the operator MUST be able to read the launcher's stdout, either directly + or in a privately captured task log, to obtain the pairing code and local + URL. An invocation whose stdout is discarded cannot be paired; rerun from + a readable terminal or captured-output task. Copilot never copies the code + into the browser URL or a persistent diagnostic log. + Web setup MUST verify an attached Git branch and canonical HEAD before + opening the browser or collecting credentials. Detached HEAD or an unreadable + branch fails immediately with checkout guidance; no fallback branch name + may be inferred for this guarded session. + Web setup MUST also reject invocation from a repository subdirectory before + HTTP or PAT collection. Its current Apply boundary uses process-relative + checkout paths; accepting a subdirectory would make the approved drift + snapshot inspect a different destination. The error identifies the + canonical repository root and tells the operator to change directory and + rerun. Canonical path comparison permits a symlink spelling of that same + root, but never a nested directory. +2. Bind `127.0.0.1:0`, record the assigned port, create an unpredictable + one-run session key, a separate 16-hex-character pairing code, and first + controller lease in process memory. Print the pairing code only to the + launcher's stdout, without adding it to accumulated diagnostics, then open the + default browser to the public `http://127.0.0.1:/` URL. The initial + page asks for the code before any setup state is shown. A same-origin POST + exchanges it for the session key held only in browser memory; five invalid + attempts cause a 30-second cooldown, not a permanent session lockout. The + page MUST use the same 16-hex-character and busy-state guard for button + clicks and Enter/form submission, including takeover. Incomplete or + non-hex text must not consume a server attempt; the server still validates + every request independently. + counter resets after the cooldown, so unauthenticated loopback traffic + cannot permanently consume the operator's pairing opportunity. A second paired tab remains + read-only until its operator explicitly re-enters that same code for a + takeover POST; no takeover ticket is distributed in bootstrap. Failed + takeover codes share the bounded attempt counter and cooldown. A successful takeover + rotates the controller capability and invalidates the previous tab. + If opening fails, print the + public URL and instructions; serving continues. Neither code nor key may + appear in URL, history, cookies, browser storage, or accumulated logs. + If binding or packaged + assets fail, stop without a partial UI and suggest `copilot setup`. +3. Show the six stages already used by terminal setup. Import one immutable + snapshot of defaults, config file, and non-secret flags. Mark supplied + fields with their source; fields fixed by existing flags/config are + read-only in the web questionnaire. A changed config file after launch is + not silently reread; tell the user to restart to adopt it. The browser + sends only bounded answer values; the application owns conditionals. +4. Before the setup PAT, collect permission-affecting local intent and show + the exact required grants plus **May need after GitHub inspection**. A + review/back action may reopen a second pass over saved answers; the UI + labels it as the same run. Opening the guided link is a user gesture to + GitHub; `target_name` is only resource owner. The page explicitly asks the + user to check their GitHub account, switch **All repositories** to + **Only select repositories**, select this repository, and Generate. The + manual path remains available. +5. Paste the setup PAT into a masked field on the local page. Send it once + to the CLI process; clear the input after receipt and never echo it in a + response. The server validates identity, target access, and grants, + displays the actual GitHub account for confirmation, then completes + authenticated inspection, remaining questions, plan, and final grant + audit. Unknown organization approval is not labeled `pending` without + evidence. A new required grant invalidates the prior link and blocks + dependent mutation until corrected/replaced authority passes re-audit. +6. Show the final plan with file actions, backups, GitHub resource names and + scopes, workflow updates, warnings, and credential **status only**. The + operator reviews a plan revision. Then present the *distinct* bot PAT + grants and resolved expected bot user ID. GitHub's form opens under the + bot account; the submitted bot PAT must pass the current guided numeric-ID + and grant checks before Secret `PAT` may be written. Manual and existing + PAT handling retain the baseline's exact claims, not invented identity + assurances. An existing GitHub Secret value cannot be read back: when the + existing policy requires re-audit, ask for a new/re-entered bot PAT and + show preserve-versus-replace consequences before Apply. Other credentials + use masked local inputs. Unlike the terminal composition, the web + composition MUST NOT dispatch or bootstrap the credential-health workflow + during this pre-Apply step. Existing Secret values remain unreadable; the + bot PAT is re-entered for grant/identity audit. Existing optional provider + credentials may be explicitly kept with `unverifiable` status, never + described as healthy. Post-Apply health is checked with `copilot doctor`. +7. Before Apply, recompute a compact impact summary and verify that the + approved plan revision, repository identity, relevant workspace file + digests, remote facts, permission audits, and bot credential are current. + Revisions or stale evidence return to review and require new approval; + missing grants return to the appropriate PAT step. The final Apply button + starts one execution through the existing setup mutation use case. +8. Show event-driven progress, actual completed facts, and a final state. + On success, distinguish local disposal from user-owned GitHub deletion of + the temporary setup PAT, and installed bot Secret from later Action health. + Offer `copilot doctor`/relevant GitHub Settings as inspectable next steps. + Never silently delete the bot PAT, which the Action still needs. + +### 6.2 Alternative and boundary paths + +- `copilot setup` without `--web` is unchanged. `--dry-run --web` MAY display + a local-only plan with explicit **No changes** outcome, no required PAT + creation, and no Apply action; any remote facts unavailable without a PAT + are labeled unknown. It does not become a credential-health proof. +- A detected `PERSONAL_ACCESS_TOKEN` is **not silently consumed** in web + mode. Offer `Use existing environment setup PAT` with no displayed value, + or choose guided/manual web input; the chosen credential follows the same + audit. A supplied value must never be sent to the browser. Exiting Copilot + does not unset the parent shell's environment variable; cleanup copy must + distinguish this source from a one-run pasted PAT and tell the operator + how to remove/revoke it when appropriate. +- Existing non-secret setup flags and `--config` are honored with their + current precedence. `--non-interactive`, `--yes`, `--token`, + `--workflow-pat`, `--secret`, and + `--confirm-unverifiable-write-permissions` combined with `--web` fail early + with a concrete CLI fallback; this prevents a hidden approval or command + history secret path from masquerading as visual review. Unverifiable writes + use the existing explicit, narrowly allowed acknowledgement in the UI; + unverifiable required reads remain blocked. +- The bot may be the same account as the setup operator only under existing + policy. Warn about self-event/guarded-approval consequences. The browser's + active GitHub account is never inferred from Git, `gh`, or the setup PAT. +- An unsupported fine-grained grant or GitHub owner policy disables the + guided link for that role and provides the existing full permission table + and manual compatibility path; never auto-select a broader classic PAT. +- Leaving the page or closing its tab does not prove cancellation or + revocation. A second tab starts read-only and may take over only after its + operator explicitly re-enters the pairing code from the launching output; + takeover rotates the controller lease, invalidates pending responses from + the old tab, and shows the current server-owned phase. No PAT value is + rehydrated into either tab. Browser Back revisits a *view*; it cannot undo + a committed state or bypass current validation. +- A user may cancel before Apply. The server stops and reports that local + setup mutation did not start; any PAT they already generated on GitHub may + still exist and needs owner cleanup. During Apply, cancellation is + cooperative only at supported safe boundaries; the result is partial or + indeterminate until inspected, never simply `No changes`. + +### 6.3 State machine and event contract + +| State | Meaning / evidence | Allowed next action | Block/expiry behavior | +|---|---|---|---| +| `repository` | canonical checkout/remote shown; no mutation | confirm target | invalid remote blocks startup | +| `choices` | one server-owned intent draft and source locks | answer, review, cancel | invalid/stale answer rejected | +| `setup-pat` | grant preview, unknowns, account checklist | guided/manual/environment, paste, audit | wrong account, missing grant, org approval block | +| `plan` | final normalized plan revision and audit | inspect, revise, approve, dry-run end | drift invalidates revision | +| `credentials` | bot ID/grants and other credentials | paste, audit, revise, cancel | wrong bot/Secret scope blocks | +| `ready-to-apply` | current approved plan and credential facts | one explicit Apply | stale revision returns to review | +| `applying` | mutation has begun; completed facts accumulate | wait; bounded safe cancel | duplicate Apply returns same operation | +| `complete` | every required operation reported success | inspect, stop | bot Secret may remain active | +| `partial` | at least one operation may have committed | inspect, run doctor, deliberate retry | no automatic replay/rollback | +| `blocked` | gate failed before mutation | fix named cause, retry/review, stop | retain non-secret draft only | +| `cancelled` | operator ended pre-Apply | close | cleanup GitHub-created PATs manually | +| `expired` | idle/hard session limit, no Apply running | restart CLI | no credential/draft recovery | + +Every event includes the current session revision, the plan revision when +one exists, and one bounded idempotency key for Apply. The server serializes +decisions for a session; +duplicate answers are harmless, an out-of-order answer returns current state, +and only the active controller can submit. One plan may have at most one +in-flight Apply operation. After a process crash, no session is resumed and +no prior Apply result is assumed; the next run uses existing read-before-write +and doctor policies to reconcile facts, and requires fresh token entry and +approval before any new mutation. + +## 7. User-facing configuration + +| Input | Type / default | Allowed scope and validation | Precedence / persistence | +|---|---|---|---| +| `--web` | boolean / off | interactive local setup only | command invocation; no stored preference | +| `--dry-run --web` | boolean / off | no Apply, credentials optional only where existing plan needs evidence | one run; no mutation | +| Existing non-secret flags and `--config` | existing typed values | same validators, skip/fixed semantics as CLI | flags > config > defaults; snapshot at launch | +| Web answers | existing setup question types | existing bounded enums, names, counts, cross-field rules | editable defaults only; one-run memory | +| Environment setup PAT | optional hidden choice / unused | only after explicit operator selection and audit | process memory; never a web response | +| Bot login | explicit GitHub user / none | existing numeric-ID resolution and guided check | one run; non-secret only | +| Local server | fixed `127.0.0.1:0` | no host/port override | OS-assigned port; never persisted | +| Session lifetime | 30-minute human-idle, 4-hour hard cap | Apply in progress is not interrupted by idle timeout; show countdown/warning and fail closed on hard cap before Apply | monotonic process clock; not configurable | + +Example recommended: `copilot setup --web` in the intended checkout, with +guided setup and bot PAT links. Alternative: `copilot setup` keeps the terminal +wizard; `copilot setup --non-interactive --config setup.yml` remains an +automation path and never starts a browser. `--web --yes` is invalid rather +than silently treating a web Apply button as already clicked. No browser +theme, host, timeout, or credential persistence config is introduced. Unknown +flags/fields fail existing parsing. There is no stored web session or schema +to migrate; a future version cannot reread an old session. + +## 8. Clean Architecture design + +### 8.1 Boundaries and dependency direction + +| Boundary | Owns | Must not own/import | +|---|---|---| +| Domain and pure policies | setup choices, grant plans, PAT roles, identity comparison, config/storage rules, plan revision and drift decisions | Svelte, HTTP, terminal, Octokit, process state | +| Application | frontend-neutral `SetupSession` coordination, stage transitions, immutable semantic commands/views, credential/permission/plan/apply use cases | web routes, DOM, Node HTTP, provider DTOs | +| Semantic ports | repository/workspace facts, GitHub reads/writes, secret input handoff, clock, operation progress, presentation | HTTP request/response or browser objects | +| Adapters/data | existing GitHub/workspace adapters; terminal and web request/view adapters | duplicate question lists, grant matrices, authorization decisions | +| Infrastructure/composition | short-lived Node HTTP server, session lifecycle, asset serving, browser opener, provider wiring | product decisions or alternate setup implementation | +| Browser presentation | Svelte components, accessible copy, conditional view rendering | direct GitHub API calls, filesystem, persisted PATs, authoritative plan state | + +```mermaid +flowchart LR + T[Terminal adapter] --> U[Shared SetupSession use case] + W[Svelte view + local HTTP adapter] --> U + U --> Q[Questionnaire, permission, plan, credential policies] + U --> P[Semantic workspace/GitHub/clock/progress ports] + I[Existing provider adapters] --> P + W -. static same-origin assets .-> H[CLI-owned loopback server] +``` + +Text equivalent: both presentations submit semantic decisions to one +application coordinator, which uses existing policies and provider adapters. +The loopback server adapts HTTP and serves compiled assets; it is not a +second business-logic engine. `src/cli/commands/setup.ts` becomes a thin +entrypoint/composition root. No `application` or `domain` module imports +Svelte, browser, HTTP, terminal rendering, or `runLocalAction` directly. + +The final web Apply authorization is one application use case with injected +repository-facts, selected-file snapshot, remote-facts, permission-audit, +approval, and live-session ports. It must fail closed on missing/drifted facts +or a session that was cancelled/expired while asynchronous reads were running. +The compared remote facts include the GitHub default branch, because setup may +use it as the initial main branch when none was explicitly configured. A +default-branch change after review invalidates Apply even if other remote +resources and the local checkout are unchanged. +It checks repository identity and selected-file digests before remote reads +and again after the final asynchronous permission audit, immediately before +returning approval; drift during those awaits cannot inherit earlier proof. +The CLI supplies Git/HTTP/provider adapters, but must not reimplement this +decision as an inline sequence. The subsequent mutation boundary remains +single-flight and cannot be entered if the approval use case did not return an +approved result. Deterministic fake-port tests cover every drift category, +cancel/expiry interleavings, and audit outcomes. + +The pre-PAT permission-intent review is likewise an application use case: +it owns questionnaire transitions, owner-kind conflict checks, provisional +grant calculation, review passes, and guided-link eligibility. Terminal and +web adapters supply prompts, status presentation, and the journey view; the +command only wires them and handles the resulting guided/manual outcome. This +keeps the grant decision out of a presentation-specific entrypoint and lets +pure fake-port tests cover repeated review, conflicts, fallback, and invalid +local configuration without creating a GitHub PAT. +Explicit `--config`/CLI `features.release` and `features.hotfix` values remain +fixed during the permission-intent pass. Its issue-workflow selector shows +those constraints before input and rejects a contradictory selection without +changing the draft or recalculating a misleading PAT grant. An explicit +enabled release/hotfix workflow also prevents disabling the parent Issues +capability; the operator may edit the originating config/flag and restart. +The resulting fixed inputs carry into the full wizard without a second, +silent override. Web validation and pre-answer guidance are localized in all +four supported languages; CLI explains the same constraints in English. + +The initial setup-PAT identity/access gate is an application use case shared by +both presentations; it requires explicit acknowledgement for unverifiable +write grants and confirmation of the authenticated operator account before +planning. The configured setup-PAT audit is another application use case. It compares +provisional and final required grants, verifies the authenticated identity and +effective access through the read-only permission port, and returns a blocked +result when owner-kind or grants differ. A pure policy builds corrected official +GitHub form links; presenters own their display and explanatory text. The +command does not decide whether an unverifiable write grant is acceptable. + +The browser is decomposed at its own boundary. `web/src/App.svelte` is only +the page shell and view composition. A single `web/src/session/` client owns +bootstrap, polling, revision-bound answer/cancel/takeover/close commands, and +redacted session state; it never retains a submitted PAT in a store or browser +storage. `web/src/components/` contains cohesive presenters for progress, +header/theme, status, prompt kinds, context, and outcome. Components receive +the redacted view and callbacks; they never call `fetch`, import provider or +policy modules, or infer authority from local UI state. Prompt inputs keep +only transient local values, clear secrets before submission, and the prompt +presenter is keyed by the server prompt revision inside `PromptCard`. A new +question remounts with its own defaults; ordinary polling or session-message +revisions do not erase an answer in progress. Background polling also preserves +an action-error banner across successful state reads until a user-initiated +action succeeds; a new connection failure may replace it with the connection +error. Small pure helpers may normalize defaults +and allowlisted links. Adding a prompt kind belongs in its presenter rather +than growing the page shell; avoid one-file-per-element indirection with no +reuse. Architecture tests guard dependency direction and bound shell and +presenter sizes, with reviewed exceptions only when cohesion justifies them. + +An empty issue-workflow multi-selection MUST submit an explicit `none` answer, +not an empty answer that reuses defaults or silently selects every workflow. +When the questionnaire's multi-select default is `All`, the browser MUST +visibly preselect `All` and submit `All` if the operator continues unchanged; +explicitly deselecting it MUST still submit `none`. `All` remains mutually +exclusive with individual workflow choices. This is a pure presentation +initialization/serialization rule, not a second questionnaire parser. +Both terminal and web routes use the same questionnaire parser for this choice. + +### 8.2 Contracts, ownership, and trust + +- The session command API is semantic (`answer(questionId, value, revision)`, + `review(role)`, `submitCredential(role, value)`, `approvePlan(revision)`, + `apply(revision, idempotencyKey)`, `cancel()`), not a general RPC, shell, + arbitrary path, or raw GitHub endpoint. Exact DTO/schema/size limits are + checked by the HTTP adapter before the application sees them. Provider + URLs and error strings are never trusted as web links or HTML. +- One application-owned session contains repository identity, copied draft, + answered IDs, stage, plan revision, non-secret verification facts, and + short-lived credentials only while needed. Secret-bearing structures never + implement serialization or enter presenter views. The browser receives + only a dedicated redacted view model. Server/process memory disposal is + best-effort, not a remote revocation or guaranteed zeroization. +- Existing questionnaire/default/permission policies are the only source of + question visibility, defaults, validation, and grants. A web control may + describe a rule but cannot loosen it. Once choices or remote facts change, + downstream plan, URL, account confirmation, and approval proofs are + invalidated according to their dependencies; the UI explains why it + returned to an earlier stage. +- The repository path is fixed after launch. Recheck canonical path, git + owner/repo, selected branch/ref, and relevant file digests both before and + after asynchronous remote/PAT checks; recheck remote facts during those + checks, immediately before Apply. Unexpected drift yields a new plan revision and + requires fresh human review; never apply from a stale browser response. + Repository-relative plan labels such as `workflows/name.yml` and + `ISSUE_TEMPLATE/name.yml` MUST be translated to their actual checkout + destinations under `.github/` for this comparison. Include managed assets + that a changed selection may retire, not only files displayed as selected. + The guard set always includes the repository-agent guidance manifest, profile, + guide, skill, and managed `AGENTS.md` pointer destination. Disabling guidance + can retire manifest-owned artifacts; a changed manifest or any allowlisted + artifact after approval MUST invalidate Apply before reconciliation. These + paths are guard evidence even when omitted from the plan's selected files. +- The shared execution boundary owns idempotency and partial facts. The + browser uses bounded polling or server events for **read-only** progress; + reconnecting to the same live process retrieves redacted current state, + not secret values or an implicit retry. + +### 8.3 Executable architecture and packaging constraints + +1. A dependency test MUST reject `src/domain`/`src/application` imports, + re-exports, and literal lazy/CommonJS dependencies on + Svelte, Vite, DOM, `node:http`, terminal presenters, Octokit concrete + adapters, and CLI modules; Svelte modules MUST import only public + view/contracts and never provider or mutation modules. +2. Contract tests MUST run the same scenario fixtures through terminal and + web adapters and compare normalized choices, grant sets, plan revisions, + identity gates, and result facts. Question/permission tables cannot be + copied into web source; a structural check enforces one policy owner. +3. Build order MUST produce a Vite static directory plus the `ncc` CLI + bundle. `package.json` allowlist and asset manifest MUST include only + required hashed HTML/JS/CSS/fonts. Paths resolve relative to the installed + package, not `process.cwd()`. Assets are read-only, served with exact MIME + types, and cannot escape their directory through encoded paths or symlinks. +4. `pnpm run validate:build`, `validate:npm-package`, and an isolated + `npm pack`/global-install smoke fixture MUST prove asset presence, + checksums/manifest parity, executable launcher, and no runtime use of + Vite or source/dev files. The Action/API bundles MUST NOT ship Svelte or + acquire a new runtime web-server dependency through shared imports. +5. No issue, PR, check, comment, or label is generated by launching the UI. + Existing setup-induced GitHub resources remain governed by the approved + plan and its existing workflow/permission validators. + +## 9. Web UI/UX and content contract + +### 9.1 Information hierarchy and navigation + +The first viewport of every phase answers: **what is happening**, **what is +complete**, **what is next**, **what the user must do**, and **whether any +change has started**. Render a six-stage text-labelled progress rail, not a +percentage or question count. Use a stable repository badge and PAT role +heading. Show one primary action per screen, with a secondary Review/Back +action only where safe. A stage reopened after revision says why, preserves +answers, and shows `review pass 2` or equivalent, never `Start again` unless +a new CLI process really starts. Permission summary is short by default; +the exact table, reasons, and remote unknowns are one expansion away. + +```text +Copilot setup · Local assistant vypdev/copilot · develop +Repository ✓ Choices ✓ Setup PAT → Plan · Bot PAT · Apply · + +Setup PAT — action required +Known grants: Metadata read · Contents read · Secrets write +May need after GitHub inspection: Actions write (existing managed Secret) +No repository changes have started. + +1. Open GitHub's prepared PAT form as your setup account. +2. Change All repositories to Only select repositories → vypdev/copilot. +3. Generate there, then paste the PAT below. Copilot has not created it. +[Open GitHub form] [View all permissions] [Use existing PAT] +``` + +Text equivalent: the operator is at the third phase, sees known and unknown +grants, must complete GitHub's form under the correct account and select the +specific repository, and knows no setup mutation has begun. The link is an +explicit user action to the fixed official GitHub host; it never contains a +credential. The `Bot PAT` screen repeats the checklist under a visibly +different account/role and states that its token remains needed by Actions. + +| Primary state | Representative visible copy | Primary action | +|---|---|---| +| Pending | `Inspecting existing resources for vypdev/copilot. No setup changes have started.` | Wait; `View details` is secondary | +| Action required | `Open GitHub as the bot account @vypbot, select only vypdev/copilot, then paste its PAT here.` | Open official form | +| Blocked before mutation | `Nothing was changed. The setup PAT lacks repository Variables write. Create a corrected PAT, then return to this step; your answers remain.` | Correct setup PAT | +| Partial after mutation | `8 files were installed and Secret PAT was updated; one workflow update failed. Do not delete the bot PAT. Inspect these results before retrying.` | Inspect result/doctor | +| Complete | `Setup finished. The bot PAT is installed as Secret PAT; its future Action health is not yet proven. Delete the temporary setup PAT in GitHub when no longer needed.` | View result/cleanup | +| Cancelled/expired | `No setup mutation started in this session. Any PAT already generated in GitHub may still exist.` | Open GitHub PAT Settings / restart | + +Errors follow `impact -> cause -> action -> retained state`; partial states +list each committed local file/resource and unresolved step without a raw +provider payload. Plan review shows `create/update/preserve/skip`, target +scope, backups, and warnings, not credentials or their values. Before Apply, +show the exact repository, affected file/resource counts, verified account +labels, unresolved warnings, and an unchecked explicit confirmation. The +button says `Apply to vypdev/copilot`; it is disabled until the current plan +revision is accepted. After click, disable retries until the same operation +returns; never imply progress based on elapsed time alone. + +### 9.2 Browser, responsive, accessibility, and localization + +The visual system is a reusable set of tokens and patterns, not page-specific +colors copied into each screen. `web/src/styles/` separates palette tokens, +foundations, layout, form controls, feedback, and responsive rules, imported +once by `style.css`. Shared visual primitives cover banners, buttons and +card/field patterns where presenters actually reuse them; presenters compose +these for question, choice, credential, plan, context, and result states. +State styling uses semantic tokens (`success`, `warning`, `error`, focus) in +light, dark, and system modes. Components retain native labels, keyboard/focus +behavior and explicit status text; decoration never carries meaning alone. +Visual changes require representative state and interaction tests plus both- +palette contrast checks, not screenshot-only assertions. The contributor +architecture guide documents these boundaries for future steps. + +- The visual system MUST ship complete **light and dark** palettes for page, + surfaces, borders, text, muted text, focus, links, warnings, errors, and + success states. Follow `prefers-color-scheme` by default and offer an + accessible `System / Light / Dark` control. A manual choice lasts for this + browser tab only; it stores no credential or session state and a reload + returns to the system preference. Native controls receive the matching + `color-scheme`. There is no light-only loading flash in a dark system theme. + Status meaning never depends on hue alone. Test text contrast at >= 4.5:1 + (>= 3:1 for large text) and essential UI/focus indicators at >= 3:1 in + both palettes; review hover, disabled, validation, code, and external-link + states as well as the happy-path cards. These thresholds follow + [WCAG contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) + and the system-default behavior follows + [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/%40media/prefers-color-scheme). +- The page SHOULD use a coherent editorial dashboard layout: restrained + typography using locally packaged/system fonts, generous spacing, a + high-contrast progress rail, a focused question card, and a persistent + context/impact panel on wide screens. At narrow widths the context moves + below the main action without hiding grant deltas or recovery text. Motion + is purposeful, brief, and removed under `prefers-reduced-motion`. +- The page MUST work at narrow width and 200% zoom, keyboard-only, reduced + motion, and light/dark modes. Semantic headings, labels, descriptions, + field errors, focus restoration, and a polite live region carry status; + color/icons are supplemental. The PAT field is masked, has a visible + show/hide control only if security review approves, and never displays a + pasted value in a toast, debug view, or browser history. +- GitHub links have descriptive text, `rel="noopener noreferrer"`, and a + visible external-destination warning. `Referrer-Policy: no-referrer` protects + the local origin when a GitHub link opens. A copyable link may be shown + when browser pop-ups are blocked; it contains no session or PAT value. +- English is the initial setup locale, matching the terminal. A visible + language selector offers four complete catalogs: English (`en`), Spanish + (`es`), French (`fr`), and Portuguese (`pt`). These languages are chosen for + direct maintainer translation; the earlier ten-language preview is retired + rather than advertised as complete. A locale MUST NOT appear until its + complete catalog and reviewed safety copy pass the release gate. + It changes explanatory UI text immediately without restarting setup, + changing repository/issue locale, modifying answers, or replaying an Apply. + Unsupported locale falls back atomically to English. Account/repo names + and remote messages are escaped as text, never injected as HTML or Markdown. +- No issue/PR/check/comment is added by the web surface, so notification + budget is zero. Progress updates in the page are coalesced and do not + repeatedly steal focus or announce the same state. GitHub-side account + switching and 2FA are explained, not reproduced in the local UI. + +### 9.3 First-time comprehension and progressive disclosure + +Every active question MUST explain, in the selected language: what this +controls, when it applies, why the suggested answer is safe, a concrete +example, what changes if selected, and how to inspect the result. The primary +card uses one plain-language sentence and one recommendation; a reusable +details panel holds deeper examples, security implications, and a documentation +link. The semantic question ID is the stable key. Explanatory copy is owned by +one reviewed application presentation catalog and projected into both terminal +and web views; the browser MUST NOT own an independent copy of setup rules. +Conditional visibility and validation stay in the existing questionnaire. +Question IDs absent from a locale catalog fail the catalog completeness test. + +The same completeness rule applies to the English-only interactive CLI. Each +CLI question shows its meaning, recommended answer and a descriptive reference +link before accepting input; entering `?` opens its full `what / when / where / +how / why / example / effect / verify` explanation and returns to the **same** +unanswered question without changing the draft. The web shows the same semantic +help in the selected one of four languages, with those headings in progressive +disclosure and a contextual link beside the question. A link may point to a +relevant Copilot configuration guide or official GitHub documentation, not an +unrelated generic home page. The PAT/setup/bot/plan/Apply/blocked stages, +credential prompts, conditional permission rows, dynamic choices, validation, +and cleanup receive the same treatment. First-time comprehension is not +declared complete while any first-party prompt falls back to English in a +non-English web locale. + +The four-language release gate is all-or-nothing for each selectable locale. +It covers every questionnaire ID and every displayed field (`label`, `summary`, +`when`, `where`, `how`, `why`, `example`, `effect`, `verify`), selectable option +labels, permission name/reason/condition/status, credential and confirmation +prompts, plan warnings, progress and validation notices, and every terminal +outcome. Stable wire values, GitHub-owned content, commands, product names, +user-entered text and repository identifiers are not translated. A static +catalog/key audit MUST reject a missing or unchanged English first-party +sentence; render tests MUST traverse representative normal, conditional, +blocked and partial paths in every locale. A persistent "translation preview" +notice is a development warning only and MUST disappear only when these gates +pass. A separate semantic review of safety-critical PAT, Apply and cleanup copy +in each advertised language remains a release gate in addition to machine +checks; direct translation is not its own independent review. + +Translation production is a development-time operation, never a setup-time +dependency. This four-language slice is translated directly by maintainers and +sends no content to a translation service. If future work explicitly +authorizes external assistance, a translation provider may +receive only an allowlisted export of static, publicly visible English UI copy +and non-sensitive semantic context (message ID, UI location, placeholder names, +and terminology guidance). The export MUST exclude PATs, API keys, cookies, +repository/account names, questionnaire answers, GitHub responses, session +state, logs, source code, and any other runtime or user-specific data. The +export is reviewed before transmission; neither the web client, CLI, CI, nor +published package calls a translation service. Returned text is committed as +static catalogs only after placeholder/option-identity checks, a second-pass +semantic review against the English source, and the safety-copy review above. +An unavailable provider or failed review blocks advertising the affected +locale; it never triggers an English-mixed view or sends runtime content as a +fallback. Translation provenance and review status are recorded without +storing provider credentials or submitted runtime data. + +Help links are selected by a closed, versioned registry keyed by semantic +question/prompt IDs. Their HTTPS origin and path are allowlisted; user-supplied +check names, provider messages, repository names and URLs never become a help +destination. Every registered URL/anchor is verified in documentation/link +tests, and the CLI prints the full safe URL. Browser links open separately +with `noopener noreferrer`, a visible external destination, and no PAT/session +parameters. If a destination is unavailable, setup remains usable and the +local explanation is still complete. GitHub's own PAT form and account/2FA +pages are outside Copilot's translation boundary; the wizard explains those +handoffs in the selected language. + +The ordinary path MUST not force a beginner to understand provider executable +paths, raw producer tuples, or rule syntax. Advanced controls remain reachable +and explain why they matter. In particular: + +| Decision | Normal presentation | Expanded explanation and guard | +|---|---|---| +| Agent executable | `Use the standard agent command` (recommended) | `codex`, `opencode`, or `agent` runs on the Action runner, not this browser; a custom absolute path is advanced and bound to the chosen provider. Do not copy one explicit path to a different provider. | +| Provider reasoning | Do not offer a misleading toggle while the current string-only CLI adapter cannot return separate reasoning parts | If a future adapter supports it, disclose actual text/retention behavior; never promise concision or metadata-only output without a bounded contract. | +| Bugbot dry-run | `Publish Bugbot findings` (recommended) versus persistent `Analyze without publishing` | Not the same as `copilot setup --dry-run`; suppresses review publication/SCM effects and is incompatible with approval evidence. | +| Organization Bugbot rules | Optional multiline rule editor, one rule per line | These rules take precedence over repository rules; their storage scope is shown separately. Never call a repository Variable an organization-wide policy. | +| Agent CLI provisioning | `Automatic` (recommended) / `Use installed only` / advanced `Reinstall reviewed version` | Explain runner ownership, pinned Codex/OpenCode installs, Cursor's manual prerequisite, and explicit-path exemption. | + +Examples in the card must be clearly illustrative, not a real detected value. +The reviewer can always see the current stored value, source (default/config/ +answer/remote evidence), and implications in the final plan. The web's existing +one-line input MUST NOT be used for newline-delimited rule text. + +### 9.4 Assisted trusted-CI and coverage selection + +After the setup PAT is audited, a read-only discovery use case SHOULD propose +recent, exact GitHub Actions job checks for the target repository. It joins +observed check run IDs/App IDs to workflow run attempts and job check-run URLs; +it does not infer a source from a name alone. Display each candidate as a +checkbox/card with exact job/check name, workflow name, App name and numeric ID, +observed SHA/date/conclusion, `required by branch` evidence when available, +and an inspectable GitHub run link. Never suggest Copilot's own approval check +or a generic commit status that the current approval observer cannot consume. +Deduplicate only identical exact tuples, paginate/bound reads, and distinguish +`observed`, `no recent PR runs`, `runs without verifiable jobs`, `permission +denied`, and `unavailable`. Do not claim that a workflow is configured but has +not run unless a separate workflow inventory actually proves it. An empty or +failed discovery keeps a validated manual path; no list result is itself an +attestation. The user may explicitly retry the **current** discovery question +at most twice per setup run. A retry is read-only, retains unsent checkbox and +manual-field input, never advances the questionnaire, and cannot apply a stale +response after a new answer, cancellation, or controller takeover. CLI offers +`r` or an equivalent numbered retry option; the text fallback trims spaces +around comma-separated IDs and recognizes `retry` regardless of casing. The current question updates in +place; prior answers and the PAT are not requested again. When retries are +exhausted, explain the manual path rather than presenting a dead button. Do +not offer retry for a personal-owner Projects endpoint that categorically +does not support the fine-grained PAT, or when no discovery adapter was run. + +Selection becomes 1–8 structured identities, not a semicolon-delimited text +field. `coverage.checkName` MUST select one of those checks. Because the +persisted coverage contract stores a check *name*, two trusted producers with +the same name cannot be disambiguated for coverage: reject that selection with +an actionable explanation before the coverage question, rather than +silently displaying two producer cards for one name. An existing ambiguous +configuration may enter interactive setup for repair, but final validation +still forbids applying or installing it. The selector displays the +full producer identity, recent SHA/date/conclusion and inspectable run, even +though it persists the uniquely selected check name. Ask the explicit producer +and coverage-step attestation **after** the final coverage identity and, +where applicable, numeric reporter choices; it must never precede the +choice it claims to attest. If branch-rule evidence +was not fetched, label required-by-branch as **not checked**, never `not +required`. In check mode, +the UI asks which selected check *fails when the coverage budget fails*, shows +the related workflow/job link and an explicit `I verified the enforcing CI +step` action. A green check or filename containing `coverage` is suggestive, +never proof. Numeric mode explains the exact +`copilot-diff-coverage-v1` artifact, reporter, head/base binding, and threshold; +it can show observed artifact evidence but never sets `reporterAttested` +automatically. Recommendation mode may display unresolved prerequisites; +guarded mode fails closed until exact identities and human attestations pass. + +Discovery MUST return a semantic state (`observed`, `no-recent-runs`, +`permission-denied`, `unavailable`) independently of its candidates. The web +and terminal explain which state occurred, the bounded sample (20 recent PR +workflow runs, at most 15 inspected; up to 30 Projects over two pages), and the +next action before asking for a manual tuple. A network/API failure must not +masquerade as an empty repository. The manual path labels check name, numeric +source App ID, and workflow name separately (or gives an equivalent CLI +template), validates the exact tuple, and never treats it as verified. +The web App ID field MUST remain string-bound (with a numeric keyboard hint) +and normalize both string and numeric values before validation; an edited +number MUST NOT throw or silently drop a valid producer. Observed check +conclusions, including GitHub's `stale` and `startup_failure`, MUST have +distinct localized labels in every supported locale. Unknown future values +retain an honest unknown-outcome fallback. + +Private-repository discovery needs GitHub `Checks: read` and `Actions: read` +from the setup PAT; these conditional read grants MUST be disclosed in the +pre-PAT intent and generated URL when PR approval is enabled, because setup +then inspects producer readiness and offers remote discovery. +If the operator declines extra grants, local workflow inspection may propose +unverified names but cannot invent App IDs or silently elevate the PAT. This +choice is separate from runtime bot-PAT permissions. The GitHub API's check +run and workflow-list endpoints are the provider boundary; the browser never +receives the PAT. See [check runs](https://docs.github.com/en/rest/checks/runs) +and [workflows](https://docs.github.com/en/rest/actions/workflows). + +### 9.4a GitHub Projects without opaque IDs + +GitHub-supplied Project titles and URLs shown as terminal selector choices +MUST have terminal controls, line separators, and bidirectional override +characters removed before display. The terminal driver applies the same +sanitization at its output boundary to all choices without altering the +underlying selected Project number. Provider text never becomes terminal +markup or a second apparent choice. +If the terminal cannot render an interactive multi-selector (or its driver +does not implement one), the text fallback MUST list these sanitized Project +choices with their actual URL numbers, plus available `manual` and `retry` +actions. It accepts those IDs directly, preserves the current selection on +empty Enter, and never implies that a row index is the Project number. + +The permission-intent pass asks only whether Projects integration is wanted; +it must not ask for numbers before the setup PAT exists. +An explicit No on a revised Project-intent pass takes precedence over an +earlier Project selection: clear saved Project numbers in the reviewed draft, +remove the conditional organization Projects grant from the preview and URL, +and do not reject a personal owner merely because of those old numbers. +Fixed configuration overrides that keep Projects enabled remain visible as +fixed decisions rather than an editable opt-out. + +The post-PAT pass +shows a bounded, read-only list of Projects owned by the repository owner, +each with title, owner, number and inspectable GitHub URL. Organization +Projects use GitHub's paginated organization Projects endpoint and require +organization `Projects: read` for discovery. Setup only reads Projects and +stores their selected numbers/Status names in repository configuration, so its +PAT does not need `Projects: write`. The separate runtime bot PAT needs +`Projects: write` when automation later updates Project items. +The bounded adapter follows both GitHub REST `Link: rel="next"` forms (`page` +and `after`) for at most two inventory pages. It must report truncation if +another page remains, reject non-GitHub/unsafe pagination URLs, and not treat +a partially paginated Status-field response as a complete set of options. +The discovery adapter MUST exclude Projects whose `closed_at` is non-null +(and any row explicitly marked `state: closed`); the UI and CLI state that +only open, accessible Projects are suggested. A closed Project must not be +offered as an active automation target. +Personal-owner REST listing does not accept a fine-grained PAT, so the UI +explicitly says discovery is unsupported and offers validated manual entry. +Network failure, permission denial, no accessible Projects, and a genuinely +empty list have different messages and recovery actions. + +Web Projects use checkboxes with descriptive links; CLI uses a numbered +multi-select. The answer serializes **positive Project numbers** from +`/orgs/OWNER/projects/NUMBER` or `/users/OWNER/projects/NUMBER`, not GraphQL +`PVT_…` IDs. Manual fallback accepts a bounded comma-separated list of positive +numbers or exact matching GitHub Project URLs; it rejects duplicates, wrong +owners, GraphQL IDs and malformed URLs at the question and clearly marks +unverified entries. Existing valid numeric configuration remains readable. +No Project is created by selecting it. + +The four legacy “column” settings actually refer to the Projects V2 `Status` +single-select option. The UI calls them **Status values**, explains the four +issue/PR transitions, and proposes choices only when the selected Projects' +Status options can be inspected and have a common intersection. Missing +Status fields, inaccessible fields, incompatible options or manual entries +are explicit validation/recovery states, not silently verified choices. +The existing four shared values cannot map different vocabularies per Project; +the selector must explicitly explain this and block incompatible discovered +Projects before Apply. Per-Project mappings need a separate design. For manual +Projects whose fields cannot be read, require the operator to check the exact +Status option in each Project and answer a separate, non-defaulted attestation +question after the four values. `No` returns to Project selection within the +same run, Enter stays on the question with help, and only an explicit `Yes` +proceeds. This is a run-scoped human assertion, not a fabricated +remote verification or a new persisted Project field. Do not label it verified +by GitHub. A beginner may skip +Projects. `Empty` means the bounded API returned no *accessible* Projects; the +API does not prove there are none, so neither web nor CLI may assert a +genuinely empty organization. Show the applicable GitHub Project link and a +specific recovery action for each discovery state. + +```text +Want Projects? → audit setup PAT → list owner's Projects or explain why + → select by title/URL (or enter numbers manually) + → inspect common Status options → review effects → Apply +``` + +Text equivalent: decide before creating the PAT, choose existing Projects and +Status values after authorization, review the plan, and only then apply. +Add at least 18 distinct cases to the earlier 241-case budget: 5 policy, 3 +use-case, 4 adapter, 4 UI/CLI, and 2 security/integration. Include pagination +limits, personal-owner unsupported, empty/403/5xx, manual normalization and +owner checks, incompatible Status options, four locales, CLI parity, safe +links and no PAT in browser views. User documentation shows a Project URL, +explains number versus GraphQL ID, and says `Status` rather than “column”. +Provider evidence: [GitHub Projects REST](https://docs.github.com/en/rest/projects/projects) +and [REST pagination](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api) +and [Project fields](https://docs.github.com/en/rest/projects/fields). + +Representative recovery copy, with the same meaning in each advertised web +language and English CLI: + +```text +CI suggestion found: Tests · CI · GitHub App 15368 · success · abc1234 · Sep 29 +Required by branch rule: not checked. Open this CI run; confirm that its +coverage step fails the job below your budget. Choose it only after inspection. +Retry GitHub discovery (2 read-only attempts left), or add name/App ID/workflow. + +Projects query returned no accessible entries. That does not prove the owner +has no Projects. Retry, check Projects: read and owner, or enter the positive +number from github.com/orgs/OWNER/projects/12. The four selected Status values +must exist in every chosen Project; different vocabularies cannot be mapped. +``` + +The read-only retry is an application-port operation triggered by the CLI or +revision/capability-protected local browser endpoint. The current-question +projection is pure; only the application use case owns GitHub discovery and +the two-attempt budget. The browser keeps its prompt revision stable while the +question's candidates/status update, preserving unsent choices. A concurrent +answer, cancellation, or controller takeover prevents the old result from +committing to the visible prompt. Discovery refresh never stores a PAT in the +browser, changes the setup plan, or creates test issues/Actions. + +### 9.5 Language and truthful terminal outcomes + +The selector names the four supported languages; English is default +and all four catalogs must be complete for the shipped setup shell, prompt actions, +question labels/help, progress, PAT guidance, plan, validation, and all +result/cleanup states. An unfinished development branch may preview a locale + only with an explicit persistent notice on untranslated technical content, + including outside the questionnaire; this is not +release acceptance and such a preview must not be shipped as a complete +translation. Translation is presentation-only: stable question IDs, +enum values, API wire values, PAT URLs, and policy serialization remain +locale-neutral. A language change preserves the current draft, pending prompt +revision, pairing/controller capabilities, typed-but-unsubmitted non-secret +answer, and focus. Do not translate user-supplied repository/workflow/check +names or provider errors. Selection is tab-memory only, not a repository +setting; reload returns to English. Set the document `lang` and announce the +selected language accessibly. Unknown locale falls back to a +complete English view; a missing key in any advertised locale fails the build. +Dynamic provider/GitHub prose remains marked as external English when no +trusted structured reason exists, never silently machine-translated. +Translation catalogs are static packaged assets with no external fetch. +Keep one module per language and copy area (shell, question guidance, options, +permissions, prompts, progress, errors, and plan warnings); locale-neutral +resolvers compose them without duplicating setup rules. CI compares the exact +key set of every language against English, not just key counts, and checks +interpolation placeholders, non-empty copy, static option coverage, and the +full defined-question inventory. An equal count with a missing and an extra +key MUST fail. Deliberately identical product names and technical identifiers +are documented exceptions to the unchanged-English-copy audit. +The CLI remains English-only, including the complete question/stage/credential +help and links. First-party server-to-browser prose MUST instead carry a +stable semantic message ID plus locale-neutral parameters so the web never +renders an English CLI sentence as product copy. Technical identifiers, +commands and user-supplied names remain verbatim with bidi isolation; values +submitted back to setup remain the original locale-neutral option values. +For errors with no known semantic ID, the page MUST identify the content as +untranslated external/diagnostic text and still display a localized impact and +recovery action. It MUST NOT silently treat an arbitrary English message as a +translated explanation. Prompt choice labels and permission reasons use +stable identifiers; their submitted values and policy inputs remain unchanged. + +Screenshot evidence on 2026-09-29 showed `PLAN 04/06`, `Setup needs attention`, +and `No setup changes started`. That proves the page reported no Apply +mutation, but hid the actual cause and made cancellation, expiry, and a +blocking validation look identical. This is a product defect. The terminal +may have printed the cause; the browser MUST show the same normalized, +redacted reason and next action itself. A terminal outcome view is immutable +and includes: outcome kind, stopped stage, mutation-started fact, safe reason +code/message, completed local/remote effect names where known, next action, +and PAT cleanup guidance. Never infer `nothing changed` merely from a generic +`blocked` label if an earlier side effect is possible. Preserve the final +cause against later cleanup reminders and close events. Example: + +```text +Setup stopped before applying · Plan (4 of 6) +What happened: The selected CI check could not be verified with this PAT. +Already changed: Nothing in your repository or GitHub configuration. +Next: Give the setup PAT Checks: read and retry, or enter an exact check manually. +Your GitHub-created PAT still exists. Delete it in GitHub when finished. +[See technical details] [Close local session] +``` + +Text equivalent: the user knows the verified cause, what did and did not +change, the safe next action, and the separate GitHub PAT cleanup obligation. +An unknown provider failure says `Cause not confirmed` and offers a bounded +diagnostic code, never a false specific explanation. `copilot doctor` is a +follow-up inspection tool, not a substitute for the result on this page. + +### 9.6 First-run completion: orientation, editing, evidence, and handoff + +This is a proposed extension of the current implementation slice. A developer +must be able to complete setup without guessing whether a displayed value was +read from GitHub, inherited from this checkout, or merely supplied as a product +default. The same semantic decisions and recovery contract apply to English CLI +and the four-language browser; presentation controls may differ. + +```text +Confirm repository -> choose basic/custom scope -> permission preview -> setup PAT + -> inspect facts -> answer relevant groups -> review/edit -> bot PAT + -> Apply once -> inspect itemized receipt -> read-only verification/cleanup +``` + +Text equivalent: the operator sees the target, chooses the amount of optional +configuration, authorizes only needed reads/writes, reviews detected facts and +answers, corrects any group without restarting, explicitly approves mutation, +then receives a durable-to-the-live-session receipt and a safe verification +path. No step silently creates a PAT, issue, Action run, or Project item. + +1. **Orientation and progressive disclosure.** Start with a short capability + summary: what Copilot will install, what a basic path includes, and what + custom settings expose. Basic is the recommended presentation preset, not a + separate policy engine; it retains explicit choices for capabilities, + branches, Projects, guarded approval, storage scope, and all grants that can + change mutation or security. Advanced choices may retain documented defaults + only after the operator sees a grouped summary and can expand/edit them. + The UI MUST show current group and question position/remaining count, not + only six broad stages; conditional questions change the denominator + truthfully. CLI uses a textual equivalent. No fixed time estimate is + presented without measured evidence. +2. **Editing and restart safety.** Back/Change never mutates GitHub, never + resurrects a secret input, and preserves unaffected answers. The final + review groups consequences (features, agents, branches, CI approval, + Projects, Secrets/Variables) and links to edit each group. Changing an + answer re-evaluates dependent questions, project/check evidence, PAT grants, + plan, and Apply revision. An increased grant invalidates previous PAT + readiness until re-audited; a reduced grant warns about excess access but + does not silently revoke a GitHub token. A live session can be rejoined; + after process exit the operator restarts and re-enters credentials. No draft + or approval is persisted to disk or browser storage in this release. A + future opt-in resume requires a separate credential/data-safety SDD with + non-secret data only, explicit consent, 0600 permissions, expiry and fresh + PAT/identity/remote audit. No browser localStorage for PATs, pairing + capabilities, or approval state. +3. **Source-labelled suggestions.** A read-only fact port reports actual + default branch, available development branch, observed workflows/checks, + and selected owner/repository with `observed`, `inherited`, `suggested`, or + `unavailable` provenance plus a source link where safe. A configured + `master` default MUST NOT appear as GitHub-observed `main`. Missing or + inaccessible data is not an empty inventory. Before PAT, local Git facts + are labelled local; after PAT, remote facts may supersede suggestions but + never overwrite an explicit answer. The operator confirms branch roles. +4. **Evidence-assisted CI and Projects.** Observed check identity includes + name, App ID, workflow, run/ref and bounded search scope. When branch + protection/rulesets are readable, mark required-check evidence with the + exact source; otherwise say `not checked` and link to the corresponding + GitHub settings page. A green run never establishes coverage enforcement; + the human attestation remains mandatory. Show the workflow file and run + when exact safe links are available. For several Projects whose Status + vocabularies differ, support an explicit per-Project mapping of all four + transitions or explain why that capability is not yet safe; never claim a + shared value works when it does not. The runtime model and PAT audit must + support per-Project mappings before the UI offers them. No broad grant is + added merely to make a suggestion appear. + In the first implementation slice, Basic skips only catalogued advanced + questions whose configuration still equals the product default; it never + suppresses a non-default override. The plan names every group with retained + defaults, exposes an edit control for it, and re-runs permission audit after + an edit. Selecting independent agent models exposes all per-role questions. + The web plan review also lists issue workflows, all agent/model routes, + exact trusted CI producer identities, coverage mode/check, each selected + Project Status transition, storage scopes, and issue-resource handling; + technical values remain unchanged while labels are localized. + An active repository-owned branch ruleset is positive required-check evidence + only when both check context and source App ID match the observed run; + inherited organization rulesets, branch protection, or an unreadable ruleset + remain `not checked`, never `optional`. A numeric + Project selection with incompatible Status options is blocked rather than + silently mapped to the wrong option. Per-Project mappings require a separate + runtime input and are not implied by the shared-Status selector. +5. **PAT handoff and lifecycle.** Every GitHub form handoff displays the + expected operator/bot account, owner, exact repository selection, grants, + expiry, and the step to return to. Distinguish pending organization + approval, wrong account, insufficient scope, expired/revoked PAT, and + provider outage when evidenced. Setup PAT deletion is a user action in + GitHub, never implied by closing the local page. Bot PAT renewal is a + post-setup obligation; its Secret name/scope and renewal date (if known) + appear in the receipt without revealing value. CLI help prioritizes the + hidden prompt; command-line flags that expose a PAT in shell history are + advanced escape hatches with an explicit warning. +6. **Structured results and read-only verification.** The terminal and web + receive one redacted semantic result: stage, cause, completed/skipped/ + failed/potentially-applied resources, scope, mutation-started fact, + diagnostic reference, safe next action, and PAT cleanup. Unknown causes + remain explicitly unknown, not generic success/failure. An interrupted + Apply cannot claim nothing changed. A post-success `doctor` affordance + runs only read-only checks; it is separate from Apply and cannot silently + dispatch credential-health or create test resources. After a confirmed + successful Apply, the controller may run one bounded in-page read-only + inspection with at most one retry; the page exposes only redacted counts, + never raw doctor evidence or provider errors. The browser also gives the + explicit `copilot doctor --read-only` command; that mode must mark Secret + values unverified. Plain `copilot doctor` may dispatch the + installed credential-health workflow and must not be described as read-only. + The local setup workflow emits a versioned, value-free operation receipt + covering files, Secrets, labels, issue types, Variables and the initial tag. + A mutation attempt is `needs-inspection` until a confirmed return; a later + operation is `not-started` after an earlier exception. The CLI-to-browser + mapper accepts only these exact operation IDs, states and scopes and never + serializes raw provider errors or arbitrary result identifiers. + If Secrets or Variables management is enabled but its provisioning port is + missing, the application fails preflight before any setup write and reports + an explicit unavailable error. The receipt marks every operation + `not-started`, never `completed`, and states that no values were changed. + A port that attempted a write and returned an error + remains `needs-inspection` because the provider may have applied it. + A successful Secret or Variable provisioning call with zero created and + zero updated values is `skipped`, not `completed`; the provider outcome, + not the presence of a configured port, determines that receipt state. +7. **Comprehension and accessibility gate.** Every question needs a concrete + recommendation, source of the expected value, consequence of alternatives, + validation at the field, and targeted documentation. Generic `enter the + exact value` copy alone does not meet this requirement. The most important + consequence remains visible; expanded help adds detail. Errors identify + the field, cause and correction, preserving input. Dynamic progress/errors + use locale keys and accessible status announcements; an unknown provider or + validation message shows a localized, honest fallback with a terminal- + inspection instruction rather than unreviewed English text. Focus returns + to the current heading + or invalid control, and every state is usable by keyboard, screen reader, + at 200% zoom, and in both themes. English, Spanish, French and Portuguese + must pass semantic language review across success, partial, blocked, + validation and PAT cleanup; key parity is necessary but insufficient. + +Example review and receipt (labels localized in web; CLI English): + +```text +Review setup for org/repo · 6 groups checked, no changes applied +Branches: main (GitHub observed), develop (your answer) [Change] +Trusted CI: Tests · App 15368 · CI (observed run; branch rule not checked) [Change] +Projects: Engineering #12; four Status transitions verified [Change] +Setup PAT: required grants re-audited · Bot PAT: still to be provided +[Apply later] [Continue to bot PAT] + +Setup partially applied · 3 of 4 resource groups inspected +Files: applied · Secret PAT (repository): potentially written · Variables: not started +Cause: GitHub rejected the Variable write (403). No automatic replay. +Next: inspect Secret PAT and Variables in GitHub, then run read-only doctor. +Temporary setup PAT still exists in GitHub. [PAT settings] [Technical details] +``` + +The first view is a no-mutation decision point with editable facts and +explicit provenance. The second is an itemized partial result that does not +equate a failed operation with rollback and separates the two PAT lifecycles. +Primary design references: [W3C multi-page forms](https://www.w3.org/WAI/tutorials/forms/multi-page/), +[W3C error notifications](https://www.w3.org/WAI/tutorials/forms/notifications/), +[GOV.UK check answers](https://design-system.service.gov.uk/patterns/check-answers/), +and [GitHub PAT management](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). + +## 10. Failure, recovery, and cleanup + +| Condition | Impact and retained facts | Automatic retry | Operator action / cleanup | +|---|---|---|---| +| Browser launch denied or unavailable | local server is ready; no mutation | none | open printed loopback URL or use terminal setup | +| Bind/assets/packaged path failure | web mode never starts | none | run terminal setup; report installation problem | +| Unsupported browser/JS disabled | no secret submitted; no mutation | none | terminal setup; no partial web fallback | +| Second setup process/tab | only one checkout operation/controller; previous state intact | none | stop first process or explicitly take over tab | +| Orphaned local setup lock | no automatic unlink, no setup mutation | none | verify no setup process is running; remove only the printed lock path and retry | +| Tab closes, laptop sleeps, session idles | live session may remain until 30-minute idle cap; no implied cancellation | no mutation replay | reopen while live; otherwise restart and clean up GitHub PATs | +| Git remote/ref/file/config changes mid-session | reviewed plan is stale; no Apply | re-read once on request | review new plan or restart to adopt changed config | +| Wrong GitHub account or wrong repository | PAT audit fails; no dependent mutation | no automatic PAT creation | switch account/select repository and generate/correct PAT; delete unused one | +| Org approval pending, unknown, or GitHub rate limit/outage | no dependent mutation; only evidenced pending is named pending | bounded read-only retry with backoff | inspect GitHub/admin status; retry when accessible | +| Grant added/removed after remote inspection | old link/plan no longer exact; no dependent mutation | recompute preview only | correct PAT; disclose excess grant without claiming least privilege | +| Bot ID mismatch or Secret storage shadow | no Secret write; plan/credentials retained without value echo | none | correct account or scope; delete unused PAT if needed | +| Secret write accepted then later operation fails | partial; Secret name/scope may be active, value unreadable | no automatic Apply retry | inspect report/doctor before overwriting or revoking bot PAT | +| Request times out while Apply continues | result unknown to browser; server operation may still run | reconnect to same process, read-only state | inspect progress/result before any retry | +| CLI crash/power loss mid-Apply | durable result unknown; no session recovery | no replay | run doctor/read-before-write reconciliation, enter fresh PATs, approve new plan | +| Local cancel before Apply | no setup mutation; GitHub-created PATs may exist | none | delete unused PATs in GitHub; close page | + +If the local server shuts down, it closes listeners and invalidates session +capabilities. It attempts best-effort cancellation of pre-mutation work and +does not promise to interrupt in-flight GitHub writes. No remote PAT is +deleted by closing the page. The cleanup screen links to GitHub PAT Settings +for the temporary setup PAT and to bot-PAT rotation guidance separately. + +## 11. Security, permissions, and privacy + +1. **Boundary:** the web server is loopback-only, but TCP loopback does not + identify or isolate the launching OS user: another local user can connect + to the port. The browser must enter a cryptographically random code shown + only in the launcher's readable stdout; the server exchanges it for a one-run + session key. That key is required for every API read and mutation, including + bootstrap and takeover. Possession of the pairing code grants local session + access including explicit takeover, so it must not be shared. If a task runner + captures stdout, that private capture must be protected like the code. A + malicious process that can read that output, browser extensions with page + access, or a compromised browser + remain outside this boundary. The product must say so honestly. +2. **Request defense:** reject `Host` not exactly `127.0.0.1:`, + proxy/forwarded host headers, unexpected `Origin`/`Referer` on mutations, + cross-site Fetch Metadata, unsupported methods/content types, and requests + over size/time limits. No wildcard CORS or credentials cross-origin. + The pairing endpoint accepts only same-origin JSON POST, bounds wrong-code + attempts with a 30-second recoverable cooldown, and returns the session key + only for the correct code. The code + and key are not sent in an HTTP URL or stored in a cookie/localStorage/ + sessionStorage; the browser sends the key in a custom header to every + subsequent API route. In addition, + state-changing answer, cancel, and close requests require a separate one-run, + cryptographically random controller capability in a custom header; answers + additionally require a revision check. Pairing and takeover instead require + the pairing code with bounded wrong-code attempts; + that capability is delivered only by an authenticated same-origin no-store + bootstrap response, never a URL or browser storage. Reject missing/invalid + keys or capabilities and rotate the controller capability on code-authorized + tab takeover. Bootstrap never discloses a takeover credential to read-only + tabs. + These controls defend cross-site requests and host confusion, but do not + prove the identity of a local OS user. + The local server sets no cookies and ignores, never logs, any Cookie header + another application on the same hostname may have caused the browser to + send. +3. **Browser isolation:** serve packaged scripts/styles only, with a restrictive + CSP (`default-src 'none'`, bundled `script-src/style-src`, `connect-src + 'self'`, `frame-ancestors 'none'`, `form-action 'self'` as applicable), + `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and + `Cache-Control: no-store` for HTML/API/secret responses. No inline/eval + scripts, third-party font/image/script, iframe, service worker, WebSocket + from another origin, or dev-server middleware in the published package. + Escape untrusted content; do not use raw HTML rendering for provider data. +4. **Secrets:** credential submission uses POST over loopback to an exact + role-specific endpoint; both DOM input and any client variable are cleared + after acknowledgement, but browser/process memory cannot be proved erased. + The server never returns a submitted credential, logs raw request bodies, + records it in traces or crash diagnostics, or writes it to a temp file. + It retains the setup token only as long as necessary and the bot token + until its approved Secret write; after failure, give cleanup guidance. + `autocomplete="off"` and related hints are defense in depth, not a + guarantee that browsers/extensions cannot capture a pasted secret. +5. **Provider boundary:** the local UI sends PATs only to this CLI's existing + verified GitHub adapters; the browser never directly calls GitHub APIs + with them. Official GitHub form links use the existing allowlisted builder. + Numeric bot-ID and setup-account checks are preserved; unknown access + does not become success. The final Apply approval cannot be bypassed by + `--yes` or a forged/stale browser event. +6. **Abuse/failure:** cap simultaneous TCP clients at 16 and bound read/write time, body and + field sizes, retries, and progress buffer. Avoid exposing arbitrary local + files, project paths, source maps, stack traces, or provider responses. + Expired/invalid capability is a 403-like local error with no secret data; + stale revision is a 409-like refresh instruction. Audit the actual mutation + result without a credential-bearing event log. + +## 12. Observability and operational UX + +The page and terminal share a correlation ID that is random and non-secret. +Report stage, repository, normalized result status, account **login/ID where +already verified**, grant-verification outcomes, plan revision, and completed +resource names/scopes; never PAT text, cookies, browser headers, raw payloads, +or the session capability. Debug mode does not relax redaction. Local access +logs are disabled by default. A page refresh reads the process-owned state +once; status polling is bounded and stops after a terminal state. There is no +cloud telemetry, GitHub comment, or notification from merely using web mode. + +## 13. Compatibility, migration, rollout, and rollback + +Terminal and non-interactive paths remain unchanged. Web mode has no durable +schema or migration; a running terminal setup is never converted into a web +session, and a web session cannot switch presentation mid-run after entering +credentials. New npm packages include static UI assets, but Action/API bundles +and workflow assets retain parity. Roll out behind explicit `--web` only, +first with packaged read-only journey/plan fixtures, then credential handling +and Apply after security review. No hosted service or feature flag is needed. +Rollback removes/hides `--web` and its static assets; it cannot undo PATs +users created in GitHub or resources already applied. A downgraded package +still offers the terminal setup and doctor paths. + +## 14. Testing strategy and numeric budget + +The revised floor is **350 distinct cases** (the previous 274 plus 76 +first-run-completion cases), derived from shared-engine parity, +six-stage transitions, two PAT roles, local HTTP abuse, packaged installs, +drift, partial mutation, CI discovery/attestation, complete four-language help, +contextual documentation navigation, English CLI parity, bounded Project +discovery, number/URL validation and shared Status options, +and truthful terminal results. Each test/parameterized behavior counts once; +existing CLI tests are retained, not re-counted as new web evidence. + +| Area | Minimum cases | Risk covered | +|---|---:|---| +| Pure choices/config/grant/plan projection | 48 | prior rules plus source provenance, optional-step filtering, dependent invalidation and shared-Status compatibility | +| Session/use cases/idempotency/races | 54 | prior stages plus back/edit/review, live reconnect/no-durable-resume boundaries, stale PAT grants, Apply races and immutable receipt | +| GitHub/workspace/HTTP adapters | 40 | bounded discovery, branch/rule facts, provider error mapping, retry auth and 403/5xx differentiation | +| CLI/packaging/workflow contracts | 37 | English progress/help/edit, PAT safety warnings, safe links and bundle isolation | +| UI/accessibility/localization/content | 101 | four-language question/receipt content, progress, edit controls, validation, focus and blocked/partial states | +| Integration/compatibility/recovery | 42 | live reconnect, preserved answers, incompatible-Project blocking and complete beginner replay in CLI/web | +| Security/abuse | 28 | forged links, stale revisions/controller takeover, PAT exclusion, permissions and least-privilege fallback | +| **Total** | **350** | No double counting | + +Within the 101 UI cases, cover at least one render/interaction for each prompt +presenter, one revision-change form reset, secret clearing before dispatch, +read-only disabling, all outcome variants, and both theme palettes. Static +architecture tests additionally reject network/storage/provider calls from +presenters and shell growth; these do not replace behavioral UI evidence. + +Repository-wide Jest/coverage, lint, typecheck, build, documentation, +workflow, catalog, and npm-package gates remain. New pure policies target +100% branch coverage; changed application/server/credential modules target +at least 95% statements/lines and 90% branches/functions, with no regression +to higher existing budgets. Architecture tests parse imports, re-exports, +literal lazy/CommonJS dependencies, and contract +schemas, not prose. Use deterministic fake clock/IDs, temp repositories, +fake GitHub ports, fake browsers/HTTP clients, and adversarial origins; +never use real PATs, issue/Action test resources, external services, or +blocking sleeps in CI. Golden UI fixtures must have semantic assertions. +Human evidence covers macOS/Linux/Windows launch or documented fallback, +packaged global install, two GitHub browser accounts, 2FA occurring only at +GitHub, wrong-account handling, narrow/200%-zoom keyboard and screen-reader +pass, system/light/dark visual review including contrast/focus/error states, +browser close/reopen, and truthful partial result. Controlled evidence +uses test accounts outside this repository; no dogfooding is required. + +## 15. Documentation and discoverability + +| Audience | Artifact | Required content | Check | +|---|---|---|---| +| New user | `README.md`, `docs/how-to-use.mdx` | default CLI and optional `--web` normal path, stages, screenshots with text alternative | route/link + UX fixture | +| Setup owner | `docs/authentication.mdx`, `docs/configuration.mdx`, `docs/configuration-checklist.mdx` | two PAT roles, account/repo selection, grants, flag conflicts, env-token opt-in, expiry/renewal | permission/CLI contract | +| Operator | `docs/security-operations/operations/troubleshooting.mdx`, `docs/security-operations/operations/provisioning.mdx` | browser/bind/session errors, partial Secret write, recovery/doctor, GitHub cleanup | recovery fixture | +| Contributor | `docs/development/architecture.mdx`, `docs/dependency-rules.md`, this SDD | shared coordinator, trust boundaries, asset pipeline, transport schemas and threat model | architecture + package checks | + +Docs are published with implementation, not ahead of it. Register routes and +verify links/assets. Examples must match executable fixtures. The terminal +help for `--web` explains local-only scope and the `--non-interactive` conflict. + +## 16. Acceptance scenarios + +1. Given an installed npm package and an eligible checkout on an attached + branch, `copilot setup + --web` opens a bundled local page showing the exact repository and six + stages; no source checkout or Vite server is needed. A detached HEAD is + rejected before a browser opens or any PAT is requested. Invocation from a + subdirectory is likewise rejected with the canonical root path; the + approved snapshot and Apply can therefore never use different roots. +2. Given a failed browser opener, the CLI prints the loopback URL and keeps + serving; given a failed bind or missing assets, it stops without a false + partial setup claim and offers terminal fallback. +3. Given the same bounded configuration through CLI and web, normalized + questions, grant sets, plan and result facts match; fixed config/flags + remain visibly locked and are not silently overridden. +4. Given `--web` with non-interactive/`--yes`/secret-bearing flags, launch + fails before HTTP/credential use. Given an environment PAT, web mode asks + explicitly whether to use it without returning its value to the browser. +5. Given a deliberate second review of choices, the page preserves answers, + labels the review pass, recalculates grants, and returns to Setup PAT + without implying that setup restarted. +6. Given guided setup PAT creation, the page shows the exact local grants and + remote unknowns, opens the official GitHub form only on user action, and + instructs account and single-repository selection. Wrong account or new + required grant blocks setup before mutation. +7. Given finalized runtime requirements, the distinct bot step checks the + PAT's actual numeric account ID and grants before Secret `PAT` write; + manual/legacy paths claim only their existing checks. +8. Given a current approved plan and verified credentials, one click applies + it once. Duplicate click returns the same operation; stale revision, + changed checkout file, changed remote identity, or new permission need + returns to review with no new mutation. This includes a changed guidance + manifest or any managed guidance artifact when guidance is disabled and + its prior artifacts would be retired. +9. Given an invalid/stale tab event or second tab, no action occurs until the + new tab re-enters the launcher-output pairing code and explicitly takes + control; the first tab then cannot submit. Bootstrap to a read-only tab + contains no takeover credential, and wrong-code attempts are bounded. + Given a concurrent terminal or web setup in the same checkout, the + per-checkout guard blocks its Apply before any mutation. +10. Given cancel/idle expiry before Apply, the process closes without setup + mutation and warns that any PAT already generated at GitHub remains the + user's responsibility. A crash during Apply never auto-replays it. +11. Given a Secret write succeeds and a later setup operation fails, the + result lists the Secret's name/scope as possibly active, never prints its + value, and requires inspection before retry or bot PAT deletion. +12. Given hostile Host/Origin/cross-site requests, missing/incorrect pairing + codes or a missing session key on bootstrap, state, or mutations, a missing/replayed controller + capability, excess simultaneous clients, path traversal, oversized body, + or injected account/provider text, the server rejects/escapes it without + mutation or secret disclosure. A failed lock write leaves no published + lock; an orphaned lock is never removed automatically. +13. Given a browser refresh, the user re-enters the terminal pairing code and + the same live process restores redacted state only; PAT values, controller + capability, and plan approval are never stored in browser storage or URLs. + Neither the code nor the session key appears in browser history. The final page never calls local disposal + GitHub revocation or Secret installation verified Action health. + Given a rejected answer, bounded retry, or takeover, ordinary background + polling keeps its error readable; a subsequent successful user action + clears it. Polling cannot silently dismiss the banner after 900 ms. +14. Given narrow width, 200% zoom, keyboard-only and reduced-motion settings, + every primary state and recovery action remains understandable without + color, sound, hover, or developer tools; English fallback is complete. + System/light/dark selection renders every state legibly, with contrast + checks for text and essential controls in both palettes. +15. Given no `--web`, existing terminal, unattended, and dry-run contracts + remain unchanged. Build/package/architecture checks detect missing UI + assets, policy duplication, and Svelte leaking into Action/API bundles. +16. Given a desktop browser but no input TTY and privately readable captured + stdout, web mode still permits pairing and explicit browser decisions; if + stdout was discarded, the operator must relaunch with readable output. + Given an existing unreadable Secret `PAT`, + the UI does not claim to recover its value and follows the current + re-entry/preservation policy. Given an environment-supplied setup PAT, + exit never claims to have removed it from the parent shell. +17. Given any reachable questionnaire ID, a first-time operator can read + specific what/when/where/how/why/example/effect/verification help and a + directly relevant safe reference link in the CLI (English) and web (all + four locales); no help item silently falls back to a generic section. +18. Given `?` at an interactive CLI question, expanded English help and its + URL appear without recording an answer or advancing the questionnaire; + the same question and default are then presented again. Credential-method, + repository-owner and bot-login prompts offer the same non-advancing help; + the final Apply prompt explains planned writes and partial-failure recovery + before returning to an unanswered approval; a PAT is never echoed while + showing help. +19. Given a web language switch during a question, PAT, plan, validation or + result, all first-party copy and option labels switch together without + changing IDs, option values, typed input, permissions or Apply state. + Technical values retain their original identity in every locale. +20. Given a dynamic GitHub or provider error, the page explains its structured + impact and next action in the selected language; raw external text is + separately labelled, escaped and never used as a documentation link. +21. Given any question, Back or Change returns to the selected earlier answer + with unaffected non-secret answers retained; a changed dependency asks + newly applicable questions and re-audits grants before new Apply approval. +22. Given a long or conditional question group, browser and CLI show the + current group and truthful remaining question count; a basic presentation + path exposes all security-significant choices and an editable advanced + summary without silently enabling a capability. +23. Given an actual `main` default branch but a product `master` fallback, + the suggestion is labelled GitHub-observed `main`; if remote inspection + fails, the product fallback remains explicitly labelled unverified. +24. Given a readable required-check rule, the exact source and matching + producer are visible; given unreadable rules, the UI says `not checked` + and retains manual attestation. Given incompatible Project Status values, + no shared mapping is applied to all Projects without explicit mapping. +25. Given a wrong, pending, expired or insufficient PAT, the handoff names + the evidenced cause, expected account/owner and repair step; GitHub + cleanup and bot renewal remain separate human obligations. +26. Given failure before or during Apply, browser and CLI render the same + redacted, itemized effect states; ambiguous writes are `potentially + applied`, not `rolled back` or `nothing changed`. A read-only doctor action + never dispatches or creates a test resource. +27. Given a browser reconnect to a live process, it reads the current + server-owned state without replaying an answer or restoring a secret. + Given process exit, no durable draft, pairing authority, or approval is + written; restart revalidates PAT, identity, remote facts, and plan. +28. Given a novice keyboard/screen-reader user in any supported web locale, + every high-risk question identifies source, recommendation and consequence, + errors identify the exact field and correction, and progress/result + changes are announced without relying on color or a terminal window. + +## 17. Requirements traceability + +| Requirement | Owner/boundary | Verification | Documentation | +|---|---|---|---| +| Optional packaged local UI (§4.1, §6.1, §8.3) | CLI composition + asset adapter | scenarios 1–2, 15; npm pack fixture | how-to-use, architecture | +| Shared setup engine/parity (§4.1, §8) | application coordinator + existing policies | scenarios 3, 5, 15; import/schema checks | architecture | +| Bounded config/compatibility (§6.2–7) | CLI parser + config policy | scenarios 3–4, 15–16 | configuration | +| Separate PAT roles/evidence (§4.3, §6) | permission/identity/credential use cases | scenarios 6–7, 11, 13, 16 | authentication, credentials | +| Revision-bound Apply/recovery (§6.1, §6.3, §10) | CLI root precondition + session coordinator + execution boundary | scenarios 1, 8–11; nested-path launch regression | troubleshooting, provisioning | +| Browser security/privacy (§4.3, §11) | loopback HTTP/asset adapters + redacted presenter | scenarios 9, 12–13 | authentication, architecture | +| Accessible truthful UX (§9) | Svelte presenter + message catalog | scenarios 5–7, 10–11, 13–14 | how-to-use, troubleshooting | +| Complete question help and links (§9.3–9.5) | application semantic help catalog + English CLI renderer + four-language web presenter | scenarios 17–20; exhaustive ID/link/locale gates | how-to-use, configuration, authentication, agents | +| First-run completion (§9.6) | pure questionnaire/evidence policies, application session/edit/receipt use cases, read-only provider ports, CLI/web presenters | scenarios 21–28; 76 added risk-derived cases plus human first-use review | how-to-use, authentication, troubleshooting, configuration | + +## 18. Implementation sequence and current evidence + +The first implementation slice adds the explicit `--web` route, a Svelte/Vite +static build packaged next to the CLI, a loopback HTTP adapter, an in-memory +semantic prompt bridge, web presentation adapters over existing questionnaire, +wizard, credential, permission, and mutation use cases, and a light/dark/system +responsive UI. It also adds a per-checkout setup guard, redacted views, +controller takeover, origin/Host/capability/revision checks, final browser +Apply approval, and pre-Apply repository/file/remote/permission rechecks. +It composes credential collection without pre-Apply credential-health workflow +dispatch/bootstrap, preserving the terminal's existing composition separately. +The final drift guard resolves package-source labels to checkout destinations, +includes retireable managed assets, and treats cancellation/expiry during +asynchronous final GitHub checks as a hard pre-mutation stop. +Temporary fixture tests and npm-pack checks exercise the built artifacts; +neither this repository nor GitHub is used as a setup test target. + +The browser presentation now uses a small page shell, a single session +transport module, prompt-specific presenters, reusable status/theme/card and +control patterns, pure answer/link helpers, and layered CSS. Tests enforce +the browser dependency boundary, module-size budget, palette contrast, +answer normalization, allowlisted links, and revision/capability transport. +This decomposition is an implementation slice, not evidence of the still-open +application coordinator and full UI/accessibility acceptance gates. + +These facts are **not** release acceptance. The orchestration in +`src/cli/commands/setup.ts` still needs extraction into the prescribed + application-level session coordinator; the revised 350-case budget, full human +cross-platform/accessibility review, exact per-resource progress/partial +evidence, and adversarial concurrency/idle/crash suite remain open. The +catalog stays `proposed` until the definition of done is evidenced. Existing +terminal policy/use cases remain the authority; the current web path does not +introduce its own permission catalog. + +The latest full local run on 2026-09-29 passed 506 Jest suites / 5,484 tests, +with 95.97% statements, 90.91% branches, 96.55% functions, and 97.27% lines +repository-wide. The new setup-PAT intent, bootstrap audit, configured audit, +remote-fact comparison, and override merge modules each reached 100% in all +four metrics; final web Apply authorization reached 100% lines and 95.83% +branches. The browser session transport reached 100% in all four metrics. +The local HTTP server reached 99.41% lines and 92.46% branches. +Focused tests additionally cover the CLI handoff, semantic Svelte/Vite renders, +empty issue-workflow selection, drift, cancellation, and package isolation. +Typecheck, lint, Svelte diagnostics, full build, catalog, documentation, +workflow, npm-package validation, and package smoke checks passed without +real PATs or setup dogfooding. Human browser/accessibility and cross-platform +review, the formal 350-case-by-area acceptance mapping, and the complete +application-level session coordinator remain open release gates. The generated +bundle synchronization check runs after the source/build commit is staged. + +The 2026-09-29 localization slice originally previewed ten languages, but +product scope was reduced to English, Spanish, French, and Portuguese. The six +discarded locale catalogs and browser-only high-risk-question overrides are +removed. Separate language modules now own the web shell, question labels and +specific purposes, option labels, prompt decisions, permission explanations, +and progress/validation messages. The four-language question-help contract +includes all defined questions and expanded fields. Tests check exact key +parity, placeholders, static option coverage, permission-reason and plan-warning +inventories, and unchanged wire values. Browser-originated session errors now +have per-language catalogs and an explicitly translated unknown-error fallback; +credential checks carry locale-neutral names and statuses for local display. +These checks prove structural completeness, not linguistic quality or complete +UI coverage. The persistent translation preview remains until dynamic provider +text and normal/blocked/partial render review are closed. The source CI discovery +adapter is a bounded suggestion source, not coverage enforcement or an +attestation. + +The subsequent guidance slice gives all 108 defined questions a field-specific +English/Spanish purpose, adds their contextual documentation links, and lets +the terminal open non-advancing `?` help for questionnaire, credential and +final Apply prompts. Inventory and link tests cover those definitions. Until +every web state is localized and reviewed, every non-English locale displays a +persistent translation-preview notice, including on PAT and result screens. +The related documentation link is visible beside each question without opening +the expanded help. This is a development affordance, **not** four-language +release acceptance. Dynamic provider text and independent +language/accessibility review remain open. + +The next 2026-09-29 discovery slice preserves the questionnaire draft across +bounded, read-only retries; distinguishes unavailable CI/Projects data from +no accessible results; shows the source, run, conclusion, and sampling limits +of suggested checks; and validates unique producer names before selecting a +coverage-enforcing check. Project choices are made from accessible numbered +Projects when available, with an owner-checked URL/number fallback. Selected +Projects retain their identity if they disappear from a later bounded listing, +but are visibly unverified. Common `Status` options are checked when readable; +otherwise an explicit, run-scoped human attestation is required before Apply. +Personal-owner Projects do not offer a retry that the fine-grained PAT API +cannot fulfill. The web and CLI share the same application-owned discovery +contract; neither creates a test issue or dispatches a test Action. + +This slice passed 508 Jest suites / 5,591 tests on 2026-09-29. Repository-wide +coverage was 95.8% statements, 90.64% branches, 96.41% functions, and 97.15% +lines; all configured coverage budgets passed. Typecheck, lint, Svelte +diagnostics, isolated web and CLI production builds, workflow/documentation +validation, and specification validation also passed. These automated checks +do not close the 274-case-by-area acceptance mapping, independent linguistic +and accessibility review, real-browser/cross-platform trials, or the +application-level session coordinator. The catalog remains `proposed`. + +The subsequent first-run slice implements Basic/Custom presentation with +explicit permission-affecting decisions, per-question progress and contextual +documentation, source-labelled branch suggestions, revision-bound Back and +section editing, permission re-audit after edits, evidence-labelled check and +Project discovery, a complete grouped plan, and an itemized redacted result. +Web and English CLI use the same questionnaire and policies. The web result can +run a bounded metadata-only doctor after confirmed success; its response +contains counts only. `copilot doctor --read-only` is the matching terminal +path and never dispatches credential-health Actions; plain doctor may dispatch +the already-installed health workflow. The session/error and result copy has +matching English, Spanish, French, and Portuguese catalogs. The latest full +local run passed 509 Jest suites / 5,646 tests with 95.66% statements, 90.41% +branches, 96.41% functions, and 97.08% lines overall; instrumented web +TypeScript reached 100% statements, functions, and lines. Svelte components +are not included in that TypeScript coverage claim. The result/doctor tests +use fake ports and local loopback fixtures; no PAT, repository setup, test +issue, or GitHub Action was created. + +These measurements do **not** close independent linguistic, keyboard/screen- +reader, 200%-zoom, dark/light, real-browser/cross-platform, or full 350-case +acceptance review. A disk-persisted resume remains a separately specified +future capability; this slice supports reconnecting to a live session only. +The CLI orchestration has not yet been extracted into the prescribed +frontend-neutral coordinator. Until these gates are evidenced, the catalog +remains `proposed` and the non-English browser notice remains a translation +preview, not an unconditional release-quality claim. + +The PR #402 follow-up hardens three observed edge cases: numeric App ID input +in the manual CI producer selector, complete GitHub check-conclusion labels in +all four locales, and terminal-control sanitization for discovered Project +choices. Focused regressions cover the normalized tuple, rendered input type, +every new locale label, and raw terminal output. These corrections do not +change the still-open release acceptance gates above. + +A second review follow-up adds the GitHub default branch to the pre-Apply +remote-facts comparison and keeps discovered Project choices usable in both +optional-driver and raw-mode-unavailable text fallbacks. Fake-port and +terminal-driver regressions exercise branch drift, visible Project numbers, +retry, sanitized output, and unchanged selection semantics. + +A final pairing follow-up shares one client-side validity predicate between +button state and form submission, so Enter cannot consume invalid attempts or +bypass a busy state. Structural tests cover accepted hex input, incomplete and +non-hex input, and the busy state. Server-side rate limiting remains authoritative. + +1. Review this threat model and UI prototype with product/security/accessibility; + freeze semantic transport schemas, redacted views, and error taxonomy. +2. Extract the existing CLI orchestration into a frontend-neutral setup + session use case without changing terminal behavior; add parity and + dependency tests first. +3. Build a read-only Svelte/Vite six-stage prototype and packaged asset + pipeline; prove isolated npm install, loopback restrictions, and fallback. +4. Add revision-bound local HTTP commands, session lifetime, tab control, + CSRF/CSP/content limits, and adversarial tests before accepting a PAT. +5. Add role-separated masked PAT collection, identity/permission audits, + final plan and single-flight Apply through existing adapters. +6. Add recovery, partial-result, documentation, accessibility, security, + package, and cross-platform evidence; only then expose `--web` as released. + +## 19. Definition of Done + +- [ ] Product/security/accessibility review accepts the complete local threat + model and every normative requirement has scenario/traceability evidence. +- [ ] Terminal behavior remains compatible; web and terminal use one + application decision engine with enforceable dependency rules. +- [ ] Local HTTP, controller, PAT, plan revision, and Apply defenses pass the + adversarial/security budget with no secret in browser storage or logs. +- [ ] All 350 distinct setup-assistant cases by area pass without real PATs or + dogfooding; the repository and changed-module coverage thresholds + already pass for the current implementation slice. +- [ ] Global npm-pack install serves complete local assets; Action/API bundles + and workflow assets remain unchanged except intentional shared policies. +- [ ] Pending, action, blocked, partial, complete, cancelled, and expired + views pass responsive/accessibility/localization/human review. +- [ ] The browser shell, session transport, prompt presenters, shared visual + patterns, and palette/style layers remain separate, with enforced + dependency and module-size checks and contributor guidance. +- [ ] User/setup/operator/contributor docs, flag help, migration/rollback, + recovery, and PAT cleanup/renewal guidance are linked and validated. +- [ ] Build, lint, typecheck, coverage, architecture, workflow, package, + documentation, catalog generation, and `validate:specifications` pass. +- [ ] No readiness-blocking decision remains unresolved; no GitHub issue, + Action run, or test PAT is created while validating this implementation. + +## 20. References and decisions + +- Related specifications: [setup baseline](./setup-configuration-credentials-and-doctor.md), + [operator PAT](./temporary-setup-operator-authorization.md), + [bot PAT](./guided-bot-pat-onboarding.md), + [PAT permission evidence](./setup-pat-permission-guidance-and-verification.md), + [CLI contract](./cli-and-single-action-execution.md). +- Provider/framework sources: [GitHub PAT creation and deletion](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), + [GitHub account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts), + [Svelte overview](https://svelte.dev/docs/svelte/overview), + [Vite static build](https://vite.dev/guide/build), + [Node HTTP](https://nodejs.org/api/http.html). +- Language-selection decision: this release deliberately supports English, + Spanish, French, and Portuguese, which maintainers can translate and review + directly. It makes no claim about a live ranking of speaker populations. +- Security sources: [OWASP CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html), + [CSP](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html), + [HTML5 storage](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html). +- Decision: use Svelte + Vite static assets and a CLI-owned Node loopback + server; no SvelteKit, SSR, hosted service, third-party assets, account + manager, or private GitHub website automation in this version. +- Rejected: wrapping the interactive terminal through HTTP, a second web + grant/plan implementation, a browser-only GitHub API client, accepting + `--yes` as web approval, and promising automatic PAT revocation. +- Follow-up outside scope: future GitHub App authentication and optional + durable resume require separate credential/data-safety specifications. diff --git a/specs/setup-configuration-credentials-and-doctor.md b/specs/setup-configuration-credentials-and-doctor.md index daf9b1db6..f3cd1c1ac 100644 --- a/specs/setup-configuration-credentials-and-doctor.md +++ b/specs/setup-configuration-credentials-and-doctor.md @@ -2,11 +2,11 @@ - Status: Implemented — automated architecture, UX, documentation, and coverage gates complete; controlled live GitHub permission-path evidence remains external - Date: 2026-09-11 -- Last updated: 2026-09-24 +- Last updated: 2026-09-28 - Catalog capability ID: `setup-and-doctor` -- Last verified: 2026-09-24 +- Last verified: 2026-09-28 (automated journey/presentation gates; live GitHub path remains external) - Owners: Copilot maintainers -- Scope: interactive/non-interactive installation planning, file and resource provisioning, credential validation, and read-only diagnosis +- Scope: interactive/non-interactive installation planning, file and resource provisioning, credential validation, and metadata-only diagnosis - Related issues/PRs: merge-queue readiness SDD; architecture quality and scalability hardening SDD - Required review gates: product UX, architecture, testing, documentation, security/operations @@ -17,7 +17,10 @@ `copilot setup` builds and previews a validated installation plan before writing workflows, templates, Variables, Secrets, labels, issue types, projects, or the initial tag. `copilot doctor` inspects the expected contract without changing -repository configuration. The setup PAT is separate from the workflow PAT and +repository configuration, but its normal credential check may dispatch an +installed GitHub Action. `copilot doctor --read-only` skips that dispatch, +reports Secret values as unverified, and performs only read operations. The +setup PAT is separate from the workflow PAT and provider credentials; secret values never enter config files or plan objects. ```text @@ -70,6 +73,12 @@ but unusable, overwrite hand-maintained files, or expose credentials. [`architecture-quality-and-scalability-hardening.md`](./architecture-quality-and-scalability-hardening.md). Role-specific PAT guidance and safe permission evidence are specified in [`setup-pat-permission-guidance-and-verification.md`](./setup-pat-permission-guidance-and-verification.md). + Assisted operator PAT creation is proposed in + [`temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md), + not an implemented setup path. + Guided creation of the separate persistent bot PAT is proposed in + [`guided-bot-pat-onboarding.md`](./guided-bot-pat-onboarding.md), + without a local account manager. Transactional rollback across local and GitHub writes requires a separate design. ## 3. Actors, surfaces, and terminology @@ -121,7 +130,7 @@ organization value that GitHub Actions will expose. | Credentials | one ambiguous token | setup/workflow/provider separation | least privilege | | Remote state | overwrite assumptions | inspect + preserve/replace decision | controlled drift | | Readiness | discovered during release | setup and doctor checks | earlier action | -| Diagnosis | mutation required | read-only doctor | safe audit | +| Diagnosis | mutation required | explicit metadata-only `doctor --read-only`; ordinary doctor may dispatch the installed health Action | safe inspection or separately authorized active check | The architecture hardening preserves the product contract while making cancellation, skipped diagnosis, ordering, and read-only authority explicit. @@ -154,6 +163,15 @@ cancellation, skipped diagnosis, ordering, and read-only authority explicit. complete permission audit before provisioning; credential health and storage preservation do not authorize an unaudited keep path. - Invalid required credentials must be replaced. +- Terminal credential-health validation MAY temporarily create and remove the + selected-branch health workflow before final Apply. As soon as the create + request is attempted, the journey MUST disclose a possible remote mutation; + a subsequent failure or cancellation MUST report a partial outcome and + direct the operator to inspect the selected branch, even if cleanup appeared + successful, because the create/delete commits remain in history. A failed + create request is conservatively classified as possible mutation when its + remote outcome is uncertain. The browser path does not bootstrap a workflow + before Apply and retains its no-mutation pre-approval guarantee. - A missing remote resource snapshot is never an empty inventory. Selected Secret/Variable management MUST stop before all remote resource, label, issue-type, and tag calls when inspection fails, its port is absent, or a @@ -183,6 +201,12 @@ by policy when irrelevant; there is no legacy state alias or back-navigation mode. After questionnaire completion, credential validation and provisioning remain separate application flows. +The pre-PAT intent review may start a **new questionnaire pass** over the +current in-memory draft. Each pass remains forward-only; this explicit review +loop is not an implicit reset or back-navigation inside a questionnaire. The +journey presentation reopens `Setup choices` only before PAT entry and returns +to `Setup PAT` when that pass finishes. + Cancellation before confirmation writes nothing. Partial remote provisioning retains successful facts and reports remaining work; retries MUST preserve valid existing resources and avoid duplicate shadowing. @@ -235,6 +259,79 @@ asset parity. ## 9. UI/UX and content contract +### Interactive journey presentation (2026-09-28 amendment) + +Interactive `copilot setup` MUST show a bounded, text-first six-stage journey: +`Repository → Setup choices → Setup PAT → Plan → Bot PAT & credentials → Apply`. +The active stage is named in words, previously completed stages are marked +complete, and later stages remain pending. A stage number describes position, +not a percentage or a count of questions. Show the journey at meaningful +transitions, not after every answer. Never mark `Apply` complete until the +action reports success; failure after application begins is **Partial**, not +`No changes`. Before application begins, say `No changes have been applied`. +Cancellation or a blocked audit does not advance the journey. Dry-run ends +after plan review with an explicit `No changes` result; unattended input keeps +its existing non-interactive output rather than receiving interactive prompts. + +```text +Copilot setup · owner/repo +Stage 2/6 · Setup choices +Complete: Repository +Now: Setup choices +Next: Setup PAT → Plan → Bot PAT & credentials → Apply +No changes have been applied. +``` + +Text equivalent: the named current phase follows repository detection; all +other phases are explicitly complete or pending, and no remote mutation has +started. In a narrow terminal, each status remains on its own wrapped line. +Icons and color may reinforce the state but MUST NOT be its only carrier. +The journey is a view of existing setup state, not a new questionnaire or +source of permission truth. The application boundary owns stage ordering and +transition validity; the terminal adapter owns width, wrapping, and ANSI. +Do not persist phase state or print credentials. Detailed permission tables +remain available on explicit request and for manual/unattended paths; the +interactive guided review defaults to an exact compact grant summary. + +If the operator chooses to review intent again, the journey MUST visibly +reopen `Setup choices`, label the review pass, and mark `Setup PAT` pending +until the repeated questions finish. This is the only backwards journey +transition and is allowed only before PAT entry and before mutation. The +terminal MUST explain that existing answers remain as defaults, Enter keeps +them, the flow returns to PAT review afterward, and no setup changes have +been applied. It MUST NOT reuse the first-pass introduction. On completion, +show an explicit return to `Setup PAT` and recalculate the permission preview. +The later full wizard still does not re-ask the pre-PAT answers. No fixed +question counter or percentage is displayed because the set is conditional. + +```text +Copilot setup · owner/repo +Stage 2/6 · Setup choices · review pass 2 +Complete: Repository +Now: reviewing saved setup choices +Next: Setup PAT → Plan → Bot PAT & credentials → Apply +No changes have been applied. +``` + +Text equivalent: the operator deliberately returned to a second pass over +saved choices, will reach PAT review afterward, and has not begun mutation. +This amendment adds at least **eight distinct cases** beyond the original +journey budget: three transition/guard cases, two introduction and narrow +no-color presentation cases, and three CLI return/cancellation/permission +preview integration cases. The existing coverage thresholds remain. + +The presentation introduces no new flags or persisted configuration. It has +no effect on GitHub Actions, issues, PRs, comments, or checks. Rollback removes +the stage renderer and restores the existing table-first presentation without +changing saved setup state. The amendment adds a minimum **12 distinct tests**: +four pure transition/view-model cases, three terminal width/color cases, +three guided/manual detail cases, and two end-to-end dry-run/partial-state +cases. Changed presentation code targets 95% line and 90% branch coverage. +Acceptance requires stage ordering, accurate no-change/partial claims, no +duplicate intent questions, exact summary-to-table grants, and secret-free +output in both wide and narrow no-color terminals. User and contributor docs +MUST describe the phases and where the full permission table can be opened. + ```markdown Pending: **Inspecting existing Copilot resources.** No changes have been made. Action required: **The workflow `PAT` Secret is missing.** Add or enter it to enable release workflows. @@ -265,7 +362,7 @@ arbitrary warning text into the pure plan builder. | unverifiable credential | feature may be unsafe | name/scope only | yes | run health/manual check | none | | denied changed file | file unchanged | backup status | yes | approve/adapt | remove unused backup manually | | partial GitHub writes | subset installed | resource names/status | yes | rerun preserve-existing | no destructive rollback | -| doctor fail | no mutation | diagnostic report | yes | run setup/fix access | none | +| read-only doctor fail | no mutation or Action dispatch | diagnostic report | yes | fix access and rerun | none | ## 11. Security, permissions, and privacy @@ -332,9 +429,14 @@ widths, canceled prompts, secret masking, and GitHub permission variants. 2. Given non-interactive missing required input, setup fails without prompting or writes. 3. Given valid organization Secret and preserve-existing, no repository shadow is created. 4. Given invalid required existing credential, setup requires replacement. -5. Given canceled confirmation, local and GitHub state are unchanged. +5. Given canceled confirmation without a prior credential-health bootstrap + attempt, local and GitHub state are unchanged. With such an attempt, setup + reports a partial result and requires branch/history inspection. 6. Given a changed managed file, setup backs up before approved replacement. -7. Given doctor, no mutation port is called and unhealthy state returns non-zero. +7. Given `doctor --read-only`, no mutation or credential-health dispatch port + is called; installed Secret names are inspected but values are unverified, + and unhealthy state returns non-zero. Given ordinary doctor, any installed + credential-health dispatch is an explicit, separately chosen active check. 8. Given merge-queue without proven support, setup/doctor reports fail closed. 9. Given output inspection, no secret value appears. 10. Given no explicit locale, doctor renders one complete English report. @@ -358,6 +460,11 @@ widths, canceled prompts, secret masking, and GitHub permission variants. Secret/Variable/label/issue-type/tag mutation; absence cannot be interpreted as an empty repository inventory. Pre-plan failures still reach the final audit as bounded unavailable access facts. +18. Given terminal credential-health bootstrap was attempted and cleanup + fails, setup reports `partial` with branch-inspection guidance rather + than `blocked` or "no changes applied"; the same conservative outcome + applies when creation times out ambiguously. Without a bootstrap attempt, + pre-Apply cancellation remains `cancelled`. 18. Given an organization Secret or Variable target, repository inventory is available and confirms that no same-name repository resource exists; otherwise setup blocks before credential collection or mutation, even with @@ -387,7 +494,8 @@ widths, canceled prompts, secret masking, and GitHub permission variants. - [x] Every new option has default, bounds, precedence, persistence, retirement/rejection, and security rules. - [x] The 112-case budget and coverage thresholds pass. -- [x] Setup cancel/retry/partial state and doctor read-only behavior pass. +- [x] Setup cancel/retry/partial state and metadata-only `doctor --read-only` + behavior pass; ordinary doctor dispatch is disclosed separately. - [x] Secrets are absent from plans, config, logs, errors, and backups. - [x] Workflow/assets, documentation, and catalog checks pass. - [ ] Human terminal and permission-path UX evidence is captured. @@ -402,6 +510,10 @@ widths, canceled prompts, secret masking, and GitHub permission variants. - Permission companion: `setup-pat-permission-guidance-and-verification.md` owns the pre-prompt matrices, post-entry evidence states, and read-only probe boundary for setup and workflow PATs. +- Future interface companion: [local web setup assistant](./local-web-setup-assistant.md) + specifies an optional, loopback-only `--web` adapter over the same setup + policies and application gates. This baseline describes the shipped CLI; + the web mode is not implemented by this amendment. - Decision: one configuration policy serves setup, doctor, and workflow inputs. - Rejected: storing credentials in YAML/JSON or silently overwriting managed files. - Follow-up: cross-provider transactional rollback is outside this baseline. diff --git a/specs/setup-pat-permission-guidance-and-verification.md b/specs/setup-pat-permission-guidance-and-verification.md index 2a81afc26..8a78044bd 100644 --- a/specs/setup-pat-permission-guidance-and-verification.md +++ b/specs/setup-pat-permission-guidance-and-verification.md @@ -109,7 +109,12 @@ transient response. ### 4.2 Non-goals -1. Setup does not enumerate, create, edit, rotate, or revoke GitHub PATs. +1. The implemented permission-guidance flow does not enumerate, create, edit, + rotate, or revoke GitHub PATs. The proposed guided operator PAT flow and + its explicit GitHub deletion responsibility are documented in + [`temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md). + The separate proposed guided workflow PAT is covered by + [`guided-bot-pat-onboarding.md`](./guided-bot-pat-onboarding.md). 2. Setup does not prove write access by creating temporary labels, branches, files, Variables, Secrets, comments, projects, or workflow runs. 3. Existing remote Secret values remain unavailable. Credential-health evidence @@ -473,6 +478,11 @@ derived from the existing immutable configuration, repository owner type, storage targets, and selected features. The permission catalog, status semantics, maximum probe concurrency, and prohibition on write probes are not configurable. +If authenticated repository inspection cannot establish whether the owner is +an organization or a user, the final token-backed setup audit MUST stop before +all provisioning, with a retry/inspection action. The preview may display +potential organization grants, but neither a guided owner assertion nor an +accepted PAT probe may convert unknown ownership into verified scope. Recommended interactive use remains `copilot setup`. Non-interactive setup prints permission results for supplied PATs but never prompts. `--dry-run` @@ -941,6 +951,12 @@ at widths 40/80/120 and `NO_COLOR`. still can. An unclosed quoted fence cannot hide a later shell block after the blockquote level ends, and an exceptional quoted shell fence remains inspectable if its container ends without a closing marker. +51. Given authenticated remote owner type is `Unknown`, the setup permission + preview retains potential organization grants for selected issue workflows, + Projects, and organization storage, but a token-backed final audit blocks + before permission probes or provisioning. A guided owner assertion cannot + bypass this; once GitHub verifies `User` or `Organization`, the requirements + are recomputed for that actual type. ## 17. Requirements traceability diff --git a/specs/temporary-setup-operator-authorization.md b/specs/temporary-setup-operator-authorization.md new file mode 100644 index 000000000..c896c581d --- /dev/null +++ b/specs/temporary-setup-operator-authorization.md @@ -0,0 +1,749 @@ +# Assisted Setup PAT Creation + +- Status: Draft — permission-intent preflight implemented locally; controlled GitHub UX and full test budget remain unverified +- Date: 2026-09-25 +- Catalog capability ID: `temporary-setup-operator-authorization` +- Last verified: Not applicable; prospective change +- Owners: Copilot maintainers and setup operators +- Scope: collect setup permission intent before the operator PAT link, then guide creation, verification, use, and user-owned deletion for one `copilot setup` run +- Related issues/PRs: [PR #402](https://github.com/vypdev/copilot/pull/402); no Action dogfooding for this design +- Required review gates: product UX, architecture, testing, documentation, security, GitHub form compatibility +- Open decisions blocking readiness: controlled browser UX and full test-budget evidence; remote-only facts cannot be known before authenticated inspection, so the link discloses residual uncertainty + +## 1. Executive summary + +Interactive `copilot setup` will offer **Create with GitHub guidance** (the +recommended choice) or **I already have a PAT** before requesting the operator +credential. Guided mode first asks only the setup choices that determine PAT +permissions, reusing answers from the existing questionnaire and local +configuration. It shows a reviewable permission preview, then prints an +official GitHub fine-grained PAT URL with every *locally determined* required +grant preselected; it does not add all conditional grants for convenience. +The user chooses the browser account, selects the individual repository, +reviews the form, generates the PAT, and pastes it into the existing masked +prompt. Copilot verifies access, completes the plan and final audit, runs +setup, discards its local value, and tells the user to delete the PAT in +GitHub. A one-day expiry is a safety backstop, **not** proof of deletion or +revocation. + +Authenticated repository/organization inventory and credential-health +workflow status are unavailable before the first PAT. The preview MUST name +those unresolved grants, and a later verified need MUST block dependent +mutation and produce a corrected link. This is a bounded exception to the +one-link goal, not permission to request every possible grant up front. + +The companion [bot PAT SDD](./guided-bot-pat-onboarding.md) covers the second, +persistent token installed as Actions Secret `PAT` after the setup plan is +known. Both roles share one URL-building contract, but not a credential or +lifecycle. + +```text +resolve repository -> choose guided/manual -> collect permission-affecting intent + -> review exact known grants and remote unknowns -> prefilled GitHub form + -> masked PAT input -> verify -> complete plan and final grant audit + -> correct link if remote facts add grants -> guide/verify bot PAT + -> install Secret -> apply setup -> cleanup reminder +``` + +Text equivalent: the terminal guides two separate PATs during one setup run; +GitHub owns authentication and issuance; Copilot verifies and uses each token +only for its role; the operator deletes the temporary PAT in GitHub. + +## 2. Problem, current behavior, evidence, and feasibility + +### 2.1 Problem + +The first-time operator sees a large permission table but the current guided +URL contains only `metadata=read` and `contents=read`: it is built before the +questionnaire and filters out every conditional row. The operator must still +enter the other needed permissions manually or replace the PAT after the +final audit. The same browser may contain a personal and a bot account. +Merely disposing of the PAT in local memory does not remove it from GitHub. + +### 2.2 Observed repository behavior + +1. `src/cli/commands/setup.ts` resolves the repository, prints the bootstrap + setup-PAT table, and requests the setup PAT **before** the questionnaire. +2. `buildSetupPatPermissionRequirements()` has conditional rows because the + final features and remote state are not yet known. The approved plan is + re-audited with `buildConfiguredSetupPatPermissionRequirements()`. +3. `SetupCredentialPromptAdapter` uses a masked terminal input. The setup PAT + is not installed as runtime Secret `PAT`. +4. `SetupCredentialsUseCase` gathers the distinct workflow PAT later, after + the plan, and GitHub cannot reveal existing Secret values. +5. `buildSetupPatCreationUrl()` serializes only `required` rows. The initial + setup call supplies the bootstrap table, where only Metadata and Contents + are required; the other ten rows are conditional. `loadSetupOverrides()` + and the interactive questionnaire currently run after setup-PAT entry. + +### 2.3 External primary evidence + +| Question | Official source | Decision | +|---|---|---| +| Can the form be prepared? | [GitHub PAT URL parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#pre-filling-fine-grained-personal-access-token-details-using-url-parameters) | Use documented `name`, `description`, `target_name`, `expires_in`, and permission levels. Validate names and levels. | +| Can the URL select one repository? | [GitHub PAT creation steps](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) | No documented individual-repository URL parameter; the user must select it in GitHub. | +| Who handles the account and 2FA? | [GitHub browser account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts) | GitHub owns the browser session and account choice; local Git/`gh` identity is not evidence. | +| Can this link create or delete the PAT? | [GitHub PAT management](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) | No. The user generates and deletes it in GitHub Settings. No documented owner PAT mint/revoke API was found. | + +Controlled browser prefill check on 2026-09-25: a test URL with all twelve +documented setup-table grants displayed eight repository and four organization +permissions at the requested levels for `vypdev`. GitHub initially selected +**All repositories**; changing to **Only select repositories** and selecting +`vypdev/copilot` retained all twelve grants. No PAT was generated. This proves +form prefill and repository-selector behavior for that account/session, not +token issuance, permission sufficiency, or universal organization policy. + +### 2.4 Viability decision + +**Guided creation and preselected permissions are feasible; exact one-pass +least privilege cannot be guaranteed from unauthenticated local intent alone.** +Do not replay private website requests, read cookies, capture 2FA, or call the +link an authorization grant. A future GitHub App design would use a different +credential and requires its own endpoint and revocation proof; it is outside +this SDD and is not displayed as an available terminal choice. + +### 2.5 Retrospective classification + +Not applicable: this is a proposed extension to the observed setup flow. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Setup operator | configure one repository | `copilot setup` | permission table, choice, URL, masked prompt, result | +| GitHub | authenticate and issue PAT | official form | account switcher, 2FA, repository selector, Generate | +| Bot owner | issue the separate runtime PAT | later setup step | [bot PAT journey](./guided-bot-pat-onboarding.md) | + +**Operator PAT** means the human's one-run setup credential. **Guided** means +the CLI prepares a form URL; the user still creates the PAT. **Discarded** means +the CLI no longer retains the value. **Deleted/revoked** means GitHub has +invalidated it; this flow cannot infer that from local disposal. +**Permission-intent preflight** means the short, pre-PAT portion of the setup +questionnaire that determines locally knowable grants. **Provisional link** +means authenticated remote facts may require a corrected grant after entry. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +1. Interactive setup MUST offer guided creation or existing manual PAT input + without introducing a second setup command. +2. Guided mode MUST collect, review, and reuse all locally knowable + permission-affecting choices **before** generating the setup PAT URL. The + URL MUST include the resulting required grants from the same policy as + the terminal table and final audit. No selected choice may be silently + reverted or asked a second time in the main questionnaire. +3. Unknown remote-dependent grants MUST be listed beside the preview and + omitted by default, not silently overgranted. A changed plan or verified + remote need MUST invalidate the old link and block dependent mutation until + the supplied PAT passes the recalculated audit. +4. The actual operator PAT MUST pass existing identity, repository-access, and + final-plan permission checks before dependent mutation. Guided setup MUST + show its authenticated account and ask the operator to confirm that this is + the account intended to configure the repository. +5. The final terminal result MUST distinguish local disposal from GitHub + deletion and provide a concrete deletion action. + +### 4.2 Non-goals + +1. Automatic browser login, 2FA, PAT generation, or PAT deletion. +2. A local multi-account manager or storing browser credentials. +3. Replacing the bot PAT or changing Action runtime authentication; the + companion SDD covers assisted creation of that separate PAT. +4. Automatically opening the browser, managing the clipboard, shortening URLs, + or adding account-profile persistence in the first release. +5. Dogfooding this repository's issue/Action workflow for this design. + +### 4.3 Fixed invariants + +1. The URL host/path are fixed to + `https://github.com/settings/personal-access-tokens/new`; it contains no + PAT, cookie, 2FA code, callback secret, or arbitrary URL input. +2. `target_name` selects only resource owner. The CLI MUST tell the user to + select the individual repository and check the active browser account. +3. The CLI MUST NOT say a PAT was created, deleted, or revoked by Copilot. +4. The operator PAT MUST NOT become Actions Secret `PAT`; the bot PAT MUST NOT + become operator setup authority. +5. `--yes`, non-interactive mode, and dry-run MUST NOT trigger browser actions, + generate a PAT, or silently accept new permissions. +6. Preflight answers are operator intent, not GitHub facts or authorization. + Unknown owner type, remote inventory, approval, and workflow status MUST + never be fabricated from defaults or treated as proven by a user answer. + An unknown or unavailable authenticated owner type MUST block a token-backed + final setup-PAT audit before provisioning. The operator's preflight owner + assertion may shape a provisional link but cannot replace verified GitHub + owner evidence. The final requirement preview MUST retain potential + organization grants rather than silently treating `Unknown` as `User`. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User effect | +|---|---|---|---| +| Before setup PAT | bootstrap permission table and two-grant provisional link | guided/manual choice, short permission-intent preflight, reviewed grants and unresolved remote needs | needed local grants are preselected | +| GitHub | user navigates form and transcribes grants | documented prefilled URL; user selects repo and generates | less repetitive form work | +| After paste | identity/access and grant audit | same audit; display actual account | wrong token found before setup | +| After plan | final grant audit | same audit; show exact delta and corrected URL only if a choice changed or remote evidence adds a grant | no silent overgrant or mutation | +| Completion | local PAT not stored | explicit GitHub deletion reminder and link | honest cleanup | + +```mermaid +sequenceDiagram + participant U as Operator + participant C as Copilot CLI + participant G as GitHub + C->>U: Offer guided/manual choice + C->>U: Ask permission-affecting setup choices and show grant preview + C->>U: Print official PAT URL and repository instruction + U->>G: Choose account, complete 2FA if required, select repo, Generate + U->>C: Paste PAT into masked prompt + C->>G: Verify account, repository, and grants + C->>U: Show actual account and request confirmation + C->>U: Reuse preflight choices, confirm plan, explain any grant delta + C->>G: Apply approved setup + C->>U: Report local disposal and user-owned GitHub deletion +``` + +Text equivalent: the CLI first collects and reviews permission-driving local +choices, the user creates the prepared PAT on GitHub, the CLI verifies remote +facts and any grant change before using it for setup, then explicitly asks the +user to delete it on GitHub. + +## 6. Functional behavior and state model + +### 6.1 Normal path + +1. Resolve repository and the existing local setup inputs (`--config`, CLI + flags, and defaults) without network mutation. If `--token` or + `PERSONAL_ACCESS_TOKEN` is supplied, retain existing precedence and skip + guided preflight. Otherwise offer `Create with GitHub guidance` + (recommended) or `I already have a PAT`. +2. Guided mode asks only the permission-driving setup choices not already + fixed by local inputs, using the same questionnaire definitions and + validation, in their normal order. It creates a one-run intent draft that + the later full questionnaire MUST consume without repeating those answers. + The question set is the dependency closure of the existing permission + policy: agent/model or other choices join preflight only when they change + whether a managed resource or scope exists, not merely its value or name. + The operator can explicitly revise the draft before PAT creation; after + creation, a revision requires a new permission comparison before mutation. +3. Render the intended choices, an exact required-grant preview, and a + separate **May need after GitHub inspection** list. Require explicit review + of this preview; `--yes` does not waive it. Compute grants using the same + permission policy as the final audit, projected over locally known facts. + Include Metadata read and Contents read even when no optional capability + is selected. Never turn every conditional row into a required row. +4. Build the official URL from that reviewed required-grant set. Each + permission MUST use its documented query name and level; a missing mapping + blocks guided link generation and leaves manual setup available. If a + remote-only grant is unresolved, label the link **provisional** and name + the exact potential grant and trigger beside it. Print the complete URL + outside `renderBox` with account/repository instructions; do not open the + browser automatically. Accept the PAT only through the masked prompt. +5. Inspect the supplied PAT, show the actual GitHub account and grant report, + and confirm the intended account. Authenticated remote inspection then + verifies owner type, managed-resource inventory, and health-workflow state. + Complete the remaining setup questions without re-asking preflight choices. + Recompute the final grants from the actual configuration and remote facts + before any dependent mutation. A missing grant or changed intent invalidates + the previous link; show the permission delta and a corrected URL, require + an audited replacement/corrected PAT, and remind the user to delete any + obsolete PAT. A plan that merely removes grants MUST NOT claim the existing + token was least-privileged; explain that the user may replace it before + continuing. +6. Continue to the separate bot PAT journey only after final setup-PAT access + is accepted. On success, failure, or cancellation after PAT creation, + remind the user to delete the setup PAT in GitHub. Never claim deletion + was verified. + +The preflight question-to-grant contract is based on the current final setup +permission policy. It MUST be derived from configuration fields, not a second +hard-coded permission table in the terminal adapter: + +| Pre-PAT intent question or local input | Grant projected into the URL when selected | Still unknown until GitHub inspection | +|---|---|---| +| Repository owner kind (`organization` or `personal`), asked only if an organization grant is a candidate | Enables valid organization grants for an asserted organization owner; never by itself adds a grant | Actual owner kind and organization PAT policy | +| Create initial tag? | Repository Contents write instead of read | Whether tag creation is ultimately needed | +| Manage Actions Secrets and their requested default scope, preservation, and known explicit overrides? | Repository Secrets write for selected managed names/inventory; organization Secrets write when an organization target or inventory is definitely selected | Existing effective scopes, inherited names, and conditional organization inventory | +| Manage Actions Variables and their requested default scope, preservation, and known explicit overrides? | Repository Variables write for selected managed names/inventory; organization Variables write when an organization target or inventory is definitely selected | Existing effective scopes, inherited names, and conditional organization inventory | +| Enable issue workflow types? | Repository Issues write; organization Issue Types write if owner is an organization | Verified owner kind | +| Enable release/hotfix or guarded PR approval? | Repository Administration read | Final branch-rule readiness | +| Configure organization Project IDs? | Organization Projects write when owner is an organization and IDs are selected | Project access and ownership | +| Remote condition shown, not asked: existing managed Secrets need credential-health validation; workflow is confirmed missing on the selected branch | No grant from an unverified assertion; explain potential Actions write and, only for confirmed missing workflow, Contents write plus Workflows write | Authenticated inventory and independent selected-ref workflow proof | + +The last row is **not** a second questionnaire about facts the operator may +not know. It is a preview of remote-only conditions; the CLI MUST NOT ask the +operator to attest to workflow presence or Secret inventory as if that proved +it. Individual resource overrides that depend on inherited remote names stay +in the post-auth questionnaire and may require a corrected link. If the +operator cannot identify whether the owner is an organization when an +organization grant is a candidate, the CLI explains how to check it and +offers the manual path rather than guessing a URL. An explicit organization +storage/project choice with a declared personal owner is rejected before URL +generation. The target owner and selected repository are verified after the +PAT is entered. + +### 6.2 Alternatives + +- Manual uses the existing masked prompt and permission audit without a link. +- A guided user may cancel or revise the preflight before GitHub. No remote + resource changes occur and no PAT exists unless the user generated one. +- Existing supplied-token and unattended paths remain unchanged; no new prompt + or browser action occurs. A cleanup reminder MAY be shown, but the CLI + cannot know who created or owns a supplied token. +- Dry-run shows a plan and can explain the future PAT requirement, but never + creates a credential or opens GitHub. +- If the user cancels before pasting, no local PAT value exists; a PAT they may + already have generated in GitHub remains their deletion responsibility. +- If GitHub organization approval is required, setup waits for verified target + access; call it `pending approval` only with explicit provider evidence. +- If the preflight predicts a need that the final plan does not have, the CLI + displays the excess grant and offers replacement guidance; it never silently + describes the earlier URL as the exact final least-privilege plan. + +### 6.3 State machine + +| State | Entered when | Visible meaning | Next | Owner | +|---|---|---|---|---| +| `choice` | no supplied PAT | guided/manual decision | `intent-collecting`, `manual-input`, `cancelled` | operator | +| `intent-collecting` | guided selected | only grant-driving setup choices are being asked | `intent-review`, `cancelled` | operator | +| `intent-review` | local choices projected | review exact grants and remote unknowns | `form-ready`, `intent-collecting`, `cancelled` | operator | +| `form-ready` | reviewed URL validated | GitHub action required | `pat-entered`, `cancelled` | operator | +| `pat-entered` | masked value received | verification in progress | `verified`, `blocked` | CLI | +| `verified` | current grants accepted and account confirmed | reuse intent, finish plan and final audit | `setup-running`, `grant-correction`, `blocked` | CLI/operator | +| `grant-correction` | final evidence/choice changed grants | no dependent mutation; correct or replace PAT | `pat-entered`, `cancelled` | operator | +| `setup-running` | plan approved | apply setup | `complete`, `partial` | CLI | +| `complete` | setup finished | delete operator PAT in GitHub | terminal | operator | +| `partial` | some changes applied | inspect report, then delete PAT | retry/terminal | operator | + +Re-entering the same PAT does not create another one. Re-running preflight with +the same inputs yields the same grant set and URL; stale or out-of-order +answers cannot modify a reviewed draft. A changed plan invalidates the old +link. A crash cannot guarantee GitHub deletion; restart and recovery +instructions must not imply otherwise. + +Choosing `Review all setup choices again` from `intent-review` starts an +explicit second (or later) pass over the existing draft, not a fresh setup +run. The user is told this before the first repeated question. Previously +answered values remain defaults; local flags/config still fix their original +fields. This loop never creates a PAT, inspects GitHub, or applies setup. Its +journey stage reopens `Setup choices`, then returns to `Setup PAT` with a newly +computed grant preview. Cancellation ends the run with no setup mutation. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values | Scope/persistence | +|---|---|---|---|---| +| PAT help choice | interactive enum | guided | `guided`, `manual` | one run; not saved | +| Permission-intent answers | existing bounded setup fields | existing CLI/config/default values; ask only unset or revisable fields | existing feature, workflow, tag, storage, and Project validators | one run; frozen for later questionnaire, not saved | +| Owner-kind assertion | interactive enum when an organization grant is possible | no assumed value; ask operator | `organization`, `personal` (or choose manual if unknown) | one run; verified after PAT, not saved | +| Generated expiry | integer days | `1` | documented 1–366; first release fixes link at 1 | URL only; GitHub policy may override | +| Repository | existing Git remote identity | current repo | verified owner/repo | one run | +| Supplied operator token | existing secret input | none | current CLI/env precedence | memory only | + +Precedence remains existing CLI flags over `--config` over setup defaults; +interactive intent changes override only the corresponding defaulted draft +field for this run. `--skip-secrets` and `--skip-variables` override both +the draft and URL projection. A reviewed choice is snapshotted into the main +questionnaire rather than reread from a changed file. Invalid cross-field +combinations (personal owner with organization storage or organization +Projects, disabled issues with enabled issue workflow types, unsupported URL +permission/level) block link generation with a specific recovery action. +No new account or PAT configuration is persisted. Wrong owner, invalid grant, +URL length outside a reviewed terminal bound, and contradictory permission +grants block link generation. Existing `--token` and environment precedence +remain; `--yes` does not choose an identity or waive checks. Expiry, host, +secret-free URL, role separation, and omission of unverified remote-only grants +are not configurable in this release. The recommended example is interactive +guided setup; the meaningful alternative is manual PAT creation and masked +input. Existing configuration files need no migration; a supplied PAT follows +the current path without a hidden preflight. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and direction + +| Boundary | Owns | Must not own/import | +|---|---|---| +| Pure policy | project local intent to required/remote-unknown grants; permission-plan-to-URL mapping, role, validation | browser, HTTP, terminal, token values | +| Application | guided choice, intent snapshot/reuse, grant-delta comparison, final audit, cleanup message state | process/browser APIs, GitHub DTOs | +| Ports | secret input, identity/grant inspection, presentation | private website sessions | +| Adapters | GitHub query mapping and terminal rendering | permission decisions | +| Composition | connect existing setup stages | duplicate policy tables | + +```mermaid +flowchart LR + E[Setup entrypoint] --> A[Guided PAT flow] + A --> I[Permission-intent preflight] + I --> P[Existing permission policy] + P --> B[Pure GitHub URL builder] + A --> V[GitHub identity and grant audit] + A --> T[Terminal presenter] +``` + +Text equivalent: setup collects only permission-affecting local intent before +deriving a link from the existing policy, then verifies the pasted PAT and +remote facts, reuses the intent in the full wizard, and renders any grant delta. + +### 8.2 Contracts, state, and trust boundaries + +- The URL builder receives `{role, owner, name, description, expiresIn, + permissions}` and emits only documented query parameters. It rejects + duplicate/conflicting grants and unsupported scope/level pairs. The bot SDD + reuses this contract with different role and expiry. +- A preflight projection accepts normalized local setup overrides and bounded + questionnaire answers, returns `{draft, requiredGrants, unresolvedTriggers}`, + and has no provider token or GitHub DTO. The same draft is consumed by the + full wizard; no second hard-coded permission matrix or duplicate prompts. +- The final audit compares normalized grants by role, scope, permission, and + strongest level. A grant added or upgraded is a blocking delta until a new + audited PAT is supplied; a removed grant is disclosed as possible excess. +- GitHub web authentication, 2FA, account switching, repository selection, + PAT generation, and deletion stay entirely in GitHub. +- No durable local token or browser session state is introduced. The remote + setup mutations remain governed by the existing approved plan. +- Token identity and permission responses are untrusted provider evidence; + presentation escapes account names and never prints raw responses. + +### 8.3 Executable constraints + +Architecture tests forbid the pure projection and URL builder from importing +terminal, HTTP, filesystem, or browser modules. Contract tests parse every +emitted query key and level against GitHub's documented set. Setup contract +tests prove preflight fields are the same normalized fields consumed by the +wizard, with no duplicated question or privilege table. Security tests reject +token/cookie material in URLs and logs and prove no setup mutation precedes +final grant acceptance. + +## 9. Terminal UI and content contract + +The [setup journey presentation contract](./setup-configuration-credentials-and-doctor.md#9-uiux-and-content-contract) +applies across both PAT roles. Before interactive guided intent, show the +bootstrap grants compactly, not the complete conditional table. After choices, +show every currently required grant and a count of conditional/remote-unknown +grants. The review choices are `Continue to GitHub`, `Review all setup choices +again`, `View full permission table`, and `Enter a PAT manually`, in precisely +that numbered order. Selecting detail prints the same policy +requirements with reasons and returns to review **without rerunning questions**. +Manual entry shows the full bootstrap table immediately; supplied-token and +non-interactive paths keep the existing table and audit. The raw GitHub URL +remains on one copyable line outside a box. The active journey stage stays +`Setup PAT` until the entered credential passes the initial audit; a failed +audit or cancellation cannot make it look complete. + +```text +Stage 3/6 · Setup PAT +Required now: Metadata read · Contents write · Secrets write (repository) +May need after GitHub inspection: 2 conditional grants +No changes have been applied. +1) Continue 2) Revise choices 3) View full permission table 4) Enter a PAT manually +``` + +Text equivalent: the URL will contain the exact required grants in the +summary, while two remote-dependent grants are unresolved; the user can +inspect reasons before accepting. Labels, counts, and detailed rows derive +from the same requirement objects and may not be edited independently. +Add five operator-specific cases to the journey budget: detail returns to +review, manual shows full table, revision changes the summary, cancellation +preserves the no-change state, and narrow no-color output remains readable. + +When choice 2 is selected, the terminal MUST show a transition message before +repeating any question: + +```text +Reviewing your setup choices again (pass 2). +This is the same setup run. Your answers are saved as defaults; press Enter +to keep one or enter a new value. After this pass you return to the setup PAT +permission review. No setup changes have been applied. +Stage 2/6 · Setup choices · review pass 2 +``` + +Text equivalent: this is a deliberate second pass over saved answers, with +no repository mutation, followed by a return to the PAT grant review. After +the pass, print `Choice review complete. Returning to setup PAT permission +review.` before the recalculated preview. No new command, persisted setting, +browser action, or account state is introduced. The review counter is one-run +presentation state; it cannot be used as authorization or grant evidence. + +The current CLI is English; this example is illustrative and follows its +existing text-first styling. Preserve one primary action per state. + +```text +Setup PAT · 1 of 2 Repository: vypdev/copilot +Choose how to provide the setup PAT: + 1) Create with GitHub guidance (recommended) + 2) I already have a PAT +Select [1]: + +Before creating the PAT, choose what setup will configure. Existing --config +and CLI selections are shown as defaults and will be reused later. +Enable issue workflows? [Yes]: Yes +Create/update Actions Secrets? [Yes]: Yes +Secrets storage? [Repository]: Repository +Create/update Actions Variables? [Yes]: Yes +Variables storage? [Repository]: Repository +Enable guarded approval or release/hotfix? [No from --config]: No +Create an initial tag? [Yes]: No +Organization-owned repository? [No answer yet]: Yes +Configure organization Projects? [No]: No + +Review before opening GitHub: + Required now: Metadata read, Contents read, repository Secrets write, + repository Variables write, Issues write, organization Issue Types write. + May be needed after GitHub inspection: Actions write for existing managed + Secrets; Contents write + Workflows write only if the health workflow is + independently confirmed missing; organization Secret/Variable access + if preservation resolves to that scope. +Confirm these choices and permission preview? [No]: Yes + +Action required: Open GitHub as the account configuring vypdev/copilot. +GitHub initially selects All repositories: change to Only select repositories +and select vypdev/copilot. Review the prefilled grants, then Generate. +PAT creation URL: +https://github.com/settings/personal-access-tokens/new?name=...&target_name=vypdev&expires_in=1&... +Setup PAT (hidden): +``` + +This example is illustrative: only fields that actually affect the selected +grant set are asked, and their defaults reflect existing setup inputs. The +remote-only list makes the link **provisional**, not broken. The URL is printed +as an unwrapped plain line outside a bordered box; terminal auto-linking is +optional, never required. Do not copy it to clipboard or open a browser +automatically. + +| State | First visible text | Next action | +|---|---|---| +| Pending | `Collecting setup choices that determine the PAT permissions. No GitHub changes have started.` | answer/review intent | +| Action required | `GitHub prefilled six grants. Change All repositories to Only select repositories → vypdev/copilot before Generate.` | complete GitHub form | +| Blocked | `Setup has not changed the repository: authenticated inspection found an existing managed Secret, so Actions write is required. Create a replacement PAT with the corrected link and retry; delete the obsolete PAT in GitHub.` | correct PAT | +| Partial | `Some setup changes were applied. The operator PAT may still be active in GitHub. Inspect the setup report, then delete the PAT.` | inspect/delete | +| Complete | `Setup complete. The operator PAT was discarded locally, not deleted from GitHub. Delete it in GitHub Settings.` | delete PAT | + +If a later choice removes a grant, say `The PAT may have more access than this +plan needs; review or replace it before continuing` rather than claiming exact +least privilege. If preflight is cancelled, say no repository changes started +and remind the user that any PAT already generated in GitHub remains theirs +to delete. If GitHub rejects an owner/permission combination, return to intent +review or the manual path; never suggest a hidden URL parameter as a fix. + +Errors follow impact, cause, action, retained state. Status uses words, not +color/emoji alone. Narrow terminals keep choices and instructions readable; +the URL stays copyable. English message catalog is the initial source; later +locales follow existing fallback policy. Escape untrusted repository/account +names. No issue, PR, comment, label, or check is created by this UI. + +## 10. Failure, recovery, and cleanup + +| Condition | Impact | Retained fact | Retry/action | Cleanup | +|---|---|---|---|---| +| Wrong browser account | PAT belongs to an unintended user | no mutation before guided account confirmation | decline, switch in GitHub, recreate if needed | user deletes wrong PAT | +| Preflight cancelled or invalid | no link or remote mutation | local inputs only | revise choices or use manual path | delete any already generated PAT in GitHub | +| Owner assertion differs from verified owner | organization grants may be invalid or omitted | authenticated owner type; no dependent mutation | revise intent and create corrected PAT | delete obsolete PAT | +| Wrong repo/owner | PAT lacks target access | no dependent mutation | select correct repo in GitHub | user deletes unused PAT | +| Remote inspection or changed plan adds grant | setup cannot proceed safely | intent draft, actual remote facts, grant delta | create a replacement PAT with corrected URL and re-audit | user deletes obsolete PAT | +| Final plan removes grant | token may exceed least privilege | final grant comparison | replace PAT or explicitly continue under existing audit policy | user owns excess-token cleanup | +| Unknown form parameter | no safe guided URL | manual path remains | use table/manual form | no generated PAT | +| User cancels after GitHub generation | PAT may remain active | no local value | delete in GitHub | user-owned | +| Setup partially applies | repo may be changed | report of completed steps | inspect before retry | delete operator PAT only after no retry needs it | +| GitHub deletion not confirmed | PAT may remain valid until expiry | local disposal only | open PAT Settings and delete | do not claim revoked | + +The cleanup URL points to GitHub PAT Settings, not to a destructive endpoint. +The CLI cannot identify or delete the exact PAT from the supplied value. A +one-day expiry still permits use until expiry and may be shortened by policy. + +## 11. Security, permissions, and privacy + +1. Least-privilege grants come from the same setup policy used by the final + permission audit. The link never adds every conditional grant by default. + The CLI must identify GitHub's initial **All repositories** selection as a + separate, manual scope decision; `target_name` is not repository scoping. +2. No password, cookie, browser profile, 2FA code, or PAT appears in a URL, + config file, telemetry, logs, or GitHub issue. Masked input is retained. +3. Existing command-line PAT flags remain for compatibility; guided mode does + not put token values in process arguments and docs should warn about those + legacy flags exposing values in shell history/process inspection. +4. Private GitHub website requests are not a supported authentication + contract; no browser scraping is added. +5. Preflight is local-only and uses the same bounded validators as setup. + Operator-declared owner kind cannot authorize organization operations; + authenticated inspection and the final audit remain mandatory. + +## 12. Observability and operational UX + +Show role, target repository, reviewed intent, exact prefilled grants, remote +unknowns, actual authenticated login, any grant delta, access result, and +setup/cleanup status. Do not record token values or raw API payloads. A failed +check includes one next action and known retained state. Limit output to one +choice, one intent review, one guidance block, existing audit report, and one +final cleanup reminder; no polling, comments, or notifications. + +## 13. Compatibility, migration, rollout, and rollback + +The manual prompt, `--token`, `PERSONAL_ACCESS_TOKEN`, `--non-interactive`, +`--yes`, and dry-run continue to work with existing precedence. The current +guided implementation prints a provisional bootstrap-only URL; the proposed +revision adds an interactive local preflight and grants selected by intent. +No repository schema, Secret, or account-store migration occurs. Rollback +hides the new preflight and returns to the existing guided/manual prompt; +PATs already generated by users remain their responsibility. Documentation +must distinguish shipped bootstrap-only behavior from this proposed behavior +until implementation is released. + +## 14. Testing strategy and numeric budget + +The minimum is **48 distinct cases**, derived from permission-intent branching, +local/remote evidence separation, exact URL grants, changed-plan correction, +identity/scope, cancellation, and cleanup truth. + +| Area | Cases | Risk covered | +|---|---:|---| +| Pure intent/URL/configuration policy | 12 | every local grant trigger and scope/level, dedupe, invalid owner/permission, encoding | +| State/application/idempotency | 10 | preflight review/revision, draft reuse, stale answers, added/removed grant, retry/cancel | +| Provider/permission contracts | 5 | owner/identity, repository access, remote inventory and health evidence, unknown response | +| Setup/compatibility | 6 | manual, supplied token, unattended, dry-run, CLI/config/default precedence, skip flags | +| Terminal/accessibility/localization | 7 | pending, review, action, blocked, partial, complete, narrow full URL and scope warning | +| Integration/security | 8 | no URL secret, no early mutation, no automatic all-conditionals, wrong account, remote unknown, cleanup truth | +| **Total** | **48** | Distinct tests, no double counting | + +Existing repository-wide gates remain. New pure preflight/projection and URL +policies target 100% branch coverage; changed setup code targets at least 95% +lines/statements and 90% branches/functions. Use deterministic GitHub fakes, +fixed clock and no real PATs in CI. Contract tests compare the URL's parsed +permission set with the reviewed preview and final policy fixtures; semantic +UI assertions accompany, rather than rely only on, snapshots. Required +coverage includes the exact six-grant example above, the twelve-grant +GitHub-form compatibility case, no optional grants selected, organization +versus personal owner, preflight choice reuse, remote-only grant correction, +and a plan that removes access. Human UX evidence includes a narrow terminal, +browser account switcher, 2FA handled by GitHub, explicit All-to-selected +repository change, and guided/manual fallback. The 2026-09-25 browser test +is prefill evidence only; final acceptance does not require dogfooding or a +live token value in test evidence. + +## 15. Documentation and discoverability + +| Audience | Artifact | Required content | Validation | +|---|---|---|---| +| New user | `README.md`, `docs/how-to-use.mdx` | two roles, pre-PAT choices, guided/manual normal path | navigation/link check | +| Setup owner | `docs/authentication.mdx`, `docs/configuration.mdx` | question-to-permission mapping, URL limits, remote unknowns, exact grants, All-to-selected repository step | policy fixture | +| Operator | `docs/security-operations/operations/troubleshooting.mdx` | wrong account, changed/removed grants, correction, cancellation, deletion | recovery fixture | +| Contributor | `docs/development/architecture.mdx`, this SDD | local intent snapshot, shared policy/URL builder, trust boundary | architecture test | + +Docs are updated with implementation, not ahead of it. Examples must match +CLI fixtures and clearly distinguish setup-PAT deletion from persistent bot +Secret renewal. + +## 16. Acceptance scenarios + +1. Given interactive setup without a supplied token, the CLI offers guided + creation and manual input; guided is the default. +2. Given guided choice, the CLI prints a documented GitHub URL, repository + instruction, and hidden PAT prompt; it does not open a browser. +3. Given guided mode and local defaults/flags/config, the CLI asks only + permission-affecting choices that are not fixed by those inputs, shows an + exact grant preview before the URL, and reuses answers in the full wizard. +4. Given selected Secret/Variable provisioning, issue workflows, initial tag, + release/hotfix/guarded approval, and organization Projects, the URL + contains exactly the corresponding strongest-level grants; disabling + those capabilities omits their grants. +5. Given remote-only Secret inventory or health-workflow uncertainty, the + preview labels the corresponding possible grants provisional and does not + add them merely because they are conditional in the bootstrap table. +6. Given an unintended browser account, the CLI displays the actual login and + the operator declines it; given a wrong repo, access verification fails. + Either way, setup blocks before dependent mutation. +7. Given a final plan requiring an additional or upgraded grant, the CLI + explains the exact delta, provides a corrected link, and blocks mutation + until a replacement/corrected PAT passes re-audit. A removed grant is + disclosed as possible excess access. +8. Given manual, supplied-token, unattended, or dry-run paths, their existing + behavior is preserved without surprise browser action. +9. Given cancellation during preflight or after GitHub generated a PAT, the + CLI reports no mutation and, when relevant, warns that the PAT may remain + active and links to GitHub Settings. +10. Given partial setup, the CLI reports completed changes separately from + token cleanup and does not claim rollback. +11. Given complete setup, the CLI says local value discarded, GitHub deletion + still required; it never says revoked without evidence. +12. Given an unsupported URL permission or contradictory owner/scope choice, + no misleading link is shown and the manual permission table remains + available. +13. Given GitHub initially selects All repositories, the CLI explicitly + instructs the operator to select only the target repository; neither + `target_name` nor a locally selected repository is presented as proof of + that GitHub form choice. +14. All primary states remain readable without color at narrow width and the + full URL is copyable. +15. Given authenticated remote owner type is `Unknown` (or unavailable), a + token-backed final audit blocks before provisioning even if the operator + asserted `Organization` or `User` earlier. The preview keeps possible + organization grants visible and asks for a fresh GitHub inspection; it + never presents the asserted kind as verified evidence. + +## 17. Requirements traceability + +| Requirement | Owner | Test/evidence | Documentation | +|---|---|---|---| +| Guided/manual choice (§4.1) | setup CLI + presenter | scenarios 1–2, 8 | how-to-use | +| Intent collection/reuse (§4.1, §6.1) | questionnaire + pure projection | scenarios 3–4, 9 | how-to-use/configuration | +| Exact/provisional grants (§4.1–4.3) | permission policy + URL builder | scenarios 4–5, 7, 12–13 | authentication/configuration | +| Actual token audit (§4.1) | existing permission use case | scenarios 6–7, 15 | troubleshooting | +| Cleanup truth (§4.1–4.3) | setup result presenter | scenarios 9–11 | authentication/troubleshooting | +| Accessible UI (§9) | terminal renderer | scenario 14 | how-to-use | + +## 18. Implementation sequence + +1. Define one permission-intent projection over the existing setup fields, + normalized override precedence, and required-versus-remote-unknown grants. +2. Split/reuse the existing questionnaire so permission-driving local answers + occur before the setup PAT and are not repeated after authenticated remote + inspection. Keep manual and supplied-token paths unchanged. +3. Feed the reviewed projection to the existing pure URL builder, compare + parsed link grants to preview fixtures, and make All-to-selected repository + instructions unavoidable. +4. Reconcile owner kind, remote inventory, and health-workflow evidence with + the final plan; show grant deltas and block mutation until re-audit. +5. Update user/architecture/recovery docs, coverage, UX evidence, and catalog + validation before enabling the new flow; do not dogfood this repository's + Issue/Action workflow. + +## 19. Definition of Done + +- [x] Pre-PAT local intent and remote-only provisional-grant boundary are specified; final audit still requires correction when grants change. +- [ ] Every MUST maps to acceptance and verification. +- [ ] Only documented GitHub URL parameters are emitted; URL contains no secret. +- [ ] Account/repo/permissions are checked through the supplied PAT. +- [ ] Manual, supplied-token, unattended, dry-run, and `--yes` behavior remain safe. +- [ ] Cancellation, partial setup, and deletion wording are accurate. +- [ ] Architecture, 48-case floor, coverage, security, and narrow-terminal UX pass. +- [ ] User, setup, operator, contributor docs and navigation are updated. +- [ ] Catalog evidence and generated `specs/CATALOG.md` are current; + `pnpm run validate:specifications` passes. + +## 20. References and decisions + +- Related: [guided bot PAT onboarding](./guided-bot-pat-onboarding.md), + [setup baseline](./setup-configuration-credentials-and-doctor.md), and + [PAT permission guidance](./setup-pat-permission-guidance-and-verification.md). +- Future presentation: [local web setup assistant](./local-web-setup-assistant.md) + reuses this role's grant, audit, and cleanup rules; it does not change the + browser-owned PAT issuance or GitHub deletion contract. Its new `--web` + flag is separate from this terminal-focused first release. +- Primary sources: [GitHub PAT form and URL parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), + [GitHub browser account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts). +- Decision: ship guided PAT creation for both roles; do not present automatic + PAT issuance, website request replay, or a local account manager as part of + this product. A future App token is a separate credential and proposal. +- Implementation snapshot (2026-09-25): the guided pre-PAT phase reuses the + normal questionnaire definitions and validation, skips fields fixed by + flags/config, and passes its in-memory draft and answered IDs to the main + wizard. The reviewed local choices project through the same setup permission + policy used by the final audit. Owner kind is explicitly asked when an + organization grant or inherited-resource access is possible; remote-only + health and inventory conditions are disclosed rather than granted by guess. + The terminal prints the exact URL only after review, and instructs the user + to switch GitHub's All repositories selection to Only select repositories. + Final audits still block missing grants and print a corrected link with + added-grant delta; removed grants are flagged as possible excess access. + The bot URL remains after the final plan. No PAT is generated or revoked by + Copilot; the URL never selects a repository. Browser prefill of twelve + grants was checked without minting a PAT. Controlled browser acceptance, + full numeric test budget, and security review remain open gates. diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 3b62cd2cd..b337ce63f 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -4,18 +4,43 @@ */ import { execSync } from 'child_process'; +import { join } from 'node:path'; import { program } from '../cli'; import { runLocalAction } from '../actions/local_action'; import { ACTIONS } from '../data/model/action_types'; import { INPUT_KEYS } from '../application/contracts/input_keys'; import type { SetupTokenPermissionReport, SetupTokenPermissionRequirement } from '../domain/setup_token_permissions'; +import { WebSetupBridge } from '../cli/web_setup_bridge'; +import { WebSetupQuestionnaireCollector } from '../cli/web_setup_adapters'; +import { startWebSetupServer, openWebSetupBrowser } from '../cli/web_setup_server'; +import { captureSetupApplySnapshot, setupApplySnapshotMatches } from '../cli/setup_apply_snapshot'; +import { acquireSetupSessionGuard } from '../cli/setup_session_guard'; +import { createSetupReviewState } from '../application/policies/setup_questionnaire_policy'; +import type { WebSetupPrompt } from '../application/contracts/web_setup_view'; +import { SetupDoctorWorkspaceQueryAdapter } from '../infrastructure/setup_workspace_adapter'; jest.mock('child_process', () => ({ execSync: jest.fn(), })); jest.mock('../actions/local_action', () => ({ - runLocalAction: jest.fn().mockResolvedValue(undefined), + runLocalAction: jest.fn().mockResolvedValue([]), +})); + +// Setup serialization is exercised against temporary Git repositories in its +// dedicated adapter tests; CLI command tests mock the filesystem boundary. +jest.mock('../cli/setup_session_guard', () => ({ + acquireSetupSessionGuard: jest.fn(() => jest.fn()), +})); + +jest.mock('../cli/web_setup_server', () => ({ + startWebSetupServer: jest.fn(async () => ({ url: 'http://127.0.0.1:40000/', pairingCode: '0123456789abcdef', closed: Promise.resolve(), close: jest.fn() })), + openWebSetupBrowser: jest.fn(), +})); + +jest.mock('../cli/setup_apply_snapshot', () => ({ + captureSetupApplySnapshot: jest.fn(() => ({})), + setupApplySnapshotMatches: jest.fn(() => true), })); jest.mock('../utils/logger', () => ({ @@ -61,7 +86,7 @@ jest.mock('../cli/setup_doctor_presenter', () => ({ }), })); -const mockTokenPermissionInspect = jest.fn(async (request: { role: 'setup' | 'workflow'; requirements: readonly SetupTokenPermissionRequirement[] }): Promise => ({ +const mockTokenPermissionInspect = jest.fn(async (request: { role: 'setup' | 'workflow'; token: string; requirements: readonly SetupTokenPermissionRequirement[] }): Promise => ({ role: request.role, identityStatus: 'valid' as const, identityMessage: 'verified', @@ -73,7 +98,7 @@ jest.mock('../infrastructure/composition/setup_token_permissions_composition_roo createSetupTokenPermissionsUseCase: () => ({ inspect: mockTokenPermissionInspect }), })); -const mockRemoteConfigurationInspect = jest.fn().mockResolvedValue({ +const defaultRemoteConfiguration = { ownerType: 'User', repositoryVisibility: 'private', repositorySecrets: [], @@ -85,11 +110,20 @@ const mockRemoteConfigurationInspect = jest.fn().mockResolvedValue({ organizationAccess: 'not_applicable', organizationSecretsAccess: 'not_applicable', organizationVariablesAccess: 'not_applicable', +}; +const mockRemoteConfigurationInspect = jest.fn().mockResolvedValue(defaultRemoteConfiguration); +const mockSetupCredentialsCollect = jest.fn().mockResolvedValue({ + collection: { apiKeys: [] }, checks: [], existingSecretNames: [], }); +const mockSetupCredentialsOptions = jest.fn(); jest.mock('../infrastructure/composition/setup_credentials_composition_root', () => ({ - createSetupCredentialsUseCase: () => ({ collect: jest.fn().mockResolvedValue({ collection: { apiKeys: [] }, checks: [], existingSecretNames: [] }) }), + createSetupCredentialsUseCase: (_prompt: unknown, _presenter: unknown, options: unknown) => { + mockSetupCredentialsOptions(options); + return { collect: mockSetupCredentialsCollect }; + }, createSetupRemoteConfigurationReadPort: () => ({ inspect: mockRemoteConfigurationInspect, + inspectCredentialHealthWorkflow: jest.fn(async () => 'installed'), }), })); @@ -100,6 +134,7 @@ describe('CLI', () => { beforeEach(() => { jest.clearAllMocks(); + mockSetupCredentialsCollect.mockResolvedValue({ collection: { apiKeys: [] }, checks: [], existingSecretNames: [] }); process.exitCode = undefined; process.env.AGENT_PROVIDER = 'opencode'; process.env.AGENT_MODEL = 'test-model'; @@ -112,7 +147,7 @@ describe('CLI', () => { ? 'a'.repeat(40) : 'https://github.com/test-owner/test-repo.git', )); - (runLocalAction as jest.Mock).mockResolvedValue(undefined); + (runLocalAction as jest.Mock).mockResolvedValue([]); mockIsIssue.mockResolvedValue(true); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); @@ -189,6 +224,10 @@ describe('CLI', () => { }); describe('doctor', () => { + it('passes the read-only choice to diagnosis without dispatching health checks from the command', async () => { + await program.parseAsync(['node', 'cli', 'doctor', '--non-interactive', '--read-only', '--token', 'github_pat_doctor_test_token']); + expect(mockDoctorExecute).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })); + }); it('presents the report with its resolved catalog and returns a failing exit code when unhealthy', async () => { const catalog = { locale: 'es-ES', message: jest.fn() }; const report = { healthy: false, checks: [], totals: { pass: 0, warn: 0, fail: 1, skipped: 0 } }; @@ -453,6 +492,1005 @@ describe('CLI', () => { describe('setup', () => { // Token check: hasValidSetupToken/setupEnvFileExists and message variants are covered in // setup_files.test.ts and initial_setup_use_case.test.ts. + beforeEach(() => { + const setupCommand = program.commands.find(command => command.name() === 'setup')!; + for (const option of setupCommand.options) { + setupCommand.setOptionValue(option.attributeName(), option.defaultValue); + } + }); + + describe('local web command handoff', () => { + let ask: jest.SpyInstance; + let collect: jest.SpyInstance; + + const answerWebPrompt = async (prompt: WebSetupPrompt): Promise => { + if (prompt.title === 'Confirm this repository') return 'Yes, this is my repository'; + if (prompt.title === 'Choose setup detail') return 'Basic guided setup'; + if (prompt.title === 'How will you provide your setup PAT?') return 'Manual PAT'; + if (prompt.title === 'Temporary setup PAT') return 'github_pat_web_setup_test_token'; + if (prompt.kind === 'plan') return 'approve'; + if (prompt.title === 'Apply this setup now?') return 'Apply setup'; + if (prompt.title === 'Update existing workflows?') return 'Keep existing'; + throw new Error(`Unexpected browser prompt: ${prompt.title}`); + }; + + beforeEach(() => { + (setupApplySnapshotMatches as jest.Mock).mockReturnValue(true); + (execSync as jest.Mock).mockImplementation((command: string) => Buffer.from( + command === 'git rev-parse HEAD' ? 'a'.repeat(40) + : command === 'git rev-parse --show-toplevel' ? process.cwd() + : command === 'git rev-parse --abbrev-ref HEAD' || command === 'git symbolic-ref --quiet --short HEAD' ? 'develop' + : 'https://github.com/test-owner/test-repo.git', + )); + ask = jest.spyOn(WebSetupBridge.prototype, 'ask').mockImplementation(answerWebPrompt); + collect = jest.spyOn(WebSetupQuestionnaireCollector.prototype, 'collect') + .mockImplementation(async initial => createSetupReviewState(initial.draft)); + }); + + afterEach(() => { ask.mockRestore(); collect.mockRestore(); }); + + it('uses one browser session through PAT, plan, revalidation, and Apply', async () => { + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(startWebSetupServer).toHaveBeenCalledTimes(1); + expect(openWebSetupBrowser).toHaveBeenCalledWith('http://127.0.0.1:40000/'); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('0123456789abcdef'), false, undefined, true); + expect(ask.mock.calls.map(call => call[0].title)).toEqual(expect.arrayContaining([ + 'Confirm this repository', 'How will you provide your setup PAT?', 'Temporary setup PAT', + 'Review your setup plan', 'Apply this setup now?', + ])); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(3); + expect(captureSetupApplySnapshot).toHaveBeenCalledTimes(1); + expect(setupApplySnapshotMatches).toHaveBeenCalledTimes(2); + expect(runLocalAction).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + const bridge = (startWebSetupServer as jest.Mock).mock.calls[0][0] as WebSetupBridge; + expect(await bridge.runReadOnlyDoctor()).toBe('complete'); + expect(mockDoctorExecute).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'test-owner', repository: 'test-repo', setupToken: 'github_pat_web_setup_test_token', readOnly: true, + })); + expect(bridge.snapshot().doctor).toEqual({ status: 'complete', healthy: true, pass: 0, warn: 0, fail: 0, skipped: 0 }); + }); + + it('prints the pairing code to stdout even without an interactive TTY', async () => { + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('0123456789abcdef'), false, undefined, true); + expect(startWebSetupServer).toHaveBeenCalledTimes(1); + } finally { + if (descriptor) Object.defineProperty(process.stdout, 'isTTY', descriptor); + else Reflect.deleteProperty(process.stdout, 'isTTY'); + } + }); + + it('stops before acquiring a PAT when repository confirmation is declined', async () => { + ask.mockResolvedValueOnce('Stop and choose another checkout'); + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + }); + + it('treats a dismissed repository confirmation as cancellation', async () => { + ask.mockResolvedValueOnce(undefined); + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(130); + }); + + it('rejects detached HEAD before opening the browser or collecting a PAT', async () => { + (execSync as jest.Mock).mockImplementation((command: string) => { + if (command === 'git symbolic-ref --quiet --short HEAD') throw new Error('detached HEAD'); + return Buffer.from(command === 'git rev-parse HEAD' ? 'a'.repeat(40) + : command === 'git rev-parse --show-toplevel' ? process.cwd() + : 'https://github.com/test-owner/test-repo.git'); + }); + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(startWebSetupServer).not.toHaveBeenCalled(); + expect(openWebSetupBrowser).not.toHaveBeenCalled(); + expect(ask).not.toHaveBeenCalled(); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + const { logError } = require('../utils/logger'); + expect(logError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Check out a branch') })); + }); + + it('rejects web setup from a subdirectory before opening the browser or collecting a PAT', async () => { + const root = process.cwd(); + const nested = join(root, 'src'); + const cwd = jest.spyOn(process, 'cwd').mockReturnValue(nested); + (execSync as jest.Mock).mockImplementation((command: string) => Buffer.from( + command === 'git rev-parse --show-toplevel' ? root + : command === 'git config --get remote.origin.url' ? 'https://github.com/test-owner/test-repo.git' + : 'true', + )); + try { + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(startWebSetupServer).not.toHaveBeenCalled(); + expect(acquireSetupSessionGuard).not.toHaveBeenCalled(); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + const { logError } = require('../utils/logger'); + expect(logError).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining(`repository root (${root})`), + })); + } finally { cwd.mockRestore(); } + }); + + it('reports a blocked browser session if launch fails before the journey starts', async () => { + (openWebSetupBrowser as jest.Mock).mockImplementationOnce(() => { throw new Error('Browser launch failed'); }); + const finish = jest.spyOn(WebSetupBridge.prototype, 'finish'); + try { + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(finish).toHaveBeenCalledWith('blocked', expect.stringContaining('No further setup changes')); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { finish.mockRestore(); } + }); + + it.each([ + [undefined, 130], + ['decline', undefined], + ] as const)('honors a %s browser plan decision before credentials or Apply', async (answer, exitCode) => { + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.kind === 'plan' + ? answer : answerWebPrompt(prompt)); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(exitCode); + expect(ask.mock.calls.map(call => call[0].title)).not.toContain('Apply this setup now?'); + }); + + it('guides setup PAT review and confirms the audited operator account before planning', async () => { + ask.mockImplementation(async (prompt: WebSetupPrompt) => { + if (prompt.title === 'How will you provide your setup PAT?') return 'Guided GitHub link'; + if (prompt.title === 'What kind of GitHub account owns this repository?') return 'Personal account'; + if (prompt.title === 'Review these provisional setup PAT grants') return 'Continue to GitHub'; + if (prompt.title.includes('Is that the intended operator account?')) return 'Yes, continue'; + return answerWebPrompt(prompt); + }); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', account: 'operator', identityStatus: 'valid', identityMessage: 'verified', + ready: true, confirmationRequired: false, checks: [], + }); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(ask.mock.calls.map(call => call[0].title)).toEqual(expect.arrayContaining([ + 'Review these provisional setup PAT grants', + expect.stringContaining('Is that the intended operator account?'), + ])); + expect(runLocalAction).toHaveBeenCalledTimes(1); + }); + + it('warns when the final guided plan no longer needs earlier PAT grants', async () => { + ask.mockImplementation(async (prompt: WebSetupPrompt) => { + if (prompt.title === 'How will you provide your setup PAT?') return 'Guided GitHub link'; + if (prompt.title === 'What kind of GitHub account owns this repository?') return 'Personal account'; + if (prompt.title === 'Review these provisional setup PAT grants') return 'Continue to GitHub'; + if (prompt.title.includes('Is that the intended operator account?')) return 'Yes, continue'; + return answerWebPrompt(prompt); + }); + collect.mockImplementationOnce(async initial => createSetupReviewState(initial.draft)) + .mockImplementationOnce(async initial => createSetupReviewState({ + ...initial.draft, manageRepositoryVariables: false, + })); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', account: 'operator', identityStatus: 'valid', identityMessage: 'verified', + ready: true, confirmationRequired: false, checks: [], + }); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Your PAT may have excess access')); + }); + + it('keeps web dry-run local and never asks for either PAT or Apply', async () => { + await program.parseAsync(['node', 'cli', 'setup', '--web', '--dry-run', '--pr-approval-mode', 'off']); + expect(ask.mock.calls.map(call => call[0].title)).not.toContain('Temporary setup PAT'); + expect(ask.mock.calls.map(call => call[0].title)).not.toContain('Apply this setup now?'); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it('requires explicit selection before using an environment PAT', async () => { + mockGetSetupToken.mockReturnValueOnce('github_pat_from_environment_test'); + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.title === 'An environment setup PAT is available' + ? 'Use the environment PAT' : answerWebPrompt(prompt)); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(ask.mock.calls.map(call => call[0].title)).toContain('An environment setup PAT is available'); + expect(ask.mock.calls.map(call => call[0].title)).not.toContain('Temporary setup PAT'); + expect(mockTokenPermissionInspect.mock.calls[0][0].token).toBe('github_pat_from_environment_test'); + expect(runLocalAction).toHaveBeenCalledTimes(1); + }); + + it('discards an environment PAT when the operator chooses a different one', async () => { + mockGetSetupToken.mockReturnValueOnce('github_pat_from_environment_test'); + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.title === 'An environment setup PAT is available' + ? 'Create or enter a different PAT' : answerWebPrompt(prompt)); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(ask.mock.calls.map(call => call[0].title)).toContain('Temporary setup PAT'); + expect(mockTokenPermissionInspect.mock.calls[0][0].token).toBe('github_pat_web_setup_test_token'); + expect(runLocalAction).toHaveBeenCalledTimes(1); + }); + + it('does not use an environment PAT when the browser choice is cancelled', async () => { + mockGetSetupToken.mockReturnValueOnce('github_pat_from_environment_test'); + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.title === 'An environment setup PAT is available' + ? undefined : answerWebPrompt(prompt)); + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(mockTokenPermissionInspect).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(130); + }); + + it('blocks final Apply if GitHub facts changed after the reviewed plan', async () => { + mockRemoteConfigurationInspect.mockResolvedValueOnce(defaultRemoteConfiguration) + .mockResolvedValueOnce({ ...defaultRemoteConfiguration, repositoryVisibility: 'public' }); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('does not enter the mutation boundary when final Apply is declined', async () => { + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.title === 'Apply this setup now?' + ? 'Stop without applying' : answerWebPrompt(prompt)); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it('treats a dismissed final Apply prompt as cancellation', async () => { + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.title === 'Apply this setup now?' + ? undefined : answerWebPrompt(prompt)); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(130); + }); + + it.each(['cancelled', 'expired'] as const)('does not Apply after the browser session is %s', async state => { + ask.mockImplementation(async function (this: WebSetupBridge, prompt: WebSetupPrompt) { + if (prompt.title === 'Apply this setup now?') { + if (state === 'cancelled') this.cancel(); + else this.finish('blocked', 'The local session expired.'); + return 'Apply setup'; + } + return answerWebPrompt(prompt); + }); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(state === 'cancelled' ? 130 : 1); + }); + + it('reports partial completion when an approved action fails', async () => { + (runLocalAction as jest.Mock).mockResolvedValueOnce([{ success: false, errors: ['provider failed'] }]); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(1); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('partial completion')); + }); + + it('passes only explicitly approved changed workflows to the mutation boundary', async () => { + const comparison = jest.spyOn(SetupDoctorWorkspaceQueryAdapter.prototype, 'compareWorkflows') + .mockReturnValue([ + { file: 'copilot_issue.yml', destination: '.github/workflows/copilot_issue.yml', status: 'changed' }, + { file: 'unmanaged.yml', destination: '.github/workflows/unmanaged.yml', status: 'unmanaged' }, + ]); + ask.mockImplementation(async (prompt: WebSetupPrompt) => prompt.title === 'Update existing workflows?' + ? 'Update setup-managed workflows' : answerWebPrompt(prompt)); + try { + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).toHaveBeenCalledWith(expect.objectContaining({ setupWorkflowUpdates: ['copilot_issue.yml'] })); + } finally { comparison.mockRestore(); } + }); + + it('refuses to launch a browser session without a verified HEAD', async () => { + (execSync as jest.Mock).mockImplementation((command: string) => { + if (command === 'git rev-parse HEAD') throw new Error('missing HEAD'); + return Buffer.from(command === 'git rev-parse --show-toplevel' ? process.cwd() + : 'https://github.com/test-owner/test-repo.git'); + }); + await program.parseAsync(['node', 'cli', 'setup', '--web']); + expect(startWebSetupServer).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('fails closed when selected files drift after browser plan approval', async () => { + (setupApplySnapshotMatches as jest.Mock).mockReturnValue(false); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('fails closed when the approved file snapshot is unavailable', async () => { + (captureSetupApplySnapshot as jest.Mock).mockReturnValueOnce(undefined); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + const { logError } = require('../utils/logger'); + expect(logError).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('approved setup evidence is incomplete'), + })); + }); + + it('fails closed when the GitHub remote becomes unresolvable just before Apply', async () => { + let remoteReads = 0; + (execSync as jest.Mock).mockImplementation((command: string) => Buffer.from( + command === 'git rev-parse HEAD' ? 'a'.repeat(40) + : command === 'git rev-parse --show-toplevel' ? process.cwd() + : command === 'git symbolic-ref --quiet --short HEAD' ? 'develop' + : command === 'git config --get remote.origin.url' && ++remoteReads > 1 + ? 'not-a-github-remote' : 'https://github.com/test-owner/test-repo.git', + )); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(remoteReads).toBeGreaterThan(1); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('fails closed when the attached branch disappears just before Apply', async () => { + let branchReads = 0; + (execSync as jest.Mock).mockImplementation((command: string) => { + if (command === 'git symbolic-ref --quiet --short HEAD' && ++branchReads > 1) throw new Error('detached HEAD'); + return Buffer.from(command === 'git rev-parse HEAD' ? 'a'.repeat(40) + : command === 'git rev-parse --show-toplevel' ? process.cwd() + : command === 'git symbolic-ref --quiet --short HEAD' ? 'develop' + : 'https://github.com/test-owner/test-repo.git'); + }); + await program.parseAsync(['node', 'cli', 'setup', '--web', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(branchReads).toBeGreaterThan(1); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('rejects unattended CLI approval flags in web mode', async () => { + await program.parseAsync(['node', 'cli', 'setup', '--web', '--yes']); + expect(startWebSetupServer).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it.each([ + ['--non-interactive'], + ['--token', 'github_pat_operator_test_token'], + ['--workflow-pat', 'github_pat_bot_test_token'], + ['--secret', 'PAT=github_pat_bot_test_token'], + ['--confirm-unverifiable-write-permissions'], + ])('rejects incompatible web option %s before opening the browser', async (...flags) => { + await program.parseAsync(['node', 'cli', 'setup', '--web', ...flags]); + expect(startWebSetupServer).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + }); + const guidedTerminal = (answer?: (prompt: string) => string | undefined) => ({ + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => ({ kind: 'value' as const, value: answer?.(prompt) + ?? (prompt.includes('repository owner an organization') ? '2' + : prompt.includes('Review these intended grants') ? '1' : '') })), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'github_pat_guided_setup_test_token' }), + close: jest.fn(), + }); + + const acceptedSetupPatReport = () => ({ + role: 'setup' as const, identityStatus: 'valid' as const, identityMessage: 'verified', + account: 'operator', ready: true, confirmationRequired: false, checks: [], + }); + + it('carries guided intent through the final audit and prepares the separate bot link', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botGuide = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'configureWorkflowPatGuide'); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(2); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements).toEqual(expect.arrayContaining([ + expect.objectContaining({ scope: 'repository', permission: 'Contents', level: 'write', applicability: 'required' }), + ])); + expect(botGuide).toHaveBeenCalledTimes(1); + expect(runLocalAction).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + } finally { + botGuide.mockRestore(); + createTerminal.mockRestore(); + } + }); + + it('falls back to manual PAT entry when the owner kind cannot be confirmed', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('repository owner an organization') ? '3' : ''); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Owner type was not confirmed')); + expect(input.readText).not.toHaveBeenCalledWith(expect.stringContaining('Review these intended grants')); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Setup PAT permissions required'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('allows the operator to choose manual entry after reviewing local intent', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('Review these intended grants') ? '4' + : prompt.includes('repository owner an organization') ? '2' : ''); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('shows the full permission table immediately for manual setup PAT entry', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('How would you like to provide the setup PAT?') ? '2' : ''); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Setup PAT permissions required'); + expect(output).toContain('Stage 3/6 · Setup PAT'); + } finally { createTerminal.mockRestore(); } + }); + + it('revises permission intent before the link and drops the initial-tag write grant', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + let reviews = 0; + let tags = 0; + const input = guidedTerminal(prompt => { + if (prompt.includes('repository owner an organization')) return '2'; + if (prompt.includes('Review these intended grants')) return ++reviews === 1 ? '2' : '1'; + if (prompt.includes('Create v1.0.0')) return ++tags === 2 ? 'no' : ''; + return ''; + }); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(reviews).toBe(2); + expect(tags).toBe(2); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements).toEqual(expect.arrayContaining([ + expect.objectContaining({ permission: 'Contents', level: 'read' }), + ])); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('contents=read'); + expect(output).toContain('Stage 2/6 · Setup choices · review pass 2'); + expect(output).toContain('This is the same setup run. Your answers are saved as defaults'); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith('Choice review complete. Returning to setup PAT permission review.'); + const firstPatReview = output.indexOf('Stage 3/6 · Setup PAT'); + const choiceReview = output.indexOf('Stage 2/6 · Setup choices · review pass 2'); + const returnedPatReview = output.indexOf('Stage 3/6 · Setup PAT', choiceReview + 1); + expect(firstPatReview).toBeLessThan(choiceReview); + expect(choiceReview).toBeLessThan(returnedPatReview); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('keeps fixed flag choices out of every review pass', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + let reviews = 0; + const input = guidedTerminal(prompt => { + if (prompt.includes('repository owner an organization')) return '2'; + if (prompt.includes('Review these intended grants')) return ++reviews === 1 ? '2' : '1'; + return ''; + }); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--skip-secrets', '--pr-approval-mode', 'off']); + expect(reviews).toBe(2); + expect(input.readText.mock.calls.some(([prompt]) => String(prompt).includes('Validate and provision required GitHub Actions Secrets?'))).toBe(false); + expect(input.readText.mock.calls.some(([prompt]) => String(prompt).includes('Bot PR approval mode'))).toBe(false); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('review pass 2'); + } finally { createTerminal.mockRestore(); } + }); + + it('cancels safely during a repeated choice pass without requesting a PAT', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + let revisiting = false; + const terminal = { + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => { + if (prompt.includes('Review these intended grants')) { + revisiting = true; + return { kind: 'value' as const, value: '2' }; + } + if (revisiting && prompt.includes('Issue automation:')) return { kind: 'end-of-input' as const }; + return { kind: 'value' as const, value: prompt.includes('repository owner an organization') ? '2' : '' }; + }), + readSecret: jest.fn(), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup']); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Stage 2/6 · Setup choices · review pass 2'); + expect(output).toContain('Cancelled: setup stopped.'); + expect(terminal.readSecret).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(130); + } finally { createTerminal.mockRestore(); } + }); + + it('shows setup permission details on demand without repeating the intent questionnaire', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + let reviews = 0; + const input = guidedTerminal(prompt => { + if (prompt.includes('repository owner an organization')) return '2'; + if (prompt.includes('Review these intended grants')) return ++reviews === 1 ? '3' : '1'; + return ''; + }); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(reviews).toBe(2); + expect(input.readText.mock.calls.filter(([prompt]) => String(prompt).includes('Create v1.0.0'))).toHaveLength(1); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Setup PAT permission summary'); + expect(output).toContain('Setup PAT permissions required'); + expect(output).toContain('Stage 3/6 · Setup PAT'); + } finally { createTerminal.mockRestore(); } + }); + + it('blocks a personal owner paired with organization storage before requesting a PAT', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup', '--secrets-scope', 'organization']); + const { logInfo } = require('../utils/logger'); + expect((logInfo as jest.Mock).mock.calls.flat()).toContainEqual(expect.stringContaining('declared a personal account')); + expect(input.readSecret).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('reports invalid fixed local configuration before making a guided PAT link', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const configFile = require('../cli/setup_config_file') as typeof import('../cli/setup_config_file'); + const loadConfig = jest.spyOn(configFile, 'loadSetupConfigurationOverrides') + .mockReturnValue({ repository: { mainBranch: 'invalid branch' } }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup', '--config', 'invalid-local-config.yml', '--pr-approval-mode', 'off']); + const { logInfo } = require('../utils/logger'); + expect((logInfo as jest.Mock).mock.calls.flat()).toContainEqual(expect.stringContaining('configuration needs correction')); + expect(input.readSecret).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { loadConfig.mockRestore(); createTerminal.mockRestore(); } + }); + + it('accepts a minimal personal-repository intent without asking the owner kind', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => { + if (prompt.includes('Issue automation:') || prompt.includes('Pull request automation:') + || prompt.includes('Create v1.0.0') || prompt.includes('Create/update GitHub Actions Variables?') + || prompt.includes('Validate and provision required GitHub Actions Secrets?')) return 'no'; + if (prompt.includes('Review these intended grants')) return '1'; + return ''; + }); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(input.readText.mock.calls.some(([prompt]) => String(prompt).includes('repository owner an organization'))).toBe(false); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements.map((item: SetupTokenPermissionRequirement) => item.permission)) + .toEqual(['Metadata', 'Contents']); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('asks the owner kind and includes Projects when no other organization grant is selected', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const configFile = require('../cli/setup_config_file') as typeof import('../cli/setup_config_file'); + const loadConfig = jest.spyOn(configFile, 'loadSetupConfigurationOverrides').mockReturnValue({ + createInitialTag: false, + features: { issues: false, release: false, hotfix: false }, + projects: { ids: '42' }, + }); + const input = guidedTerminal(prompt => prompt.includes('repository owner an organization') ? '1' : undefined); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--config', 'projects-only.yml', + '--skip-variables', '--skip-secrets', '--pr-approval-mode', 'off']); + expect(input.readText.mock.calls.some(([prompt]) => String(prompt).includes('repository owner an organization'))).toBe(true); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements.filter((item: SetupTokenPermissionRequirement) => item.scope === 'organization')) + .toEqual([expect.objectContaining({ permission: 'Projects', level: 'read' })]); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(process.exitCode).toBe(1); + } finally { loadConfig.mockRestore(); createTerminal.mockRestore(); } + }); + + it('describes an explicitly empty issue-workflow selection as none', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const configFile = require('../cli/setup_config_file') as typeof import('../cli/setup_config_file'); + const loadConfig = jest.spyOn(configFile, 'loadSetupConfigurationOverrides') + .mockReturnValue({ issueWorkflows: { enabled: [] }, pullRequestApproval: { mode: 'off' } }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup', '--config', 'empty-issue-workflows.yml']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('issue workflows: none')); + } finally { loadConfig.mockRestore(); createTerminal.mockRestore(); } + }); + + it('falls back to manual entry when the setup PAT form cannot express a grant', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(() => { throw new urlPolicy.UnsupportedSetupPatLinkError(['repository Unsupported write']); }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('guided setup PAT link is unavailable')); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(process.exitCode).toBe(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('does not turn an unexpected setup-link failure into a misleading manual fallback', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(() => { throw new Error('unexpected link failure'); }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(input.readSecret).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('retains setup progress when the final bot PAT form is unsupported', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const original = urlPolicy.buildSetupPatCreationUrl; + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(input => input.role === 'workflow' + ? (() => { throw new urlPolicy.UnsupportedSetupPatLinkError(['repository Checks read']); })() + : original(input)); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('guided fine-grained bot PAT link is unavailable')); + expect(runLocalAction).toHaveBeenCalledTimes(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('surfaces an unexpected bot-link failure instead of silently entering manual mode', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const original = urlPolicy.buildSetupPatCreationUrl; + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(input => { + if (input.role === 'workflow') throw new Error('unexpected bot link failure'); + return original(input); + }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('blocks a guided plan when GitHub reports a different owner kind', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('repository owner an organization') ? '1' : undefined); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('GitHub reports User')); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Setup PAT permissions changed'); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(1); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('shows the corrected grants and blocks before mutation when the final PAT audit fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect + .mockResolvedValueOnce(acceptedSetupPatReport()) + .mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(2); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Setup PAT permissions changed'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('lists remote-only grants added after discovering an existing Secret', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockRemoteConfigurationInspect.mockResolvedValueOnce({ + ...defaultRemoteConfiguration, + repositorySecrets: ['PAT'], + }); + mockTokenPermissionInspect + .mockResolvedValueOnce(acceptedSetupPatReport()) + .mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off']); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Setup PAT permissions changed'); + expect(output).toContain('repository Actions write'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('blocks before mutation if GitHub cannot verify the owner type despite guided organization intent', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('repository owner an organization') ? '1' : undefined); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockRemoteConfigurationInspect.mockResolvedValueOnce({ ...defaultRemoteConfiguration, ownerType: 'Unknown' }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logError } = require('../utils/logger'); + expect(logError).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('could not verify whether this repository is owned'), + })); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it.each([ + ['operator', true], ['separate-bot', false], + ])('verifies the guided bot PAT account @%s before applying setup', async (login, reusedAccount) => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const identityModule = require('../infrastructure/setup_github_identity_query_adapter') as typeof import('../infrastructure/setup_github_identity_query_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botIdentity = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'guidedWorkflowBotIdentity', 'get') + .mockReturnValue({ id: 42, login }); + const identify = jest.spyOn(identityModule.SetupGithubIdentityQueryAdapter.prototype, 'identify') + .mockResolvedValue({ id: 42, login }); + mockSetupCredentialsCollect.mockResolvedValueOnce({ + collection: { apiKeys: [], workflowPat: { name: 'PAT', value: 'bot-pat' } }, checks: [], existingSecretNames: [], + }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(identify).toHaveBeenCalledWith('bot-pat'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining(`Workflow PAT owner verified as @${login}`)); + expect((logInfo as jest.Mock).mock.calls.flat().some((message: unknown) => String(message).includes('same GitHub account'))) + .toBe(reusedAccount); + expect(runLocalAction).toHaveBeenCalledTimes(1); + } finally { identify.mockRestore(); botIdentity.mockRestore(); createTerminal.mockRestore(); } + }); + + it('does not mutate when the guided bot PAT identity check fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const identityModule = require('../infrastructure/setup_github_identity_query_adapter') as typeof import('../infrastructure/setup_github_identity_query_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botIdentity = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'guidedWorkflowBotIdentity', 'get') + .mockReturnValue({ id: 42, login: 'bot-account' }); + const identify = jest.spyOn(identityModule.SetupGithubIdentityQueryAdapter.prototype, 'identify') + .mockResolvedValue({ id: 43, login: 'wrong-account' }); + mockSetupCredentialsCollect.mockResolvedValueOnce({ + collection: { apiKeys: [], workflowPat: { name: 'PAT', value: 'bot-pat' } }, checks: [], existingSecretNames: [], + }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('No bot Secret write started')); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { identify.mockRestore(); botIdentity.mockRestore(); createTerminal.mockRestore(); } + }); + + it('warns that a guided bot PAT may be stored if applying setup fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botIdentity = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'guidedWorkflowBotIdentity', 'get') + .mockReturnValue({ id: 42, login: 'bot-account' }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + (runLocalAction as jest.Mock).mockRejectedValueOnce(new Error('setup failed after mutation started')); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Setup may be partially applied')); + expect(process.exitCode).toBe(1); + } finally { botIdentity.mockRestore(); createTerminal.mockRestore(); } + }); + + it('reports partial completion when an action succeeds but returns errors', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + (runLocalAction as jest.Mock).mockResolvedValueOnce([{ success: true, errors: ['partial failure'] }]); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('partial completion')); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + it('offers the guided setup PAT link and a repair link when its initial audit fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => ({ kind: 'value', value: prompt.includes('repository owner an organization') || prompt.includes('Review these intended grants') ? '1' : '' })), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'github_pat_guided_setup_test_token' }), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', identityStatus: 'valid', identityMessage: 'verified', + ready: false, confirmationRequired: false, checks: [], + }); + + try { + await program.parseAsync(['node', 'cli', 'setup']); + + expect(terminal.readText).toHaveBeenCalledWith(expect.stringContaining('How would you like to provide the setup PAT?')); + expect(terminal.readSecret).toHaveBeenCalledWith('Setup PAT'); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Setup PAT access needs attention'); + expect(output).toContain('Revoke temporary setup PAT'); + expect(output).toContain('contents=write'); + expect(output).toContain('issue_types=write'); + expect(output).toContain('Only select repositories'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(terminal.close).toHaveBeenCalledTimes(1); + } finally { + createTerminal.mockRestore(); + } + }); + + it('keeps manual PAT entry free of pre-PAT questions and guided cleanup claims', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn().mockResolvedValue({ kind: 'value', value: '2' }), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'ghp_manual_setup_test_token' }), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', identityStatus: 'valid', identityMessage: 'verified', + ready: false, confirmationRequired: false, checks: [], + }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(terminal.readText).toHaveBeenCalledTimes(2); + expect(terminal.readText).toHaveBeenCalledWith(expect.stringContaining('How much configuration detail')); + expect(terminal.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(runLocalAction).not.toHaveBeenCalled(); + } finally { + createTerminal.mockRestore(); + } + }); + + it('exits cleanly when permission intent is cancelled before a GitHub link exists', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn() + .mockResolvedValueOnce({ kind: 'value', value: '' }) + .mockResolvedValueOnce({ kind: 'end-of-input' }), + readSecret: jest.fn(), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(terminal.readSecret).not.toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(130); + } finally { + createTerminal.mockRestore(); + } + }); + + it('stops before planning if the guided setup PAT belongs to an unintended account', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => ({ kind: 'value', value: prompt.includes('Is this the account you intended') ? '2' : prompt.includes('repository owner an organization') || prompt.includes('Review these intended grants') ? '1' : '' })), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'github_pat_guided_setup_test_token' }), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', identityStatus: 'valid', identityMessage: 'verified', + account: 'wrong-account', ready: true, confirmationRequired: false, checks: [], + }); + + try { + await program.parseAsync(['node', 'cli', 'setup']); + + expect(terminal.readText).toHaveBeenCalledWith(expect.stringContaining('Is this the account you intended to configure with?')); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(terminal.close).toHaveBeenCalledTimes(1); + } finally { + createTerminal.mockRestore(); + } + }); + it('calls runLocalAction with INITIAL_SETUP', async () => { await program.parseAsync([ 'node', @@ -489,6 +1527,75 @@ describe('CLI', () => { expect(params[INPUT_KEYS.SINGLE_ACTION]).toBe(ACTIONS.INITIAL_SETUP); }); + it('reports partial application when the local setup action returns a failed result', async () => { + (runLocalAction as jest.Mock).mockResolvedValueOnce([{ success: false, errors: [] }]); + await program.parseAsync([ + 'node', 'cli', 'setup', '--token', 'ghp_abcdefghijklmnopqrstuvwxyz12', + '--skip-secrets', '--non-interactive', '--pr-approval-mode', 'off', '--yes', + ]); + const { logInfo } = require('../utils/logger'); + expect(runLocalAction).toHaveBeenCalledTimes(1); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Secret may already have been written')); + expect(process.exitCode).toBe(1); + }); + + it('reports a possible pre-Apply GitHub mutation if temporary health workflow creation was attempted', async () => { + mockSetupCredentialsCollect.mockImplementationOnce(async () => { + mockSetupCredentialsOptions.mock.lastCall?.[0].onTemporaryWorkflowMutationAttempt(); + throw new Error('Could not safely remove the temporary credential health workflow'); + }); + await program.parseAsync([ + 'node', 'cli', 'setup', '--token', 'ghp_abcdefghijklmnopqrstuvwxyz12', + '--skip-secrets', '--non-interactive', '--pr-approval-mode', 'off', '--yes', + ]); + const { logInfo } = require('../utils/logger'); + expect(logInfo.mock.calls.flat().join('\n')).toContain('temporary credential-health workflow create was attempted before Apply'); + expect(logInfo.mock.calls.flat().join('\n')).not.toContain('No changes were applied'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('shows a partial journey when terminal credential validation may have mutated GitHub', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + mockSetupCredentialsCollect.mockImplementationOnce(async () => { + mockSetupCredentialsOptions.mock.lastCall?.[0].onTemporaryWorkflowMutationAttempt(); + throw new Error('Could not safely remove the temporary credential health workflow'); + }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Partial: changes may exist'); + expect(output).not.toContain('Partial: application started'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('does not claim a clean cancellation after temporary workflow creation was attempted', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const { SetupTerminalCancelledError } = require('../cli/setup_credential_prompt_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + mockSetupCredentialsCollect.mockImplementationOnce(async () => { + mockSetupCredentialsOptions.mock.lastCall?.[0].onTemporaryWorkflowMutationAttempt(); + throw new SetupTerminalCancelledError(); + }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo.mock.calls.flat().join('\n')).toContain('Setup stopped after a possible credential-health workflow change'); + expect(logInfo.mock.calls.flat().join('\n')).not.toContain('Setup cancelled. No changes were applied.'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Partial: changes may exist'); + expect(process.exitCode).toBe(130); + } finally { createTerminal.mockRestore(); } + }); + it.each([ { ready: false, identityStatus: 'valid' as const }, { ready: true, identityStatus: 'invalid' as const }, diff --git a/src/__tests__/cli_context_branch.test.ts b/src/__tests__/cli_context_branch.test.ts new file mode 100644 index 000000000..50c739a4d --- /dev/null +++ b/src/__tests__/cli_context_branch.test.ts @@ -0,0 +1,41 @@ +import { execFileSync, execSync } from 'child_process'; +import { getCurrentAttachedBranch, hasLocalOrTrackedGitBranch } from '../cli_context'; + +jest.mock('child_process', () => ({ execSync: jest.fn(), execFileSync: jest.fn() })); + +describe('verified branch for web setup', () => { + afterEach(() => jest.clearAllMocks()); + + test('returns the attached branch from the requested checkout', () => { + (execSync as jest.Mock).mockReturnValue(Buffer.from('develop\n')); + expect(getCurrentAttachedBranch('/a/checkout')).toBe('develop'); + expect(execSync).toHaveBeenCalledWith('git symbolic-ref --quiet --short HEAD', { cwd: '/a/checkout' }); + }); + + test.each([['', undefined], ['HEAD', undefined]])('rejects an unusable branch value %j', (output, expected) => { + (execSync as jest.Mock).mockReturnValue(Buffer.from(output)); + expect(getCurrentAttachedBranch('/a/checkout')).toBe(expected); + }); + + test('returns no branch for a detached HEAD or failed git read', () => { + (execSync as jest.Mock).mockImplementation(() => { throw new Error('detached'); }); + expect(getCurrentAttachedBranch('/a/checkout')).toBeUndefined(); + }); + + test('labels a branch observed in a local or origin-tracking ref only', () => { + (execFileSync as jest.Mock).mockImplementation((_command, args: string[]) => { + if (args[3] === 'refs/heads/develop') throw new Error('missing'); + return Buffer.alloc(0); + }); + expect(hasLocalOrTrackedGitBranch('/a/checkout', 'develop')).toBe(true); + expect(execFileSync).toHaveBeenCalledWith('git', ['show-ref', '--verify', '--quiet', 'refs/remotes/origin/develop'], + { cwd: '/a/checkout', stdio: 'pipe' }); + }); + + test('never treats unsafe input or a missing branch as observed', () => { + expect(hasLocalOrTrackedGitBranch('/a/checkout', 'bad..branch')).toBe(false); + expect(execFileSync).not.toHaveBeenCalled(); + (execFileSync as jest.Mock).mockImplementation(() => { throw new Error('missing'); }); + expect(hasLocalOrTrackedGitBranch('/a/checkout', 'develop')).toBe(false); + }); +}); diff --git a/src/__tests__/cli_context_root.test.ts b/src/__tests__/cli_context_root.test.ts new file mode 100644 index 000000000..181d9639d --- /dev/null +++ b/src/__tests__/cli_context_root.test.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getGitRepositoryRoot, isGitRepositoryRoot } from '../cli_context'; + +describe('canonical checkout root for setup plans', () => { + test('repository-relative files resolve from the root even when launched in a child directory', () => { + const checkout = mkdtempSync(join(tmpdir(), 'copilot-setup-root-test-')); + try { + execFileSync('git', ['init', '-q', checkout]); + const child = join(checkout, 'nested'); + mkdirSync(child); + expect(getGitRepositoryRoot(child)).toBe(realpathSync(checkout)); + expect(isGitRepositoryRoot(child)).toBe(false); + expect(isGitRepositoryRoot(checkout)).toBe(true); + } finally { + rmSync(checkout, { recursive: true, force: true }); + } + }); + + test('an unrelated directory has no checkout root', () => { + const outside = mkdtempSync(join(tmpdir(), 'copilot-not-a-checkout-')); + try { + expect(() => getGitRepositoryRoot(outside)).toThrow(); + expect(isGitRepositoryRoot(outside)).toBe(false); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/src/application/contracts/web_setup_view.ts b/src/application/contracts/web_setup_view.ts new file mode 100644 index 000000000..6d9f1f9c8 --- /dev/null +++ b/src/application/contracts/web_setup_view.ts @@ -0,0 +1,108 @@ +import type { SetupJourneyView } from '../policies/setup_journey_policy'; +import type { SetupQuestion, SetupQuestionnaireProgress } from '../../domain/setup_questionnaire'; +export type { SetupApprovalCheckCandidate, SetupProjectCandidate, SetupDiscoveryStatus } from '../../domain/setup_questionnaire'; +export type { SetupQuestion } from '../../domain/setup_questionnaire'; +export type { SetupFeature } from '../../domain/setup'; +import type { SetupTokenPermissionReport, SetupTokenPermissionRequirement, SetupTokenRole } from '../../domain/setup_token_permissions'; +import type { SetupCredentialStatus } from '../../domain/setup'; + +export interface SetupQuestionExplanation { + readonly label: string; + readonly summary: string; + readonly when: string; + readonly where: string; + readonly how: string; + readonly why: string; + readonly example: string; + readonly effect: string; + readonly verify: string; + readonly documentation: { readonly title: string; readonly url: string }; +} + +export type SetupQuestionPresentation = Readonly>; + +export type WebSetupPromptCopyId = + | 'repository.confirm' | 'setup.depth' | 'setup.environmentPat' | 'plan.review' | 'workflow.update' + | 'setupPat.method' | 'setupPat.ownerKind' | 'setupPat.review' | 'setupPat.entry' + | 'setupPat.confirmAccount' | 'setupPat.confirmWrites' | 'botPat.method' | 'botPat.login' + | 'botPat.entry.guided' | 'botPat.entry.manual' | 'credential.apiKey' + | 'credential.existing' | 'apply.confirm'; + +export interface WebSetupPromptCopyRef { + readonly copyId?: WebSetupPromptCopyId; + readonly copyValues?: Readonly>; +} + +export type WebSetupMessageCopyId = + | 'session.controlMoved' | 'session.cancelled' | 'plan.ready' | 'permission.preview' + | 'setupPat.corrected.bootstrap' | 'setupPat.corrected.final' | 'setupPat.cleanup' + | 'botPat.separation' | 'credential.checks' + | 'credential.status.valid' | 'credential.status.invalid' | 'credential.status.missing' + | 'credential.status.unverifiable' | 'credential.status.not_required' + | 'validation.producers' | 'validation.duplicateNames' | 'validation.number' | 'validation.boolean' + | 'validation.choice' | 'validation.projectStatusVerified' | 'validation.projectStatusRedo' + | 'validation.unknownResource' | 'validation.unknownWorkflow' | 'validation.firstQuestion' + | 'validation.duplicateProducer' | 'validation.savedStatus' | 'validation.projectIncompatible' + | 'validation.projectLimit' | 'validation.projectOwnerNeeded' | 'validation.projectOwnerMismatch' + | 'validation.projectUrl' | 'validation.projectNumber' | 'validation.projectNumberRange' + | 'validation.projectDuplicate' | 'validation.unknown' + | 'validation.fixedIssues' | 'validation.fixedWorkflowEnabled' | 'validation.fixedWorkflowDisabled'; + +export type WebSetupPrompt = + | ({ kind: 'question'; title: string; question: SetupQuestion; presentation?: SetupQuestionPresentation; phase: string; pass: number; + progress?: SetupQuestionnaireProgress; canGoBack?: boolean } & WebSetupPromptCopyRef) + | ({ kind: 'choice'; title: string; description?: string; choices: readonly string[]; defaultValue?: string } & WebSetupPromptCopyRef) + | ({ kind: 'text' | 'secret'; title: string; description?: string; optional?: boolean; link?: string } & WebSetupPromptCopyRef) + | ({ kind: 'confirm'; title: string; description?: string; choices: readonly string[] } & WebSetupPromptCopyRef) + | ({ kind: 'plan'; title: string; plan: WebSetupPlan; editGroups?: readonly SetupQuestion['stateId'][] } & WebSetupPromptCopyRef); + +export interface WebSetupPlan { + readonly presentationDefaults: readonly { readonly group: string; readonly count: number }[]; + readonly decisions: { + readonly enabledCapabilities: readonly string[]; + readonly agentRouting: readonly { readonly role: string; readonly provider: string; readonly modelProvider: string; readonly model: string }[]; + readonly issueWorkflows: readonly string[]; + readonly productionBranch: string; + readonly developmentBranch: string; + readonly approvalMode: string; + readonly trustedChecks: readonly { readonly name: string; readonly sourceAppId: number; readonly workflowName: string }[]; + readonly producerAttested: boolean; + readonly coverageMode: string; + readonly coverageCheck: string; + readonly coverageMinimum?: number; + readonly coverageArtifactWorkflow?: string; + readonly coverageReporterAttested?: boolean; + readonly projectNumbers: readonly string[]; + readonly projectStatuses: readonly { readonly transition: string; readonly value: string }[]; + readonly variableScope: string; + readonly secretScope: string; + readonly initialTag: boolean; + }; + readonly files: readonly string[]; + readonly workflows: readonly string[]; + readonly variables: readonly string[]; + readonly secrets: readonly string[]; + readonly warnings: readonly string[]; +} + +export interface WebSetupView { + readonly revision: number; + readonly promptRevision?: number; + readonly repository: string; + readonly journey?: SetupJourneyView; + readonly prompt?: WebSetupPrompt; + readonly message?: { tone: 'info' | 'success' | 'warning' | 'error'; text: string; link?: string; + copyId?: WebSetupMessageCopyId; copyValues?: Readonly>; + credentialChecks?: readonly { readonly name: string; readonly status: SetupCredentialStatus }[] }; + readonly permissions?: { role: SetupTokenRole; requirements?: readonly SetupTokenPermissionRequirement[]; report?: SetupTokenPermissionReport }; + readonly outcome?: 'complete' | 'partial' | 'blocked' | 'cancelled' | 'dry-run'; + readonly doctor?: { readonly status: 'running' | 'complete' | 'failed'; readonly healthy?: boolean; + readonly pass?: number; readonly warn?: number; readonly fail?: number; readonly skipped?: number }; + readonly resultDetail?: { + readonly reasonCode: 'permissions' | 'storage' | 'configuration' | 'session-expired' | 'cancelled' | 'provider' | 'rate-limit' | 'unknown'; + readonly stoppedStage: string; + readonly mutationStarted: boolean; + readonly diagnosticRef?: string; + readonly effects?: readonly { readonly id: string; readonly state: 'completed' | 'skipped' | 'needs-inspection' | 'not-started'; readonly scope?: 'local' | 'repository' | 'organization' | 'mixed' }[]; + }; +} diff --git a/src/application/errors/setup_interaction_cancelled_error.ts b/src/application/errors/setup_interaction_cancelled_error.ts new file mode 100644 index 000000000..16e033379 --- /dev/null +++ b/src/application/errors/setup_interaction_cancelled_error.ts @@ -0,0 +1,7 @@ +/** Shared cancellation signal for terminal and browser setup presenters. */ +export class SetupInteractionCancelledError extends Error { + constructor() { + super('Setup input was cancelled.'); + this.name = 'SetupInteractionCancelledError'; + } +} diff --git a/src/application/policies/__tests__/setup_configuration_policy.test.ts b/src/application/policies/__tests__/setup_configuration_policy.test.ts index 27fe2c190..89367fdfe 100644 --- a/src/application/policies/__tests__/setup_configuration_policy.test.ts +++ b/src/application/policies/__tests__/setup_configuration_policy.test.ts @@ -550,6 +550,19 @@ describe('setup configuration policy', () => { ]); }); + it('rejects opaque Project IDs and invalid Status option names before Apply', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.projects.ids = 'PVT_example'; + expect(validateSetupConfiguration(configuration)).toContain( + 'Project IDs must be a comma-separated list of 1–10 distinct positive Project URL numbers; PVT_ node IDs are not accepted.', + ); + configuration.projects.ids = '2'; + configuration.projects.issueCreatedColumn = 'Invalid\nStatus'; + expect(validateSetupConfiguration(configuration)).toContain( + 'Project issueCreatedColumn must name one existing single-line Status option (1–100 characters).', + ); + }); + it('adds warnings for organization storage, projects, and always-provision mode', () => { const configuration = createDefaultSetupConfiguration(); configuration.features.release = false; @@ -560,7 +573,7 @@ describe('setup configuration policy', () => { configuration.agents.findings.provider = 'cursor'; expect(buildSetupPlan(configuration).warnings).toEqual(expect.arrayContaining([ - expect.stringContaining('Project IDs'), + expect.stringContaining('Selected Project numbers'), expect.stringContaining('Always-provision mode reinstalls only default Codex/OpenCode runtimes'), expect.stringContaining('no automatic Cursor installer'), expect.stringContaining('Organization-level'), diff --git a/src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts b/src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts new file mode 100644 index 000000000..dd16619ce --- /dev/null +++ b/src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts @@ -0,0 +1,106 @@ +import { buildSetupPatCreationUrl, UnsupportedSetupPatLinkError } from '../setup_pat_creation_url_policy'; +import type { SetupTokenPermissionRequirement } from '../../../domain/setup_token_permissions'; + +const permission = ( + role: 'setup' | 'workflow', + scope: 'repository' | 'organization', + name: string, + level: 'read' | 'write', + applicability: 'required' | 'conditional' = 'required', +): SetupTokenPermissionRequirement => ({ + id: `${role}.${scope}.${name}`, role, scope, permission: name, level, applicability, + reason: 'test', probe: 'metadata', +}); + +describe('buildSetupPatCreationUrl', () => { + it('fills only required setup grants, owner, role, and one-day expiry', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [ + permission('setup', 'repository', 'Metadata', 'read'), + permission('setup', 'repository', 'Contents', 'read'), + permission('setup', 'repository', 'Secrets', 'write', 'conditional'), + ], + })); + expect(`${url.origin}${url.pathname}`).toBe('https://github.com/settings/personal-access-tokens/new'); + expect(Object.fromEntries(url.searchParams)).toEqual({ + name: 'Copilot setup copilot', + description: 'Copilot repository setup for vypdev/copilot', + target_name: 'vypdev', expires_in: '1', contents: 'read', metadata: 'read', + }); + expect(url.searchParams.has('repository')).toBe(false); + }); + + it('maps repository and organization bot grants without conflating scopes', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'workflow', owner: 'vypdev', repository: 'copilot', expiresIn: 90, + requirements: [ + permission('workflow', 'repository', 'Variables', 'read'), + permission('workflow', 'repository', 'Pull requests', 'write'), + permission('workflow', 'organization', 'Variables', 'read'), + permission('workflow', 'organization', 'Projects', 'write'), + permission('workflow', 'organization', 'Members', 'read'), + ], + })); + expect(url.searchParams.get('actions_variables')).toBe('read'); + expect(url.searchParams.get('organization_actions_variables')).toBe('read'); + expect(url.searchParams.get('organization_projects')).toBe('write'); + expect(url.searchParams.get('pull_requests')).toBe('write'); + expect(url.searchParams.get('members')).toBe('read'); + expect(url.searchParams.get('expires_in')).toBe('90'); + }); + + it('keeps the strongest duplicate grant', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [permission('setup', 'repository', 'Contents', 'write'), permission('setup', 'repository', 'Contents', 'read')], + })); + expect(url.searchParams.get('contents')).toBe('write'); + }); + + it('keeps even the largest valid owner, repository, and grant set within a practical terminal URL', () => { + const grants = [ + ...['Metadata', 'Contents', 'Secrets', 'Variables', 'Issues', 'Actions', 'Checks', 'Administration', 'Workflows', 'Pull requests'] + .map(name => permission('workflow', 'repository', name, name === 'Metadata' ? 'read' : 'write')), + ...['Secrets', 'Variables', 'Issue Types', 'Projects', 'Members'] + .map(name => permission('workflow', 'organization', name, 'write')), + ]; + const url = buildSetupPatCreationUrl({ + role: 'workflow', owner: 'a'.repeat(39), repository: 'r'.repeat(100), expiresIn: 366, + requirements: grants, + }); + expect(url.length).toBeLessThan(2_048); + }); + + it('offers Checks read on setup and bot links while still requiring GitHub form and token audit', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'workflow', owner: 'vypdev', repository: 'copilot', expiresIn: 90, + requirements: [permission('workflow', 'repository', 'Checks', 'read')], + })); + expect(url.searchParams.get('checks')).toBe('read'); + }); + + it.each([ + ['Metadata', 'write'], ['Workflows', 'read'], + ] as const)('rejects an unsupported %s %s access level', (name, level) => { + expect(() => buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [permission('setup', 'repository', name, level)], + })).toThrow(UnsupportedSetupPatLinkError); + }); + + it.each(['bad/owner', '', 'a'.repeat(40)])('rejects unsafe or invalid owner %s', owner => { + expect(() => buildSetupPatCreationUrl({ role: 'setup', owner, repository: 'copilot', expiresIn: 1, requirements: [] })).toThrow(); + }); + + it.each([0, 367, 1.5])('rejects invalid expiration %s', expiresIn => { + expect(() => buildSetupPatCreationUrl({ role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn, requirements: [] })).toThrow(); + }); + + it('rejects a mixed-role permission list', () => { + expect(() => buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [permission('workflow', 'repository', 'Contents', 'read')], + })).toThrow('role'); + }); +}); diff --git a/src/application/policies/__tests__/setup_pat_intent_policy.test.ts b/src/application/policies/__tests__/setup_pat_intent_policy.test.ts new file mode 100644 index 000000000..cc68126a9 --- /dev/null +++ b/src/application/policies/__tests__/setup_pat_intent_policy.test.ts @@ -0,0 +1,176 @@ +import { createDefaultSetupConfiguration } from '../setup_configuration_policy'; +import { fixedSetupPatIntentQuestionIds, setupPatIntentNeedsOwnerKind, setupPatIntentOwnerConflict } from '../setup_pat_intent_policy'; +import { buildSetupPatIntentPermissionRequirements, buildSetupPatIntentUncertainty } from '../setup_token_permission_policy'; +import { buildSetupPatCreationUrl } from '../setup_pat_creation_url_policy'; + +const grants = (configuration: ReturnType, owner: 'Organization' | 'User') => + buildSetupPatIntentPermissionRequirements(configuration, owner).map(item => `${item.scope}:${item.permission}:${item.level}`); + +describe('setup PAT permission intent', () => { + it('turns selected local operations into exact repository and organization grants', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.projects.ids = 'PVT_example'; + configuration.storage.secrets.defaultScope = 'organization'; + expect(grants(configuration, 'Organization')).toEqual(expect.arrayContaining([ + 'repository:Metadata:read', 'repository:Contents:write', 'repository:Secrets:write', + 'repository:Variables:write', 'repository:Issues:write', 'repository:Administration:read', + 'organization:Secrets:write', 'organization:Issue Types:write', 'organization:Projects:read', + ])); + expect(grants(configuration, 'Organization')).not.toContain('repository:Actions:write'); + expect(grants(configuration, 'Organization')).not.toContain('repository:Workflows:write'); + }); + + it('reduces to metadata and contents read when all optional setup operations are off', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.createInitialTag = false; + configuration.manageRepositorySecrets = false; + configuration.manageRepositoryVariables = false; + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + expect(grants(configuration, 'User')).toEqual(['repository:Metadata:read', 'repository:Contents:read']); + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(false); + }); + + it('separates remote-only credential health and inherited inventory from required grants', () => { + const configuration = createDefaultSetupConfiguration(); + const unknown = buildSetupPatIntentUncertainty(configuration, 'Organization'); + expect(unknown.join(' ')).toContain('Actions write'); + expect(unknown.join(' ')).toContain('Workflows write'); + expect(unknown.join(' ')).toContain('organization Secrets write'); + expect(unknown.join(' ')).toContain('organization Variables write'); + expect(grants(configuration, 'Organization')).not.toContain('repository:Actions:write'); + expect(grants(configuration, 'Organization')).not.toContain('organization:Secrets:write'); + }); + + it('flags contradictory personal ownership and explicit organization targets', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.storage.variables.defaultScope = 'organization'; + expect(setupPatIntentOwnerConflict(configuration, 'User')).toBe(true); + expect(setupPatIntentOwnerConflict(configuration, 'Organization')).toBe(false); + configuration.storage.variables.defaultScope = 'repository'; + configuration.projects.ids = 'PVT_example'; + expect(setupPatIntentOwnerConflict(configuration, 'User')).toBe(true); + }); + + it('does not ask choices fixed by config or skip flags', () => { + const fixed = fixedSetupPatIntentQuestionIds({ + features: { issues: false }, + issueWorkflows: { enabled: [] }, + storage: { variables: { defaultScope: 'organization' } }, + createInitialTag: false, + }, true, true); + expect(fixed).toEqual(expect.arrayContaining([ + 'features.issues', 'issueWorkflows.enabled', 'storage.variables.defaultScope', + 'createInitialTag', 'manageRepositoryVariables', 'manageRepositorySecrets', + ])); + }); + + it('records explicit release and hotfix feature overrides as fixed intent inputs', () => { + expect(fixedSetupPatIntentQuestionIds({ features: { release: false, hotfix: true } }, false, false)) + .toEqual(expect.arrayContaining(['features.release', 'features.hotfix'])); + }); + + it('recognizes fixed approval, Projects, and preservation values without treating defaults as fixed', () => { + expect(fixedSetupPatIntentQuestionIds({}, false, false)).toEqual([]); + expect(fixedSetupPatIntentQuestionIds({ + pullRequestApproval: { mode: 'off' }, projects: { ids: '' }, + storage: { secrets: { preserveExisting: false }, variables: { preserveExisting: true } }, + }, false, false)).toEqual(expect.arrayContaining([ + 'pullRequestApproval.mode', 'projects.enabled', 'projects.ids', + 'storage.secrets.preserveExisting', 'storage.variables.preserveExisting', + ])); + }); + + it('asks owner kind for possible inherited resources even with no definite organization grant', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + expect(grants(configuration, 'Organization').some(item => item.startsWith('organization:'))).toBe(false); + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(true); + configuration.manageRepositorySecrets = false; + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(true); + configuration.storage.variables.preserveExisting = false; + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(false); + configuration.storage.variables.preserveExisting = true; + configuration.manageRepositoryVariables = false; + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(false); + }); + + it('asks owner kind for Projects even when all other organization grants are disabled', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.createInitialTag = false; + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + configuration.manageRepositorySecrets = false; + configuration.manageRepositoryVariables = false; + configuration.projects.ids = 'PVT_example'; + + expect(grants(configuration, 'Organization').filter(item => item.startsWith('organization:'))) + .toEqual(['organization:Projects:read']); + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(true); + expect(setupPatIntentOwnerConflict(configuration, 'User')).toBe(true); + expect(setupPatIntentOwnerConflict(configuration, 'Organization')).toBe(false); + + configuration.projects.ids = ' '; + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(false); + }); + + it('omits unresolved remote conditions when management is disabled or owner is personal', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.manageRepositorySecrets = false; + expect(buildSetupPatIntentUncertainty(configuration, 'User')).toEqual([]); + expect(buildSetupPatIntentUncertainty(configuration, 'Organization')).toEqual([ + expect.stringContaining('organization Variables write'), + ]); + configuration.manageRepositoryVariables = false; + expect(buildSetupPatIntentUncertainty(configuration, 'Organization')).toEqual([]); + }); + + it('does not predict organization inventory for explicitly organization-scoped defaults', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.storage.secrets.defaultScope = 'organization'; + configuration.storage.variables.preserveExisting = false; + expect(buildSetupPatIntentUncertainty(configuration, 'Organization')).toEqual([ + expect.stringContaining('Actions write'), + ]); + }); + + it('projects the reviewed grants to documented URL parameters without selecting a repository', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + const requirements = buildSetupPatIntentPermissionRequirements(configuration, 'User'); + const url = new URL(buildSetupPatCreationUrl({ role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, requirements })); + expect(Object.fromEntries(url.searchParams)).toEqual(expect.objectContaining({ + metadata: 'read', contents: 'write', secrets: 'write', actions_variables: 'write', + })); + expect(url.searchParams.has('issues')).toBe(false); + expect(url.searchParams.has('repository')).toBe(false); + }); + + it('prefills the six grants in the reviewed organization example', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.createInitialTag = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.issueWorkflows.enabled = ['feature']; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + const requirements = buildSetupPatIntentPermissionRequirements(configuration, 'Organization'); + expect(requirements.map(item => `${item.scope}:${item.permission}:${item.level}`)).toEqual([ + 'repository:Metadata:read', 'repository:Contents:read', 'repository:Secrets:write', + 'repository:Variables:write', 'repository:Issues:write', 'organization:Issue Types:write', + ]); + const url = new URL(buildSetupPatCreationUrl({ role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, requirements })); + expect(Object.fromEntries([...url.searchParams].filter(([key]) => !['name', 'description', 'target_name', 'expires_in'].includes(key)))).toEqual({ + actions_variables: 'write', contents: 'read', issue_types: 'write', issues: 'write', metadata: 'read', secrets: 'write', + }); + }); +}); diff --git a/src/application/policies/__tests__/setup_project_selection_policy.test.ts b/src/application/policies/__tests__/setup_project_selection_policy.test.ts new file mode 100644 index 000000000..be2920e79 --- /dev/null +++ b/src/application/policies/__tests__/setup_project_selection_policy.test.ts @@ -0,0 +1,66 @@ +import { parseSetupProjectSelection, sharedProjectStatusOptions, validateDiscoveredProjectStatuses } from '../setup_project_selection_policy'; +import { buildInitialSetupConfiguration } from '../../usecases/setup/setup_wizard_use_case'; + +describe('setup Project selection', () => { + test.each([ + ['', ''], ['none', ''], ['1,2', '1,2'], + ['https://github.com/orgs/acme/projects/5', '5'], + ['3, https://github.com/orgs/ACME/projects/7', '3,7'], + ])('normalizes %s to numeric Project URL numbers', (input, expected) => { + expect(parseSetupProjectSelection(input, 'acme')).toEqual({ value: expected }); + }); + + test.each([ + 'PVT_kwDOExample', '0', '-2', '1,1', '1,01', + 'https://github.com/orgs/other/projects/5', + 'https://github.com/orgs/acme/projects/5?token=secret', + 'https://evil.example/orgs/acme/projects/5', + 'https://github.com/orgs/%ZZ/projects/5', + '2147483648', '1,,2', + ])('rejects invalid or misleading Project selection %s', input => { + expect(parseSetupProjectSelection(input, 'acme')).toHaveProperty('error'); + }); + + test('rejects more than ten Projects', () => { + expect(parseSetupProjectSelection(Array.from({ length: 11 }, (_, index) => String(index + 1)).join(','))).toHaveProperty('error'); + }); + + test('a Project URL requires a known owner', () => { + expect(parseSetupProjectSelection('https://github.com/orgs/acme/projects/5')).toHaveProperty('error'); + }); + + test('uses only Status options common to all selected Projects', () => { + expect(sharedProjectStatusOptions('', [])).toEqual({ state: 'unavailable', options: [] }); + expect(sharedProjectStatusOptions('2,3', [ + { number: 2, statusOptions: ['Todo', 'In Progress'] }, + { number: 3, statusOptions: ['In Progress', 'Done'] }, + ])).toEqual({ state: 'observed', options: ['In Progress'] }); + expect(sharedProjectStatusOptions('2,3', [ + { number: 2, statusOptions: ['Todo'] }, { number: 3, statusOptions: ['Done'] }, + ])).toEqual({ state: 'incompatible', options: [] }); + expect(sharedProjectStatusOptions('2,4', [{ number: 2, statusOptions: ['Todo'] }])) + .toEqual({ state: 'unavailable', options: [] }); + }); + + test('validates configured Status values against discovered Projects without trusting incomplete discovery', () => { + const base = buildInitialSetupConfiguration({ mode: 'interactive' }); + const configuration = { ...base, projects: { ...base.projects, ids: '2,3' } }; + const candidate = (number: number, statusOptions?: string[]) => ({ number, owner: 'acme', title: String(number), + url: `https://github.com/orgs/acme/projects/${number}`, statusOptions }); + expect(validateDiscoveredProjectStatuses(configuration)).toEqual([]); + expect(validateDiscoveredProjectStatuses(configuration, { status: 'permission-denied', candidates: [] })).toEqual([]); + expect(validateDiscoveredProjectStatuses({ ...configuration, projects: { ...configuration.projects, ids: '' } }, + { status: 'observed', candidates: [candidate(2)] })).toEqual([]); + expect(validateDiscoveredProjectStatuses(configuration, { status: 'observed', candidates: [candidate(2, ['Todo'])] })).toEqual([]); + expect(validateDiscoveredProjectStatuses(configuration, { status: 'observed', candidates: [ + candidate(2, ['Todo']), candidate(3, ['In Progress']), + ] })).toEqual(['Selected Projects have no common Status option. Choose compatible Projects.']); + expect(validateDiscoveredProjectStatuses(configuration, { status: 'observed', candidates: [ + candidate(2, ['Todo']), candidate(3, ['Todo']), + ] })).toEqual([ + 'Status value "In Progress" is not available in every selected Project.', + 'Status value "In Progress" is not available in every selected Project.', + 'Status value "In Progress" is not available in every selected Project.', + ]); + }); +}); diff --git a/src/application/policies/__tests__/setup_question_documentation_policy.test.ts b/src/application/policies/__tests__/setup_question_documentation_policy.test.ts new file mode 100644 index 000000000..6ef12d6ee --- /dev/null +++ b/src/application/policies/__tests__/setup_question_documentation_policy.test.ts @@ -0,0 +1,49 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { SetupQuestion } from '../../../domain/setup_questionnaire'; +import { setupQuestionDocumentation } from '../setup_question_documentation_policy'; + +describe('setup question documentation', () => { + test.each([ + ['features.issues', 'capabilities', '/issues/workflow-setup'], + ['features.pullRequests', 'capabilities', '/pull-requests/workflow-setup'], + ['issueWorkflows.enabled', 'capabilities', '/issues/workflow-setup'], + ['repositoryAgentGuidance.enabled', 'capabilities', '/agents/repository-collaboration'], + ['agents.planner.provider', 'agent-runtime', '/agents/runtime-selection'], + ['agents.findings.executable', 'agent-model-defaults', '/agents/cli-configuration'], + ['agents.findings.model', 'agent-model-defaults', '/agents/model-selection'], + ['repository.mainBranch', 'repository', '/configuration'], + ['repository.preBranchSdd', 'repository', '/issues/pre-branch-sdds'], + ['repository.issueManagedBranches', 'repository', '/issues/branch-management'], + ['repository.inactivityThresholdHours', 'repository', '/issues/notifications-and-auto-close'], + ['repository.desiredAssigneesCount', 'repository', '/issues/assignees-and-projects'], + ['repository.releaseReconciliationStrategy', 'deployment', '/issues/deployment-orchestration'], + ['ai.bugbotSeverity', 'bugbot', '/bugbot/configuration'], + ['ai.bugbotFixVerifyCommands', 'bugbot', '/bugbot/verification-commands'], + ['ai.pullRequestDescriptionMode', 'bugbot', '/pull-requests/ai-description'], + ['projects.enabled', 'projects', '/issues/assignees-and-projects'], + ['manageRepositorySecrets', 'provisioning', '/how-to-use'], + ])('%s has a related, locally documented destination', (id, stateId, path) => { + const reference = setupQuestionDocumentation({ id, stateId: stateId as SetupQuestion['stateId'] }); + expect(reference.title.trim()).not.toBe(''); + expect(reference.url).toBe(`https://docs.page/vypdev/copilot${path}`); + expect(existsSync(resolve(__dirname, '../../../../docs', `${path.slice(1)}.mdx`))).toBe(true); + }); + + test('storage questions point to the official GitHub Actions resource guide', () => { + const reference = setupQuestionDocumentation({ id: 'storage.secrets.defaultScope', stateId: 'storage' }); + expect(reference.url).toBe('https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets'); + }); + + test('trusted check selection links directly to the official status-check explanation', () => { + expect(setupQuestionDocumentation({ id: 'pullRequestApproval.testChecks', stateId: 'pull-request-approval' }).url) + .toBe('https://docs.github.com/en/pull-requests/reference/status-checks'); + }); + + test('Project selection and Status options link to official GitHub explanations', () => { + expect(setupQuestionDocumentation({ id: 'projects.ids', stateId: 'projects' }).url) + .toBe('https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects'); + expect(setupQuestionDocumentation({ id: 'projects.issueCreatedColumn', stateId: 'projects' }).url) + .toBe('https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields/about-single-select-fields'); + }); +}); diff --git a/src/application/policies/__tests__/setup_question_purpose_policy.test.ts b/src/application/policies/__tests__/setup_question_purpose_policy.test.ts new file mode 100644 index 000000000..ea0293e57 --- /dev/null +++ b/src/application/policies/__tests__/setup_question_purpose_policy.test.ts @@ -0,0 +1,99 @@ +import type { SetupQuestion } from '../../../domain/setup_questionnaire'; +import { setupQuestionPurpose, setupQuestionPurposes } from '../setup_question_purpose_policy'; +import { setupQuestionPresentation } from '../setup_question_guidance_policy'; +import { setupQuestionContentInventory } from '../setup_questionnaire_policy'; +import { questionLabelsFrPt } from '../setup_question_labels_fr_pt'; +import { spanishQuestionLabels } from '../setup_question_translations'; +import { purposesFrPt } from '../setup_question_purpose_fr_pt'; + +function question(id: string, stateId: SetupQuestion['stateId'], label = id): SetupQuestion { + return { id, stateId, label, kind: 'text', defaultValue: '' }; +} + +describe('setup question purpose', () => { + test.each([ + ['repository.repositoryLocale', 'repository', 'does not change the setup page language'], + ['repository.reconciliationPullRequestMode', 'deployment', 'merged automatically'], + ['ai.bugbotFixVerifyCommands', 'bugbot', 'must pass'], + ['pullRequestApproval.coverage.reporterAttested', 'pull-request-approval', 'not just its green check'], + ['manageRepositorySecrets', 'provisioning', 'bot PAT'], + ] as const)('%s explains the exact field, not just its section', (id, stateId, expected) => { + const presentation = setupQuestionPresentation(question(id, stateId)); + expect(presentation.en.summary).toContain(expected); + expect(presentation.es.summary).not.toEqual(presentation.en.summary); + expect(presentation.en.documentation.url).toMatch(/^https:\/\//u); + }); + + test('patterned agent and storage questions have specific explanations', () => { + expect(setupQuestionPurpose(question('agents.tester.provider', 'agent-runtime'))?.en).toContain('agent CLI'); + expect(setupQuestionPurpose(question('storage.secrets.organizationVisibility', 'storage'))?.en).toContain('organization Secrets'); + expect(setupQuestionPurpose(question('projects.issueInProgressColumn', 'projects'))?.en).toContain('Status field option'); + }); + + test('Spanish provisioning guidance states Cursor is a runner prerequisite, not a setup install', () => { + const guidance = setupQuestionPresentation(question('ai.provisioningMode', 'agent-runtime')); + expect(guidance.es.effect).toContain('Cursor debe estar preinstalado en el runner'); + expect(guidance.es.effect).not.toContain('se instala aparte'); + }); + + test('unknown questions retain section guidance without inventing semantics', () => { + const presentation = setupQuestionPresentation(question('future.question', 'bugbot')); + expect(presentation.en.summary).toContain('Bugbot'); + expect(setupQuestionPurpose(question('future.question', 'bugbot'))).toBeUndefined(); + }); + + test('every defined question has a complete four-language help contract', () => { + const questions = setupQuestionContentInventory(); + expect(questions.length).toBeGreaterThan(100); + expect(new Set(questions.map(item => item.id)).size).toBe(questions.length); + const detailed = new Set([ + 'agents.findings.executable', 'ai.includeReasoning', 'ai.bugbotDryRun', + 'ai.bugbotOrganizationRules', 'ai.provisioningMode', + 'pullRequestApproval.testChecks', 'pullRequestApproval.producerAttested', + 'pullRequestApproval.coverage.mode', 'pullRequestApproval.coverage.checkName', + ]); + for (const item of questions) { + expect(Boolean(setupQuestionPurpose(item)) || detailed.has(item.id)).toBe(true); + const presentation = setupQuestionPresentation(item); + for (const locale of ['en', 'es', 'fr', 'pt'] as const) { + const content = presentation[locale]; + for (const field of ['label', 'summary', 'when', 'where', 'how', 'why', 'example', 'effect', 'verify'] as const) { + expect(content[field].trim()).not.toBe(''); + if (locale !== 'en') expect(content[field]).not.toEqual(presentation.en[field]); + } + expect(content.documentation.url).toMatch(/^https:\/\/(docs\.page|docs\.github\.com)\//u); + } + } + }); + + test('high-risk choices explain the concrete verification action in every language', () => { + const cases: readonly [string, SetupQuestion['stateId'], string][] = [ + ['pullRequestApproval.coverage.checkName', 'pull-request-approval', 'job'], + ['pullRequestApproval.producerAttested', 'pull-request-approval', 'App'], + ['pullRequestApproval.coverage.artifactWorkflowName', 'pull-request-approval', 'copilot-diff-coverage-v1'], + ['projects.issueCreatedColumn', 'projects', 'Status'], + ]; + for (const [id, stateId, technicalTerm] of cases) { + const help = setupQuestionPresentation(question(id, stateId)); + for (const locale of ['en', 'es', 'fr', 'pt'] as const) { + expect(help[locale].how).toContain(technicalTerm); + expect(help[locale].how.length).toBeGreaterThan(90); + } + } + }); + + test('French and Portuguese label keys match the Spanish source inventory exactly', () => { + for (const locale of ['fr', 'pt'] as const) { + expect(Object.keys(questionLabelsFrPt[locale]).sort()).toEqual(Object.keys(spanishQuestionLabels).sort()); + for (const translated of Object.values(questionLabelsFrPt[locale])) expect(translated.trim()).not.toBe(''); + } + }); + + test('French and Portuguese specific-purpose keys match English and Spanish', () => { + const ids = Object.keys(setupQuestionPurposes).sort(); + for (const locale of ['fr', 'pt'] as const) { + expect(Object.keys(purposesFrPt[locale]).sort()).toEqual(ids); + for (const value of Object.values(purposesFrPt[locale])) expect(value.trim()).not.toBe(''); + } + }); +}); diff --git a/src/application/policies/__tests__/setup_questionnaire_policy.test.ts b/src/application/policies/__tests__/setup_questionnaire_policy.test.ts index 0c1a47db4..b0dfbfe14 100644 --- a/src/application/policies/__tests__/setup_questionnaire_policy.test.ts +++ b/src/application/policies/__tests__/setup_questionnaire_policy.test.ts @@ -1,7 +1,14 @@ import { createDefaultSetupConfiguration } from '../setup_configuration_policy'; +import { validateSetupConfiguration } from '../setup_configuration_validation'; import { createSetupQuestionnaire, + createSetupPermissionIntentQuestionnaire, createSetupReviewState, + refreshSetupQuestionnaireQuestion, + reopenSetupQuestionnaireGroup, + setupBasicSkippedQuestionIds, + setupEditableGroups, + setupQuestionnaireProgress, enterSetupConfirmation, finishSetupQuestionnaire, setupQuestionnaireStateLabel, @@ -10,6 +17,204 @@ import { import type { SetupQuestionnaireContext, SetupQuestionnaireState } from '../../../domain/setup_questionnaire'; describe('setup questionnaire policy', () => { + it('basic presentation omits only advanced defaults, never security or mutation choices', () => { + const skipped = setupBasicSkippedQuestionIds(); + expect(skipped).toContain('agents.findings.executable'); + expect(skipped).toContain('ai.bugbotCommentLimit'); + expect(skipped.length).toBeGreaterThan(20); + for (const required of ['features.issues', 'features.pullRequests', 'projects.enabled', 'projects.ids', + 'repository.mainBranch', 'repository.developmentBranch', 'pullRequestApproval.mode', + 'ai.membersOnly', 'ai.bugbotTelemetry', 'ai.bugbotDryRun', 'createInitialTag', + 'manageRepositoryVariables', 'manageRepositorySecrets', 'storage.secrets.defaultScope', + 'storage.variables.defaultScope']) expect(skipped).not.toContain(required); + const configured = createDefaultSetupConfiguration(); + configured.ai.bugbotCommentLimit = 12; + expect(setupBasicSkippedQuestionIds(configured)).not.toContain('ai.bugbotCommentLimit'); + }); + it('reports truthful conditional progress and returns to a saved answer without resetting later values', () => { + const first = createSetupQuestionnaire(createDefaultSetupConfiguration()); + expect(setupQuestionnaireProgress(first, {})).toMatchObject({ position: 1, groupPosition: 1, group: 'capabilities' }); + const second = transitionSetupQuestionnaire(first, { kind: 'answer', value: 'no' }); + expect(second.question?.id).toBe('features.pullRequests'); + expect(setupQuestionnaireProgress(second, {})?.position).toBe(2); + const back = transitionSetupQuestionnaire(second, { kind: 'back' }); + expect(back.question?.id).toBe(first.question?.id); + expect(back.question?.defaultValue).toBe(false); + expect(back.draft.features.issues).toBe(false); + expect(transitionSetupQuestionnaire(back, { kind: 'answer', value: 'yes' }).draft.features.issues).toBe(true); + expect(transitionSetupQuestionnaire(first, { kind: 'back' }).validation).toContain('first question'); + }); + + it('does not invent progress or refresh a question after it disappears from the visible pass', () => { + const initial = createSetupQuestionnaire(createDefaultSetupConfiguration()); + const hidden = { skipQuestionIds: [initial.question!.id] }; + expect(setupQuestionnaireProgress(initial, hidden)).toBeUndefined(); + expect(refreshSetupQuestionnaireQuestion(initial, hidden)).toBe(initial); + const noQuestion = { ...initial, question: undefined }; + expect(setupQuestionnaireProgress(noQuestion, {})).toBeUndefined(); + expect(refreshSetupQuestionnaireQuestion(noQuestion, {})).toBe(noQuestion); + const review = createSetupReviewState(initial.draft); + expect(setupQuestionnaireProgress(review, {})).toBeUndefined(); + expect(refreshSetupQuestionnaireQuestion(review, {})).toBe(review); + expect(reopenSetupQuestionnaireGroup(initial, 'repository', {})).toBeUndefined(); + }); + + it('treats legacy states without a phase as the full questionnaire during refresh and Back', () => { + const first = createSetupQuestionnaire(createDefaultSetupConfiguration()); + const refreshed = refreshSetupQuestionnaireQuestion({ ...first, phase: undefined }, {}); + expect(refreshed.question?.id).toBe(first.question?.id); + const second = transitionSetupQuestionnaire(first, { kind: 'answer', value: '' }); + const previous = transitionSetupQuestionnaire({ ...second, phase: undefined }, { kind: 'back' }); + expect(previous.question?.id).toBe(first.question?.id); + const context: SetupQuestionnaireContext = { projectOwner: 'acme', projectDiscovery: { status: 'unsupported', candidates: [] } }; + const selection = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration(), context), 'projects.ids', context); + const selected = transitionSetupQuestionnaire(selection, { kind: 'answer', value: '5' }, context); + const attestation = advanceTo(selected, 'projects.statusVerified', context); + expect(transitionSetupQuestionnaire({ ...attestation, phase: undefined }, { kind: 'answer', value: 'no' }, context) + .question?.id).toBe('projects.ids'); + }); + + it('keeps Basic defaults safe when an optional nested configuration section is absent', () => { + const configuration = createDefaultSetupConfiguration(); + const incomplete = { ...configuration, ai: undefined } as unknown as typeof configuration; + expect(setupBasicSkippedQuestionIds(incomplete)).not.toContain('ai.bugbotCommentLimit'); + }); + + it('does not restart when answering a question makes that question disappear', () => { + const initial = createDefaultSetupConfiguration(); + initial.projects.ids = '12'; + const context = { projectsWanted: true }; + const question = advanceTo(createSetupQuestionnaire(initial, context), 'projects.ids', context); + const next = transitionSetupQuestionnaire(question, { kind: 'answer', value: 'none' }, context); + expect(next.question?.id).not.toBe('features.issues'); + expect(next.stateId).not.toBe('capabilities'); + }); + + it('offers plan correction groups and reopens an existing review without clearing its draft', () => { + const draft = createDefaultSetupConfiguration(); + expect(setupEditableGroups(draft)).toContain('repository'); + expect(setupEditableGroups(draft)).toContain('projects'); + const review = createSetupReviewState(draft); + const reopened = reopenSetupQuestionnaireGroup(review, 'repository', {}); + expect(reopened).toMatchObject({ terminal: 'collecting', stateId: 'repository', draft }); + expect(reopened?.question?.id).toBe('repository.mainBranch'); + expect(reopenSetupQuestionnaireGroup(review, 'agent-role-overrides', {})).toBeUndefined(); + }); + it('preserves independent agent overrides on revisit and normalizes them only when explicitly disabled', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.agents.planner.model = 'different-model'; + expect(setupEditableGroups(configuration)).toContain('agent-role-overrides'); + const question = advanceTo(createSetupQuestionnaire(configuration), 'agents.configureIndependently'); + expect(question.question?.defaultValue).toBe(true); + const disabled = transitionSetupQuestionnaire(question, { kind: 'answer', value: 'no' }); + expect(disabled.draft.agents.planner.model).toBe(disabled.draft.agents.findings.model); + }); + it('collects only permission-driving questions and reuses their answers in the full wizard', () => { + const defaults = createDefaultSetupConfiguration(); + const intentContext = { skipQuestionIds: ['createInitialTag', 'manageRepositorySecrets'] }; + let state = createSetupPermissionIntentQuestionnaire(defaults, intentContext); + const visited: string[] = []; + while (state.terminal === 'collecting') { + visited.push(state.question!.id); + const value = state.question!.id === 'features.pullRequests' ? 'no' : ''; + state = transitionSetupQuestionnaire(state, { kind: 'answer', value }, intentContext); + } + expect(visited).toContain('features.issues'); + expect(visited).toContain('issueWorkflows.enabled'); + expect(visited).not.toContain('pullRequestApproval.mode'); + expect(visited).not.toContain('createInitialTag'); + expect(visited).not.toContain('manageRepositorySecrets'); + expect(visited).not.toContain('agents.findings.model'); + expect(state.draft.features.pullRequests).toBe(false); + const full = createSetupQuestionnaire(state.draft, { skipQuestionIds: [...intentContext.skipQuestionIds, ...(state.answeredQuestionIds ?? [])] }); + expect(full.question?.id).not.toBe('features.issues'); + expect(full.draft.features.pullRequests).toBe(false); + }); + + it('uses the current draft when permission intent is revised', () => { + const first = createSetupPermissionIntentQuestionnaire(createDefaultSetupConfiguration()); + const changed = transitionSetupQuestionnaire(first, { kind: 'answer', value: 'no' }); + const revised = createSetupPermissionIntentQuestionnaire(changed.draft); + expect(revised.question?.defaultValue).toBe(false); + expect(revised.phase).toBe('permission-intent'); + }); + + it('drops release and hotfix intent when issue automation is turned off', () => { + const state = transitionSetupQuestionnaire( + createSetupPermissionIntentQuestionnaire(createDefaultSetupConfiguration()), + { kind: 'answer', value: 'no' }, + ); + expect(state.draft.features.issues).toBe(false); + expect(state.draft.features.release).toBe(false); + expect(state.draft.features.hotfix).toBe(false); + expect(state.draft.issueWorkflows.enabled).toEqual([]); + }); + + it('lets either presentation explicitly clear every issue workflow', () => { + const state = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration()), 'issueWorkflows.enabled'); + const cleared = transitionSetupQuestionnaire(state, { kind: 'answer', value: 'none' }); + expect(cleared.draft.issueWorkflows.enabled).toEqual([]); + expect(validateSetupConfiguration(cleared.draft)).toContain( + 'At least one issue workflow must be enabled when issue automation is enabled.', + ); + }); + + it('explains fixed release/hotfix overrides and rejects conflicting PAT-intent answers without changing them', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.features.release = true; + configuration.features.hotfix = false; + configuration.issueWorkflows.enabled = ['feature', 'bugfix', 'release']; + const context = { fixedWorkflowFeatures: { release: true, hotfix: false } }; + const first = createSetupPermissionIntentQuestionnaire(configuration, context); + expect(first.question?.fixedWorkflowFeatures).toEqual(context.fixedWorkflowFeatures); + const disabled = transitionSetupQuestionnaire(first, { kind: 'answer', value: 'no' }, context); + expect(disabled.validation).toContain('release or hotfix override'); + expect(disabled.draft).toEqual(first.draft); + + const workflows = advanceTo(first, 'issueWorkflows.enabled', context); + expect(workflows.question?.fixedWorkflowFeatures).toEqual(context.fixedWorkflowFeatures); + const missingRelease = transitionSetupQuestionnaire(workflows, { kind: 'answer', value: 'feature,bugfix' }, context); + expect(missingRelease.validation).toContain('release workflow must remain enabled'); + expect(missingRelease.draft).toEqual(workflows.draft); + const extraHotfix = transitionSetupQuestionnaire(workflows, { kind: 'answer', value: 'feature,release,hotfix' }, context); + expect(extraHotfix.validation).toContain('hotfix workflow must remain disabled'); + expect(extraHotfix.draft).toEqual(workflows.draft); + const accepted = transitionSetupQuestionnaire(workflows, { kind: 'answer', value: 'feature,release' }, context); + expect(accepted.validation).toBeUndefined(); + expect(accepted.draft.features.release).toBe(true); + expect(accepted.draft.features.hotfix).toBe(false); + }); + + it('enters review immediately when the permission-intent phase has no open questions', () => { + const ids = [ + 'features.issues', 'features.pullRequests', 'issueWorkflows.enabled', 'pullRequestApproval.mode', + 'projects.enabled', 'createInitialTag', 'manageRepositoryVariables', 'manageRepositorySecrets', + 'storage.variables.defaultScope', 'storage.variables.preserveExisting', + 'storage.secrets.defaultScope', 'storage.secrets.preserveExisting', + ]; + const state = createSetupPermissionIntentQuestionnaire(createDefaultSetupConfiguration(), { skipQuestionIds: ids }); + expect(state).toEqual(expect.objectContaining({ terminal: 'review', phase: 'permission-intent', answeredQuestionIds: [] })); + }); + + it('enters the full review immediately when all questions are already fixed', () => { + const configuration = createDefaultSetupConfiguration(); + const ids: string[] = []; + let state = createSetupQuestionnaire(configuration); + while (state.terminal === 'collecting') { + ids.push(state.question!.id); + state = transitionSetupQuestionnaire(state, { kind: 'answer', value: '' }); + } + expect(createSetupQuestionnaire(configuration, { skipQuestionIds: ids })).toEqual( + expect.objectContaining({ terminal: 'review', phase: 'full' }), + ); + }); + + it('supports a legacy collecting state without an explicit phase', () => { + const { phase: _phase, ...legacy } = createSetupQuestionnaire(createDefaultSetupConfiguration()); + const next = transitionSetupQuestionnaire(legacy, { kind: 'answer', value: '' }); + expect(next.question?.id).toBe('features.pullRequests'); + }); + it('walks the declared applicable sections in deterministic order', () => { const visited: string[] = []; let state = createSetupQuestionnaire(createDefaultSetupConfiguration()); @@ -45,6 +250,186 @@ describe('setup questionnaire policy', () => { expect(state.draft.pullRequestApproval.producerAttested).toBe(true); }); + it('asks producer attestation only after the exact coverage check has been selected', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend', + testChecks: [{ name: 'Tests', sourceAppId: 12, workflowName: 'CI' }] }; + const check = advanceTo(createSetupQuestionnaire(configuration), 'pullRequestApproval.coverage.checkName'); + expect(check.question?.choices).toEqual(['Tests']); + const next = transitionSetupQuestionnaire(check, { kind: 'answer', value: 'Tests' }); + expect(next.question?.id).toBe('pullRequestApproval.producerAttested'); + }); + + it('offers observed CI producers without treating a green check as attestation', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; + const candidate = { name: 'Tests', sourceAppId: 12, workflowName: 'CI', + runUrl: 'https://github.com/acme/project/actions/runs/42', headSha: 'a'.repeat(40), conclusion: 'success' }; + const context: SetupQuestionnaireContext = { approvalCheckCandidates: [candidate] }; + const first = advanceTo(createSetupQuestionnaire(configuration, context), 'pullRequestApproval.testChecks', context); + expect(first.question).toMatchObject({ kind: 'producer-select', producerCandidates: [candidate] }); + const invalid = transitionSetupQuestionnaire(first, { kind: 'answer', value: '' }, context); + expect(invalid.validation).toContain('Select 1–8 observed checks'); + const selected = transitionSetupQuestionnaire(first, { kind: 'answer', value: 'Tests|12|CI' }, context); + expect(selected.draft.pullRequestApproval.testChecks).toEqual([{ name: 'Tests', sourceAppId: 12, workflowName: 'CI' }]); + expect(selected.draft.pullRequestApproval.producerAttested).toBe(false); + const coverage = advanceTo(selected, 'pullRequestApproval.coverage.checkName', context); + expect(coverage.question?.choices).toEqual(['Tests']); + expect(coverage.question?.producerCandidates).toEqual([candidate]); + }); + + it('rejects ambiguous trusted check names and preserves previous answers on discovery refresh', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; + const context: SetupQuestionnaireContext = { approvalCheckDiscoveryStatus: 'unavailable', + discoveryRetryRemaining: { checks: 2, projects: 0 }, approvalCheckCandidates: [] }; + const state = advanceTo(createSetupQuestionnaire(configuration, context), 'pullRequestApproval.testChecks', context); + const refreshed = refreshSetupQuestionnaireQuestion(state, { ...context, approvalCheckDiscoveryStatus: 'observed', + discoveryRetryRemaining: { checks: 1, projects: 0 }, approvalCheckCandidates: [{ name: 'Tests', sourceAppId: 12, + workflowName: 'CI', runUrl: 'https://github.com/acme/repo/actions/runs/5', headSha: 'a'.repeat(40), conclusion: 'success' }] }); + expect(refreshed.question).toMatchObject({ id: state.question?.id, discoveryStatus: 'observed', discoveryRetryRemaining: 1 }); + expect(refreshed.answeredQuestionIds).toEqual(state.answeredQuestionIds); + expect(refreshed.draft).toEqual(state.draft); + expect(transitionSetupQuestionnaire(refreshed, { kind: 'answer', value: 'Tests|12|CI;Tests|13|Other' }).validation) + .toContain('same check name'); + }); + + it('does not label a required ruleset check as verified after the target development branch changes', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; + configuration.repository.developmentBranch = 'release'; + const context: SetupQuestionnaireContext = { approvalCheckCandidates: [{ name: 'Tests', sourceAppId: 12, + workflowName: 'CI', runUrl: 'https://github.com/acme/repo/actions/runs/5', headSha: 'a'.repeat(40), conclusion: 'success', + requiredByRuleset: { branch: 'develop', sourceUrl: 'https://github.com/acme/repo/rules/3' } }] }; + const state = advanceTo(createSetupQuestionnaire(configuration, context), 'pullRequestApproval.testChecks', context); + expect(state.question?.producerCandidates?.[0].requiredByRuleset).toBeUndefined(); + configuration.repository.developmentBranch = 'develop'; + const matched = advanceTo(createSetupQuestionnaire(configuration, context), 'pullRequestApproval.testChecks', context); + expect(matched.question?.producerCandidates?.[0].requiredByRuleset?.branch).toBe('develop'); + }); + + it('keeps manual check entry when discovery found no trusted producer', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; + const state = advanceTo(createSetupQuestionnaire(configuration, { approvalCheckCandidates: [] }), + 'pullRequestApproval.testChecks', { approvalCheckCandidates: [] }); + expect(state.question?.kind).toBe('producer-select'); + expect(transitionSetupQuestionnaire(state, { kind: 'answer', value: 'Tests|x|CI' }).validation).toContain('Select 1–8'); + }); + + it('normalizes observed producer numbers but rejects repeated or out-of-range selections', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; + const candidate = { name: 'Tests', sourceAppId: 12, workflowName: 'CI', runUrl: 'https://github.com/acme/repo/actions/runs/5', + headSha: 'a'.repeat(40), conclusion: 'success' }; + const context: SetupQuestionnaireContext = { approvalCheckCandidates: [candidate], approvalCheckDiscoveryStatus: 'observed' }; + const state = advanceTo(createSetupQuestionnaire(configuration, context), 'pullRequestApproval.testChecks', context); + expect(state.question?.discoveryRetryRemaining).toBe(0); + expect(transitionSetupQuestionnaire(state, { kind: 'answer', value: '1' }, context).draft.pullRequestApproval.testChecks) + .toEqual([{ name: 'Tests', sourceAppId: 12, workflowName: 'CI' }]); + expect(transitionSetupQuestionnaire(state, { kind: 'answer', value: '1,1' }, context).validation) + .toContain('selected more than once'); + expect(transitionSetupQuestionnaire(state, { kind: 'answer', value: '2' }, context).validation) + .toContain('Select 1–8 observed checks'); + const noCandidates = { ...state, question: { ...state.question!, producerCandidates: undefined } }; + expect(transitionSetupQuestionnaire(noCandidates, { kind: 'answer', value: '1' }, context).validation) + .toContain('Select 1–8 observed checks'); + }); + + it('asks only Project intent before PAT and selects concrete Projects afterwards', () => { + const defaults = createDefaultSetupConfiguration(); + const intent = advanceTo(createSetupPermissionIntentQuestionnaire(defaults), 'projects.enabled'); + expect(intent.question?.kind).toBe('boolean'); + const wanted = transitionSetupQuestionnaire(intent, { kind: 'answer', value: 'yes' }); + expect(wanted.projectsWanted).toBe(true); + expect(wanted.draft.projects.ids).toBe(''); + const context: SetupQuestionnaireContext = { projectsWanted: true, projectOwner: 'acme', projectDiscovery: { + status: 'observed', candidates: [ + { number: 2, title: 'First', owner: 'acme', url: 'https://github.com/orgs/acme/projects/2', statusOptions: ['Todo', 'In Progress'] }, + { number: 3, title: 'Second', owner: 'acme', url: 'https://github.com/orgs/acme/projects/3', statusOptions: ['In Progress'] }, + ], + } }; + const select = advanceTo(createSetupQuestionnaire(wanted.draft, context), 'projects.ids', context); + expect(select.question).toMatchObject({ kind: 'project-select', discoveryStatus: 'observed' }); + const selected = transitionSetupQuestionnaire(select, { kind: 'answer', value: '2,3' }, context); + expect(selected.draft.projects.ids).toBe('2,3'); + expect(selected.question).toMatchObject({ id: 'projects.issueCreatedColumn', kind: 'choice', choices: ['In Progress'] }); + expect(transitionSetupQuestionnaire(selected, { kind: 'answer', value: '' }, context).validation) + .toContain('saved Status value'); + }); + + it('clears saved Project IDs when the operator changes Project intent to no', () => { + const defaults = createDefaultSetupConfiguration(); + const selected = { ...defaults, projects: { ...defaults.projects, ids: '42' } }; + const intent = advanceTo(createSetupPermissionIntentQuestionnaire(selected), 'projects.enabled'); + const declined = transitionSetupQuestionnaire(intent, { kind: 'answer', value: 'no' }); + expect(declined.projectsWanted).toBe(false); + expect(declined.draft.projects.ids).toBe(''); + }); + + it('rejects incompatible Projects and GraphQL IDs before leaving the question', () => { + const context: SetupQuestionnaireContext = { projectOwner: 'acme', projectDiscovery: { status: 'observed', candidates: [ + { number: 2, title: 'First', owner: 'acme', url: 'https://github.com/orgs/acme/projects/2', statusOptions: ['Todo'] }, + { number: 3, title: 'Second', owner: 'acme', url: 'https://github.com/orgs/acme/projects/3', statusOptions: ['Done'] }, + ] } }; + const state = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration(), context), 'projects.ids', context); + expect(transitionSetupQuestionnaire(state, { kind: 'answer', value: '2,3' }, context).validation) + .toContain('no common Status option'); + expect(transitionSetupQuestionnaire(state, { kind: 'answer', value: 'PVT_fake' }, context).validation) + .toContain('positive Project number'); + }); + + it('does not offer a futile Project retry when the personal-owner listing is unsupported', () => { + const context: SetupQuestionnaireContext = { projectOwner: 'someone', projectDiscovery: { + status: 'unsupported', candidates: [], + }, discoveryRetryRemaining: { checks: 0, projects: 0 } }; + const selection = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration(), context), 'projects.ids', context); + expect(selection.question?.discoveryStatus).toBe('unsupported'); + expect(selection.question?.discoveryRetryRemaining).toBeUndefined(); + }); + + it('requires an explicit human check of all four Status values when Project fields were not observed', () => { + const context: SetupQuestionnaireContext = { projectOwner: 'acme', projectDiscovery: { + status: 'unsupported', candidates: [], + } }; + const selection = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration(), context), 'projects.ids', context); + const selected = transitionSetupQuestionnaire(selection, { kind: 'answer', value: '5' }, context); + const attestation = advanceTo(selected, 'projects.statusVerified', context); + expect(attestation.question?.projectStatusValues).toEqual([ + { transition: 'issueCreated', value: 'Todo' }, + { transition: 'pullRequestCreated', value: 'In Progress' }, + { transition: 'issueInProgress', value: 'In Progress' }, + { transition: 'pullRequestInProgress', value: 'In Progress' }, + ]); + const retry = transitionSetupQuestionnaire(attestation, { kind: 'answer', value: 'no' }, context); + expect(retry.question?.id).toBe('projects.ids'); + expect(retry.validation).toContain('not confirmed'); + expect(retry.draft).toEqual(attestation.draft); + expect(retry.answeredQuestionIds).not.toContain('projects.statusVerified'); + expect(transitionSetupQuestionnaire(attestation, { kind: 'answer', value: '' }, context).validation) + .toContain('Open every selected Project'); + expect(transitionSetupQuestionnaire(attestation, { kind: 'answer', value: 'yes' }, context).question?.id) + .toBe('createInitialTag'); + }); + + it('does not redirect an attestation refusal to a Project question hidden in this pass', () => { + const context: SetupQuestionnaireContext = { projectOwner: 'acme', projectDiscovery: { status: 'unsupported', candidates: [] } }; + const selection = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration(), context), 'projects.ids', context); + const selected = transitionSetupQuestionnaire(selection, { kind: 'answer', value: '5' }, context); + const attestation = advanceTo(selected, 'projects.statusVerified', context); + const hidden: SetupQuestionnaireContext = { ...context, skipQuestionIds: ['projects.ids'] }; + const declined = transitionSetupQuestionnaire(attestation, { kind: 'answer', value: 'no' }, hidden); + expect(declined.question?.id).toBe('projects.statusVerified'); + expect(declined.validation).toContain('Open every selected Project'); + }); + + it('accepts a manual Project number when an older question has no discovery candidate field', () => { + const context: SetupQuestionnaireContext = { projectOwner: 'acme' }; + const state = advanceTo(createSetupQuestionnaire(createDefaultSetupConfiguration(), context), 'projects.ids', context); + const legacy = { ...state, question: { ...state.question!, projectCandidates: undefined } }; + expect(transitionSetupQuestionnaire(legacy, { kind: 'answer', value: '5' }, context).draft.projects.ids).toBe('5'); + }); + it('asks for numeric threshold, artifact workflow, and reporter attestation only in numeric mode', () => { const configuration = createDefaultSetupConfiguration(); configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; @@ -233,9 +618,9 @@ function advanceTo( let state = initial; for (let attempts = 0; attempts < 200 && state.terminal === 'collecting'; attempts += 1) { if (state.question?.id === questionId) return state; - state = transitionSetupQuestionnaire(state, { kind: 'answer', value: '' }, context); + state = transitionSetupQuestionnaire(state, { kind: 'answer', value: state.question?.id === 'pullRequestApproval.testChecks' ? 'Tests|12|CI' : '' }, context); } - throw new Error(`Question ${questionId} was not reached.`); + throw new Error(`Question ${questionId} was not reached; stopped at ${state.question?.id}: ${state.validation ?? 'no validation error'}.`); } function deepFreeze(value: T): T { diff --git a/src/application/policies/__tests__/setup_remote_facts_policy.test.ts b/src/application/policies/__tests__/setup_remote_facts_policy.test.ts new file mode 100644 index 000000000..d813ed6cd --- /dev/null +++ b/src/application/policies/__tests__/setup_remote_facts_policy.test.ts @@ -0,0 +1,37 @@ +import type { SetupRemoteConfiguration } from '../../../domain/setup'; +import { sameSetupRemoteFacts } from '../setup_remote_facts_policy'; + +const facts: SetupRemoteConfiguration = { + ownerType: 'Organization', repositoryId: 5, repositoryVisibility: 'private', defaultBranch: 'main', + repositorySecrets: ['PAT', 'OPENAI_API_KEY'], repositorySecretsAccess: 'available', + organizationSecrets: ['EXISTING'], + repositoryVariables: [{ name: 'MAIN_BRANCH', value: 'main' }, { name: 'AGENT_PROVIDER', value: 'codex' }], + repositoryVariablesAccess: 'available', organizationVariables: [{ name: 'CUSTOM', value: 'one' }], + organizationAccess: 'available', organizationSecretsAccess: 'available', organizationVariablesAccess: 'available', + credentialHealthWorkflow: 'installed', +}; + +describe('setup remote fact equivalence', () => { + test('resource ordering is immaterial', () => { + expect(sameSetupRemoteFacts(facts, { + ...facts, repositorySecrets: [...facts.repositorySecrets].reverse(), + repositoryVariables: [...facts.repositoryVariables].reverse(), + })).toBe(true); + }); + + test.each([ + ['ownerType', 'User'], ['repositoryId', 6], ['repositoryVisibility', 'public'], ['defaultBranch', 'develop'], + ['repositorySecrets', []], ['repositorySecretsAccess', 'unknown'], ['organizationSecrets', []], + ['repositoryVariablesAccess', 'unavailable'], ['organizationVariables', []], + ['organizationAccess', 'unknown'], ['organizationSecretsAccess', 'unknown'], + ['organizationVariablesAccess', 'unknown'], ['credentialHealthWorkflow', 'missing'], + ] as const)('detects a change in %s', (field, value) => { + expect(sameSetupRemoteFacts(facts, { ...facts, [field]: value })).toBe(false); + }); + + test('detects a changed variable value even with the same name', () => { + expect(sameSetupRemoteFacts(facts, { + ...facts, repositoryVariables: [{ name: 'MAIN_BRANCH', value: 'develop' }, facts.repositoryVariables[1]], + })).toBe(false); + }); +}); diff --git a/src/application/policies/__tests__/setup_token_permission_policy.test.ts b/src/application/policies/__tests__/setup_token_permission_policy.test.ts index f5b104f34..261b6c631 100644 --- a/src/application/policies/__tests__/setup_token_permission_policy.test.ts +++ b/src/application/policies/__tests__/setup_token_permission_policy.test.ts @@ -4,6 +4,7 @@ import { buildSetupPatPermissionRequirements, buildWorkflowPatPermissionRequirements, normalizePermissionRequirements, + requiredSetupPatPermissionDelta, } from '../setup_token_permission_policy'; import type { SetupRemoteConfiguration } from '../../../domain/setup'; import type { SetupTokenPermissionRequirement } from '../../../domain/setup_token_permissions'; @@ -23,14 +24,28 @@ function disabledRuntimeConfiguration() { } describe('setup token permission policy', () => { + it('reports only newly required or upgraded grants after guided review', () => { + const baseline = buildSetupPatPermissionRequirements(); + const metadata = baseline.find(item => item.permission === 'Metadata')!; + const contents = baseline.find(item => item.permission === 'Contents')!; + const secrets = baseline.find(item => item.permission === 'Secrets' && item.scope === 'repository')!; + const final = [metadata, { ...contents, permission: 'contents', level: 'write' as const }, + { ...secrets, applicability: 'required' as const }]; + expect(requiredSetupPatPermissionDelta([metadata, contents, secrets], final)).toEqual([ + 'repository contents write', 'repository Secrets write', + ]); + expect(requiredSetupPatPermissionDelta(final, [metadata, contents, secrets])).toEqual([]); + }); + it('describes the complete setup PAT permission catalog before the prompt', () => { const requirements = buildSetupPatPermissionRequirements(); expect(requirements.map(item => `${item.scope}:${item.permission}:${item.level}`)).toEqual([ 'repository:Metadata:read', 'repository:Contents:read', 'repository:Secrets:write', - 'repository:Variables:write', 'repository:Issues:write', 'repository:Actions:write', + 'repository:Variables:write', 'repository:Issues:write', 'repository:Actions:write', 'repository:Actions:read', + 'repository:Checks:read', 'repository:Administration:read', 'repository:Workflows:write', 'organization:Secrets:write', 'organization:Variables:write', - 'organization:Issue Types:write', 'organization:Projects:write', + 'organization:Issue Types:write', 'organization:Projects:read', ]); }); @@ -44,6 +59,8 @@ describe('setup token permission policy', () => { it('keeps feature-dependent setup grants conditional with visible conditions', () => { const administration = buildSetupPatPermissionRequirements().find(item => item.permission === 'Administration'); expect(administration).toMatchObject({ applicability: 'conditional', condition: expect.stringContaining('Release') }); + const approvalRead = buildSetupPatPermissionRequirements().find(item => item.permission === 'Actions' && item.level === 'read'); + expect(approvalRead).toMatchObject({ applicability: 'conditional', condition: 'Pull-request approval enabled' }); }); it('recomputes only repository setup mutations selected by the approved configuration', () => { @@ -65,6 +82,22 @@ describe('setup token permission policy', () => { ]); }); + it('keeps possible organization grants visible when remote owner type is unknown', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.projects.ids = 'PVT_example'; + configuration.storage.secrets.defaultScope = 'organization'; + const unknown = buildConfiguredSetupPatPermissionRequirements(configuration, { + ...organization, ownerType: 'Unknown', + }).map(item => `${item.scope}:${item.permission}:${item.level}`); + expect(unknown).toEqual(expect.arrayContaining([ + 'organization:Secrets:write', 'organization:Issue Types:write', 'organization:Projects:read', + ])); + const personal = buildConfiguredSetupPatPermissionRequirements(configuration, { + ...organization, ownerType: 'User', + }); + expect(personal.some(item => item.scope === 'organization')).toBe(false); + }); + it('omits stale disabled issue workflows from the configured setup PAT plan', () => { const configuration = createDefaultSetupConfiguration(); configuration.manageRepositorySecrets = false; @@ -118,7 +151,7 @@ describe('setup token permission policy', () => { expect(permissions).toEqual(expect.arrayContaining([ 'repository:Actions:write', 'repository:Workflows:write', - 'organization:Projects:write', + 'organization:Projects:read', ])); }); diff --git a/src/application/policies/merge_setup_overrides_policy.ts b/src/application/policies/merge_setup_overrides_policy.ts new file mode 100644 index 000000000..73d0f98ff --- /dev/null +++ b/src/application/policies/merge_setup_overrides_policy.ts @@ -0,0 +1,30 @@ +import type { SetupConfigurationOverrides } from './setup_configuration_policy'; + +/** Explicit CLI flags override only their fields; file-only settings remain intact. */ +export function mergeSetupOverrides( + fileOverrides: SetupConfigurationOverrides, + flagOverrides: SetupConfigurationOverrides, +): SetupConfigurationOverrides { + return { + ...fileOverrides, + ...flagOverrides, + features: { ...fileOverrides.features, ...flagOverrides.features }, + agents: { ...fileOverrides.agents, ...flagOverrides.agents }, + repository: { ...fileOverrides.repository, ...flagOverrides.repository }, + ai: { ...fileOverrides.ai, ...flagOverrides.ai }, + pullRequestApproval: { + ...fileOverrides.pullRequestApproval, + ...flagOverrides.pullRequestApproval, + coverage: { ...fileOverrides.pullRequestApproval?.coverage, ...flagOverrides.pullRequestApproval?.coverage }, + } as SetupConfigurationOverrides['pullRequestApproval'], + projects: { ...fileOverrides.projects, ...flagOverrides.projects }, + issueWorkflows: { ...fileOverrides.issueWorkflows, ...flagOverrides.issueWorkflows }, + repositoryAgentGuidance: { ...fileOverrides.repositoryAgentGuidance, ...flagOverrides.repositoryAgentGuidance }, + storage: { + ...fileOverrides.storage, + ...flagOverrides.storage, + secrets: { ...fileOverrides.storage?.secrets, ...flagOverrides.storage?.secrets, overrides: { ...fileOverrides.storage?.secrets?.overrides, ...flagOverrides.storage?.secrets?.overrides } }, + variables: { ...fileOverrides.storage?.variables, ...flagOverrides.storage?.variables, overrides: { ...fileOverrides.storage?.variables?.overrides, ...flagOverrides.storage?.variables?.overrides } }, + }, + }; +} diff --git a/src/application/policies/setup_configuration_plan.ts b/src/application/policies/setup_configuration_plan.ts index 2fb46e435..4edf73f63 100644 --- a/src/application/policies/setup_configuration_plan.ts +++ b/src/application/policies/setup_configuration_plan.ts @@ -13,8 +13,15 @@ import { import { usesOrganizationStorage } from './setup_configuration_storage_policy'; import { buildSetupCredentialRequirements } from './setup_credential_requirement_policy'; import { resolveLocaleProfile } from '../../domain/locale'; -import { issueWorkflowFormFiles, serializeIssueWorkflowProfile } from '../../domain/issue_workflow_profile'; +import { ISSUE_WORKFLOW_CATALOG, ISSUE_WORKFLOW_KINDS, issueWorkflowFormFiles, serializeIssueWorkflowProfile } from '../../domain/issue_workflow_profile'; import { effectiveIssueWorkflowFeatures, effectiveIssueWorkflowProfile } from './setup_issue_workflow_policy'; +import { + REPOSITORY_AGENT_GUIDE_PATH, + REPOSITORY_AGENT_MANIFEST_PATH, + REPOSITORY_AGENT_POINTER_PATH, + REPOSITORY_AGENT_PROFILE_PATH, + REPOSITORY_AGENT_SKILL_PATH, +} from './repository_agent_guidance_policy'; export { buildSetupCredentialRequirements }; @@ -61,6 +68,37 @@ export function buildSetupPlan( }; } +/** Actual checkout destinations covered by a web Apply drift check. + * The presentation plan uses package-source labels for workflows/forms; + * comparing those labels as checkout paths would silently miss local edits. + */ +export function setupPlanGuardPaths(plan: Readonly): string[] { + const selected = plan.selectedFiles.map(file => { + if (file.startsWith('workflows/')) return `.github/${file}`; + if (file.startsWith('ISSUE_TEMPLATE/')) return `.github/${file}`; + if (file === 'pull_request_template.md') return '.github/pull_request_template.md'; + if (file === 'AGENTS.md (managed pointer only)') return 'AGENTS.md'; + return file; + }); + // Deselected managed assets can be retired to setup-backups during Apply. + const retiredCandidates = [ + ...['config.yml', ...ISSUE_WORKFLOW_KINDS.map(kind => ISSUE_WORKFLOW_CATALOG[kind].formFile)] + .map(file => `.github/ISSUE_TEMPLATE/${file}`), + ...['release_workflow.yml', 'hotfix_workflow.yml', 'copilot_deployment_orchestration.yml'] + .map(file => `.github/workflows/${file}`), + ]; + // The manifest can authorize retirement even when guidance is disabled and + // its artifacts are absent from the presentation plan. + const guidanceCandidates = [ + REPOSITORY_AGENT_MANIFEST_PATH, + REPOSITORY_AGENT_PROFILE_PATH, + REPOSITORY_AGENT_GUIDE_PATH, + REPOSITORY_AGENT_SKILL_PATH, + REPOSITORY_AGENT_POINTER_PATH, + ]; + return [...new Set([...selected, ...retiredCandidates, ...guidanceCandidates])].sort(); +} + export function buildSetupRepositoryVariables(configuration: SetupConfiguration): SetupVariable[] { const variables: SetupVariable[] = []; const add = (name: string, value: string | number | boolean | undefined) => { @@ -266,7 +304,7 @@ function buildSetupWarnings(configuration: SetupConfiguration): string[] { warnings.push('Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.'); } if (configuration.projects.ids.trim()) { - warnings.push('Project IDs must be accessible to the PAT and use the expected project column names.'); + warnings.push('Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.'); } if (setupAgentTasksForFeatures(configuration).some(task => configuration.agents[task].provider === 'cursor')) { warnings.push('Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.'); diff --git a/src/application/policies/setup_configuration_validation.ts b/src/application/policies/setup_configuration_validation.ts index 177bafcac..4091b56da 100644 --- a/src/application/policies/setup_configuration_validation.ts +++ b/src/application/policies/setup_configuration_validation.ts @@ -8,9 +8,21 @@ import { canonicalizeLocaleTag } from '../../domain/locale'; import { ISSUE_WORKFLOW_KINDS } from '../../domain/issue_workflow_profile'; import { effectiveIssueWorkflowProfile } from './setup_issue_workflow_policy'; import { validatePullRequestApprovalPolicy } from '../../domain/pull_request_approval_policy'; +import { parseSetupProjectSelection } from './setup_project_selection_policy'; export function validateSetupConfiguration(configuration: SetupConfiguration, options: { allowIncompleteApproval?: boolean } = {}): string[] { const errors: string[] = []; + const projectSelection = parseSetupProjectSelection(configuration.projects.ids); + if ('error' in projectSelection || projectSelection.value !== configuration.projects.ids) { + errors.push('Project IDs must be a comma-separated list of 1–10 distinct positive Project URL numbers; PVT_ node IDs are not accepted.'); + } + if (configuration.projects.ids) { + for (const [name, value] of Object.entries(configuration.projects).filter(([name]) => name.endsWith('Column'))) { + if (typeof value !== 'string' || !value.trim() || value.length > 100 || /[\p{Cc}\p{Cf}]/u.test(value)) { + errors.push(`Project ${name} must name one existing single-line Status option (1–100 characters).`); + } + } + } errors.push(...validatePullRequestApprovalPolicy(configuration.pullRequestApproval, options.allowIncompleteApproval === true)); if (configuration.actionInputs['pr-approval-policy'] !== undefined) { errors.push('pr-approval-policy cannot be overridden through actionInputs.'); diff --git a/src/application/policies/setup_journey_policy.ts b/src/application/policies/setup_journey_policy.ts new file mode 100644 index 000000000..152e66c58 --- /dev/null +++ b/src/application/policies/setup_journey_policy.ts @@ -0,0 +1,51 @@ +export const SETUP_JOURNEY_STAGES = [ + 'repository', 'choices', 'setup-pat', 'plan', 'credentials', 'apply', +] as const; + +export type SetupJourneyStage = typeof SETUP_JOURNEY_STAGES[number]; +export type SetupJourneyOutcome = 'complete' | 'dry-run' | 'cancelled' | 'blocked' | 'partial'; + +const labels: Readonly> = { + repository: 'Repository', + choices: 'Setup choices', + 'setup-pat': 'Setup PAT', + plan: 'Plan', + credentials: 'Bot PAT & credentials', + apply: 'Apply', +}; + +export interface SetupJourneyView { + readonly repository: string; + readonly position: number; + readonly total: number; + readonly current: string; + readonly complete: readonly string[]; + readonly pending: readonly string[]; + readonly outcome?: SetupJourneyOutcome; + readonly mutationStarted: boolean; + readonly choiceReviewPass: number; +} + +export function buildSetupJourneyView( + repository: string, + stage: SetupJourneyStage, + mutationStarted: boolean, + outcome?: SetupJourneyOutcome, + choiceReviewPass = 1, +): SetupJourneyView { + const position = SETUP_JOURNEY_STAGES.indexOf(stage); + return { + repository: [...repository].map(character => { + const codePoint = character.codePointAt(0)!; + return codePoint < 32 || (codePoint >= 127 && codePoint <= 159) ? '?' : character; + }).join('').slice(0, 120), + position: position + 1, + total: SETUP_JOURNEY_STAGES.length, + current: labels[stage], + complete: SETUP_JOURNEY_STAGES.slice(0, position).map(item => labels[item]), + pending: SETUP_JOURNEY_STAGES.slice(position + 1).map(item => labels[item]), + ...(outcome ? { outcome } : {}), + mutationStarted, + choiceReviewPass, + }; +} diff --git a/src/application/policies/setup_pat_creation_url_policy.ts b/src/application/policies/setup_pat_creation_url_policy.ts new file mode 100644 index 000000000..36aa70872 --- /dev/null +++ b/src/application/policies/setup_pat_creation_url_policy.ts @@ -0,0 +1,76 @@ +import type { SetupTokenPermissionRequirement, SetupTokenPermissionScope } from '../../domain/setup_token_permissions'; + +const PAT_FORM = 'https://github.com/settings/personal-access-tokens/new'; + +const QUERY_PERMISSIONS: Readonly>>> = { + repository: { + Metadata: 'metadata', + Contents: 'contents', + Secrets: 'secrets', + Variables: 'actions_variables', + Issues: 'issues', + Actions: 'actions', + Administration: 'administration', + Checks: 'checks', + Workflows: 'workflows', + 'Pull requests': 'pull_requests', + }, + organization: { + Secrets: 'organization_secrets', + Variables: 'organization_actions_variables', + 'Issue Types': 'issue_types', + Projects: 'organization_projects', + // GitHub's PAT form documents this organization permission as "members". + Members: 'members', + }, +}; + +export class UnsupportedSetupPatLinkError extends Error { + constructor(readonly permissions: readonly string[]) { + super(`GitHub's fine-grained PAT form cannot prefill: ${permissions.join(', ')}.`); + this.name = 'UnsupportedSetupPatLinkError'; + } +} + +/** Builds known GitHub form fields; Checks is accepted by the form but omitted from the published URL table. Never accepts credential material. */ +export function buildSetupPatCreationUrl(input: Readonly<{ + role: 'setup' | 'workflow'; + owner: string; + repository: string; + expiresIn: number; + requirements: readonly SetupTokenPermissionRequirement[]; +}>): string { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(input.owner) + || !/^[A-Za-z0-9._-]{1,100}$/.test(input.repository) + || !Number.isInteger(input.expiresIn) + || input.expiresIn < 1 + || input.expiresIn > 366) { + throw new Error('Invalid PAT form owner, repository, or expiration.'); + } + + const grants = new Map(); + const unsupported: string[] = []; + for (const item of input.requirements) { + if (item.role !== input.role) throw new Error('PAT permission role does not match the requested form.'); + if (item.applicability !== 'required') continue; + const key = QUERY_PERMISSIONS[item.scope][item.permission]; + if (!key || (key === 'metadata' && item.level !== 'read') + || (key === 'workflows' && item.level !== 'write')) { + unsupported.push(`${item.scope} ${item.permission} ${item.level}`); + continue; + } + if (grants.get(key) !== 'write') grants.set(key, item.level); + } + if (unsupported.length > 0) throw new UnsupportedSetupPatLinkError(unsupported); + + const url = new URL(PAT_FORM); + url.searchParams.set('name', `Copilot ${input.role === 'setup' ? 'setup' : 'bot'} ${input.repository}`.slice(0, 40)); + url.searchParams.set('description', `Copilot ${input.role === 'setup' ? 'repository setup' : 'GitHub Action'} for ${input.owner}/${input.repository}`); + url.searchParams.set('target_name', input.owner); + url.searchParams.set('expires_in', String(input.expiresIn)); + for (const [key, level] of [...grants].sort(([left], [right]) => left.localeCompare(right))) { + url.searchParams.set(key, level); + } + // Owner/repository lengths and the finite permission map bound this URL well below terminal limits. + return url.toString(); +} diff --git a/src/application/policies/setup_pat_intent_policy.ts b/src/application/policies/setup_pat_intent_policy.ts new file mode 100644 index 000000000..545354846 --- /dev/null +++ b/src/application/policies/setup_pat_intent_policy.ts @@ -0,0 +1,47 @@ +import type { SetupConfiguration } from '../../domain/setup'; +import type { SetupConfigurationOverrides } from './setup_configuration_policy'; +import { buildSetupPatIntentPermissionRequirements } from './setup_token_permission_policy'; + +/** Local inputs with explicit precedence are decisions, not questions. */ +export function fixedSetupPatIntentQuestionIds( + overrides: SetupConfigurationOverrides, + skipVariables: boolean, + skipSecrets: boolean, +): string[] { + const fixed: string[] = []; + for (const feature of ['issues', 'pullRequests', 'release', 'hotfix'] as const) { + if (overrides.features?.[feature] !== undefined) fixed.push(`features.${feature}`); + } + if (overrides.issueWorkflows?.enabled !== undefined) fixed.push('issueWorkflows.enabled'); + if (overrides.pullRequestApproval?.mode !== undefined) fixed.push('pullRequestApproval.mode'); + if (overrides.projects?.ids !== undefined) fixed.push('projects.enabled', 'projects.ids'); + if (overrides.createInitialTag !== undefined) fixed.push('createInitialTag'); + if (skipVariables || overrides.manageRepositoryVariables !== undefined) fixed.push('manageRepositoryVariables'); + if (skipSecrets || overrides.manageRepositorySecrets !== undefined) fixed.push('manageRepositorySecrets'); + for (const kind of ['variables', 'secrets'] as const) { + if (overrides.storage?.[kind]?.defaultScope !== undefined) fixed.push(`storage.${kind}.defaultScope`); + if (overrides.storage?.[kind]?.preserveExisting !== undefined) fixed.push(`storage.${kind}.preserveExisting`); + } + return fixed; +} + +export function setupPatIntentNeedsOwnerKind(configuration: Readonly, projectsWanted = configuration.projects.ids.trim().length > 0): boolean { + return buildSetupPatIntentPermissionRequirements(configuration, 'Organization', projectsWanted) + .some(requirement => requirement.scope === 'organization') + || (configuration.manageRepositorySecrets && configuration.storage.secrets.preserveExisting) + || (configuration.manageRepositoryVariables && configuration.storage.variables.preserveExisting); +} + +export function setupPatIntentOwnerConflict(configuration: Readonly, ownerKind: 'Organization' | 'User', projectsWanted = configuration.projects.ids.trim().length > 0): boolean { + return ownerKind === 'User' && ( + (configuration.manageRepositorySecrets && ( + configuration.storage.secrets.defaultScope === 'organization' + || Object.values(configuration.storage.secrets.overrides).includes('organization') + )) + || (configuration.manageRepositoryVariables && ( + configuration.storage.variables.defaultScope === 'organization' + || Object.values(configuration.storage.variables.overrides).includes('organization') + )) + || projectsWanted + ); +} diff --git a/src/application/policies/setup_permission_summary_policy.ts b/src/application/policies/setup_permission_summary_policy.ts new file mode 100644 index 000000000..356135bc0 --- /dev/null +++ b/src/application/policies/setup_permission_summary_policy.ts @@ -0,0 +1,17 @@ +import type { SetupTokenPermissionRequirement } from '../../domain/setup_token_permissions'; + +export interface SetupPermissionSummary { + readonly required: readonly string[]; + readonly conditionalCount: number; +} + +/** A lossless required-grant view of the same requirements used for URL creation. */ +export function summarizeSetupPermissions( + requirements: readonly SetupTokenPermissionRequirement[], +): SetupPermissionSummary { + return { + required: requirements.filter(item => item.applicability === 'required') + .map(item => `${item.permission} ${item.level} (${item.scope})`), + conditionalCount: requirements.filter(item => item.applicability === 'conditional').length, + }; +} diff --git a/src/application/policies/setup_project_selection_policy.ts b/src/application/policies/setup_project_selection_policy.ts new file mode 100644 index 000000000..b4e536f47 --- /dev/null +++ b/src/application/policies/setup_project_selection_policy.ts @@ -0,0 +1,57 @@ +/** Project V2 setup stores the positive number in its GitHub URL, never a GraphQL node ID. */ +import type { SetupConfiguration } from '../../domain/setup'; +import type { SetupDiscoveryResult, SetupProjectCandidate } from '../../domain/setup_questionnaire'; + +export function parseSetupProjectSelection(raw: string, owner?: string): { value: string } | { error: string } { + const input = raw.normalize('NFKC').trim(); + if (!input || input.toLowerCase() === 'none') return { value: '' }; + const parts = input.split(',').map(part => part.trim()); + if (parts.length > 10 || parts.some(part => !part)) return { error: 'Choose at most 10 Projects; separate numbers or URLs with commas.' }; + const numbers: number[] = []; + for (const part of parts) { + let numberText = part; + if (part.startsWith('https://')) { + if (!owner) return { error: 'A Project URL needs a known repository owner; enter its positive number instead.' }; + try { + const url = new URL(part); + const match = url.pathname.match(/^\/(?:orgs|users)\/([^/]+)\/projects\/([1-9]\d*)\/?$/u); + if (url.origin !== 'https://github.com' || url.search || url.hash || url.username || url.password + || !match || decodeURIComponent(match[1]).toLowerCase() !== owner.toLowerCase()) { + return { error: `Use a GitHub Project URL belonging to ${owner}, without query parameters.` }; + } + numberText = match[2]; + } catch { return { error: 'Enter a valid GitHub Project URL or positive Project number.' }; } + } + if (!/^[1-9]\d*$/u.test(numberText)) return { error: 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.' }; + const number = Number(numberText); + if (!Number.isSafeInteger(number) || number > 2_147_483_647) return { error: 'Project numbers must be positive integers at most 2147483647.' }; + if (numbers.includes(number)) return { error: `Project ${number} was selected more than once.` }; + numbers.push(number); + } + return { value: numbers.join(',') }; +} + +export function sharedProjectStatusOptions(projectNumbers: string, projects: readonly { number: number; statusOptions?: readonly string[] }[]): + { state: 'observed' | 'unavailable' | 'incompatible'; options: readonly string[] } { + const numbers = projectNumbers.split(',').map(Number).filter(Boolean); + if (!numbers.length) return { state: 'unavailable', options: [] }; + const selected = numbers.map(number => projects.find(project => project.number === number)); + if (selected.some(project => !project?.statusOptions?.length)) return { state: 'unavailable', options: [] }; + const [first, ...rest] = selected as { statusOptions: readonly string[] }[]; + const common = first.statusOptions.filter(option => rest.every(project => project.statusOptions.includes(option))); + return common.length ? { state: 'observed', options: common } : { state: 'incompatible', options: [] }; +} + +/** A discovered mismatch is unsafe even if values arrived through --config rather than the interactive selector. */ +export function validateDiscoveredProjectStatuses( + configuration: Readonly, discovery?: SetupDiscoveryResult, +): readonly string[] { + if (!configuration.projects.ids || !discovery || discovery.status !== 'observed') return []; + const common = sharedProjectStatusOptions(configuration.projects.ids, discovery.candidates); + if (common.state === 'incompatible') return ['Selected Projects have no common Status option. Choose compatible Projects.']; + if (common.state !== 'observed') return []; + const names = [configuration.projects.issueCreatedColumn, configuration.projects.pullRequestCreatedColumn, + configuration.projects.issueInProgressColumn, configuration.projects.pullRequestInProgressColumn]; + return names.filter(name => !common.options.includes(name)).map(name => + `Status value "${name}" is not available in every selected Project.`); +} diff --git a/src/application/policies/setup_question_documentation_policy.ts b/src/application/policies/setup_question_documentation_policy.ts new file mode 100644 index 000000000..da22786c3 --- /dev/null +++ b/src/application/policies/setup_question_documentation_policy.ts @@ -0,0 +1,70 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; + +export interface SetupQuestionDocumentation { + readonly title: string; + readonly url: string; +} + +const docs = { + features: { title: 'Copilot features and workflow triggers', url: 'https://docs.page/vypdev/copilot/features' }, + issueWorkflows: { title: 'Copilot issue workflow setup', url: 'https://docs.page/vypdev/copilot/issues/workflow-setup' }, + branchManagement: { title: 'Issue branch management', url: 'https://docs.page/vypdev/copilot/issues/branch-management' }, + preBranchSdd: { title: 'Pre-branch design documents', url: 'https://docs.page/vypdev/copilot/issues/pre-branch-sdds' }, + issueLifecycle: { title: 'Issue notifications and automatic closure', url: 'https://docs.page/vypdev/copilot/issues/notifications-and-auto-close' }, + assignments: { title: 'Assignees and GitHub Projects', url: 'https://docs.page/vypdev/copilot/issues/assignees-and-projects' }, + pullRequestWorkflows: { title: 'Pull-request workflow setup', url: 'https://docs.page/vypdev/copilot/pull-requests/workflow-setup' }, + pullRequestDescription: { title: 'AI pull-request descriptions', url: 'https://docs.page/vypdev/copilot/pull-requests/ai-description' }, + repositoryGuidance: { title: 'Repository guidance for agents', url: 'https://docs.page/vypdev/copilot/agents/repository-collaboration' }, + runtime: { title: 'Agent runtime selection', url: 'https://docs.page/vypdev/copilot/agents/runtime-selection' }, + model: { title: 'Agent model selection', url: 'https://docs.page/vypdev/copilot/agents/model-selection' }, + command: { title: 'Agent CLI configuration', url: 'https://docs.page/vypdev/copilot/agents/cli-configuration' }, + repository: { title: 'Copilot repository configuration', url: 'https://docs.page/vypdev/copilot/configuration' }, + deployment: { title: 'Release and hotfix orchestration', url: 'https://docs.page/vypdev/copilot/issues/deployment-orchestration' }, + bugbot: { title: 'Bugbot configuration', url: 'https://docs.page/vypdev/copilot/bugbot/configuration' }, + bugbotVerification: { title: 'Bugbot autofix verification commands', url: 'https://docs.page/vypdev/copilot/bugbot/verification-commands' }, + approval: { title: 'Guarded pull-request approval', url: 'https://docs.page/vypdev/copilot/pull-requests/guarded-approval' }, + githubStatusChecks: { title: 'GitHub: status checks and required checks', url: 'https://docs.github.com/en/pull-requests/reference/status-checks' }, + projects: { title: 'Assignees and GitHub Projects', url: 'https://docs.page/vypdev/copilot/issues/assignees-and-projects' }, + githubProjects: { title: 'GitHub: About Projects', url: 'https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects' }, + githubStatus: { title: 'GitHub: About single-select fields', url: 'https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields/about-single-select-fields' }, + provisioning: { title: 'Copilot setup and provisioning', url: 'https://docs.page/vypdev/copilot/how-to-use' }, + storage: { title: 'GitHub Actions Secrets and Variables', url: 'https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets' }, +} as const satisfies Record; + +/** Links are selected from source-controlled constants, never derived from answers or remote text. */ +export function setupQuestionDocumentation(question: Pick): SetupQuestionDocumentation { + const id = question.id; + if (id === 'issueWorkflows.enabled') return docs.issueWorkflows; + if (id === 'features.issues') return docs.issueWorkflows; + if (id === 'features.pullRequests') return docs.pullRequestWorkflows; + if (id === 'repository.issueManagedBranches' || /^repository\.(feature|bugfix|hotfix|release|docs|chore)Tree$/u.test(id)) return docs.branchManagement; + if (id === 'repository.preBranchSdd') return docs.preBranchSdd; + if (id === 'repository.inactivityThresholdHours' || id === 'features.inactiveIssueClosure') return docs.issueLifecycle; + if (id === 'repository.desiredAssigneesCount' || id === 'repository.desiredReviewersCount') return docs.assignments; + if (id === 'ai.pullRequestDescriptionMode') return docs.pullRequestDescription; + if (id === 'ai.bugbotFixVerifyCommands') return docs.bugbotVerification; + if (id === 'projects.ids') return docs.githubProjects; + if (id === 'pullRequestApproval.testChecks') return docs.githubStatusChecks; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(id) || id === 'projects.statusVerified') return docs.githubStatus; + if (id.startsWith('repositoryAgentGuidance.')) return docs.repositoryGuidance; + if (id.startsWith('agents.')) { + if (id.endsWith('.provider')) return docs.runtime; + if (id.endsWith('.executable')) return docs.command; + return docs.model; + } + if (id === 'ai.provisioningMode') return docs.command; + const byState = { + capabilities: docs.features, + 'agent-runtime': docs.runtime, + 'agent-model-defaults': docs.model, + 'agent-role-overrides': docs.model, + repository: docs.repository, + deployment: docs.deployment, + bugbot: docs.bugbot, + 'pull-request-approval': docs.approval, + projects: docs.projects, + provisioning: docs.provisioning, + storage: docs.storage, + } as const; + return byState[question.stateId]; +} diff --git a/src/application/policies/setup_question_guidance_fr.ts b/src/application/policies/setup_question_guidance_fr.ts new file mode 100644 index 000000000..c14bc7a6c --- /dev/null +++ b/src/application/policies/setup_question_guidance_fr.ts @@ -0,0 +1,79 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; +import type { SetupQuestionExplanation } from '../contracts/web_setup_view'; +import { translatedQuestionLabel } from './setup_question_labels_fr_pt'; +import { setupQuestionPurposeFrPt } from './setup_question_purpose_fr_pt'; + +type Copy = Pick; + +const where: Readonly> = { + capabilities: 'La configuration écrit les workflows choisis dans ce dépôt et ne demande que les autorisations GitHub nécessaires.', + 'agent-runtime': 'Les workflows GitHub Actions générés lancent cet agent sur leur runner ; rien n’est installé sur cet ordinateur.', + 'agent-model-defaults': 'Le modèle et la commande communs sont enregistrés dans la configuration du dépôt lue par les workflows.', + 'agent-role-overrides': 'Cette exception propre à une tâche est enregistrée dans le dépôt et lue uniquement lorsque cette tâche s’exécute.', + repository: 'Le profil du dépôt et les workflows générés utilisent cette valeur pour les futurs événements de branches, tickets et pull requests.', + deployment: 'Le profil du dépôt commande les futurs workflows de version et de correctif urgent ; répondre ne publie rien.', + bugbot: 'Le workflow généré lit ce réglage dans la configuration du dépôt ou les Variables GitHub Actions sélectionnées.', + 'pull-request-approval': 'L’approbation encadrée utilise les identités exactes des producteurs CI et les preuves des exécutions GitHub.', + projects: 'Les Projects choisis et leurs valeurs du champ Status seront utilisés par l’automatisation future des tickets et pull requests.', + provisioning: 'Après la confirmation finale, la configuration peut créer ou mettre à jour les fichiers et ressources GitHub Actions choisis.', + storage: 'GitHub Actions stocke ces ressources au niveau du dépôt ou de l’organisation ; ce choix change leur visibilité et les autorisations du PAT.', +}; + +const section: Readonly> = { + capabilities: { summary: 'Choisissez les automatisations que Copilot installera.', when: 'Ce choix influence les workflows, les autorisations GitHub et les questions suivantes.', example: 'Désactivez une fonction que vous ne prévoyez pas d’utiliser.', effect: 'Seules les fonctions sélectionnées figureront dans le plan.', verify: 'Examinez le plan avant d’appliquer les changements.' }, + 'agent-runtime': { summary: 'Choisissez l’agent CLI pour cette tâche.', when: 'Il sera utilisé lorsque la fonction sélectionnée s’exécutera dans GitHub Actions.', example: 'Codex est lancé avec la commande codex.', effect: 'L’Action lance le fournisseur choisi, sans solution de remplacement implicite.', verify: 'Vérifiez que le runner dispose du CLI et des identifiants nécessaires.' }, + 'agent-model-defaults': { summary: 'Définissez les modèles utilisés par défaut pour les tâches de l’agent.', when: 'Ils s’appliquent sauf si vous configurez chaque tâche séparément.', example: 'Gardez le modèle proposé si vous n’avez pas de besoin particulier.', effect: 'L’Action transmet ces valeurs au CLI sélectionné.', verify: 'Examinez le plan et les modèles autorisés sur le runner.' }, + 'agent-role-overrides': { summary: 'Personnalisez cette tâche de l’agent.', when: 'Uniquement si vous avez activé la configuration indépendante des tâches.', example: 'Utilisez un modèle différent pour la revue et la planification.', effect: 'Seule cette tâche utilise cette exception.', verify: 'Examinez les valeurs de chaque tâche dans le plan.' }, + repository: { summary: 'Définissez comment Copilot traite votre dépôt.', when: 'Ce réglage agit sur les workflows et futurs événements de tickets ou pull requests.', example: 'Indiquez le véritable nom de votre branche de développement.', effect: 'L’automatisation future suivra les branches et règles choisies.', verify: 'Examinez les fichiers prévus et le profil du dépôt.' }, + deployment: { summary: 'Définissez le comportement des versions et correctifs urgents.', when: 'Ce réglage n’importe que si ces workflows sont activés.', example: 'Gardez la stratégie par défaut sauf si votre organisation des branches diffère.', effect: 'Il modifie la gestion des branches et pull requests de réconciliation.', verify: 'Examinez la partie versions et correctifs du plan.' }, + bugbot: { summary: 'Définissez comment Bugbot analyse et signale les changements.', when: 'Ce réglage sert lorsque les fonctions de revue IA s’exécutent.', example: 'Par défaut, les résultats admissibles sont publiés sans bloquer toutes les pull requests.', effect: 'Il change les futures publications et diagnostics de revue.', verify: 'Examinez les Variables Bugbot du plan et les résultats de revue.' }, + 'pull-request-approval': { summary: 'Choisissez les preuves exigées avant que le bot recommande ou soumette une approbation.', when: 'Ce réglage ne s’applique que si l’automatisation des pull requests est activée.', example: '« Recommend » informe une personne ; « guarded » peut approuver sur GitHub.', effect: 'Une vérification verte affichée ici ne suffit jamais à approuver une pull request.', verify: 'Inspectez les preuves CI, Bugbot et les règles de branche.' }, + projects: { summary: 'Choisissez une valeur Status existante pour une transition de ticket ou PR.', when: 'Seulement si vous intégrez des Projects.', example: 'Todo à la création ; In Progress au début du travail.', effect: 'L’automatisation modifiera le champ Status, pas une colonne visuelle.', verify: 'Vérifiez les options Status de chaque Project choisi.' }, + provisioning: { summary: 'Choisissez les ressources GitHub Actions gérées par la configuration.', when: 'Cela influence les autorisations du PAT et les écritures prévues.', example: 'Gardez les Secrets activés si le PAT du bot doit être installé.', effect: 'Les ressources sélectionnées pourront être créées ou mises à jour après approbation.', verify: 'Examinez les noms exacts des ressources dans le plan.' }, + storage: { summary: 'Choisissez où résident les Variables et Secrets GitHub Actions.', when: 'Ce réglage s’applique quand leur création est activée.', example: 'Le dépôt est le périmètre par défaut le plus simple.', effect: 'Il change la visibilité, les autorisations et l’ordre de priorité.', verify: 'Examinez le périmètre et les avertissements de masquage dans le plan.' }, +}; + +const special: Readonly> = { + 'agents.findings.executable': { summary: 'Choisissez la commande de l’agent sur le runner GitHub Actions, pas sur cet ordinateur.', when: 'Ne la changez que si un agent personnalisé est délibérément installé sur le runner.', example: 'Laissez vide pour codex, opencode ou agent selon le fournisseur.', effect: 'Le chemin personnalisé est utilisé pour les tâches choisies et n’est jamais installé automatiquement.', verify: 'Vérifiez que le runner possède exactement cet exécutable avant d’activer le workflow.' }, + 'ai.includeReasoning': { summary: 'Demandez des explications supplémentaires si la réponse du fournisseur les contient.', when: 'Réservé aux diagnostics avancés ; le parcours CLI actuel ne fournit pas de parties de raisonnement séparées.', example: 'Laissez désactivé pour une configuration normale.', effect: 'Cela peut ajouter du texte du fournisseur, sans garantir des métadonnées brèves.', verify: 'Inspectez une réponse structurée contrôlée ; ne supposez pas que l’option a produit plus de texte.' }, + 'ai.bugbotDryRun': { summary: 'Gardez Bugbot en mode analyse seule pour ses futures exécutions.', when: 'Utile pour une évaluation ; incompatible avec les preuves nécessaires à l’approbation.', example: 'Choisissez Non pour publier les revues normales.', effect: 'Bugbot analyse sans publier de résultat ni modifier le dépôt. Ce n’est pas setup --dry-run.', verify: 'Inspectez le résultat du workflow Bugbot : le mode analyse seule ne publie ni revue ni vérification.' }, + 'ai.bugbotOrganizationRules': { summary: 'Définissez des consignes générales pour Bugbot, une règle par ligne.', when: 'Utile si l’équipe partage des critères de revue dans le dépôt configuré.', example: 'Signaler les changements qui contournent l’isolation des clients.', effect: 'Ces règles précèdent celles du dépôt ; le périmètre de la Variable détermine le stockage.', verify: 'Inspectez la Variable configurée et activez le traçage des sources de règles.' }, + 'ai.provisioningMode': { summary: 'Décidez comment l’Action trouve ou installe l’agent CLI.', when: 'Ce choix s’applique sur le runner au démarrage d’une tâche IA activée.', example: 'Auto réutilise un CLI installé ou installe une version fixée de Codex/OpenCode.', effect: 'Always réinstalle les versions examinées ; Disabled exige un CLI préinstallé. Cursor doit être préinstallé.', verify: 'Inspectez l’étape de préparation et la version du binaire rapportée par le runner.' }, + 'pullRequestApproval.testChecks': { summary: 'Choisissez les jobs CI que le bot peut considérer comme preuve de tests indépendante.', when: 'Obligatoire pour les modes Recommend et Guarded.', example: 'Sélectionnez le job Tests exact, son ID d’App GitHub et son workflow dans une exécution récente.', effect: 'Seules les identités exactes listées satisfont la condition d’approbation.', verify: 'Ouvrez l’exécution liée et vérifiez le job, l’App et le résultat pour le commit courant.' }, + 'pullRequestApproval.producerAttested': { summary: 'Confirmez avoir inspecté le producteur CI exact et son étape obligatoire de couverture.', when: 'Obligatoire avant que le mode Guarded puisse approuver.', example: 'Vérifiez que le job Tests échoue si le seuil de couverture n’est pas atteint.', effect: 'Votre confirmation est enregistrée ; Copilot ne la déduit pas d’une vérification verte.', verify: 'Inspectez le fichier du workflow et une exécution réelle avant de répondre Oui.' }, + 'pullRequestApproval.coverage.mode': { summary: 'Choisissez comment prouver la couverture exigée du code modifié.', when: 'Ce choix s’applique lorsque l’approbation de PR est activée.', example: 'Check : le CI impose le seuil. Numeric : un workflow fiable publie des décomptes limités.', effect: 'Check fait confiance au garde CI ; Numeric lit copilot-diff-coverage-v1 et compare un seuil.', verify: 'Inspectez respectivement la condition d’échec du CI ou l’artefact du rapporteur.' }, + 'pullRequestApproval.coverage.checkName': { summary: 'Sélectionnez la vérification fiable qui échoue sous le seuil de couverture.', when: 'Obligatoire dans les deux modes de preuve.', example: 'Utilisez le même job Tests exact que dans l’étape précédente.', effect: 'Le succès d’une autre vérification ou App ne remplace pas ce garde.', verify: 'Vérifiez que l’étape de couverture est obligatoire, pas seulement informative.' }, + 'projects.enabled': { summary: 'Décidez si les futurs tickets et PR doivent utiliser des Projects GitHub existants.', when: 'Avant de créer le PAT de configuration pour prévoir le droit de lecture des Projects.', example: 'Oui si l’équipe utilise un Project de l’organisation ; Non pour ignorer cette intégration.', effect: 'Oui prévoit Projects: read de l’organisation si nécessaire. Aucun Project n’est modifié maintenant.', verify: 'Vérifiez les droits du PAT ; les Projects précis seront choisis après son autorisation.' }, + 'projects.ids': { summary: 'Choisissez les Projects existants que Copilot pourra actualiser plus tard.', when: 'Après la vérification du PAT ; si la liste est inaccessible, utilisez la saisie manuelle.', example: 'Pour https://github.com/orgs/acme/projects/5, choisissez la carte ou saisissez 5, jamais PVT_…', effect: 'Leurs numéros seront enregistrés ; aucun élément Project n’est modifié maintenant.', verify: 'Ouvrez chaque Project et vérifiez propriétaire et numéro avant de confirmer le plan.' }, +}; + +function howToChoose(question: SetupQuestion): string { + if (question.id === 'pullRequestApproval.coverage.checkName') return 'Choisissez l’une des vérifications fiables ci-dessus. Ouvrez son exécution et son workflow : l’étape de couverture doit faire échouer le job si le seuil n’est pas atteint. Un résultat vert ne suffit pas.'; + if (question.id === 'pullRequestApproval.producerAttested') return 'Répondez Oui uniquement après avoir vérifié chaque nom, ID d’App et workflow choisis, ainsi que l’étape de couverture obligatoire du check retenu. Sinon, répondez Non et restez en mode recommandation.'; + if (question.id === 'pullRequestApproval.coverage.artifactWorkflowName') return 'Saisissez le nom exact d’un workflow fiable choisi qui publie copilot-diff-coverage-v1 pour cette PR et ses commits de base et de tête. Ne devinez pas le nom du workflow.'; + if (question.id === 'projects.statusVerified') return 'Ouvrez chaque Project choisi sur GitHub, inspectez son champ Status et comparez les quatre valeurs exactes ci-dessus. Répondez Oui uniquement si toutes existent dans chaque Project ; Non revient au choix des Projects.'; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) return 'Choisissez une option du champ Status présente dans tous les Projects sélectionnés. Si les options ne sont pas lisibles, ouvrez chaque Project sur GitHub et saisissez la même valeur existante ; des valeurs différentes par Project ne sont pas prises en charge.'; + switch (question.kind) { + case 'boolean': return 'Choisissez Oui pour activer ou Non pour désactiver ; la réponse suggérée apparaît plus bas.'; + case 'producer-select': return 'Inspectez chaque exécution candidate sur GitHub, puis choisissez le job, l’ID d’App et le workflow exacts. Ne saisissez manuellement que si aucun candidat vérifié n’apparaît.'; + case 'project-select': return 'Choisissez par titre et URL. Saisissez le numéro positif ou l’URL GitHub exacte si un Project manque ; les ID PVT_ sont invalides.'; + case 'scope-overrides': return 'Sélectionnez uniquement les noms hérités à remplacer volontairement dans le dépôt. Laissez vide pour conserver les valeurs de l’organisation.'; + case 'multi-select': return 'Cochez les workflows que vous utiliserez. Vous pouvez en choisir plusieurs ; vérifiez leurs autorisations avant de créer un PAT.'; + case 'choice': return 'Choisissez une valeur après avoir lu ses conséquences ; la valeur enregistrée n’est pas traduite.'; + case 'number': return 'Saisissez un entier dans la plage indiquée ; gardez la valeur suggérée en cas de doute.'; + default: return 'Saisissez la valeur exacte utilisée par votre dépôt ou runner ; ne laissez vide que si la question le permet.'; + } +} + +export function frenchQuestionExplanation(question: SetupQuestion, documentation: SetupQuestionExplanation['documentation']): SetupQuestionExplanation { + const copy = special[question.id] ?? section[question.stateId]; + return { + label: translatedQuestionLabel(question, 'fr'), + ...copy, + summary: special[question.id] ? copy.summary : (setupQuestionPurposeFrPt(question, 'fr') ?? copy.summary), + where: where[question.stateId], + how: howToChoose(question), + why: `Cette décision permet d’accorder le plan, les autorisations du PAT et l’automatisation future avant d’appliquer des changements. ${copy.when}`, + documentation: { title: 'Documentation de cette option', url: documentation.url }, + }; +} diff --git a/src/application/policies/setup_question_guidance_policy.ts b/src/application/policies/setup_question_guidance_policy.ts new file mode 100644 index 000000000..911ae0a1e --- /dev/null +++ b/src/application/policies/setup_question_guidance_policy.ts @@ -0,0 +1,139 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; +import type { SetupQuestionExplanation, SetupQuestionPresentation } from '../contracts/web_setup_view'; +import { spanishQuestionLabel } from './setup_question_translations'; +import { setupQuestionDocumentation } from './setup_question_documentation_policy'; +import { setupQuestionPurpose } from './setup_question_purpose_policy'; +import { frenchQuestionExplanation } from './setup_question_guidance_fr'; +import { portugueseQuestionExplanation } from './setup_question_guidance_pt'; + +type Copy = Pick; + +const location: Readonly> = { + capabilities: { en: 'Setup writes the selected workflow files into this repository and requests only the GitHub permissions those workflows need.', es: 'Setup escribe los workflows seleccionados en este repositorio y solicita solo los permisos de GitHub necesarios.' }, + 'agent-runtime': { en: 'The generated GitHub Actions workflows invoke this agent on their runner; this does not install an agent on your computer.', es: 'Los workflows de GitHub Actions invocan este agente en su runner; no se instala en tu ordenador.' }, + 'agent-model-defaults': { en: 'The shared model and command defaults are stored in the repository configuration consumed by the generated workflows.', es: 'Los valores comunes de modelo y comando se guardan en la configuración del repositorio que usan los workflows.' }, + 'agent-role-overrides': { en: 'This task-specific override is stored in the repository configuration and read only when that task runs.', es: 'Esta excepción por tarea se guarda en la configuración del repositorio y se lee cuando se ejecuta esa tarea.' }, + repository: { en: 'The repository profile and generated workflows use this value for future branch, issue and pull-request events.', es: 'El perfil del repositorio y los workflows generados usan este valor en futuros eventos de ramas, issues y pull requests.' }, + deployment: { en: 'The repository profile controls later release and hotfix workflows; nothing is released by answering this question.', es: 'El perfil del repositorio controla los futuros workflows de release y hotfix; responder no publica ninguna versión.' }, + bugbot: { en: 'The generated workflow reads this setting from repository configuration or selected GitHub Actions Variables when Bugbot runs.', es: 'El workflow lee este ajuste de la configuración o las Variables de GitHub Actions seleccionadas al ejecutar Bugbot.' }, + 'pull-request-approval': { en: 'The guarded-approval configuration uses exact CI producer identities and evidence from GitHub pull-request runs.', es: 'La aprobación protegida usa identidades exactas de los productores de CI y pruebas de las ejecuciones de PR en GitHub.' }, + projects: { en: 'Future issue and pull-request automation uses the selected GitHub Projects and their Status field values.', es: 'La automatización futura de issues y pull requests usa los GitHub Projects y los valores de su campo Status.' }, + provisioning: { en: 'After the final Apply confirmation, setup may create or update the selected files and GitHub Actions resources.', es: 'Tras confirmar Aplicar, setup podrá crear o actualizar los archivos y recursos de GitHub Actions elegidos.' }, + storage: { en: 'GitHub Actions stores these resources at repository or organization scope; the scope changes visibility and required PAT grants.', es: 'GitHub Actions guarda estos recursos en el repositorio o la organización; el ámbito cambia la visibilidad y los permisos del PAT.' }, +}; + +const special: Readonly> = { + 'agents.findings.executable': { + en: { summary: 'Choose the agent command used on the GitHub Actions runner, not on this computer.', when: 'Only change this for a runner with a deliberately installed custom agent binary.', example: 'Leave empty for codex, opencode, or agent according to the provider.', effect: 'A custom path is shared unless a task has its own override; it is never installed automatically.', verify: 'Check the runner has this exact executable before enabling the workflow.' }, + es: { summary: 'Elige el comando del agente en el runner de GitHub Actions, no en este ordenador.', when: 'Cámbialo solo si el runner tiene instalado expresamente otro binario.', example: 'Déjalo vacío para usar codex, opencode o agent según el proveedor.', effect: 'La ruta personalizada se comparte salvo que una tarea tenga su propia excepción; nunca se instala automáticamente.', verify: 'Comprueba que el runner tiene exactamente ese ejecutable.' }, + }, + 'ai.includeReasoning': { + en: { summary: 'Ask for additional provider reasoning when the agent response exposes it.', when: 'Advanced diagnostics only; the current string-only CLI path does not provide separate reasoning parts.', example: 'Keep this off for normal setup.', effect: 'May add provider-produced explanation text, not guaranteed concise metadata.', verify: 'Inspect a controlled structured response; do not assume this toggle produced extra text.' }, + es: { summary: 'Solicita razonamiento adicional si la respuesta del proveedor lo ofrece.', when: 'Solo para diagnósticos avanzados; el CLI actual devuelve texto sin partes de razonamiento separadas.', example: 'Déjalo desactivado en una configuración normal.', effect: 'Podría añadir texto del proveedor; no garantiza metadatos breves.', verify: 'Comprueba una respuesta estructurada controlada; no presupongas que la opción tuvo efecto.' }, + }, + 'ai.bugbotDryRun': { + en: { summary: 'Keep Bugbot in analysis-only mode for future runs.', when: 'Useful during evaluation; incompatible with PR approval evidence.', example: 'Choose No to publish normal reviews.', effect: 'Bugbot analyzes but does not publish findings or make SCM changes. This is not setup --dry-run.', verify: 'Inspect the Bugbot workflow result; no published review or Check should appear from dry-run.' }, + es: { summary: 'Mantiene Bugbot en modo solo análisis para las futuras ejecuciones.', when: 'Útil durante una evaluación; incompatible con la evidencia de aprobación de PR.', example: 'Elige No para publicar revisiones normalmente.', effect: 'Bugbot analiza pero no publica hallazgos ni modifica el repositorio. No es setup --dry-run.', verify: 'Revisa el resultado de Bugbot; el modo ensayo no publica revisión ni Check.' }, + }, + 'ai.bugbotOrganizationRules': { + en: { summary: 'Set broad Bugbot review instructions, one rule per line.', when: 'Use when your team needs review criteria shared across its configured repository.', example: 'Flag changes that bypass tenant isolation.', effect: 'These rules run before repository rules; the selected Variable scope determines storage, not the title.', verify: 'Inspect the configured Variable and enable rule-source tracing for a review.' }, + es: { summary: 'Define criterios generales de revisión para Bugbot, una regla por línea.', when: 'Úsalo si el equipo necesita criterios comunes en el repositorio configurado.', example: 'Señala cambios que omitan el aislamiento entre clientes.', effect: 'Se aplican antes que las reglas del repositorio; el ámbito de la Variable determina dónde se guardan.', verify: 'Revisa la Variable configurada y activa el rastreo de fuentes de reglas.' }, + }, + 'ai.provisioningMode': { + en: { summary: 'Decide how the Action finds or installs the selected agent CLI.', when: 'Applies on the runner when an enabled AI task starts.', example: 'Auto reuses an installed CLI or installs pinned Codex/OpenCode when missing.', effect: 'Always reinstalls reviewed defaults; Disabled requires a preinstalled CLI. Cursor must be preinstalled.', verify: 'Inspect the runner provisioning step and its reported binary version.' }, + es: { summary: 'Decide cómo encuentra o instala la Action el agente CLI.', when: 'Se aplica en el runner cuando empieza una tarea de IA.', example: 'Auto reutiliza el CLI existente o instala una versión fijada de Codex/OpenCode si falta.', effect: 'Always reinstala versiones fijadas; Disabled exige instalación previa. Cursor debe estar preinstalado en el runner.', verify: 'Revisa el paso de preparación y la versión del binario en el runner.' }, + }, + 'pullRequestApproval.testChecks': { + en: { summary: 'Choose CI jobs the approval bot may trust as independent test evidence.', when: 'Required for recommend or guarded approval.', example: 'Select the exact Tests job, its GitHub App ID, and parent workflow from a recent run.', effect: 'Only the listed exact producer identities can satisfy the approval gate.', verify: 'Open the linked workflow run and confirm the job, App, and current-head result.' }, + es: { summary: 'Selecciona los jobs de CI que el bot puede considerar pruebas fiables.', when: 'Obligatorio para las aprobaciones recomendadas o protegidas.', example: 'Elige el job Tests, su ID de GitHub App y el workflow de una ejecución reciente.', effect: 'Solo esas identidades exactas podrán satisfacer la condición de aprobación.', verify: 'Abre la ejecución vinculada y comprueba job, App y resultado para el commit actual.' }, + }, + 'pullRequestApproval.producerAttested': { + en: { summary: 'Confirm that you inspected the exact CI producer and its coverage-enforcing step.', when: 'Required before guarded mode can ever submit an approval.', example: 'Verify the selected Tests job fails when the coverage budget fails.', effect: 'Your assertion is recorded; Copilot does not infer it from a green check.', verify: 'Inspect the workflow file and an actual CI run before selecting Yes.' }, + es: { summary: 'Confirma que comprobaste el productor exacto de CI y su paso obligatorio de cobertura.', when: 'Necesario antes de que el modo protegido pueda aprobar.', example: 'Comprueba que el job Tests falla cuando no se alcanza la cobertura mínima.', effect: 'Se registra tu confirmación; Copilot no la deduce de un check verde.', verify: 'Revisa el workflow y una ejecución real antes de elegir Sí.' }, + }, + 'pullRequestApproval.coverage.mode': { + en: { summary: 'Choose how approval proves the changed-code coverage requirement.', when: 'Applies when PR approval is enabled.', example: 'Check: CI enforces the budget. Numeric: a trusted workflow publishes bounded counts.', effect: 'Check mode trusts a selected CI gate; numeric mode reads copilot-diff-coverage-v1 and compares a threshold.', verify: 'Inspect the CI failure condition or the reporter artifact, respectively.' }, + es: { summary: 'Elige cómo se demuestra la cobertura del código modificado.', when: 'Se aplica si habilitas la aprobación de PR.', example: 'Check: CI exige el mínimo. Numeric: un workflow fiable publica recuentos de líneas.', effect: 'Check confía en una condición de CI; numeric lee copilot-diff-coverage-v1 y compara un umbral.', verify: 'Comprueba la condición de fallo del CI o el artefacto del reporter.' }, + }, + 'pullRequestApproval.coverage.checkName': { + en: { summary: 'Select the trusted check that fails when coverage is below budget.', when: 'Required for both coverage evidence modes.', example: 'Use the same exact Tests check selected in the previous step.', effect: 'A success from another check or App cannot substitute for this gate.', verify: 'Inspect the selected job and confirm its coverage step is mandatory, not advisory.' }, + es: { summary: 'Selecciona el check fiable que falla si no se alcanza la cobertura mínima.', when: 'Obligatorio en ambos modos de evidencia.', example: 'Usa el mismo check Tests elegido en el paso anterior.', effect: 'Un éxito de otro check o App no sustituye esta condición.', verify: 'Comprueba que el paso de cobertura es obligatorio, no solo informativo.' }, + }, + 'projects.enabled': { + en: { summary: 'Decide whether future issue and PR automation should use existing GitHub Projects.', when: 'Ask now, before creating the setup PAT, so its Project read permission can be scoped correctly.', example: 'Choose Yes if your team already tracks work in an organization Project; choose No to skip it.', effect: 'Yes includes organization Projects: read in the setup PAT when applicable. No Project is changed now.', verify: 'Review the PAT permission table; exact Projects are selected after GitHub authorizes the PAT.' }, + es: { summary: 'Decide si la automatización futura de issues y PR usará Projects existentes.', when: 'Se pregunta antes de crear el PAT de configuración para ajustar el permiso de lectura de Projects.', example: 'Elige Sí si tu equipo usa un Project de la organización; No para omitirlo.', effect: 'Sí incluye Projects: read de la organización en el PAT cuando aplica. Ahora no se modifica ningún Project.', verify: 'Revisa los permisos del PAT; elegirás los Projects concretos tras autorizarlo en GitHub.' }, + }, + 'projects.ids': { + en: { summary: 'Choose the existing Projects that Copilot may update in future issue and PR workflows.', when: 'After the setup PAT is checked, GitHub may list accessible organization Projects. Personal Projects or unavailable lists need manual entry.', example: 'For https://github.com/orgs/acme/projects/5, select the project card or enter 5; never enter PVT_…', effect: 'Setup stores Project numbers in repository configuration; it does not create or edit Project items now.', verify: 'Open each linked Project and check its owner and URL number before approving the plan.' }, + es: { summary: 'Elige los Projects existentes que Copilot podrá actualizar en futuros flujos de issues y PR.', when: 'Después de comprobar el PAT, GitHub puede listar Projects accesibles de la organización. Para Projects personales o fallos de consulta, introdúcelos manualmente.', example: 'Para https://github.com/orgs/acme/projects/5, marca la tarjeta o escribe 5; nunca PVT_…', effect: 'Setup guarda números de Project en la configuración; ahora no crea ni edita elementos.', verify: 'Abre cada Project enlazado y comprueba el dueño y número de la URL antes de aprobar el plan.' }, + }, +}; + +const section: Readonly> = { + capabilities: { en: { summary: 'Choose which automation Copilot will install.', when: 'This affects workflows, GitHub permissions, and later questions.', example: 'Disable a feature you do not plan to use.', effect: 'Only selected capabilities are planned.', verify: 'Review the generated setup plan before Apply.' }, es: { summary: 'Elige qué automatizaciones instalará Copilot.', when: 'Afecta a workflows, permisos de GitHub y preguntas posteriores.', example: 'Desactiva una función que no vayas a usar.', effect: 'Solo se planifican las funciones seleccionadas.', verify: 'Revisa el plan antes de aplicar cambios.' } }, + 'agent-runtime': { en: { summary: 'Choose the agent CLI for this task.', when: 'Applies when the selected feature runs in GitHub Actions.', example: 'Codex runs through the codex CLI.', effect: 'The Action invokes the selected provider, never an implicit fallback.', verify: 'Check the runner has the selected CLI and credentials.' }, es: { summary: 'Elige el agente CLI para esta tarea.', when: 'Se aplica al ejecutar la función elegida en GitHub Actions.', example: 'Codex usa el CLI codex.', effect: 'La Action usa ese proveedor, sin sustitución implícita.', verify: 'Comprueba el CLI y las credenciales del runner.' } }, + 'agent-model-defaults': { en: { summary: 'Set the model defaults shared by agent tasks.', when: 'Used unless you configure each task separately.', example: 'Keep the reviewed model by accepting the suggested value.', effect: 'The Action passes these values to the selected CLI.', verify: 'Check the plan and runner model allowlist.' }, es: { summary: 'Define el modelo común para las tareas del agente.', when: 'Se usa salvo que configures cada tarea por separado.', example: 'Acepta el modelo revisado que aparece como sugerencia.', effect: 'La Action pasa estos valores al CLI elegido.', verify: 'Revisa el plan y la lista de modelos permitidos.' } }, + 'agent-role-overrides': { en: { summary: 'Override this one agent task.', when: 'Only when independent task configuration is enabled.', example: 'Use a different model for review than for planning.', effect: 'Only this task uses the override.', verify: 'Inspect the per-task plan values.' }, es: { summary: 'Personaliza esta tarea del agente.', when: 'Solo si activaste la configuración independiente por tarea.', example: 'Usa un modelo distinto para revisión y planificación.', effect: 'Solo esta tarea usa el valor personalizado.', verify: 'Revisa los valores de cada tarea en el plan.' } }, + repository: { en: { summary: 'Set how Copilot treats your repository.', when: 'Applies to generated workflows and future issue/PR events.', example: 'Use your actual development branch name.', effect: 'Future automation follows the chosen branch and workflow rules.', verify: 'Review the planned files and repository profile.' }, es: { summary: 'Define cómo Copilot tratará tu repositorio.', when: 'Se aplica a los workflows y futuros eventos de issues/PR.', example: 'Indica el nombre real de tu rama de desarrollo.', effect: 'La automatización seguirá las ramas y reglas elegidas.', verify: 'Revisa los archivos del plan y el perfil del repositorio.' } }, + deployment: { en: { summary: 'Choose release and hotfix behavior.', when: 'Only matters when those workflows are enabled.', example: 'Keep the default strategy unless your branching policy differs.', effect: 'Changes how release branches and reconciliation PRs are managed.', verify: 'Inspect the release/hotfix section of the plan.' }, es: { summary: 'Define el comportamiento de releases y hotfixes.', when: 'Importa si activaste esos workflows.', example: 'Conserva la estrategia predeterminada salvo que tus ramas funcionen distinto.', effect: 'Cambia la gestión de ramas y PR de reconciliación.', verify: 'Revisa la sección de releases y hotfixes del plan.' } }, + bugbot: { en: { summary: 'Choose how Bugbot analyzes and reports code changes.', when: 'Used when AI review features run.', example: 'The default publishes eligible findings without blocking all PRs.', effect: 'Changes future review publication and diagnostics.', verify: 'Inspect the Bugbot Variables in the plan and later review results.' }, es: { summary: 'Define cómo Bugbot analiza y comunica cambios de código.', when: 'Se usa cuando se ejecutan funciones de revisión con IA.', example: 'Por defecto publica hallazgos aptos sin bloquear todos los PR.', effect: 'Cambia futuras revisiones y diagnósticos.', verify: 'Revisa las Variables de Bugbot en el plan y sus resultados.' } }, + 'pull-request-approval': { en: { summary: 'Choose evidence required before the bot recommends or submits PR approval.', when: 'Only applies if PR automation is enabled.', example: 'Recommend informs a human; guarded may submit a native approval.', effect: 'No PR is approved solely because this page shows green checks.', verify: 'Inspect the trusted CI, Bugbot, and branch-rule evidence.' }, es: { summary: 'Elige las pruebas necesarias para recomendar o aprobar un PR.', when: 'Solo se aplica si activaste la automatización de PR.', example: 'Recommend informa a una persona; guarded puede publicar una aprobación.', effect: 'Ningún PR se aprueba solo porque esta pantalla muestre checks verdes.', verify: 'Revisa CI, Bugbot y las reglas de rama.' } }, + projects: { en: { summary: 'Choose an existing Project Status value for an issue or PR transition.', when: 'Only when Projects integration is selected.', example: 'Todo when an issue is created; In Progress when work starts.', effect: 'Future automation updates the Status field, not a visual board column.', verify: 'Open each selected Project and inspect its Status field options.' }, es: { summary: 'Elige un valor Status existente para una transición de issue o PR.', when: 'Solo si elegiste integrar Projects.', example: 'Todo al crear un issue; In Progress al empezar el trabajo.', effect: 'La automatización futura actualiza el campo Status, no una columna visual.', verify: 'Abre cada Project y revisa las opciones de su campo Status.' } }, + provisioning: { en: { summary: 'Choose which GitHub Actions resources setup manages.', when: 'Affects PAT grants and setup writes.', example: 'Keep Secrets enabled if the bot PAT must be installed.', effect: 'Selected resources may be created or updated after approval.', verify: 'Inspect exact resource names in the plan.' }, es: { summary: 'Elige qué recursos de GitHub Actions gestionará setup.', when: 'Afecta a permisos del PAT y cambios de configuración.', example: 'Mantén Secrets si hay que instalar el PAT del bot.', effect: 'Los recursos seleccionados podrán crearse o actualizarse tras aprobar.', verify: 'Revisa los nombres exactos en el plan.' } }, + storage: { en: { summary: 'Choose where GitHub Actions Variables and Secrets live.', when: 'Applies when provisioning is enabled.', example: 'Repository scope is the simplest default.', effect: 'Affects visibility, permission grants, and precedence.', verify: 'Check the selected scope and shadow warnings in the plan.' }, es: { summary: 'Elige dónde se guardan Variables y Secrets de GitHub Actions.', when: 'Se aplica si activaste su configuración.', example: 'El ámbito de repositorio es el predeterminado más sencillo.', effect: 'Afecta a visibilidad, permisos y precedencia.', verify: 'Revisa el ámbito y los avisos de superposición en el plan.' } }, +}; + +export function setupQuestionPresentation(question: SetupQuestion): SetupQuestionPresentation { + const copy = special[question.id] ?? section[question.stateId]; + const purpose = setupQuestionPurpose(question); + const documentation = setupQuestionDocumentation(question); + const genericHow = question.kind === 'boolean' + ? { en: 'Choose Yes to enable this behavior or No to leave it off; the suggested answer appears below.', es: 'Elige Sí para activarlo o No para dejarlo desactivado; abajo verás la respuesta sugerida.' } + : question.kind === 'producer-select' + ? { en: 'Inspect each candidate run on GitHub, then select its exact job, source App ID and workflow. A listed run is observed, not proof of a required coverage gate; use manual entry for a missing producer.', es: 'Abre cada ejecución candidata en GitHub y comprueba el job, la App y el workflow exactos. Una ejecución listada es observada, no prueba que exija cobertura; usa la entrada manual si falta un productor.' } + : question.kind === 'project-select' + ? { en: 'Select Projects by title and URL. If one is missing, enter its positive URL number or exact GitHub URL; PVT_ IDs are not valid.', es: 'Marca Projects por título y URL. Si falta uno, introduce su número positivo o URL exacta de GitHub; los IDs PVT_ no valen.' } + : question.kind === 'scope-overrides' + ? { en: 'Select only inherited names you deliberately want to replace at repository scope. Leave empty to keep organization values.', es: 'Selecciona solo los nombres heredados que quieras sustituir en el repositorio. Vacío conserva los valores de la organización.' } + : question.kind === 'multi-select' + ? { en: 'Toggle the listed workflows you intend to use. You can select more than one; review their GitHub permissions before creating a PAT.', es: 'Marca los workflows que usarás. Puedes elegir varios; revisa sus permisos de GitHub antes de crear el PAT.' } + : question.kind === 'choice' + ? { en: 'Select one of the listed values after reading its consequence; the stored value is not translated.', es: 'Elige una de las opciones tras revisar sus consecuencias; el valor guardado no se traduce.' } + : question.kind === 'number' + ? { en: 'Enter a whole number within the range described in the question; accept the suggested value when unsure.', es: 'Introduce un número entero dentro del intervalo indicado; acepta el sugerido si tienes dudas.' } + : { en: 'Enter the exact value used by your repository or runner; leave it empty only when the question says empty is allowed.', es: 'Introduce el valor exacto de tu repositorio o runner; déjalo vacío solo si la pregunta lo permite.' }; + const howById: Readonly> = { + 'pullRequestApproval.coverage.checkName': { + en: 'Select one of the trusted checks above. Open its linked run and workflow file; the coverage step must fail this job when the budget fails. A green result alone is not proof.', + es: 'Elige uno de los checks fiables anteriores. Abre su ejecución y workflow; el paso de cobertura debe hacer fallar el job si no se alcanza el mínimo. Un resultado verde no basta.', + }, + 'pullRequestApproval.producerAttested': { + en: 'Answer Yes only after inspecting every selected name, App ID and workflow, plus the coverage-enforcing step of the check you just chose. Otherwise answer No and stay in recommendation mode.', + es: 'Responde Sí solo tras comprobar cada nombre, ID de App y workflow, además del paso obligatorio de cobertura del check elegido. Si no, responde No y mantén el modo recomendación.', + }, + 'pullRequestApproval.coverage.artifactWorkflowName': { + en: 'Enter the exact name of a trusted selected workflow that publishes copilot-diff-coverage-v1 for this PR/head/base. Do not enter an artifact filename or a guessed workflow name.', + es: 'Escribe el nombre exacto de un workflow fiable seleccionado que publique copilot-diff-coverage-v1 para este PR/head/base. No pongas un archivo ni un nombre supuesto.', + }, + 'projects.statusVerified': { + en: 'Open every selected Project in GitHub, inspect its Status field, and compare the exact four values shown above. Choose Yes only when all four exist in every Project; No returns to Project selection.', + es: 'Abre cada Project elegido en GitHub, revisa su campo Status y compara los cuatro valores exactos anteriores. Elige Sí solo si todos existen en cada Project; No vuelve a la selección de Projects.', + }, + }; + const statusHow = /^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id) + ? { en: 'Choose an option shown in every selected Project’s Status field. If options cannot be read, open each Project in GitHub and enter the same exact existing option; different names per Project are not supported.', + es: 'Elige una opción del campo Status de todos los Projects seleccionados. Si no se pueden consultar, abre cada Project y escribe el mismo valor existente; no se admiten nombres distintos por Project.' } + : undefined; + const how = howById[question.id] ?? statusHow ?? genericHow; + const where = location[question.stateId]; + return { + en: { label: question.label.replace(' (Space toggles, Enter confirms)', ''), ...copy.en, + summary: special[question.id] ? copy.en.summary : (purpose?.en ?? copy.en.summary), + where: where.en, how: how.en, why: `This choice is requested now so the plan, token permissions and future automation agree. ${copy.en.when}`, documentation }, + es: { label: spanishQuestionLabel(question), ...copy.es, + summary: special[question.id] ? copy.es.summary : (purpose?.es ?? copy.es.summary), + where: where.es, how: how.es, why: `Esta elección permite ajustar el plan, los permisos del PAT y la automatización futura antes de aplicar cambios. ${copy.es.when}`, documentation }, + fr: frenchQuestionExplanation(question, documentation), + pt: portugueseQuestionExplanation(question, documentation), + }; +} diff --git a/src/application/policies/setup_question_guidance_pt.ts b/src/application/policies/setup_question_guidance_pt.ts new file mode 100644 index 000000000..def2f336b --- /dev/null +++ b/src/application/policies/setup_question_guidance_pt.ts @@ -0,0 +1,79 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; +import type { SetupQuestionExplanation } from '../contracts/web_setup_view'; +import { translatedQuestionLabel } from './setup_question_labels_fr_pt'; +import { setupQuestionPurposeFrPt } from './setup_question_purpose_fr_pt'; + +type Copy = Pick; + +const where: Readonly> = { + capabilities: 'A configuração escreve os fluxos escolhidos neste repositório e pede apenas as permissões GitHub necessárias.', + 'agent-runtime': 'Os fluxos GitHub Actions gerados executam este agente no respetivo runner; nada é instalado neste computador.', + 'agent-model-defaults': 'O modelo e o comando comuns são guardados na configuração do repositório usada pelos fluxos gerados.', + 'agent-role-overrides': 'Esta exceção para uma tarefa é guardada no repositório e lida apenas quando essa tarefa é executada.', + repository: 'O perfil do repositório e os fluxos gerados usam este valor em futuros eventos de ramos, questões e pull requests.', + deployment: 'O perfil do repositório controla futuros fluxos de release e hotfix; responder não publica nada.', + bugbot: 'O fluxo gerado lê esta definição da configuração ou das Variables do GitHub Actions selecionadas.', + 'pull-request-approval': 'A aprovação protegida usa identidades exatas dos produtores CI e provas das execuções de pull requests no GitHub.', + projects: 'Os Projects escolhidos e os valores do campo Status serão usados pela futura automatização de questões e pull requests.', + provisioning: 'Após a confirmação final, a configuração pode criar ou atualizar os ficheiros e recursos do GitHub Actions escolhidos.', + storage: 'O GitHub Actions guarda estes recursos no repositório ou na organização; o âmbito altera a visibilidade e as permissões do PAT.', +}; + +const section: Readonly> = { + capabilities: { summary: 'Escolha as automatizações que o Copilot irá instalar.', when: 'Isto afeta os fluxos, as permissões GitHub e as perguntas seguintes.', example: 'Desative uma função que não pretende utilizar.', effect: 'Só as funções selecionadas entram no plano.', verify: 'Reveja o plano antes de aplicar alterações.' }, + 'agent-runtime': { summary: 'Escolha o agente CLI para esta tarefa.', when: 'Aplica-se quando a função selecionada é executada no GitHub Actions.', example: 'O Codex é executado através do comando codex.', effect: 'A Action executa o fornecedor escolhido, sem substituição implícita.', verify: 'Confirme que o runner tem o CLI e as credenciais necessárias.' }, + 'agent-model-defaults': { summary: 'Defina os modelos predefinidos comuns às tarefas do agente.', when: 'Usam-se salvo se configurar cada tarefa separadamente.', example: 'Mantenha o modelo sugerido se não tiver uma necessidade específica.', effect: 'A Action passa estes valores ao CLI escolhido.', verify: 'Reveja o plano e os modelos permitidos no runner.' }, + 'agent-role-overrides': { summary: 'Personalize esta tarefa do agente.', when: 'Apenas se tiver ativado a configuração independente por tarefa.', example: 'Use um modelo diferente para revisão e planeamento.', effect: 'A exceção só se aplica a esta tarefa.', verify: 'Reveja os valores de cada tarefa no plano.' }, + repository: { summary: 'Defina como o Copilot trata o seu repositório.', when: 'Aplica-se aos fluxos gerados e a futuros eventos de questões ou pull requests.', example: 'Indique o nome real do ramo de desenvolvimento.', effect: 'A futura automatização segue os ramos e as regras escolhidos.', verify: 'Reveja os ficheiros planeados e o perfil do repositório.' }, + deployment: { summary: 'Defina o comportamento de releases e hotfixes.', when: 'Só importa se esses fluxos estiverem ativados.', example: 'Mantenha a estratégia predefinida salvo se a política de ramos for diferente.', effect: 'Altera a gestão de ramos e pull requests de reconciliação.', verify: 'Reveja a secção de releases e hotfixes do plano.' }, + bugbot: { summary: 'Defina como o Bugbot analisa e comunica alterações.', when: 'Usa-se quando as funções de revisão por IA são executadas.', example: 'Por predefinição, publica resultados elegíveis sem bloquear todas as pull requests.', effect: 'Altera futuras publicações e diagnósticos de revisão.', verify: 'Reveja as Variables do Bugbot no plano e os resultados posteriores.' }, + 'pull-request-approval': { summary: 'Escolha as provas exigidas antes de o bot recomendar ou submeter uma aprovação.', when: 'Só se aplica se a automatização de pull requests estiver ativa.', example: '«Recommend» informa uma pessoa; «guarded» pode aprovar no GitHub.', effect: 'Uma verificação verde nesta página nunca aprova uma pull request por si só.', verify: 'Inspecione as provas CI, o Bugbot e as regras de ramos.' }, + projects: { summary: 'Escolha um valor Status existente para uma transição de questão ou PR.', when: 'Apenas se integrar Projects.', example: 'Todo na criação; In Progress no início do trabalho.', effect: 'A automatização atualiza o campo Status, não uma coluna visual.', verify: 'Verifique as opções Status de cada Project escolhido.' }, + provisioning: { summary: 'Escolha os recursos do GitHub Actions geridos pela configuração.', when: 'Isto afeta as permissões do PAT e as alterações previstas.', example: 'Mantenha os Secrets ativos se for necessário instalar o PAT do bot.', effect: 'Os recursos selecionados poderão ser criados ou atualizados após aprovação.', verify: 'Reveja os nomes exatos dos recursos no plano.' }, + storage: { summary: 'Escolha onde ficam as Variables e Secrets do GitHub Actions.', when: 'Aplica-se quando a sua criação está ativa.', example: 'O âmbito do repositório é a opção predefinida mais simples.', effect: 'Altera visibilidade, permissões e precedência.', verify: 'Confirme o âmbito e os avisos de sobreposição no plano.' }, +}; + +const special: Readonly> = { + 'agents.findings.executable': { summary: 'Escolha o comando do agente no runner GitHub Actions, não neste computador.', when: 'Altere-o apenas se tiver instalado deliberadamente outro agente no runner.', example: 'Deixe vazio para codex, opencode ou agent, conforme o fornecedor.', effect: 'O caminho personalizado é usado pelas tarefas escolhidas e nunca é instalado automaticamente.', verify: 'Confirme que o runner tem exatamente este executável antes de ativar o fluxo.' }, + 'ai.includeReasoning': { summary: 'Peça explicações adicionais se a resposta do fornecedor as disponibilizar.', when: 'Só para diagnóstico avançado; o percurso CLI atual não fornece partes de raciocínio separadas.', example: 'Mantenha desativado numa configuração normal.', effect: 'Pode acrescentar texto do fornecedor, sem garantir metadados breves.', verify: 'Inspecione uma resposta estruturada controlada; não presuma que a opção produziu texto adicional.' }, + 'ai.bugbotDryRun': { summary: 'Mantenha o Bugbot em modo apenas de análise nas próximas execuções.', when: 'Útil numa avaliação; incompatível com provas de aprovação.', example: 'Escolha Não para publicar revisões normais.', effect: 'O Bugbot analisa sem publicar resultados nem alterar o repositório. Não é setup --dry-run.', verify: 'Inspecione o resultado do fluxo Bugbot: a simulação não publica revisão nem verificação.' }, + 'ai.bugbotOrganizationRules': { summary: 'Defina instruções gerais para o Bugbot, uma regra por linha.', when: 'Use se a equipa precisar de critérios de revisão partilhados no repositório configurado.', example: 'Assinalar alterações que contornem o isolamento entre clientes.', effect: 'Estas regras precedem as do repositório; o âmbito da Variable determina o armazenamento.', verify: 'Inspecione a Variable configurada e ative o rastreio das fontes das regras.' }, + 'ai.provisioningMode': { summary: 'Decida como a Action encontra ou instala o agente CLI.', when: 'Aplica-se no runner quando começa uma tarefa de IA ativa.', example: 'Auto reutiliza um CLI instalado ou instala uma versão fixa de Codex/OpenCode.', effect: 'Always reinstala as versões revistas; Disabled exige um CLI pré-instalado. Cursor tem de estar pré-instalado.', verify: 'Inspecione a etapa de preparação e a versão do binário comunicada pelo runner.' }, + 'pullRequestApproval.testChecks': { summary: 'Escolha os jobs CI que o bot pode aceitar como prova independente de testes.', when: 'Obrigatório para os modos Recommend e Guarded.', example: 'Selecione o job Tests exato, o ID da App GitHub e o workflow de uma execução recente.', effect: 'Só as identidades exatas listadas satisfazem a condição de aprovação.', verify: 'Abra a execução associada e confirme job, App e resultado do commit atual.' }, + 'pullRequestApproval.producerAttested': { summary: 'Confirme que inspecionou o produtor CI exato e a sua etapa obrigatória de cobertura.', when: 'Obrigatório antes de o modo Guarded poder aprovar.', example: 'Confirme que o job Tests falha se o limite de cobertura não for atingido.', effect: 'A sua confirmação fica registada; o Copilot não a deduz de uma verificação verde.', verify: 'Inspecione o ficheiro do workflow e uma execução real antes de escolher Sim.' }, + 'pullRequestApproval.coverage.mode': { summary: 'Escolha como comprovar a cobertura exigida do código alterado.', when: 'Aplica-se quando a aprovação de PR está ativa.', example: 'Check: o CI exige o limite. Numeric: um workflow fiável publica contagens limitadas.', effect: 'Check confia numa condição CI; Numeric lê copilot-diff-coverage-v1 e compara o limite.', verify: 'Inspecione, respetivamente, a condição de falha CI ou o artefacto do relatório.' }, + 'pullRequestApproval.coverage.checkName': { summary: 'Selecione a verificação fiável que falha abaixo do limite de cobertura.', when: 'Obrigatório nos dois modos de prova.', example: 'Use o mesmo job Tests exato da etapa anterior.', effect: 'O sucesso de outra verificação ou App não substitui esta condição.', verify: 'Confirme que a etapa de cobertura é obrigatória e não apenas informativa.' }, + 'projects.enabled': { summary: 'Decida se futuras questões e PR devem usar Projects GitHub existentes.', when: 'Antes de criar o PAT de configuração para prever o acesso de leitura a Projects.', example: 'Sim se a equipa usa um Project da organização; Não para ignorar.', effect: 'Sim inclui Projects: read da organização quando necessário. Nenhum Project é alterado agora.', verify: 'Reveja as permissões do PAT; escolherá os Projects concretos depois de o autorizar.' }, + 'projects.ids': { summary: 'Selecione os Projects existentes que o Copilot poderá atualizar futuramente.', when: 'Após verificar o PAT; se a lista não estiver disponível, introduza os dados manualmente.', example: 'Para https://github.com/orgs/acme/projects/5, marque o cartão ou introduza 5, nunca PVT_…', effect: 'Os números ficam guardados; nenhum item de Project é alterado agora.', verify: 'Abra cada Project e confirme proprietário e número antes de aprovar o plano.' }, +}; + +function howToChoose(question: SetupQuestion): string { + if (question.id === 'pullRequestApproval.coverage.checkName') return 'Escolha uma das verificações fiáveis acima. Abra a execução e o workflow: o passo de cobertura tem de fazer falhar o job quando o limite não é atingido. Um resultado verde não basta.'; + if (question.id === 'pullRequestApproval.producerAttested') return 'Responda Sim apenas depois de verificar cada nome, ID da App e workflow escolhido, bem como o passo obrigatório de cobertura do check selecionado. Caso contrário, responda Não e mantenha o modo de recomendação.'; + if (question.id === 'pullRequestApproval.coverage.artifactWorkflowName') return 'Introduza o nome exato de um workflow fiável selecionado que publique copilot-diff-coverage-v1 para este PR e os seus commits base e head. Não adivinhe o nome do workflow.'; + if (question.id === 'projects.statusVerified') return 'Abra cada Project escolhido no GitHub, inspecione o campo Status e compare os quatro valores exatos acima. Responda Sim apenas se todos existirem em cada Project; Não regressa à seleção de Projects.'; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) return 'Escolha uma opção do campo Status presente em todos os Projects selecionados. Se não conseguir consultar as opções, abra cada Project no GitHub e introduza o mesmo valor existente; valores diferentes por Project não são suportados.'; + switch (question.kind) { + case 'boolean': return 'Escolha Sim para ativar ou Não para desativar; a resposta sugerida aparece abaixo.'; + case 'producer-select': return 'Inspecione cada execução candidata no GitHub e escolha o job, ID da App e workflow exatos. Introduza manualmente apenas se não houver candidato verificado.'; + case 'project-select': return 'Selecione pelo título e URL. Se faltar um Project, introduza o número positivo ou URL exato do GitHub; IDs PVT_ não são válidos.'; + case 'scope-overrides': return 'Selecione apenas os nomes herdados que pretende substituir no repositório. Deixe vazio para conservar os valores da organização.'; + case 'multi-select': return 'Assinale os fluxos que pretende usar. Pode escolher vários; reveja as permissões antes de criar um PAT.'; + case 'choice': return 'Escolha um valor após ler as consequências; o valor guardado não é traduzido.'; + case 'number': return 'Introduza um número inteiro no intervalo indicado; mantenha o valor sugerido se tiver dúvidas.'; + default: return 'Introduza o valor exato usado pelo repositório ou runner; deixe vazio apenas se a pergunta o permitir.'; + } +} + +export function portugueseQuestionExplanation(question: SetupQuestion, documentation: SetupQuestionExplanation['documentation']): SetupQuestionExplanation { + const copy = special[question.id] ?? section[question.stateId]; + return { + label: translatedQuestionLabel(question, 'pt'), + ...copy, + summary: special[question.id] ? copy.summary : (setupQuestionPurposeFrPt(question, 'pt') ?? copy.summary), + where: where[question.stateId], + how: howToChoose(question), + why: `Esta decisão permite alinhar o plano, as permissões do PAT e a futura automatização antes de aplicar alterações. ${copy.when}`, + documentation: { title: 'Documentação desta opção', url: documentation.url }, + }; +} diff --git a/src/application/policies/setup_question_labels/fr.ts b/src/application/policies/setup_question_labels/fr.ts new file mode 100644 index 000000000..8a1bbf6f1 --- /dev/null +++ b/src/application/policies/setup_question_labels/fr.ts @@ -0,0 +1,86 @@ +import type { spanishQuestionLabels } from '../setup_question_translations'; + +/** French presentation labels; semantic question IDs remain unchanged. */ +export const questionLabelsFr: Readonly> = { + 'features.issues': 'Automatiser les tickets : branches, étiquettes, projets et cycle de vie', + 'features.pullRequests': 'Automatiser les pull requests : revue, description et cycle de vie', + 'features.commits': 'Automatiser les commits : progression, taille et analyse Bugbot', + 'features.issueComments': 'Répondre aux commentaires des tickets et permettre les corrections Bugbot', + 'features.pullRequestComments': 'Répondre aux commentaires des pull requests et permettre les corrections Bugbot', + 'features.agentProvisioning': 'Vérifier l’installation des agents CLI dans GitHub Actions', + 'features.credentialHealth': 'Vérifier l’état des identifiants distants', + 'features.inactiveIssueClosure': 'Fermer les tickets inactifs après le délai défini', + 'features.issueTemplates': 'Installer les modèles de ticket', + 'features.pullRequestTemplate': 'Installer le modèle de pull request', + 'issueWorkflows.enabled': 'Types de workflows de ticket à activer', + 'repositoryAgentGuidance.enabled': 'Générer des instructions pour les agents dans le dépôt ?', + 'repositoryAgentGuidance.agentsPointer': 'Comment trouver les instructions depuis AGENTS.md', + 'agents.findings.modelProvider': 'Fournisseur de modèle partagé (sauf réglage propre à une tâche)', + 'agents.findings.model': 'Modèle partagé (sauf réglage propre à une tâche)', + 'agents.findings.effort': 'Effort de raisonnement partagé (les réglages par tâche sont conservés)', + 'agents.findings.executable': 'Exécutable partagé validé (les réglages par tâche sont conservés)', + 'agents.configureIndependently': 'Configurer le modèle et la commande séparément pour chaque tâche ?', + 'repository.mainBranch': 'Branche de production', + 'repository.developmentBranch': 'Branche de développement', + 'repository.featureTree': 'Préfixe des branches de fonctionnalité', + 'repository.bugfixTree': 'Préfixe des branches de correction', + 'repository.hotfixTree': 'Préfixe des branches de correctif urgent', + 'repository.releaseTree': 'Préfixe des branches de version', + 'repository.docsTree': 'Préfixe des branches de documentation', + 'repository.choreTree': 'Préfixe des branches de maintenance', + 'repository.issueManagedBranches': 'L’Action peut-elle créer des branches liées aux tickets ?', + 'repository.preBranchSdd': 'Exiger un SDD avant de créer certaines branches ?', + 'repository.reopenIssueOnPush': 'Rouvrir un ticket fermé quand sa branche reçoit des commits ?', + 'repository.desiredAssigneesCount': 'Nombre souhaité de responsables par ticket', + 'repository.desiredReviewersCount': 'Nombre souhaité de réviseurs par pull request', + 'repository.inactivityThresholdHours': 'Heures d’inactivité avant la fermeture d’un ticket en attente', + 'repository.repositoryLocale': 'Langue des messages du dépôt', + 'repository.issueLocale': 'Langue des tickets (vide : hériter)', + 'repository.pullRequestLocale': 'Langue des pull requests (vide : hériter)', + 'repository.commitPrefixTransforms': 'Transformation des préfixes de commit', + 'repository.releaseReconciliationStrategy': 'Stratégie de réconciliation des versions', + 'repository.hotfixReconciliationStrategy': 'Stratégie de réconciliation des correctifs urgents', + 'repository.reconciliationPullRequestMode': 'Mode des pull requests de réconciliation', + 'repository.reconciliationBackmergeMode': 'Mode de fusion de retour', + 'repository.hotfixActiveReleasePolicy': 'Destination du correctif pendant une version active', + 'repository.reconciliationTree': 'Préfixe des branches de réconciliation', + 'repository.reconciliationCleanup': 'Nettoyage des branches après réconciliation', + 'repository.reconciliationIssueCompletion': 'Sort du ticket après réconciliation', + 'repository.orchestrationPresentationMode': 'Niveau de détail du centre de contrôle des versions', + 'repository.orchestrationDiagrams': 'Afficher des diagrammes accessibles pour les versions ?', + 'repository.orchestrationCommentMode': 'Comment publier les commentaires du cycle de version', + 'ai.pullRequestDescriptionMode': 'Comment mettre à jour la description des pull requests', + 'ai.ignoreFiles': 'Fichiers que l’IA doit ignorer', + 'ai.membersOnly': 'Limiter le traitement par IA aux membres du dépôt ?', + 'ai.includeReasoning': 'Inclure des explications supplémentaires du fournisseur ?', + 'ai.bugbotSeverity': 'Gravité minimale des résultats publiés par Bugbot', + 'ai.bugbotCommentLimit': 'Nombre maximal de commentaires Bugbot par exécution', + 'ai.bugbotFixVerifyCommands': 'Commandes de vérification des corrections Bugbot', + 'ai.bugbotDryRun': 'Analyser avec Bugbot sans publier de changements ?', + 'ai.bugbotEffort': 'Profondeur de l’analyse Bugbot', + 'ai.bugbotReviewDrafts': 'Analyser les pull requests en brouillon ?', + 'ai.bugbotTraceRules': 'Indiquer quelles sources de règles ont été appliquées ?', + 'ai.bugbotSuggestedChanges': 'Publier des suggestions de modification sûres ?', + 'ai.bugbotTelemetry': 'Enregistrer des métriques Bugbot sans contenu ?', + 'ai.bugbotFailOnUnresolved': 'Faire échouer la vérification si des résultats restent ouverts ?', + 'ai.bugbotOrganizationRules': 'Règles Bugbot communes, une par ligne', + 'ai.provisioningMode': 'Comment préparer l’agent CLI sur le runner', + 'pullRequestApproval.mode': 'Que peut faire le bot pour approuver les pull requests ?', + 'pullRequestApproval.testChecks': 'Quelles vérifications CI sont fiables pour approuver ?', + 'pullRequestApproval.producerAttested': 'Avez-vous vérifié le job, l’App et l’étape obligatoire de couverture ?', + 'pullRequestApproval.coverage.mode': 'Comment prouver la couverture requise', + 'pullRequestApproval.coverage.checkName': 'Vérification fiable imposant la couverture', + 'pullRequestApproval.coverage.minDiffPercent': 'Couverture minimale des lignes modifiées (0–100)', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Workflow publiant copilot-diff-coverage-v1', + 'pullRequestApproval.coverage.reporterAttested': 'Avez-vous vérifié l’installation du rapporteur numérique ?', + 'projects.enabled': 'Intégrer des Projects GitHub existants ?', + 'projects.ids': 'Choisir des Projects existants ou saisir leurs numéros d’URL', + 'projects.statusVerified': 'Avez-vous vérifié sur GitHub les quatre valeurs Status exactes de chaque Project choisi ?', + 'projects.issueCreatedColumn': 'Valeur Status des nouveaux tickets', + 'projects.pullRequestCreatedColumn': 'Valeur Status des nouvelles pull requests', + 'projects.issueInProgressColumn': 'Valeur Status des tickets en cours', + 'projects.pullRequestInProgressColumn': 'Valeur Status des pull requests en cours', + createInitialTag: 'Créer v1.0.0 si aucune étiquette de version n’existe ?', + manageRepositoryVariables: 'Créer ou mettre à jour les Variables GitHub Actions ?', + manageRepositorySecrets: 'Valider et configurer les Secrets GitHub Actions ?', +}; diff --git a/src/application/policies/setup_question_labels/pt.ts b/src/application/policies/setup_question_labels/pt.ts new file mode 100644 index 000000000..bd63d7667 --- /dev/null +++ b/src/application/policies/setup_question_labels/pt.ts @@ -0,0 +1,86 @@ +import type { spanishQuestionLabels } from '../setup_question_translations'; + +/** Portuguese presentation labels; semantic question IDs remain unchanged. */ +export const questionLabelsPt: Readonly> = { + 'features.issues': 'Automatizar questões: ramos, etiquetas, projetos e ciclo de vida', + 'features.pullRequests': 'Automatizar pull requests: revisão, descrição e ciclo de vida', + 'features.commits': 'Automatizar commits: progresso, tamanho e análise do Bugbot', + 'features.issueComments': 'Responder a comentários de questões e permitir correções do Bugbot', + 'features.pullRequestComments': 'Responder a comentários de pull requests e permitir correções do Bugbot', + 'features.agentProvisioning': 'Verificar a instalação dos agentes CLI no GitHub Actions', + 'features.credentialHealth': 'Verificar o estado das credenciais remotas', + 'features.inactiveIssueClosure': 'Fechar questões inativas após o prazo definido', + 'features.issueTemplates': 'Instalar modelos de questão', + 'features.pullRequestTemplate': 'Instalar o modelo de pull request', + 'issueWorkflows.enabled': 'Tipos de fluxo de questões a ativar', + 'repositoryAgentGuidance.enabled': 'Gerar instruções para agentes no repositório?', + 'repositoryAgentGuidance.agentsPointer': 'Como encontrar as instruções a partir de AGENTS.md', + 'agents.findings.modelProvider': 'Fornecedor de modelo partilhado (salvo definição própria de uma tarefa)', + 'agents.findings.model': 'Modelo partilhado (salvo definição própria de uma tarefa)', + 'agents.findings.effort': 'Esforço de raciocínio partilhado (as definições por tarefa são preservadas)', + 'agents.findings.executable': 'Executável partilhado validado (as definições por tarefa são preservadas)', + 'agents.configureIndependently': 'Configurar modelo e comando separadamente para cada tarefa?', + 'repository.mainBranch': 'Ramo de produção', + 'repository.developmentBranch': 'Ramo de desenvolvimento', + 'repository.featureTree': 'Prefixo dos ramos de funcionalidade', + 'repository.bugfixTree': 'Prefixo dos ramos de correção', + 'repository.hotfixTree': 'Prefixo dos ramos de hotfix', + 'repository.releaseTree': 'Prefixo dos ramos de release', + 'repository.docsTree': 'Prefixo dos ramos de documentação', + 'repository.choreTree': 'Prefixo dos ramos de manutenção', + 'repository.issueManagedBranches': 'A Action pode criar ramos associados a questões?', + 'repository.preBranchSdd': 'Exigir um SDD antes de criar determinados ramos?', + 'repository.reopenIssueOnPush': 'Reabrir uma questão fechada quando o seu ramo recebe commits?', + 'repository.desiredAssigneesCount': 'Número pretendido de responsáveis por questão', + 'repository.desiredReviewersCount': 'Número pretendido de revisores por pull request', + 'repository.inactivityThresholdHours': 'Horas de inatividade antes de fechar uma questão em espera', + 'repository.repositoryLocale': 'Idioma das mensagens do repositório', + 'repository.issueLocale': 'Idioma das questões (vazio: herdar)', + 'repository.pullRequestLocale': 'Idioma das pull requests (vazio: herdar)', + 'repository.commitPrefixTransforms': 'Transformação dos prefixos dos commits', + 'repository.releaseReconciliationStrategy': 'Estratégia de reconciliação de releases', + 'repository.hotfixReconciliationStrategy': 'Estratégia de reconciliação de hotfixes', + 'repository.reconciliationPullRequestMode': 'Modo das pull requests de reconciliação', + 'repository.reconciliationBackmergeMode': 'Modo da fusão de retorno', + 'repository.hotfixActiveReleasePolicy': 'Destino do hotfix durante uma release ativa', + 'repository.reconciliationTree': 'Prefixo dos ramos de reconciliação', + 'repository.reconciliationCleanup': 'Limpeza de ramos após a reconciliação', + 'repository.reconciliationIssueCompletion': 'O que fazer à questão após a reconciliação', + 'repository.orchestrationPresentationMode': 'Nível de detalhe do centro de controlo de releases', + 'repository.orchestrationDiagrams': 'Mostrar diagramas acessíveis para releases?', + 'repository.orchestrationCommentMode': 'Como publicar os comentários do ciclo de release', + 'ai.pullRequestDescriptionMode': 'Como atualizar a descrição das pull requests', + 'ai.ignoreFiles': 'Ficheiros que a IA deve ignorar', + 'ai.membersOnly': 'Limitar o processamento por IA aos membros do repositório?', + 'ai.includeReasoning': 'Incluir explicações adicionais do fornecedor?', + 'ai.bugbotSeverity': 'Gravidade mínima dos resultados publicados pelo Bugbot', + 'ai.bugbotCommentLimit': 'Número máximo de comentários do Bugbot por execução', + 'ai.bugbotFixVerifyCommands': 'Comandos de verificação das correções do Bugbot', + 'ai.bugbotDryRun': 'Analisar com o Bugbot sem publicar alterações?', + 'ai.bugbotEffort': 'Profundidade da análise do Bugbot', + 'ai.bugbotReviewDrafts': 'Rever pull requests em rascunho?', + 'ai.bugbotTraceRules': 'Indicar que fontes de regras foram aplicadas?', + 'ai.bugbotSuggestedChanges': 'Publicar sugestões de alteração seguras?', + 'ai.bugbotTelemetry': 'Registar métricas do Bugbot sem conteúdo?', + 'ai.bugbotFailOnUnresolved': 'Fazer falhar a verificação se houver resultados por resolver?', + 'ai.bugbotOrganizationRules': 'Regras Bugbot partilhadas, uma por linha', + 'ai.provisioningMode': 'Como preparar o agente CLI no runner', + 'pullRequestApproval.mode': 'O que pode o bot fazer na aprovação de pull requests?', + 'pullRequestApproval.testChecks': 'Que verificações CI são fiáveis para aprovar?', + 'pullRequestApproval.producerAttested': 'Verificou o job, a App e a etapa obrigatória de cobertura?', + 'pullRequestApproval.coverage.mode': 'Como comprovar a cobertura exigida', + 'pullRequestApproval.coverage.checkName': 'Verificação fiável que exige cobertura', + 'pullRequestApproval.coverage.minDiffPercent': 'Cobertura mínima das linhas alteradas (0–100)', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Workflow que publica copilot-diff-coverage-v1', + 'pullRequestApproval.coverage.reporterAttested': 'Verificou a instalação do relatório numérico?', + 'projects.enabled': 'Integrar Projects GitHub existentes?', + 'projects.ids': 'Selecionar Projects existentes ou introduzir os números dos URL', + 'projects.statusVerified': 'Confirmou no GitHub os quatro valores Status exatos de cada Project escolhido?', + 'projects.issueCreatedColumn': 'Valor Status das novas questões', + 'projects.pullRequestCreatedColumn': 'Valor Status das novas pull requests', + 'projects.issueInProgressColumn': 'Valor Status das questões em curso', + 'projects.pullRequestInProgressColumn': 'Valor Status das pull requests em curso', + createInitialTag: 'Criar v1.0.0 se ainda não existir uma etiqueta de versão?', + manageRepositoryVariables: 'Criar ou atualizar as Variables do GitHub Actions?', + manageRepositorySecrets: 'Validar e configurar os Secrets do GitHub Actions?', +}; diff --git a/src/application/policies/setup_question_labels_fr_pt.ts b/src/application/policies/setup_question_labels_fr_pt.ts new file mode 100644 index 000000000..6461aadfc --- /dev/null +++ b/src/application/policies/setup_question_labels_fr_pt.ts @@ -0,0 +1,36 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; +import { spanishQuestionLabel } from './setup_question_translations'; +import { questionLabelsFr } from './setup_question_labels/fr'; +import { questionLabelsPt } from './setup_question_labels/pt'; + +export const questionLabelsFrPt = { fr: questionLabelsFr, pt: questionLabelsPt } as const; + +const roleNames = { + fr: { planner: 'Planification', findings: 'Résultats', reviewer: 'Revue', fixer: 'Correction', tester: 'Tests' }, + pt: { planner: 'Planeamento', findings: 'Resultados', reviewer: 'Revisão', fixer: 'Correção', tester: 'Testes' }, +} as const; + +export function translatedQuestionLabel(question: SetupQuestion, locale: 'en' | 'es' | 'fr' | 'pt'): string { + if (locale === 'en') return question.label.replace(' (Space toggles, Enter confirms)', ''); + if (locale === 'es') return spanishQuestionLabel(question); + const exact = questionLabelsFrPt[locale][question.id]; + if (exact) return exact; + const agent = question.id.match(/^agents\.(planner|findings|reviewer|fixer|tester)\.(provider|modelProvider|model|effort|executable)$/u); + if (agent) { + const fields = { + fr: { provider: 'agent CLI', modelProvider: 'fournisseur du modèle', model: 'modèle', effort: 'effort de raisonnement', executable: 'commande exécutable' }, + pt: { provider: 'agente CLI', modelProvider: 'fornecedor do modelo', model: 'modelo', effort: 'esforço de raciocínio', executable: 'comando executável' }, + } as const; + return `${roleNames[locale][agent[1] as keyof typeof roleNames.fr]} : ${fields[locale][agent[2] as keyof typeof fields.fr]}`; + } + const storage = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storage) { + const resources = { fr: { variables: 'Variables', secrets: 'Secrets' }, pt: { variables: 'Variables', secrets: 'Secrets' } } as const; + const fields = { + fr: { defaultScope: 'périmètre par défaut', organizationVisibility: 'visibilité dans l’organisation', preserveExisting: 'conserver les ressources existantes', overrides: 'exceptions de périmètre' }, + pt: { defaultScope: 'âmbito predefinido', organizationVisibility: 'visibilidade na organização', preserveExisting: 'conservar recursos existentes', overrides: 'exceções de âmbito' }, + } as const; + return `${resources[locale][storage[1] as 'variables' | 'secrets']} : ${fields[locale][storage[2] as keyof typeof fields.fr]}`; + } + return question.label; +} diff --git a/src/application/policies/setup_question_purpose/fr.ts b/src/application/policies/setup_question_purpose/fr.ts new file mode 100644 index 000000000..8e251d23c --- /dev/null +++ b/src/application/policies/setup_question_purpose/fr.ts @@ -0,0 +1,51 @@ +/** French question-specific purposes. */ +export const purposesFr: Readonly> = { + 'issueWorkflows.enabled': 'Choisissez les workflows de ticket que Copilot pourra exécuter ; chacun agit différemment sur les branches, étiquettes et automatisations.', + 'repositoryAgentGuidance.enabled': 'Générez des instructions pour aider les agents IA à travailler en sécurité dans ce projet.', + 'repositoryAgentGuidance.agentsPointer': 'Décidez si le fichier AGENTS.md racine renvoie aux instructions générées, est créé s’il manque, ou reste intact.', + 'agents.configureIndependently': 'Attribuez des fournisseurs et modèles distincts à la planification, aux résultats, à la revue, à la correction et aux tests.', + 'repository.mainBranch': 'Indiquez la branche de production utilisée comme référence par les versions et correctifs urgents.', + 'repository.developmentBranch': 'Indiquez la branche d’intégration habituelle visée par la création de branches et la réconciliation.', + 'repository.issueManagedBranches': 'Autorisez l’Action à créer une branche liée lorsqu’un ticket passe en cours.', + 'repository.preBranchSdd': 'Exigez un document de conception approuvé avant certaines branches de fonctionnalité ou de changement de contrat.', + 'repository.reopenIssueOnPush': 'Rouvrez un ticket terminé quand de nouveaux commits arrivent sur sa branche liée.', + 'repository.desiredAssigneesCount': 'Définissez combien de personnes Copilot affecte à un nouveau ticket ; zéro désactive l’affectation automatique.', + 'repository.desiredReviewersCount': 'Définissez combien de réviseurs Copilot sollicite pour une pull request ; zéro désactive les demandes automatiques.', + 'repository.inactivityThresholdHours': 'Définissez combien de temps un ticket reste sans activité avant que le workflow activé puisse le fermer.', + 'repository.repositoryLocale': 'Choisissez la balise de langue BCP-47 des messages Copilot sur GitHub ; elle ne change pas la langue de cette page.', + 'repository.issueLocale': 'Changez la langue des messages GitHub pour les tickets ; laissez vide pour hériter de la langue du dépôt.', + 'repository.pullRequestLocale': 'Changez la langue des messages GitHub pour les pull requests ; laissez vide pour hériter de la langue du dépôt.', + 'repository.commitPrefixTransforms': 'Définissez les substitutions de préfixes de commit ; laissez vide si vos conventions n’en ont pas besoin.', + 'repository.releaseReconciliationStrategy': 'Choisissez comment les changements d’une version terminée reviennent dans le développement sans perdre leur filiation.', + 'repository.hotfixReconciliationStrategy': 'Choisissez comment un correctif urgent de production est reporté sur les branches en cours.', + 'repository.reconciliationPullRequestMode': 'Choisissez si les pull requests de réconciliation sont créées, fusionnées, mises en file ou laissées à une personne.', + 'repository.reconciliationBackmergeMode': 'Choisissez une fusion de retour directe ou passant par une branche de synchronisation.', + 'repository.hotfixActiveReleasePolicy': 'Choisissez où propager un correctif urgent lorsqu’une branche de version est déjà active.', + 'repository.reconciliationCleanup': 'Choisissez les branches temporaires à supprimer après une réconciliation réussie.', + 'repository.reconciliationIssueCompletion': 'Choisissez si le ticket à l’origine de la réconciliation se ferme ou reste ouvert pour suivi.', + 'repository.orchestrationPresentationMode': 'Choisissez le niveau de progression et de détail affiché dans le centre de contrôle GitHub des versions.', + 'repository.orchestrationDiagrams': 'Incluez des diagrammes Mermaid accessibles dans les informations de version.', + 'repository.orchestrationCommentMode': 'Choisissez si les commentaires de version sont mis à jour ou publiés à chaque étape importante.', + 'ai.pullRequestDescriptionMode': 'Choisissez si l’IA remplace, complète, préserve ou ne modifie jamais les descriptions des pull requests.', + 'ai.ignoreFiles': 'Indiquez les motifs de fichiers à exclure de la revue IA ; ces fichiers restent visibles sur GitHub.', + 'ai.membersOnly': 'N’autorisez le traitement IA que pour les demandes des membres du dépôt, pas pour tous les contributeurs externes.', + 'ai.bugbotSeverity': 'Fixez la gravité minimale publiée par Bugbot ; les résultats moins graves restent non publiés.', + 'ai.bugbotCommentLimit': 'Limitez les commentaires Bugbot par exécution pour ne pas submerger une pull request.', + 'ai.bugbotFixVerifyCommands': 'Indiquez les commandes qui doivent réussir avant qu’une correction automatique Bugbot soit considérée comme vérifiée.', + 'ai.bugbotEffort': 'Choisissez la profondeur des revues Bugbot ; un effort supérieur peut durer et consommer davantage.', + 'ai.bugbotReviewDrafts': 'Décidez si Bugbot analyse les pull requests en brouillon avant qu’elles soient prêtes.', + 'ai.bugbotTraceRules': 'Ajoutez l’origine de chaque règle de revue appliquée dans les résumés Bugbot pour faciliter l’audit.', + 'ai.bugbotSuggestedChanges': 'Autorisez Bugbot à joindre des suggestions de code sûres aux résultats publiés.', + 'ai.bugbotTelemetry': 'Enregistrez des métriques opérationnelles Bugbot sans stocker le contenu du dépôt.', + 'ai.bugbotFailOnUnresolved': 'Faites échouer la vérification Bugbot tant que des résultats exploitables restent ouverts.', + 'pullRequestApproval.mode': 'Choisissez si le bot recommande une approbation, peut approuver GitHub sous garde, ou n’intervient pas.', + 'pullRequestApproval.coverage.minDiffPercent': 'Définissez le pourcentage minimal de lignes modifiées couvertes qu’un rapporteur numérique fiable doit prouver.', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Indiquez le workflow fiable exact qui publie l’artefact copilot-diff-coverage-v1.', + 'pullRequestApproval.coverage.reporterAttested': 'Confirmez avoir inspecté le rapporteur numérique dans ce workflow exact, et non seulement sa vérification verte.', + 'projects.enabled': 'Décidez si Copilot doit ajouter tickets et pull requests à des Projects existants ; le PAT servira ensuite à lister ceux de l’organisation.', + 'projects.ids': 'Choisissez des Projects existants par leur titre ou saisissez le numéro positif de leur URL ; les ID PVT_ ne conviennent pas.', + 'projects.statusVerified': 'Confirmez que les quatre options Status choisies existent dans chaque Project lorsque GitHub n’a pas pu vérifier leurs champs.', + createInitialTag: 'Créez v1.0.0 seulement si le dépôt n’a encore aucune étiquette de version.', + manageRepositoryVariables: 'Autorisez la création ou mise à jour des Variables GitHub Actions nécessaires aux workflows choisis.', + manageRepositorySecrets: 'Autorisez la validation et l’installation des Secrets GitHub Actions requis, dont le PAT du bot si nécessaire.', +}; diff --git a/src/application/policies/setup_question_purpose/pt.ts b/src/application/policies/setup_question_purpose/pt.ts new file mode 100644 index 000000000..a05caaa54 --- /dev/null +++ b/src/application/policies/setup_question_purpose/pt.ts @@ -0,0 +1,51 @@ +/** Portuguese question-specific purposes. */ +export const purposesPt: Readonly> = { + 'issueWorkflows.enabled': 'Escolha os fluxos de questões que o Copilot poderá executar; cada um afeta de forma diferente ramos, etiquetas e automatizações.', + 'repositoryAgentGuidance.enabled': 'Gere instruções para ajudar os agentes de IA a trabalhar com segurança neste projeto.', + 'repositoryAgentGuidance.agentsPointer': 'Decida se o AGENTS.md da raiz aponta para as instruções geradas, é criado se faltar ou permanece intacto.', + 'agents.configureIndependently': 'Defina fornecedores e modelos distintos para planeamento, resultados, revisão, correção e testes.', + 'repository.mainBranch': 'Indique o ramo de produção usado como referência por releases e hotfixes.', + 'repository.developmentBranch': 'Indique o ramo de integração habitual usado na criação de ramos e na reconciliação.', + 'repository.issueManagedBranches': 'Permita que a Action crie um ramo associado quando uma questão passa a estar em curso.', + 'repository.preBranchSdd': 'Exija um documento de desenho aprovado antes de determinados ramos de funcionalidade ou de alteração de contratos.', + 'repository.reopenIssueOnPush': 'Reabra uma questão concluída quando forem enviados novos commits para o ramo associado.', + 'repository.desiredAssigneesCount': 'Defina quantas pessoas o Copilot atribui a uma nova questão; zero desativa a atribuição automática.', + 'repository.desiredReviewersCount': 'Defina quantos revisores o Copilot solicita para uma pull request; zero desativa os pedidos automáticos.', + 'repository.inactivityThresholdHours': 'Defina quanto tempo uma questão fica sem atividade antes de o fluxo ativado a poder fechar.', + 'repository.repositoryLocale': 'Escolha a etiqueta BCP-47 das mensagens do Copilot no GitHub; não altera o idioma desta página.', + 'repository.issueLocale': 'Altere o idioma das mensagens GitHub para questões; deixe vazio para herdar o idioma do repositório.', + 'repository.pullRequestLocale': 'Altere o idioma das mensagens GitHub para pull requests; deixe vazio para herdar o idioma do repositório.', + 'repository.commitPrefixTransforms': 'Defina substituições dos prefixos dos commits; deixe vazio se as suas convenções não precisarem delas.', + 'repository.releaseReconciliationStrategy': 'Escolha como as alterações de uma release concluída regressam ao desenvolvimento sem perder a sua origem.', + 'repository.hotfixReconciliationStrategy': 'Escolha como um hotfix de produção é propagado para os ramos em curso.', + 'repository.reconciliationPullRequestMode': 'Escolha se as pull requests de reconciliação são criadas, integradas, colocadas em fila ou deixadas a uma pessoa.', + 'repository.reconciliationBackmergeMode': 'Escolha uma fusão de retorno direta ou através de um ramo de sincronização.', + 'repository.hotfixActiveReleasePolicy': 'Escolha para onde propagar um hotfix quando já existe um ramo de release ativo.', + 'repository.reconciliationCleanup': 'Escolha que ramos temporários serão eliminados após uma reconciliação bem-sucedida.', + 'repository.reconciliationIssueCompletion': 'Escolha se a questão que iniciou a reconciliação é fechada ou fica aberta para acompanhamento.', + 'repository.orchestrationPresentationMode': 'Escolha o nível de progresso e detalhe apresentado no centro de controlo GitHub das releases.', + 'repository.orchestrationDiagrams': 'Inclua diagramas Mermaid acessíveis na informação sobre releases.', + 'repository.orchestrationCommentMode': 'Escolha se os comentários da release são atualizados ou publicados em cada marco.', + 'ai.pullRequestDescriptionMode': 'Escolha se a IA substitui, acrescenta, preserva ou nunca altera as descrições das pull requests.', + 'ai.ignoreFiles': 'Indique padrões de ficheiros a excluir da revisão por IA; continuam visíveis no GitHub.', + 'ai.membersOnly': 'Permita o processamento por IA apenas para pedidos de membros do repositório, não de quaisquer colaboradores externos.', + 'ai.bugbotSeverity': 'Defina a gravidade mínima publicada pelo Bugbot; resultados menos graves não são publicados.', + 'ai.bugbotCommentLimit': 'Limite os comentários do Bugbot por execução para não sobrecarregar uma pull request.', + 'ai.bugbotFixVerifyCommands': 'Indique os comandos que têm de passar antes de uma correção automática do Bugbot ser considerada verificada.', + 'ai.bugbotEffort': 'Escolha a profundidade das revisões do Bugbot; mais esforço pode demorar e consumir mais recursos.', + 'ai.bugbotReviewDrafts': 'Decida se o Bugbot revê pull requests em rascunho antes de estarem prontas.', + 'ai.bugbotTraceRules': 'Inclua a origem de cada regra de revisão aplicada nos resumos do Bugbot para facilitar auditorias.', + 'ai.bugbotSuggestedChanges': 'Permita ao Bugbot anexar sugestões de código seguras aos resultados publicados.', + 'ai.bugbotTelemetry': 'Registe métricas operacionais do Bugbot sem guardar conteúdo do repositório.', + 'ai.bugbotFailOnUnresolved': 'Faça falhar a verificação do Bugbot enquanto existirem resultados acionáveis por resolver.', + 'pullRequestApproval.mode': 'Escolha se o bot recomenda aprovação, pode aprovar no GitHub sob condições ou não intervém.', + 'pullRequestApproval.coverage.minDiffPercent': 'Defina a percentagem mínima de linhas alteradas cobertas que um relatório numérico fiável tem de provar.', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Indique o workflow fiável exato que publica o artefacto copilot-diff-coverage-v1.', + 'pullRequestApproval.coverage.reporterAttested': 'Confirme que inspecionou o relatório numérico nesse workflow exato, e não apenas uma verificação verde.', + 'projects.enabled': 'Decida se o Copilot deve adicionar questões e pull requests a Projects existentes; o PAT será usado depois para listar os da organização.', + 'projects.ids': 'Selecione Projects existentes pelo título ou introduza o número positivo do URL; IDs PVT_ não são usados.', + 'projects.statusVerified': 'Confirme que as quatro opções Status escolhidas existem em todos os Projects quando o GitHub não conseguiu verificar os campos.', + createInitialTag: 'Crie v1.0.0 apenas se o repositório ainda não tiver uma etiqueta de versão.', + manageRepositoryVariables: 'Permita criar ou atualizar as Variables do GitHub Actions necessárias aos fluxos escolhidos.', + manageRepositorySecrets: 'Permita validar e instalar os Secrets do GitHub Actions necessários, incluindo o PAT do bot quando aplicável.', +}; diff --git a/src/application/policies/setup_question_purpose_fr_pt.ts b/src/application/policies/setup_question_purpose_fr_pt.ts new file mode 100644 index 000000000..8e83d9a07 --- /dev/null +++ b/src/application/policies/setup_question_purpose_fr_pt.ts @@ -0,0 +1,51 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; +import { purposesFr } from './setup_question_purpose/fr'; +import { purposesPt } from './setup_question_purpose/pt'; + +export const purposesFrPt = { fr: purposesFr, pt: purposesPt } as const; + +export function setupQuestionPurposeFrPt(question: SetupQuestion, locale: 'fr' | 'pt'): string | undefined { + const exact = purposesFrPt[locale][question.id]; + if (exact) return exact; + if (question.id.startsWith('features.')) return locale === 'fr' + ? 'Activez ou désactivez cette fonction. Si vous la désactivez, cette configuration n’installera ni son automatisation ni ses autorisations conditionnelles.' + : 'Ative ou desative esta função. Se a desativar, esta configuração não instalará a automatização nem pedirá as permissões condicionais correspondentes.'; + if (/^agents\.[^.]+\.provider$/u.test(question.id)) return locale === 'fr' + ? 'Choisissez l’agent CLI de cette tâche dans GitHub Actions ; ce fournisseur détermine la commande et les identifiants du runner.' + : 'Escolha o agente CLI desta tarefa no GitHub Actions; o fornecedor determina o comando e as credenciais do runner.'; + const setting = question.id.match(/^agents\.[^.]+\.(modelProvider|model|effort|executable)$/u)?.[1]; + if (setting) { + const fields = { + fr: { modelProvider: 'le service fournissant le modèle et ses identifiants', model: 'le nom exact du modèle autorisé par le fournisseur', effort: 'l’effort de raisonnement (ou vide pour la valeur du fournisseur)', executable: 'la commande présente sur le runner GitHub Actions, pas sur cet ordinateur' }, + pt: { modelProvider: 'o serviço que fornece o modelo e as suas credenciais', model: 'o nome exato do modelo permitido pelo fornecedor', effort: 'o esforço de raciocínio (ou vazio para usar a predefinição do fornecedor)', executable: 'o comando disponível no runner GitHub Actions, não neste computador' }, + } as const; + const scope = question.stateId === 'agent-model-defaults' + ? (locale === 'fr' ? 'pour toutes les tâches actives' : 'para todas as tarefas ativas') + : (locale === 'fr' ? 'pour cette tâche' : 'para esta tarefa'); + return `${locale === 'fr' ? 'Définissez' : 'Defina'} ${fields[locale][setting as keyof typeof fields.fr]} ${scope}.`; + } + if (/^repository\.(feature|bugfix|hotfix|release|docs|chore|reconciliation)Tree$/u.test(question.id)) return locale === 'fr' + ? 'Définissez le préfixe des branches créées par Copilot pour ce type de travail ; il doit suivre votre convention de nommage.' + : 'Defina o prefixo dos ramos criados pelo Copilot para este tipo de trabalho; deve seguir as suas regras de nomes.'; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) return locale === 'fr' + ? 'Choisissez la valeur du champ Status appliquée à la création ou au début du travail ; ce n’est pas le nom d’une colonne visuelle.' + : 'Escolha o valor do campo Status aplicado na criação ou no início do trabalho; não é o nome de uma coluna visual.'; + const storage = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storage) { + const resource = storage[1] === 'variables' ? 'Variables' : 'Secrets'; + const field = storage[2]; + if (field === 'defaultScope') return locale === 'fr' + ? `Choisissez si les nouvelles ${resource} sont stockées dans le dépôt ou l’organisation ; ce dernier périmètre peut exiger davantage d’autorisations du PAT.` + : `Escolha se as novas ${resource} ficam no repositório ou na organização; este último âmbito pode exigir mais permissões do PAT.`; + if (field === 'organizationVisibility') return locale === 'fr' + ? `Choisissez les dépôts pouvant utiliser les ${resource} de l’organisation ; « selected » est l’accès le plus restreint.` + : `Escolha os repositórios que podem usar as ${resource} da organização; «selected» é a visibilidade mais restrita.`; + if (field === 'preserveExisting') return locale === 'fr' + ? `Conservez les ${resource} existantes déjà applicables au lieu de les écraser pendant la configuration.` + : `Conserve as ${resource} existentes e aplicáveis em vez de as substituir durante a configuração.`; + return locale === 'fr' + ? `Sélectionnez les ${resource} héritées de l’organisation à définir plutôt dans le dépôt.` + : `Selecione as ${resource} herdadas da organização que pretende definir no repositório.`; + } + return undefined; +} diff --git a/src/application/policies/setup_question_purpose_policy.ts b/src/application/policies/setup_question_purpose_policy.ts new file mode 100644 index 000000000..f7fcf9ecc --- /dev/null +++ b/src/application/policies/setup_question_purpose_policy.ts @@ -0,0 +1,104 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; + +type Purpose = Readonly<{ en: string; es: string }>; + +/** Field-specific meaning for choices whose label alone is easy to misinterpret. */ +export const setupQuestionPurposes: Readonly> = { + 'issueWorkflows.enabled': { en: 'Choose the issue workflows Copilot may run; each type has different branch, label and automation effects.', es: 'Elige los flujos de issues que podrá ejecutar Copilot; cada tipo tiene efectos distintos sobre ramas, etiquetas y automatización.' }, + 'repositoryAgentGuidance.enabled': { en: 'Generate repository instructions that tell AI agents how to work safely in this project.', es: 'Genera instrucciones para que los agentes de IA trabajen con seguridad en este proyecto.' }, + 'repositoryAgentGuidance.agentsPointer': { en: 'Choose whether the root AGENTS.md points to generated instructions, is created if missing, or stays untouched.', es: 'Elige si el AGENTS.md raíz apunta a las instrucciones generadas, se crea si falta o permanece intacto.' }, + 'agents.configureIndependently': { en: 'Give planning, findings, review, fixing and testing separate provider and model settings instead of shared defaults.', es: 'Da a planificación, hallazgos, revisión, corrección y pruebas ajustes distintos de proveedor y modelo.' }, + 'repository.mainBranch': { en: 'Name the production branch; release and hotfix automation use it as their production reference.', es: 'Indica la rama de producción; las automatizaciones de release y hotfix la usan como referencia.' }, + 'repository.developmentBranch': { en: 'Name the normal integration branch; branch creation and reconciliation target it.', es: 'Indica la rama de integración habitual; la creación de ramas y la reconciliación la usan.' }, + 'repository.issueManagedBranches': { en: 'Allow the Action to create a linked branch when an issue enters an in-progress state.', es: 'Permite a la Action crear una rama vinculada cuando un issue pasa a «en curso».' }, + 'repository.preBranchSdd': { en: 'Require an approved design document before feature or contract-changing branches are created.', es: 'Exige un diseño aprobado antes de crear ramas de funcionalidad o cambios de contrato.' }, + 'repository.reopenIssueOnPush': { en: 'Reopen a completed issue when someone pushes more work to its linked branch.', es: 'Reabre un issue completado si alguien añade cambios a su rama vinculada.' }, + 'repository.desiredAssigneesCount': { en: 'Set how many people Copilot assigns to a new issue; zero disables automatic assignment.', es: 'Define cuántas personas asigna Copilot a un issue nuevo; cero desactiva la asignación automática.' }, + 'repository.desiredReviewersCount': { en: 'Set how many reviewers Copilot requests for a pull request; zero disables automatic requests.', es: 'Define cuántos revisores solicita Copilot para un pull request; cero desactiva la solicitud automática.' }, + 'repository.inactivityThresholdHours': { en: 'Set how long an issue waits without activity before the enabled inactivity workflow may close it.', es: 'Define cuánto tiempo espera sin actividad un issue antes de que el flujo habilitado pueda cerrarlo.' }, + 'repository.repositoryLocale': { en: 'Choose the BCP-47 language tag for Copilot messages on GitHub; this does not change the setup page language.', es: 'Elige la etiqueta BCP-47 de los mensajes de Copilot en GitHub; no cambia el idioma de esta página.' }, + 'repository.issueLocale': { en: 'Override the GitHub message language for issues; leave empty to inherit the repository language.', es: 'Cambia el idioma de los mensajes de issues; vacío hereda el idioma del repositorio.' }, + 'repository.pullRequestLocale': { en: 'Override the GitHub message language for pull requests; leave empty to inherit the repository language.', es: 'Cambia el idioma de los mensajes de pull requests; vacío hereda el idioma del repositorio.' }, + 'repository.commitPrefixTransforms': { en: 'Define commit-prefix rewrites used by commit automation; leave empty if your conventions need no mapping.', es: 'Define sustituciones de prefijos de commits; déjalo vacío si tus convenciones no necesitan cambios.' }, + 'repository.releaseReconciliationStrategy': { en: 'Choose how completed release changes return to development without losing production lineage.', es: 'Elige cómo vuelven los cambios de una release a desarrollo sin perder su relación con producción.' }, + 'repository.hotfixReconciliationStrategy': { en: 'Choose how an emergency production fix is carried back to ongoing branches.', es: 'Elige cómo se incorpora un arreglo urgente de producción a las demás ramas activas.' }, + 'repository.reconciliationPullRequestMode': { en: 'Choose whether reconciliation PRs are created, merged automatically, queued, or left for a human.', es: 'Elige si los PR de reconciliación se crean, fusionan automáticamente, encolan o quedan para una persona.' }, + 'repository.reconciliationBackmergeMode': { en: 'Choose whether the return merge is direct or goes through a synchronization branch.', es: 'Elige si la integración de vuelta es directa o pasa por una rama de sincronización.' }, + 'repository.hotfixActiveReleasePolicy': { en: 'Choose where a hotfix propagates when a release branch is already active.', es: 'Elige a dónde se propaga un hotfix si ya hay una rama de release activa.' }, + 'repository.reconciliationCleanup': { en: 'Choose which temporary branches are deleted after successful reconciliation.', es: 'Elige qué ramas temporales se eliminan tras una reconciliación correcta.' }, + 'repository.reconciliationIssueCompletion': { en: 'Choose whether the issue that launched reconciliation closes or stays open for follow-up.', es: 'Elige si el issue que inició la reconciliación se cierra o sigue abierto.' }, + 'repository.orchestrationPresentationMode': { en: 'Choose how much release progress and detail appears in the GitHub control-center view.', es: 'Elige cuánto progreso y detalle muestra el centro de control de releases en GitHub.' }, + 'repository.orchestrationDiagrams': { en: 'Include accessible Mermaid diagrams in release status information.', es: 'Incluye diagramas Mermaid accesibles en la información de releases.' }, + 'repository.orchestrationCommentMode': { en: 'Choose whether release lifecycle comments update in place or are posted at milestones.', es: 'Elige si los comentarios de la release se actualizan o se publican en cada hito.' }, + 'ai.pullRequestDescriptionMode': { en: 'Choose whether AI replaces, appends to, preserves, or never edits pull-request descriptions.', es: 'Elige si la IA sustituye, amplía, conserva o nunca modifica las descripciones de pull requests.' }, + 'ai.ignoreFiles': { en: 'List file patterns the AI review should skip; this does not hide those files on GitHub.', es: 'Indica patrones de archivos que la revisión con IA debe omitir; no los oculta en GitHub.' }, + 'ai.membersOnly': { en: 'Allow AI processing only for requests from repository members, not arbitrary external contributors.', es: 'Permite el procesamiento con IA solo para miembros del repositorio, no para colaboradores externos.' }, + 'ai.bugbotSeverity': { en: 'Set the lowest severity Bugbot publishes; lower-severity findings remain unpublished.', es: 'Define la gravedad mínima que publica Bugbot; los hallazgos menores no se publican.' }, + 'ai.bugbotCommentLimit': { en: 'Cap the number of Bugbot review comments in one run to avoid overwhelming a pull request.', es: 'Limita los comentarios de Bugbot por ejecución para no saturar un pull request.' }, + 'ai.bugbotFixVerifyCommands': { en: 'Specify commands that must pass before Bugbot considers an automatic fix verified.', es: 'Indica los comandos que deben pasar antes de considerar verificada una corrección de Bugbot.' }, + 'ai.bugbotEffort': { en: 'Choose the depth of Bugbot reviews; higher effort can take longer and use more model capacity.', es: 'Elige la profundidad de las revisiones de Bugbot; más esfuerzo puede tardar y consumir más.' }, + 'ai.bugbotReviewDrafts': { en: 'Decide whether Bugbot reviews draft pull requests before they are marked ready.', es: 'Decide si Bugbot revisa pull requests en borrador antes de que estén listos.' }, + 'ai.bugbotTraceRules': { en: 'Include the source of each applied review rule in Bugbot summaries for auditability.', es: 'Incluye la procedencia de las reglas aplicadas en los resúmenes de Bugbot para facilitar auditorías.' }, + 'ai.bugbotSuggestedChanges': { en: 'Allow Bugbot to attach safe inline code suggestions to published findings.', es: 'Permite a Bugbot adjuntar sugerencias de código seguras a los hallazgos publicados.' }, + 'ai.bugbotTelemetry': { en: 'Record operational Bugbot metrics without recording repository content.', es: 'Registra métricas operativas de Bugbot sin guardar contenido del repositorio.' }, + 'ai.bugbotFailOnUnresolved': { en: 'Make the Bugbot workflow check fail while actionable findings remain unresolved.', es: 'Hace fallar el check de Bugbot mientras queden hallazgos accionables sin resolver.' }, + 'pullRequestApproval.mode': { en: 'Choose whether the bot recommends approval, may submit a guarded GitHub approval, or does neither.', es: 'Elige si el bot recomienda aprobar, puede publicar una aprobación protegida o no interviene.' }, + 'pullRequestApproval.coverage.minDiffPercent': { en: 'Set the minimum percentage of changed lines that a trusted numeric reporter must prove are covered.', es: 'Define el porcentaje mínimo de líneas modificadas cubiertas que debe acreditar un reporter numérico fiable.' }, + 'pullRequestApproval.coverage.artifactWorkflowName': { en: 'Name the exact trusted workflow that publishes the copilot-diff-coverage-v1 artifact.', es: 'Indica el workflow fiable exacto que publica el artefacto copilot-diff-coverage-v1.' }, + 'pullRequestApproval.coverage.reporterAttested': { en: 'Confirm you inspected the numeric coverage reporter in that exact workflow, not just its green check.', es: 'Confirma que revisaste el reporter numérico en ese workflow exacto, no solo su check verde.' }, + 'projects.enabled': { en: 'Decide whether Copilot should add issues and pull requests to existing GitHub Projects; the setup PAT is needed to list private organization Projects later.', es: 'Decide si Copilot debe añadir issues y pull requests a Projects existentes; el PAT de setup hará falta después para consultar Projects privados de la organización.' }, + 'projects.ids': { en: 'Choose existing Projects by title after PAT verification, or enter the positive number in each Project URL; PVT_ node IDs are not used.', es: 'Elige Projects existentes por título tras verificar el PAT o introduce el número positivo de cada URL; no se usan IDs de nodo PVT_.' }, + 'projects.statusVerified': { en: 'Confirm that all four chosen Status options actually exist in every selected Project when GitHub could not verify their fields.', es: 'Confirma que las cuatro opciones Status existen en todos los Projects elegidos cuando GitHub no pudo comprobar sus campos.' }, + createInitialTag: { en: 'Create v1.0.0 only if this repository has no version tag yet.', es: 'Crea v1.0.0 solo si este repositorio todavía no tiene un tag de versión.' }, + manageRepositoryVariables: { en: 'Allow setup to create or update GitHub Actions Variables required by selected workflows.', es: 'Permite a setup crear o actualizar Variables de GitHub Actions necesarias para los workflows elegidos.' }, + manageRepositorySecrets: { en: 'Allow setup to validate and install required GitHub Actions Secrets, including the bot PAT when needed.', es: 'Permite a setup validar e instalar Secrets de GitHub Actions, incluido el PAT del bot cuando haga falta.' }, +}; + +export function setupQuestionPurpose(question: SetupQuestion): Purpose | undefined { + const exact = setupQuestionPurposes[question.id]; + if (exact) return exact; + if (question.id.startsWith('features.')) return { + en: `Enable or disable ${question.label.toLowerCase()}. Disabling it removes its automation and conditional permission needs from this setup.`, + es: 'Activa o desactiva esta función. Si la desactivas, setup no instalará su automatización ni solicitará sus permisos condicionales.', + }; + if (/^agents\.[^.]+\.provider$/u.test(question.id)) return { + en: 'Choose the agent CLI for this task in GitHub Actions; the provider determines the runner command and credentials.', + es: 'Elige el agente CLI de esta tarea en GitHub Actions; determina el comando y las credenciales del runner.', + }; + const agentSetting = question.id.match(/^agents\.[^.]+\.(modelProvider|model|effort|executable)$/u)?.[1]; + if (agentSetting) { + const shared = question.stateId === 'agent-model-defaults'; + const scope = shared ? { en: 'enabled agent tasks without a per-role override', es: 'las tareas activas del agente sin una excepción propia' } + : { en: 'this agent task', es: 'esta tarea del agente' }; + const setting: Record = { + modelProvider: { en: 'the service that supplies the model and its credentials', es: 'el servicio que proporciona el modelo y sus credenciales' }, + model: { en: 'the exact model name allowed by the selected provider', es: 'el nombre exacto del modelo permitido por el proveedor elegido' }, + effort: { en: 'the reasoning-effort level, or leave empty for the provider default', es: 'el nivel de razonamiento, o vacío para usar el valor del proveedor' }, + executable: { en: 'the executable available on the GitHub Actions runner, not this computer', es: 'el ejecutable disponible en el runner de GitHub Actions, no en este ordenador' }, + }; + return { + en: `Set ${setting[agentSetting].en} for ${scope.en}.`, + es: `Define ${setting[agentSetting].es} para ${scope.es}.`, + }; + } + if (/^repository\.(feature|bugfix|hotfix|release|docs|chore|reconciliation)Tree$/u.test(question.id)) return { + en: 'Set the prefix of branches Copilot creates for this work type; it must match your naming policy.', + es: 'Define el prefijo de las ramas que Copilot crea para este tipo de trabajo; debe seguir tus reglas de nombres.', + }; + if (/^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(question.id)) return { + en: 'Choose the existing Status field option applied when this issue or pull request is created or enters progress; it is not a board-view column name.', + es: 'Elige la opción existente del campo Status al crear este issue o pull request o pasarlo a «en curso»; no es el nombre de una columna visual.', + }; + const storageSetting = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storageSetting) { + const resource = storageSetting[1] === 'variables' ? 'Variables' : 'Secrets'; + const setting: Record = { + defaultScope: { en: `Choose whether new ${resource} live in the repository or organization; organization storage can need extra PAT grants.`, es: `Elige si los ${resource} nuevos se guardan en el repositorio o la organización; este último ámbito puede exigir más permisos del PAT.` }, + organizationVisibility: { en: `Choose which repositories can use organization ${resource}; selected is the narrowest visibility.`, es: `Elige qué repositorios pueden usar los ${resource} de la organización; «selected» es la visibilidad más restringida.` }, + preserveExisting: { en: `Keep effective existing ${resource} instead of overwriting them during setup.`, es: `Conserva los ${resource} existentes que ya se aplican, en lugar de sobrescribirlos durante setup.` }, + overrides: { en: `Select inherited organization ${resource} that should instead be set at repository scope.`, es: `Selecciona los ${resource} heredados de la organización que quieras definir en el repositorio.` }, + }; + return setting[storageSetting[2]]; + } + return undefined; +} diff --git a/src/application/policies/setup_question_translations.ts b/src/application/policies/setup_question_translations.ts new file mode 100644 index 000000000..4d5526fe7 --- /dev/null +++ b/src/application/policies/setup_question_translations.ts @@ -0,0 +1,105 @@ +import type { SetupQuestion } from '../../domain/setup_questionnaire'; + +export const spanishQuestionLabels: Readonly> = { + 'features.issues': 'Automatizar issues: ramas, etiquetas, proyectos y ciclo de vida', + 'features.pullRequests': 'Automatizar pull requests: revisión, descripción y ciclo de vida', + 'features.commits': 'Automatizar commits: progreso, tamaño y análisis de Bugbot', + 'features.issueComments': 'Responder a comentarios de issues y permitir correcciones de Bugbot', + 'features.pullRequestComments': 'Responder a comentarios de pull requests y permitir correcciones de Bugbot', + 'features.agentProvisioning': 'Comprobar la instalación de agentes CLI en GitHub Actions', + 'features.credentialHealth': 'Comprobar el estado de las credenciales remotas', + 'features.inactiveIssueClosure': 'Cerrar issues sin actividad tras el plazo configurado', + 'features.issueTemplates': 'Instalar plantillas de issues', + 'features.pullRequestTemplate': 'Instalar plantilla de pull request', + 'issueWorkflows.enabled': 'Tipos de flujo de issues que quieres activar', + 'repositoryAgentGuidance.enabled': '¿Generar instrucciones para agentes en el repositorio?', + 'repositoryAgentGuidance.agentsPointer': 'Cómo descubrir las instrucciones desde AGENTS.md', + 'agents.findings.modelProvider': 'Proveedor de modelo compartido (salvo configuración propia de una tarea)', + 'agents.findings.model': 'Modelo compartido (salvo configuración propia de una tarea)', + 'agents.findings.effort': 'Esfuerzo de razonamiento compartido (los ajustes por tarea se conservan)', + 'agents.findings.executable': 'Ejecutable compartido validado (los ajustes por tarea se conservan)', + 'agents.configureIndependently': '¿Configurar modelo y comando por tarea?', + 'repository.mainBranch': 'Rama de producción', + 'repository.developmentBranch': 'Rama de desarrollo', + 'repository.featureTree': 'Prefijo de ramas de funcionalidad', + 'repository.bugfixTree': 'Prefijo de ramas de corrección', + 'repository.hotfixTree': 'Prefijo de ramas de hotfix', + 'repository.releaseTree': 'Prefijo de ramas de release', + 'repository.docsTree': 'Prefijo de ramas de documentación', + 'repository.choreTree': 'Prefijo de ramas de mantenimiento', + 'repository.issueManagedBranches': '¿Puede la Action crear ramas vinculadas a issues?', + 'repository.preBranchSdd': '¿Exigir un SDD antes de crear ciertas ramas?', + 'repository.reopenIssueOnPush': '¿Reabrir issues cerrados al actualizar su rama?', + 'repository.desiredAssigneesCount': 'Número deseado de personas asignadas a issues', + 'repository.desiredReviewersCount': 'Número deseado de revisores de pull requests', + 'repository.inactivityThresholdHours': 'Horas sin actividad antes de cerrar un issue en espera', + 'repository.repositoryLocale': 'Idioma de los mensajes del repositorio', + 'repository.issueLocale': 'Idioma de los issues (vacío: heredar)', + 'repository.pullRequestLocale': 'Idioma de los pull requests (vacío: heredar)', + 'repository.commitPrefixTransforms': 'Transformación de prefijos de commits', + 'repository.releaseReconciliationStrategy': 'Estrategia para reconciliar releases', + 'repository.hotfixReconciliationStrategy': 'Estrategia para reconciliar hotfixes', + 'repository.reconciliationPullRequestMode': 'Modo de pull requests de reconciliación', + 'repository.reconciliationBackmergeMode': 'Modo de integración de vuelta', + 'repository.hotfixActiveReleasePolicy': 'Destino del hotfix durante una release activa', + 'repository.reconciliationTree': 'Prefijo de ramas de reconciliación', + 'repository.reconciliationCleanup': 'Limpieza de ramas tras reconciliar', + 'repository.reconciliationIssueCompletion': 'Qué hacer con el issue al terminar', + 'repository.orchestrationPresentationMode': 'Nivel de detalle del centro de control de releases', + 'repository.orchestrationDiagrams': '¿Mostrar diagramas accesibles de releases?', + 'repository.orchestrationCommentMode': 'Cómo publicar comentarios del ciclo de release', + 'ai.pullRequestDescriptionMode': 'Cómo actualizar la descripción de los pull requests', + 'ai.ignoreFiles': 'Archivos que la IA debe ignorar', + 'ai.membersOnly': '¿Limitar el procesamiento de IA a miembros del repositorio?', + 'ai.includeReasoning': '¿Incluir el razonamiento adicional del proveedor?', + 'ai.bugbotSeverity': 'Gravedad mínima para publicar hallazgos de Bugbot', + 'ai.bugbotCommentLimit': 'Máximo de comentarios de Bugbot por ejecución', + 'ai.bugbotFixVerifyCommands': 'Comandos para verificar correcciones de Bugbot', + 'ai.bugbotDryRun': '¿Analizar sin publicar cambios de Bugbot?', + 'ai.bugbotEffort': 'Profundidad del análisis de Bugbot', + 'ai.bugbotReviewDrafts': '¿Revisar pull requests en borrador?', + 'ai.bugbotTraceRules': '¿Indicar qué fuentes de reglas se aplicaron?', + 'ai.bugbotSuggestedChanges': '¿Publicar sugerencias de cambio seguras?', + 'ai.bugbotTelemetry': '¿Registrar métricas de Bugbot sin contenido?', + 'ai.bugbotFailOnUnresolved': '¿Bloquear el check si quedan hallazgos sin resolver?', + 'ai.bugbotOrganizationRules': 'Reglas generales de Bugbot, una por línea', + 'ai.provisioningMode': 'Cómo preparar el agente CLI en el runner', + 'pullRequestApproval.mode': '¿Qué puede hacer el bot con las aprobaciones de PR?', + 'pullRequestApproval.testChecks': '¿Qué checks de CI son fiables para aprobar PRs?', + 'pullRequestApproval.producerAttested': '¿Has comprobado el job, la App y el paso obligatorio de cobertura?', + 'pullRequestApproval.coverage.mode': 'Cómo demostrar que se cumple la cobertura', + 'pullRequestApproval.coverage.checkName': 'Check fiable que exige la cobertura', + 'pullRequestApproval.coverage.minDiffPercent': 'Cobertura mínima de líneas modificadas (0–100)', + 'pullRequestApproval.coverage.artifactWorkflowName': 'Workflow que publica copilot-diff-coverage-v1', + 'pullRequestApproval.coverage.reporterAttested': '¿Has comprobado que el reporter numérico está instalado?', + 'projects.enabled': '¿Quieres integrar Projects de GitHub?', + 'projects.ids': 'Selecciona Projects existentes o indica los números de sus URL', + 'projects.statusVerified': '¿Has comprobado en GitHub los cuatro valores Status exactos de cada Project elegido?', + 'projects.issueCreatedColumn': 'Estado Status de nuevos issues', + 'projects.pullRequestCreatedColumn': 'Estado Status de nuevos pull requests', + 'projects.issueInProgressColumn': 'Estado Status de issues en curso', + 'projects.pullRequestInProgressColumn': 'Estado Status de pull requests en curso', + createInitialTag: '¿Crear v1.0.0 si todavía no existe ningún tag?', + manageRepositoryVariables: '¿Crear o actualizar Variables de GitHub Actions?', + manageRepositorySecrets: '¿Validar y configurar Secrets de GitHub Actions?', +}; + +const roleNames: Readonly> = { + planner: 'Planificación', findings: 'Hallazgos', reviewer: 'Revisión', fixer: 'Corrección', tester: 'Pruebas', +}; + +export function spanishQuestionLabel(question: SetupQuestion): string { + if (spanishQuestionLabels[question.id]) return spanishQuestionLabels[question.id]; + const agent = question.id.match(/^agents\.(planner|findings|reviewer|fixer|tester)\.(provider|modelProvider|model|effort|executable)$/u); + if (agent) { + const field: Record = { provider: 'agente CLI', modelProvider: 'proveedor del modelo', model: 'modelo', effort: 'esfuerzo', executable: 'comando ejecutable' }; + return `${roleNames[agent[1]]}: ${field[agent[2]]}`; + } + const storage = question.id.match(/^storage\.(variables|secrets)\.(defaultScope|organizationVisibility|preserveExisting|overrides)$/u); + if (storage) { + const resource = storage[1] === 'variables' ? 'Variables' : 'Secrets'; + const field: Record = { defaultScope: 'ámbito predeterminado', organizationVisibility: 'visibilidad en la organización', preserveExisting: 'conservar los existentes', overrides: 'excepciones de ámbito' }; + return `${resource}: ${field[storage[2]]}`; + } + return question.label; +} diff --git a/src/application/policies/setup_questionnaire_policy.ts b/src/application/policies/setup_questionnaire_policy.ts index 9976f049f..3f12910d0 100644 --- a/src/application/policies/setup_questionnaire_policy.ts +++ b/src/application/policies/setup_questionnaire_policy.ts @@ -6,13 +6,22 @@ import type { SetupQuestionnaireEvent, SetupQuestionnaireState, SetupQuestionnaireStateId, + SetupQuestionnaireProgress, } from '../../domain/setup_questionnaire'; import { cloneSetupConfiguration } from './setup_configuration_clone_policy'; -import { SETUP_AGENT_TASKS, SETUP_FEATURE_DESCRIPTIONS } from './setup_configuration_defaults'; +import { createDefaultSetupConfiguration, SETUP_AGENT_TASKS, SETUP_FEATURE_DESCRIPTIONS } from './setup_configuration_defaults'; import { ISSUE_WORKFLOW_KINDS, ISSUE_WORKFLOW_CATALOG, createIssueWorkflowProfile, type IssueWorkflowKind } from '../../domain/issue_workflow_profile'; +import { parseSetupProjectSelection, sharedProjectStatusOptions } from './setup_project_selection_policy'; const AGENT_PROVIDERS = ['codex', 'opencode', 'cursor'] as const; const MODEL_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter', 'opencode', 'local'] as const; +const PERMISSION_INTENT_QUESTION_IDS = new Set([ + 'features.issues', 'features.pullRequests', 'issueWorkflows.enabled', + 'pullRequestApproval.mode', 'projects.enabled', 'createInitialTag', + 'manageRepositoryVariables', 'manageRepositorySecrets', + 'storage.variables.defaultScope', 'storage.variables.preserveExisting', + 'storage.secrets.defaultScope', 'storage.secrets.preserveExisting', +]); interface QuestionDefinition { readonly stateId: SetupQuestion['stateId']; @@ -21,7 +30,7 @@ interface QuestionDefinition { readonly kind: SetupQuestion['kind']; readonly choices?: readonly string[]; readonly applies?: (draft: SetupConfiguration, independently: boolean, context: SetupQuestionnaireContext) => boolean; - readonly read?: (draft: SetupConfiguration) => string | number | boolean; + readonly read?: (draft: SetupConfiguration, context: SetupQuestionnaireContext) => string | number | boolean; } export function createSetupQuestionnaire( @@ -29,8 +38,29 @@ export function createSetupQuestionnaire( context: SetupQuestionnaireContext = {}, ): SetupQuestionnaireState { const draft = cloneSetupConfiguration(configuration); - const question = questions(draft, false, context)[0]; - return { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false }; + const independently = hasIndependentAgentSettings(draft); + const question = questions(draft, independently, context, 'full')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: independently, phase: 'full' } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: independently, phase: 'full' }; +} + +function hasIndependentAgentSettings(draft: SetupConfiguration): boolean { + const shared = draft.agents.findings; + return SETUP_AGENT_TASKS.filter(task => task !== 'findings').some(task => + (['modelProvider', 'model', 'effort', 'executable'] as const).some(field => draft.agents[task][field] !== shared[field])); +} + +export function createSetupPermissionIntentQuestionnaire( + configuration: SetupConfiguration, + context: SetupQuestionnaireContext = {}, +): SetupQuestionnaireState { + const draft = cloneSetupConfiguration(configuration); + const projectsWanted = context.projectsWanted ?? Boolean(draft.projects.ids.trim()); + const question = questions(draft, false, context, 'permission-intent')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [], projectsWanted } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [], projectsWanted }; } export function createSetupReviewState(configuration: SetupConfiguration): SetupQuestionnaireState { @@ -42,6 +72,84 @@ export function createSetupReviewState(configuration: SetupConfiguration): Setup }; } +/** Re-project the current question after a read-only discovery without replaying answers. */ +export function refreshSetupQuestionnaireQuestion( + state: SetupQuestionnaireState, + context: SetupQuestionnaireContext, +): SetupQuestionnaireState { + if (state.terminal !== 'collecting' || !state.question) return state; + const question = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full') + .find(candidate => candidate.id === state.question?.id); + return question ? { ...state, question, validation: undefined } : state; +} + +/** The denominator follows the currently applicable, unskipped questions. */ +export function setupQuestionnaireProgress( + state: SetupQuestionnaireState, + context: SetupQuestionnaireContext, +): SetupQuestionnaireProgress | undefined { + if (state.terminal !== 'collecting' || !state.question) return undefined; + const visible = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full'); + const index = visible.findIndex(item => item.id === state.question?.id); + if (index < 0) return undefined; + const group = state.question.stateId; + const groupQuestions = visible.filter(item => item.stateId === group); + return { position: index + 1, total: visible.length, groupPosition: groupQuestions.findIndex(item => item.id === state.question?.id) + 1, + groupTotal: groupQuestions.length, group }; +} + +/** Reopen an already answered group for final-plan correction without clearing unrelated values. */ +export function reopenSetupQuestionnaireGroup( + state: SetupQuestionnaireState, + group: SetupQuestion['stateId'], + context: SetupQuestionnaireContext, +): SetupQuestionnaireState | undefined { + if (state.terminal !== 'review') return undefined; + const first = questions(state.draft, state.configureIndependently, context, 'full').find(item => item.stateId === group); + return first ? { ...state, stateId: first.stateId, terminal: 'collecting', question: first, validation: undefined, + phase: 'full', answeredQuestionIds: [] } : undefined; +} + +export function setupQuestionIdsForGroup(group: SetupQuestion['stateId']): readonly string[] { + return definitions().filter(item => item.stateId === group).map(item => item.id); +} + +/** Basic changes presentation only: security- and permission-driving decisions stay visible. */ +export function setupBasicSkippedQuestionIds(configuration?: SetupConfiguration): readonly string[] { + const defaults = configuration ? createDefaultSetupConfiguration() : undefined; + const advancedRepository = new Set([ + 'featureTree', 'bugfixTree', 'hotfixTree', 'releaseTree', 'docsTree', 'choreTree', + 'reconciliationTree', 'reopenIssueOnPush', 'inactivityThresholdHours', + 'issueLocale', 'pullRequestLocale', 'commitPrefixTransforms', + ]); + const advancedBugbot = new Set([ + 'pullRequestDescriptionMode', 'ignoreFiles', 'includeReasoning', 'bugbotCommentLimit', + 'bugbotFixVerifyCommands', 'bugbotEffort', 'bugbotReviewDrafts', 'bugbotTraceRules', + 'bugbotSuggestedChanges', 'bugbotOrganizationRules', + ]); + return definitions().filter(definition => + definition.id === 'agents.findings.effort' + || definition.id === 'agents.findings.executable' + || (definition.id.startsWith('agents.') && definition.id.endsWith('.provider') && definition.id !== 'agents.findings.provider') + || (definition.id.startsWith('repository.') && advancedRepository.has(definition.id.slice('repository.'.length))) + || (definition.id.startsWith('ai.') && advancedBugbot.has(definition.id.slice('ai.'.length))) + ).filter(definition => !configuration || JSON.stringify(valueAtPath(configuration, definition.id)) + === JSON.stringify(valueAtPath(defaults!, definition.id)) + ).map(definition => definition.id); +} + +function valueAtPath(value: unknown, path: string): unknown { + return path.split('.').reduce((current, key) => current && typeof current === 'object' + ? (current as Record)[key] : undefined, value); +} + +export function setupEditableGroups(configuration: SetupConfiguration): readonly SetupQuestion['stateId'][] { + // Projects can be enabled at review even if the operator declined it before + // the PAT handoff. The re-run audits any newly required grant before Apply. + const visible = questions(configuration, hasIndependentAgentSettings(configuration), { projectsWanted: true }, 'full'); + return [...new Set(visible.map(item => item.stateId))]; +} + export function transitionSetupQuestionnaire( state: SetupQuestionnaireState, event: SetupQuestionnaireEvent, @@ -54,8 +162,26 @@ export function transitionSetupQuestionnaire( draft: cloneSetupConfiguration(state.draft), terminal: 'cancelled', configureIndependently: state.configureIndependently, + phase: state.phase, + answeredQuestionIds: state.answeredQuestionIds, + projectsWanted: state.projectsWanted, }; } + if (event.kind === 'back') { + const visible = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full'); + const index = visible.findIndex(item => item.id === state.question?.id); + if (index <= 0) return { ...state, validation: 'This is the first question in this pass. Review it or cancel setup.' }; + const previous = visible[index - 1]; + return { ...state, stateId: previous.stateId, question: previous, validation: undefined, + answeredQuestionIds: state.answeredQuestionIds?.filter(id => visible.findIndex(item => item.id === id) < index - 1) }; + } + if (state.question.id === 'projects.statusVerified' && ['n', 'no', 'false', '0'].includes(event.value.normalize('NFKC').trim().toLowerCase())) { + const selection = questions(state.draft, state.configureIndependently, context, state.phase ?? 'full') + .find(question => question.id === 'projects.ids'); + if (selection) return { ...state, question: selection, stateId: 'projects', + validation: 'Status values were not confirmed. Choose compatible Projects, then review their Status options again.', + answeredQuestionIds: state.answeredQuestionIds?.filter(id => id !== 'projects.ids' && !id.startsWith('projects.')) }; + } const parsed = parseAnswer(state.question, event.value); if ('error' in parsed) { return { @@ -64,13 +190,32 @@ export function transitionSetupQuestionnaire( validation: parsed.error, }; } + if (state.question.id === 'features.issues' && parsed.value === false + && (context.fixedWorkflowFeatures?.release === true || context.fixedWorkflowFeatures?.hotfix === true)) { + return { ...state, validation: 'Issue automation is required by an explicit release or hotfix override. Keep Issues enabled or edit your configuration.' }; + } + if (state.question.id === 'issueWorkflows.enabled') { + const selected = new Set(String(parsed.value).split(',')); + for (const kind of ['release', 'hotfix'] as const) { + const fixed = context.fixedWorkflowFeatures?.[kind]; + if (fixed !== undefined && selected.has(kind) !== fixed) { + return { ...state, validation: `The ${kind} workflow must ${fixed ? 'remain enabled' : 'remain disabled'} because it is fixed by your configuration. Match that choice or edit your configuration.` }; + } + } + } const configureIndependently = state.question.id === 'agents.configureIndependently' ? Boolean(parsed.value) : state.configureIndependently; - const draft = applyAnswer(state.draft, state.question, parsed.value); - const nextQuestions = questions(draft, configureIndependently, context); - const nextIndex = nextQuestions.findIndex((question) => question.id === state.question?.id); - const next = nextQuestions[nextIndex + 1]; + const draft = applyAnswer(state.draft, state.question, parsed.value, state.configureIndependently); + const projectsWanted = state.question.id === 'projects.enabled' ? Boolean(parsed.value) : state.projectsWanted; + const answeredQuestionIds = [...(state.answeredQuestionIds ?? []), state.question.id]; + const nextQuestions = questions(draft, configureIndependently, context, state.phase ?? 'full'); + // A just-answered question may become inapplicable (for example, clearing + // Projects removes its dependent fields). Advance by canonical definition + // order; indexing the new visible list at -1 would restart the wizard. + const definitionOrder = definitions().map(definition => definition.id); + const currentOrder = definitionOrder.indexOf(state.question.id); + const next = nextQuestions.find(question => definitionOrder.indexOf(question.id) > currentOrder); return next ? { stateId: next.stateId, @@ -78,8 +223,11 @@ export function transitionSetupQuestionnaire( question: next, terminal: 'collecting', configureIndependently, + phase: state.phase, + answeredQuestionIds, + projectsWanted, } - : { stateId: 'review', draft, terminal: 'review', configureIndependently }; + : { stateId: 'review', draft, terminal: 'review', configureIndependently, phase: state.phase, answeredQuestionIds, projectsWanted }; } export function enterSetupConfirmation(state: SetupQuestionnaireState): SetupQuestionnaireState { @@ -124,8 +272,12 @@ function questions( draft: SetupConfiguration, independently: boolean, context: SetupQuestionnaireContext, + phase: 'full' | 'permission-intent', ): SetupQuestion[] { - return definitions().filter((definition) => definition.applies?.(draft, independently, context) ?? true) + return definitions().filter((definition) => + (phase === 'full' ? definition.id !== 'projects.enabled' : PERMISSION_INTENT_QUESTION_IDS.has(definition.id)) + && !context.skipQuestionIds?.includes(definition.id) + && (definition.applies?.(draft, independently, context) ?? true)) .map((definition) => toQuestion(definition, draft, context)); } @@ -162,20 +314,30 @@ function definitions(): readonly QuestionDefinition[] { ...SETUP_AGENT_TASKS.map((task): QuestionDefinition => ({ stateId: 'agent-runtime', id: `agents.${task}.provider`, label: `${formatTask(task)} runtime`, kind: 'choice', choices: AGENT_PROVIDERS, })), - { stateId: 'agent-model-defaults', id: 'agents.findings.modelProvider', label: 'Model provider for all tasks', kind: 'choice', choices: MODEL_PROVIDERS }, - { stateId: 'agent-model-defaults', id: 'agents.findings.model', label: 'Model name for all tasks', kind: 'text' }, - { stateId: 'agent-model-defaults', id: 'agents.findings.effort', label: 'Reasoning effort for all tasks (empty uses provider default)', kind: 'text' }, - { stateId: 'agent-model-defaults', id: 'agents.findings.executable', label: 'Validated executable for all tasks (empty uses the manifest basename)', kind: 'text' }, - { stateId: 'agent-model-defaults', id: 'agents.configureIndependently', label: 'Configure model provider, model, effort, and executable independently for every task?', kind: 'boolean', read: () => false }, + { stateId: 'agent-model-defaults', id: 'agents.findings.modelProvider', label: 'Shared model provider (unless a role has its own setting)', kind: 'choice', choices: MODEL_PROVIDERS }, + { stateId: 'agent-model-defaults', id: 'agents.findings.model', label: 'Shared model name (unless a role has its own setting)', kind: 'text' }, + { stateId: 'agent-model-defaults', id: 'agents.findings.effort', label: 'Shared reasoning effort (empty uses provider default; per-role overrides stay separate)', kind: 'text' }, + { stateId: 'agent-model-defaults', id: 'agents.findings.executable', label: 'Shared validated executable (empty uses manifest basename; per-role overrides stay separate)', kind: 'text' }, + { stateId: 'agent-model-defaults', id: 'agents.configureIndependently', label: 'Configure model provider, model, effort, and executable independently for every task?', kind: 'boolean', read: draft => hasIndependentAgentSettings(draft) }, ...SETUP_AGENT_TASKS.filter((task) => task !== 'findings').flatMap((task) => agentOverrideQuestions(task)), ...repositoryQuestions(), ...deploymentQuestions(), ...bugbotQuestions(), ...approvalQuestions(), - { stateId: 'projects', id: 'projects.ids', label: 'GitHub Project IDs (comma-separated, empty skips integration)', kind: 'text' }, + { stateId: 'projects', id: 'projects.enabled', label: 'Integrate existing GitHub Projects with issue and pull-request automation?', kind: 'boolean', + read: (draft, context) => context.projectsWanted ?? Boolean(draft.projects.ids.trim()), + applies: draft => draft.features.issues !== false || draft.features.pullRequests !== false }, + { stateId: 'projects', id: 'projects.ids', label: 'Select existing GitHub Projects (or enter Project numbers from their URLs)', kind: 'text', + applies: (draft, _independent, context) => (draft.features.issues !== false || draft.features.pullRequests !== false) && context.projectsWanted !== false }, ...['issueCreatedColumn', 'pullRequestCreatedColumn', 'issueInProgressColumn', 'pullRequestInProgressColumn'].map((field): QuestionDefinition => ({ stateId: 'projects', id: `projects.${field}`, label: projectLabel(field), kind: 'text', applies: (config) => Boolean(config.projects.ids.trim()), })), + { stateId: 'projects', id: 'projects.statusVerified', + label: 'Have you checked every selected Project in GitHub and confirmed all four exact Status values?', + kind: 'boolean', read: () => false, + applies: (draft, _independently, context) => Boolean(draft.projects.ids.trim()) + && sharedProjectStatusOptions(draft.projects.ids, context.projectDiscovery?.candidates ?? []).state === 'unavailable', + }, { stateId: 'provisioning', id: 'createInitialTag', label: 'Create v1.0.0 when no version tag exists?', kind: 'boolean' }, { stateId: 'provisioning', id: 'manageRepositoryVariables', label: 'Create/update GitHub Actions Variables?', kind: 'boolean' }, { stateId: 'provisioning', id: 'manageRepositorySecrets', label: 'Validate and provision required GitHub Actions Secrets?', kind: 'boolean' }, @@ -184,6 +346,13 @@ function definitions(): readonly QuestionDefinition[] { ]; } +/** Stable content inventory for documentation and localization audits; never answers questions. */ +export function setupQuestionContentInventory(): readonly SetupQuestion[] { + return definitions().map(({ stateId, id, label, kind, choices }) => ({ + stateId, id, label, kind, choices, defaultValue: '', + })); +} + function agentOverrideQuestions(task: AgentTask): QuestionDefinition[] { const applies = (_draft: SetupConfiguration, independently: boolean) => independently; return [ @@ -269,12 +438,6 @@ function approvalQuestions(): QuestionDefinition[] { read: draft => draft.pullRequestApproval.testChecks.map(check => `${check.name}|${check.sourceAppId}|${check.workflowName}`).join(';'), applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', }, - { - stateId: 'pull-request-approval', id: 'pullRequestApproval.producerAttested', - label: 'Have you verified each exact check, source App ID, workflow, and coverage-enforcing CI step?', - kind: 'boolean', - applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', - }, { stateId: 'pull-request-approval', id: 'pullRequestApproval.coverage.mode', label: 'Coverage evidence mode', kind: 'choice', choices: ['check', 'numeric'], @@ -283,7 +446,7 @@ function approvalQuestions(): QuestionDefinition[] { { stateId: 'pull-request-approval', id: 'pullRequestApproval.coverage.checkName', label: 'Exact trusted check that enforces the coverage budget (no inferred percentage)', - kind: 'text', + kind: 'choice', applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', }, { @@ -310,6 +473,12 @@ function approvalQuestions(): QuestionDefinition[] { applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off' && draft.pullRequestApproval.coverage.mode === 'numeric', }, + { + stateId: 'pull-request-approval', id: 'pullRequestApproval.producerAttested', + label: 'Have you verified each exact check, source App ID, workflow, and coverage-enforcing CI step?', + kind: 'boolean', + applies: draft => draft.features.pullRequests !== false && draft.pullRequestApproval.mode !== 'off', + }, ]; } @@ -340,17 +509,58 @@ function choice(field: string, label: string, choices: readonly string[]): Quest } function toQuestion(definition: QuestionDefinition, draft: SetupConfiguration, context: SetupQuestionnaireContext): SetupQuestion { + const branchScopedCandidates = context.approvalCheckCandidates?.map(candidate => + candidate.requiredByRuleset?.branch !== draft.repository.developmentBranch + ? { ...candidate, requiredByRuleset: undefined } : candidate); + const producerCandidates = definition.id === 'pullRequestApproval.testChecks' ? branchScopedCandidates + : definition.id === 'pullRequestApproval.coverage.checkName' ? branchScopedCandidates?.filter(candidate => + draft.pullRequestApproval.testChecks.some(check => check.name === candidate.name + && check.sourceAppId === candidate.sourceAppId && check.workflowName === candidate.workflowName)) : undefined; + const coverageChoices = definition.id === 'pullRequestApproval.coverage.checkName' + ? [...new Set(draft.pullRequestApproval.testChecks.map(check => check.name))] : undefined; const allowedNames = definition.kind === 'scope-overrides' ? inheritedNames(definition.id.includes('.variables.') ? 'variables' : 'secrets', draft, context) : undefined; + const projectQuestion = definition.id === 'projects.ids'; + const statusQuestion = /^projects\.(issue|pullRequest)(Created|InProgress)Column$/u.test(definition.id); + const projectCandidates = context.projectDiscovery?.candidates ?? []; + const projectStatus = statusQuestion + ? sharedProjectStatusOptions(draft.projects.ids, projectCandidates) : undefined; return { stateId: definition.stateId, id: definition.id, label: definition.label, - kind: definition.kind, - defaultValue: definition.read?.(draft) ?? readPath(draft, definition.id, allowedNames), - ...(definition.choices ? { choices: definition.choices } : {}), + kind: definition.id === 'pullRequestApproval.testChecks' ? 'producer-select' + : projectQuestion ? 'project-select' + : statusQuestion && projectStatus?.state === 'observed' ? 'choice' : definition.kind, + defaultValue: definition.read?.(draft, context) ?? readPath(draft, definition.id, allowedNames), + ...(coverageChoices ? { choices: coverageChoices } : projectStatus?.state === 'observed' + ? { choices: projectStatus.options } : definition.choices ? { choices: definition.choices } : {}), ...(allowedNames ? { allowedNames } : {}), + ...(producerCandidates?.length ? { producerCandidates } : {}), + ...(definition.id === 'pullRequestApproval.coverage.checkName' + ? { trustedProducers: draft.pullRequestApproval.testChecks } : {}), + ...(definition.id === 'pullRequestApproval.testChecks' && context.approvalCheckDiscoveryStatus + ? { discoveryStatus: context.approvalCheckDiscoveryStatus, discoveryTruncated: context.approvalCheckDiscoveryTruncated, + discoveryRetryRemaining: context.discoveryRetryRemaining?.checks ?? 0 } : {}), + ...(projectQuestion ? { discoveryStatus: context.projectDiscovery?.status ?? 'unavailable', + discoveryTruncated: context.projectDiscovery?.truncated, + ...(context.projectDiscovery && context.projectDiscovery.status !== 'unsupported' && context.discoveryRetryRemaining + ? { discoveryRetryRemaining: context.discoveryRetryRemaining.projects } : {}), + projectCandidates, projectOwner: context.projectOwner } : {}), + ...(statusQuestion && projectStatus ? { statusOptionState: projectStatus.state } : {}), + ...(definition.id === 'projects.statusVerified' ? { projectStatusValues: [ + { transition: 'issueCreated' as const, value: draft.projects.issueCreatedColumn }, + { transition: 'pullRequestCreated' as const, value: draft.projects.pullRequestCreatedColumn }, + { transition: 'issueInProgress' as const, value: draft.projects.issueInProgressColumn }, + { transition: 'pullRequestInProgress' as const, value: draft.projects.pullRequestInProgressColumn }, + ] } : {}), + ...(definition.id === 'repository.mainBranch' && context.branchSources + ? { suggestionSource: context.branchSources.main } : {}), + ...(definition.id === 'repository.developmentBranch' && context.branchSources + ? { suggestionSource: context.branchSources.development } : {}), + ...((definition.id === 'issueWorkflows.enabled' || definition.id === 'features.issues') && context.fixedWorkflowFeatures + ? { fixedWorkflowFeatures: context.fixedWorkflowFeatures } : {}), }; } @@ -370,6 +580,34 @@ function readPath( function parseAnswer(question: SetupQuestion, raw: string): { value: string | number | boolean | Record } | { error: string } { const input = raw.normalize('NFKC').trim(); + if (question.id === 'projects.statusVerified') return ['y', 'yes', 'true', '1'].includes(input.toLowerCase()) + ? { value: true } : { error: 'Open every selected Project in GitHub and confirm that all four exact Status values exist. Answer Yes after checking, or No to choose Projects again.' }; + if (question.id === 'projects.ids') { + const parsed = parseSetupProjectSelection(input || String(question.defaultValue), question.projectOwner); + if ('error' in parsed) return parsed; + const status = sharedProjectStatusOptions(parsed.value, question.projectCandidates ?? []); + if (status.state === 'incompatible') return { error: 'Selected Projects have no common Status option. Choose compatible Projects or configure them separately.' }; + return parsed; + } + if (question.id === 'pullRequestApproval.testChecks') { + const entries = (input || String(question.defaultValue)).split(';').map(item => item.trim()).filter(Boolean) + .flatMap(item => item.split(',').map(value => value.trim()).filter(Boolean)) + .map(item => { + const index = Number(item) - 1; + const candidate = Number.isSafeInteger(index) && /^[1-9]\d*$/u.test(item) ? question.producerCandidates?.[index] : undefined; + return candidate ? `${candidate.name}|${candidate.sourceAppId}|${candidate.workflowName}` : item; + }); + if (entries.length < 1 || entries.length > 8 || entries.some(entry => !/^[^|;\r\n]{1,100}\|[1-9][0-9]*\|[^|;\r\n]{1,100}$/u.test(entry))) { + return { error: 'Select 1–8 observed checks or enter exact name|App ID|workflow tuples.' }; + } + if (new Set(entries).size !== entries.length) return { error: 'A trusted check was selected more than once.' }; + const names = entries.map(entry => entry.split('|', 1)[0]); + if (new Set(names).size !== names.length) return { error: 'Two trusted producers use the same check name. Coverage stores only one name; choose one producer or rename the CI jobs before continuing.' }; + return { value: entries.join(';') }; + } + if (!input && question.statusOptionState === 'observed' && !question.choices?.includes(String(question.defaultValue))) { + return { error: 'The saved Status value is not available in every selected Project. Choose a listed Status option.' }; + } if (!input && question.kind !== 'scope-overrides') return { value: question.defaultValue }; if (question.kind === 'text') return { value: input }; if (question.kind === 'number') { @@ -409,9 +647,29 @@ function applyAnswer( configuration: SetupConfiguration, question: SetupQuestion, value: string | number | boolean | Record, + independently: boolean, ): SetupConfiguration { const draft = cloneSetupConfiguration(configuration); - if (question.id === 'agents.configureIndependently') return draft; + if (question.id === 'agents.configureIndependently') { + if (!value) for (const task of SETUP_AGENT_TASKS.filter(task => task !== 'findings')) { + draft.agents[task] = { ...draft.agents[task], modelProvider: draft.agents.findings.modelProvider, + model: draft.agents.findings.model, effort: draft.agents.findings.effort, + executable: draft.agents.findings.executable }; + } + return draft; + } + if (question.id === 'projects.enabled') { + if (!value) draft.projects.ids = ''; + return draft; + } + if (question.id === 'projects.statusVerified') return draft; + if (question.id === 'features.issues' && value === false) { + draft.features.issues = false; + draft.features.release = false; + draft.features.hotfix = false; + draft.issueWorkflows = createIssueWorkflowProfile([]); + return draft; + } if (question.id === 'features.pullRequests' && value === false) { draft.features.pullRequests = false; draft.pullRequestApproval = { ...draft.pullRequestApproval, mode: 'off' }; @@ -452,7 +710,9 @@ function applyAnswer( } if (['agents.findings.modelProvider', 'agents.findings.model', 'agents.findings.effort', 'agents.findings.executable'].includes(question.id)) { const field = question.id.split('.')[2] as 'modelProvider' | 'model' | 'effort' | 'executable'; - for (const task of SETUP_AGENT_TASKS) draft.agents[task] = { ...draft.agents[task], [field]: value as string }; + for (const task of independently ? (['findings'] as const) : SETUP_AGENT_TASKS) { + draft.agents[task] = { ...draft.agents[task], [field]: value as string }; + } return draft; } const parts = question.id.split('.'); @@ -470,6 +730,7 @@ function applyAnswer( function parseWorkflowSelection(raw: string): { value: IssueWorkflowKind[] } | { error: string } { const normalized = raw.trim().toLowerCase(); + if (normalized === 'none') return { value: [] }; if (!normalized || normalized === 'all') return { value: [...ISSUE_WORKFLOW_KINDS] }; const requested = normalized.split(',').map(item => item.trim()).filter(Boolean) .map(item => item.replace(/\s+—.*$/u, '').replace(/^\d+[.)]\s*/u, '')); diff --git a/src/application/policies/setup_remote_facts_policy.ts b/src/application/policies/setup_remote_facts_policy.ts new file mode 100644 index 000000000..dda849397 --- /dev/null +++ b/src/application/policies/setup_remote_facts_policy.ts @@ -0,0 +1,24 @@ +import type { SetupRemoteConfiguration, SetupVariable } from '../../domain/setup'; + +/** Compare the semantic GitHub facts used by setup, not object/response ordering. */ +export function sameSetupRemoteFacts(left: SetupRemoteConfiguration, right: SetupRemoteConfiguration): boolean { + const variables = (items: readonly SetupVariable[]): readonly string[] => items + .map(item => JSON.stringify([item.name, item.value])).sort(); + const normalize = (facts: SetupRemoteConfiguration) => ({ + ownerType: facts.ownerType, + defaultBranch: facts.defaultBranch, + repositoryId: facts.repositoryId, + repositoryVisibility: facts.repositoryVisibility, + repositorySecrets: [...facts.repositorySecrets].sort(), + repositorySecretsAccess: facts.repositorySecretsAccess, + organizationSecrets: [...facts.organizationSecrets].sort(), + repositoryVariables: variables(facts.repositoryVariables), + repositoryVariablesAccess: facts.repositoryVariablesAccess, + organizationVariables: variables(facts.organizationVariables), + organizationAccess: facts.organizationAccess, + organizationSecretsAccess: facts.organizationSecretsAccess, + organizationVariablesAccess: facts.organizationVariablesAccess, + credentialHealthWorkflow: facts.credentialHealthWorkflow, + }); + return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)); +} diff --git a/src/application/policies/setup_terminal_choice_policy.ts b/src/application/policies/setup_terminal_choice_policy.ts new file mode 100644 index 000000000..44f095141 --- /dev/null +++ b/src/application/policies/setup_terminal_choice_policy.ts @@ -0,0 +1,3 @@ +export function safeTerminalChoiceText(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ''); +} diff --git a/src/application/policies/setup_token_permission_policy.ts b/src/application/policies/setup_token_permission_policy.ts index b1e98ade1..3c22e732c 100644 --- a/src/application/policies/setup_token_permission_policy.ts +++ b/src/application/policies/setup_token_permission_policy.ts @@ -39,20 +39,24 @@ const requirement = (input: PermissionInput): SetupTokenPermissionRequirement => * interactive configuration does not exist before the setup PAT prompt. */ export function buildSetupPatPermissionRequirements(): SetupTokenPermissionRequirement[] { - return normalizePermissionRequirements([ + // Keep conditional read and write paths separate here: collapsing Actions + // into one write row would hide the approval-only read requirement. + return [ requirement({ role: 'setup', scope: 'repository', permission: 'Metadata', level: 'read', reason: 'Resolve repository identity and visibility.', probe: 'metadata' }), requirement({ role: 'setup', scope: 'repository', permission: 'Contents', level: 'read', reason: 'Inspect installed workflows and repository files.', probe: 'contents' }), requirement({ role: 'setup', scope: 'repository', permission: 'Secrets', level: 'write', applicability: 'conditional', condition: 'Secret provisioning enabled', reason: 'Inspect and provision selected GitHub Actions Secrets.', probe: 'secrets' }), requirement({ role: 'setup', scope: 'repository', permission: 'Variables', level: 'write', applicability: 'conditional', condition: 'Variable provisioning enabled', reason: 'Inspect and provision selected GitHub Actions Variables.', probe: 'variables' }), requirement({ role: 'setup', scope: 'repository', permission: 'Issues', level: 'write', applicability: 'conditional', condition: 'Issue workflows enabled', reason: 'Provision labels and issue resources.', probe: 'issues' }), requirement({ role: 'setup', scope: 'repository', permission: 'Actions', level: 'write', applicability: 'conditional', condition: 'Credential health enabled', reason: 'Inspect and dispatch credential-health workflows.', probe: 'actions' }), + requirement({ role: 'setup', scope: 'repository', permission: 'Actions', level: 'read', applicability: 'conditional', condition: 'Pull-request approval enabled', reason: 'Inspect CI workflow runs and jobs for exact producer identities.', probe: 'actions' }), + requirement({ role: 'setup', scope: 'repository', permission: 'Checks', level: 'read', applicability: 'conditional', condition: 'Pull-request approval enabled', reason: 'Discover exact CI check and producer identities.', probe: 'checks' }), requirement({ role: 'setup', scope: 'repository', permission: 'Administration', level: 'read', applicability: 'conditional', condition: 'Release, hotfix, or guarded approval enabled', reason: 'Inspect branch protection and rulesets.', probe: 'administration' }), requirement({ role: 'setup', scope: 'repository', permission: 'Workflows', level: 'write', applicability: 'conditional', condition: 'Temporary health workflow required', reason: 'Bootstrap a missing credential-health workflow.', probe: 'workflows' }), requirement({ role: 'setup', scope: 'organization', permission: 'Secrets', level: 'write', applicability: 'conditional', condition: 'Organization Secret storage selected', reason: 'Inspect and provision organization Actions Secrets.', probe: 'secrets' }), requirement({ role: 'setup', scope: 'organization', permission: 'Variables', level: 'write', applicability: 'conditional', condition: 'Organization Variable storage selected', reason: 'Inspect and provision organization Actions Variables.', probe: 'variables' }), requirement({ role: 'setup', scope: 'organization', permission: 'Issue Types', level: 'write', applicability: 'conditional', condition: 'Issue type automation enabled', reason: 'Provision and assign configured issue types.', probe: 'issue-types' }), - requirement({ role: 'setup', scope: 'organization', permission: 'Projects', level: 'write', applicability: 'conditional', condition: 'Organization Projects selected', reason: 'Inspect and configure selected Projects.', probe: 'projects' }), - ]); + requirement({ role: 'setup', scope: 'organization', permission: 'Projects', level: 'read', applicability: 'conditional', condition: 'Organization Projects selected', reason: 'Inspect selected Projects and their Status options; setup does not edit Project items.', probe: 'projects' }), + ]; } /** @@ -63,6 +67,56 @@ export function buildSetupPatPermissionRequirements(): SetupTokenPermissionRequi export function buildConfiguredSetupPatPermissionRequirements( configuration: Readonly, remote?: Readonly, +): SetupTokenPermissionRequirement[] { + // Unknown is not evidence of a personal owner: keep possible organization + // grants visible until the final audit can verify the actual owner type. + return buildSetupPatRequirements(configuration, + remote?.ownerType === 'Organization' || remote?.ownerType === 'Unknown', remote); +} + +/** Grants justified by local choices alone; remote-only conditions stay unresolved. */ +export function buildSetupPatIntentPermissionRequirements( + configuration: Readonly, + ownerKind: 'Organization' | 'User', + projectsWanted = configuration.projects.ids.trim().length > 0, +): SetupTokenPermissionRequirement[] { + return buildSetupPatRequirements(configuration, ownerKind === 'Organization', undefined, projectsWanted); +} + +export function buildSetupPatIntentUncertainty(configuration: Readonly, ownerKind: 'Organization' | 'User'): string[] { + const unknown: string[] = []; + if (configuration.manageRepositorySecrets) { + unknown.push('Existing managed Secrets may require repository Actions write for credential-health checks. A confirmed missing health workflow may also require repository Contents write and Workflows write.'); + } + if (ownerKind === 'Organization') { + for (const kind of ['secrets', 'variables'] as const) { + const managed = kind === 'secrets' ? configuration.manageRepositorySecrets : configuration.manageRepositoryVariables; + if (managed && configuration.storage[kind].preserveExisting && configuration.storage[kind].defaultScope === 'repository') { + unknown.push(`Inherited organization ${kind} may require organization ${kind === 'secrets' ? 'Secrets' : 'Variables'} write after inventory inspection.`); + } + } + } + return unknown; +} + +/** Required grants newly introduced (or upgraded) after the provisional review. */ +export function requiredSetupPatPermissionDelta( + before: readonly SetupTokenPermissionRequirement[], + after: readonly SetupTokenPermissionRequirement[], +): string[] { + const previous = new Map(before.filter(item => item.applicability === 'required') + .map(item => [`${item.scope}:${item.permission.toLowerCase()}`, item.level])); + return after.filter(item => item.applicability === 'required' + && (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === undefined + || (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === 'read' && item.level === 'write'))) + .map(item => `${item.scope} ${item.permission} ${item.level}`); +} + +function buildSetupPatRequirements( + configuration: Readonly, + organization: boolean, + remote?: Readonly, + projectsWanted = configuration.projects.ids.trim().length > 0, ): SetupTokenPermissionRequirement[] { const repositorySecretNames = buildSetupCredentialRequirements(configuration) .map(credential => credential.name); @@ -80,13 +134,13 @@ export function buildConfiguredSetupPatPermissionRequirements( || configuration.features.hotfix || enabledIssueWorkflowKinds.some(kind => kind === 'release' || kind === 'hotfix'); const guardedApproval = configuration.pullRequestApproval.mode === 'guarded'; + const approvalEnabled = configuration.pullRequestApproval.mode !== 'off'; const hasExistingCredential = repositorySecretNames.some(name => remote?.repositorySecrets.includes(name) || remote?.organizationSecrets.includes(name), ); const needsCredentialHealth = configuration.manageRepositorySecrets && hasExistingCredential; const needsCredentialHealthBootstrap = needsCredentialHealth && remote?.credentialHealthWorkflow === 'missing'; - const organization = remote?.ownerType === 'Organization'; return normalizePermissionRequirements([ requirement({ role: 'setup', scope: 'repository', permission: 'Metadata', level: 'read', reason: 'Resolve repository identity and visibility.', probe: 'metadata' }), @@ -111,6 +165,10 @@ export function buildConfiguredSetupPatPermissionRequirements( role: 'setup', scope: 'repository', permission: 'Actions', level: 'write', reason: 'Dispatch credential-health checks for existing Secrets.', probe: 'actions', })] : []), + ...(approvalEnabled ? [ + requirement({ role: 'setup', scope: 'repository', permission: 'Actions', level: 'read', reason: 'Inspect CI workflow runs and jobs for approval evidence.', probe: 'actions' }), + requirement({ role: 'setup', scope: 'repository', permission: 'Checks', level: 'read', reason: 'Discover exact CI check and producer identities.', probe: 'checks' }), + ] : []), ...(needsCredentialHealthBootstrap ? [ requirement({ role: 'setup', scope: 'repository', permission: 'Contents', level: 'write', reason: 'Temporarily install credential health when its workflow is not confirmed installed.', probe: 'contents' }), requirement({ role: 'setup', scope: 'repository', permission: 'Workflows', level: 'write', reason: 'Temporarily install credential health when its workflow is not confirmed installed.', probe: 'workflows' }), @@ -131,9 +189,9 @@ export function buildConfiguredSetupPatPermissionRequirements( role: 'setup', scope: 'organization', permission: 'Issue Types', level: 'write', reason: 'Provision native issue types for the selected workflows.', probe: 'issue-types', })] : []), - ...(organization && configuration.projects.ids.trim().length > 0 ? [requirement({ - role: 'setup', scope: 'organization', permission: 'Projects', level: 'write', - reason: 'Inspect and configure the selected organization Projects.', probe: 'projects', + ...(organization && projectsWanted ? [requirement({ + role: 'setup', scope: 'organization', permission: 'Projects', level: 'read', + reason: 'Inspect selected Projects and their Status options; setup does not edit Project items.', probe: 'projects', })] : []), ]); } diff --git a/src/application/ports/setup_approval_check_discovery_port.ts b/src/application/ports/setup_approval_check_discovery_port.ts new file mode 100644 index 000000000..ef61a3f70 --- /dev/null +++ b/src/application/ports/setup_approval_check_discovery_port.ts @@ -0,0 +1,5 @@ +import type { SetupApprovalCheckCandidate, SetupDiscoveryResult } from '../../domain/setup_questionnaire'; + +export interface SetupApprovalCheckDiscoveryPort { + discover(owner: string, repository: string, token: string, targetBranch?: string): Promise>; +} diff --git a/src/application/ports/setup_pat_identity_ports.ts b/src/application/ports/setup_pat_identity_ports.ts new file mode 100644 index 000000000..508374bed --- /dev/null +++ b/src/application/ports/setup_pat_identity_ports.ts @@ -0,0 +1,9 @@ +export interface SetupGithubIdentity { + readonly id: number; + readonly login: string; +} + +export interface SetupGithubIdentityQueryPort { + resolve(login: string, setupToken: string): Promise; + identify(token: string): Promise; +} diff --git a/src/application/ports/setup_project_discovery_port.ts b/src/application/ports/setup_project_discovery_port.ts new file mode 100644 index 000000000..3832a57c5 --- /dev/null +++ b/src/application/ports/setup_project_discovery_port.ts @@ -0,0 +1,6 @@ +import type { SetupDiscoveryResult, SetupProjectCandidate } from '../../domain/setup_questionnaire'; + +/** Read-only GitHub Project inventory for setup; the PAT never crosses into a browser view. */ +export interface SetupProjectDiscoveryPort { + discover(owner: string, ownerType: 'Organization' | 'User' | 'Unknown', token: string): Promise>; +} diff --git a/src/application/ports/setup_terminal_ports.ts b/src/application/ports/setup_terminal_ports.ts index f4dbe4265..e35b60551 100644 --- a/src/application/ports/setup_terminal_ports.ts +++ b/src/application/ports/setup_terminal_ports.ts @@ -4,6 +4,7 @@ import type { SetupQuestionnaireContext, SetupQuestionnaireState, SetupQuestionnaireStateId, + SetupQuestionnaireProgress, } from '../../domain/setup_questionnaire'; export type TerminalReadResult = @@ -16,7 +17,7 @@ export interface TerminalDriver { isInteractive(): boolean; readText(prompt: string): Promise; /** Optional raw-mode selector. Drivers without it fall back to text parsing. */ - readMultiSelect?(prompt: string, choices: readonly string[], selected: readonly string[]): Promise; + readMultiSelect?(prompt: string, choices: readonly string[], selected: readonly string[], helpText?: string): Promise; readSecret(prompt: string): Promise; close(): void; } @@ -24,7 +25,9 @@ export interface TerminalDriver { export interface SetupQuestionRenderer { showIntroduction(): void; showState(stateId: SetupQuestionnaireStateId): void; - renderPrompt(question: SetupQuestion): string; + renderPrompt(question: SetupQuestion, progress?: SetupQuestionnaireProgress): string; + renderHelp(question: SetupQuestion): string; + showHelp(question: SetupQuestion): void; showValidation(message: string): void; showCancelled(): void; } @@ -33,9 +36,15 @@ export interface SetupConfigurationCollectorPort { collect( initial: SetupQuestionnaireState, context: SetupQuestionnaireContext, + discoveryRefresh?: SetupDiscoveryRefreshPort, ): Promise; } +/** Read-only, explicitly requested discovery; undefined means the retry budget is exhausted. */ +export interface SetupDiscoveryRefreshPort { + refresh(kind: 'checks' | 'projects'): Promise; +} + export interface SetupPlanPresenterPort { present(plan: SetupPlan): void; } @@ -45,5 +54,6 @@ export interface SetupPlanConfirmationPort { | { readonly kind: 'approved' } | { readonly kind: 'declined' } | { readonly kind: 'cancelled' } + | { readonly kind: 'revise'; readonly group: SetupQuestion['stateId'] } >; } diff --git a/src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts b/src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts index 5e7db0bd8..4a70a770d 100644 --- a/src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts @@ -1,5 +1,5 @@ import { InitialSetupUseCase } from '../initial_setup_use_case'; -import { Result } from '../../../../data/model/result'; +import { Result, getResultPayload } from '../../../../data/model/result'; import type { Execution } from '../../../../data/model/execution'; import { createDefaultSetupConfiguration } from '../../../policies/setup_configuration_policy'; import { projectInitialSetupContext } from '../../push_single_action_contexts'; @@ -140,6 +140,8 @@ describe('InitialSetupUseCase', () => { ); expect(mockSetupHasValidToken).toHaveBeenCalledTimes(1); expect(mockSetupPrepare).not.toHaveBeenCalled(); + expect(getResultPayload(getResultPayload(results[0].payload)?.setupReceipt)?.effects) + .toEqual(expect.arrayContaining([{ id: 'files', state: 'not-started', scope: 'local' }])); } finally { mockSetupHasValidToken.mockReturnValue(true); } @@ -170,6 +172,126 @@ describe('InitialSetupUseCase', () => { param.labels, ); expect(results[0].steps?.some((s) => s.includes('Issue types'))).toBe(true); + expect(getResultPayload(getResultPayload(results[0].payload)?.setupReceipt)?.effects).toEqual([ + { id: 'files', state: 'completed', scope: 'local' }, + { id: 'secrets', state: 'skipped', scope: 'repository' }, + { id: 'labels', state: 'skipped', scope: 'repository' }, + { id: 'issue-types', state: 'skipped', scope: 'repository' }, + { id: 'variables', state: 'skipped', scope: 'repository' }, + { id: 'initial-tag', state: 'skipped', scope: 'repository' }, + ]); + }); + + it('distinguishes skipped files from newly created labels and issue types in the receipt', async () => { + mockSetupPrepare.mockReturnValueOnce({ copied: 0, skipped: 2 }); + mockEnsureInitialLabels.mockResolvedValueOnce({ + configured: { created: 1, existing: 4, errors: [] }, progress: { created: 0, existing: 21, errors: [] }, + }); + mockEnsureIssueTypes.mockResolvedValueOnce({ success: true, created: 2, existing: 1, errors: [] }); + const result = await useCase.invoke(baseParam()); + const effects = getResultPayload(getResultPayload(result[0].payload)?.setupReceipt)?.effects; + expect(effects).toEqual(expect.arrayContaining([ + { id: 'files', state: 'skipped', scope: 'local' }, + { id: 'labels', state: 'completed', scope: 'repository' }, + { id: 'issue-types', state: 'completed', scope: 'repository' }, + ])); + }); + + it('reports provisioned Secrets and keeps a failed Secret write marked for inspection', async () => { + const setupConfiguration = createDefaultSetupConfiguration(); + const secretPort = { upsertSecrets: jest.fn().mockResolvedValueOnce({ created: 1, updated: 0, skipped: 0, errors: [] }) + .mockResolvedValueOnce({ created: 0, updated: 0, skipped: 0, errors: ['Secret write denied'] }) }; + const withSecrets = new InitialSetupUseCase( + { getUser: mockGetUserFromToken, getUserDetails: jest.fn() }, + { ensureInitialLabels: mockEnsureInitialLabels }, { ensureIssueTypes: mockEnsureIssueTypes }, + { getLatestTag: mockGetLatestTag }, { getDefaultBranch: mockGetDefaultBranch } as any, + { createTag: mockCreateTag } as any, + { prepare: mockSetupPrepare, hasValidToken: mockSetupHasValidToken }, + { upsert: mockSetupVariablesUpsert }, secretPort, + ); + const param = baseParam({ inputs: { setupConfiguration, setupRemoteConfiguration: repositorySnapshot, + setupCredentials: { workflowPat: { name: 'PAT', value: 'fake-workflow-token' }, apiKeys: [] } } }); + const completed = await withSecrets.invoke(param); + expect(getResultPayload(getResultPayload(completed[0].payload)?.setupReceipt)?.effects) + .toContainEqual({ id: 'secrets', state: 'completed', scope: 'repository' }); + const failed = await withSecrets.invoke(param); + expect(failed[0].success).toBe(false); + expect(getResultPayload(getResultPayload(failed[0].payload)?.setupReceipt)?.effects) + .toContainEqual({ id: 'secrets', state: 'needs-inspection', scope: 'repository' }); + expect(secretPort.upsertSecrets).toHaveBeenCalledTimes(2); + }); + + it('marks Secrets and Variables skipped when providers report no created or updated values', async () => { + const setupConfiguration = createDefaultSetupConfiguration(); + mockSetupVariablesUpsert.mockResolvedValueOnce({ created: 0, updated: 0, errors: [] }); + const secretPort = { upsertSecrets: jest.fn().mockResolvedValue({ created: 0, updated: 0, skipped: 1, errors: [] }) }; + const noOpProvisioning = new InitialSetupUseCase( + { getUser: mockGetUserFromToken, getUserDetails: jest.fn() }, + { ensureInitialLabels: mockEnsureInitialLabels }, { ensureIssueTypes: mockEnsureIssueTypes }, + { getLatestTag: mockGetLatestTag }, { getDefaultBranch: mockGetDefaultBranch } as any, + { createTag: mockCreateTag } as any, + { prepare: mockSetupPrepare, hasValidToken: mockSetupHasValidToken }, + { upsert: mockSetupVariablesUpsert }, secretPort, + ); + const result = await noOpProvisioning.invoke(baseParam({ inputs: { setupConfiguration, setupRemoteConfiguration: repositorySnapshot, + setupCredentials: { workflowPat: { name: 'PAT', value: 'fake-workflow-token' }, apiKeys: [] } } })); + expect(result[0].success).toBe(true); + expect(secretPort.upsertSecrets).toHaveBeenCalledTimes(1); + expect(mockSetupVariablesUpsert).toHaveBeenCalledTimes(1); + expect(getResultPayload(getResultPayload(result[0].payload)?.setupReceipt)?.effects).toEqual(expect.arrayContaining([ + { id: 'secrets', state: 'skipped', scope: 'repository' }, + { id: 'variables', state: 'skipped', scope: 'repository' }, + ])); + }); + + it('marks a failed Variable write for inspection and retains mixed scope even when setup stops early', async () => { + const setupConfiguration = createDefaultSetupConfiguration(); + setupConfiguration.storage.variables.overrides = { AGENT_PROVIDER: 'organization' }; + mockSetupHasValidToken.mockReturnValueOnce(false); + const stopped = await useCase.invoke(baseParam({ inputs: { setupConfiguration } })); + expect(getResultPayload(getResultPayload(stopped[0].payload)?.setupReceipt)?.effects) + .toContainEqual({ id: 'variables', state: 'not-started', scope: 'mixed' }); + setupConfiguration.storage.variables.overrides = {}; + mockSetupVariablesUpsert.mockResolvedValueOnce({ created: 0, updated: 0, errors: ['Variable write denied'] }); + const failed = await useCase.invoke(baseParam({ inputs: { setupConfiguration, setupRemoteConfiguration: repositorySnapshot } })); + expect(getResultPayload(getResultPayload(failed[0].payload)?.setupReceipt)?.effects) + .toContainEqual({ id: 'variables', state: 'needs-inspection', scope: 'repository' }); + }); + + it('never reports Variables or Secrets completed when their provisioning adapters are absent', async () => { + const setupConfiguration = createDefaultSetupConfiguration(); + const noProvisioningPorts = new InitialSetupUseCase( + { getUser: mockGetUserFromToken, getUserDetails: jest.fn() }, + { ensureInitialLabels: mockEnsureInitialLabels }, { ensureIssueTypes: mockEnsureIssueTypes }, + { getLatestTag: mockGetLatestTag }, { getDefaultBranch: mockGetDefaultBranch } as any, + { createTag: mockCreateTag } as any, + { prepare: mockSetupPrepare, hasValidToken: mockSetupHasValidToken }, + ); + const inputs = { setupConfiguration, setupRemoteConfiguration: repositorySnapshot, + setupCredentials: { workflowPat: { name: 'PAT', value: 'fake-workflow-token' }, apiKeys: [] } }; + const result = await noProvisioningPorts.invoke(baseParam({ inputs })); + expect(result[0].success).toBe(false); + expect(mockSetupPrepare).not.toHaveBeenCalled(); + expect(result[0].errors?.map(error => error.message)).toEqual(expect.arrayContaining([ + 'GitHub Actions Variable provisioning is unavailable; no Variables were changed.', + 'GitHub Actions Secret provisioning is unavailable; no Secrets were changed.', + ])); + expect(getResultPayload(getResultPayload(result[0].payload)?.setupReceipt)?.effects).toEqual(expect.arrayContaining([ + { id: 'variables', state: 'not-started', scope: 'repository' }, + { id: 'secrets', state: 'not-started', scope: 'repository' }, + ])); + }); + + it('marks a failed local write as needing inspection and later resources as not started', async () => { + mockSetupPrepare.mockImplementationOnce(() => { throw new Error('write may have happened'); }); + const results = await useCase.invoke(baseParam()); + const effects = getResultPayload(getResultPayload(results[0].payload)?.setupReceipt)?.effects; + expect(results[0].success).toBe(false); + expect(effects).toEqual(expect.arrayContaining([ + { id: 'files', state: 'needs-inspection', scope: 'local' }, + { id: 'secrets', state: 'not-started', scope: 'repository' }, + { id: 'labels', state: 'not-started', scope: 'repository' }, + ])); }); it('creates default tag v1.0.0 when no version tags exist', async () => { @@ -197,6 +319,9 @@ describe('InitialSetupUseCase', () => { expect.arrayContaining([{ name: 'AGENT_PROVIDER', value: 'codex' }]), ); expect(results[0].steps).toContain('⏭️ Initial version tag creation disabled by setup configuration.'); + expect(getResultPayload(getResultPayload(results[0].payload)?.setupReceipt)?.effects) + .toEqual(expect.arrayContaining([{ id: 'initial-tag', state: 'skipped', scope: 'repository' }, + { id: 'variables', state: 'completed', scope: 'repository' }])); }); it('provisions Variables at organization scope when the configuration selects it', async () => { diff --git a/src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts b/src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts index 0daa0211b..8cfbde1ef 100644 --- a/src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts +++ b/src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts @@ -101,6 +101,7 @@ describe('setup resource provisioning policy', () => { ); expect(result.errors).toEqual([]); + expect(result.writes).toBe(2); expect(result.step).toContain('1 created, 1 updated'); expect(upsertSecrets).toHaveBeenCalledWith([ { name: 'PAT', value: 'workflow-token' }, @@ -109,14 +110,52 @@ describe('setup resource provisioning policy', () => { }); it('reports when setup secrets are enabled without validated credentials', async () => { + const upsertSecrets = jest.fn(); const result = await ensureRepositorySecrets( context, - { setupRepositorySecretsPort: { upsertSecrets: jest.fn() } }, + { setupRepositorySecretsPort: { upsertSecrets } }, createDefaultSetupConfiguration(), ); expect(result.errors).toEqual([]); + expect(result.writes).toBe(0); expect(result.step).toContain('were not changed'); + const empty = await ensureRepositorySecrets({ setupCredentials: { apiKeys: [] } }, + { setupRepositorySecretsPort: { upsertSecrets } }, createDefaultSetupConfiguration()); + expect(empty).toMatchObject({ writes: 0, errors: [], step: expect.stringContaining('kept unchanged') }); + expect(upsertSecrets).not.toHaveBeenCalled(); + }); + + it('fails closed when a managed resource has values to write but its provisioning port is absent', async () => { + const configuration = createDefaultSetupConfiguration(); + expect(await ensureRepositoryVariables(context, {}, configuration, repositorySnapshot)).toEqual({ + errors: ['GitHub Actions Variable provisioning is unavailable; no Variables were changed.'], + writes: 0, + }); + expect(await ensureRepositorySecrets({ setupCredentials: { + workflowPat: { name: 'PAT', value: 'fake-workflow-token' }, apiKeys: [], + } }, {}, configuration, repositorySnapshot)).toEqual({ + errors: ['GitHub Actions Secret provisioning is unavailable; no Secrets were changed.'], + writes: 0, + }); + configuration.manageRepositoryVariables = false; + configuration.manageRepositorySecrets = false; + expect(await ensureRepositoryVariables(context, {}, configuration, repositorySnapshot)).toEqual({ errors: [], writes: 0 }); + expect(await ensureRepositorySecrets(context, {}, configuration, repositorySnapshot)).toEqual({ errors: [], writes: 0 }); + }); + + it('returns zero writes and an unchanged explanation when all requested values are skipped', async () => { + const configuration = createDefaultSetupConfiguration(); + const variables = await ensureRepositoryVariables(context, { setupRepositoryVariablesPort: { + upsert: jest.fn().mockResolvedValue({ created: 0, updated: 0, errors: [] }), + } }, configuration, repositorySnapshot); + const secrets = await ensureRepositorySecrets({ setupCredentials: { + workflowPat: { name: 'PAT', value: 'fake-workflow-token' }, apiKeys: [], + } }, { setupRepositorySecretsPort: { + upsertSecrets: jest.fn().mockResolvedValue({ created: 0, updated: 0, skipped: 1, errors: [] }), + } }, configuration, repositorySnapshot); + expect(variables).toMatchObject({ writes: 0, errors: [], step: expect.stringContaining('kept unchanged') }); + expect(secrets).toMatchObject({ writes: 0, errors: [], step: expect.stringContaining('kept unchanged') }); }); it('uses the organization variable port when the resolved target is organizational', async () => { diff --git a/src/application/usecases/actions/initial_setup_workflow.ts b/src/application/usecases/actions/initial_setup_workflow.ts index 3e3d8dee4..9a9c77743 100644 --- a/src/application/usecases/actions/initial_setup_workflow.ts +++ b/src/application/usecases/actions/initial_setup_workflow.ts @@ -11,10 +11,12 @@ import type { BoundSetupWorkspacePort } from '../../ports/setup_workspace_ports' import { DEFAULT_INITIAL_TAG } from '../../../data/model/version_policy'; import { logDebugInfo, logError, logInfo } from '../../ports/logging_ports'; import { getTaskEmoji } from '../../../utils/task_emoji'; -import type { SetupConfiguration } from '../../../domain/setup'; +import type { SetupConfiguration, SetupOperationEffect } from '../../../domain/setup'; import type { SetupResourceProvisioningDependencies } from './setup_resource_provisioning'; import type { InitialSetupContext } from '../push_single_action_contexts'; import { + SECRET_PROVISIONING_UNAVAILABLE, + VARIABLE_PROVISIONING_UNAVAILABLE, ensureRepositorySecrets, ensureRepositoryVariables, resolveRemoteConfiguration, @@ -52,22 +54,49 @@ export async function runInitialSetupWorkflow( logInfo(`${getTaskEmoji(TASK_ID)} Executing ${TASK_ID}.`); const steps: string[] = []; const errors: ApplicationError[] = []; + const configuration = request.setupConfiguration; + const effects: SetupOperationEffect[] = [ + { id: 'files', state: 'not-started', scope: 'local' }, + { id: 'secrets', state: 'not-started', scope: resourceScope(configuration, 'secrets') }, + { id: 'labels', state: 'not-started', scope: 'repository' }, + { id: 'issue-types', state: 'not-started', scope: 'repository' }, + { id: 'variables', state: 'not-started', scope: resourceScope(configuration, 'variables') }, + { id: 'initial-tag', state: 'not-started', scope: 'repository' }, + ]; + const mark = (id: SetupOperationEffect['id'], state: SetupOperationEffect['state']) => { + const index = effects.findIndex(effect => effect.id === id); + effects[index] = { ...effects[index], state }; + }; + const receipt = () => buildResult(errors, steps, effects); try { const setupConfiguration = request.setupConfiguration; if (!dependencies.setupWorkspacePort.hasValidToken()) { logInfo(' 🛑 Setup requires the setup PAT provided for this command with a valid token.'); errors.push(new ApplicationError('authorization.credential-invalid', 'A valid setup PAT must be provided to run setup. It is separate from the workflow PAT Secret.')); - return [buildResult(errors, steps)]; + return [receipt()]; } logInfo('🔐 Checking GitHub access...'); const githubAccess = await verifyGitHubAccess(request, dependencies.authenticatedUserPort); if (!githubAccess.success) { errors.push(...githubAccess.errors); - return [buildResult(errors, steps)]; + return [receipt()]; } steps.push(`✅ GitHub access verified: ${githubAccess.user}`); + const secretValues = Number(Boolean(request.setupCredentials?.workflowPat)) + (request.setupCredentials?.apiKeys.length ?? 0); + const missingProvisioningPorts: ApplicationError[] = []; + if (setupConfiguration?.manageRepositorySecrets && secretValues > 0 && !dependencies.setupRepositorySecretsPort) { + missingProvisioningPorts.push(new ApplicationError('provider.unavailable', SECRET_PROVISIONING_UNAVAILABLE)); + } + if (setupConfiguration?.manageRepositoryVariables && !dependencies.setupRepositoryVariablesPort) { + missingProvisioningPorts.push(new ApplicationError('provider.unavailable', VARIABLE_PROVISIONING_UNAVAILABLE)); + } + if (missingProvisioningPorts.length > 0) { + errors.push(...missingProvisioningPorts); + return [receipt()]; + } + const remoteConfigurationErrors: string[] = []; const remoteConfiguration = await resolveRemoteConfiguration( request, @@ -81,7 +110,7 @@ export async function runInitialSetupWorkflow( if (remoteConfigurationErrors.length === 0) { errors.push(new ApplicationError('provider.unavailable', 'Could not inspect existing GitHub Actions resource scopes. Restore inventory access and rerun setup.')); } - return [buildResult(errors, steps)]; + return [receipt()]; } const inventoryErrors = [ ...validateSetupStorageAgainstRemote(setupConfiguration, remoteConfiguration), @@ -92,7 +121,7 @@ export async function runInitialSetupWorkflow( ]; if (inventoryErrors.length > 0) { errors.push(...fromMessages(inventoryErrors, 'provider.unavailable')); - return [buildResult(errors, steps)]; + return [receipt()]; } } @@ -105,15 +134,22 @@ export async function runInitialSetupWorkflow( approvedWorkflowFiles: request.workflowUpdates, } : {}), }; + mark('files', 'needs-inspection'); const filesResult = dependencies.setupWorkspacePort.prepare(workspaceSelection); + mark('files', filesResult.copied > 0 ? 'completed' : 'skipped'); steps.push(`✅ Setup files: ${filesResult.copied} copied, ${filesResult.skipped} already existed`); + if (setupConfiguration?.manageRepositorySecrets && secretValues > 0) mark('secrets', 'needs-inspection'); const secrets = await ensureRepositorySecrets(request, dependencies, setupConfiguration, remoteConfiguration); + mark('secrets', secrets.errors.length ? 'needs-inspection' : secrets.writes > 0 ? 'completed' : 'skipped'); if (secrets.step) steps.push(secrets.step); if (secrets.errors.length > 0) errors.push(...fromMessages(secrets.errors, 'authorization.credential-invalid')); logInfo('🏷️ Checking configured and progress labels...'); + mark('labels', 'needs-inspection'); const labels = await ensureInitialLabels(request, dependencies.initialLabelProvisioningPort, setupConfiguration); + mark('labels', !labels.completed || labels.configured.errors.length || labels.progress.errors.length + ? 'needs-inspection' : labels.configured.created + labels.progress.created > 0 ? 'completed' : 'skipped'); if (!labels.completed) { errors.push(labels.error); } else { @@ -122,26 +158,33 @@ export async function runInitialSetupWorkflow( } logInfo('📋 Checking issue types...'); + mark('issue-types', 'needs-inspection'); const issueTypes = await ensureIssueTypes(request, dependencies.issueTypeProvisioningPort, setupConfiguration); + mark('issue-types', !issueTypes.success ? 'needs-inspection' : issueTypes.created > 0 ? 'completed' : 'skipped'); if (!issueTypes.success) { errors.push(...fromMessages(issueTypes.errors, 'provider.unavailable')); } else { steps.push(`✅ Issue types checked: ${issueTypes.created} created, ${issueTypes.existing} already existed`); } + if (setupConfiguration?.manageRepositoryVariables) mark('variables', 'needs-inspection'); const variables = await ensureRepositoryVariables(request, dependencies, setupConfiguration, remoteConfiguration); + mark('variables', variables.errors.length ? 'needs-inspection' : variables.writes > 0 ? 'completed' : 'skipped'); if (variables.step) steps.push(variables.step); if (variables.errors.length > 0) errors.push(...fromMessages(variables.errors, 'provider.unavailable')); + if (setupConfiguration?.createInitialTag !== false) mark('initial-tag', 'needs-inspection'); const defaultVersion = await ensureDefaultVersion(request, dependencies, setupConfiguration); + mark('initial-tag', defaultVersion.error ? 'needs-inspection' + : defaultVersion.step?.includes('created on branch') ? 'completed' : 'skipped'); if (defaultVersion.step) steps.push(defaultVersion.step); if (defaultVersion.error) errors.push(defaultVersion.error); - return [buildResult(errors, steps)]; + return [receipt()]; } catch (error) { const semanticError = toApplicationError(error, 'workflow.failed', 'Error running initial setup.'); logError(semanticError); errors.push(semanticError); - return [buildResult(errors, steps)]; + return [receipt()]; } } @@ -249,16 +292,23 @@ function appendLabelSummary( } } -function buildResult(errors: ApplicationError[], steps: string[]): Result { +function buildResult(errors: ApplicationError[], steps: string[], effects: readonly SetupOperationEffect[]): Result { return new Result({ id: TASK_ID, success: errors.length === 0, executed: true, steps, + payload: { setupReceipt: { version: 1, effects: effects.map(effect => ({ ...effect })) } }, errors: errors.length > 0 ? errors : undefined, }); } +function resourceScope(configuration: SetupConfiguration | undefined, kind: 'secrets' | 'variables'): SetupOperationEffect['scope'] { + const policy = configuration?.storage[kind]; + if (!policy) return 'repository'; + return Object.values(policy.overrides).some(scope => scope !== policy.defaultScope) ? 'mixed' : policy.defaultScope; +} + function fromMessages(messages: readonly string[], code: ApplicationErrorCode): ApplicationError[] { return messages.map(message => new ApplicationError(code, message)); } diff --git a/src/application/usecases/actions/setup_resource_provisioning.ts b/src/application/usecases/actions/setup_resource_provisioning.ts index b3a6d346b..e9d36a798 100644 --- a/src/application/usecases/actions/setup_resource_provisioning.ts +++ b/src/application/usecases/actions/setup_resource_provisioning.ts @@ -34,24 +34,35 @@ export interface SetupRepositoryContext { export type SetupResource = { name: string; value: string }; export type SetupResourceGroup = { target: SetupResourceTarget; resources: SetupResource[] }; +export type SetupResourceProvisioningOutcome = { step?: string; errors: string[]; writes: number }; + +export const VARIABLE_PROVISIONING_UNAVAILABLE = 'GitHub Actions Variable provisioning is unavailable; no Variables were changed.'; +export const SECRET_PROVISIONING_UNAVAILABLE = 'GitHub Actions Secret provisioning is unavailable; no Secrets were changed.'; export async function ensureRepositoryVariables( context: SetupRepositoryContext, dependencies: SetupResourceProvisioningDependencies, setupConfiguration?: SetupConfiguration, remoteConfiguration?: SetupRemoteConfiguration, -): Promise<{ step?: string; errors: string[] }> { - if (!setupConfiguration?.manageRepositoryVariables || !dependencies.setupRepositoryVariablesPort) { - return { errors: [] }; +): Promise { + if (!setupConfiguration?.manageRepositoryVariables) { + return { errors: [], writes: 0 }; + } + if (!dependencies.setupRepositoryVariablesPort) { + return { errors: [VARIABLE_PROVISIONING_UNAVAILABLE], writes: 0 }; } try { const desired = buildSetupRepositoryVariables(setupConfiguration); const groups = groupSetupResources(desired, 'variable', setupConfiguration, remoteConfiguration); const result = await upsertVariableGroups(context, dependencies.setupRepositoryVariablesPort, groups); - if (result.errors.length > 0) return { errors: result.errors }; + const writes = result.created + result.updated; + if (result.errors.length > 0) return { errors: result.errors, writes }; return { - step: `✅ GitHub Actions Variables: ${result.created} created, ${result.updated} updated; existing effective values preserved when no override was selected.`, + step: writes > 0 + ? `✅ GitHub Actions Variables: ${result.created} created, ${result.updated} updated; existing effective values preserved when no override was selected.` + : '✅ GitHub Actions Variables kept unchanged; no values were created or updated.', errors: [], + writes, }; } catch (error) { const semanticError = toApplicationError( @@ -60,7 +71,7 @@ export async function ensureRepositoryVariables( 'Unable to configure GitHub Actions Variables.', ); logError(semanticError); - return { errors: [semanticError.message] }; + return { errors: [semanticError.message], writes: 0 }; } } @@ -69,26 +80,33 @@ export async function ensureRepositorySecrets( dependencies: SetupResourceProvisioningDependencies, setupConfiguration?: SetupConfiguration, remoteConfiguration?: SetupRemoteConfiguration, -): Promise<{ step?: string; errors: string[] }> { - if (!setupConfiguration?.manageRepositorySecrets || !dependencies.setupRepositorySecretsPort) { - return { errors: [] }; +): Promise { + if (!setupConfiguration?.manageRepositorySecrets) { + return { errors: [], writes: 0 }; } const credentials = context.setupCredentials; if (!credentials) { - return { step: '⚠️ Repository Secrets were not changed: run interactive setup to validate and provide credentials.', errors: [] }; + return { step: '⚠️ Repository Secrets were not changed: run interactive setup to validate and provide credentials.', errors: [], writes: 0 }; } const values = [ ...(credentials.workflowPat ? [credentials.workflowPat] : []), ...credentials.apiKeys, ]; - if (values.length === 0) return { step: '✅ Existing Repository Secrets kept unchanged.', errors: [] }; + if (values.length === 0) return { step: '✅ Existing Repository Secrets kept unchanged.', errors: [], writes: 0 }; + if (!dependencies.setupRepositorySecretsPort) { + return { errors: [SECRET_PROVISIONING_UNAVAILABLE], writes: 0 }; + } try { const groups = groupSetupResources(values, 'secret', setupConfiguration, remoteConfiguration); const result = await upsertSecretGroups(context, dependencies.setupRepositorySecretsPort, groups); - if (result.errors.length > 0) return { errors: result.errors }; + const writes = result.created + result.updated; + if (result.errors.length > 0) return { errors: result.errors, writes }; return { - step: `✅ GitHub Actions Secrets: ${result.created} created, ${result.updated} updated; existing effective values kept when no replacement was selected.`, + step: writes > 0 + ? `✅ GitHub Actions Secrets: ${result.created} created, ${result.updated} updated; existing effective values kept when no replacement was selected.` + : '✅ Existing GitHub Actions Secrets kept unchanged; no values were created or updated.', errors: [], + writes, }; } catch (error) { const semanticError = toApplicationError( @@ -97,7 +115,7 @@ export async function ensureRepositorySecrets( 'Unable to configure GitHub Actions Secrets.', ); logError(semanticError); - return { errors: [semanticError.message] }; + return { errors: [semanticError.message], writes: 0 }; } } diff --git a/src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts b/src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts new file mode 100644 index 000000000..3ea6c3d4f --- /dev/null +++ b/src/application/usecases/setup/__tests__/audit_configured_setup_pat_use_case.test.ts @@ -0,0 +1,132 @@ +import { createDefaultSetupConfiguration } from '../../../policies/setup_configuration_defaults'; +import { buildSetupPatPermissionRequirements } from '../../../policies/setup_token_permission_policy'; +import type { SetupRemoteConfiguration } from '../../../../domain/setup'; +import type { SetupTokenPermissionReport } from '../../../../domain/setup_token_permissions'; +import { AuditConfiguredSetupPatUseCase, type AuditConfiguredSetupPatPorts } from '../audit_configured_setup_pat_use_case'; + +const remote: SetupRemoteConfiguration = { + ownerType: 'Organization', repositoryId: 42, repositoryVisibility: 'private', + repositorySecrets: [], repositorySecretsAccess: 'available', organizationSecrets: [], + repositoryVariables: [], repositoryVariablesAccess: 'available', organizationVariables: [], + organizationAccess: 'available', organizationSecretsAccess: 'available', organizationVariablesAccess: 'available', +}; +const report: SetupTokenPermissionReport = { + role: 'setup', account: 'operator', identityStatus: 'valid', identityMessage: 'valid', + checks: [], ready: true, confirmationRequired: false, +}; + +function harness(options: { token?: string; guided?: boolean; assertedOwnerKind?: 'Organization' | 'User' } = {}) { + const provisionalRequirements = buildSetupPatPermissionRequirements(); + const ports: AuditConfiguredSetupPatPorts = { + permissions: { inspect: jest.fn(async () => report) }, + presenter: { showRequirements: jest.fn(), showReport: jest.fn() }, + confirmUnverifiable: jest.fn(async () => true), + showOwnerMismatch: jest.fn(), showExcessGrants: jest.fn(), showUpdatedLink: jest.fn(), + }; + const context = { + owner: 'owner', repository: 'repo', provisionalRequirements, + token: options.token, guided: options.guided ?? false, assertedOwnerKind: options.assertedOwnerKind, + }; + return { context, ports, useCase: new AuditConfiguredSetupPatUseCase(context, ports) }; +} + +describe('AuditConfiguredSetupPatUseCase', () => { + const configuration = createDefaultSetupConfiguration(); + + test('reports final grants and audits the supplied PAT without mutations', async () => { + const { ports, useCase } = harness({ token: 'test-token' }); + expect(await useCase.audit(configuration, remote)).toEqual({ status: 'accepted' }); + expect(ports.permissions.inspect).toHaveBeenCalledWith(expect.objectContaining({ + role: 'setup', owner: 'owner', repository: 'repo', token: 'test-token', requirements: expect.any(Array), + })); + expect(ports.presenter.showRequirements).toHaveBeenCalledWith('setup', expect.any(Array)); + expect(ports.presenter.showReport).toHaveBeenCalledWith(report); + expect(ports.confirmUnverifiable).not.toHaveBeenCalled(); + }); + + test('preview without a PAT shows requirements but never probes permissions', async () => { + const { ports, useCase } = harness(); + expect(await useCase.audit(configuration, remote)).toEqual({ status: 'accepted' }); + expect(ports.permissions.inspect).not.toHaveBeenCalled(); + expect(ports.presenter.showRequirements).toHaveBeenCalled(); + }); + + test('owner mismatch blocks before probing and offers a corrected link', async () => { + const { ports, useCase } = harness({ token: 'test-token', guided: true, assertedOwnerKind: 'User' }); + expect(await useCase.audit(configuration, remote)).toEqual(expect.objectContaining({ status: 'blocked' })); + expect(ports.showOwnerMismatch).toHaveBeenCalledWith('User', 'Organization'); + expect(ports.showUpdatedLink).toHaveBeenCalledWith(expect.stringContaining('target_name=owner'), expect.any(Array)); + expect(ports.permissions.inspect).not.toHaveBeenCalled(); + }); + + test.each(['Organization', 'User'] as const)('unknown owner type blocks token-backed audit despite %s assertion', async assertedOwnerKind => { + const { ports, useCase } = harness({ token: 'test-token', guided: true, assertedOwnerKind }); + const result = await useCase.audit(configuration, { ...remote, ownerType: 'Unknown' }); + expect(result).toEqual({ status: 'blocked', errors: [expect.stringContaining('could not verify')] }); + expect(ports.showOwnerMismatch).not.toHaveBeenCalled(); + expect(ports.showUpdatedLink).not.toHaveBeenCalled(); + expect(ports.permissions.inspect).not.toHaveBeenCalled(); + expect(ports.presenter.showRequirements).toHaveBeenCalledWith('setup', expect.arrayContaining([ + expect.objectContaining({ scope: 'organization', permission: 'Issue Types' }), + ])); + }); + + test('unavailable owner inspection blocks a token-backed audit before permission probes', async () => { + const { ports, useCase } = harness({ token: 'test-token' }); + expect(await useCase.audit(configuration)).toEqual({ + status: 'blocked', errors: [expect.stringContaining('could not verify')], + }); + expect(ports.permissions.inspect).not.toHaveBeenCalled(); + }); + + test('dry-run preview without a token may show unknown owner grants without authorizing mutations', async () => { + const { ports, useCase } = harness({ assertedOwnerKind: 'User' }); + expect(await useCase.audit(configuration, { ...remote, ownerType: 'Unknown' })).toEqual({ status: 'accepted' }); + expect(ports.permissions.inspect).not.toHaveBeenCalled(); + expect(ports.showOwnerMismatch).not.toHaveBeenCalled(); + }); + + test('guided review explains grants removed from the provisional link', async () => { + const { context, ports, useCase } = harness({ guided: true }); + const secrets = context.provisionalRequirements.find(item => item.permission === 'Secrets' && item.scope === 'repository')!; + context.provisionalRequirements = [...context.provisionalRequirements, { ...secrets, applicability: 'required' }]; + const minimal = createDefaultSetupConfiguration(); + minimal.manageRepositorySecrets = false; + minimal.manageRepositoryVariables = false; + minimal.issueWorkflows.enabled = []; + minimal.createInitialTag = false; + for (const feature of Object.keys(minimal.features)) minimal.features[feature] = false; + minimal.pullRequestApproval = { ...minimal.pullRequestApproval, mode: 'off' }; + expect(await useCase.audit(minimal, remote)).toEqual({ status: 'accepted' }); + expect(ports.showExcessGrants).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining('Secrets')])); + }); + + test('unverifiable writes require explicit confirmation', async () => { + const { ports, useCase } = harness({ token: 'test-token' }); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, ready: false, confirmationRequired: true }); + expect(await useCase.audit(configuration, remote)).toEqual({ status: 'accepted' }); + expect(ports.confirmUnverifiable).toHaveBeenCalledTimes(1); + }); + + test('declined unverifiable writes block the final plan', async () => { + const { ports, useCase } = harness({ token: 'test-token', guided: true }); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, ready: false, confirmationRequired: true }); + jest.spyOn(ports, 'confirmUnverifiable').mockResolvedValue(false); + expect(await useCase.audit(configuration, remote)).toEqual(expect.objectContaining({ status: 'blocked' })); + expect(ports.showUpdatedLink).toHaveBeenCalledTimes(1); + }); + + test.each(['invalid', 'unverifiable'] as const)('%s PAT identity blocks even when checks are otherwise ready', async identityStatus => { + const { ports, useCase } = harness({ token: 'test-token', guided: false }); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, identityStatus }); + expect(await useCase.audit(configuration, remote)).toEqual(expect.objectContaining({ status: 'blocked' })); + expect(ports.showUpdatedLink).not.toHaveBeenCalled(); + }); + + test('missing required access blocks and offers a new guided link', async () => { + const { ports, useCase } = harness({ token: 'test-token', guided: true }); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, ready: false }); + expect(await useCase.audit(configuration, remote)).toEqual(expect.objectContaining({ status: 'blocked' })); + expect(ports.showUpdatedLink).toHaveBeenCalledWith(expect.stringContaining('https://github.com/settings/personal-access-tokens/new?'), expect.any(Array)); + }); +}); diff --git a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts index 64a5f658f..85d5add2f 100644 --- a/src/application/usecases/setup/__tests__/doctor_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/doctor_use_case.test.ts @@ -86,6 +86,17 @@ describe('SetupDoctorUseCase', () => { expect(report.totals.fail).toBe(0); }); + it('read-only mode never dispatches credential-health Actions and marks Secret values unverified', async () => { + const configuration = createDefaultSetupConfiguration(); + const deps = dependencies(configuration); + const { report } = await new SetupDoctorUseCase(deps).execute({ ...request(configuration), readOnly: true }); + expect(deps.remoteHealth.validateExisting).not.toHaveBeenCalled(); + expect(deps.remoteConfiguration.inspect).toHaveBeenCalledTimes(1); + expect(report.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'credential.pat', status: 'warn', evidence: expect.objectContaining({ present: true }) }), + ])); + }); + it('uses repository locale for the whole report and reuses its catalog in merge readiness', async () => { const configuration = createDefaultSetupConfiguration(); configuration.repository.repositoryLocale = 'es-ES'; diff --git a/src/application/usecases/setup/__tests__/prepare_setup_pat_intent_use_case.test.ts b/src/application/usecases/setup/__tests__/prepare_setup_pat_intent_use_case.test.ts new file mode 100644 index 000000000..a331b028d --- /dev/null +++ b/src/application/usecases/setup/__tests__/prepare_setup_pat_intent_use_case.test.ts @@ -0,0 +1,184 @@ +import type { SetupQuestionnaireState } from '../../../../domain/setup_questionnaire'; +import { SetupInteractionCancelledError } from '../../../errors/setup_interaction_cancelled_error'; +import { UnsupportedSetupPatLinkError } from '../../../policies/setup_pat_creation_url_policy'; +import * as linkPolicy from '../../../policies/setup_pat_creation_url_policy'; +import { + PrepareSetupPatIntentUseCase, type PrepareSetupPatIntentPorts, type PrepareSetupPatIntentRequest, +} from '../prepare_setup_pat_intent_use_case'; + +const request: PrepareSetupPatIntentRequest = { + owner: 'owner', repository: 'repo', overrides: {}, skipRepositoryVariables: false, skipRepositorySecrets: false, +}; + +function harness() { + const ports: PrepareSetupPatIntentPorts = { + collect: jest.fn(async (initial: SetupQuestionnaireState) => ({ + ...initial, stateId: 'review' as const, question: undefined, + terminal: 'review' as const, answeredQuestionIds: ['features.issues'], + })), + chooseOwnerKind: jest.fn(async () => 'Organization' as const), + review: jest.fn(async () => 'continue' as const), + showPreview: jest.fn(), showDetails: jest.fn(), onManual: jest.fn(), + advanceToSetupPat: jest.fn(), revisitChoices: jest.fn(() => 2), + }; + return { ports, useCase: new PrepareSetupPatIntentUseCase(ports) }; +} + +describe('PrepareSetupPatIntentUseCase', () => { + test('guides a scoped PAT from the reviewed draft and deduplicates answered/fixed questions', async () => { + const { ports, useCase } = harness(); + const result = await useCase.execute({ ...request, overrides: { features: { issues: true } } }); + expect(result.kind).toBe('guided'); + if (result.kind !== 'guided') return; + expect(result.url).toContain('https://github.com/settings/personal-access-tokens/new?'); + expect(result.url).toContain('target_name=owner'); + expect(result.permissionIntent.answeredQuestionIds.filter(id => id === 'features.issues')).toHaveLength(1); + expect(ports.advanceToSetupPat).toHaveBeenCalledTimes(1); + expect(ports.showPreview).toHaveBeenCalledWith(expect.objectContaining({ pass: 1, requirements: result.requirements })); + }); + + test('passes explicit release/hotfix constraints to the intent questionnaire and retains their fixed provenance', async () => { + const { ports, useCase } = harness(); + const result = await useCase.execute({ ...request, overrides: { features: { release: true, hotfix: false } } }); + expect(ports.collect).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ + fixedWorkflowFeatures: { release: true, hotfix: false }, + skipQuestionIds: expect.arrayContaining(['features.release', 'features.hotfix']), + }), 1); + expect(result.kind).toBe('guided'); + if (result.kind === 'guided') expect(result.permissionIntent.answeredQuestionIds) + .toEqual(expect.arrayContaining(['features.release', 'features.hotfix'])); + }); + + test('review details is non-terminal and uses the same provisional grants', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'review').mockResolvedValueOnce('details').mockResolvedValueOnce('continue'); + const result = await useCase.execute(request); + expect(result.kind).toBe('guided'); + expect(ports.review).toHaveBeenCalledTimes(2); + expect(ports.showDetails).toHaveBeenCalledTimes(1); + expect(ports.showDetails).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ role: 'setup' })])); + }); + + test('previews Projects read from intent before any Project number can be discovered', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'collect').mockImplementation(async initial => ({ + ...initial, terminal: 'review', question: undefined, projectsWanted: true, + draft: { ...initial.draft, projects: { ...initial.draft.projects, ids: '' } }, + })); + const result = await useCase.execute(request); + expect(result.kind).toBe('guided'); + if (result.kind !== 'guided') return; + expect(result.url).toContain('organization_projects=read'); + expect(result.permissionIntent.projectsWanted).toBe(true); + expect(result.permissionIntent.draft.projects.ids).toBe(''); + }); + + test('explicitly opting out of Projects on review clears earlier IDs and removes the organization grant', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'review').mockResolvedValueOnce('revise').mockResolvedValueOnce('continue'); + jest.spyOn(ports, 'chooseOwnerKind').mockResolvedValue('User'); + jest.spyOn(ports, 'collect') + .mockImplementationOnce(async initial => ({ ...initial, terminal: 'review', question: undefined, + projectsWanted: true, draft: { ...initial.draft, projects: { ...initial.draft.projects, ids: '42' } } })) + .mockImplementationOnce(async initial => ({ ...initial, terminal: 'review', question: undefined, + projectsWanted: false })); + const result = await useCase.execute(request); + expect(result.kind).toBe('guided'); + if (result.kind !== 'guided') return; + expect(result.permissionIntent.projectsWanted).toBe(false); + expect(result.permissionIntent.draft.projects.ids).toBe(''); + expect(result.url).not.toContain('organization_projects='); + expect(ports.showPreview).toHaveBeenLastCalledWith(expect.objectContaining({ + projectsWanted: false, ownerConflict: false, draft: expect.objectContaining({ projects: expect.objectContaining({ ids: '' }) }), + })); + }); + + test('a fixed Project ID override still requests the organization read grant', async () => { + const { useCase } = harness(); + const result = await useCase.execute({ ...request, overrides: { projects: { ids: '42' } } }); + expect(result.kind).toBe('guided'); + if (result.kind !== 'guided') return; + expect(result.permissionIntent.projectsWanted).toBe(true); + expect(result.permissionIntent.draft.projects.ids).toBe('42'); + expect(result.url).toContain('organization_projects=read'); + }); + + test('revisiting choices re-collects with the next pass and retains one session', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'review').mockResolvedValueOnce('revise').mockResolvedValueOnce('continue'); + const result = await useCase.execute(request); + expect(result.kind).toBe('guided'); + expect(ports.collect).toHaveBeenCalledTimes(2); + expect(ports.collect).toHaveBeenNthCalledWith(2, expect.any(Object), expect.any(Object), 2); + expect(ports.revisitChoices).toHaveBeenCalledTimes(1); + expect(ports.advanceToSetupPat).toHaveBeenCalledTimes(2); + }); + + test('manual choice avoids link creation and keeps baseline table available', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'review').mockResolvedValue('manual'); + expect(await useCase.execute(request)).toEqual({ kind: 'manual' }); + expect(ports.onManual).toHaveBeenCalledWith('chosen'); + }); + + test('unknown owner kind falls back before a misleading organization link', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'chooseOwnerKind').mockResolvedValue('unknown'); + expect(await useCase.execute(request)).toEqual({ kind: 'manual' }); + expect(ports.onManual).toHaveBeenCalledWith('owner-unknown'); + expect(ports.showPreview).not.toHaveBeenCalled(); + }); + + test('user owner conflicts with organization Projects and cannot continue', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'chooseOwnerKind').mockResolvedValue('User'); + const collect = jest.spyOn(ports, 'collect'); + collect.mockImplementation(async initial => ({ + ...initial, terminal: 'review', question: undefined, projectsWanted: true, + draft: { ...initial.draft, projects: { ...initial.draft.projects, ids: '42' } }, + })); + await expect(useCase.execute(request)).rejects.toThrow('Correct the reported setup intent'); + expect(ports.showPreview).toHaveBeenCalledWith(expect.objectContaining({ ownerConflict: true })); + }); + + test('invalid reviewed configuration blocks the guided link', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'collect').mockImplementation(async initial => ({ + ...initial, terminal: 'review', question: undefined, + draft: { ...initial.draft, repository: { ...initial.draft.repository, mainBranch: '' } }, + })); + await expect(useCase.execute(request)).rejects.toThrow('Correct the reported setup intent'); + expect(ports.showPreview).toHaveBeenCalledWith(expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('main branch')]) })); + }); + + test('unsupported GitHub form grant falls back to manual without broadening access', async () => { + const { ports, useCase } = harness(); + const link = jest.spyOn(linkPolicy, 'buildSetupPatCreationUrl').mockImplementation(() => { + throw new UnsupportedSetupPatLinkError(['repository Unsupported write']); + }); + try { + expect(await useCase.execute(request)).toEqual({ kind: 'manual' }); + expect(ports.onManual).toHaveBeenCalledWith('unsupported'); + } finally { + link.mockRestore(); + } + }); + + test('unexpected link errors propagate instead of silently using a fallback', async () => { + const { ports, useCase } = harness(); + const link = jest.spyOn(linkPolicy, 'buildSetupPatCreationUrl').mockImplementation(() => { throw new Error('unexpected'); }); + try { + await expect(useCase.execute(request)).rejects.toThrow('unexpected'); + expect(ports.onManual).not.toHaveBeenCalled(); + } finally { + link.mockRestore(); + } + }); + + test('questionnaire cancellation is a shared cancellation outcome', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'collect').mockImplementation(async initial => ({ ...initial, terminal: 'cancelled' })); + await expect(useCase.execute(request)).rejects.toThrow(SetupInteractionCancelledError); + expect(ports.chooseOwnerKind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts b/src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts new file mode 100644 index 000000000..acc8d5cdc --- /dev/null +++ b/src/application/usecases/setup/__tests__/setup_journey_use_case.test.ts @@ -0,0 +1,108 @@ +import { SetupJourneyUseCase } from '../setup_journey_use_case'; + +describe('setup journey', () => { + it('keeps ordered milestones and does not imply changes before apply', () => { + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('choices'); + journey.advance('setup-pat'); + journey.advance('plan'); + expect(present.mock.calls.map(([view]) => view.current)).toEqual(['Setup choices', 'Setup PAT', 'Plan']); + expect(present.mock.lastCall?.[0]).toMatchObject({ + complete: ['Repository', 'Setup choices', 'Setup PAT'], + pending: ['Bot PAT & credentials', 'Apply'], mutationStarted: false, + }); + }); + + it('rejects backwards progress and false completion', () => { + const journey = new SetupJourneyUseCase('owner/repo', { present: jest.fn() }); + journey.advance('plan'); + expect(() => journey.advance('choices')).toThrow('backwards'); + expect(() => journey.finish('complete')).toThrow('before applying'); + expect(() => journey.finish('partial')).toThrow('before mutation'); + expect(() => journey.markMutationStarted()).toThrow('credential validation or apply'); + }); + + it('distinguishes dry-run, blocked, cancelled, and post-mutation partial state', () => { + for (const outcome of ['dry-run', 'blocked', 'cancelled'] as const) { + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('plan'); + journey.finish(outcome); + expect(present.mock.lastCall?.[0]).toMatchObject({ outcome, mutationStarted: false }); + expect(() => journey.advance('apply')).toThrow('finished'); + } + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('apply'); + journey.markMutationStarted(); + journey.finish('partial'); + expect(present.mock.lastCall?.[0]).toMatchObject({ outcome: 'partial', mutationStarted: true }); + }); + + it('records a possible temporary workflow mutation during credential validation and allows a partial result', () => { + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('credentials'); + journey.markMutationStarted(); + journey.markMutationStarted(); + expect(present.mock.lastCall?.[0]).toMatchObject({ current: 'Bot PAT & credentials', mutationStarted: true }); + journey.finish('partial'); + expect(present.mock.lastCall?.[0]).toMatchObject({ outcome: 'partial', mutationStarted: true }); + expect(() => journey.advance('apply')).toThrow('finished'); + }); + + it('reports completion only after mutation starts and ignores duplicate finish', () => { + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('apply'); + journey.markMutationStarted(); + expect(present.mock.lastCall?.[0].mutationStarted).toBe(true); + expect(present.mock.lastCall?.[0].outcome).toBeUndefined(); + journey.finish('complete'); + journey.finish('blocked'); + expect(present.mock.lastCall?.[0].outcome).toBe('complete'); + }); + + it('ignores repeated advances', () => { + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('choices'); + journey.advance('choices'); + expect(present).toHaveBeenCalledTimes(1); + expect(present.mock.lastCall?.[0]).toMatchObject({ current: 'Setup choices', complete: ['Repository'] }); + }); + + it('reopens only pre-PAT choices and returns to PAT review without resetting the run', () => { + const present = jest.fn(); + const journey = new SetupJourneyUseCase('owner/repo', { present }); + journey.advance('choices'); + journey.advance('setup-pat'); + expect(journey.revisitChoices()).toBe(2); + expect(present.mock.lastCall?.[0]).toMatchObject({ + current: 'Setup choices', choiceReviewPass: 2, complete: ['Repository'], + pending: ['Setup PAT', 'Plan', 'Bot PAT & credentials', 'Apply'], mutationStarted: false, + }); + journey.advance('setup-pat'); + expect(present.mock.lastCall?.[0]).toMatchObject({ current: 'Setup PAT', choiceReviewPass: 2 }); + expect(journey.revisitChoices()).toBe(3); + }); + + it('rejects a review loop outside pre-PAT review or after cancellation', () => { + const journey = new SetupJourneyUseCase('owner/repo', { present: jest.fn() }); + expect(() => journey.revisitChoices()).toThrow('pre-PAT'); + journey.advance('setup-pat'); + journey.finish('cancelled'); + expect(() => journey.revisitChoices()).toThrow('pre-PAT'); + const later = new SetupJourneyUseCase('owner/repo', { present: jest.fn() }); + later.advance('plan'); + expect(() => later.revisitChoices()).toThrow('pre-PAT'); + }); + + it('cannot reopen choices once application has begun', () => { + const journey = new SetupJourneyUseCase('owner/repo', { present: jest.fn() }); + journey.advance('apply'); + journey.markMutationStarted(); + expect(() => journey.revisitChoices()).toThrow('pre-PAT'); + }); +}); diff --git a/src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts b/src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts index 25ff9fb3c..7ab7e9d3c 100644 --- a/src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts +++ b/src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts @@ -1,5 +1,5 @@ import { SetupQuestionnaireController } from '../setup_questionnaire_controller'; -import { createSetupQuestionnaire } from '../../../policies/setup_questionnaire_policy'; +import { createSetupQuestionnaire, setupQuestionContentInventory } from '../../../policies/setup_questionnaire_policy'; import { createDefaultSetupConfiguration } from '../../../policies/setup_configuration_policy'; import type { SetupQuestionRenderer, TerminalDriver, TerminalReadResult } from '../../../ports/setup_terminal_ports'; @@ -8,6 +8,8 @@ function renderer(): jest.Mocked { showIntroduction: jest.fn(), showState: jest.fn(), renderPrompt: jest.fn((question) => `${question.id}: `), + renderHelp: jest.fn((question) => `Help for ${question.id}`), + showHelp: jest.fn(), showValidation: jest.fn(), showCancelled: jest.fn(), }; @@ -58,6 +60,28 @@ describe('SetupQuestionnaireController', () => { expect(input.readText.mock.calls[0][0]).toBe(input.readText.mock.calls[1][0]); }); + it('shows question help and repeats the same unanswered question without mutating the draft', async () => { + const output = renderer(); + const input = terminal([{ kind: 'value', value: '?' }, { kind: 'value', value: '' }]); + const initial = createSetupQuestionnaire(createDefaultSetupConfiguration()); + const result = await new SetupQuestionnaireController(input, output).collect(initial, {}); + expect(result.terminal).toBe('review'); + expect(output.showHelp).toHaveBeenCalledWith(initial.question); + expect(input.readText.mock.calls[0][0]).toBe(input.readText.mock.calls[1][0]); + expect(initial.answeredQuestionIds).toBeUndefined(); + }); + + it('explains :back at the first CLI question without losing the current draft', async () => { + const output = renderer(); + const input = terminal([{ kind: 'value', value: ':back' }, { kind: 'value', value: '' }]); + const result = await new SetupQuestionnaireController(input, output).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration()), {}, + ); + expect(result.terminal).toBe('review'); + expect(output.showValidation).toHaveBeenCalledWith('This is the first question in this pass. Review it or cancel setup.'); + expect(input.readText.mock.calls[0][0]).toBe(input.readText.mock.calls[1][0]); + }); + it.each([ ['Ctrl-C', { kind: 'cancel' } as const], ['EOF', { kind: 'end-of-input' } as const], @@ -83,4 +107,155 @@ describe('SetupQuestionnaireController', () => { )).rejects.toThrow('requires an interactive terminal'); expect(input.readText).not.toHaveBeenCalled(); }); + + it('retries Project discovery in the CLI without advancing or replaying prior questions', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', projectDiscovery: { status: 'unavailable' as const, candidates: [] }, + discoveryRetryRemaining: { checks: 0, projects: 1 } }; + const initial = createSetupQuestionnaire(createDefaultSetupConfiguration(), context); + expect(initial.question?.id).toBe('projects.ids'); + const input = { ...terminal([]), readMultiSelect: jest.fn() + .mockResolvedValueOnce({ kind: 'value', value: 'retry' }) + .mockResolvedValueOnce({ kind: 'value', value: 'none' }) }; + const refresh = jest.fn(async () => ({ ...context, + projectDiscovery: { status: 'observed' as const, candidates: [{ number: 5, title: 'Roadmap', owner: 'owner', + url: 'https://github.com/orgs/owner/projects/5' }] }, + discoveryRetryRemaining: { checks: 0, projects: 0 } })); + const result = await new SetupQuestionnaireController(input, renderer()).collect(initial, context, { refresh }); + expect(result.terminal).toBe('review'); + expect(result.draft.projects.ids).toBe(''); + expect(refresh).toHaveBeenCalledWith('projects'); + expect(input.readMultiSelect).toHaveBeenCalledTimes(2); + expect(input.readMultiSelect.mock.calls[1][1]).toEqual(expect.arrayContaining([expect.stringContaining('Roadmap')])); + }); + + it('removes terminal controls and bidirectional overrides from discovered Project choices', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', projectDiscovery: { status: 'observed' as const, + candidates: [{ number: 5, title: 'Roadmap\u001b[2J\nFake\u202e', owner: 'owner', + url: 'https://github.com/orgs/owner/projects/5\u001b[1m' }] } }; + const input = { ...terminal([]), readMultiSelect: jest.fn().mockResolvedValue({ kind: 'value', value: 'none' }) }; + await new SetupQuestionnaireController(input, renderer()).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration(), context), context, + ); + const choices = input.readMultiSelect.mock.calls[0][1] as string[]; + expect(choices[0]).toContain('5 — Roadmap[2JFake'); + expect(choices[0]).not.toMatch(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u); + expect(choices[0]).toContain('https://github.com/orgs/owner/projects/5[1m'); + }); + + it('lists discovered Projects and supports retry when the terminal has no selector method', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', projectDiscovery: { status: 'observed' as const, + candidates: [{ number: 5, title: 'Roadmap', owner: 'owner', url: 'https://github.com/orgs/owner/projects/5' }] }, + discoveryRetryRemaining: { checks: 0, projects: 1 } }; + const input = terminal([{ kind: 'value', value: '5, ReTrY' }, { kind: 'value', value: '6' }]); + const refresh = jest.fn(async () => ({ ...context, + projectDiscovery: { status: 'observed' as const, + candidates: [{ number: 6, title: 'Planning', owner: 'owner', url: 'https://github.com/orgs/owner/projects/6' }] }, + discoveryRetryRemaining: { checks: 0, projects: 0 } })); + const result = await new SetupQuestionnaireController(input, renderer()).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration(), context), context, { refresh }, + ); + expect(result.terminal).toBe('review'); + expect(result.draft.projects.ids).toBe('6'); + expect(input.readText.mock.calls[0][0]).toContain('5 — Roadmap (https://github.com/orgs/owner/projects/5)'); + expect(input.readText.mock.calls[0][0]).toContain('retry — Retry GitHub Project discovery'); + expect(input.readText.mock.calls[1][0]).toContain('6 — Planning'); + expect(input.readText.mock.calls[1][0]).toContain('Current selection: 5'); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('keeps selected Project numbers on empty Enter in the text fallback', async () => { + const defaults = createDefaultSetupConfiguration(); + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', projectDiscovery: { status: 'observed' as const, + candidates: [{ number: 5, title: 'Roadmap', owner: 'owner', url: 'https://github.com/orgs/owner/projects/5' }] } }; + const input = terminal([{ kind: 'value', value: '' }]); + const result = await new SetupQuestionnaireController(input, renderer()).collect( + createSetupQuestionnaire({ ...defaults, projects: { ...defaults.projects, ids: '5' } }, context), context, + ); + expect(result.terminal).toBe('review'); + expect(result.draft.projects.ids).toBe('5'); + expect(input.readText.mock.calls[0][0]).toContain('Current selection: 5'); + }); + + it('combines selected Projects with manually entered URLs or numbers', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', projectDiscovery: { status: 'observed' as const, + candidates: [{ number: 5, title: 'Roadmap', owner: 'owner', url: 'https://github.com/orgs/owner/projects/5' }] } }; + const input = { ...terminal([{ kind: 'value', value: 'https://github.com/orgs/owner/projects/7' }]), + readMultiSelect: jest.fn().mockResolvedValue({ kind: 'value', value: '5, MANUAL' }) }; + const result = await new SetupQuestionnaireController(input, renderer()).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration(), context), context, + ); + expect(result.terminal).toBe('review'); + expect(result.draft.projects.ids).toBe('5,7'); + expect(input.readText).toHaveBeenCalledWith(expect.stringContaining('Enter additional Project numbers')); + }); + + it('cancels safely if manual Project entry reaches end of input', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner' }; + const input = { ...terminal([{ kind: 'end-of-input' }]), + readMultiSelect: jest.fn().mockResolvedValue({ kind: 'value', value: 'manual' }) }; + const output = renderer(); + const result = await new SetupQuestionnaireController(input, output).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration(), context), context, + ); + expect(result.terminal).toBe('cancelled'); + expect(output.showCancelled).toHaveBeenCalledTimes(1); + }); + + it('treats an empty manual Project entry as selecting none', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner' }; + const input = { ...terminal([{ kind: 'value', value: '' }]), + readMultiSelect: jest.fn().mockResolvedValue({ kind: 'value', value: 'manual' }) }; + const result = await new SetupQuestionnaireController(input, renderer()).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration(), context), context, + ); + expect(result.terminal).toBe('review'); + expect(result.draft.projects.ids).toBe(''); + }); + + it('keeps a CI check question open if a read-only discovery retry returns no new context', async () => { + const configuration = createDefaultSetupConfiguration(); + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'recommend' }; + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id) + .filter(id => id !== 'pullRequestApproval.testChecks'), + approvalCheckDiscoveryStatus: 'unavailable' as const, discoveryRetryRemaining: { checks: 1, projects: 0 } }; + const refresh = jest.fn(async () => undefined); + const input = terminal([{ kind: 'value', value: 'r' }, { kind: 'cancel' }]); + const result = await new SetupQuestionnaireController(input, renderer()).collect( + createSetupQuestionnaire(configuration, context), context, { refresh }, + ); + expect(result.terminal).toBe('cancelled'); + expect(refresh).toHaveBeenCalledWith('checks'); + expect(input.readText).toHaveBeenCalledTimes(2); + }); + + it('renders defensive empty selector choices without crashing on an older question shape', async () => { + const initial = createSetupQuestionnaire(createDefaultSetupConfiguration()); + const input = { ...terminal([]), readMultiSelect: jest.fn().mockResolvedValue({ kind: 'cancel' }) }; + const multi = { ...initial, question: { ...initial.question!, kind: 'multi-select' as const, choices: undefined } }; + expect((await new SetupQuestionnaireController(input, renderer()).collect(multi, {})).terminal).toBe('cancelled'); + const project = { ...initial, question: { ...initial.question!, kind: 'project-select' as const, projectCandidates: undefined } }; + expect((await new SetupQuestionnaireController(input, renderer()).collect(project, {})).terminal).toBe('cancelled'); + expect(input.readMultiSelect).toHaveBeenCalledTimes(2); + }); + + it('explains exhausted discovery retries and lets the operator continue', async () => { + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', discoveryRetryRemaining: { checks: 0, projects: 0 } }; + const input = { ...terminal([]), readMultiSelect: jest.fn() + .mockResolvedValueOnce({ kind: 'value', value: 'retry' }) + .mockResolvedValueOnce({ kind: 'value', value: 'none' }) }; + const output = renderer(); + const result = await new SetupQuestionnaireController(input, output).collect( + createSetupQuestionnaire(createDefaultSetupConfiguration(), context), context, + ); + expect(result.terminal).toBe('review'); + expect(output.showValidation).toHaveBeenCalledWith(expect.stringContaining('No discovery retries remain')); + }); }); diff --git a/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts b/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts index 353cc98b8..783f9680c 100644 --- a/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts @@ -1,10 +1,11 @@ -import { SetupWizardUseCase } from '../setup_wizard_use_case'; +import { buildInitialSetupConfiguration, SetupWizardUseCase } from '../setup_wizard_use_case'; import { buildSetupCredentialRequirements, buildSetupRepositoryVariables, createDefaultSetupConfiguration, } from '../../../policies/setup_configuration_policy'; -import { createSetupReviewState } from '../../../policies/setup_questionnaire_policy'; +import { createSetupReviewState, setupQuestionContentInventory } from '../../../policies/setup_questionnaire_policy'; +import type { SetupQuestionnaireContext, SetupQuestionnaireState } from '../../../../domain/setup_questionnaire'; const remote = { ownerType: 'Organization' as const, @@ -31,6 +32,76 @@ function dependencies(overrides: Record = {}) { } describe('SetupWizardUseCase', () => { + it('suggests an observed GitHub default branch without overriding an explicit configured branch', async () => { + const collect = jest.fn(async (state: SetupQuestionnaireState, _context: SetupQuestionnaireContext) => createSetupReviewState(state.draft)); + const deps = dependencies({ collector: { collect }, remoteConfiguration: { inspect: jest.fn().mockResolvedValue({ ...remote, defaultBranch: 'main' }) } }); + const request = { mode: 'interactive' as const, remoteTarget: { owner: 'acme', repository: 'repo', token: 'setup-token' }, + overrides: { pullRequestApproval: { mode: 'off' as const } } }; + await new SetupWizardUseCase(deps).execute(request); + expect(collect.mock.calls[0][0].question?.id).toBe('features.issues'); + expect(collect.mock.calls[0][0].draft.repository.mainBranch).toBe('main'); + expect(collect.mock.calls[0][1].branchSources?.main).toBe('github'); + expect(collect.mock.calls[0][1].branchSources?.development).toBe('default'); + collect.mockClear(); + await new SetupWizardUseCase(deps).execute({ ...request, developmentBranchObservedLocally: true }); + expect(collect.mock.calls[0][1].branchSources?.development).toBe('local'); + collect.mockClear(); + await new SetupWizardUseCase(deps).execute({ ...request, overrides: { ...request.overrides, repository: { mainBranch: 'production' } } }); + expect(collect.mock.calls[0][0].draft.repository.mainBranch).toBe('production'); + expect(collect.mock.calls[0][1].branchSources?.main).toBe('configuration'); + }); + it('re-enters a chosen plan section, retains other answers and rebuilds the plan before approval', async () => { + let pass = 0; + const collect = jest.fn(async (state, _context) => { + pass += 1; + return pass === 1 + ? { ...createSetupReviewState(state.draft), answeredQuestionIds: setupQuestionContentInventory().map(item => item.id) } + : createSetupReviewState({ ...state.draft, repository: { ...state.draft.repository, mainBranch: 'main' } }); + }); + const confirmation = { confirm: jest.fn().mockResolvedValueOnce({ kind: 'revise', group: 'repository' }) + .mockResolvedValueOnce({ kind: 'approved' }) }; + const deps = dependencies({ collector: { collect }, confirmation }); + const result = await new SetupWizardUseCase(deps).execute({ mode: 'interactive', + overrides: { pullRequestApproval: { mode: 'off' } } }); + expect(result.status).toBe('completed'); + if (result.status === 'completed') expect(result.configuration.repository.mainBranch).toBe('main'); + expect(collect).toHaveBeenCalledTimes(2); + expect(collect.mock.calls[1][0].question?.id).toBe('repository.mainBranch'); + expect(collect.mock.calls[1][1].skipQuestionIds).toContain('features.issues'); + expect(confirmation.confirm).toHaveBeenCalledTimes(2); + expect(deps.planPresenter.present).toHaveBeenCalledTimes(2); + }); + it('starts the main questionnaire from reviewed permission intent and skips its answered questions', async () => { + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } } }); + draft.createInitialTag = false; + draft.manageRepositorySecrets = false; + const collect = jest.fn(async (state, _context) => createSetupReviewState(state.draft)); + const result = await new SetupWizardUseCase(dependencies({ collector: { collect } })).execute({ + mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + permissionIntent: { draft, answeredQuestionIds: ['createInitialTag', 'manageRepositorySecrets', 'features.issues'] }, + }); + expect(result.status).toBe('completed'); + if (result.status === 'completed') { + expect(result.configuration.createInitialTag).toBe(false); + expect(result.configuration.manageRepositorySecrets).toBe(false); + } + expect(collect.mock.calls[0][0].question?.id).not.toBe('features.issues'); + expect(collect.mock.calls[0][1].skipQuestionIds).toEqual(['createInitialTag', 'manageRepositorySecrets', 'features.issues']); + }); + + it('lets interactive setup repair legacy duplicate check names before final validation', async () => { + const producer = { name: 'Tests', sourceAppId: 12, workflowName: 'CI' }; + const collect = jest.fn(async state => createSetupReviewState({ ...state.draft, + pullRequestApproval: { ...state.draft.pullRequestApproval, testChecks: [producer] }, + })); + const result = await new SetupWizardUseCase(dependencies({ collector: { collect } })).execute({ + mode: 'interactive', overrides: { pullRequestApproval: { mode: 'recommend', coverage: { mode: 'check', checkName: 'Tests' }, + testChecks: [producer, { name: 'Tests', sourceAppId: 13, workflowName: 'Other CI' }] } }, + }); + expect(collect).toHaveBeenCalledTimes(1); + expect(result.status).toBe('completed'); + }); + it('requires an explicit exact CI producer in non-interactive guarded setup', async () => { await expect(new SetupWizardUseCase(dependencies()).execute({ mode: 'non-interactive' })) .rejects.toThrow('guarded/recommend mode requires 1–8 exact test checks'); @@ -176,7 +247,120 @@ describe('SetupWizardUseCase', () => { remote, variableNames: buildSetupRepositoryVariables(createDefaultSetupConfiguration()).map((item) => item.name), secretNames: buildSetupCredentialRequirements(createDefaultSetupConfiguration()).map((item) => item.name), - })); + }), expect.objectContaining({ refresh: expect.any(Function) })); + }); + + it('discovers Projects with the entered setup PAT only after intent and passes the inventory to the questionnaire', async () => { + const events: string[] = []; + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } } }); + const inspect = jest.fn(async () => { events.push('inspect'); return remote; }); + const discover = jest.fn(async () => { + events.push('projects'); + return { status: 'observed' as const, candidates: [{ number: 12, title: 'Roadmap', owner: 'owner', + url: 'https://github.com/orgs/owner/projects/12', statusOptions: ['Todo', 'In Progress'] }] }; + }); + const collect = jest.fn(async (state) => { events.push('collect'); return createSetupReviewState(state.draft); }); + await new SetupWizardUseCase(dependencies({ + collector: { collect }, remoteConfiguration: { inspect }, projectDiscovery: { discover }, + })).execute({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + permissionIntent: { draft, answeredQuestionIds: ['projects.enabled'], projectsWanted: true }, + remoteTarget: { owner: 'owner', repository: 'repo', token: 'setup-token' } }); + expect(events).toEqual(['inspect', 'projects', 'collect']); + expect(discover).toHaveBeenCalledWith('owner', 'Organization', 'setup-token'); + expect(collect).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + projectsWanted: true, projectDiscovery: expect.objectContaining({ status: 'observed' }), + }), expect.objectContaining({ refresh: expect.any(Function) })); + }); + + it('discovers Projects when the operator enables them while revising a plan that originally skipped them', async () => { + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } } }); + const discover = jest.fn().mockResolvedValue({ status: 'empty', candidates: [] }); + const collect = jest.fn(async state => createSetupReviewState(state.draft)); + await new SetupWizardUseCase(dependencies({ collector: { collect }, + remoteConfiguration: { inspect: jest.fn().mockResolvedValue(remote) }, + projectDiscovery: { discover } })).execute({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + permissionIntent: { draft, answeredQuestionIds: ['projects.enabled'], projectsWanted: false }, + revision: { group: 'projects', answeredQuestionIds: ['projects.enabled'] }, + remoteTarget: { owner: 'owner', repository: 'repo', token: 'setup-token' } }); + expect(discover).toHaveBeenCalledWith('owner', 'Organization', 'setup-token'); + expect(collect).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + projectsWanted: true, projectDiscovery: expect.objectContaining({ status: 'empty' }), + }), expect.anything()); + }); + + it('bounds explicit Project discovery retries and never requests another PAT', async () => { + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } } }); + const discover = jest.fn().mockResolvedValueOnce({ status: 'unavailable', candidates: [] }) + .mockResolvedValueOnce({ status: 'observed', candidates: [{ number: 4, owner: 'owner', title: 'Roadmap', + url: 'https://github.com/orgs/owner/projects/4', statusOptions: ['Todo', 'In Progress'] }] }) + .mockResolvedValueOnce({ status: 'empty', candidates: [] }); + const collect = jest.fn(async (state, context, refresh) => { + expect(context.discoveryRetryRemaining.projects).toBe(2); + expect((await refresh.refresh('projects'))?.projectDiscovery?.status).toBe('observed'); + expect((await refresh.refresh('projects'))?.projectDiscovery?.status).toBe('empty'); + expect(await refresh.refresh('projects')).toBeUndefined(); + return createSetupReviewState(state.draft); + }); + await new SetupWizardUseCase(dependencies({ collector: { collect }, remoteConfiguration: { inspect: jest.fn().mockResolvedValue(remote) }, + projectDiscovery: { discover } })).execute({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + permissionIntent: { draft, answeredQuestionIds: ['projects.enabled'], projectsWanted: true }, + remoteTarget: { owner: 'owner', repository: 'repo', token: 'setup-token' } }); + expect(discover).toHaveBeenCalledTimes(3); + expect(discover).toHaveBeenNthCalledWith(3, 'owner', 'Organization', 'setup-token'); + }); + + it('keeps provider failures explicit during initial CI/Project discovery and bounded retries', async () => { + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'recommend' } } }); + const approvalDiscover = jest.fn().mockRejectedValue(new Error('CI provider unavailable')); + const projectDiscover = jest.fn().mockRejectedValue(new Error('Projects provider unavailable')); + const collect = jest.fn(async (state, context, refresh) => { + expect(context.approvalCheckDiscoveryStatus).toBe('unavailable'); + expect(context.projectDiscovery?.status).toBe('unavailable'); + expect((await refresh.refresh('checks'))?.approvalCheckDiscoveryStatus).toBe('unavailable'); + expect((await refresh.refresh('projects'))?.projectDiscovery?.status).toBe('unavailable'); + return { ...state, terminal: 'cancelled' as const }; + }); + const result = await new SetupWizardUseCase(dependencies({ collector: { collect }, + remoteConfiguration: { inspect: jest.fn().mockResolvedValue(remote) }, + approvalCheckDiscovery: { discover: approvalDiscover }, projectDiscovery: { discover: projectDiscover } })).execute({ + mode: 'interactive', overrides: { pullRequestApproval: { mode: 'recommend' } }, + permissionIntent: { draft, answeredQuestionIds: ['projects.enabled'], projectsWanted: true }, + remoteTarget: { owner: 'owner', repository: 'repo', token: 'setup-token' }, + }); + expect(result.status).toBe('cancelled'); + expect(approvalDiscover).toHaveBeenCalledTimes(2); + expect(projectDiscover).toHaveBeenCalledTimes(2); + }); + + it('uses Unknown ownership for Project suggestions when remote repository inspection is unavailable', async () => { + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } } }); + const discover = jest.fn().mockResolvedValue({ status: 'unavailable', candidates: [] }); + const collect = jest.fn(async (state, _context, refresh) => { + expect((await refresh.refresh('projects'))?.projectDiscovery?.status).toBe('unavailable'); + return { ...state, terminal: 'cancelled' as const }; + }); + await new SetupWizardUseCase(dependencies({ collector: { collect }, + remoteConfiguration: { inspect: jest.fn().mockRejectedValue(new Error('GitHub unavailable')) }, + projectDiscovery: { discover } })).execute({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + permissionIntent: { draft, answeredQuestionIds: ['projects.enabled'], projectsWanted: true }, + remoteTarget: { owner: 'owner', repository: 'repo', token: 'setup-token' } }); + expect(discover).toHaveBeenCalledTimes(2); + expect(discover).toHaveBeenNthCalledWith(1, 'owner', 'Unknown', 'setup-token'); + expect(discover).toHaveBeenNthCalledWith(2, 'owner', 'Unknown', 'setup-token'); + }); + + it('requires interactive mode for plan edits and tolerates an older review without answer history', async () => { + const confirmation = { confirm: jest.fn().mockResolvedValue({ kind: 'revise', group: 'repository' }) }; + await expect(new SetupWizardUseCase(dependencies({ confirmation })).execute({ + mode: 'non-interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + })).rejects.toThrow('Plan editing requires interactive setup.'); + confirmation.confirm.mockResolvedValueOnce({ kind: 'revise', group: 'repository' }) + .mockResolvedValueOnce({ kind: 'declined' }); + const collect = jest.fn(async state => createSetupReviewState(state.draft)); + await new SetupWizardUseCase(dependencies({ collector: { collect }, confirmation })).execute({ + mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + }); + expect(collect).toHaveBeenCalledTimes(2); }); it('adds live merge-queue readiness to the setup plan', async () => { @@ -362,7 +546,7 @@ describe('SetupWizardUseCase', () => { }); expect(collect).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ remote: expect.objectContaining({ repositoryVariablesAccess: 'unavailable' }), - })); + }), expect.objectContaining({ refresh: expect.any(Function) })); expect(deps.finalPermissionAudit.audit).toHaveBeenCalledTimes(1); expect(deps.planPresenter.present).not.toHaveBeenCalled(); expect(deps.confirmation.confirm).not.toHaveBeenCalled(); diff --git a/src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts b/src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts new file mode 100644 index 000000000..98eb5a011 --- /dev/null +++ b/src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts @@ -0,0 +1,22 @@ +import { VerifyGuidedWorkflowPatIdentityUseCase } from '../verify_guided_workflow_pat_identity_use_case'; + +describe('VerifyGuidedWorkflowPatIdentityUseCase', () => { + const identities = { + resolve: jest.fn(), + identify: jest.fn(), + }; + beforeEach(() => jest.clearAllMocks()); + + it('accepts matching immutable IDs even when the login casing differs', async () => { + identities.identify.mockResolvedValue({ id: 42, login: 'vypbot' }); + await expect(new VerifyGuidedWorkflowPatIdentityUseCase(identities) + .execute({ id: 42, login: 'VypBot' }, 'workflow-token')).resolves.toEqual({ id: 42, login: 'VypBot' }); + expect(identities.identify).toHaveBeenCalledWith('workflow-token'); + }); + + it('rejects another account without leaking either token', async () => { + identities.identify.mockResolvedValue({ id: 99, login: 'operator' }); + await expect(new VerifyGuidedWorkflowPatIdentityUseCase(identities) + .execute({ id: 42, login: 'vypbot' }, 'workflow-token')).rejects.toThrow('not the selected bot'); + }); +}); diff --git a/src/application/usecases/setup/__tests__/verify_setup_pat_bootstrap_use_case.test.ts b/src/application/usecases/setup/__tests__/verify_setup_pat_bootstrap_use_case.test.ts new file mode 100644 index 000000000..aec2397a0 --- /dev/null +++ b/src/application/usecases/setup/__tests__/verify_setup_pat_bootstrap_use_case.test.ts @@ -0,0 +1,72 @@ +import type { SetupTokenPermissionReport } from '../../../../domain/setup_token_permissions'; +import { buildSetupPatPermissionRequirements } from '../../../policies/setup_token_permission_policy'; +import { VerifySetupPatBootstrapUseCase, type VerifySetupPatBootstrapPorts } from '../verify_setup_pat_bootstrap_use_case'; + +const report: SetupTokenPermissionReport = { + role: 'setup', account: 'operator', identityStatus: 'valid', identityMessage: 'valid', checks: [], + ready: true, confirmationRequired: false, +}; +const request = { + owner: 'owner', repository: 'repo', token: 'test-token', requirements: buildSetupPatPermissionRequirements(), guided: true, +}; + +function harness() { + const ports: VerifySetupPatBootstrapPorts = { + permissions: { inspect: jest.fn(async () => report) }, + presenter: { showRequirements: jest.fn(), showReport: jest.fn() }, + confirmUnverifiable: jest.fn(async () => true), + confirmAccount: jest.fn(async () => true), + showCorrectedLink: jest.fn(), + }; + return { ports, useCase: new VerifySetupPatBootstrapUseCase(ports) }; +} + +describe('VerifySetupPatBootstrapUseCase', () => { + test('audits read-only and returns the authenticated operator account', async () => { + const { ports, useCase } = harness(); + expect(await useCase.execute(request)).toBe('operator'); + expect(ports.permissions.inspect).toHaveBeenCalledWith({ + role: 'setup', owner: 'owner', repository: 'repo', token: 'test-token', requirements: request.requirements, + }); + expect(ports.presenter.showReport).toHaveBeenCalledWith(report); + expect(ports.confirmAccount).toHaveBeenCalledWith('operator'); + expect(ports.confirmUnverifiable).not.toHaveBeenCalled(); + }); + + test('requires explicit confirmation for unverifiable write grants', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, ready: false, confirmationRequired: true }); + expect(await useCase.execute(request)).toBe('operator'); + expect(ports.confirmUnverifiable).toHaveBeenCalledTimes(1); + }); + + test('declined unverifiable grants block before account confirmation', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, ready: false, confirmationRequired: true }); + jest.spyOn(ports, 'confirmUnverifiable').mockResolvedValue(false); + await expect(useCase.execute(request)).rejects.toThrow('missing or unconfirmed required access'); + expect(ports.showCorrectedLink).toHaveBeenCalledWith(expect.stringContaining('target_name=owner')); + expect(ports.confirmAccount).not.toHaveBeenCalled(); + }); + + test.each(['invalid', 'unverifiable'] as const)('%s identity blocks regardless of a ready permission table', async identityStatus => { + const { ports, useCase } = harness(); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, identityStatus }); + await expect(useCase.execute(request)).rejects.toThrow('missing or unconfirmed required access'); + expect(ports.confirmAccount).not.toHaveBeenCalled(); + }); + + test('manual PAT failure does not imply a guided correction URL', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.permissions, 'inspect').mockResolvedValue({ ...report, ready: false }); + await expect(useCase.execute({ ...request, guided: false })).rejects.toThrow('missing or unconfirmed required access'); + expect(ports.showCorrectedLink).not.toHaveBeenCalled(); + }); + + test('operator rejects an authenticated but unintended account', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'confirmAccount').mockResolvedValue(false); + await expect(useCase.execute(request)).rejects.toThrow('unintended account'); + expect(ports.showCorrectedLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/usecases/setup/__tests__/verify_web_setup_apply_use_case.test.ts b/src/application/usecases/setup/__tests__/verify_web_setup_apply_use_case.test.ts new file mode 100644 index 000000000..338260923 --- /dev/null +++ b/src/application/usecases/setup/__tests__/verify_web_setup_apply_use_case.test.ts @@ -0,0 +1,184 @@ +import { createDefaultSetupConfiguration } from '../../../policies/setup_configuration_defaults'; +import { SetupInteractionCancelledError } from '../../../errors/setup_interaction_cancelled_error'; +import type { SetupRemoteConfiguration } from '../../../../domain/setup'; +import { + VerifyWebSetupApplyUseCase, type VerifyWebSetupApplyPorts, type VerifyWebSetupApplyRequest, +} from '../verify_web_setup_apply_use_case'; + +const repository = { owner: 'owner', repository: 'repo', checkoutRoot: '/checkout', branch: 'develop', head: 'a'.repeat(40) }; +const remoteBase: SetupRemoteConfiguration = { + ownerType: 'User', repositoryId: 42, repositoryVisibility: 'private', defaultBranch: 'main', + repositorySecrets: ['PAT', 'OPENAI_API_KEY'], repositorySecretsAccess: 'available', organizationSecrets: [], + repositoryVariables: [{ name: 'AGENT_PROVIDER', value: 'codex' }, { name: 'MAIN_BRANCH', value: 'main' }], + repositoryVariablesAccess: 'available', organizationVariables: [], + organizationAccess: 'not_applicable', organizationSecretsAccess: 'not_applicable', organizationVariablesAccess: 'not_applicable', +}; +const approvedRemote = { ...remoteBase, credentialHealthWorkflow: 'installed' as const }; +const request: VerifyWebSetupApplyRequest = { + repository, selectedFiles: ['.github/workflows/copilot.yml'], fileSnapshot: { '.github/workflows/copilot.yml': 'file:abc' }, + approvedRemote, configuration: createDefaultSetupConfiguration(), setupToken: 'test-token', +}; + +function harness() { + let session: 'active' | 'cancelled' | 'ended' = 'active'; + const ports: VerifyWebSetupApplyPorts = { + confirm: jest.fn(async () => 'apply' as const), + readRepositoryFacts: jest.fn(() => ({ ...repository })), + fileSnapshotMatches: jest.fn(() => true), + remote: { + inspect: jest.fn(async () => ({ ...remoteBase })), + inspectCredentialHealthWorkflow: jest.fn(async () => 'installed' as const), + }, + permissionAudit: { audit: jest.fn(async () => ({ status: 'accepted' as const })) }, + sessionState: () => session, + }; + return { ports, useCase: new VerifyWebSetupApplyUseCase(ports), setSession: (next: typeof session) => { session = next; } }; +} + +describe('VerifyWebSetupApplyUseCase', () => { + test('authorizes only after reconfirming local, remote, workflow and PAT facts', async () => { + const { ports, useCase } = harness(); + expect(await useCase.execute(request)).toBe('approved'); + expect(ports.fileSnapshotMatches).toHaveBeenCalledWith(repository.checkoutRoot, request.selectedFiles, request.fileSnapshot); + expect(ports.readRepositoryFacts).toHaveBeenCalledTimes(2); + expect(ports.fileSnapshotMatches).toHaveBeenCalledTimes(2); + expect(ports.remote.inspect).toHaveBeenCalledWith('owner', 'repo', 'test-token'); + expect(ports.remote.inspectCredentialHealthWorkflow).toHaveBeenCalledWith('owner', 'repo', 'test-token', request.configuration.repository.mainBranch); + expect(ports.permissionAudit.audit).toHaveBeenCalledWith(request.configuration, approvedRemote); + }); + + test.each([ + ['stop', 'declined'], + [undefined, 'cancelled'], + ] as const)('does not inspect or mutate after approval response %s', async (answer, expected) => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'confirm').mockResolvedValue(answer); + expect(await useCase.execute(request)).toBe(expected); + expect(ports.readRepositoryFacts).not.toHaveBeenCalled(); + expect(ports.remote.inspect).not.toHaveBeenCalled(); + }); + + test.each([ + ['owner', 'different'], ['repository', 'different'], ['checkoutRoot', '/elsewhere'], + ['branch', 'main'], ['head', 'b'.repeat(40)], + ] as const)('fails closed when %s changed', async (field, value) => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'readRepositoryFacts').mockReturnValue({ ...repository, [field]: value }); + await expect(useCase.execute(request)).rejects.toThrow('repository identity changed'); + expect(ports.remote.inspect).not.toHaveBeenCalled(); + }); + + test('fails closed when repository facts disappear', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'readRepositoryFacts').mockReturnValue(undefined); + await expect(useCase.execute(request)).rejects.toThrow('repository identity changed'); + }); + + test('fails closed on file drift before remote reads', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports, 'fileSnapshotMatches').mockReturnValue(false); + await expect(useCase.execute(request)).rejects.toThrow('Selected repository files changed'); + expect(ports.remote.inspect).not.toHaveBeenCalled(); + }); + + test('rejects a changed HEAD after asynchronous GitHub and permission checks', async () => { + const { ports, useCase } = harness(); + const read = jest.spyOn(ports, 'readRepositoryFacts') + .mockReturnValueOnce({ ...repository }) + .mockReturnValue({ ...repository, head: 'b'.repeat(40) }); + await expect(useCase.execute(request)).rejects.toThrow('repository identity changed'); + expect(read).toHaveBeenCalledTimes(2); + expect(ports.permissionAudit.audit).toHaveBeenCalledTimes(1); + }); + + test('rejects a selected-file change after asynchronous final checks', async () => { + const { ports, useCase } = harness(); + const matches = jest.spyOn(ports, 'fileSnapshotMatches') + .mockReturnValueOnce(true) + .mockReturnValue(false); + await expect(useCase.execute(request)).rejects.toThrow('Selected repository files changed'); + expect(matches).toHaveBeenCalledTimes(2); + expect(ports.permissionAudit.audit).toHaveBeenCalledTimes(1); + }); + + test('fails closed when GitHub facts changed', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.remote, 'inspect').mockResolvedValue({ ...remoteBase, repositoryVisibility: 'public' }); + await expect(useCase.execute(request)).rejects.toThrow('GitHub repository facts changed'); + expect(ports.permissionAudit.audit).not.toHaveBeenCalled(); + }); + + test('rejects a changed default branch before applying the reviewed plan', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.remote, 'inspect').mockResolvedValue({ ...remoteBase, defaultBranch: 'develop' }); + await expect(useCase.execute(request)).rejects.toThrow('GitHub repository facts changed'); + expect(ports.permissionAudit.audit).not.toHaveBeenCalled(); + }); + + test('ignores response key and resource ordering when GitHub facts are unchanged', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.remote, 'inspect').mockResolvedValue({ + repositoryVariables: [...remoteBase.repositoryVariables].reverse(), + repositorySecrets: [...remoteBase.repositorySecrets].reverse(), + ownerType: remoteBase.ownerType, repositoryId: remoteBase.repositoryId, + organizationSecrets: remoteBase.organizationSecrets, organizationVariables: remoteBase.organizationVariables, + repositorySecretsAccess: remoteBase.repositorySecretsAccess, + repositoryVariablesAccess: remoteBase.repositoryVariablesAccess, + organizationAccess: remoteBase.organizationAccess, + organizationSecretsAccess: remoteBase.organizationSecretsAccess, + organizationVariablesAccess: remoteBase.organizationVariablesAccess, + repositoryVisibility: remoteBase.repositoryVisibility, + defaultBranch: remoteBase.defaultBranch, + }); + expect(await useCase.execute(request)).toBe('approved'); + }); + + test('fails closed when a remote variable value changes', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.remote, 'inspect').mockResolvedValue({ + ...remoteBase, repositoryVariables: [{ name: 'AGENT_PROVIDER', value: 'cursor' }, ...remoteBase.repositoryVariables.slice(1)], + }); + await expect(useCase.execute(request)).rejects.toThrow('GitHub repository facts changed'); + expect(ports.permissionAudit.audit).not.toHaveBeenCalled(); + }); + + test('unknown selected-ref workflow state cannot inherit earlier installed evidence', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.remote, 'inspectCredentialHealthWorkflow').mockRejectedValue(new Error('read failed')); + await expect(useCase.execute(request)).rejects.toThrow('GitHub repository facts changed'); + expect(ports.permissionAudit.audit).not.toHaveBeenCalled(); + }); + + test('treats an unavailable workflow inspection port as unknown rather than reusing stale evidence', async () => { + const { ports, useCase } = harness(); + delete (ports.remote as { inspectCredentialHealthWorkflow?: unknown }).inspectCredentialHealthWorkflow; + await expect(useCase.execute(request)).rejects.toThrow('GitHub repository facts changed'); + expect(ports.permissionAudit.audit).not.toHaveBeenCalled(); + }); + + test('blocks when the final PAT audit loses a required grant', async () => { + const { ports, useCase } = harness(); + jest.spyOn(ports.permissionAudit, 'audit').mockResolvedValue({ status: 'blocked', errors: ['missing'] }); + await expect(useCase.execute(request)).rejects.toThrow('Setup PAT access changed'); + }); + + test.each(['cancelled', 'ended'] as const)('stops before facts are read when session is %s', async state => { + const { ports, useCase, setSession } = harness(); + setSession(state); + await expect(useCase.execute(request)).rejects.toThrow(state === 'cancelled' ? SetupInteractionCancelledError : 'session expired'); + expect(ports.readRepositoryFacts).not.toHaveBeenCalled(); + }); + + test('cancellation arriving during remote inspection wins before another read', async () => { + const { ports, useCase, setSession } = harness(); + jest.spyOn(ports.remote, 'inspect').mockImplementation(async () => { setSession('cancelled'); return remoteBase; }); + await expect(useCase.execute(request)).rejects.toThrow(SetupInteractionCancelledError); + expect(ports.remote.inspectCredentialHealthWorkflow).not.toHaveBeenCalled(); + }); + + test('expiry arriving during the final audit prevents approval', async () => { + const { ports, useCase, setSession } = harness(); + jest.spyOn(ports.permissionAudit, 'audit').mockImplementation(async () => { setSession('ended'); return { status: 'accepted' }; }); + await expect(useCase.execute(request)).rejects.toThrow('session expired'); + }); +}); diff --git a/src/application/usecases/setup/audit_configured_setup_pat_use_case.ts b/src/application/usecases/setup/audit_configured_setup_pat_use_case.ts new file mode 100644 index 000000000..2850602a6 --- /dev/null +++ b/src/application/usecases/setup/audit_configured_setup_pat_use_case.ts @@ -0,0 +1,76 @@ +import type { SetupConfiguration, SetupRemoteConfiguration } from '../../../domain/setup'; +import type { SetupTokenPermissionReport, SetupTokenPermissionRequirement } from '../../../domain/setup_token_permissions'; +import { buildSetupPatCreationUrl } from '../../policies/setup_pat_creation_url_policy'; +import { buildConfiguredSetupPatPermissionRequirements, requiredSetupPatPermissionDelta } from '../../policies/setup_token_permission_policy'; +import type { SetupFinalPermissionAuditPort } from '../../ports/setup_wizard_ports'; +import type { SetupTokenPermissionAuditPort, SetupTokenPermissionPresenterPort } from '../../ports/setup_token_permission_ports'; + +export interface AuditConfiguredSetupPatContext { + readonly owner: string; + readonly repository: string; + readonly token?: string; + readonly provisionalRequirements: readonly SetupTokenPermissionRequirement[]; + readonly assertedOwnerKind?: 'Organization' | 'User'; + readonly guided: boolean; +} + +export interface AuditConfiguredSetupPatPorts { + readonly permissions: SetupTokenPermissionAuditPort; + readonly presenter: SetupTokenPermissionPresenterPort; + confirmUnverifiable(report: SetupTokenPermissionReport): Promise; + showOwnerMismatch(asserted: 'Organization' | 'User', actual: 'Organization' | 'User'): void; + showExcessGrants(grants: readonly string[]): void; + showUpdatedLink(url: string, addedGrants: readonly string[]): void; +} + +/** Rechecks the final plan without granting permission based on the browser preview. */ +export class AuditConfiguredSetupPatUseCase implements SetupFinalPermissionAuditPort { + constructor( + private readonly context: AuditConfiguredSetupPatContext, + private readonly ports: AuditConfiguredSetupPatPorts, + ) {} + + async audit( + configuration: Readonly, + remote?: Readonly, + ): Promise<{ status: 'accepted' } | { status: 'blocked'; errors: readonly string[] }> { + const required = buildConfiguredSetupPatPermissionRequirements(configuration, remote); + this.ports.presenter.showRequirements('setup', required); + if (this.context.token && (!remote || remote.ownerType === 'Unknown')) { + return { status: 'blocked', errors: [ + 'GitHub could not verify whether this repository is owned by an organization or a user. Retry remote inspection before applying setup; the pre-PAT owner selection is not authorization evidence.', + ] }; + } + if (this.context.assertedOwnerKind && remote && remote.ownerType !== 'Unknown' + && remote.ownerType !== this.context.assertedOwnerKind) { + this.ports.showOwnerMismatch(this.context.assertedOwnerKind, remote.ownerType); + this.showCorrectedLink(required); + return { status: 'blocked', errors: ['Repository owner type differs from the pre-PAT selection. Rerun setup with the correct owner type and PAT.'] }; + } + if (this.context.guided) { + const removed = requiredSetupPatPermissionDelta(required, this.context.provisionalRequirements); + if (removed.length) this.ports.showExcessGrants(removed); + } + if (!this.context.token) return { status: 'accepted' }; + const report = await this.ports.permissions.inspect({ + role: 'setup', owner: this.context.owner, repository: this.context.repository, + token: this.context.token, requirements: required, + }); + this.ports.presenter.showReport(report); + const accepted = report.ready || (report.confirmationRequired && await this.ports.confirmUnverifiable(report)); + if (!accepted || report.identityStatus !== 'valid') { + if (this.context.guided) this.showCorrectedLink(required); + return { status: 'blocked', errors: [ + 'The setup PAT has missing or unconfirmed access required by the approved setup plan. Grant or explicitly confirm the permissions shown above and retry.', + ] }; + } + return { status: 'accepted' }; + } + + private showCorrectedLink(required: readonly SetupTokenPermissionRequirement[]): void { + this.ports.showUpdatedLink(buildSetupPatCreationUrl({ + role: 'setup', owner: this.context.owner, repository: this.context.repository, + expiresIn: 1, requirements: required, + }), requiredSetupPatPermissionDelta(this.context.provisionalRequirements, required)); + } +} diff --git a/src/application/usecases/setup/doctor_use_case.ts b/src/application/usecases/setup/doctor_use_case.ts index a85bd6ace..5558731ba 100644 --- a/src/application/usecases/setup/doctor_use_case.ts +++ b/src/application/usecases/setup/doctor_use_case.ts @@ -46,6 +46,8 @@ export interface DoctorRequest { repository: string; setupToken: string; configuration: SetupConfiguration; + /** Inspect installed resources without dispatching credential-health Actions. */ + readOnly?: boolean; } export interface SetupDoctorDependencies { @@ -257,7 +259,7 @@ export class SetupDoctorUseCase { const remoteSecrets = new Set([...remote.repositorySecrets, ...remote.organizationSecrets]); const present = requirements.filter((requirement) => remoteSecrets.has(requirement.name)); let health: readonly SetupCredentialCheck[] | undefined; - if (present.length > 0) { + if (present.length > 0 && !request.readOnly) { try { health = await this.dependencies.remoteHealth.validateExisting( request.owner, diff --git a/src/application/usecases/setup/prepare_setup_pat_intent_use_case.ts b/src/application/usecases/setup/prepare_setup_pat_intent_use_case.ts new file mode 100644 index 000000000..bded9788f --- /dev/null +++ b/src/application/usecases/setup/prepare_setup_pat_intent_use_case.ts @@ -0,0 +1,130 @@ +import type { SetupConfiguration } from '../../../domain/setup'; +import type { SetupQuestionnaireContext, SetupQuestionnaireState } from '../../../domain/setup_questionnaire'; +import type { SetupTokenPermissionRequirement } from '../../../domain/setup_token_permissions'; +import { ApplicationError } from '../../errors/application_error'; +import { SetupInteractionCancelledError } from '../../errors/setup_interaction_cancelled_error'; +import type { SetupConfigurationOverrides } from '../../policies/setup_configuration_policy'; +import { validateSetupConfiguration } from '../../policies/setup_configuration_policy'; +import { createSetupPermissionIntentQuestionnaire } from '../../policies/setup_questionnaire_policy'; +import { fixedSetupPatIntentQuestionIds, setupPatIntentNeedsOwnerKind, setupPatIntentOwnerConflict } from '../../policies/setup_pat_intent_policy'; +import { buildSetupPatIntentPermissionRequirements, buildSetupPatIntentUncertainty } from '../../policies/setup_token_permission_policy'; +import { buildSetupPatCreationUrl, UnsupportedSetupPatLinkError } from '../../policies/setup_pat_creation_url_policy'; +import { buildInitialSetupConfiguration } from './setup_wizard_use_case'; + +export interface PrepareSetupPatIntentRequest { + readonly owner: string; + readonly repository: string; + readonly overrides: SetupConfigurationOverrides; + readonly skipRepositoryVariables: boolean; + readonly skipRepositorySecrets: boolean; +} + +export interface SetupPatIntentPreview { + readonly draft: SetupConfiguration; + readonly requirements: readonly SetupTokenPermissionRequirement[]; + readonly uncertain: readonly string[]; + readonly ownerConflict: boolean; + readonly errors: readonly string[]; + readonly pass: number; + readonly projectsWanted: boolean; +} + +export interface PrepareSetupPatIntentPorts { + collect(initial: SetupQuestionnaireState, context: SetupQuestionnaireContext, pass: number): Promise; + chooseOwnerKind(): Promise<'Organization' | 'User' | 'unknown'>; + review(): Promise<'continue' | 'revise' | 'manual' | 'details'>; + showPreview(preview: SetupPatIntentPreview): void; + showDetails(requirements: readonly SetupTokenPermissionRequirement[]): void; + onManual(reason: 'owner-unknown' | 'chosen' | 'unsupported'): void; + advanceToSetupPat(): void; + revisitChoices(): number; +} + +export type PrepareSetupPatIntentResult = + | { readonly kind: 'manual' } + | { + readonly kind: 'guided'; + readonly url: string; + readonly requirements: readonly SetupTokenPermissionRequirement[]; + readonly ownerKind: 'Organization' | 'User'; + readonly permissionIntent: { readonly draft: SetupConfiguration; readonly answeredQuestionIds: readonly string[]; readonly projectsWanted: boolean }; + }; + +/** Frontend-neutral preparation; GitHub still issues the PAT in its own UI. */ +export class PrepareSetupPatIntentUseCase { + constructor(private readonly ports: PrepareSetupPatIntentPorts) {} + + async execute(request: PrepareSetupPatIntentRequest): Promise { + const fixedQuestionIds = fixedSetupPatIntentQuestionIds( + request.overrides, request.skipRepositoryVariables, request.skipRepositorySecrets, + ); + let draft = buildInitialSetupConfiguration({ + mode: 'interactive', overrides: request.overrides, + skipRepositoryVariables: request.skipRepositoryVariables, + skipRepositorySecrets: request.skipRepositorySecrets, + }); + let pass = 1; + let projectsWanted = Boolean(draft.projects.ids.trim()); + while (true) { + const context = { skipQuestionIds: fixedQuestionIds, projectsWanted, + fixedWorkflowFeatures: { release: request.overrides.features?.release, hotfix: request.overrides.features?.hotfix } }; + const intent = await this.ports.collect(createSetupPermissionIntentQuestionnaire(draft, context), context, pass); + if (intent.terminal === 'cancelled') throw new SetupInteractionCancelledError(); + draft = intent.draft; + projectsWanted = intent.projectsWanted ?? Boolean(draft.projects.ids.trim()); + if (!projectsWanted && draft.projects.ids.trim()) { + draft = { ...draft, projects: { ...draft.projects, ids: '' } }; + } + const ownerKind = setupPatIntentNeedsOwnerKind(draft, projectsWanted) ? await this.ports.chooseOwnerKind() : 'User'; + if (ownerKind === 'unknown') { + this.ports.onManual('owner-unknown'); + return { kind: 'manual' }; + } + const ownerConflict = setupPatIntentOwnerConflict(draft, ownerKind, projectsWanted); + const errors = validateSetupConfiguration(draft, { allowIncompleteApproval: true }); + const requirements = buildSetupPatIntentPermissionRequirements(draft, ownerKind, projectsWanted); + this.ports.advanceToSetupPat(); + this.ports.showPreview({ + draft, requirements, uncertain: buildSetupPatIntentUncertainty(draft, ownerKind), + ownerConflict, errors, pass, projectsWanted, + }); + + let decision: Awaited>; + do { + decision = await this.ports.review(); + if (decision === 'details') this.ports.showDetails(requirements); + } while (decision === 'details'); + if (decision === 'manual') { + this.ports.onManual('chosen'); + return { kind: 'manual' }; + } + if (decision === 'revise') { + pass = this.ports.revisitChoices(); + continue; + } + if (ownerConflict || errors.length > 0) { + throw new ApplicationError('configuration.invalid', 'Correct the reported setup intent or local --config/flags, then retry guided setup. No PAT was requested.'); + } + try { + return { + kind: 'guided', + url: buildSetupPatCreationUrl({ + role: 'setup', owner: request.owner, repository: request.repository, expiresIn: 1, + requirements, + }), + requirements, + ownerKind, + permissionIntent: { + draft, + projectsWanted, + answeredQuestionIds: [...new Set([...fixedQuestionIds, ...(intent.answeredQuestionIds ?? [])])], + }, + }; + } catch (error) { + if (!(error instanceof UnsupportedSetupPatLinkError)) throw error; + this.ports.onManual('unsupported'); + return { kind: 'manual' }; + } + } + } +} diff --git a/src/application/usecases/setup/setup_journey_use_case.ts b/src/application/usecases/setup/setup_journey_use_case.ts new file mode 100644 index 000000000..106ceb776 --- /dev/null +++ b/src/application/usecases/setup/setup_journey_use_case.ts @@ -0,0 +1,63 @@ +import { buildSetupJourneyView, SETUP_JOURNEY_STAGES } from '../../policies/setup_journey_policy'; +import type { SetupJourneyOutcome, SetupJourneyStage, SetupJourneyView } from '../../policies/setup_journey_policy'; + +export interface SetupJourneyPresenterPort { + present(view: SetupJourneyView): void; +} + +/** Tracks semantic milestones, independently of the CLI's rendering. */ +export class SetupJourneyUseCase { + private stage: SetupJourneyStage = 'repository'; + private outcome?: SetupJourneyOutcome; + private mutationStarted = false; + private choiceReviewPass = 1; + + constructor(private readonly repository: string, private readonly presenter: SetupJourneyPresenterPort) {} + + advance(stage: SetupJourneyStage): void { + if (this.outcome) throw new Error('Cannot advance a finished setup journey.'); + const next = SETUP_JOURNEY_STAGES.indexOf(stage); + if (next < SETUP_JOURNEY_STAGES.indexOf(this.stage)) throw new Error('Setup journey cannot move backwards.'); + if (next === SETUP_JOURNEY_STAGES.indexOf(this.stage)) return; + this.stage = stage; + this.present(); + } + + /** The only deliberate backwards transition: revisit local choices before PAT entry. */ + revisitChoices(): number { + if (this.stage !== 'setup-pat' || this.outcome || this.mutationStarted) { + throw new Error('Setup choices can be revisited only from pre-PAT review.'); + } + this.choiceReviewPass += 1; + this.stage = 'choices'; + this.present(); + return this.choiceReviewPass; + } + + markMutationStarted(): void { + if ((this.stage !== 'credentials' && this.stage !== 'apply') || this.outcome) { + throw new Error('Setup mutation can start only during credential validation or apply.'); + } + if (this.mutationStarted) return; + this.mutationStarted = true; + this.present(); + } + + finish(outcome: SetupJourneyOutcome): void { + if (this.outcome) return; + if (outcome === 'complete' && (this.stage !== 'apply' || !this.mutationStarted)) { + throw new Error('Setup cannot be complete before applying the plan.'); + } + if (outcome === 'partial' && !this.mutationStarted) { + throw new Error('Setup cannot be partial before mutation starts.'); + } + this.outcome = outcome; + this.present(); + } + + private present(): void { + this.presenter.present(buildSetupJourneyView( + this.repository, this.stage, this.mutationStarted, this.outcome, this.choiceReviewPass, + )); + } +} diff --git a/src/application/usecases/setup/setup_questionnaire_controller.ts b/src/application/usecases/setup/setup_questionnaire_controller.ts index 9a39852de..263a8c288 100644 --- a/src/application/usecases/setup/setup_questionnaire_controller.ts +++ b/src/application/usecases/setup/setup_questionnaire_controller.ts @@ -2,15 +2,18 @@ import type { SetupConfigurationCollectorPort, SetupQuestionRenderer, TerminalDriver, + SetupDiscoveryRefreshPort, } from '../../ports/setup_terminal_ports'; import type { + SetupQuestion, SetupQuestionnaireContext, SetupQuestionnaireEvent, SetupQuestionnaireState, SetupQuestionnaireStateId, } from '../../../domain/setup_questionnaire'; -import { transitionSetupQuestionnaire } from '../../policies/setup_questionnaire_policy'; +import { refreshSetupQuestionnaireQuestion, setupQuestionnaireProgress, transitionSetupQuestionnaire } from '../../policies/setup_questionnaire_policy'; import { ApplicationError } from '../../errors/application_error'; +import { safeTerminalChoiceText } from '../../policies/setup_terminal_choice_policy'; export class SetupQuestionnaireController implements SetupConfigurationCollectorPort { constructor( @@ -21,6 +24,7 @@ export class SetupQuestionnaireController implements SetupConfigurationCollector async collect( initial: SetupQuestionnaireState, context: SetupQuestionnaireContext, + discoveryRefresh?: SetupDiscoveryRefreshPort, ): Promise { if (!this.terminal.isInteractive()) { throw new ApplicationError( @@ -30,6 +34,8 @@ export class SetupQuestionnaireController implements SetupConfigurationCollector } this.renderer.showIntroduction(); let state = initial; + let currentContext = context; + let pendingProjectSelection: readonly string[] | undefined; let visibleState: SetupQuestionnaireStateId | undefined; while (state.terminal === 'collecting' && state.question) { if (visibleState !== state.stateId) { @@ -37,20 +43,76 @@ export class SetupQuestionnaireController implements SetupConfigurationCollector visibleState = state.stateId; } if (state.validation) this.renderer.showValidation(state.validation); - const input = state.question.kind === 'multi-select' && this.terminal.readMultiSelect - ? await this.terminal.readMultiSelect( - this.renderer.renderPrompt(state.question), - state.question.choices ?? [], - parseSelectedDefaults(state.question.defaultValue), - ) - : await this.terminal.readText(this.renderer.renderPrompt(state.question)); - state = transitionSetupQuestionnaire(state, toEvent(input), context); + const question = state.question; + const prompt = this.renderer.renderPrompt(question, setupQuestionnaireProgress(state, currentContext)); + const selectable = question.kind === 'multi-select' || question.kind === 'project-select'; + const choices = selectable ? choicesForQuestion(question) : []; + const selected = question.kind === 'project-select' && pendingProjectSelection + ? pendingProjectSelection : parseSelectedDefaults(question.defaultValue); + let input = selectable && this.terminal.readMultiSelect + ? await this.terminal.readMultiSelect(prompt, choices, selected, this.renderer.renderHelp(question)) + : await this.terminal.readText(selectable ? textChoicePrompt(prompt, choices, selected) : prompt); + if (selectable && input.kind === 'value' && !input.value.trim()) { + input = { kind: 'value', value: selected.join(',') || 'none' }; + } + const selectionTokens = selectable && input.kind === 'value' + ? input.value.split(',').map(value => value.trim()).filter(Boolean) : []; + const hasRetry = selectionTokens.some(value => value.toLowerCase() === 'retry'); + if (state.question.kind === 'project-select' && input.kind === 'value' + && selectionTokens.some(value => value.toLowerCase() === 'manual') && !hasRetry) { + const manual = await this.terminal.readText('Enter additional Project numbers or GitHub URLs, comma-separated (empty adds none): '); + input = manual.kind === 'value' + ? { kind: 'value', value: [selectionTokens.filter(value => value.toLowerCase() !== 'manual').join(','), manual.value] + .filter(value => value && value !== 'none').join(',') || 'none' } : manual; + } + if (input.kind === 'value' && input.value.trim() === '?') { + this.renderer.showHelp(state.question); + continue; + } + if (input.kind === 'value' && input.value.trim().toLowerCase() === ':back') { + pendingProjectSelection = undefined; + state = transitionSetupQuestionnaire(state, { kind: 'back' }, currentContext); + continue; + } + const kind = state.question.id === 'projects.ids' ? 'projects' + : state.question.id === 'pullRequestApproval.testChecks' ? 'checks' : undefined; + if (kind && input.kind === 'value' && (input.value.trim().toLowerCase() === 'r' + || hasRetry)) { + if (kind === 'projects') pendingProjectSelection = selectionTokens.filter(value => !['retry', 'r'].includes(value.toLowerCase())); + if (!state.question.discoveryRetryRemaining || !discoveryRefresh) { + this.renderer.showValidation('No discovery retries remain. Use the manual option or continue.'); + continue; + } + const refreshed = await discoveryRefresh.refresh(kind); + if (refreshed) { + currentContext = refreshed; + state = refreshSetupQuestionnaireQuestion(state, currentContext); + } + continue; + } + pendingProjectSelection = undefined; + state = transitionSetupQuestionnaire(state, toEvent(input), currentContext); } if (state.terminal === 'cancelled') this.renderer.showCancelled(); return state; } } +function choicesForQuestion(question: SetupQuestion): readonly string[] { + return question.kind === 'project-select' + ? [...(question.projectCandidates ?? []).map(candidate => `${candidate.number} — ${safeTerminalChoiceText(candidate.title)} (${safeTerminalChoiceText(candidate.url)})`), + 'manual — Enter Project number or URL', + ...(question.discoveryRetryRemaining ? ['retry — Retry GitHub Project discovery'] : [])] + : question.choices ?? []; +} + +function textChoicePrompt(prompt: string, choices: readonly string[], selected: readonly string[]): string { + return [prompt, 'Available IDs:', ...choices.map(choice => ` ${safeTerminalChoiceText(choice)}`), + `Current selection: ${safeTerminalChoiceText(selected.join(', ') || 'none')}`, + 'Enter IDs shown before “—”, separated by commas; use manual or retry when offered, none to clear, or Enter to keep the default: ', + ].join('\n'); +} + function parseSelectedDefaults(value: string | number | boolean): readonly string[] { return typeof value === 'string' ? value.split(',').map(item => item.trim()).filter(Boolean) : []; } diff --git a/src/application/usecases/setup/setup_wizard_use_case.ts b/src/application/usecases/setup/setup_wizard_use_case.ts index 75d141133..3ba6ec4bb 100644 --- a/src/application/usecases/setup/setup_wizard_use_case.ts +++ b/src/application/usecases/setup/setup_wizard_use_case.ts @@ -10,6 +10,7 @@ import type { } from '../../ports/setup_wizard_ports'; import { ApplicationError } from '../../errors/application_error'; import type { SetupConfiguration, SetupPlan, SetupRemoteConfiguration } from '../../../domain/setup'; +import type { SetupQuestion, SetupQuestionnaireContext } from '../../../domain/setup_questionnaire'; import { buildSetupCredentialRequirements, buildSetupRepositoryVariables, @@ -27,11 +28,17 @@ import { createSetupReviewState, enterSetupConfirmation, finishSetupQuestionnaire, + setupQuestionIdsForGroup, + setupBasicSkippedQuestionIds, + setupQuestionContentInventory, } from '../../policies/setup_questionnaire_policy'; import { cloneSetupConfiguration } from '../../policies/setup_configuration_clone_policy'; import { resolveStaticSetupDoctorCatalog } from '../../policies/setup_doctor_message_catalog'; import { DEFAULT_PULL_REQUEST_APPROVAL_POLICY } from '../../../domain/pull_request_approval_policy'; import type { SetupApprovalReadinessPort } from '../../ports/setup_approval_readiness_port'; +import type { SetupApprovalCheckDiscoveryPort } from '../../ports/setup_approval_check_discovery_port'; +import type { SetupProjectDiscoveryPort } from '../../ports/setup_project_discovery_port'; +import { validateDiscoveredProjectStatuses } from '../../policies/setup_project_selection_policy'; import type { DoctorCheck } from '../../../domain/setup'; export interface SetupWizardRequest { @@ -40,11 +47,19 @@ export interface SetupWizardRequest { skipRepositoryVariables?: boolean; skipRepositorySecrets?: boolean; previewOnly?: boolean; + presentationMode?: 'basic' | 'custom'; + developmentBranchObservedLocally?: boolean; + /** Internal stable presentation snapshot across plan revisions. */ + basicSkippedQuestionIds?: readonly string[]; + reviewedGroups?: readonly SetupQuestion['stateId'][]; remoteTarget?: { owner: string; repository: string; token: string; }; + permissionIntent?: { draft: SetupConfiguration; answeredQuestionIds: readonly string[]; projectsWanted?: boolean }; + /** Internal same-run plan correction. No credential or approval is persisted here. */ + revision?: { group: SetupQuestion['stateId']; answeredQuestionIds: readonly string[] }; } export type SetupWizardResult = @@ -86,33 +101,19 @@ export interface SetupWizardDependencies { remoteConfiguration?: SetupRemoteConfigurationReadPort; mergeQueueReadiness?: SetupMergeQueueReadinessPort; approvalReadiness?: SetupApprovalReadinessPort; + approvalCheckDiscovery?: SetupApprovalCheckDiscoveryPort; + projectDiscovery?: SetupProjectDiscoveryPort; } export class SetupWizardUseCase { constructor(private readonly dependencies: SetupWizardDependencies) {} async execute(request: SetupWizardRequest): Promise { - const effectiveOverrides = request.mode === 'non-interactive' - && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined - ? { - ...request.overrides, - repositoryAgentGuidance: { - ...request.overrides?.repositoryAgentGuidance, - agentsPointer: 'create-if-missing' as const, - }, - } - : request.overrides; - const defaults = mergeSetupConfiguration( - mergeSetupConfiguration(createDefaultSetupConfiguration(), { pullRequestApproval: DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), - { - ...effectiveOverrides, - ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), - ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), - }, - ); - if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { - defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; - } + const defaults = buildInitialSetupConfiguration(request); + const effectiveOverrides = request.overrides; + const initial = request.permissionIntent ? cloneSetupConfiguration(request.permissionIntent.draft) : defaults; + const basicSkippedQuestionIds = request.presentationMode === 'basic' + ? request.basicSkippedQuestionIds ?? setupBasicSkippedQuestionIds(initial) : []; let remoteConfiguration: SetupRemoteConfiguration | undefined; if (request.remoteTarget) { try { @@ -125,21 +126,81 @@ export class SetupWizardUseCase { remoteConfiguration = unavailableRemoteConfiguration(); } } - const defaultValidationErrors = validateSetupConfiguration(defaults, { allowIncompleteApproval: true }); + const projectOwnerType = remoteConfiguration?.ownerType ?? 'Unknown'; + const explicitMainBranch = request.overrides?.repository?.mainBranch !== undefined; + if (!explicitMainBranch && remoteConfiguration?.defaultBranch) { + initial.repository.mainBranch = remoteConfiguration.defaultBranch; + } + const defaultValidationErrors = validateSetupConfiguration(initial, { allowIncompleteApproval: true }); if (defaultValidationErrors.length > 0) { throw new ApplicationError( 'configuration.invalid', `Invalid setup configuration:\n${defaultValidationErrors.map((error) => `- ${error}`).join('\n')}`, ); } - const context = { + let approvalDiscovery = request.mode === 'interactive' && initial.pullRequestApproval.mode !== 'off' + && request.remoteTarget && this.dependencies.approvalCheckDiscovery + ? await this.dependencies.approvalCheckDiscovery.discover( + request.remoteTarget.owner, request.remoteTarget.repository, request.remoteTarget.token, initial.repository.developmentBranch, + ).catch(() => ({ status: 'unavailable' as const, candidates: [], truncated: false })) : undefined; + let projectDiscovery = request.mode === 'interactive' + && (request.permissionIntent?.projectsWanted !== false || request.revision?.group === 'projects') + && request.remoteTarget && this.dependencies.projectDiscovery + ? await this.dependencies.projectDiscovery.discover( + request.remoteTarget.owner, projectOwnerType, request.remoteTarget.token, + ).catch(() => ({ status: 'unavailable' as const, candidates: [] })) : undefined; + let context: SetupQuestionnaireContext = { ...(remoteConfiguration ? { remote: remoteConfiguration } : {}), - variableNames: buildSetupRepositoryVariables(defaults).map((variable) => variable.name), - secretNames: buildSetupCredentialRequirements(defaults).map((requirement) => requirement.name), + branchSources: { main: explicitMainBranch ? 'configuration' : remoteConfiguration?.defaultBranch ? 'github' : 'default', + development: request.overrides?.repository?.developmentBranch !== undefined ? 'configuration' + : request.developmentBranchObservedLocally ? 'local' : 'default' }, + variableNames: buildSetupRepositoryVariables(initial).map((variable) => variable.name), + secretNames: buildSetupCredentialRequirements(initial).map((requirement) => requirement.name), + ...(request.revision ? { skipQuestionIds: [...new Set([ + ...request.revision.answeredQuestionIds, + ...basicSkippedQuestionIds, + ])].filter(id => !setupQuestionIdsForGroup(request.revision!.group).includes(id)), + projectsWanted: request.revision.group === 'projects' || Boolean(initial.projects.ids.trim()) } : {}), + ...(!request.revision ? { skipQuestionIds: [...new Set([ + ...(request.permissionIntent?.answeredQuestionIds ?? []), + ...(request.permissionIntent?.projectsWanted === false ? ['projects.ids'] : []), + ...basicSkippedQuestionIds, + ])], ...(request.permissionIntent ? { projectsWanted: request.permissionIntent.projectsWanted } : {}) } : {}), + ...(approvalDiscovery ? { approvalCheckCandidates: approvalDiscovery.candidates, + approvalCheckDiscoveryStatus: approvalDiscovery.status, + approvalCheckDiscoveryTruncated: approvalDiscovery.truncated } : {}), + ...(projectDiscovery ? { projectDiscovery } : {}), + ...(request.remoteTarget ? { projectOwner: request.remoteTarget.owner } : {}), + discoveryRetryRemaining: { checks: approvalDiscovery ? 2 : 0, + projects: projectDiscovery && projectDiscovery.status !== 'unsupported' ? 2 : 0 }, + }; + const discoveryRefresh = { + refresh: async (kind: 'checks' | 'projects'): Promise => { + const target = request.remoteTarget; + // This context always owns both retry budgets; the selected adapter was present when its budget was issued. + const remaining = context.discoveryRetryRemaining![kind]; + if (!target || remaining <= 0) return undefined; + if (kind === 'checks') { + approvalDiscovery = await this.dependencies.approvalCheckDiscovery!.discover( + target.owner, target.repository, target.token, initial.repository.developmentBranch, + ).catch(() => ({ status: 'unavailable' as const, candidates: [], truncated: false })); + context = { ...context, approvalCheckCandidates: approvalDiscovery.candidates, + approvalCheckDiscoveryStatus: approvalDiscovery.status, + approvalCheckDiscoveryTruncated: approvalDiscovery.truncated, + discoveryRetryRemaining: { ...context.discoveryRetryRemaining!, checks: remaining - 1 } }; + } else { + projectDiscovery = await this.dependencies.projectDiscovery!.discover( + target.owner, projectOwnerType, target.token, + ).catch(() => ({ status: 'unavailable' as const, candidates: [] })); + context = { ...context, projectDiscovery, + discoveryRetryRemaining: { ...context.discoveryRetryRemaining!, projects: remaining - 1 } }; + } + return context; + }, }; const questionnaire = request.mode === 'interactive' - ? await this.collectInteractive(defaults, context) - : createSetupReviewState(defaults); + ? await this.collectInteractive(initial, context, discoveryRefresh) + : createSetupReviewState(initial); if (questionnaire.terminal === 'cancelled') { return { status: 'cancelled', @@ -156,7 +217,10 @@ export class SetupWizardUseCase { // default must not outlive an explicit decision to disable PR automation. collectedConfiguration.pullRequestApproval = { ...collectedConfiguration.pullRequestApproval, mode: 'off' }; } - const validationErrors = validateSetupConfiguration(collectedConfiguration, { allowIncompleteApproval: request.previewOnly === true }); + const validationErrors = [ + ...validateSetupConfiguration(collectedConfiguration, { allowIncompleteApproval: request.previewOnly === true }), + ...validateDiscoveredProjectStatuses(collectedConfiguration, projectDiscovery), + ]; if (validationErrors.length > 0) { throw new ApplicationError( 'configuration.invalid', @@ -262,9 +326,28 @@ export class SetupWizardUseCase { } } const plan = buildSetupPlan(configuration, readiness, approvalReadiness); + if (basicSkippedQuestionIds.length) { + const byGroup = new Map(); + for (const item of setupQuestionContentInventory()) { + if (basicSkippedQuestionIds.includes(item.id) && !request.reviewedGroups?.includes(item.stateId) + && request.revision?.group !== item.stateId) byGroup.set(item.stateId, (byGroup.get(item.stateId) ?? 0) + 1); + } + plan.presentationDefaults = [...byGroup].map(([group, count]) => ({ group, count })); + } this.dependencies.planPresenter.present(plan); const confirmation = enterSetupConfirmation(questionnaire); const decision = await this.dependencies.confirmation.confirm(plan); + if (decision.kind === 'revise') { + if (request.mode !== 'interactive') throw new ApplicationError('configuration.invalid', 'Plan editing requires interactive setup.'); + const answeredQuestionIds = [...new Set([ + ...(request.revision?.answeredQuestionIds ?? []), + ...(request.permissionIntent?.answeredQuestionIds ?? []), + ...(questionnaire.answeredQuestionIds ?? []), + ])]; + return this.execute({ ...request, overrides: cloneSetupConfiguration(configuration), permissionIntent: undefined, + basicSkippedQuestionIds, reviewedGroups: [...new Set([...(request.reviewedGroups ?? []), decision.group])], + revision: { group: decision.group, answeredQuestionIds } }); + } const completed = finishSetupQuestionnaire(confirmation, decision.kind === 'approved'); if (completed.terminal === 'cancelled') { return { @@ -286,14 +369,41 @@ export class SetupWizardUseCase { private collectInteractive( defaults: SetupConfiguration, context: Parameters['collect']>[1], + discoveryRefresh?: Parameters['collect']>[2], ) { if (!this.dependencies.collector) { throw new ApplicationError('configuration.invalid', 'Interactive setup requires a questionnaire collector.'); } - return this.dependencies.collector.collect(createSetupQuestionnaire(defaults, context), context); + return this.dependencies.collector.collect(createSetupQuestionnaire(defaults, context), context, discoveryRefresh); } } +export function buildInitialSetupConfiguration(request: Pick): SetupConfiguration { + const effectiveOverrides = request.mode === 'non-interactive' + && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined + ? { + ...request.overrides, + repositoryAgentGuidance: { + ...request.overrides?.repositoryAgentGuidance, + agentsPointer: 'create-if-missing' as const, + }, + } + : request.overrides; + const defaults = mergeSetupConfiguration( + mergeSetupConfiguration(createDefaultSetupConfiguration(), { pullRequestApproval: DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), + { + ...effectiveOverrides, + ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), + ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), + }, + ); + if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { + defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; + } + return defaults; +} + /** An unavailable read is explicit, never an authoritative empty inventory. */ function unavailableRemoteConfiguration(): SetupRemoteConfiguration { return { diff --git a/src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts b/src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts new file mode 100644 index 000000000..720b73286 --- /dev/null +++ b/src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts @@ -0,0 +1,18 @@ +import type { SetupGithubIdentity, SetupGithubIdentityQueryPort } from '../../ports/setup_pat_identity_ports'; +import { ApplicationError } from '../../errors/application_error'; + +/** Binds a guided runtime PAT to the bot account chosen before token entry. */ +export class VerifyGuidedWorkflowPatIdentityUseCase { + constructor(private readonly identities: SetupGithubIdentityQueryPort) {} + + async execute(expected: SetupGithubIdentity, workflowToken: string): Promise { + const actual = await this.identities.identify(workflowToken); + if (actual.id !== expected.id) { + throw new ApplicationError( + 'authorization.credential-invalid', + `The workflow PAT belongs to @${actual.login}, not the selected bot @${expected.login}. No Secret was written. Delete the unintended PAT in GitHub and create one as @${expected.login}.`, + ); + } + return expected; + } +} diff --git a/src/application/usecases/setup/verify_setup_pat_bootstrap_use_case.ts b/src/application/usecases/setup/verify_setup_pat_bootstrap_use_case.ts new file mode 100644 index 000000000..a2398ac46 --- /dev/null +++ b/src/application/usecases/setup/verify_setup_pat_bootstrap_use_case.ts @@ -0,0 +1,48 @@ +import type { SetupTokenPermissionReport, SetupTokenPermissionRequirement } from '../../../domain/setup_token_permissions'; +import { ApplicationError } from '../../errors/application_error'; +import { buildSetupPatCreationUrl } from '../../policies/setup_pat_creation_url_policy'; +import type { SetupTokenPermissionAuditPort, SetupTokenPermissionPresenterPort } from '../../ports/setup_token_permission_ports'; + +export interface VerifySetupPatBootstrapRequest { + readonly owner: string; + readonly repository: string; + readonly token: string; + readonly requirements: readonly SetupTokenPermissionRequirement[]; + readonly guided: boolean; +} + +export interface VerifySetupPatBootstrapPorts { + readonly permissions: SetupTokenPermissionAuditPort; + readonly presenter: SetupTokenPermissionPresenterPort; + confirmUnverifiable(report: SetupTokenPermissionReport): Promise; + confirmAccount(account?: string): Promise; + showCorrectedLink(url: string): void; +} + +/** Initial read-only gate shared by terminal and browser setup presentations. */ +export class VerifySetupPatBootstrapUseCase { + constructor(private readonly ports: VerifySetupPatBootstrapPorts) {} + + async execute(request: VerifySetupPatBootstrapRequest): Promise { + const report = await this.ports.permissions.inspect({ + role: 'setup', owner: request.owner, repository: request.repository, + token: request.token, requirements: request.requirements, + }); + this.ports.presenter.showReport(report); + const accepted = report.ready + || (report.confirmationRequired && await this.ports.confirmUnverifiable(report)); + if (!accepted || report.identityStatus !== 'valid') { + if (request.guided) this.ports.showCorrectedLink(buildSetupPatCreationUrl({ + role: 'setup', owner: request.owner, repository: request.repository, + expiresIn: 1, requirements: request.requirements, + })); + throw new ApplicationError('authorization.credential-invalid', + 'The setup PAT has missing or unconfirmed required access. Grant or explicitly confirm the permissions shown above and retry.'); + } + if (!await this.ports.confirmAccount(report.account)) { + throw new ApplicationError('authorization.credential-invalid', + 'The setup PAT belongs to an unintended account. Revoke it in GitHub and retry with the correct account.'); + } + return report.account; + } +} diff --git a/src/application/usecases/setup/verify_web_setup_apply_use_case.ts b/src/application/usecases/setup/verify_web_setup_apply_use_case.ts new file mode 100644 index 000000000..c11fde3e9 --- /dev/null +++ b/src/application/usecases/setup/verify_web_setup_apply_use_case.ts @@ -0,0 +1,89 @@ +import type { SetupConfiguration, SetupRemoteConfiguration } from '../../../domain/setup'; +import { ApplicationError } from '../../errors/application_error'; +import { SetupInteractionCancelledError } from '../../errors/setup_interaction_cancelled_error'; +import { sameSetupRemoteFacts } from '../../policies/setup_remote_facts_policy'; +import type { SetupFinalPermissionAuditPort, SetupRemoteConfigurationReadPort } from '../../ports/setup_wizard_ports'; + +export interface WebSetupRepositoryFacts { + readonly owner: string; + readonly repository: string; + readonly checkoutRoot: string; + readonly branch: string; + readonly head: string; +} + +export interface VerifyWebSetupApplyRequest { + readonly repository: WebSetupRepositoryFacts; + readonly selectedFiles: readonly string[]; + readonly fileSnapshot: Readonly>; + readonly approvedRemote: Readonly; + readonly configuration: Readonly; + readonly setupToken: string; +} + +export interface VerifyWebSetupApplyPorts { + confirm(): Promise<'apply' | 'stop' | undefined>; + readRepositoryFacts(): WebSetupRepositoryFacts | undefined; + fileSnapshotMatches(root: string, files: readonly string[], snapshot: Readonly>): boolean; + remote: SetupRemoteConfigurationReadPort; + permissionAudit: SetupFinalPermissionAuditPort; + sessionState(): 'active' | 'cancelled' | 'ended'; +} + +/** Authorizes one web Apply against the facts the operator actually reviewed. */ +export class VerifyWebSetupApplyUseCase { + constructor(private readonly ports: VerifyWebSetupApplyPorts) {} + + async execute(request: VerifyWebSetupApplyRequest): Promise<'approved' | 'declined' | 'cancelled'> { + const decision = await this.ports.confirm(); + if (decision === undefined) return 'cancelled'; + if (decision === 'stop') return 'declined'; + this.assertActive(); + this.assertLocalSnapshot(request); + + let remote = await this.ports.remote.inspect(request.repository.owner, request.repository.repository, request.setupToken); + this.assertActive(); + let credentialHealthWorkflow: 'installed' | 'missing' | 'unavailable' = 'unavailable'; + try { + credentialHealthWorkflow = await this.ports.remote.inspectCredentialHealthWorkflow?.( + request.repository.owner, request.repository.repository, request.setupToken, request.configuration.repository.mainBranch, + ) ?? 'unavailable'; + } catch { /* Unknown selected-ref state must not inherit a provisional value. */ } + this.assertActive(); + remote = { ...remote, credentialHealthWorkflow }; + if (!sameSetupRemoteFacts(remote, request.approvedRemote)) { + throw new ApplicationError('configuration.invalid', 'GitHub repository facts changed since plan review. No mutation started; restart and review a new plan.'); + } + const audit = await this.ports.permissionAudit.audit(request.configuration, remote); + this.assertActive(); + if (audit.status === 'blocked') { + throw new ApplicationError('authorization.credential-invalid', 'Setup PAT access changed since plan review. No mutation started; correct the PAT and review a new plan.'); + } + // The remote reads above can take time. Close that window before the caller applies. + this.assertLocalSnapshot(request); + return 'approved'; + } + + private assertLocalSnapshot(request: VerifyWebSetupApplyRequest): void { + const current = this.ports.readRepositoryFacts(); + const expected = request.repository; + if (!current || current.owner !== expected.owner || current.repository !== expected.repository + || current.checkoutRoot !== expected.checkoutRoot || current.branch !== expected.branch + || current.head !== expected.head) { + throw new ApplicationError('configuration.invalid', 'The repository identity changed during setup. No mutation started; restart and review a new plan.'); + } + if (!this.ports.fileSnapshotMatches(expected.checkoutRoot, request.selectedFiles, request.fileSnapshot)) { + throw new ApplicationError('configuration.invalid', 'Selected repository files changed since plan review. No mutation started; restart and review a new plan.'); + } + } + + private assertActive(): void { + const state = this.ports.sessionState(); + if (state === 'cancelled') { + throw new SetupInteractionCancelledError(); + } + if (state === 'ended') { + throw new ApplicationError('configuration.invalid', 'The local setup session expired during final checks. No mutation started; start a new run and review a fresh plan.'); + } + } +} diff --git a/src/architecture/__tests__/setup_doctor_boundaries.test.ts b/src/architecture/__tests__/setup_doctor_boundaries.test.ts index 974957015..86512f6f2 100644 --- a/src/architecture/__tests__/setup_doctor_boundaries.test.ts +++ b/src/architecture/__tests__/setup_doctor_boundaries.test.ts @@ -36,12 +36,22 @@ describe('setup and doctor architecture boundaries', () => { 'src/application/policies/merge_queue_message_catalog.ts', 'src/application/policies/setup_doctor_message_catalog.ts', 'src/application/policies/setup_doctor_report_policy.ts', + 'src/application/policies/setup_journey_policy.ts', + 'src/application/policies/setup_permission_summary_policy.ts', ]) { const source = read(file); expect(source).not.toMatch(/from ['"]node:|\/cli\/|\/infrastructure\/|octokit|Execution/); } }); + it('keeps setup journey decisions in the application and terminal rendering in the CLI', () => { + const journey = read('src/application/usecases/setup/setup_journey_use_case.ts'); + const renderer = read('src/cli/setup_journey_presenter.ts'); + expect(journey).not.toMatch(/from ['"]node:|\/cli\/|\/infrastructure\/|console\.|process\./u); + expect(renderer).toContain('renderBox('); + expect(renderer).not.toMatch(/buildSetupPatCreationUrl|buildWorkflowPatPermissionRequirements/u); + }); + it('resolves one doctor catalog and reuses it through readiness and presentation', () => { const doctor = read('src/application/usecases/setup/doctor_use_case.ts'); const command = read('src/cli/commands/doctor.ts'); diff --git a/src/architecture/__tests__/web_setup_boundaries.test.ts b/src/architecture/__tests__/web_setup_boundaries.test.ts new file mode 100644 index 000000000..484d46535 --- /dev/null +++ b/src/architecture/__tests__/web_setup_boundaries.test.ts @@ -0,0 +1,162 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; +import ts from 'typescript'; + +const root = resolve(__dirname, '..', '..', '..'); + +function sources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name); + return entry.isDirectory() ? sources(path) : entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : []; + }); +} + +function browserSources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name); + return entry.isDirectory() ? browserSources(path) : /\.(svelte|ts)$/.test(entry.name) ? [path] : []; + }); +} + +function moduleImports(path: string, text = readFileSync(path, 'utf8')): Array<{ specifier: string; typeOnly: boolean }> { + const raw = text; + const source = path.endsWith('.svelte') + ? [...raw.matchAll(/]*>([\s\S]*?)<\/script>/g)].map(match => match[1]).join('\n') + : raw; + const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const dependencies: Array<{ specifier: string; typeOnly: boolean }> = []; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const named = node.importClause?.namedBindings; + dependencies.push({ specifier: node.moduleSpecifier.text, + typeOnly: node.importClause?.isTypeOnly === true || (named && ts.isNamedImports(named) + && named.elements.length > 0 && named.elements.every(element => element.isTypeOnly)) === true }); + return; + } + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const named = node.exportClause; + dependencies.push({ specifier: node.moduleSpecifier.text, + typeOnly: node.isTypeOnly || (named && ts.isNamedExports(named) + && named.elements.length > 0 && named.elements.every(element => element.isTypeOnly)) === true }); + return; + } + if (ts.isCallExpression(node) && node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]) + && ((ts.isIdentifier(node.expression) && node.expression.text === 'require') + || node.expression.kind === ts.SyntaxKind.ImportKeyword)) { + dependencies.push({ specifier: node.arguments[0].text, typeOnly: false }); + } + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) + && ts.isStringLiteral(node.argument.literal)) { + dependencies.push({ specifier: node.argument.literal.text, typeOnly: true }); + } + ts.forEachChild(node, visit); + }; + visit(ast); + return dependencies; +} + +function localModule(from: string, specifier: string): string | undefined { + if (!specifier.startsWith('.')) return undefined; + const base = resolve(dirname(from), specifier); + return [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.svelte`, join(base, 'index.ts')] + .find(candidate => existsSync(candidate)); +} + +describe('local web setup architecture', () => { + test('dependency reader includes re-exports, require, dynamic import and type-only forms', () => { + expect(moduleImports('fixture.ts', ` + import { type Input } from './types'; + export * from './runtime'; + export { Adapter } from './adapter'; + export type { Contract } from './contract'; + export { type View } from './view'; + const runtime = require('./commonjs'); + const later = import('./lazy'); + type LazyType = import('./type-import').Shape; + `)).toEqual([ + { specifier: './types', typeOnly: true }, + { specifier: './runtime', typeOnly: false }, + { specifier: './adapter', typeOnly: false }, + { specifier: './contract', typeOnly: true }, + { specifier: './view', typeOnly: true }, + { specifier: './commonjs', typeOnly: false }, + { specifier: './lazy', typeOnly: false }, + { specifier: './type-import', typeOnly: true }, + ]); + }); + test('browser imports only redacted application contracts, as types, across the actual dependency graph', () => { + const browser = join(root, 'web', 'src'); + const contract = join(root, 'src', 'application', 'contracts', 'web_setup_view'); + for (const path of browserSources(browser)) { + for (const imported of moduleImports(path)) { + const target = imported.specifier.startsWith('.') ? resolve(dirname(path), imported.specifier) : ''; + if (target.startsWith(`${join(root, 'src')}${sep}`)) { + expect(imported.typeOnly).toBe(true); + expect(target).toBe(contract); + } + if (path.includes(`${sep}components${sep}`) || path.includes(`${sep}lib${sep}`)) { + expect(target).not.toContain(`${sep}session${sep}`); + } + } + } + }); + + test('new setup application decisions have no transitive path to CLI, infrastructure, or browser adapters', () => { + const useCases = ['prepare_setup_pat_intent_use_case', 'verify_setup_pat_bootstrap_use_case', + 'audit_configured_setup_pat_use_case', 'verify_web_setup_apply_use_case'] + .map(name => join(root, 'src', 'application', 'usecases', 'setup', `${name}.ts`)); + const visited = new Set(); + const traverse = (path: string): void => { + if (visited.has(path)) return; + visited.add(path); + for (const imported of moduleImports(path)) { + const target = localModule(path, imported.specifier); + if (!target) continue; + for (const forbidden of ['cli', 'infrastructure', 'actions', 'web']) { + expect(target.startsWith(`${join(root, 'src', forbidden)}${sep}`) || target.startsWith(`${join(root, forbidden)}${sep}`)).toBe(false); + } + traverse(target); + } + }; + for (const entry of useCases) traverse(entry); + expect(visited.size).toBeGreaterThan(useCases.length); + }); + test('domain and application never import browser, HTTP server, or terminal adapters', () => { + for (const path of [...sources(join(root, 'src', 'domain')), ...sources(join(root, 'src', 'application'))]) { + const text = readFileSync(path, 'utf8'); + expect(text).not.toMatch(/from ['"](?:svelte|vite|node:http|node:child_process|.*(?:web_setup_server|web_setup_adapters|setup_terminal_driver|setup_question_renderer))['"]/); + } + }); + + test('browser presenters cannot acquire transport, provider, or persistent-secret responsibilities', () => { + const browser = join(root, 'web', 'src'); + for (const path of browserSources(browser)) { + const ui = readFileSync(path, 'utf8'); + expect(ui).not.toMatch(/setup_token_permission_policy|github_identity|runLocalAction|setup_credentials_use_case|localStorage|sessionStorage/); + if (!path.endsWith(join('session', 'setupSession.ts'))) { + expect(ui).not.toMatch(/\bfetch\s*\(|\/api\/|X-Setup-Capability/); + } + if (path.endsWith('.svelte')) { + expect(ui).not.toMatch(/from ['"](?:node:|.*(?:infrastructure|cli\/commands|cli\/web_setup_server))/); + } + } + expect(readFileSync(join(browser, 'App.svelte'), 'utf8')).toContain('createSetupSession'); + expect(readFileSync(join(browser, 'App.svelte'), 'utf8')).toContain('void session.poll()'); + expect(readFileSync(join(browser, 'App.svelte'), 'utf8')).toContain('promptRevision={$session.view.promptRevision!}'); + expect(readFileSync(join(browser, 'components', 'PromptCard.svelte'), 'utf8')).toContain('CredentialPrompt'); + expect(readFileSync(join(browser, 'components', 'PromptCard.svelte'), 'utf8')).toContain('{#key promptRevision}'); + }); + + test('page shell and presenters remain small and styles have one explicit entrypoint', () => { + const browser = join(root, 'web', 'src'); + expect(readFileSync(join(browser, 'App.svelte'), 'utf8').split('\n').length).toBeLessThanOrEqual(100); + expect(readFileSync(join(browser, 'session', 'setupSession.ts'), 'utf8').split('\n').length).toBeLessThanOrEqual(180); + for (const path of browserSources(join(browser, 'components'))) { + expect(readFileSync(path, 'utf8').split('\n').length).toBeLessThanOrEqual(90); + } + const css = readFileSync(join(browser, 'style.css'), 'utf8'); + for (const layer of ['tokens', 'foundation', 'layout', 'controls', 'feedback', 'responsive']) { + expect(css).toContain(`@import './styles/${layer}.css'`); + } + }); +}); diff --git a/src/cli/__tests__/setup_apply_snapshot.test.ts b/src/cli/__tests__/setup_apply_snapshot.test.ts new file mode 100644 index 000000000..1f4915fbc --- /dev/null +++ b/src/cli/__tests__/setup_apply_snapshot.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { captureSetupApplySnapshot, setupApplySnapshotMatches } from '../setup_apply_snapshot'; +import { createDefaultSetupConfiguration } from '../../application/policies/setup_configuration_policy'; +import { buildSetupPlan, setupPlanGuardPaths } from '../../application/policies/setup_configuration_plan'; + +describe('web setup apply snapshot', () => { + let root: string; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'copilot-apply-snapshot-')); }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + test('detects creation and modification of selected files', () => { + const names = ['.github/workflows/copilot.yml']; + const first = captureSetupApplySnapshot(root, names); + expect(first[names[0]]).toBe('missing'); + mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(root, names[0]), 'original'); + expect(setupApplySnapshotMatches(root, names, first)).toBe(false); + const second = captureSetupApplySnapshot(root, names); + expect(setupApplySnapshotMatches(root, names, second)).toBe(true); + writeFileSync(join(root, names[0]), 'changed'); + expect(setupApplySnapshotMatches(root, names, second)).toBe(false); + }); + + test('rejects traversal and symlinked paths', () => { + expect(() => captureSetupApplySnapshot(root, ['../outside'])).toThrow('outside'); + mkdirSync(join(root, '.github')); + symlinkSync(tmpdir(), join(root, '.github', 'workflows')); + expect(() => captureSetupApplySnapshot(root, ['.github/workflows/copilot.yml'])).toThrow('symbolic link'); + }); + + test('rejects absolute paths and files too large to snapshot safely', () => { + expect(() => captureSetupApplySnapshot(root, [join(root, 'absolute.yml')])).toThrow('outside'); + writeFileSync(join(root, 'large.yml'), 'x'.repeat(5 * 1024 * 1024 + 1)); + expect(() => captureSetupApplySnapshot(root, ['large.yml'])).toThrow('Cannot safely snapshot'); + }); + + test('normalizes duplicate selected paths and remains stable when nothing changed', () => { + writeFileSync(join(root, 'a.yml'), 'stable'); + const snapshot = captureSetupApplySnapshot(root, ['a.yml', 'a.yml']); + expect(Object.keys(snapshot)).toEqual(['a.yml']); + expect(setupApplySnapshotMatches(root, ['a.yml'], snapshot)).toBe(true); + }); + + test('guards checkout destinations, not the package-source labels shown in the plan', () => { + const paths = setupPlanGuardPaths(buildSetupPlan(createDefaultSetupConfiguration())); + expect(paths).toContain('.github/workflows/copilot_issue.yml'); + expect(paths).toContain('.github/ISSUE_TEMPLATE/feature_request.yml'); + expect(paths).toContain('.github/pull_request_template.md'); + expect(paths).toContain('AGENTS.md'); + expect(paths).not.toContain('workflows/copilot_issue.yml'); + expect(paths).not.toContain('AGENTS.md (managed pointer only)'); + const snapshot = captureSetupApplySnapshot(root, paths); + mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(root, '.github', 'workflows', 'copilot_issue.yml'), 'edited after approval'); + expect(setupApplySnapshotMatches(root, paths, snapshot)).toBe(false); + }); + + test('also guards managed files that a configuration may retire', () => { + const plan = buildSetupPlan(createDefaultSetupConfiguration()); + const paths = setupPlanGuardPaths({ ...plan, selectedFiles: [] }); + expect(paths).toContain('.github/workflows/release_workflow.yml'); + expect(paths).toContain('.github/workflows/hotfix_workflow.yml'); + expect(paths).toContain('.github/ISSUE_TEMPLATE/release.yml'); + expect(paths).toContain('.github/ISSUE_TEMPLATE/config.yml'); + }); + + test.each([ + '.copilot/setup-manifest.json', + '.copilot/repository-profile.json', + '.copilot/AGENT_GUIDE.md', + '.agents/skills/copilot-repository-workflow/SKILL.md', + 'AGENTS.md', + ])('guards %s against drift even when repository guidance is disabled', name => { + const configuration = createDefaultSetupConfiguration(); + configuration.repositoryAgentGuidance = { ...configuration.repositoryAgentGuidance, enabled: false }; + const plan = buildSetupPlan(configuration); + expect(plan.selectedFiles).not.toContain(name); + const paths = setupPlanGuardPaths(plan); + expect(paths).toContain(name); + const file = join(root, name); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, 'reviewed'); + const reviewed = captureSetupApplySnapshot(root, paths); + writeFileSync(file, 'changed after approval'); + expect(setupApplySnapshotMatches(root, paths, reviewed)).toBe(false); + }); +}); diff --git a/src/cli/__tests__/setup_command_options.test.ts b/src/cli/__tests__/setup_command_options.test.ts new file mode 100644 index 000000000..61938b5fc --- /dev/null +++ b/src/cli/__tests__/setup_command_options.test.ts @@ -0,0 +1,120 @@ +import { collectApprovalCheck, collectScope, collectSecret, loadSetupOverrides } from '../setup_command_options'; +import { mergeSetupOverrides } from '../../application/policies/merge_setup_overrides_policy'; +import { ISSUE_WORKFLOW_KINDS } from '../../domain/issue_workflow_profile'; + +describe('setup command option adapter', () => { + test('collects opaque Secrets without logging or altering their values', () => { + expect(collectSecret('PAT=a=b', { EXISTING: 'keep' })).toEqual({ PAT: 'a=b', EXISTING: 'keep' }); + expect(collectApprovalCheck('CI|42|ci.yml', ['other'])).toEqual(['other', 'CI|42|ci.yml']); + }); + + test.each(['PAT', '=value', 'pat=value', 'PAT='])('rejects malformed Secret flag %s', value => { + expect(() => collectSecret(value, {})).toThrow('--secret'); + }); + + test('collects and normalizes per-resource scopes', () => { + expect(collectScope('PAT=ORGANIZATION', { EXISTING: 'repository' })).toEqual({ PAT: 'organization', EXISTING: 'repository' }); + }); + + test.each(['PAT', '=organization', 'pat=repository', 'PAT=elsewhere'])('rejects malformed scope override %s', value => { + expect(() => collectScope(value, {})).toThrow('Scope overrides'); + }); + + test('maps bounded features and issue workflows', () => { + const selected = loadSetupOverrides({ features: 'issues,pullRequests', issueWorkflows: 'feature,bugfix' }); + expect(selected.features).toMatchObject({ issues: true, pullRequests: true, release: false }); + expect(selected.issueWorkflows?.enabled).toEqual(['feature', 'bugfix']); + const all = loadSetupOverrides({ features: 'all', issueWorkflows: 'all' }); + expect(Object.values(all.features ?? {}).every(Boolean)).toBe(true); + expect(all.issueWorkflows?.enabled).toEqual(ISSUE_WORKFLOW_KINDS); + }); + + test.each([ + [{ features: 'unknown' }, 'Unknown setup feature'], + [{ issueWorkflows: 'unknown' }, 'Unknown issue workflow'], + [{ issueWorkflows: 'feature,feature' }, 'cannot contain duplicates'], + [{ agent: 'unknown' }, '--agent'], + [{ agentGuidance: 'unknown' }, '--agent-guidance'], + [{ prApprovalMode: 'unknown' }, '--pr-approval-mode'], + [{ variablesScope: 'unknown' }, '--variables-scope'], + [{ secretsScope: 'unknown' }, '--secrets-scope'], + [{ variablesVisibility: 'unknown' }, '--variables-visibility'], + [{ secretsVisibility: 'unknown' }, '--secrets-visibility'], + ] as const)('rejects invalid option %j', (options, message) => { + expect(() => loadSetupOverrides(options)).toThrow(message); + }); + + test('maps agent, guidance, approval and storage flags into typed overrides', () => { + const result = loadSetupOverrides({ + agent: 'cursor', agentGuidance: 'disabled', prApprovalMode: 'guarded', + prApprovalCheck: ['CI|42|ci.yml'], prApprovalCoverageCheck: 'coverage', prApprovalAttestProducer: true, + variablesScope: 'organization', variablesVisibility: 'private', variableScope: { AGENT_MODEL: 'repository' }, + secretsScope: 'organization', secretsVisibility: 'selected', secretScope: { PAT: 'repository' }, + }); + expect(result.agents?.planner?.provider).toBe('cursor'); + expect(result.repositoryAgentGuidance).toEqual({ agentsPointer: 'disabled', enabled: false }); + expect(result.pullRequestApproval).toMatchObject({ + mode: 'guarded', producerAttested: true, testChecks: [{ name: 'CI', sourceAppId: 42, workflowName: 'ci.yml' }], + coverage: { mode: 'check', checkName: 'coverage' }, + }); + expect(result.storage).toMatchObject({ + variables: { defaultScope: 'organization', organizationVisibility: 'private', overrides: { AGENT_MODEL: 'repository' } }, + secrets: { defaultScope: 'organization', organizationVisibility: 'selected', overrides: { PAT: 'repository' } }, + }); + }); + + test('keeps unspecified approval mode and resource overrides absent', () => { + const result = loadSetupOverrides({ + prApprovalCheck: ['CI|42|ci.yml'], + variablesScope: 'repository', secretsScope: 'repository', + }); + expect(result.pullRequestApproval?.mode).toBeUndefined(); + expect(result.pullRequestApproval?.testChecks).toEqual([{ name: 'CI', sourceAppId: 42, workflowName: 'ci.yml' }]); + expect(result.storage?.variables?.overrides).toEqual({}); + expect(result.storage?.secrets?.overrides).toEqual({}); + }); +}); + +describe('setup override merge policy', () => { + test('CLI storage scope, visibility and per-name flags override conflicting file values', () => { + const merged = mergeSetupOverrides({ storage: { + secrets: { defaultScope: 'repository', organizationVisibility: 'private', overrides: { PAT: 'repository' } }, + variables: { defaultScope: 'repository', organizationVisibility: 'private', overrides: { API_KEY: 'repository' } }, + } }, { storage: { + secrets: { defaultScope: 'organization', organizationVisibility: 'selected', overrides: { PAT: 'organization' } }, + variables: { defaultScope: 'organization', organizationVisibility: 'all', overrides: { API_KEY: 'organization' } }, + } }); + expect(merged.storage?.secrets).toMatchObject({ defaultScope: 'organization', organizationVisibility: 'selected', overrides: { PAT: 'organization' } }); + expect(merged.storage?.variables).toMatchObject({ defaultScope: 'organization', organizationVisibility: 'all', overrides: { API_KEY: 'organization' } }); + }); + test('flag fields win while unrelated file-only fields survive at every nested boundary', () => { + const merged = mergeSetupOverrides({ + features: { issues: true }, agents: { planner: { provider: 'codex' } }, + repository: { mainBranch: 'master' }, ai: { bugbotSeverity: 'info' }, + pullRequestApproval: { mode: 'recommend', coverage: { mode: 'check', checkName: 'base' } }, + projects: { ids: '1' }, issueWorkflows: { enabled: ['feature'] }, + repositoryAgentGuidance: { enabled: true }, + storage: { secrets: { defaultScope: 'organization', overrides: { PAT: 'repository' } }, + variables: { defaultScope: 'repository', overrides: { OLD: 'organization' } } }, + }, { + features: { pullRequests: false }, agents: { fixer: { provider: 'cursor' } }, + repository: { developmentBranch: 'develop' }, ai: { bugbotEffort: 'high' }, + pullRequestApproval: { mode: 'guarded', coverage: { mode: 'check', checkName: 'flag' } }, + projects: { issueCreatedColumn: 'Todo' }, issueWorkflows: { enabled: ['bugfix'] }, + repositoryAgentGuidance: { agentsPointer: 'disabled' }, + storage: { secrets: { overrides: { BOT: 'organization' } }, variables: { overrides: { NEW: 'repository' } } }, + }); + expect(merged.features).toMatchObject({ issues: true, pullRequests: false }); + expect(merged.agents).toMatchObject({ planner: { provider: 'codex' }, fixer: { provider: 'cursor' } }); + expect(merged.repository).toMatchObject({ mainBranch: 'master', developmentBranch: 'develop' }); + expect(merged.ai).toMatchObject({ bugbotSeverity: 'info', bugbotEffort: 'high' }); + expect(merged.pullRequestApproval).toMatchObject({ mode: 'guarded', coverage: { mode: 'check', checkName: 'flag' } }); + expect(merged.projects).toMatchObject({ ids: '1', issueCreatedColumn: 'Todo' }); + expect(merged.issueWorkflows?.enabled).toEqual(['bugfix']); + expect(merged.repositoryAgentGuidance).toMatchObject({ enabled: true, agentsPointer: 'disabled' }); + expect(merged.storage).toMatchObject({ + secrets: { defaultScope: 'organization', overrides: { PAT: 'repository', BOT: 'organization' } }, + variables: { defaultScope: 'repository', overrides: { OLD: 'organization', NEW: 'repository' } }, + }); + }); +}); diff --git a/src/cli/__tests__/setup_journey_presenter.test.ts b/src/cli/__tests__/setup_journey_presenter.test.ts new file mode 100644 index 000000000..b010c2c8c --- /dev/null +++ b/src/cli/__tests__/setup_journey_presenter.test.ts @@ -0,0 +1,58 @@ +import { buildSetupJourneyView } from '../../application/policies/setup_journey_policy'; +import { renderSetupJourney } from '../setup_journey_presenter'; + +describe('setup journey presenter', () => { + it('shows semantic status without relying on color or icons', () => { + const output = renderSetupJourney(buildSetupJourneyView('owner/repo', 'setup-pat', false), 80); + expect(output).toContain('Stage 3/6 · Setup PAT'); + expect(output).toContain('Complete: Repository → Setup choices'); + expect(output).toContain('No changes have been applied.'); + }); + + it('keeps narrow output readable and distinguishes partial from complete', () => { + const partial = renderSetupJourney(buildSetupJourneyView('owner/repo', 'apply', true, 'partial'), 40); + expect(partial).toContain('Partial: changes may exist'); + expect(partial).not.toContain('No changes have been applied.'); + expect(partial.split('\n').every(line => line.length <= 42)).toBe(true); + const complete = renderSetupJourney(buildSetupJourneyView('owner/repo', 'apply', true, 'complete'), 80); + expect(complete).toContain('Complete: setup applied successfully.'); + }); + + it('states that dry-run applies no changes', () => { + const output = renderSetupJourney(buildSetupJourneyView('owner/repo', 'plan', false, 'dry-run'), 80); + expect(output).toContain('dry run only; no changes were applied'); + }); + + it('labels the second choice pass and stays readable at narrow width without color', () => { + const output = renderSetupJourney(buildSetupJourneyView('owner/repo', 'choices', false, undefined, 2), 48); + expect(output).toContain('Stage 2/6 · Setup choices · review pass 2'); + expect(output).toContain('Now: reviewing saved setup choices'); + expect(output).toContain('Next: Setup PAT'); + expect(output).toContain('No changes have been applied.'); + expect(output.split('\n').every(line => line.length <= 50)).toBe(true); + }); + + it('renders the active mutation state and the first-stage pending list', () => { + const applying = renderSetupJourney(buildSetupJourneyView('owner/repo', 'apply', true), 80); + expect(applying).toContain('changes may already exist'); + const starting = renderSetupJourney(buildSetupJourneyView('owner/repo', 'repository', false), 80); + expect(starting).toContain('Complete: none'); + expect(starting).toContain('Next: Setup choices'); + }); + + it('explains a pre-Apply credential-health mutation without claiming final Apply began', () => { + const validating = renderSetupJourney(buildSetupJourneyView('owner/repo', 'credentials', true), 95); + expect(validating).toContain('temporary GitHub workflow change may exist'); + expect(validating).not.toContain('Applying the approved plan'); + const partial = renderSetupJourney(buildSetupJourneyView('owner/repo', 'credentials', true, 'partial'), 95); + expect(partial).toContain('Partial: changes may exist'); + expect(partial).not.toContain('No changes have been applied'); + }); + + it('does not echo terminal control characters from a repository label', () => { + const output = renderSetupJourney(buildSetupJourneyView('owner/\u001b[31mrepo', 'choices', false), 80); + expect(output).not.toContain('\u001b[31m'); + expect(output).toContain('owner/?[31mrepo'); + expect(renderSetupJourney(buildSetupJourneyView('owner/\u007f\u0080repo', 'choices', false), 80)).toContain('owner/??repo'); + }); +}); diff --git a/src/cli/__tests__/setup_presenters.test.ts b/src/cli/__tests__/setup_presenters.test.ts index 2f07b517d..9bc820e0e 100644 --- a/src/cli/__tests__/setup_presenters.test.ts +++ b/src/cli/__tests__/setup_presenters.test.ts @@ -8,6 +8,7 @@ import { ConsoleSetupPlanPresenter, renderSetupPlan } from '../setup_plan_presen import { ConsoleSetupQuestionRenderer } from '../setup_question_renderer'; import { SetupWorkflowUpdatePromptAdapter } from '../setup_workflow_update_prompt_adapter'; import { resolveStaticSetupDoctorCatalog } from '../../application/policies/setup_doctor_message_catalog'; +import { setupEditableGroups } from '../../application/policies/setup_questionnaire_policy'; function terminal(results: readonly TerminalReadResult[]): jest.Mocked { let index = 0; @@ -43,6 +44,22 @@ describe('setup presenters and prompt-specific adapters', () => { log.mockRestore(); }); + it('distinguishes the first permission-intent pass from a deliberate second pass', () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + new ConsoleSetupQuestionRenderer('permission-intent').showIntroduction(); + expect(log.mock.calls.flat().join('\n')).toContain('later full wizard'); + log.mockClear(); + new ConsoleSetupQuestionRenderer('permission-intent', 2).showIntroduction(); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('Reviewing your setup choices again (pass 2)'); + expect(output).toContain('same setup run'); + expect(output).toContain('Press Enter to keep each answer'); + expect(output).toContain('return to the setup PAT permission review'); + expect(output).not.toContain('First, choose'); + } finally { log.mockRestore(); } + }); + it('renders every doctor presentation label in the resolved repository locale', () => { const catalog = resolveStaticSetupDoctorCatalog('es-ES'); const rendered = renderDoctorReport(buildDoctorReport([ @@ -131,6 +148,21 @@ describe('setup presenters and prompt-specific adapters', () => { kind: 'boolean', defaultValue: true, })).toContain('[Y]'); + expect(renderer.renderPrompt({ + stateId: 'projects', id: 'projects.ids', label: 'Projects', kind: 'project-select', defaultValue: '', + })).toContain('in text mode enter their URL numbers'); + expect(renderer.renderPrompt({ + stateId: 'capabilities', id: 'issueWorkflows.enabled', label: 'Issue workflows', + kind: 'multi-select', defaultValue: '', choices: ['feature — Feature'], + })).toContain('in text mode enter IDs'); + const help = renderer.renderHelp({ + stateId: 'agent-model-defaults', id: 'agents.findings.executable', + label: 'Validated executable for all tasks', kind: 'text', defaultValue: '', + }); + for (const heading of ['What:', 'When:', 'Where:', 'How:', 'Why:', 'Example:', 'Effect:', 'Verify:', 'Read more']) { + expect(help).toContain(heading); + } + expect(help).toContain('https://docs.page/vypdev/copilot/agents/cli-configuration'); const log = jest.spyOn(console, 'log').mockImplementation(); renderer.showIntroduction(); renderer.showState('capabilities'); @@ -140,6 +172,39 @@ describe('setup presenters and prompt-specific adapters', () => { log.mockRestore(); }); + it('shows full check identities, bounded discovery evidence, and Project Status warnings', () => { + const renderer = new ConsoleSetupQuestionRenderer(); + const coverage = renderer.renderPrompt({ stateId: 'pull-request-approval', id: 'pullRequestApproval.coverage.checkName', + label: 'Coverage check', kind: 'choice', defaultValue: 'coverage', choices: ['coverage', 'other'], + trustedProducers: [{ name: 'coverage', sourceAppId: 42, workflowName: 'CI' }] }); + expect(coverage).toContain('coverage — CI · App 42'); + const checks = renderer.renderPrompt({ stateId: 'pull-request-approval', id: 'pullRequestApproval.testChecks', + label: 'Trusted checks', kind: 'producer-select', defaultValue: '', discoveryStatus: 'observed', + discoveryTruncated: true, discoveryRetryRemaining: 1, + producerCandidates: [{ name: 'tests', sourceAppId: 42, workflowName: 'CI', conclusion: 'success', + headSha: 'abcdef123', runUrl: 'https://github.com/owner/repo/actions/runs/1', + requiredByRuleset: { branch: 'develop', sourceUrl: 'https://github.com/owner/repo/rules/1' } }] }); + expect(checks).toContain('tests · App 42 · CI · success · abcdef1'); + expect(checks).toContain('Required on develop by active ruleset'); + expect(checks).toContain('Only a bounded sample was inspected'); + expect(checks).toContain('Type r to retry GitHub discovery'); + const projects = renderer.renderPrompt({ stateId: 'projects', id: 'projects.ids', label: 'Projects', + kind: 'project-select', defaultValue: '', discoveryStatus: 'empty' }); + expect(projects).toContain('no open, accessible Projects'); + expect(projects).toContain('at most 30 open, accessible organization Projects'); + const status = renderer.renderPrompt({ stateId: 'projects', id: 'projects.issueCreatedColumn', label: 'Status', + kind: 'text', defaultValue: 'Todo', statusOptionState: 'unavailable', + projectStatusValues: [{ transition: 'issueCreated', value: 'Todo' }] }); + expect(status).toContain('Verify these exact Status values'); + expect(status).toContain('Status options could not be verified'); + expect(renderer.renderPrompt({ stateId: 'projects', id: 'projects.issueCreatedColumn', label: 'Status', + kind: 'text', defaultValue: 'Todo', statusOptionState: 'incompatible' })).toContain('no common Status values'); + const log = jest.spyOn(console, 'log').mockImplementation(); + renderer.showHelp({ stateId: 'projects', id: 'projects.ids', label: 'Projects', kind: 'project-select', defaultValue: '' }); + expect(log).toHaveBeenCalledWith(expect.stringContaining('About this setup choice')); + log.mockRestore(); + }); + it('distinguishes approval, decline, and interrupted confirmation', async () => { const input = terminal([ { kind: 'value', value: 'maybe' }, @@ -156,6 +221,38 @@ describe('setup presenters and prompt-specific adapters', () => { await expect(new DryRunSetupPlanConfirmation().confirm(plan)).resolves.toEqual({ kind: 'approved' }); }); + it('explains final Apply on ? and re-asks without approving it', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([{ kind: 'value', value: '?' }, { kind: 'value', value: 'no' }]); + await expect(new SetupPlanConfirmationAdapter(input, false).confirm(buildSetupPlan(createDefaultSetupConfiguration()))) + .resolves.toEqual({ kind: 'declined' }); + expect(input.readText).toHaveBeenCalledTimes(2); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('This is the final approval'); + expect(output).toContain('PATs created on GitHub are not deleted automatically'); + expect(output).toContain('https://docs.page/vypdev/copilot/how-to-use'); + } finally { log.mockRestore(); } + }); + + it('lists editable plan sections, rejects an invalid number, and returns the chosen group', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const plan = buildSetupPlan(createDefaultSetupConfiguration()); + const input = terminal([{ kind: 'value', value: ':edit' }, { kind: 'value', value: '99' }, + { kind: 'value', value: ':edit' }, { kind: 'value', value: '1' }]); + await expect(new SetupPlanConfirmationAdapter(input, false).confirm(plan)) + .resolves.toEqual({ kind: 'revise', group: setupEditableGroups(plan.configuration)[0] }); + expect(log.mock.calls.flat().join('\n')).toContain('Choose one of the listed section numbers'); + } finally { log.mockRestore(); } + }); + + it('cancels rather than applying when section selection is interrupted', async () => { + const input = terminal([{ kind: 'value', value: ':edit' }, { kind: 'end-of-input' }]); + await expect(new SetupPlanConfirmationAdapter(input, false).confirm(buildSetupPlan(createDefaultSetupConfiguration()))) + .resolves.toEqual({ kind: 'cancelled' }); + }); + it('keeps non-interactive credential values separate from questionnaire and plan state', async () => { const adapter = new SetupCredentialPromptAdapter(undefined, { PAT: 'workflow-token' }); const requirement = { name: 'PAT', kind: 'workflowPat' as const, description: 'Runtime token' }; @@ -237,6 +334,268 @@ describe('setup presenters and prompt-specific adapters', () => { log.mockRestore(); }); + it('guides setup PAT creation, confirms the authenticated account, and gives an honest cleanup reminder', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '' }, + { kind: 'value', value: 'setup-token' }, + { kind: 'value', value: '' }, + ]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new?expires_in=1'); + await expect(adapter.requestSetupPat()).resolves.toBe('setup-token'); + await expect(adapter.confirmGuidedSetupAccount('operator')).resolves.toBe(true); + adapter.showSetupPatCleanupReminder(); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('https://github.com/settings/personal-access-tokens/new?expires_in=1'); + expect(output).toContain('Provisional'); + expect(output).toContain('@operator'); + expect(output).toContain('not revoked automatically'); + expect(output).not.toContain('setup-token'); + expect(input.readSecret).toHaveBeenCalledTimes(1); + } finally { log.mockRestore(); } + }); + + it('keeps missing-terminal setup choices on the manual path', async () => { + const adapter = new SetupCredentialPromptAdapter(undefined, {}); + await expect(adapter.chooseSetupPatMethod()).resolves.toBe('manual'); + await expect(adapter.chooseSetupOwnerKind()).resolves.toBe('unknown'); + await expect(adapter.reviewSetupPatIntent()).resolves.toBe('manual'); + await expect(adapter.requestSetupPat()).resolves.toBeUndefined(); + await expect(adapter.confirmGuidedSetupAccount()).resolves.toBe(true); + }); + + it.each([ + ['1', 'Organization'], ['2', 'User'], ['3', 'unknown'], + ] as const)('requires an explicit owner-kind selection %s', async (selection, expected) => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([{ kind: 'value', value: '' }, { kind: 'value', value: selection }]); + await expect(new SetupCredentialPromptAdapter(input, {}).chooseSetupOwnerKind()).resolves.toBe(expected); + expect(input.readText).toHaveBeenCalledTimes(2); + } finally { log.mockRestore(); } + }); + + it('opens credential choice help with ? and then asks the same unanswered question', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([{ kind: 'value', value: '?' }, { kind: 'value', value: '2' }]); + await expect(new SetupCredentialPromptAdapter(input, {}).chooseSetupOwnerKind()).resolves.toBe('User'); + expect(input.readText).toHaveBeenCalledTimes(2); + expect(String(input.readText.mock.calls[0][0])).toContain('Type ? for more detail'); + expect(log.mock.calls.flat().join('\n')).toContain('owner/repository'); + expect(log.mock.calls.flat().join('\n')).toContain('https://docs.page/vypdev/copilot/authentication'); + } finally { log.mockRestore(); } + }); + + it('explains a bot login on ? without treating it as an account', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '1' }, { kind: 'value', value: '?' }, + { kind: 'value', value: 'vypbot' }, { kind: 'value', value: 'bot-token' }, + ]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', async login => ({ login, id: 123 })); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' })) + .resolves.toEqual({ name: 'PAT', value: 'bot-token' }); + expect(log.mock.calls.flat().join('\n')).toContain('numeric account ID'); + expect(input.readText).toHaveBeenCalledTimes(3); + } finally { log.mockRestore(); } + }); + + it('reviews intent explicitly, supports revision, and can fall back to manual input', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '1' }, + { kind: 'value', value: '2' }, + { kind: 'value', value: '4' }, + { kind: 'value', value: 'manual-token' }, + ]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + await expect(adapter.chooseSetupPatMethod()).resolves.toBe('guided'); + await expect(adapter.reviewSetupPatIntent()).resolves.toBe('revise'); + await expect(adapter.reviewSetupPatIntent()).resolves.toBe('manual'); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + adapter.useManualSetupPat(); + await expect(adapter.requestSetupPat()).resolves.toBe('manual-token'); + adapter.showSetupPatCleanupReminder(); + expect(adapter.usedGuidedSetupPat).toBe(false); + expect(log.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + } finally { log.mockRestore(); } + }); + + it('offers the full setup permission table as a review action', async () => { + const input = terminal([{ kind: 'value', value: '3' }]); + await expect(new SetupCredentialPromptAdapter(input, {}).reviewSetupPatIntent()).resolves.toBe('details'); + expect(input.readText).toHaveBeenCalledWith(expect.stringContaining('view full permission table')); + }); + + it('lists PAT review actions in the same order as the numbered menu', async () => { + const input = terminal([{ kind: 'value', value: '3' }]); + await new SetupCredentialPromptAdapter(input, {}).reviewSetupPatIntent(); + const prompt = String(input.readText.mock.calls[0][0]); + expect(prompt).toMatch(/1\) continue to GitHub[\s\S]*2\) review all setup choices again[\s\S]*3\) view full permission table[\s\S]*4\) enter a PAT manually/u); + }); + + it('rejects an invalid authenticated setup account without prompting', async () => { + const input = terminal([{ kind: 'value', value: '1' }]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + await adapter.chooseSetupPatMethod(); + await expect(adapter.confirmGuidedSetupAccount('bad/account')).resolves.toBe(false); + expect(input.readText).toHaveBeenCalledTimes(1); + }); + + it('keeps manual setup PAT choice free of link and cleanup claims', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '2' }, + { kind: 'value', value: 'manual-token' }, + ]), {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + await expect(adapter.requestSetupPat()).resolves.toBe('manual-token'); + adapter.showSetupPatCleanupReminder(); + const output = log.mock.calls.flat().join('\n'); + expect(output).not.toContain('https://github.com/settings/personal-access-tokens/new'); + expect(output).not.toContain('not revoked automatically'); + } finally { log.mockRestore(); } + }); + + it('distinguishes an initial PAT failure from a blocked final plan', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '1' }, { kind: 'value', value: 'setup-token' }, + ]), {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + await adapter.requestSetupPat(); + log.mockClear(); + adapter.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?contents=read', 'bootstrap'); + expect(log.mock.calls.flat().join('\n')).toContain('no setup plan has been applied'); + log.mockClear(); + adapter.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?contents=write', 'final'); + expect(log.mock.calls.flat().join('\n')).toContain('no plan mutation has started'); + log.mockClear(); + adapter.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?contents=write', 'final', ['repository Contents write']); + expect(log.mock.calls.flat().join('\n')).toContain('repository Contents write'); + } finally { log.mockRestore(); } + }); + + it('does not show a correction link before guided mode is selected', () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + new SetupCredentialPromptAdapter(undefined, {}).showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new', 'final'); + expect(log).not.toHaveBeenCalled(); + } finally { log.mockRestore(); } + }); + + it('rejects an unintended setup account before continuing', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '1' }, + { kind: 'value', value: 'setup-token' }, + { kind: 'value', value: '2' }, + ]), {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + await adapter.requestSetupPat(); + await expect(adapter.confirmGuidedSetupAccount('wrong-account')).resolves.toBe(false); + } finally { log.mockRestore(); } + }); + + it('resolves the intended bot ID before accepting a guided workflow PAT', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '' }, + { kind: 'value', value: 'bad/login' }, + { kind: 'value', value: 'vypbot' }, + { kind: 'value', value: 'bot-token' }, + ]); + const resolve = jest.fn(async () => ({ id: 42, login: 'vypbot' })); + const adapter = new SetupCredentialPromptAdapter(input, {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new?expires_in=90', resolve); + const requirement = { name: 'PAT', kind: 'workflowPat' as const, description: 'Runtime token' }; + await expect(adapter.requestWorkflowPat(requirement)).resolves.toEqual({ name: 'PAT', value: 'bot-token' }); + expect(resolve).toHaveBeenCalledWith('vypbot'); + expect(adapter.guidedWorkflowBotIdentity).toEqual({ id: 42, login: 'vypbot' }); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('GitHub account ID 42'); + expect(output).toContain('https://github.com/settings/personal-access-tokens/new?expires_in=90'); + expect(output).not.toContain('bot-token'); + } finally { log.mockRestore(); } + }); + + it('shows bot permission details on demand without asking for the bot identity twice', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '3' }, + { kind: 'value', value: '1' }, + { kind: 'value', value: 'vypbot' }, + { kind: 'value', value: 'bot-token' }, + ]); + const resolve = jest.fn(async () => ({ id: 42, login: 'vypbot' })); + const adapter = new SetupCredentialPromptAdapter(input, {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', resolve, [{ + id: 'workflow.repository.contents', role: 'workflow', scope: 'repository', permission: 'Contents', + level: 'write', applicability: 'required', reason: 'Manage branches.', probe: 'contents', + }]); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'Runtime token' })) + .resolves.toEqual({ name: 'PAT', value: 'bot-token' }); + expect(resolve).toHaveBeenCalledTimes(1); + expect(input.readText.mock.calls.filter(([prompt]) => String(prompt).includes('Expected GitHub bot login'))).toHaveLength(1); + expect(log.mock.calls.flat().join('\n')).toContain('Workflow PAT permissions required'); + } finally { log.mockRestore(); } + }); + + it('propagates cancellation before a bot login can be resolved', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '1' }, { kind: 'cancel' }, + ]), {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', jest.fn()); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'Runtime token' })) + .rejects.toBeInstanceOf(SetupTerminalCancelledError); + } finally { log.mockRestore(); } + }); + + it('manual bot PAT entry does not assert a guided bot identity', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '2' }, { kind: 'value', value: 'manual-bot-token' }, + ]), {}); + const resolve = jest.fn(); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', resolve, [{ + id: 'workflow.repository.contents', role: 'workflow', scope: 'repository', permission: 'Contents', + level: 'write', applicability: 'required', reason: 'Manage branches.', probe: 'contents', + }]); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'Runtime token' })) + .resolves.toEqual({ name: 'PAT', value: 'manual-bot-token' }); + expect(resolve).not.toHaveBeenCalled(); + expect(adapter.guidedWorkflowBotIdentity).toBeUndefined(); + expect(log.mock.calls.flat().join('\n')).toContain('Workflow PAT permissions required'); + } finally { log.mockRestore(); } + }); + + it('does not invent workflow PAT requirements when the manual guide has none', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '2' }, { kind: 'value', value: 'manual-bot-token' }, + ]), {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', async () => ({ login: 'bot', id: 1 })); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'Runtime token' })) + .resolves.toEqual({ name: 'PAT', value: 'manual-bot-token' }); + expect(log.mock.calls.flat().join('\n')).not.toContain('Workflow PAT permissions required'); + } finally { log.mockRestore(); } + }); + it('supports explicit existing-credential choices and propagates interrupted secret input', async () => { const log = jest.spyOn(console, 'log').mockImplementation(); const requirement = { name: 'PAT', kind: 'workflowPat' as const, description: 'Runtime token' }; diff --git a/src/cli/__tests__/setup_result_receipt.test.ts b/src/cli/__tests__/setup_result_receipt.test.ts new file mode 100644 index 000000000..bd8358bf8 --- /dev/null +++ b/src/cli/__tests__/setup_result_receipt.test.ts @@ -0,0 +1,62 @@ +import { ApplicationError } from '../../data/model/application_error'; +import { Result } from '../../data/model/result'; +import { setupActionResultFailure, setupResultEffects, setupResultReason } from '../setup_result_receipt'; + +const reference = '12345678-1234-4123-8123-123456789abc'; + +describe('setup result receipt', () => { + test('reads only the versioned, complete, value-free resource receipt', () => { + const effects = [ + { id: 'files', state: 'completed', scope: 'local' }, + { id: 'secrets', state: 'needs-inspection', scope: 'organization' }, + { id: 'labels', state: 'skipped', scope: 'repository' }, + { id: 'issue-types', state: 'completed', scope: 'repository' }, + { id: 'variables', state: 'not-started', scope: 'mixed' }, + { id: 'initial-tag', state: 'not-started', scope: 'repository' }, + ]; + const result = new Result({ id: 'InitialSetupUseCase', success: false, executed: true, + payload: { setupReceipt: { version: 1, effects, secretValue: 'must-never-appear' } } }); + expect(setupResultEffects([result])).toEqual(effects); + expect(JSON.stringify(setupResultEffects([result]))).not.toContain('must-never-appear'); + const poisoned = new Result({ id: 'github_pat_fake', success: false, executed: true, + payload: { setupReceipt: { version: 1, effects: effects.map(item => item.id === 'files' ? { ...item, id: 'github_pat_fake' } : item) } } }); + expect(setupResultEffects([poisoned])).toEqual([{ id: 'step-1', state: 'needs-inspection' }]); + }); + test.each([ + ['authorization.denied', 'permissions'], + ['configuration.invalid', 'configuration'], + ['provider.rate-limited', 'rate-limit'], + ['provider.unavailable', 'provider'], + ['workflow.failed', 'unknown'], + ] as const)('maps %s to a redacted %s reason', (code, reason) => { + expect(setupResultReason(code)).toBe(reason); + }); + + test('does not serialize provider error messages or unsafe action identifiers', () => { + const error = new ApplicationError('provider.unavailable', 'private diagnostic', { correlationId: reference }); + const results = [ + new Result({ id: 'files', success: true, executed: true }), + new Result({ id: 'secret\nPAT', success: false, executed: true, errors: [error] }), + new Result({ id: 'variables', success: true, executed: false }), + ]; + expect(setupResultEffects(results)).toEqual([ + { id: 'files', state: 'completed' }, + { id: 'step-2', state: 'needs-inspection' }, + { id: 'variables', state: 'skipped' }, + ]); + expect(setupActionResultFailure(results)).toEqual({ reasonCode: 'provider', diagnosticRef: reference }); + expect(JSON.stringify(setupResultEffects(results))).not.toContain('private diagnostic'); + }); + + test('uses unknown reason for failed result without structured error', () => { + expect(setupActionResultFailure([new Result({ id: 'files', success: false })])).toEqual({ reasonCode: 'unknown' }); + expect(setupActionResultFailure([new Result({ id: 'files', success: true })])).toBeUndefined(); + }); + + test('does not throw or leak text if a legacy action returns an unstructured error', () => { + const result = { id: 'InitialSetupUseCase', success: false, executed: true, + errors: ['private provider diagnostic'] } as unknown as Result; + expect(setupActionResultFailure([result])).toEqual({ reasonCode: 'unknown' }); + expect(setupResultEffects([result])).toEqual([{ id: 'setup-workflow', state: 'needs-inspection' }]); + }); +}); diff --git a/src/cli/__tests__/setup_session_guard.test.ts b/src/cli/__tests__/setup_session_guard.test.ts new file mode 100644 index 000000000..04784eb2e --- /dev/null +++ b/src/cli/__tests__/setup_session_guard.test.ts @@ -0,0 +1,100 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { acquireSetupSessionGuard } from '../setup_session_guard'; + +describe('setup session guard', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'copilot-setup-guard-')); + execFileSync('git', ['init', '-q', root]); + mkdirSync(join(root, 'nested')); + }); + const lockPath = () => join(tmpdir(), `copilot-setup-${createHash('sha256').update(realpathSync(root)).digest('hex').slice(0, 32)}.lock`); + afterEach(() => { + const lock = lockPath(); + if (existsSync(lock)) unlinkSync(lock); + rmSync(root, { recursive: true, force: true }); + }); + + test('serializes setup across directories of the same checkout and releases only its own lock', () => { + const release = acquireSetupSessionGuard(root); + expect(() => acquireSetupSessionGuard(join(root, 'nested'))).toThrow('Another setup process'); + release(); + const releaseNext = acquireSetupSessionGuard(join(root, 'nested')); + release(); // An old release callback must not remove a new owner's lock. + expect(() => acquireSetupSessionGuard(root)).toThrow('Another setup process'); + releaseNext(); + const releaseThird = acquireSetupSessionGuard(root); + releaseThird(); + }); + + test('fails closed on a verified dead owner until the operator removes its exact lock', () => { + const oldRecord = JSON.stringify({ pid: 99999999, nonce: 'old-owner', repository: realpathSync(root) }); + writeFileSync(lockPath(), oldRecord); + expect(() => acquireSetupSessionGuard(root)).toThrow(`remove only that file manually`); + expect(readFileSync(lockPath(), 'utf8')).toBe(oldRecord); + unlinkSync(lockPath()); // Simulates explicit operator recovery after verifying no setup is running. + const release = acquireSetupSessionGuard(root); + expect(JSON.parse(readFileSync(lockPath(), 'utf8')).pid).toBe(process.pid); + release(); + }); + + test('a replacement lock is never unlinked after a stale-owner probe', () => { + writeFileSync(lockPath(), JSON.stringify({ pid: 99999999, nonce: 'old-owner', repository: realpathSync(root) })); + const newRecord = JSON.stringify({ pid: process.pid, nonce: 'new-owner', repository: realpathSync(root) }); + const probe = jest.spyOn(process, 'kill').mockImplementationOnce(() => { + unlinkSync(lockPath()); + writeFileSync(lockPath(), newRecord); // Another process won the race after our read. + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + try { + expect(() => acquireSetupSessionGuard(root)).toThrow('remove only that file manually'); + expect(readFileSync(lockPath(), 'utf8')).toBe(newRecord); + } finally { probe.mockRestore(); } + }); + + test.each([ + ['not-json'], + [JSON.stringify({ pid: -1, nonce: 'bad', repository: 'wrong' })], + ])('fails closed for an unverifiable lock %s', content => { + writeFileSync(lockPath(), content); + expect(() => acquireSetupSessionGuard(root)).toThrow('lock'); + expect(readFileSync(lockPath(), 'utf8')).toBe(content); + }); + + test('propagates a filesystem error instead of treating it as a competing session', () => { + const open = jest.spyOn(require('node:fs'), 'openSync').mockImplementationOnce(() => { throw Object.assign(new Error('Permission denied'), { code: 'EACCES' }); }); + try { + expect(() => acquireSetupSessionGuard(root)).toThrow('Permission denied'); + } finally { + open.mockRestore(); + } + }); + + test('does not mistake a failed atomic link for an existing setup session', () => { + const link = jest.spyOn(require('node:fs'), 'linkSync').mockImplementationOnce(() => { + throw Object.assign(new Error('Filesystem is read-only'), { code: 'EROFS' }); + }); + try { + expect(() => acquireSetupSessionGuard(root)).toThrow('Filesystem is read-only'); + expect(existsSync(lockPath())).toBe(false); + expect(readdirSync(tmpdir()).filter(name => name.startsWith(`${basename(lockPath())}.`))).toEqual([]); + } finally { link.mockRestore(); } + }); + + test('a failed staged write never publishes an empty lock or leaves a staging file', () => { + const write = jest.spyOn(require('node:fs'), 'writeFileSync').mockImplementationOnce(() => { + throw Object.assign(new Error('Disk full'), { code: 'ENOSPC' }); + }); + try { + expect(() => acquireSetupSessionGuard(root)).toThrow('Disk full'); + expect(existsSync(lockPath())).toBe(false); + expect(readdirSync(tmpdir()).filter(name => name.startsWith(`${basename(lockPath())}.`))).toEqual([]); + } finally { write.mockRestore(); } + const release = acquireSetupSessionGuard(root); + release(); + }); +}); diff --git a/src/cli/__tests__/setup_terminal_driver.test.ts b/src/cli/__tests__/setup_terminal_driver.test.ts index cd3749aa2..36063a257 100644 --- a/src/cli/__tests__/setup_terminal_driver.test.ts +++ b/src/cli/__tests__/setup_terminal_driver.test.ts @@ -128,6 +128,60 @@ describe('NodeTerminalDriver', () => { expect(mockStdin.setRawMode).toHaveBeenLastCalledWith(false); }); + it('never writes provider-controlled terminal sequences from a choice', async () => { + const pending = new NodeTerminalDriver().readMultiSelect( + 'Projects', ['5 — Roadmap\u001b[2J\nFake\u202e'], [], + ); + const rendered = mockStdout.write.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(rendered).toContain('5 — Roadmap[2JFake'); + expect(rendered).not.toContain('\u001b[2J'); + expect(rendered).not.toContain('Roadmap\nFake'); + expect(rendered).not.toContain('\u202e'); + mockInputHandlers.get('data')?.(Buffer.from(' \n')); + await expect(pending).resolves.toEqual({ kind: 'value', value: '5' }); + }); + + it('shows sanitized choices and retry/manual IDs when raw-mode selection is unavailable', async () => { + mockStdin.setRawMode = undefined as unknown as jest.Mock; + mockQuestion.mockResolvedValueOnce('retry'); + const result = await new NodeTerminalDriver().readMultiSelect( + 'Projects', ['5 — Roadmap\u001b[2J\nFake', 'manual — Enter a URL', 'retry — Search again'], ['5'], + ); + expect(result).toEqual({ kind: 'value', value: 'retry' }); + const prompt = mockQuestion.mock.calls[0][0] as string; + expect(prompt).toContain('5 — Roadmap[2JFake'); + expect(prompt).toContain('manual — Enter a URL'); + expect(prompt).toContain('retry — Search again'); + expect(prompt).toContain('Current selection: 5'); + expect(prompt).not.toContain('\u001b[2J'); + }); + + it('keeps the default All selection on unchanged Enter', async () => { + const pending = new NodeTerminalDriver().readMultiSelect( + 'Issue workflows', ['All', 'feature — Feature', 'help — Help'], ['feature', 'help'], + ); + mockInputHandlers.get('data')?.(Buffer.from('\n')); + await expect(pending).resolves.toEqual({ kind: 'value', value: 'feature,help' }); + }); + + it('submits explicit none when the owner clears All and confirms', async () => { + const pending = new NodeTerminalDriver().readMultiSelect( + 'Issue workflows', ['All', 'feature — Feature', 'help — Help'], ['feature', 'help'], + ); + mockInputHandlers.get('data')?.(Buffer.from(' \n')); + await expect(pending).resolves.toEqual({ kind: 'value', value: 'none' }); + expect(mockStdin.setRawMode).toHaveBeenLastCalledWith(false); + }); + + it('shows raw-mode help without losing the current multi-selection', async () => { + const pending = new NodeTerminalDriver().readMultiSelect( + 'Issue workflows', ['All', 'feature — Feature', 'help — Help'], [], 'What: choose issue workflows', + ); + mockInputHandlers.get('data')?.(Buffer.from('\u001b[B ?\n')); + await expect(pending).resolves.toEqual({ kind: 'value', value: 'feature' }); + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('What: choose issue workflows')); + }); + it.each([ ['data', '\u0003', 'cancel'], ['data', '\u0004', 'end-of-input'], diff --git a/src/cli/__tests__/setup_terminal_driver_stream_integration.test.ts b/src/cli/__tests__/setup_terminal_driver_stream_integration.test.ts new file mode 100644 index 000000000..7b833f8d4 --- /dev/null +++ b/src/cli/__tests__/setup_terminal_driver_stream_integration.test.ts @@ -0,0 +1,68 @@ +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +describe('NodeTerminalDriver stream transitions', () => { + it('covers every input-type transition without echoing secrets', async () => { + const driverUrl = pathToFileURL(resolve(__dirname, '../setup_terminal_driver.ts')).href; + const source = ` + delete process.env.JEST_WORKER_ID; + Object.defineProperty(process.stdin, 'isTTY', { value: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true }); + process.stdin.setRawMode = () => {}; + const { NodeTerminalDriver } = await import(${JSON.stringify(driverUrl)}); + const driver = new NodeTerminalDriver(); + // T T M M S S T S M T covers every adjacent text/multi-select/secret pair. + const results = [ + await driver.readText('text1> '), + await driver.readText('text2> '), + await driver.readMultiSelect('multi1>', ['All', 'feature — Feature'], ['feature']), + await driver.readMultiSelect('multi2>', ['All', 'feature — Feature'], ['feature']), + await driver.readSecret('secret1>'), + await driver.readSecret('secret2>'), + await driver.readText('text3> '), + await driver.readSecret('secret3>'), + await driver.readMultiSelect('multi3>', ['All', 'feature — Feature'], ['feature']), + await driver.readText('text4> '), + ]; + driver.close(); + console.log('RESULT:', JSON.stringify(results.map((result, index) => + [4, 5, 7].includes(index) && result.kind === 'value' + ? { kind: result.kind, length: result.value.length } : result))); + `; + const child = spawn(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', source], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const prompts = ['text1> ', 'text2> ', 'multi1>', 'multi2>', 'secret1>:', 'secret2>:', + 'text3> ', 'secret3>:', 'multi3>', 'text4> ']; + const answers = ['one\n', 'two\n', '\n', '\n', 'dummy-one\n', 'dummy-two\n', + 'three\n', 'dummy-three\n', '\n', 'four\n']; + let output = ''; + let errorOutput = ''; + let answered = 0; + child.stdout.on('data', (chunk: Buffer) => { + output += chunk.toString(); + while (answered < prompts.length && output.includes(prompts[answered])) { + child.stdin.write(answers[answered]); + answered += 1; + } + }); + child.stderr.on('data', (chunk: Buffer) => { errorOutput += chunk.toString(); }); + const exitCode = await new Promise((resolveExit, reject) => { + const timeout = setTimeout(() => child.kill(), 10_000); + child.once('error', reject); + child.once('close', code => { + clearTimeout(timeout); + resolveExit(code); + }); + }); + + expect(exitCode).toBe(0); + expect(answered).toBe(prompts.length); + expect(output).toContain('RESULT:'); + expect(output).toContain('"value":"four"'); + expect(output).toContain('"length":11'); + expect(output).not.toContain('dummy-'); + expect(errorOutput).not.toContain('Error:'); + }); +}); diff --git a/src/cli/__tests__/setup_token_permission_presenter.test.ts b/src/cli/__tests__/setup_token_permission_presenter.test.ts index 818f9d9af..fbbdc858d 100644 --- a/src/cli/__tests__/setup_token_permission_presenter.test.ts +++ b/src/cli/__tests__/setup_token_permission_presenter.test.ts @@ -1,6 +1,8 @@ import { + ConsoleSetupTokenPermissionPresenter, renderSetupTokenPermissionReport, renderSetupTokenPermissionRequirements, + renderSetupTokenPermissionSummary, } from '../setup_token_permission_presenter'; import type { SetupTokenPermissionRequirement } from '../../domain/setup_token_permissions'; @@ -15,6 +17,25 @@ const secrets: SetupTokenPermissionRequirement = { }; describe('setup token permission presenter', () => { + it('uses the full requirement table by default and summary only when requested', () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + new ConsoleSetupTokenPermissionPresenter().showRequirements('setup', [metadata, secrets]); + expect(log.mock.calls.flat().join('\n')).toContain('Provision Actions Secrets.'); + log.mockClear(); + new ConsoleSetupTokenPermissionPresenter('summary').showRequirements('setup', [metadata, secrets]); + expect(log.mock.calls.flat().join('\n')).toContain('Conditional permissions: 1'); + expect(log.mock.calls.flat().join('\n')).not.toContain('Provision Actions Secrets.'); + } finally { log.mockRestore(); } + }); + it('summarizes only required URL grants and counts conditional rows without changing policy', () => { + const output = renderSetupTokenPermissionSummary('setup', [metadata, secrets], 80); + expect(output).toContain('Required now: Metadata read (repository)'); + expect(output).toContain('Conditional permissions: 1'); + expect(output).not.toContain('Provision Actions Secrets.'); + expect(renderSetupTokenPermissionRequirements('setup', [metadata, secrets])).toContain('Provision Actions Secrets.'); + expect(renderSetupTokenPermissionSummary('setup', [], 80)).toContain('Required now: none'); + }); it('renders the requirement matrix before setup PAT input', () => { const output = renderSetupTokenPermissionRequirements('setup', [metadata, secrets], 120); expect(output).toContain('Setup PAT permissions required'); diff --git a/src/cli/__tests__/web_setup_adapters.test.ts b/src/cli/__tests__/web_setup_adapters.test.ts new file mode 100644 index 000000000..5d765f205 --- /dev/null +++ b/src/cli/__tests__/web_setup_adapters.test.ts @@ -0,0 +1,464 @@ +import { WebSetupBridge } from '../web_setup_bridge'; +import { WebSetupCredentialPrompt, WebSetupJourneyPresenter, WebSetupPermissionPresenter, WebSetupPlanConfirmation, WebSetupPlanPresenter, WebSetupQuestionnaireCollector, WebSetupWorkflowUpdatePrompt } from '../web_setup_adapters'; +import { buildInitialSetupConfiguration } from '../../application/usecases/setup/setup_wizard_use_case'; +import { createSetupPermissionIntentQuestionnaire, createSetupQuestionnaire, setupQuestionContentInventory } from '../../application/policies/setup_questionnaire_policy'; +import type { SetupPlan } from '../../domain/setup'; +import type { SetupTokenPermissionReport } from '../../domain/setup_token_permissions'; +import type { SetupCredentialCheck } from '../../domain/setup'; + +const next = () => new Promise(resolve => setImmediate(resolve)); + +function answer(bridge: WebSetupBridge, value: string): void { + const revision = bridge.snapshot().promptRevision; + expect(revision).toBeDefined(); + expect(bridge.answer(revision!, value)).toBe(true); +} + +const emptyPlan = (): SetupPlan => ({ + configuration: buildInitialSetupConfiguration({ mode: 'interactive' }), + workflowFiles: ['copilot.yml'], issueTemplateFiles: [], selectedFiles: ['.github/workflows/copilot.yml'], + variables: [{ name: 'AGENT_PROVIDER', value: 'codex' }], requiredSecrets: ['PAT'], credentialRequirements: [], + mergeQueueReadiness: [], approvalReadiness: [], warnings: ['Review changes'], +}); + +describe('semantic web setup adapters', () => { + test('collects policy-owned intent questions without terminal prompt parsing', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const collector = new WebSetupQuestionnaireCollector(bridge); + const initial = createSetupPermissionIntentQuestionnaire(buildInitialSetupConfiguration({ mode: 'interactive' })); + const result = collector.collect(initial, {}); + const seen: string[] = []; + for (let index = 0; index < 30; index += 1) { + const prompt = bridge.snapshot().prompt; + if (!prompt) break; + expect(prompt.kind).toBe('question'); + if (prompt.kind === 'question') seen.push(prompt.question.id); + answer(bridge, ''); + await next(); + } + const state = await result; + expect(state.terminal).toBe('review'); + expect(seen).toContain('features.issues'); + expect(state.answeredQuestionIds).toEqual(seen); + expect(JSON.stringify(bridge.snapshot())).not.toContain('github_pat_'); + }); + + test('labels a legacy questionnaire without a phase as the full wizard', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const ask = jest.spyOn(bridge, 'ask').mockResolvedValueOnce(undefined); + const initial = createSetupPermissionIntentQuestionnaire(buildInitialSetupConfiguration({ mode: 'interactive' })); + const result = await new WebSetupQuestionnaireCollector(bridge).collect({ ...initial, phase: undefined }, {}); + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ phase: 'full', pass: 1 }), undefined, expect.any(Function)); + expect(result.terminal).toBe('cancelled'); + }); + + test('invalid answer stays on the same policy question and exposes validation', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const collector = new WebSetupQuestionnaireCollector(bridge); + const result = collector.collect(createSetupPermissionIntentQuestionnaire(buildInitialSetupConfiguration({ mode: 'interactive' })), {}); + const firstPrompt = bridge.snapshot().prompt; + const initialId = firstPrompt?.kind === 'question' ? firstPrompt.question.id : ''; + answer(bridge, 'not-yes-or-no'); + await next(); + expect(bridge.snapshot().prompt?.kind).toBe('question'); + const retryPrompt = bridge.snapshot().prompt; + expect(retryPrompt?.kind === 'question' && retryPrompt.question.id).toBe(initialId); + expect(bridge.snapshot().message?.text).toContain('Enter yes or no'); + bridge.cancel(); + expect((await result).terminal).toBe('cancelled'); + }); + + test('a web discovery retry refreshes the current Project question without restarting the questionnaire', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const context = { skipQuestionIds: setupQuestionContentInventory().map(item => item.id).filter(id => id !== 'projects.ids'), + projectOwner: 'owner', projectDiscovery: { status: 'unavailable' as const, candidates: [] }, + discoveryRetryRemaining: { checks: 0, projects: 1 } }; + const initial = createSetupQuestionnaire(buildInitialSetupConfiguration({ mode: 'interactive' }), context); + const refreshed = { ...context, projectDiscovery: { status: 'observed' as const, candidates: [ + { number: 5, owner: 'owner', title: 'Roadmap', url: 'https://github.com/orgs/owner/projects/5' }, + ] }, discoveryRetryRemaining: { checks: 0, projects: 0 } }; + const refresh = jest.fn(async () => refreshed); + const pending = new WebSetupQuestionnaireCollector(bridge).collect(initial, context, { refresh }); + const revision = bridge.snapshot().promptRevision!; + expect(await bridge.retryDiscovery(revision)).toBe('updated'); + expect(refresh).toHaveBeenCalledWith('projects'); + const prompt = bridge.snapshot().prompt; + expect(prompt?.kind).toBe('question'); + if (prompt?.kind === 'question') expect(prompt.question.projectCandidates?.[0].title).toBe('Roadmap'); + answer(bridge, '5'); + expect((await pending).draft.projects.ids).toBe('5'); + }); + + test('back navigation shows the earlier web question within the same run', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const initial = createSetupPermissionIntentQuestionnaire(buildInitialSetupConfiguration({ mode: 'interactive' })); + const firstId = initial.question?.id; + const pending = new WebSetupQuestionnaireCollector(bridge).collect(initial, {}); + answer(bridge, ''); + await next(); + expect(bridge.snapshot().prompt?.kind).toBe('question'); + expect(bridge.snapshot().promptRevision).toBeGreaterThan(1); + expect(bridge.back(bridge.snapshot().promptRevision!)).toBe('updated'); + const returned = bridge.snapshot().prompt; + expect(returned?.kind === 'question' && returned.question.id).toBe(firstId); + bridge.cancel(); + expect((await pending).terminal).toBe('cancelled'); + }); + + test.each([['approve', 'approved'], ['decline', 'declined']])('plan %s maps to %s', async (reply, expected) => { + const bridge = new WebSetupBridge('owner/repo'); + const confirmation = new WebSetupPlanConfirmation(bridge); + const pending = confirmation.confirm(emptyPlan()); + expect(bridge.snapshot().prompt?.kind).toBe('plan'); + const planPrompt = bridge.snapshot().prompt; + if (planPrompt?.kind === 'plan') expect(planPrompt.plan.secrets).toEqual(['PAT']); + answer(bridge, reply); + expect((await pending).kind).toBe(expected); + }); + + test('the web plan displays exact trusted check producers and rejects unknown edit groups', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const base = emptyPlan(); + const plan = { ...base, configuration: { ...base.configuration, + pullRequestApproval: { ...base.configuration.pullRequestApproval, + testChecks: [{ name: 'tests', sourceAppId: 42, workflowName: 'CI' }] } } }; + const pending = new WebSetupPlanConfirmation(bridge).confirm(plan); + const prompt = bridge.snapshot().prompt; + expect(prompt?.kind).toBe('plan'); + if (prompt?.kind === 'plan') expect(prompt.plan.decisions.trustedChecks).toEqual([ + { name: 'tests', sourceAppId: 42, workflowName: 'CI' }, + ]); + const revision = bridge.snapshot().promptRevision!; + expect(bridge.answer(revision, 'revise:unlisted')).toBe(false); + expect(bridge.snapshot().promptRevision).toBe(revision); + answer(bridge, 'approve'); + expect((await pending).kind).toBe('approved'); + }); + + test('web plan revision only accepts a section actually offered in the current plan', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = new WebSetupPlanConfirmation(bridge).confirm(emptyPlan()); + const prompt = bridge.snapshot().prompt; + expect(prompt?.kind).toBe('plan'); + const group = prompt?.kind === 'plan' ? prompt.editGroups?.[0] : undefined; + expect(group).toBeDefined(); + answer(bridge, `revise:${group}`); + expect(await pending).toEqual({ kind: 'revise', group }); + + const invalidBridge = new WebSetupBridge('owner/repo'); + jest.spyOn(invalidBridge, 'ask').mockResolvedValueOnce('revise:unlisted'); + await expect(new WebSetupPlanConfirmation(invalidBridge).confirm(emptyPlan())).rejects.toThrow('Invalid setup section.'); + }); + + test('guided bot token uses distinct GitHub link, numeric identity, and masked handoff', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const resolve = jest.fn().mockResolvedValue({ login: 'bot-user', id: 42 }); + prompt.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new?name=bot', resolve); + const pending = prompt.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' }); + answer(bridge, 'Guided GitHub link'); + await next(); + expect(bridge.snapshot().prompt?.kind).toBe('text'); + answer(bridge, 'bot-user'); + await next(); + const secretPrompt = bridge.snapshot().prompt; + expect(secretPrompt?.kind).toBe('secret'); + if (secretPrompt?.kind === 'secret') { + expect(secretPrompt.link).toContain('github.com/settings/personal-access-tokens/new'); + expect(secretPrompt.description).toContain('GitHub ID 42'); + } + answer(bridge, 'github_pat_fake_bot_value'); + expect((await pending)?.value).toBe('github_pat_fake_bot_value'); + expect(prompt.guidedWorkflowBotIdentity?.id).toBe(42); + expect(resolve).toHaveBeenCalledWith('bot-user'); + expect(JSON.stringify(bridge.snapshot())).not.toContain('github_pat_fake_bot_value'); + }); + + test('setup PAT guidance and verified account confirmation remain role-specific', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const method = prompt.chooseSetupPatMethod(); + answer(bridge, 'Guided GitHub link'); + expect(await method).toBe('guided'); + prompt.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new?name=setup'); + const token = prompt.requestSetupPat(); + expect(bridge.snapshot().prompt?.kind).toBe('secret'); + answer(bridge, 'github_pat_fake_setup_value'); + expect(await token).toBe('github_pat_fake_setup_value'); + const account = prompt.confirmGuidedSetupAccount('operator'); + expect(bridge.snapshot().prompt?.title).toContain('@operator'); + answer(bridge, 'No, stop'); + expect(await account).toBe(false); + expect(JSON.stringify(bridge.snapshot())).not.toContain('github_pat_fake_setup_value'); + }); + + test('unverifiable required writes need a specific acknowledgement', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const report: SetupTokenPermissionReport = { + role: 'setup', identityStatus: 'valid', identityMessage: 'checked', ready: false, confirmationRequired: true, + checks: [{ id: 'secrets', role: 'setup', scope: 'repository', permission: 'Secrets', level: 'write', + applicability: 'required', reason: 'Provision', probe: 'secrets', status: 'unverifiable', message: 'No safe write probe' }], + }; + const pending = prompt.confirmUnverifiableTokenPermissions(report); + answer(bridge, 'Yes, I checked them'); + expect(await pending).toBe(true); + const presenter = new WebSetupPermissionPresenter(bridge); + presenter.showReport(report); + expect(bridge.snapshot().permissions?.report?.checks[0].status).toBe('unverifiable'); + }); + + test('workflow update decision is explicit and never inferred from a changed file', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupWorkflowUpdatePrompt(bridge); + const pending = prompt.confirmWorkflowUpdates([{ file: 'copilot.yml', destination: '.github/workflows/copilot.yml', status: 'changed' }], false); + expect(bridge.snapshot().prompt?.title).toContain('Update existing workflows'); + answer(bridge, 'Keep existing'); + expect(await pending).toBe(false); + }); + + test.each([['Organization', 'Organization'], ['Personal account', 'User'], ['Not sure', 'unknown']])('owner answer %s resolves to %s', async (reply, expected) => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const pending = prompt.chooseSetupOwnerKind(); + answer(bridge, reply); + expect(await pending).toBe(expected); + }); + + test.each([ + ['Continue to GitHub', 'continue'], ['Review setup choices again', 'revise'], + ['View full permission table', 'details'], ['Enter a PAT manually', 'manual'], + ])('intent review %s resolves to %s', async (reply, expected) => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const pending = prompt.reviewSetupPatIntent(); + answer(bridge, reply); + expect(await pending).toBe(expected); + }); + + test('manual setup PAT does not claim guided account verification or cleanup', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const pending = prompt.chooseSetupPatMethod(); + answer(bridge, 'Manual PAT'); + expect(await pending).toBe('manual'); + expect(prompt.usedGuidedSetupPat).toBe(false); + expect(await prompt.confirmGuidedSetupAccount()).toBe(true); + prompt.showSetupPatCleanupReminder(); + expect(bridge.snapshot().message).toBeUndefined(); + }); + + test('guided cleanup and corrected link are visible without token values', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const pending = prompt.chooseSetupPatMethod(); + answer(bridge, 'Guided GitHub link'); + await pending; + prompt.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new?name=setup'); + prompt.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?name=updated', 'final', ['Secrets write']); + expect(bridge.snapshot().message?.link).toContain('name=updated'); + expect(bridge.snapshot().message?.text).toContain('Secrets write'); + prompt.showSetupPatCleanupReminder(); + expect(bridge.snapshot().message?.link).toBe('https://github.com/settings/personal-access-tokens'); + prompt.useManualSetupPat(); + expect(prompt.usedGuidedSetupPat).toBe(false); + }); + + test.each([['Keep existing', false], ['Update setup-managed workflows', true]])('workflow answer %s maps to %s', async (reply, expected) => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupWorkflowUpdatePrompt(bridge); + const comparisons = [{ file: 'copilot.yml', destination: '.github/workflows/copilot.yml', status: 'unmanaged' as const }]; + const pending = prompt.confirmWorkflowUpdates(comparisons, false); + answer(bridge, reply); + expect(await pending).toBe(expected); + }); + + test('workflow update leaves unchanged files alone and honors an explicit flag', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupWorkflowUpdatePrompt(bridge); + const comparison = { file: 'copilot.yml', destination: '.github/workflows/copilot.yml', status: 'changed' as const }; + expect(await prompt.confirmWorkflowUpdates([{ ...comparison, status: 'unchanged' }], false)).toBe(false); + expect(await prompt.confirmWorkflowUpdates([comparison], true)).toBe(true); + expect(bridge.snapshot().prompt).toBeUndefined(); + }); + + test('manual bot PAT falls back to permission table without claiming ID binding', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + prompt.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new?name=bot', async () => ({ login: 'bot', id: 1 }), []); + const pending = prompt.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' }); + answer(bridge, 'Manual PAT'); + await next(); + expect(bridge.snapshot().prompt?.kind).toBe('secret'); + expect(bridge.snapshot().permissions?.role).toBe('workflow'); + answer(bridge, 'manual_fake_pat'); + expect((await pending)?.value).toBe('manual_fake_pat'); + expect(prompt.guidedWorkflowBotIdentity).toBeUndefined(); + }); + + test('manual bot entry without prepared requirements does not invent a permission table', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + prompt.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', async () => ({ login: 'bot', id: 1 })); + const pending = prompt.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' }); + answer(bridge, 'Manual PAT'); + await next(); + expect(bridge.snapshot().permissions).toBeUndefined(); + answer(bridge, 'manual_fake_pat'); + expect((await pending)?.value).toBe('manual_fake_pat'); + }); + + test('API key and existing credential decisions stay in separate secret/choice prompts', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const requirement = { name: 'OPENAI_API_KEY', kind: 'apiKey' as const, description: 'AI', provider: 'OpenAI', alternativeGroups: ['agent'] }; + const check: SetupCredentialCheck = { name: requirement.name, status: 'unverifiable', message: 'Value cannot be read.' }; + const decision = prompt.chooseExistingCredential(requirement, check); + expect(bridge.snapshot().prompt?.kind).toBe('choice'); + answer(bridge, 'replace'); + expect(await decision).toBe('replace'); + const value = prompt.requestApiKey(requirement, check); + expect(bridge.snapshot().prompt?.kind).toBe('secret'); + if (bridge.snapshot().prompt?.kind === 'secret') expect(bridge.snapshot().prompt).toMatchObject({ optional: true }); + answer(bridge, 'fake_api_key'); + expect((await value)?.value).toBe('fake_api_key'); + expect(JSON.stringify(bridge.snapshot())).not.toContain('fake_api_key'); + }); + + test('presentation adapters publish redacted plan, journey and permission facts', () => { + const bridge = new WebSetupBridge('owner/repo'); + new WebSetupPlanPresenter(bridge).present(emptyPlan()); + expect(bridge.snapshot().message?.text).toContain('1 Secret names'); + new WebSetupPermissionPresenter(bridge).showRequirements('setup', []); + expect(bridge.snapshot().permissions?.role).toBe('setup'); + new WebSetupPermissionPresenter(bridge).showDetailedRequirements('workflow', []); + expect(bridge.snapshot().permissions?.role).toBe('workflow'); + new WebSetupJourneyPresenter(bridge).present({ repository: 'owner/repo', position: 2, total: 6, + current: 'Setup choices', complete: ['Repository'], pending: ['Setup PAT'], mutationStarted: false, choiceReviewPass: 2 }); + expect(bridge.snapshot().journey?.choiceReviewPass).toBe(2); + }); + + test('a cancelled plan and cancelled workflow decision do not approve anything', async () => { + const planBridge = new WebSetupBridge('owner/repo'); + const plan = new WebSetupPlanConfirmation(planBridge).confirm(emptyPlan()); + planBridge.cancel(); + expect((await plan).kind).toBe('cancelled'); + const workflowBridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupWorkflowUpdatePrompt(workflowBridge); + const workflow = prompt.confirmWorkflowUpdates([{ file: 'a', destination: 'a', status: 'changed' }], false); + workflowBridge.cancel(); + await expect(workflow).rejects.toThrow('cancelled'); + }); + + test('invalid browser choice cannot consume a prompt and cancellation never supplies a PAT', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const method = prompt.chooseSetupPatMethod(); + const revision = bridge.snapshot().promptRevision!; + expect(bridge.answer(revision, 'not-an-option')).toBe(false); + expect(bridge.snapshot().promptRevision).toBe(revision); + answer(bridge, 'Manual PAT'); + expect(await method).toBe('manual'); + const token = prompt.requestSetupPat(); + bridge.cancel(); + await expect(token).rejects.toThrow('cancelled'); + }); + + test('rejects an out-of-contract choice even if the bridge supplies one', async () => { + const bridge = new WebSetupBridge('owner/repo'); + jest.spyOn(bridge, 'ask').mockResolvedValueOnce('unlisted choice'); + await expect(new WebSetupCredentialPrompt(bridge).chooseSetupPatMethod()).rejects.toThrow('Invalid setup choice'); + }); + + test('guided setup account requires a reported identity and a positive operator decision', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const method = prompt.chooseSetupPatMethod(); + answer(bridge, 'Guided GitHub link'); + await method; + expect(await prompt.confirmGuidedSetupAccount()).toBe(false); + const confirmed = prompt.confirmGuidedSetupAccount('operator'); + answer(bridge, 'Yes, continue'); + expect(await confirmed).toBe(true); + }); + + test('unverifiable writes without a required confirmation do not prompt', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const report: SetupTokenPermissionReport = { role: 'setup', identityStatus: 'valid', identityMessage: 'checked', + ready: true, confirmationRequired: false, checks: [] }; + expect(await prompt.confirmUnverifiableTokenPermissions(report)).toBe(false); + expect(bridge.snapshot().prompt).toBeUndefined(); + const noWrite: SetupTokenPermissionReport = { ...report, ready: false, confirmationRequired: true }; + expect(await prompt.confirmUnverifiableTokenPermissions(noWrite)).toBe(false); + }); + + test('invalid guided bot login blocks before a PAT is requested', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const resolve = jest.fn(); + prompt.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', resolve); + const pending = prompt.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' }); + answer(bridge, 'Guided GitHub link'); + await next(); + answer(bridge, 'invalid/login'); + await expect(pending).rejects.toThrow('valid GitHub bot login'); + expect(resolve).not.toHaveBeenCalled(); + expect(bridge.snapshot().prompt).toBeUndefined(); + }); + + test('credential explanation and checks distinguish invalid from verified evidence', () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + prompt.explainCredentialSeparation([{ name: 'PAT', kind: 'workflowPat', description: 'runtime' }]); + expect(bridge.snapshot().message?.text).toContain('separate from your setup PAT'); + prompt.showCredentialChecks([{ name: 'PAT', status: 'invalid', message: 'Wrong account' }]); + expect(bridge.snapshot().message?.tone).toBe('warning'); + prompt.showCredentialChecks([{ name: 'PAT', status: 'valid', message: 'Checked' }]); + expect(bridge.snapshot().message?.tone).toBe('success'); + }); + + test('cancelled bot-login and optional API-key inputs never produce credentials', async () => { + const botBridge = new WebSetupBridge('owner/repo'); + const botPrompt = new WebSetupCredentialPrompt(botBridge); + botPrompt.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', async () => ({ login: 'bot', id: 1 })); + const bot = botPrompt.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' }); + answer(botBridge, 'Guided GitHub link'); + await next(); + botBridge.cancel(); + await expect(bot).rejects.toThrow('cancelled'); + + const keyBridge = new WebSetupBridge('owner/repo'); + const keyPrompt = new WebSetupCredentialPrompt(keyBridge); + const key = keyPrompt.requestApiKey({ name: 'OPENAI_API_KEY', kind: 'apiKey', description: 'AI', alternativeGroups: ['agent'] }); + answer(keyBridge, ''); + expect(await key).toBeUndefined(); + }); + + test('manual bot entry without a prepared link shows existing status but no numeric identity claim', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const pending = prompt.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'runtime' }, + { name: 'PAT', status: 'unverifiable', message: 'Existing value unreadable' }); + expect(bridge.snapshot().prompt?.kind).toBe('secret'); + const secret = bridge.snapshot().prompt; + if (secret?.kind === 'secret') { + expect(secret.description).toContain('Existing Secret: unverifiable'); + expect(secret.link).toBeUndefined(); + } + answer(bridge, ''); + expect(await pending).toBeUndefined(); + expect(prompt.guidedWorkflowBotIdentity).toBeUndefined(); + }); + + test('a cancelled choice and a corrected bootstrap link do not grant access', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const prompt = new WebSetupCredentialPrompt(bridge); + const method = prompt.chooseSetupPatMethod(); + bridge.cancel(); + await expect(method).rejects.toThrow('cancelled'); + const another = new WebSetupBridge('owner/repo'); + new WebSetupCredentialPrompt(another).showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new', 'bootstrap'); + expect(another.snapshot().message?.text).toContain('access failed'); + }); +}); diff --git a/src/cli/__tests__/web_setup_bridge.test.ts b/src/cli/__tests__/web_setup_bridge.test.ts new file mode 100644 index 000000000..71284616e --- /dev/null +++ b/src/cli/__tests__/web_setup_bridge.test.ts @@ -0,0 +1,225 @@ +import { WebSetupBridge } from '../web_setup_bridge'; + +describe('WebSetupBridge', () => { + test('read-only verification runs only after a completed setup and publishes counts without diagnostic values', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const run = jest.fn().mockResolvedValue({ healthy: false, pass: 3, warn: 1, fail: 0, skipped: 2, + token: 'must-not-appear' }); + bridge.configureReadOnlyDoctor(run); + expect(await bridge.runReadOnlyDoctor()).toBe('unavailable'); + bridge.finish('complete', 'Done'); + expect(await bridge.runReadOnlyDoctor()).toBe('complete'); + expect(await bridge.runReadOnlyDoctor()).toBe('complete'); + expect(run).toHaveBeenCalledTimes(1); + expect(bridge.snapshot().doctor).toEqual({ status: 'complete', healthy: false, pass: 3, warn: 1, fail: 0, skipped: 2 }); + expect(JSON.stringify(bridge.snapshot())).not.toContain('must-not-appear'); + }); + test('read-only verification serializes no provider error and allows only one bounded retry', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const run = jest.fn().mockRejectedValue(new Error('private GitHub diagnostic')); + bridge.configureReadOnlyDoctor(run); + bridge.finish('complete', 'Done'); + expect(await bridge.runReadOnlyDoctor()).toBe('failed'); + expect(await bridge.runReadOnlyDoctor()).toBe('failed'); + expect(await bridge.runReadOnlyDoctor()).toBe('unavailable'); + expect(run).toHaveBeenCalledTimes(2); + expect(bridge.snapshot().doctor).toEqual({ status: 'failed' }); + expect(JSON.stringify(bridge.snapshot())).not.toContain('private GitHub diagnostic'); + }); + test('keeps a redacted operation receipt and diagnostic reference together', () => { + const bridge = new WebSetupBridge('owner/repo'); + bridge.setJourney({ repository: 'owner/repo', position: 6, total: 6, current: 'Apply', complete: [], pending: [], mutationStarted: true, choiceReviewPass: 1 }); + bridge.effects([{ id: 'files', state: 'completed' }, { id: 'secret', state: 'needs-inspection' }]); + bridge.resultReason('provider', '12345678-1234-4123-8123-123456789abc'); + bridge.finish('partial', 'Inspect changes'); + expect(bridge.snapshot().resultDetail).toEqual(expect.objectContaining({ reasonCode: 'provider', diagnosticRef: '12345678-1234-4123-8123-123456789abc', effects: [ + { id: 'files', state: 'completed' }, { id: 'secret', state: 'needs-inspection' }, + ] })); + }); + test('back navigation rotates the question revision without resolving or echoing a draft answer', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const commit = jest.fn(); + const pending = bridge.ask({ kind: 'text', title: 'Second' }, undefined, + () => ({ prompt: { kind: 'text', title: 'First' }, commit })); + const oldRevision = bridge.snapshot().promptRevision!; + expect(bridge.back(oldRevision)).toBe('updated'); + expect(commit).toHaveBeenCalledTimes(1); + const newRevision = bridge.snapshot().promptRevision!; + expect(newRevision).toBeGreaterThan(oldRevision); + expect(bridge.answer(oldRevision, 'stale')).toBe(false); + expect(bridge.answer(newRevision, 'fresh')).toBe(true); + expect(await pending).toBe('fresh'); + expect(JSON.stringify(bridge.snapshot())).not.toContain('fresh'); + }); + test('publishes semantic prompts with one-use revisions and never echoes an answer', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = bridge.ask({ kind: 'secret', title: 'Setup PAT' }); + const revision = bridge.snapshot().promptRevision!; + expect(bridge.answer(revision + 1, 'secret-value')).toBe(false); + expect(bridge.answer(revision, 'secret-value')).toBe(true); + expect(await pending).toBe('secret-value'); + expect(bridge.answer(revision, 'secret-value')).toBe(false); + expect(bridge.wasAnswered(revision)).toBe(true); + expect(JSON.stringify(bridge.snapshot())).not.toContain('secret-value'); + }); + + test('a second tab is read-only until takeover and old capabilities fail', () => { + const bridge = new WebSetupBridge('owner/repo'); + const first = bridge.bootstrap(); + const second = bridge.bootstrap(); + expect(first.controller).toBe(true); + expect(second.controller).toBe(false); + expect(second.capability).toBeUndefined(); + expect(JSON.stringify(second)).not.toContain('takeoverTicket'); + const replacement = bridge.takeOver(); + expect(bridge.isController(first.capability!)).toBe(false); + expect(bridge.isController(replacement)).toBe(true); + }); + + test('cancellation resolves a pending decision and forbids future prompts', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = bridge.ask({ kind: 'text', title: 'Name' }); + bridge.cancel(); + expect(await pending).toBeUndefined(); + expect(bridge.snapshot().outcome).toBe('cancelled'); + expect(bridge.snapshot().resultDetail).toEqual(expect.objectContaining({ reasonCode: 'cancelled', mutationStarted: false })); + await expect(bridge.ask({ kind: 'text', title: 'Again' })).rejects.toThrow('ended'); + }); + + test('late messages and duplicate finishes cannot replace a terminal outcome', () => { + const bridge = new WebSetupBridge('owner/repo'); + bridge.finish('partial', 'Inspect before retry'); + bridge.finish('complete', 'Done'); + expect(bridge.snapshot().outcome).toBe('partial'); + expect(bridge.snapshot().message?.text).toBe('Inspect before retry'); + }); + + test('read-only subscribers receive redacted revisions and can unsubscribe', () => { + const bridge = new WebSetupBridge('old/repo'); + const seen: number[] = []; + const unsubscribe = bridge.subscribe(view => seen.push(view.revision)); + bridge.setRepository('owner/repo'); + bridge.message('Progress', 'info'); + unsubscribe(); + bridge.message('Later'); + expect(seen).toEqual([1, 2]); + expect(bridge.snapshot().repository).toBe('owner/repo'); + }); + + test('a failing subscriber is detached without losing prompts or blocking healthy observers', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const failed = jest.fn(() => { throw new Error('Observer failed'); }); + const seen: number[] = []; + bridge.subscribe(failed); + bridge.subscribe(view => seen.push(view.revision)); + const pending = bridge.ask({ kind: 'text', title: 'Continue setup' }); + const revision = bridge.snapshot().promptRevision!; + expect(seen).toEqual([revision]); + expect(bridge.answer(revision, 'yes')).toBe(true); + expect(await pending).toBe('yes'); + bridge.message('Next step'); + expect(failed).toHaveBeenCalledTimes(1); + expect(seen).toEqual([revision, revision + 1, revision + 2]); + }); + + test('only one semantic decision can be pending at a time', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = bridge.ask({ kind: 'choice', title: 'A', choices: ['yes'] }); + await expect(bridge.ask({ kind: 'text', title: 'B' })).rejects.toThrow('already pending'); + bridge.answer(bridge.snapshot().promptRevision!, 'yes'); + expect(await pending).toBe('yes'); + }); + + test('explicit discovery retry updates the pending prompt in place without consuming its answer', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = bridge.ask({ kind: 'text', title: 'Discovery' }, async () => ({ + prompt: { kind: 'text', title: 'Updated discovery' }, commit: jest.fn(), + })); + const revision = bridge.snapshot().promptRevision!; + expect(await bridge.retryDiscovery(revision)).toBe('updated'); + expect(bridge.snapshot().promptRevision).toBe(revision); + expect(bridge.snapshot().prompt).toMatchObject({ title: 'Updated discovery' }); + expect(bridge.answer(revision, 'kept choice')).toBe(true); + expect(await pending).toBe('kept choice'); + expect(await bridge.retryDiscovery(revision)).toBe('stale'); + }); + + test('a retry cannot commit after cancellation or a controller takeover', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const first = bridge.bootstrap(); + let finish!: (value: { prompt: { kind: 'text'; title: string }; commit: () => void }) => void; + const commit = jest.fn(); + const pending = bridge.ask({ kind: 'text', title: 'Original' }, () => new Promise(resolve => { finish = resolve; })); + const revision = bridge.snapshot().promptRevision!; + const retry = bridge.retryDiscovery(revision); + expect(bridge.answer(revision, 'racing answer')).toBe(false); + bridge.takeOver(); + expect(bridge.isController(first.capability!)).toBe(false); + finish({ prompt: { kind: 'text', title: 'Stale' }, commit }); + expect(await retry).toBe('stale'); + expect(commit).not.toHaveBeenCalled(); + expect(bridge.snapshot().prompt).toMatchObject({ title: 'Original' }); + bridge.cancel(); + expect(await pending).toBeUndefined(); + }); + + test('cancelling while discovery is in flight discards its result and leaves no pending answer', async () => { + const bridge = new WebSetupBridge('owner/repo'); + let finish!: (value: { prompt: { kind: 'text'; title: string }; commit: () => void }) => void; + const commit = jest.fn(); + const pending = bridge.ask({ kind: 'text', title: 'Original' }, () => new Promise(resolve => { finish = resolve; })); + const revision = bridge.snapshot().promptRevision!; + const retry = bridge.retryDiscovery(revision); + await expect(bridge.retryDiscovery(revision)).resolves.toBe('unavailable'); + expect(bridge.cancel()).toBe(true); + finish({ prompt: { kind: 'text', title: 'Late' }, commit }); + expect(await retry).toBe('stale'); + expect(commit).not.toHaveBeenCalled(); + expect(await pending).toBeUndefined(); + expect(bridge.snapshot().prompt).toBeUndefined(); + }); + + test('rejects a value outside the visible choice without consuming the prompt', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = bridge.ask({ kind: 'confirm', title: 'Apply?', choices: ['Apply setup', 'Stop'] }); + const revision = bridge.snapshot().promptRevision!; + expect(bridge.answer(revision, 'yes')).toBe(false); + expect(bridge.snapshot().promptRevision).toBe(revision); + expect(bridge.answer(revision, 'Stop')).toBe(true); + expect(await pending).toBe('Stop'); + }); + + test('Apply cannot be cancelled once the mutation boundary started', async () => { + const bridge = new WebSetupBridge('owner/repo'); + bridge.setJourney({ repository: 'owner/repo', position: 6, total: 6, current: 'Apply', complete: [], pending: [], mutationStarted: true, choiceReviewPass: 1 }); + expect(bridge.cancel()).toBe(false); + expect(bridge.snapshot().outcome).toBeUndefined(); + bridge.finish('partial', 'Inspect resources'); + expect(bridge.cancel()).toBe(false); + }); + + test('finishing resolves an unanswered prompt but preserves no secret', async () => { + const bridge = new WebSetupBridge('owner/repo'); + const pending = bridge.ask({ kind: 'secret', title: 'PAT' }); + bridge.finish('blocked', 'Session expired'); + expect(await pending).toBeUndefined(); + expect(bridge.snapshot().prompt).toBeUndefined(); + expect(bridge.snapshot().outcome).toBe('blocked'); + }); + + test('blocked result retains a specific safe reason and stage, without raw provider text', () => { + const bridge = new WebSetupBridge('owner/repo'); + bridge.setJourney({ repository: 'owner/repo', position: 4, total: 6, current: 'Plan', complete: [], pending: [], mutationStarted: false, choiceReviewPass: 1 }); + bridge.resultReason('permissions'); + bridge.finish('blocked', 'Full terminal details'); + expect(bridge.snapshot().resultDetail).toEqual({ reasonCode: 'permissions', stoppedStage: 'Plan', mutationStarted: false }); + expect(JSON.stringify(bridge.snapshot().resultDetail)).not.toContain('Full terminal details'); + }); + + test('requirement/report projection exposes role and status only', () => { + const bridge = new WebSetupBridge('owner/repo'); + bridge.requirements('setup', []); + bridge.report({ role: 'setup', identityStatus: 'valid', identityMessage: 'checked', checks: [], ready: true, confirmationRequired: false }); + expect(bridge.snapshot().permissions).toMatchObject({ role: 'setup', report: { ready: true }, requirements: [] }); + }); +}); diff --git a/src/cli/__tests__/web_setup_browser_open.test.ts b/src/cli/__tests__/web_setup_browser_open.test.ts new file mode 100644 index 000000000..6a4560625 --- /dev/null +++ b/src/cli/__tests__/web_setup_browser_open.test.ts @@ -0,0 +1,29 @@ +import { EventEmitter } from 'node:events'; +import { openWebSetupBrowser } from '../web_setup_server'; + +const mockSpawn = jest.fn(); +jest.mock('node:child_process', () => ({ spawn: (...args: unknown[]) => mockSpawn(...args) })); + +describe('local browser launch fallback', () => { + test.each([ + ['darwin', 'open', ['http://127.0.0.1:12345/']], + ['linux', 'xdg-open', ['http://127.0.0.1:12345/']], + ['win32', 'cmd', ['/c', 'start', '', 'http://127.0.0.1:12345/']], + ])('uses the %s opener and tolerates failure', (platform, command, args) => { + const original = process.platform; + Object.defineProperty(process, 'platform', { configurable: true, value: platform }); + const child = new EventEmitter() as EventEmitter & { unref: jest.Mock }; + child.unref = jest.fn(); + mockSpawn.mockReturnValueOnce(child); + const url = 'http://127.0.0.1:12345/'; + try { + openWebSetupBrowser(url); + expect(mockSpawn).toHaveBeenCalledWith(command, args, { stdio: 'ignore', detached: true, windowsHide: true }); + expect(child.unref).toHaveBeenCalledTimes(1); + expect(() => child.emit('error', new Error('No desktop opener'))).not.toThrow(); + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }); + mockSpawn.mockClear(); + } + }); +}); diff --git a/src/cli/__tests__/web_setup_browser_session.test.ts b/src/cli/__tests__/web_setup_browser_session.test.ts new file mode 100644 index 000000000..a926e24e4 --- /dev/null +++ b/src/cli/__tests__/web_setup_browser_session.test.ts @@ -0,0 +1,647 @@ +import { createSetupSession } from '../../../web/src/session/setupSession'; +import type { WebSetupView } from '../../application/contracts/web_setup_view'; + +jest.mock('svelte/store', () => ({ + writable: (initial: unknown) => { + let value = initial; + const listeners = new Set<(next: unknown) => void>(); + return { + set(next: unknown) { value = next; for (const listener of listeners) listener(value); }, + subscribe(listener: (next: unknown) => void) { listeners.add(listener); listener(value); return () => listeners.delete(listener); }, + }; + }, +})); + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +describe('browser session transport', () => { + const TEST_SESSION_KEY = 'a'.repeat(64); + const view: WebSetupView = { revision: 3, promptRevision: 7, repository: 'owner/repo', prompt: { kind: 'secret', title: 'Setup PAT' } }; + const originalFetch = globalThis.fetch; + + afterEach(() => { globalThis.fetch = originalFetch; }); + + test('starts unpaired, and does not contact the API until terminal pairing', async () => { + globalThis.fetch = jest.fn() as typeof fetch; + const session = createSetupSession(); + let state = {} as { paired: boolean }; + session.subscribe(next => { state = next; }); + await session.connect(); + await session.refresh(); + expect(state.paired).toBe(false); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('pairs through same-origin POST and keeps code and key out of observable state', async () => { + const requests: Array<{ path: string; options?: RequestInit }> = []; + globalThis.fetch = jest.fn(async (path: string, options?: RequestInit) => { + requests.push({ path, options }); + if (path === '/api/pair') return response({ sessionKey: TEST_SESSION_KEY }); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(); + let latest = ''; + session.subscribe(next => { latest = JSON.stringify(next); }); + await session.pair(' 0123456789ABCDEF '); + expect(requests.map(request => request.path)).toEqual(['/api/pair', '/api/bootstrap', '/api/state']); + expect(JSON.parse(String(requests[0].options?.body))).toEqual({ code: '0123456789abcdef' }); + expect(requests[1].options?.headers).toEqual(expect.objectContaining({ 'X-Setup-Session-Key': TEST_SESSION_KEY })); + expect(latest).toContain('"paired":true'); + expect(latest).not.toContain('0123456789abcdef'); + expect(latest).not.toContain(TEST_SESSION_KEY); + }); + + test('invalid pairing response never enables the setup UI', async () => { + globalThis.fetch = jest.fn(async () => response({ sessionKey: 'not-a-key' })) as typeof fetch; + const session = createSetupSession(); + let latest = ''; + session.subscribe(next => { latest = JSON.stringify(next); }); + await session.pair('0123456789abcdef'); + expect(latest).toContain('Invalid local pairing response'); + expect(latest).toContain('"paired":false'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + test('failed bootstrap after pairing returns to unpaired state', async () => { + globalThis.fetch = jest.fn(async (path: string) => path === '/api/pair' + ? response({ sessionKey: TEST_SESSION_KEY }) : response({ error: 'No session' }, 403)) as typeof fetch; + const session = createSetupSession(); + let latest = ''; + session.subscribe(next => { latest = JSON.stringify(next); }); + await session.pair('0123456789abcdef'); + expect(latest).toContain('"paired":false'); + expect(latest).toContain('pair again'); + }); + + test('incorrect code is shown as an error without disclosing the code', async () => { + globalThis.fetch = jest.fn(async () => response({ error: 'Incorrect pairing code.' }, 403)) as typeof fetch; + const session = createSetupSession(); + let latest = ''; + session.subscribe(next => { latest = JSON.stringify(next); }); + await session.pair('0123456789abcdef'); + expect(latest).toContain('Incorrect pairing code.'); + expect(latest).toContain('"paired":false'); + expect(latest).not.toContain('0123456789abcdef'); + }); + + test('pairing does not run concurrently or repeat after success', async () => { + let resolvePair!: (value: Response) => void; + const pendingPair = new Promise(resolve => { resolvePair = resolve; }); + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/pair') return pendingPair; + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(); + const first = session.pair('0123456789abcdef'); + await session.pair('0123456789abcdef'); + expect(requests).toEqual(['/api/pair']); + resolvePair(response({ sessionKey: TEST_SESSION_KEY })); + await first; + await session.pair('0123456789abcdef'); + expect(requests).toEqual(['/api/pair', '/api/bootstrap', '/api/state']); + }); + + test('pairing failures use a bounded generic message when the server omits one', async () => { + globalThis.fetch = jest.fn(async () => response({}, 403)) as typeof fetch; + const session = createSetupSession(); + let latest = ''; + session.subscribe(next => { latest = JSON.stringify(next); }); + await session.pair('0123456789abcdef'); + expect(latest).toContain('Pairing was rejected.'); + expect(latest).toContain('"paired":false'); + }); + + test('a non-Error pairing failure stays generic and keeps the page unpaired', async () => { + globalThis.fetch = jest.fn(async () => { throw 'network unavailable'; }) as typeof fetch; + const session = createSetupSession(); + let latest = ''; + session.subscribe(next => { latest = JSON.stringify(next); }); + await session.pair('0123456789abcdef'); + expect(latest).toContain('Could not pair this browser.'); + expect(latest).not.toContain('network unavailable'); + expect(latest).toContain('"paired":false'); + }); + + test('bootstrap and revision-bound submission keep the PAT out of observable state', async () => { + const requests: Array<{ path: string; options?: RequestInit }> = []; + globalThis.fetch = jest.fn(async (path: string, options?: RequestInit) => { + requests.push({ path, options }); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'one-run-capability', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/answer') return response({ ok: true }); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession('private-session-key'); + let latest = ''; + session.subscribe(state => { latest = JSON.stringify(state); }); + await session.connect(); + await session.submit(7, 'ghp_example_secret'); + + expect(requests.map(request => request.path)).toEqual(['/api/bootstrap', '/api/state', '/api/answer', '/api/state']); + for (const request of requests) { + expect(request.options?.headers).toEqual(expect.objectContaining({ 'X-Setup-Session-Key': 'private-session-key' })); + } + expect(requests[2].options?.headers).toEqual(expect.objectContaining({ 'X-Setup-Capability': 'one-run-capability' })); + expect(JSON.parse(String(requests[2].options?.body))).toEqual({ revision: 7, value: 'ghp_example_secret' }); + expect(latest).not.toContain('ghp_example_secret'); + expect(latest).not.toContain('private-session-key'); + await session.submit(6, 'stale'); + expect(requests).toHaveLength(4); + }); + + test('takeover replaces the controller capability and refreshes server-owned state', async () => { + const requests: Array<{ path: string; options?: RequestInit }> = []; + globalThis.fetch = jest.fn(async (path: string, options?: RequestInit) => { + requests.push({ path, options }); + if (path === '/api/bootstrap') return response({ controller: false }); + if (path === '/api/state') return response(view); + if (path === '/api/takeover') return response({ capability: 'new-capability' }); + if (path === '/api/answer') return response({ ok: true }); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let controller = false; + session.subscribe(state => { controller = state.controller; }); + await session.connect(); + await session.submit(7, 'blocked'); + expect(requests.some(request => request.path === '/api/answer')).toBe(false); + await session.takeOver(' 0123456789ABCDEF '); + expect(controller).toBe(true); + expect(requests.find(request => request.path === '/api/takeover')?.options?.headers).not.toHaveProperty('X-Setup-Capability'); + expect(JSON.parse(String(requests.find(request => request.path === '/api/takeover')?.options?.body))).toEqual({ code: '0123456789abcdef' }); + await session.submit(7, 'allowed'); + expect(requests.find(request => request.path === '/api/answer')?.options?.headers).toEqual(expect.objectContaining({ 'X-Setup-Capability': 'new-capability' })); + }); + + test('takeover cannot start before pairing, from the controller tab, or twice concurrently', async () => { + const unpaired = createSetupSession(); + globalThis.fetch = jest.fn() as typeof fetch; + await unpaired.takeOver('0123456789abcdef'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + let releaseTakeover!: (response: Response) => void; + const pendingTakeover = new Promise(resolve => { releaseTakeover = resolve; }); + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: false }); + if (path === '/api/state') return response(view); + if (path === '/api/takeover') return pendingTakeover; + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + await session.connect(); + const first = session.takeOver('0123456789abcdef'); + await session.takeOver('0123456789abcdef'); + expect((globalThis.fetch as jest.Mock).mock.calls.filter(([path]) => path === '/api/takeover')).toHaveLength(1); + releaseTakeover(response({ capability: 'new-controller' })); + await first; + await session.takeOver('0123456789abcdef'); + expect((globalThis.fetch as jest.Mock).mock.calls.filter(([path]) => path === '/api/takeover')).toHaveLength(1); + }); + + test('a rejected answer refreshes the question while keeping the error visible and the PAT private', async () => { + const requests: string[] = []; + let rejectAnswer = true; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/answer') return rejectAnswer + ? response({ error: 'This question changed. Refresh the current state.' }, 409) + : response({ accepted: true }); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest = ''; + session.subscribe(state => { latest = JSON.stringify(state); }); + await session.connect(); + await session.submit(7, 'ghp_fake_rejected'); + + expect(requests).toEqual(['/api/bootstrap', '/api/state', '/api/answer', '/api/state']); + expect(latest).toContain('This question changed'); + expect(latest).not.toContain('ghp_fake_rejected'); + expect(JSON.parse(latest).busy).toBe(false); + await session.poll(); + expect(requests.at(-1)).toBe('/api/state'); + expect(latest).toContain('This question changed'); + rejectAnswer = false; + await session.submit(7, 'safe-answer'); + expect(JSON.parse(latest).error).toBe(''); + }); + + test('a discovery retry sends only the revision and controller capability, then reads updated state', async () => { + const requests: Array<{ path: string; options?: RequestInit }> = []; + globalThis.fetch = jest.fn(async (path: string, options?: RequestInit) => { + requests.push({ path, options }); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller' }); + if (path === '/api/state') return response(view); + if (path === '/api/retry-discovery') return response({ updated: true }); + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + await session.connect(); + await session.retryDiscovery(7); + expect(requests.map(request => request.path)).toEqual(['/api/bootstrap', '/api/state', '/api/retry-discovery', '/api/state']); + expect(JSON.parse(String(requests[2].options?.body))).toEqual({ revision: 7 }); + expect(requests[2].options?.headers).toEqual(expect.objectContaining({ 'X-Setup-Capability': 'controller' })); + await session.retryDiscovery(8); + expect(requests).toHaveLength(4); + }); + + test('a rejected discovery retry retains the current question and explains the error', async () => { + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller' }); + if (path === '/api/state') return response(view); + if (path === '/api/retry-discovery') return response({ error: 'Discovery cannot be retried here. Use the manual option.' }, 409); + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = {} as { error: string; view?: WebSetupView; busy: boolean }; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.retryDiscovery(7); + expect(requests).toEqual(['/api/bootstrap', '/api/state', '/api/retry-discovery', '/api/state']); + expect(latest.error).toContain('manual option'); + expect(latest.view?.promptRevision).toBe(7); + expect(latest.busy).toBe(false); + await session.poll(); + expect(latest.error).toContain('manual option'); + }); + + test('controller transfer during a discovery retry reconnects read-only and never replays it', async () => { + let bootstraps = 0; + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') return response({ controller: ++bootstraps === 1, capability: 'controller' }); + if (path === '/api/state') return response(view); + if (path === '/api/retry-discovery') return response({ error: 'Control moved to another tab.' }, 403); + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = {} as { controller: boolean; busy: boolean }; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.retryDiscovery(7); + expect(requests).toEqual(['/api/bootstrap', '/api/state', '/api/retry-discovery', '/api/bootstrap', '/api/state']); + expect(latest.controller).toBe(false); + expect(latest.busy).toBe(false); + }); + + test('a transport exception during discovery retry produces a bounded local error', async () => { + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller' }); + if (path === '/api/state') return response(view); + if (path === '/api/retry-discovery') throw 'transport unavailable'; + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = {} as { error: string; busy: boolean }; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.retryDiscovery(7); + expect(latest.error).toBe('Could not retry discovery.'); + expect(latest.busy).toBe(false); + }); + + test('control transfer reconnects as read-only and does not replay an answer', async () => { + let bootstraps = 0; + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') { + bootstraps += 1; + return response({ controller: bootstraps === 1, capability: bootstraps === 1 ? 'old' : undefined, takeoverTicket: 'new-ticket' }); + } + if (path === '/api/state') return response(view); + if (path === '/api/answer') return response({ error: 'Control moved to another tab.' }, 403); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest: { controller: boolean; busy: boolean } | undefined; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.submit(7, 'fake-token'); + + expect(requests.filter(path => path === '/api/answer')).toHaveLength(1); + expect(bootstraps).toBe(2); + expect(latest).toMatchObject({ controller: false, busy: false }); + }); + + test('a refused cancellation leaves the server-owned journey intact', async () => { + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/cancel') return response({ error: 'This setup has already started applying or ended.' }, 409); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest = ''; + session.subscribe(state => { latest = JSON.stringify(state); }); + await session.connect(); + await session.cancel(); + + expect(latest).toContain('already started applying'); + expect(JSON.parse(latest)).toMatchObject({ controller: true, busy: false, view }); + }); + + test('successful cancellation shows the server result and clears the busy indicator', async () => { + const cancelled: WebSetupView = { revision: 4, repository: 'owner/repo', outcome: 'cancelled' }; + let stateReads = 0; + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(++stateReads === 1 ? view : cancelled); + if (path === '/api/cancel') return response({ cancelled: true }); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest: { busy: boolean; view?: WebSetupView } | undefined; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.cancel(); + expect(latest).toMatchObject({ busy: false, view: cancelled }); + }); + + test('a server rejection without a message gives a bounded generic error', async () => { + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/answer') return response({}, 409); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest = ''; + session.subscribe(state => { latest = JSON.stringify(state); }); + await session.connect(); + await session.submit(7, 'fake-secret'); + expect(latest).toContain('The request was rejected.'); + expect(latest).not.toContain('fake-secret'); + }); + + test('non-Error transport failures still give safe submission and cancellation messages', async () => { + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/answer' || path === '/api/cancel') throw 'transport unavailable'; + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest = ''; + session.subscribe(state => { latest = JSON.stringify(state); }); + await session.connect(); + await session.submit(7, 'fake-secret'); + expect(latest).toContain('Could not submit this answer.'); + expect(latest).not.toContain('fake-secret'); + await session.cancel(); + expect(latest).toContain('Cancellation failed.'); + }); + + test('an unexpected takeover transport failure keeps the tab read-only', async () => { + let bootstrapReads = 0; + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') { bootstrapReads += 1; return response({ controller: false }); } + if (path === '/api/state') return response(view); + if (path === '/api/takeover') throw 'transport unavailable'; + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let controller = true; + session.subscribe(state => { controller = state.controller; }); + await session.connect(); + await session.takeOver('0123456789abcdef'); + expect(bootstrapReads).toBe(1); + expect(controller).toBe(false); + }); + + test('a busy submission cannot send a second answer or cancel concurrently', async () => { + let resolveAnswer: ((value: Response) => void) | undefined; + const pendingAnswer = new Promise(resolve => { resolveAnswer = resolve; }); + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'controller', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/answer') return pendingAnswer; + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + await session.connect(); + const first = session.submit(7, 'first'); + await session.submit(7, 'duplicate'); + await session.cancel(); + expect(requests.filter(path => path === '/api/answer')).toHaveLength(1); + expect(requests).not.toContain('/api/cancel'); + resolveAnswer!(response({ accepted: true })); + await first; + }); + + test('overlapping refresh calls do not race to replace the current view', async () => { + let resolveState: ((value: Response) => void) | undefined; + const pendingState = new Promise(resolve => { resolveState = resolve; }); + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/state') return pendingState; + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + const first = session.refresh(); + await session.refresh(); + expect(requests).toEqual(['/api/state']); + resolveState!(response(view)); + await first; + }); + + test('a failed takeover retains read-only state and explains the rejected code', async () => { + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') return response({ controller: false }); + if (path === '/api/state') return response(view); + if (path === '/api/takeover') return response({ error: 'Incorrect pairing code.' }, 403); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest: { controller: boolean; busy: boolean; error: string } | undefined; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.takeOver('0123456789abcdef'); + + expect(requests.filter(path => path === '/api/bootstrap')).toHaveLength(1); + expect(latest).toMatchObject({ controller: false, busy: false, error: 'Incorrect pairing code.' }); + await session.poll(); + expect(latest?.error).toBe('Incorrect pairing code.'); + }); + + test('failed bootstrap and state requests are reported without claiming a live session', async () => { + globalThis.fetch = jest.fn(async (path: string) => path === '/api/bootstrap' + ? response({ error: 'Unavailable' }, 503) : response(view)) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = ''; + session.subscribe(state => { latest = JSON.stringify(state); }); + await session.connect(); + expect(latest).toContain('Could not connect'); + expect(JSON.parse(latest).controller).toBe(false); + + globalThis.fetch = jest.fn(async () => response({ error: 'Unavailable' }, 503)) as typeof fetch; + const stillPaired = createSetupSession(TEST_SESSION_KEY); + stillPaired.subscribe(state => { latest = JSON.stringify(state); }); + await stillPaired.refresh(); + expect(latest).toContain('Connection lost'); + expect(JSON.parse(latest).view).toBeUndefined(); + }); + + test('a failed reconnect drops the old controller capability and cannot replay a PAT', async () => { + let available = true; + const requests: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + requests.push(path); + if (path === '/api/bootstrap') return available + ? response({ controller: true, capability: 'old-controller', takeoverTicket: 'ticket' }) + : response({ error: 'Unavailable' }, 503); + if (path === '/api/state') return response(view); + throw new Error('Unexpected route'); + }) as typeof fetch; + + const session = createSetupSession(TEST_SESSION_KEY); + let latest: { controller: boolean; error: string; view?: WebSetupView } | undefined; + session.subscribe(state => { latest = state; }); + await session.connect(); + expect(latest?.controller).toBe(true); + available = false; + await session.connect(); + expect(latest).toMatchObject({ controller: false, error: expect.stringContaining('Could not connect') }); + expect(latest?.view).toBeUndefined(); + await session.submit(7, 'fake-sensitive-pat'); + expect(requests).not.toContain('/api/answer'); + }); + + test('control transfer during cancellation reconnects without reporting success', async () => { + let bootstraps = 0; + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: ++bootstraps === 1, capability: 'old', takeoverTicket: 'ticket' }); + if (path === '/api/state') return response(view); + if (path === '/api/cancel') return response({ error: 'Control moved to another tab.' }, 403); + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest: { controller: boolean; busy: boolean } | undefined; + session.subscribe(state => { latest = state; }); + await session.connect(); + await session.cancel(); + expect(bootstraps).toBe(2); + expect(latest).toMatchObject({ controller: false, busy: false }); + }); + + test('failure to close the browser session can be handled in the terminal', async () => { + globalThis.fetch = jest.fn(async () => { throw new Error('CLI stopped'); }) as typeof fetch; + await expect(createSetupSession(TEST_SESSION_KEY).close()).resolves.toBeUndefined(); + }); + + test('read-only doctor is available only after success to the controller, refreshes redacted results, and cannot run twice concurrently', async () => { + const complete: WebSetupView = { ...view, outcome: 'complete' }; + let releaseDoctor!: (value: Response) => void; + const pendingDoctor = new Promise(resolve => { releaseDoctor = resolve; }); + let currentView = complete; + const requests: Array<{ path: string; options?: RequestInit }> = []; + globalThis.fetch = jest.fn(async (path: string, options?: RequestInit) => { + requests.push({ path, options }); + if (path === '/api/bootstrap') return response({ controller: true, capability: 'doctor-capability' }); + if (path === '/api/state') return response(currentView); + if (path === '/api/doctor') return pendingDoctor; + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = {} as { busy: boolean; error: string; view?: WebSetupView }; + session.subscribe(next => { latest = next; }); + await session.runDoctor(); + expect(requests).toHaveLength(0); + await session.connect(); + const first = session.runDoctor(); + await session.runDoctor(); + expect(requests.filter(request => request.path === '/api/doctor')).toHaveLength(1); + expect(latest.view?.doctor?.status).toBe('running'); + expect(requests.find(request => request.path === '/api/doctor')?.options?.headers) + .toEqual(expect.objectContaining({ 'X-Setup-Capability': 'doctor-capability' })); + currentView = { ...complete, doctor: { status: 'complete', healthy: true, pass: 3, warn: 1, fail: 0, skipped: 2 } }; + releaseDoctor(response({ ok: true })); + await first; + expect(latest).toMatchObject({ busy: false, error: '', view: { doctor: { status: 'complete', pass: 3 } } }); + }); + + test('doctor failure preserves success, reports the error, and refreshes the failed status', async () => { + const complete: WebSetupView = { ...view, outcome: 'complete' }; + let currentView = complete; + globalThis.fetch = jest.fn(async (path: string) => { + if (path === '/api/bootstrap') return response({ controller: true, capability: 'doctor-capability' }); + if (path === '/api/state') return response(currentView); + if (path === '/api/doctor') { + currentView = { ...complete, doctor: { status: 'failed' } }; + return response({ error: 'Read-only diagnosis is unavailable.' }, 503); + } + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = {} as { busy: boolean; error: string; view?: WebSetupView }; + session.subscribe(next => { latest = next; }); + await session.connect(); + await session.runDoctor(); + expect(latest).toMatchObject({ busy: false, error: 'Read-only diagnosis is unavailable.', view: { outcome: 'complete', doctor: { status: 'failed' } } }); + }); + + test('Back is revision-bound, preserves the server-owned question, and handles stale control safely', async () => { + let controller = true; + let failBack = false; + const previous: WebSetupView = { ...view, promptRevision: 8, prompt: { kind: 'question', title: 'Production branch', phase: 'plan', pass: 1, question: { + stateId: 'repository', id: 'repository.mainBranch', label: 'Production branch', kind: 'text', defaultValue: 'main', + } } }; + let currentView = view; + const paths: string[] = []; + globalThis.fetch = jest.fn(async (path: string) => { + paths.push(path); + if (path === '/api/bootstrap') return response({ controller, ...(controller ? { capability: 'controller-capability' } : {}) }); + if (path === '/api/state') return response(currentView); + if (path === '/api/back') { + if (failBack) { controller = false; return response({ error: 'Control moved to another tab.' }, 403); } + currentView = previous; + return response({ accepted: true }); + } + throw new Error('Unexpected route'); + }) as typeof fetch; + const session = createSetupSession(TEST_SESSION_KEY); + let latest = {} as { controller: boolean; busy: boolean; view?: WebSetupView }; + session.subscribe(next => { latest = next; }); + await session.back(7); + expect(paths).toHaveLength(0); + await session.connect(); + await session.back(6); + expect(paths).not.toContain('/api/back'); + await session.back(7); + expect(latest).toMatchObject({ busy: false, view: { promptRevision: 8 } }); + failBack = true; + await session.back(8); + expect(latest.controller).toBe(false); + expect(paths.filter(path => path === '/api/back')).toHaveLength(2); + }); +}); diff --git a/src/cli/__tests__/web_setup_catalog.test.ts b/src/cli/__tests__/web_setup_catalog.test.ts new file mode 100644 index 000000000..35bd30f8f --- /dev/null +++ b/src/cli/__tests__/web_setup_catalog.test.ts @@ -0,0 +1,310 @@ +import { en, es, setupCatalogs, setupLocales, stageLabel, tr } from '../../../web/src/i18n/catalog'; +import { permissionName, permissionStatus, permissionTerm, permissionTermCatalogs } from '../../../web/src/i18n/permissionTerms'; +import { isQuestionOptionLocalized, optionCatalogs, questionOptionLabel } from '../../../web/src/i18n/questionOptions'; +import { setupQuestionContentInventory } from '../../application/policies/setup_questionnaire_policy'; +import { permissionCopy, permissionCopyCatalogs, isKnownPermissionCopy } from '../../../web/src/i18n/permissionCopy'; +import { permissionTexts } from '../../../web/src/i18n/permissions/en'; +import * as ts from 'typescript'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { promptCopyCatalogs, localizedPromptChoice, localizedPromptCopy } from '../../../web/src/i18n/promptCopy'; +import { messageCopyCatalogs, localizedMessage } from '../../../web/src/i18n/messageCopy'; +import { localizedSessionError, sessionErrorCatalogs } from '../../../web/src/i18n/sessionErrors'; +import { localizedPlanWarning, planWarningCatalogs } from '../../../web/src/i18n/planWarningCopy'; +import { agentRoleName, agentRoleNames } from '../../../web/src/i18n/agentRoleNames'; +import { projectTransitionKey } from '../../../web/src/i18n/projectTransitions'; +import { validationCopy } from '../web_setup_adapters'; +import { translatedQuestionLabel } from '../../application/policies/setup_question_labels_fr_pt'; + +describe('web setup localization catalog', () => { + test('English is the default and only the four selected locales are advertised', () => { + expect(setupLocales).toEqual(['en', 'es', 'fr', 'pt']); + expect(es).toBe(setupCatalogs.es); + const keys = Object.keys(en).sort(); + for (const locale of setupLocales) { + expect(Object.keys(setupCatalogs[locale]).sort()).toEqual(keys); + for (const key of keys) { + const value = setupCatalogs[locale][key as keyof typeof en]; + expect(value.trim()).not.toBe(''); + expect([...value.matchAll(/\{([a-zA-Z]\w*)\}/gu)].map(match => match[1]).sort()) + .toEqual([...en[key as keyof typeof en].matchAll(/\{([a-zA-Z]\w*)\}/gu)].map(match => match[1]).sort()); + } + } + }); + + test('shared question labels route English and Spanish without leaking terminal selector hints', () => { + const question = { stateId: 'capabilities' as const, id: 'features.issues', label: 'Issue workflows (Space toggles, Enter confirms)', + kind: 'boolean' as const, defaultValue: true }; + expect(translatedQuestionLabel(question, 'en')).toBe('Issue workflows'); + expect(translatedQuestionLabel(question, 'es')).not.toContain('Space toggles'); + }); + + test('unsupported runtime locale falls back to complete English copy', () => { + expect(tr('blockedTitle', 'invalid' as typeof setupLocales[number])).toBe(en.blockedTitle); + }); + + test('interpolation and stage names follow the selected locale', () => { + expect(tr('reviewPass', 'es', { pass: '2' })).toContain('pasada 2'); + expect(tr('reviewPass', 'en')).not.toContain('{pass}'); + expect(stageLabel('Setup PAT', 'fr')).toBe(tr('setupPat', 'fr')); + expect(stageLabel('unrecognized', 'en')).toBe(tr('gettingReady', 'en')); + }); + + test('public permission and prompt copy translate known text and omit missing placeholders safely', () => { + const known = 'Inspect selected Projects and their Status options; setup does not edit Project items.'; + expect(permissionCopy('es', known)).toContain('Status'); + expect(permissionCopy('en', known)).toBe(known); + expect(permissionCopy('fr', 'Provider-generated detail')).toBe(tr('permissionUnknown', 'fr')); + expect(localizedPromptCopy({ kind: 'choice', title: 'raw', choices: [], copyId: 'setupPat.confirmAccount', + copyValues: { account: 'bot' } }, 'es')?.title).toContain('bot'); + expect(localizedPromptCopy({ kind: 'choice', title: 'raw', choices: [], copyId: 'setupPat.confirmAccount' }, 'en')?.title) + .not.toContain('{account}'); + expect(localizedPromptCopy({ kind: 'choice', title: 'raw', choices: [] }, 'es')).toBeUndefined(); + expect(localizedPromptCopy({ kind: 'choice', title: 'raw', choices: [], copyId: 'credential.existing', + copyValues: { status: 'valid' } }, 'fr')?.title).toContain('Valide'); + expect(localizedPromptCopy({ kind: 'choice', title: 'raw', choices: [], copyId: 'credential.existing', + copyValues: { status: 'provider-specific' } }, 'es')?.title).toContain('provider-specific'); + expect(localizedPromptChoice({ kind: 'choice', title: 'raw', choices: ['Fallback'], copyId: 'credential.apiKey' }, 'es', 0)) + .toBe('Fallback'); + }); + + test('every known questionnaire validation has localized copy and unknown text cannot leak English into other locales', () => { + const messages = [ + 'This is the first question in this pass. Review it or cancel setup.', + 'A trusted check was selected more than once.', + 'The saved Status value is not available in every selected Project. Choose a listed Status option.', + 'Selected Projects have no common Status option. Choose compatible Projects or configure them separately.', + 'Choose at most 10 Projects; separate numbers or URLs with commas.', + 'A Project URL needs a known repository owner; enter its positive number instead.', + 'Use a GitHub Project URL belonging to acme, without query parameters.', + 'Enter a valid GitHub Project URL or positive Project number.', + 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.', + 'Project numbers must be positive integers at most 2147483647.', + 'Project 7 was selected more than once.', + 'Issue automation is required by an explicit release or hotfix override. Keep Issues enabled or edit your configuration.', + 'The release workflow must remain enabled because it is fixed by your configuration. Match that choice or edit your configuration.', + 'The hotfix workflow must remain disabled because it is fixed by your configuration. Match that choice or edit your configuration.', + ]; + for (const message of messages) { + const copy = validationCopy(message); + expect(copy).toBeDefined(); + for (const locale of ['es', 'fr', 'pt'] as const) { + expect(localizedMessage({ tone: 'warning', text: message, copyId: copy!.id, copyValues: copy!.values }, locale)) + .not.toBe(message); + } + } + expect(localizedMessage({ tone: 'warning', text: 'unexpected English diagnostic' }, 'es')).toBe(tr('unknownLocalError', 'es')); + }); + + test('every non-English preview explicitly discloses untranslated setup text', () => { + for (const locale of setupLocales.filter(item => item !== 'en')) { + expect(tr('translationPreviewTitle', locale).trim()).not.toBe(''); + expect(tr('translationPreviewBody', locale)).not.toEqual(en.translationPreviewBody); + } + }); + + test('permission metadata uses translated labels without changing permission identities', () => { + for (const section of ['names', 'terms'] as const) { + const keys = Object.keys(permissionTermCatalogs[section].en).sort(); + for (const locale of setupLocales) expect(Object.keys(permissionTermCatalogs[section][locale]).sort()).toEqual(keys); + } + for (const locale of setupLocales) { + for (const term of ['repository', 'organization', 'read', 'write', 'required', 'conditional', 'verified', 'missing', 'unverifiable'] as const) { + expect(permissionTerm(locale, term).trim()).not.toBe(''); + } + } + expect(permissionTerm('es', 'write')).toBe('Escritura'); + expect(permissionTerm('fr', 'unverifiable')).toBe('Non vérifiable'); + expect(permissionStatus('es', 'verified')).toBe('Verificado'); + expect(permissionStatus('es', 'unknown-status')).toBeUndefined(); + for (const locale of setupLocales) { + for (const name of ['Metadata', 'Contents', 'Secrets', 'Variables', 'Issues', 'Actions', 'Checks', 'Administration', 'Workflows', 'Issue Types', 'Projects', 'Pull requests', 'Members']) { + expect(permissionName(locale, name).trim()).not.toBe(''); + } + } + expect(permissionName('es', 'Checks')).toBe('Comprobaciones'); + expect(permissionName('fr', 'Workflows')).toBe('Flux de travail'); + expect(permissionName('es', 'Unknown')).toBe('Unknown'); + }); + + test('static question options have the same keys in every translated catalog', () => { + const keys = Object.keys(optionCatalogs.es).sort(); + for (const locale of ['fr', 'pt'] as const) { + expect(Object.keys(optionCatalogs[locale]).sort()).toEqual(keys); + } + for (const question of setupQuestionContentInventory()) { + for (const choice of question.choices ?? []) { + for (const locale of ['es', 'fr', 'pt'] as const) { + expect(isQuestionOptionLocalized(question.id, choice, locale)).toBe(true); + } + } + } + expect(questionOptionLabel('issueWorkflows.enabled', 'feature — Feature', 'fr')).toBe('feature — Fonctionnalité'); + expect(questionOptionLabel('repository.reconciliationCleanup', 'all', 'pt')).toBe('Todos os ramos temporários'); + expect(questionOptionLabel('agents.findings.provider', 'codex', 'es')).toBe('codex'); + expect(isQuestionOptionLocalized('pullRequestApproval.coverage.checkName', 'Coverage from GitHub', 'fr')).toBe(true); + }); + + test('plan review names every agent role in each supported language', () => { + for (const locale of setupLocales) { + expect(Object.keys(agentRoleNames[locale]).sort()).toEqual(Object.keys(agentRoleNames.en).sort()); + for (const role of ['planner', 'findings', 'reviewer', 'fixer', 'tester']) { + expect(agentRoleName(role, locale).trim()).not.toBe(''); + } + } + expect(agentRoleName('planner', 'es')).toBe('Planificador'); + expect(agentRoleName('findings', 'fr')).toBe('Analyste des problèmes'); + }); + + test('question and plan presenters share the same localized Project Status transition labels', () => { + expect(Object.keys(projectTransitionKey).sort()).toEqual([ + 'issueCreated', 'issueInProgress', 'pullRequestCreated', 'pullRequestInProgress', + ]); + for (const locale of setupLocales) { + for (const key of Object.values(projectTransitionKey)) expect(tr(key, locale).trim()).not.toBe(''); + } + }); + + test('every literal permission reason and condition has exactly one translated key', () => { + const path = resolve(__dirname, '../../application/policies/setup_token_permission_policy.ts'); + const file = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); + const emitted = new Set(); + const collect = (node: ts.Node): void => { + if (ts.isStringLiteral(node)) emitted.add(node.text); + else ts.forEachChild(node, collect); + }; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) && ['reason', 'condition'].includes(node.name.getText(file))) collect(node.initializer); + ts.forEachChild(node, visit); + }; + visit(file); + expect([...permissionTexts].sort()).toEqual([...emitted].sort()); + for (const locale of ['es', 'fr', 'pt'] as const) { + expect(Object.keys(permissionCopyCatalogs[locale]).sort()).toEqual([...permissionTexts].sort()); + for (const text of permissionTexts) { + expect(isKnownPermissionCopy(text)).toBe(true); + expect(permissionCopyCatalogs[locale][text].trim()).not.toBe(''); + expect(permissionCopyCatalogs[locale][text]).not.toBe(text); + } + } + }); + + test('every prompt has the same translated keys, placeholders and choice count', () => { + const ids = Object.keys(promptCopyCatalogs.en).sort(); + for (const locale of setupLocales) { + expect(Object.keys(promptCopyCatalogs[locale]).sort()).toEqual(ids); + for (const id of ids) { + const source = promptCopyCatalogs.en[id as keyof typeof promptCopyCatalogs.en]; + const copy = promptCopyCatalogs[locale][id as keyof typeof promptCopyCatalogs.en]; + expect(copy.title.trim()).not.toBe(''); + expect(copy.description.trim()).not.toBe(''); + for (const field of ['title', 'description'] as const) { + const placeholders = (value: string) => [...value.matchAll(/\{([a-zA-Z]\w*)\}/gu)].map(match => match[1]).sort(); + expect(placeholders(copy[field])).toEqual(placeholders(source[field])); + if (locale !== 'en') expect(copy[field]).not.toBe(source[field]); + } + expect(copy.choices?.length ?? 0).toBe(source.choices?.length ?? 0); + } + } + const prompt = { kind: 'choice' as const, title: 'How will you provide your setup PAT?', choices: ['Guided GitHub link', 'Manual PAT'], copyId: 'setupPat.method' as const }; + expect(localizedPromptChoice(prompt, 'fr', 0)).toBe('Lien GitHub guidé'); + expect(prompt.choices[0]).toBe('Guided GitHub link'); + }); + + test('progress and validation messages have identical semantic keys and placeholders', () => { + const ids = Object.keys(messageCopyCatalogs.en).sort(); + const placeholders = (value: string) => [...value.matchAll(/\{([a-zA-Z]\w*)\}/gu)].map(match => match[1]).sort(); + for (const locale of setupLocales) { + expect(Object.keys(messageCopyCatalogs[locale]).sort()).toEqual(ids); + for (const id of ids) { + const source = messageCopyCatalogs.en[id as keyof typeof messageCopyCatalogs.en]; + const translated = messageCopyCatalogs[locale][id as keyof typeof messageCopyCatalogs.en]; + expect(translated.trim()).not.toBe(''); + expect(placeholders(translated)).toEqual(placeholders(source)); + if (locale !== 'en') expect(translated).not.toBe(source); + } + } + expect(localizedMessage({ tone: 'info', text: 'Plan ready', copyId: 'plan.ready', copyValues: { files: '2', variables: '1', secrets: '3' } }, 'es')) + .toContain('2 archivos, 1 Variables y 3 nombres'); + const preview = localizedMessage({ tone: 'info', text: 'raw English', copyId: 'permission.preview', copyValues: { + issues: 'feature|bugfix', approval: 'guarded', secrets: 'repository', variables: 'off', projects: 'none', + } }, 'es'); + expect(preview).toContain('Funcionalidad, Corrección'); + expect(preview).toContain('Aprobar solo con garantías'); + expect(preview).not.toContain('raw English'); + expect(localizedMessage({ tone: 'warning', text: 'raw English', copyId: 'credential.checks', + copyValues: { count: '2', names: 'BOT_PAT, API_KEY' }, credentialChecks: [ + { name: 'BOT_PAT', status: 'valid' }, { name: 'API_KEY', status: 'unverifiable' }, + ] }, 'fr')).toContain('API_KEY: Invérifiable sans nouvelle valeur'); + }); + + test('local session errors have exact key parity and a translated unknown-error fallback', () => { + const keys = Object.keys(sessionErrorCatalogs.en).sort(); + for (const locale of setupLocales) { + expect(Object.keys(sessionErrorCatalogs[locale]).sort()).toEqual(keys); + for (const key of keys) { + const value = sessionErrorCatalogs[locale][key as keyof typeof sessionErrorCatalogs.en]; + expect(value.trim()).not.toBe(''); + if (locale !== 'en') expect(value).not.toBe(sessionErrorCatalogs.en[key as keyof typeof sessionErrorCatalogs.en]); + } + expect(localizedSessionError('unrecognized server detail', locale)).toBe(tr('unknownLocalError', locale)); + } + expect(localizedSessionError('Incorrect pairing code. Check the terminal.', 'es').toLowerCase()).toContain('código'); + }); + + test('every static HTTP error and final doctor/back failure has reviewed four-language copy', () => { + const server = readFileSync(resolve(__dirname, '../web_setup_server.ts'), 'utf8'); + const errors = new Set([...server.matchAll(/error:\s*'([^']+)'/gu)].map(match => match[1])); + for (const extra of ['No earlier question is available here.', 'Read-only verification failed. Check the terminal.', + 'Read-only verification is unavailable or already running.', 'Could not retry discovery.', + 'Could not return to the previous question.', 'Read-only verification failed.']) errors.add(extra); + for (const error of errors) { + expect(Object.prototype.hasOwnProperty.call(sessionErrorCatalogs.en, error)).toBe(true); + for (const locale of ['es', 'fr', 'pt'] as const) { + expect(localizedSessionError(error, locale)).not.toBe(tr('unknownLocalError', locale)); + } + } + }); + + test('all current plan warnings have exactly one translation per language', () => { + const path = resolve(__dirname, '../../application/policies/setup_configuration_plan.ts'); + const file = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); + const emitted = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && node.expression.getText(file) === 'warnings.push' && ts.isStringLiteral(node.arguments[0])) { + emitted.add(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(file); + expect(Object.keys(planWarningCatalogs.en).sort()).toEqual([...emitted].sort()); + for (const locale of setupLocales) { + expect(Object.keys(planWarningCatalogs[locale]).sort()).toEqual([...emitted].sort()); + for (const warning of emitted) { + const translated = localizedPlanWarning(warning, locale); + expect(translated.trim()).not.toBe(''); + if (locale !== 'en') expect(translated).not.toBe(warning); + } + } + expect(localizedPlanWarning('Unreviewed English warning', 'es')).toBe(tr('planUnknownWarning', 'es')); + }); + + test('unknown server-side validation is not mislabeled as a known translated rule', () => { + expect(validationCopy('A future validation rule.')).toBeUndefined(); + }); + + test('localized release and hotfix warnings distinguish installed automation from disabled events', () => { + const warnings = [ + 'Release automation is installed, but release issue events are disabled by the selected issue workflow profile.', + 'Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.', + ]; + const installedPhrases = { en: 'is installed', es: 'ya está instalada', fr: 'est déjà installée', pt: 'já está instalada' } as const; + for (const locale of setupLocales) { + for (const warning of warnings) { + const translated = localizedPlanWarning(warning, locale); + expect(translated).toContain(installedPhrases[locale]); + expect(translated).toMatch(/disable|desactiva|désactive|desativa/u); + } + } + }); +}); diff --git a/src/cli/__tests__/web_setup_components.test.ts b/src/cli/__tests__/web_setup_components.test.ts new file mode 100644 index 000000000..5b0948366 --- /dev/null +++ b/src/cli/__tests__/web_setup_components.test.ts @@ -0,0 +1,451 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { setupQuestionPresentation } from '../../application/policies/setup_question_guidance_policy'; +import type { SetupQuestion } from '../../domain/setup_questionnaire'; + +function markup(name: string, props: Record, locale = 'en'): string { + return execFileSync(process.execPath, [ + resolve(__dirname, '../../../scripts/render-web-setup-component.cjs'), name, JSON.stringify(props), locale, + ], { encoding: 'utf8' }); +} + +const noOp = async (): Promise => undefined; + +describe('web setup component semantics', () => { + test('pairing screen explains terminal code without exposing a key in the URL', () => { + const html = markup('PairingPanel', { busy: false }); + expect(html).toContain('Pair this browser'); + expect(html).toContain('Pairing code from terminal'); + expect(html).toContain('16-character pairing code'); + expect(html).toContain('After refreshing'); + expect(html).not.toContain('setup-key'); + }); + test.each([ + ['en', 'Pair this browser'], ['es', 'Vincula este navegador'], + ['fr', 'Associer ce navigateur'], ['pt', 'Emparelhar este navegador'], + ])('%s has a localized pairing screen', (locale, label) => { + const html = markup('PairingPanel', { busy: false }, locale); + expect(html).toContain(label); + expect(html).toContain('copilot setup --web'); + }); + test('language selector lists exactly the four supported languages, with English first', () => { + const html = markup('LanguageSwitch', {}); + for (const code of ['en', 'es', 'fr', 'pt']) { + expect(html).toContain(`value="${code}"`); + } + expect(html.indexOf('value="en"')).toBeLessThan(html.indexOf('value="es"')); + expect(html).not.toContain('value="ar"'); + expect(html).toContain('aria-live="polite"'); + }); + test.each([ + ['complete', 'Your configuration was applied', 'not revoked automatically'], + ['dry-run', 'No changes were made', 'did not begin applying'], + ['cancelled', 'No setup changes started', 'did not begin applying'], + ['blocked', 'No setup changes started', 'did not begin applying'], + ['partial', 'Check partial changes before retrying', 'may have succeeded'], + ])('%s result explains actual mutation state and PAT cleanup', (outcome, heading, explanation) => { + const html = markup('ResultPanel', { outcome, controller: true, onClose: noOp }); + expect(html).toContain(heading); + expect(html).toContain(explanation); + expect(html).toContain('Close local session'); + expect(html).toContain('copilot doctor'); + }); + test('completed Spanish result offers safe in-page verification and explains unverified Secret values', () => { + const before = markup('ResultPanel', { outcome: 'complete', controller: true, onClose: noOp }, 'es'); + expect(before).toContain('Comprobar instalación (solo lectura)'); + const html = markup('ResultPanel', { outcome: 'complete', controller: true, onClose: noOp, + doctor: { status: 'complete', healthy: false, pass: 4, warn: 1, fail: 0, skipped: 2 } }, 'es'); + expect(html).toContain('4 correctas'); + expect(html).toContain('Este modo no puede verificar los valores de Secrets.'); + expect(html).toContain('copilot doctor --read-only'); + expect(html).not.toContain('private GitHub diagnostic'); + }); + + test('blocked page identifies the cause and next action in the chosen language', () => { + const detail = { reasonCode: 'permissions', stoppedStage: 'Plan', mutationStarted: false }; + const html = markup('ResultPanel', { outcome: 'blocked', detail, controller: true }, 'es'); + expect(html).toContain('Faltan permisos del PAT'); + expect(html).toContain('Plan'); + expect(html).toContain('Comprueba los permisos mostrados'); + expect(html).toContain('No se iniciaron cambios'); + }); + + test('partial result shows structured cause, safe effects, and diagnostic reference', () => { + const html = markup('ResultPanel', { outcome: 'partial', controller: true, onClose: noOp, + detail: { reasonCode: 'provider', stoppedStage: 'Apply', mutationStarted: true, + diagnosticRef: '12345678-1234-4123-8123-123456789abc', + effects: [{ id: 'files', state: 'completed' }, { id: 'secret', state: 'needs-inspection' }] } }); + expect(html).toContain('GitHub or another provider did not complete'); + expect(html).toContain('Reported completed'); + expect(html).toContain('Outcome needs inspection'); + expect(html).toContain('12345678-1234-4123-8123-123456789abc'); + }); + + test('French technical question guidance is complete, not a mixed-language preview', () => { + const question: SetupQuestion = { stateId: 'pull-request-approval', id: 'pullRequestApproval.testChecks', + label: 'Trusted checks', kind: 'text', defaultValue: '' }; + const html = markup('QuestionPrompt', { prompt: { + kind: 'question', title: 'Approval', phase: 'full', pass: 1, + question, presentation: setupQuestionPresentation(question), + }, controller: true, busy: false }, 'fr'); + expect(html).toContain('Quelles vérifications CI sont fiables pour approuver ?'); + expect(html).toContain('jobs CI'); + expect(html).not.toContain('Detailed guidance below is currently available in English'); + }); + + test.each([ + ['en', 'Keep release selected'], ['es', 'Mantén release seleccionado'], + ['fr', 'Gardez release sélectionné'], ['pt', 'Mantenha release selecionado'], + ])('%s PAT-intent workflow question explains fixed feature overrides before input', (locale, expected) => { + const html = markup('QuestionPrompt', { prompt: { kind: 'question', title: 'Issue workflows', phase: 'permission-intent', pass: 1, + question: { stateId: 'capabilities', id: 'issueWorkflows.enabled', label: 'Issue workflows', kind: 'multi-select', + defaultValue: 'feature,release', choices: ['feature', 'release', 'hotfix'], + fixedWorkflowFeatures: { release: true, hotfix: false } } }, controller: true, busy: false }, locale); + expect(html).toContain(expected); + expect(html).toContain('features.hotfix=false'); + }); + + test('question details explain where, how and why, with a safe contextual link', () => { + const html = markup('QuestionPrompt', { prompt: { + kind: 'question', title: 'Repository', phase: 'full', pass: 1, + question: { stateId: 'repository', id: 'repository.mainBranch', label: 'Production branch', kind: 'text', defaultValue: 'main' }, + presentation: { + en: { label: 'Production branch', summary: 'Choose the production branch.', when: 'Before release.', + where: 'Repository settings.', how: 'Enter its exact name.', why: 'Releases target this branch.', + example: 'main', effect: 'Changes release target.', verify: 'Review plan.', + documentation: { title: 'Copilot configuration', url: 'https://docs.page/vypdev/copilot/configuration' } }, + es: { label: 'Rama de producción', summary: 'Elige la rama de producción.', when: 'Antes de publicar.', + where: 'Configuración del repositorio.', how: 'Escribe el nombre exacto.', why: 'La publicación usa esta rama.', + example: 'main', effect: 'Cambia el destino.', verify: 'Revisa el plan.', + documentation: { title: 'Configuración de Copilot', url: 'https://docs.page/vypdev/copilot/configuration' } }, + }, + }, controller: true, busy: false }, 'es'); + expect(html).toContain('Dónde se configura'); + expect(html).toContain('Cómo elegir'); + expect(html).toContain('Por qué importa'); + expect(html).toContain('https://docs.page/vypdev/copilot/configuration'); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html.indexOf('question-help-link')).toBeLessThan(html.indexOf(' { + const html = markup('QuestionPrompt', { prompt: { + kind: 'question', title: 'Projects', phase: 'full', pass: 1, + question: { stateId: 'projects', id: 'projects.ids', label: 'Projects', kind: 'project-select', defaultValue: '', + discoveryStatus: 'empty', discoveryRetryRemaining: 2, projectCandidates: [], projectOwner: 'acme' }, + }, controller: true, busy: false }, locale); + expect(html).toContain(retryLabel); + expect(html).toContain('2'); + expect(html).toContain('discovery-actions'); + }); + + test('discovery scope is specific and unsupported personal Projects do not offer retry', () => { + const checkNotice = markup('DiscoveryNotice', { kind: 'checks', status: 'observed' }, 'en'); + expect(checkNotice).toContain('20 recent pull-request workflow runs'); + expect(checkNotice).toContain('15 runs'); + const projectNotice = markup('DiscoveryNotice', { kind: 'projects', status: 'observed' }, 'en'); + expect(projectNotice).toContain('30 open, accessible organization Projects'); + expect(projectNotice).toContain('Closed Projects are excluded'); + const unsupported = markup('QuestionPrompt', { prompt: { kind: 'question', title: 'Projects', phase: 'full', pass: 1, + question: { stateId: 'projects', id: 'projects.ids', label: 'Projects', kind: 'project-select', defaultValue: '', + discoveryStatus: 'unsupported', projectCandidates: [], projectOwner: 'owner' } }, controller: true, busy: false }, 'en'); + expect(unsupported).not.toContain('Retry GitHub discovery'); + expect(unsupported).toContain('fine-grained PAT'); + }); + + test('coverage selector shows the selected producer identity and the observation limitation', () => { + const html = markup('QuestionPrompt', { prompt: { + kind: 'question', title: 'Coverage', phase: 'full', pass: 1, + question: { stateId: 'pull-request-approval', id: 'pullRequestApproval.coverage.checkName', + label: 'Coverage check', kind: 'choice', defaultValue: 'Tests', choices: ['Tests'], + trustedProducers: [{ name: 'Tests', workflowName: 'CI', sourceAppId: 12 }], + producerCandidates: [{ name: 'Tests', workflowName: 'CI', sourceAppId: 12, + runUrl: 'https://github.com/acme/repo/actions/runs/7', headSha: 'a'.repeat(40), + conclusion: 'success', observedAt: '2026-09-29T10:00:00Z' }] }, + }, controller: true, busy: false }, 'en'); + expect(html).toContain('Tests — CI · App 12'); + expect(html).toContain('2026-09-29T10:00:00Z'); + expect(html).toContain('Required by branch rule: not checked'); + }); + + test('manual producer App ID remains string-bound while offering a numeric keyboard', () => { + const html = markup('ProducerSelector', { candidates: [], selected: [], controller: true }); + expect(html).toMatch(/id="producer-app-id"[^>]*type="text"[^>]*inputmode="numeric"/u); + expect(html).toContain('pattern="[1-9][0-9]*"'); + }); + + test('a Project retained across discovery refresh remains visible as unverified and removable', () => { + const html = markup('ProjectSelector', { candidates: [], selected: ['12'], value: '', controller: true }, 'en'); + expect(html).toContain('#12'); + expect(html).toContain('no longer appear in this GitHub result'); + expect(html).toContain('Remove selection'); + }); + + test('manual Project Status attestation displays every transition and exact value', () => { + const question: SetupQuestion = { stateId: 'projects', id: 'projects.statusVerified', label: 'Verify Status', + kind: 'boolean', defaultValue: false, projectStatusValues: [ + { transition: 'issueCreated', value: 'Todo' }, + { transition: 'pullRequestCreated', value: 'Doing' }, + { transition: 'issueInProgress', value: 'Started' }, + { transition: 'pullRequestInProgress', value: 'Active' }, + ] }; + const html = markup('QuestionPrompt', { prompt: { kind: 'question', title: 'Projects', phase: 'full', pass: 1, + question, presentation: setupQuestionPresentation(question) }, controller: true, busy: false }, 'es'); + for (const value of ['Todo', 'Doing', 'Started', 'Active']) expect(html).toContain(value); + expect(html).toContain('Issue nuevo'); + expect(html).toContain('Pull request en curso'); + }); + + test('boolean recommendations use the selected language rather than raw true or false', () => { + const html = markup('QuestionPrompt', { prompt: { + kind: 'question', title: 'Features', phase: 'full', pass: 1, + question: { stateId: 'capabilities', id: 'features.issues', label: 'Issue automation?', kind: 'boolean', defaultValue: false }, + }, controller: true, busy: false }, 'es'); + expect(html).toContain('Respuesta sugerida: No'); + expect(html).not.toContain('Respuesta sugerida: false'); + }); + + test('choice recommendations translate their display label without changing the option value', () => { + const html = markup('QuestionPrompt', { prompt: { + kind: 'question', title: 'Provisioning', phase: 'full', pass: 1, + question: { stateId: 'provisioning', id: 'ai.provisioningMode', label: 'Provisioning mode', + kind: 'choice', defaultValue: 'always', choices: ['auto', 'always', 'disabled'] }, + }, controller: true, busy: false }, 'es'); + expect(html).toContain('Respuesta sugerida: Reinstalar siempre'); + expect(html).toContain('value="always"'); + }); + + test('read-only result cannot show its close control', () => { + expect(markup('ResultPanel', { outcome: 'complete', controller: false, onClose: noOp })).not.toContain('Close local session'); + }); + + test('choice prompt escapes untrusted text and disables a read-only controller', () => { + const html = markup('ChoicePrompt', { + prompt: { kind: 'choice', title: 'Choose', choices: ['', 'Safe'] }, + controller: false, busy: false, onSubmit: noOp, + }); + expect(html).toContain('<script>'); + expect(html).not.toContain('Test setup'); + writeFileSync(join(root, 'assets', 'app.js'), 'const ready = true;'); + writeFileSync(join(root, 'assets', 'app.css'), ':root { color: black; }'); + bridge = new WebSetupBridge('owner/repo'); + server = await startWebSetupServer(bridge, root); + await registerSession(server); + }); + afterEach(async () => { if (server) await server.close(); sessionKeys.clear(); rmSync(root, { recursive: true, force: true }); }); + + const jsonPost = (url: string, path: string, body: unknown, headers: Record = {}) => fetch(`${url}${path}`, { + method: 'POST', headers: { Origin: url.slice(0, -1), 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), + }); + + test('serves only bundled local files with restrictive headers', async () => { + const page = await fetch(server.url); + expect(page.status).toBe(200); + expect(page.headers.get('content-security-policy')).toContain("default-src 'none'"); + expect(page.headers.get('cache-control')).toBe('no-store'); + expect(page.headers.get('access-control-allow-origin')).toBeNull(); + expect((await fetch(`${server.url}assets/app.js`)).status).toBe(200); + const css = await fetch(`${server.url}assets/app.css`); + expect(css.status).toBe(200); + expect(css.headers.get('content-type')).toBe('text/css; charset=utf-8'); + writeFileSync(join(root, 'assets', 'unlisted.js'), 'alert(1)'); + expect((await fetch(`${server.url}assets/unlisted.js`)).status).toBe(404); + expect((await fetch(`${server.url}assets/%2e%2e/index.html`)).status).toBe(404); + }); + + test('ends a response safely if an asset write fails after headers were sent', async () => { + const end = jest.spyOn(ServerResponse.prototype, 'end').mockImplementationOnce(() => { + throw new Error('simulated asset write failure'); + }); + try { + const response = await fetch(server.url); + expect(response.status).toBe(200); + expect(await response.text()).toBe(''); + expect((await fetch(server.url)).status).toBe(200); + } finally { end.mockRestore(); } + }); + + test('uses the packaged web asset location when no override is supplied', async () => { + const filesystem = require('node:fs/promises') as typeof import('node:fs/promises'); + const original = filesystem.realpath; + const packaged = join(__dirname, '..', '..', 'web'); + const realpath = jest.spyOn(filesystem, 'realpath').mockImplementation(async path => + String(path) === packaged ? original(root) : original(path)); + let defaultServer: WebSetupServer | undefined; + try { + defaultServer = await startWebSetupServer(new WebSetupBridge('owner/repo')); + expect((await fetch(defaultServer.url)).status).toBe(200); + } finally { + if (defaultServer) await defaultServer.close(); + realpath.mockRestore(); + } + }); + + test('the public loopback URL contains no secret and API access requires terminal pairing', async () => { + const launch = new URL(server.url); + const key = sessionKeys.get(launch.origin)!; + expect(launch.hash).toBe(''); + expect(launch.search).toBe(''); + expect(server.pairingCode).toMatch(/^[a-f0-9]{16}$/); + expect(key).toMatch(/^[a-f0-9]{64}$/); + for (const path of ['api/bootstrap', 'api/state']) { + const missing = await globalThis.fetch(`${server.url}${path}`); + expect(missing.status).toBe(403); + expect(await missing.text()).not.toContain(key!); + expect((await globalThis.fetch(`${server.url}${path}`, { headers: { 'X-Setup-Session-Key': '0'.repeat(64) } })).status).toBe(403); + } + expect((await globalThis.fetch(`${server.url}api/takeover`, { + method: 'POST', headers: { Origin: launch.origin, 'Content-Type': 'application/json' }, body: '{}', + })).status).toBe(403); + expect(bridge.snapshot().prompt).toBeUndefined(); + expect((await fetch(`${server.url}api/bootstrap`)).status).toBe(200); + }); + + test('a key from a different local setup run cannot bootstrap this session', async () => { + const other = await startWebSetupServer(new WebSetupBridge('owner/other'), root); + try { + const firstKey = sessionKeys.get(new URL(server.url).origin)!; + await registerSession(other); + const otherKey = sessionKeys.get(new URL(other.url).origin)!; + expect(otherKey).not.toBe(firstKey); + expect((await globalThis.fetch(`${other.url}api/bootstrap`, { + headers: { 'X-Setup-Session-Key': firstKey }, + })).status).toBe(403); + expect((await globalThis.fetch(`${other.url}api/bootstrap`, { + headers: { 'X-Setup-Session-Key': otherKey }, + })).status).toBe(200); + } finally { await other.close(); } + }); + + test('pairing rejects missing, cross-origin, and incorrect codes without exposing the session key', async () => { + const endpoint = `${server.url}api/pair`; + const origin = new URL(server.url).origin; + const post = (code: unknown, requestOrigin = origin) => globalThis.fetch(endpoint, { + method: 'POST', headers: { Origin: requestOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), + }); + expect((await post(server.pairingCode, 'https://evil.example')).status).toBe(403); + expect((await post(undefined)).status).toBe(403); + const wrong = await post('0'.repeat(16)); + expect(wrong.status).toBe(403); + expect(await wrong.text()).not.toContain(sessionKeys.get(origin)!); + expect((await post(server.pairingCode)).status).toBe(200); + }); + + test('pairing requires JSON even for a valid code', async () => { + const response = await globalThis.fetch(`${server.url}api/pair`, { + method: 'POST', + headers: { Origin: new URL(server.url).origin, 'Content-Type': 'text/plain' }, + body: JSON.stringify({ code: server.pairingCode }), + }); + expect(response.status).toBe(415); + expect(await response.text()).not.toContain(sessionKeys.get(new URL(server.url).origin)!); + }); + + test('five incorrect pairing attempts cause a recoverable cooldown, not a permanent lockout', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(100_000); + try { + const endpoint = `${server.url}api/pair`; + const origin = new URL(server.url).origin; + const post = (code: string) => globalThis.fetch(endpoint, { + method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + expect((await post('0'.repeat(16))).status).toBe(403); + } + const locked = await post(server.pairingCode); + expect(locked.status).toBe(429); + expect(await locked.clone().text()).toContain('Wait 30 seconds'); + expect(await locked.text()).not.toContain(sessionKeys.get(origin)!); + expect((await fetch(`${server.url}api/bootstrap`)).status).toBe(200); + now.mockReturnValue(130_001); + expect((await post(server.pairingCode)).status).toBe(200); + } finally { now.mockRestore(); } + }); + + test('a correct pairing resets prior wrong-code attempts', async () => { + const endpoint = `${server.url}api/pair`; + const origin = new URL(server.url).origin; + const post = (code: string) => globalThis.fetch(endpoint, { + method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), + }); + for (let attempt = 0; attempt < 4; attempt += 1) expect((await post('0'.repeat(16))).status).toBe(403); + expect((await post(server.pairingCode)).status).toBe(200); + for (let attempt = 0; attempt < 4; attempt += 1) expect((await post('0'.repeat(16))).status).toBe(403); + expect((await post(server.pairingCode)).status).toBe(200); + }); + + test('bounds simultaneous loopback connections', async () => { + const { port } = new URL(server.url); + const sockets: Socket[] = []; + const open = () => new Promise((resolveSocket, reject) => { + const socket = connect(Number(port), '127.0.0.1'); + socket.once('connect', () => resolveSocket(socket)); + socket.once('error', reject); + }); + try { + sockets.push(...await Promise.all(Array.from({ length: 16 }, open))); + const overflow = await open(); + sockets.push(overflow); + await expect(new Promise((resolveClose, reject) => { + const timeout = setTimeout(() => reject(new Error('Excess connection was not closed.')), 1000); + overflow.once('close', () => { clearTimeout(timeout); resolveClose(); }); + })).resolves.toBeUndefined(); + } finally { for (const socket of sockets) socket.destroy(); } + }); + + test('rejects forged hosts and cross-origin mutation', async () => { + const forgedStatus = await new Promise((resolveStatus, reject) => { + const forged = request(server.url, { headers: { Host: 'evil.example' } }, response => { + response.resume(); resolveStatus(response.statusCode ?? 0); + }); + forged.on('error', reject); forged.end(); + }); + expect(forgedStatus).toBe(403); + const boot = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const pending = bridge.ask({ kind: 'secret', title: 'PAT' }); + const revision = bridge.snapshot().promptRevision!; + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'sensitive' }, { + Origin: 'https://evil.example', 'X-Setup-Capability': boot.capability, + })).status).toBe(403); + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'sensitive' }, { + 'X-Setup-Capability': 'wrong', + })).status).toBe(403); + expect(bridge.answer(revision, 'allowed')).toBe(true); + expect(await pending).toBe('allowed'); + }); + + test('accepts one authorized answer and never returns the submitted PAT', async () => { + const boot = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const pending = bridge.ask({ kind: 'secret', title: 'Setup PAT' }); + const revision = bridge.snapshot().promptRevision!; + const response = await jsonPost(server.url, 'api/answer', { revision, value: 'ghp_private' }, { 'X-Setup-Capability': boot.capability }); + expect(response.status).toBe(200); + expect(await response.text()).not.toContain('ghp_private'); + expect(await pending).toBe('ghp_private'); + const duplicate = await jsonPost(server.url, 'api/answer', { revision, value: 'again' }, { 'X-Setup-Capability': boot.capability }); + expect(duplicate.status).toBe(200); + expect(await duplicate.json()).toMatchObject({ accepted: true, duplicate: true }); + expect((await jsonPost(server.url, 'api/answer', { revision: revision + 1, value: 'again' }, { 'X-Setup-Capability': boot.capability })).status).toBe(409); + expect(await (await fetch(`${server.url}api/state`)).text()).not.toContain('ghp_private'); + }); + + test('allows only the paired controller to explicitly retry the current discovery revision', async () => { + const boot = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const commit = jest.fn(); + const pending = bridge.ask({ kind: 'text', title: 'Checks' }, async () => ({ + prompt: { kind: 'text', title: 'Checks refreshed' }, commit, + })); + const revision = bridge.snapshot().promptRevision!; + expect((await jsonPost(server.url, 'api/retry-discovery', { revision }, { 'X-Setup-Capability': 'wrong' })).status).toBe(403); + expect((await jsonPost(server.url, 'api/retry-discovery', { revision: 'bad' }, { 'X-Setup-Capability': boot.capability })).status).toBe(400); + expect((await jsonPost(server.url, 'api/retry-discovery', { revision: revision + 1 }, { 'X-Setup-Capability': boot.capability })).status).toBe(409); + const response = await jsonPost(server.url, 'api/retry-discovery', { revision }, { 'X-Setup-Capability': boot.capability }); + expect(response.status).toBe(200); + expect(commit).toHaveBeenCalledTimes(1); + expect(bridge.snapshot().promptRevision).toBe(revision); + expect(bridge.snapshot().prompt?.title).toBe('Checks refreshed'); + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'selected' }, { 'X-Setup-Capability': boot.capability })).status).toBe(200); + expect(await pending).toBe('selected'); + }); + + test('authorizes back navigation by revision without replaying the entire setup session', async () => { + const boot = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const commit = jest.fn(); + const pending = bridge.ask({ kind: 'text', title: 'Current question' }, undefined, () => ({ + prompt: { kind: 'text', title: 'Previous question' }, commit, + })); + const revision = bridge.snapshot().promptRevision!; + expect((await jsonPost(server.url, 'api/back', { revision }, { 'X-Setup-Capability': 'wrong' })).status).toBe(403); + expect((await fetch(`${server.url}api/back`, { method: 'POST', headers: { + Origin: server.url.slice(0, -1), 'Content-Type': 'text/plain', 'X-Setup-Capability': boot.capability, + }, body: '{}' })).status).toBe(415); + expect((await jsonPost(server.url, 'api/back', { revision: 'invalid' }, { 'X-Setup-Capability': boot.capability })).status).toBe(400); + expect((await jsonPost(server.url, 'api/back', { revision: revision + 1 }, { 'X-Setup-Capability': boot.capability })).status).toBe(409); + expect((await jsonPost(server.url, 'api/back', { revision }, { 'X-Setup-Capability': boot.capability })).status).toBe(200); + expect(commit).toHaveBeenCalledTimes(1); + expect(bridge.snapshot().prompt?.title).toBe('Previous question'); + expect((await jsonPost(server.url, 'api/back', { revision }, { 'X-Setup-Capability': boot.capability })).status).toBe(409); + expect((await jsonPost(server.url, 'api/answer', { revision: bridge.snapshot().promptRevision, value: 'answer' }, + { 'X-Setup-Capability': boot.capability })).status).toBe(200); + expect(await pending).toBe('answer'); + }); + + test('rejects oversized answers, wrong method, and unsupported content type', async () => { + const boot = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const pending = bridge.ask({ kind: 'text', title: 'Answer' }); + const revision = bridge.snapshot().promptRevision!; + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'x'.repeat(5000) }, { 'X-Setup-Capability': boot.capability })).status).toBe(400); + expect((await fetch(`${server.url}api/answer`)).status).toBe(404); + expect((await fetch(`${server.url}api/answer`, { method: 'POST', headers: { Origin: server.url.slice(0, -1), 'Content-Type': 'text/plain', 'X-Setup-Capability': boot.capability }, body: '{}' })).status).toBe(415); + bridge.cancel(); + await pending; + }); + + test('a second tab explicitly takes over and invalidates the first tab capability', async () => { + const first = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const second = await (await fetch(`${server.url}api/bootstrap`)).json() as { controller: boolean; capability?: string; takeoverTicket?: string }; + expect(second.controller).toBe(false); + expect(second.capability).toBeUndefined(); + expect(second.takeoverTicket).toBeUndefined(); + expect((await jsonPost(server.url, 'api/takeover', { code: 'wrong' })).status).toBe(403); + const takeover = await jsonPost(server.url, 'api/takeover', { code: server.pairingCode }); + expect(takeover.status).toBe(200); + const { capability } = await takeover.json() as { capability: string }; + const pending = bridge.ask({ kind: 'choice', title: 'Proceed?', choices: ['yes', 'no'] }); + const revision = bridge.snapshot().promptRevision; + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'yes' }, { 'X-Setup-Capability': first.capability })).status).toBe(403); + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'yes' }, { 'X-Setup-Capability': capability })).status).toBe(200); + expect(await pending).toBe('yes'); + }); + + test('limits wrong-code takeover attempts temporarily without returning a controller capability', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(100_000); + try { + await fetch(`${server.url}api/bootstrap`); + await fetch(`${server.url}api/bootstrap`); + for (let attempt = 0; attempt < 5; attempt += 1) { + const rejected = await jsonPost(server.url, 'api/takeover', { code: 'wrong' }); + expect(rejected.status).toBe(403); + expect(await rejected.json()).not.toHaveProperty('capability'); + } + expect((await jsonPost(server.url, 'api/takeover', { code: server.pairingCode })).status).toBe(429); + now.mockReturnValue(130_001); + expect((await jsonPost(server.url, 'api/takeover', { code: server.pairingCode })).status).toBe(200); + } finally { now.mockRestore(); } + }); + + test('cancel requires the controller and never claims to cancel in-flight Apply', async () => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const pending = bridge.ask({ kind: 'text', title: 'Answer' }); + expect((await jsonPost(server.url, 'api/cancel', {}, { 'X-Setup-Capability': 'wrong' })).status).toBe(403); + expect((await jsonPost(server.url, 'api/cancel', {}, { 'X-Setup-Capability': capability })).status).toBe(200); + expect(await pending).toBeUndefined(); + expect(bridge.snapshot().outcome).toBe('cancelled'); + + const secondBridge = new WebSetupBridge('owner/repo'); + const secondServer = await startWebSetupServer(secondBridge, root); + await registerSession(secondServer); + try { + const nextCapability = (await (await fetch(`${secondServer.url}api/bootstrap`)).json() as { capability: string }).capability; + secondBridge.setJourney({ repository: 'owner/repo', position: 6, total: 6, current: 'Apply', complete: [], pending: [], mutationStarted: true, choiceReviewPass: 1 }); + expect((await jsonPost(secondServer.url, 'api/cancel', {}, { 'X-Setup-Capability': nextCapability })).status).toBe(409); + expect(secondBridge.snapshot().outcome).toBeUndefined(); + } finally { await secondServer.close(); } + }); + + test('close is rejected before a result and succeeds for the finished controller', async () => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + expect((await jsonPost(server.url, 'api/close', {}, { 'X-Setup-Capability': capability })).status).toBe(403); + bridge.finish('complete', 'done'); + expect((await jsonPost(server.url, 'api/close', {}, { 'X-Setup-Capability': 'wrong' })).status).toBe(403); + expect((await jsonPost(server.url, 'api/close', {}, { 'X-Setup-Capability': capability })).status).toBe(200); + await server.closed; + }); + + test('post-success read-only verification requires the controller and returns only redacted counts', async () => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const run = jest.fn().mockResolvedValue({ healthy: false, pass: 4, warn: 1, fail: 0, skipped: 2, + secret: 'must-not-appear' }); + bridge.configureReadOnlyDoctor(run); + expect((await jsonPost(server.url, 'api/doctor', {}, { 'X-Setup-Capability': capability })).status).toBe(409); + bridge.finish('complete', 'done'); + expect((await jsonPost(server.url, 'api/doctor', {}, { 'X-Setup-Capability': 'wrong' })).status).toBe(403); + expect((await jsonPost(server.url, 'api/doctor', {}, { 'X-Setup-Capability': capability })).status).toBe(200); + const state = await (await fetch(`${server.url}api/state`)).text(); + expect(state).toContain('"warn":1'); + expect(state).not.toContain('must-not-appear'); + expect((await jsonPost(server.url, 'api/doctor', {}, { 'X-Setup-Capability': capability })).status).toBe(200); + expect(run).toHaveBeenCalledTimes(1); + }); + + test.each([ + [{ 'X-Forwarded-Host': 'evil.example' }, 403], + [{ 'X-Forwarded-Proto': 'https' }, 403], + [{ Forwarded: 'host=evil.example' }, 403], + [{ 'Sec-Fetch-Site': 'cross-site' }, 403], + ])('rejects proxy or cross-site context %j', async (headers, expected) => { + expect((await fetch(server.url, { headers })).status).toBe(expected); + }); + + test('rejects foreign Referer, malformed JSON and wrong API methods', async () => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + expect((await jsonPost(server.url, 'api/answer', { revision: 1, value: 'x' }, { + Referer: 'https://evil.example/', 'X-Setup-Capability': capability, + })).status).toBe(403); + expect((await fetch(`${server.url}api/state`, { method: 'POST', headers: { Origin: server.url.slice(0, -1), 'Content-Type': 'application/json' }, body: '{}' })).status).toBe(405); + expect((await fetch(`${server.url}api/answer`, { + method: 'POST', headers: { Origin: server.url.slice(0, -1), 'Content-Type': 'application/json', 'X-Setup-Capability': capability }, body: '{invalid', + })).status).toBe(400); + expect((await fetch(`${server.url}api/not-a-route`)).status).toBe(404); + }); + + test('rejects invalid payload types and bodies beyond the byte limit', async () => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const pending = bridge.ask({ kind: 'secret', title: 'PAT' }); + const revision = bridge.snapshot().promptRevision; + for (const invalid of [{ revision: '1', value: 'x' }, { revision, value: 7 }, { revision: -1, value: 'x' }]) { + expect((await jsonPost(server.url, 'api/answer', invalid, { 'X-Setup-Capability': capability })).status).toBe(400); + } + expect((await jsonPost(server.url, 'api/answer', [], { 'X-Setup-Capability': capability })).status).toBe(400); + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'x'.repeat(8500) }, { 'X-Setup-Capability': capability })).status).toBe(400); + bridge.cancel(); + await pending; + }); + + test('startup refuses missing or symlinked packaged assets', async () => { + const invalid = mkdtempSync(join(tmpdir(), 'copilot-web-assets-test-')); + try { + writeFileSync(join(invalid, 'index.html'), 'No assets'); + await expect(startWebSetupServer(new WebSetupBridge('owner/repo'), invalid)).rejects.toThrow('incomplete'); + mkdirSync(join(invalid, 'assets')); + writeFileSync(join(invalid, 'index.html'), ''); + writeFileSync(join(invalid, 'assets/app.css'), 'body {}'); + symlinkSync(join(root, 'assets/app.js'), join(invalid, 'assets/app.js')); + await expect(startWebSetupServer(new WebSetupBridge('owner/repo'), invalid)).rejects.toThrow('escapes'); + } finally { rmSync(invalid, { recursive: true, force: true }); } + }); + + test('startup refuses an index symlink outside the asset root', async () => { + const invalid = mkdtempSync(join(tmpdir(), 'copilot-web-index-test-')); + try { + symlinkSync(join(root, 'index.html'), join(invalid, 'index.html')); + await expect(startWebSetupServer(new WebSetupBridge('owner/repo'), invalid)).rejects.toThrow('index must be inside'); + } finally { rmSync(invalid, { recursive: true, force: true }); } + }); + + test('an asset replaced by an escaping symlink is not served', async () => { + unlinkSync(join(root, 'assets', 'app.js')); + const outside = mkdtempSync(join(tmpdir(), 'copilot-asset-escape-test-')); + try { + writeFileSync(join(outside, 'app.js'), 'alert(1)'); + symlinkSync(join(outside, 'app.js'), join(root, 'assets', 'app.js')); + expect((await fetch(`${server.url}assets/app.js`)).status).toBe(404); + } finally { rmSync(outside, { recursive: true, force: true }); } + }); + + test('all mutating endpoints require an exact JSON content type', async () => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + const wrongType = (path: string) => fetch(`${server.url}${path}`, { + method: 'POST', headers: { Origin: server.url.slice(0, -1), 'Content-Type': 'text/plain', 'X-Setup-Capability': capability }, body: '{}', + }); + expect((await wrongType('api/takeover')).status).toBe(415); + expect((await wrongType('api/cancel')).status).toBe(415); + bridge.finish('complete', 'done'); + expect((await wrongType('api/close')).status).toBe(415); + expect((await wrongType('api/doctor')).status).toBe(415); + }); + + test('mutating requests without a controller capability do nothing', async () => { + const origin = server.url.slice(0, -1); + const pending = bridge.ask({ kind: 'secret', title: 'PAT' }); + const revision = bridge.snapshot().promptRevision; + expect((await jsonPost(server.url, 'api/answer', { revision, value: 'ignored' })).status).toBe(403); + expect((await jsonPost(server.url, 'api/cancel', {})).status).toBe(403); + expect((await jsonPost(server.url, 'api/doctor', {})).status).toBe(403); + expect((await jsonPost(server.url, 'api/takeover', { code: 42 })).status).toBe(403); + expect((await fetch(`${server.url}api/answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, value: 'ignored' }) })).status).toBe(403); + expect(origin).toContain('127.0.0.1'); + bridge.cancel(); + await pending; + }); + + test.each(['api/answer', 'api/cancel', 'api/close'])('rejects control transferred while reading %s', async path => { + const { capability } = await (await fetch(`${server.url}api/bootstrap`)).json() as { capability: string }; + if (path === 'api/answer') void bridge.ask({ kind: 'secret', title: 'PAT' }); + if (path === 'api/close') bridge.finish('complete', 'done'); + const controller = jest.spyOn(bridge, 'isController').mockReturnValueOnce(true).mockReturnValueOnce(false); + try { + const response = await jsonPost(server.url, path, path === 'api/answer' + ? { revision: bridge.snapshot().promptRevision, value: 'unaccepted' } : {}, { 'X-Setup-Capability': capability }); + expect(response.status).toBe(403); + if (path === 'api/answer') expect(bridge.snapshot().prompt?.kind).toBe('secret'); + } finally { + controller.mockRestore(); + if (path === 'api/answer') bridge.cancel(); + } + }); + + test('an unanswered pre-Apply prompt expires without accepting a late answer', async () => { + await server.close(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + try { + bridge = new WebSetupBridge('owner/repo'); + server = await startWebSetupServer(bridge, root); + await registerSession(server); + const pending = bridge.ask({ kind: 'secret', title: 'Setup PAT' }); + const revision = bridge.snapshot().promptRevision!; + await jest.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(bridge.snapshot().outcome).toBe('blocked'); + expect(await pending).toBeUndefined(); + expect(bridge.answer(revision, 'late-token')).toBe(false); + expect(JSON.stringify(bridge.snapshot())).not.toContain('late-token'); + } finally { + await server.close(); + jest.useRealTimers(); + } + }); + + test('the idle limit does not interrupt a mutation already in progress', async () => { + await server.close(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + try { + bridge = new WebSetupBridge('owner/repo'); + server = await startWebSetupServer(bridge, root); + await registerSession(server); + bridge.setJourney({ repository: 'owner/repo', position: 6, total: 6, current: 'Apply', + complete: [], pending: [], mutationStarted: true, choiceReviewPass: 1 }); + await jest.advanceTimersByTimeAsync(4 * 60 * 60 * 1000); + expect(bridge.snapshot().outcome).toBeUndefined(); + } finally { + await server.close(); + jest.useRealTimers(); + } + }); + + test('the absolute lifetime expires a pre-Apply session even after an earlier active interval', async () => { + await server.close(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + try { + bridge = new WebSetupBridge('owner/repo'); + server = await startWebSetupServer(bridge, root); + await registerSession(server); + const applying = { repository: 'owner/repo', position: 6, total: 6, current: 'Apply', + complete: [] as string[], pending: [] as string[], mutationStarted: true, choiceReviewPass: 1 }; + bridge.setJourney(applying); + await jest.advanceTimersByTimeAsync(30 * 60 * 1000); + bridge.setJourney({ ...applying, mutationStarted: false }); + await jest.advanceTimersByTimeAsync(3.5 * 60 * 60 * 1000); + expect(bridge.snapshot().outcome).toBe('blocked'); + expect(bridge.snapshot().message?.text).toContain('four-hour limit'); + } finally { + await server.close(); + jest.useRealTimers(); + } + }); + + test('a finished session closes itself after its result-reading window', async () => { + await server.close(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + try { + bridge = new WebSetupBridge('owner/repo'); + server = await startWebSetupServer(bridge, root); + await registerSession(server); + bridge.finish('complete', 'done'); + await jest.advanceTimersByTimeAsync(10 * 60 * 1000); + await expect(server.closed).resolves.toBeUndefined(); + } finally { + await server.close(); + jest.useRealTimers(); + } + }); +}); diff --git a/src/cli/__tests__/web_setup_ui_helpers.test.ts b/src/cli/__tests__/web_setup_ui_helpers.test.ts new file mode 100644 index 000000000..782255e24 --- /dev/null +++ b/src/cli/__tests__/web_setup_ui_helpers.test.ts @@ -0,0 +1,203 @@ +import type { WebSetupPrompt } from '../../application/contracts/web_setup_view'; +import { safeGithubLink, safeGithubRunLink, safeGithubProjectLink, safeGithubRulesetLink } from '../../../web/src/lib/githubLink'; +import { checkConclusionLabel } from '../../../web/src/i18n/checkEvidence'; +import { manualProducerIdentity } from '../../../web/src/lib/manualProducerIdentity'; +import { canSubmitPairingCode } from '../../../web/src/lib/pairingCode'; +import { featureName } from '../../../web/src/i18n/featureNames'; +import { focusOnRevision } from '../../../web/src/lib/focusOnRevision'; +import { safeHelpLink } from '../../../web/src/lib/helpLink'; +import { initialQuestionAnswer, submittedQuestionAnswer, toggleSelection } from '../../../web/src/lib/questionAnswer'; + +function question(kind: Extract['question']['kind'], defaultValue: string): Extract { + return { kind: 'question', title: 'Choice', phase: 'full', pass: 1, question: { + stateId: 'repository', id: 'test', label: 'A choice', kind, defaultValue, + choices: ['All', 'One — details', 'Two — details'], allowedNames: ['one', 'two'], + } }; +} + +describe('web setup presentation helpers', () => { + test('Enter and button pairing share strict code and busy validation', () => { + expect(canSubmitPairingCode('0123456789abcdef', false)).toBe(true); + expect(canSubmitPairingCode('0123456789ABCDEF', false)).toBe(true); + expect(canSubmitPairingCode(' 0123456789abcdef ', false)).toBe(true); + for (const invalid of ['', '0123456789abcde', '0123456789abcdef0', '0123456789abcdeg']) { + expect(canSubmitPairingCode(invalid, false)).toBe(false); + } + expect(canSubmitPairingCode('0123456789abcdef', true)).toBe(false); + }); + test('focus follows a new prompt revision but not background status polls', async () => { + const focus = jest.fn(); + const action = focusOnRevision({ isConnected: true, focus }, 1); + await Promise.resolve(); + expect(focus).toHaveBeenCalledTimes(1); + action.update(1); + await Promise.resolve(); + expect(focus).toHaveBeenCalledTimes(1); + action.update(2); + await Promise.resolve(); + expect(focus).toHaveBeenCalledTimes(2); + action.update(3); + action.destroy(); + await Promise.resolve(); + expect(focus).toHaveBeenCalledTimes(2); + }); + test('localizes observed check outcomes and feature names without inventing unknown outcomes', () => { + expect(checkConclusionLabel('success', 'es')).toBe('Correcto'); + expect(checkConclusionLabel('future-state', 'fr')).toBe('Résultat inconnu'); + expect(featureName('credentialHealth', 'pt')).toBe('Estado das credenciais'); + expect(featureName('future-capability', 'en')).toBe('future-capability'); + }); + test.each([ + ['en', 'Stale', 'Failed to start'], + ['es', 'Obsoleto', 'Falló al iniciar'], + ['fr', 'Obsolète', 'Échec au démarrage'], + ['pt', 'Obsoleto', 'Falha ao iniciar'], + ] as const)('%s labels both uncommon GitHub check conclusions', (locale, stale, startup) => { + expect(checkConclusionLabel('stale', locale)).toBe(stale); + expect(checkConclusionLabel('startup_failure', locale)).toBe(startup); + }); + + test('manual producer identity accepts a numeric App ID without calling string methods on it', () => { + expect(manualProducerIdentity(' Test ', 42, ' CI ')).toBe('Test|42|CI'); + expect(manualProducerIdentity('Test', '42', 'CI')).toBe('Test|42|CI'); + for (const invalid of [undefined, 0, -2, 1.5, '1e2', '9007199254740992']) { + expect(manualProducerIdentity('Test', invalid, 'CI')).toBeUndefined(); + } + expect(manualProducerIdentity('Bad|name', '42', 'CI')).toBeUndefined(); + }); + + test.each([ + ['https://github.com/acme/repo/rules/7', true], + ['https://github.com/acme/repo/rules/7?token=x', false], + ['https://evil.example/acme/repo/rules/7', false], + ['https://github.com/acme/repo/rules/0', false], + ['not-a-url', false], + ])('ruleset link allowlist %s: %s', (link, allowed) => { + expect(Boolean(safeGithubRulesetLink(link))).toBe(allowed); + }); + test('preselects matching multi-select defaults without selecting All', () => { + expect(initialQuestionAnswer(question('multi-select', 'One,Two'))).toEqual({ + value: 'One,Two', selected: ['One — details', 'Two — details'], + }); + }); + + test('preserves the documented All default until explicitly deselected', () => { + const prompt = question('multi-select', 'All'); + const initial = initialQuestionAnswer(prompt); + expect(initial).toEqual({ value: 'All', selected: ['All'] }); + expect(submittedQuestionAnswer(prompt, initial.value, initial.selected)).toBe('All'); + expect(submittedQuestionAnswer(prompt, initial.value, toggleSelection(initial.selected, 'All'))).toBe('none'); + expect(submittedQuestionAnswer(prompt, initial.value, toggleSelection(initial.selected, 'One — details'))).toBe('One — details'); + }); + + test('never invents an All selection when the choices do not offer it', () => { + const prompt = question('multi-select', 'All'); + expect(initialQuestionAnswer({ ...prompt, question: { ...prompt.question, choices: ['One — details'] } }).selected).toEqual([]); + }); + + test('a multi-select without choices starts empty and never invents an option', () => { + const prompt = question('multi-select', 'one'); + expect(initialQuestionAnswer({ ...prompt, question: { ...prompt.question, choices: undefined } }).selected).toEqual([]); + }); + + test('a plain text question retains its default without a selection', () => { + expect(initialQuestionAnswer(question('text', 'hello'))).toEqual({ value: 'hello', selected: [] }); + }); + + test('scope overrides preserve explicit names and serialize an empty set as none', () => { + const prompt = question('scope-overrides', 'one,two'); + expect(initialQuestionAnswer(prompt).selected).toEqual(['one', 'two']); + expect(submittedQuestionAnswer(prompt, '', [])).toBe('none'); + expect(submittedQuestionAnswer(prompt, '', ['one'])).toBe('one'); + }); + + test('observed producer defaults select exact identities and can add a manual tuple', () => { + const prompt = { ...question('producer-select', 'Tests|12|CI'), question: { + ...question('producer-select', 'Tests|12|CI').question, + producerCandidates: [{ name: 'Tests', sourceAppId: 12, workflowName: 'CI', + runUrl: 'https://github.com/acme/repo/actions/runs/1', headSha: 'a'.repeat(40), conclusion: 'success' }], + } }; + expect(initialQuestionAnswer(prompt)).toEqual({ value: '', selected: ['Tests|12|CI'] }); + expect(submittedQuestionAnswer(prompt, 'Lint|12|CI; ', ['Tests|12|CI'])).toBe('Tests|12|CI;Lint|12|CI'); + const unmatched = { ...prompt, question: { ...prompt.question, defaultValue: 'Other|13|CI' } }; + expect(initialQuestionAnswer(unmatched)).toEqual({ value: '', selected: ['Other|13|CI'] }); + }); + + test('Project defaults distinguish discovered checkboxes from manually entered numbers', () => { + const prompt = { ...question('project-select', '2,9'), question: { + ...question('project-select', '2,9').question, id: 'projects.ids', + projectCandidates: [{ number: 2, title: 'Roadmap', owner: 'acme', url: 'https://github.com/orgs/acme/projects/2' }], + } }; + expect(initialQuestionAnswer(prompt)).toEqual({ selected: ['2'], value: '9' }); + expect(submittedQuestionAnswer(prompt, '9', ['2'])).toBe('2,9'); + expect(submittedQuestionAnswer(prompt, '', [])).toBe('none'); + }); + + test.each([ + ['https://github.com/orgs/acme/projects/2', true], + ['https://github.com/users/acme/projects/2', true], + ['https://github.com/orgs/acme/projects/2?token=x', false], + ['https://evil.example/orgs/acme/projects/2', false], + ['not-a-url', false], + ])('safe Project detail link %s: %s', (link, allowed) => { + expect(Boolean(safeGithubProjectLink(link))).toBe(allowed); + }); + + test('All is mutually exclusive with individual choices', () => { + expect(toggleSelection(['one'], 'All')).toEqual(['All']); + expect(toggleSelection(['All'], 'one')).toEqual(['one']); + expect(toggleSelection(['one'], 'one')).toEqual([]); + expect(toggleSelection(['All'], 'All')).toEqual([]); + }); + + test('ordinary question answers use the entered value', () => { + expect(submittedQuestionAnswer(question('text', 'old'), 'new', [])).toBe('new'); + expect(submittedQuestionAnswer(question('multi-select', ''), '', ['One — details'])).toBe('One — details'); + expect(submittedQuestionAnswer(question('multi-select', 'One'), '', [])).toBe('none'); + }); + + test.each([ + [undefined, false], + ['https://github.com/settings/personal-access-tokens/new?name=Setup', true], + ['https://github.com/settings/personal-access-tokens', true], + ['https://evil.example/settings/personal-access-tokens', false], + ['http://github.com/settings/personal-access-tokens', false], + ['https://github.com/settings/keys', false], + ['javascript:alert(1)', false], + ['not-a-url', false], + ])('allowlisted GitHub link %s: %s', (link, allowed) => { + expect(Boolean(safeGithubLink(link))).toBe(allowed); + }); + + test.each([ + ['https://github.com/acme/repo/actions/runs/42', true], + ['https://github.com/acme/repo/actions/runs/42?x=1', false], + ['https://github.com/acme/repo/actions/runs/42#secret', false], + ['https://evil.example/acme/repo/actions/runs/42', false], + ['https://github.com/acme/repo/settings/secrets', false], + ['https://github.com@evil.example/acme/repo/actions/runs/42', false], + ['javascript:alert(1)', false], + ['not-a-url', false], + ])('CI run link allowlist %s: %s', (link, allowed) => { + expect(Boolean(safeGithubRunLink(link))).toBe(allowed); + }); + + test.each([ + ['https://docs.page/vypdev/copilot/agents/model-selection', true], + ['https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets', true], + ['https://docs.page/vypdev/copilot/pull-requests/guarded-approval#coverage', true], + ['https://docs.page/vypdev/copilot/agents/model-selection?token=secret', false], + ['https://docs.page.evil.example/vypdev/copilot/agents', false], + ['https://docs.page@evil.example/vypdev/copilot/agents', false], + ['http://docs.page/vypdev/copilot/agents', false], + ['https://docs.github.com/other/guide', false], + ['javascript:alert(1)', false], + ['not-a-url', false], + ])('documentation link allowlist %s: %s', (link, allowed) => { + expect(Boolean(safeHelpLink(link))).toBe(allowed); + }); + + test('absent documentation link remains absent', () => { + expect(safeHelpLink(undefined)).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/__tests__/setup_policy.test.ts b/src/cli/commands/__tests__/setup_policy.test.ts index 6ab4285ef..7384d6d16 100644 --- a/src/cli/commands/__tests__/setup_policy.test.ts +++ b/src/cli/commands/__tests__/setup_policy.test.ts @@ -7,7 +7,6 @@ const gitInfo = { owner: 'owner', repo: 'repo' } as const; describe('setup command policy', () => { it('builds the initial setup action with repository and token context', () => { const params = buildSetupParams({ debug: true }, gitInfo, 'token'); - if (!params) throw new Error('Expected valid setup parameters.'); expect(params).toMatchObject({ [INPUT_KEYS.DEBUG]: 'true', [INPUT_KEYS.SINGLE_ACTION]: ACTIONS.INITIAL_SETUP, @@ -18,7 +17,4 @@ describe('setup command policy', () => { expect(params[INPUT_KEYS.WELCOME_MESSAGES]).toHaveLength(2); }); - it('does not build params for an invalid git context', () => { - expect(buildSetupParams({}, { error: 'missing' }, 'token')).toBeUndefined(); - }); }); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index b6e0a7933..40a7bbd1a 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -13,9 +13,10 @@ import { SetupCredentialPromptAdapter, SetupTerminalCancelledError } from '../se export function registerDoctorCommand(program: Command): void { program .command('doctor') - .description('Verify Copilot workflows, Variables, Secrets, and setup PAT without changing repository configuration') + .description('Verify Copilot resources; use --read-only to avoid dispatching credential-health Actions') .option('-t, --token ', 'Setup PAT (or PERSONAL_ACCESS_TOKEN from the environment)') .option('--config ', 'YAML or JSON setup configuration used as the expected contract') + .option('--read-only', 'Inspect metadata and installed resources without dispatching credential-health Actions', false) .option('--non-interactive', 'Do not prompt; use --token or PERSONAL_ACCESS_TOKEN', false) .action(async options => { const terminal = options.nonInteractive ? undefined : createInteractiveTerminalDriver(); @@ -39,6 +40,7 @@ export function registerDoctorCommand(program: Command): void { repository: gitInfo.repo, setupToken: token, configuration: expected, + readOnly: Boolean(options.readOnly), }); new SetupDoctorPresenter(diagnosis.catalog).present(diagnosis.report); if (!diagnosis.report.healthy) process.exitCode = 1; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 9aa76fbe6..5d57fc8b2 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -3,27 +3,26 @@ import { runLocalAction } from '../../actions/local_action'; import { TITLE } from '../../application/contracts/product_identity'; import { getSetupToken } from '../../utils/setup_files'; import { logError, logInfo } from '../../utils/logger'; -import { getGitInfo, isInsideGitRepo } from '../../cli_context'; +import { getCurrentAttachedBranch, getCurrentHeadSha, getGitInfo, getGitRepositoryRoot, hasLocalOrTrackedGitBranch, isGitRepositoryRoot, isInsideGitRepo } from '../../cli_context'; import { buildSetupParams } from './setup_policy'; -import { loadSetupConfigurationOverrides } from '../setup_config_file'; +import { collectApprovalCheck, collectScope, collectSecret, loadSetupOverrides } from '../setup_command_options'; import { SetupQuestionnaireController, SetupWizardUseCase } from '../../application/usecases/setup'; +import { setupPlanGuardPaths } from '../../application/policies/setup_configuration_plan'; +import { PrepareSetupPatIntentUseCase } from '../../application/usecases/setup/prepare_setup_pat_intent_use_case'; +import { AuditConfiguredSetupPatUseCase } from '../../application/usecases/setup/audit_configured_setup_pat_use_case'; +import { VerifySetupPatBootstrapUseCase } from '../../application/usecases/setup/verify_setup_pat_bootstrap_use_case'; +import { buildSetupCredentialRequirements, effectiveIssueWorkflowFeatures } from '../../application/policies/setup_configuration_policy'; import { - SETUP_FEATURE_DESCRIPTIONS, - buildSetupCredentialRequirements, - effectiveIssueWorkflowFeatures, -} from '../../application/policies/setup_configuration_policy'; -import { - buildConfiguredSetupPatPermissionRequirements, buildSetupPatPermissionRequirements, buildWorkflowPatPermissionRequirements, } from '../../application/policies/setup_token_permission_policy'; -import type { SetupConfigurationOverrides } from '../../application/policies/setup_configuration_policy'; import { createSetupCredentialsUseCase, createSetupRemoteConfigurationReadPort } from '../../infrastructure/composition/setup_credentials_composition_root'; -import { createSetupMergeQueueReadinessUseCase } from '../../infrastructure/composition/setup_doctor_composition_root'; +import { createSetupDoctorUseCase, createSetupMergeQueueReadinessUseCase } from '../../infrastructure/composition/setup_doctor_composition_root'; import { SetupDoctorWorkspaceQueryAdapter } from '../../infrastructure/setup_workspace_adapter'; import { GithubSetupApprovalReadinessAdapter } from '../../infrastructure/setup_approval_readiness_adapter'; -import type { SetupConfiguration, SetupRemoteConfiguration, SetupResourceScope } from '../../domain/setup'; -import { ISSUE_WORKFLOW_KINDS, type IssueWorkflowKind } from '../../domain/issue_workflow_profile'; +import { GithubSetupApprovalCheckDiscoveryAdapter } from '../../infrastructure/github_setup_approval_check_discovery_adapter'; +import { GithubSetupProjectDiscoveryAdapter } from '../../infrastructure/github_setup_project_discovery_adapter'; +import type { SetupConfiguration } from '../../domain/setup'; import { ApplicationError, toApplicationError } from '../../application/errors/application_error'; import { createInteractiveTerminalDriver } from '../setup_terminal_driver'; import { ConsoleSetupQuestionRenderer } from '../setup_question_renderer'; @@ -33,6 +32,23 @@ import { SetupCredentialPromptAdapter, SetupTerminalCancelledError } from '../se import { SetupWorkflowUpdatePromptAdapter } from '../setup_workflow_update_prompt_adapter'; import { ConsoleSetupTokenPermissionPresenter } from '../setup_token_permission_presenter'; import { createSetupTokenPermissionsUseCase } from '../../infrastructure/composition/setup_token_permissions_composition_root'; +import { buildSetupPatCreationUrl, UnsupportedSetupPatLinkError } from '../../application/policies/setup_pat_creation_url_policy'; +import { SetupGithubIdentityQueryAdapter } from '../../infrastructure/setup_github_identity_query_adapter'; +import { VerifyGuidedWorkflowPatIdentityUseCase } from '../../application/usecases/setup/verify_guided_workflow_pat_identity_use_case'; +import { VerifyWebSetupApplyUseCase } from '../../application/usecases/setup/verify_web_setup_apply_use_case'; +import { SetupJourneyUseCase } from '../../application/usecases/setup/setup_journey_use_case'; +import { buildSetupJourneyView } from '../../application/policies/setup_journey_policy'; +import { ConsoleSetupJourneyPresenter } from '../setup_journey_presenter'; +import { WebSetupBridge } from '../web_setup_bridge'; +import { captureSetupApplySnapshot, setupApplySnapshotMatches } from '../setup_apply_snapshot'; +import { acquireSetupSessionGuard } from '../setup_session_guard'; +import { startWebSetupServer, openWebSetupBrowser, type WebSetupServer } from '../web_setup_server'; +import { + WebSetupCredentialPrompt, WebSetupJourneyPresenter, WebSetupPermissionPresenter, + WebSetupPlanConfirmation, WebSetupPlanPresenter, WebSetupQuestionnaireCollector, + WebSetupWorkflowUpdatePrompt, +} from '../web_setup_adapters'; +import { setupActionResultFailure, setupResultEffects, setupResultReason } from '../setup_result_receipt'; export function registerSetupCommand(program: Command): void { program @@ -50,6 +66,7 @@ export function registerSetupCommand(program: Command): void { .option('--pr-approval-coverage-check ', 'Exact selected check that enforces the coverage budget') .option('--pr-approval-attest-producer', 'Confirm exact check/App/workflow identity and a coverage-enforcing CI step', false) .option('--non-interactive', 'Use defaults and config-file values without prompting', false) + .option('--web', 'Run the optional local browser setup assistant (127.0.0.1 only)', false) .option('--yes', 'Apply the plan without the final confirmation prompt', false) .option('--confirm-unverifiable-write-permissions', 'Confirm that required PAT write permissions shown as Unverifiable were configured exactly as displayed', false) .option('--dry-run', 'Show the setup plan without changing files or GitHub', false) @@ -65,20 +82,26 @@ export function registerSetupCommand(program: Command): void { .option('--workflow-pat ', 'Workflow PAT for the bot account (prefer the hidden interactive prompt)') .option('--secret ', 'Secret value for non-interactive setup; repeat for each API key', collectSecret, {}) .action(async (options) => { - const terminal = options.nonInteractive ? undefined : createInteractiveTerminalDriver(); - const credentialPrompt = new SetupCredentialPromptAdapter(terminal, { + const terminal = options.nonInteractive || options.web ? undefined : createInteractiveTerminalDriver(); + const webBridge = options.web ? new WebSetupBridge('Resolving repository…') : undefined; + let webServer: WebSetupServer | undefined; + const credentialPrompt = webBridge ? new WebSetupCredentialPrompt(webBridge) : new SetupCredentialPromptAdapter(terminal, { ...(options.workflowPat ? { PAT: options.workflowPat } : {}), ...options.secret, }, Boolean(options.confirmUnverifiableWritePermissions)); - const permissionPresenter = new ConsoleSetupTokenPermissionPresenter(); + const permissionPresenter = webBridge ? new WebSetupPermissionPresenter(webBridge) + : new ConsoleSetupTokenPermissionPresenter(options.nonInteractive ? 'full' : 'summary'); const tokenPermissions = createSetupTokenPermissionsUseCase(); - const workflowPrompt = new SetupWorkflowUpdatePromptAdapter(terminal); + const workflowPrompt = webBridge ? new WebSetupWorkflowUpdatePrompt(webBridge) : new SetupWorkflowUpdatePromptAdapter(terminal); const cwd = process.cwd(); + let setupMutationStarted = false; + let setupApplyStarted = false; + let releaseSetupGuard: (() => void) | undefined; + let journey: SetupJourneyUseCase | undefined; try { - if (!options.nonInteractive && !terminal) { - logError('Interactive setup requires a terminal. Use --non-interactive with explicit configuration.'); - process.exitCode = 1; - return; + if (options.web && (options.nonInteractive || options.yes || options.token || options.workflowPat + || Object.keys(options.secret ?? {}).length || options.confirmUnverifiableWritePermissions)) { + throw new ApplicationError('configuration.invalid', '--web cannot be combined with --non-interactive, --yes, --token, --workflow-pat, --secret, or --confirm-unverifiable-write-permissions. Use the browser for these decisions or run copilot setup in the terminal.'); } logInfo('🔍 Checking we are inside a git repository...'); if (!isInsideGitRepo(cwd)) { @@ -95,9 +118,116 @@ export function registerSetupCommand(program: Command): void { return; } logInfo(`📦 Repository: ${gitInfo.owner}/${gitInfo.repo}`); - const setupPatPermissions = buildSetupPatPermissionRequirements(); - permissionPresenter.showRequirements('setup', setupPatPermissions); + const checkoutRoot = webBridge ? getGitRepositoryRoot(cwd) : cwd; + if (webBridge && !isGitRepositoryRoot(cwd)) { + throw new ApplicationError('configuration.invalid', `Web setup must start from the repository root (${checkoutRoot}). Change to that directory and rerun before creating PATs. No local setup session started.`); + } + releaseSetupGuard = acquireSetupSessionGuard(cwd); + const initialBranch = webBridge ? getCurrentAttachedBranch(cwd) : undefined; + const initialHead = webBridge ? getCurrentHeadSha() : undefined; + if (webBridge && (!initialBranch || !initialHead)) { + throw new ApplicationError('configuration.invalid', 'An attached Git branch and revision are required for web setup. Check out a branch before creating PATs. No local setup session started.'); + } + if (webBridge) { + webBridge.setRepository(`${gitInfo.owner}/${gitInfo.repo}`); + webBridge.setJourney(buildSetupJourneyView(`${gitInfo.owner}/${gitInfo.repo}`, 'repository', false)); + webServer = await startWebSetupServer(webBridge); + logInfo(`🌐 Local setup assistant: ${webServer.url}`); + logInfo(`🔑 Browser pairing code: ${webServer.pairingCode}`, false, undefined, true); + logInfo('If the browser does not open, copy this URL into a browser on this computer, then enter the pairing code shown above. The terminal setup remains available with copilot setup.'); + openWebSetupBrowser(webServer.url); + } + if (!options.nonInteractive) { + journey = new SetupJourneyUseCase(`${gitInfo.owner}/${gitInfo.repo}`, + webBridge ? new WebSetupJourneyPresenter(webBridge) : new ConsoleSetupJourneyPresenter()); + if (webBridge) { + const target = await webBridge.ask({ kind: 'confirm', title: 'Confirm this repository', copyId: 'repository.confirm', copyValues: { repository: `${gitInfo.owner}/${gitInfo.repo}`, branch: initialBranch ?? '' }, + description: `This local checkout resolves to ${gitInfo.owner}/${gitInfo.repo} on branch ${initialBranch}. Confirm the target before configuring PAT access or files.`, + choices: ['Yes, this is my repository', 'Stop and choose another checkout'] }); + if (target === undefined) throw new SetupTerminalCancelledError(); + if (target !== 'Yes, this is my repository') { journey.finish('cancelled'); return; } + } + journey.advance('choices'); + } + const overrides = loadSetupOverrides(options); + let presentationMode: 'basic' | 'custom' = 'custom'; + if (!options.nonInteractive && !options.dryRun) { + if (webBridge) { + const depth = await webBridge.ask({ kind: 'choice', title: 'Choose setup detail', copyId: 'setup.depth', + choices: ['Basic guided setup', 'Customize every setting'], defaultValue: 'Basic guided setup' }); + if (depth === undefined) throw new SetupTerminalCancelledError(); + presentationMode = depth === 'Basic guided setup' ? 'basic' : 'custom'; + } else if (credentialPrompt instanceof SetupCredentialPromptAdapter) { + presentationMode = await credentialPrompt.chooseSetupPresentationMode(); + } + } + let setupPatPermissions = buildSetupPatPermissionRequirements(); let token = getSetupToken(cwd, options.token); + if (webBridge && token) { + const choice = await webBridge.ask({ kind: 'choice', title: 'An environment setup PAT is available', copyId: 'setup.environmentPat', + description: 'Its value stays in the CLI process and is never sent to this page. Exiting Copilot cannot unset your parent shell variable.', + choices: ['Use the environment PAT', 'Create or enter a different PAT'] }); + if (choice === undefined) throw new SetupTerminalCancelledError(); + if (choice !== 'Use the environment PAT') token = undefined; + } + if (token || options.nonInteractive) permissionPresenter.showDetailedRequirements('setup', setupPatPermissions); + else permissionPresenter.showRequirements('setup', setupPatPermissions); + let setupPatAccount: string | undefined; + let permissionIntent: { draft: SetupConfiguration; answeredQuestionIds: readonly string[]; projectsWanted: boolean } | undefined; + let assertedOwnerKind: 'Organization' | 'User' | undefined; + if (!token && !options.nonInteractive && !options.dryRun) { + if (await credentialPrompt.chooseSetupPatMethod() === 'guided') { + const prepared = await new PrepareSetupPatIntentUseCase({ + collect: (initial, context, pass) => (webBridge + ? new WebSetupQuestionnaireCollector(webBridge, pass) + : new SetupQuestionnaireController(terminal!, new ConsoleSetupQuestionRenderer('permission-intent', pass))) + .collect(initial, context), + chooseOwnerKind: () => credentialPrompt.chooseSetupOwnerKind(), + review: () => credentialPrompt.reviewSetupPatIntent(), + showPreview: ({ draft, requirements, uncertain, ownerConflict, errors, pass, projectsWanted }) => { + if (ownerConflict) logInfo('This plan selects organization storage or Projects, but the owner was declared a personal account. Revise the choices or use the manual PAT path.'); + if (errors.length) logInfo(`The selected local configuration needs correction before a guided link can be generated:\n${errors.map(item => ` - ${item}`).join('\n')}`); + if (pass > 1) logInfo('Choice review complete. Returning to setup PAT permission review.'); + logInfo('Permission intent:'); + logInfo(` Initial tag: ${draft.createInitialTag ? 'yes' : 'no'}; issue workflows: ${draft.features.issues ? draft.issueWorkflows.enabled.join(', ') || 'none' : 'disabled'}; PR approval: ${draft.pullRequestApproval.mode}`); + logInfo(` Secrets: ${draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off'}; Variables: ${draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off'}; Projects: ${projectsWanted ? 'yes (choose exact Projects after PAT)' : 'none'}`); + webBridge?.message(`Permission preview: issue workflows ${draft.features.issues ? draft.issueWorkflows.enabled.join(', ') || 'none' : 'disabled'}; PR approval ${draft.pullRequestApproval.mode}; Secrets ${draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off'}; Variables ${draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off'}; Projects ${projectsWanted ? 'yes (choose after PAT)' : 'none'}.`, 'info', undefined, 'permission.preview', { + issues: draft.features.issues ? draft.issueWorkflows.enabled.join('|') || 'none' : 'disabled', + approval: draft.pullRequestApproval.mode, + secrets: draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off', + variables: draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off', + projects: projectsWanted ? 'yes' : 'none', + }); + permissionPresenter.showRequirements('setup', requirements); + if (uncertain.length) logInfo(`May need after GitHub inspection:\n${uncertain.map(item => ` - ${item}`).join('\n')}`); + }, + showDetails: requirements => permissionPresenter.showDetailedRequirements('setup', requirements), + onManual: reason => { + if (reason === 'owner-unknown') logInfo('Owner type was not confirmed. Use the manual PAT table, or check whether the GitHub owner is an organization before retrying guided setup.'); + if (reason === 'unsupported') logInfo('A guided setup PAT link is unavailable for this owner or permission set. Enter a manually created PAT using the table above.'); + credentialPrompt.useManualSetupPat(); + permissionPresenter.showDetailedRequirements('setup', setupPatPermissions); + }, + advanceToSetupPat: () => { journey?.advance('setup-pat'); }, + revisitChoices: () => journey!.revisitChoices(), + }).execute({ + owner: gitInfo.owner, repository: gitInfo.repo, overrides, + skipRepositoryVariables: Boolean(options.skipVariables), + skipRepositorySecrets: Boolean(options.skipSecrets), + }); + if (prepared.kind === 'guided') { + credentialPrompt.configureSetupPatGuide(prepared.url); + setupPatPermissions = [...prepared.requirements]; + assertedOwnerKind = prepared.ownerKind; + permissionIntent = prepared.permissionIntent; + } + } else { + journey?.advance('setup-pat'); + permissionPresenter.showDetailedRequirements('setup', setupPatPermissions); + } + } + if (options.dryRun && !token && !webBridge) journey?.advance('plan'); + if (!token && !options.dryRun) journey?.advance('setup-pat'); if (!token && !options.nonInteractive && !options.dryRun) token = await credentialPrompt.requestSetupPat(); if (!token && !options.dryRun) { logError('🛑 Setup requires PERSONAL_ACCESS_TOKEN with a valid token.'); @@ -108,71 +238,61 @@ export function registerSetupCommand(program: Command): void { return; } if (token) { - const permissionReport = await tokenPermissions.inspect({ - role: 'setup', - owner: gitInfo.owner, - repository: gitInfo.repo, - token, - requirements: setupPatPermissions, - }); - permissionPresenter.showReport(permissionReport); - const permissionAccepted = permissionReport.ready - || (permissionReport.confirmationRequired - && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); - if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { - throw new ApplicationError( - 'authorization.credential-invalid', - 'The setup PAT has missing or unconfirmed required access. Grant or explicitly confirm the permissions shown above and retry.', - ); - } + journey?.advance('setup-pat'); + setupPatAccount = await new VerifySetupPatBootstrapUseCase({ + permissions: tokenPermissions, + presenter: permissionPresenter, + confirmUnverifiable: report => credentialPrompt.confirmUnverifiableTokenPermissions(report), + confirmAccount: account => credentialPrompt.confirmGuidedSetupAccount(account), + showCorrectedLink: url => credentialPrompt.showUpdatedSetupPatLink(url, 'bootstrap'), + }).execute({ owner: gitInfo.owner, repository: gitInfo.repo, token, + requirements: setupPatPermissions, guided: credentialPrompt.usedGuidedSetupPat }); + journey?.advance('plan'); } logInfo(options.dryRun ? '🧭 Building a dry-run setup plan...' : '🧭 Building your setup plan...'); - const auditConfiguredSetupPat = async ( - configuration: Readonly, - remoteConfiguration?: Readonly, - ): Promise<{ status: 'accepted' } | { status: 'blocked'; errors: readonly string[] }> => { - const configuredSetupPatPermissions = buildConfiguredSetupPatPermissionRequirements(configuration, remoteConfiguration); - permissionPresenter.showRequirements('setup', configuredSetupPatPermissions); - if (!token) return { status: 'accepted' }; - const permissionReport = await tokenPermissions.inspect({ - role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, token, - requirements: configuredSetupPatPermissions, - }); - permissionPresenter.showReport(permissionReport); - const permissionAccepted = permissionReport.ready - || (permissionReport.confirmationRequired - && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); - if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { - return { status: 'blocked', errors: [ - 'The setup PAT has missing or unconfirmed access required by the approved setup plan. Grant or explicitly confirm the permissions shown above and retry.', - ] }; - } - return { status: 'accepted' }; - }; + const auditConfiguredSetupPat = new AuditConfiguredSetupPatUseCase({ + owner: gitInfo.owner, repository: gitInfo.repo, token, + provisionalRequirements: setupPatPermissions, assertedOwnerKind, + guided: credentialPrompt.usedGuidedSetupPat, + }, { + permissions: tokenPermissions, + presenter: permissionPresenter, + confirmUnverifiable: report => credentialPrompt.confirmUnverifiableTokenPermissions(report), + showOwnerMismatch: (asserted, actual) => logInfo(`The owner was declared ${asserted}, but GitHub reports ${actual}. The guided link is no longer valid for this plan.`), + showExcessGrants: grants => logInfo(`The final plan no longer requires grants suggested earlier: ${grants.join(', ')}. Your PAT may have excess access; replace it in GitHub if least privilege is required.`), + showUpdatedLink: (url, grants) => credentialPrompt.showUpdatedSetupPatLink(url, 'final', grants), + }); const remoteConfigurationReader = createSetupRemoteConfigurationReadPort(); const wizard = new SetupWizardUseCase({ - ...(terminal ? { - collector: new SetupQuestionnaireController(terminal, new ConsoleSetupQuestionRenderer()), + ...(terminal || webBridge ? { + collector: webBridge ? new WebSetupQuestionnaireCollector(webBridge) + : new SetupQuestionnaireController(terminal!, new ConsoleSetupQuestionRenderer()), } : {}), - planPresenter: new ConsoleSetupPlanPresenter(), + planPresenter: webBridge ? new WebSetupPlanPresenter(webBridge) : new ConsoleSetupPlanPresenter(), confirmation: options.dryRun ? new DryRunSetupPlanConfirmation() - : new SetupPlanConfirmationAdapter(terminal, Boolean(options.yes)), - finalPermissionAudit: { audit: auditConfiguredSetupPat }, + : webBridge ? new WebSetupPlanConfirmation(webBridge) + : new SetupPlanConfirmationAdapter(terminal, Boolean(options.yes)), + finalPermissionAudit: auditConfiguredSetupPat, remoteConfiguration: remoteConfigurationReader, mergeQueueReadiness: createSetupMergeQueueReadinessUseCase(), approvalReadiness: new GithubSetupApprovalReadinessAdapter(), + approvalCheckDiscovery: new GithubSetupApprovalCheckDiscoveryAdapter(), + projectDiscovery: new GithubSetupProjectDiscoveryAdapter(), }); - const overrides = loadSetupOverrides(options); const result = await wizard.execute({ mode: options.nonInteractive ? 'non-interactive' : 'interactive', overrides, + ...(permissionIntent ? { permissionIntent } : {}), skipRepositoryVariables: Boolean(options.skipVariables), skipRepositorySecrets: Boolean(options.skipSecrets), previewOnly: Boolean(options.dryRun), + presentationMode, + developmentBranchObservedLocally: hasLocalOrTrackedGitBranch(cwd, overrides.repository?.developmentBranch ?? 'develop'), ...(token ? { remoteTarget: { owner: gitInfo.owner, repository: gitInfo.repo, token } } : {}), }); if (result.status === 'cancelled') { + journey?.finish('cancelled'); if (result.reason !== 'questionnaire-cancelled') { logInfo('⏭️ Setup cancelled. No changes were applied.'); } @@ -180,6 +300,8 @@ export function registerSetupCommand(program: Command): void { return; } if (result.status === 'blocked') { + journey?.finish('blocked'); + webBridge?.resultReason(result.reason === 'setup-permissions-unavailable' ? 'permissions' : 'storage'); logError(new ApplicationError( result.reason === 'setup-permissions-unavailable' ? 'authorization.credential-invalid' : 'provider.unavailable', `${result.reason === 'setup-permissions-unavailable' @@ -190,6 +312,8 @@ export function registerSetupCommand(program: Command): void { return; } const { configuration, remoteConfiguration } = result; + const guardedFiles = webBridge ? setupPlanGuardPaths(result.plan) : undefined; + const webApplySnapshot = guardedFiles ? captureSetupApplySnapshot(checkoutRoot, guardedFiles) : undefined; const credentialRequirements = buildSetupCredentialRequirements(configuration); const workflowComparisons = new SetupDoctorWorkspaceQueryAdapter().compareWorkflows(effectiveIssueWorkflowFeatures(configuration), configuration); const updateWorkflows = await workflowPrompt.confirmWorkflowUpdates(workflowComparisons, Boolean(options.updateWorkflows)); @@ -197,10 +321,34 @@ export function registerSetupCommand(program: Command): void { ? workflowComparisons.filter(comparison => comparison.status === 'changed').map(comparison => comparison.file) : []; if (options.dryRun) { + if (webBridge) journey?.advance('plan'); + journey?.finish('dry-run'); logInfo('✅ Dry run complete. No files or GitHub resources were changed.'); return; } - const credentials = await createSetupCredentialsUseCase(credentialPrompt, permissionPresenter).collect({ + journey?.advance('credentials'); + const workflowTokenPermissions = buildWorkflowPatPermissionRequirements(configuration, remoteConfiguration); + const githubIdentities = new SetupGithubIdentityQueryAdapter(); + if (!options.nonInteractive && !options.workflowPat && !options.secret?.PAT) { + try { + const workflowPatGuide = buildSetupPatCreationUrl({ + role: 'workflow', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 90, + requirements: workflowTokenPermissions, + }); + credentialPrompt.configureWorkflowPatGuide(workflowPatGuide, login => githubIdentities.resolve(login, token!), workflowTokenPermissions); + } catch (error) { + if (!(error instanceof UnsupportedSetupPatLinkError)) throw error; + logInfo('A guided fine-grained bot PAT link is unavailable for one or more required permissions. Use the permission table and manual path; review whether a classic PAT is required for this plan.'); + permissionPresenter.showDetailedRequirements('workflow', workflowTokenPermissions); + } + } + const credentials = await createSetupCredentialsUseCase(credentialPrompt, permissionPresenter, + webBridge ? { allowPreApplyHealthWorkflow: false } : { + onTemporaryWorkflowMutationAttempt: () => { + setupMutationStarted = true; + journey?.markMutationStarted(); + }, + }).collect({ owner: gitInfo.owner, repository: gitInfo.repo, setupToken: token ?? '', @@ -209,9 +357,51 @@ export function registerSetupCommand(program: Command): void { secretStoragePolicy: configuration.storage.secrets, ref: configuration.repository.mainBranch, remoteConfiguration, - workflowTokenPermissions: buildWorkflowPatPermissionRequirements(configuration, remoteConfiguration), + workflowTokenPermissions, }); + const guidedBotIdentity = credentialPrompt.guidedWorkflowBotIdentity; + if (guidedBotIdentity && credentials.collection.workflowPat) { + const verifiedBot = await new VerifyGuidedWorkflowPatIdentityUseCase(githubIdentities) + .execute(guidedBotIdentity, credentials.collection.workflowPat.value); + logInfo(`✅ Workflow PAT owner verified as @${verifiedBot.login} (GitHub account ID ${verifiedBot.id}).`); + if (setupPatAccount?.toLowerCase() === verifiedBot.login.toLowerCase()) { + logInfo('The workflow PAT and setup PAT use the same GitHub account. If this account authors PRs, bot-generated events and guarded self-approval may not behave as intended; use a dedicated bot account where required.'); + } + } + if (webBridge) { + if (!remoteConfiguration || !guardedFiles || !webApplySnapshot || !initialBranch || !initialHead || !token) { + throw new ApplicationError('configuration.invalid', 'The approved setup evidence is incomplete. No mutation started; restart and review a new plan.'); + } + const authorization = await new VerifyWebSetupApplyUseCase({ + confirm: async () => { + const answer = await webBridge.ask({ kind: 'confirm', title: 'Apply this setup now?', copyId: 'apply.confirm', + description: 'This is the final approval. Local files and selected GitHub resources may change. A partial result may require inspection before retrying.', + choices: ['Apply setup', 'Stop without applying'] }); + return answer === undefined ? undefined : answer === 'Apply setup' ? 'apply' : 'stop'; + }, + readRepositoryFacts: () => { + const current = getGitInfo(); + return 'error' in current ? undefined : { + owner: current.owner, repository: current.repo, checkoutRoot: getGitRepositoryRoot(cwd), + branch: getCurrentAttachedBranch(cwd) ?? '', head: getCurrentHeadSha() ?? '', + }; + }, + fileSnapshotMatches: setupApplySnapshotMatches, + remote: remoteConfigurationReader, + permissionAudit: auditConfiguredSetupPat, + sessionState: () => webBridge.snapshot().outcome === 'cancelled' ? 'cancelled' + : webBridge.snapshot().outcome ? 'ended' : 'active', + }).execute({ + repository: { owner: gitInfo.owner, repository: gitInfo.repo, checkoutRoot, + branch: initialBranch, head: initialHead }, + selectedFiles: guardedFiles, fileSnapshot: webApplySnapshot, approvedRemote: remoteConfiguration, + configuration, setupToken: token, + }); + if (authorization === 'cancelled') throw new SetupTerminalCancelledError(); + if (authorization === 'declined') { journey?.finish('cancelled'); return; } + } logInfo('⚙️ Applying the approved setup plan...'); + journey?.advance('apply'); const params = buildSetupParams( options, gitInfo, @@ -221,166 +411,65 @@ export function registerSetupCommand(program: Command): void { approvedWorkflowFiles, remoteConfiguration, ); - if (!params) return; - await runLocalAction(params); + setupMutationStarted = true; + journey?.markMutationStarted(); + setupApplyStarted = true; + const actionResults = await runLocalAction(params); + webBridge?.effects(setupResultEffects(actionResults)); + if (actionResults.some(actionResult => !actionResult.success || actionResult.errors.length > 0)) { + const failure = setupActionResultFailure(actionResults); + if (failure) webBridge?.resultReason(failure.reasonCode, failure.diagnosticRef); + journey?.finish('partial'); + logInfo('Setup reported failures or partial completion. If a bot PAT was supplied, its Secret may already have been written; inspect the result and GitHub Secret name/scope before retrying or revoking it.'); + process.exitCode = 1; + } else { + if (webBridge && token) { + const doctorToken = token; + webBridge.configureReadOnlyDoctor(async () => { + const diagnosis = await createSetupDoctorUseCase().execute({ owner: gitInfo.owner, + repository: gitInfo.repo, setupToken: doctorToken, configuration, readOnly: true }); + return { healthy: diagnosis.report.healthy, ...diagnosis.report.totals }; + }); + } + journey?.finish('complete'); + } } catch (error) { + journey?.finish(setupMutationStarted ? 'partial' : error instanceof SetupTerminalCancelledError ? 'cancelled' : 'blocked'); + const normalizedError = error instanceof SetupTerminalCancelledError ? undefined + : toApplicationError(error, 'workflow.failed', 'Setup failed.'); + webBridge?.resultReason(error instanceof SetupTerminalCancelledError ? 'cancelled' + : setupResultReason(normalizedError!.code), normalizedError?.correlationId); + if (setupMutationStarted && !setupApplyStarted) { + logInfo('A temporary credential-health workflow create was attempted before Apply. Inspect the selected branch and GitHub workflow history before retrying; a failed request may still have reached GitHub.'); + } + if (credentialPrompt.guidedWorkflowBotIdentity) { + logInfo(setupApplyStarted + ? 'Setup may be partially applied. Inspect the GitHub Secret before deleting or replacing the bot PAT.' + : 'No bot Secret write started. If you generated an unused bot PAT in GitHub, delete it there; Copilot cannot revoke it.'); + } if (error instanceof SetupTerminalCancelledError) { - logInfo('Setup cancelled. No changes were applied.'); + logInfo(setupMutationStarted + ? 'Setup stopped after a possible credential-health workflow change. Inspect the selected branch and GitHub workflow history before retrying.' + : 'Setup cancelled. No changes were applied.'); process.exitCode = 130; return; } logError(toApplicationError(error, 'workflow.failed', 'Setup failed.')); process.exitCode = 1; } finally { + credentialPrompt.showSetupPatCleanupReminder(); terminal?.close(); + if (webBridge && webServer) { + const outcome = webBridge.snapshot().journey?.outcome ?? (process.exitCode ? 'blocked' : 'cancelled'); + webBridge.finish(outcome, outcome === 'complete' + ? 'Setup completed. Delete the temporary setup PAT in GitHub; keep the bot PAT while its Secret is in use.' + : outcome === 'dry-run' ? 'Dry run complete. No files or GitHub resources changed.' + : outcome === 'partial' ? 'Setup may be partial. Inspect GitHub resources and run copilot doctor --read-only before retrying.' + : 'No further setup changes will be applied. Any PAT already created in GitHub still exists until you delete it there.'); + logInfo('The local browser page shows the result. Choose “Close local session” there, or stop this command with Ctrl+C.'); + await webServer.closed; + } + releaseSetupGuard?.(); } }); } - -function collectSecret(value: string, previous: Record): Record { - const separator = value.indexOf('='); - if (separator <= 0) throw new Error('--secret must use NAME=VALUE syntax.'); - const name = value.slice(0, separator).trim(); - const secret = value.slice(separator + 1); - if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !secret) throw new Error('--secret must use a non-empty NAME=VALUE with an uppercase secret name.'); - return { ...previous, [name]: secret }; -} - -function collectApprovalCheck(value: string, previous: string[]): string[] { - return [...previous, value]; -} - -function loadSetupOverrides(options: { - config?: string; - agent?: string; - features?: string; - issueWorkflows?: string; - agentGuidance?: string; - variablesScope?: string; - secretsScope?: string; - variablesVisibility?: string; - secretsVisibility?: string; - variableScope?: Record; - secretScope?: Record; - prApprovalMode?: string; - prApprovalCheck?: string[]; - prApprovalCoverageCheck?: string; - prApprovalAttestProducer?: boolean; -}): SetupConfigurationOverrides { - const fromFile = options.config ? loadSetupConfigurationOverrides(options.config) : {}; - const fromFlags: SetupConfigurationOverrides = {}; - if (options.prApprovalMode || options.prApprovalCheck?.length || options.prApprovalCoverageCheck || options.prApprovalAttestProducer) { - if (options.prApprovalMode && !['off', 'recommend', 'guarded'].includes(options.prApprovalMode)) { - throw new Error('--pr-approval-mode must be guarded, recommend, or off.'); - } - const checks = options.prApprovalCheck?.map(value => { - const [name, appId, workflowName] = value.split('|').map(item => item.trim()); - return { name, sourceAppId: Number(appId), workflowName }; - }); - fromFlags.pullRequestApproval = { - ...(options.prApprovalMode ? { mode: options.prApprovalMode as 'off' | 'recommend' | 'guarded' } : {}), - ...(checks?.length ? { testChecks: checks } : {}), - ...(options.prApprovalAttestProducer ? { producerAttested: true } : {}), - ...(options.prApprovalCoverageCheck ? { coverage: { mode: 'check', checkName: options.prApprovalCoverageCheck } } : {}), - }; - } - if (options.agent) { - if (!['codex', 'opencode', 'cursor'].includes(options.agent)) { - throw new Error('--agent must be one of: codex, opencode, cursor.'); - } - fromFlags.agents = Object.fromEntries( - ['planner', 'findings', 'reviewer', 'fixer', 'tester'].map(task => [task, { provider: options.agent }]), - ) as SetupConfigurationOverrides['agents']; - } - if (options.features) { - if (options.features.trim().toLowerCase() === 'all') { - fromFlags.features = Object.fromEntries(Object.keys(SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, true])); - } else { - const requested = options.features.split(',').map(feature => feature.trim()).filter(Boolean); - const unknown = requested.filter(feature => !Object.prototype.hasOwnProperty.call(SETUP_FEATURE_DESCRIPTIONS, feature)); - if (unknown.length > 0) throw new Error(`Unknown setup feature(s): ${unknown.join(', ')}.`); - fromFlags.features = Object.fromEntries(Object.keys(SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, requested.includes(feature)])); - } - } - if (options.issueWorkflows) { - const raw = options.issueWorkflows.trim().toLowerCase(); - const requested = raw === 'all' ? [...ISSUE_WORKFLOW_KINDS] : raw.split(',').map(item => item.trim()).filter(Boolean); - const unknown = requested.filter(item => !ISSUE_WORKFLOW_KINDS.includes(item as IssueWorkflowKind)); - if (unknown.length > 0) throw new Error(`Unknown issue workflow(s): ${unknown.join(', ')}.`); - if (new Set(requested).size !== requested.length) throw new Error('Issue workflow selection cannot contain duplicates.'); - fromFlags.issueWorkflows = { enabled: requested as IssueWorkflowKind[] }; - } - if (options.agentGuidance) { - const mode = options.agentGuidance.trim().toLowerCase(); - if (!['prompt', 'create-if-missing', 'disabled'].includes(mode)) throw new Error('--agent-guidance must be prompt, create-if-missing, or disabled.'); - fromFlags.repositoryAgentGuidance = { agentsPointer: mode as 'prompt' | 'create-if-missing' | 'disabled', enabled: mode !== 'disabled' }; - } - const storage: NonNullable = {}; - if (options.variablesScope || options.variablesVisibility || Object.keys(options.variableScope ?? {}).length > 0) { - storage.variables = { - ...(options.variablesScope ? { defaultScope: parseScope(options.variablesScope, '--variables-scope') } : {}), - ...(options.variablesVisibility ? { organizationVisibility: parseVisibility(options.variablesVisibility, '--variables-visibility') } : {}), - ...(Object.keys(options.variableScope ?? {}).length > 0 ? { overrides: options.variableScope } : {}), - }; - } - if (options.secretsScope || options.secretsVisibility || Object.keys(options.secretScope ?? {}).length > 0) { - storage.secrets = { - ...(options.secretsScope ? { defaultScope: parseScope(options.secretsScope, '--secrets-scope') } : {}), - ...(options.secretsVisibility ? { organizationVisibility: parseVisibility(options.secretsVisibility, '--secrets-visibility') } : {}), - ...(Object.keys(options.secretScope ?? {}).length > 0 ? { overrides: options.secretScope } : {}), - }; - } - if (Object.keys(storage).length > 0) fromFlags.storage = storage; - return mergeSetupOverrides(fromFile, fromFlags); -} - -function mergeSetupOverrides( - fileOverrides: SetupConfigurationOverrides, - flagOverrides: SetupConfigurationOverrides, -): SetupConfigurationOverrides { - return { - ...fileOverrides, - ...flagOverrides, - features: { ...fileOverrides.features, ...flagOverrides.features }, - agents: { ...fileOverrides.agents, ...flagOverrides.agents }, - repository: { ...fileOverrides.repository, ...flagOverrides.repository }, - ai: { ...fileOverrides.ai, ...flagOverrides.ai }, - pullRequestApproval: { - ...fileOverrides.pullRequestApproval, - ...flagOverrides.pullRequestApproval, - coverage: { ...fileOverrides.pullRequestApproval?.coverage, ...flagOverrides.pullRequestApproval?.coverage }, - } as SetupConfigurationOverrides['pullRequestApproval'], - projects: { ...fileOverrides.projects, ...flagOverrides.projects }, - issueWorkflows: { ...fileOverrides.issueWorkflows, ...flagOverrides.issueWorkflows }, - repositoryAgentGuidance: { ...fileOverrides.repositoryAgentGuidance, ...flagOverrides.repositoryAgentGuidance }, - storage: { - ...fileOverrides.storage, - ...flagOverrides.storage, - secrets: { ...fileOverrides.storage?.secrets, ...flagOverrides.storage?.secrets, overrides: { ...fileOverrides.storage?.secrets?.overrides, ...flagOverrides.storage?.secrets?.overrides } }, - variables: { ...fileOverrides.storage?.variables, ...flagOverrides.storage?.variables, overrides: { ...fileOverrides.storage?.variables?.overrides, ...flagOverrides.storage?.variables?.overrides } }, - }, - }; -} - -function collectScope(value: string, previous: Record): Record { - const separator = value.indexOf('='); - if (separator <= 0) throw new Error('Scope overrides must use NAME=repository or NAME=organization syntax.'); - const name = value.slice(0, separator).trim(); - const scope = value.slice(separator + 1).trim().toLowerCase(); - if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !['repository', 'organization'].includes(scope)) { - throw new Error('Scope overrides must use an uppercase NAME and repository or organization scope.'); - } - return { ...previous, [name]: scope as SetupResourceScope }; -} - -function parseScope(value: string, flag: string): 'repository' | 'organization' { - const normalized = value.trim().toLowerCase(); - if (normalized !== 'repository' && normalized !== 'organization') throw new Error(`${flag} must be repository or organization.`); - return normalized; -} - -function parseVisibility(value: string, flag: string): 'all' | 'private' | 'selected' { - const normalized = value.trim().toLowerCase(); - if (!['all', 'private', 'selected'].includes(normalized)) throw new Error(`${flag} must be selected, private, or all.`); - return normalized as 'all' | 'private' | 'selected'; -} diff --git a/src/cli/commands/setup_policy.ts b/src/cli/commands/setup_policy.ts index 11ff37247..16fc8fcfa 100644 --- a/src/cli/commands/setup_policy.ts +++ b/src/cli/commands/setup_policy.ts @@ -10,14 +10,13 @@ export interface SetupCommandOptions { export function buildSetupParams( options: SetupCommandOptions, - gitInfo: GitInfo, + gitInfo: Extract, token: string, configuration?: SetupConfiguration, credentials?: SetupCredentialCollection, approvedWorkflowFiles: readonly string[] = [], remoteConfiguration?: SetupRemoteConfiguration, -): Record | undefined { - if ('error' in gitInfo) return undefined; +): Record { return { ...(configuration ? buildSetupActionInputs(configuration) : {}), [INPUT_KEYS.DEBUG]: options.debug?.toString() ?? 'false', diff --git a/src/cli/setup_apply_snapshot.ts b/src/cli/setup_apply_snapshot.ts new file mode 100644 index 000000000..42c2d69d3 --- /dev/null +++ b/src/cli/setup_apply_snapshot.ts @@ -0,0 +1,42 @@ +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync } from 'node:fs'; +import { resolve, relative, isAbsolute, sep } from 'node:path'; + +/** Captures only the selected setup paths. Missing files are part of the snapshot. */ +export function captureSetupApplySnapshot(repositoryRoot: string, selectedFiles: readonly string[]): Readonly> { + const root = resolve(repositoryRoot); + const result: Record = {}; + for (const name of [...new Set(selectedFiles)].sort()) { + const path = resolve(root, name); + const inside = relative(root, path); + if (isAbsolute(name) || !inside || inside === '..' || inside.startsWith(`..${sep}`)) { + throw new Error('The setup plan contains a path outside the repository.'); + } + // A lexically in-repository path can still escape through a parent symlink. + let prefix = root; + for (const segment of inside.split(sep)) { + prefix = resolve(prefix, segment); + try { + if (lstatSync(prefix).isSymbolicLink()) throw new Error(`Setup path ${name} traverses a symbolic link.`); + } catch (cause) { + if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 'ENOENT') break; + throw cause; + } + } + try { + const stat = lstatSync(path); + if (stat.isFile() && stat.size <= 5 * 1024 * 1024) { + result[name] = `file:${createHash('sha256').update(readFileSync(path)).digest('hex')}`; + } else throw new Error(`Cannot safely snapshot setup file ${name}.`); + } catch (cause) { + if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 'ENOENT') result[name] = 'missing'; + else throw cause; + } + } + return result; +} + +export function setupApplySnapshotMatches(repositoryRoot: string, selectedFiles: readonly string[], expected: Readonly>): boolean { + const current = captureSetupApplySnapshot(repositoryRoot, selectedFiles); + return JSON.stringify(current) === JSON.stringify(expected); +} diff --git a/src/cli/setup_command_options.ts b/src/cli/setup_command_options.ts new file mode 100644 index 000000000..13a0c8ef5 --- /dev/null +++ b/src/cli/setup_command_options.ts @@ -0,0 +1,127 @@ +import { loadSetupConfigurationOverrides } from './setup_config_file'; +import { SETUP_FEATURE_DESCRIPTIONS, type SetupConfigurationOverrides } from '../application/policies/setup_configuration_policy'; +import { mergeSetupOverrides } from '../application/policies/merge_setup_overrides_policy'; +import type { SetupResourceScope } from '../domain/setup'; +import { ISSUE_WORKFLOW_KINDS, type IssueWorkflowKind } from '../domain/issue_workflow_profile'; + +export function collectSecret(value: string, previous: Record): Record { + const separator = value.indexOf('='); + if (separator <= 0) throw new Error('--secret must use NAME=VALUE syntax.'); + const name = value.slice(0, separator).trim(); + const secret = value.slice(separator + 1); + if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !secret) throw new Error('--secret must use a non-empty NAME=VALUE with an uppercase secret name.'); + return { ...previous, [name]: secret }; +} + +export function collectApprovalCheck(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +export interface SetupCommandOverrideOptions { + config?: string; + agent?: string; + features?: string; + issueWorkflows?: string; + agentGuidance?: string; + variablesScope?: string; + secretsScope?: string; + variablesVisibility?: string; + secretsVisibility?: string; + variableScope?: Record; + secretScope?: Record; + prApprovalMode?: string; + prApprovalCheck?: string[]; + prApprovalCoverageCheck?: string; + prApprovalAttestProducer?: boolean; +} + +export function loadSetupOverrides(options: SetupCommandOverrideOptions): SetupConfigurationOverrides { + const fromFile = options.config ? loadSetupConfigurationOverrides(options.config) : {}; + const fromFlags: SetupConfigurationOverrides = {}; + if (options.prApprovalMode || options.prApprovalCheck?.length || options.prApprovalCoverageCheck || options.prApprovalAttestProducer) { + if (options.prApprovalMode && !['off', 'recommend', 'guarded'].includes(options.prApprovalMode)) { + throw new Error('--pr-approval-mode must be guarded, recommend, or off.'); + } + const checks = options.prApprovalCheck?.map(value => { + const [name, appId, workflowName] = value.split('|').map(item => item.trim()); + return { name, sourceAppId: Number(appId), workflowName }; + }); + fromFlags.pullRequestApproval = { + ...(options.prApprovalMode ? { mode: options.prApprovalMode as 'off' | 'recommend' | 'guarded' } : {}), + ...(checks?.length ? { testChecks: checks } : {}), + ...(options.prApprovalAttestProducer ? { producerAttested: true } : {}), + ...(options.prApprovalCoverageCheck ? { coverage: { mode: 'check', checkName: options.prApprovalCoverageCheck } } : {}), + }; + } + if (options.agent) { + if (!['codex', 'opencode', 'cursor'].includes(options.agent)) { + throw new Error('--agent must be one of: codex, opencode, cursor.'); + } + fromFlags.agents = Object.fromEntries( + ['planner', 'findings', 'reviewer', 'fixer', 'tester'].map(task => [task, { provider: options.agent }]), + ) as SetupConfigurationOverrides['agents']; + } + if (options.features) { + if (options.features.trim().toLowerCase() === 'all') { + fromFlags.features = Object.fromEntries(Object.keys(SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, true])); + } else { + const requested = options.features.split(',').map(feature => feature.trim()).filter(Boolean); + const unknown = requested.filter(feature => !Object.prototype.hasOwnProperty.call(SETUP_FEATURE_DESCRIPTIONS, feature)); + if (unknown.length > 0) throw new Error(`Unknown setup feature(s): ${unknown.join(', ')}.`); + fromFlags.features = Object.fromEntries(Object.keys(SETUP_FEATURE_DESCRIPTIONS).map(feature => [feature, requested.includes(feature)])); + } + } + if (options.issueWorkflows) { + const raw = options.issueWorkflows.trim().toLowerCase(); + const requested = raw === 'all' ? [...ISSUE_WORKFLOW_KINDS] : raw.split(',').map(item => item.trim()).filter(Boolean); + const unknown = requested.filter(item => !ISSUE_WORKFLOW_KINDS.includes(item as IssueWorkflowKind)); + if (unknown.length > 0) throw new Error(`Unknown issue workflow(s): ${unknown.join(', ')}.`); + if (new Set(requested).size !== requested.length) throw new Error('Issue workflow selection cannot contain duplicates.'); + fromFlags.issueWorkflows = { enabled: requested as IssueWorkflowKind[] }; + } + if (options.agentGuidance) { + const mode = options.agentGuidance.trim().toLowerCase(); + if (!['prompt', 'create-if-missing', 'disabled'].includes(mode)) throw new Error('--agent-guidance must be prompt, create-if-missing, or disabled.'); + fromFlags.repositoryAgentGuidance = { agentsPointer: mode as 'prompt' | 'create-if-missing' | 'disabled', enabled: mode !== 'disabled' }; + } + const storage: NonNullable = {}; + if (options.variablesScope || options.variablesVisibility || Object.keys(options.variableScope ?? {}).length > 0) { + storage.variables = { + ...(options.variablesScope ? { defaultScope: parseScope(options.variablesScope, '--variables-scope') } : {}), + ...(options.variablesVisibility ? { organizationVisibility: parseVisibility(options.variablesVisibility, '--variables-visibility') } : {}), + ...(Object.keys(options.variableScope ?? {}).length > 0 ? { overrides: options.variableScope } : {}), + }; + } + if (options.secretsScope || options.secretsVisibility || Object.keys(options.secretScope ?? {}).length > 0) { + storage.secrets = { + ...(options.secretsScope ? { defaultScope: parseScope(options.secretsScope, '--secrets-scope') } : {}), + ...(options.secretsVisibility ? { organizationVisibility: parseVisibility(options.secretsVisibility, '--secrets-visibility') } : {}), + ...(Object.keys(options.secretScope ?? {}).length > 0 ? { overrides: options.secretScope } : {}), + }; + } + if (Object.keys(storage).length > 0) fromFlags.storage = storage; + return mergeSetupOverrides(fromFile, fromFlags); +} + +export function collectScope(value: string, previous: Record): Record { + const separator = value.indexOf('='); + if (separator <= 0) throw new Error('Scope overrides must use NAME=repository or NAME=organization syntax.'); + const name = value.slice(0, separator).trim(); + const scope = value.slice(separator + 1).trim().toLowerCase(); + if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !['repository', 'organization'].includes(scope)) { + throw new Error('Scope overrides must use an uppercase NAME and repository or organization scope.'); + } + return { ...previous, [name]: scope as SetupResourceScope }; +} + +function parseScope(value: string, flag: string): 'repository' | 'organization' { + const normalized = value.trim().toLowerCase(); + if (normalized !== 'repository' && normalized !== 'organization') throw new Error(`${flag} must be repository or organization.`); + return normalized; +} + +function parseVisibility(value: string, flag: string): 'all' | 'private' | 'selected' { + const normalized = value.trim().toLowerCase(); + if (!['all', 'private', 'selected'].includes(normalized)) throw new Error(`${flag} must be selected, private, or all.`); + return normalized as 'all' | 'private' | 'selected'; +} diff --git a/src/cli/setup_confirmation_adapter.ts b/src/cli/setup_confirmation_adapter.ts index 4682085dd..547b0ee38 100644 --- a/src/cli/setup_confirmation_adapter.ts +++ b/src/cli/setup_confirmation_adapter.ts @@ -3,7 +3,9 @@ import type { TerminalDriver, } from '../application/ports/setup_terminal_ports'; import type { SetupPlan } from '../domain/setup'; -import { color } from './setup_prompt_rendering'; +import { color, renderBox } from './setup_prompt_rendering'; +import { setupEditableGroups, setupQuestionnaireStateLabel } from '../application/policies/setup_questionnaire_policy'; +import type { SetupQuestion } from '../domain/setup_questionnaire'; export class SetupPlanConfirmationAdapter implements SetupPlanConfirmationPort { constructor( @@ -12,17 +14,39 @@ export class SetupPlanConfirmationAdapter implements SetupPlanConfirmationPort { ) {} async confirm(plan: SetupPlan): Promise< - { kind: 'approved' } | { kind: 'declined' } | { kind: 'cancelled' } + { kind: 'approved' } | { kind: 'declined' } | { kind: 'cancelled' } | { kind: 'revise'; group: SetupQuestion['stateId'] } > { if (this.assumeYes) return { kind: 'approved' }; if (!this.terminal) return { kind: 'declined' }; - const target = plan.configuration.manageRepositoryVariables - ? 'the repository and GitHub Variables' - : 'the repository'; + const target = plan.configuration.manageRepositoryVariables || plan.configuration.manageRepositorySecrets + ? 'repository files and selected GitHub Actions resources' + : 'repository files'; + const groups = setupEditableGroups(plan.configuration); while (true) { - const result = await this.terminal.readText(`Apply this setup plan to ${target}? ${color('[N]', 90)}: `); + const result = await this.terminal.readText(`Apply this setup plan to ${target}? Type ? for details or :edit to change an answer. ${color('[N]', 90)}: `); if (result.kind !== 'value') return { kind: 'cancelled' }; const value = result.value.normalize('NFKC').trim().toLowerCase(); + if (value === '?') { + console.log(renderBox([ + `This is the final approval. The plan lists ${plan.selectedFiles.length} file(s), ${plan.variables.length} Variable(s), and ${plan.requiredSecrets.length} Secret name(s).`, + 'Yes starts the listed local and GitHub setup writes. No leaves the plan unapplied.', + 'A failure after writes begin may leave partial changes; inspect the result and run copilot doctor --read-only before retrying.', + 'PATs created on GitHub are not deleted automatically if you decline or cancel.', + 'Read more: https://docs.page/vypdev/copilot/how-to-use', + ].join('\n'), 'Before applying setup')); + continue; + } + if (value === ':edit') { + console.log(groups.map((group, index) => ` ${index + 1}) ${setupQuestionnaireStateLabel(group)}`).join('\n')); + const selected = await this.terminal.readText('Choose a section number (empty returns to the plan): '); + if (selected.kind !== 'value') return { kind: 'cancelled' }; + const index = Number(selected.value.trim()) - 1; + if (/^[1-9]\d*$/u.test(selected.value.trim()) && Number.isSafeInteger(index) && groups[index]) { + return { kind: 'revise', group: groups[index] }; + } + if (selected.value.trim()) console.log(color('Choose one of the listed section numbers.', 33)); + continue; + } if (!value || ['n', 'no', 'false', '0'].includes(value)) return { kind: 'declined' }; if (['y', 'yes', 'true', '1'].includes(value)) return { kind: 'approved' }; console.log(color('Enter yes or no.', 33)); diff --git a/src/cli/setup_credential_prompt_adapter.ts b/src/cli/setup_credential_prompt_adapter.ts index 45630cd3a..953e9cfbd 100644 --- a/src/cli/setup_credential_prompt_adapter.ts +++ b/src/cli/setup_credential_prompt_adapter.ts @@ -7,29 +7,135 @@ import type { SetupCredentialValue, } from '../domain/setup'; import type { SetupTokenPermissionReport } from '../domain/setup_token_permissions'; +import type { SetupGithubIdentity } from '../application/ports/setup_pat_identity_ports'; import { color, renderBox, statusIcon } from './setup_prompt_rendering'; +import type { SetupTokenPermissionRequirement } from '../domain/setup_token_permissions'; +import { renderSetupTokenPermissionRequirements } from './setup_token_permission_presenter'; +import { SetupInteractionCancelledError } from '../application/errors/setup_interaction_cancelled_error'; -export class SetupTerminalCancelledError extends Error { - constructor() { - super('Setup input was cancelled.'); - this.name = 'SetupTerminalCancelledError'; - } -} +/** @deprecated Use the presentation-neutral cancellation signal in new adapters. */ +export const SetupTerminalCancelledError = SetupInteractionCancelledError; + +const AUTHENTICATION_GUIDE = 'https://docs.page/vypdev/copilot/authentication'; +const GITHUB_PAT_SETTINGS = 'https://github.com/settings/personal-access-tokens'; export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { + private setupPatGuide?: string; + private workflowPatGuide?: string; + private resolveBotIdentity?: (login: string) => Promise; + private guidedSetup = false; + private setupMethodChosen = false; + private guidedBotIdentity?: SetupGithubIdentity; + private workflowPatRequirements?: readonly SetupTokenPermissionRequirement[]; + constructor( private readonly terminal: TerminalDriver | undefined, private readonly credentialValues: Readonly>, private readonly confirmUnverifiableWritePermissions = false, ) {} + configureSetupPatGuide(url: string): void { this.setupPatGuide = url; } + get usedGuidedSetupPat(): boolean { return this.guidedSetup; } + async chooseSetupPresentationMode(): Promise<'basic' | 'custom'> { + if (!this.terminal) return 'custom'; + const choice = await this.readChoice('How much configuration detail would you like to review now?', + ['Basic guided setup', 'Customize every setting'], 'Basic guided setup', + 'Basic keeps every permission, security, branch-role, Projects, approval, and storage decision visible. It uses existing defaults for selected advanced agent, branch-prefix, and Bugbot settings. The final plan shows their consequences and lets you edit any section before Apply. Customize asks every applicable question. Neither path changes GitHub before your final approval.'); + return choice === 'Basic guided setup' ? 'basic' : 'custom'; + } + async chooseSetupPatMethod(): Promise<'guided' | 'manual'> { + if (!this.terminal) return 'manual'; + this.setupMethodChosen = true; + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link', + `Guided opens GitHub's official fine-grained PAT form with proposed grants. Manual means you create the PAT yourself and enter it here. In either case GitHub handles account sign-in and 2FA; Copilot never revokes the token automatically.\nRead more: ${AUTHENTICATION_GUIDE}`)) === 'guided link'; + return this.guidedSetup ? 'guided' : 'manual'; + } + useManualSetupPat(): void { this.guidedSetup = false; this.setupPatGuide = undefined; this.setupMethodChosen = true; } + async chooseSetupOwnerKind(): Promise<'Organization' | 'User' | 'unknown'> { + if (!this.terminal) return 'unknown'; + const choice = await this.readChoice('Is the GitHub repository owner an organization or a personal account?', ['organization', 'personal account', 'not sure'], undefined, + `The owner is the name before / in owner/repository. Organization-owned repositories can require organization-level grants or SSO approval; a personal account cannot. Check the repository header on GitHub if unsure.\nRead more: ${AUTHENTICATION_GUIDE}`); + return choice === 'organization' ? 'Organization' : choice === 'personal account' ? 'User' : 'unknown'; + } + async reviewSetupPatIntent(): Promise<'continue' | 'revise' | 'manual' | 'details'> { + if (!this.terminal) return 'manual'; + const choice = await this.readChoice( + 'Review these intended grants before opening GitHub. What would you like to do?', + ['continue to GitHub', 'review all setup choices again', 'view full permission table', 'enter a PAT manually'], + undefined, + `These grants are provisional: your choices and GitHub visibility determine the final least-privilege PAT permissions. Reviewing choices does not restart this setup run or apply changes.\nRead more: ${AUTHENTICATION_GUIDE}`, + ); + if (choice === 'review all setup choices again') return 'revise'; + if (choice === 'view full permission table') return 'details'; + if (choice === 'enter a PAT manually') return 'manual'; + return 'continue'; + } + configureWorkflowPatGuide(url: string, resolveIdentity: (login: string) => Promise, requirements?: readonly SetupTokenPermissionRequirement[]): void { + this.workflowPatGuide = url; + this.resolveBotIdentity = resolveIdentity; + this.workflowPatRequirements = requirements; + } + get guidedWorkflowBotIdentity(): SetupGithubIdentity | undefined { return this.guidedBotIdentity; } + + async confirmGuidedSetupAccount(account?: string): Promise { + if (!this.guidedSetup || !this.terminal) return true; + if (!account || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(account)) return false; + console.log(`GitHub authenticated the setup PAT as @${account}.`); + return (await this.readChoice('Is this the account you intended to configure with?', ['yes', 'no'], 'yes', + `Use the operator account that is authorized to configure this repository and organization. A different account's PAT may have different access even if the form looked correct. Select no to stop safely.\nRead more: ${AUTHENTICATION_GUIDE}`)) === 'yes'; + } + + showSetupPatCleanupReminder(): void { + if (!this.guidedSetup || !this.setupPatGuide) return; + console.log(renderBox( + 'The setup PAT was not revoked automatically. After setup finishes or is cancelled, delete it in GitHub → Settings → Developer settings → Personal access tokens. Ending this process does not remove the token from GitHub.', + 'Revoke temporary setup PAT', + 33, + )); + console.log('https://github.com/settings/personal-access-tokens'); + } + + showUpdatedSetupPatLink(url: string, stage: 'bootstrap' | 'final', delta?: readonly string[]): void { + if (!this.guidedSetup) return; + console.log(renderBox( + stage === 'bootstrap' + ? 'The setup PAT did not pass the initial access check; no setup plan has been applied. Review its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.' + : 'The selected plan requires access this PAT did not prove; no plan mutation has started. Update its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.', + stage === 'bootstrap' ? 'Setup PAT access needs attention' : 'Setup PAT permissions changed', + 33, + )); + if (delta?.length) console.log(delta.map(item => ` - ${item}`).join('\n')); + console.log(url); + } + async requestSetupPat(): Promise { if (!this.terminal) return undefined; + if (this.setupPatGuide && !this.setupMethodChosen) { + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link', + `Guided opens GitHub's official form; manual uses a PAT you made yourself. Both are entered only into this local command.\nRead more: ${AUTHENTICATION_GUIDE}`)) === 'guided link'; + if (this.guidedSetup) { + console.log(renderBox( + 'Provisional link: Open this GitHub link in your browser, sign in as the account configuring this repository, complete any 2FA or SSO, and review the prefilled fine-grained permissions. GitHub owns token creation; Copilot never handles your web session. Change All repositories to Only select repositories and select ONLY this repository. Remote inspection may require a corrected token later.', + 'Create setup PAT in GitHub', 33, + )); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } + } + if (this.setupMethodChosen && this.guidedSetup && this.setupPatGuide) { + console.log(renderBox( + 'Open this GitHub link as the account configuring this repository; complete any 2FA or SSO. Review the prefilled grants. Change All repositories to Only select repositories and select ONLY this repository. GitHub creates the PAT; Copilot does not handle your browser session. Remote inspection may require a corrected token later.', + 'Create setup PAT in GitHub', 33, + )); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } console.log(renderBox( 'Enter a GitHub setup PAT. It is used in memory for this run only and is never stored. The workflow PAT is a different bot-account token and is requested separately.', 'Setup PAT', 33, )); + console.log(`PAT creation and cleanup: ${AUTHENTICATION_GUIDE}\nGitHub PAT settings: ${GITHUB_PAT_SETTINGS}`); return this.readSecret('Setup PAT'); } @@ -71,15 +177,55 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { 33, )); console.log(`Credential options: ${requirements.map((requirement) => requirement.name).join(', ')}`); + console.log(`Why these credentials are separate: ${AUTHENTICATION_GUIDE}`); } - requestWorkflowPat( + async requestWorkflowPat( requirement: SetupCredentialRequirement, current?: SetupCredentialCheck, ): Promise { + if (this.terminal && !this.credentialValues[requirement.name]?.trim() && this.workflowPatGuide) { + let choice: string; + do { + choice = await this.readChoice('How would you like to provide the bot workflow PAT?', ['guided link', 'manual PAT', 'view full permission table'], 'guided link', + `Use a PAT from the dedicated bot account, not the operator setup PAT. Guided opens GitHub's form; manual keeps the permission table visible. The bot PAT is installed as an Actions Secret only after Apply.\nRead more: ${AUTHENTICATION_GUIDE}`); + if (choice === 'view full permission table' && this.workflowPatRequirements) { + console.log(renderSetupTokenPermissionRequirements('workflow', this.workflowPatRequirements)); + } + } while (choice === 'view full permission table'); + const guided = choice === 'guided link'; + if (guided) { + const login = await this.readBotLogin(); + const identity = await this.resolveBotIdentity!(login); + this.guidedBotIdentity = identity; + console.log(`Expected bot account resolved: @${identity.login} (GitHub account ID ${identity.id}).`); + console.log(renderBox( + `Open this link in a separate/private browser session, sign in as @${login} (the bot account), and complete its 2FA or SSO. Review every grant and select ONLY the intended repository manually. GitHub creates the PAT; Copilot does not store bot web credentials. The suggested expiry is 90 days—renew the token and update the Actions Secret before then.`, + 'Create bot PAT in GitHub', 33, + )); + console.log(this.workflowPatGuide); + console.log('Copy the one-time bot token and paste it below. It will be validated before any Secret is written.'); + } else if (this.workflowPatRequirements) { + console.log(renderSetupTokenPermissionRequirements('workflow', this.workflowPatRequirements)); + } + } return this.requestSecretForRequirement(requirement, current, 'workflow PAT owned by the bot account'); } + private async readBotLogin(): Promise { + while (true) { + const result = await this.terminal!.readText('Expected GitHub bot login (without @; type ? for help): '); + if (result.kind !== 'value') throw new SetupTerminalCancelledError(); + const login = result.value.trim(); + if (login === '?') { + console.log(renderBox(`Enter the exact GitHub username of the separate bot account, without @. Copilot resolves its numeric account ID and compares it with the PAT before any Secret is written. It does not sign in as the bot or store its web credentials.\nRead more: ${AUTHENTICATION_GUIDE}`, 'About the bot account')); + continue; + } + if (/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) return login; + console.log(color('Enter a valid GitHub account login.', 33)); + } + } + requestApiKey( requirement: SetupCredentialRequirement, current?: SetupCredentialCheck, @@ -98,6 +244,7 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { `How should Copilot handle the existing ${requirement.name}?`, ['keep', 'replace', 'skip'], check.status === 'valid' ? 'keep' : 'replace', + `Keep retains the existing Secret; GitHub does not reveal its value for inspection. Replace asks for a new credential and may update the Secret after Apply. Skip leaves this optional credential unconfigured. Check the plan before approving writes.\nRead more: ${AUTHENTICATION_GUIDE}`, ) as Promise; } @@ -132,7 +279,8 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { private async readChoice( label: string, choices: readonly string[], - defaultValue: string, + defaultValue?: string, + help?: string, ): Promise { while (true) { const lines = choices.map((choice, index) => @@ -140,10 +288,15 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { const result = await this.terminal!.readText([ label, ...lines, - `Select 1-${choices.length} ${color(`[${choices.indexOf(defaultValue) + 1}]`, 90)}: `, + ...(help ? ['Type ? for more detail without selecting an answer.'] : []), + `Select 1-${choices.length}${defaultValue ? ` ${color(`[${choices.indexOf(defaultValue) + 1}]`, 90)}` : ''}: `, ].join('\n')); if (result.kind !== 'value') throw new SetupTerminalCancelledError(); - if (!result.value.trim()) return defaultValue; + if (result.value.trim() === '?' && help) { + console.log(renderBox(help, 'About this credential choice')); + continue; + } + if (!result.value.trim() && defaultValue) return defaultValue; const index = Number(result.value) - 1; if (Number.isInteger(index) && choices[index]) return choices[index]; console.log(color('Select one of the listed options.', 33)); diff --git a/src/cli/setup_journey_presenter.ts b/src/cli/setup_journey_presenter.ts new file mode 100644 index 000000000..83e09476f --- /dev/null +++ b/src/cli/setup_journey_presenter.ts @@ -0,0 +1,30 @@ +import type { SetupJourneyPresenterPort } from '../application/usecases/setup/setup_journey_use_case'; +import type { SetupJourneyView } from '../application/policies/setup_journey_policy'; +import { renderBox } from './setup_prompt_rendering'; + +export class ConsoleSetupJourneyPresenter implements SetupJourneyPresenterPort { + present(view: SetupJourneyView): void { + console.log(renderSetupJourney(view)); + } +} + +export function renderSetupJourney(view: SetupJourneyView, maximumWidth?: number): string { + const revisitingChoices = view.current === 'Setup choices' && view.choiceReviewPass > 1; + const state = view.outcome === 'complete' ? 'Complete: setup applied successfully.' + : view.outcome === 'dry-run' ? 'Complete: dry run only; no changes were applied.' + : view.outcome === 'partial' ? 'Partial: changes may exist; inspect the branch and GitHub resources before retrying.' + : view.outcome === 'blocked' ? 'Blocked: setup cannot continue.' + : view.outcome === 'cancelled' ? 'Cancelled: setup stopped.' + : view.mutationStarted ? view.current === 'Bot PAT & credentials' + ? 'Checking credentials; a temporary GitHub workflow change may exist.' + : 'Applying the approved plan; changes may already exist.' + : 'No changes have been applied.'; + return renderBox([ + `Repository: ${view.repository}`, + `Stage ${view.position}/${view.total} · ${view.current}${revisitingChoices ? ` · review pass ${view.choiceReviewPass}` : ''}`, + `Complete: ${view.complete.join(' → ') || 'none'}`, + `Now: ${revisitingChoices ? 'reviewing saved setup choices' : view.current}`, + `Next: ${view.pending.join(' → ') || 'none'}`, + state, + ].join('\n'), 'Copilot setup', 36, maximumWidth); +} diff --git a/src/cli/setup_plan_presenter.ts b/src/cli/setup_plan_presenter.ts index 2e1a6af39..865e5a03e 100644 --- a/src/cli/setup_plan_presenter.ts +++ b/src/cli/setup_plan_presenter.ts @@ -36,6 +36,8 @@ export function renderSetupPlan(plan: SetupPlan): string { ` Outcome: ${approval.mode === 'off' ? 'disabled' : approval.mode === 'recommend' ? 'recommendation only' : 'eligible PRs may be approved after default-branch installation and live evidence'}`, ' Native approval still requires readable stale-dismissal rules and a distinct runtime PAT bot.', '', color('Repository changes', 36), + ` Production/development branches: ${plan.configuration.repository.mainBranch} / ${plan.configuration.repository.developmentBranch}`, + ` Projects: ${plan.configuration.projects.ids || '(none)'}`, ` Files selected: ${plan.selectedFiles.length}`, ` Variables to upsert: ${plan.configuration.manageRepositoryVariables ? plan.variables.length : 0}`, ` Secret options to validate/provision: ${plan.configuration.manageRepositorySecrets ? plan.credentialRequirements.length : 0}`, @@ -43,6 +45,8 @@ export function renderSetupPlan(plan: SetupPlan): string { ` Secret storage: ${storageLabel(plan.configuration.storage.secrets)}`, ' Labels and issue types: always checked by Copilot setup', ` Initial tag: ${plan.configuration.createInitialTag ? 'v1.0.0 when no version tag exists' : 'disabled'}`, '', + ...(plan.presentationDefaults?.length ? [color('Advanced defaults retained in basic setup', 36), + ...plan.presentationDefaults.map(item => ` ${item.group}: ${item.count} settings not asked; use :edit at plan confirmation to review or change.`), ''] : []), ...(plan.mergeQueueReadiness.length > 0 ? [ color('Merge queue readiness', 36), ...plan.mergeQueueReadiness.map((check) => ` ${doctorIcon(check.status)} ${check.id}: ${check.summary}`), diff --git a/src/cli/setup_question_renderer.ts b/src/cli/setup_question_renderer.ts index d2f637f19..945aa4246 100644 --- a/src/cli/setup_question_renderer.ts +++ b/src/cli/setup_question_renderer.ts @@ -1,10 +1,35 @@ import type { SetupQuestionRenderer } from '../application/ports/setup_terminal_ports'; -import type { SetupQuestion } from '../domain/setup_questionnaire'; +import type { SetupQuestion, SetupQuestionnaireProgress } from '../domain/setup_questionnaire'; import { color, renderBox } from './setup_prompt_rendering'; import { setupQuestionnaireStateLabel } from '../application/policies/setup_questionnaire_policy'; +import { setupQuestionPresentation } from '../application/policies/setup_question_guidance_policy'; export class ConsoleSetupQuestionRenderer implements SetupQuestionRenderer { + constructor( + private readonly phase: 'full' | 'permission-intent' = 'full', + private readonly choiceReviewPass = 1, + ) {} + showIntroduction(): void { + if (this.phase === 'permission-intent') { + console.log(renderBox( + this.choiceReviewPass > 1 + ? [ + `Reviewing your setup choices again (pass ${this.choiceReviewPass}).`, + 'This is the same setup run. Your answers are saved as defaults.', + 'Press Enter to keep each answer, or enter a new value.', + 'After this pass you return to the setup PAT permission review.', + 'No setup changes have been applied.', + ].join('\n') + : [ + 'First, choose the setup options that affect your temporary PAT permissions.', + 'These answers carry into the later full wizard and are not asked there again', + 'unless you choose to review them here. No GitHub changes happen in this step.', + ].join('\n'), + this.choiceReviewPass > 1 ? 'Review saved setup choices' : 'Setup PAT permission intent', + )); + return; + } console.log(renderBox( 'This wizard configures repository workflows, GitHub Actions resources, AI agents, and operational defaults.\n\nThe setup PAT is used in memory only. Runtime credentials are collected separately after the plan is approved.', 'Copilot Setup', @@ -15,28 +40,73 @@ export class ConsoleSetupQuestionRenderer implements SetupQuestionRenderer { console.log(color(`\n${setupQuestionnaireStateLabel(stateId)}\n`, 36)); } - renderPrompt(question: SetupQuestion): string { + renderPrompt(question: SetupQuestion, progress?: SetupQuestionnaireProgress): string { + const help = setupQuestionPresentation(question).en; + const step = progress ? `${setupQuestionnaireStateLabel(progress.group)} — question ${progress.groupPosition} of ${progress.groupTotal} (overall ${progress.position} of ${progress.total}).\n` : ''; + const source = question.suggestionSource === 'github' ? ' (observed from authenticated GitHub repository metadata)' + : question.suggestionSource === 'local' ? ' (observed in this local checkout; confirm it exists on GitHub)' + : question.suggestionSource === 'configuration' ? ' (provided by your configuration)' + : question.suggestionSource === 'default' ? ' (product default; not verified against GitHub)' : ''; + const fixedWorkflowNote = question.id === 'features.issues' && (question.fixedWorkflowFeatures?.release || question.fixedWorkflowFeatures?.hotfix) + ? ' Configuration explicitly enables release or hotfix; keep Issues enabled unless you edit --config/flags.' + : (['release', 'hotfix'] as const).flatMap(kind => { + const fixed = question.fixedWorkflowFeatures?.[kind]; + return fixed === undefined ? [] : [` Configuration fixes features.${kind}=${fixed}; ${fixed ? 'keep' : 'leave'} ${kind} ${fixed ? 'selected' : 'unselected'} unless you edit --config/flags.`]; + }).join('\n'); + const heading = `${step}${question.label}\n ${help.summary}\n Suggested: ${formatDefault(question.defaultValue)}${source}. ${help.documentation.title}: ${help.documentation.url}\n Type ? for detailed help; type :back to return to the previous question without clearing saved answers.${fixedWorkflowNote ? `\n${fixedWorkflowNote}` : ''}${discoveryNote(question)}`; + const reviewedStatuses = question.projectStatusValues?.map(item => ` ${item.transition}: ${item.value}`).join('\n'); const fallback = formatDefault(question.defaultValue); if (question.kind === 'choice') { const choices = question.choices ?? []; const lines = choices.map((choice, index) => - ` ${index + 1}) ${choice}${choice === question.defaultValue ? color(' (default)', 90) : ''}`); - return [question.label, ...lines, `Select 1-${choices.length} ${color(`[${choices.indexOf(String(question.defaultValue)) + 1}]`, 90)}: `].join('\n'); + ` ${index + 1}) ${choice}${question.id === 'pullRequestApproval.coverage.checkName' + ? (() => { const producer = question.trustedProducers?.find(item => item.name === choice); + return producer ? ` — ${producer.workflowName} · App ${producer.sourceAppId}` : ''; })() : ''}${choice === question.defaultValue ? color(' (default)', 90) : ''}`); + return [heading, ...lines, `Select 1-${choices.length} ${color(`[${choices.indexOf(String(question.defaultValue)) + 1}]`, 90)}: `].join('\n'); } if (question.kind === 'multi-select') { - const selected = new Set(formatDefault(question.defaultValue).split(',').map(item => item.trim()).filter(Boolean)); - const choices = question.choices ?? []; - const lines = choices.map((choice, index) => { - const workflowId = choice === 'All' ? 'all' : choice.split(' — ')[0]; - const checked = selected.has('all') || selected.has(workflowId) ? '●' : '○'; - return ` ${checked} ${index === 0 ? 'All' : choice}`; - }); - return [question.label, ...lines, 'Use ↑/↓ and Space to toggle; Enter to confirm.'].join('\n'); + return [heading, 'Choose from the options below. In a selector use ↑/↓ and Space; in text mode enter IDs separated by commas. Enter confirms; ? shows help and B goes back.'].join('\n'); + } + if (question.kind === 'producer-select') { + const choices = question.producerCandidates ?? []; + const lines = choices.map((candidate, index) => + ` ${index + 1}) ${candidate.name} · App ${candidate.sourceAppId} · ${candidate.workflowName} · ${candidate.conclusion} · ${candidate.headSha.slice(0, 7)} · ${candidate.observedAt ?? 'date unavailable'}\n ${candidate.runUrl}\n ${candidate.requiredByRuleset ? `Required on ${candidate.requiredByRuleset.branch} by active ruleset: ${candidate.requiredByRuleset.sourceUrl}` : 'Required by branch rule: not checked'}`); + return [heading, ...lines, 'Enter check numbers separated by commas (for example 1,2), or exact name|App ID|workflow tuples separated by semicolons.', + 'A listed ruleset proves only the exact required check/App pair on that target branch. "Not checked" is not evidence that the check is optional; inspect branch protection too.', + 'A suggested check is not proof of coverage. Inspect its workflow and required step before attesting.', + ` ${color(`[${fallback}]`, 90)}: `].join('\n'); + } + if (question.kind === 'project-select') { + return [heading, + 'Choose Projects from the list below. In a selector use ↑/↓ and Space; in text mode enter their URL numbers separated by commas. B returns to the previous question.', + 'Project numbers come from GitHub URLs, not PVT_ node IDs. Use manual if a Project is missing, or retry to query GitHub again without restarting setup.', + 'All selected Projects must share each chosen Status value; this setup cannot map different values per Project.'].join('\n'); } if (question.kind === 'scope-overrides' && question.allowedNames?.length) { - return `${question.label}\n Available: ${question.allowedNames.join(', ')}; enter "none" to inherit all\n ${color(`[${fallback}]`, 90)}: `; + return `${heading}\n Available: ${question.allowedNames.join(', ')}; enter "none" to inherit all\n ${color(`[${fallback}]`, 90)}: `; } - return `${question.label} ${color(`[${fallback}]`, 90)}: `; + return `${heading}${reviewedStatuses ? `\n Verify these exact Status values in every selected Project:\n${reviewedStatuses}` : ''}${question.statusOptionState === 'unavailable' + ? '\n Status options could not be verified for every Project. Check the exact existing value in every selected Project before continuing.' : ''}${question.statusOptionState === 'incompatible' + ? '\n Selected Projects have no common Status values. Return to Project selection and choose compatible Projects.' : ''}\n ${color(`[${fallback}]`, 90)}: `; + } + + renderHelp(question: SetupQuestion): string { + const help = setupQuestionPresentation(question).en; + return [ + `What: ${help.summary}`, + `When: ${help.when}`, + `Where: ${help.where}`, + `How: ${help.how}`, + `Why: ${help.why}`, + `Example: ${help.example}`, + `Effect: ${help.effect}`, + `Verify: ${help.verify}`, + `Read more — ${help.documentation.title}: ${help.documentation.url}`, + ].join('\n'); + } + + showHelp(question: SetupQuestion): void { + console.log(renderBox(this.renderHelp(question), 'About this setup choice')); } showValidation(message: string): void { @@ -52,3 +122,29 @@ function formatDefault(value: string | number | boolean): string { if (typeof value === 'boolean') return value ? 'Y' : 'N'; return String(value) || 'none'; } + +function discoveryNote(question: SetupQuestion): string { + const status = question.discoveryStatus; + if (!status) return ''; + const check: Record = { + observed: 'Recent CI jobs were found. Inspect the linked runs before trusting a producer.', + 'no-recent-runs': 'No recent PR CI runs were found. Run normal CI or enter an exact producer manually.', + 'no-verifiable-checks': 'Recent runs exist, but exact job/App identity could not be verified. Use manual entry after inspecting GitHub.', + 'permission-denied': 'GitHub denied CI discovery. Give the setup PAT Actions: read and Checks: read, or enter a verified producer manually.', + unavailable: 'CI discovery failed; this does not mean there are no checks. Retry or use verified manual entry.', + }; + const project: Record = { + observed: 'Existing organization Projects are listed below. Inspect each GitHub URL before selecting it.', + empty: 'The bounded GitHub query returned no open, accessible Projects; this does not prove none exist. Check organization access or enter a verified number manually.', + 'permission-denied': 'GitHub denied Project discovery. Check organization Projects: read on the setup PAT, or enter numbers manually.', + unavailable: 'Project discovery failed; this does not mean no Projects exist. Use a verified number or retry.', + unsupported: 'Fine-grained PATs cannot list personal Projects through this GitHub API. Use the number in an existing Project URL.', + }; + const note = question.id === 'projects.ids' ? project[status] : check[status]; + const sample = status === 'observed' || status === 'empty' || status === 'no-recent-runs' || status === 'no-verifiable-checks' + ? question.id === 'projects.ids' + ? '\n Search scope: at most 30 open, accessible organization Projects from two pages; up to 100 fields per Project. Closed Projects are excluded.' + : '\n Search scope: up to 20 recent PR workflow runs; at most 15 runs and 100 checks per commit are inspected.' + : ''; + return note ? `\n ${note}${sample}${question.discoveryTruncated ? '\n Only a bounded sample was inspected; use manual entry for missing items.' : ''}${question.discoveryRetryRemaining ? `\n Type r to retry GitHub discovery (${question.discoveryRetryRemaining} read-only attempts left).` : ''}` : ''; +} diff --git a/src/cli/setup_result_receipt.ts b/src/cli/setup_result_receipt.ts new file mode 100644 index 000000000..a92c3689b --- /dev/null +++ b/src/cli/setup_result_receipt.ts @@ -0,0 +1,55 @@ +import { getResultPayload, type Result } from '../data/model/result'; +import type { ApplicationErrorCode } from '../application/errors/application_error'; +import type { WebSetupView } from '../application/contracts/web_setup_view'; + +export function setupResultReason(code?: ApplicationErrorCode): NonNullable['reasonCode'] { + if (!code) return 'unknown'; + if (code.startsWith('authorization.')) return 'permissions'; + if (code.startsWith('configuration.') || code === 'validation.invalid-input') return 'configuration'; + if (code === 'provider.rate-limited') return 'rate-limit'; + if (code.startsWith('provider.') || code === 'timeout') return 'provider'; + return 'unknown'; +} + +/** Provider errors are never serialized; failed steps remain potentially applied. */ +export function setupResultEffects(results: readonly Result[]): NonNullable['effects']> { + for (const result of results) { + const receipt = getResultPayload(getResultPayload(result.payload)?.setupReceipt); + if (receipt?.version !== 1 || !Array.isArray(receipt.effects)) continue; + const parsed = receipt.effects.map(parseEffect); + if (parsed.length === EFFECT_IDS.length && parsed.every(Boolean) + && EFFECT_IDS.every(id => parsed.some(effect => effect?.id === id))) return parsed as NonNullable['effects']>; + } + return results.map((result, index) => ({ + id: safeEffectId(result.id, index), + state: !result.success || result.errors.length > 0 ? 'needs-inspection' + : result.executed ? 'completed' : 'skipped', + })); +} + +const EFFECT_IDS = ['files', 'secrets', 'labels', 'issue-types', 'variables', 'initial-tag'] as const; +const EFFECT_STATES = ['completed', 'skipped', 'needs-inspection', 'not-started'] as const; +const EFFECT_SCOPES = ['local', 'repository', 'organization', 'mixed'] as const; + +function parseEffect(value: unknown): { id: typeof EFFECT_IDS[number]; state: typeof EFFECT_STATES[number]; scope: typeof EFFECT_SCOPES[number] } | undefined { + const effect = getResultPayload(value); + if (!effect || !EFFECT_IDS.includes(effect.id as typeof EFFECT_IDS[number]) + || !EFFECT_STATES.includes(effect.state as typeof EFFECT_STATES[number]) + || !EFFECT_SCOPES.includes(effect.scope as typeof EFFECT_SCOPES[number])) return undefined; + return { id: effect.id as typeof EFFECT_IDS[number], state: effect.state as typeof EFFECT_STATES[number], scope: effect.scope as typeof EFFECT_SCOPES[number] }; +} + +export function setupActionResultFailure(results: readonly Result[]): { + reasonCode: NonNullable['reasonCode']; diagnosticRef?: string; +} | undefined { + const first = results.flatMap(result => result.errors)[0]; + if (!first) return results.some(result => !result.success) ? { reasonCode: 'unknown' } : undefined; + if (typeof first !== 'object') return { reasonCode: 'unknown' }; + return { reasonCode: setupResultReason(first.code), + ...(first.correlationId ? { diagnosticRef: first.correlationId } : {}) }; +} + +function safeEffectId(value: string, index: number): string { + return EFFECT_IDS.includes(value as typeof EFFECT_IDS[number]) ? value + : value === 'InitialSetupUseCase' ? 'setup-workflow' : `step-${index + 1}`; +} diff --git a/src/cli/setup_session_guard.ts b/src/cli/setup_session_guard.ts new file mode 100644 index 000000000..ebeb30971 --- /dev/null +++ b/src/cli/setup_session_guard.ts @@ -0,0 +1,67 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { closeSync, linkSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface GuardRecord { pid: number; nonce: string; repository: string } + +/** One cooperative setup process per canonical checkout; no credential is stored in the lock. */ +export function acquireSetupSessionGuard(cwd: string): () => void { + const top = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + const repository = realpathSync(top); + const hash = createHash('sha256').update(repository).digest('hex').slice(0, 32); + const lockPath = join(tmpdir(), `copilot-setup-${hash}.lock`); + const record: GuardRecord = { pid: process.pid, nonce: randomBytes(16).toString('hex'), repository }; + const stagedPath = `${lockPath}.${record.nonce}.tmp`; + writeStagedLock(stagedPath, record); + let published = false; + let collision: unknown; + try { + linkSync(stagedPath, lockPath); // Atomic publication of a fully written record. + published = true; + } catch (cause) { + collision = cause; + } finally { + try { unlinkSync(stagedPath); } catch { /* A staging-file cleanup failure does not invalidate the published lock. */ } + } + if (!published) { + if (!collision || typeof collision !== 'object' || !('code' in collision) || collision.code !== 'EEXIST') throw collision; + let existing: GuardRecord; + try { existing = JSON.parse(readFileSync(lockPath, 'utf8')) as GuardRecord; } + catch { throw new Error('A setup lock exists but cannot be verified. Inspect it before retrying.'); } + if (!Number.isSafeInteger(existing.pid) || existing.pid <= 0 || existing.repository !== repository || !existing.nonce) { + throw setupLockError('A setup lock has unexpected contents. Inspect it before retrying.', collision); + } + try { + process.kill(existing.pid, 0); + throw setupLockError(`Another setup process (${existing.pid}) is active for this checkout. Finish or stop it before starting a second setup.`, collision); + } catch (checkError) { + if (!checkError || typeof checkError !== 'object' || !('code' in checkError) || checkError.code !== 'ESRCH') throw checkError; + } + // Filesystem reads and unlink are not atomic. Never remove a dead owner's lock here. + throw setupLockError(`A setup lock for a stopped process (${existing.pid}) remains at ${lockPath}. Verify no setup is running, remove only that file manually, then retry.`, collision); + } + return () => { + try { + const current = JSON.parse(readFileSync(lockPath, 'utf8')) as GuardRecord; + if (current.pid === record.pid && current.nonce === record.nonce && current.repository === record.repository) unlinkSync(lockPath); + } catch { /* Missing or replaced lock is not ours to remove. */ } + }; +} + +function writeStagedLock(path: string, record: GuardRecord): void { + const fd = openSync(path, 'wx', 0o600); + try { + writeFileSync(fd, JSON.stringify(record)); + closeSync(fd); + } catch (cause) { + try { closeSync(fd); } catch { /* Already closed or unavailable. */ } + try { unlinkSync(path); } catch { /* Preserve the write failure. */ } + throw cause; + } +} + +function setupLockError(message: string, cause: unknown): Error { + return Object.assign(new Error(message), { cause }); +} diff --git a/src/cli/setup_terminal_driver.ts b/src/cli/setup_terminal_driver.ts index 6a1168f33..5322f646c 100644 --- a/src/cli/setup_terminal_driver.ts +++ b/src/cli/setup_terminal_driver.ts @@ -6,19 +6,23 @@ export function interactiveTerminalAvailable(): boolean { return Boolean(stdin.isTTY && stdout.isTTY && !process.env.JEST_WORKER_ID); } +// Keep this boundary safe even when choices are not constructed by the setup controller. +function safeTerminalChoiceText(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ''); +} + export function createInteractiveTerminalDriver(): TerminalDriver | undefined { return interactiveTerminalAvailable() ? new NodeTerminalDriver() : undefined; } export class NodeTerminalDriver implements TerminalDriver { - private readonly readline: Interface; + private readline?: Interface; private closed = false; constructor() { if (!interactiveTerminalAvailable()) { throw new Error('An interactive terminal is required.'); } - this.readline = createInterface({ input: stdin, output: stdout }); } isInteractive(): boolean { @@ -27,6 +31,8 @@ export class NodeTerminalDriver implements TerminalDriver { async readText(prompt: string): Promise { if (this.closed) return { kind: 'end-of-input' }; + const readline = createInterface({ input: stdin, output: stdout }); + this.readline = readline; const abort = new AbortController(); let interrupted = false; let ended = false; @@ -38,17 +44,19 @@ export class NodeTerminalDriver implements TerminalDriver { ended = true; abort.abort(); }; - this.readline.once('SIGINT', onInterrupt); - this.readline.once('close', onClose); + readline.once('SIGINT', onInterrupt); + readline.once('close', onClose); try { - return { kind: 'value', value: await this.readline.question(prompt, { signal: abort.signal }) }; + return { kind: 'value', value: await readline.question(prompt, { signal: abort.signal }) }; } catch (error) { if (interrupted) return { kind: 'cancel' }; if (ended || this.closed || isAbortError(error)) return { kind: 'end-of-input' }; throw error; } finally { - this.readline.off('SIGINT', onInterrupt); - this.readline.off('close', onClose); + readline.off('SIGINT', onInterrupt); + readline.off('close', onClose); + readline.close(); + if (this.readline === readline) this.readline = undefined; } } @@ -91,11 +99,16 @@ export class NodeTerminalDriver implements TerminalDriver { prompt: string, choices: readonly string[], selected: readonly string[], + helpText?: string, ): Promise { if (this.closed) return { kind: 'end-of-input' }; const input = stdin as typeof stdin & { setRawMode?: (mode: boolean) => void }; if (!input.setRawMode) { - return this.readText(`${prompt}\nEnter comma-separated IDs (or "all"): `); + return this.readText([prompt, 'Available IDs:', + ...choices.map(choice => ` ${safeTerminalChoiceText(choice)}`), + `Current selection: ${safeTerminalChoiceText(selected.join(', ') || 'none')}`, + 'Enter IDs shown before “—”, separated by commas; use manual or retry when offered, none to clear, or Enter to keep the default: ', + ].join('\n')); } stdout.write(`${prompt}\n`); input.setRawMode(true); @@ -109,7 +122,7 @@ export class NodeTerminalDriver implements TerminalDriver { const lines = choices.map((choice, choiceIndex) => { const id = choice === 'All' ? 'all' : choice.split(' — ')[0]; const checked = id === 'all' ? value.size === choices.length - 1 : value.has(id); - return `${choiceIndex === index ? '❯' : ' '} ${checked ? '●' : '○'} ${choice}`; + return `${choiceIndex === index ? '❯' : ' '} ${checked ? '●' : '○'} ${safeTerminalChoiceText(choice)}`; }); stdout.write(`${rendered ? `\x1b[${choices.length}A\x1b[0J` : ''}${lines.join('\n')}\n`); rendered = true; @@ -133,6 +146,13 @@ export class NodeTerminalDriver implements TerminalDriver { if (data.startsWith('\u001b[B', offset)) { index = Math.min(choices.length - 1, index + 1); offset += 2; render(); continue; } if (character === '\u0003') { finish({ kind: 'cancel' }); return; } if (character === '\u0004') { finish({ kind: 'end-of-input' }); return; } + if (character === 'b' || character === 'B') { finish({ kind: 'value', value: ':back' }); return; } + if (character === '?' && helpText) { + stdout.write(`\n${helpText}\n\n`); + rendered = false; + render(); + continue; + } if (character === ' ') { const id = choices[index] === 'All' ? 'all' : choices[index].split(' — ')[0]; if (id === 'all') value = value.size === choices.length - 1 ? new Set() : new Set(choices.slice(1).map(choice => choice.split(' — ')[0])); @@ -140,7 +160,7 @@ export class NodeTerminalDriver implements TerminalDriver { else value.add(id); render(); } else if (character === '\r' || character === '\n') { - finish({ kind: 'value', value: [...value].join(',') }); + finish({ kind: 'value', value: value.size === 0 ? 'none' : [...value].join(',') }); return; } } @@ -154,7 +174,7 @@ export class NodeTerminalDriver implements TerminalDriver { close(): void { if (this.closed) return; this.closed = true; - this.readline.close(); + this.readline?.close(); } } diff --git a/src/cli/setup_token_permission_presenter.ts b/src/cli/setup_token_permission_presenter.ts index d8792d945..831dd3169 100644 --- a/src/cli/setup_token_permission_presenter.ts +++ b/src/cli/setup_token_permission_presenter.ts @@ -7,9 +7,18 @@ import type { SetupTokenRole, } from '../domain/setup_token_permissions'; import { renderBox } from './setup_prompt_rendering'; +import { summarizeSetupPermissions } from '../application/policies/setup_permission_summary_policy'; export class ConsoleSetupTokenPermissionPresenter implements SetupTokenPermissionPresenterPort { + constructor(private readonly mode: 'full' | 'summary' = 'full') {} + showRequirements(role: SetupTokenRole, requirements: readonly SetupTokenPermissionRequirement[]): void { + console.log(this.mode === 'summary' + ? renderSetupTokenPermissionSummary(role, requirements) + : renderSetupTokenPermissionRequirements(role, requirements)); + } + + showDetailedRequirements(role: SetupTokenRole, requirements: readonly SetupTokenPermissionRequirement[]): void { console.log(renderSetupTokenPermissionRequirements(role, requirements)); } @@ -18,6 +27,18 @@ export class ConsoleSetupTokenPermissionPresenter implements SetupTokenPermissio } } +export function renderSetupTokenPermissionSummary( + role: SetupTokenRole, + requirements: readonly SetupTokenPermissionRequirement[], + maximumWidth = stdout.columns ?? 120, +): string { + const summary = summarizeSetupPermissions(requirements); + return renderBox([ + `Required now: ${summary.required.join(' · ') || 'none'}`, + `Conditional permissions: ${summary.conditionalCount}. View the full table for reasons and triggers.`, + ].join('\n'), `${roleTitle(role)} PAT permission summary`, 36, maximumWidth); +} + export function renderSetupTokenPermissionRequirements( role: SetupTokenRole, requirements: readonly SetupTokenPermissionRequirement[], diff --git a/src/cli/web_setup_adapters.ts b/src/cli/web_setup_adapters.ts new file mode 100644 index 000000000..905e61eb2 --- /dev/null +++ b/src/cli/web_setup_adapters.ts @@ -0,0 +1,243 @@ +import type { SetupConfigurationCollectorPort, SetupDiscoveryRefreshPort, SetupPlanConfirmationPort, SetupPlanPresenterPort } from '../application/ports/setup_terminal_ports'; +import type { SetupCredentialPromptPort, SetupWorkflowUpdatePromptPort } from '../application/ports/setup_wizard_ports'; +import type { SetupTokenPermissionPresenterPort } from '../application/ports/setup_token_permission_ports'; +import type { SetupJourneyPresenterPort } from '../application/usecases/setup/setup_journey_use_case'; +import type { SetupGithubIdentity } from '../application/ports/setup_pat_identity_ports'; +import type { SetupCredentialCheck, SetupCredentialDecision, SetupCredentialRequirement, SetupCredentialValue, SetupPlan, SetupWorkflowComparison } from '../domain/setup'; +import type { SetupTokenPermissionReport, SetupTokenPermissionRequirement, SetupTokenRole } from '../domain/setup_token_permissions'; +import type { SetupQuestion, SetupQuestionnaireContext, SetupQuestionnaireState } from '../domain/setup_questionnaire'; +import { refreshSetupQuestionnaireQuestion, setupEditableGroups, setupQuestionnaireProgress, setupQuestionnaireStateLabel, transitionSetupQuestionnaire } from '../application/policies/setup_questionnaire_policy'; +import { setupQuestionPresentation } from '../application/policies/setup_question_guidance_policy'; +import { SetupInteractionCancelledError } from '../application/errors/setup_interaction_cancelled_error'; +import { WebSetupBridge, toWebSetupPlan } from './web_setup_bridge'; +import type { WebSetupPromptCopyId } from '../application/contracts/web_setup_view'; +import type { WebSetupMessageCopyId } from '../application/contracts/web_setup_view'; + +export class WebSetupQuestionnaireCollector implements SetupConfigurationCollectorPort { + constructor(private readonly bridge: WebSetupBridge, private readonly pass = 1) {} + + async collect(initial: SetupQuestionnaireState, context: SetupQuestionnaireContext, discoveryRefresh?: SetupDiscoveryRefreshPort): Promise { + let state = initial; + let currentContext = context; + while (state.terminal === 'collecting' && state.question) { + if (state.validation) { + const copy = validationCopy(state.validation) ?? { id: 'validation.unknown' as const }; + this.bridge.message(state.validation, 'warning', undefined, copy.id, copy.values); + } + const value = await this.bridge.ask({ + kind: 'question', title: setupQuestionnaireStateLabel(state.stateId), + question: state.question, presentation: setupQuestionPresentation(state.question), phase: state.phase ?? 'full', pass: this.pass, + progress: setupQuestionnaireProgress(state, currentContext), canGoBack: (setupQuestionnaireProgress(state, currentContext)?.position ?? 1) > 1, + }, discoveryRefresh && state.question.discoveryRetryRemaining ? async () => { + const kind = state.question?.id === 'projects.ids' ? 'projects' + : state.question?.id === 'pullRequestApproval.testChecks' ? 'checks' : undefined; + if (!kind) return undefined; + const refreshed = await discoveryRefresh.refresh(kind); + if (!refreshed) return undefined; + const refreshedState = refreshSetupQuestionnaireQuestion(state, refreshed); + return refreshedState.question ? { prompt: { kind: 'question' as const, + title: setupQuestionnaireStateLabel(refreshedState.stateId), + question: refreshedState.question, presentation: setupQuestionPresentation(refreshedState.question), + phase: refreshedState.phase ?? 'full' as const, pass: this.pass, + progress: setupQuestionnaireProgress(refreshedState, refreshed), + canGoBack: (setupQuestionnaireProgress(refreshedState, refreshed)?.position ?? 1) > 1 }, + commit: () => { currentContext = refreshed; state = refreshedState; } } : undefined; + } : undefined, () => { + const previous = transitionSetupQuestionnaire(state, { kind: 'back' }, currentContext); + if (previous.question?.id === state.question?.id || !previous.question) return undefined; + return { prompt: { kind: 'question' as const, title: setupQuestionnaireStateLabel(previous.stateId), + question: previous.question, presentation: setupQuestionPresentation(previous.question), + phase: previous.phase ?? 'full', pass: this.pass, progress: setupQuestionnaireProgress(previous, currentContext), + canGoBack: (setupQuestionnaireProgress(previous, currentContext)?.position ?? 1) > 1 }, + commit: () => { state = previous; } }; + }); + state = transitionSetupQuestionnaire(state, value === undefined ? { kind: 'cancel' } : { kind: 'answer', value }, currentContext); + } + return state; + } +} + +export class WebSetupPlanPresenter implements SetupPlanPresenterPort { + constructor(private readonly bridge: WebSetupBridge) {} + present(plan: SetupPlan): void { + this.bridge.message(`Plan ready: ${plan.selectedFiles.length} files, ${plan.variables.length} Variables and ${plan.requiredSecrets.length} Secret names. Review it before continuing.`, 'info', undefined, 'plan.ready', { files: String(plan.selectedFiles.length), variables: String(plan.variables.length), secrets: String(plan.requiredSecrets.length) }); + } +} + +export class WebSetupPlanConfirmation implements SetupPlanConfirmationPort { + constructor(private readonly bridge: WebSetupBridge) {} + async confirm(plan: SetupPlan): Promise<{ kind: 'approved' | 'declined' | 'cancelled' } | { kind: 'revise'; group: SetupQuestion['stateId'] }> { + const groups = setupEditableGroups(plan.configuration); + const response = await this.bridge.ask({ kind: 'plan', title: 'Review your setup plan', copyId: 'plan.review', plan: toWebSetupPlan(plan), editGroups: groups }); + if (response?.startsWith('revise:')) { + const group = response.slice('revise:'.length) as SetupQuestion['stateId']; + if (groups.includes(group)) return { kind: 'revise', group }; + throw new Error('Invalid setup section.'); + } + return { kind: response === undefined ? 'cancelled' : response === 'approve' ? 'approved' : 'declined' }; + } +} + +export class WebSetupWorkflowUpdatePrompt implements SetupWorkflowUpdatePromptPort { + constructor(private readonly bridge: WebSetupBridge) {} + async confirmWorkflowUpdates(comparisons: readonly SetupWorkflowComparison[], forcedByFlag: boolean): Promise { + const changed = comparisons.filter(item => item.status === 'changed' || item.status === 'unmanaged'); + if (!changed.length) return false; + if (forcedByFlag) return true; + const answer = await this.bridge.ask({ + kind: 'confirm', title: 'Update existing workflows?', + description: changed.map(item => `${item.destination} (${item.status})`).join('\n'), + choices: ['Keep existing', 'Update setup-managed workflows'], + copyId: 'workflow.update', copyValues: { files: changed.map(item => item.destination).join(', ') }, + }); + if (answer === undefined) throw new SetupInteractionCancelledError(); + return answer === 'Update setup-managed workflows'; + } +} + +export class WebSetupPermissionPresenter implements SetupTokenPermissionPresenterPort { + constructor(private readonly bridge: WebSetupBridge) {} + showRequirements(role: SetupTokenRole, requirements: readonly SetupTokenPermissionRequirement[]): void { this.bridge.requirements(role, requirements); } + showDetailedRequirements(role: SetupTokenRole, requirements: readonly SetupTokenPermissionRequirement[]): void { this.bridge.requirements(role, requirements); } + showReport(report: SetupTokenPermissionReport): void { this.bridge.report(report); } +} + +export class WebSetupJourneyPresenter implements SetupJourneyPresenterPort { + constructor(private readonly bridge: WebSetupBridge) {} + present(view: Parameters[0]): void { this.bridge.setJourney(view); } +} + +export class WebSetupCredentialPrompt implements SetupCredentialPromptPort { + private setupGuide?: string; + private workflowGuide?: string; + private guidedSetup = false; + private botIdentity?: SetupGithubIdentity; + private resolveBot?: (login: string) => Promise; + private workflowRequirements?: readonly SetupTokenPermissionRequirement[]; + + constructor(private readonly bridge: WebSetupBridge) {} + get usedGuidedSetupPat(): boolean { return this.guidedSetup; } + get guidedWorkflowBotIdentity(): SetupGithubIdentity | undefined { return this.botIdentity; } + configureSetupPatGuide(url: string): void { this.setupGuide = url; } + useManualSetupPat(): void { this.guidedSetup = false; this.setupGuide = undefined; } + async chooseSetupPatMethod(): Promise<'guided' | 'manual'> { + this.guidedSetup = await this.choice('How will you provide your setup PAT?', ['Guided GitHub link', 'Manual PAT'], undefined, 'setupPat.method') === 'Guided GitHub link'; + return this.guidedSetup ? 'guided' : 'manual'; + } + async chooseSetupOwnerKind(): Promise<'Organization' | 'User' | 'unknown'> { + const answer = await this.choice('What kind of GitHub account owns this repository?', ['Organization', 'Personal account', 'Not sure'], undefined, 'setupPat.ownerKind'); + return answer === 'Organization' ? 'Organization' : answer === 'Personal account' ? 'User' : 'unknown'; + } + async reviewSetupPatIntent(): Promise<'continue' | 'revise' | 'manual' | 'details'> { + const answer = await this.choice('Review these provisional setup PAT grants', ['Continue to GitHub', 'Review setup choices again', 'View full permission table', 'Enter a PAT manually'], undefined, 'setupPat.review'); + return answer === 'Review setup choices again' ? 'revise' : answer === 'View full permission table' ? 'details' + : answer === 'Enter a PAT manually' ? 'manual' : 'continue'; + } + async requestSetupPat(): Promise { + return this.secret('Temporary setup PAT', + 'Use the operator account in GitHub. Complete 2FA there, switch to Only select repositories, select this repository, and copy the generated token here. This token is for this run only; delete it in GitHub afterwards.', + this.guidedSetup ? this.setupGuide : undefined, false, 'setupPat.entry'); + } + async confirmGuidedSetupAccount(account?: string): Promise { + if (!this.guidedSetup) return true; + if (!account) return false; + return await this.choice(`GitHub authenticated the setup PAT as @${account}. Is that the intended operator account?`, ['Yes, continue', 'No, stop'], undefined, 'setupPat.confirmAccount', { account }) === 'Yes, continue'; + } + showUpdatedSetupPatLink(url: string, stage: 'bootstrap' | 'final', delta?: readonly string[]): void { + this.bridge.message(`Setup PAT ${stage === 'final' ? 'permissions changed' : 'access failed'}. No setup mutation started. ${delta?.join(', ') ?? ''} Create a corrected PAT using the updated GitHub link.`, 'warning', url, stage === 'final' ? 'setupPat.corrected.final' : 'setupPat.corrected.bootstrap', { grants: delta?.join(', ') ?? '' }); + } + showSetupPatCleanupReminder(): void { + if (this.guidedSetup) this.bridge.message('Delete the temporary setup PAT in GitHub Settings after this run. Closing Copilot does not revoke it.', 'warning', 'https://github.com/settings/personal-access-tokens', 'setupPat.cleanup'); + } + async confirmUnverifiableTokenPermissions(report: SetupTokenPermissionReport): Promise { + const writes = report.checks.filter(item => item.applicability === 'required' && item.level === 'write' && item.status === 'unverifiable'); + if (!report.confirmationRequired || writes.length === 0) return false; + return await this.choice('GitHub cannot safely prove these write grants without a mutation. Confirm they are configured exactly as shown.', ['No, stop', 'Yes, I checked them'], undefined, 'setupPat.confirmWrites') === 'Yes, I checked them'; + } + configureWorkflowPatGuide(url: string, resolveIdentity: (login: string) => Promise, requirements?: readonly SetupTokenPermissionRequirement[]): void { + this.workflowGuide = url; this.resolveBot = resolveIdentity; this.workflowRequirements = requirements; + } + explainCredentialSeparation(requirements: readonly SetupCredentialRequirement[]): void { + this.bridge.message(`The bot PAT is separate from your setup PAT. Runtime credentials (${requirements.map(item => item.name).join(', ')}) become GitHub Actions Secrets; existing Secret values cannot be read back. This browser flow will not dispatch or install a credential-health workflow before Apply. Re-enter an existing bot PAT so its grants can be audited.`, 'info', undefined, 'botPat.separation', { names: requirements.map(item => item.name).join(', ') }); + } + async requestWorkflowPat(requirement: SetupCredentialRequirement, current?: SetupCredentialCheck): Promise { + let guide: string | undefined; + let botInfo = ''; + if (this.workflowGuide) { + const method = await this.choice('How will you provide the bot PAT?', ['Guided GitHub link', 'Manual PAT'], undefined, 'botPat.method'); + if (method === 'Guided GitHub link') { + const login = await this.text('Expected GitHub bot login', 'Enter the bot account login, without @. We will verify its numeric account ID against the token.', 'botPat.login'); + if (!login || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) throw new Error('Enter a valid GitHub bot login.'); + this.botIdentity = await this.resolveBot!(login); + guide = this.workflowGuide; + botInfo = `Expected bot account: @${this.botIdentity.login} (GitHub ID ${this.botIdentity.id}). Open GitHub as this account, not the setup operator. `; + } else if (this.workflowRequirements) this.bridge.requirements('workflow', this.workflowRequirements); + } + const value = await this.secret(`${requirement.name} — bot account PAT`, + `${botInfo}Use the bot account, select only the intended repository and review all grants. Suggested expiry is 90 days. ${current ? `Existing Secret: ${current.status}; its value cannot be read back.` : ''}`, + guide, false, guide ? 'botPat.entry.guided' : 'botPat.entry.manual', { name: requirement.name, account: this.botIdentity?.login ?? '', accountId: String(this.botIdentity?.id ?? ''), existing: current?.status ?? '' }); + return value ? { name: requirement.name, value } : undefined; + } + async requestApiKey(requirement: SetupCredentialRequirement, current?: SetupCredentialCheck): Promise { + const value = await this.secret(`${requirement.name} — ${requirement.provider ?? 'provider'} API key`, current?.message, undefined, Boolean(requirement.alternativeGroups?.length), 'credential.apiKey', { name: requirement.name, provider: requirement.provider ?? 'provider' }); + return value ? { name: requirement.name, value } : undefined; + } + async chooseExistingCredential(requirement: SetupCredentialRequirement, check: SetupCredentialCheck): Promise { + const answer = await this.choice(`Existing ${requirement.name}: ${check.status}`, ['keep', 'replace', 'skip'], check.message, 'credential.existing', { name: requirement.name, status: check.status }); + return answer as SetupCredentialDecision; + } + showCredentialChecks(checks: readonly SetupCredentialCheck[]): void { + this.bridge.message(checks.map(item => `${item.name}: ${item.status} — ${item.message}`).join('\n'), checks.some(item => item.status === 'invalid') ? 'warning' : 'success', undefined, 'credential.checks', { names: checks.map(item => item.name).join(', '), count: String(checks.length) }, checks.map(item => ({ name: item.name, status: item.status }))); + } + private async choice(title: string, choices: readonly string[], description?: string, copyId?: WebSetupPromptCopyId, copyValues?: Readonly>): Promise { + const answer = await this.bridge.ask({ kind: 'choice', title, choices, description, copyId, copyValues }); + if (answer === undefined) throw new SetupInteractionCancelledError(); + if (!choices.includes(answer)) throw new Error('Invalid setup choice.'); + return answer; + } + private async text(title: string, description?: string, copyId?: WebSetupPromptCopyId): Promise { + const answer = await this.bridge.ask({ kind: 'text', title, description, copyId }); + if (answer === undefined) throw new SetupInteractionCancelledError(); + return answer.trim(); + } + private async secret(title: string, description?: string, link?: string, optional = false, copyId?: WebSetupPromptCopyId, copyValues?: Readonly>): Promise { + const answer = await this.bridge.ask({ kind: 'secret', title, description, optional, link, copyId, copyValues }); + if (answer === undefined) throw new SetupInteractionCancelledError(); + return answer.trim(); + } +} + +export function validationCopy(message: string): { id: WebSetupMessageCopyId; values?: Readonly> } | undefined { + const fixed: Readonly> = { + 'Select 1–8 observed checks or enter exact name|App ID|workflow tuples.': 'validation.producers', + 'Two trusted producers use the same check name. Coverage stores only one name; choose one producer or rename the CI jobs before continuing.': 'validation.duplicateNames', + 'Open every selected Project in GitHub and confirm that all four exact Status values exist. Answer Yes after checking, or No to choose Projects again.': 'validation.projectStatusVerified', + 'Status values were not confirmed. Choose compatible Projects, then review their Status options again.': 'validation.projectStatusRedo', + 'Enter a non-negative whole number.': 'validation.number', + 'Enter yes or no.': 'validation.boolean', + 'Select one of the listed options.': 'validation.choice', + 'This is the first question in this pass. Review it or cancel setup.': 'validation.firstQuestion', + 'A trusted check was selected more than once.': 'validation.duplicateProducer', + 'The saved Status value is not available in every selected Project. Choose a listed Status option.': 'validation.savedStatus', + 'Selected Projects have no common Status option. Choose compatible Projects or configure them separately.': 'validation.projectIncompatible', + 'Choose at most 10 Projects; separate numbers or URLs with commas.': 'validation.projectLimit', + 'A Project URL needs a known repository owner; enter its positive number instead.': 'validation.projectOwnerNeeded', + 'Enter a valid GitHub Project URL or positive Project number.': 'validation.projectUrl', + 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.': 'validation.projectNumber', + 'Project numbers must be positive integers at most 2147483647.': 'validation.projectNumberRange', + 'Issue automation is required by an explicit release or hotfix override. Keep Issues enabled or edit your configuration.': 'validation.fixedIssues', + }; + if (fixed[message]) return { id: fixed[message] }; + const fixedWorkflow = message.match(/^The (release|hotfix) workflow must (remain enabled|remain disabled) because it is fixed by your configuration\. Match that choice or edit your configuration\.$/u); + if (fixedWorkflow) return { id: fixedWorkflow[2] === 'remain enabled' ? 'validation.fixedWorkflowEnabled' : 'validation.fixedWorkflowDisabled', + values: { kind: fixedWorkflow[1] } }; + const inherited = message.match(/^Unknown inherited resource name\(s\): (.+)\.$/u); + if (inherited) return { id: 'validation.unknownResource', values: { names: inherited[1] } }; + const workflows = message.match(/^Unknown issue workflow\(s\): (.+)\.$/u); + if (workflows) return { id: 'validation.unknownWorkflow', values: { names: workflows[1] } }; + const owner = message.match(/^Use a GitHub Project URL belonging to ([^,]+), without query parameters\.$/u); + if (owner) return { id: 'validation.projectOwnerMismatch', values: { owner: owner[1] } }; + const duplicate = message.match(/^Project ([1-9]\d*) was selected more than once\.$/u); + if (duplicate) return { id: 'validation.projectDuplicate', values: { number: duplicate[1] } }; + return undefined; +} diff --git a/src/cli/web_setup_bridge.ts b/src/cli/web_setup_bridge.ts new file mode 100644 index 000000000..33aec1212 --- /dev/null +++ b/src/cli/web_setup_bridge.ts @@ -0,0 +1,236 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import type { SetupJourneyView } from '../application/policies/setup_journey_policy'; +import type { SetupPlan } from '../domain/setup'; +import type { SetupTokenPermissionReport, SetupTokenPermissionRequirement, SetupTokenRole } from '../domain/setup_token_permissions'; +import type { WebSetupPlan, WebSetupPrompt, WebSetupView } from '../application/contracts/web_setup_view'; +import type { WebSetupMessageCopyId } from '../application/contracts/web_setup_view'; + +/** A one-run, in-memory handoff. Values submitted by the browser are never part of a view. */ +export class WebSetupBridge { + private revision = 0; + private view: WebSetupView; + private pending?: { revision: number; resolve: (value: string | undefined) => void; + refresh?: () => Promise<{ prompt: WebSetupPrompt; commit: () => void } | undefined>; + navigateBack?: () => { prompt: WebSetupPrompt; commit: () => void } | undefined; refreshing?: boolean }; + private subscribers = new Set<(view: WebSetupView) => void>(); + private controller?: string; + private lastAnsweredRevision?: number; + private readOnlyDoctor?: () => Promise<{ healthy: boolean; pass: number; warn: number; fail: number; skipped: number }>; + private doctorAttempts = 0; + + constructor(repository: string) { + this.view = { revision: 0, repository }; + } + + snapshot(): WebSetupView { return this.view; } + setRepository(repository: string): void { this.publish({ repository }); } + + subscribe(listener: (view: WebSetupView) => void): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + bootstrap(): { controller: boolean; capability?: string } { + if (!this.controller) this.controller = randomBytes(32).toString('hex'); + // A second tab starts read-only. Its explicit takeover rotates the controller capability. + const first = !this.bootstrapped; + this.bootstrapped = true; + return { controller: first, ...(first ? { capability: this.controller } : {}) }; + } + + private bootstrapped = false; + + takeOver(): string { + this.controller = randomBytes(32).toString('hex'); + this.publish({ message: { tone: 'info', text: 'Control moved to this tab. The previous tab is now read-only.', copyId: 'session.controlMoved' } }); + return this.controller; + } + + isController(capability: string): boolean { + return Boolean(this.controller && sameCapability(capability, this.controller)); + } + + async ask(prompt: WebSetupPrompt, refresh?: () => Promise<{ prompt: WebSetupPrompt; commit: () => void } | undefined>, + navigateBack?: () => { prompt: WebSetupPrompt; commit: () => void } | undefined): Promise { + if (this.pending || this.view.outcome) throw new Error('A setup decision is already pending or the session has ended.'); + const revision = this.revision + 1; + this.publish({ prompt, promptRevision: revision }); + return new Promise(resolve => { this.pending = { revision, resolve, refresh, navigateBack }; }); + } + + back(revision: number): 'updated' | 'stale' | 'unavailable' { + const pending = this.pending; + if (!pending || pending.revision !== revision || this.view.outcome) return 'stale'; + if (!pending.navigateBack || pending.refreshing) return 'unavailable'; + const result = pending.navigateBack(); + if (!result) return 'unavailable'; + result.commit(); + pending.revision = this.revision + 1; + this.publish({ prompt: result.prompt, promptRevision: pending.revision, message: undefined }); + return 'updated'; + } + + async retryDiscovery(revision: number): Promise<'updated' | 'stale' | 'unavailable'> { + const pending = this.pending; + if (!pending || pending.revision !== revision || this.view.outcome) return 'stale'; + if (!pending.refresh || pending.refreshing) return 'unavailable'; + const controller = this.controller; + pending.refreshing = true; + try { + const result = await pending.refresh(); + if (this.pending !== pending || this.view.outcome || controller !== this.controller) return 'stale'; + if (!result) return 'unavailable'; + result.commit(); + // Keep promptRevision stable so the browser retains unsent manual and checkbox input. + this.publish({ prompt: result.prompt }); + return 'updated'; + } finally { + pending.refreshing = false; + } + } + + answer(revision: number, value: string): boolean { + if (!this.pending || this.pending.revision !== revision || this.pending.refreshing || this.view.outcome) return false; + const prompt = this.view.prompt; + if (prompt && (prompt.kind === 'choice' || prompt.kind === 'confirm') && !prompt.choices.includes(value)) return false; + if (prompt?.kind === 'plan' && value !== 'approve' && value !== 'decline' + && !prompt.editGroups?.some(group => value === `revise:${group}`)) return false; + const pending = this.pending; + this.pending = undefined; + this.lastAnsweredRevision = revision; + this.publish({ prompt: undefined, promptRevision: undefined }); + pending.resolve(value); + return true; + } + + wasAnswered(revision: number): boolean { return this.lastAnsweredRevision === revision; } + + configureReadOnlyDoctor(run: () => Promise<{ healthy: boolean; pass: number; warn: number; fail: number; skipped: number }>): void { + this.readOnlyDoctor = run; + } + + async runReadOnlyDoctor(): Promise<'complete' | 'failed' | 'unavailable' | 'busy'> { + if (this.view.outcome !== 'complete' || !this.readOnlyDoctor) return 'unavailable'; + if (this.view.doctor?.status === 'running') return 'busy'; + if (this.view.doctor?.status === 'complete') return 'complete'; + if (this.doctorAttempts >= 2) return 'unavailable'; + this.doctorAttempts += 1; + this.publish({ doctor: { status: 'running' } }); + try { + const summary = await this.readOnlyDoctor(); + const counts = [summary.pass, summary.warn, summary.fail, summary.skipped]; + if (counts.some(value => !Number.isSafeInteger(value) || value < 0)) throw new Error('Invalid doctor summary.'); + this.publish({ doctor: { status: 'complete', healthy: summary.healthy === true, + pass: summary.pass, warn: summary.warn, fail: summary.fail, skipped: summary.skipped } }); + return 'complete'; + } catch { + this.publish({ doctor: { status: 'failed' } }); + return 'failed'; + } + } + + cancel(): boolean { + if (this.view.journey?.mutationStarted || this.view.outcome) return false; + const pending = this.pending; + this.pending = undefined; + this.publish({ prompt: undefined, promptRevision: undefined, outcome: 'cancelled', resultDetail: { + reasonCode: 'cancelled', stoppedStage: this.view.journey?.current ?? 'Preparation', mutationStarted: false, + }, message: { tone: 'warning', text: 'Setup cancelled before applying further changes. Any PAT created at GitHub still exists until you delete it there.', copyId: 'session.cancelled' } }); + pending?.resolve(undefined); + return true; + } + + setJourney(journey: SetupJourneyView): void { this.publish({ journey }); } + message(text: string, tone: 'info' | 'success' | 'warning' | 'error' = 'info', link?: string, copyId?: WebSetupMessageCopyId, copyValues?: Readonly>, credentialChecks?: NonNullable['credentialChecks']): void { + this.publish({ message: { tone, text, ...(link ? { link } : {}), copyId, copyValues, credentialChecks } }); + } + requirements(role: SetupTokenRole, requirements: readonly SetupTokenPermissionRequirement[]): void { + this.publish({ permissions: { role, requirements, report: undefined } }); + } + report(report: SetupTokenPermissionReport): void { + this.publish({ permissions: { role: report.role, requirements: this.view.permissions?.requirements, report } }); + } + resultReason(reasonCode: NonNullable['reasonCode'], diagnosticRef?: string): void { + if (this.view.outcome) return; + this.publish({ resultDetail: { + reasonCode, + stoppedStage: this.view.journey?.current ?? 'Preparation', + mutationStarted: this.view.journey?.mutationStarted === true, + ...(this.view.resultDetail?.effects ? { effects: this.view.resultDetail.effects } : {}), + ...(diagnosticRef && /^[0-9a-f-]{36}$/u.test(diagnosticRef) ? { diagnosticRef } : {}), + } }); + } + effects(effects: NonNullable['effects']): void { + if (this.view.outcome) return; + this.publish({ resultDetail: { reasonCode: this.view.resultDetail?.reasonCode ?? 'unknown', + stoppedStage: this.view.journey?.current ?? 'Preparation', + mutationStarted: this.view.journey?.mutationStarted === true, effects, + ...(this.view.resultDetail?.diagnosticRef ? { diagnosticRef: this.view.resultDetail.diagnosticRef } : {}) } }); + } + finish(outcome: NonNullable, text: string): void { + if (this.view.outcome) return; + this.pending?.resolve(undefined); + this.pending = undefined; + this.publish({ prompt: undefined, promptRevision: undefined, outcome, message: { tone: outcome === 'complete' || outcome === 'dry-run' ? 'success' : 'warning', text }, + ...(this.view.resultDetail ? {} : { resultDetail: { + reasonCode: outcome === 'cancelled' ? 'cancelled' : outcome === 'blocked' ? 'unknown' : 'unknown', + stoppedStage: this.view.journey?.current ?? 'Preparation', + mutationStarted: this.view.journey?.mutationStarted === true, + } }), + }); + } + + private publish(change: Partial): void { + this.revision += 1; + this.view = { ...this.view, ...change, revision: this.revision }; + for (const listener of this.subscribers) { + try { listener(this.view); } + catch { this.subscribers.delete(listener); /* Observers cannot abort a setup decision. */ } + } + } +} + +function sameCapability(provided: string, expected: string): boolean { + if (!/^[a-f0-9]{64}$/.test(provided)) return false; + return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex')); +} + +export function toWebSetupPlan(plan: SetupPlan): WebSetupPlan { + return { + presentationDefaults: plan.presentationDefaults ?? [], + decisions: { + enabledCapabilities: Object.entries(plan.configuration.features).filter(([, enabled]) => enabled).map(([name]) => name), + agentRouting: Object.entries(plan.configuration.agents).map(([role, agent]) => ({ role, + provider: agent.provider, modelProvider: agent.modelProvider, model: agent.model })), + issueWorkflows: plan.configuration.features.issues ? plan.configuration.issueWorkflows.enabled : [], + productionBranch: plan.configuration.repository.mainBranch, + developmentBranch: plan.configuration.repository.developmentBranch, + approvalMode: plan.configuration.pullRequestApproval.mode, + trustedChecks: plan.configuration.pullRequestApproval.testChecks.map(check => ({ name: check.name, + sourceAppId: check.sourceAppId, workflowName: check.workflowName })), + producerAttested: plan.configuration.pullRequestApproval.producerAttested, + coverageMode: plan.configuration.pullRequestApproval.coverage.mode, + coverageCheck: plan.configuration.pullRequestApproval.coverage.checkName, + ...(plan.configuration.pullRequestApproval.coverage.mode === 'numeric' ? { + coverageMinimum: plan.configuration.pullRequestApproval.coverage.minDiffPercent, + coverageArtifactWorkflow: plan.configuration.pullRequestApproval.coverage.artifactWorkflowName, + coverageReporterAttested: plan.configuration.pullRequestApproval.coverage.reporterAttested, + } : {}), + projectNumbers: plan.configuration.projects.ids.split(',').filter(Boolean), + projectStatuses: [ + { transition: 'issueCreated', value: plan.configuration.projects.issueCreatedColumn }, + { transition: 'pullRequestCreated', value: plan.configuration.projects.pullRequestCreatedColumn }, + { transition: 'issueInProgress', value: plan.configuration.projects.issueInProgressColumn }, + { transition: 'pullRequestInProgress', value: plan.configuration.projects.pullRequestInProgressColumn }, + ], + variableScope: plan.configuration.manageRepositoryVariables ? plan.configuration.storage.variables.defaultScope : 'disabled', + secretScope: plan.configuration.manageRepositorySecrets ? plan.configuration.storage.secrets.defaultScope : 'disabled', + initialTag: plan.configuration.createInitialTag, + }, + files: plan.selectedFiles, + workflows: plan.workflowFiles, + variables: plan.variables.map(variable => variable.name), + secrets: plan.requiredSecrets, + warnings: plan.warnings, + }; +} diff --git a/src/cli/web_setup_server.ts b/src/cli/web_setup_server.ts new file mode 100644 index 000000000..7e50c28a4 --- /dev/null +++ b/src/cli/web_setup_server.ts @@ -0,0 +1,302 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { readFile, realpath } from 'node:fs/promises'; +import { join, resolve, sep } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { WebSetupBridge } from './web_setup_bridge'; + +const MAX_BODY_BYTES = 8192; +const MAX_ANSWER_LENGTH = 4096; +const PAIRING_COOLDOWN_MS = 30_000; +const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; + +export interface WebSetupServer { + readonly url: string; + readonly pairingCode: string; + readonly closed: Promise; + close(): Promise; +} + +/** Transport only: setup policy and credential decisions live behind the bridge. */ +export async function startWebSetupServer(bridge: WebSetupBridge, assets = join(__dirname, '..', 'web')): Promise { + const sessionKey = randomBytes(32); + const pairingCode = randomBytes(8); + let failedPairings = 0; + let pairingLockedUntil = 0; + const pairingCoolingDown = (): boolean => { + if (pairingLockedUntil && Date.now() >= pairingLockedUntil) { + pairingLockedUntil = 0; + failedPairings = 0; + } + return pairingLockedUntil > Date.now(); + }; + const recordInvalidPairing = (): void => { + failedPairings += 1; + if (failedPairings >= 5) pairingLockedUntil = Date.now() + PAIRING_COOLDOWN_MS; + }; + const assetRoot = await realpath(assets); + if (!(await realpath(join(assetRoot, 'index.html'))).startsWith(`${assetRoot}${sep}`)) { + throw new Error('Local setup index must be inside its packaged asset directory.'); + } + const indexHtml = (await readFile(join(assetRoot, 'index.html'))).toString('utf8'); + const allowedAssets = new Set([...indexHtml.matchAll(/(?:\.\/)?(assets\/[A-Za-z0-9._-]+\.(?:js|css))/g)] + .map(match => match[1])); + if (allowedAssets.size < 2) throw new Error('Local setup web assets are incomplete. Reinstall Copilot or use terminal setup.'); + for (const asset of allowedAssets) { + const packagedPath = await realpath(join(assetRoot, asset)); + if (!packagedPath.startsWith(`${assetRoot}${sep}`)) throw new Error('Local setup asset escapes its packaged directory.'); + await readFile(packagedPath); + } + let closeResolver: () => void = () => undefined; + const closed = new Promise(resolveClosed => { closeResolver = resolveClosed; }); + let closing = false; + let idleTimer: NodeJS.Timeout | undefined; + let resultTimer: NodeJS.Timeout | undefined; + const armIdle = (): void => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (!bridge.snapshot().journey?.mutationStarted && !bridge.snapshot().outcome) { + bridge.resultReason('session-expired'); + bridge.finish('blocked', 'This local setup session expired after 30 minutes without a decision. Start a new setup run; GitHub PATs are not revoked automatically.'); + } + }, 30 * 60 * 1000); + }; + const server = createServer(async (request, response) => { + const address = server.address(); + const origin = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + const host = `127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + response.setHeader('Content-Security-Policy', CSP); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Referrer-Policy', 'no-referrer'); + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + response.setHeader('X-Frame-Options', 'DENY'); + try { + if (request.headers.host !== host || request.headers['x-forwarded-host'] || request.headers.forwarded + || request.headers['x-forwarded-proto'] || request.headers['sec-fetch-site'] === 'cross-site') { + respond(response, 403, { error: 'Invalid local host or request context.' }); + return; + } + if (request.method === 'POST' && (request.headers.origin !== origin + || (request.headers.referer && !request.headers.referer.startsWith(`${origin}/`)))) { + respond(response, 403, { error: 'Invalid request origin.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/pair') { + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + if (pairingCoolingDown()) { respond(response, 429, { error: 'Too many pairing attempts. Wait 30 seconds and retry.' }); return; } + const body = await readJson(request); + if (!matchesHexSecret(body.code, pairingCode)) { + recordInvalidPairing(); + respond(response, 403, { error: 'Incorrect pairing code. Check the terminal.' }); + return; + } + failedPairings = 0; + respond(response, 200, { sessionKey: sessionKey.toString('hex') }); + return; + } + if (request.url?.startsWith('/api/') && !matchesHexSecret(request.headers['x-setup-session-key'], sessionKey)) { + respond(response, 403, { error: 'Pair this browser using the code printed by the CLI.' }); + return; + } + if (request.method === 'GET' && request.url === '/api/bootstrap') { + respond(response, 200, bridge.bootstrap()); + return; + } + if (request.method === 'GET' && request.url === '/api/state') { + respond(response, 200, bridge.snapshot()); + return; + } + if (request.method === 'POST' && request.url === '/api/takeover') { + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + const body = await readJson(request); + if (pairingCoolingDown()) { respond(response, 429, { error: 'Too many pairing attempts. Wait 30 seconds and retry.' }); return; } + if (!matchesHexSecret(body.code, pairingCode)) { + recordInvalidPairing(); + respond(response, 403, { error: 'Incorrect pairing code. Check the launching output.' }); + return; + } + failedPairings = 0; + const capability = bridge.takeOver(); + armIdle(); + respond(response, 200, { capability }); + return; + } + if (request.method === 'POST' && request.url === '/api/answer') { + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { respond(response, 403, { error: 'This tab is read-only.' }); return; } + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + const body = await readJson(request); + if (!Number.isSafeInteger(body.revision) || (body.revision as number) <= 0 || typeof body.value !== 'string' || body.value.length > MAX_ANSWER_LENGTH) { + respond(response, 400, { error: 'Invalid answer.' }); return; + } + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'Control moved to another tab.' }); return; + } + const accepted = bridge.answer(body.revision as number, body.value); + const duplicate = !accepted && bridge.wasAnswered(body.revision as number); + if (accepted) armIdle(); + respond(response, accepted || duplicate ? 200 : 409, + accepted || duplicate ? { accepted: true, ...(duplicate ? { duplicate: true } : {}) } + : { error: 'This question changed. Refresh the current state.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/retry-discovery') { + const capability = String(request.headers['x-setup-capability'] ?? ''); + if (!bridge.isController(capability)) { respond(response, 403, { error: 'This tab is read-only.' }); return; } + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + const body = await readJson(request); + if (!Number.isSafeInteger(body.revision) || (body.revision as number) <= 0) { + respond(response, 400, { error: 'Invalid question revision.' }); return; + } + if (!bridge.isController(capability)) { respond(response, 403, { error: 'Control moved to another tab.' }); return; } + const result = await bridge.retryDiscovery(body.revision as number); + if (!bridge.isController(capability)) { respond(response, 403, { error: 'Control moved to another tab.' }); return; } + if (result === 'updated') armIdle(); + respond(response, result === 'updated' ? 200 : 409, result === 'updated' + ? { updated: true } : { error: result === 'stale' ? 'This question changed. Refresh the current state.' + : 'Discovery cannot be retried here. Use the manual option.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/back') { + const capability = String(request.headers['x-setup-capability'] ?? ''); + if (!bridge.isController(capability)) { respond(response, 403, { error: 'This tab is read-only.' }); return; } + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + const body = await readJson(request); + if (!Number.isSafeInteger(body.revision) || (body.revision as number) <= 0) { + respond(response, 400, { error: 'Invalid question revision.' }); return; + } + if (!bridge.isController(capability)) { respond(response, 403, { error: 'Control moved to another tab.' }); return; } + const result = bridge.back(body.revision as number); + if (result === 'updated') armIdle(); + respond(response, result === 'updated' ? 200 : 409, result === 'updated' + ? { updated: true } : { error: result === 'stale' ? 'This question changed. Refresh the current state.' + : 'No earlier question is available here.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/doctor') { + const capability = String(request.headers['x-setup-capability'] ?? ''); + if (!bridge.isController(capability)) { respond(response, 403, { error: 'This tab is read-only.' }); return; } + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + await readJson(request); + if (!bridge.isController(capability)) { respond(response, 403, { error: 'Control moved to another tab.' }); return; } + const result = await bridge.runReadOnlyDoctor(); + if (!bridge.isController(capability)) { respond(response, 403, { error: 'Control moved to another tab.' }); return; } + respond(response, result === 'complete' ? 200 : 409, result === 'complete' + ? { checked: true } : { error: result === 'failed' ? 'Read-only verification failed. Check the terminal.' + : 'Read-only verification is unavailable or already running.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/cancel') { + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { respond(response, 403, { error: 'This tab is read-only.' }); return; } + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + await readJson(request); + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'Control moved to another tab.' }); return; + } + const cancelled = bridge.cancel(); + respond(response, cancelled ? 200 : 409, cancelled ? { cancelled: true } : { error: 'This setup has already started applying or ended.' }); + return; + } + if (request.method === 'POST' && request.url === '/api/close') { + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? '')) || !bridge.snapshot().outcome) { + respond(response, 403, { error: 'Only the controller can close a finished session.' }); return; + } + if (request.headers['content-type'] !== 'application/json') { respond(response, 415, { error: 'JSON required.' }); return; } + await readJson(request); + if (!bridge.isController(String(request.headers['x-setup-capability'] ?? ''))) { + respond(response, 403, { error: 'Control moved to another tab.' }); return; + } + respond(response, 200, { closed: true }); + setImmediate(() => void close()); + return; + } + if (request.method !== 'GET') { respond(response, 405, { error: 'Method not allowed.' }); return; } + const pathname = request.url ?? ''; + if (pathname !== '/' && !/^\/assets\/[A-Za-z0-9._-]+$/.test(pathname)) { + respond(response, 404, { error: 'Not found.' }); return; + } + const relative = pathname === '/' ? 'index.html' : pathname.slice(1); + if (relative !== 'index.html' && !allowedAssets.has(relative)) { + respond(response, 404, { error: 'Not found.' }); return; + } + const file = resolve(assetRoot, relative); + const realFile = await realpath(file); + if (!realFile.startsWith(`${assetRoot}${sep}`)) { respond(response, 404, { error: 'Not found.' }); return; } + const content = await readFile(realFile); + const contentType = file.endsWith('.js') ? 'text/javascript; charset=utf-8' + : file.endsWith('.css') ? 'text/css; charset=utf-8' + : 'text/html; charset=utf-8'; + response.writeHead(200, { 'Content-Type': contentType }); + response.end(content); + } catch { + if (!response.headersSent) respond(response, 400, { error: 'Invalid local request.' }); + else response.end(); + } + }); + server.requestTimeout = 15_000; + server.headersTimeout = 15_000; + server.maxRequestsPerSocket = 250; + server.maxConnections = 16; + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolveListen(); }); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Unable to bind local setup server.'); + const url = `http://127.0.0.1:${address.port}/`; + const close = async (): Promise => { + if (closing) return closed; + closing = true; + if (idleTimer) clearTimeout(idleTimer); + if (hardTimer) clearTimeout(hardTimer); + if (resultTimer) clearTimeout(resultTimer); + unsubscribe?.(); + bridge.cancel(); + server.closeAllConnections(); + await new Promise(resolveClose => server.close(() => resolveClose())); + closeResolver(); + }; + armIdle(); + const hardTimer = setTimeout(() => { + if (!bridge.snapshot().journey?.mutationStarted && !bridge.snapshot().outcome) { + bridge.resultReason('session-expired'); + bridge.finish('blocked', 'This local setup session reached its four-hour limit. Start a new run; no prior approval can be replayed.'); + } + }, 4 * 60 * 60 * 1000); + const unsubscribe = bridge.subscribe(view => { + if (view.outcome && !resultTimer) resultTimer = setTimeout(() => void close(), 10 * 60 * 1000); + }); + return { url, pairingCode: pairingCode.toString('hex'), closed, close }; +} + +function matchesHexSecret(value: unknown, expected: Buffer): boolean { + return typeof value === 'string' && value.length === expected.length * 2 && /^[a-f0-9]+$/.test(value) + && timingSafeEqual(Buffer.from(value, 'hex'), expected); +} + +function respond(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(body)); +} + +async function readJson(request: IncomingMessage): Promise> { + let size = 0; + const chunks: Buffer[] = []; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) throw new Error('Body too large.'); + chunks.push(buffer); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('JSON object required.'); + return parsed as Record; +} + +export function openWebSetupBrowser(url: string): void { + const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true }); + child.on('error', () => { /* The URL was already printed; manual opening remains available. */ }); + child.unref(); +} diff --git a/src/cli_context.ts b/src/cli_context.ts index 5661cba8a..b7ed62a3f 100644 --- a/src/cli_context.ts +++ b/src/cli_context.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { realpathSync } from 'node:fs'; import { ERRORS } from './cli/cli_errors'; import { canonicalGitObjectId } from './domain/git_object_id'; @@ -30,6 +30,28 @@ export function getCurrentBranch(): string { } } +/** A verified branch name for web setup; detached HEAD and failed git reads are not guessed. */ +export function getCurrentAttachedBranch(cwd: string): string | undefined { + try { + const branch = execSync('git symbolic-ref --quiet --short HEAD', { cwd }).toString().trim(); + return branch && branch !== 'HEAD' ? branch : undefined; + } catch { + return undefined; + } +} + +/** Positive local evidence only; a missing ref says nothing about remote branches. */ +export function hasLocalOrTrackedGitBranch(cwd: string, branch: string): boolean { + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/u.test(branch) || branch.includes('..') || branch.endsWith('.lock')) return false; + for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) { + try { + execFileSync('git', ['show-ref', '--verify', '--quiet', ref], { cwd, stdio: 'pipe' }); + return true; + } catch { /* Try the other explicit ref. */ } + } + return false; +} + /** Returns the canonical object ID for the workspace revision being analyzed. */ export function getCurrentHeadSha(): string | undefined { try { @@ -48,10 +70,15 @@ export function isInsideGitRepo(cwd: string): boolean { } } +/** Canonical checkout root for plans whose file paths are repository-relative. */ +export function getGitRepositoryRoot(cwd: string): string { + const root = execSync('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }).toString().trim(); + return realpathSync(root); +} + export function isGitRepositoryRoot(cwd: string): boolean { try { - const root = execSync('git rev-parse --show-toplevel', { cwd, stdio: 'pipe' }).toString().trim(); - return realpathSync(root) === realpathSync(cwd); + return getGitRepositoryRoot(cwd) === realpathSync(cwd); } catch { return false; } diff --git a/src/data/repository/repository_variables_repository.ts b/src/data/repository/repository_variables_repository.ts index d66ee73fe..e02a6b55a 100644 --- a/src/data/repository/repository_variables_repository.ts +++ b/src/data/repository/repository_variables_repository.ts @@ -16,6 +16,7 @@ import type { } from '../../infrastructure/github/ports/github_repository_variables_protocol'; import nacl from 'tweetnacl'; import { createHash } from 'node:crypto'; +import { isSafeBranchTree } from '../../domain/deployment_configuration'; class GithubActionsResourceTransport { constructor(private readonly githubClient: GithubClientPort) {} @@ -52,6 +53,8 @@ class GithubActionsResourceTransport { const credentialHealthWorkflow = await this.inspectDefaultCredentialHealthWorkflow(client, owner, repository); return { ownerType, + ...(typeof metadata.default_branch === 'string' && isSafeBranchTree(metadata.default_branch) + ? { defaultBranch: metadata.default_branch } : {}), repositoryId: metadata.id, repositoryVisibility, repositorySecrets: repositorySecretsResult.resources, diff --git a/src/domain/__tests__/pull_request_approval.test.ts b/src/domain/__tests__/pull_request_approval.test.ts index c60a47d18..e40000e2b 100644 --- a/src/domain/__tests__/pull_request_approval.test.ts +++ b/src/domain/__tests__/pull_request_approval.test.ts @@ -38,6 +38,13 @@ describe('guarded PR approval policy', () => { expect(validatePullRequestApprovalPolicy(policy)).toEqual([]); expect(parsePullRequestApprovalPolicy(JSON.stringify(policy))).toEqual(policy); }); + it('allows an ambiguous legacy producer list only for interactive repair, never for final policy use', () => { + const ambiguous = { ...policy, testChecks: [...policy.testChecks, + { name: 'CI Check', sourceAppId: 123, workflowName: 'Another CI' }] }; + expect(validatePullRequestApprovalPolicy(ambiguous, true)).toEqual([]); + expect(validatePullRequestApprovalPolicy(ambiguous)).toContain('Trusted check names must be unique because coverage stores only a check name.'); + expect(() => parsePullRequestApprovalPolicy(JSON.stringify(ambiguous))).toThrow('unique'); + }); it.each([ [{ ...policy, version: 2 }, 'version'], [{ ...policy, targetRoles: ['development', 'development'] }, 'targetRoles'], diff --git a/src/domain/pull_request_approval_policy.ts b/src/domain/pull_request_approval_policy.ts index 9fac5318b..40630818e 100644 --- a/src/domain/pull_request_approval_policy.ts +++ b/src/domain/pull_request_approval_policy.ts @@ -99,6 +99,7 @@ export function validatePullRequestApprovalPolicy(value: unknown, allowIncomplet errors.push('guarded/recommend mode requires 1–8 exact test checks.'); } else { const identities = new Set(); + const names = new Set(); for (const item of value.testChecks) { if (!isRecord(item)) { errors.push('Each test check must be an object.'); continue; } unknownKeys(item, PRODUCER_KEYS, 'test check', errors); @@ -108,6 +109,8 @@ export function validatePullRequestApprovalPolicy(value: unknown, allowIncomplet const identity = `${item.name}:${item.sourceAppId}:${item.workflowName}`; if (identities.has(identity)) errors.push('Test checks cannot contain duplicate producer identities.'); identities.add(identity); + if (value.mode !== 'off' && !allowIncomplete && names.has(String(item.name))) errors.push('Trusted check names must be unique because coverage stores only a check name.'); + names.add(String(item.name)); } } if (typeof value.producerAttested !== 'boolean') errors.push('producerAttested must be boolean.'); diff --git a/src/domain/setup.ts b/src/domain/setup.ts index 1f9f73671..a2df26621 100644 --- a/src/domain/setup.ts +++ b/src/domain/setup.ts @@ -197,6 +197,8 @@ export type SetupCredentialHealthWorkflowState = 'installed' | 'missing' | 'unav export interface SetupRemoteConfiguration { ownerType: SetupOwnerType; + /** Read from authenticated GitHub metadata, not inferred from local branch names. */ + defaultBranch?: string; repositoryId?: number; repositoryVisibility: SetupRepositoryVisibility; repositorySecrets: readonly string[]; @@ -241,6 +243,8 @@ export interface SetupVariable { } export interface SetupPlan { + /** Informational only: advanced defaults not asked in basic presentation. */ + presentationDefaults?: readonly { group: string; count: number }[]; configuration: SetupConfiguration; workflowFiles: string[]; issueTemplateFiles: string[]; @@ -252,3 +256,10 @@ export interface SetupPlan { approvalReadiness: DoctorCheck[]; warnings: string[]; } + +/** Structured, value-free receipt for the local setup workflow. */ +export interface SetupOperationEffect { + readonly id: 'files' | 'secrets' | 'labels' | 'issue-types' | 'variables' | 'initial-tag'; + readonly state: 'completed' | 'skipped' | 'needs-inspection' | 'not-started'; + readonly scope: 'local' | 'repository' | 'organization' | 'mixed'; +} diff --git a/src/domain/setup_questionnaire.ts b/src/domain/setup_questionnaire.ts index 297739b10..f82299667 100644 --- a/src/domain/setup_questionnaire.ts +++ b/src/domain/setup_questionnaire.ts @@ -1,4 +1,5 @@ import type { SetupConfiguration, SetupRemoteConfiguration } from './setup'; +import type { PullRequestApprovalProducer } from './pull_request_approval_policy'; export const SETUP_QUESTIONNAIRE_STATE_ORDER = [ 'capabilities', @@ -19,7 +20,36 @@ export const SETUP_QUESTIONNAIRE_STATE_ORDER = [ ] as const; export type SetupQuestionnaireStateId = (typeof SETUP_QUESTIONNAIRE_STATE_ORDER)[number]; -export type SetupQuestionKind = 'boolean' | 'number' | 'text' | 'choice' | 'multi-select' | 'scope-overrides'; +export type SetupQuestionKind = 'boolean' | 'number' | 'text' | 'choice' | 'multi-select' | 'scope-overrides' | 'producer-select' | 'project-select'; + +export type SetupDiscoveryStatus = 'observed' | 'no-recent-runs' | 'no-verifiable-checks' | 'empty' | 'permission-denied' | 'unavailable' | 'unsupported'; + +export interface SetupProjectCandidate { + readonly number: number; + readonly title: string; + readonly owner: string; + readonly url: string; + readonly statusOptions?: readonly string[]; +} + +export interface SetupDiscoveryResult { + readonly status: SetupDiscoveryStatus; + readonly candidates: readonly T[]; + readonly truncated?: boolean; +} + +export interface SetupApprovalCheckCandidate { + readonly name: string; + readonly sourceAppId: number; + readonly sourceAppName?: string; + readonly workflowName: string; + readonly runUrl: string; + readonly headSha: string; + readonly conclusion: string; + readonly observedAt?: string; + /** Exact App-bound status check in an active ruleset for this branch; absence means unverified, not optional. */ + readonly requiredByRuleset?: { readonly branch: string; readonly sourceUrl: string }; +} export interface SetupQuestion { readonly stateId: Exclude; @@ -29,6 +59,17 @@ export interface SetupQuestion { readonly defaultValue: string | number | boolean; readonly choices?: readonly string[]; readonly allowedNames?: readonly string[]; + readonly producerCandidates?: readonly SetupApprovalCheckCandidate[]; + readonly trustedProducers?: readonly PullRequestApprovalProducer[]; + readonly discoveryStatus?: SetupDiscoveryStatus; + readonly discoveryTruncated?: boolean; + readonly discoveryRetryRemaining?: number; + readonly projectCandidates?: readonly SetupProjectCandidate[]; + readonly projectOwner?: string; + readonly statusOptionState?: 'observed' | 'unavailable' | 'incompatible'; + readonly projectStatusValues?: readonly { readonly transition: 'issueCreated' | 'pullRequestCreated' | 'issueInProgress' | 'pullRequestInProgress'; readonly value: string }[]; + readonly suggestionSource?: 'github' | 'local' | 'configuration' | 'default'; + readonly fixedWorkflowFeatures?: Readonly<{ release?: boolean; hotfix?: boolean }>; } export interface SetupQuestionnaireState { @@ -38,10 +79,22 @@ export interface SetupQuestionnaireState { readonly validation?: string; readonly terminal: 'collecting' | 'review' | 'confirmation' | 'completed' | 'cancelled'; readonly configureIndependently: boolean; + readonly phase?: 'full' | 'permission-intent'; + readonly answeredQuestionIds?: readonly string[]; + readonly projectsWanted?: boolean; +} + +export interface SetupQuestionnaireProgress { + readonly position: number; + readonly total: number; + readonly groupPosition: number; + readonly groupTotal: number; + readonly group: SetupQuestion['stateId']; } export type SetupQuestionnaireEvent = | { readonly kind: 'answer'; readonly value: string } + | { readonly kind: 'back' } | { readonly kind: 'cancel' } | { readonly kind: 'end-of-input' }; @@ -49,4 +102,14 @@ export interface SetupQuestionnaireContext { readonly remote?: SetupRemoteConfiguration; readonly variableNames?: readonly string[]; readonly secretNames?: readonly string[]; + readonly skipQuestionIds?: readonly string[]; + readonly approvalCheckCandidates?: readonly SetupApprovalCheckCandidate[]; + readonly approvalCheckDiscoveryStatus?: SetupDiscoveryStatus; + readonly approvalCheckDiscoveryTruncated?: boolean; + readonly projectDiscovery?: SetupDiscoveryResult; + readonly projectOwner?: string; + readonly projectsWanted?: boolean; + readonly discoveryRetryRemaining?: Readonly<{ checks: number; projects: number }>; + readonly branchSources?: Readonly<{ main: 'github' | 'configuration' | 'default'; development: 'local' | 'configuration' | 'default' }>; + readonly fixedWorkflowFeatures?: Readonly<{ release?: boolean; hotfix?: boolean }>; } diff --git a/src/infrastructure/__tests__/github_setup_approval_check_discovery_adapter.test.ts b/src/infrastructure/__tests__/github_setup_approval_check_discovery_adapter.test.ts new file mode 100644 index 000000000..db25c8957 --- /dev/null +++ b/src/infrastructure/__tests__/github_setup_approval_check_discovery_adapter.test.ts @@ -0,0 +1,106 @@ +import * as github from '@actions/github'; +import { GithubSetupApprovalCheckDiscoveryAdapter } from '../github_setup_approval_check_discovery_adapter'; + +jest.mock('@actions/github', () => ({ getOctokit: jest.fn() })); + +const sha = 'a'.repeat(40); +const owner = 'acme'; +const repository = 'project'; + +function arrange(runs: unknown[], checks: unknown[], jobs: unknown[]): { listWorkflowRunsForRepo: jest.Mock; listForRef: jest.Mock; listJobsForWorkflowRunAttempt: jest.Mock; request: jest.Mock } { + const listWorkflowRunsForRepo = jest.fn().mockResolvedValue({ data: { workflow_runs: runs } }); + const listForRef = jest.fn().mockResolvedValue({ data: { check_runs: checks } }); + const listJobsForWorkflowRunAttempt = jest.fn().mockResolvedValue({ data: { jobs } }); + const request = jest.fn().mockResolvedValue({ data: [] }); + (github.getOctokit as jest.Mock).mockReturnValue({ request, rest: { + actions: { listWorkflowRunsForRepo, listJobsForWorkflowRunAttempt }, checks: { listForRef }, + } }); + return { listWorkflowRunsForRepo, listForRef, listJobsForWorkflowRunAttempt, request }; +} + +describe('GitHub setup approval check discovery', () => { + beforeEach(() => jest.clearAllMocks()); + + test('marks only an exact check/App pair required by an active branch ruleset', async () => { + const calls = arrange( + [{ id: 42, name: 'CI', head_sha: sha, run_attempt: 1, status: 'completed' }], + [{ id: 90, name: 'Tests', app: { id: 12 }, head_sha: sha, conclusion: 'success' }, + { id: 91, name: 'Other', app: { id: 99 }, head_sha: sha, conclusion: 'success' }], + [{ name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/90' }, + { name: 'Other', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/91' }], + ); + calls.request.mockResolvedValueOnce({ data: [{ type: 'required_status_checks', ruleset_id: 7, + ruleset_source_type: 'Repository', ruleset_source: 'acme/project', + parameters: { required_status_checks: [{ context: 'Tests', integration_id: 12 }, { context: 'Other', integration_id: 12 }] } }] }); + const result = await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret', 'develop'); + expect(calls.request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/rules/branches/{branch}', + expect.objectContaining({ branch: 'develop', per_page: 100 })); + expect(result.candidates[0].requiredByRuleset).toEqual({ branch: 'develop', sourceUrl: 'https://github.com/acme/project/rules/7' }); + expect(result.candidates[1].requiredByRuleset).toBeUndefined(); + }); + + test('suggests only exact jobs joined to a completed PR workflow and Check Run App ID', async () => { + const calls = arrange( + [{ id: 42, name: 'CI', head_sha: sha, run_attempt: 2, status: 'completed', conclusion: 'success', created_at: '2026-09-29T00:00:00Z' }], + [{ id: 90, name: 'Tests', app: { id: 12, name: 'GitHub Actions' }, head_sha: sha, conclusion: 'success' }, + { id: 91, name: 'Foreign', app: { id: 34 }, head_sha: sha, conclusion: 'success' }], + [{ name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/90' }], + ); + const result = await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret'); + expect(result).toEqual({ status: 'observed', candidates: [{ name: 'Tests', sourceAppId: 12, sourceAppName: 'GitHub Actions', workflowName: 'CI', + runUrl: 'https://github.com/acme/project/actions/runs/42', headSha: sha, conclusion: 'success', observedAt: '2026-09-29T00:00:00Z' }] }); + expect(calls.listWorkflowRunsForRepo).toHaveBeenCalledWith(expect.objectContaining({ owner, repo: repository, event: 'pull_request', per_page: 20 })); + expect(calls.listForRef).toHaveBeenCalledWith(expect.objectContaining({ ref: sha, filter: 'all' })); + expect(calls.listJobsForWorkflowRunAttempt).toHaveBeenCalledWith(expect.objectContaining({ run_id: 42, attempt_number: 2 })); + }); + + test('does not invent identities from a job name, skipped workflow or unsafe producer', async () => { + arrange( + [{ id: 42, name: 'CI', head_sha: sha, run_attempt: 1, status: 'completed' }, + { id: 43, name: 'Copilot - Approval', head_sha: sha, run_attempt: 1, status: 'completed' }, + { id: 44, name: 'Pending', head_sha: sha, run_attempt: 1, status: 'in_progress' }], + [{ id: 90, name: 'Tests|fake', app: { id: 12 }, head_sha: sha, conclusion: 'success' }, + { id: 91, name: 'Tests', app: null, head_sha: sha, conclusion: 'success' }, + { id: 92, name: 'Tests', app: { id: 12 }, head_sha: 'b'.repeat(40), conclusion: 'success' }, + { id: 93, name: 'Tests\u202eevil', app: { id: 12 }, head_sha: sha, conclusion: 'success' }], + [{ name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/90' }, + { name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/91' }, + { name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/92' }, + { name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/93' }], + ); + expect(await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret')).toEqual({ status: 'no-verifiable-checks', candidates: [] }); + }); + + test('distinguishes no recent PR runs from missing permission and provider outage', async () => { + const empty = arrange([], [], []); + expect(await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret')) + .toEqual({ status: 'no-recent-runs', candidates: [] }); + empty.listWorkflowRunsForRepo.mockRejectedValueOnce(Object.assign(new Error('denied'), { status: 403 })); + expect(await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret')) + .toEqual({ status: 'permission-denied', candidates: [] }); + empty.listWorkflowRunsForRepo.mockRejectedValueOnce(new Error('offline')); + expect(await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret')) + .toEqual({ status: 'unavailable', candidates: [] }); + }); + + test('does not claim ruleset evidence when rules are malformed or GitHub denies inspection', async () => { + const calls = arrange([{ id: 42, name: 'CI', head_sha: sha, run_attempt: 1, status: 'completed' }], + [{ id: 90, name: 'Tests', app: { id: 12 }, head_sha: sha, conclusion: 'success' }], + [{ name: 'Tests', check_run_url: 'https://api.github.com/repos/acme/project/check-runs/90' }]); + calls.request.mockResolvedValueOnce({ data: [{ type: 'required_status_checks', ruleset_id: 0, + ruleset_source_type: 'Repository', ruleset_source: 'acme/project', + parameters: { required_status_checks: [{ context: 'Tests', integration_id: 12 }] } }] }); + expect((await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret', 'develop')) + .candidates[0].requiredByRuleset).toBeUndefined(); + calls.request.mockRejectedValueOnce(Object.assign(new Error('denied'), { status: 403 })); + expect((await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret', 'develop')) + .candidates[0].requiredByRuleset).toBeUndefined(); + }); + + test('maps a later Check Runs failure to a nonempty error status instead of an empty trusted list', async () => { + const calls = arrange([{ id: 42, name: 'CI', head_sha: sha, run_attempt: 1, status: 'completed' }], [], []); + calls.listForRef.mockRejectedValueOnce(Object.assign(new Error('denied'), { status: 403 })); + expect(await new GithubSetupApprovalCheckDiscoveryAdapter().discover(owner, repository, 'secret')) + .toEqual({ status: 'permission-denied', candidates: [] }); + }); +}); diff --git a/src/infrastructure/__tests__/github_setup_project_discovery_adapter.test.ts b/src/infrastructure/__tests__/github_setup_project_discovery_adapter.test.ts new file mode 100644 index 000000000..23aff3f60 --- /dev/null +++ b/src/infrastructure/__tests__/github_setup_project_discovery_adapter.test.ts @@ -0,0 +1,102 @@ +import * as github from '@actions/github'; +import { GithubSetupProjectDiscoveryAdapter } from '../github_setup_project_discovery_adapter'; + +jest.mock('@actions/github', () => ({ getOctokit: jest.fn() })); + +const adapter = new GithubSetupProjectDiscoveryAdapter(); +const owner = 'acme'; + +function arrange(request: jest.Mock): void { + (github.getOctokit as jest.Mock).mockReturnValue({ request }); +} + +describe('GitHub setup Project discovery', () => { + beforeEach(() => jest.clearAllMocks()); + + test('lists existing organization Projects and reads their Status options without writes', async () => { + const request = jest.fn().mockImplementation(async (route: string) => route.endsWith('/fields') + ? { data: [{ name: 'Status', data_type: 'single_select', options: [{ name: { raw: 'Todo' } }, { name: { raw: 'In Progress' } }] }], headers: {} } + : { data: [{ number: 5, title: 'Roadmap', state: 'open', closed_at: null }], headers: {} }); + arrange(request); + expect(await adapter.discover(owner, 'Organization', 'secret')).toEqual({ status: 'observed', candidates: [{ + number: 5, title: 'Roadmap', owner, url: 'https://github.com/orgs/acme/projects/5', statusOptions: ['Todo', 'In Progress'], + }] }); + expect(request).toHaveBeenCalledWith('GET /orgs/{org}/projectsV2', { org: owner, per_page: 50 }); + expect(request).toHaveBeenCalledWith('GET /orgs/{org}/projectsV2/{project_number}/fields', { org: owner, project_number: 5, per_page: 100 }); + expect(request.mock.calls.every(([route]) => route.startsWith('GET '))).toBe(true); + }); + + test('uses bounded cursor pagination and marks inaccessible Status options as unverified', async () => { + const request = jest.fn().mockResolvedValueOnce({ data: [{ number: 2, title: 'First' }], + headers: { link: '; rel="next"' } }) + .mockResolvedValueOnce({ data: [{ number: 3, title: 'Second' }], headers: {} }) + .mockResolvedValueOnce({ data: [], headers: {} }) + .mockRejectedValueOnce(Object.assign(new Error('forbidden'), { status: 403 })); + arrange(request); + const result = await adapter.discover(owner, 'Organization', 'secret'); + expect(result).toMatchObject({ status: 'observed', candidates: [ + { number: 2, title: 'First' }, { number: 3, title: 'Second' }, + ] }); + expect(result.candidates.every(candidate => candidate.statusOptions === undefined)).toBe(true); + expect(request).toHaveBeenNthCalledWith(2, 'GET /orgs/{org}/projectsV2', { org: owner, per_page: 50, after: 'cursor2' }); + }); + + test('follows REST page links, reports a truncated inventory, and does not trust partial field listings', async () => { + const request = jest.fn().mockImplementation(async (route: string, params: { page?: number }) => { + if (route.endsWith('/fields')) return { data: [ + { name: 'Status', data_type: 'single_select', options: [{ name: 'Todo' }] }, + ], headers: { link: '; rel="next"' } }; + if (params.page === 2) return { data: [{ number: 3, title: 'Second' }], + headers: { link: '; rel="next"' } }; + return { data: [{ number: 2, title: 'First' }], + headers: { link: '; rel="next"' } }; + }); + arrange(request); + const result = await adapter.discover(owner, 'Organization', 'secret'); + expect(result).toMatchObject({ status: 'observed', truncated: true, candidates: [ + { number: 2, title: 'First' }, { number: 3, title: 'Second' }, + ] }); + expect(result.candidates.every(candidate => candidate.statusOptions === undefined)).toBe(true); + expect(request).toHaveBeenNthCalledWith(2, 'GET /orgs/{org}/projectsV2', { org: owner, per_page: 50, page: 2 }); + expect(request.mock.calls.filter(([route]) => route === 'GET /orgs/{org}/projectsV2')).toHaveLength(2); + }); + + test('ignores unsafe or invalid REST pagination links', async () => { + const request = jest.fn().mockResolvedValue({ data: [{ number: 2, title: 'First' }], headers: { + link: '; rel="next", ; rel="last"', + } }); + arrange(request); + expect(await adapter.discover(owner, 'Organization', 'secret')).toMatchObject({ status: 'observed', candidates: [{ number: 2 }] }); + expect(request.mock.calls.filter(([route]) => route === 'GET /orgs/{org}/projectsV2')).toHaveLength(1); + }); + + test('ignores a malformed next-page URL without making another request', async () => { + const request = jest.fn().mockResolvedValue({ data: [{ number: 2, title: 'First' }], + headers: { link: '; rel="next"' } }); + arrange(request); + expect((await adapter.discover(owner, 'Organization', 'secret')).candidates).toHaveLength(1); + expect(request.mock.calls.filter(([route]) => route === 'GET /orgs/{org}/projectsV2')).toHaveLength(1); + }); + + test('distinguishes personal-owner unsupported, empty, denied and provider unavailable', async () => { + expect(await adapter.discover(owner, 'User', 'secret')).toEqual({ status: 'unsupported', candidates: [] }); + expect(github.getOctokit).not.toHaveBeenCalled(); + arrange(jest.fn().mockResolvedValue({ data: [], headers: {} })); + expect(await adapter.discover(owner, 'Organization', 'secret')).toEqual({ status: 'empty', candidates: [] }); + arrange(jest.fn().mockRejectedValue(Object.assign(new Error('denied'), { status: 403 }))); + expect(await adapter.discover(owner, 'Organization', 'secret')).toEqual({ status: 'permission-denied', candidates: [] }); + arrange(jest.fn().mockRejectedValue(new Error('offline'))); + expect(await adapter.discover(owner, 'Organization', 'secret')).toEqual({ status: 'unavailable', candidates: [] }); + }); + + test('ignores closed, malformed and unsafe Project rows', async () => { + arrange(jest.fn().mockResolvedValue({ data: [ + { number: 1, title: 'Closed', state: 'closed' }, + { number: 4, title: 'Closed by date', closed_at: '2026-09-29T10:00:00Z' }, + { number: 0, title: 'Zero' }, { number: 2, title: ' + + diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 000000000..d6767d7a3 --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,85 @@ + + +
                          + +
                          + +
                          + {#if $setupLocale !== 'en'} + + {/if} + {#if $session.paired}{/if} + {#if $session.view?.journey?.choiceReviewPass && $session.view.journey.choiceReviewPass > 1 && !$session.view.outcome} +
                          {tr('reviewPass', $setupLocale, { pass: String($session.view.journey.choiceReviewPass) })}
                          + {/if} + {#if !$session.controller && $session.view} + + + {/if} + {#if $session.error}{/if} + {#if $session.view?.message && !$session.view.outcome} + + {/if} + + {#if !$session.paired} + + {:else if $session.view?.outcome} + + {:else if $session.view?.prompt} +
                          + + +
                          + {#if $session.controller && $session.view.journey?.current !== 'Apply'}{/if} + {:else} + + {/if} +
                          {tr('footerLocal', $setupLocale)} {tr('footerCloud', $setupLocale)} {tr('footerGithub', $setupLocale)}
                          +
                          +
                          +
                          diff --git a/web/src/components/ActionButton.svelte b/web/src/components/ActionButton.svelte new file mode 100644 index 000000000..b5c767d42 --- /dev/null +++ b/web/src/components/ActionButton.svelte @@ -0,0 +1,11 @@ + + + diff --git a/web/src/components/ChoicePrompt.svelte b/web/src/components/ChoicePrompt.svelte new file mode 100644 index 000000000..66dd91ca9 --- /dev/null +++ b/web/src/components/ChoicePrompt.svelte @@ -0,0 +1,11 @@ + + +
                          {#each prompt.choices as option, index}{/each}
                          diff --git a/web/src/components/ContextPanel.svelte b/web/src/components/ContextPanel.svelte new file mode 100644 index 000000000..e91d40447 --- /dev/null +++ b/web/src/components/ContextPanel.svelte @@ -0,0 +1,20 @@ + + + diff --git a/web/src/components/CoverageCheckEvidence.svelte b/web/src/components/CoverageCheckEvidence.svelte new file mode 100644 index 000000000..e2e5c7522 --- /dev/null +++ b/web/src/components/CoverageCheckEvidence.svelte @@ -0,0 +1,16 @@ + + +

                          {candidate.workflowName} · GitHub App {candidate.sourceAppId} + {#if safeGithubRunLink(candidate.runUrl)} · {tr('ciRun', $setupLocale)}{/if} +

                          +

                          {checkConclusionLabel(candidate.conclusion, $setupLocale)} · {candidate.headSha.slice(0, 7)} · {candidate.observedAt ?? tr('observationTimeUnknown', $setupLocale)} + · {candidate.requiredByRuleset ? tr('branchRequirementObserved', $setupLocale, { branch: candidate.requiredByRuleset.branch }) : tr('branchRequirementUnknown', $setupLocale)} + {#if safeGithubRulesetLink(candidate.requiredByRuleset?.sourceUrl)} · {tr('ciRule', $setupLocale)}{/if} +

                          diff --git a/web/src/components/CredentialPrompt.svelte b/web/src/components/CredentialPrompt.svelte new file mode 100644 index 000000000..590d85349 --- /dev/null +++ b/web/src/components/CredentialPrompt.svelte @@ -0,0 +1,25 @@ + + +{#if githubLink}{tr('githubForm', $setupLocale)}

                          {tr('githubFormHelp', $setupLocale)}

                          {/if} + + +{#if prompt.kind === 'secret'}

                          {tr('secretHelp', $setupLocale)}

                          {/if} + diff --git a/web/src/components/DiscoveryNotice.svelte b/web/src/components/DiscoveryNotice.svelte new file mode 100644 index 000000000..745fafc44 --- /dev/null +++ b/web/src/components/DiscoveryNotice.svelte @@ -0,0 +1,23 @@ + + +{#if key}

                          {tr(key, $setupLocale)}

                          {/if} +{#if status === 'observed' || status === 'empty' || status === 'no-recent-runs' || status === 'no-verifiable-checks'} +

                          {tr(kind === 'checks' ? 'checksDiscoveryScope' : 'projectsDiscoveryScope', $setupLocale)}

                          +{/if} +{#if truncated}

                          {tr('discoveryTruncated', $setupLocale)}

                          {/if} diff --git a/web/src/components/FixedWorkflowNotice.svelte b/web/src/components/FixedWorkflowNotice.svelte new file mode 100644 index 000000000..e5e0d61b3 --- /dev/null +++ b/web/src/components/FixedWorkflowNotice.svelte @@ -0,0 +1,14 @@ + + +{#if question.id === 'features.issues' && (question.fixedWorkflowFeatures?.release || question.fixedWorkflowFeatures?.hotfix)} +

                          {tr('fixedIssuesRequired', $setupLocale)}

                          +{:else if question.fixedWorkflowFeatures} + {#each Object.entries(question.fixedWorkflowFeatures).filter(([, fixed]) => fixed !== undefined) as [kind, fixed]} +

                          {tr(fixed ? 'fixedWorkflowEnabled' : 'fixedWorkflowDisabled', $setupLocale, { kind })}

                          + {/each} +{/if} diff --git a/web/src/components/LanguageSwitch.svelte b/web/src/components/LanguageSwitch.svelte new file mode 100644 index 000000000..15d6a241c --- /dev/null +++ b/web/src/components/LanguageSwitch.svelte @@ -0,0 +1,15 @@ + + + +{localeNames[$setupLocale]} diff --git a/web/src/components/PairingPanel.svelte b/web/src/components/PairingPanel.svelte new file mode 100644 index 000000000..2b4b73e4b --- /dev/null +++ b/web/src/components/PairingPanel.svelte @@ -0,0 +1,29 @@ + + +
                          +
                          {tr('privateSession', $setupLocale)}
                          +

                          {tr(mode === 'pair' ? 'pairTitle' : 'takeOver', $setupLocale)}

                          +

                          {tr(mode === 'pair' ? 'pairBody' : 'readOnlyBody', $setupLocale)}

                          +
                          { event.preventDefault(); submit(); }}> + + +

                          {tr('pairHelp', $setupLocale)}

                          + + +
                          diff --git a/web/src/components/PlanDecisionSummary.svelte b/web/src/components/PlanDecisionSummary.svelte new file mode 100644 index 000000000..5222c6061 --- /dev/null +++ b/web/src/components/PlanDecisionSummary.svelte @@ -0,0 +1,43 @@ + + +

                          {tr('planChoices', $setupLocale)}

                          +
                          +
                          {tr('planEnabledCapabilities', $setupLocale)}
                          {decisions.enabledCapabilities.length ? decisions.enabledCapabilities.map(item => featureName(item, $setupLocale)).join(', ') : tr('none', $setupLocale)}
                          +
                          {tr('planIssueWorkflows', $setupLocale)}
                          {decisions.issueWorkflows.length ? decisions.issueWorkflows.map(item => questionOptionLabel('issueWorkflows.enabled', item, $setupLocale)).join(', ') : tr('none', $setupLocale)}
                          +
                          {tr('planAgentRouting', $setupLocale)}
                            {#each decisions.agentRouting as agent}
                          • {agentRoleName(agent.role, $setupLocale)}: {agent.provider} · {agent.modelProvider}/{agent.model}
                          • {/each}
                          +
                          {tr('planBranchRoles', $setupLocale)}
                          {decisions.productionBranch} / {decisions.developmentBranch}
                          +
                          {tr('planApprovalMode', $setupLocale)}
                          {approvalLabel(decisions.approvalMode)}
                          + {#if decisions.approvalMode !== 'off'} +
                          {tr('planTrustedChecks', $setupLocale)}
                          {#if decisions.trustedChecks.length}
                            {#each decisions.trustedChecks as check}
                          • {check.name} · {tr('producerAppId', $setupLocale)} {check.sourceAppId} · {check.workflowName}
                          • {/each}
                          {:else}{tr('none', $setupLocale)}{/if}
                          +
                          {tr('planProducerAttested', $setupLocale)}
                          {tr(decisions.producerAttested ? 'yes' : 'no', $setupLocale)}
                          +
                          {tr('planCoverage', $setupLocale)}
                          {questionOptionLabel('pullRequestApproval.coverage.mode', decisions.coverageMode, $setupLocale)}{#if decisions.coverageCheck} · {decisions.coverageCheck}{/if}
                          + {#if decisions.coverageMode === 'numeric'} +
                          {tr('planCoverageThreshold', $setupLocale)}
                          {decisions.coverageMinimum === undefined ? tr('none', $setupLocale) : `${decisions.coverageMinimum}%`}
                          +
                          {tr('planCoverageReporter', $setupLocale)}
                          {decisions.coverageArtifactWorkflow || tr('none', $setupLocale)}
                          +
                          {tr('planReporterAttested', $setupLocale)}
                          {tr(decisions.coverageReporterAttested ? 'yes' : 'no', $setupLocale)}
                          + {/if} + {/if} +
                          {tr('editProjects', $setupLocale)}
                          {decisions.projectNumbers.length ? decisions.projectNumbers.map(item => `#${item}`).join(', ') : tr('none', $setupLocale)}
                          + {#if decisions.projectNumbers.length}
                          {tr('planProjectStatuses', $setupLocale)}
                            {#each decisions.projectStatuses as status}
                          • {tr(projectTransitionKey[status.transition], $setupLocale)}: {status.value}
                          • {/each}
                          {/if} +
                          {tr('planVariableScope', $setupLocale)}
                          {scopeLabel(decisions.variableScope)}
                          +
                          {tr('planSecretScope', $setupLocale)}
                          {scopeLabel(decisions.secretScope)}
                          +
                          {tr('planIssueResources', $setupLocale)}
                          {tr('planIssueResourcesValue', $setupLocale)}
                          +
                          {tr('planInitialTag', $setupLocale)}
                          {tr(decisions.initialTag ? 'yes' : 'no', $setupLocale)}
                          +
                          +

                          {tr('planAdvancedDefaults', $setupLocale)}

                          +
                          diff --git a/web/src/components/PlanPrompt.svelte b/web/src/components/PlanPrompt.svelte new file mode 100644 index 000000000..766ee084e --- /dev/null +++ b/web/src/components/PlanPrompt.svelte @@ -0,0 +1,41 @@ + + +

                          {tr('planBody', $setupLocale)}

                          + +{#if prompt.plan.presentationDefaults.length} +

                          {tr('planBasicDefaultsIntro', $setupLocale)}

                          +
                            {#each prompt.plan.presentationDefaults as group}
                          • {groupKeys[group.group as SetupQuestion['stateId']] ? tr(groupKeys[group.group as SetupQuestion['stateId']], $setupLocale) : group.group}: {group.count}
                          • {/each}
                          +
                          +{/if} +
                          {#each sections as section}

                          {section.title} {section.items.length}

                            {#each section.items as item}
                          • {item}
                          • {/each}
                          {/each}
                          +{#if prompt.plan.warnings.length}

                          {tr('beforeContinue', $setupLocale)}

                            {#each prompt.plan.warnings as warning}
                          • {localizedPlanWarning(warning, $setupLocale)}
                          • {/each}
                          {/if} +{#if prompt.editGroups?.length} +

                          {tr('changeAnswersTitle', $setupLocale)}

                          {tr('changeAnswersHelp', $setupLocale)}

                          +
                          {#each prompt.editGroups as group}{/each}
                          +
                          +{/if} +
                          onSubmit('decline')} disabled={!controller || busy} /> onSubmit('approve')} disabled={!controller || busy} />
                          diff --git a/web/src/components/ProducerSelector.svelte b/web/src/components/ProducerSelector.svelte new file mode 100644 index 000000000..fdee60945 --- /dev/null +++ b/web/src/components/ProducerSelector.svelte @@ -0,0 +1,48 @@ + + +
                          + {#each candidates as candidate} + {@const identity = `${candidate.name}|${candidate.sourceAppId}|${candidate.workflowName}`} +
                          + + {#if safeGithubRunLink(candidate.runUrl)}{tr('ciRun', $setupLocale)}{/if} + {#if safeGithubRulesetLink(candidate.requiredByRuleset?.sourceUrl)}{tr('ciRule', $setupLocale)}{/if} +
                          + {/each} +
                          +{#if manual.length}
                            {#each manual as identity}
                          • {identity}
                          • {/each}
                          {/if} +

                          {tr('producerManualHelp', $setupLocale)}

                          +
                          + + + +
                          + +{#if error}{/if} diff --git a/web/src/components/ProjectSelector.svelte b/web/src/components/ProjectSelector.svelte new file mode 100644 index 000000000..63862e288 --- /dev/null +++ b/web/src/components/ProjectSelector.svelte @@ -0,0 +1,32 @@ + + +
                          + {#each candidates as candidate} +
                          + + {#if safeGithubProjectLink(candidate.url)}{tr('projectUrl', $setupLocale)}{/if} +
                          + {/each} +
                          +{#if noLongerListed.length} +

                          {tr('projectSelectionNotObserved', $setupLocale)}

                          +
                            {#each noLongerListed as number}
                          • #{number}
                          • {/each}
                          +{/if} + + diff --git a/web/src/components/PromptCard.svelte b/web/src/components/PromptCard.svelte new file mode 100644 index 000000000..7338ed063 --- /dev/null +++ b/web/src/components/PromptCard.svelte @@ -0,0 +1,37 @@ + + +
                          +
                          {tr('currentDecision', $setupLocale)}{tr('session', $setupLocale)} {revision}
                          + {#if prompt.kind !== 'question'}

                          {copy?.title ?? prompt.title}

                          {/if} + {#if copy?.description || ('description' in prompt && prompt.description)}

                          {copy?.description ?? ('description' in prompt ? prompt.description : '')}

                          {/if} + {#key promptRevision} + {#if prompt.kind === 'question'} + + {:else if prompt.kind === 'choice' || prompt.kind === 'confirm'} + + {:else if prompt.kind === 'text' || prompt.kind === 'secret'} + + {:else if prompt.kind === 'plan'} + + {/if} + {/key} +
                          diff --git a/web/src/components/QuestionGuidance.svelte b/web/src/components/QuestionGuidance.svelte new file mode 100644 index 000000000..e40d80d38 --- /dev/null +++ b/web/src/components/QuestionGuidance.svelte @@ -0,0 +1,18 @@ + + +{#if helpUrl}{/if} +{#if explanation}
                          {tr('whyMatters', $setupLocale)}
                          +
                          {tr('whenApplies', $setupLocale)}
                          {explanation.when}
                          +
                          {tr('whereConfigured', $setupLocale)}
                          {explanation.where}
                          +
                          {tr('howToChoose', $setupLocale)}
                          {explanation.how}
                          +
                          {tr('whyRecommendation', $setupLocale)}
                          {explanation.why}
                          +
                          {tr('example', $setupLocale)}
                          {explanation.example}
                          +
                          {tr('effect', $setupLocale)}
                          {explanation.effect}
                          +
                          {tr('verify', $setupLocale)}
                          {explanation.verify}
                          +
                          {/if} diff --git a/web/src/components/QuestionPrompt.svelte b/web/src/components/QuestionPrompt.svelte new file mode 100644 index 000000000..a06d01c1d --- /dev/null +++ b/web/src/components/QuestionPrompt.svelte @@ -0,0 +1,88 @@ + +

                          {explanation?.label ?? prompt.question.label.replace(' (Space toggles, Enter confirms)', '')}

                          {#if prompt.phase === 'permission-intent'}{tr('permissionPreview', $setupLocale)}{/if}
                          +{#if prompt.progress}

                          {tr('questionProgress', $setupLocale, { current: String(prompt.progress.groupPosition), total: String(prompt.progress.groupTotal), overall: String(prompt.progress.position), all: String(prompt.progress.total) })}

                          {/if} +{#if explanation}

                          {explanation.summary}

                          {/if} +{#if prompt.question.id === 'pullRequestApproval.testChecks'}{/if} +{#if prompt.question.id === 'projects.ids'}{/if} +{#if prompt.question.discoveryRetryRemaining !== undefined} +
                          + {#if prompt.question.discoveryRetryRemaining > 0} + + {tr('retryRemaining', $setupLocale, { count: String(prompt.question.discoveryRetryRemaining) })} + {:else} +

                          {tr('retryExhausted', $setupLocale)}

                          + {/if} +
                          +{/if} +{#if prompt.question.statusOptionState === 'unavailable'}

                          {tr('projectStatusUnavailable', $setupLocale)}

                          {/if} +{#if prompt.question.statusOptionState === 'incompatible'}{/if} +{#if prompt.question.id === 'projects.ids'}

                          {tr('projectSharedStatus', $setupLocale)}

                          {/if} + +{#if prompt.question.projectStatusValues}
                            {#each prompt.question.projectStatusValues as item}
                          • {tr(projectTransitionKey[item.transition], $setupLocale)}: {item.value}
                          • {/each}
                          {/if} +{#if prompt.question.kind === 'boolean'} +
                          +{:else if prompt.question.kind === 'choice'} + + {#if prompt.question.id === 'pullRequestApproval.coverage.checkName'} + {#each (prompt.question.producerCandidates ?? []).filter(candidate => candidate.name === value) as candidate} + + {/each} + {/if} +{:else if prompt.question.kind === 'producer-select'} + +{:else if prompt.question.kind === 'project-select'} + +{:else if prompt.question.kind === 'multi-select' || prompt.question.kind === 'scope-overrides'} +
                          {#each (prompt.question.kind === 'multi-select' ? prompt.question.choices ?? [] : prompt.question.allowedNames ?? []) as option}{/each}
                          +{:else} + {#if prompt.question.id === 'ai.bugbotOrganizationRules'} + + {:else} + + {/if} +{/if} +

                          {tr('suggested', $setupLocale, { answer: suggestedAnswer })}

                          +{#if prompt.question.suggestionSource}

                          {tr(prompt.question.suggestionSource === 'github' ? 'sourceGithub' : prompt.question.suggestionSource === 'local' ? 'sourceLocal' : prompt.question.suggestionSource === 'configuration' ? 'sourceConfig' : 'sourceDefault', $setupLocale)}

                          {/if} + +
                          + {#if prompt.canGoBack}{/if} + onSubmit(submittedQuestionAnswer(prompt, value, selected))} disabled={!controller || busy} /> +
                          diff --git a/web/src/components/ResultPanel.svelte b/web/src/components/ResultPanel.svelte new file mode 100644 index 000000000..a5f34d326 --- /dev/null +++ b/web/src/components/ResultPanel.svelte @@ -0,0 +1,58 @@ + + +

                          {heading}

                          + {#if outcome === 'blocked' || outcome === 'cancelled' || outcome === 'partial'} +
                          +

                          {tr('whatHappened', $setupLocale)}: {tr(reasons[detail?.reasonCode ?? 'unknown'][0], $setupLocale)}

                          + {#if detail?.stoppedStage}

                          {tr('progress', $setupLocale)}: {stageLabel(detail.stoppedStage, $setupLocale)}

                          {/if} +

                          {tr('alreadyChanged', $setupLocale)}: {detail?.mutationStarted || outcome === 'partial' ? tr('inspectPartial', $setupLocale) : tr('noChanges', $setupLocale)}

                          +

                          {tr('nextAction', $setupLocale)}: {outcome === 'partial' ? `${tr('inspectPartial', $setupLocale)} ${tr(reasons[detail?.reasonCode ?? 'unknown'][1], $setupLocale)}` : tr(reasons[detail?.reasonCode ?? 'unknown'][1], $setupLocale)}

                          + {#if detail?.diagnosticRef}

                          {tr('diagnosticReference', $setupLocale)}: {detail.diagnosticRef}

                          {/if} +
                          + {/if} + {#if detail?.effects?.length} +

                          {tr('resourceReceipt', $setupLocale)}

                          +
                            {#each detail.effects as effect}
                          • {effectKeys[effect.id] ? tr(effectKeys[effect.id], $setupLocale) : effect.id} — {tr(effect.state === 'completed' ? 'effectCompleted' : effect.state === 'skipped' ? 'effectSkipped' : effect.state === 'not-started' ? 'effectNotStarted' : 'effectInspect', $setupLocale)}{#if effect.scope} · {tr(effect.scope === 'local' ? 'scopeLocal' : effect.scope === 'organization' ? 'scopeOrganization' : effect.scope === 'mixed' ? 'scopeMixed' : 'scopeRepository', $setupLocale)}{/if}
                          • {/each}
                          +
                          + {/if} +

                          {explanation}

                          + {#if outcome === 'complete'}

                          {tr('botRenewal', $setupLocale)}

                          {/if} +

                          {tr('doctorHelp', $setupLocale)}

                          + {#if outcome === 'complete'} +
                          + {#if doctor?.status === 'running'}

                          {tr('doctorRunning', $setupLocale)}

                          + {:else if doctor?.status === 'complete'}

                          {tr(doctor.healthy ? 'doctorPassed' : 'doctorWarnings', $setupLocale)} {tr('doctorCounts', $setupLocale, { pass: String(doctor.pass ?? 0), warn: String(doctor.warn ?? 0), fail: String(doctor.fail ?? 0), skipped: String(doctor.skipped ?? 0) })} {tr('doctorSecretLimit', $setupLocale)}

                          + {:else if doctor?.status === 'failed'}

                          {tr('doctorFailed', $setupLocale)}

                          {/if} + {#if controller && doctor?.status !== 'running' && doctor?.status !== 'complete'}{/if} +
                          + {/if} + {#if controller}{/if}
                          diff --git a/web/src/components/SetupHeader.svelte b/web/src/components/SetupHeader.svelte new file mode 100644 index 000000000..4d3a5ecac --- /dev/null +++ b/web/src/components/SetupHeader.svelte @@ -0,0 +1,12 @@ + + +
                          + +
                          {tr('localSession', $setupLocale)}
                          +
                          diff --git a/web/src/components/SetupIntro.svelte b/web/src/components/SetupIntro.svelte new file mode 100644 index 000000000..c4536448e --- /dev/null +++ b/web/src/components/SetupIntro.svelte @@ -0,0 +1,18 @@ + + +
                          {stageLabel(view?.journey?.current, $setupLocale).toUpperCase()} {String(view?.journey?.position ?? 1).padStart(2, '0')} / 06
                          +

                          {title}

                          +

                          {view?.outcome ? tr('resultLede', $setupLocale) : tr('activeLede', $setupLocale)}

                          diff --git a/web/src/components/SetupSidebar.svelte b/web/src/components/SetupSidebar.svelte new file mode 100644 index 000000000..e9b80044a --- /dev/null +++ b/web/src/components/SetupSidebar.svelte @@ -0,0 +1,21 @@ + + + diff --git a/web/src/components/StatusBanner.svelte b/web/src/components/StatusBanner.svelte new file mode 100644 index 000000000..2237014be --- /dev/null +++ b/web/src/components/StatusBanner.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/components/ThemeSwitch.svelte b/web/src/components/ThemeSwitch.svelte new file mode 100644 index 000000000..caaab5c13 --- /dev/null +++ b/web/src/components/ThemeSwitch.svelte @@ -0,0 +1,13 @@ + + +
                          + + + +
                          diff --git a/web/src/components/WaitingPanel.svelte b/web/src/components/WaitingPanel.svelte new file mode 100644 index 000000000..e66070fc9 --- /dev/null +++ b/web/src/components/WaitingPanel.svelte @@ -0,0 +1,6 @@ + + +

                          {tr('working', $setupLocale)}

                          {tr('workingBody', $setupLocale)}

                          diff --git a/web/src/i18n/agentRoleNames.ts b/web/src/i18n/agentRoleNames.ts new file mode 100644 index 000000000..7db0753f6 --- /dev/null +++ b/web/src/i18n/agentRoleNames.ts @@ -0,0 +1,12 @@ +import type { SetupLocale } from './catalog'; + +export const agentRoleNames: Readonly>>> = { + en: { planner: 'Planner', findings: 'Findings analyst', reviewer: 'Reviewer', fixer: 'Fixer', tester: 'Tester' }, + es: { planner: 'Planificador', findings: 'Analista de hallazgos', reviewer: 'Revisor', fixer: 'Corrector', tester: 'Probador' }, + fr: { planner: 'Planificateur', findings: 'Analyste des problèmes', reviewer: 'Réviseur', fixer: 'Correcteur', tester: 'Testeur' }, + pt: { planner: 'Planeador', findings: 'Analista de problemas', reviewer: 'Revisor', fixer: 'Corretor', tester: 'Testador' }, +}; + +export function agentRoleName(role: string, locale: SetupLocale): string { + return agentRoleNames[locale][role] ?? role; +} diff --git a/web/src/i18n/catalog.ts b/web/src/i18n/catalog.ts new file mode 100644 index 000000000..18ef568c4 --- /dev/null +++ b/web/src/i18n/catalog.ts @@ -0,0 +1,28 @@ +import { en } from './en'; +import { es } from './es'; +import { fr } from './fr'; +import { pt } from './pt'; +export const setupLocales = ['en', 'es', 'fr', 'pt'] as const; +export type SetupLocale = typeof setupLocales[number]; + +export { en, es }; + +export type SetupMessageKey = keyof typeof en; +export const localeNames: Record = { + en: 'English', es: 'Español', fr: 'Français', pt: 'Português', +}; +export const setupCatalogs: Readonly>>> = { + en, es, fr, pt, +}; +export function tr(key: SetupMessageKey, language: SetupLocale, values: Record = {}): string { + const source = (setupCatalogs[language] ?? setupCatalogs.en)[key]; + return source.replace(/\{(\w+)\}/gu, (_, name: string) => values[name] ?? ''); +} + +const stageKeys: Record = { + Repository: 'repository', 'Setup choices': 'choices', 'Setup PAT': 'setupPat', + Plan: 'plan', 'Bot PAT & credentials': 'botPat', Apply: 'apply', Preparation: 'gettingReady', +}; +export function stageLabel(stage: string | undefined, language: SetupLocale): string { + return tr(stageKeys[stage ?? ''] ?? 'gettingReady', language); +} diff --git a/web/src/i18n/checkEvidence.ts b/web/src/i18n/checkEvidence.ts new file mode 100644 index 000000000..f43a8cd9e --- /dev/null +++ b/web/src/i18n/checkEvidence.ts @@ -0,0 +1,12 @@ +import type { SetupLocale } from './catalog'; + +const labels: Record> = { + en: { success: 'Passed', failure: 'Failed', cancelled: 'Cancelled', neutral: 'Neutral', skipped: 'Skipped', timed_out: 'Timed out', action_required: 'Action required', stale: 'Stale', startup_failure: 'Failed to start', unknown: 'Unknown outcome' }, + es: { success: 'Correcto', failure: 'Falló', cancelled: 'Cancelado', neutral: 'Neutral', skipped: 'Omitido', timed_out: 'Agotó el tiempo', action_required: 'Requiere intervención', stale: 'Obsoleto', startup_failure: 'Falló al iniciar', unknown: 'Resultado desconocido' }, + fr: { success: 'Réussi', failure: 'Échoué', cancelled: 'Annulé', neutral: 'Neutre', skipped: 'Ignoré', timed_out: 'Délai dépassé', action_required: 'Action nécessaire', stale: 'Obsolète', startup_failure: 'Échec au démarrage', unknown: 'Résultat inconnu' }, + pt: { success: 'Concluído', failure: 'Falhou', cancelled: 'Cancelado', neutral: 'Neutro', skipped: 'Ignorado', timed_out: 'Tempo esgotado', action_required: 'Ação necessária', stale: 'Obsoleto', startup_failure: 'Falha ao iniciar', unknown: 'Resultado desconhecido' }, +}; + +export function checkConclusionLabel(value: string, locale: SetupLocale): string { + return labels[locale][value] ?? labels[locale].unknown; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts new file mode 100644 index 000000000..9771d631d --- /dev/null +++ b/web/src/i18n/en.ts @@ -0,0 +1,104 @@ +export const en = { + language: 'Language', english: 'English', spanish: 'Español', + setup: 'SETUP', connecting: 'Connecting…', localSession: 'LOCAL SESSION', + progress: 'Setup progress', studio: 'SETUP STUDIO', journey: 'YOUR SETUP JOURNEY', + repository: 'Repository', choices: 'Setup choices', setupPat: 'Setup PAT', plan: 'Plan', botPat: 'Bot PAT & credentials', apply: 'Apply', + localDesign: 'Local by design', localDesignBody: 'This page runs on your computer. GitHub creates both PATs in its own browser tabs.', + preparing: 'Preparing your setup…', completeTitle: 'Setup complete.', previewTitle: 'Preview complete.', cancelledTitle: 'Setup cancelled.', blockedTitle: 'Setup needs attention.', + gettingReady: 'GETTING READY', activeLede: 'One decision at a time. Your choices shape the permissions, plan and credentials needed for this repository.', + resultLede: 'Review the result and next action below. The terminal has additional technical detail.', + readOnly: 'Read-only tab', readOnlyBody: 'Another tab controls this session. You can review progress here or explicitly take over.', takeOver: 'Take control in this tab', + attention: 'Needs attention', checked: 'Checked', pleaseNote: 'Please note', progressUpdate: 'Progress update', + reviewPass: 'Reviewing saved choices — pass {pass}. This is the same setup run, not a restart.', cancelSetup: 'Cancel setup', + cancelConfirm: 'Cancel this local setup session? PATs already created in GitHub will still exist.', + footerLocal: 'LOCALHOST ONLY', footerCloud: 'NO CLOUD SETUP ACCOUNT', footerGithub: 'GITHUB OWNS PAT ISSUANCE', + currentDecision: 'CURRENT DECISION', session: 'SESSION', continue: 'Continue', previousQuestion: 'Previous question', questionProgress: 'Question {current} of {total} in this group · {overall} of {all} overall', yes: 'Yes', no: 'No', permissionPreview: 'PERMISSION PREVIEW', + changeAnswersTitle: 'Change your answers', changeAnswersHelp: 'Return to a section without losing other answers. The plan and required PAT grants will be checked again.', changeSection: 'Change {section}', + editCapabilities: 'Capabilities', editRuntimes: 'Agent runtimes', editModels: 'Agent models', editRoleModels: 'Per-role models', editRepository: 'Repository behavior', editDeployment: 'Release and hotfix', editBugbot: 'Bugbot', editApproval: 'PR approval', editProjects: 'Projects', editProvisioning: 'Provisioning', editStorage: 'Secrets and Variables', + suggested: 'Suggested answer: {answer}. You can review choices again before creating your setup PAT.', none: 'none', + sourceGithub: 'Observed from authenticated GitHub repository metadata.', sourceConfig: 'Provided by your configuration; GitHub has not replaced it.', sourceDefault: 'Product default; not verified against this repository.', + sourceLocal: 'Observed in this local checkout; confirm this branch exists on GitHub before applying.', + whyMatters: 'Why this matters', whenApplies: 'When it applies', whereConfigured: 'Where it is configured', howToChoose: 'How to choose', whyRecommendation: 'Why this matters', example: 'Example', effect: 'What changes', verify: 'How to check', learnMore: 'Read documentation about this setting', + resultApplied: 'Your configuration was applied', resultNoChanges: 'No changes were made', resultStopped: 'No setup changes started', resultPartial: 'Check partial changes before retrying', + resultCompleteBody: 'The temporary setup PAT is not revoked automatically. Delete it in GitHub after confirming your setup. Keep the bot PAT until you rotate the installed Secret.', + resultPartialBody: 'A bot Secret write or another change may have succeeded. Inspect GitHub and run copilot doctor --read-only before replacing or deleting its PAT.', + resultNoChangesBody: 'Copilot did not begin applying setup changes. PATs you created in GitHub still exist until you delete them there.', + whatHappened: 'What happened', alreadyChanged: 'Already changed', noChanges: 'No repository or GitHub setup changes were started in this session.', + nextAction: 'Next action', reasonPermissions: 'The setup PAT lacked or could not confirm required permissions.', nextPermissions: 'Check the displayed grants, correct the PAT in GitHub, then start a fresh setup run.', + reasonStorage: 'The selected GitHub Actions storage could not be used safely.', nextStorage: 'Inspect the Variables/Secrets scope and existing resources, then retry.', + reasonConfiguration: 'The chosen configuration could not be validated.', nextConfiguration: 'Review the terminal validation details, correct the choices, and retry.', + reasonExpired: 'The local setup session expired before applying changes.', nextExpired: 'Start a new setup session; the old approval cannot be replayed.', + reasonCancelled: 'This setup was cancelled before applying changes.', nextCancelled: 'Start a new run if you still want to configure this repository.', + reasonUnknown: 'Setup stopped before Apply. The browser could not determine the exact cause.', nextUnknown: 'Check the final terminal error before retrying; do not assume a PAT was revoked.', + reasonProvider: 'GitHub or another provider did not complete the requested operation.', nextProvider: 'Use the diagnostic reference to inspect the terminal result, check provider availability and access, then retry only after reviewing possible partial changes.', + reasonRateLimit: 'GitHub temporarily limited the requests needed for setup.', nextRateLimit: 'Wait for the limit to reset. Inspect any completed changes before starting another setup run.', + diagnosticReference: 'Diagnostic reference', resourceReceipt: 'Setup operation receipt', effectCompleted: 'Reported completed', effectSkipped: 'Reported skipped', effectInspect: 'Outcome needs inspection', + effectNotStarted: 'Not started', scopeLocal: 'Local checkout', scopeMixed: 'Repository and organization', + receiptFiles: 'Setup files', receiptSecrets: 'GitHub Actions Secrets', receiptLabels: 'Issue labels', receiptIssueTypes: 'Issue types', receiptVariables: 'GitHub Actions Variables', receiptInitialTag: 'Initial version tag', + inspectPartial: 'Some setup changes may already be active. Inspect GitHub and the terminal result before retrying.', + patSettings: 'Open GitHub PAT settings ↗', closeSession: 'Close local session', + doctorHelp: 'After success, you can verify installed resources here without dispatching Actions. Or run copilot doctor --read-only from the repository root with the temporary setup PAT and the same non-secret --config file, if used.', + doctorRun: 'Verify installed setup (read-only)', doctorRunning: 'Checking installed resources without dispatching Actions…', + doctorPassed: 'Read-only verification found no failing checks.', doctorWarnings: 'Read-only verification needs attention.', + doctorFailed: 'Read-only verification did not finish. Check the terminal or run the command below.', + doctorCounts: '{pass} passed · {warn} warnings · {fail} failed · {skipped} skipped.', + doctorSecretLimit: 'Secret values cannot be verified in this mode.', + botRenewal: 'The bot PAT stays in the selected GitHub Actions Secret for future runs. Its actual expiry is not verified here; record it in GitHub and rotate the Secret before expiry.', + working: 'Working on the next step', workingBody: 'The local process is checking your answers and preparing the next decision. Keep this page open.', + repoFocus: 'Repository in focus', repoFocusBody: 'All decisions in this session target only:', access: 'access', readOnlyCheck: 'Read-only access check', provisionalGrants: 'Provisional least-privilege grants', + conditionalGrants: 'Conditional grants depend on the choices and GitHub facts. A final audit runs before setup changes.', + permissionUnknown: 'This permission needs review. Check its exact grant and technical explanation in the terminal before continuing.', + permissionsFollow: 'Permissions follow your choices', permissionsFollowBody: 'We’ll show the exact grants before you create each PAT. Nothing is created just by opening this page.', + files: 'Files', workflows: 'Workflows', variables: 'Variables', secretNames: 'Secret names', + planBody: 'Review exactly what this run may change. The bot PAT and any additional credentials are collected next.', + planChoices: 'Your key decisions', planEnabledCapabilities: 'Enabled capabilities', planBranchRoles: 'Production / development branch', planApprovalMode: 'Pull-request approval', planVariableScope: 'Variables scope', planSecretScope: 'Secrets scope', planInitialTag: 'Create initial tag', + planAgentRouting: 'Agent and model for each task', planIssueWorkflows: 'Issue workflows', planTrustedChecks: 'Trusted CI producers', planCoverage: 'Coverage evidence', planProjectStatuses: 'Project Status transitions', planIssueResources: 'Issue labels and issue types', planIssueResourcesValue: 'Checked or provisioned during Apply', + planProducerAttested: 'CI identity and enforcing step verified by you', planCoverageThreshold: 'Minimum changed-line coverage', planCoverageReporter: 'Artifact-publishing workflow', planReporterAttested: 'Coverage reporter verified by you', + planAdvancedDefaults: 'The plan also includes defaults for settings you did not change. Use Change below to inspect any section before approving.', + planUnknownWarning: 'An additional plan warning could not be displayed here. Read the terminal warning before approving.', + planBasicDefaultsIntro: 'Basic setup retained these advanced defaults. Open a section below to review or change them:', + scopeRepository: 'Repository', scopeOrganization: 'Organization', scopeDisabled: 'Not provisioned', approvalOff: 'Off', approvalRecommend: 'Recommendation only', approvalGuarded: 'Guarded approval', + beforeContinue: 'Before you continue', stopHere: 'Stop here', approvePlan: 'Approve this plan', + githubLink: 'Open GitHub link ↗', githubForm: 'Open the official GitHub PAT form', + githubFormHelp: 'Check the signed-in account, select Only select repositories, then select this repository. GitHub handles 2FA and creates the PAT.', + pasteHere: 'Paste the value here', yourAnswer: 'Your answer', hiddenAfter: 'Hidden after submission', typeAnswer: 'Type your answer', + secretHelp: 'Sent only to this local process. It will not be shown again or saved in browser storage.', + pairTitle: 'Pair this browser', pairLabel: 'Pairing code from terminal', pairPlaceholder: '16 hexadecimal characters', + pairBody: 'Find the 16-character pairing code in the terminal that started copilot setup --web. Enter it here to view or control this setup. The code is never placed in the browser URL or stored after this page closes.', + pairHelp: 'Keep the code private. After refreshing this page, enter it again to reconnect.', pairButton: 'Connect to local setup', privateSession: 'PRIVATE LOCAL SESSION', + theme: 'Color theme', themeAuto: 'Auto', themeSystem: 'Follow system theme', themeLight: 'Light theme', themeDark: 'Dark theme', + selectOne: 'Select one', ciRun: 'Open CI run on GitHub ↗', manualCheck: 'Check not listed? Enter exact name|App ID|workflow, separated by semicolons.', + checksObserved: 'Recent CI jobs were found. Open each run and verify the exact job, App and enforcing coverage step before trusting it.', + checksNoRecent: 'No recent pull-request workflow runs were found. Run your normal CI on a real pull request, or enter an exact producer manually.', + checksNoVerifiable: 'Recent runs exist, but no job could be linked to an exact Check Run and App identity. Inspect GitHub or enter an exact producer manually.', + checksDenied: 'GitHub did not allow CI discovery. Give the setup PAT Actions: read and Checks: read, or enter an exact producer manually.', + checksUnavailable: 'GitHub CI discovery could not complete. This is not evidence that the repository has no checks. Retry setup or enter an exact producer manually.', + projectsObserved: 'These existing Projects belong to the repository owner. Select only Projects this automation should update.', + projectsEmpty: 'This bounded GitHub query returned no open, accessible organization Projects; it does not prove none exist. Check access or enter a verified Project number.', + projectsDenied: 'GitHub did not allow Project discovery. Check organization Projects: read on the setup PAT, or enter Project numbers manually.', + projectsUnavailable: 'GitHub Project discovery could not complete. This is not evidence that no Projects exist. Enter a verified Project number manually or retry.', + projectsUnsupported: 'GitHub does not support listing personal Projects with a fine-grained PAT through this endpoint. Enter an existing Project number from its URL.', + discoveryTruncated: 'Only a bounded sample of accessible Projects or recent checks was inspected. Use manual entry for an item not shown.', + checksDiscoveryScope: 'Search scope: up to 20 recent pull-request workflow runs; at most 15 runs and 100 checks per commit are inspected.', + projectsDiscoveryScope: 'Search scope: at most 30 open, accessible organization Projects from two pages; up to 100 fields are inspected per Project. Closed Projects are excluded.', + retryDiscovery: 'Retry GitHub discovery', retryRemaining: '{count} read-only retries left. Your answers stay here.', + retryExhausted: 'No discovery retries remain. Inspect GitHub and use the manual option if the item is missing.', + observationTimeUnknown: 'observation time unavailable', branchRequirementUnknown: 'Required by branch rule: not checked', + branchRequirementObserved: 'Required on {branch} by an active ruleset for this exact check and App.', ciRule: 'Open required-check ruleset ↗', + projectSharedStatus: 'All selected Projects must share each chosen Status value. This setup cannot assign a different Status vocabulary per Project.', + fixedWorkflowEnabled: 'Your configuration fixes features.{kind}=true. Keep {kind} selected; edit --config or flags to change this.', + fixedWorkflowDisabled: 'Your configuration fixes features.{kind}=false. Leave {kind} unselected; edit --config or flags to change this.', + fixedIssuesRequired: 'Your configuration explicitly enables release or hotfix automation. Keep Issues enabled, or change --config or flags first.', + projectSelectionNotObserved: 'Previously selected Project numbers are retained, but no longer appear in this GitHub result. Verify each in GitHub or remove it.', + removeSelection: 'Remove selection', + projectTransitionIssueCreated: 'New issue', projectTransitionPullRequestCreated: 'New pull request', + projectTransitionIssueInProgress: 'Issue in progress', projectTransitionPullRequestInProgress: 'Pull request in progress', + projectUrl: 'Open Project on GitHub ↗', projectManual: 'Project not listed? Enter its positive number or exact GitHub URL. PVT_ IDs are not accepted.', + projectStatusUnavailable: 'Status options could not be verified for every selected Project. Enter the exact existing Status value after checking each Project in GitHub.', + projectStatusIncompatible: 'Selected Projects have no shared Status values. Choose compatible Projects before continuing.', + producerName: 'Check/job name', producerAppId: 'Source GitHub App ID', producerWorkflow: 'Workflow name', producerAdd: 'Add exact check', producerRemove: 'Remove check', + producerManualHelp: 'Use the exact identity shown on GitHub. Adding it does not attest that it enforces coverage.', + producerManualInvalid: 'Enter a check name, positive numeric App ID and workflow name. Do not use | or ; in names.', + translationPreviewTitle: 'Translation review in progress', translationPreviewBody: 'First-party setup questions are translated. Some dynamic GitHub or provider diagnostics may still appear in English while localization review finishes. Changing language does not change your answers.', + unknownLocalError: 'An unexpected local setup error occurred. Check the terminal for details, then refresh or restart setup.', +} as const; diff --git a/web/src/i18n/errors/en.ts b/web/src/i18n/errors/en.ts new file mode 100644 index 000000000..cfe7a91fe --- /dev/null +++ b/web/src/i18n/errors/en.ts @@ -0,0 +1,39 @@ +export const sessionErrorsEn = { + 'The local setup session is unavailable.': 'The local setup session is unavailable. Check the terminal; you may need to restart setup.', + 'Connection lost. The CLI may have stopped. Check the terminal before trying again.': 'Connection lost. The CLI may have stopped. Check the terminal before trying again.', + 'Could not join this local session.': 'Could not join this local session. Check the terminal and pair again.', + 'Could not connect to the local setup session. Check the terminal and pair again.': 'Could not connect to the local setup session. Check the terminal and pair again.', + 'Pairing was rejected.': 'Pairing was rejected. Check the code in the launching terminal.', + 'Invalid local pairing response.': 'The local pairing response was invalid. Restart setup from the terminal.', + 'Could not pair this browser.': 'Could not pair this browser. Check the terminal and try again.', + 'The request was rejected.': 'The local request was rejected. Refresh the page and check the terminal.', + 'Could not submit this answer.': 'Could not submit this answer. Refresh the page and try again.', + 'Cancellation failed.': 'Cancellation failed. Check whether setup has already started applying before closing.', + 'Takeover failed.': 'Could not take control. Re-enter the pairing code from the launching terminal.', + 'Invalid local host or request context.': 'The local request context was rejected. Open the exact URL printed in the terminal.', + 'Invalid request origin.': 'The request came from another origin. Open the exact local URL printed in the terminal.', + 'JSON required.': 'The local request format was invalid. Refresh the page and try again.', + 'Too many pairing attempts. Restart setup.': 'Too many incorrect pairing attempts. Restart setup from the terminal.', + 'Too many pairing attempts. Wait 30 seconds and retry.': 'Too many incorrect pairing attempts. Wait 30 seconds and try the code again; terminal setup remains available.', + 'Incorrect pairing code. Check the terminal.': 'Incorrect pairing code. Check the launching terminal.', + 'Incorrect pairing code. Check the launching output.': 'Incorrect pairing code. Check the launching output.', + 'Pair this browser using the code printed by the CLI.': 'Pair this browser using the code printed by the CLI.', + 'This tab is read-only.': 'This tab is read-only. Enter the pairing code again to take control.', + 'Invalid answer.': 'The answer was invalid. Review the current question and try again.', + 'Control moved to another tab.': 'Control moved to another tab. This tab is now read-only.', + 'This question changed. Refresh the current state.': 'This question changed. Refresh the page before answering again.', + 'This setup has already started applying or ended.': 'Setup has already started applying or ended. Inspect the current result before retrying.', + 'Only the controller can close a finished session.': 'Only the controlling tab can close this finished session.', + 'Method not allowed.': 'This local request is not allowed. Refresh the page.', + 'Not found.': 'The requested local page was not found. Open the URL printed in the terminal.', + 'Invalid local request.': 'The local request was invalid. Refresh the page and try again.', + 'Body too large.': 'The submitted answer is too large. Shorten it and try again.', + 'JSON object required.': 'The local request format was invalid. Refresh the page and try again.', + 'Could not retry discovery.': 'Could not refresh the GitHub suggestions. Retry or use verified manual entry.', + 'Could not return to the previous question.': 'Could not return to the previous question. Refresh the page and try again.', + 'Invalid question revision.': 'The question changed. Refresh the page before going back.', + 'No earlier question is available here.': 'There is no earlier question in this stage. Continue or return to the plan review.', + 'Read-only verification failed.': 'Read-only verification failed. Check the terminal and retry once.', + 'Read-only verification failed. Check the terminal.': 'Read-only verification failed. Check the terminal and retry once.', + 'Read-only verification is unavailable or already running.': 'Read-only verification is unavailable or already running. Check the current result before retrying.', +} as const; diff --git a/web/src/i18n/errors/es.ts b/web/src/i18n/errors/es.ts new file mode 100644 index 000000000..af03a9dcc --- /dev/null +++ b/web/src/i18n/errors/es.ts @@ -0,0 +1,41 @@ +import type { sessionErrorsEn } from './en'; + +export const sessionErrorsEs: Readonly> = { + 'The local setup session is unavailable.': 'La sesión local no está disponible. Revisa la terminal; quizá debas reiniciar la configuración.', + 'Connection lost. The CLI may have stopped. Check the terminal before trying again.': 'Se perdió la conexión. Es posible que el CLI se haya detenido. Revisa la terminal antes de reintentar.', + 'Could not join this local session.': 'No se pudo acceder a esta sesión local. Revisa la terminal y vuelve a vincular el navegador.', + 'Could not connect to the local setup session. Check the terminal and pair again.': 'No se pudo conectar con la sesión local. Revisa la terminal y vuelve a vincular el navegador.', + 'Pairing was rejected.': 'Se rechazó la vinculación. Comprueba el código en la terminal que inició el proceso.', + 'Invalid local pairing response.': 'La respuesta de vinculación local no es válida. Reinicia la configuración desde la terminal.', + 'Could not pair this browser.': 'No se pudo vincular este navegador. Revisa la terminal y vuelve a intentarlo.', + 'The request was rejected.': 'Se rechazó la petición local. Actualiza la página y revisa la terminal.', + 'Could not submit this answer.': 'No se pudo enviar la respuesta. Actualiza la página y vuelve a intentarlo.', + 'Cancellation failed.': 'No se pudo cancelar. Comprueba si ya se han empezado a aplicar cambios antes de cerrar.', + 'Takeover failed.': 'No se pudo tomar el control. Vuelve a introducir el código de la terminal inicial.', + 'Invalid local host or request context.': 'Se rechazó el contexto de la petición local. Abre la URL exacta que aparece en la terminal.', + 'Invalid request origin.': 'La petición llegó desde otro origen. Abre la URL local exacta de la terminal.', + 'JSON required.': 'El formato de la petición local no es válido. Actualiza la página y reinténtalo.', + 'Too many pairing attempts. Restart setup.': 'Demasiados intentos de vinculación fallidos. Reinicia la configuración desde la terminal.', + 'Too many pairing attempts. Wait 30 seconds and retry.': 'Demasiados intentos de vinculación fallidos. Espera 30 segundos y vuelve a probar el código; puedes seguir con el setup en la terminal.', + 'Incorrect pairing code. Check the terminal.': 'Código de vinculación incorrecto. Comprueba la terminal inicial.', + 'Incorrect pairing code. Check the launching output.': 'Código de vinculación incorrecto. Comprueba la salida de inicio.', + 'Pair this browser using the code printed by the CLI.': 'Vincula este navegador con el código mostrado por el CLI.', + 'This tab is read-only.': 'Esta pestaña es de solo lectura. Introduce otra vez el código para tomar el control.', + 'Invalid answer.': 'La respuesta no es válida. Revisa la pregunta actual e inténtalo de nuevo.', + 'Control moved to another tab.': 'El control pasó a otra pestaña. Esta ahora es de solo lectura.', + 'This question changed. Refresh the current state.': 'La pregunta cambió. Actualiza la página antes de volver a responder.', + 'This setup has already started applying or ended.': 'La configuración ya empezó a aplicarse o terminó. Revisa el resultado antes de reintentar.', + 'Only the controller can close a finished session.': 'Solo la pestaña que tiene el control puede cerrar esta sesión finalizada.', + 'Method not allowed.': 'Esta petición local no está permitida. Actualiza la página.', + 'Not found.': 'No se encontró la página local solicitada. Abre la URL de la terminal.', + 'Invalid local request.': 'La petición local no es válida. Actualiza la página y reinténtalo.', + 'Body too large.': 'La respuesta enviada es demasiado larga. Acórtala y reinténtalo.', + 'JSON object required.': 'El formato de la petición local no es válido. Actualiza la página y reinténtalo.', + 'Could not retry discovery.': 'No se pudieron actualizar las sugerencias de GitHub. Reinténtalo o introduce datos verificados manualmente.', + 'Could not return to the previous question.': 'No se pudo volver a la pregunta anterior. Actualiza la página y reinténtalo.', + 'Invalid question revision.': 'La pregunta cambió. Actualiza la página antes de volver atrás.', + 'No earlier question is available here.': 'No hay una pregunta anterior en esta etapa. Continúa o vuelve a revisar el plan.', + 'Read-only verification failed.': 'Falló la comprobación de solo lectura. Revisa la terminal y vuelve a intentarlo una vez.', + 'Read-only verification failed. Check the terminal.': 'Falló la comprobación de solo lectura. Revisa la terminal y vuelve a intentarlo una vez.', + 'Read-only verification is unavailable or already running.': 'La comprobación de solo lectura no está disponible o ya se está ejecutando. Revisa el resultado antes de reintentar.', +}; diff --git a/web/src/i18n/errors/fr.ts b/web/src/i18n/errors/fr.ts new file mode 100644 index 000000000..9b00aea18 --- /dev/null +++ b/web/src/i18n/errors/fr.ts @@ -0,0 +1,41 @@ +import type { sessionErrorsEn } from './en'; + +export const sessionErrorsFr: Readonly> = { + 'The local setup session is unavailable.': 'La session locale est indisponible. Vérifiez le terminal ; vous devrez peut-être relancer la configuration.', + 'Connection lost. The CLI may have stopped. Check the terminal before trying again.': 'Connexion perdue. Le CLI s’est peut-être arrêté. Vérifiez le terminal avant de réessayer.', + 'Could not join this local session.': 'Impossible de rejoindre cette session locale. Vérifiez le terminal et associez de nouveau le navigateur.', + 'Could not connect to the local setup session. Check the terminal and pair again.': 'Impossible de se connecter à la session locale. Vérifiez le terminal et associez de nouveau le navigateur.', + 'Pairing was rejected.': 'L’association a été refusée. Vérifiez le code dans le terminal de lancement.', + 'Invalid local pairing response.': 'La réponse d’association locale est invalide. Relancez la configuration depuis le terminal.', + 'Could not pair this browser.': 'Impossible d’associer ce navigateur. Vérifiez le terminal et réessayez.', + 'The request was rejected.': 'La demande locale a été refusée. Actualisez la page et vérifiez le terminal.', + 'Could not submit this answer.': 'Impossible d’envoyer cette réponse. Actualisez la page et réessayez.', + 'Cancellation failed.': 'Annulation impossible. Vérifiez si des changements ont déjà commencé avant de fermer.', + 'Takeover failed.': 'Impossible de prendre le contrôle. Ressaisissez le code du terminal de lancement.', + 'Invalid local host or request context.': 'Le contexte de la demande locale a été refusé. Ouvrez l’URL exacte affichée dans le terminal.', + 'Invalid request origin.': 'La demande vient d’une autre origine. Ouvrez l’URL locale exacte du terminal.', + 'JSON required.': 'Le format de la demande locale est invalide. Actualisez la page et réessayez.', + 'Too many pairing attempts. Restart setup.': 'Trop de tentatives d’association incorrectes. Relancez la configuration depuis le terminal.', + 'Too many pairing attempts. Wait 30 seconds and retry.': 'Trop de tentatives d’association incorrectes. Attendez 30 secondes puis ressaisissez le code ; la configuration par terminal reste disponible.', + 'Incorrect pairing code. Check the terminal.': 'Code d’association incorrect. Vérifiez le terminal de lancement.', + 'Incorrect pairing code. Check the launching output.': 'Code d’association incorrect. Vérifiez la sortie de lancement.', + 'Pair this browser using the code printed by the CLI.': 'Associez ce navigateur avec le code affiché par le CLI.', + 'This tab is read-only.': 'Cet onglet est en lecture seule. Ressaisissez le code pour prendre le contrôle.', + 'Invalid answer.': 'La réponse est invalide. Revoyez la question actuelle et réessayez.', + 'Control moved to another tab.': 'Le contrôle est passé à un autre onglet. Celui-ci est maintenant en lecture seule.', + 'This question changed. Refresh the current state.': 'La question a changé. Actualisez la page avant de répondre de nouveau.', + 'This setup has already started applying or ended.': 'La configuration est déjà en cours d’application ou terminée. Inspectez le résultat avant de réessayer.', + 'Only the controller can close a finished session.': 'Seul l’onglet qui détient le contrôle peut fermer cette session terminée.', + 'Method not allowed.': 'Cette demande locale n’est pas autorisée. Actualisez la page.', + 'Not found.': 'La page locale demandée est introuvable. Ouvrez l’URL affichée dans le terminal.', + 'Invalid local request.': 'La demande locale est invalide. Actualisez la page et réessayez.', + 'Body too large.': 'La réponse envoyée est trop longue. Raccourcissez-la et réessayez.', + 'JSON object required.': 'Le format de la demande locale est invalide. Actualisez la page et réessayez.', + 'Could not retry discovery.': 'Impossible d’actualiser les suggestions GitHub. Réessayez ou saisissez des données vérifiées manuellement.', + 'Could not return to the previous question.': 'Impossible de revenir à la question précédente. Actualisez la page et réessayez.', + 'Invalid question revision.': 'La question a changé. Actualisez la page avant de revenir en arrière.', + 'No earlier question is available here.': 'Il n’y a pas de question précédente à cette étape. Continuez ou revenez à la révision du plan.', + 'Read-only verification failed.': 'La vérification en lecture seule a échoué. Consultez le terminal et réessayez une fois.', + 'Read-only verification failed. Check the terminal.': 'La vérification en lecture seule a échoué. Consultez le terminal et réessayez une fois.', + 'Read-only verification is unavailable or already running.': 'La vérification en lecture seule est indisponible ou déjà en cours. Consultez le résultat avant de réessayer.', +}; diff --git a/web/src/i18n/errors/pt.ts b/web/src/i18n/errors/pt.ts new file mode 100644 index 000000000..76bc2d203 --- /dev/null +++ b/web/src/i18n/errors/pt.ts @@ -0,0 +1,41 @@ +import type { sessionErrorsEn } from './en'; + +export const sessionErrorsPt: Readonly> = { + 'The local setup session is unavailable.': 'A sessão local não está disponível. Verifique o terminal; poderá ter de reiniciar a configuração.', + 'Connection lost. The CLI may have stopped. Check the terminal before trying again.': 'Ligação perdida. O CLI pode ter parado. Verifique o terminal antes de tentar novamente.', + 'Could not join this local session.': 'Não foi possível entrar nesta sessão local. Verifique o terminal e volte a emparelhar o navegador.', + 'Could not connect to the local setup session. Check the terminal and pair again.': 'Não foi possível ligar à sessão local. Verifique o terminal e volte a emparelhar o navegador.', + 'Pairing was rejected.': 'O emparelhamento foi recusado. Confirme o código no terminal de lançamento.', + 'Invalid local pairing response.': 'A resposta de emparelhamento local é inválida. Reinicie a configuração a partir do terminal.', + 'Could not pair this browser.': 'Não foi possível emparelhar este navegador. Verifique o terminal e tente novamente.', + 'The request was rejected.': 'O pedido local foi recusado. Atualize a página e verifique o terminal.', + 'Could not submit this answer.': 'Não foi possível enviar a resposta. Atualize a página e tente novamente.', + 'Cancellation failed.': 'Não foi possível cancelar. Confirme se a aplicação das alterações já começou antes de fechar.', + 'Takeover failed.': 'Não foi possível assumir o controlo. Volte a introduzir o código do terminal de lançamento.', + 'Invalid local host or request context.': 'O contexto do pedido local foi recusado. Abra o URL exato apresentado no terminal.', + 'Invalid request origin.': 'O pedido veio de outra origem. Abra o URL local exato do terminal.', + 'JSON required.': 'O formato do pedido local é inválido. Atualize a página e tente novamente.', + 'Too many pairing attempts. Restart setup.': 'Demasiadas tentativas incorretas de emparelhamento. Reinicie a configuração no terminal.', + 'Too many pairing attempts. Wait 30 seconds and retry.': 'Demasiadas tentativas incorretas de emparelhamento. Aguarde 30 segundos e volte a introduzir o código; a configuração no terminal continua disponível.', + 'Incorrect pairing code. Check the terminal.': 'Código de emparelhamento incorreto. Verifique o terminal de lançamento.', + 'Incorrect pairing code. Check the launching output.': 'Código de emparelhamento incorreto. Verifique a saída de lançamento.', + 'Pair this browser using the code printed by the CLI.': 'Emparelhe este navegador com o código apresentado pelo CLI.', + 'This tab is read-only.': 'Este separador é apenas de leitura. Reintroduza o código para assumir o controlo.', + 'Invalid answer.': 'A resposta é inválida. Reveja a pergunta atual e tente novamente.', + 'Control moved to another tab.': 'O controlo passou para outro separador. Este é agora apenas de leitura.', + 'This question changed. Refresh the current state.': 'A pergunta mudou. Atualize a página antes de voltar a responder.', + 'This setup has already started applying or ended.': 'A configuração já começou a ser aplicada ou terminou. Inspecione o resultado antes de tentar novamente.', + 'Only the controller can close a finished session.': 'Só o separador que detém o controlo pode fechar esta sessão terminada.', + 'Method not allowed.': 'Este pedido local não é permitido. Atualize a página.', + 'Not found.': 'A página local pedida não foi encontrada. Abra o URL apresentado no terminal.', + 'Invalid local request.': 'O pedido local é inválido. Atualize a página e tente novamente.', + 'Body too large.': 'A resposta enviada é demasiado longa. Encurte-a e tente novamente.', + 'JSON object required.': 'O formato do pedido local é inválido. Atualize a página e tente novamente.', + 'Could not retry discovery.': 'Não foi possível atualizar as sugestões do GitHub. Tente novamente ou introduza dados verificados manualmente.', + 'Could not return to the previous question.': 'Não foi possível voltar à pergunta anterior. Atualize a página e tente novamente.', + 'Invalid question revision.': 'A pergunta mudou. Atualize a página antes de voltar atrás.', + 'No earlier question is available here.': 'Não há uma pergunta anterior nesta etapa. Continue ou volte à revisão do plano.', + 'Read-only verification failed.': 'A verificação só de leitura falhou. Consulte o terminal e tente mais uma vez.', + 'Read-only verification failed. Check the terminal.': 'A verificação só de leitura falhou. Consulte o terminal e tente mais uma vez.', + 'Read-only verification is unavailable or already running.': 'A verificação só de leitura não está disponível ou já está em curso. Consulte o resultado antes de tentar novamente.', +}; diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts new file mode 100644 index 000000000..b3006bf54 --- /dev/null +++ b/web/src/i18n/es.ts @@ -0,0 +1,106 @@ +import type { en } from './en'; + +export const es: Record = { + language: 'Idioma', english: 'English', spanish: 'Español', + setup: 'CONFIGURACIÓN', connecting: 'Conectando…', localSession: 'SESIÓN LOCAL', + progress: 'Progreso de la configuración', studio: 'ASISTENTE DE CONFIGURACIÓN', journey: 'TU RECORRIDO', + repository: 'Repositorio', choices: 'Opciones de configuración', setupPat: 'PAT de configuración', plan: 'Plan', botPat: 'PAT del bot y credenciales', apply: 'Aplicar', + localDesign: 'Local por diseño', localDesignBody: 'Esta página se ejecuta en tu ordenador. GitHub crea ambos PAT en sus propias pestañas.', + preparing: 'Preparando la configuración…', completeTitle: 'Configuración completada.', previewTitle: 'Vista previa completada.', cancelledTitle: 'Configuración cancelada.', blockedTitle: 'La configuración necesita atención.', + gettingReady: 'PREPARANDO', activeLede: 'Una decisión cada vez. Tus elecciones determinan los permisos, el plan y las credenciales que necesita este repositorio.', + resultLede: 'Revisa abajo el resultado y el siguiente paso. La terminal contiene más detalles técnicos.', + readOnly: 'Pestaña de solo lectura', readOnlyBody: 'Otra pestaña controla esta sesión. Puedes ver el progreso aquí o tomar el control explícitamente.', takeOver: 'Tomar el control en esta pestaña', + attention: 'Necesita atención', checked: 'Comprobado', pleaseNote: 'Ten en cuenta', progressUpdate: 'Actualización de progreso', + reviewPass: 'Revisando las opciones guardadas — pasada {pass}. Es la misma ejecución, no un reinicio.', cancelSetup: 'Cancelar configuración', + cancelConfirm: '¿Cancelar esta sesión local? Los PAT ya creados en GitHub seguirán existiendo.', + footerLocal: 'SOLO LOCALHOST', footerCloud: 'SIN CUENTA DE CONFIGURACIÓN EN LA NUBE', footerGithub: 'GITHUB EMITE LOS PAT', + currentDecision: 'DECISIÓN ACTUAL', session: 'SESIÓN', continue: 'Continuar', previousQuestion: 'Pregunta anterior', questionProgress: 'Pregunta {current} de {total} en este grupo · {overall} de {all} en total', yes: 'Sí', no: 'No', permissionPreview: 'VISTA PREVIA DE PERMISOS', + changeAnswersTitle: 'Cambiar respuestas', changeAnswersHelp: 'Vuelve a una sección sin perder las demás respuestas. Se comprobarán de nuevo el plan y los permisos necesarios del PAT.', changeSection: 'Cambiar {section}', + editCapabilities: 'Funciones', editRuntimes: 'Agentes', editModels: 'Modelos de agentes', editRoleModels: 'Modelos por tarea', editRepository: 'Comportamiento del repositorio', editDeployment: 'Releases y hotfixes', editBugbot: 'Bugbot', editApproval: 'Aprobación de PR', editProjects: 'Projects', editProvisioning: 'Aprovisionamiento', editStorage: 'Secrets y Variables', + suggested: 'Respuesta sugerida: {answer}. Podrás revisar las opciones antes de crear el PAT de configuración.', none: 'ninguna', + sourceGithub: 'Consultado en los metadatos autenticados de este repositorio en GitHub.', sourceConfig: 'Valor de tu configuración; GitHub no lo ha sustituido.', sourceDefault: 'Valor predeterminado del producto; no verificado en este repositorio.', + sourceLocal: 'Observado en este checkout local; confirma que la rama existe en GitHub antes de aplicar cambios.', + whyMatters: 'Por qué importa', whenApplies: 'Cuándo se aplica', whereConfigured: 'Dónde se configura', howToChoose: 'Cómo elegir', whyRecommendation: 'Por qué importa', example: 'Ejemplo', effect: 'Qué cambia', verify: 'Cómo comprobarlo', learnMore: 'Leer documentación de esta opción', + resultApplied: 'Tu configuración se ha aplicado', resultNoChanges: 'No se hicieron cambios', resultStopped: 'No se empezó a aplicar la configuración', resultPartial: 'Revisa los cambios parciales antes de reintentar', + resultCompleteBody: 'El PAT temporal de configuración no se revoca automáticamente. Elimínalo en GitHub tras comprobar el resultado. Conserva el PAT del bot hasta que rotes el Secret instalado.', + resultPartialBody: 'Puede haberse escrito el Secret del bot u otro recurso. Revisa GitHub y ejecuta copilot doctor --read-only antes de reemplazar o eliminar ese PAT.', + resultNoChangesBody: 'Copilot no empezó a aplicar cambios de configuración. Los PAT creados en GitHub siguen existiendo hasta que los elimines allí.', + whatHappened: 'Qué ha pasado', alreadyChanged: 'Qué ha cambiado', noChanges: 'No se iniciaron cambios de configuración en el repositorio ni en GitHub durante esta sesión.', + nextAction: 'Siguiente paso', reasonPermissions: 'Faltan permisos del PAT de configuración o no pudieron confirmarse.', nextPermissions: 'Comprueba los permisos mostrados, corrige el PAT en GitHub e inicia una nueva sesión.', + reasonStorage: 'No se pudo usar de forma segura el almacenamiento elegido de GitHub Actions.', nextStorage: 'Revisa el ámbito de Variables/Secrets y los recursos existentes; después, reinténtalo.', + reasonConfiguration: 'No se pudo validar la configuración elegida.', nextConfiguration: 'Revisa el detalle de validación en la terminal, corrige las opciones y reinténtalo.', + reasonExpired: 'La sesión local caducó antes de aplicar cambios.', nextExpired: 'Inicia una sesión nueva; la aprobación anterior no puede reutilizarse.', + reasonCancelled: 'La sesión se canceló antes de aplicar cambios.', nextCancelled: 'Inicia una nueva ejecución si todavía quieres configurar este repositorio.', + reasonUnknown: 'La configuración se detuvo antes de aplicar cambios. La web no pudo determinar la causa exacta.', nextUnknown: 'Consulta el último error de la terminal antes de reintentar; no supongas que se revocó ningún PAT.', + reasonProvider: 'GitHub u otro proveedor no completó la operación solicitada.', nextProvider: 'Usa la referencia de diagnóstico para consultar la terminal, comprueba la disponibilidad y el acceso al proveedor y revisa posibles cambios parciales antes de reintentar.', + reasonRateLimit: 'GitHub ha limitado temporalmente las solicitudes necesarias para la configuración.', nextRateLimit: 'Espera a que se restablezca el límite. Inspecciona los cambios completados antes de iniciar otra configuración.', + diagnosticReference: 'Referencia de diagnóstico', resourceReceipt: 'Registro de operaciones de configuración', effectCompleted: 'Marcada como completada', effectSkipped: 'Marcada como omitida', effectInspect: 'Resultado pendiente de inspección', + effectNotStarted: 'No iniciada', scopeLocal: 'Checkout local', scopeMixed: 'Repositorio y organización', + receiptFiles: 'Archivos de configuración', receiptSecrets: 'Secrets de GitHub Actions', receiptLabels: 'Etiquetas de issues', receiptIssueTypes: 'Tipos de issue', receiptVariables: 'Variables de GitHub Actions', receiptInitialTag: 'Etiqueta de versión inicial', + inspectPartial: 'Algunos cambios podrían estar activos. Revisa GitHub y el resultado de la terminal antes de reintentar.', + patSettings: 'Abrir ajustes de PAT en GitHub ↗', closeSession: 'Cerrar sesión local', + doctorHelp: 'Tras completar el setup, puedes comprobar aquí los recursos instalados sin lanzar Actions. También puedes ejecutar copilot doctor --read-only desde la raíz del repositorio con el PAT temporal y el mismo archivo --config no secreto, si lo usaste.', + doctorRun: 'Comprobar instalación (solo lectura)', doctorRunning: 'Comprobando los recursos instalados sin lanzar Actions…', + doctorPassed: 'La comprobación de solo lectura no encontró fallos.', doctorWarnings: 'La comprobación de solo lectura requiere atención.', + doctorFailed: 'La comprobación de solo lectura no terminó. Revisa la terminal o ejecuta el comando indicado abajo.', + doctorCounts: '{pass} correctas · {warn} avisos · {fail} fallos · {skipped} omitidas.', + doctorSecretLimit: 'Este modo no puede verificar los valores de Secrets.', + botRenewal: 'El PAT del bot permanece en el Secret de GitHub Actions seleccionado para futuras ejecuciones. Su fecha real de caducidad no se verifica aquí; anótala en GitHub y rota el Secret antes de que venza.', + working: 'Preparando el siguiente paso', workingBody: 'El proceso local comprueba tus respuestas y prepara la siguiente decisión. Mantén esta página abierta.', + repoFocus: 'Repositorio seleccionado', repoFocusBody: 'Todas las decisiones de esta sesión afectan solo a:', access: 'acceso', readOnlyCheck: 'Comprobación de acceso de solo lectura', provisionalGrants: 'Permisos provisionales de mínimo privilegio', + conditionalGrants: 'Los permisos condicionales dependen de tus elecciones y de GitHub. Se hará una auditoría final antes de cualquier cambio.', + permissionUnknown: 'Este permiso requiere revisión. Comprueba en la terminal el permiso exacto y su explicación técnica antes de continuar.', + permissionsFollow: 'Los permisos dependen de tus elecciones', permissionsFollowBody: 'Mostraremos los permisos exactos antes de crear cada PAT. Abrir esta página no crea nada.', + files: 'Archivos', workflows: 'Workflows', variables: 'Variables', secretNames: 'Nombres de Secrets', + planBody: 'Revisa exactamente lo que podría cambiar. Después se pedirán el PAT del bot y las demás credenciales.', + planChoices: 'Tus decisiones principales', planEnabledCapabilities: 'Funciones activadas', planBranchRoles: 'Rama de producción / desarrollo', planApprovalMode: 'Aprobación de pull requests', planVariableScope: 'Ámbito de Variables', planSecretScope: 'Ámbito de Secrets', planInitialTag: 'Crear etiqueta inicial', + planAgentRouting: 'Agente y modelo por tarea', planIssueWorkflows: 'Flujos de issues', planTrustedChecks: 'Productores de CI fiables', planCoverage: 'Prueba de cobertura', planProjectStatuses: 'Transiciones de Status en Projects', planIssueResources: 'Etiquetas y tipos de issue', planIssueResourcesValue: 'Se comprobarán o crearán al aplicar', + planProducerAttested: 'Identidad de CI y paso obligatorio comprobados por ti', planCoverageThreshold: 'Cobertura mínima de líneas modificadas', planCoverageReporter: 'Workflow que publica el artefacto', planReporterAttested: 'Generador de cobertura comprobado por ti', + planAdvancedDefaults: 'El plan también incluye valores predeterminados de ajustes que no cambiaste. Usa Cambiar más abajo para revisar cualquier sección antes de aprobar.', + planUnknownWarning: 'Hay un aviso adicional del plan que no se puede mostrar aquí. Léelo en la terminal antes de aprobar.', + planBasicDefaultsIntro: 'El modo básico conservó estos ajustes avanzados. Abre una sección más abajo para revisarlos o cambiarlos:', + scopeRepository: 'Repositorio', scopeOrganization: 'Organización', scopeDisabled: 'Sin aprovisionar', approvalOff: 'Desactivada', approvalRecommend: 'Solo recomendaciones', approvalGuarded: 'Aprobación protegida', + beforeContinue: 'Antes de continuar', stopHere: 'Detener aquí', approvePlan: 'Aprobar este plan', + githubLink: 'Abrir enlace de GitHub ↗', githubForm: 'Abrir el formulario oficial de PAT de GitHub', + githubFormHelp: 'Comprueba la cuenta activa, elige Only select repositories y selecciona este repositorio. GitHub gestiona el 2FA y crea el PAT.', + pasteHere: 'Pega aquí el valor', yourAnswer: 'Tu respuesta', hiddenAfter: 'Oculto tras enviarlo', typeAnswer: 'Escribe tu respuesta', + secretHelp: 'Se envía solo a este proceso local. No volverá a mostrarse ni se guardará en el almacenamiento del navegador.', + pairTitle: 'Vincula este navegador', pairLabel: 'Código de vinculación de la terminal', pairPlaceholder: '16 caracteres hexadecimales', + pairBody: 'Busca el código de 16 caracteres en la terminal donde ejecutaste copilot setup --web. Introdúcelo para ver o controlar la sesión. No aparece en la URL ni se almacena al cerrar la página.', + pairHelp: 'Mantén el código en privado. Tras actualizar la página, introdúcelo de nuevo para reconectar.', pairButton: 'Conectar a la configuración local', privateSession: 'SESIÓN LOCAL PRIVADA', + theme: 'Tema de color', themeAuto: 'Sistema', themeSystem: 'Seguir tema del sistema', themeLight: 'Tema claro', themeDark: 'Tema oscuro', + selectOne: 'Selecciona una opción', ciRun: 'Abrir ejecución de CI en GitHub ↗', manualCheck: '¿No aparece el check? Introduce nombre|ID de App|workflow exactos, separados por punto y coma.', + checksObserved: 'Se encontraron jobs recientes de CI. Abre cada ejecución y comprueba el job, la App y el paso obligatorio de cobertura antes de confiar en él.', + checksNoRecent: 'No hay ejecuciones recientes de workflows de pull request. Ejecuta tu CI habitual en un PR real o introduce manualmente un productor exacto.', + checksNoVerifiable: 'Hay ejecuciones recientes, pero ningún job pudo vincularse a un Check Run y una App concretos. Revisa GitHub o introduce el productor manualmente.', + checksDenied: 'GitHub no permitió consultar los checks. Concede Actions: read y Checks: read al PAT de configuración, o introduce el productor manualmente.', + checksUnavailable: 'No se pudo completar la consulta de CI. Esto no significa que el repositorio no tenga checks. Reintenta o introduce el productor manualmente.', + projectsObserved: 'Estos Projects existentes pertenecen al dueño del repositorio. Selecciona solo los que deba actualizar la automatización.', + projectsEmpty: 'Esta consulta limitada no devolvió Projects abiertos y accesibles; eso no demuestra que no existan. Comprueba el acceso o introduce un número verificado.', + projectsDenied: 'GitHub no permitió consultar Projects. Comprueba Projects: read de la organización en el PAT o introduce los números manualmente.', + projectsUnavailable: 'No se pudo consultar Projects. Eso no demuestra que no existan. Introduce un número verificado o reintenta.', + projectsUnsupported: 'GitHub no permite listar Projects personales con un PAT de permisos precisos mediante esta API. Introduce el número de la URL de un Project existente.', + discoveryTruncated: 'Solo se inspeccionó una muestra limitada de Projects accesibles o checks recientes. Usa la entrada manual si falta uno.', + checksDiscoveryScope: 'Alcance: hasta 20 ejecuciones recientes de workflows de PR; se inspeccionan como máximo 15 ejecuciones y 100 checks por commit.', + projectsDiscoveryScope: 'Alcance: hasta 30 Projects abiertos y accesibles de la organización en dos páginas; se inspeccionan hasta 100 campos por Project. Se excluyen los Projects cerrados.', + retryDiscovery: 'Reintentar búsqueda en GitHub', retryRemaining: 'Quedan {count} reintentos de solo lectura. Tus respuestas se conservan.', + retryExhausted: 'No quedan reintentos. Consulta GitHub e introduce manualmente lo que falte.', + observationTimeUnknown: 'fecha de observación no disponible', branchRequirementUnknown: 'Obligatorio según la regla de rama: no comprobado', + branchRequirementObserved: 'Obligatorio en {branch} por un ruleset activo para este check y esta App exactos.', ciRule: 'Abrir ruleset del check obligatorio ↗', + projectSharedStatus: 'Todos los Projects elegidos deben compartir cada valor Status. Este setup no asigna valores diferentes por Project.', + fixedWorkflowEnabled: 'Tu configuración fija features.{kind}=true. Mantén {kind} seleccionado; cambia --config o los flags si quieres modificarlo.', + fixedWorkflowDisabled: 'Tu configuración fija features.{kind}=false. Deja {kind} sin seleccionar; cambia --config o los flags si quieres modificarlo.', + fixedIssuesRequired: 'Tu configuración activa expresamente release o hotfix. Mantén Issues activado o cambia antes --config o los flags.', + projectSelectionNotObserved: 'Se conservan los números de Project elegidos antes, aunque ya no figuren en esta consulta. Compruébalos en GitHub o quítalos.', + removeSelection: 'Quitar selección', + projectTransitionIssueCreated: 'Issue nuevo', projectTransitionPullRequestCreated: 'Pull request nuevo', + projectTransitionIssueInProgress: 'Issue en curso', projectTransitionPullRequestInProgress: 'Pull request en curso', + projectUrl: 'Abrir Project en GitHub ↗', projectManual: '¿Falta un Project? Introduce su número positivo o URL exacta de GitHub. No se aceptan IDs PVT_.', + projectStatusUnavailable: 'No se pudieron verificar las opciones Status de todos los Projects elegidos. Comprueba cada Project en GitHub e introduce el valor exacto.', + projectStatusIncompatible: 'Los Projects elegidos no comparten valores Status. Elige Projects compatibles antes de continuar.', + producerName: 'Nombre del check/job', producerAppId: 'ID de la App de GitHub', producerWorkflow: 'Nombre del workflow', producerAdd: 'Añadir check exacto', producerRemove: 'Quitar check', + producerManualHelp: 'Usa la identidad exacta de GitHub. Añadirla no acredita que exija la cobertura.', + producerManualInvalid: 'Indica nombre, ID numérico positivo de App y workflow. No uses | ni ; en los nombres.', + translationPreviewTitle: 'Revisión de la traducción en curso', translationPreviewBody: 'Las preguntas propias de la configuración ya están traducidas. Algunos diagnósticos dinámicos de GitHub o del proveedor aún pueden aparecer en inglés mientras termina la revisión. Cambiar de idioma no modifica tus respuestas.', + unknownLocalError: 'Se ha producido un error inesperado en la configuración local. Consulta la terminal y actualiza la página o reinicia la configuración.', +}; diff --git a/web/src/i18n/featureNames.ts b/web/src/i18n/featureNames.ts new file mode 100644 index 000000000..f7e9f5c7b --- /dev/null +++ b/web/src/i18n/featureNames.ts @@ -0,0 +1,21 @@ +import type { SetupFeature } from '../../../src/application/contracts/web_setup_view'; +import type { SetupLocale } from './catalog'; + +export const featureNames: Readonly>>> = { + en: { issues: 'Issues', pullRequests: 'Pull requests', commits: 'Commits', issueComments: 'Issue comments', + pullRequestComments: 'Pull-request comments', release: 'Releases', hotfix: 'Hotfixes', agentProvisioning: 'Agent provisioning', + credentialHealth: 'Credential health', inactiveIssueClosure: 'Inactive issue closure', issueTemplates: 'Issue templates', pullRequestTemplate: 'Pull-request template' }, + es: { issues: 'Issues', pullRequests: 'Pull requests', commits: 'Commits', issueComments: 'Comentarios en issues', + pullRequestComments: 'Comentarios en pull requests', release: 'Releases', hotfix: 'Correcciones urgentes', agentProvisioning: 'Instalación de agentes', + credentialHealth: 'Estado de credenciales', inactiveIssueClosure: 'Cierre de issues inactivos', issueTemplates: 'Plantillas de issues', pullRequestTemplate: 'Plantilla de pull requests' }, + fr: { issues: 'Tickets', pullRequests: 'Pull requests', commits: 'Commits', issueComments: 'Commentaires des tickets', + pullRequestComments: 'Commentaires des pull requests', release: 'Versions', hotfix: 'Correctifs urgents', agentProvisioning: 'Installation des agents', + credentialHealth: 'État des identifiants', inactiveIssueClosure: 'Fermeture des tickets inactifs', issueTemplates: 'Modèles de tickets', pullRequestTemplate: 'Modèle de pull request' }, + pt: { issues: 'Questões', pullRequests: 'Pull requests', commits: 'Commits', issueComments: 'Comentários nas questões', + pullRequestComments: 'Comentários nas pull requests', release: 'Versões', hotfix: 'Correções urgentes', agentProvisioning: 'Instalação de agentes', + credentialHealth: 'Estado das credenciais', inactiveIssueClosure: 'Fecho de questões inativas', issueTemplates: 'Modelos de questões', pullRequestTemplate: 'Modelo de pull request' }, +}; + +export function featureName(value: string, locale: SetupLocale): string { + return featureNames[locale][value as SetupFeature] ?? value; +} diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts new file mode 100644 index 000000000..a3b593454 --- /dev/null +++ b/web/src/i18n/fr.ts @@ -0,0 +1,106 @@ +import type { SetupMessageKey } from './catalog'; + +export const fr: Record = { + language: 'Langue', english: 'Anglais', spanish: 'Espagnol', + setup: 'CONFIGURATION', connecting: 'Connexion…', localSession: 'SESSION LOCALE', + progress: 'Progression', studio: 'ASSISTANT DE CONFIGURATION', journey: 'VOTRE PARCOURS', + repository: 'Dépôt', choices: 'Choix', setupPat: 'PAT de configuration', plan: 'Plan', botPat: 'PAT du bot et identifiants', apply: 'Appliquer', + localDesign: 'Local par conception', localDesignBody: 'Cette page fonctionne sur votre ordinateur. GitHub crée les deux PAT dans ses propres onglets.', + preparing: 'Préparation…', completeTitle: 'Configuration terminée.', previewTitle: 'Aperçu terminé.', cancelledTitle: 'Configuration annulée.', blockedTitle: 'Une intervention est nécessaire.', + gettingReady: 'PRÉPARATION', activeLede: 'Une décision à la fois. Vos choix déterminent les droits, le plan et les identifiants nécessaires.', + resultLede: 'Consultez le résultat et la prochaine étape ci-dessous. Le terminal fournit les détails techniques.', + readOnly: 'Onglet en lecture seule', readOnlyBody: 'Un autre onglet contrôle cette session. Vous pouvez suivre la progression ou prendre le contrôle.', takeOver: 'Prendre le contrôle', + attention: 'Action nécessaire', checked: 'Vérifié', pleaseNote: 'À noter', progressUpdate: 'Mise à jour', + reviewPass: 'Révision des choix enregistrés — passage {pass}. C’est la même session, pas un redémarrage.', cancelSetup: 'Annuler', + cancelConfirm: 'Annuler cette session locale ? Les PAT créés sur GitHub continueront d’exister.', + footerLocal: 'LOCALHOST UNIQUEMENT', footerCloud: 'AUCUN COMPTE CLOUD', footerGithub: 'GITHUB ÉMET LES PAT', + currentDecision: 'DÉCISION ACTUELLE', session: 'SESSION', continue: 'Continuer', previousQuestion: 'Question précédente', questionProgress: 'Question {current} sur {total} dans ce groupe · {overall} sur {all} au total', yes: 'Oui', no: 'Non', permissionPreview: 'APERÇU DES DROITS', + changeAnswersTitle: 'Modifier vos réponses', changeAnswersHelp: 'Revenez à une section sans perdre les autres réponses. Le plan et les droits PAT requis seront vérifiés à nouveau.', changeSection: 'Modifier {section}', + editCapabilities: 'Fonctionnalités', editRuntimes: 'Agents', editModels: 'Modèles des agents', editRoleModels: 'Modèles par tâche', editRepository: 'Comportement du dépôt', editDeployment: 'Releases et correctifs', editBugbot: 'Bugbot', editApproval: 'Approbation des PR', editProjects: 'Projects', editProvisioning: 'Provisionnement', editStorage: 'Secrets et Variables', + suggested: 'Réponse suggérée : {answer}. Vous pourrez revoir vos choix avant de créer le PAT.', none: 'aucune', + sourceGithub: 'Observé dans les métadonnées authentifiées de ce dépôt GitHub.', sourceConfig: 'Fourni par votre configuration ; GitHub ne l’a pas remplacé.', sourceDefault: 'Valeur par défaut du produit ; non vérifiée dans ce dépôt.', + sourceLocal: 'Observé dans ce dossier local ; vérifiez que cette branche existe sur GitHub avant d’appliquer les changements.', + whyMatters: 'Pourquoi c’est important', whenApplies: 'Quand cela s’applique', whereConfigured: 'Où se fait le réglage', howToChoose: 'Comment choisir', whyRecommendation: 'Pourquoi c’est important', example: 'Exemple', effect: 'Ce qui change', verify: 'Comment vérifier', learnMore: 'Lire la documentation de ce réglage', + resultApplied: 'Configuration appliquée', resultNoChanges: 'Aucun changement effectué', resultStopped: 'Aucun changement commencé', resultPartial: 'Vérifiez les changements partiels', + resultCompleteBody: 'Le PAT temporaire n’est pas révoqué automatiquement. Supprimez-le sur GitHub après vérification. Gardez le PAT du bot jusqu’à rotation du Secret.', + resultPartialBody: 'Un Secret ou une autre ressource a peut-être été modifié. Vérifiez GitHub et exécutez copilot doctor --read-only avant de remplacer ou supprimer un PAT.', + resultNoChangesBody: 'Copilot n’a pas commencé à appliquer la configuration. Les PAT créés sur GitHub restent actifs jusqu’à leur suppression là-bas.', + whatHappened: 'Ce qui s’est passé', alreadyChanged: 'Déjà modifié', noChanges: 'Aucun changement du dépôt ou de GitHub n’a commencé pendant cette session.', + nextAction: 'Prochaine étape', reasonPermissions: 'Les droits nécessaires du PAT de configuration manquent ou n’ont pas pu être confirmés.', nextPermissions: 'Vérifiez les droits affichés, corrigez le PAT sur GitHub, puis recommencez.', + reasonStorage: 'Le stockage GitHub Actions choisi ne peut pas être utilisé en sécurité.', nextStorage: 'Vérifiez la portée des Variables/Secrets et les ressources existantes, puis réessayez.', + reasonConfiguration: 'La configuration choisie n’a pas pu être validée.', nextConfiguration: 'Lisez les détails du terminal, corrigez les choix et réessayez.', + reasonExpired: 'La session locale a expiré avant toute modification.', nextExpired: 'Démarrez une nouvelle session ; l’ancienne approbation ne peut pas être réutilisée.', + reasonCancelled: 'La configuration a été annulée avant toute modification.', nextCancelled: 'Démarrez une nouvelle session si vous souhaitez encore configurer ce dépôt.', + reasonUnknown: 'La configuration s’est arrêtée avant l’application ; la cause exacte est inconnue.', nextUnknown: 'Consultez la dernière erreur du terminal avant de réessayer. Ne supposez pas qu’un PAT a été révoqué.', + reasonProvider: 'GitHub ou un autre fournisseur n’a pas terminé l’opération demandée.', nextProvider: 'Utilisez la référence de diagnostic pour consulter le terminal, vérifiez la disponibilité et les accès, puis examinez tout changement partiel avant de réessayer.', + reasonRateLimit: 'GitHub a temporairement limité les requêtes nécessaires à la configuration.', nextRateLimit: 'Attendez la réinitialisation de la limite. Examinez les changements effectués avant de recommencer.', + diagnosticReference: 'Référence de diagnostic', resourceReceipt: 'Reçu des opérations de configuration', effectCompleted: 'Signalée comme terminée', effectSkipped: 'Signalée comme ignorée', effectInspect: 'Résultat à vérifier', + effectNotStarted: 'Non commencée', scopeLocal: 'Dossier local', scopeMixed: 'Dépôt et organisation', + receiptFiles: 'Fichiers de configuration', receiptSecrets: 'Secrets GitHub Actions', receiptLabels: 'Étiquettes des tickets', receiptIssueTypes: 'Types de ticket', receiptVariables: 'Variables GitHub Actions', receiptInitialTag: 'Première étiquette de version', + inspectPartial: 'Des changements peuvent déjà être actifs. Vérifiez GitHub et le terminal avant de réessayer.', + patSettings: 'Ouvrir les paramètres PAT GitHub ↗', closeSession: 'Fermer la session locale', + doctorHelp: 'Après la configuration, vérifiez ici les ressources installées sans déclencher d’Actions. Vous pouvez aussi exécuter copilot doctor --read-only à la racine du dépôt avec le PAT temporaire et le même fichier --config non secret, le cas échéant.', + doctorRun: 'Vérifier l’installation (lecture seule)', doctorRunning: 'Vérification des ressources installées sans déclencher d’Actions…', + doctorPassed: 'La vérification en lecture seule n’a trouvé aucun échec.', doctorWarnings: 'La vérification en lecture seule demande une attention particulière.', + doctorFailed: 'La vérification en lecture seule ne s’est pas terminée. Consultez le terminal ou exécutez la commande ci-dessous.', + doctorCounts: '{pass} réussis · {warn} avertissements · {fail} échecs · {skipped} ignorés.', + doctorSecretLimit: 'Ce mode ne peut pas vérifier les valeurs des Secrets.', + botRenewal: 'Le PAT du bot reste dans le secret GitHub Actions choisi pour les futures exécutions. Sa date d’expiration réelle n’est pas vérifiée ici ; notez-la sur GitHub et remplacez le secret avant cette date.', + working: 'Préparation de l’étape suivante', workingBody: 'Le processus local vérifie vos réponses. Gardez cette page ouverte.', + repoFocus: 'Dépôt concerné', repoFocusBody: 'Toutes les décisions de cette session concernent uniquement :', access: 'accès', readOnlyCheck: 'Vérification en lecture seule', provisionalGrants: 'Droits provisoires minimaux', + conditionalGrants: 'Les droits conditionnels dépendent de vos choix et de GitHub. Un contrôle final précède toute modification.', + permissionUnknown: 'Ce droit exige une vérification. Consultez le droit exact et son explication technique dans le terminal avant de continuer.', + permissionsFollow: 'Les droits suivent vos choix', permissionsFollowBody: 'Les droits exacts sont affichés avant chaque PAT. Ouvrir cette page ne crée rien.', + files: 'Fichiers', workflows: 'Workflows', variables: 'Variables', secretNames: 'Noms des Secrets', + planBody: 'Vérifiez précisément ce qui pourrait changer. Le PAT du bot et les autres identifiants seront demandés ensuite.', + planChoices: 'Vos décisions principales', planEnabledCapabilities: 'Fonctionnalités activées', planBranchRoles: 'Branche de production / développement', planApprovalMode: 'Approbation des pull requests', planVariableScope: 'Portée des variables', planSecretScope: 'Portée des secrets', planInitialTag: 'Créer la première étiquette', + planAgentRouting: 'Agent et modèle par tâche', planIssueWorkflows: 'Workflows de tickets', planTrustedChecks: 'Producteurs CI de confiance', planCoverage: 'Preuve de couverture', planProjectStatuses: 'Transitions Status des Projects', planIssueResources: 'Étiquettes et types de ticket', planIssueResourcesValue: 'Vérifiés ou créés lors de l’application', + planProducerAttested: 'Identité CI et étape obligatoire vérifiées par vous', planCoverageThreshold: 'Couverture minimale des lignes modifiées', planCoverageReporter: 'Workflow publiant l’artefact', planReporterAttested: 'Producteur du rapport de couverture vérifié par vous', + planAdvancedDefaults: 'Le plan comprend aussi les valeurs par défaut des réglages non modifiés. Utilisez Modifier ci-dessous pour examiner une section avant de valider.', + planUnknownWarning: 'Un avertissement supplémentaire du plan ne peut pas être affiché ici. Lisez-le dans le terminal avant de valider.', + planBasicDefaultsIntro: 'Le parcours de base a conservé ces réglages avancés. Ouvrez une section ci-dessous pour les vérifier ou les modifier :', + scopeRepository: 'Dépôt', scopeOrganization: 'Organisation', scopeDisabled: 'Non provisionné', approvalOff: 'Désactivée', approvalRecommend: 'Recommandation uniquement', approvalGuarded: 'Approbation protégée', + beforeContinue: 'Avant de continuer', stopHere: 'Arrêter ici', approvePlan: 'Approuver ce plan', + githubLink: 'Ouvrir le lien GitHub ↗', githubForm: 'Ouvrir le formulaire PAT officiel de GitHub', + githubFormHelp: 'Vérifiez le compte connecté, choisissez Only select repositories et ce dépôt. GitHub gère la 2FA et crée le PAT.', + pasteHere: 'Collez la valeur ici', yourAnswer: 'Votre réponse', hiddenAfter: 'Masquée après envoi', typeAnswer: 'Saisissez votre réponse', + secretHelp: 'Envoyé uniquement au processus local. Ni réaffiché ni enregistré dans le navigateur.', + pairTitle: 'Associer ce navigateur', pairLabel: 'Code du terminal', pairPlaceholder: '16 caractères hexadécimaux', + pairBody: 'Trouvez le code à 16 caractères dans le terminal qui a lancé copilot setup --web. Il ne figure pas dans l’URL et n’est pas conservé après fermeture.', + pairHelp: 'Gardez ce code secret. Après actualisation, saisissez-le de nouveau.', pairButton: 'Se connecter à la configuration locale', privateSession: 'SESSION LOCALE PRIVÉE', + theme: 'Thème', themeAuto: 'Auto', themeSystem: 'Suivre le système', themeLight: 'Thème clair', themeDark: 'Thème sombre', + selectOne: 'Choisissez une option', ciRun: 'Voir l’exécution CI sur GitHub ↗', manualCheck: 'Check absent ? Saisissez nom|ID App|workflow exacts, séparés par des points-virgules.', + checksObserved: 'Des jobs CI récents ont été trouvés. Ouvrez chaque exécution et vérifiez le job, l’App et l’étape obligatoire de couverture avant de lui faire confiance.', + checksNoRecent: 'Aucune exécution récente de workflow de pull request. Lancez le CI habituel sur une vraie PR ou saisissez un producteur exact.', + checksNoVerifiable: 'Des exécutions récentes existent, mais aucun job ne correspond à une identité exacte de Check Run et d’App. Vérifiez GitHub ou saisissez-la.', + checksDenied: 'GitHub a refusé la découverte CI. Accordez Actions: read et Checks: read au PAT de configuration, ou saisissez un producteur exact.', + checksUnavailable: 'La découverte CI a échoué. Cela ne prouve pas l’absence de checks. Réessayez ou saisissez un producteur exact.', + projectsObserved: 'Ces Projects existants appartiennent au propriétaire du dépôt. Ne choisissez que ceux que l’automatisation doit modifier.', + projectsEmpty: 'Cette requête GitHub limitée n’a renvoyé aucun Project ouvert et accessible ; elle ne prouve pas leur absence. Vérifiez l’accès ou saisissez un numéro vérifié.', + projectsDenied: 'GitHub a refusé la découverte des Projects. Vérifiez Projects: read au niveau organisation sur le PAT, ou saisissez les numéros.', + projectsUnavailable: 'La découverte des Projects a échoué. Cela ne prouve pas leur absence. Saisissez un numéro vérifié ou réessayez.', + projectsUnsupported: 'Cette API GitHub ne liste pas les Projects personnels avec un PAT à permissions fines. Saisissez le numéro figurant dans l’URL.', + discoveryTruncated: 'Seul un échantillon limité de Projects accessibles ou checks récents a été inspecté. Saisissez manuellement un élément absent.', + checksDiscoveryScope: 'Périmètre : jusqu’à 20 exécutions récentes de workflows de PR ; au plus 15 exécutions et 100 checks par commit sont inspectés.', + projectsDiscoveryScope: 'Périmètre : au plus 30 Projects ouverts et accessibles de l’organisation sur deux pages ; jusqu’à 100 champs sont inspectés par Project. Les Projects fermés sont exclus.', + retryDiscovery: 'Relancer la recherche GitHub', retryRemaining: 'Il reste {count} essais en lecture seule. Vos réponses sont conservées.', + retryExhausted: 'Plus aucun essai disponible. Vérifiez GitHub et saisissez manuellement tout élément manquant.', + observationTimeUnknown: 'date d’observation indisponible', branchRequirementUnknown: 'Exigé par la règle de branche : non vérifié', + branchRequirementObserved: 'Exigé sur {branch} par un ruleset actif pour ce contrôle et cette application précis.', ciRule: 'Ouvrir le ruleset du contrôle obligatoire ↗', + projectSharedStatus: 'Tous les Projects choisis doivent partager chaque valeur Status. Cette configuration ne définit pas de valeurs différentes par Project.', + fixedWorkflowEnabled: 'Votre configuration fixe features.{kind}=true. Gardez {kind} sélectionné ; modifiez --config ou les options pour changer ce choix.', + fixedWorkflowDisabled: 'Votre configuration fixe features.{kind}=false. Ne sélectionnez pas {kind} ; modifiez --config ou les options pour changer ce choix.', + fixedIssuesRequired: 'Votre configuration active explicitement release ou hotfix. Gardez Issues activé ou modifiez d’abord --config ou les options.', + projectSelectionNotObserved: 'Les numéros de Projects choisis auparavant sont conservés, mais n’apparaissent plus dans ce résultat GitHub. Vérifiez-les sur GitHub ou retirez-les.', + removeSelection: 'Retirer la sélection', + projectTransitionIssueCreated: 'Nouveau ticket', projectTransitionPullRequestCreated: 'Nouvelle pull request', + projectTransitionIssueInProgress: 'Ticket en cours', projectTransitionPullRequestInProgress: 'Pull request en cours', + projectUrl: 'Ouvrir le Project sur GitHub ↗', projectManual: 'Project absent ? Saisissez son numéro positif ou son URL GitHub exacte. Les ID PVT_ sont refusés.', + projectStatusUnavailable: 'Les options Status n’ont pas pu être vérifiées pour tous les Projects. Vérifiez-les sur GitHub et saisissez la valeur exacte.', + projectStatusIncompatible: 'Les Projects choisis ne partagent aucun Status. Choisissez des Projects compatibles avant de continuer.', + producerName: 'Nom du check/job', producerAppId: 'ID de l’App GitHub source', producerWorkflow: 'Nom du workflow', producerAdd: 'Ajouter le check exact', producerRemove: 'Retirer le check', + producerManualHelp: 'Utilisez l’identité exacte affichée sur GitHub. L’ajouter ne prouve pas que la couverture est imposée.', + producerManualInvalid: 'Indiquez le nom, un ID numérique positif d’App et le workflow. N’utilisez ni | ni ; dans les noms.', + translationPreviewTitle: 'Révision de la traduction en cours', translationPreviewBody: 'Les questions propres à la configuration sont traduites. Certains diagnostics dynamiques de GitHub ou du fournisseur peuvent encore apparaître en anglais pendant la révision. Changer de langue ne modifie pas vos réponses.', + unknownLocalError: 'Une erreur inattendue est survenue pendant la configuration locale. Consultez le terminal, puis actualisez la page ou relancez la configuration.', +}; diff --git a/web/src/i18n/localeStore.ts b/web/src/i18n/localeStore.ts new file mode 100644 index 000000000..2f07df970 --- /dev/null +++ b/web/src/i18n/localeStore.ts @@ -0,0 +1,4 @@ +import { writable } from 'svelte/store'; +import type { SetupLocale } from './catalog'; + +export const setupLocale = writable('en'); diff --git a/web/src/i18n/messageCopy.ts b/web/src/i18n/messageCopy.ts new file mode 100644 index 000000000..c58cfc0aa --- /dev/null +++ b/web/src/i18n/messageCopy.ts @@ -0,0 +1,34 @@ +import type { WebSetupView } from '../../../src/application/contracts/web_setup_view'; +import { tr, type SetupLocale } from './catalog'; +import { messageCopyEn } from './messages/en'; +import { messageCopyEs } from './messages/es'; +import { messageCopyFr } from './messages/fr'; +import { messageCopyPt } from './messages/pt'; +import { questionOptionLabel } from './questionOptions'; + +export const messageCopyCatalogs: Readonly> = { + en: messageCopyEn, es: messageCopyEs, fr: messageCopyFr, pt: messageCopyPt, +}; + +export function localizedMessage(message: NonNullable, locale: SetupLocale): string { + if (!message.copyId) return locale === 'en' ? message.text : tr('unknownLocalError', locale); + const template = messageCopyCatalogs[locale][message.copyId]; + const values = message.copyValues ?? {}; + const localizedValues = message.copyId === 'permission.preview' + ? { + ...values, + issues: (values.issues ?? '').split('|').map(value => questionOptionLabel('issueWorkflows.enabled', value, locale)).join(', '), + approval: questionOptionLabel('pullRequestApproval.mode', values.approval ?? '', locale), + secrets: questionOptionLabel('storage.secrets.defaultScope', values.secrets ?? '', locale), + variables: questionOptionLabel('storage.variables.defaultScope', values.variables ?? '', locale), + projects: questionOptionLabel('projects.ids', values.projects ?? '', locale), + } + : values; + const summary = template.replace(/\{([a-zA-Z]\w*)\}/gu, (_, key: string) => localizedValues[key] ?? ''); + if (message.copyId !== 'credential.checks' || !message.credentialChecks?.length) return summary; + const checks = message.credentialChecks.map(check => { + const statusId = `credential.status.${check.status}` as const; + return `${check.name}: ${messageCopyCatalogs[locale][statusId]}`; + }); + return `${summary}\n${checks.join('\n')}`; +} diff --git a/web/src/i18n/messages/en.ts b/web/src/i18n/messages/en.ts new file mode 100644 index 000000000..8a562d3ed --- /dev/null +++ b/web/src/i18n/messages/en.ts @@ -0,0 +1,39 @@ +import type { WebSetupMessageCopyId } from '../../../../src/application/contracts/web_setup_view'; + +export const messageCopyEn: Readonly> = { + 'session.controlMoved': 'Control moved to this tab. The previous tab is now read-only.', + 'session.cancelled': 'Setup stopped before applying further changes. Any PAT created on GitHub still exists until you delete it there.', + 'plan.ready': 'Plan ready: {files} files, {variables} Variables and {secrets} Secret names. Review it before continuing.', + 'permission.preview': 'Permission preview: issue workflows {issues}; PR approval {approval}; Secrets {secrets}; Variables {variables}; Projects {projects}. Review the exact grants before creating the setup PAT.', + 'setupPat.corrected.bootstrap': 'The setup PAT could not access the repository. No setup changes started. Required grants: {grants}. Create a corrected PAT using the updated GitHub link.', + 'setupPat.corrected.final': 'Required setup PAT permissions changed after inspection. No setup changes started. New grants: {grants}. Create a corrected PAT using the updated GitHub link.', + 'setupPat.cleanup': 'Delete the temporary setup PAT in GitHub Settings after this run. Closing Copilot does not revoke it.', + 'botPat.separation': 'The bot PAT is separate from your setup PAT. These credentials become GitHub Actions Secrets: {names}. Existing Secret values cannot be read back. Re-enter an existing bot PAT so its grants can be checked. No credential-health workflow runs before Apply.', + 'credential.checks': 'Credential checks finished for {count} items. Each result is shown below. The terminal has technical details; an existing Secret value cannot be read back.', + 'credential.status.valid': 'Valid', 'credential.status.invalid': 'Invalid', 'credential.status.missing': 'Missing', + 'credential.status.unverifiable': 'Cannot be verified without a new value', 'credential.status.not_required': 'Not required', + 'validation.producers': 'Select 1–8 observed checks or enter exact name|App ID|workflow tuples.', + 'validation.duplicateNames': 'Two trusted producers use the same check name. Coverage stores only the name: choose one producer or rename the CI jobs.', + 'validation.projectStatusVerified': 'Open every selected Project in GitHub and confirm that all four exact Status values exist. Answer Yes after checking, or No to choose Projects again.', + 'validation.projectStatusRedo': 'Status values were not confirmed. Choose compatible Projects, then review their Status options again.', + 'validation.number': 'Enter a non-negative whole number.', + 'validation.boolean': 'Enter yes or no.', + 'validation.choice': 'Select one of the listed options.', + 'validation.unknownResource': 'Unknown inherited resource names: {names}. Choose only names shown in the inherited list.', + 'validation.unknownWorkflow': 'Unknown issue workflows: {names}. Choose only the listed workflow types.', + 'validation.firstQuestion': 'This is the first question in this pass. Review it or cancel setup.', + 'validation.duplicateProducer': 'This trusted check was selected twice. Remove the duplicate selection.', + 'validation.savedStatus': 'The saved Status value is not available in every selected Project. Choose a listed option.', + 'validation.projectIncompatible': 'The selected Projects have no common Status option. Choose compatible Projects or configure them separately.', + 'validation.projectLimit': 'Choose at most 10 Projects; separate their numbers or URLs with commas.', + 'validation.projectOwnerNeeded': 'A Project URL needs a known repository owner; enter its positive number instead.', + 'validation.projectOwnerMismatch': 'Use a GitHub Project URL belonging to {owner}, without query parameters.', + 'validation.projectUrl': 'Enter a valid GitHub Project URL or positive Project number.', + 'validation.projectNumber': 'Enter the positive Project number from its GitHub URL, not a PVT_ GraphQL ID.', + 'validation.projectNumberRange': 'Project numbers must be positive integers at most 2147483647.', + 'validation.projectDuplicate': 'Project {number} was selected more than once.', + 'validation.fixedIssues': 'Your configuration explicitly enables release or hotfix automation. Keep Issues enabled or edit that configuration first.', + 'validation.fixedWorkflowEnabled': 'Your configuration enables {kind}. Keep that workflow selected or edit the configuration first.', + 'validation.fixedWorkflowDisabled': 'Your configuration disables {kind}. Deselect that workflow or edit the configuration first.', + 'validation.unknown': 'This answer could not be accepted. Review the question and its help; the terminal has the technical detail.', +}; diff --git a/web/src/i18n/messages/es.ts b/web/src/i18n/messages/es.ts new file mode 100644 index 000000000..4da458446 --- /dev/null +++ b/web/src/i18n/messages/es.ts @@ -0,0 +1,39 @@ +import type { WebSetupMessageCopyId } from '../../../../src/application/contracts/web_setup_view'; + +export const messageCopyEs: Readonly> = { + 'session.controlMoved': 'El control ha pasado a esta pestaña. La anterior ahora es de solo lectura.', + 'session.cancelled': 'La configuración se detuvo antes de aplicar más cambios. Los PAT creados en GitHub siguen existiendo hasta que los elimines allí.', + 'plan.ready': 'Plan listo: {files} archivos, {variables} Variables y {secrets} nombres de Secrets. Revísalo antes de continuar.', + 'permission.preview': 'Vista previa de permisos: flujos de incidencias {issues}; aprobación de PR {approval}; Secrets {secrets}; Variables {variables}; Projects {projects}. Revisa los permisos exactos antes de crear el PAT de configuración.', + 'setupPat.corrected.bootstrap': 'El PAT de configuración no pudo acceder al repositorio. No se iniciaron cambios. Permisos necesarios: {grants}. Crea un PAT corregido desde el enlace actualizado de GitHub.', + 'setupPat.corrected.final': 'Los permisos necesarios del PAT cambiaron tras la inspección. No se iniciaron cambios. Permisos nuevos: {grants}. Crea un PAT corregido desde el enlace actualizado.', + 'setupPat.cleanup': 'Elimina el PAT temporal de configuración en los ajustes de GitHub después de esta ejecución. Cerrar Copilot no lo revoca.', + 'botPat.separation': 'El PAT del bot es distinto del de configuración. Estas credenciales se instalarán como Secrets de GitHub Actions: {names}. Los valores existentes no pueden leerse. Vuelve a introducir un PAT del bot para comprobar sus permisos. Antes de Aplicar no se ejecuta ningún workflow de comprobación.', + 'credential.checks': 'Se han comprobado {count} credenciales. Abajo aparece el resultado de cada una. La terminal contiene los detalles técnicos; el valor de un Secret existente no puede leerse.', + 'credential.status.valid': 'Válida', 'credential.status.invalid': 'No válida', 'credential.status.missing': 'Falta', + 'credential.status.unverifiable': 'No verificable sin un valor nuevo', 'credential.status.not_required': 'No necesaria', + 'validation.producers': 'Selecciona entre 1 y 8 checks observados o introduce las tuplas exactas nombre|ID de App|workflow.', + 'validation.duplicateNames': 'Dos productores fiables tienen el mismo nombre de check. La cobertura solo guarda el nombre: elige uno o cambia el nombre de los jobs de CI.', + 'validation.projectStatusVerified': 'Abre cada Project elegido en GitHub y confirma los cuatro valores Status exactos. Responde Sí tras comprobarlo, o No para elegir Projects de nuevo.', + 'validation.projectStatusRedo': 'No se confirmaron los valores Status. Elige Projects compatibles y vuelve a revisar sus opciones Status.', + 'validation.number': 'Introduce un número entero no negativo.', + 'validation.boolean': 'Elige Sí o No.', + 'validation.choice': 'Elige una de las opciones mostradas.', + 'validation.unknownResource': 'Nombres de recursos heredados desconocidos: {names}. Elige solo nombres de la lista heredada.', + 'validation.unknownWorkflow': 'Flujos de issues desconocidos: {names}. Elige solo los tipos indicados.', + 'validation.firstQuestion': 'Esta es la primera pregunta de esta pasada. Revísala o cancela la configuración.', + 'validation.duplicateProducer': 'Has seleccionado dos veces el mismo check fiable. Quita la selección duplicada.', + 'validation.savedStatus': 'El valor Status guardado no existe en todos los Projects seleccionados. Elige una opción de la lista.', + 'validation.projectIncompatible': 'Los Projects elegidos no comparten ningún valor Status. Elige Projects compatibles o configúralos por separado.', + 'validation.projectLimit': 'Elige como máximo 10 Projects; separa sus números o URL con comas.', + 'validation.projectOwnerNeeded': 'Para usar la URL de un Project hay que conocer el propietario del repositorio; introduce su número positivo.', + 'validation.projectOwnerMismatch': 'Usa la URL de un Project de GitHub que pertenezca a {owner}, sin parámetros.', + 'validation.projectUrl': 'Introduce una URL válida de un Project de GitHub o su número positivo.', + 'validation.projectNumber': 'Introduce el número positivo del Project que aparece en su URL, no un ID GraphQL PVT_.', + 'validation.projectNumberRange': 'El número del Project debe ser un entero positivo no mayor de 2147483647.', + 'validation.projectDuplicate': 'Has seleccionado el Project {number} más de una vez.', + 'validation.fixedIssues': 'Tu configuración activa expresamente release o hotfix. Mantén Issues activado o cambia antes la configuración.', + 'validation.fixedWorkflowEnabled': 'Tu configuración activa {kind}. Mantén ese flujo seleccionado o cambia antes la configuración.', + 'validation.fixedWorkflowDisabled': 'Tu configuración desactiva {kind}. Desmarca ese flujo o cambia antes la configuración.', + 'validation.unknown': 'No se pudo aceptar esta respuesta. Revisa la pregunta y su ayuda; la terminal contiene el detalle técnico.', +}; diff --git a/web/src/i18n/messages/fr.ts b/web/src/i18n/messages/fr.ts new file mode 100644 index 000000000..20a35b119 --- /dev/null +++ b/web/src/i18n/messages/fr.ts @@ -0,0 +1,39 @@ +import type { WebSetupMessageCopyId } from '../../../../src/application/contracts/web_setup_view'; + +export const messageCopyFr: Readonly> = { + 'session.controlMoved': 'Le contrôle est passé à cet onglet. L’onglet précédent est maintenant en lecture seule.', + 'session.cancelled': 'La configuration s’est arrêtée avant de nouveaux changements. Tout PAT créé sur GitHub existe encore jusqu’à sa suppression là-bas.', + 'plan.ready': 'Plan prêt : {files} fichiers, {variables} Variables et {secrets} noms de Secrets. Examinez-le avant de continuer.', + 'permission.preview': 'Aperçu des droits : flux de tickets {issues} ; approbation des PR {approval} ; Secrets {secrets} ; Variables {variables} ; Projects {projects}. Vérifiez les droits exacts avant de créer le PAT de configuration.', + 'setupPat.corrected.bootstrap': 'Le PAT de configuration n’a pas pu accéder au dépôt. Aucun changement n’a commencé. Droits requis : {grants}. Créez un PAT corrigé avec le lien GitHub mis à jour.', + 'setupPat.corrected.final': 'Les droits requis du PAT ont changé après inspection. Aucun changement n’a commencé. Nouveaux droits : {grants}. Créez un PAT corrigé avec le lien mis à jour.', + 'setupPat.cleanup': 'Supprimez le PAT temporaire de configuration dans les paramètres GitHub après cette exécution. Fermer Copilot ne le révoque pas.', + 'botPat.separation': 'Le PAT du bot est distinct du PAT de configuration. Ces identifiants deviendront des Secrets GitHub Actions : {names}. Les valeurs existantes ne peuvent pas être relues. Saisissez à nouveau un PAT du bot pour vérifier ses droits. Aucun workflow de vérification ne démarre avant Appliquer.', + 'credential.checks': 'Vérification terminée pour {count} identifiants. Le résultat de chacun figure ci-dessous. Le terminal contient les détails techniques ; la valeur d’un Secret existant ne peut pas être relue.', + 'credential.status.valid': 'Valide', 'credential.status.invalid': 'Invalide', 'credential.status.missing': 'Manquant', + 'credential.status.unverifiable': 'Invérifiable sans nouvelle valeur', 'credential.status.not_required': 'Non requis', + 'validation.producers': 'Choisissez 1 à 8 vérifications observées ou saisissez les triplets exacts nom|ID d’App|workflow.', + 'validation.duplicateNames': 'Deux producteurs fiables portent le même nom de vérification. La couverture ne stocke que ce nom : choisissez-en un ou renommez les jobs CI.', + 'validation.projectStatusVerified': 'Ouvrez chaque Project choisi sur GitHub et confirmez les quatre valeurs Status exactes. Répondez Oui après vérification, ou Non pour choisir à nouveau les Projects.', + 'validation.projectStatusRedo': 'Les valeurs Status n’ont pas été confirmées. Choisissez des Projects compatibles, puis revérifiez leurs options Status.', + 'validation.number': 'Saisissez un entier positif ou nul.', + 'validation.boolean': 'Choisissez Oui ou Non.', + 'validation.choice': 'Choisissez une des options affichées.', + 'validation.unknownResource': 'Noms de ressources héritées inconnus : {names}. Choisissez seulement les noms de la liste héritée.', + 'validation.unknownWorkflow': 'Workflows de ticket inconnus : {names}. Choisissez seulement les types proposés.', + 'validation.firstQuestion': 'C’est la première question de cette passe. Vérifiez-la ou annulez la configuration.', + 'validation.duplicateProducer': 'Cette vérification fiable a été choisie deux fois. Retirez le doublon.', + 'validation.savedStatus': 'La valeur Status enregistrée n’existe pas dans tous les Projects choisis. Sélectionnez une option proposée.', + 'validation.projectIncompatible': 'Les Projects choisis ne partagent aucune valeur Status. Choisissez des Projects compatibles ou configurez-les séparément.', + 'validation.projectLimit': 'Choisissez au plus 10 Projects ; séparez leurs numéros ou URL par des virgules.', + 'validation.projectOwnerNeeded': 'Une URL de Project exige de connaître le propriétaire du dépôt ; saisissez plutôt son numéro positif.', + 'validation.projectOwnerMismatch': 'Utilisez une URL de Project GitHub appartenant à {owner}, sans paramètres.', + 'validation.projectUrl': 'Saisissez une URL valide de Project GitHub ou son numéro positif.', + 'validation.projectNumber': 'Saisissez le numéro positif figurant dans l’URL du Project, pas un ID GraphQL PVT_.', + 'validation.projectNumberRange': 'Le numéro du Project doit être un entier positif inférieur ou égal à 2147483647.', + 'validation.projectDuplicate': 'Le Project {number} a été choisi plusieurs fois.', + 'validation.fixedIssues': 'Votre configuration active explicitement release ou hotfix. Gardez Issues activé ou modifiez d’abord la configuration.', + 'validation.fixedWorkflowEnabled': 'Votre configuration active {kind}. Gardez ce flux sélectionné ou modifiez d’abord la configuration.', + 'validation.fixedWorkflowDisabled': 'Votre configuration désactive {kind}. Désélectionnez ce flux ou modifiez d’abord la configuration.', + 'validation.unknown': 'Cette réponse n’a pas été acceptée. Vérifiez la question et son aide ; le terminal contient le détail technique.', +}; diff --git a/web/src/i18n/messages/pt.ts b/web/src/i18n/messages/pt.ts new file mode 100644 index 000000000..195895186 --- /dev/null +++ b/web/src/i18n/messages/pt.ts @@ -0,0 +1,39 @@ +import type { WebSetupMessageCopyId } from '../../../../src/application/contracts/web_setup_view'; + +export const messageCopyPt: Readonly> = { + 'session.controlMoved': 'O controlo passou para este separador. O separador anterior é agora apenas de leitura.', + 'session.cancelled': 'A configuração parou antes de novas alterações. Os PAT criados no GitHub continuam a existir até os eliminar lá.', + 'plan.ready': 'Plano pronto: {files} ficheiros, {variables} Variables e {secrets} nomes de Secrets. Reveja-o antes de continuar.', + 'permission.preview': 'Pré-visualização das permissões: fluxos de issues {issues}; aprovação de PR {approval}; Secrets {secrets}; Variables {variables}; Projects {projects}. Reveja as permissões exatas antes de criar o PAT de configuração.', + 'setupPat.corrected.bootstrap': 'O PAT de configuração não conseguiu aceder ao repositório. Não começaram alterações. Permissões necessárias: {grants}. Crie um PAT corrigido com a ligação GitHub atualizada.', + 'setupPat.corrected.final': 'As permissões necessárias do PAT mudaram após inspeção. Não começaram alterações. Novas permissões: {grants}. Crie um PAT corrigido com a ligação atualizada.', + 'setupPat.cleanup': 'Elimine o PAT temporário de configuração nas definições do GitHub após esta execução. Fechar o Copilot não o revoga.', + 'botPat.separation': 'O PAT do bot é distinto do PAT de configuração. Estas credenciais serão Secrets do GitHub Actions: {names}. Os valores existentes não podem voltar a ser lidos. Reintroduza um PAT do bot para verificar as permissões. Nenhum fluxo de verificação é executado antes de Aplicar.', + 'credential.checks': 'Verificação concluída para {count} credenciais. O resultado de cada uma aparece abaixo. O terminal contém os detalhes técnicos; o valor de um Secret existente não pode voltar a ser lido.', + 'credential.status.valid': 'Válida', 'credential.status.invalid': 'Inválida', 'credential.status.missing': 'Em falta', + 'credential.status.unverifiable': 'Não verificável sem um novo valor', 'credential.status.not_required': 'Não necessária', + 'validation.producers': 'Selecione 1 a 8 verificações observadas ou introduza os triplos exatos nome|ID da App|workflow.', + 'validation.duplicateNames': 'Dois produtores fiáveis têm o mesmo nome de check. A cobertura guarda apenas o nome: escolha um ou altere o nome dos jobs de CI.', + 'validation.projectStatusVerified': 'Abra cada Project escolhido no GitHub e confirme os quatro valores Status exatos. Responda Sim após verificar, ou Não para voltar a escolher Projects.', + 'validation.projectStatusRedo': 'Os valores Status não foram confirmados. Escolha Projects compatíveis e volte a verificar as opções Status.', + 'validation.number': 'Introduza um número inteiro não negativo.', + 'validation.boolean': 'Escolha Sim ou Não.', + 'validation.choice': 'Escolha uma das opções apresentadas.', + 'validation.unknownResource': 'Nomes de recursos herdados desconhecidos: {names}. Escolha apenas nomes da lista herdada.', + 'validation.unknownWorkflow': 'Fluxos de questões desconhecidos: {names}. Escolha apenas os tipos indicados.', + 'validation.firstQuestion': 'Esta é a primeira pergunta desta ronda. Reveja-a ou cancele a configuração.', + 'validation.duplicateProducer': 'Esta verificação de confiança foi selecionada duas vezes. Remova a seleção duplicada.', + 'validation.savedStatus': 'O valor Status guardado não existe em todos os Projects escolhidos. Selecione uma opção da lista.', + 'validation.projectIncompatible': 'Os Projects escolhidos não partilham qualquer valor Status. Escolha Projects compatíveis ou configure-os separadamente.', + 'validation.projectLimit': 'Escolha no máximo 10 Projects; separe os números ou URL por vírgulas.', + 'validation.projectOwnerNeeded': 'Uma URL de Project exige que se conheça o proprietário do repositório; introduza antes o número positivo.', + 'validation.projectOwnerMismatch': 'Use uma URL de Project do GitHub pertencente a {owner}, sem parâmetros.', + 'validation.projectUrl': 'Introduza uma URL válida de Project do GitHub ou o respetivo número positivo.', + 'validation.projectNumber': 'Introduza o número positivo apresentado na URL do Project, não um ID GraphQL PVT_.', + 'validation.projectNumberRange': 'O número do Project tem de ser um inteiro positivo até 2147483647.', + 'validation.projectDuplicate': 'O Project {number} foi selecionado mais do que uma vez.', + 'validation.fixedIssues': 'A sua configuração ativa explicitamente release ou hotfix. Mantenha Issues ativo ou altere primeiro a configuração.', + 'validation.fixedWorkflowEnabled': 'A sua configuração ativa {kind}. Mantenha esse fluxo selecionado ou altere primeiro a configuração.', + 'validation.fixedWorkflowDisabled': 'A sua configuração desativa {kind}. Desmarque esse fluxo ou altere primeiro a configuração.', + 'validation.unknown': 'Esta resposta não foi aceite. Reveja a pergunta e a ajuda; o terminal contém o detalhe técnico.', +}; diff --git a/web/src/i18n/options/es.ts b/web/src/i18n/options/es.ts new file mode 100644 index 000000000..0b3effe35 --- /dev/null +++ b/web/src/i18n/options/es.ts @@ -0,0 +1,16 @@ +export const optionLabelsEs = { + All: 'Todos', prompt: 'Preguntar antes de crear el enlace', 'create-if-missing': 'Crear el enlace si falta', disabled: 'Desactivado', + replace: 'Sustituir', append: 'Añadir', preserve: 'Conservar', + info: 'Informativa', low: 'Baja', medium: 'Media', high: 'Alta', + smart: 'Adaptativa', default: 'Predeterminado', auto: 'Automático', always: 'Reinstalar siempre', + recommend: 'Recomendar aprobación', guarded: 'Aprobar solo con garantías', off: 'Desactivado', + check: 'Check de CI que exige la cobertura', numeric: 'Informe numérico verificable', + repository: 'Repositorio', organization: 'Organización', selected: 'Repositorios seleccionados', private: 'Repositorios privados', all: 'Todos los repositorios', + 'production-lineage': 'Conservar el linaje de producción', 'canonical-gitflow': 'Git-Flow canónico', manual: 'Manual', + 'auto-merge': 'Fusionar automáticamente', 'merge-queue': 'Cola de integración', 'create-only': 'Solo crear PR', + direct: 'Integración directa', 'sync-branch': 'Mediante rama de sincronización', 'prefer-release': 'Priorizar la release', development: 'Desarrollo', both: 'Ambos destinos', + 'source-only': 'Solo rama de origen', 'sync-only': 'Solo rama de sincronización', none: 'Ninguna', + close: 'Cerrar el issue', 'keep-open': 'Mantener abierto', guided: 'Guiado', compact: 'Compacto', quiet: 'Mínimo', + update: 'Actualizar el comentario', milestones: 'Publicar en hitos', + feature: 'Funcionalidad', bugfix: 'Corrección', documentation: 'Documentación', chore: 'Mantenimiento', help: 'Ayuda o pregunta', hotfix: 'Arreglo urgente', release: 'Release', +} as const; diff --git a/web/src/i18n/options/fr.ts b/web/src/i18n/options/fr.ts new file mode 100644 index 000000000..a90815f7a --- /dev/null +++ b/web/src/i18n/options/fr.ts @@ -0,0 +1,18 @@ +import type { optionLabelsEs } from './es'; + +export const optionLabelsFr: Record = { + All: 'Tous', prompt: 'Demander avant de créer le renvoi', 'create-if-missing': 'Créer le renvoi s’il manque', disabled: 'Désactivé', + replace: 'Remplacer', append: 'Ajouter', preserve: 'Conserver', + info: 'Information', low: 'Faible', medium: 'Moyenne', high: 'Élevée', + smart: 'Adaptatif', default: 'Par défaut', auto: 'Automatique', always: 'Toujours réinstaller', + recommend: 'Recommander une approbation', guarded: 'Approuver sous garde', off: 'Désactivé', + check: 'Vérification CI imposant la couverture', numeric: 'Rapport numérique vérifiable', + repository: 'Dépôt', organization: 'Organisation', selected: 'Dépôts sélectionnés', private: 'Dépôts privés', all: 'Tous les dépôts', + 'production-lineage': 'Préserver la filiation de production', 'canonical-gitflow': 'Git-Flow canonique', manual: 'Manuel', + 'auto-merge': 'Fusionner automatiquement', 'merge-queue': 'File de fusion', 'create-only': 'Créer la PR uniquement', + direct: 'Fusion directe', 'sync-branch': 'Par branche de synchronisation', 'prefer-release': 'Privilégier la version', development: 'Développement', both: 'Les deux destinations', + 'source-only': 'Branche source seulement', 'sync-only': 'Branche de synchronisation seulement', none: 'Aucune', + close: 'Fermer le ticket', 'keep-open': 'Garder ouvert', guided: 'Guidé', compact: 'Compact', quiet: 'Minimal', + update: 'Mettre le commentaire à jour', milestones: 'Publier aux étapes clés', + feature: 'Fonctionnalité', bugfix: 'Correction', documentation: 'Documentation', chore: 'Maintenance', help: 'Aide ou question', hotfix: 'Correctif urgent', release: 'Version', +}; diff --git a/web/src/i18n/options/pt.ts b/web/src/i18n/options/pt.ts new file mode 100644 index 000000000..dc467e955 --- /dev/null +++ b/web/src/i18n/options/pt.ts @@ -0,0 +1,18 @@ +import type { optionLabelsEs } from './es'; + +export const optionLabelsPt: Record = { + All: 'Todos', prompt: 'Perguntar antes de criar o apontador', 'create-if-missing': 'Criar o apontador se faltar', disabled: 'Desativado', + replace: 'Substituir', append: 'Acrescentar', preserve: 'Conservar', + info: 'Informação', low: 'Baixa', medium: 'Média', high: 'Alta', + smart: 'Adaptativo', default: 'Predefinido', auto: 'Automático', always: 'Reinstalar sempre', + recommend: 'Recomendar aprovação', guarded: 'Aprovar sob condições', off: 'Desativado', + check: 'Verificação CI que exige cobertura', numeric: 'Relatório numérico verificável', + repository: 'Repositório', organization: 'Organização', selected: 'Repositórios selecionados', private: 'Repositórios privados', all: 'Todos os repositórios', + 'production-lineage': 'Preservar a linhagem de produção', 'canonical-gitflow': 'Git-Flow canónico', manual: 'Manual', + 'auto-merge': 'Integrar automaticamente', 'merge-queue': 'Fila de integração', 'create-only': 'Criar apenas a PR', + direct: 'Integração direta', 'sync-branch': 'Através de ramo de sincronização', 'prefer-release': 'Priorizar a release', development: 'Desenvolvimento', both: 'Ambos os destinos', + 'source-only': 'Apenas ramo de origem', 'sync-only': 'Apenas ramo de sincronização', none: 'Nenhum', + close: 'Fechar a questão', 'keep-open': 'Manter aberta', guided: 'Guiado', compact: 'Compacto', quiet: 'Mínimo', + update: 'Atualizar o comentário', milestones: 'Publicar nos marcos', + feature: 'Funcionalidade', bugfix: 'Correção', documentation: 'Documentação', chore: 'Manutenção', help: 'Ajuda ou pergunta', hotfix: 'Hotfix', release: 'Release', +}; diff --git a/web/src/i18n/permissionCopy.ts b/web/src/i18n/permissionCopy.ts new file mode 100644 index 000000000..10897909a --- /dev/null +++ b/web/src/i18n/permissionCopy.ts @@ -0,0 +1,17 @@ +import { tr, type SetupLocale } from './catalog'; +import { permissionTexts, type PermissionText } from './permissions/en'; +import { permissionCopyEs } from './permissions/es'; +import { permissionCopyFr } from './permissions/fr'; +import { permissionCopyPt } from './permissions/pt'; + +const source = new Set(permissionTexts); +export const permissionCopyCatalogs = { es: permissionCopyEs, fr: permissionCopyFr, pt: permissionCopyPt } as const; + +/** Known public explanations are translated; unknown text is never exposed in another language. */ +export function permissionCopy(locale: SetupLocale, text: string): string { + if (locale === 'en') return text; + if (!source.has(text)) return tr('permissionUnknown', locale); + return permissionCopyCatalogs[locale][text as PermissionText]; +} + +export function isKnownPermissionCopy(text: string): boolean { return source.has(text); } diff --git a/web/src/i18n/permissionTerms.ts b/web/src/i18n/permissionTerms.ts new file mode 100644 index 000000000..d59bca9cf --- /dev/null +++ b/web/src/i18n/permissionTerms.ts @@ -0,0 +1,31 @@ +import type { SetupLocale } from './catalog'; +import { namesEn, termsEn } from './permissionTerms/en'; +import { namesEs, termsEs } from './permissionTerms/es'; +import { namesFr, termsFr } from './permissionTerms/fr'; +import { namesPt, termsPt } from './permissionTerms/pt'; + +export type PermissionTerm = 'repository' | 'organization' | 'read' | 'write' + | 'required' | 'conditional' | 'verified' | 'missing' | 'unverifiable'; +export type PermissionName = 'Metadata' | 'Contents' | 'Secrets' | 'Variables' | 'Issues' + | 'Actions' | 'Checks' | 'Administration' | 'Workflows' | 'Issue Types' + | 'Projects' | 'Pull requests' | 'Members'; + +const names = { en: namesEn, es: namesEs, fr: namesFr, pt: namesPt } as const; + +/** Translates presentation labels only; GitHub permission IDs remain unchanged. */ +const terms = { en: termsEn, es: termsEs, fr: termsFr, pt: termsPt } as const; + +export function permissionTerm(locale: SetupLocale, term: PermissionTerm): string { + return terms[locale][term]; +} + +export function permissionName(locale: SetupLocale, name: string): string { + return Object.prototype.hasOwnProperty.call(names[locale], name) ? names[locale][name as PermissionName] : name; +} + +export function permissionStatus(locale: SetupLocale, status: unknown): string | undefined { + if (status === 'verified' || status === 'missing' || status === 'unverifiable') return terms[locale][status]; + return undefined; +} + +export const permissionTermCatalogs = { names, terms }; diff --git a/web/src/i18n/permissionTerms/en.ts b/web/src/i18n/permissionTerms/en.ts new file mode 100644 index 000000000..9bb647326 --- /dev/null +++ b/web/src/i18n/permissionTerms/en.ts @@ -0,0 +1,7 @@ +import type { PermissionName, PermissionTerm } from '../permissionTerms'; +export const namesEn: Readonly> = { + Metadata: 'Metadata', Contents: 'Contents', Secrets: 'Secrets', Variables: 'Variables', Issues: 'Issues', Actions: 'Actions', Checks: 'Checks', Administration: 'Administration', Workflows: 'Workflows', 'Issue Types': 'Issue Types', Projects: 'Projects', 'Pull requests': 'Pull requests', Members: 'Members', +}; +export const termsEn: Readonly> = { + repository: 'repository', organization: 'organization', read: 'Read', write: 'Write', required: 'Required', conditional: 'Conditional', verified: 'Verified', missing: 'Missing', unverifiable: 'Unverifiable', +}; diff --git a/web/src/i18n/permissionTerms/es.ts b/web/src/i18n/permissionTerms/es.ts new file mode 100644 index 000000000..0b4c2360f --- /dev/null +++ b/web/src/i18n/permissionTerms/es.ts @@ -0,0 +1,7 @@ +import type { PermissionName, PermissionTerm } from '../permissionTerms'; +export const namesEs: Readonly> = { + Metadata: 'Metadatos', Contents: 'Contenido', Secrets: 'Secretos', Variables: 'Variables', Issues: 'Incidencias', Actions: 'Acciones', Checks: 'Comprobaciones', Administration: 'Administración', Workflows: 'Flujos de trabajo', 'Issue Types': 'Tipos de incidencia', Projects: 'Proyectos', 'Pull requests': 'Solicitudes de cambio', Members: 'Miembros', +}; +export const termsEs: Readonly> = { + repository: 'repositorio', organization: 'organización', read: 'Lectura', write: 'Escritura', required: 'Obligatorio', conditional: 'Condicional', verified: 'Verificado', missing: 'Faltante', unverifiable: 'No verificable', +}; diff --git a/web/src/i18n/permissionTerms/fr.ts b/web/src/i18n/permissionTerms/fr.ts new file mode 100644 index 000000000..88c71ba9f --- /dev/null +++ b/web/src/i18n/permissionTerms/fr.ts @@ -0,0 +1,7 @@ +import type { PermissionName, PermissionTerm } from '../permissionTerms'; +export const namesFr: Readonly> = { + Metadata: 'Métadonnées', Contents: 'Contenu', Secrets: 'Secrets', Variables: 'Variables', Issues: 'Tickets', Actions: 'Actions', Checks: 'Vérifications', Administration: 'Administration', Workflows: 'Flux de travail', 'Issue Types': 'Types de ticket', Projects: 'Projets', 'Pull requests': 'Demandes de tirage', Members: 'Membres', +}; +export const termsFr: Readonly> = { + repository: 'dépôt', organization: 'organisation', read: 'Lecture', write: 'Écriture', required: 'Obligatoire', conditional: 'Conditionnel', verified: 'Vérifié', missing: 'Manquant', unverifiable: 'Non vérifiable', +}; diff --git a/web/src/i18n/permissionTerms/pt.ts b/web/src/i18n/permissionTerms/pt.ts new file mode 100644 index 000000000..c484d410a --- /dev/null +++ b/web/src/i18n/permissionTerms/pt.ts @@ -0,0 +1,7 @@ +import type { PermissionName, PermissionTerm } from '../permissionTerms'; +export const namesPt: Readonly> = { + Metadata: 'Metadados', Contents: 'Conteúdo', Secrets: 'Segredos', Variables: 'Variáveis', Issues: 'Questões', Actions: 'Ações', Checks: 'Verificações', Administration: 'Administração', Workflows: 'Fluxos de trabalho', 'Issue Types': 'Tipos de questão', Projects: 'Projetos', 'Pull requests': 'Pedidos de alteração', Members: 'Membros', +}; +export const termsPt: Readonly> = { + repository: 'repositório', organization: 'organização', read: 'Leitura', write: 'Escrita', required: 'Obrigatório', conditional: 'Condicional', verified: 'Verificado', missing: 'Em falta', unverifiable: 'Não verificável', +}; diff --git a/web/src/i18n/permissions/en.ts b/web/src/i18n/permissions/en.ts new file mode 100644 index 000000000..ef579e0e6 --- /dev/null +++ b/web/src/i18n/permissions/en.ts @@ -0,0 +1,53 @@ +/** Static first-party explanations emitted by the permission policy. */ +export const permissionTexts = [ + 'Resolve repository identity and visibility.', + 'Inspect installed workflows and repository files.', + 'Inspect and provision selected GitHub Actions Secrets.', + 'Inspect and provision selected GitHub Actions Variables.', + 'Provision labels and issue resources.', + 'Inspect and dispatch credential-health workflows.', + 'Inspect CI workflow runs and jobs for exact producer identities.', + 'Discover exact CI check and producer identities.', + 'Inspect branch protection and rulesets.', + 'Bootstrap a missing credential-health workflow.', + 'Inspect and provision organization Actions Secrets.', + 'Inspect and provision organization Actions Variables.', + 'Provision and assign configured issue types.', + 'Inspect selected Projects and their Status options; setup does not edit Project items.', + 'Create the initial repository tag when no version tag exists.', + 'Inspect and provision selected repository Actions Secrets.', + 'Inspect and provision selected repository Actions Variables.', + 'Provision labels for the selected issue workflows.', + 'Dispatch credential-health checks for existing Secrets.', + 'Inspect CI workflow runs and jobs for approval evidence.', + 'Temporarily install credential health when its workflow is not confirmed installed.', + 'Inspect branch protection and effective rulesets.', + 'Inspect and provision selected organization Actions Secrets.', + 'Inspect and provision selected organization Actions Variables.', + 'Provision native issue types for the selected workflows.', + 'Resolve repository and collaborator metadata.', + 'Dispatch selected release or hotfix workflows and check previous runs.', + 'Check previous workflow runs before executing an enabled route.', + 'Create managed branches, edit files, or merge selected release/hotfix changes.', + 'Manage selected issue lifecycles, comments, and progress.', + 'Manage selected pull request workflows, reviews, or autofix.', + 'Verify current-head required checks and producer identities.', + 'Load the guarded approval policy.', + 'Select or authorize organization members for enabled workflows.', + 'Assign configured organization issue types.', + 'Update selected organization Projects.', + 'Load the organization-scoped approval policy.', + 'Secret provisioning enabled', + 'Variable provisioning enabled', + 'Issue workflows enabled', + 'Credential health enabled', + 'Pull-request approval enabled', + 'Release, hotfix, or guarded approval enabled', + 'Temporary health workflow required', + 'Organization Secret storage selected', + 'Organization Variable storage selected', + 'Issue type automation enabled', + 'Organization Projects selected', +] as const; + +export type PermissionText = typeof permissionTexts[number]; diff --git a/web/src/i18n/permissions/es.ts b/web/src/i18n/permissions/es.ts new file mode 100644 index 000000000..b9dde70ee --- /dev/null +++ b/web/src/i18n/permissions/es.ts @@ -0,0 +1,52 @@ +import type { PermissionText } from './en'; + +export const permissionCopyEs: Readonly> = { + 'Resolve repository identity and visibility.': 'Comprobar la identidad y visibilidad del repositorio.', + 'Inspect installed workflows and repository files.': 'Examinar los workflows instalados y los archivos del repositorio.', + 'Inspect and provision selected GitHub Actions Secrets.': 'Examinar y configurar los Secrets de GitHub Actions seleccionados.', + 'Inspect and provision selected GitHub Actions Variables.': 'Examinar y configurar las Variables de GitHub Actions seleccionadas.', + 'Provision labels and issue resources.': 'Crear etiquetas y recursos de issues.', + 'Inspect and dispatch credential-health workflows.': 'Examinar y ejecutar los workflows de comprobación de credenciales.', + 'Inspect CI workflow runs and jobs for exact producer identities.': 'Examinar ejecuciones y jobs de CI para identificar sus productores exactos.', + 'Discover exact CI check and producer identities.': 'Identificar los checks de CI y sus productores exactos.', + 'Inspect branch protection and rulesets.': 'Examinar la protección de ramas y sus reglas.', + 'Bootstrap a missing credential-health workflow.': 'Instalar provisionalmente el workflow de comprobación de credenciales que falta.', + 'Inspect and provision organization Actions Secrets.': 'Examinar y configurar Secrets de Actions en la organización.', + 'Inspect and provision organization Actions Variables.': 'Examinar y configurar Variables de Actions en la organización.', + 'Provision and assign configured issue types.': 'Crear y asignar los tipos de issue configurados.', + 'Inspect selected Projects and their Status options; setup does not edit Project items.': 'Consultar los Projects seleccionados y sus opciones de Status; el setup no modifica los elementos de los Projects.', + 'Create the initial repository tag when no version tag exists.': 'Crear el tag inicial si el repositorio aún no tiene ninguno de versión.', + 'Inspect and provision selected repository Actions Secrets.': 'Examinar y configurar los Secrets de Actions seleccionados en el repositorio.', + 'Inspect and provision selected repository Actions Variables.': 'Examinar y configurar las Variables de Actions seleccionadas en el repositorio.', + 'Provision labels for the selected issue workflows.': 'Crear las etiquetas de los flujos de issues seleccionados.', + 'Dispatch credential-health checks for existing Secrets.': 'Ejecutar comprobaciones de credenciales para los Secrets existentes.', + 'Inspect CI workflow runs and jobs for approval evidence.': 'Examinar ejecuciones y jobs de CI como prueba para la aprobación.', + 'Temporarily install credential health when its workflow is not confirmed installed.': 'Instalar temporalmente la comprobación de credenciales si su workflow no está confirmado.', + 'Inspect branch protection and effective rulesets.': 'Examinar la protección de ramas y las reglas efectivas.', + 'Inspect and provision selected organization Actions Secrets.': 'Examinar y configurar los Secrets de Actions seleccionados en la organización.', + 'Inspect and provision selected organization Actions Variables.': 'Examinar y configurar las Variables de Actions seleccionadas en la organización.', + 'Provision native issue types for the selected workflows.': 'Crear tipos de issue nativos para los flujos seleccionados.', + 'Resolve repository and collaborator metadata.': 'Consultar los metadatos del repositorio y sus colaboradores.', + 'Dispatch selected release or hotfix workflows and check previous runs.': 'Ejecutar los flujos de release o hotfix seleccionados y comprobar ejecuciones anteriores.', + 'Check previous workflow runs before executing an enabled route.': 'Comprobar ejecuciones anteriores antes de iniciar un flujo habilitado.', + 'Create managed branches, edit files, or merge selected release/hotfix changes.': 'Crear ramas gestionadas, editar archivos o integrar cambios de release y hotfix.', + 'Manage selected issue lifecycles, comments, and progress.': 'Gestionar el ciclo de vida, los comentarios y el progreso de los issues seleccionados.', + 'Manage selected pull request workflows, reviews, or autofix.': 'Gestionar flujos de pull requests, revisiones o correcciones automáticas.', + 'Verify current-head required checks and producer identities.': 'Verificar los checks obligatorios y productores del commit actual.', + 'Load the guarded approval policy.': 'Leer la política de aprobación protegida.', + 'Select or authorize organization members for enabled workflows.': 'Seleccionar o autorizar miembros de la organización para los flujos activos.', + 'Assign configured organization issue types.': 'Asignar los tipos de issue configurados en la organización.', + 'Update selected organization Projects.': 'Actualizar los Projects seleccionados de la organización.', + 'Load the organization-scoped approval policy.': 'Leer la política de aprobación guardada en la organización.', + 'Secret provisioning enabled': 'Configuración de Secrets activada', + 'Variable provisioning enabled': 'Configuración de Variables activada', + 'Issue workflows enabled': 'Flujos de issues activados', + 'Credential health enabled': 'Comprobación de credenciales activada', + 'Pull-request approval enabled': 'Aprobación de pull requests activada', + 'Release, hotfix, or guarded approval enabled': 'Release, hotfix o aprobación protegida activados', + 'Temporary health workflow required': 'Hace falta un workflow temporal de comprobación', + 'Organization Secret storage selected': 'Se eligió guardar Secrets en la organización', + 'Organization Variable storage selected': 'Se eligió guardar Variables en la organización', + 'Issue type automation enabled': 'Automatización de tipos de issue activada', + 'Organization Projects selected': 'Se seleccionaron Projects de la organización', +}; diff --git a/web/src/i18n/permissions/fr.ts b/web/src/i18n/permissions/fr.ts new file mode 100644 index 000000000..2f933ce66 --- /dev/null +++ b/web/src/i18n/permissions/fr.ts @@ -0,0 +1,52 @@ +import type { PermissionText } from './en'; + +export const permissionCopyFr: Readonly> = { + 'Resolve repository identity and visibility.': 'Vérifier l’identité et la visibilité du dépôt.', + 'Inspect installed workflows and repository files.': 'Examiner les workflows installés et les fichiers du dépôt.', + 'Inspect and provision selected GitHub Actions Secrets.': 'Examiner et configurer les Secrets GitHub Actions choisis.', + 'Inspect and provision selected GitHub Actions Variables.': 'Examiner et configurer les Variables GitHub Actions choisies.', + 'Provision labels and issue resources.': 'Créer des étiquettes et des ressources pour les tickets.', + 'Inspect and dispatch credential-health workflows.': 'Examiner et lancer les workflows de vérification des identifiants.', + 'Inspect CI workflow runs and jobs for exact producer identities.': 'Examiner les exécutions et jobs CI pour identifier exactement leurs producteurs.', + 'Discover exact CI check and producer identities.': 'Identifier précisément les vérifications CI et leurs producteurs.', + 'Inspect branch protection and rulesets.': 'Examiner la protection des branches et les ensembles de règles.', + 'Bootstrap a missing credential-health workflow.': 'Installer provisoirement le workflow de vérification des identifiants manquant.', + 'Inspect and provision organization Actions Secrets.': 'Examiner et configurer les Secrets Actions de l’organisation.', + 'Inspect and provision organization Actions Variables.': 'Examiner et configurer les Variables Actions de l’organisation.', + 'Provision and assign configured issue types.': 'Créer et attribuer les types de ticket configurés.', + 'Inspect selected Projects and their Status options; setup does not edit Project items.': 'Consulter les Projects sélectionnés et leurs options Status ; la configuration ne modifie aucun élément de Project.', + 'Create the initial repository tag when no version tag exists.': 'Créer le tag initial si le dépôt ne possède encore aucun tag de version.', + 'Inspect and provision selected repository Actions Secrets.': 'Examiner et configurer les Secrets Actions choisis dans le dépôt.', + 'Inspect and provision selected repository Actions Variables.': 'Examiner et configurer les Variables Actions choisies dans le dépôt.', + 'Provision labels for the selected issue workflows.': 'Créer les étiquettes des workflows de ticket choisis.', + 'Dispatch credential-health checks for existing Secrets.': 'Lancer des vérifications d’identifiants pour les Secrets existants.', + 'Inspect CI workflow runs and jobs for approval evidence.': 'Examiner les exécutions et jobs CI comme preuve pour l’approbation.', + 'Temporarily install credential health when its workflow is not confirmed installed.': 'Installer provisoirement la vérification des identifiants si son workflow n’est pas confirmé.', + 'Inspect branch protection and effective rulesets.': 'Examiner la protection des branches et les règles applicables.', + 'Inspect and provision selected organization Actions Secrets.': 'Examiner et configurer les Secrets Actions choisis dans l’organisation.', + 'Inspect and provision selected organization Actions Variables.': 'Examiner et configurer les Variables Actions choisies dans l’organisation.', + 'Provision native issue types for the selected workflows.': 'Créer des types de ticket natifs pour les workflows choisis.', + 'Resolve repository and collaborator metadata.': 'Consulter les métadonnées du dépôt et de ses collaborateurs.', + 'Dispatch selected release or hotfix workflows and check previous runs.': 'Lancer les workflows de version ou correctif urgent choisis et vérifier les exécutions précédentes.', + 'Check previous workflow runs before executing an enabled route.': 'Vérifier les exécutions précédentes avant de lancer un parcours activé.', + 'Create managed branches, edit files, or merge selected release/hotfix changes.': 'Créer des branches gérées, modifier des fichiers ou fusionner les changements de version et correctif.', + 'Manage selected issue lifecycles, comments, and progress.': 'Gérer le cycle de vie, les commentaires et la progression des tickets choisis.', + 'Manage selected pull request workflows, reviews, or autofix.': 'Gérer les workflows de pull request, les revues ou les corrections automatiques.', + 'Verify current-head required checks and producer identities.': 'Vérifier les contrôles requis et leurs producteurs pour le commit courant.', + 'Load the guarded approval policy.': 'Lire la politique d’approbation encadrée.', + 'Select or authorize organization members for enabled workflows.': 'Sélectionner ou autoriser des membres de l’organisation pour les workflows activés.', + 'Assign configured organization issue types.': 'Attribuer les types de ticket configurés dans l’organisation.', + 'Update selected organization Projects.': 'Mettre à jour les projets de l’organisation choisis.', + 'Load the organization-scoped approval policy.': 'Lire la politique d’approbation stockée dans l’organisation.', + 'Secret provisioning enabled': 'Configuration des Secrets activée', + 'Variable provisioning enabled': 'Configuration des Variables activée', + 'Issue workflows enabled': 'Workflows de ticket activés', + 'Credential health enabled': 'Vérification des identifiants activée', + 'Pull-request approval enabled': 'Approbation des pull requests activée', + 'Release, hotfix, or guarded approval enabled': 'Version, correctif urgent ou approbation encadrée activés', + 'Temporary health workflow required': 'Workflow temporaire de vérification requis', + 'Organization Secret storage selected': 'Stockage des Secrets dans l’organisation choisi', + 'Organization Variable storage selected': 'Stockage des Variables dans l’organisation choisi', + 'Issue type automation enabled': 'Automatisation des types de ticket activée', + 'Organization Projects selected': 'Projets de l’organisation choisis', +}; diff --git a/web/src/i18n/permissions/pt.ts b/web/src/i18n/permissions/pt.ts new file mode 100644 index 000000000..b9719aa30 --- /dev/null +++ b/web/src/i18n/permissions/pt.ts @@ -0,0 +1,52 @@ +import type { PermissionText } from './en'; + +export const permissionCopyPt: Readonly> = { + 'Resolve repository identity and visibility.': 'Verificar a identidade e visibilidade do repositório.', + 'Inspect installed workflows and repository files.': 'Inspecionar os fluxos instalados e os ficheiros do repositório.', + 'Inspect and provision selected GitHub Actions Secrets.': 'Inspecionar e configurar os Secrets do GitHub Actions selecionados.', + 'Inspect and provision selected GitHub Actions Variables.': 'Inspecionar e configurar as Variables do GitHub Actions selecionadas.', + 'Provision labels and issue resources.': 'Criar etiquetas e recursos para questões.', + 'Inspect and dispatch credential-health workflows.': 'Inspecionar e executar os fluxos de verificação de credenciais.', + 'Inspect CI workflow runs and jobs for exact producer identities.': 'Inspecionar execuções e jobs CI para identificar exatamente os seus produtores.', + 'Discover exact CI check and producer identities.': 'Identificar as verificações CI e os seus produtores exatos.', + 'Inspect branch protection and rulesets.': 'Inspecionar a proteção de ramos e os conjuntos de regras.', + 'Bootstrap a missing credential-health workflow.': 'Instalar temporariamente o fluxo de verificação de credenciais em falta.', + 'Inspect and provision organization Actions Secrets.': 'Inspecionar e configurar os Secrets de Actions da organização.', + 'Inspect and provision organization Actions Variables.': 'Inspecionar e configurar as Variables de Actions da organização.', + 'Provision and assign configured issue types.': 'Criar e atribuir os tipos de questão configurados.', + 'Inspect selected Projects and their Status options; setup does not edit Project items.': 'Consultar os Projects selecionados e as suas opções de Status; a configuração não altera os itens dos Projects.', + 'Create the initial repository tag when no version tag exists.': 'Criar a etiqueta inicial se o repositório ainda não tiver etiquetas de versão.', + 'Inspect and provision selected repository Actions Secrets.': 'Inspecionar e configurar os Secrets de Actions selecionados no repositório.', + 'Inspect and provision selected repository Actions Variables.': 'Inspecionar e configurar as Variables de Actions selecionadas no repositório.', + 'Provision labels for the selected issue workflows.': 'Criar etiquetas para os fluxos de questões selecionados.', + 'Dispatch credential-health checks for existing Secrets.': 'Executar verificações de credenciais para os Secrets existentes.', + 'Inspect CI workflow runs and jobs for approval evidence.': 'Inspecionar execuções e jobs CI como prova para aprovação.', + 'Temporarily install credential health when its workflow is not confirmed installed.': 'Instalar temporariamente a verificação de credenciais se o fluxo não estiver confirmado.', + 'Inspect branch protection and effective rulesets.': 'Inspecionar a proteção de ramos e as regras aplicáveis.', + 'Inspect and provision selected organization Actions Secrets.': 'Inspecionar e configurar os Secrets de Actions selecionados na organização.', + 'Inspect and provision selected organization Actions Variables.': 'Inspecionar e configurar as Variables de Actions selecionadas na organização.', + 'Provision native issue types for the selected workflows.': 'Criar tipos de questão nativos para os fluxos selecionados.', + 'Resolve repository and collaborator metadata.': 'Consultar os metadados do repositório e dos colaboradores.', + 'Dispatch selected release or hotfix workflows and check previous runs.': 'Executar os fluxos de release ou hotfix selecionados e verificar execuções anteriores.', + 'Check previous workflow runs before executing an enabled route.': 'Verificar execuções anteriores antes de iniciar um percurso ativo.', + 'Create managed branches, edit files, or merge selected release/hotfix changes.': 'Criar ramos geridos, editar ficheiros ou integrar alterações de release e hotfix.', + 'Manage selected issue lifecycles, comments, and progress.': 'Gerir o ciclo de vida, os comentários e o progresso das questões selecionadas.', + 'Manage selected pull request workflows, reviews, or autofix.': 'Gerir fluxos de pull requests, revisões ou correções automáticas.', + 'Verify current-head required checks and producer identities.': 'Verificar as verificações obrigatórias e os produtores do commit atual.', + 'Load the guarded approval policy.': 'Ler a política de aprovação protegida.', + 'Select or authorize organization members for enabled workflows.': 'Selecionar ou autorizar membros da organização para os fluxos ativos.', + 'Assign configured organization issue types.': 'Atribuir os tipos de questão configurados na organização.', + 'Update selected organization Projects.': 'Atualizar os Projetos selecionados da organização.', + 'Load the organization-scoped approval policy.': 'Ler a política de aprovação guardada na organização.', + 'Secret provisioning enabled': 'Configuração de Secrets ativa', + 'Variable provisioning enabled': 'Configuração de Variables ativa', + 'Issue workflows enabled': 'Fluxos de questões ativos', + 'Credential health enabled': 'Verificação de credenciais ativa', + 'Pull-request approval enabled': 'Aprovação de pull requests ativa', + 'Release, hotfix, or guarded approval enabled': 'Release, hotfix ou aprovação protegida ativos', + 'Temporary health workflow required': 'É necessário um fluxo temporário de verificação', + 'Organization Secret storage selected': 'Armazenamento dos Secrets na organização selecionado', + 'Organization Variable storage selected': 'Armazenamento das Variables na organização selecionado', + 'Issue type automation enabled': 'Automatização de tipos de questão ativa', + 'Organization Projects selected': 'Projetos da organização selecionados', +}; diff --git a/web/src/i18n/planWarningCopy.ts b/web/src/i18n/planWarningCopy.ts new file mode 100644 index 000000000..5132ca5d0 --- /dev/null +++ b/web/src/i18n/planWarningCopy.ts @@ -0,0 +1,19 @@ +import { tr, type SetupLocale } from './catalog'; +import { planWarningsEn } from './planWarnings/en'; +import { planWarningsEs } from './planWarnings/es'; +import { planWarningsFr } from './planWarnings/fr'; +import { planWarningsPt } from './planWarnings/pt'; + +export const planWarningCatalogs = { + en: planWarningsEn, + es: planWarningsEs, + fr: planWarningsFr, + pt: planWarningsPt, +} as const; + +export function localizedPlanWarning(warning: string, locale: SetupLocale): string { + const catalog = planWarningCatalogs[locale] ?? planWarningsEn; + return Object.prototype.hasOwnProperty.call(catalog, warning) + ? catalog[warning as keyof typeof planWarningsEn] + : locale === 'en' ? warning : tr('planUnknownWarning', locale); +} diff --git a/web/src/i18n/planWarnings/en.ts b/web/src/i18n/planWarnings/en.ts new file mode 100644 index 000000000..21833bb13 --- /dev/null +++ b/web/src/i18n/planWarnings/en.ts @@ -0,0 +1,14 @@ +export const planWarningsEn = { + 'No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.': 'No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.', + 'Help / question issues remain branchless even when issue-managed-branches is enabled.': 'Help / question issues remain branchless even when issue-managed-branches is enabled.', + 'Release automation is installed, but release issue events are disabled by the selected issue workflow profile.': 'Release automation is installed, but release issue events are disabled by the selected issue workflow profile.', + 'Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.': 'Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.', + 'Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.': 'Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.', + 'Release and hotfix workflows require the workflow PAT Secret and a writable token.': 'Release and hotfix workflows require the workflow PAT Secret and a writable token.', + 'Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.': 'Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.', + 'Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.': 'Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.', + 'Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.': 'Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.', + 'Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.': 'Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.', + 'Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.': 'Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.', + 'Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.': 'Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.', +} as const; diff --git a/web/src/i18n/planWarnings/es.ts b/web/src/i18n/planWarnings/es.ts new file mode 100644 index 000000000..f0b4d5e79 --- /dev/null +++ b/web/src/i18n/planWarnings/es.ts @@ -0,0 +1,16 @@ +import type { planWarningsEn } from './en'; + +export const planWarningsEs: Readonly> = { + 'No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.': 'No has activado ningún tipo de flujo de incidencias. Sus eventos no se gestionarán hasta que actives un formulario de incidencia y su entrada en el perfil.', + 'Help / question issues remain branchless even when issue-managed-branches is enabled.': 'Las incidencias de ayuda y consulta no crean ramas, aunque actives las ramas gestionadas por incidencias.', + 'Release automation is installed, but release issue events are disabled by the selected issue workflow profile.': 'La automatización de versiones ya está instalada, pero el perfil elegido desactiva los eventos de incidencias de versiones.', + 'Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.': 'La automatización de correcciones urgentes ya está instalada, pero el perfil elegido desactiva los eventos de sus incidencias.', + 'Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.': 'Has desactivado la generación de instrucciones para agentes del repositorio. Los colaboradores no recibirán el perfil ni la guía del flujo de trabajo generados.', + 'Release and hotfix workflows require the workflow PAT Secret and a writable token.': 'Los flujos de versiones y correcciones urgentes requieren el Secret con el PAT de la Action y un token con permisos de escritura.', + 'Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.': 'La cola de merge bloqueará la operación si cada productor obligatorio no se verifica automáticamente o no dispone de una certificación exacta revisada.', + 'Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.': 'El modo de provisión permanente solo reinstala los entornos predeterminados de Codex y OpenCode desde paquetes fijados en el manifiesto. No sustituye ejecutables explícitos; Cursor debe estar instalado previamente.', + 'Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.': 'Está activado el cierre de incidencias inactivas. Las que estén en espera se cerrarán tras el plazo configurado y podrán reabrirse con un comentario.', + 'Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.': 'El PAT del bot debe tener acceso a los Projects seleccionados, y los cuatro valores de Status configurados deben existir en cada uno de ellos.', + 'Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.': 'Cursor es un entorno experimental en Copilot. Requiere una CLI compatible ya instalada y CURSOR_API_KEY; Copilot no instala Cursor automáticamente.', + 'Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.': 'Los Secrets y Variables de organización requieren permisos en ella. Limitar el acceso a los repositorios elegidos es la opción más segura; los valores del repositorio tienen prioridad.', +}; diff --git a/web/src/i18n/planWarnings/fr.ts b/web/src/i18n/planWarnings/fr.ts new file mode 100644 index 000000000..4c8204b6f --- /dev/null +++ b/web/src/i18n/planWarnings/fr.ts @@ -0,0 +1,16 @@ +import type { planWarningsEn } from './en'; + +export const planWarningsFr: Readonly> = { + 'No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.': 'Aucun type de flux de travail pour les tickets n’est activé. Leurs événements ne seront pas gérés tant qu’un formulaire de ticket pris en charge et son entrée de profil ne seront pas activés.', + 'Help / question issues remain branchless even when issue-managed-branches is enabled.': 'Les tickets d’aide ou de question ne créent pas de branche, même lorsque les branches gérées par les tickets sont activées.', + 'Release automation is installed, but release issue events are disabled by the selected issue workflow profile.': 'L’automatisation des versions est déjà installée, mais le profil choisi désactive les événements des tickets de version.', + 'Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.': 'L’automatisation des correctifs urgents est déjà installée, mais le profil choisi désactive les événements des tickets correspondants.', + 'Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.': 'La génération des consignes pour les agents du dépôt est désactivée. Les collaborateurs ne recevront ni le profil ni le guide de flux de travail générés.', + 'Release and hotfix workflows require the workflow PAT Secret and a writable token.': 'Les flux de version et de correctif urgent nécessitent le Secret contenant le PAT de l’Action et un jeton autorisé à écrire.', + 'Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.': 'La file de fusion bloque l’opération si chaque producteur requis n’est pas vérifié automatiquement ou couvert par une attestation exacte et examinée.', + 'Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.': 'Le mode de provisionnement permanent ne réinstalle que les environnements Codex et OpenCode par défaut depuis les paquets verrouillés du manifeste. Il ne remplace jamais les exécutables indiqués explicitement ; Cursor doit être préinstallé.', + 'Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.': 'La fermeture des tickets inactifs est activée. Les tickets en attente seront fermés après le délai configuré et pourront être rouverts par un nouveau commentaire.', + 'Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.': 'Le PAT du bot doit avoir accès aux Projects sélectionnés, et les quatre valeurs Status configurées doivent exister dans chacun de ces Projects.', + 'Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.': 'Cursor est un environnement expérimental dans Copilot. Il nécessite une CLI compatible déjà installée et CURSOR_API_KEY ; Copilot ne l’installe pas automatiquement.', + 'Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.': 'Les Secrets et Variables d’organisation nécessitent des droits sur celle-ci. Limiter l’accès aux dépôts sélectionnés est le choix le plus sûr ; les valeurs du dépôt prévalent.', +}; diff --git a/web/src/i18n/planWarnings/pt.ts b/web/src/i18n/planWarnings/pt.ts new file mode 100644 index 000000000..9a8769f57 --- /dev/null +++ b/web/src/i18n/planWarnings/pt.ts @@ -0,0 +1,16 @@ +import type { planWarningsEn } from './en'; + +export const planWarningsPt: Readonly> = { + 'No issue workflow kind is enabled; issue events will remain unmanaged until a supported Issue Form and profile entry are enabled.': 'Não está ativo nenhum tipo de fluxo de trabalho para issues. Os respetivos eventos não serão geridos até ativar um formulário de issue suportado e a entrada correspondente no perfil.', + 'Help / question issues remain branchless even when issue-managed-branches is enabled.': 'As issues de ajuda ou perguntas não criam ramos, mesmo com os ramos geridos por issues ativados.', + 'Release automation is installed, but release issue events are disabled by the selected issue workflow profile.': 'A automação de versões já está instalada, mas o perfil escolhido desativa os eventos das issues de versão.', + 'Hotfix automation is installed, but hotfix issue events are disabled by the selected issue workflow profile.': 'A automação de correções urgentes já está instalada, mas o perfil escolhido desativa os eventos das respetivas issues.', + 'Repository agent guidance generation is disabled; collaborators will not receive the generated profile or workflow skill.': 'A criação de orientações para agentes do repositório está desativada. Os colaboradores não receberão o perfil nem o guia do fluxo de trabalho gerados.', + 'Release and hotfix workflows require the workflow PAT Secret and a writable token.': 'Os fluxos de versão e correção urgente exigem o Secret com o PAT da Action e um token com permissão de escrita.', + 'Merge queue mode fails closed unless every required producer is verified automatically or covered by an exact reviewed attestation.': 'A fila de integração bloqueia a operação se cada produtor obrigatório não for verificado automaticamente ou coberto por uma declaração exata e revista.', + 'Always-provision mode reinstalls only default Codex/OpenCode runtimes from pinned manifest packages; explicit executables are never replaced and Cursor must be preinstalled.': 'O modo de aprovisionamento permanente reinstala apenas os ambientes padrão de Codex e OpenCode a partir de pacotes fixados no manifesto. Nunca substitui executáveis indicados explicitamente; o Cursor tem de estar pré-instalado.', + 'Inactive issue closure is enabled; waiting issues are closed after the configured inactivity threshold and can be reopened with a new comment.': 'O fecho de issues inativas está ativado. As que aguardam resposta serão fechadas após o prazo configurado e poderão ser reabertas com um novo comentário.', + 'Selected Project numbers must be accessible to the bot PAT, and all four configured Status values must exist in every selected Project.': 'O PAT do bot tem de ter acesso aos Projects selecionados, e os quatro valores de Status configurados têm de existir em cada Project.', + 'Cursor is an experimental runtime in Copilot and requires a compatible preinstalled CLI plus CURSOR_API_KEY; Copilot has no automatic Cursor installer.': 'O Cursor é um ambiente experimental no Copilot. Exige uma CLI compatível já instalada e CURSOR_API_KEY; o Copilot não instala o Cursor automaticamente.', + 'Organization-level Secrets and Variables require organization permissions; selected access is the safest default and repository values take precedence.': 'Os Secrets e Variables da organização exigem permissões nessa organização. Restringir o acesso aos repositórios selecionados é a opção mais segura; os valores do repositório prevalecem.', +}; diff --git a/web/src/i18n/projectTransitions.ts b/web/src/i18n/projectTransitions.ts new file mode 100644 index 000000000..be584887e --- /dev/null +++ b/web/src/i18n/projectTransitions.ts @@ -0,0 +1,7 @@ +import type { SetupMessageKey } from './catalog'; + +/** Shared presentation labels for the same Project Status transitions in questions and plan review. */ +export const projectTransitionKey: Readonly> = { + issueCreated: 'projectTransitionIssueCreated', pullRequestCreated: 'projectTransitionPullRequestCreated', + issueInProgress: 'projectTransitionIssueInProgress', pullRequestInProgress: 'projectTransitionPullRequestInProgress', +}; diff --git a/web/src/i18n/promptCopy.ts b/web/src/i18n/promptCopy.ts new file mode 100644 index 000000000..bc73e4033 --- /dev/null +++ b/web/src/i18n/promptCopy.ts @@ -0,0 +1,35 @@ +import type { WebSetupPrompt } from '../../../src/application/contracts/web_setup_view'; +import type { SetupLocale } from './catalog'; +import { promptCopyEn, type PromptCopy } from './prompts/en'; +import { promptCopyEs } from './prompts/es'; +import { promptCopyFr } from './prompts/fr'; +import { promptCopyPt } from './prompts/pt'; +import { messageCopyCatalogs } from './messageCopy'; + +export const promptCopyCatalogs: Readonly> = { + en: promptCopyEn, es: promptCopyEs, fr: promptCopyFr, pt: promptCopyPt, +}; + +function interpolate(template: string, values: Readonly>): string { + return template.replace(/\{([a-zA-Z]\w*)\}/gu, (_, key: string) => values[key] ?? ''); +} + +export function localizedPromptCopy(prompt: WebSetupPrompt, locale: SetupLocale): PromptCopy | undefined { + if (!prompt.copyId) return undefined; + const copy = promptCopyCatalogs[locale][prompt.copyId]; + const values = { ...(prompt.copyValues ?? {}) }; + if (prompt.copyId === 'credential.existing' && values.status && + ['valid', 'invalid', 'missing', 'unverifiable', 'not_required'].includes(values.status)) { + const statusId = `credential.status.${values.status}` as keyof typeof messageCopyCatalogs.en; + values.status = messageCopyCatalogs[locale][statusId]; + } + return { + title: interpolate(copy.title, values), + description: interpolate(copy.description, values), + ...(copy.choices ? { choices: copy.choices } : {}), + }; +} + +export function localizedPromptChoice(prompt: Extract, locale: SetupLocale, index: number): string { + return localizedPromptCopy(prompt, locale)?.choices?.[index] ?? prompt.choices[index]; +} diff --git a/web/src/i18n/prompts/en.ts b/web/src/i18n/prompts/en.ts new file mode 100644 index 000000000..a19b58bd5 --- /dev/null +++ b/web/src/i18n/prompts/en.ts @@ -0,0 +1,24 @@ +import type { WebSetupPromptCopyId } from '../../../../src/application/contracts/web_setup_view'; + +export interface PromptCopy { readonly title: string; readonly description: string; readonly choices?: readonly string[] } + +export const promptCopyEn: Readonly> = { + 'repository.confirm': { title: 'Confirm this repository', description: 'This checkout points to {repository} on branch {branch}. Confirm the target before configuring PAT access or files.', choices: ['Yes, this is my repository', 'Stop and choose another checkout'] }, + 'setup.depth': { title: 'Choose how much detail to review', description: 'Basic still asks about permissions, security, branch roles, Projects, approval and storage. Selected advanced agent, branch-prefix and Bugbot settings keep their defaults; inspect and edit them in the final plan. Custom asks every applicable question. Neither path applies changes now.', choices: ['Basic guided setup (recommended)', 'Customize every setting'] }, + 'setup.environmentPat': { title: 'An environment setup PAT is available', description: 'Its value stays in the CLI process and is never sent to this page. Exiting Copilot cannot unset your parent shell variable.', choices: ['Use the environment PAT', 'Create or enter a different PAT'] }, + 'plan.review': { title: 'Review your setup plan', description: 'Inspect the affected files, workflows, Variables and Secret names before approving. No setup changes start by opening this plan.' }, + 'workflow.update': { title: 'Update existing workflows?', description: 'These existing workflow files differ from the setup plan: {files}. Keeping them may leave some new settings inactive.', choices: ['Keep existing', 'Update setup-managed workflows'] }, + 'setupPat.method': { title: 'How will you provide your setup PAT?', description: 'This temporary PAT authorizes this one setup run. GitHub creates it under your operator account; Copilot cannot revoke it for you.', choices: ['Guided GitHub link', 'Manual PAT'] }, + 'setupPat.ownerKind': { title: 'Who owns this repository on GitHub?', description: 'The owner determines which organization permissions may be needed. Check the repository header on GitHub if unsure.', choices: ['Organization', 'Personal account', 'Not sure'] }, + 'setupPat.review': { title: 'Review provisional setup PAT permissions', description: 'These grants follow your choices so far. GitHub inspection may add a requirement; you will review any change before setup starts.', choices: ['Continue to GitHub', 'Review setup choices again', 'View full permission table', 'Enter a PAT manually'] }, + 'setupPat.entry': { title: 'Temporary setup PAT', description: 'Open GitHub as your operator account, complete 2FA, choose “Only select repositories”, select this repository and copy the generated PAT here. This PAT is for this run only; delete it on GitHub afterwards.' }, + 'setupPat.confirmAccount': { title: 'GitHub authenticated the setup PAT as @{account}', description: 'Is this the operator account that should configure this repository? A wrong account must stop before any setup change.', choices: ['Yes, continue', 'No, stop'] }, + 'setupPat.confirmWrites': { title: 'Confirm write permissions that GitHub cannot safely test', description: 'Some required write grants cannot be proven without a mutation. Check them in GitHub against the displayed permission table before confirming.', choices: ['No, stop', 'Yes, I checked them'] }, + 'botPat.method': { title: 'How will you provide the bot PAT?', description: 'The bot PAT is separate from the setup PAT. It belongs to the account that will run future GitHub Actions and is stored as a Secret after approval.', choices: ['Guided GitHub link', 'Manual PAT'] }, + 'botPat.login': { title: 'Expected GitHub bot login', description: 'Enter the bot account login without @. Copilot resolves its numeric GitHub ID and compares it with the PAT owner.' }, + 'botPat.entry.guided': { title: '{name} — bot account PAT', description: 'Open GitHub as @{account} (account ID {accountId}), not as the setup operator. Select only this repository, review all grants and paste the PAT here. Suggested expiry: 90 days. An existing Secret value cannot be read back.' }, + 'botPat.entry.manual': { title: '{name} — bot account PAT', description: 'Use the bot account, select only the intended repository and review all grants before pasting its PAT. Suggested expiry: 90 days. An existing Secret value cannot be read back.' }, + 'credential.apiKey': { title: '{name} — {provider} API key', description: 'Paste the API key for {provider}. It is sent only to this local setup process and, if approved, installed as a GitHub Actions Secret. Existing Secret values cannot be read back.' }, + 'credential.existing': { title: 'Existing {name}: {status}', description: 'GitHub cannot reveal the current Secret value. Keep it only if you deliberately accept that its health cannot be verified here; replace or skip it otherwise.', choices: ['Keep existing', 'Replace it', 'Skip this credential'] }, + 'apply.confirm': { title: 'Apply this setup now?', description: 'This is the final approval. Local files and selected GitHub resources may change. A partial result may require inspection before retrying.', choices: ['Apply setup', 'Stop without applying'] }, +}; diff --git a/web/src/i18n/prompts/es.ts b/web/src/i18n/prompts/es.ts new file mode 100644 index 000000000..98c7371f4 --- /dev/null +++ b/web/src/i18n/prompts/es.ts @@ -0,0 +1,23 @@ +import type { WebSetupPromptCopyId } from '../../../../src/application/contracts/web_setup_view'; +import type { PromptCopy } from './en'; + +export const promptCopyEs: Readonly> = { + 'repository.confirm': { title: 'Confirma el repositorio', description: 'Este checkout apunta a {repository} en la rama {branch}. Comprueba el destino antes de configurar permisos del PAT o archivos.', choices: ['Sí, es mi repositorio', 'Detener y elegir otro checkout'] }, + 'setup.depth': { title: 'Elige cuánto detalle quieres revisar', description: 'El modo básico sigue preguntando por permisos, seguridad, ramas, Projects, aprobación y almacenamiento. Algunos ajustes avanzados de agentes, prefijos de rama y Bugbot conservan sus valores predeterminados; podrás revisarlos y editarlos en el plan final. El modo personalizado pregunta por todos los ajustes aplicables. Ninguno aplica cambios todavía.', choices: ['Configuración básica guiada (recomendada)', 'Personalizar todos los ajustes'] }, + 'setup.environmentPat': { title: 'Hay un PAT de configuración en el entorno', description: 'Su valor permanece en el proceso CLI y no se envía a esta página. Cerrar Copilot no elimina la variable de la terminal original.', choices: ['Usar el PAT del entorno', 'Crear o introducir otro PAT'] }, + 'plan.review': { title: 'Revisa el plan de configuración', description: 'Comprueba los archivos, workflows, Variables y nombres de Secrets antes de aprobar. Abrir el plan no inicia ningún cambio.' }, + 'workflow.update': { title: '¿Actualizar los workflows existentes?', description: 'Estos archivos difieren del plan: {files}. Si los conservas, algunos ajustes nuevos podrían no activarse.', choices: ['Conservar los existentes', 'Actualizar los workflows gestionados'] }, + 'setupPat.method': { title: '¿Cómo proporcionarás el PAT de configuración?', description: 'Este PAT temporal autoriza una sola ejecución. GitHub lo crea con tu cuenta de operador; Copilot no puede revocarlo por ti.', choices: ['Enlace guiado de GitHub', 'Introducir PAT manualmente'] }, + 'setupPat.ownerKind': { title: '¿Quién es propietario de este repositorio en GitHub?', description: 'El propietario determina qué permisos de organización pueden hacer falta. Consulta la cabecera del repositorio en GitHub si dudas.', choices: ['Organización', 'Cuenta personal', 'No lo sé'] }, + 'setupPat.review': { title: 'Revisa los permisos provisionales del PAT de configuración', description: 'Estos permisos dependen de las opciones elegidas. Al inspeccionar GitHub podría aparecer otro requisito; lo revisarás antes de aplicar cambios.', choices: ['Continuar en GitHub', 'Revisar de nuevo las opciones', 'Ver la tabla completa de permisos', 'Introducir un PAT manualmente'] }, + 'setupPat.entry': { title: 'PAT temporal de configuración', description: 'Abre GitHub con tu cuenta de operador, completa el 2FA, elige «Only select repositories», selecciona este repositorio y pega aquí el PAT generado. Es solo para esta ejecución; elimínalo en GitHub después.' }, + 'setupPat.confirmAccount': { title: 'GitHub autenticó el PAT de configuración como @{account}', description: '¿Es la cuenta de operador que debe configurar este repositorio? Una cuenta incorrecta debe detener el proceso antes de cualquier cambio.', choices: ['Sí, continuar', 'No, detener'] }, + 'setupPat.confirmWrites': { title: 'Confirma permisos de escritura que GitHub no puede probar sin cambios', description: 'Algunos permisos de escritura no pueden verificarse sin modificar recursos. Compruébalos en GitHub frente a la tabla mostrada antes de confirmar.', choices: ['No, detener', 'Sí, los he comprobado'] }, + 'botPat.method': { title: '¿Cómo proporcionarás el PAT del bot?', description: 'Es distinto del PAT de configuración. Pertenece a la cuenta que ejecutará las futuras GitHub Actions y se instalará como Secret tras la aprobación.', choices: ['Enlace guiado de GitHub', 'Introducir PAT manualmente'] }, + 'botPat.login': { title: 'Usuario previsto del bot en GitHub', description: 'Introduce el nombre de usuario del bot sin @. Copilot consultará su ID numérico de GitHub y lo comparará con el propietario del PAT.' }, + 'botPat.entry.guided': { title: '{name} — PAT de la cuenta bot', description: 'Abre GitHub como @{account} (ID {accountId}), no como operador de setup. Selecciona solo este repositorio, revisa todos los permisos y pega aquí el PAT. Caducidad sugerida: 90 días. El valor de un Secret existente no puede leerse.' }, + 'botPat.entry.manual': { title: '{name} — PAT de la cuenta bot', description: 'Usa la cuenta bot, selecciona solo el repositorio previsto y revisa todos los permisos antes de pegar el PAT. Caducidad sugerida: 90 días. El valor de un Secret existente no puede leerse.' }, + 'credential.apiKey': { title: '{name} — clave API de {provider}', description: 'Pega la clave API de {provider}. Solo se envía a este proceso local y, si apruebas el plan, se instala como Secret de GitHub Actions. Los valores existentes no pueden leerse.' }, + 'credential.existing': { title: '{name} existente: {status}', description: 'GitHub no muestra el valor actual del Secret. Consérvalo solo si aceptas expresamente que aquí no se puede comprobar su estado; de lo contrario, sustitúyelo u omítelo.', choices: ['Conservar', 'Sustituir', 'Omitir esta credencial'] }, + 'apply.confirm': { title: '¿Aplicar la configuración ahora?', description: 'Esta es la aprobación final. Pueden cambiar archivos locales y recursos de GitHub seleccionados. Si el resultado es parcial, revísalo antes de reintentar.', choices: ['Aplicar configuración', 'Detener sin aplicar'] }, +}; diff --git a/web/src/i18n/prompts/fr.ts b/web/src/i18n/prompts/fr.ts new file mode 100644 index 000000000..bd17b4112 --- /dev/null +++ b/web/src/i18n/prompts/fr.ts @@ -0,0 +1,23 @@ +import type { WebSetupPromptCopyId } from '../../../../src/application/contracts/web_setup_view'; +import type { PromptCopy } from './en'; + +export const promptCopyFr: Readonly> = { + 'repository.confirm': { title: 'Confirmez ce dépôt', description: 'Ce dossier pointe vers {repository}, branche {branch}. Vérifiez la cible avant de configurer les accès PAT ou les fichiers.', choices: ['Oui, c’est mon dépôt', 'Arrêter et choisir un autre dossier'] }, + 'setup.depth': { title: 'Choisissez le niveau de détail', description: 'Le parcours de base demande toujours les autorisations, la sécurité, les branches, Projects, l’approbation et le stockage. Certains réglages avancés des agents, préfixes de branche et Bugbot conservent leurs valeurs par défaut ; vous pourrez les examiner et les modifier dans le plan final. Le parcours personnalisé pose toutes les questions applicables. Aucun changement n’est appliqué à ce stade.', choices: ['Configuration de base guidée (recommandée)', 'Personnaliser tous les réglages'] }, + 'setup.environmentPat': { title: 'Un PAT de configuration est disponible dans l’environnement', description: 'Sa valeur reste dans le processus CLI et n’est jamais envoyée à cette page. Quitter Copilot ne supprime pas la variable du shell parent.', choices: ['Utiliser le PAT de l’environnement', 'Créer ou saisir un autre PAT'] }, + 'plan.review': { title: 'Examinez le plan de configuration', description: 'Vérifiez les fichiers, workflows, Variables et noms de Secrets avant d’approuver. Ouvrir le plan ne lance aucun changement.' }, + 'workflow.update': { title: 'Mettre à jour les workflows existants ?', description: 'Ces fichiers diffèrent du plan : {files}. Les conserver peut laisser certains nouveaux réglages inactifs.', choices: ['Conserver les fichiers existants', 'Mettre à jour les workflows gérés'] }, + 'setupPat.method': { title: 'Comment fournirez-vous le PAT de configuration ?', description: 'Ce PAT temporaire autorise une seule exécution. GitHub le crée avec votre compte opérateur ; Copilot ne peut pas le révoquer à votre place.', choices: ['Lien GitHub guidé', 'Saisir un PAT manuellement'] }, + 'setupPat.ownerKind': { title: 'À qui appartient ce dépôt sur GitHub ?', description: 'Le propriétaire détermine les éventuelles autorisations d’organisation. Consultez l’en-tête du dépôt sur GitHub en cas de doute.', choices: ['Organisation', 'Compte personnel', 'Je ne sais pas'] }, + 'setupPat.review': { title: 'Examinez les autorisations provisoires du PAT de configuration', description: 'Ces autorisations suivent vos choix. L’inspection de GitHub peut révéler un besoin supplémentaire ; vous le reverrez avant tout changement.', choices: ['Continuer sur GitHub', 'Revoir les choix de configuration', 'Voir toutes les autorisations', 'Saisir un PAT manuellement'] }, + 'setupPat.entry': { title: 'PAT temporaire de configuration', description: 'Ouvrez GitHub avec votre compte opérateur, effectuez la 2FA, choisissez « Only select repositories », sélectionnez ce dépôt puis collez ici le PAT généré. Il ne sert qu’à cette exécution ; supprimez-le sur GitHub ensuite.' }, + 'setupPat.confirmAccount': { title: 'GitHub a authentifié le PAT de configuration comme @{account}', description: 'Est-ce bien le compte opérateur autorisé à configurer ce dépôt ? Un autre compte doit arrêter le processus avant tout changement.', choices: ['Oui, continuer', 'Non, arrêter'] }, + 'setupPat.confirmWrites': { title: 'Confirmez les droits d’écriture que GitHub ne peut pas vérifier sans changement', description: 'Certains droits d’écriture ne peuvent être prouvés sans modifier des ressources. Comparez-les au tableau affiché sur GitHub avant de confirmer.', choices: ['Non, arrêter', 'Oui, je les ai vérifiés'] }, + 'botPat.method': { title: 'Comment fournirez-vous le PAT du bot ?', description: 'Il est distinct du PAT de configuration. Il appartient au compte qui exécutera les futures GitHub Actions et sera installé comme Secret après approbation.', choices: ['Lien GitHub guidé', 'Saisir un PAT manuellement'] }, + 'botPat.login': { title: 'Identifiant GitHub attendu pour le bot', description: 'Saisissez l’identifiant du bot sans @. Copilot récupère son ID numérique GitHub et le compare au propriétaire du PAT.' }, + 'botPat.entry.guided': { title: '{name} — PAT du compte bot', description: 'Ouvrez GitHub en tant que @{account} (ID {accountId}), pas en tant qu’opérateur. Sélectionnez uniquement ce dépôt, vérifiez tous les droits et collez le PAT ici. Expiration suggérée : 90 jours. La valeur d’un Secret existant ne peut pas être relue.' }, + 'botPat.entry.manual': { title: '{name} — PAT du compte bot', description: 'Utilisez le compte bot, sélectionnez uniquement le dépôt voulu et vérifiez tous les droits avant de coller le PAT. Expiration suggérée : 90 jours. La valeur d’un Secret existant ne peut pas être relue.' }, + 'credential.apiKey': { title: '{name} — clé API de {provider}', description: 'Collez la clé API de {provider}. Elle est envoyée uniquement au processus local puis, si vous approuvez le plan, installée comme Secret GitHub Actions. Les valeurs existantes ne peuvent pas être relues.' }, + 'credential.existing': { title: '{name} existant : {status}', description: 'GitHub ne révèle pas la valeur actuelle du Secret. Ne la conservez que si vous acceptez explicitement de ne pas pouvoir vérifier son état ici ; sinon, remplacez-la ou ignorez-la.', choices: ['Conserver', 'Remplacer', 'Ignorer cet identifiant'] }, + 'apply.confirm': { title: 'Appliquer la configuration maintenant ?', description: 'C’est l’approbation finale. Des fichiers locaux et ressources GitHub choisis peuvent changer. Un résultat partiel doit être inspecté avant une nouvelle tentative.', choices: ['Appliquer la configuration', 'Arrêter sans appliquer'] }, +}; diff --git a/web/src/i18n/prompts/pt.ts b/web/src/i18n/prompts/pt.ts new file mode 100644 index 000000000..3ebc69906 --- /dev/null +++ b/web/src/i18n/prompts/pt.ts @@ -0,0 +1,23 @@ +import type { WebSetupPromptCopyId } from '../../../../src/application/contracts/web_setup_view'; +import type { PromptCopy } from './en'; + +export const promptCopyPt: Readonly> = { + 'repository.confirm': { title: 'Confirme este repositório', description: 'Esta pasta aponta para {repository}, no ramo {branch}. Confirme o destino antes de configurar acessos PAT ou ficheiros.', choices: ['Sim, é o meu repositório', 'Parar e escolher outra pasta'] }, + 'setup.depth': { title: 'Escolha o nível de detalhe', description: 'O percurso básico continua a perguntar sobre permissões, segurança, ramos, Projects, aprovação e armazenamento. Algumas definições avançadas de agentes, prefixos de ramos e Bugbot mantêm os valores predefinidos; poderá revê-las e editá-las no plano final. O percurso personalizado pergunta por todas as definições aplicáveis. Nenhum dos percursos aplica alterações nesta fase.', choices: ['Configuração básica guiada (recomendada)', 'Personalizar todas as definições'] }, + 'setup.environmentPat': { title: 'Existe um PAT de configuração no ambiente', description: 'O valor permanece no processo CLI e nunca é enviado para esta página. Sair do Copilot não remove a variável da shell original.', choices: ['Usar o PAT do ambiente', 'Criar ou introduzir outro PAT'] }, + 'plan.review': { title: 'Reveja o plano de configuração', description: 'Confirme ficheiros, fluxos, Variables e nomes de Secrets antes de aprovar. Abrir o plano não inicia alterações.', choices: undefined }, + 'workflow.update': { title: 'Atualizar os fluxos existentes?', description: 'Estes ficheiros diferem do plano: {files}. Conservá-los pode deixar algumas definições novas inativas.', choices: ['Conservar os existentes', 'Atualizar os fluxos geridos'] }, + 'setupPat.method': { title: 'Como irá fornecer o PAT de configuração?', description: 'Este PAT temporário autoriza uma única execução. O GitHub cria-o na sua conta de operador; o Copilot não pode revogá-lo por si.', choices: ['Ligação guiada do GitHub', 'Introduzir PAT manualmente'] }, + 'setupPat.ownerKind': { title: 'Quem é o proprietário deste repositório no GitHub?', description: 'O proprietário determina as permissões de organização necessárias. Consulte o cabeçalho do repositório no GitHub se tiver dúvidas.', choices: ['Organização', 'Conta pessoal', 'Não tenho a certeza'] }, + 'setupPat.review': { title: 'Reveja as permissões provisórias do PAT de configuração', description: 'Estas permissões seguem as suas escolhas. A inspeção do GitHub pode revelar outro requisito; irá revê-lo antes de qualquer alteração.', choices: ['Continuar no GitHub', 'Rever as escolhas', 'Ver a tabela completa de permissões', 'Introduzir um PAT manualmente'] }, + 'setupPat.entry': { title: 'PAT temporário de configuração', description: 'Abra o GitHub com a sua conta de operador, conclua a 2FA, escolha «Only select repositories», selecione este repositório e cole aqui o PAT gerado. É apenas para esta execução; elimine-o no GitHub depois.' }, + 'setupPat.confirmAccount': { title: 'O GitHub autenticou o PAT de configuração como @{account}', description: 'É esta a conta de operador que deve configurar o repositório? Uma conta errada tem de parar o processo antes de qualquer alteração.', choices: ['Sim, continuar', 'Não, parar'] }, + 'setupPat.confirmWrites': { title: 'Confirme permissões de escrita que o GitHub não pode testar sem alterações', description: 'Algumas permissões de escrita não podem ser provadas sem alterar recursos. Compare-as com a tabela no GitHub antes de confirmar.', choices: ['Não, parar', 'Sim, já as verifiquei'] }, + 'botPat.method': { title: 'Como irá fornecer o PAT do bot?', description: 'É diferente do PAT de configuração. Pertence à conta que executará as futuras GitHub Actions e será instalado como Secret após aprovação.', choices: ['Ligação guiada do GitHub', 'Introduzir PAT manualmente'] }, + 'botPat.login': { title: 'Utilizador GitHub esperado para o bot', description: 'Introduza o nome de utilizador do bot sem @. O Copilot consulta o ID numérico do GitHub e compara-o com o proprietário do PAT.' }, + 'botPat.entry.guided': { title: '{name} — PAT da conta bot', description: 'Abra o GitHub como @{account} (ID {accountId}), não como operador. Selecione apenas este repositório, reveja todas as permissões e cole aqui o PAT. Validade sugerida: 90 dias. Não é possível voltar a ler o valor de um Secret existente.' }, + 'botPat.entry.manual': { title: '{name} — PAT da conta bot', description: 'Use a conta bot, selecione apenas o repositório pretendido e reveja todas as permissões antes de colar o PAT. Validade sugerida: 90 dias. Não é possível voltar a ler o valor de um Secret existente.' }, + 'credential.apiKey': { title: '{name} — chave API de {provider}', description: 'Cole a chave API de {provider}. Só é enviada para este processo local e, se aprovar o plano, instalada como Secret do GitHub Actions. Os valores existentes não podem voltar a ser lidos.' }, + 'credential.existing': { title: '{name} existente: {status}', description: 'O GitHub não revela o valor atual do Secret. Conserve-o apenas se aceitar expressamente que aqui não se pode verificar o seu estado; caso contrário, substitua-o ou ignore-o.', choices: ['Conservar', 'Substituir', 'Ignorar esta credencial'] }, + 'apply.confirm': { title: 'Aplicar a configuração agora?', description: 'Esta é a aprovação final. Ficheiros locais e recursos GitHub selecionados podem mudar. Um resultado parcial exige inspeção antes de tentar novamente.', choices: ['Aplicar configuração', 'Parar sem aplicar'] }, +}; diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts new file mode 100644 index 000000000..f97556353 --- /dev/null +++ b/web/src/i18n/pt.ts @@ -0,0 +1,106 @@ +import type { SetupMessageKey } from './catalog'; + +export const pt: Record = { + language: 'Idioma', english: 'Inglês', spanish: 'Espanhol', + setup: 'CONFIGURAÇÃO', connecting: 'A ligar…', localSession: 'SESSÃO LOCAL', + progress: 'Progresso', studio: 'ASSISTENTE DE CONFIGURAÇÃO', journey: 'O SEU PERCURSO', + repository: 'Repositório', choices: 'Opções', setupPat: 'PAT de configuração', plan: 'Plano', botPat: 'PAT do bot e credenciais', apply: 'Aplicar', + localDesign: 'Local por conceção', localDesignBody: 'Esta página funciona no seu computador. O GitHub cria ambos os PAT nos seus próprios separadores.', + preparing: 'A preparar a configuração…', completeTitle: 'Configuração concluída.', previewTitle: 'Pré-visualização concluída.', cancelledTitle: 'Configuração cancelada.', blockedTitle: 'A configuração requer atenção.', + gettingReady: 'A PREPARAR', activeLede: 'Uma decisão de cada vez. As suas escolhas determinam permissões, plano e credenciais.', + resultLede: 'Consulte abaixo o resultado e o próximo passo. O terminal contém mais detalhes técnicos.', + readOnly: 'Separador só de leitura', readOnlyBody: 'Outro separador controla esta sessão. Pode acompanhar o progresso ou assumir o controlo.', takeOver: 'Assumir o controlo', + attention: 'Requer atenção', checked: 'Verificado', pleaseNote: 'Atenção', progressUpdate: 'Atualização de progresso', + reviewPass: 'A rever as escolhas guardadas — passagem {pass}. É a mesma execução, não um reinício.', cancelSetup: 'Cancelar configuração', + cancelConfirm: 'Cancelar esta sessão local? Os PAT já criados no GitHub continuarão a existir.', + footerLocal: 'APENAS LOCALHOST', footerCloud: 'SEM CONTA DE CONFIGURAÇÃO NA NUVEM', footerGithub: 'O GITHUB EMITE OS PAT', + currentDecision: 'DECISÃO ATUAL', session: 'SESSÃO', continue: 'Continuar', previousQuestion: 'Pergunta anterior', questionProgress: 'Pergunta {current} de {total} neste grupo · {overall} de {all} no total', yes: 'Sim', no: 'Não', permissionPreview: 'PRÉ-VISUALIZAÇÃO DAS PERMISSÕES', + changeAnswersTitle: 'Alterar respostas', changeAnswersHelp: 'Volte a uma secção sem perder as outras respostas. O plano e as permissões PAT necessárias serão verificados de novo.', changeSection: 'Alterar {section}', + editCapabilities: 'Funcionalidades', editRuntimes: 'Agentes', editModels: 'Modelos dos agentes', editRoleModels: 'Modelos por tarefa', editRepository: 'Comportamento do repositório', editDeployment: 'Releases e correções', editBugbot: 'Bugbot', editApproval: 'Aprovação de PR', editProjects: 'Projects', editProvisioning: 'Provisionamento', editStorage: 'Secrets e Variables', + suggested: 'Resposta sugerida: {answer}. Pode rever as opções antes de criar o PAT.', none: 'nenhuma', + sourceGithub: 'Observado nos metadados autenticados deste repositório no GitHub.', sourceConfig: 'Fornecido pela sua configuração; o GitHub não o substituiu.', sourceDefault: 'Valor predefinido do produto; não verificado neste repositório.', + sourceLocal: 'Observado neste checkout local; confirme que o ramo existe no GitHub antes de aplicar alterações.', + whyMatters: 'Porque importa', whenApplies: 'Quando se aplica', whereConfigured: 'Onde se configura', howToChoose: 'Como escolher', whyRecommendation: 'Porque importa', example: 'Exemplo', effect: 'O que muda', verify: 'Como verificar', learnMore: 'Ler a documentação desta opção', + resultApplied: 'A configuração foi aplicada', resultNoChanges: 'Nenhuma alteração efetuada', resultStopped: 'Nenhuma alteração iniciada', resultPartial: 'Verifique as alterações parciais', + resultCompleteBody: 'O PAT temporário não é revogado automaticamente. Elimine-o no GitHub após verificar. Guarde o PAT do bot até rodar o Secret.', + resultPartialBody: 'Um Secret ou outro recurso pode ter sido alterado. Verifique o GitHub e execute copilot doctor --read-only antes de substituir ou eliminar o PAT.', + resultNoChangesBody: 'O Copilot não começou a aplicar alterações. Os PAT criados no GitHub permanecem até serem eliminados lá.', + whatHappened: 'O que aconteceu', alreadyChanged: 'O que mudou', noChanges: 'Não foram iniciadas alterações no repositório nem no GitHub nesta sessão.', + nextAction: 'Próximo passo', reasonPermissions: 'Faltam permissões do PAT de configuração ou não foi possível confirmá-las.', nextPermissions: 'Verifique as permissões, corrija o PAT no GitHub e inicie uma nova sessão.', + reasonStorage: 'O armazenamento GitHub Actions escolhido não pôde ser usado com segurança.', nextStorage: 'Verifique o âmbito das Variables/Secrets e os recursos existentes; depois tente novamente.', + reasonConfiguration: 'Não foi possível validar a configuração escolhida.', nextConfiguration: 'Leia os detalhes no terminal, corrija as opções e tente novamente.', + reasonExpired: 'A sessão local expirou antes de aplicar alterações.', nextExpired: 'Inicie uma nova sessão; a aprovação anterior não pode ser reutilizada.', + reasonCancelled: 'A configuração foi cancelada antes de aplicar alterações.', nextCancelled: 'Inicie outra execução se ainda quiser configurar o repositório.', + reasonUnknown: 'A configuração parou antes de aplicar; a causa exata é desconhecida.', nextUnknown: 'Leia o último erro no terminal antes de tentar novamente. Não presuma que um PAT foi revogado.', + reasonProvider: 'O GitHub ou outro fornecedor não concluiu a operação solicitada.', nextProvider: 'Use a referência de diagnóstico para consultar o terminal, verifique a disponibilidade e o acesso e reveja possíveis alterações parciais antes de tentar novamente.', + reasonRateLimit: 'O GitHub limitou temporariamente os pedidos necessários para a configuração.', nextRateLimit: 'Aguarde a reposição do limite. Inspecione as alterações concluídas antes de iniciar outra configuração.', + diagnosticReference: 'Referência de diagnóstico', resourceReceipt: 'Registo das operações de configuração', effectCompleted: 'Indicada como concluída', effectSkipped: 'Indicada como ignorada', effectInspect: 'Resultado por inspecionar', + effectNotStarted: 'Não iniciada', scopeLocal: 'Checkout local', scopeMixed: 'Repositório e organização', + receiptFiles: 'Ficheiros de configuração', receiptSecrets: 'Secrets do GitHub Actions', receiptLabels: 'Etiquetas das questões', receiptIssueTypes: 'Tipos de questão', receiptVariables: 'Variables do GitHub Actions', receiptInitialTag: 'Etiqueta de versão inicial', + inspectPartial: 'Algumas alterações podem estar ativas. Verifique o GitHub e o terminal antes de tentar novamente.', + patSettings: 'Abrir definições de PAT no GitHub ↗', closeSession: 'Fechar sessão local', + doctorHelp: 'Depois de concluir a configuração, pode verificar aqui os recursos instalados sem iniciar Actions. Também pode executar copilot doctor --read-only na raiz do repositório com o PAT temporário e o mesmo ficheiro --config não secreto, caso o tenha usado.', + doctorRun: 'Verificar instalação (só de leitura)', doctorRunning: 'A verificar os recursos instalados sem iniciar Actions…', + doctorPassed: 'A verificação só de leitura não encontrou falhas.', doctorWarnings: 'A verificação só de leitura requer atenção.', + doctorFailed: 'A verificação só de leitura não terminou. Consulte o terminal ou execute o comando abaixo.', + doctorCounts: '{pass} aprovados · {warn} avisos · {fail} falhas · {skipped} ignorados.', + doctorSecretLimit: 'Este modo não consegue verificar os valores dos Secrets.', + botRenewal: 'O PAT do bot permanece no Secret do GitHub Actions escolhido para execuções futuras. A data de validade real não é verificada aqui; registe-a no GitHub e substitua o Secret antes de expirar.', + working: 'A preparar o próximo passo', workingBody: 'O processo local está a verificar as respostas. Mantenha esta página aberta.', + repoFocus: 'Repositório em foco', repoFocusBody: 'Todas as decisões desta sessão afetam apenas:', access: 'acesso', readOnlyCheck: 'Verificação de acesso só de leitura', provisionalGrants: 'Permissões provisórias mínimas', + conditionalGrants: 'As permissões condicionais dependem das escolhas e do GitHub. Haverá uma auditoria final antes das alterações.', + permissionUnknown: 'Esta permissão exige revisão. Consulte a permissão exata e a explicação técnica no terminal antes de continuar.', + permissionsFollow: 'As permissões seguem as suas escolhas', permissionsFollowBody: 'Mostramos as permissões exatas antes de criar cada PAT. Abrir esta página não cria nada.', + files: 'Ficheiros', workflows: 'Workflows', variables: 'Variables', secretNames: 'Nomes dos Secrets', + planBody: 'Reveja exatamente o que pode mudar. O PAT do bot e outras credenciais serão pedidos a seguir.', + planChoices: 'As suas decisões principais', planEnabledCapabilities: 'Funcionalidades ativadas', planBranchRoles: 'Ramo de produção / desenvolvimento', planApprovalMode: 'Aprovação de pull requests', planVariableScope: 'Âmbito das Variables', planSecretScope: 'Âmbito dos Secrets', planInitialTag: 'Criar etiqueta inicial', + planAgentRouting: 'Agente e modelo por tarefa', planIssueWorkflows: 'Fluxos de questões', planTrustedChecks: 'Produtores CI de confiança', planCoverage: 'Evidência de cobertura', planProjectStatuses: 'Transições Status dos Projects', planIssueResources: 'Etiquetas e tipos de questão', planIssueResourcesValue: 'Verificados ou criados ao aplicar', + planProducerAttested: 'Identidade CI e passo obrigatório verificados por si', planCoverageThreshold: 'Cobertura mínima das linhas alteradas', planCoverageReporter: 'Workflow que publica o artefacto', planReporterAttested: 'Produtor do relatório de cobertura verificado por si', + planAdvancedDefaults: 'O plano também inclui valores predefinidos para definições que não alterou. Use Alterar abaixo para rever qualquer secção antes de aprovar.', + planUnknownWarning: 'Um aviso adicional do plano não pode ser apresentado aqui. Leia-o no terminal antes de aprovar.', + planBasicDefaultsIntro: 'O percurso básico manteve estas definições avançadas. Abra uma secção abaixo para as rever ou alterar:', + scopeRepository: 'Repositório', scopeOrganization: 'Organização', scopeDisabled: 'Sem aprovisionamento', approvalOff: 'Desativada', approvalRecommend: 'Apenas recomendações', approvalGuarded: 'Aprovação protegida', + beforeContinue: 'Antes de continuar', stopHere: 'Parar aqui', approvePlan: 'Aprovar este plano', + githubLink: 'Abrir ligação do GitHub ↗', githubForm: 'Abrir formulário oficial de PAT no GitHub', + githubFormHelp: 'Confirme a conta ligada, escolha Only select repositories e este repositório. O GitHub gere 2FA e cria o PAT.', + pasteHere: 'Cole o valor aqui', yourAnswer: 'A sua resposta', hiddenAfter: 'Oculto após envio', typeAnswer: 'Escreva a sua resposta', + secretHelp: 'Enviado apenas ao processo local. Não será mostrado de novo nem guardado no navegador.', + pairTitle: 'Emparelhar este navegador', pairLabel: 'Código do terminal', pairPlaceholder: '16 caracteres hexadecimais', + pairBody: 'Encontre o código de 16 caracteres no terminal onde iniciou copilot setup --web. Não aparece no URL nem fica guardado após fechar a página.', + pairHelp: 'Mantenha o código privado. Após atualizar a página, introduza-o novamente.', pairButton: 'Ligar à configuração local', privateSession: 'SESSÃO LOCAL PRIVADA', + theme: 'Tema', themeAuto: 'Automático', themeSystem: 'Seguir sistema', themeLight: 'Tema claro', themeDark: 'Tema escuro', + selectOne: 'Selecione uma opção', ciRun: 'Abrir execução de CI no GitHub ↗', manualCheck: 'Check não listado? Indique nome|ID da App|workflow exatos, separados por ponto e vírgula.', + checksObserved: 'Foram encontrados jobs recentes de CI. Abra cada execução e confirme o job, a App e o passo obrigatório de cobertura antes de confiar nele.', + checksNoRecent: 'Não há execuções recentes de workflows de pull request. Execute o CI habitual numa PR real ou introduza um produtor exato.', + checksNoVerifiable: 'Há execuções recentes, mas nenhum job foi ligado a um Check Run e App exatos. Consulte o GitHub ou introduza o produtor manualmente.', + checksDenied: 'O GitHub recusou a consulta do CI. Conceda Actions: read e Checks: read ao PAT de configuração ou introduza um produtor exato.', + checksUnavailable: 'A consulta do CI falhou. Isto não prova que o repositório não tenha checks. Tente novamente ou introduza um produtor exato.', + projectsObserved: 'Estes Projects existentes pertencem ao proprietário do repositório. Selecione só os que a automatização deve atualizar.', + projectsEmpty: 'Esta consulta limitada ao GitHub não devolveu Projects abertos e acessíveis; isso não prova que não existam. Verifique o acesso ou introduza um número confirmado.', + projectsDenied: 'O GitHub recusou a consulta de Projects. Verifique Projects: read da organização no PAT ou introduza os números.', + projectsUnavailable: 'Não foi possível consultar Projects. Isto não prova que não existam. Introduza um número verificado ou tente novamente.', + projectsUnsupported: 'Esta API do GitHub não lista Projects pessoais com um PAT de permissões precisas. Introduza o número do URL de um Project existente.', + discoveryTruncated: 'Só foi inspecionada uma amostra limitada de Projects acessíveis ou checks recentes. Introduza manualmente um elemento em falta.', + checksDiscoveryScope: 'Âmbito: até 20 execuções recentes de workflows de PR; são inspecionadas no máximo 15 execuções e 100 checks por commit.', + projectsDiscoveryScope: 'Âmbito: no máximo 30 Projects abertos e acessíveis da organização em duas páginas; são inspecionados até 100 campos por Project. Os Projects fechados são excluídos.', + retryDiscovery: 'Repetir pesquisa no GitHub', retryRemaining: 'Restam {count} tentativas só de leitura. As suas respostas são mantidas.', + retryExhausted: 'Não restam tentativas. Verifique o GitHub e introduza manualmente os itens em falta.', + observationTimeUnknown: 'data de observação indisponível', branchRequirementUnknown: 'Exigido pela regra do ramo: não verificado', + branchRequirementObserved: 'Exigido em {branch} por um ruleset ativo para esta verificação e esta App específicas.', ciRule: 'Abrir ruleset da verificação obrigatória ↗', + projectSharedStatus: 'Todos os Projects escolhidos devem partilhar cada valor Status. Esta configuração não atribui valores diferentes por Project.', + fixedWorkflowEnabled: 'A sua configuração fixa features.{kind}=true. Mantenha {kind} selecionado; altere --config ou as opções para mudar esta escolha.', + fixedWorkflowDisabled: 'A sua configuração fixa features.{kind}=false. Não selecione {kind}; altere --config ou as opções para mudar esta escolha.', + fixedIssuesRequired: 'A sua configuração ativa explicitamente release ou hotfix. Mantenha Issues ativo ou altere primeiro --config ou as opções.', + projectSelectionNotObserved: 'Os números de Projects escolhidos anteriormente são mantidos, mas já não aparecem neste resultado do GitHub. Confirme-os no GitHub ou remova-os.', + removeSelection: 'Remover seleção', + projectTransitionIssueCreated: 'Nova questão', projectTransitionPullRequestCreated: 'Novo pull request', + projectTransitionIssueInProgress: 'Questão em curso', projectTransitionPullRequestInProgress: 'Pull request em curso', + projectUrl: 'Abrir Project no GitHub ↗', projectManual: 'Project em falta? Introduza o número positivo ou o URL exato do GitHub. IDs PVT_ não são aceites.', + projectStatusUnavailable: 'Não foi possível verificar as opções Status de todos os Projects. Consulte cada um no GitHub e introduza o valor exato.', + projectStatusIncompatible: 'Os Projects escolhidos não partilham valores Status. Selecione Projects compatíveis antes de continuar.', + producerName: 'Nome do check/job', producerAppId: 'ID da App GitHub de origem', producerWorkflow: 'Nome do workflow', producerAdd: 'Adicionar check exato', producerRemove: 'Remover check', + producerManualHelp: 'Use a identidade exata apresentada no GitHub. Adicioná-la não prova que exige cobertura.', + producerManualInvalid: 'Indique nome, ID numérico positivo da App e workflow. Não use | ou ; nos nomes.', + translationPreviewTitle: 'Revisão da tradução em curso', translationPreviewBody: 'As perguntas próprias da configuração já estão traduzidas. Alguns diagnósticos dinâmicos do GitHub ou do fornecedor ainda podem aparecer em inglês durante a revisão. Mudar de idioma não altera as suas respostas.', + unknownLocalError: 'Ocorreu um erro inesperado na configuração local. Consulte o terminal e atualize a página ou reinicie a configuração.', +}; diff --git a/web/src/i18n/questionOptions.ts b/web/src/i18n/questionOptions.ts new file mode 100644 index 000000000..76fa16477 --- /dev/null +++ b/web/src/i18n/questionOptions.ts @@ -0,0 +1,33 @@ +import type { SetupLocale } from './catalog'; +import { optionLabelsEs } from './options/es'; +import { optionLabelsFr } from './options/fr'; +import { optionLabelsPt } from './options/pt'; + +const catalogs = { es: optionLabelsEs, fr: optionLabelsFr, pt: optionLabelsPt } as const; +const technicalValues = new Set(['codex', 'opencode', 'cursor', 'openai', 'anthropic', 'google', 'openrouter', 'local']); + +/** Only visible labels change; submit the original option value. */ +export function questionOptionLabel(questionId: string, option: string, locale: SetupLocale): string { + if (locale === 'en' || technicalValues.has(option)) return option; + const catalog: Record = catalogs[locale]; + if (questionId === 'issueWorkflows.enabled' && option.includes(' — ')) { + const kind = option.split(' — ', 1)[0]; + if (catalog[kind]) return `${kind} — ${catalog[kind]}`; + } + if (questionId === 'repository.reconciliationCleanup' && option === 'all') { + return { es: 'Todas las ramas temporales', fr: 'Toutes les branches temporaires', pt: 'Todos os ramos temporários' }[locale]; + } + return catalog[option] ?? option; +} + +export function isQuestionOptionLocalized(questionId: string, option: string, locale: SetupLocale): boolean { + if (locale === 'en' || technicalValues.has(option)) return true; + if (questionId === 'pullRequestApproval.coverage.checkName') return true; // remote check name + if (questionId === 'issueWorkflows.enabled' && option.includes(' — ')) { + const kind = option.split(' — ', 1)[0]; + return Object.prototype.hasOwnProperty.call(catalogs[locale], kind); + } + return Object.prototype.hasOwnProperty.call(catalogs[locale], option); +} + +export const optionCatalogs = catalogs; diff --git a/web/src/i18n/sessionErrors.ts b/web/src/i18n/sessionErrors.ts new file mode 100644 index 000000000..1ed673609 --- /dev/null +++ b/web/src/i18n/sessionErrors.ts @@ -0,0 +1,20 @@ +import { tr, type SetupLocale } from './catalog'; +import { sessionErrorsEn } from './errors/en'; +import { sessionErrorsEs } from './errors/es'; +import { sessionErrorsFr } from './errors/fr'; +import { sessionErrorsPt } from './errors/pt'; + +export const sessionErrorCatalogs = { + en: sessionErrorsEn, + es: sessionErrorsEs, + fr: sessionErrorsFr, + pt: sessionErrorsPt, +} as const; + +export function localizedSessionError(raw: string, locale: SetupLocale): string { + const catalog = sessionErrorCatalogs[locale] ?? sessionErrorsEn; + if (Object.prototype.hasOwnProperty.call(catalog, raw)) { + return catalog[raw as keyof typeof sessionErrorsEn]; + } + return tr('unknownLocalError', locale); +} diff --git a/web/src/lib/focusOnRevision.ts b/web/src/lib/focusOnRevision.ts new file mode 100644 index 000000000..be388e2f0 --- /dev/null +++ b/web/src/lib/focusOnRevision.ts @@ -0,0 +1,15 @@ +/** Move keyboard/screen-reader focus only when the decision itself changes. */ +export function focusOnRevision(node: { readonly isConnected: boolean; focus(options?: { preventScroll?: boolean }): void }, initialRevision: number) { + let revision = initialRevision; + let active = true; + const focus = () => queueMicrotask(() => { if (active && node.isConnected) node.focus({ preventScroll: true }); }); + focus(); + return { + update(nextRevision: number) { + if (nextRevision === revision) return; + revision = nextRevision; + focus(); + }, + destroy() { active = false; }, + }; +} diff --git a/web/src/lib/githubLink.ts b/web/src/lib/githubLink.ts new file mode 100644 index 000000000..8699de805 --- /dev/null +++ b/web/src/lib/githubLink.ts @@ -0,0 +1,45 @@ +export function safeGithubLink(link?: string): string | undefined { + try { + const url = new URL(link ?? ''); + return url.protocol === 'https:' && url.hostname === 'github.com' + && (url.pathname === '/settings/personal-access-tokens' || url.pathname.startsWith('/settings/personal-access-tokens/')) + ? url.toString() : undefined; + } catch { + return undefined; + } +} + +/** Only the immutable run-detail route is an allowed CI evidence destination. */ +export function safeGithubRunLink(link?: string): string | undefined { + try { + const url = new URL(link ?? ''); + return url.protocol === 'https:' && url.hostname === 'github.com' + && !url.username && !url.password && !url.search && !url.hash + && /^\/[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}\/actions\/runs\/[1-9][0-9]*$/u.test(url.pathname) + ? url.toString() : undefined; + } catch { + return undefined; + } +} + +/** Exact repository ruleset page used only for verified required-check evidence. */ +export function safeGithubRulesetLink(link?: string): string | undefined { + try { + const url = new URL(link ?? ''); + return url.protocol === 'https:' && url.hostname === 'github.com' + && !url.username && !url.password && !url.search && !url.hash + && /^\/[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}\/rules\/[1-9][0-9]*$/u.test(url.pathname) + ? url.toString() : undefined; + } catch { return undefined; } +} + +/** Existing Project detail pages only; do not trust provider-supplied arbitrary GitHub URLs. */ +export function safeGithubProjectLink(link?: string): string | undefined { + try { + const url = new URL(link ?? ''); + return url.protocol === 'https:' && url.hostname === 'github.com' + && !url.username && !url.password && !url.search && !url.hash + && /^\/(?:orgs|users)\/[A-Za-z0-9-]{1,39}\/projects\/[1-9][0-9]*$/u.test(url.pathname) + ? url.toString() : undefined; + } catch { return undefined; } +} diff --git a/web/src/lib/helpLink.ts b/web/src/lib/helpLink.ts new file mode 100644 index 000000000..17713f72f --- /dev/null +++ b/web/src/lib/helpLink.ts @@ -0,0 +1,11 @@ +/** Documentation links are source-controlled; this final check protects against future transport mistakes. */ +export function safeHelpLink(candidate: string | undefined): string | undefined { + if (!candidate) return undefined; + try { + const url = new URL(candidate); + if (url.protocol !== 'https:' || url.username || url.password || url.search) return undefined; + if (url.hostname === 'docs.page' && url.port === '' && url.pathname.startsWith('/vypdev/copilot/')) return url.href; + if (url.hostname === 'docs.github.com' && url.port === '' && url.pathname.startsWith('/en/')) return url.href; + } catch { return undefined; } + return undefined; +} diff --git a/web/src/lib/manualProducerIdentity.ts b/web/src/lib/manualProducerIdentity.ts new file mode 100644 index 000000000..799723ce3 --- /dev/null +++ b/web/src/lib/manualProducerIdentity.ts @@ -0,0 +1,14 @@ +export function manualProducerIdentity( + rawName: string, + rawAppId: string | number | undefined, + rawWorkflow: string, +): string | undefined { + const name = rawName.trim(); + const workflow = rawWorkflow.trim(); + const appId = String(rawAppId ?? '').trim(); + const numericId = Number(appId); + if (!name || !workflow || name.length > 100 || workflow.length > 100 + || /[|;\r\n]/u.test(name + workflow) + || !/^[1-9]\d*$/u.test(appId) || !Number.isSafeInteger(numericId)) return undefined; + return `${name}|${numericId}|${workflow}`; +} diff --git a/web/src/lib/pairingCode.ts b/web/src/lib/pairingCode.ts new file mode 100644 index 000000000..4083552eb --- /dev/null +++ b/web/src/lib/pairingCode.ts @@ -0,0 +1,3 @@ +export function canSubmitPairingCode(code: string, busy: boolean): boolean { + return !busy && /^[A-Fa-f0-9]{16}$/u.test(code.trim()); +} diff --git a/web/src/lib/questionAnswer.ts b/web/src/lib/questionAnswer.ts new file mode 100644 index 000000000..e6c22499d --- /dev/null +++ b/web/src/lib/questionAnswer.ts @@ -0,0 +1,33 @@ +import type { WebSetupPrompt } from '../../../src/application/contracts/web_setup_view'; + +type QuestionPrompt = Extract; + +export function initialQuestionAnswer(prompt: QuestionPrompt): { value: string; selected: string[] } { + const value = String(prompt.question.defaultValue); + const defaults = value.split(',').map(item => item.trim()).filter(Boolean); + const selected = prompt.question.kind === 'multi-select' + ? defaults.includes('All') && prompt.question.choices?.includes('All') ? ['All'] + : (prompt.question.choices ?? []).filter(item => item !== 'All' && defaults.includes(item.split(' — ')[0])) + : prompt.question.kind === 'scope-overrides' ? defaults + : prompt.question.kind === 'producer-select' ? value.split(';').map(item => item.trim()).filter(Boolean) + : prompt.question.kind === 'project-select' ? defaults.filter(number => + prompt.question.projectCandidates?.some(candidate => String(candidate.number) === number)) : []; + const manualProjects = prompt.question.kind === 'project-select' + ? defaults.filter(number => !selected.includes(number)).join(',') : value; + return { value: prompt.question.kind === 'producer-select' ? '' : manualProjects, selected }; +} + +export function toggleSelection(selected: string[], item: string): string[] { + if (item === 'All') return selected.includes('All') ? [] : ['All']; + return selected.includes(item) + ? selected.filter(candidate => candidate !== item) + : [...selected.filter(candidate => candidate !== 'All'), item]; +} + +export function submittedQuestionAnswer(prompt: QuestionPrompt, value: string, selected: string[]): string { + if (prompt.question.kind === 'scope-overrides') return selected.length ? selected.join(',') : 'none'; + if (prompt.question.kind === 'multi-select') return selected.length ? selected.join(',') : 'none'; + if (prompt.question.kind === 'producer-select') return [...selected, ...value.split(';').map(item => item.trim()).filter(Boolean)].join(';'); + if (prompt.question.kind === 'project-select') return [...selected, ...value.split(',').map(item => item.trim()).filter(Boolean)].join(',') || 'none'; + return value; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 000000000..e9bc4ed7d --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,5 @@ +import App from './App.svelte'; +import './style.css'; +import { mount } from 'svelte'; + +mount(App, { target: document.getElementById('app')! }); diff --git a/web/src/session/setupSession.ts b/web/src/session/setupSession.ts new file mode 100644 index 000000000..abf193f0f --- /dev/null +++ b/web/src/session/setupSession.ts @@ -0,0 +1,179 @@ +import { writable } from 'svelte/store'; +import type { WebSetupView } from '../../../src/application/contracts/web_setup_view'; + +interface SessionState { + view?: WebSetupView; + paired: boolean; + controller: boolean; + busy: boolean; + error: string; +} + +interface Bootstrap { + controller: boolean; + capability?: string; +} + +export function createSetupSession(initialSessionKey?: string) { + const state = writable({ paired: Boolean(initialSessionKey), controller: false, busy: false, error: '' }); + let sessionKey = initialSessionKey; + let capability: string | undefined; + let current: SessionState = { paired: Boolean(initialSessionKey), controller: false, busy: false, error: '' }; + let loading = false; + + function set(patch: Partial): void { + current = { ...current, ...patch }; + state.set(current); + } + + async function refresh(preserveError = false): Promise { + if (loading || !sessionKey) return; + loading = true; + try { + const response = await fetch('/api/state', { cache: 'no-store', headers: { 'X-Setup-Session-Key': sessionKey } } as RequestInit); + if (!response.ok) throw new Error('The local setup session is unavailable.'); + set({ view: await response.json() as WebSetupView, ...(preserveError ? {} : { error: '' }) }); + } catch { + set({ view: undefined, error: 'Connection lost. The CLI may have stopped. Check the terminal before trying again.' }); + } finally { + loading = false; + } + } + const poll = (): Promise => refresh(true); + async function connect(): Promise { + if (!sessionKey) return; + try { + const response = await fetch('/api/bootstrap', { cache: 'no-store', headers: { 'X-Setup-Session-Key': sessionKey } } as RequestInit); + if (!response.ok) throw new Error('Could not join this local session.'); + const bootstrap = await response.json() as Bootstrap; + capability = bootstrap.capability; + set({ controller: bootstrap.controller, error: '' }); + await refresh(); + } catch { + sessionKey = undefined; + capability = undefined; + set({ view: undefined, paired: false, controller: false, error: 'Could not connect to the local setup session. Check the terminal and pair again.' }); + } + } + async function pair(code: string): Promise { + if (current.busy || current.paired) return; + set({ busy: true, error: '' }); + try { + const response = await fetch('/api/pair', { + method: 'POST', cache: 'no-store', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: code.trim().toLowerCase() }), + } as RequestInit); + const data = await response.json() as Record; + if (!response.ok) throw new Error(String(data.error ?? 'Pairing was rejected.')); + if (typeof data.sessionKey !== 'string' || !/^[a-f0-9]{64}$/.test(data.sessionKey)) throw new Error('Invalid local pairing response.'); + sessionKey = data.sessionKey; + set({ paired: true, error: '' }); + await connect(); + } catch (cause) { + set({ error: cause instanceof Error ? cause.message : 'Could not pair this browser.' }); + } finally { + set({ busy: false }); + } + } + async function post(path: string, body: Record, authorized = true): Promise> { + const response = await fetch(path, { + method: 'POST', cache: 'no-store', + headers: { 'Content-Type': 'application/json', 'X-Setup-Session-Key': sessionKey, + ...(authorized && capability ? { 'X-Setup-Capability': capability } : {}) }, + body: JSON.stringify(body), + } as RequestInit); + const data = await response.json() as Record; + if (!response.ok) throw new Error(String(data.error ?? 'The request was rejected.')); + return data; + } + async function recoverMutation(cause: unknown, fallback: string): Promise { + const message = cause instanceof Error ? cause.message : fallback; + set({ error: message }); + if (/read-only|Control moved/.test(message)) await connect(); + else await refresh(true); + } + + async function submit(revision: number, value: string): Promise { + if (current.busy || !current.controller || current.view?.promptRevision !== revision) return; + set({ busy: true, error: '' }); + try { + await post('/api/answer', { revision, value }); + await refresh(); + } catch (cause) { + await recoverMutation(cause, 'Could not submit this answer.'); + } finally { + set({ busy: false }); + } + } + + async function retryDiscovery(revision: number): Promise { + if (current.busy || !current.controller || current.view?.promptRevision !== revision) return; + set({ busy: true, error: '' }); + try { + await post('/api/retry-discovery', { revision }); + await refresh(); + } catch (cause) { + await recoverMutation(cause, 'Could not retry discovery.'); + } finally { + set({ busy: false }); + } + } + + async function back(revision: number): Promise { + if (current.busy || !current.controller || current.view?.promptRevision !== revision) return; + set({ busy: true, error: '' }); + try { + await post('/api/back', { revision }); + await refresh(); + } catch (cause) { + await recoverMutation(cause, 'Could not return to the previous question.'); + } finally { + set({ busy: false }); + } + } + + async function cancel(): Promise { + if (!current.controller || current.busy) return; + set({ busy: true, error: '' }); + try { + await post('/api/cancel', {}); + await refresh(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : 'Cancellation failed.'; + set({ error: message }); + if (/read-only|Control moved/.test(message)) await connect(); + } finally { + set({ busy: false }); + } + } + + async function takeOver(code: string): Promise { + if (current.busy || !current.paired || current.controller) return; + set({ busy: true, error: '' }); + try { + const result = await post('/api/takeover', { code: code.trim().toLowerCase() }, false); + capability = String(result.capability); + set({ controller: true, error: '' }); + await refresh(); + } catch (cause) { + set({ error: cause instanceof Error ? cause.message : 'Takeover failed.' }); + await refresh(true); + } finally { + set({ busy: false }); + } + } + + async function close(): Promise { + try { await post('/api/close', {}); } + catch { /* The CLI can also be stopped in the terminal. */ } + } + + async function runDoctor(): Promise { + if (current.busy || !current.controller || current.view?.outcome !== 'complete') return; + set({ busy: true, error: '', view: { ...current.view, doctor: { status: 'running' } } }); + try { await post('/api/doctor', {}); } + catch (cause) { set({ error: cause instanceof Error ? cause.message : 'Read-only verification failed.' }); } + finally { await refresh(true); set({ busy: false }); } + } + return { subscribe: state.subscribe, pair, connect, refresh, poll, submit, retryDiscovery, back, cancel, takeOver, close, runDoctor }; +} diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 000000000..c5ed5b97e --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,6 @@ +@import './styles/tokens.css'; +@import './styles/foundation.css'; +@import './styles/layout.css'; +@import './styles/controls.css'; +@import './styles/feedback.css'; +@import './styles/responsive.css'; diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css new file mode 100644 index 000000000..a73890b81 --- /dev/null +++ b/web/src/styles/controls.css @@ -0,0 +1,58 @@ +.decision-card { padding: clamp(25px, 3vw, 40px); min-height: 360px; } +.card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 29px; } +.card-kicker { font-size: 10px; font-weight: 900; color: var(--accent-strong); letter-spacing: .17em; } +.revision { color: var(--muted); font-size: 10px; letter-spacing: .08em; } +.description { white-space: pre-line; color: var(--muted); line-height: 1.65; margin-top: 0; font-size: 13px; } +.question-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin-bottom: 15px; } +.question-heading h2, .decision-card > label { display: block; font-weight: 700; font-size: 15px; line-height: 1.4; margin: 0 0 10px; } +.phase-tag { color: var(--accent-strong); background: var(--accent-tint); font-size: 9px; font-weight: 900; padding: 6px 8px; border-radius: 5px; letter-spacing: .07em; } +input[type="text"], input[type="password"], input[type="number"], select { width: 100%; min-height: 46px; border: 1px solid var(--control-line); border-radius: 8px; background: var(--surface-soft); color: var(--text); padding: 10px 13px; } +textarea { width: 100%; min-height: 120px; resize: vertical; border: 1px solid var(--control-line); border-radius: 8px; background: var(--surface-soft); color: var(--text); padding: 12px 13px; font: inherit; line-height: 1.5; } +.language-switch { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; white-space: nowrap; } +.language-switch select { width: auto; min-height: 32px; max-width: 140px; padding: 5px 8px; font-size: 11px; } +.question-details { border: 1px solid var(--line); background: var(--surface-soft); border-radius: 8px; padding: 13px 15px; margin-bottom: 20px; font-size: 12px; } +.question-details summary { cursor: pointer; color: var(--accent-strong); font-weight: 800; } +.question-details dl { display: grid; grid-template-columns: minmax(90px, 130px) minmax(0, 1fr); gap: 10px 14px; margin: 14px 0 0; line-height: 1.55; } +.question-details dt { font-weight: 750; color: var(--text); } +.question-details dd { margin: 0; color: var(--muted); } +.question-help-link { margin: -12px 0 18px; font-size: 12px; font-weight: 700; } +.discovery-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin: 4px 0 16px; } +.discovery-actions .field-help { margin: 0; } +.secondary-button { border: 1px solid var(--control-line); border-radius: 8px; background: var(--surface-soft); color: var(--accent-strong); padding: 9px 13px; font: inherit; font-size: 12px; font-weight: 750; } +.secondary-button:hover:not(:disabled), .secondary-button:focus-visible { border-color: var(--accent); background: var(--accent-tint); } +.secondary-button:disabled { opacity: .55; cursor: not-allowed; } +.status-review-list { margin: 6px 0 18px; padding-inline-start: 20px; color: var(--muted); font-size: 12px; line-height: 1.7; } +.status-review-list strong { color: var(--text); } +.producer-grid { max-height: 330px; margin-bottom: 15px; } +.producer-option { align-items: flex-start; cursor: pointer; } +.producer-option > span { min-width: 0; display: grid; gap: 5px; overflow-wrap: anywhere; } +.producer-option small { color: var(--muted); font-size: 10px; line-height: 1.5; } +.producer-option a { font-size: 11px; } +input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px; } +.field-help { color: var(--muted); font-size: 12px; line-height: 1.6; margin: 14px 0 24px; } +.segmented { display: flex; gap: 9px; } +.segmented button { flex: 1; padding: 13px; border: 1px solid var(--control-line); border-radius: 8px; color: var(--text); background: var(--surface-soft); font-weight: 700; } +.segmented button.selected { border-color: var(--accent); background: var(--accent-tint); color: var(--accent-strong); } +.check-grid { display: grid; gap: 7px; max-height: 280px; overflow-y: auto; } +.check-option { display: flex; align-items: center; gap: 10px; padding: 10px 13px; background: var(--surface-soft); border: 1px solid var(--control-line); border-radius: 7px; font-size: 12px; } +.choice-list { display: grid; gap: 9px; } +.choice-card { width: 100%; min-height: 52px; display: flex; justify-content: space-between; align-items: center; text-align: left; background: var(--surface-soft); color: var(--text); border: 1px solid var(--control-line); border-radius: 8px; padding: 13px 15px; font-size: 13px; font-weight: 650; } +.choice-card:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-tint); } +.github-link { display: block; padding: 14px; background: var(--accent-tint); border-radius: 8px; border: 1px solid var(--accent); font-size: 13px; font-weight: 800; text-decoration: none; margin: 0 0 12px; } +.github-link span { float: right; } +.plan-sections { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.plan-decisions { background: var(--surface-soft); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 15px; } +.plan-decisions h3 { margin: 0 0 10px; font-size: 13px; } +.plan-decisions dl { margin: 0; display: grid; gap: 8px; } +.plan-decisions dl > div { display: grid; grid-template-columns: minmax(130px, 35%) 1fr; gap: 12px; font-size: 12px; } +.plan-decisions dt { color: var(--muted); } +.plan-decisions dd { margin: 0; overflow-wrap: anywhere; } +.plan-sections > div, .plan-warnings { background: var(--surface-soft); border: 1px solid var(--line); border-radius: 8px; padding: 13px; } +.plan-sections h3, .plan-warnings h3 { font-size: 12px; margin: 0 0 8px; display: flex; justify-content: space-between; } +.plan-sections h3 span { color: var(--accent-strong); } +.plan-sections ul, .plan-warnings ul { margin: 0; padding-inline-start: 18px; max-height: 120px; overflow: auto; font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; } +.plan-warnings { margin-top: 10px; color: var(--warn); background: var(--warn-bg); } +.plan-edit { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 14px; } +.plan-edit h3 { margin: 0 0 4px; font-size: 14px; } +.plan-edit-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.button-row { display: flex; justify-content: space-between; gap: 10px; margin-top: 18px; } diff --git a/web/src/styles/feedback.css b/web/src/styles/feedback.css new file mode 100644 index 000000000..7de70559c --- /dev/null +++ b/web/src/styles/feedback.css @@ -0,0 +1,25 @@ +.review-pass, .banner { padding: 14px 18px; margin: 0 0 20px; border-radius: 8px; font-size: 12px; line-height: 1.5; white-space: pre-line; } +.review-pass { color: var(--accent-strong); background: var(--accent-tint); border: 1px solid var(--accent); } +.review-pass span { margin-inline-end: 8px; font-weight: 800; } +.banner { background: var(--surface-soft); border: 1px solid var(--line); } +.banner p { margin: 5px 0 0; } +.banner.warning { color: var(--warn); background: var(--warn-bg); border-color: var(--warn); } +.banner.error { color: var(--error); background: var(--error-bg); border-color: var(--error); } +.banner.success { color: var(--accent-strong); background: var(--accent-tint); border-color: var(--accent); } +.banner button { margin-top: 12px; } +.cancel-link { margin-top: 18px; background: transparent; border: 0; color: var(--muted); text-decoration: underline; font-size: 12px; padding: 5px 0; } +.result-card, .waiting-card { padding: 36px; max-width: 750px; } +.result-icon { display: grid; place-items: center; width: 43px; height: 43px; border-radius: 50%; background: var(--accent-tint); color: var(--accent-strong); font-size: 22px; } +.result-card h2, .waiting-card h2 { font-size: 21px; margin: 18px 0 10px; } +.result-card p, .waiting-card p { line-height: 1.6; color: var(--muted); font-size: 13px; } +.result-facts { margin: 20px 0; border: 1px solid var(--line); border-radius: 8px; padding: 8px 17px; background: var(--surface-soft); } +.result-facts p { margin: 8px 0; } +.result-facts strong { color: var(--text); } +.result-effects { margin: 20px 0; border: 1px solid var(--line); border-radius: 8px; padding: 14px 17px; background: var(--surface-soft); } +.result-effects h3 { margin: 0 0 10px; color: var(--text); font-size: 13px; } +.result-effects ul { margin: 0; padding-left: 20px; } +.result-effects li { padding: 3px 0; overflow-wrap: anywhere; font-size: 12px; } +.result-links { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; margin: 22px 0; font-size: 12px; } +.result-links code { background: var(--surface-soft); padding: 8px; border-radius: 5px; } +.spinner { width: 25px; height: 25px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/web/src/styles/foundation.css b/web/src/styles/foundation.css new file mode 100644 index 000000000..1d51ad297 --- /dev/null +++ b/web/src/styles/foundation.css @@ -0,0 +1,16 @@ +* { box-sizing: border-box; } +body { margin: 0; background: var(--page); color: var(--text); } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .5; } +:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +a { color: var(--accent-strong); text-underline-offset: 3px; } +.card, .context-card { background: var(--surface); border: 1px solid var(--line); border-radius: 15px; box-shadow: var(--shadow); } +.primary { border: 1px solid var(--accent-strong); background: var(--accent-strong); color: var(--side); padding: 12px 18px; border-radius: 8px; font-size: 12px; font-weight: 800; min-height: 43px; } +:root[data-theme="light"] .primary, :root:not([data-theme="dark"]) .primary { color: #fff; } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .primary { color: #0d2419; } } +.primary span { margin-left: 18px; } +.primary:hover:not(:disabled) { filter: brightness(1.1); } +.secondary { border: 1px solid var(--control-line); color: var(--text); background: var(--surface-soft); border-radius: 8px; padding: 11px 16px; font-size: 12px; font-weight: 700; } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } } diff --git a/web/src/styles/layout.css b/web/src/styles/layout.css new file mode 100644 index 000000000..f05e6ad36 --- /dev/null +++ b/web/src/styles/layout.css @@ -0,0 +1,52 @@ +.shell { min-height: 100vh; display: grid; grid-template-columns: minmax(250px, 288px) minmax(0, 1fr); } +.sidebar { background: var(--side); color: var(--side-text); padding: 34px 28px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; } +.brand { display: flex; align-items: center; gap: 13px; letter-spacing: -.035em; } +.brand-mark { width: 37px; height: 37px; display: grid; place-items: center; border-radius: 11px; background: #75d9a0; color: #0c3021; font-size: 26px; line-height: 1; } +.brand strong { display: block; font-size: 23px; line-height: 1; } +.brand small { display: block; font-size: 9px; letter-spacing: .23em; margin-top: 5px; color: #aac8bd; font-weight: 800; } +.rail-caption { color: #9dbab0; font-size: 10px; letter-spacing: .18em; font-weight: 800; margin-block: 78px 22px; margin-inline-start: 7px; } +.steps { padding: 0; margin: 0; list-style: none; position: relative; } +.steps::before { content: ''; position: absolute; top: 22px; bottom: 22px; inset-inline-start: 19px; width: 1px; background: var(--side-line); } +.steps li { position: relative; min-height: 55px; display: flex; align-items: center; gap: 16px; padding-block: 8px; padding-inline: 1px 12px; color: #a6c2b7; font-size: 13px; font-weight: 600; border-radius: 10px; } +.steps li.current { background: #25443b; color: #fff; } +.steps li.completed { color: #dbf2e4; } +.step-index { flex: 0 0 37px; height: 37px; border: 1px solid var(--side-line); border-radius: 50%; display: grid; place-items: center; background: var(--side); font-size: 11px; font-weight: 800; letter-spacing: .04em; } +.steps .current .step-index { background: #80dba8; border-color: #80dba8; color: #102b1d; } +.steps .completed .step-index { background: #204c37; border-color: #45966b; color: #b9f8c9; font-size: 15px; } +.sidebar-note { margin-top: auto; padding: 19px 16px; border: 1px solid var(--side-line); border-radius: 13px; display: flex; gap: 13px; background: rgba(255,255,255,.035); } +.sidebar-note > span { font-size: 20px; color: #8fe1ae; } +.sidebar-note strong { font-size: 12px; } +.sidebar-note p { color: #afcabe; font-size: 11px; line-height: 1.6; margin: 6px 0 0; } +.main { min-width: 0; } +.topbar { height: 80px; border-bottom: 1px solid var(--line); background: var(--surface); display: flex; justify-content: space-between; align-items: center; padding: 0 clamp(24px, 4vw, 70px); gap: 16px; } +.breadcrumb { display: flex; align-items: center; gap: 12px; font-size: 12px; min-width: 0; } +.breadcrumb span:first-child { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .14em; } +.breadcrumb span:nth-child(2) { color: var(--muted); } +.breadcrumb strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.top-actions { display: flex; align-items: center; gap: 18px; flex-shrink: 0; } +.local-pill { color: var(--accent-strong); background: var(--accent-tint); padding: 8px 11px; border-radius: 6px; font-size: 10px; font-weight: 800; letter-spacing: .08em; white-space: nowrap; } +.pulse-dot { width: 6px; height: 6px; display: inline-block; margin-right: 5px; border-radius: 50%; background: currentColor; } +.theme-switch { display: flex; padding: 3px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); } +.theme-switch button { background: transparent; color: var(--muted); border: 0; min-width: 31px; height: 27px; border-radius: 5px; font-size: 11px; } +.theme-switch button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 4px rgba(0,0,0,.12); font-weight: 800; } +.content { max-width: 1320px; padding: 52px clamp(24px, 4vw, 70px) 35px; margin: 0 auto; } +.eyebrow { display: flex; align-items: center; gap: 10px; color: var(--accent-strong); font-size: 10px; letter-spacing: .18em; font-weight: 900; } +.eyebrow-line { width: 21px; height: 2px; background: var(--accent); } +.eyebrow-count { color: var(--muted); margin-inline-start: 8px; letter-spacing: .09em; } +h1 { font-size: clamp(30px, 3vw, 45px); line-height: 1.15; letter-spacing: -.045em; margin: 15px 0 13px; max-width: 860px; } +.lede { color: var(--muted); line-height: 1.65; font-size: 14px; max-width: 700px; margin: 0 0 30px; } +.workspace-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(230px, .8fr); gap: 19px; align-items: start; } +.context-column { display: grid; gap: 17px; } +.context-card { padding: 25px; box-shadow: none; } +.context-icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: var(--accent-tint); color: var(--accent-strong); font-size: 19px; } +.context-card h2 { font-size: 14px; margin: 17px 0 7px; letter-spacing: -.015em; } +.context-card p, .context-card > small { display: block; font-size: 12px; line-height: 1.65; color: var(--muted); margin: 0 0 12px; } +.context-card code { display: block; background: var(--surface-soft); padding: 10px; border-radius: 6px; overflow-wrap: anywhere; font-size: 11px; } +.permissions ul { padding: 0; margin: 8px 0 13px; list-style: none; max-height: 270px; overflow: auto; } +.permissions li { display: flex; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 11px; } +.permissions li small { color: var(--muted); display: block; margin-top: 3px; } +.permissions li strong { color: var(--accent-strong); text-transform: uppercase; font-size: 9px; } +footer { margin-top: 40px; color: var(--muted); opacity: .85; font-size: 9px; letter-spacing: .11em; font-weight: 700; } +footer span { margin: 0 8px; } +[dir="rtl"] .context-card code, [dir="rtl"] .result-links code, [dir="rtl"] .producer-option strong, +[dir="rtl"] .producer-option small, [dir="rtl"] input[type="password"] { direction: ltr; unicode-bidi: isolate; } diff --git a/web/src/styles/responsive.css b/web/src/styles/responsive.css new file mode 100644 index 000000000..e9d3f18bd --- /dev/null +++ b/web/src/styles/responsive.css @@ -0,0 +1,5 @@ +@media (max-width: 1100px) { .workspace-grid { grid-template-columns: 1fr; } .context-column { grid-template-columns: repeat(2, minmax(0,1fr)); } } +@media (max-width: 780px) { .shell { display: block; } .sidebar { position: static; height: auto; padding: 16px 20px; } .rail-caption, .sidebar-note { display: none; } .steps { display: flex; overflow-x: auto; margin-top: 18px; gap: 4px; } .steps::before { display: none; } .steps li { flex: 0 0 auto; min-height: 37px; padding: 4px 7px; font-size: 11px; gap: 6px; } .step-index { width: 26px; height: 26px; flex-basis: 26px; } .topbar { height: auto; min-height: 65px; flex-wrap: wrap; padding: 12px 20px; } .content { padding: 28px 20px; } } +@media (max-width: 540px) { .context-column, .plan-sections { grid-template-columns: 1fr; } .top-actions { width: 100%; justify-content: space-between; } .decision-card { padding: 22px; } .breadcrumb { max-width: 100%; } h1 { font-size: 29px; } } +@media (max-width: 540px) { .plan-decisions dl > div { grid-template-columns: 1fr; gap: 2px; } } +@media (max-width: 540px) { .question-details dl { grid-template-columns: 1fr; gap: 3px; } .question-details dd { margin-bottom: 9px; } .language-switch { margin-inline-start: auto; } } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 000000000..c0be70663 --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,27 @@ +:root { + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color-scheme: light dark; + --page: #f6f8f7; --side: #102423; --side-line: #31504b; --side-text: #e8f5f0; + --surface: #fff; --surface-soft: #f3f7f5; --line: #d7e3df; --control-line: #748b82; --text: #18312c; + --muted: #58736b; --accent: #176e5e; --accent-strong: #075743; --accent-tint: #dff4e9; + --focus: #966000; --warn: #76510d; --warn-bg: #fff6df; --error: #a73434; + --error-bg: #fff0ec; --shadow: 0 18px 50px rgba(30, 64, 52, .08); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --page: #0d1716; --side: #10201d; --side-line: #294a41; --side-text: #eaf7f1; + --surface: #182722; --surface-soft: #20342d; --line: #355247; --control-line: #688f7f; --text: #eaf5ee; + --muted: #adccbc; --accent: #7ed6ac; --accent-strong: #a4edc2; --accent-tint: #234b38; + --focus: #ffca6a; --warn: #ffdd8a; --warn-bg: #463b21; --error: #ffc0b7; + --error-bg: #4a2b2a; --shadow: 0 18px 50px rgba(0, 0, 0, .14); + } +} +:root[data-theme="dark"] { + --page: #0d1716; --side: #10201d; --side-line: #294a41; --side-text: #eaf7f1; + --surface: #182722; --surface-soft: #20342d; --line: #355247; --control-line: #688f7f; --text: #eaf5ee; + --muted: #adccbc; --accent: #7ed6ac; --accent-strong: #a4edc2; --accent-tint: #234b38; + --focus: #ffca6a; --warn: #ffdd8a; --warn-bg: #463b21; --error: #ffc0b7; + --error-bg: #4a2b2a; --shadow: 0 18px 50px rgba(0, 0, 0, .14); +} +:root[data-theme="light"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..36703ec0f --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "types": ["svelte", "vite/client"], + "rootDir": "..", + "allowJs": true, + "checkJs": false, + "noEmit": true + }, + "include": ["src/**/*", "vite.config.mts", "../src/application/contracts/web_setup_view.ts"] +} diff --git a/web/vite.config.mts b/web/vite.config.mts new file mode 100644 index 000000000..c4e549ab9 --- /dev/null +++ b/web/vite.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + root: 'web', + base: './', + plugins: [svelte()], + build: { outDir: '../build/web', emptyOutDir: true, sourcemap: false }, +});