@stravica-ai/digital-operator-pa 0.1.49 → 0.1.51

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.
Files changed (45) hide show
  1. package/dist/engineering-pa-cli/src/pa/cli.mjs +94 -4
  2. package/dist/engineering-pa-cli/src/pa/onboard.mjs +144 -0
  3. package/dist/engineering-pa-cli/src/secrets/cli.mjs +26 -3
  4. package/dist/engineering-pa-cli/src/secrets/commands/init.mjs +128 -0
  5. package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +32 -12
  6. package/dist/engineering-pa-cli/src/secrets/vault.mjs +87 -4
  7. package/dist/engineering-pa-core/README.md +1 -1
  8. package/dist/engineering-pa-core/docs/commands/onboard.md +92 -29
  9. package/dist/engineering-pa-core/docs/pa-operating-model.md +1 -0
  10. package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +173 -0
  11. package/dist/engineering-pa-core/src/distill/health.mjs +170 -6
  12. package/dist/engineering-pa-core/src/distill/run.mjs +19 -0
  13. package/dist/engineering-pa-core/src/instance-root/find.mjs +104 -0
  14. package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +6 -1
  15. package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +6 -1
  16. package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +4 -1
  17. package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +6 -1
  18. package/dist/engineering-pa-core/src/scheduler/install.mjs +41 -4
  19. package/dist/engineering-pa-core/src/scheduler/list.mjs +34 -13
  20. package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -1
  21. package/dist/engineering-pa-core/src/vault/mode.mjs +50 -0
  22. package/dist/engineering-pa-core/src/vault/require.mjs +306 -0
  23. package/dist/engineering-pa-core/src/vault/tool-needs.mjs +74 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +1 -1
  25. package/dist/engineering-pa-core-extensions-atlassian/package.json +3 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +60 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +7 -1
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +4 -2
  29. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +7 -1
  30. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +4 -2
  31. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +6 -2
  32. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +7 -3
  33. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +9 -3
  34. package/dist/engineering-pa-scaffold/src/generator.mjs +13 -1
  35. package/dist/engineering-pa-workspace/app/notes.html +1 -1
  36. package/dist/engineering-pa-workspace/app/project-detail.html +17 -1
  37. package/dist/engineering-pa-workspace/app/projects.html +1 -1
  38. package/dist/engineering-pa-workspace/app/tasks.html +1 -1
  39. package/dist/engineering-pa-workspace/src/install-daemon.mjs +2 -2
  40. package/dist/engineering-pa-workspace/src/repo-root.mjs +13 -100
  41. package/dist/engineering-pa-workspace/src/server.mjs +2 -2
  42. package/dist/engineering-pa-workspace/src/ws.mjs +79 -10
  43. package/docs/onboarding-solo.md +20 -3
  44. package/docs/what-it-can-do.md +1 -0
  45. package/package.json +1 -1
@@ -15,7 +15,11 @@ import {
15
15
  computeDistillationHealth,
16
16
  renderHealthLine,
17
17
  } from '../../../engineering-pa-core/src/distill/health.mjs';
18
+ import { vaultState } from '../../../engineering-pa-core/src/vault/require.mjs';
19
+ import { enabledToolsNeedingSecrets } from '../../../engineering-pa-core/src/vault/tool-needs.mjs';
20
+ import { resolveDefaultScope } from '../secrets/vault.mjs';
18
21
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
22
+ import { doOnboard } from './onboard.mjs';
19
23
 
20
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
25
 
@@ -38,6 +42,7 @@ Usage:
38
42
  pa migrate-inspect [options] Host inspection for migration planning
39
43
  pa launch --agent=<n> --task=<id> [--dry-run]
40
44
  Boot a scoped agent instance bound to an identity and task
45
+ pa onboard <verb> Onboarding walkthrough state (complete)
41
46
  pa -h, --help Show this help
42
47
 
43
48
  Routed sub-CLIs (also available as standalone bins):
