/** * cli:scaffold-pwa — patchers.ts * * Pure string → string patchers for the client app's existing files. Each one: * - is IDEMPOTENT: applying it twice is a fixpoint (second run → changed:false, * byte-identical content); * - honours the `@customised` head marker (bespoke seam — never rewrite a file * an LLM/dev pass owns); * - returns { content, changed, warning? } — when an anchor cannot be found the * patcher returns the source UNCHANGED with a warning, and the caller surfaces * the full snippet in nextSteps (site-vitrine's snippet-fallback pattern). * * No filesystem access here — index.ts reads the targets, generate.ts routes * them through these patchers, index.ts writes the results. */ export interface PatchResult { content: string changed: boolean warning?: string } /** Resolved manifest values shared by the vite-config + index.html patchers. */ export interface ManifestConfig { name: string shortName: string description: string themeColor: string backgroundColor: string lang?: string } export interface MainTsxOptions { mobile: { enabled: boolean; breakpoint?: number } } /** Same contract as every scaffolder: a `@customised` head marker means the file is * bespoke-owned — patchers must never touch it. ONE definition, lib/guarded-rm * (audit 2026-09-24); re-exported for the sibling modules of this CLI. */ import { isCustomised } from '../../../../../lib/guarded-rm.js' export { isCustomised } function unchanged(content: string, warning?: string): PatchResult { return warning ? { content, changed: false, warning } : { content, changed: false } } /** End offset of the last top-level `import …` line, or -1 when none. */ function lastImportLineEnd(src: string): number { const re = /^import\s[^\n]*$/gm let end = -1 let m: RegExpExecArray | null while ((m = re.exec(src)) !== null) end = m.index + m[0].length return end } /** Single-quoted JS string literal (escapes backslashes + quotes). */ function q(s: string): string { return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` } /** Extract a balanced `{ … }` block starting at `openIdx` (naive brace count). */ function braceBlock(src: string, openIdx: number): string | null { let depth = 0 for (let i = openIdx; i < src.length; i++) { const ch = src[i] if (ch === '{') depth++ else if (ch === '}') { depth-- if (depth === 0) return src.slice(openIdx, i + 1) } } return null } /** Leading whitespace of the line `idx` sits on (used to indent inserted members). */ function lineIndentAt(src: string, idx: number): string { const lineStart = src.lastIndexOf('\n', idx - 1) + 1 const m = /^[ \t]*/.exec(src.slice(lineStart, idx)) return m ? m[0] : '' } // ════════════════════════════════════════════════════════════════════════════ // vite.config.ts // ════════════════════════════════════════════════════════════════════════════ /** * The VitePWA plugin entry, mirroring the socle's vite.config.ts * (web/smartstack-web) with its why-comments carried over verbatim. * Exported so index.ts can hand the FULL snippet to the developer when the * patcher could not find an anchor. */ export function vitePwaSnippet(m: ManifestConfig, indent = ' ', precacheMaxMiB = 2.5): string { const i = indent const lang = m.lang ? `\n${i} lang: ${q(m.lang)},` : '' return `${i}// PWA — APP build only. A library build config (vite.lib.config.ts style) ${i}// must NEVER gain this plugin: packages ship no service worker. ${i}VitePWA({ ${i} strategies: 'injectManifest', ${i} srcDir: 'src/pwa', ${i} filename: 'sw.ts', ${i} // Update flow is PROMPT (UpdateBanner) — never autoUpdate: a ${i} // silent reload could destroy an in-progress offline entry. ${i} registerType: 'prompt', ${i} // Registration is done manually in main.tsx (wired to the ${i} // setServiceWorkerUpdateState channel). ${i} injectRegister: false, ${i} manifest: { ${i} name: ${q(m.name)}, ${i} short_name: ${q(m.shortName)}, ${i} description: ${q(m.description)}, ${i} start_url: '/', ${i} scope: '/', ${i} display: 'standalone', ${i} orientation: 'portrait-primary', ${i} theme_color: ${q(m.themeColor)}, ${i} background_color: ${q(m.backgroundColor)},${lang} ${i} categories: ['business', 'productivity'], ${i} icons: [ ${i} { src: '/icons/pwa-192.png', sizes: '192x192', type: 'image/png' }, ${i} { src: '/icons/pwa-512.png', sizes: '512x512', type: 'image/png' }, ${i} { src: '/icons/pwa-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ${i} ], ${i} }, ${i} injectManifest: { ${i} globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], ${i} // iOS Cache API quota is ~50 MB: the precache stays SMALL. A chunk ${i} // above this cap is EXCLUDED from the precache (Workbox build WARNING ${i} // only) — the sw.ts runtime CacheFirst net serves it offline once ${i} // fetched, but FIRST-offline-visit coverage needs entry chunks UNDER ${i} // the cap: split with build.rollupOptions.output.manualChunks rather ${i} // than raising it (audited by DEV-PWA-012). ${i} maximumFileSizeToCacheInBytes: ${precacheMaxMiB} * 1024 * 1024, ${i} }, ${i} // The SW breaks HMR loops and produces false positives in dev — ${i} // PWA behaviour is validated on a real build served by the API. ${i} devOptions: { enabled: false }, ${i}}),` } export function patchViteConfig(src: string, manifest: ManifestConfig, precacheMaxMiB = 2.5): PatchResult { if (src.includes('VitePWA(')) return unchanged(src) if (isCustomised(src)) { return unchanged(src, 'vite.config.ts is marked @customised — VitePWA wiring skipped; add the snippet manually.') } const importEnd = lastImportLineEnd(src) if (importEnd < 0 || !/plugins\s*:\s*\[/.test(src)) { return unchanged( src, 'Could not find the import block and/or the `plugins: [` array in vite.config.ts — add the VitePWA snippet manually.', ) } const importLine = "import { VitePWA } from 'vite-plugin-pwa'" let out = src.slice(0, importEnd) + '\n' + importLine + src.slice(importEnd) const plugins = /plugins\s*:\s*\[/.exec(out)! const at = plugins.index + plugins[0].length out = out.slice(0, at) + '\n' + vitePwaSnippet(manifest, undefined, precacheMaxMiB) + out.slice(at) return { content: out, changed: true } } // ════════════════════════════════════════════════════════════════════════════ // src/main.tsx // ════════════════════════════════════════════════════════════════════════════ const OUTBOX_IMPORT = "import './extensions/outbox.generated';" const REGISTER_SW_IMPORTS = "import { registerSW } from 'virtual:pwa-register'\n" + "import { setServiceWorkerUpdateState, setServiceWorkerUpdater, initOutbox } from '@atlashub/smartstack'" const REGISTER_SW_BLOCK = `// PWA: register the service worker and bridge its lifecycle into the // package's update channel (UpdateBanner prompts — never auto-reload: // a silent reload could destroy an in-progress offline entry). // registerSW is a no-op in dev (devOptions disabled in vite.config.ts). const updateSW = registerSW({ onNeedRefresh: () => setServiceWorkerUpdateState({ needRefresh: true }), onOfflineReady: () => setServiceWorkerUpdateState({ offlineReady: true }), }) setServiceWorkerUpdater(updateSW) // Offline-write foundation: wire the outbox drain triggers (boot, 'online', // tab-visible). Runs AFTER the './extensions/outbox.generated' import above so // every spec is registered before the boot drain replays queued mutations // (audit DEV-PWA-008 checks this order). initOutbox()` /** Full manual snippet for nextSteps when main.tsx could not be patched. */ export function mainTsxSnippet(opts: MainTsxOptions): string { const bp = opts.mobile.breakpoint !== undefined ? `, breakpoint: ${opts.mobile.breakpoint}` : '' const mobileHint = opts.mobile.enabled ? `\n\n// …and add to the SmartStackProvider config object:\n// mobile: { enabled: true${bp} },` : '' return ( `Add to src/main.tsx (imports at the top — the outbox.generated import MUST come before initOutbox(); the block goes before createRoot):\n\n` + `${OUTBOX_IMPORT}\n${REGISTER_SW_IMPORTS}\n\n${REGISTER_SW_BLOCK}${mobileHint}` ) } /** * ⚠ What the `mobile` block actually does. The package enables the mobile shell * BY DEFAULT (`MobileShellConfig.enabled` defaults to true, breakpoint 768), so * `mobile: { enabled: true }` does not "switch it on" — it makes the choice * EXPLICIT, and it is the only way to reach `breakpoint` / `shellComponent` / * `bottomNav`. That is why the anchor bug mattered: an app asking for * `breakpoint: 900` silently kept 768, because the block was never written. * Whether a given PAGE shows up inside the shell is a different contract — the * per-page `PageMobileMeta` scaffold-routes emits from `pwa.support`. * * The two shapes the SmartStackProvider config takes in a client main.tsx: * * 1. `config={{ … }}` — the INLINE JSX literal `ss init` generates * (src/commands/init.ts). This is the default * shape of every standard app, so it is tried * FIRST: it is bound to the provider, not a * same-named local. * 2. `const config = { … }` — the hoisted form apps hand-edit into. * * Returns the match offset (for indentation), the insert offset (just past the * opening brace) and the balanced body (to test for an existing `mobile:` key). */ type ProviderConfigAnchor = | { kind: 'none' } | { kind: 'jsx' | 'const'; matchIndex: number; insertAt: number; body: string | null } export function findProviderConfigAnchor(src: string): ProviderConfigAnchor { const candidates: Array<{ kind: 'jsx' | 'const'; re: RegExp }> = [ { kind: 'jsx', re: /config=\{\{/ }, { kind: 'const', re: /const\s+config\s*=\s*\{/ }, ] let unbalanced: ProviderConfigAnchor | null = null for (const { kind, re } of candidates) { const m = re.exec(src) if (!m) continue const insertAt = m.index + m[0].length const body = braceBlock(src, insertAt - 1) // An unbalanced first candidate must not mask a usable second one. if (body === null) { unbalanced ??= { kind, matchIndex: m.index, insertAt, body } continue } return { kind, matchIndex: m.index, insertAt, body } } return unbalanced ?? { kind: 'none' } } export function patchMainTsx(src: string, opts: MainTsxOptions): PatchResult { if (isCustomised(src)) { return unchanged(src, 'main.tsx is marked @customised — PWA wiring skipped; apply the snippet manually.') } let out = src let changed = false const warnings: string[] = [] // ── 1) outbox.generated side-effect import — after the LAST ./extensions/* import. // aggregate-outbox always emits the file (even empty), so the import is // unconditional and can never dangle. if (!out.includes('./extensions/outbox.generated')) { const extRe = /^import\s+['"]\.\/extensions\/[^'"]+['"];?[^\S\n]*$/gm let lastExt: RegExpExecArray | null = null let m: RegExpExecArray | null while ((m = extRe.exec(out)) !== null) lastExt = m if (lastExt) { const at = lastExt.index + lastExt[0].length // NOTE: the comment must not spell the init call — DEV-PWA-008 checks the // TEXTUAL order "outbox.generated import before the init call". out = out.slice(0, at) + `\n// Offline-write outbox registrations (aggregate-outbox) — must precede the outbox boot drain.\n${OUTBOX_IMPORT}` + out.slice(at) changed = true } else { const at = lastImportLineEnd(out) if (at >= 0) { out = out.slice(0, at) + `\n${OUTBOX_IMPORT}` + out.slice(at) changed = true } else { warnings.push( "Could not find an import block in main.tsx — add `import './extensions/outbox.generated'` manually (it must precede initOutbox()).", ) } } } // ── 2) registerSW bridge + initOutbox(), before createRoot. if (!out.includes('registerSW(')) { const importEnd = lastImportLineEnd(out) const rootMatch = /^[^\n]*createRoot\(/m.exec(out) if (importEnd < 0 || !rootMatch) { warnings.push( 'Could not anchor the registerSW/initOutbox block in main.tsx (import block and/or createRoot( not found) — apply the snippet manually.', ) } else { out = out.slice(0, importEnd) + '\n' + REGISTER_SW_IMPORTS + out.slice(importEnd) const root2 = /^[^\n]*createRoot\(/m.exec(out)! out = out.slice(0, root2.index) + REGISTER_SW_BLOCK + '\n\n' + out.slice(root2.index) changed = true } } // ── 3) mobile shell config in the SmartStackProvider config literal. // Written for BOTH values: the package defaults to enabled, so `enabled:false` // is exactly the case that MUST land in the file — before 2026-09 it wrote // nothing and the option was inert (audit B-WARN-16). { const enabled = opts.mobile.enabled const anchor = findProviderConfigAnchor(out) if (anchor.kind === 'none') { warnings.push( `Could not find the SmartStackProvider config literal in main.tsx (neither \`config={{\` nor \`const config = {\`) — add \`mobile: { enabled: ${enabled} }\` to the config manually.`, ) } else if (anchor.body === null) { warnings.push('The SmartStackProvider config literal in main.tsx never closes — mobile wiring skipped.') } else if (!/\bmobile\s*:/.test(anchor.body)) { const bp = opts.mobile.breakpoint !== undefined ? `, breakpoint: ${opts.mobile.breakpoint}` : '' // Members line up with the anchor line + one level, so the JSX form // (`config={{` nested inside ) stays readable. const ind = lineIndentAt(out, anchor.matchIndex) + ' ' out = out.slice(0, anchor.insertAt) + (enabled ? `\n${ind}// Mobile shell (PWA): viewports under the breakpoint render the mobile shell.` : `\n${ind}// Mobile shell (PWA) OFF: the package enables it by default — this keeps the desktop layout on narrow viewports.`) + `\n${ind}mobile: { enabled: ${enabled}${bp} },` + out.slice(anchor.insertAt) changed = true } } const warning = warnings.length > 0 ? warnings.join(' ') : undefined return warning ? { content: out, changed, warning } : { content: out, changed } } // ════════════════════════════════════════════════════════════════════════════ // src/App.tsx // ════════════════════════════════════════════════════════════════════════════ export function patchAppTsx(src: string): PatchResult { if (src.includes('UpdateBanner')) return unchanged(src) if (isCustomised(src)) { return unchanged( src, 'App.tsx is marked @customised — mount and manually next to .', ) } if (!//.test(src)) { return unchanged( src, 'Could not find `` in App.tsx — mount and manually.', ) } let out = src.replace( //, '<>\n \n {/* PWA prompts: update available (prompt flow, never auto-reload) + install */}\n \n \n ', ) const ssImportRe = /import\s*\{([^}]*)\}\s*from\s*['"]@atlashub\/smartstack['"]/ const im = ssImportRe.exec(out) if (im) { const names = im[1] .split(',') .map((s) => s.trim()) .filter(Boolean) for (const n of ['UpdateBanner', 'InstallBanner']) { if (!names.includes(n)) names.push(n) } out = out.replace(ssImportRe, `import { ${names.join(', ')} } from '@atlashub/smartstack'`) } else { const at = lastImportLineEnd(out) const line = "import { UpdateBanner, InstallBanner } from '@atlashub/smartstack'" out = at >= 0 ? out.slice(0, at) + '\n' + line + out.slice(at) : line + '\n' + out } return { content: out, changed: true } } // ════════════════════════════════════════════════════════════════════════════ // index.html // ════════════════════════════════════════════════════════════════════════════ function escAttr(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"') } export function patchIndexHtml(src: string, m: ManifestConfig): PatchResult { if (isCustomised(src)) { return unchanged(src, 'index.html is marked @customised — add the PWA meta tags manually.') } if (!src.includes('')) { return unchanged(src, 'Could not find in index.html — add the PWA meta tags manually.') } let out = src let changed = false const insertions: string[] = [] // viewport-fit=cover on the viewport meta (safe-area insets on notched iOS). const vpRe = // const vp = vpRe.exec(out) if (vp) { if (!vp[1].includes('viewport-fit=cover')) { out = out.replace(vpRe, ``) changed = true } } else { insertions.push('') } // Per-tag idempotent inserts (presence check per tag, socle index.html shape). const tags: Array<{ present: boolean; tag: string }> = [ { present: /]*prefers-color-scheme:\s*light/.test(out), tag: ``, }, { present: /]*prefers-color-scheme:\s*dark/.test(out), tag: ``, }, { present: out.includes('name="mobile-web-app-capable"'), tag: '' }, { present: out.includes('name="apple-mobile-web-app-capable"'), tag: '' }, { present: out.includes('name="apple-mobile-web-app-status-bar-style"'), tag: '', }, { present: out.includes('name="apple-mobile-web-app-title"'), tag: ``, }, { present: out.includes('rel="apple-touch-icon"'), tag: '' }, ] for (const t of tags) { if (!t.present) insertions.push(t.tag) } if (insertions.length > 0) { const idx = out.indexOf('') out = out.slice(0, idx) + insertions.map((t) => ` ${t}\n`).join('') + out.slice(idx) changed = true } return { content: out, changed } } // ════════════════════════════════════════════════════════════════════════════ // package.json // ════════════════════════════════════════════════════════════════════════════ /** Pinned to the socle's web/smartstack-web/package.json (features/pwa). * `idb` is a dependency of the PACKAGE itself — the client app must NOT add it. */ export const PWA_DEV_DEPENDENCIES: Record = { 'vite-plugin-pwa': '^1.3.0', 'workbox-core': '^7.4.1', 'workbox-expiration': '^7.4.1', 'workbox-precaching': '^7.4.1', 'workbox-routing': '^7.4.1', 'workbox-strategies': '^7.4.1', } export function mergePackageJson(src: string): PatchResult { let pkg: { dependencies?: Record devDependencies?: Record [k: string]: unknown } try { pkg = JSON.parse(src) as typeof pkg } catch { return unchanged(src, 'package.json is not valid JSON — add the PWA devDependencies manually.') } const deps = pkg.dependencies ?? {} const dev = pkg.devDependencies ?? {} let changed = false for (const [name, version] of Object.entries(PWA_DEV_DEPENDENCIES)) { // Never downgrade (or touch) an entry the app already carries, in either section. if (dev[name] !== undefined || deps[name] !== undefined) continue dev[name] = version changed = true } if (!changed) return unchanged(src) pkg.devDependencies = dev const trailing = src.endsWith('\n') ? '\n' : '' return { content: JSON.stringify(pkg, null, 2) + trailing, changed: true } } // ════════════════════════════════════════════════════════════════════════════ // src/vite-env.d.ts // ════════════════════════════════════════════════════════════════════════════ const PWA_CLIENT_REF = '/// ' /** Types the `virtual:pwa-register` module main.tsx imports. */ export function patchViteEnv(src: string): PatchResult { if (src.includes('vite-plugin-pwa/client')) return unchanged(src) const base = src.length === 0 ? '' : src.endsWith('\n') ? src : src + '\n' return { content: base + PWA_CLIENT_REF + '\n', changed: true } }