@robsonai/emilyremote 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROVENANCE.json +18 -0
- package/README.md +125 -0
- package/bin/emilyremote.cjs +6 -0
- package/lib/cli.cjs +282 -0
- package/package.json +13 -0
- package/scripts/verify_install.py +48 -0
- package/vendor/robsonai_emilyremote-0.3.0-py3-none-any.whl +0 -0
package/PROVENANCE.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "emilyremote.npm-provenance/1",
|
|
3
|
+
"display_name": "EmilyRemote",
|
|
4
|
+
"npm_package_name": "@robsonai/emilyremote",
|
|
5
|
+
"npm_name_status": "requested organization target; robsonai organization ownership and publication are pending verification",
|
|
6
|
+
"primary_command": "emilyremote",
|
|
7
|
+
"prior_distribution": {
|
|
8
|
+
"name": "@martinrobson/dialhome@0.2.0",
|
|
9
|
+
"status": "published; anonymous version metadata and tarball verified",
|
|
10
|
+
"tarball_sha256": "dfd56b3bd0f8e57b07559bf43551c05c58957732cd69e70c37a5fbbf86506326"
|
|
11
|
+
},
|
|
12
|
+
"sdk_version": "0.3.0",
|
|
13
|
+
"wheel": "vendor/robsonai_emilyremote-0.3.0-py3-none-any.whl",
|
|
14
|
+
"wheel_sha256": "e730b011b844dc70442fa793d29c98eb9df8dea5341ebbb49ba38fd73da0269e",
|
|
15
|
+
"source_changes": "Version 0.3.0 uses the emilyremote Python namespace and robsonai-emilyremote distribution, with release security fixes. Prior release test results do not certify this build.",
|
|
16
|
+
"scope": "Host Python runtime only; optional dependencies, Android APK, and microcontroller firmware are not installed by this npm wrapper",
|
|
17
|
+
"sdk_source": "RobsonAI EmilyRemote 0.3.0 release source, rebuilt after namespace and security changes"
|
|
18
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# EmilyRemote by RobsonAI
|
|
2
|
+
|
|
3
|
+
EmilyRemote installs the RobsonAI EmilyRemote 0.3.0 host SDK from a bundled,
|
|
4
|
+
verified Python wheel. The Python namespace is `emilyremote`, the Python
|
|
5
|
+
distribution is `robsonai-emilyremote`, and the command is `emilyremote`.
|
|
6
|
+
The requested npm publication target is **`@robsonai/emilyremote`**. The display
|
|
7
|
+
name is **EmilyRemote**; npm package names use lowercase. Organization ownership
|
|
8
|
+
and publication are pending verification. The examples below become public
|
|
9
|
+
installation commands after publication.
|
|
10
|
+
|
|
11
|
+
## Install and initialize
|
|
12
|
+
|
|
13
|
+
Requires Node.js 18 or newer and Python 3.11 or newer with `venv` and `ensurepip`.
|
|
14
|
+
The wrapper has no external Node dependencies. The SDK core has no external
|
|
15
|
+
Python dependencies.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @robsonai/emilyremote@0.3.0
|
|
19
|
+
emilyremote setup --python python3
|
|
20
|
+
emilyremote self-check
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or use npx:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx --yes --package=@robsonai/emilyremote@0.3.0 emilyremote setup --python python3
|
|
27
|
+
npx --yes --package=@robsonai/emilyremote@0.3.0 emilyremote self-check
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Installing the npm package runs no lifecycle hook. `setup` is an explicit action:
|
|
31
|
+
it creates a private, versioned virtualenv and installs the bundled wheel using
|
|
32
|
+
`pip --isolated --no-index --no-deps`. It checks the wheel SHA-256, installed
|
|
33
|
+
metadata, every installed SDK Python file, and the SDK self-check before writing
|
|
34
|
+
an atomic READY receipt. A failed attempt is retained for diagnosis and never
|
|
35
|
+
published as a ready installation. A setup lock prevents simultaneous installers.
|
|
36
|
+
If a process dies during setup, inspect the reported lock before removing it.
|
|
37
|
+
|
|
38
|
+
Setup does not install system Python, Node, optional dependencies, operating
|
|
39
|
+
system services, enroll a node, configure credentials, or start a worker.
|
|
40
|
+
On Debian/Ubuntu, Python may require its matching `python3-venv` system package.
|
|
41
|
+
The wrapper reports missing prerequisites and does not install them implicitly.
|
|
42
|
+
|
|
43
|
+
## Isolated installation or local tarball
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install --prefix ./npm-runtime --ignore-scripts --no-audit --no-fund ./robsonai-emilyremote-0.3.0.tgz
|
|
47
|
+
./npm-runtime/node_modules/.bin/emilyremote setup --home ./emilyremote-state --python /usr/bin/python3
|
|
48
|
+
./npm-runtime/node_modules/.bin/emilyremote --home ./emilyremote-state self-check
|
|
49
|
+
./npm-runtime/node_modules/.bin/emilyremote --home ./emilyremote-state status
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--home` or `EMILYREMOTE_HOME` selects the installation directory. Otherwise the
|
|
53
|
+
defaults are:
|
|
54
|
+
|
|
55
|
+
| Platform | Default |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Linux | `$XDG_DATA_HOME/robsonai/emilyremote`, otherwise `~/.local/share/robsonai/emilyremote` |
|
|
58
|
+
| macOS | `~/Library/Application Support/RobsonAI/EmilyRemote` |
|
|
59
|
+
| Windows | `%LOCALAPPDATA%\RobsonAI\EmilyRemote` |
|
|
60
|
+
|
|
61
|
+
Choose a directory owned by your account with trusted parent directories.
|
|
62
|
+
POSIX setup rejects writable shared ancestors, except root-owned sticky temporary
|
|
63
|
+
directories, and rejects symlinked ancestors. Windows ACL behavior still needs
|
|
64
|
+
native qualification; use the default per-user location.
|
|
65
|
+
|
|
66
|
+
Use the same home for setup and subsequent commands. A new npm invocation can
|
|
67
|
+
reuse the same verified runtime. `setup --python` accepts an executable path,
|
|
68
|
+
including spaces, rather than a shell command. Windows setup also probes the
|
|
69
|
+
`py -3` launcher when no executable is selected. PowerShell can invoke the npm
|
|
70
|
+
generated `emilyremote.cmd` shim; use a Python executable path for `--python`.
|
|
71
|
+
Only the `emilyremote` command is installed. The explicit npx `--package` form
|
|
72
|
+
selects that command directly. For an unpublished local tarball, use
|
|
73
|
+
`npx --offline --yes --package=./robsonai-emilyremote-0.3.0.tgz emilyremote --help`.
|
|
74
|
+
|
|
75
|
+
## Upgrading from the earlier package
|
|
76
|
+
|
|
77
|
+
The historical `@martinrobson/dialhome@0.2.0` distribution remains separate.
|
|
78
|
+
Version 0.3.0 installs the fully renamed EmilyRemote package and command without
|
|
79
|
+
adding a `dialhome` alias. Use `EMILYREMOTE_HOME` or the new default directory;
|
|
80
|
+
`DIALHOME_HOME` is not read. Keep old installation files and operational state
|
|
81
|
+
for reference until migration is verified. Create fresh runtime/node state and
|
|
82
|
+
explicitly re-enroll as needed; do not assume old identity databases, receipt
|
|
83
|
+
schemas, or running services migrate automatically. Installing this version
|
|
84
|
+
does not remove the prior package or change its services.
|
|
85
|
+
|
|
86
|
+
## SDK commands
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
emilyremote --home ./emilyremote-state node --config /absolute/path/node.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Arguments are passed directly without a shell, using isolated Python mode.
|
|
93
|
+
The caller's ordinary OS environment remains available to the SDK; Python path
|
|
94
|
+
injection and pip index environment variables are removed. `--` stops wrapper
|
|
95
|
+
option parsing. Node shutdown signals are forwarded to the Python process.
|
|
96
|
+
|
|
97
|
+
Only commands explicitly requested by the caller run. The SDK's shell capability
|
|
98
|
+
uses that process owner's OS permissions; this wrapper is not a sandbox. The
|
|
99
|
+
wrapper's `status` reports package readiness, not a running service or enrollment.
|
|
100
|
+
|
|
101
|
+
## Supported scope and validation
|
|
102
|
+
|
|
103
|
+
This package installs the Python host runtime on systems that can provide the
|
|
104
|
+
prerequisites. Linux package installation and self-check are verified for this
|
|
105
|
+
release. macOS and Windows paths and launcher handling are implemented but still
|
|
106
|
+
require native qualification. Core installation
|
|
107
|
+
does not include the optional `cryptography`, `pyserial`, or `pymavlink` packages,
|
|
108
|
+
local model weights, Android APK, Arduino/ESP32 firmware, or a controller service.
|
|
109
|
+
It makes no claim that Emily can dispatch a task until that integration is tested.
|
|
110
|
+
|
|
111
|
+
## Draft publication procedure
|
|
112
|
+
|
|
113
|
+
1. Verify the authenticated account has publish rights for the `robsonai`
|
|
114
|
+
organization and target `@robsonai/emilyremote@0.3.0`. Review licensing before
|
|
115
|
+
publishing; the package currently declares `UNLICENSED`.
|
|
116
|
+
2. Run `npm test`, `npm pack --json`, and test installation of the resulting
|
|
117
|
+
tarball into a fresh prefix and fresh EmilyRemote home.
|
|
118
|
+
3. Review the packed file list and provenance. It contains the SDK wheel and
|
|
119
|
+
wrapper, with no node credentials or private keys.
|
|
120
|
+
4. From the verified package directory, use `npm publish --access public` only
|
|
121
|
+
after publication is authorized and the registry authentication is ready.
|
|
122
|
+
5. Verify the registry tarball integrity and repeat installation using the public
|
|
123
|
+
package name. Do not describe the npm URL as live before that check succeeds.
|
|
124
|
+
|
|
125
|
+
`PROVENANCE.json` records the release wheel identity and historical distribution.
|
package/lib/cli.cjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
const VERSION = '0.3.0';
|
|
10
|
+
const WHEEL_SHA = 'e730b011b844dc70442fa793d29c98eb9df8dea5341ebbb49ba38fd73da0269e';
|
|
11
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
12
|
+
const WHEEL = path.join(PACKAGE_ROOT, 'vendor', 'robsonai_emilyremote-0.3.0-py3-none-any.whl');
|
|
13
|
+
const VERIFY = path.join(PACKAGE_ROOT, 'scripts', 'verify_install.py');
|
|
14
|
+
|
|
15
|
+
const HELP = `EmilyRemote by RobsonAI ${VERSION}
|
|
16
|
+
|
|
17
|
+
emilyremote setup [--python PATH] [--home DIRECTORY]
|
|
18
|
+
emilyremote [--home DIRECTORY] <SDK command> [arguments...]
|
|
19
|
+
emilyremote status [--home DIRECTORY]
|
|
20
|
+
|
|
21
|
+
Setup explicitly installs the bundled core SDK into your own versioned Python
|
|
22
|
+
virtualenv. Python 3.11+ with venv/ensurepip is required. Installation is offline.
|
|
23
|
+
Installing this npm package does not start setup, services, or enrollment.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
emilyremote setup --python python3
|
|
27
|
+
emilyremote self-check
|
|
28
|
+
emilyremote node --config /path/to/node.json
|
|
29
|
+
|
|
30
|
+
--home overrides EMILYREMOTE_HOME and the platform's per-user data directory.
|
|
31
|
+
--python selects a Python executable only for setup. Use -- to stop wrapper
|
|
32
|
+
option parsing. Other arguments pass directly to the SDK without a shell.
|
|
33
|
+
Optional update/serial/vehicle dependencies are not bundled.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
let home, python;
|
|
38
|
+
const rest = [];
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
if (argv[i] === '--') { rest.push(...argv.slice(i + 1)); break; }
|
|
41
|
+
if (argv[i] === '--home' || argv[i] === '--python') {
|
|
42
|
+
const flag = argv[i];
|
|
43
|
+
if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
44
|
+
if (flag === '--home') { if (home !== undefined) throw new Error('Duplicate --home'); home = argv[++i]; }
|
|
45
|
+
else { if (python !== undefined) throw new Error('Duplicate --python'); python = argv[++i]; }
|
|
46
|
+
} else rest.push(argv[i]);
|
|
47
|
+
}
|
|
48
|
+
if (python !== undefined && rest[0] !== 'setup') throw new Error('--python is supported only for setup');
|
|
49
|
+
return { home, python, command: rest[0], args: rest.slice(1) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultHome(env = process.env, platform = process.platform, userHome = os.homedir()) {
|
|
53
|
+
if (env.EMILYREMOTE_HOME) return path.resolve(env.EMILYREMOTE_HOME);
|
|
54
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(userHome, 'AppData', 'Local'), 'RobsonAI', 'EmilyRemote');
|
|
55
|
+
if (platform === 'darwin') return path.join(userHome, 'Library', 'Application Support', 'RobsonAI', 'EmilyRemote');
|
|
56
|
+
return path.join(env.XDG_DATA_HOME || path.join(userHome, '.local', 'share'), 'robsonai', 'emilyremote');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sha(file) { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); }
|
|
60
|
+
function checkWheel() { if (sha(WHEEL) !== WHEEL_SHA) throw new Error('Bundled SDK wheel failed its SHA-256 check; reinstall this npm package'); }
|
|
61
|
+
function pythonPath(runtime, platform = process.platform) { return path.join(runtime, platform === 'win32' ? 'Scripts' : 'bin', platform === 'win32' ? 'python.exe' : 'python'); }
|
|
62
|
+
function cleanEnv(env = process.env) {
|
|
63
|
+
const copy = { ...env };
|
|
64
|
+
// SDK children retain normal OS environment, but never inherit Python path
|
|
65
|
+
// injection or pip index settings from the calling npm process.
|
|
66
|
+
for (const key of Object.keys(copy)) if (/^(PYTHON|PIP_)/i.test(key)) delete copy[key];
|
|
67
|
+
copy.PYTHONDONTWRITEBYTECODE = '1';
|
|
68
|
+
return copy;
|
|
69
|
+
}
|
|
70
|
+
function privateDir(directory) { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); }
|
|
71
|
+
function checkHomeAncestors(home) {
|
|
72
|
+
// A private leaf is insufficient if another account can replace a parent.
|
|
73
|
+
// Root-owned sticky temporary directories protect an owned child from rename.
|
|
74
|
+
let current = path.dirname(path.resolve(home));
|
|
75
|
+
while (true) {
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.lstatSync(current);
|
|
78
|
+
if (stat.isSymbolicLink()) throw new Error(`EmilyRemote home requires real directory ancestors; resolve this symlink first: ${current}`);
|
|
79
|
+
if (!stat.isDirectory()) throw new Error(`EmilyRemote home ancestor is not a directory: ${current}`);
|
|
80
|
+
if (process.platform !== 'win32') {
|
|
81
|
+
if (![0, process.getuid()].includes(stat.uid)) throw new Error(`EmilyRemote home ancestor is not owned by this user or root: ${current}`);
|
|
82
|
+
const protectedTemporary = stat.uid === 0 && (stat.mode & 0o1000);
|
|
83
|
+
if ((stat.mode & 0o022) && !protectedTemporary) throw new Error(`EmilyRemote home ancestor is writable by other users: ${current}`);
|
|
84
|
+
}
|
|
85
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
86
|
+
const parent = path.dirname(current);
|
|
87
|
+
if (parent === current) break;
|
|
88
|
+
current = parent;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checkHomePath(home, target) {
|
|
92
|
+
checkHomeAncestors(home);
|
|
93
|
+
const relative = path.relative(home, target);
|
|
94
|
+
if (relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative)) throw new Error('Path escapes EmilyRemote home');
|
|
95
|
+
let current = home;
|
|
96
|
+
for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
|
|
97
|
+
if (segment) current = path.join(current, segment);
|
|
98
|
+
try {
|
|
99
|
+
const stat = fs.lstatSync(current);
|
|
100
|
+
if (stat.isSymbolicLink()) throw new Error(`Symlink refused inside EmilyRemote home: ${current}`);
|
|
101
|
+
if (process.platform !== 'win32' && stat.uid !== process.getuid()) throw new Error(`EmilyRemote state must belong to the current user: ${current}`);
|
|
102
|
+
if (process.platform !== 'win32' && (stat.mode & 0o022)) throw new Error(`EmilyRemote state must not be writable by other users: ${current}`);
|
|
103
|
+
}
|
|
104
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function homeDir(home, target) { checkHomePath(home, target); privateDir(target); }
|
|
108
|
+
function writeJsonAtomic(destination, value) {
|
|
109
|
+
privateDir(path.dirname(destination));
|
|
110
|
+
const temp = `${destination}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
111
|
+
let fd;
|
|
112
|
+
try {
|
|
113
|
+
fd = fs.openSync(temp, 'wx', 0o600);
|
|
114
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, 2) + '\n');
|
|
115
|
+
fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
116
|
+
fs.renameSync(temp, destination);
|
|
117
|
+
if (process.platform !== 'win32') { const parent = fs.openSync(path.dirname(destination), 'r'); try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); } }
|
|
118
|
+
} finally {
|
|
119
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
120
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function runChecked(executable, args, options = {}, runner = spawnSync) {
|
|
125
|
+
const result = runner(executable, args, { shell: false, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
|
|
126
|
+
timeout: 120000, windowsHide: true, env: cleanEnv(), ...options });
|
|
127
|
+
if (result.error) throw new Error(`Could not run ${path.basename(executable)}: ${result.error.code || result.error.message}`);
|
|
128
|
+
if (result.status !== 0) throw new Error(`Command failed (${result.status === null ? result.signal : result.status}): ${(result.stderr || result.stdout || '').trim().slice(-3000)}`);
|
|
129
|
+
return result.stdout || '';
|
|
130
|
+
}
|
|
131
|
+
function discoverPython(selected, runner = spawnSync) {
|
|
132
|
+
const candidates = selected ? [{ executable: selected, prefix: [] }] : process.platform === 'win32'
|
|
133
|
+
? [{ executable: 'py', prefix: ['-3'] }, { executable: 'python', prefix: [] }]
|
|
134
|
+
: [{ executable: 'python3', prefix: [] }, { executable: 'python', prefix: [] }];
|
|
135
|
+
const problems = [];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
try {
|
|
138
|
+
const output = runChecked(candidate.executable, [...candidate.prefix, '-I', '-B', '-c',
|
|
139
|
+
'import json,sys; print(json.dumps({"version":list(sys.version_info[:3]),"executable":sys.executable}))'], { timeout: 15000 }, runner);
|
|
140
|
+
const info = JSON.parse(output);
|
|
141
|
+
if (info.version[0] !== 3 || info.version[1] < 11) throw new Error(`Python ${info.version.join('.')} is too old`);
|
|
142
|
+
if (!path.isAbsolute(info.executable)) throw new Error('Python did not report an absolute executable path');
|
|
143
|
+
return info;
|
|
144
|
+
} catch (error) { problems.push(`${candidate.executable}: ${error.message}`); }
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Python 3.11+ is required. Install it with venv/ensurepip support, then run emilyremote setup --python /absolute/path/to/python. ${problems.join('; ')}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function receiptPath(home) { return path.join(home, 'receipts', `${VERSION}.json`); }
|
|
150
|
+
function readReceipt(home) {
|
|
151
|
+
const file = receiptPath(home);
|
|
152
|
+
checkHomePath(home, file);
|
|
153
|
+
if (!fs.existsSync(file)) throw new Error('SDK is not installed here. Run emilyremote setup first');
|
|
154
|
+
const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
155
|
+
if (receipt.state !== 'READY' || receipt.version !== VERSION || receipt.wheel_sha256 !== WHEEL_SHA) throw new Error('Installation receipt is not READY for this SDK version; run emilyremote setup');
|
|
156
|
+
if (typeof receipt.runtime_relative !== 'string' || path.isAbsolute(receipt.runtime_relative)) throw new Error('Invalid runtime path in installation receipt');
|
|
157
|
+
const root = path.resolve(home, 'runtimes', VERSION);
|
|
158
|
+
const runtime = path.resolve(home, receipt.runtime_relative);
|
|
159
|
+
if (!runtime.startsWith(root + path.sep) || runtime === root) throw new Error('Runtime path escapes the versioned installation directory');
|
|
160
|
+
checkHomePath(home, runtime);
|
|
161
|
+
if (!fs.existsSync(runtime) || !fs.realpathSync(runtime).startsWith(fs.realpathSync(root) + path.sep)) throw new Error('Runtime directory is missing or escapes its installation');
|
|
162
|
+
return { receipt, runtime };
|
|
163
|
+
}
|
|
164
|
+
function verifyInstalled(runtime, runner = spawnSync) {
|
|
165
|
+
checkWheel();
|
|
166
|
+
return JSON.parse(runChecked(pythonPath(runtime), ['-I', '-B', VERIFY, '--wheel', WHEEL, '--runtime', runtime], { timeout: 30000 }, runner));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function setup(home, selectedPython, { runner = spawnSync, log = console.log } = {}) {
|
|
170
|
+
checkWheel();
|
|
171
|
+
homeDir(home, home);
|
|
172
|
+
const lock = path.join(home, '.setup-lock');
|
|
173
|
+
checkHomePath(home, lock);
|
|
174
|
+
try { fs.mkdirSync(lock, { mode: 0o700 }); } catch (error) {
|
|
175
|
+
if (error.code === 'EEXIST') throw new Error(`Setup is already running or was interrupted. Inspect ${lock} before removing that lock and trying again`);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
let runtime;
|
|
179
|
+
try {
|
|
180
|
+
writeJsonAtomic(path.join(lock, 'owner.json'), { pid: process.pid, started_at: new Date().toISOString() });
|
|
181
|
+
try {
|
|
182
|
+
const existing = readReceipt(home);
|
|
183
|
+
verifyInstalled(existing.runtime, runner);
|
|
184
|
+
log(JSON.stringify({ state: 'READY', already_installed: true, version: VERSION, runtime: existing.runtime, receipt: receiptPath(home) }));
|
|
185
|
+
return 0;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (fs.existsSync(receiptPath(home))) {
|
|
188
|
+
checkHomePath(home, receiptPath(home));
|
|
189
|
+
log(`Existing installation needs repair: ${error.message}`);
|
|
190
|
+
// A failed replacement must never leave an invalid READY receipt.
|
|
191
|
+
fs.renameSync(receiptPath(home), path.join(home, 'receipts', `${VERSION}-invalidated-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.json`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const python = discoverPython(selectedPython, runner);
|
|
195
|
+
const attempt = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
|
|
196
|
+
runtime = path.join(home, 'runtimes', VERSION, attempt);
|
|
197
|
+
homeDir(home, path.dirname(runtime));
|
|
198
|
+
checkHomePath(home, runtime);
|
|
199
|
+
const temporary = path.join(home, 'tmp', attempt);
|
|
200
|
+
homeDir(home, temporary);
|
|
201
|
+
const environment = { ...cleanEnv(), TMPDIR: temporary, TMP: temporary, TEMP: temporary,
|
|
202
|
+
// pip compares against Python's os.devnull exactly; Node's Windows
|
|
203
|
+
// spelling (\\\\.\\nul) differs from Python's "nul".
|
|
204
|
+
PIP_CONFIG_FILE: process.platform === 'win32' ? 'nul' : '/dev/null' };
|
|
205
|
+
log(`Installing EmilyRemote ${VERSION} offline into ${runtime}`);
|
|
206
|
+
try {
|
|
207
|
+
runChecked(python.executable, ['-I', '-B', '-m', 'venv', runtime], { env: environment }, runner);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(`Unable to create the private virtualenv. Python must include venv and ensurepip (on Debian/Ubuntu the matching python3-venv package). No system packages were installed. ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
const executable = pythonPath(runtime);
|
|
212
|
+
const pipReport = path.join(home, 'receipts', `${attempt}-pip.json`);
|
|
213
|
+
homeDir(home, path.dirname(pipReport));
|
|
214
|
+
checkHomePath(home, pipReport);
|
|
215
|
+
const pipOutput = runChecked(executable, ['-I', '-B', '-m', 'pip', '--isolated', 'install',
|
|
216
|
+
'--no-index', '--no-deps', '--no-compile', '--no-cache-dir', '--disable-pip-version-check',
|
|
217
|
+
'--report', pipReport, WHEEL], { env: environment }, runner);
|
|
218
|
+
fs.writeFileSync(path.join(home, 'receipts', `${attempt}-install.log`), pipOutput, { mode: 0o600 });
|
|
219
|
+
const verification = verifyInstalled(runtime, runner);
|
|
220
|
+
const selfCheck = JSON.parse(runChecked(executable, ['-I', '-B', '-m', 'emilyremote', 'self-check'], { env: environment }, runner));
|
|
221
|
+
if (selfCheck.ok !== true || selfCheck.version !== VERSION) throw new Error('Installed SDK self-check did not pass');
|
|
222
|
+
const receipt = { schema: 'emilyremote.npm-installation/1', state: 'READY', version: VERSION,
|
|
223
|
+
installed_at: new Date().toISOString(), wheel_sha256: WHEEL_SHA,
|
|
224
|
+
runtime_relative: path.relative(home, runtime), python: python.version, verification, self_check: selfCheck,
|
|
225
|
+
pip_report_relative: path.relative(home, pipReport),
|
|
226
|
+
scope: 'Core package installed only; no service, enrollment, task dispatch, or optional dependencies' };
|
|
227
|
+
writeJsonAtomic(receiptPath(home), receipt);
|
|
228
|
+
log(JSON.stringify({ state: 'READY', version: VERSION, runtime, receipt: receiptPath(home) }));
|
|
229
|
+
return 0;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (runtime) {
|
|
232
|
+
const failure = path.join(home, 'receipts', `${path.basename(runtime)}-failed.json`);
|
|
233
|
+
try {
|
|
234
|
+
checkHomePath(home, failure);
|
|
235
|
+
writeJsonAtomic(failure, { state: 'FAILED', version: VERSION, runtime_relative: path.relative(home, runtime),
|
|
236
|
+
failed_at: new Date().toISOString(), error: error.message,
|
|
237
|
+
note: 'This attempt was not published as a READY installation. Partial files retained for diagnosis.' });
|
|
238
|
+
} catch (_) { /* Preserve the original error; never write through an unsafe path. */ }
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
} finally {
|
|
242
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function delegate(runtime, args, spawner = spawn) {
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const child = spawner(pythonPath(runtime), ['-I', '-B', '-m', 'emilyremote', ...args],
|
|
249
|
+
{ shell: false, stdio: 'inherit', env: cleanEnv(), windowsHide: true });
|
|
250
|
+
const forward = (signal) => { try { child.kill(signal); } catch (_) {} };
|
|
251
|
+
const onInt = () => forward('SIGINT');
|
|
252
|
+
const onTerm = () => forward('SIGTERM');
|
|
253
|
+
process.on('SIGINT', onInt); process.on('SIGTERM', onTerm);
|
|
254
|
+
const cleanup = () => { process.off('SIGINT', onInt); process.off('SIGTERM', onTerm); };
|
|
255
|
+
child.once('error', (error) => { cleanup(); reject(new Error(`Unable to launch installed SDK: ${error.message}`)); });
|
|
256
|
+
child.once('exit', (code, signal) => { cleanup(); resolve(code === null ? signal === 'SIGINT' ? 130 : 143 : code); });
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function main(argv = process.argv.slice(2)) {
|
|
261
|
+
const parsed = parseArgs(argv);
|
|
262
|
+
if (!parsed.command || ['--help', '-h', 'help'].includes(parsed.command)) { console.log(HELP); return 0; }
|
|
263
|
+
if (parsed.command === '--version') { console.log(VERSION); return 0; }
|
|
264
|
+
const home = path.resolve(parsed.home || defaultHome());
|
|
265
|
+
if (parsed.command === 'setup') {
|
|
266
|
+
if (parsed.args.includes('--help') || parsed.args.includes('-h')) { console.log(HELP); return 0; }
|
|
267
|
+
if (parsed.args.length) throw new Error('Unknown setup arguments; use emilyremote setup --help');
|
|
268
|
+
return setup(home, parsed.python);
|
|
269
|
+
}
|
|
270
|
+
const installed = readReceipt(home);
|
|
271
|
+
const verification = verifyInstalled(installed.runtime);
|
|
272
|
+
if (parsed.command === 'status') {
|
|
273
|
+
if (parsed.args.length) throw new Error('status does not accept SDK arguments');
|
|
274
|
+
console.log(JSON.stringify({ state: 'READY', version: VERSION, runtime: installed.runtime,
|
|
275
|
+
receipt: receiptPath(home), verification, service_started_by_wrapper: false }, null, 2));
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
return delegate(installed.runtime, [parsed.command, ...parsed.args]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { main, parseArgs, defaultHome, cleanEnv, checkHomeAncestors, checkHomePath, runChecked, discoverPython, readReceipt,
|
|
282
|
+
verifyInstalled, setup, delegate, pythonPath, VERSION, WHEEL_SHA, WHEEL, receiptPath };
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@robsonai/emilyremote",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "EmilyRemote by RobsonAI: explicit offline installer and command wrapper for the EmilyRemote host SDK",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"engines": {"node": ">=18"},
|
|
7
|
+
"bin": {"emilyremote": "bin/emilyremote.cjs"},
|
|
8
|
+
"files": ["bin/", "lib/", "scripts/", "vendor/", "README.md", "PROVENANCE.json"],
|
|
9
|
+
"scripts": {"test": "node --test test/*.test.cjs"},
|
|
10
|
+
"publishConfig": {"access": "public"},
|
|
11
|
+
"keywords": ["emilyremote", "robsonai", "edge", "sdk"],
|
|
12
|
+
"author": "RobsonAI"
|
|
13
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Read-only installed-wheel verification. Invoked with isolated Python -I -B."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
EXPECTED_SHA = "e730b011b844dc70442fa793d29c98eb9df8dea5341ebbb49ba38fd73da0269e"
|
|
13
|
+
parser = argparse.ArgumentParser()
|
|
14
|
+
parser.add_argument("--wheel", required=True)
|
|
15
|
+
parser.add_argument("--runtime", required=True)
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
assert sys.version_info >= (3, 11), "Python 3.11+ is required"
|
|
18
|
+
wheel = Path(args.wheel)
|
|
19
|
+
assert hashlib.sha256(wheel.read_bytes()).hexdigest() == EXPECTED_SHA, "Bundled wheel mismatch"
|
|
20
|
+
runtime = Path(args.runtime).resolve()
|
|
21
|
+
assert Path(sys.prefix).resolve() == runtime, "Python is not running in the selected virtualenv"
|
|
22
|
+
assert sys.prefix != sys.base_prefix, "Global Python is not an isolated runtime"
|
|
23
|
+
assert importlib.metadata.version("robsonai-emilyremote") == "0.3.0", "Installed metadata version mismatch"
|
|
24
|
+
spec = importlib.util.find_spec("emilyremote")
|
|
25
|
+
assert spec and spec.origin and spec.origin.endswith("__init__.py"), "SDK did not resolve to Python source"
|
|
26
|
+
package = Path(spec.origin).resolve().parent
|
|
27
|
+
assert runtime in package.parents, "SDK import came from outside its virtualenv"
|
|
28
|
+
hashes = {}
|
|
29
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
30
|
+
for name in archive.namelist():
|
|
31
|
+
if not name.startswith("emilyremote/") or not name.endswith(".py"):
|
|
32
|
+
continue
|
|
33
|
+
relative = name[len("emilyremote/"):]
|
|
34
|
+
actual = package / relative
|
|
35
|
+
assert package in actual.resolve().parents, "Source path escapes installed SDK"
|
|
36
|
+
expected = hashlib.sha256(archive.read(name)).hexdigest()
|
|
37
|
+
assert hashlib.sha256(actual.read_bytes()).hexdigest() == expected, "Installed source mismatch: " + name
|
|
38
|
+
hashes[name] = expected
|
|
39
|
+
actual_names = {"emilyremote/" + p.relative_to(package).as_posix() for p in package.rglob("*.py")}
|
|
40
|
+
assert actual_names == set(hashes), "Installed SDK source set differs from the wheel"
|
|
41
|
+
all_files = {"emilyremote/" + p.relative_to(package).as_posix() for p in package.rglob("*") if p.is_file()}
|
|
42
|
+
assert all_files == set(hashes), "Unexpected installed SDK files (including bytecode or native modules)"
|
|
43
|
+
import emilyremote
|
|
44
|
+
assert emilyremote.__version__ == "0.3.0", "Runtime version mismatch"
|
|
45
|
+
print(json.dumps({"ok": True, "version": emilyremote.__version__, "python": sys.version.split()[0],
|
|
46
|
+
"executable": sys.executable, "runtime": str(runtime), "package": str(package),
|
|
47
|
+
"wheel_sha256": EXPECTED_SHA, "source_hashes": hashes,
|
|
48
|
+
"uid": os.getuid() if hasattr(os, "getuid") else None}, sort_keys=True))
|
|
Binary file
|