@git.zone/cli 2.25.2 → 3.0.1

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 (34) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/mod_config/index.js +10 -3
  3. package/dist_ts/mod_docker/classes.dockerpruner.d.ts +87 -0
  4. package/dist_ts/mod_docker/classes.dockerpruner.js +262 -0
  5. package/dist_ts/mod_docker/index.d.ts +2 -0
  6. package/dist_ts/mod_docker/index.js +167 -6
  7. package/dist_ts/mod_format/index.js +8 -3
  8. package/dist_ts/mod_meta/meta.classes.meta.d.ts +11 -0
  9. package/dist_ts/mod_meta/meta.classes.meta.js +61 -8
  10. package/dist_ts/mod_services/classes.dockercontainer.d.ts +9 -0
  11. package/dist_ts/mod_services/classes.dockercontainer.js +48 -1
  12. package/dist_ts/mod_services/classes.globalregistry.d.ts +7 -1
  13. package/dist_ts/mod_services/classes.globalregistry.js +29 -7
  14. package/dist_ts/mod_services/classes.servicemanager.d.ts +6 -4
  15. package/dist_ts/mod_services/classes.servicemanager.js +42 -16
  16. package/dist_ts/mod_services/index.js +6 -3
  17. package/dist_ts/mod_standard/index.js +10 -2
  18. package/dist_ts/mod_tools/classes.packagemanager.js +22 -1
  19. package/package.json +1 -1
  20. package/readme.hints.md +30 -0
  21. package/readme.md +29 -4
  22. package/readme.plan.md +31 -0
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/mod_config/index.ts +15 -2
  25. package/ts/mod_docker/classes.dockerpruner.ts +334 -0
  26. package/ts/mod_docker/index.ts +231 -6
  27. package/ts/mod_format/index.ts +17 -2
  28. package/ts/mod_meta/meta.classes.meta.ts +82 -7
  29. package/ts/mod_services/classes.dockercontainer.ts +72 -0
  30. package/ts/mod_services/classes.globalregistry.ts +32 -7
  31. package/ts/mod_services/classes.servicemanager.ts +57 -16
  32. package/ts/mod_services/index.ts +9 -2
  33. package/ts/mod_standard/index.ts +9 -1
  34. package/ts/mod_tools/classes.packagemanager.ts +25 -0
