diff --git a/backend.ts b/backend.ts index 3950484..47ab32e 100644 --- a/backend.ts +++ b/backend.ts @@ -214,6 +214,25 @@ export async function getDeployments(id: string, signal?: AbortSignal): Promise< return await backendRequest(`/glues/${id}/deployments`, { signal }); } +export interface AssociateDeploymentAccountParams { + type: string; + accountSelector?: Record; + accountId: string; +} + +export async function associateDeploymentAccount( + deploymentId: string, + params: AssociateDeploymentAccountParams, + signal?: AbortSignal, +): Promise { + await backendRequest<{ success: true }>(`/deployments/${deploymentId}/associateAccount`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + signal, + }); +} + function areDeploymentsEqual(a: DeploymentDTO, b: DeploymentDTO): boolean { if (a.status !== b.status) { return false; @@ -230,6 +249,9 @@ function areDeploymentsEqual(a: DeploymentDTO, b: DeploymentDTO): boolean { if (!equal(a.registrationGroupsToSetup, b.registrationGroupsToSetup)) { return false; } + if (!equal(a.compatibleAccounts, b.compatibleAccounts)) { + return false; + } return true; } diff --git a/commands/deploy.ts b/commands/deploy.ts index 4659aa9..20c5023 100644 --- a/commands/deploy.ts +++ b/commands/deploy.ts @@ -41,9 +41,12 @@ export async function deploy(options: DeployOptions, file: string) { }; let instance: Instance | undefined; - const unmountUI = () => { + let exitOnUnmount = false; + const unmountUI = async () => { if (instance) { + exitOnUnmount = false; instance.unmount(); + await instance.waitUntilExit(); instance = undefined; } }; @@ -53,7 +56,14 @@ export async function deploy(options: DeployOptions, file: string) { if (instance) { instance.rerender(element); } else { + exitOnUnmount = true; instance = render(element); + instance.waitUntilExit().then(() => { + // Exit only when Ink unmounts itself, such as when the user presses Ctrl-C. + if (exitOnUnmount) { + process.exit(0); + } + }); } }; @@ -65,7 +75,7 @@ export async function deploy(options: DeployOptions, file: string) { updateUI({ codeAnalysisDuration: performance.now() - duration, codeAnalysisState: "success" }); if (options.debugWriteCreateDeploymentParams) { - unmountUI(); + await unmountUI(); await Deno.writeTextFile( options.debugWriteCreateDeploymentParams, JSON.stringify(deploymentParams, null, 2) + "\n", @@ -99,7 +109,7 @@ export async function deploy(options: DeployOptions, file: string) { !lookupResult.value || absPath.localeCompare(lookupResult.value, undefined, { sensitivity: "base" }) !== 0 ) { - unmountUI(); + await unmountUI(); console.warn( `Warning: You are deploying to an existing glue named %c${ JSON.stringify(glueName) @@ -141,6 +151,6 @@ export async function deploy(options: DeployOptions, file: string) { } // Sometimes client libraries keep connections alive or something, preventing // the process from naturally exiting immediately, so we explicitly exit here. - unmountUI(); + await unmountUI(); Deno.exit(); } diff --git a/deno.json b/deno.json index 2123b88..6c9f1b0 100644 --- a/deno.json +++ b/deno.json @@ -52,6 +52,7 @@ "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0", "@cliffy/table": "jsr:@cliffy/table@^1.0.0", "@cliffy/keypress": "jsr:@cliffy/keypress@^1.0.0", + "@inkjs/ui": "npm:@inkjs/ui@^2.0.0", "@opensrc/deno-open": "jsr:@opensrc/deno-open@^1.0.0", "@std/assert": "jsr:@std/assert@^1.0.18", "@std/async": "jsr:@std/async@^1.2.0", diff --git a/deno.lock b/deno.lock index 3d635cf..792ef3a 100644 --- a/deno.lock +++ b/deno.lock @@ -48,6 +48,7 @@ "jsr:@streak-glue/runtime@~0.2.42": "0.2.42", "npm:@googleapis/drive@20": "20.2.0", "npm:@googleapis/gmail@14": "14.0.1", + "npm:@inkjs/ui@2": "2.0.0_ink@7.1.0__@types+react@19.2.17__react@19.2.7", "npm:@notionhq/client@^5.22.0": "5.23.3", "npm:@octokit/webhooks-types@^7.6.1": "7.6.1", "npm:@slack/types@^2.17.0": "2.21.1", @@ -270,6 +271,16 @@ "googleapis-common" ] }, + "@inkjs/ui@2.0.0_ink@7.1.0__@types+react@19.2.17__react@19.2.7": { + "integrity": "sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==", + "dependencies": [ + "chalk", + "cli-spinners@3.4.0", + "deepmerge", + "figures", + "ink" + ] + }, "@isaacs/cliui@8.0.2": { "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dependencies": [ @@ -625,6 +636,9 @@ "cli-spinners@2.9.2": { "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==" }, + "cli-spinners@3.4.0": { + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==" + }, "cli-truncate@6.0.1": { "integrity": "sha512-2FPVnc3JxdRLONB/9edO1RwuUFFPJ3U2c6XvyccEhjqV5xw6mS22aH27OFdD1u4IYQOEUzXsT6ZU06d1VCSu+Q==", "dependencies": [ @@ -679,6 +693,9 @@ "ms" ] }, + "deepmerge@4.3.1": { + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, "delayed-stream@1.0.0": { "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, @@ -751,6 +768,12 @@ "web-streams-polyfill" ] }, + "figures@6.1.0": { + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dependencies": [ + "is-unicode-supported" + ] + }, "follow-redirects@1.16.0": { "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" }, @@ -930,7 +953,7 @@ "ink-spinner@5.0.0_ink@7.1.0__@types+react@19.2.17__react@19.2.7_react@19.2.7_@types+react@19.2.17": { "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", "dependencies": [ - "cli-spinners", + "cli-spinners@2.9.2", "ink", "react" ] @@ -992,6 +1015,9 @@ "is-stream@2.0.1": { "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" }, + "is-unicode-supported@2.1.0": { + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==" + }, "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, @@ -1413,6 +1439,7 @@ "jsr:@std/streams@^1.0.17", "jsr:@std/testing@^1.0.17", "jsr:@streak-glue/runtime@~0.2.42", + "npm:@inkjs/ui@2", "npm:@types/react@19", "npm:chalk@^5.6.2", "npm:hono@^4.12.25", diff --git a/ui/common.tsx b/ui/common.tsx index 188fb20..f22cfd7 100644 --- a/ui/common.tsx +++ b/ui/common.tsx @@ -1,15 +1,21 @@ import { Box, Newline, Text } from "ink"; import Spinner from "ink-spinner"; import Link from "ink-link"; +import { Select } from "@inkjs/ui"; +import { useRef, useState } from "react"; +import { open } from "@opensrc/deno-open"; import type { AccountInjectionDTO, + AccountSlimDTO, BuildStepDTO, BuildStepName, + DeploymentDTO, RegistrationGroupToSetup, SecretInjectionDTO, StepStatus, TriggerDTO, } from "../backend.ts"; +import { associateDeploymentAccount } from "../backend.ts"; import { toSortedByTypeThenLabel } from "./utils.ts"; import { prettyLabels } from "../lib/prettyLabels.ts"; @@ -91,10 +97,7 @@ export const RegistrationAccountSetupSection = ( {registrationGroupsToSetup.map((ats) => ( - {ats.type} {ats.accountSelector ? `(${prettyLabels(ats.accountSelector)})` : ""}:{" "} - - {ats.accountSetupUrl} - + {ats.type} {ats.accountSelector ? `(${prettyLabels(ats.accountSelector)})` : ""} ))} @@ -105,19 +108,148 @@ export const RegistrationAccountSetupSection = ( )} {secretsToSetup.map((secretInjection) => ( - - - {secretInjection.name} ({secretInjection.label}):{" "} - - {secretInjection.secretSetupUrl} - - - + + {secretInjection.name} ({secretInjection.label}): + + {secretInjection.secretSetupUrl} + + ))} ); }; +export function AccountPickerSection({ deployment }: { deployment: DeploymentDTO }) { + const accountPickerNeeded = deployment.buildSteps.some((step) => + step.name === "registrationAuth" && step.status === "in_progress" + ); + const registrationGroup = deployment.registrationGroupsToSetup[0]; + if (!accountPickerNeeded || !registrationGroup) { + return null; + } + + const key = JSON.stringify([registrationGroup.type, registrationGroup.accountSelector]); + return ( + + ); +} + +function AccountPicker( + { deploymentId, registrationGroup, compatibleAccounts }: { + deploymentId: string; + registrationGroup: RegistrationGroupToSetup; + compatibleAccounts: AccountSlimDTO[]; + }, +) { + const [associating, setAssociating] = useState(false); + const [setupUrl, setSetupUrl] = useState(); + const [error, setError] = useState(); + const accountsById = new Map(compatibleAccounts.map((account) => [account.id, account])); + const usableAccounts = registrationGroup.compatibleAccountIds.filter((candidate) => + !candidate.missingScopes?.length && accountsById.has(candidate.id) + ); + const accountsNeedingScopes = registrationGroup.compatibleAccountIds.filter((candidate) => + candidate.missingScopes?.length && candidate.accountSetupUrl && accountsById.has(candidate.id) + ); + + const groupDisplayName = accountTypeDisplayName(registrationGroup.type) + + (registrationGroup.accountSelector + ? ` (${prettyLabels(registrationGroup.accountSelector)})` + : ""); + const setupUrls = new Map( + accountsNeedingScopes.map((candidate) => [`setup:${candidate.id}`, candidate.accountSetupUrl!]), + ); + + const lastPickedValueRef = useRef(undefined); + + return ( + + Choose {groupDisplayName} account: +