@ours.network/install 1.2.1-nightly.11 → 1.2.1-nightly.12
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 +116 -6
- package/assets/Dockerfile.gateway +4 -0
- package/assets/release-lock.json +19 -19
- package/assets/release.json +9 -9
- package/assets/sources.json +13 -13
- package/lib/build-transition.mjs +1 -0
- package/lib/effects.mjs +89 -11
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +142 -0
- package/lib/orchestrate.mjs +11 -0
- package/lib/plan.mjs +8 -2
- package/lib/server-onboarding.mjs +12 -7
- package/lib/setup-options.mjs +6 -1
- package/lib/setup.mjs +3 -2
- package/lib/target.mjs +15 -7
- package/lib/usage.mjs +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,12 +68,12 @@ it. They exclude the daemon and its native database/ADAPT dependencies. Fleet
|
|
|
68
68
|
runs beside the harnesses on the client machine and calls the daemon HTTP API.
|
|
69
69
|
Client commands report an unavailable server and never start one automatically.
|
|
70
70
|
|
|
71
|
-
The
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
artifacts.
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
The checked-in source policy and packed installer select the same exact published
|
|
72
|
+
nightly release set, including SDK `3.8.1-nightly.10`, CLI `2.8.1-nightly.8`, and
|
|
73
|
+
daemon `3.8.1-nightly.2`. The isolated host CLI pair uses these released client
|
|
74
|
+
artifacts. `releases/nightly.json` binds the gateway-capable consumers and daemon
|
|
75
|
+
to verified SHA512 integrities. Mixed or unselected nested ours versions remain
|
|
76
|
+
rejected. An explicit `--sources` override remains available for development.
|
|
77
77
|
|
|
78
78
|
## Migrate an existing global installation
|
|
79
79
|
|
|
@@ -333,3 +333,113 @@ origin directly. UUID/capability checks still precede credential-bearing calls.
|
|
|
333
333
|
This adds client HTTPS support, not an HTTPS daemon listener, certificate
|
|
334
334
|
provisioning or automatic reverse-proxy configuration. Existing local HTTP and
|
|
335
335
|
SSH-tunnel profiles continue to work.
|
|
336
|
+
|
|
337
|
+
## One-URL Docker gateway
|
|
338
|
+
|
|
339
|
+
New Docker installations use one loopback-published nginx port (default 3050).
|
|
340
|
+
Clients import one `serverUrl`; their daemon endpoint is `<serverUrl>/daemon` and
|
|
341
|
+
Fleet derives `<serverUrl>/cowork/management/rpc`. Cowork, Messenger and Telegram
|
|
342
|
+
backend ports are internal only. Existing installations retain their prior layout
|
|
343
|
+
on ordinary install/update. Select compatible releases before explicitly migrating:
|
|
344
|
+
|
|
345
|
+
```sh
|
|
346
|
+
ours-install server gateway-enable --state-dir /private/ours
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Migration checks the installed Cowork, Messenger and Telegram capabilities before
|
|
350
|
+
stopping services, verifies authenticated daemon and read-only Cowork management,
|
|
351
|
+
and preserves the existing credential and client integration settings. Failure
|
|
352
|
+
restores routing, client profile and prior service selection. An interrupted
|
|
353
|
+
migration blocks other mutations: repeat `gateway-enable` to finish rollback,
|
|
354
|
+
then run it again to attempt migration. The private `gateway-transition.json`
|
|
355
|
+
retains recovery state until rollback or migration completes. Back up the whole
|
|
356
|
+
installation using the normal server backup procedure before maintenance.
|
|
357
|
+
|
|
358
|
+
For a nested external URL, pass `--server-url https://ours.example/base` during
|
|
359
|
+
Docker install or `gateway-enable`. This sets browser origin and path configuration;
|
|
360
|
+
it does **not** publish a public listener, provide TLS, or install external
|
|
361
|
+
credentials. Configure the authenticated entry first and retain the `/base` path
|
|
362
|
+
when proxying. A compatible release must contain these capabilities:
|
|
363
|
+
`cowork.http-management-v1`, `messenger.gateway-prefix-v1`, and
|
|
364
|
+
`telegram.gateway-listener-v1`. Older releases are rejected without enabling the
|
|
365
|
+
new listener; no package version is silently substituted.
|
|
366
|
+
|
|
367
|
+
### Authenticated external entry
|
|
368
|
+
|
|
369
|
+
Messenger has no application authentication. Loopback access trusts local users;
|
|
370
|
+
never forward this gateway publicly without authentication. All applications on
|
|
371
|
+
this origin share one trust boundary: scripts or XSS in any application can make
|
|
372
|
+
requests to the others. Cowork's entered server credential grants operator room
|
|
373
|
+
administration and stays in memory until reload. Path prefixes do not isolate
|
|
374
|
+
applications. Only expose this origin to trusted operators.
|
|
375
|
+
|
|
376
|
+
A supported external entry is nginx TLS plus HTTP Basic authentication for browser
|
|
377
|
+
paths, with machine paths left to their existing server-token checks. Create the
|
|
378
|
+
password file using an interactive `htpasswd` prompt and make it readable by the
|
|
379
|
+
proxy worker. Use real certificates and the matching `--server-url`. The inner
|
|
380
|
+
gateway remains bound to `127.0.0.1:3050`; neither backend ports nor the inner
|
|
381
|
+
listener should be externally forwarded. Example for `/base`:
|
|
382
|
+
|
|
383
|
+
```nginx
|
|
384
|
+
# In http {}:
|
|
385
|
+
map $http_upgrade $ours_connection { default upgrade; '' close; }
|
|
386
|
+
server {
|
|
387
|
+
listen 443 ssl;
|
|
388
|
+
server_name ours.example;
|
|
389
|
+
ssl_certificate /private/tls/fullchain.pem;
|
|
390
|
+
ssl_certificate_key /private/tls/key.pem;
|
|
391
|
+
auth_basic "ours operators";
|
|
392
|
+
auth_basic_user_file /private/ours-users;
|
|
393
|
+
access_log off;
|
|
394
|
+
proxy_set_header Host $http_host;
|
|
395
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
396
|
+
proxy_set_header Connection $ours_connection;
|
|
397
|
+
proxy_http_version 1.1;
|
|
398
|
+
proxy_read_timeout 300s;
|
|
399
|
+
proxy_buffering off;
|
|
400
|
+
location = /base/.well-known/ours { auth_basic off; proxy_pass http://127.0.0.1:3050; }
|
|
401
|
+
location /base/daemon/ { auth_basic off; proxy_pass http://127.0.0.1:3050; }
|
|
402
|
+
location = /base/cowork/management/rpc { auth_basic off; proxy_pass http://127.0.0.1:3050; }
|
|
403
|
+
location /base/tg-connector/ { auth_basic off; proxy_pass http://127.0.0.1:3050; }
|
|
404
|
+
location /base/ { proxy_pass http://127.0.0.1:3050; }
|
|
405
|
+
location / { return 404; }
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Browser requests include same-origin proxy credentials. Fleet/SDK use the issued
|
|
410
|
+
`X-Ours-Api-Token`, require no browser login, and reject redirects. An SSH tunnel
|
|
411
|
+
to the loopback gateway is another private transport option, but every client must
|
|
412
|
+
establish its own tunnel and use a URL/origin matching that tunnel; it is not
|
|
413
|
+
automatic remote access. Do not treat a token entered in Cowork as Messenger auth.
|
|
414
|
+
|
|
415
|
+
Publish compatible Cowork, Messenger, Telegram and Fleet artifacts before enabling
|
|
416
|
+
gateway defaults in a qualified installer release. Gateway client setup acquires
|
|
417
|
+
and checks Fleet before activating the managed profile or publishing native
|
|
418
|
+
commands. Migration also checks the installed `ours-fleet version --json`
|
|
419
|
+
capability before stopping services when the selected managed profile includes
|
|
420
|
+
Fleet. Upgrade that executable first. The gate does not inventory differently pinned
|
|
421
|
+
running Fleet instances or remote clients: upgrade every client using this server
|
|
422
|
+
to a compatible Fleet release before cutover.
|
|
423
|
+
|
|
424
|
+
### Cross-repository gateway qualification
|
|
425
|
+
|
|
426
|
+
Build Cowork, Fleet, Messenger and Telegram checkouts with their locked dependencies.
|
|
427
|
+
The real-service test uses disposable local daemon state, a local test broker,
|
|
428
|
+
nginx Docker containers and Chromium; it never uses installed identities or tokens.
|
|
429
|
+
It requires Linux Docker host networking and `openssl`. Set:
|
|
430
|
+
|
|
431
|
+
```sh
|
|
432
|
+
OURS_TEST_GATEWAY_SERVICES=1 \
|
|
433
|
+
OURS_TEST_COWORK_ROOT=/checkouts/ours-cowork \
|
|
434
|
+
OURS_TEST_FLEET_ROOT=/checkouts/ours-fleet \
|
|
435
|
+
OURS_TEST_MESSENGER_ROOT=/checkouts/ours-messenger-server \
|
|
436
|
+
OURS_TEST_TELEGRAM_ROOT=/checkouts/ours-tg-connector \
|
|
437
|
+
OURS_TEST_BROWSER=/opt/google/chrome/chrome \
|
|
438
|
+
node --test packages/installer/test/gateway-services.test.mjs
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Run `OURS_TEST_DOCKER=1 node --test packages/installer/test/gateway-docker.test.mjs`
|
|
442
|
+
separately for Compose port isolation with a client container. That routing test
|
|
443
|
+
uses echo upstreams; the real-service test covers Fleet room operations, browser
|
|
444
|
+
paths, external authentication and Messenger WebSocket access. Neither test is
|
|
445
|
+
cross-platform Docker Desktop qualification or a live deployment test.
|
package/assets/release-lock.json
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"@ours.network/claude-code": "1.2.0-nightly.6",
|
|
12
12
|
"@ours.network/cli": "2.8.1-nightly.8",
|
|
13
13
|
"@ours.network/codex": "1.2.0-nightly.6",
|
|
14
|
-
"@ours.network/cowork": "1.3.2-nightly.
|
|
14
|
+
"@ours.network/cowork": "1.3.2-nightly.20260922.70e6adf",
|
|
15
15
|
"@ours.network/daemon": "3.8.1-nightly.2",
|
|
16
|
-
"@ours.network/fleet": "1.2.0-nightly.
|
|
16
|
+
"@ours.network/fleet": "1.2.0-nightly.5",
|
|
17
17
|
"@ours.network/mcp": "1.2.0-nightly.6",
|
|
18
|
-
"@ours.network/messenger-server": "1.0.31-nightly.
|
|
18
|
+
"@ours.network/messenger-server": "1.0.31-nightly.5",
|
|
19
19
|
"@ours.network/sdk": "3.8.1-nightly.10",
|
|
20
|
-
"@ours.network/tg-connector": "1.0.1-nightly.
|
|
20
|
+
"@ours.network/tg-connector": "1.0.1-nightly.5"
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"node_modules/@adapt-toolkit/sdk": {
|
|
@@ -566,9 +566,9 @@
|
|
|
566
566
|
]
|
|
567
567
|
},
|
|
568
568
|
"node_modules/@anthropic-ai/sdk": {
|
|
569
|
-
"version": "0.
|
|
570
|
-
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.
|
|
571
|
-
"integrity": "sha512-
|
|
569
|
+
"version": "0.128.0",
|
|
570
|
+
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.128.0.tgz",
|
|
571
|
+
"integrity": "sha512-tl5cBFZC1jVFrTuQyvQObA4WmuNgcYVeXfriqOumgtLAHUm4gPUj18YnszBW60v0PrjDB8nBDOC6wd9CmHQFlA==",
|
|
572
572
|
"license": "MIT",
|
|
573
573
|
"optional": true,
|
|
574
574
|
"peer": true,
|
|
@@ -1103,9 +1103,9 @@
|
|
|
1103
1103
|
}
|
|
1104
1104
|
},
|
|
1105
1105
|
"node_modules/@ours.network/cowork": {
|
|
1106
|
-
"version": "1.3.2-nightly.
|
|
1107
|
-
"resolved": "https://registry.npmjs.org/@ours.network/cowork/-/cowork-1.3.2-nightly.
|
|
1108
|
-
"integrity": "sha512-
|
|
1106
|
+
"version": "1.3.2-nightly.20260922.70e6adf",
|
|
1107
|
+
"resolved": "https://registry.npmjs.org/@ours.network/cowork/-/cowork-1.3.2-nightly.20260922.70e6adf.tgz",
|
|
1108
|
+
"integrity": "sha512-RpbbD9pxundiln4+3eD1wwMgXd13nM24CbaabFUQMartqpNMVm1cVzU6sGvuZ2hgMMRVwWbrmjk1gL7DtxiZ4g==",
|
|
1109
1109
|
"license": "FSL-1.1-Apache-2.0",
|
|
1110
1110
|
"dependencies": {
|
|
1111
1111
|
"@ours.network/sdk": "3.8.1-nightly.10",
|
|
@@ -1145,9 +1145,9 @@
|
|
|
1145
1145
|
}
|
|
1146
1146
|
},
|
|
1147
1147
|
"node_modules/@ours.network/fleet": {
|
|
1148
|
-
"version": "1.2.0-nightly.
|
|
1149
|
-
"resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.
|
|
1150
|
-
"integrity": "sha512-
|
|
1148
|
+
"version": "1.2.0-nightly.5",
|
|
1149
|
+
"resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.5.tgz",
|
|
1150
|
+
"integrity": "sha512-wCM7oLwgpfnMuJv1/nB/2zhHqEqyl2x29gCo41RXKBMw7PJI64NVMpjeSu7SiCyjNlelAMOkRMGRcv+IdUnvgA==",
|
|
1151
1151
|
"license": "FSL-1.1-Apache-2.0",
|
|
1152
1152
|
"dependencies": {
|
|
1153
1153
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
@@ -1220,9 +1220,9 @@
|
|
|
1220
1220
|
}
|
|
1221
1221
|
},
|
|
1222
1222
|
"node_modules/@ours.network/messenger-server": {
|
|
1223
|
-
"version": "1.0.31-nightly.
|
|
1224
|
-
"resolved": "https://registry.npmjs.org/@ours.network/messenger-server/-/messenger-server-1.0.31-nightly.
|
|
1225
|
-
"integrity": "sha512-
|
|
1223
|
+
"version": "1.0.31-nightly.5",
|
|
1224
|
+
"resolved": "https://registry.npmjs.org/@ours.network/messenger-server/-/messenger-server-1.0.31-nightly.5.tgz",
|
|
1225
|
+
"integrity": "sha512-4UtUeq+WNgTSQjeWKMAer7mSPeue8hUrY23AgnSwreBfVui4GjN/aJ6jS6Znz9v3nS5D9DM+faeW1JVHEpgyDg==",
|
|
1226
1226
|
"license": "FSL-1.1-Apache-2.0",
|
|
1227
1227
|
"dependencies": {
|
|
1228
1228
|
"@eslint-community/regexpp": "4.12.2",
|
|
@@ -1266,9 +1266,9 @@
|
|
|
1266
1266
|
}
|
|
1267
1267
|
},
|
|
1268
1268
|
"node_modules/@ours.network/tg-connector": {
|
|
1269
|
-
"version": "1.0.1-nightly.
|
|
1270
|
-
"resolved": "https://registry.npmjs.org/@ours.network/tg-connector/-/tg-connector-1.0.1-nightly.
|
|
1271
|
-
"integrity": "sha512-
|
|
1269
|
+
"version": "1.0.1-nightly.5",
|
|
1270
|
+
"resolved": "https://registry.npmjs.org/@ours.network/tg-connector/-/tg-connector-1.0.1-nightly.5.tgz",
|
|
1271
|
+
"integrity": "sha512-WyoqnWg7Y2D45J2b4aJ20PydSKaSheC8NdVJTnrjQozsD4pW0ueDfuTwvuO5zx56KUqhhWXRVP7kztk9pycU5w==",
|
|
1272
1272
|
"license": "FSL-1.1-Apache-2.0",
|
|
1273
1273
|
"dependencies": {
|
|
1274
1274
|
"@ours.network/sdk": "3.8.1-nightly.10",
|
package/assets/release.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"channel": "nightly",
|
|
4
|
-
"installerVersion": "1.2.1-nightly.
|
|
4
|
+
"installerVersion": "1.2.1-nightly.12",
|
|
5
5
|
"packages": {
|
|
6
6
|
"@ours.network/sdk": {
|
|
7
7
|
"version": "3.8.1-nightly.10",
|
|
@@ -12,20 +12,20 @@
|
|
|
12
12
|
"integrity": "sha512-7kZ1EzLpWNsyAdil4NoZfQc5RtAcbfIqc0AgTaMyaGA+ayqZRVdarYuBOo+fOT6dwbLLlMvWpZQgVMtR7ND4Lg=="
|
|
13
13
|
},
|
|
14
14
|
"@ours.network/tg-connector": {
|
|
15
|
-
"version": "1.0.1-nightly.
|
|
16
|
-
"integrity": "sha512-
|
|
15
|
+
"version": "1.0.1-nightly.5",
|
|
16
|
+
"integrity": "sha512-WyoqnWg7Y2D45J2b4aJ20PydSKaSheC8NdVJTnrjQozsD4pW0ueDfuTwvuO5zx56KUqhhWXRVP7kztk9pycU5w=="
|
|
17
17
|
},
|
|
18
18
|
"@ours.network/cowork": {
|
|
19
|
-
"version": "1.3.2-nightly.
|
|
20
|
-
"integrity": "sha512-
|
|
19
|
+
"version": "1.3.2-nightly.20260922.70e6adf",
|
|
20
|
+
"integrity": "sha512-RpbbD9pxundiln4+3eD1wwMgXd13nM24CbaabFUQMartqpNMVm1cVzU6sGvuZ2hgMMRVwWbrmjk1gL7DtxiZ4g=="
|
|
21
21
|
},
|
|
22
22
|
"@ours.network/messenger-server": {
|
|
23
|
-
"version": "1.0.31-nightly.
|
|
24
|
-
"integrity": "sha512-
|
|
23
|
+
"version": "1.0.31-nightly.5",
|
|
24
|
+
"integrity": "sha512-4UtUeq+WNgTSQjeWKMAer7mSPeue8hUrY23AgnSwreBfVui4GjN/aJ6jS6Znz9v3nS5D9DM+faeW1JVHEpgyDg=="
|
|
25
25
|
},
|
|
26
26
|
"@ours.network/fleet": {
|
|
27
|
-
"version": "1.2.0-nightly.
|
|
28
|
-
"integrity": "sha512-
|
|
27
|
+
"version": "1.2.0-nightly.5",
|
|
28
|
+
"integrity": "sha512-wCM7oLwgpfnMuJv1/nB/2zhHqEqyl2x29gCo41RXKBMw7PJI64NVMpjeSu7SiCyjNlelAMOkRMGRcv+IdUnvgA=="
|
|
29
29
|
},
|
|
30
30
|
"@ours.network/mcp": {
|
|
31
31
|
"version": "1.2.0-nightly.6",
|
package/assets/sources.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"release": {
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"channel": "nightly",
|
|
5
|
-
"installerVersion": "1.2.1-nightly.
|
|
5
|
+
"installerVersion": "1.2.1-nightly.12",
|
|
6
6
|
"packages": {
|
|
7
7
|
"@ours.network/sdk": {
|
|
8
8
|
"version": "3.8.1-nightly.10",
|
|
@@ -13,20 +13,20 @@
|
|
|
13
13
|
"integrity": "sha512-7kZ1EzLpWNsyAdil4NoZfQc5RtAcbfIqc0AgTaMyaGA+ayqZRVdarYuBOo+fOT6dwbLLlMvWpZQgVMtR7ND4Lg=="
|
|
14
14
|
},
|
|
15
15
|
"@ours.network/tg-connector": {
|
|
16
|
-
"version": "1.0.1-nightly.
|
|
17
|
-
"integrity": "sha512-
|
|
16
|
+
"version": "1.0.1-nightly.5",
|
|
17
|
+
"integrity": "sha512-WyoqnWg7Y2D45J2b4aJ20PydSKaSheC8NdVJTnrjQozsD4pW0ueDfuTwvuO5zx56KUqhhWXRVP7kztk9pycU5w=="
|
|
18
18
|
},
|
|
19
19
|
"@ours.network/cowork": {
|
|
20
|
-
"version": "1.3.2-nightly.
|
|
21
|
-
"integrity": "sha512-
|
|
20
|
+
"version": "1.3.2-nightly.20260922.70e6adf",
|
|
21
|
+
"integrity": "sha512-RpbbD9pxundiln4+3eD1wwMgXd13nM24CbaabFUQMartqpNMVm1cVzU6sGvuZ2hgMMRVwWbrmjk1gL7DtxiZ4g=="
|
|
22
22
|
},
|
|
23
23
|
"@ours.network/messenger-server": {
|
|
24
|
-
"version": "1.0.31-nightly.
|
|
25
|
-
"integrity": "sha512-
|
|
24
|
+
"version": "1.0.31-nightly.5",
|
|
25
|
+
"integrity": "sha512-4UtUeq+WNgTSQjeWKMAer7mSPeue8hUrY23AgnSwreBfVui4GjN/aJ6jS6Znz9v3nS5D9DM+faeW1JVHEpgyDg=="
|
|
26
26
|
},
|
|
27
27
|
"@ours.network/fleet": {
|
|
28
|
-
"version": "1.2.0-nightly.
|
|
29
|
-
"integrity": "sha512-
|
|
28
|
+
"version": "1.2.0-nightly.5",
|
|
29
|
+
"integrity": "sha512-wCM7oLwgpfnMuJv1/nB/2zhHqEqyl2x29gCo41RXKBMw7PJI64NVMpjeSu7SiCyjNlelAMOkRMGRcv+IdUnvgA=="
|
|
30
30
|
},
|
|
31
31
|
"@ours.network/mcp": {
|
|
32
32
|
"version": "1.2.0-nightly.6",
|
|
@@ -67,19 +67,19 @@
|
|
|
67
67
|
},
|
|
68
68
|
"@ours.network/tg-connector": {
|
|
69
69
|
"type": "npm",
|
|
70
|
-
"version": "1.0.1-nightly.
|
|
70
|
+
"version": "1.0.1-nightly.5"
|
|
71
71
|
},
|
|
72
72
|
"@ours.network/cowork": {
|
|
73
73
|
"type": "npm",
|
|
74
|
-
"version": "1.3.2-nightly.
|
|
74
|
+
"version": "1.3.2-nightly.20260922.70e6adf"
|
|
75
75
|
},
|
|
76
76
|
"@ours.network/messenger-server": {
|
|
77
77
|
"type": "npm",
|
|
78
|
-
"version": "1.0.31-nightly.
|
|
78
|
+
"version": "1.0.31-nightly.5"
|
|
79
79
|
},
|
|
80
80
|
"@ours.network/fleet": {
|
|
81
81
|
"type": "npm",
|
|
82
|
-
"version": "1.2.0-nightly.
|
|
82
|
+
"version": "1.2.0-nightly.5"
|
|
83
83
|
},
|
|
84
84
|
"@ours.network/mcp": {
|
|
85
85
|
"type": "npm",
|
package/lib/build-transition.mjs
CHANGED
|
@@ -53,6 +53,7 @@ export async function serverBuildTransition(record, args, effects) {
|
|
|
53
53
|
}
|
|
54
54
|
await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
|
|
55
55
|
await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
|
|
56
|
+
if (record.gateway && ['daemon', 'cowork', 'gateway'].every(name => runningServices.includes(name))) await stage('Verify authenticated gateway management', () => effects.verifyGateway(record));
|
|
56
57
|
const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
|
|
57
58
|
delete completed.buildTransition;
|
|
58
59
|
effects.writeJson(path, JSON.stringify(completed, null, 2) + '\n');
|
package/lib/effects.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { randomUUID, createHash } from 'node:crypto';
|
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
21
|
import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
|
|
22
22
|
import { validateHostProfile } from './target.mjs';
|
|
23
|
+
import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
|
|
23
24
|
import { createServerOnboarding } from './server-onboarding.mjs';
|
|
24
25
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
25
26
|
import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
|
|
@@ -537,6 +538,7 @@ export function networkEffects(effects) {
|
|
|
537
538
|
const baseEnv = (record) => ({
|
|
538
539
|
OURS_DAEMON_ID: record.instanceId,
|
|
539
540
|
OURS_IMAGE: `${record.project}:runtime`,
|
|
541
|
+
OURS_GATEWAY_IMAGE: `${record.project}:gateway`,
|
|
540
542
|
OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
|
|
541
543
|
OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
|
|
542
544
|
OURS_HOST_PORT: String(record.port ?? 3050),
|
|
@@ -548,6 +550,7 @@ export function networkEffects(effects) {
|
|
|
548
550
|
'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
|
|
549
551
|
record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
|
|
550
552
|
? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
|
|
553
|
+
...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
|
|
551
554
|
'--project-name', record.project, ...args,
|
|
552
555
|
];
|
|
553
556
|
const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
|
|
@@ -630,7 +633,7 @@ export function networkEffects(effects) {
|
|
|
630
633
|
const instanceId = env.OURS_DAEMON_ID || randomUUID();
|
|
631
634
|
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) throw new Error('OURS_DAEMON_ID must be a lowercase UUID');
|
|
632
635
|
const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
|
|
633
|
-
return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
636
|
+
return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])], ...(mode === 'docker' ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
634
637
|
},
|
|
635
638
|
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
|
|
636
639
|
if (existing) {
|
|
@@ -709,6 +712,44 @@ export function networkEffects(effects) {
|
|
|
709
712
|
if (!retainConfig) writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
|
|
710
713
|
},
|
|
711
714
|
async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
|
|
715
|
+
async prepareGateway(record) {
|
|
716
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
717
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
718
|
+
atomicWriteConfig(join(record.workDir, 'Dockerfile.gateway'), readFileSync(join(INSTALLER_ASSETS, 'Dockerfile.gateway'), 'utf8'));
|
|
719
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
720
|
+
},
|
|
721
|
+
async removeGatewayContainer(record) { await compose(record, ['rm', '-f', 'gateway']); },
|
|
722
|
+
async verifyGateway(record) {
|
|
723
|
+
const { prefix } = gatewayAddress(record);
|
|
724
|
+
const script = `const fs=require('node:fs');(async()=>{
|
|
725
|
+
const base=${JSON.stringify('http://gateway:8080' + prefix)};
|
|
726
|
+
const get=async(path,options={})=>{const r=await fetch(base+path,{...options,redirect:'error',signal:AbortSignal.timeout(5000)});if(!r.ok)throw Error('Gateway readiness HTTP '+r.status);return r.json();};
|
|
727
|
+
const selection=await get('/daemon/selection');
|
|
728
|
+
if(selection.instanceId!==${JSON.stringify(record.instanceId)})throw Error('Gateway daemon instance mismatch');
|
|
729
|
+
const denied=await fetch(base+'/cowork/management/rpc',{method:'POST',redirect:'error',signal:AbortSignal.timeout(5000),headers:{'content-type':'application/json'},body:'{}'});
|
|
730
|
+
await denied.body?.cancel();if(denied.status!==401)throw Error('Gateway management does not reject unauthenticated requests');
|
|
731
|
+
const token=fs.readFileSync('/var/lib/ours/daemon-token','utf8').trim();
|
|
732
|
+
const headers={'x-ours-api-token':token,'content-type':'application/json'};
|
|
733
|
+
const identities=await get('/daemon/identities',{headers});
|
|
734
|
+
if(!Array.isArray(identities.identities))throw Error('Gateway daemon API invalid');
|
|
735
|
+
const result=await get('/cowork/management/rpc',{method:'POST',headers,body:JSON.stringify({version:1,id:'installer-readiness',method:'room.list',params:{}})});
|
|
736
|
+
if(result.version!==1||result.id!=='installer-readiness'||!Array.isArray(result.result)||result.error)throw Error('Gateway Cowork management unavailable');
|
|
737
|
+
})().catch(()=>{console.error('Authenticated gateway readiness failed');process.exitCode=1;});`;
|
|
738
|
+
await compose(record, ['exec', '-T', 'daemon', 'node', '-e', script], { sensitive: true });
|
|
739
|
+
},
|
|
740
|
+
async qualifyGatewayRuntime(record) {
|
|
741
|
+
for (const [name, args, capability] of [
|
|
742
|
+
['cowork', ['--json', 'capabilities'], 'cowork.http-management-v1'],
|
|
743
|
+
['messenger-server', ['capabilities'], 'messenger.gateway-prefix-v1'],
|
|
744
|
+
['tg-connector', ['capabilities'], 'telegram.gateway-listener-v1'],
|
|
745
|
+
]) {
|
|
746
|
+
const probe = await effects.run('docker', ['run', '--rm', '--network', 'none', '--read-only', '--entrypoint', 'node', `${record.project}:runtime`, `/opt/ours/node_modules/@ours.network/${name}/dist/cli.js`, ...args]);
|
|
747
|
+
let value; try { value = JSON.parse(probe.stdout); } catch {}
|
|
748
|
+
const capabilities = value?.result?.capabilities ?? value?.capabilities;
|
|
749
|
+
if (!Array.isArray(capabilities) || !capabilities.includes(capability))
|
|
750
|
+
throw new Error(`Selected ${name} artifact lacks ${capability}; select a compatible release before enabling the gateway`);
|
|
751
|
+
}
|
|
752
|
+
},
|
|
712
753
|
async prepareInstallation(record, { runtimeOnly = false } = {}) {
|
|
713
754
|
let copied = false;
|
|
714
755
|
if (!existsSync(record.workDir)) {
|
|
@@ -723,12 +764,20 @@ export function networkEffects(effects) {
|
|
|
723
764
|
else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
|
|
724
765
|
if (record.mode === 'docker') {
|
|
725
766
|
refreshDockerPolicyCopy(record);
|
|
767
|
+
if (record.gateway) {
|
|
768
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
769
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
770
|
+
}
|
|
726
771
|
// The installer owns these dependencies in both installation modes.
|
|
727
772
|
const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
728
773
|
writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
|
|
729
774
|
const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
|
|
730
775
|
if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
731
776
|
await effects.qualifyDockerRuntime(record);
|
|
777
|
+
if (record.gateway) {
|
|
778
|
+
await effects.qualifyGatewayRuntime(record);
|
|
779
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
780
|
+
}
|
|
732
781
|
if (runtimeOnly) return;
|
|
733
782
|
await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
|
|
734
783
|
} else {
|
|
@@ -869,7 +918,7 @@ export function networkEffects(effects) {
|
|
|
869
918
|
async publishServerBuild(record, candidate) {
|
|
870
919
|
const previous = join(candidate.root, 'previous-runtime');
|
|
871
920
|
if (record.mode === 'docker') {
|
|
872
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
921
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
873
922
|
const retained = `${candidate.project}:previous-${target}`;
|
|
874
923
|
const found = await effects.run('docker', ['image', 'inspect', retained], { allowCodes: [1] });
|
|
875
924
|
if (found.code !== 0) {
|
|
@@ -890,7 +939,7 @@ export function networkEffects(effects) {
|
|
|
890
939
|
renameSync(candidate.workDir, record.workDir);
|
|
891
940
|
} else privateDirectory(record.workDir);
|
|
892
941
|
if (record.mode === 'docker') {
|
|
893
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
942
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
894
943
|
await effects.run('docker', ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
|
|
895
944
|
}
|
|
896
945
|
}
|
|
@@ -905,7 +954,7 @@ export function networkEffects(effects) {
|
|
|
905
954
|
},
|
|
906
955
|
async discardServerBuild(candidate) {
|
|
907
956
|
if (candidate.mode === 'docker') {
|
|
908
|
-
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
|
|
957
|
+
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance', ...(candidate.gateway ? ['gateway', 'previous-gateway'] : [])]) {
|
|
909
958
|
const image = `${candidate.project}:${target}`;
|
|
910
959
|
const found = await effects.run('docker', ['image', 'inspect', image], { allowCodes: [1] });
|
|
911
960
|
if (found.code === 0) await effects.run('docker', ['image', 'rm', image]);
|
|
@@ -1155,14 +1204,17 @@ export function networkEffects(effects) {
|
|
|
1155
1204
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
1156
1205
|
},
|
|
1157
1206
|
async discoverClientProfile(endpoint, credentialPath) {
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1207
|
+
const base = serverBase(endpoint);
|
|
1208
|
+
const request = path => fetch(`${base}${path}`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
1209
|
+
const discovery = await request('/.well-known/ours');
|
|
1210
|
+
if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
|
|
1211
|
+
// Only an absent discovery resource denotes a legacy direct daemon. Never
|
|
1212
|
+
// reinterpret authentication, redirect, or malformed metadata as legacy.
|
|
1213
|
+
if (discovery.status !== 404) throw new Error(`Gateway discovery answered HTTP ${discovery.status}`);
|
|
1214
|
+
const response = await request('/selection');
|
|
1162
1215
|
if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
|
|
1163
1216
|
const selection = await response.json();
|
|
1164
|
-
|
|
1165
|
-
return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
|
|
1217
|
+
return validateHostProfile({ endpoint: base, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
|
|
1166
1218
|
},
|
|
1167
1219
|
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
|
|
1168
1220
|
const root = join(home, '.ours-client');
|
|
@@ -1196,7 +1248,26 @@ export function networkEffects(effects) {
|
|
|
1196
1248
|
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1197
1249
|
return { configPath, profile: validateHostProfile(saved), settings };
|
|
1198
1250
|
},
|
|
1199
|
-
async
|
|
1251
|
+
async qualifyInstalledGatewayClient(profile) {
|
|
1252
|
+
if (!profile.installer?.integrations?.includes('fleet')) return;
|
|
1253
|
+
let info;
|
|
1254
|
+
try { info = JSON.parse((await effects.run('ours-fleet', ['version', '--json'])).stdout); } catch {}
|
|
1255
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1256
|
+
throw new Error('Installed Fleet does not support gateway HTTP management; upgrade Fleet before gateway-enable');
|
|
1257
|
+
},
|
|
1258
|
+
async qualifyGatewayClient({ profile, sourcesPath, sources, integrations, refresh = false }) {
|
|
1259
|
+
if (!profile.serverUrl || !integrations.includes('fleet')) return;
|
|
1260
|
+
// Acquire into the retained package cache without publishing a profile,
|
|
1261
|
+
// credential, native command, or integration. Normal acquisition rechecks it.
|
|
1262
|
+
const staging = mkdtempSync(join(home, '.ours-gateway-client-'));
|
|
1263
|
+
try {
|
|
1264
|
+
const stagedSources = join(staging, 'sources.json');
|
|
1265
|
+
writeFileSync(stagedSources, sources ? JSON.stringify(sources, null, 2) + '\n' : readFileSync(sourcesPath), { mode: 0o600 });
|
|
1266
|
+
await effects.acquireClientPackages(join(home, '.ours-client/profile.json'), stagedSources, integrations,
|
|
1267
|
+
{ refresh, gatewayServerUrl: profile.serverUrl, qualifyOnly: true });
|
|
1268
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
1269
|
+
},
|
|
1270
|
+
async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false, gatewayServerUrl, qualifyOnly = false } = {}) {
|
|
1200
1271
|
const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
|
|
1201
1272
|
// Every client installation includes the native CLI and its SDK dependency.
|
|
1202
1273
|
const selected = clientPackageNames(integrations);
|
|
@@ -1234,6 +1305,13 @@ export function networkEffects(effects) {
|
|
|
1234
1305
|
writePrivateNew(join(root, '.packages-ready'), 'ready\n');
|
|
1235
1306
|
}
|
|
1236
1307
|
verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
|
|
1308
|
+
if (integrations.includes('fleet') && (gatewayServerUrl || readHostProfileFile(configPath)?.serverUrl)) {
|
|
1309
|
+
let info;
|
|
1310
|
+
try { info = JSON.parse(readFileSync(join(root, 'node_modules/@ours.network/fleet/dist/build-info.json'), 'utf8')); } catch {}
|
|
1311
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1312
|
+
throw new Error('Selected Fleet artifact does not support gateway HTTP management; select a compatible client release');
|
|
1313
|
+
}
|
|
1314
|
+
if (qualifyOnly) return;
|
|
1237
1315
|
const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
|
|
1238
1316
|
// Local acquisition alone does not publish native commands. Use the user's
|
|
1239
1317
|
// configured npm prefix and retained dependency closure, including on retry.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
4
|
+
import { validateInstallation } from './plan.mjs';
|
|
5
|
+
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
// Called under the installation lock. No stored state or credentials are changed.
|
|
8
|
+
export async function enableGateway(record, args, effects) {
|
|
9
|
+
validateInstallation(record, record.root);
|
|
10
|
+
if (record.mode !== 'docker' || record.schema !== 2 || record.buildTransition || record.layoutConversion)
|
|
11
|
+
throw new Error('Gateway migration requires a settled schema-2 Docker installation');
|
|
12
|
+
const journal = join(record.root, 'gateway-transition.json');
|
|
13
|
+
const recordPath = join(record.root, 'installation.json');
|
|
14
|
+
const profilePath = join(effects.home, '.ours-client', 'profile.json');
|
|
15
|
+
const paths = [recordPath, ...['docker-compose.gateway.yaml', 'nginx.conf', 'Dockerfile.gateway'].map(name => join(record.workDir, name)), profilePath];
|
|
16
|
+
const snapshot = path => {
|
|
17
|
+
if (existsSync(path)) {
|
|
18
|
+
const stat = lstatSync(path);
|
|
19
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & (path === profilePath || path === journal || path === recordPath ? 0o077 : 0o022)))
|
|
20
|
+
throw new Error('Gateway migration requires private owned configuration files');
|
|
21
|
+
}
|
|
22
|
+
return snapshotConfig(path);
|
|
23
|
+
};
|
|
24
|
+
async function rollback(saved) {
|
|
25
|
+
const phase = value => { saved.phase = value; atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n'); };
|
|
26
|
+
if (saved.phase === 'switching') phase('rollback-stopping');
|
|
27
|
+
if (saved.phase === 'rollback-stopping') {
|
|
28
|
+
await effects.serverLifecycle(saved.candidate, 'stop');
|
|
29
|
+
await effects.removeGatewayContainer(saved.candidate);
|
|
30
|
+
phase('rollback-restoring');
|
|
31
|
+
}
|
|
32
|
+
if (saved.phase === 'preparing') phase('rollback-restoring');
|
|
33
|
+
if (saved.phase === 'rollback-restoring') {
|
|
34
|
+
for (let i = 0; i < paths.length; i++) restoreConfig(paths[i], saved.snapshots[i]);
|
|
35
|
+
phase('rollback-restarting');
|
|
36
|
+
}
|
|
37
|
+
await effects.serverLifecycle(saved.original, 'start', saved.running);
|
|
38
|
+
unlinkSync(journal);
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(journal)) {
|
|
41
|
+
snapshot(journal);
|
|
42
|
+
const saved = JSON.parse(readFileSync(journal, 'utf8'));
|
|
43
|
+
validateInstallation(saved.original, record.root);
|
|
44
|
+
validateInstallation(saved.candidate, record.root);
|
|
45
|
+
if (saved.schema !== 1 || !['preparing', 'switching', 'rollback-stopping', 'rollback-restoring', 'rollback-restarting'].includes(saved.phase) || !Array.isArray(saved.snapshots) || saved.snapshots.length !== paths.length
|
|
46
|
+
|| !Array.isArray(saved.running) || saved.running.some(name => !saved.original.services.includes(name)))
|
|
47
|
+
throw new Error('Invalid retained gateway transition; operator recovery required');
|
|
48
|
+
await rollback(saved);
|
|
49
|
+
throw new Error('Interrupted gateway migration rolled back; repeat gateway-enable to begin a new migration');
|
|
50
|
+
}
|
|
51
|
+
if (record.gateway) {
|
|
52
|
+
if (args.serverUrl && args.serverUrl !== gatewayAddress(record).base) throw new Error('Gateway URL is already configured; refusing implicit URL replacement');
|
|
53
|
+
await effects.verifyGateway(record);
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
const candidate = { ...record, gateway: { version: 1, ...(args.serverUrl ? { serverUrl: args.serverUrl } : {}) }, services: [...record.services, 'gateway'] };
|
|
57
|
+
validateInstallation(candidate, record.root);
|
|
58
|
+
const current = effects.readManagedClientProfile();
|
|
59
|
+
const selected = current?.expectedInstanceId === record.instanceId && current.endpoint === `http://127.0.0.1:${record.port}`;
|
|
60
|
+
const saved = { schema: 1, phase: 'preparing', original: record, candidate, snapshots: paths.map(snapshot), running: await effects.serverLifecycle(record, 'status') };
|
|
61
|
+
// Reject old service artifacts before stopping the working deployment.
|
|
62
|
+
await effects.qualifyGatewayRuntime(candidate);
|
|
63
|
+
if (selected) await effects.qualifyInstalledGatewayClient(current);
|
|
64
|
+
atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
|
|
65
|
+
try {
|
|
66
|
+
await effects.prepareGateway(candidate);
|
|
67
|
+
saved.phase = 'switching';
|
|
68
|
+
atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
|
|
69
|
+
await effects.serverLifecycle(record, 'stop');
|
|
70
|
+
await effects.serverLifecycle(candidate, 'start', [...new Set([...saved.running, 'daemon', 'cowork', 'gateway'])]);
|
|
71
|
+
await effects.verifyGateway(candidate);
|
|
72
|
+
if (selected) {
|
|
73
|
+
const serverUrl = gatewayAddress(candidate).base;
|
|
74
|
+
atomicWriteConfig(profilePath, JSON.stringify({ ...current, serverUrl, endpoint: serverUrl + '/daemon' }, null, 2) + '\n');
|
|
75
|
+
}
|
|
76
|
+
// Preserve the running/stopped selection; gateway follows a running daemon.
|
|
77
|
+
const keep = [...saved.running, ...(saved.running.includes('daemon') ? ['gateway'] : [])];
|
|
78
|
+
await effects.serverLifecycle(candidate, 'stop', candidate.services.filter(name => !keep.includes(name)));
|
|
79
|
+
atomicWriteConfig(recordPath, JSON.stringify(candidate, null, 2) + '\n');
|
|
80
|
+
unlinkSync(journal);
|
|
81
|
+
return candidate;
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
try { await rollback(saved); }
|
|
84
|
+
catch (rollbackError) { throw new AggregateError([cause, rollbackError], 'Gateway migration and rollback failed; retained journal requires gateway-enable recovery'); }
|
|
85
|
+
throw new Error('Gateway migration failed; previous routing, profile and service selection restored', { cause });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/gateway.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** One public URL; fixed same-origin service paths are never supplied by a peer. */
|
|
2
|
+
export const GATEWAY_SERVICES = Object.freeze({daemon:'/daemon',cowork:'/cowork',telegram:'/tg-connector',messenger:'/messenger'});
|
|
3
|
+
export function serverBase(value) {
|
|
4
|
+
if(typeof value!=='string'||/[\s\\?#]/.test(value)||!/^https?:\/\//.test(value))throw new Error('Server URL must be an HTTP or HTTPS base URL');
|
|
5
|
+
const url=new URL(value);
|
|
6
|
+
if(url.username||url.password)throw new Error('Server URL cannot contain credentials');
|
|
7
|
+
return url.origin+url.pathname.replace(/\/+$/,'');
|
|
8
|
+
}
|
|
9
|
+
export function gatewayDiscovery(record) {
|
|
10
|
+
return {schema:1,instanceId:record.instanceId,services:GATEWAY_SERVICES,capabilities:['ours.gateway-v1','cowork.http-management-v1']};
|
|
11
|
+
}
|
|
12
|
+
export function validateGatewayDiscovery(url,value,credentialPath) {
|
|
13
|
+
const base=serverBase(url);
|
|
14
|
+
if(value?.schema!==1||!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.instanceId??'')
|
|
15
|
+
||!Array.isArray(value.capabilities)||!value.capabilities.includes('ours.gateway-v1')||!value.capabilities?.includes('cowork.http-management-v1')
|
|
16
|
+
||Object.keys(GATEWAY_SERVICES).some(key=>value.services?.[key]!==GATEWAY_SERVICES[key]))throw new Error('Incompatible server gateway discovery');
|
|
17
|
+
return {serverUrl:base,endpoint:base+'/daemon',expectedInstanceId:value.instanceId,credentialPath};
|
|
18
|
+
}
|
|
19
|
+
export function gatewayAddress(record) {
|
|
20
|
+
const base = serverBase(record.gateway?.serverUrl ?? `http://127.0.0.1:${record.port}`);
|
|
21
|
+
const url = new URL(base);
|
|
22
|
+
if (!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]*$/.test(url.pathname)) throw new Error('Gateway base path must contain plain URL path segments');
|
|
23
|
+
return { base, origin: url.origin, prefix: url.pathname.replace(/\/$/, '') };
|
|
24
|
+
}
|
|
25
|
+
export function gatewayCompose(record) {
|
|
26
|
+
const { origin, prefix } = gatewayAddress(record);
|
|
27
|
+
return `services:
|
|
28
|
+
daemon:
|
|
29
|
+
ports: !reset []
|
|
30
|
+
telegram:
|
|
31
|
+
ports: !reset []
|
|
32
|
+
environment:
|
|
33
|
+
OURS_TG_CONTROL_HOST: "0.0.0.0"
|
|
34
|
+
cowork:
|
|
35
|
+
ports: !reset []
|
|
36
|
+
environment:
|
|
37
|
+
OURS_COWORK_HTTP_MANAGEMENT: "1"
|
|
38
|
+
OURS_COWORK_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
|
|
39
|
+
messenger:
|
|
40
|
+
ports: !reset []
|
|
41
|
+
environment:
|
|
42
|
+
OURS_MESSENGER_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
|
|
43
|
+
OURS_MESSENGER_BASE_PATH: ${JSON.stringify(prefix + "/messenger/")}
|
|
44
|
+
gateway:
|
|
45
|
+
image: "\${OURS_GATEWAY_IMAGE:-${record.project}:gateway}"
|
|
46
|
+
build:
|
|
47
|
+
context: .
|
|
48
|
+
dockerfile: Dockerfile.gateway
|
|
49
|
+
user: "101:101"
|
|
50
|
+
read_only: true
|
|
51
|
+
cap_drop: [ALL]
|
|
52
|
+
security_opt: [no-new-privileges:true]
|
|
53
|
+
tmpfs: ["/tmp:rw,nosuid,nodev,noexec,size=32m,mode=1777"]
|
|
54
|
+
networks: [ours]
|
|
55
|
+
ports:
|
|
56
|
+
- {target: 8080, published: "${record.port}", host_ip: "127.0.0.1"}
|
|
57
|
+
restart: unless-stopped
|
|
58
|
+
stop_grace_period: 10s
|
|
59
|
+
healthcheck:
|
|
60
|
+
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz]
|
|
61
|
+
interval: 5s
|
|
62
|
+
timeout: 3s
|
|
63
|
+
retries: 6
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
export function gatewayNginx(record) {
|
|
67
|
+
const { prefix: basePath } = gatewayAddress(record);
|
|
68
|
+
const routes=[['daemon',3050],['cowork',record.coworkPort??3052],['telegram',3051],['messenger',8420]];
|
|
69
|
+
const upstream=Object.fromEntries(routes);
|
|
70
|
+
for(const port of Object.values(upstream))if(!Number.isInteger(port)||port<1||port>65535)throw new Error('Invalid gateway upstream port');
|
|
71
|
+
const route=(name,servicePath)=> { const prefix=basePath+servicePath; return`location ${prefix}/ {
|
|
72
|
+
set $upstream_${name} http://${name}:${upstream[name]};
|
|
73
|
+
rewrite ^${prefix}/(.*)$ /$1 break;
|
|
74
|
+
proxy_pass $upstream_${name};
|
|
75
|
+
proxy_set_header Host $http_host;
|
|
76
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
77
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
78
|
+
proxy_set_header Connection $connection_upgrade;
|
|
79
|
+
proxy_http_version 1.1;
|
|
80
|
+
proxy_buffering off;
|
|
81
|
+
proxy_read_timeout 300s;
|
|
82
|
+
}`; };
|
|
83
|
+
return `worker_processes 1;
|
|
84
|
+
pid /tmp/nginx.pid;
|
|
85
|
+
error_log /dev/stderr warn;
|
|
86
|
+
events { worker_connections 1024; }
|
|
87
|
+
http {
|
|
88
|
+
access_log off;
|
|
89
|
+
client_body_temp_path /tmp/client;
|
|
90
|
+
proxy_temp_path /tmp/proxy;
|
|
91
|
+
fastcgi_temp_path /tmp/fastcgi;
|
|
92
|
+
uwsgi_temp_path /tmp/uwsgi;
|
|
93
|
+
scgi_temp_path /tmp/scgi;
|
|
94
|
+
proxy_next_upstream off;
|
|
95
|
+
resolver 127.0.0.11 valid=5s ipv6=off;
|
|
96
|
+
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
|
97
|
+
server {
|
|
98
|
+
listen 8080;
|
|
99
|
+
server_name _;
|
|
100
|
+
client_max_body_size 64m;
|
|
101
|
+
location = /healthz { return 200 'ok'; }
|
|
102
|
+
location = ${basePath}/.well-known/ours { default_type application/json; return 200 '${JSON.stringify(gatewayDiscovery(record))}'; }
|
|
103
|
+
location = / { return 302 ${basePath}/messenger/; }
|
|
104
|
+
location = ${basePath}/daemon { return 308 ${basePath}/daemon/; }
|
|
105
|
+
location = ${basePath}/cowork { return 308 ${basePath}/cowork/; }
|
|
106
|
+
location = ${basePath}/tg-connector { return 308 ${basePath}/tg-connector/; }
|
|
107
|
+
location = ${basePath}/messenger { return 308 ${basePath}/messenger/; }
|
|
108
|
+
# Never publish the unauthenticated loopback browser RPC. Management has its
|
|
109
|
+
# own application-level credential check and rejects browser-origin requests.
|
|
110
|
+
location = ${basePath}/cowork/rpc { return 403; }
|
|
111
|
+
location ${basePath}/cowork/management/ {
|
|
112
|
+
client_max_body_size 1m;
|
|
113
|
+
set $management http://cowork:${upstream.cowork};
|
|
114
|
+
rewrite ^${basePath}/cowork/(.*)$ /$1 break;
|
|
115
|
+
proxy_pass $management;
|
|
116
|
+
proxy_set_header Host $http_host;
|
|
117
|
+
proxy_http_version 1.1;
|
|
118
|
+
proxy_read_timeout 60s;
|
|
119
|
+
}
|
|
120
|
+
${route('daemon','/daemon')}
|
|
121
|
+
${route('cowork','/cowork')}
|
|
122
|
+
# Telegram's control API is local-only; no unauthenticated remote proxy.
|
|
123
|
+
location ${basePath}/tg-connector/ {
|
|
124
|
+
auth_request /_server_auth;
|
|
125
|
+
set $telegram http://telegram:3051;
|
|
126
|
+
rewrite ^${basePath}/tg-connector/(.*)$ /$1 break;
|
|
127
|
+
proxy_pass $telegram;
|
|
128
|
+
}
|
|
129
|
+
location = /_server_auth {
|
|
130
|
+
internal;
|
|
131
|
+
set $auth http://daemon:3050/identities;
|
|
132
|
+
proxy_pass $auth;
|
|
133
|
+
proxy_pass_request_body off;
|
|
134
|
+
proxy_set_header Content-Length "";
|
|
135
|
+
proxy_set_header X-Ours-Api-Token $http_x_ours_api_token;
|
|
136
|
+
}
|
|
137
|
+
${route('messenger','/messenger')}
|
|
138
|
+
location / { return 404; }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
}
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enableGateway } from './gateway-transition.mjs';
|
|
1
2
|
import { executeLegacyMigration } from './legacy-migration.mjs';
|
|
2
3
|
// ours-install v3 — the orchestrator.
|
|
3
4
|
//
|
|
@@ -1324,6 +1325,12 @@ async function executeServerCommand(args, effects) {
|
|
|
1324
1325
|
record = effects.newInstallation(args.stateDir, args.mode);
|
|
1325
1326
|
for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
|
|
1326
1327
|
}
|
|
1328
|
+
if (effects.readJson(join(record.root, 'gateway-transition.json')) && args.operation !== 'gateway-enable' && args.operation !== 'status') throw new Error('Interrupted gateway migration; run server gateway-enable before other changes');
|
|
1329
|
+
if (args.serverUrl && args.operation === 'install') {
|
|
1330
|
+
if (!record.gateway) throw new Error('Use server gateway-enable to migrate an existing Docker installation');
|
|
1331
|
+
if (existing && record.gateway.serverUrl !== args.serverUrl) throw new Error('Conflicting gateway server URL');
|
|
1332
|
+
record.gateway = { ...record.gateway, serverUrl: args.serverUrl };
|
|
1333
|
+
}
|
|
1327
1334
|
if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
|
|
1328
1335
|
throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
|
|
1329
1336
|
}
|
|
@@ -1390,7 +1397,10 @@ async function executeServerCommand(args, effects) {
|
|
|
1390
1397
|
} else {
|
|
1391
1398
|
await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
|
|
1392
1399
|
}
|
|
1400
|
+
if (record.gateway) await effects.verifyGateway(record);
|
|
1393
1401
|
if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
|
|
1402
|
+
} else if (args.operation === 'gateway-enable') {
|
|
1403
|
+
await enableGateway(record, args, effects);
|
|
1394
1404
|
} else if (['backup', 'restore', 'reset'].includes(args.operation)) {
|
|
1395
1405
|
await effects.serverMaintenance(record, args);
|
|
1396
1406
|
} else if (['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -1479,6 +1489,7 @@ export async function runClientCommand(command, effects) {
|
|
|
1479
1489
|
resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
|
|
1480
1490
|
}
|
|
1481
1491
|
effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
|
|
1492
|
+
if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
|
|
1482
1493
|
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
|
|
1483
1494
|
let phase = 'Validate saved server connection';
|
|
1484
1495
|
try {
|
package/lib/plan.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
3
|
//
|
|
3
4
|
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
@@ -362,9 +363,14 @@ export function validateInstallation(record, root) {
|
|
|
362
363
|
|| !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
|
|
363
364
|
|| !/^ours-[a-z0-9]+$/.test(record.project ?? '')
|
|
364
365
|
|| (record.sourcePolicyHash !== undefined && !/^[0-9a-f]{64}$/.test(record.sourcePolicyHash))
|
|
365
|
-
|| !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s))) {
|
|
366
|
+
|| !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s) && !(s === 'gateway' && record.gateway?.version === 1))) {
|
|
366
367
|
throw new Error('Invalid or conflicting installation selection');
|
|
367
368
|
}
|
|
369
|
+
if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
|
|
370
|
+
|| Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
|
|
371
|
+
throw new Error('Invalid gateway installation selection');
|
|
372
|
+
}
|
|
373
|
+
if (record.gateway) gatewayAddress(record);
|
|
368
374
|
if (record.layoutConversion !== undefined) {
|
|
369
375
|
// DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
|
|
370
376
|
// Removal target: 3.0, after supported installs convert and upgrade inputs
|
|
@@ -402,7 +408,7 @@ export function validateInstallation(record, root) {
|
|
|
402
408
|
throw new Error('Invalid server build transition');
|
|
403
409
|
}
|
|
404
410
|
validateInstallation(candidate, candidate.root);
|
|
405
|
-
for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
411
|
+
for (const key of ['schema', 'mode', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
406
412
|
if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
|
|
407
413
|
}
|
|
408
414
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
/** Human bootstrap and local client handoff through the existing owner interfaces. */
|
|
2
3
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
@@ -82,7 +83,8 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
82
83
|
const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
|
|
83
84
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
|
|
84
85
|
}
|
|
85
|
-
const
|
|
86
|
+
const serverUrl = record.gateway ? gatewayAddress(record).base : undefined;
|
|
87
|
+
const endpoint = serverUrl ? `${serverUrl}/daemon` : `http://127.0.0.1:${record.port}`;
|
|
86
88
|
const current = effects.readManagedClientProfile();
|
|
87
89
|
if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
|
|
88
90
|
privatePath(record.root, true);
|
|
@@ -93,13 +95,16 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
93
95
|
const published = join(root, 'issued-' + randomUUID());
|
|
94
96
|
const credential = join(stage, 'credential');
|
|
95
97
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (!current) {
|
|
99
|
+
effects.out?.('Issuing a separate local client credential with the retained server authority.');
|
|
100
|
+
try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
|
|
101
|
+
catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
|
|
102
|
+
}
|
|
103
|
+
const retainedCredential = current?.credentialPath ?? credential;
|
|
104
|
+
const stat = privatePath(retainedCredential);
|
|
105
|
+
if (stat.size > 4096 || !readFileSync(retainedCredential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
|
|
101
106
|
const profile = {
|
|
102
|
-
...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
|
|
107
|
+
...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current?.credentialPath ?? join(published, 'credential') }),
|
|
103
108
|
installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
|
|
104
109
|
};
|
|
105
110
|
writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
package/lib/setup-options.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { serverBase } from './gateway.mjs';
|
|
1
2
|
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
2
3
|
|
|
3
4
|
const scopes = ['all', 'server', 'client'];
|
|
4
5
|
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
5
6
|
const valueFlags = new Map(Object.entries({
|
|
6
|
-
'--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
7
|
+
'--server-url': 'serverUrl', '--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
7
8
|
'--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
|
|
8
9
|
'--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
9
10
|
}));
|
|
@@ -40,6 +41,10 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
40
41
|
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
41
42
|
if (options.mode === 'native') options.mode = 'packages';
|
|
42
43
|
if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
|
|
44
|
+
if (options.serverUrl !== undefined) {
|
|
45
|
+
if (!server || options.mode !== 'docker' || options.operation !== 'install') throw new Error('--server-url requires Docker server installation');
|
|
46
|
+
options.serverUrl = serverBase(options.serverUrl);
|
|
47
|
+
}
|
|
43
48
|
if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
|
|
44
49
|
if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
45
50
|
if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
|
package/lib/setup.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
import { inspectLegacyMigration } from './legacy-migration.mjs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
|
|
@@ -10,7 +11,7 @@ import { USAGE } from './usage.mjs';
|
|
|
10
11
|
import { validateIdentityName } from './server-onboarding.mjs';
|
|
11
12
|
import { validateFleetSettings } from './fleet-settings.mjs';
|
|
12
13
|
|
|
13
|
-
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
14
|
+
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
14
15
|
|
|
15
16
|
export function completeReleasePolicy(retained, supplied) {
|
|
16
17
|
if (retained?.release) {
|
|
@@ -67,7 +68,7 @@ export async function prepareSetupPlan(options, effects) {
|
|
|
67
68
|
}
|
|
68
69
|
// Reject a local client already attached to another server before changing the server.
|
|
69
70
|
const saved = options.scope === 'all' ? effects.readManagedClientProfile() : null;
|
|
70
|
-
if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== `http://127.0.0.1:${plan.port}`)) {
|
|
71
|
+
if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== (plan.existing?.gateway ? gatewayAddress(plan.existing).base + '/daemon' : `http://127.0.0.1:${plan.port}`))) {
|
|
71
72
|
throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
|
|
72
73
|
}
|
|
73
74
|
} else {
|
package/lib/target.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serverBase } from './gateway.mjs';
|
|
1
2
|
// ours-install v3 — argument handling and daemon detection.
|
|
2
3
|
//
|
|
3
4
|
// Pure argument handling and daemon-target selection. The orchestrator
|
|
@@ -85,17 +86,23 @@ export function validateHostProfile(value) {
|
|
|
85
86
|
const mixed = LEGACY_PROFILE_KEYS.filter((key) => Object.hasOwn(value, key));
|
|
86
87
|
if (mixed.length) throw profileError(`legacy selection keys cannot be mixed with a host profile (${mixed.join(', ')}).`);
|
|
87
88
|
const { endpoint, expectedInstanceId, credentialPath } = value;
|
|
88
|
-
if (typeof endpoint !== 'string' || endpoint.trim() !== endpoint || endpoint === '') throw profileError('endpoint must be a non-empty HTTP or HTTPS
|
|
89
|
+
if (typeof endpoint !== 'string' || endpoint.trim() !== endpoint || endpoint === '') throw profileError('endpoint must be a non-empty HTTP or HTTPS base URL.');
|
|
89
90
|
if (typeof expectedInstanceId !== 'string' || !PROFILE_UUID.test(expectedInstanceId)) throw profileError('expectedInstanceId must be a lowercase UUID.');
|
|
90
91
|
if (typeof credentialPath !== 'string' || credentialPath === '' || !credentialPath.startsWith('/') || resolve(credentialPath) !== credentialPath) {
|
|
91
92
|
throw profileError('credentialPath must be a normalized absolute path.');
|
|
92
93
|
}
|
|
93
94
|
let url;
|
|
94
|
-
try { url = new URL(endpoint); } catch { throw profileError('endpoint must be an HTTP or HTTPS
|
|
95
|
-
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.
|
|
96
|
-
throw profileError('endpoint must be an HTTP or HTTPS
|
|
95
|
+
try { url = new URL(endpoint); } catch { throw profileError('endpoint must be an HTTP or HTTPS base URL.'); }
|
|
96
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash) {
|
|
97
|
+
throw profileError('endpoint must be an HTTP or HTTPS base URL without credentials, query, or fragment.');
|
|
97
98
|
}
|
|
98
|
-
|
|
99
|
+
const endpointBase = serverBase(endpoint);
|
|
100
|
+
if (value.serverUrl !== undefined) {
|
|
101
|
+
const serverUrl = serverBase(value.serverUrl);
|
|
102
|
+
if (endpointBase !== serverUrl + '/daemon') throw profileError('endpoint must select serverUrl/daemon.');
|
|
103
|
+
return { serverUrl, endpoint: endpointBase, expectedInstanceId, credentialPath };
|
|
104
|
+
}
|
|
105
|
+
return { endpoint: endpointBase, expectedInstanceId, credentialPath };
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
export function resolveProfileSelection({ args, env = {}, home = homedir(), exists, readProfile }) {
|
|
@@ -454,10 +461,11 @@ export function searchFreePort(isTaken, { floor = FREE_PORT_FLOOR, reserved = IN
|
|
|
454
461
|
export function parseNetworkArgs(argv) {
|
|
455
462
|
if (!['server', 'client'].includes(argv[0])) return null;
|
|
456
463
|
const [role, operation] = argv;
|
|
457
|
-
const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild'];
|
|
464
|
+
const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
|
|
458
465
|
if (!operations.includes(operation)) throw new InstallUsageError(`Unsupported ${role} operation: ${operation ?? '(missing)'}`);
|
|
459
466
|
const allowed = role === 'client' ? ['config'] : ['state-dir'];
|
|
460
467
|
if (role === 'server' && operation === 'install') allowed.push('mode', 'sources', 'migrate');
|
|
468
|
+
if (role === 'server' && ['install', 'gateway-enable'].includes(operation)) allowed.push('server-url');
|
|
461
469
|
if (operation === 'access-issue') allowed.push('output');
|
|
462
470
|
if (['access-replace', 'reset'].includes(operation)) allowed.push('confirm');
|
|
463
471
|
if (['restore', 'update'].includes(operation)) allowed.push('compatible');
|
|
@@ -486,7 +494,7 @@ export function parseNetworkArgs(argv) {
|
|
|
486
494
|
} else {
|
|
487
495
|
const value = inline ?? argv[++i];
|
|
488
496
|
if (!value || value.startsWith('--')) throw new InstallUsageError(`--${name} requires a value`);
|
|
489
|
-
result[key] = name === 'mode' ? value : resolve(value);
|
|
497
|
+
result[key] = name === 'mode' ? value : name === 'server-url' ? serverBase(value) : resolve(value);
|
|
490
498
|
}
|
|
491
499
|
}
|
|
492
500
|
const required = role === 'client' ? [] : ['stateDir'];
|
package/lib/usage.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
|
|
|
29
29
|
--config PATH complete connection profile for client-only setup
|
|
30
30
|
--sources PATH explicit full development source policy override
|
|
31
31
|
--port N daemon port (default 3050 on a fresh installation)
|
|
32
|
+
--server-url URL external gateway URL (Docker install; configure authenticated entry separately)
|
|
32
33
|
--cowork-port N cowork port (default 3052)
|
|
33
34
|
--messenger-port N messenger port (default 8420)
|
|
34
35
|
--compatible required for server updates and legacy state migration
|
|
@@ -46,6 +47,7 @@ Fleet is configured but left stopped for operator review.
|
|
|
46
47
|
|
|
47
48
|
Scoped maintenance:
|
|
48
49
|
ours-install server status|start|stop|restart|rebuild --state-dir PATH
|
|
50
|
+
ours-install server gateway-enable --state-dir PATH [--server-url URL]
|
|
49
51
|
ours-install server access-issue --state-dir PATH --output PATH
|
|
50
52
|
ours-install server access-replace --state-dir PATH --confirm
|
|
51
53
|
ours-install server backup|restore server|daemon|telegram|cowork|messenger LABEL --state-dir PATH
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "1.2.1-nightly.
|
|
3
|
+
"version": "1.2.1-nightly.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
|
|
6
6
|
"type": "module",
|