122 lines
6.2 KiB
JavaScript
122 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* install-shell.mjs — install the app wrapper as the CDN root's entry point.
|
|
*
|
|
* WHY THIS EXISTS
|
|
* `public/` is the single SERVED SURFACE (the CDN web-root). The bundler writes
|
|
* bundles to `public/<name>/…`, but a web-root also needs an `index.html` at its
|
|
* TOP level, for two independent reasons:
|
|
* 1. Railway/CDN — visiting the site root should land on the app, not a 404 or
|
|
* a directory listing.
|
|
* 2. Capacitor — `cap copy` HARD-FAILS with "The web assets directory (./public)
|
|
* must contain an index.html file" because that file is the app's entry point.
|
|
* (This is exactly how we found the gap.)
|
|
*
|
|
* WHAT IT DOES
|
|
* Copies the chosen wrapper from `layouts/<wrapper>/` to the root of `public/`,
|
|
* so `public/index.html` is the app shell and `public/<name>/…` are the bundles
|
|
* it mounts. One served tree, two roles.
|
|
*
|
|
* WHY COPY RATHER THAN SYMLINK
|
|
* `cap copy` and static hosts follow real files predictably; a symlink into a
|
|
* sibling directory is fragile across Railway's build, Gradle's asset packaging,
|
|
* and `file://`. Copying keeps the served tree self-describing.
|
|
*
|
|
* TOKEN SAFETY
|
|
* `token.local.js` is gitignored and LOCAL-ONLY. It is copied for local staging
|
|
* (so the map works on your laptop) but MUST NOT ship in a release build — the
|
|
* production token comes from `tokenUrl` at runtime. Pass --no-token to omit it.
|
|
*
|
|
* Usage: node internal/install-shell.mjs [wrapper] [--no-token]
|
|
* default wrapper: app-wrapper-material
|
|
*/
|
|
import { cpSync, existsSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { join, resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const args = process.argv.slice(2)
|
|
const noToken = args.includes('--no-token')
|
|
const wrapper = args.find(a => !a.startsWith('--')) || 'app-wrapper-material'
|
|
|
|
const src = join(ROOT, 'layouts', wrapper)
|
|
const dst = join(ROOT, 'public')
|
|
|
|
if (!existsSync(src)) {
|
|
console.error(`[install-shell] FAIL — no such wrapper: layouts/${wrapper}`)
|
|
console.error(`[install-shell] available: ${readdirSync(join(ROOT, 'layouts')).filter(f => !f.endsWith('.md')).join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
if (!existsSync(join(src, 'index.html'))) {
|
|
console.error(`[install-shell] FAIL — layouts/${wrapper}/index.html is missing; a wrapper must have an entry point.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// Copy every wrapper file to the served root, skipping things that must not ship.
|
|
// NOTE: profiles/ and js/ inside the wrapper were dev-time copies of built bundles;
|
|
// the real bundles now live at public/<name>/, so those are skipped to avoid
|
|
// shipping (and silently staling) a second copy.
|
|
// token.local.js is NEVER installed: the shell no longer references it, and a
|
|
// credential in the served web-root is the exact failure this repo kept hitting.
|
|
const SKIP = new Set(['profiles', 'js', 'token.local.js'])
|
|
|
|
for (const entry of readdirSync(src)) {
|
|
if (SKIP.has(entry)) continue
|
|
cpSync(join(src, entry), join(dst, entry), { recursive: true })
|
|
}
|
|
|
|
// ⭐ NESTED LAYOUTS. The wrapper is copied to the ROOT of public/, but it imports
|
|
// sibling layouts by relative path (e.g. `../pane-wrapper/pane-wrapper.js`). From
|
|
// public/index.html that `../` ESCAPES THE WEB ROOT — the module 404s and the whole
|
|
// shell dies with a bare "Failed to resolve module specifier". So every layout the
|
|
// wrapper imports must be copied INTO public/ at the same relative depth.
|
|
// Detected from the source rather than hardcoded, so adding a nested layout needs
|
|
// no edit here.
|
|
const shellSrc = readFileSync(join(src, 'index.html'), 'utf8')
|
|
const nested = [...shellSrc.matchAll(/from\s+['"]\.\.\/([A-Za-z0-9._-]+)\//g)].map(m => m[1])
|
|
for (const layout of new Set(nested)) {
|
|
const from = join(ROOT, 'layouts', layout)
|
|
if (!existsSync(from)) {
|
|
console.error(`[install-shell] FAIL — index.html imports ../${layout}/ but layouts/${layout} does not exist.`)
|
|
process.exit(1)
|
|
}
|
|
cpSync(from, join(dst, layout), { recursive: true })
|
|
console.log(`[install-shell] nested layout: layouts/${layout} → public/${layout}/`)
|
|
}
|
|
|
|
// SUBPATH-PORTABLE imports: `../<layout>/` resolves only at the web-root (../ cannot escape
|
|
// root); served under a subpath (e.g. as a go-base plugin at /plugin-assets/doxa-maps-spa/)
|
|
// it 404s. The layouts are copied as SIBLINGS of index.html, so `./<layout>/` is correct at
|
|
// EVERY depth. Rewrite the copied entry so the shell works both at root and under any subpath.
|
|
{
|
|
let entrySrc = readFileSync(join(dst, 'index.html'), 'utf8')
|
|
for (const layout of new Set(nested)) entrySrc = entrySrc.split(`../${layout}/`).join(`./${layout}/`)
|
|
writeFileSync(join(dst, 'index.html'), entrySrc)
|
|
}
|
|
|
|
// ⭐ The wrapper is copied to the ROOT of public/, but generate-tree then fans the
|
|
// tree out into public/<name>/<bundle>/ — and in an earlier build order those
|
|
// wrapper files got picked up and duplicated INTO every bundle folder. That put
|
|
// SIX copies of token.local.js under public/ (gitignored, so never committed, but
|
|
// they WOULD have shipped inside the APK). Sweep any that exist so a stale layout
|
|
// can never re-introduce them.
|
|
for (const leaked of ['token.local.js', 'nav.js', 'icons.js', 'languages.js']) {
|
|
for (const bundleDir of readdirSync(dst, { withFileTypes: true })) {
|
|
if (!bundleDir.isDirectory() || bundleDir.name === 'assets') continue
|
|
for (const sub of readdirSync(join(dst, bundleDir.name), { withFileTypes: true })) {
|
|
if (!sub.isDirectory()) continue
|
|
const f = join(dst, bundleDir.name, sub.name, leaked)
|
|
if (existsSync(f)) { rmSync(f, { force: true }); console.log(`[install-shell] swept stray ${bundleDir.name}/${sub.name}/${leaked}`) }
|
|
}
|
|
const f2 = join(dst, bundleDir.name, leaked)
|
|
if (existsSync(f2)) { rmSync(f2, { force: true }); console.log(`[install-shell] swept stray ${bundleDir.name}/${leaked}`) }
|
|
}
|
|
}
|
|
|
|
// A stale duplicate bundle tree inside public/ would shadow the real one.
|
|
for (const stale of ['profiles', 'js']) {
|
|
const p = join(dst, stale)
|
|
if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); console.log(`[install-shell] removed stale public/${stale}/`) }
|
|
}
|
|
|
|
console.log(`[install-shell] ${wrapper} → public/ (entry: public/index.html${noToken ? ', token omitted' : ''})`)
|