@akanjs/devkit 3.1.0-rc.1 → 3.1.0-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 (138) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.test.ts +51 -5
  4. package/akanConfig/akanConfig.ts +33 -14
  5. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  6. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  7. package/akanContext.ts +4 -4
  8. package/akanMcpContract.ts +1 -1
  9. package/applicationBuildRunner.test.ts +57 -2
  10. package/applicationBuildRunner.ts +15 -6
  11. package/artifact/implicitRootLayout.test.ts +2 -0
  12. package/artifact/implicitRootLayout.ts +1 -0
  13. package/biome.base.json +1 -11
  14. package/biomeBase.ts +1 -1
  15. package/cloud/cloudApi.ts +22 -15
  16. package/cloud/cloudHttpError.test.ts +32 -0
  17. package/cloud/cloudHttpError.ts +54 -0
  18. package/cloud/cloudUpdates.ts +138 -0
  19. package/cloud/configLock.ts +2 -1
  20. package/cloud/globalConfig.test.ts +17 -0
  21. package/cloud/globalConfig.ts +9 -2
  22. package/cloud/index.ts +3 -0
  23. package/cloud/pipelineSecrets.ts +114 -0
  24. package/cloud/updatePackUpload.test.ts +100 -45
  25. package/cloud/updatePackUpload.ts +38 -18
  26. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  27. package/codeAgent/agent/akanCodeModel.ts +1 -1
  28. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  29. package/codeAgent/tools/WebToolPack.ts +4 -4
  30. package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
  31. package/commandDecorators/argMeta.ts +11 -2
  32. package/commandDecorators/command.ts +45 -10
  33. package/commandDecorators/commandBuilder.ts +6 -1
  34. package/commandDecorators/commandDecorators.test.ts +205 -4
  35. package/commandDecorators/exitCodeError.ts +15 -0
  36. package/commandDecorators/index.ts +1 -0
  37. package/createTunnel.test.ts +14 -0
  38. package/createTunnel.ts +3 -1
  39. package/dependencyScanner.ts +1 -1
  40. package/devkitUtils.test.ts +1 -1
  41. package/executors.test.ts +131 -3
  42. package/executors.ts +56 -36
  43. package/frontendBuild/autoImportSync.test.ts +3 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  45. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  46. package/frontendBuild/csrArtifactBuilder.ts +23 -4
  47. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  48. package/frontendBuild/cssBlockParser.test.ts +123 -0
  49. package/frontendBuild/cssBlockParser.ts +132 -0
  50. package/frontendBuild/cssCompiler.test.ts +53 -0
  51. package/frontendBuild/cssCompiler.ts +8 -4
  52. package/frontendBuild/fontOptimizer.ts +2 -1
  53. package/frontendBuild/frontendBuild.test.ts +14 -0
  54. package/frontendBuild/hmrWatcher.ts +1 -1
  55. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  56. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  57. package/frontendBuild/routeClientBuilder.ts +1 -1
  58. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  59. package/frontendBuild/themeValidator.test.ts +83 -2
  60. package/frontendBuild/themeValidator.ts +37 -15
  61. package/getRelatedCnsts.ts +2 -1
  62. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  63. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  65. package/integration/devStabilityHarness.ts +5 -3
  66. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  67. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  68. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  69. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  70. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  71. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  72. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  73. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  74. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  75. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  76. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  77. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  78. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  79. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  80. package/mobile/desktopServerStage.test.ts +13 -0
  81. package/mobile/desktopServerStage.ts +5 -7
  82. package/mobile/index.ts +2 -0
  83. package/mobile/mobileTarget.ts +1 -2
  84. package/mobile/nativeApi.test.ts +26 -0
  85. package/mobile/nativeApi.ts +5 -2
  86. package/mobile/nativeApp.test.ts +135 -33
  87. package/mobile/nativeApp.ts +109 -73
  88. package/mobile/nativeConfig.test.ts +3 -0
  89. package/mobile/nativeConfig.ts +5 -3
  90. package/mobile/nativePublicEnv.test.ts +131 -0
  91. package/mobile/nativePublicEnv.ts +116 -0
  92. package/mobile/nativeWebDir.ts +3 -2
  93. package/mobile/serverBundleSecrets.test.ts +79 -0
  94. package/mobile/serverBundleSecrets.ts +60 -16
  95. package/mobile/updatePackFolder.test.ts +98 -0
  96. package/mobile/updatePackFolder.ts +131 -0
  97. package/mobile/updateUploader.test.ts +115 -22
  98. package/mobile/updateUploader.ts +77 -10
  99. package/package.json +20 -20
  100. package/piDependencyPins.test.ts +18 -19
  101. package/pipeline/PipelineCompiler.test.ts +157 -1
  102. package/pipeline/PipelineCompiler.ts +80 -46
  103. package/pipeline/PipelineDefinition.test.ts +9 -0
  104. package/pipeline/PipelineDefinition.ts +4 -3
  105. package/pipeline/PipelineDryRun.test.ts +27 -0
  106. package/pipeline/PipelineDryRun.ts +2 -2
  107. package/pipeline/PipelineInputReader.ts +157 -253
  108. package/pipeline/PipelineRules.ts +92 -0
  109. package/pipeline/index.ts +1 -0
  110. package/platformTest/LinuxDockerTarget.ts +2 -1
  111. package/platformTest/PlatformTestTarget.ts +7 -5
  112. package/platformTest/WindowsSshTarget.ts +2 -1
  113. package/qualityScanner.test.ts +5 -3
  114. package/qualityScanner.ts +1 -1
  115. package/recipeScanner.test.ts +9 -3
  116. package/scanInfo.ts +1 -1
  117. package/slicePlanner.ts +5 -3
  118. package/subspace.test.ts +90 -19
  119. package/subspace.ts +52 -500
  120. package/subspaceClone.ts +362 -0
  121. package/subspaceConfig.ts +1 -2
  122. package/subspaceReport.ts +162 -0
  123. package/testHelpers.ts +2 -0
  124. package/transforms/barrelImportsPlugin.ts +1 -1
  125. package/transforms/browserDirnamePlugin.test.ts +88 -0
  126. package/transforms/browserDirnamePlugin.ts +25 -0
  127. package/transforms/rscUseClientTransform.ts +2 -2
  128. package/transforms/serverEnvPlugin.test.ts +76 -0
  129. package/transforms/serverEnvPlugin.ts +9 -25
  130. package/transforms/serverEnvProfile.test.ts +189 -0
  131. package/transforms/serverEnvProfile.ts +324 -60
  132. package/transforms/transforms.test.ts +18 -2
  133. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  134. package/typecheck/projectTypecheck.ts +6 -5
  135. package/types.ts +1 -0
  136. package/workflow/artifacts.ts +1 -1
  137. package/workflow/rolloutGate.test.ts +5 -1
  138. package/workflow/source.ts +2 -2
