@clossys/launcher 0.3.1 → 0.5.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 +1373 -60
- package/contracts/conversation-contract.md +2 -2
- 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 +467 -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 +804 -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 +163 -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 +403 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +781 -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/existing-declaration-adoption.check.d.ts +2 -0
- package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
- package/dist/existing-declaration-adoption.check.js +10 -0
- package/dist/existing-declaration-adoption.check.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 +3101 -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 +35 -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 +190 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +555 -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 +203 -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 +881 -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 +256 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +882 -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 +523 -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 +36 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +568 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +55 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +438 -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 -4
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +23 -7
- package/skill-catalogue/advisor/SKILL.md +59 -6
- package/skill-catalogue/architect/SKILL.md +2 -2
- package/skill-catalogue/bouncer/SKILL.md +2 -2
- package/skill-catalogue/builder/SKILL.md +2 -2
- package/skill-catalogue/butler/SKILL.md +2 -2
- package/skill-catalogue/controller/SKILL.md +2 -2
- package/skill-catalogue/customer/SKILL.md +2 -2
- package/skill-catalogue/designer/SKILL.md +4 -2
- package/skill-catalogue/giver/SKILL.md +2 -2
- package/skill-catalogue/influencer/SKILL.md +2 -2
- package/skill-catalogue/inspector/SKILL.md +2 -2
- package/skill-catalogue/integrator/SKILL.md +2 -2
- package/skill-catalogue/keeper/SKILL.md +2 -2
- package/skill-catalogue/launcher/SKILL.md +23 -7
- package/skill-catalogue/locksmith/SKILL.md +2 -2
- package/skill-catalogue/messenger/SKILL.md +2 -2
- package/skill-catalogue/observer/SKILL.md +2 -2
- package/skill-catalogue/publisher/SKILL.md +2 -2
- package/skill-catalogue/starter/SKILL.md +3 -2
- package/skill-catalogue/strategist/SKILL.md +12 -4
- package/skill-catalogue/writer/SKILL.md +2 -2
- package/src/admission-fixture.ts +585 -0
- package/src/admission.ts +819 -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 +170 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +987 -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/existing-declaration-adoption.check.ts +12 -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 +3101 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +74 -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 +660 -0
- package/src/ledger-trust.ts +272 -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 +915 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +1014 -0
- package/src/plan-command.ts +532 -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 +580 -0
- package/src/setup-templates.ts +479 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
// Observing one local clone as a RepositoryObservation (RFC apply-approved-plan
|
|
2
|
+
// §7 V4 and V7, issue #1178): the read side of the apply planner. It reports
|
|
3
|
+
// the committed head of a clean clone, as the clone's object database stores
|
|
4
|
+
// it, whose origin is the repository the inventory id names, and nothing else. The clone's working tree is never
|
|
5
|
+
// read: every byte comes from a git object at the head commit, so an edit that
|
|
6
|
+
// is not committed, or a file that is ignored, is not observed.
|
|
7
|
+
//
|
|
8
|
+
// The clone is untrusted content, and the rule is: this repository's own object
|
|
9
|
+
// database and refs are read through git plumbing, never the working tree and
|
|
10
|
+
// never an alternate object store, `commondir` or submodule repository;
|
|
11
|
+
// anything unusual is refused, not interpreted. Its `.git/config` is read as
|
|
12
|
+
// data, never executed, and refused unless every key is one a plain clone
|
|
13
|
+
// carries; a clone that names an alternate object store, or holds a split
|
|
14
|
+
// index's shared file (git rewrites its mtime on every index read), is refused;
|
|
15
|
+
// only then does a git command run inside it, with every hook, filesystem
|
|
16
|
+
// monitor and pager switched off. git inside the clone reads no configuration but the
|
|
17
|
+
// vetted `.git/config` and fixed `-c` overrides: the system and global
|
|
18
|
+
// configuration and the environment's configuration variables are pinned or
|
|
19
|
+
// removed. What the object database holds is trusted to match its ids, as the
|
|
20
|
+
// clone's object database stores it. The committed
|
|
21
|
+
// tree is listed, and a submodule refused, before any command that reads the
|
|
22
|
+
// working tree runs, and none of those considers a submodule. The origin's tip
|
|
23
|
+
// is asked with `git ls-remote`, which writes no ref and no object, from a
|
|
24
|
+
// directory outside the clone. It performs no write to the clone, and none
|
|
25
|
+
// anywhere except one empty scratch directory under the system temporary
|
|
26
|
+
// directory, removed before it returns. The clone is not locked, and the checks
|
|
27
|
+
// are not repeated after the reads: a process that changes the clone in
|
|
28
|
+
// between goes unnoticed, which changes nothing reported, since every byte is
|
|
29
|
+
// an object at the remote tip's commit (see the README's declared limits).
|
|
30
|
+
//
|
|
31
|
+
// Anything that cannot be established is a skip with a reason id, never a
|
|
32
|
+
// guess. A skip is `violated` when the clone is what it must not be (dirty,
|
|
33
|
+
// on another branch, behind or ahead of the origin, hostile), and
|
|
34
|
+
// `indeterminate` when this module could not tell. It never throws for what a
|
|
35
|
+
// repository contains; a port that throws is a skip too.
|
|
36
|
+
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
import { accessSync, constants, lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
39
|
+
import type { Stats } from "node:fs";
|
|
40
|
+
import { tmpdir } from "node:os";
|
|
41
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
42
|
+
import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
|
|
43
|
+
import { LEDGER_PATH, SKILLS_MANIFEST_PATH, TEMPLATE_PATHS, compareCodeUnits, compareTuples, contentDigest, matchesPathPattern } from "./change-set-contract.js";
|
|
44
|
+
import type {
|
|
45
|
+
DependencyPlacement, DiscoveryRoot, LockfileName, PackageManagerKind, PinnedPackage, ReleaseAgeSurfaceKind, RepositoryProfileObservation, RepositoryVisibility,
|
|
46
|
+
} from "./change-set-contract.js";
|
|
47
|
+
import { parseGitHubRemote } from "./core.js";
|
|
48
|
+
import { sameRepository } from "./identity.js";
|
|
49
|
+
import { readInstalledLedger } from "./ledger-contract.js";
|
|
50
|
+
import { isUnreadable, readLockfile } from "./lockfile-readers.js";
|
|
51
|
+
import type { LockfileFormat, LockfileView, RootDependency } from "./lockfile-readers.js";
|
|
52
|
+
import type { RepositoryObservation, SkippedRepositoryObservation } from "./plan-bundle.js";
|
|
53
|
+
import { loadContract } from "./plan-contract.js";
|
|
54
|
+
import { wouldViolateRootEntries } from "./root-entries.js";
|
|
55
|
+
import { editReleaseAgeExemption } from "./release-age-edit.js";
|
|
56
|
+
import { isSupportedStarterVersion } from "./setup-templates.js";
|
|
57
|
+
|
|
58
|
+
export interface RepositoryObservationPorts {
|
|
59
|
+
/** GitHub's immutable node id for the repository `id` names. May throw or reject. */
|
|
60
|
+
readonly nodeId: (id: string) => string | Promise<string>;
|
|
61
|
+
readonly visibility: (id: string) => RepositoryVisibility | Promise<RepositoryVisibility>;
|
|
62
|
+
/**
|
|
63
|
+
* An origin URL as `owner/name`, or null. Defaults to a parser of exactly the
|
|
64
|
+
* https and ssh GitHub URLs, with which only those two transports fetch; a
|
|
65
|
+
* mapper given here is what lets a local path be fetched, so tests use one for
|
|
66
|
+
* local bare origins.
|
|
67
|
+
*/
|
|
68
|
+
readonly originId?: (url: string) => string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ObserveRepositoryInput {
|
|
72
|
+
/** The inventory id, `owner/name` or a bare name. */
|
|
73
|
+
readonly id: string;
|
|
74
|
+
/** The path of the local clone. */
|
|
75
|
+
readonly clone: string;
|
|
76
|
+
/** Qualifies a bare id; a bare id without it is skipped. */
|
|
77
|
+
readonly hubOwner?: string;
|
|
78
|
+
readonly ports: RepositoryObservationPorts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every reason an observation is skipped, with the verdict it carries. */
|
|
82
|
+
const SKIPS = {
|
|
83
|
+
"clone-missing": "indeterminate",
|
|
84
|
+
"clone-unreadable": "indeterminate",
|
|
85
|
+
"id-owner-unknown": "indeterminate",
|
|
86
|
+
"remote-tip-unreadable": "indeterminate",
|
|
87
|
+
"node-id-unavailable": "indeterminate",
|
|
88
|
+
"visibility-unavailable": "indeterminate",
|
|
89
|
+
"tree-too-large": "indeterminate",
|
|
90
|
+
"submodule-present": "indeterminate",
|
|
91
|
+
"manifest-unreadable": "indeterminate",
|
|
92
|
+
"lockfile-ambiguous": "indeterminate",
|
|
93
|
+
"package-manager-unknown": "indeterminate",
|
|
94
|
+
"lockfile-unreadable": "indeterminate",
|
|
95
|
+
"release-age-surface-invalid": "indeterminate",
|
|
96
|
+
"agents-link-unreportable": "indeterminate",
|
|
97
|
+
"observation-too-large": "indeterminate",
|
|
98
|
+
"case-variant-owned-path": "indeterminate",
|
|
99
|
+
"ledger-unreadable": "indeterminate",
|
|
100
|
+
"profile-ambiguous": "indeterminate",
|
|
101
|
+
"invalid-id": "violated",
|
|
102
|
+
"clone-config-unsafe": "violated",
|
|
103
|
+
"origin-mismatch": "violated",
|
|
104
|
+
"not-on-default-branch": "violated",
|
|
105
|
+
"remote-tip-mismatch": "violated",
|
|
106
|
+
"working-tree-dirty": "violated",
|
|
107
|
+
"package-manager-conflict": "violated",
|
|
108
|
+
} as const satisfies Record<string, "violated" | "indeterminate">;
|
|
109
|
+
|
|
110
|
+
type SkipReason = keyof typeof SKIPS;
|
|
111
|
+
|
|
112
|
+
/** Ends the observation with a skip; caught in observeRepository() and nowhere else. */
|
|
113
|
+
class Refusal extends Error {
|
|
114
|
+
constructor(readonly reason: SkipReason) {
|
|
115
|
+
super(reason);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function refuse(reason: SkipReason): never {
|
|
120
|
+
throw new Refusal(reason);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const KIB = 1024;
|
|
124
|
+
const MIB = 1024 * KIB;
|
|
125
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
126
|
+
const SMALL_OUTPUT = MIB;
|
|
127
|
+
const MAX_TREE_BYTES = 64 * MIB;
|
|
128
|
+
const MAX_TREE_ENTRIES = 200_000;
|
|
129
|
+
const MAX_BLOB_BYTES = 4 * MIB;
|
|
130
|
+
const MAX_LOCKFILE_BYTES = 32 * MIB;
|
|
131
|
+
const MAX_READ_BYTES = 64 * MIB;
|
|
132
|
+
const MAX_OWNED_FILES = 5000;
|
|
133
|
+
const MAX_JSON_DEPTH = 256;
|
|
134
|
+
|
|
135
|
+
const ID_PATTERN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/)?(?!\.\.?$)[A-Za-z0-9._-]+$/u;
|
|
136
|
+
const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u;
|
|
137
|
+
const NODE_ID_PATTERN = /^[A-Za-z0-9_=+/-]+$/u;
|
|
138
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
|
|
139
|
+
const BRANCH_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u;
|
|
140
|
+
const INTEGRITY = /^sha512-[A-Za-z0-9+/]{86}==$/u;
|
|
141
|
+
const EXACT_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u;
|
|
142
|
+
const PACKAGE_MANAGER_FIELD = /^(npm|pnpm|yarn)@\S+$/u;
|
|
143
|
+
const AGENTS_LINK = /^\.agents(?:\/skills(?:\/clossys-[^/\\\u0000-\u001f]+)?)?$/u;
|
|
144
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
145
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/u;
|
|
146
|
+
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
147
|
+
|
|
148
|
+
const LOCKFILES: readonly { readonly name: Exclude<LockfileName, "none">; readonly manager: Exclude<PackageManagerKind, "none"> }[] = [
|
|
149
|
+
{ name: "package-lock.json", manager: "npm" },
|
|
150
|
+
{ name: "pnpm-lock.yaml", manager: "pnpm" },
|
|
151
|
+
{ name: "yarn.lock", manager: "yarn" },
|
|
152
|
+
];
|
|
153
|
+
/** Lockfiles of managers this module does not read: their presence makes the package manager ambiguous. */
|
|
154
|
+
const OTHER_LOCKFILES: readonly string[] = ["npm-shrinkwrap.json", "bun.lock", "bun.lockb"];
|
|
155
|
+
const RELEASE_AGE_SURFACES: readonly { readonly surface: ReleaseAgeSurfaceKind; readonly path: string }[] = [
|
|
156
|
+
{ surface: "pnpm-workspace", path: "pnpm-workspace.yaml" },
|
|
157
|
+
{ surface: "yarnrc", path: ".yarnrc.yml" },
|
|
158
|
+
{ surface: "npmrc", path: ".npmrc" },
|
|
159
|
+
];
|
|
160
|
+
const DISCOVERY: readonly DiscoveryRoot[] = [".claude/skills", ".cursor/skills"];
|
|
161
|
+
const PROFILE_ROOTS: readonly string[] = ["clossys", ".agents", ".claude", ".cursor"];
|
|
162
|
+
/** The roots a setup set adds beside them: its workflows and scripts, and its Starter request. */
|
|
163
|
+
const SETUP_PROFILE_ROOTS: readonly string[] = [".github", ".starter"];
|
|
164
|
+
const NPMRC_PATH = ".npmrc";
|
|
165
|
+
const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml";
|
|
166
|
+
const PROFILE_NAMES: ReadonlySet<string> = new Set(["repository-profile.json", "repository-declaration.json"]);
|
|
167
|
+
const PROFILE_FIRST = "governance/repository-profile.json";
|
|
168
|
+
const PROFILE_SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([".git", "node_modules", "dist", "build", ".next", "coverage", ".turbo", ".cache"]);
|
|
169
|
+
const STARTER = `${PACKAGE_SCOPE.scope}/starter`;
|
|
170
|
+
|
|
171
|
+
// ---- git ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const GIT_OPTIONS: readonly string[] = [
|
|
174
|
+
"-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "core.pager=cat", "-c", "core.untrackedCache=false", "-c", "protocol.ext.allow=never", "--no-pager",
|
|
175
|
+
];
|
|
176
|
+
/** Variables that would point git at another repository, object store or configuration than the clone's own. */
|
|
177
|
+
const GIT_REMOVED_ENV: readonly string[] = [
|
|
178
|
+
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_REPLACE_REF_BASE", "GIT_GRAFT_FILE",
|
|
179
|
+
"GIT_SHALLOW_FILE", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_SYSTEM", "GIT_ATTR_SOURCE", "GIT_EXTERNAL_DIFF",
|
|
180
|
+
];
|
|
181
|
+
/**
|
|
182
|
+
* The configuration variables `git ls-remote` alone receives: `GIT_CONFIG_COUNT`
|
|
183
|
+
* and the numbered key and value variables it reads. They are the operator's own
|
|
184
|
+
* environment, the same trust as the global git config file `ls-remote` keeps,
|
|
185
|
+
* and they cannot loosen a fixed `-c` override or `GIT_ALLOW_PROTOCOL`, which
|
|
186
|
+
* both win over them.
|
|
187
|
+
*/
|
|
188
|
+
const GIT_COUNTED_CONFIG_ENV = /^GIT_CONFIG_(?:COUNT|(?:KEY|VALUE)_.*)$/iu;
|
|
189
|
+
/** git's own test switches (`GIT_TEST_SPLIT_INDEX` forces a split index on an index write, for one): never passed on to any git command. */
|
|
190
|
+
const GIT_TEST_ENV = /^GIT_TEST_/iu;
|
|
191
|
+
/** The transports a fetch may use when the default origin parser named the origin: exactly the two GitHub serves. */
|
|
192
|
+
const REMOTE_PROTOCOLS = "https:ssh";
|
|
193
|
+
|
|
194
|
+
interface GitRun {
|
|
195
|
+
/** The exit status, or -1 when git did not run to completion (no git, a timeout, output over the bound). */
|
|
196
|
+
readonly status: number;
|
|
197
|
+
readonly stdout: Buffer;
|
|
198
|
+
/** Whether the output was cut off at the bound. */
|
|
199
|
+
readonly overflow: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The git executable as an absolute path, found once per PATH value among the
|
|
204
|
+
* entries that are absolute and not empty. An empty entry, or a relative one,
|
|
205
|
+
* would search the directory git runs in, and for a command run inside the
|
|
206
|
+
* clone that directory is the repository's own content.
|
|
207
|
+
*/
|
|
208
|
+
let resolvedGit: { readonly searched: string; readonly binary: string | null } | null = null;
|
|
209
|
+
|
|
210
|
+
function gitBinary(): string | null {
|
|
211
|
+
const searched = process.env.PATH ?? process.env.Path ?? "";
|
|
212
|
+
if (resolvedGit !== null && resolvedGit.searched === searched) return resolvedGit.binary;
|
|
213
|
+
const name = process.platform === "win32" ? "git.exe" : "git";
|
|
214
|
+
let binary: string | null = null;
|
|
215
|
+
for (const directory of searched.split(delimiter)) {
|
|
216
|
+
if (directory === "" || !isAbsolute(directory)) continue;
|
|
217
|
+
const candidate = join(directory, name);
|
|
218
|
+
try {
|
|
219
|
+
if (!statSync(candidate).isFile()) continue;
|
|
220
|
+
accessSync(candidate, constants.X_OK);
|
|
221
|
+
binary = candidate;
|
|
222
|
+
break;
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
resolvedGit = { searched, binary };
|
|
228
|
+
return binary;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* git inside the clone reads no configuration but the vetted `.git/config` and
|
|
233
|
+
* fixed `-c` overrides: the system and global configuration are switched off,
|
|
234
|
+
* and every other source the environment names is removed
|
|
235
|
+
* (`GIT_CONFIG_COUNT` and its numbered key and value variables,
|
|
236
|
+
* `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_SYSTEM`). `ls-remote`, which runs outside
|
|
237
|
+
* the clone, keeps the operator's global and system git config files
|
|
238
|
+
* (credential helpers, proxy) and is given `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`
|
|
239
|
+
* and `GIT_CONFIG_VALUE_n`, so an origin that needs credentials supplied through
|
|
240
|
+
* them is observed; `GIT_CONFIG_PARAMETERS` (git's own encoding of `-c` for its
|
|
241
|
+
* children, not an interface) and `GIT_CONFIG_SYSTEM` are not forwarded to it,
|
|
242
|
+
* and an origin that needs either is skipped as `remote-tip-unreadable`. git's
|
|
243
|
+
* `GIT_TEST_*` switches reach no command.
|
|
244
|
+
*/
|
|
245
|
+
function gitEnvironment(cwd: string, protocols: string, remote: boolean): NodeJS.ProcessEnv {
|
|
246
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
247
|
+
for (const name of GIT_REMOVED_ENV) delete env[name];
|
|
248
|
+
for (const name of Object.keys(env)) {
|
|
249
|
+
if (GIT_TEST_ENV.test(name) || (!remote && GIT_COUNTED_CONFIG_ENV.test(name))) delete env[name];
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
...env,
|
|
253
|
+
...(remote ? {} : { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }),
|
|
254
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
255
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
256
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
257
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
258
|
+
GIT_PAGER: "cat",
|
|
259
|
+
GIT_ALLOW_PROTOCOL: protocols,
|
|
260
|
+
// A repository above `cwd` is never discovered: a directory beside the clone cannot stand in for it.
|
|
261
|
+
GIT_CEILING_DIRECTORIES: dirname(cwd),
|
|
262
|
+
LC_ALL: "C",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function git(cwd: string, args: readonly string[], maxBuffer: number = SMALL_OUTPUT, protocols: string = REMOTE_PROTOCOLS, remote = false): GitRun {
|
|
267
|
+
const binary = gitBinary();
|
|
268
|
+
if (binary === null) return { status: -1, stdout: Buffer.alloc(0), overflow: false };
|
|
269
|
+
const run = spawnSync(binary, [...GIT_OPTIONS, ...args], {
|
|
270
|
+
cwd,
|
|
271
|
+
env: gitEnvironment(cwd, protocols, remote),
|
|
272
|
+
shell: false,
|
|
273
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
274
|
+
timeout: GIT_TIMEOUT_MS,
|
|
275
|
+
maxBuffer,
|
|
276
|
+
encoding: "buffer",
|
|
277
|
+
windowsHide: true,
|
|
278
|
+
});
|
|
279
|
+
const overflow = (run.error as NodeJS.ErrnoException | undefined)?.code === "ENOBUFS";
|
|
280
|
+
return { status: run.error === undefined ? (run.status ?? -1) : -1, stdout: run.stdout ?? Buffer.alloc(0), overflow };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function gitText(cwd: string, args: readonly string[]): string | null {
|
|
284
|
+
const run = git(cwd, args);
|
|
285
|
+
return run.status === 0 && !run.overflow ? run.stdout.toString("utf8") : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- the id and the clone ---------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function checkId(id: string, hubOwner: string | undefined): void {
|
|
291
|
+
if (!ID_PATTERN.test(id)) refuse("invalid-id");
|
|
292
|
+
if (!id.includes("/") && (hubOwner === undefined || !OWNER_PATTERN.test(hubOwner))) refuse("id-owner-unknown");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The clone's real path, once it is a real directory holding a real `.git` directory of its own, with an object database of its own. */
|
|
296
|
+
function locateClone(clone: string): string {
|
|
297
|
+
const stat = statOf(clone, "clone-missing");
|
|
298
|
+
if (!stat.isDirectory()) refuse("clone-unreadable");
|
|
299
|
+
const gitDir = join(clone, ".git");
|
|
300
|
+
if (!statOf(gitDir, "clone-unreadable").isDirectory()) refuse("clone-unreadable");
|
|
301
|
+
// A regular config file, and no `commondir` redirecting git to another directory's configuration.
|
|
302
|
+
if (!statOf(join(gitDir, "config"), "clone-unreadable").isFile()) refuse("clone-unreadable");
|
|
303
|
+
if (isPresent(join(gitDir, "commondir"))) refuse("clone-unreadable");
|
|
304
|
+
// Objects that are the clone's own: a real directory, and no list of other stores to read from.
|
|
305
|
+
if (!statOf(join(gitDir, "objects"), "clone-unreadable").isDirectory()) refuse("clone-unreadable");
|
|
306
|
+
for (const name of ["alternates", "http-alternates"]) if (isPresent(join(gitDir, "objects", "info", name))) refuse("clone-config-unsafe");
|
|
307
|
+
// A split index: git freshens the shared file's mtime on every index read, which would be a write to the clone.
|
|
308
|
+
if (holdsSharedIndex(gitDir)) refuse("clone-config-unsafe");
|
|
309
|
+
try {
|
|
310
|
+
return realpathSync(clone);
|
|
311
|
+
} catch {
|
|
312
|
+
return refuse("clone-unreadable");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Whether the git directory holds a `sharedindex.*` entry of any type, by listing it: no shell glob, and no git command. */
|
|
317
|
+
function holdsSharedIndex(gitDir: string): boolean {
|
|
318
|
+
try {
|
|
319
|
+
return readdirSync(gitDir).some((name) => name.toLowerCase().startsWith("sharedindex."));
|
|
320
|
+
} catch {
|
|
321
|
+
return refuse("clone-unreadable");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Whether anything, of any type, is at `path`; an error other than absence is a refusal. */
|
|
326
|
+
function isPresent(path: string): boolean {
|
|
327
|
+
try {
|
|
328
|
+
lstatSync(path);
|
|
329
|
+
return true;
|
|
330
|
+
} catch (cause) {
|
|
331
|
+
const code = (cause as NodeJS.ErrnoException).code;
|
|
332
|
+
if (code === "ENOENT" || code === "ENOTDIR") return false;
|
|
333
|
+
return refuse("clone-unreadable");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function statOf(path: string, absent: SkipReason): Stats {
|
|
338
|
+
try {
|
|
339
|
+
const stat = lstatSync(path);
|
|
340
|
+
if (stat.isSymbolicLink()) refuse("clone-unreadable");
|
|
341
|
+
return stat;
|
|
342
|
+
} catch (cause) {
|
|
343
|
+
if (cause instanceof Refusal) throw cause;
|
|
344
|
+
const code = (cause as NodeJS.ErrnoException).code;
|
|
345
|
+
return refuse(code === "ENOENT" || code === "ENOTDIR" ? absent : "clone-unreadable");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---- .git/config, read as data -------------------------------------------------
|
|
350
|
+
|
|
351
|
+
interface ConfigEntry {
|
|
352
|
+
readonly key: string;
|
|
353
|
+
/** Null for a key written without a value. */
|
|
354
|
+
readonly value: string | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const CONFIG_CORE: ReadonlySet<string> = new Set(["repositoryformatversion", "filemode", "bare", "logallrefupdates", "ignorecase", "precomposeunicode", "symlinks"]);
|
|
358
|
+
const CONFIG_REMOTE: ReadonlySet<string> = new Set(["url", "fetch", "gh-resolved"]);
|
|
359
|
+
const CONFIG_BRANCH: ReadonlySet<string> = new Set(["remote", "merge", "rebase", "vscode-merge-base", "github-pr-owner-number"]);
|
|
360
|
+
const CONFIG_USER: ReadonlySet<string> = new Set(["name", "email"]);
|
|
361
|
+
|
|
362
|
+
function readConfig(clone: string, neutral: string): ConfigEntry[] {
|
|
363
|
+
const run = git(neutral, ["config", "--file", join(clone, ".git", "config"), "--no-includes", "--list", "-z"]);
|
|
364
|
+
if (run.status !== 0 || run.overflow) return refuse("clone-unreadable");
|
|
365
|
+
let text: string;
|
|
366
|
+
try {
|
|
367
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(run.stdout);
|
|
368
|
+
} catch {
|
|
369
|
+
return refuse("clone-config-unsafe");
|
|
370
|
+
}
|
|
371
|
+
const records = text.split("\0");
|
|
372
|
+
if (records.pop() !== "") return refuse("clone-unreadable");
|
|
373
|
+
return records.map((record) => {
|
|
374
|
+
const newline = record.indexOf("\n");
|
|
375
|
+
return newline === -1 ? { key: record, value: null } : { key: record.slice(0, newline), value: record.slice(newline + 1) };
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Whether a key is one a plain clone carries. What is not recognised is refused: the set of keys that run something is unbounded. */
|
|
380
|
+
function isPlainCloneKey({ key, value }: ConfigEntry): boolean {
|
|
381
|
+
const first = key.indexOf(".");
|
|
382
|
+
const last = key.lastIndexOf(".");
|
|
383
|
+
if (first < 1 || last === key.length - 1) return false;
|
|
384
|
+
const section = key.slice(0, first).toLowerCase();
|
|
385
|
+
const variable = key.slice(last + 1).toLowerCase();
|
|
386
|
+
const subsection = first === last ? null : key.slice(first + 1, last);
|
|
387
|
+
if (section === "core") {
|
|
388
|
+
if (subsection !== null || !CONFIG_CORE.has(variable)) return false;
|
|
389
|
+
if (variable === "repositoryformatversion") return value === "0";
|
|
390
|
+
if (variable === "bare") return value === "false";
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
if (section === "user") return subsection === null && CONFIG_USER.has(variable);
|
|
394
|
+
if (subsection === null || subsection === "") return false;
|
|
395
|
+
if (section === "remote") return CONFIG_REMOTE.has(variable);
|
|
396
|
+
if (section === "branch") return CONFIG_BRANCH.has(variable);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function checkConfig(entries: readonly ConfigEntry[]): void {
|
|
401
|
+
if (!entries.every(isPlainCloneKey)) refuse("clone-config-unsafe");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- origin and the remote tip -------------------------------------------------
|
|
405
|
+
|
|
406
|
+
function originUrl(entries: readonly ConfigEntry[]): string {
|
|
407
|
+
const urls = entries.filter((entry) => entry.key === "remote.origin.url");
|
|
408
|
+
const only = urls.length === 1 ? urls[0]!.value : null;
|
|
409
|
+
// git would read a value that is padded or holds a control character as a different URL than the mapper judged.
|
|
410
|
+
if (only === null || only === "" || only !== only.trim() || CONTROL.test(only)) return refuse("origin-mismatch");
|
|
411
|
+
return only;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The exact forms of a GitHub origin: https, scp-style ssh and `ssh://`, with an owner and a name and nothing more. */
|
|
415
|
+
const GITHUB_HTTPS = /^https:\/\/github\.com\/([^/?#@:\s]+)\/([^/?#@:\s]+)$/u;
|
|
416
|
+
const GITHUB_SSH = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([^/?#@:\s]+)\/([^/?#@:\s]+)$/u;
|
|
417
|
+
|
|
418
|
+
/** An origin URL as `owner/name` when it is exactly an https or ssh GitHub URL; any other scheme, host, port, user, extra path, query or fragment is null. */
|
|
419
|
+
export function defaultOriginId(url: string): string | null {
|
|
420
|
+
const match = GITHUB_HTTPS.exec(url) ?? GITHUB_SSH.exec(url);
|
|
421
|
+
if (match === null) return null;
|
|
422
|
+
const parsed = parseGitHubRemote(`https://github.com/${match[1]}/${match[2]}`);
|
|
423
|
+
return parsed === null ? null : `${parsed.owner}/${parsed.repository}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function checkOrigin(url: string, id: string, hubOwner: string | undefined, originId: (url: string) => string | null): void {
|
|
427
|
+
let named: string | null;
|
|
428
|
+
try {
|
|
429
|
+
named = originId(url);
|
|
430
|
+
} catch {
|
|
431
|
+
return refuse("origin-mismatch");
|
|
432
|
+
}
|
|
433
|
+
if (typeof named !== "string" || !ID_PATTERN.test(named) || !named.includes("/") || !sameRepository(named, id, hubOwner)) refuse("origin-mismatch");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function safeBranch(branch: string): boolean {
|
|
437
|
+
if (!BRANCH_SHAPE.test(branch) || /[:+*]/u.test(branch)) return false;
|
|
438
|
+
return !branch.includes("..") && !branch.includes("//") && !branch.endsWith("/") && !branch.includes(".lock");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The origin's default branch and its tip, from `git ls-remote`: it writes no ref and no object anywhere. */
|
|
442
|
+
function remoteTip(url: string, neutral: string, protocols: string): { readonly branch: string; readonly oid: string } {
|
|
443
|
+
const run = git(neutral, ["ls-remote", "--symref", "--", url, "HEAD"], SMALL_OUTPUT, protocols, true);
|
|
444
|
+
if (run.status !== 0 || run.overflow) return refuse("remote-tip-unreadable");
|
|
445
|
+
let text: string;
|
|
446
|
+
try {
|
|
447
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(run.stdout);
|
|
448
|
+
} catch {
|
|
449
|
+
return refuse("remote-tip-unreadable");
|
|
450
|
+
}
|
|
451
|
+
const symrefs: string[] = [];
|
|
452
|
+
const oids: string[] = [];
|
|
453
|
+
for (const line of text.split("\n")) {
|
|
454
|
+
if (line === "") continue;
|
|
455
|
+
const symref = /^ref: (\S+)\t(\S+)$/u.exec(line);
|
|
456
|
+
const oid = /^([0-9a-f]+)\t(\S+)$/u.exec(line);
|
|
457
|
+
if (symref !== null) {
|
|
458
|
+
if (symref[2] === "HEAD") symrefs.push(symref[1]!);
|
|
459
|
+
} else if (oid !== null) {
|
|
460
|
+
if (oid[2] === "HEAD") oids.push(oid[1]!);
|
|
461
|
+
} else return refuse("remote-tip-unreadable");
|
|
462
|
+
}
|
|
463
|
+
const target = symrefs.length === 1 ? symrefs[0]! : "";
|
|
464
|
+
const branch = target.startsWith("refs/heads/") ? target.slice("refs/heads/".length) : "";
|
|
465
|
+
const tip = oids.length === 1 ? oids[0]! : "";
|
|
466
|
+
if (!safeBranch(branch) || !OBJECT_ID.test(tip)) return refuse("remote-tip-unreadable");
|
|
467
|
+
return { branch, oid: tip };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- the local clone against the remote tip ----------------------------------------
|
|
471
|
+
|
|
472
|
+
/** git's own view of the clone: its work tree is `clone`, its git directory is `clone/.git`, and it is not bare. Runs only after the configuration passed. */
|
|
473
|
+
function checkRepositoryShape(clone: string): void {
|
|
474
|
+
const text = gitText(clone, ["rev-parse", "--show-toplevel", "--is-bare-repository", "--absolute-git-dir"]);
|
|
475
|
+
const lines = text === null ? [] : text.split("\n");
|
|
476
|
+
if (lines.length !== 4 || lines[3] !== "") return refuse("clone-unreadable");
|
|
477
|
+
try {
|
|
478
|
+
if (realpathSync(lines[0]!) !== clone || lines[1] !== "false" || realpathSync(lines[2]!) !== join(clone, ".git")) refuse("clone-unreadable");
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
if (cause instanceof Refusal) throw cause;
|
|
481
|
+
refuse("clone-unreadable");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function checkHead(clone: string, tip: { readonly branch: string; readonly oid: string }): void {
|
|
486
|
+
const symbolic = git(clone, ["symbolic-ref", "-q", "HEAD"]);
|
|
487
|
+
if (symbolic.status === 1) refuse("not-on-default-branch");
|
|
488
|
+
if (symbolic.status !== 0 || symbolic.overflow) refuse("clone-unreadable");
|
|
489
|
+
if (symbolic.stdout.toString("utf8").trim() !== `refs/heads/${tip.branch}`) refuse("not-on-default-branch");
|
|
490
|
+
const head = git(clone, ["rev-parse", "--verify", "-q", `refs/heads/${tip.branch}^{commit}`]);
|
|
491
|
+
const local = head.status === 0 && !head.overflow ? head.stdout.toString("utf8").trim() : "";
|
|
492
|
+
if (!OBJECT_ID.test(local)) refuse("clone-unreadable");
|
|
493
|
+
if (local !== tip.oid) refuse("remote-tip-mismatch");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The index, read as data: no entry is a submodule, and none carries a flag
|
|
498
|
+
* that hides an edit from `git status` (skip-worktree, assume-unchanged) or is
|
|
499
|
+
* unmerged. Reads the index file only, and neither the working tree nor a
|
|
500
|
+
* submodule.
|
|
501
|
+
*/
|
|
502
|
+
function checkIndex(clone: string): void {
|
|
503
|
+
const run = git(clone, ["ls-files", "-v", "-s", "-z"], MAX_TREE_BYTES);
|
|
504
|
+
if (run.overflow) refuse("tree-too-large");
|
|
505
|
+
if (run.status !== 0) refuse("clone-unreadable");
|
|
506
|
+
let submodule = false;
|
|
507
|
+
let hidden = false;
|
|
508
|
+
let position = 0;
|
|
509
|
+
while (position < run.stdout.length) {
|
|
510
|
+
let end = run.stdout.indexOf(0, position);
|
|
511
|
+
if (end === -1) end = run.stdout.length;
|
|
512
|
+
const record = run.stdout.subarray(position, end);
|
|
513
|
+
position = end + 1;
|
|
514
|
+
const tab = record.indexOf(9);
|
|
515
|
+
const header = tab === -1 ? null : INDEX_HEADER.exec(record.subarray(0, tab).toString("latin1"));
|
|
516
|
+
if (header === null) return refuse("clone-unreadable");
|
|
517
|
+
const [, tag, mode, stage] = header as unknown as [string, string, string, string];
|
|
518
|
+
if (mode === "160000") submodule = true;
|
|
519
|
+
// `H` is a cached entry with nothing hidden; anything else is a flag that keeps status from looking.
|
|
520
|
+
if (tag !== "H" || stage !== "0") hidden = true;
|
|
521
|
+
}
|
|
522
|
+
if (submodule) refuse("submodule-present");
|
|
523
|
+
if (hidden) refuse("working-tree-dirty");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const INDEX_HEADER = /^(\S) (\d{6}) (?:[0-9a-f]{40}|[0-9a-f]{64}) (\d)$/u;
|
|
527
|
+
|
|
528
|
+
/** Runs after the tree was listed and checkIndex refused a submodule: it never considers one, since git would open its own repository and read its configuration. */
|
|
529
|
+
function checkClean(clone: string): void {
|
|
530
|
+
const run = git(clone, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"], 16 * MIB);
|
|
531
|
+
// Output past the bound is output: a status that long is not clean.
|
|
532
|
+
if (run.overflow || (run.status === 0 && run.stdout.length > 0)) refuse("working-tree-dirty");
|
|
533
|
+
if (run.status !== 0) refuse("clone-unreadable");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---- ports ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
async function readPorts(id: string, ports: RepositoryObservationPorts): Promise<{ readonly nodeId: string; readonly visibility: RepositoryVisibility }> {
|
|
539
|
+
let nodeId: unknown;
|
|
540
|
+
try {
|
|
541
|
+
nodeId = await ports.nodeId(id);
|
|
542
|
+
} catch {
|
|
543
|
+
return refuse("node-id-unavailable");
|
|
544
|
+
}
|
|
545
|
+
if (typeof nodeId !== "string" || !NODE_ID_PATTERN.test(nodeId)) return refuse("node-id-unavailable");
|
|
546
|
+
let visibility: unknown;
|
|
547
|
+
try {
|
|
548
|
+
visibility = await ports.visibility(id);
|
|
549
|
+
} catch {
|
|
550
|
+
return refuse("visibility-unavailable");
|
|
551
|
+
}
|
|
552
|
+
if (visibility !== "private" && visibility !== "internal" && visibility !== "public") return refuse("visibility-unavailable");
|
|
553
|
+
return { nodeId, visibility };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---- the committed tree ---------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
interface TreeEntry {
|
|
559
|
+
readonly path: string;
|
|
560
|
+
/** 100644, 100755 or 120000: a submodule is refused before a tree is returned. */
|
|
561
|
+
readonly mode: string;
|
|
562
|
+
readonly oid: string;
|
|
563
|
+
readonly size: number;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
interface TreeNode {
|
|
567
|
+
readonly children: Map<string, TreeNode>;
|
|
568
|
+
entry: TreeEntry | null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
interface Tree {
|
|
572
|
+
readonly entries: readonly TreeEntry[];
|
|
573
|
+
readonly root: TreeNode;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const LS_TREE_HEADER = /^(\d{6}) (blob|commit) ([0-9a-f]{40}|[0-9a-f]{64}) +(\d+|-)$/u;
|
|
577
|
+
const REGULAR_MODES: ReadonlySet<string> = new Set(["100644", "100755"]);
|
|
578
|
+
|
|
579
|
+
function isRegular(entry: TreeEntry | null): entry is TreeEntry {
|
|
580
|
+
return entry !== null && REGULAR_MODES.has(entry.mode);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function listTree(clone: string, commit: string): Tree {
|
|
584
|
+
const run = git(clone, ["ls-tree", "-r", "-z", "-l", "--full-tree", commit], MAX_TREE_BYTES);
|
|
585
|
+
if (run.overflow) return refuse("tree-too-large");
|
|
586
|
+
if (run.status !== 0) return refuse("clone-unreadable");
|
|
587
|
+
const entries: TreeEntry[] = [];
|
|
588
|
+
const root: TreeNode = { children: new Map(), entry: null };
|
|
589
|
+
const out = run.stdout;
|
|
590
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
591
|
+
let submodule = false;
|
|
592
|
+
let position = 0;
|
|
593
|
+
while (position < out.length) {
|
|
594
|
+
let end = out.indexOf(0, position);
|
|
595
|
+
if (end === -1) end = out.length;
|
|
596
|
+
if (entries.length >= MAX_TREE_ENTRIES) return refuse("tree-too-large");
|
|
597
|
+
const record = out.subarray(position, end);
|
|
598
|
+
position = end + 1;
|
|
599
|
+
const tab = record.indexOf(9);
|
|
600
|
+
const header = tab === -1 ? null : LS_TREE_HEADER.exec(record.subarray(0, tab).toString("latin1"));
|
|
601
|
+
if (header === null) return refuse("clone-unreadable");
|
|
602
|
+
const [, mode, type, oid, size] = header as unknown as [string, string, string, string, string];
|
|
603
|
+
let path: string;
|
|
604
|
+
try {
|
|
605
|
+
path = decoder.decode(record.subarray(tab + 1));
|
|
606
|
+
} catch {
|
|
607
|
+
return refuse("clone-unreadable");
|
|
608
|
+
}
|
|
609
|
+
if (mode === "160000" && type === "commit") {
|
|
610
|
+
submodule = true;
|
|
611
|
+
entries.push({ path, mode, oid, size: 0 });
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (type !== "blob" || size === "-" || (mode !== "100644" && mode !== "100755" && mode !== "120000")) return refuse("clone-unreadable");
|
|
615
|
+
const entry: TreeEntry = { path, mode, oid, size: Number(size) };
|
|
616
|
+
insertEntry(root, entry);
|
|
617
|
+
entries.push(entry);
|
|
618
|
+
}
|
|
619
|
+
if (submodule) return refuse("submodule-present");
|
|
620
|
+
return { entries, root };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function insertEntry(root: TreeNode, entry: TreeEntry): void {
|
|
624
|
+
let node = root;
|
|
625
|
+
for (const segment of entry.path.split("/")) {
|
|
626
|
+
// A file and a directory at one path, or one path twice, is a tree git would not have written.
|
|
627
|
+
if (node.entry !== null) refuse("clone-unreadable");
|
|
628
|
+
let child = node.children.get(segment);
|
|
629
|
+
if (child === undefined) {
|
|
630
|
+
child = { children: new Map(), entry: null };
|
|
631
|
+
node.children.set(segment, child);
|
|
632
|
+
}
|
|
633
|
+
node = child;
|
|
634
|
+
}
|
|
635
|
+
if (node.entry !== null || node.children.size > 0) refuse("clone-unreadable");
|
|
636
|
+
node.entry = entry;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The node at exactly `path`, or null. */
|
|
640
|
+
function nodeAt(tree: Tree, path: string): TreeNode | null {
|
|
641
|
+
let node: TreeNode | undefined = tree.root;
|
|
642
|
+
for (const segment of path.split("/")) {
|
|
643
|
+
node = node.children.get(segment);
|
|
644
|
+
if (node === undefined) return null;
|
|
645
|
+
}
|
|
646
|
+
return node;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** What is at exactly `path`: nothing, a regular blob, or anything else (a symbolic link or a directory). */
|
|
650
|
+
type Lookup = { readonly kind: "absent" } | { readonly kind: "regular"; readonly entry: TreeEntry } | { readonly kind: "other" };
|
|
651
|
+
|
|
652
|
+
function lookup(tree: Tree, path: string): Lookup {
|
|
653
|
+
const node = nodeAt(tree, path);
|
|
654
|
+
if (node === null) return { kind: "absent" };
|
|
655
|
+
return isRegular(node.entry) ? { kind: "regular", entry: node.entry } : { kind: "other" };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---- letter case ------------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* How a case-insensitive or normalising file system may read a name: each
|
|
662
|
+
* segment decomposed, case-folded through both cases, decomposed again, and
|
|
663
|
+
* without trailing dots and spaces.
|
|
664
|
+
*/
|
|
665
|
+
export function fold(path: string): string {
|
|
666
|
+
return path
|
|
667
|
+
.split("/")
|
|
668
|
+
.map((segment) => {
|
|
669
|
+
const folded = segment.normalize("NFD").toLowerCase().toUpperCase().toLowerCase().normalize("NFD");
|
|
670
|
+
// A loop, not a pattern: `/[. ]+$/` retries from every dot or space of a long run.
|
|
671
|
+
let end = folded.length;
|
|
672
|
+
while (end > 0 && (folded[end - 1] === "." || folded[end - 1] === " ")) end -= 1;
|
|
673
|
+
return folded.slice(0, end);
|
|
674
|
+
})
|
|
675
|
+
.join("/");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The read of the packed contract that INTRODUCIBLE_ROOTS makes, kept whole: every path the apply flow may own. */
|
|
679
|
+
const OWNED_PATTERNS: readonly string[] = (() => {
|
|
680
|
+
const definitions = loadContract("repository-change-set.json").definitions as Record<string, { allOf?: { enum?: unknown }[] }> | undefined;
|
|
681
|
+
const patterns = definitions?.ownedPattern?.allOf?.[1]?.enum;
|
|
682
|
+
if (!Array.isArray(patterns) || !patterns.every((entry) => typeof entry === "string")) throw new Error("the packed change-set contract has no ownedPattern list");
|
|
683
|
+
return patterns as string[];
|
|
684
|
+
})();
|
|
685
|
+
|
|
686
|
+
interface PatternSet {
|
|
687
|
+
readonly patterns: readonly { readonly pattern: string; readonly first: string; readonly last: string }[];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function patternSet(patterns: readonly string[]): PatternSet {
|
|
691
|
+
return {
|
|
692
|
+
patterns: patterns.map((pattern) => {
|
|
693
|
+
const segments = pattern.split("/");
|
|
694
|
+
return { pattern, first: segments[0]!, last: segments[segments.length - 1]! };
|
|
695
|
+
}),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const OWNED = patternSet(OWNED_PATTERNS);
|
|
700
|
+
const OWNED_FOLDED = patternSet(OWNED_PATTERNS.map(fold));
|
|
701
|
+
|
|
702
|
+
/** Whether `path` matches any pattern; a literal first or last segment of a pattern is checked first, only to avoid matching every path against every pattern. */
|
|
703
|
+
function matchesAny(path: string, set: PatternSet): boolean {
|
|
704
|
+
const segments = path.split("/");
|
|
705
|
+
const last = segments[segments.length - 1]!;
|
|
706
|
+
return set.patterns.some(({ pattern, first, last: tail }) => {
|
|
707
|
+
if (first !== "**" && !first.includes("*") && first !== segments[0]) return false;
|
|
708
|
+
if (tail !== "**" && !tail.includes("*") && tail !== last) return false;
|
|
709
|
+
return matchesPathPattern(path, pattern);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Whether `entry` is a link `linkedAgentsPaths` reports under its folded name, so it is reported there rather than refused here. */
|
|
714
|
+
function isReportedAgentsLink(entry: TreeEntry): boolean {
|
|
715
|
+
if (entry.mode !== "120000") return false;
|
|
716
|
+
const parts = fold(entry.path).split("/");
|
|
717
|
+
return parts.length === 3 && parts[0] === ".agents" && parts[1] === "skills" && parts[2]!.startsWith("clossys-");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Every file, link and (through its files) directory beneath `node`, and the node's own entry. */
|
|
721
|
+
function entriesUnder(node: TreeNode): TreeEntry[] {
|
|
722
|
+
const found: TreeEntry[] = [];
|
|
723
|
+
const stack: TreeNode[] = [node];
|
|
724
|
+
for (let next = stack.pop(); next !== undefined; next = stack.pop()) {
|
|
725
|
+
if (next.entry !== null) found.push(next.entry);
|
|
726
|
+
stack.push(...next.children.values());
|
|
727
|
+
}
|
|
728
|
+
return found;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* What the head holds at the flow's own paths, by exact spelling, whatever its
|
|
733
|
+
* type: a file or a link is listed, and a directory is listed by every file
|
|
734
|
+
* under it, so that the path reads as occupied and is never planned over. A path
|
|
735
|
+
* that is not the flow's own, but reads as one where letter case or a trailing
|
|
736
|
+
* dot is not told apart, is refused: a write to one could land on the other.
|
|
737
|
+
*/
|
|
738
|
+
function ownedEntries(tree: Tree): TreeEntry[] {
|
|
739
|
+
const owned = new Map<string, TreeEntry>();
|
|
740
|
+
const folded = new Set<string>();
|
|
741
|
+
const stack: { readonly path: string; readonly node: TreeNode; readonly covered: boolean }[] = [];
|
|
742
|
+
for (const [name, node] of tree.root.children) stack.push({ path: name, node, covered: false });
|
|
743
|
+
for (let item = stack.pop(); item !== undefined; item = stack.pop()) {
|
|
744
|
+
const { path, node } = item;
|
|
745
|
+
let covered = item.covered;
|
|
746
|
+
if (matchesAny(path, OWNED)) {
|
|
747
|
+
const key = fold(path);
|
|
748
|
+
if (folded.has(key)) refuse("case-variant-owned-path");
|
|
749
|
+
folded.add(key);
|
|
750
|
+
if (!covered) for (const entry of entriesUnder(node)) owned.set(entry.path, entry);
|
|
751
|
+
covered = true;
|
|
752
|
+
} else if (matchesAny(fold(path), OWNED_FOLDED) && !(node.entry !== null && isReportedAgentsLink(node.entry))) refuse("case-variant-owned-path");
|
|
753
|
+
for (const [name, child] of node.children) stack.push({ path: `${path}/${name}`, node: child, covered });
|
|
754
|
+
}
|
|
755
|
+
return [...owned.values()].sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Root names whose case variants are refused: the roots the flow adds, and the two it writes into. */
|
|
759
|
+
const CASE_GUARDED_ROOTS: readonly string[] = [...PROFILE_ROOTS, ".github", ".starter"];
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Two root entries that differ only by letter case (or by normalisation form or a
|
|
763
|
+
* trailing dot) are one name on a file system that does not tell them apart, and
|
|
764
|
+
* a variant of a root the flow adds stands in for it in every check that looks
|
|
765
|
+
* for the root by name. The roots the flow adds are refused for any spelling but
|
|
766
|
+
* their own; `.github` and `.starter` only when two spellings are present.
|
|
767
|
+
*/
|
|
768
|
+
function checkRootNames(tree: Tree): void {
|
|
769
|
+
const spellings = new Map<string, string[]>();
|
|
770
|
+
for (const name of tree.root.children.keys()) {
|
|
771
|
+
const key = fold(name);
|
|
772
|
+
const held = spellings.get(key);
|
|
773
|
+
if (held === undefined) spellings.set(key, [name]);
|
|
774
|
+
else held.push(name);
|
|
775
|
+
}
|
|
776
|
+
for (const root of CASE_GUARDED_ROOTS) {
|
|
777
|
+
const names = spellings.get(fold(root)) ?? [];
|
|
778
|
+
if (names.length > 1 || (PROFILE_ROOTS.includes(root) && names.length === 1 && names[0] !== root)) refuse("case-variant-owned-path");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Every node whose path is `segments` when each name is folded, with the spelling it has: a directory may have several spellings at once. */
|
|
783
|
+
function variantsAt(tree: Tree, segments: readonly string[]): { readonly name: string; readonly node: TreeNode }[] {
|
|
784
|
+
let level: { readonly name: string; readonly node: TreeNode }[] = [{ name: "", node: tree.root }];
|
|
785
|
+
for (const segment of segments) {
|
|
786
|
+
const wanted = fold(segment);
|
|
787
|
+
const next: { readonly name: string; readonly node: TreeNode }[] = [];
|
|
788
|
+
for (const { node } of level) for (const [name, child] of node.children) if (fold(name) === wanted) next.push({ name, node: child });
|
|
789
|
+
level = next;
|
|
790
|
+
}
|
|
791
|
+
return level;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Whether a node, or a node above it along `segments` under any spelling, is a symbolic link. */
|
|
795
|
+
function anySymlinkOnPath(tree: Tree, segments: readonly string[]): boolean {
|
|
796
|
+
for (let length = 1; length <= segments.length; length += 1) {
|
|
797
|
+
if (variantsAt(tree, segments.slice(0, length)).some(({ node }) => node.entry?.mode === "120000")) return true;
|
|
798
|
+
}
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ---- blobs ------------------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
/** Reads blobs of the committed tree by object id, each path once, within the total the observation may read. */
|
|
805
|
+
class BlobReader {
|
|
806
|
+
private readonly cache = new Map<string, Buffer>();
|
|
807
|
+
private total = 0;
|
|
808
|
+
|
|
809
|
+
constructor(private readonly clone: string) {}
|
|
810
|
+
|
|
811
|
+
/** The blob's exact bytes; `over` is the skip when it is larger than `cap`. */
|
|
812
|
+
read(entry: TreeEntry, cap: number, over: SkipReason): Buffer {
|
|
813
|
+
const cached = this.cache.get(entry.path);
|
|
814
|
+
if (cached !== undefined) return cached;
|
|
815
|
+
if (entry.size > cap) refuse(over);
|
|
816
|
+
if (this.total + entry.size > MAX_READ_BYTES) refuse("observation-too-large");
|
|
817
|
+
const run = git(this.clone, ["cat-file", "blob", entry.oid], entry.size + 1);
|
|
818
|
+
// The listed size and the bytes must agree, or the object is not the one the tree named.
|
|
819
|
+
if (run.status !== 0 || run.overflow || run.stdout.length !== entry.size) refuse("clone-unreadable");
|
|
820
|
+
this.total += entry.size;
|
|
821
|
+
this.cache.set(entry.path, run.stdout);
|
|
822
|
+
return run.stdout;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The digest of a file's bytes exactly as materialize computes one: the bytes
|
|
828
|
+
* are read as UTF-8 text, a sequence that is not UTF-8 becoming U+FFFD, and that
|
|
829
|
+
* text is hashed (`readFileSync(path, "utf8")` on disk, `git show` decoded as
|
|
830
|
+
* UTF-8 on a ref, and the same for a link's target). Hashing the raw bytes would
|
|
831
|
+
* give a different digest for a file that is not UTF-8.
|
|
832
|
+
*/
|
|
833
|
+
function digestOf(bytes: Buffer): string {
|
|
834
|
+
return contentDigest(bytes.toString("utf8"));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ---- strict JSON ------------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
/** UTF-8 text, or null. A byte order mark is kept as U+FEFF, which strict JSON refuses. */
|
|
840
|
+
function decodeUtf8(bytes: Uint8Array): string | null {
|
|
841
|
+
try {
|
|
842
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
843
|
+
} catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Whether `text` is JSON with no object that repeats a key, at any depth
|
|
850
|
+
* (keys compared after decoding their escapes). JSON.parse takes the last of
|
|
851
|
+
* two equal keys, so it alone would read a document another reader reads
|
|
852
|
+
* differently.
|
|
853
|
+
*/
|
|
854
|
+
function isStrictJson(text: string): boolean {
|
|
855
|
+
let at = 0;
|
|
856
|
+
const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/uy;
|
|
857
|
+
const space = (): void => {
|
|
858
|
+
while (at < text.length && (text[at] === " " || text[at] === "\t" || text[at] === "\n" || text[at] === "\r")) at += 1;
|
|
859
|
+
};
|
|
860
|
+
const string = (): string | null => {
|
|
861
|
+
if (text[at] !== '"') return null;
|
|
862
|
+
const start = at;
|
|
863
|
+
at += 1;
|
|
864
|
+
while (at < text.length) {
|
|
865
|
+
const code = text.charCodeAt(at);
|
|
866
|
+
if (code === 0x22) {
|
|
867
|
+
at += 1;
|
|
868
|
+
return JSON.parse(text.slice(start, at)) as string;
|
|
869
|
+
}
|
|
870
|
+
if (code < 0x20) return null;
|
|
871
|
+
if (code !== 0x5c) {
|
|
872
|
+
at += 1;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const escape = text[at + 1];
|
|
876
|
+
if (escape === "u") {
|
|
877
|
+
if (!/^[0-9a-fA-F]{4}$/u.test(text.slice(at + 2, at + 6))) return null;
|
|
878
|
+
at += 6;
|
|
879
|
+
} else if (escape !== undefined && '"\\/bfnrt'.includes(escape)) at += 2;
|
|
880
|
+
else return null;
|
|
881
|
+
}
|
|
882
|
+
return null;
|
|
883
|
+
};
|
|
884
|
+
const value = (depth: number): boolean => {
|
|
885
|
+
if (depth > MAX_JSON_DEPTH) return false;
|
|
886
|
+
space();
|
|
887
|
+
const start = text[at];
|
|
888
|
+
if (start === "{") {
|
|
889
|
+
at += 1;
|
|
890
|
+
space();
|
|
891
|
+
if (text[at] === "}") {
|
|
892
|
+
at += 1;
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
const seen = new Set<string>();
|
|
896
|
+
for (;;) {
|
|
897
|
+
space();
|
|
898
|
+
const key = string();
|
|
899
|
+
if (key === null || seen.has(key)) return false;
|
|
900
|
+
seen.add(key);
|
|
901
|
+
space();
|
|
902
|
+
if (text[at] !== ":") return false;
|
|
903
|
+
at += 1;
|
|
904
|
+
if (!value(depth + 1)) return false;
|
|
905
|
+
space();
|
|
906
|
+
if (text[at] === ",") at += 1;
|
|
907
|
+
else if (text[at] === "}") {
|
|
908
|
+
at += 1;
|
|
909
|
+
return true;
|
|
910
|
+
} else return false;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (start === "[") {
|
|
914
|
+
at += 1;
|
|
915
|
+
space();
|
|
916
|
+
if (text[at] === "]") {
|
|
917
|
+
at += 1;
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
for (;;) {
|
|
921
|
+
if (!value(depth + 1)) return false;
|
|
922
|
+
space();
|
|
923
|
+
if (text[at] === ",") at += 1;
|
|
924
|
+
else if (text[at] === "]") {
|
|
925
|
+
at += 1;
|
|
926
|
+
return true;
|
|
927
|
+
} else return false;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (start === '"') return string() !== null;
|
|
931
|
+
for (const literal of ["true", "false", "null"]) {
|
|
932
|
+
if (text.startsWith(literal, at)) {
|
|
933
|
+
at += literal.length;
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
number.lastIndex = at;
|
|
938
|
+
const match = number.exec(text);
|
|
939
|
+
if (match === null) return false;
|
|
940
|
+
at += match[0].length;
|
|
941
|
+
return true;
|
|
942
|
+
};
|
|
943
|
+
if (!value(0)) return false;
|
|
944
|
+
space();
|
|
945
|
+
return at === text.length;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** The JSON a file holds, or null when it is not UTF-8 text without a byte order mark, holds a repeated key, or is not JSON. */
|
|
949
|
+
function parseStrictJson(bytes: Uint8Array): { readonly text: string; readonly value: unknown } | null {
|
|
950
|
+
const text = decodeUtf8(bytes);
|
|
951
|
+
if (text === null || !isStrictJson(text)) return null;
|
|
952
|
+
try {
|
|
953
|
+
return { text, value: JSON.parse(text) as unknown };
|
|
954
|
+
} catch {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
960
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ---- manifest, package manager and lockfile ------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
interface ManifestEntry {
|
|
966
|
+
readonly placement: DependencyPlacement;
|
|
967
|
+
readonly name: string;
|
|
968
|
+
readonly value: string;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
interface Manifest {
|
|
972
|
+
readonly entries: readonly ManifestEntry[];
|
|
973
|
+
/** The `packageManager` field: undefined when absent, null when present but not text this module recognises. */
|
|
974
|
+
readonly declared: PackageManagerKind | null | undefined;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const PLACEMENTS: readonly DependencyPlacement[] = ["dependencies", "devDependencies"];
|
|
978
|
+
|
|
979
|
+
function readManifest(entry: TreeEntry, reader: BlobReader): Manifest {
|
|
980
|
+
const parsed = parseStrictJson(reader.read(entry, MAX_BLOB_BYTES, "manifest-unreadable"));
|
|
981
|
+
if (parsed === null || !isRecord(parsed.value)) return refuse("manifest-unreadable");
|
|
982
|
+
const manifest = parsed.value;
|
|
983
|
+
const entries: ManifestEntry[] = [];
|
|
984
|
+
for (const placement of PLACEMENTS) {
|
|
985
|
+
if (!Object.hasOwn(manifest, placement)) continue;
|
|
986
|
+
const map = manifest[placement];
|
|
987
|
+
if (!isRecord(map)) return refuse("manifest-unreadable");
|
|
988
|
+
const rows: ManifestEntry[] = [];
|
|
989
|
+
for (const [name, value] of Object.entries(map)) {
|
|
990
|
+
if (typeof value !== "string") return refuse("manifest-unreadable");
|
|
991
|
+
rows.push({ placement, name, value });
|
|
992
|
+
}
|
|
993
|
+
entries.push(...rows.sort((left, right) => compareCodeUnits(left.name, right.name)));
|
|
994
|
+
}
|
|
995
|
+
let declared: Manifest["declared"];
|
|
996
|
+
if (Object.hasOwn(manifest, "packageManager")) {
|
|
997
|
+
const field = manifest.packageManager;
|
|
998
|
+
const match = typeof field === "string" ? PACKAGE_MANAGER_FIELD.exec(field) : null;
|
|
999
|
+
declared = match === null ? null : (match[1] as Exclude<PackageManagerKind, "none">);
|
|
1000
|
+
}
|
|
1001
|
+
return { entries, declared };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
interface PackageState {
|
|
1005
|
+
readonly packageManager: PackageManagerKind;
|
|
1006
|
+
readonly lockfile: LockfileName;
|
|
1007
|
+
readonly lockedPackages: readonly PinnedPackage[];
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** The lockfile files present at the root, in any spelling, and whether a lockfile of a manager this module does not read is. */
|
|
1011
|
+
function rootLockfiles(tree: Tree): { readonly known: readonly (typeof LOCKFILES)[number][]; readonly other: boolean } {
|
|
1012
|
+
return {
|
|
1013
|
+
known: LOCKFILES.filter((lockfile) => variantsAt(tree, [lockfile.name]).length > 0),
|
|
1014
|
+
other: OTHER_LOCKFILES.some((name) => variantsAt(tree, [name]).length > 0),
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The Starter the lockfile locks must have a lockfile row of that name and
|
|
1020
|
+
* version: not an `npm:` alias, not another package installed under its name,
|
|
1021
|
+
* and not a tarball of another package. Anything else is a lockfile this module
|
|
1022
|
+
* does not interpret. The host and the integrity are the planner's to check.
|
|
1023
|
+
*/
|
|
1024
|
+
function checkStarterIdentity(view: LockfileView, row: RootDependency, manifest: Manifest | null): void {
|
|
1025
|
+
const locked = view.entries.find((candidate) => candidate.key === (view.format === "npm" ? `node_modules/${STARTER}` : `${STARTER}@${row.version}`));
|
|
1026
|
+
const written = manifest?.entries.find((candidate) => candidate.placement === row.placement && candidate.name === row.name);
|
|
1027
|
+
const named = locked !== undefined && !locked.link && locked.name === STARTER && locked.installedName === STARTER && locked.version === row.version;
|
|
1028
|
+
const fetched = locked?.tarball == null || locked.tarball.endsWith(`/${STARTER}/-/starter-${row.version}.tgz`);
|
|
1029
|
+
const aliased = row.specifier.startsWith("npm:") || (written !== undefined && written.value !== row.specifier);
|
|
1030
|
+
if (!named || !fetched || aliased) refuse("lockfile-unreadable");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function lockedPackagesOf(entry: TreeEntry, format: LockfileFormat, reader: BlobReader, manifest: Manifest | null): PinnedPackage[] {
|
|
1034
|
+
const text = decodeUtf8(reader.read(entry, MAX_LOCKFILE_BYTES, "lockfile-unreadable"));
|
|
1035
|
+
if (text === null) return refuse("lockfile-unreadable");
|
|
1036
|
+
let view: ReturnType<typeof readLockfile>;
|
|
1037
|
+
try {
|
|
1038
|
+
view = readLockfile(format, text);
|
|
1039
|
+
} catch {
|
|
1040
|
+
return refuse("lockfile-unreadable");
|
|
1041
|
+
}
|
|
1042
|
+
if (isUnreadable(view)) return refuse("lockfile-unreadable");
|
|
1043
|
+
const seen = new Map<string, PinnedPackage>();
|
|
1044
|
+
for (const row of view.root) {
|
|
1045
|
+
if ((row.placement !== "dependencies" && row.placement !== "devDependencies") || row.version === null || row.link) continue;
|
|
1046
|
+
if (row.integrity === null || !INTEGRITY.test(row.integrity)) continue;
|
|
1047
|
+
if (row.name === STARTER) checkStarterIdentity(view, row, manifest);
|
|
1048
|
+
const key = `${row.name}\0${row.version}`;
|
|
1049
|
+
const held = seen.get(key);
|
|
1050
|
+
// One name and version with two integrity values is a lockfile that says two things.
|
|
1051
|
+
if (held !== undefined && held.integrity !== row.integrity) return refuse("lockfile-unreadable");
|
|
1052
|
+
seen.set(key, { name: row.name, version: row.version, integrity: row.integrity });
|
|
1053
|
+
}
|
|
1054
|
+
return [...seen.values()].sort((left, right) => compareTuples([left.name, left.version], [right.name, right.version]));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function readPackageState(tree: Tree, manifest: Manifest | null, reader: BlobReader): PackageState {
|
|
1058
|
+
const { known, other } = rootLockfiles(tree);
|
|
1059
|
+
if (manifest === null) {
|
|
1060
|
+
if (known.length > 0 || other) return refuse("package-manager-conflict");
|
|
1061
|
+
return { packageManager: "none", lockfile: "none", lockedPackages: [] };
|
|
1062
|
+
}
|
|
1063
|
+
if (known.length > 1 || other) return refuse("lockfile-ambiguous");
|
|
1064
|
+
const lockfile = known[0];
|
|
1065
|
+
if (lockfile === undefined) {
|
|
1066
|
+
// Nothing but the package.json field decides, and it is not guessed.
|
|
1067
|
+
if (manifest.declared === undefined || manifest.declared === null) return refuse("package-manager-unknown");
|
|
1068
|
+
return { packageManager: manifest.declared, lockfile: "none", lockedPackages: [] };
|
|
1069
|
+
}
|
|
1070
|
+
if (manifest.declared === null) return refuse("package-manager-unknown");
|
|
1071
|
+
if (manifest.declared !== undefined && manifest.declared !== lockfile.manager) return refuse("package-manager-conflict");
|
|
1072
|
+
const found = lookup(tree, lockfile.name);
|
|
1073
|
+
if (found.kind !== "regular" || lockfile.manager === "yarn") return refuse("lockfile-unreadable");
|
|
1074
|
+
return { packageManager: lockfile.manager, lockfile: lockfile.name, lockedPackages: lockedPackagesOf(found.entry, lockfile.manager, reader, manifest) };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ---- the other fields ----------------------------------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
function readReleaseAgeSurfaces(tree: Tree): { readonly surface: ReleaseAgeSurfaceKind; readonly path: string }[] {
|
|
1080
|
+
const found: { surface: ReleaseAgeSurfaceKind; path: string }[] = [];
|
|
1081
|
+
for (const { surface, path } of RELEASE_AGE_SURFACES) {
|
|
1082
|
+
const variants = variantsAt(tree, [path]);
|
|
1083
|
+
if (variants.length === 0) continue;
|
|
1084
|
+
const only = variants[0]!;
|
|
1085
|
+
if (variants.length > 1 || only.name !== path || !isRegular(only.node.entry)) refuse("release-age-surface-invalid");
|
|
1086
|
+
found.push({ surface, path });
|
|
1087
|
+
}
|
|
1088
|
+
return found.sort((left, right) => compareTuples([left.surface, left.path], [right.surface, right.path]));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Whether a workflow of the repository's own is at the head: a regular file
|
|
1093
|
+
* directly in `.github/workflows`, spelled exactly so (GitHub reads no other
|
|
1094
|
+
* spelling), that is not a `clossys-` file.
|
|
1095
|
+
*/
|
|
1096
|
+
function hasConsumerCi(tree: Tree): boolean {
|
|
1097
|
+
const workflows = nodeAt(tree, ".github/workflows");
|
|
1098
|
+
if (workflows === null) return false;
|
|
1099
|
+
for (const [name, child] of workflows.children) {
|
|
1100
|
+
if (isRegular(child.entry) && /\.ya?ml$/iu.test(name) && !fold(name).startsWith("clossys-")) return true;
|
|
1101
|
+
}
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function readSymlinkedSkillRoots(tree: Tree): DiscoveryRoot[] {
|
|
1106
|
+
return DISCOVERY.filter((root) => anySymlinkOnPath(tree, root.split("/")));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function readLinkedAgentsPaths(tree: Tree): string[] {
|
|
1110
|
+
const linked = new Set<string>();
|
|
1111
|
+
if (anySymlinkOnPath(tree, [".agents"])) linked.add(".agents");
|
|
1112
|
+
if (variantsAt(tree, [".agents", "skills"]).some(({ node }) => node.entry?.mode === "120000")) linked.add(".agents/skills");
|
|
1113
|
+
for (const { node } of variantsAt(tree, [".agents", "skills"])) {
|
|
1114
|
+
for (const [name, child] of node.children) {
|
|
1115
|
+
const folded = fold(name);
|
|
1116
|
+
if (child.entry?.mode === "120000" && folded.startsWith("clossys-")) linked.add(`.agents/skills/${folded}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const paths = [...linked].sort(compareCodeUnits);
|
|
1120
|
+
if (!paths.every((path) => AGENTS_LINK.test(path))) refuse("agents-link-unreportable");
|
|
1121
|
+
return paths;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function readOwnedFiles(owned: readonly TreeEntry[], reader: BlobReader): { readonly path: string; readonly sha256: string }[] {
|
|
1125
|
+
if (owned.length > MAX_OWNED_FILES) refuse("observation-too-large");
|
|
1126
|
+
const lockfileNames = new Set<string>(LOCKFILES.map((lockfile) => lockfile.name));
|
|
1127
|
+
return owned.map((entry) => ({ path: entry.path, sha256: digestOf(reader.read(entry, lockfileNames.has(entry.path) ? MAX_LOCKFILE_BYTES : MAX_BLOB_BYTES, "observation-too-large")) }));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function readLedger(tree: Tree, reader: BlobReader): Uint8Array | null {
|
|
1131
|
+
const found = lookup(tree, LEDGER_PATH);
|
|
1132
|
+
if (found.kind === "absent") return null;
|
|
1133
|
+
if (found.kind === "other") return refuse("ledger-unreadable");
|
|
1134
|
+
return new Uint8Array(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large"));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** The composed-skill manifest's entries, or null when it is absent or is not exactly the shape the flow writes. */
|
|
1138
|
+
function readSkillsManifest(tree: Tree, reader: BlobReader): { readonly name: string; readonly sha256: string }[] | null {
|
|
1139
|
+
const found = lookup(tree, SKILLS_MANIFEST_PATH);
|
|
1140
|
+
if (found.kind !== "regular") return null;
|
|
1141
|
+
const parsed = parseStrictJson(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large"));
|
|
1142
|
+
const document = parsed?.value;
|
|
1143
|
+
if (!isRecord(document) || Object.keys(document).length !== 2 || document.schemaVersion !== 1 || !Array.isArray(document.skills)) return null;
|
|
1144
|
+
const skills: { name: string; sha256: string }[] = [];
|
|
1145
|
+
for (const skill of document.skills as unknown[]) {
|
|
1146
|
+
if (!isRecord(skill) || !hasExactKeys(skill, ["name", "source", "sha256", "version"])) return null;
|
|
1147
|
+
const { name, source, sha256, version } = skill;
|
|
1148
|
+
if (typeof name !== "string" || !SKILL_NAME.test(name) || typeof source !== "string" || typeof version !== "string") return null;
|
|
1149
|
+
if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256) || skills.some((held) => held.name === name)) return null;
|
|
1150
|
+
skills.push({ name, sha256 });
|
|
1151
|
+
}
|
|
1152
|
+
return skills.sort((left, right) => compareCodeUnits(left.name, right.name));
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function hasExactKeys(record: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
1156
|
+
const own = Object.keys(record);
|
|
1157
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(record, key));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The Controller repository profile: the one Controller would locate, its path, and what a set that adds the four roots would break in
|
|
1162
|
+
* it. `setupRoots` are the roots a setup set adds beside them, when the repository is in its setup phase.
|
|
1163
|
+
*/
|
|
1164
|
+
function readProfile(tree: Tree, reader: BlobReader, setupRoots: readonly string[]): { readonly profile: RepositoryProfileObservation | null; readonly text: string | null } {
|
|
1165
|
+
const path = locateProfile(tree);
|
|
1166
|
+
if (path === null) return { profile: null, text: null };
|
|
1167
|
+
const unparseable = { profile: { path, rootVocabulary: "unparseable", undeclaredRoots: [], prohibitedRoots: [] } as RepositoryProfileObservation, text: null };
|
|
1168
|
+
const entry = nodeAt(tree, path)!.entry!;
|
|
1169
|
+
if (entry.size > MAX_BLOB_BYTES) return unparseable;
|
|
1170
|
+
const parsed = parseStrictJson(reader.read(entry, MAX_BLOB_BYTES, "observation-too-large"));
|
|
1171
|
+
if (parsed === null) return unparseable;
|
|
1172
|
+
// checkRootNames() refused every spelling but the exact one, so exact presence is presence.
|
|
1173
|
+
const introduced = [...PROFILE_ROOTS, ...setupRoots].filter((root) => !tree.root.children.has(root));
|
|
1174
|
+
const verdict = wouldViolateRootEntries(parsed.value, introduced);
|
|
1175
|
+
if (verdict.verdict === "indeterminate") return unparseable;
|
|
1176
|
+
if (verdict.verdict === "satisfied") return { profile: { path, rootVocabulary: verdict.vocabulary, undeclaredRoots: [], prohibitedRoots: [] }, text: null };
|
|
1177
|
+
return {
|
|
1178
|
+
profile: { path, rootVocabulary: "checked", undeclaredRoots: [...verdict.undeclared], prohibitedRoots: [...verdict.prohibited] },
|
|
1179
|
+
text: verdict.undeclared.length > 0 ? parsed.text : null,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** The path of the profile Controller would locate: `governance/` first, otherwise the one candidate in the tree; null for none. */
|
|
1184
|
+
function locateProfile(tree: Tree): string | null {
|
|
1185
|
+
const first = lookup(tree, PROFILE_FIRST);
|
|
1186
|
+
if (first.kind === "regular") return PROFILE_FIRST;
|
|
1187
|
+
if (first.kind === "other") return refuse("profile-ambiguous");
|
|
1188
|
+
const candidates = tree.entries.filter((entry) => {
|
|
1189
|
+
const segments = entry.path.split("/");
|
|
1190
|
+
if (!PROFILE_NAMES.has(segments[segments.length - 1]!)) return false;
|
|
1191
|
+
if (segments.slice(0, -1).some((directory) => PROFILE_SKIPPED_DIRECTORIES.has(directory))) return false;
|
|
1192
|
+
return !entry.path.startsWith(".github/workflows/");
|
|
1193
|
+
});
|
|
1194
|
+
if (candidates.length > 1 || (candidates.length === 1 && !isRegular(candidates[0]!))) return refuse("profile-ambiguous");
|
|
1195
|
+
return candidates[0]?.path ?? null;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** `apply` only when the base carries every proof of an earlier set; any doubt is `setup`. */
|
|
1199
|
+
function readPhase(tree: Tree, ledger: Uint8Array | null, manifest: readonly ManifestEntry[], locked: readonly PinnedPackage[]): "setup" | "apply" {
|
|
1200
|
+
if (ledger === null) return "setup";
|
|
1201
|
+
let readable: boolean;
|
|
1202
|
+
try {
|
|
1203
|
+
readable = readInstalledLedger(ledger) !== null;
|
|
1204
|
+
} catch {
|
|
1205
|
+
readable = false;
|
|
1206
|
+
}
|
|
1207
|
+
if (!readable) return "setup";
|
|
1208
|
+
if (!Object.values(TEMPLATE_PATHS).every((paths) => paths.every((path) => lookup(tree, path).kind === "regular"))) return "setup";
|
|
1209
|
+
const starters = manifest.filter((entry) => entry.name === STARTER);
|
|
1210
|
+
const version = starters.length === 1 ? starters[0]!.value : "";
|
|
1211
|
+
// The pin must also be one the setup templates support, or the request they wrote could not have named it.
|
|
1212
|
+
if (!EXACT_VERSION.test(version) || !isSupportedStarterVersion(version)) return "setup";
|
|
1213
|
+
return locked.some((pkg) => pkg.name === STARTER && pkg.version === version) ? "apply" : "setup";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** The exact text of a regular file at the root, or null when it is absent, not a regular file, or not UTF-8. */
|
|
1217
|
+
function readSurfaceText(tree: Tree, reader: BlobReader, path: string): string | null {
|
|
1218
|
+
const found = lookup(tree, path);
|
|
1219
|
+
return found.kind === "regular" ? decodeUtf8(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large")) : null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** `files` with the .npmrc's digest added when it is there: a pnpm setup reads it, though the flow never writes it. */
|
|
1223
|
+
function withNpmrc(files: { readonly path: string; readonly sha256: string }[], tree: Tree, reader: BlobReader): { readonly path: string; readonly sha256: string }[] {
|
|
1224
|
+
const found = lookup(tree, NPMRC_PATH);
|
|
1225
|
+
if (found.kind !== "regular") return files;
|
|
1226
|
+
return [...files, { path: NPMRC_PATH, sha256: digestOf(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large")) }].sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* The root names a setup set adds beyond the four every set adds: the two directories its templates go in, and, for a pnpm
|
|
1231
|
+
* repository with no workspace file, that file when the exemption edit will create it (a conflicting .npmrc refuses the edit, and
|
|
1232
|
+
* then nothing creates it).
|
|
1233
|
+
*/
|
|
1234
|
+
function setupRoots(tree: Tree, packageManager: PackageManagerKind, npmrcText: string | null): string[] {
|
|
1235
|
+
const roots = [...SETUP_PROFILE_ROOTS];
|
|
1236
|
+
if (packageManager === "pnpm" && !tree.root.children.has(PNPM_WORKSPACE_PATH) && editReleaseAgeExemption({ surface: "pnpm-workspace", text: null, npmrc: npmrcText }).kind === "edited") {
|
|
1237
|
+
roots.push(PNPM_WORKSPACE_PATH);
|
|
1238
|
+
}
|
|
1239
|
+
return roots;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Everything read from the committed tree, which is the tree at the head commit. */
|
|
1243
|
+
function readCommittedHead(clone: string, tree: Tree): Omit<RepositoryObservation, "id" | "nodeId" | "visibility" | "defaultBranch" | "baseCommit"> {
|
|
1244
|
+
checkRootNames(tree);
|
|
1245
|
+
const owned = ownedEntries(tree);
|
|
1246
|
+
const reader = new BlobReader(clone);
|
|
1247
|
+
const packageJson = lookup(tree, "package.json");
|
|
1248
|
+
if (packageJson.kind === "other") refuse("manifest-unreadable");
|
|
1249
|
+
const manifest = packageJson.kind === "regular" ? readManifest(packageJson.entry, reader) : null;
|
|
1250
|
+
const packages = readPackageState(tree, manifest, reader);
|
|
1251
|
+
const releaseAgeSurfaces = readReleaseAgeSurfaces(tree);
|
|
1252
|
+
const linkedAgentsPaths = readLinkedAgentsPaths(tree);
|
|
1253
|
+
const files = withNpmrc(readOwnedFiles(owned, reader), tree, reader);
|
|
1254
|
+
const ledger = readLedger(tree, reader);
|
|
1255
|
+
const skillsManifest = readSkillsManifest(tree, reader);
|
|
1256
|
+
const manifestEntries = manifest?.entries ?? [];
|
|
1257
|
+
const phase = readPhase(tree, ledger, manifestEntries, packages.lockedPackages);
|
|
1258
|
+
const pnpmWorkspaceText = readSurfaceText(tree, reader, PNPM_WORKSPACE_PATH);
|
|
1259
|
+
const npmrcText = readSurfaceText(tree, reader, NPMRC_PATH);
|
|
1260
|
+
const { profile, text } = readProfile(tree, reader, phase === "setup" ? setupRoots(tree, packages.packageManager, npmrcText) : []);
|
|
1261
|
+
return {
|
|
1262
|
+
phase,
|
|
1263
|
+
packageManager: packages.packageManager,
|
|
1264
|
+
lockfile: packages.lockfile,
|
|
1265
|
+
releaseAgeSurfaces,
|
|
1266
|
+
consumerCi: hasConsumerCi(tree),
|
|
1267
|
+
symlinkedSkillRoots: readSymlinkedSkillRoots(tree),
|
|
1268
|
+
repositoryProfile: profile,
|
|
1269
|
+
linkedAgentsPaths,
|
|
1270
|
+
files,
|
|
1271
|
+
manifestEntries,
|
|
1272
|
+
lockedPackages: packages.lockedPackages,
|
|
1273
|
+
ledger,
|
|
1274
|
+
skillsManifest,
|
|
1275
|
+
repositoryProfileText: text,
|
|
1276
|
+
pnpmWorkspaceText,
|
|
1277
|
+
npmrcText,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Observes one clone: the committed head of a clean clone whose origin is the
|
|
1283
|
+
* repository `id` names, or the reason it cannot be observed. Nothing in the
|
|
1284
|
+
* clone is written or executed, and its working tree is not read.
|
|
1285
|
+
*/
|
|
1286
|
+
export async function observeRepository(input: ObserveRepositoryInput): Promise<RepositoryObservation | SkippedRepositoryObservation> {
|
|
1287
|
+
const { id, hubOwner, ports } = input;
|
|
1288
|
+
let neutral: string | null = null;
|
|
1289
|
+
try {
|
|
1290
|
+
checkId(id, hubOwner);
|
|
1291
|
+
// Objects from a store the clone does not own are refused, as the alternates file that names one is.
|
|
1292
|
+
if ((process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES ?? "") !== "") refuse("clone-unreadable");
|
|
1293
|
+
const clone = locateClone(input.clone);
|
|
1294
|
+
// A directory of its own, outside the clone, for the commands that must not see any repository's configuration.
|
|
1295
|
+
neutral = mkdtempSync(join(tmpdir(), "observe-repository-"));
|
|
1296
|
+
const config = readConfig(clone, neutral);
|
|
1297
|
+
checkConfig(config);
|
|
1298
|
+
checkRepositoryShape(clone);
|
|
1299
|
+
const url = originUrl(config);
|
|
1300
|
+
checkOrigin(url, id, hubOwner, ports.originId ?? defaultOriginId);
|
|
1301
|
+
// The file transport is only for an origin the caller's own mapper named: a local path is never what the default parser names.
|
|
1302
|
+
const tip = remoteTip(url, neutral, ports.originId === undefined ? REMOTE_PROTOCOLS : `${REMOTE_PROTOCOLS}:file`);
|
|
1303
|
+
checkHead(clone, tip);
|
|
1304
|
+
// The object database first: the head tree is listed, and a submodule refused, before any command reads the working tree.
|
|
1305
|
+
const tree = listTree(clone, tip.oid);
|
|
1306
|
+
checkIndex(clone);
|
|
1307
|
+
checkClean(clone);
|
|
1308
|
+
const { nodeId, visibility } = await readPorts(id, ports);
|
|
1309
|
+
const head = readCommittedHead(clone, tree);
|
|
1310
|
+
return { id, nodeId, visibility, defaultBranch: tip.branch, baseCommit: tip.oid, ...head };
|
|
1311
|
+
} catch (cause) {
|
|
1312
|
+
// Anything that is not a named refusal is still not an observation.
|
|
1313
|
+
const reason: SkipReason = cause instanceof Refusal ? cause.reason : "clone-unreadable";
|
|
1314
|
+
return { id, skipped: reason, verdict: SKIPS[reason] };
|
|
1315
|
+
} finally {
|
|
1316
|
+
if (neutral !== null) rmSync(neutral, { recursive: true, force: true });
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ---- the committed files, for a caller that writes them elsewhere -------------------------------
|
|
1321
|
+
|
|
1322
|
+
/** The most files a caller may be handed; a tree with more is refused as `tree-too-large`. */
|
|
1323
|
+
const MAX_COMMITTED_FILES = 20_000;
|
|
1324
|
+
|
|
1325
|
+
/** One file of the committed tree: its git mode, and its exact bytes (a symbolic link's bytes are its target). */
|
|
1326
|
+
export interface CommittedFile {
|
|
1327
|
+
readonly path: string;
|
|
1328
|
+
readonly mode: "100644" | "100755" | "120000";
|
|
1329
|
+
readonly bytes: Buffer;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** A skip reason as an id; the same fixed set an observation is skipped with. */
|
|
1333
|
+
export type CommittedFilesReason = SkipReason;
|
|
1334
|
+
|
|
1335
|
+
export type CommittedFilesResult = { readonly ok: true; readonly files: readonly CommittedFile[] } | { readonly ok: false; readonly reason: CommittedFilesReason };
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Every file of the tree at `commit`, read as observeRepository() reads a tree: the clone is vetted the same way (a real
|
|
1339
|
+
* `.git` of its own, a `.git/config` of plain keys only, no alternate object store), and then only `ls-tree` and `cat-file` run,
|
|
1340
|
+
* at that commit. Nothing is checked out and no index, ref or worktree is written; the working tree is never read. A submodule,
|
|
1341
|
+
* more than a fixed number of files, or more than the observation's read total is refused, and the reason is one fixed id.
|
|
1342
|
+
*/
|
|
1343
|
+
export function readCommittedFiles(cloneInput: string, commit: string): CommittedFilesResult {
|
|
1344
|
+
let neutral: string | null = null;
|
|
1345
|
+
try {
|
|
1346
|
+
if (!OBJECT_ID.test(commit)) refuse("clone-unreadable");
|
|
1347
|
+
if ((process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES ?? "") !== "") refuse("clone-unreadable");
|
|
1348
|
+
const clone = locateClone(cloneInput);
|
|
1349
|
+
neutral = mkdtempSync(join(tmpdir(), "observe-repository-"));
|
|
1350
|
+
checkConfig(readConfig(clone, neutral));
|
|
1351
|
+
checkRepositoryShape(clone);
|
|
1352
|
+
const named = git(clone, ["rev-parse", "--verify", "-q", `${commit}^{commit}`]);
|
|
1353
|
+
if (named.status !== 0 || named.overflow || named.stdout.toString("utf8").trim() !== commit) refuse("clone-unreadable");
|
|
1354
|
+
const tree = listTree(clone, commit);
|
|
1355
|
+
if (tree.entries.length > MAX_COMMITTED_FILES) refuse("tree-too-large");
|
|
1356
|
+
if (tree.entries.reduce((sum, entry) => sum + entry.size, 0) > MAX_READ_BYTES) refuse("tree-too-large");
|
|
1357
|
+
const reader = new BlobReader(clone);
|
|
1358
|
+
const files = tree.entries.map((entry): CommittedFile => ({ path: entry.path, mode: entry.mode as CommittedFile["mode"], bytes: reader.read(entry, MAX_READ_BYTES, "tree-too-large") }));
|
|
1359
|
+
return { ok: true, files };
|
|
1360
|
+
} catch (cause) {
|
|
1361
|
+
return { ok: false, reason: cause instanceof Refusal ? cause.reason : "clone-unreadable" };
|
|
1362
|
+
} finally {
|
|
1363
|
+
if (neutral !== null) rmSync(neutral, { recursive: true, force: true });
|
|
1364
|
+
}
|
|
1365
|
+
}
|