@stravica-ai/digital-operator-pa 0.1.31 → 0.1.33
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/docs/gsc.md +47 -35
- package/dist/engineering-pa-cli/docs/staging-alias.md +5 -2
- package/dist/engineering-pa-cli/src/gsc/cli.mjs +13 -8
- package/dist/engineering-pa-cli/src/gsc/cloudflare.mjs +88 -87
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +20 -9
- package/dist/engineering-pa-cli/src/staging-alias/sites.mjs +4 -2
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +22 -10
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +32 -17
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +69 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +17 -0
- package/dist/engineering-pa-library-cli/src/verbs/refresh-clones.mjs +153 -0
- package/package.json +1 -1
|
@@ -52,34 +52,34 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
|
|
|
52
52
|
single-file vault. Store the key in the pot the host is already
|
|
53
53
|
configured to read; no `pa gsc`-specific configuration is needed.
|
|
54
54
|
|
|
55
|
-
6. **Store Cloudflare credentials** (also one-time).
|
|
56
|
-
|
|
55
|
+
6. **Store Cloudflare credentials** (also one-time). Each CF account is
|
|
56
|
+
addressed by a suffixed vault-key pair. `--account <name>` is required
|
|
57
|
+
on every invocation — the unsuffixed bare pair that used to be a
|
|
58
|
+
single-account default was dropped in the 2026-09-01 rename
|
|
59
|
+
(`w-2026-08-31-dave-010`).
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
pnpm exec secrets set CLOUDFLARE_PAGES_API_KEY <token>
|
|
61
|
-
pnpm exec secrets set CLOUDFLARE_PAGES_ACCOUNT_ID <account-id>
|
|
62
|
-
```
|
|
61
|
+
Pick the surface segment that matches how the token is actually
|
|
62
|
+
scoped in Cloudflare:
|
|
63
63
|
|
|
64
|
-
**Multi-account setup** — multiple CF accounts, each addressed by name:
|
|
65
64
|
```bash
|
|
66
|
-
# Stravica account
|
|
67
|
-
pnpm exec secrets set
|
|
68
|
-
pnpm exec secrets set
|
|
65
|
+
# Stravica account (Workers Scripts + D1) → --account stravica
|
|
66
|
+
pnpm exec secrets set CLOUDFLARE_WORKERS_API_TOKEN_STRAVICA <token>
|
|
67
|
+
pnpm exec secrets set CLOUDFLARE_WORKERS_ACCOUNT_ID_STRAVICA <account-id>
|
|
69
68
|
|
|
70
|
-
# Clive account
|
|
71
|
-
pnpm exec secrets set
|
|
69
|
+
# Clive account (Pages + zone purge) → --account clive
|
|
70
|
+
pnpm exec secrets set CLOUDFLARE_PAGES_API_TOKEN_CLIVE <token>
|
|
72
71
|
pnpm exec secrets set CLOUDFLARE_PAGES_ACCOUNT_ID_CLIVE <account-id>
|
|
73
72
|
```
|
|
74
73
|
|
|
75
|
-
The CF API token needs
|
|
76
|
-
|
|
77
|
-
tokens also work.
|
|
74
|
+
The CF API token needs the permissions its surface implies (for DNS
|
|
75
|
+
planting: `Zone:DNS:Edit` and `Zone:Zone:Read` on the target zones;
|
|
76
|
+
account-level "Write to all resources" tokens also work).
|
|
78
77
|
|
|
79
|
-
For back-compat, the
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
For back-compat, the deprecated `CLOUDFLARE_PAGES_API_KEY_<NAME>` /
|
|
79
|
+
`CLOUDFLARE_PAGES_ACCOUNT_ID_<NAME>` pair is still accepted, and a
|
|
80
|
+
hit emits a stderr deprecation warning naming the current pair. Rename
|
|
81
|
+
the vault entries at your convenience — the deprecated pair will be
|
|
82
|
+
removed in a follow-up.
|
|
83
83
|
|
|
84
84
|
Same scoped-vault note as the SA key: on scoped hosts these live in
|
|
85
85
|
whichever pot `secrets get` reads (see step 5); use `secrets set --scope
|
|
@@ -134,26 +134,38 @@ pnpm exec pa gsc list-properties
|
|
|
134
134
|
|
|
135
135
|
## Multi-account selection
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
the
|
|
139
|
-
|
|
137
|
+
`--account <name>` is required on every invocation. The tool upper-cases
|
|
138
|
+
the name and tries the candidate vault-key pairs below in order — the
|
|
139
|
+
first fully-present pair wins. Storing multiple pairs for the same
|
|
140
|
+
account is a supported migration state; the tool always picks the
|
|
141
|
+
highest-priority pair that resolves.
|
|
140
142
|
|
|
141
143
|
```
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
--account
|
|
145
|
-
|
|
144
|
+
1. CLOUDFLARE_WORKERS_API_TOKEN_<NAME>
|
|
145
|
+
CLOUDFLARE_WORKERS_ACCOUNT_ID_<NAME>
|
|
146
|
+
Workers surface — Workers Scripts, D1, KV. Used by --account stravica.
|
|
147
|
+
|
|
148
|
+
2. CLOUDFLARE_PAGES_API_TOKEN_<NAME>
|
|
149
|
+
CLOUDFLARE_PAGES_ACCOUNT_ID_<NAME>
|
|
150
|
+
Pages surface — Pages + zone purge. Used by --account clive.
|
|
151
|
+
|
|
152
|
+
3. CLOUDFLARE_PAGES_API_KEY_<NAME>
|
|
153
|
+
CLOUDFLARE_PAGES_ACCOUNT_ID_<NAME>
|
|
154
|
+
Deprecated pre-2026-09-01 naming. Accepted with a stderr warning;
|
|
155
|
+
will be removed in a follow-up. Rename the vault entries at your
|
|
156
|
+
convenience.
|
|
146
157
|
```
|
|
147
158
|
|
|
148
|
-
If `--account` is passed but the
|
|
149
|
-
fails fast with a message
|
|
150
|
-
|
|
151
|
-
the safety the flag exists
|
|
159
|
+
If `--account` is passed but none of the candidate pairs are fully set,
|
|
160
|
+
the command fails fast with a message naming the primary (WORKERS) pair
|
|
161
|
+
you're expected to store. It never silently falls back to any pair not
|
|
162
|
+
addressed by the suffix — that would defeat the safety the flag exists
|
|
163
|
+
to provide.
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
The unsuffixed bare pair (`CLOUDFLARE_PAGES_API_KEY` /
|
|
166
|
+
`CLOUDFLARE_PAGES_ACCOUNT_ID`) and the very-legacy
|
|
167
|
+
`CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` pair are no longer
|
|
168
|
+
read — `--account <name>` is mandatory.
|
|
157
169
|
|
|
158
170
|
The account name accepts letters and digits only — anything else is a
|
|
159
171
|
usage error.
|
|
@@ -98,8 +98,11 @@ Edit `src/staging-alias/sites.mjs`. Add one entry:
|
|
|
98
98
|
},
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
…and store the vault keys
|
|
102
|
-
`
|
|
101
|
+
…and store the vault keys under the surface segment that matches how
|
|
102
|
+
the token is scoped: `CLOUDFLARE_WORKERS_API_TOKEN_NEWSITE` +
|
|
103
|
+
`CLOUDFLARE_WORKERS_ACCOUNT_ID_NEWSITE` (Workers surface), or
|
|
104
|
+
`CLOUDFLARE_PAGES_API_TOKEN_NEWSITE` + `CLOUDFLARE_PAGES_ACCOUNT_ID_NEWSITE`
|
|
105
|
+
(Pages surface). See `packages/cli/docs/gsc.md` for the candidate order.
|
|
103
106
|
|
|
104
107
|
## Exit codes
|
|
105
108
|
|
|
@@ -53,14 +53,19 @@ Auth (one-time setup; see docs/gsc.md):
|
|
|
53
53
|
GSC_SERVICE_ACCOUNT_KEY (entire JSON contents as the value).
|
|
54
54
|
- SA email added as a user on each GSC property via the GSC dashboard.
|
|
55
55
|
|
|
56
|
-
Cloudflare DNS planting reads two vault keys.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
Cloudflare DNS planting reads two vault keys. --account <name> is
|
|
57
|
+
required (letters/digits only). The suffix is upper-cased and each
|
|
58
|
+
candidate pair below is tried in order — first fully-present pair wins.
|
|
59
|
+
|
|
60
|
+
1. CLOUDFLARE_WORKERS_API_TOKEN_<NAME> +
|
|
61
|
+
CLOUDFLARE_WORKERS_ACCOUNT_ID_<NAME>
|
|
62
|
+
(Workers surface — e.g. --account stravica)
|
|
63
|
+
2. CLOUDFLARE_PAGES_API_TOKEN_<NAME> +
|
|
64
|
+
CLOUDFLARE_PAGES_ACCOUNT_ID_<NAME>
|
|
65
|
+
(Pages surface — e.g. --account clive)
|
|
66
|
+
3. CLOUDFLARE_PAGES_API_KEY_<NAME> +
|
|
67
|
+
CLOUDFLARE_PAGES_ACCOUNT_ID_<NAME>
|
|
68
|
+
(deprecated pre-2026-09-01 naming; hit emits a stderr warning)
|
|
64
69
|
`;
|
|
65
70
|
|
|
66
71
|
export async function run(argv, overrides = {}) {
|
|
@@ -5,22 +5,29 @@
|
|
|
5
5
|
// 2. find an existing TXT record by name (idempotency)
|
|
6
6
|
// 3. create or update a TXT record
|
|
7
7
|
//
|
|
8
|
-
// Credentials are read from the SOPS vault. The vault holds
|
|
9
|
-
//
|
|
8
|
+
// Credentials are read from the SOPS vault. The vault holds account-suffixed
|
|
9
|
+
// pairs — the caller picks the suffix via the `account` option (lower-cased
|
|
10
|
+
// externally; matched case-insensitively to the upper-case suffix).
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// CLOUDFLARE_PAGES_ACCOUNT_ID_<SUFFIX>
|
|
14
|
-
// The caller picks the suffix via the `account` option (lower-cased
|
|
15
|
-
// externally; matched case-insensitively to the upper-case suffix).
|
|
12
|
+
// Vault-key shapes tried in order per account (2026-09-01 rename,
|
|
13
|
+
// w-2026-08-31-dave-010; see vault-key-rename-plan-2026-08-31.md):
|
|
16
14
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
15
|
+
// 1. Workers surface (Workers Scripts / D1 / KV) — e.g. Stravica:
|
|
16
|
+
// CLOUDFLARE_WORKERS_API_TOKEN_<SUFFIX>
|
|
17
|
+
// CLOUDFLARE_WORKERS_ACCOUNT_ID_<SUFFIX>
|
|
20
18
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
19
|
+
// 2. Pages surface (Pages + zone purge) — e.g. Clive:
|
|
20
|
+
// CLOUDFLARE_PAGES_API_TOKEN_<SUFFIX>
|
|
21
|
+
// CLOUDFLARE_PAGES_ACCOUNT_ID_<SUFFIX>
|
|
22
|
+
//
|
|
23
|
+
// 3. Deprecated (pre-2026-09-01) — accepted with a stderr warning during
|
|
24
|
+
// the transition window; will be removed in a follow-up:
|
|
25
|
+
// CLOUDFLARE_PAGES_API_KEY_<SUFFIX>
|
|
26
|
+
// CLOUDFLARE_PAGES_ACCOUNT_ID_<SUFFIX>
|
|
27
|
+
//
|
|
28
|
+
// The unsuffixed bare pair (CLOUDFLARE_PAGES_API_KEY / CLOUDFLARE_PAGES_
|
|
29
|
+
// ACCOUNT_ID) that used to be accepted as a single-account default was
|
|
30
|
+
// removed in this same rename — `--account <name>` is now required.
|
|
24
31
|
//
|
|
25
32
|
// `fetchImpl` and `now` are injectable for tests.
|
|
26
33
|
|
|
@@ -28,18 +35,23 @@ import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultE
|
|
|
28
35
|
|
|
29
36
|
const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
30
37
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
// Ordered candidate templates. First fully-present pair wins per account.
|
|
39
|
+
// The `deprecated` flag drives the stderr warning + return-value flag.
|
|
40
|
+
const CANDIDATE_TEMPLATES = Object.freeze([
|
|
41
|
+
Object.freeze({
|
|
42
|
+
tokenTemplate: 'CLOUDFLARE_WORKERS_API_TOKEN_<S>',
|
|
43
|
+
accountIdTemplate: 'CLOUDFLARE_WORKERS_ACCOUNT_ID_<S>',
|
|
44
|
+
}),
|
|
45
|
+
Object.freeze({
|
|
46
|
+
tokenTemplate: 'CLOUDFLARE_PAGES_API_TOKEN_<S>',
|
|
47
|
+
accountIdTemplate: 'CLOUDFLARE_PAGES_ACCOUNT_ID_<S>',
|
|
48
|
+
}),
|
|
49
|
+
Object.freeze({
|
|
50
|
+
tokenTemplate: 'CLOUDFLARE_PAGES_API_KEY_<S>',
|
|
51
|
+
accountIdTemplate: 'CLOUDFLARE_PAGES_ACCOUNT_ID_<S>',
|
|
52
|
+
deprecated: true,
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
43
55
|
|
|
44
56
|
/**
|
|
45
57
|
* Normalise an `--account` value to the upper-case suffix used in the
|
|
@@ -65,28 +77,32 @@ export function accountSuffix(account) {
|
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
/**
|
|
68
|
-
* Build the vault
|
|
69
|
-
*
|
|
70
|
-
*
|
|
80
|
+
* Build the ordered list of vault-key candidate pairs a given `account`
|
|
81
|
+
* selection resolves to. Callers try candidates in order — first fully-
|
|
82
|
+
* present pair wins.
|
|
71
83
|
*
|
|
72
|
-
* @param {string}
|
|
73
|
-
*
|
|
84
|
+
* @param {string} account Required — the unsuffixed bare pair was dropped
|
|
85
|
+
* in the 2026-09-01 rename (w-2026-08-31-dave-010).
|
|
86
|
+
* @returns {{ candidates: Array<{tokenKey: string, accountIdKey: string, deprecated?: boolean}> }}
|
|
74
87
|
*/
|
|
75
88
|
export function resolveVaultKeys(account) {
|
|
76
|
-
if (account) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
if (!account) {
|
|
90
|
+
throw new VaultError(
|
|
91
|
+
'--account <name> is required (e.g. --account stravica). ' +
|
|
92
|
+
'The unsuffixed vault-key fallback was removed in the 2026-09-01 rename.',
|
|
93
|
+
1,
|
|
94
|
+
);
|
|
82
95
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const s = accountSuffix(account);
|
|
97
|
+
const candidates = CANDIDATE_TEMPLATES.map((tpl) => {
|
|
98
|
+
const entry = {
|
|
99
|
+
tokenKey: tpl.tokenTemplate.replace('<S>', s),
|
|
100
|
+
accountIdKey: tpl.accountIdTemplate.replace('<S>', s),
|
|
101
|
+
};
|
|
102
|
+
if (tpl.deprecated) entry.deprecated = true;
|
|
103
|
+
return entry;
|
|
104
|
+
});
|
|
105
|
+
return { candidates };
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
/**
|
|
@@ -95,19 +111,18 @@ export function resolveVaultKeys(account) {
|
|
|
95
111
|
*
|
|
96
112
|
* Resolution order:
|
|
97
113
|
* 1. Test overrides (`cloudflareToken` + `cloudflareAccountId`).
|
|
98
|
-
* 2.
|
|
99
|
-
*
|
|
114
|
+
* 2. `--account` is required. Try each candidate pair in order; first
|
|
115
|
+
* fully-present pair wins. A hit on a deprecated pair emits a
|
|
116
|
+
* stderr warning (unless `warn` is overridden for tests).
|
|
100
117
|
*
|
|
101
118
|
* @param {object} overrides
|
|
102
119
|
* @param {object} [overrides.paths]
|
|
103
120
|
* @param {string} [overrides.cloudflareToken]
|
|
104
121
|
* @param {string} [overrides.cloudflareAccountId]
|
|
105
|
-
* @param {string} [overrides.account] Account selector (e.g. "stravica").
|
|
122
|
+
* @param {string} [overrides.account] Account selector (e.g. "stravica"). Required unless raw creds supplied.
|
|
106
123
|
* @param {object} [overrides.vaultData] Pre-decrypted vault map (test seam).
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* fallback paths without touching the filesystem.
|
|
110
|
-
* @returns {Promise<{token: string, accountId: string, account: string|null, vaultKeys: {tokenKey: string, accountIdKey: string}}>}
|
|
124
|
+
* @param {Function} [overrides.warn] Test seam for the deprecation warning (defaults to writing to stderr).
|
|
125
|
+
* @returns {Promise<{token: string, accountId: string, account: string|null, vaultKeys: {tokenKey: string, accountIdKey: string, deprecated?: boolean}}>}
|
|
111
126
|
*/
|
|
112
127
|
export async function loadCloudflareCreds(overrides = {}) {
|
|
113
128
|
if (overrides.cloudflareToken && overrides.cloudflareAccountId) {
|
|
@@ -118,6 +133,9 @@ export async function loadCloudflareCreds(overrides = {}) {
|
|
|
118
133
|
vaultKeys: null,
|
|
119
134
|
};
|
|
120
135
|
}
|
|
136
|
+
|
|
137
|
+
const plan = resolveVaultKeys(overrides.account);
|
|
138
|
+
|
|
121
139
|
let data;
|
|
122
140
|
if (overrides.vaultData) {
|
|
123
141
|
data = overrides.vaultData;
|
|
@@ -143,50 +161,33 @@ export async function loadCloudflareCreds(overrides = {}) {
|
|
|
143
161
|
data = await sopsDecrypt(paths, paths.secretsFile);
|
|
144
162
|
}
|
|
145
163
|
|
|
146
|
-
const
|
|
164
|
+
const warn = overrides.warn || ((msg) => process.stderr.write(`${msg}\n`));
|
|
147
165
|
|
|
148
|
-
|
|
149
|
-
if (overrides.account) {
|
|
150
|
-
const token = data[plan.tokenKey];
|
|
151
|
-
const accountId = data[plan.accountIdKey];
|
|
152
|
-
if (!token || !accountId) {
|
|
153
|
-
const missing = [];
|
|
154
|
-
if (!token) missing.push(plan.tokenKey);
|
|
155
|
-
if (!accountId) missing.push(plan.accountIdKey);
|
|
156
|
-
throw new VaultError(
|
|
157
|
-
`--account "${overrides.account}" expects vault keys [${missing.join(', ')}] but they are not set. ` +
|
|
158
|
-
`Store them with:\n secrets set ${plan.tokenKey} <token>\n secrets set ${plan.accountIdKey} <id>`,
|
|
159
|
-
1,
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
return {
|
|
163
|
-
token,
|
|
164
|
-
accountId,
|
|
165
|
-
account: overrides.account,
|
|
166
|
-
vaultKeys: { tokenKey: plan.tokenKey, accountIdKey: plan.accountIdKey },
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// No --account: try unsuffixed pair first, then legacy bare pair.
|
|
171
|
-
const candidates = [
|
|
172
|
-
{ tokenKey: plan.tokenKey, accountIdKey: plan.accountIdKey },
|
|
173
|
-
...(plan.fallbacks || []),
|
|
174
|
-
];
|
|
175
|
-
for (const cand of candidates) {
|
|
166
|
+
for (const cand of plan.candidates) {
|
|
176
167
|
const token = data[cand.tokenKey];
|
|
177
168
|
const accountId = data[cand.accountIdKey];
|
|
178
169
|
if (token && accountId) {
|
|
179
|
-
|
|
170
|
+
if (cand.deprecated) {
|
|
171
|
+
warn(
|
|
172
|
+
`warning: Cloudflare vault keys [${cand.tokenKey}, ${cand.accountIdKey}] are deprecated ` +
|
|
173
|
+
`(pre-2026-09-01 naming). Rename to the current pair — see ` +
|
|
174
|
+
`packages/cli/docs/gsc.md for the current key shapes.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
token,
|
|
179
|
+
accountId,
|
|
180
|
+
account: overrides.account,
|
|
181
|
+
vaultKeys: { ...cand },
|
|
182
|
+
};
|
|
180
183
|
}
|
|
181
184
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// a thing we advertise.
|
|
185
|
+
|
|
186
|
+
const primary = plan.candidates[0];
|
|
185
187
|
throw new VaultError(
|
|
186
|
-
|
|
187
|
-
`
|
|
188
|
-
` secrets set ${
|
|
189
|
-
` secrets set ${VAULT_KEYS.accountIdUnsuffixed} <id>`,
|
|
188
|
+
`--account "${overrides.account}" expects vault keys [${primary.tokenKey}, ${primary.accountIdKey}] ` +
|
|
189
|
+
`(or one of the fallback pairs listed in packages/cli/docs/gsc.md) but none are set. ` +
|
|
190
|
+
`Store them with:\n secrets set ${primary.tokenKey} <token>\n secrets set ${primary.accountIdKey} <id>`,
|
|
190
191
|
1,
|
|
191
192
|
);
|
|
192
193
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
27
|
import { existsSync } from 'node:fs';
|
|
28
28
|
import { join, resolve, dirname } from 'node:path';
|
|
29
|
+
import { resolveExtraScanRoots } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
|
|
29
30
|
|
|
30
31
|
const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
31
32
|
|
|
@@ -637,15 +638,25 @@ function guidedMissHint(graph, term) {
|
|
|
637
638
|
async function readBody(repoRoot, id) {
|
|
638
639
|
// The id is either a path relative to the repo root, or a bare slug.
|
|
639
640
|
// We try repoRoot/<id> first, then repoRoot/operator/knowledge/<id>.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
641
|
+
//
|
|
642
|
+
// Extra scan roots (Wave H, w-2026-08-24-dave-033): a doc merged into the
|
|
643
|
+
// graph from a pull-clone at `~/stravica-library-clones/<tier>/` has an id
|
|
644
|
+
// shaped `operator/knowledge/<domain>/<file>.md` but is not resolvable
|
|
645
|
+
// under the home repoRoot. The extra scan roots — same env var the
|
|
646
|
+
// builders read — are checked in declared order after the home root, so
|
|
647
|
+
// `--full` body search still finds the file.
|
|
648
|
+
const roots = [repoRoot, ...resolveExtraScanRoots()];
|
|
649
|
+
for (const root of roots) {
|
|
650
|
+
const candidates = [
|
|
651
|
+
resolve(root, id),
|
|
652
|
+
resolve(root, 'operator', 'knowledge', id),
|
|
653
|
+
];
|
|
654
|
+
for (const path of candidates) {
|
|
655
|
+
if (existsSync(path)) {
|
|
656
|
+
try {
|
|
657
|
+
return await readFile(path, 'utf8');
|
|
658
|
+
} catch { return null; }
|
|
659
|
+
}
|
|
649
660
|
}
|
|
650
661
|
}
|
|
651
662
|
return null;
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
//
|
|
17
17
|
// `accountKey` is the vault-key suffix (lowercase form passed to
|
|
18
18
|
// `loadCloudflareCreds({ account })`). The CF helper upper-cases it
|
|
19
|
-
// internally
|
|
20
|
-
//
|
|
19
|
+
// internally and tries the current candidate templates in order —
|
|
20
|
+
// CLOUDFLARE_WORKERS_API_TOKEN_<SUFFIX>, then CLOUDFLARE_PAGES_API_
|
|
21
|
+
// TOKEN_<SUFFIX>, then the deprecated CLOUDFLARE_PAGES_API_KEY_<SUFFIX>
|
|
22
|
+
// (see cloudflare.mjs for the full list and gsc.md for the rationale).
|
|
21
23
|
|
|
22
24
|
/** @typedef {{
|
|
23
25
|
* hostname: string,
|
|
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
27
27
|
import { realpathSync } from 'node:fs';
|
|
28
28
|
import { join, resolve, dirname, relative } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
|
-
import { buildFileIndex, extractLinks,
|
|
30
|
+
import { buildFileIndex, extractLinks, scanSourcesFor, toPosix, walkFiles } from './links.mjs';
|
|
31
31
|
import { writeJsonAtomic } from './write-atomic.mjs';
|
|
32
32
|
|
|
33
33
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -64,23 +64,35 @@ Output: operator/knowledge/.backlinks.json
|
|
|
64
64
|
* writes a minimal one. Readers treat it as optional either way.
|
|
65
65
|
* @returns {Promise<{outPath:string, targetCount:number, unresolved:object[]}>}
|
|
66
66
|
*/
|
|
67
|
-
export async function buildBacklinkIndex({ root, coverage = null }) {
|
|
67
|
+
export async function buildBacklinkIndex({ root, coverage = null, extraRoots }) {
|
|
68
68
|
// Pass 1 — enumerate. `[[slug]]` cannot be resolved without knowing which
|
|
69
69
|
// files exist, so the whole tree is collected before any link is read.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
//
|
|
71
|
+
// Sources: home root first, then env-supplied extra scan roots (Wave H,
|
|
72
|
+
// w-2026-08-24-dave-033). Files are relativised against THEIR source root
|
|
73
|
+
// so ids are shape-compatible across the flat namespace. Home-wins on
|
|
74
|
+
// collision.
|
|
75
|
+
const sources = scanSourcesFor(root, { extraRoots });
|
|
76
|
+
const fileEntries = [];
|
|
77
|
+
const seenIds = new Set();
|
|
78
|
+
for (const { sourceRoot, scanDirs } of sources) {
|
|
79
|
+
for (const dir of scanDirs) {
|
|
80
|
+
for (const abs of await walkFiles(dir)) {
|
|
81
|
+
const id = toPosix(relative(sourceRoot, abs));
|
|
82
|
+
if (seenIds.has(id)) continue;
|
|
83
|
+
seenIds.add(id);
|
|
84
|
+
fileEntries.push({ abs, id });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
|
-
const
|
|
75
|
-
const fileIndex = buildFileIndex(fileIds);
|
|
88
|
+
const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
|
|
76
89
|
|
|
77
90
|
// Pass 2 — build backlinks map: target -> [sources]
|
|
78
91
|
const backlinks = {};
|
|
79
92
|
const unresolved = [];
|
|
80
93
|
|
|
81
|
-
for (
|
|
82
|
-
const content = await readFile(
|
|
83
|
-
const relSource = fileIds[i];
|
|
94
|
+
for (const { abs, id: relSource } of fileEntries) {
|
|
95
|
+
const content = await readFile(abs, 'utf8');
|
|
84
96
|
const result = extractLinks(content, relSource, fileIndex);
|
|
85
97
|
unresolved.push(...result.unresolved);
|
|
86
98
|
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
deriveDomain,
|
|
25
25
|
extractLinks,
|
|
26
26
|
isIndexJson,
|
|
27
|
-
|
|
27
|
+
scanSourcesFor,
|
|
28
28
|
toPosix,
|
|
29
29
|
walkFiles,
|
|
30
30
|
} from './links.mjs';
|
|
@@ -87,20 +87,38 @@ function reportUnresolved(label, entries) {
|
|
|
87
87
|
* @returns {Promise<object>} outPath, counts, diagnostics and the unresolved
|
|
88
88
|
* entries the CLI reports
|
|
89
89
|
*/
|
|
90
|
-
export async function buildKnowledgeGraph({ root, coverage = null }) {
|
|
91
|
-
|
|
90
|
+
export async function buildKnowledgeGraph({ root, coverage = null, extraRoots }) {
|
|
91
|
+
// Sources: home root first, then each extra scan root (env-supplied by
|
|
92
|
+
// default). Files from an extra root are relativised against THEIR root,
|
|
93
|
+
// so their ids are still shaped `operator/knowledge/…` and merge into the
|
|
94
|
+
// same flat namespace as home. Home wins on collision (first-in policy).
|
|
95
|
+
const sources = scanSourcesFor(root, { extraRoots });
|
|
92
96
|
|
|
93
97
|
// ── Pass 1: enumerate the tree ────────────────────────────────────────
|
|
94
98
|
// Resolution needs the whole file set before any link is interpreted —
|
|
95
99
|
// `[[slug]]` cannot be resolved without knowing which files exist.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
//
|
|
101
|
+
// Each entry pairs the on-disk path with the source root it belongs to,
|
|
102
|
+
// so pass 2 can read the file from disk while emitting a source-relative
|
|
103
|
+
// id. Ids that collide keep the first occurrence (home-wins).
|
|
104
|
+
const fileEntries = [];
|
|
105
|
+
const indexEntries = [];
|
|
106
|
+
const seenIds = new Set();
|
|
107
|
+
for (const { sourceRoot, scanDirs } of sources) {
|
|
108
|
+
for (const dir of scanDirs) {
|
|
109
|
+
for (const abs of await walkFiles(dir)) {
|
|
110
|
+
const id = toPosix(relative(sourceRoot, abs));
|
|
111
|
+
if (seenIds.has(id)) continue;
|
|
112
|
+
seenIds.add(id);
|
|
113
|
+
fileEntries.push({ abs, id });
|
|
114
|
+
}
|
|
115
|
+
for (const abs of await walkFiles(dir, isIndexJson)) {
|
|
116
|
+
const id = toPosix(relative(sourceRoot, abs));
|
|
117
|
+
indexEntries.push({ abs, id });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
101
120
|
}
|
|
102
|
-
const
|
|
103
|
-
const fileIndex = buildFileIndex(fileIds);
|
|
121
|
+
const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
|
|
104
122
|
|
|
105
123
|
// ── Pass 2: nodes + forward edges ─────────────────────────────────────
|
|
106
124
|
const nodesMap = new Map();
|
|
@@ -115,10 +133,8 @@ export async function buildKnowledgeGraph({ root, coverage = null }) {
|
|
|
115
133
|
edges.push({ from, to, type });
|
|
116
134
|
};
|
|
117
135
|
|
|
118
|
-
for (
|
|
119
|
-
const
|
|
120
|
-
const relPath = fileIds[i];
|
|
121
|
-
const content = await readFile(filePath, 'utf8');
|
|
136
|
+
for (const { abs, id: relPath } of fileEntries) {
|
|
137
|
+
const content = await readFile(abs, 'utf8');
|
|
122
138
|
const title = extractTitle(content) || relPath.split('/').pop().replace('.md', '');
|
|
123
139
|
|
|
124
140
|
nodesMap.set(relPath, { id: relPath, title, domain: deriveDomain(relPath) });
|
|
@@ -130,8 +146,7 @@ export async function buildKnowledgeGraph({ root, coverage = null }) {
|
|
|
130
146
|
|
|
131
147
|
// ── index.json hubs ───────────────────────────────────────────────────
|
|
132
148
|
const indexFiles = [];
|
|
133
|
-
for (const indexPath of
|
|
134
|
-
const id = toPosix(relative(root, indexPath));
|
|
149
|
+
for (const { abs: indexPath, id } of indexEntries) {
|
|
135
150
|
let data;
|
|
136
151
|
try {
|
|
137
152
|
data = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
@@ -180,7 +195,7 @@ export async function buildKnowledgeGraph({ root, coverage = null }) {
|
|
|
180
195
|
version: '1.0',
|
|
181
196
|
generatedAt: new Date().toISOString(),
|
|
182
197
|
diagnostics: {
|
|
183
|
-
markdownFiles:
|
|
198
|
+
markdownFiles: fileEntries.length,
|
|
184
199
|
indexHubs: {
|
|
185
200
|
files: indexFiles.length,
|
|
186
201
|
entries: hubs.entryCount,
|
|
@@ -39,6 +39,50 @@ import { join, normalize, posix } from 'node:path';
|
|
|
39
39
|
// Knowledge trees the builders scan, repo-root-relative.
|
|
40
40
|
export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
|
|
41
41
|
|
|
42
|
+
// Environment variable that names ADDITIONAL scan roots beyond the workspace
|
|
43
|
+
// root. Colon-separated absolute paths, each pointing at a directory shaped
|
|
44
|
+
// like a workspace root (i.e. containing an `operator/knowledge/` subtree).
|
|
45
|
+
//
|
|
46
|
+
// Motivation (Wave H, w-2026-08-24-dave-033): the REMOTE-mode read path is
|
|
47
|
+
// `~/stravica-library-clones/<tier>/`. Each tier repo carries its own
|
|
48
|
+
// `operator/knowledge/**` tree, and canonical knowledge lives in those tier
|
|
49
|
+
// repos — not in the home workspace. Without extra scan roots, `pa knowledge`
|
|
50
|
+
// on an operator host misses every canonical doc. Extra roots let the builders
|
|
51
|
+
// merge tier-repo content into the SAME graph the home tree produces, so node
|
|
52
|
+
// ids are still `operator/knowledge/<domain>/<file>.md` regardless of which
|
|
53
|
+
// root supplied the file.
|
|
54
|
+
//
|
|
55
|
+
// Collision policy: FIRST WINS. The home workspace root is scanned first, so
|
|
56
|
+
// a doc present in both home and a tier clone keeps the home copy. This
|
|
57
|
+
// matches the retirement's repatriation rule ("HOME-REPO copy wins for
|
|
58
|
+
// estate-native classes"); the intent is that canonical docs only ever live
|
|
59
|
+
// on ONE side, and any collision is a symptom that needs resolving upstream.
|
|
60
|
+
export const EXTRA_SCAN_ROOTS_ENV = 'LIBRARY_KNOWLEDGE_EXTRA_ROOTS';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read + normalise the extra scan roots from the environment. Returns an
|
|
64
|
+
* array of absolute paths (deduped, in declared order). Missing / empty
|
|
65
|
+
* entries are silently skipped so a stray colon never manufactures a bogus
|
|
66
|
+
* root.
|
|
67
|
+
*
|
|
68
|
+
* @param {NodeJS.ProcessEnv} [env] defaults to process.env
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function resolveExtraScanRoots(env = process.env) {
|
|
72
|
+
const raw = env[EXTRA_SCAN_ROOTS_ENV];
|
|
73
|
+
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const part of raw.split(':')) {
|
|
77
|
+
const trimmed = part.trim();
|
|
78
|
+
if (!trimmed) continue;
|
|
79
|
+
if (seen.has(trimmed)) continue;
|
|
80
|
+
seen.add(trimmed);
|
|
81
|
+
out.push(trimmed);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
const WIKI_RE = /\[\[([^\]]+)\]\]/g;
|
|
43
87
|
const MD_LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
44
88
|
const EXTERNAL_RE = /^(?:https?:\/\/|mailto:|#)/;
|
|
@@ -120,6 +164,31 @@ export function scanDirsFor(root) {
|
|
|
120
164
|
return KNOWLEDGE_SCAN_DIRS.map((rel) => join(root, ...rel.split('/')));
|
|
121
165
|
}
|
|
122
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Full scan-source list: the home workspace root first, then every extra
|
|
169
|
+
* scan root from the environment. Each entry pairs a source root with the
|
|
170
|
+
* scan dirs it contributes, so callers relativise ids against the SOURCE
|
|
171
|
+
* root (yielding `operator/knowledge/…` shaped ids regardless of where on
|
|
172
|
+
* disk the file lives).
|
|
173
|
+
*
|
|
174
|
+
* Home comes first so the first-wins collision policy resolves in favour
|
|
175
|
+
* of the home workspace copy.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} root the home workspace root
|
|
178
|
+
* @param {object} [opts]
|
|
179
|
+
* @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
|
|
180
|
+
* @param {string[]} [opts.extraRoots] override the env-resolved list (tests)
|
|
181
|
+
* @returns {{ sourceRoot: string, scanDirs: string[] }[]}
|
|
182
|
+
*/
|
|
183
|
+
export function scanSourcesFor(root, opts = {}) {
|
|
184
|
+
const extras = opts.extraRoots ?? resolveExtraScanRoots(opts.env);
|
|
185
|
+
const sources = [{ sourceRoot: root, scanDirs: scanDirsFor(root) }];
|
|
186
|
+
for (const extra of extras) {
|
|
187
|
+
sources.push({ sourceRoot: extra, scanDirs: scanDirsFor(extra) });
|
|
188
|
+
}
|
|
189
|
+
return sources;
|
|
190
|
+
}
|
|
191
|
+
|
|
123
192
|
// ── File index ──────────────────────────────────────────────────────────────
|
|
124
193
|
|
|
125
194
|
function pushInto(map, key, value) {
|
|
@@ -18,6 +18,7 @@ import { runFlush } from './verbs/flush.mjs';
|
|
|
18
18
|
import { runGet } from './verbs/get.mjs';
|
|
19
19
|
import { runSearch } from './verbs/search.mjs';
|
|
20
20
|
import { runStatus } from './verbs/status.mjs';
|
|
21
|
+
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
21
22
|
|
|
22
23
|
const USAGE = `library — REMOTE-mode client for the remote-library service.
|
|
23
24
|
|
|
@@ -27,6 +28,7 @@ Usage:
|
|
|
27
28
|
library get <path>
|
|
28
29
|
library search <query> [--files-only]
|
|
29
30
|
library status [--json]
|
|
31
|
+
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
30
32
|
library -h | --help
|
|
31
33
|
|
|
32
34
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
@@ -86,6 +88,7 @@ export async function main(argv, seams = {}) {
|
|
|
86
88
|
case 'get': return await runGet({ ...ctx, args: parseGet(rest) });
|
|
87
89
|
case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
|
|
88
90
|
case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
|
|
91
|
+
case 'refresh-clones': return await runRefreshClones({ ...ctx, args: parseRefreshClones(rest) });
|
|
89
92
|
default:
|
|
90
93
|
err(`[library] unknown verb: ${verb}`);
|
|
91
94
|
log(USAGE);
|
|
@@ -140,3 +143,17 @@ function parseStatus(argv) {
|
|
|
140
143
|
});
|
|
141
144
|
return { json: values.json };
|
|
142
145
|
}
|
|
146
|
+
function parseRefreshClones(argv) {
|
|
147
|
+
const { values } = parseArgs({
|
|
148
|
+
args: argv,
|
|
149
|
+
strict: true,
|
|
150
|
+
allowPositionals: false,
|
|
151
|
+
options: {
|
|
152
|
+
json: { type: 'boolean', default: false },
|
|
153
|
+
'timeout-ms': { type: 'string' },
|
|
154
|
+
tier: { type: 'string', multiple: true },
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const timeoutMs = values['timeout-ms'] ? parseInt(values['timeout-ms'], 10) : undefined;
|
|
158
|
+
return { json: values.json, timeoutMs, tiers: values.tier };
|
|
159
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// verbs/refresh-clones.mjs — `library refresh-clones`
|
|
2
|
+
//
|
|
3
|
+
// REMOTE-mode session-boot verb: refreshes every tier clone under the
|
|
4
|
+
// pull-clones root by running `git fetch --quiet origin` then
|
|
5
|
+
// `git pull --ff-only --quiet origin main` for each. Called from the
|
|
6
|
+
// session-boot hook (Wave H, w-2026-08-24-dave-033) so `pa knowledge`
|
|
7
|
+
// reads the freshest projection of the tier repos.
|
|
8
|
+
//
|
|
9
|
+
// Design notes:
|
|
10
|
+
// - Q4 boot-only refresh: this verb is meant to be invoked from the
|
|
11
|
+
// session-boot path (Claude Code SessionStart hook, `pa environment`
|
|
12
|
+
// adjacency). It is NOT a cron / launchd job — the retirement plan
|
|
13
|
+
// is boot-only refresh v1.
|
|
14
|
+
// - Parallel per tier. Total wall time is dominated by the slowest
|
|
15
|
+
// tier's fetch; per-tier timeout keeps a wedged remote from
|
|
16
|
+
// blocking session start.
|
|
17
|
+
// - Refuses to touch a tier clone with uncommitted local changes
|
|
18
|
+
// (`git status --porcelain` non-empty) — pull-clones are a
|
|
19
|
+
// read-only projection per spec §3.3 and any local edit is a
|
|
20
|
+
// corruption signal, not a merge target.
|
|
21
|
+
// - Exit code 0 iff every tier refreshed successfully. Any per-tier
|
|
22
|
+
// failure surfaces on stderr and produces a non-zero exit so the
|
|
23
|
+
// session-boot hook logs it.
|
|
24
|
+
|
|
25
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
import { pullClonesRootExists, resolvePullClonesRoot } from '../pull-clones.mjs';
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TIER_TIMEOUT_MS = 20_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} ctx
|
|
35
|
+
* @param {object} ctx.config
|
|
36
|
+
* @param {object} [ctx.args] { json?, timeoutMs?, tiers? }
|
|
37
|
+
* @param {(...m:unknown[])=>void} [ctx.log]
|
|
38
|
+
* @param {(...m:unknown[])=>void} [ctx.err]
|
|
39
|
+
* @param {(args:string[], cwd:string, opts?:object)=>Promise<{code:number, stdout:string, stderr:string}>} [ctx.git]
|
|
40
|
+
* @returns {Promise<number>}
|
|
41
|
+
*/
|
|
42
|
+
export async function runRefreshClones({
|
|
43
|
+
config,
|
|
44
|
+
args = {},
|
|
45
|
+
log = defaultLog,
|
|
46
|
+
err = defaultErr,
|
|
47
|
+
git = runGit,
|
|
48
|
+
}) {
|
|
49
|
+
const root = resolvePullClonesRoot({ preferences: config.preferences });
|
|
50
|
+
const timeoutMs = args.timeoutMs ?? DEFAULT_TIER_TIMEOUT_MS;
|
|
51
|
+
|
|
52
|
+
if (!pullClonesRootExists(root)) {
|
|
53
|
+
const payload = { root, tiers: [], error: 'pull-clones root not found' };
|
|
54
|
+
if (args.json) log(JSON.stringify(payload, null, 2));
|
|
55
|
+
else err(`[library refresh-clones] pull-clones root not found: ${root}`);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tiers = await enumerateTiers(root, args.tiers);
|
|
60
|
+
const results = await Promise.all(
|
|
61
|
+
tiers.map((tier) => refreshOne({ tier, git, timeoutMs })),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const failures = results.filter((r) => r.status !== 'ok');
|
|
65
|
+
const payload = { root, tiers: results, ok: failures.length === 0 };
|
|
66
|
+
|
|
67
|
+
if (args.json) {
|
|
68
|
+
log(JSON.stringify(payload, null, 2));
|
|
69
|
+
} else {
|
|
70
|
+
log(`pull-clones root: ${root} (${results.length} tier${results.length === 1 ? '' : 's'})`);
|
|
71
|
+
for (const r of results) {
|
|
72
|
+
const line = r.status === 'ok'
|
|
73
|
+
? ` ${r.name.padEnd(32)} ok (${r.newCommits} new commit${r.newCommits === 1 ? '' : 's'})`
|
|
74
|
+
: ` ${r.name.padEnd(32)} ${r.status}${r.error ? ` (${r.error})` : ''}`;
|
|
75
|
+
log(line);
|
|
76
|
+
}
|
|
77
|
+
if (failures.length > 0) {
|
|
78
|
+
err(`[library refresh-clones] ${failures.length} tier${failures.length === 1 ? '' : 's'} failed`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return failures.length === 0 ? 0 : 2;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function enumerateTiers(root, only) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
88
|
+
} catch { return []; }
|
|
89
|
+
const filter = only && only.length ? new Set(only) : null;
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
93
|
+
if (filter && !filter.has(entry.name)) continue;
|
|
94
|
+
const full = join(root, entry.name);
|
|
95
|
+
const s = await stat(join(full, '.git')).catch(() => null);
|
|
96
|
+
if (!s) continue;
|
|
97
|
+
out.push({ name: entry.name, path: full });
|
|
98
|
+
}
|
|
99
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function refreshOne({ tier, git, timeoutMs }) {
|
|
103
|
+
const cwd = tier.path;
|
|
104
|
+
|
|
105
|
+
// Dirty check — refuse rather than clobber. Pull-clones are read-only
|
|
106
|
+
// projections; any local edit is a corruption signal that should be
|
|
107
|
+
// investigated, not merged over.
|
|
108
|
+
const dirty = await git(['status', '--porcelain'], cwd, { timeoutMs });
|
|
109
|
+
if (dirty.code !== 0) {
|
|
110
|
+
return { name: tier.name, status: 'error', error: `git status: ${(dirty.stderr || 'nonzero exit').slice(0, 160)}` };
|
|
111
|
+
}
|
|
112
|
+
if (dirty.stdout.trim() !== '') {
|
|
113
|
+
return { name: tier.name, status: 'dirty', error: 'uncommitted local changes; refusing to refresh' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fetched = await git(['fetch', '--quiet', 'origin'], cwd, { timeoutMs });
|
|
117
|
+
if (fetched.code !== 0) {
|
|
118
|
+
return { name: tier.name, status: 'error', error: `git fetch: ${(fetched.stderr || 'nonzero exit').slice(0, 160)}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const count = await git(['rev-list', 'HEAD..origin/main', '--count'], cwd, { timeoutMs });
|
|
122
|
+
const newCommits = count.code === 0 ? parseInt(count.stdout.trim(), 10) || 0 : 0;
|
|
123
|
+
|
|
124
|
+
if (newCommits === 0) {
|
|
125
|
+
return { name: tier.name, status: 'ok', newCommits: 0 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pulled = await git(['merge', '--ff-only', '--quiet', 'origin/main'], cwd, { timeoutMs });
|
|
129
|
+
if (pulled.code !== 0) {
|
|
130
|
+
return { name: tier.name, status: 'error', error: `git merge --ff-only: ${(pulled.stderr || 'nonzero exit').slice(0, 160)}` };
|
|
131
|
+
}
|
|
132
|
+
return { name: tier.name, status: 'ok', newCommits };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runGit(argsArr, cwd, opts = {}) {
|
|
136
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIER_TIMEOUT_MS;
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
const proc = spawn('git', argsArr, { cwd });
|
|
139
|
+
let stdout = '';
|
|
140
|
+
let stderr = '';
|
|
141
|
+
const killer = setTimeout(() => {
|
|
142
|
+
stderr += `\n[timeout after ${timeoutMs}ms]`;
|
|
143
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
proc.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
|
|
146
|
+
proc.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
|
|
147
|
+
proc.on('error', (e) => { clearTimeout(killer); resolve({ code: -1, stdout, stderr: e.message }); });
|
|
148
|
+
proc.on('close', (code) => { clearTimeout(killer); resolve({ code: code ?? -1, stdout, stderr }); });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function defaultLog(...m) { process.stdout.write(m.join(' ') + '\n'); }
|
|
153
|
+
function defaultErr(...m) { process.stderr.write(m.join(' ') + '\n'); }
|
package/package.json
CHANGED