@@ -1,7 +1,21 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { AppExecutor, WorkspaceExecutor } from "../executors";
3
- import { App, type ArgMeta, type ArgsOption, getArgMetas, Lib, Module, Pkg, Sys, Workspace } from "./argMeta";
4
- import { getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { AppExecutor, LibExecutor, WorkspaceExecutor } from "../executors";
4
+ import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
5
+ import {
6
+ App,
7
+ type ArgMeta,
8
+ type ArgsOption,
9
+ getArgMetas,
10
+ type InternalArgMeta,
11
+ Lib,
12
+ Module,
13
+ Pkg,
14
+ Sys,
15
+ SysOrOnlyApp,
16
+ Workspace,
17
+ } from "./argMeta";
18
+ import { getAppsArgumentValue, getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
5
19
  import { command } from "./commandBuilder";
6
20
  import {
7
21
  assertUniqueDependencies,
@@ -289,6 +303,89 @@ describe("enum arg choices", () => {
289
303
  });
290
304
  });
291
305
 
306
+ describe("prompts on a stdin that is not a terminal", () => {
307
+ const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
308
+ beforeEach(() => {
309
+ Object.defineProperty(process.stdin, "isTTY", { value: undefined, configurable: true });
310
+ });
311
+ afterEach(() => {
312
+ if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
313
+ else Reflect.deleteProperty(process.stdin, "isTTY");
314
+ });
315
+ const metaOf = (type: "Argument" | "Option", name: string, argsOption: ArgsOption = {}): ArgMeta => ({
316
+ name,
317
+ argsOption: { type: "string", ...argsOption },
318
+ key: "run",
319
+ idx: 0,
320
+ type,
321
+ });
322
+ const context = { values: {} };
323
+ const workspaceWith = (appNames: string[]) => {
324
+ const workspace = new WorkspaceExecutor({ workspaceRoot: "/workspace", repoName: "repo" });
325
+ workspace.getExecs = async () => [appNames, ["util"], []];
326
+ workspace.getApps = async () => appNames;
327
+ return workspace;
328
+ };
329
+
330
+ test("a missing positional is refused by name instead of read from the pipe", async () => {
331
+ await expect(getArgumentValue(metaOf("Argument", "name", { desc: "secret name" }), undefined)).rejects.toThrow(
332
+ "Missing argument 'name': stdin is not a terminal to prompt on, so pass it on the command line.",
333
+ );
334
+ await expect(getArgumentValue(metaOf("Argument", "action", { enum: ["scan", "ssr"] }), undefined)).rejects.toThrow(
335
+ "Missing argument 'action': stdin is not a terminal to prompt on, so pass it on the command line (choices: scan, ssr).",
336
+ );
337
+ });
338
+
339
+ test("a missing option names its flag and the values it takes", async () => {
340
+ await expect(
341
+ getOptionValue(metaOf("Option", "platform", { enum: ["ios", "android"] }), {}, context),
342
+ ).rejects.toThrow(
343
+ "Missing option '--platform': stdin is not a terminal to prompt on, so pass it on the command line (choices: ios, android).",
344
+ );
345
+ await expect(
346
+ getOptionValue(
347
+ metaOf("Option", "format", { enum: [{ label: "JSON output", value: "json" }, "text"] }),
348
+ {},
349
+ context,
350
+ ),
351
+ ).rejects.toThrow("(choices: json, text)");
352
+ await expect(getOptionValue(metaOf("Option", "assembleType"), {}, context)).rejects.toThrow(
353
+ "Missing option '--assemble-type'",
354
+ );
355
+ await expect(getOptionValue(metaOf("Option", "force", { type: "boolean" }), {}, context)).rejects.toThrow(
356
+ "Missing option '--force'",
357
+ );
358
+ });
359
+
360
+ test("a value given, defaulted, nullable or the only choice still resolves without a prompt", async () => {
361
+ expect(await getArgumentValue(metaOf("Argument", "name"), "NPM_TOKEN")).toBe("NPM_TOKEN");
362
+ expect(await getOptionValue(metaOf("Option", "env", { default: "main" }), {}, context)).toBe("main");
363
+ expect(await getOptionValue(metaOf("Option", "host", { nullable: true }), {}, context)).toBe(null);
364
+ expect(await getOptionValue(metaOf("Option", "target", { enum: async () => ["default"] }), {}, context)).toBe(
365
+ "default",
366
+ );
367
+ const [app] = await getAppsArgumentValue(undefined, workspaceWith(["web"]));
368
+ expect(app?.name).toBe("web");
369
+ });
370
+
371
+ test("an app that is missing or unknown is refused with the workspace's apps", async () => {
372
+ const workspace = workspaceWith(["web", "admin"]);
373
+ await expect(getInternalArgumentValue({ key: "", idx: 0, type: "App" }, undefined, workspace)).rejects.toThrow(
374
+ "Missing app: stdin is not a terminal to prompt on, so pass it on the command line (choices: web, admin).",
375
+ );
376
+ await expect(getInternalArgumentValue({ key: "", idx: 0, type: "App" }, "globex", workspace)).rejects.toThrow(
377
+ "Unknown app 'globex'",
378
+ );
379
+ await expect(getInternalArgumentValue({ key: "", idx: 0, type: "Sys" }, "globex", workspace)).rejects.toThrow(
380
+ "Unknown app or lib 'globex': stdin is not a terminal to prompt on, so pass it on the command line (choices: web, admin, util).",
381
+ );
382
+ await expect(getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, undefined, workspace)).rejects.toThrow(
383
+ "Missing module",
384
+ );
385
+ await expect(getAppsArgumentValue(undefined, workspace)).rejects.toThrow("(choices: web, admin, all)");
386
+ });
387
+ });
388
+
292
389
  describe("enum arg type inference", () => {
293
390
  afterEach(() => {
294
391
  CommandContainer.clear();
@@ -334,3 +431,107 @@ describe("enum arg type inference", () => {
334
431
  ]);
335
432
  });
336
433
  });
