Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

src/create-package-app.ts

335 lines · 9.3 KB · TypeScript
import { kody } from 'kody:runtime'
import { STARTER_FILES, STARTER_TEMPLATE_RENAMES } from './starter-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-remix/` (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<unknown> {
	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-remix/`.
 *
 * Copies the embedded Remix starter (`starter-remix/`, not this kit’s demo `kody.app.entry`),
 * replaces placeholders, saves via `packageSave`, and publishes with
 * `packagePublishExternalPush`. Emits `.kody/icon.svg` as the list/identity mark
 * (no root `community-icon.*`; PWA icons stay kit-served). Prefer `dryRun: true`
 * to verify transforms without creating a package.
 *
 * @example One-call playbook (ChatGPT / execute)
 * ```ts
 * import createPackageApp from 'kody:@kentcdodds/package-app-kit/create-package-app'
 *
 * export default async function main() {
 *   // Transform only:
 *   // return await createPackageApp({ packageName: 'my-notes', title: 'My Notes', dryRun: true })
 *
 *   return await createPackageApp({
 *     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 createPackageApp(
	input: CreatePackageAppInput,
): Promise<CreatePackageAppResult> {
	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 { STARTER_FILES, STARTER_TEMPLATE_RENAMES }