@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1

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 (89) hide show
  1. package/CHANGELOG.md +353 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +1 -1
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +25 -3
  9. package/applicationBuildReporter.ts +8 -1
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +76 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +16 -7
  22. package/codeAgent/skills/akan-service/SKILL.md +48 -19
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/createTunnel.test.ts +12 -2
  26. package/declarationReader.ts +211 -0
  27. package/executors.test.ts +65 -8
  28. package/executors.ts +5 -3
  29. package/frameworkGraph.test.ts +121 -0
  30. package/frameworkGraph.ts +165 -0
  31. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  32. package/frontendBuild/allRoutesBuilder.ts +32 -12
  33. package/frontendBuild/buildRouteClient.test.ts +192 -2
  34. package/frontendBuild/bundleDefine.ts +4 -0
  35. package/frontendBuild/clientBuildTypes.ts +7 -1
  36. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  37. package/frontendBuild/clientEntriesBundler.ts +55 -4
  38. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  39. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  40. package/frontendBuild/fontOptimizer.test.ts +54 -0
  41. package/frontendBuild/fontOptimizer.ts +5 -4
  42. package/frontendBuild/index.ts +3 -0
  43. package/frontendBuild/routeClientBuilder.ts +6 -2
  44. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  45. package/frontendBuild/routeModulePreloads.ts +52 -0
  46. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  47. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  48. package/frontendBuild/ssrDevBundler.ts +2 -1
  49. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  50. package/frontendBuild/vendorImportScanner.ts +80 -0
  51. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  52. package/frontendBuild/vendorNarrowing.ts +64 -0
  53. package/frontendBuild/vendorSpecifiers.ts +13 -0
  54. package/gitRevision.ts +6 -2
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/no-deep-internal-import.grit +1 -1
  61. package/maintainabilityBaseline.ts +7 -2
  62. package/maintainabilityContract.test.ts +183 -0
  63. package/maintainabilityContract.ts +145 -30
  64. package/maintainabilityExceptionScanner.test.ts +13 -2
  65. package/maintainabilityExceptionScanner.ts +16 -6
  66. package/maintainabilityLimits.ts +12 -0
  67. package/maintainabilityReport.test.ts +35 -0
  68. package/maintainabilityReport.ts +29 -8
  69. package/mobile/nativePublicEnv.test.ts +5 -1
  70. package/package.json +3 -3
  71. package/preview/PreviewChrome.ts +3 -3
  72. package/preview/PreviewView.ts +26 -8
  73. package/preview/previewView.test.ts +87 -3
  74. package/routeJsBudget.test.ts +177 -0
  75. package/routeJsBudget.ts +222 -0
  76. package/structureScanner.test.ts +167 -0
  77. package/structureScanner.ts +164 -0
  78. package/transforms/barrelAnalyzer.ts +10 -3
  79. package/transforms/barrelImportsPlugin.ts +13 -7
  80. package/transforms/serverEnvPlugin.test.ts +1 -1
  81. package/transforms/serverEnvPlugin.ts +1 -1
  82. package/transforms/transforms.test.ts +64 -12
  83. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  84. package/workflow/plan.ts +79 -17
  85. package/workflow/types.ts +1 -0
  86. package/workspaceGraph.test.ts +58 -0
  87. package/workspaceGraph.ts +32 -3
  88. package/workspaceLayout.ts +1 -0
  89. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -229,6 +229,28 @@ const assertCurrentDirectoryIsWorkspaceRoot = async () => {
229
229
  );
230
230
  };
231
231
 
232
+ export type RerunOf = (target: string) => string;
233
+
234
+ const shellWordOf = (word: string) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`);
235
+
236
+ //? Commander fills operands left to right, so every operand before an empty slot was given.
237
+ export const rerunCommandOf =
238
+ (argv: readonly string[], operands: readonly unknown[], slot: number): RerunOf =>
239
+ (target) => {
240
+ const [commandName = "", ...words] = argv;
241
+ let at = 0;
242
+ for (const operand of operands.slice(0, slot)) if (typeof operand === "string") at = words.indexOf(operand, at) + 1;
243
+ const given = operands[slot];
244
+ const givenAt = typeof given === "string" ? words.indexOf(given, at) : -1;
245
+ const rerun = words.map(shellWordOf);
246
+ if (givenAt >= 0) rerun.splice(givenAt, 1, target);
247
+ else rerun.splice(at, 0, target);
248
+ return ["akan", commandName, ...rerun].join(" ");
249
+ };
250
+
251
+ const rerunRemedyOf = (rerun: RerunOf | undefined, placeholder: string) =>
252
+ rerun ? { remedy: `rerun it as \`${rerun(placeholder)}\`` } : {};
253
+
232
254
  const parseAppNameList = (value: string | string[] | undefined): string[] =>
