@akanjs/devkit 3.0.2 → 3.1.0-rc.0
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 +82 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +195 -0
- package/cloud/updatePackUpload.ts +115 -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 +1 -0
- package/mobile/nativeApp.ts +96 -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
package/qualityScanner.test.ts
CHANGED
|
@@ -233,17 +233,23 @@ describe("AkanQualityScanner layout rules", () => {
|
|
|
233
233
|
expect(warnings[0]?.fix).toContain("akan sync");
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
test("
|
|
236
|
+
test("flags a test at the lib root and points it at its module folder or srvkit", async () => {
|
|
237
237
|
const root = await makeWorkspace({
|
|
238
238
|
"libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
|
|
239
239
|
"libs/demo/lib/user.signal.test.ts": "export const test = 1;\n",
|
|
240
|
+
"libs/demo/lib/user/user.signal.test.ts": "export const test = 1;\n",
|
|
240
241
|
"libs/demo/lib/helper.ts": "export const helper = 1;\n",
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-facet-file");
|
|
244
245
|
|
|
245
|
-
expect(warnings).
|
|
246
|
-
|
|
246
|
+
expect(warnings.map((warning) => warning.file).sort()).toEqual([
|
|
247
|
+
"libs/demo/lib/helper.ts",
|
|
248
|
+
"libs/demo/lib/user.signal.test.ts",
|
|
249
|
+
]);
|
|
250
|
+
const rootTest = warnings.find((warning) => warning.file === "libs/demo/lib/user.signal.test.ts");
|
|
251
|
+
expect(rootTest?.message).toContain("lib/<model>/<model>.signal.test.ts");
|
|
252
|
+
expect(rootTest?.message).toContain("srvkit/");
|
|
247
253
|
});
|
|
248
254
|
});
|
|
249
255
|
|
package/qualityScanner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from
|
|
|
10
10
|
import {
|
|
11
11
|
facetIndexShadowOf,
|
|
12
12
|
isAllowedLibFacetRootFile,
|
|
13
|
+
libFacetRootHintOf,
|
|
13
14
|
rootAllowedDirs,
|
|
14
15
|
rootAllowedFiles,
|
|
15
16
|
rootEntryHintOf,
|
|
@@ -145,7 +146,7 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
145
146
|
"akan.layout.lib-root-folder":
|
|
146
147
|
"Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
|
|
147
148
|
"akan.layout.lib-facet-file":
|
|
148
|
-
"Move the file into a domain module folder under lib
|
|
149
|
+
"Move the file into a domain module folder under lib/ (a test beside the file it covers, or into srvkit/ when it drives several modules); keep the lib facet root limited to generated support facets.",
|
|
149
150
|
"akan.layout.facet-index-shadow":
|
|
150
151
|
"Delete the hand-written index file and run akan sync; move a namespace it builds into <facet>/<Folder>/index.tsx.",
|
|
151
152
|
"akan.layout.module-ui-file":
|
|
@@ -460,7 +461,9 @@ export class AkanQualityScanner {
|
|
|
460
461
|
"akan.layout.lib-facet-file",
|
|
461
462
|
"layout",
|
|
462
463
|
sourceFile.file,
|
|
463
|
-
`Unexpected lib facet root file "${libFacetFile}".
|
|
464
|
+
`Unexpected lib facet root file "${libFacetFile}". ${
|
|
465
|
+
libFacetRootHintOf(libFacetFile) ?? "Keep direct lib/ root files limited to generated support facets."
|
|
466
|
+
}`,
|
|
464
467
|
),
|
|
465
468
|
);
|
|
466
469
|
|
package/scanInfo.ts
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
barrelFacetDirs,
|
|
15
15
|
facetIndexShadowOf,
|
|
16
16
|
isAllowedLibFacetRootFile,
|
|
17
|
+
libFacetRootHintOf,
|
|
18
|
+
moduleTestHint,
|
|
19
|
+
moduleTestTargetOf,
|
|
17
20
|
rootAllowedDirs,
|
|
18
21
|
rootAllowedFiles,
|
|
19
22
|
rootEntryHintOf,
|
|
@@ -93,7 +96,7 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
|
|
|
93
96
|
|
|
94
97
|
for (const filename of libRoot.files)
|
|
95
98
|
if (!isAllowedLibFacetRootFile(filename))
|
|
96
|
-
addViolation(path.join("lib", filename), "unsupported lib facet root file");
|
|
99
|
+
addViolation(path.join("lib", filename), libFacetRootHintOf(filename) ?? "unsupported lib facet root file");
|
|
97
100
|
for (const dirname of libRoot.dirs)
|
|
98
101
|
if (dirname.startsWith("__") && !internalLibDirs.has(dirname))
|
|
99
102
|
addViolation(path.join("lib", dirname), "unsupported internal lib folder");
|
|
@@ -133,12 +136,14 @@ async function validateModuleFiles(
|
|
|
133
136
|
|
|
134
137
|
for (const filename of files) {
|
|
135
138
|
const scanPath = getScanPath(exec, path.join(modulePath, filename));
|
|
136
|
-
if (filename === "index.ts" || filename === "index.tsx"
|
|
139
|
+
if (filename === "index.ts" || filename === "index.tsx") continue;
|
|
137
140
|
if (filename === `${moduleName}.abstract.md`) continue;
|
|
138
|
-
const
|
|
139
|
-
const
|
|
141
|
+
const testTarget = moduleTestTargetOf(filename);
|
|
142
|
+
const sourceName = testTarget ?? filename;
|
|
143
|
+
const uiMatch = sourceName.match(/^([A-Z][A-Za-z0-9]+)\.([A-Z][A-Za-z0-9]*)\.tsx$/);
|
|
144
|
+
const match = uiMatch ?? sourceName.match(/^([a-z][a-zA-Z0-9]*)\.([a-z][a-z0-9]*)\.ts$/);
|
|
140
145
|
if (!match) {
|
|
141
|
-
violations.push(`${scanPath}: unsupported module file`);
|
|
146
|
+
violations.push(`${scanPath}: ${testTarget ? moduleTestHint : "unsupported module file"}`);
|
|
142
147
|
continue;
|
|
143
148
|
}
|
|
144
149
|
const [, fileModuleName, fileType] = match;
|
|
@@ -146,7 +151,9 @@ async function validateModuleFiles(
|
|
|
146
151
|
if (fileModuleName !== expectedName)
|
|
147
152
|
violations.push(`${scanPath}: module name mismatch: expected '${expectedName}', got '${fileModuleName}'`);
|
|
148
153
|
if (!(uiMatch ? moduleUiFileTypes : moduleNonUiFileTypes)[kind].has(fileType))
|
|
149
|
-
violations.push(
|
|
154
|
+
violations.push(
|
|
155
|
+
`${scanPath}: ${testTarget ? moduleTestHint : `unsupported ${kind}${uiMatch ? " UI" : ""} file`}`,
|
|
156
|
+
);
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
159
|
|
package/subspace.test.ts
CHANGED
|
@@ -3,7 +3,13 @@ import { mkdir, readdir, rm } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Executor, WorkspaceExecutor } from "./executors";
|
|
5
5
|
import { FileSys } from "./fileSys";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
formatSubspaceDiff,
|
|
8
|
+
formatSubspacePlan,
|
|
9
|
+
formatSubspacePushResults,
|
|
10
|
+
formatSubspaceStatuses,
|
|
11
|
+
Subspace,
|
|
12
|
+
} from "./subspace";
|
|
7
13
|
import { SubspaceConfig } from "./subspaceConfig";
|
|
8
14
|
import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
|
|
9
15
|
|
|
@@ -88,9 +94,10 @@ const makeMirror = async (servedApp: string, privateApp: string, libName: string
|
|
|
88
94
|
);
|
|
89
95
|
await write(path.join(wsRoot, "benchmarks/bench.ts"), "export {};\n");
|
|
90
96
|
await write(
|
|
91
|
-
path.join(wsRoot, "akan.
|
|
92
|
-
`export default { pushableBranches: ["develop"], exclude: ["benchmarks"], subspaces: [{ name: "acme", repo: ${JSON.stringify(bareRoot)}, apps: ["${servedApp}"] }] };\n`,
|
|
97
|
+
path.join(wsRoot, "akan.config.ts"),
|
|
98
|
+
`export default { subspace: { pushableBranches: ["develop"], exclude: ["benchmarks"], subspaces: [{ name: "acme", repo: ${JSON.stringify(bareRoot)}, apps: ["${servedApp}"] }] } };\n`,
|
|
93
99
|
);
|
|
100
|
+
await write(path.join(wsRoot, "Jenkinsfile"), "pipeline { agent any }\n");
|
|
94
101
|
|
|
95
102
|
await makeSys(wsRoot, "apps", servedApp, {
|
|
96
103
|
"lib/task/task.constant.ts": [
|
|
@@ -152,7 +159,8 @@ describe("Subspace push", () => {
|
|
|
152
159
|
expect(await FileSys.fileExists(path.join(subspaceRoot, "libs/kit/common/helper.ts"))).toBe(true);
|
|
153
160
|
expect(await FileSys.dirExists(path.join(subspaceRoot, "apps/private"))).toBe(false);
|
|
154
161
|
expect(entries).not.toContain("benchmarks");
|
|
155
|
-
expect(entries).not.toContain("akan.
|
|
162
|
+
expect(entries).not.toContain("akan.config.ts");
|
|
163
|
+
expect(entries).not.toContain("Jenkinsfile");
|
|
156
164
|
expect(await FileSys.fileExists(path.join(subspaceRoot, "apps/served/lib/cnst.ts"))).toBe(false);
|
|
157
165
|
expect(await FileSys.fileExists(path.join(subspaceRoot, "apps/served/env/env.server.local.ts"))).toBe(false);
|
|
158
166
|
expect(await FileSys.fileExists(path.join(subspaceRoot, "apps/served/env/env.server.ts"))).toBe(true);
|
|
@@ -216,6 +224,63 @@ describe("Subspace push", () => {
|
|
|
216
224
|
expect((await subspace.push("develop", { verify: false })).outcome).toBe("skipped");
|
|
217
225
|
});
|
|
218
226
|
|
|
227
|
+
test("skips a push after an unrelated workspace commit, and re-stamps a library only when its content moves", async () => {
|
|
228
|
+
const { wsRoot, workRoot, bareRoot, subspace } = await makeMirror("calm-served", "calm-private", "calm-kit");
|
|
229
|
+
expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
|
|
230
|
+
const stampOf = async (root: string) =>
|
|
231
|
+
(
|
|
232
|
+
(await FileSys.readJson(path.join(root, "libs/calm-kit/package.json"))) as {
|
|
233
|
+
akan: { source: { sha: string; hash: string } };
|
|
234
|
+
}
|
|
235
|
+
).akan.source;
|
|
236
|
+
const first = await stampOf(await cloneSubspace(workRoot, bareRoot, "calm-first"));
|
|
237
|
+
|
|
238
|
+
await write(path.join(wsRoot, "apps/calm-private/lib/task/task.constant.ts"), "export const task = 2;\n");
|
|
239
|
+
await git(wsRoot, ["add", "-A"]);
|
|
240
|
+
await git(wsRoot, ["commit", "--quiet", "-m", "outside the slice"]);
|
|
241
|
+
expect((await subspace.push("develop", { verify: false })).outcome).toBe("skipped");
|
|
242
|
+
|
|
243
|
+
await write(path.join(wsRoot, "libs/calm-kit/common/helper.ts"), "export const helper = 2;\n");
|
|
244
|
+
await git(wsRoot, ["add", "-A"]);
|
|
245
|
+
await git(wsRoot, ["commit", "--quiet", "-m", "inside the slice"]);
|
|
246
|
+
expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
|
|
247
|
+
const second = await stampOf(await cloneSubspace(workRoot, bareRoot, "calm-second"));
|
|
248
|
+
expect(second.hash).not.toBe(first.hash);
|
|
249
|
+
expect(second.sha).not.toBe(first.sha);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("keeps the subspace's own root config and drops a Jenkinsfile an earlier push left there", async () => {
|
|
253
|
+
const { workRoot, bareRoot, subspace } = await makeMirror("own-served", "own-private", "own-kit");
|
|
254
|
+
const customer = await cloneSubspace(workRoot, bareRoot, "own-customer");
|
|
255
|
+
await write(path.join(customer, "akan.config.ts"), "export default { pipeline: {} };\n");
|
|
256
|
+
await git(customer, ["add", "-A"]);
|
|
257
|
+
await git(customer, ["commit", "--quiet", "-m", "our own global stages"]);
|
|
258
|
+
await write(path.join(customer, "Jenkinsfile"), "pipeline { agent none }\n");
|
|
259
|
+
await git(customer, ["add", "-A"]);
|
|
260
|
+
await git(customer, ["commit", "--quiet", "-m", `${Subspace.syncSubjectPrefix}workspace@0000000`]);
|
|
261
|
+
await git(customer, ["push", "--quiet", "origin", "develop"]);
|
|
262
|
+
|
|
263
|
+
expect((await subspace.push("develop", { verify: false })).outcome).toBe("pushed");
|
|
264
|
+
const subspaceRoot = await cloneSubspace(workRoot, bareRoot, "own-check");
|
|
265
|
+
expect(await FileSys.readText(path.join(subspaceRoot, "akan.config.ts"))).toContain(
|
|
266
|
+
"export default { pipeline: {} };",
|
|
267
|
+
);
|
|
268
|
+
expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("refuses a lock that resolves from a registry only this machine reaches", async () => {
|
|
272
|
+
const { wsRoot, subspace } = await makeMirror("lock-served", "lock-private", "lock-kit");
|
|
273
|
+
await write(
|
|
274
|
+
path.join(wsRoot, "bun.lock"),
|
|
275
|
+
'{ "packages": { "lodash": ["lodash@4.0.0", "http://127.0.0.1:4873/lodash/-/lodash-4.0.0.tgz"] } }\n',
|
|
276
|
+
);
|
|
277
|
+
await git(wsRoot, ["add", "-f", "bun.lock"]);
|
|
278
|
+
await git(wsRoot, ["commit", "--quiet", "-m", "a verdaccio lock"]);
|
|
279
|
+
const result = (await subspace.push("develop", { verify: false })) as { outcome: string; reason?: string };
|
|
280
|
+
expect(result.outcome).toBe("refused");
|
|
281
|
+
expect(result.reason).toContain("http://127.0.0.1:4873");
|
|
282
|
+
});
|
|
283
|
+
|
|
219
284
|
test("refuses a branch the subspace does not have, and one the config does not allow", async () => {
|
|
220
285
|
const { subspace } = await makeMirror("refuse-served", "refuse-private", "refuse-kit");
|
|
221
286
|
const missing = await subspace.push("release", { verify: false }).catch((error: Error) => error);
|
|
@@ -445,3 +510,42 @@ describe("SubspaceConfig", () => {
|
|
|
445
510
|
expect(() => config.assertPushable("develop")).not.toThrow();
|
|
446
511
|
});
|
|
447
512
|
});
|
|
513
|
+
|
|
514
|
+
describe("Subspace.plan", () => {
|
|
515
|
+
test("reports the slice a push ships without adding a remote or a clone", async () => {
|
|
516
|
+
const { wsRoot, subspace, clonePath } = await makeMirror("served", "private", "kit");
|
|
517
|
+
const plan = await subspace.plan("develop");
|
|
518
|
+
expect(plan).toMatchObject({
|
|
519
|
+
name: "acme",
|
|
520
|
+
branch: "develop",
|
|
521
|
+
branchRefusal: null,
|
|
522
|
+
apps: ["served"],
|
|
523
|
+
libs: [{ name: "kit", apps: ["served"] }],
|
|
524
|
+
excludedRootEntries: ["benchmarks"],
|
|
525
|
+
subspaceOwnedRootEntries: ["akan.config.ts", "Jenkinsfile"],
|
|
526
|
+
prunedDependencies: ["another-app-dep"],
|
|
527
|
+
lockProblem: null,
|
|
528
|
+
});
|
|
529
|
+
expect(plan.rootEntries).toEqual(expect.arrayContaining([".gitignore", "AGENTS.md", "biome.json", "package.json"]));
|
|
530
|
+
expect(plan.rootEntries).not.toContain("benchmarks");
|
|
531
|
+
expect(plan.rootEntries).not.toContain("Jenkinsfile");
|
|
532
|
+
expect(plan.files).toBeGreaterThan(0);
|
|
533
|
+
expect((await git(wsRoot, ["remote"])).trim()).toBe("");
|
|
534
|
+
expect(await FileSys.dirExists(clonePath)).toBe(false);
|
|
535
|
+
expect(formatSubspacePlan(plan)).toContain("Nothing here would make a push refuse.");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("names what a push would refuse: a branch it may not push and a loopback-registry lock", async () => {
|
|
539
|
+
const { wsRoot, subspace } = await makeMirror("served", "private", "kit");
|
|
540
|
+
await write(
|
|
541
|
+
path.join(wsRoot, "bun.lock"),
|
|
542
|
+
'{ "packages": { "x": ["x@1.0.0", "http://127.0.0.1:4873/x/-/x-1.0.0.tgz"] } }\n',
|
|
543
|
+
);
|
|
544
|
+
await git(wsRoot, ["add", "-f", "bun.lock"]);
|
|
545
|
+
await git(wsRoot, ["commit", "--quiet", "-m", "lock"]);
|
|
546
|
+
const plan = await subspace.plan("main");
|
|
547
|
+
expect(plan.branchRefusal).toContain('Branch "main" is not pushable');
|
|
548
|
+
expect(plan.lockProblem).toContain("http://127.0.0.1:4873");
|
|
549
|
+
expect(formatSubspacePlan(plan)).toContain("A push would be refused:");
|
|
550
|
+
});
|
|
551
|
+
});
|
package/subspace.ts
CHANGED
|
@@ -55,6 +55,28 @@ export interface SubspacePushResult {
|
|
|
55
55
|
prunedDependencies?: string[];
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface SubspacePlan {
|
|
59
|
+
name: string;
|
|
60
|
+
repo: string;
|
|
61
|
+
branch: string;
|
|
62
|
+
/** Why a push from this branch would be refused, or null when it may push. */
|
|
63
|
+
branchRefusal: string | null;
|
|
64
|
+
apps: string[];
|
|
65
|
+
/** Each library the slice carries, with the apps whose closure pulls it in. */
|
|
66
|
+
libs: { name: string; apps: string[] }[];
|
|
67
|
+
/** Tracked files under the slice's apps/ and libs/ directories. */
|
|
68
|
+
files: number;
|
|
69
|
+
/** Workspace-root entries a push copies into the subspace. */
|
|
70
|
+
rootEntries: string[];
|
|
71
|
+
excludedRootEntries: string[];
|
|
72
|
+
/** Workspace-root entries never pushed: the subspace keeps its own copy, if it has one. */
|
|
73
|
+
subspaceOwnedRootEntries: string[];
|
|
74
|
+
prunedDependencies: string[];
|
|
75
|
+
/** Why the workspace's bun.lock would make a push refuse, or null. */
|
|
76
|
+
lockProblem: string | null;
|
|
77
|
+
warnings: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
export interface SubspaceEnvUploadResult {
|
|
59
81
|
name: string;
|
|
60
82
|
/** The cloud workspace whose env archive was replaced: the subspace's own, never this workspace's. */
|
|
@@ -79,14 +101,19 @@ export interface SubspacePullResult {
|
|
|
79
101
|
// pull diffs against the last push received, so it is exactly the customer's own work.
|
|
80
102
|
export class Subspace {
|
|
81
103
|
static readonly anchorFile = "akan.subspace.json";
|
|
82
|
-
/**
|
|
83
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The workspace's own never reach a subspace — its root config names every other customer's repo, its Jenkinsfile
|
|
106
|
+
* deploys the workspace — and a subspace's own survive every push.
|
|
107
|
+
*/
|
|
108
|
+
static readonly subspaceOwnedRootEntries = ["akan.config.ts", "Jenkinsfile"];
|
|
109
|
+
static readonly syncSubjectPrefix = "chore(subspace): sync from ";
|
|
84
110
|
/** Workspace members, which are replaced wholesale rather than overlaid. */
|
|
85
111
|
static readonly memberDirs = ["apps", "libs"];
|
|
86
112
|
/** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
|
|
87
113
|
static readonly subspaceOwnedDirs = ["env"];
|
|
88
114
|
static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
|
|
89
115
|
static readonly holdDir = ".akan-subspace-hold";
|
|
116
|
+
static readonly #loopbackRegistry = /https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(?::\d+)?/;
|
|
90
117
|
|
|
91
118
|
#workspace: WorkspaceExecutor;
|
|
92
119
|
#config: SubspaceConfig;
|
|
@@ -118,6 +145,10 @@ export class Subspace {
|
|
|
118
145
|
return await this.#workspace.spawn("git", args);
|
|
119
146
|
}
|
|
120
147
|
|
|
148
|
+
async #cloneGit(args: string[]) {
|
|
149
|
+
return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
|
|
150
|
+
}
|
|
151
|
+
|
|
121
152
|
async branch() {
|
|
122
153
|
return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
123
154
|
}
|
|
@@ -167,6 +198,52 @@ export class Subspace {
|
|
|
167
198
|
return { plans, libs, paths };
|
|
168
199
|
}
|
|
169
200
|
|
|
201
|
+
/** What a push from `branch` would ship, worked out from this checkout alone: no fetch, no remote, no clone. */
|
|
202
|
+
async plan(branch: string): Promise<SubspacePlan> {
|
|
203
|
+
const { plans, libs } = await this.slice();
|
|
204
|
+
const rootFiles = plans[0]?.rootFiles ?? [];
|
|
205
|
+
const present = new Set(rootFiles.map((file) => file.split("/")[0] ?? file));
|
|
206
|
+
const owned = Subspace.subspaceOwnedRootEntries;
|
|
207
|
+
const excluded = this.#config.exclude.filter((entry) => !owned.includes(entry));
|
|
208
|
+
const excludedTops = new Set(excluded.map((entry) => entry.replace(/\/+$/, "")));
|
|
209
|
+
const libFiles = new Map(plans.flatMap((plan) => Object.entries(plan.libFiles)));
|
|
210
|
+
const { pruned, warnings } = await SlicePlanner.pruneDependencies(
|
|
211
|
+
this.#workspace,
|
|
212
|
+
await this.#workspace.getPackageJson(),
|
|
213
|
+
plans.flatMap((plan) => plan.requiredDependencies),
|
|
214
|
+
);
|
|
215
|
+
let branchRefusal: string | null = null;
|
|
216
|
+
try {
|
|
217
|
+
this.#config.assertPushable(branch);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
branchRefusal = error instanceof Error ? error.message : String(error);
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
name: this.name,
|
|
223
|
+
repo: this.#declaration.repo,
|
|
224
|
+
branch,
|
|
225
|
+
branchRefusal,
|
|
226
|
+
apps: [...this.#declaration.apps],
|
|
227
|
+
libs: libs.map((lib) => ({
|
|
228
|
+
name: lib,
|
|
229
|
+
apps: plans.filter((plan) => plan.libs.includes(lib)).map((plan) => plan.app),
|
|
230
|
+
})),
|
|
231
|
+
files:
|
|
232
|
+
plans.reduce((sum, plan) => sum + plan.appFiles.length, 0) +
|
|
233
|
+
[...libFiles.values()].reduce((sum, files) => sum + files.length, 0),
|
|
234
|
+
rootEntries: [...present]
|
|
235
|
+
.filter((entry) => !excludedTops.has(entry) && !owned.includes(entry))
|
|
236
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
237
|
+
excludedRootEntries: excluded,
|
|
238
|
+
subspaceOwnedRootEntries: owned.filter((entry) => present.has(entry)),
|
|
239
|
+
prunedDependencies: pruned,
|
|
240
|
+
lockProblem: rootFiles.includes("bun.lock")
|
|
241
|
+
? await this.#loopbackRegistryLock(this.#workspace.workspaceRoot)
|
|
242
|
+
: null,
|
|
243
|
+
warnings: [...new Set([...plans.flatMap((plan) => plan.warnings), ...warnings])],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
170
247
|
#isSubspaceOwned(file: string) {
|
|
171
248
|
const segments = file.split("/");
|
|
172
249
|
if (!Subspace.memberDirs.includes(segments[0] ?? "")) return false;
|
|
@@ -258,6 +335,36 @@ export class Subspace {
|
|
|
258
335
|
await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
|
|
259
336
|
}
|
|
260
337
|
|
|
338
|
+
//* A root entry last written by a sync commit is a copy an earlier push left there, the parent's Jenkinsfile among them:
|
|
339
|
+
//* it goes. Any other is the subspace's own and is held through the extraction.
|
|
340
|
+
async #holdRootEntries() {
|
|
341
|
+
const holdRoot = path.join(this.#clonePath, Subspace.holdDir, "root");
|
|
342
|
+
const held: string[] = [];
|
|
343
|
+
for (const entry of Subspace.subspaceOwnedRootEntries) {
|
|
344
|
+
const from = path.join(this.#clonePath, entry);
|
|
345
|
+
if (!(await FileSys.entryExists(from))) continue;
|
|
346
|
+
if (await this.#isPushedCopy(entry)) continue;
|
|
347
|
+
await mkdir(holdRoot, { recursive: true });
|
|
348
|
+
await rename(from, path.join(holdRoot, entry));
|
|
349
|
+
held.push(entry);
|
|
350
|
+
}
|
|
351
|
+
return held;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async #restoreRootEntries(held: string[]) {
|
|
355
|
+
const holdRoot = path.join(this.#clonePath, Subspace.holdDir, "root");
|
|
356
|
+
for (const entry of Subspace.subspaceOwnedRootEntries) {
|
|
357
|
+
const target = path.join(this.#clonePath, entry);
|
|
358
|
+
await rm(target, { recursive: true, force: true });
|
|
359
|
+
if (held.includes(entry)) await rename(path.join(holdRoot, entry), target);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async #isPushedCopy(entry: string) {
|
|
364
|
+
const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
|
|
365
|
+
return subject.trim().startsWith(Subspace.syncSubjectPrefix);
|
|
366
|
+
}
|
|
367
|
+
|
|
261
368
|
static async #copyMissing(from: string, to: string) {
|
|
262
369
|
await mkdir(to, { recursive: true });
|
|
263
370
|
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
@@ -337,16 +444,19 @@ export class Subspace {
|
|
|
337
444
|
async #applySlice(clone: Executor, branch: string) {
|
|
338
445
|
const { libs, paths, plans } = await this.slice();
|
|
339
446
|
const held = await this.#holdSubspaceOwned();
|
|
447
|
+
const heldRootEntries = await this.#holdRootEntries();
|
|
340
448
|
for (const member of Subspace.memberDirs)
|
|
341
449
|
await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
|
|
342
450
|
|
|
343
451
|
//* Overlaid, never reconciled: a subspace owns its deployment, so a root entry the workspace lacks is left alone.
|
|
344
452
|
await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
|
|
345
453
|
await this.#extract(paths);
|
|
454
|
+
await this.#restoreRootEntries(heldRootEntries);
|
|
346
455
|
await this.#restoreSubspaceOwned(held);
|
|
347
456
|
|
|
348
|
-
for (const entry of
|
|
349
|
-
|
|
457
|
+
for (const entry of this.#config.exclude)
|
|
458
|
+
if (!Subspace.subspaceOwnedRootEntries.includes(entry))
|
|
459
|
+
await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
|
|
350
460
|
|
|
351
461
|
await this.#rewriteGitignore();
|
|
352
462
|
const pruned = await this.#rewriteManifest(plans);
|
|
@@ -404,11 +514,12 @@ export class Subspace {
|
|
|
404
514
|
const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
|
|
405
515
|
const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
|
|
406
516
|
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
407
|
-
//* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD
|
|
408
|
-
//*
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
517
|
+
//* The extraction overwrote the manifest, so the previous stamp comes from the clone's HEAD. It is kept whole while
|
|
518
|
+
//* the library's content is unchanged — matching the workspace sha too would rewrite every manifest, and push,
|
|
519
|
+
//* after any unrelated workspace commit.
|
|
520
|
+
const unchanged = previous?.origin === origin && previous.hash === hash;
|
|
521
|
+
const source = unchanged ? previous : { origin, sha, hash, syncedAt: new Date().toISOString() };
|
|
522
|
+
manifest[LibSource.manifestKey] = { ...akan, source };
|
|
412
523
|
await FileSys.writeJson(manifestPath, manifest);
|
|
413
524
|
}
|
|
414
525
|
|
|
@@ -472,6 +583,16 @@ export class Subspace {
|
|
|
472
583
|
return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
|
|
473
584
|
}
|
|
474
585
|
|
|
586
|
+
// A lock written against a local registry (verdaccio) resolves on this machine alone: every install of it fails.
|
|
587
|
+
async #loopbackRegistryLock(root = this.#clonePath) {
|
|
588
|
+
const lockPath = path.join(root, "bun.lock");
|
|
589
|
+
if (!(await FileSys.fileExists(lockPath))) return null;
|
|
590
|
+
const registry = (await FileSys.readText(lockPath)).match(Subspace.#loopbackRegistry)?.[0];
|
|
591
|
+
return registry
|
|
592
|
+
? `bun.lock resolves packages from ${registry}, a registry only this machine reaches; reinstall from a public registry and commit that lock first`
|
|
593
|
+
: null;
|
|
594
|
+
}
|
|
595
|
+
|
|
475
596
|
// Refuses rather than throws, so one failing subspace does not abort the rest; the env is explicit so the child
|
|
476
597
|
// sees the clone's values, not the workspace's.
|
|
477
598
|
async #verify(clone: Executor) {
|
|
@@ -510,9 +631,11 @@ export class Subspace {
|
|
|
510
631
|
const failure = await this.#verify(clone);
|
|
511
632
|
if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
|
|
512
633
|
}
|
|
634
|
+
const lockProblem = await this.#loopbackRegistryLock();
|
|
635
|
+
if (lockProblem) return { name: this.name, outcome: "refused", reason: lockProblem, changedFiles };
|
|
513
636
|
await this.#writeAnchorFile(branch);
|
|
514
637
|
await clone.spawn("git", ["add", "-A"]);
|
|
515
|
-
const message =
|
|
638
|
+
const message = `${Subspace.syncSubjectPrefix}${this.#workspace.repoName}@${await this.headSha()}`;
|
|
516
639
|
await clone.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
517
640
|
//* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
|
|
518
641
|
await clone.spawn("git", ["push", "--quiet", "origin", branch]);
|
|
@@ -694,3 +817,32 @@ export function formatSubspaceDiff(result: SubspaceDiffResult) {
|
|
|
694
817
|
];
|
|
695
818
|
return sections.join("\n");
|
|
696
819
|
}
|
|
820
|
+
|
|
821
|
+
export function formatSubspacePlan(plan: SubspacePlan) {
|
|
822
|
+
const list = (entries: string[]) => (entries.length ? entries.join(", ") : "(none)");
|
|
823
|
+
const blockers = [plan.branchRefusal, plan.lockProblem].filter((reason): reason is string => !!reason);
|
|
824
|
+
const sections = [
|
|
825
|
+
`Akan Subspace Plan — ${plan.name} (${plan.branch})`,
|
|
826
|
+
`repo: ${plan.repo}`,
|
|
827
|
+
"what `akan subspace push` would ship, worked out from this checkout alone",
|
|
828
|
+
"",
|
|
829
|
+
`apps: ${list(plan.apps)}`,
|
|
830
|
+
`libs: ${plan.libs.length ? "" : "(none)"}`.trimEnd(),
|
|
831
|
+
...plan.libs.map((lib) => ` - ${lib.name} (from ${lib.apps.join(", ")})`),
|
|
832
|
+
`files under apps/ and libs/: ${plan.files}`,
|
|
833
|
+
"",
|
|
834
|
+
`root entries pushed: ${list(plan.rootEntries)}`,
|
|
835
|
+
`root entries excluded: ${list(plan.excludedRootEntries)}`,
|
|
836
|
+
`root entries the subspace keeps its own: ${list(plan.subspaceOwnedRootEntries)}`,
|
|
837
|
+
"",
|
|
838
|
+
`Root dependencies left out of package.json (${plan.prunedDependencies.length}):`,
|
|
839
|
+
...(plan.prunedDependencies.length ? plan.prunedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
|
|
840
|
+
"",
|
|
841
|
+
blockers.length ? "A push would be refused:" : "Nothing here would make a push refuse.",
|
|
842
|
+
...blockers.map((reason) => ` - ${reason}`),
|
|
843
|
+
...(plan.warnings.length
|
|
844
|
+
? ["", `Warnings (${plan.warnings.length}):`, ...plan.warnings.map((w) => ` - ${w}`)]
|
|
845
|
+
: []),
|
|
846
|
+
];
|
|
847
|
+
return sections.join("\n");
|
|
848
|
+
}
|
package/subspaceConfig.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import type { SubspaceConfigInput, SubspaceDeclaration } from "akanjs";
|
|
3
2
|
import type { WorkspaceExecutor } from "./executors";
|
|
4
|
-
import { FileSys } from "./fileSys";
|
|
5
3
|
|
|
6
4
|
export type { SubspaceConfigInput, SubspaceDeclaration };
|
|
7
5
|
|
|
8
6
|
// No branch field: the workspace's current branch is used, so every subspace on it holds the same akanjs and libs.
|
|
9
7
|
export class SubspaceConfig {
|
|
10
|
-
|
|
8
|
+
/** Where the declaration lives, as error messages name it. */
|
|
9
|
+
static readonly source = "subspace in the workspace root akan.config.ts";
|
|
11
10
|
static readonly defaultPushableBranches = ["main", "develop", "debug"];
|
|
12
11
|
|
|
13
12
|
static async from(workspace: WorkspaceExecutor): Promise<SubspaceConfig | null> {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const input = await import(configPath).then((mod: { default: SubspaceConfigInput }) => mod.default);
|
|
17
|
-
return new SubspaceConfig(input);
|
|
13
|
+
const { subspace } = await workspace.getConfig();
|
|
14
|
+
return subspace ? new SubspaceConfig(subspace) : null;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
readonly pushableBranches: string[];
|
|
@@ -34,18 +31,18 @@ export class SubspaceConfig {
|
|
|
34
31
|
const idOwners = new Map<string, string>();
|
|
35
32
|
for (const subspace of this.subspaces) {
|
|
36
33
|
if (!subspace.name || !subspace.repo)
|
|
37
|
-
throw new Error(`${SubspaceConfig.
|
|
34
|
+
throw new Error(`${SubspaceConfig.source}: every subspace needs a name and a repo`);
|
|
38
35
|
if (seenNames.has(subspace.name))
|
|
39
|
-
throw new Error(`${SubspaceConfig.
|
|
36
|
+
throw new Error(`${SubspaceConfig.source}: duplicate subspace "${subspace.name}"`);
|
|
40
37
|
seenNames.add(subspace.name);
|
|
41
38
|
if (!subspace.apps?.length)
|
|
42
|
-
throw new Error(`${SubspaceConfig.
|
|
39
|
+
throw new Error(`${SubspaceConfig.source}: subspace "${subspace.name}" declares no apps`);
|
|
43
40
|
for (const app of subspace.apps) {
|
|
44
41
|
const owner = appOwners.get(app);
|
|
45
42
|
//* One app never goes to two subspaces: per-subspace domains and ids are not expressible. Share a lib instead.
|
|
46
43
|
if (owner)
|
|
47
44
|
throw new Error(
|
|
48
|
-
`${SubspaceConfig.
|
|
45
|
+
`${SubspaceConfig.source}: app "${app}" is claimed by both "${owner}" and "${subspace.name}"`,
|
|
49
46
|
);
|
|
50
47
|
appOwners.set(app, subspace.name);
|
|
51
48
|
}
|
|
@@ -54,7 +51,7 @@ export class SubspaceConfig {
|
|
|
54
51
|
//* An env upload replaces the cloud workspace's archive whole, so two subspaces would overwrite each other.
|
|
55
52
|
if (idOwner)
|
|
56
53
|
throw new Error(
|
|
57
|
-
`${SubspaceConfig.
|
|
54
|
+
`${SubspaceConfig.source}: subspaces "${idOwner}" and "${subspace.name}" declare the same workspaceId`,
|
|
58
55
|
);
|
|
59
56
|
idOwners.set(subspace.workspaceId, subspace.name);
|
|
60
57
|
}
|
|
@@ -64,7 +61,7 @@ export class SubspaceConfig {
|
|
|
64
61
|
if (!names.length) return this.subspaces;
|
|
65
62
|
return names.map((name) => {
|
|
66
63
|
const subspace = this.subspaces.find((candidate) => candidate.name === name);
|
|
67
|
-
if (!subspace) throw new Error(`${SubspaceConfig.
|
|
64
|
+
if (!subspace) throw new Error(`${SubspaceConfig.source}: unknown subspace "${name}"`);
|
|
68
65
|
return subspace;
|
|
69
66
|
});
|
|
70
67
|
}
|
|
@@ -72,7 +69,7 @@ export class SubspaceConfig {
|
|
|
72
69
|
assertPushable(branch: string) {
|
|
73
70
|
if (this.pushableBranches.includes(branch)) return;
|
|
74
71
|
throw new Error(
|
|
75
|
-
`Branch "${branch}" is not pushable. ${SubspaceConfig.
|
|
72
|
+
`Branch "${branch}" is not pushable. ${SubspaceConfig.source} allows: ${this.pushableBranches.join(", ")}`,
|
|
76
73
|
);
|
|
77
74
|
}
|
|
78
75
|
}
|
package/testHelpers.ts
CHANGED
|
@@ -61,6 +61,10 @@ export const createFakeExecutor = <Extra extends object = object>(
|
|
|
61
61
|
recorder.record(`${name}.scanSync`, ...args);
|
|
62
62
|
return { name };
|
|
63
63
|
},
|
|
64
|
+
//? `PkgExecutor.from(fake, name)` reads `.workspace`; without one it falls back to `fromRoot()`, which forks git.
|
|
65
|
+
get workspace(): unknown {
|
|
66
|
+
return this;
|
|
67
|
+
},
|
|
64
68
|
...extra,
|
|
65
69
|
}) as Extra & {
|
|
66
70
|
name: string;
|
|
@@ -17,13 +17,7 @@ const DEFAULT_INCLUDE = ["akanjs/", "@apps/", "@libs/"];
|
|
|
17
17
|
// Runtime hosts and singleton framework packages stay external; ordinary npm dependencies are bundled.
|
|
18
18
|
const DEFAULT_EXCLUDE_EXACT = new Set<string>(["akanjs/webkit", "@akanjs/cli", "@akanjs/devkit"]);
|
|
19
19
|
const DEFAULT_EXCLUDE_PREFIX = ["@akanjs/cli/", "@akanjs/devkit/"];
|
|
20
|
-
const OPTIONAL_BACKEND_EXTERNAL_EXACT = new Set<string>([
|
|
21
|
-
"@libsql/client",
|
|
22
|
-
"bullmq",
|
|
23
|
-
"ioredis",
|
|
24
|
-
"postgres",
|
|
25
|
-
"protobufjs",
|
|
26
|
-
]);
|
|
20
|
+
const OPTIONAL_BACKEND_EXTERNAL_EXACT = new Set<string>(["bullmq", "ioredis", "postgres", "protobufjs"]);
|
|
27
21
|
const RUNTIME_EXTERNAL_EXACT = new Set<string>([
|
|
28
22
|
"react",
|
|
29
23
|
"react-dom",
|