@webority/ensemble 0.0.1

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 ADDED
@@ -0,0 +1,41 @@
1
+ # @webority/ensemble
2
+
3
+ Connect a machine to your [Ensemble](https://ensemble.host) control plane. Installs the local **agent
4
+ runner** (runs your AI coding sessions, appears in your fleet) and wires **ensemble-bus** into your
5
+ coding CLIs so your sessions message each other — all **scoped to your organization**.
6
+
7
+ ## Install & enrol
8
+ Get an **enrollment token** from the portal → *Connect Runner*, then:
9
+
10
+ ```sh
11
+ npm i -g @webority/ensemble
12
+ ensemble enroll --token <ENROLLMENT_TOKEN>
13
+ ```
14
+
15
+ Or one line (no npm knowledge needed):
16
+
17
+ ```sh
18
+ curl -fsSL https://ensemble.host/install.sh | sh -s -- --token <ENROLLMENT_TOKEN>
19
+ ```
20
+
21
+ Windows (PowerShell):
22
+
23
+ ```powershell
24
+ irm https://ensemble.host/install.ps1 | iex # then: ensemble enroll --token <TOKEN>
25
+ ```
26
+
27
+ `enroll` exchanges your enrollment token for a **per-machine token** (revocable individually from the
28
+ portal), downloads + configures + auto-starts the runner, and wires the bus into whichever of
29
+ `claude` / `codex` / `grok` / `gemini` / `opencode` it finds. **Your subscription logins never leave
30
+ your machine** — the server only ever sees a machine token.
31
+
32
+ ## Commands
33
+ - `ensemble enroll --token <t> [--api <url>] [--name <machine>]`
34
+ - `ensemble status` — connection + your org's live session mailboxes
35
+ - `ensemble hooks` — re-wire the bus after installing a new coding CLI
36
+
37
+ ## Security model
38
+ Machine ⇄ server auth is a **per-machine token**, resolved server-side to your organization; every
39
+ row is tenant-isolated by a global query filter, so no machine can reach another org's data. The
40
+ runner dials **outbound only** (no inbound port). Revoke any machine — or the enrollment token — from
41
+ the portal at any time.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const core = require('../lib/core');
7
+
8
+ function parseArgs(argv) {
9
+ const args = { _: [] };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a.startsWith('--')) {
13
+ const k = a.slice(2);
14
+ const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
15
+ args[k] = v;
16
+ } else args._.push(a);
17
+ }
18
+ return args;
19
+ }
20
+
21
+ async function enroll(args) {
22
+ const token = args.token || process.env.ENSEMBLE_ENROLL_TOKEN;
23
+ if (!token) core.die('missing --token <enrollment-token> — get it from your Ensemble portal → Connect Runner');
24
+ const api = args.api || core.DEFAULT_API;
25
+ const name = args.name || os.hostname();
26
+ core.log('Ensemble — enrolling "' + name + '" with ' + api);
27
+ core.log('• fetching runner + bus binaries');
28
+ const { runnerPath } = await core.ensureBinaries();
29
+ core.log('• exchanging enrollment token for a per-machine token');
30
+ const machineToken = await core.enrollMachine(api, token, name);
31
+ core.writeRunnerConfig(api, machineToken);
32
+ const wired = core.wireHooks();
33
+ core.log('• wired session hooks: ' + (wired.join(', ') || '(no supported coding CLI found — run `ensemble hooks` after installing claude/codex/grok)'));
34
+ core.log('• installing auto-start + launching the runner');
35
+ core.installAutostart(runnerPath);
36
+ core.startRunner(runnerPath);
37
+ core.log('\n✓ Enrolled. This machine runs your Ensemble runner and its coding sessions now share the bus (scoped to your org).');
38
+ core.log(' Open a NEW terminal (so ENSEMBLE_MACHINE_TOKEN loads), then start Claude/Codex/Grok as usual.');
39
+ }
40
+
41
+ async function status(args) {
42
+ const api = args.api || core.DEFAULT_API;
43
+ const tokenFile = path.join(core.ENSEMBLE_DIR, 'machine-token');
44
+ if (!fs.existsSync(tokenFile)) return core.log('not enrolled — run: ensemble enroll --token <token>');
45
+ const t = fs.readFileSync(tokenFile, 'utf8').trim();
46
+ const res = await core.request('GET', api.replace(/\/$/, '') + '/api/bus/who', { 'X-Ensemble-Machine-Token': t });
47
+ if (res.status === 200) {
48
+ const mbx = JSON.parse(res.body);
49
+ core.log('✓ connected to ' + api + ' — your org has ' + mbx.length + ' mailbox(es):');
50
+ mbx.slice(0, 25).forEach((m) => core.log(' ' + m.name + ' (' + m.engine + ')'));
51
+ } else {
52
+ core.log('✗ machine token not accepted (HTTP ' + res.status + ') — re-enroll with a fresh enrollment token.');
53
+ }
54
+ }
55
+
56
+ function usage() {
57
+ core.log(
58
+ `ensemble — connect this machine to your Ensemble control plane
59
+
60
+ ensemble enroll --token <enrollment-token> [--api <url>] [--name <machine>]
61
+ download + enrol the runner, wire session hooks, and start it
62
+ ensemble status show connection + your org's live session mailboxes
63
+ ensemble hooks (re)wire ensemble-bus into installed coding CLIs
64
+ ensemble version
65
+
66
+ Get your enrollment token from the Ensemble portal → Connect Runner.`);
67
+ }
68
+
69
+ (async () => {
70
+ const argv = process.argv.slice(2);
71
+ const cmd = argv[0];
72
+ const args = parseArgs(argv.slice(1));
73
+ try {
74
+ if (cmd === 'enroll') await enroll(args);
75
+ else if (cmd === 'status') await status(args);
76
+ else if (cmd === 'hooks') { await core.ensureBinaries(); core.log('wired: ' + (core.wireHooks().join(', ') || 'none')); }
77
+ else if (cmd === 'version' || cmd === '--version' || cmd === '-v') core.log(require('../package.json').version);
78
+ else usage();
79
+ } catch (e) {
80
+ core.die(e && e.message ? e.message : String(e));
81
+ }
82
+ })();
package/lib/core.js ADDED
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+ // Core logic for the `ensemble` CLI — zero runtime deps (Node built-ins only), cross-platform.
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const https = require('https');
7
+ const { spawn, spawnSync } = require('child_process');
8
+
9
+ const HOME = os.homedir();
10
+ const ENSEMBLE_DIR = path.join(HOME, '.ensemble');
11
+ const BIN_DIR = path.join(ENSEMBLE_DIR, 'bin');
12
+ const RUNNER_DIR = path.join(ENSEMBLE_DIR, 'runner');
13
+ const DEFAULT_API = process.env.ENSEMBLE_API || 'https://api.ensemble.host';
14
+ // Public host for the prebuilt binaries (runner + bus). Overridable for testing.
15
+ const DL_BASE = process.env.ENSEMBLE_DL_BASE || 'https://ensembledl.blob.core.windows.net/cli';
16
+
17
+ function log(msg) { process.stdout.write(msg + '\n'); }
18
+ function warn(msg) { process.stderr.write(msg + '\n'); }
19
+ function die(msg) { warn('ensemble: ' + msg); process.exit(1); }
20
+
21
+ function platform() {
22
+ const p = process.platform, a = process.arch;
23
+ if (p === 'win32') return { key: 'win-x64', exe: '.exe', os: 'windows' };
24
+ if (p === 'darwin') return { key: a === 'arm64' ? 'osx-arm64' : 'osx-x64', exe: '', os: 'mac' };
25
+ return { key: a === 'arm64' ? 'linux-arm64' : 'linux-x64', exe: '', os: 'linux' };
26
+ }
27
+
28
+ function ensureDirs() {
29
+ for (const d of [ENSEMBLE_DIR, BIN_DIR, RUNNER_DIR]) fs.mkdirSync(d, { recursive: true });
30
+ }
31
+
32
+ // --- HTTP helpers ---
33
+ function request(method, url, headers, body) {
34
+ return new Promise((resolve, reject) => {
35
+ const u = new URL(url);
36
+ const req = https.request(u, { method, headers }, (res) => {
37
+ let data = '';
38
+ res.on('data', (c) => (data += c));
39
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
40
+ });
41
+ req.on('error', reject);
42
+ if (body) req.write(body);
43
+ req.end();
44
+ });
45
+ }
46
+
47
+ function downloadTo(url, dest) {
48
+ return new Promise((resolve, reject) => {
49
+ const doGet = (u, redirects) => {
50
+ https.get(u, (res) => {
51
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirects < 5) {
52
+ res.resume();
53
+ return doGet(res.headers.location, redirects + 1);
54
+ }
55
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode + ' for ' + u)); }
56
+ const tmp = dest + '.download';
57
+ const out = fs.createWriteStream(tmp);
58
+ res.pipe(out);
59
+ out.on('finish', () => out.close(() => { fs.renameSync(tmp, dest); resolve(); }));
60
+ out.on('error', reject);
61
+ }).on('error', reject);
62
+ };
63
+ doGet(url, 0);
64
+ });
65
+ }
66
+
67
+ async function ensureBinaries() {
68
+ ensureDirs();
69
+ const pf = platform();
70
+ const busPath = path.join(BIN_DIR, 'ensemble-bus' + pf.exe);
71
+ const runnerPath = path.join(RUNNER_DIR, 'Ensemble.Runner' + pf.exe);
72
+ const targets = [
73
+ { name: 'ensemble-bus-' + pf.key + pf.exe, dest: busPath },
74
+ { name: 'ensemble-runner-' + pf.key + pf.exe, dest: runnerPath },
75
+ ];
76
+ for (const t of targets) {
77
+ if (fs.existsSync(t.dest) && fs.statSync(t.dest).size > 0) continue;
78
+ log(' downloading ' + t.name + ' …');
79
+ await downloadTo(DL_BASE + '/' + t.name, t.dest);
80
+ if (pf.os !== 'windows') {
81
+ fs.chmodSync(t.dest, 0o755);
82
+ // macOS requires at least an ad-hoc signature to run an unsigned binary.
83
+ if (pf.os === 'mac') spawnSync('codesign', ['-s', '-', '-f', t.dest], { stdio: 'ignore' });
84
+ }
85
+ }
86
+ return { busPath, runnerPath, pf };
87
+ }
88
+
89
+ // --- enrollment: exchange the enrollment token for a per-machine runner token ---
90
+ async function enrollMachine(api, enrollToken, name) {
91
+ const res = await request('POST', api.replace(/\/$/, '') + '/api/runner-tokens/enroll',
92
+ { 'X-Ensemble-Machine-Token': enrollToken, 'Content-Type': 'application/json' },
93
+ JSON.stringify({ name }));
94
+ if (res.status === 401) throw new Error('enrollment token was rejected (invalid or revoked).');
95
+ if (res.status !== 200) throw new Error('enroll failed: HTTP ' + res.status + ' ' + res.body);
96
+ const token = JSON.parse(res.body).token;
97
+ if (!token) throw new Error('enroll returned no token');
98
+ return token;
99
+ }
100
+
101
+ function writeRunnerConfig(api, machineToken) {
102
+ ensureDirs();
103
+ const hubUrl = api.replace(/\/$/, '') + '/hubs/runner';
104
+ const cfg = {
105
+ Logging: { LogLevel: { Default: 'Information', 'Microsoft.Hosting.Lifetime': 'Information' } },
106
+ Ensemble: { HubUrl: hubUrl, MachineToken: machineToken },
107
+ };
108
+ fs.writeFileSync(path.join(RUNNER_DIR, 'appsettings.json'), JSON.stringify(cfg, null, 2));
109
+ // ensemble-bus reads ENSEMBLE_MACHINE_TOKEN from env — persist it to the shell rc + a dotfile.
110
+ fs.writeFileSync(path.join(ENSEMBLE_DIR, 'machine-token'), machineToken, { mode: 0o600 });
111
+ const pf = platform();
112
+ if (pf.os === 'windows') {
113
+ spawnSync('setx', ['ENSEMBLE_MACHINE_TOKEN', machineToken], { stdio: 'ignore' });
114
+ }
115
+ const rc = path.join(HOME, pf.os === 'mac' ? '.zshrc' : '.bashrc');
116
+ try {
117
+ let cur = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
118
+ cur = cur.replace(/\nexport ENSEMBLE_MACHINE_TOKEN=.*\n?/g, '\n');
119
+ if (!cur.endsWith('\n')) cur += '\n';
120
+ cur += `export ENSEMBLE_MACHINE_TOKEN="${machineToken}"\n`;
121
+ if (!/\.ensemble\/bin/.test(cur)) cur += `export PATH="$HOME/.ensemble/bin:$PATH"\n`;
122
+ fs.writeFileSync(rc, cur);
123
+ } catch (e) { /* best-effort */ }
124
+ }
125
+
126
+ // --- wire ensemble-bus into each installed harness's session hooks ---
127
+ function wireHooks() {
128
+ const pf = platform();
129
+ const busExe = path.join(BIN_DIR, 'ensemble-bus' + pf.exe);
130
+ const busInvoke = pf.os === 'windows' ? busExe.replace(/\\/g, '\\\\') : busExe;
131
+ const wired = [];
132
+
133
+ // Claude Code — settings.json hooks
134
+ const claudeSettings = path.join(HOME, '.claude', 'settings.json');
135
+ if (has('claude')) { mergeClaudeHooks(claudeSettings, busInvoke); wired.push('claude'); }
136
+
137
+ // Codex — ~/.codex/hooks.json
138
+ if (has('codex')) { writeEngineHooks(path.join(HOME, '.codex', 'hooks.json'), busInvoke, 'codex'); wired.push('codex'); }
139
+ // Grok — ~/.grok/hooks/agent-bus.json
140
+ if (has('grok')) { writeEngineHooks(path.join(HOME, '.grok', 'hooks', 'agent-bus.json'), busInvoke, 'grok'); wired.push('grok'); }
141
+ // Gemini / OpenCode — best-effort (same hook shape if the engine supports lifecycle hooks)
142
+ if (has('gemini')) wired.push('gemini(detected)');
143
+ if (has('opencode')) wired.push('opencode(detected)');
144
+ return wired;
145
+ }
146
+
147
+ function has(cmd) {
148
+ const pf = platform();
149
+ const which = pf.os === 'windows' ? spawnSync('where', [cmd], { stdio: 'ignore' }) : spawnSync('which', [cmd], { stdio: 'ignore' });
150
+ return which.status === 0;
151
+ }
152
+
153
+ function writeEngineHooks(file, busInvoke, engine) {
154
+ fs.mkdirSync(path.dirname(file), { recursive: true });
155
+ const hook = (ev) => `"${busInvoke}" hook ${ev} --engine ${engine}`;
156
+ const cfg = {
157
+ hooks: {
158
+ 'session-start': hook('session-start'),
159
+ 'user-prompt': hook('user-prompt'),
160
+ 'stop': hook('stop'),
161
+ 'session-end': hook('session-end'),
162
+ },
163
+ };
164
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
165
+ }
166
+
167
+ function mergeClaudeHooks(file, busInvoke) {
168
+ fs.mkdirSync(path.dirname(file), { recursive: true });
169
+ let s = {};
170
+ try { if (fs.existsSync(file)) s = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { s = {}; }
171
+ s.hooks = s.hooks || {};
172
+ const cmd = (ev) => ({ hooks: [{ type: 'command', command: `"${busInvoke}" hook ${ev} --engine claude 2>/dev/null || true` }] });
173
+ s.hooks.SessionStart = [cmd('session-start')];
174
+ s.hooks.UserPromptSubmit = [cmd('user-prompt')];
175
+ s.hooks.Stop = [cmd('stop')];
176
+ s.hooks.SessionEnd = [cmd('session-end')];
177
+ fs.writeFileSync(file, JSON.stringify(s, null, 2));
178
+ }
179
+
180
+ // --- per-user auto-start of the runner (per OS), plus start it now ---
181
+ function installAutostart(runnerPath) {
182
+ const pf = platform();
183
+ if (pf.os === 'windows') {
184
+ const startup = path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'),
185
+ 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
186
+ fs.mkdirSync(startup, { recursive: true });
187
+ const vbs = path.join(RUNNER_DIR, 'run-hidden.vbs');
188
+ fs.writeFileSync(vbs,
189
+ `Set sh = CreateObject("WScript.Shell")\r\nsh.CurrentDirectory = "${RUNNER_DIR}"\r\nsh.Run """${runnerPath}""", 0, False\r\n`);
190
+ fs.copyFileSync(vbs, path.join(startup, 'EnsembleRunner.vbs'));
191
+ } else if (pf.os === 'mac') {
192
+ const plist = path.join(HOME, 'Library', 'LaunchAgents', 'host.ensemble.runner.plist');
193
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
194
+ fs.writeFileSync(plist,
195
+ `<?xml version="1.0" encoding="UTF-8"?>
196
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
197
+ <plist version="1.0"><dict>
198
+ <key>Label</key><string>host.ensemble.runner</string>
199
+ <key>ProgramArguments</key><array><string>${runnerPath}</string></array>
200
+ <key>WorkingDirectory</key><string>${RUNNER_DIR}</string>
201
+ <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
202
+ <key>StandardOutPath</key><string>${path.join(RUNNER_DIR, 'runner.log')}</string>
203
+ <key>StandardErrorPath</key><string>${path.join(RUNNER_DIR, 'runner.log')}</string>
204
+ </dict></plist>`);
205
+ spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' });
206
+ spawnSync('launchctl', ['load', plist], { stdio: 'ignore' });
207
+ } else {
208
+ const unitDir = path.join(HOME, '.config', 'systemd', 'user');
209
+ fs.mkdirSync(unitDir, { recursive: true });
210
+ fs.writeFileSync(path.join(unitDir, 'ensemble-runner.service'),
211
+ `[Unit]
212
+ Description=Ensemble runner daemon
213
+ After=network-online.target
214
+ [Service]
215
+ WorkingDirectory=${RUNNER_DIR}
216
+ ExecStart=${runnerPath}
217
+ Restart=always
218
+ RestartSec=5
219
+ [Install]
220
+ WantedBy=default.target`);
221
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
222
+ spawnSync('systemctl', ['--user', 'enable', '--now', 'ensemble-runner.service'], { stdio: 'ignore' });
223
+ spawnSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' });
224
+ }
225
+ }
226
+
227
+ function startRunner(runnerPath) {
228
+ const pf = platform();
229
+ if (pf.os === 'mac' || pf.os === 'linux') return; // launchd/systemd already started it
230
+ const child = spawn(runnerPath, [], { cwd: RUNNER_DIR, detached: true, stdio: 'ignore', env: { ...process.env, DOTNET_ENVIRONMENT: 'Production' } });
231
+ child.unref();
232
+ }
233
+
234
+ module.exports = {
235
+ log, warn, die, platform, DEFAULT_API, ENSEMBLE_DIR, RUNNER_DIR, BIN_DIR,
236
+ ensureBinaries, enrollMachine, writeRunnerConfig, wireHooks, installAutostart, startRunner, request,
237
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@webority/ensemble",
3
+ "version": "0.0.1",
4
+ "description": "Connect this machine to your Ensemble control plane — runs the local agent runner and wires the ensemble-bus session bus so your coding sessions talk (per-org, isolated).",
5
+ "bin": {
6
+ "ensemble": "bin/ensemble.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "lib",
14
+ "README.md"
15
+ ],
16
+ "os": [
17
+ "win32",
18
+ "darwin",
19
+ "linux"
20
+ ],
21
+ "keywords": [
22
+ "ensemble",
23
+ "ai-agents",
24
+ "claude-code",
25
+ "codex",
26
+ "control-plane",
27
+ "agent-runner"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/Webority/ensemble.git",
32
+ "directory": "Ensemble.Cli"
33
+ },
34
+ "license": "UNLICENSED",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }