Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/commands/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiClient, 'request'>
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[] }
Expand All @@ -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<void> = sleepSeconds, open: typeof openUrl = openUrl): Promise<RepoRow[]> {
export async function authorizeTerminal(api: GitHubApi, wait: (s: number) => Promise<void> = sleepSeconds, open: typeof openUrl = openUrl): Promise<RepoRow[]> {
const start = await api.request<GitHubDeviceStart>('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
Expand Down Expand Up @@ -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<void> = 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<void> = sleepSeconds, open: typeof openUrl = openUrl): Promise<{ installationId: number; repoId: number }> {
let mine = await api.request<GitHubView>('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')
Expand Down
6 changes: 5 additions & 1 deletion src/commands/template-author.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 45 additions & 12 deletions src/commands/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----

Expand Down Expand Up @@ -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<string, RepoRef>()
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 {
Expand Down Expand Up @@ -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<any>
// 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<any>
rawRequest: (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }>
}

Expand All @@ -367,6 +384,8 @@ export type TemplateDeployDeps = {
ask?: (v: TemplateVar) => Promise<string>
wait?: (s: number) => Promise<void>
fetchGitHub?: (t: GitHubTarget) => Promise<FetchedTemplate>
authorize?: typeof authorizeTerminal
open?: (url: string) => boolean
}

export async function templateDeploy(target: string, opts: TemplateDeployOpts = {}, deps: TemplateDeployDeps = {}): Promise<void> {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: --yes promises a non-interactive run, but it only suppresses the variable prompt (tty includes !opts.yes); the new GitHub-authorization gate canAuthorizeHere(opts) ignores opts.yes, so insta template deploy <code> --yes on a terminal still starts the device/App-install flow and can block for up to 15 minutes per repo. Gate the GitHub path behind --yes as well, so --yes means no interactive steps of either kind.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/commands/template.ts, line 446:

<comment>`--yes` promises a non-interactive run, but it only suppresses the variable prompt (`tty` includes `!opts.yes`); the new GitHub-authorization gate `canAuthorizeHere(opts)` ignores `opts.yes`, so `insta template deploy <code> --yes` on a terminal still starts the device/App-install flow and can block for up to 15 minutes per repo. Gate the GitHub path behind `--yes` as well, so `--yes` means no interactive steps of either kind.</comment>

<file context>
@@ -426,16 +442,30 @@ export async function templateDeploy(target: string, opts: TemplateDeployOpts =
-    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
</file context>
Suggested change
const canAuthorize = canAuthorizeHere(opts)
const canAuthorize = !opts.yes && 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.
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
.option('--set <NAME=value>', '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")
Expand All @@ -591,6 +592,7 @@ tpl.command('draft <code>').description('Show one community template draft: its
tpl.command('edit <code>').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 <file>', 'JSON file holding the PATCH body, or - to read it from stdin')
.option('--org <id>', "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 <code>').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 <id>', "target org (default: linked project's org)").option('--json')
Expand Down
35 changes: 33 additions & 2 deletions src/template-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ export type ManifestEnv = {
optional?: Record<string, VarSpec | string>
}

// 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
Expand Down Expand Up @@ -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})$/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This accepts invalid GitHub owners such as acme-, so local validation lets an unusable source reach deployment. Require an alphanumeric final character and reject consecutive hyphens.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/template-manifest.ts, line 75:

<comment>This accepts invalid GitHub owners such as `acme-`, so local validation lets an unusable source reach deployment. Require an alphanumeric final character and reject consecutive hyphens.</comment>

<file context>
@@ -67,6 +71,9 @@ export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/
 // 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}$/
 
</file context>
Suggested change
const GITHUB_OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/
const GITHUB_OWNER_RE = /^(?!.*--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/

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']
Expand Down Expand Up @@ -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<string, unknown>
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[] = []
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions test/help-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Loading
Loading