@@ -92,6 +97,8 @@ export async function main(argv) {
92
97
  return await runMigrateInspect(rest);
93
98
  case 'launch':
94
99
  return await doLaunch(rest);
100
+ case 'onboard':
101
+ return await doOnboard(rest);
95
102
  default:
96
103
  process.stderr.write(`[pa] unknown verb: ${verb}\n`);
97
104
  process.stderr.write(USAGE);
@@ -122,9 +129,11 @@ async function doStatus(opts = {}) {
122
129
  const out = opts.stdout ?? process.stdout;
123
130
  const err = opts.stderr ?? process.stderr;
124
131
  const cwd = opts.cwd ?? process.cwd();
132
+ const env = opts.env ?? process.env;
133
+ const home = opts.home;
125
134
  const repoRoot = opts.repoRoot
126
- ?? process.env.DAVE_REPO_ROOT
127
- ?? process.env.PA_REPO_ROOT
135
+ ?? env.DAVE_REPO_ROOT
136
+ ?? env.PA_REPO_ROOT
128
137
  ?? cwd;
129
138
  const workspacePort = resolveWorkspacePort(repoRoot, opts);
130
139
 
@@ -174,17 +183,67 @@ async function doStatus(opts = {}) {
174
183
  }
175
184
 
176
185
  // 3. Workspace reachability probe.
186
+ //
187
+ // 0.1.50 (item 12): report BOTH the running port (from `.ws.pid` when
188
+ // present) and the configured port when they differ, so the operator
189
+ // can see when the auto-probe moved the server onto a different port
190
+ // than preferences declare. Probe the running port when available,
191
+ // falling back to the configured port.
177
192
  out.write('\nWorkspace:\n');
178
- const probeUrl = `http://localhost:${workspacePort}/__api/identity`;
193
+ const runningPort = readRunningWorkspacePort(repoRoot);
194
+ const effectivePort = runningPort ?? workspacePort;
195
+ const probeUrl = `http://localhost:${effectivePort}/__api/identity`;
179
196
  const reachability = await probeWorkspace(probeUrl, opts.fetchImpl);
180
197
  out.write(` ${probeUrl}\n`);
198
+ if (runningPort !== null && runningPort !== workspacePort) {
199
+ out.write(` running port: ${runningPort} (configured: ${workspacePort})\n`);
200
+ }
181
201
  out.write(` status: ${reachability.status}`);
182
202
  if (reachability.detail) out.write(` (${reachability.detail})`);
183
203
  out.write('\n');
184
204
  // Workspace-not-running is OK — operators may run pa status before
185
205
  // starting the workspace server.
186
206
 
187
- // 4. Distillation health — schedule installed?, last run, staging
207
+ // 4. Vault state — three states rendered on one line (initialised /
208
+ // not needed / NOT INITIALISED). Load-bearing because 0.1.50 shipped
209
+ // a scaffold that created an empty `.vault/` for every colleague and
210
+ // then never initialised it: a jira/confluence call after onboard
211
+ // hit `secrets get` against a vault that did not exist, and the
212
+ // failure mode was silent misdirection. `not needed` renders when no
213
+ // enabled tool in operator/extensions/manifest.json declares any
214
+ // secret keys (via packages/core/src/vault/tool-needs.mjs).
215
+ out.write('\nVault:\n');
216
+ try {
217
+ const needs = enabledToolsNeedingSecrets(repoRoot);
218
+ // 0.1.51 fix round (w-2026-09-18-dave-007): consult the SAME
219
+ // default-scope resolver the credentialed tools use so the Vault
220
+ // line can never say `initialised` while `secrets get` would miss.
221
+ // In solo mode the resolver returns scope=null (its defaults.json
222
+ // and identity.json branches are gated off), so `vaultPaths`
223
+ // targets the instance's own legacy vault — matching what init
224
+ // seeded. In HQ mode a resolved scope steers `vaultState` at the
225
+ // scoped file the tools will actually read.
226
+ const scoped = resolveDefaultScope({ cwd: repoRoot, env, home });
227
+ const state = vaultState({ repoRoot, home, env, scope: scoped.scope || undefined });
228
+ if (state.ok) {
229
+ const n = state.keyCount == null ? '?' : state.keyCount;
230
+ const scopeSuffix = scoped.scope ? `; scope '${scoped.scope}' from ${scoped.source}` : '';
231
+ out.write(` initialised (${n} keys${scopeSuffix})\n`);
232
+ } else if (needs.length === 0) {
233
+ out.write(' not needed (no enabled tool declares secret needs)\n');
234
+ } else if (state.state === 'partial') {
235
+ const gaps = state.missing.join(', ');
236
+ const toolLabels = needs.map((t) => t.toolId).join(', ');
237
+ out.write(` NOT INITIALISED (partial; missing: ${gaps}) - enabled tools needing secrets: ${toolLabels}. Run: pnpm exec secrets init\n`);
238
+ } else {
239
+ const toolLabels = needs.map((t) => t.toolId).join(', ');
240
+ out.write(` NOT INITIALISED - enabled tools needing secrets: ${toolLabels}. Run: pnpm exec secrets init\n`);
241
+ }
242
+ } catch (e) {
243
+ out.write(` vault probe failed: ${e?.message || e}\n`);
244
+ }
245
+
246
+ // 5. Distillation health — schedule installed?, last run, staging
188
247
  // count. Load-bearing because the whole self-learning loop dies
189
248
  // silently otherwise (the estate has already been bitten: on the
190
249
  // WSD-laptop cold-install proof of 0.1.41, staged observations piled
@@ -219,6 +278,37 @@ async function doStatus(opts = {}) {
219
278
  return allOk ? 0 : 2;
220
279
  }
221
280
 
281
+ /**
282
+ * 0.1.50 (item 12): read the port the workspace is currently listening
283
+ * on from `.ws.pid`. Returns null when the pidfile is absent, corrupt,
284
+ * or the recorded PID is not alive. Never throws — callers fall back
285
+ * to the configured port silently.
286
+ *
287
+ * `.ws.pid` is written by `ws.mjs::cmdStart` on foreground spawn.
288
+ * Supervised installs (launchd / systemd) do NOT write it; when that is
289
+ * the case we still fall back to the configured port and the reachability
290
+ * probe hits the identity endpoint from there.
291
+ */
292
+ function readRunningWorkspacePort(repoRoot) {
293
+ try {
294
+ const pidPath = join(repoRoot, '.ws.pid');
295
+ if (!existsSync(pidPath)) return null;
296
+ const raw = JSON.parse(readFileSync(pidPath, 'utf8'));
297
+ if (!raw || typeof raw.pid !== 'number' || typeof raw.port !== 'number') return null;
298
+ // Verify the process is alive before trusting the port. A stale
299
+ // pidfile pointing at a dead process should not surface a
300
+ // running-port line.
301
+ try {
302
+ process.kill(raw.pid, 0);
303
+ } catch {
304
+ return null;
305
+ }
306
+ return raw.port;
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
222
312
  function resolveWorkspacePort(repoRoot, opts = {}) {
223
313
  if (opts.workspacePort) return opts.workspacePort;
224
314
  try {
@@ -0,0 +1,144 @@
1
+ // onboard.mjs — `pa onboard` verb, currently just the `complete` subcommand.
2
+ //
3
+ // Adds a completion guard to the /onboard walkthrough. `.session/onboarding.json`
4
+ // carries per-step state; hand-editing it to set `completedAt` (the pre-0.1.50
5
+ // pattern) skipped the mandatory-step check. This verb refuses when a mandatory
6
+ // step is not `done`, names it, and quotes the remediation. When every mandatory
7
+ // step is done it stamps `completedAt` with an ISO string (UTC, second-precision)
8
+ // and preserves the 2-space indent of the file so subsequent diffs stay clean.
9
+ //
10
+ // Mandatory steps (as of 0.1.51): librarianSchedule, plus vault when
11
+ // any enabled tool declares a secrets need (see
12
+ // packages/core/src/vault/tool-needs.mjs::enabledToolsNeedingSecrets).
13
+ // See packages/core/docs/commands/onboard.md §Completion.
14
+
15
+ import { readFile, writeFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+
18
+ import { enabledToolsNeedingSecrets } from '../../../engineering-pa-core/src/vault/tool-needs.mjs';
19
+ import { findRepoRoot } from './repo-root.mjs';
20
+
21
+ const HELP = `pa onboard - Onboarding walkthrough state
22
+
23
+ Usage:
24
+ pa onboard complete Guard-checked completion of /onboard
25
+ pa onboard -h, --help Show this help
26
+
27
+ Subcommands:
28
+ complete
29
+ Reads .session/onboarding.json, refuses when any mandatory step (currently
30
+ librarianSchedule) is not 'done', otherwise stamps completedAt.
31
+
32
+ Exit codes:
33
+ 0 ok | 1 usage or a mandatory step is pending | 2 IO failure
34
+ `;
35
+
36
+ const BASE_MANDATORY_STEPS = [
37
+ {
38
+ id: 'librarianSchedule',
39
+ remediation: 'run /onboard and complete the librarianSchedule step (installs the mandatory distillation sweep)',
40
+ },
41
+ ];
42
+
43
+ // The vault step is conditionally mandatory: required only when at
44
+ // least one enabled tool declares a secrets need. See
45
+ // packages/core/src/vault/tool-needs.mjs. A `notNeeded` value on this
46
+ // step is a clean pass through the gate; `pending` refuses completion.
47
+ function mandatoryStepsFor(steps, enabledNeedingSecrets) {
48
+ const list = [...BASE_MANDATORY_STEPS];
49
+ const vault = steps ? steps.vault : undefined;
50
+ if (vault === 'notNeeded') return list;
51
+ if (enabledNeedingSecrets && enabledNeedingSecrets.length > 0) {
52
+ const toolLabels = enabledNeedingSecrets.map((t) => t.toolId).join(', ');
53
+ list.push({
54
+ id: 'vault',
55
+ remediation: `run /onboard and complete the vault step (initialise the SOPS+age vault used by: ${toolLabels})`,
56
+ });
57
+ }
58
+ return list;
59
+ }
60
+
61
+ export async function doOnboard(argv) {
62
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
63
+ process.stdout.write(HELP);
64
+ return 0;
65
+ }
66
+ const sub = argv[0];
67
+ const rest = argv.slice(1);
68
+ switch (sub) {
69
+ case 'complete':
70
+ return await onboardComplete(rest);
71
+ default:
72
+ process.stderr.write(`[pa onboard] unknown subcommand: ${sub}\n`);
73
+ process.stderr.write(HELP);
74
+ return 1;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Run the completion guard against `.session/onboarding.json`. Reads the
80
+ * file, checks every mandatory step is `done`, and stamps `completedAt`
81
+ * to the current UTC ISO timestamp (millisecond-truncated) when the
82
+ * gate passes.
83
+ *
84
+ * @param {string[]} argv reserved; no flags today
85
+ * @returns {Promise<number>} exit code
86
+ */
87
+ export async function onboardComplete(argv, deps = {}) {
88
+ const stderr = deps.stderr || process.stderr;
89
+ const stdout = deps.stdout || process.stdout;
90
+ const now = deps.now || (() => new Date().toISOString());
91
+ const repoRoot = deps.repoRoot
92
+ || process.env.PA_REPO_ROOT
93
+ || process.env.DAVE_REPO_ROOT
94
+ || await findRepoRoot(process.cwd());
95
+ if (!repoRoot) {
96
+ stderr.write('[pa onboard complete] could not resolve the operator repo root — set PA_REPO_ROOT or run from inside the instance.\n');
97
+ return 2;
98
+ }
99
+ const path = join(repoRoot, '.session', 'onboarding.json');
100
+ let raw;
101
+ try {
102
+ raw = await readFile(path, 'utf8');
103
+ } catch (e) {
104
+ if (e && e.code === 'ENOENT') {
105
+ stderr.write(`[pa onboard complete] no onboarding state at ${path} — run /onboard first.\n`);
106
+ return 1;
107
+ }
108
+ stderr.write(`[pa onboard complete] failed to read ${path}: ${e.message}\n`);
109
+ return 2;
110
+ }
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ } catch (e) {
115
+ stderr.write(`[pa onboard complete] onboarding state at ${path} is not valid JSON: ${e.message}\n`);
116
+ return 2;
117
+ }
118
+ const steps = parsed.steps || {};
119
+ const enabledNeedingSecrets = deps.enabledToolsNeedingSecrets
120
+ ? deps.enabledToolsNeedingSecrets(repoRoot)
121
+ : enabledToolsNeedingSecrets(repoRoot);
122
+ const mandatory = mandatoryStepsFor(steps, enabledNeedingSecrets);
123
+ const pending = [];
124
+ for (const step of mandatory) {
125
+ // `notNeeded` on the vault step passes; every other terminal value
126
+ // that is not `done` is pending. Skipped is never valid for a
127
+ // mandatory step (mirrors librarianSchedule's contract).
128
+ if (steps[step.id] === 'done') continue;
129
+ if (step.id === 'vault' && steps.vault === 'notNeeded') continue;
130
+ pending.push(step);
131
+ }
132
+ if (pending.length > 0) {
133
+ for (const step of pending) {
134
+ stderr.write(`[pa onboard complete] mandatory step '${step.id}' is not done (currently: ${JSON.stringify(steps[step.id] ?? null)})\n`);
135
+ stderr.write(` Remediation: ${step.remediation}\n`);
136
+ }
137
+ return 1;
138
+ }
139
+ parsed.completedAt = now();
140
+ const hadTrailingNewline = raw.endsWith('\n');
141
+ await writeFile(path, JSON.stringify(parsed, null, 2) + (hadTrailingNewline ? '\n' : ''));
142
+ stdout.write(`Onboarding marked complete at ${parsed.completedAt}\n`);
143
+ return 0;
144
+ }
@@ -22,7 +22,10 @@ import { resolveScopePaths, VaultError } from './vault.mjs';
22
22
  const HELP = `secrets — SOPS+age secrets management
23
23
 
24
24
  Usage:
25
- secrets init Initialise the vault (generate keys, create empty secrets file)
25
+ secrets init Initialise the vault (generate keys, create empty secrets file).
26
+ --empty: explicit no-seed init (current default shape).
27
+ --stdin-json: after init, seed KEYs from a JSON object
28
+ read on stdin (values never echoed; refuses a TTY stdin).
26
29
  secrets get KEY Retrieve a secret (add --exists for a present/absent check that never prints the value)
27
30
  secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
28
31
  Since 0.1.20, canonical-first dual-write is
@@ -53,7 +56,18 @@ Usage:
53
56
  --values is required — decrypt-only is never
54
57
  a valid pass (see below). Never prints values.
55
58
 
56
- Private key: ~/.config/stravica-pa/age-key.txt (never committed)
59
+ Age private key resolution (0.1.51, w-2026-09-18-dave-007). One shared
60
+ order every verb + pa status + the atlassian resolver consult:
61
+ 1. $SOPS_AGE_KEY_FILE Explicit override, always wins.
62
+ 2. <repo>/.vault/age-key.txt Solo-mode key, gitignored. Wins when
63
+ present in either mode.
64
+ 3. ~/.config/stravica-pa/age-key.txt
65
+ Per-user key. Used only when the
66
+ instance is in HQ mode
67
+ (library.mode: remote). Solo mode
68
+ with no in-instance key resolves back
69
+ to #2, init writes there.
70
+
57
71
  Public key: .vault/recipients.txt (committed to repo)
58
72
  Encrypted store: .vault/secrets.enc.json (committed to repo)
59
73
  SOPS config: .vault/.sops.yaml (committed to repo)
@@ -330,7 +344,7 @@ export async function run(argv, overrides = {}) {
330
344
  const cmdOverrides = paths ? { ...overrides, paths } : overrides;
331
345
 
332
346
  switch (cmd) {
333
- case 'init': return runInit(cmdOverrides);
347
+ case 'init': return runInit({ ...cmdOverrides, argv: cmdArgs });
334
348
  case 'get': return runGet(cmdArgs, cmdOverrides);
335
349
  case 'set': return runSet(cmdArgs, cmdOverrides);
336
350
  case 'list': return runList(cmdOverrides);
@@ -353,6 +367,15 @@ export async function run(argv, overrides = {}) {
353
367
  }
354
368
 
355
369
  export async function main(argv, overrides = {}) {
370
+ // 0.1.51 fix round 2 (w-2026-09-18-dave-007): env defaulted at the
371
+ // BIN entry so every downstream defaultPaths / resolveScopePaths /
372
+ // resolveKeyFile call sees the operator's real environment
373
+ // (SOPS_AGE_KEY_FILE, DAVE_ROOT, etc). The vault.mjs resolvers do NOT
374
+ // default env from process.env themselves — that leak was breaking
375
+ // scoped-fixture tests when the developer's shell carried
376
+ // SOPS_AGE_KEY_FILE. run() is exported for tests to call with an
377
+ // explicit env; main() is what the packaged bin invokes.
378
+ if (overrides.env === undefined) overrides = { ...overrides, env: process.env };
356
379
  try {
357
380
  const code = await run(argv, overrides);
358
381
  process.exit(code ?? 0);
@@ -1,6 +1,23 @@
1
1
  // init — generate age keypair, write recipients, write SOPS config,
2
2
  // create an empty encrypted secrets file if none exists.
3
+ //
4
+ // Two non-interactive paths are exposed for the guided onboarding
5
+ // vault step AND the e2e test to share ONE code path:
6
+ //
7
+ // secrets init --empty
8
+ // Explicit "create the key and the empty store, no seeded keys".
9
+ // The default behaviour has always produced this shape; the flag
10
+ // is a self-documenting affirmation the caller wanted it.
11
+ //
12
+ // secrets init --stdin-json
13
+ // Read {"KEY": "value", ...} from stdin and seed those keys after
14
+ // the vault is created (never writes to canonical clones — a fresh
15
+ // init lands values in the workspace vault only). Values are never
16
+ // echoed to stdout or stderr and never appear in any log line the
17
+ // tool writes; the tool refuses if stdin is a TTY (no accidental
18
+ // paste-into-prompt where the value would land in shell history).
3
19
 
20
+ import { readFileSync } from 'node:fs';
4
21
  import {
5
22
  defaultPaths,
6
23
  checkDeps,
@@ -14,12 +31,93 @@ import {
14
31
  writeSopsConfig,
15
32
  keyFileExists,
16
33
  secretsFileExists,
34
+ sopsDecrypt,
17
35
  sopsEncrypt,
18
36
  VaultError,
19
37
  } from '../vault.mjs';
20
38
 
39
+ const KEY_NAME_RE = /^[A-Z0-9_]+$/;
40
+
41
+ function extractInitFlags(argv) {
42
+ const out = { empty: false, stdinJson: false };
43
+ for (const a of argv || []) {
44
+ if (a === '--empty') out.empty = true;
45
+ else if (a === '--stdin-json') out.stdinJson = true;
46
+ else if (a === '-h' || a === '--help') out.help = true;
47
+ else out.unknown = a;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function readStdinSync() {
53
+ // Read raw bytes from stdin synchronously. Never printed anywhere.
54
+ try {
55
+ return readFileSync(0, 'utf8');
56
+ } catch (err) {
57
+ throw new VaultError(`Error: could not read stdin for --stdin-json: ${err && err.message ? err.message : err}`, 1);
58
+ }
59
+ }
60
+
61
+ function parseStdinJson(raw) {
62
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
63
+ throw new VaultError('Error: --stdin-json received empty input; expected a JSON object of KEY/value pairs.', 1);
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ } catch (err) {
69
+ throw new VaultError(`Error: --stdin-json input is not valid JSON: ${err && err.message ? err.message : err}`, 1);
70
+ }
71
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
72
+ throw new VaultError('Error: --stdin-json input must be a JSON OBJECT of KEY/value pairs.', 1);
73
+ }
74
+ const out = {};
75
+ for (const [key, value] of Object.entries(parsed)) {
76
+ if (!KEY_NAME_RE.test(key)) {
77
+ throw new VaultError(`Error: --stdin-json key "${key}" must match ${KEY_NAME_RE} (upper snake case).`, 1);
78
+ }
79
+ if (typeof value !== 'string') {
80
+ throw new VaultError(`Error: --stdin-json value for "${key}" must be a string.`, 1);
81
+ }
82
+ out[key] = value;
83
+ }
84
+ return out;
85
+ }
86
+
21
87
  export async function runInit(overrides = {}) {
22
88
  const paths = overrides.paths || defaultPaths({ ...overrides, allowGitRootWithoutVault: true });
89
+ const argv = overrides.argv || [];
90
+ const flags = extractInitFlags(argv);
91
+
92
+ if (flags.help) {
93
+ console.log([
94
+ 'Usage: secrets init [--empty | --stdin-json]',
95
+ '',
96
+ ' --empty Create the key and an empty encrypted store (default shape).',
97
+ ' --stdin-json After init, seed KEYs from a JSON object read on stdin.',
98
+ ' Refuses when stdin is a TTY. Values are never echoed.',
99
+ ].join('\n'));
100
+ return 0;
101
+ }
102
+
103
+ if (flags.empty && flags.stdinJson) {
104
+ console.error('Error: --empty and --stdin-json are mutually exclusive.');
105
+ throw new VaultError('mutually exclusive flags', 1);
106
+ }
107
+
108
+ // Read stdin BEFORE running the init side effects — a malformed
109
+ // payload should not leave a partially-initialised vault on disk when
110
+ // the caller was going to seed it in one shot.
111
+ let seedPairs = null;
112
+ if (flags.stdinJson) {
113
+ const stdinStream = overrides.stdinStream || process.stdin;
114
+ if (stdinStream && stdinStream.isTTY && !overrides.allowTtyStdin) {
115
+ console.error('Error: --stdin-json requires piped JSON on stdin; refusing to prompt (would land the value in shell history).');
116
+ throw new VaultError('stdin is a TTY', 1);
117
+ }
118
+ const raw = overrides.stdinRead ? overrides.stdinRead() : readStdinSync();
119
+ seedPairs = parseStdinJson(raw);
120
+ }
23
121
 
24
122
  const missing = await checkDeps();
25
123
  if (missing.length > 0) {
@@ -33,6 +131,13 @@ export async function runInit(overrides = {}) {
33
131
  ensureKeyDir(paths);
34
132
 
35
133
  if (keyFileExists(paths)) {
134
+ // 0.1.51 fix round 2 (w-2026-09-18-dave-007): the shared resolver
135
+ // routes solo instances to `<repoRoot>/.vault/age-key.txt` and HQ
136
+ // instances to `~/.config/stravica-pa/age-key.txt`. A pre-existing
137
+ // key at the resolved path is intentional reuse (init is
138
+ // idempotent). A foreign per-user key on a fresh solo laptop no
139
+ // longer meets this check — the solo resolver returns the instance
140
+ // path, which does not exist, and init generates a NEW key there.
36
141
  console.log(`Age private key already exists at ${paths.keyFile}`);
37
142
  } else {
38
143
  await ageKeygen(paths);
@@ -59,6 +164,29 @@ export async function runInit(overrides = {}) {
59
164
  console.log('Encrypted secrets file already exists.');
60
165
  }
61
166
 
167
+ // Seed non-interactively. Merge (do not replace) any keys that were
168
+ // already in the store — this verb is idempotent by design and a
169
+ // re-run with a new key/value should not clobber the operator's
170
+ // existing set. Values are never echoed; the summary line at the end
171
+ // prints only key NAMES and the pair count.
172
+ if (seedPairs && Object.keys(seedPairs).length > 0) {
173
+ let data = {};
174
+ if (secretsFileExists(paths)) {
175
+ try {
176
+ data = await sopsDecrypt(paths, paths.secretsFile);
177
+ } catch (err) {
178
+ console.error(`Error: could not decrypt ${paths.secretsFile} to merge seeded keys: ${err && err.message ? err.message : err}`);
179
+ throw new VaultError('seed merge decrypt failed', 2);
180
+ }
181
+ }
182
+ for (const [k, v] of Object.entries(seedPairs)) data[k] = v;
183
+ await sopsEncrypt(paths, pubKey, data);
184
+ const names = Object.keys(seedPairs).sort();
185
+ console.log(`Seeded ${names.length} key(s): ${names.join(', ')}`);
186
+ } else if (flags.stdinJson) {
187
+ console.log('--stdin-json input carried no keys; vault created empty.');
188
+ }
189
+
62
190
  console.log('');
63
191
  console.log('Vault initialised.');
64
192
  console.log(` Private key: ${paths.keyFile} (NEVER commit this)`);
@@ -21,6 +21,7 @@
21
21
  // "device-side vault mutations must round-trip to canonical immediately").
22
22
 
23
23
  import { readFileSync } from 'node:fs';
24
+ import { isSoloMode } from '../../../../engineering-pa-core/src/vault/mode.mjs';
24
25
  import {
25
26
  resolveCommandPaths,
26
27
  resolveScopePaths,
@@ -153,19 +154,38 @@ export async function runSet(args, overrides = {}) {
153
154
  // callers that build paths themselves and only pass home via
154
155
  // overrides, then to homedir() for the real CLI at run time.
155
156
  const canonicalHome = paths.home ?? overrides.home;
156
- const resolved = overrides.resolveCanonicalCloneTarget
157
- ? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
158
- : resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
159
- if (resolved.file) {
160
- effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
157
+
158
+ // Solo-mode gate (0.1.51, w-2026-09-18-dave-007). Auto-discovery of
159
+ // a canonical clone via ~/.config/stravica-pa/defaults.json or the
160
+ // ~/stravica-canonical/*/vault/<scope>.enc.json convention is the
161
+ // co-existence hazard the fix closes: on a colleague laptop that
162
+ // already carries ANOTHER instance's vault, a solo `secrets set`
163
+ // must never silently dual-write across the estate boundary.
164
+ // Explicit --canonical-clone PATH still wins (above); the gate only
165
+ // suppresses the AUTO-discovery tier. HQ mode (preferences.library.
166
+ // mode === 'remote') keeps today's behaviour because there the auto-
167
+ // discovery IS the desired behaviour — the operator owns every vault
168
+ // the scan can find. See packages/core/src/vault/mode.mjs.
169
+ const soloGate = overrides.isSoloMode
170
+ ? overrides.isSoloMode({ repoRoot: paths.root })
171
+ : isSoloMode({ repoRoot: paths.root });
172
+ if (soloGate) {
173
+ console.error(`notice: solo mode - canonical-clone auto-discovery is off for scope '${paths.scope}'. Workspace-only write. Pass --canonical-clone PATH to force a dual-write.`);
161
174
  } else {
162
- // Absence notice — never a failure. Explains WHY nothing resolved so
163
- // an operator on a host without a canonical layout knows whether to
164
- // provision one, pass --no-canonical to silence, or ignore.
165
- const tail = resolved.tried && resolved.tried.length > 0
166
- ? ` (tried: ${resolved.tried.join('; ')})`
167
- : '';
168
- console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
175
+ const resolved = overrides.resolveCanonicalCloneTarget
176
+ ? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
177
+ : resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
178
+ if (resolved.file) {
179
+ effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
180
+ } else {
181
+ // Absence notice — never a failure. Explains WHY nothing resolved so
182
+ // an operator on a host without a canonical layout knows whether to
183
+ // provision one, pass --no-canonical to silence, or ignore.
184
+ const tail = resolved.tried && resolved.tried.length > 0
185
+ ? ` (tried: ${resolved.tried.join('; ')})`
186
+ : '';
187
+ console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
188
+ }
169
189
  }
170
190
  }
171
191