@stravica-ai/digital-operator-pa 0.1.19 → 0.1.20
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.
|
@@ -8,7 +8,7 @@ Dave uses SOPS with age encryption for secrets management. Secrets are encrypted
|
|
|
8
8
|
|
|
9
9
|
Implemented: `init`, `get`, `set`, `list`, `delete`, `rotate`, `status`, `backup`, `vaults`, `find` (alias `which`), `verify`. No npm dependencies — shells out to `sops` and `age` via `node:child_process.spawn`.
|
|
10
10
|
|
|
11
|
-
`set`
|
|
11
|
+
**Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20.** A plain `secrets set` on a scoped vault writes canonical FIRST and workspace SECOND whenever a canonical clone for the scope is discoverable on this host (via `~/.config/stravica-pa/defaults.json`'s `canonicalClone` key or the `~/stravica-canonical/*/vault/<scope>.enc.json` estate convention). Absence of a canonical clone is a stderr NOTICE, never a failure. Explicit `--canonical-clone PATH` still wins over auto-discovery (and bootstraps a missing file); `--no-canonical` opts out entirely and suppresses the notice. `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.
|
|
12
12
|
|
|
13
13
|
`get`/`set` run without `--scope`/`--file` now resolve a **default scope** — `$SECRETS_DEFAULT_SCOPE` env var, then `"defaultScope"` in `~/.config/stravica-pa/defaults.json`, then the calling PA's own `agents/<name>/identity.json`, then the legacy single-file vault as a last resort. Every such invocation prints a one-line stderr notice naming the target vault BEFORE the operation runs, including the legacy-fallback case ("no scope specified and no default configured") — see `secrets --help` → "Default scope" for the full precedence and example notices.
|
|
14
14
|
|
|
@@ -67,11 +67,17 @@ node core/tools/secrets/bin/secrets status
|
|
|
67
67
|
# Open a browser with a QR code of the master key for backup
|
|
68
68
|
node core/tools/secrets/bin/secrets backup
|
|
69
69
|
|
|
70
|
-
#
|
|
71
|
-
# Aborts (exit 2) before touching the workspace copy if
|
|
72
|
-
# write fails (e.g. the existing canonical store can't be
|
|
70
|
+
# Force a specific canonical file (wins over auto-discovery; bootstraps
|
|
71
|
+
# a missing file). Aborts (exit 2) before touching the workspace copy if
|
|
72
|
+
# the canonical write fails (e.g. the existing canonical store can't be
|
|
73
|
+
# decrypted). Auto-discovery is on by default since 0.1.20 — this flag
|
|
74
|
+
# is only for pointing at a specific target.
|
|
73
75
|
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /path/to/canonical/vault/scope.enc.json
|
|
74
76
|
|
|
77
|
+
# Opt out of canonical dual-write entirely (suppresses the absence
|
|
78
|
+
# notice too). Mutually exclusive with --canonical-clone.
|
|
79
|
+
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --no-canonical
|
|
80
|
+
|
|
75
81
|
# Byte-level comparison of DECRYPTED values against a reference vault.
|
|
76
82
|
# --values is required — there is no decrypt-only verification mode
|
|
77
83
|
# (decrypt-only false-passes a vault that decrypts cleanly but holds the
|
|
@@ -79,29 +85,66 @@ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /p
|
|
|
79
85
|
node core/tools/secrets/bin/secrets verify --values --against /path/to/other/vault/scope.enc.json
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
### Canonical-first writes
|
|
88
|
+
### Canonical-first writes
|
|
89
|
+
|
|
90
|
+
Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20 —
|
|
91
|
+
a plain `secrets set` on a scoped vault writes canonical FIRST and the
|
|
92
|
+
workspace SECOND whenever a canonical clone for the scope is discoverable
|
|
93
|
+
on this host. The abort-before-workspace ordering is identical to the
|
|
94
|
+
explicit-flag path it grew out of: if the canonical write fails for any
|
|
95
|
+
reason (existing canonical store undecryptable, sops encrypt failure),
|
|
96
|
+
the command exits 2 and the workspace copy is never touched.
|
|
97
|
+
|
|
98
|
+
**Resolution order** (first hit wins; every miss recorded in the absence
|
|
99
|
+
notice's tried-tier detail):
|
|
100
|
+
|
|
101
|
+
1. **Explicit `--canonical-clone PATH`** — always wins, bootstraps a
|
|
102
|
+
missing file (this is the flag's historical behaviour).
|
|
103
|
+
2. **`"canonicalClone"` in `~/.config/stravica-pa/defaults.json`** — the
|
|
104
|
+
same file (already read for `workspaceRoot` and `defaultScope`); the
|
|
105
|
+
CLI never writes it. Two shapes supported:
|
|
106
|
+
- **Object (scope → file map):**
|
|
107
|
+
```json
|
|
108
|
+
{ "canonicalClone": { "seo-ops": "/Users/thefoot/stravica-canonical/stravica-operator-barry/vault/seo-ops.enc.json" } }
|
|
109
|
+
```
|
|
110
|
+
- **String (root dir)** — derives `<root>/vault/<scope>.enc.json`,
|
|
111
|
+
which fits the estate's operator-repo layout:
|
|
112
|
+
```json
|
|
113
|
+
{ "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
|
|
114
|
+
```
|
|
115
|
+
3. **Estate convention** — `~/stravica-canonical/<repo>/vault/<scope>.enc.json`
|
|
116
|
+
(glob shape `~/stravica-canonical/*/vault/…enc.json`). This reuses the
|
|
117
|
+
root the rest of the estate already uses (see
|
|
118
|
+
`library-sync-cycle`'s `--repos-root` default and
|
|
119
|
+
`dex-cutover-driver`'s `reposRoot`). Exactly one match wins; ambiguity
|
|
120
|
+
(multiple matches) or zero matches skips this tier.
|
|
121
|
+
|
|
122
|
+
**Absence** is a stderr notice, never a failure — a host with no
|
|
123
|
+
canonical layout keeps writing to workspace only:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
notice: no canonical clone configured for scope 'seo-ops' on this host — workspace-only write (tried: …)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The `tried:` tail names every tier that missed and why, so an operator on
|
|
130
|
+
a host that ought to have a canonical knows exactly where to configure it
|
|
131
|
+
(or that `--no-canonical` is the intended answer for a host that never
|
|
132
|
+
had one).
|
|
83
133
|
|
|
84
|
-
|
|
85
|
-
resolved workspace copy. With `--canonical-clone PATH`, `set`:
|
|
134
|
+
**Flags:**
|
|
86
135
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
2. Decrypts the existing canonical store (if any) and writes the new key
|
|
93
|
-
into it. **If this fails for any reason — the existing canonical
|
|
94
|
-
store can't be decrypted, or the encrypt itself fails — `set` aborts
|
|
95
|
-
with exit 2 and the workspace copy is never touched.**
|
|
96
|
-
3. Only on canonical success does it proceed to the existing
|
|
97
|
-
decrypt-mutate-encrypt cycle against the resolved workspace copy.
|
|
136
|
+
- `--canonical-clone PATH` — force a specific canonical file (wins over
|
|
137
|
+
auto-discovery; bootstraps a missing file). Backwards-compatible with
|
|
138
|
+
every prior release.
|
|
139
|
+
- `--no-canonical` — opt out of dual-write entirely, and suppress the
|
|
140
|
+
absence notice. Mutually exclusive with `--canonical-clone`.
|
|
98
141
|
|
|
99
142
|
This closes the "device-side vault mutation only round-trips halfway"
|
|
100
|
-
class
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
143
|
+
class BY DEFAULT rather than opt-in: any workspace write that a
|
|
144
|
+
canonical layout could catch, it now does — no flag required.
|
|
145
|
+
|
|
146
|
+
Legacy vaults (no scope) never trigger canonical resolution — the notice
|
|
147
|
+
would be pure noise for a fallback that has no natural canonical mirror.
|
|
105
148
|
|
|
106
149
|
### Value-compare verification (`verify --values --against`)
|
|
107
150
|
|
|
@@ -25,10 +25,17 @@ Usage:
|
|
|
25
25
|
secrets init Initialise the vault (generate keys, create empty secrets file)
|
|
26
26
|
secrets get KEY Retrieve a secret
|
|
27
27
|
secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
Since 0.1.20, canonical-first dual-write is
|
|
29
|
+
the DEFAULT for scoped writes when a canonical
|
|
30
|
+
clone for the scope is discoverable on this
|
|
31
|
+
host. See "Canonical-first writes" below.
|
|
32
|
+
--canonical-clone PATH: force a specific
|
|
33
|
+
canonical clone's vault file (bootstraps a
|
|
34
|
+
missing file; wins over auto-discovery).
|
|
35
|
+
--no-canonical: opt out of the dual-write and
|
|
36
|
+
the absence notice.
|
|
37
|
+
If the canonical write fails, aborts before
|
|
38
|
+
touching the workspace copy at all.
|
|
32
39
|
secrets list List all secret key names (not values)
|
|
33
40
|
secrets delete KEY Remove a secret
|
|
34
41
|
secrets rotate Re-encrypt with current recipients
|
|
@@ -111,16 +118,30 @@ Default scope (get/set only, when neither --scope nor --file is given):
|
|
|
111
118
|
note: writing to '(legacy)' pot — no scope specified and no default configured. Use --scope <name> or configure a default.
|
|
112
119
|
|
|
113
120
|
Canonical-first writes (set only):
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
Canonical-first dual-write is DEFAULT for scoped writes since 0.1.20 —
|
|
122
|
+
a plain 'secrets set' on a scoped vault writes canonical FIRST and
|
|
123
|
+
workspace SECOND whenever a canonical clone for the scope is
|
|
124
|
+
discoverable on this host. Discovery order, first hit wins:
|
|
125
|
+
1. Explicit --canonical-clone PATH (always wins, bootstraps a
|
|
126
|
+
missing file).
|
|
127
|
+
2. "canonicalClone" in ~/.config/stravica-pa/defaults.json — either:
|
|
128
|
+
object (scope → file map):
|
|
129
|
+
{ "canonicalClone": { "seo-ops": "/abs/vault/seo-ops.enc.json" } }
|
|
130
|
+
string (root dir), derives <root>/vault/<scope>.enc.json:
|
|
131
|
+
{ "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
|
|
132
|
+
3. Estate convention: ~/stravica-canonical/*/vault/<scope>.enc.json
|
|
133
|
+
(exactly one match; ambiguity or zero matches skips this tier).
|
|
134
|
+
Absence is a NOTICE, never a failure — a host with no canonical layout
|
|
135
|
+
keeps writing to workspace only, with a one-line stderr line naming why
|
|
136
|
+
nothing resolved. If any canonical write fails (existing store
|
|
137
|
+
undecryptable, sops encrypt failure), the command aborts with exit 2
|
|
138
|
+
BEFORE touching the workspace copy — the ordering invariant is
|
|
139
|
+
identical to the flag path since it always was.
|
|
140
|
+
|
|
141
|
+
--canonical-clone PATH Force PATH as the canonical target (wins over
|
|
142
|
+
auto-discovery; bootstraps a missing file).
|
|
143
|
+
--no-canonical Opt out of dual-write entirely, and suppress
|
|
144
|
+
the absence notice.
|
|
124
145
|
|
|
125
146
|
Value-compare verification:
|
|
126
147
|
secrets verify --values --against <path>
|
|
@@ -139,7 +160,7 @@ const USAGE = `Usage: secrets {init|get|set|list|delete|rotate|status|backup|vau
|
|
|
139
160
|
Commands:
|
|
140
161
|
init Initialise the vault (generate keys, create config)
|
|
141
162
|
get KEY Retrieve a secret value
|
|
142
|
-
set KEY VALUE Store or update a secret (
|
|
163
|
+
set KEY VALUE Store or update a secret (canonical-first by default; --no-canonical to opt out)
|
|
143
164
|
list List all secret key names
|
|
144
165
|
delete KEY Remove a secret
|
|
145
166
|
rotate Re-encrypt with current recipients
|
|
@@ -2,18 +2,29 @@
|
|
|
2
2
|
// Passing `-` as VALUE reads the secret from stdin (trailing newline stripped),
|
|
3
3
|
// which keeps the value out of argv / process listings / shell history.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// flag
|
|
5
|
+
// Canonical-first dual-write is the DEFAULT for scoped writes since 0.1.20:
|
|
6
|
+
// when a canonical clone for the target scope is discoverable on this host
|
|
7
|
+
// (via ~/.config/stravica-pa/defaults.json "canonicalClone" or the
|
|
8
|
+
// ~/stravica-canonical/*/vault/<scope>.enc.json convention — see
|
|
9
|
+
// resolveCanonicalCloneTarget in ../vault.mjs), set writes canonical FIRST
|
|
10
|
+
// and workspace SECOND, with the same abort-before-workspace ordering the
|
|
11
|
+
// explicit --canonical-clone flag has always had. Absence of a canonical
|
|
12
|
+
// clone is a NOTICE, never a failure (a host that never had a canonical
|
|
13
|
+
// keeps working); --no-canonical opts out (and suppresses the notice).
|
|
14
|
+
// Explicit --canonical-clone PATH still wins over any auto-discovery.
|
|
15
|
+
//
|
|
16
|
+
// Ordering invariant (pinned by tests): if the canonical write fails for
|
|
17
|
+
// any reason (existing canonical store undecryptable, sops encrypt
|
|
18
|
+
// failure), the command aborts with exit 2 BEFORE touching the workspace
|
|
19
|
+
// copy at all — a device-side vault mutation never round-trips only
|
|
20
|
+
// halfway (see up-path-single-writer-ownership's vault follow-up:
|
|
21
|
+
// "device-side vault mutations must round-trip to canonical immediately").
|
|
12
22
|
|
|
13
23
|
import { readFileSync } from 'node:fs';
|
|
14
24
|
import {
|
|
15
25
|
resolveCommandPaths,
|
|
16
26
|
resolveScopePaths,
|
|
27
|
+
resolveCanonicalCloneTarget,
|
|
17
28
|
checkDeps,
|
|
18
29
|
depInstallMessage,
|
|
19
30
|
checkVault,
|
|
@@ -25,19 +36,21 @@ import {
|
|
|
25
36
|
VaultError,
|
|
26
37
|
} from '../vault.mjs';
|
|
27
38
|
|
|
28
|
-
// --canonical-clone may appear anywhere in argv, matching
|
|
29
|
-
// --file convention in cli.mjs. Local to `set` (not
|
|
30
|
-
// no other command writes to a second target.
|
|
31
|
-
function
|
|
39
|
+
// --canonical-clone / --no-canonical may appear anywhere in argv, matching
|
|
40
|
+
// the --scope/--file convention in cli.mjs. Local to `set` (not global
|
|
41
|
+
// flags) since no other command writes to a second target.
|
|
42
|
+
function extractCanonicalFlags(argv) {
|
|
32
43
|
const rest = [];
|
|
33
44
|
let canonicalClone;
|
|
45
|
+
let noCanonical = false;
|
|
34
46
|
for (let i = 0; i < argv.length; i += 1) {
|
|
35
47
|
const a = argv[i];
|
|
36
48
|
if (a === '--canonical-clone') { canonicalClone = argv[i + 1]; i += 1; continue; }
|
|
37
49
|
if (a.startsWith('--canonical-clone=')) { canonicalClone = a.slice('--canonical-clone='.length); continue; }
|
|
50
|
+
if (a === '--no-canonical') { noCanonical = true; continue; }
|
|
38
51
|
rest.push(a);
|
|
39
52
|
}
|
|
40
|
-
return { canonicalClone, rest };
|
|
53
|
+
return { canonicalClone, noCanonical, rest };
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export async function runSet(args, overrides = {}) {
|
|
@@ -45,15 +58,20 @@ export async function runSet(args, overrides = {}) {
|
|
|
45
58
|
// target vault) whenever --scope/--file wasn't given upstream in
|
|
46
59
|
// cli.mjs — see vault.mjs's default-scope resolution section.
|
|
47
60
|
const paths = resolveCommandPaths(overrides);
|
|
48
|
-
const { canonicalClone, rest } =
|
|
61
|
+
const { canonicalClone, noCanonical, rest } = extractCanonicalFlags(args);
|
|
49
62
|
const key = rest[0];
|
|
50
63
|
let value = rest[1];
|
|
51
64
|
|
|
52
65
|
if (!key || value == null || value === '') {
|
|
53
|
-
console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH] (use `-` as VALUE to read from stdin)');
|
|
66
|
+
console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH | --no-canonical] (use `-` as VALUE to read from stdin)');
|
|
54
67
|
throw new VaultError('usage', 1);
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
if (canonicalClone && noCanonical) {
|
|
71
|
+
console.error('Error: --canonical-clone and --no-canonical are mutually exclusive.');
|
|
72
|
+
throw new VaultError('mutually exclusive canonical flags', 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
if (value === '-') {
|
|
58
76
|
const readStdin = overrides.readStdin ?? (() => readFileSync(0, 'utf-8'));
|
|
59
77
|
const raw = readStdin();
|
|
@@ -79,16 +97,54 @@ export async function runSet(args, overrides = {}) {
|
|
|
79
97
|
throw new VaultError(vaultState.reason || 'vault not initialised', vaultState.exitCode ?? 1);
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
// Canonical-first resolution order (BEFORE any workspace write):
|
|
101
|
+
// 1. Explicit --canonical-clone PATH → wins, always. Bootstraps a
|
|
102
|
+
// missing canonical file (matches the flag's historical behaviour).
|
|
103
|
+
// 2. --no-canonical → skip canonical entirely, no notice, no discovery.
|
|
104
|
+
// 3. Scoped write (paths.scope set) → auto-discover via
|
|
105
|
+
// resolveCanonicalCloneTarget. Hit → dual-write canonical-first,
|
|
106
|
+
// SAME output lines as the explicit-flag path. Miss → workspace-only
|
|
107
|
+
// PLUS a one-line stderr notice naming why nothing resolved.
|
|
108
|
+
// 4. Legacy fallback (no scope) → no canonical resolution, no notice —
|
|
109
|
+
// legacy has no natural canonical mirror.
|
|
110
|
+
let effectiveCanonical = null;
|
|
111
|
+
let canonicalPathsFromOverride = overrides.canonicalPaths || null;
|
|
112
|
+
if (canonicalClone) {
|
|
113
|
+
effectiveCanonical = { file: canonicalClone, source: 'flag' };
|
|
114
|
+
} else if (!noCanonical && paths.scope) {
|
|
115
|
+
// Honour the paths' own `home` first — it carries the caller's
|
|
116
|
+
// injected home end-to-end so test isolation cannot leak into the
|
|
117
|
+
// real ~/stravica-canonical/ scan. Falls back to overrides.home for
|
|
118
|
+
// callers that build paths themselves and only pass home via
|
|
119
|
+
// overrides, then to homedir() for the real CLI at run time.
|
|
120
|
+
const canonicalHome = paths.home ?? overrides.home;
|
|
121
|
+
const resolved = overrides.resolveCanonicalCloneTarget
|
|
122
|
+
? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
|
|
123
|
+
: resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
|
|
124
|
+
if (resolved.file) {
|
|
125
|
+
effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
|
|
126
|
+
} else {
|
|
127
|
+
// Absence notice — never a failure. Explains WHY nothing resolved so
|
|
128
|
+
// an operator on a host without a canonical layout knows whether to
|
|
129
|
+
// provision one, pass --no-canonical to silence, or ignore.
|
|
130
|
+
const tail = resolved.tried && resolved.tried.length > 0
|
|
131
|
+
? ` (tried: ${resolved.tried.join('; ')})`
|
|
132
|
+
: '';
|
|
133
|
+
console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
82
137
|
// Canonical FIRST. Resolved the same way `--file` already works for
|
|
83
138
|
// every other scope-aware command — an arbitrary path, its own
|
|
84
139
|
// .sops.yaml expected alongside it, reusing THIS host's key material
|
|
85
140
|
// (same recipients in practice for a workspace/canonical-clone pair).
|
|
86
141
|
// Any failure here (existing canonical store undecryptable, sops
|
|
87
142
|
// encrypt failure) throws and propagates straight out of runSet —
|
|
88
|
-
// the workspace write below is never reached.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
143
|
+
// the workspace write below is never reached. Ordering pinned by
|
|
144
|
+
// canonical-clone.test.mjs — do not reorder.
|
|
145
|
+
if (effectiveCanonical) {
|
|
146
|
+
const canonicalPaths = canonicalPathsFromOverride || resolveScopePaths({
|
|
147
|
+
file: effectiveCanonical.file,
|
|
92
148
|
keyDir: paths.keyDir,
|
|
93
149
|
keyFile: paths.keyFile,
|
|
94
150
|
});
|
|
@@ -165,6 +165,11 @@ export function defaultPaths(overrides = {}) {
|
|
|
165
165
|
sopsConfig: join(vaultDir, '.sops.yaml'),
|
|
166
166
|
keyDir,
|
|
167
167
|
keyFile: join(keyDir, 'age-key.txt'),
|
|
168
|
+
// Retained on the paths object so downstream consumers (canonical-
|
|
169
|
+
// clone auto-discovery in commands/set.mjs) can honour the SAME home
|
|
170
|
+
// the caller injected, instead of falling back to the real user's
|
|
171
|
+
// homedir() and cross-contaminating test isolation.
|
|
172
|
+
home,
|
|
168
173
|
};
|
|
169
174
|
}
|
|
170
175
|
|
|
@@ -220,6 +225,7 @@ export function resolveScopePaths(opts = {}) {
|
|
|
220
225
|
scope: opts.scope ?? null,
|
|
221
226
|
scoped: true,
|
|
222
227
|
layout: 'file',
|
|
228
|
+
home,
|
|
223
229
|
};
|
|
224
230
|
}
|
|
225
231
|
|
|
@@ -260,6 +266,7 @@ export function resolveScopePaths(opts = {}) {
|
|
|
260
266
|
scope: opts.scope,
|
|
261
267
|
scoped: true,
|
|
262
268
|
layout: 'workspace',
|
|
269
|
+
home,
|
|
263
270
|
};
|
|
264
271
|
}
|
|
265
272
|
|
|
@@ -275,6 +282,7 @@ export function resolveScopePaths(opts = {}) {
|
|
|
275
282
|
scope: opts.scope,
|
|
276
283
|
scoped: true,
|
|
277
284
|
layout: 'canonical',
|
|
285
|
+
home,
|
|
278
286
|
};
|
|
279
287
|
}
|
|
280
288
|
|
|
@@ -332,6 +340,138 @@ function readDefaultsDefaultScope(home) {
|
|
|
332
340
|
}
|
|
333
341
|
}
|
|
334
342
|
|
|
343
|
+
// ── Canonical-clone target resolution (set only) ─────────────────────
|
|
344
|
+
//
|
|
345
|
+
// When `set` runs on a scoped vault WITHOUT --canonical-clone (and WITHOUT
|
|
346
|
+
// --no-canonical), auto-discover the canonical clone's vault file for the
|
|
347
|
+
// resolved scope. Resolution order (each tier tried in turn, first hit
|
|
348
|
+
// wins; every miss recorded in `tried` for the absence-notice detail):
|
|
349
|
+
//
|
|
350
|
+
// 1. "canonicalClone" in ~/.config/stravica-pa/defaults.json — two
|
|
351
|
+
// shapes supported:
|
|
352
|
+
// - object (scope → file map):
|
|
353
|
+
// { "canonicalClone": { "seo-ops": "/abs/path/seo-ops.enc.json" } }
|
|
354
|
+
// - string (root dir): derives <root>/vault/<scope>.enc.json:
|
|
355
|
+
// { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
|
|
356
|
+
// This CLI never writes defaults.json.
|
|
357
|
+
// 2. Estate convention discovery under ~/stravica-canonical/*/vault/
|
|
358
|
+
// <scope>.enc.json — the ~/stravica-canonical/ layout is the
|
|
359
|
+
// established root for canonical clones on every host (see
|
|
360
|
+
// library-sync-cycle's default --repos-root, dex-cutover-driver's
|
|
361
|
+
// reposRoot, and the routine references across projects/). Exactly
|
|
362
|
+
// one match wins; zero matches or multiple matches skips this tier
|
|
363
|
+
// (multiple is recorded as ambiguity so the notice explains WHY the
|
|
364
|
+
// auto-write did not happen).
|
|
365
|
+
//
|
|
366
|
+
// A resolved-but-nonexistent file (config points at a path that isn't
|
|
367
|
+
// there) is treated as a miss with a `tried` line, not a bootstrap — the
|
|
368
|
+
// auto-write path only ever writes to canonicals that already exist.
|
|
369
|
+
// Explicit --canonical-clone still bootstraps missing files (it always did).
|
|
370
|
+
|
|
371
|
+
function readDefaultsCanonicalClone(home) {
|
|
372
|
+
const file = defaultsFilePath(home);
|
|
373
|
+
if (!existsSync(file)) return { file, shape: null, value: null, reason: 'file not present' };
|
|
374
|
+
let parsed;
|
|
375
|
+
try {
|
|
376
|
+
parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
377
|
+
} catch {
|
|
378
|
+
return { file, shape: null, value: null, reason: 'file is not valid JSON' };
|
|
379
|
+
}
|
|
380
|
+
if (!parsed || typeof parsed !== 'object' || !('canonicalClone' in parsed)) {
|
|
381
|
+
return { file, shape: null, value: null, reason: 'canonicalClone key not set' };
|
|
382
|
+
}
|
|
383
|
+
const v = parsed.canonicalClone;
|
|
384
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
385
|
+
return { file, shape: 'root', value: resolve(v.trim()), reason: null };
|
|
386
|
+
}
|
|
387
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
388
|
+
return { file, shape: 'map', value: v, reason: null };
|
|
389
|
+
}
|
|
390
|
+
return { file, shape: null, value: null, reason: 'canonicalClone must be a non-empty string or a scope→file object' };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Directory scan for the estate convention: ~/stravica-canonical/<repo>/
|
|
394
|
+
// vault/<scope>.enc.json (glob-shape ~/stravica-canonical/*/vault/…enc.json;
|
|
395
|
+
// slash-star notation avoided in this comment because JSDoc closes on */).
|
|
396
|
+
// Returns { file, source, detail } on exactly-one hit, or
|
|
397
|
+
// { file: null, reason } describing the miss (none / ambiguous / root
|
|
398
|
+
// absent / unreadable). Never throws — a scan failure is a miss, not a
|
|
399
|
+
// secrets-set failure.
|
|
400
|
+
function discoverConventionalCanonical(home, scope) {
|
|
401
|
+
const root = join(home, 'stravica-canonical');
|
|
402
|
+
if (!existsSync(root)) {
|
|
403
|
+
return { file: null, reason: `no ${root} directory` };
|
|
404
|
+
}
|
|
405
|
+
let entries;
|
|
406
|
+
try {
|
|
407
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
408
|
+
} catch (err) {
|
|
409
|
+
return { file: null, reason: `${root} unreadable (${err && err.code ? err.code : 'error'})` };
|
|
410
|
+
}
|
|
411
|
+
const hits = [];
|
|
412
|
+
for (const ent of entries) {
|
|
413
|
+
if (!ent.isDirectory()) continue;
|
|
414
|
+
const candidate = join(root, ent.name, 'vault', `${scope}.enc.json`);
|
|
415
|
+
if (existsSync(candidate)) hits.push(candidate);
|
|
416
|
+
}
|
|
417
|
+
if (hits.length === 1) {
|
|
418
|
+
return { file: hits[0], source: 'convention', detail: `~/stravica-canonical/*/vault/${scope}.enc.json` };
|
|
419
|
+
}
|
|
420
|
+
if (hits.length === 0) {
|
|
421
|
+
return { file: null, reason: `no ~/stravica-canonical/*/vault/${scope}.enc.json` };
|
|
422
|
+
}
|
|
423
|
+
return { file: null, reason: `ambiguous — ${hits.length} matches under ~/stravica-canonical: ${hits.join(', ')}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolve the canonical clone's vault file for `scope`. Returns
|
|
428
|
+
* { file, source, detail, tried } on hit — file is an absolute path,
|
|
429
|
+
* source is 'defaults-map' |
|
|
430
|
+
* 'defaults-root' | 'convention'.
|
|
431
|
+
* { file: null, tried } on miss — tried is a list of the
|
|
432
|
+
* per-tier miss reasons, in
|
|
433
|
+
* priority order.
|
|
434
|
+
* Never throws. Never a failure — absence is a notice case, not an error.
|
|
435
|
+
* Scope is required; callers must not invoke this on the legacy vault
|
|
436
|
+
* fall-through (no scope → no natural canonical mirror).
|
|
437
|
+
*/
|
|
438
|
+
export function resolveCanonicalCloneTarget(opts = {}) {
|
|
439
|
+
const home = opts.home ?? homedir();
|
|
440
|
+
const scope = opts.scope;
|
|
441
|
+
const tried = [];
|
|
442
|
+
if (!scope) return { file: null, tried: ['no scope resolved (legacy fallback)'] };
|
|
443
|
+
|
|
444
|
+
const defaults = readDefaultsCanonicalClone(home);
|
|
445
|
+
if (defaults.shape === 'map') {
|
|
446
|
+
const entry = defaults.value[scope];
|
|
447
|
+
if (typeof entry === 'string' && entry.trim() !== '') {
|
|
448
|
+
const file = resolve(entry.trim());
|
|
449
|
+
if (existsSync(file)) {
|
|
450
|
+
return { file, source: 'defaults-map', detail: `${defaults.file} → canonicalClone["${scope}"]`, tried };
|
|
451
|
+
}
|
|
452
|
+
tried.push(`${defaults.file} → canonicalClone["${scope}"] = ${file} (path not present)`);
|
|
453
|
+
} else {
|
|
454
|
+
tried.push(`${defaults.file} → canonicalClone map has no entry for "${scope}"`);
|
|
455
|
+
}
|
|
456
|
+
} else if (defaults.shape === 'root') {
|
|
457
|
+
const file = join(defaults.value, 'vault', `${scope}.enc.json`);
|
|
458
|
+
if (existsSync(file)) {
|
|
459
|
+
return { file, source: 'defaults-root', detail: `${defaults.file} → canonicalClone root ${defaults.value}`, tried };
|
|
460
|
+
}
|
|
461
|
+
tried.push(`${defaults.file} → canonicalClone root ${defaults.value} → ${file} (path not present)`);
|
|
462
|
+
} else {
|
|
463
|
+
tried.push(`${defaults.file} canonicalClone: ${defaults.reason}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const conv = discoverConventionalCanonical(home, scope);
|
|
467
|
+
if (conv.file) {
|
|
468
|
+
return { file: conv.file, source: conv.source, detail: conv.detail, tried };
|
|
469
|
+
}
|
|
470
|
+
tried.push(`convention: ${conv.reason}`);
|
|
471
|
+
|
|
472
|
+
return { file: null, tried };
|
|
473
|
+
}
|
|
474
|
+
|
|
335
475
|
/**
|
|
336
476
|
* Walk up from `cwd` for the nearest ancestor directory that sits under
|
|
337
477
|
* an "agents" path segment AND contains identity.json. Returns the file
|
package/package.json
CHANGED