@intx/tool-packaging 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ import type { RegistryConfig } from "./resolver.js";
2
+ export declare function buildRegistryFetchOpts(registry: RegistryConfig): Record<string, unknown>;
3
+ export declare function defaultTarballUrl(registryUrl: string, name: string, version: string): string;
4
+ /**
5
+ * Read an HTTP-registry tarball response into a Uint8Array while enforcing
6
+ * `maxBytes`. Two guards:
7
+ *
8
+ * 1. If the upstream sent a `Content-Length` header, parse it (digit-
9
+ * only, per RFC 9110 §8.6) and reject up front when the declared
10
+ * length exceeds the cap. A header that fails the digit shape is
11
+ * also rejected so a header like `1e9` cannot read as 1e9 against
12
+ * `Number()` while a digit-only cap check would pass.
13
+ * 2. Stream the body chunk-by-chunk, tallying byte length, and abort
14
+ * the read when the running total crosses the cap. This catches
15
+ * the missing-or-lying header case.
16
+ *
17
+ * The body is read whether it is a web `ReadableStream` (the `getReader`
18
+ * shape a real `Response` exposes, exercised by the test seams) or a
19
+ * Node/Minipass async-iterable of `Buffer` chunks (what
20
+ * `npm-registry-fetch` actually returns in production). Both branches
21
+ * enforce the same running `maxBytes` cap while streaming, so the byte
22
+ * guard is never bypassed by buffering the whole body up front
23
+ * (`arrayBuffer()`).
24
+ *
25
+ * An optional `signal` adds a time guard: when it aborts (the caller's
26
+ * fetch deadline), the in-flight read is cancelled — the web reader via
27
+ * `cancel()`, the Node stream via `destroy()` — and the call rejects, so
28
+ * a registry that streams the body slowly or stalls mid-stream cannot
29
+ * outlast the deadline while staying under the byte cap.
30
+ *
31
+ * All rejections surface as `registry.fetch.failed` so the apply layer
32
+ * routes them the same as any other registry-side fetch defect.
33
+ *
34
+ * Exported for direct unit testing.
35
+ */
36
+ export declare function readResponseWithLimit(res: Response, maxBytes: number, ctx: {
37
+ readonly registry: string;
38
+ readonly name: string;
39
+ readonly version: string;
40
+ }, signal?: AbortSignal): Promise<Uint8Array>;
@@ -0,0 +1,231 @@
1
+ // Registry HTTP fetch helpers for the tool-package loader: building the
2
+ // npm-registry-fetch options for a configured registry, deriving a default
3
+ // tarball URL, and reading a response body under a byte cap. Extracted from
4
+ // `loader.ts` so the fetch concern is isolated from author-code loading and
5
+ // store layout.
6
+ import { ToolLoaderError } from "./loader-internal.js";
7
+ export function buildRegistryFetchOpts(registry) {
8
+ const opts = { registry: registry.url };
9
+ if (registry.auth?.token !== undefined) {
10
+ opts.token = registry.auth.token;
11
+ }
12
+ if (registry.auth?.basic !== undefined) {
13
+ const { user, pass } = registry.auth.basic;
14
+ // `npm-registry-fetch` builds the `Authorization: Basic` header by
15
+ // base64-encoding `<username>:<password>` itself. Pre-encoding
16
+ // `pass` would double-encode the password component (the registry
17
+ // would see `base64(plaintext)` as the password, not `plaintext`).
18
+ opts.forceAuth = { username: user, password: pass };
19
+ }
20
+ return opts;
21
+ }
22
+ export function defaultTarballUrl(registryUrl, name, version) {
23
+ const base = registryUrl.endsWith("/") ? registryUrl : `${registryUrl}/`;
24
+ // Match npm's canonical tarball URL: {registry}/{name}/-/{basename}-{version}.tgz
25
+ const basename = name.startsWith("@") ? name.split("/")[1] : name;
26
+ if (basename === undefined) {
27
+ throw new Error(`internal: cannot derive tarball basename for ${name}`);
28
+ }
29
+ return `${base}${name}/-/${basename}-${version}.tgz`;
30
+ }
31
+ /**
32
+ * True when `body` is a web `ReadableStream`-shaped value the reader loop
33
+ * can pull through `getReader()`. Test seams that build a real `Response`
34
+ * hit this path; the production `npm-registry-fetch` body does not.
35
+ */
36
+ function hasWebReadableBody(body) {
37
+ return (typeof body === "object" &&
38
+ body !== null &&
39
+ "getReader" in body &&
40
+ typeof body.getReader === "function");
41
+ }
42
+ /**
43
+ * True when `body` is a Node-style byte stream: async-iterable, with an
44
+ * optional `destroy` the abort path uses to tear down a stalled read.
45
+ * This is the shape `npm-registry-fetch`'s Minipass response body has —
46
+ * chunks are validated as `Uint8Array` per-iteration, so the element type
47
+ * is left `unknown` here rather than asserted.
48
+ */
49
+ function isByteStreamAsyncIterable(body) {
50
+ return (typeof body === "object" &&
51
+ body !== null &&
52
+ Symbol.asyncIterator in body &&
53
+ typeof body[Symbol.asyncIterator] === "function");
54
+ }
55
+ /**
56
+ * Read an HTTP-registry tarball response into a Uint8Array while enforcing
57
+ * `maxBytes`. Two guards:
58
+ *
59
+ * 1. If the upstream sent a `Content-Length` header, parse it (digit-
60
+ * only, per RFC 9110 §8.6) and reject up front when the declared
61
+ * length exceeds the cap. A header that fails the digit shape is
62
+ * also rejected so a header like `1e9` cannot read as 1e9 against
63
+ * `Number()` while a digit-only cap check would pass.
64
+ * 2. Stream the body chunk-by-chunk, tallying byte length, and abort
65
+ * the read when the running total crosses the cap. This catches
66
+ * the missing-or-lying header case.
67
+ *
68
+ * The body is read whether it is a web `ReadableStream` (the `getReader`
69
+ * shape a real `Response` exposes, exercised by the test seams) or a
70
+ * Node/Minipass async-iterable of `Buffer` chunks (what
71
+ * `npm-registry-fetch` actually returns in production). Both branches
72
+ * enforce the same running `maxBytes` cap while streaming, so the byte
73
+ * guard is never bypassed by buffering the whole body up front
74
+ * (`arrayBuffer()`).
75
+ *
76
+ * An optional `signal` adds a time guard: when it aborts (the caller's
77
+ * fetch deadline), the in-flight read is cancelled — the web reader via
78
+ * `cancel()`, the Node stream via `destroy()` — and the call rejects, so
79
+ * a registry that streams the body slowly or stalls mid-stream cannot
80
+ * outlast the deadline while staying under the byte cap.
81
+ *
82
+ * All rejections surface as `registry.fetch.failed` so the apply layer
83
+ * routes them the same as any other registry-side fetch defect.
84
+ *
85
+ * Exported for direct unit testing.
86
+ */
87
+ export async function readResponseWithLimit(res, maxBytes, ctx, signal) {
88
+ const declaredLengthRaw = res.headers.get("content-length");
89
+ if (declaredLengthRaw !== null) {
90
+ if (!/^\d+$/.test(declaredLengthRaw)) {
91
+ throw new ToolLoaderError({
92
+ category: "registry.fetch.failed",
93
+ message: `registry "${ctx.registry}" returned non-digit Content-Length ${JSON.stringify(declaredLengthRaw)} for ${ctx.name}@${ctx.version}`,
94
+ package: { name: ctx.name, version: ctx.version },
95
+ });
96
+ }
97
+ const declaredLength = Number(declaredLengthRaw);
98
+ if (!Number.isFinite(declaredLength) || declaredLength > maxBytes) {
99
+ throw new ToolLoaderError({
100
+ category: "registry.fetch.failed",
101
+ message: `tarball for ${ctx.name}@${ctx.version} declares Content-Length ${declaredLengthRaw} which exceeds the ${String(maxBytes)}-byte cap`,
102
+ package: { name: ctx.name, version: ctx.version },
103
+ });
104
+ }
105
+ }
106
+ const timeoutError = () => new ToolLoaderError({
107
+ category: "registry.fetch.failed",
108
+ message: `tarball read for ${ctx.name}@${ctx.version} exceeded the registry fetch timeout`,
109
+ package: { name: ctx.name, version: ctx.version },
110
+ });
111
+ const capOverflowError = () => new ToolLoaderError({
112
+ category: "registry.fetch.failed",
113
+ message: `tarball for ${ctx.name}@${ctx.version} streamed past the ${String(maxBytes)}-byte cap`,
114
+ package: { name: ctx.name, version: ctx.version },
115
+ });
116
+ // `res.body`'s declared web-`ReadableStream` type is a lie on the
117
+ // production path: `npm-registry-fetch` returns a Minipass (Node)
118
+ // stream that has no `getReader`, only async iteration. Treat the body
119
+ // as an unvalidated boundary value and dispatch on its actual runtime
120
+ // shape rather than trusting the declared type.
121
+ const body = res.body;
122
+ if (body === null || body === undefined) {
123
+ // No body and the upstream returned 2xx: treat as a zero-byte
124
+ // tarball. The cache and tar-extract layers will reject the
125
+ // resulting bytes as non-tar content, but the fetch itself didn't
126
+ // fail — keep this path simple rather than over-rejecting.
127
+ return new Uint8Array(0);
128
+ }
129
+ const chunks = [];
130
+ let total = 0;
131
+ if (hasWebReadableBody(body)) {
132
+ const reader = body.getReader();
133
+ // Cancelling the reader settles any pending read() as done, so the
134
+ // post-read check below surfaces the timeout even when the underlying
135
+ // body stream does not itself observe the abort signal.
136
+ let timedOut = false;
137
+ const onAbort = () => {
138
+ timedOut = true;
139
+ void reader.cancel();
140
+ };
141
+ signal?.addEventListener("abort", onAbort, { once: true });
142
+ if (signal?.aborted === true)
143
+ onAbort();
144
+ try {
145
+ for (;;) {
146
+ const { value, done } = await reader.read();
147
+ if (timedOut)
148
+ throw timeoutError();
149
+ if (done)
150
+ break;
151
+ if (value === undefined)
152
+ continue;
153
+ total += value.byteLength;
154
+ if (total > maxBytes) {
155
+ // Stop reading; we already have enough evidence the upstream
156
+ // is over the cap. The reader.cancel() call requests
157
+ // cancellation upstream; the runtime decides whether to drop
158
+ // the in-flight TCP frames or just unsubscribe our reader.
159
+ await reader.cancel();
160
+ throw capOverflowError();
161
+ }
162
+ chunks.push(value);
163
+ }
164
+ }
165
+ finally {
166
+ signal?.removeEventListener("abort", onAbort);
167
+ reader.releaseLock();
168
+ }
169
+ }
170
+ else if (isByteStreamAsyncIterable(body)) {
171
+ // A `for await` parked awaiting the next chunk cannot observe the
172
+ // abort flag until a chunk (or the stream's end) arrives, so unlike
173
+ // the web reader's cancel() the flag alone cannot unblock a stalled
174
+ // read. destroy() forces the iteration to settle — it rejects with a
175
+ // stream-teardown error, which the catch below rewrites to the
176
+ // deadline error when the teardown was ours.
177
+ let timedOut = false;
178
+ const onAbort = () => {
179
+ timedOut = true;
180
+ if (typeof body.destroy === "function")
181
+ body.destroy();
182
+ };
183
+ signal?.addEventListener("abort", onAbort, { once: true });
184
+ if (signal?.aborted === true)
185
+ onAbort();
186
+ try {
187
+ for await (const chunk of body) {
188
+ if (timedOut)
189
+ throw timeoutError();
190
+ if (!(chunk instanceof Uint8Array)) {
191
+ throw new ToolLoaderError({
192
+ category: "registry.fetch.failed",
193
+ message: `registry "${ctx.registry}" streamed a non-binary chunk fetching ${ctx.name}@${ctx.version}`,
194
+ package: { name: ctx.name, version: ctx.version },
195
+ });
196
+ }
197
+ total += chunk.byteLength;
198
+ if (total > maxBytes)
199
+ throw capOverflowError();
200
+ chunks.push(chunk);
201
+ }
202
+ // An abort that lands after the final chunk destroys the stream
203
+ // without rejecting the iteration; surface the timeout here so that
204
+ // race does not slip through as a successful read.
205
+ if (timedOut)
206
+ throw timeoutError();
207
+ }
208
+ catch (err) {
209
+ if (timedOut && !(err instanceof ToolLoaderError))
210
+ throw timeoutError();
211
+ throw err;
212
+ }
213
+ finally {
214
+ signal?.removeEventListener("abort", onAbort);
215
+ }
216
+ }
217
+ else {
218
+ throw new ToolLoaderError({
219
+ category: "registry.fetch.failed",
220
+ message: `registry "${ctx.registry}" returned a response body of an unreadable shape for ${ctx.name}@${ctx.version}`,
221
+ package: { name: ctx.name, version: ctx.version },
222
+ });
223
+ }
224
+ const out = new Uint8Array(total);
225
+ let offset = 0;
226
+ for (const chunk of chunks) {
227
+ out.set(chunk, offset);
228
+ offset += chunk.byteLength;
229
+ }
230
+ return out;
231
+ }
@@ -1,3 +1,4 @@
1
+ import type { ToolCredentialDeclaration } from "@intx/types/package-json";
1
2
  import { type ToolPackageManifest, type ToolPackagePin, type ToolPackageSource } from "@intx/types/tool-packages";
