doxa-maps-cdn/scripts/laptop.ts
doxa-maps 8770903270 Laptop: the plugin-studio route in one command, bun run laptop plugin-studio
A second compose profile beside "subdomain":
- dx-maps-studio: the dx-maps container with ACCESS=public on research.localhost:4616, the
  PUBLIC Plugin Studio. There is no sign-in provider; publishing and PocketBase's dashboard ask
  for ADMIN_PASSWORD. Its /cdn is built from main with EMBED_BASE_URL=http://research.localhost:4616/cdn.
- dxlife-studio: this site's research-gallery-plugin-studio branch on :4614. Its two settings,
  NUXT_PUBLIC_RESEARCH_GALLERY_URL and NUXT_PUBLIC_DX_MAPS_BASE (names agreed with dx-base), point
  at the Studio and its /cdn, and are read at run time.
The two routes use separate data volumes. .env.laptop gains a generated ADMIN_PASSWORD (never
printed). laptop-dxlife.sh takes DXLIFE_PORT (4613 subdomain, 4614 plugin-studio).
2026-09-29 04:43:02 +00:00

131 lines
9.3 KiB
TypeScript

// bun run laptop <subdomain|plugin-studio|drop-in|down|status> (documentation/LAPTOP-6WAY.md)
// subdomain the dx-maps team container (Plugin Studio, PocketBase, the research site), a local sign-in
// provider and this site, in Docker (compose.laptop.yaml, profile subdomain).
// plugin-studio the PUBLIC Plugin Studio (the dx-maps container with ACCESS=public: no sign-in; publishing and
// PocketBase's dashboard behind ADMIN_PASSWORD) and this site's plugin-studio branch, which
// embeds its production maps from the Studio's /cdn (profile plugin-studio).
// drop-in this site alone, no Docker, no PocketBase: its own copy of the maps (switch to the drop-in branch).
// down stop the subdomain route; its data is kept (add --wipe to delete it too).
// status what is running, and the addresses.
// .env.laptop holds the generated secrets and your read token (GIT_TOKEN). It is never committed and never printed.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { randomBytes } from 'node:crypto'
import { request } from 'node:http'
const ENV = '.env.laptop'
const COMPOSE = ['docker', 'compose', '--env-file', ENV, '-f', 'compose.laptop.yaml']
// Measured on a running route: about 1.4 GB in use (dx-maps 0.3, this site 0.9, sign-in 0.15), plus about 1 GB
// during a publish. The services' limits add up to 5 GB (2560 + 2048 + 384).
const HARD_MB = 3500, COMFORT_MB = 5500
const mode = process.argv[2] ?? 'help'
const wipe = process.argv.includes('--wipe')
const run = (cmd: string[]) => Bun.spawnSync(cmd, { stdout: 'inherit', stderr: 'inherit' }).exitCode ?? 1
const out = (cmd: string[]) => { const r = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' }); return r.exitCode === 0 ? r.stdout.toString().trim() : null }
const say = (s = '') => console.log(s)
const stop = (s: string): never => { console.error(s); process.exit(1) }
// GET http://127.0.0.1:<port><path> with a chosen Host header, so research.localhost works even where the
// system resolver doesn't send *.localhost to this computer.
function status(port: number, path: string, host = `localhost:${port}`): Promise<number> {
return new Promise(resolve => {
const r = request({ host: '127.0.0.1', port, path, headers: { host }, timeout: 5000 }, res => { res.resume(); resolve(res.statusCode ?? 0) })
r.on('error', () => resolve(0)); r.on('timeout', () => { r.destroy(); resolve(0) }); r.end()
})
}
async function waitFor(label: string, port: number, path: string, host: string | undefined, ok: (s: number) => boolean, minutes: number) {
const t0 = Date.now(); let s = 0
while (Date.now() - t0 < minutes * 60_000) {
s = await status(port, path, host)
if (ok(s)) { say(` ✓ ${label} (${Math.round((Date.now() - t0) / 1000)} s)`); return true }
await Bun.sleep(5000)
}
say(` ✗ ${label}: still ${s || 'no answer'} after ${minutes} min. Look at: docker compose -f compose.laptop.yaml logs`)
return false
}
function readEnv(): Record<string, string> {
if (!existsSync(ENV)) return {}
return Object.fromEntries(readFileSync(ENV, 'utf8').split('\n').filter(l => /^[A-Z_]+=/.test(l)).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
}
function ensureEnv() {
const env = readEnv()
const secret = () => randomBytes(24).toString('base64url').slice(0, 32)
const want: Record<string, () => string> = {
OIDC_CLIENT_SECRET: secret, OIDC_COOKIE_SECRET: secret, PB_OIDC_CLIENT_SECRET: secret, ADMIN_PASSWORD: secret,
PB_SUPERUSER_EMAIL: () => 'pb-admin@team.localhost', PB_SUPERUSER_PASSWORD: secret,
GIT_TOKEN: () => process.env.GIT_TOKEN ?? '', DXMAPS_DIR: () => '../doxa-maps'
}
let added = 0
for (const [k, make] of Object.entries(want)) if (!(k in env)) { env[k] = make(); added++ }
if (added) writeFileSync(ENV, Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n') + '\n', { mode: 0o600 })
if (!env.GIT_TOKEN) stop(`${ENV}: GIT_TOKEN is empty. Put a read token for the team's git server there (GIT_TOKEN=...), then run this again.`)
return env
}
async function up(profile: 'subdomain' | 'plugin-studio') {
if (!out(['docker', 'compose', 'version', '--short'])) stop(`Docker with Compose is needed for the ${profile} route. Without Docker, use: bun run laptop drop-in`)
const env = ensureEnv()
const dx = env.DXMAPS_DIR || '../doxa-maps'
if (!existsSync(`${dx}/Dockerfile`)) stop(`${dx}/Dockerfile not found. Clone dx-maps next to this folder (or set DXMAPS_DIR in ${ENV}) and pull development: its Dockerfile is at the root.`)
const dockerMb = Math.round(Number(out(['docker', 'info', '--format', '{{.MemTotal}}']) ?? 0) / 1048576)
if (dockerMb && dockerMb < HARD_MB) {
const running = out(['docker', 'ps', '--format', '{{.Names}}'])?.split('\n').filter(Boolean) ?? []
stop([`Docker has ${dockerMb} MB; the ${profile} route needs at least ${HARD_MB} MB (it uses about 1.4 GB, 2.5 GB while publishing). Any of these makes room:`,
` - raise Docker's memory: Docker Desktop, Settings, Resources, Memory, to 6 GB or more;`,
running.length ? ` - stop containers you aren't using (running now: ${running.join(', ')});` : ` - close other memory-heavy apps;`,
` - or take the lighter route, no Docker and no PocketBase (about 1.5 GB): bun run laptop drop-in`].join('\n'))
}
if (dockerMb && dockerMb < COMFORT_MB) say(`Docker has ${dockerMb} MB: enough, but tight while the container publishes. If a service restarts, give Docker 6 GB.`)
say(`Starting the ${profile} route (Docker has ${dockerMb || '?'} MB). The first start builds and installs: allow 5-10 minutes.`)
if (run([...COMPOSE, '--profile', profile, 'up', '-d', '--build']) !== 0) stop('docker compose up failed (see above).')
say('Waiting for the services:')
if (profile === 'plugin-studio') return studioUp()
const up = [
await waitFor('sign-in provider http://localhost:4620/team', 4620, '/team/.well-known/openid-configuration', undefined, s => s === 200, 5),
await waitFor('Plugin Studio http://localhost:4616/', 4616, '/ping', undefined, s => s === 200, 12),
await waitFor('research site http://research.localhost:4615/ (after the first publish)', 4615, '/', 'research.localhost:4615', s => s === 200, 12),
await waitFor('this site http://localhost:4613/', 4613, '/', undefined, s => s === 200, 12)
]
say()
say(up.every(Boolean) ? 'The subdomain route is up:' : 'Some services are not up yet (see above). The addresses:')
say(' this site (subdomain branch) http://localhost:4613/ Research menu, /regions, /embed-test')
say(' the research site http://research.localhost:4615/ /embed-test too')
say(' Plugin Studio + PocketBase http://localhost:4616/ sign in as admin or designer (any other name is a plain member, refused)')
say('Chrome and Firefox send research.localhost to this computer. Safari may not: add "127.0.0.1 research.localhost" to /etc/hosts.')
say('Stop with: bun run laptop down (data kept; --wipe deletes it)')
}
async function studioUp() {
const up = [
await waitFor('public Plugin Studio http://research.localhost:4616/', 4616, '/', 'research.localhost:4616', s => s === 200, 12),
await waitFor('its /cdn (after the first publish)', 4616, '/cdn/dx-maps/manifest.json', 'research.localhost:4616', s => s === 200, 12),
await waitFor('this site http://localhost:4614/', 4614, '/', undefined, s => s === 200, 12)
]
say()
say(up.every(Boolean) ? 'The plugin-studio route is up:' : 'Some services are not up yet (see above). The addresses:')
say(' this site (plugin-studio branch) http://localhost:4614/ its Research tab opens the Studio; its maps come from the Studio /cdn')
say(' the public Plugin Studio http://research.localhost:4616/ open to everyone, as on the server')
say(' publishing and PocketBase http://research.localhost:4616/admin/status and /pb/_/ user admin, password: ADMIN_PASSWORD in .env.laptop')
say('Chrome and Firefox send research.localhost to this computer. Safari may not: add "127.0.0.1 research.localhost" to /etc/hosts.')
say('Stop with: bun run laptop down (data kept; --wipe deletes it)')
}
async function show() {
run([...COMPOSE, '--profile', 'subdomain', '--profile', 'plugin-studio', 'ps'])
for (const [label, port, path, host] of [['site, subdomain', 4613, '/', undefined], ['site, plugin-studio', 4614, '/', undefined], ['research site', 4615, '/', 'research.localhost:4615'], ['Plugin Studio', 4616, '/', 'research.localhost:4616'], ['sign-in provider', 4620, '/team/.well-known/openid-configuration', undefined]] as const)
say(` ${label.padEnd(16)} ${await status(port, path, host) || 'no answer'}`)
}
switch (mode) {
case 'subdomain': await up('subdomain'); break
case 'plugin-studio': await up('plugin-studio'); break
case 'down': process.exit(run([...COMPOSE, '--profile', 'subdomain', '--profile', 'plugin-studio', 'down', ...(wipe ? ['--volumes'] : [])]))
case 'status': await show(); break
case 'drop-in':
say('The drop-in route needs no Docker: the site carries its own copy of the maps.')
say(' git switch research-gallery-drop-in && PORT=4611 bun run dev then open http://localhost:4611/')
break
default:
say('bun run laptop subdomain | plugin-studio | drop-in | down [--wipe] | status (documentation/LAPTOP-6WAY.md)')
}