@akanjs/devkit 3.0.0-beta.2 → 3.0.0-beta.21
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/README.ko.md +2 -2
- package/README.md +3 -2
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +1 -3
- package/akanApp/akanApp.host.ts +82 -254
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +142 -106
- package/akanConfig/akanConfig.ts +147 -191
- package/akanConfig/types.ts +4 -3
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +54 -61
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +32 -1
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +70 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +132 -106
- package/executors.ts +195 -496
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +62 -37
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +46 -69
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +8 -32
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +23 -46
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +29 -58
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +49 -161
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/index.ts +6 -0
- package/mobile/mobileTarget.ts +16 -13
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +85 -0
- package/mobile/nativeApp.ts +210 -0
- package/mobile/nativeConfig.test.ts +199 -0
- package/mobile/nativeConfig.ts +184 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +62 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +10 -10
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +30 -25
- package/subspace.ts +81 -115
- package/subspaceConfig.ts +11 -9
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/types.ts +1 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +13 -3
- package/workspaceLayout.ts +20 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AkanMobileTargetConfig } from "akanjs";
|
|
4
|
+
|
|
5
|
+
export interface NativeWebDirSources {
|
|
6
|
+
/** The target's CSR bundle: one HTML file with its scripts and styles inlined. */
|
|
7
|
+
html: string;
|
|
8
|
+
/** The build's own copy of `public/`, already pruned of fonts nothing reads. */
|
|
9
|
+
publicDir?: string;
|
|
10
|
+
/** Subset fonts, served from `/_akan/fonts` as the page's `<FontFace>` names them. */
|
|
11
|
+
fontsDir?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class NativeWebDir {
|
|
15
|
+
//* The runtime serves its bridge from this prefix and refuses a web root that has one of its own.
|
|
16
|
+
static readonly reservedEntry = "__akan_native";
|
|
17
|
+
|
|
18
|
+
constructor(readonly dir: string) {}
|
|
19
|
+
|
|
20
|
+
//* The bundle is inlined into this page and its own code names the global, so only the tag marks an injection;
|
|
21
|
+
//* it goes first in <head>, ahead of every script, because the page reads it as it boots.
|
|
22
|
+
static injectTarget(html: string, target: Pick<AkanMobileTargetConfig, "name" | "basePath" | "indexPath">) {
|
|
23
|
+
if (html.includes(NativeWebDir.#targetTag)) return html;
|
|
24
|
+
const basePath = target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
|
|
25
|
+
const script = `${NativeWebDir.#targetTag}${JSON.stringify({
|
|
26
|
+
name: target.name,
|
|
27
|
+
basePath,
|
|
28
|
+
indexPath: target.indexPath,
|
|
29
|
+
})};</script>`;
|
|
30
|
+
const head = /<head(?:\s[^>]*)?>/i.exec(html);
|
|
31
|
+
if (!head) return `${script}\n${html}`;
|
|
32
|
+
const at = head.index + head[0].length;
|
|
33
|
+
return `${html.slice(0, at)}\n${script}${html.slice(at)}`;
|
|
34
|
+
}
|
|
35
|
+
static readonly #targetTag = "<script>window.__AKAN_MOBILE_TARGET__=";
|
|
36
|
+
|
|
37
|
+
/** Rebuilds the folder from scratch and answers its files as web paths: relative, `/`-separated and sorted. */
|
|
38
|
+
async assemble(target: AkanMobileTargetConfig, { html, publicDir, fontsDir }: NativeWebDirSources) {
|
|
39
|
+
if (!(await Bun.file(html).exists()))
|
|
40
|
+
throw new Error(`CSR html for mobile target '${target.name}' not found: ${html}`);
|
|
41
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
42
|
+
await mkdir(this.dir, { recursive: true });
|
|
43
|
+
if (publicDir && (await NativeWebDir.#isDir(publicDir))) await cp(publicDir, this.dir, { recursive: true });
|
|
44
|
+
if (fontsDir && (await NativeWebDir.#isDir(fontsDir)))
|
|
45
|
+
await cp(fontsDir, path.join(this.dir, "_akan", "fonts"), { recursive: true });
|
|
46
|
+
if (await NativeWebDir.#isDir(path.join(this.dir, NativeWebDir.reservedEntry)))
|
|
47
|
+
throw new Error(`public/${NativeWebDir.reservedEntry} is reserved for the native runtime.`);
|
|
48
|
+
await Bun.write(path.join(this.dir, "index.html"), NativeWebDir.injectTarget(await Bun.file(html).text(), target));
|
|
49
|
+
const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: this.dir, onlyFiles: true, dot: true }));
|
|
50
|
+
//? Bun.Glob answers with the platform's separator, so a Windows scan reads `_akan\fonts\…`.
|
|
51
|
+
return files.map((file) => file.split(path.sep).join("/")).sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static async #isDir(dir: string) {
|
|
55
|
+
try {
|
|
56
|
+
return (await stat(dir)).isDirectory();
|
|
57
|
+
} catch {
|
|
58
|
+
// A missing folder is a source the build did not produce, not an error.
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isPlaceholderAppId } from "./placeholderAppId";
|
|
3
|
+
|
|
4
|
+
describe("isPlaceholderAppId", () => {
|
|
5
|
+
test("flags placeholder bundle identifiers that Apple's portal already claims", () => {
|
|
6
|
+
for (const appId of ["com.myapp.app", "com.myorg.myapp", "com.example.foo", "com.changeme.app", ""]) {
|
|
7
|
+
expect(isPlaceholderAppId(appId)).toBe(true);
|
|
8
|
+
}
|
|
9
|
+
for (const appId of ["com.minimal.app", "com.nearthlab.leadingflight", "io.akanjs.demo"]) {
|
|
10
|
+
expect(isPlaceholderAppId(appId)).toBe(false);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Placeholder bundle IDs are already claimed on Apple's portal, so device signing fails with "cannot be registered
|
|
2
|
+
// to your development team".
|
|
3
|
+
export const PLACEHOLDER_APP_IDS = [
|
|
4
|
+
"com.myapp.app",
|
|
5
|
+
"com.myorg.myapp",
|
|
6
|
+
"com.example.app",
|
|
7
|
+
"com.example.myapp",
|
|
8
|
+
] as const;
|
|
9
|
+
const placeholderAppIdSegment = /^(example|examples|myorg|myapp|mycompany|myorganization|changeme|todo|sample|test)$/;
|
|
10
|
+
|
|
11
|
+
export const isPlaceholderAppId = (appId: string | null | undefined): boolean => {
|
|
12
|
+
const normalized = appId?.trim().toLowerCase() ?? "";
|
|
13
|
+
if (!normalized) return true;
|
|
14
|
+
if ((PLACEHOLDER_APP_IDS as readonly string[]).includes(normalized)) return true;
|
|
15
|
+
return normalized.split(".").some((segment) => placeholderAppIdSegment.test(segment));
|
|
16
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
|
|
3
|
+
|
|
4
|
+
describe("toIosInfoPlistUsageDescriptions", () => {
|
|
5
|
+
test("writes Apple's real Info.plist keys, not NS + the description name", () => {
|
|
6
|
+
expect(
|
|
7
|
+
toIosInfoPlistUsageDescriptions({
|
|
8
|
+
cameraUsageDescription: "camera",
|
|
9
|
+
photoAddUsageDescription: "add",
|
|
10
|
+
photoUsageDescription: "read",
|
|
11
|
+
locationAlwaysUsageDescription: "always",
|
|
12
|
+
}),
|
|
13
|
+
).toEqual({
|
|
14
|
+
NSCameraUsageDescription: "camera",
|
|
15
|
+
NSPhotoLibraryAddUsageDescription: "add",
|
|
16
|
+
NSPhotoLibraryUsageDescription: "read",
|
|
17
|
+
NSLocationAlwaysAndWhenInUseUsageDescription: "always",
|
|
18
|
+
NSLocationAlwaysUsageDescription: "always",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("keeps the NS prefix convention for a key it does not know", () => {
|
|
23
|
+
expect(toIosInfoPlistUsageDescriptions({ bluetoothAlwaysUsageDescription: "ble" })).toEqual({
|
|
24
|
+
NSBluetoothAlwaysUsageDescription: "ble",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { capitalize } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
//* Apple's Info.plist keys do not follow the description names (photo → NSPhotoLibrary…), so each one is spelled out.
|
|
4
|
+
const iosUsageDescriptionKeys = {
|
|
5
|
+
cameraUsageDescription: ["NSCameraUsageDescription"],
|
|
6
|
+
photoAddUsageDescription: ["NSPhotoLibraryAddUsageDescription"],
|
|
7
|
+
photoUsageDescription: ["NSPhotoLibraryUsageDescription"],
|
|
8
|
+
contactsUsageDescription: ["NSContactsUsageDescription"],
|
|
9
|
+
//? iOS 11+ reads the AlwaysAndWhenInUse key; the Always key only serves iOS 10 and below.
|
|
10
|
+
locationAlwaysUsageDescription: ["NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationAlwaysUsageDescription"],
|
|
11
|
+
locationWhenInUseUsageDescription: ["NSLocationWhenInUseUsageDescription"],
|
|
12
|
+
microphoneUsageDescription: ["NSMicrophoneUsageDescription"],
|
|
13
|
+
speechRecognitionUsageDescription: ["NSSpeechRecognitionUsageDescription"],
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const toIosInfoPlistUsageDescriptions = (descriptions: { [key: string]: string }) =>
|
|
17
|
+
Object.fromEntries(
|
|
18
|
+
Object.entries(descriptions).flatMap(([key, value]) => {
|
|
19
|
+
const plistKeys: readonly string[] = Object.hasOwn(iosUsageDescriptionKeys, key)
|
|
20
|
+
? iosUsageDescriptionKeys[key as keyof typeof iosUsageDescriptionKeys]
|
|
21
|
+
: [`NS${capitalize(key)}`];
|
|
22
|
+
return plistKeys.map((plistKey) => [plistKey, value]);
|
|
23
|
+
}),
|
|
24
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.21",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./akanConfig": "./akanConfig/index.ts",
|
|
29
29
|
"./artifact": "./artifact/index.ts",
|
|
30
30
|
"./cloud": "./cloud/index.ts",
|
|
31
|
+
"./codeAgent": "./codeAgent/index.ts",
|
|
31
32
|
"./commandDecorators": "./commandDecorators/index.ts",
|
|
32
33
|
"./frontendBuild": "./frontendBuild/index.ts",
|
|
33
34
|
"./incrementalBuilder": "./incrementalBuilder/index.ts",
|
|
@@ -38,13 +39,15 @@
|
|
|
38
39
|
"./*": "./*.ts"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
42
|
+
"@earendil-works/chord": "0.86.1",
|
|
43
|
+
"@earendil-works/pi-agent-core": "0.86.1",
|
|
44
|
+
"@earendil-works/pi-ai": "0.86.1",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "0.86.1",
|
|
46
|
+
"@earendil-works/pi-telemetry": "0.86.1",
|
|
47
|
+
"@earendil-works/pi-tui": "0.86.1",
|
|
41
48
|
"@inquirer/prompts": "^8.4.3",
|
|
42
|
-
"@langchain/core": "^1.1.47",
|
|
43
|
-
"@langchain/deepseek": "^1.0.26",
|
|
44
|
-
"@langchain/openai": "^1.4.6",
|
|
45
49
|
"@tailwindcss/node": "^4.3.0",
|
|
46
|
-
"
|
|
47
|
-
"akanjs": "3.0.0-beta.2",
|
|
50
|
+
"akanjs": "3.0.0-beta.21",
|
|
48
51
|
"chalk": "^5.6.2",
|
|
49
52
|
"commander": "^14.0.3",
|
|
50
53
|
"dayjs": "^1.11.20",
|
|
@@ -56,16 +59,13 @@
|
|
|
56
59
|
"subset-font": "^2.5.0",
|
|
57
60
|
"tailwind-scrollbar": "^4.0.2",
|
|
58
61
|
"tailwindcss": "^4.3.0",
|
|
62
|
+
"typebox": "1.1.38",
|
|
59
63
|
"typescript": "^6.0.3"
|
|
60
64
|
},
|
|
61
65
|
"peerDependencies": {
|
|
62
|
-
"@capacitor/cli": "^8.3.4",
|
|
63
66
|
"react": "19.2.7"
|
|
64
67
|
},
|
|
65
68
|
"peerDependenciesMeta": {
|
|
66
|
-
"@capacitor/cli": {
|
|
67
|
-
"optional": true
|
|
68
|
-
},
|
|
69
69
|
"react": {
|
|
70
70
|
"optional": true
|
|
71
71
|
}
|
package/packageExports.test.ts
CHANGED
|
@@ -3,30 +3,17 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { PackageExportsMap } from "@akanjs/devkit/packageExportsMap";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// Inside the monorepo Bun resolves `@akanjs/devkit/executors` through the root tsconfig `paths`
|
|
9
|
-
// (`@akanjs/devkit/*` -> `pkgs/@akanjs/devkit/*`), and that resolver probes extensions and directory
|
|
10
|
-
// indexes. A published consumer has no such mapping: it goes through this package's `exports` map,
|
|
11
|
-
// whose targets are matched *exactly*. So `"./*": "./*"` type-checked, built, and passed every test
|
|
12
|
-
// here while every subpath import failed at runtime for anyone installing the tarball:
|
|
13
|
-
//
|
|
14
|
-
// error: Cannot find module '@akanjs/devkit/executors' from
|
|
15
|
-
// '<consumer>/node_modules/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts'
|
|
16
|
-
//
|
|
17
|
-
// `PackageRunner.verifyDistPackage` runs the same check against the built dist tree of every
|
|
18
|
-
// publishable package at release time; these tests keep this package honest on every run.
|
|
6
|
+
//? The monorepo resolves subpaths through tsconfig `paths`, which probe extensions; a published consumer goes through
|
|
7
|
+
//? the exports map, matched exactly — so `"./*": "./*"` passed every test here and broke every installed subpath.
|
|
19
8
|
|
|
20
9
|
const packageDir = import.meta.dir;
|
|
21
10
|
const exportsMap = await PackageExportsMap.from(packageDir);
|
|
22
11
|
|
|
23
|
-
/** Every facet the root barrel re-exports, as the subpath a consumer would import. */
|
|
24
12
|
const barrelFacets = async (): Promise<string[]> => {
|
|
25
13
|
const barrel = await Bun.file(path.join(packageDir, "index.ts")).text();
|
|
26
14
|
return [...barrel.matchAll(/^export (?:type )?\* from "\.\/([^"]+)";$/gm)].map((match) => `./${match[1]}`);
|
|
27
15
|
};
|
|
28
16
|
|
|
29
|
-
/** Every `@akanjs/devkit/<subpath>` specifier written anywhere in the two packages that use them. */
|
|
30
17
|
const importedSubpaths = async (): Promise<string[]> => {
|
|
31
18
|
const repoRoot = path.resolve(packageDir, "../../..");
|
|
32
19
|
const glob = new Bun.Glob("pkgs/@akanjs/{cli,devkit}/**/*.{ts,tsx}");
|
|
@@ -53,9 +40,7 @@ describe("published exports map", () => {
|
|
|
53
40
|
});
|
|
54
41
|
|
|
55
42
|
test("covers both facet shapes and keeps explicit extensions intact", () => {
|
|
56
|
-
//
|
|
57
|
-
// facets need their own literal entry, and `./*.ts` -> `./*.ts` keeps an already-suffixed
|
|
58
|
-
// specifier from becoming `./cloud/cloudApi.ts.ts`.
|
|
43
|
+
// One wildcard cannot serve bare files, directory facets and already-suffixed specifiers (no `.ts.ts`) at once.
|
|
59
44
|
expect(exportsMap.resolve("./executors")).toBe("./executors.ts");
|
|
60
45
|
expect(exportsMap.resolve("./frontendBuild")).toBe("./frontendBuild/index.ts");
|
|
61
46
|
expect(exportsMap.resolve("./cloud/cloudApi.ts")).toBe("./cloud/cloudApi.ts");
|
package/packageExportsMap.ts
CHANGED
|
@@ -7,17 +7,9 @@ export interface UnreachableSubpath {
|
|
|
7
7
|
target: string | null;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
* Exports targets are matched **exactly**: no extension is appended and no `index.ts` is probed. That
|
|
14
|
-
* is invisible inside this monorepo, where `@akanjs/devkit/*` and `akanjs/*` resolve through the root
|
|
15
|
-
* tsconfig `paths` instead — a resolver that *does* probe both. A map of `{"./*": "./*"}` therefore
|
|
16
|
-
* type-checks, builds, and passes every test while every subpath import fails for anyone who installs
|
|
17
|
-
* the tarball. This class exists so that gap can be asserted against before publishing.
|
|
18
|
-
*/
|
|
10
|
+
// Bun matches exports targets exactly (no extension, no index.ts), while this monorepo resolves `akanjs/*` through
|
|
11
|
+
// tsconfig paths, which probe both — so a broken map passes every in-repo test and fails for every installer.
|
|
19
12
|
export class PackageExportsMap {
|
|
20
|
-
/** Reads `<packageDir>/package.json` and builds the map from its `exports` field. */
|
|
21
13
|
static async from(packageDir: string) {
|
|
22
14
|
const manifest = (await Bun.file(path.join(packageDir, "package.json")).json()) as { exports?: unknown };
|
|
23
15
|
return new PackageExportsMap(packageDir, manifest.exports);
|
|
@@ -60,7 +52,6 @@ export class PackageExportsMap {
|
|
|
60
52
|
// wins over `./*`, which is what keeps an already-suffixed specifier from gaining a second `.ts`.
|
|
61
53
|
this.#patterns.sort((a, b) => b.prefix.length - a.prefix.length || b.suffix.length - a.suffix.length);
|
|
62
54
|
}
|
|
63
|
-
/** Returns the target an `exports` lookup yields, or null when the subpath is unexported. */
|
|
64
55
|
resolve(subpath: string): string | null {
|
|
65
56
|
const literal = this.#literals.get(subpath);
|
|
66
57
|
if (literal) return literal;
|
|
@@ -71,20 +62,13 @@ export class PackageExportsMap {
|
|
|
71
62
|
}
|
|
72
63
|
return null;
|
|
73
64
|
}
|
|
74
|
-
|
|
75
|
-
* Resolves a subpath and reports whether the target it yields is a readable file.
|
|
76
|
-
*
|
|
77
|
-
* A directory does not count. `{"./*": "./*"}` maps `./commandDecorators` onto the directory of
|
|
78
|
-
* that name, which exists but is not a module — an `existsSync` check here reports such a subpath
|
|
79
|
-
* as reachable while the import still fails.
|
|
80
|
-
*/
|
|
65
|
+
// A directory does not count: `{"./*": "./*"}` maps `./commandDecorators` onto a folder, which is not importable.
|
|
81
66
|
resolveToFile(subpath: string): { target: string | null; exists: boolean } {
|
|
82
67
|
const target = this.resolve(subpath);
|
|
83
68
|
if (!target) return { target: null, exists: false };
|
|
84
69
|
const stat = statSync(path.join(this.#packageDir, target), { throwIfNoEntry: false });
|
|
85
70
|
return { target, exists: !!stat?.isFile() };
|
|
86
71
|
}
|
|
87
|
-
/** Returns the given subpaths that no consumer could import, in input order. */
|
|
88
72
|
findUnreachable(subpaths: Iterable<string>): UnreachableSubpath[] {
|
|
89
73
|
const unreachable: UnreachableSubpath[] = [];
|
|
90
74
|
for (const subpath of subpaths) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { PackageJson } from "@akanjs/devkit/types";
|
|
4
|
+
|
|
5
|
+
//? A published install reads neither pi's npm-shrinkwrap.json nor our overrides, so PackageRunner re-declares pi's
|
|
6
|
+
//? sibling pins (a floating 0.80.x once broke every global install); this keeps them equal to the shrinkwrap.
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(import.meta.dir, "../../..");
|
|
9
|
+
const piName = "@earendil-works/pi-coding-agent";
|
|
10
|
+
const piDir = `${repoRoot}/node_modules/${piName}`;
|
|
11
|
+
const publishedPackages = ["pkgs/@akanjs/devkit/package.json", "pkgs/@akanjs/cli/package.json"];
|
|
12
|
+
|
|
13
|
+
interface Shrinkwrap {
|
|
14
|
+
packages: Record<string, { version?: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
|
|
18
|
+
|
|
19
|
+
const shrinkwrappedPins = async () => {
|
|
20
|
+
const { packages } = await readJson<Shrinkwrap>(`${piDir}/npm-shrinkwrap.json`);
|
|
21
|
+
// Top-level entries only: a nested `node_modules/` key is another package's placement, not ours to pin.
|
|
22
|
+
const entries = Object.entries(packages)
|
|
23
|
+
.filter(([key]) => /^node_modules\/@earendil-works\/[^/]+$/.test(key))
|
|
24
|
+
.map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const);
|
|
25
|
+
return Object.fromEntries(entries);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("pi dependency pins", () => {
|
|
29
|
+
test("the shrinkwrap read here belongs to the coding agent we publish", async () => {
|
|
30
|
+
const installed = await readJson<PackageJson>(`${piDir}/package.json`);
|
|
31
|
+
for (const file of publishedPackages) {
|
|
32
|
+
const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
|
|
33
|
+
expect({ file, [piName]: dependencies[piName] }).toEqual({ file, [piName]: installed.version });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every sibling pi package is published at the version the coding agent shrinkwrapped", async () => {
|
|
38
|
+
const pins = await shrinkwrappedPins();
|
|
39
|
+
expect(Object.keys(pins).length).toBeGreaterThan(0);
|
|
40
|
+
for (const file of publishedPackages) {
|
|
41
|
+
const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
|
|
42
|
+
const declared = Object.fromEntries(Object.keys(pins).map((name) => [name, dependencies[name]]));
|
|
43
|
+
expect({ file, ...declared }).toEqual({ file, ...pins });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BunTestReport } from "./BunTestReport";
|
|
3
|
+
|
|
4
|
+
describe("BunTestReport", () => {
|
|
5
|
+
test("reads the final summary and attributes each failure to the file header above it", () => {
|
|
6
|
+
const report = new BunTestReport(
|
|
7
|
+
[
|
|
8
|
+
"bun test v1.4.2 (744846f84)",
|
|
9
|
+
"",
|
|
10
|
+
"server\\artifact\\routeSeedIndex.test.ts:",
|
|
11
|
+
"(pass) route seed index > keeps seeds [0.40ms]",
|
|
12
|
+
"(fail) route seed index > restores relative seed paths [1.20ms]",
|
|
13
|
+
"",
|
|
14
|
+
"lib/linter.test.ts:",
|
|
15
|
+
"\u001b[31m(fail) Linter > finds biome [3.00ms]\u001b[0m",
|
|
16
|
+
"",
|
|
17
|
+
"2 tests failed:",
|
|
18
|
+
"(fail) route seed index > restores relative seed paths [1.20ms]",
|
|
19
|
+
"(fail) Linter > finds biome [3.00ms]",
|
|
20
|
+
"",
|
|
21
|
+
" 1 pass",
|
|
22
|
+
" 3 skip",
|
|
23
|
+
" 2 fail",
|
|
24
|
+
" 1 error",
|
|
25
|
+
"Ran 6 tests across 2 files. [120.00ms]",
|
|
26
|
+
].join("\r\n"),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(report.hasSummary).toBe(true);
|
|
30
|
+
expect([report.pass, report.fail, report.skip, report.errors, report.files]).toEqual([1, 2, 3, 1, 2]);
|
|
31
|
+
expect(report.failures).toEqual([
|
|
32
|
+
{ file: "server/artifact/routeSeedIndex.test.ts", name: "route seed index > restores relative seed paths" },
|
|
33
|
+
{ file: "lib/linter.test.ts", name: "Linter > finds biome" },
|
|
34
|
+
]);
|
|
35
|
+
expect(report.passed).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a run with no summary is not passed, and counts what it saw before it stopped", () => {
|
|
39
|
+
const report = new BunTestReport(
|
|
40
|
+
[
|
|
41
|
+
"server/akanApp.test.ts:",
|
|
42
|
+
"(pass) AkanApp > boots [1ms]",
|
|
43
|
+
"(pass) AkanApp > relays [2ms]",
|
|
44
|
+
"panic(main thread)",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(report.hasSummary).toBe(false);
|
|
49
|
+
expect(report.passed).toBe(false);
|
|
50
|
+
expect(report.pass).toBe(2);
|
|
51
|
+
expect(report.lastFile).toBe("server/akanApp.test.ts");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a clean summary passes", () => {
|
|
55
|
+
const report = new BunTestReport(" 4 pass\n 0 fail\nRan 4 tests across 1 file. [12.00ms]\n");
|
|
56
|
+
|
|
57
|
+
expect(report.passed).toBe(true);
|
|
58
|
+
expect(report.files).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
export interface BunTestFailure {
|
|
4
|
+
file: string | null;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class BunTestReport {
|
|
9
|
+
static readonly #fileHeaderPattern = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/;
|
|
10
|
+
//? After the last file, bun repeats every failure under an "N tests failed:" line — those belong to no header.
|
|
11
|
+
static readonly #relistPattern = /^\d+ tests? failed:$/;
|
|
12
|
+
static readonly #durationSuffix = /\s+\[[\d.]+m?s\]$/;
|
|
13
|
+
|
|
14
|
+
readonly pass: number;
|
|
15
|
+
readonly fail: number;
|
|
16
|
+
readonly skip: number;
|
|
17
|
+
readonly errors: number;
|
|
18
|
+
readonly files: number;
|
|
19
|
+
readonly hasSummary: boolean;
|
|
20
|
+
readonly failures: BunTestFailure[];
|
|
21
|
+
readonly lastFile: string | null;
|
|
22
|
+
|
|
23
|
+
constructor(output: string) {
|
|
24
|
+
const lines = stripVTControlCharacters(output).split(/\r?\n/);
|
|
25
|
+
let currentFile: string | null = null;
|
|
26
|
+
let lastFile: string | null = null;
|
|
27
|
+
let relisted = false;
|
|
28
|
+
const failures = new Map<string, BunTestFailure>();
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (BunTestReport.#relistPattern.test(line)) relisted = true;
|
|
31
|
+
const header = BunTestReport.#fileHeaderPattern.exec(line);
|
|
32
|
+
if (header?.[1] && !relisted) {
|
|
33
|
+
currentFile = header[1].replaceAll("\\", "/");
|
|
34
|
+
lastFile = currentFile;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!line.startsWith("(fail) ")) continue;
|
|
38
|
+
const name = line.slice("(fail) ".length).replace(BunTestReport.#durationSuffix, "");
|
|
39
|
+
if (!failures.has(name)) failures.set(name, { file: relisted ? null : currentFile, name });
|
|
40
|
+
}
|
|
41
|
+
const ran = lines.findLast((line) => /^Ran \d+ tests? across \d+ files?/.test(line));
|
|
42
|
+
this.hasSummary = !!ran;
|
|
43
|
+
this.files = ran ? Number(/across (\d+) files?/.exec(ran)?.[1] ?? 0) : 0;
|
|
44
|
+
this.failures = [...failures.values()];
|
|
45
|
+
this.lastFile = lastFile;
|
|
46
|
+
this.pass = ran
|
|
47
|
+
? BunTestReport.#lastCount(lines, /^\s*(\d+) pass$/)
|
|
48
|
+
: lines.filter((line) => line.startsWith("(pass) ")).length;
|
|
49
|
+
this.fail = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) fail$/) : this.failures.length;
|
|
50
|
+
this.skip = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) skip$/) : 0;
|
|
51
|
+
this.errors = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) errors?$/) : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get passed() {
|
|
55
|
+
return this.hasSummary && this.fail === 0 && this.errors === 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static #lastCount(lines: string[], pattern: RegExp) {
|
|
59
|
+
for (const line of lines.toReversed()) {
|
|
60
|
+
const count = pattern.exec(line)?.[1];
|
|
61
|
+
if (count) return Number(count);
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs } from "../testHelpers";
|
|
5
|
+
import { IdleWatchedProcess } from "./IdleWatchedProcess";
|
|
6
|
+
|
|
7
|
+
const makeRoot = tempDirs("akan-idle-watch-");
|
|
8
|
+
const makeLogPath = async () => path.join(await makeRoot(), "nested", "run.log");
|
|
9
|
+
|
|
10
|
+
describe("IdleWatchedProcess", () => {
|
|
11
|
+
test("tees the output of a command that finishes into the log and reports its exit code", async () => {
|
|
12
|
+
const logPath = await makeLogPath();
|
|
13
|
+
const result = await new IdleWatchedProcess(
|
|
14
|
+
[process.execPath, "--eval", "console.log('out'); console.error('err'); process.exit(3)"],
|
|
15
|
+
{ logPath, idleMs: 10_000 },
|
|
16
|
+
).run();
|
|
17
|
+
|
|
18
|
+
expect(result.exitCode).toBe(3);
|
|
19
|
+
expect(result.hung).toBe(false);
|
|
20
|
+
expect(result.output).toContain("out");
|
|
21
|
+
expect(result.output).toContain("err");
|
|
22
|
+
expect(await readFile(logPath, "utf8")).toContain("out");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("gives up on a command that goes quiet, after asking the caller to clean up", async () => {
|
|
26
|
+
const logPath = await makeLogPath();
|
|
27
|
+
let cleanedUp = false;
|
|
28
|
+
const result = await new IdleWatchedProcess(
|
|
29
|
+
[process.execPath, "--eval", "console.log('started'); setInterval(() => {}, 1000)"],
|
|
30
|
+
{
|
|
31
|
+
logPath,
|
|
32
|
+
idleMs: 400,
|
|
33
|
+
onIdle: async () => {
|
|
34
|
+
cleanedUp = true;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
).run();
|
|
38
|
+
|
|
39
|
+
expect(result.hung).toBe(true);
|
|
40
|
+
expect(result.exitCode).toBeNull();
|
|
41
|
+
expect(cleanedUp).toBe(true);
|
|
42
|
+
expect(result.output).toContain("started");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a command that keeps talking is not idle, but still stops at the total budget", async () => {
|
|
46
|
+
const logPath = await makeLogPath();
|
|
47
|
+
const result = await new IdleWatchedProcess(
|
|
48
|
+
[process.execPath, "--eval", "setInterval(() => console.log('tick'), 50)"],
|
|
49
|
+
{ logPath, idleMs: 5_000, totalMs: 500 },
|
|
50
|
+
).run();
|
|
51
|
+
|
|
52
|
+
expect(result.hung).toBe(false);
|
|
53
|
+
expect(result.timedOut).toBe(true);
|
|
54
|
+
expect(result.exitCode).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface IdleWatchedResult {
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
hung: boolean;
|
|
7
|
+
timedOut: boolean;
|
|
8
|
+
output: string;
|
|
9
|
+
ms: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface IdleWatchedOptions {
|
|
13
|
+
logPath: string;
|
|
14
|
+
idleMs: number;
|
|
15
|
+
totalMs?: number;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
onIdle?: () => Promise<void>;
|
|
19
|
+
onOutput?: (text: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Silence is the only reliable hang signal: a runner stuck in a synchronous loop never lets bun's test timeout fire.
|
|
23
|
+
export class IdleWatchedProcess {
|
|
24
|
+
readonly #command: string[];
|
|
25
|
+
readonly #options: IdleWatchedOptions;
|
|
26
|
+
|
|
27
|
+
constructor(command: string[], options: IdleWatchedOptions) {
|
|
28
|
+
this.#command = command;
|
|
29
|
+
this.#options = options;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async run(): Promise<IdleWatchedResult> {
|
|
33
|
+
const { logPath, idleMs, totalMs, cwd, env, onIdle, onOutput } = this.#options;
|
|
34
|
+
await mkdir(path.dirname(logPath), { recursive: true });
|
|
35
|
+
const log = Bun.file(logPath).writer();
|
|
36
|
+
const startedAt = performance.now();
|
|
37
|
+
let lastOutputAt = startedAt;
|
|
38
|
+
let output = "";
|
|
39
|
+
let hung = false;
|
|
40
|
+
let timedOut = false;
|
|
41
|
+
const proc = Bun.spawn(this.#command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
42
|
+
const pump = async (stream: ReadableStream<Uint8Array>) => {
|
|
43
|
+
const decoder = new TextDecoder();
|
|
44
|
+
for await (const chunk of stream) {
|
|
45
|
+
lastOutputAt = performance.now();
|
|
46
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
47
|
+
output += text;
|
|
48
|
+
log.write(chunk);
|
|
49
|
+
onOutput?.(text);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const pumping = Promise.all([pump(proc.stdout), pump(proc.stderr)]);
|
|
53
|
+
const watch = setInterval(
|
|
54
|
+
() => {
|
|
55
|
+
const now = performance.now();
|
|
56
|
+
if (now - lastOutputAt >= idleMs) hung = true;
|
|
57
|
+
else if (totalMs !== undefined && now - startedAt >= totalMs) timedOut = true;
|
|
58
|
+
else return;
|
|
59
|
+
clearInterval(watch);
|
|
60
|
+
void (async () => {
|
|
61
|
+
await onIdle?.().catch(() => undefined);
|
|
62
|
+
proc.kill();
|
|
63
|
+
})();
|
|
64
|
+
},
|
|
65
|
+
Math.max(50, Math.min(1000, idleMs / 4)),
|
|
66
|
+
);
|
|
67
|
+
const exitCode = await proc.exited;
|
|
68
|
+
clearInterval(watch);
|
|
69
|
+
await pumping;
|
|
70
|
+
await log.end();
|
|
71
|
+
return { exitCode: hung || timedOut ? null : exitCode, hung, timedOut, output, ms: performance.now() - startedAt };
|
|
72
|
+
}
|
|
73
|
+
}
|