diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml new file mode 100644 index 0000000..24af02d --- /dev/null +++ b/.github/workflows/npm.yml @@ -0,0 +1,25 @@ +name: npm package + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + npm: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm test + - run: npm run test:package + - run: npm run test:bootstrap diff --git a/.gitignore b/.gitignore index 83d1018..523f7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ __pycache__/ dist/ build/ .pytest_cache/ +node_modules/ +*.tgz run-logs/ .zcodeignore diff --git a/README.md b/README.md index 2f573cd..226d382 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,35 @@ python -m pip install -e '.[dev]' nanodot --help ``` +## Install with npm on macOS or Linux + +Requires Node.js 22 or newer. After the first npm release is published: + +```sh +npm install -g nanodot +nanodot --help +``` + +Or run without a global installation: + +```sh +npx nanodot --help +``` + +The launcher uses an existing Python 3.11+ when available. Otherwise, its first +run downloads a private Python runtime automatically. macOS (Intel and Apple +Silicon) and common Linux distributions (x86_64 and aarch64) are supported. +Automatic setup needs internet access and `curl`, which is included with macOS +and common Linux distributions; on minimal container images, install `curl` or +Python 3.11+ first. Later runs reuse the installed runtime. + +Runtime downloads live in `~/Library/Caches/nanodot/npm` on macOS and +`~/.cache/nanodot/npm` on Linux; `XDG_CACHE_HOME` overrides the cache location. +Application data uses `~/.nanodot`, or the directory set by `NANODOT_HOME`. + +See [npm packaging and release checks](docs/npm-packaging.md) for testing a +package before publication. + ## First use: watch a public PR Try the complete offline lifecycle first (no token, model, or network): @@ -200,6 +229,8 @@ checkout, Python setup, and dependency installation. notifications, task management, memory, optional models, and troubleshooting. - [First-use walkthrough](docs/first-pr-watch.md): the full watch lifecycle, background runner, cancellation, and verification details. +- [npm packaging](docs/npm-packaging.md): installing via npm on macOS or Linux, + and testing a package before publication. See [adapter contracts](docs/design/adapter-seam.md) for dependency direction and [the safety/validation review](docs/design/safety-validation.md) for the review diff --git a/bin/nanodot.cjs b/bin/nanodot.cjs new file mode 100755 index 0000000..cadc65f --- /dev/null +++ b/bin/nanodot.cjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); + +const UV_VERSION = '0.12.21'; +const INSTALLER_URL = `https://astral.sh/uv/${UV_VERSION}/install.sh`; +const SOURCE = path.resolve(__dirname, '..', 'src'); +const PYTHON_PROBE = 'import sys\nif sys.version_info < (3, 11): raise SystemExit(1)\nprint(sys.executable)'; + +function probePython(command) { + const result = spawnSync(command, ['-I', '-c', PYTHON_PROBE], { + encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], + }); + return result.status === 0 ? result.stdout.trim() : null; +} + +function run(command, args, env = process.env, check = true) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { env, stdio: ['inherit', check ? 2 : 'inherit', 'inherit'] }); + const interrupt = () => child.kill('SIGINT'); + const terminate = () => child.kill('SIGTERM'); + process.on('SIGINT', interrupt); + process.on('SIGTERM', terminate); + const cleanup = () => { + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', terminate); + }; + child.on('error', error => { cleanup(); reject(error); }); + child.on('close', (code, signal) => { + cleanup(); + const status = code ?? 128 + os.constants.signals[signal]; + if (check && status !== 0) { + const error = new Error(`${path.basename(command)} exited with status ${status}`); + error.exitCode = status; + reject(error); + } else { + resolve(status); + } + }); + }); +} + +function managedPython(uv, env) { + const result = spawnSync(uv, ['python', 'find', '--managed-python', '--no-python-downloads', '3.12'], { + env, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'], + }); + return result.status === 0 ? probePython(result.stdout.trim()) : null; +} + +async function python() { + for (const candidate of ['python3', 'python3.12', 'python']) { + const found = probePython(candidate); + if (found) return found; + } + + const base = process.env.XDG_CACHE_HOME || (process.platform === 'darwin' + ? path.join(os.homedir(), 'Library', 'Caches') : path.join(os.homedir(), '.cache')); + const cache = path.join(base, 'nanodot', 'npm'); + const uvDirectory = path.join(cache, `uv-${UV_VERSION}`); + const uv = path.join(uvDirectory, 'uv'); + const env = { + ...process.env, + UV_PYTHON_INSTALL_DIR: path.join(cache, 'python'), + UV_CACHE_DIR: path.join(cache, 'downloads'), + }; + if (fs.existsSync(uv)) { + const found = managedPython(uv, env); + if (found) return found; + } + + console.error('nanodot: Setting up Python for the first run. This requires internet access.'); + fs.mkdirSync(cache, { recursive: true, mode: 0o700 }); + if (!fs.existsSync(uv)) { + const temporary = fs.mkdtempSync(path.join(cache, 'setup-')); + const installer = path.join(temporary, 'install.sh'); + const installDirectory = path.join(temporary, 'uv'); + try { + await run('curl', ['-q', '--fail', '--location', '--silent', '--show-error', + '--connect-timeout', '15', '--max-time', '120', '--output', installer, INSTALLER_URL]); + await run('/bin/sh', [installer], { + ...process.env, UV_UNMANAGED_INSTALL: installDirectory, UV_NO_MODIFY_PATH: '1', + }); + // Publish a complete installation; simultaneous first runs can use the winner. + try { + fs.renameSync(installDirectory, uvDirectory); + } catch (error) { + if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error; + } + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + } + await run(uv, ['python', 'install', '--no-bin', '3.12'], env); + const found = managedPython(uv, env); + if (!found) throw new Error('Python setup did not produce a usable interpreter'); + return found; +} + +async function main() { + const executable = await python(); + const env = { + ...process.env, + PYTHONPATH: SOURCE + (process.env.PYTHONPATH ? path.delimiter + process.env.PYTHONPATH : ''), + PYTHONSAFEPATH: '1', + }; + // Insert the bundled source before the caller's working directory as well. + const entry = 'import sys; sys.path.insert(0, sys.argv.pop(1)); from nanodot.cli import main; raise SystemExit(main())'; + process.exitCode = await run(executable, ['-c', entry, SOURCE, ...process.argv.slice(2)], env, false); +} + +main().catch(error => { + console.error(`nanodot: ${error.message}. Install Python 3.11+ or retry setup with internet access.`); + process.exitCode = error.exitCode || 1; +}); diff --git a/docs/npm-packaging.md b/docs/npm-packaging.md new file mode 100644 index 0000000..70613be --- /dev/null +++ b/docs/npm-packaging.md @@ -0,0 +1,51 @@ +# npm packaging + +The npm package bundles the Python source from the same checkout and exposes +`nanodot` through a Node launcher. It has no npm dependencies or installation +hooks; installation also works with `--ignore-scripts`. Runtime preparation +happens on the first command that needs it. The bundled CLI is standard-library +only — `pyproject.toml` declares no runtime Python dependencies — so the +package ships the `.py` sources and needs no `pip install` step at run time. + +The launcher reuses Python 3.11+ from PATH or downloads Python 3.12 using uv's +official installer, pinned to uv 0.12.21. uv and Python are stored in nanodot's +runtime cache. The unmanaged installer and `--no-bin` Python installation keep +setup from changing shell profiles or creating global Python commands. +The download step requires `curl` on PATH. The CLI receives the original +arguments, working directory, environment, signals and exit status. Its +background runner inherits the bundled source path. + +## Validate and try a package + +```sh +npm ci --ignore-scripts --no-audit --no-fund +npm test +npm run test:package +npm run test:bootstrap +npm pack +npm install -g ./nanodot-0.1.0.tgz --ignore-scripts +nanodot --help +``` + +The tests cover reused Python, automatic setup and cache reuse, failed setup, +argument/environment forwarding, cooperative termination, and installation of +the packed archive outside the source checkout. The bootstrap smoke downloads +the real runtime, checks a cached restart, and verifies anonymous GitHub TLS. +It requires internet access. CI runs these checks on Linux and macOS. When the +MVP is present, the package smoke also starts and stops its background runner. +The existing Python suite remains the application behavior check. + +## Release + +Keep `package.json`, `pyproject.toml`, and `src/nanodot/__init__.py` versions in +sync. Run both Python and npm checks against the final source, inspect +`npm pack --dry-run`, and publish the tested package with an authorized npm +account: + +```sh +npm publish ./nanodot-0.1.0.tgz --access public +``` + +Publication is a separate maintainer action. This repository does not publish +packages automatically. The first full CLI release also needs the reviewed MVP +from PR #28 on main. diff --git a/npm-tests/bootstrap-smoke.cjs b/npm-tests/bootstrap-smoke.cjs new file mode 100644 index 0000000..23ecc4d --- /dev/null +++ b/npm-tests/bootstrap-smoke.cjs @@ -0,0 +1,65 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'nanodot npm bootstrap ')); +const launcher = path.resolve(__dirname, '..', 'bin', 'nanodot.cjs'); +const version = require('../package.json').version; +const preload = path.join(temporary, 'force-bootstrap.cjs'); +// Hide PATH Python probes only; all downloads and the managed interpreter are real. +fs.writeFileSync(preload, [ + "const cp = require('node:child_process');", + 'const original = cp.spawnSync;', + 'cp.spawnSync = function(command, args, options) {', + " if (['python3', 'python3.12', 'python'].includes(command) && args[0] === '-I')", + " return { status: 1, stdout: '' };", + ' return original.call(this, command, args, options);', + '};', +].join('\n')); +const env = { + ...process.env, XDG_CACHE_HOME: path.join(temporary, 'cache'), + NANODOT_HOME: path.join(temporary, 'data'), +}; + +function invoke(command, args, environment = env) { + const result = spawnSync(command, args, { + cwd: temporary, env: environment, encoding: 'utf8', timeout: 180000, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + return result; +} + +try { + const first = invoke(process.execPath, ['--require', preload, launcher, '--version']); + assert.equal(first.stdout.trim(), 'nanodot ' + version); + assert.match(first.stderr, /Setting up Python/); + const second = invoke(process.execPath, ['--require', preload, launcher, '--version']); + assert.equal(second.stdout.trim(), 'nanodot ' + version); + assert.equal(second.stderr, ''); + const cache = path.join(env.XDG_CACHE_HOME, 'nanodot', 'npm'); + const uvDirectory = fs.readdirSync(cache).find(name => name.startsWith('uv-')); + const found = invoke(path.join(cache, uvDirectory, 'uv'), + ['python', 'find', '--managed-python', '--no-python-downloads', '3.12'], + { ...env, UV_PYTHON_INSTALL_DIR: path.join(cache, 'python') }); + const python = found.stdout.trim(); + // Verify TLS trust in the downloaded runtime, needed for the public PR watcher. + invoke(python, ['-c', [ + 'from urllib.error import HTTPError', + 'from urllib.request import Request, urlopen', + 'request = Request("https://api.github.com/repos/ThinkFlowLab/nanodot",', + ' headers={"User-Agent": "nanodot-npm-smoke", "Accept": "application/vnd.github+json"})', + 'try:', + ' with urlopen(request, timeout=30) as response: assert response.status == 200', + 'except HTTPError:', + ' # An HTTP response verifies TLS even when anonymous API quota is exhausted.', + ' # Certificate and connection failures are URLError, and still fail this check.', + ' pass', + ].join('\n')]); + console.log('PASS real runtime download, cached restart, clean stdout and public GitHub TLS'); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/npm-tests/launcher.test.cjs b/npm-tests/launcher.test.cjs new file mode 100644 index 0000000..39ada0a --- /dev/null +++ b/npm-tests/launcher.test.cjs @@ -0,0 +1,226 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { once } = require('node:events'); +const test = require('node:test'); + +const LAUNCHER = path.resolve(__dirname, '..', 'bin', 'nanodot.cjs'); +const SOURCE = path.resolve(__dirname, '..', 'src'); + +function fakePython() { + const fs = require('node:fs'); + const path = require('node:path'); + const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'))); + const args = process.argv.slice(2); + if (args[0] === '-I') { + if (config.oldPython && path.basename(__filename) === 'python3') process.exit(1); + console.log(__filename); + process.exit(0); + } + fs.appendFileSync(path.join(ROOT, 'app.jsonl'), JSON.stringify({ + args, cwd: process.cwd(), pythonpath: process.env.PYTHONPATH, + dataHome: process.env.NANODOT_HOME, + input: config.readInput ? fs.readFileSync(0, 'utf8') : null, + }) + '\n'); + if (config.waitForSignal) { + process.on('SIGTERM', () => { + fs.writeFileSync(path.join(ROOT, 'terminated'), 'yes'); + process.exit(0); + }); + console.log('READY'); + setTimeout(() => process.exit(99), 10000); + } else { + console.log('fake nanodot'); + process.exit(config.exitCode || 0); + } +} + +function fakeUv() { + const fs = require('node:fs'); + const path = require('node:path'); + const args = process.argv.slice(2); + const python = path.join(process.env.UV_PYTHON_INSTALL_DIR, 'python'); + if (args[1] === 'find') { + if (!fs.existsSync(python)) process.exit(1); + console.log(python); + } else if (args[1] === 'install') { + if (!args.includes('--no-bin')) process.exit(2); + fs.appendFileSync(path.join(ROOT, 'python-installs'), 'install\n'); + fs.mkdirSync(path.dirname(python), { recursive: true }); + const temporary = python + '.' + process.pid; + fs.writeFileSync(temporary, fs.readFileSync(path.join(ROOT, 'python-stub')), { mode: 0o755 }); + fs.renameSync(temporary, python); + console.log('runtime setup output'); + } else { + process.exit(2); + } +} + +function fakeBootstrap() { + const fs = require('node:fs'); + const path = require('node:path'); + const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'))); + if (process.env.UV_NO_MODIFY_PATH !== '1') process.exit(3); + if (config.failSetup) process.exit(9); + fs.mkdirSync(process.env.UV_UNMANAGED_INSTALL, { recursive: true }); + fs.copyFileSync(path.join(ROOT, 'uv-stub'), path.join(process.env.UV_UNMANAGED_INSTALL, 'uv')); + console.log('installer output'); +} + +function fakeCurl() { + const fs = require('node:fs'); + const path = require('node:path'); + const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'))); + const args = process.argv.slice(2); + if (args[0] !== '-q' || args.at(-1) !== 'https://astral.sh/uv/0.12.21/install.sh') process.exit(3); + fs.appendFileSync(path.join(ROOT, 'downloads'), 'download\n'); + if (config.failDownload) process.exit(22); + fs.copyFileSync(path.join(ROOT, 'installer.sh'), args[args.indexOf('--output') + 1]); +} + +function fixture(t, options = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nanodot npm test ')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + const caller = path.join(root, 'caller'); + fs.mkdirSync(bin); + fs.mkdirSync(caller); + const script = fn => '#!' + process.execPath + '\nconst ROOT = ' + JSON.stringify(root) + + ';\n(' + fn.toString() + ')();\n'; + const write = (file, text) => fs.writeFileSync(file, text, { mode: 0o755 }); + write(path.join(root, 'python-stub'), script(fakePython)); + write(path.join(root, 'uv-stub'), script(fakeUv)); + write(path.join(root, 'bootstrap.cjs'), script(fakeBootstrap)); + write(path.join(bin, 'curl'), script(fakeCurl)); + const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"; + write(path.join(root, 'installer.sh'), 'exec ' + quote(process.execPath) + ' ' + + quote(path.join(root, 'bootstrap.cjs')) + '\n'); + fs.writeFileSync(path.join(root, 'config.json'), JSON.stringify(options)); + const env = { + ...process.env, PATH: bin, XDG_CACHE_HOME: path.join(root, 'cache'), + NANODOT_HOME: path.join(root, 'data'), PYTHONPATH: 'original-pythonpath', + }; + const invoke = (...args) => spawnSync(process.execPath, [LAUNCHER, ...args], { + cwd: caller, env, encoding: 'utf8', timeout: 15000, + }); + const warm = name => write(path.join(bin, name || 'python3'), script(fakePython)); + const config = value => fs.writeFileSync(path.join(root, 'config.json'), JSON.stringify(value)); + const count = name => fs.existsSync(path.join(root, name)) + ? fs.readFileSync(path.join(root, name), 'utf8').trim().split('\n').length : 0; + return { root, caller, env, invoke, warm, config, count }; +} + +test('existing Python preserves literal arguments, data home, cwd and CLI exit status', t => { + const f = fixture(t, { exitCode: 23 }); + f.warm(); + const args = ['watch', 'add', 'owner/repo#1; echo unsafe', '--purpose', 'a "quoted" purpose']; + const result = f.invoke(...args); + assert.equal(result.status, 23, result.stderr); + assert.equal(result.stderr, ''); + const call = JSON.parse(fs.readFileSync(path.join(f.root, 'app.jsonl'))); + assert.deepEqual(call.args.slice(3), args); + assert.equal(call.args[2], SOURCE); + assert.equal(call.cwd, fs.realpathSync(f.caller)); + assert.equal(call.dataHome, f.env.NANODOT_HOME); + assert.equal(call.pythonpath, SOURCE + path.delimiter + 'original-pythonpath'); + assert.equal(f.count('downloads'), 0); +}); + +test('an unsuitable Python does not hide an available supported interpreter', t => { + const f = fixture(t, { oldPython: true }); + f.warm(); + f.warm('python3.12'); + const result = f.invoke('--help'); + assert.equal(result.status, 0, result.stderr); + assert.equal(f.count('downloads'), 0); +}); + +test('stdin reaches the CLI for noninteractive secret entry', t => { + const f = fixture(t, { readInput: true }); + f.warm(); + const result = spawnSync(process.execPath, [LAUNCHER, 'config', 'set', 'github-token', '-'], { + cwd: f.caller, env: f.env, input: 'example-test-token\n', encoding: 'utf8', timeout: 15000, + }); + assert.equal(result.status, 0, result.stderr); + const call = JSON.parse(fs.readFileSync(path.join(f.root, 'app.jsonl'))); + assert.equal(call.input, 'example-test-token\n'); +}); + +test('first run prepares a private runtime and later runs reuse it quietly', t => { + const f = fixture(t); + const first = f.invoke('--version'); + assert.equal(first.status, 0, first.stderr); + assert.equal(first.stdout, 'fake nanodot\n'); + assert.match(first.stderr, /Setting up Python/); + assert.match(first.stderr, /runtime setup output/); + const second = f.invoke('--version'); + assert.equal(second.status, 0, second.stderr); + assert.equal(second.stderr, ''); + assert.equal(f.count('downloads'), 1); + assert.equal(f.count('python-installs'), 1); + const cache = path.join(f.env.XDG_CACHE_HOME, 'nanodot', 'npm'); + assert.deepEqual(fs.readdirSync(cache).sort(), ['python', 'uv-0.12.21']); +}); + +test('failed download reports failure and a subsequent command can retry', t => { + const f = fixture(t, { failDownload: true }); + const first = f.invoke('--help'); + assert.equal(first.status, 22); + assert.match(first.stderr, /Install Python 3.11\+ or retry/); + assert.equal(f.count('python-installs'), 0); + f.config({}); + assert.equal(f.invoke('--help').status, 0); + assert.equal(f.count('downloads'), 2); +}); + +test('a failed installer does not publish an incomplete cached executable', t => { + const f = fixture(t, { failSetup: true }); + assert.equal(f.invoke('--help').status, 9); + const cache = path.join(f.env.XDG_CACHE_HOME, 'nanodot', 'npm'); + assert.deepEqual(fs.readdirSync(cache), []); + f.config({}); + const result = f.invoke('--help'); + assert.equal(result.status, 0, result.stderr); +}); + +test('SIGTERM reaches the CLI and the launcher waits for cooperative completion', async t => { + const f = fixture(t, { waitForSignal: true }); + f.warm(); + const child = spawn(process.execPath, [LAUNCHER, 'runner'], { + cwd: f.caller, env: f.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); }); + let output = ''; + await new Promise((resolve, reject) => { + child.on('error', reject); + child.stdout.on('data', chunk => { + output += chunk; + if (output.includes('READY')) resolve(); + }); + child.once('exit', code => { if (!output.includes('READY')) reject(new Error('early exit ' + code)); }); + }); + const done = once(child, 'close'); + child.kill('SIGTERM'); + const [code] = await done; + assert.equal(code, 0); + assert.equal(fs.readFileSync(path.join(f.root, 'terminated'), 'utf8'), 'yes'); +}); + +test('simultaneous first runs both receive a complete cached runtime', async t => { + const f = fixture(t); + const invoke = () => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [LAUNCHER, '--help'], { cwd: f.caller, env: f.env }); + let error = ''; + child.stderr.on('data', chunk => { error += chunk; }); + child.on('error', reject); + child.on('close', code => resolve({ code, error })); + }); + const results = await Promise.all([invoke(), invoke()]); + for (const result of results) assert.equal(result.code, 0, result.error); + const cache = path.join(f.env.XDG_CACHE_HOME, 'nanodot', 'npm'); + assert.deepEqual(fs.readdirSync(cache).sort(), ['python', 'uv-0.12.21']); +}); diff --git a/npm-tests/package-smoke.cjs b/npm-tests/package-smoke.cjs new file mode 100644 index 0000000..d1477e3 --- /dev/null +++ b/npm-tests/package-smoke.cjs @@ -0,0 +1,63 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'nanodot npm package ')); +const npm = process.env.npm_execpath || require.resolve('npm/bin/npm-cli.js'); +const metadata = require('../package.json'); + +function run(command, args, cwd = temporary, env = process.env, expected = 0) { + const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', timeout: 180000 }); + assert.equal(result.status, expected, result.stdout + result.stderr); + return result.stdout; +} + +try { + const pyproject = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8'); + const version = fs.readFileSync(path.join(root, 'src/nanodot/__init__.py'), 'utf8'); + assert.equal(pyproject.match(/^version = "([^"]+)"/m)[1], metadata.version); + assert.equal(version.match(/__version__ = "([^"]+)"/)[1], metadata.version); + const packed = JSON.parse(run(process.execPath, [npm, 'pack', '--json', '--pack-destination', temporary], root))[0]; + const paths = packed.files.map(file => file.path); + assert(paths.includes('bin/nanodot.cjs')); + assert(paths.includes('src/nanodot/cli.py')); + assert(paths.every(file => ['package.json', 'README.md', 'bin/nanodot.cjs'].includes(file) + || (file.startsWith('src/nanodot/') && file.endsWith('.py'))), paths); + const archive = path.join(temporary, packed.filename); + const prefix = path.join(temporary, 'global prefix'); + run(process.execPath, [npm, 'install', '--global', '--prefix', prefix, '--ignore-scripts', + '--no-audit', '--no-fund', archive]); + const env = { ...process.env, NANODOT_HOME: path.join(temporary, 'data'), + XDG_CACHE_HOME: path.join(temporary, 'runtime-cache') }; + delete env.PYTHONPATH; + const cli = path.join(prefix, 'bin', 'nanodot'); + fs.writeFileSync(path.join(temporary, 'nanodot.py'), 'raise RuntimeError("wrong source loaded")\n'); + assert.match(run(cli, ['--help'], temporary, env), /usage: nanodot/); + assert.equal(run(cli, ['--version'], temporary, env).trim(), 'nanodot ' + metadata.version); + // Once the MVP is integrated, check the daemon's own Python subprocess too. + if (paths.includes('src/nanodot/native/runner_control.py')) { + try { + assert.match(run(cli, ['start'], temporary, env), /runner started/); + assert.match(run(cli, ['status'], temporary, env), /runner is running/); + } finally { + run(cli, ['stop'], temporary, env); + } + assert.match(run(cli, ['status'], temporary, env, 1), /runner is not running/); + console.log('PASS installed background runner inherits the packaged source outside the checkout'); + } + const output = run(process.execPath, [npm, 'exec', '--yes', '--cache', + path.join(temporary, 'npm-cache'), '--package', archive, '--', 'nanodot', '--version'], temporary, env); + assert.equal(output.trim(), 'nanodot ' + metadata.version); + console.log('PASS packed global install with --ignore-scripts, npx execution, payload and versions'); +} finally { + if (fs.existsSync(path.join(temporary, 'data', 'runner.pid'))) { + console.error('Runner cleanup was not confirmed; preserved test data at ' + temporary); + } else { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..de49e36 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "nanodot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nanodot", + "version": "0.1.0", + "license": "MIT", + "os": [ + "darwin", + "linux" + ], + "bin": { + "nanodot": "bin/nanodot.cjs" + }, + "engines": { + "node": ">=22" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..27ed7a0 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "nanodot", + "version": "0.1.0", + "description": "A local-first assistant with persistent memory and a read-only PR watcher", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ThinkFlowLab/nanodot.git" + }, + "bin": { + "nanodot": "bin/nanodot.cjs" + }, + "files": [ + "bin/nanodot.cjs", + "src/nanodot/**/*.py" + ], + "engines": { + "node": ">=22" + }, + "os": ["darwin", "linux"], + "scripts": { + "test": "node --test npm-tests/*.test.cjs", + "test:package": "node npm-tests/package-smoke.cjs", + "test:bootstrap": "node npm-tests/bootstrap-smoke.cjs" + } +}