@intx/tool-packaging 0.2.2

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.
@@ -0,0 +1,56 @@
1
+ import { PackageJSON } from "@intx/types/package-json";
2
+ /**
3
+ * Outcome of extracting the top-level `package.json` entry from an
4
+ * npm-style tarball.
5
+ *
6
+ * `kind: "ok"` carries a value already validated against `PackageJSON`;
7
+ * `kind: "shape-invalid"` distinguishes "JSON parsed cleanly but did
8
+ * not match the expected schema" from "the JSON itself was malformed"
9
+ * (`kind: "json-error"`). Callers that ship their own domain error type
10
+ * can map the rejected outcomes onto whatever shape they raise.
11
+ */
12
+ export type ExtractPackageJSONOutcome = {
13
+ kind: "ok";
14
+ parsed: PackageJSON;
15
+ raw: unknown;
16
+ } | {
17
+ kind: "missing-entry";
18
+ } | {
19
+ kind: "multiple-entries";
20
+ paths: string[];
21
+ } | {
22
+ kind: "parse-error";
23
+ message: string;
24
+ } | {
25
+ kind: "json-error";
26
+ message: string;
27
+ } | {
28
+ kind: "shape-invalid";
29
+ message: string;
30
+ raw: unknown;
31
+ };
32
+ /**
33
+ * Stream the tarball bytes through a tar parser, drain only the top-
34
+ * level `package.json` member, JSON.parse the collected bytes, run them
35
+ * through `PackageJSON`, and return a discriminated outcome. The bytes
36
+ * argument may be any Uint8Array (tarball or gzipped tarball —
37
+ * `tar.Parser` auto-detects gzip on the input stream).
38
+ *
39
+ * The tar entry is matched by its tail (`<segment>/package.json` with
40
+ * exactly two segments) rather than the literal `package/package.json`
41
+ * path because the sidecar's tarball extractor uses `strip:1` and
42
+ * therefore accepts any first segment. Matching by tail here keeps the
43
+ * hub's validation aligned with the sidecar's runtime contract — a
44
+ * tarball whose top-level directory is not literally `package/` will
45
+ * load identically in both places.
46
+ *
47
+ * On a tar parser failure the upstream readable is destroyed so a
48
+ * malformed archive does not leave a half-drained source buffered in
49
+ * the parser's internal state.
50
+ *
51
+ * The raw parsed JSON is surfaced alongside the validated descriptor on
52
+ * success so callers that need fields outside `PackageJSON`'s minimum
53
+ * schema (the resolver's `readDependencyFields` consumes `dependencies`,
54
+ * `optionalDependencies`, etc.) can read them without re-extracting.
55
+ */
56
+ export declare function extractTarballPackageJSON(bytes: Uint8Array): Promise<ExtractPackageJSONOutcome>;
@@ -0,0 +1,125 @@
1
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference -- npm-team packages ship no types; declarations.d.ts must be visible to downstream typecheckers that import from this package's source.
2
+ /// <reference path="./declarations.d.ts" />
3
+ // Single source of truth for "open an npm-style tarball, find the
4
+ // package/package.json entry, parse it as JSON, hand the parsed value
5
+ // back". Used by the hub-side resolver (`AssetRegistrySource` builds
6
+ // packuments from asset-stored tarballs) and the hub-sessions package
7
+ // (the `package-registry` kind handler validates uploads before the
8
+ // commit is accepted). Both call sites used to ship near-identical
9
+ // streaming parsers; the duplication drifted independently.
10
+ //
11
+ // The helper returns a discriminated outcome rather than throwing so
12
+ // each caller can construct its own domain-shaped error
13
+ // (ManifestInvalidError on the resolver side, ValidatePushResult
14
+ // reason string on the kind-handler side) without losing the
15
+ // failure-class distinction.
16
+ // This module is Node-bound: it streams through node:stream and the tar
17
+ // library, which are not portable to environments without those APIs.
18
+ import { Readable } from "node:stream";
19
+ import { type } from "arktype";
20
+ import { Parser as TarParser } from "tar/parse";
21
+ import { concatBytes } from "@intx/types";
22
+ import { PackageJSON } from "@intx/types/package-json";
23
+ /**
24
+ * Stream the tarball bytes through a tar parser, drain only the top-
25
+ * level `package.json` member, JSON.parse the collected bytes, run them
26
+ * through `PackageJSON`, and return a discriminated outcome. The bytes
27
+ * argument may be any Uint8Array (tarball or gzipped tarball —
28
+ * `tar.Parser` auto-detects gzip on the input stream).
29
+ *
30
+ * The tar entry is matched by its tail (`<segment>/package.json` with
31
+ * exactly two segments) rather than the literal `package/package.json`
32
+ * path because the sidecar's tarball extractor uses `strip:1` and
33
+ * therefore accepts any first segment. Matching by tail here keeps the
34
+ * hub's validation aligned with the sidecar's runtime contract — a
35
+ * tarball whose top-level directory is not literally `package/` will
36
+ * load identically in both places.
37
+ *
38
+ * On a tar parser failure the upstream readable is destroyed so a
39
+ * malformed archive does not leave a half-drained source buffered in
40
+ * the parser's internal state.
41
+ *
42
+ * The raw parsed JSON is surfaced alongside the validated descriptor on
43
+ * success so callers that need fields outside `PackageJSON`'s minimum
44
+ * schema (the resolver's `readDependencyFields` consumes `dependencies`,
45
+ * `optionalDependencies`, etc.) can read them without re-extracting.
46
+ */
47
+ export async function extractTarballPackageJSON(bytes) {
48
+ return new Promise((resolve) => {
49
+ let resolved = false;
50
+ let pkgJsonBuf = null;
51
+ const collectChunks = [];
52
+ // Capture every top-level `<seg>/package.json` path we see during
53
+ // the walk so the kind handler can reject ambiguous archives. The
54
+ // hub's resolver and the sidecar's tarball extractor resolve
55
+ // collisions differently — the resolver via this helper captures
56
+ // the first occurrence, while the sidecar's `tar.extract` with
57
+ // `strip:1` overwrites on every subsequent path with the same
58
+ // stripped name. A tarball carrying multiple top-level package
59
+ // directories therefore validates against the first entry on the
60
+ // hub but loads the last entry on the sidecar; the divergence is
61
+ // resolved at the validation boundary by refusing the upload.
62
+ const topLevelPackageJSONPaths = [];
63
+ const source = Readable.from([bytes]);
64
+ const finalize = (outcome) => {
65
+ if (resolved)
66
+ return;
67
+ resolved = true;
68
+ resolve(outcome);
69
+ };
70
+ const parser = new TarParser();
71
+ parser.on("entry", (entry) => {
72
+ const segments = entry.path.split("/");
73
+ const isTopLevelPackageJSON = segments.length === 2 && segments[1] === "package.json";
74
+ if (isTopLevelPackageJSON) {
75
+ topLevelPackageJSONPaths.push(entry.path);
76
+ }
77
+ if (isTopLevelPackageJSON && pkgJsonBuf === null) {
78
+ entry.on("data", (chunk) => {
79
+ collectChunks.push(chunk);
80
+ });
81
+ entry.on("end", () => {
82
+ pkgJsonBuf = concatBytes(collectChunks);
83
+ });
84
+ }
85
+ else {
86
+ entry.resume();
87
+ }
88
+ });
89
+ parser.on("error", (err) => {
90
+ source.destroy();
91
+ finalize({ kind: "parse-error", message: err.message });
92
+ });
93
+ parser.on("end", () => {
94
+ if (pkgJsonBuf === null) {
95
+ finalize({ kind: "missing-entry" });
96
+ return;
97
+ }
98
+ if (topLevelPackageJSONPaths.length > 1) {
99
+ finalize({
100
+ kind: "multiple-entries",
101
+ paths: topLevelPackageJSONPaths,
102
+ });
103
+ return;
104
+ }
105
+ let raw;
106
+ try {
107
+ raw = JSON.parse(new TextDecoder().decode(pkgJsonBuf));
108
+ }
109
+ catch (cause) {
110
+ finalize({
111
+ kind: "json-error",
112
+ message: cause instanceof Error ? cause.message : String(cause),
113
+ });
114
+ return;
115
+ }
116
+ const validated = PackageJSON(raw);
117
+ if (validated instanceof type.errors) {
118
+ finalize({ kind: "shape-invalid", message: validated.summary, raw });
119
+ return;
120
+ }
121
+ finalize({ kind: "ok", parsed: validated, raw });
122
+ });
123
+ source.pipe(parser);
124
+ });
125
+ }
@@ -0,0 +1,241 @@
1
+ import { type ToolPackageManifest, type ToolPackagePin, type ToolPackageSource } from "@intx/types/tool-packages";
2
+ /**
3
+ * One HTTP registry. `url` is the actual endpoint; `auth.token` and
4
+ * `auth.basic` mirror the npm-registry-fetch options for bearer /
5
+ * basic auth.
6
+ *
7
+ * The registry's identifier (the string `scopeRouting` entries and
8
+ * manifest `registry` references point at) is the slot it occupies
9
+ * in the registry map: in the hub-side resolver, the key of the
10
+ * `ReadonlyMap<string, RegistrySource>` passed to
11
+ * `createClosureResolver`; in the sidecar-side loader, the key of
12
+ * the `ReadonlyMap<string, RegistryConfig>` on `LoaderConfig`.
13
+ * Keeping the name on the map slot rather than on the value
14
+ * eliminates the (until-now uninforced) "the map key must equal the
15
+ * config's name" invariant.
16
+ *
17
+ * The default-registry decision is owned by `defaultRegistry` on
18
+ * `ClosureResolverConfig`, not by a per-registry flag; only one
19
+ * entry can be the default and centralizing the choice keeps that
20
+ * invariant in the config object.
21
+ */
22
+ export interface RegistryConfig {
23
+ readonly url: string;
24
+ readonly auth?: {
25
+ readonly token?: string;
26
+ readonly basic?: {
27
+ readonly user: string;
28
+ readonly pass: string;
29
+ };
30
+ };
31
+ }
32
+ /** Route packages in a given scope (e.g. `@intx`) to a named registry. */
33
+ export interface ScopeRoute {
34
+ readonly scope: string;
35
+ readonly registry: string;
36
+ }
37
+ /**
38
+ * One version manifest as it appears inside a packument. Fields are
39
+ * non-readonly to match the npm-pick-manifest declaration; consumers
40
+ * should treat values as immutable in practice.
41
+ */
42
+ export interface PackumentVersion {
43
+ name: string;
44
+ version: string;
45
+ dist: {
46
+ tarball: string;
47
+ integrity?: string;
48
+ };
49
+ dependencies?: Record<string, string>;
50
+ optionalDependencies?: Record<string, string>;
51
+ peerDependencies?: Record<string, string>;
52
+ peerDependenciesMeta?: Record<string, {
53
+ optional?: boolean;
54
+ }>;
55
+ os?: string[];
56
+ cpu?: string[];
57
+ }
58
+ /**
59
+ * The packument document a registry serves for one package. Shape
60
+ * matches what `npm-pick-manifest` accepts.
61
+ */
62
+ export interface Packument {
63
+ name: string;
64
+ "dist-tags"?: Record<string, string>;
65
+ versions: Record<string, PackumentVersion>;
66
+ }
67
+ /**
68
+ * Test seam for HTTP packument fetches. The default `HttpRegistrySource`
69
+ * wraps `npm-registry-fetch`; tests inject a function that returns a
70
+ * static packument for a given `(name, registry)` pair.
71
+ */
72
+ export type PackumentFetcher = (packageName: string, registry: RegistryConfig) => Promise<Packument>;
73
+ /**
74
+ * The shape returned by `RegistrySource.materializeRefForEntry`. The
75
+ * walker copies these fields onto the `ToolPackageManifestEntry` it
76
+ * emits, alongside `name`, `version`, `integrity`, and the platform
77
+ * filter metadata it derives from the picked packument version.
78
+ */
79
+ export interface MaterializedRef {
80
+ readonly source: ToolPackageSource;
81
+ /** Set for HTTP-sourced entries so the sidecar can fetch without
82
+ * re-resolving against the registry; asset-sourced entries omit it. */
83
+ readonly tarballUrl?: string;
84
+ }
85
+ /**
86
+ * A pluggable source of npm-style packuments and per-version source
87
+ * tags. The walker treats every named registry as one of these,
88
+ * regardless of whether bytes live behind an HTTP registry or inside a
89
+ * `package-registry` asset.
90
+ *
91
+ * `name` is the identifier the source was registered under in the
92
+ * resolver's registry map. Diagnostics that need to print a
93
+ * registry's name read it from here; the impls are the source of
94
+ * truth and the interface guarantees it is always available.
95
+ */
96
+ export interface RegistrySource {
97
+ readonly name: string;
98
+ fetchPackument(name: string): Promise<Packument>;
99
+ materializeRefForEntry(name: string, version: string, picked: PackumentVersion): MaterializedRef;
100
+ }
101
+ /**
102
+ * Configuration for `createClosureResolver`.
103
+ *
104
+ * `registries` is keyed by the same name the agent's pin set
105
+ * references via the `registry` field in `ToolPackagePin`-derived
106
+ * entries (today indirectly via scope routing; built-ins reach this
107
+ * map by the registry name the session service resolved them from).
108
+ *
109
+ * `defaultRegistry` names the entry in `registries` consulted for any
110
+ * package whose scope does not match a `scopeRouting` entry. The map
111
+ * must contain this key.
112
+ */
113
+ export interface ClosureResolverConfig {
114
+ readonly registries: ReadonlyMap<string, RegistrySource>;
115
+ readonly defaultRegistry: string;
116
+ readonly scopeRouting?: readonly ScopeRoute[];
117
+ }
118
+ export interface ClosureResolver {
119
+ resolveClosure(pins: readonly ToolPackagePin[]): Promise<ToolPackageManifest>;
120
+ }
121
+ /**
122
+ * One unsatisfied peer-dependency declaration discovered while
123
+ * resolving a closure.
124
+ */
125
+ export interface PeerDependencyViolation {
126
+ readonly dependent: {
127
+ readonly name: string;
128
+ readonly version: string;
129
+ };
130
+ readonly peer: {
131
+ readonly name: string;
132
+ readonly range: string;
133
+ };
134
+ readonly satisfiedBy: {
135
+ readonly version: string;
136
+ } | null;
137
+ }
138
+ /**
139
+ * Thrown by `resolveClosure` when the resolved closure does not satisfy
140
+ * one or more peer-dependency declarations, by the closure walker for
141
+ * duplicate-name pins, and by the hub-side session-launch path when a
142
+ * direct package-registry attachment overlaps a resolver-driven pin
143
+ * for the same asset. The deploy-assembly path maps every shape to
144
+ * the same `manifest.invalid` deploy-apply error category before the
145
+ * deploy ships.
146
+ *
147
+ * The category is intentionally broad: peer-dep violations and
148
+ * duplicate-name pins are structurally distinct defects but both
149
+ * indicate a closure the operator cannot ship without changing the
150
+ * pin set. Adding a per-shape category would expand the wire
151
+ * taxonomy for no operator-facing gain; readers parsing
152
+ * deploy-apply errors should look at the message for the structural
153
+ * distinction.
154
+ */
155
+ export declare class ManifestInvalidError extends Error {
156
+ /**
157
+ * Populated only when the constructor was invoked with the
158
+ * structured peer-dependency form. String-constructed instances
159
+ * (duplicate-name pins, direct-vs-resolver-asset conflicts) carry
160
+ * an empty array — readers that need the structural distinction
161
+ * should consult `message` rather than treating `violations.length
162
+ * === 0` as a signal.
163
+ */
164
+ readonly violations: readonly PeerDependencyViolation[];
165
+ constructor(violationsOrMessage: readonly PeerDependencyViolation[] | string);
166
+ }
167
+ /**
168
+ * HTTP-backed registry source. Wraps `npm-registry-fetch` for packument
169
+ * lookups and emits registry-shaped manifest entries.
170
+ */
171
+ export declare class HttpRegistrySource implements RegistrySource {
172
+ #private;
173
+ readonly name: string;
174
+ constructor(args: {
175
+ /** Registry identifier. Becomes the map slot key in the
176
+ * resolver's registries map. */
177
+ readonly name: string;
178
+ readonly config: RegistryConfig;
179
+ /** Test seam: when omitted, the default `npm-registry-fetch`
180
+ * wrapper is used. Production callers omit it. */
181
+ readonly fetchPackument?: PackumentFetcher;
182
+ });
183
+ fetchPackument(name: string): Promise<Packument>;
184
+ materializeRefForEntry(_name: string, _version: string, picked: PackumentVersion): MaterializedRef;
185
+ }
186
+ /**
187
+ * Asset-backed registry source. Reads tarballs from a
188
+ * `package-registry` asset via the asset service's in-process read
189
+ * API, extracts each tarball's `package.json`, and synthesizes a
190
+ * packument keyed by package name with one version entry per tarball.
191
+ *
192
+ * Caller passes bound `readBlob`/`listBlobs` methods so the resolver
193
+ * does not import the asset service directly; the session service
194
+ * adapts `AssetService.readAssetBlob` / `AssetService.listAssetBlobs`
195
+ * to these signatures at construction.
196
+ *
197
+ * The packument is cached in-instance for the lifetime of the source,
198
+ * so one resolution pass touches each tarball at most once even when
199
+ * a name is asked for multiple times.
200
+ */
201
+ export declare class AssetRegistrySource implements RegistrySource {
202
+ #private;
203
+ readonly name: string;
204
+ constructor(args: {
205
+ readonly name: string;
206
+ readonly assetId: string;
207
+ readonly readBlob: (path: string) => Promise<Uint8Array>;
208
+ readonly listBlobs: (dir: string) => Promise<string[]>;
209
+ });
210
+ fetchPackument(name: string): Promise<Packument>;
211
+ /**
212
+ * Resolve a `(name, version)` pair to its asset-relative tarball
213
+ * path. Must be called after `fetchPackument` has populated the
214
+ * internal index for that package — the walker calls them in that
215
+ * order, so the ordering is implicit at the call site. Non-walker
216
+ * callers must call `fetchPackument` first or accept the structured
217
+ * error this method throws when the index lookup misses.
218
+ */
219
+ materializeRefForEntry(name: string, version: string, _picked: PackumentVersion): MaterializedRef;
220
+ }
221
+ export declare function createClosureResolver(config: ClosureResolverConfig): ClosureResolver;
222
+ /**
223
+ * Parse and canonicalize a `name@range` spec into a pin. Throws on
224
+ * unparseable specs or invalid version ranges. Callers that already
225
+ * have a `ToolPackagePin` do not need this helper.
226
+ *
227
+ * A bare `*` is accepted because npm semantics treat it as the
228
+ * any-version range; the resolver then picks whatever the registry
229
+ * advertises as latest at deploy-assembly time. Operators who care
230
+ * about reproducibility of tool-package closures should pin to a
231
+ * concrete range (`^1.2.3`) — `*` lets the closure shift under the
232
+ * agent without any change to the pin set.
233
+ *
234
+ * NOTE: `*` is special-cased in two places — here and inside the
235
+ * `ToolPackagePinArray` narrow at `@intx/types/tool-packages`. The
236
+ * sites live in separate packages by design (resolver vs. wire-type
237
+ * validation) and cannot import each other; any new magic-range
238
+ * additions need to be made at both call sites to keep the
239
+ * REST-boundary validator and the resolver in agreement.
240
+ */
241
+ export declare function parsePin(spec: string): ToolPackagePin;