233
255
  (Array.isArray(value) ? value : value === undefined ? [] : [value])
234
256
  .flatMap((entry) => entry.split(","))
@@ -239,6 +261,7 @@ const parseAppNameList = (value: string | string[] | undefined): string[] =>
239
261
  export const getAppsArgumentValue = async (
240
262
  value: string | string[] | undefined,
241
263
  workspace: WorkspaceExecutor,
264
+ rerun?: RerunOf,
242
265
  ): Promise<AppExecutor[]> => {
243
266
  const appNames = await workspace.getApps();
244
267
  if (appNames.length === 0) throw new Error("No apps found in this workspace (apps/<appName>/akan.config.ts)");
@@ -253,7 +276,7 @@ export const getAppsArgumentValue = async (
253
276
  return [...new Set(requested)].map((name) => AppExecutor.from(workspace, name));
254
277
  }
255
278
  if (appNames.length === 1 && appNames[0]) return [AppExecutor.from(workspace, appNames[0])];
256
- assertPromptable("Missing apps", { choices: [...appNames, "all"] });
279
+ assertPromptable("Missing apps", { choices: [...appNames, "all"], ...rerunRemedyOf(rerun, "<apps...>") });
257
280
  const remembered = new Set(await AppSelectionMemory.read(workspace.workspaceRoot));
258
281
  const picked = await checkbox<string>({
259
282
  message: "Select the apps to run (space to toggle, enter to confirm)",
@@ -269,6 +292,7 @@ export const getInternalArgumentValue = async (
269
292
  argMeta: InternalArgMeta,
270
293
  value: string | undefined,
271
294
  workspace: WorkspaceExecutor,
295
+ rerun?: RerunOf,
272
296
  ) => {
273
297
  if (argMeta.type === "Workspace") return workspace;
274
298
  const sysType = argMeta.type.toLowerCase();
@@ -286,12 +310,24 @@ export const getInternalArgumentValue = async (
286
310
  if (found) return found;
287
311
  if (!value && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
288
312
  return AppExecutor.from(workspace, appNames[0]);
289
- assertPromptable(problemOf(sysType === "exec" ? "app, lib or pkg" : "app or lib"), {
290
- choices: [...appNames, ...libNames, ...pkgChoices],
291
- });
313
+ const [kind, placeholder] =
314
+ sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
315
+ const choices = [...appNames, ...libNames, ...pkgChoices];
316
+ const only = choices.length === 1 ? execOf(choices[0] ?? "") : null;
317
+ //? Not for Lib, Pkg or Module, which name what a command acts on: `akan remove-library` would delete the only one.
318
+ if (!value && only && !process.stdin.isTTY) {
319
+ const how = rerun ? `: ${rerun(only.name)}` : "";
320
+ Logger.rawLog(
321
+ `No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
322
+ undefined,
323
+ "error",
324
+ );
325
+ return only;
326
+ }
327
+ assertPromptable(problemOf(kind), { choices, ...rerunRemedyOf(rerun, placeholder) });
292
328
  const name = await select<string>({
293
329
  message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
294
- choices: [...appNames, ...libNames, ...pkgChoices],
330
+ choices,
295
331
  });
296
332
  const picked = execOf(name);
297
333
  if (!picked) throw new Error(`Invalid system name: ${name}`);
@@ -299,18 +335,18 @@ export const getInternalArgumentValue = async (
299
335
  } else if (sysType === "app") {
300
336
  if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
301
337
  if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
302
- assertPromptable(problemOf("app"), { choices: appNames });
338
+ assertPromptable(problemOf("app"), { choices: appNames, ...rerunRemedyOf(rerun, "<app>") });
303
339
  const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
304
340
  return AppExecutor.from(workspace, appName);
305
341
  } else if (sysType === "lib") {
306
342
  if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
307
- assertPromptable(problemOf("lib"), { choices: libNames });
343
+ assertPromptable(problemOf("lib"), { choices: libNames, ...rerunRemedyOf(rerun, "<lib>") });
308
344
  const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
309
345
  return LibExecutor.from(workspace, libName);
310
346
  } else if (sysType === "pkg") {
311
347
  const pkgs = await workspace.getPkgs();
312
348
  if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
313
- assertPromptable(problemOf("pkg"), { choices: pkgs });
349
+ assertPromptable(problemOf("pkg"), { choices: pkgs, ...rerunRemedyOf(rerun, "<pkg>") });
314
350
  const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
315
351
  return PkgExecutor.from(workspace, pkgName);
316
352
  } else if (sysType === "module") {
@@ -328,7 +364,14 @@ export const getInternalArgumentValue = async (
328
364
  if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
329
365
  } else throw new Error(`Invalid system name: ${sysName}`);
330
366
  }
331
- assertPromptable(problemOf("module"));
367
+ const systems = [
368
+ ...appNames.map((name) => AppExecutor.from(workspace, name)),
369
+ ...libNames.map((name) => LibExecutor.from(workspace, name)),
370
+ ];
371
+ const choices = process.stdin.isTTY
372
+ ? []
373
+ : (await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))).flat();
374
+ assertPromptable(problemOf("module"), { choices, ...rerunRemedyOf(rerun, "<sys:module>") });
332
375
  const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
333
376
  message: `select the App or Lib name`,
334
377
  choices: [
@@ -443,6 +486,7 @@ export const runCommands = async (...commands: CommandCls[]) => {
443
486
  const workspace = WorkspaceExecutor.fromRoot();
444
487
  const commandContext: CommandContext = { values: {} };
445
488
  for (const argMeta of allArgMetas) {
489
+ const rerun = rerunCommandOf(process.argv.slice(2), cmdArgs, argMeta.idx);
446
490
  if (argMeta.type === "Option")
447
491
  commandArgs[argMeta.idx] = await getOptionValue(argMeta, opt, commandContext);
448
492
  else if (argMeta.type === "Argument")
@@ -451,12 +495,14 @@ export const runCommands = async (...commands: CommandCls[]) => {
451
495
  commandArgs[argMeta.idx] = await getAppsArgumentValue(
452
496
  cmdArgs[argMeta.idx] as string | string[] | undefined,
453
497
  workspace,
498
+ rerun,
454
499
  );
455
500
  else
456
501
  commandArgs[argMeta.idx] = await getInternalArgumentValue(
457
502
  argMeta as InternalArgMeta,
458
503
  cmdArgs[argMeta.idx] as string,
459
504
  workspace,
505
+ rerun,
460
506
  );
461
507
  if (commandArgs[argMeta.idx] instanceof AppExecutor)
462
508
  process.env.AKAN_PUBLIC_APP_NAME = (commandArgs[argMeta.idx] as AppExecutor).name;
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import path from "node:path";
3
- import { AppExecutor, LibExecutor, WorkspaceExecutor } from "../executors";
3
+ import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
4
4
  import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
5
5
  import {
6
6
  App,
@@ -15,7 +15,13 @@ import {
15
15
  SysOrOnlyApp,
16
16
  Workspace,
17
17
  } from "./argMeta";
18
- import { getAppsArgumentValue, getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
18
+ import {
19
+ getAppsArgumentValue,
20
+ getArgumentValue,
21
+ getInternalArgumentValue,
22
+ getOptionValue,
23
+ rerunCommandOf,
24
+ } from "./command";
19
25
  import { command } from "./commandBuilder";
20
26
  import {
21
27
  assertUniqueDependencies,
@@ -499,6 +505,129 @@ describe("a Sys that defaults to the only app", () => {
499
505
  expect(app instanceof AppExecutor ? app.name : null).toBe("admin");
500
506
  expect(asked).toEqual([["web", "admin", "util"]]);
501
507
  });
508
+
509
+ test("a terminal is still asked when the workspace holds a single app or lib", async () => {
510
+ setTTY(true);
511
+ answer = "web";
512
+ const workspace = workspaceWith([]);
513
+ workspace.getExecs = async () => [["web"], [], []];
514
+ const app = await getInternalArgumentValue(sysMetaOf("sync"), undefined, workspace);
515
+ expect(app instanceof AppExecutor ? app.name : null).toBe("web");
516
+ expect(asked).toEqual([["web"]]);
517
+ });
518
+
519
+ test("without a terminal, an Exec takes the workspace's only app, lib or pkg", async () => {
520
+ setTTY(undefined);
521
+ const workspace = workspaceWith([]);
522
+ workspace.getExecs = async () => [[], [], ["tools"]];
523
+ const pkg = await getInternalArgumentValue({ key: "", idx: 0, type: "Exec" }, undefined, workspace);
524
+ expect(pkg instanceof PkgExecutor ? pkg.name : null).toBe("tools");
525
+ expect(asked).toEqual([]);
526
+ });
527
+
528
+ test("without a terminal, the only lib, pkg or module is not taken, as a command may be removing it", async () => {
529
+ setTTY(undefined);
530
+ const workspace = workspaceWith([]);
531
+ workspace.getExecs = async () => [[], ["util"], []];
532
+ workspace.getPkgs = async () => ["tools"];
533
+ const rerunOf = (name: string) => rerunCommandOf([name], [undefined], 0);
534
+ await expect(
535
+ getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace, rerunOf("remove-library")),
536
+ ).rejects.toThrow("so rerun it as `akan remove-library <lib>` (choices: util).");
537
+ await expect(
538
+ getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace, rerunOf("remove-package")),
539
+ ).rejects.toThrow("so rerun it as `akan remove-package <pkg>` (choices: tools).");
540
+ expect(asked).toEqual([]);
541
+ });
542
+
543
+ test("without a terminal, a named target the workspace lacks is refused, not swapped for the only one", async () => {
544
+ setTTY(undefined);
545
+ const workspace = workspaceWith([]);
546
+ workspace.getExecs = async () => [["web"], [], []];
547
+ await expect(
548
+ getInternalArgumentValue(sysMetaOf("sync"), "webb", workspace, rerunCommandOf(["sync", "webb"], ["webb"], 0)),
549
+ ).rejects.toThrow(
550
+ "Unknown app or lib 'webb': stdin is not a terminal to prompt on, so rerun it as `akan sync <app-or-lib>` (choices: web).",
551
+ );
552
+ expect(asked).toEqual([]);
553
+ });
554
+ });
555
+
556
+ describe("the line a refused target is rerun with", () => {
557
+ test("puts the target in its operand slot, after the operands before it, keeping every option", () => {
558
+ expect(rerunCommandOf(["test", "--write", "false"], [undefined, undefined], 0)("<t>")).toBe(
559
+ "akan test <t> --write false",
560
+ );
561
+ expect(rerunCommandOf(["create-module", "--page", "true", "photo"], ["photo", undefined], 1)("web")).toBe(
562
+ "akan create-module --page true photo web",
563
+ );
564
+ expect(rerunCommandOf(["build", "--env", "it's main"], [undefined], 0)("web")).toBe(
565
+ "akan build web --env 'it'\\''s main'",
566
+ );
567
+ });
568
+
569
+ test("takes the place of a target that was given but is not in the workspace", () => {
570
+ expect(rerunCommandOf(["sync", "nope"], ["nope"], 0)("<app-or-lib>")).toBe("akan sync <app-or-lib>");
571
+ });
572
+ });
573
+
574
+ describe("a target left out where no terminal can ask for it", () => {
575
+ const track = tempRoots();
576
+ const run = async (apps: string[]) => {
577
+ const { root } = track(await makeCliTempWorkspace());
578
+ await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
579
+ await writeText(path.join(root, "tsconfig.json"), "{}\n");
580
+ for (const app of apps) await writeText(path.join(root, "apps", app, "akan.config.ts"), "export default {};\n");
581
+ const entry = path.join(root, "cli.ts");
582
+ await writeText(
583
+ entry,
584
+ [
585
+ `import { command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
586
+ 'class ThingCommand extends command("thing", ({ public: target }) => ({',
587
+ ' createThing: target({ desc: "creates a thing" })',
588
+ ' .arg("thing", String, { desc: "name of the thing" })',
589
+ " .with(Sys)",
590
+ ' .option("force", Boolean, { default: false })',
591
+ " .exec(async function (thing, sys, force) {",
592
+ ' process.stdout.write(thing + " in " + sys.name + ", force " + force + "\\n");',
593
+ " }),",
594
+ "})) {}",
595
+ "await runCommands(ThingCommand);",
596
+ "",
597
+ ].join("\n"),
598
+ );
599
+ const proc = Bun.spawn(["bun", entry, "create-thing", "photo", "--force"], {
600
+ cwd: root,
601
+ stdin: "ignore",
602
+ stdout: "pipe",
603
+ stderr: "pipe",
604
+ });
605
+ const [stdout, stderr, code] = await Promise.all([
606
+ new Response(proc.stdout).text(),
607
+ new Response(proc.stderr).text(),
608
+ proc.exited,
609
+ ]);
610
+ return { stdout, stderr, code };
611
+ };
612
+
613
+ test("runs on the workspace's only app or lib, naming it on stderr", async () => {
614
+ const ran = await run(["web"]);
615
+ expect(ran.code).toBe(0);
616
+ expect(ran.stdout).toContain("photo in web, force true\n");
617
+ expect(ran.stderr).toContain(
618
+ "No app or lib named, so this runs on 'web', the only one in this workspace: akan create-thing photo web --force",
619
+ );
620
+ }, 30_000);
621
+
622
+ test("exits at once with the choices and the line to rerun when there are several", async () => {
623
+ const refused = await run(["web", "admin"]);
624
+ expect(refused.code).toBe(1);
625
+ expect(refused.stdout).toContain(
626
+ "Missing app or lib: stdin is not a terminal to prompt on, so rerun it as `akan create-thing photo <app-or-lib> --force`",
627
+ );
628
+ expect(refused.stdout).toMatch(/\(choices: (web, admin|admin, web)\)\./);
629
+ expect(refused.stdout).not.toContain("photo in");
630
+ }, 30_000);
502
631
  });
503
632
 
504
633
  describe("the exit code of a command that failed", () => {
@@ -20,10 +20,20 @@ const publicBlobOf = (publicKey: string) => {
20
20
  return parsed.getPublicSSH();
21
21
  };
22
22
 
23
+ //? ssh2 1.17's keygen strips a leading zero byte off an ed25519 public key, so 1 pair in 256 cannot be read back.
24
+ const ed25519Pair = () => {
25
+ for (let tries = 1; ; tries++) {
26
+ const pair = utils.generateKeyPairSync("ed25519");
27
+ const parsed = utils.parseKey(pair.private);
28
+ if (!(parsed instanceof Error)) return pair;
29
+ if (tries === 8) throw parsed;
30
+ }
31
+ };
32
+
23
33
  const sshServers: { server: Server; clients: Set<Connection> }[] = [];
24
34
 
25
35
  const startSshServer = async () => {
26
- const hostKey = utils.generateKeyPairSync("ed25519");
36
+ const hostKey = ed25519Pair();
27
37
  const methods: string[] = [];
28
38
  const clients = new Set<Connection>();
29
39
  const server = new Server({ hostKeys: [hostKey.private] }, (client) => {
@@ -136,7 +146,7 @@ describe("createTunnel", () => {
136
146
  const ssh = await startSshServer();
137
147
  const knownHosts = path.join(root, "known_hosts.json");
138
148
  const keyPath = path.join(root, "id_tunnel");
139
- await writeFile(keyPath, utils.generateKeyPairSync("ed25519").private);
149
+ await writeFile(keyPath, ed25519Pair().private);
140
150
  Object.assign(process.env, {
141
151
  SSH_TUNNEL_PORT: String(ssh.port),
142
152
  SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
@@ -0,0 +1,211 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+
4
+ export interface MemberFilter {
5
+ only?: string[];
6
+ omit?: string[];
7
+ prefix?: string;
8
+ }
9
+
10
+ const nodeFlags =
11
+ ts.NodeBuilderFlags.NoTruncation |
12
+ ts.NodeBuilderFlags.UseAliasDefinedOutsideCurrentScope |
13
+ ts.NodeBuilderFlags.OmitParameterModifiers;
14
+ const typeFlags = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope;
15
+
16
+ export class DeclarationReader {
17
+ static readonly #printer = ts.createPrinter({ removeComments: true, omitTrailingSemicolon: true });
18
+ readonly #dir: string;
19
+ readonly #program: ts.Program;
20
+ readonly #checker: ts.TypeChecker;
21
+ readonly #entries: Map<string, string>;
22
+ readonly #scratch: ts.SourceFile;
23
+
24
+ constructor(akanjsDir: string) {
25
+ this.#dir = akanjsDir;
26
+ const configPath = path.join(akanjsDir, "tsconfig.json");
27
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
28
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, akanjsDir, undefined, configPath);
29
+ this.#entries = DeclarationReader.#entryFiles(akanjsDir);
30
+ this.#program = ts.createProgram({
31
+ rootNames: [...new Set(this.#entries.values())],
32
+ options: { ...parsed.options, noEmit: true },
33
+ });
34
+ this.#checker = this.#program.getTypeChecker();
35
+ this.#scratch = ts.createSourceFile("reference.ts", "", ts.ScriptTarget.Latest);
36
+ }
37
+
38
+ static #entryFiles(akanjsDir: string) {
39
+ const manifest = JSON.parse(ts.sys.readFile(path.join(akanjsDir, "package.json")) ?? "{}") as {
40
+ exports: Record<string, unknown>;
41
+ };
42
+ const entries = new Map<string, string>();
43
+ for (const [key, value] of Object.entries(manifest.exports)) {
44
+ const target = typeof value === "string" ? value : (value as { types?: string; import?: string }).types;
45
+ if (key.includes("*") || typeof target !== "string" || !/\.tsx?$/.test(target)) continue;
46
+ entries.set(key === "." ? "akanjs" : `akanjs/${key.slice(2)}`, path.join(akanjsDir, target));
47
+ }
48
+ return entries;
49
+ }
50
+
51
+ exported(from: string, name: string): ts.Symbol {
52
+ const file = this.#entries.get(from);
53
+ const source = file ? this.#program.getSourceFile(file) : undefined;
54
+ const module = source ? this.#checker.getSymbolAtLocation(source) : undefined;
55
+ const symbol = module ? this.#checker.getExportsOfModule(module).find((each) => each.name === name) : undefined;
56
+ if (!symbol) throw new Error(`${name} is not exported from ${from}: update the API reference manifest`);
57
+ return symbol.flags & ts.SymbolFlags.Alias ? this.#checker.getAliasedSymbol(symbol) : symbol;
58
+ }
59
+
60
+ declared(file: string, name: string): ts.Symbol {
61
+ const source = this.#program.getSourceFile(path.join(this.#dir, file));
62
+ const statement = source?.statements.find(
63
+ (each) =>
64
+ (ts.isClassDeclaration(each) || ts.isInterfaceDeclaration(each) || ts.isTypeAliasDeclaration(each)) &&
65
+ each.name?.text === name,
66
+ ) as ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | undefined;
67
+ const symbol = statement?.name ? this.#checker.getSymbolAtLocation(statement.name) : undefined;
68
+ if (!symbol) throw new Error(`${name} is not declared in ${file}: update the API reference manifest`);
69
+ return symbol;
70
+ }
71
+
72
+ declaredType(symbol: ts.Symbol): ts.Type {
73
+ return this.#checker.getDeclaredTypeOfSymbol(symbol);
74
+ }
75
+
76
+ instanceType(symbol: ts.Symbol): ts.Type {
77
+ if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias))
78
+ return this.declaredType(symbol);
79
+ const type = this.#checker.getTypeOfSymbol(symbol);
80
+ const [signature] = type.getCallSignatures();
81
+ return signature ? signature.getReturnType() : type;
82
+ }
83
+
84
+ declarationText(symbol: ts.Symbol): string {
85
+ const [declaration] = symbol.declarations ?? [];
86
+ if (!declaration) throw new Error(`${symbol.name} has no declaration`);
87
+ return DeclarationReader.#print(declaration, declaration.getSourceFile()).replace(/^export /, "");
88
+ }
89
+
90
+ signatures(symbol: ts.Symbol, name: string): string[] {
91
+ const type = this.#checker.getTypeOfSymbol(symbol);
92
+ const signatures = type.getCallSignatures();
93
+ if (signatures.length === 0) throw new Error(`${name} is not callable`);
94
+ return signatures.map((signature) => `${name}${this.#checker.signatureToString(signature, undefined, typeFlags)}`);
95
+ }
96
+
97
+ props(type: ts.Type, omit: string[] = []): string[] {
98
+ const lines: string[] = [];
99
+ const properties = this.#checker.getPropertiesOfType(type);
100
+ DeclarationReader.#assertNamed(properties, omit);
101
+ for (const property of properties) {
102
+ if (omit.includes(property.name) || DeclarationReader.#isInternalName(property.name)) continue;
103
+ const [declaration] = property.declarations ?? [];
104
+ if (declaration && !this.#isOwn(declaration)) continue;
105
+ const optional = property.flags & ts.SymbolFlags.Optional ? "?" : "";
106
+ const propertyType = declaration
107
+ ? this.#checker.getTypeOfSymbolAtLocation(property, declaration)
108
+ : this.#checker.getTypeOfSymbol(property);
109
+ const shown = this.#checker.typeToString(
110
+ optional ? this.#checker.getNonNullableType(propertyType) : propertyType,
111
+ undefined,
112
+ typeFlags,
113
+ );
114
+ const doc = this.#firstSentence(property);
115
+ if (doc) lines.push(`/** ${doc} */`);
116
+ lines.push(`${property.name}${optional}: ${shown};`);
117
+ }
118
+ return lines;
119
+ }
120
+
121
+ members(type: ts.Type, { only, omit = [], prefix = "." }: MemberFilter): [string, string][] {
122
+ const properties = this.#checker.getPropertiesOfType(type);
123
+ DeclarationReader.#assertNamed(properties, [...(only ?? []), ...omit]);
124
+ const found: [string, string][] = [];
125
+ for (const property of properties) {
126
+ const name = property.name;
127
+ if ((only && !only.includes(name)) || omit.includes(name) || DeclarationReader.#isInternalName(name)) continue;
128
+ const [declaration] = property.declarations ?? [];
129
+ if (!declaration || DeclarationReader.#isHidden(declaration)) continue;
130
+ const signatures = this.#checker.getTypeOfSymbolAtLocation(property, declaration).getCallSignatures();
131
+ for (const signature of signatures)
132
+ found.push([name, `${prefix}${name}${this.#withoutReturn(signature, declaration)}`]);
133
+ }
134
+ return found;
135
+ }
136
+
137
+ static #assertNamed(properties: ts.Symbol[], names: string[]) {
138
+ for (const name of names)
139
+ if (!properties.some((property) => property.name === name))
140
+ throw new Error(`${name} is not a member any more: update the API reference manifest`);
141
+ }
142
+
143
+ static #isInternalName(name: string) {
144
+ return name.startsWith("_") || name.startsWith("#");
145
+ }
146
+
147
+ #isOwn(declaration: ts.Declaration) {
148
+ return !path.relative(this.#dir, declaration.getSourceFile().fileName).startsWith("..");
149
+ }
150
+
151
+ static #isHidden(declaration: ts.Declaration) {
152
+ const flags = ts.getCombinedModifierFlags(declaration);
153
+ return (
154
+ (flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) !== 0 ||
155
+ (ts.isPropertyDeclaration(declaration) && !declaration.type && !declaration.initializer) ||
156
+ ts.isConstructorDeclaration(declaration)
157
+ );
158
+ }
159
+
160
+ #withoutReturn(signature: ts.Signature, enclosing: ts.Declaration) {
161
+ const node = this.#checker.signatureToSignatureDeclaration(
162
+ signature,
163
+ ts.SyntaxKind.MethodSignature,
164
+ enclosing,
165
+ nodeFlags,
166
+ ) as ts.MethodSignature | undefined;
167
+ if (!node) throw new Error(`${this.#checker.signatureToString(signature)} cannot be printed`);
168
+ const typeParameters = node.typeParameters?.filter((each) => !each.name.text.startsWith("_"));
169
+ const parameters = node.parameters.map((parameter) =>
170
+ parameter.questionToken && parameter.type && ts.isUnionTypeNode(parameter.type)
171
+ ? ts.factory.updateParameterDeclaration(
172
+ parameter,
173
+ parameter.modifiers,
174
+ parameter.dotDotDotToken,
175
+ parameter.name,
176
+ parameter.questionToken,
177
+ DeclarationReader.#withoutUndefined(parameter.type),
178
+ parameter.initializer,
179
+ )
180
+ : parameter,
181
+ );
182
+ const stripped = ts.factory.createMethodSignature(
183
+ undefined,
184
+ "x",
185
+ undefined,
186
+ typeParameters?.length ? typeParameters : undefined,
187
+ parameters,
188
+ undefined,
189
+ );
190
+ return DeclarationReader.#print(stripped, this.#scratch)
191
+ .replace(/^x/, "")
192
+ .replace(/;$/, "")
193
+ .replaceAll(" & Record<never, never>", "");
194
+ }
195
+
196
+ static #print(node: ts.Node, source: ts.SourceFile) {
197
+ return DeclarationReader.#printer.printNode(ts.EmitHint.Unspecified, node, source).replace(/\s+/g, " ");
198
+ }
199
+
200
+ static #withoutUndefined(type: ts.UnionTypeNode): ts.TypeNode {
201
+ const kept = type.types.filter((each) => each.kind !== ts.SyntaxKind.UndefinedKeyword);
202
+ return kept.length === 1 ? kept[0] : ts.factory.createUnionTypeNode(kept);
203
+ }
204
+
205
+ #firstSentence(symbol: ts.Symbol) {
206
+ const text = ts.displayPartsToString(symbol.getDocumentationComment(this.#checker)).replace(/\s+/g, " ").trim();
207
+ if (!text) return "";
208
+ const end = text.search(/\.(\s|$)/);
209
+ return (end === -1 ? text : text.slice(0, end + 1)).replace(/\*\//g, "*\\/");
210
+ }
211
+ }
package/executors.test.ts CHANGED
@@ -304,8 +304,8 @@ describe("Executor filesystem helpers", () => {
304
304
 
305
305
  await exec.applyTemplate({ basePath: "app", template: "app", dict: { appName: "demo" }, options: { libs: [] } });
306
306
  const templateRoot = path.resolve(import.meta.dir, "../cli/templates/app/public");
307
- await expect(readFile(path.join(root, "app/public/logo.png"))).resolves.toEqual(
308
- await readFile(path.join(templateRoot, "logo.png")),
307
+ await expect(readFile(path.join(root, "app/public/apple-touch-icon.png"))).resolves.toEqual(
308
+ await readFile(path.join(templateRoot, "apple-touch-icon.png")),
309
309
  );
310
310
  await expect(readFile(path.join(root, "app/public/favicon.ico"))).resolves.toEqual(
311
311
  await readFile(path.join(templateRoot, "favicon.ico")),
@@ -315,22 +315,32 @@ describe("Executor filesystem helpers", () => {
315
315
  test("keeps a static file already there when overwrite is false, and reports it unwritten", async () => {
316
316
  const root = await makeTempRoot();
317
317
  const exec = new Executor("fixture", root);
318
- await writeText(path.join(root, "app/public/logo.png"), "the app's own logo");
318
+ await writeText(path.join(root, "app/public/favicon.ico"), "the app's own icon");
319
319
 
320
320
  const applied = await exec.applyTemplate({
321
321
  basePath: "app/public",
322
- template: "app/public/logo.png",
322
+ template: "app/public/favicon.ico",
323
323
  overwrite: false,
324
324
  });
325
325
 
326
- expect(await readFile(path.join(root, "app/public/logo.png"), "utf8")).toBe("the app's own logo");
326
+ expect(await readFile(path.join(root, "app/public/favicon.ico"), "utf8")).toBe("the app's own icon");
327
327
  expect(applied).toEqual([
328
- expect.objectContaining({ filePath: path.join(root, "app/public/logo.png"), written: false }),
328
+ expect.objectContaining({ filePath: path.join(root, "app/public/favicon.ico"), written: false }),
329
329
  ]);
330
330
  });
331
331
  });
332
332
 
333
333
  describe("Workspace and app executor environment contracts", () => {
334
+ const baseDevEnvKeys = [
335
+ "AKAN_PUBLIC_APP_NAME",
336
+ "AKAN_WORKSPACE_ROOT",
337
+ "AKAN_WORKSPACE_ID",
338
+ "AKAN_PUBLIC_REPO_NAME",
339
+ "AKAN_PUBLIC_SERVE_DOMAIN",
340
+ "AKAN_PUBLIC_ENV",
341
+ "PORT_OFFSET",
342
+ ];
343
+
334
344
  test("reads base development environment", () => {
335
345
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
336
346
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -427,8 +437,7 @@ describe("Workspace and app executor environment contracts", () => {
427
437
  "",
428
438
  ].join("\n"),
429
439
  );
430
- delete process.env.AKAN_PUBLIC_REPO_NAME;
431
- delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
440
+ for (const key of baseDevEnvKeys) delete process.env[key];
432
441
 
433
442
  expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
434
443
  appName: "file-app",
@@ -458,6 +467,7 @@ describe("Workspace and app executor environment contracts", () => {
458
467
  "",
459
468
  ].join("\r\n"),
460
469
  );
470
+ for (const key of baseDevEnvKeys) delete process.env[key];
461
471
 
462
472
  expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
463
473
  appName: 'say \\"hi\\"\nagain',
@@ -471,6 +481,53 @@ describe("Workspace and app executor environment contracts", () => {
471
481
  });
472
482
  });
473
483
 
484
+ test("the shell outranks the workspace .env key by key, and a default stands in for what neither sets", async () => {
485
+ const root = await makeTempRoot();
486
+ await writeFile(
487
+ path.join(root, ".env"),
488
+ [
489
+ "AKAN_PUBLIC_ENV=local",
490
+ "AKAN_PUBLIC_REPO_NAME=file-repo",
491
+ "AKAN_PUBLIC_SERVE_DOMAIN=file.example.com",
492
+ "",
493
+ ].join("\n"),
494
+ );
495
+ for (const key of baseDevEnvKeys) delete process.env[key];
496
+ Object.assign(process.env, { AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "shell.example.com" });
497
+
498
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
499
+ env: "main",
500
+ repoName: "file-repo",
501
+ serveDomain: "shell.example.com",
502
+ defaulted: [],
503
+ });
504
+
505
+ delete process.env.AKAN_PUBLIC_ENV;
506
+ await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_ENV=local\n");
507
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
508
+ env: "local",
509
+ serveDomain: "shell.example.com",
510
+ defaulted: ["AKAN_PUBLIC_REPO_NAME"],
511
+ });
512
+ });
513
+
514
+ test("an empty export reads as unset, so the .env's value stands", async () => {
515
+ const root = await makeTempRoot();
516
+ await writeFile(
517
+ path.join(root, ".env"),
518
+ "AKAN_PUBLIC_ENV=main\nAKAN_PUBLIC_REPO_NAME=file-repo\nAKAN_PUBLIC_SERVE_DOMAIN=file.example.com\n",
519
+ );
520
+ for (const key of baseDevEnvKeys) delete process.env[key];
521
+ Object.assign(process.env, { AKAN_PUBLIC_ENV: "", AKAN_PUBLIC_REPO_NAME: "", AKAN_PUBLIC_SERVE_DOMAIN: "" });
522
+
523
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
524
+ env: "main",
525
+ repoName: "file-repo",
526
+ serveDomain: "file.example.com",
527
+ defaulted: [],
528
+ });
529
+ });
530
+
474
531
  test("builds app command environment and prepareCommand artifacts", async () => {
475
532
  const root = await makeTempRoot();
476
533
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";