@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.
- package/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +33 -14
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +57 -2
- package/applicationBuildRunner.ts +15 -6
- package/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +1 -11
- package/biomeBase.ts +1 -1
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +9 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/tools/WebToolPack.ts +4 -4
- package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +45 -10
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/createTunnel.test.ts +14 -0
- package/createTunnel.ts +3 -1
- package/dependencyScanner.ts +1 -1
- package/devkitUtils.test.ts +1 -1
- package/executors.test.ts +131 -3
- package/executors.ts +56 -36
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +23 -4
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCompiler.test.ts +53 -0
- package/frontendBuild/cssCompiler.ts +8 -4
- package/frontendBuild/fontOptimizer.ts +2 -1
- package/frontendBuild/frontendBuild.test.ts +14 -0
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
- package/frontendBuild/pagesBundleBuilder.ts +64 -9
- package/frontendBuild/routeClientBuilder.ts +1 -1
- package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
- package/frontendBuild/themeValidator.test.ts +83 -2
- package/frontendBuild/themeValidator.ts +37 -15
- package/getRelatedCnsts.ts +2 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/integration/devStabilityHarness.ts +5 -3
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +5 -3
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +115 -22
- package/mobile/updateUploader.ts +77 -10
- package/package.json +20 -20
- package/piDependencyPins.test.ts +18 -19
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.test.ts +5 -3
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +1 -1
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +362 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/testHelpers.ts +2 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/browserDirnamePlugin.test.ts +88 -0
- package/transforms/browserDirnamePlugin.ts +25 -0
- package/transforms/rscUseClientTransform.ts +2 -2
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/typecheck/projectTypecheck.ts +6 -5
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- 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,
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
|
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(
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
136
|
-
.replace(/<style[\s\S]*?<\/style
|
|
137
|
-
.replace(/<[
|
|
135
|
+
.replace(/<script[\s\S]*?<\/script[^<>]*>/gi, " ")
|
|
136
|
+
.replace(/<style[\s\S]*?<\/style[^<>]*>/gi, " ")
|
|
137
|
+
.replace(/<[^<>]+>/g, " ")
|
|
138
138
|
.replace(/ /g, " ")
|
|
139
|
-
.replace(/&/g, "&")
|
|
140
139
|
.replace(/</g, "<")
|
|
141
140
|
.replace(/>/g, ">")
|
|
142
141
|
.replace(/"/g, '"')
|
|
142
|
+
.replace(/&/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)(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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?:
|
|
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
|
|
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
|
-
`${
|
|
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
|
-
}
|
|
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,
|
|
192
|
+
const [hasPackageJson, hasTsConfig, ...hasMemberDirs] = await Promise.all([
|
|
169
193
|
FileSys.fileExists(`${cwd}/package.json`),
|
|
170
194
|
FileSys.fileExists(`${cwd}/tsconfig.json`),
|
|
171
|
-
FileSys.
|
|
195
|
+
...["apps", "libs", "pkgs"].map((dir) => FileSys.dirExists(`${cwd}/${dir}`)),
|
|
172
196
|
]);
|
|
173
|
-
if (hasPackageJson && hasTsConfig &&
|
|
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
|
|
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) => ({
|
|
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
|
|