@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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { mkdir, rm } from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import type { BunPlugin } from "bun";
|
|
4
3
|
import type {
|
|
@@ -16,10 +15,14 @@ import {
|
|
|
16
15
|
} from "./frontendBuild";
|
|
17
16
|
import { Spinner } from "./spinner";
|
|
18
17
|
import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
|
|
18
|
+
import { ServerEnvProfile } from "./transforms/serverEnvProfile";
|
|
19
|
+
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
19
20
|
|
|
20
21
|
export interface TypecheckOptions {
|
|
21
22
|
clean?: boolean;
|
|
22
23
|
incremental?: boolean;
|
|
24
|
+
/** The app's own tests and specs too (`akan typecheck`); a build leaves them out. */
|
|
25
|
+
tests?: boolean;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
|
|
@@ -32,6 +35,8 @@ export interface ApplicationBuildRunnerOptions {
|
|
|
32
35
|
reporter?: BuildProgressReporter;
|
|
33
36
|
/** The backend env the build is for, when it is not the workspace's own (a mobile or desktop build's `--env`). */
|
|
34
37
|
environment?: string;
|
|
38
|
+
/** Bundles `env/env.server.<environment>.<profile>.ts`, which may hold no secret, in place of the environment's file. */
|
|
39
|
+
profile?: string;
|
|
35
40
|
}
|
|
36
41
|
export interface BuildOptions {
|
|
37
42
|
spinner?: boolean;
|
|
@@ -67,34 +72,41 @@ const SSR_RENDER_EXTERNALS = [
|
|
|
67
72
|
// is a no-op as of Bun 1.4.2.
|
|
68
73
|
export const AKAN_BACKEND_MINIFY = { whitespace: true, syntax: true, identifiers: false } as const;
|
|
69
74
|
|
|
70
|
-
export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
|
|
71
|
-
"@libsql/client",
|
|
72
|
-
"bullmq",
|
|
73
|
-
"ioredis",
|
|
74
|
-
"postgres",
|
|
75
|
-
"protobufjs",
|
|
76
|
-
] as const;
|
|
75
|
+
export const AKAN_OPTIONAL_BACKEND_EXTERNALS = ["bullmq", "ioredis", "postgres", "protobufjs"] as const;
|
|
77
76
|
|
|
78
77
|
export class ApplicationBuildRunner {
|
|
79
78
|
#app: App;
|
|
80
79
|
#fast: boolean;
|
|
81
80
|
#reporter?: BuildProgressReporter;
|
|
82
81
|
#environment?: string;
|
|
82
|
+
#profile?: string;
|
|
83
83
|
#spinner?: boolean;
|
|
84
84
|
#startedAt = Date.now();
|
|
85
85
|
#phases: BuildPhaseResult[] = [];
|
|
86
86
|
|
|
87
|
-
constructor(app: App, { fast = false, reporter, environment }: ApplicationBuildRunnerOptions = {}) {
|
|
87
|
+
constructor(app: App, { fast = false, reporter, environment, profile }: ApplicationBuildRunnerOptions = {}) {
|
|
88
88
|
this.#app = app;
|
|
89
89
|
this.#fast = fast;
|
|
90
90
|
this.#reporter = reporter;
|
|
91
91
|
this.#environment = environment;
|
|
92
|
+
this.#profile = profile;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get #envDir() {
|
|
96
|
+
return path.join(this.#app.cwdPath, "env");
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
|
|
95
100
|
// serial build is needed because of Bun.build is unstable for parallel build
|
|
96
101
|
this.#spinner = spinner;
|
|
97
|
-
const { web, assets } = await this.#app.getConfig();
|
|
102
|
+
const { web, assets, baseDevEnv } = await this.#app.getConfig();
|
|
103
|
+
//? Before any phase: a profile that would ship a secret stops the build, not the image after it.
|
|
104
|
+
if (this.#profile)
|
|
105
|
+
await ServerEnvProfile.assertShippable({
|
|
106
|
+
envDir: this.#envDir,
|
|
107
|
+
environment: this.#environment ?? baseDevEnv.env,
|
|
108
|
+
profile: this.#profile,
|
|
109
|
+
});
|
|
98
110
|
await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"));
|
|
99
111
|
if (!this.#fast) await this.#runPhase("typecheck", "Typechecking", () => this.typecheck());
|
|
100
112
|
await this.#runPhase(
|
|
@@ -148,11 +160,31 @@ export class ApplicationBuildRunner {
|
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
async typecheck(options: TypecheckOptions = {}) {
|
|
151
|
-
const { clean = false, incremental = true } = options;
|
|
152
163
|
await this.#app.getPageKeys({ refresh: true });
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
const typecheck = new ProjectTypecheck(
|
|
165
|
+
this.#app.cwdPath,
|
|
166
|
+
this.#app.workspace.workspaceRoot,
|
|
167
|
+
this.#app.getCommandEnv(),
|
|
168
|
+
);
|
|
169
|
+
//* TypeScript's `include` globs do not cross a symlink, so synced lib pages need their real path.
|
|
170
|
+
const libPageIncludes = (await this.#app.getPageRoots())
|
|
171
|
+
.filter((root) => root.keyPrefix)
|
|
172
|
+
.flatMap((root) => {
|
|
173
|
+
const rel = path.relative(typecheck.typecheckDir, root.realDir).split(path.sep).join("/");
|
|
174
|
+
return [`${rel}/**/*.ts`, `${rel}/**/*.tsx`];
|
|
175
|
+
});
|
|
176
|
+
await typecheck.run(
|
|
177
|
+
[
|
|
178
|
+
"../../main.ts",
|
|
179
|
+
"../../server.ts",
|
|
180
|
+
"../../client.ts",
|
|
181
|
+
"../../page/**/*.ts",
|
|
182
|
+
"../../page/**/*.tsx",
|
|
183
|
+
...libPageIncludes,
|
|
184
|
+
"../../../../pkgs/akanjs/*/types/**/*.d.ts",
|
|
185
|
+
],
|
|
186
|
+
options,
|
|
187
|
+
);
|
|
156
188
|
}
|
|
157
189
|
|
|
158
190
|
async #runPhase<T>(
|
|
@@ -209,6 +241,28 @@ export class ApplicationBuildRunner {
|
|
|
209
241
|
return `${sha.stdout.toString().trim()}${suffix}`;
|
|
210
242
|
}
|
|
211
243
|
|
|
244
|
+
#serverEnv: BunPlugin | null = null;
|
|
245
|
+
#warnedLibEnvs = new Set<string>();
|
|
246
|
+
//? One plugin for every server-side bundle of the build: the backend, the RSC worker and the SSR route bundle.
|
|
247
|
+
#serverEnvPlugin(baseEnv: string, branches: Iterable<string>): BunPlugin {
|
|
248
|
+
this.#serverEnv ??= createServerEnvPlugin({
|
|
249
|
+
envDir: this.#envDir,
|
|
250
|
+
//? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
|
|
251
|
+
environment: this.#environment ?? baseEnv,
|
|
252
|
+
environments: ["local", "testing", ...branches],
|
|
253
|
+
profile: this.#profile,
|
|
254
|
+
onLibEnv: (file, { profileFile, stripped }) => {
|
|
255
|
+
if (profileFile || !stripped.length || this.#warnedLibEnvs.has(file)) return;
|
|
256
|
+
this.#warnedLibEnvs.add(file);
|
|
257
|
+
const lib = path.relative(this.#app.workspace.workspaceRoot, file);
|
|
258
|
+
this.#app.logger.warn(
|
|
259
|
+
`${lib}: the "${this.#profile}" build leaves out ${stripped.join(", ")}. Give the lib ${path.basename(ServerEnvProfile.profileFileOf(file, this.#profile ?? ""))} to say what this profile runs with.`,
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
return this.#serverEnv;
|
|
264
|
+
}
|
|
265
|
+
|
|
212
266
|
async #buildBackend() {
|
|
213
267
|
const { externalLibs, web, baseDevEnv, branches } = await this.#app.getConfig();
|
|
214
268
|
const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
@@ -223,12 +277,7 @@ export class ApplicationBuildRunner {
|
|
|
223
277
|
// `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
|
|
224
278
|
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
225
279
|
} satisfies Omit<Bun.BuildConfig, "entrypoints">;
|
|
226
|
-
const serverEnvPlugin =
|
|
227
|
-
envDir: path.join(this.#app.cwdPath, "env"),
|
|
228
|
-
//? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
|
|
229
|
-
environment: this.#environment ?? baseDevEnv.env,
|
|
230
|
-
environments: ["local", "testing", ...branches],
|
|
231
|
-
});
|
|
280
|
+
const serverEnvPlugin = this.#serverEnvPlugin(baseDevEnv.env, branches);
|
|
232
281
|
const backendConfig = {
|
|
233
282
|
...sharedConfig,
|
|
234
283
|
plugins: [this.#createExternalSpecifiersPlugin(externalLibs), serverEnvPlugin],
|
|
@@ -306,74 +355,14 @@ void run().catch((error) => {
|
|
|
306
355
|
this.#app.log(`[cli] no route files under ${this.#app.cwdPath}/page — skipping SSR build`);
|
|
307
356
|
return null;
|
|
308
357
|
}
|
|
309
|
-
const
|
|
358
|
+
const { baseDevEnv, branches } = await this.#app.getConfig();
|
|
359
|
+
const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
|
|
360
|
+
serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
|
|
361
|
+
}).build();
|
|
310
362
|
const allRoutes = await new AllRoutesBuilder(this.#app, base.artifact, "build").build();
|
|
311
363
|
return { base, allRoutes };
|
|
312
364
|
}
|
|
313
365
|
|
|
314
|
-
async #writeTypecheckTsconfig(incremental: boolean) {
|
|
315
|
-
const typecheckDir = path.join(this.#app.cwdPath, ".akan", "typecheck");
|
|
316
|
-
await mkdir(typecheckDir, { recursive: true });
|
|
317
|
-
//* TypeScript's `include` globs do not cross a symlink, so synced lib pages need their real path.
|
|
318
|
-
const libPageIncludes = (await this.#app.getPageRoots())
|
|
319
|
-
.filter((root) => root.keyPrefix)
|
|
320
|
-
.flatMap((root) => {
|
|
321
|
-
const rel = path.relative(typecheckDir, root.realDir).split(path.sep).join("/");
|
|
322
|
-
return [`${rel}/**/*.ts`, `${rel}/**/*.tsx`];
|
|
323
|
-
});
|
|
324
|
-
const tsconfig = {
|
|
325
|
-
extends: "../../tsconfig.json",
|
|
326
|
-
compilerOptions: {
|
|
327
|
-
noEmit: true,
|
|
328
|
-
incremental,
|
|
329
|
-
tsBuildInfoFile: "./tsconfig.tsbuildinfo",
|
|
330
|
-
},
|
|
331
|
-
include: [
|
|
332
|
-
"../../main.ts",
|
|
333
|
-
"../../server.ts",
|
|
334
|
-
"../../client.ts",
|
|
335
|
-
"../../page/**/*.ts",
|
|
336
|
-
"../../page/**/*.tsx",
|
|
337
|
-
...libPageIncludes,
|
|
338
|
-
"../../../../pkgs/akanjs/*/types/**/*.d.ts",
|
|
339
|
-
],
|
|
340
|
-
references: [],
|
|
341
|
-
};
|
|
342
|
-
const tsconfigPath = path.join(typecheckDir, "tsconfig.json");
|
|
343
|
-
await Bun.write(tsconfigPath, `${JSON.stringify(tsconfig, null, 2)}\n`);
|
|
344
|
-
return { typecheckDir, tsconfigPath };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async #checkProjectInChildProcess(tsconfigPath: string) {
|
|
348
|
-
const entry = await this.#resolveTypecheckWorkerEntry();
|
|
349
|
-
const proc = Bun.spawn([process.execPath, entry], {
|
|
350
|
-
cwd: this.#app.workspace.workspaceRoot,
|
|
351
|
-
env: this.#app.getCommandEnv({
|
|
352
|
-
AKAN_COMMAND_TYPE: "typecheck",
|
|
353
|
-
AKAN_TYPECHECK_TSCONFIG: tsconfigPath,
|
|
354
|
-
}),
|
|
355
|
-
stdout: "pipe",
|
|
356
|
-
stderr: "pipe",
|
|
357
|
-
});
|
|
358
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
359
|
-
new Response(proc.stdout).text(),
|
|
360
|
-
new Response(proc.stderr).text(),
|
|
361
|
-
proc.exited,
|
|
362
|
-
]);
|
|
363
|
-
if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
async #resolveTypecheckWorkerEntry() {
|
|
367
|
-
const candidates = [
|
|
368
|
-
path.join(this.#app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
369
|
-
path.join(this.#app.workspace.workspaceRoot, "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
370
|
-
path.join(import.meta.dir, "typecheck.proc.js"),
|
|
371
|
-
path.join(import.meta.dir, "typecheck.proc.ts"),
|
|
372
|
-
];
|
|
373
|
-
for (const candidate of candidates) if (await Bun.file(candidate).exists()) return candidate;
|
|
374
|
-
throw new Error(`[cli] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
366
|
async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
|
|
378
367
|
const result = await Bun.build(config);
|
|
379
368
|
if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
|
|
@@ -39,8 +39,9 @@ describe("resolveSsrPageEntries", () => {
|
|
|
39
39
|
expect(generatedSource).toContain("const inheritedLayout = resolveRouteModule(inheritedModule as never");
|
|
40
40
|
expect(generatedSource).not.toContain("<System.Provider");
|
|
41
41
|
expect(generatedSource).toContain("export async function generateHead(props: PageProps)");
|
|
42
|
-
|
|
43
|
-
expect(generatedSource).toContain("
|
|
42
|
+
// The ancestor root is a level of the route tree, which merges its head; this boundary returns only its own.
|
|
43
|
+
expect(generatedSource).toContain("[userLayout].map((layout)");
|
|
44
|
+
expect(generatedSource).toContain("return RouteHead.mergeNodes(heads);");
|
|
44
45
|
expect(generatedSource).not.toContain("generateMetadata");
|
|
45
46
|
expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
|
|
46
47
|
expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
|
|
@@ -71,6 +72,9 @@ describe("resolveSsrPageEntries", () => {
|
|
|
71
72
|
expect(generatedSource).toContain("<System.Provider");
|
|
72
73
|
expect(generatedSource).toContain("export const theme = userLayout.theme ?? inheritedLayout.theme;");
|
|
73
74
|
expect(generatedSource).toContain("theme={theme}");
|
|
75
|
+
expect(generatedSource).toContain("export const viewport = userLayout.viewport ?? inheritedLayout.viewport;");
|
|
76
|
+
expect(generatedSource).toContain("viewport={viewport}");
|
|
77
|
+
expect(generatedSource).toContain("[inheritedLayout, userLayout].map((layout)");
|
|
74
78
|
expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
|
|
75
79
|
expect(generatedSource).toContain(
|
|
76
80
|
'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
|
|
@@ -189,11 +189,13 @@ async function writeGeneratedRootLayoutFile(opts: {
|
|
|
189
189
|
: "";
|
|
190
190
|
const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
|
|
191
191
|
const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
|
|
192
|
+
//? Under an ancestor root boundary the route tree merges that root's head itself; merging it here would repeat it.
|
|
193
|
+
const headLayouts = opts.includeSystemProvider ? "[inheritedLayout, userLayout]" : "[userLayout]";
|
|
192
194
|
const layoutExports = `export async function generateHead(props: PageProps) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return
|
|
195
|
+
const heads = await Promise.all(
|
|
196
|
+
${headLayouts}.map((layout) => (layout.generateHead ? layout.generateHead(props) : layout.head)),
|
|
197
|
+
);
|
|
198
|
+
return RouteHead.mergeNodes(heads);
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
@@ -204,7 +206,7 @@ ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
|
|
|
204
206
|
${layoutBinding} return `;
|
|
205
207
|
const source = opts.includeSystemProvider
|
|
206
208
|
? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
207
|
-
import { loadFonts, resolveRouteModule } from "akanjs/client";
|
|
209
|
+
import { loadFonts, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
208
210
|
import type { ReactNode } from "react";
|
|
209
211
|
import { System } from "akanjs/ui";
|
|
210
212
|
import { env } from "@apps/${opts.appName}/env/env.client";
|
|
@@ -216,15 +218,18 @@ if (defaultFonts.length > 1) throw new Error("[route-convention] only one defaul
|
|
|
216
218
|
const defaultFont = defaultFonts[0];
|
|
217
219
|
const defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;
|
|
218
220
|
export const theme = userLayout.theme ?? inheritedLayout.theme;
|
|
221
|
+
export const viewport = userLayout.viewport ?? inheritedLayout.viewport;
|
|
222
|
+
export const manifest = userLayout.manifest ?? inheritedLayout.manifest;
|
|
219
223
|
|
|
220
224
|
${layoutExports}(
|
|
221
225
|
<System.Provider
|
|
222
226
|
of={GeneratedLayout as never}
|
|
223
227
|
appName=${JSON.stringify(opts.appName)}
|
|
224
228
|
${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}
|
|
225
|
-
manifest={
|
|
229
|
+
manifest={manifest}
|
|
226
230
|
env={env}
|
|
227
231
|
theme={theme}
|
|
232
|
+
viewport={viewport}
|
|
228
233
|
fonts={loadFonts(userFonts)}
|
|
229
234
|
className={defaultFontClassName}
|
|
230
235
|
layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
|
|
@@ -238,7 +243,7 @@ ${layoutExports}(
|
|
|
238
243
|
}
|
|
239
244
|
`
|
|
240
245
|
: `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
241
|
-
import { resolveRouteModule } from "akanjs/client";
|
|
246
|
+
import { resolveRouteModule, RouteHead } from "akanjs/client";
|
|
242
247
|
import type { ReactNode } from "react";
|
|
243
248
|
${inheritedImport}${userImport}
|
|
244
249
|
${layoutExports}${layoutReturn};
|
package/biome.base.json
CHANGED
|
@@ -301,8 +301,6 @@
|
|
|
301
301
|
"{apps,libs}/**/*.document.ts",
|
|
302
302
|
"{apps,libs}/**/*.service.ts",
|
|
303
303
|
"{apps,libs}/**/*.signal.ts",
|
|
304
|
-
"{apps,libs}/**/*.signal.test.ts",
|
|
305
|
-
"{apps,libs}/**/*.service.test.ts",
|
|
306
304
|
"{apps,libs}/**/*.store.ts",
|
|
307
305
|
"{apps,libs}/**/*.Template.tsx",
|
|
308
306
|
"{apps,libs}/**/*.Unit.tsx",
|
|
@@ -315,6 +313,13 @@
|
|
|
315
313
|
"./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"
|
|
316
314
|
]
|
|
317
315
|
},
|
|
316
|
+
{
|
|
317
|
+
"includes": ["{apps,libs}/**/*.signal.test.ts", "{apps,libs}/**/*.service.test.ts"],
|
|
318
|
+
"plugins": [
|
|
319
|
+
"./node_modules/@akanjs/devkit/lint/no-import-external-library-in-test.grit",
|
|
320
|
+
"./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"
|
|
321
|
+
]
|
|
322
|
+
},
|
|
318
323
|
{
|
|
319
324
|
"includes": [
|
|
320
325
|
"apps/**/*.tsx",
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -63,6 +63,45 @@ export interface TunnelGrant {
|
|
|
63
63
|
expiresAt: string | null;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** `scope` is `"*"` for every branch or the one branch it overrides. No call ever returns a value. */
|
|
67
|
+
export interface PipelineSecretSummary {
|
|
68
|
+
name: string;
|
|
69
|
+
scope: string;
|
|
70
|
+
updatedAt: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PipelineSecretListing extends PipelineSecretSummary {
|
|
74
|
+
updatedBy: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What akasys answers for an uploaded pack or a registered binary; a pack's release also says how it was taken. */
|
|
78
|
+
export interface CloudRecord {
|
|
79
|
+
id: string;
|
|
80
|
+
status?: string;
|
|
81
|
+
problems?: string[];
|
|
82
|
+
[key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface UpdateUploadRequest {
|
|
86
|
+
appName: string;
|
|
87
|
+
target: string;
|
|
88
|
+
platform: "ios" | "android";
|
|
89
|
+
channel: string;
|
|
90
|
+
notes: string | null;
|
|
91
|
+
size: number;
|
|
92
|
+
sha256: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface UpdateUploadTicket {
|
|
96
|
+
uploadId: string;
|
|
97
|
+
/** A presigned PUT: whoever holds it writes the staging object, so it is never logged or put in an argv. */
|
|
98
|
+
uploadUrl: string;
|
|
99
|
+
expiresAt: string;
|
|
100
|
+
contentType: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type AppBinaryDistribution = "appStore" | "testFlight" | "playStore" | "playTesting" | "direct";
|
|
104
|
+
|
|
66
105
|
export interface TunnelSummary {
|
|
67
106
|
code: string;
|
|
68
107
|
hostname: string;
|
|
@@ -74,6 +113,11 @@ export interface TunnelSummary {
|
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
export class CloudApi {
|
|
116
|
+
/** The HTTP status an error from this client carries, or null for one without (a dropped connection, a timeout). */
|
|
117
|
+
static statusOf(error: unknown) {
|
|
118
|
+
const status = Number(/(?:^|: )(\d{3}) /.exec(error instanceof Error ? error.message : String(error))?.[1]);
|
|
119
|
+
return Number.isNaN(status) ? null : status;
|
|
120
|
+
}
|
|
77
121
|
readonly #api: HttpClient;
|
|
78
122
|
#accessToken: AccessToken | null = null;
|
|
79
123
|
#workspace: Workspace;
|
|
@@ -113,6 +157,49 @@ export class CloudApi {
|
|
|
113
157
|
formData.append("file", file);
|
|
114
158
|
return await this.#api.post<boolean>(`/uploadEnv/${devProjectId}`, formData);
|
|
115
159
|
}
|
|
160
|
+
async uploadSecret(devProjectId: string, secret: { scope: string; name: string; value: string }) {
|
|
161
|
+
return await this.#api.post<PipelineSecretSummary>(`/uploadSecret/${encodeURIComponent(devProjectId)}`, secret);
|
|
162
|
+
}
|
|
163
|
+
async listSecrets(devProjectId: string) {
|
|
164
|
+
return await this.#api.get<PipelineSecretListing[]>(`/listSecrets/${encodeURIComponent(devProjectId)}`);
|
|
165
|
+
}
|
|
166
|
+
async removeSecret(devProjectId: string, secret: { scope: string; name: string }) {
|
|
167
|
+
return await this.#api.post<boolean>(`/removeSecret/${encodeURIComponent(devProjectId)}`, secret);
|
|
168
|
+
}
|
|
169
|
+
async requestUpdateUpload(devProjectId: string, upload: UpdateUploadRequest) {
|
|
170
|
+
return await this.#api.post<UpdateUploadTicket>(`/requestUpdateUpload/${encodeURIComponent(devProjectId)}`, upload);
|
|
171
|
+
}
|
|
172
|
+
// The endpoint declares ten minutes; Cloudflare cuts at 100 s anyway, which is what UpdatePackUpload's retry is for.
|
|
173
|
+
async commitUpdateUpload(devProjectId: string, uploadId: string) {
|
|
174
|
+
return await this.#api.post<CloudRecord>(
|
|
175
|
+
`/commitUpdateUpload/${encodeURIComponent(devProjectId)}`,
|
|
176
|
+
{ uploadId },
|
|
177
|
+
{ signal: AbortSignal.timeout(11 * 60_000) },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
async registerAppBinary(
|
|
181
|
+
devProjectId: string,
|
|
182
|
+
binary: {
|
|
183
|
+
appName: string;
|
|
184
|
+
target: string;
|
|
185
|
+
distribution: AppBinaryDistribution;
|
|
186
|
+
bundle: Blob;
|
|
187
|
+
/** The pack staged with UpdatePackUpload.stage; the multipart `pack` is for a server older than staging. */
|
|
188
|
+
packUploadId?: string;
|
|
189
|
+
pack?: Blob;
|
|
190
|
+
channel?: string;
|
|
191
|
+
},
|
|
192
|
+
) {
|
|
193
|
+
const formData = new FormData();
|
|
194
|
+
for (const key of ["appName", "target", "distribution"] as const) formData.append(key, binary[key]);
|
|
195
|
+
if (binary.channel) formData.append("channel", binary.channel);
|
|
196
|
+
formData.append("bundle", binary.bundle, "bundle.json");
|
|
197
|
+
if (binary.packUploadId) formData.append("packUploadId", binary.packUploadId);
|
|
198
|
+
if (binary.pack) formData.append("pack", binary.pack, "pack.tgz");
|
|
199
|
+
return await this.#api.post<CloudRecord>(`/registerAppBinary/${encodeURIComponent(devProjectId)}`, formData, {
|
|
200
|
+
signal: AbortSignal.timeout(11 * 60_000),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
116
203
|
async downloadEnv(devProjectId: string): Promise<unknown> {
|
|
117
204
|
const localPath = `${this.#workspace.workspaceRoot}/local/env.tar`;
|
|
118
205
|
await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -53,6 +53,10 @@ export class GlobalConfig {
|
|
|
53
53
|
await GlobalConfig.#setAkanGlobalConfig(mutate(await GlobalConfig.#getAkanGlobalConfig()));
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
+
static async hostFor(workspace: { getConfig(): Promise<{ cloudHost: string | null }> }, host?: string | null) {
|
|
57
|
+
if (host) return host;
|
|
58
|
+
return (await workspace.getConfig()).cloudHost ?? GlobalConfig.akanCloudHost;
|
|
59
|
+
}
|
|
56
60
|
static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
|
|
57
61
|
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
58
62
|
return GlobalConfig.toHostConfig(akanConfig.cloudHost[host] ?? getDefaultHostConfig(host));
|
package/cloud/index.ts
CHANGED