akm-cli 0.9.0-beta.50 → 0.9.0-beta.51

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 (48) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/wiki/ingest-workflow-template.md +27 -6
  6. package/dist/cli/parse-args.js +46 -1
  7. package/dist/cli.js +12 -6
  8. package/dist/commands/config-cli.js +18 -2
  9. package/dist/commands/env/child-env.js +47 -0
  10. package/dist/commands/env/env-cli.js +17 -2
  11. package/dist/commands/env/secret-cli.js +24 -2
  12. package/dist/commands/health/checks.js +1 -1
  13. package/dist/commands/improve/improve-auto-accept.js +30 -2
  14. package/dist/commands/improve/improve-cli.js +1 -1
  15. package/dist/commands/improve/improve-result-file.js +9 -2
  16. package/dist/commands/improve/recombine.js +52 -15
  17. package/dist/commands/lint/env-key-rules.js +4 -0
  18. package/dist/commands/read/knowledge.js +5 -2
  19. package/dist/commands/read/search-cli.js +2 -4
  20. package/dist/commands/read/search.js +9 -6
  21. package/dist/commands/read/show.js +19 -5
  22. package/dist/commands/sources/init.js +13 -8
  23. package/dist/commands/sources/installed-stashes.js +6 -2
  24. package/dist/commands/sources/schema-repair.js +33 -47
  25. package/dist/commands/sources/source-add.js +7 -3
  26. package/dist/commands/tasks/tasks.js +38 -10
  27. package/dist/core/asset/asset-registry.js +1 -1
  28. package/dist/core/asset/asset-spec.js +4 -2
  29. package/dist/core/config/config-migration.js +12 -11
  30. package/dist/indexer/passes/memory-inference.js +3 -2
  31. package/dist/indexer/search/db-search.js +6 -4
  32. package/dist/indexer/search/search-source.js +15 -2
  33. package/dist/integrations/agent/prompts.js +1 -1
  34. package/dist/llm/memory-infer-impl.js +138 -0
  35. package/dist/llm/memory-infer.js +1 -135
  36. package/dist/migrate-storage-node.mjs +8 -0
  37. package/dist/output/renderers.js +1 -1
  38. package/dist/scripts/migrate-storage.js +463 -347
  39. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +99 -99
  40. package/dist/sources/include.js +6 -2
  41. package/dist/sources/providers/git-install.js +10 -6
  42. package/dist/sources/providers/provider-utils.js +13 -7
  43. package/dist/sources/providers/website.js +8 -3
  44. package/dist/sources/website-ingest.js +136 -20
  45. package/dist/text-import-hook.mjs +0 -0
  46. package/docs/data-and-telemetry.md +2 -2
  47. package/docs/migration/release-notes/0.9.0.md +39 -0
  48. package/package.json +8 -8
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { createHash } from "node:crypto";
5
5
  import fs from "node:fs";
6
+ import { isIP } from "node:net";
6
7
  import path from "node:path";
7
8
  import { fetchWithRetry, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir } from "../core/common.js";
8
9
  import { ConfigError, UsageError } from "../core/errors.js";
@@ -31,6 +32,20 @@ const WEBSITE_PAGE_BYTE_CAP = 5 * 1024 * 1024;
31
32
  * whole crawl and return what we have when time runs out.
32
33
  */
33
34
  const WEBSITE_CRAWL_WALL_CLOCK_MS = 10 * 60 * 1000;
