@stravica-ai/digital-operator-pa 0.1.34 → 0.1.35
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/dist/engineering-pa-cli/src/secrets/commands/get.mjs +2 -2
- package/dist/engineering-pa-cli/src/secrets/commands/list.mjs +2 -2
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +77 -0
- package/dist/engineering-pa-library-cli/package.json +1 -1
- package/dist/engineering-pa-library-cli/src/cli.mjs +53 -1
- package/dist/engineering-pa-library-cli/src/envelope.mjs +95 -24
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
depInstallMessage,
|
|
11
11
|
checkVault,
|
|
12
12
|
secretsFileExists,
|
|
13
|
-
|
|
13
|
+
sopsDecryptOrReport,
|
|
14
14
|
readVaultKeyNames,
|
|
15
15
|
VaultError,
|
|
16
16
|
} from '../vault.mjs';
|
|
@@ -70,7 +70,7 @@ export async function runGet(args, overrides = {}) {
|
|
|
70
70
|
throw new VaultError('missing dependencies', 1);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const data = await
|
|
73
|
+
const data = await sopsDecryptOrReport(paths, paths.secretsFile);
|
|
74
74
|
if (!(key in data)) {
|
|
75
75
|
console.error(`Error: Key "${key}" not found.`);
|
|
76
76
|
throw new VaultError('key not found', 1);
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
depInstallMessage,
|
|
7
7
|
checkVault,
|
|
8
8
|
secretsFileExists,
|
|
9
|
-
|
|
9
|
+
sopsDecryptOrReport,
|
|
10
10
|
VaultError,
|
|
11
11
|
} from '../vault.mjs';
|
|
12
12
|
|
|
@@ -30,7 +30,7 @@ export async function runList(overrides = {}) {
|
|
|
30
30
|
return 0;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const data = await
|
|
33
|
+
const data = await sopsDecryptOrReport(paths, paths.secretsFile);
|
|
34
34
|
const keys = Object.keys(data).sort();
|
|
35
35
|
for (const key of keys) console.log(key);
|
|
36
36
|
return 0;
|
|
@@ -782,6 +782,83 @@ export async function sopsDecrypt(paths, filePath) {
|
|
|
782
782
|
return JSON.parse(stdout);
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
+
/**
|
|
786
|
+
* Parse the age recipient public keys out of a sops config file. Kept
|
|
787
|
+
* deliberately narrow: reads every line of the YAML at `sopsConfigPath`
|
|
788
|
+
* and extracts anything shaped like `age1[0-9a-z]+` (matches both inline
|
|
789
|
+
* `age: age1abc...` and the multi-line `age: >-\n age1abc...` shapes the
|
|
790
|
+
* scoped-vault fixtures use). Order is stable, duplicates deduplicated.
|
|
791
|
+
* Returns an empty array on any read failure. This helper only feeds a
|
|
792
|
+
* diagnostic message and must never throw over its own IO.
|
|
793
|
+
*/
|
|
794
|
+
export function readRecipientsFromSopsConfig(sopsConfigPath) {
|
|
795
|
+
let text;
|
|
796
|
+
try {
|
|
797
|
+
text = readFileSync(sopsConfigPath, 'utf-8');
|
|
798
|
+
} catch {
|
|
799
|
+
return [];
|
|
800
|
+
}
|
|
801
|
+
const out = [];
|
|
802
|
+
const seen = new Set();
|
|
803
|
+
const re = /age1[0-9a-z]+/g;
|
|
804
|
+
let m;
|
|
805
|
+
while ((m = re.exec(text)) !== null) {
|
|
806
|
+
const value = m[0];
|
|
807
|
+
if (!seen.has(value)) {
|
|
808
|
+
seen.add(value);
|
|
809
|
+
out.push(value);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return out;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Build the single stderr diagnostic line that names, without ever
|
|
817
|
+
* decrypting anything, WHY a decrypt failed: the scope (or vault path),
|
|
818
|
+
* the caller's age public key (never the private key, never the value),
|
|
819
|
+
* and the scope's recipient list read from the sops config. Falls back
|
|
820
|
+
* to a shorter form when one of those pieces cannot be recovered. The
|
|
821
|
+
* caller still needs a message, and silence on this seam is the defect
|
|
822
|
+
* being fixed (w-2026-09-02-dave-015 part 1).
|
|
823
|
+
*/
|
|
824
|
+
export function formatDecryptFailure(paths) {
|
|
825
|
+
const target = paths.scope
|
|
826
|
+
? `scope "${paths.scope}"`
|
|
827
|
+
: (paths.secretsFile ? `vault ${paths.secretsFile}` : 'vault');
|
|
828
|
+
let callerPub = null;
|
|
829
|
+
try {
|
|
830
|
+
callerPub = publicKeyFromKeyFile(paths.keyFile);
|
|
831
|
+
} catch { /* no key file, or unreadable: leave callerPub null */ }
|
|
832
|
+
const callerDesc = callerPub
|
|
833
|
+
? `age key ${callerPub}`
|
|
834
|
+
: `age key at ${paths.keyFile}`;
|
|
835
|
+
const recipients = paths.sopsConfig ? readRecipientsFromSopsConfig(paths.sopsConfig) : [];
|
|
836
|
+
const recipientsDesc = recipients.length > 0
|
|
837
|
+
? `recipients (from ${paths.sopsConfig}): ${recipients.join(', ')}`
|
|
838
|
+
: `recipients could not be read from ${paths.sopsConfig ?? '<no sops config>'}`;
|
|
839
|
+
return `Error: cannot decrypt ${target}: this host's ${callerDesc} is not in the recipient list; ${recipientsDesc}. Fix: add this host's public key to the scope's recipients, or run this verb from a host that already has decrypt access.`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Decrypt wrapper for the interactive verbs (get, list). On failure it
|
|
844
|
+
* prints the {@link formatDecryptFailure} one-liner to stderr and
|
|
845
|
+
* re-throws with exitCode 2 preserved, so main() exits with the correct
|
|
846
|
+
* code AND the caller sees why, closing the silent-exit-2 seam
|
|
847
|
+
* (w-2026-09-02-dave-015 part 1). Never handles the decrypted value on
|
|
848
|
+
* the failure path; on success it returns exactly what sopsDecrypt did.
|
|
849
|
+
*/
|
|
850
|
+
export async function sopsDecryptOrReport(paths, filePath) {
|
|
851
|
+
try {
|
|
852
|
+
return await sopsDecrypt(paths, filePath);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
console.error(formatDecryptFailure(paths));
|
|
855
|
+
if (err && typeof err === 'object' && err.exitCode == null) {
|
|
856
|
+
err.exitCode = 2;
|
|
857
|
+
}
|
|
858
|
+
throw err;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
785
862
|
/** Encrypt a JSON object to the secrets file with the given public key recipient. */
|
|
786
863
|
export async function sopsEncrypt(paths, pubKey, obj) {
|
|
787
864
|
const tmp = mkdtempSync(join(tmpdir(), 'vault-'));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/engineering-pa-library-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./src/cli.mjs",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// error surface is the resolveLibraryConfig throw path.
|
|
12
12
|
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
14
17
|
|
|
15
18
|
import { loadLibraryConfig, LibraryConfigError } from './config.mjs';
|
|
16
19
|
import { runPut } from './verbs/put.mjs';
|
|
@@ -20,6 +23,26 @@ import { runSearch } from './verbs/search.mjs';
|
|
|
20
23
|
import { runStatus } from './verbs/status.mjs';
|
|
21
24
|
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
22
25
|
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Own-package version, read from the shipped package.json alongside this
|
|
30
|
+
* source (both in-tree and bundled under
|
|
31
|
+
* dist/engineering-pa-library-cli/). Read at invocation, not import: a
|
|
32
|
+
* missing/malformed package.json degrades to 'unknown' rather than
|
|
33
|
+
* crashing --version, which is the whole point of the vault-free help
|
|
34
|
+
* seam (w-2026-09-02-dave-015 part 2).
|
|
35
|
+
*/
|
|
36
|
+
function readOwnVersion() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(join(__dirname, '..', 'package.json'), 'utf-8');
|
|
39
|
+
const pkg = JSON.parse(raw);
|
|
40
|
+
return typeof pkg.version === 'string' ? pkg.version : 'unknown';
|
|
41
|
+
} catch {
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
const USAGE = `library — REMOTE-mode client for the remote-library service.
|
|
24
47
|
|
|
25
48
|
Usage:
|
|
@@ -30,9 +53,15 @@ Usage:
|
|
|
30
53
|
library status [--json]
|
|
31
54
|
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
32
55
|
library -h | --help
|
|
56
|
+
library -v | --version
|
|
33
57
|
|
|
34
58
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
35
|
-
Verbs are only active in REMOTE mode. SIMPLE mode
|
|
59
|
+
Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
|
|
60
|
+
one-line mode summary and exits 0; every other verb exits 1 in SIMPLE mode
|
|
61
|
+
with a clean not-applicable message.
|
|
62
|
+
|
|
63
|
+
--help and --version never touch config or the vault, so they always work
|
|
64
|
+
from a directory with no operator config and no vault installed.
|
|
36
65
|
`;
|
|
37
66
|
|
|
38
67
|
/**
|
|
@@ -44,10 +73,19 @@ export async function main(argv, seams = {}) {
|
|
|
44
73
|
const log = seams.log ?? ((...m) => process.stdout.write(m.join(' ') + '\n'));
|
|
45
74
|
const err = seams.err ?? ((...m) => process.stderr.write(m.join(' ') + '\n'));
|
|
46
75
|
|
|
76
|
+
// --help and --version are answered BEFORE any config or vault
|
|
77
|
+
// resolution runs. Both must work from a directory with no operator
|
|
78
|
+
// config and no vault installed at all: the vault-free help/version
|
|
79
|
+
// seam (w-2026-09-02-dave-015 part 2). Everything after this line is
|
|
80
|
+
// allowed to touch preferences.json; nothing before it is.
|
|
47
81
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
48
82
|
log(USAGE);
|
|
49
83
|
return 0;
|
|
50
84
|
}
|
|
85
|
+
if (argv[0] === '-v' || argv[0] === '--version') {
|
|
86
|
+
log(readOwnVersion());
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
51
89
|
|
|
52
90
|
const verb = argv[0];
|
|
53
91
|
const rest = argv.slice(1);
|
|
@@ -64,6 +102,20 @@ export async function main(argv, seams = {}) {
|
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
if (config.mode === 'simple') {
|
|
105
|
+
// `library status` is the one verb useful in SIMPLE mode too: a plain
|
|
106
|
+
// one-line mode summary that tells the operator the CLI is installed
|
|
107
|
+
// and the remote path is intentionally not wired. No vault touch,
|
|
108
|
+
// no network call, exit 0. The "must work with no vault at all"
|
|
109
|
+
// requirement from w-2026-09-02-dave-015 part 2.
|
|
110
|
+
if (verb === 'status') {
|
|
111
|
+
const wantsJson = rest.includes('--json');
|
|
112
|
+
if (wantsJson) {
|
|
113
|
+
log(JSON.stringify({ mode: 'simple' }, null, 2));
|
|
114
|
+
} else {
|
|
115
|
+
log('mode: simple (library CLI installed; no remote endpoint configured)');
|
|
116
|
+
}
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
67
119
|
err(
|
|
68
120
|
`[library] not applicable in simple mode (library.mode="simple"). ` +
|
|
69
121
|
`The library CLI is a REMOTE-mode client only (spec §3.3). ` +
|
|
@@ -13,12 +13,30 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
15
|
import { hostname } from 'node:os';
|
|
16
|
-
import { isAbsolute, normalize, relative, sep } from 'node:path';
|
|
16
|
+
import { basename, extname, isAbsolute, normalize, relative, sep } from 'node:path';
|
|
17
17
|
|
|
18
18
|
import { EVENT_KINDS, canonicalJson } from '@stravica-ai/remote-library';
|
|
19
19
|
|
|
20
20
|
export const KNOWN_KINDS = new Set(EVENT_KINDS);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Kinds whose ingest schema accepts an optional `payload.source` routing
|
|
24
|
+
* hint (spec §2.3 payloadSource dimension). When the operator asserts
|
|
25
|
+
* an explicit `--tier <repo>` on `library put`, the CLI stamps
|
|
26
|
+
* `payload.source = <repo>` on these four kinds so the routing engine's
|
|
27
|
+
* `payloadSource` match can direct the event to the correct tier repo.
|
|
28
|
+
*
|
|
29
|
+
* Kept in lockstep with the remote-library package's per-kind payload
|
|
30
|
+
* schemas: adding a source-accepting kind here without the matching
|
|
31
|
+
* schema field would 422 at ingest.
|
|
32
|
+
*/
|
|
33
|
+
export const KINDS_ACCEPTING_SOURCE = new Set([
|
|
34
|
+
'knowledge.doc.upsert',
|
|
35
|
+
'knowledge.doc.delete',
|
|
36
|
+
'profile.upsert',
|
|
37
|
+
'profile.slice.upsert',
|
|
38
|
+
]);
|
|
39
|
+
|
|
22
40
|
/**
|
|
23
41
|
* Compute the sha256 hash of the canonical JSON form of a payload.
|
|
24
42
|
* The `sha256:` prefix matches the server's contentSha convention.
|
|
@@ -118,9 +136,13 @@ export function guessKindFromPath(sourcePath) {
|
|
|
118
136
|
* @param {string} [args.tier] default "auto" sentinel (spec §2.3)
|
|
119
137
|
* @param {'staging'|'flush'|'role-output'|'manual'|'synthesis'} [args.sourceKind]
|
|
120
138
|
* default "manual"; `library flush` passes "flush"
|
|
121
|
-
* @param {string} [args.source] optional routing hint;
|
|
122
|
-
*
|
|
123
|
-
*
|
|
139
|
+
* @param {string} [args.source] optional routing hint; encoded into
|
|
140
|
+
* the payload for any kind whose ingest schema accepts `source`
|
|
141
|
+
* (KINDS_ACCEPTING_SOURCE: knowledge.doc.upsert, knowledge.doc.delete,
|
|
142
|
+
* profile.upsert, profile.slice.upsert; spec §3.1 shape). When
|
|
143
|
+
* `--tier` is asserted explicitly (not the `auto` sentinel), the tier
|
|
144
|
+
* value is used as the source stamp for these kinds automatically, so
|
|
145
|
+
* callers rarely pass this directly.
|
|
124
146
|
* @param {() => Date} [args.clock] test seam
|
|
125
147
|
* @returns {object} EventInput ready for POST /events
|
|
126
148
|
*/
|
|
@@ -170,26 +192,75 @@ export function buildEnvelope({
|
|
|
170
192
|
|
|
171
193
|
const now = clock();
|
|
172
194
|
// Per-kind payload construction (spec §3.1 + `remote-library` payload
|
|
173
|
-
// schema).
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// `
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
// schema).
|
|
196
|
+
//
|
|
197
|
+
// The delete branch omits `content` (ingest 422 otherwise; Fix Wave E
|
|
198
|
+
// w-2026-08-24-dave-029). profile.slice.upsert takes `{slice, content}`
|
|
199
|
+
// (slice is a single filename component, derived from the source
|
|
200
|
+
// path's basename minus its extension; the schema pattern refuses
|
|
201
|
+
// path separators). Every other content-bearing kind ships as
|
|
202
|
+
// `{path, content}` byte-identical to the pre-Wave-E encoding.
|
|
203
|
+
//
|
|
204
|
+
// Source stamping (w-2026-09-03-dave-010): when the caller asserts an
|
|
205
|
+
// explicit tier and the kind's ingest schema accepts a routing hint,
|
|
206
|
+
// `payload.source = <tier>` is attached. The routing engine's
|
|
207
|
+
// `payloadSource` match then directs the event to the matching tier
|
|
208
|
+
// rule (the operator-barry-profile rules from rules v23 depend on
|
|
209
|
+
// this signal). Kinds not in KINDS_ACCEPTING_SOURCE keep their
|
|
210
|
+
// pre-cure payload shape byte-identical; the envelope regression
|
|
211
|
+
// tests pin that determinism against a frozen contentSha.
|
|
212
|
+
const shouldStampFromTier =
|
|
213
|
+
tier !== 'auto' && KINDS_ACCEPTING_SOURCE.has(resolvedKind);
|
|
214
|
+
const effectiveSource = shouldStampFromTier ? tier : source;
|
|
215
|
+
const stampSource =
|
|
216
|
+
effectiveSource !== undefined &&
|
|
217
|
+
KINDS_ACCEPTING_SOURCE.has(resolvedKind);
|
|
218
|
+
let payload;
|
|
219
|
+
if (resolvedKind === 'knowledge.doc.delete') {
|
|
220
|
+
payload = {
|
|
221
|
+
path: relPath ?? null,
|
|
222
|
+
...(stampSource ? { source: effectiveSource } : {}),
|
|
223
|
+
};
|
|
224
|
+
} else if (resolvedKind === 'profile.slice.upsert') {
|
|
225
|
+
// Slice is the source path's basename without its extension. The
|
|
226
|
+
// ingest schema's pattern (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) refuses
|
|
227
|
+
// path separators, empty strings, and leading dot/hyphen. Callers
|
|
228
|
+
// passing a source path outside the slice tree get an actionable
|
|
229
|
+
// client-side refusal here rather than a 422 from the wire.
|
|
230
|
+
const filename = basename(relPath ?? sourcePath ?? '');
|
|
231
|
+
const slice = filename.endsWith('.md')
|
|
232
|
+
? filename.slice(0, -3)
|
|
233
|
+
: extname(filename)
|
|
234
|
+
? filename.slice(0, -extname(filename).length)
|
|
235
|
+
: filename;
|
|
236
|
+
if (!slice) {
|
|
237
|
+
const err = new Error(
|
|
238
|
+
`profile.slice.upsert requires a non-empty slice name (derived from ` +
|
|
239
|
+
`basename of sourcePath minus extension); sourcePath=${JSON.stringify(sourcePath)}`,
|
|
240
|
+
);
|
|
241
|
+
err.exitCode = 1;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
payload = {
|
|
245
|
+
slice,
|
|
246
|
+
content,
|
|
247
|
+
...(stampSource ? { source: effectiveSource } : {}),
|
|
248
|
+
};
|
|
249
|
+
} else if (KINDS_ACCEPTING_SOURCE.has(resolvedKind)) {
|
|
250
|
+
// knowledge.doc.upsert, profile.upsert.
|
|
251
|
+
payload = {
|
|
252
|
+
path: relPath ?? null,
|
|
253
|
+
content,
|
|
254
|
+
...(stampSource ? { source: effectiveSource } : {}),
|
|
255
|
+
};
|
|
256
|
+
} else {
|
|
257
|
+
// Every other kind keeps the pre-cure {path, content} shape
|
|
258
|
+
// byte-identical.
|
|
259
|
+
payload = {
|
|
260
|
+
path: relPath ?? null,
|
|
261
|
+
content,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
193
264
|
|
|
194
265
|
const envelope = {
|
|
195
266
|
clientEventId: randomUUID(),
|
package/package.json
CHANGED