@karma42/agent 0.0.0-stage → 0.1.0-beta.2
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/README.md +102 -2
- package/package.json +32 -4
- package/runtime/AGENT_PROMPT_V2.md +115 -0
- package/runtime/claude-settings.json +82 -0
- package/runtime/control-v2.mjs +358 -0
- package/runtime/launcher.mjs +55 -0
- package/runtime/manifest.json +12 -0
- package/runtime/providers.mjs +284 -0
- package/runtime/runner.mjs +591 -0
- package/runtime/runtime.mjs +67 -0
- package/src/cli.mjs +89 -0
- package/src/connection.mjs +214 -0
- package/src/doctor.mjs +28 -0
- package/src/plan.mjs +88 -0
- package/src/session-stdio.mjs +93 -0
- package/src/session.mjs +110 -0
- package/src/state.mjs +93 -0
- package/src/supervisor.mjs +17 -0
- package/src/system.mjs +75 -0
- package/src/terminal.mjs +33 -0
package/src/session.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { readdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { makePlan, VERSION } from './plan.mjs';
|
|
5
|
+
import { canonicalPath, connectionDir, digest, ensureHome, list, load, lstatExists, privateDir, readPrivate, save, withLock, writePrivate } from './state.mjs';
|
|
6
|
+
import { system } from './system.mjs';
|
|
7
|
+
|
|
8
|
+
export const sessionHome = () => join(homedir(), '.karma42', 'sessions');
|
|
9
|
+
const TOKEN = /^[\w-]{43}$/;
|
|
10
|
+
// Include the service origin: the same project/agent IDs on a local service and production are distinct.
|
|
11
|
+
export const sessionId = (service, agentId) => `${digest(service)}/${agentId}`;
|
|
12
|
+
export const sessionName = record => `karma42-${digest([record.service, record.agentId]).slice(0, 24)}`;
|
|
13
|
+
export function validateSession(record, id) {
|
|
14
|
+
if (record?.schema !== 'client-session-1' || record.id !== id || record.id !== sessionId(record.service, record.agentId) || record.packageVersion !== VERSION
|
|
15
|
+
|| !/^[\w-]{1,200}$/.test(record.agentId ?? '') || !/^[\w-]{1,64}$/.test(record.agentName ?? '')) throw new Error('Invalid session identity. Reconnect using My agents.');
|
|
16
|
+
const plan = makePlan({ provider: record.provider, mode: 'guarded', projectUrl: record.service, repo: record.repo, folder: record.folder });
|
|
17
|
+
if (plan.projectUrl !== record.service || canonicalPath(record.folder) !== record.folder) throw new Error('Session workspace changed. Review the connection again.');
|
|
18
|
+
if (record.consent !== digest([record.id, record.service, record.agentName, record.repo, record.folder, record.provider, record.packageVersion])) throw new Error('Session approval is missing or changed. Review the connection again.');
|
|
19
|
+
return record;
|
|
20
|
+
}
|
|
21
|
+
export function sessionConfiguration(record, home = sessionHome()) {
|
|
22
|
+
validateSession(record, record.id);
|
|
23
|
+
const name = sessionName(record);
|
|
24
|
+
// Client launches only the pinned bridge, never a model. No credential is in configuration.
|
|
25
|
+
const args = ['--yes', '--ignore-scripts', `@karma42/agent@${record.packageVersion}`, 'session-serve', '--connection', record.id, '--state-dir', home];
|
|
26
|
+
const entry = { command: 'npx', args };
|
|
27
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
28
|
+
return { name, workspace: record.folder, agent: record.agentName, project: record.service,
|
|
29
|
+
claudeCommand: ['claude', 'mcp', 'add', '--scope', 'local', name, '--', 'npx', ...args].map(quote).join(' '),
|
|
30
|
+
claude: { mcpServers: { [name]: entry } },
|
|
31
|
+
codex: `[mcp_servers.${name}]\ncommand = "npx"\nargs = ${JSON.stringify(args)}\nstartup_timeout_sec = 30\n`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function createSessionOperations({ home = sessionHome(), io, sys = system } = {}) {
|
|
35
|
+
home = canonicalPath(home);
|
|
36
|
+
const confirm = async text => { if (!io.interactive || !await io.confirm(text)) throw new Error('Cancelled. No coding agent or MCP connection was started.'); };
|
|
37
|
+
async function finish(dir, receipt, locked = false) {
|
|
38
|
+
const action = async () => {
|
|
39
|
+
if (!TOKEN.test(receipt.token ?? '') || !/^[\w-]{1,200}$/.test(receipt.agentId ?? '') || !/^[\w-]{1,64}$/.test(receipt.agentName ?? '')) throw new Error('Invalid private pairing receipt. Generate a fresh code.');
|
|
40
|
+
const plan = makePlan({ provider: receipt.provider, mode: 'guarded', projectUrl: receipt.service, repo: receipt.repo, folder: receipt.folder });
|
|
41
|
+
const id = sessionId(plan.projectUrl, receipt.agentId);
|
|
42
|
+
if (connectionDir(home, id) !== dir) throw new Error('Recovery identity mismatch.');
|
|
43
|
+
io.say(`Agent: ${receipt.agentName}\nProject: ${plan.projectUrl}\nRepository: ${plan.repo}\nWorkspace: ${plan.folder}\nYour existing client controls model usage, filesystem access and approvals. The package will not change permission settings or launch another coding agent. Native MCP trust prompts remain yours to approve. GitHub access and repository checkout have not been checked.`);
|
|
44
|
+
await confirm('I will supervise this session, review changes and grant only the permissions I intend. Save this named connection?');
|
|
45
|
+
const record = { schema: 'client-session-1', id, service: plan.projectUrl, agentId: receipt.agentId, agentName: receipt.agentName, repo: plan.repo, folder: plan.folder, provider: plan.provider, packageVersion: VERSION, createdAt: sys.now() };
|
|
46
|
+
record.consent = digest([id, record.service, record.agentName, record.repo, record.folder, record.provider, VERSION]);
|
|
47
|
+
// Preserve old journals on re-pair; they are credential-bound and use separate names.
|
|
48
|
+
writePrivate(join(dir, 'token'), receipt.token); save(dir, record);
|
|
49
|
+
unlinkSync(join(dir, 'pending-session.json'));
|
|
50
|
+
const config = sessionConfiguration(record, home);
|
|
51
|
+
io.say(`Saved session connection: ${id}\nMCP entry: ${config.name}\nAdd this entry ONLY to the current workspace’s MCP configuration, preserving other entries. Claude Code: run the local-scope command from the workspace below; native trust prompts remain yours. Codex: merge the TOML entry into ${record.folder}/.codex/config.toml in this trusted workspace. Never replace the entire config or change approval settings.\n${record.provider === 'claude' ? config.claudeCommand : config.codex}\nAsk this current agent to use ${config.name}, call get_project and verify agent ${record.agentName}. If the tools are missing, reload the client’s MCP configuration and return to this conversation. No separate start command is needed.\nEach project/agent has its own entry. Only one active connection may use this identity; another agent needs its own code/name. Closing a chat does not necessarily close the client’s MCP process. Disconnect this named MCP entry to release it.`);
|
|
52
|
+
return record;
|
|
53
|
+
};
|
|
54
|
+
return locked ? action() : withLock(dir, action);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
async connect(options) {
|
|
58
|
+
const folder = canonicalPath(resolve(options.folder ?? process.cwd()));
|
|
59
|
+
const plan = makePlan({ provider: options.provider, mode: 'guarded', projectUrl: options['project-url'], repo: 'https://github.com/placeholder/project', folder });
|
|
60
|
+
if (options['agent-id'] !== undefined && !/^[\w-]{1,200}$/.test(options['agent-id'])) throw new Error('Choose a valid agent ID.');
|
|
61
|
+
sys.verifyBundle();
|
|
62
|
+
ensureHome(home);
|
|
63
|
+
return withLock(home, async () => {
|
|
64
|
+
const expected = options['agent-id'];
|
|
65
|
+
const expectedDir = expected ? connectionDir(home, sessionId(plan.projectUrl, expected)) : null;
|
|
66
|
+
const pair = async () => {
|
|
67
|
+
await confirm('Connect your current coding session to this project? Enter the setup code privately; never paste it into your AI chat. This rotates this agent’s credential: stop its previous connection first.');
|
|
68
|
+
const code = await io.secret('One-time setup code (hidden):');
|
|
69
|
+
if (!TOKEN.test(code)) throw new Error('Use the 43-character code from My agents.');
|
|
70
|
+
const paired = await sys.request(plan.projectUrl, 'exchange', { code });
|
|
71
|
+
if (!TOKEN.test(paired?.token ?? '') || !/^[\w-]{1,200}$/.test(paired.agentId ?? '') || !/^[\w-]{1,64}$/.test(paired.agentName ?? '')) throw new Error('Unexpected pairing reply. Generate a fresh code.');
|
|
72
|
+
const authoritative = makePlan({ ...plan, projectUrl: plan.projectUrl, repo: paired.repoUrl });
|
|
73
|
+
const id = sessionId(plan.projectUrl, paired.agentId), dir = connectionDir(home, id);
|
|
74
|
+
// Recovery receipt before further approval; never lose an exchanged credential.
|
|
75
|
+
writePrivate(join(dir, 'pending-session.json'), { token: paired.token, service: plan.projectUrl, agentId: paired.agentId, agentName: paired.agentName, repo: authoritative.repo, folder, provider: plan.provider });
|
|
76
|
+
io.say(`Pairing saved privately for ${id}. Interrupted setup can be recovered with session-prepare --connection ${id}.`);
|
|
77
|
+
if (expected && expected !== paired.agentId) throw new Error('This code belongs to a different agent. Its private recovery receipt was saved; the requested connection was preserved. Review My agents before proceeding.');
|
|
78
|
+
return finish(dir, readPrivate(join(dir, 'pending-session.json'), true), Boolean(expectedDir));
|
|
79
|
+
};
|
|
80
|
+
return expectedDir ? withLock(expectedDir, pair) : pair();
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
prepare(options) {
|
|
84
|
+
const dir = connectionDir(home, options.connection);
|
|
85
|
+
if (!lstatExists(join(dir, 'pending-session.json'))) throw new Error('No interrupted session pairing found. Use session-config for a saved connection, or Reconnect for a new code.');
|
|
86
|
+
return finish(dir, readPrivate(join(dir, 'pending-session.json'), true));
|
|
87
|
+
},
|
|
88
|
+
config(options) {
|
|
89
|
+
const record = validateSession(load(connectionDir(home, options.connection)), options.connection);
|
|
90
|
+
io.say(JSON.stringify(sessionConfiguration(record, home), null, 2));
|
|
91
|
+
},
|
|
92
|
+
status() {
|
|
93
|
+
const records = list(home);
|
|
94
|
+
if (lstatExists(home)) for (const key of readdirSync(home)) {
|
|
95
|
+
if (!/^[a-f0-9]{64}$/.test(key)) continue;
|
|
96
|
+
privateDir(join(home, key));
|
|
97
|
+
for (const agent of readdirSync(join(home, key))) {
|
|
98
|
+
if (!/^[\w-]{1,200}$/.test(agent)) continue;
|
|
99
|
+
const id = `${key}/${agent}`, dir = connectionDir(home, id);
|
|
100
|
+
if (lstatExists(join(dir, 'pending-session.json'))) {
|
|
101
|
+
const { token, ...receipt } = readPrivate(join(dir, 'pending-session.json'), true);
|
|
102
|
+
const position = records.findIndex(record => record.id === id);
|
|
103
|
+
const pending = { ...receipt, id, pending: true };
|
|
104
|
+
if (position >= 0) records[position] = pending; else records.push(pending);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
io.say(JSON.stringify(records.map(record => ({ id: record.id, agent: record.agentName, project: record.service, workspace: record.folder, provider: record.provider, mcp: sessionName(record), state: record.pending ? 'interrupted pairing; use session-prepare' : 'saved locally; live activity not checked' })), null, 2)); },
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const defaultHome = () => join(homedir(), '.karma42', 'connections');
|
|
7
|
+
export function canonicalPath(value) {
|
|
8
|
+
let path = resolve(value);
|
|
9
|
+
const tail = [];
|
|
10
|
+
while (!lstatExists(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
11
|
+
return join(realpathSync(path), ...tail);
|
|
12
|
+
}
|
|
13
|
+
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
14
|
+
export function privateDir(path) {
|
|
15
|
+
try { mkdirSync(path, { mode: 0o700 }); }
|
|
16
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
17
|
+
const stat = lstatSync(path);
|
|
18
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error('Connection storage must be a private directory, without symbolic links.');
|
|
19
|
+
}
|
|
20
|
+
export function ensureHome(home) {
|
|
21
|
+
// Only the caller-selected root is created recursively. Existing root must be private.
|
|
22
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
23
|
+
privateDir(home);
|
|
24
|
+
}
|
|
25
|
+
export function readPrivate(path, json = false) {
|
|
26
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd);
|
|
29
|
+
if (!stat.isFile() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error('Connection files must be private regular files.');
|
|
30
|
+
const value = readFileSync(fd, 'utf8');
|
|
31
|
+
if (!json) return value;
|
|
32
|
+
try { return JSON.parse(value); } catch { throw new Error('Invalid private connection record. Restore its backup or reconnect.'); }
|
|
33
|
+
} finally { closeSync(fd); }
|
|
34
|
+
}
|
|
35
|
+
export function writePrivate(path, value) {
|
|
36
|
+
try {
|
|
37
|
+
const stat = lstatSync(path);
|
|
38
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error('Refusing to replace unsafe connection storage.');
|
|
39
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
40
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
41
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
42
|
+
try { writeFileSync(fd, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`); }
|
|
43
|
+
finally { closeSync(fd); }
|
|
44
|
+
try { renameSync(temp, path); } finally { try { unlinkSync(temp); } catch {} }
|
|
45
|
+
}
|
|
46
|
+
export function connectionDir(home, id) {
|
|
47
|
+
if (!/^[a-f0-9]{64}\/[\w-]{1,200}$/.test(id ?? '')) throw new Error('Choose a connection ID from status.');
|
|
48
|
+
ensureHome(home);
|
|
49
|
+
const [project, agent] = id.split('/');
|
|
50
|
+
privateDir(join(home, project));
|
|
51
|
+
const dir = join(home, project, agent);
|
|
52
|
+
privateDir(dir);
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
export function save(dir, record) { writePrivate(join(dir, 'connection.json'), record); }
|
|
56
|
+
export function load(dir) { return readPrivate(join(dir, 'connection.json'), true); }
|
|
57
|
+
export function binding(record) {
|
|
58
|
+
return digest([record.schema, record.id, record.service, record.agentId, record.agentName, record.repo, record.folder, record.provider, record.mode, record.copyVersion, record.runtimePolicy, record.bundleHash]);
|
|
59
|
+
}
|
|
60
|
+
export function withLock(dir, action) {
|
|
61
|
+
const lock = join(dir, 'operation.lock');
|
|
62
|
+
let fd;
|
|
63
|
+
try { fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'EEXIST') throw new Error('Another operation holds this connection. After a crash, check that its process has stopped before removing operation.lock.');
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
writeFileSync(fd, `${process.pid}\n`); closeSync(fd);
|
|
69
|
+
return Promise.resolve().then(action).finally(() => unlinkSync(lock));
|
|
70
|
+
}
|
|
71
|
+
export function list(home) {
|
|
72
|
+
if (!lstatExists(home)) return [];
|
|
73
|
+
ensureHome(home);
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const project of readdirSync(home)) {
|
|
76
|
+
if (!/^[a-f0-9]{64}$/.test(project)) continue;
|
|
77
|
+
privateDir(join(home, project));
|
|
78
|
+
for (const agent of readdirSync(join(home, project))) {
|
|
79
|
+
if (!/^[\w-]{1,200}$/.test(agent)) continue;
|
|
80
|
+
const dir = connectionDir(home, `${project}/${agent}`);
|
|
81
|
+
if (lstatExists(join(dir, 'connection.json'))) result.push(load(dir));
|
|
82
|
+
else if (lstatExists(join(dir, 'pending-pair.json'))) {
|
|
83
|
+
const { token, ...pending } = readPrivate(join(dir, 'pending-pair.json'), true);
|
|
84
|
+
result.push({ ...pending, phase: 'paired', consent: null });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
export function lstatExists(path) { try { lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
91
|
+
export function cleanEnvironment(input = process.env) {
|
|
92
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !key.startsWith('PILOT_')));
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Separate process keeps ambient PILOT_* overrides out of the canonical supervisor.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { load, binding } from './state.mjs';
|
|
4
|
+
import { COPY_VERSION } from './plan.mjs';
|
|
5
|
+
import { supervise } from '../runtime/launcher.mjs';
|
|
6
|
+
const dir = process.argv[2];
|
|
7
|
+
const record = load(dir);
|
|
8
|
+
if (record.phase !== 'prepared' || record.copyVersion !== COPY_VERSION || record.consent?.binding !== binding(record)) throw new Error('Prepare this connection and confirm its permissions before starting.');
|
|
9
|
+
process.exitCode = await supervise({
|
|
10
|
+
service: record.service, agentName: record.agentName, repoDir: record.folder, home: join(dir, 'agent-state'),
|
|
11
|
+
env: {
|
|
12
|
+
PILOT_PROVIDER: record.provider,
|
|
13
|
+
PILOT_PERMISSION_MODE: record.mode === 'bypass' ? 'bypassPermissions' : 'acceptEdits',
|
|
14
|
+
PILOT_MAX_TURNS_PER_HOUR: '0', PILOT_TURN_SECONDS: '0', PILOT_TURN_BUDGET_USD: '0',
|
|
15
|
+
PILOT_WORK_INTERVAL_MINUTES: '15',
|
|
16
|
+
},
|
|
17
|
+
});
|
package/src/system.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { doctor } from './doctor.mjs';
|
|
7
|
+
import { cleanEnvironment, privateDir, readPrivate, writePrivate } from './state.mjs';
|
|
8
|
+
|
|
9
|
+
export function run(command, args, cwd) {
|
|
10
|
+
const result = spawnSync(command, args, { cwd, shell: false, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024, env: cleanEnvironment(), windowsHide: true });
|
|
11
|
+
// Do not print raw subprocess errors/output: they can contain credentials or terminal escapes.
|
|
12
|
+
if (result.error || result.status !== 0) return null;
|
|
13
|
+
return result.stdout.trim();
|
|
14
|
+
}
|
|
15
|
+
export async function request(service, route, body, token) {
|
|
16
|
+
let response;
|
|
17
|
+
try {
|
|
18
|
+
response = await fetch(`${service}/api/join/${route}`, {
|
|
19
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
20
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
} catch { throw new Error(route === 'exchange' ? 'Pairing reply unavailable. The code may have been consumed; generate a fresh code in My agents.' : 'Could not update the readiness check. Retry prepare when the service is available.'); }
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
if (response.status === 401 || response.status === 403) throw new Error(route === 'exchange' ? 'The setup code is expired, invalid or no longer authorized. Generate a fresh code in My agents.' : 'This connection is no longer authorized. Check membership and reconnect in My agents.');
|
|
26
|
+
if (response.status === 429) throw new Error('Too many requests. Wait before retrying.');
|
|
27
|
+
throw new Error('The project service could not complete this request.');
|
|
28
|
+
}
|
|
29
|
+
const raw = await response.text();
|
|
30
|
+
if (raw.length > 16_384) throw new Error('Unexpected project service response.');
|
|
31
|
+
try { return JSON.parse(raw); } catch { throw new Error('Unexpected project service response.'); }
|
|
32
|
+
}
|
|
33
|
+
export function verifyBundle() {
|
|
34
|
+
const root = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
35
|
+
let manifest;
|
|
36
|
+
try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); }
|
|
37
|
+
catch { throw new Error('Packaged runtime missing. Build or reinstall this package before preparing an agent.'); }
|
|
38
|
+
const names = ['runner.mjs', 'control-v2.mjs', 'runtime.mjs', 'launcher.mjs', 'claude-settings.json', 'AGENT_PROMPT_V2.md', 'providers.mjs'];
|
|
39
|
+
if (Object.keys(manifest.files ?? {}).sort().join() !== [...names].sort().join()) throw new Error('Invalid packaged runtime manifest.');
|
|
40
|
+
const files = {};
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const content = readFileSync(join(root, name));
|
|
43
|
+
if (createHash('sha256').update(content).digest('hex') !== manifest.files[name]) throw new Error('Packaged runtime integrity check failed. Reinstall the package.');
|
|
44
|
+
files[name] = content;
|
|
45
|
+
}
|
|
46
|
+
return { files, fingerprint: createHash('sha256').update(JSON.stringify(manifest.files)).digest('hex') };
|
|
47
|
+
}
|
|
48
|
+
export function installBundle(state) {
|
|
49
|
+
const bundle = verifyBundle();
|
|
50
|
+
const kit = join(state, 'kit'); privateDir(kit);
|
|
51
|
+
for (const [name, content] of Object.entries(bundle.files)) writePrivate(join(kit, name), content.toString('utf8'));
|
|
52
|
+
return bundle.fingerprint;
|
|
53
|
+
}
|
|
54
|
+
export function verifyInstalled(state) {
|
|
55
|
+
const bundle = verifyBundle();
|
|
56
|
+
for (const [name, content] of Object.entries(bundle.files)) {
|
|
57
|
+
if (readPrivate(join(state, 'kit', name)) !== content.toString('utf8')) throw new Error('Prepared runtime files changed. Run prepare before starting.');
|
|
58
|
+
}
|
|
59
|
+
return bundle.fingerprint;
|
|
60
|
+
}
|
|
61
|
+
export function launch(dir, record) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./supervisor.mjs', import.meta.url)), dir], {
|
|
64
|
+
shell: false, stdio: 'inherit', env: cleanEnvironment(),
|
|
65
|
+
});
|
|
66
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
67
|
+
let stopping = false;
|
|
68
|
+
const handlers = Object.fromEntries(signals.map(signal => [signal, () => { stopping = true; child.kill(signal); }]));
|
|
69
|
+
for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
|
|
70
|
+
function cleanup() { for (const [signal, handler] of Object.entries(handlers)) process.off(signal, handler); }
|
|
71
|
+
child.once('error', () => { cleanup(); reject(new Error('Could not launch the foreground supervisor.')); });
|
|
72
|
+
child.once('exit', (code, signal) => { cleanup(); resolve(stopping && signal ? 0 : code ?? 1); });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export const system = { doctor, run, request, installBundle, verifyBundle, verifyInstalled, launch, now: () => new Date().toISOString() };
|
package/src/terminal.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
export function terminal(input = process.stdin, output = process.stdout) {
|
|
3
|
+
return {
|
|
4
|
+
interactive: !!input.isTTY && !!output.isTTY,
|
|
5
|
+
say: text => output.write(`${text}\n`),
|
|
6
|
+
async ask(text) {
|
|
7
|
+
const rl = createInterface({ input, output });
|
|
8
|
+
try { return (await rl.question(`${text} `)).trim(); } finally { rl.close(); }
|
|
9
|
+
},
|
|
10
|
+
async confirm(text) { return (await this.ask(`${text} [yes/no]`)).toLowerCase() === 'yes'; },
|
|
11
|
+
secret(text) {
|
|
12
|
+
if (!input.isTTY || !output.isTTY) throw new Error('Enter the setup code in an interactive terminal.');
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const wasRaw = input.isRaw; let value = '';
|
|
15
|
+
input.setRawMode(true); input.resume(); output.write(`${text} `);
|
|
16
|
+
const finish = (error) => {
|
|
17
|
+
input.off('data', onData); input.off('end', onEnd); input.setRawMode(wasRaw); input.pause(); output.write('\n');
|
|
18
|
+
if (error) reject(error); else resolve(value);
|
|
19
|
+
};
|
|
20
|
+
const onEnd = () => finish(new Error('Code entry cancelled.'));
|
|
21
|
+
const onData = chunk => {
|
|
22
|
+
for (const char of chunk.toString('utf8')) {
|
|
23
|
+
if (char === '\x03' || char === '\x04') { finish(new Error('Code entry cancelled.')); return; }
|
|
24
|
+
if (char === '\r' || char === '\n') { finish(); return; }
|
|
25
|
+
if (char === '\x7f' || char === '\b') value = value.slice(0, -1);
|
|
26
|
+
else if (/[\w-]/.test(char) && value.length < 100) value += char;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
input.on('data', onData); input.once('end', onEnd);
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|