@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +46 -7
  2. package/akanApp/akanApp.host.ts +3 -2
  3. package/akanConfig/akanConfig.test.ts +76 -6
  4. package/akanConfig/akanConfig.ts +111 -13
  5. package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
  6. package/akanConfig/akanWorkspaceConfig.ts +63 -0
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +23 -1
  9. package/akanContext.ts +1 -1
  10. package/applicationBuildRunner.test.ts +1 -1
  11. package/applicationBuildRunner.ts +73 -84
  12. package/artifact/implicitRootLayout.test.ts +6 -2
  13. package/artifact/implicitRootLayout.ts +12 -7
  14. package/biome.base.json +7 -2
  15. package/cloud/cloudApi.ts +82 -0
  16. package/cloud/globalConfig.ts +4 -0
  17. package/cloud/index.ts +1 -0
  18. package/cloud/updatePackUpload.test.ts +195 -0
  19. package/cloud/updatePackUpload.ts +115 -0
  20. package/commandDecorators/command.ts +8 -2
  21. package/commandDecorators/targetMeta.ts +1 -0
  22. package/executors.test.ts +21 -0
  23. package/executors.ts +9 -2
  24. package/frontendBuild/csrArtifactBuilder.ts +10 -1
  25. package/frontendBuild/cssCandidateCache.test.ts +10 -0
  26. package/frontendBuild/cssCandidateCache.ts +4 -2
  27. package/frontendBuild/pagesBundleBuilder.ts +5 -1
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
  29. package/incrementalBuilder/buildBatch.proc.ts +14 -2
  30. package/incrementalBuilder/buildBatchProtocol.ts +3 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
  32. package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
  33. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
  34. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
  35. package/lint/no-import-external-library-in-test.grit +24 -0
  36. package/mobile/desktopServerStage.test.ts +6 -0
  37. package/mobile/desktopServerStage.ts +25 -3
  38. package/mobile/index.ts +1 -0
  39. package/mobile/nativeApp.test.ts +1 -0
  40. package/mobile/nativeApp.ts +96 -34
  41. package/mobile/nativeConfig.test.ts +59 -1
  42. package/mobile/nativeConfig.ts +13 -3
  43. package/mobile/serverBundleSecrets.test.ts +61 -0
  44. package/mobile/serverBundleSecrets.ts +85 -0
  45. package/mobile/updateUploader.test.ts +49 -0
  46. package/mobile/updateUploader.ts +71 -0
  47. package/package.json +3 -2
  48. package/pipeline/PipelineCompiler.test.ts +631 -0
  49. package/pipeline/PipelineCompiler.ts +646 -0
  50. package/pipeline/PipelineDefinition.test.ts +89 -0
  51. package/pipeline/PipelineDefinition.ts +117 -0
  52. package/pipeline/PipelineDryRun.test.ts +93 -0
  53. package/pipeline/PipelineDryRun.ts +89 -0
  54. package/pipeline/PipelineInputReader.ts +739 -0
  55. package/pipeline/index.ts +4 -0
  56. package/qualityScanner.test.ts +9 -3
  57. package/qualityScanner.ts +5 -2
  58. package/scanInfo.ts +13 -6
  59. package/subspace.test.ts +108 -4
  60. package/subspace.ts +162 -10
  61. package/subspaceConfig.ts +11 -14
  62. package/testHelpers.ts +4 -0
  63. package/transforms/externalizeFrameworkPlugin.ts +1 -7
  64. package/transforms/serverEnvPlugin.test.ts +93 -2
  65. package/transforms/serverEnvPlugin.ts +71 -18
  66. package/transforms/serverEnvProfile.test.ts +82 -0
  67. package/transforms/serverEnvProfile.ts +114 -0
  68. package/typecheck/projectTypecheck.test.ts +32 -0
  69. package/typecheck/projectTypecheck.ts +71 -0
  70. package/workspaceLayout.test.ts +26 -3
  71. 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(config: AkanAppConfig, environment: NativeEnv): Record<string, string> {
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 { dir: this.dir, entry: "main.js", env: DesktopServerStage.env(config, environment) };
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
@@ -7,4 +7,5 @@ export * from "./nativeConfig";
7
7
  export * from "./nativePluginFolders";
8
8
  export * from "./nativeWebDir";
9
9
  export * from "./placeholderAppId";
10
+ export * from "./updateUploader";
10
11
  export * from "./usageDescriptions";
@@ -58,6 +58,7 @@ describe("NativeApp", () => {
58
58
  ({
59
59
  config: {},
60
60
  api: {
61
+ requestedDistribution: () => ({ notice: null, unused: [] }),
61
62
  build: async ({ env }: { env?: Record<string, string> }) => {
62
63
  seen = env;
63
64
  return { artifacts: [], warnings: [] };
@@ -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
- { channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
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
- //? The updater keeps the installed app's signature, so a release is signed as the downloaded app was.
321
- ...(platform === "macos" ? { macos: api.macosDistributionFromEnv(process.env) } : {}),
322
- ...(platform === "windows" ? { windows: NativeApp.#windowsSigning(api) } : {}),
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 channel its binary follows. */
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 packed = await api.packUpdate({
396
- ...this.#task(platform, config),
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
- out: out ?? path.join(this.updatesDir, platform),
399
- });
400
- return { ...packed, channel: config.updates.channel ?? "production" };
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: { recovery: "reload", window: { fullscreen: true, skipTaskbar: true }, screenCapture: "auto" },
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 });
@@ -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 publicKey; the type still has them optional.
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
- return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
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
+ });