@stravica-ai/digital-operator-pa 0.1.27 → 0.1.28
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/bin/library +12 -0
- package/bin/library-tunnel +12 -0
- package/dist/engineering-pa-library-cli/README.md +64 -0
- package/dist/engineering-pa-library-cli/bin/library +12 -0
- package/dist/engineering-pa-library-cli/package.json +21 -0
- package/dist/engineering-pa-library-cli/src/ack.mjs +64 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +142 -0
- package/dist/engineering-pa-library-cli/src/config.mjs +239 -0
- package/dist/engineering-pa-library-cli/src/envelope.mjs +142 -0
- package/dist/engineering-pa-library-cli/src/http.mjs +156 -0
- package/dist/engineering-pa-library-cli/src/pull-clones.mjs +74 -0
- package/dist/engineering-pa-library-cli/src/verbs/flush.mjs +136 -0
- package/dist/engineering-pa-library-cli/src/verbs/get.mjs +45 -0
- package/dist/engineering-pa-library-cli/src/verbs/put.mjs +69 -0
- package/dist/engineering-pa-library-cli/src/verbs/search.mjs +98 -0
- package/dist/engineering-pa-library-cli/src/verbs/status.mjs +124 -0
- package/dist/engineering-pa-library-tunnel/README.md +84 -0
- package/dist/engineering-pa-library-tunnel/bin/library-tunnel +12 -0
- package/dist/engineering-pa-library-tunnel/package.json +18 -0
- package/dist/engineering-pa-library-tunnel/src/check-verb.mjs +50 -0
- package/dist/engineering-pa-library-tunnel/src/check.mjs +102 -0
- package/dist/engineering-pa-library-tunnel/src/cli.mjs +155 -0
- package/dist/engineering-pa-library-tunnel/src/config.mjs +117 -0
- package/dist/engineering-pa-library-tunnel/src/install.mjs +106 -0
- package/dist/engineering-pa-library-tunnel/src/plist.mjs +119 -0
- package/dist/engineering-pa-library-tunnel/src/status.mjs +93 -0
- package/dist/engineering-pa-library-tunnel/src/uninstall.mjs +48 -0
- package/index.mjs +4 -0
- package/package.json +5 -2
package/bin/library
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// library - REMOTE-mode client CLI for the remote-library service.
|
|
3
|
+
// Bundled entry: delegates to the copied @stravica-ai/engineering-pa-library-cli sources.
|
|
4
|
+
import('../dist/engineering-pa-library-cli/src/cli.mjs').then((m) => {
|
|
5
|
+
return m.main(process.argv.slice(2));
|
|
6
|
+
}).then(
|
|
7
|
+
(code) => process.exit(code ?? 0),
|
|
8
|
+
(err) => {
|
|
9
|
+
process.stderr.write(`[library] ${err?.message ?? err}\n`);
|
|
10
|
+
process.exit(typeof err?.exitCode === 'number' ? err.exitCode : 2);
|
|
11
|
+
},
|
|
12
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// library-tunnel - durable SSH tunnel manager for the remote-library ingest.
|
|
3
|
+
// Bundled entry: delegates to the copied @stravica-ai/engineering-pa-library-tunnel sources.
|
|
4
|
+
import('../dist/engineering-pa-library-tunnel/src/cli.mjs').then((m) => {
|
|
5
|
+
return m.main(process.argv.slice(2));
|
|
6
|
+
}).then(
|
|
7
|
+
(code) => process.exit(code ?? 0),
|
|
8
|
+
(err) => {
|
|
9
|
+
process.stderr.write(`[library-tunnel] ${err?.message ?? err}\n`);
|
|
10
|
+
process.exit(typeof err?.exitCode === 'number' ? err.exitCode : 2);
|
|
11
|
+
},
|
|
12
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# library-cli
|
|
2
|
+
|
|
3
|
+
REMOTE-mode client for the `@stravica-ai/remote-library` service (spec §3.3).
|
|
4
|
+
Ships the `library` bin with five verbs: `put`, `flush`, `get`, `search`, `status`.
|
|
5
|
+
|
|
6
|
+
Only active when `operator/config/preferences.json` has `library.mode: "remote"`
|
|
7
|
+
plus `library.remoteEndpoint` and `library.remoteKey`. SIMPLE-mode installs (or
|
|
8
|
+
unset `library.mode`) refuse every verb with a clean exit-1 message; the CLI is
|
|
9
|
+
deliberately not a shim for the pre-remote library sweep.
|
|
10
|
+
|
|
11
|
+
## Verbs
|
|
12
|
+
|
|
13
|
+
- `library put <path> [--kind <kind>] [--tier auto|<tier>]`
|
|
14
|
+
Streams a single event to `POST /events`. `--tier auto` sends the spec §2.3
|
|
15
|
+
sentinel; the server's routing engine resolves the actual tier.
|
|
16
|
+
- `library flush`
|
|
17
|
+
Sweeps `<repoRoot>/agents/<paName>/staging/*` and streams one event per file.
|
|
18
|
+
Archives to `staging/_archived/` on `accepted` or `deduped` (both count as
|
|
19
|
+
ack per spec §4.2). Failures leave the file in place; next flush retries
|
|
20
|
+
(idempotent via `clientEventId`).
|
|
21
|
+
- `library get <path>`
|
|
22
|
+
Reads from the local pulled clone. `<path>` is resolved under
|
|
23
|
+
`LIBRARY_PULL_CLONES_ROOT` (env; else `library.pullClonesRoot` in
|
|
24
|
+
preferences; else `~/stravica-library-clones/`).
|
|
25
|
+
- `library search <query> [--files-only]`
|
|
26
|
+
Filesystem-native substring search over the pull-clones root.
|
|
27
|
+
- `library status [--json]`
|
|
28
|
+
GETs `/status` from the service, plus `git rev-list HEAD..origin/main --count`
|
|
29
|
+
per tier clone.
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
- `operator/config/preferences.json` (base) and `preferences.local.json`
|
|
34
|
+
(device-local overlay; shallow-merged on top):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"library": {
|
|
39
|
+
"mode": "remote",
|
|
40
|
+
"remoteEndpoint": "https://ops-01.internal",
|
|
41
|
+
"remoteKey": "sk_barry_dave_deadbeef:secret"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- `LIBRARY_PULL_CLONES_ROOT` env — overrides the pull-clones root at runtime.
|
|
47
|
+
|
|
48
|
+
Ingest authenticates by subkey alone (spec §3.4, main ruling 2026-08-20 option b);
|
|
49
|
+
no install-key header is sent. The former `REMOTE_LIBRARY_INSTALL_KEY` env
|
|
50
|
+
interim was removed in the same pass.
|
|
51
|
+
|
|
52
|
+
## Exit codes
|
|
53
|
+
|
|
54
|
+
`0` ok · `1` usage / configuration error · `2` operational failure · `3` transport/IO failure.
|
|
55
|
+
|
|
56
|
+
## Testing
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pnpm --dir operator/extensions/tools/library-cli test
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
All tests are `node:test` and hit a locally-bound mock ingest server (no
|
|
63
|
+
network, no Mongo). Integration against the real docker-compose stack lands
|
|
64
|
+
with item 16.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// library — REMOTE-mode client CLI for the remote-library service.
|
|
3
|
+
// See src/cli.mjs for the routing table and exit-code contract.
|
|
4
|
+
|
|
5
|
+
import { main } from '../src/cli.mjs';
|
|
6
|
+
|
|
7
|
+
main(process.argv.slice(2))
|
|
8
|
+
.then((code) => process.exit(code ?? 0))
|
|
9
|
+
.catch((err) => {
|
|
10
|
+
process.stderr.write(`[library] ${err?.message ?? err}\n`);
|
|
11
|
+
process.exit(typeof err?.exitCode === 'number' ? err.exitCode : 2);
|
|
12
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravica-ai/engineering-pa-library-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local `library` CLI for the remote-library REMOTE-mode write path (spec §3.3): put | flush | get | search | status. SIMPLE mode installs refuse every verb with a clean not-applicable message.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/cli.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"private": true,
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.mjs"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@stravica-ai/remote-library": "^0.1.1"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ack.mjs — parse the remote-library ingest `POST /events` response
|
|
2
|
+
// (single-event path) into a discriminated ack.
|
|
3
|
+
//
|
|
4
|
+
// Wire contract (SOURCE OF TRUTH: remote-library/src/ingest/server.ts
|
|
5
|
+
// as of 2026-08-20 rehearsal fix, RB-5). Locked on BOTH sides by
|
|
6
|
+
// library-cli/test/ingest-contract.test.mjs (this side) and the
|
|
7
|
+
// wire-shape assertions in remote-library/src/test/routing.test.ts
|
|
8
|
+
// (server side).
|
|
9
|
+
//
|
|
10
|
+
// Status 202 body {eventId: <ulid>, receivedAt: <iso8601>}
|
|
11
|
+
// -> newly-accepted event (worker will pick up on next tick).
|
|
12
|
+
// Status 200 body {eventId: <ulid>, deduped: true}
|
|
13
|
+
// -> clientEventId was already on record; return the eventId of
|
|
14
|
+
// the ORIGINAL insert so the producer sees the same anchor
|
|
15
|
+
// (spec §4.2). A retrying producer MUST see success and
|
|
16
|
+
// archive the source file, or the staging directory grows
|
|
17
|
+
// unboundedly across retries.
|
|
18
|
+
// Status 4xx / 5xx with {error: <code>, detail?: <string>, errors?}
|
|
19
|
+
// -> rejected. The `error` code is the taxonomy from
|
|
20
|
+
// ingest/server.ts::statusCodeForRejection (e.g.
|
|
21
|
+
// `no-matching-rule`, `subkey-not-permitted`, `schema-invalid`).
|
|
22
|
+
// Callers surface it verbatim.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{kind: 'accepted', eventId: string}
|
|
26
|
+
* | {kind: 'deduped', eventId: string}
|
|
27
|
+
* | {kind: 'rejected', reason: string}} IngestAck
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{httpStatus: number, body: unknown}} res
|
|
32
|
+
* @returns {IngestAck}
|
|
33
|
+
*/
|
|
34
|
+
export function parseIngestAck(res) {
|
|
35
|
+
const { httpStatus, body } = res;
|
|
36
|
+
const isObj = body !== null && typeof body === 'object';
|
|
37
|
+
|
|
38
|
+
if (httpStatus === 202 && isObj) {
|
|
39
|
+
const eventId = /** @type {any} */ (body).eventId;
|
|
40
|
+
if (typeof eventId === 'string' && eventId.length > 0) {
|
|
41
|
+
return { kind: 'accepted', eventId };
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'rejected', reason: `http 202 without eventId: ${JSON.stringify(body)}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (httpStatus === 200 && isObj) {
|
|
47
|
+
const b = /** @type {any} */ (body);
|
|
48
|
+
if (b.deduped === true && typeof b.eventId === 'string' && b.eventId.length > 0) {
|
|
49
|
+
return { kind: 'deduped', eventId: b.eventId };
|
|
50
|
+
}
|
|
51
|
+
return { kind: 'rejected', reason: `http 200 without deduped ack shape: ${JSON.stringify(body)}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Any other status is a rejection. Prefer the server's error code +
|
|
55
|
+
// detail; fall back to a JSON-encoded body so no signal is lost.
|
|
56
|
+
if (isObj) {
|
|
57
|
+
const b = /** @type {any} */ (body);
|
|
58
|
+
if (typeof b.error === 'string') {
|
|
59
|
+
const detail = typeof b.detail === 'string' ? `: ${b.detail}` : '';
|
|
60
|
+
return { kind: 'rejected', reason: `http ${httpStatus} ${b.error}${detail}` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { kind: 'rejected', reason: `http ${httpStatus}: ${JSON.stringify(body)}` };
|
|
64
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// cli.mjs — argv dispatch for `library`.
|
|
2
|
+
//
|
|
3
|
+
// Exit code contract (tooling standard §6):
|
|
4
|
+
// 0 ok | 1 usage error | 2 operational failure | 3 transport/IO failure
|
|
5
|
+
//
|
|
6
|
+
// Modes:
|
|
7
|
+
// - SIMPLE mode installs (or unset library.mode) print a clean
|
|
8
|
+
// "not applicable in simple mode" message and exit 1. Spec §3.3
|
|
9
|
+
// is explicit that the verbs are REMOTE-mode only.
|
|
10
|
+
// - REMOTE mode with misconfig refuses to boot (spec §5.1); the
|
|
11
|
+
// error surface is the resolveLibraryConfig throw path.
|
|
12
|
+
|
|
13
|
+
import { parseArgs } from 'node:util';
|
|
14
|
+
|
|
15
|
+
import { loadLibraryConfig, LibraryConfigError } from './config.mjs';
|
|
16
|
+
import { runPut } from './verbs/put.mjs';
|
|
17
|
+
import { runFlush } from './verbs/flush.mjs';
|
|
18
|
+
import { runGet } from './verbs/get.mjs';
|
|
19
|
+
import { runSearch } from './verbs/search.mjs';
|
|
20
|
+
import { runStatus } from './verbs/status.mjs';
|
|
21
|
+
|
|
22
|
+
const USAGE = `library — REMOTE-mode client for the remote-library service.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
library put <path> [--kind <kind>] [--tier auto|<tier>]
|
|
26
|
+
library flush
|
|
27
|
+
library get <path>
|
|
28
|
+
library search <query> [--files-only]
|
|
29
|
+
library status [--json]
|
|
30
|
+
library -h | --help
|
|
31
|
+
|
|
32
|
+
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
33
|
+
Verbs are only active in REMOTE mode. SIMPLE mode exits 1 with a clean message.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
38
|
+
* @param {object} [seams] test seams (fetchImpl, git, log, err, repoRoot)
|
|
39
|
+
* @returns {Promise<number>} exit code
|
|
40
|
+
*/
|
|
41
|
+
export async function main(argv, seams = {}) {
|
|
42
|
+
const log = seams.log ?? ((...m) => process.stdout.write(m.join(' ') + '\n'));
|
|
43
|
+
const err = seams.err ?? ((...m) => process.stderr.write(m.join(' ') + '\n'));
|
|
44
|
+
|
|
45
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
46
|
+
log(USAGE);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const verb = argv[0];
|
|
51
|
+
const rest = argv.slice(1);
|
|
52
|
+
|
|
53
|
+
let config;
|
|
54
|
+
try {
|
|
55
|
+
config = loadLibraryConfig({ repoRoot: seams.repoRoot });
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e instanceof LibraryConfigError) {
|
|
58
|
+
err(`[library] ${e.message}`);
|
|
59
|
+
return e.exitCode ?? 1;
|
|
60
|
+
}
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (config.mode === 'simple') {
|
|
65
|
+
err(
|
|
66
|
+
`[library] not applicable in simple mode (library.mode="simple"). ` +
|
|
67
|
+
`The library CLI is a REMOTE-mode client only (spec §3.3). ` +
|
|
68
|
+
`Set library.mode="remote" in operator/config/preferences.json (with ` +
|
|
69
|
+
`library.remoteEndpoint + library.remoteKey) to enable.`,
|
|
70
|
+
);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ctx = {
|
|
75
|
+
config,
|
|
76
|
+
fetchImpl: seams.fetchImpl,
|
|
77
|
+
log,
|
|
78
|
+
err,
|
|
79
|
+
git: seams.git,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
switch (verb) {
|
|
84
|
+
case 'put': return await runPut({ ...ctx, args: parsePut(rest) });
|
|
85
|
+
case 'flush': return await runFlush({ ...ctx, args: parseFlush(rest) });
|
|
86
|
+
case 'get': return await runGet({ ...ctx, args: parseGet(rest) });
|
|
87
|
+
case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
|
|
88
|
+
case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
|
|
89
|
+
default:
|
|
90
|
+
err(`[library] unknown verb: ${verb}`);
|
|
91
|
+
log(USAGE);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
err(`[library ${verb}] ${e.message ?? e}`);
|
|
96
|
+
return typeof e.exitCode === 'number' ? e.exitCode : 2;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parsePut(argv) {
|
|
101
|
+
const { values, positionals } = parseArgs({
|
|
102
|
+
args: argv,
|
|
103
|
+
strict: true,
|
|
104
|
+
allowPositionals: true,
|
|
105
|
+
options: {
|
|
106
|
+
kind: { type: 'string' },
|
|
107
|
+
tier: { type: 'string', default: 'auto' },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return { path: positionals[0], kind: values.kind, tier: values.tier };
|
|
111
|
+
}
|
|
112
|
+
function parseFlush(argv) {
|
|
113
|
+
parseArgs({ args: argv, strict: true, allowPositionals: true, options: {} });
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
function parseGet(argv) {
|
|
117
|
+
const { positionals } = parseArgs({
|
|
118
|
+
args: argv,
|
|
119
|
+
strict: true,
|
|
120
|
+
allowPositionals: true,
|
|
121
|
+
options: {},
|
|
122
|
+
});
|
|
123
|
+
return { path: positionals[0] };
|
|
124
|
+
}
|
|
125
|
+
function parseSearch(argv) {
|
|
126
|
+
const { values, positionals } = parseArgs({
|
|
127
|
+
args: argv,
|
|
128
|
+
strict: true,
|
|
129
|
+
allowPositionals: true,
|
|
130
|
+
options: { 'files-only': { type: 'boolean', default: false } },
|
|
131
|
+
});
|
|
132
|
+
return { query: positionals[0], filesOnly: values['files-only'] };
|
|
133
|
+
}
|
|
134
|
+
function parseStatus(argv) {
|
|
135
|
+
const { values } = parseArgs({
|
|
136
|
+
args: argv,
|
|
137
|
+
strict: true,
|
|
138
|
+
allowPositionals: false,
|
|
139
|
+
options: { json: { type: 'boolean', default: false } },
|
|
140
|
+
});
|
|
141
|
+
return { json: values.json };
|
|
142
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// config.mjs — read + validate the `library.*` block in
|
|
2
|
+
// operator/config/preferences.json (with the standard preferences.local.json
|
|
3
|
+
// overlay applied on top).
|
|
4
|
+
//
|
|
5
|
+
// Spec §5.1 (remote-library-spec.md):
|
|
6
|
+
// - mode is EXPLICIT (`library.mode`), never auto-detected
|
|
7
|
+
// - mode=remote with missing endpoint or key REFUSES to boot; there is
|
|
8
|
+
// NO silent fallback to SIMPLE (falling back is a data-loss path)
|
|
9
|
+
//
|
|
10
|
+
// Implementation notes:
|
|
11
|
+
// - The reader mirrors the platform's overlay-aware `readPreferences`
|
|
12
|
+
// shape (shallow merge; overlay wins; missing/malformed = null)
|
|
13
|
+
// rather than importing it, so the tool has no runtime coupling to
|
|
14
|
+
// the installed `@stravica-ai/digital-operator-pa` package layout.
|
|
15
|
+
// - Consumers pass `{ repoRoot }` or the resolved paths; both surfaces
|
|
16
|
+
// are seams for tests.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { resolve, join, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
// operator/extensions/tools/library-cli/src/ → up five levels to the repo root.
|
|
26
|
+
export const DEFAULT_REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..');
|
|
27
|
+
|
|
28
|
+
export class LibraryConfigError extends Error {
|
|
29
|
+
constructor(message, code) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'LibraryConfigError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.exitCode = 1; // usage / configuration error (tooling exit-code contract)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VALID_MODES = new Set(['simple', 'remote']);
|
|
38
|
+
const DEFAULT_MODE = 'simple';
|
|
39
|
+
|
|
40
|
+
function readJsonObject(path) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
43
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merge base + optional device-local overlay preferences, shallow.
|
|
51
|
+
* Fail-open: missing/malformed either file contributes nothing.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} paths
|
|
54
|
+
* @param {string} paths.base preferences.json path
|
|
55
|
+
* @param {string} paths.local preferences.local.json path
|
|
56
|
+
* @returns {object} merged preferences (empty object if both absent)
|
|
57
|
+
*/
|
|
58
|
+
export function readMergedPreferences({ base, local }) {
|
|
59
|
+
const b = readJsonObject(base) || {};
|
|
60
|
+
const o = readJsonObject(local) || {};
|
|
61
|
+
return { ...b, ...o };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the library subsystem config from a merged preferences object.
|
|
66
|
+
* Returns `{ mode, remoteEndpoint?, remoteKey? }` on success.
|
|
67
|
+
* Throws {@link LibraryConfigError} on invalid shape or on
|
|
68
|
+
* `mode=remote` with missing required fields (spec §5.1 refuse-boot).
|
|
69
|
+
*
|
|
70
|
+
* @param {object} preferences merged preferences (base + overlay)
|
|
71
|
+
* @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string}}
|
|
72
|
+
*/
|
|
73
|
+
export function resolveLibraryConfig(preferences) {
|
|
74
|
+
const raw = preferences && typeof preferences === 'object' ? preferences.library : null;
|
|
75
|
+
const block = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
76
|
+
|
|
77
|
+
const mode = block.mode ?? DEFAULT_MODE;
|
|
78
|
+
|
|
79
|
+
if (!VALID_MODES.has(mode)) {
|
|
80
|
+
throw new LibraryConfigError(
|
|
81
|
+
`library.mode must be "simple" or "remote", got ${JSON.stringify(mode)}`,
|
|
82
|
+
'invalid-mode',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (mode === 'simple') {
|
|
87
|
+
return { mode };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// mode === 'remote'; validate required fields per spec §5.1.
|
|
91
|
+
const missing = [];
|
|
92
|
+
const endpoint = block.remoteEndpoint;
|
|
93
|
+
const key = block.remoteKey;
|
|
94
|
+
if (typeof endpoint !== 'string' || endpoint.trim() === '') missing.push('library.remoteEndpoint');
|
|
95
|
+
if (typeof key !== 'string' || key.trim() === '') missing.push('library.remoteKey');
|
|
96
|
+
if (missing.length > 0) {
|
|
97
|
+
throw new LibraryConfigError(
|
|
98
|
+
`library.mode="remote" but required field(s) missing/empty: ${missing.join(', ')}. ` +
|
|
99
|
+
`Refusing to boot the library subsystem (spec §5.1: no silent fallback to SIMPLE).`,
|
|
100
|
+
'remote-config-missing',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Shape check on remoteKey (`<subkeyId>:<secret>`) is cheap and catches
|
|
105
|
+
// a common paste mistake without over-validating (the server does the
|
|
106
|
+
// authoritative check).
|
|
107
|
+
if (!/^[^:\s]+:[^\s]+$/.test(key)) {
|
|
108
|
+
throw new LibraryConfigError(
|
|
109
|
+
`library.remoteKey must be of shape "<subkeyId>:<secret>" (no whitespace, colon separator).`,
|
|
110
|
+
'remote-key-shape',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { mode, remoteEndpoint: endpoint, remoteKey: key };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Full-scope vault reference form for `library.remoteKey`, per Barry's
|
|
119
|
+
* ruling on w-2026-07-15-023: `<scope>/<KEY_NAME>` (e.g.
|
|
120
|
+
* `hq-estate/REMOTE_LIBRARY_SUBKEY_DAVE`). Kept separate from the
|
|
121
|
+
* literal-subkey shape (`<subkeyId>:<secret>`) so the shape validator
|
|
122
|
+
* downstream still refuses garbage. Any value that neither matches this
|
|
123
|
+
* ref shape nor the literal shape is refused by resolveLibraryConfig.
|
|
124
|
+
*/
|
|
125
|
+
const REMOTE_KEY_REF_RE = /^([a-z0-9-]+)\/([A-Z][A-Z0-9_]*)$/;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a `library.remoteKey` value that is a full-scope vault
|
|
129
|
+
* reference into the underlying literal `<subkeyId>:<secret>` by calling
|
|
130
|
+
* the estate `secrets` CLI. Literal values (containing a colon) and
|
|
131
|
+
* anything else pass through untouched; the caller-side shape validator
|
|
132
|
+
* is authoritative on shape.
|
|
133
|
+
*
|
|
134
|
+
* Canonical stravica-operator-barry cannot carry the literal subkey
|
|
135
|
+
* (sync-cycle would round-trip a plaintext secret through a git-tracked
|
|
136
|
+
* file). The reference lives in canonical; the vault holds the value;
|
|
137
|
+
* this function is the bridge. See w-2026-08-21-005 for the incident
|
|
138
|
+
* that made this the durable pattern.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} refOrLiteral the raw remoteKey value from preferences
|
|
141
|
+
* @param {object} [opts]
|
|
142
|
+
* @param {string} [opts.repoRoot] defaults to DEFAULT_REPO_ROOT
|
|
143
|
+
* @param {(args:{scope:string,key:string,repoRoot:string})=>string} [opts.secretsGet]
|
|
144
|
+
* injectable resolver (tests)
|
|
145
|
+
* @returns {string} the resolved literal (never printed)
|
|
146
|
+
*/
|
|
147
|
+
export function resolveRemoteKeyRef(
|
|
148
|
+
refOrLiteral,
|
|
149
|
+
{ repoRoot = DEFAULT_REPO_ROOT, secretsGet = defaultSecretsGet } = {},
|
|
150
|
+
) {
|
|
151
|
+
if (typeof refOrLiteral !== 'string') return refOrLiteral;
|
|
152
|
+
const m = REMOTE_KEY_REF_RE.exec(refOrLiteral);
|
|
153
|
+
if (!m) return refOrLiteral;
|
|
154
|
+
const [, scope, key] = m;
|
|
155
|
+
let resolved;
|
|
156
|
+
try {
|
|
157
|
+
resolved = secretsGet({ scope, key, repoRoot });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new LibraryConfigError(
|
|
160
|
+
`library.remoteKey vault reference "${refOrLiteral}" failed to resolve ` +
|
|
161
|
+
`(scope=${scope} key=${key}): ${err && err.message ? err.message : err}. ` +
|
|
162
|
+
`Fix: pnpm exec secrets set ${key} --scope ${scope} <value>`,
|
|
163
|
+
'remote-key-ref-unresolved',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (typeof resolved !== 'string' || resolved.length === 0) {
|
|
167
|
+
throw new LibraryConfigError(
|
|
168
|
+
`library.remoteKey vault reference "${refOrLiteral}" resolved to empty value ` +
|
|
169
|
+
`(scope=${scope} key=${key}). ` +
|
|
170
|
+
`Fix: pnpm exec secrets set ${key} --scope ${scope} <value>`,
|
|
171
|
+
'remote-key-ref-unresolved',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
// secrets get can leave a trailing newline depending on subshell; strip
|
|
175
|
+
// whitespace once at the boundary so the shape validator sees the
|
|
176
|
+
// clean payload.
|
|
177
|
+
return resolved.replace(/\s+$/, '');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Default vault fetcher: invokes the `secrets` bin from the installed
|
|
182
|
+
* `@stravica-ai/digital-operator-pa` package under `repoRoot`. Uses
|
|
183
|
+
* direct `node <bin>` invocation (not `pnpm exec`) to sidestep the
|
|
184
|
+
* pnpm-11 stdout footgun that has bitten sam-tasks and others.
|
|
185
|
+
* Value is never logged.
|
|
186
|
+
*/
|
|
187
|
+
function defaultSecretsGet({ scope, key, repoRoot }) {
|
|
188
|
+
const secretsBin = join(
|
|
189
|
+
repoRoot,
|
|
190
|
+
'node_modules',
|
|
191
|
+
'@stravica-ai',
|
|
192
|
+
'digital-operator-pa',
|
|
193
|
+
'bin',
|
|
194
|
+
'secrets',
|
|
195
|
+
);
|
|
196
|
+
const out = execFileSync('node', [secretsBin, 'get', key, '--scope', scope], {
|
|
197
|
+
cwd: repoRoot,
|
|
198
|
+
encoding: 'utf8',
|
|
199
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
+
});
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read + resolve in one call. Returns `{ mode, ...remoteFields, repoRoot,
|
|
206
|
+
* preferences }` on success or throws {@link LibraryConfigError}. The
|
|
207
|
+
* `preferences` field is the full merged object so callers can pluck
|
|
208
|
+
* neighbours (paName, pullClonesRoot, workspacePort) without a second read.
|
|
209
|
+
*
|
|
210
|
+
* Also resolves a `library.remoteKey` that carries the full-scope vault
|
|
211
|
+
* reference form (`<scope>/<KEY_NAME>`) into its underlying literal via
|
|
212
|
+
* `resolveRemoteKeyRef`. Literal values pass through unchanged.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} [opts]
|
|
215
|
+
* @param {string} [opts.repoRoot] defaults to DEFAULT_REPO_ROOT
|
|
216
|
+
* @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string, repoRoot: string, preferences: object}}
|
|
217
|
+
*/
|
|
218
|
+
export function loadLibraryConfig({ repoRoot = DEFAULT_REPO_ROOT } = {}) {
|
|
219
|
+
const base = join(repoRoot, 'operator', 'config', 'preferences.json');
|
|
220
|
+
const local = join(repoRoot, 'operator', 'config', 'preferences.local.json');
|
|
221
|
+
const preferences = readMergedPreferences({ base, local });
|
|
222
|
+
// Vault-ref resolution runs BEFORE resolveLibraryConfig's shape check
|
|
223
|
+
// so a ref value never reaches the `<subkeyId>:<secret>` validator.
|
|
224
|
+
// The check on the returned literal still fires and refuses garbage.
|
|
225
|
+
if (
|
|
226
|
+
preferences &&
|
|
227
|
+
preferences.library &&
|
|
228
|
+
preferences.library.mode === 'remote' &&
|
|
229
|
+
typeof preferences.library.remoteKey === 'string'
|
|
230
|
+
) {
|
|
231
|
+
const original = preferences.library.remoteKey;
|
|
232
|
+
const resolved = resolveRemoteKeyRef(original, { repoRoot });
|
|
233
|
+
if (resolved !== original) {
|
|
234
|
+
preferences.library = { ...preferences.library, remoteKey: resolved };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const resolved = resolveLibraryConfig(preferences);
|
|
238
|
+
return { ...resolved, repoRoot, preferences };
|
|
239
|
+
}
|