Files
apxtri/models/Wwws.js
T
2026-10-12 13:51:47 +00:00

1373 lines
53 KiB
JavaScript
Executable File

const fs = require("fs-extra");
const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process");
const { minify: minifyHTML } = require("html-minifier-terser");
const { minify: minifyJS } = require("terser");
const { glob } = require("glob");
const Odmdb = require("./Odmdb.js");
const logger = require("../utils/logger.js");
const { getWebappSource, listWebappNames } = require("../utils/webapps.js");
const DATA_PATH = process.env.DATAPATH;
const NODE_PATH = process.env.NODEPATH;
class Wwws {
static async _setupCdnFiles(tribe) {
const linkConfigPath = path.join(DATA_PATH, tribe, 'objects', 'wwws', 'cdn', 'lib', 'libsource.json');
try {
if (await fs.pathExists(linkConfigPath)) {
logger.debug(`Found CDN library config at ${linkConfigPath}`);
const links = await fs.readJson(linkConfigPath);
if (!Array.isArray(links)) {
logger.warn(`libsource.json is not an array. Skipping CDN setup.`);
return;
}
const projectRoot = path.join(NODE_PATH, 'apxtri');
const cdnLibPath = path.join(DATA_PATH, tribe, 'objects', 'wwws', 'cdn', 'lib');
for (const link of links) {
if (link.pathsrc && link.pathdest) {
const sourcePath = path.join(projectRoot, link.pathsrc);
const destPath = path.join(cdnLibPath, link.pathdest);
if (await fs.pathExists(sourcePath)) {
await fs.remove(destPath);
await fs.copy(sourcePath, destPath);
logger.debug(`Copied CDN asset: ${destPath} <- ${sourcePath}`);
} else {
logger.warn(`Source path for CDN asset does not exist, skipping: ${sourcePath}`);
}
}
}
}
} catch (error) {
logger.error(`Failed during CDN file setup for tribe ${tribe}:`, error);
}
}
/**
* Get cache file path for localdb (versioned filename)
*/
static _getCachePath(tribe, appname, subappName, coreVersion, subappVersion, lg) {
const tmpDir = path.join(DATA_PATH, tribe, 'tmp', 'localdb');
return path.join(tmpDir, `core${coreVersion}-${subappName}${subappVersion}_${lg}.json`);
}
/**
* Read a single option document of a tribe (e.g. 'profil', 'conditiontojoin').
* Resolves the same on-disk layout as the localdb loader: {DATAPATH}/{tribe}/
* objects/options/{optionid}.json (or itm/, or the _lg language variant).
* @param {string} tribe - Tribe identifier
* @param {string} optionid - Option identifier (optionid)
* @param {string} [lg] - Language code (en/fr/...), default en
* @returns {Promise<object>} {status, ref, msg, data}
*/
static async readOption(tribe, optionid, lg) {
const name = String(optionid || "").replace(/[^a-zA-Z0-9_-]/g, "");
if (!tribe || !name) {
return { status: 400, ref: "Wwws", msg: "option_invalid_params", data: { tribe, optionid } };
}
const lgCode = String(lg || "en").replace(/[^a-zA-Z-]/g, "").slice(0, 5);
const dir = path.join(DATA_PATH, tribe, "objects", "options");
const tryPaths = [
path.join(dir, `${name}.json`),
path.join(dir, "itm", `${name}.json`),
path.join(dir, `${name}_${lgCode}.json`),
path.join(dir, "itm", `${name}_${lgCode}.json`),
path.join(dir, `${name}_en.json`),
path.join(dir, "itm", `${name}_en.json`),
];
for (const p of tryPaths) {
if (await fs.pathExists(p)) {
try {
return { status: 200, ref: "Wwws", msg: "option_found", data: await fs.readJson(p) };
} catch (e) {
return { status: 500, ref: "Wwws", msg: "option_read_failed", data: { tribe, optionid, error: e.message } };
}
}
}
return { status: 404, ref: "Wwws", msg: "option_not_found", data: { tribe, optionid } };
}
/**
* Delete old cache files for a subapp (cleanup)
*/
static async _cleanupOldCache(tribe, appname, subappName, keepCoreVer, keepSubappVer, lg) {
const tmpDir = path.join(DATA_PATH, tribe, 'tmp', 'localdb');
if (!await fs.pathExists(tmpDir)) return;
const files = await fs.readdir(tmpDir);
const prefix = `${appname}-`;
for (const file of files) {
if (!file.endsWith(`_${lg}.json`)) continue;
// Match: core{ver}-{subapp}{ver}_{lg}.json
const match = file.match(/^core(\d+)-(\w+)(\d+)_/);
if (!match) continue;
const [, fileCoreVer, fileSubapp, fileSubVer] = match;
if (fileSubapp === subappName && (fileCoreVer !== String(keepCoreVer) || fileSubVer !== String(keepSubappVer))) {
await fs.remove(path.join(tmpDir, file));
logger.debug(`Cleaned old cache: ${file}`);
}
}
}
/**
* Save versioned cache
*/
static async _saveCache(tribe, appname, subappName, coreVersion, subappVersion, lg, data) {
const cachePath = this._getCachePath(tribe, appname, subappName, coreVersion, subappVersion, lg);
await fs.ensureDir(path.dirname(cachePath));
await fs.writeJson(cachePath, { ts: Date.now(), data });
logger.debug(`Saved localdb cache: ${cachePath}`);
// Cleanup old versions
await this._cleanupOldCache(tribe, appname, subappName, coreVersion, subappVersion, lg);
}
/**
* Build local data object from core + subapp config
*/
static async _buildLocalData(tribe, appname, subappName, conf, lg) {
const coreConf = conf.core || {};
const subappConf = conf.subapp?.[subappName];
const localData = {
coreVersion: coreConf.version || 0,
subappVersion: subappConf?.version || 0,
town: process.env.TOWN,
nation: process.env.NATION,
headers: { xlang: lg },
core: { schema: {}, options: {}, ref: {} },
subapp: { schema: {}, options: {}, ref: {} }
};
const promises = [];
const TOWN = process.env.TOWN || 'apxtri';
const loadResource = (collection, section, key, basePath, rootPath, extension) => {
promises.push((async () => {
const dir = path.dirname(basePath.replace(/\$town\b/g, TOWN));
const name = path.basename(basePath);
const tryPaths = [
path.join(rootPath, dir, name),
path.join(rootPath, dir, 'itm', name),
path.join(rootPath, dir, name + `_${lg}`),
path.join(rootPath, dir, 'itm', name + `_${lg}`),
];
let fullPath = null;
for (const p of tryPaths) {
const withExt = extension ? p + extension : p;
if (await fs.pathExists(withExt)) { fullPath = withExt; break; }
}
try {
if (fullPath) {
logger.debug(`Loading ${section} resource: ${fullPath}`);
localData[section][collection][key] = extension === '.json'
? await fs.readJson(fullPath)
: await fs.readFile(fullPath, 'utf-8');
} else {
logger.warn(`Resource not found: ${path.join(rootPath, dir, name)} (tried itm/ and _${lg} variants)`);
}
} catch (error) {
logger.error(`Failed to load resource ${fullPath}: ${error.message}`);
}
})());
};
const loadSection = (section, config) => {
if (!config) return;
if (config.schema) {
for (const schemaPath of config.schema) {
promises.push((async () => {
const objname = path.basename(schemaPath, '.json');
const odmdb = new Odmdb(objname, tribe);
if (await odmdb._loadSchema()) {
localData[section].schema[objname] = odmdb.schema;
}
})());
}
}
if (config.options) {
for (const key in config.options) {
loadResource('options', section, key, config.options[key], DATA_PATH, '.json');
}
}
if (config.ref) {
for (const key in config.ref) {
loadResource('ref', section, key, config.ref[key], NODE_PATH, '.json');
}
}
if (config.itms) {
for (const key in config.itms) {
loadResource('itms', section, key, config.itms[key], DATA_PATH, '.json');
}
}
if (config.tpldata) {
for (const key in config.tpldata) {
loadResource('tpldata', section, key, config.tpldata[key], DATA_PATH, '.json');
}
}
if (config.screens) {
for (const key in config.screens) {
loadResource('screens', section, key, config.screens[key], DATA_PATH, '.json');
}
}
if (config.tpl) {
for (const key in config.tpl) {
loadResource('tpl', section, key, config.tpl[key], DATA_PATH, '.mustache');
}
}
};
loadSection('core', coreConf);
loadSection('subapp', subappConf);
await Promise.all(promises);
return localData;
}
/**
* Collect all referenced file paths from core + subapp config
*/
static _getReferencedFiles(tribe, appname, conf) {
const files = [];
const add = (basePath, rootPath) => {
const full = path.join(rootPath, basePath);
files.push(full);
const lgVariants = ['en', 'fr', 'de', 'it', 'es', 'nl'];
for (const lg of lgVariants) {
const ext = path.extname(full);
const base = full.slice(0, -ext.length);
files.push(`${base}_${lg}${ext}`);
}
};
const walk = (section) => {
if (!section) return;
if (section.schema) {
for (const s of section.schema) {
const objname = path.basename(s, '.json');
// Simulate Odmdb schema resolution
const possible = [
path.join(NODE_PATH, tribe, 'schema', `${objname}.json`),
path.join(NODE_PATH, 'apxtri', 'schema', `${objname}.json`),
path.join(NODE_PATH, `${objname}.json`),
path.join(process.env.NODEPATH || NODE_PATH, 'schema', `${objname}.json`),
];
for (const p of possible) files.push(p);
}
}
if (section.options) for (const k in section.options) add(section.options[k], DATA_PATH);
if (section.ref) for (const k in section.ref) add(section.ref[k], NODE_PATH);
if (section.itms) for (const k in section.itms) add(section.itms[k], DATA_PATH);
if (section.tpldata) for (const k in section.tpldata) add(section.tpldata[k], DATA_PATH);
if (section.screens) for (const k in section.screens) add(section.screens[k], DATA_PATH);
if (section.tpl) for (const k in section.tpl) add(section.tpl[k], DATA_PATH);
};
const appconfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
files.push(appconfPath);
walk(conf.core);
if (conf.subapp) for (const name in conf.subapp) walk(conf.subapp[name]);
return files;
}
/**
* Detect file changes by comparing mtime snapshots.
* Returns { changed: boolean, section: 'core'|'subapp.{name}'|null }
*/
static async detectChanges(tribe, appname) {
const statePath = path.join(DATA_PATH, tribe, 'tmp', 'localdb', 'file_versions.json');
const oldState = await fs.readJson(statePath).catch(() => ({}));
const newState = {};
let anythingChanged = false;
const codeConfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
let conf;
try { conf = await fs.readJson(codeConfPath); } catch { return { changed: false }; }
const files = this._getReferencedFiles(tribe, appname, conf);
for (const f of files) {
try {
const stat = await fs.stat(f);
newState[f] = stat.mtimeMs;
} catch { /* file may not exist */ }
}
for (const f in newState) {
if (oldState[f] !== newState[f]) { anythingChanged = true; break; }
}
// Also check for deleted files
for (const f in oldState) {
if (!(f in newState)) { anythingChanged = true; break; }
}
await fs.ensureDir(path.dirname(statePath));
await fs.writeJson(statePath, newState);
return { changed: anythingChanged };
}
/**
* Increment version in appconf.json (core or subapp.{name})
*/
static async incrementVersion(tribe, appname, section) {
const codeConfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
if (!await fs.pathExists(codeConfPath)) return null;
try {
const conf = await fs.readJson(codeConfPath);
if (section === 'core') {
conf.core = conf.core || {};
conf.core.version = (conf.core.version || 0) + 1;
} else if (section && conf.subapp?.[section]) {
conf.subapp[section].version = (conf.subapp[section].version || 0) + 1;
}
await fs.writeJson(codeConfPath, conf, { spaces: 2 });
const newVer = section === 'core' ? conf.core.version : conf.subapp?.[section]?.version;
logger.info(`Version incremented: ${appname}/${section} → v${newVer}`);
return newVer;
} catch (e) {
logger.error(`Failed to increment version: ${e.message}`);
return null;
}
}
static async initlocaldata(tribe, appname, subappName, coreVersion, subappVersion, profils, lg, xmode) {
await this._setupCdnFiles(tribe);
logger.debug(`initlocaldata: tribe=${tribe} app=${appname} subapp=${subappName} coreVer=${coreVersion} subappVer=${subappVersion} mode=${xmode||'prod'}`);
// Read appconf.json
const codeConfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
if (!await fs.pathExists(codeConfPath)) {
return { status: 404, ref: "Wwws", msg: "app_config_not_found" };
}
let conf;
try {
conf = await fs.readJson(codeConfPath);
} catch (e) {
return { status: 500, ref: "Wwws", msg: "app_config_invalid" };
}
const coreConf = conf.core || {};
const subappConf = conf.subapp?.[subappName];
if (!subappConf) {
logger.warn(`Subapp config not found: ${subappName} in app: ${appname}`);
return { status: 404, ref: "Wwws", msg: "page_config_not_found" };
}
// DEV MODE: always rebuild, ignore cache and version check
if (xmode === 'dev') {
logger.debug(`Dev mode: rebuilding ${subappName} from source`);
const localData = await this._buildLocalData(tribe, appname, subappName, conf, lg);
return { status: 200, ref: "Wwws", msg: "data_model_update", data: localData };
}
// Check profile access BEFORE the version short-circuit: a client already
// holding the current versions must still be denied when its profile does
// not match the app/subapp gate, otherwise "no_new_data_model" would leak
// access to an unauthorized page.
const allowedProfils = [...(coreConf.profils || []), ...(subappConf.profils || [])];
if (allowedProfils.length > 0 && !profils.some(p => allowedProfils.includes(p))) {
return { status: 403, ref: "Wwws", msg: "permission_denied_for_page" };
}
// Check if versions match
if (coreConf.version == coreVersion && subappConf.version == subappVersion) {
logger.debug(`Versions up to date for ${subappName}: core=${coreVersion} subapp=${subappVersion}`);
return { status: 200, ref: "Wwws", msg: "no_new_data_model" };
}
// Check versioned cache file
const cachePath = this._getCachePath(tribe, appname, subappName, coreConf.version, subappConf.version, lg);
if (await fs.pathExists(cachePath)) {
try {
const cached = await fs.readJson(cachePath);
logger.debug(`Cache hit: ${cachePath}`);
return { status: 200, ref: "Wwws", msg: "data_model_update", data: cached.data };
} catch (e) {
logger.warn(`Cache read failed, rebuilding: ${cachePath}`);
}
}
// Build data
const localData = await this._buildLocalData(tribe, appname, subappName, conf, lg);
// Save cache
await this._saveCache(tribe, appname, subappName, coreConf.version, subappConf.version, lg, localData);
logger.info(`LocalDB data built for ${subappName}: core=${coreConf.version} subapp=${subappConf.version}`);
return { status: 200, ref: "Wwws", msg: "data_model_update", data: localData };
}
/**
* Cleanup old tmp files in localdb directory
*/
static async cleanupOldCaches(tribe) {
const tmpDir = path.join(DATA_PATH, tribe, 'tmp', 'localdb');
if (!await fs.pathExists(tmpDir)) return;
const files = await fs.readdir(tmpDir);
const now = Date.now();
let removed = 0;
for (const file of files) {
if (file === 'file_versions.json') continue;
const fpath = path.join(tmpDir, file);
try {
const stat = await fs.stat(fpath);
if (now - stat.mtimeMs > 7 * 24 * 60 * 60 * 1000) {
await fs.remove(fpath);
removed++;
}
} catch {}
}
if (removed > 0) logger.info(`Cleaned ${removed} old localdb cache(s) for tribe ${tribe}`);
return removed;
}
static async createSubapp(tribe, app, subapp) {
const np = process.env.NODEPATH;
if (!/^[a-z0-9-]+$/.test(subapp)) {
return { status: 400, ref: "Wwws", msg: "invalid_subapp_name", data: { name: subapp } };
}
const srcPath = path.join(getWebappSource(tribe, app), subapp);
if (await fs.pathExists(srcPath)) {
return { status: 200, ref: "Wwws", msg: "subapp_already_exists", data: { name: subapp } };
}
const appDir = getWebappSource(tribe, app);
if (!await fs.pathExists(appDir)) {
return { status: 404, ref: "Wwws", msg: "webapp_not_found", data: { app } };
}
const skeletonPath = path.join(np, 'apxtri', 'data', 'wwws', 'newwebapp', 'bdc');
if (!await fs.pathExists(skeletonPath)) {
return { status: 500, ref: "Wwws", msg: "skeleton_not_found", data: { skeletonPath } };
}
await fs.copy(skeletonPath, srcPath);
const placeholderVars = { WEBAPPNAME: app, TRIBE: tribe };
const templateFiles = await glob('**/*.{html,json,js}', { cwd: srcPath, nodir: true });
for (const file of templateFiles) {
const filePath = path.join(srcPath, file);
try {
let content = await fs.readFile(filePath, 'utf8');
let modified = false;
for (const [key, val] of Object.entries(placeholderVars)) {
const regex = new RegExp('\\{\\{' + key + '\\}\\}', 'g');
if (regex.test(content)) {
content = content.replace(regex, val);
modified = true;
}
}
if (modified) {
await fs.writeFile(filePath, content, 'utf8');
}
} catch (e) {
logger.warn(`Skipping placeholder replacement for ${file}: ${e.message}`);
}
}
return {
status: 201,
ref: "Wwws",
msg: "subapp_created",
data: { name: subapp }
};
}
static async saveStyle(tribe, app, cssContent) {
const np = process.env.NODEPATH;
const appDir = getWebappSource(tribe, app);
if (!await fs.pathExists(appDir)) {
return { status: 404, ref: "Wwws", msg: "webapp_not_found", data: { app } };
}
// Save webapp-level style.css (remove first to avoid cross-user ownership issues)
const webappStylePath = path.join(appDir, 'style.css');
if (await fs.pathExists(webappStylePath)) {
await fs.remove(webappStylePath);
}
await fs.writeFile(webappStylePath, cssContent, 'utf8');
// Copy to all subapp css/style.css
const updated = [];
const entries = await fs.readdir(appDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const subappCssPath = path.join(appDir, entry.name, 'css', 'style.css');
if (await fs.pathExists(path.dirname(subappCssPath))) {
if (await fs.pathExists(subappCssPath)) {
await fs.remove(subappCssPath);
}
await fs.writeFile(subappCssPath, cssContent, 'utf8');
updated.push(entry.name);
}
}
// Rebuild all subapps via buildpwa
const buildResult = await this.buildpwa(tribe, app);
return {
status: 200,
ref: "Wwws",
msg: "style_saved",
data: { updated, buildStatus: buildResult.status }
};
}
static async renameSubapp(tribe, app, oldSubapp, newSubapp) {
const np = process.env.NODEPATH;
const dp = process.env.DATAPATH;
const confPath = path.join(getWebappSource(tribe, app), 'appconf.json');
if (!await fs.pathExists(confPath)) {
return { status: 404, ref: "Wwws", msg: "app_config_not_found", data: { app } };
}
let conf;
try {
conf = await fs.readJson(confPath);
} catch (e) {
return { status: 500, ref: "Wwws", msg: "app_config_invalid", data: { app } };
}
if (!conf.subapp || !conf.subapp[oldSubapp]) {
return { status: 404, ref: "Wwws", msg: "subapp_rename_not_found", data: { old: oldSubapp } };
}
if (conf.subapp[newSubapp]) {
return { status: 409, ref: "Wwws", msg: "subapp_rename_exists", data: { name: newSubapp } };
}
if (!/^[a-z0-9-]+$/.test(newSubapp)) {
return { status: 400, ref: "Wwws", msg: "invalid_subapp_name", data: { name: newSubapp } };
}
// Rename source directory
const srcOld = path.join(getWebappSource(tribe, app), oldSubapp);
const srcNew = path.join(getWebappSource(tribe, app), newSubapp);
if (await fs.pathExists(srcOld)) {
await fs.move(srcOld, srcNew);
}
// Rename dist directory if it exists
const distOld = path.join(dp, tribe, 'wwws', app, oldSubapp);
const distNew = path.join(dp, tribe, 'wwws', app, newSubapp);
if (await fs.pathExists(distOld)) {
await fs.move(distOld, distNew);
}
// Update appconf key and increment version
conf.subapp[newSubapp] = conf.subapp[oldSubapp];
delete conf.subapp[oldSubapp];
conf.subapp[newSubapp].version = (conf.subapp[newSubapp].version || 0) + 1;
await fs.writeJson(confPath, conf, { spaces: 2 });
// Clean old localdb caches matching the old subapp name
const tmpDir = path.join(dp, tribe, 'tmp', 'localdb');
if (await fs.pathExists(tmpDir)) {
const files = await fs.readdir(tmpDir);
for (const file of files) {
if (file.includes(`-${oldSubapp}`)) {
await fs.remove(path.join(tmpDir, file));
logger.debug(`Cleaned old cache: ${file}`);
}
}
}
return {
status: 200,
ref: "Wwws",
msg: "subapp_renamed",
data: { old: oldSubapp, new: newSubapp, version: conf.subapp[newSubapp].version }
};
}
static async deleteSubapp(tribe, app, subapp) {
const np = process.env.NODEPATH;
const dp = process.env.DATAPATH;
const confPath = path.join(getWebappSource(tribe, app), 'appconf.json');
if (!await fs.pathExists(confPath)) {
return { status: 404, ref: "Wwws", msg: "app_config_not_found", data: { app } };
}
let conf;
try {
conf = await fs.readJson(confPath);
} catch (e) {
return { status: 500, ref: "Wwws", msg: "app_config_invalid", data: { app } };
}
if (!conf.subapp || !conf.subapp[subapp]) {
return { status: 404, ref: "Wwws", msg: "subapp_delete_not_found", data: { name: subapp } };
}
// Remove source directory
const srcPath = path.join(getWebappSource(tribe, app), subapp);
if (await fs.pathExists(srcPath)) {
await fs.remove(srcPath);
logger.debug(`Deleted subapp source: ${srcPath}`);
}
// Remove dist directory if it exists
const distPath = path.join(dp, tribe, 'wwws', app, subapp);
if (await fs.pathExists(distPath)) {
await fs.remove(distPath);
logger.debug(`Deleted subapp dist: ${distPath}`);
}
// Remove from appconf
delete conf.subapp[subapp];
await fs.writeJson(confPath, conf, { spaces: 2 });
// Clean localdb caches
const tmpDir = path.join(dp, tribe, 'tmp', 'localdb');
if (await fs.pathExists(tmpDir)) {
const files = await fs.readdir(tmpDir);
for (const file of files) {
if (file.includes(`-${subapp}`)) {
await fs.remove(path.join(tmpDir, file));
logger.debug(`Cleaned old cache: ${file}`);
}
}
}
return {
status: 200,
ref: "Wwws",
msg: "subapp_deleted",
data: { name: subapp }
};
}
/** Cached uid/gid -> name maps, so error messages can say "phil:phil" not "1001:1001". */
static _userNameCache = null;
static _initUserNameCache() {
if (Wwws._userNameCache) return Wwws._userNameCache;
// Keys are STRINGIFIED: stat() hands back numeric uid/gid, and Map.get(997) does
// not match a "997" key — the fallback would silently print raw ids forever.
const read = (file, sep, idx) => {
const map = new Map();
try {
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim() || line.startsWith('#')) continue;
const parts = line.split(sep);
if (parts.length > idx) map.set(String(parts[idx]), parts[0]);
}
} catch (e) { /* no passwd/group file — fall back to numeric ids */ }
return map;
};
Wwws._userNameCache = { users: read('/etc/passwd', ':', 2), groups: read('/etc/group', ':', 2) };
return Wwws._userNameCache;
}
static _userName(id) {
const { users } = Wwws._initUserNameCache();
return users.get(String(id)) || String(id);
}
static _groupName(id) {
const { groups } = Wwws._initUserNameCache();
return groups.get(String(id)) || String(id);
}
static _ownerOf(p) {
try {
const st = fs.statSync(p);
return `${Wwws._userName(st.uid)}:${Wwws._groupName(st.gid)}`;
} catch (e) {
return null;
}
}
/**
* Pre-flight access check on a directory tree, run before the build writes anything.
*
* WHY THIS EXISTS — the build must run as the runtime user (apxtri, the owner of
* {DATAPATH}) while the sources under {NODEPATH} are written by the dev user,
* opencode and `git pull`. Two failure modes follow, and neither is self-explanatory:
*
* - write: fs.copy({overwrite:true}) unlinks the destination before writing, so it
* needs W_OK on the destination *directory*, not on the file. A directory created
* by the dev user is owned by that user, and setup/grant-dev.sh only ever adds a
* per-user ACL entry (`u:{user}:rwx`) — apxtri is not in it. With no setgid bit and
* no `group:apxtri` entry on the tree, apxtri falls back to `other::r-x` (read
* only) and the build dies with a bare EACCES on an `unlink`, halfway through the
* copy, leaving a half-provisioned dist.
* - read: the build reads the sources under {NODEPATH}, so a source file the
* runtime user cannot read breaks the build with no self-explanatory error.
*
* mode 'w' tests W_OK|X_OK on directories (what copy/symlink/unlink need);
* mode 'r' tests R_OK|X_OK on directories and R_OK on files (what the build reads need).
* Symlinks are skipped: the target's permissions govern, and following `css/`
* would walk out of the tree being checked.
*/
static async _checkTreeAccess(root, mode) {
const failures = [];
const cap = 10;
let count = 0;
if (!root || !await fs.pathExists(root)) return { ok: true, count: 0, failures: [] };
const dirFlag = mode === 'w' ? (fs.constants.W_OK | fs.constants.X_OK) : (fs.constants.R_OK | fs.constants.X_OK);
const record = async (p, err) => {
count++;
if (failures.length < cap) failures.push({ path: p, owner: Wwws._ownerOf(p), error: (err && err.code) || String(err) });
};
const walk = async (dir) => {
try {
await fs.access(dir, dirFlag);
} catch (e) {
await record(dir, e);
return; // unreadable dir: stop, its content is already reported
}
let entries = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (e) {
if (mode === 'r') await record(dir, e);
return;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else if (mode === 'r') {
try {
await fs.access(full, fs.constants.R_OK);
} catch (e) {
await record(full, e);
}
}
// mode 'w': the file itself is never the problem — the containing directory is.
}
};
await walk(root);
return { ok: count === 0, count, failures };
}
/**
* Resolve the uid that must own everything the build produces. {DATAPATH} itself is
* created and owned by the runtime user (apxtri), so it is the authoritative reference.
*/
static _runtimeUid() {
try {
return fs.statSync(process.env.DATAPATH).uid;
} catch (e) {
return null;
}
}
/** Username owning {DATAPATH} — the account that must own everything the build writes. */
static _runtimeUser() {
const uid = Wwws._runtimeUid();
return uid === null ? null : Wwws._userName(uid);
}
/**
* Pre-flight gate for buildpwa: fails with a structured error, before the first write,
* when the source is not readable by the runtime user or a destination is not writable
* by whoever runs the build. See _checkTreeAccess for the two failure modes.
*/
static async _preflight({ srcBase, cdnCssRoot, servedCdn, distBase, tribeId, appName }) {
const buildCmd = `sudo -u apxtri env PATH="$NODE_BIN:$PATH" "$NODE_BIN/node" models/Wwws.js ${tribeId} ${appName} pwa`;
// 1. Wrong user — the build would create files the runtime user cannot later replace.
// Cheapest and most important check: everything below is evaluated against the
// uid of the *current* process, so a build run as phil would report "all good".
const runtimeUid = Wwws._runtimeUid();
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
if (runtimeUid !== null && currentUid !== null && currentUid !== runtimeUid) {
const as = Wwws._userName(currentUid);
const expected = Wwws._userName(runtimeUid);
logger.error(`buildpwa running as ${as} (uid ${currentUid}) but ${process.env.DATAPATH} is owned by ${expected} (uid ${runtimeUid})`);
return {
status: 500,
ref: "Wwws",
msg: "build_wrong_user",
data: {
root: process.env.DATAPATH,
paths: [process.env.DATAPATH],
count: 1,
owner: `${as}:${as}`,
expectedUser: expected,
buildCmd,
chown: ''
}
};
}
// 2. Read: the build reads the sources, so unreadable files break the build
// with no self-explanatory error.
const readable = await Wwws._checkTreeAccess(srcBase, 'r');
if (!readable.ok) {
return {
status: 500,
ref: "Wwws",
msg: "source_not_readable",
data: {
root: srcBase,
paths: readable.failures.map(f => f.path),
count: readable.count,
owner: readable.failures[0].owner,
chown: `sudo chown -R ${Wwws._runtimeUser()} ${srcBase}`,
buildCmd
}
};
}
// 3. Write: every directory the build will copy into, symlink or replace.
for (const target of [cdnCssRoot, servedCdn, distBase]) {
if (!target) continue;
// A destination that does not exist yet is created by ensureDir, so its existing
// parent is what must be writable — check the parent instead of passing vacuously.
const root = await fs.pathExists(target) ? target : path.dirname(target);
const writable = await Wwws._checkTreeAccess(root, 'w');
if (writable.ok) continue;
logger.error(`buildpwa cannot write in ${root}: ${writable.count} path(s) denied`);
return {
status: 500,
ref: "Wwws",
msg: "dist_not_writable",
data: {
root,
paths: writable.failures.map(f => f.path),
count: writable.count,
owner: writable.failures[0].owner,
chown: `sudo chown -R ${Wwws._runtimeUser()} ${root}`,
buildCmd
}
};
}
return null;
}
/**
* Provision the served CDN for a tribe: {DATAPATH}/{tribe}/wwws/cdn.
*
* A tribe's CDN is the union of two sources:
* - the core cdn (apxtri/cdn): framework libs (lib/apxtri/*), the base css,
* and the Inter-*.ttf fonts referenced by the compiled base.css;
* - the tribe's own `wwws/cdn` in its git repo (brand fonts/logos, its lib
* subset, shared/, static/, conf.json).
* setup/init.sh only provisions the TOWN cdn and Tribes.pull() only runs git,
* so a tribe deployed via git otherwise serves a cdn missing its lib/fonts and
* every /cdn/{tribe}/lib/* 404's. Core is copied first, the tribe overlay wins
* on name clashes; the css/ dir stays a symlink to the core (compiled here).
*/
static async _provisionTribeCdn(tribeId) {
const np = process.env.NODEPATH;
const dp = process.env.DATAPATH;
if (!np || !dp) return;
// The town tribe's cdn is provisioned by setup/init.sh (and its lib refreshed
// by setup/update.sh) and has no code directory of its own — nothing to do here.
if (tribeId === process.env.TOWN) return;
const coreCdn = path.join(np, 'apxtri', 'cdn');
const tribeCdnSrc = path.join(np, tribeId, 'wwws', 'cdn');
const servedCdn = path.join(dp, tribeId, 'wwws', 'cdn');
await fs.ensureDir(servedCdn);
// css/ → symlink to the core (buildpwa compiles into apxtri/cdn/css).
const cssSrc = path.join(coreCdn, 'css');
const cssDest = path.join(servedCdn, 'css');
if (await fs.pathExists(cssSrc)) {
try {
const st = await fs.lstat(cssDest);
if (!st.isSymbolicLink() || (await fs.realpath(cssDest)) !== await fs.realpath(cssSrc)) {
await fs.remove(cssDest);
await fs.symlink(cssSrc, cssDest);
logger.info(`CDN css symlink: ${cssDest} → ${cssSrc}`);
}
} catch (e) {
await fs.symlink(cssSrc, cssDest);
logger.info(`CDN css symlink created: ${cssDest} → ${cssSrc}`);
}
}
// Core base (lib + fonts + static): re-copied on every build so the framework
// assets stay present AND up to date with the core. The tribe overlay below is
// applied after, so a tribe's own version of a file always wins.
for (const name of ['lib', 'fonts', 'static']) {
const coreDir = path.join(coreCdn, name);
if (await fs.pathExists(coreDir)) {
await fs.copy(coreDir, path.join(servedCdn, name), { overwrite: true });
}
}
// Tribe overlay — the tribe's own cdn assets (brand fonts/logos, its lib
// subset, shared/, static/, conf.json) win on name clashes.
if (await fs.pathExists(tribeCdnSrc)) {
for (const name of await fs.readdir(tribeCdnSrc)) {
if (name === 'css') continue;
await fs.copy(path.join(tribeCdnSrc, name), path.join(servedCdn, name), { overwrite: true });
}
}
// Rewrite /cdn/dev/ → /cdn/{tribe}/ in the served lib (bip39.js references it).
if (tribeId !== 'dev') {
const servedLib = path.join(servedCdn, 'lib');
for (const rel of await glob('**/*.{js,json}', { cwd: servedLib, nodir: true })) {
const f = path.join(servedLib, rel);
let content = await fs.readFile(f, 'utf8');
if (content.includes('/cdn/dev/')) {
content = content.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
await fs.writeFile(f, content);
}
}
}
}
static async buildpwa(tribeId, appName, subappName) {
const np = process.env.NODEPATH;
const dp = process.env.DATAPATH;
if (!np || !dp) {
return { status: 500, ref: "Wwws", msg: "env_not_set", data: { NODEPATH: np, DATAPATH: dp } };
}
const srcBase = getWebappSource(tribeId, appName);
const distBase = path.join(dp, tribeId, 'wwws', appName);
logger.info(`Building PWA for tribe=${tribeId} app=${appName}${subappName ? ' subapp=' + subappName : ''}`);
logger.info(` Source: ${srcBase}`);
logger.info(` Dist: ${distBase}`);
if (!await fs.pathExists(srcBase)) {
return { status: 404, ref: "Wwws", msg: "source_not_found", data: { srcBase } };
}
const subapps = [];
const entries = await fs.readdir(srcBase, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (subappName && entry.name !== subappName) continue;
const subSrc = path.join(srcBase, entry.name);
if (await fs.pathExists(path.join(subSrc, 'manifest.json'))) {
subapps.push({ name: entry.name, src: subSrc, dist: path.join(distBase, entry.name) });
}
}
}
if (subapps.length === 0) {
logger.warn(`No subapp with manifest.json found in ${srcBase}`);
return { status: 200, ref: "Wwws", msg: "no_pwa_subapp_found", data: {} };
}
const results = [];
// 0. Shared CDN base CSS (webapp themes + fonts) — compiled once when present
const cdnCssRoot = path.join(np, 'apxtri', 'cdn', 'css');
const baseSrc = path.join(cdnCssRoot, 'base.src.css');
const baseDist = path.join(cdnCssRoot, 'base.css');
const useCdnCss = await fs.pathExists(cdnCssRoot);
// 0bis. Access pre-flight — must pass BEFORE any write. Every check below is
// evaluated against the uid of the current process, so a build run as the dev user
// would copy phil-owned files into the served tree and lock out the runtime user on
// the NEXT build. Fails with an actionable message instead of a bare EACCES
// halfway through the copy. See _preflight / _checkTreeAccess.
const preflight = await this._preflight({
srcBase,
cdnCssRoot: useCdnCss ? cdnCssRoot : null,
servedCdn: path.join(dp, tribeId, 'wwws', 'cdn'),
distBase,
tribeId,
appName
});
if (preflight) return preflight;
// Provision the served CDN for this tribe (see _provisionTribeCdn). A tribe
// deployed via git (update.sh / mytribes "Pull & deploy") never went through
// init.sh, which only provisions the TOWN cdn — without this every
// /cdn/{tribe}/lib/* (axios, auth, localdb, menu…) 404's.
await this._provisionTribeCdn(tribeId);
const twCli = path.join(np, 'apxtri', 'node_modules', '@tailwindcss', 'cli', 'dist', 'index.mjs');
if (useCdnCss && await fs.pathExists(baseSrc)) {
try {
logger.info('Compiling shared CDN base CSS (cdn/css/base.css)...');
const baseTmp = baseDist + '.tmp';
// Base keeps the default cwd (apxtri): daisyUI + webapp themes + fonts
// only; any project-utility duplication here is harmless.
execSync(
`node "${twCli}" -i "${baseSrc}" -o "${baseTmp}" --minify`,
{ cwd: path.join(np, 'apxtri'), stdio: 'pipe' }
);
if (await fs.pathExists(baseDist)) await fs.remove(baseDist);
await fs.move(baseTmp, baseDist, { overwrite: true });
// Rewrite /cdn/dev/ → /cdn/{town}/ in compiled CSS (font URLs etc.)
if (tribeId !== 'dev') {
let baseCss = await fs.readFile(baseDist, 'utf8');
baseCss = baseCss.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
await fs.writeFile(baseDist, baseCss);
}
} catch (err) {
// A base.css compile failure (e.g. missing /opt/apxtowns/main_modules
// symlink) must NOT abort the whole build: the committed base.css is
// still served, and the per-subapp HTML/JS/manifest /cdn/dev/ rewrite
// below is what fixes CDN paths on non-dev towns.
logger.warn(`Base CSS compile failed, continuing with existing base.css: ${err.message}`);
}
}
for (const subapp of subapps) {
logger.info(`--- Building subapp: ${subapp.name || '(root)'} ---`);
const subResult = { name: subapp.name, files: [] };
try {
await fs.ensureDir(path.join(subapp.dist, 'css'));
await fs.ensureDir(path.join(subapp.dist, 'js'));
// 1. Tailwind CSS compilation — per-webapp output in CDN (cdn/css/{subapp}.css)
// when cdn/css exists; legacy dist/css/output.css otherwise.
const styleSrc = path.join(subapp.src, 'css', 'style.css');
let cssDist, copyBackSrc;
if (useCdnCss) {
cssDist = path.join(cdnCssRoot, subapp.name + '.css');
} else {
cssDist = path.join(subapp.dist, 'css', 'output.css');
copyBackSrc = path.join(subapp.src, 'css', 'output.css');
}
if (await fs.pathExists(styleSrc)) {
logger.info(` Compiling Tailwind CSS... -> ${useCdnCss ? path.join('cdn', 'css', subapp.name + '.css') : 'css/output.css'}`);
const cssTmp = cssDist + '.tmp';
// --cwd = the webapp root: automatic detection is anchored to the cwd
// subtree, so each webapp only ships its own utilities (+ shared cdn/lib
// via the ../css @source globs) instead of a whole-project superset.
execSync(
`node "${twCli}" -i "${styleSrc}" -o "${cssTmp}" --cwd "${subapp.src}" --minify`,
{ cwd: path.join(np, 'apxtri'), stdio: 'pipe' }
);
// Remove old output and rename tmp (atomic, avoids losing CSS on build failure)
if (await fs.pathExists(cssDist)) {
await fs.remove(cssDist);
}
await fs.move(cssTmp, cssDist, { overwrite: true });
subResult.files.push(useCdnCss
? path.join('cdn', 'css', subapp.name + '.css')
: 'css/output.css');
// CDN mode: drop stale legacy dist output if present
if (useCdnCss) {
const legDist = path.join(subapp.dist, 'css', 'output.css');
if (await fs.pathExists(legDist)) {
await fs.remove(legDist);
}
}
// Legacy: copy output.css back to source so the source tree stays in sync
if (copyBackSrc) {
try {
await fs.copyFile(cssDist, copyBackSrc);
subResult.files.push('css/output.css (→ src)');
} catch (copyErr) {
const fixCmd = 'sudo chown -R apxtri:apxtri "' + getWebappSource(tribeId, appName) + '"';
logger.warn('Cannot copy output.css to source (' + copyBackSrc + '): ' + copyErr.message + '. Fix: ' + fixCmd);
subResult.warnings = subResult.warnings || [];
subResult.warnings.push({
step: 'copy_to_source',
file: copyBackSrc,
chown_cmd: fixCmd
});
}
}
}
// 2. HTML minification with path rewriting
const htmlSrc = path.join(subapp.src, 'index.html');
if (await fs.pathExists(htmlSrc)) {
logger.info(` Minifying index.html...`);
let html = await fs.readFile(htmlSrc, 'utf8');
// Rewrite /cdn/dev/ → /cdn/{town}/ for non-dev instances
if (tribeId !== 'dev') {
html = html.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
}
// Rewrite hardcoded per-instance meta (xtribe / xnation)
html = html.replace(/<meta name="xtribe" content="[^"]*"/g, `<meta name="xtribe" content="${tribeId}"`);
html = html.replace(/<meta name="xnation" content="[^"]*"/g, `<meta name="xnation" content="${process.env.NATION || tribeId}"`);
// Rewrite relative paths for dist
html = html.replace(/href="\.\.\/dist\//g, 'href="');
html = html.replace(/src="\.\.\/dist\//g, 'src="');
html = html.replace(/href="\.\.\/src\//g, 'href="');
html = html.replace(/src="\.\.\/src\//g, 'src="');
html = html.replace(/href="dist\//g, 'href="');
html = html.replace(/src="dist\//g, 'src="');
html = html.replace(/href="src\//g, 'href="');
html = html.replace(/src="src\//g, 'src="');
html = html.replace(/href="manifest\.json"/g, 'href="manifest.json"');
html = html.replace(/src="sw\.js"/g, 'src="sw.js"');
const minified = await minifyHTML(html, {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
minifyCSS: true,
minifyJS: true,
});
await fs.writeFile(path.join(subapp.dist, 'index.html'), minified);
subResult.files.push('index.html');
}
// 3. JS minification
const jsSrc = path.join(subapp.src, 'js', 'app.js');
if (await fs.pathExists(jsSrc)) {
logger.info(` Minifying js/app.js...`);
const js = await fs.readFile(jsSrc, 'utf8');
// Rewrite /cdn/dev/ → /cdn/{town}/ for non-dev instances
const jsRewritten = tribeId !== 'dev'
? js.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/')
: js;
const compressOpts = { compress: true, mangle: true };
compressOpts.compress = { drop_console: true, drop_debugger: true };
const minified = await minifyJS(jsRewritten, compressOpts);
await fs.writeFile(path.join(subapp.dist, 'js', 'app.js'), minified.code);
subResult.files.push('js/app.js');
}
// 4. Copy static/ directory if exists
const staticSrc = path.join(subapp.src, 'static');
if (await fs.pathExists(staticSrc)) {
const staticDist = path.join(subapp.dist, 'static');
const realSrc = await fs.realpath(staticSrc);
const realDist = await fs.realpath(staticDist).catch(() => null);
if (realSrc !== realDist) {
logger.info(` Copying static/...`);
await fs.copy(staticSrc, staticDist, { overwrite: true });
subResult.files.push('static/*');
} else {
logger.info(` Skipping static/ (same path via symlink)`);
}
}
// 5. Copy fonts/ directory if exists
const fontsSrc = path.join(subapp.src, 'fonts');
if (await fs.pathExists(fontsSrc)) {
const fontsDist = path.join(subapp.dist, 'fonts');
const realSrc = await fs.realpath(fontsSrc);
const realDist = await fs.realpath(fontsDist).catch(() => null);
if (realSrc !== realDist) {
logger.info(` Copying fonts/...`);
await fs.copy(fontsSrc, fontsDist, { overwrite: true });
subResult.files.push('fonts/*');
} else {
logger.info(` Skipping fonts/ (same path via symlink)`);
}
}
// 6. Copy manifest.json
const manifestSrc = path.join(subapp.src, 'manifest.json');
if (await fs.pathExists(manifestSrc)) {
logger.info(` Copying manifest.json...`);
if (tribeId !== 'dev') {
let manifest = await fs.readFile(manifestSrc, 'utf8');
manifest = manifest.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
await fs.writeFile(path.join(subapp.dist, 'manifest.json'), manifest);
} else {
await fs.copyFile(manifestSrc, path.join(subapp.dist, 'manifest.json'));
}
subResult.files.push('manifest.json');
}
// 7. Copy and adjust sw.js
const swSrc = path.join(subapp.src, 'sw.js');
if (await fs.pathExists(swSrc)) {
logger.info(` Copying sw.js...`);
let sw = await fs.readFile(swSrc, 'utf8');
sw = sw.replace(/'\.\//g, "'./");
await fs.writeFile(path.join(subapp.dist, 'sw.js'), sw);
subResult.files.push('sw.js');
}
subResult.status = 'ok';
logger.info(` ✓ Subapp ${subapp.name} built successfully`);
} catch (err) {
subResult.status = 'error';
subResult.error = err.message;
logger.error(` ✗ Subapp ${subapp.name} build failed: ${err.message}`);
}
results.push(subResult);
}
return {
status: 200,
ref: "Wwws",
msg: "pwa_build_complete",
data: { subapps: results }
};
}
// === Tribe webapp style (colors + fonts) ===
static _tribestyleCache = new Map();
static async getTribestyle(tribeId) {
const np = process.env.NODEPATH;
const cacheKey = tribeId || '';
if (this._tribestyleCache.has(cacheKey)) {
const cached = this._tribestyleCache.get(cacheKey);
return { status: 200, ref: "Wwws", msg: cached.found ? "tribestyle_found" : "tribestyle_default", data: cached.data };
}
const skipApps = ['apidoc', 'cdn'];
const candidates = [];
if (tribeId) candidates.push(tribeId);
const town = process.env.TOWN;
if (town && town !== tribeId) candidates.push(town);
for (const candidate of candidates) {
let apps;
try { apps = listWebappNames(candidate).filter((a) => !skipApps.includes(a)).sort(); }
catch { continue; }
for (const app of apps) {
const appDir = getWebappSource(candidate, app);
let st;
try { st = await fs.stat(appDir); } catch { continue; }
if (!st.isDirectory()) continue;
let subs;
try { subs = await fs.readdir(appDir); } catch { continue; }
for (const sub of subs.sort()) {
const subDir = path.join(appDir, sub);
try { st = await fs.stat(subDir); } catch { continue; }
if (!st.isDirectory()) continue;
if (!await fs.pathExists(path.join(subDir, 'manifest.json'))) continue;
const cssPath = path.join(subDir, 'css', 'style.css');
if (!await fs.pathExists(cssPath)) continue;
let css;
try { css = await fs.readFile(cssPath, 'utf8'); } catch { continue; }
const parsed = this._parseTribestyle(css);
if (!parsed.themes.light && !parsed.themes.dark) continue;
const data = { tribe: candidate, app, subapp: sub, path: cssPath, ...parsed };
this._tribestyleCache.set(cacheKey, { found: true, data });
return { status: 200, ref: "Wwws", msg: "tribestyle_found", data };
}
}
}
const empty = { themes: { light: null, dark: null }, fonts: { sans: '', serif: '', mono: '', custom: [] } };
this._tribestyleCache.set(cacheKey, { found: false, data: empty });
return { status: 200, ref: "Wwws", msg: "tribestyle_default", data: empty };
}
static _parseTribestyle(css) {
const themes = { light: null, dark: null };
const fonts = { sans: '', serif: '', mono: '', custom: [] };
// Custom fonts via @font-face
const fontFaces = this._extractDirectiveBlocks(css, 'font-face');
for (const fb of fontFaces) {
const m = fb.body.match(/font-family\s*:\s*['"]([^'"]+)['"]/i);
if (m && !fonts.custom.includes(m[1])) fonts.custom.push(m[1]);
}
// Fonts from @theme block
const themeBlocks = this._extractDirectiveBlocks(css, 'theme');
if (themeBlocks.length) {
fonts.sans = this._getCssVar(themeBlocks[0].body, 'font-sans');
fonts.serif = this._getCssVar(themeBlocks[0].body, 'font-serif');
fonts.mono = this._getCssVar(themeBlocks[0].body, 'font-mono');
}
// Colors from @plugin ".../theme" blocks (DaisyUI themes)
const pluginBlocks = this._extractDirectiveBlocks(css, 'plugin');
for (const pb of pluginBlocks) {
if (!/\/theme\b/.test(pb.header)) continue;
const colors = {};
const re = /--color-([a-z0-9-]+)\s*:\s*([^;]+);/gi;
let m;
while ((m = re.exec(pb.body))) colors[m[1].toLowerCase()] = m[2].trim();
if (!Object.keys(colors).length) continue;
const name = (this._getCssProp(pb.body, 'name') || '').replace(/["']/g, '').trim();
const colorScheme = (this._getCssProp(pb.body, 'color-scheme') || '').replace(/["']/g, '').trim();
const isDefault = /default\s*:\s*true/i.test(pb.body);
const entry = { name, colorScheme, colors };
if (/dark/i.test(name) || colorScheme === 'dark') {
if (!themes.dark) themes.dark = entry;
} else if (/light/i.test(name) || colorScheme === 'light') {
if (!themes.light) themes.light = entry;
} else if (isDefault) {
if (!themes.light) themes.light = entry;
else if (!themes.dark) themes.dark = entry;
}
}
return { themes, fonts };
}
static _extractDirectiveBlocks(css, directive) {
const blocks = [];
const re = new RegExp('@' + directive + '\\b[^{]*\\{', 'gi');
let m;
while ((m = re.exec(css))) {
const bodyStart = css.indexOf('{', m.index) + 1;
let depth = 1, i = bodyStart;
while (i < css.length && depth > 0) {
if (css[i] === '{') depth++;
else if (css[i] === '}') depth--;
i++;
}
blocks.push({ header: css.slice(m.index, bodyStart - 1).trim(), body: css.slice(bodyStart, i - 1) });
re.lastIndex = i;
}
return blocks;
}
static _getCssVar(block, name) {
const m = block.match(new RegExp('--' + name + '\\s*:\\s*([^;]+);'));
return m ? m[1].trim() : '';
}
static _getCssProp(block, prop) {
const m = block.match(new RegExp(prop + '\\s*:\\s*([^;]+);'));
return m ? m[1].trim() : '';
}
}
module.exports = Wwws;
// CLI support: node apxtri/models/Wwws.js {tribeId} {appName} pwa
if (require.main === module && process.argv.length === 5 && process.argv[4] === 'pwa') {
const dotenv = require('dotenv');
const envPath = path.join(__dirname, '..', '.env');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const cliNodePath = process.env.NODEPATH;
const cliDataPath = process.env.DATAPATH;
const tribeId = process.argv[2];
const appName = process.argv[3];
console.log(`[CLI] Wwws.buildpwa tribe=${tribeId} app=${appName}`);
console.log(` NODEPATH=${cliNodePath} DATAPATH=${cliDataPath}`);
(async () => {
const result = await Wwws.buildpwa(tribeId, appName);
console.log(JSON.stringify(result, null, 2));
if (result.status !== 200) process.exit(1);
})().catch(err => {
console.error('Build failed:', err);
process.exit(1);
});
}