#!/usr/bin/env node /** * cli:scaffold-pwa — Turn a generated CLIENT SmartStack app into an * installable, offline-capable PWA. * * Emits/patches (all paths relative to the resolved web root): * - src/pwa/sw.ts faithful socle service worker (overwrite) * - src/pwa/cacheKey.ts faithful socle cache-key module (overwrite) * - src/pwa/swMessages.ts faithful socle SW-messaging helper (overwrite) * - public/icons/*.png placeholder icons (skip-if-exists) * - src/components/pwa/OutboxStatusChip.tsx dev-owned queue chip (skip-if-exists) * - vite.config.ts, src/main.tsx, src/App.tsx, * index.html, package.json, src/vite-env.d.ts idempotent patches * * Fail-closed: refuses non-CLIENT web roots (detectFrontendMode) and packages * without the PWA channel (version floor + capability probe — see validate.ts). * A `@customised` head marker on any target always wins: the file is skipped. */ import { parseArgs } from 'node:util' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { validate } from './validate.js' import { generate, type TargetSources } from './generate.js' import { isCustomised } from './patchers.js' import { MIN_SMARTSTACK_PWA_VERSION } from './types.js' import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js' import { readSpecArg } from '../../../../../lib/spec-arg.js' import { safeJoinPath } from '../../../../../lib/fs.js' const COMMAND = 'scaffold-pwa' function readIfExists(p: string): string | undefined { try { return readFileSync(p, 'utf-8') } catch { return undefined } } async function main(): Promise { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, dry_run: { type: 'boolean', default: false }, }, strict: true, }) const specSrc = readSpecArg(values) if ('error' in specSrc) { printEnvelope(failGenerate(COMMAND, [specSrc.error])) process.exit(1) } let raw: unknown try { raw = JSON.parse(specSrc.raw) } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in --spec'])) process.exit(1) } const v = await validate(raw) if (!v.valid || !v.spec || !v.webRootAbs) { printEnvelope(failGenerate(COMMAND, v.errors)) process.exit(1) } const spec = v.spec const webRoot = v.webRootAbs // Read the patch targets — generate stays pure. const targets: TargetSources = { viteConfig: readIfExists(join(webRoot, 'vite.config.ts')), mainTsx: readIfExists(join(webRoot, 'src', 'main.tsx')), appTsx: readIfExists(join(webRoot, 'src', 'App.tsx')), indexHtml: readIfExists(join(webRoot, 'index.html')), packageJson: readIfExists(join(webRoot, 'package.json')), viteEnv: readIfExists(join(webRoot, 'src', 'vite-env.d.ts')), indexCss: readIfExists(join(webRoot, 'src', 'index.css')), } const result = generate(spec, targets) const warnings = [...v.warnings, ...result.warnings] if (values.dry_run) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, webRoot, manifest: result.manifest as unknown as Record, files: result.files.map((f) => `${f.path} [${f.strategy}]`), }, warnings, }), ) process.exit(0) } const filesCreated: string[] = [] const filesModified: string[] = [] const skipped: string[] = [] for (const file of result.files) { const abs = resolve(safeJoinPath(webRoot, file.path)) const exists = existsSync(abs) if (file.strategy === 'skip-if-exists' && exists && !spec.force) { skipped.push(abs) continue } if (exists && file.encoding !== 'base64') { const current = readFileSync(abs, 'utf-8') // Bespoke seam: a `@customised` head marker ALWAYS wins (same contract as // scaffold-component). Patchers already skip customised sources; this // guards the overwrite-strategy files (sw.ts) and racy re-reads. if (isCustomised(current)) { skipped.push(abs) continue } if (current === file.content) continue // already up to date } mkdirSync(dirname(abs), { recursive: true }) if (file.encoding === 'base64') { writeFileSync(abs, Buffer.from(file.content, 'base64')) } else { writeFileSync(abs, file.content, 'utf-8') } ;(exists ? filesModified : filesCreated).push(abs) } const nextSteps: string[] = [...result.manualSnippets] nextSteps.push('Run `npm install` in the web app — new devDependencies: vite-plugin-pwa + workbox-*.') nextSteps.push( 'Run `npm run build` — the service worker only exists on a real build (devOptions.enabled is false by design; validate PWA behaviour on the built app served by the API).', ) nextSteps.push( `⚠ Precache cap: chunks larger than ${spec.precacheMaxMiB} MiB are EXCLUDED from the precache (Workbox build ` + 'WARNING only — not an error). An oversized ENTRY chunk = blank screen on the FIRST offline launch. The ' + 'generated sw.ts ships a CacheFirst runtime net (assets cached after first use), but first-visit offline ' + 'coverage requires entry chunks under the cap: watch the `npm run build` output and split oversized chunks ' + '(vite build.rollupOptions.output.manualChunks) rather than raising the cap. DEV-PWA-012 verifies dist/ ' + 'against the cap after a build.', ) if (spec.icons === 'placeholder') { nextSteps.push( 'Replace the solid-color placeholder icons under public/icons/ with branded artwork (same file names + sizes: 192, 512, 512-maskable, 180).', ) } nextSteps.push( 'Run aggregate-outbox (skills/development/frontend/pwa/cli/aggregate-outbox) so src/extensions/outbox.generated.ts exists — main.tsx now imports it unconditionally.', ) nextSteps.push('Then run the audit-dev-pwa rule pack to verify the wiring (DEV-PWA-001..012).') nextSteps.push( `Runtime requires @atlashub/smartstack >= ${MIN_SMARTSTACK_PWA_VERSION} (or any build exposing setServiceWorkerUpdater + initOutbox).`, ) printEnvelope( generateEnvelope(COMMAND, { data: { webRoot, mode: 'client', appCode: spec.appCode, manifest: result.manifest as unknown as Record, packageVersion: v.packageVersion ?? null, fileCount: filesCreated.length + filesModified.length, skipped, }, filesCreated, filesModified, warnings, nextSteps, }), ) } void main()