/** * cli:scaffold-pwa — validate.ts * * Fail-closed gates, in order: * 1. Zod parse (defaults applied). * 2. Web root resolution (spec.webRoot → detector → web/{appCode}-web), * existence + assertWebProjectRoot (never write into a backend tree). * 3. detectFrontendMode(webRootAbs) MUST be 'client' — the socle manages its * own PWA; 'unknown' refuses rather than guesses. * 4. Package gate: version floor (MIN_SMARTSTACK_PWA_VERSION) AND capability * probe (grep the installed dist for setServiceWorkerUpdater + initOutbox). * Probe success WINS over the version floor (file:/pre-release installs). * * Each gate is exported on its own so unit tests can exercise it with temp-dir * fixtures (fake node_modules with/without the export markers). */ import fs from 'node:fs' import path from 'node:path' import { assertWebProjectRoot } from '../../../../../lib/fs.js' import { detectFrontendMode, findSmartStackStructure } from '../../../../../lib/detector.js' import { MIN_SMARTSTACK_PWA_VERSION, ScaffoldPwaInputSchema, type ScaffoldPwaInput, type ValidationResult, } from './types.js' /** * The package exports the wiring depends on — the capability-probe markers. * Two families, both mandatory: * - setServiceWorkerUpdater + initOutbox → the PWA/offline channel main.tsx wires; * - MobileShell + useMobileNavContext → the "descente par paliers" mobile shell. * A package that predates the shell renders no mobile chrome at all, and the * `mobile: { … }` config block this CLI writes has nothing to configure — so * probing for it turns a silent false start into an explicit refusal. */ export const REQUIRED_PACKAGE_EXPORTS = [ 'setServiceWorkerUpdater', 'initOutbox', 'MobileShell', 'useMobileNavContext', ] as const /** spec.webRoot → findSmartStackStructure(projectPath).web → web/{appCode}-web. */ export async function resolveWebRoot(spec: ScaffoldPwaInput): Promise { if (spec.webRoot) return path.resolve(spec.projectPath, spec.webRoot) const structure = await findSmartStackStructure(spec.projectPath) if (structure.web) return path.resolve(structure.web) return path.resolve(spec.projectPath, 'web', `${spec.appCode}-web`) } /** Loose semver compare on the numeric triple (prerelease/range prefixes stripped). * Returns <0 / 0 / >0. */ export function compareVersions(a: string, b: string): number { const norm = (v: string): number[] => v .replace(/^[\s^~>=<]+/, '') .split('-')[0] .split('.') .map((n) => Number.parseInt(n, 10) || 0) const av = norm(a) const bv = norm(b) for (let i = 0; i < 3; i++) { const d = (av[i] ?? 0) - (bv[i] ?? 0) if (d !== 0) return d } return 0 } /** Version of the INSTALLED package (node_modules), not the declared range. */ export function readInstalledPackageVersion(webRootAbs: string): string | null { try { const pkg = JSON.parse( fs.readFileSync( path.join(webRootAbs, 'node_modules', '@atlashub', 'smartstack', 'package.json'), 'utf-8', ), ) as { version?: unknown } return typeof pkg.version === 'string' ? pkg.version : null } catch { return null } } /** * Grep node_modules/@atlashub/smartstack/dist/*.{js,mjs,cjs,d.ts} for the * required export markers. BOTH must be present for the probe to pass. */ export function probePackageCapabilities(webRootAbs: string): { found: string[]; missing: string[] } { const distDir = path.join(webRootAbs, 'node_modules', '@atlashub', 'smartstack', 'dist') let entries: string[] = [] try { entries = fs.readdirSync(distDir) } catch { /* package not installed — probe fails, the version floor may still pass */ } const targets = entries.filter((f) => /\.(js|mjs|cjs|d\.ts)$/.test(f)) const found = new Set() for (const f of targets) { let content = '' try { content = fs.readFileSync(path.join(distDir, f), 'utf-8') } catch { continue } for (const marker of REQUIRED_PACKAGE_EXPORTS) { if (content.includes(marker)) found.add(marker) } if (found.size === REQUIRED_PACKAGE_EXPORTS.length) break } return { found: [...found], missing: REQUIRED_PACKAGE_EXPORTS.filter((m) => !found.has(m)), } } export async function validate(raw: unknown): Promise { // ── Gate 1: Zod ──────────────────────────────────────────────────────────── const parsed = ScaffoldPwaInputSchema.safeParse(raw) if (!parsed.success) { return { valid: false, errors: parsed.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`), warnings: [], } } const spec = parsed.data // ── Gate 2: web root ─────────────────────────────────────────────────────── const webRootAbs = await resolveWebRoot(spec) if (!fs.existsSync(webRootAbs)) { return { valid: false, errors: [ `Web root not found: ${webRootAbs} — pass \`webRoot\` explicitly (relative to projectPath).`, ], warnings: [], } } try { assertWebProjectRoot(webRootAbs) } catch (e) { return { valid: false, errors: [e instanceof Error ? e.message : String(e)], warnings: [] } } // ── Gate 3: frontend mode — CLIENT only ─────────────────────────────────── const mode = await detectFrontendMode(webRootAbs) if (mode.mode === 'source') { return { valid: false, errors: [ `${webRootAbs} is the SmartStack.app SOURCE monorepo — the socle manages its own PWA (web/smartstack-web/vite.config.ts) — never scaffold there.`, ], warnings: [], } } if (mode.mode === 'unknown') { return { valid: false, errors: [ `Could not determine the frontend mode of ${webRootAbs} — scaffold-pwa only runs on CLIENT projects (@atlashub/smartstack consumers). Evidence: ${mode.evidence.join(' | ')}`, ], warnings: [], } } // ── Gate 4: package gate (probe wins over version floor) ────────────────── const installedVersion = readInstalledPackageVersion(webRootAbs) const probe = probePackageCapabilities(webRootAbs) const probeOk = probe.missing.length === 0 const versionOk = installedVersion !== null && compareVersions(installedVersion, MIN_SMARTSTACK_PWA_VERSION) >= 0 if (!probeOk && !versionOk) { return { valid: false, errors: [ `@atlashub/smartstack ${installedVersion ?? '(not installed / unreadable)'} does not expose the PWA channel: ` + `missing export(s) ${probe.missing.join(', ')} in node_modules/@atlashub/smartstack/dist. ` + `Upgrade the package to >= ${MIN_SMARTSTACK_PWA_VERSION} (npm install in the web app) and re-run.`, ], warnings: [], packageVersion: installedVersion, } } const warnings: string[] = [] if (probeOk && !versionOk) { warnings.push( `Capability probe found ${REQUIRED_PACKAGE_EXPORTS.join(' + ')} — accepting installed version ` + `${installedVersion ?? '(unreadable)'} below the ${MIN_SMARTSTACK_PWA_VERSION} floor (file:/pre-release install).`, ) } if (!probeOk && versionOk) { warnings.push( `Version floor satisfied (${installedVersion} >= ${MIN_SMARTSTACK_PWA_VERSION}) but the capability probe could not ` + `confirm export(s) ${probe.missing.join(', ')} in the installed dist — verify the install is complete.`, ) } return { valid: true, errors: [], warnings, spec, webRootAbs, packageVersion: installedVersion ?? mode.packageVersion, } }