@ours.network/install 0.18.0-nightly.1 → 0.18.0-nightly.3
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 +59 -17
- package/lib/components.mjs +13 -12
- package/lib/extras.mjs +69 -38
- package/lib/orchestrate.mjs +56 -123
- package/lib/ui.mjs +15 -0
- package/lib/usage.mjs +5 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,27 +1,64 @@
|
|
|
1
1
|
# @ours.network/install — `ours-install`
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
3
|
+
The all-in-one installer for the ours.network stack. One run installs and
|
|
4
|
+
configures the shared daemon, MCP adapter, cowork, Telegram connector, Fleet,
|
|
5
|
+
and plugins for every safely detected agent harness.
|
|
5
6
|
|
|
6
7
|
```sh
|
|
7
8
|
npm install --global @ours.network/install
|
|
8
9
|
ours-install
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
The
|
|
12
|
+
The normal flow uses one daemon at `~/.ours` on port 3050, shows an eight-stage
|
|
13
|
+
progress bar, and asks only for information it cannot safely infer (normally the
|
|
14
|
+
Human identity's display name). Existing daemon conflicts and moving a Telegram
|
|
15
|
+
connector from another daemon still require explicit confirmation.
|
|
16
|
+
|
|
17
|
+
## What the installer does
|
|
18
|
+
|
|
19
|
+
- Installs `@ours.network/cli`, `@ours.network/mcp`,
|
|
20
|
+
`@ours.network/tg-connector`, `@ours.network/cowork`, and
|
|
21
|
+
`@ours.network/fleet` on one release channel.
|
|
22
|
+
- Configures, starts, and enables the single shared daemon.
|
|
23
|
+
- Creates the daemon's Human identity (historically called the root identity),
|
|
24
|
+
or preserves the existing one on a re-run.
|
|
25
|
+
- Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
|
|
26
|
+
installations.
|
|
27
|
+
- Configures and starts cowork against the shared daemon.
|
|
28
|
+
- Configures Telegram against the same daemon, but does **not** start it.
|
|
29
|
+
- Runs Fleet's host initialization and, when `~/fleet.yaml` is absent, writes a
|
|
30
|
+
conservative stopped starter with `FleetCoordinator`, a `fleet-health`
|
|
31
|
+
watchdog, and a ten-minute `coordinator_health` loop. An existing
|
|
32
|
+
`~/fleet.yaml` is never overwritten.
|
|
33
|
+
|
|
34
|
+
The operator CLI owns daemon configuration, lifecycle, and boot persistence.
|
|
35
|
+
The MCP package is only the stdio adapter spawned by agent harnesses; the
|
|
36
|
+
installer never asks `ours-mcp` to start a daemon.
|
|
37
|
+
|
|
38
|
+
The external-history storage epoch is a clean breaking reset. The daemon refuses
|
|
39
|
+
old packet state without modifying it, and this installer never migrates, purges,
|
|
40
|
+
or silently replaces identities, contacts, invites, pending payloads, or history.
|
|
41
|
+
Back up any wanted old state and remove it explicitly before starting the new epoch.
|
|
42
|
+
|
|
43
|
+
## What remains stopped
|
|
44
|
+
|
|
45
|
+
Telegram and Fleet are installed but intentionally not started. Review and
|
|
46
|
+
activate them when ready:
|
|
12
47
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
48
|
+
```sh
|
|
49
|
+
# After configuring a Telegram bot and route locally:
|
|
50
|
+
ours-tg-connector install-service
|
|
51
|
+
|
|
52
|
+
# After reviewing ~/fleet.yaml:
|
|
53
|
+
ours-fleet doctor
|
|
54
|
+
ours-fleet config
|
|
55
|
+
ours-fleet up
|
|
56
|
+
ours-fleet ls
|
|
57
|
+
```
|
|
20
58
|
|
|
21
|
-
The
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
separate per-harness daemon registry.
|
|
59
|
+
The final installer screen repeats these commands and provides a copy-paste
|
|
60
|
+
prompt for Claude Code, Codex, or Hermes. The agent should guide local bot-token
|
|
61
|
+
entry without asking the user to paste the secret into chat.
|
|
25
62
|
|
|
26
63
|
## Preview and automation
|
|
27
64
|
|
|
@@ -32,8 +69,13 @@ ours-install --state-dir /absolute/path --port 3070
|
|
|
32
69
|
```
|
|
33
70
|
|
|
34
71
|
Dry-run walks the real plan without writing files, installing packages, starting
|
|
35
|
-
processes, or changing services.
|
|
36
|
-
|
|
72
|
+
processes, or changing services. `OURS_ASSUME_YES=1` uses the OS username for a
|
|
73
|
+
new Human identity and asks no ordinary setup questions, but it never bypasses
|
|
74
|
+
selection conflicts, connector moves, or destructive safeguards.
|
|
75
|
+
|
|
76
|
+
A non-default daemon must be selected coherently with a config file or matching
|
|
77
|
+
port and state directory. Harnesses and the generated Fleet role receive that
|
|
78
|
+
selection through `OURS_CONFIG`; there is no per-application daemon.
|
|
37
79
|
|
|
38
80
|
## Uninstall
|
|
39
81
|
|
|
@@ -53,7 +95,7 @@ dist-tags. Without an override, the installer's own version selects the channel.
|
|
|
53
95
|
|
|
54
96
|
## Environment
|
|
55
97
|
|
|
56
|
-
- `OURS_ASSUME_YES=1`: accept defaults without prompting.
|
|
98
|
+
- `OURS_ASSUME_YES=1`: accept safe defaults without prompting.
|
|
57
99
|
- `OURS_INSTALL_DRY_RUN=1`: preview without mutation.
|
|
58
100
|
- `OURS_NPM`: npm executable.
|
|
59
101
|
- `OURS_CONFIG`: explicit daemon configuration file.
|
package/lib/components.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// contents and the installed versions, and every function returns a plan.
|
|
7
7
|
//
|
|
8
8
|
// The three components are the MCP server, the Telegram connector and cowork.
|
|
9
|
-
// None of them IS the daemon; all three attach to one.
|
|
10
|
-
//
|
|
9
|
+
// None of them IS the daemon; all three attach to one. A normal install carries
|
|
10
|
+
// the complete stack, while the messenger remains deliberately out of scope.
|
|
11
11
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { pkgSpec, resolveChannel } from './logic.mjs';
|
|
@@ -29,8 +29,8 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
|
|
|
29
29
|
// has to be impossible rather than discouraged.
|
|
30
30
|
export const COMPONENTS = [
|
|
31
31
|
{ key: 'mcp', label: 'MCP server for your harness', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
|
|
32
|
-
{ key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default:
|
|
33
|
-
{ key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default:
|
|
32
|
+
{ key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: true },
|
|
33
|
+
{ key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: true },
|
|
34
34
|
];
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -105,10 +105,10 @@ export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? jo
|
|
|
105
105
|
// -----------------------------------------------------------------------------
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Which components this run installs.
|
|
109
|
-
* cowork
|
|
110
|
-
*
|
|
111
|
-
*
|
|
108
|
+
* Which components this run installs. The default is the complete stack: MCP,
|
|
109
|
+
* Telegram and cowork all attach to the same daemon. Explicit programmatic
|
|
110
|
+
* answers are retained for compatibility, but the public installer no longer
|
|
111
|
+
* asks the user to assemble the product one package at a time.
|
|
112
112
|
*
|
|
113
113
|
* `installed` marks a component already present so the question reads "keep it?"
|
|
114
114
|
* — and DECLINING AN ALREADY-INSTALLED COMPONENT NEVER UNINSTALLS IT. Removal is
|
|
@@ -203,10 +203,11 @@ 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
|
-
|
|
206
|
+
// Telegram is intentionally staged but stopped. Its CLI currently couples
|
|
207
|
+
// service installation with `enable --now`, so calling it here would violate
|
|
208
|
+
// the installer's promise not to launch Telegram before a bot is configured.
|
|
209
|
+
// The end screen gives this exact command as the explicit opt-in start step.
|
|
210
|
+
service: null,
|
|
210
211
|
untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
|
|
211
212
|
};
|
|
212
213
|
if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
|
package/lib/extras.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// terminal. Every function takes what was observed and returns a plan; the
|
|
11
11
|
// orchestrator (a later PR) is what performs it.
|
|
12
12
|
|
|
13
|
-
import { join, resolve } from 'node:path';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { pkgSpec } from './logic.mjs';
|
|
15
15
|
|
|
16
16
|
const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
|
|
@@ -213,28 +213,63 @@ export function restartHints(summary = []) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
// -----------------------------------------------------------------------------
|
|
216
|
-
// ours-fleet —
|
|
216
|
+
// ours-fleet — installed and staged, never started implicitly
|
|
217
217
|
// -----------------------------------------------------------------------------
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
* OURS_CONFIG ?? ~/.ours/config.json, then OURS_PORT / OURS_STATE_DIR /
|
|
227
|
-
* OURS_API_TOKEN — and it does so PER ROLE, through
|
|
228
|
-
* resolveEndpoint({ ...process.env, ...role.env }). Different roles can already
|
|
229
|
-
* target different daemons. Temp supervisors inherit the same four names.
|
|
230
|
-
*
|
|
231
|
-
* So for a non-default state directory the installer's ONLY job is to say the
|
|
232
|
-
* one fleet.yaml line that points a role at the daemon this run created. Saying
|
|
233
|
-
* it is the whole feature; anything more would be configuring a tool that is
|
|
234
|
-
* already correct.
|
|
235
|
-
*/
|
|
236
|
-
export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
|
|
219
|
+
export const fleetConfigPath = (home) => join(resolve(home), 'fleet.yaml');
|
|
220
|
+
|
|
221
|
+
const yamlString = (value) => JSON.stringify(String(value));
|
|
222
|
+
|
|
223
|
+
/** A conservative, useful first fleet: one coordinator, one watchdog, one loop. */
|
|
224
|
+
export function defaultFleetConfig({ home, stateDir, isDefaultStateDir } = {}) {
|
|
225
|
+
const cwd = resolve(home);
|
|
237
226
|
const config = stateDir ? cfgPath(stateDir) : null;
|
|
227
|
+
const roleEnv = isDefaultStateDir || !config
|
|
228
|
+
? ''
|
|
229
|
+
: `\n env:\n OURS_CONFIG: ${yamlString(config)}`;
|
|
230
|
+
return `# Generated by ours-install. Review this file before starting Fleet.\n`
|
|
231
|
+
+ `defaults:\n`
|
|
232
|
+
+ ` harness: codex\n`
|
|
233
|
+
+ ` session: acp\n`
|
|
234
|
+
+ ` permissions:\n`
|
|
235
|
+
+ ` approval: allow\n`
|
|
236
|
+
+ ` filesystem: workspace\n`
|
|
237
|
+
+ ` unattended: wait\n`
|
|
238
|
+
+ ` monitor:\n`
|
|
239
|
+
+ ` mode: fleet\n\n`
|
|
240
|
+
+ `roles:\n`
|
|
241
|
+
+ ` FleetCoordinator:\n`
|
|
242
|
+
+ ` identity: FleetCoordinator\n`
|
|
243
|
+
+ ` cwd: ${yamlString(cwd)}\n`
|
|
244
|
+
+ ` mission: Coordinate durable agent work, delegate bounded tasks, and report material outcomes.\n`
|
|
245
|
+
+ ` bio: Fleet coordinator for this host; engage it to assign work or check agent status.\n`
|
|
246
|
+
+ ` persona: |\n`
|
|
247
|
+
+ ` Keep a concise durable worklog. Preserve user state, verify delegated results,\n`
|
|
248
|
+
+ ` and escalate decisions that require new authority. Report material progress only.${roleEnv}\n\n`
|
|
249
|
+
+ `watchdogs:\n`
|
|
250
|
+
+ ` fleet-health:\n`
|
|
251
|
+
+ ` coordinator: FleetCoordinator\n`
|
|
252
|
+
+ ` watch: [FleetCoordinator]\n`
|
|
253
|
+
+ ` harness: codex\n`
|
|
254
|
+
+ ` session: acp\n`
|
|
255
|
+
+ ` interval: 10m\n`
|
|
256
|
+
+ ` timeout: 8m\n\n`
|
|
257
|
+
+ `loops:\n`
|
|
258
|
+
+ ` coordinator_health:\n`
|
|
259
|
+
+ ` roles: [FleetCoordinator]\n`
|
|
260
|
+
+ ` interval: 10m\n`
|
|
261
|
+
+ ` initial_delay: 10m\n`
|
|
262
|
+
+ ` enabled: true\n`
|
|
263
|
+
+ ` prompt: |\n`
|
|
264
|
+
+ ` Perform one bounded fleet health pass. Reconcile active work, specialist state,\n`
|
|
265
|
+
+ ` declared blockers, and CI. Unstick only safe in-scope work. If nothing material\n`
|
|
266
|
+
+ ` changed, complete silently.\n`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function planFleet({ home, stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
|
|
270
|
+
const config = stateDir ? cfgPath(stateDir) : null;
|
|
271
|
+
const resolvedHome = home ?? (stateDir ? dirname(resolve(stateDir)) : null);
|
|
272
|
+
const path = resolvedHome ? fleetConfigPath(resolvedHome) : null;
|
|
238
273
|
const plan = {
|
|
239
274
|
key: 'fleet',
|
|
240
275
|
label: 'ours-fleet',
|
|
@@ -251,12 +286,13 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
|
|
|
251
286
|
// this defers to it rather than restating it.
|
|
252
287
|
install: ['npm', 'i', '-g', pkgSpec('fleet', channel)],
|
|
253
288
|
init: ['ours-fleet', 'init'],
|
|
254
|
-
|
|
255
|
-
|
|
289
|
+
configPath: path,
|
|
290
|
+
config: resolvedHome ? defaultFleetConfig({ home: resolvedHome, stateDir, isDefaultStateDir }) : null,
|
|
291
|
+
writes: path ? [path] : [],
|
|
256
292
|
roleEnv: isDefaultStateDir ? {} : { OURS_CONFIG: config },
|
|
257
293
|
instruction: isDefaultStateDir
|
|
258
|
-
?
|
|
259
|
-
: `
|
|
294
|
+
? `review ${path}, then run ours-fleet doctor and ours-fleet up when you are ready`
|
|
295
|
+
: `review ${path}; its coordinator is pinned to this daemon with OURS_CONFIG=${config}`,
|
|
260
296
|
};
|
|
261
297
|
return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
|
|
262
298
|
}
|
|
@@ -266,14 +302,9 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
|
|
|
266
302
|
// -----------------------------------------------------------------------------
|
|
267
303
|
|
|
268
304
|
/**
|
|
269
|
-
* buildHandoffPrompt is
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* DEFAULT STATE DIRECTORY → THE TEXT IS UNCHANGED, BYTE FOR BYTE. That is the
|
|
273
|
-
* overwhelming majority case and it is pinned by a test, because the agent on
|
|
274
|
-
* the other end of this prompt configures fleet roles and harness environments,
|
|
275
|
-
* and a stray line about a state directory the user never chose is a worse
|
|
276
|
-
* outcome than no line at all.
|
|
305
|
+
* buildHandoffPrompt is pure, renumbers automatically, and drops steps for
|
|
306
|
+
* components that were not installed. The default path needs no daemon preamble;
|
|
307
|
+
* a non-default path names the exact config so the assistant cannot guess.
|
|
277
308
|
*/
|
|
278
309
|
export function buildHandoffPromptV3({
|
|
279
310
|
identity = false,
|
|
@@ -291,16 +322,16 @@ export function buildHandoffPromptV3({
|
|
|
291
322
|
}
|
|
292
323
|
if (fleet) {
|
|
293
324
|
steps.push(
|
|
294
|
-
'
|
|
295
|
-
+ '
|
|
296
|
-
+ '
|
|
325
|
+
'Review ~/fleet.yaml with me. It already contains a stopped FleetCoordinator,\n'
|
|
326
|
+
+ ' a fleet-health watchdog, and a 10-minute coordinator health loop. Ask\n'
|
|
327
|
+
+ ' before changing permissions or starting the fleet.',
|
|
297
328
|
);
|
|
298
329
|
}
|
|
299
330
|
if (telegram) {
|
|
300
331
|
steps.push(
|
|
301
|
-
'
|
|
302
|
-
+ '
|
|
303
|
-
+ '
|
|
332
|
+
'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
|
|
333
|
+
+ ' and guide me through entering the @BotFather token locally. Register the\n'
|
|
334
|
+
+ ' route, then start the connector only after I approve.',
|
|
304
335
|
);
|
|
305
336
|
}
|
|
306
337
|
if (steps.length === 0) return { text: '', empty: true };
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -35,10 +35,10 @@ import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } fro
|
|
|
35
35
|
import { summarizeRun } from './rerun.mjs';
|
|
36
36
|
import { configJournal, reportRollback } from './journal.mjs';
|
|
37
37
|
import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
|
|
38
|
-
import { detectPlatform, resolveChannel
|
|
38
|
+
import { detectPlatform, resolveChannel } from './logic.mjs';
|
|
39
39
|
import { daemonEnv } from './effects.mjs';
|
|
40
40
|
import { USAGE } from './usage.mjs';
|
|
41
|
-
import { ok, info, warn, heading, banner, box, c } from './ui.mjs';
|
|
41
|
+
import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
|
|
42
42
|
|
|
43
43
|
export const EXIT_OK = 0;
|
|
44
44
|
export const EXIT_REFUSED = 2;
|
|
@@ -418,42 +418,15 @@ function readChanged(stdout) {
|
|
|
418
418
|
/**
|
|
419
419
|
* THE QUESTION NOBODY WAS ASKING.
|
|
420
420
|
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* to its own default, and tg and cowork (both default false) were skipped in
|
|
425
|
-
* silence. The screen then printed "cowork — not installed" as though that had
|
|
426
|
-
* been a decision.
|
|
427
|
-
*
|
|
428
|
-
* This was the ONE phase with no question. Harness plugins ask, ours-fleet asks,
|
|
429
|
-
* voice asks, the broker asks. Components did not.
|
|
430
|
-
*
|
|
431
|
-
* IT IS NOT A COWORK BUG, and fixing only the component that got reported would
|
|
432
|
-
* have left the identical defect live under a different label: the Telegram
|
|
433
|
-
* connector is unreachable by exactly the same route, and nobody noticed only
|
|
434
|
-
* because tg is usually configured somewhere else.
|
|
435
|
-
*
|
|
436
|
-
* A REQUIRED component is never asked — since #74 the MCP package IS the daemon,
|
|
437
|
-
* the daemon phase has already installed it, and a question whose only honest
|
|
438
|
-
* answer is yes is not a question. Non-interactive runs are unchanged: assumeYes
|
|
439
|
-
* takes each component's own default, which is what NON_INTERACTIVE_ANSWERS
|
|
440
|
-
* documents (mcp yes, tg no, cowork no), so nothing about the unattended contract
|
|
441
|
-
* moves.
|
|
421
|
+
* The public installer has one product, not a package-selection questionnaire:
|
|
422
|
+
* MCP, Telegram, and cowork are all installed. Explicit injected answers remain
|
|
423
|
+
* only as a compatibility/test seam for callers that deliberately omit a piece.
|
|
442
424
|
*/
|
|
443
425
|
export async function askComponents(args, effects) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
effects.out(heading('What else should this daemon carry?'));
|
|
449
|
-
for (const component of optional) {
|
|
450
|
-
const already = (args.installed ?? {})[component.key] === true;
|
|
451
|
-
answers[component.key] = await effects.ask(
|
|
452
|
-
already ? `Keep ${component.label}?` : `Install ${component.label}?`,
|
|
453
|
-
already || component.default,
|
|
454
|
-
);
|
|
455
|
-
}
|
|
456
|
-
return answers;
|
|
426
|
+
// The product is the complete stack. Asking the operator to reconstruct that
|
|
427
|
+
// product from package names was choice theatre and made unattended installs
|
|
428
|
+
// incomplete. Explicit injected answers remain a test/compatibility seam.
|
|
429
|
+
return { ...(args.answers ?? {}) };
|
|
457
430
|
}
|
|
458
431
|
|
|
459
432
|
/**
|
|
@@ -529,29 +502,14 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
|
|
|
529
502
|
}
|
|
530
503
|
}
|
|
531
504
|
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
532
|
-
// ONE UNIT OF WORK: these bytes and the service that reads them. If the
|
|
533
|
-
// service does not come up, the connector's config names this daemon while its
|
|
534
|
-
// unit still carries the OLD environment — and the phase's own catch would
|
|
535
|
-
// report one failed line and let the run print "install complete".
|
|
536
|
-
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
537
505
|
if (plan.changed) {
|
|
538
|
-
// Written BEFORE the service: install-service bakes these values into the
|
|
539
|
-
// unit as environment, and environment outranks the config file after.
|
|
540
|
-
journal.snapshot(path);
|
|
541
506
|
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
542
507
|
} else {
|
|
543
508
|
effects.out(ok(`${path} already points here — not touched`));
|
|
544
509
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// Scoped to THIS component: the daemon came up correctly and is not undone
|
|
549
|
-
// by a connector that did not. That is the rule this journal's per-unit scope
|
|
550
|
-
// exists to keep.
|
|
551
|
-
rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its config back');
|
|
552
|
-
throw error;
|
|
553
|
-
}
|
|
554
|
-
return { key: 'tg', state: 'installed' };
|
|
510
|
+
effects.out(ok('Telegram connector installed and configured, but not started.'));
|
|
511
|
+
effects.out(info('After adding a bot, start it explicitly with: ours-tg-connector install-service'));
|
|
512
|
+
return { key: 'tg', state: 'installed', note: 'configured; stopped' };
|
|
555
513
|
}
|
|
556
514
|
|
|
557
515
|
const path = coworkConfigPath(effects.home, effects.env);
|
|
@@ -608,27 +566,11 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
|
|
|
608
566
|
*/
|
|
609
567
|
export async function askBroker(args, effects) {
|
|
610
568
|
const standard = args.brokerUrl;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
effects.out(info(
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (!(await effects.ask('Use a custom broker address?', false))) {
|
|
617
|
-
effects.out(ok('using the standard broker.'));
|
|
618
|
-
return standard;
|
|
619
|
-
}
|
|
620
|
-
const entered = String(await effects.askLine('Enter the broker address: ', '')).trim();
|
|
621
|
-
const checked = validateBroker(entered);
|
|
622
|
-
if (!entered || !checked.ok || checked.empty) {
|
|
623
|
-
if (entered) effects.out(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
624
|
-
else effects.out(ok('using the standard broker.'));
|
|
625
|
-
return standard;
|
|
626
|
-
}
|
|
627
|
-
if (await effects.ask(`Use "${checked.value}"? (No = go back to the standard broker)`, true)) {
|
|
628
|
-
effects.out(ok(`broker set to ${checked.value}.`));
|
|
629
|
-
return checked.value;
|
|
630
|
-
}
|
|
631
|
-
effects.out(ok('using the standard broker.'));
|
|
569
|
+
// Custom deployments remain available through OURS_BROKER_URL. The ordinary
|
|
570
|
+
// install is intentionally linear and explains the standard safe default.
|
|
571
|
+
effects.out(info(effects.env.OURS_BROKER_URL
|
|
572
|
+
? 'All services use the end-to-end encrypted broker configured in OURS_BROKER_URL.'
|
|
573
|
+
: 'All services use the standard end-to-end encrypted ours broker.'));
|
|
632
574
|
return standard;
|
|
633
575
|
}
|
|
634
576
|
|
|
@@ -748,24 +690,13 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
748
690
|
return [];
|
|
749
691
|
}
|
|
750
692
|
|
|
751
|
-
// Asked BEFORE planning, because the plan's `wanted` is the answer. Only a
|
|
752
|
-
// harness we can actually drive is worth a question — the others are going to
|
|
753
|
-
// print manual steps whatever the operator says.
|
|
754
|
-
const answers = {};
|
|
755
|
-
if (!args.assumeYes) {
|
|
756
|
-
for (const h of detected) {
|
|
757
|
-
if (h.status !== 'ok') continue;
|
|
758
|
-
answers[h.name] = await effects.ask(`Install the ours plugin into ${h.label ?? h.name}?`, true);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
693
|
const plans = planHarnessPlugins({
|
|
763
694
|
harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
|
|
764
695
|
stateDir: target.stateDir,
|
|
765
696
|
isDefaultStateDir,
|
|
766
697
|
channel: args.channel,
|
|
767
|
-
assumeYes:
|
|
768
|
-
answers,
|
|
698
|
+
assumeYes: true,
|
|
699
|
+
answers: {},
|
|
769
700
|
});
|
|
770
701
|
|
|
771
702
|
const rows = [];
|
|
@@ -808,48 +739,21 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
808
739
|
return rows;
|
|
809
740
|
}
|
|
810
741
|
|
|
811
|
-
/**
|
|
812
|
-
* ours-fleet. The feature that needed zero code anywhere.
|
|
813
|
-
*
|
|
814
|
-
* `ours-fleet init` takes no daemon argument and reads no daemon config; fleet
|
|
815
|
-
* resolves a daemon per role, from the role's own env. So the installer installs
|
|
816
|
-
* it, runs its one-time host setup, and — for a non-default state directory —
|
|
817
|
-
* SAYS the one fleet.yaml line. Saying it is the whole feature.
|
|
818
|
-
*/
|
|
742
|
+
/** Install Fleet, initialize its host support, and stage a stopped starter config. */
|
|
819
743
|
export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
|
|
820
744
|
effects.out(heading('ours-fleet (your always-online agent team)'));
|
|
821
745
|
effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
|
|
822
746
|
effects.out(info('become always-online agents that survive a reboot.'));
|
|
823
|
-
const wanted = args.assumeYes ? true : await effects.ask('Install it?', true);
|
|
824
747
|
const plan = planFleet({
|
|
825
|
-
stateDir: target.stateDir, isDefaultStateDir, wanted, channel: args.channel,
|
|
748
|
+
home: effects.home, stateDir: target.stateDir, isDefaultStateDir, wanted: true, channel: args.channel,
|
|
826
749
|
});
|
|
827
750
|
if (plan.action === 'skip') {
|
|
828
751
|
effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
829
752
|
return { key: 'fleet', label: plan.label, state: 'skipped' };
|
|
830
753
|
}
|
|
831
754
|
const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
// Two written analyses disagree, and NEITHER was verified — ours-fleet is not in
|
|
836
|
-
// this repo:
|
|
837
|
-
// lib/nightly-install.mjs:611 says fleet resolves its daemon from
|
|
838
|
-
// OURS_CONFIG / OURS_PORT / OURS_STATE_DIR and has no concept of a registry,
|
|
839
|
-
// so an `init` run without the pair points every role at the historical
|
|
840
|
-
// default daemon — which, when the selected daemon is not the default, may
|
|
841
|
-
// be one the user does not even have.
|
|
842
|
-
// lib/extras.mjs:180 says `init` takes no daemon argument of any kind, reads no
|
|
843
|
-
// daemon config, and resolves per role through
|
|
844
|
-
// resolveEndpoint({ ...process.env, ...role.env }) — so the pair is
|
|
845
|
-
// unnecessary here.
|
|
846
|
-
// Passing it is harmless if extras.mjs is right and load-bearing if
|
|
847
|
-
// nightly-install.mjs is. When the cheap action is safe under both readings and
|
|
848
|
-
// the expensive one is only safe under one, take the cheap one. (Coordinator
|
|
849
|
-
//
|
|
850
|
-
// Passed for EVERY state directory, not only a non-default one, exactly as the
|
|
851
|
-
// nightly flow does: `init` is a one-time host setup and the pair is what names
|
|
852
|
-
// the daemon it was set up beside.
|
|
755
|
+
// Pass the complete daemon tuple to host initialization. The generated role
|
|
756
|
+
// also carries OURS_CONFIG when the chosen daemon is non-default.
|
|
853
757
|
const initEnv = daemonEnv(target.stateDir, target.port);
|
|
854
758
|
const init = install.ok
|
|
855
759
|
? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (one-time host setup: units, dirs, linger)`, () => effects.run(plan.init[0], plan.init.slice(1), { env: initEnv }))
|
|
@@ -858,9 +762,14 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
|
|
|
858
762
|
effects.out(info(`retry manually: ${plan.init.join(' ')}`));
|
|
859
763
|
return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
|
|
860
764
|
}
|
|
861
|
-
effects.
|
|
765
|
+
if (effects.readText(plan.configPath) === null) {
|
|
766
|
+
await perform(effects, args.dryRun, `write starter fleet config ${plan.configPath}`, () => effects.writeText(plan.configPath, plan.config));
|
|
767
|
+
} else {
|
|
768
|
+
effects.out(ok(`${plan.configPath} already exists — not touched`));
|
|
769
|
+
}
|
|
770
|
+
effects.out(ok('ours-fleet installed and initialized; no fleet roles were started.'));
|
|
862
771
|
if (plan.instruction) effects.out(info(plan.instruction));
|
|
863
|
-
return { key: 'fleet', label: plan.label, state: 'installed', note:
|
|
772
|
+
return { key: 'fleet', label: plan.label, state: 'installed', note: `configured at ${plan.configPath}; stopped` };
|
|
864
773
|
}
|
|
865
774
|
|
|
866
775
|
/** Voice configuration belongs to the shared daemon, not the MCP adapter. */
|
|
@@ -914,6 +823,20 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
|
|
|
914
823
|
}
|
|
915
824
|
|
|
916
825
|
const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
|
|
826
|
+
if (has('tg') || has('fleet')) {
|
|
827
|
+
effects.out('');
|
|
828
|
+
effects.out(` ${c.bold('Installed but intentionally stopped')}`);
|
|
829
|
+
if (has('tg')) {
|
|
830
|
+
effects.out(` ${c.gray('• Telegram: add a bot/route first, then run')}`);
|
|
831
|
+
effects.out(` ${c.cyan('ours-tg-connector install-service')}`);
|
|
832
|
+
}
|
|
833
|
+
if (has('fleet')) {
|
|
834
|
+
effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
|
|
835
|
+
effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
|
|
836
|
+
effects.out(` ${c.cyan('ours-fleet ls')}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
917
840
|
const { text, empty } = buildHandoffPromptV3({
|
|
918
841
|
identity: !has('identity'),
|
|
919
842
|
fleet: has('fleet'),
|
|
@@ -927,7 +850,7 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
|
|
|
927
850
|
} else {
|
|
928
851
|
effects.out('');
|
|
929
852
|
effects.out(` ${c.gray('─'.repeat(64))}`);
|
|
930
|
-
effects.out(` ${c.bold('ONE LAST STEP')} —
|
|
853
|
+
effects.out(` ${c.bold('ONE LAST STEP')} — paste this prompt into Claude Code, Codex, or Hermes.`);
|
|
931
854
|
effects.out(` ${c.gray('─'.repeat(64))}`);
|
|
932
855
|
effects.out('');
|
|
933
856
|
effects.out(box(text.split('\n'), 'paste this into your agent'));
|
|
@@ -987,6 +910,7 @@ export async function runInstall(argv, effects) {
|
|
|
987
910
|
effects.out(banner());
|
|
988
911
|
effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
|
|
989
912
|
if (args.dryRun) effects.out(info('dry-run: nothing will be installed or changed'));
|
|
913
|
+
effects.out(progress(1, 8, 'Check the host', 'Verify the platform and Node.js before changing anything.'));
|
|
990
914
|
|
|
991
915
|
// An unsupported platform is not a refusal of an incoherent selection, it is a
|
|
992
916
|
// machine this cannot run on. v2 exited 0 there and so does this, so a script
|
|
@@ -995,9 +919,11 @@ export async function runInstall(argv, effects) {
|
|
|
995
919
|
|
|
996
920
|
// Which daemon, before anything is decided about it. Only args.stateDir can
|
|
997
921
|
// change here; every refusal downstream is unaffected.
|
|
922
|
+
effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
|
|
998
923
|
const selection = await runSelectionPhase(args, effects);
|
|
999
924
|
if (selection.action === 'refuse') return EXIT_REFUSED;
|
|
1000
925
|
|
|
926
|
+
effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
|
|
1001
927
|
const daemon = await runDaemonPhase(args, effects);
|
|
1002
928
|
if (daemon.refused) return EXIT_REFUSED;
|
|
1003
929
|
const target = daemon.target;
|
|
@@ -1020,6 +946,7 @@ export async function runInstall(argv, effects) {
|
|
|
1020
946
|
});
|
|
1021
947
|
}
|
|
1022
948
|
|
|
949
|
+
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; Telegram stays stopped.'));
|
|
1023
950
|
const components = await runComponentPhase(args, effects, target);
|
|
1024
951
|
for (const component of COMPONENTS) {
|
|
1025
952
|
const state = components.installed.includes(component.key) ? 'installed'
|
|
@@ -1029,13 +956,18 @@ export async function runInstall(argv, effects) {
|
|
|
1029
956
|
label: component.label,
|
|
1030
957
|
state,
|
|
1031
958
|
version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
|
|
1032
|
-
note: components.failed.find((f) => f.key === component.key)?.reason
|
|
959
|
+
note: components.failed.find((f) => f.key === component.key)?.reason
|
|
960
|
+
?? (state === 'installed' && component.key === 'tg' ? 'configured; stopped'
|
|
961
|
+
: state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
|
|
1033
962
|
});
|
|
1034
963
|
}
|
|
1035
964
|
const mcpReady = components.installed.includes('mcp');
|
|
1036
965
|
|
|
966
|
+
effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
|
|
1037
967
|
summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
|
|
968
|
+
effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
|
|
1038
969
|
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
|
|
970
|
+
effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
|
|
1039
971
|
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
|
|
1040
972
|
summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
|
|
1041
973
|
|
|
@@ -1046,6 +978,7 @@ export async function runInstall(argv, effects) {
|
|
|
1046
978
|
for (const failure of components.failed) {
|
|
1047
979
|
effects.out(warn(`${failure.key} did not install: ${failure.reason}`));
|
|
1048
980
|
}
|
|
981
|
+
effects.out(progress(8, 8, 'Finish', 'Summarize what is running, what is stopped, and the exact next commands.'));
|
|
1049
982
|
await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
|
|
1050
983
|
return EXIT_OK;
|
|
1051
984
|
}
|
package/lib/ui.mjs
CHANGED
|
@@ -63,6 +63,21 @@ export const info = (s) => ` ${c.gray('•')} ${s}`;
|
|
|
63
63
|
export const warn = (s) => ` ${c.yellow('!')} ${s}`;
|
|
64
64
|
export const why = (s) => ` ${c.gray('why: ' + s)}`;
|
|
65
65
|
|
|
66
|
+
// --- install progress --------------------------------------------------------------------------
|
|
67
|
+
// A stable progress bar rather than cursor animation. It stays readable in CI/curl logs,
|
|
68
|
+
// while a real terminal gets colour through the ordinary theme helpers. The explanatory
|
|
69
|
+
// sentence is part of the progress event so a user always knows what the current stage does.
|
|
70
|
+
export function progress(current, total, label, explanation = '') {
|
|
71
|
+
const count = Math.max(1, Number.parseInt(total, 10) || 1);
|
|
72
|
+
const position = Math.min(count, Math.max(0, Number.parseInt(current, 10) || 0));
|
|
73
|
+
const width = 24;
|
|
74
|
+
const filled = Math.round((position / count) * width);
|
|
75
|
+
const bar = c.cyan('█'.repeat(filled)) + c.gray('·'.repeat(width - filled));
|
|
76
|
+
const percent = String(Math.round((position / count) * 100)).padStart(3);
|
|
77
|
+
const detail = explanation ? `\n ${c.gray(explanation)}` : '';
|
|
78
|
+
return `\n ${bar} ${c.bold(`${percent}%`)} ${c.bold(label)}${detail}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
// --- framed panel -------------------------------------------------------------------------------
|
|
67
82
|
// A boxed block for the one moment that must stand out (the post-first-install next steps).
|
|
68
83
|
// Content lines must be PLAIN strings (no ANSI) so the width math stays honest — only the frame
|
package/lib/usage.mjs
CHANGED
|
@@ -11,10 +11,11 @@ export const USAGE = `ours-install — the unified ours.network stack installer.
|
|
|
11
11
|
|
|
12
12
|
ours-install [--state-dir PATH] [--port N] [--dry-run] [--help] [--version]
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
Progress-driven setup for the whole stack: one shared daemon, MCP, Telegram,
|
|
15
|
+
cowork, detected harness plugins (Claude Code / Codex / Hermes), a Human
|
|
16
|
+
identity, and ours-fleet. The daemon and cowork start; Telegram and Fleet are
|
|
17
|
+
staged but stopped. The installer asks only for information it cannot infer and
|
|
18
|
+
ends with exact next commands plus a copy-paste agent hand-off prompt.
|
|
18
19
|
|
|
19
20
|
--state-dir the daemon's STATE DIRECTORY, which is what identifies a daemon
|
|
20
21
|
(default ~/.ours). A second state directory is a second daemon.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.18.0-nightly.
|
|
3
|
+
"version": "0.18.0-nightly.3",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "The
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ours-install": "install.mjs"
|