@akanjs/devkit 3.1.1-rc.5 → 3.1.1-rc.7
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 +13 -2
- package/akanConfig/akanConfig.ts +5 -5
- package/akanConfig/productionLockfile.test.ts +80 -0
- package/akanConfig/productionLockfile.ts +32 -0
- package/akanContext.ts +25 -0
- package/applicationBuildRunner.test.ts +41 -0
- package/applicationBuildRunner.ts +23 -3
- package/cloud/cloudApi.ts +4 -1
- package/cloud/cloudUpdates.ts +37 -1
- package/cloud/updatePackUpload.test.ts +38 -2
- package/cloud/updatePackUpload.ts +5 -1
- package/commandDecorators/command.ts +3 -14
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
- package/commandDecorators/workspaceVersionCheck.ts +50 -0
- package/mobile/desktopRelease.test.ts +361 -0
- package/mobile/desktopRelease.ts +270 -0
- package/mobile/desktopServerStage.ts +4 -2
- package/mobile/index.ts +2 -0
- package/mobile/nativeApp.ts +17 -0
- package/mobile/tarWriter.test.ts +89 -0
- package/mobile/tarWriter.ts +93 -0
- package/package.json +2 -2
|
@@ -154,6 +154,17 @@ describe("AkanAppConfig", () => {
|
|
|
154
154
|
expect(config.dockerfile).toContain('RUN if [ "$TARGETARCH" = "arm64"');
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test("installs from the build's bun.lock frozen, or unlocked when the build wrote none", () => {
|
|
158
|
+
const config = new AkanAppConfig(app, [], packageJson, { docker: { postRuns: ["echo after"] } }, baseDevEnv);
|
|
159
|
+
const locked = config.dockerfileFor("main");
|
|
160
|
+
const unlocked = config.dockerfileFor("main", { lockfile: false });
|
|
161
|
+
|
|
162
|
+
expect(locked).toContain("COPY ./package.json ./bun.lock ./\nRUN bun install --production --frozen-lockfile\n");
|
|
163
|
+
expect(locked.indexOf("--frozen-lockfile")).toBeLessThan(locked.indexOf("echo after"));
|
|
164
|
+
expect(unlocked).toContain("COPY ./package.json ./package.json\nRUN bun install --production\n");
|
|
165
|
+
expect(unlocked).not.toContain("bun.lock");
|
|
166
|
+
});
|
|
167
|
+
|
|
157
168
|
test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
|
|
158
169
|
const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
|
|
159
170
|
|
|
@@ -219,8 +230,8 @@ describe("AkanAppConfig", () => {
|
|
|
219
230
|
"",
|
|
220
231
|
"RUN mkdir -p /workspace",
|
|
221
232
|
"WORKDIR /workspace",
|
|
222
|
-
"COPY ./package.json ./
|
|
223
|
-
"RUN bun install --production",
|
|
233
|
+
"COPY ./package.json ./bun.lock ./",
|
|
234
|
+
"RUN bun install --production --frozen-lockfile",
|
|
224
235
|
"",
|
|
225
236
|
"COPY . .",
|
|
226
237
|
"ENV PORT=8282",
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -634,10 +634,10 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
634
634
|
...(this.web.csr ? {} : { AKAN_CSR: "false" }),
|
|
635
635
|
};
|
|
636
636
|
}
|
|
637
|
-
dockerfileFor(environment: string): string {
|
|
638
|
-
return this.#makeDockerfile(environment);
|
|
637
|
+
dockerfileFor(environment: string, { lockfile = true }: { lockfile?: boolean } = {}): string {
|
|
638
|
+
return this.#makeDockerfile(environment, lockfile);
|
|
639
639
|
}
|
|
640
|
-
#makeDockerfile(environment?: string): string {
|
|
640
|
+
#makeDockerfile(environment?: string, lockfile = true): string {
|
|
641
641
|
if (typeof this.docker === "string") return this.docker;
|
|
642
642
|
const { image, preRuns, postRuns, command } = this.docker;
|
|
643
643
|
const preRunScripts = this.#getDockerRunScripts(preRuns);
|
|
@@ -651,8 +651,8 @@ ARG TARGETARCH
|
|
|
651
651
|
${preRunScripts.join("\n")}
|
|
652
652
|
RUN mkdir -p /workspace
|
|
653
653
|
WORKDIR /workspace
|
|
654
|
-
COPY ./package.json ./package.json
|
|
655
|
-
RUN bun install --production
|
|
654
|
+
${lockfile ? "COPY ./package.json ./bun.lock ./" : "COPY ./package.json ./package.json"}
|
|
655
|
+
RUN bun install --production${lockfile ? " --frozen-lockfile" : ""}
|
|
656
656
|
${postRunScripts.join("\n")}
|
|
657
657
|
COPY . .
|
|
658
658
|
${envLines.join("\n")}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ProductionLockfile } from "./productionLockfile";
|
|
6
|
+
|
|
7
|
+
// js-tokens 3.0.2 is locked although 4.0.0 satisfies the range: a re-resolve would move it.
|
|
8
|
+
const workspaceLock = `{
|
|
9
|
+
"lockfileVersion": 1,
|
|
10
|
+
"configVersion": 1,
|
|
11
|
+
"workspaces": {
|
|
12
|
+
"": {
|
|
13
|
+
"name": "repo",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"loose-envify": "^1.4.0",
|
|
16
|
+
"ms": "^2.0.0",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"packages": {
|
|
21
|
+
"js-tokens": ["js-tokens@3.0.2", "", {}, "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg=="],
|
|
22
|
+
|
|
23
|
+
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
|
24
|
+
|
|
25
|
+
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const productionPackageJson = {
|
|
31
|
+
name: "portal",
|
|
32
|
+
description: "portal",
|
|
33
|
+
version: "1.0.0",
|
|
34
|
+
dependencies: { "loose-envify": "^1.4.0" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const withWorkspace = async (lock: string | null, run: (root: string) => Promise<void>) => {
|
|
38
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-production-lock-test-"));
|
|
39
|
+
try {
|
|
40
|
+
if (lock !== null) await writeFile(path.join(root, "bun.lock"), lock);
|
|
41
|
+
await run(root);
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(root, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("ProductionLockfile", () => {
|
|
48
|
+
test("keeps the workspace's locked versions and drops what the production package.json does not reach", async () => {
|
|
49
|
+
await withWorkspace(workspaceLock, async (root) => {
|
|
50
|
+
const lock = await ProductionLockfile.prune(root, productionPackageJson);
|
|
51
|
+
expect(lock).toContain('"loose-envify": ["loose-envify@1.4.0"');
|
|
52
|
+
expect(lock).toContain('"js-tokens": ["js-tokens@3.0.2"');
|
|
53
|
+
expect(lock).not.toContain("ms@2.0.0");
|
|
54
|
+
expect(lock).not.toContain('"ms": "^2.0.0"');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a bun install --frozen-lockfile of the pruned lockfile changes nothing", async () => {
|
|
59
|
+
await withWorkspace(workspaceLock, async (root) => {
|
|
60
|
+
const lock = await ProductionLockfile.prune(root, productionPackageJson);
|
|
61
|
+
await writeFile(path.join(root, "package.json"), JSON.stringify(productionPackageJson, null, 2));
|
|
62
|
+
await writeFile(path.join(root, "bun.lock"), lock);
|
|
63
|
+
const install = Bun.spawn([process.execPath, "install", "--lockfile-only", "--frozen-lockfile"], {
|
|
64
|
+
cwd: root,
|
|
65
|
+
stdout: "ignore",
|
|
66
|
+
stderr: "pipe",
|
|
67
|
+
});
|
|
68
|
+
expect(await install.exited).toBe(0);
|
|
69
|
+
expect(await Bun.file(path.join(root, "bun.lock")).text()).toBe(lock);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("refuses a workspace without a bun.lock, so the build can fall back to an unlocked install", async () => {
|
|
74
|
+
await withWorkspace(null, async (root) => {
|
|
75
|
+
await expect(ProductionLockfile.prune(root, productionPackageJson)).rejects.toThrow(
|
|
76
|
+
"the workspace root has no bun.lock",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { PackageJson } from "../types";
|
|
5
|
+
|
|
6
|
+
export class ProductionLockfile {
|
|
7
|
+
static readonly fileName = "bun.lock";
|
|
8
|
+
|
|
9
|
+
//? Beside a copy of the workspace lockfile, Bun keeps each version it locked and drops what the package.json no
|
|
10
|
+
//? longer reaches; only a package the workspace never locked is resolved from the registry.
|
|
11
|
+
static async prune(workspaceRoot: string, packageJson: PackageJson): Promise<string> {
|
|
12
|
+
const workspaceLock = Bun.file(path.join(workspaceRoot, ProductionLockfile.fileName));
|
|
13
|
+
if (!(await workspaceLock.exists())) throw new Error(`the workspace root has no ${ProductionLockfile.fileName}`);
|
|
14
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "akan-production-lock-"));
|
|
15
|
+
try {
|
|
16
|
+
await Promise.all([
|
|
17
|
+
Bun.write(path.join(dir, "package.json"), JSON.stringify(packageJson, null, 2)),
|
|
18
|
+
Bun.write(path.join(dir, ProductionLockfile.fileName), workspaceLock),
|
|
19
|
+
]);
|
|
20
|
+
const install = Bun.spawn([process.execPath, "install", "--lockfile-only", "--no-progress"], {
|
|
21
|
+
cwd: dir,
|
|
22
|
+
stdout: "ignore",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
const [exitCode, stderr] = await Promise.all([install.exited, new Response(install.stderr).text()]);
|
|
26
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || `bun install --lockfile-only exited with ${exitCode}`);
|
|
27
|
+
return await Bun.file(path.join(dir, ProductionLockfile.fileName)).text();
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/akanContext.ts
CHANGED
|
@@ -632,6 +632,28 @@ export class AkanContextAnalyzer {
|
|
|
632
632
|
};
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
//? Older templates wrote these for Next.js: nothing installs or reads them, and editors warn about the plugin.
|
|
636
|
+
static async #configLeftovers(workspace: WorkspaceExecutor, sysPaths: string[]) {
|
|
637
|
+
const leftovers: { path: string; message: string }[] = [];
|
|
638
|
+
for (const sysPath of sysPaths) {
|
|
639
|
+
const tsconfigPath = `${sysPath}/tsconfig.json`;
|
|
640
|
+
const tsconfig = await safeReadText(path.join(workspace.workspaceRoot, tsconfigPath));
|
|
641
|
+
if (tsconfig && (/"name"\s*:\s*"next"/.test(tsconfig) || tsconfig.includes(".next/types")))
|
|
642
|
+
leftovers.push({
|
|
643
|
+
path: tsconfigPath,
|
|
644
|
+
message: `${tsconfigPath} still names the Next.js TS plugin or .next/types, which Akan never uses — delete the "next" plugin entry and the ".next/types/**/*.ts" include.`,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
const bunfig = await safeReadText(path.join(workspace.workspaceRoot, "bunfig.toml"));
|
|
648
|
+
if (bunfig?.includes("bun-plugin-tailwind"))
|
|
649
|
+
leftovers.push({
|
|
650
|
+
path: "bunfig.toml",
|
|
651
|
+
message:
|
|
652
|
+
"bunfig.toml still loads bun-plugin-tailwind, which is not installed and which Akan never uses (it compiles Tailwind itself) — delete the [serve.static] section, which Akan never reads.",
|
|
653
|
+
});
|
|
654
|
+
return leftovers;
|
|
655
|
+
}
|
|
656
|
+
|
|
635
657
|
static async doctor(
|
|
636
658
|
workspace: WorkspaceExecutor,
|
|
637
659
|
{
|
|
@@ -701,6 +723,9 @@ export class AkanContextAnalyzer {
|
|
|
701
723
|
{ severity: "warning", code: agentDrift.code, path: "AGENTS.md", message: agentDrift.message },
|
|
702
724
|
repairAction("generated", "akan agent install agents-md", agentDrift.hint, true),
|
|
703
725
|
);
|
|
726
|
+
const sysPaths = [...context.apps, ...context.libs].map((sys) => sys.path);
|
|
727
|
+
for (const leftover of await AkanContextAnalyzer.#configLeftovers(workspace, sysPaths))
|
|
728
|
+
diagnostics.push({ severity: "warning", code: "config-leftover", ...leftover });
|
|
704
729
|
|
|
705
730
|
for (const sys of [...context.apps, ...context.libs]) {
|
|
706
731
|
for (const module of sys.modules) {
|
|
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
6
|
+
import { ProductionLockfile } from "./akanConfig/productionLockfile";
|
|
6
7
|
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
7
8
|
import type { App } from "./commandDecorators";
|
|
8
9
|
import { AppExecutor, Executor } from "./executors";
|
|
@@ -70,6 +71,7 @@ console.info(new SampleService().name);
|
|
|
70
71
|
getConfig: async () => config,
|
|
71
72
|
dist: new Executor("dist", root),
|
|
72
73
|
workspace: { workspaceRoot: root },
|
|
74
|
+
logger: { warn: () => undefined },
|
|
73
75
|
} as unknown as App;
|
|
74
76
|
try {
|
|
75
77
|
await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
|
|
@@ -81,6 +83,45 @@ console.info(new SampleService().name);
|
|
|
81
83
|
}
|
|
82
84
|
});
|
|
83
85
|
|
|
86
|
+
test("writes the pruned lockfile beside package.json, or warns and leaves the image's install unlocked", async () => {
|
|
87
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
|
|
88
|
+
const config = new AkanAppConfig(
|
|
89
|
+
{ name: "portal" } as never,
|
|
90
|
+
[],
|
|
91
|
+
{ name: "repo", version: "1.0.0", description: "repo" },
|
|
92
|
+
{},
|
|
93
|
+
{ repoName: "akanjs", serveDomain: "akanjs.com", env: "local", portOffset: 0, workspaceRoot: root },
|
|
94
|
+
);
|
|
95
|
+
const warnings: string[] = [];
|
|
96
|
+
const app = {
|
|
97
|
+
getConfig: async () => config,
|
|
98
|
+
dist: new Executor("dist", root),
|
|
99
|
+
workspace: { workspaceRoot: root },
|
|
100
|
+
logger: { warn: (message: string) => warnings.push(message) },
|
|
101
|
+
} as unknown as App;
|
|
102
|
+
const prune = spyOn(ProductionLockfile, "prune");
|
|
103
|
+
try {
|
|
104
|
+
prune.mockResolvedValueOnce('{ "lockfileVersion": 1 }\n');
|
|
105
|
+
await new ApplicationBuildRunner(app).buildAppMeta();
|
|
106
|
+
expect(prune.mock.calls[0]?.[1]).toEqual(JSON.parse(await readFile(path.join(root, "package.json"), "utf8")));
|
|
107
|
+
expect(await readFile(path.join(root, "bun.lock"), "utf8")).toBe('{ "lockfileVersion": 1 }\n');
|
|
108
|
+
expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("--frozen-lockfile");
|
|
109
|
+
expect(warnings).toEqual([]);
|
|
110
|
+
|
|
111
|
+
await rm(path.join(root, "bun.lock"));
|
|
112
|
+
prune.mockRejectedValueOnce(new Error("the workspace root has no bun.lock"));
|
|
113
|
+
await new ApplicationBuildRunner(app).buildAppMeta();
|
|
114
|
+
expect(await Bun.file(path.join(root, "bun.lock")).exists()).toBe(false);
|
|
115
|
+
expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("RUN bun install --production\n");
|
|
116
|
+
expect(warnings).toEqual([
|
|
117
|
+
"The image installs its dependencies unlocked, at the newest versions package.json allows: the workspace root has no bun.lock",
|
|
118
|
+
]);
|
|
119
|
+
} finally {
|
|
120
|
+
prune.mockRestore();
|
|
121
|
+
await rm(root, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
84
125
|
test("bakes into the route bundles the env their server env is, not the shell's that the root .env outranks", async () => {
|
|
85
126
|
const { root, workspace } = await createTempApp("portal");
|
|
86
127
|
const shellEnv = process.env.AKAN_PUBLIC_ENV;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { toError } from "akanjs/common";
|
|
2
3
|
import type { BunPlugin } from "bun";
|
|
4
|
+
import { ProductionLockfile } from "./akanConfig/productionLockfile";
|
|
3
5
|
import type {
|
|
4
6
|
ApplicationBuildPhaseResult,
|
|
5
7
|
ApplicationBuildProgressReporter,
|
|
@@ -18,7 +20,7 @@ import { Spinner } from "./spinner";
|
|
|
18
20
|
import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
|
|
19
21
|
import { ServerEnvProfile } from "./transforms/serverEnvProfile";
|
|
20
22
|
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
21
|
-
import type { BaseDevEnv } from "./types";
|
|
23
|
+
import type { BaseDevEnv, PackageJson } from "./types";
|
|
22
24
|
|
|
23
25
|
export interface TypecheckOptions {
|
|
24
26
|
clean?: boolean;
|
|
@@ -236,9 +238,16 @@ export class ApplicationBuildRunner {
|
|
|
236
238
|
|
|
237
239
|
async buildAppMeta() {
|
|
238
240
|
const akanConfig = await this.#app.getConfig();
|
|
239
|
-
const
|
|
241
|
+
const packageJson = akanConfig.getProductionPackageJson();
|
|
242
|
+
const lockfile = await this.#pruneLockfile(packageJson);
|
|
243
|
+
const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env, {
|
|
244
|
+
lockfile: lockfile !== null,
|
|
245
|
+
});
|
|
240
246
|
await Promise.all([
|
|
241
|
-
this.#app.dist.writeJson("package.json",
|
|
247
|
+
this.#app.dist.writeJson("package.json", packageJson),
|
|
248
|
+
...(lockfile !== null
|
|
249
|
+
? [this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/${ProductionLockfile.fileName}`, lockfile)]
|
|
250
|
+
: []),
|
|
242
251
|
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, dockerfile),
|
|
243
252
|
this.#app.dist.writeJson("akan.build.json", {
|
|
244
253
|
buildId: await this.#resolveBuildId(),
|
|
@@ -248,6 +257,17 @@ export class ApplicationBuildRunner {
|
|
|
248
257
|
]);
|
|
249
258
|
}
|
|
250
259
|
|
|
260
|
+
async #pruneLockfile(packageJson: PackageJson): Promise<string | null> {
|
|
261
|
+
try {
|
|
262
|
+
return await ProductionLockfile.prune(this.#app.workspace.workspaceRoot, packageJson);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
this.#app.logger.warn(
|
|
265
|
+
`The image installs its dependencies unlocked, at the newest versions package.json allows: ${toError(error).message}`,
|
|
266
|
+
);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
251
271
|
//* Read back at runtime as `buildId` on the ops channel; a deployment's own AKAN_BUILD_ID still wins over it there.
|
|
252
272
|
async #resolveBuildId() {
|
|
253
273
|
const fromEnv = process.env.AKAN_BUILD_ID?.trim();
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -86,7 +86,10 @@ export interface CloudRecord {
|
|
|
86
86
|
export interface UpdateUploadRequest {
|
|
87
87
|
appName: string;
|
|
88
88
|
target: string;
|
|
89
|
-
platform: "ios" | "android";
|
|
89
|
+
platform: "ios" | "android" | "macos" | "windows" | "linux";
|
|
90
|
+
//? Absent is a phone's web bundle, so a phone request reads as it did before desktop releases.
|
|
91
|
+
kind?: "app";
|
|
92
|
+
arch?: "arm64" | "x64";
|
|
90
93
|
channel: string;
|
|
91
94
|
notes: string | null;
|
|
92
95
|
size: number;
|
package/cloud/cloudUpdates.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Logger } from "akanjs/common";
|
|
|
3
3
|
import type { NativeEnv } from "../akanConfig";
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { ExitCodeError } from "../commandDecorators/exitCodeError";
|
|
6
|
+
import type { DesktopRelease, DesktopSigner } from "../mobile/desktopRelease";
|
|
6
7
|
import { type MobilePlatform, resolveMobileTargets } from "../mobile/mobileTarget";
|
|
7
8
|
import { NativeApp } from "../mobile/nativeApp";
|
|
8
9
|
import { NativePublicEnv } from "../mobile/nativePublicEnv";
|
|
@@ -11,7 +12,8 @@ import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
|
|
|
11
12
|
import { UpdatePackUpload } from "./updatePackUpload";
|
|
12
13
|
|
|
13
14
|
//* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
|
|
14
|
-
//* it to sign and publish, and the store build a pack of the same commit is embedded in.
|
|
15
|
+
//* it to sign and publish, and the store build a pack of the same commit is embedded in. A desktop release is signed
|
|
16
|
+
//* where it was published, and goes up as it is.
|
|
15
17
|
export class CloudUpdates {
|
|
16
18
|
constructor(
|
|
17
19
|
readonly cloudApi: CloudApi,
|
|
@@ -77,6 +79,40 @@ export class CloudUpdates {
|
|
|
77
79
|
});
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
//? `manifestUrl` is where the project serves the channel once published; before that, the URL the app was built with.
|
|
83
|
+
async pushRelease(
|
|
84
|
+
release: DesktopRelease,
|
|
85
|
+
{
|
|
86
|
+
appName,
|
|
87
|
+
target,
|
|
88
|
+
notes,
|
|
89
|
+
signer,
|
|
90
|
+
url,
|
|
91
|
+
}: { appName: string; target: string; notes: string | null; signer: DesktopSigner; url: string },
|
|
92
|
+
) {
|
|
93
|
+
const { platform, arch, channel } = release;
|
|
94
|
+
return await release.withArchive(release.record(signer), async (archive) => {
|
|
95
|
+
const record = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
|
|
96
|
+
.send(archive, { appName, target, platform, kind: "app", arch, channel, notes })
|
|
97
|
+
.catch((error: unknown) => {
|
|
98
|
+
throw CloudApi.refusal(error, "a release is uploaded by a project member signed in with `akan login`");
|
|
99
|
+
});
|
|
100
|
+
const label = `${appName} ${target} ${platform}-${arch} on channel ${channel}: release ${record.id}`;
|
|
101
|
+
const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
|
|
102
|
+
if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
|
|
103
|
+
const manifestUrl =
|
|
104
|
+
typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}.json`;
|
|
105
|
+
const what = `${label} (${release.manifest.bundle}, ${release.payloads.length + 3} files)`;
|
|
106
|
+
Logger.rawLog(
|
|
107
|
+
record.status === "published"
|
|
108
|
+
? `Published ${what}. Installed apps read ${manifestUrl}.`
|
|
109
|
+
: `Staged ${what}. Publish it from the project's release screen; installed apps read ${manifestUrl}.`,
|
|
110
|
+
);
|
|
111
|
+
if (problems) Logger.rawLog(`The project noted:\n${problems}`);
|
|
112
|
+
return record;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
80
116
|
//? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
|
|
81
117
|
async registerBinary(
|
|
82
118
|
app: App,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { truncate, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { Workspace } from "../commandDecorators";
|
|
6
6
|
import { tempDirs } from "../testHelpers";
|
|
@@ -24,6 +24,7 @@ class FakeCloudHost {
|
|
|
24
24
|
commitAnswers: (number | [number, string] | Response)[] = [];
|
|
25
25
|
release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
|
|
26
26
|
requestRefusal: Response | null = null;
|
|
27
|
+
contentType = "application/gzip";
|
|
27
28
|
|
|
28
29
|
constructor() {
|
|
29
30
|
this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
|
|
@@ -42,6 +43,7 @@ class FakeCloudHost {
|
|
|
42
43
|
this.stallPuts = false;
|
|
43
44
|
this.commitAnswers = [];
|
|
44
45
|
this.requestRefusal = null;
|
|
46
|
+
this.contentType = "application/gzip";
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
async #route(request: Request) {
|
|
@@ -54,7 +56,7 @@ class FakeCloudHost {
|
|
|
54
56
|
uploadId,
|
|
55
57
|
uploadUrl: `${this.host}/storage/${uploadId}?X-Amz-Signature=secret-signature`,
|
|
56
58
|
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
|
|
57
|
-
contentType:
|
|
59
|
+
contentType: this.contentType,
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
if (url.pathname.startsWith("/storage/") && request.method === "PUT") {
|
|
@@ -302,4 +304,38 @@ describe("UpdatePackUpload", () => {
|
|
|
302
304
|
await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
|
|
303
305
|
expect(cloudHost.requests).toEqual([]);
|
|
304
306
|
});
|
|
307
|
+
|
|
308
|
+
test("announces a desktop release with its kind and CPU, a phone pack with neither, and PUTs the ticket's type", async () => {
|
|
309
|
+
const desktop = { ...fields, platform: "macos" as const, kind: "app" as const, arch: "arm64" as const };
|
|
310
|
+
cloudHost.contentType = "application/x-tar";
|
|
311
|
+
|
|
312
|
+
await new UpdatePackUpload(api, "project-1", quick).send(await archiveOf("release tar"), desktop);
|
|
313
|
+
await new UpdatePackUpload(api, "project-1", quick).send(await archiveOf("pack bytes"), fields);
|
|
314
|
+
|
|
315
|
+
expect(cloudHost.requests[0]).toMatchObject({ platform: "macos", kind: "app", arch: "arm64", size: 11 });
|
|
316
|
+
expect(Object.keys(cloudHost.requests[1] ?? {}).filter((key) => key === "kind" || key === "arch")).toEqual([]);
|
|
317
|
+
expect(cloudHost.puts.map((put) => put.headers["content-type"])).toEqual([
|
|
318
|
+
"application/x-tar",
|
|
319
|
+
"application/x-tar",
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("takes a desktop release up to 1 GiB and a phone pack up to 200 MiB, refusing more before a ticket", async () => {
|
|
324
|
+
const desktop = { ...fields, platform: "windows" as const, kind: "app" as const, arch: "x64" as const };
|
|
325
|
+
const sparse = async (bytes: number) => {
|
|
326
|
+
const archive = await archiveOf("");
|
|
327
|
+
await truncate(archive, bytes);
|
|
328
|
+
return archive;
|
|
329
|
+
};
|
|
330
|
+
const upload = new UpdatePackUpload(api, "project-1", quick);
|
|
331
|
+
|
|
332
|
+
await expect(upload.send(await sparse(UpdatePackUpload.maxBytes + 1), fields)).rejects.toThrow(
|
|
333
|
+
`The pack archive is ${UpdatePackUpload.maxBytes + 1} bytes; a project takes 1 byte to 200 MiB.`,
|
|
334
|
+
);
|
|
335
|
+
await expect(upload.send(await sparse(UpdatePackUpload.maxAppBytes + 1), desktop)).rejects.toThrow(
|
|
336
|
+
`The release archive is ${UpdatePackUpload.maxAppBytes + 1} bytes; a project takes 1 byte to 1 GiB.`,
|
|
337
|
+
);
|
|
338
|
+
expect(cloudHost.requests).toEqual([]);
|
|
339
|
+
expect(UpdatePackUpload.maxAppBytes).toBe(1_073_741_824);
|
|
340
|
+
});
|
|
305
341
|
});
|
|
@@ -16,6 +16,7 @@ interface UpdatePackUploadOptions {
|
|
|
16
16
|
//* embedded pack; either re-hashes it, and an upload is used once, one way.
|
|
17
17
|
export class UpdatePackUpload {
|
|
18
18
|
static readonly maxBytes = 200 * 1024 * 1024;
|
|
19
|
+
static readonly maxAppBytes = 1024 * 1024 * 1024;
|
|
19
20
|
static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
|
|
20
21
|
//? A stalled PUT never fails on its own, so each try ends once the pack would be through even at this crawl.
|
|
21
22
|
static readonly #putFloorBytesPerSecond = 64 * 1024;
|
|
@@ -57,7 +58,10 @@ export class UpdatePackUpload {
|
|
|
57
58
|
/** Puts the pack in the project's staging storage and answers the upload id a commit or a registration names. */
|
|
58
59
|
async stage(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<string> {
|
|
59
60
|
const file = Bun.file(archive);
|
|
60
|
-
if (
|
|
61
|
+
if (fields.kind === "app") {
|
|
62
|
+
if (file.size < 1 || file.size > UpdatePackUpload.maxAppBytes)
|
|
63
|
+
throw new Error(`The release archive is ${file.size} bytes; a project takes 1 byte to 1 GiB.`);
|
|
64
|
+
} else if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
|
|
61
65
|
throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
|
|
62
66
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
63
67
|
for await (const chunk of file.stream()) hasher.update(chunk);
|
|
@@ -26,6 +26,7 @@ import { CommandContainer } from "./dependencyBuilder";
|
|
|
26
26
|
import { ExitCodeError } from "./exitCodeError";
|
|
27
27
|
import { formatCommandHelp, formatHelp } from "./helpFormatter";
|
|
28
28
|
import { type CommandCls, getTargetCommandNames, getTargetMetas, type TargetFailure } from "./targetMeta";
|
|
29
|
+
import { WorkspaceVersionCheck } from "./workspaceVersionCheck";
|
|
29
30
|
|
|
30
31
|
const loggedCliErrorObjects = new WeakSet<object>();
|
|
31
32
|
const loggedCliErrorMessages = new Set<string>();
|
|
@@ -374,20 +375,8 @@ export const runCommands = async (...commands: CommandCls[]) => {
|
|
|
374
375
|
program.version(process.env.AKAN_VERSION).description("Akan CLI").configureHelp({
|
|
375
376
|
helpWidth: 100,
|
|
376
377
|
});
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
: null;
|
|
380
|
-
if (installedAkanPackageJson && installedAkanPackageJson.version !== process.env.AKAN_VERSION) {
|
|
381
|
-
Logger.rawLog(
|
|
382
|
-
chalk.yellow(
|
|
383
|
-
`
|
|
384
|
-
Akan CLI version is mismatch with installed package. ${process.env.AKAN_VERSION} (global) vs ${installedAkanPackageJson.version} (akanjs)
|
|
385
|
-
It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
386
|
-
),
|
|
387
|
-
undefined,
|
|
388
|
-
"error",
|
|
389
|
-
);
|
|
390
|
-
}
|
|
378
|
+
const versionWarning = WorkspaceVersionCheck.warning(cliPackageJson?.version, process.argv, process.cwd());
|
|
379
|
+
if (versionWarning) Logger.rawLog(chalk.yellow(`\n${versionWarning}`), undefined, "error");
|
|
391
380
|
|
|
392
381
|
for (const command of commands) {
|
|
393
382
|
const targetMetas = getTargetMetas(command);
|
|
@@ -695,6 +695,6 @@ describe("a workspace whose akanjs is not the CLI's version", () => {
|
|
|
695
695
|
]);
|
|
696
696
|
expect(code).toBe(0);
|
|
697
697
|
expect(stdout).toBe('{"phases":1}');
|
|
698
|
-
expect(stderr).toContain("0.0.1-older
|
|
698
|
+
expect(stderr).toContain("this workspace has akanjs 0.0.1-older");
|
|
699
699
|
}, 30_000);
|
|
700
700
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, symlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeJson } from "../testHelpers";
|
|
5
|
+
import { WorkspaceVersionCheck } from "./workspaceVersionCheck";
|
|
6
|
+
|
|
7
|
+
const makeRoot = tempDirs("akan-version-check-");
|
|
8
|
+
const argv = (...args: string[]) => ["bun", "akan", ...args];
|
|
9
|
+
|
|
10
|
+
const makeWorkspace = async ({ akanjs, cli }: { akanjs?: string; cli?: string }) => {
|
|
11
|
+
const root = await makeRoot();
|
|
12
|
+
if (akanjs) await writeJson(path.join(root, "node_modules/akanjs/package.json"), { name: "akanjs", version: akanjs });
|
|
13
|
+
if (cli)
|
|
14
|
+
await writeJson(path.join(root, "node_modules/@akanjs/cli/package.json"), { name: "@akanjs/cli", version: cli });
|
|
15
|
+
return root;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("WorkspaceVersionCheck", () => {
|
|
19
|
+
test("stays quiet when the CLI and the workspace's akanjs match, or when akanjs is not installed", async () => {
|
|
20
|
+
expect(
|
|
21
|
+
WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("sync", "demo"), await makeWorkspace({ akanjs: "3.1.1-rc.5" })),
|
|
22
|
+
).toBeNull();
|
|
23
|
+
expect(WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("sync", "demo"), await makeWorkspace({}))).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("names both versions and the matching CLI with the same arguments", async () => {
|
|
27
|
+
const root = await makeWorkspace({ akanjs: "3.1.0-rc.6" });
|
|
28
|
+
|
|
29
|
+
const warning = WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo", "--title", "it's"), root);
|
|
30
|
+
|
|
31
|
+
expect(warning).toContain("@akanjs/cli 3.1.0, but this workspace has akanjs 3.1.0-rc.6");
|
|
32
|
+
expect(warning).toContain(`Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title 'it'\\''s'`);
|
|
33
|
+
expect(warning).toEndWith("together: akan update");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("offers akan update with the CLI's prerelease tag only when the CLI is the newer one", async () => {
|
|
37
|
+
const older = await makeWorkspace({ akanjs: "3.1.1-rc.4" });
|
|
38
|
+
expect(WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("build", "demo"), older)).toEndWith("akan update --tag rc");
|
|
39
|
+
|
|
40
|
+
const newer = await makeWorkspace({ akanjs: "3.1.1-rc.5" });
|
|
41
|
+
const warning = WorkspaceVersionCheck.warning("3.1.0", argv("build", "demo"), newer);
|
|
42
|
+
expect(warning).not.toContain("akan update");
|
|
43
|
+
expect(warning).toEndWith("Pin it so `bunx akan` runs it: bun add -d @akanjs/cli@3.1.1-rc.5");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("points at the workspace's own pinned CLI when it matches akanjs", async () => {
|
|
47
|
+
const root = await makeWorkspace({ akanjs: "3.1.1-rc.5", cli: "3.1.1-rc.5" });
|
|
48
|
+
|
|
49
|
+
const warning = WorkspaceVersionCheck.warning("3.1.0", argv("start", "demo"), root);
|
|
50
|
+
|
|
51
|
+
expect(warning).toContain("Run the matching CLI: bunx akan start demo");
|
|
52
|
+
expect(warning).not.toContain("bun add -d");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("leaves update, create-workspace, help and version alone", async () => {
|
|
56
|
+
const root = await makeWorkspace({ akanjs: "3.1.0-rc.6" });
|
|
57
|
+
for (const args of [["update"], ["create-workspace", "next"], ["--version"], ["-V"], ["sync", "--help"]])
|
|
58
|
+
expect(WorkspaceVersionCheck.warning("3.1.0", argv(...args), root)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("skips a workspace that links akanjs from its own source", async () => {
|
|
62
|
+
const root = await makeWorkspace({});
|
|
63
|
+
await writeJson(path.join(root, "pkgs/akanjs/package.json"), { name: "akanjs", version: "3.1.1-rc.4" });
|
|
64
|
+
await mkdir(path.join(root, "node_modules"), { recursive: true });
|
|
65
|
+
await symlink(path.join(root, "pkgs/akanjs"), path.join(root, "node_modules/akanjs"), "dir");
|
|
66
|
+
|
|
67
|
+
expect(WorkspaceVersionCheck.installedAkanVersion(root)).toBeNull();
|
|
68
|
+
expect(WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo"), root)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { PackageJson } from "../types";
|
|
4
|
+
|
|
5
|
+
export class WorkspaceVersionCheck {
|
|
6
|
+
static readonly #exemptCommands = new Set(["update", "create-workspace"]);
|
|
7
|
+
static readonly #infoFlags = new Set(["--help", "-h", "--version", "-V"]);
|
|
8
|
+
static readonly #updateTags = new Set(["dev", "canary", "beta", "rc", "alpha"]);
|
|
9
|
+
|
|
10
|
+
static warning(cliVersion: string | undefined, argv: string[], workspaceRoot: string) {
|
|
11
|
+
const args = argv.slice(2);
|
|
12
|
+
if (!cliVersion || WorkspaceVersionCheck.#exemptCommands.has(args[0] ?? "")) return null;
|
|
13
|
+
if (args.some((arg) => WorkspaceVersionCheck.#infoFlags.has(arg))) return null;
|
|
14
|
+
const installed = WorkspaceVersionCheck.installedAkanVersion(workspaceRoot);
|
|
15
|
+
if (!installed || installed === cliVersion) return null;
|
|
16
|
+
const pinnedCli = WorkspaceVersionCheck.#versionAt(path.join(workspaceRoot, "node_modules/@akanjs/cli"));
|
|
17
|
+
const runner = pinnedCli === installed ? ["bunx"] : ["bunx", "-p", `@akanjs/cli@${installed}`];
|
|
18
|
+
const rerun = [...runner, "akan", ...args].map(WorkspaceVersionCheck.#quote).join(" ");
|
|
19
|
+
const [, prerelease = ""] = cliVersion.split("-");
|
|
20
|
+
const tag = prerelease.split(".")[0];
|
|
21
|
+
const update = `akan update${WorkspaceVersionCheck.#updateTags.has(tag) ? ` --tag ${tag}` : ""}`;
|
|
22
|
+
const lines = [
|
|
23
|
+
`This akan is @akanjs/cli ${cliVersion}, but this workspace has akanjs ${installed}: what it generates may not match the installed framework.`,
|
|
24
|
+
`Run the matching CLI: ${rerun}`,
|
|
25
|
+
];
|
|
26
|
+
if (Bun.semver.order(cliVersion, installed) > 0)
|
|
27
|
+
lines.push(`Or move the workspace and this CLI to the newest release of the line together: ${update}`);
|
|
28
|
+
else if (pinnedCli !== installed)
|
|
29
|
+
lines.push(`Pin it so \`bunx akan\` runs it: bun add -d @akanjs/cli@${installed}`);
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static installedAkanVersion(workspaceRoot: string) {
|
|
34
|
+
const packageDir = path.join(workspaceRoot, "node_modules", "akanjs");
|
|
35
|
+
if (!existsSync(packageDir)) return null;
|
|
36
|
+
//? A workspace link (the framework's own repo) runs akanjs from source, so no installed release can differ.
|
|
37
|
+
if (!realpathSync(packageDir).split(path.sep).includes("node_modules")) return null;
|
|
38
|
+
return WorkspaceVersionCheck.#versionAt(packageDir);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static #versionAt(packageDir: string) {
|
|
42
|
+
const packageJsonPath = path.join(packageDir, "package.json");
|
|
43
|
+
if (!existsSync(packageJsonPath)) return null;
|
|
44
|
+
return (JSON.parse(readFileSync(packageJsonPath, "utf8")) as Partial<PackageJson>).version ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static #quote(arg: string) {
|
|
48
|
+
return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
49
|
+
}
|
|
50
|
+
}
|