@stravica-ai/digital-operator-pa 0.1.50 → 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.
- package/dist/engineering-pa-cli/src/pa/cli.mjs +47 -3
- package/dist/engineering-pa-cli/src/pa/onboard.mjs +35 -5
- package/dist/engineering-pa-cli/src/secrets/cli.mjs +26 -3
- package/dist/engineering-pa-cli/src/secrets/commands/init.mjs +128 -0
- package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +32 -12
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +87 -4
- package/dist/engineering-pa-core/README.md +1 -1
- package/dist/engineering-pa-core/docs/commands/onboard.md +76 -13
- package/dist/engineering-pa-core/docs/pa-operating-model.md +1 -0
- package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +173 -0
- package/dist/engineering-pa-core/src/vault/mode.mjs +50 -0
- package/dist/engineering-pa-core/src/vault/require.mjs +306 -0
- package/dist/engineering-pa-core/src/vault/tool-needs.mjs +74 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +1 -1
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +60 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +7 -1
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +7 -1
- package/dist/engineering-pa-scaffold/src/generator.mjs +10 -0
- package/docs/onboarding-solo.md +19 -2
- package/docs/what-it-can-do.md +1 -0
- package/package.json +1 -1
|
@@ -15,6 +15,9 @@ 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';
|
|
19
22
|
import { doOnboard } from './onboard.mjs';
|
|
20
23
|
|
|
@@ -126,9 +129,11 @@ async function doStatus(opts = {}) {
|
|
|
126
129
|
const out = opts.stdout ?? process.stdout;
|
|
127
130
|
const err = opts.stderr ?? process.stderr;
|
|
128
131
|
const cwd = opts.cwd ?? process.cwd();
|
|
132
|
+
const env = opts.env ?? process.env;
|
|
133
|
+
const home = opts.home;
|
|
129
134
|
const repoRoot = opts.repoRoot
|
|
130
|
-
??
|
|
131
|
-
??
|
|
135
|
+
?? env.DAVE_REPO_ROOT
|
|
136
|
+
?? env.PA_REPO_ROOT
|
|
132
137
|
?? cwd;
|
|
133
138
|
const workspacePort = resolveWorkspacePort(repoRoot, opts);
|
|
134
139
|
|
|
@@ -199,7 +204,46 @@ async function doStatus(opts = {}) {
|
|
|
199
204
|
// Workspace-not-running is OK — operators may run pa status before
|
|
200
205
|
// starting the workspace server.
|
|
201
206
|
|
|
202
|
-
// 4.
|
|
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
|
|
203
247
|
// count. Load-bearing because the whole self-learning loop dies
|
|
204
248
|
// silently otherwise (the estate has already been bitten: on the
|
|
205
249
|
// WSD-laptop cold-install proof of 0.1.41, staged observations piled
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
// step is done it stamps `completedAt` with an ISO string (UTC, second-precision)
|
|
8
8
|
// and preserves the 2-space indent of the file so subsequent diffs stay clean.
|
|
9
9
|
//
|
|
10
|
-
// Mandatory steps (as of 0.1.
|
|
11
|
-
//
|
|
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.
|
|
12
14
|
|
|
13
15
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
14
16
|
import { join } from 'node:path';
|
|
15
17
|
|
|
18
|
+
import { enabledToolsNeedingSecrets } from '../../../engineering-pa-core/src/vault/tool-needs.mjs';
|
|
16
19
|
import { findRepoRoot } from './repo-root.mjs';
|
|
17
20
|
|
|
18
21
|
const HELP = `pa onboard - Onboarding walkthrough state
|
|
@@ -30,13 +33,31 @@ Exit codes:
|
|
|
30
33
|
0 ok | 1 usage or a mandatory step is pending | 2 IO failure
|
|
31
34
|
`;
|
|
32
35
|
|
|
33
|
-
const
|
|
36
|
+
const BASE_MANDATORY_STEPS = [
|
|
34
37
|
{
|
|
35
38
|
id: 'librarianSchedule',
|
|
36
39
|
remediation: 'run /onboard and complete the librarianSchedule step (installs the mandatory distillation sweep)',
|
|
37
40
|
},
|
|
38
41
|
];
|
|
39
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
|
+
|
|
40
61
|
export async function doOnboard(argv) {
|
|
41
62
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
42
63
|
process.stdout.write(HELP);
|
|
@@ -95,9 +116,18 @@ export async function onboardComplete(argv, deps = {}) {
|
|
|
95
116
|
return 2;
|
|
96
117
|
}
|
|
97
118
|
const steps = parsed.steps || {};
|
|
119
|
+
const enabledNeedingSecrets = deps.enabledToolsNeedingSecrets
|
|
120
|
+
? deps.enabledToolsNeedingSecrets(repoRoot)
|
|
121
|
+
: enabledToolsNeedingSecrets(repoRoot);
|
|
122
|
+
const mandatory = mandatoryStepsFor(steps, enabledNeedingSecrets);
|
|
98
123
|
const pending = [];
|
|
99
|
-
for (const step of
|
|
100
|
-
|
|
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);
|
|
101
131
|
}
|
|
102
132
|
if (pending.length > 0) {
|
|
103
133
|
for (const step of pending) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
|
@@ -16,6 +16,8 @@ import { join, dirname, resolve, basename, sep } from 'node:path';
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { mkdtempSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
|
+
import { isSoloMode } from '../../../engineering-pa-core/src/vault/mode.mjs';
|
|
20
|
+
import { resolveKeyFile as coreResolveKeyFile } from '../../../engineering-pa-core/src/vault/require.mjs';
|
|
19
21
|
|
|
20
22
|
// ── Path resolution ──────────────────────────────────────────────────
|
|
21
23
|
|
|
@@ -155,8 +157,34 @@ export function defaultPaths(overrides = {}) {
|
|
|
155
157
|
allowGitRootWithoutVault: overrides.allowGitRootWithoutVault,
|
|
156
158
|
}).root;
|
|
157
159
|
const home = overrides.home ?? homedir();
|
|
160
|
+
// Pass caller-supplied env only. The resolver treats undefined env as
|
|
161
|
+
// empty (no SOPS_AGE_KEY_FILE consulted) so `defaultPaths` under a
|
|
162
|
+
// test harness that never passed `env` is never subject to a leaked
|
|
163
|
+
// env-override from the developer's shell. Real CLI invocations
|
|
164
|
+
// thread `env: process.env` in via `main()` in cli.mjs.
|
|
165
|
+
const env = overrides.env;
|
|
158
166
|
const vaultDir = overrides.vaultDir ?? join(root, '.vault');
|
|
159
|
-
|
|
167
|
+
// 0.1.51 fix round 2 (w-2026-09-18-dave-007): the key file resolves
|
|
168
|
+
// through the ONE shared resolver in core/vault so init, get, set,
|
|
169
|
+
// status, requireVault, and the atlassian resolver all agree on the
|
|
170
|
+
// active key path. Solo mode → in-instance `.vault/age-key.txt`;
|
|
171
|
+
// HQ mode → per-user `~/.config/stravica-pa/age-key.txt`; explicit
|
|
172
|
+
// `SOPS_AGE_KEY_FILE` env override always wins. Callers that hand-
|
|
173
|
+
// craft `overrides.keyFile` or `overrides.keyDir` bypass the resolver
|
|
174
|
+
// entirely so the existing test helpers keep their isolated key dir.
|
|
175
|
+
let keyFile;
|
|
176
|
+
let keyDir;
|
|
177
|
+
if (overrides.keyFile) {
|
|
178
|
+
keyFile = overrides.keyFile;
|
|
179
|
+
keyDir = overrides.keyDir ?? dirname(keyFile);
|
|
180
|
+
} else if (overrides.keyDir) {
|
|
181
|
+
keyDir = overrides.keyDir;
|
|
182
|
+
keyFile = join(keyDir, 'age-key.txt');
|
|
183
|
+
} else {
|
|
184
|
+
const keyResolved = coreResolveKeyFile({ repoRoot: root, home, env, mode: overrides.mode });
|
|
185
|
+
keyFile = keyResolved.keyFile;
|
|
186
|
+
keyDir = dirname(keyFile);
|
|
187
|
+
}
|
|
160
188
|
return {
|
|
161
189
|
root,
|
|
162
190
|
vaultDir,
|
|
@@ -164,7 +192,7 @@ export function defaultPaths(overrides = {}) {
|
|
|
164
192
|
recipientsFile: join(vaultDir, 'recipients.txt'),
|
|
165
193
|
sopsConfig: join(vaultDir, '.sops.yaml'),
|
|
166
194
|
keyDir,
|
|
167
|
-
keyFile
|
|
195
|
+
keyFile,
|
|
168
196
|
// Retained on the paths object so downstream consumers (canonical-
|
|
169
197
|
// clone auto-discovery in commands/set.mjs) can honour the SAME home
|
|
170
198
|
// the caller injected, instead of falling back to the real user's
|
|
@@ -208,12 +236,26 @@ function scopeError(message, exitCode = 1) {
|
|
|
208
236
|
*/
|
|
209
237
|
export function resolveScopePaths(opts = {}) {
|
|
210
238
|
const home = opts.home ?? homedir();
|
|
211
|
-
|
|
212
|
-
|
|
239
|
+
// See defaultPaths above — env is threaded from the CLI entry point,
|
|
240
|
+
// never defaulted to process.env at this layer, so a test harness
|
|
241
|
+
// that never passed env does not inherit SOPS_AGE_KEY_FILE.
|
|
242
|
+
const env = opts.env;
|
|
213
243
|
|
|
214
244
|
if (opts.file) {
|
|
215
245
|
const secretsFile = resolve(opts.file);
|
|
216
246
|
const vaultDir = dirname(secretsFile);
|
|
247
|
+
// --file bypasses workspace resolution: there is no instance to
|
|
248
|
+
// detect solo/HQ mode from. The env override still wins; otherwise
|
|
249
|
+
// fall back to the per-user path (unchanged 0.1.50 behaviour). A
|
|
250
|
+
// caller that wants the in-instance key file explicitly can pass
|
|
251
|
+
// `keyFile` here or via SOPS_AGE_KEY_FILE.
|
|
252
|
+
const envOverride = env && typeof env.SOPS_AGE_KEY_FILE === 'string' && env.SOPS_AGE_KEY_FILE.length > 0
|
|
253
|
+
? env.SOPS_AGE_KEY_FILE
|
|
254
|
+
: null;
|
|
255
|
+
const keyFile = opts.keyFile
|
|
256
|
+
?? envOverride
|
|
257
|
+
?? join(opts.keyDir ?? join(home, '.config', 'stravica-pa'), 'age-key.txt');
|
|
258
|
+
const keyDir = opts.keyDir ?? dirname(keyFile);
|
|
217
259
|
return {
|
|
218
260
|
root: vaultDir,
|
|
219
261
|
vaultDir,
|
|
@@ -237,6 +279,10 @@ export function resolveScopePaths(opts = {}) {
|
|
|
237
279
|
}
|
|
238
280
|
|
|
239
281
|
const { root } = resolveWorkspaceRoot({ cwd: opts.cwd, env: opts.env, home: opts.home });
|
|
282
|
+
// 0.1.51 fix round 2: the ONE shared resolver, same as defaultPaths.
|
|
283
|
+
const keyResolved = coreResolveKeyFile({ repoRoot: root, home, env, mode: opts.mode });
|
|
284
|
+
const keyFile = opts.keyFile ?? keyResolved.keyFile;
|
|
285
|
+
const keyDir = opts.keyDir ?? dirname(keyFile);
|
|
240
286
|
const workspaceDir = join(root, '.vault', 'scopes');
|
|
241
287
|
const workspaceFile = join(workspaceDir, `${opts.scope}.enc.json`);
|
|
242
288
|
const canonicalDir = join(root, 'vault');
|
|
@@ -524,6 +570,43 @@ export function resolveDefaultScope(opts = {}) {
|
|
|
524
570
|
return { scope: env.SECRETS_DEFAULT_SCOPE.trim(), source: 'env', detail: 'SECRETS_DEFAULT_SCOPE env var' };
|
|
525
571
|
}
|
|
526
572
|
|
|
573
|
+
// Solo-mode gate (0.1.51 fix round, w-2026-09-18-dave-007). On a
|
|
574
|
+
// colleague laptop that carries ANOTHER estate's `defaultScope` in
|
|
575
|
+
// ~/.config/stravica-pa/defaults.json (or an agents/<name>/identity.json
|
|
576
|
+
// authored by a teammate), a fresh solo instance's `secrets init` seeds
|
|
577
|
+
// the legacy `.vault/secrets.enc.json` while a subsequent bare
|
|
578
|
+
// `secrets get` follows the resolver into `.vault/scopes/<scope>.enc.json`
|
|
579
|
+
// — a scope this instance never created — and the credentialed tools
|
|
580
|
+
// fail on "could not read JIRA_DOMAIN". In solo mode we therefore
|
|
581
|
+
// ignore both the defaults.json AND identity.json branches so init,
|
|
582
|
+
// get, set, status and the atlassian resolver all agree on the
|
|
583
|
+
// instance's own legacy vault. HQ mode (`library.mode === 'remote'`)
|
|
584
|
+
// is unchanged: the scoped estate vault IS what tools should target.
|
|
585
|
+
//
|
|
586
|
+
// Explicit `SECRETS_DEFAULT_SCOPE` (above) always wins in every mode
|
|
587
|
+
// — an env-var override is a caller decision and never gets rewritten.
|
|
588
|
+
//
|
|
589
|
+
// repoRoot resolution is silent: an explicit `opts.repoRoot` wins,
|
|
590
|
+
// else a quiet walk from cwd looks for the vault-bearing workspace
|
|
591
|
+
// marker (`.vault/` or `vault/*.enc.json`). No stderr notice, no
|
|
592
|
+
// throw — a caller outside any workspace (unusual, but possible in
|
|
593
|
+
// ad-hoc test setups) falls through to the pre-gate behaviour so the
|
|
594
|
+
// rest of the resolver stays testable in isolation.
|
|
595
|
+
let repoRoot = opts.repoRoot ?? null;
|
|
596
|
+
if (!repoRoot) {
|
|
597
|
+
try {
|
|
598
|
+
const { root: walked } = walkForWorkspace(cwd);
|
|
599
|
+
if (walked) repoRoot = walked;
|
|
600
|
+
} catch {
|
|
601
|
+
repoRoot = null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const soloModeImpl = opts.isSoloMode ?? isSoloMode;
|
|
605
|
+
const inSoloMode = repoRoot ? soloModeImpl({ repoRoot }) : false;
|
|
606
|
+
if (inSoloMode) {
|
|
607
|
+
return { scope: null, source: null, detail: null };
|
|
608
|
+
}
|
|
609
|
+
|
|
527
610
|
const defaults = readDefaultsDefaultScope(home);
|
|
528
611
|
if (defaults.scope) {
|
|
529
612
|
return { scope: defaults.scope, source: 'defaults', detail: defaults.file };
|
|
@@ -10,7 +10,7 @@ Phase 1 Task 4 ports the following from the predecessor `Stravica/dave` `core/`
|
|
|
10
10
|
|
|
11
11
|
- `desk` CLI + work-item/project/spawn-log schemas
|
|
12
12
|
- Briefing-contract framework + standards docs
|
|
13
|
-
- Canonical roles (librarian, tool-developer, system-admin, agent-ops)
|
|
13
|
+
- Canonical roles (librarian, tool-developer, system-admin, agent-ops, agentic-workflow-designer)
|
|
14
14
|
- Knowledge retrieval + distillation engine
|
|
15
15
|
- Scheduler primitives
|
|
16
16
|
- Memory-sync mechanism
|