@jini-ai/platform 0.6.15-rc.2 → 0.6.15

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -2
  3. package/dist/blob-storage.js +10 -9
  4. package/dist/child-environment.js +1 -1
  5. package/dist/command.js +4 -1
  6. package/dist/core/effective-port.d.ts +6 -0
  7. package/dist/core/effective-port.js +8 -0
  8. package/dist/download.js +25 -24
  9. package/dist/fetch-with-timeout.d.ts +26 -6
  10. package/dist/fetch-with-timeout.js +50 -10
  11. package/dist/fs/atomic-write.js +5 -2
  12. package/dist/fs/boot-identity.d.ts +0 -4
  13. package/dist/fs/boot-identity.js +9 -3
  14. package/dist/fs/directory-registry.js +2 -2
  15. package/dist/fs/directory-running-lock.js +9 -4
  16. package/dist/fs/directory-sync.js +2 -3
  17. package/dist/fs/directory-workspace.js +11 -14
  18. package/dist/fs/durable-json.js +22 -16
  19. package/dist/fs/errno.d.ts +9 -0
  20. package/dist/fs/errno.js +21 -0
  21. package/dist/fs/file-lock.js +17 -19
  22. package/dist/fs/guarded-reader.js +15 -9
  23. package/dist/fs/user-data-directories.js +5 -7
  24. package/dist/fs.js +2 -1
  25. package/dist/http/guarded/client.js +2 -1
  26. package/dist/http.js +2 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/mail/builtin-providers.js +4 -2
  30. package/dist/mail/feedback.js +1 -1
  31. package/dist/mail/prepare-sender-email.js +7 -1
  32. package/dist/mail/provider.js +14 -8
  33. package/dist/mail/rate-limit.js +3 -1
  34. package/dist/net/endpoint-policy.js +15 -7
  35. package/dist/object-store/local-fs.js +3 -1
  36. package/dist/process.js +10 -9
  37. package/dist/proxy-env.js +22 -14
  38. package/dist/secrets/credential-sets/provider-matches.js +14 -3
  39. package/dist/secrets/credential-sets/vault.meta.js +6 -2
  40. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
  41. package/dist/secrets/credential-sets/vault.service.js +22 -17
  42. package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
  43. package/dist/secrets/credential-sets/vault.types.js +7 -0
  44. package/dist/secrets/credential-token.js +9 -2
  45. package/dist/secrets/keyring.env.js +2 -4
  46. package/dist/shell.js +2 -1
  47. package/dist/terminal.js +4 -7
  48. package/dist/toolchain.js +4 -9
  49. package/dist/web/first-distinct.d.ts +11 -0
  50. package/dist/web/first-distinct.js +16 -0
  51. package/dist/web/html-document.js +35 -39
  52. package/dist/web/html-to-markdown.js +20 -9
  53. package/dist/web/web-page.js +28 -25
  54. package/dist/windows-system-tools.d.ts +15 -0
  55. package/dist/windows-system-tools.js +16 -0
  56. package/package.json +2 -2
package/dist/proxy-env.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * proxy-config tools; owns no other sibling dependency.
12
12
  */
13
13
  import { execFileSync } from "node:child_process";
