From 35f30be12e4751bbf13907a513b5a4ab735bbc24 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 9 Oct 2026 03:17:35 -0700 Subject: [PATCH 1/4] feat(template): accept a GitHub source on a compute service The local manifest check now takes `source` as the third way to define a web or worker service, next to image and build. Exactly one of the three is required, and the owner, repo, branch, rootDir and buildCommand rules and messages are the platform parser's, so an author hears the refusal before the upload. A present but malformed source counts as declared, so it is reported as one problem instead of also reading as missing. The CLI validates and never normalizes. The manifest travels as written, and an unknown key inside source is left to the platform, as it is for every service key. --- src/template-manifest.ts | 35 +++++++++++++++++++++++++++++++++-- test/template.test.ts | 27 +++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/template-manifest.ts b/src/template-manifest.ts index 4fa0074..0929817 100644 --- a/src/template-manifest.ts +++ b/src/template-manifest.ts @@ -22,12 +22,16 @@ export type ManifestEnv = { optional?: Record } +// The platform API's SourceInput names. The start command stays in the service's `command`. +export type ManifestSource = { owner?: string; repo?: string; branch?: string; rootDir?: string; buildCommand?: string } + export type ManifestService = { type?: string // web and worker are judged here, every other type (postgres, redis, mysql, mongodb, storage) is the platform's call pgVersion?: number // postgres only, a Postgres major the platform offers, checked by the platform public?: boolean // storage only, anonymous public-read, checked by the platform image?: string build?: string + source?: ManifestSource // a GitHub repo the platform builds at deploy, instead of image or build port?: number healthcheck?: string volume?: boolean // needs a /data disk; the platform owns the size @@ -67,6 +71,9 @@ export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/ const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/ // The platform's HEALTHCHECK_RE (src/provisioning/templateManifest.ts), verbatim. const HEALTHCHECK_RE = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/ +// GitHub's grammars for a source's owner and repo, as the platform parser has them. +const GITHUB_OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/ +const GITHUB_REPO_RE = /^[A-Za-z0-9._-]{1,100}$/ // The compute vocabulary every CLI has known. Any other type is the platform's to accept or refuse. const COMPUTE_TYPES = ['web', 'worker'] @@ -111,6 +118,27 @@ function asVarSpec(v: VarSpec | string | null | undefined): VarSpec { return v ?? {} } +// The platform parser's `source` rules, every problem at once. Unknown keys are its call. +function sourceProblems(where: string, raw: unknown): string[] { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [`${where}.source must be a map`] + const s = raw as Record + const problems: string[] = [] + if (typeof s.owner !== 'string' || !GITHUB_OWNER_RE.test(s.owner)) problems.push(`${where}.source.owner must be a GitHub user or organization name`) + if (typeof s.repo !== 'string' || !GITHUB_REPO_RE.test(s.repo) || s.repo === '.' || s.repo === '..') problems.push(`${where}.source.repo must be a GitHub repository name`) + if (s.branch !== undefined) { + const b = typeof s.branch === 'string' ? s.branch.trim() : '' + if (!b || b.length > 255 || /\s/.test(b) || b.includes('..') || b.startsWith('/') || b.endsWith('/')) problems.push(`${where}.source.branch must be a branch name`) + } + if (s.rootDir !== undefined) { + const d = typeof s.rootDir === 'string' ? s.rootDir.trim() : null + if (d === null || d.startsWith('/') || d.split('/').includes('..')) problems.push(`${where}.source.rootDir must be a relative path inside the repository`) + } + if (s.buildCommand !== undefined && (typeof s.buildCommand !== 'string' || s.buildCommand.trim().length > 1000)) { + problems.push(`${where}.source.buildCommand must be a command of at most 1000 characters`) + } + return problems +} + /** All local validation problems, empty when the manifest is deployable. */ export function validateManifest(m: TemplateManifest): string[] { const problems: string[] = [] @@ -135,8 +163,11 @@ export function validateManifest(m: TemplateManifest): string[] { const type = typeof svc.type === 'string' ? svc.type : undefined // Every other type, and every field and env on it, is the platform's to judge. It answers 400 with its own list. if (!type || !COMPUTE_TYPES.includes(type)) continue - if (svc.image && svc.build) problems.push(`${where}: image and build are mutually exclusive`) - if (!svc.image && !svc.build) problems.push(`${where}: one of image or build is required`) + // Present, not truthy: a malformed source is reported by sourceProblems, never read as absent. + const ways = [!!svc.image, !!svc.build, svc.source !== undefined].filter(Boolean).length + if (ways > 1) problems.push(`${where}: image, build and source are mutually exclusive`) + if (ways === 0) problems.push(`${where}: one of image, build or source is required`) + if (svc.source !== undefined) problems.push(...sourceProblems(where, svc.source)) // A parsed YAML document holds whatever the author typed, so both scalars are type-checked the // platform's way BEFORE any rule reads them as strings. const image = svc.image !== undefined ? scalarString(svc.image) : undefined diff --git a/test/template.test.ts b/test/template.test.ts index 5fcd10a..f87d39a 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -84,12 +84,31 @@ describe('validateManifest', () => { const m = { ...MANIFEST, services: { app: { type: 'worker', image: 'nginx:latest' } } } expect(validateManifest(m).join('\n')).toMatch(/not a pin/) }) - // The CLI judges only the compute vocabulary, web and worker. image XOR build is its rule. - it('requires exactly one of image/build on a web or worker service', () => { + // The CLI judges only web and worker. Exactly one of image, build or source is its rule. + it('requires exactly one of image, build or source on a web or worker service', () => { const m: TemplateManifest = { code: 'x', version: '1', services: { a: { type: 'web', image: 'a:1', build: 'b', healthcheck: '/' }, b: { type: 'worker' } } } const problems = validateManifest(m) - expect(problems).toContain('services.a: image and build are mutually exclusive') - expect(problems).toContain('services.b: one of image or build is required') + expect(problems).toContain('services.a: image, build and source are mutually exclusive') + expect(problems).toContain('services.b: one of image, build or source is required') + }) + // Spec 2026-10-08 template GitHub sources: the platform parser's rules, said before the upload. + it('takes source as the third way, with the platform field rules', () => { + const svc = (extra: Record) => + ({ code: 'x', version: '1', services: { web: { type: 'web', port: 3000, ...extra } } }) as unknown as TemplateManifest + const src = (s: Record) => validateManifest(svc({ source: { owner: 'acme', repo: 'shop', ...s } })) + expect(src({})).toEqual([]) + expect(src({ branch: 'release/2', rootDir: './apps/web', buildCommand: 'pnpm build' })).toEqual([]) + expect(src({ rootDir: '.', buildCommand: '' })).toEqual([]) + expect(validateManifest({ code: 'x', version: '1', services: { jobs: { type: 'worker', source: { owner: 'acme', repo: 'jobs' } } } })).toEqual([]) + expect(validateManifest(svc({ image: 'a:1', source: { owner: 'acme', repo: 'shop' } }))).toEqual(['services.web: image, build and source are mutually exclusive']) + expect(validateManifest(svc({ source: 'acme/shop' }))).toEqual(['services.web.source must be a map']) + // An unknown key is the platform's call, as an unknown service key is. + expect(src({ commit: 'abc' })).toEqual([]) + for (const owner of ['', '-acme', 'a'.repeat(40), 'ac_me', 42]) expect(src({ owner }), String(owner)).toEqual(['services.web.source.owner must be a GitHub user or organization name']) + for (const repo of ['', '.', '..', 'a b', 'a/b', 'r'.repeat(101)]) expect(src({ repo }), repo).toEqual(['services.web.source.repo must be a GitHub repository name']) + for (const branch of ['', 'a b', 'a..b', '/main', 'main/', 'b'.repeat(256), 7]) expect(src({ branch }), String(branch)).toEqual(['services.web.source.branch must be a branch name']) + for (const rootDir of ['/apps/web', '../web', 'apps/../../web', 3]) expect(src({ rootDir }), String(rootDir)).toEqual(['services.web.source.rootDir must be a relative path inside the repository']) + for (const buildCommand of ['x'.repeat(1001), false]) expect(src({ buildCommand }), String(buildCommand).slice(0, 8)).toEqual(['services.web.source.buildCommand must be a command of at most 1000 characters']) }) // The platform accepts a managed postgres (provisioning/templateManifest.ts). This validator From bed3a6eba686ff854ad1ae3c906267307c35f189 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 9 Oct 2026 03:23:15 -0700 Subject: [PATCH 2/4] feat(template): deploy links GitHub when a source repo needs it template deploy reads the platform's coded refusal of a private repo the deployer cannot read, github_not_linked or github_repo_unreachable with its repos. On a terminal or with --agent it links GitHub with the device flow, opens the GitHub App install for a repo still out of reach, waits until every listed repo is readable and deploys again, once. With --json or with no terminal it prints the platform's message and exits non-zero. A missing-variables refusal is still answered once, before or after it. authorizeTerminal and findCallerRepo take any client with request(), so the deploy path reuses them as connect-repo does. The help of template deploy and template edit says how source works. --- src/commands/github.ts | 6 +- src/commands/template.ts | 52 +++++++++++--- src/index.ts | 2 + test/help-surface.test.ts | 21 ++++++ test/template.test.ts | 140 +++++++++++++++++++++++++++++++++++++- 5 files changed, 207 insertions(+), 14 deletions(-) diff --git a/src/commands/github.ts b/src/commands/github.ts index bf84d15..2444464 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -89,6 +89,8 @@ export function repoLine(serviceName: string, s: SourceView): string { } type RepoRow = { id: number; owner: string; repo: string; installationId?: number } +// What these helpers need of a client: ApiClient, or template deploy's injectable TemplateApi. +export type GitHubApi = Pick type GitHubView = { linked: boolean; repos: RepoRow[]; installations: { installationId: number; accountLogin: string; accountType: string }[] } type GitHubDeviceStart = { state: string; verificationUri: string; userCode: string; interval?: number; expiresAt: string } type GitHubDevicePoll = { pending: boolean; slowDownBy?: number; repos?: RepoRow[] } @@ -100,7 +102,7 @@ const pollDelay = (s: number) => Math.min(Math.max(s, 1), 60) // GitHub shows the person a code to type; the platform holds the device code and finishes the exchange, // so nothing secret passes through the CLI. -export async function authorizeTerminal(api: ApiClient, wait: (s: number) => Promise = sleepSeconds, open: typeof openUrl = openUrl): Promise { +export async function authorizeTerminal(api: GitHubApi, wait: (s: number) => Promise = sleepSeconds, open: typeof openUrl = openUrl): Promise { const start = await api.request('POST', '/me/github/device', {}) const deadline = Date.parse(start.expiresAt) // A NaN deadline makes every comparison false, which reads as an instant expiry — or, inverted, as a @@ -143,7 +145,7 @@ export function canAuthorizeHere(opts: { json?: boolean } = {}): boolean { return !!agentMode() || !!process.stderr.isTTY } -export async function findCallerRepo(api: ApiClient, ref: RepoRef, authorize: typeof authorizeTerminal = authorizeTerminal, canAuthorize = canAuthorizeHere(), wait: (s: number) => Promise = sleepSeconds, open: typeof openUrl = openUrl): Promise<{ installationId: number; repoId: number }> { +export async function findCallerRepo(api: GitHubApi, ref: RepoRef, authorize: typeof authorizeTerminal = authorizeTerminal, canAuthorize = canAuthorizeHere(), wait: (s: number) => Promise = sleepSeconds, open: typeof openUrl = openUrl): Promise<{ installationId: number; repoId: number }> { let mine = await api.request('GET', '/me/github/repos') if (!mine.linked && !canAuthorize) { throw new Error('this GitHub account is not authorized for InstaCloud yet, and nothing here can read the code GitHub shows — run `insta compute connect-repo` from a terminal, connect the repository from the console, or pass --public for a public repository') diff --git a/src/commands/template.ts b/src/commands/template.ts index b8bb9ae..e4b9ac3 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -12,6 +12,7 @@ import type { ProjectConfig } from '../config.js' import { info, printJson, handleApproval, renderNextActions, CliCancel } from '../util.js' import { MANIFEST_FILE, collectManifestVariables, loadTemplateManifest, type TemplateManifest, type TemplateVar } from '../template-manifest.js' import { parseGitHubTemplateUrl, fetchGitHubTemplate, type GitHubTarget, type GitHubSource, type FetchedTemplate } from '../github-source.js' +import { authorizeTerminal, canAuthorizeHere, findCallerRepo, type RepoRef } from './github.js' // ---- pure, unit-tested helpers ---- @@ -194,6 +195,18 @@ export function missingVariablesFrom(body: any): TemplateVar[] | null { return list.map((v: any) => ({ name: String(v.name ?? v.key ?? v), required: true, description: v.description })) } +// The platform's GitHub access refusal, each repo once. null = some other error, not ours to read. +export function unreachableReposFrom(body: any): RepoRef[] | null { + if (body?.code !== 'github_not_linked' && body?.code !== 'github_repo_unreachable') return null + const byName = new Map() + for (const r of Array.isArray(body.repos) ? body.repos : []) { + if (typeof r?.owner !== 'string' || typeof r?.repo !== 'string') continue + const key = `${r.owner}/${r.repo}`.toLowerCase() + if (!byName.has(key)) byName.set(key, { owner: r.owner, repo: r.repo }) + } + return [...byName.values()] +} + // A deploy target that reads as a filesystem path must resolve as one — a typo'd directory should // not fall through to a registry lookup that 404s with a confusing "no such template". export function looksLikePath(target: string): boolean { @@ -354,7 +367,8 @@ export type TemplateDeployOpts = { branch?: string; set?: string[]; yes?: boolea // What the deploy path needs of the API client — ApiClient satisfies it. export type TemplateApi = { - request: (method: string, path: string, body?: unknown, opts?: { projectId?: string }) => Promise + // signal: findCallerRepo bounds each access poll by the time it has left. + request: (method: string, path: string, body?: unknown, opts?: { projectId?: string; signal?: AbortSignal }) => Promise rawRequest: (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }> } @@ -367,6 +381,8 @@ export type TemplateDeployDeps = { ask?: (v: TemplateVar) => Promise wait?: (s: number) => Promise fetchGitHub?: (t: GitHubTarget) => Promise + authorize?: typeof authorizeTerminal + open?: (url: string) => boolean } export async function templateDeploy(target: string, opts: TemplateDeployOpts = {}, deps: TemplateDeployDeps = {}): Promise { @@ -426,16 +442,30 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts = variables, ...(opts.region !== undefined ? { region: opts.region } : {}), } - let res - try { - res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, body) - } catch (e) { - // The platform's own variable check is the authority; when it names what is missing in a - // machine-readable way, prompt from that and retry once instead of parroting an opaque 4xx. - const missing = e instanceof ApiError ? missingVariablesFrom(e.body) : null - if (!missing?.length) throw e - Object.assign(variables, await resolveVariables(missing, {}, { tty, ask })) - res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, { ...body, variables }) + // Each refusal the CLI can answer is answered once, in whichever order the platform raises them. + const canAuthorize = canAuthorizeHere(opts) + let askedVariables = false + let askedGitHub = false + let res: { status: number; body: any } | undefined + while (!res) { + try { + res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, { ...body, variables }) + } catch (e) { + if (!(e instanceof ApiError)) throw e + // The platform's own variable check is the authority: prompt from its list and retry. + const missing = askedVariables ? null : missingVariablesFrom(e.body) + if (missing?.length) { + askedVariables = true + Object.assign(variables, await resolveVariables(missing, {}, { tty, ask })) + continue + } + // A repo the deployer cannot read yet: link GitHub and wait for access here, then retry. + const repos = askedGitHub || !canAuthorize ? null : unreachableReposFrom(e.body) + if (!repos?.length) throw e + askedGitHub = true + process.stderr.write(`${e.message}\n`) + for (const ref of repos) await findCallerRepo(api, ref, deps.authorize ?? authorizeTerminal, true, deps.wait, deps.open) + } } // handleApproval owns the whole 202 contract (hint on stderr, raw envelope on stdout under // --json, exit code 2) — pass the flag through as every other gated command does. diff --git a/src/index.ts b/src/index.ts index b075dc8..6874ea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -572,6 +572,7 @@ tpl.command('deploy ').description('Deploy a template onto a .option('--set ', 'set a template variable (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[]) .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting') .option('--json') + .addHelpText('after', "\nA service with source builds from a GitHub repo when the template deploys. A public repo needs nothing more. A private one needs your GitHub account linked and able to read it: on a terminal or with --agent, this command links GitHub, opens the GitHub App install for a repo it cannot reach yet, waits for access and deploys again. With --json, or outside a terminal without --agent, it prints the platform's message and exits non-zero.") .action(guard((target, o) => template.templateDeploy(target, o))) // Authoring: the org's community templates, the same drafts the console editor changes. tpl.command('create').description("Create a community template draft from the linked project (or --project), or an empty one with --blank. Prints its code, status and the console editor link") @@ -591,6 +592,7 @@ tpl.command('draft ').description('Show one community template draft: its tpl.command('edit ').description("Change a draft with the console editor's PATCH body as JSON. Without expectedUpdatedAt in it, the draft's current one is used") .requiredOption('--patch ', 'JSON file holding the PATCH body, or - to read it from stdin') .option('--org ', "target org (default: linked project's org)").option('--json') + .addHelpText('after', '\nA service built from a GitHub repo is added with the services entry {"name": "web", "add": {"type": "compute", "source": {"owner": "acme", "repo": "shop", "branch": "main"}, "port": 3000}}, and "port": null makes it a worker. {"name": "web", "settings": {"source": {"owner": "acme", "repo": "shop", "branch": "release"}}} changes its branch, rootDir or buildCommand and replaces the whole source. A service keeps its kind, so switching between image and source takes a new service. A private repo deploys only for someone whose linked GitHub account can read it.') .action(guard((code, o) => templateAuthor.templateEdit(code, o))) tpl.command('regenerate ').description('Rebuild a draft from its project as the project is now, keeping your edits. A blocked item is fixed in the project, then regenerated') .option('--org ', "target org (default: linked project's org)").option('--json') diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 0bd304b..80e7b1b 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -202,3 +202,24 @@ describe('--api-url placement', () => { expect(`${r.stderr}${r.stdout}`, path.join(' ')).not.toContain('unknown option') }, 30_000) }) + +// Spec 2026-10-08 template GitHub sources: the help is where a person and an agent learn `source`. +describe('template GitHub sources in help', () => { + const helpOf = (...path: string[]) => { + const r = run([...path, '--help']) + expect(r.status, path.join(' ')).toBe(0) + return r.stdout.replace(/\s+/g, ' ') + } + it('template deploy says a source service builds from GitHub and what happens for a private repo', () => { + const help = helpOf('template', 'deploy') + expect(help).toContain('A service with source builds from a GitHub repo when the template deploys.') + expect(help).toContain('this command links GitHub, opens the GitHub App install for a repo it cannot reach yet, waits for access and deploys again') + expect(help).toContain("With --json, or outside a terminal without --agent, it prints the platform's message and exits non-zero.") + }, 30_000) + it('template edit shows how to add a service built from GitHub and change its source', () => { + const help = helpOf('template', 'edit') + expect(help).toContain('{"name": "web", "add": {"type": "compute", "source": {"owner": "acme", "repo": "shop", "branch": "main"}, "port": 3000}}') + expect(help).toContain('{"name": "web", "settings": {"source": {"owner": "acme", "repo": "shop", "branch": "release"}}}') + expect(help).toContain('switching between image and source takes a new service') + }, 30_000) +}) diff --git a/test/template.test.ts b/test/template.test.ts index f87d39a..f05e686 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -8,7 +8,7 @@ import { } from '../src/template-manifest.js' import { templateListLines, templateInfoLines, normalizeInfoServices, normalizeInfoVariables, - parseSetFlags, resolveVariables, missingVariablesFrom, looksLikePath, deployMode, templateDeploy, publicBucketLines, + parseSetFlags, resolveVariables, missingVariablesFrom, unreachableReposFrom, looksLikePath, deployMode, templateDeploy, publicBucketLines, stepIndexFor, deploymentUrls, serviceStateLines, partialMessage, watchDeployment, DEPLOY_STEPS, } from '../src/commands/template.js' import { ApiError } from '../src/api.js' @@ -626,6 +626,27 @@ describe('missingVariablesFrom', () => { }) }) +describe('unreachableReposFrom', () => { + it('reads the repos of both access codes, each repo once, case-insensitively', () => { + const repos = [ + { service: 'web', owner: 'acme', repo: 'shop', branch: 'main' }, + { service: 'jobs', owner: 'Acme', repo: 'Shop', branch: 'dev' }, + { service: 'api', owner: 'acme', repo: 'api', branch: 'main' }, + ] + for (const code of ['github_not_linked', 'github_repo_unreachable']) { + expect(unreachableReposFrom({ error: 'x', code, repos }), code).toEqual([{ owner: 'acme', repo: 'shop' }, { owner: 'acme', repo: 'api' }]) + } + }) + it('drops an entry without a string owner and repo', () => { + expect(unreachableReposFrom({ code: 'github_not_linked', repos: [{ owner: 'acme' }, null, 'acme/shop', { owner: 'acme', repo: 7 }] })).toEqual([]) + expect(unreachableReposFrom({ code: 'github_not_linked' })).toEqual([]) + }) + it('leaves every other error alone, the publish refusal included', () => { + const others = [{ error: 'missing_variables', missing: [] }, { code: 'template_source_unreachable', repos: [{ owner: 'acme', repo: 'shop' }] }, { error: 'github_not_linked' }, undefined, null] + for (const body of others) expect(unreachableReposFrom(body)).toBeNull() + }) +}) + describe('looksLikePath', () => { it('reads ./dir, absolute and nested paths as paths, bare codes as codes', () => { expect(looksLikePath('./tpl')).toBe(true) @@ -1044,6 +1065,123 @@ describe('templateDeploy', () => { process.stdout.isTTY = tty.out } }) + + // Spec 2026-10-08 template GitHub sources: the platform names the repos, the CLI links GitHub. + const SHOP = { service: 'web', owner: 'acme', repo: 'shop', branch: 'main' } + const NOT_LINKED = 'this template builds from GitHub repositories only a linked GitHub account can read (acme/shop@main): connect GitHub from your profile in the InstaCloud console, or run insta template deploy in a terminal, then deploy again' + const UNREACHABLE = "your linked GitHub accounts cannot read acme/shop@main: ask the template's author for access, or install the InstaCloud GitHub App on that repository, then deploy again" + const refused = (code: string, message: string, repos: unknown[] = [SHOP]) => new ApiError(400, message, { error: message, code, repos }) + const SHOP_ROW = { id: 42, owner: 'acme', repo: 'shop', installationId: 7 } + const noAuthorize = async () => { throw new Error('must not authorize') } + // Refuses the first POSTs with `refusals`, then accepts, and answers /me/github/repos in turn. + function githubPlatform(refusals: Error[], repoAnswers: unknown[]) { + const calls: string[] = [] + const posts: any[] = [] + const api = { + request: async (method: string, path: string) => { + calls.push(`${method} ${path}`) + if (path.startsWith('/templates/')) return { template: { code: 'shop', variables: { required: [], optional: [] } } } + if (path.startsWith('/template-deployments/')) return { status: 'succeeded', services: [] } + if (path === '/me/github/setup') return { installUrl: 'https://github.com/apps/instacloud/installations/new?state=nonce' } + if (path === '/me/github/repos') { + const answer = repoAnswers.shift() + if (!answer) throw new Error('unexpected repos read') + return answer + } + throw new Error(`unexpected ${method} ${path}`) + }, + rawRequest: async (method: string, path: string, body?: unknown) => { + calls.push(`${method} ${path}`) + posts.push(body) + const refusal = refusals.shift() + if (refusal) throw refusal + return { status: 202, body: { deploymentId: 'dep_1' } } + }, + } + return { api, calls, posts } + } + // canAuthorizeHere reads the stderr terminal (github-connect.test.ts sets it the same way). + async function withStderrTTY(value: boolean, run: () => Promise): Promise { + const was = process.stderr.isTTY + Object.defineProperty(process.stderr, 'isTTY', { value, configurable: true }) + try { return await run() } finally { Object.defineProperty(process.stderr, 'isTTY', { value: was, configurable: true }) } + } + const DEPLOYED = ['POST /projects/proj_1/template-deployments', 'GET /template-deployments/dep_1'] + + it('links GitHub with the device flow on a terminal, then deploys again with the same body', async () => { + const { api, calls, posts } = githubPlatform([refused('github_not_linked', NOT_LINKED)], [{ linked: false, repos: [], installations: [] }]) + const authorize = vi.fn(async () => [SHOP_ROW]) + const opened: string[] = [] + await withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, authorize, open: (u: string) => { opened.push(u); return true } })) + expect(authorize).toHaveBeenCalledOnce() + expect(calls).toEqual(['GET /templates/shop', 'POST /projects/proj_1/template-deployments', 'GET /me/github/repos', ...DEPLOYED]) + expect(posts).toHaveLength(2) + expect(posts[1]).toEqual(posts[0]) + expect(opened).toEqual([]) + expect(stderr.join('')).toContain(NOT_LINKED) + expect(stdout.join('')).toContain('template shop deployed to branch main') + }) + + it('opens the App install for a repo the linked account cannot reach, waits for it, then deploys again', async () => { + const linked = { linked: true, repos: [], installations: [] } + const { api, calls, posts } = githubPlatform([refused('github_repo_unreachable', UNREACHABLE)], [linked, { linked: true, repos: [SHOP_ROW], installations: [] }]) + const opened: string[] = [] + await withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, authorize: noAuthorize, open: (u: string) => { opened.push(u); return true } })) + expect(opened).toEqual(['https://github.com/apps/instacloud/installations/new?state=cli']) + expect(calls).toEqual(['GET /templates/shop', 'POST /projects/proj_1/template-deployments', 'GET /me/github/repos', 'POST /me/github/setup', 'GET /me/github/repos', ...DEPLOYED]) + expect(posts).toHaveLength(2) + expect(stderr.join('')).toContain(UNREACHABLE) + expect(stderr.join('')).toContain('install the InstaCloud GitHub App on acme and grant access to acme/shop') + }) + + it('asks GitHub once per repo, however many services build from it', async () => { + const both = [SHOP, { ...SHOP, service: 'jobs', branch: 'dev' }, { service: 'api', owner: 'acme', repo: 'api', branch: 'main' }] + const listed = { linked: true, repos: [SHOP_ROW, { id: 43, owner: 'acme', repo: 'api', installationId: 7 }], installations: [] } + const { api, calls, posts } = githubPlatform([refused('github_repo_unreachable', UNREACHABLE, both)], [listed, listed]) + await withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, authorize: noAuthorize, open: () => true })) + expect(calls.filter((c) => c === 'GET /me/github/repos')).toHaveLength(2) + expect(posts).toHaveLength(2) + }) + + it('--json prints the platform message and stops, with no GitHub call', async () => { + const { api, calls } = githubPlatform([refused('github_not_linked', NOT_LINKED)], []) + await expect(withStderrTTY(true, () => templateDeploy('shop', { json: true }, { api, project: PROJECT, wait: NO_WAIT, authorize: noAuthorize, open: () => true }))) + .rejects.toThrow(NOT_LINKED) + expect(calls).toEqual(['GET /templates/shop', 'POST /projects/proj_1/template-deployments']) + expect(stdout.join('')).toBe('') + }) + + it('with no terminal it stops the same way', async () => { + const { api, calls } = githubPlatform([refused('github_repo_unreachable', UNREACHABLE)], []) + await expect(withStderrTTY(false, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, authorize: noAuthorize, open: () => true }))) + .rejects.toThrow(UNREACHABLE) + expect(calls).toEqual(['GET /templates/shop', 'POST /projects/proj_1/template-deployments']) + }) + + it('retries once: a second refusal after linking is the error', async () => { + const { api, posts } = githubPlatform([refused('github_not_linked', NOT_LINKED), refused('github_not_linked', NOT_LINKED)], [{ linked: false, repos: [], installations: [] }]) + const authorize = vi.fn(async () => [SHOP_ROW]) + await expect(withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, authorize, open: () => true }))) + .rejects.toThrow(NOT_LINKED) + expect(authorize).toHaveBeenCalledOnce() + expect(posts).toHaveLength(2) + }) + + it('answers a missing-variables refusal that comes after the GitHub one too', async () => { + const tty = { in: process.stdin.isTTY, out: process.stdout.isTTY } + process.stdin.isTTY = true + process.stdout.isTTY = true + try { + const missing = new ApiError(400, 'missing_variables', { error: 'missing_variables', missing: [{ name: 'API_KEY', key: 'API_KEY' }] }) + const { api, posts } = githubPlatform([refused('github_not_linked', NOT_LINKED), missing], [{ linked: false, repos: [], installations: [] }]) + await withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, ask: async () => 'k-1', authorize: async () => [SHOP_ROW], open: () => true })) + expect(posts).toHaveLength(3) + expect(posts[2]).toMatchObject({ templateCode: 'shop', variables: { API_KEY: 'k-1' } }) + } finally { + process.stdin.isTTY = tty.in + process.stdout.isTTY = tty.out + } + }) }) describe('deployment progress', () => { From b7507323cac660023bcdf5f3733bf88ec2dda48a Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 9 Oct 2026 03:36:50 -0700 Subject: [PATCH 3/4] fix(template): correct kind-switch help and show source in drafts The template edit help said switching between image and source takes a new service. The platform refuses that with "delete it and add a new one", and a service that comes from the project cannot be deleted from a draft, only marked removed. The help now names both ("delete" for a service the author added, "removed" for one from the project) and says to add the new one under another name. template draft printed nothing for a service built from GitHub, since the platform's draft view gives it image null plus source. It now prints "builds from owner/repo@branch (rootDir/)", or "(default branch)" when the source names no branch. The source rules comment names the platform's parseTemplateSource. Tests pin source null as "must be a map" and a second missing-variables refusal as the error rather than another prompt. --- src/commands/template-author.ts | 3 +++ src/index.ts | 2 +- src/template-manifest.ts | 2 +- test/help-surface.test.ts | 2 +- test/template-author.test.ts | 11 +++++++++++ test/template.test.ts | 18 ++++++++++++++++++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/commands/template-author.ts b/src/commands/template-author.ts index e295a9e..1fe81c5 100644 --- a/src/commands/template-author.ts +++ b/src/commands/template-author.ts @@ -17,6 +17,8 @@ export type TemplateDraftVariable = { export type TemplateDraftService = { name: string; type: string; image: string | null; port: number | null; healthcheck?: string | null; volume: boolean; mountPath: string | null public?: boolean; pgVersion?: number; removed: boolean; variables: TemplateDraftVariable[] + // A service built from GitHub has image null and this instead. + source?: { owner: string; repo: string; branch?: string; rootDir?: string; buildCommand?: string } } export type TemplateDraft = { code: string; orgId: string; name: string; status: string; publishedVersion: string | null @@ -105,6 +107,7 @@ function serviceText(s: TemplateDraftService): string { // Only a web service has one, and a platform that does not serve the field says nothing. s.type === 'web' && s.healthcheck !== undefined ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined, s.image ? `image ${s.image}` : undefined, + s.source ? `builds from ${s.source.owner}/${s.source.repo}${s.source.branch ? `@${s.source.branch}` : ' (default branch)'}${s.source.rootDir ? ` (${s.source.rootDir}/)` : ''}` : undefined, // A null mount path on a volume is the default /data. s.volume ? `volume at ${s.mountPath ?? '/data'}` : undefined, s.removed ? 'removed' : undefined, diff --git a/src/index.ts b/src/index.ts index 6874ea6..31c289a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -592,7 +592,7 @@ tpl.command('draft ').description('Show one community template draft: its tpl.command('edit ').description("Change a draft with the console editor's PATCH body as JSON. Without expectedUpdatedAt in it, the draft's current one is used") .requiredOption('--patch ', 'JSON file holding the PATCH body, or - to read it from stdin') .option('--org ', "target org (default: linked project's org)").option('--json') - .addHelpText('after', '\nA service built from a GitHub repo is added with the services entry {"name": "web", "add": {"type": "compute", "source": {"owner": "acme", "repo": "shop", "branch": "main"}, "port": 3000}}, and "port": null makes it a worker. {"name": "web", "settings": {"source": {"owner": "acme", "repo": "shop", "branch": "release"}}} changes its branch, rootDir or buildCommand and replaces the whole source. A service keeps its kind, so switching between image and source takes a new service. A private repo deploys only for someone whose linked GitHub account can read it.') + .addHelpText('after', '\nA service built from a GitHub repo is added with the services entry {"name": "web", "add": {"type": "compute", "source": {"owner": "acme", "repo": "shop", "branch": "main"}, "port": 3000}}, and "port": null makes it a worker. {"name": "web", "settings": {"source": {"owner": "acme", "repo": "shop", "branch": "release"}}} changes its branch, rootDir or buildCommand and replaces the whole source. A service keeps its kind. To switch between image and source, take the old one out ("delete": true for a service you added, "removed": true for one from the project) and add a new one under another name. A private repo deploys only for someone whose linked GitHub account can read it.') .action(guard((code, o) => templateAuthor.templateEdit(code, o))) tpl.command('regenerate ').description('Rebuild a draft from its project as the project is now, keeping your edits. A blocked item is fixed in the project, then regenerated') .option('--org ', "target org (default: linked project's org)").option('--json') diff --git a/src/template-manifest.ts b/src/template-manifest.ts index 0929817..555a645 100644 --- a/src/template-manifest.ts +++ b/src/template-manifest.ts @@ -118,7 +118,7 @@ function asVarSpec(v: VarSpec | string | null | undefined): VarSpec { return v ?? {} } -// The platform parser's `source` rules, every problem at once. Unknown keys are its call. +// Mirrors parseTemplateSource in the platform's src/provisioning/templateManifest.ts, all at once. function sourceProblems(where: string, raw: unknown): string[] { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [`${where}.source must be a map`] const s = raw as Record diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 80e7b1b..724ef27 100644 --- a/test/help-surface.test.ts +++ b/test/help-surface.test.ts @@ -220,6 +220,6 @@ describe('template GitHub sources in help', () => { const help = helpOf('template', 'edit') expect(help).toContain('{"name": "web", "add": {"type": "compute", "source": {"owner": "acme", "repo": "shop", "branch": "main"}, "port": 3000}}') expect(help).toContain('{"name": "web", "settings": {"source": {"owner": "acme", "repo": "shop", "branch": "release"}}}') - expect(help).toContain('switching between image and source takes a new service') + expect(help).toContain('A service keeps its kind. To switch between image and source, take the old one out ("delete": true for a service you added, "removed": true for one from the project) and add a new one under another name.') }, 30_000) }) diff --git a/test/template-author.test.ts b/test/template-author.test.ts index 1132e67..44e10c5 100644 --- a/test/template-author.test.ts +++ b/test/template-author.test.ts @@ -169,6 +169,17 @@ describe('draft rendering', () => { ]) expect(draftLines(t, null)).not.toContainEqual(expect.stringMatching(/^editor:/)) }) + it('says a service builds from its GitHub repo, with the branch and root directory when it has them', () => { + const t = view({ services: [ + { name: 'web', type: 'web', image: null, source: { owner: 'acme', repo: 'shop', branch: 'release', rootDir: 'apps/web' }, port: 3000, healthcheck: '/', volume: false, mountPath: null, removed: false, variables: [] }, + { name: 'jobs', type: 'worker', image: null, source: { owner: 'acme', repo: 'jobs' }, port: null, volume: false, mountPath: null, removed: false, variables: [] }, + ] }) + expect(draftLines(t, null).slice(2, 5)).toEqual([ + 'services (2):', + ' web (web, port 3000, health check /, builds from acme/shop@release (apps/web/))', + ' jobs (worker, builds from acme/jobs (default branch))', + ]) + }) }) describe('template drafts', () => { diff --git a/test/template.test.ts b/test/template.test.ts index f05e686..1a1a301 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -102,6 +102,7 @@ describe('validateManifest', () => { expect(validateManifest({ code: 'x', version: '1', services: { jobs: { type: 'worker', source: { owner: 'acme', repo: 'jobs' } } } })).toEqual([]) expect(validateManifest(svc({ image: 'a:1', source: { owner: 'acme', repo: 'shop' } }))).toEqual(['services.web: image, build and source are mutually exclusive']) expect(validateManifest(svc({ source: 'acme/shop' }))).toEqual(['services.web.source must be a map']) + expect(validateManifest(svc({ source: null }))).toEqual(['services.web.source must be a map']) // An unknown key is the platform's call, as an unknown service key is. expect(src({ commit: 'abc' })).toEqual([]) for (const owner of ['', '-acme', 'a'.repeat(40), 'ac_me', 42]) expect(src({ owner }), String(owner)).toEqual(['services.web.source.owner must be a GitHub user or organization name']) @@ -1167,6 +1168,23 @@ describe('templateDeploy', () => { expect(posts).toHaveLength(2) }) + it('answers a missing-variables refusal once: a second one is the error, not another prompt', async () => { + const tty = { in: process.stdin.isTTY, out: process.stdout.isTTY } + process.stdin.isTTY = true + process.stdout.isTTY = true + try { + const missing = () => new ApiError(400, 'missing_variables', { error: 'missing_variables', missing: [{ name: 'API_KEY', key: 'API_KEY' }] }) + const { api, posts } = githubPlatform([missing(), missing()], []) + const ask = vi.fn(async () => 'k-1') + await expect(templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, ask, authorize: noAuthorize, open: () => true })).rejects.toThrow('missing_variables') + expect(ask).toHaveBeenCalledOnce() + expect(posts).toHaveLength(2) + } finally { + process.stdin.isTTY = tty.in + process.stdout.isTTY = tty.out + } + }) + it('answers a missing-variables refusal that comes after the GitHub one too', async () => { const tty = { in: process.stdin.isTTY, out: process.stdout.isTTY } process.stdin.isTTY = true From cc391d0f5ff38a5b01792e4d471fc1a6b028d825 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 9 Oct 2026 11:18:06 -0700 Subject: [PATCH 4/4] fix(template): strip control characters from rendered GitHub source text A stored draft can carry an escape sequence in a source branch or rootDir, and the draft view printed it to the terminal as is. serviceText now passes owner, repo, branch and rootDir through safeText. The same goes for what a GitHub access refusal puts on screen: the platform message written to stderr before the GitHub flow, and the owner and repo that findCallerRepo prints and throws. validateManifest now follows the platform's order. The required sentence comes first and counts truthy image or build or a present source. Only when that passes is the mutual exclusion counted by key presence, so an empty image or build beside another way is refused as mutually exclusive, while an empty image alone, or an empty image beside an empty build, gets only the required sentence. The message strings are unchanged. Adds a deploy test for the missing_variables then github_not_linked order. --- src/commands/template-author.ts | 5 +-- src/commands/template.ts | 11 ++++--- src/template-manifest.ts | 8 ++--- test/template-author.test.ts | 8 +++++ test/template.test.ts | 56 +++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/commands/template-author.ts b/src/commands/template-author.ts index 1fe81c5..1eb9e4f 100644 --- a/src/commands/template-author.ts +++ b/src/commands/template-author.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { agentMode } from '../agent.js' import { ApiClient, ApiError, requireProject } from '../api.js' -import type { ProjectConfig } from '../config.js' +import { safeText, type ProjectConfig } from '../config.js' import { info, printJson, refuse } from '../util.js' import { resolveOrgId } from './billing.js' import { confirmOnTerminal } from './postgres.js' @@ -107,7 +107,8 @@ function serviceText(s: TemplateDraftService): string { // Only a web service has one, and a platform that does not serve the field says nothing. s.type === 'web' && s.healthcheck !== undefined ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined, s.image ? `image ${s.image}` : undefined, - s.source ? `builds from ${s.source.owner}/${s.source.repo}${s.source.branch ? `@${s.source.branch}` : ' (default branch)'}${s.source.rootDir ? ` (${s.source.rootDir}/)` : ''}` : undefined, + // A stored draft's text, so stripped of control characters before it reaches a terminal. + s.source ? `builds from ${safeText(s.source.owner)}/${safeText(s.source.repo)}${s.source.branch ? `@${safeText(s.source.branch)}` : ' (default branch)'}${s.source.rootDir ? ` (${safeText(s.source.rootDir)}/)` : ''}` : undefined, // A null mount path on a volume is the default /data. s.volume ? `volume at ${s.mountPath ?? '/data'}` : undefined, s.removed ? 'removed' : undefined, diff --git a/src/commands/template.ts b/src/commands/template.ts index e4b9ac3..9e7b754 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -8,7 +8,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import * as clack from '@clack/prompts' import { ApiClient, ApiError, requireProject } from '../api.js' -import type { ProjectConfig } from '../config.js' +import { safeText, type ProjectConfig } from '../config.js' import { info, printJson, handleApproval, renderNextActions, CliCancel } from '../util.js' import { MANIFEST_FILE, collectManifestVariables, loadTemplateManifest, type TemplateManifest, type TemplateVar } from '../template-manifest.js' import { parseGitHubTemplateUrl, fetchGitHubTemplate, type GitHubTarget, type GitHubSource, type FetchedTemplate } from '../github-source.js' @@ -201,8 +201,11 @@ export function unreachableReposFrom(body: any): RepoRef[] | null { const byName = new Map() for (const r of Array.isArray(body.repos) ? body.repos : []) { if (typeof r?.owner !== 'string' || typeof r?.repo !== 'string') continue - const key = `${r.owner}/${r.repo}`.toLowerCase() - if (!byName.has(key)) byName.set(key, { owner: r.owner, repo: r.repo }) + // Platform text that findCallerRepo prints and throws, so control characters go here. + const owner = safeText(r.owner) + const repo = safeText(r.repo) + const key = `${owner}/${repo}`.toLowerCase() + if (!byName.has(key)) byName.set(key, { owner, repo }) } return [...byName.values()] } @@ -463,7 +466,7 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts = const repos = askedGitHub || !canAuthorize ? null : unreachableReposFrom(e.body) if (!repos?.length) throw e askedGitHub = true - process.stderr.write(`${e.message}\n`) + process.stderr.write(`${safeText(e.message)}\n`) for (const ref of repos) await findCallerRepo(api, ref, deps.authorize ?? authorizeTerminal, true, deps.wait, deps.open) } } diff --git a/src/template-manifest.ts b/src/template-manifest.ts index 555a645..19bb157 100644 --- a/src/template-manifest.ts +++ b/src/template-manifest.ts @@ -163,10 +163,10 @@ export function validateManifest(m: TemplateManifest): string[] { const type = typeof svc.type === 'string' ? svc.type : undefined // Every other type, and every field and env on it, is the platform's to judge. It answers 400 with its own list. if (!type || !COMPUTE_TYPES.includes(type)) continue - // Present, not truthy: a malformed source is reported by sourceProblems, never read as absent. - const ways = [!!svc.image, !!svc.build, svc.source !== undefined].filter(Boolean).length - if (ways > 1) problems.push(`${where}: image, build and source are mutually exclusive`) - if (ways === 0) problems.push(`${where}: one of image, build or source is required`) + // The platform's order: required check first, then exclusion by keys present. + const present = [svc.image, svc.build, svc.source].filter((v) => v !== undefined).length + if (!svc.image && !svc.build && svc.source === undefined) problems.push(`${where}: one of image, build or source is required`) + else if (present > 1) problems.push(`${where}: image, build and source are mutually exclusive`) if (svc.source !== undefined) problems.push(...sourceProblems(where, svc.source)) // A parsed YAML document holds whatever the author typed, so both scalars are type-checked the // platform's way BEFORE any rule reads them as strings. diff --git a/test/template-author.test.ts b/test/template-author.test.ts index 44e10c5..5759481 100644 --- a/test/template-author.test.ts +++ b/test/template-author.test.ts @@ -180,6 +180,14 @@ describe('draft rendering', () => { ' jobs (worker, builds from acme/jobs (default branch))', ]) }) + it('prints no control character of a stored source: ESC and C1 CSI are dropped from every field', () => { + const t = view({ services: [ + { name: 'web', type: 'web', image: null, source: { owner: 'ac\u001b[31mme', repo: 'sh\u009bop', branch: 'main\u001b[31m', rootDir: 'apps\u009b31m/web' }, port: 3000, healthcheck: '/', volume: false, mountPath: null, removed: false, variables: [] }, + ] }) + const out = draftLines(t, null).join('\n') + expect(out).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/) + expect(out).toContain('builds from ac[31mme/shop@main[31m (apps31m/web/)') + }) }) describe('template drafts', () => { diff --git a/test/template.test.ts b/test/template.test.ts index 1a1a301..e31b283 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -112,6 +112,27 @@ describe('validateManifest', () => { for (const buildCommand of ['x'.repeat(1001), false]) expect(src({ buildCommand }), String(buildCommand).slice(0, 8)).toEqual(['services.web.source.buildCommand must be a command of at most 1000 characters']) }) + // Exclusion counts the keys present, the required sentence counts the usable ones. Same rule as the platform. + it('refuses an empty image or build beside another way as mutually exclusive', () => { + const svc = (extra: Record) => + ({ code: 'x', version: '1', services: { web: { type: 'web', port: 3000, ...extra } } }) as unknown as TemplateManifest + const exclusive = ['services.web: image, build and source are mutually exclusive'] + const src = { owner: 'acme', repo: 'shop' } + expect(validateManifest(svc({ image: '', source: src }))).toEqual(exclusive) + expect(validateManifest(svc({ build: '', source: src }))).toEqual(exclusive) + expect(validateManifest(svc({ image: '', build: 'b' }))).toEqual(exclusive) + expect(validateManifest(svc({ image: 'a:1', build: '' }))).toEqual(exclusive) + }) + it('keeps the required sentence for an empty image or build on its own', () => { + const svc = (extra: Record) => + ({ code: 'x', version: '1', services: { web: { type: 'web', port: 3000, ...extra } } }) as unknown as TemplateManifest + const required = ['services.web: one of image, build or source is required'] + expect(validateManifest(svc({ image: '' }))).toEqual(required) + expect(validateManifest(svc({ build: '' }))).toEqual(required) + // Nothing usable, so the required check fails first and exclusion is never asked. + expect(validateManifest(svc({ image: '', build: '' }))).toEqual(required) + }) + // The platform accepts a managed postgres (provisioning/templateManifest.ts). This validator // used to reject it, so a template pairing an app with a database could not be deployed from a // local directory or a GitHub URL at all, though the registry lane took it happily. @@ -642,6 +663,10 @@ describe('unreachableReposFrom', () => { expect(unreachableReposFrom({ code: 'github_not_linked', repos: [{ owner: 'acme' }, null, 'acme/shop', { owner: 'acme', repo: 7 }] })).toEqual([]) expect(unreachableReposFrom({ code: 'github_not_linked' })).toEqual([]) }) + it('drops control characters from a repo name, as the CLI prints it later', () => { + const repos = [{ owner: 'ac\u001b[31mme', repo: 'sh\u009bop' }, { owner: 'acme', repo: 'api' }, { owner: 'ac\u001bme', repo: 'a\u009bpi' }] + expect(unreachableReposFrom({ code: 'github_not_linked', repos })).toEqual([{ owner: 'ac[31mme', repo: 'shop' }, { owner: 'acme', repo: 'api' }]) + }) it('leaves every other error alone, the publish refusal included', () => { const others = [{ error: 'missing_variables', missing: [] }, { code: 'template_source_unreachable', repos: [{ owner: 'acme', repo: 'shop' }] }, { error: 'github_not_linked' }, undefined, null] for (const body of others) expect(unreachableReposFrom(body)).toBeNull() @@ -1200,6 +1225,37 @@ describe('templateDeploy', () => { process.stdout.isTTY = tty.out } }) + + it('answers a missing-variables refusal first and a GitHub refusal second, once each', async () => { + const tty = { in: process.stdin.isTTY, out: process.stdout.isTTY } + process.stdin.isTTY = true + process.stdout.isTTY = true + try { + const missing = new ApiError(400, 'missing_variables', { error: 'missing_variables', missing: [{ name: 'API_KEY', key: 'API_KEY' }] }) + const { api, calls, posts } = githubPlatform([missing, refused('github_not_linked', NOT_LINKED)], [{ linked: false, repos: [], installations: [] }]) + const ask = vi.fn(async () => 'k-1') + const authorize = vi.fn(async () => [SHOP_ROW]) + await withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, ask, authorize, open: () => true })) + expect(ask).toHaveBeenCalledOnce() + expect(authorize).toHaveBeenCalledOnce() + expect(calls).toEqual(['GET /templates/shop', 'POST /projects/proj_1/template-deployments', 'POST /projects/proj_1/template-deployments', 'GET /me/github/repos', ...DEPLOYED]) + expect(posts).toHaveLength(3) + expect(posts[1]).toMatchObject({ variables: { API_KEY: 'k-1' } }) + expect(posts[2]).toEqual(posts[1]) + expect(stdout.join('')).toContain('template shop deployed to branch main') + } finally { + process.stdin.isTTY = tty.in + process.stdout.isTTY = tty.out + } + }) + + it("prints the platform's message without control characters before it links GitHub", async () => { + const dirty = `${NOT_LINKED}\u001b]0;pwned\u0007 \u009b31m` + const { api } = githubPlatform([refused('github_not_linked', dirty)], [{ linked: false, repos: [], installations: [] }]) + await withStderrTTY(true, () => templateDeploy('shop', {}, { api, project: PROJECT, wait: NO_WAIT, authorize: async () => [SHOP_ROW], open: () => true })) + expect(stderr.join('')).toContain(`${NOT_LINKED}]0;pwned 31m\n`) + expect(stderr.join('')).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/) + }) }) describe('deployment progress', () => {