@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.
@@ -10,7 +10,7 @@ import {
10
10
  depInstallMessage,
11
11
  checkVault,
12
12
  secretsFileExists,
13
- sopsDecrypt,
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 sopsDecrypt(paths, paths.secretsFile);
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
- sopsDecrypt,
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 sopsDecrypt(paths, paths.secretsFile);
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.0",
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 exits 1 with a clean message.
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; only encoded
122
- * into the payload for `knowledge.doc.delete` (mirrors the schema's
123
- * optional `source` field for the delete kind — spec §3.1 shape).
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). Fix Wave E (w-2026-08-24-dave-029): before this branch the
174
- // envelope encoded `{path, content}` for every kind, and the ingest
175
- // schema for `knowledge.doc.delete` refused the `content` key with
176
- // 422 (additionalProperties:false, required=["path"]). Result: the
177
- // delete verb structurally could not succeed via the CLI — the same
178
- // deferred-defect class the retirement programme is here to close.
179
- // The delete branch omits `content` and (optionally) attaches
180
- // `source`. Every other kind keeps the pre-cure `{path, content}`
181
- // shape byte-for-byte; the regression test in envelope.test.mjs
182
- // asserts that determinism against a frozen contentSha.
183
- const payload =
184
- resolvedKind === 'knowledge.doc.delete'
185
- ? {
186
- path: relPath ?? null,
187
- ...(source !== undefined ? { source } : {}),
188
- }
189
- : {
190
- path: relPath ?? null,
191
- content,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",