@ours.network/install 0.18.0-nightly.4 → 0.18.0-nightly.5
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 +14 -11
- package/lib/components.mjs +4 -5
- package/lib/effects.mjs +35 -5
- package/lib/extras.mjs +1 -1
- package/lib/orchestrate.mjs +132 -14
- package/lib/target.mjs +12 -2
- package/lib/usage.mjs +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ connector from another daemon still require explicit confirmation.
|
|
|
24
24
|
or preserves the existing one on a re-run.
|
|
25
25
|
- Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
|
|
26
26
|
installations.
|
|
27
|
-
- Configures and starts cowork
|
|
28
|
-
- Configures Telegram
|
|
27
|
+
- Configures and starts cowork as a durable shim over the shared daemon.
|
|
28
|
+
- Configures and starts Telegram as a durable shim over the same daemon.
|
|
29
29
|
- Runs Fleet's host initialization and, when `~/fleet.yaml` is absent, writes a
|
|
30
30
|
conservative stopped starter with `FleetCoordinator`, a `fleet-health`
|
|
31
31
|
watchdog, and a ten-minute `coordinator_health` loop. An existing
|
|
@@ -35,20 +35,21 @@ The operator CLI owns daemon configuration, lifecycle, and boot persistence.
|
|
|
35
35
|
The MCP package is only the stdio adapter spawned by agent harnesses; the
|
|
36
36
|
installer never asks `ours-mcp` to start a daemon.
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
Daemon state is temporarily scoped to its package major version. On a same-major
|
|
39
|
+
update, the installer refreshes the packages and runs `ours daemon restart`; the
|
|
40
|
+
CLI streams structured startup phases until restore is complete instead of
|
|
41
|
+
appearing to hang. A different-major update is detected before package
|
|
42
|
+
replacement. The installer explains the incompatibility and, only in an
|
|
43
|
+
interactive run, offers to stop the CLI-managed daemon, copy the complete state
|
|
44
|
+
directory to a timestamped directory under `~/.ours-backups/`, remove the
|
|
45
|
+
managed service and old state, then initialize the new major. The default answer is no, and
|
|
46
|
+
`OURS_ASSUME_YES` never authorizes this purge.
|
|
42
47
|
|
|
43
48
|
## What remains stopped
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
activate them when ready:
|
|
50
|
+
Only Fleet is intentionally not started. Review and activate it when ready:
|
|
47
51
|
|
|
48
52
|
```sh
|
|
49
|
-
# After configuring a Telegram bot and route locally:
|
|
50
|
-
ours-tg-connector install-service
|
|
51
|
-
|
|
52
53
|
# After reviewing ~/fleet.yaml:
|
|
53
54
|
ours-fleet doctor
|
|
54
55
|
ours-fleet config
|
|
@@ -92,6 +93,8 @@ targets only the explicit state directory.
|
|
|
92
93
|
|
|
93
94
|
`OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) selects the packages' nightly
|
|
94
95
|
dist-tags. Without an override, the installer's own version selects the channel.
|
|
96
|
+
The operator CLI intentionally has no nightly dist-tag and remains untagged on
|
|
97
|
+
both channels.
|
|
95
98
|
|
|
96
99
|
## Environment
|
|
97
100
|
|
package/lib/components.mjs
CHANGED
|
@@ -203,11 +203,10 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
|
|
|
203
203
|
changed: changes.length > 0,
|
|
204
204
|
changes,
|
|
205
205
|
config: merged,
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
service: null,
|
|
206
|
+
// The connector is a durable shim over the shared daemon. Its service command
|
|
207
|
+
// owns the platform-specific unit and `enable --now`; the installer invokes
|
|
208
|
+
// it after committing the coherent daemon selection below.
|
|
209
|
+
service: ['ours-tg-connector', 'install-service'],
|
|
211
210
|
untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
|
|
212
211
|
};
|
|
213
212
|
if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
|
package/lib/effects.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// unit file or the service manager directly.
|
|
13
13
|
|
|
14
14
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
15
|
-
import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { cpSync, existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
|
|
17
17
|
import { dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
@@ -29,7 +29,12 @@ async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
|
29
29
|
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
|
30
30
|
const body = await res.json();
|
|
31
31
|
if (typeof body?.stateDir !== 'string') return { ok: false, reason: 'no stateDir in reply' };
|
|
32
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
stateDir: body.stateDir,
|
|
35
|
+
version: typeof body.version === 'string' ? body.version : null,
|
|
36
|
+
compat: Number.isInteger(body.compat) ? body.compat : null,
|
|
37
|
+
};
|
|
33
38
|
} catch (error) {
|
|
34
39
|
return { ok: false, reason: error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error) };
|
|
35
40
|
} finally {
|
|
@@ -74,6 +79,17 @@ function installedVersionOf(pkg) {
|
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
function packageDependenciesOf(pkgSpec) {
|
|
83
|
+
const probe = capture('npm', ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
|
|
84
|
+
if (!probe.ok) return null;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(probe.stdout);
|
|
87
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
/**
|
|
78
94
|
* A read-only command probe that NEVER throws and NEVER inherits stdio.
|
|
79
95
|
*
|
|
@@ -221,6 +237,15 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
221
237
|
// pure planner already gated four ways, and this deletes exactly that.
|
|
222
238
|
removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
|
|
223
239
|
removeFile: (path) => { rmSync(resolve(path), { force: true }); },
|
|
240
|
+
copyDir: (source, destination) => {
|
|
241
|
+
mkdirSync(dirname(resolve(destination)), { recursive: true, mode: 0o700 });
|
|
242
|
+
cpSync(resolve(source), resolve(destination), {
|
|
243
|
+
recursive: true,
|
|
244
|
+
errorOnExist: true,
|
|
245
|
+
force: false,
|
|
246
|
+
preserveTimestamps: true,
|
|
247
|
+
});
|
|
248
|
+
},
|
|
224
249
|
// Rewrites a config file we do NOT own, so it keeps the file's own mode
|
|
225
250
|
// rather than imposing 0600: tightening the permissions of somebody else's
|
|
226
251
|
// ~/.codex/config.toml is a side effect nobody asked this to have.
|
|
@@ -266,13 +291,17 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
266
291
|
// invocation only and never to the installer's own process: a state
|
|
267
292
|
// directory selected by one run must not leak into anything the operator
|
|
268
293
|
// starts afterwards.
|
|
269
|
-
run: async (cmd, args, { env: extraEnv = null } = {}) => {
|
|
294
|
+
run: async (cmd, args, { env: extraEnv = null, stream = false } = {}) => {
|
|
270
295
|
// Always built from this layer's OWN env rather than left to spawnSync's
|
|
271
296
|
// implicit inheritance, so what a child receives is a property of the
|
|
272
297
|
// effects object a caller constructed and not of whatever ambient shell
|
|
273
298
|
// the installer happened to start in.
|
|
274
299
|
const childEnv = { ...env, ...(extraEnv ?? {}) };
|
|
275
|
-
const r = spawnSync(cmd, args, {
|
|
300
|
+
const r = spawnSync(cmd, args, {
|
|
301
|
+
encoding: 'utf8',
|
|
302
|
+
stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
|
|
303
|
+
env: childEnv,
|
|
304
|
+
});
|
|
276
305
|
if (r.status !== 0) {
|
|
277
306
|
const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
|
|
278
307
|
throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
|
|
@@ -289,6 +318,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
289
318
|
return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
|
|
290
319
|
},
|
|
291
320
|
installedVersion: installedVersionOf,
|
|
321
|
+
packageDependencies: packageDependenciesOf,
|
|
292
322
|
out: out ?? ((line) => process.stdout.write(`${line}\n`)),
|
|
293
323
|
// Never called when assumeYes: the orchestrator takes the default itself.
|
|
294
324
|
ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
|
|
@@ -296,7 +326,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
296
326
|
};
|
|
297
327
|
}
|
|
298
328
|
|
|
299
|
-
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
|
|
329
|
+
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, knownStateDirsIn };
|
|
300
330
|
|
|
301
331
|
// -----------------------------------------------------------------------------
|
|
302
332
|
// THE PAIR
|
package/lib/extras.mjs
CHANGED
|
@@ -331,7 +331,7 @@ export function buildHandoffPromptV3({
|
|
|
331
331
|
steps.push(
|
|
332
332
|
'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
|
|
333
333
|
+ ' and guide me through entering the @BotFather token locally. Register the\n'
|
|
334
|
-
+ ' route
|
|
334
|
+
+ ' route in the connector service that ours-install already started.',
|
|
335
335
|
);
|
|
336
336
|
}
|
|
337
337
|
if (steps.length === 0) return { text: '', empty: true };
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// ask(prompt, default) -> boolean (never called when assumeYes)
|
|
24
24
|
// now() -> number
|
|
25
25
|
|
|
26
|
-
import { join } from 'node:path';
|
|
26
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
27
27
|
import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
|
|
28
28
|
import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
|
|
29
29
|
import {
|
|
@@ -66,6 +66,104 @@ async function perform(effects, dryRun, label, thunk) {
|
|
|
66
66
|
|
|
67
67
|
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
68
68
|
|
|
69
|
+
function semverMajor(version) {
|
|
70
|
+
const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
|
|
71
|
+
return match ? Number(match[1]) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function incompatibleUpgrade(target, cliDependencies) {
|
|
75
|
+
if (target.action !== 'update' || !target.daemonVersion) return null;
|
|
76
|
+
const runningMajor = semverMajor(target.daemonVersion);
|
|
77
|
+
const targetRange = cliDependencies?.['@ours.network/sdk'];
|
|
78
|
+
const targetMajor = semverMajor(targetRange);
|
|
79
|
+
if (runningMajor === null) {
|
|
80
|
+
return { unknown: true, runningMajor: null, runningVersion: target.daemonVersion };
|
|
81
|
+
}
|
|
82
|
+
if (targetMajor === null) {
|
|
83
|
+
return { unknown: true, runningMajor, runningVersion: target.daemonVersion };
|
|
84
|
+
}
|
|
85
|
+
return runningMajor !== targetMajor
|
|
86
|
+
? { unknown: false, runningMajor, runningVersion: target.daemonVersion, targetMajor, targetRange }
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
|
|
91
|
+
const dir = target.stateDir;
|
|
92
|
+
const configPath = join(dir, 'config.json');
|
|
93
|
+
if (mismatch.unknown) {
|
|
94
|
+
effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
|
|
95
|
+
effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
|
|
96
|
+
return { refused: { reason: 'compatibility-unknown', exitCode: EXIT_REFUSED } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
effects.out(warn(
|
|
100
|
+
`ours: daemon v${mismatch.runningVersion} cannot be restored by the requested major v${mismatch.targetMajor}; major upgrades are intentionally incompatible.`,
|
|
101
|
+
));
|
|
102
|
+
if (resolve(dir) === resolve(effects.home) || dirname(resolve(dir)) === resolve(dir)) {
|
|
103
|
+
effects.out(info(`Automatic purge is not available for the broad state path ${dir}. Back it up and remove it manually.`));
|
|
104
|
+
return { refused: { reason: 'incompatible-major-broad-path', exitCode: EXIT_REFUSED } };
|
|
105
|
+
}
|
|
106
|
+
// Backups live one directory below a non-daemon container. Putting a copied
|
|
107
|
+
// state beside ~/.ours under another `.ours*` name makes daemon discovery see
|
|
108
|
+
// the backup as a second live target on the next installer run.
|
|
109
|
+
const backupPath = join(
|
|
110
|
+
dirname(dir),
|
|
111
|
+
'.ours-backups',
|
|
112
|
+
`${basename(dir)}-before-v${mismatch.targetMajor}-${effects.now()}`,
|
|
113
|
+
);
|
|
114
|
+
if (args.dryRun) {
|
|
115
|
+
effects.out(info(`[dry-run] would ask to stop the old daemon, copy its complete state to ${backupPath}, remove its service/state, and initialize v${mismatch.targetMajor}.`));
|
|
116
|
+
return { refused: { reason: 'incompatible-major-dry-run', exitCode: EXIT_REFUSED } };
|
|
117
|
+
}
|
|
118
|
+
if (args.assumeYes) {
|
|
119
|
+
effects.out(info('This purge is never accepted through OURS_ASSUME_YES. Re-run interactively to confirm the backup and reset.'));
|
|
120
|
+
return { refused: { reason: 'incompatible-major-unattended', exitCode: EXIT_REFUSED } };
|
|
121
|
+
}
|
|
122
|
+
if (effects.readJson(join(dir, 'ours-cli-daemon.json')) === null) {
|
|
123
|
+
effects.out(info('The daemon is not CLI-managed. Stop its external launcher, back up and remove its state/service, then re-run the installer.'));
|
|
124
|
+
return { refused: { reason: 'incompatible-major-external', exitCode: EXIT_REFUSED } };
|
|
125
|
+
}
|
|
126
|
+
const confirmed = await effects.ask(
|
|
127
|
+
`Back up all daemon state to ${backupPath}, purge the incompatible daemon and service, and install v${mismatch.targetMajor}?`,
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
if (!confirmed) {
|
|
131
|
+
effects.out(info(`Nothing was changed. Back up ${dir}, remove the old daemon service/state, and re-run when ready.`));
|
|
132
|
+
return { refused: { reason: 'incompatible-major-declined', exitCode: EXIT_REFUSED } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
|
|
136
|
+
'ours', ['daemon', 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
|
|
137
|
+
));
|
|
138
|
+
try {
|
|
139
|
+
await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
try {
|
|
142
|
+
await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
|
|
143
|
+
effects.out(ok('backup failed, but the old daemon was started again'));
|
|
144
|
+
} catch {
|
|
145
|
+
effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
|
|
151
|
+
'ours', ['daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
|
|
152
|
+
));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
try {
|
|
155
|
+
await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
|
|
156
|
+
effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
|
|
157
|
+
} catch {
|
|
158
|
+
effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
await perform(effects, false, `remove incompatible state at ${dir}`, () => effects.removeDir(dir));
|
|
163
|
+
effects.out(ok(`backup retained at ${backupPath}`));
|
|
164
|
+
return { purged: true, backupPath };
|
|
165
|
+
}
|
|
166
|
+
|
|
69
167
|
/**
|
|
70
168
|
* A step that is allowed to fail without ending the run.
|
|
71
169
|
*
|
|
@@ -184,7 +282,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
184
282
|
}
|
|
185
283
|
|
|
186
284
|
const dir = target.stateDir;
|
|
187
|
-
|
|
285
|
+
let creating = target.action === 'create';
|
|
188
286
|
effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
|
|
189
287
|
if (target.stalePidRecord) {
|
|
190
288
|
effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
|
|
@@ -213,9 +311,23 @@ export async function runDaemonPhase(args, effects) {
|
|
|
213
311
|
// adapter each harness spawns. Both are required, but only `ours daemon`
|
|
214
312
|
// participates in lifecycle or service management.
|
|
215
313
|
const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
|
|
314
|
+
// The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
|
|
315
|
+
// connector packages it has no nightly dist-tag. Keep this untagged on every
|
|
316
|
+
// installer channel, and inspect that package's SDK dependency for the gate.
|
|
317
|
+
const cliPkg = '@ours.network/cli';
|
|
318
|
+
if (!creating && target.daemonVersion) {
|
|
319
|
+
const mismatch = incompatibleUpgrade(target, effects.packageDependencies(cliPkg));
|
|
320
|
+
if (mismatch) {
|
|
321
|
+
const prepared = await prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch);
|
|
322
|
+
if (prepared.refused) return { target, refused: prepared.refused, steps };
|
|
323
|
+
creating = prepared.purged === true;
|
|
324
|
+
target.backupPath = prepared.backupPath;
|
|
325
|
+
target.action = 'create';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
216
328
|
await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
|
|
217
329
|
steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
|
|
218
|
-
await perform(effects, args.dryRun,
|
|
330
|
+
await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
|
|
219
331
|
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
220
332
|
|
|
221
333
|
// The config file — merged, never rewritten, and untouched when it already
|
|
@@ -249,8 +361,11 @@ export async function runDaemonPhase(args, effects) {
|
|
|
249
361
|
|
|
250
362
|
try {
|
|
251
363
|
if (creating) {
|
|
252
|
-
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
|
|
364
|
+
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath], { stream: true }));
|
|
253
365
|
steps.push({ id: 'start', changed: true });
|
|
366
|
+
} else {
|
|
367
|
+
await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'restart', '--config', configPath], { stream: true }));
|
|
368
|
+
steps.push({ id: 'restart', changed: true });
|
|
254
369
|
}
|
|
255
370
|
|
|
256
371
|
const service = await runServicePhase(args, effects, dir, target.port);
|
|
@@ -502,14 +617,21 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
|
|
|
502
617
|
}
|
|
503
618
|
}
|
|
504
619
|
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
620
|
+
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
505
621
|
if (plan.changed) {
|
|
622
|
+
journal.snapshot(path);
|
|
506
623
|
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
507
624
|
} else {
|
|
508
625
|
effects.out(ok(`${path} already points here — not touched`));
|
|
509
626
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
627
|
+
try {
|
|
628
|
+
await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its daemon selection back');
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
effects.out(ok('Telegram connector installed as a durable service on the shared daemon.'));
|
|
634
|
+
return { key: 'tg', state: 'installed', note: 'configured; service running' };
|
|
513
635
|
}
|
|
514
636
|
|
|
515
637
|
const path = coworkConfigPath(effects.home, effects.env);
|
|
@@ -823,13 +945,9 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
|
|
|
823
945
|
}
|
|
824
946
|
|
|
825
947
|
const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
|
|
826
|
-
if (has('
|
|
948
|
+
if (has('fleet')) {
|
|
827
949
|
effects.out('');
|
|
828
950
|
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
951
|
if (has('fleet')) {
|
|
834
952
|
effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
|
|
835
953
|
effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
|
|
@@ -946,7 +1064,7 @@ export async function runInstall(argv, effects) {
|
|
|
946
1064
|
});
|
|
947
1065
|
}
|
|
948
1066
|
|
|
949
|
-
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon;
|
|
1067
|
+
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; run both shims as durable services.'));
|
|
950
1068
|
const components = await runComponentPhase(args, effects, target);
|
|
951
1069
|
for (const component of COMPONENTS) {
|
|
952
1070
|
const state = components.installed.includes(component.key) ? 'installed'
|
|
@@ -957,7 +1075,7 @@ export async function runInstall(argv, effects) {
|
|
|
957
1075
|
state,
|
|
958
1076
|
version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
|
|
959
1077
|
note: components.failed.find((f) => f.key === component.key)?.reason
|
|
960
|
-
?? (state === 'installed' && component.key === 'tg' ? 'configured;
|
|
1078
|
+
?? (state === 'installed' && component.key === 'tg' ? 'configured; service running'
|
|
961
1079
|
: state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
|
|
962
1080
|
});
|
|
963
1081
|
}
|
package/lib/target.mjs
CHANGED
|
@@ -176,7 +176,11 @@ export function classifyProbe(probe, targetStateDir) {
|
|
|
176
176
|
if (!samePath(probe.stateDir, targetStateDir)) {
|
|
177
177
|
return { kind: 'foreign', reason: 'daemon owns a different state directory', stateDir: resolve(probe.stateDir) };
|
|
178
178
|
}
|
|
179
|
-
return {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'present',
|
|
181
|
+
stateDir: resolve(probe.stateDir),
|
|
182
|
+
daemonVersion: typeof probe.version === 'string' ? probe.version : null,
|
|
183
|
+
};
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
/**
|
|
@@ -327,7 +331,13 @@ export async function resolveTarget({ stateDir, port = null, portExplicit = fals
|
|
|
327
331
|
message: `--port ${port} disagrees with port ${found.port}, where the daemon for ${target} is actually running`,
|
|
328
332
|
};
|
|
329
333
|
}
|
|
330
|
-
return {
|
|
334
|
+
return {
|
|
335
|
+
action: 'update',
|
|
336
|
+
port: found.port,
|
|
337
|
+
stateDir: target,
|
|
338
|
+
config: found.config,
|
|
339
|
+
daemonVersion: found.daemonVersion ?? null,
|
|
340
|
+
};
|
|
331
341
|
}
|
|
332
342
|
|
|
333
343
|
// Creating. Only now is a port chosen.
|
package/lib/usage.mjs
CHANGED
|
@@ -13,8 +13,9 @@ export const USAGE = `ours-install — the unified ours.network stack installer.
|
|
|
13
13
|
|
|
14
14
|
Progress-driven setup for the whole stack: one shared daemon, MCP, Telegram,
|
|
15
15
|
cowork, detected harness plugins (Claude Code / Codex / Hermes), a Human
|
|
16
|
-
identity, and ours-fleet. The daemon and cowork start
|
|
17
|
-
staged but stopped. The installer asks only
|
|
16
|
+
identity, and ours-fleet. The daemon, Telegram connector, and cowork shim start
|
|
17
|
+
as durable services; only Fleet is staged but stopped. The installer asks only
|
|
18
|
+
for information it cannot infer and
|
|
18
19
|
ends with exact next commands plus a copy-paste agent hand-off prompt.
|
|
19
20
|
|
|
20
21
|
--state-dir the daemon's STATE DIRECTORY, which is what identifies a daemon
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.18.0-nightly.
|
|
3
|
+
"version": "0.18.0-nightly.5",
|
|
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",
|