14
+ import { captureLineTail } from "@jini-ai/core/text";
14
15
  const CANONICAL_PROXY_ENV_KEYS = new Map([
15
16
  ["all_proxy", "ALL_PROXY"],
16
17
  ["http_proxy", "HTTP_PROXY"],
@@ -78,18 +79,21 @@ function collectSourceProxyEntries(input) {
78
79
  if (value == null)
79
80
  continue;
80
81
  const canonicalKey = canonicalProxyEnvKey(key);
81
- if (!canonicalKey) {
82
+ if (canonicalKey)
83
+ keepPreferredProxyEntry(proxyEntries, { canonicalKey, key, value });
84
+ else
82
85
  input.merged[key] = value;
83
- continue;
84
- }
85
- const current = proxyEntries.get(canonicalKey);
86
- const preferLowercase = key === key.toLowerCase();
87
- if (!current || preferLowercase || !current.preferLowercase) {
88
- proxyEntries.set(canonicalKey, { preferLowercase, value });
89
- }
90
86
  }
91
87
  return proxyEntries;
92
88
  }
89
+ /** @internal Record `key`'s value unless a lowercase spelling of the same variable already won. */
90
+ function keepPreferredProxyEntry(proxyEntries, entry) {
91
+ const current = proxyEntries.get(entry.canonicalKey);
92
+ const preferLowercase = entry.key === entry.key.toLowerCase();
93
+ if (!current || preferLowercase || !current.preferLowercase) {
94
+ proxyEntries.set(entry.canonicalKey, { preferLowercase, value: entry.value });
95
+ }
96
+ }
93
97
  /** @internal Whether `env` already contains any case-variant of the given canonical proxy key. */
94
98
  function hasCanonicalProxyEnv(env, canonicalKey) {
95
99
  return Object.keys(env).some((key) => key.toLowerCase() === canonicalKey.toLowerCase());
@@ -249,16 +253,20 @@ function parseScutilLines(stdout) {
249
253
  }
250
254
  /** @internal Record one `N : value` ExceptionsList entry; returns whether the array is still open (false on `}`). */
251
255
  function collectScutilException(line, exceptions) {
252
- const match = line.match(/^\d+\s*:\s*(.+)$/);
253
- if (match)
254
- exceptions.push(match[1].trim());
256
+ // Same as /^\d+\s*:\s*(.+)$/, split so the value tail cannot backtrack.
257
+ const index = /^\d+\s*:/.exec(line);
258
+ const value = index ? captureLineTail({ rest: line.slice(index[0].length) }) : null;
259
+ if (value !== null)
260
+ exceptions.push(value.trim());
255
261
  return line !== "}";
256
262
  }
257
263
  /** @internal Record one top-level `Name : value` scutil scalar, ignoring lines of any other shape. */
258
264
  function collectScutilScalar(line, scalars) {
259
- const match = line.match(/^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.+)$/);
260
- if (match)
261
- scalars.set(match[1], match[2].trim());
265
+ // Same as /^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.+)$/, split so the value tail cannot backtrack.
266
+ const name = /^([A-Za-z][A-Za-z0-9]*)\s*:/.exec(line);
267
+ const value = name ? captureLineTail({ rest: line.slice(name[0].length) }) : null;
268
+ if (name && value !== null)
269
+ scalars.set(name[1], value.trim());
262
270
  }
263
271
  /** @internal The `<prefix>Proxy`/`<prefix>Port` URL when `<prefix>Enable` is `1`, else `null`. */
264
272
  function scutilEnabledProxy(input) {
@@ -23,15 +23,26 @@ export function normalizeCredentialWorksFor({ value }, _options = {}) {
23
23
  */
24
24
  export function matchingCredentialProviderIds({ credential, providers }, _options = {}) {
25
25
  const { credentialType, worksFor } = credential.meta;
26
- if ((credential.kind !== "vendor" && credential.kind !== "custom") ||
27
- (credential.kind === "custom" && credentialType !== "custom") || (credentialType !== "vendor" && credentialType !== "custom") ||
28
- !Array.isArray(worksFor) || Array.from(worksFor).some(id => typeof id !== "string" || !id || id !== id.toLowerCase() || /\s/.test(id)))
26
+ if (!hasMatchableType({ kind: credential.kind, credentialType }) ||
27
+ !Array.isArray(worksFor) || !Array.from(worksFor).every(isCanonicalProviderId))
29
28
  return [];
30
29
  const ids = new Set(worksFor);
31
30
  return providers.filter(provider => (credentialType !== "custom" || provider.acceptsCustomToken) &&
32
31
  (ids.has(provider.id.toLowerCase()) || ids.has(provider.vendorId.toLowerCase())))
33
32
  .map(provider => provider.id);
34
33
  }
34
+ /** vendor/custom rows only; a custom-kind row must also carry the custom subtype. */
35
+ function hasMatchableType({ kind, credentialType }) {
36
+ if (kind !== "vendor" && kind !== "custom")
37
+ return false;
38
+ if (kind === "custom" && credentialType !== "custom")
39
+ return false;
40
+ return credentialType === "vendor" || credentialType === "custom";
41
+ }
42
+ /** A non-empty, already-lowercased ID without whitespace, as {@link normalizeCredentialWorksFor} stores it. */
43
+ function isCanonicalProviderId(id) {
44
+ return typeof id === "string" && id !== "" && id === id.toLowerCase() && !/\s/.test(id);
45
+ }
35
46
  /** Select an eligible explicit ID, the descriptor's provider-group default, or a sole candidate.
36
47
  * Missing IDs and unresolved ambiguity return null; custom-group defaults do not rank candidates.
37
48
  * @complexity O(credentials * average worksFor) time, O(credentials) candidate space.
@@ -39,16 +39,20 @@ function isJson(value) {
39
39
  * @throws VaultError when a supplied subtype or compatibility list is invalid.
40
40
  * @complexity O(worksFor characters); returns a fresh metadata object.
41
41
  */
42
+ /** Older vendor rows imply compatibility with their own provider; nothing else implies any. */
43
+ function defaultWorksFor({ credentialType, provider }) {
44
+ return credentialType === "vendor" && provider != null ? [provider] : [];
45
+ }
42
46
  function credentialMeta({ meta, kind, provider }) {
43
47
  const credentialType = meta.credentialType === undefined ? kind : meta.credentialType;
44
48
  if ((credentialType !== "vendor" && credentialType !== "custom") || (kind === "custom" && credentialType !== "custom"))
45
49
  throw new VaultError({ code: "invalid-meta" });
46
- const worksFor = meta.worksFor === undefined ? (credentialType === "vendor" && provider != null ? [provider] : []) : meta.worksFor;
50
+ const worksFor = meta.worksFor === undefined ? defaultWorksFor({ credentialType, provider }) : meta.worksFor;
47
51
  return { ...meta, credentialType, worksFor: normalizeCredentialWorksFor({ value: worksFor }) };
48
52
  }
49
53
  /** Plain JSON object with identifier-only top-level keys; checked on input and on override output. */
50
54
  function requireMetaShape({ value }) {
51
- if (!isObject(value) || !isJson(value) || Object.keys(value).some(key => !/^[A-Za-z0-9_]+$/.test(key)))
55
+ if (!isObject(value) || !isJson(value) || Object.keys(value).some(key => !/^\w+$/.test(key)))
52
56
  throw new VaultError({ code: "invalid-meta" });
53
57
  return value;
54
58
  }
@@ -1,4 +1,4 @@
1
- import { assertVaultReplacement, vaultGroupKey, VaultError } from "./vault.types.js";
1
+ import { assertVaultReplacement, isInVaultQueryScope, vaultGroupKey, VaultError } from "./vault.types.js";
2
2
  /** Single-turn mutations are atomic in JS; clones prevent callers bypassing CAS through aliases. */
3
3
  export class InMemoryVaultRepo {
4
4
  rows = new Map();
@@ -56,15 +56,13 @@ export class InMemoryVaultRepo {
56
56
  /** O(n) memory scan, n stored rows; clones only the default, never its siblings. */
57
57
  async findDefault(input, _options = {}) {
58
58
  for (const row of this.rows.values()) {
59
- if (row.isDefault && row.workspaceId === input.workspaceId && row.kind === input.kind && row.ownerKey === input.ownerKey &&
60
- row.principalId === input.principalId && row.provider === input.provider)
59
+ if (row.isDefault && row.provider === input.provider && isInVaultQueryScope({ record: row, query: input }))
61
60
  return structuredClone(row);
62
61
  }
63
62
  return null;
64
63
  }
65
64
  async list(input, _options = {}) {
66
- return structuredClone([...this.rows.values()].filter(row => row.workspaceId === input.workspaceId && row.kind === input.kind &&
67
- row.ownerKey === input.ownerKey && row.principalId === input.principalId && (input.provider === undefined || row.provider === input.provider))
65
+ return structuredClone([...this.rows.values()].filter(row => isInVaultQueryScope({ record: row, query: input }))
68
66
  // Rows are keyed by (workspaceId, id), so one workspace never lists two equal IDs.
69
67
  .sort((a, b) => a.id < b.id ? -1 : 1));
70
68
  }
@@ -1,18 +1,23 @@
1
1
  import { credentialTokenHint } from "../credential-token.js";
2
2
  import { buildVaultAad } from "./vault.aad.js";
3
3
  import { isCopyableKind } from "./vault.meta.js";
4
- import { VAULT_KINDS, VaultError } from "./vault.types.js";
4
+ import { VAULT_KINDS, VaultError, isInVaultQueryScope } from "./vault.types.js";
5
5
  /** Kinds whose payloads are addresses, environment maps or OAuth sessions, never single tokens. */
6
6
  const HINTLESS_KINDS = ["database-destination", "mcp-env", "mcp-auth"];
7
7
  function isNonEmptyString({ value }) {
8
8
  return typeof value === "string" && value !== "";
9
9
  }
10
+ /** The one error for malformed, missing or unsupported records and repo capabilities. */
11
+ function invalidRecord() {
12
+ return new VaultError({ code: "invalid-record" });
13
+ }
10
14
  function isNullableString({ value }) {
11
15
  return value === null || typeof value === "string";
12
16
  }
13
17
  function hasValidOwnership({ context }) {
18
+ const validBinding = !("binding" in context) || isNullableString({ value: context.binding });
14
19
  return isNonEmptyString({ value: context.workspaceId }) && isNonEmptyString({ value: context.ownerKey }) &&
15
- isNullableString({ value: context.principalId }) && (!("binding" in context) || isNullableString({ value: context.binding }));
20
+ isNullableString({ value: context.principalId }) && validBinding;
16
21
  }
17
22
  function hasValidKind({ context }) {
18
23
  if (!VAULT_KINDS.some(kind => kind === context.kind) && context.kind !== "custom")
@@ -25,19 +30,19 @@ function assertContext(context) {
25
30
  }
26
31
  /** Unified credential rows declare both subtype and compatibility; read defaults cover older rows. */
27
32
  function declaresCredential({ meta }) {
28
- return meta !== null && typeof meta === "object" && Object.hasOwn(meta, "credentialType") && Object.hasOwn(meta, "worksFor");
29
- }
30
- function isInQueryScope({ record, query }) {
31
- return record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey &&
32
- record.principalId === query.principalId && (query.provider === undefined || record.provider === query.provider);
33
+ if (meta === null || typeof meta !== "object")
34
+ return false;
35
+ return Object.hasOwn(meta, "credentialType") && Object.hasOwn(meta, "worksFor");
33
36
  }
34
37
  /** Copy changes the site/key, never the credential's owning feature or endpoint. Principal
35
38
  * identities are local to each installation and cannot be carried to the target.
36
39
  */
37
40
  function isCopyTarget({ sourceContext, targetContext, sameRepo }) {
38
- return sourceContext.kind === targetContext.kind && sourceContext.ownerKey === targetContext.ownerKey &&
39
- sourceContext.binding === targetContext.binding && sourceContext.principalId === null && targetContext.principalId === null &&
40
- !(sameRepo && sourceContext.workspaceId === targetContext.workspaceId);
41
+ const sameFeature = sourceContext.kind === targetContext.kind && sourceContext.ownerKey === targetContext.ownerKey &&
42
+ sourceContext.binding === targetContext.binding;
43
+ const noPrincipal = sourceContext.principalId === null && targetContext.principalId === null;
44
+ const sameSite = sameRepo && sourceContext.workspaceId === targetContext.workspaceId;
45
+ return sameFeature && noPrincipal && !sameSite;
41
46
  }
42
47
  function sourceNeedsUpgrade({ raw }) {
43
48
  return Object.hasOwn(raw.meta, "legacyAad") || (raw.kind === "vendor" && !declaresCredential({ meta: raw.meta }));
@@ -97,7 +102,7 @@ export class SecretsVaultService {
97
102
  // JSON round-trip makes the payload contract explicit; encryption never receives an object
98
103
  // coerced to '[object Object]' or a connection URI masquerading as an entire payload.
99
104
  if (typeof input.payload !== "object" || Array.isArray(input.payload))
100
- throw new VaultError({ code: "invalid-record" });
105
+ throw invalidRecord();
101
106
  const plaintext = JSON.stringify(input.payload);
102
107
  return this.deps.sealer.seal({ plaintext, key: await this.deps.keyring.activeKey({}), aad: buildVaultAad(input.context) });
103
108
  }
@@ -110,10 +115,10 @@ export class SecretsVaultService {
110
115
  payload = JSON.parse(plaintext);
111
116
  }
112
117
  catch {
113
- throw new VaultError({ code: "invalid-record" });
118
+ throw invalidRecord();
114
119
  }
115
120
  if (payload === null || typeof payload !== "object" || Array.isArray(payload))
116
- throw new VaultError({ code: "invalid-record" });
121
+ throw invalidRecord();
117
122
  return payload;
118
123
  }
119
124
  /** Create/replace configuration and a sealed JSON payload. Throws on policy, validation or CAS failure. */
@@ -167,7 +172,7 @@ export class SecretsVaultService {
167
172
  async putEnvelope({ input, identity, existing }) {
168
173
  if (input.payload === undefined) {
169
174
  if (!existing)
170
- throw new VaultError({ code: "invalid-record" });
175
+ throw invalidRecord();
171
176
  return { sealed: existing.sealed, last4: existing.last4, tokenLength: existing.tokenLength };
172
177
  }
173
178
  const sealed = await this.seal({ context: identity, payload: input.payload });
@@ -195,7 +200,7 @@ export class SecretsVaultService {
195
200
  assertContext(input);
196
201
  await this.authorize({ action: "get", context: input });
197
202
  if (!this.deps.repo.findOne)
198
- throw new VaultError({ code: "invalid-record" });
203
+ throw invalidRecord();
199
204
  const record = await this.deps.repo.findOne(input);
200
205
  if (!record)
201
206
  return null;
@@ -213,7 +218,7 @@ export class SecretsVaultService {
213
218
  await this.authorize({ action: "get", context: input });
214
219
  // Old injected ports retain their existing operations; default reads require this capability.
215
220
  if (!this.deps.repo.findDefault)
216
- throw new VaultError({ code: "invalid-record" });
221
+ throw invalidRecord();
217
222
  const record = await this.deps.repo.findDefault(input);
218
223
  if (!record)
219
224
  return null;
@@ -301,7 +306,7 @@ export class SecretsVaultService {
301
306
  assertContext(input);
302
307
  await this.authorize({ action: "list", context: input });
303
308
  return (await this.deps.repo.list(input)).map(record => {
304
- if (!isInQueryScope({ record, query: input }))
309
+ if (!isInVaultQueryScope({ record, query: input }))
305
310
  throw new VaultError({ code: "context" });
306
311
  return summary(this.parse(record));
307
312
  });
@@ -152,6 +152,12 @@ export interface VaultRepoPort {
152
152
  expectedRevision: number;
153
153
  }, options?: VaultOptions): Promise<boolean>;
154
154
  }
155
+ /** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
156
+ * matches every provider. @complexity O(1). */
157
+ export declare function isInVaultQueryScope({ record, query }: {
158
+ record: VaultRecord;
159
+ query: VaultQuery;
160
+ }, _options?: VaultOptions): boolean;
155
161
  /** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
156
162
  export declare function vaultGroupKey({ record }: {
157
163
  record: VaultDefaultQuery;
@@ -9,6 +9,13 @@ export class VaultError extends Error {
9
9
  this.name = "VaultError";
10
10
  }
11
11
  }
12
+ /** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
13
+ * matches every provider. @complexity O(1). */
14
+ export function isInVaultQueryScope({ record, query }, _options = {}) {
15
+ const sameOwner = record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey;
16
+ const sameProvider = query.provider === undefined || record.provider === query.provider;
17
+ return sameOwner && record.principalId === query.principalId && sameProvider;
18
+ }
12
19
  /** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
13
20
  export function vaultGroupKey({ record }, _options = {}) {
14
21
  return JSON.stringify([record.workspaceId, record.kind, record.ownerKey, record.principalId, record.provider]);
@@ -60,10 +60,17 @@ export function credentialTokenHint({ token }, _optional = {}) {
60
60
  return { length: token.length, last4: token.length >= 12 ? token.slice(-4) : null };
61
61
  }
62
62
  const SECRET_NAME = /(?:^|[_-])(?:key|api[_-]?key|token|access[_-]?token|secret|password|auth|sig)$/i;
63
- const SECRET_ARG = /(?:^|\s)--[\w-]*(?:api[_-]?key|token|secret|password)(?:=|\s+)(?!\s*$)\S/i;
63
+ // A `--flag` followed by a value; the flag name is captured whole and its suffix checked separately,
64
+ // so the name run and the keyword can no longer backtrack against each other.
65
+ const CLI_FLAG_WITH_VALUE = /(?:^|\s)--([\w-]*)(?=(?:=|\s+)\S)/gi;
66
+ const SECRET_FLAG_SUFFIX = /(?:api[_-]?key|token|secret|password)$/i;
67
+ /** True when some `--…api-key/token/secret/password` flag carries a value. @complexity O(value length). */
68
+ function hasSecretCliArg(value) {
69
+ return [...value.matchAll(CLI_FLAG_WITH_VALUE)].some(match => SECRET_FLAG_SUFFIX.test(match[1]));
70
+ }
64
71
  /** Protect plaintext metadata, using Jini's detector plus structural URL/CLI rules. */
65
72
  export function assertCredentialFreeField({ value, field, containsSecret }, _optional = {}) {
66
- let credential = containsSecret({ text: value }) || SECRET_ARG.test(value);
73
+ let credential = containsSecret({ text: value }) || hasSecretCliArg(value);
67
74
  try {
68
75
  const url = new URL(value);
69
76
  credential ||= url.username !== '' || url.password !== '' || [...url.searchParams.keys()].some(key => SECRET_NAME.test(key));
@@ -206,8 +206,7 @@ function toActiveRootKeyMaterial(source, raw) {
206
206
  * @example inspectRootKeyMaterial({ env, envVarName, keyFilePath })
207
207
  */
208
208
  export function inspectRootKeyMaterial(options) {
209
- const envVarName = options.envVarName;
210
- const keyFilePath = options.keyFilePath;
209
+ const { envVarName, keyFilePath } = options;
211
210
  const raw = readActiveRootKeyMaterial({ env: options.env, envVarName, keyFilePath });
212
211
  if (raw.hex)
213
212
  return { active: true, source: raw.source, fingerprint: fingerprintRootKeyHex({ hex: raw.hex }), keyFilePath };
@@ -221,8 +220,7 @@ function invalidFields(raw) {
221
220
  * @example revealRootKeyMaterial({ env, envVarName, keyFilePath })
222
221
  */
223
222
  export function revealRootKeyMaterial(options) {
224
- const envVarName = options.envVarName;
225
- const keyFilePath = options.keyFilePath;
223
+ const { envVarName, keyFilePath } = options;
226
224
  const raw = readActiveRootKeyMaterial({ env: options.env, envVarName, keyFilePath });
227
225
  if (raw.hex)
228
226
  return { active: true, source: raw.source, hex: raw.hex, fingerprint: fingerprintRootKeyHex({ hex: raw.hex }), keyFilePath };
package/dist/shell.js CHANGED
@@ -41,7 +41,8 @@ export function execFileBuffered(command, args, opts = {}) {
41
41
  /** @internal Single-quote a POSIX shell argument, escaping embedded single quotes. */
42
42
  function quotePosixShellArg(value) {
43
43
  const text = String(value ?? "");
44
- return `'${text.replace(/'/g, `'\\''`)}'`;
44
+ const escapedQuote = "'\\''";
45
+ return `'${text.replace(/'/g, escapedQuote)}'`;
45
46
  }
46
47
  /** @internal Join a command and its arguments into one quoted POSIX shell command line. */
47
48
  function buildShellCommandLine(command, args) {
package/dist/terminal.js CHANGED
@@ -171,7 +171,8 @@ export function createTerminalService(options) {
171
171
  }
172
172
  };
173
173
  const emit = (session, event, data) => {
174
- const id = session.nextEventId++;
174
+ const id = session.nextEventId;
175
+ session.nextEventId += 1;
175
176
  const byteLength = recordByteLength(data);
176
177
  const record = { id, event, data, timestamp: Date.now(), byteLength };
177
178
  session.events.push(record);
@@ -282,12 +283,8 @@ export function createTerminalService(options) {
282
283
  return session ? toPublicSession(session) : null;
283
284
  };
284
285
  const list = (filter = {}) => Array.from(sessions.values())
285
- .filter((session) => {
286
- if (typeof filter.projectId === 'string' && filter.projectId && session.projectId !== filter.projectId) {
287
- return false;
288
- }
289
- return true;
290
- })
286
+ // Only a non-empty string projectId filters; anything else lists every session.
287
+ .filter((session) => !(typeof filter.projectId === 'string' && filter.projectId && session.projectId !== filter.projectId))
291
288
  .map(toPublicSession);
292
289
  const attach = (id, lastEventId, sink) => {
293
290
  const session = sessions.get(id);
package/dist/toolchain.js CHANGED
@@ -255,18 +255,13 @@ function compareVersionLikeDirNames(left, right) {
255
255
  const leftSemver = parseVersionLikeDirName(left);
256
256
  const rightSemver = parseVersionLikeDirName(right);
257
257
  if (leftSemver && rightSemver) {
258
- for (let index = 0; index < leftSemver.length; index += 1) {
259
- const difference = rightSemver[index] - leftSemver[index];
260
- if (difference !== 0)
261
- return difference;
262
- }
258
+ const difference = leftSemver.map((part, index) => rightSemver[index] - part).find((value) => value !== 0);
259
+ return difference ?? left.localeCompare(right);
263
260
  }
264
- else if (leftSemver) {
261
+ if (leftSemver)
265
262
  return -1;
266
- }
267
- else if (rightSemver) {
263
+ if (rightSemver)
268
264
  return 1;
269
- }
270
265
  return left.localeCompare(right);
271
266
  }
272
267
  /** @internal Parse a `vX.Y.Z` / `X.Y.Z` directory name into its numeric parts, or `null` when it does not match. */
@@ -0,0 +1,11 @@
1
+ /** Items with distinct non-empty keys, in order, capped at `max`; `truncated` reports a further
2
+ * distinct key beyond the cap (scanning stops there). @complexity O(items). */
3
+ export declare function firstDistinct<T>({ items, max, keyOf }: {
4
+ items: readonly T[];
5
+ max: number;
6
+ keyOf: (item: T) => string | null | undefined;
7
+ }): {
8
+ picked: [string, T][];
9
+ truncated: boolean;
10
+ };
11
+ //# sourceMappingURL=first-distinct.d.ts.map
@@ -0,0 +1,16 @@
1
+ /** Items with distinct non-empty keys, in order, capped at `max`; `truncated` reports a further
2
+ * distinct key beyond the cap (scanning stops there). @complexity O(items). */
3
+ export function firstDistinct({ items, max, keyOf }) {
4
+ const picked = new Map();
5
+ const truncated = items.some((item) => {
6
+ const key = keyOf(item);
7
+ if (!key || picked.has(key))
8
+ return false;
9
+ if (picked.size === max)
10
+ return true;
11
+ picked.set(key, item);
12
+ return false;
13
+ });
14
+ return { picked: [...picked], truncated };
15
+ }
16
+ //# sourceMappingURL=first-distinct.js.map
@@ -1,4 +1,5 @@
1
1
  import { parse, serialize } from "parse5";
2
+ import { firstDistinct } from "./first-distinct.js";
2
3
  export const MAX_LINKS = 500;
3
4
  export const MAX_IMAGES = 300;
4
5
  const MAX_STYLESHEETS = 100;
@@ -88,19 +89,25 @@ const COLLECTED = new Map([["a", "anchors"], ["img", "images"], ["link", "linkTa
88
89
  function collect(document) {
89
90
  const raw = { anchors: [], images: [], linkTags: [], metas: [] };
90
91
  for (const node of walk(document)) {
91
- if (!isElement(node))
92
- continue;
93
- const list = COLLECTED.get(node.tagName);
94
- if (list)
95
- raw[list].push(node);
96
- else if (node.tagName === "title" || node.tagName === "html")
97
- raw[node.tagName] ??= node;
98
- // The first base WITH an href wins, as in browsers.
99
- else if (node.tagName === "base")
100
- raw.base ??= attr({ element: node, name: "href" });
92
+ if (isElement(node))
93
+ collectElement(raw, node);
101
94
  }
102
95
  return raw;
103
96
  }
97
+ function collectElement(raw, node) {
98
+ const list = COLLECTED.get(node.tagName);
99
+ if (list) {
100
+ raw[list].push(node);
101
+ return;
102
+ }
103
+ if (node.tagName === "title" || node.tagName === "html") {
104
+ raw[node.tagName] ??= node;
105
+ return;
106
+ }
107
+ // The first base WITH an href wins, as in browsers.
108
+ if (node.tagName === "base")
109
+ raw.base ??= attr({ element: node, name: "href" });
110
+ }
104
111
  /** Meta keys whose values are URLs and are returned absolute. */
105
112
  const URL_META = new Set(["og:image", "og:url", "twitter:image"]);
106
113
  /** `<link rel>` tokens copied into meta, in emission order, under their meta key. */
@@ -127,11 +134,16 @@ function putMeta({ meta, key, value }, _optional = {}) {
127
134
  return;
128
135
  meta[key] = collapse(value).slice(0, MAX_META_VALUE);
129
136
  }
137
+ function metaKey({ property, name }) {
138
+ if (property?.startsWith("og:"))
139
+ return property;
140
+ return name && META_NAMES.has(name) ? name : undefined;
141
+ }
130
142
  /** Every `og:*` property, or an allowlisted `name`; URL-valued keys are resolved. */
131
143
  function metaTagEntry({ element, base }, _optional = {}) {
132
144
  const property = attr({ element, name: "property" })?.toLowerCase();
133
145
  const name = attr({ element, name: "name" })?.toLowerCase();
134
- const key = property?.startsWith("og:") ? property : name && META_NAMES.has(name) ? name : undefined;
146
+ const key = metaKey({ property, name });
135
147
  if (key === undefined)
136
148
  return undefined;
137
149
  const content = attr({ element, name: "content" });
@@ -141,37 +153,21 @@ function relTokens({ element }, _optional = {}) {
141
153
  return (attr({ element, name: "rel" }) ?? "").toLowerCase().split(/\s+/);
142
154
  }
143
155
  function readLinks(anchors, base, pageHost) {
144
- const seen = new Set();
145
- const links = [];
146
- let truncated = false;
147
- for (const anchor of anchors) {
148
- const href = absolute({ raw: attr({ element: anchor, name: "href" }), base, protocols: LINK_PROTOCOLS });
149
- if (!href || seen.has(href))
150
- continue;
151
- seen.add(href);
152
- if (links.length === MAX_LINKS) {
153
- truncated = true;
154
- break;
155
- }
156
- links.push({ href, text: linkText(anchor), internal: isInternal(href, pageHost) });
157
- }
156
+ const { picked, truncated } = firstDistinct({
157
+ items: anchors,
158
+ max: MAX_LINKS,
159
+ keyOf: (anchor) => absolute({ raw: attr({ element: anchor, name: "href" }), base, protocols: LINK_PROTOCOLS }),
160
+ });
161
+ const links = picked.map(([href, anchor]) => ({ href, text: linkText(anchor), internal: isInternal(href, pageHost) }));
158
162
  return { links, truncated };
159
163
  }
160
164
  function readImages(images, base) {
161
- const seen = new Set();
162
- const out = [];
163
- let truncated = false;
164
- for (const img of images) {
165
- const src = absolute({ raw: imageSource({ img }), base, protocols: ASSET_PROTOCOLS });
166
- if (!src || seen.has(src))
167
- continue;
168
- seen.add(src);
169
- if (out.length === MAX_IMAGES) {
170
- truncated = true;
171
- break;
172
- }
173
- out.push({ src, alt: collapse(attr({ element: img, name: "alt" }) ?? "") });
174
- }
165
+ const { picked, truncated } = firstDistinct({
166
+ items: images,
167
+ max: MAX_IMAGES,
168
+ keyOf: (img) => absolute({ raw: imageSource({ img }), base, protocols: ASSET_PROTOCOLS }),
169
+ });
170
+ const out = picked.map(([src, img]) => ({ src, alt: collapse(attr({ element: img, name: "alt" }) ?? "") }));
175
171
  return { images: out, truncated };
176
172
  }
177
173
  function readStylesheets(linkTags, base) {
@@ -12,18 +12,25 @@ const MAX_DEPTH = 300;
12
12
  function isElement(node) {
13
13
  return "tagName" in node;
14
14
  }
15
+ /** A node's children for {@link plainText}; skipped elements and leaves contribute none. */
16
+ function scannableChildren(node) {
17
+ if (!("childNodes" in node) || (isElement(node) && SKIPPED.has(node.tagName)))
18
+ return [];
19
+ return node.childNodes;
20
+ }
15
21
  /** Iterative so a degraded, very deep subtree cannot overflow the stack either. O(subtree nodes). */
16
22
  function plainText(root) {
17
23
  const pending = [root];
18
24
  const parts = [];
19
25
  while (pending.length) {
20
26
  const node = pending.pop();
21
- if (node.nodeName === "#text" && "value" in node)
27
+ if (node.nodeName === "#text" && "value" in node) {
22
28
  parts.push(node.value);
23
- else if ("childNodes" in node && !(isElement(node) && SKIPPED.has(node.tagName))) {
24
- for (let i = node.childNodes.length - 1; i >= 0; i--)
25
- pending.push(node.childNodes[i]);
29
+ continue;
26
30
  }
31
+ const children = scannableChildren(node);
32
+ for (let i = children.length - 1; i >= 0; i--)
33
+ pending.push(children[i]);
27
34
  }
28
35
  return parts.join("").replace(/\s+/g, " ");
29
36
  }
@@ -112,9 +119,11 @@ function takeEdgeWhitespace({ node, last }, _options = {}) {
112
119
  const edge = edgeText({ node, last });
113
120
  if (!edge)
114
121
  return "";
115
- const whitespace = (last ? /\s+$/ : /^\s+/).exec(edge.value)?.[0] ?? "";
116
- edge.value = last ? edge.value.slice(0, edge.value.length - whitespace.length) : edge.value.slice(whitespace.length);
117
- return whitespace;
122
+ const value = edge.value;
123
+ // trimStart/trimEnd strip exactly the characters `\s` matches.
124
+ const kept = last ? value.trimEnd() : value.trimStart();
125
+ edge.value = kept;
126
+ return last ? value.slice(kept.length) : value.slice(0, value.length - kept.length);
118
127
  }
119
128
  /** The first/last leaf of the subtree when it is a text node. */
120
129
  function edgeText({ node, last }, _options = {}) {
@@ -141,9 +150,11 @@ function hastText(root) {
141
150
  while (pending.length) {
142
151
  const node = pending.pop();
143
152
  // HAST text nodes always carry a value.
144
- if (node.type === "text")
153
+ if (node.type === "text") {
145
154
  parts.push(node.value);
146
- else if (node.children)
155
+ continue;
156
+ }
157
+ if (node.children)
147
158
  for (let i = node.children.length - 1; i >= 0; i--)
148
159
  pending.push(node.children[i]);
149
160
  }