35
+ const WEBSITE_MAX_REDIRECTS = 8;
36
+ export function shouldAllowPrivateWebsiteHostsForTests() {
37
+ return process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
38
+ }
39
+ export function shouldAllowPrivateWebsiteUrlForTests(rawUrl) {
40
+ if (!shouldAllowPrivateWebsiteHostsForTests())
41
+ return false;
42
+ try {
43
+ return isLoopbackWebsiteHostname(new URL(rawUrl).hostname.toLowerCase());
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
34
49
  function resolveFetcherStashDir(explicitStashDir) {
35
50
  if (explicitStashDir)
36
51
  return explicitStashDir;
@@ -52,7 +67,7 @@ export function getWebsiteCachePaths(siteUrl) {
52
67
  }
53
68
  export async function ensureWebsiteMirror(config, options) {
54
69
  const rawUrl = config.url ?? "";
55
- const normalizedUrl = validateWebsiteUrl(rawUrl);
70
+ const normalizedUrl = validateWebsiteUrl(rawUrl, { allowPrivateHosts: options?.allowPrivateHosts });
56
71
  const cachePaths = getWebsiteCachePaths(normalizedUrl);
57
72
  const requireStashDir = options?.requireStashDir === true;
58
73
  const force = options?.force === true;
@@ -74,6 +89,7 @@ export async function ensureWebsiteMirror(config, options) {
74
89
  await scrapeWebsiteToStash(normalizedUrl, cachePaths.stashDir, {
75
90
  maxPages: coercePositiveInt(config.options?.maxPages, MAX_PAGES_DEFAULT),
76
91
  maxDepth: coercePositiveInt(config.options?.maxDepth, MAX_DEPTH_DEFAULT),
92
+ allowPrivateHosts: options?.allowPrivateHosts,
77
93
  });
78
94
  fs.writeFileSync(cachePaths.manifestPath, `${JSON.stringify({ url: normalizedUrl, fetchedAt: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
79
95
  return cachePaths;
@@ -126,7 +142,7 @@ async function scrapeWebsiteToStash(startUrl, stashDir, options) {
126
142
  }
127
143
  }
128
144
  export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
129
- const normalizedUrl = validateWebsiteInputUrl(rawUrl);
145
+ const normalizedUrl = validateWebsiteInputUrl(rawUrl, { allowPrivateHosts: options?.allowPrivateHosts });
130
146
  const parsedUrl = new URL(normalizedUrl);
131
147
  const stashDir = resolveFetcherStashDir(options?.stashDir);
132
148
  const context = {
@@ -147,7 +163,7 @@ export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
147
163
  warn("[akm] wiki-fetcher %s threw on %s: %s", fetcher.name, normalizedUrl, error instanceof Error ? error.message : String(error));
148
164
  }
149
165
  }
150
- const fetched = await fetchWebsitePage(normalizedUrl);
166
+ const fetched = await fetchWebsitePage(normalizedUrl, { allowPrivateHosts: options?.allowPrivateHosts });
151
167
  if (!fetched) {
152
168
  throw new UsageError(`No content could be fetched from ${normalizedUrl}`);
153
169
  }
@@ -189,7 +205,7 @@ async function crawlWebsite(startUrl, options) {
189
205
  if (!normalized || visited.has(normalized))
190
206
  continue;
191
207
  visited.add(normalized);
192
- const fetched = await fetchWebsitePage(normalized);
208
+ const fetched = await fetchWebsitePage(normalized, { allowPrivateHosts: options.allowPrivateHosts });
193
209
  if (!fetched)
194
210
  continue;
195
211
  pages.push(fetched.page);
@@ -211,17 +227,8 @@ async function crawlWebsite(startUrl, options) {
211
227
  }
212
228
  return pages;
213
229
  }
214
- async function fetchWebsitePage(pageUrl) {
215
- const parsedUrl = new URL(pageUrl);
216
- if (parsedUrl.hostname.endsWith(".invalid")) {
217
- throw new Error(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`);
218
- }
219
- const response = await fetchWithRetry(pageUrl, {
220
- headers: {
221
- Accept: "text/html, text/markdown, text/plain;q=0.9, application/xhtml+xml;q=0.8",
222
- "User-Agent": "akm-cli website provider",
223
- },
224
- }, { timeout: 15_000, retries: 1 });
230
+ async function fetchWebsitePage(pageUrl, options) {
231
+ const response = await fetchWebsiteResponse(pageUrl, 0, options);
225
232
  if (!response.ok) {
226
233
  if (response.status === 404)
227
234
  return null;
@@ -238,6 +245,7 @@ async function fetchWebsitePage(pageUrl) {
238
245
  throw err;
239
246
  }
240
247
  const finalUrl = normalizeCrawlUrl(response.url || pageUrl) ?? pageUrl;
248
+ assertWebsiteRequestUrl(finalUrl, Error, options);
241
249
  if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml") || looksLikeMarkup(body)) {
242
250
  const title = extractHtmlTitle(body) || new URL(finalUrl).hostname;
243
251
  return {
@@ -258,6 +266,29 @@ async function fetchWebsitePage(pageUrl) {
258
266
  links: [],
259
267
  };
260
268
  }
269
+ async function fetchWebsiteResponse(pageUrl, redirectCount = 0, options) {
270
+ assertWebsiteRequestUrl(pageUrl, Error, options);
271
+ const response = await fetchWithRetry(pageUrl, {
272
+ headers: {
273
+ Accept: "text/html, text/markdown, text/plain;q=0.9, application/xhtml+xml;q=0.8",
274
+ "User-Agent": "akm-cli website provider",
275
+ },
276
+ redirect: "manual",
277
+ }, { timeout: 15_000, retries: 1 });
278
+ if (response.status >= 300 && response.status < 400) {
279
+ if (redirectCount >= WEBSITE_MAX_REDIRECTS) {
280
+ throw new Error(`Too many redirects while fetching ${pageUrl}`);
281
+ }
282
+ const location = response.headers.get("location");
283
+ if (!location) {
284
+ throw new Error(`Redirect response from ${pageUrl} did not include a Location header`);
285
+ }
286
+ const nextUrl = new URL(location, pageUrl).toString();
287
+ assertWebsiteRequestUrl(nextUrl, Error, options);
288
+ return fetchWebsiteResponse(nextUrl, redirectCount + 1, options);
289
+ }
290
+ return response;
291
+ }
261
292
  function buildMarkdownSnapshot(page, slug, tags) {
262
293
  const title = sanitizeString(page.title, 200) || slug;
263
294
  const description = sanitizeString(`Snapshot of ${page.url}`, 500);
@@ -282,13 +313,13 @@ function buildMarkdownSnapshot(page, slug, tags) {
282
313
  "",
283
314
  ].join("\n");
284
315
  }
285
- export function validateWebsiteUrl(rawUrl) {
286
- return validateWebsiteUrlWithError(rawUrl, ConfigError);
316
+ export function validateWebsiteUrl(rawUrl, options) {
317
+ return validateWebsiteUrlWithError(rawUrl, ConfigError, options);
287
318
  }
288
- export function validateWebsiteInputUrl(rawUrl) {
289
- return validateWebsiteUrlWithError(rawUrl, UsageError);
319
+ export function validateWebsiteInputUrl(rawUrl, options) {
320
+ return validateWebsiteUrlWithError(rawUrl, UsageError, options);
290
321
  }
291
- function validateWebsiteUrlWithError(rawUrl, ErrorType) {
322
+ function validateWebsiteUrlWithError(rawUrl, ErrorType, options) {
292
323
  if (!rawUrl) {
293
324
  throw new ErrorType("Website provider requires a URL");
294
325
  }
@@ -305,6 +336,7 @@ function validateWebsiteUrlWithError(rawUrl, ErrorType) {
305
336
  if (parsed.username || parsed.password) {
306
337
  throw new ErrorType("Website URL must not contain embedded credentials");
307
338
  }
339
+ assertWebsiteRequestUrl(parsed.toString(), ErrorType, options);
308
340
  parsed.hash = "";
309
341
  return normalizeSiteUrl(parsed.toString());
310
342
  }
@@ -503,6 +535,90 @@ function isAssetLikePath(pathname) {
503
535
  function isSafeLinkUrl(url) {
504
536
  return url.protocol === "http:" || url.protocol === "https:";
505
537
  }
538
+ function assertWebsiteRequestUrl(rawUrl, ErrorType = Error, options) {
539
+ const parsedUrl = new URL(rawUrl);
540
+ const hostname = parsedUrl.hostname.toLowerCase();
541
+ if (hostname.endsWith(".invalid")) {
542
+ throw new ErrorType(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`);
543
+ }
544
+ if (isForbiddenWebsiteHostname(hostname, options)) {
545
+ throw new ErrorType(`Refusing to fetch non-public website host: ${parsedUrl.hostname}`);
546
+ }
547
+ }
548
+ // WHATWG URL.hostname wraps IPv6 literals in brackets (e.g. "[::1]"), but
549
+ // node:net's isIP() only recognizes the bare address form and returns 0 for
550
+ // anything bracketed — silently skipping all IPv6 forbidden-host checks
551
+ // below for every hostname parsed off a URL. Strip the brackets before any
552
+ // isIP()/isForbiddenIpv6() call so those checks actually run.
553
+ function stripIpv6Brackets(hostname) {
554
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
555
+ }
556
+ function isForbiddenWebsiteHostname(hostname, options) {
557
+ if (options?.allowPrivateHosts === true)
558
+ return false;
559
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "metadata.google.internal") {
560
+ return true;
561
+ }
562
+ const bareHostname = stripIpv6Brackets(hostname);
563
+ const ipVersion = isIP(bareHostname);
564
+ if (ipVersion === 4)
565
+ return isForbiddenIpv4(bareHostname);
566
+ if (ipVersion === 6)
567
+ return isForbiddenIpv6(bareHostname);
568
+ return false;
569
+ }
570
+ function isLoopbackWebsiteHostname(hostname) {
571
+ if (hostname === "localhost" || hostname.endsWith(".localhost"))
572
+ return true;
573
+ const bareHostname = stripIpv6Brackets(hostname);
574
+ const ipVersion = isIP(bareHostname);
575
+ if (ipVersion === 4)
576
+ return bareHostname.startsWith("127.");
577
+ if (ipVersion === 6)
578
+ return bareHostname === "::1";
579
+ return false;
580
+ }
581
+ function isForbiddenIpv4(hostname) {
582
+ const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
583
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
584
+ return true;
585
+ const [a, b] = parts;
586
+ return (a === 0 ||
587
+ a === 10 ||
588
+ a === 127 ||
589
+ (a === 169 && b === 254) ||
590
+ (a === 172 && b >= 16 && b <= 31) ||
591
+ (a === 192 && b === 168));
592
+ }
593
+ /**
594
+ * Extracts the embedded IPv4 address from an IPv4-mapped IPv6 literal
595
+ * (`::ffff:a.b.c.d` or its canonical hex form `::ffff:xxxx:yyyy`), or
596
+ * returns null if `hostname` isn't one.
597
+ */
598
+ function extractIpv4MappedAddress(normalizedHostname) {
599
+ const match = normalizedHostname.match(/^::ffff:(?:(\d{1,3}(?:\.\d{1,3}){3})|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/);
600
+ if (!match)
601
+ return null;
602
+ if (match[1])
603
+ return match[1];
604
+ const high = Number.parseInt(match[2], 16);
605
+ const low = Number.parseInt(match[3], 16);
606
+ return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
607
+ }
608
+ function isForbiddenIpv6(hostname) {
609
+ const normalized = hostname.toLowerCase();
610
+ const mappedIpv4 = extractIpv4MappedAddress(normalized);
611
+ if (mappedIpv4)
612
+ return isForbiddenIpv4(mappedIpv4);
613
+ return (normalized === "::" ||
614
+ normalized === "::1" ||
615
+ normalized.startsWith("fc") ||
616
+ normalized.startsWith("fd") ||
617
+ normalized.startsWith("fe8") ||
618
+ normalized.startsWith("fe9") ||
619
+ normalized.startsWith("fea") ||
620
+ normalized.startsWith("feb"));
621
+ }
506
622
  function stripDangerousBlockTag(value, tagName) {
507
623
  const pattern = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}\\s*>`, "gi");
508
624
  return value.replace(pattern, "");
File without changes
@@ -63,7 +63,7 @@ Override: set `AKM_CACHE_DIR` or `XDG_CACHE_HOME`.
63
63
 
64
64
  | Path | Contents | Safe to delete? |
65
65
  |---|---|---|
66
- | `<stash>/` | All your asset files: agents, skills, commands, knowledge, workflows, memories, vaults, wikis, lessons | **No** — this is YOUR data |
66
+ | `<stash>/` | All your asset files: agents, skills, commands, knowledge, workflows, memories, env files, secrets, wikis, lessons, facts | **No** — this is YOUR data |
67
67
  | `<stash>/.akm/` | Hidden AKM metadata (v0.7 proposals, legacy runs) | Caution — check for pending proposals first |
68
68
 
69
69
  Override: set `AKM_STASH_DIR` (or configure `stashDir` in `config.json`).
@@ -130,7 +130,7 @@ An append-only log of every mutating action you perform with AKM. Events are sto
130
130
 
131
131
  ### 2. Proposals Table
132
132
 
133
- The proposal queue: pending, accepted, and rejected improvement proposals for your stash assets. Generated by `akm reflect` and the improve loop.
133
+ The proposal queue: pending, accepted, and rejected improvement proposals for your stash assets. Generated by `akm improve`, `akm propose`, and related proposal-producing flows.
134
134
 
135
135
  Contents:
136
136
  - Proposal UUID (primary key)
@@ -0,0 +1,39 @@
1
+ Migration notes for akm v0.9.0
2
+
3
+ This is the final 0.9.0 cut. If you were already running a 0.9.0 beta, the
4
+ main upgrade work is to align scripts, prompts, and stash layout with the
5
+ released command surface.
6
+
7
+ Key operator-facing changes:
8
+
9
+ - Node.js is supported again for the published CLI. Install with Bun, Node.js
10
+ >= 20.12, or the prebuilt binary.
11
+ - The legacy `vault` asset type and `akm vault ...` command family are gone.
12
+ Use `env:` for whole `.env` groups and `secret:` for a single sensitive
13
+ value.
14
+ - `akm-migrate-storage` still ships and still performs the non-destructive
15
+ `vaults/` -> `env/` copy for older stashes. Run it before indexing if you are
16
+ upgrading from a stash that still stores `.env` files only under `vaults/`.
17
+ - Proposal workflow is fully consolidated around `akm improve`, `akm propose`,
18
+ and `akm proposal ...`. Update any old `akm reflect`, `akm distill`,
19
+ `akm accept`, `akm reject`, or `akm proposals` usage.
20
+
21
+ Primary public command family for 0.9.0:
22
+
23
+ - `akm improve <ref> [--task "..."]`
24
+ - `akm propose <type> <name> (--task "..." | --file <path>)`
25
+ - `akm proposal list`
26
+ - `akm proposal show <id>`
27
+ - `akm proposal diff <id>`
28
+ - `akm proposal accept <id>`
29
+ - `akm proposal reject <id> --reason "..."`
30
+
31
+ Recommended post-upgrade checks:
32
+
33
+ - Run `akm help migrate 0.9.0` on every machine where the CLI is installed.
34
+ - Run `akm-migrate-storage --yes` once if the stash ever used `vaults/`.
35
+ - Rebuild the index with `akm index`.
36
+ - Review agent instructions and docs for old `vault`, `reflect`, and `distill`
37
+ examples.
38
+
39
+ Full changelog: https://github.com/itlackey/akm/blob/main/CHANGELOG.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.50",
3
+ "version": "0.9.0-beta.51",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [
@@ -46,11 +46,11 @@
46
46
  "docs/data-and-telemetry.md"
47
47
  ],
48
48
  "bin": {
49
- "akm": "dist/cli.js",
50
- "akm-migrate-storage": "dist/scripts/migrate-storage.js"
49
+ "akm": "dist/akm",
50
+ "akm-migrate-storage": "dist/akm-migrate-storage"
51
51
  },
52
52
  "scripts": {
53
- "preinstall": "node -e \"var ua=process.env.npm_config_user_agent||'';var major=parseInt((process.versions.node||'0').split('.')[0],10);if(process.versions.bun||ua.startsWith('bun/')||process.env.BUN_INSTALL||major>=20){process.exit(0)}console.error('\\n ERROR: akm-cli requires the Bun runtime (https://bun.sh), Node.js >= 20, or the prebuilt binary.\\n Install options:\\n 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\\n 2. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\\n');process.exit(1)\"",
53
+ "preinstall": "node -e \"var ua=process.env.npm_config_user_agent||'';var v=(process.versions.node||'0').split('.').map(function(n){return parseInt(n,10)||0});var ok=v[0]>20||(v[0]===20&&(v[1]>12||(v[1]===12&&v[2]>=0)));if(process.versions.bun||ua.startsWith('bun/')||process.env.BUN_INSTALL||ok){process.exit(0)}console.error('\\n ERROR: akm-cli requires the Bun runtime (https://bun.sh), Node.js >= 20.12, or the prebuilt binary.\\n Install options:\\n 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\\n 2. Node: upgrade to Node.js 20.12 or newer (https://nodejs.org)\\n 3. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\\n');process.exit(1)\"",
54
54
  "build": "rm -rf dist && bun scripts/gen-config-schema.ts &&bun run tsc --project ./tsconfig.build.json && bun scripts/copy-assets.ts && bun scripts/fix-esm-extensions.ts",
55
55
  "check": "bun run lint && bunx tsc --noEmit && bun run test:unit && bun run test:integration",
56
56
  "check:fast": "bun run lint && bunx tsc --noEmit && bun run test:unit",
@@ -58,9 +58,9 @@
58
58
  "sweep:tmp": "bun scripts/sweep-test-tmp.ts",
59
59
  "test": "bash scripts/test-unit.sh",
60
60
  "test:unit": "bash scripts/test-unit.sh",
61
- "test:integration": "bun run sweep:tmp && bun test --parallel=1 --timeout=30000 ./tests/integration ./tests/commands ./tests/workflows",
62
- "test:unit:shard": "bun run sweep:tmp && bun test --parallel=1 --timeout=30000 ./tests --path-ignore-patterns=tests/integration --shard=${SHARD:?set SHARD=k/N}",
63
- "test:integration:shard": "bun run sweep:tmp && bun test --parallel=1 --timeout=30000 ./tests/integration ./tests/commands ./tests/workflows --shard=${SHARD:?set SHARD=k/N}",
61
+ "test:integration": "bun run sweep:tmp && bun test --isolate --timeout=30000 ./tests/integration ./tests/commands ./tests/workflows",
62
+ "test:unit:shard": "bun run sweep:tmp && bun test --isolate --timeout=30000 ./tests --path-ignore-patterns=tests/integration --shard=${SHARD:?set SHARD=k/N}",
63
+ "test:integration:shard": "bun run sweep:tmp && bun test --isolate --timeout=30000 ./tests/integration ./tests/commands ./tests/workflows --shard=${SHARD:?set SHARD=k/N}",
64
64
  "test:node-smoke": "bun scripts/node-smoke.ts",
65
65
  "test:node-compat": "AKM_NODE_COMPAT_TESTS=1 bun test --timeout=120000 tests/integration/node-compat.test.ts",
66
66
  "test:time": "bun scripts/test-timing-report.ts",
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "engines": {
97
97
  "bun": ">=1.0.0",
98
- "node": ">=20.0.0"
98
+ "node": ">=20.12.0"
99
99
  },
100
100
  "dependencies": {
101
101
  "@clack/prompts": "^1.3.0",