@jini-ai/platform 0.6.12 → 0.6.15-rc.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.
- package/CHANGELOG.md +33 -0
- package/README.md +54 -0
- package/dist/asset-cache.js +27 -13
- package/dist/command.d.ts +1 -1
- package/dist/command.js +11 -8
- package/dist/download.js +26 -32
- package/dist/fetch-with-timeout.d.ts +77 -0
- package/dist/fetch-with-timeout.js +148 -2
- package/dist/fs/directory-user-config.js +2 -1
- package/dist/fs/directory-workspace.js +3 -1
- package/dist/fs/guarded-reader.d.ts +19 -47
- package/dist/fs/guarded-reader.js +82 -13
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mail/adapter-module.d.ts +18 -0
- package/dist/mail/adapter-module.js +13 -1
- package/dist/mail/email-sender-registry.d.ts +15 -0
- package/dist/mail/email-sender-registry.js +22 -0
- package/dist/mail/email-sender.d.ts +109 -0
- package/dist/mail/email-sender.js +10 -0
- package/dist/mail/index.d.ts +16 -2
- package/dist/mail/index.js +9 -1
- package/dist/mail/mail-status-cache.d.ts +33 -0
- package/dist/mail/mail-status-cache.js +56 -0
- package/dist/mail/prepare-sender-email.d.ts +35 -0
- package/dist/mail/prepare-sender-email.js +68 -0
- package/dist/mail/provider.js +4 -1
- package/dist/mail/resolve-mailer.d.ts +95 -0
- package/dist/mail/resolve-mailer.js +348 -0
- package/dist/mail/senders.d.ts +5 -0
- package/dist/mail/senders.js +5 -0
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
- package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
- package/dist/secrets/credential-sets/vault.service.js +18 -9
- package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
- package/package.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @jini-ai/platform
|
|
2
2
|
|
|
3
|
+
## 0.6.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5634614: Add vendor-neutral email sender contracts, source discovery and template preparation to the mail
|
|
8
|
+
surface, with a universal `@jini-ai/platform/mail/senders` entry. Add the Forms sender to
|
|
9
|
+
`@jini-ai/cms/forms/email-sender`, deriving tokens from stable field IDs, resolving authorized submissions and
|
|
10
|
+
permitted recipients, and configuring sender-owned email actions through an injected guarded writer.
|
|
11
|
+
Delivery planning and production readiness remain host responsibilities.
|
|
12
|
+
The dedicated sender entry requires the optional platform peer at ^0.6.15; the existing Forms
|
|
13
|
+
entry continues to load without platform installed.
|
|
14
|
+
- 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
|
|
15
|
+
shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
|
|
16
|
+
(guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
|
|
17
|
+
`@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
|
|
18
|
+
filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
|
|
19
|
+
breaks are listed in `owner-store-to-server` and `guard-host-ports`.
|
|
20
|
+
- 26bd2d2: Expose the mail engine through `@jini-ai/platform/mail`: host contracts, redacted
|
|
21
|
+
provider diagnostics, permission-aware domain reads, sender verification, delivery
|
|
22
|
+
policy wrapping, the purpose-gate predicate, provider resolution and status caching.
|
|
23
|
+
Retain existing exports and keep credential binding, persistence and product policy
|
|
24
|
+
in host-supplied ports.
|
|
25
|
+
- c14aeb4: Add optional fetch injection, bounded/strict UTF-8 response text reading, text consumption within the fetch timeout scope, and descriptor-based guarded file reads. Existing defaults and positional fetch signatures remain unchanged.
|
|
26
|
+
|
|
27
|
+
Add an opt-in details overload to readFetchResponseText with retained-byte metadata, bounded-prefix truncation, and abortable reads with prompt cleanup. fetchTextWithTimeout now races every body read against its timeout, so an injected transport whose body ignores the abort signal still times out.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [7479159]
|
|
30
|
+
- Updated dependencies [c31264d]
|
|
31
|
+
- Updated dependencies [0728914]
|
|
32
|
+
- Updated dependencies [6ecbdb2]
|
|
33
|
+
- Updated dependencies [63bacc5]
|
|
34
|
+
- @jini-ai/core@0.6.13
|
|
35
|
+
|
|
3
36
|
## 0.6.12
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ need a `PtySpawn` implementation supplied by the caller — this package defines
|
|
|
37
37
|
so profile-only `PATH` entries are visible).
|
|
38
38
|
- **Filesystem** — `pathContains`, `atomicCopyFile`, `removePathBestEffort`, `readLogTail`.
|
|
39
39
|
- **HTTP readiness** — `waitForHttpOk`.
|
|
40
|
+
- **Deadline signals** — `createDeadlineSignal({ waitMs }, { signal, startedAt, now })` from `@jini-ai/platform/fetch-with-timeout`; subtracts elapsed milliseconds using the optional `now: () => number` clock (default `Date.now`) and combines the remaining budget with caller cancellation.
|
|
40
41
|
- **Toolchain discovery** — `wellKnownUserToolchainBins` (npm/pnpm/bun/cargo/deno/go/pyenv,
|
|
41
42
|
asdf/volta/mise/nvm/fnm shims).
|
|
42
43
|
- **Proxy-aware env** — `resolveSystemProxyEnv`, `mergeProxyAwareEnv` (macOS `scutil` / Windows
|
|
@@ -98,6 +99,15 @@ The package also exposes `./http/guarded`, `./mail`, `./mail/smtp`,
|
|
|
98
99
|
`./fs/guarded-reader`, `./fs/durable-json`, and `./secrets`. New APIs take a required
|
|
99
100
|
object followed by an optional object. Application identity and policy values belong to the caller.
|
|
100
101
|
|
|
102
|
+
Email event senders use the universal `@jini-ai/platform/mail/senders` entry (also exported by `/mail`).
|
|
103
|
+
`EmailSender` exposes `listSources`, `describeSource`, `resolveEvent`, and `configureEmail`.
|
|
104
|
+
Each operation authorizes its workspace and principal; the sender owns values, recipients and binding.
|
|
105
|
+
Delivery providers are separate; `createEmailSenderRegistry` reuses core's keyed registry.
|
|
106
|
+
`listEmailSources` lists events across senders; `validateEmailSourceTemplate` checks compatibility.
|
|
107
|
+
`prepareSenderEmail` refuses disabled bindings and events before the latest activation.
|
|
108
|
+
Preparation renders through the injected version-checked template port and sends nothing.
|
|
109
|
+
Its result can be frozen in the feature's existing job; missing optional values produce token warnings.
|
|
110
|
+
|
|
101
111
|
`@jini-ai/platform/web` loads these optional peers at import time: `parse5@7.3.0`,
|
|
102
112
|
`hast-util-from-parse5@8.0.3`, `hast-util-to-mdast@10.1.2`,
|
|
103
113
|
`mdast-util-to-markdown@2.1.2`, and `mdast-util-gfm-table@2.0.0`. Install all five
|
|
@@ -142,6 +152,49 @@ The SMTP subpath accepts a transport and clock; its nodemailer factory accepts t
|
|
|
142
152
|
as a structural port. Core loads no mail vendor and adds no mail dependency.
|
|
143
153
|
|
|
144
154
|
The guarded reader requires root, deny rules, all read/traversal limits, and a filesystem port.
|
|
155
|
+
Its existing options object also accepts `descriptorRead: { exactMode?: number, rejectGrowth?: boolean }`.
|
|
156
|
+
When present (including `{}`), reads open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, verify
|
|
157
|
+
regular-file status and size with `fstatSync`, and read that same descriptor. `exactMode` compares
|
|
158
|
+
exact permission bits including setuid/setgid/sticky (`mode & 0o7777`); `rejectGrowth: true` rejects bytes beyond its initial size.
|
|
159
|
+
The filesystem's optional `fstatSync` and `constants` capabilities become required for this policy;
|
|
160
|
+
unsupported flags fail closed. No-follow protects the final path component; ancestor-directory
|
|
161
|
+
replacement races remain outside this guarantee. Omitting the policy retains existing reads.
|
|
162
|
+
|
|
163
|
+
`@jini-ai/platform/fetch-with-timeout` also exports `TextReadOptions`,
|
|
164
|
+
`readFetchResponseText({ response }, { maxResponseBytes?, strictUtf8? })`, and
|
|
165
|
+
`fetchTextWithTimeout({ url, timeoutMs, init? }, { fetch?, maxResponseBytes?, strictUtf8? })`.
|
|
166
|
+
Text defaults to unlimited bytes and replacement UTF-8 decoding. Caps must be nonnegative safe
|
|
167
|
+
integers; exactly the cap is accepted, overflow throws `RangeError`, and strict UTF-8 throws on
|
|
168
|
+
malformed/truncated input. Streamed bytes are counted before decoding, independent of Content-Length;
|
|
169
|
+
failed guarded consumption cancels and releases the reader without masking the original error.
|
|
170
|
+
Incoming chunk allocation is controlled by the upstream stream. The text-fetch helper maps timeout
|
|
171
|
+
errors during both headers and body consumption to `FetchTimeoutError`, returns `{ response, text }`,
|
|
172
|
+
and leaves the response metadata available with its body consumed. The existing positional
|
|
173
|
+
`fetchWithTimeout(url, init, { timeoutMs, fetch? })` now accepts transport injection and still returns
|
|
174
|
+
the original unconsumed response. Neither function retries.
|
|
175
|
+
|
|
176
|
+
For retained-byte metadata and optional truncation, use the single-object overload:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const { text, byteLength, truncated } = await readFetchResponseText({
|
|
180
|
+
response,
|
|
181
|
+
maxResponseBytes: 1024,
|
|
182
|
+
result: "details",
|
|
183
|
+
onLimit: "truncate",
|
|
184
|
+
signal,
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
This overload requires a nonnegative safe-integer cap. `onLimit` defaults to `"reject"`;
|
|
189
|
+
`strictUtf8` defaults to `false`. `byteLength` counts retained source bytes before decoding,
|
|
190
|
+
and `truncated` becomes true only after observing bytes beyond the cap. An exact-sized body
|
|
191
|
+
(including an empty body at a zero cap) requires EOF to establish completeness. Truncation
|
|
192
|
+
cancels consumption and flushes the decoder; cutting a UTF-8 character produces replacement
|
|
193
|
+
text by default or throws with `strictUtf8: true`. A signal interrupts pending reads and
|
|
194
|
+
preserves its abort reason. Details-mode cancellation failures are observed without waiting
|
|
195
|
+
for cancellation to settle, and every acquired reader's lock is released. Existing string
|
|
196
|
+
overloads, defaults, and text-fetch behavior remain unchanged.
|
|
197
|
+
|
|
145
198
|
The durable JSON factory requires file path, filesystem/time/sleep/liveness/random/reporting ports,
|
|
146
199
|
and host notices. It preserves atomic replacement and corrupt-file states; parent-directory
|
|
147
200
|
persistence and the inherited simultaneous stale-lock-break race remain filesystem/locking limits.
|
|
@@ -169,6 +222,7 @@ vendor-shaped `NativeSmtpTransport` so the factory adapts the payload once. Poli
|
|
|
169
222
|
|
|
170
223
|
| Subpath | API and required host ports/policy |
|
|
171
224
|
| --- | --- |
|
|
225
|
+
| `./mail` | `createMailAdapterContext({ credential, kit }, { now, settings })` supplies a live clock and fresh empty settings by default; modules validate opaque per-install settings. |
|
|
172
226
|
| `@jini-ai/analytics` (separate package) | `ingestHit({ input, deps }, { hooks, hash })`, `normalizeIngestContext({ input, dailySalt }, { hash })`, `deriveDailySalt({ rootKeySeed, workspaceId, utcDate, saltContext })`. The host supplies sink/config, host resolution, HKDF context, and privacy bounds. `LocalBufferSink({}, { initialHits })` is process-local. |
|
|
173
227
|
| `@jini-ai/cms/trash` (separate package) | `createTrashService({ repo, adapters, transaction, idGen, entityPolicy }, { retentionDays, ...optional })`, `createTrashSweep({ repo, adapters, transaction, entityPolicy })`, and `startTrashSweeper({ sweep, clock, scheduler, leaseOwner }, optional)`. The host owns authorization, transactions, retention, scheduling, and adapters; follow-up hooks are in argument two. |
|
|
174
228
|
| `./fs` | `writeFileAtomic({ filePath, content, fs, tempName })`, `writeJsonFileAtomic({ filePath, data, fs, tempName })`, `upsertEnvLine({ source, key, value })`, `readEnvLine({ source, key })`, `resolvePathWithin({ root, segment })`. Atomic writes preserve existing modes and require filesystem/temp-name ports. |
|
package/dist/asset-cache.js
CHANGED
|
@@ -29,7 +29,7 @@ import { isPrivateAddress } from './net/address.js';
|
|
|
29
29
|
/** Media extensions we are willing to cache + proxy. Anything else (HTML,
|
|
30
30
|
* CSS, JS, fonts) is intentionally left alone so this surface never becomes a
|
|
31
31
|
* general-purpose open proxy. */
|
|
32
|
-
export const CACHEABLE_MEDIA_EXT = /\.(?:png|jpe?g|webp|gif|avif|
|
|
32
|
+
export const CACHEABLE_MEDIA_EXT = /\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov|m4v|ogg|mp3|wav)$/i;
|
|
33
33
|
const EXT_TO_MIME = {
|
|
34
34
|
'.png': 'image/png',
|
|
35
35
|
'.jpg': 'image/jpeg',
|
|
@@ -37,7 +37,6 @@ const EXT_TO_MIME = {
|
|
|
37
37
|
'.webp': 'image/webp',
|
|
38
38
|
'.gif': 'image/gif',
|
|
39
39
|
'.avif': 'image/avif',
|
|
40
|
-
'.svg': 'image/svg+xml',
|
|
41
40
|
'.mp4': 'video/mp4',
|
|
42
41
|
'.webm': 'video/webm',
|
|
43
42
|
'.mov': 'video/quicktime',
|
|
@@ -46,6 +45,7 @@ const EXT_TO_MIME = {
|
|
|
46
45
|
'.mp3': 'audio/mpeg',
|
|
47
46
|
'.wav': 'audio/wav',
|
|
48
47
|
};
|
|
48
|
+
const CACHEABLE_MEDIA_MIME = new Set(Object.values(EXT_TO_MIME));
|
|
49
49
|
/** True when `raw` is an absolute http(s) URL pointing at a media file we are
|
|
50
50
|
* willing to route through the cache. Pure + side-effect free so the URL
|
|
51
51
|
* rewriter and tests can share the exact predicate. */
|
|
@@ -55,11 +55,14 @@ export function isCacheableExternalUrl(raw) {
|
|
|
55
55
|
const value = raw.trim();
|
|
56
56
|
if (!/^https?:\/\//i.test(value))
|
|
57
57
|
return false;
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
// Only the parsed pathname establishes eligibility; a query/fragment that
|
|
59
|
+
// happens to end in .png must not turn an arbitrary endpoint into media.
|
|
60
|
+
try {
|
|
61
|
+
return CACHEABLE_MEDIA_EXT.test(new URL(value).pathname);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
/** Map a cacheable external URL to its same-origin proxy URL. `routePath`
|
|
65
68
|
* defaults to `/api/asset-cache` but is caller-configurable since the route
|
|
@@ -158,13 +161,16 @@ export function createAssetCache(opts, options = {}) {
|
|
|
158
161
|
const suppliedDispatcher = options.dispatcher ?? opts.dispatcher;
|
|
159
162
|
const dispatcher = suppliedDispatcher ?? createAssetCacheDispatcher({}, opts.lookupImpl ? { lookupImpl: opts.lookupImpl } : {});
|
|
160
163
|
const inflight = new Map();
|
|
161
|
-
async function readFromDisk(key) {
|
|
164
|
+
async function readFromDisk({ key }, _options = {}) {
|
|
162
165
|
const blobPath = path.join(cacheDir, key);
|
|
163
166
|
const metaPath = `${blobPath}.json`;
|
|
164
167
|
try {
|
|
165
168
|
const [buf, metaRaw] = await Promise.all([readFile(blobPath), readFile(metaPath, 'utf8')]);
|
|
166
169
|
const meta = JSON.parse(metaRaw);
|
|
167
|
-
const contentType =
|
|
170
|
+
const contentType = meta.contentType;
|
|
171
|
+
// Old cache entries must satisfy the same passive-media policy as new fetches.
|
|
172
|
+
if (typeof contentType !== 'string' || !CACHEABLE_MEDIA_MIME.has(contentType))
|
|
173
|
+
return null;
|
|
168
174
|
return { buf, contentType };
|
|
169
175
|
}
|
|
170
176
|
catch {
|
|
@@ -179,15 +185,23 @@ export function createAssetCache(opts, options = {}) {
|
|
|
179
185
|
await rename(tmpPath, blobPath);
|
|
180
186
|
await writeFile(`${blobPath}.json`, JSON.stringify({ contentType: result.contentType }));
|
|
181
187
|
}
|
|
182
|
-
function resolveContentType(headerValue, url) {
|
|
188
|
+
function resolveContentType({ headerValue, url }, _options = {}) {
|
|
189
|
+
// Moved from URL eligibility, which now uses URL.pathname instead of split:
|
|
190
|
+
// `String#split` always returns a non-empty array (even `''.split(x)` is
|
|
191
|
+
// `['']`), so index 0 is never `undefined` — the `!` narrows a
|
|
192
|
+
// `noUncheckedIndexedAccess` false positive, not a real fallback path.
|
|
183
193
|
// `(headerValue ?? '')` is reachable (no content-type header at all). The
|
|
184
194
|
// trailing `?? ''` after the optional-chained `.split(';')[0]?.trim()` is
|
|
185
195
|
// not: `String#split` always returns a non-empty array, so `[0]` is never
|
|
186
196
|
// `undefined` and `?.` never short-circuits — kept for
|
|
187
197
|
// `noUncheckedIndexedAccess`, not a real fallback path.
|
|
188
198
|
const header = (headerValue ?? '').split(';')[0].trim().toLowerCase();
|
|
189
|
-
if (
|
|
199
|
+
if (CACHEABLE_MEDIA_MIME.has(header))
|
|
190
200
|
return header;
|
|
201
|
+
// An explicit active/unsupported type must never be relabelled by its URL.
|
|
202
|
+
if (header && header !== 'application/octet-stream') {
|
|
203
|
+
throw new AssetCacheError(415, `unsupported content-type: ${header}`);
|
|
204
|
+
}
|
|
191
205
|
const ext = path.posix.extname(url.pathname).toLowerCase();
|
|
192
206
|
const guessed = EXT_TO_MIME[ext];
|
|
193
207
|
if (guessed)
|
|
@@ -262,7 +276,7 @@ export function createAssetCache(opts, options = {}) {
|
|
|
262
276
|
const declaredLength = Number(response.headers.get('content-length') ?? '');
|
|
263
277
|
if (Number.isFinite(declaredLength))
|
|
264
278
|
assertWithinLimit(declaredLength);
|
|
265
|
-
const contentType = resolveContentType(response.headers.get('content-type'), url);
|
|
279
|
+
const contentType = resolveContentType({ headerValue: response.headers.get('content-type'), url });
|
|
266
280
|
const buf = await readBodyCapped(response, controller);
|
|
267
281
|
const result = { buf, contentType };
|
|
268
282
|
try {
|
|
@@ -283,7 +297,7 @@ export function createAssetCache(opts, options = {}) {
|
|
|
283
297
|
}
|
|
284
298
|
const url = rawUrl.trim();
|
|
285
299
|
const key = assetCacheKey(url);
|
|
286
|
-
const cached = await readFromDisk(key);
|
|
300
|
+
const cached = await readFromDisk({ key });
|
|
287
301
|
if (cached)
|
|
288
302
|
return cached;
|
|
289
303
|
const existing = inflight.get(key);
|
package/dist/command.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type CommandInvocationRequest = {
|
|
|
17
17
|
* @param request - The command, optional args, and env to base the invocation on.
|
|
18
18
|
* @returns The `{ command, args }` (plus `windowsVerbatimArguments` when a shim is used) to spawn.
|
|
19
19
|
*/
|
|
20
|
-
export declare function createCommandInvocation({ args, command, env }: CommandInvocationRequest): CommandInvocation;
|
|
20
|
+
export declare function createCommandInvocation({ args, command, env }: CommandInvocationRequest, _options?: {}): CommandInvocation;
|
|
21
21
|
/**
|
|
22
22
|
* Build the invocation for re-entering the repo-pinned package manager. Prefers
|
|
23
23
|
* `npm_execpath` (routing Node-loadable managers through `process.execPath`),
|
package/dist/command.js
CHANGED
|
@@ -25,10 +25,13 @@ import { extname } from "node:path";
|
|
|
25
25
|
// into one literal arg with the `%` preserved. The two layers cancel, so the
|
|
26
26
|
// child receives the original arg byte-for-byte while cmd never has a chance
|
|
27
27
|
// to expand anything inside it.
|
|
28
|
-
function quoteWindowsCommandArg(value) {
|
|
29
|
-
if (
|
|
28
|
+
function quoteWindowsCommandArg({ value }, _options = {}) {
|
|
29
|
+
if (/[\r\n]/.test(value))
|
|
30
|
+
throw new Error("Windows command shim arguments cannot contain line breaks");
|
|
31
|
+
if (value !== "" && !/[\s"&<>|^%=,;()]/.test(value))
|
|
30
32
|
return value;
|
|
31
|
-
|
|
33
|
+
// Backslashes before the closing quote must survive the child's argv parser.
|
|
34
|
+
const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"').replace(/\\+$/, '$&$&');
|
|
32
35
|
return `"${escaped}"`;
|
|
33
36
|
}
|
|
34
37
|
// Build the `cmd.exe /d /s /c "<line>"` invocation Node uses internally for
|
|
@@ -45,8 +48,8 @@ function quoteWindowsCommandArg(value) {
|
|
|
45
48
|
// Without this, paths containing spaces (`C:\Users\First Last\...\foo.cmd`)
|
|
46
49
|
// get split on the first space and cmd.exe reports "not recognized as an
|
|
47
50
|
// internal or external command" — see issue #315.
|
|
48
|
-
function buildCmdShimInvocation(command, args, env) {
|
|
49
|
-
const inner = [command, ...args].map(quoteWindowsCommandArg).join(" ");
|
|
51
|
+
function buildCmdShimInvocation({ command, args, env }, _options = {}) {
|
|
52
|
+
const inner = [command, ...args].map((value) => quoteWindowsCommandArg({ value })).join(" ");
|
|
50
53
|
return {
|
|
51
54
|
args: ["/d", "/s", "/c", `"${inner}"`],
|
|
52
55
|
command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe",
|
|
@@ -63,9 +66,9 @@ const nodeLoadablePackageManagerExtensions = new Set([".js", ".cjs", ".mjs"]);
|
|
|
63
66
|
* @param request - The command, optional args, and env to base the invocation on.
|
|
64
67
|
* @returns The `{ command, args }` (plus `windowsVerbatimArguments` when a shim is used) to spawn.
|
|
65
68
|
*/
|
|
66
|
-
export function createCommandInvocation({ args = [], command, env = process.env }) {
|
|
69
|
+
export function createCommandInvocation({ args = [], command, env = process.env }, _options = {}) {
|
|
67
70
|
if (process.platform === "win32" && /\.(bat|cmd)$/i.test(command)) {
|
|
68
|
-
return buildCmdShimInvocation(command, args, env);
|
|
71
|
+
return buildCmdShimInvocation({ command, args, env });
|
|
69
72
|
}
|
|
70
73
|
return { args, command };
|
|
71
74
|
}
|
|
@@ -94,7 +97,7 @@ export function createPackageManagerInvocation(args, env = process.env) {
|
|
|
94
97
|
// package manager out of the box, while a bare `pnpm` only resolves when a
|
|
95
98
|
// separate global install or a `corepack enable` shim is present. See #2438.
|
|
96
99
|
if (process.platform === "win32") {
|
|
97
|
-
return buildCmdShimInvocation("corepack", ["pnpm", ...args], env);
|
|
100
|
+
return buildCmdShimInvocation({ command: "corepack", args: ["pnpm", ...args], env });
|
|
98
101
|
}
|
|
99
102
|
return { args: ["pnpm", ...args], command: "corepack" };
|
|
100
103
|
}
|
package/dist/download.js
CHANGED
|
@@ -186,17 +186,16 @@ async function writeSentinel(basePath) {
|
|
|
186
186
|
schemaVersion: STORE_SCHEMA_VERSION,
|
|
187
187
|
});
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
/** Clear only this target's data after rechecking store ownership; keep its lock held. */
|
|
190
|
+
async function resetOwnedTarget({ target }, _options = {}) {
|
|
191
|
+
const { basePath } = target;
|
|
190
192
|
const sentinel = await readJson(join(basePath, STORE_SENTINEL));
|
|
191
193
|
if (!isStoreSentinel(sentinel)) {
|
|
192
194
|
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_NOT_OWNED, `download base is not owned: ${basePath}`);
|
|
193
195
|
}
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
await rm(join(basePath, entry), { force: true, recursive: true }).catch(() => undefined);
|
|
196
|
+
for (const path of [target.finalPath, target.partialPath, target.manifestPath]) {
|
|
197
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
197
198
|
}
|
|
198
|
-
await writeSentinel(basePath);
|
|
199
|
-
await ensureStoreDirs(basePath);
|
|
200
199
|
}
|
|
201
200
|
async function ensureStoreDirs(basePath) {
|
|
202
201
|
await mkdir(join(basePath, STATE_DIR), { recursive: true });
|
|
@@ -540,8 +539,8 @@ async function releaseLock(lock) {
|
|
|
540
539
|
return;
|
|
541
540
|
await rm(lock.path, { force: true }).catch(() => undefined);
|
|
542
541
|
}
|
|
543
|
-
async function suspiciousReset(target) {
|
|
544
|
-
await
|
|
542
|
+
async function suspiciousReset({ target }, _options = {}) {
|
|
543
|
+
await resetOwnedTarget({ target });
|
|
545
544
|
return { manifest: null, reset: true };
|
|
546
545
|
}
|
|
547
546
|
async function loadReusableState(target) {
|
|
@@ -549,18 +548,18 @@ async function loadReusableState(target) {
|
|
|
549
548
|
const finalExists = await pathExists(target.finalPath);
|
|
550
549
|
const partialExists = await pathExists(target.partialPath);
|
|
551
550
|
if (manifest === "invalid") {
|
|
552
|
-
return await suspiciousReset(target);
|
|
551
|
+
return await suspiciousReset({ target });
|
|
553
552
|
}
|
|
554
553
|
if (manifest == null) {
|
|
555
|
-
return finalExists || partialExists ? await suspiciousReset(target) : { manifest: null };
|
|
554
|
+
return finalExists || partialExists ? await suspiciousReset({ target }) : { manifest: null };
|
|
556
555
|
}
|
|
557
556
|
if (!manifestMatchesTarget(manifest, target)) {
|
|
558
|
-
return await suspiciousReset(target);
|
|
557
|
+
return await suspiciousReset({ target });
|
|
559
558
|
}
|
|
560
559
|
if (manifest.state === "complete")
|
|
561
560
|
return await reusableCompleteState({ manifest, target });
|
|
562
561
|
if (!partialExists)
|
|
563
|
-
return await suspiciousReset(target);
|
|
562
|
+
return await suspiciousReset({ target });
|
|
564
563
|
return { manifest };
|
|
565
564
|
}
|
|
566
565
|
/** Reuse a complete download only when its final file exists and still hashes to the requested checksum. */
|
|
@@ -570,10 +569,10 @@ async function reusableCompleteState(input) {
|
|
|
570
569
|
// maps to `null` and fails the checksum comparison into the same reset.
|
|
571
570
|
const actual = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
|
|
572
571
|
if (actual !== target.checksum.value)
|
|
573
|
-
return await suspiciousReset(target);
|
|
572
|
+
return await suspiciousReset({ target });
|
|
574
573
|
const bytes = await statFileSize(target.finalPath);
|
|
575
574
|
if (bytes == null)
|
|
576
|
-
return await suspiciousReset(target);
|
|
575
|
+
return await suspiciousReset({ target });
|
|
577
576
|
return {
|
|
578
577
|
manifest,
|
|
579
578
|
result: {
|
|
@@ -591,13 +590,13 @@ async function reusableCompleteState(input) {
|
|
|
591
590
|
async function runManagedDownload(target, options) {
|
|
592
591
|
await ensureManagedBase(target.basePath);
|
|
593
592
|
await pruneManagedDownloads({ basePath: target.basePath }).catch(() => undefined);
|
|
594
|
-
const
|
|
593
|
+
const lock = await acquireLock(target);
|
|
595
594
|
try {
|
|
596
|
-
const state = await loadStateAllowingOneReset({
|
|
595
|
+
const state = await loadStateAllowingOneReset({ target });
|
|
597
596
|
if (state.result != null)
|
|
598
597
|
return state.result;
|
|
599
598
|
const download = await downloadWithRetries(target, state.manifest, options);
|
|
600
|
-
const bytes = await promoteVerifiedPartial(target);
|
|
599
|
+
const bytes = await promoteVerifiedPartial({ target });
|
|
601
600
|
await writeJson(target.manifestPath, createManifest(target, "complete", { totalBytes: download.totalBytes }));
|
|
602
601
|
return {
|
|
603
602
|
bucket: target.bucket,
|
|
@@ -611,36 +610,31 @@ async function runManagedDownload(target, options) {
|
|
|
611
610
|
};
|
|
612
611
|
}
|
|
613
612
|
finally {
|
|
614
|
-
await releaseLock(
|
|
613
|
+
await releaseLock(lock);
|
|
615
614
|
}
|
|
616
615
|
}
|
|
617
|
-
/**
|
|
618
|
-
async function loadStateAllowingOneReset(
|
|
619
|
-
const { held, target } = input;
|
|
616
|
+
/** Reload once after target cleanup, retaining the lock until the run finishes. */
|
|
617
|
+
async function loadStateAllowingOneReset({ target }, _options = {}) {
|
|
620
618
|
const state = await loadReusableState(target);
|
|
621
619
|
if (state.reset !== true)
|
|
622
620
|
return state;
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// another process may have taken in the meantime.
|
|
627
|
-
await releaseLock(held.lock);
|
|
628
|
-
held.lock = null;
|
|
621
|
+
// Whole-base cleanup used to remove the lock and require re-acquisition.
|
|
622
|
+
// Keep it throughout target cleanup so another process cannot take it
|
|
623
|
+
// before we write fresh state, or have its own lock deleted by our finally.
|
|
629
624
|
await ensureManagedBase(target.basePath);
|
|
630
|
-
held.lock = await acquireLock(target);
|
|
631
625
|
const reloaded = await loadReusableState(target);
|
|
632
626
|
if (reloaded.reset === true) {
|
|
633
|
-
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after
|
|
627
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after target cleanup");
|
|
634
628
|
}
|
|
635
629
|
return reloaded;
|
|
636
630
|
}
|
|
637
631
|
/** Verify the downloaded partial's checksum, promote it to the final path, and return the final size. */
|
|
638
|
-
async function promoteVerifiedPartial(target) {
|
|
632
|
+
async function promoteVerifiedPartial({ target }, _options = {}) {
|
|
639
633
|
const actual = await hashFile(target.partialPath, target.checksum.algorithm).catch((error) => {
|
|
640
634
|
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, `downloaded partial could not be hashed: ${errorMessage(error)}`);
|
|
641
635
|
});
|
|
642
636
|
if (actual !== target.checksum.value) {
|
|
643
|
-
await
|
|
637
|
+
await resetOwnedTarget({ target }).catch(() => undefined);
|
|
644
638
|
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.CHECKSUM_MISMATCH, "downloaded file checksum did not match requested payload", {
|
|
645
639
|
actual,
|
|
646
640
|
expected: target.checksum.value,
|
|
@@ -650,7 +644,7 @@ async function promoteVerifiedPartial(target) {
|
|
|
650
644
|
if (await pathExists(target.finalPath)) {
|
|
651
645
|
const existing = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
|
|
652
646
|
if (existing !== target.checksum.value) {
|
|
653
|
-
await
|
|
647
|
+
await resetOwnedTarget({ target }).catch(() => undefined);
|
|
654
648
|
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "existing complete file did not match requested payload");
|
|
655
649
|
}
|
|
656
650
|
await rm(target.partialPath, { force: true }).catch(() => undefined);
|
|
@@ -86,7 +86,67 @@ export interface FetchWithTimeoutOptions {
|
|
|
86
86
|
/** Milliseconds before the call is aborted. Pick one of {@link FETCH_TIMEOUT_MS}, or a specific
|
|
87
87
|
* justified number for a call site that doesn't fit those classes. */
|
|
88
88
|
timeoutMs: number;
|
|
89
|
+
/** Host-supplied transport; defaults to the global fetch. */
|
|
90
|
+
fetch?: typeof fetch;
|
|
91
|
+
}
|
|
92
|
+
/** Optional caller cancellation and elapsed-time accounting for a wait budget. */
|
|
93
|
+
export interface DeadlineSignalOptions {
|
|
94
|
+
signal?: AbortSignal;
|
|
95
|
+
/** Start time in milliseconds, on the same clock as `now`. */
|
|
96
|
+
startedAt?: number;
|
|
97
|
+
/** Clock port in milliseconds; defaults to Date.now and is read only with startedAt. */
|
|
98
|
+
now?: () => number;
|
|
89
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Creates one signal that aborts when the remaining wait budget expires or the caller cancels.
|
|
102
|
+
* Spent time is subtracted once; future start times cannot increase the original budget.
|
|
103
|
+
* @param input - Wait budget in milliseconds. Negative remaining budgets become zero.
|
|
104
|
+
* @param options - Optional caller signal, start time and clock port.
|
|
105
|
+
* @returns A native deadline signal preserving the first abort reason. Zero expires asynchronously,
|
|
106
|
+
* following AbortSignal.timeout(0); an already-aborted caller is reflected immediately.
|
|
107
|
+
* Creates a native timeout without mutating inputs or the caller's signal.
|
|
108
|
+
* @throws {RangeError} If the remaining delay is invalid for AbortSignal.timeout.
|
|
109
|
+
* @complexity O(1) time and space.
|
|
110
|
+
* @example createDeadlineSignal({ waitMs: 5_000 }, { signal, startedAt, now: Date.now })
|
|
111
|
+
*/
|
|
112
|
+
export declare function createDeadlineSignal({ waitMs }: {
|
|
113
|
+
waitMs: number;
|
|
114
|
+
}, { signal, startedAt, now }?: DeadlineSignalOptions): AbortSignal;
|
|
115
|
+
/** Opt-in byte and decoding guards; defaults remain unlimited and replacement-decoded. */
|
|
116
|
+
export type TextReadOptions = {
|
|
117
|
+
/** Nonnegative safe integer. Actual streamed bytes, independent of Content-Length. */
|
|
118
|
+
maxResponseBytes?: number;
|
|
119
|
+
/** Reject malformed UTF-8, including an incomplete trailing character. */
|
|
120
|
+
strictUtf8?: boolean;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Consumes a response as UTF-8, checking bytes before decoding/retaining each chunk.
|
|
124
|
+
* Details mode reports retained source bytes and truncates only on observed overflow;
|
|
125
|
+
* reaching the cap still requires EOF to establish completeness. Defaults reject overflow
|
|
126
|
+
* and replace malformed UTF-8. A supplied signal interrupts even a stalled read.
|
|
127
|
+
* @throws {RangeError} An invalid cap or a response exceeding it.
|
|
128
|
+
* @throws {TypeError} Invalid UTF-8 when strict decoding is enabled.
|
|
129
|
+
* Read failures and abort reasons propagate; acquired readers are cancelled on failure
|
|
130
|
+
* and always released. Details mode observes cancellation failures without awaiting cleanup.
|
|
131
|
+
* @complexity O(n) time and retained text for n bytes, bounded by the optional cap.
|
|
132
|
+
* The upstream stream controls the allocation of individual incoming chunks.
|
|
133
|
+
* @example readFetchResponseText({ response, maxResponseBytes: 1024, result: "details", onLimit: "truncate" })
|
|
134
|
+
*/
|
|
135
|
+
export declare function readFetchResponseText(input: {
|
|
136
|
+
response: Response;
|
|
137
|
+
maxResponseBytes: number;
|
|
138
|
+
result: "details";
|
|
139
|
+
onLimit?: "reject" | "truncate";
|
|
140
|
+
strictUtf8?: boolean;
|
|
141
|
+
signal?: AbortSignal;
|
|
142
|
+
}): Promise<{
|
|
143
|
+
text: string;
|
|
144
|
+
byteLength: number;
|
|
145
|
+
truncated: boolean;
|
|
146
|
+
}>;
|
|
147
|
+
export declare function readFetchResponseText({ response }: {
|
|
148
|
+
response: Response;
|
|
149
|
+
}, options?: TextReadOptions): Promise<string>;
|
|
90
150
|
/**
|
|
91
151
|
* `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
|
|
92
152
|
* already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
|
|
@@ -103,4 +163,21 @@ export interface FetchWithTimeoutOptions {
|
|
|
103
163
|
* genuine network error) propagates unchanged.
|
|
104
164
|
*/
|
|
105
165
|
export declare function fetchWithTimeout(url: string | URL, init: RequestInit | undefined, options: FetchWithTimeoutOptions): Promise<Response>;
|
|
166
|
+
/**
|
|
167
|
+
* Fetches and consumes text inside one timeout scope. The returned response keeps its metadata
|
|
168
|
+
* and has a consumed body. No retries; other fetch/read errors propagate unchanged.
|
|
169
|
+
* @throws {FetchTimeoutError} The timeout ends fetch or body consumption before caller abort.
|
|
170
|
+
* @throws {RangeError | TypeError} See {@link readFetchResponseText} for opt-in guards.
|
|
171
|
+
* @complexity O(n) time and text storage for n response bytes, optionally capped.
|
|
172
|
+
*/
|
|
173
|
+
export declare function fetchTextWithTimeout(required: {
|
|
174
|
+
url: string | URL;
|
|
175
|
+
timeoutMs: number;
|
|
176
|
+
init?: RequestInit;
|
|
177
|
+
}, optional?: TextReadOptions & {
|
|
178
|
+
fetch?: typeof fetch;
|
|
179
|
+
}): Promise<{
|
|
180
|
+
response: Response;
|
|
181
|
+
text: string;
|
|
182
|
+
}>;
|
|
106
183
|
//# sourceMappingURL=fetch-with-timeout.d.ts.map
|