Skip to content
Merged
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/template-author.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type TemplateDraft = {
readme: string | null; logoUrl: string | null; services: TemplateDraftService[]
publishRequirements: TemplateRequirement[]; updatedAt: string
}
/** A row of the org's list (the platform's CommunityTemplateSummary), as the table reads it. */
export type TemplateDraftSummary = Pick<TemplateDraft, 'code' | 'name' | 'status' | 'publishedVersion' | 'hasUnpublishedChanges' | 'takenDown'>
type DraftAnswer = { template: TemplateDraft }
/** What regenerate found added or removed in the project since the last build. */
export type TemplateChanges = { addedServices: string[]; removedServices: string[]; addedVariables: string[]; removedVariables: string[] }
Expand Down Expand Up @@ -64,7 +66,7 @@ export function templateStatusWord(t: Pick<TemplateDraft, 'status' | 'hasUnpubli
const headLine = (t: TemplateDraft): string => `${t.code}: ${t.name} (${templateStatusWord(t)})`

/** One aligned row per template of the org, as `template list` prints the gallery. */
export function draftListLines(templates: TemplateDraft[]): string[] {
export function draftListLines(templates: TemplateDraftSummary[]): string[] {
if (!templates.length) return ['no templates in this org yet, create one with: insta template create']
const head = ['CODE', 'STATUS', 'VERSION', 'NAME']
const rows = templates.map((t) => [t.code, templateStatusWord(t), t.publishedVersion ?? '-', t.name])
Expand Down Expand Up @@ -201,7 +203,7 @@ const orgFlag = (opts: { org?: string }): string => (opts.org ? ` --org ${opts.o
export async function templateDrafts(opts: OrgOpts = {}, deps: TemplateAuthorDeps = {}): Promise<void> {
const api = deps.api ?? (await ApiClient.load())
const orgId = await resolveOrgId(opts, deps.project)
const { templates } = await api.request<{ templates: TemplateDraft[] }>('GET', `/orgs/${orgId}/templates`)
const { templates } = await api.request<{ templates: TemplateDraftSummary[] }>('GET', `/orgs/${orgId}/templates`)
if (opts.json) return printJson(templates)
for (const line of draftListLines(templates)) info(line)
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,8 @@ tpl.command('create').description("Create a community template draft from the li
.option('--json')
.action(guard((o) => templateAuthor.templateCreate(o)))
tpl.command('drafts').description("List your org's community templates, drafts and published, with their status")
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.option('--org <id>', "target org (default: linked project's org)")
.option('--json', 'print one summary per template as the platform serves it, with no services, variables or publish requirements. insta template draft <code> --json prints one draft in full')
.action(guard((o) => templateAuthor.templateDrafts(o)))
tpl.command('draft <code>').description('Show one community template draft: its services, every variable with its choice, and the publish requirements')
.option('--org <id>', "target org (default: linked project's org)").option('--json')
Expand Down
41 changes: 35 additions & 6 deletions test/template-author.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Template authoring (spec 2026-10-06 §3): a real ApiClient over a fake fetch.
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Readable } from 'node:stream'
Expand All @@ -10,7 +12,7 @@ import type { ProjectConfig } from '../src/config.js'
import {
templateEditorUrl, templateStatusWord, draftListLines, requirementLines, readinessLines, regenerateChangeLines, draftLines,
parsePatch, readAllStdin, templateDrafts, templateDraft, templateCreate, templateEdit, templateRegenerate, templatePublish, templateUnpublish,
templateDelete, BLANK_NOT_YET, type TemplateDraft,
templateDelete, BLANK_NOT_YET, type TemplateDraft, type TemplateDraftSummary,
} from '../src/commands/template-author.js'

const API = 'https://api.instacloud.com'
Expand Down Expand Up @@ -50,6 +52,14 @@ const view = (over: Partial<TemplateDraft> & Record<string, unknown> = {}): Temp
...over,
} as TemplateDraft)

// The platform's CommunityTemplateSummary, one row of the org list, every key present.
const summary = (over: Partial<TemplateDraftSummary> & Record<string, unknown> = {}): TemplateDraftSummary => ({
id: '11111111-0000-4000-8000-000000000001', code: 'my-app', orgId: ORG, sourceProjectId: PROJECT, blank: false,
status: 'draft', publishedVersion: null, hasUnpublishedChanges: true, takenDown: false,
name: 'My App', tagline: null, category: null, logoUrl: null, updatedAt: UPDATED, deployCount: 0, totalProjects: 0,
...over,
} as TemplateDraftSummary)

type Call = { method: string; path: string; body: unknown }
type Answer = { status?: number; body: unknown }

Expand Down Expand Up @@ -102,8 +112,15 @@ describe('draft rendering', () => {
expect(templateStatusWord(view({ status: 'published', hasUnpublishedChanges: true }))).toBe('published, with unpublished edits')
expect(templateStatusWord(view({ status: 'published', takenDown: true }))).toBe('taken down')
})
it('lists the org in aligned columns, and says so when there is nothing', () => {
expect(draftListLines([view(), view({ code: 'n8n-fork', name: 'n8n fork', status: 'published', publishedVersion: '1.0.2' })])).toEqual([
it("has the platform's 16 summary keys in the summary fixture, no more and no fewer", () => {
const KEYS = [
'id', 'code', 'orgId', 'sourceProjectId', 'blank', 'status', 'publishedVersion', 'hasUnpublishedChanges', 'takenDown',
'name', 'tagline', 'category', 'logoUrl', 'updatedAt', 'deployCount', 'totalProjects',
]
expect(Object.keys(summary()).sort()).toEqual([...KEYS].sort())
})
it('lists the org in aligned columns from summary rows, and says so when there is nothing', () => {
expect(draftListLines([summary(), summary({ code: 'n8n-fork', name: 'n8n fork', status: 'published', publishedVersion: '1.0.2', hasUnpublishedChanges: false })])).toEqual([
'CODE STATUS VERSION NAME',
'my-app draft - My App',
'n8n-fork published 1.0.2 n8n fork',
Expand Down Expand Up @@ -157,7 +174,7 @@ describe('draft rendering', () => {
describe('template drafts', () => {
it("lists the linked project's org", async () => {
const project = linked()
const { api, calls } = platform({ [`GET /orgs/${ORG}/templates`]: () => ({ body: { templates: [view()] } }) })
const { api, calls } = platform({ [`GET /orgs/${ORG}/templates`]: () => ({ body: { templates: [summary()] } }) })
await templateDrafts({}, { api, project })
expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([`GET /orgs/${ORG}/templates`])
expect(printed()).toBe('CODE STATUS VERSION NAME\nmy-app draft - My App\n')
Expand All @@ -169,8 +186,8 @@ describe('template drafts', () => {
expect(project).not.toHaveBeenCalled()
expect(calls[0]!.path).toBe(`/orgs/${OTHER_ORG}/templates`)
})
it('prints the platform list as served under --json', async () => {
const templates = [view()]
it('prints the platform list as served under --json, one summary per template', async () => {
const templates = [summary()]
const { api } = platform({ [`GET /orgs/${ORG}/templates`]: () => ({ body: { templates } }) })
await templateDrafts({ json: true }, { api, project: linked() })
expect(JSON.parse(printed())).toEqual(templates)
Expand Down Expand Up @@ -601,3 +618,15 @@ describe('template delete', () => {
expect(e.message).toBe('a published template cannot be deleted, unpublish it instead')
})
})

describe('template drafts --help', () => {
const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url))
it('says --json prints one summary per template and names the command that prints a draft in full', () => {
const r = spawnSync(process.execPath, ['--import', 'tsx', entry, 'template', 'drafts', '--help'], { encoding: 'utf8', timeout: 30_000 })
expect(r.status, r.stderr).toBe(0)
// Commander wraps help at 80 columns, so words are matched across line breaks.
const help = r.stdout.replace(/\s+/g, ' ')
expect(help).toContain('one summary per template')
expect(help).toContain('insta template draft <code> --json prints one draft in full')
}, 30_000)
})
Loading