@b00dea/cockpit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +74 -4
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- package/surfaces/skills/procedures/work.md +105 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns `cockpit sibling add` and `cockpit sibling remove`: they grow and prune the `[[sibling]]`
|
|
3
|
+
* tables of cockpit.toml so an agent or a human never hand-edits them.
|
|
4
|
+
*
|
|
5
|
+
* It decides which row to record and why; the text edit lives in `config/sibling-toml.ts` and the
|
|
6
|
+
* verified write in `config/verified-write.ts`. It never clones, moves or deletes a checkout, and
|
|
7
|
+
* it never talks to a remote host: the only child processes are local `git init` and a local read
|
|
8
|
+
* of an existing checkout's `origin`, and both cross the supplied `execute`, which production
|
|
9
|
+
* binds to `environment-exec/execute.ts`. Before editing, it reads (never writes) the checkout
|
|
10
|
+
* identity through `setup-observation.ts` and the stack's lifecycle record, so it can refuse an
|
|
11
|
+
* edit that would strand a recorded stack.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
|
|
15
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
16
|
+
import { parse, TomlError } from 'smol-toml';
|
|
17
|
+
import { authenticatedExecutionOutcome } from '../environment-exec/execute.js';
|
|
18
|
+
import { loadCockpitConfig, readBoundedInput } from '../config/load.js';
|
|
19
|
+
import { cockpitCommand } from '../fix-catalogue.js';
|
|
20
|
+
import { appendSiblingTable, recordSiblingRemote, removeSiblingTable } from '../config/sibling-toml.js';
|
|
21
|
+
import { writeVerifiedConfig } from '../config/verified-write.js';
|
|
22
|
+
import { CockpitConfig, deriveSiblingVariable, Mount, runtimeDeclarationSha256, Sibling } from '../schema.js';
|
|
23
|
+
import { ENVIRONMENT_CODES, errorCause, filesystemObjectIdentity, RefusalError, hasErrorCode } from './contract.js';
|
|
24
|
+
import { declaredRuntime } from './declaration.js';
|
|
25
|
+
import { resolveGitExecutable } from './executable.js';
|
|
26
|
+
import { stackId } from './identity.js';
|
|
27
|
+
import { decodeLifecycleRecord } from './lifecycle-stage.js';
|
|
28
|
+
import { observeGeneratedCheckoutIdentity } from './setup-observation.js';
|
|
29
|
+
import { readState, selectStateRoot } from './state.js';
|
|
30
|
+
import { refusalOutcome as refused } from '../refusal.js';
|
|
31
|
+
const STEP_TIMEOUT_MS = 10_000;
|
|
32
|
+
const FREE_TEXT_UNSAFE = /[\u0000-\u001f\u007f]/;
|
|
33
|
+
function usage(message, target) {
|
|
34
|
+
return refused('CLI_USAGE_INVALID', message, target);
|
|
35
|
+
}
|
|
36
|
+
/** Key order must not matter when two parsed configurations are compared. */
|
|
37
|
+
function canonical(value) {
|
|
38
|
+
return JSON.stringify(value, (_key, item) => item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
39
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : 1)) : item);
|
|
40
|
+
}
|
|
41
|
+
function gitStep(executable, cwd, env, id, args) {
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
argv: [executable.canonicalPath, ...args],
|
|
45
|
+
argv0: executable.invocationPath,
|
|
46
|
+
cwd, env, timeoutMs: STEP_TIMEOUT_MS,
|
|
47
|
+
stdoutLimitBytes: 4_096, stderrLimitBytes: 4_096,
|
|
48
|
+
paths: [], ports: [],
|
|
49
|
+
labels: { purpose: id },
|
|
50
|
+
expected: [],
|
|
51
|
+
consumedFiles: [{
|
|
52
|
+
path: executable.canonicalPath, sha256: executable.sha256,
|
|
53
|
+
maxBytes: 536_870_912, argvIndexes: [0], executable: true,
|
|
54
|
+
executionMode: executable.executionMode,
|
|
55
|
+
}],
|
|
56
|
+
filesystemChecks: [],
|
|
57
|
+
compensation: [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function runGit(dependencies, step) {
|
|
61
|
+
const outcome = authenticatedExecutionOutcome(await dependencies.execute(step), step);
|
|
62
|
+
if (!outcome)
|
|
63
|
+
return { kind: 'failed', exitCode: -1, detail: 'the execution outcome could not be authenticated' };
|
|
64
|
+
const decode = (bytes) => new TextDecoder('utf-8', { fatal: false }).decode(bytes).trim();
|
|
65
|
+
if (outcome.kind !== 'complete') {
|
|
66
|
+
const stderr = decode(outcome.stderr);
|
|
67
|
+
return { kind: 'failed', exitCode: outcome.execution.exitCode, detail: `${outcome.code}${stderr ? `: ${stderr.split('\n', 1)[0].slice(0, 256)}` : ''}` };
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'complete', text: decode(outcome.stdout) };
|
|
70
|
+
}
|
|
71
|
+
/** Initialises one local repository through the authenticated process seam used by sibling creation. */
|
|
72
|
+
export async function initializeLocalGitRepository(input, dependencies) {
|
|
73
|
+
const searchEnvironment = input.env;
|
|
74
|
+
const refuse = (reason) => ({ kind: 'refusal',
|
|
75
|
+
reason: `${input.path}: ${reason}; choose an empty directory or an existing local Git repository` });
|
|
76
|
+
const git = await resolveGitExecutable(searchEnvironment);
|
|
77
|
+
if (git.state !== 'available')
|
|
78
|
+
return refuse(git.reason);
|
|
79
|
+
try {
|
|
80
|
+
const directory = await lstat(input.path, { bigint: true });
|
|
81
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || await realpath(input.path) !== input.path) {
|
|
82
|
+
return refuse('the dedicated cockpit directory is not a canonical plain directory');
|
|
83
|
+
}
|
|
84
|
+
const entries = await readdir(input.path);
|
|
85
|
+
if (entries.length > 0) {
|
|
86
|
+
if (!entries.includes('.git'))
|
|
87
|
+
return refuse('the dedicated cockpit directory already contains unrelated files');
|
|
88
|
+
const metadata = await lstat(resolve(input.path, '.git'));
|
|
89
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()
|
|
90
|
+
|| await realpath(resolve(input.path, '.git')) !== resolve(input.path, '.git')) {
|
|
91
|
+
return refuse('the existing Git metadata must be a plain local .git directory');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const ancestors = [];
|
|
96
|
+
for (let path = dirname(input.path);;) {
|
|
97
|
+
const status = await lstat(path, { bigint: true });
|
|
98
|
+
if (!status.isDirectory() || status.isSymbolicLink())
|
|
99
|
+
return refuse('a parent directory changed before initialization');
|
|
100
|
+
ancestors.unshift({ path, ...filesystemObjectIdentity(status) });
|
|
101
|
+
if (dirname(path) === path)
|
|
102
|
+
break;
|
|
103
|
+
if (ancestors.length >= 64)
|
|
104
|
+
return refuse('the directory ancestry exceeds the verification bound');
|
|
105
|
+
path = dirname(path);
|
|
106
|
+
}
|
|
107
|
+
const initialised = await runGit(dependencies, {
|
|
108
|
+
...gitStep(git.value, dirname(input.path), { PATH: input.env.PATH ?? '' }, 'repository:init', ['init', input.path]),
|
|
109
|
+
paths: [input.path],
|
|
110
|
+
filesystemChecks: [
|
|
111
|
+
{ path: input.path, expected: 'directory', identity: { ...filesystemObjectIdentity(directory), ancestors } },
|
|
112
|
+
{ path: resolve(input.path, '.git'), expected: 'absent' },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
if (initialised.kind !== 'complete')
|
|
116
|
+
return refuse(`git init failed (${initialised.detail})`);
|
|
117
|
+
const metadata = await lstat(resolve(input.path, '.git'));
|
|
118
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()
|
|
119
|
+
|| await realpath(resolve(input.path, '.git')) !== resolve(input.path, '.git')) {
|
|
120
|
+
return refuse('git init did not create plain local metadata');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const top = await runGit(dependencies, gitStep(git.value, input.path, { PATH: input.env.PATH ?? '' }, 'repository:root', ['rev-parse', '--show-toplevel']));
|
|
124
|
+
if (top.kind !== 'complete' || top.text !== input.path) {
|
|
125
|
+
return refuse('the Git repository does not belong to this exact directory');
|
|
126
|
+
}
|
|
127
|
+
return { kind: 'complete' };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return refuse(`the local repository could not be verified (${errorCause(error)})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function remoteProblem(remote) {
|
|
134
|
+
if (remote.startsWith('-'))
|
|
135
|
+
return 'a remote that starts with "-" would be read as a git option';
|
|
136
|
+
if (/\s/.test(remote) || FREE_TEXT_UNSAFE.test(remote))
|
|
137
|
+
return 'a remote cannot contain whitespace or control characters';
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
/** The deepest existing ancestor must resolve inside the siblings root, so a symlinked path cannot send `git init` elsewhere. */
|
|
141
|
+
async function confinedBelow(root, target) {
|
|
142
|
+
let probe = target;
|
|
143
|
+
for (;;) {
|
|
144
|
+
try {
|
|
145
|
+
const real = await realpath(probe);
|
|
146
|
+
const realRoot = await realpath(root);
|
|
147
|
+
return real === realRoot || real.startsWith(`${realRoot}${sep}`);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!hasErrorCode(error, 'ENOENT') || probe === dirname(probe))
|
|
151
|
+
return false;
|
|
152
|
+
probe = dirname(probe);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function kind(path) {
|
|
157
|
+
try {
|
|
158
|
+
const entry = await lstat(path);
|
|
159
|
+
return entry.isDirectory() ? 'directory' : 'other';
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
163
|
+
return 'absent';
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Build the edited text and the exact configuration it must parse to. The schema is run on the
|
|
169
|
+
* candidate before any directory is touched, so a refusal never leaves a stray repository behind.
|
|
170
|
+
*/
|
|
171
|
+
const EDIT_BY_HAND_FIX = 'make the [[sibling]] change in cockpit.toml by hand, then run npx --no cockpit sync --config <your cockpit.toml> --dry-run';
|
|
172
|
+
const CORRECT_OPTION_FIX = 'correct the value the message names in the sibling options and run the command again, or make the [[sibling]] change in cockpit.toml by hand, then run npx --no cockpit sync --config <your cockpit.toml> --dry-run';
|
|
173
|
+
function prepare(base, edit, siblings) {
|
|
174
|
+
if (edit.kind === 'unsupported')
|
|
175
|
+
return refused(ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, `cockpit.toml cannot be edited safely: ${edit.reason}; nothing was written`, 'sibling', EDIT_BY_HAND_FIX);
|
|
176
|
+
const expected = { ...base, sibling: siblings };
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = CockpitConfig.safeParse(parse(edit.text));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const where = error instanceof TomlError ? `at line ${error.line}, column ${error.column}` : errorCause(error);
|
|
183
|
+
return refused(ENVIRONMENT_CODES.CONFIG_PARSE_INVALID, `the edit would leave cockpit.toml unparseable (${where}); nothing was written`, 'sibling', CORRECT_OPTION_FIX);
|
|
184
|
+
}
|
|
185
|
+
if (!parsed.success) {
|
|
186
|
+
const issue = parsed.error.issues[0];
|
|
187
|
+
return refused(ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, `the edit would leave cockpit.toml invalid (${issue ? `${issue.path.join('.') || '(root)'}: ${issue.message}` : 'schema'}); nothing was written`, 'sibling', CORRECT_OPTION_FIX);
|
|
188
|
+
}
|
|
189
|
+
if (canonical(parsed.data) !== canonical(expected)) {
|
|
190
|
+
return refused(ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, 'the edit would change more than the one sibling table; nothing was written', 'sibling', EDIT_BY_HAND_FIX);
|
|
191
|
+
}
|
|
192
|
+
return { text: edit.text, expected };
|
|
193
|
+
}
|
|
194
|
+
const isOutcome = (value) => 'diagnostics' in value;
|
|
195
|
+
async function commit(path, sha256, prepared, verb) {
|
|
196
|
+
const rerun = `let the other edit finish, then run the same npx --no cockpit sibling ${verb} command again`;
|
|
197
|
+
const written = await writeVerifiedConfig({
|
|
198
|
+
path, expectedSha256: sha256, temporaryTag: 'sibling',
|
|
199
|
+
edit: () => prepared.text,
|
|
200
|
+
isExactChange: (config) => canonical(config) === canonical(prepared.expected),
|
|
201
|
+
messages: {
|
|
202
|
+
changedBefore: { message: 'cockpit.toml changed since it was read; nothing was written', fix: rerun },
|
|
203
|
+
changedDuring: { message: 'cockpit.toml changed while the edit was being prepared; nothing was written', fix: rerun },
|
|
204
|
+
invalid: (reason) => ({ message: `the edit would leave cockpit.toml invalid (${reason}); nothing was written`, fix: CORRECT_OPTION_FIX }),
|
|
205
|
+
notExact: { message: 'the edit would change more than the one sibling table; nothing was written', fix: EDIT_BY_HAND_FIX },
|
|
206
|
+
writtenMismatch: { message: 'the table was written, but re-reading cockpit.toml shows a change beyond it',
|
|
207
|
+
fix: 'review cockpit.toml for the change you did not make before running npx --no cockpit sync --config <your cockpit.toml> --dry-run' },
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
return written.kind === 'complete' ? null : refused(written.code, written.message, path, written.fix);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A recorded stack runs the runtime declarations it was started from, and `up` refuses to resume it
|
|
214
|
+
* once they differ. A row the stack does not use (mount "none", named by no generated service)
|
|
215
|
+
* leaves them unchanged and is written at once; any other row would change the environment under
|
|
216
|
+
* the stack, so that edit is refused before anything is written, while `down` still works.
|
|
217
|
+
*/
|
|
218
|
+
async function refuseWhileStackRecorded(loaded, edited, env) {
|
|
219
|
+
if (runtimeDeclarationSha256(edited) === loaded.runtimeSha256)
|
|
220
|
+
return undefined;
|
|
221
|
+
const configPath = loaded.source.path;
|
|
222
|
+
// Lifecycle commands start a stack only for a declared runtime on these platforms, so no other
|
|
223
|
+
// cockpit can have one recorded.
|
|
224
|
+
if (declaredRuntime(loaded.config.environment) === 'none' || (process.platform !== 'darwin' && process.platform !== 'linux'))
|
|
225
|
+
return undefined;
|
|
226
|
+
const checkout = await observeGeneratedCheckoutIdentity({ source: loaded.source, cockpitName: loaded.config.cockpit.name, env });
|
|
227
|
+
if (checkout.state === 'failed')
|
|
228
|
+
return refused(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `cannot tell whether a stack started from ${configPath} is still recorded: ${checkout.reason}; nothing was written`, configPath);
|
|
229
|
+
const namespace = await selectStateRoot({ platform: process.platform, home: env.HOME, xdgStateHome: env.XDG_STATE_HOME,
|
|
230
|
+
checkoutRoots: [checkout.currentCheckoutPath, checkout.mainCheckoutPath], cockpitId: checkout.cockpitId });
|
|
231
|
+
if (namespace.kind !== 'complete')
|
|
232
|
+
return refused(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `cannot tell whether a stack started from ${configPath} is still recorded: ${'message' in namespace ? namespace.message : 'the state root is missing'}; nothing was written`, configPath);
|
|
233
|
+
const stack = stackId({ cockpitId: checkout.cockpitId, checkoutId: checkout.checkoutId, variant: { kind: 'shared' } });
|
|
234
|
+
const address = namespace.value.paths.lifecycle(stack);
|
|
235
|
+
const stored = await readState(address);
|
|
236
|
+
if (stored.kind === 'missing')
|
|
237
|
+
return undefined;
|
|
238
|
+
if (stored.kind !== 'complete')
|
|
239
|
+
return refused(stored.code, `${stored.message}; nothing was written`, stored.target);
|
|
240
|
+
const lifecycle = decodeLifecycleRecord(stored.value, { cockpitId: checkout.cockpitId, stackId: stack });
|
|
241
|
+
if (!lifecycle)
|
|
242
|
+
return refused(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'lifecycle progress record is malformed; nothing was written', address.path);
|
|
243
|
+
if (lifecycle.value.teardownComplete === true)
|
|
244
|
+
return undefined;
|
|
245
|
+
return refused('SIBLING_STACK_RECORDED', `a stack started from ${configPath} is still recorded, and this repository would change the environment underneath it; nothing was written — fix: run \`${cockpitCommand('down', { config: configPath })}\`, then rerun this command`, configPath);
|
|
246
|
+
}
|
|
247
|
+
export async function runSiblingAdd(input, dependencies) {
|
|
248
|
+
const searchEnvironment = input.env;
|
|
249
|
+
const modes = [input.create, input.from !== undefined, input.remote !== undefined].filter(Boolean).length;
|
|
250
|
+
if (modes > 1)
|
|
251
|
+
return usage('--create, --from and --remote are mutually exclusive; choose the one that matches where the repository is');
|
|
252
|
+
const slug = Sibling.shape.slug.safeParse(input.slug);
|
|
253
|
+
if (!slug.success)
|
|
254
|
+
return usage(`slug ${JSON.stringify(input.slug)} must be lowercase letters, digits and hyphens, starting with a letter or digit`, 'slug');
|
|
255
|
+
for (const [name, value] of [['--purpose', input.purpose], ['--remote', input.remote], ['--from', input.from]]) {
|
|
256
|
+
if (value !== undefined && (value.length === 0 || FREE_TEXT_UNSAFE.test(value)))
|
|
257
|
+
return usage(`${name} must be non-empty text without control characters`, name);
|
|
258
|
+
}
|
|
259
|
+
if (input.remote !== undefined) {
|
|
260
|
+
const problem = remoteProblem(input.remote);
|
|
261
|
+
if (problem)
|
|
262
|
+
return usage(`--remote: ${problem}`, '--remote');
|
|
263
|
+
}
|
|
264
|
+
const loaded = await loadCockpitConfig(input.configPath);
|
|
265
|
+
if (loaded.kind !== 'complete')
|
|
266
|
+
return { status: 2, result: null, diagnostics: loaded.diagnostics };
|
|
267
|
+
const configPath = loaded.result.source.path;
|
|
268
|
+
const config = loaded.result.config;
|
|
269
|
+
const configDir = dirname(configPath);
|
|
270
|
+
const root = resolve(configDir, config.cockpit.siblings_root);
|
|
271
|
+
const existing = config.sibling.findIndex((sibling) => sibling.slug === slug.data);
|
|
272
|
+
const found = existing === -1 ? undefined : config.sibling[existing];
|
|
273
|
+
if (found !== undefined) {
|
|
274
|
+
const recordsRemoteLater = input.remote !== undefined && found.acquire === 'none';
|
|
275
|
+
if (!recordsRemoteLater) {
|
|
276
|
+
return refused('SIBLING_SLUG_DUPLICATE', `sibling "${slug.data}" is already declared (dir "${found.dir}", acquire "${found.acquire}")`
|
|
277
|
+
+ `${found.acquire === 'none' ? '; to record its remote later use --remote <url>' : `; remove it first with ${cockpitCommand('sibling remove', { operand: slug.data, config: configPath })}`}`, slug.data);
|
|
278
|
+
}
|
|
279
|
+
if (input.dir !== undefined && input.dir !== found.dir) {
|
|
280
|
+
return usage(`sibling "${slug.data}" already uses dir "${found.dir}"; --dir cannot move it`, '--dir');
|
|
281
|
+
}
|
|
282
|
+
if (await kind(resolve(root, found.dir)) !== 'directory') {
|
|
283
|
+
return refused('SIBLING_DIRECTORY_MISSING', `sibling "${slug.data}" has acquire "none" but ${resolve(root, found.dir)} does not exist; create the directory first, or remove the sibling and add it with --remote to let setup clone it`, slug.data);
|
|
284
|
+
}
|
|
285
|
+
const text = await sourceText(configPath, loaded.result.source.sha256);
|
|
286
|
+
if (typeof text !== 'string')
|
|
287
|
+
return text;
|
|
288
|
+
const edit = recordSiblingRemote(text, existing, config.sibling.length, input.remote);
|
|
289
|
+
const updated = Sibling.parse({ ...found, acquire: 'auto', remote: input.remote });
|
|
290
|
+
const prepared = prepare(config, edit, config.sibling.map((sibling, index) => index === existing ? updated : sibling));
|
|
291
|
+
if (isOutcome(prepared))
|
|
292
|
+
return prepared;
|
|
293
|
+
const stranded = await refuseWhileStackRecorded(loaded.result, prepared.expected, input.env);
|
|
294
|
+
if (stranded)
|
|
295
|
+
return stranded;
|
|
296
|
+
const failure = await commit(configPath, loaded.result.source.sha256, prepared, 'add');
|
|
297
|
+
if (failure)
|
|
298
|
+
return failure;
|
|
299
|
+
return success('remote-recorded', configPath, updated, { dir: 'existing row', var: 'existing row', purpose: 'existing row' }, `recorded remote ${input.remote} for sibling "${slug.data}" in ${configPath}; the existing directory ${resolve(root, found.dir)} was left as it is and nothing was cloned. `
|
|
300
|
+
+ `Run \`${cockpitCommand('setup', { config: configPath })}\` to verify the remote against the checkout.`);
|
|
301
|
+
}
|
|
302
|
+
if (input.purpose === undefined) {
|
|
303
|
+
return usage('a new sibling needs --purpose, printed when its checkout is missing; say what the repository is for, e.g. --purpose "HTTP API for orders"', '--purpose');
|
|
304
|
+
}
|
|
305
|
+
const purpose = input.purpose;
|
|
306
|
+
const dir = input.dir ?? slug.data;
|
|
307
|
+
const dirCheck = Sibling.shape.dir.safeParse(dir);
|
|
308
|
+
if (!dirCheck.success)
|
|
309
|
+
return usage(`--dir ${JSON.stringify(dir)} must be "." or a confined relative path without dot segments`, '--dir');
|
|
310
|
+
const varName = input.varName ?? deriveSiblingVariable(slug.data);
|
|
311
|
+
if (!Sibling.shape.var.safeParse(varName).success) {
|
|
312
|
+
return usage(`variable ${JSON.stringify(varName)} must be uppercase and must not control Docker, Compose or the host process; use a sibling-specific name such as ${deriveSiblingVariable(slug.data)}`, '--var');
|
|
313
|
+
}
|
|
314
|
+
const mount = siblingMount(input.mount, config);
|
|
315
|
+
if ('diagnostics' in mount)
|
|
316
|
+
return mount;
|
|
317
|
+
for (const sibling of config.sibling) {
|
|
318
|
+
if (sibling.dir === dir)
|
|
319
|
+
return refused('SIBLING_DIR_DUPLICATE', `dir "${dir}" is already used by sibling "${sibling.slug}"; choose another with --dir`, '--dir');
|
|
320
|
+
if (sibling.var === varName)
|
|
321
|
+
return refused('SIBLING_VAR_DUPLICATE', `variable ${varName} is already exported by sibling "${sibling.slug}"; choose another with --var`, '--var');
|
|
322
|
+
}
|
|
323
|
+
const target = resolve(root, dir);
|
|
324
|
+
const state = await kind(target);
|
|
325
|
+
let mode = 'existing';
|
|
326
|
+
let remote;
|
|
327
|
+
let source;
|
|
328
|
+
const git = await resolveGitExecutable(searchEnvironment);
|
|
329
|
+
if (input.create) {
|
|
330
|
+
mode = 'create';
|
|
331
|
+
if (state === 'other')
|
|
332
|
+
return refused('SIBLING_PATH_NOT_EMPTY', `${target} exists and is not a directory; choose another --dir`, '--dir');
|
|
333
|
+
if (state === 'directory' && (await readdir(target)).length > 0) {
|
|
334
|
+
return refused('SIBLING_PATH_NOT_EMPTY', `${target} already has content; --create only initialises an empty or missing path. Use no mode flag to register an existing directory, or --dir for a new one`, '--dir');
|
|
335
|
+
}
|
|
336
|
+
if (await kind(root) !== 'directory')
|
|
337
|
+
return refused('SIBLING_DIRECTORY_MISSING', `the siblings root ${root} does not exist`, 'cockpit.siblings_root');
|
|
338
|
+
if (!await confinedBelow(root, target))
|
|
339
|
+
return refused('SIBLING_PATH_NOT_EMPTY', `${target} resolves outside the siblings root through a symbolic link`, '--dir');
|
|
340
|
+
if (git.state !== 'available')
|
|
341
|
+
return refused(ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, git.reason, 'git');
|
|
342
|
+
}
|
|
343
|
+
else if (input.from !== undefined) {
|
|
344
|
+
mode = 'from';
|
|
345
|
+
source = resolve(input.from);
|
|
346
|
+
if (await kind(source) !== 'directory')
|
|
347
|
+
return refused('SIBLING_DIRECTORY_MISSING', `--from ${source} is not a directory`, '--from');
|
|
348
|
+
if (git.state !== 'available')
|
|
349
|
+
return refused(ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, git.reason, 'git');
|
|
350
|
+
const origin = await runGit(dependencies, gitStep(git.value, configDir, { PATH: input.env.PATH ?? '' }, 'sibling:origin', ['-C', source, 'config', '--get', 'remote.origin.url']));
|
|
351
|
+
if (origin.kind === 'failed' && origin.exitCode !== 1) {
|
|
352
|
+
return refused('SIBLING_GIT_FAILED', `could not read the origin of ${source} (${origin.detail}); is it a git checkout?`, '--from');
|
|
353
|
+
}
|
|
354
|
+
if (origin.kind === 'failed' || origin.text === '') {
|
|
355
|
+
return refused('SIBLING_REMOTE_MISSING', `${source} has no "origin" remote, so setup would have nothing to clone. Either move the repository under ${root} and register it with --dir <name> (acquire "none"), or add a remote first (git -C ${source} remote add origin <url>) and re-run`, '--from');
|
|
356
|
+
}
|
|
357
|
+
const problem = remoteProblem(origin.text);
|
|
358
|
+
if (problem)
|
|
359
|
+
return refused('SIBLING_GIT_FAILED', `the origin of ${source} is not usable: ${problem}`, '--from');
|
|
360
|
+
remote = origin.text;
|
|
361
|
+
}
|
|
362
|
+
else if (input.remote !== undefined) {
|
|
363
|
+
mode = 'remote';
|
|
364
|
+
remote = input.remote;
|
|
365
|
+
}
|
|
366
|
+
else if (state !== 'directory') {
|
|
367
|
+
return refused('SIBLING_DIRECTORY_MISSING', `${target} does not exist. Create it first, or say where the repository comes from: --create (new, git init), --from <path> (an existing checkout elsewhere) or --remote <url>`, '--dir');
|
|
368
|
+
}
|
|
369
|
+
const acquire = remote === undefined ? 'none' : 'auto';
|
|
370
|
+
const row = { slug: slug.data, dir, ...(remote === undefined ? {} : { remote }), mount: mount.value, acquire, var: varName,
|
|
371
|
+
toolchain: 'none', ...(input.instructionPointer === true ? { instruction_pointer: true } : {}), purpose };
|
|
372
|
+
const original = await sourceText(configPath, loaded.result.source.sha256);
|
|
373
|
+
if (typeof original !== 'string')
|
|
374
|
+
return original;
|
|
375
|
+
const edit = appendSiblingTable(original, row, config.sibling.length);
|
|
376
|
+
const added = Sibling.parse(row);
|
|
377
|
+
const prepared = prepare(config, edit, [...config.sibling, added]);
|
|
378
|
+
if (isOutcome(prepared))
|
|
379
|
+
return prepared;
|
|
380
|
+
const stranded = await refuseWhileStackRecorded(loaded.result, prepared.expected, input.env);
|
|
381
|
+
if (stranded)
|
|
382
|
+
return stranded;
|
|
383
|
+
if (mode === 'create') {
|
|
384
|
+
if (git.state !== 'available')
|
|
385
|
+
return refused(ENVIRONMENT_CODES.RUNTIME_UNAVAILABLE, git.reason, 'git');
|
|
386
|
+
const initialised = await runGit(dependencies, gitStep(git.value, configDir, { PATH: input.env.PATH ?? '' }, 'sibling:init', ['init', target]));
|
|
387
|
+
if (initialised.kind === 'failed')
|
|
388
|
+
return refused('SIBLING_GIT_FAILED', `git init ${target} failed (${initialised.detail}); cockpit.toml was not changed`, '--create');
|
|
389
|
+
}
|
|
390
|
+
const failure = await commit(configPath, loaded.result.source.sha256, prepared, 'add');
|
|
391
|
+
if (failure) {
|
|
392
|
+
return mode === 'create'
|
|
393
|
+
? { ...failure, diagnostics: failure.diagnostics.map((item) => ({ ...item, message: `${item.message}. ${target} was initialised and is left in place` })) }
|
|
394
|
+
: failure;
|
|
395
|
+
}
|
|
396
|
+
const sources = {
|
|
397
|
+
dir: input.dir === undefined ? 'defaulted to the slug' : '--dir',
|
|
398
|
+
var: input.varName === undefined ? 'derived from the slug' : '--var',
|
|
399
|
+
mount: mount.source,
|
|
400
|
+
purpose: '--purpose',
|
|
401
|
+
};
|
|
402
|
+
// Creating a hosted repository is outward-facing, so cockpit only hands the commands over. The
|
|
403
|
+
// JSON result carries them too: an agent reading --json would otherwise never see them.
|
|
404
|
+
const publish = {
|
|
405
|
+
command: `gh repo create <owner>/${slug.data} --private --source ${JSON.stringify(target)} --remote origin`,
|
|
406
|
+
recordRemote: cockpitCommand('sibling add', { operand: slug.data, config: configPath, args: '--remote <url>' }),
|
|
407
|
+
};
|
|
408
|
+
const note = mode === 'create'
|
|
409
|
+
? `initialised ${target} and recorded sibling "${slug.data}" (acquire "none") in ${configPath}.\n`
|
|
410
|
+
+ `Creating a GitHub remote is outward-facing, so cockpit does not do it. To publish it yourself:\n`
|
|
411
|
+
+ ` ${publish.command}\n`
|
|
412
|
+
+ `then record the remote:\n ${publish.recordRemote}`
|
|
413
|
+
: mode === 'from'
|
|
414
|
+
? `recorded sibling "${slug.data}" with remote ${remote} read from the origin of ${source} (acquire "auto") in ${configPath}. The original checkout was not touched; \`${cockpitCommand('setup', { config: configPath })}\` clones a fresh copy to ${target}.`
|
|
415
|
+
: mode === 'remote'
|
|
416
|
+
? `recorded sibling "${slug.data}" with remote ${remote} (acquire "auto") in ${configPath}; \`${cockpitCommand('setup', { config: configPath })}\` clones it to ${target}.`
|
|
417
|
+
: `recorded the existing directory ${target} as sibling "${slug.data}" (acquire "none") in ${configPath}.`;
|
|
418
|
+
const mountNote = input.mount === undefined && mount.value === 'none'
|
|
419
|
+
? `\nIt is recorded as mount "none" because ${mount.source}. Once a service in ${config.environment.compose_files.join(', ')} bind-mounts \${${varName}}, `
|
|
420
|
+
+ 'change this row to mount = "always" so cockpit checks that mount and grants agents the checkout (pass --mount always when adding a sibling that is already mounted).'
|
|
421
|
+
: '';
|
|
422
|
+
return success(mode === 'create' ? 'created' : 'added', configPath, added, sources, `${note}${mountNote}`, { path: target,
|
|
423
|
+
...(source === undefined ? {} : { from: source }), ...(mode === 'create' ? { publish } : {}) });
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* A compose-first cockpit's own Compose files decide what is mounted, and cockpit cannot add a bind
|
|
427
|
+
* mount to them, so a new row there starts as "none": declaring "always" for a checkout no service
|
|
428
|
+
* mounts made `check` pass and every later `up`, `sleep` and `down` refuse the runtime topology.
|
|
429
|
+
*/
|
|
430
|
+
function siblingMount(requested, config) {
|
|
431
|
+
if (requested === undefined) {
|
|
432
|
+
return declaredRuntime(config.environment) === 'compose'
|
|
433
|
+
? { value: 'none', source: 'this is a compose-first cockpit, and its own Compose files must bind-mount a mount = "always" sibling' }
|
|
434
|
+
: { value: 'always', source: 'defaulted to always' };
|
|
435
|
+
}
|
|
436
|
+
if (requested === 'host') {
|
|
437
|
+
return usage('--mount host needs a host_reason, which sibling add does not record; add the sibling with --mount none, then set mount = "host" and host_reason in cockpit.toml by hand', '--mount');
|
|
438
|
+
}
|
|
439
|
+
const parsed = Mount.safeParse(requested);
|
|
440
|
+
if (!parsed.success)
|
|
441
|
+
return usage(`--mount ${JSON.stringify(requested)} must be always, none or profile:<name> (lowercase letters, digits and hyphens)`, '--mount');
|
|
442
|
+
return { value: parsed.data, source: '--mount' };
|
|
443
|
+
}
|
|
444
|
+
function success(action, configPath, row, sources, note, extra = {}) {
|
|
445
|
+
return { status: 0, diagnostics: [], destination: null, artifact: `${note}\nNext: ${cockpitCommand('sync', { config: configPath, args: '--dry-run' })}\n`,
|
|
446
|
+
result: { action, config: configPath, sibling: row, sources, ...extra, next: cockpitCommand('sync', { config: configPath, args: '--dry-run' }) } };
|
|
447
|
+
}
|
|
448
|
+
/** The text the edit is made on must be the text the configuration was parsed from. */
|
|
449
|
+
async function sourceText(path, sha256) {
|
|
450
|
+
const bytes = await readFile(path);
|
|
451
|
+
if (createHash('sha256').update(bytes).digest('hex') !== sha256) {
|
|
452
|
+
return refused(ENVIRONMENT_CODES.CONFIG_CHANGED_DURING_EXECUTION, 'cockpit.toml changed while it was being read; nothing was written. Re-run the command', path);
|
|
453
|
+
}
|
|
454
|
+
return bytes.toString('utf8');
|
|
455
|
+
}
|
|
456
|
+
const siblingVariable = (row) => new RegExp(`(?<![A-Z0-9_])${row.var}(?![A-Z0-9_])`);
|
|
457
|
+
/** Every place outside the row itself that names the sibling's exported variable. */
|
|
458
|
+
function referencesTo(config, index) {
|
|
459
|
+
const row = config.sibling[index];
|
|
460
|
+
const variable = siblingVariable(row);
|
|
461
|
+
const found = [];
|
|
462
|
+
const walk = (value, path) => {
|
|
463
|
+
if (typeof value === 'string') {
|
|
464
|
+
if (variable.test(value))
|
|
465
|
+
found.push({ where: path, detail: `mentions ${row.var}` });
|
|
466
|
+
}
|
|
467
|
+
else if (Array.isArray(value)) {
|
|
468
|
+
value.forEach((item, position) => walk(item, `${path}[${position}]`));
|
|
469
|
+
}
|
|
470
|
+
else if (value !== null && typeof value === 'object') {
|
|
471
|
+
for (const [key, item] of Object.entries(value))
|
|
472
|
+
walk(item, path === '' ? key : `${path}.${key}`);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
walk({ ...config, sibling: config.sibling.filter((_, position) => position !== index) }, '');
|
|
476
|
+
return found;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* A compose-first cockpit's own Compose files consume the sibling through its exported variable,
|
|
480
|
+
* which cockpit.toml alone cannot show; removing the row would leave Compose an unset variable.
|
|
481
|
+
*/
|
|
482
|
+
async function composeReferencesTo(configDir, config, row) {
|
|
483
|
+
const variable = siblingVariable(row);
|
|
484
|
+
const found = [];
|
|
485
|
+
for (const composeFile of config.environment.compose_files) {
|
|
486
|
+
const path = resolve(configDir, composeFile);
|
|
487
|
+
let text;
|
|
488
|
+
try {
|
|
489
|
+
const read = await readBoundedInput(path);
|
|
490
|
+
if (read.kind === 'too-large')
|
|
491
|
+
throw new RefusalError('it exceeds the structured input limit');
|
|
492
|
+
text = Buffer.from(read.bytes).toString('utf8');
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
return refused('COMPOSE_TOPOLOGY_UNAVAILABLE', `${path} could not be read (${errorCause(error)}), so cockpit cannot tell whether it still uses ${row.var}; nothing was written`, composeFile);
|
|
496
|
+
}
|
|
497
|
+
if (variable.test(text))
|
|
498
|
+
found.push({ where: composeFile, detail: `mentions ${row.var}` });
|
|
499
|
+
}
|
|
500
|
+
return found;
|
|
501
|
+
}
|
|
502
|
+
export async function runSiblingRemove(input) {
|
|
503
|
+
const loaded = await loadCockpitConfig(input.configPath);
|
|
504
|
+
if (loaded.kind !== 'complete')
|
|
505
|
+
return { status: 2, result: null, diagnostics: loaded.diagnostics };
|
|
506
|
+
const configPath = loaded.result.source.path;
|
|
507
|
+
const config = loaded.result.config;
|
|
508
|
+
const index = config.sibling.findIndex((sibling) => sibling.slug === input.slug);
|
|
509
|
+
if (index === -1) {
|
|
510
|
+
return refused('SIBLING_NOT_FOUND', `no sibling "${input.slug}" in ${configPath}; declared: ${config.sibling.map((sibling) => sibling.slug).join(', ')}`, input.slug);
|
|
511
|
+
}
|
|
512
|
+
const row = config.sibling[index];
|
|
513
|
+
const composeReferences = await composeReferencesTo(dirname(configPath), config, row);
|
|
514
|
+
if (!Array.isArray(composeReferences))
|
|
515
|
+
return composeReferences;
|
|
516
|
+
const references = [...referencesTo(config, index), ...composeReferences];
|
|
517
|
+
if (references.length > 0) {
|
|
518
|
+
return { ...refused('SIBLING_REFERENCED', `sibling "${row.slug}" is still referenced, so its table was not removed. Remove or repoint these first: ${references.map((reference) => `${reference.where} (${reference.detail})`).join('; ')}`, row.slug),
|
|
519
|
+
result: { slug: row.slug, references } };
|
|
520
|
+
}
|
|
521
|
+
const original = await sourceText(configPath, loaded.result.source.sha256);
|
|
522
|
+
if (typeof original !== 'string')
|
|
523
|
+
return original;
|
|
524
|
+
const edit = removeSiblingTable(original, index, config.sibling.length);
|
|
525
|
+
const prepared = prepare(config, edit, config.sibling.filter((_, position) => position !== index));
|
|
526
|
+
if (isOutcome(prepared))
|
|
527
|
+
return prepared;
|
|
528
|
+
const stranded = await refuseWhileStackRecorded(loaded.result, prepared.expected, input.env);
|
|
529
|
+
if (stranded)
|
|
530
|
+
return stranded;
|
|
531
|
+
const failure = await commit(configPath, loaded.result.source.sha256, prepared, 'remove');
|
|
532
|
+
if (failure)
|
|
533
|
+
return failure;
|
|
534
|
+
const message = `removed sibling "${row.slug}" from ${configPath}. The checkout at ${resolve(dirname(configPath), config.cockpit.siblings_root, row.dir)} and every generated file were left alone.\n`
|
|
535
|
+
+ `Comments above the removed table were kept. Run \`${cockpitCommand('sync', { config: configPath, args: '--dry-run' })}\` to see exactly what changes before syncing.\n`;
|
|
536
|
+
return { status: 0, diagnostics: [], destination: null, artifact: message,
|
|
537
|
+
result: { action: 'removed', config: configPath, slug: row.slug, dir: row.dir, next: cockpitCommand('sync', { config: configPath, args: '--dry-run' }) } };
|
|
538
|
+
}
|