import { kody } from 'kody:runtime' import { TANSTACK_STARTER_FILES as STARTER_FILES, TANSTACK_STARTER_TEMPLATE_RENAMES as STARTER_TEMPLATE_RENAMES, } from './starter-tanstack-templates.ts' export type CreatePackageAppInput = { /** * Package name leaf or scoped name. * Examples: `my-notes`, `@kentcdodds/my-notes` * Preferred over deprecated `kodyId`. */ packageName?: string /** @deprecated Use `packageName`. */ kodyId?: string /** Human title shown in the app shell / PWA manifest. */ title: string /** Optional package.json#kody.description. Defaults from title. */ description?: string /** * Optional SW cache name. Defaults to `{packageId}-v1`. * Bump when shell caching rules change. */ cacheName?: string /** * When true, only build the transformed file set — no packageSave / publish. * Prefer this to verify placeholders before minting a real package. */ dryRun?: boolean } export type CreatePackageAppFile = { path: string content: string } export type CreatePackageAppDryRunResult = { ok: true dryRun: true package_name: string kody_id: string files: CreatePackageAppFile[] placeholders: { PACKAGE_NAME: string PACKAGE_ID: string APP_TITLE: string CACHE_NAME: string KODY_DESCRIPTION: string } } export type CreatePackageAppPublishedResult = { ok: true dryRun?: false package_id: string kody_id: string hosted_app_url: string | null published_commit: string | null name: string publish_status: string } export type CreatePackageAppErrorResult = { ok: false error: string package_id?: string kody_id?: string details?: unknown } export type CreatePackageAppResult = | CreatePackageAppDryRunResult | CreatePackageAppPublishedResult | CreatePackageAppErrorResult type PlaceholderMap = { PACKAGE_NAME: string PACKAGE_ID: string APP_TITLE: string CACHE_NAME: string KODY_DESCRIPTION: string } function requireNonEmpty(value: unknown, label: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new Error(`${label} is required (non-empty string).`) } return value.trim() } /** * Normalize `@scope/leaf` or `leaf` into scoped package.json name + kody id leaf. */ export function resolvePackageIdentity(rawPackageName: string): { packageName: string packageId: string } { const value = requireNonEmpty(rawPackageName, 'packageName') if (value.includes('/')) { const match = value.match(/^@([a-z0-9-]+)\/([a-z0-9][a-z0-9._-]*)$/i) if (!match) { throw new Error( `packageName must look like @owner/leaf or leaf (got ${JSON.stringify(value)}).`, ) } return { packageName: `@${match[1]}/${match[2]}`, packageId: match[2] } } if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) { throw new Error( `packageName leaf must be alphanumeric with ._- (got ${JSON.stringify(value)}).`, ) } return { packageName: `@kentcdodds/${value}`, packageId: value } } function applyPlaceholders(content: string, map: PlaceholderMap): string { return content .replaceAll('__PACKAGE_NAME__', map.PACKAGE_NAME) .replaceAll('__PACKAGE_ID__', map.PACKAGE_ID) .replaceAll('__APP_TITLE__', map.APP_TITLE) .replaceAll('__CACHE_NAME__', map.CACHE_NAME) .replaceAll('__KODY_DESCRIPTION__', map.KODY_DESCRIPTION) } /** * Build the Artifacts file set from embedded `starter-tanstack/` (not the kit demo app). * * Accepted input shape (also what `createPackageApp` resolves before calling this): * - `packageName` — scoped name (`@owner/leaf`); fills `__PACKAGE_NAME__` * - `packageId` — name leaf; fills `__PACKAGE_ID__` * - `title` — human app title for shell / PWA manifest; fills `__APP_TITLE__` * - `description` — `package.json#kody.description`; fills `__KODY_DESCRIPTION__` * - `cacheName` — service worker cache name; fills `__CACHE_NAME__` (defaults to `{packageId}-v1` upstream) * * @example * buildScaffoldFiles({ * packageName: '@kentcdodds/my-notes', * packageId: 'my-notes', * title: 'My Notes', * description: 'Personal notes PWA on Kody.', * cacheName: 'my-notes-v1', * }) */ export function buildScaffoldFiles(input: { packageName: string packageId: string title: string description: string cacheName: string }): CreatePackageAppFile[] { const map: PlaceholderMap = { PACKAGE_NAME: input.packageName, PACKAGE_ID: input.packageId, APP_TITLE: input.title, CACHE_NAME: input.cacheName, KODY_DESCRIPTION: input.description, } const files: CreatePackageAppFile[] = [] for (const [templatePath, raw] of Object.entries(STARTER_FILES)) { const renamed = STARTER_TEMPLATE_RENAMES[templatePath] ?? templatePath files.push({ path: renamed, content: applyPlaceholders(raw, map), }) } files.sort((a, b) => a.path.localeCompare(b.path)) return files } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } async function awaitDispatchedPublish(input: { packageId: string workflowId: string maxWaitMs?: number }): Promise { const maxWaitMs = input.maxWaitMs ?? 120_000 const started = Date.now() while (Date.now() - started < maxWaitMs) { await sleep(2500) const listed = (await kody.workflowRunList({ limit: 50 })) as { workflows?: Array<{ id: string; status: string | null }> } const run = (listed.workflows || []).find((w) => w.id === input.workflowId) const status = run?.status ?? null if ( status === 'complete' || status === 'errored' || status === 'terminated' || status === 'cancelled' ) { if (status !== 'complete') { return { status: 'workflow_failed', workflow_id: input.workflowId, run_status: status, last_error: (run as { last_error?: string | null } | undefined)?.last_error ?? null, } } // Idempotent re-call: should return already_published with hosted URL. return await kody.packagePublishExternalPush({ package_id: input.packageId, }) } } return { status: 'workflow_timeout', workflow_id: input.workflowId, message: `Publish workflow ${input.workflowId} did not finish within ${maxWaitMs}ms.`, } } /** * Scaffold a new private Kody package app from `@kentcdodds/package-app-kit` `starter-tanstack/` (TanStack Router + React). * * Copies the embedded TanStack Router starter (`starter-tanstack/`, not this kit’s demo `kody.app.entry`), * replaces placeholders, saves via `packageSave`, and publishes with * `packagePublishExternalPush`. Emits `.kody/icon.svg` (TanStack koala PNG * base64-wrapped for text-only packageSave) as the list/identity mark, plus * `src/icon-png-bytes.ts` so favicon/PWA routes serve the same mark without * binary packageSave (no root `community-icon.*`). Prefer `dryRun: true` * to verify transforms without creating a package. * * @example One-call playbook (ChatGPT / execute) * ```ts * import createPackageAppTanstack from 'kody:@kentcdodds/package-app-kit/create-package-app-tanstack' * * export default async function main() { * // Transform only: * // return await createPackageAppTanstack({ packageName: 'my-notes', title: 'My Notes', dryRun: true }) * * return await createPackageAppTanstack({ * packageName: '@kentcdodds/my-notes', * title: 'My Notes', * description: 'Personal notes PWA on Kody.', * }) * } * ``` * * @returns `{ ok, package_id, kody_id, hosted_app_url, published_commit }` on success */ export default async function createPackageAppTanstack( input: CreatePackageAppInput, ): Promise { try { const rawName = input.packageName ?? input.kodyId const { packageName, packageId } = resolvePackageIdentity(String(rawName || '')) const title = requireNonEmpty(input.title, 'title') const description = typeof input.description === 'string' && input.description.trim() ? input.description.trim() : `${title} — package app scaffolded from package-app-kit.` const cacheName = typeof input.cacheName === 'string' && input.cacheName.trim() ? input.cacheName.trim() : `${packageId}-v1` const placeholders: PlaceholderMap = { PACKAGE_NAME: packageName, PACKAGE_ID: packageId, APP_TITLE: title, CACHE_NAME: cacheName, KODY_DESCRIPTION: description, } const files = buildScaffoldFiles({ packageName, packageId, title, description, cacheName, }) if (input.dryRun) { return { ok: true, dryRun: true, package_name: packageName, kody_id: packageId, files, placeholders, } } const saved = (await kody.packageSave({ files, })) as { package_id: string kody_id: string name: string } let published = (await kody.packagePublishExternalPush({ package_id: saved.package_id, })) as { status: string published_commit?: string | null hosted_app_url?: string | null workflow_id?: string message?: string failed_checks?: unknown } if (published.status === 'dispatched' && published.workflow_id) { published = (await awaitDispatchedPublish({ packageId: saved.package_id, workflowId: published.workflow_id, })) as typeof published } if ( published.status === 'published' || published.status === 'already_published' ) { return { ok: true, package_id: saved.package_id, kody_id: saved.kody_id, name: saved.name, hosted_app_url: published.hosted_app_url ?? null, published_commit: published.published_commit ?? null, publish_status: published.status, } } return { ok: false, error: `Publish did not complete (status=${published.status}).`, package_id: saved.package_id, kody_id: saved.kody_id, details: published, } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { ok: false, error: message } } } export { TANSTACK_STARTER_FILES, TANSTACK_STARTER_TEMPLATE_RENAMES } from './starter-tanstack-templates.ts'