Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/npm.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ __pycache__/
dist/
build/
.pytest_cache/
node_modules/
*.tgz
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
118 changes: 118 additions & 0 deletions bin/nanodot.cjs
Original file line number Diff line number Diff line change
@@ -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;
});
51 changes: 51 additions & 0 deletions docs/npm-packaging.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions npm-tests/bootstrap-smoke.cjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading