clean-room-skill 0.1.12 → 0.1.13

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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +32 -5
  5. package/agents/clean-architect.md +3 -0
  6. package/agents/clean-implementer-verifier-shell.md +3 -0
  7. package/agents/clean-polish-reviewer.md +3 -0
  8. package/agents/clean-qa-editor.md +3 -0
  9. package/agents/contaminated-handoff-sanitizer.md +3 -0
  10. package/agents/contaminated-manager-verifier.md +3 -0
  11. package/agents/contaminated-source-analyst.md +3 -0
  12. package/bin/install.js +11 -1621
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/HOOKS.md +14 -10
  15. package/docs/REFERENCE.md +24 -4
  16. package/examples/codex/.codex/agents/clean-architect.toml +3 -3
  17. package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
  18. package/examples/codex/.codex/agents/clean-qa-editor.toml +2 -2
  19. package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
  20. package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +3 -3
  21. package/examples/codex/.codex/agents/contaminated-source-analyst.toml +2 -2
  22. package/lib/bootstrap.cjs +5 -1
  23. package/lib/doctor.cjs +157 -5
  24. package/lib/hooks.cjs +18 -0
  25. package/lib/install-artifacts.cjs +178 -4
  26. package/lib/install-claude-plugin.cjs +374 -0
  27. package/lib/install-cli.cjs +99 -0
  28. package/lib/install-operations.cjs +376 -0
  29. package/lib/install-options.cjs +149 -0
  30. package/lib/install-runtime-selection.cjs +180 -0
  31. package/lib/install-status.cjs +292 -0
  32. package/lib/install-tui.cjs +359 -0
  33. package/lib/preflight-bootstrap.cjs +39 -0
  34. package/lib/preflight-cli.cjs +95 -0
  35. package/lib/preflight-constants.cjs +25 -0
  36. package/lib/preflight-output.cjs +37 -0
  37. package/lib/preflight-paths.cjs +67 -0
  38. package/lib/preflight-template.cjs +103 -0
  39. package/lib/preflight-validation.cjs +276 -0
  40. package/lib/preflight.cjs +18 -461
  41. package/lib/run-clean-artifacts.cjs +276 -0
  42. package/lib/run-cli.cjs +90 -0
  43. package/lib/run-constants.cjs +171 -0
  44. package/lib/run-controller.cjs +247 -0
  45. package/lib/run-coverage.cjs +350 -0
  46. package/lib/run-hooks.cjs +96 -0
  47. package/lib/run-manifest.cjs +111 -0
  48. package/lib/run-progress.cjs +160 -0
  49. package/lib/run-results.cjs +433 -0
  50. package/lib/run-roots.cjs +230 -0
  51. package/lib/run-stages.cjs +409 -0
  52. package/lib/run.cjs +4 -2254
  53. package/lib/runtime-layout.cjs +12 -5
  54. package/package.json +8 -2
  55. package/plugin.json +1 -1
  56. package/skills/attended/SKILL.md +2 -0
  57. package/skills/clean-room/SKILL.md +2 -2
  58. package/skills/unattended/SKILL.md +2 -0
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ const { assertManagedPath, fileHash } = require('./fs-utils.cjs');
6
+ const {
7
+ configPathForRuntime,
8
+ hasManagedHookEntries,
9
+ hasManagedOpenCodePlugin,
10
+ pluginPathForRuntime,
11
+ } = require('./hooks.cjs');
12
+ const { buildDesiredFiles, packageVersion } = require('./install-artifacts.cjs');
13
+ const {
14
+ manifestHash,
15
+ planInstall,
16
+ readManifest,
17
+ } = require('./install-plan.cjs');
18
+ const {
19
+ CLAUDE_PLUGIN_ID,
20
+ CLAUDE_PLUGIN_MARKETPLACE_NAME,
21
+ } = require('./install-claude-plugin.cjs');
22
+ const { isUpdateTargetStatus } = require('./install-runtime-selection.cjs');
23
+ const {
24
+ RUNTIMES,
25
+ resolveRuntimeLayout,
26
+ } = require('./runtime-layout.cjs');
27
+
28
+ function runtimeInstallStatuses(scope, configDir) {
29
+ return RUNTIMES.map((runtime) => runtimeInstallStatus(runtime, scope, configDir));
30
+ }
31
+
32
+ function runtimeInstallStatus(runtime, scope, configDir) {
33
+ const layout = resolveRuntimeLayout(runtime, scope, { configDir });
34
+ const status = {
35
+ runtime,
36
+ targetRoot: layout.targetRoot,
37
+ state: 'not-installed',
38
+ detail: 'not installed',
39
+ };
40
+ try {
41
+ const manifest = readManifest(layout.targetRoot);
42
+ if (manifest) {
43
+ const phase = manifest.phase ? `phase ${manifest.phase}` : 'manifest present';
44
+ const hooksMode = manifest.hooks_mode ? `, hooks ${manifest.hooks_mode}` : '';
45
+ return {
46
+ ...status,
47
+ state: 'installed',
48
+ detail: `${phase}${hooksMode}`,
49
+ };
50
+ }
51
+ } catch (err) {
52
+ return {
53
+ ...status,
54
+ state: 'error',
55
+ detail: err.message,
56
+ };
57
+ }
58
+
59
+ if (!layout.supportsHookRegistration) {
60
+ return status;
61
+ }
62
+ const hookState = detectHookRegistration(layout, configPathForRuntime(runtime, layout.targetRoot));
63
+ if (hookState === 'present') {
64
+ return {
65
+ ...status,
66
+ state: 'hooks-only',
67
+ detail: 'managed hooks without install manifest',
68
+ };
69
+ }
70
+ if (hookState.startsWith('error: ')) {
71
+ return {
72
+ ...status,
73
+ state: 'error',
74
+ detail: hookState.slice('error: '.length),
75
+ };
76
+ }
77
+ return status;
78
+ }
79
+
80
+ function collectRuntimeStatus(runtime, scope, configDir) {
81
+ const layout = resolveRuntimeLayout(runtime, scope, { configDir });
82
+ const base = {
83
+ runtime,
84
+ scope,
85
+ targetRoot: layout.targetRoot,
86
+ supportsHookRegistration: layout.supportsHookRegistration,
87
+ state: 'not-installed',
88
+ detail: 'not installed',
89
+ installedVersion: null,
90
+ currentVersion: packageVersion(),
91
+ hooksMode: null,
92
+ phase: null,
93
+ files: 0,
94
+ missing: 0,
95
+ modified: 0,
96
+ stale: 0,
97
+ unknownConflicts: 0,
98
+ hookRegistration: layout.supportsHookRegistration ? 'none' : 'unsupported',
99
+ updateAvailable: false,
100
+ claudePlugin: null,
101
+ issues: [],
102
+ };
103
+
104
+ let manifest;
105
+ try {
106
+ manifest = readManifest(layout.targetRoot);
107
+ } catch (err) {
108
+ return {
109
+ ...base,
110
+ state: 'error',
111
+ detail: err.message,
112
+ issues: [err.message],
113
+ };
114
+ }
115
+
116
+ const configPath = configPathForRuntime(runtime, layout.targetRoot);
117
+ const hookState = detectHookRegistration(layout, configPath);
118
+ if (!manifest) {
119
+ if (hookState === 'present') {
120
+ return {
121
+ ...base,
122
+ state: 'hooks-only',
123
+ detail: 'managed hooks without install manifest',
124
+ hookRegistration: 'present',
125
+ issues: ['managed hooks exist without an install manifest'],
126
+ };
127
+ }
128
+ return base;
129
+ }
130
+
131
+ const hooksMode = manifest.hooks_mode || 'safe';
132
+ let desired;
133
+ let plan;
134
+ let fileStats;
135
+ try {
136
+ desired = buildDesiredFiles(layout, hooksMode);
137
+ plan = planInstall(layout.targetRoot, desired, manifest);
138
+ fileStats = manifestFileStats(layout.targetRoot, manifest);
139
+ } catch (err) {
140
+ return {
141
+ ...base,
142
+ state: 'error',
143
+ detail: err.message,
144
+ installedVersion: manifest.version || null,
145
+ hooksMode,
146
+ phase: manifest.phase || null,
147
+ hookRegistration: hookState,
148
+ issues: [err.message],
149
+ };
150
+ }
151
+ const issues = [];
152
+ if (manifest.phase && manifest.phase !== 'complete') {
153
+ issues.push(`manifest phase is ${manifest.phase}`);
154
+ }
155
+ if (fileStats.missing > 0) {
156
+ issues.push(`${fileStats.missing} managed file(s) missing`);
157
+ }
158
+ if (fileStats.modified > 0) {
159
+ issues.push(`${fileStats.modified} managed file(s) locally modified`);
160
+ }
161
+ if (plan.removals.length > 0) {
162
+ issues.push(`${plan.removals.length} stale managed file(s)`);
163
+ }
164
+ if (plan.unknownConflicts.length > 0) {
165
+ issues.push(`${plan.unknownConflicts.length} unmanaged package-path conflict(s)`);
166
+ }
167
+ if (layout.supportsHookRegistration && hooksMode !== 'copy-only' && hookState !== 'present') {
168
+ issues.push('managed hook registration missing');
169
+ }
170
+
171
+ const updateAvailable = manifest.version !== packageVersion() ||
172
+ plan.removals.length > 0 ||
173
+ plan.unknownConflicts.length > 0 ||
174
+ fileStats.missing > 0;
175
+
176
+ return {
177
+ ...base,
178
+ state: updateAvailable ? 'update-available' : 'installed',
179
+ detail: updateAvailable ? 'update available' : 'installed',
180
+ installedVersion: manifest.version || null,
181
+ hooksMode,
182
+ phase: manifest.phase || null,
183
+ files: Object.keys(manifest.files || {}).length,
184
+ missing: fileStats.missing,
185
+ modified: fileStats.modified,
186
+ stale: plan.removals.length,
187
+ unknownConflicts: plan.unknownConflicts.length,
188
+ hookRegistration: hookState,
189
+ updateAvailable,
190
+ claudePlugin: manifest.claude_plugin || null,
191
+ issues,
192
+ };
193
+ }
194
+
195
+ function detectHookRegistration(layout, configPath) {
196
+ if (!layout.supportsHookRegistration) {
197
+ return 'unsupported';
198
+ }
199
+ if (layout.hookRegistration === 'local-plugin') {
200
+ try {
201
+ return hasManagedOpenCodePlugin(pluginPathForRuntime(layout.runtime, layout.targetRoot)) ? 'present' : 'missing';
202
+ } catch (err) {
203
+ return `error: ${err.message}`;
204
+ }
205
+ }
206
+ if (layout.hookRegistration !== 'json-config' || !configPath) {
207
+ return 'unsupported';
208
+ }
209
+ try {
210
+ return hasManagedHookEntries(configPath) ? 'present' : 'missing';
211
+ } catch (err) {
212
+ return `error: ${err.message}`;
213
+ }
214
+ }
215
+
216
+ function manifestFileStats(targetRoot, manifest) {
217
+ let missing = 0;
218
+ let modified = 0;
219
+ for (const relPath of Object.keys(manifest?.files || {})) {
220
+ const fullPath = assertManagedPath(targetRoot, relPath);
221
+ if (!fs.existsSync(fullPath)) {
222
+ missing += 1;
223
+ continue;
224
+ }
225
+ const expected = manifestHash(manifest, relPath);
226
+ if (expected && fileHash(fullPath) !== expected) {
227
+ modified += 1;
228
+ }
229
+ }
230
+ return { missing, modified };
231
+ }
232
+
233
+ function printStatusReport(statuses) {
234
+ console.log(`clean-room-skill package version: ${packageVersion()}`);
235
+ for (const status of statuses) {
236
+ console.log(`${status.runtime} (${status.scope}) ${status.state}`);
237
+ console.log(` target: ${status.targetRoot}`);
238
+ if (status.installedVersion) {
239
+ console.log(` version: ${status.installedVersion}${status.installedVersion !== status.currentVersion ? ` -> ${status.currentVersion}` : ''}`);
240
+ console.log(` phase: ${status.phase || 'unknown'}`);
241
+ console.log(` hooks: ${status.hooksMode || 'unknown'}; registration ${status.hookRegistration}`);
242
+ console.log(` files: ${status.files}; missing ${status.missing}; modified ${status.modified}; stale ${status.stale}; conflicts ${status.unknownConflicts}`);
243
+ if (status.claudePlugin) {
244
+ console.log(` plugin: ${status.claudePlugin.plugin_id || CLAUDE_PLUGIN_ID}; marketplace ${status.claudePlugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
245
+ }
246
+ } else if (status.hookRegistration === 'present') {
247
+ console.log(' hooks: managed hook registration present without install manifest');
248
+ }
249
+ if (status.issues.length > 0) {
250
+ console.log(` issues: ${status.issues.join('; ')}`);
251
+ }
252
+ }
253
+ }
254
+
255
+ function selectedStatusRuntimes(options) {
256
+ return options.runtimes.length > 0 ? options.runtimes : [...RUNTIMES];
257
+ }
258
+
259
+ function selectedUpdateRuntimes(options) {
260
+ if (options.runtimes.length > 0) {
261
+ return options.runtimes;
262
+ }
263
+ return runtimeInstallStatuses(options.scope, options.configDir)
264
+ .filter((status) => isUpdateTargetStatus(status))
265
+ .map((status) => status.runtime);
266
+ }
267
+
268
+ function runStatus(options) {
269
+ const runtimes = selectedStatusRuntimes(options);
270
+ const statuses = runtimes.map((runtime) =>
271
+ collectRuntimeStatus(runtime, options.scope, options.configDir)
272
+ );
273
+ printStatusReport(statuses);
274
+ return statuses;
275
+ }
276
+
277
+ function resolveTargetRoot(runtime, scope, configDir) {
278
+ return resolveRuntimeLayout(runtime, scope, { configDir }).targetRoot;
279
+ }
280
+
281
+ module.exports = {
282
+ collectRuntimeStatus,
283
+ detectHookRegistration,
284
+ manifestFileStats,
285
+ printStatusReport,
286
+ resolveTargetRoot,
287
+ runStatus,
288
+ runtimeInstallStatus,
289
+ runtimeInstallStatuses,
290
+ selectedStatusRuntimes,
291
+ selectedUpdateRuntimes,
292
+ };
@@ -0,0 +1,359 @@
1
+ 'use strict';
2
+
3
+ const { operationForOptions } = require('./install-options.cjs');
4
+ const {
5
+ defaultRuntimeSelections,
6
+ detectedRuntimeSelections,
7
+ displayPath,
8
+ emptyRuntimeSelectionMessage,
9
+ isSelectableRuntimeStatus,
10
+ selectableRuntimeSelections,
11
+ unavailableRuntimeSelectionMessage,
12
+ } = require('./install-runtime-selection.cjs');
13
+ const { runtimeInstallStatuses } = require('./install-status.cjs');
14
+ const { RUNTIMES } = require('./runtime-layout.cjs');
15
+
16
+ async function resolveInteractiveOptions(options) {
17
+ if (options.runtimes.length > 0 && options.scope) {
18
+ return options;
19
+ }
20
+ if (!process.stdin.isTTY || options.yes) {
21
+ throw new Error('specify runtime and scope flags when running non-interactively');
22
+ }
23
+ return runInstallerTui(options);
24
+ }
25
+
26
+ async function runInstallerTui(options) {
27
+ const React = await import('react');
28
+ const ink = await import('ink');
29
+ const h = React.createElement;
30
+
31
+ return new Promise((resolve, reject) => {
32
+ let result = null;
33
+ let error = null;
34
+
35
+ function complete(nextOptions) {
36
+ result = nextOptions;
37
+ }
38
+
39
+ function abort(err) {
40
+ error = err;
41
+ }
42
+
43
+ function App() {
44
+ return h(InstallerTui, {
45
+ React,
46
+ ink,
47
+ h,
48
+ initialOptions: options,
49
+ onComplete: complete,
50
+ onAbort: abort,
51
+ });
52
+ }
53
+
54
+ const instance = ink.render(h(App), {
55
+ stdin: process.stdin,
56
+ stdout: process.stdout,
57
+ stderr: process.stderr,
58
+ exitOnCtrlC: false,
59
+ });
60
+
61
+ instance.waitUntilExit().then(() => {
62
+ if (error) {
63
+ reject(error);
64
+ return;
65
+ }
66
+ resolve(result || options);
67
+ }, reject);
68
+ });
69
+ }
70
+
71
+ function InstallerTui({ React, ink, h, initialOptions, onComplete, onAbort }) {
72
+ const { Box, Text, useApp, useInput } = ink;
73
+ const { useMemo, useState } = React;
74
+ const { exit } = useApp();
75
+ const initialFlags = useMemo(() => ({
76
+ actionResolved: !!initialOptions.operation ||
77
+ !(initialOptions.runtimes.length === 0 && !initialOptions.uninstall),
78
+ promptedRuntimes: false,
79
+ uninstallConfirmed: true,
80
+ }), [initialOptions]);
81
+ const [draft, setDraft] = useState(() => ({
82
+ ...initialOptions,
83
+ runtimes: [...initialOptions.runtimes],
84
+ }));
85
+ const [flags, setFlags] = useState(initialFlags);
86
+ const [stage, setStage] = useState(() => nextTuiStage(initialOptions, initialFlags));
87
+
88
+ function fail(message) {
89
+ onAbort(new Error(message));
90
+ exit();
91
+ }
92
+
93
+ useInput((input, key) => {
94
+ if (key.ctrl && input === 'c') {
95
+ fail('aborted by user');
96
+ }
97
+ });
98
+
99
+ function advance(nextDraft, nextFlags = {}) {
100
+ const mergedFlags = { ...flags, ...nextFlags };
101
+ const nextStage = nextTuiStage(nextDraft, mergedFlags);
102
+ setDraft(nextDraft);
103
+ setFlags(mergedFlags);
104
+ if (nextStage === 'complete') {
105
+ onComplete(nextDraft);
106
+ exit();
107
+ return;
108
+ }
109
+ setStage(nextStage);
110
+ }
111
+
112
+ const action = operationForOptions(draft);
113
+
114
+ return h(Box, { flexDirection: 'column', gap: 1 },
115
+ h(Box, { flexDirection: 'column' },
116
+ h(Text, { bold: true }, 'clean-room-skill installer'),
117
+ h(Text, { dimColor: true }, 'Use arrows or j/k to move. Enter selects. Ctrl+C cancels.')
118
+ ),
119
+ stage === 'action' && h(SingleChoice, {
120
+ React,
121
+ Box,
122
+ Text,
123
+ useInput,
124
+ h,
125
+ title: 'Action',
126
+ initialIndex: defaultActionIndex(draft),
127
+ items: [
128
+ { label: 'Update', value: 'update', detail: 'refresh installed runtimes without onboarding' },
129
+ { label: 'Install', value: 'install', detail: 'add or repair runtime files' },
130
+ { label: 'Uninstall', value: 'uninstall', detail: 'remove managed files and generated hooks' },
131
+ { label: 'Status', value: 'status', detail: 'inspect runtime installs without changing files' },
132
+ ],
133
+ onSubmit: (item) => advance({
134
+ ...draft,
135
+ operation: item.value,
136
+ uninstall: item.value === 'uninstall',
137
+ }, {
138
+ actionResolved: true,
139
+ uninstallConfirmed: item.value !== 'uninstall',
140
+ }),
141
+ }),
142
+ stage === 'scope' && h(SingleChoice, {
143
+ React,
144
+ Box,
145
+ Text,
146
+ useInput,
147
+ h,
148
+ title: 'Scope',
149
+ items: [
150
+ { label: 'Global', value: 'global', detail: 'runtime user config' },
151
+ { label: 'Local', value: 'local', detail: 'current project config' },
152
+ ],
153
+ onSubmit: (item) => advance({ ...draft, scope: item.value }),
154
+ }),
155
+ stage === 'runtimes' && h(RuntimeMultiSelect, {
156
+ React,
157
+ Box,
158
+ Text,
159
+ useInput,
160
+ h,
161
+ action,
162
+ statuses: runtimeInstallStatuses(draft.scope, draft.configDir),
163
+ onSubmit: (runtimes) => advance({ ...draft, runtimes }, {
164
+ promptedRuntimes: true,
165
+ uninstallConfirmed: operationForOptions(draft) !== 'uninstall',
166
+ }),
167
+ }),
168
+ stage === 'confirm-uninstall' && h(ConfirmUninstall, {
169
+ React,
170
+ Box,
171
+ Text,
172
+ useInput,
173
+ h,
174
+ runtimes: draft.runtimes,
175
+ onSubmit: () => advance(draft, { uninstallConfirmed: true }),
176
+ }),
177
+ stage === 'hooks' && h(SingleChoice, {
178
+ React,
179
+ Box,
180
+ Text,
181
+ useInput,
182
+ h,
183
+ title: 'Hook mode',
184
+ items: [
185
+ { label: 'Safe', value: 'safe', detail: 'enforces during clean-room role sessions' },
186
+ { label: 'Copy-only', value: 'copy-only', detail: 'copy scripts without host hook registration' },
187
+ { label: 'Strict', value: 'strict', detail: 'fail closed in dedicated Codex, Claude, or OpenCode homes' },
188
+ ],
189
+ onSubmit: (item) => advance({ ...draft, hookMode: item.value, hookModeSpecified: true }),
190
+ })
191
+ );
192
+ }
193
+
194
+ function defaultActionIndex(options) {
195
+ if (operationForOptions(options) === 'status') return 3;
196
+ if (operationForOptions(options) === 'uninstall') return 2;
197
+ if (runtimeInstallStatuses(options.scope || 'global', options.configDir).some((status) => status.state === 'installed')) {
198
+ return 0;
199
+ }
200
+ return 1;
201
+ }
202
+
203
+ function nextTuiStage(options, flags) {
204
+ if (!options.scope) {
205
+ return 'scope';
206
+ }
207
+ if (!flags.actionResolved) {
208
+ return 'action';
209
+ }
210
+ if (operationForOptions(options) === 'status') {
211
+ return 'complete';
212
+ }
213
+ if (options.runtimes.length === 0) {
214
+ return 'runtimes';
215
+ }
216
+ if (operationForOptions(options) === 'uninstall' && flags.promptedRuntimes && !flags.uninstallConfirmed) {
217
+ return 'confirm-uninstall';
218
+ }
219
+ if (operationForOptions(options) === 'install' && !options.hookModeSpecified) {
220
+ return 'hooks';
221
+ }
222
+ return 'complete';
223
+ }
224
+
225
+ function SingleChoice({ React, Box, Text, useInput, h, title, items, initialIndex = 0, onSubmit }) {
226
+ const [index, setIndex] = React.useState(initialIndex);
227
+ useInput((input, key) => {
228
+ if (key.upArrow || input === 'k') {
229
+ setIndex((current) => Math.max(0, current - 1));
230
+ } else if (key.downArrow || input === 'j') {
231
+ setIndex((current) => Math.min(items.length - 1, current + 1));
232
+ } else if (key.home) {
233
+ setIndex(0);
234
+ } else if (key.end) {
235
+ setIndex(items.length - 1);
236
+ } else if (key.return || /[\r\n]/.test(input)) {
237
+ onSubmit(items[index]);
238
+ }
239
+ });
240
+
241
+ return h(Box, { flexDirection: 'column' },
242
+ h(Text, { bold: true }, title),
243
+ ...items.map((item, itemIndex) => h(Text, {
244
+ key: item.value,
245
+ color: itemIndex === index ? 'cyan' : undefined,
246
+ }, `${itemIndex === index ? '>' : ' '} ${item.label.padEnd(10)} ${item.detail}`))
247
+ );
248
+ }
249
+
250
+ function RuntimeMultiSelect({ React, Box, Text, useInput, h, action, statuses, onSubmit }) {
251
+ const initialSelected = React.useMemo(() => new Set(defaultRuntimeSelections(statuses, action)), [statuses, action]);
252
+ const [index, setIndex] = React.useState(0);
253
+ const [selected, setSelected] = React.useState(initialSelected);
254
+ const [error, setError] = React.useState('');
255
+
256
+ function toggle(status) {
257
+ setError('');
258
+ if (!isSelectableRuntimeStatus(status, action)) {
259
+ setError(unavailableRuntimeSelectionMessage(status, action));
260
+ return;
261
+ }
262
+ setSelected((current) => {
263
+ const next = new Set(current);
264
+ if (next.has(status.runtime)) {
265
+ next.delete(status.runtime);
266
+ } else {
267
+ next.add(status.runtime);
268
+ }
269
+ return next;
270
+ });
271
+ }
272
+
273
+ useInput((input, key) => {
274
+ if (key.upArrow || input === 'k') {
275
+ setIndex((current) => Math.max(0, current - 1));
276
+ } else if (key.downArrow || input === 'j') {
277
+ setIndex((current) => Math.min(statuses.length - 1, current + 1));
278
+ } else if (key.home) {
279
+ setIndex(0);
280
+ } else if (key.end) {
281
+ setIndex(statuses.length - 1);
282
+ } else if (input === ' ') {
283
+ toggle(statuses[index]);
284
+ } else if (input === 'a') {
285
+ setError('');
286
+ setSelected(new Set(action === 'update' ? selectableRuntimeSelections(statuses, action) : RUNTIMES));
287
+ } else if (input === 'i') {
288
+ setError('');
289
+ setSelected(new Set(detectedRuntimeSelections(statuses, action)));
290
+ } else if (key.return || /[\r\n]/.test(input)) {
291
+ const runtimes = RUNTIMES.filter((runtime) => selected.has(runtime));
292
+ if (runtimes.length === 0) {
293
+ setError(emptyRuntimeSelectionMessage(statuses, action));
294
+ return;
295
+ }
296
+ onSubmit(runtimes);
297
+ }
298
+ });
299
+
300
+ return h(Box, { flexDirection: 'column' },
301
+ h(Text, { bold: true }, `Runtimes to ${action}`),
302
+ h(Text, { dimColor: true }, `${action === 'update' ? 'Space toggles installed runtimes. a selects installed runtimes.' : 'Space toggles. a selects all.'} i selects detected installs. Enter continues.`),
303
+ ...statuses.map((status, itemIndex) => {
304
+ const checked = selected.has(status.runtime) ? '[x]' : '[ ]';
305
+ const cursor = itemIndex === index ? '>' : ' ';
306
+ return h(Text, {
307
+ key: status.runtime,
308
+ color: itemIndex === index ? 'cyan' : undefined,
309
+ }, `${cursor} ${checked} ${status.runtime.padEnd(12)} ${status.detail} (${displayPath(status.targetRoot)})`);
310
+ }),
311
+ error ? h(Text, { color: 'red' }, error) : null
312
+ );
313
+ }
314
+
315
+ function ConfirmUninstall({ React, Box, Text, useInput, h, runtimes, onSubmit }) {
316
+ const [text, setText] = React.useState('');
317
+ const [error, setError] = React.useState('');
318
+
319
+ useInput((input, key) => {
320
+ const submit = key.return || /[\r\n]/.test(input);
321
+ if (submit) {
322
+ const printable = input.replace(/[^\x20-\x7E]/g, '');
323
+ const nextText = `${text}${printable}`;
324
+ if (nextText.trim().toLowerCase() === 'uninstall') {
325
+ onSubmit();
326
+ return;
327
+ }
328
+ setError('Type uninstall to continue.');
329
+ } else if (key.backspace || key.delete) {
330
+ setError('');
331
+ setText((current) => current.slice(0, -1));
332
+ } else if (!key.ctrl && input) {
333
+ const printable = input.replace(/[^\x20-\x7E]/g, '');
334
+ if (printable) {
335
+ setError('');
336
+ setText((current) => `${current}${printable}`);
337
+ }
338
+ }
339
+ });
340
+
341
+ return h(Box, { flexDirection: 'column' },
342
+ h(Text, { bold: true, color: 'yellow' }, 'Confirm uninstall'),
343
+ h(Text, null, `Selected runtimes: ${runtimes.join(', ')}`),
344
+ h(Text, { dimColor: true }, 'Only manifest-managed files and generated clean-room hook entries are removed.'),
345
+ h(Text, null, `Type uninstall: ${text}`),
346
+ error ? h(Text, { color: 'red' }, error) : null
347
+ );
348
+ }
349
+
350
+ module.exports = {
351
+ ConfirmUninstall,
352
+ InstallerTui,
353
+ RuntimeMultiSelect,
354
+ SingleChoice,
355
+ defaultActionIndex,
356
+ nextTuiStage,
357
+ resolveInteractiveOptions,
358
+ runInstallerTui,
359
+ };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const { resolveGoalPath } = require('./preflight-paths.cjs');
4
+
5
+ /**
6
+ * Apply bootstrap output policy values to the preflight goal object.
7
+ * @param {object} goal - Preflight goal object.
8
+ * @param {object} bootstrap - Verified bootstrap metadata and roots.
9
+ */
10
+ function applyBootstrapOutputPolicy(goal, bootstrap) {
11
+ goal.output_policy.artifact_base_root = bootstrap.outputRoot;
12
+ goal.output_policy.implementation_root = bootstrap.roots.implementation;
13
+ }
14
+
15
+ /**
16
+ * Validate that the preflight goal output policy matches bootstrap paths.
17
+ * @param {object} goal - Preflight goal object.
18
+ * @param {object} bootstrap - Verified bootstrap metadata and roots.
19
+ * @param {string} cwd - Current working directory.
20
+ * @param {string} homeDir - User home directory.
21
+ * @returns {string[]} Validation error messages.
22
+ */
23
+ function validateBootstrapOutputPolicy(goal, bootstrap, cwd, homeDir) {
24
+ const errors = [];
25
+ const artifactBaseRoot = resolveGoalPath(goal?.output_policy?.artifact_base_root, cwd, homeDir);
26
+ const implementationRoot = resolveGoalPath(goal?.output_policy?.implementation_root, cwd, homeDir);
27
+ if (artifactBaseRoot !== bootstrap.outputRoot) {
28
+ errors.push(`output_policy.artifact_base_root must match bootstrap task root: ${bootstrap.outputRoot}`);
29
+ }
30
+ if (implementationRoot !== bootstrap.roots.implementation) {
31
+ errors.push(`output_policy.implementation_root must match bootstrap implementation root: ${bootstrap.roots.implementation}`);
32
+ }
33
+ return errors;
34
+ }
35
+
36
+ module.exports = {
37
+ applyBootstrapOutputPolicy,
38
+ validateBootstrapOutputPolicy,
39
+ };