@ours.network/install 0.18.0-nightly.5 → 1.0.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/README.md +20 -2
- package/install.sh +3 -0
- package/lib/components.mjs +8 -8
- package/lib/detect.mjs +3 -3
- package/lib/effects.mjs +42 -10
- package/lib/extras.mjs +13 -16
- package/lib/logic.mjs +8 -17
- package/lib/marketplace.mjs +104 -0
- package/lib/orchestrate-uninstall.mjs +2 -2
- package/lib/orchestrate.mjs +94 -24
- package/lib/plan.mjs +9 -9
- package/lib/rerun.mjs +7 -7
- package/lib/target.mjs +13 -15
- package/lib/uninstall.mjs +19 -20
- 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
|
|
@@ -75,8 +90,11 @@ new Human identity and asks no ordinary setup questions, but it never bypasses
|
|
|
75
90
|
selection conflicts, connector moves, or destructive safeguards.
|
|
76
91
|
|
|
77
92
|
A non-default daemon must be selected coherently with a config file or matching
|
|
78
|
-
port and state directory.
|
|
79
|
-
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.
|
|
80
98
|
|
|
81
99
|
## Uninstall
|
|
82
100
|
|
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
|
/**
|
|
@@ -217,12 +217,12 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
|
|
|
217
217
|
to: { daemonUrl: endpoint, daemonStateDir: dir },
|
|
218
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.`,
|
|
219
219
|
};
|
|
220
|
-
// Never
|
|
220
|
+
// Never repoint an existing connector without a human, in any mode.
|
|
221
221
|
return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// -----------------------------------------------------------------------------
|
|
225
|
-
//
|
|
225
|
+
// cowork attachment
|
|
226
226
|
// -----------------------------------------------------------------------------
|
|
227
227
|
|
|
228
228
|
/**
|
|
@@ -341,7 +341,7 @@ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVe
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
// -----------------------------------------------------------------------------
|
|
344
|
-
//
|
|
344
|
+
// Component failures are isolated so the remaining components can continue.
|
|
345
345
|
// -----------------------------------------------------------------------------
|
|
346
346
|
|
|
347
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
|
@@ -69,9 +69,9 @@ function readTextFile(path) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function installedVersionOf(pkg) {
|
|
72
|
+
function installedVersionOf(pkg, npmBin = 'npm') {
|
|
73
73
|
try {
|
|
74
|
-
const out = execFileSync(
|
|
74
|
+
const out = execFileSync(npmBin, ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
75
75
|
return JSON.parse(out)?.dependencies?.[pkg]?.version ?? null;
|
|
76
76
|
} catch {
|
|
77
77
|
// Unreadable is NOT "new enough": the cowork gate fails closed on null.
|
|
@@ -79,8 +79,8 @@ function installedVersionOf(pkg) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function packageDependenciesOf(pkgSpec) {
|
|
83
|
-
const probe = capture(
|
|
82
|
+
function packageDependenciesOf(pkgSpec, npmBin = 'npm') {
|
|
83
|
+
const probe = capture(npmBin, ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
|
|
84
84
|
if (!probe.ok) return null;
|
|
85
85
|
try {
|
|
86
86
|
const parsed = JSON.parse(probe.stdout);
|
|
@@ -90,6 +90,33 @@ function packageDependenciesOf(pkgSpec) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
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
|
+
|
|
93
120
|
/**
|
|
94
121
|
* A read-only command probe that NEVER throws and NEVER inherits stdio.
|
|
95
122
|
*
|
|
@@ -223,6 +250,7 @@ function knownStateDirsIn(home) {
|
|
|
223
250
|
* the orchestrator never reaches for a terminal itself.
|
|
224
251
|
*/
|
|
225
252
|
export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
|
|
253
|
+
const npmBin = env.OURS_NPM?.trim() || 'npm';
|
|
226
254
|
return {
|
|
227
255
|
home,
|
|
228
256
|
env,
|
|
@@ -297,14 +325,15 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
297
325
|
// effects object a caller constructed and not of whatever ambient shell
|
|
298
326
|
// the installer happened to start in.
|
|
299
327
|
const childEnv = { ...env, ...(extraEnv ?? {}) };
|
|
300
|
-
const
|
|
328
|
+
const executable = cmd === 'npm' ? npmBin : cmd;
|
|
329
|
+
const r = spawnSync(executable, args, {
|
|
301
330
|
encoding: 'utf8',
|
|
302
331
|
stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
|
|
303
332
|
env: childEnv,
|
|
304
333
|
});
|
|
305
334
|
if (r.status !== 0) {
|
|
306
335
|
const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
|
|
307
|
-
throw new Error(`${
|
|
336
|
+
throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
|
|
308
337
|
}
|
|
309
338
|
return { ok: true, code: r.status, stdout: r.stdout ?? '' };
|
|
310
339
|
},
|
|
@@ -317,8 +346,11 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
317
346
|
const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
|
|
318
347
|
return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
|
|
319
348
|
},
|
|
320
|
-
installedVersion: installedVersionOf,
|
|
321
|
-
packageDependencies: packageDependenciesOf,
|
|
349
|
+
installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
|
|
350
|
+
packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
|
|
351
|
+
resolvePackageVersion: (pkg, channel) => resolvePackageVersion(pkg, channel, npmBin),
|
|
352
|
+
codexMarketplace,
|
|
353
|
+
hasClaudePlugin,
|
|
322
354
|
out: out ?? ((line) => process.stdout.write(`${line}\n`)),
|
|
323
355
|
// Never called when assumeYes: the orchestrator takes the default itself.
|
|
324
356
|
ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
|
|
@@ -326,7 +358,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
326
358
|
};
|
|
327
359
|
}
|
|
328
360
|
|
|
329
|
-
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, knownStateDirsIn };
|
|
361
|
+
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
|
|
330
362
|
|
|
331
363
|
// -----------------------------------------------------------------------------
|
|
332
364
|
// THE PAIR
|
|
@@ -335,7 +367,7 @@ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFil
|
|
|
335
367
|
/**
|
|
336
368
|
* The environment that names ONE daemon, for a single child invocation.
|
|
337
369
|
*
|
|
338
|
-
*
|
|
370
|
+
* A state directory and its endpoint always travel
|
|
339
371
|
* together; "endpoint selected, state directory defaulted" must be unreachable.
|
|
340
372
|
* Every consumer downstream — ours-mcp's proxy, ours-fleet's per-role resolver,
|
|
341
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
|
/**
|
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.
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -37,12 +37,31 @@ import { configJournal, reportRollback } from './journal.mjs';
|
|
|
37
37
|
import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
|
|
38
38
|
import { detectPlatform, resolveChannel } from './logic.mjs';
|
|
39
39
|
import { daemonEnv } from './effects.mjs';
|
|
40
|
+
import {
|
|
41
|
+
buildClaudeMarketplace, buildCodexMarketplace, marketplaceJson, marketplacePaths,
|
|
42
|
+
validateChannelVersion,
|
|
43
|
+
} from './marketplace.mjs';
|
|
40
44
|
import { USAGE } from './usage.mjs';
|
|
41
45
|
import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
|
|
42
46
|
|
|
43
47
|
export const EXIT_OK = 0;
|
|
44
48
|
export const EXIT_REFUSED = 2;
|
|
45
49
|
|
|
50
|
+
export async function resolveExactSuite(args, effects) {
|
|
51
|
+
const packages = {};
|
|
52
|
+
for (const key of ['mcp', 'claude-code', 'codex']) {
|
|
53
|
+
const version = await effects.resolvePackageVersion(`@ours.network/${key}`, args.channel);
|
|
54
|
+
const checked = validateChannelVersion(version, args.channel);
|
|
55
|
+
if (!checked.ok) return { ok: false, reason: `${key}: ${checked.reason}` };
|
|
56
|
+
packages[key] = checked.version;
|
|
57
|
+
}
|
|
58
|
+
const versions = new Set(Object.values(packages));
|
|
59
|
+
if (versions.size !== 1) {
|
|
60
|
+
return { ok: false, reason: `the ${args.channel} dist-tags are not lockstep (${Object.entries(packages).map(([k, v]) => `${k}=${v}`).join(', ')})` };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, channel: args.channel, version: versions.values().next().value, packages };
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
/**
|
|
47
66
|
* A dry run prints what it WOULD do and performs no mutation. The prefix is the
|
|
48
67
|
* existing installer's, kept so the two flows read the same.
|
|
@@ -200,7 +219,7 @@ function pairFor(plan, target) {
|
|
|
200
219
|
/**
|
|
201
220
|
* Which daemon is this run for?
|
|
202
221
|
*
|
|
203
|
-
* Never asks for a
|
|
222
|
+
* Never asks for a path, but when several daemons are detected
|
|
204
223
|
* it shows them and lets the operator pick, because choosing from what was found
|
|
205
224
|
* is not prompting for a state directory.
|
|
206
225
|
*
|
|
@@ -242,7 +261,7 @@ export async function runSelectionPhase(args, effects) {
|
|
|
242
261
|
const chosen = resolveSelection(answer, plan);
|
|
243
262
|
if (chosen.action === 'invalid') {
|
|
244
263
|
// Refused rather than guessed. Interpreting an unrecognised answer as a path
|
|
245
|
-
// would
|
|
264
|
+
// would reintroduce the forbidden "type a state directory" prompt through the
|
|
246
265
|
// through the back door.
|
|
247
266
|
effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
|
|
248
267
|
effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
|
|
@@ -256,10 +275,10 @@ export async function runSelectionPhase(args, effects) {
|
|
|
256
275
|
}
|
|
257
276
|
|
|
258
277
|
/**
|
|
259
|
-
*
|
|
278
|
+
* Run the daemon half of an installation and return the target decision plus step
|
|
260
279
|
* outcomes, or a refusal.
|
|
261
280
|
*/
|
|
262
|
-
export async function runDaemonPhase(args, effects) {
|
|
281
|
+
export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
263
282
|
const target = await resolveTarget({
|
|
264
283
|
stateDir: args.stateDir,
|
|
265
284
|
port: args.port,
|
|
@@ -301,8 +320,8 @@ export async function runDaemonPhase(args, effects) {
|
|
|
301
320
|
// daemon's broker is its own record, and re-asking would invite an operator to
|
|
302
321
|
// change it from a screen that is not about changing it. The broker question
|
|
303
322
|
// stays in v3: it is orthogonal to --state-dir/--port, so it
|
|
304
|
-
// does not violate
|
|
305
|
-
//
|
|
323
|
+
// does not violate the rule that state-directory and port values never appear
|
|
324
|
+
// in prompts.
|
|
306
325
|
if (creating) args.brokerUrl = await askBroker(args, effects);
|
|
307
326
|
|
|
308
327
|
const steps = [];
|
|
@@ -310,7 +329,9 @@ export async function runDaemonPhase(args, effects) {
|
|
|
310
329
|
// The operator CLI owns the shared daemon; ours-mcp is the per-session stdio
|
|
311
330
|
// adapter each harness spawns. Both are required, but only `ours daemon`
|
|
312
331
|
// participates in lifecycle or service management.
|
|
313
|
-
const mcpPkg =
|
|
332
|
+
const mcpPkg = exactSuite?.packages?.mcp
|
|
333
|
+
? `@ours.network/mcp@${exactSuite.packages.mcp}`
|
|
334
|
+
: componentSpec(componentByKey('mcp'), args.channel);
|
|
314
335
|
// The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
|
|
315
336
|
// connector packages it has no nightly dist-tag. Keep this untagged on every
|
|
316
337
|
// installer channel, and inspect that package's SDK dependency for the gate.
|
|
@@ -458,7 +479,7 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
458
479
|
}
|
|
459
480
|
|
|
460
481
|
/**
|
|
461
|
-
*
|
|
482
|
+
* Install the boot service, including the legacy-unit case.
|
|
462
483
|
*
|
|
463
484
|
* A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
|
|
464
485
|
* the file. `--force` is passed ONLY here, only for a unit
|
|
@@ -545,11 +566,11 @@ export async function askComponents(args, effects) {
|
|
|
545
566
|
}
|
|
546
567
|
|
|
547
568
|
/**
|
|
548
|
-
*
|
|
569
|
+
* A component that fails is reported with its retry command and
|
|
549
570
|
* the run CONTINUES — a failed component is never a reason to undo a successful
|
|
550
571
|
* one, or to undo the daemon.
|
|
551
572
|
*/
|
|
552
|
-
export async function runComponentPhase(args, effects, target) {
|
|
573
|
+
export async function runComponentPhase(args, effects, target, exactSuite = null) {
|
|
553
574
|
const dir = target.stateDir;
|
|
554
575
|
const endpoint = `http://127.0.0.1:${target.port}`;
|
|
555
576
|
const isDefaultStateDir = dir === join(effects.home, '.ours');
|
|
@@ -568,13 +589,15 @@ export async function runComponentPhase(args, effects, target) {
|
|
|
568
589
|
continue;
|
|
569
590
|
}
|
|
570
591
|
try {
|
|
571
|
-
results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
|
|
592
|
+
results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }));
|
|
572
593
|
} catch (error) {
|
|
573
594
|
// Reported with its reason and the exact manual command; the run continues.
|
|
574
595
|
// The retry carries the CHANNEL — a nightly run that hands the operator a
|
|
575
596
|
// stable retry command sends them straight into the split-brain install
|
|
576
597
|
// this phase exists to avoid.
|
|
577
|
-
const
|
|
598
|
+
const retrySpec = component.key === 'mcp' && exactSuite?.packages?.mcp
|
|
599
|
+
? `@ours.network/mcp@${exactSuite.packages.mcp}` : componentSpec(component, args.channel);
|
|
600
|
+
const retry = `npm i -g ${retrySpec}`;
|
|
578
601
|
effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
579
602
|
effects.out(info(`retry manually: ${retry}`));
|
|
580
603
|
results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
|
|
@@ -583,10 +606,11 @@ export async function runComponentPhase(args, effects, target) {
|
|
|
583
606
|
return summarizeComponentRun(results);
|
|
584
607
|
}
|
|
585
608
|
|
|
586
|
-
async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
|
|
609
|
+
async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }) {
|
|
587
610
|
if (component.key === 'mcp') {
|
|
588
611
|
const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
|
|
589
|
-
|
|
612
|
+
const exact = exactSuite?.packages?.mcp ? `@ours.network/mcp@${exactSuite.packages.mcp}` : plan.install[3];
|
|
613
|
+
await perform(effects, args.dryRun, `install ${exact}`, () => effects.run('npm', ['i', '-g', exact]));
|
|
590
614
|
if (Object.keys(plan.harnessEnv).length > 0) {
|
|
591
615
|
effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
|
|
592
616
|
}
|
|
@@ -787,7 +811,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
787
811
|
}
|
|
788
812
|
|
|
789
813
|
/**
|
|
790
|
-
*
|
|
814
|
+
* Install harness plugins after the daemon and components are ready.
|
|
791
815
|
*
|
|
792
816
|
* Two things this phase must never do, both inherited rules rather than new
|
|
793
817
|
* ones. It never DRIVES a command it could not identify — an alias or a wrapper
|
|
@@ -797,7 +821,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
797
821
|
* get the exact export line and no promise. Hermes' writer is ours, so its
|
|
798
822
|
* invocation carries the whole pair and the claim is true.
|
|
799
823
|
*/
|
|
800
|
-
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
|
|
824
|
+
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
|
|
801
825
|
effects.out(heading('Harness plugins'));
|
|
802
826
|
const detected = await effects.detectHarnesses();
|
|
803
827
|
for (const h of detected) {
|
|
@@ -822,6 +846,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
822
846
|
});
|
|
823
847
|
|
|
824
848
|
const rows = [];
|
|
849
|
+
const markets = marketplacePaths(effects.home);
|
|
825
850
|
for (const plan of plans) {
|
|
826
851
|
const row = { key: plan.name, label: `${plan.label} plugin` };
|
|
827
852
|
if (plan.action === 'skip') {
|
|
@@ -832,27 +857,65 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
832
857
|
if (plan.action === 'manual') {
|
|
833
858
|
// NEVER a dead end: the plugin is still installable, by hand, and the run
|
|
834
859
|
// says so instead of pretending the harness does not exist.
|
|
860
|
+
let manual = plan.manual;
|
|
861
|
+
if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
|
|
862
|
+
const isClaude = plan.name === 'claude-code';
|
|
863
|
+
const root = isClaude ? markets.claudeRoot : markets.codexRoot;
|
|
864
|
+
const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
|
|
865
|
+
const value = isClaude
|
|
866
|
+
? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
|
|
867
|
+
: buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
|
|
868
|
+
await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
|
|
869
|
+
manual = isClaude
|
|
870
|
+
? [`/plugin marketplace add ${root}`, '/plugin install ours']
|
|
871
|
+
: [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
|
|
872
|
+
}
|
|
835
873
|
effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
|
|
836
|
-
for (const step of
|
|
874
|
+
for (const step of manual) effects.out(info(` ${step}`));
|
|
837
875
|
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
838
876
|
continue;
|
|
839
877
|
}
|
|
840
878
|
|
|
879
|
+
let steps = plan.steps;
|
|
880
|
+
let manual = plan.manual;
|
|
881
|
+
if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
|
|
882
|
+
const isClaude = plan.name === 'claude-code';
|
|
883
|
+
const root = isClaude ? markets.claudeRoot : markets.codexRoot;
|
|
884
|
+
const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
|
|
885
|
+
const value = isClaude
|
|
886
|
+
? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
|
|
887
|
+
: buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
|
|
888
|
+
await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
|
|
889
|
+
if (!isClaude) {
|
|
890
|
+
const current = await effects.codexMarketplace();
|
|
891
|
+
const source = current?.marketplaceSource;
|
|
892
|
+
if (current && !(source?.sourceType === 'local' && source?.source === root)) {
|
|
893
|
+
await perform(effects, args.dryRun, 'remove moving Codex marketplace source', () => effects.run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace']));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
steps = isClaude
|
|
897
|
+
? [['claude', 'plugin', 'marketplace', 'add', root], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']]
|
|
898
|
+
: [['codex', 'plugin', 'marketplace', 'add', root], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'], ['npm', 'i', '-g', `@ours.network/codex@${exactSuite.packages.codex}`]];
|
|
899
|
+
manual = isClaude
|
|
900
|
+
? [`/plugin marketplace add ${root}`, '/plugin install ours']
|
|
901
|
+
: [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
|
|
902
|
+
}
|
|
903
|
+
|
|
841
904
|
const env = pairFor(plan, target);
|
|
842
905
|
let failed = null;
|
|
843
|
-
for (const step of
|
|
906
|
+
for (const step of steps) {
|
|
844
907
|
const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
|
|
845
908
|
if (!outcome.ok) { failed = outcome; break; }
|
|
846
909
|
}
|
|
847
910
|
if (failed) {
|
|
848
911
|
effects.out(info(`${plan.label} can still be installed by hand:`));
|
|
849
|
-
for (const step of
|
|
912
|
+
for (const step of manual) effects.out(info(` ${step}`));
|
|
850
913
|
rows.push({ ...row, state: 'failed', note: 'install step failed' });
|
|
851
914
|
continue;
|
|
852
915
|
}
|
|
853
916
|
if (plan.envLine) {
|
|
854
|
-
//
|
|
855
|
-
//
|
|
917
|
+
// This harness cannot persist the selected daemon pair. Say exactly what is
|
|
918
|
+
// true and how the operator can configure it explicitly.
|
|
856
919
|
effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
|
|
857
920
|
effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
|
|
858
921
|
}
|
|
@@ -1035,6 +1098,13 @@ export async function runInstall(argv, effects) {
|
|
|
1035
1098
|
// that wrapped the old installer keeps its meaning.
|
|
1036
1099
|
if (!runPreflight(effects).ok) return EXIT_OK;
|
|
1037
1100
|
|
|
1101
|
+
const exactSuite = await resolveExactSuite(args, effects);
|
|
1102
|
+
if (!exactSuite.ok) {
|
|
1103
|
+
effects.out(warn(`Release suite could not be resolved safely: ${exactSuite.reason}. Nothing was changed.`));
|
|
1104
|
+
return EXIT_REFUSED;
|
|
1105
|
+
}
|
|
1106
|
+
effects.out(ok(`Release channel: ${exactSuite.channel} → exact lockstep suite v${exactSuite.version}`));
|
|
1107
|
+
|
|
1038
1108
|
// Which daemon, before anything is decided about it. Only args.stateDir can
|
|
1039
1109
|
// change here; every refusal downstream is unaffected.
|
|
1040
1110
|
effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
|
|
@@ -1042,7 +1112,7 @@ export async function runInstall(argv, effects) {
|
|
|
1042
1112
|
if (selection.action === 'refuse') return EXIT_REFUSED;
|
|
1043
1113
|
|
|
1044
1114
|
effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
|
|
1045
|
-
const daemon = await runDaemonPhase(args, effects);
|
|
1115
|
+
const daemon = await runDaemonPhase(args, effects, exactSuite);
|
|
1046
1116
|
if (daemon.refused) return EXIT_REFUSED;
|
|
1047
1117
|
const target = daemon.target;
|
|
1048
1118
|
const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
|
|
@@ -1065,7 +1135,7 @@ export async function runInstall(argv, effects) {
|
|
|
1065
1135
|
}
|
|
1066
1136
|
|
|
1067
1137
|
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; run both shims as durable services.'));
|
|
1068
|
-
const components = await runComponentPhase(args, effects, target);
|
|
1138
|
+
const components = await runComponentPhase(args, effects, target, exactSuite);
|
|
1069
1139
|
for (const component of COMPONENTS) {
|
|
1070
1140
|
const state = components.installed.includes(component.key) ? 'installed'
|
|
1071
1141
|
: components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
|
|
@@ -1084,7 +1154,7 @@ export async function runInstall(argv, effects) {
|
|
|
1084
1154
|
effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
|
|
1085
1155
|
summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
|
|
1086
1156
|
effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
|
|
1087
|
-
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
|
|
1157
|
+
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite }));
|
|
1088
1158
|
effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
|
|
1089
1159
|
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
|
|
1090
1160
|
summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
|
package/lib/plan.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
4
4
|
// injects file reads, and every function returns a PLAN the caller renders and
|
|
5
5
|
// executes. Nothing here writes, spawns, or runs systemctl.
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
|
|
|
11
11
|
export const DEFAULT_SYSTEMD_UNIT = 'ours.service';
|
|
12
12
|
|
|
13
13
|
// -----------------------------------------------------------------------------
|
|
14
|
-
//
|
|
14
|
+
// Which unit file does this state directory own?
|
|
15
15
|
// -----------------------------------------------------------------------------
|
|
16
16
|
|
|
17
17
|
// 1–32 chars, alphanumeric with interior hyphens/underscores, no dots.
|
|
@@ -59,7 +59,7 @@ export function unitPathForStateDir(stateDir, home) {
|
|
|
59
59
|
* legacy — the unit published ours-mcp wrote: NO marker, ExecStart running
|
|
60
60
|
* ours-mcp. This is the migration blocker. `ours daemon
|
|
61
61
|
* install-service` refuses to overwrite an unmarked unit without
|
|
62
|
-
* --force, so
|
|
62
|
+
* --force, so service installation fails for every existing Linux user.
|
|
63
63
|
* foreign — unmarked and NOT recognisably ours-mcp's. Someone else's file.
|
|
64
64
|
*
|
|
65
65
|
* THE legacy/foreign SPLIT IS NOW THE ENTIRE SAFETY BOUNDARY. A `legacy` unit is
|
|
@@ -85,7 +85,7 @@ export function classifyUnit(text) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
88
|
+
* Decide what this run should do about the boot service.
|
|
89
89
|
*
|
|
90
90
|
* Returns one of:
|
|
91
91
|
* { action: 'install' } — call the CLI; it does the rest
|
|
@@ -202,9 +202,9 @@ export function legacyReplacedNotice(unitPath, stateDir) {
|
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
204
|
* The CLI invocation that installs the boot service. The unit NAME is not passed:
|
|
205
|
-
* ours-sdk #20 made the CLI derive it from --state-dir itself,
|
|
206
|
-
*
|
|
207
|
-
*
|
|
205
|
+
* ours-sdk #20 made the CLI derive it from --state-dir itself, so the installer
|
|
206
|
+
* neither passes a per-instance unit name nor writes the unit itself: it selects
|
|
207
|
+
* the daemon and the CLI names
|
|
208
208
|
* the unit. One derivation, in one place.
|
|
209
209
|
*/
|
|
210
210
|
export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
@@ -221,7 +221,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
// -----------------------------------------------------------------------------
|
|
224
|
-
//
|
|
224
|
+
// Daemon configuration file
|
|
225
225
|
// -----------------------------------------------------------------------------
|
|
226
226
|
|
|
227
227
|
/**
|
|
@@ -247,7 +247,7 @@ export function planDaemonConfig(existing, { port, stateDir, brokerUrl }) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
/**
|
|
250
|
-
* The ordered, announced steps for the daemon half of a run
|
|
250
|
+
* The ordered, announced steps for the daemon half of a run. Each is
|
|
251
251
|
* idempotent, and an `update` skips creation entirely: it never moves a port and
|
|
252
252
|
* never creates a second daemon.
|
|
253
253
|
*/
|
package/lib/rerun.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// ours-install v3 — re-running, and a second daemon alongside the first.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pure re-run and coexistence planning, like the earlier stages.
|
|
4
4
|
//
|
|
5
5
|
// Two properties this file exists to make checkable rather than hoped for:
|
|
6
6
|
//
|
|
7
|
-
// IDEMPOTENCE
|
|
7
|
+
// IDEMPOTENCE. Running the installer again with the same answers changes
|
|
8
8
|
// nothing except refreshed npm packages. Not "changes little" — nothing: no
|
|
9
9
|
// config written, no unit rewritten, no systemctl run, no daemon restarted.
|
|
10
10
|
//
|
|
11
|
-
// COEXISTENCE
|
|
11
|
+
// COEXISTENCE. Two daemons share no per-daemon artefact. Everything keyed
|
|
12
12
|
// to a daemon is derived from its state directory, so two state directories
|
|
13
13
|
// produce two of everything.
|
|
14
14
|
|
|
@@ -16,8 +16,8 @@ import { join, resolve } from 'node:path';
|
|
|
16
16
|
import { unitNameForStateDir } from './plan.mjs';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Everything that belongs to
|
|
20
|
-
*
|
|
19
|
+
* Everything that belongs to one daemon is derived from its state directory.
|
|
20
|
+
* Listing the artifacts in one place makes "these two daemons share
|
|
21
21
|
* nothing" a property a test can check instead of a claim in a document.
|
|
22
22
|
*
|
|
23
23
|
* `port` is included because it is per-daemon, but note it is NOT what identifies
|
|
@@ -55,7 +55,7 @@ export function daemonCollisions(a, b) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* The per-component coexistence rule
|
|
58
|
+
* The per-component coexistence rule, stated so the screen can never
|
|
59
59
|
* imply something the design does not do.
|
|
60
60
|
*
|
|
61
61
|
* mcp — coexists naturally. Each harness registration carries its own
|
|
@@ -107,7 +107,7 @@ export function summarizeRun(steps) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* Did a re-run leave the daemon alone?
|
|
110
|
+
* Did a re-run leave the daemon alone? An update never deletes state,
|
|
111
111
|
* never moves a port, and never creates a second daemon.
|
|
112
112
|
*/
|
|
113
113
|
export function assertUpdateLeftDaemonAlone({ before, after }) {
|
package/lib/target.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ours-install v3 — argument handling and daemon detection.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pure argument handling and daemon-target selection. The orchestrator
|
|
4
4
|
// injects the probe, the file reads and the port check, so the whole decision
|
|
5
5
|
// table is testable without a socket, a daemon or a filesystem.
|
|
6
6
|
//
|
|
@@ -19,17 +19,15 @@ export const FREE_PORT_SPAN = 1000;
|
|
|
19
19
|
|
|
20
20
|
// Ports that are other components' DEFAULTS, not facts about the machine: 3051
|
|
21
21
|
// is the Telegram connector's, 3052 is cowork's loopback console. The free-port
|
|
22
|
-
// search skips them; an explicit --port is still honoured as typed
|
|
22
|
+
// search skips them; an explicit --port is still honoured exactly as typed.
|
|
23
23
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// be merged once someone decides whether cowork's 3052 belongs in the shipped
|
|
28
|
-
// list — flagged rather than assumed.
|
|
24
|
+
// Keep both component defaults out of automatic selection: 3051 belongs to the
|
|
25
|
+
// Telegram connector and 3052 to cowork. Explicit operator selections remain
|
|
26
|
+
// valid.
|
|
29
27
|
export const INSTALL_RESERVED_PORTS = [3051, 3052];
|
|
30
28
|
|
|
31
29
|
// The CLI-owned PID record that proves a daemon belongs to a state directory
|
|
32
|
-
// even when nothing is recorded in its config
|
|
30
|
+
// even when nothing is recorded in its config.
|
|
33
31
|
export const CLI_PID_RECORD = 'ours-cli-daemon.json';
|
|
34
32
|
// The SAME record, written by a different daemon. `ours daemon start` writes
|
|
35
33
|
// ours-cli-daemon.json; prerelease ours-mcp wrote daemon.pid (packages/core
|
|
@@ -77,7 +75,7 @@ export function samePath(a, b) {
|
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
// -----------------------------------------------------------------------------
|
|
80
|
-
//
|
|
78
|
+
// Arguments
|
|
81
79
|
// -----------------------------------------------------------------------------
|
|
82
80
|
|
|
83
81
|
const VALUE_FLAGS = new Set(['--state-dir', '--port']);
|
|
@@ -143,14 +141,14 @@ export function parseInstallArgs(argv = [], env = {}, { home = homedir() } = {})
|
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
// -----------------------------------------------------------------------------
|
|
146
|
-
//
|
|
144
|
+
// Is there a daemon at this state directory?
|
|
147
145
|
// -----------------------------------------------------------------------------
|
|
148
146
|
|
|
149
147
|
/**
|
|
150
148
|
* The port to probe first: the one recorded in <state-dir>/config.json, else the
|
|
151
149
|
* built-in default. An explicit --port does NOT change where we look — the
|
|
152
150
|
* question is which daemon owns this directory, and that is answered by the
|
|
153
|
-
* directory's own record, not by what the operator typed
|
|
151
|
+
* directory's own record, not by what the operator typed.
|
|
154
152
|
*/
|
|
155
153
|
export function candidatePort(config) {
|
|
156
154
|
const recorded = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
|
|
@@ -158,7 +156,7 @@ export function candidatePort(config) {
|
|
|
158
156
|
}
|
|
159
157
|
|
|
160
158
|
/**
|
|
161
|
-
* Classify one probe result against the target state directory
|
|
159
|
+
* Classify one probe result against the target state directory.
|
|
162
160
|
*
|
|
163
161
|
* present — an ours daemon answered and reports THIS state directory
|
|
164
162
|
* foreign — something answered, but it is not an ours daemon, or it is one
|
|
@@ -246,12 +244,12 @@ export async function findDaemon({ stateDir, probe, readJson, readText }) {
|
|
|
246
244
|
}
|
|
247
245
|
// A FOREIGN DAEMON ON A PORT WE GUESSED IS NOT A REASON TO REFUSE.
|
|
248
246
|
//
|
|
249
|
-
//
|
|
247
|
+
// Regression guard. As first written, any foreign answer on the candidate port
|
|
250
248
|
// refused the run. But a state directory with no recorded port has told us
|
|
251
249
|
// nothing, so the candidate is the built-in default — which, on any machine
|
|
252
250
|
// that already runs a daemon, is where the FIRST daemon answers. The result
|
|
253
251
|
// was that a second daemon could never be created while the first was up:
|
|
254
|
-
//
|
|
252
|
+
// coexistence was unreachable, and the refusal's own advice ("re-run with
|
|
255
253
|
// --port for a free port") could not work either, because an explicit --port
|
|
256
254
|
// deliberately does not change where we look.
|
|
257
255
|
//
|
|
@@ -280,7 +278,7 @@ export async function findDaemon({ stateDir, probe, readJson, readText }) {
|
|
|
280
278
|
}
|
|
281
279
|
|
|
282
280
|
// -----------------------------------------------------------------------------
|
|
283
|
-
//
|
|
281
|
+
// Derived-port rule
|
|
284
282
|
// -----------------------------------------------------------------------------
|
|
285
283
|
|
|
286
284
|
/**
|
package/lib/uninstall.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ours-uninstall v3 — removing ONE daemon, and the non-interactive contract.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pure uninstall planning, like every other stage: the caller
|
|
4
4
|
// injects the file reads and this returns a plan.
|
|
5
5
|
//
|
|
6
6
|
// ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// THE BIAS OF THIS WHOLE FILE IS TOWARD KEEPING THINGS. State is kept by default,
|
|
12
12
|
// a component's config file is kept even when its daemon keys are removed, and
|
|
13
13
|
// global packages are kept while any other daemon still needs them. The one
|
|
14
|
-
// destructive operation, --purge, is separately gated
|
|
14
|
+
// destructive operation, --purge, is separately gated because
|
|
15
15
|
// it deletes identity keys, and no other step here is irreversible.
|
|
16
16
|
|
|
17
17
|
import { join, resolve } from 'node:path';
|
|
@@ -22,9 +22,9 @@ import { canonHarnesses } from './logic.mjs';
|
|
|
22
22
|
/**
|
|
23
23
|
* Does this directory even look like an ours state directory?
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Purge applies to any explicitly selected ours state directory, not only one
|
|
26
|
+
* created by this installer. This does not add a provenance gate: it asks
|
|
27
|
+
* "is this a state directory at all",
|
|
28
28
|
* not "is it ours". With provenance gone, the typed path would otherwise be the
|
|
29
29
|
* only thing between `ours-uninstall --state-dir ~ --purge` and a deleted home
|
|
30
30
|
* directory, and a typed path is no protection against a path typed exactly as
|
|
@@ -85,7 +85,7 @@ export function inspectComponentConfig(path, { readText } = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
88
|
+
* Refuse if a component still points at this daemon.
|
|
89
89
|
*
|
|
90
90
|
* Read the connector's and cowork's config files; if either names this daemon's
|
|
91
91
|
* endpoint or its state directory, list them and stop. Exit 2, nothing removed.
|
|
@@ -160,7 +160,7 @@ export function planComponentDetach(key, existing) {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
|
-
*
|
|
163
|
+
* Stop the boot service, then the daemon itself.
|
|
164
164
|
*
|
|
165
165
|
* Both delegate their refusals rather than reimplementing them: `ours daemon
|
|
166
166
|
* uninstall-service` refuses to remove a unit not marked as CLI-managed, and
|
|
@@ -186,14 +186,13 @@ export function planDaemonRemoval({ stateDir, cliStartedIt }) {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
*
|
|
189
|
+
* State is kept unless every purge gate opens.
|
|
190
190
|
*
|
|
191
191
|
* --purge given — never the default; deleting identity keys is opt-in.
|
|
192
|
-
* interactive — an unattended run never deletes state
|
|
192
|
+
* interactive — an unattended run never deletes state.
|
|
193
193
|
* looks like a state directory — see looksLikeStateDir.
|
|
194
|
-
* typed confirmation — the full path, not a y/N.
|
|
195
|
-
*
|
|
196
|
-
* provenance gone this is the last thing standing between
|
|
194
|
+
* typed confirmation — the full path, not a y/N. Without a provenance
|
|
195
|
+
* condition, this is the last thing standing between
|
|
197
196
|
* a mistyped command and someone's identity keys.
|
|
198
197
|
*
|
|
199
198
|
* Returns the exact directory, never a glob or a parent, and only when every gate
|
|
@@ -219,7 +218,7 @@ export function planStatePurge({ stateDir, purge = false, assumeYes = false, exi
|
|
|
219
218
|
}
|
|
220
219
|
|
|
221
220
|
/**
|
|
222
|
-
*
|
|
221
|
+
* Global packages are shared. Remove them only when no other state
|
|
223
222
|
* directory on this machine still has a daemon config; otherwise keep them and
|
|
224
223
|
* say which daemon still needs them.
|
|
225
224
|
*/
|
|
@@ -257,7 +256,7 @@ export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pl
|
|
|
257
256
|
}
|
|
258
257
|
|
|
259
258
|
// -----------------------------------------------------------------------------
|
|
260
|
-
//
|
|
259
|
+
// Harness plugins written by the installer
|
|
261
260
|
// -----------------------------------------------------------------------------
|
|
262
261
|
|
|
263
262
|
/**
|
|
@@ -397,7 +396,7 @@ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDa
|
|
|
397
396
|
}
|
|
398
397
|
|
|
399
398
|
/**
|
|
400
|
-
*
|
|
399
|
+
* Decide which harnesses to detach using the documented environment contract.
|
|
401
400
|
*
|
|
402
401
|
* The nightly uninstaller let the operator choose: a `checkboxSelect` picker on a
|
|
403
402
|
* terminal, `OURS_UNINSTALL` without one, and NOTHING removed when it had
|
|
@@ -472,7 +471,7 @@ export function selectHarnesses(plugins, chosen) {
|
|
|
472
471
|
}
|
|
473
472
|
|
|
474
473
|
/**
|
|
475
|
-
*
|
|
474
|
+
* Preserve the full uninstall order, refusing before any mutation rather than stopping
|
|
476
475
|
* half-way.
|
|
477
476
|
*/
|
|
478
477
|
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
|
|
@@ -528,7 +527,7 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
|
|
|
528
527
|
}
|
|
529
528
|
|
|
530
529
|
// -----------------------------------------------------------------------------
|
|
531
|
-
//
|
|
530
|
+
// Non-interactive behavior
|
|
532
531
|
// -----------------------------------------------------------------------------
|
|
533
532
|
|
|
534
533
|
/**
|
|
@@ -565,7 +564,7 @@ export function refusalSurvivesAssumeYes(refusal) {
|
|
|
565
564
|
}
|
|
566
565
|
|
|
567
566
|
// -----------------------------------------------------------------------------
|
|
568
|
-
//
|
|
567
|
+
// Documented OURS_UNINSTALL_* environment contract
|
|
569
568
|
// -----------------------------------------------------------------------------
|
|
570
569
|
|
|
571
570
|
/**
|
|
@@ -592,13 +591,13 @@ export function refusalSurvivesAssumeYes(refusal) {
|
|
|
592
591
|
* that matters most.
|
|
593
592
|
* OURS_UNINSTALL_TELEGRAM
|
|
594
593
|
* OURS_UNINSTALL_ROOMS at the value `detach`, which is exactly the
|
|
595
|
-
* confirmation
|
|
594
|
+
* confirmation that the interactive flow asks a human for and which an
|
|
596
595
|
* unattended run otherwise cannot give, so today the
|
|
597
596
|
* run refuses instead of detaching.
|
|
598
597
|
*
|
|
599
598
|
* REFUSED
|
|
600
599
|
* OURS_UNINSTALL_DATA=yes v3 never deletes state without a human
|
|
601
|
-
* present
|
|
600
|
+
* present; that rule protects private
|
|
602
601
|
* keys that exist nowhere else.
|
|
603
602
|
* OURS_UNINSTALL_PROFILE names an entry in the daemon registry. v3
|
|
604
603
|
* has no registry; the selector is --state-dir.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet, harness plugins, Human identity, stopped Fleet starter, progress UI, and guided next steps.",
|
|
6
6
|
"type": "module",
|