#!/usr/bin/env node /** * Read and write files in NextExplorer, and publish the site to the folder Start9 Pages serves. * * node scripts/nextexplorer.mjs [args] (or: npm run nx -- [args]) * * whoami the account, and the locations it can reach, with the path * Start9 Pages needs for each * ls [path] list a folder (no path: the account's locations) * get [local-file] download a file * put upload a file, or a folder's contents, into a folder * (--replace: overwrite names that already exist) * mkdir create a folder, and any missing parents * mv rename in place * rm [--permanent] delete (to NextExplorer's trash, unless --permanent) * deploy [--dry-run] [--no-build] build the site and publish it to NX_DEST * rollback swap NX_DEST with the copy the last deploy kept * * Remote paths start with where the account's access comes from, as `whoami` shows it: * - a location (granted in the account's Volumes tab): Websites/independentproof/index.html * - a folder shared with the account, by the share's label: share:Websites/independentproof/index.html * If several shares carry that label, the read-write one is used. * * SETTINGS come from deploy.local.env (git-ignored) and the environment, the environment winning: * NX_URL NextExplorer address, e.g. https://.local: * NX_USER account email * NX_PASS the password, or NX_PASS_FILE: a file holding only the password * (default ~/.config/nextexplorer/.password, which should be mode 600) * NX_CA_FILE the StartOS server's root CA, so TLS is verified rather than switched off * NX_DEST the folder the site is published to, e.g. share:Websites/independentproof * NX_PAGES_PATH the same folder as Start9 Pages names it (from the storage root, e.g. * Files/Websites/independentproof). Needed for a share, whose location on disk an account * that doesn't own it can't see; worked out automatically for a location. * SITE_URL where Start9 Pages serves the site; printed after a deploy * * WHY DEPLOY SWAPS FOLDERS. NextExplorer never overwrites on upload: a second index.html lands as * "index (1).html". So deploy uploads the whole build into a fresh sibling folder, then renames the * live folder aside to ".previous" and the new one into place. Stale files can't linger, the * live site is never half-updated, and `rollback` can swap the previous copy back. * * API: read from NextExplorer 3.1.0's backend (backend/src/routes). Login is a session cookie from * POST /api/auth/login; uploads are multipart POST /api/upload with `uploadTo` (an existing folder) * and `relativePath` (may include new subfolders) sent before the file part, one file per request. */ import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, join, posix, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const dist = join(root, 'dist'); // ---------------------------------------------------------------------------- settings const localEnv = join(root, 'deploy.local.env'); if (existsSync(localEnv)) { for (const line of readFileSync(localEnv, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2'); } } const env = process.env; const expand = (p) => (p ? p.replace(/^~(?=$|\/)/, homedir()) : p); // Node reads extra CA certificates only at startup, so re-run once with the server's CA added. const caFile = expand(env.NX_CA_FILE); if (caFile && env.NODE_EXTRA_CA_CERTS !== caFile) { if (!existsSync(caFile)) fail(`NX_CA_FILE not found: ${caFile}`); const r = spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit', env: { ...env, NODE_EXTRA_CA_CERTS: caFile }, }); process.exit(r.status ?? 1); } const BASE = (env.NX_URL ?? '').replace(/\/+$/, ''); function fail(msg) { console.error(`error: ${msg}`); process.exit(1); } function password() { if (env.NX_PASS) return env.NX_PASS; const account = (env.NX_USER ?? '').split('@')[0]; const file = expand(env.NX_PASS_FILE) || join(homedir(), '.config/nextexplorer', `${account}.password`); if (!existsSync(file)) { fail(`no password: set NX_PASS, or put it (and nothing else) in ${file} with mode 600`); } if ((statSync(file).mode & 0o077) !== 0) console.warn(`! ${file} is readable by others; chmod 600 it`); return readFileSync(file, 'utf8').replace(/\r?\n$/, ''); } // ---------------------------------------------------------------------------- API let cookie = ''; async function api(method, path, { json, form, raw } = {}) { const headers = { cookie }; let body; if (json !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(json); } else if (form) { body = form; } const res = await fetch(`${BASE}${path}`, { method, headers, body }); if (raw) { if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await errorText(res)}`); return res; } const text = await res.text(); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${errorOf(text)}`); return text ? JSON.parse(text) : null; } const errorOf = (text) => { try { const j = JSON.parse(text); return j.error?.message ?? j.error ?? j.message ?? text; } catch { return text; } }; const errorText = async (res) => errorOf(await res.text()); /** * The session is saved and reused (it lasts 30 days). NextExplorer allows only 10 logins per 15 * minutes per address, so logging in on every command would lock the account out mid-deploy. */ function sessionFile() { const account = (env.NX_USER ?? '').split('@')[0]; return join(homedir(), '.config/nextexplorer', `${account}.session`); } async function login() { if (!BASE) fail('NX_URL is not set (deploy.local.env)'); if (!env.NX_USER) fail('NX_USER is not set (deploy.local.env)'); const saved = sessionFile(); if (existsSync(saved)) { cookie = readFileSync(saved, 'utf8').trim(); const me = await fetch(`${BASE}/api/auth/me`, { headers: { cookie } }); if (me.ok) { const body = await me.json(); const user = body.user ?? body; if (user?.email === env.NX_USER || user?.username === env.NX_USER.split('@')[0]) return user; } cookie = ''; } const res = await fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: env.NX_USER, password: password() }), }); if (!res.ok) fail(`login failed: ${res.status} ${await errorText(res)}`); const cookies = res.headers.getSetCookie(); if (cookies.length === 0) fail('login succeeded but set no session cookie'); cookie = cookies.map((c) => c.split(';')[0]).join('; '); mkdirSync(dirname(saved), { recursive: true, mode: 0o700 }); writeFileSync(saved, cookie, { mode: 0o600 }); return (await res.json()).user; } const clean = (p) => (p ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); /** Folders shared with this account: [{ label, shareToken, accessMode, ... }]. */ let sharesCache; async function sharedWithMe() { sharesCache ??= (await api('GET', '/api/shares/shared-with-me')).shares ?? []; return sharesCache; } /** `share: