@akanjs/devkit 3.1.0-rc.2 → 3.1.0-rc.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/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.ts +2 -2
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +24 -1
- package/applicationBuildRunner.ts +11 -1
- package/artifact/implicitRootLayout.test.ts +2 -0
- package/artifact/implicitRootLayout.ts +1 -0
- package/biome.base.json +3 -12
- package/biomeBase.ts +1 -1
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.ts +3 -2
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +128 -29
- package/codeAgent/tools/akanCodePlugins.test.ts +143 -16
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +52 -14
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +130 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -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 +108 -3
- package/executors.ts +30 -13
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +5 -3
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- 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/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/mobileTarget.ts +1 -2
- package/mobile/nativeApp.test.ts +1 -1
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +3 -2
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/updateUploader.test.ts +1 -0
- package/mobile/updateUploader.ts +3 -3
- package/package.json +20 -20
- package/piDependencyPins.test.ts +18 -19
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +37 -4
- package/subspaceClone.ts +5 -4
- 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/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- package/workflow/source.ts +2 -2
package/mobile/updateUploader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
|
|
4
|
-
import { isRecord } from "akanjs/common";
|
|
4
|
+
import { isRecord, trimTrailingSlashes } from "akanjs/common";
|
|
5
5
|
import { UpdatePackFolder } from "./updatePackFolder";
|
|
6
6
|
|
|
7
7
|
/** What the uploader needs of an S3 client; Bun.S3Client is one. */
|
|
@@ -32,9 +32,9 @@ export class UpdateUploader {
|
|
|
32
32
|
|
|
33
33
|
/** `s3://bucket/prefix`: the credentials and endpoint come from S3_* (or AWS_*) in the environment. */
|
|
34
34
|
static parseTarget(target: string): { bucket: string; prefix: string } {
|
|
35
|
-
const match = /^s3:\/\/([^/]+)
|
|
35
|
+
const match = /^s3:\/\/([^/]+)(?:\/(.*))?$/.exec(target.trim());
|
|
36
36
|
if (!match?.[1]) throw new Error(`--upload takes s3://<bucket>/<prefix>, not ${JSON.stringify(target)}.`);
|
|
37
|
-
return { bucket: match[1], prefix: (match[2] ?? "")
|
|
37
|
+
return { bucket: match[1], prefix: trimTrailingSlashes(match[2] ?? "") };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
static order(files: string[]): { releases: string[]; manifests: string[] } {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.21",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -40,31 +40,31 @@
|
|
|
40
40
|
"./*": "./*.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@earendil-works/
|
|
44
|
-
"@earendil-works/pi-
|
|
45
|
-
"@earendil-works/pi-
|
|
46
|
-
"@earendil-works/pi-coding-agent": "
|
|
47
|
-
"@earendil-works/pi-
|
|
48
|
-
"@earendil-works/pi-tui": "
|
|
49
|
-
"@inquirer/prompts": "^8.
|
|
50
|
-
"@tailwindcss/node": "^4.3.
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
52
|
-
"chalk": "^
|
|
53
|
-
"commander": "^
|
|
54
|
-
"dayjs": "^1.11.
|
|
55
|
-
"fontaine": "^0.
|
|
43
|
+
"@earendil-works/pi-agent-core": "1.3.0",
|
|
44
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
45
|
+
"@earendil-works/pi-codemode": "1.3.0",
|
|
46
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
47
|
+
"@earendil-works/pi-mcp": "1.3.0",
|
|
48
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
49
|
+
"@inquirer/prompts": "^8.7.3",
|
|
50
|
+
"@tailwindcss/node": "^4.3.3",
|
|
51
|
+
"akanjs": "3.1.0-rc.21",
|
|
52
|
+
"chalk": "^6.0.1",
|
|
53
|
+
"commander": "^15.0.0",
|
|
54
|
+
"dayjs": "^1.11.23",
|
|
55
|
+
"fontaine": "^1.0.1",
|
|
56
56
|
"fonteditor-core": "^2.6.3",
|
|
57
|
-
"ignore": "^7.0.
|
|
58
|
-
"ora": "^9.4.
|
|
57
|
+
"ignore": "^7.0.12",
|
|
58
|
+
"ora": "^9.4.1",
|
|
59
59
|
"ssh2": "^1.17.0",
|
|
60
|
-
"subset-font": "^2.
|
|
60
|
+
"subset-font": "^2.9.0",
|
|
61
61
|
"tailwind-scrollbar": "^4.0.2",
|
|
62
|
-
"tailwindcss": "^4.3.
|
|
63
|
-
"typebox": "1.
|
|
62
|
+
"tailwindcss": "^4.3.3",
|
|
63
|
+
"typebox": "1.3.37",
|
|
64
64
|
"typescript": "^6.0.3"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"react": "19.3.
|
|
67
|
+
"react": "19.3.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependenciesMeta": {
|
|
70
70
|
"react": {
|
package/piDependencyPins.test.ts
CHANGED
|
@@ -2,31 +2,24 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { PackageJson } from "@akanjs/devkit/types";
|
|
4
4
|
|
|
5
|
-
//?
|
|
6
|
-
//?
|
|
5
|
+
//? PackageRunner re-declares pi's sibling pins because neither our `overrides` nor pi's own ranges reach a
|
|
6
|
+
//? consumer's install. The source of truth is pi's `dependencies`: since 1.3.0 it pins every sibling exactly, where
|
|
7
|
+
//? 0.86.1 floated `^0.86.1` and only the (now removed) `npm-shrinkwrap.json` held the resolved version.
|
|
7
8
|
|
|
8
9
|
const repoRoot = path.resolve(import.meta.dir, "../../..");
|
|
9
10
|
const piName = "@earendil-works/pi-coding-agent";
|
|
10
11
|
const piDir = `${repoRoot}/node_modules/${piName}`;
|
|
11
12
|
const publishedPackages = ["pkgs/@akanjs/devkit/package.json", "pkgs/@akanjs/cli/package.json"];
|
|
12
13
|
|
|
13
|
-
interface Shrinkwrap {
|
|
14
|
-
packages: Record<string, { version?: string }>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
14
|
const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
|
|
18
15
|
|
|
19
|
-
const
|
|
20
|
-
const {
|
|
21
|
-
|
|
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);
|
|
16
|
+
const siblingEntries = async () => {
|
|
17
|
+
const { dependencies = {} } = await readJson<PackageJson>(`${piDir}/package.json`);
|
|
18
|
+
return Object.entries(dependencies).filter(([name]) => /^@earendil-works\/[^/]+$/.test(name));
|
|
26
19
|
};
|
|
27
20
|
|
|
28
21
|
describe("pi dependency pins", () => {
|
|
29
|
-
test("the
|
|
22
|
+
test("the package read here belongs to the coding agent we publish", async () => {
|
|
30
23
|
const installed = await readJson<PackageJson>(`${piDir}/package.json`);
|
|
31
24
|
for (const file of publishedPackages) {
|
|
32
25
|
const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
|
|
@@ -34,13 +27,19 @@ describe("pi dependency pins", () => {
|
|
|
34
27
|
}
|
|
35
28
|
});
|
|
36
29
|
|
|
37
|
-
test("
|
|
38
|
-
const
|
|
39
|
-
expect(
|
|
30
|
+
test("the coding agent pins each sibling exactly", async () => {
|
|
31
|
+
const siblings = await siblingEntries();
|
|
32
|
+
expect(siblings.length).toBeGreaterThan(0);
|
|
33
|
+
const floating = siblings.filter(([, version]) => !/^\d+\.\d+\.\d+/.test(version));
|
|
34
|
+
expect(floating).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every sibling pi package is published at the version the coding agent pins", async () => {
|
|
38
|
+
const siblings = await siblingEntries();
|
|
40
39
|
for (const file of publishedPackages) {
|
|
41
40
|
const { dependencies = {} } = await readJson<PackageJson>(`${repoRoot}/${file}`);
|
|
42
|
-
const declared = Object.fromEntries(
|
|
43
|
-
expect({ file, ...declared }).toEqual({ file, ...
|
|
41
|
+
const declared = Object.fromEntries(siblings.map(([name]) => [name, dependencies[name]]));
|
|
42
|
+
expect({ file, ...declared }).toEqual({ file, ...Object.fromEntries(siblings) });
|
|
44
43
|
}
|
|
45
44
|
});
|
|
46
45
|
});
|
|
@@ -19,9 +19,10 @@ export class LinuxDockerTarget extends PlatformTestTarget {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
//* No compiler in the image: with one, ssh2 builds its native addon and Bun panics loading it (oven-sh/bun#18546).
|
|
22
|
+
//* apt goes over https: a captive portal answers plain http with its splash page, which apt reports as unsigned.
|
|
22
23
|
static readonly dockerfile = [
|
|
23
24
|
`FROM oven/bun:${Bun.version}-slim`,
|
|
24
|
-
"RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
|
|
25
|
+
"RUN sed -i 's|http://|https://|g' /etc/apt/sources.list.d/debian.sources && apt-get update && apt-get install -y --no-install-recommends git ca-certificates procps lsof && rm -rf /var/lib/apt/lists/*",
|
|
25
26
|
].join("\n");
|
|
26
27
|
|
|
27
28
|
get image() {
|
|
@@ -108,14 +108,16 @@ export abstract class PlatformTestTarget {
|
|
|
108
108
|
const logPath = this.#logPath(context, "install");
|
|
109
109
|
const install = await this.exec("bun install --frozen-lockfile", logPath, PlatformTestTarget.installIdleMs);
|
|
110
110
|
if (install.exitCode === 0) return;
|
|
111
|
-
const
|
|
112
|
-
(
|
|
113
|
-
|
|
111
|
+
const failedPackages = [
|
|
112
|
+
...install.output.matchAll(
|
|
113
|
+
/(?:pre|post)?install script from "([^"]+)" exited|failed to link binaries for package: (@?[^@\s]+)@/g,
|
|
114
|
+
),
|
|
115
|
+
].map((match) => match[1] ?? match[2] ?? "");
|
|
114
116
|
const tolerable =
|
|
115
|
-
|
|
117
|
+
failedPackages.length > 0 && failedPackages.every((pkg) => this.tolerateScriptFailures.includes(pkg));
|
|
116
118
|
if (!tolerable) throw new Error(`bun install failed — see ${logPath}`);
|
|
117
119
|
//* A failed lifecycle script makes bun skip linking the root `.bin`; a second pass without scripts links it.
|
|
118
|
-
envWarnings.push(`install
|
|
120
|
+
envWarnings.push(`install failed for ${failedPackages.join(", ")} — tolerated on ${this.platform}`);
|
|
119
121
|
const relink = await this.exec(
|
|
120
122
|
"bun install --frozen-lockfile --ignore-scripts",
|
|
121
123
|
this.#logPath(context, "install-relink"),
|
|
@@ -9,7 +9,8 @@ export class WindowsSshTarget extends PlatformTestTarget {
|
|
|
9
9
|
static readonly utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
|
|
10
10
|
static readonly defaultWorkRoot = "C:\\akan-test";
|
|
11
11
|
//* arm64 has neither a sharp@0.32 prebuilt nor a Chrome for puppeteer's postinstall; x64 users get both.
|
|
12
|
-
|
|
12
|
+
//* Nor a msgpackr-extract prebuilt: bun deletes it when node-gyp fails, racing the link of msgpackr's bins.
|
|
13
|
+
static readonly defaultTolerateScriptFailures = ["sharp", "puppeteer", "msgpackr"];
|
|
13
14
|
static readonly #runDirPattern = "^\\d{8}-\\d{6}$";
|
|
14
15
|
static readonly #keptRuns = 2;
|
|
15
16
|
|
package/qualityScanner.ts
CHANGED
|
@@ -215,7 +215,7 @@ export class AkanQualityScanner {
|
|
|
215
215
|
if (!(await isDirectory(absoluteTargetRoot))) continue;
|
|
216
216
|
await this.#walkTargetFiles(workspaceRoot, absoluteTargetRoot, ignoreFilter, files);
|
|
217
217
|
}
|
|
218
|
-
return files.sort();
|
|
218
|
+
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
async #readGitIgnore(workspaceRoot: string) {
|
package/recipeScanner.test.ts
CHANGED
|
@@ -48,7 +48,10 @@ export default function Page() {
|
|
|
48
48
|
describe("scanRecipes", () => {
|
|
49
49
|
test("detects framework recipes with full variant surface", () => {
|
|
50
50
|
const recipes = scanRecipes([{ path: "recipe.ts", content: FRAMEWORK, importFrom: "akanjs/ui" }]);
|
|
51
|
-
expect(recipes.map((r) => r.name).sort()).toEqual([
|
|
51
|
+
expect(recipes.map((r) => r.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
52
|
+
"badgeRecipe",
|
|
53
|
+
"buttonRecipe",
|
|
54
|
+
]);
|
|
52
55
|
|
|
53
56
|
const button = byName(recipes, "buttonRecipe");
|
|
54
57
|
expect(button?.importFrom).toBe("akanjs/ui");
|
|
@@ -91,7 +94,10 @@ describe("scanRecipes", () => {
|
|
|
91
94
|
{ path: "a.ts", content: src, importFrom: "@a" },
|
|
92
95
|
{ path: "b.ts", content: `export const other = recipe(tv({ base: "c" }));`, importFrom: "@b" },
|
|
93
96
|
]);
|
|
94
|
-
expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort()).toEqual([
|
|
97
|
+
expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
98
|
+
"other@@b",
|
|
99
|
+
"shown@@a",
|
|
100
|
+
]);
|
|
95
101
|
});
|
|
96
102
|
});
|
|
97
103
|
|
|
@@ -154,7 +160,7 @@ describe("collectRecipeSources", () => {
|
|
|
154
160
|
expect(
|
|
155
161
|
scanRecipes(sources)
|
|
156
162
|
.map((r) => r.name)
|
|
157
|
-
.sort(),
|
|
163
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
158
164
|
).toEqual(["appBox", "appCard"]);
|
|
159
165
|
});
|
|
160
166
|
|
package/scanInfo.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type {
|
|
3
4
|
AppConfigResult,
|
|
@@ -113,7 +114,7 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
|
|
|
113
114
|
if (violations.length > 0) {
|
|
114
115
|
throw new Error(
|
|
115
116
|
`[scan-convention]\n${violations
|
|
116
|
-
.sort()
|
|
117
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
117
118
|
.map((violation) => `- ${violation}`)
|
|
118
119
|
.join("\n")}`,
|
|
119
120
|
);
|
|
@@ -295,11 +296,19 @@ class ScanInfo {
|
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
export const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
|
|
299
|
+
export interface MigrationScanEntry {
|
|
300
|
+
id: string;
|
|
301
|
+
checksum: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
298
304
|
export class AppInfo extends ScanInfo {
|
|
305
|
+
static readonly migrationDir = "script/migration";
|
|
306
|
+
static readonly #migrationIdRe = /^\d{12}_[A-Za-z][A-Za-z0-9]*$/;
|
|
299
307
|
readonly type = "app";
|
|
300
308
|
readonly exec: AppExecutor;
|
|
301
309
|
readonly akanConfig: AppConfigResult;
|
|
302
310
|
readonly libDeps: string[];
|
|
311
|
+
readonly migrations: MigrationScanEntry[];
|
|
303
312
|
|
|
304
313
|
static appInfos = new Map<string, AppInfo>();
|
|
305
314
|
static async fromExecutor(exec: AppExecutor, options: { refresh?: boolean } = {}) {
|
|
@@ -314,17 +323,21 @@ export class AppInfo extends ScanInfo {
|
|
|
314
323
|
LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
|
|
315
324
|
}),
|
|
316
325
|
);
|
|
317
|
-
const libDeps = await
|
|
318
|
-
|
|
326
|
+
const [libDeps, migrations] = await Promise.all([
|
|
327
|
+
AppInfo.#getAllLibDeps(exec, scanResult.libDeps),
|
|
328
|
+
AppInfo.scanMigrations(exec),
|
|
329
|
+
]);
|
|
330
|
+
const appInfo = new AppInfo(exec, scanResult as AppScanResult, libDeps, migrations);
|
|
319
331
|
AppInfo.appInfos.set(exec.name, appInfo);
|
|
320
332
|
return appInfo;
|
|
321
333
|
}
|
|
322
334
|
|
|
323
|
-
constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[]) {
|
|
335
|
+
constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[], migrations: MigrationScanEntry[] = []) {
|
|
324
336
|
super(scanResult);
|
|
325
337
|
this.exec = exec;
|
|
326
338
|
this.akanConfig = scanResult.akanConfig;
|
|
327
339
|
this.libDeps = libDeps;
|
|
340
|
+
this.migrations = migrations;
|
|
328
341
|
}
|
|
329
342
|
override getScanResult(): AppScanResult {
|
|
330
343
|
return this.scanResult as AppScanResult;
|
|
@@ -334,6 +347,26 @@ export class AppInfo extends ScanInfo {
|
|
|
334
347
|
(this.scanResult as AppScanResult).routes = routes;
|
|
335
348
|
}
|
|
336
349
|
|
|
350
|
+
//* Line endings and a BOM dropped: an editor or autocrlf would otherwise record another checksum for the same script.
|
|
351
|
+
static async scanMigrations(exec: AppExecutor) {
|
|
352
|
+
const files = (await exec.readdir(AppInfo.migrationDir)).filter(
|
|
353
|
+
(file) => file.endsWith(".ts") && !/\.(test|spec|d)\.ts$/.test(file),
|
|
354
|
+
);
|
|
355
|
+
return await Promise.all(
|
|
356
|
+
files.map(async (file) => {
|
|
357
|
+
const id = file.slice(0, -".ts".length);
|
|
358
|
+
if (!AppInfo.#migrationIdRe.test(id))
|
|
359
|
+
throw new Error(
|
|
360
|
+
`${AppInfo.migrationDir}/${file}: a migration is named <yyyyMMddHHmm>_<camelName>.ts; create one with \`akan create-migration\``,
|
|
361
|
+
);
|
|
362
|
+
const source = (await exec.readFile(`${AppInfo.migrationDir}/${file}`))
|
|
363
|
+
.replace(/^\uFEFF/, "")
|
|
364
|
+
.replaceAll("\r\n", "\n");
|
|
365
|
+
return { id, checksum: createHash("sha256").update(source).digest("hex") };
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
337
370
|
static async #getAllLibDeps(exec: AppExecutor, libDeps: string[], libSet = new Set<string>()) {
|
|
338
371
|
await Promise.all(
|
|
339
372
|
libDeps.map(async (libName) => {
|
package/subspaceClone.ts
CHANGED
|
@@ -221,19 +221,20 @@ export class SubspaceClone {
|
|
|
221
221
|
|
|
222
222
|
// An allowlist, not a filter, so no secret can reach a customer's clone; `AKAN_PUBLIC_*` ships in every bundle anyway.
|
|
223
223
|
#localEnv() {
|
|
224
|
-
const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
224
|
+
const { serveDomain, defaulted = [] } = WorkspaceExecutor.getBaseDevEnv();
|
|
225
225
|
return {
|
|
226
226
|
AKAN_WORKSPACE_ID: "local",
|
|
227
227
|
AKAN_PUBLIC_REPO_NAME: this.#declaration.name,
|
|
228
|
-
|
|
228
|
+
//? Written down, the local default would read as chosen and pass the clone's build check for a real environment.
|
|
229
|
+
...(defaulted.includes("AKAN_PUBLIC_SERVE_DOMAIN") ? {} : { AKAN_PUBLIC_SERVE_DOMAIN: serveDomain }),
|
|
229
230
|
AKAN_PUBLIC_ENV: "local",
|
|
230
231
|
AKAN_PUBLIC_OPERATION_MODE: "local",
|
|
231
232
|
AKAN_PUBLIC_LOG_LEVEL: "warn",
|
|
232
233
|
};
|
|
233
234
|
}
|
|
234
235
|
|
|
235
|
-
//
|
|
236
|
-
// `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
|
|
236
|
+
// No `.env` arrives with the slice, and the clone runs under the customer's repo name in a local env. Excluded
|
|
237
|
+
// through `.git/info/exclude`, not the copied `.gitignore`, so it can never be committed to the customer's repo.
|
|
237
238
|
async #writeLocalEnv() {
|
|
238
239
|
const excludePath = path.join(this.#clonePath, ".git/info/exclude");
|
|
239
240
|
const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
|
package/testHelpers.ts
CHANGED
|
@@ -80,6 +80,8 @@ export const createFakeExecutor = <Extra extends object = object>(
|
|
|
80
80
|
export const makeCliTempWorkspace = async (parentDir = os.tmpdir()) => {
|
|
81
81
|
await mkdir(parentDir, { recursive: true });
|
|
82
82
|
const root = await mkdtemp(path.join(parentDir, "akan-cli-"));
|
|
83
|
+
//? The CLI recognises a workspace root by an apps/, libs/ or pkgs/ folder beside package.json and tsconfig.json.
|
|
84
|
+
await mkdir(path.join(root, "apps"));
|
|
83
85
|
await writeText(path.join(root, ".gitignore"), "");
|
|
84
86
|
await writeText(
|
|
85
87
|
path.join(root, ".env"),
|
|
@@ -195,7 +195,7 @@ const resolvePackageExport = (exportsMap: Record<string, ExportValue> | undefine
|
|
|
195
195
|
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
|
|
196
196
|
const wildcard = subpath.slice(prefix.length, subpath.length - suffix.length);
|
|
197
197
|
const resolved = resolveExportValue(value);
|
|
198
|
-
if (resolved) return resolved.
|
|
198
|
+
if (resolved) return resolved.split("*").join(wildcard);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
return null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
5
|
+
import { createBrowserDirnamePlugin } from "./browserDirnamePlugin";
|
|
6
|
+
|
|
7
|
+
const makeTempRoot = tempDirs("akan-devkit-browser-dirname-");
|
|
8
|
+
|
|
9
|
+
const build = async (entry: string) => {
|
|
10
|
+
const result = await Bun.build({
|
|
11
|
+
entrypoints: [entry],
|
|
12
|
+
target: "browser",
|
|
13
|
+
minify: true,
|
|
14
|
+
plugins: [createBrowserDirnamePlugin()],
|
|
15
|
+
});
|
|
16
|
+
expect(result.success).toBe(true);
|
|
17
|
+
return (await Promise.all(result.outputs.map((output) => output.text()))).join("\n");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("createBrowserDirnamePlugin", () => {
|
|
21
|
+
test("gives a vendor module its path from node_modules on, never the absolute one", async () => {
|
|
22
|
+
const root = await makeTempRoot();
|
|
23
|
+
await writeText(path.join(root, "node_modules/cjsdep/package.json"), '{ "name": "cjsdep", "main": "index.js" }\n');
|
|
24
|
+
await writeText(
|
|
25
|
+
path.join(root, "node_modules/cjsdep/index.js"),
|
|
26
|
+
"module.exports = { dir: __dirname, file: __filename };\n",
|
|
27
|
+
);
|
|
28
|
+
await writeText(
|
|
29
|
+
path.join(root, "node_modules/esmdep/package.json"),
|
|
30
|
+
'{ "name": "esmdep", "type": "module", "main": "index.js" }\n',
|
|
31
|
+
);
|
|
32
|
+
await writeText(
|
|
33
|
+
path.join(root, "node_modules/esmdep/index.js"),
|
|
34
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: the fixture module's own source holds a template literal
|
|
35
|
+
'export const dir = typeof __dirname !== "undefined" ? `${__dirname}/` : "browser";\n',
|
|
36
|
+
);
|
|
37
|
+
await writeText(path.join(root, "node_modules/esmdep/legacy.cjs"), "module.exports = __filename;\n");
|
|
38
|
+
const entry = path.join(root, "entry.ts");
|
|
39
|
+
await writeText(
|
|
40
|
+
entry,
|
|
41
|
+
'import cjs from "cjsdep";\nimport { dir } from "esmdep";\nimport legacy from "esmdep/legacy.cjs";\nconsole.info(cjs, dir, legacy);\n',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const output = await build(entry);
|
|
45
|
+
|
|
46
|
+
expect(output).not.toContain(root);
|
|
47
|
+
expect(output).not.toContain(realpathSync(root));
|
|
48
|
+
expect(output).toContain('"/node_modules/cjsdep"');
|
|
49
|
+
expect(output).toContain('"/node_modules/cjsdep/index.js"');
|
|
50
|
+
expect(output).toContain('"/node_modules/esmdep/"');
|
|
51
|
+
expect(output).toContain('"/node_modules/esmdep/legacy.cjs"');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("leaves a module's own __dirname binding alone", async () => {
|
|
55
|
+
const root = await makeTempRoot();
|
|
56
|
+
await writeText(path.join(root, "node_modules/owndep/package.json"), '{ "name": "owndep", "main": "index.js" }\n');
|
|
57
|
+
await writeText(
|
|
58
|
+
path.join(root, "node_modules/owndep/index.js"),
|
|
59
|
+
'const __dirname = "own-binding";\nmodule.exports = __dirname;\n',
|
|
60
|
+
);
|
|
61
|
+
const entry = path.join(root, "entry.ts");
|
|
62
|
+
await writeText(entry, 'import own from "owndep";\nconsole.info(own);\n');
|
|
63
|
+
|
|
64
|
+
const output = await build(entry);
|
|
65
|
+
|
|
66
|
+
expect(output).toContain('"own-binding"');
|
|
67
|
+
expect(output).not.toContain("/node_modules/owndep");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
//? `define` would leak here: Bun applies it to the server modules a macro runs, which then read `/`.
|
|
71
|
+
test("keeps the real __dirname in the modules a macro runs", async () => {
|
|
72
|
+
const root = await makeTempRoot();
|
|
73
|
+
await writeText(path.join(root, "node_modules/fsdep/package.json"), '{ "name": "fsdep", "main": "index.js" }\n');
|
|
74
|
+
await writeText(path.join(root, "node_modules/fsdep/marker.txt"), "beside-me\n");
|
|
75
|
+
await writeText(
|
|
76
|
+
path.join(root, "node_modules/fsdep/index.js"),
|
|
77
|
+
'const fs = require("node:fs");\nmodule.exports = () => fs.readFileSync(__dirname + "/marker.txt", "utf8").trim();\n',
|
|
78
|
+
);
|
|
79
|
+
await writeText(
|
|
80
|
+
path.join(root, "macro.ts"),
|
|
81
|
+
'import readMarker from "fsdep";\nexport const marker = () => readMarker();\n',
|
|
82
|
+
);
|
|
83
|
+
const entry = path.join(root, "entry.ts");
|
|
84
|
+
await writeText(entry, 'import { marker } from "./macro" with { type: "macro" };\nconsole.info(marker());\n');
|
|
85
|
+
|
|
86
|
+
expect(await build(entry)).toContain('"beside-me"');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { BunPlugin } from "bun";
|
|
3
|
+
|
|
4
|
+
const vendorScript = /[\\/]node_modules[\\/].+\.[cm]?js$/;
|
|
5
|
+
|
|
6
|
+
//* Bun's browser build declares `__dirname` / `__filename` in each module naming them as the absolute path it read the
|
|
7
|
+
//* module from, so a CSR bundle carried the checkout location and its hash moved with it. A vendor module gets its path
|
|
8
|
+
//* from `node_modules` on instead. Not `define`: Bun applies that to the modules a macro imports and runs, where a
|
|
9
|
+
//* server module reading files beside itself would read `/`, while a plugin never sees the macro graph.
|
|
10
|
+
export const createBrowserDirnamePlugin = (): BunPlugin => ({
|
|
11
|
+
name: "akan-browser-dirname",
|
|
12
|
+
setup(build) {
|
|
13
|
+
build.onLoad({ filter: vendorScript }, async (args) => {
|
|
14
|
+
const source = await Bun.file(args.path).text();
|
|
15
|
+
if (!source.includes("__dirname") && !source.includes("__filename")) return undefined;
|
|
16
|
+
const posixPath = args.path.replaceAll("\\", "/");
|
|
17
|
+
const filename = posixPath.slice(posixPath.indexOf("/node_modules/"));
|
|
18
|
+
const define = { __dirname: JSON.stringify(path.posix.dirname(filename)), __filename: JSON.stringify(filename) };
|
|
19
|
+
return {
|
|
20
|
+
loader: "js",
|
|
21
|
+
contents: new Bun.Transpiler({ loader: "js", target: "browser", define }).transformSync(source),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -2,9 +2,9 @@ import path from "node:path";
|
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import { loaderFor } from "./moduleSyntax";
|
|
4
4
|
|
|
5
|
-
const USE_CLIENT_RE = /^\s*(?:\/\*[
|
|
5
|
+
const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
|
|
6
6
|
const IMPLICIT_ROOT_LAYOUT_RE =
|
|
7
|
-
/[/\\]\.akan[/\\]generated[/\\](?:implicit-root-layout|root-layouts[/\\]
|
|
7
|
+
/[/\\]\.akan[/\\]generated[/\\](?:implicit-root-layout|root-layouts[/\\][^/\\]*__root_layout)\.(tsx|ts|jsx|js)$/;
|
|
8
8
|
const STAR_REEXPORT_HINT_RE = /\bexport\s*\*\s*from\b/;
|
|
9
9
|
|
|
10
10
|
export interface UseClientTransformArgs {
|
|
@@ -4,7 +4,12 @@ import { ApplicationBuildReporter } from "../applicationBuildReporter";
|
|
|
4
4
|
import { tempDirs, writeText as write } from "../testHelpers";
|
|
5
5
|
import { BarrelAnalyzer, type BarrelExportTarget } from "./barrelAnalyzer";
|
|
6
6
|
import { rewriteBarrelImports } from "./barrelImportsPlugin";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
hasUseClientDirective,
|
|
9
|
+
scanUseClientExports,
|
|
10
|
+
toClientReferencePath,
|
|
11
|
+
transformUseClient,
|
|
12
|
+
} from "./rscUseClientTransform";
|
|
8
13
|
import { createUseClientBundlePlugin } from "./useClientBundlePlugin";
|
|
9
14
|
|
|
10
15
|
const makeTempRoot = tempDirs("akan-devkit-transform-");
|
|
@@ -27,6 +32,12 @@ describe("transformUseClient", () => {
|
|
|
27
32
|
).toBeNull();
|
|
28
33
|
});
|
|
29
34
|
|
|
35
|
+
test("reads the directive through the leading comments only, in linear time", () => {
|
|
36
|
+
expect(hasUseClientDirective('/* a */\n// b /* c\n/** d **/\n"use client";')).toBe(true);
|
|
37
|
+
expect(hasUseClientDirective('/* a */ x /* b */ "use client";')).toBe(false);
|
|
38
|
+
expect(hasUseClientDirective(`${"/* a */\n".repeat(40)}export const value = 1;`)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
30
41
|
test("a client module with no exports stays server code and is not reported as a client module", () => {
|
|
31
42
|
const reported: string[] = [];
|
|
32
43
|
const onClientModule = (file: string) => reported.push(file);
|
|
@@ -93,7 +104,12 @@ describe('export * in a "use client" module', () => {
|
|
|
93
104
|
"export const text = \"export * from './a string'\";",
|
|
94
105
|
"",
|
|
95
106
|
].join("\n");
|
|
96
|
-
expect(scanUseClientExports(shapes, file).sort()).toEqual([
|
|
107
|
+
expect(scanUseClientExports(shapes, file).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
108
|
+
"LeaveInfo",
|
|
109
|
+
"Voc",
|
|
110
|
+
"ns",
|
|
111
|
+
"text",
|
|
112
|
+
]);
|
|
97
113
|
expect(scanUseClientExports('export * from "./a";\nexport const X = 1;\n', file)).toEqual(["X"]);
|
|
98
114
|
const stub = transformUseClient(shapes, { path: file, workspaceRoot: "/repo" });
|
|
99
115
|
expect(stub).toContain("export const LeaveInfo = registerClientReference");
|
|
@@ -167,6 +167,15 @@ describe("createTsconfigPackageResolver — node_modules fallback", () => {
|
|
|
167
167
|
expect((await resolve("akanjs/signal"))?.entryFile).toBe(entryFile);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
test("substitutes every * of a wildcard target, as Node does", async () => {
|
|
171
|
+
const root = await makeTempRoot();
|
|
172
|
+
await writePackageJson(root, "akanjs", { exports: { "./*": "./*/*.js" } });
|
|
173
|
+
const entryFile = await write(root, "node_modules/akanjs/signal/signal.js");
|
|
174
|
+
const resolve = await resolverFor(root, {});
|
|
175
|
+
|
|
176
|
+
expect((await resolve("akanjs/signal"))?.entryFile).toBe(entryFile);
|
|
177
|
+
});
|
|
178
|
+
|
|
170
179
|
test("resolves a scoped package subpath", async () => {
|
|
171
180
|
// Vendor-neutral on purpose: `packageExports.test.ts` reads every `"@akanjs/devkit/<x>"` literal here as an import.
|
|
172
181
|
const root = await makeTempRoot();
|
package/types.ts
CHANGED
package/workflow/artifacts.ts
CHANGED
|
@@ -205,7 +205,7 @@ const syncStateSlug = (target: string) =>
|
|
|
205
205
|
target
|
|
206
206
|
.trim()
|
|
207
207
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
208
|
-
.replace(
|
|
208
|
+
.replace(/^-|-$/g, "")
|
|
209
209
|
.toLowerCase();
|
|
210
210
|
|
|
211
211
|
export const workflowSyncStatePath = (target: string) => `${workflowSyncDir}/${syncStateSlug(target) || "target"}.json`;
|
|
@@ -56,7 +56,11 @@ describe("tooling rollout gate", () => {
|
|
|
56
56
|
}),
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
expect(
|
|
59
|
+
expect(
|
|
60
|
+
violations
|
|
61
|
+
.map((violation) => `${violation.packageName}:${violation.status}`)
|
|
62
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
63
|
+
).toEqual([
|
|
60
64
|
"@ttsc/graph:reference-only",
|
|
61
65
|
"ast-grep:experiment-only",
|
|
62
66
|
"recast:experiment-only",
|
package/workflow/source.ts
CHANGED
|
@@ -32,7 +32,7 @@ export const sourceFile = (sys: Sys, path: string, action: PrimitiveChangedFile[
|
|
|
32
32
|
export const moduleComponentName = (moduleName: string) =>
|
|
33
33
|
moduleName
|
|
34
34
|
.replace(/[-_]+/g, " ")
|
|
35
|
-
.replace(/(
|
|
35
|
+
.replace(/(?<=^|\s)[a-zA-Z0-9]/g, (char) => char.toUpperCase())
|
|
36
36
|
.replace(/\s+/g, "");
|
|
37
37
|
|
|
38
38
|
export const moduleSourcePaths = (moduleName: string) => {
|
|
@@ -768,7 +768,7 @@ export const insertTemplateField = ({
|
|
|
768
768
|
|
|
769
769
|
export const ensureEnumImport = (content: string) => {
|
|
770
770
|
if (content.includes("enumOf")) return content;
|
|
771
|
-
const baseImport = /import \{ ([^}]+) \} from "akanjs\/base";/.exec(content);
|
|
771
|
+
const baseImport = /import \{ ([^{}]+) \} from "akanjs\/base";/.exec(content);
|
|
772
772
|
if (baseImport) {
|
|
773
773
|
const names = baseImport[1]?.split(",").map((name) => name.trim()) ?? [];
|
|
774
774
|
return content.replace(baseImport[0], `import { ${[...names, "enumOf"].sort().join(", ")} } from "akanjs/base";`);
|