@akanjs/devkit 3.1.1-rc.0 → 3.1.1-rc.10

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 (46) hide show
  1. package/akanConfig/akanConfig.test.ts +13 -2
  2. package/akanConfig/akanConfig.ts +5 -5
  3. package/akanConfig/productionLockfile.test.ts +80 -0
  4. package/akanConfig/productionLockfile.ts +32 -0
  5. package/akanContext.ts +25 -0
  6. package/applicationBuildRunner.test.ts +41 -0
  7. package/applicationBuildRunner.ts +23 -3
  8. package/biomeBase.test.ts +2 -2
  9. package/cloud/cloudApi.test.ts +46 -0
  10. package/cloud/cloudApi.ts +23 -13
  11. package/cloud/cloudHttpError.test.ts +8 -0
  12. package/cloud/cloudHttpError.ts +11 -1
  13. package/cloud/cloudUpdates.ts +48 -2
  14. package/cloud/updatePackUpload.test.ts +38 -2
  15. package/cloud/updatePackUpload.ts +5 -1
  16. package/commandDecorators/command.ts +3 -14
  17. package/commandDecorators/commandDecorators.test.ts +1 -1
  18. package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
  19. package/commandDecorators/workspaceVersionCheck.ts +50 -0
  20. package/executors.test.ts +21 -1
  21. package/index.ts +0 -1
  22. package/lint/gritRules.test.ts +3 -3
  23. package/linter.test.ts +18 -1
  24. package/linter.ts +10 -6
  25. package/mobile/desktopRelease.test.ts +473 -0
  26. package/mobile/desktopRelease.ts +307 -0
  27. package/mobile/desktopServerStage.ts +4 -2
  28. package/mobile/index.ts +2 -0
  29. package/mobile/nativeApi.test.ts +4 -1
  30. package/mobile/nativeApi.ts +3 -3
  31. package/mobile/nativeApp.test.ts +2 -2
  32. package/mobile/nativeApp.ts +29 -5
  33. package/mobile/nativeConfig.test.ts +16 -8
  34. package/mobile/nativeConfig.ts +1 -1
  35. package/mobile/tarWriter.test.ts +89 -0
  36. package/mobile/tarWriter.ts +93 -0
  37. package/mobile/updateUploader.test.ts +34 -0
  38. package/mobile/updateUploader.ts +9 -6
  39. package/package.json +2 -2
  40. package/renderMutationScanner.test.ts +223 -0
  41. package/renderMutationScanner.ts +209 -0
  42. package/signalMutationIndex.test.ts +120 -0
  43. package/signalMutationIndex.ts +127 -0
  44. package/ssrScanner.test.ts +31 -0
  45. package/ssrScanner.ts +31 -12
  46. package/uploadRelease.ts +0 -80
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { createHash } from "node:crypto";
3
- import { writeFile } from "node:fs/promises";
3
+ import { truncate, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import type { Workspace } from "../commandDecorators";
6
6
  import { tempDirs } from "../testHelpers";
@@ -24,6 +24,7 @@ class FakeCloudHost {
24
24
  commitAnswers: (number | [number, string] | Response)[] = [];
25
25
  release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
26
26
  requestRefusal: Response | null = null;
27
+ contentType = "application/gzip";
27
28
 
28
29
  constructor() {
29
30
  this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
@@ -42,6 +43,7 @@ class FakeCloudHost {
42
43
  this.stallPuts = false;
43
44
  this.commitAnswers = [];
44
45
  this.requestRefusal = null;
46
+ this.contentType = "application/gzip";
45
47
  }
46
48
 
47
49
  async #route(request: Request) {
@@ -54,7 +56,7 @@ class FakeCloudHost {
54
56
  uploadId,
55
57
  uploadUrl: `${this.host}/storage/${uploadId}?X-Amz-Signature=secret-signature`,
56
58
  expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
57
- contentType: "application/gzip",
59
+ contentType: this.contentType,
58
60
  });
59
61
  }
60
62
  if (url.pathname.startsWith("/storage/") && request.method === "PUT") {
@@ -302,4 +304,38 @@ describe("UpdatePackUpload", () => {
302
304
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
303
305
  expect(cloudHost.requests).toEqual([]);
304
306
  });
307
+
308
+ test("announces a desktop release with its kind and CPU, a phone pack with neither, and PUTs the ticket's type", async () => {
309
+ const desktop = { ...fields, platform: "macos" as const, kind: "app" as const, arch: "arm64" as const };
310
+ cloudHost.contentType = "application/x-tar";
311
+
312
+ await new UpdatePackUpload(api, "project-1", quick).send(await archiveOf("release tar"), desktop);
313
+ await new UpdatePackUpload(api, "project-1", quick).send(await archiveOf("pack bytes"), fields);
314
+
315
+ expect(cloudHost.requests[0]).toMatchObject({ platform: "macos", kind: "app", arch: "arm64", size: 11 });
316
+ expect(Object.keys(cloudHost.requests[1] ?? {}).filter((key) => key === "kind" || key === "arch")).toEqual([]);
317
+ expect(cloudHost.puts.map((put) => put.headers["content-type"])).toEqual([
318
+ "application/x-tar",
319
+ "application/x-tar",
320
+ ]);
321
+ });
322
+
323
+ test("takes a desktop release up to 1 GiB and a phone pack up to 200 MiB, refusing more before a ticket", async () => {
324
+ const desktop = { ...fields, platform: "windows" as const, kind: "app" as const, arch: "x64" as const };
325
+ const sparse = async (bytes: number) => {
326
+ const archive = await archiveOf("");
327
+ await truncate(archive, bytes);
328
+ return archive;
329
+ };
330
+ const upload = new UpdatePackUpload(api, "project-1", quick);
331
+
332
+ await expect(upload.send(await sparse(UpdatePackUpload.maxBytes + 1), fields)).rejects.toThrow(
333
+ `The pack archive is ${UpdatePackUpload.maxBytes + 1} bytes; a project takes 1 byte to 200 MiB.`,
334
+ );
335
+ await expect(upload.send(await sparse(UpdatePackUpload.maxAppBytes + 1), desktop)).rejects.toThrow(
336
+ `The release archive is ${UpdatePackUpload.maxAppBytes + 1} bytes; a project takes 1 byte to 1 GiB.`,
337
+ );
338
+ expect(cloudHost.requests).toEqual([]);
339
+ expect(UpdatePackUpload.maxAppBytes).toBe(1_073_741_824);
340
+ });
305
341
  });
