[feat] nopy update command and auto-update pipeline
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Reports what each publishable package looks like on Gitea versus npmjs.
|
||||
*
|
||||
* The two registries are deliberately not equivalent: `publish-snapshot.yml`
|
||||
* pushes a `main` snapshot to Gitea on every push to `main`, while `release.yml`
|
||||
* publishes a tagged version to both. Gitea is therefore a superset, and the
|
||||
* interesting question before a release is which versions exist *only* there —
|
||||
* those are the ones that can still be tested and un-published.
|
||||
*
|
||||
* It also flags a missing `latest` dist-tag, which is worth a line of output
|
||||
* because npm reports it by printing nothing and exiting 0. `npm view <name>`
|
||||
* against a registry with no `latest` looks identical to a working lookup of an
|
||||
* empty package, which is how the whole 1.0.0-alphaN dist-tag problem stayed
|
||||
* invisible for as long as it did.
|
||||
*
|
||||
* node scripts/registry-status.mjs
|
||||
* node scripts/registry-status.mjs --json
|
||||
* node scripts/registry-status.mjs --registry http://localhost:4873/
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const PACKAGES_DIR = 'packages';
|
||||
const SCOPE = '@bitsquare';
|
||||
const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
|
||||
const FALLBACK_GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const asJson = argv.includes('--json');
|
||||
const flag = (name, fallback) => {
|
||||
const at = argv.indexOf(name);
|
||||
return at !== -1 && argv[at + 1] ? argv[at + 1] : fallback;
|
||||
};
|
||||
|
||||
const withSlash = (url) => (url.endsWith('/') ? url : `${url}/`);
|
||||
|
||||
/**
|
||||
* The scope mapping in the repo's own `.npmrc` is the single source of truth,
|
||||
* so this never drifts from what an actual install would do.
|
||||
*/
|
||||
function resolveGiteaRegistry() {
|
||||
try {
|
||||
const out = execFileSync('npm', ['config', 'get', `${SCOPE}:registry`], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10_000,
|
||||
}).trim();
|
||||
// npm prints the literal string "undefined" for an unset key.
|
||||
if (out && out !== 'undefined' && out !== 'null') return withSlash(out);
|
||||
} catch {
|
||||
// npm missing or unreadable config — the fallback is still correct.
|
||||
}
|
||||
return FALLBACK_GITEA;
|
||||
}
|
||||
|
||||
const GITEA_REGISTRY = withSlash(flag('--registry', resolveGiteaRegistry()));
|
||||
|
||||
/** Fetches a packument, normalising every failure into a shape the report can print. */
|
||||
async function packument(registry, name) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${registry}${encodeURIComponent(name)}`, {
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
return { reachable: false, note: error.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
|
||||
}
|
||||
|
||||
if (response.status === 404) return { reachable: true, published: false };
|
||||
if (!response.ok) return { reachable: true, note: `HTTP ${response.status}` };
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
return { reachable: true, note: 'unparseable response' };
|
||||
}
|
||||
|
||||
// Gitea answers a missing package with 200 + {"error": "Not found"} rather
|
||||
// than a 404, so the body has to be checked as well as the status.
|
||||
if (body.error) return { reachable: true, published: false };
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
published: true,
|
||||
tags: body['dist-tags'] ?? {},
|
||||
versions: Object.keys(body.versions ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
const packages = fs
|
||||
.readdirSync(PACKAGES_DIR)
|
||||
.map((name) => path.join(PACKAGES_DIR, name))
|
||||
.filter((dir) => fs.existsSync(path.join(dir, 'package.json')))
|
||||
.map((dir) => ({ dir, manifest: JSON.parse(fs.readFileSync(path.join(dir, 'package.json'))) }))
|
||||
.filter(({ manifest }) => !manifest.private)
|
||||
.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
|
||||
|
||||
const report = await Promise.all(
|
||||
packages.map(async ({ dir, manifest }) => {
|
||||
const [gitea, npmjs] = await Promise.all([
|
||||
packument(GITEA_REGISTRY, manifest.name),
|
||||
packument(NPMJS_REGISTRY, manifest.name),
|
||||
]);
|
||||
|
||||
const npmjsVersions = new Set(npmjs.versions ?? []);
|
||||
const giteaOnly = (gitea.versions ?? []).filter((v) => !npmjsVersions.has(v));
|
||||
|
||||
return {
|
||||
name: manifest.name,
|
||||
dir,
|
||||
local: manifest.version,
|
||||
gitea,
|
||||
npmjs,
|
||||
giteaOnly,
|
||||
localPublished: {
|
||||
gitea: (gitea.versions ?? []).includes(manifest.version),
|
||||
npmjs: npmjsVersions.has(manifest.version),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ registries: { gitea: GITEA_REGISTRY, npmjs: NPMJS_REGISTRY }, report },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const describe = (result) => {
|
||||
if (!result.reachable) return `(${result.note})`;
|
||||
if (result.note) return `(${result.note})`;
|
||||
if (!result.published) return '(not published)';
|
||||
const tags = Object.entries(result.tags)
|
||||
.map(([tag, version]) => `${tag}=${version}`)
|
||||
.sort()
|
||||
.join(', ');
|
||||
const count = `${result.versions.length} version${result.versions.length === 1 ? '' : 's'}`;
|
||||
return `${count}${tags ? ` — ${tags}` : ' — no dist-tags'}`;
|
||||
};
|
||||
|
||||
console.log('Registry status\n');
|
||||
console.log(` gitea ${GITEA_REGISTRY}`);
|
||||
console.log(` npmjs ${NPMJS_REGISTRY}`);
|
||||
|
||||
for (const entry of report) {
|
||||
console.log(`\n${entry.name} (local ${entry.local})`);
|
||||
console.log(` gitea ${describe(entry.gitea)}`);
|
||||
console.log(` npmjs ${describe(entry.npmjs)}`);
|
||||
|
||||
if (entry.giteaOnly.length > 0) {
|
||||
console.log(` gitea only ${entry.giteaOnly.join(', ')}`);
|
||||
}
|
||||
|
||||
const notes = [];
|
||||
if (entry.gitea.published && !entry.gitea.tags.latest) {
|
||||
notes.push('no `latest` on gitea — an untagged install resolves to nothing, silently');
|
||||
}
|
||||
if (!entry.localPublished.gitea && !entry.localPublished.npmjs) {
|
||||
notes.push(`local ${entry.local} is on neither registry`);
|
||||
} else if (entry.localPublished.gitea && !entry.localPublished.npmjs) {
|
||||
notes.push(`local ${entry.local} is testable on gitea, not yet released to npmjs`);
|
||||
}
|
||||
for (const note of notes) console.log(` ! ${note}`);
|
||||
}
|
||||
|
||||
const testable = report.filter((entry) => entry.giteaOnly.length > 0);
|
||||
console.log(
|
||||
testable.length > 0
|
||||
? `\n${testable.length} of ${report.length} packages have versions on gitea that npmjs does not.` +
|
||||
'\nInstall one with an explicit tag, e.g. `npm i -g @bitsquare/nopy@main` — see README.PUBLISH.md.'
|
||||
: '\nEvery version on gitea is also on npmjs.'
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Installs a published snapshot the way a stranger would, into a throwaway
|
||||
* project, and runs it.
|
||||
*
|
||||
* This is the rehearsal that the local `pnpm pack` check cannot be: it goes to
|
||||
* the real registry, resolves the real `@bitsquare/nopy-cube` version that
|
||||
* `pnpm publish` baked into the tarball, and puts a real `nopy` binary on disk.
|
||||
* A tarball that installs here is one a user can install.
|
||||
*
|
||||
* node scripts/try-snapshot.mjs # @main from Gitea
|
||||
* node scripts/try-snapshot.mjs --tag latest # a release, still from Gitea
|
||||
* node scripts/try-snapshot.mjs --registry https://registry.npmjs.org/
|
||||
* node scripts/try-snapshot.mjs --keep # leave the directory behind
|
||||
*
|
||||
* `npm` is used rather than `pnpm` on purpose: npm is the client that rejects a
|
||||
* leaked `workspace:` range, so a clean install here is the stronger proof.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const SCOPE = '@bitsquare';
|
||||
const DEFAULT_REGISTRY = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
|
||||
const CLI_PACKAGE = '@bitsquare/nopy';
|
||||
const BUNDLE_PACKAGE = '@bitsquare/cubes-core';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
/** Reads `--flag value`, falling back to a default */
|
||||
const flag = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
return index === -1 ? fallback : args[index + 1];
|
||||
};
|
||||
|
||||
const tag = flag('--tag', 'main');
|
||||
const registry = flag('--registry', DEFAULT_REGISTRY).replace(/\/?$/, '/');
|
||||
const keep = args.includes('--keep');
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-snapshot-'));
|
||||
|
||||
/** Runs a command in the throwaway project, streaming its output */
|
||||
const run = (file, argv) =>
|
||||
execFileSync(file, argv, { cwd: dir, stdio: 'inherit', env: process.env });
|
||||
|
||||
/** Runs a command and captures stdout */
|
||||
const capture = (file, argv) =>
|
||||
execFileSync(file, argv, { cwd: dir, encoding: 'utf-8', env: process.env }).trim();
|
||||
|
||||
/**
|
||||
* Runs a command with stdin closed and returns everything it printed,
|
||||
* regardless of exit status — used for the interactive path, which is expected
|
||||
* to bail out once it finds no terminal to prompt at.
|
||||
*/
|
||||
const probe = (file, argv) => {
|
||||
try {
|
||||
return execFileSync(file, argv, {
|
||||
cwd: dir,
|
||||
encoding: 'utf-8',
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
return `${error.stdout ?? ''}${error.stderr ?? ''}`;
|
||||
}
|
||||
};
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
console.log(`Registry: ${registry}`);
|
||||
console.log(`Channel: ${tag}`);
|
||||
console.log(`Project: ${dir}\n`);
|
||||
|
||||
// Scoped, never a bare `registry=`: the Gitea registry serves @bitsquare and
|
||||
// does not proxy npmjs, so commander/execa/zod must keep resolving there.
|
||||
fs.writeFileSync(path.join(dir, '.npmrc'), `${SCOPE}:registry=${registry}\n`, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'package.json'),
|
||||
`${JSON.stringify({ name: 'nopy-snapshot-check', version: '0.0.0', private: true }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
console.log('--- install ---');
|
||||
run('npm', [
|
||||
'install',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
`${CLI_PACKAGE}@${tag}`,
|
||||
`${BUNDLE_PACKAGE}@${tag}`,
|
||||
]);
|
||||
|
||||
const installed = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, 'node_modules', CLI_PACKAGE, 'package.json'), 'utf-8')
|
||||
);
|
||||
const bundle = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, 'node_modules', BUNDLE_PACKAGE, 'package.json'), 'utf-8')
|
||||
);
|
||||
|
||||
// The whole point of packing with pnpm: this must be a concrete version, not
|
||||
// the literal string `workspace:*`.
|
||||
const linked = installed.dependencies?.['@bitsquare/nopy-cube'];
|
||||
if (!linked || linked.startsWith('workspace:')) {
|
||||
throw new Error(
|
||||
`${CLI_PACKAGE} declares nopy-cube as "${linked}" — a workspace range escaped.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- versions ---');
|
||||
console.log(`${CLI_PACKAGE}@${installed.version}`);
|
||||
console.log(`${BUNDLE_PACKAGE}@${bundle.version}`);
|
||||
console.log(` -> @bitsquare/nopy-cube ${linked}`);
|
||||
|
||||
console.log('\n--- nopy --version ---');
|
||||
console.log(capture(path.join(dir, 'node_modules', '.bin', 'nopy'), ['--version']));
|
||||
|
||||
// The part a tarball most often breaks: the loader reading cubes out of an
|
||||
// installed bundle in node_modules rather than a local directory.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.nopyrc.json'),
|
||||
`${JSON.stringify({ hosts: ['snapshot-check'], cubePackages: [BUNDLE_PACKAGE] }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
console.log('\n--- cube discovery ---');
|
||||
// stdin is closed, so the cube-selection prompt renders its choices and
|
||||
// gives up immediately instead of waiting for a keystroke. Those rendered
|
||||
// choices are the evidence: they only exist if the loader resolved the
|
||||
// bundle out of node_modules and imported every manifest.
|
||||
const discovery = probe(path.join(dir, 'node_modules', '.bin', 'nopy'), ['install', '-P', '-D']);
|
||||
|
||||
// Built from a char code rather than written literally: a raw escape byte in
|
||||
// a regex is a lint error, and the `\x1b` escape is flagged just the same.
|
||||
const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, 'g');
|
||||
const listed = [
|
||||
...new Set(
|
||||
[
|
||||
...discovery
|
||||
.replace(ansi, '')
|
||||
.matchAll(/([a-z0-9:_-]+) - [^\n]*\(@bitsquare\/cubes-core\)/g),
|
||||
].map((match) => match[1])
|
||||
),
|
||||
];
|
||||
|
||||
if (listed.length === 0) {
|
||||
throw new Error(
|
||||
`nopy loaded no cubes from ${BUNDLE_PACKAGE}. Output was:\n${discovery.slice(0, 2000)}`
|
||||
);
|
||||
}
|
||||
|
||||
// A count of what the prompt's viewport rendered, not of the whole bundle —
|
||||
// the check is that the loader found cubes at all, not how many.
|
||||
console.log(`${listed.length} cubes listed by the selection prompt`);
|
||||
console.log(` ${listed.slice(0, 5).join(', ')}${listed.length > 5 ? ', …' : ''}`);
|
||||
|
||||
console.log('\nSnapshot install works.');
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
console.error(`\nSnapshot check failed: ${error.message}`);
|
||||
} finally {
|
||||
if (keep || failed) {
|
||||
console.error(`\nLeft the project at ${dir}`);
|
||||
} else {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user