2
3
  /**
3
4
  * One HTTP registry. `url` is the actual endpoint; `auth.token` and
@@ -54,6 +55,7 @@ export interface PackumentVersion {
54
55
  }>;
55
56
  os?: string[];
56
57
  cpu?: string[];
58
+ credentials?: ToolCredentialDeclaration[];
57
59
  }
58
60
  /**
59
61
  * The packument document a registry serves for one package. Shape
@@ -73,8 +75,9 @@ export type PackumentFetcher = (packageName: string, registry: RegistryConfig) =
73
75
  /**
74
76
  * The shape returned by `RegistrySource.materializeRefForEntry`. The
75
77
  * 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
+ * emits, alongside `name`, `version`, and the platform filter metadata
79
+ * it derives from the picked packument version. The content identity
80
+ * (`integrity`) rides on `source`, so the impl embeds it there.
78
81
  */
79
82
  export interface MaterializedRef {
80
83
  readonly source: ToolPackageSource;
@@ -96,7 +99,7 @@ export interface MaterializedRef {
96
99
  export interface RegistrySource {
97
100
  readonly name: string;
98
101
  fetchPackument(name: string): Promise<Packument>;
99
- materializeRefForEntry(name: string, version: string, picked: PackumentVersion): MaterializedRef;
102
+ materializeRefForEntry(name: string, version: string, picked: PackumentVersion, integrity: string): MaterializedRef;
100
103
  }
101
104
  /**
102
105
  * Configuration for `createClosureResolver`.
@@ -181,7 +184,7 @@ export declare class HttpRegistrySource implements RegistrySource {
181
184
  readonly fetchPackument?: PackumentFetcher;
182
185
  });
183
186
  fetchPackument(name: string): Promise<Packument>;
184
- materializeRefForEntry(_name: string, _version: string, picked: PackumentVersion): MaterializedRef;
187
+ materializeRefForEntry(_name: string, _version: string, picked: PackumentVersion, integrity: string): MaterializedRef;
185
188
  }
186
189
  /**
187
190
  * Asset-backed registry source. Reads tarballs from a
@@ -216,7 +219,7 @@ export declare class AssetRegistrySource implements RegistrySource {
216
219
  * callers must call `fetchPackument` first or accept the structured
217
220
  * error this method throws when the index lookup misses.
218
221
  */
219
- materializeRefForEntry(name: string, version: string, _picked: PackumentVersion): MaterializedRef;
222
+ materializeRefForEntry(name: string, version: string, _picked: PackumentVersion, integrity: string): MaterializedRef;
220
223
  }
221
224
  export declare function createClosureResolver(config: ClosureResolverConfig): ClosureResolver;
222
225
  /**
package/dist/resolver.js CHANGED
@@ -98,9 +98,9 @@ export class HttpRegistrySource {
98
98
  async fetchPackument(name) {
99
99
  return this.#fetchPackument(name, this.#config);
100
100
  }
101
- materializeRefForEntry(_name, _version, picked) {
101
+ materializeRefForEntry(_name, _version, picked, integrity) {
102
102
  return {
103
- source: { kind: "registry", registry: this.name },
103
+ source: { kind: "registry", registry: this.name, integrity },
104
104
  tarballUrl: picked.dist.tarball,
105
105
  };
106
106
  }
@@ -156,7 +156,7 @@ export class AssetRegistrySource {
156
156
  * callers must call `fetchPackument` first or accept the structured
157
157
  * error this method throws when the index lookup misses.
158
158
  */
159
- materializeRefForEntry(name, version, _picked) {
159
+ materializeRefForEntry(name, version, _picked, integrity) {
160
160
  const key = `${name}@${version}`;
161
161
  const path = this.#pathByNameVersion.get(key);
162
162
  if (path === undefined) {
@@ -173,7 +173,7 @@ export class AssetRegistrySource {
173
173
  source: {
174
174
  kind: "asset",
175
175
  assetId: this.#assetId,
176
- path,
176
+ package: { format: "tarball", path, integrity },
177
177
  },
178
178
  };
179
179
  }
@@ -201,6 +201,11 @@ export class AssetRegistrySource {
201
201
  version: validated.version,
202
202
  dist: { tarball: repoPath, integrity },
203
203
  ...readDependencyFields(extracted.raw),
204
+ // Read from the arktype-validated `parsed` (credentials is in-schema),
205
+ // not `raw`. Absent stays absent -- never defaulted to an empty array.
206
+ ...(validated.interchange?.credentials !== undefined
207
+ ? { credentials: validated.interchange.credentials }
208
+ : {}),
204
209
  };
205
210
  if (packument === undefined) {
206
211
  byName.set(validated.name, {
@@ -400,6 +405,10 @@ export function createClosureResolver(config) {
400
405
  // top-level resolution.
401
406
  const topLevelNames = new Set(pins.map((p) => p.name));
402
407
  const topLevelResolved = new Map();
408
+ // Credential declarations harvested from each top-level package,
409
+ // captured alongside the resolved version. Only top-level pins
410
+ // contribute; a transitive dependency's declaration is never recorded.
411
+ const topLevelDeclarations = new Map();
403
412
  // Optional-subtree bookkeeping. Subtree-tagged entries and peer
404
413
  // declarations stay in their per-subtree stash until the whole
405
414
  // subtree finishes successfully, at which point they merge into
@@ -517,6 +526,9 @@ export function createClosureResolver(config) {
517
526
  }
518
527
  if (topLevelNames.has(next.name) && !topLevelResolved.has(next.name)) {
519
528
  topLevelResolved.set(next.name, picked.version);
529
+ if (picked.credentials !== undefined) {
530
+ topLevelDeclarations.set(next.name, picked.credentials);
531
+ }
520
532
  }
521
533
  const key = `${picked.name}@${picked.version}`;
522
534
  const alreadyInHardClosure = entries.has(key);
@@ -543,7 +555,7 @@ export function createClosureResolver(config) {
543
555
  // rather than aborting the whole closure walk.
544
556
  let ref;
545
557
  try {
546
- ref = source.materializeRefForEntry(picked.name, picked.version, picked);
558
+ ref = source.materializeRefForEntry(picked.name, picked.version, picked, picked.dist.integrity);
547
559
  }
548
560
  catch (err) {
549
561
  if (next.subtreeId !== null) {
@@ -556,7 +568,6 @@ export function createClosureResolver(config) {
556
568
  const entry = {
557
569
  name: picked.name,
558
570
  version: picked.version,
559
- integrity: picked.dist.integrity,
560
571
  source: ref.source,
561
572
  ...(ref.tarballUrl !== undefined
562
573
  ? { tarballUrl: ref.tarballUrl }
@@ -638,7 +649,12 @@ export function createClosureResolver(config) {
638
649
  // in the walker, not in the input.
639
650
  throw new Error(`resolver internal error: top-level pin ${p.name}@${p.version} did not resolve to a concrete version`);
640
651
  }
641
- return { name: p.name, version: resolved };
652
+ const credentials = topLevelDeclarations.get(p.name);
653
+ return {
654
+ name: p.name,
655
+ version: resolved,
656
+ ...(credentials !== undefined ? { credentials } : {}),
657
+ };
642
658
  }),
643
659
  entries: Array.from(entries.values()),
644
660
  };
@@ -0,0 +1,25 @@
1
+ import type { MaterializeClosureArgs, MaterializeClosureResult } from "./loader.js";
2
+ /**
3
+ * Lay out a resolved manifest closure into the per-instance store
4
+ * WITHOUT importing any author code. This is phases 1-2 of the loader:
5
+ *
6
+ * 1. Fetch + SRI-verify + extract each platform-matching entry into
7
+ * the content-addressable cache.
8
+ * 2. Resolve the closure's dependency ranges by first arrival, then
9
+ * copy each entry into `<instanceScratchDir>/store/<name>/<version>/`
10
+ * and symlink each direct dep into that entry's `node_modules/` so
11
+ * Node's ancestor walk resolves bare-specifier imports.
12
+ *
13
+ * Returns the `storeDir` the closure was laid out under. The first real
14
+ * `import()` of author code is NOT here — it belongs to `loadManifest`'s
15
+ * phase-3 loop — so a caller (e.g. an install-time probe) can stage the
16
+ * frozen closure into a package directory the loader consumes without
17
+ * executing author code on its host.
18
+ *
19
+ * Only entries whose `os`/`cpu` match `host` are materialized; the rest
20
+ * are skipped with a `platform.mismatch.skipped` debug log. Errors
21
+ * surface as `ToolLoaderError` with a `category` matching the
22
+ * corresponding `DeployApplyErrorCategory`.
23
+ */
24
+ export declare function materializeClosure(args: MaterializeClosureArgs): Promise<MaterializeClosureResult>;
25
+ export declare function storeEntryDir(storeDir: string, name: string, version: string): string;