import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { runArchiveSmoke } from './smoke-app-installer.mjs'; import { isolateWorkspacePackages } from './smoke-registry-config.mjs'; import { dialects, readMainConfig } from './smoke-database-config.mjs'; const repo = path.resolve(import.meta.dirname, '..'); const id = createHash('sha256').update(repo).digest('hex').slice(0, 12); const stateDir = path.join(os.tmpdir(), `nocobase-unreleased-${id}`); const stateFile = path.join(stateDir, 'state.json'); const container = `nocobase-unreleased-${id}`; const label = 'nocobase.unreleased'; // Sessions prepared while these commands were named local-registry:*. Clean removes one so that a fresh session can // take the port it still holds. const legacyStateDir = path.join(os.tmpdir(), `nocobase-local-registry-${id}`); const legacyContainer = `nocobase-local-registry-${id}`; const legacyLabel = 'nocobase.local-registry'; const help = `Test the unreleased checkout as published packages pnpm unreleased:prepare [--port 4873] [--reset] pnpm unreleased:create NAME [--template default|examples] [--dialect sqlite] # --dialect only decides which configuration commands are printed afterwards [--output-dir /parent/directory] [--json] [--no-install] pnpm unreleased:smoke [--template default|examples] [--dialect sqlite] [--config /absolute/test.yml] [--timeout 420] [--workdir /empty/directory] pnpm unreleased:installer-smoke [--app-port 13100] [--workdir /empty/directory] pnpm unreleased:clean eval "$(pnpm -s unreleased:env)" Prepare builds and publishes all workspace packages to a fresh local npm registry. Env prints the variables create and smoke run with, as shell exports, so plain pnpm and npm commands in the current shell — and an agent started from it — resolve the snapshot too, instead of the registry in your own pnpm and npm configuration. Use --reset to remove the previous session and clear its snapshot before preparing again. Smoke runs test/dev/build/start and retains applications and logs outside the repository. Installer-smoke runs the snapshot's app-installer: it creates a default application, builds its deployment archive, installs, upgrades and rolls it back. It needs pm2 on PATH; it runs pm2 with its own PM2_HOME, stops it afterwards, and retains the installation and its logs outside the repository. A non-SQLite smoke test requires --config pointing to a dedicated test database; application migrations and seeds may modify it. Clean removes the registry and its caches, not test applications. `; export function parseArgs(argv) { const [action, ...args] = argv; if ( !['prepare', 'create', 'smoke', 'installer-smoke', 'env', 'clean'].includes( action, ) ) throw new Error( 'Expected prepare, create, smoke, installer-smoke, env, or clean.', ); const options = { action, port: 4873, 'app-port': 13100, template: 'default', dialect: 'sqlite', timeout: 420, }; const allowed = { prepare: ['port'], smoke: ['template', 'dialect', 'config', 'timeout', 'workdir'], 'installer-smoke': ['app-port', 'workdir'], create: ['template', 'dialect', 'output-dir'], env: [], clean: [], }; for (let i = 0; i < args.length; i++) { if (action === 'prepare' && args[i] === '--reset') { options.reset = true; continue; } if (action === 'create' && ['--json', '--no-install'].includes(args[i])) { options[args[i].slice(2)] = true; continue; } if (action === 'create' && !args[i].startsWith('-')) { if (options.name) throw new Error('Only one application name is allowed.'); options.name = args[i]; continue; } const key = args[i].replace(/^--/, ''); if ( !args[i].startsWith('--') || !allowed[action].includes(key) || !args[i + 1] || args[i + 1].startsWith('--') ) throw new Error(`Invalid option: ${args[i]}`); options[key] = args[++i]; } if (action === 'create' && !/^[a-z0-9][a-z0-9._-]*$/.test(options.name ?? '')) throw new Error( 'Provide an application name using lowercase letters, digits, dots, dashes or underscores.', ); for (const key of ['port', 'app-port', 'timeout']) { options[key] = Number(options[key]); if ( !Number.isInteger(options[key]) || options[key] < 1 || (key !== 'timeout' && options[key] > 65535) ) throw new Error(`Invalid ${key}.`); } if (!['default', 'examples'].includes(options.template)) throw new Error('Unknown template.'); if (!dialects.includes(options.dialect)) throw new Error('Unknown dialect.'); if (action === 'smoke' && options.dialect !== 'sqlite' && !options.config) throw new Error( 'A non-SQLite smoke test requires --config for a dedicated test database.', ); return options; } function run( command, args, { cwd = repo, env = process.env, capture = false } = {}, ) { const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', }); if (result.error || result.status !== 0) throw new Error( `${command} ${args[0] ?? ''} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status})`}.`, ); return result.stdout?.trim(); } const INHERITED_CONFIG = /^(npm_config_|pnpm_config_)/i; export function registryEnv(state) { const env = { ...process.env }; // Ignore inherited registry/auth/cache overrides; use a session-only npmrc for both tools. for (const key of Object.keys(env)) if (INHERITED_CONFIG.test(key)) delete env[key]; return { ...env, ...registryEnvOverrides(state) }; } /** * What `registryEnv` sets on top of the inherited environment. * * `XDG_CONFIG_HOME` is the one that is easy to leave out and the one that matters most. `pnpm config set` writes a * scoped registry to the global `auth.ini` in pnpm's configuration directory (`~/Library/Preferences/pnpm` on macOS, * `$XDG_CONFIG_HOME/pnpm` elsewhere), and that file is read however `PNPM_CONFIG_USERCONFIG` is set. Anyone who * followed the documented `pnpm config set @nocobase:registry …` has one, so without a session configuration * directory every `@nocobase/*` package still resolves against the published registry — and because a snapshot * carries the same version numbers as the last release, nothing reports that it did. pnpm reads no other variable for * that directory, so moving it means moving `XDG_CONFIG_HOME` as a whole. */ export function registryEnvOverrides(state) { const sessionDir = path.dirname(state.npmrc); return { NPM_CONFIG_USERCONFIG: state.npmrc, PNPM_CONFIG_USERCONFIG: state.npmrc, PNPM_CONFIG_NPMRC_AUTH_FILE: state.npmrc, XDG_CONFIG_HOME: path.join(sessionDir, 'config'), PNPM_CONFIG_REGISTRY: state.registry, npm_config_registry: state.registry, NPM_CONFIG_REGISTRY: state.registry, NOCOBASE_REGISTRY: state.registry, NODE_AUTH_TOKEN: '', PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0', PNPM_CONFIG_CACHE_DIR: path.join(sessionDir, 'cache'), PNPM_CONFIG_STORE_DIR: path.join(sessionDir, 'store'), NPM_CONFIG_CACHE: path.join(sessionDir, 'npm-cache'), }; } /** * `registryEnv` as commands a POSIX shell can evaluate, so that plain `pnpm` and `npm` in an interactive shell — and * an agent started from it — resolve exactly as `create` and `smoke` do. `inherited` is the environment this process * received, which is the calling shell's plus whatever pnpm adds to run the script: the configuration variables * `registryEnv` drops are unset, which is harmless for the ones only pnpm set. */ export function formatShellEnv(state, inherited = process.env) { const overrides = registryEnvOverrides(state); const unset = Object.keys(inherited) .filter((key) => INHERITED_CONFIG.test(key) && !(key in overrides)) .sort(); return [ `# Unreleased packages from ${state.repo} at ${state.registry}`, '# XDG_CONFIG_HOME moves for this shell too, so tools that keep their settings there (gh, for one) will not find', '# them here. Open a new shell to leave the session.', ...(unset.length ? [`unset ${unset.join(' ')}`] : []), ...Object.entries(overrides).map( ([key, value]) => `export ${key}=${shellQuote(value)}`, ), ].join('\n'); } function shellQuote(value) { return `'${String(value).replaceAll("'", `'\\''`)}'`; } async function assertRegistryAvailable(state) { try { const response = await fetch(`${state.registry}-/ping`, { signal: AbortSignal.timeout(3000), }); if (!response.ok) throw new Error('Registry is unavailable.'); } catch { throw new Error( 'The local npm registry is unavailable. Run pnpm unreleased:clean and pnpm unreleased:prepare.', ); } } function assertRegistries(state, env, cwd = repo) { for (const tool of ['npm', 'pnpm']) for (const key of ['registry', '@nocobase:registry']) { const actual = run(tool, ['config', 'get', key], { env, capture: true, cwd, }) .split('\n') .at(-1) .replace(/\/$/, ''); if (actual !== state.registry.replace(/\/$/, '')) throw new Error( `${tool} ${key} does not point to the local npm registry; refusing to publish or install.`, ); } } function readState() { if (!fs.existsSync(stateFile)) throw new Error('Run pnpm unreleased:prepare first.'); const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); if ( state.repo !== repo || state.container !== container || !/^http:\/\/127\.0\.0\.1:\d+\/$/.test(state.registry) ) throw new Error('Invalid unreleased session state.'); return state; } export function assertWorkdir(directory, root = repo) { const resolved = path.resolve(directory); fs.mkdirSync(resolved, { recursive: true }); const real = fs.realpathSync(resolved); const relative = path.relative(fs.realpathSync(root), real); if ( relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) ) throw new Error('The test directory must be outside the repository.'); if (fs.readdirSync(real).length) throw new Error('The test directory must be empty.'); return real; } async function prepare(options) { if ( fs.existsSync(stateFile) || fs.existsSync(path.join(legacyStateDir, 'state.json')) ) throw new Error( 'An unreleased session already exists. Run pnpm unreleased:prepare --reset to prepare a fresh snapshot.', ); const packages = fs .globSync('packages/*/*/package.json', { cwd: repo }) .map((file) => JSON.parse(fs.readFileSync(path.join(repo, file), 'utf8'))) .filter((pkg) => !pkg.private); const state = { repo, container, registry: `http://127.0.0.1:${options.port}/`, npmrc: path.join(stateDir, 'npmrc'), ready: false, versions: Object.fromEntries( packages.map((pkg) => [pkg.name, pkg.version]), ), }; for (const pkg of packages) { if ( pkg.publishConfig?.registry && pkg.publishConfig.registry.replace(/\/$/, '') !== state.registry.replace(/\/$/, '') ) throw new Error( `${pkg.name} publishConfig.registry points outside the local npm registry.`, ); } fs.writeFileSync(stateFile, JSON.stringify(state), { mode: 0o600 }); const config = path.join(stateDir, 'verdaccio.yaml'); fs.writeFileSync( config, isolateWorkspacePackages( fs.readFileSync(path.join(repo, '.github/verdaccio/config.yaml'), 'utf8'), packages, ), ); run('docker', [ 'run', '-d', '--name', container, '--label', `${label}=${id}`, '-p', `127.0.0.1:${options.port}:4873`, '-v', `${config}:/verdaccio/conf/config.yaml:ro`, 'verdaccio/verdaccio:5', ]); let available = false; for (let i = 0; i < 60; i++) { try { if ( ( await fetch(`${state.registry}-/ping`, { signal: AbortSignal.timeout(1000), }) ).ok ) { available = true; break; } } catch { /* Startup may take a few seconds. */ } await new Promise((resolve) => setTimeout(resolve, 1000)); } if (!available) throw new Error( `Registry did not start. Inspect docker logs ${container}.`, ); const response = await fetch( `${state.registry}-/user/org.couchdb.user:local-test`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'local-test', password: 'local-test', type: 'user', roles: [], }), signal: AbortSignal.timeout(10000), }, ); const { token } = await response.json(); if (!response.ok || !token) throw new Error('Local npm registry authentication failed.'); fs.writeFileSync( state.npmrc, `registry=${state.registry}\n@nocobase:registry=${state.registry}\n//127.0.0.1:${options.port}/:_authToken=${token}\n`, { mode: 0o600 }, ); // Build with normal dependency resolution before using the empty isolated registry. console.log('Building publishable workspace packages...'); run('pnpm', ['-r', '--filter', '!@nocobase/docs', '--if-present', 'build'], { env: { ...process.env, NOCOBASE_SKIP_WORKSPACE_DEPENDENCY_BUILD: '1' }, }); const env = registryEnv(state); assertRegistries(state, env); console.log('Publishing the checkout to the isolated local npm registry...'); run('pnpm', ['changeset', 'publish', '--no-git-tag'], { env }); // The repository is in prerelease mode. Explicitly expose this snapshot as latest for manual pnpm create. for (const pkg of packages) run( 'npm', [ 'dist-tag', 'add', `${pkg.name}@${pkg.version}`, 'latest', '--registry', state.registry, ], { env, capture: true }, ); state.ready = true; fs.writeFileSync(stateFile, JSON.stringify(state), { mode: 0o600 }); const wrapper = path.join(stateDir, 'create.mjs'); fs.writeFileSync( wrapper, `import { createManually } from ${JSON.stringify(new URL(import.meta.url).href)}; try { createManually(process.argv.slice(2)); } catch (error) { console.error(error.message); process.exitCode = 1; } `, { mode: 0o600 }, ); console.log(`Ready: ${state.registry} For manual development: pnpm unreleased:create my-app For an automated check: pnpm unreleased:smoke --template default For the installer: pnpm unreleased:installer-smoke For manual testing, run outside the repository: node ${JSON.stringify(wrapper)} my-app --json The wrapper runs pnpm create with isolated configuration and snapshot versions. To make plain pnpm and npm in a shell resolve the snapshot, for example to test an agent Skill: eval "$(pnpm -s unreleased:env)" Clean up with: pnpm unreleased:clean`); } export function createManually(args, cwd = process.cwd()) { const state = readState(); if (!state.ready) throw new Error('Preparation did not complete.'); const relative = path.relative(repo, fs.realpathSync(cwd)); if ( relative === '' || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ) throw new Error('Run manual creation outside the repository.'); let template = 'default'; const forwarded = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--registry' || args[i].startsWith('--registry=')) throw new Error('The local wrapper owns the registry setting.'); if (args[i] === '--template') template = args[++i]; else if (args[i].startsWith('--template=')) template = args[i].slice('--template='.length); else forwarded.push(args[i]); } if (!['default', 'examples'].includes(template)) throw new Error('Unknown template.'); const env = registryEnv(state); assertRegistries(state, env, cwd); run( 'pnpm', [ 'create', `@nocobase/app@${state.versions['@nocobase/create-app']}`, ...forwarded, '--registry', state.registry, '--template', `@nocobase/app-template-${template}@${state.versions[`@nocobase/app-template-${template}`]}`, ], { env, cwd }, ); } function removeOwnedContainer(name, labelKey) { const names = run( 'docker', ['ps', '-a', '--filter', `name=^/${name}$`, '--format', '{{.Names}}'], { capture: true }, ); if (!names) return; const owner = run( 'docker', ['inspect', '--format', `{{index .Config.Labels "${labelKey}"}}`, name], { capture: true }, ); if (owner !== id) throw new Error( 'Refusing to remove a container not owned by this checkout.', ); run('docker', ['rm', '-f', '-v', name]); } function clean() { if (fs.existsSync(stateFile)) removeOwnedContainer(readState().container, label); if (fs.existsSync(path.join(legacyStateDir, 'state.json'))) { removeOwnedContainer(legacyContainer, legacyLabel); fs.rmSync(legacyStateDir, { recursive: true, force: true }); } // Keep the operation lock in place throughout reset and preparation. for (const entry of fs.readdirSync(stateDir)) { if (entry !== 'lock') fs.rmSync(path.join(stateDir, entry), { recursive: true, force: true }); } console.log( 'Local npm registry and isolated caches removed. Test applications and logs retained.', ); } async function main() { if (process.argv.includes('--help') || process.argv.includes('-h')) { console.log(help); return; } const options = parseArgs(process.argv.slice(2)); fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); const lock = path.join(stateDir, 'lock'); if (fs.existsSync(lock)) { const pid = Number(fs.readFileSync(lock, 'utf8')); let alive = false; try { process.kill(pid, 0); alive = true; } catch { /* A previous operation was interrupted. */ } if (alive) throw new Error('Another unreleased operation is running.'); fs.unlinkSync(lock); } fs.writeFileSync(lock, String(process.pid), { flag: 'wx' }); try { if (options.action === 'prepare') { if (options.reset) clean(); await prepare(options); } else if (options.action === 'create') { const state = readState(); if (!state.ready) throw new Error('Run pnpm unreleased:prepare first.'); await assertRegistryAvailable(state); const parent = path.resolve( options['output-dir'] ?? path.join(repo, '..', 'nocobase-local-apps'), ); const target = assertWorkdir(path.join(parent, options.name)); createManually( [ options.name, '--template', options.template, ...(options.json ? ['--json'] : []), ...(options['no-install'] ? ['--no-install'] : []), ], fs.realpathSync(parent), ); if (!options.json) { // Creation stops at a configurable project. `--dialect` is not passed through — it decides which driver the // application depends on, which is a change to the application rather than to how it is scaffolded. const configure = options.dialect === 'sqlite' ? ['pnpm nocobase config init'] : [ `pnpm add @nocobase/db-${options.dialect}`, `pnpm nocobase config init --dialect ${options.dialect}`, ]; console.log( [ `Application: ${target}`, `Next: cd ${JSON.stringify(target)}`, ...(options['no-install'] ? [' pnpm install'] : []), ...configure.map((command) => ` ${command}`), ].join('\n'), ); } } else if (options.action === 'smoke') { const state = readState(); if (!state.ready) throw new Error( 'Preparation did not complete. Run pnpm unreleased:prepare --reset.', ); if (options.config) readMainConfig(path.resolve(options.config), options.dialect); const directory = assertWorkdir( options.workdir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'nocobase-unreleased-smoke-')), ); const env = registryEnv(state); assertRegistries(state, env); console.log(`Test application and logs: ${directory}`); run( 'bash', [ path.join(repo, 'scripts/smoke-create-app.sh'), '--registry', state.registry, '--create-app-version', state.versions['@nocobase/create-app'], '--template', `@nocobase/app-template-${options.template}@${state.versions[`@nocobase/app-template-${options.template}`]}`, '--dialect', options.dialect, '--json', '--workdir', directory, '--timeout', String(options.timeout), ...(options.config ? ['--config', path.resolve(options.config)] : []), ], { env }, ); } else if (options.action === 'installer-smoke') { const state = readState(); if (!state.ready) throw new Error( 'Preparation did not complete. Run pnpm unreleased:prepare --reset.', ); await assertRegistryAvailable(state); const installer = state.versions['@nocobase/app-installer']; if (!installer) throw new Error( 'This snapshot has no @nocobase/app-installer. Run pnpm unreleased:prepare --reset from a checkout that has it.', ); if (spawnSync('pm2', ['--version'], { stdio: 'ignore' }).status !== 0) throw new Error('pm2 is required on PATH: npm install -g pm2'); const directory = assertWorkdir( options.workdir ?? fs.mkdtempSync( path.join(os.tmpdir(), 'nocobase-unreleased-installer-'), ), ); // pm2 keeps its sockets in PM2_HOME, and a Unix socket path has to stay under about 104 bytes, which the macOS // temporary directory already comes close to. A short directory under /tmp keeps this run off your own pm2. const pm2Home = fs.mkdtempSync('/tmp/nb-pm2-'); const env = { ...registryEnv(state), PM2_HOME: pm2Home }; assertRegistries(state, env); const command = ['npx', '--yes', `@nocobase/app-installer@${installer}`]; console.log(`Installations and logs: ${directory}`); try { // A default application from the snapshot, built for this machine: what a user deploys. createManually( ['app', '--template', 'default', '--json'], fs.realpathSync(directory), ); const project = path.join(directory, 'app'); run('pnpm', ['build', '--tar'], { cwd: project, env }); runArchiveSmoke({ root: path.join(directory, 'crm'), port: options['app-port'], archive: path.join(project, 'storage/exports/dist.tar.gz'), installer: command, env, }); console.log( `Installer smoke test passed. Installations and logs retained: ${directory}`, ); } finally { spawnSync('pm2', ['kill'], { env, stdio: 'ignore' }); fs.rmSync(pm2Home, { recursive: true, force: true }); } } else if (options.action === 'env') { const state = readState(); if (!state.ready) throw new Error('Run pnpm unreleased:prepare first.'); await assertRegistryAvailable(state); // stdout carries only what the shell evaluates; everything meant for a person is a comment. console.log(formatShellEnv(state)); } else { clean(); } } finally { fs.rmSync(lock, { force: true }); } } if (import.meta.main) main().catch((error) => { console.error(error.message); process.exitCode = 1; });