@akanjs/devkit 3.1.0-rc.4 → 3.1.0-rc.5
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/abstractDoc.ts +1 -1
- package/agentsIndex.test.ts +4 -1
- package/akanConfig/akanConfig.ts +2 -2
- package/akanContext.ts +4 -4
- package/akanMcpContract.ts +1 -1
- package/applicationBuildRunner.test.ts +24 -1
- package/applicationBuildRunner.ts +11 -1
- package/biome.base.json +0 -10
- package/cloud/configLock.ts +2 -1
- package/cloud/globalConfig.ts +3 -2
- package/codeAgent/agent/CodeAgentProxy.ts +2 -2
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +3 -1
- package/codeAgent/tools/WebToolPack.ts +4 -4
- package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
- package/commandDecorators/command.ts +5 -4
- package/createTunnel.test.ts +14 -0
- package/createTunnel.ts +3 -1
- package/dependencyScanner.ts +1 -1
- package/devkitUtils.test.ts +1 -1
- package/executors.test.ts +62 -3
- package/executors.ts +26 -9
- package/frontendBuild/autoImportSync.test.ts +3 -1
- package/frontendBuild/clientEntryDiscovery.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
- package/frontendBuild/csrArtifactBuilder.ts +5 -3
- package/frontendBuild/csrDevPatcher.test.ts +4 -1
- package/frontendBuild/cssBlockParser.test.ts +123 -0
- package/frontendBuild/cssBlockParser.ts +132 -0
- package/frontendBuild/cssCompiler.test.ts +53 -0
- package/frontendBuild/cssCompiler.ts +8 -4
- package/frontendBuild/fontOptimizer.ts +2 -1
- package/frontendBuild/frontendBuild.test.ts +14 -0
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
- package/frontendBuild/pagesBundleBuilder.ts +64 -9
- package/frontendBuild/routeClientBuilder.ts +1 -1
- package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
- package/frontendBuild/themeValidator.test.ts +83 -2
- package/frontendBuild/themeValidator.ts +37 -15
- package/getRelatedCnsts.ts +2 -1
- package/integration/devStabilityHarness.ts +5 -3
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
- package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
- package/mobile/mobileTarget.ts +1 -2
- package/mobile/nativeConfig.test.ts +3 -0
- package/mobile/nativeConfig.ts +3 -2
- package/mobile/nativeWebDir.ts +3 -2
- package/mobile/updateUploader.test.ts +1 -0
- package/mobile/updateUploader.ts +3 -3
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +2 -1
- package/platformTest/PlatformTestTarget.ts +7 -5
- package/platformTest/WindowsSshTarget.ts +2 -1
- package/qualityScanner.ts +1 -1
- package/recipeScanner.test.ts +9 -3
- package/scanInfo.ts +1 -1
- package/subspaceClone.ts +5 -4
- package/testHelpers.ts +2 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/browserDirnamePlugin.test.ts +88 -0
- package/transforms/browserDirnamePlugin.ts +25 -0
- package/transforms/rscUseClientTransform.ts +2 -2
- package/transforms/transforms.test.ts +18 -2
- package/transforms/tsconfigPackageResolver.test.ts +9 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +1 -1
- package/workflow/rolloutGate.test.ts +5 -1
- package/workflow/source.ts +2 -2
package/abstractDoc.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class AbstractDoc {
|
|
|
26
26
|
static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
|
|
27
27
|
const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`))
|
|
28
28
|
.map((filePath) => filePath.split(path.sep).join("/"))
|
|
29
|
-
.sort();
|
|
29
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
30
30
|
const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
|
|
31
31
|
if (!module) return docs;
|
|
32
32
|
return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
|
package/agentsIndex.test.ts
CHANGED
|
@@ -86,6 +86,9 @@ describe("collectScopeRecipeSources", () => {
|
|
|
86
86
|
await write("libs/shared/ui/Recipe/panel.ts", `export const panelRecipe = recipe(tv({ base: "y" }));`);
|
|
87
87
|
await write("pkgs/akanjs/ui/recipe/buttonRecipe.ts", `export const buttonRecipe = recipe(tv({ base: "z" }));`);
|
|
88
88
|
const sources = await collectScopeRecipeSources(root, { type: "app", name: "minimal" }, ["shared"]);
|
|
89
|
-
expect(sources.map((source) => source.importFrom).sort()).toEqual([
|
|
89
|
+
expect(sources.map((source) => source.importFrom).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
|
|
90
|
+
"@apps/minimal/ui",
|
|
91
|
+
"@libs/shared/ui",
|
|
92
|
+
]);
|
|
90
93
|
});
|
|
91
94
|
});
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import type { ServerPolicy } from "@akanjs/native/api";
|
|
5
5
|
import type { AkanPlugin } from "akanjs";
|
|
6
6
|
import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
|
|
7
|
-
import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
|
|
7
|
+
import { type AkanI18nConfig, resolveAkanI18nConfig, trimSlashes } from "akanjs/common";
|
|
8
8
|
import type { AkanImageConfig } from "akanjs/server";
|
|
9
9
|
import type { App, Lib } from "../commandDecorators";
|
|
10
10
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
@@ -567,7 +567,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
567
567
|
}
|
|
568
568
|
#applyRoutes(routes: AkanRouteConfig[] = []) {
|
|
569
569
|
for (const route of routes) {
|
|
570
|
-
const basePath = route.basePath ? route.basePath
|
|
570
|
+
const basePath = route.basePath ? trimSlashes(route.basePath) : null;
|
|
571
571
|
if (basePath !== null) this.basePaths.add(basePath);
|
|
572
572
|
const domains = basePath !== null ? this.subRoutes.getOrInsert(basePath, new Set()) : this.domains;
|
|
573
573
|
for (const branch of Object.keys(route.domains)) this.branches.add(branch);
|
package/akanContext.ts
CHANGED
|
@@ -209,7 +209,7 @@ export const upsertCodexMcpServerBlock = (
|
|
|
209
209
|
const match = existing.match(codexAkanTablePattern);
|
|
210
210
|
if (!match) {
|
|
211
211
|
if (!existing.trim()) return nextBlock;
|
|
212
|
-
return `${existing.
|
|
212
|
+
return `${existing.trimEnd()}\n\n${nextBlock}`;
|
|
213
213
|
}
|
|
214
214
|
if (match[0].trim() === nextBlock.trim()) return existing;
|
|
215
215
|
if (!force)
|
|
@@ -847,8 +847,8 @@ export class AkanContextAnalyzer {
|
|
|
847
847
|
const listed = listedByImport(sectionOf(agentsMd, "## Recipes")).get("akanjs/ui") ?? new Set<string>();
|
|
848
848
|
pushIndexDiagnostic(
|
|
849
849
|
"AGENTS.md",
|
|
850
|
-
[...frameworkDeclared].filter((name) => !listed.has(name)).sort(),
|
|
851
|
-
[...listed].filter((name) => !frameworkDeclared.has(name)).sort(),
|
|
850
|
+
[...frameworkDeclared].filter((name) => !listed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
851
|
+
[...listed].filter((name) => !frameworkDeclared.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
852
852
|
"akan agent install agents-md",
|
|
853
853
|
);
|
|
854
854
|
}
|
|
@@ -859,7 +859,7 @@ export class AkanContextAnalyzer {
|
|
|
859
859
|
.catch(() => "");
|
|
860
860
|
const groups = listedByImport(sectionOf(scopeMd, "## Recipes In Scope"));
|
|
861
861
|
const ownListed = groups.get(`@${sys.path}/ui`) ?? new Set<string>();
|
|
862
|
-
const missing = [...own].filter((name) => !ownListed.has(name)).sort();
|
|
862
|
+
const missing = [...own].filter((name) => !ownListed.has(name)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
863
863
|
// Every listed name — the scope's own and its dependency libs' — must still exist at its owner.
|
|
864
864
|
const stale = [...groups.entries()]
|
|
865
865
|
.flatMap(([importFrom, names]) =>
|
package/akanMcpContract.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { 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";
|
|
@@ -110,4 +110,27 @@ console.info(new SampleService().name);
|
|
|
110
110
|
await rm(root, { recursive: true, force: true });
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("a build for a real environment stops before any phase when the serve domain is only the local default", async () => {
|
|
115
|
+
const { root, workspace } = await createTempApp("portal");
|
|
116
|
+
await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
|
|
117
|
+
const shellDomain = process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
118
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
119
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
120
|
+
let prepared = false;
|
|
121
|
+
app.prepareCommand = async () => {
|
|
122
|
+
prepared = true;
|
|
123
|
+
throw new Error("prepared");
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
await expect(new ApplicationBuildRunner(app, { environment: "main" }).build()).rejects.toThrow(
|
|
127
|
+
'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
128
|
+
);
|
|
129
|
+
expect(prepared).toBe(false);
|
|
130
|
+
await expect(new ApplicationBuildRunner(app, { environment: "local" }).build()).rejects.toThrow("prepared");
|
|
131
|
+
} finally {
|
|
132
|
+
if (shellDomain !== undefined) process.env.AKAN_PUBLIC_SERVE_DOMAIN = shellDomain;
|
|
133
|
+
await rm(root, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
113
136
|
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ApplicationBuildResult,
|
|
7
7
|
} from "./applicationBuildReporter";
|
|
8
8
|
import type { App } from "./commandDecorators";
|
|
9
|
+
import { WorkspaceExecutor } from "./executors";
|
|
9
10
|
import {
|
|
10
11
|
AllRoutesBuilder,
|
|
11
12
|
CsrArtifactBuilder,
|
|
@@ -101,6 +102,7 @@ export class ApplicationBuildRunner {
|
|
|
101
102
|
this.#spinner = spinner;
|
|
102
103
|
const { web, assets, baseDevEnv } = await this.#app.getConfig();
|
|
103
104
|
const environment = this.#environment ?? baseDevEnv.env;
|
|
105
|
+
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, "akan build");
|
|
104
106
|
//? Bundles bake AKAN_PUBLIC_ENV from process.env, where the shell outranks the .env that picked the server env.
|
|
105
107
|
process.env.AKAN_PUBLIC_ENV = environment;
|
|
106
108
|
//? Before any phase: a profile that would ship a secret stops the build, not the image after it.
|
|
@@ -362,9 +364,17 @@ void run().catch((error) => {
|
|
|
362
364
|
return { base, allRoutes };
|
|
363
365
|
}
|
|
364
366
|
|
|
367
|
+
//? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
|
|
365
368
|
async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
|
|
366
|
-
const
|
|
369
|
+
const metafileDir = process.env.AKAN_BUILD_METAFILE
|
|
370
|
+
? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
|
|
371
|
+
: null;
|
|
372
|
+
const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
|
|
367
373
|
if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
|
|
374
|
+
if (metafileDir && result.metafile) {
|
|
375
|
+
const entry = path.parse(String(config.entrypoints[0] ?? label)).name;
|
|
376
|
+
await Bun.write(path.join(metafileDir, `${entry}.json`), JSON.stringify(result.metafile));
|
|
377
|
+
}
|
|
368
378
|
return result;
|
|
369
379
|
}
|
|
370
380
|
|
package/biome.base.json
CHANGED
|
@@ -85,16 +85,6 @@
|
|
|
85
85
|
"overrides": [
|
|
86
86
|
{
|
|
87
87
|
"includes": [
|
|
88
|
-
"**/env.client.local.ts",
|
|
89
|
-
"**/env.client.debug.ts",
|
|
90
|
-
"**/env.client.develop.ts",
|
|
91
|
-
"**/env.client.main.ts",
|
|
92
|
-
"**/env.client.testing.ts",
|
|
93
|
-
"**/env.server.local.ts",
|
|
94
|
-
"**/env.server.develop.ts",
|
|
95
|
-
"**/env.server.main.ts",
|
|
96
|
-
"**/env.server.testing.ts",
|
|
97
|
-
"**/env.server.type.ts",
|
|
98
88
|
"apps/*/lib/cnst.ts",
|
|
99
89
|
"apps/*/lib/dict.ts",
|
|
100
90
|
"apps/*/lib/db.ts",
|
package/cloud/configLock.ts
CHANGED
|
@@ -36,7 +36,8 @@ export class ConfigLock {
|
|
|
36
36
|
|
|
37
37
|
static async #take(lockPath: string) {
|
|
38
38
|
const handle = await open(lockPath, "wx", 0o600).catch((error: NodeJS.ErrnoException) => {
|
|
39
|
-
|
|
39
|
+
// Windows answers EPERM, not EEXIST, while a released lock file is still pending delete behind an open handle.
|
|
40
|
+
if (error.code === "EEXIST" || (process.platform === "win32" && error.code === "EPERM")) return null;
|
|
40
41
|
throw error;
|
|
41
42
|
});
|
|
42
43
|
if (!handle) return false;
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { trimTrailingSlashes } from "akanjs/common";
|
|
2
3
|
import dayjs from "dayjs";
|
|
3
4
|
import { FileSys } from "../fileSys";
|
|
4
5
|
import { ConfigLock } from "./configLock";
|
|
@@ -60,8 +61,8 @@ export class GlobalConfig {
|
|
|
60
61
|
static #normalizeHost(host: string) {
|
|
61
62
|
const url = URL.canParse(host) ? new URL(host) : null;
|
|
62
63
|
return url && (url.protocol === "https:" || url.protocol === "http:")
|
|
63
|
-
? `${url.origin}${url.pathname
|
|
64
|
-
: host
|
|
64
|
+
? `${url.origin}${trimTrailingSlashes(url.pathname)}`
|
|
65
|
+
: trimTrailingSlashes(host);
|
|
65
66
|
}
|
|
66
67
|
static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
|
|
67
68
|
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type
|
|
3
|
+
import { type CodeAgentProfile, trimTrailingSlashes } from "akanjs/common";
|
|
4
4
|
import { akanCodeDefaultModel } from "./akanCodeModel";
|
|
5
5
|
|
|
6
6
|
interface PreparedRequest {
|
|
@@ -27,7 +27,7 @@ export class CodeAgentProxy {
|
|
|
27
27
|
readonly #token?: string;
|
|
28
28
|
|
|
29
29
|
constructor({ baseUrl, providers, tokenFile, token }: CodeAgentProxyOptions) {
|
|
30
|
-
this.baseUrl = baseUrl
|
|
30
|
+
this.baseUrl = trimTrailingSlashes(baseUrl);
|
|
31
31
|
this.providers = providers;
|
|
32
32
|
this.#tokenFile = tokenFile;
|
|
33
33
|
this.#token = token;
|
|
@@ -8,7 +8,7 @@ export interface CodeAgentModelRef {
|
|
|
8
8
|
id: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** DeepSeek's million-token window is what makes
|
|
11
|
+
/** DeepSeek's million-token window is what makes the always-loaded `AGENTS.md` affordable on every turn. */
|
|
12
12
|
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
|
|
13
13
|
|
|
14
14
|
// Declared text-only by the catalogue, yet verified against the provider to see images.
|
|
@@ -73,7 +73,9 @@ describe("workspace context files", () => {
|
|
|
73
73
|
test("a rule with no frontmatter at all is unconditional, and so is .cursorrules", () => {
|
|
74
74
|
const root = workspace({ ".cursor/rules/plain.mdc": "just do it", ".cursorrules": "the legacy file" });
|
|
75
75
|
const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
|
|
76
|
-
expect(namesOf(files, root).sort(
|
|
76
|
+
expect(namesOf(files, root).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
|
|
77
|
+
[".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort(),
|
|
78
|
+
);
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
test("rules nested under the rules directory are found, in a stable order", () => {
|
|
@@ -132,14 +132,14 @@ export class WebToolPack {
|
|
|
132
132
|
/** Enough to read a documentation page; a full HTML parser is not worth a dependency for that. */
|
|
133
133
|
static #readable(html: string) {
|
|
134
134
|
return html
|
|
135
|
-
.replace(/<script[\s\S]*?<\/script
|
|
136
|
-
.replace(/<style[\s\S]*?<\/style
|
|
137
|
-
.replace(/<[
|
|
135
|
+
.replace(/<script[\s\S]*?<\/script[^<>]*>/gi, " ")
|
|
136
|
+
.replace(/<style[\s\S]*?<\/style[^<>]*>/gi, " ")
|
|
137
|
+
.replace(/<[^<>]+>/g, " ")
|
|
138
138
|
.replace(/ /g, " ")
|
|
139
|
-
.replace(/&/g, "&")
|
|
140
139
|
.replace(/</g, "<")
|
|
141
140
|
.replace(/>/g, ">")
|
|
142
141
|
.replace(/"/g, '"')
|
|
142
|
+
.replace(/&/g, "&")
|
|
143
143
|
.replace(/[ \t]+/g, " ")
|
|
144
144
|
.replace(/\n{3,}/g, "\n\n")
|
|
145
145
|
.trim();
|
|
@@ -247,22 +247,26 @@ describe("PreviewView", () => {
|
|
|
247
247
|
expect(typeof PreviewView.available).toBe("boolean");
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
-
test.skipIf(!PreviewView.available)(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
250
|
+
test.skipIf(!PreviewView.available)(
|
|
251
|
+
"finds an empty body and a console error on a real page",
|
|
252
|
+
async () => {
|
|
253
|
+
const server = Bun.serve({
|
|
254
|
+
port: 0,
|
|
255
|
+
fetch: () =>
|
|
256
|
+
new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
|
|
257
|
+
headers: { "content-type": "text/html" },
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
try {
|
|
261
|
+
const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
|
|
262
|
+
expect(probe.pageErrors.join(" ")).toContain("rendered empty");
|
|
263
|
+
expect(probe.consoleErrors.join(" ")).toContain("boom");
|
|
264
|
+
} finally {
|
|
265
|
+
server.stop(true);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
30_000,
|
|
269
|
+
);
|
|
266
270
|
});
|
|
267
271
|
|
|
268
272
|
describe("SubagentPool", () => {
|
|
@@ -186,20 +186,21 @@ const assignCommandContext = (context: CommandContext, argMeta: ArgMeta | Intern
|
|
|
186
186
|
else context.values[argMeta.type.toLowerCase()] = value;
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
//? Not `.env`, which a fresh clone lacks; an app or a package folder has a package.json and tsconfig.json of its own.
|
|
189
190
|
const assertCurrentDirectoryIsWorkspaceRoot = async () => {
|
|
190
191
|
const cwd = process.cwd();
|
|
191
|
-
const [hasPackageJson, hasTsConfig,
|
|
192
|
+
const [hasPackageJson, hasTsConfig, ...hasMemberDirs] = await Promise.all([
|
|
192
193
|
FileSys.fileExists(`${cwd}/package.json`),
|
|
193
194
|
FileSys.fileExists(`${cwd}/tsconfig.json`),
|
|
194
|
-
FileSys.
|
|
195
|
+
...["apps", "libs", "pkgs"].map((dir) => FileSys.dirExists(`${cwd}/${dir}`)),
|
|
195
196
|
]);
|
|
196
|
-
if (hasPackageJson && hasTsConfig &&
|
|
197
|
+
if (hasPackageJson && hasTsConfig && hasMemberDirs.some(Boolean)) return;
|
|
197
198
|
|
|
198
199
|
throw new Error(
|
|
199
200
|
[
|
|
200
201
|
"Akan CLI commands must be run from the workspace root.",
|
|
201
202
|
`Current directory: ${cwd}`,
|
|
202
|
-
"Move to the directory that contains package.json, tsconfig.json, and
|
|
203
|
+
"Move to the directory that contains package.json, tsconfig.json, and an apps/, libs/ or pkgs/ folder, then run the command again.",
|
|
203
204
|
].join("\n"),
|
|
204
205
|
);
|
|
205
206
|
};
|
package/createTunnel.test.ts
CHANGED
|
@@ -48,4 +48,18 @@ describe("createTunnel", () => {
|
|
|
48
48
|
expect(failure.message).toContain(reason.message);
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("refuses to dial a cluster host built on the local default serve domain", async () => {
|
|
53
|
+
const { app } = track(await createTempApp("demo"));
|
|
54
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
55
|
+
const connect = spyOn(Client.prototype, "connect");
|
|
56
|
+
try {
|
|
57
|
+
await expect(createTunnel("redis", { app, environment: "debug" })).rejects.toThrow(
|
|
58
|
+
'The redis tunnel for the "debug" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
59
|
+
);
|
|
60
|
+
expect(connect).not.toHaveBeenCalled();
|
|
61
|
+
} finally {
|
|
62
|
+
connect.mockRestore();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
51
65
|
});
|
package/createTunnel.ts
CHANGED
|
@@ -10,7 +10,9 @@ export const createTunnel = async (
|
|
|
10
10
|
service: "redis" | "postgres",
|
|
11
11
|
{ app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
|
|
12
12
|
) => {
|
|
13
|
-
const
|
|
13
|
+
const baseDevEnv = WorkspaceExecutor.getBaseDevEnv();
|
|
14
|
+
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, `The ${service} tunnel`);
|
|
15
|
+
const { serveDomain, repoName } = baseDevEnv;
|
|
14
16
|
const host = `${app.name}-${environment}.${serveDomain}`;
|
|
15
17
|
const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
|
|
16
18
|
const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
|
package/dependencyScanner.ts
CHANGED
|
@@ -107,7 +107,7 @@ export class TypeScriptDependencyScanner {
|
|
|
107
107
|
return {
|
|
108
108
|
npmDeps: [...runtimeDeps].sort(),
|
|
109
109
|
npmDevDeps: [...devDeps].sort(),
|
|
110
|
-
missingDeps: missingDeps.sort(),
|
|
110
|
+
missingDeps: missingDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
|
package/devkitUtils.test.ts
CHANGED
|
@@ -76,7 +76,7 @@ describe("TypeScriptDependencyScanner", () => {
|
|
|
76
76
|
});
|
|
77
77
|
expect(deps.pkgDeps).toEqual(["akanjs/tool"]);
|
|
78
78
|
expect(deps.libDeps).toEqual(["shared"]);
|
|
79
|
-
expect(deps.npmDeps.sort()).toEqual(["lodash", "react"]);
|
|
79
|
+
expect(deps.npmDeps.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(["lodash", "react"]);
|
|
80
80
|
expect(deps.npmDevDeps).toEqual(["typescript"]);
|
|
81
81
|
|
|
82
82
|
const graph = scanner.generateDependencyGraph();
|
package/executors.test.ts
CHANGED
|
@@ -335,7 +335,7 @@ describe("Executor filesystem helpers", () => {
|
|
|
335
335
|
});
|
|
336
336
|
|
|
337
337
|
describe("Workspace and app executor environment contracts", () => {
|
|
338
|
-
test("reads base development environment
|
|
338
|
+
test("reads base development environment", () => {
|
|
339
339
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
340
340
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
341
341
|
process.env.AKAN_PUBLIC_ENV = "local";
|
|
@@ -351,10 +351,53 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
351
351
|
env: "local",
|
|
352
352
|
portOffset: 10,
|
|
353
353
|
workspaceId: undefined,
|
|
354
|
+
defaulted: [],
|
|
354
355
|
});
|
|
356
|
+
});
|
|
355
357
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
+
test("unset, the repo name is the origin remote's and the serve domain localhost, both marked defaulted", async () => {
|
|
359
|
+
const root = await makeTempRoot();
|
|
360
|
+
for (const args of [
|
|
361
|
+
["init", "-q"],
|
|
362
|
+
["remote", "add", "origin", "git@github.com:akan-team/fixture-repo.git"],
|
|
363
|
+
])
|
|
364
|
+
expect(Bun.spawnSync(["git", ...args], { cwd: root }).exitCode).toBe(0);
|
|
365
|
+
for (const key of ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_ENV", "AKAN_WORKSPACE_ROOT"])
|
|
366
|
+
delete process.env[key];
|
|
367
|
+
|
|
368
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
|
|
369
|
+
repoName: "fixture-repo",
|
|
370
|
+
serveDomain: "localhost",
|
|
371
|
+
env: "debug",
|
|
372
|
+
defaulted: ["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
376
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
|
|
377
|
+
repoName: "fixture-repo",
|
|
378
|
+
serveDomain: "example.com",
|
|
379
|
+
defaulted: ["AKAN_PUBLIC_REPO_NAME"],
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
|
|
384
|
+
const baseDevEnv = {
|
|
385
|
+
repoName: "fixture-repo",
|
|
386
|
+
serveDomain: "localhost",
|
|
387
|
+
env: "debug" as const,
|
|
388
|
+
portOffset: 0,
|
|
389
|
+
workspaceRoot: undefined,
|
|
390
|
+
};
|
|
391
|
+
const defaulted = { ...baseDevEnv, defaulted: ["AKAN_PUBLIC_SERVE_DOMAIN" as const] };
|
|
392
|
+
|
|
393
|
+
expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "main", "akan build")).toThrow(
|
|
394
|
+
'akan build for the "main" environment needs AKAN_PUBLIC_SERVE_DOMAIN (would be "localhost") set',
|
|
395
|
+
);
|
|
396
|
+
expect(() => WorkspaceExecutor.assertDeployableEnv(defaulted, "local", "akan build")).not.toThrow();
|
|
397
|
+
expect(() =>
|
|
398
|
+
WorkspaceExecutor.assertDeployableEnv({ ...baseDevEnv, defaulted: [] }, "main", "akan build"),
|
|
399
|
+
).not.toThrow();
|
|
400
|
+
expect(() => WorkspaceExecutor.assertDeployableEnv(baseDevEnv, "main", "akan build")).not.toThrow();
|
|
358
401
|
});
|
|
359
402
|
|
|
360
403
|
test("reads base development environment from an explicit env file", async () => {
|
|
@@ -382,6 +425,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
382
425
|
env: "develop",
|
|
383
426
|
portOffset: 7,
|
|
384
427
|
workspaceId: undefined,
|
|
428
|
+
defaulted: [],
|
|
385
429
|
});
|
|
386
430
|
});
|
|
387
431
|
|
|
@@ -440,6 +484,21 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
440
484
|
expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
|
|
441
485
|
});
|
|
442
486
|
|
|
487
|
+
test("the app command env hands the dev server the local defaults that stood in for unset values", async () => {
|
|
488
|
+
const root = await makeTempRoot();
|
|
489
|
+
delete process.env.AKAN_PUBLIC_REPO_NAME;
|
|
490
|
+
delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
491
|
+
process.env.AKAN_WORKSPACE_ROOT = root;
|
|
492
|
+
const app = AppExecutor.from(new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }), "defaults");
|
|
493
|
+
|
|
494
|
+
const env = app.getCommandEnv();
|
|
495
|
+
expect(env.AKAN_PUBLIC_SERVE_DOMAIN).toBe("localhost");
|
|
496
|
+
expect(env.AKAN_PUBLIC_REPO_NAME).toBe(WorkspaceExecutor.getBaseDevEnv().repoName);
|
|
497
|
+
|
|
498
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
499
|
+
expect(app.getCommandEnv().AKAN_PUBLIC_SERVE_DOMAIN).toBe("example.com");
|
|
500
|
+
});
|
|
501
|
+
|
|
443
502
|
test("akan start clears the dev output and keeps the bin downloads and a native folder from before dist/native", async () => {
|
|
444
503
|
const root = await makeTempRoot();
|
|
445
504
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
package/executors.ts
CHANGED
|
@@ -54,7 +54,7 @@ import { Spinner } from "./spinner";
|
|
|
54
54
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
55
55
|
import type { TypeChecker } from "./typeChecker";
|
|
56
56
|
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
57
|
-
import type { FileContent, PackageJson, TsConfigJson } from "./types";
|
|
57
|
+
import type { BaseDevEnv, FileContent, PackageJson, TsConfigJson } from "./types";
|
|
58
58
|
import { barrelFacetDirs } from "./workspaceLayout";
|
|
59
59
|
|
|
60
60
|
export interface PageRoot {
|
|
@@ -662,6 +662,7 @@ export class WorkspaceExecutor extends Executor {
|
|
|
662
662
|
}
|
|
663
663
|
/** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
|
|
664
664
|
static publicEnvPin: Record<string, string> | null = null;
|
|
665
|
+
static readonly localServeDomain = "localhost";
|
|
665
666
|
static getBaseDevEnv(envPath?: string) {
|
|
666
667
|
// Bun auto-loads .env, so we use process.env directly
|
|
667
668
|
const sourceEnv = {
|
|
@@ -673,11 +674,11 @@ export class WorkspaceExecutor extends Executor {
|
|
|
673
674
|
const workspaceRoot = sourceEnv.AKAN_WORKSPACE_ROOT;
|
|
674
675
|
const workspaceId = sourceEnv.AKAN_WORKSPACE_ID;
|
|
675
676
|
|
|
676
|
-
const repoName =
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN;
|
|
680
|
-
|
|
677
|
+
const repoName =
|
|
678
|
+
sourceEnv.AKAN_PUBLIC_REPO_NAME ||
|
|
679
|
+
resolveRepoName(envPath ? path.dirname(envPath) : (workspaceRoot ?? process.cwd()));
|
|
680
|
+
const serveDomain = sourceEnv.AKAN_PUBLIC_SERVE_DOMAIN || WorkspaceExecutor.localServeDomain;
|
|
681
|
+
const defaulted = (["AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const).filter((key) => !sourceEnv[key]);
|
|
681
682
|
|
|
682
683
|
const portOffset = parseInt(sourceEnv.PORT_OFFSET ?? "0");
|
|
683
684
|
|
|
@@ -697,8 +698,20 @@ export class WorkspaceExecutor extends Executor {
|
|
|
697
698
|
env,
|
|
698
699
|
portOffset,
|
|
699
700
|
workspaceId,
|
|
701
|
+
defaulted,
|
|
700
702
|
};
|
|
701
703
|
}
|
|
704
|
+
//? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
|
|
705
|
+
static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
|
|
706
|
+
const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
|
|
707
|
+
if (!defaulted.length) return;
|
|
708
|
+
const values = { AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName, AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain };
|
|
709
|
+
const standIns = defaulted.map((key) => `${key} (would be "${values[key]}")`).join(" and ");
|
|
710
|
+
throw new Error(
|
|
711
|
+
`${purpose} for the "${environment}" environment needs ${standIns} set, not a local default. ` +
|
|
712
|
+
`Set ${defaulted.length > 1 ? "them" : "it"} in the workspace .env or the shell, or use AKAN_PUBLIC_ENV=local.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
702
715
|
getWorkspaceId<AllowEmpty extends boolean = false>({
|
|
703
716
|
allowEmpty,
|
|
704
717
|
}: {
|
|
@@ -1159,10 +1172,12 @@ export class AppExecutor extends SysExecutor {
|
|
|
1159
1172
|
return basePort + appIndex + portOffset;
|
|
1160
1173
|
}
|
|
1161
1174
|
getCommandEnv(env: Record<string, string> = {}): Record<string, string> {
|
|
1162
|
-
const { portOffset } = WorkspaceExecutor.getBaseDevEnv();
|
|
1175
|
+
const { portOffset, repoName, serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
1163
1176
|
const PORT = (8282 + portOffset).toString();
|
|
1164
1177
|
return {
|
|
1165
1178
|
...process.env,
|
|
1179
|
+
AKAN_PUBLIC_REPO_NAME: repoName,
|
|
1180
|
+
AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
|
|
1166
1181
|
AKAN_PUBLIC_APP_NAME: this.name,
|
|
1167
1182
|
AKAN_WORKSPACE_ROOT: this.workspace.workspaceRoot,
|
|
1168
1183
|
NODE_NO_WARNINGS: "1",
|
|
@@ -1323,7 +1338,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1323
1338
|
pageKeys.push(key);
|
|
1324
1339
|
}
|
|
1325
1340
|
}
|
|
1326
|
-
pageKeys.sort();
|
|
1341
|
+
pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1327
1342
|
this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
|
|
1328
1343
|
return this.#pageKeys;
|
|
1329
1344
|
}
|
|
@@ -1581,7 +1596,9 @@ export class PkgExecutor extends Executor {
|
|
|
1581
1596
|
devDependencies: string[] = [],
|
|
1582
1597
|
): Promise<Pick<PackageJson, "dependencies" | "devDependencies">> {
|
|
1583
1598
|
const dependencyNames = [...new Set(dependencies)].sort();
|
|
1584
|
-
const devDependencyNames = [...new Set(devDependencies)]
|
|
1599
|
+
const devDependencyNames = [...new Set(devDependencies)]
|
|
1600
|
+
.filter((dep) => !dependencyNames.includes(dep))
|
|
1601
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1585
1602
|
const dependencyVersions = new Map<string, string>();
|
|
1586
1603
|
const missingDeps: string[] = [];
|
|
1587
1604
|
for (const dep of [...dependencyNames, ...devDependencyNames]) {
|
|
@@ -253,7 +253,9 @@ describe("AutoImportSync.syncForBatch", () => {
|
|
|
253
253
|
|
|
254
254
|
const result = await sync.syncForBatch([uiFile, pageFile, webkitFile]);
|
|
255
255
|
expect(result.errors).toEqual([]);
|
|
256
|
-
expect(result.changedFiles.sort(
|
|
256
|
+
expect(result.changedFiles.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
|
|
257
|
+
[pageFile, webkitFile, uiFile].sort(),
|
|
258
|
+
);
|
|
257
259
|
expect(await readFile(uiFile, "utf8")).toContain(`import { st } from "@apps/akan/client";`);
|
|
258
260
|
expect(await readFile(pageFile, "utf8")).toContain(`import { cnst } from "@apps/akan/client";`);
|
|
259
261
|
expect(await readFile(webkitFile, "utf8")).toContain(`import { fetch } from "@libs/shared/client";`);
|
|
@@ -6,7 +6,7 @@ import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
|
|
|
6
6
|
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
7
7
|
import type { AkanConfig, ClientEntryDiscovery, ScannedImport } from "./clientBuildTypes";
|
|
8
8
|
|
|
9
|
-
const USE_CLIENT_RE = /^\s*(?:\/\*[
|
|
9
|
+
const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
|
|
10
10
|
const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
|
|
11
11
|
const AKANJS_NODE_MODULE_RE = /[\\/]node_modules[\\/]akanjs[\\/]/;
|
|
12
12
|
// Bun's scanImports also surfaces CSS and asset imports, none of which can be a client component.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { tempDirs, writeText } from "../testHelpers";
|
|
4
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
4
5
|
|
|
5
6
|
const makeTempRoot = tempDirs("akan-csr-artifact-");
|
|
6
7
|
|
|
@@ -31,4 +32,33 @@ console.info(await result.outputs[0].text());
|
|
|
31
32
|
expect(stdout).toContain("envmark:debug");
|
|
32
33
|
expect(stdout).not.toContain("envmark:local");
|
|
33
34
|
});
|
|
35
|
+
|
|
36
|
+
//? An OTA release is keyed on the bundle's hash, so a no-op build from another checkout must not stage one.
|
|
37
|
+
test("bundles the same app identically from two checkout paths", async () => {
|
|
38
|
+
const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: "main" }) };
|
|
39
|
+
const buildFrom = async (root: string) => {
|
|
40
|
+
await writeText(
|
|
41
|
+
path.join(root, "node_modules/wasmdep/package.json"),
|
|
42
|
+
'{ "name": "wasmdep", "main": "index.js" }\n',
|
|
43
|
+
);
|
|
44
|
+
await writeText(
|
|
45
|
+
path.join(root, "node_modules/wasmdep/index.js"),
|
|
46
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: the fixture module's own source holds a template literal
|
|
47
|
+
'var isNode = typeof process == "object" && !!process.versions?.node;\nmodule.exports = isNode ? `${__dirname}/` : "";\n',
|
|
48
|
+
);
|
|
49
|
+
await writeText(path.join(root, "entry.ts"), 'import dir from "wasmdep";\nconsole.info(dir);\n');
|
|
50
|
+
const result = await Bun.build({
|
|
51
|
+
...CsrArtifactBuilder.bundleOptions(app as never, "build"),
|
|
52
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
53
|
+
});
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
return await result.outputs[0]?.text();
|
|
56
|
+
};
|
|
57
|
+
const [first, second] = [await makeTempRoot(), await makeTempRoot()];
|
|
58
|
+
|
|
59
|
+
const [firstOutput, secondOutput] = [await buildFrom(first), await buildFrom(second)];
|
|
60
|
+
|
|
61
|
+
expect(firstOutput).toBe(secondOutput);
|
|
62
|
+
expect(firstOutput).not.toContain(path.basename(first));
|
|
63
|
+
});
|
|
34
64
|
});
|