434
+
435
+ describe("a Sys that defaults to the only app", () => {
436
+ const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
437
+ const asked: unknown[] = [];
438
+ let answer = "";
439
+ const setTTY = (isTTY: boolean | undefined) =>
440
+ Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true });
441
+ beforeEach(() => {
442
+ asked.length = 0;
443
+ mock.module("@inquirer/prompts", () => ({
444
+ select: async ({ choices }: { choices: unknown[] }) => {
445
+ asked.push(choices);
446
+ return answer;
447
+ },
448
+ }));
449
+ });
450
+ afterEach(() => {
451
+ CommandContainer.clear();
452
+ if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
453
+ else Reflect.deleteProperty(process.stdin, "isTTY");
454
+ });
455
+ const workspaceWith = (appNames: string[]) => {
456
+ const workspace = new WorkspaceExecutor({ workspaceRoot: "/workspace", repoName: "repo" });
457
+ workspace.getExecs = async () => [appNames, ["util"], []];
458
+ return workspace;
459
+ };
460
+ const sysMetaOf = (key: "typecheck" | "sync") => {
461
+ class OnlyAppCommand extends command("onlyApp", ({ public: target }) => ({
462
+ typecheck: target({ desc: "opted in" })
463
+ .with(SysOrOnlyApp)
464
+ .exec((sys) => sys),
465
+ sync: target({ desc: "plain" })
466
+ .with(Sys)
467
+ .exec((sys) => sys),
468
+ })) {}
469
+ const meta = getArgMetas(OnlyAppCommand, key)[0].find((arg): arg is InternalArgMeta => arg.type === "Sys");
470
+ if (!meta) throw new Error(`no Sys meta on ${key}`);
471
+ return meta;
472
+ };
473
+
474
+ test("the opted-in command takes a one-app workspace's app without asking, even on a piped stdin", async () => {
475
+ setTTY(undefined);
476
+ const meta = sysMetaOf("typecheck");
477
+ expect(meta.option).toEqual({ onlyApp: true });
478
+
479
+ const app = await getInternalArgumentValue(meta, undefined, workspaceWith(["web"]));
480
+ expect(app instanceof AppExecutor ? app.name : null).toBe("web");
481
+ expect(asked).toEqual([]);
482
+ });
483
+
484
+ test("a plain Sys command still asks app or library in a one-app workspace", async () => {
485
+ setTTY(true);
486
+ answer = "util";
487
+ const meta = sysMetaOf("sync");
488
+ expect(meta.option).toBeUndefined();
489
+
490
+ const lib = await getInternalArgumentValue(meta, undefined, workspaceWith(["web"]));
491
+ expect(lib instanceof LibExecutor ? lib.name : null).toBe("util");
492
+ expect(asked).toEqual([["web", "util"]]);
493
+ });
494
+
495
+ test("the opted-in command still asks when the workspace has two apps", async () => {
496
+ setTTY(true);
497
+ answer = "admin";
498
+ const app = await getInternalArgumentValue(sysMetaOf("typecheck"), undefined, workspaceWith(["web", "admin"]));
499
+ expect(app instanceof AppExecutor ? app.name : null).toBe("admin");
500
+ expect(asked).toEqual([["web", "admin", "util"]]);
501
+ });
502
+ });
503
+
504
+ describe("the exit code of a command that failed", () => {
505
+ const track = tempRoots();
506
+ const run = async (failure: string) => {
507
+ const { root } = track(await makeCliTempWorkspace());
508
+ await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
509
+ await writeText(path.join(root, "tsconfig.json"), "{}\n");
510
+ const entry = path.join(root, "cli.ts");
511
+ await writeText(
512
+ entry,
513
+ [
514
+ `import { command, ExitCodeError, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
515
+ 'class VerdictRunner extends runner("verdict") {}',
516
+ 'class VerdictScript extends script("verdict", [VerdictRunner]) {}',
517
+ 'class VerdictCommand extends command("verdict", [VerdictScript], ({ public: target }) => ({',
518
+ ' verdict: target({ desc: "fails" }).exec(async function () {',
519
+ ` throw ${failure};`,
520
+ " }),",
521
+ "})) {}",
522
+ "await runCommands(VerdictCommand);",
523
+ "",
524
+ ].join("\n"),
525
+ );
526
+ const proc = Bun.spawn(["bun", entry, "verdict"], { cwd: root, stdout: "pipe", stderr: "pipe" });
527
+ const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
528
+ return { stdout, code };
529
+ };
530
+
531
+ test("is the one an ExitCodeError carries, and 1 for any other error", async () => {
532
+ const verdict = await run('new ExitCodeError("needs a new store build", 2)');
533
+ expect(verdict.stdout).toContain("needs a new store build");
534
+ expect(verdict.code).toBe(2);
535
+ expect((await run('new Error("broke")')).code).toBe(1);
536
+ }, 30_000);
537
+ });
@@ -0,0 +1,15 @@
1
+ //* A failure that is an answer, not a breakage: the CLI exits with its code, so a CI tells the two apart.
2
+ export class ExitCodeError extends Error {
3
+ override readonly name = "ExitCodeError";
4
+
5
+ constructor(
6
+ message: string,
7
+ readonly exitCode: number,
8
+ ) {
9
+ super(message);
10
+ }
11
+
12
+ static codeOf(error: unknown): number | null {
13
+ return error instanceof ExitCodeError ? error.exitCode : null;
14
+ }
15
+ }
@@ -2,5 +2,6 @@ export * from "./argMeta";
2
2
  export * from "./command";
3
3
  export * from "./commandBuilder";
4
4
  export * from "./dependencyBuilder";
5
+ export * from "./exitCodeError";
5
6
  export * from "./helpFormatter";
6
7
  export * from "./targetMeta";
@@ -48,4 +48,18 @@ describe("createTunnel", () => {
48
48
  expect(failure.message).toContain(reason.message);
49
49
  }
50
50
  });
51
+
52
+ test("refuses to dial a cluster host built on the local default serve domain", async () => {
53
+ const { app } = track(await createTempApp("demo"));
54
+ delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
55
+ const connect = spyOn(Client.prototype, "connect");
56
+ try {
57
+ await expect(createTunnel("redis", { app, environment: "debug" })).rejects.toThrow(
58
+ 'The redis tunnel for the "debug" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
59
+ );
60
+ expect(connect).not.toHaveBeenCalled();
61
+ } finally {
62
+ connect.mockRestore();
63
+ }
64
+ });
51
65
  });
package/createTunnel.ts CHANGED
@@ -10,7 +10,9 @@ export const createTunnel = async (
10
10
  service: "redis" | "postgres",
11
11
  { app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
12
12
  ) => {
13
- const { serveDomain, repoName } = WorkspaceExecutor.getBaseDevEnv();
13
+ const baseDevEnv = WorkspaceExecutor.getBaseDevEnv();
14
+ WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, `The ${service} tunnel`);
15
+ const { serveDomain, repoName } = baseDevEnv;
14
16
  const host = `${app.name}-${environment}.${serveDomain}`;
15
17
  const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
16
18
  const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
@@ -107,7 +107,7 @@ export class TypeScriptDependencyScanner {
107
107
  return {
108
108
  npmDeps: [...runtimeDeps].sort(),
109
109
  npmDevDeps: [...devDeps].sort(),
110
- missingDeps: missingDeps.sort(),
110
+ missingDeps: missingDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
111
111
  };
112
112
  }
113
113
 
@@ -76,7 +76,7 @@ describe("TypeScriptDependencyScanner", () => {
76
76
  });
77
77
  expect(deps.pkgDeps).toEqual(["akanjs/tool"]);
78
78
  expect(deps.libDeps).toEqual(["shared"]);
79
- expect(deps.npmDeps.sort()).toEqual(["lodash", "react"]);
79
+ expect(deps.npmDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(["lodash", "react"]);
80
80
  expect(deps.npmDevDeps).toEqual(["typescript"]);
81
81
 
82
82
  const graph = scanner.generateDependencyGraph();
package/executors.test.ts CHANGED
@@ -99,6 +99,29 @@ describe("Executor filesystem helpers", () => {
99
99
  expect(entries.dirs).toContain("nested");
100
100
  });
101
101
 
102
+ test("lists a folder in name order, whatever order the filesystem keeps it in", async () => {
103
+ const root = await makeTempRoot();
104
+ const exec = new Executor("fixture", root);
105
+ for (const name of ["summary", "file", "banner", "_noti", "admin"]) await exec.mkdir(`lib/${name}`);
106
+ for (const name of ["st.ts", "cnst.ts", "dict.ts"]) await exec.writeFile(`lib/${name}`, "");
107
+
108
+ expect(await exec.readdir("lib")).toEqual([
109
+ "_noti",
110
+ "admin",
111
+ "banner",
112
+ "cnst.ts",
113
+ "dict.ts",
114
+ "file",
115
+ "st.ts",
116
+ "summary",
117
+ ]);
118
+ expect(await exec.getFilesAndDirs("lib")).toEqual({
119
+ files: ["cnst.ts", "dict.ts", "st.ts"],
120
+ dirs: ["_noti", "admin", "banner", "file", "summary"],
121
+ });
122
+ expect(await exec.getAllFiles("**/*", { cwd: exec.getPath("lib") })).toEqual(["cnst.ts", "dict.ts", "st.ts"]);
123
+ });
124
+
102
125
  test("a refreshed tsconfig re-reads the config it extends", async () => {
103
126
  const root = await makeTempRoot();
104
127
  const appDir = path.join(root, "apps/demo");
@@ -188,6 +211,35 @@ describe("Executor filesystem helpers", () => {
188
211
  expect(formatted).toEqual([path.join(root, "apps/demo/page/task/_index.tsx")]);
189
212
  });
190
213
 
214
+ test("hands the formatter only the files a template wrote, never one overwrite: false kept", async () => {
215
+ const { root, app } = trackRoot(await createTempApp("demo"));
216
+ const formatted: string[] = [];
217
+ app.getLinter = () =>
218
+ ({
219
+ fixFiles: async (filePaths: string[]) => {
220
+ formatted.push(...filePaths);
221
+ return { fixed: [] };
222
+ },
223
+ }) as unknown as ReturnType<Executor["getLinter"]>;
224
+ const scalarDir = path.join(root, "apps/demo/lib/__scalar/money");
225
+ const own = "export class Money {}\n";
226
+ await writeText(path.join(scalarDir, "money.constant.ts"), own);
227
+
228
+ const applied = await app.applyTemplate({
229
+ basePath: "./lib/__scalar",
230
+ template: "__scalar",
231
+ dict: { model: "money", models: "moneys", sysName: "demo" },
232
+ overwrite: false,
233
+ });
234
+
235
+ expect(applied.map((fileContent) => fileContent.filePath)).toContain(path.join(scalarDir, "money.constant.ts"));
236
+ expect(formatted.sort((a, b) => a.localeCompare(b))).toEqual([
237
+ path.join(scalarDir, "money.dictionary.ts"),
238
+ path.join(scalarDir, "money.document.ts"),
239
+ ]);
240
+ expect(await readFile(path.join(scalarDir, "money.constant.ts"), "utf8")).toBe(own);
241
+ });
242
+
191
243
  test("a formatter that cannot run does not fail the scaffold", async () => {
192
244
  // create-akan-workspace scaffolds before `bun install`, so Biome may be absent; a lint fix beats a failed scaffold.
193
245
  const root = await makeTempRoot();
@@ -263,10 +315,27 @@ describe("Executor filesystem helpers", () => {
263
315
  await readFile(path.join(templateRoot, "favicon.ico")),
264
316
  );
265
317
  });
318
+
319
+ test("keeps a static file already there when overwrite is false, and reports it unwritten", async () => {
320
+ const root = await makeTempRoot();
321
+ const exec = new Executor("fixture", root);
322
+ await writeText(path.join(root, "app/public/logo.png"), "the app's own logo");
323
+
324
+ const applied = await exec.applyTemplate({
325
+ basePath: "app/public",
326
+ template: "app/public/logo.png",
327
+ overwrite: false,
328
+ });
329
+
330
+ expect(await readFile(path.join(root, "app/public/logo.png"), "utf8")).toBe("the app's own logo");
331
+ expect(applied).toEqual([
332
+ expect.objectContaining({ filePath: path.join(root, "app/public/logo.png"), written: false }),
333
+ ]);
334
+ });
266
335
  });
267
336
 
268
337
  describe("Workspace and app executor environment contracts", () => {
269
- test("reads base development environment and reports required missing values", () => {
338
+ test("reads base development environment", () => {
270
339
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
271
340
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
272
341
  process.env.AKAN_PUBLIC_ENV = "local";
@@ -282,10 +351,53 @@ describe("Workspace and app executor environment contracts", () => {
282
351
  env: "local",
283
352
  portOffset: 10,
284
353
  workspaceId: undefined,
354
+ defaulted: [],
285
355
  });
356
+ });
286
357
 
287
- delete process.env.AKAN_PUBLIC_REPO_NAME;
288
- expect(() => WorkspaceExecutor.getBaseDevEnv()).toThrow("AKAN_PUBLIC_REPO_NAME is not set");
358
+ test("unset, the repo name is the origin remote's and the serve domain localhost, both marked defaulted", async () => {
359
+ const root = await makeTempRoot();
360
+ for (const args of [
361
+ ["init", "-q"],
362
+ ["remote", "add", "origin", "git@github.com:akan-team/fixture-repo.git"],
363
+ ])
364
+ expect(Bun.spawnSync(["git", ...args], { cwd: root }).exitCode).toBe(0);
365
+ for (const key of ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_ENV", "AKAN_WORKSPACE_ROOT"])
366
+ delete process.env[key];
367
+
368
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
369
+ repoName: "fixture-repo",
370
+ serveDomain: "localhost",
371
+ env: "debug",
372
+ defaulted: ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"],
373
+ });
374
+
375
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
376
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
377
+ repoName: "fixture-repo",
378
+ serveDomain: "example.com",
379
+ defaulted: ["AKAN_PUBLIC_REPO_NAME"],
380
+ });
381
+ });
382
+
383
+ test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
384
+ const baseDevEnv = {
385
+ repoName: "fixture-repo",
386
+ serveDomain: "localhost",
387
+ env: "debug" as const,
388
+ portOffset: 0,
389
+ workspaceRoot: undefined,
390
+ };
391
+ const defaulted = { ...baseDevEnv, defaulted: ["AKAN_PUBLIC_SERVE_DOMAIN" as const] };
392
+
393
+ expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "main", "akan build")).toThrow(
394
+ 'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
395
+ );
396
+ expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "local", "akan build")).not.toThrow();
397
+ expect(() =>
398
+ WorkspaceExecutor.assertDeployableEnv({ ...baseDevEnv, defaulted: [] }, "main", "akan build"),
399
+ ).not.toThrow();
400
+ expect(() => WorkspaceExecutor.assertDeployableEnv(baseDevEnv, "main", "akan build")).not.toThrow();
289
401
  });
290
402
 
291
403
  test("reads base development environment from an explicit env file", async () => {
@@ -313,6 +425,7 @@ describe("Workspace and app executor environment contracts", () => {
313
425
  env: "develop",
314
426
  portOffset: 7,
315
427
  workspaceId: undefined,
428
+ defaulted: [],
316
429
  });
317
430
  });
318
431
 
@@ -371,6 +484,21 @@ describe("Workspace and app executor environment contracts", () => {
371
484
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
372
485
  });
373
486
 
487
+ test("the app command env hands the dev server the local defaults that stood in for unset values", async () => {
488
+ const root = await makeTempRoot();
489
+ delete process.env.AKAN_PUBLIC_REPO_NAME;
490
+ delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
491
+ process.env.AKAN_WORKSPACE_ROOT = root;
492
+ const app = AppExecutor.from(new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }), "defaults");
493
+
494
+ const env = app.getCommandEnv();
495
+ expect(env.AKAN_PUBLIC_SERVE_DOMAIN).toBe("localhost");
496
+ expect(env.AKAN_PUBLIC_REPO_NAME).toBe(WorkspaceExecutor.getBaseDevEnv().repoName);
497
+
498
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
499
+ expect(app.getCommandEnv().AKAN_PUBLIC_SERVE_DOMAIN).toBe("example.com");
500
+ });
501
+
374
502
  test("akan start clears the dev output and keeps the bin downloads and a native folder from before dist/native", async () => {
375
503
  const root = await makeTempRoot();
376
504
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";