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).
131 lines
9.3 KiB
TypeScript
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)')
|
|
}
|