@akanjs/devkit 3.0.0 → 3.0.2-rc.0
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/CHANGELOG.md +1832 -0
- package/akanConfig/akanConfig.test.ts +3 -0
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/artifact/implicitRootLayout.test.ts +2 -1
- package/artifact/implicitRootLayout.ts +2 -1
- package/artifact/routeSeedIndex.test.ts +0 -14
- package/biome.base.json +12 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/csrE2e/csrE2eHarness.fixture.ts +3 -2
- package/csrE2e/csrPageStack.e2e.test.ts +25 -3
- package/executors.test.ts +329 -4
- package/executors.ts +49 -47
- package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
- package/frontendBuild/csrArtifactBuilder.ts +14 -8
- package/frontendBuild/csrDevModuleCompiler.ts +0 -1
- package/frontendBuild/csrDevResolver.ts +0 -2
- package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
- package/frontendBuild/devGeneratedIndexSync.ts +21 -4
- package/frontendBuild/frontendBuild.test.ts +1 -1
- package/frontendBuild/hmrWatcher.ts +6 -0
- package/integration/devStability.integration.test.ts +8 -5
- package/integration/devStabilityHarness.ts +2 -0
- package/jsonFormatter.test.ts +64 -0
- package/jsonFormatter.ts +78 -0
- package/lint/__fixtures__/README.md +12 -3
- package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
- package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
- package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
- package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
- package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
- package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
- package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
- package/lint/gritRules.test.ts +23 -15
- package/lint/no-double-brace-placeholder.grit +15 -0
- package/lint/no-inline-color.grit +21 -5
- package/lint/no-static-in-object-light-model.grit +27 -0
- package/lint/no-throw-raw-error.grit +18 -33
- package/lint/no-unguarded-endpoint.grit +73 -0
- package/manifestDependencies.ts +65 -0
- package/mobile/desktopBin.test.ts +1 -1
- package/mobile/desktopServerStage.test.ts +7 -4
- package/mobile/nativeApp.test.ts +57 -8
- package/mobile/nativeApp.ts +27 -9
- package/mobile/nativeConfig.test.ts +26 -4
- package/mobile/nativeConfig.ts +19 -1
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +1 -1
- package/platformTest/PlatformTestTarget.ts +3 -1
- package/platformTest/WindowsSshTarget.ts +1 -1
- package/qualityScanner.test.ts +51 -0
- package/qualityScanner.ts +22 -1
- package/scanInfo.ts +14 -2
- package/subspace.test.ts +10 -1
- package/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +24 -1
- package/workspaceLayout.ts +19 -2
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ const target = (config: Partial<AkanNativeTarget> = {}) => ({
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
const appDir = "/repo/apps/portal";
|
|
25
|
+
const nativeDir = "/repo/dist/native/portal";
|
|
25
26
|
|
|
26
27
|
const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
27
28
|
({
|
|
@@ -31,24 +32,67 @@ const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
|
31
32
|
getScanInfo: () => ({ getLibs: () => [] }),
|
|
32
33
|
dist: { cwdPath: "/repo/dist/apps/portal" },
|
|
33
34
|
getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
|
|
35
|
+
getDevPort: async () => 8284,
|
|
34
36
|
collectPlugins: async () => plugins,
|
|
35
37
|
logger: { warn: () => undefined },
|
|
36
38
|
}) as unknown as App;
|
|
37
39
|
|
|
38
40
|
describe("NativeApp", () => {
|
|
39
|
-
test("keeps each target's web root and each platform's output apart", () => {
|
|
41
|
+
test("keeps each target's web root and each platform's output apart, outside the dist akan build empties", () => {
|
|
40
42
|
const admin = new NativeApp(fakeApp(), target({ name: "admin", basePath: "admin" }));
|
|
41
43
|
|
|
42
|
-
expect(admin.web.dir).toBe(path.join(
|
|
43
|
-
expect(admin.outDir("ios")).toBe(path.join(
|
|
44
|
-
expect(admin.outDir("android")).toBe(path.join(
|
|
45
|
-
expect(admin.devOutDir("macos")).toBe(path.join(
|
|
44
|
+
expect(admin.web.dir).toBe(path.join(nativeDir, "admin/web"));
|
|
45
|
+
expect(admin.outDir("ios")).toBe(path.join(nativeDir, "admin/build/ios"));
|
|
46
|
+
expect(admin.outDir("android")).toBe(path.join(nativeDir, "admin/build/android"));
|
|
47
|
+
expect(admin.devOutDir("macos")).toBe(path.join(nativeDir, "admin/dev/macos"));
|
|
48
|
+
expect(admin.updatesDir).toBe(path.join(nativeDir, "admin/updates"));
|
|
49
|
+
expect(path.relative("/repo/dist/apps/portal", admin.targetRoot)).toStartWith("..");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("the server a release build's page calls", () => {
|
|
53
|
+
const runtimeEnvOf = async (env: "local" | "debug", options: { server?: boolean } = {}) => {
|
|
54
|
+
const nativeApp = new NativeApp(fakeApp(), target(), env);
|
|
55
|
+
let seen: Record<string, string> | undefined;
|
|
56
|
+
nativeApp.assembleWeb = async () => undefined as never;
|
|
57
|
+
nativeApp.prepare = async () =>
|
|
58
|
+
({
|
|
59
|
+
config: {},
|
|
60
|
+
api: {
|
|
61
|
+
build: async ({ env }: { env?: Record<string, string> }) => {
|
|
62
|
+
seen = env;
|
|
63
|
+
return { artifacts: [], warnings: [] };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}) as never;
|
|
67
|
+
await nativeApp.build("linux", options.server ? { server: { dir: "/s", entry: "main.js", env: {} } } : {});
|
|
68
|
+
return seen;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
test("a local release calls the app's own dev server, not 8282", async () => {
|
|
72
|
+
expect(await runtimeEnvOf("local")).toEqual({ PUBLIC_AKAN_SERVER_URL: "http://localhost:8284" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a cloud release and a release carrying its server name none", async () => {
|
|
76
|
+
expect(await runtimeEnvOf("debug")).toEqual({});
|
|
77
|
+
expect(await runtimeEnvOf("local", { server: true })).toEqual({});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an AKAN_PUBLIC_SERVER_URL the bundle was built with is left to win", async () => {
|
|
81
|
+
process.env.AKAN_PUBLIC_SERVER_URL = "http://192.168.0.10:8284";
|
|
82
|
+
try {
|
|
83
|
+
expect(await runtimeEnvOf("local")).toEqual({});
|
|
84
|
+
} finally {
|
|
85
|
+
delete process.env.AKAN_PUBLIC_SERVER_URL;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
46
88
|
});
|
|
47
89
|
|
|
48
90
|
test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
|
|
91
|
+
const root = await makeTempRoot();
|
|
49
92
|
const app = {
|
|
50
93
|
...fakeApp(),
|
|
51
|
-
cwdPath:
|
|
94
|
+
cwdPath: path.join(root, "apps/portal"),
|
|
95
|
+
workspace: { workspaceRoot: root },
|
|
52
96
|
logger: { info: () => undefined, debug: () => undefined, warn: () => undefined },
|
|
53
97
|
} as unknown as App;
|
|
54
98
|
const nativeApp = new NativeApp(app, target());
|
|
@@ -118,7 +162,12 @@ describe("NativeApp", () => {
|
|
|
118
162
|
test("assembles each platform's web root with that platform's indexPath", async () => {
|
|
119
163
|
const root = await makeTempRoot();
|
|
120
164
|
await writeText(path.join(root, "dist/csr/index.html"), "<html><head></head><body></body></html>");
|
|
121
|
-
const app = {
|
|
165
|
+
const app = {
|
|
166
|
+
...fakeApp(),
|
|
167
|
+
cwdPath: root,
|
|
168
|
+
workspace: { workspaceRoot: root },
|
|
169
|
+
dist: { cwdPath: path.join(root, "dist") },
|
|
170
|
+
} as unknown as App;
|
|
122
171
|
const nativeApp = new NativeApp(app, target({ indexPath: "/mobile", desktop: { indexPath: "/console" } }));
|
|
123
172
|
const injected = async () => await Bun.file(path.join(nativeApp.web.dir, "index.html")).text();
|
|
124
173
|
|
|
@@ -139,7 +188,7 @@ describe("NativeApp", () => {
|
|
|
139
188
|
|
|
140
189
|
expect(warnings).toEqual([]);
|
|
141
190
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
|
|
142
|
-
expect(config.web.dir).toBe(path.join(
|
|
191
|
+
expect(config.web.dir).toBe(path.join(nativeDir, "default/web"));
|
|
143
192
|
});
|
|
144
193
|
|
|
145
194
|
test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -63,7 +63,8 @@ export class NativeApp {
|
|
|
63
63
|
readonly target: ResolvedMobileTarget,
|
|
64
64
|
readonly env?: NativeEnv,
|
|
65
65
|
) {
|
|
66
|
-
|
|
66
|
+
//* Outside `dist/apps/<app>`, which every `akan build` empties: a signed release waiting for upload lives here.
|
|
67
|
+
this.targetRoot = path.join(app.workspace.workspaceRoot, "dist", "native", app.name, target.name);
|
|
67
68
|
this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -161,13 +162,18 @@ export class NativeApp {
|
|
|
161
162
|
};
|
|
162
163
|
|
|
163
164
|
//* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
|
|
164
|
-
#task(
|
|
165
|
+
#task(
|
|
166
|
+
platform: NativePlatform,
|
|
167
|
+
config: AkanNativeConfig,
|
|
168
|
+
{ outDir = this.outDir(platform), env = {} }: { outDir?: string; env?: Record<string, string> } = {},
|
|
169
|
+
): TaskOptions {
|
|
165
170
|
return {
|
|
166
171
|
appDir: this.app.cwdPath,
|
|
167
172
|
config,
|
|
168
173
|
platform,
|
|
169
174
|
outDir,
|
|
170
175
|
envFiles: false,
|
|
176
|
+
env,
|
|
171
177
|
skipWebBuild: true,
|
|
172
178
|
log: (event) => {
|
|
173
179
|
if (event.level === "error") this.app.logger.error(event.message);
|
|
@@ -179,6 +185,13 @@ export class NativeApp {
|
|
|
179
185
|
};
|
|
180
186
|
}
|
|
181
187
|
|
|
188
|
+
//* A local release has no dev gateway behind it, so its page calls the app's dev server (baseEnv csrServerUrl), on
|
|
189
|
+
//* the port `akan start` gives this app. A carried server hands the page its own URL at launch instead.
|
|
190
|
+
async #runtimeEnv(server?: DesktopServerBundle): Promise<Record<string, string>> {
|
|
191
|
+
if (this.env !== "local" || server || process.env.AKAN_PUBLIC_SERVER_URL) return {};
|
|
192
|
+
return { PUBLIC_AKAN_SERVER_URL: `http://localhost:${await this.app.getDevPort()}` };
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
async build(
|
|
183
196
|
platform: NativePlatform,
|
|
184
197
|
{ profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
|
|
@@ -188,7 +201,7 @@ export class NativeApp {
|
|
|
188
201
|
await this.assembleWeb(platform);
|
|
189
202
|
const { api, config } = await this.prepare(platform, server, arch);
|
|
190
203
|
return await api.build({
|
|
191
|
-
...this.#task(platform, config),
|
|
204
|
+
...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
|
|
192
205
|
profile,
|
|
193
206
|
...(arch ? { arch } : {}),
|
|
194
207
|
...(platform === "windows"
|
|
@@ -219,7 +232,7 @@ export class NativeApp {
|
|
|
219
232
|
await this.assembleWeb(platform);
|
|
220
233
|
const { api, config } = await this.prepare(platform, server);
|
|
221
234
|
return await api.run({
|
|
222
|
-
...this.#task(platform, config),
|
|
235
|
+
...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
|
|
223
236
|
profile,
|
|
224
237
|
onLine: this.#appLine,
|
|
225
238
|
...(device ? { device } : {}),
|
|
@@ -238,7 +251,7 @@ export class NativeApp {
|
|
|
238
251
|
await this.web.clear();
|
|
239
252
|
const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
|
|
240
253
|
const session = await api.dev({
|
|
241
|
-
...this.#task(platform, config, this.devOutDir(platform)),
|
|
254
|
+
...this.#task(platform, config, { outDir: this.devOutDir(platform) }),
|
|
242
255
|
upstream,
|
|
243
256
|
hmrPath: "/_akan/hmr",
|
|
244
257
|
//? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
|
|
@@ -279,7 +292,7 @@ export class NativeApp {
|
|
|
279
292
|
await this.assembleWeb("ios");
|
|
280
293
|
const { api, config } = await this.prepare("ios");
|
|
281
294
|
return await api.release({
|
|
282
|
-
...this.#task("ios", config),
|
|
295
|
+
...this.#task("ios", config, { env: await this.#runtimeEnv() }),
|
|
283
296
|
platform: "ios",
|
|
284
297
|
signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
|
|
285
298
|
});
|
|
@@ -300,7 +313,7 @@ export class NativeApp {
|
|
|
300
313
|
await this.assembleWeb(platform);
|
|
301
314
|
const { api, config } = await this.prepare(platform, server);
|
|
302
315
|
return await api.publishUpdate({
|
|
303
|
-
...this.#task(platform, config),
|
|
316
|
+
...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
|
|
304
317
|
platform,
|
|
305
318
|
out: this.updatesDir,
|
|
306
319
|
...(channel ? { channel } : {}),
|
|
@@ -363,7 +376,12 @@ export class NativeApp {
|
|
|
363
376
|
const signing = NativeApp.androidSigning();
|
|
364
377
|
await this.assembleWeb("android");
|
|
365
378
|
const { api, config } = await this.prepare("android");
|
|
366
|
-
return await api.release({
|
|
379
|
+
return await api.release({
|
|
380
|
+
...this.#task("android", config, { env: await this.#runtimeEnv() }),
|
|
381
|
+
platform: "android",
|
|
382
|
+
signing,
|
|
383
|
+
formats,
|
|
384
|
+
});
|
|
367
385
|
}
|
|
368
386
|
|
|
369
387
|
/** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
|
|
@@ -377,7 +395,7 @@ export class NativeApp {
|
|
|
377
395
|
const packed = await api.packUpdate({
|
|
378
396
|
...this.#task(platform, config),
|
|
379
397
|
platform,
|
|
380
|
-
out: out ?? path.join(this.
|
|
398
|
+
out: out ?? path.join(this.updatesDir, platform),
|
|
381
399
|
});
|
|
382
400
|
return { ...packed, channel: config.updates.channel ?? "production" };
|
|
383
401
|
}
|
|
@@ -45,11 +45,33 @@ const adminTarget: AkanNativeTarget = {
|
|
|
45
45
|
const updatesKey = Buffer.alloc(32, 7).toString("base64");
|
|
46
46
|
|
|
47
47
|
describe("NativeConfig.build", () => {
|
|
48
|
+
test("hands the dmg window over with its pictures resolved against the app folder", () => {
|
|
49
|
+
const build = (dmg: NonNullable<AkanNativeTarget["desktop"]>["dmg"]) =>
|
|
50
|
+
NativeConfig.build({
|
|
51
|
+
appPath: "/repo/apps/minimal",
|
|
52
|
+
target: { ...minimalTarget, desktop: { dmg } },
|
|
53
|
+
webDir: "/repo/dist/native/minimal/default/web",
|
|
54
|
+
contributions: [],
|
|
55
|
+
locales: ["en"],
|
|
56
|
+
platform: "macos",
|
|
57
|
+
}).config.desktop?.dmg;
|
|
58
|
+
|
|
59
|
+
expect(build({ background: "assets/dmg.png", background2x: "assets/dmg@2x.png", app: { x: 150, y: 190 } })).toEqual(
|
|
60
|
+
{
|
|
61
|
+
background: path.resolve("/repo/apps/minimal", "assets/dmg.png"),
|
|
62
|
+
background2x: path.resolve("/repo/apps/minimal", "assets/dmg@2x.png"),
|
|
63
|
+
app: { x: 150, y: 190 },
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
expect(build({ background: false, iconSize: 96 })).toEqual({ background: false, iconSize: 96 });
|
|
67
|
+
expect(build(undefined)).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
48
70
|
test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
|
|
49
71
|
const { config, warnings } = NativeConfig.build({
|
|
50
72
|
appPath: "/repo/apps/minimal",
|
|
51
73
|
target: minimalTarget,
|
|
52
|
-
webDir: "/repo/
|
|
74
|
+
webDir: "/repo/dist/native/minimal/default/web",
|
|
53
75
|
contributions: [{ permission: "push", plugins: ["push"] }],
|
|
54
76
|
locales: ["en", "ko"],
|
|
55
77
|
platform: "android",
|
|
@@ -58,7 +80,7 @@ describe("NativeConfig.build", () => {
|
|
|
58
80
|
expect(warnings).toEqual([]);
|
|
59
81
|
expect(config).toEqual({
|
|
60
82
|
app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
|
|
61
|
-
web: { dir: "/repo/
|
|
83
|
+
web: { dir: "/repo/dist/native/minimal/default/web" },
|
|
62
84
|
plugins: [...NativeConfig.basePlugins, "single-instance", "push"],
|
|
63
85
|
capabilities: [
|
|
64
86
|
{
|
|
@@ -80,7 +102,7 @@ describe("NativeConfig.build", () => {
|
|
|
80
102
|
const { config, warnings } = NativeConfig.build({
|
|
81
103
|
appPath: "/repo/apps/portal",
|
|
82
104
|
target: adminTarget,
|
|
83
|
-
webDir: "/repo/
|
|
105
|
+
webDir: "/repo/dist/native/portal/admin/web",
|
|
84
106
|
contributions: [],
|
|
85
107
|
locales: ["en", "ko"],
|
|
86
108
|
platform: "android",
|
|
@@ -89,7 +111,7 @@ describe("NativeConfig.build", () => {
|
|
|
89
111
|
expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
|
|
90
112
|
expect(config).toEqual({
|
|
91
113
|
app: { id: "com.portal.admin", name: "Portal Admin", fileName: "portal-admin", version: "2.1.0", build: 42 },
|
|
92
|
-
web: { dir: "/repo/
|
|
114
|
+
web: { dir: "/repo/dist/native/portal/admin/web" },
|
|
93
115
|
plugins: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"],
|
|
94
116
|
capabilities: [
|
|
95
117
|
{
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AkanNativeDmgConfig,
|
|
6
|
+
AkanNativeTarget,
|
|
7
|
+
AkanNativeValue,
|
|
8
|
+
AkanPluginNativeConfig,
|
|
9
|
+
NativeEnv,
|
|
10
|
+
NativePermission,
|
|
11
|
+
} from "akanjs";
|
|
5
12
|
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
6
13
|
import { type NativePlatform, resolveAppId } from "./mobileTarget";
|
|
7
14
|
import type { NativePluginFolder } from "./nativePluginFolders";
|
|
@@ -171,6 +178,7 @@ export class NativeConfig {
|
|
|
171
178
|
window: NativeConfig.#compact({ ...target.desktop?.window }),
|
|
172
179
|
server: desktopServer,
|
|
173
180
|
bin: desktopBin,
|
|
181
|
+
dmg: NativeConfig.#dmg(target.desktop?.dmg, abs),
|
|
174
182
|
});
|
|
175
183
|
const config: AkanNativeConfig = {
|
|
176
184
|
app: {
|
|
@@ -234,6 +242,16 @@ export class NativeConfig {
|
|
|
234
242
|
return { ...splash, ...(splash.image ? { image: abs(splash.image) } : {}) };
|
|
235
243
|
}
|
|
236
244
|
|
|
245
|
+
static #dmg(dmg: AkanNativeDmgConfig | undefined, abs: (relative: string) => string) {
|
|
246
|
+
if (!dmg) return undefined;
|
|
247
|
+
const { background, background2x, ...layout } = dmg;
|
|
248
|
+
return {
|
|
249
|
+
...layout,
|
|
250
|
+
...(background === undefined ? {} : { background: background === false ? false : abs(background) }),
|
|
251
|
+
...(background2x ? { background2x: abs(background2x) } : {}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
237
255
|
//? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
|
|
238
256
|
static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
239
257
|
const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0",
|
|
3
|
+
"version": "3.0.2-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@earendil-works/pi-tui": "0.86.1",
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
|
-
"akanjs": "3.0.0",
|
|
50
|
+
"akanjs": "3.0.2-rc.0",
|
|
51
51
|
"chalk": "^5.6.2",
|
|
52
52
|
"commander": "^14.0.3",
|
|
53
53
|
"dayjs": "^1.11.20",
|
|
@@ -73,7 +73,7 @@ export class LinuxDockerTarget extends PlatformTestTarget {
|
|
|
73
73
|
[
|
|
74
74
|
"docker",
|
|
75
75
|
"exec",
|
|
76
|
-
...Object.entries(PlatformTestTarget.
|
|
76
|
+
...Object.entries(PlatformTestTarget.remoteEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
|
|
77
77
|
container,
|
|
78
78
|
"sh",
|
|
79
79
|
"-c",
|
|
@@ -36,7 +36,9 @@ export interface PlatformRunContext {
|
|
|
36
36
|
export abstract class PlatformTestTarget {
|
|
37
37
|
static readonly installIdleMs = 5 * 60_000;
|
|
38
38
|
static readonly testIdleMs = 2 * 60_000;
|
|
39
|
-
protected static readonly
|
|
39
|
+
protected static readonly remoteEnv = {
|
|
40
|
+
//? No suite drives puppeteer's Chrome, and downloading it into every fresh container fails install on a DNS blip.
|
|
41
|
+
PUPPETEER_SKIP_DOWNLOAD: "true",
|
|
40
42
|
GIT_AUTHOR_NAME: "akan-test",
|
|
41
43
|
GIT_AUTHOR_EMAIL: "akan-test@localhost",
|
|
42
44
|
GIT_COMMITTER_NAME: "akan-test",
|
|
@@ -71,7 +71,7 @@ export class WindowsSshTarget extends PlatformTestTarget {
|
|
|
71
71
|
"$ProgressPreference = 'SilentlyContinue'",
|
|
72
72
|
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
|
|
73
73
|
"'PID=' + $PID",
|
|
74
|
-
...Object.entries(PlatformTestTarget.
|
|
74
|
+
...Object.entries(PlatformTestTarget.remoteEnv).map(([key, value]) => `$env:${key} = '${value}'`),
|
|
75
75
|
`Set-Location -LiteralPath '${runDir}\\w'`,
|
|
76
76
|
`cmd /c "${command} 2>&1"`,
|
|
77
77
|
"exit $LASTEXITCODE",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -219,6 +219,20 @@ describe("AkanQualityScanner layout rules", () => {
|
|
|
219
219
|
]);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
test("flags a hand-written facet index beside the generated barrel, but not a folder namespace", async () => {
|
|
223
|
+
const root = await makeWorkspace({
|
|
224
|
+
"libs/demo/ui/index.tsx": 'export { Chat } from "./Chat";\n',
|
|
225
|
+
"libs/demo/ui/Chat.tsx": "export const Chat = () => null;\n",
|
|
226
|
+
"libs/demo/ui/Page/index.tsx": 'export { Inner } from "./Inner";\n',
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.facet-index-shadow");
|
|
230
|
+
|
|
231
|
+
expect(warnings.map((warning) => warning.file)).toEqual(["libs/demo/ui/index.tsx"]);
|
|
232
|
+
expect(warnings[0]?.message).toContain("index.tsx shadows the generated ui/index.ts");
|
|
233
|
+
expect(warnings[0]?.fix).toContain("akan sync");
|
|
234
|
+
});
|
|
235
|
+
|
|
222
236
|
test("keeps a root signal test out of the lib facet rule", async () => {
|
|
223
237
|
const root = await makeWorkspace({
|
|
224
238
|
"libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
|
|
@@ -233,6 +247,43 @@ describe("AkanQualityScanner layout rules", () => {
|
|
|
233
247
|
});
|
|
234
248
|
});
|
|
235
249
|
|
|
250
|
+
describe("AkanQualityScanner class export files", () => {
|
|
251
|
+
test("allow a type only the class reads, and flag an exported type and any helper beside the class", async () => {
|
|
252
|
+
const root = await makeWorkspace({
|
|
253
|
+
"libs/demo/srvkit/vendorApi.ts": [
|
|
254
|
+
"interface VendorUser {",
|
|
255
|
+
" id: string;",
|
|
256
|
+
"}",
|
|
257
|
+
"type VendorPage = { items: VendorUser[] };",
|
|
258
|
+
"export interface VendorApiOptions {",
|
|
259
|
+
" key: string;",
|
|
260
|
+
"}",
|
|
261
|
+
"export interface VendorReceipt {",
|
|
262
|
+
" id: string;",
|
|
263
|
+
"}",
|
|
264
|
+
'export type VendorStatus = "ok" | "fail";',
|
|
265
|
+
"enum VendorRegion {",
|
|
266
|
+
" Kr,",
|
|
267
|
+
"}",
|
|
268
|
+
"const pageSize = 100;",
|
|
269
|
+
"const toUser = (page: VendorPage) => page.items[0];",
|
|
270
|
+
"export class VendorApi {}",
|
|
271
|
+
"",
|
|
272
|
+
].join("\n"),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.file.class-export-global-declaration");
|
|
276
|
+
|
|
277
|
+
expect(warnings.map((warning) => /"(\w+)"/.exec(warning.message)?.[1])).toEqual([
|
|
278
|
+
"VendorReceipt",
|
|
279
|
+
"VendorStatus",
|
|
280
|
+
"VendorRegion",
|
|
281
|
+
"pageSize",
|
|
282
|
+
"toUser",
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
236
287
|
describe("bang comments in browser-reachable files", () => {
|
|
237
288
|
const bangWarningsIn = async (files: Record<string, string>) => {
|
|
238
289
|
const result = await new AkanQualityScanner().scan(await makeWorkspace(files));
|
package/qualityScanner.ts
CHANGED
|
@@ -7,7 +7,13 @@ import ts from "typescript";
|
|
|
7
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
8
|
import { FormSetterScanner } from "./formSetterScanner";
|
|
9
9
|
import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
facetIndexShadowOf,
|
|
12
|
+
isAllowedLibFacetRootFile,
|
|
13
|
+
rootAllowedDirs,
|
|
14
|
+
rootAllowedFiles,
|
|
15
|
+
rootEntryHintOf,
|
|
16
|
+
} from "./workspaceLayout";
|
|
11
17
|
|
|
12
18
|
type QualitySeverity = "warning";
|
|
13
19
|
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
@@ -140,6 +146,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
140
146
|
"Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
|
|
141
147
|
"akan.layout.lib-facet-file":
|
|
142
148
|
"Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
|
|
149
|
+
"akan.layout.facet-index-shadow":
|
|
150
|
+
"Delete the hand-written index file and run akan sync; move a namespace it builds into <facet>/<Folder>/index.tsx.",
|
|
143
151
|
"akan.layout.module-ui-file":
|
|
144
152
|
"Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
145
153
|
"akan.ssr.unnecessary-use-client":
|
|
@@ -325,6 +333,8 @@ export class AkanQualityScanner {
|
|
|
325
333
|
for (const declaration of getTopLevelDeclarations(sourceFile)) {
|
|
326
334
|
if (declaration.kind === "class" && exportedClassNames.includes(declaration.name)) continue;
|
|
327
335
|
if (declaration.kind === "interface" && allowedInterfaceNames.has(declaration.name)) continue;
|
|
336
|
+
//? A shape only this class reads (a vendor response it parses) is erased at build; a sibling file buys nothing.
|
|
337
|
+
if ((declaration.kind === "interface" || declaration.kind === "type") && !declaration.exported) continue;
|
|
328
338
|
warnings.push(
|
|
329
339
|
fileWarning(
|
|
330
340
|
"akan.file.class-export-global-declaration",
|
|
@@ -432,6 +442,17 @@ export class AkanQualityScanner {
|
|
|
432
442
|
);
|
|
433
443
|
}
|
|
434
444
|
|
|
445
|
+
const facetShadow = rootEntry?.isDir ? facetIndexShadowOf(sourceFile.file.split("/").slice(2).join("/")) : null;
|
|
446
|
+
if (facetShadow)
|
|
447
|
+
warnings.push(
|
|
448
|
+
fileWarning(
|
|
449
|
+
"akan.layout.facet-index-shadow",
|
|
450
|
+
"layout",
|
|
451
|
+
sourceFile.file,
|
|
452
|
+
`${path.basename(sourceFile.file)} ${facetShadow}.`,
|
|
453
|
+
),
|
|
454
|
+
);
|
|
455
|
+
|
|
435
456
|
const libFacetFile = getLibFacetRootFile(sourceFile.file);
|
|
436
457
|
if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile))
|
|
437
458
|
warnings.push(
|
package/scanInfo.ts
CHANGED
|
@@ -10,7 +10,14 @@ import type {
|
|
|
10
10
|
} from "./akanConfig";
|
|
11
11
|
import { AkanAppConfig } from "./akanConfig";
|
|
12
12
|
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
barrelFacetDirs,
|
|
15
|
+
facetIndexShadowOf,
|
|
16
|
+
isAllowedLibFacetRootFile,
|
|
17
|
+
rootAllowedDirs,
|
|
18
|
+
rootAllowedFiles,
|
|
19
|
+
rootEntryHintOf,
|
|
20
|
+
} from "./workspaceLayout";
|
|
14
21
|
|
|
15
22
|
const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
|
|
16
23
|
type ScalarFileType = (typeof scalarFileTypes)[number];
|
|
@@ -74,6 +81,11 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
|
|
|
74
81
|
for (const dirname of dirs)
|
|
75
82
|
if (!allowedRootDirs.has(dirname))
|
|
76
83
|
addViolation(dirname, rootEntryHintOf(exec.type, dirname) ?? `unsupported ${exec.type} root folder`);
|
|
84
|
+
for (const facet of barrelFacetDirs)
|
|
85
|
+
for (const filename of await exec.readdir(facet)) {
|
|
86
|
+
const shadow = facetIndexShadowOf(`${facet}/${filename}`);
|
|
87
|
+
if (shadow) addViolation(path.join(facet, filename), shadow);
|
|
88
|
+
}
|
|
77
89
|
|
|
78
90
|
//* A lib has no `getPageKeys`, so its own route files are validated here.
|
|
79
91
|
if (exec.type === "lib")
|
|
@@ -275,7 +287,7 @@ class ScanInfo {
|
|
|
275
287
|
}
|
|
276
288
|
}
|
|
277
289
|
|
|
278
|
-
const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
|
|
290
|
+
export const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
|
|
279
291
|
export class AppInfo extends ScanInfo {
|
|
280
292
|
readonly type = "app";
|
|
281
293
|
readonly exec: AppExecutor;
|
package/subspace.test.ts
CHANGED
|
@@ -10,7 +10,16 @@ import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
|
|
|
10
10
|
//? Each case drives several real git clones and pushes; on a 4-core Windows VM that alone lands at 2-5s.
|
|
11
11
|
setDefaultTimeout(20_000);
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
//? Subspace commits in its clone with the host's identity, and a CI host has none configured.
|
|
14
|
+
isolateEnv({
|
|
15
|
+
AKAN_PUBLIC_REPO_NAME: "workspace",
|
|
16
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "example.com",
|
|
17
|
+
AKAN_PUBLIC_ENV: "local",
|
|
18
|
+
GIT_AUTHOR_NAME: "t",
|
|
19
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
20
|
+
GIT_COMMITTER_NAME: "t",
|
|
21
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
22
|
+
});
|
|
14
23
|
const makeTempRoot = tempDirs("akan-subspace-");
|
|
15
24
|
|
|
16
25
|
const git = async (cwd: string, args: string[]) =>
|
package/testHelpers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -122,6 +123,22 @@ export const writeJson = async (filePath: string, value: object) => {
|
|
|
122
123
|
await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
123
124
|
};
|
|
124
125
|
|
|
126
|
+
const repoRoot = path.resolve(import.meta.dir, "../../..");
|
|
127
|
+
const biomeBin = path.join(repoRoot, "node_modules", ".bin", process.platform === "win32" ? "biome.exe" : "biome");
|
|
128
|
+
export const hasBiome = existsSync(biomeBin);
|
|
129
|
+
|
|
130
|
+
//? Through stdin under a repo-relative name, so the repo's own Biome config and overrides judge content kept elsewhere.
|
|
131
|
+
export const formatWithBiome = async (content: string, repoPath: string) => {
|
|
132
|
+
const proc = Bun.spawn([biomeBin, "check", "--write", `--stdin-file-path=${repoPath}`], {
|
|
133
|
+
cwd: repoRoot,
|
|
134
|
+
stdin: new Blob([content]),
|
|
135
|
+
stdout: "pipe",
|
|
136
|
+
stderr: "pipe",
|
|
137
|
+
});
|
|
138
|
+
const [output] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
139
|
+
return output;
|
|
140
|
+
};
|
|
141
|
+
|
|
125
142
|
const tsconfigJson = { compilerOptions: { target: "ESNext", paths: {} } };
|
|
126
143
|
|
|
127
144
|
export const createTempApp = async (appName = "demo", parentDir?: string) => {
|
|
@@ -230,6 +230,19 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
|
|
|
230
230
|
expect(secondMap?.get("b2")).toEqual({ subpath: "second/b2", originalName: "b2" });
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
+
test("an empty facet barrel maps no name, so an import from it is left as written", async () => {
|
|
234
|
+
const root = await makeTempRoot();
|
|
235
|
+
const pkgDir = path.join(root, "common");
|
|
236
|
+
await write(path.join(pkgDir, "index.ts"), "export {};\n");
|
|
237
|
+
const analyzer = new BarrelAnalyzer({
|
|
238
|
+
resolvePackage: async () => ({ pkgName: "@libs/util/common", entryFile: path.join(pkgDir, "index.ts"), pkgDir }),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(await analyzer.analyze("@libs/util/common")).toEqual(new Map());
|
|
242
|
+
const source = 'import { pad } from "@libs/util/common";\n';
|
|
243
|
+
expect(await rewriteBarrelImports(source, ["@libs/util/common"], analyzer)).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
233
246
|
test("rewrites flattenable named imports and preserves default, type, and unknown imports", async () => {
|
|
234
247
|
const analyzer = fakeAnalyzer([
|
|
235
248
|
["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
|
package/tsconfig.json
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
"lib": [
|
|
4
|
-
"ESNext",
|
|
5
|
-
"DOM"
|
|
6
|
-
],
|
|
3
|
+
"lib": ["ESNext", "DOM"],
|
|
7
4
|
"target": "ESNext",
|
|
8
5
|
"module": "esnext",
|
|
9
6
|
"moduleDetection": "force",
|
|
10
7
|
"jsx": "react-jsx",
|
|
11
8
|
"allowJs": true,
|
|
12
|
-
"types": [
|
|
13
|
-
"bun"
|
|
14
|
-
],
|
|
9
|
+
"types": ["bun"],
|
|
15
10
|
"moduleResolution": "bundler",
|
|
16
11
|
"allowImportingTsExtensions": true,
|
|
17
12
|
"verbatimModuleSyntax": true,
|
|
@@ -30,12 +25,6 @@
|
|
|
30
25
|
},
|
|
31
26
|
"extends": "../../../tsconfig.json",
|
|
32
27
|
"files": [],
|
|
33
|
-
"include": [
|
|
34
|
-
|
|
35
|
-
"**/*.tsx",
|
|
36
|
-
"../../akanjs/server/types/*.d.ts"
|
|
37
|
-
],
|
|
38
|
-
"exclude": [
|
|
39
|
-
"lint/__fixtures__"
|
|
40
|
-
]
|
|
28
|
+
"include": ["**/*.ts", "**/*.tsx", "../../akanjs/server/types/*.d.ts"],
|
|
29
|
+
"exclude": ["lint/__fixtures__"]
|
|
41
30
|
}
|