@b00dea/cockpit 0.1.0 → 0.1.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 (196) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +3 -0
  3. package/cockpit.schema.json +73 -3
  4. package/dist/autonomy.d.ts +3 -2
  5. package/dist/autonomy.js +30 -13
  6. package/dist/browser/commands.d.ts +57 -2
  7. package/dist/browser/commands.js +114 -10
  8. package/dist/browser/runtime.d.ts +1 -0
  9. package/dist/browser/runtime.js +4 -1
  10. package/dist/browser/surfaces.d.ts +7 -7
  11. package/dist/browser/surfaces.js +5 -0
  12. package/dist/canonical-json.d.ts +2 -0
  13. package/dist/canonical-json.js +5 -0
  14. package/dist/cli-main.d.ts +40 -3
  15. package/dist/cli-main.js +241 -62
  16. package/dist/command-table.d.ts +19 -11
  17. package/dist/command-table.js +190 -38
  18. package/dist/config/load.d.ts +16 -4
  19. package/dist/config/load.js +25 -3
  20. package/dist/config/unconfirmed-toml.d.ts +9 -0
  21. package/dist/config/unconfirmed-toml.js +29 -0
  22. package/dist/diagnostics.js +10 -1
  23. package/dist/docs/check.d.ts +16 -0
  24. package/dist/docs/check.js +197 -0
  25. package/dist/docs/decision-record.d.ts +34 -0
  26. package/dist/docs/decision-record.js +135 -0
  27. package/dist/docs/decisions.d.ts +16 -0
  28. package/dist/docs/decisions.js +103 -0
  29. package/dist/docs/markdown.d.ts +36 -0
  30. package/dist/docs/markdown.js +88 -0
  31. package/dist/docs/references.d.ts +32 -0
  32. package/dist/docs/references.js +148 -0
  33. package/dist/environment/application/confirm.d.ts +35 -0
  34. package/dist/environment/application/confirm.js +122 -0
  35. package/dist/environment/application/destructive.js +6 -2
  36. package/dist/environment/application/doctor.d.ts +12 -7
  37. package/dist/environment/application/doctor.js +20 -8
  38. package/dist/environment/application/guard.d.ts +12 -6
  39. package/dist/environment/application/guard.js +87 -32
  40. package/dist/environment/application/health.d.ts +14 -5
  41. package/dist/environment/application/health.js +8 -2
  42. package/dist/environment/application/lifecycle-context.d.ts +10 -3
  43. package/dist/environment/application/lifecycle-state.js +4 -3
  44. package/dist/environment/application/lifecycle.d.ts +2 -1
  45. package/dist/environment/application/lifecycle.js +11 -3
  46. package/dist/environment/application/services.d.ts +60 -0
  47. package/dist/environment/application/services.js +89 -0
  48. package/dist/environment/application/siblings.d.ts +43 -0
  49. package/dist/environment/application/siblings.js +269 -0
  50. package/dist/environment/claims.d.ts +10 -10
  51. package/dist/environment/claims.js +3 -11
  52. package/dist/environment/compose-topology.d.ts +15 -7
  53. package/dist/environment/compose-topology.js +100 -23
  54. package/dist/environment/contract.d.ts +42 -9
  55. package/dist/environment/contract.js +33 -0
  56. package/dist/environment/executable.d.ts +12 -0
  57. package/dist/environment/executable.js +46 -23
  58. package/dist/environment/generated-application/check.js +2 -0
  59. package/dist/environment/generated-application/prepare.js +2 -0
  60. package/dist/environment/generated-application/shared.d.ts +10 -2
  61. package/dist/environment/generated-application/shared.js +6 -1
  62. package/dist/environment/generated-application/surface-outputs.js +23 -12
  63. package/dist/environment/generated-application/surfaces.js +16 -11
  64. package/dist/environment/generated-application/sync.js +19 -4
  65. package/dist/environment/git-observation.d.ts +25 -6
  66. package/dist/environment/git-observation.js +37 -28
  67. package/dist/environment/health-composition.d.ts +3 -2
  68. package/dist/environment/health-composition.js +19 -2
  69. package/dist/environment/init-application.d.ts +39 -14
  70. package/dist/environment/init-application.js +39 -10
  71. package/dist/environment/lifecycle-composition.d.ts +4 -2
  72. package/dist/environment/lifecycle-composition.js +84 -17
  73. package/dist/environment/lifecycle-stage.d.ts +8 -3
  74. package/dist/environment/lifecycle-stage.js +11 -1
  75. package/dist/environment/logs.d.ts +1 -1
  76. package/dist/environment/map-limit.d.ts +2 -0
  77. package/dist/environment/map-limit.js +13 -0
  78. package/dist/environment/mcp-observation.d.ts +9 -0
  79. package/dist/environment/mcp-observation.js +46 -0
  80. package/dist/environment/observations.d.ts +1 -1
  81. package/dist/environment/observations.js +2 -0
  82. package/dist/environment/operator-application.d.ts +35 -6
  83. package/dist/environment/operator-application.js +42 -0
  84. package/dist/environment/presence.d.ts +4 -0
  85. package/dist/environment/presence.js +26 -0
  86. package/dist/environment/read-composition.d.ts +8 -3
  87. package/dist/environment/read-composition.js +53 -8
  88. package/dist/environment/readiness-composition.d.ts +20 -1
  89. package/dist/environment/readiness-composition.js +23 -2
  90. package/dist/environment/reclaim.d.ts +56 -19
  91. package/dist/environment/reclaim.js +147 -42
  92. package/dist/environment/runtime.d.ts +64 -18
  93. package/dist/environment/runtime.js +73 -3
  94. package/dist/environment/served-checkout-grants.d.ts +53 -0
  95. package/dist/environment/served-checkout-grants.js +182 -0
  96. package/dist/environment/sibling-checkouts.d.ts +119 -0
  97. package/dist/environment/sibling-checkouts.js +254 -0
  98. package/dist/environment/sibling-observation.d.ts +27 -0
  99. package/dist/environment/sibling-observation.js +45 -0
  100. package/dist/environment/sibling-pull.d.ts +23 -0
  101. package/dist/environment/sibling-pull.js +95 -0
  102. package/dist/environment/stack-inputs.d.ts +18 -0
  103. package/dist/environment/stack-inputs.js +210 -0
  104. package/dist/environment/stack-inventory-prune.d.ts +31 -0
  105. package/dist/environment/stack-inventory-prune.js +129 -0
  106. package/dist/environment/stack-inventory-report.d.ts +44 -0
  107. package/dist/environment/stack-inventory-report.js +78 -0
  108. package/dist/environment/stack-inventory.d.ts +94 -0
  109. package/dist/environment/stack-inventory.js +418 -0
  110. package/dist/environment/state.d.ts +48 -9
  111. package/dist/environment/state.js +142 -2
  112. package/dist/environment/tool-registration.js +3 -7
  113. package/dist/environment-exec/execute.d.ts +6 -3
  114. package/dist/environment-exec/execute.js +4 -0
  115. package/dist/fix-catalogue.d.ts +1 -1
  116. package/dist/generated-environment/adopt.js +4 -8
  117. package/dist/guards/answer-confirmation.d.ts +41 -0
  118. package/dist/guards/answer-confirmation.js +109 -0
  119. package/dist/guards/evaluator.d.ts +20 -3
  120. package/dist/guards/evaluator.js +39 -7
  121. package/dist/guards/local-identity.d.ts +6 -2
  122. package/dist/guards/local-identity.js +10 -4
  123. package/dist/guards/policy-change.d.ts +43 -0
  124. package/dist/guards/policy-change.js +159 -0
  125. package/dist/guards/policy-pin.d.ts +1 -1
  126. package/dist/guards/policy-pin.js +14 -12
  127. package/dist/init/command.d.ts +4 -3
  128. package/dist/init/command.js +36 -16
  129. package/dist/init/derive.d.ts +8 -6
  130. package/dist/init/derive.js +140 -36
  131. package/dist/init/draft.d.ts +13 -11
  132. package/dist/init/draft.js +54 -31
  133. package/dist/init/render.d.ts +18 -6
  134. package/dist/init/render.js +31 -9
  135. package/dist/repository-path.d.ts +9 -0
  136. package/dist/repository-path.js +16 -0
  137. package/dist/schema.d.ts +42 -75
  138. package/dist/schema.js +62 -11
  139. package/dist/surfaces/harness-registry.d.ts +80 -0
  140. package/dist/surfaces/harness-registry.js +29 -0
  141. package/dist/surfaces/mcp.d.ts +67 -17
  142. package/dist/surfaces/mcp.js +194 -31
  143. package/dist/surfaces/projection.d.ts +7 -7
  144. package/dist/surfaces/projection.js +41 -6
  145. package/dist/surfaces/render.d.ts +5 -44
  146. package/dist/surfaces/render.js +5 -2
  147. package/dist/team-metadata.js +2 -1
  148. package/dist/testing/test-protection.d.ts +34 -0
  149. package/dist/testing/test-protection.js +137 -0
  150. package/dist/testing/weakening.d.ts +15 -0
  151. package/dist/testing/weakening.js +30 -0
  152. package/dist/work-orders/application-closure.d.ts +7 -0
  153. package/dist/work-orders/application-closure.js +76 -0
  154. package/dist/work-orders/application-context.d.ts +162 -0
  155. package/dist/work-orders/application-context.js +137 -0
  156. package/dist/work-orders/application-decisions.d.ts +27 -0
  157. package/dist/work-orders/application-decisions.js +121 -0
  158. package/dist/work-orders/application-grading.d.ts +11 -0
  159. package/dist/work-orders/application-grading.js +212 -0
  160. package/dist/work-orders/application.d.ts +4 -97
  161. package/dist/work-orders/application.js +94 -254
  162. package/dist/work-orders/composition.d.ts +35 -58
  163. package/dist/work-orders/composition.js +216 -24
  164. package/dist/work-orders/contract.d.ts +22 -6
  165. package/dist/work-orders/contract.js +34 -16
  166. package/dist/work-orders/decisions.d.ts +23 -0
  167. package/dist/work-orders/decisions.js +55 -0
  168. package/dist/work-orders/store.d.ts +54 -5
  169. package/dist/work-orders/store.js +118 -24
  170. package/docs/README.md +6 -3
  171. package/docs/capability-matrix.md +17 -9
  172. package/docs/cli-reference.md +209 -1
  173. package/docs/command-list.md +12 -1
  174. package/docs/concepts.md +6 -4
  175. package/docs/configuration-essentials.md +7 -2
  176. package/docs/configuration-reference.md +18 -3
  177. package/docs/docs-check.md +76 -0
  178. package/docs/environment.md +194 -6
  179. package/docs/faq.md +5 -3
  180. package/docs/getting-started.md +109 -25
  181. package/docs/harness-surfaces.md +69 -2
  182. package/docs/safety-and-autonomy.md +56 -12
  183. package/docs/troubleshooting.md +29 -8
  184. package/docs/tutorial.md +3 -2
  185. package/docs/work-and-browser.md +180 -14
  186. package/libexec/browser-operation.cjs +86 -2
  187. package/package.json +2 -2
  188. package/surfaces/skills/cockpit-browser.skill.md +7 -2
  189. package/surfaces/skills/cockpit-explain.skill.md +1 -1
  190. package/surfaces/skills/cockpit-fix.skill.md +1 -1
  191. package/surfaces/skills/cockpit-setup.skill.md +3 -3
  192. package/surfaces/skills/cockpit-work.skill.md +1 -1
  193. package/surfaces/skills/procedures/explain.md +9 -4
  194. package/surfaces/skills/procedures/fix.md +25 -11
  195. package/surfaces/skills/procedures/setup.md +113 -40
  196. package/surfaces/skills/procedures/work.md +45 -16
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Owns bringing one checkout up to its upstream, the way a careful person would: fetch, then
3
+ * fast-forward the branch that is checked out, and nothing else. It never merges, rebases,
4
+ * stashes, switches branch or touches a modified file; a checkout it cannot fast-forward cleanly
5
+ * is left exactly as it was and reported with the reason. That is what makes it safe to run
6
+ * unprompted: the only change it can make is one `git merge --ff-only` would make.
7
+ */
8
+ import { formatShellCommand } from '../fix-catalogue.js';
9
+ import { parseGitBranchStatus } from './git-observation.js';
10
+ // Keys that make fetch or merge spawn a command the repository's own configuration names. Agents cannot
11
+ // write under `.git`, so this is defence in depth. Git lower-cases section and variable names in its output.
12
+ const COMMAND_KEYS = /^(?:core\.(?:sshcommand|askpass|gitproxy)|credential\.(?:.+\.)?helper|remote\..+\.(?:uploadpack|receivepack))$/;
13
+ async function commandBearingKey(git, path) {
14
+ const listed = await git.run(path, 'pull:config', ['config', '--list', '--name-only']);
15
+ if (listed.kind !== 'complete')
16
+ return { reason: `its Git configuration could not be read (${listed.detail})` };
17
+ return listed.stdout.split('\n').find(key => COMMAND_KEYS.test(key));
18
+ }
19
+ async function branchStatus(git, path) {
20
+ const status = await git.run(path, 'pull:status', ['status', '--porcelain=v2', '--branch', '--untracked-files=no']);
21
+ if (status.kind !== 'complete')
22
+ return { reason: `git status failed (${status.detail})` };
23
+ const parsed = parseGitBranchStatus(status.stdout);
24
+ return parsed.kind === 'complete' ? parsed : { reason: parsed.reason };
25
+ }
26
+ /**
27
+ * The filter drivers the upstream's attributes name that the repository's own configuration does
28
+ * not define. cockpit's Git reads no global configuration, where `git lfs install` puts its driver,
29
+ * and a checkout through an undefined filter writes the stored pointer silently, with no error.
30
+ */
31
+ async function unconfiguredFilters(git, path) {
32
+ const named = await git.run(path, 'pull:filters', ['grep', '-h', '-o', '-I', '-E', 'filter=[^[:space:]]+', '@{upstream}', '--', ':(glob)**/.gitattributes']);
33
+ // grep exits 1 when nothing matches.
34
+ if (named.kind !== 'complete')
35
+ return named.exitCode === 1 ? [] : { reason: `its attributes could not be read (${named.detail})` };
36
+ const drivers = [...new Set(named.stdout.split('\n').map(line => line.trim().slice('filter='.length)).filter(Boolean))];
37
+ // One call lists every filter variable; git exits 1 when there are none. Section and variable
38
+ // names are lower-cased in the output, the driver (subsection) keeps its case.
39
+ const configured = await git.run(path, 'pull:filter-drivers', ['config', '--get-regexp', '^filter\\.']);
40
+ if (configured.kind !== 'complete' && configured.exitCode !== 1)
41
+ return { reason: `its Git configuration could not be read (${configured.detail})` };
42
+ const defined = new Set();
43
+ for (const line of configured.kind === 'complete' ? configured.stdout.split('\n') : []) {
44
+ const match = /^filter\.(.+)\.(smudge|process)(?:\s|$)/.exec(line);
45
+ if (match)
46
+ defined.add(match[1]);
47
+ }
48
+ return drivers.filter(driver => !defined.has(driver));
49
+ }
50
+ export async function fastForwardCheckout(git, path) {
51
+ const shell = (...args) => formatShellCommand(['git', '-C', path, ...args]);
52
+ const skipped = (reason, fix, blocked = true) => ({ state: 'skipped', path, reason, fix, blocked });
53
+ const before = await branchStatus(git, path);
54
+ if ('reason' in before)
55
+ return skipped(`it is not a readable Git checkout: ${before.reason}`, `inspect it with ${shell('status')}`);
56
+ if (before.revision === '(initial)')
57
+ return skipped('it has no commits yet', `commit in it, or ignore it until it has a history`);
58
+ if (before.branch === '(detached)')
59
+ return skipped('HEAD is detached, so there is no branch to move', `check out a branch with ${shell('switch', '<branch>')} if it should follow one`, false);
60
+ if (before.upstream === null)
61
+ return skipped(`branch ${before.branch} tracks no upstream`, `set one with ${shell('branch', '--set-upstream-to', '<remote>/<branch>')} if it should follow one`, false);
62
+ const command = await commandBearingKey(git, path);
63
+ if (typeof command === 'object')
64
+ return skipped(command.reason, `inspect it with ${shell('config', '--list')}`);
65
+ if (command !== undefined)
66
+ return skipped(`its Git configuration sets ${command}, which would run a command during the fetch`, `review ${shell('config', '--list', '--show-origin')}; if you trust the setting, run ${shell('pull', '--ff-only')} yourself`);
67
+ const fetched = await git.run(path, 'pull:fetch', ['fetch', '--quiet', '--prune'], { timeoutMs: 120_000 });
68
+ if (fetched.kind !== 'complete') {
69
+ return skipped(`fetching ${before.upstream} failed (${fetched.detail})`, `run ${shell('fetch')} yourself to see why; cockpit fetches without the global Git configuration, so a credential helper set only there is not used`);
70
+ }
71
+ const after = await branchStatus(git, path);
72
+ if ('reason' in after)
73
+ return skipped(`it could not be read after fetching: ${after.reason}`, `inspect it with ${shell('status')}`);
74
+ const upstream = after.upstream ?? before.upstream;
75
+ if (!after.adjacencyReported)
76
+ return skipped(`${upstream}, which ${after.branch} tracks, no longer exists`, `push the branch to recreate it, or stop tracking it with ${shell('branch', '--unset-upstream')}`);
77
+ if (after.behind === 0)
78
+ return { state: 'current', path, branch: after.branch, upstream, ahead: after.ahead };
79
+ if (after.ahead > 0)
80
+ return skipped(`${after.branch} has diverged from ${upstream} (${after.behind} behind, ${after.ahead} ahead)`, `reconcile it by hand, for example with ${shell('rebase', upstream)}; cockpit only fast-forwards`);
81
+ if (after.trackedDirtyCount > 0)
82
+ return skipped(`${after.branch} is ${after.behind} behind ${upstream}, but ${after.trackedDirtyCount} tracked file(s) are modified`, `commit or stash the changes (see ${shell('status', '--short')}), then run pull again; cockpit never moves a checkout with work in it`);
83
+ const filters = await unconfiguredFilters(git, path);
84
+ if ('reason' in filters)
85
+ return skipped(filters.reason, `inspect it with ${shell('check-attr', '-a', '--', '<path>')}`);
86
+ if (filters.length > 0)
87
+ return skipped(`it uses the Git filter ${filters.join(', ')} (Git LFS, for one), which is configured only outside the repository, `
88
+ + 'so a fast-forward here would check out its placeholder files instead of their content', `run ${shell('pull', '--ff-only')} yourself`);
89
+ const merged = await git.run(path, 'pull:fast-forward', ['merge', '--ff-only', '--quiet', '@{upstream}'], { timeoutMs: 120_000 });
90
+ if (merged.kind !== 'complete')
91
+ return skipped(`fast-forwarding ${after.branch} to ${upstream} failed (${merged.detail})`, `run ${shell('merge', '--ff-only', '@{upstream}')} yourself to see what is in the way`);
92
+ const head = await git.run(path, 'pull:head', ['rev-parse', 'HEAD']);
93
+ return { state: 'advanced', path, branch: after.branch, upstream, from: after.revision,
94
+ to: head.kind === 'complete' ? head.stdout : 'unknown', commits: after.behind };
95
+ }
@@ -0,0 +1,18 @@
1
+ import type { CockpitConfig } from '../schema.js';
2
+ import { type EnvironmentDiagnostic } from './contract.js';
3
+ import { type ServedSibling } from './sibling-checkouts.js';
4
+ /**
5
+ * Record the inputs of every sibling the stack mounts; a warning, never a refusal, when that fails.
6
+ * With `keepExisting`, a record already there stays: the stack it describes is still the one running.
7
+ */
8
+ export declare function recordStackInputs(cockpitRoot: string, config: Pick<CockpitConfig, 'sibling' | 'environment'>, served: ReadonlyMap<string, ServedSibling>, options: Readonly<{
9
+ keepExisting: boolean;
10
+ }>): Promise<readonly EnvironmentDiagnostic[]>;
11
+ /** Forget the record once no stack runs, so a later doctor compares nothing against a stack that is gone. */
12
+ export declare function forgetStackInputs(cockpitRoot: string): Promise<readonly EnvironmentDiagnostic[]>;
13
+ /**
14
+ * One warning per mounted sibling whose served checkout, lockfiles or declared rebuild inputs differ
15
+ * from what the running stack started with. Nothing recorded means no stack came up since the last
16
+ * `down`, and is no finding.
17
+ */
18
+ export declare function staleStackDiagnostics(cockpitRoot: string, served: ReadonlyMap<string, ServedSibling>): Promise<readonly EnvironmentDiagnostic[]>;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Owns the stale-stack detector: what each mounted sibling's dependencies and schema looked like
3
+ * when the stack came up, and whether they still do. A bind mount follows a pulled branch at once,
4
+ * but installed dependencies usually live in an image or a volume built before it and a database
5
+ * keeps the schema it was migrated to, so a stack that kept running after a lockfile or migration
6
+ * changed fails like a product bug and its tests pass against code nobody is running.
7
+ *
8
+ * It decides nothing: `up` records, `down` and a stopping `sleep` forget, and doctor and preflight
9
+ * warn. The record is machine-local beside the sibling checkout record, under `.cockpit/local`.
10
+ */
11
+ import { lstatIfPresent } from './presence.js';
12
+ import { createHash } from 'node:crypto';
13
+ import { lstat, readdir, readFile, readlink, rm } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { readBoundedFile } from '../bounded-read.js';
16
+ import { cockpitCommand } from '../fix-catalogue.js';
17
+ import { sha256Hex } from '../sha256.js';
18
+ import { GUARD_LOCAL_PATHS, writeCockpitLocalFiles } from '../guards/local-identity.js';
19
+ import { ENVIRONMENT_CODES, errorCause, hasErrorCode, RefusalError, tryParseJson } from './contract.js';
20
+ import { mapLimit } from './map-limit.js';
21
+ import { servedPath } from './sibling-checkouts.js';
22
+ const FORMAT = 'cockpit-stack-inputs/v1';
23
+ const RECORD_NAME = 'stack-inputs';
24
+ const RECORD_PATH = `${GUARD_LOCAL_PATHS.directory}/${RECORD_NAME}`;
25
+ const RECORD_MAX_BYTES = 1_048_576;
26
+ // A migrations directory is small; one this large is the wrong path, and hashing it would stall every doctor.
27
+ const DIRECTORY_ENTRY_LIMIT = 20_000;
28
+ // Each in-flight digest may hold a file descriptor.
29
+ const DIGEST_CONCURRENCY = 16;
30
+ // The lockfiles the common toolchains write, watched when no install policy names the sibling's.
31
+ const COMMON_LOCKFILES = Object.freeze([
32
+ 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
33
+ 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'pdm.lock', 'Gemfile.lock', 'go.sum', 'Cargo.lock',
34
+ 'composer.lock', 'packages.lock.json', 'mix.lock',
35
+ ]);
36
+ // Directory hashes name each entry relative to the directory, so moving a checkout is no change.
37
+ async function digestPath(path) {
38
+ let status;
39
+ try {
40
+ status = await lstat(path);
41
+ }
42
+ catch (error) {
43
+ if (hasErrorCode(error, 'ENOENT', 'ENOTDIR'))
44
+ return null;
45
+ throw error;
46
+ }
47
+ if (status.isSymbolicLink())
48
+ return sha256Hex(`link\0${await readlink(path)}`);
49
+ if (status.isFile())
50
+ return sha256Hex(await readFile(path));
51
+ if (!status.isDirectory())
52
+ return sha256Hex(`other\0${status.mode}`);
53
+ const hash = createHash('sha256');
54
+ // Entries are digested concurrently but folded into the hash in sorted order, so the digest is deterministic.
55
+ let entries = 0;
56
+ const walk = async (directory, prefix) => {
57
+ const listed = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name < right.name ? -1 : 1);
58
+ entries += listed.length;
59
+ if (entries > DIRECTORY_ENTRY_LIMIT)
60
+ throw new RefusalError(`${path} holds more than ${DIRECTORY_ENTRY_LIMIT} entries`);
61
+ return (await mapLimit(listed, DIGEST_CONCURRENCY, async (entry) => {
62
+ const relative = `${prefix}${entry.name}`;
63
+ return entry.isDirectory()
64
+ ? [`dir\0${relative}\0`, ...await walk(join(directory, entry.name), `${relative}/`)]
65
+ : [`${relative}\0${await digestPath(join(directory, entry.name))}\0`];
66
+ })).flat();
67
+ };
68
+ for (const line of await walk(path, ''))
69
+ hash.update(line);
70
+ return hash.digest('hex');
71
+ }
72
+ async function exists(path) {
73
+ return await lstatIfPresent(path) !== undefined;
74
+ }
75
+ /** The paths watched for one sibling and where each came from: its own list, then the toolchain's lockfiles. */
76
+ async function watchedInputs(config, sibling, served) {
77
+ const declared = (sibling.rebuild_inputs ?? []).map(path => ({ path, source: 'rebuild_inputs' }));
78
+ const policies = config.environment.install_policies.filter(policy => policy.toolchain === sibling.toolchain)
79
+ .map(policy => ({ path: policy.lockfile, source: 'environment.install_policies' }));
80
+ // Without a policy only the lockfiles present at up are watched; one created later is a new
81
+ // toolchain, which a person adds to install_policies.
82
+ const common = policies.length > 0 ? [] : (await Promise.all(COMMON_LOCKFILES.map(async (path) => await exists(join(served, path)) ? [{ path, source: 'common lockfile names' }] : []))).flat();
83
+ const seen = new Set();
84
+ return [...declared, ...policies, ...common].filter(row => !seen.has(row.path) && seen.add(row.path));
85
+ }
86
+ function stackSiblings(config) {
87
+ return config.sibling.filter(sibling => sibling.mount === 'always' || sibling.mount.startsWith('profile:'));
88
+ }
89
+ /**
90
+ * Record the inputs of every sibling the stack mounts; a warning, never a refusal, when that fails.
91
+ * With `keepExisting`, a record already there stays: the stack it describes is still the one running.
92
+ */
93
+ export async function recordStackInputs(cockpitRoot, config, served, options) {
94
+ try {
95
+ if (options.keepExisting) {
96
+ try {
97
+ await lstat(join(cockpitRoot, RECORD_PATH));
98
+ return [];
99
+ }
100
+ catch (error) {
101
+ if (!hasErrorCode(error, 'ENOENT'))
102
+ throw error;
103
+ }
104
+ }
105
+ const siblings = await Promise.all(stackSiblings(config).map(async (sibling) => {
106
+ const directory = servedPath(served, sibling.slug);
107
+ const inputs = await Promise.all((await watchedInputs(config, sibling, directory)).map(async (row) => ({ ...row, sha256: await digestPath(join(directory, row.path)) })));
108
+ return { slug: sibling.slug, served: directory, inputs };
109
+ }));
110
+ const record = { format: FORMAT, recordedAt: new Date().toISOString(), siblings };
111
+ const written = await writeCockpitLocalFiles(cockpitRoot, [{ name: RECORD_NAME, bytes: `${JSON.stringify(record)}\n` }]);
112
+ if (written.kind !== 'complete')
113
+ throw new RefusalError(written.message);
114
+ return [];
115
+ }
116
+ catch (error) {
117
+ return [unrecorded(`the lockfiles and declared rebuild inputs could not be recorded (${errorCause(error)})`)];
118
+ }
119
+ }
120
+ function unrecorded(reason) {
121
+ return {
122
+ severity: 'warning',
123
+ code: 'STACK_INPUTS_UNRECORDED',
124
+ message: `${reason}, so doctor and preflight cannot tell when this stack falls behind its siblings' dependencies or migrations`,
125
+ target: RECORD_PATH,
126
+ fix: `run ${cockpitCommand('down')} and ${cockpitCommand('up')} to record them again`,
127
+ };
128
+ }
129
+ /** Forget the record once no stack runs, so a later doctor compares nothing against a stack that is gone. */
130
+ export async function forgetStackInputs(cockpitRoot) {
131
+ try {
132
+ await rm(join(cockpitRoot, RECORD_PATH), { force: true });
133
+ return [];
134
+ }
135
+ catch (error) {
136
+ return [{
137
+ severity: 'warning',
138
+ code: 'STACK_INPUTS_UNRECORDED',
139
+ message: `the stack stopped, but ${join(cockpitRoot, RECORD_PATH)} could not be removed (${errorCause(error)}), so doctor may still compare against it`,
140
+ target: RECORD_PATH,
141
+ fix: `delete ${join(cockpitRoot, RECORD_PATH)}`,
142
+ }];
143
+ }
144
+ }
145
+ function decode(bytes) {
146
+ const value = tryParseJson(bytes.toString('utf8'));
147
+ const text = (field) => typeof field === 'string' && field.length > 0;
148
+ if (!value || typeof value !== 'object' || value.format !== FORMAT || !text(value.recordedAt) || !Array.isArray(value.siblings)
149
+ || !value.siblings.every(row => row && text(row.slug) && text(row.served) && Array.isArray(row.inputs)
150
+ && row.inputs.every((input) => input && text(input.path) && text(input.source) && (input.sha256 === null || text(input.sha256)))))
151
+ return undefined;
152
+ return value;
153
+ }
154
+ /**
155
+ * One warning per mounted sibling whose served checkout, lockfiles or declared rebuild inputs differ
156
+ * from what the running stack started with. Nothing recorded means no stack came up since the last
157
+ * `down`, and is no finding.
158
+ */
159
+ export async function staleStackDiagnostics(cockpitRoot, served) {
160
+ const path = join(cockpitRoot, RECORD_PATH);
161
+ let bytes;
162
+ try {
163
+ const read = readBoundedFile(path, RECORD_MAX_BYTES);
164
+ if (read.kind === 'too-large')
165
+ return [unrecorded(`${path} is not a record cockpit wrote`)];
166
+ bytes = read.bytes;
167
+ }
168
+ catch (error) {
169
+ if (hasErrorCode(error, 'ENOENT'))
170
+ return [];
171
+ if (hasErrorCode(error, 'ELOOP', 'EISDIR'))
172
+ return [unrecorded(`${path} is not a record cockpit wrote`)];
173
+ return [unrecorded(`${path} could not be read (${errorCause(error)})`)];
174
+ }
175
+ const record = decode(bytes);
176
+ if (!record)
177
+ return [unrecorded(`${path} is not a record cockpit wrote`)];
178
+ const findings = await Promise.all(record.siblings.map(async (sibling) => {
179
+ const current = served.get(sibling.slug);
180
+ if (!current)
181
+ return undefined;
182
+ let changes;
183
+ if (current.served !== sibling.served)
184
+ changes = [`it is now served from ${current.served}`];
185
+ else {
186
+ changes = (await mapLimit(sibling.inputs, DIGEST_CONCURRENCY, async (input) => {
187
+ let digest;
188
+ try {
189
+ digest = await digestPath(join(sibling.served, input.path));
190
+ }
191
+ catch (error) {
192
+ return `${input.path} (${input.source}) could not be read: ${errorCause(error)}`;
193
+ }
194
+ return digest === input.sha256 ? undefined : `${input.path} (${input.source}) ${digest === null ? 'was removed' : input.sha256 === null ? 'was created' : 'changed'}`;
195
+ })).filter(change => change !== undefined);
196
+ }
197
+ if (changes.length === 0)
198
+ return undefined;
199
+ return {
200
+ severity: 'warning',
201
+ code: 'STACK_INPUTS_CHANGED',
202
+ message: `the stack came up at ${record.recordedAt} with sibling ${sibling.slug} from ${sibling.served}; since then ${changes.join(', ')}, `
203
+ + 'so its services may still run the old dependencies, schema or code',
204
+ target: `sibling:${sibling.slug}`,
205
+ fix: `run ${cockpitCommand('down')} and then ${cockpitCommand('up')} so every service starts on the current files; `
206
+ + 'a service whose image installs the dependencies needs that image rebuilt before up',
207
+ };
208
+ }));
209
+ return findings.filter(finding => finding !== undefined);
210
+ }
@@ -0,0 +1,31 @@
1
+ import { type Executor, type StacksContext, type StacksInput, type StacksOutcome } from './stack-inventory-report.js';
2
+ /**
3
+ * Whose orphans a prune removes. An agent's `--apply` passes this cockpit's `[autonomy]` policy, which
4
+ * says nothing for another cockpit's stacks, so it covers this cockpit's stacks and the state folders
5
+ * earlier builds left; a person who approved the preview covers the machine.
6
+ */
7
+ export type StacksPruneScope = 'this-cockpit' | 'machine';
8
+ /** What a person or an agent approves: the exact prune a fresh observation shows, and how to apply it. */
9
+ export interface StacksPruneSession {
10
+ readonly preview: StacksOutcome;
11
+ /** The digest of the whole preview, which a person types to approve it. */
12
+ readonly digest: string;
13
+ /** Whether the scope the session applies holds nothing to remove, and whether it deletes volumes. */
14
+ readonly empty: boolean;
15
+ readonly deletesData: boolean;
16
+ apply(): Promise<StacksOutcome>;
17
+ }
18
+ /**
19
+ * Observes the machine and plans the prune of every orphaned stack and state folder. The preview lists
20
+ * them all; `apply` removes those in `scope`, after observing again and only while that part of the plan
21
+ * still has the digest it had at the preview: containers first, then networks and volumes, then the
22
+ * state that holds the port lease, then unreadable state folders. A failed step stops the prune and
23
+ * keeps the state, so the stack stays listed and the next prune resumes it.
24
+ */
25
+ export declare function beginStacksPrune(input: StacksInput, scope: StacksPruneScope, dependencies: Readonly<{
26
+ execute: Executor;
27
+ }>): Promise<StacksPruneSession | StacksOutcome>;
28
+ /** The prune of an already identified checkout's machine: what `beginStacksPrune` opens once the context is loaded. */
29
+ export declare function openStacksPrune(context: StacksContext, scope: StacksPruneScope, dependencies: Readonly<{
30
+ execute: Executor;
31
+ }>): Promise<StacksPruneSession | StacksOutcome>;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `cockpit stacks prune`: plans the removal of every orphaned stack and unreadable state folder the
3
+ * inventory shows, binds the plan to a digest, and removes exactly that plan once it is approved.
4
+ */
5
+ import { randomUUID } from 'node:crypto';
6
+ import { authenticatedExecutionOutcome } from '../environment-exec/execute.js';
7
+ import { cockpitCommand } from '../fix-catalogue.js';
8
+ import { sha256Hex } from '../sha256.js';
9
+ import { ENVIRONMENT_CODES } from './contract.js';
10
+ import { holderId } from './identity.js';
11
+ import { machineStackRemovalSteps } from './reclaim.js';
12
+ import { loadStacksContext, observeWithDocker, refusalStatus } from './stack-inventory-report.js';
13
+ import { removeStackState, removeStateNamespaceFolder } from './state.js';
14
+ function plannedRow({ row, resources, files }) {
15
+ const named = (kind) => resources.filter((resource) => resource.kind === kind).map(({ name }) => name);
16
+ return { cockpitId: row.cockpitId, stackId: row.stackId, checkout: row.checkout, reason: row.reason,
17
+ containers: named('container'), networks: named('network'), volumes: named('volume'), stateFiles: files.map(({ path }) => path) };
18
+ }
19
+ function planOf(observed, inScope) {
20
+ const stacks = observed.stacks.filter(({ row }) => row.verdict === 'orphaned' && inScope(row));
21
+ const folders = observed.folders.filter(({ row }) => row.verdict === 'orphaned');
22
+ // The digest binds the exact resources and file bytes, so an approval covers only what was shown.
23
+ const bound = {
24
+ stacks: stacks.map(({ row, resources, files }) => ({ cockpitId: row.cockpitId, stackId: row.stackId,
25
+ resources: resources.map(({ kind, id }) => `${kind}:${id}`), files: files.map(({ kind, path, sha256 }) => `${kind}:${path}:${sha256}`) })),
26
+ folders: folders.map(({ row, fingerprint }) => `${row.path}:${fingerprint}`),
27
+ };
28
+ return { stacks, folders, digest: sha256Hex(JSON.stringify(bound)),
29
+ deletesData: stacks.some(({ resources }) => resources.some(({ kind }) => kind === 'volume')) };
30
+ }
31
+ function otherCockpitsWarning(stacks, configPath) {
32
+ if (stacks.length === 0)
33
+ return [];
34
+ return [{ severity: 'warning', target: 'stacks',
35
+ code: 'STACKS_PRUNE_UNCONFIRMED',
36
+ message: `orphaned stacks of other cockpits are removed only with a person's approval, because this cockpit's [autonomy] policy does not speak for them; an agent's --apply leaves ${stacks.map(({ row }) => row.stackId).join(', ')}`,
37
+ fix: `a person runs ${cockpitCommand('stacks prune', { config: configPath, args: '--interactive' })} in a terminal outside CI, reads the preview and types its digest` }];
38
+ }
39
+ // Containers first, so no network or volume is still held when its removal runs.
40
+ const PRUNE_ORDER = ['container', 'network', 'volume'];
41
+ /**
42
+ * Observes the machine and plans the prune of every orphaned stack and state folder. The preview lists
43
+ * them all; `apply` removes those in `scope`, after observing again and only while that part of the plan
44
+ * still has the digest it had at the preview: containers first, then networks and volumes, then the
45
+ * state that holds the port lease, then unreadable state folders. A failed step stops the prune and
46
+ * keeps the state, so the stack stays listed and the next prune resumes it.
47
+ */
48
+ export async function beginStacksPrune(input, scope, dependencies) {
49
+ const context = await loadStacksContext(input, dependencies.execute);
50
+ return 'kind' in context ? refusalStatus(context) : openStacksPrune(context, scope, dependencies);
51
+ }
52
+ /** The prune of an already identified checkout's machine: what `beginStacksPrune` opens once the context is loaded. */
53
+ export async function openStacksPrune(context, scope, dependencies) {
54
+ const observed = await observeWithDocker(context, dependencies.execute);
55
+ if ('kind' in observed)
56
+ return refusalStatus(observed);
57
+ const thisCockpit = (row) => row.cockpitId === context.own.cockpitId;
58
+ const inScope = scope === 'machine' ? () => true : thisCockpit;
59
+ const whole = planOf(observed, () => true);
60
+ const plan = planOf(observed, inScope);
61
+ const kept = observed.inventory.stacks.filter(({ verdict }) => verdict !== 'orphaned').length;
62
+ const summary = { digest: whole.digest, deletesData: whole.deletesData,
63
+ stacks: whole.stacks.map((stack) => ({ ...plannedRow(stack), thisCockpit: thisCockpit(stack.row) })),
64
+ stateFolders: whole.folders.map(({ row }) => ({ path: row.path, reason: row.reason })), kept };
65
+ const wholeEmpty = whole.stacks.length === 0 && whole.folders.length === 0;
66
+ return {
67
+ digest: whole.digest, empty: plan.stacks.length === 0 && plan.folders.length === 0, deletesData: plan.deletesData,
68
+ preview: { status: 0, diagnostics: otherCockpitsWarning(whole.stacks.filter(({ row }) => !thisCockpit(row)), context.configPath),
69
+ result: { dryRun: true, ...summary, apply: wholeEmpty ? null : cockpitCommand('stacks prune', { config: context.configPath, args: '--apply' }) } },
70
+ apply: async () => {
71
+ const again = await observeWithDocker(context, dependencies.execute);
72
+ if ('kind' in again)
73
+ return refusalStatus(again);
74
+ const current = planOf(again, inScope);
75
+ if (current.digest !== plan.digest)
76
+ return { status: 2, result: null, diagnostics: [{ severity: 'error', target: 'stacks',
77
+ code: 'STACKS_PRUNE_UNCONFIRMED',
78
+ message: 'the orphaned stacks changed between the preview and the removal, so nothing was removed',
79
+ fix: `preview again with ${cockpitCommand('stacks prune', { config: context.configPath })} and approve the new preview` }] };
80
+ const ownerId = holderId({ stackId: context.own.stackId, checkoutId: context.own.checkoutId, sessionId: randomUUID() });
81
+ const removed = [];
82
+ const incomplete = (what) => ({ status: 2, result: { removed }, diagnostics: [{ severity: 'error', target: 'stacks',
83
+ code: 'STACKS_PRUNE_INCOMPLETE',
84
+ message: `prune stopped: ${what}; what it had not removed is still listed`,
85
+ fix: `inspect the named resource, then run ${cockpitCommand('stacks prune', { config: context.configPath, args: '--apply' })} again to resume` }] });
86
+ for (const stack of current.stacks) {
87
+ for (const kind of PRUNE_ORDER) {
88
+ const batch = stack.resources.filter((resource) => resource.kind === kind);
89
+ if (batch.length === 0)
90
+ continue;
91
+ const removals = machineStackRemovalSteps(context.docker, again.selection, batch) ?? [{ step: undefined, resources: batch }];
92
+ for (const { step, resources } of removals) {
93
+ const outcome = step === undefined ? undefined : authenticatedExecutionOutcome(await dependencies.execute(step), step);
94
+ if (outcome?.kind !== 'complete')
95
+ return incomplete(`docker could not remove ${kind} ${resources.map(({ name }) => name).join(', ')} of ${stack.row.stackId}${outcome ? ` (${outcome.code})` : ''}`);
96
+ }
97
+ }
98
+ if (stack.namespace !== undefined && stack.files.length > 0) {
99
+ const state = await removeStackState(stack.namespace, stack.row.stackId, stack.files, { ownerId });
100
+ if (state.kind !== 'complete')
101
+ return incomplete(`the state of ${stack.row.stackId} could not be removed: ${state.kind === 'refusal' ? state.message : 'it disappeared'}`);
102
+ if (state.value.kind === 'changed')
103
+ return incomplete(state.value.reason);
104
+ }
105
+ removed.push(plannedRow(stack));
106
+ }
107
+ const folders = [];
108
+ for (const { row, fingerprint } of current.folders) {
109
+ const folder = await removeStateNamespaceFolder(context.namespace, row.path, fingerprint, { ownerId });
110
+ if (folder.kind !== 'complete')
111
+ return incomplete(`${row.path} could not be removed: ${folder.kind === 'refusal' ? folder.message : 'it disappeared'}`);
112
+ if (folder.value.kind === 'changed')
113
+ return incomplete(folder.value.reason);
114
+ folders.push(row.path);
115
+ }
116
+ // The removal is reported only once a fresh observation no longer lists what was removed.
117
+ const after = await observeWithDocker(context, dependencies.execute);
118
+ if ('kind' in after)
119
+ return refusalStatus(after);
120
+ const left = planOf(after, inScope);
121
+ const remaining = removed.filter(({ cockpitId, stackId }) => left.stacks.some(({ row }) => row.cockpitId === cockpitId && row.stackId === stackId));
122
+ if (remaining.length > 0 || left.folders.some(({ row }) => folders.includes(row.path))) {
123
+ return incomplete(`a fresh inventory still lists ${remaining.map(({ stackId }) => stackId).join(', ') || 'a removed state folder'}`);
124
+ }
125
+ return { status: 0, diagnostics: otherCockpitsWarning(planOf(after, (row) => !inScope(row)).stacks, context.configPath),
126
+ result: { digest: plan.digest, removed, removedStateFolders: folders } };
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `cockpit stacks`: identifies the checkout running the command, observes the machine through the
3
+ * local Docker daemon, and lists every stack with its verdict. It changes nothing; the prune in
4
+ * `stack-inventory-prune.ts` opens from the same context.
5
+ */
6
+ import type { ExecuteProbeOutcome } from '../environment-exec/execute.js';
7
+ import { type Diagnostic, type ExecutionStep } from './contract.js';
8
+ import { type ResolvedExecutable } from './executable.js';
9
+ import { checkoutId as deriveCheckoutId } from './identity.js';
10
+ import { type Observation, type OwnCheckout, type Refusal } from './stack-inventory.js';
11
+ import { type StateNamespace } from './state.js';
12
+ export type Executor = (step: ExecutionStep) => Promise<ExecuteProbeOutcome>;
13
+ /** What `cockpit stacks` and its prune are run with: the configuration and the caller's environment. */
14
+ export interface StacksInput {
15
+ readonly configPath: string;
16
+ readonly env: Readonly<Record<string, string | undefined>>;
17
+ }
18
+ export interface StacksContext {
19
+ readonly configPath: string;
20
+ readonly namespace: StateNamespace;
21
+ readonly own: OwnCheckout & Readonly<{
22
+ checkoutId: ReturnType<typeof deriveCheckoutId>;
23
+ }>;
24
+ readonly docker: Readonly<{
25
+ executable: ResolvedExecutable;
26
+ context: string;
27
+ host?: string;
28
+ cwd: string;
29
+ env: Readonly<Record<string, string>>;
30
+ timeoutMs: number;
31
+ }>;
32
+ }
33
+ export declare function loadStacksContext({ configPath, env }: StacksInput, execute: Executor): Promise<StacksContext | Refusal>;
34
+ export declare function observeWithDocker(context: StacksContext, execute: Executor): Promise<Observation | Refusal>;
35
+ export interface StacksOutcome {
36
+ readonly status: number;
37
+ readonly result: unknown;
38
+ readonly diagnostics: readonly Diagnostic[];
39
+ }
40
+ export declare function refusalStatus(refusal: Refusal): StacksOutcome;
41
+ /** `cockpit stacks`: lists every stack this machine knows about, and changes nothing. */
42
+ export declare function runStacksInventory(input: StacksInput, dependencies: Readonly<{
43
+ execute: Executor;
44
+ }>): Promise<StacksOutcome>;
@@ -0,0 +1,78 @@
1
+ import { loadCockpitConfig } from '../config/load.js';
2
+ import { cockpitCommand } from '../fix-catalogue.js';
3
+ import { ENVIRONMENT_CODES } from './contract.js';
4
+ import { selectLocalDockerDaemon } from './docker-daemon.js';
5
+ import { resolveDockerExecutable, resolveGitExecutable } from './executable.js';
6
+ import { checkoutId as deriveCheckoutId, stackId as deriveStackId } from './identity.js';
7
+ import { discoverMachineStacks } from './reclaim.js';
8
+ import { consumeSetupCheckoutProjection, observeSetupCheckout, withSetupCheckoutProjection } from './setup-observation.js';
9
+ import { observeMachineStacks, refuse } from './stack-inventory.js';
10
+ import { selectStateRoot } from './state.js';
11
+ const STEP_TIMEOUT_MS = 10_000;
12
+ export async function loadStacksContext({ configPath, env }, execute) {
13
+ const loaded = await loadCockpitConfig(configPath);
14
+ if (loaded.kind !== 'complete')
15
+ return { kind: 'refusal', diagnostics: loaded.diagnostics };
16
+ const source = loaded.result.source;
17
+ if (process.platform !== 'darwin' && process.platform !== 'linux')
18
+ return refuse(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'cockpit state is kept on macOS or Linux only', process.platform);
19
+ const git = await resolveGitExecutable(env);
20
+ if (git.state !== 'available')
21
+ return refuse(ENVIRONMENT_CODES.SETUP_PATH_TYPE_INVALID, git.reason, 'git');
22
+ const observedAt = new Date().toISOString();
23
+ const observed = await observeSetupCheckout({ source, cockpitName: loaded.result.config.cockpit.name, observedAt,
24
+ env: Object.fromEntries(Object.entries(env).filter((entry) => typeof entry[1] === 'string')), gitExecutable: git.value });
25
+ if (observed.state === 'failed')
26
+ return refuse(observed.code, observed.reason, source.path);
27
+ const checkout = await withSetupCheckoutProjection(observed, { source, cockpitName: loaded.result.config.cockpit.name,
28
+ evaluatedAt: Date.parse(observedAt) }, consumeSetupCheckoutProjection);
29
+ if (!checkout)
30
+ return refuse(ENVIRONMENT_CODES.SETUP_PATH_TYPE_INVALID, 'the checkout running this command could not be identified', source.path);
31
+ const namespace = await selectStateRoot({ platform: process.platform, home: env.HOME, xdgStateHome: env.XDG_STATE_HOME,
32
+ checkoutRoots: [checkout.currentCheckoutPath, checkout.mainCheckoutPath], cockpitId: checkout.cockpitId });
33
+ if (namespace.kind !== 'complete')
34
+ return namespace.kind === 'refusal' ? refuse(namespace.code, namespace.message, namespace.target)
35
+ : refuse(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'the state root is unavailable', source.path);
36
+ // Prune deletes, so the daemon is fixed once, as for down and reset, and every step addresses it.
37
+ const daemon = await selectLocalDockerDaemon(env, STEP_TIMEOUT_MS, execute, { command: 'stacks',
38
+ localOnly: 'the inventory and its prune describe this machine, which a remote daemon says nothing about' });
39
+ if (daemon.kind === 'refusal')
40
+ return { kind: 'refusal', diagnostics: [daemon.diagnostic] };
41
+ const docker = await resolveDockerExecutable(env);
42
+ if (docker.state !== 'available')
43
+ return refuse(ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, docker.reason, 'docker');
44
+ return {
45
+ configPath: source.path, namespace: namespace.value,
46
+ own: { cockpitId: checkout.cockpitId, checkoutId: checkout.checkoutId, mainCheckoutPath: checkout.mainCheckoutPath,
47
+ stackId: deriveStackId({ cockpitId: checkout.cockpitId, checkoutId: checkout.checkoutId, variant: { kind: 'shared' } }) },
48
+ docker: { executable: docker.value, context: daemon.dockerContext, ...(env.DOCKER_HOST === undefined ? {} : { host: env.DOCKER_HOST }),
49
+ cwd: checkout.currentCheckoutPath, env: { PATH: env.PATH ?? '' }, timeoutMs: STEP_TIMEOUT_MS },
50
+ };
51
+ }
52
+ export async function observeWithDocker(context, execute) {
53
+ const discovery = await discoverMachineStacks(context.docker, execute);
54
+ if (discovery === undefined)
55
+ return refuse(ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, `the containers, networks and volumes cockpit labels could not be listed on Docker context ${context.docker.context}`, 'docker', 'start Docker, or select the local context that runs cockpit stacks, then retry');
56
+ return observeMachineStacks({ namespace: context.namespace, own: context.own, discovery });
57
+ }
58
+ export function refusalStatus(refusal) {
59
+ return { status: 2, result: null, diagnostics: refusal.diagnostics };
60
+ }
61
+ /** `cockpit stacks`: lists every stack this machine knows about, and changes nothing. */
62
+ export async function runStacksInventory(input, dependencies) {
63
+ const context = await loadStacksContext(input, dependencies.execute);
64
+ if ('kind' in context)
65
+ return refusalStatus(context);
66
+ const observed = await observeWithDocker(context, dependencies.execute);
67
+ if ('kind' in observed)
68
+ return refusalStatus(observed);
69
+ const orphaned = observed.inventory.stacks.filter(({ verdict }) => verdict === 'orphaned').length
70
+ + observed.inventory.stateFolders.filter(({ verdict }) => verdict === 'orphaned').length;
71
+ return { status: 0, result: observed.inventory, diagnostics: orphaned === 0 ? [] : [orphanedWarning(orphaned, context.configPath)] };
72
+ }
73
+ function orphanedWarning(count, configPath) {
74
+ return { severity: 'warning', target: 'stacks',
75
+ code: 'STACKS_ORPHANED',
76
+ message: `${count === 1 ? '1 stack or state folder on this machine is' : `${count} stacks or state folders on this machine are`} orphaned: its checkout is gone or its state cannot be read, and it still holds containers, volumes, a port lease or state`,
77
+ fix: `preview the removal with ${cockpitCommand('stacks prune', { config: configPath })}, then apply it with --apply` };
78
+ }