@akanjs/devkit 3.0.2 → 3.1.0-rc.1
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 +87 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +250 -0
- package/cloud/updatePackUpload.ts +132 -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 +34 -0
- package/mobile/nativeApp.ts +106 -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
|
@@ -3,8 +3,9 @@ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from
|
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
|
-
// `base` is the boot build: it always runs alone, before the builder can serve anything.
|
|
7
|
-
|
|
6
|
+
// `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the single-file
|
|
7
|
+
// CSR build an MCP Apps view needs under the dev CSR registry, made on request and reported to no tab.
|
|
8
|
+
export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr" | "shell";
|
|
8
9
|
|
|
9
10
|
// JSON only: the worker is spawned per batch and receives this in its argv.
|
|
10
11
|
export interface BuildBatchRequest {
|
|
@@ -82,6 +82,102 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
|
|
|
82
82
|
expect(log).toContain(reason);
|
|
83
83
|
}, 30_000);
|
|
84
84
|
|
|
85
|
+
test("builds a view's shell once on request, answers with its failure, and reports it to no tab", async () => {
|
|
86
|
+
const { root } = track(await createTempApp("demo"));
|
|
87
|
+
const appDir = path.join(root, "apps/demo");
|
|
88
|
+
// A stand-in batch: boots, and fails a shell batch by naming the needs it was handed.
|
|
89
|
+
await writeText(
|
|
90
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
91
|
+
`const { generation, needs } = JSON.parse(process.argv[2]);
|
|
92
|
+
const errors = needs.includes("base") ? {} : { shell: "handed " + needs.join(",") };
|
|
93
|
+
process.send({ type: "build-batch-result", data: { generation, errors, artifact: {}, optimizedFonts: { css: "", files: [] } } });
|
|
94
|
+
`,
|
|
95
|
+
);
|
|
96
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
97
|
+
const messages: BuilderMessage[] = [];
|
|
98
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
99
|
+
cwd: appDir,
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
103
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
104
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
105
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
106
|
+
AKAN_PUBLIC_ENV: "local",
|
|
107
|
+
AKAN_WATCH: "0",
|
|
108
|
+
},
|
|
109
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
110
|
+
serialization: "advanced",
|
|
111
|
+
ipc: (message: BuilderMessage) => {
|
|
112
|
+
messages.push(message);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
117
|
+
proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
118
|
+
await until(proc, "build-csr-res", () => messages.some((message) => message.type === "build-csr-res"));
|
|
119
|
+
} finally {
|
|
120
|
+
proc.kill();
|
|
121
|
+
await proc.exited;
|
|
122
|
+
}
|
|
123
|
+
expect(messages.find((message) => message.type === "build-csr-res")).toEqual({
|
|
124
|
+
type: "build-csr-res",
|
|
125
|
+
id: 7,
|
|
126
|
+
ok: false,
|
|
127
|
+
error: "handed shell",
|
|
128
|
+
});
|
|
129
|
+
expect(messages.some((message) => message.type === "build-status")).toBe(false);
|
|
130
|
+
}, 30_000);
|
|
131
|
+
|
|
132
|
+
test("answers a second shell request in the same generation without building again", async () => {
|
|
133
|
+
const { root } = track(await createTempApp("demo"));
|
|
134
|
+
const appDir = path.join(root, "apps/demo");
|
|
135
|
+
const ledger = path.join(root, "batches.log");
|
|
136
|
+
await writeText(
|
|
137
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
138
|
+
`const { generation, needs } = JSON.parse(process.argv[2]);
|
|
139
|
+
require("node:fs").appendFileSync(${JSON.stringify(ledger)}, needs.join(",") + "\\n");
|
|
140
|
+
process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
|
|
141
|
+
`,
|
|
142
|
+
);
|
|
143
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
144
|
+
const messages: BuilderMessage[] = [];
|
|
145
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
146
|
+
cwd: appDir,
|
|
147
|
+
env: {
|
|
148
|
+
...process.env,
|
|
149
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
150
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
151
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
152
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
153
|
+
AKAN_PUBLIC_ENV: "local",
|
|
154
|
+
AKAN_WATCH: "0",
|
|
155
|
+
},
|
|
156
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
157
|
+
serialization: "advanced",
|
|
158
|
+
ipc: (message: BuilderMessage) => {
|
|
159
|
+
messages.push(message);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const answers = () => messages.filter((message) => message.type === "build-csr-res");
|
|
163
|
+
try {
|
|
164
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
165
|
+
proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
166
|
+
await until(proc, "the first build-csr-res", () => answers().length === 1);
|
|
167
|
+
proc.send({ type: "build-csr", id: 8, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
168
|
+
await until(proc, "the second build-csr-res", () => answers().length === 2);
|
|
169
|
+
} finally {
|
|
170
|
+
proc.kill();
|
|
171
|
+
await proc.exited;
|
|
172
|
+
}
|
|
173
|
+
expect(answers()).toEqual([
|
|
174
|
+
{ type: "build-csr-res", id: 7, ok: true },
|
|
175
|
+
{ type: "build-csr-res", id: 8, ok: true },
|
|
176
|
+
]);
|
|
177
|
+
const batches = (await Bun.file(ledger).text()).trim().split("\n");
|
|
178
|
+
expect(batches.filter((needs) => needs === "shell")).toHaveLength(1);
|
|
179
|
+
}, 30_000);
|
|
180
|
+
|
|
85
181
|
test("a killed boot build is tried again after the crash window, before boot-armed; a route build's check waits likewise", async () => {
|
|
86
182
|
// Inside the checkout so the route's client bundle resolves the framework from its source.
|
|
87
183
|
const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
|
|
@@ -63,6 +63,7 @@ class IncrementalBuilder {
|
|
|
63
63
|
#autoImportSync: AutoImportSync;
|
|
64
64
|
#watcher: HmrWatcher | null = null;
|
|
65
65
|
#generation = 0;
|
|
66
|
+
#shellGeneration: number | null = null;
|
|
66
67
|
#discoveredThrough = 0;
|
|
67
68
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
68
69
|
#csrBundler: CsrDevBundler;
|
|
@@ -798,7 +799,7 @@ class IncrementalBuilder {
|
|
|
798
799
|
if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
|
|
799
800
|
// `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
|
|
800
801
|
for (const need of crashedNeeds)
|
|
801
|
-
if (need !== "base")
|
|
802
|
+
if (need !== "base" && need !== "shell")
|
|
802
803
|
this.#sendBuildStatus(need, {
|
|
803
804
|
generation,
|
|
804
805
|
ok: false,
|
|
@@ -874,6 +875,10 @@ class IncrementalBuilder {
|
|
|
874
875
|
|
|
875
876
|
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
876
877
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
878
|
+
if (msg.shell) {
|
|
879
|
+
await this.#buildShell(msg);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
877
882
|
//? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
|
|
878
883
|
this.#beginCsrArm();
|
|
879
884
|
this.#csrActive = true;
|
|
@@ -903,6 +908,30 @@ class IncrementalBuilder {
|
|
|
903
908
|
await armed;
|
|
904
909
|
}
|
|
905
910
|
|
|
911
|
+
// Asked on every view read and rebuilt only when a save moved the generation, instead of armed per save: a restarted
|
|
912
|
+
// builder has no last build to trust, and so rebuilds once on the next read.
|
|
913
|
+
async #buildShell(msg: BuilderCsrReq): Promise<void> {
|
|
914
|
+
await this.#workQueue.enqueue("build-shell", async (): Promise<void> => {
|
|
915
|
+
const generation = this.#generation;
|
|
916
|
+
let error: string | undefined;
|
|
917
|
+
if (this.#shellGeneration !== generation)
|
|
918
|
+
try {
|
|
919
|
+
const result = await this.#batchRunner.run(
|
|
920
|
+
await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }),
|
|
921
|
+
);
|
|
922
|
+
error = result.errors.shell;
|
|
923
|
+
if (!error) this.#shellGeneration = generation;
|
|
924
|
+
} catch (err) {
|
|
925
|
+
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
926
|
+
}
|
|
927
|
+
await BuilderChannel.send(
|
|
928
|
+
error
|
|
929
|
+
? { type: "build-csr-res", id: msg.id, ok: false, error }
|
|
930
|
+
: { type: "build-csr-res", id: msg.id, ok: true },
|
|
931
|
+
);
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
906
935
|
//? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
|
|
907
936
|
//? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
|
|
908
937
|
//? patch CSR first while the second still builds.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises"; // @ok
|
|
2
|
+
import { createServer } from "node:net"; // @ok
|
|
3
|
+
import { tmpdir } from "node:os"; // @ok
|
|
4
|
+
import { Database } from "bun:sqlite"; // @ok
|
|
5
|
+
import { describe, expect, test } from "bun:test"; // @ok
|
|
6
|
+
import { fetch } from "../sig"; // @ok
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// A module test still reaches vendors through a lib, but it may use the runtime's own primitives — a temp dir, a
|
|
5
|
+
// socket, sqlite — that a module file leaves to srvkit/.
|
|
6
|
+
JsModuleSource() as $source where {
|
|
7
|
+
$source <: within JsImport(),
|
|
8
|
+
not $source <: or {
|
|
9
|
+
r"\"\..*",
|
|
10
|
+
r"\"akanjs.*",
|
|
11
|
+
r"\"@akanjs.*",
|
|
12
|
+
r"\"@playwright.*",
|
|
13
|
+
r"\"@apps/.*",
|
|
14
|
+
r"\"@libs/.*",
|
|
15
|
+
r"\"@pkgs/.*",
|
|
16
|
+
r"\"bun:.*",
|
|
17
|
+
r"\"node:.*",
|
|
18
|
+
r"\"react.*"
|
|
19
|
+
},
|
|
20
|
+
register_diagnostic(
|
|
21
|
+
span = $source,
|
|
22
|
+
message = "External libraries should not be imported in a module test. Only internal packages and the runtime's node: and bun: modules are permitted."
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -46,6 +46,12 @@ describe("DesktopServerStage", () => {
|
|
|
46
46
|
});
|
|
47
47
|
expect(env.PORT).toBeUndefined();
|
|
48
48
|
expect(env.AKAN_LOG_TO_FILE).toBeUndefined();
|
|
49
|
+
expect(env.AKAN_MODULES).toBeUndefined();
|
|
50
|
+
expect(DesktopServerStage.env(appConfig(), "main", { modules: ["@edge", "edgeAgent"] }).AKAN_MODULES).toBe(
|
|
51
|
+
"@edge,edgeAgent",
|
|
52
|
+
);
|
|
53
|
+
const withEnv = DesktopServerStage.env(appConfig(), "main", { env: { CLOUD_ORIGIN: "https://cloud.example" } });
|
|
54
|
+
expect(withEnv).toMatchObject({ CLOUD_ORIGIN: "https://cloud.example", AKAN_PUBLIC_OPERATION_MODE: "edge" });
|
|
49
55
|
});
|
|
50
56
|
|
|
51
57
|
test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
|
|
@@ -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
|
@@ -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: [] };
|
|
@@ -231,3 +232,36 @@ describe("NativeApp", () => {
|
|
|
231
232
|
);
|
|
232
233
|
});
|
|
233
234
|
});
|
|
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,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,42 @@ 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`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A pack `packUpdate` wrote, read back from its folder wherever it was moved. */
|
|
471
|
+
static async readPack(dir: string): Promise<NativeUpdatePack> {
|
|
472
|
+
const file = Bun.file(path.join(dir, "pack.json"));
|
|
473
|
+
if (!(await file.exists()))
|
|
474
|
+
throw new Error(`${dir} holds no pack.json: give the folder \`akan pack-update\` wrote.`);
|
|
475
|
+
const pack = (await file.json()) as NativeUpdatePack;
|
|
476
|
+
if (pack.schema !== 1) throw new Error(`${dir}/pack.json is schema ${pack.schema}, and this akan reads schema 1.`);
|
|
477
|
+
return { ...pack, out: path.resolve(dir) };
|
|
401
478
|
}
|
|
402
479
|
|
|
403
480
|
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
|
@@ -437,11 +514,6 @@ export class NativeApp {
|
|
|
437
514
|
return `/${home.join("/")}?${params}`;
|
|
438
515
|
}
|
|
439
516
|
|
|
440
|
-
static #windowsSigning(api: NativeBuildApiModule) {
|
|
441
|
-
const signing = api.windowsSigningFromEnv(process.env);
|
|
442
|
-
return signing ? { signing } : {};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
517
|
//* `installer` is the file a person downloads: a setup program on Windows, a disk image on macOS, an AppImage on Linux.
|
|
446
518
|
static assertInstaller(platform: NativePlatform, installer: boolean) {
|
|
447
519
|
if (installer && (platform === "ios" || platform === "android"))
|