@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.11

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,9 +1,11 @@
1
1
  import { Logger, sleep } from "akanjs/common";
2
2
  import type { BunFile } from "bun";
3
- import { CloudApi, type CloudRecord, type UpdateUploadRequest } from "./cloudApi";
3
+ import type { CloudApi, CloudRecord, UpdateUploadRequest } from "./cloudApi";
4
+ import { CloudHttpError } from "./cloudHttpError";
4
5
 
5
6
  interface UpdatePackUploadOptions {
6
7
  putRetryMs?: number;
8
+ putTimeoutMs?: number;
7
9
  commitRetryMs?: number;
8
10
  cutOffWindowMs?: number;
9
11
  commitLimitMs?: number;
@@ -15,9 +17,14 @@ interface UpdatePackUploadOptions {
15
17
  export class UpdatePackUpload {
16
18
  static readonly maxBytes = 200 * 1024 * 1024;
17
19
  static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
20
+ //? A stalled PUT never fails on its own, so each try ends once the pack would be through even at this crawl.
21
+ static readonly #putFloorBytesPerSecond = 64 * 1024;
22
+ //? Storage's Retry-After is honoured up to this: past it the presigned URL may lapse, and a fresh one is asked for.
23
+ static readonly #maxPutRetryAfterMs = 60_000;
18
24
  readonly #api: CloudApi;
19
25
  readonly #devProjectId: string;
20
26
  readonly #putRetryMs: number;
27
+ readonly #putTimeoutMs: number;
21
28
  readonly #commitRetryMs: number;
22
29
  readonly #cutOffWindowMs: number;
23
30
  readonly #commitLimitMs: number;
@@ -27,6 +34,7 @@ export class UpdatePackUpload {
27
34
  devProjectId: string,
28
35
  {
29
36
  putRetryMs = 1_000,
37
+ putTimeoutMs = 60_000,
30
38
  commitRetryMs = 10_000,
31
39
  cutOffWindowMs = 15 * 60_000,
32
40
  commitLimitMs = 60 * 60_000,
@@ -35,12 +43,12 @@ export class UpdatePackUpload {
35
43
  this.#api = api;
36
44
  this.#devProjectId = devProjectId;
37
45
  this.#putRetryMs = putRetryMs;
46
+ this.#putTimeoutMs = putTimeoutMs;
38
47
  this.#commitRetryMs = commitRetryMs;
39
48
  this.#cutOffWindowMs = cutOffWindowMs;
40
49
  this.#commitLimitMs = commitLimitMs;
41
50
  }
42
51
 
43
- /** Stages the pack and commits it as a release of `fields.channel`. */
44
52
  async send(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<CloudRecord> {
45
53
  const uploadId = await this.stage(archive, fields);
46
54
  return await this.settle(() => this.#api.commitUpdateUpload(this.#devProjectId, uploadId));
@@ -67,10 +75,9 @@ export class UpdatePackUpload {
67
75
  async #request(upload: UpdateUploadRequest) {
68
76
  return await this.#api.requestUpdateUpload(this.#devProjectId, upload).catch((error: unknown) => {
69
77
  //? A server older than staged uploads has no such route: its 404 is a page, where every refusal is an Err key.
70
- const message = error instanceof Error ? error.message : String(error);
71
- if (CloudApi.statusOf(error) === 404 && !/"error"\s*:\s*"[\w$-]+\.error\.[\w$-]+"/.test(message))
78
+ if (error instanceof CloudHttpError && error.status === 404 && !error.errKey)
72
79
  throw new Error(
73
- "The project's server does not take direct pack uploads yet: update akasys and try again, or upload the pack from the project's release screen.",
80
+ "The project's server does not take direct pack uploads yet: update Akan Cloud and try again, or upload the pack from the project's release screen.",
74
81
  );
75
82
  throw error;
76
83
  });
@@ -79,15 +86,26 @@ export class UpdatePackUpload {
79
86
  // The URL signs only its host: an unsigned x-amz-* header (a checksum included) makes storage refuse the PUT.
80
87
  // A Bun fetch error names the URL, a bearer credential, so nothing but the status leaves this method.
81
88
  async #put(url: string, contentType: string, file: BunFile) {
89
+ const timeoutMs = this.#putTimeoutMs + Math.ceil((file.size / UpdatePackUpload.#putFloorBytesPerSecond) * 1000);
82
90
  for (let retry = 0; ; retry++) {
83
- const status = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": contentType } })
91
+ const { status, retryAfterMs } = await fetch(url, {
92
+ method: "PUT",
93
+ body: file,
94
+ headers: { "Content-Type": contentType },
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ })
84
97
  .then(async (response) => {
85
98
  await response.arrayBuffer().catch(() => undefined);
86
- return response.status;
99
+ return {
100
+ status: response.status,
101
+ retryAfterMs: CloudHttpError.retryAfterMs(response.headers.get("retry-after")),
102
+ };
87
103
  })
88
- .catch(() => 0);
104
+ .catch(() => ({ status: 0, retryAfterMs: null }));
89
105
  if (retry === 3 || !UpdatePackUpload.#transientPut.has(status)) return status;
90
- await sleep(this.#putRetryMs * (retry + 1));
106
+ await sleep(
107
+ Math.max(this.#putRetryMs * (retry + 1), Math.min(retryAfterMs ?? 0, UpdatePackUpload.#maxPutRetryAfterMs)),
108
+ );
91
109
  }
92
110
  }
93
111
 
@@ -109,24 +127,26 @@ export class UpdatePackUpload {
109
127
  else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
110
128
  else throw error;
111
129
  const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
112
- if (now + this.#commitRetryMs > deadline) throw error;
113
- Logger.rawLog(
114
- `The project is still taking the pack; asking again in ${Math.round(this.#commitRetryMs / 1000)}s.`,
115
- );
116
- await sleep(this.#commitRetryMs);
130
+ const waitMs = Math.max(this.#commitRetryMs, (error instanceof CloudHttpError && error.retryAfterMs) || 0);
131
+ if (now + waitMs > deadline) throw error;
132
+ Logger.rawLog(`The project is still taking the pack; asking again in ${Math.round(waitMs / 1000)}s.`);
133
+ await sleep(waitMs);
117
134
  }
118
135
  }
119
136
  }
120
137
 
121
138
  //? Not every 409: an upload already committed or embedded is answered 409 too, and asking again changes nothing.
122
139
  static #inProgress(error: unknown) {
123
- const message = error instanceof Error ? error.message : String(error);
124
- return CloudApi.statusOf(error) === 409 && message.includes("uploadCommitInProgress");
140
+ return (
141
+ error instanceof CloudHttpError && error.status === 409 && !!error.errKey?.endsWith(".uploadCommitInProgress")
142
+ );
125
143
  }
126
144
 
127
145
  static #cutOff(error: unknown) {
128
- const status = CloudApi.statusOf(error);
129
- if (status === null) return !(error instanceof SyntaxError);
146
+ if (!(error instanceof CloudHttpError)) return !(error instanceof SyntaxError);
147
+ const { status, errKey } = error;
148
+ //? A gateway with no replica ready answers 503; the server's own 503 names an Err key and is a final refusal.
149
+ if (status === 503) return errKey === null || errKey === "base.error.serverUnavailable";
130
150
  return status === 502 || status === 504 || (status >= 520 && status <= 527);
131
151
  }
132
152
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
- import type { CodeAgentProfile } from "akanjs/common";
3
+ import { type CodeAgentProfile, trimTrailingSlashes } from "akanjs/common";
4
4
  import { akanCodeDefaultModel } from "./akanCodeModel";
5
5
 
6
6
  interface PreparedRequest {
@@ -27,7 +27,7 @@ export class CodeAgentProxy {
27
27
  readonly #token?: string;
28
28
 
29
29
  constructor({ baseUrl, providers, tokenFile, token }: CodeAgentProxyOptions) {
30
- this.baseUrl = baseUrl.replace(/\/+$/, "");
30
+ this.baseUrl = trimTrailingSlashes(baseUrl);
31
31
  this.providers = providers;
32
32
  this.#tokenFile = tokenFile;
33
33
  this.#token = token;
@@ -8,7 +8,7 @@ export interface CodeAgentModelRef {
8
8
  id: string;
9
9
  }
10
10
 
11
- /** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
11
+ /** DeepSeek's million-token window is what makes the always-loaded `AGENTS.md` affordable on every turn. */
12
12
  export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
13
13
 
14
14
  // Declared text-only by the catalogue, yet verified against the provider to see images.
@@ -73,7 +73,9 @@ describe("workspace context files", () => {
73
73
  test("a rule with no frontmatter at all is unconditional, and so is .cursorrules", () => {
74
74
  const root = workspace({ ".cursor/rules/plain.mdc": "just do it", ".cursorrules": "the legacy file" });
75
75
  const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
76
- expect(namesOf(files, root).sort()).toEqual([".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort());
76
+ expect(namesOf(files, root).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
77
+ [".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort(),
78
+ );
77
79
  });
78
80
 
79
81
  test("rules nested under the rules directory are found, in a stable order", () => {
@@ -132,14 +132,14 @@ export class WebToolPack {
132
132
  /** Enough to read a documentation page; a full HTML parser is not worth a dependency for that. */
133
133
  static #readable(html: string) {
134
134
  return html
135
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
136
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
137
- .replace(/<[^>]+>/g, " ")
135
+ .replace(/<script[\s\S]*?<\/script[^<>]*>/gi, " ")
136
+ .replace(/<style[\s\S]*?<\/style[^<>]*>/gi, " ")
137
+ .replace(/<[^<>]+>/g, " ")
138
138
  .replace(/&nbsp;/g, " ")
139
- .replace(/&amp;/g, "&")
140
139
  .replace(/&lt;/g, "<")
141
140
  .replace(/&gt;/g, ">")
142
141
  .replace(/&quot;/g, '"')
142
+ .replace(/&amp;/g, "&")
143
143
  .replace(/[ \t]+/g, " ")
144
144
  .replace(/\n{3,}/g, "\n\n")
145
145
  .trim();
@@ -247,22 +247,26 @@ describe("PreviewView", () => {
247
247
  expect(typeof PreviewView.available).toBe("boolean");
248
248
  });
249
249
 
250
- test.skipIf(!PreviewView.available)("finds an empty body and a console error on a real page", async () => {
251
- const server = Bun.serve({
252
- port: 0,
253
- fetch: () =>
254
- new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
255
- headers: { "content-type": "text/html" },
256
- }),
257
- });
258
- try {
259
- const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
260
- expect(probe.pageErrors.join(" ")).toContain("rendered empty");
261
- expect(probe.consoleErrors.join(" ")).toContain("boom");
262
- } finally {
263
- server.stop(true);
264
- }
265
- });
250
+ test.skipIf(!PreviewView.available)(
251
+ "finds an empty body and a console error on a real page",
252
+ async () => {
253
+ const server = Bun.serve({
254
+ port: 0,
255
+ fetch: () =>
256
+ new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
257
+ headers: { "content-type": "text/html" },
258
+ }),
259
+ });
260
+ try {
261
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
262
+ expect(probe.pageErrors.join(" ")).toContain("rendered empty");
263
+ expect(probe.consoleErrors.join(" ")).toContain("boom");
264
+ } finally {
265
+ server.stop(true);
266
+ }
267
+ },
268
+ 30_000,
269
+ );
266
270
  });
267
271
 
268
272
  describe("SubagentPool", () => {
@@ -49,11 +49,15 @@ export interface ArgMeta<Context = CommandContext> {
49
49
  idx: number;
50
50
  type: ArgType;
51
51
  }
52
+ export interface InternalArgOption {
53
+ nullable?: boolean;
54
+ onlyApp?: boolean;
55
+ }
52
56
  export interface InternalArgMeta {
53
57
  key: string;
54
58
  idx: number;
55
59
  type: InternalArgType;
56
- option?: { nullable?: boolean };
60
+ option?: InternalArgOption;
57
61
  }
58
62
 
59
63
  export const getArgMetas = (
@@ -69,9 +73,11 @@ export const getArgMetas = (
69
73
  export interface InternalArgToken<T = unknown, Type extends InternalArgType = InternalArgType> {
70
74
  type: Type;
71
75
  _value: T;
76
+ option?: InternalArgOption;
72
77
  }
73
78
 
74
- const createInternalArgToken = <T, Type extends InternalArgType>(type: Type) => ({ type }) as InternalArgToken<T, Type>;
79
+ const createInternalArgToken = <T, Type extends InternalArgType>(type: Type, option?: InternalArgOption) =>
80
+ (option ? { type, option } : { type }) as InternalArgToken<T, Type>;
75
81
 
76
82
  export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPrimitiveArgType => {
77
83
  if (type === String) return "string";
@@ -93,6 +99,9 @@ export type Lib = LibExecutor;
93
99
  export const Sys = createInternalArgToken<SysExecutor, "Sys">("Sys");
94
100
  export type Sys = SysExecutor;
95
101
 
102
+ /** Sys, except that a one-app workspace given no name means its app, as with App. */
103
+ export const SysOrOnlyApp = createInternalArgToken<SysExecutor, "Sys">("Sys", { onlyApp: true });
104
+
96
105
  export const Exec = createInternalArgToken<Executor, "Exec">("Exec");
97
106
  export type Exec = Executor;
98
107
 
@@ -23,6 +23,7 @@ import {
23
23
  } from "./argMeta";
24
24
  import { camelToKebabCase } from "./camelToKebabCase";
25
25
  import { CommandContainer } from "./dependencyBuilder";
26
+ import { ExitCodeError } from "./exitCodeError";
26
27
  import { formatCommandHelp, formatHelp } from "./helpFormatter";
27
28
  import { type CommandCls, getTargetCommandNames, getTargetMetas } from "./targetMeta";
28
29
 
@@ -102,6 +103,9 @@ const confirm = ((config, context) => prompts().then((m) => m.confirm(config, co
102
103
  const input = ((config, context) => prompts().then((m) => m.input(config, context))) as typeof inquirerInput;
103
104
  const checkbox = ((config, context) => prompts().then((m) => m.checkbox(config, context))) as typeof inquirerCheckbox;
104
105
 
106
+ const argLabelOf = (argMeta: ArgMeta) =>
107
+ argMeta.type === "Option" ? `option '--${camelToKebabCase(argMeta.name)}'` : `argument '${argMeta.name}'`;
108
+
105
109
  // A DynamicEnum is not checked: it resolves against a context the internal args have not populated yet.
106
110
  // Compared as strings: the value is still commander's raw string while a numeric choice list holds numbers.
107
111
  const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
@@ -109,10 +113,19 @@ const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
109
113
  if (!enumChoices || typeof enumChoices === "function") return;
110
114
  const choices = normalizeEnumChoices(enumChoices);
111
115
  if (choices.some((choice) => String(choice.value) === String(value))) return;
112
- const label =
113
- argMeta.type === "Option" ? `option '--${camelToKebabCase(argMeta.name)}'` : `argument '${argMeta.name}'`;
114
116
  throw new Error(
115
- `${label} argument '${String(value)}' is invalid. Allowed choices are ${choices.map((choice) => choice.name).join(", ")}.`,
117
+ `${argLabelOf(argMeta)} argument '${String(value)}' is invalid. Allowed choices are ${choices.map((choice) => choice.name).join(", ")}.`,
118
+ );
119
+ };
120
+
121
+ // On a piped stdin a prompt takes the pipe as its answer and echoes it: a piped secret printed as the secret's name.
122
+ export const assertPromptable = (
123
+ problem: string,
124
+ { choices = [], remedy = "pass it on the command line" }: { choices?: readonly unknown[]; remedy?: string } = {},
125
+ ) => {
126
+ if (process.stdin.isTTY) return;
127
+ throw new Error(
128
+ `${problem}: stdin is not a terminal to prompt on, so ${remedy}${choices.length ? ` (choices: ${choices.join(", ")})` : ""}.`,
116
129
  );
117
130
  };
118
131
 
@@ -136,8 +149,11 @@ export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unkno
136
149
  (typeof enumChoices === "function" ? await enumChoices(context) : enumChoices) ?? [],
137
150
  );
138
151
  if (choices.length === 1) return choices[0]?.value;
152
+ assertPromptable(`Missing ${argLabelOf(argMeta)}`, { choices: choices.map((choice) => choice.value) });
139
153
  return await select({ message: ask ?? desc ?? `Select the ${name} value`, choices });
140
- } else if (type === "boolean") {
154
+ }
155
+ assertPromptable(`Missing ${argLabelOf(argMeta)}`);
156
+ if (type === "boolean") {
141
157
  const message = ask ?? desc ?? `Do you want to set ${name}? ${desc ? ` (${desc})` : ""}: `;
142
158
  return await confirm({ message });
143
159
  } else return convertArgValue(await input({ message: inputMessageOf(argMeta) }), type ?? "string");
@@ -150,6 +166,13 @@ export const getArgumentValue = async (argMeta: ArgMeta, value: string | undefin
150
166
  return convertArgValue(value, type ?? "string");
151
167
  } else if (defaultValue !== undefined) return defaultValue;
152
168
  else if (nullable) return null;
169
+ const { enum: enumChoices } = argMeta.argsOption;
170
+ assertPromptable(`Missing ${argLabelOf(argMeta)}`, {
171
+ choices:
172
+ enumChoices && typeof enumChoices !== "function"
173
+ ? normalizeEnumChoices(enumChoices).map(({ value }) => value)
174
+ : [],
175
+ });
153
176
  return convertArgValue(await input({ message: inputMessageOf(argMeta) }), type ?? "string");
154
177
  };
155
178
 
@@ -163,20 +186,21 @@ const assignCommandContext = (context: CommandContext, argMeta: ArgMeta | Intern
163
186
  else context.values[argMeta.type.toLowerCase()] = value;
164
187
  };
165
188
 
189
+ //? Not `.env`, which a fresh clone lacks; an app or a package folder has a package.json and tsconfig.json of its own.
166
190
  const assertCurrentDirectoryIsWorkspaceRoot = async () => {
167
191
  const cwd = process.cwd();
168
- const [hasPackageJson, hasTsConfig, hasEnv] = await Promise.all([
192
+ const [hasPackageJson, hasTsConfig, ...hasMemberDirs] = await Promise.all([
169
193
  FileSys.fileExists(`${cwd}/package.json`),
170
194
  FileSys.fileExists(`${cwd}/tsconfig.json`),
171
- FileSys.fileExists(`${cwd}/.env`),
195
+ ...["apps", "libs", "pkgs"].map((dir) => FileSys.dirExists(`${cwd}/${dir}`)),
172
196
  ]);
173
- if (hasPackageJson && hasTsConfig && hasEnv) return;
197
+ if (hasPackageJson && hasTsConfig && hasMemberDirs.some(Boolean)) return;
174
198
 
175
199
  throw new Error(
176
200
  [
177
201
  "Akan CLI commands must be run from the workspace root.",
178
202
  `Current directory: ${cwd}`,
179
- "Move to the directory that contains package.json, tsconfig.json, and .env, then run the command again.",
203
+ "Move to the directory that contains package.json, tsconfig.json, and an apps/, libs/ or pkgs/ folder, then run the command again.",
180
204
  ].join("\n"),
181
205
  );
182
206
  };
@@ -205,6 +229,7 @@ export const getAppsArgumentValue = async (
205
229
  return [...new Set(requested)].map((name) => AppExecutor.from(workspace, name));
206
230
  }
207
231
  if (appNames.length === 1 && appNames[0]) return [AppExecutor.from(workspace, appNames[0])];
232
+ assertPromptable("Missing apps", { choices: [...appNames, "all"] });
208
233
  const remembered = new Set(await AppSelectionMemory.read(workspace.workspaceRoot));
209
234
  const picked = await checkbox<string>({
210
235
  message: "Select the apps to run (space to toggle, enter to confirm)",
@@ -224,6 +249,7 @@ export const getInternalArgumentValue = async (
224
249
  if (argMeta.type === "Workspace") return workspace;
225
250
  const sysType = argMeta.type.toLowerCase();
226
251
  const [appNames, libNames, pkgNames] = await workspace.getExecs();
252
+ const problemOf = (kind: string) => (value ? `Unknown ${kind} '${value}'` : `Missing ${kind}`);
227
253
  if (sysType === "sys" || sysType === "exec") {
228
254
  const pkgChoices = sysType === "exec" ? pkgNames : [];
229
255
  const execOf = (name: string) => {
@@ -234,6 +260,11 @@ export const getInternalArgumentValue = async (
234
260
  };
235
261
  const found = value ? execOf(value) : null;
236
262
  if (found) return found;
263
+ if (!value && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
264
+ return AppExecutor.from(workspace, appNames[0]);
265
+ assertPromptable(problemOf(sysType === "exec" ? "app, lib or pkg" : "app or lib"), {
266
+ choices: [...appNames, ...libNames, ...pkgChoices],
267
+ });
237
268
  const name = await select<string>({
238
269
  message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
239
270
  choices: [...appNames, ...libNames, ...pkgChoices],
@@ -244,15 +275,18 @@ export const getInternalArgumentValue = async (
244
275
  } else if (sysType === "app") {
245
276
  if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
246
277
  if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
278
+ assertPromptable(problemOf("app"), { choices: appNames });
247
279
  const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
248
280
  return AppExecutor.from(workspace, appName);
249
281
  } else if (sysType === "lib") {
250
282
  if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
283
+ assertPromptable(problemOf("lib"), { choices: libNames });
251
284
  const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
252
285
  return LibExecutor.from(workspace, libName);
253
286
  } else if (sysType === "pkg") {
254
287
  const pkgs = await workspace.getPkgs();
255
288
  if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
289
+ assertPromptable(problemOf("pkg"), { choices: pkgs });
256
290
  const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
257
291
  return PkgExecutor.from(workspace, pkgName);
258
292
  } else if (sysType === "module") {
@@ -270,6 +304,7 @@ export const getInternalArgumentValue = async (
270
304
  if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
271
305
  } else throw new Error(`Invalid system name: ${sysName}`);
272
306
  }
307
+ assertPromptable(problemOf("module"));
273
308
  const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
274
309
  message: `select the App or Lib name`,
275
310
  choices: [
@@ -291,7 +326,7 @@ export const runCommands = async (...commands: CommandCls[]) => {
291
326
  let failureExitCode = 1;
292
327
  process.on("unhandledRejection", (error) => {
293
328
  printCliError(error);
294
- process.exit(failureExitCode);
329
+ process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
295
330
  });
296
331
  const __dirname = getDirname(import.meta.url);
297
332
  const packageJsonCandidates = [`${path.dirname(Bun.main)}/package.json`, `${__dirname}/../package.json`];
@@ -419,6 +454,6 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
419
454
  // Handled here, not by the handler above: a rejection reaching the entry's top-level await prints Bun's own trace.
420
455
  await program.parseAsync(process.argv).catch((error: unknown) => {
421
456
  printCliError(error);
422
- process.exit(failureExitCode);
457
+ process.exit(ExitCodeError.codeOf(error) ?? failureExitCode);
423
458
  });
424
459
  };
@@ -115,7 +115,12 @@ class TargetBuilder<Deps extends readonly DependencyCls[], Params extends unknow
115
115
 
116
116
  with<const Tokens extends readonly InternalArgToken[]>(...tokens: Tokens) {
117
117
  return this.#add<AddInternalArgs<Params, Tokens>, AddInternalContext<Context, Tokens>>(
118
- ...tokens.map((token, offset) => ({ key: "", idx: this.#args.length + offset, type: token.type })),
118
+ ...tokens.map((token, offset) => ({
119
+ key: "",
120
+ idx: this.#args.length + offset,
121
+ type: token.type,
122
+ ...(token.option ? { option: token.option } : {}),
123
+ })),
119
124
  );
120
125
  }
121
126