@akanjs/devkit 3.0.0-alpha.10 → 3.0.0-alpha.101
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 +15 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +52 -23
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +82 -43
- package/biomeBase.ts +1 -1
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +134 -4
- package/executors.ts +114 -25
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The directories one app's dev watcher follows, resolved from its tsconfig `paths`.
|
|
8
|
+
*
|
|
9
|
+
* Two aliases strip down to a workspace container rather than a package, and taking either verbatim puts
|
|
10
|
+
* code the app never imports under the watcher — where a save rebuilds, restarts and reloads it for
|
|
11
|
+
* nothing, and both builders rewrite the same generated barrels:
|
|
12
|
+
*
|
|
13
|
+
* - `@apps/*` becomes the `apps/` container. Apps are leaves of the workspace graph — never one another's
|
|
14
|
+
* dependencies — so it is replaced by this app's own directory.
|
|
15
|
+
* - `@libs/*` becomes the `libs/` container. A lib is a dependency only if the app reaches it, so it is
|
|
16
|
+
* replaced by the app's own lib dependencies, transitively. The set is resolved once, at watcher
|
|
17
|
+
* install: a lib that becomes a dependency mid-session needs a fresh `akan start` anyway, because the
|
|
18
|
+
* import that made it one also needs a `sync`.
|
|
19
|
+
*
|
|
20
|
+
* Failing to resolve that set keeps the container whole, because a wrong narrowing is a dev server that
|
|
21
|
+
* silently ignores edits.
|
|
22
|
+
*/
|
|
5
23
|
export class WatchRootResolver {
|
|
6
24
|
#app: App;
|
|
7
25
|
|
|
@@ -11,6 +29,10 @@ export class WatchRootResolver {
|
|
|
11
29
|
|
|
12
30
|
async resolve(): Promise<string[]> {
|
|
13
31
|
const tsconfig = await this.#app.getTsConfig();
|
|
32
|
+
const appDir = path.resolve(this.#app.cwdPath);
|
|
33
|
+
const appsContainer = path.dirname(appDir);
|
|
34
|
+
const libsContainer = path.resolve(this.#app.workspace.workspaceRoot, "libs");
|
|
35
|
+
const libRoots = await this.#resolveLibRoots(libsContainer);
|
|
14
36
|
const set = new Set<string>();
|
|
15
37
|
set.add(path.resolve(`${this.#app.cwdPath}/page`));
|
|
16
38
|
for (const targets of Object.values(tsconfig.compilerOptions.paths ?? {})) {
|
|
@@ -20,9 +42,52 @@ export class WatchRootResolver {
|
|
|
20
42
|
// Strip the trailing filename and glob so we watch the package root dir.
|
|
21
43
|
const cleaned = target.replace(/\/?\*+.*$/, "").replace(/\/[^/]+\.[^/]+$/, "");
|
|
22
44
|
const resolved = path.resolve(this.#app.workspace.workspaceRoot, cleaned);
|
|
23
|
-
if (
|
|
45
|
+
if (resolved === libsContainer && libRoots) {
|
|
46
|
+
for (const root of libRoots) set.add(root);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const root = resolved === appsContainer ? appDir : resolved;
|
|
50
|
+
if (fs.existsSync(root)) set.add(root);
|
|
24
51
|
}
|
|
25
52
|
}
|
|
26
53
|
return [...set];
|
|
27
54
|
}
|
|
55
|
+
|
|
56
|
+
async #resolveLibRoots(libsContainer: string): Promise<string[] | null> {
|
|
57
|
+
const libDeps = await this.#resolveLibDeps(libsContainer);
|
|
58
|
+
if (!libDeps) return null;
|
|
59
|
+
return libDeps.map((name) => path.join(libsContainer, name)).filter((dir) => fs.existsSync(dir));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #resolveLibDeps(libsContainer: string): Promise<string[] | null> {
|
|
63
|
+
const scanInfo = this.#app.getScanInfo({ allowEmpty: true });
|
|
64
|
+
// Already transitive, and present whenever this runs in a process that scanned. The builder and the
|
|
65
|
+
// idle watcher run in processes that did not, so they take the manifests `scan` wrote instead.
|
|
66
|
+
if (scanInfo?.type === "app") return scanInfo.libDeps;
|
|
67
|
+
const direct = await WatchRootResolver.#readManifestLibDeps(path.join(this.#app.cwdPath, "akan.app.json"));
|
|
68
|
+
if (!direct) return null;
|
|
69
|
+
const closure = new Set<string>();
|
|
70
|
+
const queue = [...direct];
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const name = queue.shift();
|
|
73
|
+
if (!name || closure.has(name)) continue;
|
|
74
|
+
closure.add(name);
|
|
75
|
+
const nested = await WatchRootResolver.#readManifestLibDeps(path.join(libsContainer, name, "akan.lib.json"));
|
|
76
|
+
// An unsynced lib's own dependencies are unknowable, so no root can be ruled out.
|
|
77
|
+
if (!nested) return null;
|
|
78
|
+
queue.push(...nested);
|
|
79
|
+
}
|
|
80
|
+
return [...closure];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async #readManifestLibDeps(manifestPath: string): Promise<string[] | null> {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(await readFile(manifestPath, "utf8")) as { libDeps?: unknown };
|
|
86
|
+
if (!Array.isArray(parsed.libDeps)) return null;
|
|
87
|
+
return parsed.libDeps.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
88
|
+
} catch {
|
|
89
|
+
// Missing before the first sync, and unparseable while sync is mid-write.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
28
93
|
}
|
|
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
|
|
|
22
22
|
expect(prepared.event.devPlan?.files).toEqual(prepared.files);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test.each([
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
38
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
39
|
-
});
|
|
25
|
+
test.each(["common", "srvkit", "ui", "webkit"])(
|
|
26
|
+
"keeps %s facet add/delete generated index in the same generation",
|
|
27
|
+
(facet) => {
|
|
28
|
+
const root = "/repo";
|
|
29
|
+
const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
|
|
30
|
+
const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
|
|
31
|
+
const prepared = prepareDevWatchBatch({
|
|
32
|
+
generation: 20,
|
|
33
|
+
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
34
|
+
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
35
|
+
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
36
|
+
});
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
|
|
39
|
+
expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
|
|
40
|
+
expect(prepared.event.devPlan?.roles).toContain("barrel");
|
|
41
|
+
expect(prepared.event.devPlan?.actions).toContain("sync-generated");
|
|
42
|
+
},
|
|
43
|
+
);
|
|
46
44
|
|
|
47
45
|
test("marks failed generated index sync as an error generation", () => {
|
|
48
46
|
const root = "/repo";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BuilderEvent, ChangeBatch } from "akanjs/server";
|
|
1
|
+
import type { BuilderEvent, ChangeBatch, DevChangePlan } from "akanjs/server";
|
|
2
2
|
import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
|
|
3
3
|
|
|
4
4
|
export interface PrepareDevWatchBatchOptions {
|
|
@@ -12,6 +12,8 @@ export interface PreparedDevWatchBatch {
|
|
|
12
12
|
files: string[];
|
|
13
13
|
kinds: ("code" | "css" | "config")[];
|
|
14
14
|
expandedBatch: ChangeBatch;
|
|
15
|
+
/** The same plan the event carries, where `devPlan` is optional — read it here, not off the event. */
|
|
16
|
+
devPlan: DevChangePlan;
|
|
15
17
|
event: Extract<BuilderEvent, { type: "invalidate" }>;
|
|
16
18
|
hasSyncErrors: boolean;
|
|
17
19
|
}
|
|
@@ -35,13 +37,15 @@ export const prepareDevWatchBatch = ({
|
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
|
|
38
|
-
|
|
40
|
+
const withReport: DevChangePlan["actions"] = [...devPlan.actions, "report-error"];
|
|
41
|
+
devPlan.actions = withReport.sort();
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
return {
|
|
42
45
|
files,
|
|
43
46
|
kinds,
|
|
44
47
|
expandedBatch,
|
|
48
|
+
devPlan,
|
|
45
49
|
event: { type: "invalidate", kinds, files, generation, devPlan },
|
|
46
50
|
hasSyncErrors: indexSync.errors.length > 0,
|
|
47
51
|
};
|
|
@@ -14,11 +14,25 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
|
|
|
14
14
|
"build-status",
|
|
15
15
|
"builder-metrics",
|
|
16
16
|
]);
|
|
17
|
+
/**
|
|
18
|
+
* Where a dev child process writes. `"pipe"` is not only about keeping a TUI's frame clean: a Bun child
|
|
19
|
+
* that inherits the controlling terminal snapshots its termios at spawn and writes that snapshot back
|
|
20
|
+
* when it exits, so a builder spawned while something holds the terminal raw turns it raw again on the
|
|
21
|
+
* first recycle. See the XXX comment on `Spinner.oraOptions`.
|
|
22
|
+
*/
|
|
23
|
+
export type DevStdioMode = "inherit" | "pipe";
|
|
24
|
+
|
|
17
25
|
interface IncrementalBuilderHostOptions {
|
|
18
26
|
app: App;
|
|
19
27
|
entry: string;
|
|
20
28
|
env: Record<string, string>;
|
|
29
|
+
stdio?: DevStdioMode;
|
|
21
30
|
onMessage: (message: BuilderMessage) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Required when `stdio` is `"pipe"`: an undrained pipe fills and blocks the builder's next write.
|
|
33
|
+
* Text arrives as decoded chunks, not lines — a consumer that needs lines splits them itself.
|
|
34
|
+
*/
|
|
35
|
+
onOutput?: (kind: "stdout" | "stderr", text: string) => void;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
/**
|
|
@@ -63,7 +77,9 @@ export class IncrementalBuilderHost {
|
|
|
63
77
|
app: App;
|
|
64
78
|
ready = false;
|
|
65
79
|
readonly #onMessage: (message: BuilderMessage) => void;
|
|
66
|
-
#
|
|
80
|
+
readonly #stdio: DevStdioMode;
|
|
81
|
+
readonly #onOutput: ((kind: "stdout" | "stderr", text: string) => void) | null;
|
|
82
|
+
#proc: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
67
83
|
#status: IncrementalBuilderStatus = "stopped";
|
|
68
84
|
#restartAttempts = 0;
|
|
69
85
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -83,11 +99,13 @@ export class IncrementalBuilderHost {
|
|
|
83
99
|
*/
|
|
84
100
|
readonly #inFlight = new Map<number, "build-route" | "build-csr">();
|
|
85
101
|
#startOptions: IncrementalBuilderStartOptions = {};
|
|
86
|
-
constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
|
|
102
|
+
constructor({ app, entry, env, stdio = "inherit", onMessage, onOutput }: IncrementalBuilderHostOptions) {
|
|
87
103
|
this.app = app;
|
|
88
104
|
this.entry = entry;
|
|
89
105
|
this.env = env;
|
|
106
|
+
this.#stdio = stdio;
|
|
90
107
|
this.#onMessage = onMessage;
|
|
108
|
+
this.#onOutput = onOutput ?? null;
|
|
91
109
|
}
|
|
92
110
|
get status() {
|
|
93
111
|
return this.#status;
|
|
@@ -115,11 +133,11 @@ export class IncrementalBuilderHost {
|
|
|
115
133
|
// the flag is what tells it to re-announce what it booted with.
|
|
116
134
|
const afterRecycle = this.#spawnAfterRecycle;
|
|
117
135
|
this.#spawnAfterRecycle = false;
|
|
118
|
-
let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
|
|
136
|
+
let proc!: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe">;
|
|
119
137
|
proc = Bun.spawn(["bun", this.entry], {
|
|
120
138
|
cwd: this.app.cwdPath,
|
|
121
139
|
env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
|
|
122
|
-
stdio: ["ignore",
|
|
140
|
+
stdio: ["ignore", this.#stdio, this.#stdio],
|
|
123
141
|
ipc: (msg: BuilderMessage) => {
|
|
124
142
|
if (this.#proc !== proc) return;
|
|
125
143
|
if (!msg || typeof msg !== "object") return;
|
|
@@ -167,8 +185,25 @@ export class IncrementalBuilderHost {
|
|
|
167
185
|
},
|
|
168
186
|
});
|
|
169
187
|
this.#proc = proc;
|
|
188
|
+
// Re-attached per spawn, not once per host: a recycle or a crash-restart brings new pipes.
|
|
189
|
+
if (this.#stdio === "pipe" && this.#onOutput) {
|
|
190
|
+
void this.#drain(proc.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
|
|
191
|
+
void this.#drain(proc.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
|
|
192
|
+
}
|
|
170
193
|
this.logger.verbose(`builder spawned pid=${proc.pid} entry=${this.entry}${isRestart ? " restart=1" : ""}`);
|
|
171
194
|
}
|
|
195
|
+
async #drain(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
|
|
196
|
+
if (!stream) return;
|
|
197
|
+
const decoder = new TextDecoder();
|
|
198
|
+
try {
|
|
199
|
+
for await (const chunk of stream) {
|
|
200
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
201
|
+
if (text) this.#onOutput?.(kind, text);
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// The stream closes when the builder exits; nothing further to surface here.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
172
207
|
#scheduleRestart() {
|
|
173
208
|
if (this.#manualStop || this.#restartTimer) return;
|
|
174
209
|
this.#status = "restarting";
|
|
@@ -200,7 +235,7 @@ export class IncrementalBuilderHost {
|
|
|
200
235
|
// one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
|
|
201
236
|
// field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
|
|
202
237
|
this.#status = "recycling";
|
|
203
|
-
this.logger.
|
|
238
|
+
this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
|
|
204
239
|
this.#recycleTimer = setTimeout(() => {
|
|
205
240
|
this.#recycleTimer = null;
|
|
206
241
|
if (this.#proc !== proc) return;
|
|
@@ -278,7 +313,15 @@ export class IncrementalBuilderHost {
|
|
|
278
313
|
this.ready = false;
|
|
279
314
|
this.#status = "stopped";
|
|
280
315
|
}
|
|
281
|
-
static async create(
|
|
316
|
+
static async create(
|
|
317
|
+
app: App,
|
|
318
|
+
env: Record<string, string>,
|
|
319
|
+
onMessage: (message: BuilderMessage) => void,
|
|
320
|
+
{
|
|
321
|
+
stdio = "inherit",
|
|
322
|
+
onOutput,
|
|
323
|
+
}: { stdio?: DevStdioMode; onOutput?: (kind: "stdout" | "stderr", text: string) => void } = {},
|
|
324
|
+
) {
|
|
282
325
|
const candidates = [
|
|
283
326
|
path.join(app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts"),
|
|
284
327
|
path.join(
|
|
@@ -289,7 +332,8 @@ export class IncrementalBuilderHost {
|
|
|
289
332
|
path.join(import.meta.dir, "incrementalBuilder.proc.ts"),
|
|
290
333
|
];
|
|
291
334
|
for (const c of candidates)
|
|
292
|
-
if (await Bun.file(c).exists())
|
|
335
|
+
if (await Bun.file(c).exists())
|
|
336
|
+
return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput });
|
|
293
337
|
throw new Error(`[cli] frontend builder entry not found; looked in: ${candidates.join(", ")}`);
|
|
294
338
|
}
|
|
295
339
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
// barrel reaches `cssCompiler`/`ssrBaseArtifactBuilder`, which pull tailwindcss + @tailwindcss/node
|
|
5
5
|
// (~40MB) into a process that then holds them for the whole dev session. Phase 2 moved css compilation
|
|
6
6
|
// into the batch worker, so this process has no use for them — `entryModuleGraph.test.ts` keeps it that way.
|
|
7
|
+
import { CodegenLock } from "@akanjs/devkit/codegenLock";
|
|
7
8
|
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
8
9
|
import { AppExecutor, type PageRoot, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
9
10
|
import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
|
|
@@ -185,7 +186,12 @@ class IncrementalBuilder {
|
|
|
185
186
|
if (this.#shuttingDown) return;
|
|
186
187
|
this.#shuttingDown = true;
|
|
187
188
|
const started = Date.now();
|
|
188
|
-
this.#logger.
|
|
189
|
+
this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
190
|
+
// Stopped before the drain, or a save landing mid-drain would enqueue a batch behind the queue tail
|
|
191
|
+
// this method already awaited — and `process.exit(0)` would cut that batch off partway through
|
|
192
|
+
// writing its artifacts. The replacement rebuilds every artifact from its boot build anyway, so the
|
|
193
|
+
// batch is not lost; a half-written one would be.
|
|
194
|
+
this.#watcher?.stop();
|
|
189
195
|
if (this.#cssRebuildTimer) {
|
|
190
196
|
// Only reachable if a css batch landed between the idle report and this request: the fresh
|
|
191
197
|
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
@@ -200,7 +206,7 @@ class IncrementalBuilder {
|
|
|
200
206
|
// flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
|
|
201
207
|
// anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
|
|
202
208
|
const flushed = await BuilderChannel.drain();
|
|
203
|
-
this.#logger.
|
|
209
|
+
this.#logger.debug(
|
|
204
210
|
`drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
|
|
205
211
|
);
|
|
206
212
|
process.exit(0);
|
|
@@ -294,22 +300,27 @@ class IncrementalBuilder {
|
|
|
294
300
|
//* Insert framework imports that are used but omitted (e.g. `Int` in *.constant.ts, `fetch` in
|
|
295
301
|
//* *.store.ts) before regenerating barrels. Edits land on files already in this batch, so they
|
|
296
302
|
//* rebuild in this same generation; the write is idempotent so it does not re-trigger the watcher.
|
|
297
|
-
const autoImport = await
|
|
303
|
+
const [autoImport, indexSync] = await CodegenLock.run(
|
|
304
|
+
this.#app.workspace.workspaceRoot,
|
|
305
|
+
`hmr-batch:${this.#app.name}`,
|
|
306
|
+
async () => {
|
|
307
|
+
const auto = await this.#autoImportSync.syncForBatch(batch.files);
|
|
308
|
+
return [auto, await this.#generatedIndexSync.syncForBatch(batch.files)] as const;
|
|
309
|
+
},
|
|
310
|
+
);
|
|
298
311
|
for (const error of autoImport.errors) this.#logger.error(error);
|
|
299
312
|
if (autoImport.changedFiles.length > 0)
|
|
300
313
|
this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
|
|
301
|
-
const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
|
|
302
314
|
//* Both passes above write source files, and this generation's build consumes what they wrote. Hand
|
|
303
315
|
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
304
316
|
//* second generation rebuilding identical content.
|
|
305
317
|
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
306
|
-
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
318
|
+
const { files, kinds, expandedBatch, devPlan, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
307
319
|
generation,
|
|
308
320
|
batch,
|
|
309
321
|
indexSync,
|
|
310
322
|
changePlanner: this.#changePlanner,
|
|
311
323
|
});
|
|
312
|
-
const devPlan = event.devPlan;
|
|
313
324
|
this.#logger.verbose(
|
|
314
325
|
`[hmr] batch generation=${generation} kinds=${kinds.join(",")} files=${files.length} generated=${indexSync.changedFiles.length} roles=${devPlan.roles.join(",") || "(none)"} actions=${devPlan.actions.join(",") || "(none)"}`,
|
|
315
326
|
);
|
package/index.ts
CHANGED
|
@@ -22,19 +22,15 @@ export type * from "./applicationBuildRunner";
|
|
|
22
22
|
export type * from "./applicationReleasePackager";
|
|
23
23
|
export type * from "./applicationTestPreload";
|
|
24
24
|
export type * from "./artifact";
|
|
25
|
-
export type * from "./builder";
|
|
26
25
|
export type * from "./capacitorApp";
|
|
27
26
|
export type * from "./cloud";
|
|
28
27
|
export type * from "./commandDecorators";
|
|
29
28
|
export type * from "./createTunnel";
|
|
30
29
|
export type * from "./dependencyScanner";
|
|
31
30
|
export type * from "./executors";
|
|
32
|
-
export type * from "./extractDeps";
|
|
33
31
|
export type * from "./fileSys";
|
|
34
32
|
export type * from "./frontendBuild";
|
|
35
|
-
export type * from "./getCredentials";
|
|
36
33
|
export type * from "./getDirname";
|
|
37
|
-
export type * from "./getModelFileData";
|
|
38
34
|
export type * from "./getRelatedCnsts";
|
|
39
35
|
export type * from "./guideline";
|
|
40
36
|
export type * from "./incrementalBuilder";
|
|
@@ -46,11 +42,8 @@ export type * from "./recipeScanner";
|
|
|
46
42
|
export type * from "./scanInfo";
|
|
47
43
|
export type * from "./selectModel";
|
|
48
44
|
export type * from "./spinner";
|
|
49
|
-
export type * from "./streamAi";
|
|
50
45
|
export type * from "./transforms";
|
|
51
46
|
export type * from "./typeChecker";
|
|
52
47
|
export type * from "./types";
|
|
53
|
-
export type * from "./ui";
|
|
54
48
|
export type * from "./uploadRelease";
|
|
55
|
-
export type * from "./useStdoutDimensions";
|
|
56
49
|
export type * from "./workflow";
|
|
@@ -118,20 +118,12 @@ export default config;
|
|
|
118
118
|
),
|
|
119
119
|
this.writeFile(
|
|
120
120
|
"env/env.client.ts",
|
|
121
|
-
`
|
|
122
|
-
|
|
123
|
-
export const env = {
|
|
124
|
-
...getEnv(),
|
|
125
|
-
} as const;
|
|
121
|
+
`export const env = {} as const;
|
|
126
122
|
`,
|
|
127
123
|
),
|
|
128
124
|
this.writeFile(
|
|
129
125
|
"env/env.server.ts",
|
|
130
|
-
`
|
|
131
|
-
|
|
132
|
-
export const env = {
|
|
133
|
-
...getEnv(),
|
|
134
|
-
} as const;
|
|
126
|
+
`export const env = {} as const;
|
|
135
127
|
`,
|
|
136
128
|
),
|
|
137
129
|
this.writeFile(
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Executor, LibExecutor, WorkspaceExecutor } from "./executors";
|
|
6
|
+
import { formatLibStatuses, LibSource } from "./libSource";
|
|
7
|
+
|
|
8
|
+
const tempRoots: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const write = async (filePath: string, content: string) => {
|
|
15
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
16
|
+
await writeFile(filePath, content);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// `LibExecutor.from` memoises by name, so each fixture needs a name no other test has used.
|
|
20
|
+
const makeLib = async (libName: string) => {
|
|
21
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-libsource-"));
|
|
22
|
+
tempRoots.push(root);
|
|
23
|
+
await write(
|
|
24
|
+
path.join(root, "package.json"),
|
|
25
|
+
'{ "name": "workspace", "version": "0.0.1", "description": "workspace" }\n',
|
|
26
|
+
);
|
|
27
|
+
await write(path.join(root, ".gitignore"), "node_modules\n");
|
|
28
|
+
await write(path.join(root, `libs/${libName}/package.json`), `{ "name": "@${libName}", "version": "0.0.1" }\n`);
|
|
29
|
+
await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
|
|
30
|
+
await write(path.join(root, `libs/${libName}/env/env.server.testing.ts`), "export const env = { key: 1 };\n");
|
|
31
|
+
|
|
32
|
+
const git = new Executor("fixture", root);
|
|
33
|
+
await git.spawn("git", ["init", "--quiet"]);
|
|
34
|
+
|
|
35
|
+
const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `workspace-${libName}` });
|
|
36
|
+
const lib = LibExecutor.from(workspace, libName);
|
|
37
|
+
return { root, lib, source: new LibSource(lib) };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("LibSource", () => {
|
|
41
|
+
test("reports an unstamped library", async () => {
|
|
42
|
+
const { source } = await makeLib("plain-lib");
|
|
43
|
+
const status = await source.status();
|
|
44
|
+
|
|
45
|
+
expect(status.drift).toBe("unstamped");
|
|
46
|
+
expect(status.stamp).toBeNull();
|
|
47
|
+
expect(status.hash).toMatch(/^[0-9a-f]{32}$/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("stamps the origin into package.json and reads back clean", async () => {
|
|
51
|
+
const { lib, source } = await makeLib("stamped-lib");
|
|
52
|
+
const stamp = await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
53
|
+
|
|
54
|
+
expect(stamp.origin).toBe("akanjs");
|
|
55
|
+
expect(await source.read()).toEqual(stamp);
|
|
56
|
+
expect((await source.status()).drift).toBe("clean");
|
|
57
|
+
|
|
58
|
+
const manifest = await lib.getPackageJson();
|
|
59
|
+
expect(manifest.name).toBe("@stamped-lib");
|
|
60
|
+
expect((manifest.akan as { source: { sha: string } }).source.sha).toBe("3.0.0");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("detects an edit to library source as drift", async () => {
|
|
64
|
+
const { root, source } = await makeLib("drift-lib");
|
|
65
|
+
await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
66
|
+
await write(path.join(root, "libs/drift-lib/common/helper.ts"), "export const helper = 2;\n");
|
|
67
|
+
|
|
68
|
+
expect((await source.status()).drift).toBe("drifted");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("survives every other akan write to the manifest", async () => {
|
|
72
|
+
const { lib, source } = await makeLib("merge-lib");
|
|
73
|
+
const stamp = await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
74
|
+
const manifest = await lib.getPackageJson();
|
|
75
|
+
await lib.setPackageJson({ ...manifest, dependencies: { lodash: "4.0.0" } });
|
|
76
|
+
|
|
77
|
+
expect(await source.read()).toEqual(stamp);
|
|
78
|
+
expect((await source.status()).drift).toBe("drifted");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("leaves env values out of the hash — they belong to the installing workspace", async () => {
|
|
82
|
+
const { root, source } = await makeLib("env-lib");
|
|
83
|
+
await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
84
|
+
await write(path.join(root, "libs/env-lib/env/env.server.testing.ts"), "export const env = { key: 999 };\n");
|
|
85
|
+
|
|
86
|
+
expect((await source.status()).drift).toBe("clean");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("formatLibStatuses", () => {
|
|
91
|
+
test("marks drifted libraries and counts them", () => {
|
|
92
|
+
const text = formatLibStatuses([
|
|
93
|
+
{
|
|
94
|
+
lib: "util",
|
|
95
|
+
drift: "clean",
|
|
96
|
+
hash: "a".repeat(32),
|
|
97
|
+
stamp: { origin: "akanjs", sha: "3.0.0", hash: "a".repeat(32), syncedAt: "now" },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
lib: "shared",
|
|
101
|
+
drift: "drifted",
|
|
102
|
+
hash: "b".repeat(32),
|
|
103
|
+
stamp: { origin: "akanjs", sha: "3.0.0", hash: "a".repeat(32), syncedAt: "now" },
|
|
104
|
+
},
|
|
105
|
+
{ lib: "local", drift: "unstamped", hash: "c".repeat(32), stamp: null },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
expect(text).toContain("DRIFTED libs/shared akanjs@3.0.0");
|
|
109
|
+
expect(text).toContain("unstamped libs/local no akan.source in package.json");
|
|
110
|
+
expect(text).toContain("drifted: 1 / 3");
|
|
111
|
+
});
|
|
112
|
+
});
|
package/libSource.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { LibExecutor } from "./executors";
|
|
2
|
+
import type { PackageJson } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface LibSourceStamp {
|
|
5
|
+
/** Where the copy came from: a git remote URL, or `akanjs` for the published package. */
|
|
6
|
+
origin: string;
|
|
7
|
+
/** The origin's commit sha, or the package version when the origin is a registry. */
|
|
8
|
+
sha: string;
|
|
9
|
+
/** Content hash of the library as installed, so a later edit is detectable without the origin. */
|
|
10
|
+
hash: string;
|
|
11
|
+
syncedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type LibDrift = "clean" | "drifted" | "unstamped";
|
|
15
|
+
|
|
16
|
+
export interface LibStatus {
|
|
17
|
+
lib: string;
|
|
18
|
+
drift: LibDrift;
|
|
19
|
+
stamp: LibSourceStamp | null;
|
|
20
|
+
hash: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A library's origin, recorded in its own `package.json` under an `akan.source` key.
|
|
25
|
+
*
|
|
26
|
+
* The key rides `package.json` rather than a file of its own because every akan write to a library's
|
|
27
|
+
* manifest is a spread of the existing object (`LibExecutor.syncPackageJson`), so an unknown top-level
|
|
28
|
+
* key survives — while a new root file would have to be added to `libRootAllowedFiles` before
|
|
29
|
+
* `akan sync` stopped rejecting it.
|
|
30
|
+
*/
|
|
31
|
+
export class LibSource {
|
|
32
|
+
static readonly manifestKey = "akan";
|
|
33
|
+
/**
|
|
34
|
+
* Left out of the hash. `env/` holds per-deployment values that belong to the workspace the library
|
|
35
|
+
* was installed into, not to the origin, so an env edit is not drift.
|
|
36
|
+
*/
|
|
37
|
+
static readonly unhashedDirs = ["env"];
|
|
38
|
+
|
|
39
|
+
#lib: LibExecutor;
|
|
40
|
+
constructor(lib: LibExecutor) {
|
|
41
|
+
this.#lib = lib;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get #prefix() {
|
|
45
|
+
return `libs/${this.#lib.name}/`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#isHashed(file: string) {
|
|
49
|
+
const relative = file.slice(this.#prefix.length);
|
|
50
|
+
return !LibSource.unhashedDirs.includes(relative.split("/")[0] ?? "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `package.json` cannot hash the stamp it carries, so the key comes off before hashing. */
|
|
54
|
+
async #hashableContent(file: string) {
|
|
55
|
+
const content = await this.#lib.workspace.readFile(file);
|
|
56
|
+
if (file !== `${this.#prefix}package.json`) return content;
|
|
57
|
+
const manifest = JSON.parse(content) as PackageJson;
|
|
58
|
+
delete manifest[LibSource.manifestKey];
|
|
59
|
+
return JSON.stringify(manifest);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hash over the library's own files. Untracked files count: a freshly copied library is not committed
|
|
64
|
+
* yet, and its hash has to be the same one a later `status` recomputes.
|
|
65
|
+
*/
|
|
66
|
+
async computeHash() {
|
|
67
|
+
const files = (await this.#lib.workspace.listGitFiles([`libs/${this.#lib.name}`], { untracked: true })).filter(
|
|
68
|
+
(file) => this.#isHashed(file),
|
|
69
|
+
);
|
|
70
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
hasher.update(file);
|
|
73
|
+
hasher.update("\0");
|
|
74
|
+
hasher.update(await this.#hashableContent(file));
|
|
75
|
+
hasher.update("\0");
|
|
76
|
+
}
|
|
77
|
+
return hasher.digest("hex").slice(0, 32);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async read(): Promise<LibSourceStamp | null> {
|
|
81
|
+
const manifest = await this.#lib.getPackageJson();
|
|
82
|
+
const akan = manifest[LibSource.manifestKey] as { source?: LibSourceStamp } | undefined;
|
|
83
|
+
return akan?.source ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async write({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
|
|
87
|
+
const [manifest, hash] = await Promise.all([this.#lib.getPackageJson(), this.computeHash()]);
|
|
88
|
+
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
89
|
+
const stamp: LibSourceStamp = { origin, sha, hash, syncedAt: new Date().toISOString() };
|
|
90
|
+
await this.#lib.setPackageJson({ ...manifest, [LibSource.manifestKey]: { ...akan, source: stamp } });
|
|
91
|
+
return stamp;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Writes the stamp only when it would change. `syncedAt` moves on every write, so an unconditional
|
|
96
|
+
* write leaves the manifest dirty and defeats an idempotent caller — the hash is computed with the
|
|
97
|
+
* stamp removed, so comparing it first is sound.
|
|
98
|
+
*/
|
|
99
|
+
async syncStamp({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
|
|
100
|
+
const [current, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
101
|
+
if (current?.origin === origin && current.sha === sha && current.hash === hash)
|
|
102
|
+
return { stamp: current, changed: false };
|
|
103
|
+
return { stamp: await this.write({ origin, sha }), changed: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async status(): Promise<LibStatus> {
|
|
107
|
+
const [stamp, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
108
|
+
const drift = !stamp ? "unstamped" : stamp.hash === hash ? "clean" : "drifted";
|
|
109
|
+
return { lib: this.#lib.name, drift, stamp, hash };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatLibStatuses(statuses: LibStatus[]) {
|
|
114
|
+
const marks = { clean: "clean ", drifted: "DRIFTED ", unstamped: "unstamped" } as const;
|
|
115
|
+
const sections = [
|
|
116
|
+
"Akan Library Source Status",
|
|
117
|
+
"",
|
|
118
|
+
...statuses.map((status) => {
|
|
119
|
+
const origin = status.stamp ? `${status.stamp.origin}@${status.stamp.sha}` : "no akan.source in package.json";
|
|
120
|
+
return ` ${marks[status.drift]} libs/${status.lib} ${origin}`;
|
|
121
|
+
}),
|
|
122
|
+
"",
|
|
123
|
+
`drifted: ${statuses.filter((status) => status.drift === "drifted").length} / ${statuses.length}`,
|
|
124
|
+
];
|
|
125
|
+
return sections.join("\n");
|
|
126
|
+
}
|