create-agentic-workspace 0.8.0 → 0.9.0
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/README.md +8 -2
- package/package.json +2 -2
- package/permission-floor.json +1 -1
- package/src/cleanup.mjs +303 -0
- package/src/pluginRefresh.mjs +318 -0
- package/src/update.mjs +275 -0
package/README.md
CHANGED
|
@@ -11,8 +11,14 @@ npx create-agentic-workspace --dir my-workspace
|
|
|
11
11
|
|
|
12
12
|
The CLI walks you through the target directory, greenfield-vs-existing, git/GitHub identity, and
|
|
13
13
|
stage mode, **previews every file it will write and every capability it will declare**, writes
|
|
14
|
-
the workspace, and stops.
|
|
15
|
-
never pre-grants anything — it *declares*, the platform's trust dialog
|
|
14
|
+
the workspace, and stops. `create-agentic-workspace` never runs `claude`, never accepts the
|
|
15
|
+
workspace trust dialog, and never pre-grants anything — it *declares*, the platform's trust dialog
|
|
16
|
+
is the consent ceremony.
|
|
17
|
+
|
|
18
|
+
**Already have a workspace and want it current instead?** That is a different command, on purpose
|
|
19
|
+
— see the sibling package [`update-agentic-workspace`](https://github.com/lukasrepublic/agentic-foundry/tree/main/cli-update#readme),
|
|
20
|
+
which refreshes the marketplace, updates the plugin, and re-runs this reconcile. Unlike this one,
|
|
21
|
+
it does invoke `claude` (bounded by a closed allowlist — see its own README).
|
|
16
22
|
|
|
17
23
|
## What it does
|
|
18
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The pre-session bootstrap wizard for an Agentic Foundry workspace: declares (never grants) the permission floor, absorbs foundry-bootstrap.sh's out-of-session identity wiring, and scaffolds a seven-file schema-valid workspace. Zero dependencies, no lifecycle scripts, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"marketplace_name": "agentic-foundry",
|
|
35
35
|
"marketplace_repo": "lukasrepublic/agentic-foundry",
|
|
36
36
|
"plugin_name": "foundry",
|
|
37
|
-
"plugin_version": "1.
|
|
37
|
+
"plugin_version": "1.9.0",
|
|
38
38
|
"pins_researched": "2026-08-02"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/permission-floor.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"plugin_root_glob": "~/.claude/plugins/cache/*/foundry/*",
|
|
4
|
-
"generated_for_plugin_version": "1.
|
|
4
|
+
"generated_for_plugin_version": "1.9.0",
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
7
7
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
|
package/src/cleanup.mjs
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// cleanup.mjs — Phase 3 (opt-in, `--cleanup`) of `npx update-agentic-workspace`: prune superseded
|
|
2
|
+
// plugin-cache versions and remove a stale/duplicate marketplace registration. THE FIRST recursive
|
|
3
|
+
// delete of an adopter path in cli/src/ — every other `rmSync` in this package removes a temp file
|
|
4
|
+
// the process itself just wrote. Fail-closed throughout: AC-UWC-4 skips (removes nothing) on any
|
|
5
|
+
// indeterminate input, and AC-UWC-9 refuses (removes nothing AT ALL, superseded entries included)
|
|
6
|
+
// on any cache entry that is not a plain immediate-child directory of the pinned root.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { RefusalError } from './util.mjs';
|
|
10
|
+
import {
|
|
11
|
+
runClaude, readInstalledPluginsRegistry, scopeRecordsFor,
|
|
12
|
+
readMarketplaceManifest, pluginEntryOf,
|
|
13
|
+
} from './pluginRefresh.mjs';
|
|
14
|
+
|
|
15
|
+
// ── AC-UWC-1 — the live set is READ from the platform's own state, never computed by sorting ────
|
|
16
|
+
|
|
17
|
+
/** The union of every scope record's `version` for `<plugin>@<marketplace>` in
|
|
18
|
+
* installed_plugins.json, plus the refreshed marketplace manifest's `version` — NEVER the highest
|
|
19
|
+
* semver on disk. `scripts/foundry-fleet-doctor.py:70-83`'s `installed_index()` is correct for its
|
|
20
|
+
* own job (report ONE adopter's resolved plugin) and unsafe here: it `break`s after the first
|
|
21
|
+
* record and would silently drop a scope pinned to an older version that is still live. */
|
|
22
|
+
export function deriveLiveSet({ registry, manifestVersion, pluginKey }) {
|
|
23
|
+
if (!registry.ok) return { ok: false, reason: registry.reason };
|
|
24
|
+
const records = scopeRecordsFor(registry.doc, pluginKey);
|
|
25
|
+
if (records.length === 0) {
|
|
26
|
+
return { ok: false, reason: `installed plugin registry holds no record for ${pluginKey}` };
|
|
27
|
+
}
|
|
28
|
+
// REFUSE ON THE FIRST UNUSABLE RECORD, not merely when every record is unusable. A `.filter()`
|
|
29
|
+
// that drops a malformed record while its SIBLINGS keep the set non-empty is the dangerous
|
|
30
|
+
// shape: the set stays `ok`, and the dropped record's still-live directory is handed to
|
|
31
|
+
// planCachePrune as a prune candidate. That is a live install of ANOTHER project deleted on a
|
|
32
|
+
// registry the platform wrote — recoverable only by reinstalling, and invisible until that
|
|
33
|
+
// project's next session breaks. An empty-set check alone closes only the degenerate half.
|
|
34
|
+
// `!r.version.trim()` and not merely `!r.version`: a whitespace-only version is a non-empty
|
|
35
|
+
// string, so it passes a truthiness test, joins the live set as " ", and leaves the record's
|
|
36
|
+
// REAL cache directory absent from the set — the same hole as a dropped record, reached through
|
|
37
|
+
// a value that looks usable.
|
|
38
|
+
const unusable = records.findIndex(
|
|
39
|
+
(r) => !r || typeof r.version !== 'string' || !r.version.trim(),
|
|
40
|
+
);
|
|
41
|
+
if (unusable !== -1) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: `installed plugin registry record ${unusable} for ${pluginKey} carries no usable version string`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// `installPath` is REQUIRED, not opportunistic. The namespace-bridging below is only a safeguard
|
|
48
|
+
// if it actually runs; making it conditional on the field being present would mean the one shape
|
|
49
|
+
// that needs the bridge (a directory name that differs from `version`) is exactly the shape that
|
|
50
|
+
// silently skips it. Every registry the platform writes carries this field, so a record without
|
|
51
|
+
// one is indeterminate input and takes the AC-UWC-4 skip like any other.
|
|
52
|
+
const noPath = records.findIndex((r) => typeof r.installPath !== 'string' || !r.installPath.trim());
|
|
53
|
+
if (noPath !== -1) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
reason: `installed plugin registry record ${noPath} for ${pluginKey} carries no usable installPath`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TWO NAMESPACES, seeded from both. `versions` is a set of version STRINGS; planCachePrune
|
|
61
|
+
// enumerates directory NAMES. Nothing in the platform's contract says those coincide, and if it
|
|
62
|
+
// ever normalises one differently from the other, every on-disk directory — the live one
|
|
63
|
+
// included — becomes a candidate while this function still reports `ok`. `installPath` is the
|
|
64
|
+
// only field that literally names the directory, so its basename is seeded alongside `version`:
|
|
65
|
+
// the live set is then expressed in BOTH namespaces and a divergence can only ever make the set
|
|
66
|
+
// larger (fewer deletions), never smaller.
|
|
67
|
+
const versions = new Set();
|
|
68
|
+
for (const r of records) {
|
|
69
|
+
versions.add(r.version);
|
|
70
|
+
const leaf = path.basename(r.installPath);
|
|
71
|
+
if (leaf && leaf !== '.' && leaf !== path.sep) versions.add(leaf);
|
|
72
|
+
}
|
|
73
|
+
if (manifestVersion) versions.add(manifestVersion);
|
|
74
|
+
// Belt-and-braces: unreachable given the per-record refusal above, but an empty live set is the
|
|
75
|
+
// one verdict this function must never hand back on indeterminate input (AC-UWC-4), so it is
|
|
76
|
+
// asserted rather than assumed.
|
|
77
|
+
if (versions.size === 0) {
|
|
78
|
+
return { ok: false, reason: `no live version could be read for ${pluginKey}` };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, versions };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── AC-UWC-2/-9 — the candidate set is constructed by name, inside ONE pinned root ──────────────
|
|
84
|
+
|
|
85
|
+
/** Enumerate the immediate children of `pluginCacheDir`, subtract `liveVersions`, and return the
|
|
86
|
+
* prune candidates. Refuses — throwing BEFORE anything is removed, so nothing this run touches is
|
|
87
|
+
* ever partially applied — on the first entry that is a symlink, a non-directory, or whose
|
|
88
|
+
* resolved real path is not literally `pluginCacheDir/<name>`.
|
|
89
|
+
*
|
|
90
|
+
* The real-path check compares against the entry's OWN nominal join, not a realpath'd root: a
|
|
91
|
+
* SYMLINKED ANCESTOR (e.g. `plugins/cache/<marketplace>` itself replaced with a symlink to another
|
|
92
|
+
* disk) would resolve consistently on BOTH sides if the root were realpath'd first, hiding exactly
|
|
93
|
+
* the redirection this check exists to catch. Comparing the leaf's realpath to its own nominal path
|
|
94
|
+
* catches an ancestor symlink the same way it catches a leaf one — lstat on the leaf already ruled
|
|
95
|
+
* the leaf itself out, so any remaining divergence can only come from an ancestor. */
|
|
96
|
+
export function planCachePrune({ pluginCacheDir, liveVersions }) {
|
|
97
|
+
if (!fs.existsSync(pluginCacheDir)) return [];
|
|
98
|
+
const names = fs.readdirSync(pluginCacheDir);
|
|
99
|
+
const candidates = [];
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
const nominal = path.join(pluginCacheDir, name);
|
|
102
|
+
const lst = fs.lstatSync(nominal);
|
|
103
|
+
if (lst.isSymbolicLink()) {
|
|
104
|
+
throw new RefusalError(`refusing cache entry ${nominal}: symbolic link`, 'cleanup');
|
|
105
|
+
}
|
|
106
|
+
if (!lst.isDirectory()) {
|
|
107
|
+
throw new RefusalError(`refusing cache entry ${nominal}: not a directory`, 'cleanup');
|
|
108
|
+
}
|
|
109
|
+
const real = fs.realpathSync(nominal);
|
|
110
|
+
if (real !== nominal) {
|
|
111
|
+
throw new RefusalError(
|
|
112
|
+
`refusing cache entry ${nominal}: resolved real path (${real}) escapes the pinned root`,
|
|
113
|
+
'cleanup',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!liveVersions.has(name)) candidates.push(name);
|
|
117
|
+
}
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Remove exactly the candidates already validated by planCachePrune — never re-validates, never
|
|
122
|
+
* enumerates further. Called only under `--cleanup` (AC-UWC-6), and only once planCachePrune has
|
|
123
|
+
* returned without throwing for the WHOLE directory (AC-UWC-9's "removes nothing at all"). */
|
|
124
|
+
export function applyCachePrune(pluginCacheDir, candidates) {
|
|
125
|
+
for (const name of candidates) {
|
|
126
|
+
fs.rmSync(path.join(pluginCacheDir, name), { recursive: true, force: false });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── AC-UWC-7 — a stale or duplicate registration that no scope enables ──────────────────────────
|
|
131
|
+
|
|
132
|
+
export function readKnownMarketplaces(configDir) {
|
|
133
|
+
const p = path.join(configDir, 'plugins', 'known_marketplaces.json');
|
|
134
|
+
let raw;
|
|
135
|
+
try {
|
|
136
|
+
raw = fs.readFileSync(p, 'utf-8');
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { ok: false, reason: e.code === 'ENOENT' ? 'known_marketplaces.json is absent' : `known_marketplaces.json is unreadable: ${e.message}` };
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
return { ok: true, doc: JSON.parse(raw) };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { ok: false, reason: `known_marketplaces.json does not parse as JSON: ${e.message}` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** AC-UWC-4(ii)/AC-UWC-7(b) — the set of `<...>@<registrationName>` qualifiers enabled ANYWHERE,
|
|
148
|
+
* used both to decide whether a stale/duplicate registration is safe to remove and to route an
|
|
149
|
+
* unreadable settings scope to the SKIP branch rather than silently reading it as "not enabled",
|
|
150
|
+
* which would turn an I/O failure into permission to remove a LIVE registration (spec R4 — "the
|
|
151
|
+
* single most dangerous mis-implementation available in this atom"). Absence of a scope's settings
|
|
152
|
+
* file is NOT an error (nothing is configured there); a file that EXISTS but cannot be read or
|
|
153
|
+
* parsed is — mirrors the sibling AC-UAW-13 scope model exactly, for the same reason. */
|
|
154
|
+
export function resolveEnabledQualifiers(scopeDescriptors) {
|
|
155
|
+
const qualifiers = new Set();
|
|
156
|
+
for (const scope of scopeDescriptors) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = fs.readFileSync(scope.settingsPath, 'utf-8');
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e.code === 'ENOENT') continue;
|
|
162
|
+
return { ok: false, reason: `${scope.name} scope settings file is unreadable (${scope.settingsPath})` };
|
|
163
|
+
}
|
|
164
|
+
let obj;
|
|
165
|
+
try {
|
|
166
|
+
obj = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
return { ok: false, reason: `${scope.name} scope settings file does not parse as JSON (${scope.settingsPath})` };
|
|
169
|
+
}
|
|
170
|
+
for (const key of Object.keys((obj && obj.enabledPlugins) || {})) qualifiers.add(key);
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, qualifiers };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// R7 — `name` is a JSON OBJECT KEY read from the adopter's own known_marketplaces.json, not
|
|
176
|
+
// adopter-typed input, but it still becomes an argv element passed to `claude plugin marketplace
|
|
177
|
+
// remove <name>`. A key beginning with `-` (or containing anything outside a safe charset) would
|
|
178
|
+
// reach the platform CLI's own option parser as a flag rather than a positional name. Filtered
|
|
179
|
+
// out here, BEFORE any name reaches runCleanupPhase's runClaude call — never made removable, so it
|
|
180
|
+
// can never become argv.
|
|
181
|
+
// The FIRST character is restricted to alphanumeric — a leading `-` (or `.`, which some option
|
|
182
|
+
// parsers also special-case) is exactly what would make the token look like a flag; only the
|
|
183
|
+
// REST of the name may carry `.`/`_`/`-`.
|
|
184
|
+
const SAFE_REGISTRATION_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
185
|
+
|
|
186
|
+
/** A registration for OUR repo is a candidate when it is STALE (`installLocation` names a path
|
|
187
|
+
* that does not exist) or DUPLICATE (its name is not the canonical marketplace name) — AND no
|
|
188
|
+
* scope's enabledPlugins names a plugin qualified by that registration's name, AND its name is a
|
|
189
|
+
* safe argv component (R7). */
|
|
190
|
+
export function planRegistrationRemoval({ knownMarketplacesDoc, marketplaceRepo, canonicalName, enabledQualifiers }) {
|
|
191
|
+
const removable = [];
|
|
192
|
+
for (const [name, entry] of Object.entries(knownMarketplacesDoc || {})) {
|
|
193
|
+
const repo = entry && entry.source && entry.source.repo;
|
|
194
|
+
if (repo !== marketplaceRepo) continue;
|
|
195
|
+
if (!SAFE_REGISTRATION_NAME.test(name)) continue;
|
|
196
|
+
const stale = !(entry.installLocation && fs.existsSync(entry.installLocation));
|
|
197
|
+
const duplicate = name !== canonicalName;
|
|
198
|
+
if (!stale && !duplicate) continue;
|
|
199
|
+
const enabledHere = [...enabledQualifiers].some((q) => q.endsWith(`@${name}`));
|
|
200
|
+
if (enabledHere) continue;
|
|
201
|
+
removable.push(name);
|
|
202
|
+
}
|
|
203
|
+
return removable;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── the whole phase, composed ────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/** Run cleanup end to end. `print` receives the preview lines BEFORE the first removal (AC-UWC-5);
|
|
209
|
+
* `cleanupFlag` gates BOTH the filesystem removals AND every `claude` invocation this phase makes
|
|
210
|
+
* (AC-UWC-6/-7 — a flagless run issues zero of either). Returns
|
|
211
|
+
* `{ verdict, reason?, prunedVersions, removedRegistrations }`. */
|
|
212
|
+
export function runCleanupPhase({
|
|
213
|
+
cleanupFlag, configDir, marketplaceName, marketplaceRepo, pluginName, pluginKey,
|
|
214
|
+
scopeDescriptors, env, cwd, print, claudeBin,
|
|
215
|
+
}) {
|
|
216
|
+
// The delete root is built from `marketplaceName`/`pluginName` — both come from this package's
|
|
217
|
+
// own bundled pins, never from adopter input, but `path.join` normalizes `..` regardless of
|
|
218
|
+
// provenance, and a corrupted or mis-edited pin (`"marketplace_name": "../.."`) would otherwise
|
|
219
|
+
// walk `pluginCacheDir` outside `plugins/cache/` entirely, past every symlink/realpath guard
|
|
220
|
+
// planCachePrune has (those guard the LEAVES, not this join). Validated before ANYTHING else in
|
|
221
|
+
// this phase runs.
|
|
222
|
+
const SAFE_COMPONENT = /^[A-Za-z0-9._-]+$/;
|
|
223
|
+
const isSafeComponent = (s) => SAFE_COMPONENT.test(s) && s !== '.' && s !== '..';
|
|
224
|
+
if (!isSafeComponent(marketplaceName) || !isSafeComponent(pluginName)) {
|
|
225
|
+
return {
|
|
226
|
+
verdict: 'skipped',
|
|
227
|
+
reason: 'marketplace or plugin name is not a safe path component; refusing to derive a cache root from it',
|
|
228
|
+
candidateVersions: [], prunedVersions: [], removedRegistrations: [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const pluginCacheDirCandidate = path.join(configDir, 'plugins', 'cache', marketplaceName, pluginName);
|
|
232
|
+
const expectedPrefix = path.join(configDir, 'plugins', 'cache') + path.sep;
|
|
233
|
+
if (!pluginCacheDirCandidate.startsWith(expectedPrefix)) {
|
|
234
|
+
return {
|
|
235
|
+
verdict: 'skipped',
|
|
236
|
+
reason: 'the derived cache root escapes plugins/cache; refusing',
|
|
237
|
+
candidateVersions: [], prunedVersions: [], removedRegistrations: [],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const registry = readInstalledPluginsRegistry(configDir);
|
|
242
|
+
const manifest = readMarketplaceManifest(configDir, marketplaceName);
|
|
243
|
+
const manifestEntry = manifest.present ? pluginEntryOf(manifest.doc, pluginName) : null;
|
|
244
|
+
const liveSetResult = deriveLiveSet({
|
|
245
|
+
registry, manifestVersion: manifestEntry && manifestEntry.version, pluginKey,
|
|
246
|
+
});
|
|
247
|
+
const enabledResult = resolveEnabledQualifiers(scopeDescriptors);
|
|
248
|
+
|
|
249
|
+
// AC-UWC-4 — EITHER indeterminate input skips the WHOLE phase: every cache path present, every
|
|
250
|
+
// registration in place, a non-empty `skipped: <reason>` verdict, the run's own exit status
|
|
251
|
+
// untouched.
|
|
252
|
+
if (!liveSetResult.ok || !enabledResult.ok) {
|
|
253
|
+
return {
|
|
254
|
+
verdict: 'skipped',
|
|
255
|
+
reason: !liveSetResult.ok ? liveSetResult.reason : enabledResult.reason,
|
|
256
|
+
candidateVersions: [], prunedVersions: [], removedRegistrations: [],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const pluginCacheDir = pluginCacheDirCandidate;
|
|
261
|
+
// AC-UWC-9 — throws BEFORE anything is removed; propagated to the caller as a refusal.
|
|
262
|
+
const candidates = planCachePrune({ pluginCacheDir, liveVersions: liveSetResult.versions });
|
|
263
|
+
|
|
264
|
+
const known = readKnownMarketplaces(configDir);
|
|
265
|
+
const removableRegs = known.ok
|
|
266
|
+
? planRegistrationRemoval({
|
|
267
|
+
knownMarketplacesDoc: known.doc, marketplaceRepo, canonicalName: marketplaceName,
|
|
268
|
+
enabledQualifiers: enabledResult.qualifiers,
|
|
269
|
+
})
|
|
270
|
+
: [];
|
|
271
|
+
|
|
272
|
+
// AC-UWC-5 — previewed before the first removal, in EVERY mode (report-only included, so the
|
|
273
|
+
// adopter sees the same list --cleanup would act on).
|
|
274
|
+
if (candidates.length > 0 || removableRegs.length > 0) {
|
|
275
|
+
print('cleanup: the following would be removed:');
|
|
276
|
+
for (const name of candidates) print(` [cache] ${path.join(pluginCacheDir, name)}`);
|
|
277
|
+
for (const name of removableRegs) print(` [marketplace registration] ${name}`);
|
|
278
|
+
} else {
|
|
279
|
+
print('cleanup: nothing to remove.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const anything = candidates.length > 0 || removableRegs.length > 0;
|
|
283
|
+
|
|
284
|
+
if (!cleanupFlag) {
|
|
285
|
+
// AC-UWC-6/-7 — report-only: zero filesystem-removal calls AND zero `claude` invocations.
|
|
286
|
+
// `candidateVersions` is what WOULD be removed (always populated, for the preview/report);
|
|
287
|
+
// `prunedVersions` is what WAS actually removed — empty here by construction.
|
|
288
|
+
return {
|
|
289
|
+
verdict: anything ? 'changed' : 'already current',
|
|
290
|
+
candidateVersions: candidates, prunedVersions: [], removedRegistrations: [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
applyCachePrune(pluginCacheDir, candidates);
|
|
295
|
+
for (const name of removableRegs) {
|
|
296
|
+
runClaude(['plugin', 'marketplace', 'remove', name], { env, cwd, claudeBin });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
verdict: anything ? 'changed' : 'already current',
|
|
301
|
+
candidateVersions: candidates, prunedVersions: candidates, removedRegistrations: removableRegs,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// pluginRefresh.mjs — Phase 1 (marketplace refresh + the AC-UAW-4 one-time migration) and Phase 2
|
|
2
|
+
// (plugin update, AC-UAW-5/-6) of `npx update-agentic-workspace`. THE ONLY module in this atom that
|
|
3
|
+
// spawns `claude` — every process-spawning call site anywhere in the update path goes through
|
|
4
|
+
// `runClaude` below, gated by the ONE frozen allowlist (AC-UAW-14). cli/src/cleanup.mjs imports
|
|
5
|
+
// `runClaude` from here rather than declaring a second spawn site or a second copy of the allowlist.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { RefusalError } from './util.mjs';
|
|
10
|
+
import { writeTargetAtomically } from './floorReconcile.mjs';
|
|
11
|
+
|
|
12
|
+
// AC-UAW-14 — the closed six-pair allowlist, declared EXACTLY ONCE across this atom's modules.
|
|
13
|
+
// Every `claude` invocation anywhere in the update path is one of these pairs (optionally followed
|
|
14
|
+
// by more argv, e.g. a source/name and `--scope <scope>`) — never a bare `claude`, never anything
|
|
15
|
+
// that can start a session or reach the trust dialog. `plugin install` is the sixth pair, admitted
|
|
16
|
+
// on docs/troubleshooting.md:196-219's grounding: it is what re-establishes a plugin AC-UAW-4's own
|
|
17
|
+
// `marketplace remove` may have orphaned.
|
|
18
|
+
export const ALLOWED_CLAUDE_SUBCOMMANDS = Object.freeze([
|
|
19
|
+
['plugin', 'marketplace', 'update'],
|
|
20
|
+
['plugin', 'marketplace', 'add'],
|
|
21
|
+
['plugin', 'marketplace', 'remove'],
|
|
22
|
+
['plugin', 'update'],
|
|
23
|
+
['plugin', 'install'],
|
|
24
|
+
['plugin', 'list'],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Prefix match against the frozen allowlist. Exact-token comparison at every position closes both
|
|
28
|
+
* failure modes a looser check would admit: a NEAR-MISS in the same namespace (`plugin uninstall`,
|
|
29
|
+
* `plugin marketplace list`) fails because some token differs, and a PREFIX-EXTENSION shape
|
|
30
|
+
* (`plugin updatex`) fails for the same reason — `args[1] === 'update'` is false for `'updatex'`,
|
|
31
|
+
* so a `startsWith`-style bug can never pass here because none is used. */
|
|
32
|
+
export function isAllowedInvocation(args) {
|
|
33
|
+
return ALLOWED_CLAUDE_SUBCOMMANDS.some(
|
|
34
|
+
(pair) => pair.length <= args.length && pair.every((tok, i) => args[i] === tok),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve `claude` on PATH WITHOUT spawning anything (AC-UAW-13's preflight). An executable
|
|
39
|
+
* regular file or symlink named `claude` in one of PATH's directories; the first match wins, same
|
|
40
|
+
* as a shell would resolve it. */
|
|
41
|
+
export function resolveClaudeOnPath(pathEnv) {
|
|
42
|
+
for (const dir of String(pathEnv || '').split(path.delimiter)) {
|
|
43
|
+
if (!dir) continue;
|
|
44
|
+
const candidate = path.join(dir, 'claude');
|
|
45
|
+
try {
|
|
46
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
47
|
+
return candidate;
|
|
48
|
+
} catch {
|
|
49
|
+
// not here — keep looking
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** THE spawn site (AC-UAW-14). Refuses — WITHOUT spawning anything — any argv not drawn from
|
|
56
|
+
* ALLOWED_CLAUDE_SUBCOMMANDS. `env`/`cwd` are always supplied explicitly by the caller so a test
|
|
57
|
+
* can drive an isolated CLAUDE_CONFIG_DIR and an injected stub `claude` on PATH without ever
|
|
58
|
+
* touching the real one. `claudeBin`, when given, is the ABSOLUTE path resolveClaudeOnPath already
|
|
59
|
+
* found — spawned directly rather than re-resolving the bare name `claude` against `env.PATH` a
|
|
60
|
+
* second time, so the AC-UAW-13(a) preflight actually binds the executable the rest of the run
|
|
61
|
+
* uses (R8) instead of merely proving SOME `claude` existed a moment earlier. Falls back to the
|
|
62
|
+
* bare name only when no resolved path is given, e.g. from a unit test exercising this function on
|
|
63
|
+
* its own. */
|
|
64
|
+
export function runClaude(args, { env, cwd, claudeBin }) {
|
|
65
|
+
if (!isAllowedInvocation(args)) {
|
|
66
|
+
throw new RefusalError(
|
|
67
|
+
`refusing claude invocation outside the closed allowlist: claude ${args.join(' ')}`,
|
|
68
|
+
'claude',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return execFileSync(claudeBin || 'claude', args, { env, cwd, encoding: 'utf-8' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Scope model (AC-UAW-5/-13/-15) ──────────────────────────────────────────────────────────────
|
|
75
|
+
//
|
|
76
|
+
// The only two scopes grounded anywhere in this repository (docs/troubleshooting.md:196-228,
|
|
77
|
+
// skills/cut-release/SKILL.md:189-197): `user` (`<config-dir>/settings.json`) and `project`
|
|
78
|
+
// (`<cwd>/.claude/settings.json` — this command is run FROM WITHIN an already-scaffolded workspace,
|
|
79
|
+
// exactly like the reconcile path in run.mjs). `project` is therefore REQUIRED: its absence means
|
|
80
|
+
// this was not run inside a real workspace, which AC-UAW-13 treats as an unresolvable precondition.
|
|
81
|
+
// `user` is OPTIONAL — its absence just means nothing is configured at that scope (the common case
|
|
82
|
+
// on a fresh machine) — but a PRESENT-and-broken file at EITHER scope is still a refusal: an I/O
|
|
83
|
+
// failure must never be read as "not configured here", which is the failure R4/AC-UWC-4(ii) name as
|
|
84
|
+
// the single most dangerous mis-implementation on the sibling cleanup atom's side of this same seam.
|
|
85
|
+
|
|
86
|
+
export function defaultScopes({ cwd, configDir }) {
|
|
87
|
+
return [
|
|
88
|
+
{ name: 'project', settingsPath: path.join(cwd, '.claude', 'settings.json'), required: true },
|
|
89
|
+
{ name: 'user', settingsPath: path.join(configDir, 'settings.json'), required: false },
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read one scope's settings file. Throws RefusalError (AC-UAW-13) for a REQUIRED scope that is
|
|
94
|
+
* absent, or for ANY scope that exists but is unreadable or does not parse as a JSON object.
|
|
95
|
+
* Absence of an OPTIONAL scope is not an error — the caller filters it out. */
|
|
96
|
+
export function readScopeSettings(scope) {
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = fs.readFileSync(scope.settingsPath, 'utf-8');
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e.code === 'ENOENT') {
|
|
102
|
+
if (scope.required) {
|
|
103
|
+
throw new RefusalError(
|
|
104
|
+
`refusing: ${scope.name} scope settings file is absent (${scope.settingsPath})`,
|
|
105
|
+
scope.name,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { ...scope, present: false, obj: null };
|
|
109
|
+
}
|
|
110
|
+
throw new RefusalError(
|
|
111
|
+
`refusing: ${scope.name} scope settings file is unreadable (${scope.settingsPath}): ${e.message}`,
|
|
112
|
+
scope.name,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
let obj;
|
|
116
|
+
try {
|
|
117
|
+
obj = JSON.parse(raw);
|
|
118
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('not an object');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new RefusalError(
|
|
121
|
+
`refusing: ${scope.name} scope settings file does not parse as a JSON object (${scope.settingsPath}): ${e.message}`,
|
|
122
|
+
scope.name,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return { ...scope, present: true, obj };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read every scope BEFORE any mutation — the snapshot AC-UAW-15(b) requires the rest of the run to
|
|
129
|
+
* be derived from. Throws on the first unresolvable scope (AC-UAW-13); returns only the scopes that
|
|
130
|
+
* are actually present. */
|
|
131
|
+
export function snapshotScopes(scopes) {
|
|
132
|
+
return scopes.map(readScopeSettings).filter((s) => s.present);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function marketplaceEntryOf(settingsObj, marketplaceName) {
|
|
136
|
+
return (settingsObj.extraKnownMarketplaces || {})[marketplaceName];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isEnabled(settingsObj, pluginKey) {
|
|
140
|
+
return Boolean((settingsObj.enabledPlugins || {})[pluginKey]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function entryHasRef(entry) {
|
|
144
|
+
const src = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry.source : undefined;
|
|
145
|
+
return Boolean(src && typeof src === 'object' && Object.prototype.hasOwnProperty.call(src, 'ref'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── AC-UAW-4 — the migration to the tagless steady state ───────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** Classify ONE scope's migration trigger. Returns null for a scope already in the tagless steady
|
|
151
|
+
* state (or one carrying no marketplace/plugin state at all). `isInstalled(scopeName)` is the
|
|
152
|
+
* installedness oracle for the THIRD disjunct — a run killed between `marketplace add` and
|
|
153
|
+
* `plugin install` leaves a tagless, enabled entry whose plugin is not actually installed there,
|
|
154
|
+
* which the tagless-entry shape alone would read as healthy. `isInstalled` returns `true`, `false`,
|
|
155
|
+
* or `null` (cannot be determined, e.g. an unreadable installed_plugins.json) — a `null` is NEVER
|
|
156
|
+
* silently folded into either boolean: it is surfaced as its own `indeterminate-installedness`
|
|
157
|
+
* trigger so the caller can report it rather than either skip a genuine orphan or force an
|
|
158
|
+
* unneeded reinstall silently. */
|
|
159
|
+
export function classifyMigration(scopeSnap, { marketplaceName, pluginKey, isInstalled }) {
|
|
160
|
+
const entry = marketplaceEntryOf(scopeSnap.obj, marketplaceName);
|
|
161
|
+
const enabled = isEnabled(scopeSnap.obj, pluginKey);
|
|
162
|
+
if (entry !== undefined) {
|
|
163
|
+
if (entryHasRef(entry)) return { kind: 'tag-pinned' };
|
|
164
|
+
if (enabled) {
|
|
165
|
+
const installed = isInstalled(scopeSnap.name);
|
|
166
|
+
if (installed === null) return { kind: 'indeterminate-installedness' };
|
|
167
|
+
if (!installed) return { kind: 'orphaned-install' };
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
// entry absent — the shape a run SIGKILLed between its own `marketplace remove` and
|
|
172
|
+
// `marketplace add` leaves behind. Only relevant if the scope still believes the plugin enabled;
|
|
173
|
+
// otherwise this scope simply never had the marketplace registered, which is not this atom's job.
|
|
174
|
+
if (enabled) return { kind: 'interrupted-remove' };
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The AC-UAW-4 action sequence for one trigger, PER SCOPE with that scope named explicitly on
|
|
179
|
+
* every action. NEVER emits `marketplace remove` when there is no entry to remove (interrupted-
|
|
180
|
+
* remove), and NEVER re-adds an entry that is already tagless (orphaned-install needs only
|
|
181
|
+
* `plugin install`). */
|
|
182
|
+
export function migrationActions(trigger, { scope, marketplaceName, marketplaceRepo, pluginKey }) {
|
|
183
|
+
const actions = [];
|
|
184
|
+
// Nothing is actionable on uncertain data — surfaced to the caller as its own trigger kind
|
|
185
|
+
// instead (see classifyMigration), never silently resolved to an action here.
|
|
186
|
+
if (trigger.kind === 'indeterminate-installedness') return actions;
|
|
187
|
+
if (trigger.kind === 'tag-pinned') {
|
|
188
|
+
actions.push(['plugin', 'marketplace', 'remove', marketplaceName, '--scope', scope]);
|
|
189
|
+
}
|
|
190
|
+
if (trigger.kind === 'tag-pinned' || trigger.kind === 'interrupted-remove') {
|
|
191
|
+
// source argument carries NO `#` — the tagless steady state (AC-UAW-4's own text).
|
|
192
|
+
actions.push(['plugin', 'marketplace', 'add', marketplaceRepo, '--scope', scope]);
|
|
193
|
+
}
|
|
194
|
+
actions.push(['plugin', 'install', pluginKey, '--scope', scope]);
|
|
195
|
+
return actions;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** AC-UAW-15(a) — restore what the platform's own `marketplace remove` is OBSERVED to blank.
|
|
199
|
+
* `before` is the scope's settings object captured BEFORE the first migration mutation; `after` is
|
|
200
|
+
* a fresh read taken once every migration action for this scope has run. Every top-level key
|
|
201
|
+
* `before` carried survives with its ORIGINAL value except `extraKnownMarketplaces` — the one key
|
|
202
|
+
* the migration exists to change — and `enabledPlugins` is unconditionally guaranteed to still name
|
|
203
|
+
* the plugin, regardless of what either side carried. */
|
|
204
|
+
export function repairScopeSettings(before, after, pluginKey) {
|
|
205
|
+
const repaired = { ...after };
|
|
206
|
+
for (const key of Object.keys(before)) {
|
|
207
|
+
if (key === 'extraKnownMarketplaces') continue;
|
|
208
|
+
repaired[key] = before[key];
|
|
209
|
+
}
|
|
210
|
+
repaired.enabledPlugins = { ...(repaired.enabledPlugins || {}), [pluginKey]: true };
|
|
211
|
+
return repaired;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Run one scope's migration end to end: the actions, a fresh read-back, the repair, and the
|
|
215
|
+
* atomic write (reusing floorReconcile.mjs's temp-in-directory + rename — the same mechanism the
|
|
216
|
+
* reconcile path already trusts for exactly this hazard). Returns the argv arrays actually
|
|
217
|
+
* invoked, for the caller's preview/summary. */
|
|
218
|
+
export function migrateScope({ scopeSnap, trigger, marketplaceName, marketplaceRepo, pluginKey, env, cwd, claudeBin }) {
|
|
219
|
+
const actions = migrationActions(trigger, {
|
|
220
|
+
scope: scopeSnap.name, marketplaceName, marketplaceRepo, pluginKey,
|
|
221
|
+
});
|
|
222
|
+
// R6: if an action throws PARTWAY (offline, a non-zero exit, ^C) after `marketplace remove` has
|
|
223
|
+
// already blanked this scope's settings, the repair below must STILL run before the failure
|
|
224
|
+
// propagates — otherwise the adopter is left both unregistered/disabled AND with the `before`
|
|
225
|
+
// snapshot this run captured now unused and lost. Whatever landed on disk (even a partial
|
|
226
|
+
// blank) is read back and repaired the same way a fully-successful run would; the original
|
|
227
|
+
// error is then rethrown so the caller still reports the run as failed.
|
|
228
|
+
let caught = null;
|
|
229
|
+
for (const args of actions) {
|
|
230
|
+
try {
|
|
231
|
+
runClaude(args, { env, cwd, claudeBin });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
caught = e;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (fs.existsSync(scopeSnap.settingsPath)) {
|
|
238
|
+
const after = readScopeSettings(scopeSnap).obj;
|
|
239
|
+
const repaired = repairScopeSettings(scopeSnap.obj, after, pluginKey);
|
|
240
|
+
writeTargetAtomically(scopeSnap.settingsPath, repaired);
|
|
241
|
+
}
|
|
242
|
+
if (caught) throw caught;
|
|
243
|
+
return actions;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── AC-UAW-6 — the verdict comes from the manifest read-back, never from stdout ─────────────────
|
|
247
|
+
|
|
248
|
+
export function readMarketplaceManifest(configDir, marketplaceName) {
|
|
249
|
+
const manifestPath = path.join(
|
|
250
|
+
configDir, 'plugins', 'marketplaces', marketplaceName, '.claude-plugin', 'marketplace.json',
|
|
251
|
+
);
|
|
252
|
+
try {
|
|
253
|
+
const doc = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
254
|
+
return { present: true, path: manifestPath, doc };
|
|
255
|
+
} catch {
|
|
256
|
+
return { present: false, path: manifestPath, doc: null };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function pluginEntryOf(manifestDoc, pluginName) {
|
|
261
|
+
const plugins = Array.isArray(manifestDoc && manifestDoc.plugins) ? manifestDoc.plugins : [];
|
|
262
|
+
return plugins.find((p) => p && p.name === pluginName) || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Both `version` AND `source.sha` must move for the run to be reported `refreshed` — the v1.4.1
|
|
266
|
+
* scar made mechanical. The invoked CLI's own stdout carries no verdict; it is never consulted.
|
|
267
|
+
* A manifest that APPEARED where none existed before (a fresh CLAUDE_CONFIG_DIR's first resolve)
|
|
268
|
+
* counts as refreshed too — `before === null` must not read as "nothing moved" when in fact
|
|
269
|
+
* everything just did. Only "still nothing after, as before" is genuinely unrefreshed. */
|
|
270
|
+
export function manifestRefreshed(before, after) {
|
|
271
|
+
if (!after) return false;
|
|
272
|
+
if (!before) return true;
|
|
273
|
+
const versionMoved = before.version !== after.version;
|
|
274
|
+
const shaMoved = (before.source && before.source.sha) !== (after.source && after.source.sha);
|
|
275
|
+
return versionMoved && shaMoved;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── AC-UAW-5 — every enabled scope, once, from the pre-migration snapshot ───────────────────────
|
|
279
|
+
|
|
280
|
+
export function enabledScopeNames(snapshot, pluginKey) {
|
|
281
|
+
return snapshot.filter((s) => isEnabled(s.obj, pluginKey)).map((s) => s.name);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function runPluginUpdate({ scope, pluginKey, env, cwd, claudeBin }) {
|
|
285
|
+
return runClaude(['plugin', 'update', pluginKey, '--scope', scope], { env, cwd, claudeBin });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── shared with cli/src/cleanup.mjs — the platform's own per-scope install registry ─────────────
|
|
289
|
+
|
|
290
|
+
/** `<config-dir>/plugins/installed_plugins.json` — schema READ LIVE on the operator's own machine
|
|
291
|
+
* (see the sibling workspace-update-cleanup spec): `{"version": 2, "plugins": {"<key>": [record,
|
|
292
|
+
* ...]}}`, one record per scope, each carrying `installPath`/`version` and — for a project-scope
|
|
293
|
+
* record — a `projectPath`. No published schema contract; an unrecognised `version` (including a
|
|
294
|
+
* future 3) is treated as indeterminate by every caller, never parsed optimistically. */
|
|
295
|
+
export function readInstalledPluginsRegistry(configDir) {
|
|
296
|
+
const registryPath = path.join(configDir, 'plugins', 'installed_plugins.json');
|
|
297
|
+
let raw;
|
|
298
|
+
try {
|
|
299
|
+
raw = fs.readFileSync(registryPath, 'utf-8');
|
|
300
|
+
} catch {
|
|
301
|
+
return { ok: false, reason: 'installed plugin registry is absent', doc: null };
|
|
302
|
+
}
|
|
303
|
+
let doc;
|
|
304
|
+
try {
|
|
305
|
+
doc = JSON.parse(raw);
|
|
306
|
+
} catch {
|
|
307
|
+
return { ok: false, reason: 'installed plugin registry does not parse as JSON', doc: null };
|
|
308
|
+
}
|
|
309
|
+
if (!doc || typeof doc !== 'object' || doc.version !== 2) {
|
|
310
|
+
return { ok: false, reason: 'installed plugin registry carries an unrecognised schema version', doc: null };
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, reason: null, doc };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function scopeRecordsFor(doc, pluginKey) {
|
|
316
|
+
const plugins = (doc && doc.plugins) || {};
|
|
317
|
+
return Array.isArray(plugins[pluginKey]) ? plugins[pluginKey] : [];
|
|
318
|
+
}
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// update.mjs — the `npx update-agentic-workspace` orchestrator: argv -> preflight -> preview ->
|
|
2
|
+
// Phase 1 (marketplace refresh + AC-UAW-4 migration) -> Phase 2 (plugin update) -> Phase 3
|
|
3
|
+
// (cleanup, opt-in) -> Phase 4 (reinitialization: managed-file + permission-floor reconcile) ->
|
|
4
|
+
// per-phase summary. No `claude` invocation lives in this file — every one goes through
|
|
5
|
+
// pluginRefresh.mjs's single allowlisted spawn site (AC-UAW-14); this file only decides WHICH
|
|
6
|
+
// invocations to make and WHEN, and performs Phase 4's file writes via the SAME never-clobber
|
|
7
|
+
// machinery run.mjs's create path already uses (cli/src/reconcile.mjs, cli/src/floorReconcile.mjs).
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { RefusalError, physicalResolve } from './util.mjs';
|
|
11
|
+
import { loadMap, buildSettings, classifyDrift } from './permissionFloor.mjs';
|
|
12
|
+
import { buildManagedFiles } from './scaffold.mjs';
|
|
13
|
+
import { planManagedFiles, applyPlan } from './reconcile.mjs';
|
|
14
|
+
import {
|
|
15
|
+
resolveTarget, readTarget, readTrackedRules, planAdditions, applyAdditions, writeTargetAtomically,
|
|
16
|
+
} from './floorReconcile.mjs';
|
|
17
|
+
import {
|
|
18
|
+
ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
|
|
19
|
+
defaultScopes, snapshotScopes, classifyMigration, migrationActions, migrateScope,
|
|
20
|
+
readMarketplaceManifest, pluginEntryOf, manifestRefreshed,
|
|
21
|
+
enabledScopeNames, runPluginUpdate,
|
|
22
|
+
readInstalledPluginsRegistry, scopeRecordsFor,
|
|
23
|
+
} from './pluginRefresh.mjs';
|
|
24
|
+
import { runCleanupPhase } from './cleanup.mjs';
|
|
25
|
+
|
|
26
|
+
export { ALLOWED_CLAUDE_SUBCOMMANDS };
|
|
27
|
+
|
|
28
|
+
/** The update entry point's OWN small flag table (Clarifications: "the update entry point carries
|
|
29
|
+
* its own small flag table, disjoint from the wizard's") — deliberately NOT cli/src/argv.mjs +
|
|
30
|
+
* QUESTION_TABLE, which is denied to the sibling cleanup atom and whose flag set is derived from
|
|
31
|
+
* the wizard's prompts, not this command's. `--cleanup` is the cleanup atom's own opt-in. */
|
|
32
|
+
export function parseUpdateArgv(argv) {
|
|
33
|
+
const values = { cleanup: false, help: false };
|
|
34
|
+
for (const tok of argv) {
|
|
35
|
+
if (tok === '--cleanup') values.cleanup = true;
|
|
36
|
+
else if (tok === '--help') values.help = true;
|
|
37
|
+
else throw new RefusalError(`unknown flag: ${tok}`, tok);
|
|
38
|
+
}
|
|
39
|
+
return values;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** AC-UAW-11 — a pure formatter, unit-tested on its own with exactly the three phases this atom
|
|
43
|
+
* itself defines (marketplace-refresh, plugin-update, reinitialization); the full orchestrator
|
|
44
|
+
* below may print a fourth `cleanup` row once the sibling atom's phase is wired in, which is a
|
|
45
|
+
* property of the RUNTIME composition, not of this formatter's own contract. */
|
|
46
|
+
export function renderSummary(phases) {
|
|
47
|
+
const lines = ['Summary:'];
|
|
48
|
+
for (const p of phases) {
|
|
49
|
+
const verdict = p.verdict === 'skipped' ? `skipped: ${p.reason}` : p.verdict;
|
|
50
|
+
lines.push(` [${p.name}] ${verdict}`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isInstalledInScopeFactory(registry, pluginKey, cwd) {
|
|
56
|
+
return (scopeName) => {
|
|
57
|
+
// `null` = cannot be determined. NEVER folded into `true` or `false` silently — an unreadable
|
|
58
|
+
// registry must not be read as "installed everywhere" (which would silently suppress a real
|
|
59
|
+
// orphaned-install healing) nor as "installed nowhere" (which would force an unneeded
|
|
60
|
+
// reinstall on every run); classifyMigration surfaces this as its own reported trigger kind.
|
|
61
|
+
if (!registry.ok) return null;
|
|
62
|
+
const records = scopeRecordsFor(registry.doc, pluginKey);
|
|
63
|
+
if (scopeName === 'user') return records.some((r) => r && !r.projectPath);
|
|
64
|
+
return records.some((r) => r && r.projectPath && path.resolve(r.projectPath) === path.resolve(cwd));
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run the update command end to end. Never throws — every failure path is caught and turned into
|
|
69
|
+
* a refusal-shaped exit 1 (or, for a bug, exit 1 with the error message), matching run.mjs's own
|
|
70
|
+
* contract. */
|
|
71
|
+
export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output, spawnEnv = process.env }) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const print = (s) => {
|
|
74
|
+
lines.push(s);
|
|
75
|
+
output.write(`${s}\n`);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
let flags;
|
|
80
|
+
try {
|
|
81
|
+
flags = parseUpdateArgv(argv);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e instanceof RefusalError) {
|
|
84
|
+
print(`refused: ${e.message}`);
|
|
85
|
+
return { exitCode: 1, output: lines.join('\n') };
|
|
86
|
+
}
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (flags.help) {
|
|
91
|
+
print([
|
|
92
|
+
'Usage: update-agentic-workspace [--cleanup] [--help]',
|
|
93
|
+
'',
|
|
94
|
+
' --cleanup Also prune superseded plugin-cache versions and remove a stale or',
|
|
95
|
+
' duplicate marketplace registration (previewed either way; only',
|
|
96
|
+
' removed under this flag). Off by default.',
|
|
97
|
+
' --help Show this help and exit.',
|
|
98
|
+
].join('\n'));
|
|
99
|
+
return { exitCode: 0, output: lines.join('\n') };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const pins = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')).foundry;
|
|
103
|
+
const marketplaceName = pins.marketplace_name;
|
|
104
|
+
const marketplaceRepo = pins.marketplace_repo;
|
|
105
|
+
const pluginKey = `${pins.plugin_name}@${pins.marketplace_name}`;
|
|
106
|
+
|
|
107
|
+
// ── AC-UAW-13(a): the claude executable must resolve on PATH before anything else runs ──────
|
|
108
|
+
// R8: the RESOLVED path is what the rest of the run spawns — never the bare name re-resolved
|
|
109
|
+
// against env.PATH a second time, which would leave this preflight proving something the
|
|
110
|
+
// actual spawn calls do not rely on.
|
|
111
|
+
const claudeBin = resolveClaudeOnPath(spawnEnv.PATH);
|
|
112
|
+
if (!claudeBin) {
|
|
113
|
+
throw new RefusalError('claude executable not found on PATH');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── AC-UAW-15(b): snapshot every scope BEFORE any mutation; AC-UAW-13(b) refuses here too ───
|
|
117
|
+
const scopes = defaultScopes({ cwd, configDir });
|
|
118
|
+
const snapshot = snapshotScopes(scopes);
|
|
119
|
+
const enabledScopes = enabledScopeNames(snapshot, pluginKey);
|
|
120
|
+
|
|
121
|
+
const registry = readInstalledPluginsRegistry(configDir);
|
|
122
|
+
const isInstalled = isInstalledInScopeFactory(registry, pluginKey, cwd);
|
|
123
|
+
|
|
124
|
+
const migrations = [];
|
|
125
|
+
const indeterminateInstalledness = [];
|
|
126
|
+
for (const scopeSnap of snapshot) {
|
|
127
|
+
const trigger = classifyMigration(scopeSnap, { marketplaceName, pluginKey, isInstalled });
|
|
128
|
+
if (!trigger) continue;
|
|
129
|
+
if (trigger.kind === 'indeterminate-installedness') {
|
|
130
|
+
indeterminateInstalledness.push(scopeSnap.name);
|
|
131
|
+
} else {
|
|
132
|
+
migrations.push({ scopeSnap, trigger });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Phase 4's plan, computed but NOT applied yet — needed for the preview below ─────────────
|
|
137
|
+
const physicalRoot = physicalResolve(cwd);
|
|
138
|
+
const map = loadMap(path.join(pkgDir, 'permission-floor.json'));
|
|
139
|
+
const shippedSettings = buildSettings(map, pins);
|
|
140
|
+
const settingsBytes = Buffer.from(`${JSON.stringify(shippedSettings, null, 2)}\n`, 'utf-8');
|
|
141
|
+
const managedFiles = buildManagedFiles({
|
|
142
|
+
templatesDir: path.join(pkgDir, 'templates'),
|
|
143
|
+
physicalRoot,
|
|
144
|
+
projectName: path.basename(physicalRoot),
|
|
145
|
+
stageMode: 'lean',
|
|
146
|
+
settingsBytes,
|
|
147
|
+
});
|
|
148
|
+
const filePlan = planManagedFiles(managedFiles);
|
|
149
|
+
|
|
150
|
+
// This is a PREVIEW-ONLY computation: `.claude/settings.json` is also `project` scope's
|
|
151
|
+
// settings file, and Phase 1's migration (below) may write to that SAME path. Applying THIS
|
|
152
|
+
// captured plan verbatim in Phase 4 would silently clobber whatever Phase 1 just wrote —
|
|
153
|
+
// Phase 4 therefore re-reads and recomputes the floor plan fresh, right before it writes.
|
|
154
|
+
const floorTarget = resolveTarget(physicalRoot);
|
|
155
|
+
const previewFloorPlan = floorTarget.present
|
|
156
|
+
? planAdditions({
|
|
157
|
+
findings: classifyDrift(map, readTrackedRules(readTarget(floorTarget.path)), {
|
|
158
|
+
pluginRootExpansion: [], unreadableOrigins: [], home: homeDir,
|
|
159
|
+
}),
|
|
160
|
+
map, settingsObj: readTarget(floorTarget.path), pins,
|
|
161
|
+
})
|
|
162
|
+
: null;
|
|
163
|
+
|
|
164
|
+
// ── AC-UAW-7: the preview, before the first `claude` invocation and the first write ─────────
|
|
165
|
+
const previewLines = ['The following claude invocations will be made:'];
|
|
166
|
+
for (const { scopeSnap, trigger } of migrations) {
|
|
167
|
+
for (const args of migrationActions(trigger, {
|
|
168
|
+
scope: scopeSnap.name, marketplaceName, marketplaceRepo, pluginKey,
|
|
169
|
+
})) {
|
|
170
|
+
previewLines.push(` claude ${args.join(' ')}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
previewLines.push(` claude plugin marketplace update ${marketplaceName}`);
|
|
174
|
+
for (const scopeName of enabledScopes) {
|
|
175
|
+
previewLines.push(` claude plugin update ${pluginKey} --scope ${scopeName}`);
|
|
176
|
+
}
|
|
177
|
+
// C3 / AC-UAW-7: the concrete cleanup candidate list cannot be known until Phases 1-2 have
|
|
178
|
+
// refreshed the manifest, so it cannot be named here by path — but the SHAPE of what it may
|
|
179
|
+
// do, and whether this run can act on it at all, is known up front and disclosed here rather
|
|
180
|
+
// than only in the phase's own later, post-mutation block.
|
|
181
|
+
const cleanupCacheRoot = path.join(configDir, 'plugins', 'cache', marketplaceName, pins.plugin_name);
|
|
182
|
+
previewLines.push(
|
|
183
|
+
` cleanup phase (${flags.cleanup ? 'will remove what it finds' : 'report-only — nothing removed without --cleanup'}):`,
|
|
184
|
+
);
|
|
185
|
+
previewLines.push(` may prune superseded versions under ${cleanupCacheRoot}`);
|
|
186
|
+
previewLines.push(` may invoke claude plugin marketplace remove <name> --scope <scope> for a stale/duplicate registration`);
|
|
187
|
+
previewLines.push('The following workspace paths will be reconciled (never-clobber):');
|
|
188
|
+
for (const f of filePlan) previewLines.push(` [${f.action}] ${f.relPath}`);
|
|
189
|
+
if (previewFloorPlan) {
|
|
190
|
+
previewLines.push(` [permission-floor] would add allow=${previewFloorPlan.additions.allow.length}, ask=${previewFloorPlan.additions.ask.length}, deny=${previewFloorPlan.additions.deny.length}`);
|
|
191
|
+
} else {
|
|
192
|
+
previewLines.push(' [permission-floor] .claude/settings.json absent — left to the create path');
|
|
193
|
+
}
|
|
194
|
+
print(previewLines.join('\n'));
|
|
195
|
+
|
|
196
|
+
const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
|
|
197
|
+
const phases = [];
|
|
198
|
+
|
|
199
|
+
// ── Phase 1: marketplace refresh ─────────────────────────────────────────────────────────────
|
|
200
|
+
let anyMigrated = false;
|
|
201
|
+
for (const { scopeSnap, trigger } of migrations) {
|
|
202
|
+
migrateScope({ scopeSnap, trigger, marketplaceName, marketplaceRepo, pluginKey, env, cwd, claudeBin });
|
|
203
|
+
anyMigrated = true;
|
|
204
|
+
}
|
|
205
|
+
const manifestBefore = readMarketplaceManifest(configDir, marketplaceName);
|
|
206
|
+
const beforeEntry = manifestBefore.present ? pluginEntryOf(manifestBefore.doc, pins.plugin_name) : null;
|
|
207
|
+
runClaude(['plugin', 'marketplace', 'update', marketplaceName], { env, cwd, claudeBin });
|
|
208
|
+
const manifestAfter = readMarketplaceManifest(configDir, marketplaceName);
|
|
209
|
+
const afterEntry = manifestAfter.present ? pluginEntryOf(manifestAfter.doc, pins.plugin_name) : null;
|
|
210
|
+
const refreshed = manifestRefreshed(beforeEntry, afterEntry);
|
|
211
|
+
// R (risk, not block): an unreadable installed_plugins.json must not silently suppress the
|
|
212
|
+
// orphaned-install trigger for a tagless, enabled scope — surfaced here rather than folded
|
|
213
|
+
// into a plain 'changed'/'already current' verdict.
|
|
214
|
+
const marketplaceRefreshVerdict = anyMigrated || refreshed ? 'changed' : 'already current';
|
|
215
|
+
phases.push(
|
|
216
|
+
indeterminateInstalledness.length > 0
|
|
217
|
+
? {
|
|
218
|
+
name: 'marketplace-refresh',
|
|
219
|
+
verdict: 'skipped',
|
|
220
|
+
reason: `installedness could not be determined for scope(s) ${indeterminateInstalledness.join(', ')} (installed plugin registry unreadable) — the orphaned-install trigger was not evaluated there; every other migration still ran (${marketplaceRefreshVerdict})`,
|
|
221
|
+
}
|
|
222
|
+
: { name: 'marketplace-refresh', verdict: marketplaceRefreshVerdict },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// ── Phase 2: plugin update, once per PRE-migration-snapshot enabled scope (AC-UAW-15b) ──────
|
|
226
|
+
for (const scopeName of enabledScopes) {
|
|
227
|
+
runPluginUpdate({ scope: scopeName, pluginKey, env, cwd, claudeBin });
|
|
228
|
+
}
|
|
229
|
+
phases.push({ name: 'plugin-update', verdict: refreshed ? 'changed' : 'already current' });
|
|
230
|
+
|
|
231
|
+
// ── Phase 3: cleanup (sibling atom; always previewed, only acts under --cleanup) ────────────
|
|
232
|
+
const cleanupScopeDescriptors = scopes; // same {name, settingsPath} pairs, unresolved-required
|
|
233
|
+
const cleanupResult = runCleanupPhase({
|
|
234
|
+
cleanupFlag: flags.cleanup,
|
|
235
|
+
configDir, marketplaceName, marketplaceRepo, pluginName: pins.plugin_name, pluginKey,
|
|
236
|
+
scopeDescriptors: cleanupScopeDescriptors, env, cwd, print, claudeBin,
|
|
237
|
+
});
|
|
238
|
+
phases.push({ name: 'cleanup', verdict: cleanupResult.verdict, reason: cleanupResult.reason });
|
|
239
|
+
|
|
240
|
+
// ── Phase 4: reinitialization — managed files, then the additive floor reconcile ───────────
|
|
241
|
+
applyPlan(filePlan);
|
|
242
|
+
// Recomputed FRESH from disk — never the preview-time `previewFloorPlan` — because Phase 1's
|
|
243
|
+
// migration may have just rewritten this exact file (project scope's settings.json IS the
|
|
244
|
+
// floor-reconcile target). Applying a stale pre-migration plan here would silently clobber it.
|
|
245
|
+
let floorPlan = null;
|
|
246
|
+
const freshFloorTarget = resolveTarget(physicalRoot);
|
|
247
|
+
if (freshFloorTarget.present) {
|
|
248
|
+
const settingsObj = readTarget(freshFloorTarget.path);
|
|
249
|
+
const findings = classifyDrift(map, readTrackedRules(settingsObj), {
|
|
250
|
+
pluginRootExpansion: [], unreadableOrigins: [], home: homeDir,
|
|
251
|
+
});
|
|
252
|
+
floorPlan = planAdditions({ findings, map, settingsObj, pins });
|
|
253
|
+
floorPlan.settingsObj = settingsObj;
|
|
254
|
+
if (floorPlan.total > 0) {
|
|
255
|
+
writeTargetAtomically(freshFloorTarget.path, applyAdditions(settingsObj, floorPlan, { map, pins }));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const anyCreated = filePlan.some((f) => f.action === 'create');
|
|
259
|
+
const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0);
|
|
260
|
+
phases.push({ name: 'reinitialization', verdict: anyCreated || anyFloorAdded ? 'changed' : 'already current' });
|
|
261
|
+
|
|
262
|
+
print('');
|
|
263
|
+
print(renderSummary(phases));
|
|
264
|
+
|
|
265
|
+
const anyDrifted = filePlan.some((f) => f.action === 'drifted');
|
|
266
|
+
return { exitCode: anyDrifted ? 2 : 0, output: lines.join('\n') };
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e instanceof RefusalError) {
|
|
269
|
+
print(`refused: ${e.message}`);
|
|
270
|
+
return { exitCode: 1, output: lines.join('\n') };
|
|
271
|
+
}
|
|
272
|
+
print(`error: ${e.stack || e.message}`);
|
|
273
|
+
return { exitCode: 1, output: lines.join('\n') };
|
|
274
|
+
}
|
|
275
|
+
}
|