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-author.ts b/src/commands/template-author.ts index e295a9e..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' @@ -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,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, + // 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 b8bb9ae..9e7b754 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -8,10 +8,11 @@ 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' +import { authorizeTerminal, canAuthorizeHere, findCallerRepo, type RepoRef } from './github.js' // ---- pure, unit-tested helpers ---- @@ -194,6 +195,21 @@ 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 + // 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()] +} + // 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 +370,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 +384,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 +445,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(`${safeText(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..31c289a 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. 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 4fa0074..19bb157 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 ?? {} } +// 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 + 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`) + // 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. const image = svc.image !== undefined ? scalarString(svc.image) : undefined diff --git a/test/help-surface.test.ts b/test/help-surface.test.ts index 0bd304b..724ef27 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('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..5759481 100644 --- a/test/template-author.test.ts +++ b/test/template-author.test.ts @@ -169,6 +169,25 @@ 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))', + ]) + }) + 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 5fcd10a..e31b283 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' @@ -84,12 +84,53 @@ 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']) + 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']) + 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']) + }) + + // 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 @@ -607,6 +648,31 @@ 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('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() + }) +}) + describe('looksLikePath', () => { it('reads ./dir, absolute and nested paths as paths, bare codes as codes', () => { expect(looksLikePath('./tpl')).toBe(true) @@ -1025,6 +1091,171 @@ 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 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 + 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 + } + }) + + 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', () => {