@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3
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/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +69 -0
- package/executors.ts +30 -27
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/typecheck/projectTypecheck.ts +6 -5
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -3,9 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import type { AkanPlugin } from "akanjs";
|
|
4
4
|
import type { AkanNativeTarget } from "../akanConfig";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
|
+
import { CsrArtifactBuilder } from "../frontendBuild/csrArtifactBuilder";
|
|
6
7
|
import { tempDirs, writeText } from "../testHelpers";
|
|
7
8
|
import { NativeApp } from "./nativeApp";
|
|
8
9
|
import { NativeConfig } from "./nativeConfig";
|
|
10
|
+
import { NativePublicEnv } from "./nativePublicEnv";
|
|
9
11
|
|
|
10
12
|
const makeTempRoot = tempDirs("akan-native-app-");
|
|
11
13
|
|
|
@@ -88,6 +90,139 @@ describe("NativeApp", () => {
|
|
|
88
90
|
});
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
describe("the server a release carries, signed or not", () => {
|
|
94
|
+
const releasing = async () => {
|
|
95
|
+
const root = await makeTempRoot();
|
|
96
|
+
const appPath = path.join(root, "apps/portal");
|
|
97
|
+
await writeText(
|
|
98
|
+
path.join(appPath, "env/env.server.main.ts"),
|
|
99
|
+
'export const env = { security: { jwtSecret: "main-jwt-0123456789" } };\n',
|
|
100
|
+
);
|
|
101
|
+
const server = { dir: path.join(root, "server"), entry: "main.js", env: {} };
|
|
102
|
+
await writeText(path.join(server.dir, "main.js"), 'const jwt = "main-jwt-0123456789";\n');
|
|
103
|
+
const app = {
|
|
104
|
+
...fakeApp(),
|
|
105
|
+
cwdPath: appPath,
|
|
106
|
+
workspace: { workspaceRoot: root },
|
|
107
|
+
logger: { info: () => undefined, warn: () => undefined },
|
|
108
|
+
} as unknown as App;
|
|
109
|
+
const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
|
|
110
|
+
const nativeApp = new NativeApp(app, target({ updates }), "main");
|
|
111
|
+
const built: string[] = [];
|
|
112
|
+
nativeApp.assembleWeb = async () => undefined as never;
|
|
113
|
+
nativeApp.prepare = async () =>
|
|
114
|
+
({
|
|
115
|
+
config: {},
|
|
116
|
+
api: {
|
|
117
|
+
requestedDistribution: () => ({ notice: null, unused: [] }),
|
|
118
|
+
build: async ({ profile }: { profile: string }) => {
|
|
119
|
+
built.push(profile);
|
|
120
|
+
return { artifacts: [], warnings: [] };
|
|
121
|
+
},
|
|
122
|
+
publishUpdate: async () => {
|
|
123
|
+
built.push("update");
|
|
124
|
+
return {};
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}) as never;
|
|
128
|
+
return { appPath, server, nativeApp, built };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
test("an unsigned release build and an unsigned update are refused when it holds a secret, a debug build is not searched", async () => {
|
|
132
|
+
const { server, nativeApp, built } = await releasing();
|
|
133
|
+
await expect(nativeApp.build("linux", { server })).rejects.toThrow(
|
|
134
|
+
"main.js holds apps/portal/env/env.server.main.ts security.jwtSecret",
|
|
135
|
+
);
|
|
136
|
+
await expect(nativeApp.publishUpdate("linux", { server })).rejects.toThrow("security.jwtSecret");
|
|
137
|
+
await nativeApp.build("linux", { server, profile: "debug" });
|
|
138
|
+
expect(built).toEqual(["debug"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an env file that cannot be evaluated stops the release instead of going unsearched", async () => {
|
|
142
|
+
const { appPath, server, nativeApp, built } = await releasing();
|
|
143
|
+
await writeText(path.join(server.dir, "main.js"), "\n");
|
|
144
|
+
await writeText(path.join(appPath, "env/env.server.develop.ts"), 'export { env } from "./missing";\n');
|
|
145
|
+
await expect(nativeApp.build("linux", { server })).rejects.toThrow(
|
|
146
|
+
"The server this app carries was not searched for the secret values of apps/portal/env/env.server.develop.ts",
|
|
147
|
+
);
|
|
148
|
+
expect(built).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("the public env a build inlined, recorded in its bundle.json", () => {
|
|
153
|
+
const publicEnv = { AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com" };
|
|
154
|
+
const building = async (inlined: Record<string, string>, updates: object | undefined) => {
|
|
155
|
+
const root = await makeTempRoot();
|
|
156
|
+
const warned: string[] = [];
|
|
157
|
+
const app = {
|
|
158
|
+
...fakeApp(),
|
|
159
|
+
workspace: { workspaceRoot: root },
|
|
160
|
+
dist: { cwdPath: path.join(root, "dist/apps/portal") },
|
|
161
|
+
logger: { info: () => undefined, warn: (message: string) => warned.push(message) },
|
|
162
|
+
} as unknown as App;
|
|
163
|
+
await CsrArtifactBuilder.writePublicEnv(
|
|
164
|
+
app,
|
|
165
|
+
Object.fromEntries(
|
|
166
|
+
Object.entries({ ...inlined, AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_RENDER_ENV: "csr" }).map(
|
|
167
|
+
([key, value]) => [`process.env.${key}`, JSON.stringify(value)],
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
const nativeApp = new NativeApp(app, target(updates ? { updates } : {}), "main");
|
|
172
|
+
const built: string[] = [];
|
|
173
|
+
const writeBundle = async (dir: string) =>
|
|
174
|
+
await writeText(path.join(dir, "bundle.json"), JSON.stringify({ schema: 1, web: { hash: "w", files: 1 } }));
|
|
175
|
+
nativeApp.assembleWeb = async () => undefined as never;
|
|
176
|
+
nativeApp.prepare = async () =>
|
|
177
|
+
({
|
|
178
|
+
config: { updates: { channel: "production" } },
|
|
179
|
+
api: {
|
|
180
|
+
packUpdate: async ({ out }: { out: string }) => {
|
|
181
|
+
built.push("pack");
|
|
182
|
+
await writeBundle(out);
|
|
183
|
+
return { out, manifest: { app: "com.portal.app", version: "1.0.0", nativeApi: "n", files: [] } };
|
|
184
|
+
},
|
|
185
|
+
release: async ({ outDir }: { outDir: string }) => {
|
|
186
|
+
built.push("release");
|
|
187
|
+
await writeBundle(outDir);
|
|
188
|
+
return {};
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}) as never;
|
|
192
|
+
return { root, nativeApp, built, warned };
|
|
193
|
+
};
|
|
194
|
+
const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
|
|
195
|
+
|
|
196
|
+
test("a pack and a release record what the CSR build inlined, and the pack's record carries its hash", async () => {
|
|
197
|
+
const { root, nativeApp } = await building(publicEnv, updates);
|
|
198
|
+
const { pack } = await nativeApp.packUpdate("android", { out: path.join(root, "out") });
|
|
199
|
+
const bundle = await Bun.file(path.join(root, "out/bundle.json")).json();
|
|
200
|
+
expect(bundle.web).toEqual({
|
|
201
|
+
hash: "w",
|
|
202
|
+
files: 1,
|
|
203
|
+
publicEnv,
|
|
204
|
+
publicEnvHash: NativePublicEnv.hash(publicEnv),
|
|
205
|
+
});
|
|
206
|
+
expect(pack.publicEnvHash).toBe(NativePublicEnv.hash(publicEnv));
|
|
207
|
+
await nativeApp.releaseIos();
|
|
208
|
+
const release = await Bun.file(path.join(nativeApp.outDir("ios"), "bundle.json")).json();
|
|
209
|
+
expect(release.web.publicEnvHash).toBe(NativePublicEnv.hash(publicEnv));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("an env an update cannot carry stops a target with updates before it builds, and only warns without", async () => {
|
|
213
|
+
const refused = { ...publicEnv, AKAN_PUBLIC_MAP_TOKEN: "pk.x" };
|
|
214
|
+
const withUpdates = await building(refused, updates);
|
|
215
|
+
await expect(withUpdates.nativeApp.releaseIos()).rejects.toThrow("AKAN_PUBLIC_MAP_TOKEN names a token");
|
|
216
|
+
expect(withUpdates.built).toEqual([]);
|
|
217
|
+
const without = await building(refused, undefined);
|
|
218
|
+
await without.nativeApp.releaseIos();
|
|
219
|
+
expect(without.built).toEqual(["release"]);
|
|
220
|
+
expect(without.warned.join("\n")).toContain("bundle.json records none");
|
|
221
|
+
const release = await Bun.file(path.join(without.nativeApp.outDir("ios"), "bundle.json")).json();
|
|
222
|
+
expect(release.web.publicEnv).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
91
226
|
test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
|
|
92
227
|
const root = await makeTempRoot();
|
|
93
228
|
const app = {
|
|
@@ -232,36 +367,3 @@ describe("NativeApp", () => {
|
|
|
232
367
|
);
|
|
233
368
|
});
|
|
234
369
|
});
|
|
235
|
-
|
|
236
|
-
describe("a pack read back from its folder", () => {
|
|
237
|
-
test("takes the folder as the pack's place, and refuses what is not a takeable pack", async () => {
|
|
238
|
-
const root = await makeTempRoot();
|
|
239
|
-
const pack = {
|
|
240
|
-
schema: 1,
|
|
241
|
-
app: "lu",
|
|
242
|
-
target: "default",
|
|
243
|
-
platform: "android",
|
|
244
|
-
env: "main",
|
|
245
|
-
channel: "main",
|
|
246
|
-
appId: "com.lu",
|
|
247
|
-
appVersion: "1.0.0",
|
|
248
|
-
nativeApi: "0123456789abcdef",
|
|
249
|
-
webHash: "f".repeat(64),
|
|
250
|
-
files: 1,
|
|
251
|
-
size: 13,
|
|
252
|
-
out: "/elsewhere/updates/android",
|
|
253
|
-
};
|
|
254
|
-
const moved = path.join(root, "moved");
|
|
255
|
-
await writeText(path.join(moved, "pack.json"), JSON.stringify(pack));
|
|
256
|
-
expect(await NativeApp.readPack(moved)).toEqual({ ...pack, out: moved } as never);
|
|
257
|
-
|
|
258
|
-
await expect(NativeApp.readPack(path.join(root, "empty"))).rejects.toThrow("holds no pack.json");
|
|
259
|
-
const later = path.join(root, "later");
|
|
260
|
-
await writeText(path.join(later, "pack.json"), JSON.stringify({ ...pack, schema: 2 }));
|
|
261
|
-
await expect(NativeApp.readPack(later)).rejects.toThrow("schema 2");
|
|
262
|
-
const refused = path.join(root, "refused");
|
|
263
|
-
const compat = { against: "store/bundle.json", compatible: false, problems: ["deepLinks differ"] };
|
|
264
|
-
await writeText(path.join(refused, "pack.json"), JSON.stringify({ ...pack, compat }));
|
|
265
|
-
expect((await NativeApp.readPack(refused)).compat).toEqual(compat);
|
|
266
|
-
});
|
|
267
|
-
});
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { rm } from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
3
|
import type { NativeEnv } from "akanjs";
|
|
4
|
+
import { toError } from "akanjs/common";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { Executor } from "../executors";
|
|
7
7
|
import { type DesktopArch, DesktopBin } from "./desktopBin";
|
|
@@ -17,13 +17,16 @@ import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
|
|
|
17
17
|
import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
|
|
18
18
|
import { NativeConfig } from "./nativeConfig";
|
|
19
19
|
import { NativePluginFolders } from "./nativePluginFolders";
|
|
20
|
+
import { NativePublicEnv, type PublicEnv } from "./nativePublicEnv";
|
|
20
21
|
import { NativeWebDir } from "./nativeWebDir";
|
|
21
22
|
import { ServerBundleSecrets } from "./serverBundleSecrets";
|
|
23
|
+
import { type NativeUpdatePack, UpdatePackFolder } from "./updatePackFolder";
|
|
22
24
|
|
|
23
25
|
type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
24
26
|
type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
|
|
25
27
|
type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
|
|
26
28
|
type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
|
|
29
|
+
type PhoneRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
|
|
27
30
|
|
|
28
31
|
export interface NativeRunOptions {
|
|
29
32
|
device?: DeviceSelector;
|
|
@@ -56,25 +59,6 @@ export interface NativeDevOptions extends NativeRunOptions {
|
|
|
56
59
|
lang: string;
|
|
57
60
|
}
|
|
58
61
|
|
|
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
|
-
|
|
78
62
|
/** One native target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
|
|
79
63
|
* what it ships, and the calls into the API. */
|
|
80
64
|
export class NativeApp {
|
|
@@ -224,16 +208,23 @@ export class NativeApp {
|
|
|
224
208
|
NativeApp.#assertServerPlatform(platform, server);
|
|
225
209
|
NativeApp.assertInstaller(platform, installer);
|
|
226
210
|
await this.assembleWeb(platform);
|
|
211
|
+
const publicEnv = await this.#publicEnv();
|
|
227
212
|
const { api, config } = await this.prepare(platform, server, arch);
|
|
228
213
|
const distribution = await this.#distribution(api, platform, profile, { sign, notarize }, server);
|
|
229
|
-
return await
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
214
|
+
return await this.#recorded(
|
|
215
|
+
this.outDir(platform),
|
|
216
|
+
publicEnv,
|
|
217
|
+
api.build({
|
|
218
|
+
...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
|
|
219
|
+
profile,
|
|
220
|
+
...(arch ? { arch } : {}),
|
|
221
|
+
...(platform === "windows"
|
|
222
|
+
? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } }
|
|
223
|
+
: {}),
|
|
224
|
+
...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
|
|
225
|
+
...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
237
228
|
}
|
|
238
229
|
|
|
239
230
|
async run(
|
|
@@ -302,12 +293,17 @@ export class NativeApp {
|
|
|
302
293
|
|
|
303
294
|
async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
|
|
304
295
|
await this.assembleWeb("ios");
|
|
296
|
+
const publicEnv = await this.#publicEnv();
|
|
305
297
|
const { api, config } = await this.prepare("ios");
|
|
306
|
-
return await
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
298
|
+
return await this.#recorded(
|
|
299
|
+
this.outDir("ios"),
|
|
300
|
+
publicEnv,
|
|
301
|
+
api.release({
|
|
302
|
+
...this.#task("ios", config, { env: await this.#runtimeEnv() }),
|
|
303
|
+
platform: "ios",
|
|
304
|
+
signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
311
307
|
}
|
|
312
308
|
|
|
313
309
|
/** Where publishUpdate writes: upload this folder as the target's updates.url. */
|
|
@@ -324,7 +320,15 @@ export class NativeApp {
|
|
|
324
320
|
arch,
|
|
325
321
|
sign = false,
|
|
326
322
|
notarize = false,
|
|
327
|
-
|
|
323
|
+
revoke,
|
|
324
|
+
resetSequence,
|
|
325
|
+
}: {
|
|
326
|
+
channel?: string;
|
|
327
|
+
server?: DesktopServerBundle;
|
|
328
|
+
arch?: DesktopArch;
|
|
329
|
+
sign?: boolean;
|
|
330
|
+
notarize?: boolean;
|
|
331
|
+
} & PhoneRevocation = {},
|
|
328
332
|
) {
|
|
329
333
|
NativeApp.#assertServerPlatform(platform, server);
|
|
330
334
|
this.#assertUpdates();
|
|
@@ -340,11 +344,14 @@ export class NativeApp {
|
|
|
340
344
|
...(arch ? { arch } : {}),
|
|
341
345
|
...(distribution.macos ? { macos: distribution.macos } : {}),
|
|
342
346
|
...(distribution.windows ? { windows: distribution.windows } : {}),
|
|
347
|
+
...(revoke?.length ? { revoke } : {}),
|
|
348
|
+
...(resetSequence ? { resetSequence } : {}),
|
|
343
349
|
});
|
|
344
350
|
}
|
|
345
351
|
|
|
346
|
-
//* Only what the command asked for is signed or uploaded
|
|
347
|
-
//*
|
|
352
|
+
//* Only what the command asked for is signed or uploaded. A release's server is searched for the workspace's secret
|
|
353
|
+
//* values first, signed or not: an unsigned app is handed out too, and an update is published to every install. The
|
|
354
|
+
//* one line naming the identity and the service comes right before the build.
|
|
348
355
|
async #distribution(
|
|
349
356
|
api: NativeBuildApiModule,
|
|
350
357
|
platform: NativePlatform,
|
|
@@ -353,29 +360,40 @@ export class NativeApp {
|
|
|
353
360
|
server: DesktopServerBundle | undefined,
|
|
354
361
|
) {
|
|
355
362
|
const distribution = api.requestedDistribution(platform, profile, request);
|
|
363
|
+
if (server && profile === "release") await this.#assertServerClean(server);
|
|
356
364
|
if (!distribution.notice) {
|
|
357
365
|
if (distribution.unused.length && (platform === "macos" || platform === "windows"))
|
|
358
366
|
this.app.logger.info(`${distribution.unused.join(", ")} set: this build is not signed without --sign.`);
|
|
359
367
|
return distribution;
|
|
360
368
|
}
|
|
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
369
|
this.app.logger.warn(distribution.notice);
|
|
369
370
|
return distribution;
|
|
370
371
|
}
|
|
371
372
|
|
|
373
|
+
async #assertServerClean(server: DesktopServerBundle) {
|
|
374
|
+
const unread = await new ServerBundleSecrets(this.app.workspace.workspaceRoot, this.app.cwdPath).assertClean(
|
|
375
|
+
server.dir,
|
|
376
|
+
);
|
|
377
|
+
if (unread.length)
|
|
378
|
+
throw new Error(
|
|
379
|
+
`The server this app carries was not searched for the secret values of ${unread.join(", ")}, which could not be evaluated with no app env set, so the release stops here.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
372
383
|
/**
|
|
373
384
|
* What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
|
|
374
|
-
* another app's,
|
|
385
|
+
* another app's, a desktop release whose server presence is not its channel's previous release's, and a phone release
|
|
386
|
+
* whose revocations no phone would take.
|
|
375
387
|
*/
|
|
376
388
|
async assertPublishable(
|
|
377
389
|
platform: NativePlatform,
|
|
378
|
-
{
|
|
390
|
+
{
|
|
391
|
+
channel,
|
|
392
|
+
server = false,
|
|
393
|
+
arch,
|
|
394
|
+
revoke,
|
|
395
|
+
resetSequence,
|
|
396
|
+
}: { channel?: string; server?: boolean; arch?: DesktopArch } & PhoneRevocation = {},
|
|
379
397
|
) {
|
|
380
398
|
this.#assertUpdates();
|
|
381
399
|
const [api, { config }] = await Promise.all([
|
|
@@ -392,14 +410,13 @@ export class NativeApp {
|
|
|
392
410
|
server,
|
|
393
411
|
...(channel ? { channel } : {}),
|
|
394
412
|
...(arch ? { arch } : {}),
|
|
413
|
+
...(revoke?.length ? { revoke } : {}),
|
|
414
|
+
...(resetSequence ? { resetSequence } : {}),
|
|
395
415
|
});
|
|
396
416
|
} catch (error) {
|
|
397
|
-
throw new Error(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
cause: error,
|
|
401
|
-
},
|
|
402
|
-
);
|
|
417
|
+
throw new Error(`Native target '${this.target.name}': ${toError(error).message}`, {
|
|
418
|
+
cause: error,
|
|
419
|
+
});
|
|
403
420
|
}
|
|
404
421
|
}
|
|
405
422
|
|
|
@@ -422,29 +439,38 @@ export class NativeApp {
|
|
|
422
439
|
async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
|
|
423
440
|
const signing = NativeApp.androidSigning();
|
|
424
441
|
await this.assembleWeb("android");
|
|
442
|
+
const publicEnv = await this.#publicEnv();
|
|
425
443
|
const { api, config } = await this.prepare("android");
|
|
426
|
-
return await
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
444
|
+
return await this.#recorded(
|
|
445
|
+
this.outDir("android"),
|
|
446
|
+
publicEnv,
|
|
447
|
+
api.release({
|
|
448
|
+
...this.#task("android", config, { env: await this.#runtimeEnv() }),
|
|
449
|
+
platform: "android",
|
|
450
|
+
signing,
|
|
451
|
+
formats,
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
432
454
|
}
|
|
433
455
|
|
|
434
456
|
/** An unsigned OTA update of this target's web bundle for `platform`, and the record `pack.json` keeps of it. */
|
|
435
457
|
async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
|
|
436
458
|
await this.assembleWeb(platform);
|
|
459
|
+
const publicEnv = await this.#publicEnv();
|
|
437
460
|
const { api, config } = await this.prepare(platform);
|
|
438
461
|
if (!config.updates)
|
|
439
462
|
throw new Error(
|
|
440
463
|
`Native target '${this.target.name}' takes no updates: add native.updates: { url, publicKey } to akan.config.ts first.`,
|
|
441
464
|
);
|
|
442
465
|
const dir = path.resolve(out ?? path.join(this.updatesDir, platform));
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
466
|
+
await UpdatePackFolder.clear(dir);
|
|
467
|
+
const packed = await this.#recorded(
|
|
468
|
+
dir,
|
|
469
|
+
publicEnv,
|
|
470
|
+
api.packUpdate({ ...this.#task(platform, config), platform, out: dir }),
|
|
471
|
+
);
|
|
446
472
|
const channel = config.updates.channel ?? "production";
|
|
447
|
-
const bundle =
|
|
473
|
+
const bundle = await UpdatePackFolder.readBundle(path.join(packed.out, UpdatePackFolder.bundleFile));
|
|
448
474
|
const pack: NativeUpdatePack = {
|
|
449
475
|
schema: 1,
|
|
450
476
|
app: this.app.name,
|
|
@@ -456,6 +482,7 @@ export class NativeApp {
|
|
|
456
482
|
appVersion: packed.manifest.version,
|
|
457
483
|
nativeApi: packed.manifest.nativeApi ?? "",
|
|
458
484
|
webHash: bundle.web.hash,
|
|
485
|
+
...(bundle.web.publicEnvHash ? { publicEnvHash: bundle.web.publicEnvHash } : {}),
|
|
459
486
|
files: packed.manifest.files.length,
|
|
460
487
|
size: packed.manifest.files.reduce((sum, file) => sum + file.size, 0),
|
|
461
488
|
out: packed.out,
|
|
@@ -463,18 +490,27 @@ export class NativeApp {
|
|
|
463
490
|
return { ...packed, channel, pack };
|
|
464
491
|
}
|
|
465
492
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
493
|
+
//* Checked before anything is built or signed: a target with updates would ship a binary Akan Cloud cannot update.
|
|
494
|
+
async #publicEnv() {
|
|
495
|
+
const publicEnv = await NativePublicEnv.built(this.app);
|
|
496
|
+
if (!publicEnv) return null;
|
|
497
|
+
const problems = NativePublicEnv.problems(publicEnv);
|
|
498
|
+
if (!problems.length) return publicEnv;
|
|
499
|
+
const message = `The web build inlines public env an update cannot carry:\n- ${problems.join("\n- ")}`;
|
|
500
|
+
if (this.target.config.updates) throw new Error(message);
|
|
501
|
+
this.app.logger.warn(`${message}\nbundle.json records none, so no update can be checked against this build.`);
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
//? After the build: the native CLI writes bundle.json, and a phone build with updates rewrites it once more.
|
|
506
|
+
async #recorded<T>(dir: string, publicEnv: PublicEnv | null, build: Promise<T>) {
|
|
507
|
+
const result = await build;
|
|
508
|
+
const file = path.join(dir, UpdatePackFolder.bundleFile);
|
|
509
|
+
if (!publicEnv || !(await Bun.file(file).exists())) return result;
|
|
510
|
+
const bundle = await UpdatePackFolder.readBundle(file);
|
|
511
|
+
const web = { ...bundle.web, publicEnv, publicEnvHash: NativePublicEnv.hash(publicEnv) };
|
|
512
|
+
await Bun.write(file, `${JSON.stringify({ ...bundle, web }, null, 2)}\n`);
|
|
513
|
+
return result;
|
|
478
514
|
}
|
|
479
515
|
|
|
480
516
|
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -261,7 +261,8 @@ export class NativeConfig {
|
|
|
261
261
|
};
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
//? akanConfig refuses a target whose merged updates lack url or a key; the type still has
|
|
264
|
+
//? akanConfig refuses a target whose merged updates lack url or a key, or hold both key forms; the type still has
|
|
265
|
+
//? them optional.
|
|
265
266
|
static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
266
267
|
const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
|
|
267
268
|
const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { WorkspaceExecutor } from "../executors";
|
|
5
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
6
|
+
import { NativePublicEnv } from "./nativePublicEnv";
|
|
7
|
+
|
|
8
|
+
const makeTempRoot = tempDirs("akan-native-public-env-");
|
|
9
|
+
|
|
10
|
+
const recorded = {
|
|
11
|
+
AKAN_PUBLIC_ENV: "main",
|
|
12
|
+
AKAN_PUBLIC_REPO_NAME: "softs2",
|
|
13
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("NativePublicEnv", () => {
|
|
17
|
+
test("keeps the AKAN_PUBLIC_* the app sets, sorted, and leaves out what akan.config.ts derives", () => {
|
|
18
|
+
expect(
|
|
19
|
+
Object.keys(
|
|
20
|
+
NativePublicEnv.of({
|
|
21
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com",
|
|
22
|
+
AKAN_PUBLIC_APP_NAME: "lu",
|
|
23
|
+
AKAN_PUBLIC_BASE_PATHS: "",
|
|
24
|
+
AKAN_PUBLIC_RENDER_ENV: "csr",
|
|
25
|
+
AKAN_PUBLIC_ENV: "main",
|
|
26
|
+
JWT_SECRET: "never",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
).toEqual(["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_SERVE_DOMAIN"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("hashes the sorted map as JSON, whatever order it was written in", () => {
|
|
33
|
+
const reversed = Object.fromEntries(Object.entries(recorded).reverse());
|
|
34
|
+
const expected = createHash("sha256").update(JSON.stringify(recorded)).digest("hex");
|
|
35
|
+
expect(NativePublicEnv.hash(reversed)).toBe(expected);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("refuses what Akan Cloud refuses in a recorded env", () => {
|
|
39
|
+
expect(NativePublicEnv.problems(recorded)).toEqual([]);
|
|
40
|
+
expect(
|
|
41
|
+
NativePublicEnv.problems({
|
|
42
|
+
AKAN_PUBLIC_lower: "x",
|
|
43
|
+
AKAN_PUBLIC_MAP_TOKEN: "x",
|
|
44
|
+
AKAN_PUBLIC_PORT: 8282,
|
|
45
|
+
AKAN_PUBLIC_NOTE: "line\nbreak",
|
|
46
|
+
}),
|
|
47
|
+
).toEqual([
|
|
48
|
+
"AKAN_PUBLIC_lower is not an AKAN_PUBLIC_[A-Z0-9_] name",
|
|
49
|
+
"AKAN_PUBLIC_MAP_TOKEN names a token, which a page bundle must not carry",
|
|
50
|
+
"AKAN_PUBLIC_PORT is not a string",
|
|
51
|
+
"AKAN_PUBLIC_NOTE holds a control character",
|
|
52
|
+
]);
|
|
53
|
+
const many = Object.fromEntries(Array.from({ length: 257 }, (_, idx) => [`AKAN_PUBLIC_K${idx}`, "v"]));
|
|
54
|
+
expect(NativePublicEnv.problems(many)).toEqual(["257 keys, more than 256"]);
|
|
55
|
+
expect(NativePublicEnv.problems({ AKAN_PUBLIC_BIG: "x".repeat(70_000) })[0]).toContain("bytes, more than 65536");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("names the keys two maps disagree on, either side missing included", () => {
|
|
59
|
+
expect(
|
|
60
|
+
NativePublicEnv.diff(recorded, { ...recorded, AKAN_PUBLIC_SERVE_DOMAIN: "try.akanjs.com", AKAN_PUBLIC_X: "1" }),
|
|
61
|
+
).toEqual(["AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_X"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("loads --public-env from a JSON map or from a store build's bundle.json, and refuses the rest", async () => {
|
|
65
|
+
const root = await makeTempRoot();
|
|
66
|
+
const map = path.join(root, "store.env.json");
|
|
67
|
+
await writeText(map, JSON.stringify(recorded));
|
|
68
|
+
expect(await NativePublicEnv.load(map)).toEqual(recorded);
|
|
69
|
+
|
|
70
|
+
const bundle = path.join(root, "bundle.json");
|
|
71
|
+
await writeText(bundle, JSON.stringify({ schema: 1, web: { hash: "h", files: 1, publicEnv: recorded } }));
|
|
72
|
+
expect(await NativePublicEnv.load(bundle)).toEqual(recorded);
|
|
73
|
+
expect(await NativePublicEnv.recordedIn(bundle)).toEqual(recorded);
|
|
74
|
+
|
|
75
|
+
const older = path.join(root, "older.json");
|
|
76
|
+
await writeText(older, JSON.stringify({ schema: 1, web: { hash: "h", files: 1 } }));
|
|
77
|
+
await expect(NativePublicEnv.load(older)).rejects.toThrow("recorded no public env");
|
|
78
|
+
expect(await NativePublicEnv.recordedIn(older)).toBeNull();
|
|
79
|
+
expect(await NativePublicEnv.recordedIn(path.join(root, "missing.json"))).toBeNull();
|
|
80
|
+
|
|
81
|
+
const derived = path.join(root, "derived.json");
|
|
82
|
+
await writeText(derived, JSON.stringify({ ...recorded, AKAN_PUBLIC_APP_NAME: "lu" }));
|
|
83
|
+
await expect(NativePublicEnv.load(derived)).rejects.toThrow("AKAN_PUBLIC_APP_NAME, which akan.config.ts decides");
|
|
84
|
+
|
|
85
|
+
const secret = path.join(root, "secret.json");
|
|
86
|
+
await writeText(secret, JSON.stringify({ AKAN_PUBLIC_API_PASSWORD: "x" }));
|
|
87
|
+
await expect(NativePublicEnv.load(secret)).rejects.toThrow("names a password");
|
|
88
|
+
await expect(NativePublicEnv.load(path.join(root, "nope.json"))).rejects.toThrow("does not exist");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("says which keys keep a pack out of a store build that recorded its env, and nothing when it recorded none", async () => {
|
|
92
|
+
const root = await makeTempRoot();
|
|
93
|
+
const shipped = path.join(root, "store.json");
|
|
94
|
+
const pack = path.join(root, "pack.json");
|
|
95
|
+
await writeText(shipped, JSON.stringify({ schema: 1, web: { publicEnv: recorded } }));
|
|
96
|
+
await writeText(
|
|
97
|
+
pack,
|
|
98
|
+
JSON.stringify({ schema: 1, web: { publicEnv: { ...recorded, AKAN_PUBLIC_SERVE_DOMAIN: "try.akanjs.com" } } }),
|
|
99
|
+
);
|
|
100
|
+
expect(await NativePublicEnv.problemAgainst(shipped, pack)).toBe(
|
|
101
|
+
`public env AKAN_PUBLIC_SERVE_DOMAIN differs from the store build's (pass --public-env ${shipped} to repeat it)`,
|
|
102
|
+
);
|
|
103
|
+
expect(await NativePublicEnv.problemAgainst(shipped, shipped)).toBeNull();
|
|
104
|
+
expect(await NativePublicEnv.problemAgainst(pack.replace("pack", "none"), pack)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("pin", () => {
|
|
108
|
+
const saved = { ...process.env };
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
|
|
111
|
+
Object.assign(process.env, saved);
|
|
112
|
+
WorkspaceExecutor.publicEnvPin = null;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("leaves exactly the pinned values and the derived ones, and outranks the root .env", async () => {
|
|
116
|
+
const root = await makeTempRoot();
|
|
117
|
+
const envFile = path.join(root, ".env");
|
|
118
|
+
await writeText(
|
|
119
|
+
envFile,
|
|
120
|
+
"AKAN_PUBLIC_REPO_NAME=from-dotenv\nAKAN_PUBLIC_SERVE_DOMAIN=try.akanjs.com\nAKAN_PUBLIC_LOG_LEVEL=debug\n",
|
|
121
|
+
);
|
|
122
|
+
Object.assign(process.env, { AKAN_PUBLIC_LOG_LEVEL: "debug", AKAN_PUBLIC_APP_NAME: "lu" });
|
|
123
|
+
NativePublicEnv.pin(recorded);
|
|
124
|
+
expect(NativePublicEnv.of(process.env)).toEqual(recorded);
|
|
125
|
+
expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("lu");
|
|
126
|
+
const base = WorkspaceExecutor.getBaseDevEnv(envFile);
|
|
127
|
+
expect(base.repoName).toBe("softs2");
|
|
128
|
+
expect(base.serveDomain).toBe("akamir.com");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|