@clossys/launcher 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1328 -60
- package/contracts/conversation-contract.md +2 -1
- package/contracts/product-ci-workflow.yml +74 -0
- package/contracts/repository-inventory.json +53 -0
- package/dist/admission-fixture.d.ts +168 -0
- package/dist/admission-fixture.d.ts.map +1 -0
- package/dist/admission-fixture.js +453 -0
- package/dist/admission-fixture.js.map +1 -0
- package/dist/admission.d.ts +124 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +799 -0
- package/dist/admission.js.map +1 -0
- package/dist/agents-guide.d.ts +9 -0
- package/dist/agents-guide.d.ts.map +1 -0
- package/dist/agents-guide.js +26 -0
- package/dist/agents-guide.js.map +1 -0
- package/dist/apply-command-options.check.d.ts +12 -0
- package/dist/apply-command-options.check.d.ts.map +1 -0
- package/dist/apply-command-options.check.js +20 -0
- package/dist/apply-command-options.check.js.map +1 -0
- package/dist/apply-plan-cli.d.ts +39 -1
- package/dist/apply-plan-cli.d.ts.map +1 -1
- package/dist/apply-plan-cli.js +432 -15
- package/dist/apply-plan-cli.js.map +1 -1
- package/dist/apply-plan.d.ts +46 -59
- package/dist/apply-plan.d.ts.map +1 -1
- package/dist/apply-plan.js +112 -97
- package/dist/apply-plan.js.map +1 -1
- package/dist/apply-step-fixture.d.ts +87 -0
- package/dist/apply-step-fixture.d.ts.map +1 -0
- package/dist/apply-step-fixture.js +199 -0
- package/dist/apply-step-fixture.js.map +1 -0
- package/dist/apply-store.d.ts +93 -0
- package/dist/apply-store.d.ts.map +1 -0
- package/dist/apply-store.js +625 -0
- package/dist/apply-store.js.map +1 -0
- package/dist/approval-sheet.d.ts +21 -0
- package/dist/approval-sheet.d.ts.map +1 -0
- package/dist/approval-sheet.js +157 -0
- package/dist/approval-sheet.js.map +1 -0
- package/dist/body-command.d.ts +42 -0
- package/dist/body-command.d.ts.map +1 -0
- package/dist/body-command.js +143 -0
- package/dist/body-command.js.map +1 -0
- package/dist/change-set-contract.d.ts +381 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +738 -0
- package/dist/change-set-contract.js.map +1 -0
- package/dist/change-set-digest.d.ts +28 -0
- package/dist/change-set-digest.d.ts.map +1 -0
- package/dist/change-set-digest.js +65 -0
- package/dist/change-set-digest.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +14 -3
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +17 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -23
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +79 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +843 -268
- package/dist/core.js.map +1 -1
- package/dist/dry-materialize.d.ts +63 -0
- package/dist/dry-materialize.d.ts.map +1 -0
- package/dist/dry-materialize.js +330 -0
- package/dist/dry-materialize.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/generated/package-scope.generated.d.ts +6 -0
- package/dist/generated/package-scope.generated.d.ts.map +1 -0
- package/dist/generated/package-scope.generated.js +10 -0
- package/dist/generated/package-scope.generated.js.map +1 -0
- package/dist/generated/plan-contracts.generated.d.ts +3 -0
- package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
- package/dist/generated/plan-contracts.generated.js +2840 -0
- package/dist/generated/plan-contracts.generated.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +11 -0
- package/dist/host.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +48 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +24 -5
- package/dist/inventory-adoption.d.ts.map +1 -1
- package/dist/inventory-adoption.js +70 -25
- package/dist/inventory-adoption.js.map +1 -1
- package/dist/inventory-choice.d.ts +40 -0
- package/dist/inventory-choice.d.ts.map +1 -0
- package/dist/inventory-choice.js +156 -0
- package/dist/inventory-choice.js.map +1 -0
- package/dist/inventory-contract.d.ts +89 -0
- package/dist/inventory-contract.d.ts.map +1 -0
- package/dist/inventory-contract.js +121 -0
- package/dist/inventory-contract.js.map +1 -0
- package/dist/key-editor.d.ts +30 -0
- package/dist/key-editor.d.ts.map +1 -0
- package/dist/key-editor.js +445 -0
- package/dist/key-editor.js.map +1 -0
- package/dist/ledger-contract.d.ts +187 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +532 -0
- package/dist/ledger-contract.js.map +1 -0
- package/dist/ledger-trust.d.ts +90 -0
- package/dist/ledger-trust.d.ts.map +1 -0
- package/dist/ledger-trust.js +198 -0
- package/dist/ledger-trust.js.map +1 -0
- package/dist/lockfile-invariants.d.ts +48 -0
- package/dist/lockfile-invariants.d.ts.map +1 -0
- package/dist/lockfile-invariants.js +375 -0
- package/dist/lockfile-invariants.js.map +1 -0
- package/dist/lockfile-readers.d.ts +72 -0
- package/dist/lockfile-readers.d.ts.map +1 -0
- package/dist/lockfile-readers.js +713 -0
- package/dist/lockfile-readers.js.map +1 -0
- package/dist/lockfile-regen.d.ts +106 -0
- package/dist/lockfile-regen.d.ts.map +1 -0
- package/dist/lockfile-regen.js +760 -0
- package/dist/lockfile-regen.js.map +1 -0
- package/dist/lockfile-tool-env.d.ts +29 -0
- package/dist/lockfile-tool-env.d.ts.map +1 -0
- package/dist/lockfile-tool-env.js +111 -0
- package/dist/lockfile-tool-env.js.map +1 -0
- package/dist/materialize.d.ts +113 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +840 -0
- package/dist/materialize.js.map +1 -0
- package/dist/observe-repository.d.ts +90 -0
- package/dist/observe-repository.d.ts.map +1 -0
- package/dist/observe-repository.js +1367 -0
- package/dist/observe-repository.js.map +1 -0
- package/dist/plan-bundle-setup-fixture.d.ts +68 -0
- package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
- package/dist/plan-bundle-setup-fixture.js +167 -0
- package/dist/plan-bundle-setup-fixture.js.map +1 -0
- package/dist/plan-bundle.d.ts +250 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +827 -0
- package/dist/plan-bundle.js.map +1 -0
- package/dist/plan-command.d.ts +29 -0
- package/dist/plan-command.d.ts.map +1 -0
- package/dist/plan-command.js +493 -0
- package/dist/plan-command.js.map +1 -0
- package/dist/plan-contract.d.ts +153 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +61 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +25 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +106 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +23 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +177 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/planned-bundle.d.ts +20 -0
- package/dist/planned-bundle.d.ts.map +1 -0
- package/dist/planned-bundle.js +191 -0
- package/dist/planned-bundle.js.map +1 -0
- package/dist/product-repository.d.ts +4 -0
- package/dist/product-repository.d.ts.map +1 -1
- package/dist/product-repository.js +9 -1
- package/dist/product-repository.js.map +1 -1
- package/dist/provenance-gate.d.ts +48 -0
- package/dist/provenance-gate.d.ts.map +1 -0
- package/dist/provenance-gate.js +324 -0
- package/dist/provenance-gate.js.map +1 -0
- package/dist/pull-request-body.d.ts +45 -0
- package/dist/pull-request-body.d.ts.map +1 -0
- package/dist/pull-request-body.js +232 -0
- package/dist/pull-request-body.js.map +1 -0
- package/dist/registry-snapshot.d.ts +141 -0
- package/dist/registry-snapshot.d.ts.map +1 -0
- package/dist/registry-snapshot.js +483 -0
- package/dist/registry-snapshot.js.map +1 -0
- package/dist/release-age-edit.d.ts +52 -0
- package/dist/release-age-edit.d.ts.map +1 -0
- package/dist/release-age-edit.js +413 -0
- package/dist/release-age-edit.js.map +1 -0
- package/dist/root-entries.d.ts +36 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +80 -0
- package/dist/root-entries.js.map +1 -0
- package/dist/setup-template-scripts.d.ts +34 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +557 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +54 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +427 -0
- package/dist/setup-templates.js.map +1 -0
- package/dist/skills.d.ts +34 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +129 -17
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +63 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +539 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +151 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -5
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +20 -16
- package/skill-catalogue/advisor/SKILL.md +100 -11
- package/skill-catalogue/architect/SKILL.md +2 -13
- package/skill-catalogue/bouncer/SKILL.md +2 -13
- package/skill-catalogue/builder/SKILL.md +2 -13
- package/skill-catalogue/butler/SKILL.md +2 -13
- package/skill-catalogue/controller/SKILL.md +2 -13
- package/skill-catalogue/customer/SKILL.md +4 -13
- package/skill-catalogue/designer/SKILL.md +9 -14
- package/skill-catalogue/giver/SKILL.md +2 -13
- package/skill-catalogue/influencer/SKILL.md +2 -13
- package/skill-catalogue/inspector/SKILL.md +2 -13
- package/skill-catalogue/integrator/SKILL.md +2 -13
- package/skill-catalogue/keeper/SKILL.md +2 -13
- package/skill-catalogue/launcher/SKILL.md +20 -16
- package/skill-catalogue/locksmith/SKILL.md +2 -13
- package/skill-catalogue/messenger/SKILL.md +2 -13
- package/skill-catalogue/observer/SKILL.md +2 -13
- package/skill-catalogue/publisher/SKILL.md +11 -17
- package/skill-catalogue/starter/SKILL.md +3 -13
- package/skill-catalogue/strategist/SKILL.md +14 -17
- package/skill-catalogue/writer/SKILL.md +7 -14
- package/src/admission-fixture.ts +572 -0
- package/src/admission.ts +816 -0
- package/src/agents-guide.ts +29 -0
- package/src/apply-command-options.check.ts +27 -0
- package/src/apply-plan-cli.ts +454 -14
- package/src/apply-plan.ts +112 -124
- package/src/apply-step-fixture.ts +236 -0
- package/src/apply-store.ts +584 -0
- package/src/approval-sheet.ts +164 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +937 -0
- package/src/change-set-digest.ts +70 -0
- package/src/check-cli.ts +14 -3
- package/src/cli.ts +90 -22
- package/src/core.ts +973 -275
- package/src/dry-materialize.ts +353 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/generated/package-scope.generated.ts +10 -0
- package/src/generated/plan-contracts.generated.ts +2840 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +72 -3
- package/src/inventory-adoption.ts +107 -29
- package/src/inventory-choice.ts +172 -0
- package/src/inventory-contract.ts +166 -0
- package/src/key-editor.ts +446 -0
- package/src/ledger-contract.ts +637 -0
- package/src/ledger-trust.ts +267 -0
- package/src/lockfile-invariants.ts +421 -0
- package/src/lockfile-readers.ts +749 -0
- package/src/lockfile-regen.ts +851 -0
- package/src/lockfile-tool-env.ts +131 -0
- package/src/materialize.ts +886 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +964 -0
- package/src/plan-command.ts +509 -0
- package/src/plan-contract.ts +179 -0
- package/src/plan-digest.ts +102 -0
- package/src/plan-rules.ts +188 -0
- package/src/planned-bundle.ts +211 -0
- package/src/product-repository.ts +10 -1
- package/src/provenance-gate.ts +352 -0
- package/src/pull-request-body.ts +261 -0
- package/src/registry-snapshot.ts +534 -0
- package/src/release-age-edit.ts +430 -0
- package/src/root-entries.ts +81 -0
- package/src/setup-template-scripts.ts +571 -0
- package/src/setup-templates.ts +471 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
- package/CHANGELOG.md +0 -131
package/src/core.ts
CHANGED
|
@@ -2,10 +2,14 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import type {
|
|
4
4
|
ApplyWorkspaceOptions,
|
|
5
|
+
ChosenInventory,
|
|
5
6
|
CommandResult,
|
|
6
7
|
CwdObservation,
|
|
7
8
|
DependencyBucket,
|
|
9
|
+
EngineInstallFinding,
|
|
10
|
+
EnginePinChange,
|
|
8
11
|
HubDocument,
|
|
12
|
+
HubEnginePin,
|
|
9
13
|
HubHealthReport,
|
|
10
14
|
InventoryObservation,
|
|
11
15
|
InventoryValidationEntry,
|
|
@@ -16,13 +20,18 @@ import type {
|
|
|
16
20
|
WorkspaceHost,
|
|
17
21
|
WorkspaceObservation,
|
|
18
22
|
WorkspacePlan,
|
|
23
|
+
WorkspacePlanAdopt,
|
|
19
24
|
WorkspacePlanCreate,
|
|
25
|
+
WorkspacePlanResume,
|
|
20
26
|
WorkspaceRefusal,
|
|
21
27
|
} from "./types.js";
|
|
22
28
|
import { composeSkills, SKILLS_MANIFEST_REL, type SkillCompositionResult } from "./skills.js";
|
|
23
29
|
import { parseSkillManifest, summarizeSkillsManifest } from "./manifest.js";
|
|
24
30
|
import { detectLinkedHosts, serializeHostRecord, HOSTS_REL, type DiscoveredHost } from "./hosts.js";
|
|
25
31
|
import { reportInventoryDrift } from "./inventory-adoption.js";
|
|
32
|
+
import { belongsToOwner, distinctOwners, sameOwner, sameRepository } from "./identity.js";
|
|
33
|
+
import { isValidInventoryId, readInventoryDocument, renderInventoryDocument, validateInventoryDocument, type InventoryEntry } from "./inventory-contract.js";
|
|
34
|
+
import { describeChosenInventory, resolveChosenInventory } from "./inventory-choice.js";
|
|
26
35
|
|
|
27
36
|
export const DEFAULT_REPOSITORY_NAME = "workspace";
|
|
28
37
|
/** The one visible, per-repository Clossys folder (#1171). Every role's output lives under it. */
|
|
@@ -37,7 +46,11 @@ export const LEGACY_STATE_DIR_REL = ".clossys";
|
|
|
37
46
|
export const LEGACY_WORKSPACE_MARKER_REL = join(LEGACY_STATE_DIR_REL, "workspace.json");
|
|
38
47
|
export const LEGACY_WORKSPACE_INVENTORY_REL = join(LEGACY_STATE_DIR_REL, "inventory.json");
|
|
39
48
|
export const ADVISOR_PACKAGE = "@clossys/advisor";
|
|
49
|
+
export const INTEGRATOR_PACKAGE = "@clossys/integrator";
|
|
40
50
|
export const LAUNCHER_PACKAGE = "@clossys/launcher";
|
|
51
|
+
/** The engines a hub pins: each exactly, once, in `devDependencies`, at its live registry version. */
|
|
52
|
+
export const HUB_ENGINE_PACKAGES = [ADVISOR_PACKAGE, INTEGRATOR_PACKAGE] as const;
|
|
53
|
+
type HubEnginePackage = (typeof HUB_ENGINE_PACKAGES)[number];
|
|
41
54
|
|
|
42
55
|
const DEPENDENCY_BUCKETS: readonly DependencyBucket[] = [
|
|
43
56
|
"dependencies",
|
|
@@ -61,6 +74,30 @@ export const CONSUMER_AGENTS_MD = `# Account workspace
|
|
|
61
74
|
|
|
62
75
|
This folder is the account hub for Foundry packages.
|
|
63
76
|
|
|
77
|
+
After \`npx @clossys/launcher\`, the \`@clossys-*\` team is composed in this
|
|
78
|
+
hub. Talk with \`@clossys-advisor\` and \`@clossys-<package>\` here. A launcher
|
|
79
|
+
run writes nothing into a product repository; once a repository is staffed
|
|
80
|
+
in an approved plan, it gets \`@clossys-advisor\` and the voices of the roles
|
|
81
|
+
staffed there, with that plan's setup pull request. A missing \`@\` mention
|
|
82
|
+
is a bug only here in the hub; in a product repository, a role that is not
|
|
83
|
+
staffed there is expected to be absent. \`@clossys-advisor\` is the hiring
|
|
84
|
+
check.
|
|
85
|
+
|
|
86
|
+
Run \`npx @clossys/launcher\` again for hub health and to refresh the voices
|
|
87
|
+
in this hub, not as how you talk to packages.
|
|
88
|
+
|
|
89
|
+
The person in this folder is a founder, not an engineer. Speak like a
|
|
90
|
+
person. Do not dump machine identifiers, JSON, hashes, or grant fields
|
|
91
|
+
unless they ask.
|
|
92
|
+
|
|
93
|
+
Advisor is read-only until the sponsor approves a next action.
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
/** The hub guidance written while a launcher run still composed the team into checkouts beside the hub; resume refreshes it. */
|
|
97
|
+
export const SIBLING_COMPOSING_CONSUMER_AGENTS_MD = `# Account workspace
|
|
98
|
+
|
|
99
|
+
This folder is the account hub for Foundry packages.
|
|
100
|
+
|
|
64
101
|
After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
|
|
65
102
|
every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
|
|
66
103
|
\`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
|
|
@@ -76,15 +113,6 @@ unless they ask.
|
|
|
76
113
|
Advisor is read-only until the sponsor approves a next action.
|
|
77
114
|
`;
|
|
78
115
|
|
|
79
|
-
/** Canned guidance for inventoried product checkouts (not the hub). */
|
|
80
|
-
export const SISTER_CONSUMER_AGENTS_MD = `# Product repository
|
|
81
|
-
|
|
82
|
-
This repository is part of the same account engagement. The same
|
|
83
|
-
\`@clossys-<package>\` team is here for intro and questions;
|
|
84
|
-
\`@clossys-advisor\` decides hiring and compatibility. This folder is not the
|
|
85
|
-
hub — engines are hired per repository, not dumped here.
|
|
86
|
-
`;
|
|
87
|
-
|
|
88
116
|
/** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
|
|
89
117
|
export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
|
|
90
118
|
|
|
@@ -195,7 +223,7 @@ export function isHubDocument(value: unknown): value is HubDocument {
|
|
|
195
223
|
const parsed = value.repository.includes("/")
|
|
196
224
|
? { owner: value.repository.split("/")[0], repository: value.repository.split("/")[1] }
|
|
197
225
|
: null;
|
|
198
|
-
if (!parsed?.owner || !parsed.repository || parsed.owner
|
|
226
|
+
if (!parsed?.owner || !parsed.repository || !sameOwner(parsed.owner, value.owner) || !REPO.test(parsed.repository)) return false;
|
|
199
227
|
return true;
|
|
200
228
|
}
|
|
201
229
|
|
|
@@ -215,8 +243,9 @@ function readHubAt(host: WorkspaceHost, path: string): HubDocument | undefined {
|
|
|
215
243
|
* migration (#1171). `clean`: only the current path has a marker. `legacy`:
|
|
216
244
|
* only the old path does; resume migrates it (see `migrateLegacyHubState`).
|
|
217
245
|
* `indeterminate`: both paths carry a parseable marker; launcher never
|
|
218
|
-
* merges them silently, so `planWorkspace` refuses instead
|
|
219
|
-
*
|
|
246
|
+
* merges them silently, so `planWorkspace` refuses instead (and, for a hub
|
|
247
|
+
* cloned from an empty directory, `classifyClonedHub` at apply time).
|
|
248
|
+
* `none`: neither path has one.
|
|
220
249
|
*/
|
|
221
250
|
function locateHub(
|
|
222
251
|
host: WorkspaceHost,
|
|
@@ -234,19 +263,26 @@ function readHub(host: WorkspaceHost, directory: string): HubDocument | undefine
|
|
|
234
263
|
return locateHub(host, directory).document;
|
|
235
264
|
}
|
|
236
265
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
266
|
+
/**
|
|
267
|
+
* The inventory document's shape lives in docs/contracts/repository-inventory.json
|
|
268
|
+
* (in the public repository; that exact path does not ship in this
|
|
269
|
+
* package, but this package's build packs and ships its own copy of the
|
|
270
|
+
* contract) and is checked, on every read and write, by
|
|
271
|
+
* `validateInventoryDocument()` through the shared contract checker
|
|
272
|
+
* (./inventory-contract.ts, #1334, #1179).
|
|
273
|
+
*/
|
|
274
|
+
export { validateInventoryDocument } from "./inventory-contract.js";
|
|
275
|
+
export type { InventoryValidation } from "./inventory-contract.js";
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Classifies a generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship) without inventing repositories. Malformed input is "invalid", never silently folded into "empty" (#1334).
|
|
279
|
+
* Pass the file's exact bytes (`WorkspaceHost.readBytes()`), and the hub's owner when it is known, so a bare id and `<owner>/<id>` count as one repository (#1179).
|
|
280
|
+
*/
|
|
281
|
+
export function inspectInventory(raw: string | Uint8Array | null, hubOwner?: string): InventoryObservation {
|
|
239
282
|
if (raw === null) return { status: "missing", count: 0 };
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return { status: "empty", count: 0 };
|
|
244
|
-
}
|
|
245
|
-
const count = parsed.repositories.length;
|
|
246
|
-
return { status: count > 0 ? "populated" : "empty", count };
|
|
247
|
-
} catch {
|
|
248
|
-
return { status: "empty", count: 0 };
|
|
249
|
-
}
|
|
283
|
+
const validated = validateInventoryDocument(raw, hubOwner === undefined ? {} : { hubOwner });
|
|
284
|
+
if (!validated.valid) return { status: "invalid", count: 0, reason: validated.reason };
|
|
285
|
+
return { status: validated.ids.length > 0 ? "populated" : "empty", count: validated.ids.length };
|
|
250
286
|
}
|
|
251
287
|
|
|
252
288
|
function looksLikeFoundry(host: WorkspaceHost, directory: string): boolean {
|
|
@@ -284,21 +320,37 @@ function commandAvailable(host: WorkspaceHost, command: string): boolean {
|
|
|
284
320
|
/** True when the tree already pins Advisor in some bucket, so adopt would not need a new version. */
|
|
285
321
|
export function hasAdvisorPin(manifest: unknown): boolean {
|
|
286
322
|
if (!isRecord(manifest)) return false;
|
|
287
|
-
return DEPENDENCY_BUCKETS.some((bucket) =>
|
|
323
|
+
return DEPENDENCY_BUCKETS.some((bucket) => enginePinsIn(manifest[bucket], new Set())[ADVISOR_PACKAGE] !== undefined);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The one place Launcher learns a package's live version: the public npm
|
|
328
|
+
* registry (`npm view <name> version`). A failed or unparseable read is
|
|
329
|
+
* `undefined`, never a guess.
|
|
330
|
+
*/
|
|
331
|
+
function readRegistryVersion(host: WorkspaceHost, name: string): string | undefined {
|
|
332
|
+
const viewed = host.run("npm", ["view", name, "version"]);
|
|
333
|
+
const version = viewed.stdout.trim();
|
|
334
|
+
return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
|
|
288
335
|
}
|
|
289
336
|
|
|
290
|
-
/** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
|
|
291
337
|
/**
|
|
292
338
|
* Reads the public `@clossys/launcher` registry version, used only to grade
|
|
293
339
|
* catalogue-sourced skill staleness in the health report (#1183). A missing
|
|
294
340
|
* or unparseable read leaves staleness ungraded rather than refusing.
|
|
295
341
|
*/
|
|
296
342
|
export function readLiveLauncherVersion(host: WorkspaceHost): string | undefined {
|
|
297
|
-
|
|
298
|
-
const version = viewed.stdout.trim();
|
|
299
|
-
return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
|
|
343
|
+
return readRegistryVersion(host, LAUNCHER_PACKAGE);
|
|
300
344
|
}
|
|
301
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Collects GitHub owner, cwd shape, default-hub presence, and the public
|
|
348
|
+
* Advisor and Integrator versions. Default-hub presence means only that
|
|
349
|
+
* `{owner}/workspace` exists on GitHub; its contents (marker, legacy marker,
|
|
350
|
+
* or none) are not observed here but classified after the clone, at apply
|
|
351
|
+
* time (#1585).
|
|
352
|
+
*/
|
|
353
|
+
|
|
302
354
|
export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
|
|
303
355
|
const cwd = host.cwd;
|
|
304
356
|
const ghAvailable = commandAvailable(host, "gh");
|
|
@@ -315,37 +367,38 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
|
|
|
315
367
|
githubRepository = parsed.repository;
|
|
316
368
|
}
|
|
317
369
|
}
|
|
318
|
-
|
|
370
|
+
// Owners are compared as GitHub compares them (identity.ts): two spellings of one account are one candidate.
|
|
371
|
+
const seenOwners: string[] = [];
|
|
319
372
|
const envOwnerRaw = host.env.CLOSSYS_OWNER?.trim();
|
|
320
373
|
const envOwner = envOwnerRaw && OWNER.test(envOwnerRaw) ? envOwnerRaw : undefined;
|
|
321
374
|
if (ghAvailable) {
|
|
322
375
|
const user = host.run("gh", ["api", "user", "--jq", ".login"]);
|
|
323
376
|
const login = user.stdout.trim();
|
|
324
|
-
if (user.status === 0 && OWNER.test(login))
|
|
377
|
+
if (user.status === 0 && OWNER.test(login)) seenOwners.push(login);
|
|
325
378
|
for (const org of stdoutLines(host.run("gh", ["org", "list"]))) {
|
|
326
|
-
if (OWNER.test(org))
|
|
379
|
+
if (OWNER.test(org)) seenOwners.push(org);
|
|
327
380
|
}
|
|
328
381
|
}
|
|
329
|
-
if (githubOwner)
|
|
382
|
+
if (githubOwner) seenOwners.push(githubOwner);
|
|
383
|
+
const candidates = new Set(distinctOwners(seenOwners));
|
|
330
384
|
|
|
331
385
|
let remoteDefaultHub: WorkspaceObservation["remoteDefaultHub"];
|
|
332
|
-
let advisorVersion: string | undefined;
|
|
333
386
|
const ownerGuess = envOwner ?? (candidates.size === 1 ? [...candidates][0] : githubOwner);
|
|
334
387
|
if (ghAvailable && ownerGuess) {
|
|
335
388
|
const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
|
|
336
389
|
if (viewed.status === 0) remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
|
|
337
390
|
}
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
|
|
391
|
+
const advisorVersion = readRegistryVersion(host, ADVISOR_PACKAGE);
|
|
392
|
+
const integratorVersion = readRegistryVersion(host, INTEGRATOR_PACKAGE);
|
|
341
393
|
|
|
342
394
|
const hubLocation = locateHub(host, cwd);
|
|
343
395
|
// While only the legacy `.clossys/` marker exists, its sibling inventory is
|
|
344
396
|
// the one resume will migrate; read from there so planning sees it too.
|
|
345
|
-
const
|
|
397
|
+
const inventoryBytes =
|
|
346
398
|
hubLocation.migration === "legacy"
|
|
347
|
-
? host.
|
|
348
|
-
: host.
|
|
399
|
+
? host.readBytes(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
|
|
400
|
+
: host.readBytes(join(cwd, WORKSPACE_INVENTORY_REL));
|
|
401
|
+
const inventoryOwner = hubLocation.document?.owner ?? githubOwner;
|
|
349
402
|
|
|
350
403
|
const cwdObservation: CwdObservation = {
|
|
351
404
|
absolutePath: cwd,
|
|
@@ -356,7 +409,7 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
|
|
|
356
409
|
...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
|
|
357
410
|
...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
|
|
358
411
|
looksLikeFoundry: looksLikeFoundry(host, cwd),
|
|
359
|
-
inventory: inspectInventory(
|
|
412
|
+
inventory: inspectInventory(inventoryBytes, inventoryOwner),
|
|
360
413
|
};
|
|
361
414
|
|
|
362
415
|
return {
|
|
@@ -365,6 +418,7 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
|
|
|
365
418
|
...(envOwner === undefined ? {} : { envOwner }),
|
|
366
419
|
...(remoteDefaultHub === undefined ? {} : { remoteDefaultHub }),
|
|
367
420
|
...(advisorVersion === undefined ? {} : { advisorVersion }),
|
|
421
|
+
...(integratorVersion === undefined ? {} : { integratorVersion }),
|
|
368
422
|
ghAvailable,
|
|
369
423
|
gitAvailable,
|
|
370
424
|
};
|
|
@@ -393,19 +447,38 @@ function resolveOwner(observation: WorkspaceObservation, host: WorkspaceHost): {
|
|
|
393
447
|
* Decides create, resume, or adopt from a cwd observation.
|
|
394
448
|
* Appointing means: run this from the GitHub repository that should own the hub.
|
|
395
449
|
*/
|
|
396
|
-
|
|
397
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Reads repository ids from a `schemaVersion: 1` inventory document, routed
|
|
452
|
+
* through `validateInventoryDocument` -- the same schema check `--inventory`
|
|
453
|
+
* and `inspectInventory` apply, so a caller here can never end up trusting a
|
|
454
|
+
* document neither of those would have accepted (#1334). Throws, naming the
|
|
455
|
+
* offending field, on anything present but invalid; a missing file is `[]`,
|
|
456
|
+
* not a throw -- an absent inventory is a fact about the hub, not a
|
|
457
|
+
* malformed one. The file is read as bytes; `hubOwner`, when given, makes a
|
|
458
|
+
* bare id and `<hubOwner>/<id>` one repository, so a document listing both
|
|
459
|
+
* is refused.
|
|
460
|
+
*/
|
|
461
|
+
export function readInventoryRepositories(host: WorkspaceHost, source: string, label: string, hubOwner?: string): readonly string[] {
|
|
462
|
+
const raw = host.readBytes(source);
|
|
398
463
|
if (raw === null) return [];
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
464
|
+
const validated = validateInventoryDocument(raw, hubOwner === undefined ? {} : { hubOwner });
|
|
465
|
+
if (!validated.valid) throw new Error(`${label} ${validated.reason}`);
|
|
466
|
+
return validated.ids;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* How a founder gives Launcher the repositories a hub covers (#1179): they
|
|
471
|
+
* choose them on Advisor's repository-choice card, and `--repositories`
|
|
472
|
+
* writes the inventory. Named by every refusal that needs an inventory,
|
|
473
|
+
* instead of `--inventory <path>`, which still works but asks for a
|
|
474
|
+
* document a founder will not write.
|
|
475
|
+
*/
|
|
476
|
+
function chooseRepositoriesHint(extraFlag = ""): string {
|
|
477
|
+
return (
|
|
478
|
+
"choose the repositories this hub covers on Advisor's repository card " +
|
|
479
|
+
"(`npx -p @clossys/advisor advisor-repository-card`), built from the repositories GitHub lists for your sign-in, then run " +
|
|
480
|
+
`\`launcher --repositories <owner/name>[,<owner/name>...]${extraFlag}\`, and Launcher writes the inventory for you`
|
|
481
|
+
);
|
|
409
482
|
}
|
|
410
483
|
|
|
411
484
|
/**
|
|
@@ -420,48 +493,135 @@ function resolveAdoptInventory(
|
|
|
420
493
|
host: WorkspaceHost,
|
|
421
494
|
cwd: CwdObservation,
|
|
422
495
|
inventoryPath: string | undefined,
|
|
423
|
-
):
|
|
496
|
+
):
|
|
497
|
+
| {
|
|
498
|
+
inventorySource?: string;
|
|
499
|
+
mergedInventoryIds?: readonly string[];
|
|
500
|
+
mergedInventoryRepositories?: readonly InventoryEntry[];
|
|
501
|
+
mergedInventoryDocument?: string;
|
|
502
|
+
replacesInvalidInventory?: boolean;
|
|
503
|
+
}
|
|
504
|
+
| WorkspaceRefusal {
|
|
424
505
|
const trimmed = inventoryPath?.trim();
|
|
425
506
|
const onDiskPopulated = cwd.inventory?.status === "populated";
|
|
426
507
|
if (!onDiskPopulated && !trimmed) {
|
|
508
|
+
if (cwd.inventory?.status === "invalid") {
|
|
509
|
+
return refuse(
|
|
510
|
+
"violated",
|
|
511
|
+
`the on-disk hub inventory ${cwd.inventory.reason ?? "does not conform to the inventory contract"} -- to replace it, ${chooseRepositoriesHint(" --replace-inventory")}`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
427
514
|
return refuse(
|
|
428
515
|
"violated",
|
|
429
|
-
|
|
516
|
+
`appointing needs the repositories this hub covers, and it has no inventory yet: ${chooseRepositoriesHint()}`,
|
|
430
517
|
);
|
|
431
518
|
}
|
|
432
519
|
if (!trimmed) return {};
|
|
433
520
|
const resolved = resolve(cwd.absolutePath, trimmed);
|
|
434
|
-
const
|
|
435
|
-
if (
|
|
521
|
+
const importedRaw = host.readBytes(resolved);
|
|
522
|
+
if (importedRaw === null) {
|
|
523
|
+
return refuse("violated", `--inventory does not point at a readable file: ${resolved}`);
|
|
524
|
+
}
|
|
525
|
+
const owner = cwd.githubOwner === undefined ? {} : { hubOwner: cwd.githubOwner };
|
|
526
|
+
const imported = readInventoryDocument(importedRaw, owner);
|
|
527
|
+
if (!imported.valid) {
|
|
528
|
+
return refuse("violated", `--inventory at ${resolved} ${imported.reason}`);
|
|
529
|
+
}
|
|
530
|
+
if (imported.ids.length === 0) {
|
|
436
531
|
return refuse(
|
|
437
532
|
"violated",
|
|
438
|
-
|
|
533
|
+
`--inventory at ${resolved} must be a populated inventory document (nonempty repositories)`,
|
|
439
534
|
);
|
|
440
535
|
}
|
|
441
|
-
if (!onDiskPopulated)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
seen.add(id);
|
|
447
|
-
merged.push(id);
|
|
448
|
-
}
|
|
536
|
+
if (!onDiskPopulated) {
|
|
537
|
+
return {
|
|
538
|
+
inventorySource: resolved,
|
|
539
|
+
...(cwd.inventory?.status === "invalid" ? { replacesInvalidInventory: true } : {}),
|
|
540
|
+
};
|
|
449
541
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
542
|
+
const onDiskRaw = host.readBytes(join(cwd.absolutePath, WORKSPACE_INVENTORY_REL));
|
|
543
|
+
const onDisk = onDiskRaw === null ? undefined : readInventoryDocument(onDiskRaw, owner);
|
|
544
|
+
if (onDisk !== undefined && !onDisk.valid) {
|
|
545
|
+
return refuse("violated", `the on-disk hub inventory ${onDisk.reason}`);
|
|
455
546
|
}
|
|
456
|
-
|
|
547
|
+
const onDiskEntries: readonly InventoryEntry[] = onDisk !== undefined && onDisk.valid ? onDisk.entries : [];
|
|
548
|
+
// Merge whole entries, not ids: an entry's `packages` travels with it.
|
|
549
|
+
// Entries are keyed by Launcher's one repository identity (identity.ts),
|
|
550
|
+
// the same rule validateInventoryDocument() applies to ids, so the merge
|
|
551
|
+
// can never write a document the validator then refuses; the first
|
|
552
|
+
// occurrence -- the on-disk spelling and entry -- wins (#1334, #1179).
|
|
553
|
+
const merged: InventoryEntry[] = [];
|
|
554
|
+
for (const entry of [...onDiskEntries, ...imported.entries]) {
|
|
555
|
+
if (!merged.some((kept) => sameRepository(kept.id, entry.id, cwd.githubOwner))) merged.push(entry);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
mergedInventoryIds: merged.map((entry) => entry.id),
|
|
559
|
+
mergedInventoryRepositories: merged,
|
|
560
|
+
mergedInventoryDocument: renderInventoryDocument(merged),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Resolves `--repositories` against the inventory stored in `directory`, or
|
|
566
|
+
* returns a refusal. See `resolveChosenInventory()`.
|
|
567
|
+
*/
|
|
568
|
+
function resolveChosenRepositories(
|
|
569
|
+
host: WorkspaceHost,
|
|
570
|
+
directory: string,
|
|
571
|
+
owner: string,
|
|
572
|
+
repositories: readonly string[],
|
|
573
|
+
replaceInventory: boolean,
|
|
574
|
+
): { chosenInventory: ChosenInventory } | WorkspaceRefusal {
|
|
575
|
+
const resolution = resolveChosenInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), repositories, owner, replaceInventory);
|
|
576
|
+
if (resolution.kind === "refuse") return refuse("violated", resolution.message);
|
|
577
|
+
return { chosenInventory: resolution.chosen };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export interface PlanWorkspaceOptions {
|
|
581
|
+
/** `--inventory <path>`: a prepared inventory document, appoint only. */
|
|
582
|
+
readonly inventoryPath?: string;
|
|
583
|
+
/** `--repositories`: the repository ids a founder chose on Advisor's repository card (#1179). Appoint or resume of a hub checkout. */
|
|
584
|
+
readonly repositories?: readonly string[];
|
|
585
|
+
/** `--replace-inventory`: explicit approval for `repositories` to replace a stored inventory that lists a different set, or one that fails its contract. */
|
|
586
|
+
readonly replaceInventory?: boolean;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The hub engine versions a plan carries, only those the observation read. */
|
|
590
|
+
function observedEngineVersions(observation: WorkspaceObservation): { advisorVersion?: string; integratorVersion?: string } {
|
|
591
|
+
return {
|
|
592
|
+
...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
|
|
593
|
+
...(observation.integratorVersion === undefined ? {} : { integratorVersion: observation.integratorVersion }),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Create and appoint pin both hub engines, so each needs its live version:
|
|
599
|
+
* the refusal names the first one the registry did not answer for.
|
|
600
|
+
*/
|
|
601
|
+
function requireEngineVersions(
|
|
602
|
+
observation: WorkspaceObservation,
|
|
603
|
+
): { advisorVersion: string; integratorVersion: string } | WorkspaceRefusal {
|
|
604
|
+
if (!observation.advisorVersion) {
|
|
605
|
+
return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
|
|
606
|
+
}
|
|
607
|
+
if (!observation.integratorVersion) {
|
|
608
|
+
return refuse("indeterminate", `cannot read a public ${INTEGRATOR_PACKAGE} version from the npm registry`);
|
|
609
|
+
}
|
|
610
|
+
return { advisorVersion: observation.advisorVersion, integratorVersion: observation.integratorVersion };
|
|
457
611
|
}
|
|
458
612
|
|
|
459
613
|
export function planWorkspace(
|
|
460
614
|
observation: WorkspaceObservation,
|
|
461
615
|
host: WorkspaceHost,
|
|
462
|
-
options:
|
|
616
|
+
options: PlanWorkspaceOptions = {},
|
|
463
617
|
): WorkspaceDecision {
|
|
464
618
|
const { cwd } = observation;
|
|
619
|
+
if (options.repositories !== undefined && options.inventoryPath !== undefined) {
|
|
620
|
+
return refuse("violated", "--repositories and --inventory each supply the whole inventory; use one, not both");
|
|
621
|
+
}
|
|
622
|
+
if (options.replaceInventory === true && options.repositories === undefined) {
|
|
623
|
+
return refuse("violated", "--replace-inventory approves replacing the inventory with --repositories, and means nothing without it");
|
|
624
|
+
}
|
|
465
625
|
if (cwd.hubMigration === "indeterminate") {
|
|
466
626
|
return refuse(
|
|
467
627
|
"indeterminate",
|
|
@@ -475,7 +635,7 @@ export function planWorkspace(
|
|
|
475
635
|
cwd.hub === undefined &&
|
|
476
636
|
cwd.git &&
|
|
477
637
|
cwd.githubOwner !== undefined &&
|
|
478
|
-
cwd.githubOwner
|
|
638
|
+
!sameOwner(cwd.githubOwner, envOwner)
|
|
479
639
|
) {
|
|
480
640
|
return refuse(
|
|
481
641
|
"violated",
|
|
@@ -489,19 +649,43 @@ export function planWorkspace(
|
|
|
489
649
|
);
|
|
490
650
|
}
|
|
491
651
|
if (cwd.hub) {
|
|
652
|
+
let chosen: { chosenInventory: ChosenInventory } | undefined;
|
|
653
|
+
if (options.repositories !== undefined) {
|
|
654
|
+
if (cwd.hubMigration === "legacy") {
|
|
655
|
+
return refuse(
|
|
656
|
+
"violated",
|
|
657
|
+
"this hub's state is still in the legacy .clossys/ folder; run launcher once without --repositories to move it to clossys/.state/, then choose the repositories again",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
const resolved = resolveChosenRepositories(host, cwd.absolutePath, cwd.hub.owner, options.repositories, options.replaceInventory === true);
|
|
661
|
+
if ("action" in resolved) return resolved;
|
|
662
|
+
chosen = resolved;
|
|
663
|
+
}
|
|
492
664
|
return {
|
|
493
665
|
action: "resume",
|
|
494
666
|
owner: cwd.hub.owner,
|
|
495
667
|
repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
|
|
496
668
|
directory: cwd.absolutePath,
|
|
497
669
|
clone: false,
|
|
498
|
-
...(observation
|
|
670
|
+
...observedEngineVersions(observation),
|
|
499
671
|
...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" as const } : {}),
|
|
672
|
+
...(chosen ?? {}),
|
|
500
673
|
};
|
|
501
674
|
}
|
|
502
675
|
if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
|
|
503
|
-
|
|
504
|
-
|
|
676
|
+
const engines = requireEngineVersions(observation);
|
|
677
|
+
if ("action" in engines) return engines;
|
|
678
|
+
if (options.repositories !== undefined) {
|
|
679
|
+
const resolved = resolveChosenRepositories(host, cwd.absolutePath, cwd.githubOwner, options.repositories, options.replaceInventory === true);
|
|
680
|
+
if ("action" in resolved) return resolved;
|
|
681
|
+
return {
|
|
682
|
+
action: "adopt",
|
|
683
|
+
owner: cwd.githubOwner,
|
|
684
|
+
repository: cwd.githubRepository,
|
|
685
|
+
directory: cwd.absolutePath,
|
|
686
|
+
...engines,
|
|
687
|
+
chosenInventory: resolved.chosenInventory,
|
|
688
|
+
};
|
|
505
689
|
}
|
|
506
690
|
const imported = resolveAdoptInventory(host, cwd, options.inventoryPath);
|
|
507
691
|
if ("action" in imported) return imported;
|
|
@@ -510,9 +694,12 @@ export function planWorkspace(
|
|
|
510
694
|
owner: cwd.githubOwner,
|
|
511
695
|
repository: cwd.githubRepository,
|
|
512
696
|
directory: cwd.absolutePath,
|
|
513
|
-
|
|
697
|
+
...engines,
|
|
514
698
|
...(imported.inventorySource === undefined ? {} : { inventorySource: imported.inventorySource }),
|
|
515
699
|
...(imported.mergedInventoryIds === undefined ? {} : { mergedInventoryIds: imported.mergedInventoryIds }),
|
|
700
|
+
...(imported.mergedInventoryRepositories === undefined ? {} : { mergedInventoryRepositories: imported.mergedInventoryRepositories }),
|
|
701
|
+
...(imported.mergedInventoryDocument === undefined ? {} : { mergedInventoryDocument: imported.mergedInventoryDocument }),
|
|
702
|
+
...(imported.replacesInvalidInventory === undefined ? {} : { replacesInvalidInventory: imported.replacesInvalidInventory }),
|
|
516
703
|
};
|
|
517
704
|
}
|
|
518
705
|
if (cwd.git) {
|
|
@@ -524,30 +711,38 @@ export function planWorkspace(
|
|
|
524
711
|
"the current directory is not empty and is not a GitHub repository; run from the repo you want to appoint, or from an empty directory",
|
|
525
712
|
);
|
|
526
713
|
}
|
|
714
|
+
if (options.repositories !== undefined) {
|
|
715
|
+
return refuse(
|
|
716
|
+
"violated",
|
|
717
|
+
"--repositories writes the inventory of a hub checkout, and this directory is empty; run launcher here first to create or clone the hub, then choose its repositories from inside it",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
527
720
|
const ownerResult = resolveOwner(observation, host);
|
|
528
721
|
if ("action" in ownerResult) return ownerResult;
|
|
529
|
-
if (observation.remoteDefaultHub && observation.remoteDefaultHub.owner
|
|
722
|
+
if (observation.remoteDefaultHub && sameOwner(observation.remoteDefaultHub.owner, ownerResult.owner)) {
|
|
723
|
+
// A clone plan: whether `{owner}/workspace` is already a hub (marked, legacy-marked) or an existing
|
|
724
|
+
// repository to appoint (unmarked) is not known until it is cloned, so applyWorkspacePlan classifies
|
|
725
|
+
// it then (classifyClonedHub, #1585) and may take the adopt path instead of resuming.
|
|
530
726
|
return {
|
|
531
727
|
action: "resume",
|
|
532
728
|
owner: observation.remoteDefaultHub.owner,
|
|
533
729
|
repository: observation.remoteDefaultHub.repository,
|
|
534
730
|
directory: cwd.absolutePath,
|
|
535
731
|
clone: true,
|
|
536
|
-
...(observation
|
|
732
|
+
...observedEngineVersions(observation),
|
|
537
733
|
};
|
|
538
734
|
}
|
|
539
735
|
if (!observation.ghAvailable) {
|
|
540
736
|
return refuse("indeterminate", "`gh` is required to create a GitHub repository for a new hub");
|
|
541
737
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}
|
|
738
|
+
const engines = requireEngineVersions(observation);
|
|
739
|
+
if ("action" in engines) return engines;
|
|
545
740
|
return {
|
|
546
741
|
action: "create",
|
|
547
742
|
owner: ownerResult.owner,
|
|
548
743
|
repository: DEFAULT_REPOSITORY_NAME,
|
|
549
744
|
directory: cwd.absolutePath,
|
|
550
|
-
|
|
745
|
+
...engines,
|
|
551
746
|
};
|
|
552
747
|
}
|
|
553
748
|
|
|
@@ -560,11 +755,15 @@ function containedPath(root: string, relativePath: string): string {
|
|
|
560
755
|
return resolved;
|
|
561
756
|
}
|
|
562
757
|
|
|
563
|
-
function substitute(
|
|
758
|
+
function substitute(
|
|
759
|
+
contents: string,
|
|
760
|
+
plan: { owner: string; repository: string; advisorVersion?: string; integratorVersion?: string },
|
|
761
|
+
): string {
|
|
564
762
|
return contents
|
|
565
763
|
.replaceAll("__OWNER__", plan.owner)
|
|
566
764
|
.replaceAll("__REPOSITORY_NAME__", plan.repository)
|
|
567
|
-
.replaceAll("__ADVISOR_VERSION__", plan.advisorVersion ?? "0.0.0")
|
|
765
|
+
.replaceAll("__ADVISOR_VERSION__", plan.advisorVersion ?? "0.0.0")
|
|
766
|
+
.replaceAll("__INTEGRATOR_VERSION__", plan.integratorVersion ?? "0.0.0");
|
|
568
767
|
}
|
|
569
768
|
|
|
570
769
|
function writeSkeletonFile(host: WorkspaceHost, directory: string, relativePath: string, contents: string): void {
|
|
@@ -573,69 +772,242 @@ function writeSkeletonFile(host: WorkspaceHost, directory: string, relativePath:
|
|
|
573
772
|
host.writeText(target, contents);
|
|
574
773
|
}
|
|
575
774
|
|
|
775
|
+
/** Writes exact bytes, for a document copied rather than composed, so nothing is decoded and re-encoded on the way. */
|
|
776
|
+
function writeSkeletonBytes(host: WorkspaceHost, directory: string, relativePath: string, contents: Uint8Array): void {
|
|
777
|
+
const target = containedPath(directory, relativePath);
|
|
778
|
+
host.mkdirp(dirname(target));
|
|
779
|
+
host.writeBytes(target, contents);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function withFinalNewline(bytes: Uint8Array): Uint8Array {
|
|
783
|
+
if (bytes.length > 0 && bytes[bytes.length - 1] === 0x0a) return bytes;
|
|
784
|
+
const out = new Uint8Array(bytes.length + 1);
|
|
785
|
+
out.set(bytes);
|
|
786
|
+
out[bytes.length] = 0x0a;
|
|
787
|
+
return out;
|
|
788
|
+
}
|
|
789
|
+
|
|
576
790
|
function pinString(value: unknown): string | undefined {
|
|
577
791
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
578
792
|
}
|
|
579
793
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
794
|
+
/**
|
|
795
|
+
* The hub engine pins in one dependency bucket, by engine. Every other
|
|
796
|
+
* `@clossys/*` name found there is added to `extra`.
|
|
797
|
+
*/
|
|
798
|
+
function enginePinsIn(bucket: unknown, extra: Set<string>): Partial<Record<HubEnginePackage, string>> {
|
|
799
|
+
const pins: Partial<Record<HubEnginePackage, string>> = {};
|
|
800
|
+
if (!isRecord(bucket)) return pins;
|
|
583
801
|
for (const [name, version] of Object.entries(bucket)) {
|
|
584
|
-
|
|
585
|
-
|
|
802
|
+
const engine = HUB_ENGINE_PACKAGES.find((candidate) => candidate === name);
|
|
803
|
+
if (engine !== undefined) {
|
|
804
|
+
const pinned = pinString(version);
|
|
805
|
+
if (pinned !== undefined) pins[engine] = pinned;
|
|
586
806
|
continue;
|
|
587
807
|
}
|
|
588
808
|
if (name.startsWith("@clossys/")) extra.add(name);
|
|
589
809
|
}
|
|
590
|
-
return
|
|
810
|
+
return pins;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** The live engine versions to pin; an engine whose version is unknown is left as the manifest has it. */
|
|
814
|
+
interface HubEngineVersions {
|
|
815
|
+
readonly advisorVersion?: string;
|
|
816
|
+
readonly integratorVersion?: string;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function engineVersionEntries(versions: HubEngineVersions): readonly (readonly [HubEnginePackage, string])[] {
|
|
820
|
+
const entries: (readonly [HubEnginePackage, string])[] = [];
|
|
821
|
+
if (versions.advisorVersion !== undefined) entries.push([ADVISOR_PACKAGE, versions.advisorVersion]);
|
|
822
|
+
if (versions.integratorVersion !== undefined) entries.push([INTEGRATOR_PACKAGE, versions.integratorVersion]);
|
|
823
|
+
return entries;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** A `package.json` body parsed to a JSON object, or `undefined` when it is unparseable, an array, or a primitive. */
|
|
827
|
+
function parseManifestObject(raw: string): Record<string, unknown> | undefined {
|
|
828
|
+
try {
|
|
829
|
+
const parsed: unknown = JSON.parse(raw);
|
|
830
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
831
|
+
} catch {
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The manifest refusals of `mergeHubEnginePins` in "appoint" mode, raised by
|
|
838
|
+
* `adoptHubFiles` before its first write so a refusal leaves the checkout
|
|
839
|
+
* untouched: a `package.json` that is a symbolic link (live or dangling, never
|
|
840
|
+
* resolved, so the write cannot land outside the checkout), a present one that
|
|
841
|
+
* is unreadable or not a JSON object, or a missing one with no skeleton
|
|
842
|
+
* manifest to write in its place.
|
|
843
|
+
*/
|
|
844
|
+
function assertManifestAppointable(host: WorkspaceHost, directory: string, skeletonRoot: string): void {
|
|
845
|
+
const manifestPath = join(directory, "package.json");
|
|
846
|
+
if (host.isSymlink(manifestPath)) {
|
|
847
|
+
throw new Error("existing package.json is a symbolic link; replace it with a regular file before appointing");
|
|
848
|
+
}
|
|
849
|
+
const raw = host.readText(manifestPath);
|
|
850
|
+
if (raw === null) {
|
|
851
|
+
// readText collapses every read error to null, so tell "absent" from
|
|
852
|
+
// "present but unreadable" (a directory, no read permission).
|
|
853
|
+
if (host.exists(manifestPath)) {
|
|
854
|
+
throw new Error("existing package.json is present but cannot be read (is it a directory, or unreadable?)");
|
|
855
|
+
}
|
|
856
|
+
if (host.readText(join(skeletonRoot, "package.json")) === null) throw new Error("missing skeleton package.json");
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (parseManifestObject(raw) === undefined) throw new Error("existing package.json is unreadable JSON");
|
|
591
860
|
}
|
|
592
861
|
|
|
593
862
|
/**
|
|
594
|
-
* Pins
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
863
|
+
* Pins each hub engine (Advisor and Integrator) exactly, in `devDependencies`
|
|
864
|
+
* only, and returns what it changed. It only raises a pin: one older than
|
|
865
|
+
* live, or not a plain version (a range, a tag), becomes the live version;
|
|
866
|
+
* one newer than live is kept as it is. A pin left in any other bucket is
|
|
867
|
+
* moved to `devDependencies`. Other `@clossys/*` names are not touched. The
|
|
868
|
+
* manifest is written as 2-space-indented JSON with a final LF.
|
|
869
|
+
*
|
|
870
|
+
* `appoint` writes the packed skeleton manifest when the hub has none,
|
|
871
|
+
* refuses a manifest that is not a JSON object, names a dedicated
|
|
872
|
+
* `{owner}/workspace` hub `@owner/workspace` only when its manifest has no
|
|
873
|
+
* own `name` (an existing name is never overwritten, #1585), and rewrites
|
|
874
|
+
* the manifest.
|
|
875
|
+
* `resume` changes only the engine pins (never the `name`), writes the
|
|
876
|
+
* manifest only when a pin changed, and leaves a missing or unreadable
|
|
877
|
+
* manifest as it is (the health report then shows the engine pins as
|
|
878
|
+
* missing).
|
|
598
879
|
*/
|
|
599
|
-
function
|
|
880
|
+
function mergeHubEnginePins(
|
|
600
881
|
host: WorkspaceHost,
|
|
601
882
|
directory: string,
|
|
602
883
|
skeletonRoot: string,
|
|
603
|
-
|
|
884
|
+
versions: HubEngineVersions,
|
|
604
885
|
owner: string,
|
|
605
886
|
repository: string,
|
|
606
|
-
|
|
887
|
+
mode: "appoint" | "resume",
|
|
888
|
+
): readonly EnginePinChange[] {
|
|
607
889
|
const path = join(directory, "package.json");
|
|
608
890
|
const raw = host.readText(path);
|
|
609
891
|
if (raw === null) {
|
|
892
|
+
if (mode === "resume") return [];
|
|
610
893
|
const skeleton = host.readText(join(skeletonRoot, "package.json"));
|
|
611
894
|
if (skeleton === null) throw new Error("missing skeleton package.json");
|
|
612
|
-
writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository,
|
|
613
|
-
return;
|
|
895
|
+
writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, ...versions }));
|
|
896
|
+
return engineVersionEntries(versions).map(([engine, version]) => ({ package: engine, to: version }));
|
|
614
897
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (!isRecord(parsed)) throw new Error("package.json is not an object");
|
|
619
|
-
manifest = parsed;
|
|
620
|
-
} catch {
|
|
898
|
+
const manifest = parseManifestObject(raw);
|
|
899
|
+
if (manifest === undefined) {
|
|
900
|
+
if (mode === "resume") return [];
|
|
621
901
|
throw new Error("existing package.json is unreadable JSON");
|
|
622
902
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
903
|
+
const changes: EnginePinChange[] = [];
|
|
904
|
+
for (const [engine, live] of engineVersionEntries(versions)) {
|
|
905
|
+
const devPin = isRecord(manifest.devDependencies) ? pinString(manifest.devDependencies[engine]) : undefined;
|
|
906
|
+
let movedFrom: DependencyBucket | undefined;
|
|
907
|
+
let movedPin: string | undefined;
|
|
908
|
+
for (const bucket of DEPENDENCY_BUCKETS) {
|
|
909
|
+
if (bucket === "devDependencies") continue;
|
|
910
|
+
const current = manifest[bucket];
|
|
911
|
+
if (!isRecord(current) || !(engine in current)) continue;
|
|
912
|
+
if (movedFrom === undefined) {
|
|
913
|
+
movedFrom = bucket;
|
|
914
|
+
movedPin = pinString(current[engine]);
|
|
915
|
+
}
|
|
916
|
+
const next = { ...current };
|
|
917
|
+
delete next[engine];
|
|
918
|
+
if (Object.keys(next).length === 0) delete manifest[bucket];
|
|
919
|
+
else manifest[bucket] = next;
|
|
920
|
+
}
|
|
921
|
+
const existing = devPin ?? movedPin;
|
|
922
|
+
// Only raise: a pin newer than live stays, as the health grader treats it as current.
|
|
923
|
+
const to = existing !== undefined && compareVersions(existing, live) === 1 ? existing : live;
|
|
924
|
+
const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
|
|
925
|
+
devDependencies[engine] = to;
|
|
926
|
+
manifest.devDependencies = devDependencies;
|
|
927
|
+
if (to !== devPin || movedFrom !== undefined) {
|
|
928
|
+
changes.push({
|
|
929
|
+
package: engine,
|
|
930
|
+
...(existing === undefined ? {} : { from: existing }),
|
|
931
|
+
to,
|
|
932
|
+
...(devPin === undefined && movedFrom !== undefined ? { movedFrom } : {}),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// Only a nameless manifest is named: an existing `name` is the project's own and is never overwritten (#1585).
|
|
937
|
+
if (mode === "appoint" && repository === DEFAULT_REPOSITORY_NAME && !Object.hasOwn(manifest, "name")) {
|
|
636
938
|
manifest.name = `@${owner}/${repository}`;
|
|
637
939
|
}
|
|
940
|
+
if (mode === "resume" && changes.length === 0) return changes;
|
|
638
941
|
host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
942
|
+
return changes;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Lockfiles Launcher recognises in a hub, with the install command that updates each. */
|
|
946
|
+
const HUB_LOCKFILES: readonly (readonly [string, string])[] = [
|
|
947
|
+
// npm reads npm-shrinkwrap.json instead of package-lock.json when both exist.
|
|
948
|
+
["npm-shrinkwrap.json", "npm install"],
|
|
949
|
+
["package-lock.json", "npm install"],
|
|
950
|
+
["pnpm-lock.yaml", "pnpm install"],
|
|
951
|
+
["yarn.lock", "yarn install"],
|
|
952
|
+
["bun.lock", "bun install"],
|
|
953
|
+
["bun.lockb", "bun install"],
|
|
954
|
+
];
|
|
955
|
+
|
|
956
|
+
function describeEnginePinChange(change: EnginePinChange): string {
|
|
957
|
+
const moved = change.movedFrom === undefined ? "" : ` (moved from ${change.movedFrom} to devDependencies)`;
|
|
958
|
+
if (change.from === undefined) return `${change.package} added at ${change.to}${moved}`;
|
|
959
|
+
if (change.from === change.to) return `${change.package} ${change.to}${moved}`;
|
|
960
|
+
return `${change.package} ${change.from} -> ${change.to}${moved}`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** The one next step after a run changed engine pins: install with the hub's package manager, then commit the manifest with its lockfile. */
|
|
964
|
+
function enginePinNextStep(host: WorkspaceHost, directory: string): string {
|
|
965
|
+
const lockfile = HUB_LOCKFILES.find(([name]) => host.exists(join(directory, name)));
|
|
966
|
+
return lockfile === undefined
|
|
967
|
+
? "run your package manager's install in the hub, then commit package.json together with the lockfile it writes"
|
|
968
|
+
: `run \`${lockfile[1]}\` in the hub, then commit package.json together with ${lockfile[0]}`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Whether the hub's lockfile resolves its engine pins. An npm lockfile is
|
|
973
|
+
* read: each engine pinned to a plain version in `devDependencies` must
|
|
974
|
+
* resolve to that version. Any other lockfile is not read, so it counts as
|
|
975
|
+
* not resolving the engines this run changed. No lockfile, no finding.
|
|
976
|
+
*/
|
|
977
|
+
function engineInstallFinding(
|
|
978
|
+
host: WorkspaceHost,
|
|
979
|
+
directory: string,
|
|
980
|
+
changes: readonly EnginePinChange[],
|
|
981
|
+
): EngineInstallFinding | undefined {
|
|
982
|
+
const found = HUB_LOCKFILES.find(([name]) => host.exists(join(directory, name)));
|
|
983
|
+
if (found === undefined) return undefined;
|
|
984
|
+
const [lockfile, command] = found;
|
|
985
|
+
const changed = [...new Set(changes.map((change) => change.package))];
|
|
986
|
+
let packages: readonly string[] = changed;
|
|
987
|
+
if (lockfile === "package-lock.json" || lockfile === "npm-shrinkwrap.json") {
|
|
988
|
+
const lock = readJson(host, join(directory, lockfile));
|
|
989
|
+
const manifest = readJson(host, join(directory, "package.json"));
|
|
990
|
+
const devDependencies = isRecord(manifest) && isRecord(manifest.devDependencies) ? manifest.devDependencies : {};
|
|
991
|
+
if (isRecord(lock)) {
|
|
992
|
+
packages = HUB_ENGINE_PACKAGES.filter((engine) => {
|
|
993
|
+
const pinned = pinString(devDependencies[engine]);
|
|
994
|
+
if (pinned === undefined || compareVersions(pinned, pinned) === null) return false;
|
|
995
|
+
const lockPackages = isRecord(lock.packages) ? lock.packages : {};
|
|
996
|
+
const lockDependencies = isRecord(lock.dependencies) ? lock.dependencies : {};
|
|
997
|
+
const entry = lockPackages[`node_modules/${engine}`] ?? lockDependencies[engine];
|
|
998
|
+
const locked = isRecord(entry) ? pinString(entry.version) : undefined;
|
|
999
|
+
return locked === undefined || compareVersions(locked, pinned) !== 0;
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (packages.length === 0) return undefined;
|
|
1004
|
+
return {
|
|
1005
|
+
kind: "engine-pins-changed-install-needed",
|
|
1006
|
+
lockfile,
|
|
1007
|
+
command,
|
|
1008
|
+
packages,
|
|
1009
|
+
note: `${lockfile} does not resolve the pinned ${packages.join(" and ")} yet; run \`${command}\` in the hub, then commit package.json together with ${lockfile}`,
|
|
1010
|
+
};
|
|
639
1011
|
}
|
|
640
1012
|
|
|
641
1013
|
function copySkeleton(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlanCreate): void {
|
|
@@ -670,8 +1042,52 @@ function assertCleanTree(host: WorkspaceHost, directory: string): void {
|
|
|
670
1042
|
}
|
|
671
1043
|
}
|
|
672
1044
|
|
|
673
|
-
|
|
1045
|
+
/**
|
|
1046
|
+
* An inventory document Launcher composed (from a choice, or a merge),
|
|
1047
|
+
* checked again at write time by the same function every later read of it
|
|
1048
|
+
* uses, so a document Launcher writes is always one Launcher reads back.
|
|
1049
|
+
*/
|
|
1050
|
+
function revalidatedDocument(document: string, hubOwner: string, label = "the chosen inventory"): string {
|
|
1051
|
+
const validated = validateInventoryDocument(document, { hubOwner });
|
|
1052
|
+
if (!validated.valid) throw new Error(`${label} ${validated.reason}`);
|
|
1053
|
+
return document;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlanAdopt): readonly EnginePinChange[] {
|
|
674
1057
|
assertCleanTree(host, plan.directory);
|
|
1058
|
+
// Resolve and strictly re-validate the inventory document BEFORE writing
|
|
1059
|
+
// anything, including the hub marker -- planWorkspace already validated it
|
|
1060
|
+
// once, but re-checking here (rather than trusting the earlier result)
|
|
1061
|
+
// means a document that changed on disk between plan and apply still
|
|
1062
|
+
// cannot land a mismatched shape, and it means this function alone
|
|
1063
|
+
// guarantees "fail before any file is touched" (#1334).
|
|
1064
|
+
let inventoryDocument: string | Uint8Array | undefined;
|
|
1065
|
+
if (plan.action === "adopt" && plan.chosenInventory !== undefined) {
|
|
1066
|
+
if (plan.chosenInventory.kind === "write") inventoryDocument = revalidatedDocument(plan.chosenInventory.document, plan.owner);
|
|
1067
|
+
} else if (plan.action === "adopt" && plan.mergedInventoryDocument !== undefined) {
|
|
1068
|
+
inventoryDocument = revalidatedDocument(plan.mergedInventoryDocument, plan.owner, "the merged inventory");
|
|
1069
|
+
} else if (plan.action === "adopt" && plan.mergedInventoryRepositories !== undefined) {
|
|
1070
|
+
// A plan carrying the merged entries without the rendered document: each entry is written whole, `packages`
|
|
1071
|
+
// included, in the same layout renderInventoryDocument() writes; the contract check decides whether it is valid.
|
|
1072
|
+
const repositories = plan.mergedInventoryRepositories;
|
|
1073
|
+
inventoryDocument = revalidatedDocument(`${JSON.stringify({ schemaVersion: 1, repositories }, null, 2)}\n`, plan.owner, "the merged inventory");
|
|
1074
|
+
} else if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
|
|
1075
|
+
// A plan built by hand with ids only: there are no entries to keep, so each id is written alone.
|
|
1076
|
+
inventoryDocument = revalidatedDocument(renderInventoryDocument(plan.mergedInventoryIds.map((id) => ({ id }))), plan.owner, "the merged inventory");
|
|
1077
|
+
} else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
|
|
1078
|
+
const raw = host.readBytes(plan.inventorySource);
|
|
1079
|
+
if (raw === null) throw new Error(`inventory source is not readable: ${plan.inventorySource}`);
|
|
1080
|
+
const validated = validateInventoryDocument(raw, { hubOwner: plan.owner });
|
|
1081
|
+
if (!validated.valid) throw new Error(`inventory source at ${plan.inventorySource} ${validated.reason}`);
|
|
1082
|
+
if (validated.ids.length === 0) {
|
|
1083
|
+
throw new Error(`inventory source at ${plan.inventorySource} must be a populated inventory document`);
|
|
1084
|
+
}
|
|
1085
|
+
// Copied byte for byte: the bytes just validated are the bytes written.
|
|
1086
|
+
inventoryDocument = withFinalNewline(raw);
|
|
1087
|
+
}
|
|
1088
|
+
// The manifest is read and checked here too, for the same reason: mergeHubEnginePins refuses it only after
|
|
1089
|
+
// the marker and the composed files are written, which would leave a half-appointed checkout.
|
|
1090
|
+
assertManifestAppointable(host, plan.directory, skeletonRoot);
|
|
675
1091
|
const marker = {
|
|
676
1092
|
schemaVersion: 1,
|
|
677
1093
|
kind: "account-hub",
|
|
@@ -679,21 +1095,8 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
|
|
|
679
1095
|
repository: `${plan.owner}/${plan.repository}`,
|
|
680
1096
|
};
|
|
681
1097
|
writeSkeletonFile(host, plan.directory, WORKSPACE_MARKER_REL, `${JSON.stringify(marker, null, 2)}\n`);
|
|
682
|
-
if ("
|
|
683
|
-
|
|
684
|
-
writeSkeletonFile(
|
|
685
|
-
host,
|
|
686
|
-
plan.directory,
|
|
687
|
-
WORKSPACE_INVENTORY_REL,
|
|
688
|
-
`${JSON.stringify(document, null, 2)}\n`,
|
|
689
|
-
);
|
|
690
|
-
} else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
|
|
691
|
-
const raw = host.readText(plan.inventorySource);
|
|
692
|
-
if (raw === null || inspectInventory(raw).status !== "populated") {
|
|
693
|
-
throw new Error("inventory source is not a populated inventory document");
|
|
694
|
-
}
|
|
695
|
-
writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, raw.endsWith("\n") ? raw : `${raw}\n`);
|
|
696
|
-
}
|
|
1098
|
+
if (typeof inventoryDocument === "string") writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, inventoryDocument);
|
|
1099
|
+
else if (inventoryDocument !== undefined) writeSkeletonBytes(host, plan.directory, WORKSPACE_INVENTORY_REL, inventoryDocument);
|
|
697
1100
|
if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
|
|
698
1101
|
writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
|
|
699
1102
|
} else {
|
|
@@ -710,7 +1113,15 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
|
|
|
710
1113
|
const ignore = host.readText(join(skeletonRoot, ".gitignore"));
|
|
711
1114
|
if (ignore !== null) writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
|
|
712
1115
|
}
|
|
713
|
-
|
|
1116
|
+
return mergeHubEnginePins(
|
|
1117
|
+
host,
|
|
1118
|
+
plan.directory,
|
|
1119
|
+
skeletonRoot,
|
|
1120
|
+
{ advisorVersion: plan.advisorVersion, integratorVersion: plan.integratorVersion },
|
|
1121
|
+
plan.owner,
|
|
1122
|
+
plan.repository,
|
|
1123
|
+
"appoint",
|
|
1124
|
+
);
|
|
714
1125
|
}
|
|
715
1126
|
|
|
716
1127
|
function requireZero(result: CommandResult, label: string): void {
|
|
@@ -721,9 +1132,11 @@ function requireZero(result: CommandResult, label: string): void {
|
|
|
721
1132
|
|
|
722
1133
|
/**
|
|
723
1134
|
* Read-only pin and inventory report. Does not install or uninstall. Scans all
|
|
724
|
-
* four dependency buckets
|
|
725
|
-
*
|
|
726
|
-
*
|
|
1135
|
+
* four dependency buckets for both hub engines (Advisor and Integrator);
|
|
1136
|
+
* grades each pinned version against that engine's live registry version,
|
|
1137
|
+
* when known, marking a pin older than live as a stale-pin finding and a
|
|
1138
|
+
* degraded report. `liveIntegratorVersion` is the last parameter so earlier
|
|
1139
|
+
* callers keep their argument positions.
|
|
727
1140
|
*/
|
|
728
1141
|
export function reportHubHealth(
|
|
729
1142
|
host: WorkspaceHost,
|
|
@@ -732,78 +1145,123 @@ export function reportHubHealth(
|
|
|
732
1145
|
liveLauncherVersion?: string,
|
|
733
1146
|
retiredThisRun: readonly string[] = [],
|
|
734
1147
|
migration?: HubHealthReport["migration"],
|
|
1148
|
+
liveIntegratorVersion?: string,
|
|
735
1149
|
): HubHealthReport {
|
|
736
1150
|
const extra = new Set<string>();
|
|
737
|
-
const pins: Partial<Record<DependencyBucket, string
|
|
1151
|
+
const pins: Record<HubEnginePackage, Partial<Record<DependencyBucket, string>>> = {
|
|
1152
|
+
[ADVISOR_PACKAGE]: {},
|
|
1153
|
+
[INTEGRATOR_PACKAGE]: {},
|
|
1154
|
+
};
|
|
738
1155
|
const manifestRaw = host.readText(join(directory, "package.json"));
|
|
739
1156
|
if (manifestRaw !== null) {
|
|
740
1157
|
try {
|
|
741
1158
|
const parsed: unknown = JSON.parse(manifestRaw);
|
|
742
1159
|
if (isRecord(parsed)) {
|
|
743
1160
|
for (const bucket of DEPENDENCY_BUCKETS) {
|
|
744
|
-
const
|
|
745
|
-
|
|
1161
|
+
const found = enginePinsIn(parsed[bucket], extra);
|
|
1162
|
+
for (const engine of HUB_ENGINE_PACKAGES) {
|
|
1163
|
+
const pinned = found[engine];
|
|
1164
|
+
if (pinned !== undefined) pins[engine][bucket] = pinned;
|
|
1165
|
+
}
|
|
746
1166
|
}
|
|
747
1167
|
}
|
|
748
1168
|
} catch {
|
|
749
1169
|
/* unreadable manifest is reported as missing pins */
|
|
750
1170
|
}
|
|
751
1171
|
}
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1172
|
+
const live: Record<HubEnginePackage, string | undefined> = {
|
|
1173
|
+
[ADVISOR_PACKAGE]: liveAdvisorVersion,
|
|
1174
|
+
[INTEGRATOR_PACKAGE]: liveIntegratorVersion,
|
|
1175
|
+
};
|
|
1176
|
+
const pinFindings: PinFinding[] = HUB_ENGINE_PACKAGES.flatMap((engine) =>
|
|
1177
|
+
DEPENDENCY_BUCKETS.flatMap((bucket): PinFinding[] => {
|
|
1178
|
+
const pinned = pins[engine][bucket];
|
|
1179
|
+
const liveVersion = live[engine];
|
|
1180
|
+
if (pinned === undefined || liveVersion === undefined) return [];
|
|
1181
|
+
const comparison = compareVersions(pinned, liveVersion);
|
|
1182
|
+
if (comparison === null) {
|
|
1183
|
+
return [{ package: engine, bucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveVersion}` }];
|
|
1184
|
+
}
|
|
1185
|
+
return comparison < 0
|
|
1186
|
+
? [{ package: engine, bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveVersion}` }]
|
|
1187
|
+
: [];
|
|
1188
|
+
}),
|
|
1189
|
+
);
|
|
1190
|
+
const pinCount = (engine: HubEnginePackage): number => Object.values(pins[engine]).filter((value) => value !== undefined).length;
|
|
1191
|
+
const misplaced = (engine: HubEnginePackage): boolean =>
|
|
1192
|
+
pins[engine].devDependencies === undefined ||
|
|
1193
|
+
pins[engine].dependencies !== undefined ||
|
|
1194
|
+
pins[engine].optionalDependencies !== undefined ||
|
|
1195
|
+
pins[engine].peerDependencies !== undefined;
|
|
1196
|
+
const dualPin = HUB_ENGINE_PACKAGES.some((engine) => pinCount(engine) > 1);
|
|
762
1197
|
const skillsManifest = summarizeSkillsManifest(
|
|
763
1198
|
parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))),
|
|
764
1199
|
liveLauncherVersion,
|
|
765
1200
|
retiredThisRun,
|
|
766
1201
|
);
|
|
1202
|
+
const enginePin = (engine: HubEnginePackage): HubEnginePin => {
|
|
1203
|
+
const found = pins[engine];
|
|
1204
|
+
const liveVersion = live[engine];
|
|
1205
|
+
return {
|
|
1206
|
+
...(found.dependencies === undefined ? {} : { dependencies: found.dependencies }),
|
|
1207
|
+
...(found.devDependencies === undefined ? {} : { devDependencies: found.devDependencies }),
|
|
1208
|
+
...(found.optionalDependencies === undefined ? {} : { optionalDependencies: found.optionalDependencies }),
|
|
1209
|
+
...(found.peerDependencies === undefined ? {} : { peerDependencies: found.peerDependencies }),
|
|
1210
|
+
...(liveVersion === undefined ? {} : { live: liveVersion }),
|
|
1211
|
+
};
|
|
1212
|
+
};
|
|
1213
|
+
const inventory = inspectInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), readHub(host, directory)?.owner);
|
|
767
1214
|
return {
|
|
768
1215
|
marker: readHub(host, directory) === undefined ? "missing" : "present",
|
|
769
|
-
inventory
|
|
770
|
-
advisorPin:
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
...(pins.optionalDependencies === undefined ? {} : { optionalDependencies: pins.optionalDependencies }),
|
|
774
|
-
...(pins.peerDependencies === undefined ? {} : { peerDependencies: pins.peerDependencies }),
|
|
775
|
-
...(liveAdvisorVersion === undefined ? {} : { live: liveAdvisorVersion }),
|
|
776
|
-
},
|
|
777
|
-
dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
|
|
1216
|
+
inventory,
|
|
1217
|
+
advisorPin: enginePin(ADVISOR_PACKAGE),
|
|
1218
|
+
integratorPin: enginePin(INTEGRATOR_PACKAGE),
|
|
1219
|
+
dualPin,
|
|
778
1220
|
extraClossys: [...extra].sort(),
|
|
779
1221
|
pinFindings,
|
|
780
1222
|
degraded:
|
|
781
1223
|
pinFindings.some((finding) => finding.grade === "stale") ||
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
pins.optionalDependencies !== undefined ||
|
|
786
|
-
pins.peerDependencies !== undefined,
|
|
1224
|
+
dualPin ||
|
|
1225
|
+
HUB_ENGINE_PACKAGES.some(misplaced) ||
|
|
1226
|
+
inventory.status === "invalid",
|
|
787
1227
|
...(migration === undefined ? {} : { migration }),
|
|
788
1228
|
skillsManifest,
|
|
789
1229
|
};
|
|
790
1230
|
}
|
|
791
1231
|
|
|
792
|
-
|
|
1232
|
+
function formatEnginePin(pin: HubEnginePin): string {
|
|
793
1233
|
const pinParts: string[] = [];
|
|
794
1234
|
for (const bucket of DEPENDENCY_BUCKETS) {
|
|
795
|
-
const pinned =
|
|
1235
|
+
const pinned = pin[bucket];
|
|
796
1236
|
if (pinned !== undefined) pinParts.push(`${bucket} ${pinned}`);
|
|
797
1237
|
}
|
|
798
|
-
const
|
|
799
|
-
|
|
1238
|
+
const pinned = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
|
|
1239
|
+
return `${pinned}${pin.live === undefined ? "" : `; live ${pin.live}`}`;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
export function formatHubHealth(report: HubHealthReport): string {
|
|
800
1243
|
const extra = report.extraClossys.length === 0 ? "none" : report.extraClossys.join(", ");
|
|
801
1244
|
const inventory =
|
|
802
|
-
report.inventory.status === "populated"
|
|
1245
|
+
report.inventory.status === "populated"
|
|
1246
|
+
? `populated (${report.inventory.count})`
|
|
1247
|
+
: report.inventory.status === "invalid"
|
|
1248
|
+
? `invalid${report.inventory.reason === undefined ? "" : ` -- ${report.inventory.reason}`}`
|
|
1249
|
+
: report.inventory.status;
|
|
803
1250
|
const findings = report.pinFindings.map((finding) =>
|
|
804
|
-
finding.note !== undefined
|
|
1251
|
+
finding.note !== undefined
|
|
1252
|
+
? `${finding.package} ${finding.bucket} ${finding.note}`
|
|
1253
|
+
: `${finding.package} ${finding.bucket} ${finding.grade}`,
|
|
805
1254
|
);
|
|
806
1255
|
const findingLine = findings.length === 0 ? "none" : findings.join("; ");
|
|
1256
|
+
const enginePinLines =
|
|
1257
|
+
report.enginePins === undefined
|
|
1258
|
+
? []
|
|
1259
|
+
: [
|
|
1260
|
+
`engine pins changed in package.json: ${report.enginePins.changed.map(describeEnginePinChange).join("; ")}`,
|
|
1261
|
+
`next: ${report.enginePins.nextStep}`,
|
|
1262
|
+
];
|
|
1263
|
+
const installLine =
|
|
1264
|
+
report.installNeeded === undefined ? [] : [`install needed (${report.installNeeded.kind}): ${report.installNeeded.note}`];
|
|
807
1265
|
const skillParts: string[] = [];
|
|
808
1266
|
if (report.skillComposition !== undefined) {
|
|
809
1267
|
skillParts.push(
|
|
@@ -812,17 +1270,28 @@ export function formatHubHealth(report: HubHealthReport): string {
|
|
|
812
1270
|
: `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`,
|
|
813
1271
|
);
|
|
814
1272
|
for (const skip of report.skillComposition.skipped) {
|
|
1273
|
+
// skip.packageDir names one of this package's own known package directories, never document text (see skills.ts).
|
|
815
1274
|
skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
|
|
816
1275
|
}
|
|
1276
|
+
// rosterTargets and siblings' inventoryId are already position-safe by
|
|
1277
|
+
// the time they reach here: composeSkillRoster redacts every
|
|
1278
|
+
// stored-inventory id to `inventoryPositionLabel()`'s position label
|
|
1279
|
+
// before putting it in this report, because this whole report is also
|
|
1280
|
+
// JSON-dumped into the `health:` line below -- an id left raw in the
|
|
1281
|
+
// structured report would still reach the message that way even if this
|
|
1282
|
+
// prose line named it safely.
|
|
817
1283
|
if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
|
|
818
1284
|
skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
|
|
819
1285
|
}
|
|
820
|
-
for (const
|
|
821
|
-
skillParts.push(`
|
|
1286
|
+
for (const sibling of report.skillComposition.siblings ?? []) {
|
|
1287
|
+
skillParts.push(`sibling (${sibling.inventoryId}): ${sibling.note}`);
|
|
822
1288
|
}
|
|
823
1289
|
if (report.skillComposition.retired !== undefined && report.skillComposition.retired.length > 0) {
|
|
824
1290
|
skillParts.push(`skills retired: ${report.skillComposition.retired.map((name) => `clossys-${name}`).join(", ")}`);
|
|
825
1291
|
}
|
|
1292
|
+
for (const kept of report.skillComposition.preserved ?? []) {
|
|
1293
|
+
skillParts.push(`skill preserved (clossys-${kept.packageDir}, not ${kept.action === "rewrite" ? "rewritten" : "retired"}): ${kept.note}`);
|
|
1294
|
+
}
|
|
826
1295
|
}
|
|
827
1296
|
const skillsManifestLine =
|
|
828
1297
|
report.skillsManifest === undefined
|
|
@@ -841,14 +1310,17 @@ export function formatHubHealth(report: HubHealthReport): string {
|
|
|
841
1310
|
? undefined
|
|
842
1311
|
: report.inventoryDrift.status === "indeterminate"
|
|
843
1312
|
? `inventory drift: indeterminate${report.inventoryDrift.note === undefined ? "" : ` -- ${report.inventoryDrift.note}`}`
|
|
844
|
-
: `inventory drift: external-only ${report.inventoryDrift.externalOnly.
|
|
1313
|
+
: `inventory drift: external-only ${report.inventoryDrift.externalOnly.count}, launcher-only ${report.inventoryDrift.launcherOnly.count}, agreeing ${report.inventoryDrift.agreeing.count}`;
|
|
845
1314
|
return [
|
|
846
1315
|
`hub marker: ${report.marker}`,
|
|
847
1316
|
`inventory: ${inventory}`,
|
|
848
|
-
`advisor pin: ${
|
|
1317
|
+
`advisor pin: ${formatEnginePin(report.advisorPin)}`,
|
|
1318
|
+
`integrator pin: ${formatEnginePin(report.integratorPin)}`,
|
|
849
1319
|
`dual pin: ${report.dualPin ? "yes" : "no"}`,
|
|
850
1320
|
`extra @clossys/*: ${extra}`,
|
|
851
1321
|
`pin findings: ${findingLine}`,
|
|
1322
|
+
...enginePinLines,
|
|
1323
|
+
...installLine,
|
|
852
1324
|
`degraded: ${report.degraded ? "yes" : "no"}`,
|
|
853
1325
|
...(migrationLine === undefined ? [] : [migrationLine]),
|
|
854
1326
|
...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
|
|
@@ -863,20 +1335,36 @@ function withHealth(
|
|
|
863
1335
|
host: WorkspaceHost,
|
|
864
1336
|
directory: string,
|
|
865
1337
|
headline: string,
|
|
866
|
-
|
|
867
|
-
skillComposition?: SkillCompositionResult & {
|
|
1338
|
+
liveEngines: HubEngineVersions,
|
|
1339
|
+
skillComposition?: SkillCompositionResult & {
|
|
1340
|
+
linkedHosts?: readonly DiscoveredHost[];
|
|
1341
|
+
},
|
|
868
1342
|
liveLauncherVersion?: string,
|
|
869
1343
|
migration?: HubHealthReport["migration"],
|
|
870
1344
|
inventoryDrift?: HubHealthReport["inventoryDrift"],
|
|
1345
|
+
enginePinChanges: readonly EnginePinChange[] = [],
|
|
871
1346
|
): WorkspaceApplyResult {
|
|
872
|
-
const base = reportHubHealth(
|
|
873
|
-
|
|
1347
|
+
const base = reportHubHealth(
|
|
1348
|
+
host,
|
|
1349
|
+
directory,
|
|
1350
|
+
liveEngines.advisorVersion,
|
|
1351
|
+
liveLauncherVersion,
|
|
1352
|
+
skillComposition?.retired ?? [],
|
|
1353
|
+
migration,
|
|
1354
|
+
liveEngines.integratorVersion,
|
|
1355
|
+
);
|
|
1356
|
+
const preserved = skillComposition?.preserved ?? [];
|
|
1357
|
+
const installNeeded = engineInstallFinding(host, directory, enginePinChanges);
|
|
874
1358
|
const health: HubHealthReport = {
|
|
875
1359
|
...base,
|
|
1360
|
+
...(enginePinChanges.length === 0
|
|
1361
|
+
? {}
|
|
1362
|
+
: { enginePins: { changed: enginePinChanges, nextStep: enginePinNextStep(host, directory) } }),
|
|
1363
|
+
...(installNeeded === undefined ? {} : { installNeeded }),
|
|
876
1364
|
...(skillComposition === undefined ? {} : { skillComposition }),
|
|
877
1365
|
...(skillComposition?.linkedHosts === undefined ? {} : { linkedHosts: skillComposition.linkedHosts }),
|
|
878
1366
|
...(inventoryDrift === undefined || inventoryDrift.status === "no-external-source" ? {} : { inventoryDrift }),
|
|
879
|
-
degraded: base.degraded ||
|
|
1367
|
+
degraded: base.degraded || preserved.length > 0 || installNeeded !== undefined,
|
|
880
1368
|
};
|
|
881
1369
|
return {
|
|
882
1370
|
state: "satisfied",
|
|
@@ -891,8 +1379,8 @@ function withHealth(
|
|
|
891
1379
|
* a note; marks ids whose check fails as unknown. Never mutates the inventory.
|
|
892
1380
|
*/
|
|
893
1381
|
export function checkInventoryEntries(host: WorkspaceHost, directory: string): InventoryValidationReport {
|
|
894
|
-
const
|
|
895
|
-
const observation = inspectInventory(
|
|
1382
|
+
const hubOwner = readHub(host, directory)?.owner;
|
|
1383
|
+
const observation = inspectInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), hubOwner);
|
|
896
1384
|
if (observation.status !== "populated") {
|
|
897
1385
|
return { entries: [], skipped: true, note: `inventory is ${observation.status}; nothing to validate` };
|
|
898
1386
|
}
|
|
@@ -900,7 +1388,7 @@ export function checkInventoryEntries(host: WorkspaceHost, directory: string): I
|
|
|
900
1388
|
if (!available) {
|
|
901
1389
|
return { entries: [], skipped: true, note: "`gh` is unavailable; inventory ids were not validated" };
|
|
902
1390
|
}
|
|
903
|
-
const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory").filter(
|
|
1391
|
+
const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory", hubOwner).filter(
|
|
904
1392
|
(id) => id !== "",
|
|
905
1393
|
);
|
|
906
1394
|
const batch = 20;
|
|
@@ -924,6 +1412,7 @@ function shouldRefreshConsumerAgents(existing: string | null): boolean {
|
|
|
924
1412
|
if (existing === null) return true;
|
|
925
1413
|
if (existing === CONSUMER_AGENTS_MD) return false;
|
|
926
1414
|
if (existing === LEGACY_CONSUMER_AGENTS_MD) return true;
|
|
1415
|
+
if (existing === SIBLING_COMPOSING_CONSUMER_AGENTS_MD) return true;
|
|
927
1416
|
if (existing.includes("Run `npx @clossys/launcher` again to resume")) return true;
|
|
928
1417
|
return false;
|
|
929
1418
|
}
|
|
@@ -934,125 +1423,207 @@ function writeConsumerAgentsIfNeeded(host: WorkspaceHost, directory: string): vo
|
|
|
934
1423
|
writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
|
|
935
1424
|
}
|
|
936
1425
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1426
|
+
/** What a hub run reports for each inventoried repository other than the hub. It never writes into one. */
|
|
1427
|
+
const SETUP_PULL_REQUEST_NOTE =
|
|
1428
|
+
"a hub run writes nothing here; once this repository is staffed in an approved plan, @clossys-advisor and the voices of the roles staffed there arrive with that plan's setup pull request";
|
|
1429
|
+
const BESIDE_HUB_NOTE = `checkout beside the hub; ${SETUP_PULL_REQUEST_NOTE}`;
|
|
1430
|
+
const CLONE_NOT_BESIDE_HUB_NOTE = `not cloned beside the hub; ${SETUP_PULL_REQUEST_NOTE}`;
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Splits an inventory id into owner/repository, trusting a caller that
|
|
1434
|
+
* already validated it with `isValidInventoryId` (every id reaching here
|
|
1435
|
+
* comes from a document `validateInventoryDocument` already accepted)
|
|
1436
|
+
* rather than re-deriving that rule -- but still runs it, so a caller that
|
|
1437
|
+
* somehow supplies an unvalidated id gets `null`, never a wrong split.
|
|
1438
|
+
*/
|
|
1439
|
+
function parseInventoryRepositoryId(id: string, hubOwner: string): { owner: string; repository: string } | null {
|
|
1440
|
+
if (!isValidInventoryId(id)) return null;
|
|
1441
|
+
const slash = id.indexOf("/");
|
|
1442
|
+
if (slash === -1) return { owner: hubOwner, repository: id };
|
|
1443
|
+
return { owner: id.slice(0, slash), repository: id.slice(slash + 1) };
|
|
941
1444
|
}
|
|
942
1445
|
|
|
943
|
-
|
|
944
|
-
|
|
1446
|
+
/** A checkout's origin as `owner/name`, when it is a github.com repository. */
|
|
1447
|
+
function originRepository(host: WorkspaceHost, directory: string): string | undefined {
|
|
1448
|
+
const result = host.run("git", ["remote", "get-url", "origin"], { cwd: directory });
|
|
1449
|
+
const remote = result.status === 0 ? parseGitHubRemote(result.stdout.trim()) : null;
|
|
1450
|
+
return remote === null ? undefined : `${remote.owner}/${remote.repository}`;
|
|
1451
|
+
}
|
|
945
1452
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
if (!OWNER.test(owner) || !REPO.test(repository)) return null;
|
|
954
|
-
return { owner, repository };
|
|
955
|
-
}
|
|
956
|
-
if (!REPO.test(trimmed)) return null;
|
|
957
|
-
return { owner: hubOwner, repository: trimmed };
|
|
1453
|
+
/**
|
|
1454
|
+
* The hub's own repository identity: its origin's `owner/name`, or, when
|
|
1455
|
+
* the checkout has no github.com origin, the repository its hub marker
|
|
1456
|
+
* records. Never its folder path (see identity.ts).
|
|
1457
|
+
*/
|
|
1458
|
+
function hubRepositoryIdentity(host: WorkspaceHost, hubDirectory: string): string | undefined {
|
|
1459
|
+
return originRepository(host, hubDirectory) ?? readHub(host, hubDirectory)?.repository;
|
|
958
1460
|
}
|
|
959
1461
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1462
|
+
/** Where an inventoried repository other than the hub stands, as a hub run sees it. */
|
|
1463
|
+
type SiblingStatus =
|
|
1464
|
+
| "beside-the-hub"
|
|
1465
|
+
| "not-cloned"
|
|
1466
|
+
| "other-account"
|
|
1467
|
+
| "foundry-supplier-tree"
|
|
1468
|
+
| "origin-mismatch"
|
|
1469
|
+
| "not-a-git-checkout"
|
|
1470
|
+
| "git-refused"
|
|
1471
|
+
| "invalid-id"
|
|
1472
|
+
| "inventory-invalid";
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* `inventoryId` stays on every entry below for internal use only (a
|
|
1476
|
+
* `gh repo clone` argument, a directory lookup, a structured result a
|
|
1477
|
+
* caller inspects programmatically) -- never put directly in text a founder
|
|
1478
|
+
* or agent reads. `position` is the id's 0-based index in the stored
|
|
1479
|
+
* inventory's own `repositories` array; every message built from these
|
|
1480
|
+
* entries (skill-roster lines, `--clone-missing` output) names that
|
|
1481
|
+
* position instead, per the rule at the top of ./inventory-choice.ts.
|
|
1482
|
+
* `position` is `undefined` only for the one entry below that is not a
|
|
1483
|
+
* document id at all, the stored-inventory-file-itself failure.
|
|
1484
|
+
*/
|
|
1485
|
+
interface InventoriedSibling {
|
|
1486
|
+
readonly inventoryId: string;
|
|
1487
|
+
readonly position?: number;
|
|
1488
|
+
readonly status: SiblingStatus;
|
|
1489
|
+
readonly note: string;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Classifies every inventoried repository other than the hub. Read-only:
|
|
1494
|
+
* it looks for a checkout beside the hub and reads that checkout's git
|
|
1495
|
+
* origin, and nothing else, so a sibling's working tree, legacy output or
|
|
1496
|
+
* old pins can neither change the result nor be changed by it. The hub run
|
|
1497
|
+
* reports the result; only `--clone-missing` acts on it, and only on
|
|
1498
|
+
* `not-cloned` entries.
|
|
1499
|
+
*/
|
|
1500
|
+
function classifyInventoriedSiblings(host: WorkspaceHost, hubDirectory: string, hubOwner: string): readonly InventoriedSibling[] {
|
|
968
1501
|
const parent = dirname(resolve(hubDirectory));
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
const targets: { inventoryId: string; directory: string }[] = [];
|
|
1502
|
+
const hubIdentity = hubRepositoryIdentity(host, hubDirectory);
|
|
1503
|
+
const siblings: InventoriedSibling[] = [];
|
|
972
1504
|
const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
|
|
973
1505
|
let inventoryIds: readonly string[];
|
|
974
1506
|
try {
|
|
975
|
-
inventoryIds = readInventoryRepositories(host, inventoryPath, "the
|
|
976
|
-
} catch {
|
|
977
|
-
|
|
1507
|
+
inventoryIds = readInventoryRepositories(host, inventoryPath, "the stored inventory", hubOwner);
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
// An invalid stored inventory is reported, never silently read for what
|
|
1510
|
+
// it happens to look like -- no clone is attempted from it (#1334).
|
|
1511
|
+
// WORKSPACE_INVENTORY_REL is Launcher's own constant path, not document
|
|
1512
|
+
// content, so it is safe to name directly; there is no per-id position
|
|
1513
|
+
// for a failure at the file level, so `position` stays undefined.
|
|
1514
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1515
|
+
return [{ inventoryId: WORKSPACE_INVENTORY_REL, status: "inventory-invalid", note: reason }];
|
|
978
1516
|
}
|
|
979
|
-
for (const id of inventoryIds) {
|
|
980
|
-
if (id === "") continue;
|
|
1517
|
+
for (const [position, id] of inventoryIds.entries()) {
|
|
981
1518
|
const parsed = parseInventoryRepositoryId(id, hubOwner);
|
|
982
1519
|
if (parsed === null) {
|
|
983
|
-
|
|
1520
|
+
siblings.push({ inventoryId: id, position, status: "invalid-id", note: "inventory id is not a valid repository slug" });
|
|
984
1521
|
continue;
|
|
985
1522
|
}
|
|
986
|
-
if (
|
|
987
|
-
|
|
1523
|
+
if (!belongsToOwner(id, hubOwner)) {
|
|
1524
|
+
siblings.push({ inventoryId: id, position, status: "other-account", note: "other account; not this roster" });
|
|
988
1525
|
continue;
|
|
989
1526
|
}
|
|
1527
|
+
// The hub itself is recognised by repository identity, not by folder path.
|
|
1528
|
+
if (hubIdentity !== undefined && sameRepository(id, hubIdentity, hubOwner)) continue;
|
|
990
1529
|
const candidate = join(parent, parsed.repository);
|
|
991
|
-
const candidateResolved = resolve(candidate);
|
|
992
|
-
if (candidateResolved === hubResolved) continue;
|
|
993
1530
|
if (!host.exists(candidate) || !host.isDirectory(candidate)) {
|
|
994
|
-
|
|
1531
|
+
siblings.push({ inventoryId: id, position, status: "not-cloned", note: CLONE_NOT_BESIDE_HUB_NOTE });
|
|
995
1532
|
continue;
|
|
996
1533
|
}
|
|
997
1534
|
if (looksLikeFoundry(host, candidate)) {
|
|
998
|
-
|
|
1535
|
+
siblings.push({ inventoryId: id, position, status: "foundry-supplier-tree", note: "foundry supplier tree; skills are not written here" });
|
|
999
1536
|
continue;
|
|
1000
1537
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1538
|
+
if (!host.exists(join(candidate, ".git"))) {
|
|
1539
|
+
siblings.push({
|
|
1540
|
+
inventoryId: id,
|
|
1541
|
+
position,
|
|
1542
|
+
status: "not-a-git-checkout",
|
|
1543
|
+
note: "the folder beside the hub with this name is not a git checkout, so it cannot be matched to this inventory id",
|
|
1544
|
+
});
|
|
1006
1545
|
continue;
|
|
1007
1546
|
}
|
|
1008
|
-
|
|
1547
|
+
const remote = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
|
|
1548
|
+
if (remote.status !== 0 && /dubious ownership/i.test(remote.stderr)) {
|
|
1549
|
+
siblings.push({
|
|
1550
|
+
inventoryId: id,
|
|
1551
|
+
position,
|
|
1552
|
+
status: "git-refused",
|
|
1553
|
+
note: "git refuses to read this checkout (it reports dubious ownership), so its origin could not be matched to this inventory id",
|
|
1554
|
+
});
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
const parsedOrigin = remote.status === 0 ? parseGitHubRemote(remote.stdout.trim()) : null;
|
|
1558
|
+
const origin = parsedOrigin === null ? undefined : `${parsedOrigin.owner}/${parsedOrigin.repository}`;
|
|
1559
|
+
if (origin === undefined || !sameRepository(origin, id, hubOwner)) {
|
|
1560
|
+
siblings.push({ inventoryId: id, position, status: "origin-mismatch", note: "git origin does not match inventory id" });
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
siblings.push({ inventoryId: id, position, status: "beside-the-hub", note: BESIDE_HUB_NOTE });
|
|
1009
1564
|
}
|
|
1010
|
-
return
|
|
1565
|
+
return siblings;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** The position a message names instead of a document-sourced inventory id, matching `inventory-choice.ts`'s `listRemovedPositions`. `undefined` only for the stored-inventory-file-itself failure, which names the file by its own constant path instead. */
|
|
1569
|
+
export function inventoryPositionLabel(inventoryId: string, position: number | undefined): string {
|
|
1570
|
+
return position === undefined ? inventoryId : `repositories[${position}] in the stored inventory`;
|
|
1011
1571
|
}
|
|
1012
1572
|
|
|
1013
1573
|
export interface CloneMissingOutcome {
|
|
1574
|
+
/** Internal use only (a `gh repo clone` argument, a caller's own lookup) -- never put directly in a printed message; see `position`. */
|
|
1014
1575
|
readonly inventoryId: string;
|
|
1576
|
+
/** This id's 0-based position in the stored inventory's `repositories` array; what a message names instead of `inventoryId`. `undefined` only for the stored-inventory-file-itself failure. */
|
|
1577
|
+
readonly position?: number;
|
|
1015
1578
|
readonly result: "cloned" | "skipped-other-reason" | "failed";
|
|
1016
1579
|
readonly note: string;
|
|
1017
1580
|
}
|
|
1018
1581
|
|
|
1019
1582
|
/**
|
|
1020
1583
|
* Explicit, approved action (#1179, the #1045 pattern): clones every
|
|
1021
|
-
* inventoried repository that
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
* --clone-missing flag. Reverses the launcher README's own no-clone
|
|
1027
|
-
* default for exactly this one approved action.
|
|
1584
|
+
* inventoried repository that classifyInventoriedSiblings found not cloned
|
|
1585
|
+
* beside the hub, and only those -- every other reason (wrong account,
|
|
1586
|
+
* foundry supplier tree, origin mismatch, invalid slug, invalid inventory)
|
|
1587
|
+
* is reported as skipped, never attempted, and a checkout already beside
|
|
1588
|
+
* the hub is left out. Never called from resume's default path; only from
|
|
1589
|
+
* the --clone-missing flag. Reverses the launcher README's own no-clone
|
|
1590
|
+
* default for exactly this one approved action. Cloning is not composing:
|
|
1591
|
+
* a cloned repository receives its team only once it is staffed in an
|
|
1592
|
+
* approved plan, with that plan's setup pull request.
|
|
1028
1593
|
*/
|
|
1029
1594
|
export function cloneMissingInventoryRepositories(
|
|
1030
1595
|
host: WorkspaceHost,
|
|
1031
1596
|
hubDirectory: string,
|
|
1032
1597
|
hubOwner: string,
|
|
1033
1598
|
): readonly CloneMissingOutcome[] {
|
|
1034
|
-
const { skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
|
|
1035
1599
|
const parent = dirname(resolve(hubDirectory));
|
|
1036
1600
|
const outcomes: CloneMissingOutcome[] = [];
|
|
1037
|
-
for (const skip of
|
|
1038
|
-
if (skip.
|
|
1039
|
-
|
|
1601
|
+
for (const skip of classifyInventoriedSiblings(host, hubDirectory, hubOwner)) {
|
|
1602
|
+
if (skip.status === "beside-the-hub") continue;
|
|
1603
|
+
if (skip.status !== "not-cloned") {
|
|
1604
|
+
outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "skipped-other-reason", note: skip.note });
|
|
1040
1605
|
continue;
|
|
1041
1606
|
}
|
|
1042
1607
|
const parsed = parseInventoryRepositoryId(skip.inventoryId, hubOwner);
|
|
1043
1608
|
if (parsed === null) {
|
|
1044
|
-
outcomes.push({ inventoryId: skip.inventoryId, result: "failed", note: "inventory id is not a valid repository slug" });
|
|
1609
|
+
outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "failed", note: "inventory id is not a valid repository slug" });
|
|
1045
1610
|
continue;
|
|
1046
1611
|
}
|
|
1612
|
+
// Cloning itself still uses the raw id and the real path -- that is the whole
|
|
1613
|
+
// point of this action -- but the *reported* note never repeats either: the
|
|
1614
|
+
// folder name comes straight from the inventory (a document), and `gh`'s own
|
|
1615
|
+
// stderr on failure names the repository it could not find or clone. Both are
|
|
1616
|
+
// fixed text instead (#1179); `position` is still how this outcome is named.
|
|
1047
1617
|
const siblingPath = join(parent, parsed.repository);
|
|
1048
1618
|
const result = host.run("gh", ["repo", "clone", `${hubOwner}/${parsed.repository}`, siblingPath]);
|
|
1049
1619
|
if (result.status === 0) {
|
|
1050
|
-
outcomes.push({ inventoryId: skip.inventoryId, result: "cloned", note:
|
|
1620
|
+
outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "cloned", note: "cloned beside the hub" });
|
|
1051
1621
|
} else {
|
|
1052
1622
|
outcomes.push({
|
|
1053
1623
|
inventoryId: skip.inventoryId,
|
|
1624
|
+
position: skip.position,
|
|
1054
1625
|
result: "failed",
|
|
1055
|
-
note: `gh repo clone exited ${result.status ?? "null"}
|
|
1626
|
+
note: `gh repo clone exited ${result.status ?? "null"}`,
|
|
1056
1627
|
});
|
|
1057
1628
|
}
|
|
1058
1629
|
}
|
|
@@ -1127,6 +1698,16 @@ function recordLinkedHosts(host: WorkspaceHost, directory: string): readonly Dis
|
|
|
1127
1698
|
return linkedHosts;
|
|
1128
1699
|
}
|
|
1129
1700
|
|
|
1701
|
+
/**
|
|
1702
|
+
* Composes the team in the hub, and only the hub: this writes nothing into
|
|
1703
|
+
* any checkout beside the hub. An inventoried product repository receives
|
|
1704
|
+
* its skills, skills manifest, host discovery links and guidance only once
|
|
1705
|
+
* it is staffed in an approved plan, with that plan's setup pull request. Each inventoried
|
|
1706
|
+
* repository other than the hub is reported, read-only, as a sibling, named
|
|
1707
|
+
* by its `inventoryPositionLabel()` position -- never its raw stored
|
|
1708
|
+
* inventory id -- because the whole report is JSON-dumped into the apply
|
|
1709
|
+
* message's `health:` line (see `formatHubHealth`).
|
|
1710
|
+
*/
|
|
1130
1711
|
function composeSkillRoster(
|
|
1131
1712
|
host: WorkspaceHost,
|
|
1132
1713
|
hubDirectory: string,
|
|
@@ -1135,7 +1716,7 @@ function composeSkillRoster(
|
|
|
1135
1716
|
options: { launcherPackageRoot: string; skillCatalogueRoot?: string; contractPath?: string },
|
|
1136
1717
|
): SkillCompositionResult & {
|
|
1137
1718
|
readonly rosterTargets: readonly string[];
|
|
1138
|
-
readonly
|
|
1719
|
+
readonly siblings: readonly { readonly inventoryId: string; readonly note: string }[];
|
|
1139
1720
|
readonly linkedHosts: readonly DiscoveredHost[];
|
|
1140
1721
|
} {
|
|
1141
1722
|
const composeOptions = {
|
|
@@ -1148,15 +1729,11 @@ function composeSkillRoster(
|
|
|
1148
1729
|
writeConsumerAgentsIfNeeded(host, hubDirectory);
|
|
1149
1730
|
writeClossysReadme(host, hubDirectory);
|
|
1150
1731
|
const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
writeSisterConsumerAgentsIfNeeded(host, target.directory);
|
|
1157
|
-
rosterTargets.push(target.inventoryId);
|
|
1158
|
-
}
|
|
1159
|
-
return { ...hubSkill, rosterTargets, rosterSkipped: skipped, linkedHosts };
|
|
1732
|
+
const siblings = classifyInventoriedSiblings(host, hubDirectory, hubOwner)
|
|
1733
|
+
// An invalid stored inventory is a hub finding: the health report's inventory line names it.
|
|
1734
|
+
.filter((sibling) => sibling.status !== "inventory-invalid")
|
|
1735
|
+
.map(({ inventoryId, position, note }) => ({ inventoryId: inventoryPositionLabel(inventoryId, position), note }));
|
|
1736
|
+
return { ...hubSkill, rosterTargets: [hubId], siblings, linkedHosts };
|
|
1160
1737
|
}
|
|
1161
1738
|
|
|
1162
1739
|
function finishHubApply(
|
|
@@ -1166,11 +1743,12 @@ function finishHubApply(
|
|
|
1166
1743
|
launcherPackageRoot: string,
|
|
1167
1744
|
hubOwner: string,
|
|
1168
1745
|
hubRepository: string,
|
|
1169
|
-
|
|
1746
|
+
liveEngines: HubEngineVersions,
|
|
1170
1747
|
skillCatalogueRoot?: string,
|
|
1171
1748
|
contractPath?: string,
|
|
1172
1749
|
liveLauncherVersion?: string,
|
|
1173
1750
|
migration?: HubHealthReport["migration"],
|
|
1751
|
+
enginePinChanges: readonly EnginePinChange[] = [],
|
|
1174
1752
|
): WorkspaceApplyResult {
|
|
1175
1753
|
const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
|
|
1176
1754
|
launcherPackageRoot,
|
|
@@ -1180,31 +1758,119 @@ function finishHubApply(
|
|
|
1180
1758
|
// #1216: when the hub marker declares an external inventory, report drift against
|
|
1181
1759
|
// it on every apply (create's fresh marker never declares one, so this is a no-op there).
|
|
1182
1760
|
const hubDocument = readHub(host, directory);
|
|
1183
|
-
const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL);
|
|
1184
|
-
return withHealth(host, directory, headline,
|
|
1761
|
+
const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL, hubOwner);
|
|
1762
|
+
return withHealth(host, directory, headline, liveEngines, skillComposition, liveLauncherVersion, migration, inventoryDrift, enginePinChanges);
|
|
1185
1763
|
}
|
|
1186
1764
|
|
|
1187
1765
|
/**
|
|
1188
1766
|
* Migrates a legacy `.clossys/` hub marker (and its sibling inventory, when
|
|
1189
1767
|
* present) to `clossys/.state/`, then removes the old directory. Called only
|
|
1190
1768
|
* when `locateHub` found the marker at the legacy path and nowhere else
|
|
1191
|
-
* (`plan.migrateFrom === "legacy"
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1769
|
+
* (`plan.migrateFrom === "legacy"`, or `classifyClonedHub` for a fresh
|
|
1770
|
+
* clone); a hub with markers at both paths is refused by `planWorkspace`
|
|
1771
|
+
* (or, for a clone, by `classifyClonedHub`) before anything is written, so
|
|
1772
|
+
* this never merges two hub states.
|
|
1194
1773
|
*/
|
|
1195
1774
|
function migrateLegacyHubState(host: WorkspaceHost, directory: string): HubHealthReport["migration"] {
|
|
1196
1775
|
const markerRaw = host.readText(join(directory, LEGACY_WORKSPACE_MARKER_REL));
|
|
1197
1776
|
if (markerRaw === null) return undefined;
|
|
1198
1777
|
writeSkeletonFile(host, directory, WORKSPACE_MARKER_REL, markerRaw.endsWith("\n") ? markerRaw : `${markerRaw}\n`);
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
}
|
|
1778
|
+
// Moved byte for byte, so bytes that are not valid UTF-8 are still refused when read from the new place.
|
|
1779
|
+
const inventoryBytes = host.readBytes(join(directory, LEGACY_WORKSPACE_INVENTORY_REL));
|
|
1780
|
+
if (inventoryBytes !== null) writeSkeletonBytes(host, directory, WORKSPACE_INVENTORY_REL, withFinalNewline(inventoryBytes));
|
|
1203
1781
|
host.remove(join(directory, LEGACY_STATE_DIR_REL));
|
|
1204
1782
|
return { status: "migrated", from: LEGACY_STATE_DIR_REL, to: STATE_DIR_REL };
|
|
1205
1783
|
}
|
|
1206
1784
|
|
|
1207
|
-
/**
|
|
1785
|
+
/** The apply message's line saying what `--repositories` did to the inventory, or nothing without it. */
|
|
1786
|
+
function chosenInventoryNote(chosen: ChosenInventory | undefined): string {
|
|
1787
|
+
return chosen === undefined ? "" : `\n${describeChosenInventory(chosen)}`;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function engineVersionsOf(plan: WorkspacePlan): HubEngineVersions {
|
|
1791
|
+
return {
|
|
1792
|
+
...(plan.advisorVersion === undefined ? {} : { advisorVersion: plan.advisorVersion }),
|
|
1793
|
+
...(plan.integratorVersion === undefined ? {} : { integratorVersion: plan.integratorVersion }),
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Classifies a freshly cloned `{owner}/workspace` (a resume plan with
|
|
1799
|
+
* `clone: true`) by its marker, the way `planWorkspace` classifies a local
|
|
1800
|
+
* checkout, and returns how apply must continue (#1585). It only reads: every
|
|
1801
|
+
* refusal it decides is thrown here, before apply writes anything into the
|
|
1802
|
+
* clone, and the appoint path's own refusals (a `package.json` that is not a
|
|
1803
|
+
* JSON object among them) are thrown by `adoptHubFiles` before its first write.
|
|
1804
|
+
*
|
|
1805
|
+
* - both markers: refused, as `planWorkspace` refuses them locally;
|
|
1806
|
+
* - a marker (current or legacy) for another owner: refused;
|
|
1807
|
+
* - only the legacy marker: resume, migrating it;
|
|
1808
|
+
* - only the current marker: resume;
|
|
1809
|
+
* - no marker: an existing repository with unrelated content is being
|
|
1810
|
+
* appointed, so apply takes the adopt path, which keeps its files. That
|
|
1811
|
+
* path needs both engine versions, refuses the Foundry supplier tree, and
|
|
1812
|
+
* refuses an on-disk inventory that fails its contract (a missing or valid
|
|
1813
|
+
* one is kept as it is; this path writes no inventory), and refuses a
|
|
1814
|
+
* `package.json` that is not a JSON object (checked by `adoptHubFiles`).
|
|
1815
|
+
*/
|
|
1816
|
+
function classifyClonedHub(
|
|
1817
|
+
host: WorkspaceHost,
|
|
1818
|
+
plan: WorkspacePlanResume,
|
|
1819
|
+
): { kind: "resume"; migrateFrom?: "legacy" } | { kind: "appoint"; plan: WorkspacePlanAdopt } {
|
|
1820
|
+
const located = locateHub(host, plan.directory);
|
|
1821
|
+
if (located.migration === "indeterminate") {
|
|
1822
|
+
throw new Error(
|
|
1823
|
+
`in the cloned ${plan.owner}/${plan.repository}, both ${WORKSPACE_MARKER_REL} and the legacy ${LEGACY_WORKSPACE_MARKER_REL} are present; launcher never merges them silently -- remove one before resuming`,
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
if (located.document !== undefined) {
|
|
1827
|
+
if (!sameOwner(located.document.owner, plan.owner)) {
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
`the cloned ${plan.owner}/${plan.repository} carries a hub marker owned by "${located.document.owner}", not "${plan.owner}"; refusing to resume a hub for the wrong account`,
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
return located.migration === "legacy" ? { kind: "resume", migrateFrom: "legacy" } : { kind: "resume" };
|
|
1833
|
+
}
|
|
1834
|
+
if (!plan.advisorVersion) {
|
|
1835
|
+
throw new Error(`cannot read a public ${ADVISOR_PACKAGE} version from the npm registry; appointing the cloned ${plan.owner}/${plan.repository} pins it`);
|
|
1836
|
+
}
|
|
1837
|
+
if (!plan.integratorVersion) {
|
|
1838
|
+
throw new Error(`cannot read a public ${INTEGRATOR_PACKAGE} version from the npm registry; appointing the cloned ${plan.owner}/${plan.repository} pins it`);
|
|
1839
|
+
}
|
|
1840
|
+
if (looksLikeFoundry(host, plan.directory)) {
|
|
1841
|
+
throw new Error(
|
|
1842
|
+
`refusing to appoint the cloned ${plan.owner}/${plan.repository}: it looks like the Foundry supplier tree`,
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
const inventory = inspectInventory(host.readBytes(join(plan.directory, WORKSPACE_INVENTORY_REL)), plan.owner);
|
|
1846
|
+
if (inventory.status === "invalid") {
|
|
1847
|
+
throw new Error(
|
|
1848
|
+
`the cloned ${plan.owner}/${plan.repository} has an on-disk hub inventory that ${inventory.reason ?? "does not conform to the inventory contract"}; fix or remove ${WORKSPACE_INVENTORY_REL} in that repository, then run launcher from inside the checkout`,
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
return {
|
|
1852
|
+
kind: "appoint",
|
|
1853
|
+
plan: {
|
|
1854
|
+
action: "adopt",
|
|
1855
|
+
owner: plan.owner,
|
|
1856
|
+
repository: plan.repository,
|
|
1857
|
+
directory: plan.directory,
|
|
1858
|
+
advisorVersion: plan.advisorVersion,
|
|
1859
|
+
integratorVersion: plan.integratorVersion,
|
|
1860
|
+
},
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Applies a create, resume, or adopt plan through the host. Resume refreshes
|
|
1866
|
+
* composed skills and stale AGENTS.md guidance. A resume plan that clones
|
|
1867
|
+
* (`clone: true`, planned from an empty directory) is classified only after
|
|
1868
|
+
* the clone, by `classifyClonedHub`: a marked clone resumes (migrating a
|
|
1869
|
+
* legacy marker), an unmarked one is appointed through the adopt path, and
|
|
1870
|
+
* every refusal Launcher decides happens before anything is written into the clone (#1585).
|
|
1871
|
+
* Every path writes only into the hub checkout, never into an inventoried
|
|
1872
|
+
* repository beside it.
|
|
1873
|
+
*/
|
|
1208
1874
|
export function applyWorkspacePlan(
|
|
1209
1875
|
host: WorkspaceHost,
|
|
1210
1876
|
plan: WorkspacePlan,
|
|
@@ -1216,25 +1882,40 @@ export function applyWorkspacePlan(
|
|
|
1216
1882
|
const contractPath = options.contractPath;
|
|
1217
1883
|
const liveLauncherVersion = options.liveLauncherVersion;
|
|
1218
1884
|
if (plan.action === "resume") {
|
|
1885
|
+
let migrateFrom = plan.migrateFrom;
|
|
1219
1886
|
if (plan.clone) {
|
|
1220
1887
|
requireZero(
|
|
1221
1888
|
host.run("gh", ["repo", "clone", `${plan.owner}/${plan.repository}`, plan.directory]),
|
|
1222
1889
|
"gh repo clone",
|
|
1223
1890
|
);
|
|
1891
|
+
const cloned = classifyClonedHub(host, plan);
|
|
1892
|
+
if (cloned.kind === "appoint") return applyAdoptPlan(host, cloned.plan, skeletonRoot, options);
|
|
1893
|
+
migrateFrom = cloned.migrateFrom;
|
|
1894
|
+
}
|
|
1895
|
+
const migration = migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
|
|
1896
|
+
// --repositories (#1179): write the chosen inventory before composing, so
|
|
1897
|
+
// this same run's health report lists the repositories just chosen.
|
|
1898
|
+
if (plan.chosenInventory?.kind === "write") {
|
|
1899
|
+
writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, revalidatedDocument(plan.chosenInventory.document, plan.owner));
|
|
1224
1900
|
}
|
|
1225
|
-
|
|
1901
|
+
// Resume raises the hub's engine pins to live: a frozen Advisor is
|
|
1902
|
+
// bumped and Integrator is added, for each engine whose live version
|
|
1903
|
+
// observeWorkspace could read. The health report says what changed and
|
|
1904
|
+
// that the install, and a commit with the lockfile, come next.
|
|
1905
|
+
const enginePinChanges = mergeHubEnginePins(host, plan.directory, skeletonRoot, engineVersionsOf(plan), plan.owner, plan.repository, "resume");
|
|
1226
1906
|
return finishHubApply(
|
|
1227
1907
|
host,
|
|
1228
1908
|
plan.directory,
|
|
1229
|
-
`resumed ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action
|
|
1909
|
+
`resumed ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.${chosenInventoryNote(plan.chosenInventory)}`,
|
|
1230
1910
|
launcherPackageRoot,
|
|
1231
1911
|
plan.owner,
|
|
1232
1912
|
plan.repository,
|
|
1233
|
-
plan
|
|
1913
|
+
engineVersionsOf(plan),
|
|
1234
1914
|
skillCatalogueRoot,
|
|
1235
1915
|
contractPath,
|
|
1236
1916
|
liveLauncherVersion,
|
|
1237
1917
|
migration,
|
|
1918
|
+
enginePinChanges,
|
|
1238
1919
|
);
|
|
1239
1920
|
}
|
|
1240
1921
|
if (plan.action === "create") {
|
|
@@ -1254,24 +1935,41 @@ export function applyWorkspacePlan(
|
|
|
1254
1935
|
launcherPackageRoot,
|
|
1255
1936
|
plan.owner,
|
|
1256
1937
|
plan.repository,
|
|
1257
|
-
plan
|
|
1938
|
+
engineVersionsOf(plan),
|
|
1258
1939
|
skillCatalogueRoot,
|
|
1259
1940
|
contractPath,
|
|
1260
1941
|
liveLauncherVersion,
|
|
1261
1942
|
);
|
|
1262
1943
|
}
|
|
1263
|
-
|
|
1944
|
+
return applyAdoptPlan(host, plan, skeletonRoot, options);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Appoints an existing checkout as the hub, keeping its files: the adopt plan's apply, also reached from an unmarked clone (#1585). */
|
|
1948
|
+
function applyAdoptPlan(
|
|
1949
|
+
host: WorkspaceHost,
|
|
1950
|
+
plan: WorkspacePlanAdopt,
|
|
1951
|
+
skeletonRoot: string,
|
|
1952
|
+
options: ApplyWorkspaceOptions,
|
|
1953
|
+
): WorkspaceApplyResult {
|
|
1954
|
+
const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
|
|
1955
|
+
const { skillCatalogueRoot, contractPath, liveLauncherVersion } = options;
|
|
1956
|
+
const enginePinChanges = adoptHubFiles(host, skeletonRoot, plan);
|
|
1957
|
+
const inventoryReplacedNote = plan.replacesInvalidInventory === true
|
|
1958
|
+
? " The on-disk inventory failed schema validation; --inventory replaced it."
|
|
1959
|
+
: "";
|
|
1264
1960
|
return finishHubApply(
|
|
1265
1961
|
host,
|
|
1266
1962
|
plan.directory,
|
|
1267
|
-
`appointed ${plan.owner}/${plan.repository} as the account hub\nExisting project files were kept. This hub inventories engagement; it does not install the catalogue into the repo
|
|
1963
|
+
`appointed ${plan.owner}/${plan.repository} as the account hub\nExisting project files were kept. This hub inventories engagement; it does not install the catalogue into the repo.${inventoryReplacedNote}${chosenInventoryNote(plan.chosenInventory)}`,
|
|
1268
1964
|
launcherPackageRoot,
|
|
1269
1965
|
plan.owner,
|
|
1270
1966
|
plan.repository,
|
|
1271
|
-
plan
|
|
1967
|
+
engineVersionsOf(plan),
|
|
1272
1968
|
skillCatalogueRoot,
|
|
1273
1969
|
contractPath,
|
|
1274
1970
|
liveLauncherVersion,
|
|
1971
|
+
undefined,
|
|
1972
|
+
enginePinChanges,
|
|
1275
1973
|
);
|
|
1276
1974
|
}
|
|
1277
1975
|
|