@@ -16,6 +16,7 @@ interface UpdatePackUploadOptions {
16
16
  //* embedded pack; either re-hashes it, and an upload is used once, one way.
17
17
  export class UpdatePackUpload {
18
18
  static readonly maxBytes = 200 * 1024 * 1024;
19
+ static readonly maxAppBytes = 1024 * 1024 * 1024;
19
20
  static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
20
21
  //? A stalled PUT never fails on its own, so each try ends once the pack would be through even at this crawl.
21
22
  static readonly #putFloorBytesPerSecond = 64 * 1024;
@@ -57,7 +58,10 @@ export class UpdatePackUpload {
57
58
  /** Puts the pack in the project's staging storage and answers the upload id a commit or a registration names. */
58
59
  async stage(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<string> {
59
60
  const file = Bun.file(archive);
60
- if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
61
+ if (fields.kind === "app") {
62
+ if (file.size < 1 || file.size > UpdatePackUpload.maxAppBytes)
63
+ throw new Error(`The release archive is ${file.size} bytes; a project takes 1 byte to 1 GiB.`);
64
+ } else if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
61
65
  throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
62
66
  const hasher = new Bun.CryptoHasher("sha256");
63
67
  for await (const chunk of file.stream()) hasher.update(chunk);
@@ -26,6 +26,7 @@ import { CommandContainer } from "./dependencyBuilder";
26
26
  import { ExitCodeError } from "./exitCodeError";
27
27
  import { formatCommandHelp, formatHelp } from "./helpFormatter";
28
28
  import { type CommandCls, getTargetCommandNames, getTargetMetas, type TargetFailure } from "./targetMeta";
29
+ import { WorkspaceVersionCheck } from "./workspaceVersionCheck";
29
30
 
30
31
  const loggedCliErrorObjects = new WeakSet<object>();
31
32
  const loggedCliErrorMessages = new Set<string>();
@@ -374,20 +375,8 @@ export const runCommands = async (...commands: CommandCls[]) => {
374
375
  program.version(process.env.AKAN_VERSION).description("Akan CLI").configureHelp({
375
376
  helpWidth: 100,
376
377
  });
377
- const installedAkanPackageJson = (await FileSys.fileExists("./node_modules/akanjs/package.json"))
378
- ? await FileSys.readJson<PackageJson>("./node_modules/akanjs/package.json")
379
- : null;
380
- if (installedAkanPackageJson && installedAkanPackageJson.version !== process.env.AKAN_VERSION) {
381
- Logger.rawLog(
382
- chalk.yellow(
383
- `
384
- Akan CLI version is mismatch with installed package. ${process.env.AKAN_VERSION} (global) vs ${installedAkanPackageJson.version} (akanjs)
385
- It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
386
- ),
387
- undefined,
388
- "error",
389
- );
390
- }
378
+ const versionWarning = WorkspaceVersionCheck.warning(cliPackageJson?.version, process.argv, process.cwd());
379
+ if (versionWarning) Logger.rawLog(chalk.yellow(`\n${versionWarning}`), undefined, "error");
391
380
 
392
381
  for (const command of commands) {
393
382
  const targetMetas = getTargetMetas(command);
@@ -695,6 +695,6 @@ describe("a workspace whose akanjs is not the CLI's version", () => {
695
695
  ]);
696
696
  expect(code).toBe(0);
697
697
  expect(stdout).toBe('{"phases":1}');
698
- expect(stderr).toContain("0.0.1-older (akanjs)");
698
+ expect(stderr).toContain("this workspace has akanjs 0.0.1-older");
699
699
  }, 30_000);
700
700
  });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, symlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs, writeJson } from "../testHelpers";
5
+ import { WorkspaceVersionCheck } from "./workspaceVersionCheck";
6
+
7
+ const makeRoot = tempDirs("akan-version-check-");
8
+ const argv = (...args: string[]) => ["bun", "akan", ...args];
9
+
10
+ const makeWorkspace = async ({ akanjs, cli }: { akanjs?: string; cli?: string }) => {
11
+ const root = await makeRoot();
12
+ if (akanjs) await writeJson(path.join(root, "node_modules/akanjs/package.json"), { name: "akanjs", version: akanjs });
13
+ if (cli)
14
+ await writeJson(path.join(root, "node_modules/@akanjs/cli/package.json"), { name: "@akanjs/cli", version: cli });
15
+ return root;
16
+ };
17
+
18
+ describe("WorkspaceVersionCheck", () => {
19
+ test("stays quiet when the CLI and the workspace's akanjs match, or when akanjs is not installed", async () => {
20
+ expect(
21
+ WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("sync", "demo"), await makeWorkspace({ akanjs: "3.1.1-rc.5" })),
22
+ ).toBeNull();
23
+ expect(WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("sync", "demo"), await makeWorkspace({}))).toBeNull();
24
+ });
25
+
26
+ test("names both versions and the matching CLI with the same arguments", async () => {
27
+ const root = await makeWorkspace({ akanjs: "3.1.0-rc.6" });
28
+
29
+ const warning = WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo", "--title", "it's"), root);
30
+
31
+ expect(warning).toContain("@akanjs/cli 3.1.0, but this workspace has akanjs 3.1.0-rc.6");
32
+ expect(warning).toContain(`Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title 'it'\\''s'`);
33
+ expect(warning).toEndWith("together: akan update");
34
+ });
35
+
36
+ test("offers akan update with the CLI's prerelease tag only when the CLI is the newer one", async () => {
37
+ const older = await makeWorkspace({ akanjs: "3.1.1-rc.4" });
38
+ expect(WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("build", "demo"), older)).toEndWith("akan update --tag rc");
39
+
40
+ const newer = await makeWorkspace({ akanjs: "3.1.1-rc.5" });
41
+ const warning = WorkspaceVersionCheck.warning("3.1.0", argv("build", "demo"), newer);
42
+ expect(warning).not.toContain("akan update");
43
+ expect(warning).toEndWith("Pin it so `bunx akan` runs it: bun add -d @akanjs/cli@3.1.1-rc.5");
44
+ });
45
+
46
+ test("points at the workspace's own pinned CLI when it matches akanjs", async () => {
47
+ const root = await makeWorkspace({ akanjs: "3.1.1-rc.5", cli: "3.1.1-rc.5" });
48
+
49
+ const warning = WorkspaceVersionCheck.warning("3.1.0", argv("start", "demo"), root);
50
+
51
+ expect(warning).toContain("Run the matching CLI: bunx akan start demo");
52
+ expect(warning).not.toContain("bun add -d");
53
+ });
54
+
55
+ test("leaves update, create-workspace, help and version alone", async () => {
56
+ const root = await makeWorkspace({ akanjs: "3.1.0-rc.6" });
57
+ for (const args of [["update"], ["create-workspace", "next"], ["--version"], ["-V"], ["sync", "--help"]])
58
+ expect(WorkspaceVersionCheck.warning("3.1.0", argv(...args), root)).toBeNull();
59
+ });
60
+
61
+ test("skips a workspace that links akanjs from its own source", async () => {
62
+ const root = await makeWorkspace({});
63
+ await writeJson(path.join(root, "pkgs/akanjs/package.json"), { name: "akanjs", version: "3.1.1-rc.4" });
64
+ await mkdir(path.join(root, "node_modules"), { recursive: true });
65
+ await symlink(path.join(root, "pkgs/akanjs"), path.join(root, "node_modules/akanjs"), "dir");
66
+
67
+ expect(WorkspaceVersionCheck.installedAkanVersion(root)).toBeNull();
68
+ expect(WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo"), root)).toBeNull();
69
+ });
70
+ });
@@ -0,0 +1,50 @@
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { PackageJson } from "../types";
4
+
5
+ export class WorkspaceVersionCheck {
6
+ static readonly #exemptCommands = new Set(["update", "create-workspace"]);
7
+ static readonly #infoFlags = new Set(["--help", "-h", "--version", "-V"]);
8
+ static readonly #updateTags = new Set(["dev", "canary", "beta", "rc", "alpha"]);
9
+
10
+ static warning(cliVersion: string | undefined, argv: string[], workspaceRoot: string) {
11
+ const args = argv.slice(2);
12
+ if (!cliVersion || WorkspaceVersionCheck.#exemptCommands.has(args[0] ?? "")) return null;
13
+ if (args.some((arg) => WorkspaceVersionCheck.#infoFlags.has(arg))) return null;
14
+ const installed = WorkspaceVersionCheck.installedAkanVersion(workspaceRoot);
15
+ if (!installed || installed === cliVersion) return null;
16
+ const pinnedCli = WorkspaceVersionCheck.#versionAt(path.join(workspaceRoot, "node_modules/@akanjs/cli"));
17
+ const runner = pinnedCli === installed ? ["bunx"] : ["bunx", "-p", `@akanjs/cli@${installed}`];
18
+ const rerun = [...runner, "akan", ...args].map(WorkspaceVersionCheck.#quote).join(" ");
19
+ const [, prerelease = ""] = cliVersion.split("-");
20
+ const tag = prerelease.split(".")[0];
21
+ const update = `akan update${WorkspaceVersionCheck.#updateTags.has(tag) ? ` --tag ${tag}` : ""}`;
22
+ const lines = [
23
+ `This akan is @akanjs/cli ${cliVersion}, but this workspace has akanjs ${installed}: what it generates may not match the installed framework.`,
24
+ `Run the matching CLI: ${rerun}`,
25
+ ];
26
+ if (Bun.semver.order(cliVersion, installed) > 0)
27
+ lines.push(`Or move the workspace and this CLI to the newest release of the line together: ${update}`);
28
+ else if (pinnedCli !== installed)
29
+ lines.push(`Pin it so \`bunx akan\` runs it: bun add -d @akanjs/cli@${installed}`);
30
+ return lines.join("\n");
31
+ }
32
+
33
+ static installedAkanVersion(workspaceRoot: string) {
34
+ const packageDir = path.join(workspaceRoot, "node_modules", "akanjs");
35
+ if (!existsSync(packageDir)) return null;
36
+ //? A workspace link (the framework's own repo) runs akanjs from source, so no installed release can differ.
37
+ if (!realpathSync(packageDir).split(path.sep).includes("node_modules")) return null;
38
+ return WorkspaceVersionCheck.#versionAt(packageDir);
39
+ }
40
+
41
+ static #versionAt(packageDir: string) {
42
+ const packageJsonPath = path.join(packageDir, "package.json");
43
+ if (!existsSync(packageJsonPath)) return null;
44
+ return (JSON.parse(readFileSync(packageJsonPath, "utf8")) as Partial<PackageJson>).version ?? null;
45
+ }
46
+
47
+ static #quote(arg: string) {
48
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
49
+ }
50
+ }
package/executors.test.ts CHANGED
@@ -1383,7 +1383,13 @@ describe("SysExecutor scan", () => {
1383
1383
  expect(await lib.readFile("srvkit/index.ts")).toBe(
1384
1384
  ["a9", "a10", "aa", "aB"].map((name) => `export * from "./${name}";\n`).join(""),
1385
1385
  );
1386
- for (const file of ["ui/index.ts", "srvkit/index.ts", "lib/post/index.ts", "akan.lib.json"]) {
1386
+ for (const file of [
1387
+ "ui/index.ts",
1388
+ "srvkit/index.ts",
1389
+ "lib/post/index.ts",
1390
+ "akan.lib.json",
1391
+ "env/env.server.type.ts",
1392
+ ]) {
1387
1393
  const generated = await lib.readFile(file);
1388
1394
  expect(await formatWithBiome(generated, `libs/biomestable/${file}`)).toBe(generated);
1389
1395
  }
@@ -1392,6 +1398,20 @@ describe("SysExecutor scan", () => {
1392
1398
  expect(await devSync.syncForBatch(events)).toEqual({ changedFiles: [], errors: [] });
1393
1399
  });
1394
1400
 
1401
+ test.skipIf(!hasBiome)(
1402
+ "writes env.server.type.ts the way Biome prints it, spreading libs in dependency order",
1403
+ async () => {
1404
+ const { default: getContent } = (await import("../cli/templates/env/_env.server.type")) as {
1405
+ default: (scanInfo: unknown) => { content: string };
1406
+ };
1407
+ const libs = ["util", "shared", "shopAdmin", "shop", "a10", "a9"];
1408
+ const { content } = getContent({ getLibs: () => libs });
1409
+
1410
+ expect(await formatWithBiome(content, "apps/biomestable/env/env.server.type.ts")).toBe(content);
1411
+ expect([...content.matchAll(/\.\.\.(\w+)Option/g)].map(([, lib]) => lib)).toEqual(libs);
1412
+ },
1413
+ );
1414
+
1395
1415
  test("refuses a hand-written ui/index.tsx beside the generated barrel, naming the file and the fix", async () => {
1396
1416
  const { lib } = await createScannableLib("shadowedbarrel", {
1397
1417
  files: {
package/index.ts CHANGED
@@ -31,5 +31,4 @@ export type * from "./stripAnsi";
31
31
  export type * from "./transforms";
32
32
  export type * from "./typeChecker";
33
33
  export type * from "./types";
34
- export type * from "./uploadRelease";
35
34
  export type * from "./workflow";
@@ -11,7 +11,7 @@ import { Linter } from "../linter";
11
11
  const lintDir = import.meta.dir;
12
12
  const fixturesDir = path.join(lintDir, "__fixtures__");
13
13
  const workspaceRoot = path.resolve(lintDir, "../../../..");
14
- const biomeBin = path.join(workspaceRoot, "node_modules", ".bin", Linter.biomeBinName);
14
+ const [biome, ...biomeArgs] = Linter.biomeCommand(workspaceRoot);
15
15
 
16
16
  interface BiomeDiagnostic {
17
17
  severity: string;
@@ -42,7 +42,7 @@ afterAll(async () => {
42
42
 
43
43
  const runBiome = (args: string[], cwd: string) =>
44
44
  new Promise<string>((resolve, reject) => {
45
- const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
45
+ const proc = spawn(biome, [...biomeArgs, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
46
46
  let stdout = "";
47
47
  let stderr = "";
48
48
  proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
@@ -127,7 +127,7 @@ const fixtures = await collectCases();
127
127
 
128
128
  describe("grit lint rules", () => {
129
129
  test("the harness found the biome binary and at least one fixture", () => {
130
- expect(existsSync(biomeBin)).toBe(true);
130
+ expect(biome).toBe(process.execPath);
131
131
  expect(fixtures.length).toBeGreaterThan(0);
132
132
  });
133
133
 
package/linter.test.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { type BiomeReport, parseBiomeReport } from "./linter";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { type BiomeReport, Linter, parseBiomeReport } from "./linter";
5
+ import { hasBiome } from "./testHelpers";
3
6
 
4
7
  const reportOf = (diagnostics: BiomeReport["diagnostics"]): string =>
5
8
  JSON.stringify({ summary: { errors: diagnostics?.length ?? 0, warnings: 0 }, diagnostics });
@@ -77,3 +80,17 @@ describe("parseBiomeReport", () => {
77
80
  expect(() => parseBiomeReport(" \n")).toThrow("No Biome JSON output");
78
81
  });
79
82
  });
83
+
84
+ describe("Linter", () => {
85
+ test.skipIf(!hasBiome || process.platform === "win32")("runs Biome where no node is on PATH", async () => {
86
+ const pathBefore = process.env.PATH;
87
+ process.env.PATH = path.join(tmpdir(), "akan-path-without-node");
88
+ try {
89
+ const { results, errors } = await new Linter(import.meta.dir).lintFile(path.join(import.meta.dir, "linter.ts"));
90
+ expect(results).toHaveLength(1);
91
+ expect(errors).toEqual([]);
92
+ } finally {
93
+ process.env.PATH = pathBefore;
94
+ }
95
+ });
96
+ });
package/linter.ts CHANGED
@@ -113,18 +113,21 @@ const emptyResult = (filePath: string): LintResult => ({
113
113
  });
114
114
 
115
115
  export class Linter {
116
- //? Bun links a package bin as `<name>.exe` (plus a `.bunx` stub) on Windows, and a bare `<name>` elsewhere.
117
- static readonly biomeBinName = process.platform === "win32" ? "biome.exe" : "biome";
116
+ //? The package's bin is npm's `#!/usr/bin/env node` wrapper, which picks the platform binary; run through the Bun
117
+ //? running akan, it works in an image with no Node (exit 127 otherwise). A Biome on PATH is the fallback.
118
+ static biomeCommand(root: string): [string, ...string[]] {
119
+ const wrapper = path.join(root, "node_modules", "@biomejs", "biome", "bin", "biome");
120
+ return existsSync(wrapper) ? [process.execPath, wrapper] : ["biome"];
121
+ }
118
122
 
119
123
  lintRoot: string;
120
124
  configPath: string;
121
- #biomeBin: string;
125
+ #biomeCommand: [string, ...string[]];
122
126
 
123
127
  constructor(cwdPath: string) {
124
128
  this.lintRoot = this.#findBiomeRootPath(cwdPath);
125
129
  this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
126
- const localBiomeBin = path.join(this.lintRoot, "node_modules", ".bin", Linter.biomeBinName);
127
- this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
130
+ this.#biomeCommand = Linter.biomeCommand(this.lintRoot);
128
131
  }
129
132
 
130
133
  static #configPathIn(dir: string): string | null {
@@ -158,7 +161,8 @@ export class Linter {
158
161
  stderr: string;
159
162
  code: number | null;
160
163
  }>((resolve, reject) => {
161
- const proc = spawn(this.#biomeBin, args, {
164
+ const [command, ...prefixArgs] = this.#biomeCommand;
165
+ const proc = spawn(command, [...prefixArgs, ...args], {
162
166
  cwd: this.lintRoot,
163
167
  stdio: ["pipe", "pipe", "pipe"],
164
168
  });