@@ -0,0 +1,334 @@
1
+ import * as plugins from './mod.plugins.js';
2
+ import { logger } from '../gitzone.logging.js';
3
+
4
+ /**
5
+ * Label that marks a Docker resource as created by git.zone tooling.
6
+ * Established by @git.zone/tsdocker and reused by `gitzone services`.
7
+ */
8
+ export const toolLabelKey = 'git.zone.tool';
9
+
10
+ /**
11
+ * Label by which a resource's creator grants permission to reclaim it.
12
+ * Consent is opt-in and per-resource; absence always means "do not touch".
13
+ */
14
+ export const pruneConsentLabelKey = 'git.zone.safe-to-prune';
15
+
16
+ /** Single-quote a value for safe interpolation into a bash command. */
17
+ export const shellQuote = (valueArg: string): string => `'${valueArg.replace(/'/g, `'"'"'`)}'`;
18
+
19
+ export interface IPrunableContainer {
20
+ id: string;
21
+ name: string;
22
+ state: string;
23
+ running: boolean;
24
+ tool: string;
25
+ reclaimable: boolean;
26
+ reason: string;
27
+ }
28
+
29
+ export interface IPrunableVolume {
30
+ name: string;
31
+ tool: string;
32
+ attachedTo: string[];
33
+ reclaimable: boolean;
34
+ reason: string;
35
+ }
36
+
37
+ export interface IDockerInventory {
38
+ totalContainers: number;
39
+ runningContainers: number;
40
+ totalImages: number;
41
+ totalVolumes: number;
42
+ }
43
+
44
+ export interface IDockerPrunePlan {
45
+ dockerAvailable: boolean;
46
+ containers: IPrunableContainer[];
47
+ volumes: IPrunableVolume[];
48
+ inventory: IDockerInventory | null;
49
+ skipped: Array<{ target: string; reason: string }>;
50
+ /** Volumes are only ever candidates when the caller opted in. */
51
+ includeVolumes: boolean;
52
+ }
53
+
54
+ const dockerTimeoutSeconds = 60;
55
+ const dockerCommand = `timeout ${dockerTimeoutSeconds}s docker`;
56
+
57
+ /**
58
+ * Reclaims Docker resources that git.zone tooling created, and nothing else.
59
+ *
60
+ * This class exists because `gitzone docker prune` previously ran
61
+ * `docker system prune -a -f --volumes` — machine-wide, forced, and including
62
+ * volumes. On a host that also runs unrelated workloads that is unrecoverable
63
+ * data loss, and the command name gave no hint of it.
64
+ *
65
+ * The replacement never enumerates "everything unused". It starts from an
66
+ * explicit allowlist — resources carrying `git.zone.tool` *and*
67
+ * `git.zone.safe-to-prune=true` — and removes only members of that set which
68
+ * are additionally proven idle. Anything unlabeled is invisible to it by
69
+ * construction, so no other tool's resources can be caught up in it.
70
+ */
71
+ export class DockerPruner {
72
+ private smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
73
+
74
+ private async run(commandArg: string): Promise<{ exitCode: number; stdout: string }> {
75
+ const result = await this.smartshell.execSilent(commandArg);
76
+ return { exitCode: result.exitCode, stdout: result.stdout || '' };
77
+ }
78
+
79
+ private async listLines(commandArg: string): Promise<string[]> {
80
+ const result = await this.run(commandArg);
81
+ if (result.exitCode !== 0) {
82
+ throw new Error(result.stdout || `docker command failed: ${commandArg}`);
83
+ }
84
+ if (!result.stdout.trim()) {
85
+ return [];
86
+ }
87
+ return result.stdout
88
+ .trim()
89
+ .split(/\r?\n/)
90
+ .map((line) => line.trim())
91
+ .filter(Boolean);
92
+ }
93
+
94
+ private async inspectMany(idsArg: string[]): Promise<any[]> {
95
+ if (idsArg.length === 0) {
96
+ return [];
97
+ }
98
+ const result = await this.run(
99
+ `${dockerCommand} inspect ${idsArg.map((id) => shellQuote(id)).join(' ')}`,
100
+ );
101
+ if (result.exitCode !== 0) {
102
+ throw new Error(result.stdout || 'docker inspect failed');
103
+ }
104
+ try {
105
+ const parsed = JSON.parse(result.stdout);
106
+ return Array.isArray(parsed) ? parsed : [];
107
+ } catch {
108
+ throw new Error('docker inspect returned unparseable output');
109
+ }
110
+ }
111
+
112
+ /**
113
+ * True only when a resource's own labels grant permission to reclaim it.
114
+ * Both labels are required: `git.zone.tool` proves provenance,
115
+ * `git.zone.safe-to-prune=true` is the explicit consent.
116
+ */
117
+ private isToolOwned(labelsArg: { [key: string]: string } | null): boolean {
118
+ if (!labelsArg) {
119
+ return false;
120
+ }
121
+ return (
122
+ typeof labelsArg[toolLabelKey] === 'string' &&
123
+ !!labelsArg[toolLabelKey] &&
124
+ labelsArg[pruneConsentLabelKey] === 'true'
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Build a plan. Read-only.
130
+ */
131
+ public async createPlan(optionsArg: { includeVolumes: boolean }): Promise<IDockerPrunePlan> {
132
+ const skipped: Array<{ target: string; reason: string }> = [];
133
+ const containers: IPrunableContainer[] = [];
134
+ const volumes: IPrunableVolume[] = [];
135
+ let inventory: IDockerInventory | null = null;
136
+
137
+ try {
138
+ // Containers: start from the label filter, never from "all containers".
139
+ const containerIds = await this.listLines(
140
+ `${dockerCommand} ps -a --filter ${shellQuote(`label=${pruneConsentLabelKey}=true`)} --format '{{.ID}}'`,
141
+ );
142
+ const inspectedContainers = await this.inspectMany(containerIds);
143
+
144
+ for (const container of inspectedContainers) {
145
+ const labels = (container?.Config?.Labels || {}) as { [key: string]: string };
146
+ const name = String(container?.Name || '').replace(/^\//, '');
147
+ const running = container?.State?.Running === true;
148
+ const state = String(container?.State?.Status || 'unknown');
149
+
150
+ if (!this.isToolOwned(labels)) {
151
+ // The filter matched the consent label but provenance is missing.
152
+ skipped.push({
153
+ target: name,
154
+ reason: `carries ${pruneConsentLabelKey} but no ${toolLabelKey}; provenance unproven`,
155
+ });
156
+ continue;
157
+ }
158
+
159
+ containers.push({
160
+ id: String(container?.Id || ''),
161
+ name,
162
+ state,
163
+ running,
164
+ tool: labels[toolLabelKey],
165
+ reclaimable: !running,
166
+ reason: running ? 'container is running' : 'tool-owned and not running',
167
+ });
168
+ }
169
+
170
+ // Volumes: only considered when explicitly requested.
171
+ if (optionsArg.includeVolumes) {
172
+ const volumeNames = await this.listLines(
173
+ `${dockerCommand} volume ls --filter ${shellQuote(`label=${pruneConsentLabelKey}=true`)} --format '{{.Name}}'`,
174
+ );
175
+
176
+ // Attachment must be checked against every container, not just running
177
+ // ones: a stopped container still owns its data.
178
+ const allContainerIds = await this.listLines(
179
+ `${dockerCommand} ps -a --format '{{.ID}}'`,
180
+ );
181
+ const allContainers = await this.inspectMany(allContainerIds);
182
+ const volumeAttachments = new Map<string, string[]>();
183
+ for (const container of allContainers) {
184
+ const containerName = String(container?.Name || '').replace(/^\//, '');
185
+ for (const mount of container?.Mounts || []) {
186
+ if (mount?.Type === 'volume' && mount?.Name) {
187
+ const attached = volumeAttachments.get(mount.Name) || [];
188
+ attached.push(containerName);
189
+ volumeAttachments.set(mount.Name, attached);
190
+ }
191
+ }
192
+ }
193
+
194
+ const inspectedVolumes =
195
+ volumeNames.length > 0
196
+ ? await this.inspectMany(volumeNames.map((name) => `${name}`))
197
+ : [];
198
+
199
+ for (const volume of inspectedVolumes) {
200
+ const labels = (volume?.Labels || {}) as { [key: string]: string };
201
+ const name = String(volume?.Name || '');
202
+ const attachedTo = volumeAttachments.get(name) || [];
203
+
204
+ if (!this.isToolOwned(labels)) {
205
+ skipped.push({
206
+ target: `volume ${name}`,
207
+ reason: `carries ${pruneConsentLabelKey} but no ${toolLabelKey}; provenance unproven`,
208
+ });
209
+ continue;
210
+ }
211
+
212
+ volumes.push({
213
+ name,
214
+ tool: labels[toolLabelKey],
215
+ attachedTo,
216
+ reclaimable: attachedTo.length === 0,
217
+ reason:
218
+ attachedTo.length > 0
219
+ ? `attached to ${attachedTo.join(', ')}`
220
+ : 'tool-owned and not attached to any container',
221
+ });
222
+ }
223
+ }
224
+
225
+ // Cheap machine-wide counts for context. Deliberately counts only — sizing
226
+ // via `docker system df` walks every layer and volume and can take minutes
227
+ // on a large host, which is too slow for a default report.
228
+ const allContainerIds = await this.listLines(`${dockerCommand} ps -a --format '{{.ID}}'`);
229
+ const runningIds = await this.listLines(`${dockerCommand} ps --format '{{.ID}}'`);
230
+ const imageIds = await this.listLines(`${dockerCommand} images -q`);
231
+ const volumeIds = await this.listLines(`${dockerCommand} volume ls -q`);
232
+ inventory = {
233
+ totalContainers: allContainerIds.length,
234
+ runningContainers: runningIds.length,
235
+ totalImages: new Set(imageIds).size,
236
+ totalVolumes: volumeIds.length,
237
+ };
238
+
239
+ return {
240
+ dockerAvailable: true,
241
+ containers,
242
+ volumes,
243
+ inventory,
244
+ skipped,
245
+ includeVolumes: optionsArg.includeVolumes,
246
+ };
247
+ } catch (error) {
248
+ const message = error instanceof Error ? error.message : String(error);
249
+ return {
250
+ dockerAvailable: false,
251
+ containers: [],
252
+ volumes: [],
253
+ inventory: null,
254
+ skipped: [{ target: 'docker', reason: `docker unavailable (${message})` }],
255
+ includeVolumes: optionsArg.includeVolumes,
256
+ };
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Apply a plan, re-verifying each resource immediately before removing it.
262
+ * Any drift between plan and reality aborts rather than removing something
263
+ * that has since become active.
264
+ */
265
+ public async applyPlan(planArg: IDockerPrunePlan): Promise<{ containers: string[]; volumes: string[] }> {
266
+ if (!planArg.dockerAvailable) {
267
+ throw new Error('Refusing to prune without a reachable Docker daemon');
268
+ }
269
+
270
+ const removedContainers: string[] = [];
271
+ const removedVolumes: string[] = [];
272
+
273
+ const plannedContainers = planArg.containers.filter((container) => container.reclaimable);
274
+ if (plannedContainers.length > 0) {
275
+ const current = await this.inspectMany(plannedContainers.map((container) => container.id));
276
+ for (const planned of plannedContainers) {
277
+ const container = current.find((candidate) => String(candidate?.Id || '') === planned.id);
278
+ if (!container) {
279
+ throw new Error(`Refusing to remove vanished container: ${planned.name}`);
280
+ }
281
+ const labels = (container?.Config?.Labels || {}) as { [key: string]: string };
282
+ if (!this.isToolOwned(labels)) {
283
+ throw new Error(`Refusing to remove container with unproven ownership: ${planned.name}`);
284
+ }
285
+ if (container?.State?.Running === true) {
286
+ throw new Error(`Refusing to remove running container: ${planned.name}`);
287
+ }
288
+ const result = await this.run(`${dockerCommand} rm ${shellQuote(planned.id)}`);
289
+ if (result.exitCode !== 0) {
290
+ throw new Error(`Failed to remove container ${planned.name}: ${result.stdout}`);
291
+ }
292
+ removedContainers.push(planned.name);
293
+ logger.log('ok', `Removed container ${planned.name}`);
294
+ }
295
+ }
296
+
297
+ const plannedVolumes = planArg.volumes.filter((volume) => volume.reclaimable);
298
+ if (plannedVolumes.length > 0) {
299
+ if (!planArg.includeVolumes) {
300
+ throw new Error('Refusing to remove volumes without an explicit --volumes request');
301
+ }
302
+ // Re-derive attachments from scratch; a container may have started since.
303
+ const allContainerIds = await this.listLines(`${dockerCommand} ps -a --format '{{.ID}}'`);
304
+ const allContainers = await this.inspectMany(allContainerIds);
305
+ const attachedNames = new Set<string>();
306
+ for (const container of allContainers) {
307
+ for (const mount of container?.Mounts || []) {
308
+ if (mount?.Type === 'volume' && mount?.Name) {
309
+ attachedNames.add(mount.Name);
310
+ }
311
+ }
312
+ }
313
+
314
+ for (const planned of plannedVolumes) {
315
+ if (attachedNames.has(planned.name)) {
316
+ throw new Error(`Refusing to remove attached volume: ${planned.name}`);
317
+ }
318
+ const inspected = await this.inspectMany([planned.name]);
319
+ const labels = (inspected[0]?.Labels || {}) as { [key: string]: string };
320
+ if (!this.isToolOwned(labels)) {
321
+ throw new Error(`Refusing to remove volume with unproven ownership: ${planned.name}`);
322
+ }
323
+ const result = await this.run(`${dockerCommand} volume rm ${shellQuote(planned.name)}`);
324
+ if (result.exitCode !== 0) {
325
+ throw new Error(`Failed to remove volume ${planned.name}: ${result.stdout}`);
326
+ }
327
+ removedVolumes.push(planned.name);
328
+ logger.log('ok', `Removed volume ${planned.name}`);
329
+ }
330
+ }
331
+
332
+ return { containers: removedContainers, volumes: removedVolumes };
333
+ }
334
+ }
@@ -1,12 +1,237 @@
1
1
  import * as plugins from './mod.plugins.js';
2
+ import { logger } from '../gitzone.logging.js';
3
+ import type { ICliMode } from '../helpers.climode.js';
4
+ import { getCliMode, printJson } from '../helpers.climode.js';
5
+ import {
6
+ DockerPruner,
7
+ pruneConsentLabelKey,
8
+ toolLabelKey,
9
+ type IDockerPrunePlan,
10
+ } from './classes.dockerpruner.js';
2
11
 
3
- export const run = async (argvArg) => {
4
- const smartshellInstance = new plugins.smartshell.Smartshell({
5
- executor: 'bash',
6
- });
7
- switch (argvArg._[1]) {
12
+ export const run = async (argvArg: any) => {
13
+ const mode = await getCliMode(argvArg);
14
+ const command = argvArg._[1] || 'help';
15
+
16
+ switch (command) {
8
17
  case 'prune':
9
- await smartshellInstance.exec(`docker system prune -a -f --volumes`);
18
+ await handlePrune(argvArg, mode);
19
+ break;
20
+
21
+ case 'help':
22
+ showHelp(mode);
23
+ break;
24
+
25
+ default:
26
+ logger.log('error', `Unknown docker command: ${command}`);
27
+ showHelp(mode);
10
28
  break;
11
29
  }
12
30
  };
31
+
32
+ async function handlePrune(argvArg: any, mode: ICliMode) {
33
+ const apply = Boolean(argvArg.apply);
34
+ const includeVolumes = Boolean(argvArg.volumes);
35
+
36
+ const pruner = new DockerPruner();
37
+ const plan = await pruner.createPlan({ includeVolumes });
38
+
39
+ const reclaimableContainers = plan.containers.filter((container) => container.reclaimable);
40
+ const reclaimableVolumes = plan.volumes.filter((volume) => volume.reclaimable);
41
+ const nothingToDo = reclaimableContainers.length === 0 && reclaimableVolumes.length === 0;
42
+
43
+ // Removing a volume destroys persisted application data, so it always needs
44
+ // intent beyond --apply, in every output mode.
45
+ const confirmVolumeRemoval = async (): Promise<boolean> => {
46
+ if (!apply || reclaimableVolumes.length === 0 || mode.yes) {
47
+ return true;
48
+ }
49
+ if (!mode.interactive) {
50
+ throw new Error(
51
+ '`docker prune --volumes --apply` destroys persisted data. Re-run with --yes to confirm non-interactively.',
52
+ );
53
+ }
54
+ const smartinteraction = new plugins.smartinteract.SmartInteract();
55
+ const confirmAnswer = await smartinteraction.askQuestion({
56
+ name: 'confirm',
57
+ type: 'input',
58
+ message: `Type "yes" to permanently delete ${reclaimableVolumes.length} volume(s) and their data:`,
59
+ default: 'no',
60
+ });
61
+ return confirmAnswer.value === 'yes';
62
+ };
63
+
64
+ if (mode.json) {
65
+ let applied: { containers: string[]; volumes: string[] } | null = null;
66
+ if (apply && !nothingToDo) {
67
+ await confirmVolumeRemoval();
68
+ applied = await pruner.applyPlan(plan);
69
+ }
70
+ printJson({ applied, ...plan });
71
+ return;
72
+ }
73
+
74
+ printPlan(plan, apply);
75
+
76
+ if (!apply || nothingToDo) {
77
+ return;
78
+ }
79
+
80
+ if (!(await confirmVolumeRemoval())) {
81
+ logger.log('note', 'Cancelled');
82
+ return;
83
+ }
84
+
85
+ const applied = await pruner.applyPlan(plan);
86
+ logger.log(
87
+ 'ok',
88
+ `Reclaimed ${applied.containers.length} container(s) and ${applied.volumes.length} volume(s) ✓`,
89
+ );
90
+ }
91
+
92
+ function printPlan(plan: IDockerPrunePlan, applyArg: boolean) {
93
+ console.log();
94
+ logger.log('info', '═══════════════════════════════════════════════════════════════');
95
+ logger.log(
96
+ 'info',
97
+ applyArg
98
+ ? ' Prune git.zone Docker resources (apply)'
99
+ : ' Prune git.zone Docker resources (dry run)',
100
+ );
101
+ logger.log('info', '═══════════════════════════════════════════════════════════════');
102
+ console.log();
103
+
104
+ if (!plan.dockerAvailable) {
105
+ logger.log('error', 'Docker is not reachable — nothing can be inspected or reclaimed.');
106
+ for (const skipped of plan.skipped) {
107
+ logger.log('note', ` ${skipped.target}: ${skipped.reason}`);
108
+ }
109
+ return;
110
+ }
111
+
112
+ logger.log(
113
+ 'note',
114
+ `Scope: only resources labeled ${toolLabelKey}=<tool> and ${pruneConsentLabelKey}=true.`,
115
+ );
116
+ console.log();
117
+
118
+ logger.log('note', 'Containers:');
119
+ if (plan.containers.length === 0) {
120
+ logger.log('info', ' No git.zone-owned containers found');
121
+ }
122
+ for (const container of plan.containers) {
123
+ const marker = container.reclaimable ? '🗑 REMOVE' : ' keep ';
124
+ logger.log(
125
+ 'info',
126
+ ` ${marker} ${container.name} [${container.tool}] (${container.state}) — ${container.reason}`,
127
+ );
128
+ }
129
+
130
+ console.log();
131
+ logger.log('note', 'Volumes:');
132
+ if (!plan.includeVolumes) {
133
+ logger.log(
134
+ 'info',
135
+ ' Not inspected. Volumes hold persisted data and are only considered with --volumes.',
136
+ );
137
+ } else if (plan.volumes.length === 0) {
138
+ logger.log('info', ' No git.zone-owned volumes found');
139
+ } else {
140
+ for (const volume of plan.volumes) {
141
+ const marker = volume.reclaimable ? '🗑 REMOVE' : ' keep ';
142
+ logger.log('info', ` ${marker} ${volume.name} [${volume.tool}] — ${volume.reason}`);
143
+ }
144
+ }
145
+
146
+ for (const skipped of plan.skipped) {
147
+ logger.log('note', `skipped ${skipped.target}: ${skipped.reason}`);
148
+ }
149
+
150
+ if (plan.inventory) {
151
+ console.log();
152
+ logger.log('note', 'This host, for context — NOT reclaimed by this command:');
153
+ logger.log(
154
+ 'info',
155
+ ` ${plan.inventory.totalContainers} containers (${plan.inventory.runningContainers} running), ${plan.inventory.totalImages} images, ${plan.inventory.totalVolumes} volumes`,
156
+ );
157
+ logger.log(
158
+ 'info',
159
+ ' Reclaiming unlabeled resources is deliberately not offered. Run docker directly so the blast radius is explicit.',
160
+ );
161
+ }
162
+
163
+ if (!applyArg) {
164
+ console.log();
165
+ logger.log('note', 'Dry run only. Re-run with --apply to remove the items marked REMOVE.');
166
+ }
167
+ }
168
+
169
+ export function showHelp(mode?: ICliMode) {
170
+ if (mode?.json) {
171
+ printJson({
172
+ command: 'docker',
173
+ usage: 'gitzone docker <command> [options]',
174
+ commands: [
175
+ {
176
+ name: 'prune',
177
+ description:
178
+ 'Report and reclaim stopped containers created by git.zone tooling. Dry run unless --apply.',
179
+ options: [
180
+ { name: '--apply', description: 'Actually remove the reported resources' },
181
+ {
182
+ name: '--volumes',
183
+ description: 'Also consider git.zone-owned volumes attached to no container',
184
+ },
185
+ { name: '--yes', description: 'Confirm volume deletion non-interactively' },
186
+ ],
187
+ },
188
+ ],
189
+ scope: `Only resources labeled ${toolLabelKey}=<tool> and ${pruneConsentLabelKey}=true are ever considered. Images are never removed.`,
190
+ });
191
+ return;
192
+ }
193
+
194
+ console.log();
195
+ logger.log('info', '═══════════════════════════════════════════════════════════════');
196
+ logger.log('info', ' GitZone Docker Resources');
197
+ logger.log('info', '═══════════════════════════════════════════════════════════════');
198
+ console.log();
199
+
200
+ logger.log('ok', 'Usage: gitzone docker <command> [options]');
201
+ console.log();
202
+
203
+ logger.log('note', 'Commands:');
204
+ logger.log(
205
+ 'info',
206
+ ' prune Report git.zone-owned Docker resources; reclaim with --apply',
207
+ );
208
+ console.log();
209
+
210
+ logger.log('note', 'Options:');
211
+ logger.log(
212
+ 'info',
213
+ ' --apply Remove the resources marked REMOVE (default is report only)',
214
+ );
215
+ logger.log('info', ' --volumes Also consider git.zone-owned, unattached volumes ⚠️');
216
+ logger.log('info', ' --yes Confirm volume deletion without a prompt');
217
+ console.log();
218
+
219
+ logger.log('note', 'Scope:');
220
+ logger.log('info', ` Only containers and volumes labeled ${toolLabelKey}=<tool> and`);
221
+ logger.log('info', ` ${pruneConsentLabelKey}=true are ever considered.`);
222
+ logger.log('info', ' Running containers and attached volumes are never removed.');
223
+ logger.log('info', ' Images are never removed.');
224
+ console.log();
225
+ logger.log(
226
+ 'note',
227
+ ' This command deliberately cannot prune the whole machine. For that, run',
228
+ );
229
+ logger.log('note', ' docker directly so the blast radius is explicit and yours.');
230
+ console.log();
231
+
232
+ logger.log('note', 'Examples:');
233
+ logger.log('info', ' gitzone docker prune # report only');
234
+ logger.log('info', ' gitzone docker prune --apply # remove stopped tool containers');
235
+ logger.log('info', ' gitzone docker prune --volumes # include volumes in the report');
236
+ logger.log('info', ' gitzone docker prune --volumes --apply --yes');
237
+ }
@@ -335,6 +335,12 @@ const handleFormatFix = async (
335
335
  process.cwd(),
336
336
  ];
337
337
  if (autoApprove) {
338
+ // -y does more here than skip a prompt: it hands opencode unrestricted
339
+ // shell access in this directory. That must never be silent.
340
+ logger.log(
341
+ "error",
342
+ "⚠️ --yes runs opencode with --dangerously-skip-permissions: it may run any command in this directory without asking.",
343
+ );
338
344
  opencodeArgs.push("--dangerously-skip-permissions");
339
345
  }
340
346
  opencodeArgs.push(buildFormatFixPrompt(plan, extraInstructions));
@@ -635,7 +641,8 @@ export function showHelp(mode?: ICliMode): void {
635
641
  { flag: "--write, -w", description: "Apply planned changes" },
636
642
  {
637
643
  flag: "--yes",
638
- description: "Skip the interactive confirmation before writing",
644
+ description:
645
+ "Skip the interactive confirmation before writing. With `format fix` this additionally runs opencode with --dangerously-skip-permissions, allowing it to run any command in the project.",
639
646
  },
640
647
  {
641
648
  flag: "--plan-only",
@@ -693,7 +700,15 @@ export function showHelp(mode?: ICliMode): void {
693
700
  console.log("Flags:");
694
701
  console.log(" --write, -w Apply planned changes");
695
702
  console.log(
696
- " --yes Skip the interactive confirmation before writing",
703
+ " --yes Skip the interactive confirmation before writing.",
704
+ );
705
+ logger.log(
706
+ "info",
707
+ " With `format fix` this also runs opencode with",
708
+ );
709
+ logger.log(
710
+ "info",
711
+ " --dangerously-skip-permissions ⚠️",
697
712
  );
698
713
  console.log(" --plan-only Show the plan without applying changes");
699
714
  console.log(" --save-plan <file> Write the format plan to a file");