@akanjs/devkit 3.0.2 → 3.1.0-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 +46 -7
- package/akanApp/akanApp.host.ts +3 -2
- package/akanConfig/akanConfig.test.ts +76 -6
- package/akanConfig/akanConfig.ts +111 -13
- package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
- package/akanConfig/akanWorkspaceConfig.ts +63 -0
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +23 -1
- package/akanContext.ts +1 -1
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +73 -84
- package/artifact/implicitRootLayout.test.ts +6 -2
- package/artifact/implicitRootLayout.ts +12 -7
- package/biome.base.json +7 -2
- package/cloud/cloudApi.ts +82 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +195 -0
- package/cloud/updatePackUpload.ts +115 -0
- package/commandDecorators/command.ts +8 -2
- package/commandDecorators/targetMeta.ts +1 -0
- package/executors.test.ts +21 -0
- package/executors.ts +9 -2
- package/frontendBuild/csrArtifactBuilder.ts +10 -1
- package/frontendBuild/cssCandidateCache.test.ts +10 -0
- package/frontendBuild/cssCandidateCache.ts +4 -2
- package/frontendBuild/pagesBundleBuilder.ts +5 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
- package/incrementalBuilder/buildBatch.proc.ts +14 -2
- package/incrementalBuilder/buildBatchProtocol.ts +3 -2
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
- package/lint/no-import-external-library-in-test.grit +24 -0
- package/mobile/desktopServerStage.test.ts +6 -0
- package/mobile/desktopServerStage.ts +25 -3
- package/mobile/index.ts +1 -0
- package/mobile/nativeApp.test.ts +1 -0
- package/mobile/nativeApp.ts +96 -34
- package/mobile/nativeConfig.test.ts +59 -1
- package/mobile/nativeConfig.ts +13 -3
- package/mobile/serverBundleSecrets.test.ts +61 -0
- package/mobile/serverBundleSecrets.ts +85 -0
- package/mobile/updateUploader.test.ts +49 -0
- package/mobile/updateUploader.ts +71 -0
- package/package.json +3 -2
- package/pipeline/PipelineCompiler.test.ts +631 -0
- package/pipeline/PipelineCompiler.ts +646 -0
- package/pipeline/PipelineDefinition.test.ts +89 -0
- package/pipeline/PipelineDefinition.ts +117 -0
- package/pipeline/PipelineDryRun.test.ts +93 -0
- package/pipeline/PipelineDryRun.ts +89 -0
- package/pipeline/PipelineInputReader.ts +739 -0
- package/pipeline/index.ts +4 -0
- package/qualityScanner.test.ts +9 -3
- package/qualityScanner.ts +5 -2
- package/scanInfo.ts +13 -6
- package/subspace.test.ts +108 -4
- package/subspace.ts +162 -10
- package/subspaceConfig.ts +11 -14
- package/testHelpers.ts +4 -0
- package/transforms/externalizeFrameworkPlugin.ts +1 -7
- package/transforms/serverEnvPlugin.test.ts +93 -2
- package/transforms/serverEnvPlugin.ts +71 -18
- package/transforms/serverEnvProfile.test.ts +82 -0
- package/transforms/serverEnvProfile.ts +114 -0
- package/typecheck/projectTypecheck.test.ts +32 -0
- package/typecheck/projectTypecheck.ts +71 -0
- package/workspaceLayout.test.ts +26 -3
- package/workspaceLayout.ts +15 -4
|
@@ -10,6 +10,16 @@ export interface DesktopServerBundle {
|
|
|
10
10
|
dir: string;
|
|
11
11
|
entry: string;
|
|
12
12
|
env: Record<string, string>;
|
|
13
|
+
restart?: "forever" | number;
|
|
14
|
+
watchdog?: { intervalMs: number; failures: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How the targets that carry one build's server have it run (`desktop.server`, normalized). */
|
|
18
|
+
export interface DesktopServerRun {
|
|
19
|
+
modules?: string[];
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
restart?: "forever" | number;
|
|
22
|
+
watchdog?: { intervalMs: number; failures: number };
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
//* A desktop app's carried server is the backend `akan build` wrote into dist, installed on its own as the image's
|
|
@@ -44,11 +54,16 @@ export class DesktopServerStage {
|
|
|
44
54
|
);
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
static env(
|
|
57
|
+
static env(
|
|
58
|
+
config: AkanAppConfig,
|
|
59
|
+
environment: NativeEnv,
|
|
60
|
+
{ modules = [], env = {} }: Pick<DesktopServerRun, "modules" | "env"> = {},
|
|
61
|
+
): Record<string, string> {
|
|
48
62
|
//? The launcher picks the port, and the log file stays on: a user's computer has no log collector.
|
|
49
63
|
const { PORT: _port, AKAN_LOG_TO_FILE: _fileLog, ...image } = config.getProductionEnv(environment);
|
|
50
64
|
return {
|
|
51
65
|
...image,
|
|
66
|
+
...env,
|
|
52
67
|
//? Neither the developer's machine (local, which derives a forgeable JWT secret) nor a deployment (cloud).
|
|
53
68
|
AKAN_PUBLIC_OPERATION_MODE: "edge",
|
|
54
69
|
AKAN_DATABASE_MODE: "single",
|
|
@@ -59,6 +74,7 @@ export class DesktopServerStage {
|
|
|
59
74
|
AKAN_MCP: "false",
|
|
60
75
|
//? Ahead of the launcher's 1.5 s grace before it signals, which sits inside the shell's 2 s quit budget.
|
|
61
76
|
AKAN_SHUTDOWN_TIMEOUT_MS: "1000",
|
|
77
|
+
...(modules.length ? { AKAN_MODULES: modules.join(",") } : {}),
|
|
62
78
|
};
|
|
63
79
|
}
|
|
64
80
|
|
|
@@ -162,7 +178,7 @@ export class DesktopServerStage {
|
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
//? Copies the build beside what `install` put in the stage, so it runs after both.
|
|
165
|
-
async prepare(environment: NativeEnv): Promise<DesktopServerBundle> {
|
|
181
|
+
async prepare(environment: NativeEnv, run: DesktopServerRun = {}): Promise<DesktopServerBundle> {
|
|
166
182
|
const config = await this.app.getConfig();
|
|
167
183
|
const dist = this.app.dist.cwdPath;
|
|
168
184
|
await mkdir(this.dir, { recursive: true });
|
|
@@ -173,6 +189,12 @@ export class DesktopServerStage {
|
|
|
173
189
|
const carried = [...DesktopBin.select(config, scanInfo.getLibs()).keys()];
|
|
174
190
|
const notice = DesktopServerStage.imageStepsNotice(config, carried);
|
|
175
191
|
if (notice) this.app.logger[notice.level](notice.message);
|
|
176
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
dir: this.dir,
|
|
194
|
+
entry: "main.js",
|
|
195
|
+
env: DesktopServerStage.env(config, environment, run),
|
|
196
|
+
...(run.restart !== undefined ? { restart: run.restart } : {}),
|
|
197
|
+
...(run.watchdog ? { watchdog: run.watchdog } : {}),
|
|
198
|
+
};
|
|
177
199
|
}
|
|
178
200
|
}
|
package/mobile/index.ts
CHANGED
package/mobile/nativeApp.test.ts
CHANGED
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
3
4
|
import type { NativeEnv } from "akanjs";
|
|
@@ -17,6 +18,7 @@ import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
|
|
|
17
18
|
import { NativeConfig } from "./nativeConfig";
|
|
18
19
|
import { NativePluginFolders } from "./nativePluginFolders";
|
|
19
20
|
import { NativeWebDir } from "./nativeWebDir";
|
|
21
|
+
import { ServerBundleSecrets } from "./serverBundleSecrets";
|
|
20
22
|
|
|
21
23
|
type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
22
24
|
type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
|
|
@@ -42,6 +44,10 @@ export interface NativeBuildOptions {
|
|
|
42
44
|
installer?: boolean;
|
|
43
45
|
/** The CPU the desktop app runs on, of the same OS; a macOS app is arm64 only. Default this computer's. */
|
|
44
46
|
arch?: DesktopArch;
|
|
47
|
+
/** Sign with the identity AKAN_NATIVE_MACOS_* / AKAN_NATIVE_WINDOWS_* names; the env alone signs nothing. */
|
|
48
|
+
sign?: boolean;
|
|
49
|
+
/** macOS: upload the signed app to Apple's notary service. */
|
|
50
|
+
notarize?: boolean;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export interface NativeDevOptions extends NativeRunOptions {
|
|
@@ -50,6 +56,25 @@ export interface NativeDevOptions extends NativeRunOptions {
|
|
|
50
56
|
lang: string;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/** `<out>/pack.json`: what a signer needs of a pack, read instead of the log. */
|
|
60
|
+
export interface NativeUpdatePack {
|
|
61
|
+
schema: 1;
|
|
62
|
+
app: string;
|
|
63
|
+
target: string;
|
|
64
|
+
platform: MobilePlatform;
|
|
65
|
+
env: NativeEnv;
|
|
66
|
+
channel: string;
|
|
67
|
+
appId: string;
|
|
68
|
+
appVersion: string;
|
|
69
|
+
nativeApi: string;
|
|
70
|
+
/** bundle.json `web.hash`: the web files as served, the part of a bundle id that names its content. */
|
|
71
|
+
webHash: string;
|
|
72
|
+
files: number;
|
|
73
|
+
size: number;
|
|
74
|
+
out: string;
|
|
75
|
+
compat?: { against: string; compatible: boolean; problems: string[] };
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/** One native target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
|
|
54
79
|
* what it ships, and the calls into the API. */
|
|
55
80
|
export class NativeApp {
|
|
@@ -194,32 +219,19 @@ export class NativeApp {
|
|
|
194
219
|
|
|
195
220
|
async build(
|
|
196
221
|
platform: NativePlatform,
|
|
197
|
-
{ profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
|
|
222
|
+
{ profile = "release", server, installer = false, arch, sign = false, notarize = false }: NativeBuildOptions = {},
|
|
198
223
|
) {
|
|
199
224
|
NativeApp.#assertServerPlatform(platform, server);
|
|
200
225
|
NativeApp.assertInstaller(platform, installer);
|
|
201
226
|
await this.assembleWeb(platform);
|
|
202
227
|
const { api, config } = await this.prepare(platform, server, arch);
|
|
228
|
+
const distribution = await this.#distribution(api, platform, profile, { sign, notarize }, server);
|
|
203
229
|
return await api.build({
|
|
204
230
|
...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
|
|
205
231
|
profile,
|
|
206
232
|
...(arch ? { arch } : {}),
|
|
207
|
-
...(platform === "windows"
|
|
208
|
-
|
|
209
|
-
windows: {
|
|
210
|
-
...(installer ? { installer } : {}),
|
|
211
|
-
...(profile === "release" ? NativeApp.#windowsSigning(api) : {}),
|
|
212
|
-
},
|
|
213
|
-
}
|
|
214
|
-
: {}),
|
|
215
|
-
...(platform === "macos"
|
|
216
|
-
? {
|
|
217
|
-
macos: {
|
|
218
|
-
...(profile === "release" ? api.macosDistributionFromEnv(process.env) : {}),
|
|
219
|
-
...(installer ? { dmg: true } : {}),
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
: {}),
|
|
233
|
+
...(platform === "windows" ? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } } : {}),
|
|
234
|
+
...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
|
|
223
235
|
...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
|
|
224
236
|
});
|
|
225
237
|
}
|
|
@@ -306,30 +318,64 @@ export class NativeApp {
|
|
|
306
318
|
/** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
|
|
307
319
|
async publishUpdate(
|
|
308
320
|
platform: NativePlatform,
|
|
309
|
-
{
|
|
321
|
+
{
|
|
322
|
+
channel,
|
|
323
|
+
server,
|
|
324
|
+
arch,
|
|
325
|
+
sign = false,
|
|
326
|
+
notarize = false,
|
|
327
|
+
}: { channel?: string; server?: DesktopServerBundle; arch?: DesktopArch; sign?: boolean; notarize?: boolean } = {},
|
|
310
328
|
) {
|
|
311
329
|
NativeApp.#assertServerPlatform(platform, server);
|
|
312
330
|
this.#assertUpdates();
|
|
313
331
|
await this.assembleWeb(platform);
|
|
314
|
-
const { api, config } = await this.prepare(platform, server);
|
|
332
|
+
const { api, config } = await this.prepare(platform, server, arch);
|
|
333
|
+
//? The updater keeps the installed app's signature, so a release is signed as the downloaded app was.
|
|
334
|
+
const distribution = await this.#distribution(api, platform, "release", { sign, notarize }, server);
|
|
315
335
|
return await api.publishUpdate({
|
|
316
336
|
...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
|
|
317
337
|
platform,
|
|
318
338
|
out: this.updatesDir,
|
|
319
339
|
...(channel ? { channel } : {}),
|
|
320
|
-
|
|
321
|
-
...(
|
|
322
|
-
...(
|
|
340
|
+
...(arch ? { arch } : {}),
|
|
341
|
+
...(distribution.macos ? { macos: distribution.macos } : {}),
|
|
342
|
+
...(distribution.windows ? { windows: distribution.windows } : {}),
|
|
323
343
|
});
|
|
324
344
|
}
|
|
325
345
|
|
|
346
|
+
//* Only what the command asked for is signed or uploaded; a signed app's server is searched for the workspace's
|
|
347
|
+
//* secret values first, and the one line naming the identity and the service comes right before the build.
|
|
348
|
+
async #distribution(
|
|
349
|
+
api: NativeBuildApiModule,
|
|
350
|
+
platform: NativePlatform,
|
|
351
|
+
profile: "debug" | "release",
|
|
352
|
+
request: { sign: boolean; notarize: boolean },
|
|
353
|
+
server: DesktopServerBundle | undefined,
|
|
354
|
+
) {
|
|
355
|
+
const distribution = api.requestedDistribution(platform, profile, request);
|
|
356
|
+
if (!distribution.notice) {
|
|
357
|
+
if (distribution.unused.length && (platform === "macos" || platform === "windows"))
|
|
358
|
+
this.app.logger.info(`${distribution.unused.join(", ")} set: this build is not signed without --sign.`);
|
|
359
|
+
return distribution;
|
|
360
|
+
}
|
|
361
|
+
if (server) {
|
|
362
|
+
const unread = await new ServerBundleSecrets(this.app.workspace.workspaceRoot, this.app.cwdPath).assertClean(
|
|
363
|
+
server.dir,
|
|
364
|
+
);
|
|
365
|
+
if (unread.length)
|
|
366
|
+
this.app.logger.warn(`Not searched for secrets, as they could not be evaluated: ${unread.join(", ")}.`);
|
|
367
|
+
}
|
|
368
|
+
this.app.logger.warn(distribution.notice);
|
|
369
|
+
return distribution;
|
|
370
|
+
}
|
|
371
|
+
|
|
326
372
|
/**
|
|
327
373
|
* What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
|
|
328
374
|
* another app's, and a desktop release whose server presence is not its channel's previous release's.
|
|
329
375
|
*/
|
|
330
376
|
async assertPublishable(
|
|
331
377
|
platform: NativePlatform,
|
|
332
|
-
{ channel, server = false }: { channel?: string; server?: boolean } = {},
|
|
378
|
+
{ channel, server = false, arch }: { channel?: string; server?: boolean; arch?: DesktopArch } = {},
|
|
333
379
|
) {
|
|
334
380
|
this.#assertUpdates();
|
|
335
381
|
const [api, { config }] = await Promise.all([
|
|
@@ -345,6 +391,7 @@ export class NativeApp {
|
|
|
345
391
|
out: this.updatesDir,
|
|
346
392
|
server,
|
|
347
393
|
...(channel ? { channel } : {}),
|
|
394
|
+
...(arch ? { arch } : {}),
|
|
348
395
|
});
|
|
349
396
|
} catch (error) {
|
|
350
397
|
throw new Error(
|
|
@@ -384,7 +431,7 @@ export class NativeApp {
|
|
|
384
431
|
});
|
|
385
432
|
}
|
|
386
433
|
|
|
387
|
-
/** An unsigned OTA update of this target's web bundle for `platform`, and the
|
|
434
|
+
/** An unsigned OTA update of this target's web bundle for `platform`, and the record `pack.json` keeps of it. */
|
|
388
435
|
async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
|
|
389
436
|
await this.assembleWeb(platform);
|
|
390
437
|
const { api, config } = await this.prepare(platform);
|
|
@@ -392,12 +439,32 @@ export class NativeApp {
|
|
|
392
439
|
throw new Error(
|
|
393
440
|
`Native target '${this.target.name}' takes no updates: add native.updates: { url, publicKey } to akan.config.ts first.`,
|
|
394
441
|
);
|
|
395
|
-
const
|
|
396
|
-
|
|
442
|
+
const dir = path.resolve(out ?? path.join(this.updatesDir, platform));
|
|
443
|
+
//? A pack that fails must not leave the previous pack's record behind for whoever reads the exit code.
|
|
444
|
+
await Promise.all(["pack.json", "compat.json"].map((name) => rm(path.join(dir, name), { force: true })));
|
|
445
|
+
const packed = await api.packUpdate({ ...this.#task(platform, config), platform, out: dir });
|
|
446
|
+
const channel = config.updates.channel ?? "production";
|
|
447
|
+
const bundle = (await Bun.file(path.join(packed.out, "bundle.json")).json()) as { web: { hash: string } };
|
|
448
|
+
const pack: NativeUpdatePack = {
|
|
449
|
+
schema: 1,
|
|
450
|
+
app: this.app.name,
|
|
451
|
+
target: this.target.name,
|
|
397
452
|
platform,
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
453
|
+
env: this.env ?? "main",
|
|
454
|
+
channel,
|
|
455
|
+
appId: packed.manifest.app,
|
|
456
|
+
appVersion: packed.manifest.version,
|
|
457
|
+
nativeApi: packed.manifest.nativeApi ?? "",
|
|
458
|
+
webHash: bundle.web.hash,
|
|
459
|
+
files: packed.manifest.files.length,
|
|
460
|
+
size: packed.manifest.files.reduce((sum, file) => sum + file.size, 0),
|
|
461
|
+
out: packed.out,
|
|
462
|
+
};
|
|
463
|
+
return { ...packed, channel, pack };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
static async writePack(pack: NativeUpdatePack) {
|
|
467
|
+
await Bun.write(path.join(pack.out, "pack.json"), `${JSON.stringify(pack, null, 2)}\n`);
|
|
401
468
|
}
|
|
402
469
|
|
|
403
470
|
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
|
@@ -437,11 +504,6 @@ export class NativeApp {
|
|
|
437
504
|
return `/${home.join("/")}?${params}`;
|
|
438
505
|
}
|
|
439
506
|
|
|
440
|
-
static #windowsSigning(api: NativeBuildApiModule) {
|
|
441
|
-
const signing = api.windowsSigningFromEnv(process.env);
|
|
442
|
-
return signing ? { signing } : {};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
507
|
//* `installer` is the file a person downloads: a setup program on Windows, a disk image on macOS, an AppImage on Linux.
|
|
446
508
|
static assertInstaller(platform: NativePlatform, installer: boolean) {
|
|
447
509
|
if (installer && (platform === "ios" || platform === "android"))
|
|
@@ -67,6 +67,30 @@ describe("NativeConfig.build", () => {
|
|
|
67
67
|
expect(build(undefined)).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test("paints the window before its page with backgroundColor, and names the Windows publisher", () => {
|
|
71
|
+
const build = (target: Partial<AkanNativeTarget>) =>
|
|
72
|
+
NativeConfig.build({
|
|
73
|
+
appPath: "/repo/apps/minimal",
|
|
74
|
+
target: { ...minimalTarget, ...target },
|
|
75
|
+
webDir: "/repo/dist/native/minimal/default/web",
|
|
76
|
+
contributions: [],
|
|
77
|
+
locales: ["en"],
|
|
78
|
+
platform: "windows",
|
|
79
|
+
}).config;
|
|
80
|
+
|
|
81
|
+
expect(build({ backgroundColor: "#000000" }).shell).toEqual({ backgroundColor: "#000000" });
|
|
82
|
+
expect(build({ backgroundColor: { light: "#fafafa", dark: "#111111" } }).shell).toEqual({
|
|
83
|
+
backgroundColor: "#fafafa",
|
|
84
|
+
backgroundColorDark: "#111111",
|
|
85
|
+
});
|
|
86
|
+
expect(build({}).shell).toBeUndefined();
|
|
87
|
+
expect(build({ backgroundColor: "#000000", devtools: true }).shell).toEqual({
|
|
88
|
+
backgroundColor: "#000000",
|
|
89
|
+
devtools: true,
|
|
90
|
+
});
|
|
91
|
+
expect(build({ desktop: { publisher: "Leading Flight" } }).desktop?.publisher).toBe("Leading Flight");
|
|
92
|
+
});
|
|
93
|
+
|
|
70
94
|
test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
|
|
71
95
|
const { config, warnings } = NativeConfig.build({
|
|
72
96
|
appPath: "/repo/apps/minimal",
|
|
@@ -208,10 +232,38 @@ describe("NativeConfig.build", () => {
|
|
|
208
232
|
readyTimeout: 15_000,
|
|
209
233
|
});
|
|
210
234
|
expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
|
|
235
|
+
const spare = Buffer.alloc(32, 8).toString("base64");
|
|
236
|
+
expect(build({ url, publicKeys: [updatesKey, spare] }, "main").updates).toEqual({
|
|
237
|
+
url,
|
|
238
|
+
publicKeys: [updatesKey, spare],
|
|
239
|
+
channel: "main",
|
|
240
|
+
});
|
|
241
|
+
const report = { url: "https://cloud.example.com/api/ota/report/abcdefghijklmnop" };
|
|
242
|
+
expect(build({ url, publicKey: updatesKey, report }, "main").updates).toEqual({
|
|
243
|
+
url,
|
|
244
|
+
publicKey: updatesKey,
|
|
245
|
+
channel: "main",
|
|
246
|
+
report,
|
|
247
|
+
});
|
|
211
248
|
expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
|
|
212
249
|
expect(build(undefined, "main").plugins).not.toContain("updates");
|
|
213
250
|
});
|
|
214
251
|
|
|
252
|
+
test("devtools opens the web inspector of a release build only when a target asks for it", () => {
|
|
253
|
+
const build = (devtools?: boolean) =>
|
|
254
|
+
NativeConfig.build({
|
|
255
|
+
appPath: "/repo/apps/portal",
|
|
256
|
+
target: { ...adminTarget, ...(devtools === undefined ? {} : { devtools }) },
|
|
257
|
+
webDir: "/web",
|
|
258
|
+
contributions: [],
|
|
259
|
+
locales: ["en"],
|
|
260
|
+
platform: "android",
|
|
261
|
+
}).config.shell;
|
|
262
|
+
expect(build(true)).toEqual({ devtools: true });
|
|
263
|
+
expect(build(false)).toBeUndefined();
|
|
264
|
+
expect(build()).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
|
|
215
267
|
test("push, privacy and the icon and splash objects reach the runtime, paths from the app folder", () => {
|
|
216
268
|
const { config } = NativeConfig.build({
|
|
217
269
|
appPath: "/repo/apps/portal",
|
|
@@ -342,7 +394,12 @@ describe("NativeConfig.build", () => {
|
|
|
342
394
|
permissions: [],
|
|
343
395
|
deepLinks: undefined,
|
|
344
396
|
android: { autoplay: true },
|
|
345
|
-
desktop: {
|
|
397
|
+
desktop: {
|
|
398
|
+
recovery: "reload",
|
|
399
|
+
window: { fullscreen: true, skipTaskbar: true },
|
|
400
|
+
screenCapture: "auto",
|
|
401
|
+
webview2: "standalone",
|
|
402
|
+
},
|
|
346
403
|
},
|
|
347
404
|
webDir: path.join(root, "web"),
|
|
348
405
|
contributions: [],
|
|
@@ -355,6 +412,7 @@ describe("NativeConfig.build", () => {
|
|
|
355
412
|
recovery: "reload",
|
|
356
413
|
window: { fullscreen: true, skipTaskbar: true },
|
|
357
414
|
screenCapture: "auto",
|
|
415
|
+
webview2: "standalone",
|
|
358
416
|
bin: root,
|
|
359
417
|
});
|
|
360
418
|
expect(config.android).toEqual({ debugAppIdSuffix: ".debug", autoplay: true });
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -175,11 +175,19 @@ export class NativeConfig {
|
|
|
175
175
|
const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
|
|
176
176
|
recovery: target.desktop?.recovery,
|
|
177
177
|
screenCapture: target.desktop?.screenCapture,
|
|
178
|
+
webview2: target.desktop?.webview2,
|
|
179
|
+
publisher: target.desktop?.publisher,
|
|
178
180
|
window: NativeConfig.#compact({ ...target.desktop?.window }),
|
|
179
181
|
server: desktopServer,
|
|
180
182
|
bin: desktopBin,
|
|
181
183
|
dmg: NativeConfig.#dmg(target.desktop?.dmg, abs),
|
|
182
184
|
});
|
|
185
|
+
const background = target.backgroundColor;
|
|
186
|
+
const shell = NativeConfig.#compact<NonNullable<AkanNativeConfig["shell"]>>({
|
|
187
|
+
backgroundColor: typeof background === "string" ? background : background?.light,
|
|
188
|
+
backgroundColorDark: typeof background === "string" ? undefined : background?.dark,
|
|
189
|
+
devtools: target.devtools ? true : undefined,
|
|
190
|
+
});
|
|
183
191
|
const config: AkanNativeConfig = {
|
|
184
192
|
app: {
|
|
185
193
|
id: resolveAppId(target.appId, platform),
|
|
@@ -223,6 +231,7 @@ export class NativeConfig {
|
|
|
223
231
|
...(desktop ? { desktop } : {}),
|
|
224
232
|
...(target.icon ? { icon: NativeConfig.#icon(target.icon, abs) } : {}),
|
|
225
233
|
...(target.splash ? { splash: NativeConfig.#splash(target.splash, abs) } : {}),
|
|
234
|
+
...(shell ? { shell } : {}),
|
|
226
235
|
};
|
|
227
236
|
return { config, warnings };
|
|
228
237
|
}
|
|
@@ -252,10 +261,11 @@ export class NativeConfig {
|
|
|
252
261
|
};
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
//? akanConfig refuses a target whose merged updates lack url or
|
|
264
|
+
//? akanConfig refuses a target whose merged updates lack url or a key; the type still has them optional.
|
|
256
265
|
static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
257
|
-
const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
|
|
258
|
-
|
|
266
|
+
const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
|
|
267
|
+
const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
|
|
268
|
+
return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}) } : undefined;
|
|
259
269
|
}
|
|
260
270
|
|
|
261
271
|
/** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
4
|
+
import { ServerBundleSecrets } from "./serverBundleSecrets";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-devkit-server-bundle-secrets-");
|
|
7
|
+
|
|
8
|
+
const workspace = async () => {
|
|
9
|
+
const root = await makeTempRoot();
|
|
10
|
+
await write(
|
|
11
|
+
path.join(root, "libs/util/env/env.server.testing.ts"),
|
|
12
|
+
`export const env = { hostname: "lib-host", security: { jwtSecret: "lib-jwt-0123456789" }, iapVerify: { apple: "0f9e8d7c6b5a49382716abcdef012345", google: { type: "service_account", private_key: "-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\\n-----END PRIVATE KEY-----\\n" } } };\n`,
|
|
13
|
+
);
|
|
14
|
+
await write(path.join(root, "libs/util/env/env.server.type.ts"), "export type Env = object;\n");
|
|
15
|
+
await write(
|
|
16
|
+
path.join(root, "apps/demo/env/env.server.main.ts"),
|
|
17
|
+
'export const env = { mailer: { auth: { user: "ops", pass: "main-mail-pass" } } };\n',
|
|
18
|
+
);
|
|
19
|
+
const bundle = path.join(root, "bundle");
|
|
20
|
+
return { root, appDir: path.join(root, "apps/demo"), bundle };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("ServerBundleSecrets", () => {
|
|
24
|
+
test("passes a bundle that holds none of the workspace's secret values, constants of a credential block aside", async () => {
|
|
25
|
+
const { root, appDir, bundle } = await workspace();
|
|
26
|
+
await write(path.join(bundle, "server.js"), 'const t = "service_account"; const host = "lib-host";\n');
|
|
27
|
+
await write(path.join(bundle, "node_modules/leaky/index.js"), 'const k = "lib-jwt-0123456789";\n');
|
|
28
|
+
expect(await new ServerBundleSecrets(root, appDir).assertClean(bundle)).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("refuses one that does, naming the file, the env file and the key path and never the value", async () => {
|
|
32
|
+
const { root, appDir, bundle } = await workspace();
|
|
33
|
+
await write(
|
|
34
|
+
path.join(bundle, "chunk-a.js"),
|
|
35
|
+
'const e = { pass: "main-mail-pass", apple: "0f9e8d7c6b5a49382716abcdef012345" };\n',
|
|
36
|
+
);
|
|
37
|
+
await write(
|
|
38
|
+
path.join(bundle, "chunk-b.js"),
|
|
39
|
+
'const pem = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7";\n',
|
|
40
|
+
);
|
|
41
|
+
const error = await new ServerBundleSecrets(root, appDir).assertClean(bundle).catch((e: Error) => e);
|
|
42
|
+
expect(error).toBeInstanceOf(Error);
|
|
43
|
+
const message = (error as Error).message;
|
|
44
|
+
expect(message).toContain("chunk-a.js holds apps/demo/env/env.server.main.ts mailer.auth.pass");
|
|
45
|
+
expect(message).toContain("chunk-a.js holds libs/util/env/env.server.testing.ts iapVerify.apple");
|
|
46
|
+
expect(message).toContain("chunk-b.js holds libs/util/env/env.server.testing.ts iapVerify.google.private_key");
|
|
47
|
+
for (const secret of ["main-mail-pass", "0f9e8d7c", "MIIEvQ"]) expect(message).not.toContain(secret);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("names an env file it could not evaluate instead of passing it silently", async () => {
|
|
51
|
+
const { root, appDir, bundle } = await workspace();
|
|
52
|
+
await write(
|
|
53
|
+
path.join(appDir, "env/env.server.develop.ts"),
|
|
54
|
+
'import { gone } from "./missing";\nexport const env = gone;\n',
|
|
55
|
+
);
|
|
56
|
+
await write(path.join(bundle, "server.js"), "\n");
|
|
57
|
+
expect(await new ServerBundleSecrets(root, appDir).assertClean(bundle)).toEqual([
|
|
58
|
+
"apps/demo/env/env.server.develop.ts",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ServerEnvProfile } from "../transforms/serverEnvProfile";
|
|
4
|
+
|
|
5
|
+
interface SecretValue {
|
|
6
|
+
file: string;
|
|
7
|
+
path: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
//* A signed app leaves the building, and a notarized one goes to Apple first: before either, every secret value the
|
|
12
|
+
//* workspace's server env files hold is looked for in the server the app carries, whatever put it there.
|
|
13
|
+
export class ServerBundleSecrets {
|
|
14
|
+
static readonly #largestFile = 64 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
readonly workspaceRoot: string,
|
|
18
|
+
readonly appDir: string,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/** The app's `env/env.server.<name>.ts` and every lib's, but the type and example files. */
|
|
22
|
+
async envFiles(): Promise<string[]> {
|
|
23
|
+
const libsDir = path.join(this.workspaceRoot, "libs");
|
|
24
|
+
const libs = await readdir(libsDir).catch(() => [] as string[]);
|
|
25
|
+
const dirs = [path.join(this.appDir, "env"), ...libs.map((lib) => path.join(libsDir, lib, "env"))];
|
|
26
|
+
const files = await Promise.all(
|
|
27
|
+
dirs.map(async (dir) =>
|
|
28
|
+
(await readdir(dir).catch(() => [] as string[]))
|
|
29
|
+
.filter((name) => /^env\.server\.[A-Za-z0-9.-]+\.ts$/.test(name) && !/\.(type|example)\.ts$/.test(name))
|
|
30
|
+
.map((name) => path.join(dir, name)),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
return files.flat();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Refuses, naming files and key paths and never a value, when the bundle holds one; returns the env files it could not read. */
|
|
37
|
+
async assertClean(bundleDir: string): Promise<string[]> {
|
|
38
|
+
const unread: string[] = [];
|
|
39
|
+
const secrets: SecretValue[] = [];
|
|
40
|
+
for (const file of await this.envFiles()) {
|
|
41
|
+
try {
|
|
42
|
+
const value = await ServerEnvProfile.evaluate(file, path.dirname(path.dirname(file)));
|
|
43
|
+
secrets.push(...ServerEnvProfile.secretValues(value).map((secret) => ({ file, ...secret })));
|
|
44
|
+
} catch {
|
|
45
|
+
unread.push(ServerBundleSecrets.#relative(this.workspaceRoot, file));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const found: string[] = [];
|
|
49
|
+
for (const bundled of await ServerBundleSecrets.#files(bundleDir)) {
|
|
50
|
+
const text = await readFile(bundled, "latin1");
|
|
51
|
+
for (const secret of secrets)
|
|
52
|
+
if (ServerBundleSecrets.#needles(secret.value).some((needle) => text.includes(needle)))
|
|
53
|
+
found.push(
|
|
54
|
+
`${ServerBundleSecrets.#relative(bundleDir, bundled)} holds ${ServerBundleSecrets.#relative(this.workspaceRoot, secret.file)} ${secret.path}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (found.length)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`The server this app carries holds secret values from the workspace's env files, so it is not signed or sent anywhere:\n ${[...new Set(found)].join("\n ")}\nBuild its server with a profile (native.desktop.server.profile), whose bundle carries no secret.`,
|
|
60
|
+
);
|
|
61
|
+
return unread;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//? A multi-line key sits in a bundle as a JS string, escaped; one long line of it is enough to recognise.
|
|
65
|
+
static #needles(value: string): string[] {
|
|
66
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
67
|
+
const line = value.split(/\r?\n/).find((part) => part.length >= 32);
|
|
68
|
+
return [...new Set([value, escaped, ...(line ? [line] : [])])];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static #relative(from: string, to: string) {
|
|
72
|
+
return path.relative(from, to).split(path.sep).join("/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static async #files(dir: string): Promise<string[]> {
|
|
76
|
+
const files: string[] = [];
|
|
77
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
78
|
+
const file = path.join(dir, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (entry.name !== "node_modules") files.push(...(await ServerBundleSecrets.#files(file)));
|
|
81
|
+
} else if (entry.isFile() && (await stat(file)).size <= ServerBundleSecrets.#largestFile) files.push(file);
|
|
82
|
+
}
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { type UpdateBucket, UpdateUploader } from "./updateUploader";
|
|
6
|
+
|
|
7
|
+
const fakeBucket = (held: string[] = []) => {
|
|
8
|
+
const writes: { key: string; type?: string }[] = [];
|
|
9
|
+
const bucket: UpdateBucket = {
|
|
10
|
+
exists: async (key) => held.includes(key),
|
|
11
|
+
write: async (key, _data, options) => {
|
|
12
|
+
writes.push({ key, ...(options?.type ? { type: options.type } : {}) });
|
|
13
|
+
return 0;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
return { bucket, writes };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("UpdateUploader", () => {
|
|
20
|
+
test("reads s3://bucket/prefix and refuses anything else", () => {
|
|
21
|
+
expect(UpdateUploader.parseTarget("s3://releases/dcms/kiosk/")).toEqual({
|
|
22
|
+
bucket: "releases",
|
|
23
|
+
prefix: "dcms/kiosk",
|
|
24
|
+
});
|
|
25
|
+
expect(UpdateUploader.parseTarget("s3://releases")).toEqual({ bucket: "releases", prefix: "" });
|
|
26
|
+
expect(() => UpdateUploader.parseTarget("https://releases.example/dcms")).toThrow("s3://<bucket>/<prefix>");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("uploads what the bucket lacks first, then each signature before its manifest", async () => {
|
|
30
|
+
const dir = await mkdtemp(path.join(tmpdir(), "akan-update-upload-"));
|
|
31
|
+
try {
|
|
32
|
+
await mkdir(path.join(dir, "windows-x64", "files"), { recursive: true });
|
|
33
|
+
for (const file of ["windows-x64/files/2000-aa.tar.gz", "windows-x64/files/1000-bb.tar.gz"])
|
|
34
|
+
await writeFile(path.join(dir, file), "tar");
|
|
35
|
+
for (const file of ["windows-x64/main.json", "windows-x64/main.json.sig", "windows-x64/main.json.tmp"])
|
|
36
|
+
await writeFile(path.join(dir, file), "{}");
|
|
37
|
+
const { bucket, writes } = fakeBucket(["dcms/windows-x64/files/1000-bb.tar.gz"]);
|
|
38
|
+
|
|
39
|
+
expect(await new UpdateUploader(bucket, "dcms").upload(dir)).toEqual({ uploaded: 3, skipped: 1 });
|
|
40
|
+
expect(writes).toEqual([
|
|
41
|
+
{ key: "dcms/windows-x64/files/2000-aa.tar.gz" },
|
|
42
|
+
{ key: "dcms/windows-x64/main.json.sig", type: "text/plain" },
|
|
43
|
+
{ key: "dcms/windows-x64/main.json", type: "application/json" },
|
|
44
|
+
]);
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|