mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 07:47:55 +08:00
650 lines
23 KiB
JavaScript
650 lines
23 KiB
JavaScript
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;
|
|
});
|