@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.
- package/dist/engineering-pa-cli/src/pa/cli.mjs +94 -4
- package/dist/engineering-pa-cli/src/pa/onboard.mjs +144 -0
- 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 +92 -29
- 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/distill/health.mjs +170 -6
- package/dist/engineering-pa-core/src/distill/run.mjs +19 -0
- package/dist/engineering-pa-core/src/instance-root/find.mjs +104 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +6 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +6 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +4 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +6 -1
- package/dist/engineering-pa-core/src/scheduler/install.mjs +41 -4
- package/dist/engineering-pa-core/src/scheduler/list.mjs +34 -13
- package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -1
- 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/package.json +3 -0
- 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/confluence/commands/mirror-folder.mjs +4 -2
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +7 -1
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +4 -2
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +6 -2
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +7 -3
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +9 -3
- package/dist/engineering-pa-scaffold/src/generator.mjs +13 -1
- package/dist/engineering-pa-workspace/app/notes.html +1 -1
- package/dist/engineering-pa-workspace/app/project-detail.html +17 -1
- package/dist/engineering-pa-workspace/app/projects.html +1 -1
- package/dist/engineering-pa-workspace/app/tasks.html +1 -1
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +2 -2
- package/dist/engineering-pa-workspace/src/repo-root.mjs +13 -100
- package/dist/engineering-pa-workspace/src/server.mjs +2 -2
- package/dist/engineering-pa-workspace/src/ws.mjs +79 -10
- package/docs/onboarding-solo.md +20 -3
- package/docs/what-it-can-do.md +1 -0
- 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
|
-
??
|
|
127
|
-
??
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|