@akanjs/devkit 3.0.1 → 3.0.2
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 +15 -2
- package/akanApp/akanApp.host.test.ts +62 -0
- package/akanApp/akanApp.host.ts +13 -2
- package/akanApp/devHostPolicy.test.ts +14 -0
- package/akanApp/devHostPolicy.ts +11 -0
- package/akanConfig/akanConfig.test.ts +4 -1
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/biome.base.json +8 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/createTunnel.test.ts +51 -0
- package/createTunnel.ts +32 -14
- package/csrE2e/csrPageStack.e2e.test.ts +12 -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/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-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-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/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +78 -1
- package/workspaceLayout.ts +19 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// `SignalContext` checks `signalOption.guards ?? []`, so an endpoint, a named slice, or a slice's root query declaring
|
|
5
|
+
// none is open to every HTTP and websocket caller. An option the rule cannot read, a variable or a spread, is left alone.
|
|
6
|
+
or {
|
|
7
|
+
JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) as $call where {
|
|
8
|
+
or {
|
|
9
|
+
and {
|
|
10
|
+
$callee <: r"(?:query|mutation|pubsub|message)",
|
|
11
|
+
$call <: within JsCallExpression(callee = `endpoint`),
|
|
12
|
+
or { $args <: [$returns], $args <: [$returns, $option] }
|
|
13
|
+
},
|
|
14
|
+
and {
|
|
15
|
+
$callee <: `init`,
|
|
16
|
+
$call <: within JsCallExpression(callee = `slice`),
|
|
17
|
+
or { $args <: [], $args <: [$option] }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
or {
|
|
21
|
+
$option <: undefined,
|
|
22
|
+
$option <: JsObjectExpression(members = $members) where {
|
|
23
|
+
or {
|
|
24
|
+
$members <: not some or {
|
|
25
|
+
JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')"),
|
|
26
|
+
JsShorthandPropertyObjectMember(name = r"guards"),
|
|
27
|
+
JsSpread()
|
|
28
|
+
},
|
|
29
|
+
$members <: some JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')", value = JsArrayExpression(elements = []))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
register_diagnostic(
|
|
34
|
+
span = $callee,
|
|
35
|
+
message = "No `guards`, so every HTTP and websocket caller reaches this endpoint: a missing or empty `guards` fails open, which is also why MCP refuses it. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
|
|
36
|
+
severity = "warn"
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
JsCallExpression(callee = `slice`, arguments = JsCallArguments(args = [$srv, $sliceOption, ...])) where {
|
|
40
|
+
$sliceOption <: JsObjectExpression(members = $sliceMembers),
|
|
41
|
+
or {
|
|
42
|
+
and {
|
|
43
|
+
$sliceMembers <: not some or {
|
|
44
|
+
JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')"),
|
|
45
|
+
JsShorthandPropertyObjectMember(name = r"guards"),
|
|
46
|
+
JsSpread()
|
|
47
|
+
},
|
|
48
|
+
register_diagnostic(
|
|
49
|
+
span = $sliceOption,
|
|
50
|
+
message = "No `guards` on this slice, so every HTTP and websocket caller reaches its root query and generated reads and writes: a missing guard fails open. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
|
|
51
|
+
severity = "warn"
|
|
52
|
+
)
|
|
53
|
+
},
|
|
54
|
+
and {
|
|
55
|
+
$sliceMembers <: some JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')", value = $guardMap),
|
|
56
|
+
$guardMap <: JsObjectExpression(members = $guardKeys),
|
|
57
|
+
or {
|
|
58
|
+
$guardKeys <: not some or {
|
|
59
|
+
JsPropertyObjectMember(name = r"(?:root|\x22root\x22|'root')"),
|
|
60
|
+
JsShorthandPropertyObjectMember(name = r"root"),
|
|
61
|
+
JsSpread()
|
|
62
|
+
},
|
|
63
|
+
$guardKeys <: some JsPropertyObjectMember(name = r"(?:root|\x22root\x22|'root')", value = JsArrayExpression(elements = []))
|
|
64
|
+
},
|
|
65
|
+
register_diagnostic(
|
|
66
|
+
span = $guardMap,
|
|
67
|
+
message = "No `root` guard on this slice, so its root query lists any filter to every HTTP and websocket caller: a missing or empty `root` fails open. Write `root: Admin`.",
|
|
68
|
+
severity = "warn"
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { LibSource } from "./libSource";
|
|
2
|
+
import type { PackageJson } from "./types";
|
|
3
|
+
|
|
4
|
+
type DependencyMap = Record<string, string>;
|
|
5
|
+
|
|
6
|
+
export interface ScannedImports {
|
|
7
|
+
runtime: readonly string[];
|
|
8
|
+
typeOnly: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ManifestDependencies {
|
|
12
|
+
readonly #root: PackageJson;
|
|
13
|
+
readonly #runtime: Set<string>;
|
|
14
|
+
readonly #typeOnly: Set<string>;
|
|
15
|
+
readonly #isKept: (dep: string) => boolean;
|
|
16
|
+
|
|
17
|
+
constructor(root: PackageJson, { runtime, typeOnly }: ScannedImports, isKept: (dep: string) => boolean) {
|
|
18
|
+
this.#root = root;
|
|
19
|
+
this.#runtime = new Set(runtime);
|
|
20
|
+
this.#typeOnly = new Set(typeOnly);
|
|
21
|
+
this.#isKept = isKept;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static keptBy(manifest: PackageJson): string[] {
|
|
25
|
+
const { keepDependencies } = (manifest[LibSource.manifestKey] ?? {}) as { keepDependencies?: unknown };
|
|
26
|
+
return Array.isArray(keepDependencies)
|
|
27
|
+
? keepDependencies.filter((dep): dep is string => typeof dep === "string")
|
|
28
|
+
: [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//* The scan recognises only the names the root declares, so only those can be proven unused and pruned.
|
|
32
|
+
realign(manifest: PackageJson) {
|
|
33
|
+
const removed: string[] = [];
|
|
34
|
+
const unverifiable: string[] = [];
|
|
35
|
+
const dependencies: DependencyMap = {};
|
|
36
|
+
for (const [dep, version] of Object.entries(manifest.dependencies ?? {})) {
|
|
37
|
+
if (this.#typeOnly.has(dep)) continue;
|
|
38
|
+
const rootVersion = this.#rootVersionOf(dep);
|
|
39
|
+
if (!rootVersion) {
|
|
40
|
+
unverifiable.push(dep);
|
|
41
|
+
dependencies[dep] = version;
|
|
42
|
+
} else if (this.#runtime.has(dep) || this.#isKept(dep)) dependencies[dep] = rootVersion;
|
|
43
|
+
else removed.push(dep);
|
|
44
|
+
}
|
|
45
|
+
const runtimeOfRootDependencies = [...this.#runtime].filter((dep) => this.#root.dependencies?.[dep]);
|
|
46
|
+
this.#append(dependencies, runtimeOfRootDependencies);
|
|
47
|
+
|
|
48
|
+
const devDependencies: DependencyMap = {};
|
|
49
|
+
for (const [dep, version] of Object.entries(manifest.devDependencies ?? {}))
|
|
50
|
+
if (!Object.hasOwn(dependencies, dep)) devDependencies[dep] = this.#rootVersionOf(dep) ?? version;
|
|
51
|
+
this.#append(devDependencies, [...this.#typeOnly]);
|
|
52
|
+
return { dependencies, devDependencies, removed, unverifiable };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#append(map: DependencyMap, deps: string[]) {
|
|
56
|
+
for (const dep of deps.filter((dep) => !Object.hasOwn(map, dep)).sort((a, b) => (a < b ? -1 : 1))) {
|
|
57
|
+
const rootVersion = this.#rootVersionOf(dep);
|
|
58
|
+
if (rootVersion) map[dep] = rootVersion;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#rootVersionOf(dep: string) {
|
|
63
|
+
return this.#root.dependencies?.[dep] ?? this.#root.devDependencies?.[dep];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -28,7 +28,7 @@ const stage = async (
|
|
|
28
28
|
bin: AkanBinConfig,
|
|
29
29
|
{ libBins = [], libs = [] }: { libBins?: { lib: string; bin: AkanBinConfig }[]; libs?: string[] } = {},
|
|
30
30
|
) => {
|
|
31
|
-
const binDir = path.join(root, "
|
|
31
|
+
const binDir = path.join(root, "dist/native/portal/default/bin");
|
|
32
32
|
const config = { app: { name: "portal" }, bin, libBins } as never;
|
|
33
33
|
const carried = await new DesktopBin(fakeApp(path.join(root, "apps/portal"), libs), config).stage(binDir);
|
|
34
34
|
return { carried, binDir };
|
|
@@ -241,10 +241,13 @@ describe("DesktopServerStage", () => {
|
|
|
241
241
|
});
|
|
242
242
|
|
|
243
243
|
test("a phone build cannot carry the server", async () => {
|
|
244
|
-
const app = new NativeApp(
|
|
245
|
-
name: "
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
const app = new NativeApp(
|
|
245
|
+
{ name: "portal", cwdPath: "/repo/apps/portal", workspace: { workspaceRoot: "/repo" } } as never,
|
|
246
|
+
{
|
|
247
|
+
name: "default",
|
|
248
|
+
config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
|
|
249
|
+
},
|
|
250
|
+
);
|
|
248
251
|
const server = { dir: "/tmp/server", entry: "main.js", env: {} };
|
|
249
252
|
await expect(app.build("ios", { server })).rejects.toThrow("Only a desktop app carries its server");
|
|
250
253
|
await expect(app.run("android", { server })).rejects.toThrow("Only a desktop app carries its server");
|
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.
|
|
3
|
+
"version": "3.0.2",
|
|
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.
|
|
50
|
+
"akanjs": "3.0.2",
|
|
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(
|