@ours.network/install 0.18.0-nightly.4 → 0.18.0-nightly.6
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 +34 -13
- package/install.sh +3 -0
- package/lib/components.mjs +12 -13
- package/lib/detect.mjs +3 -3
- package/lib/effects.mjs +72 -10
- package/lib/extras.mjs +14 -17
- package/lib/logic.mjs +8 -17
- package/lib/marketplace.mjs +104 -0
- package/lib/orchestrate-uninstall.mjs +2 -2
- package/lib/orchestrate.mjs +226 -38
- package/lib/plan.mjs +9 -9
- package/lib/rerun.mjs +7 -7
- package/lib/target.mjs +25 -17
- package/lib/uninstall.mjs +19 -20
- package/lib/usage.mjs +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,21 @@ npm install --global @ours.network/install
|
|
|
9
9
|
ours-install
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
### Stable and nightly channels
|
|
13
|
+
|
|
14
|
+
Installing `@ours.network/install@latest` selects the stable channel; installing
|
|
15
|
+
`@ours.network/install@nightly` selects the nightly channel. Before changing the
|
|
16
|
+
machine, the installer resolves `@ours.network/mcp`,
|
|
17
|
+
`@ours.network/claude-code`, and `@ours.network/codex`, verifies that their
|
|
18
|
+
selected dist-tags expose one exact lockstep version, and fails closed if they
|
|
19
|
+
do not. MCP and the Codex launcher are installed by exact version, and local
|
|
20
|
+
Claude Code and Codex marketplace manifests pin the corresponding plugin
|
|
21
|
+
packages to that same version.
|
|
22
|
+
|
|
23
|
+
`OURS_CHANNEL=latest|nightly` (or the legacy `OURS_INSTALL_CHANNEL`) remains an
|
|
24
|
+
explicit override. Without an override, the installed package's own version
|
|
25
|
+
selects the channel.
|
|
26
|
+
|
|
12
27
|
The normal flow uses one daemon at `~/.ours` on port 3050, shows an eight-stage
|
|
13
28
|
progress bar, and asks only for information it cannot safely infer (normally the
|
|
14
29
|
Human identity's display name). Existing daemon conflicts and moving a Telegram
|
|
@@ -24,8 +39,8 @@ connector from another daemon still require explicit confirmation.
|
|
|
24
39
|
or preserves the existing one on a re-run.
|
|
25
40
|
- Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
|
|
26
41
|
installations.
|
|
27
|
-
- Configures and starts cowork
|
|
28
|
-
- Configures Telegram
|
|
42
|
+
- Configures and starts cowork as a durable shim over the shared daemon.
|
|
43
|
+
- Configures and starts Telegram as a durable shim over the same daemon.
|
|
29
44
|
- Runs Fleet's host initialization and, when `~/fleet.yaml` is absent, writes a
|
|
30
45
|
conservative stopped starter with `FleetCoordinator`, a `fleet-health`
|
|
31
46
|
watchdog, and a ten-minute `coordinator_health` loop. An existing
|
|
@@ -35,20 +50,21 @@ The operator CLI owns daemon configuration, lifecycle, and boot persistence.
|
|
|
35
50
|
The MCP package is only the stdio adapter spawned by agent harnesses; the
|
|
36
51
|
installer never asks `ours-mcp` to start a daemon.
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
Daemon state is temporarily scoped to its package major version. On a same-major
|
|
54
|
+
update, the installer refreshes the packages and runs `ours daemon restart`; the
|
|
55
|
+
CLI streams structured startup phases until restore is complete instead of
|
|
56
|
+
appearing to hang. A different-major update is detected before package
|
|
57
|
+
replacement. The installer explains the incompatibility and, only in an
|
|
58
|
+
interactive run, offers to stop the CLI-managed daemon, copy the complete state
|
|
59
|
+
directory to a timestamped directory under `~/.ours-backups/`, remove the
|
|
60
|
+
managed service and old state, then initialize the new major. The default answer is no, and
|
|
61
|
+
`OURS_ASSUME_YES` never authorizes this purge.
|
|
42
62
|
|
|
43
63
|
## What remains stopped
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
activate them when ready:
|
|
65
|
+
Only Fleet is intentionally not started. Review and activate it when ready:
|
|
47
66
|
|
|
48
67
|
```sh
|
|
49
|
-
# After configuring a Telegram bot and route locally:
|
|
50
|
-
ours-tg-connector install-service
|
|
51
|
-
|
|
52
68
|
# After reviewing ~/fleet.yaml:
|
|
53
69
|
ours-fleet doctor
|
|
54
70
|
ours-fleet config
|
|
@@ -74,8 +90,11 @@ new Human identity and asks no ordinary setup questions, but it never bypasses
|
|
|
74
90
|
selection conflicts, connector moves, or destructive safeguards.
|
|
75
91
|
|
|
76
92
|
A non-default daemon must be selected coherently with a config file or matching
|
|
77
|
-
port and state directory.
|
|
78
|
-
selection through `OURS_CONFIG
|
|
93
|
+
port and state directory. Hermes and the generated Fleet role persist that
|
|
94
|
+
selection through `OURS_CONFIG`. Claude Code and Codex plugin registrations
|
|
95
|
+
cannot store an environment value; for those harnesses the installer prints the
|
|
96
|
+
exact `export OURS_CONFIG=...` line that must be added to the shell profile
|
|
97
|
+
before starting the harness. There is no per-application daemon.
|
|
79
98
|
|
|
80
99
|
## Uninstall
|
|
81
100
|
|
|
@@ -92,6 +111,8 @@ targets only the explicit state directory.
|
|
|
92
111
|
|
|
93
112
|
`OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) selects the packages' nightly
|
|
94
113
|
dist-tags. Without an override, the installer's own version selects the channel.
|
|
114
|
+
The operator CLI intentionally has no nightly dist-tag and remains untagged on
|
|
115
|
+
both channels.
|
|
95
116
|
|
|
96
117
|
## Environment
|
|
97
118
|
|
package/install.sh
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
# From a CLONE (a sibling install.mjs is present) it runs that directly. Piped as `curl … | bash`
|
|
22
22
|
# it installs the published command globally — `npm i -g @ours.network/install` — then runs
|
|
23
23
|
# `ours-install`. Idempotent: a re-run updates to @latest and runs again.
|
|
24
|
+
# The bootstrap is deliberately stable. For nightly, install the published nightly command
|
|
25
|
+
# directly: `npm i -g @ours.network/install@nightly && ours-install`; that package's own
|
|
26
|
+
# X.Y.Z-nightly.N version selects and exactly resolves the matching stack channel.
|
|
24
27
|
#
|
|
25
28
|
# Non-interactive env overrides (all optional) — consumed by the Node installer:
|
|
26
29
|
# OURS_ASSUME_YES=1 accept every default; never prompt (no tty needed)
|
package/lib/components.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ours-install v3 — component selection and attachment.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Component selection and attachment planning. Repoint decisions stay beside
|
|
4
|
+
// attachment decisions so an existing connector can never move silently.
|
|
5
5
|
// Pure, like target.mjs and plan.mjs: the caller injects the current file
|
|
6
6
|
// contents and the installed versions, and every function returns a plan.
|
|
7
7
|
//
|
|
@@ -101,7 +101,7 @@ export const tgConfigPath = (home, env = {}) => env.OURS_TG_CONFIG ?? join(home,
|
|
|
101
101
|
export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? join(home, '.ours-cowork', 'config.json');
|
|
102
102
|
|
|
103
103
|
// -----------------------------------------------------------------------------
|
|
104
|
-
//
|
|
104
|
+
// Component selection
|
|
105
105
|
// -----------------------------------------------------------------------------
|
|
106
106
|
|
|
107
107
|
/**
|
|
@@ -133,7 +133,7 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// -----------------------------------------------------------------------------
|
|
136
|
-
//
|
|
136
|
+
// MCP server attachment
|
|
137
137
|
// -----------------------------------------------------------------------------
|
|
138
138
|
|
|
139
139
|
/**
|
|
@@ -159,7 +159,7 @@ export function planMcpAttachment({ stateDir, isDefaultStateDir, channel = 'late
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// -----------------------------------------------------------------------------
|
|
162
|
-
//
|
|
162
|
+
// Telegram connector attachment and repointing
|
|
163
163
|
// -----------------------------------------------------------------------------
|
|
164
164
|
|
|
165
165
|
/**
|
|
@@ -203,11 +203,10 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
|
|
|
203
203
|
changed: changes.length > 0,
|
|
204
204
|
changes,
|
|
205
205
|
config: merged,
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
service: null,
|
|
206
|
+
// The connector is a durable shim over the shared daemon. Its service command
|
|
207
|
+
// owns the platform-specific unit and `enable --now`; the installer invokes
|
|
208
|
+
// it after committing the coherent daemon selection below.
|
|
209
|
+
service: ['ours-tg-connector', 'install-service'],
|
|
211
210
|
untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
|
|
212
211
|
};
|
|
213
212
|
if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
|
|
@@ -218,12 +217,12 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
|
|
|
218
217
|
to: { daemonUrl: endpoint, daemonStateDir: dir },
|
|
219
218
|
prompt: `The Telegram connector currently uses ${current.daemonUrl ?? 'an unrecorded daemon'} (${current.daemonStateDir ?? 'unrecorded state directory'}).\nPoint it at ${endpoint} (${dir}) instead? This MOVES the connector; it does not add a second one.`,
|
|
220
219
|
};
|
|
221
|
-
// Never
|
|
220
|
+
// Never repoint an existing connector without a human, in any mode.
|
|
222
221
|
return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
|
|
223
222
|
}
|
|
224
223
|
|
|
225
224
|
// -----------------------------------------------------------------------------
|
|
226
|
-
//
|
|
225
|
+
// cowork attachment
|
|
227
226
|
// -----------------------------------------------------------------------------
|
|
228
227
|
|
|
229
228
|
/**
|
|
@@ -342,7 +341,7 @@ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVe
|
|
|
342
341
|
}
|
|
343
342
|
|
|
344
343
|
// -----------------------------------------------------------------------------
|
|
345
|
-
//
|
|
344
|
+
// Component failures are isolated so the remaining components can continue.
|
|
346
345
|
// -----------------------------------------------------------------------------
|
|
347
346
|
|
|
348
347
|
/**
|
package/lib/detect.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ours-install v3 — which daemons are already on this machine, and which one this
|
|
2
2
|
// run is for.
|
|
3
3
|
//
|
|
4
|
-
// Never ask for a state directory or
|
|
4
|
+
// Never ask for a state directory or port to be typed. When several
|
|
5
5
|
// daemons are DETECTED, show them and let one be picked: selecting from what was
|
|
6
6
|
// found is not prompting for a path.
|
|
7
7
|
//
|
|
@@ -91,7 +91,7 @@ export function detectDaemons({ candidates = [], exists, readJson }) {
|
|
|
91
91
|
/**
|
|
92
92
|
* A state directory for a daemon this run would CREATE, derived and never typed.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
94
|
+
* The UI does not ask for a path, so "create a new one" has to derive somewhere
|
|
95
95
|
* to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
|
|
96
96
|
* An operator who wants a specific path still has `--state-dir`, which bypasses
|
|
97
97
|
* this screen entirely.
|
|
@@ -151,7 +151,7 @@ export function planDaemonSelection({
|
|
|
151
151
|
*
|
|
152
152
|
* Deliberately strict: an answer that is not a number in range, or the create
|
|
153
153
|
* option, is NOT a state directory to be interpreted. Accepting free text here
|
|
154
|
-
* would
|
|
154
|
+
* would reintroduce the forbidden "type a path" prompt through the
|
|
155
155
|
* back door.
|
|
156
156
|
*/
|
|
157
157
|
export function resolveSelection(answer, { candidates = [], createOption = null } = {}) {
|
package/lib/effects.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// unit file or the service manager directly.
|
|
13
13
|
|
|
14
14
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
15
|
-
import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { cpSync, existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
|
|
17
17
|
import { dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
@@ -29,7 +29,12 @@ async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
|
29
29
|
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
|
30
30
|
const body = await res.json();
|
|
31
31
|
if (typeof body?.stateDir !== 'string') return { ok: false, reason: 'no stateDir in reply' };
|
|
32
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
stateDir: body.stateDir,
|
|
35
|
+
version: typeof body.version === 'string' ? body.version : null,
|
|
36
|
+
compat: Number.isInteger(body.compat) ? body.compat : null,
|
|
37
|
+
};
|
|
33
38
|
} catch (error) {
|
|
34
39
|
return { ok: false, reason: error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error) };
|
|
35
40
|
} finally {
|
|
@@ -64,9 +69,9 @@ function readTextFile(path) {
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
function installedVersionOf(pkg) {
|
|
72
|
+
function installedVersionOf(pkg, npmBin = 'npm') {
|
|
68
73
|
try {
|
|
69
|
-
const out = execFileSync(
|
|
74
|
+
const out = execFileSync(npmBin, ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
70
75
|
return JSON.parse(out)?.dependencies?.[pkg]?.version ?? null;
|
|
71
76
|
} catch {
|
|
72
77
|
// Unreadable is NOT "new enough": the cowork gate fails closed on null.
|
|
@@ -74,6 +79,44 @@ function installedVersionOf(pkg) {
|
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
function packageDependenciesOf(pkgSpec, npmBin = 'npm') {
|
|
83
|
+
const probe = capture(npmBin, ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
|
|
84
|
+
if (!probe.ok) return null;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(probe.stdout);
|
|
87
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolvePackageVersion(pkg, channel, npmBin = 'npm') {
|
|
94
|
+
const tag = channel === 'nightly' ? 'nightly' : 'latest';
|
|
95
|
+
const probe = capture(npmBin, ['view', `${pkg}@${tag}`, 'version', '--json'], { timeout: 15_000 });
|
|
96
|
+
if (!probe.ok) return '';
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(probe.stdout);
|
|
99
|
+
return typeof parsed === 'string' ? parsed : '';
|
|
100
|
+
} catch {
|
|
101
|
+
return /^\S+$/.test(probe.stdout.trim()) ? probe.stdout.trim() : '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function codexMarketplace() {
|
|
106
|
+
const probe = capture('codex', ['plugin', 'marketplace', 'list', '--json'], { timeout: 6_000 });
|
|
107
|
+
if (!probe.ok) return null;
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(probe.stdout)?.marketplaces?.find((m) => m?.name === 'ours-codex-marketplace') ?? null;
|
|
110
|
+
} catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasClaudePlugin() {
|
|
114
|
+
const probe = capture('claude', ['plugin', 'list', '--json'], { timeout: 6_000 });
|
|
115
|
+
if (!probe.ok) return false;
|
|
116
|
+
try { return JSON.parse(probe.stdout)?.some((p) => p?.id === 'ours@ours.network') ?? false; }
|
|
117
|
+
catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
77
120
|
/**
|
|
78
121
|
* A read-only command probe that NEVER throws and NEVER inherits stdio.
|
|
79
122
|
*
|
|
@@ -207,6 +250,7 @@ function knownStateDirsIn(home) {
|
|
|
207
250
|
* the orchestrator never reaches for a terminal itself.
|
|
208
251
|
*/
|
|
209
252
|
export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
|
|
253
|
+
const npmBin = env.OURS_NPM?.trim() || 'npm';
|
|
210
254
|
return {
|
|
211
255
|
home,
|
|
212
256
|
env,
|
|
@@ -221,6 +265,15 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
221
265
|
// pure planner already gated four ways, and this deletes exactly that.
|
|
222
266
|
removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
|
|
223
267
|
removeFile: (path) => { rmSync(resolve(path), { force: true }); },
|
|
268
|
+
copyDir: (source, destination) => {
|
|
269
|
+
mkdirSync(dirname(resolve(destination)), { recursive: true, mode: 0o700 });
|
|
270
|
+
cpSync(resolve(source), resolve(destination), {
|
|
271
|
+
recursive: true,
|
|
272
|
+
errorOnExist: true,
|
|
273
|
+
force: false,
|
|
274
|
+
preserveTimestamps: true,
|
|
275
|
+
});
|
|
276
|
+
},
|
|
224
277
|
// Rewrites a config file we do NOT own, so it keeps the file's own mode
|
|
225
278
|
// rather than imposing 0600: tightening the permissions of somebody else's
|
|
226
279
|
// ~/.codex/config.toml is a side effect nobody asked this to have.
|
|
@@ -266,16 +319,21 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
266
319
|
// invocation only and never to the installer's own process: a state
|
|
267
320
|
// directory selected by one run must not leak into anything the operator
|
|
268
321
|
// starts afterwards.
|
|
269
|
-
run: async (cmd, args, { env: extraEnv = null } = {}) => {
|
|
322
|
+
run: async (cmd, args, { env: extraEnv = null, stream = false } = {}) => {
|
|
270
323
|
// Always built from this layer's OWN env rather than left to spawnSync's
|
|
271
324
|
// implicit inheritance, so what a child receives is a property of the
|
|
272
325
|
// effects object a caller constructed and not of whatever ambient shell
|
|
273
326
|
// the installer happened to start in.
|
|
274
327
|
const childEnv = { ...env, ...(extraEnv ?? {}) };
|
|
275
|
-
const
|
|
328
|
+
const executable = cmd === 'npm' ? npmBin : cmd;
|
|
329
|
+
const r = spawnSync(executable, args, {
|
|
330
|
+
encoding: 'utf8',
|
|
331
|
+
stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
|
|
332
|
+
env: childEnv,
|
|
333
|
+
});
|
|
276
334
|
if (r.status !== 0) {
|
|
277
335
|
const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
|
|
278
|
-
throw new Error(`${
|
|
336
|
+
throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
|
|
279
337
|
}
|
|
280
338
|
return { ok: true, code: r.status, stdout: r.stdout ?? '' };
|
|
281
339
|
},
|
|
@@ -288,7 +346,11 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
288
346
|
const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
|
|
289
347
|
return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
|
|
290
348
|
},
|
|
291
|
-
installedVersion: installedVersionOf,
|
|
349
|
+
installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
|
|
350
|
+
packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
|
|
351
|
+
resolvePackageVersion: (pkg, channel) => resolvePackageVersion(pkg, channel, npmBin),
|
|
352
|
+
codexMarketplace,
|
|
353
|
+
hasClaudePlugin,
|
|
292
354
|
out: out ?? ((line) => process.stdout.write(`${line}\n`)),
|
|
293
355
|
// Never called when assumeYes: the orchestrator takes the default itself.
|
|
294
356
|
ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
|
|
@@ -296,7 +358,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
296
358
|
};
|
|
297
359
|
}
|
|
298
360
|
|
|
299
|
-
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
|
|
361
|
+
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
|
|
300
362
|
|
|
301
363
|
// -----------------------------------------------------------------------------
|
|
302
364
|
// THE PAIR
|
|
@@ -305,7 +367,7 @@ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFil
|
|
|
305
367
|
/**
|
|
306
368
|
* The environment that names ONE daemon, for a single child invocation.
|
|
307
369
|
*
|
|
308
|
-
*
|
|
370
|
+
* A state directory and its endpoint always travel
|
|
309
371
|
* together; "endpoint selected, state directory defaulted" must be unreachable.
|
|
310
372
|
* Every consumer downstream — ours-mcp's proxy, ours-fleet's per-role resolver,
|
|
311
373
|
* ours-hermes-install — reads these three names and falls back to `~/.ours` for
|
package/lib/extras.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
|
|
2
2
|
//
|
|
3
3
|
// The v3 installer keeps harness plugins, ours-fleet, voice setup and the
|
|
4
|
-
// copy-paste hand-off prompt
|
|
4
|
+
// copy-paste hand-off prompt.
|
|
5
5
|
//
|
|
6
6
|
// The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
|
|
7
7
|
// stdio adapter with no unit, and these extra phases preserve that boundary.
|
|
8
8
|
//
|
|
9
9
|
// Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
|
|
10
10
|
// terminal. Every function takes what was observed and returns a plan; the
|
|
11
|
-
// orchestrator
|
|
11
|
+
// orchestrator performs it.
|
|
12
12
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { pkgSpec } from './logic.mjs';
|
|
@@ -16,7 +16,7 @@ import { pkgSpec } from './logic.mjs';
|
|
|
16
16
|
const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
|
|
17
17
|
|
|
18
18
|
// -----------------------------------------------------------------------------
|
|
19
|
-
//
|
|
19
|
+
// Harness plugins
|
|
20
20
|
// -----------------------------------------------------------------------------
|
|
21
21
|
|
|
22
22
|
export const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
|
|
@@ -29,13 +29,12 @@ export const HARNESSES = [
|
|
|
29
29
|
];
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
32
|
+
* TWO OF THE THREE REGISTRATIONS CANNOT PERSIST AN ENVIRONMENT VALUE.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* wiring it up harder; none of the three registrations can carry a value:
|
|
34
|
+
* For a non-default state directory, planMcpAttachment exposes the required
|
|
35
|
+
* OURS_CONFIG value. Claude Code and Codex cannot persist that value in their
|
|
36
|
+
* plugin registrations, so the installer prints an explicit shell export and
|
|
37
|
+
* never claims it was applied automatically:
|
|
39
38
|
*
|
|
40
39
|
* Claude Code the marketplace plugin's mcpServers.ours is command+args, with
|
|
41
40
|
* no env key, and `claude plugin install` injects nothing per
|
|
@@ -46,9 +45,7 @@ export const HARNESSES = [
|
|
|
46
45
|
* Hermes renderConfigBlock is OUR writer, and now emits an `env:` block
|
|
47
46
|
* carrying OURS_CONFIG — so for Hermes the pair is real.
|
|
48
47
|
*
|
|
49
|
-
*
|
|
50
|
-
* directory, silently: the harness attaches to ~/.ours while the operator was
|
|
51
|
-
* told the run targeted somewhere else. The shape is:
|
|
48
|
+
* The supported behavior is:
|
|
52
49
|
*
|
|
53
50
|
* default state directory today's behaviour, byte for byte.
|
|
54
51
|
* Hermes, non-default real: the pair is handed to ours-hermes-install's
|
|
@@ -56,7 +53,7 @@ export const HARNESSES = [
|
|
|
56
53
|
* as the ours server's own env block.
|
|
57
54
|
* Claude / Codex, non-def install the plugin (it is still the right plugin)
|
|
58
55
|
* and PRINT the exact line the operator must add.
|
|
59
|
-
* Never claim
|
|
56
|
+
* Never claim automatic configuration in the screen text.
|
|
60
57
|
*
|
|
61
58
|
* Deliberately NOT done: registering a second, user-scoped `ours` MCP server via
|
|
62
59
|
* `claude mcp add --env`. Two `ours` servers in front of one harness, and which
|
|
@@ -94,7 +91,7 @@ const driveSteps = {
|
|
|
94
91
|
codex: (channel) => [
|
|
95
92
|
['codex', 'plugin', 'marketplace', 'add', CODEX_MARKET],
|
|
96
93
|
['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'],
|
|
97
|
-
//
|
|
94
|
+
// Product requirement in v2 and kept: choosing the Codex plugin also installs the
|
|
98
95
|
// ours-codex live launcher, in the same step.
|
|
99
96
|
['npm', 'i', '-g', pkgSpec('codex', channel)],
|
|
100
97
|
],
|
|
@@ -122,7 +119,7 @@ const driveSteps = {
|
|
|
122
119
|
* `env` is what an invocation must carry, and it is EMPTY unless the harness can
|
|
123
120
|
* genuinely apply it. `envLine` is what the operator is told. `claimsPair` is
|
|
124
121
|
* false whenever the pair is only printed — the screen text renderer reads it so
|
|
125
|
-
*
|
|
122
|
+
* the screen cannot claim that the pair was persisted where it was only printed.
|
|
126
123
|
*/
|
|
127
124
|
export function planHarnessPlugins({
|
|
128
125
|
harnesses = [],
|
|
@@ -170,7 +167,7 @@ export function planHarnessPlugins({
|
|
|
170
167
|
}
|
|
171
168
|
|
|
172
169
|
// -----------------------------------------------------------------------------
|
|
173
|
-
//
|
|
170
|
+
// Preconditions the operator must satisfy before installation
|
|
174
171
|
// -----------------------------------------------------------------------------
|
|
175
172
|
|
|
176
173
|
/**
|
|
@@ -331,7 +328,7 @@ export function buildHandoffPromptV3({
|
|
|
331
328
|
steps.push(
|
|
332
329
|
'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
|
|
333
330
|
+ ' and guide me through entering the @BotFather token locally. Register the\n'
|
|
334
|
-
+ ' route
|
|
331
|
+
+ ' route in the connector service that ours-install already started.',
|
|
335
332
|
);
|
|
336
333
|
}
|
|
337
334
|
if (steps.length === 0) return { text: '', empty: true };
|
package/lib/logic.mjs
CHANGED
|
@@ -107,7 +107,7 @@ export function resolveChannel(raw, selfVersion = '') {
|
|
|
107
107
|
|
|
108
108
|
// A published nightly carries the `-nightly.N` prerelease suffix the bump script writes.
|
|
109
109
|
export function isNightlyVersion(version) {
|
|
110
|
-
return
|
|
110
|
+
return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-nightly\.(?:0|[1-9]\d*)$/.test(String(version || ''));
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
// The npm dist-tag to install for one package key under a channel. Looks the key
|
|
@@ -207,15 +207,13 @@ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, broker
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
// ── Rooms / ours-cowork ────────────────────────────────────────────────────────
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// EXTERNAL daemon mode, so Rooms can now answer the same common-vs-dedicated
|
|
214
|
-
// question the Telegram connector does. Its exact contract, as reported:
|
|
210
|
+
// cowork supports an external-daemon mode, so Rooms can use the same
|
|
211
|
+
// common-vs-dedicated selection as the Telegram connector. Its public config
|
|
212
|
+
// contract is:
|
|
215
213
|
//
|
|
216
214
|
// ~/.ours-cowork/config.json carries an OPTIONAL `daemon` block.
|
|
217
215
|
// absent ⇒ EMBEDDED — cowork hosts its own daemon (what every install
|
|
218
|
-
// before
|
|
216
|
+
// before external mode existed, and still the safe answer for one already
|
|
219
217
|
// running that way).
|
|
220
218
|
// present ⇒ { mode: 'external', endpoint: 'http://127.0.0.1:<port>',
|
|
221
219
|
// stateDir: '<absolute ours-daemon state dir>' }
|
|
@@ -236,19 +234,12 @@ export const COWORK_DEFAULT_PORT = 3052;
|
|
|
236
234
|
export const COWORK_DAEMON_MODES = ['embedded', 'external'];
|
|
237
235
|
|
|
238
236
|
// Which cowork builds understand the `daemon` block. Its config is a STRICT
|
|
239
|
-
// document, so handing an unknown key to a build that predates
|
|
237
|
+
// document, so handing an unknown key to a build that predates external mode is not a
|
|
240
238
|
// harmless no-op — and cowork's boot is fail-closed, so the failure surfaces as a
|
|
241
239
|
// Rooms daemon that will not start rather than a warning.
|
|
242
240
|
//
|
|
243
|
-
// The
|
|
244
|
-
//
|
|
245
|
-
// @ours.network/cowork@nightly = 0.4.1-nightly.20260816.4aaf940
|
|
246
|
-
// gitHead 4aaf9406016098704d06b52352f7a38adc2ef160
|
|
247
|
-
// dist.shasum 5a6422409b1203a9bcc6aca33965fe47e9a5c17c
|
|
248
|
-
// depends on @ours.network/sdk 1.3.1; `latest` still 0.4.0
|
|
249
|
-
// and the packed tarball really carries it — dist/daemon.js and dist/cli.js
|
|
250
|
-
// contain the mode enum ["embedded","external"], the endpoint+stateDir pairing
|
|
251
|
-
// check, OURS_COWORK_DAEMON_MODE/_ENDPOINT/_STATE_DIR, and the daemon-token read.
|
|
241
|
+
// The first published cowork version whose package contains external-daemon
|
|
242
|
+
// config parsing, endpoint/state-directory pairing, and daemon-token loading.
|
|
252
243
|
export const COWORK_EXTERNAL_MIN_VERSION = '0.4.1-nightly.20260816.4aaf940';
|
|
253
244
|
|
|
254
245
|
// Does the cowork build actually on this machine support an external daemon?
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const NUM = '(?:0|[1-9]\\d*)';
|
|
4
|
+
const STABLE_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`);
|
|
5
|
+
const NIGHTLY_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}-nightly\\.${NUM}$`);
|
|
6
|
+
|
|
7
|
+
// npm view --json normally returns a JSON string, while older/custom npm wrappers may
|
|
8
|
+
// return the plain version. Accept exactly one scalar either way; arrays/objects are not
|
|
9
|
+
// a deliberate dist-tag resolution and therefore fail closed.
|
|
10
|
+
export function parseNpmVersion(text) {
|
|
11
|
+
const raw = String(text ?? '').trim();
|
|
12
|
+
if (!raw) return '';
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
return typeof parsed === 'string' ? parsed.trim() : '';
|
|
16
|
+
} catch {
|
|
17
|
+
return /^\S+$/.test(raw) ? raw : '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateChannelVersion(version, channel) {
|
|
22
|
+
const value = String(version ?? '').trim();
|
|
23
|
+
const selected = channel === 'nightly' ? 'nightly' : 'latest';
|
|
24
|
+
const valid = selected === 'nightly'
|
|
25
|
+
? NIGHTLY_VERSION.test(value)
|
|
26
|
+
: STABLE_VERSION.test(value);
|
|
27
|
+
return valid
|
|
28
|
+
? { ok: true, version: value, channel: selected }
|
|
29
|
+
: {
|
|
30
|
+
ok: false,
|
|
31
|
+
version: value,
|
|
32
|
+
channel: selected,
|
|
33
|
+
reason: selected === 'nightly'
|
|
34
|
+
? `expected an exact X.Y.Z-nightly.N version, got ${value || '<empty>'}`
|
|
35
|
+
: `expected an exact stable X.Y.Z version, got ${value || '<empty>'}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function exactVersion(version, channel) {
|
|
40
|
+
const checked = validateChannelVersion(version, channel);
|
|
41
|
+
if (!checked.ok) throw new Error(checked.reason);
|
|
42
|
+
return checked.version;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildClaudeMarketplace(version, channel) {
|
|
46
|
+
const pinned = exactVersion(version, channel);
|
|
47
|
+
return {
|
|
48
|
+
$schema: 'https://json.schemastore.org/claude-code-marketplace.json',
|
|
49
|
+
name: 'ours.network',
|
|
50
|
+
owner: {
|
|
51
|
+
name: 'Adapt Toolkit',
|
|
52
|
+
url: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
|
|
53
|
+
},
|
|
54
|
+
plugins: [{
|
|
55
|
+
name: 'ours',
|
|
56
|
+
displayName: 'ours',
|
|
57
|
+
description: 'Secure agent-to-agent communication channel over ADAPT: self-sovereign pubkey identity, end-to-end encryption.',
|
|
58
|
+
author: { name: 'Adapt Toolkit' },
|
|
59
|
+
homepage: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
|
|
60
|
+
repository: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
|
|
61
|
+
keywords: ['mcp', 'a2a', 'adapt', 'e2e', 'messaging'],
|
|
62
|
+
source: {
|
|
63
|
+
source: 'npm',
|
|
64
|
+
package: '@ours.network/claude-code',
|
|
65
|
+
version: pinned,
|
|
66
|
+
},
|
|
67
|
+
}],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildCodexMarketplace(version, channel) {
|
|
72
|
+
const pinned = exactVersion(version, channel);
|
|
73
|
+
return {
|
|
74
|
+
name: 'ours-codex-marketplace',
|
|
75
|
+
interface: { displayName: 'ours.network for Codex' },
|
|
76
|
+
plugins: [{
|
|
77
|
+
name: 'ours',
|
|
78
|
+
source: {
|
|
79
|
+
source: 'npm',
|
|
80
|
+
package: '@ours.network/codex',
|
|
81
|
+
version: pinned,
|
|
82
|
+
registry: 'https://registry.npmjs.org',
|
|
83
|
+
},
|
|
84
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
85
|
+
category: 'Productivity',
|
|
86
|
+
}],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function marketplacePaths(home) {
|
|
91
|
+
const root = join(home, '.ours', 'install', 'marketplaces');
|
|
92
|
+
const claudeRoot = join(root, 'claude-code');
|
|
93
|
+
const codexRoot = join(root, 'codex');
|
|
94
|
+
return {
|
|
95
|
+
claudeRoot,
|
|
96
|
+
claudeManifest: join(claudeRoot, '.claude-plugin', 'marketplace.json'),
|
|
97
|
+
codexRoot,
|
|
98
|
+
codexManifest: join(codexRoot, '.agents', 'plugins', 'marketplace.json'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function marketplaceJson(value) {
|
|
103
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
104
|
+
}
|
|
@@ -40,7 +40,7 @@ async function perform(effects, dryRun, label, thunk) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Which components are being removed alongside this daemon. Asked once, before
|
|
43
|
-
* anything is touched, so
|
|
43
|
+
* anything is touched, so a referenced-component refusal can be resolved in the same run
|
|
44
44
|
* rather than sending the operator away and back.
|
|
45
45
|
*
|
|
46
46
|
* Non-interactively the answer is NO — assume-yes never consents to removing
|
|
@@ -81,7 +81,7 @@ export async function runUninstall(argv, effects) {
|
|
|
81
81
|
effects.out(heading(`ours-uninstall --state-dir ${dir}`));
|
|
82
82
|
if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
|
|
83
83
|
|
|
84
|
-
//
|
|
84
|
+
// Read the documented OURS_UNINSTALL_* contract before any
|
|
85
85
|
// file is opened. A variable this uninstaller cannot deliver stops the run
|
|
86
86
|
// here, naming itself and naming the replacement, rather than being silently
|
|
87
87
|
// ignored while the operator's script reports success.
|