← 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 · TypeScriptimport { 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 }