@akanjs/devkit 3.0.0-alpha.10 → 3.0.0-alpha.100
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
package/biome.base.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
|
|
3
3
|
"vcs": {
|
|
4
4
|
"enabled": true,
|
|
5
5
|
"clientKind": "git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"noArrayIndexKey": "off",
|
|
26
26
|
"noShadowRestrictedNames": "off",
|
|
27
27
|
"noUnnecessaryConditions": {
|
|
28
|
-
"level": "
|
|
28
|
+
"level": "warn"
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"correctness": {
|
|
@@ -117,7 +117,9 @@
|
|
|
117
117
|
"libs/*/client.ts",
|
|
118
118
|
"libs/*/server.ts",
|
|
119
119
|
"libs/*/index.ts",
|
|
120
|
-
"libs/*/lib/*/index.tsx"
|
|
120
|
+
"libs/*/lib/*/index.tsx",
|
|
121
|
+
"apps/*/lib/__lib/**",
|
|
122
|
+
"libs/*/lib/__lib/**"
|
|
121
123
|
],
|
|
122
124
|
"linter": { "enabled": false },
|
|
123
125
|
"formatter": { "enabled": false },
|
|
@@ -140,28 +142,65 @@
|
|
|
140
142
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
|
|
141
143
|
},
|
|
142
144
|
{
|
|
143
|
-
"includes": [
|
|
145
|
+
"includes": [
|
|
146
|
+
"{apps,libs}/**/page/**/*.ts",
|
|
147
|
+
"{apps,libs}/**/page/**/*.tsx",
|
|
148
|
+
"{apps,libs}/**/*.Unit.tsx",
|
|
149
|
+
"{apps,libs}/**/*.View.tsx"
|
|
150
|
+
],
|
|
144
151
|
"plugins": [
|
|
145
152
|
"./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
|
|
146
153
|
"./node_modules/@akanjs/devkit/lint/no-use-client-in-server.grit"
|
|
147
154
|
]
|
|
148
155
|
},
|
|
149
156
|
{
|
|
150
|
-
"includes": ["
|
|
157
|
+
"includes": ["{apps,libs}/**/page/**/*.ts", "{apps,libs}/**/page/**/*.tsx"],
|
|
151
158
|
"plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
|
|
152
159
|
},
|
|
153
160
|
{
|
|
154
|
-
"includes": [
|
|
161
|
+
"includes": [
|
|
162
|
+
"{apps,libs}/**/*.constant.ts",
|
|
163
|
+
"{apps,libs}/**/*.document.ts",
|
|
164
|
+
"{apps,libs}/**/*.service.ts",
|
|
165
|
+
"{apps,libs}/**/*.store.ts"
|
|
166
|
+
],
|
|
155
167
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-js-private-class-method.grit"]
|
|
156
168
|
},
|
|
157
169
|
{
|
|
158
|
-
"includes": ["
|
|
170
|
+
"includes": ["{apps,libs}/**/*.store.ts"],
|
|
159
171
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
|
|
160
172
|
},
|
|
161
173
|
{
|
|
162
|
-
"includes": ["
|
|
174
|
+
"includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
|
|
175
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"includes": ["{apps,libs}/**/*.signal.ts"],
|
|
163
179
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit"]
|
|
164
180
|
},
|
|
181
|
+
{
|
|
182
|
+
"includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
|
|
183
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"includes": ["{apps,libs}/**/ui/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
|
|
187
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-async-component-in-ui.grit"]
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
|
|
191
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"includes": [
|
|
195
|
+
"{apps,libs}/**/*.ts",
|
|
196
|
+
"{apps,libs}/**/*.tsx",
|
|
197
|
+
"!**/*.test.ts",
|
|
198
|
+
"!**/*.test.tsx",
|
|
199
|
+
"!**/*.spec.ts",
|
|
200
|
+
"!**/*.spec.tsx"
|
|
201
|
+
],
|
|
202
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit"]
|
|
203
|
+
},
|
|
165
204
|
{
|
|
166
205
|
"includes": [
|
|
167
206
|
"**/ui/**/*.ts",
|
|
@@ -186,46 +225,46 @@
|
|
|
186
225
|
},
|
|
187
226
|
{
|
|
188
227
|
"includes": [
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
228
|
+
"{apps,libs}/**/*.constant.ts",
|
|
229
|
+
"{apps,libs}/**/*.dictionary.ts",
|
|
230
|
+
"{apps,libs}/**/*.document.ts",
|
|
231
|
+
"{apps,libs}/**/*.service.ts",
|
|
232
|
+
"{apps,libs}/**/*.signal.ts",
|
|
233
|
+
"{apps,libs}/**/*.store.ts",
|
|
234
|
+
"{apps,libs}/**/*.Template.tsx",
|
|
235
|
+
"{apps,libs}/**/*.Unit.tsx",
|
|
236
|
+
"{apps,libs}/**/*.Util.tsx",
|
|
237
|
+
"{apps,libs}/**/*.View.tsx",
|
|
238
|
+
"{apps,libs}/**/*.Zone.tsx"
|
|
200
239
|
],
|
|
201
240
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"]
|
|
202
241
|
},
|
|
203
242
|
{
|
|
204
243
|
"includes": [
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
214
|
-
"
|
|
215
|
-
"
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
227
|
-
"
|
|
228
|
-
"
|
|
244
|
+
"{apps,libs}/**/page/**/*.ts",
|
|
245
|
+
"{apps,libs}/**/page/**/*.tsx",
|
|
246
|
+
"{apps,libs}/**/index.ts",
|
|
247
|
+
"{apps,libs}/**/index.tsx",
|
|
248
|
+
"{apps,libs}/**/cnst.ts",
|
|
249
|
+
"{apps,libs}/**/db.ts",
|
|
250
|
+
"{apps,libs}/**/dict.ts",
|
|
251
|
+
"{apps,libs}/**/option.ts",
|
|
252
|
+
"{apps,libs}/**/sig.ts",
|
|
253
|
+
"{apps,libs}/**/srv.ts",
|
|
254
|
+
"{apps,libs}/**/st.ts",
|
|
255
|
+
"{apps,libs}/**/*.constant.ts",
|
|
256
|
+
"{apps,libs}/**/*.dictionary.ts",
|
|
257
|
+
"{apps,libs}/**/*.document.ts",
|
|
258
|
+
"{apps,libs}/**/*.service.ts",
|
|
259
|
+
"{apps,libs}/**/*.signal.ts",
|
|
260
|
+
"{apps,libs}/**/*.signal.test.ts",
|
|
261
|
+
"{apps,libs}/**/*.service.test.ts",
|
|
262
|
+
"{apps,libs}/**/*.store.ts",
|
|
263
|
+
"{apps,libs}/**/*.Template.tsx",
|
|
264
|
+
"{apps,libs}/**/*.Unit.tsx",
|
|
265
|
+
"{apps,libs}/**/*.Util.tsx",
|
|
266
|
+
"{apps,libs}/**/*.View.tsx",
|
|
267
|
+
"{apps,libs}/**/*.Zone.tsx"
|
|
229
268
|
],
|
|
230
269
|
"plugins": [
|
|
231
270
|
"./node_modules/@akanjs/devkit/lint/no-import-external-library.grit",
|
package/biomeBase.ts
CHANGED
|
@@ -6,4 +6,4 @@ export const biomeBaseConfig = "@akanjs/devkit/biome.base.json";
|
|
|
6
6
|
// Biome disagrees with the shipped base config therefore fails to load it at all, which is why the version is
|
|
7
7
|
// pinned here instead of resolved to latest at create time. Bump this and `biome.base.json` in one commit, and run
|
|
8
8
|
// `biome migrate --write` in the workspace root and in `pkgs/@akanjs/devkit` so both configs move together.
|
|
9
|
-
export const biomeVersion = "2.5.
|
|
9
|
+
export const biomeVersion = "2.5.12";
|
package/capacitorApp.ts
CHANGED
|
@@ -123,6 +123,16 @@ interface MaterializeCapacitorConfigOptions {
|
|
|
123
123
|
}
|
|
124
124
|
type MobilePlatform = "ios" | "android";
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* `AppExecutor.spawn`'s own options plus the two the Capacitor config is written from before the spawn:
|
|
128
|
+
* the platform the command targets, and — for iOS — whether it targets a device or a simulator, which
|
|
129
|
+
* decide the `capacitor.config.json` that command reads.
|
|
130
|
+
*/
|
|
131
|
+
type SpawnMobileOptions = Parameters<AppExecutor["spawn"]>[2] & {
|
|
132
|
+
platform?: MobilePlatform;
|
|
133
|
+
iosRunTargetKind?: IosRunTargetKind;
|
|
134
|
+
};
|
|
135
|
+
|
|
126
136
|
export interface LocalDevHostResolution {
|
|
127
137
|
host: string;
|
|
128
138
|
source: "override" | "detected" | "loopback" | "platform";
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
2
2
|
import dayjs from "dayjs";
|
|
3
3
|
import { FileSys } from "../fileSys";
|
|
4
4
|
import {
|
|
@@ -29,9 +29,16 @@ export class GlobalConfig {
|
|
|
29
29
|
remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* This file holds the cloud jwt, a refresh token that does not expire, and the LLM api key, so it is
|
|
34
|
+
* written owner-only — the same `0600` the runtime gives its control socket. `Bun.write` takes no mode
|
|
35
|
+
* and lands on `0666 & ~umask` (0644 on a default shell), so the mode is applied after the write; an
|
|
36
|
+
* existing world-readable file is tightened by the next write rather than left as it was found.
|
|
37
|
+
*/
|
|
32
38
|
static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
|
|
33
|
-
await mkdir(basePath, { recursive: true });
|
|
39
|
+
await mkdir(basePath, { recursive: true, mode: 0o700 });
|
|
34
40
|
await Bun.write(configPath, JSON.stringify(akanConfig, null, 2));
|
|
41
|
+
await chmod(configPath, 0o600);
|
|
35
42
|
}
|
|
36
43
|
static async getHostConfig(host = GlobalConfig.akanCloudHost): Promise<HostConfig> {
|
|
37
44
|
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodegenLock } from "./codegenLock";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
const makeRoot = async () => {
|
|
9
|
+
const root = await mkdtemp(path.join(tmpdir(), "akan-codegen-lock-"));
|
|
10
|
+
roots.push(root);
|
|
11
|
+
return root;
|
|
12
|
+
};
|
|
13
|
+
const seedHolder = async (root: string, holder: unknown) => {
|
|
14
|
+
const lockPath = CodegenLock.pathIn(root);
|
|
15
|
+
await Bun.write(lockPath, typeof holder === "string" ? holder : JSON.stringify(holder));
|
|
16
|
+
return lockPath;
|
|
17
|
+
};
|
|
18
|
+
/** A pid that cannot be alive: `kill(0)` on it is ESRCH on every platform this runs on. */
|
|
19
|
+
const deadPid = 0x7ffffff;
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("CodegenLock", () => {
|
|
26
|
+
test("serializes concurrent callers in the same process", async () => {
|
|
27
|
+
const root = await makeRoot();
|
|
28
|
+
const order: string[] = [];
|
|
29
|
+
const body = async (name: string) => {
|
|
30
|
+
order.push(`${name}:in`);
|
|
31
|
+
await Bun.sleep(20);
|
|
32
|
+
order.push(`${name}:out`);
|
|
33
|
+
};
|
|
34
|
+
await Promise.all([
|
|
35
|
+
CodegenLock.run(root, "a", () => body("a")),
|
|
36
|
+
CodegenLock.run(root, "b", () => body("b")),
|
|
37
|
+
CodegenLock.run(root, "c", () => body("c")),
|
|
38
|
+
]);
|
|
39
|
+
for (const name of ["a", "b", "c"]) {
|
|
40
|
+
const enter = order.indexOf(`${name}:in`);
|
|
41
|
+
const leave = order.indexOf(`${name}:out`);
|
|
42
|
+
expect(leave).toBe(enter + 1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("releases the lock file even when the body throws", async () => {
|
|
47
|
+
const root = await makeRoot();
|
|
48
|
+
const lockPath = CodegenLock.pathIn(root);
|
|
49
|
+
await expect(
|
|
50
|
+
CodegenLock.run(root, "boom", async () => {
|
|
51
|
+
expect(await Bun.file(lockPath).exists()).toBe(true);
|
|
52
|
+
throw new Error("boom");
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow("boom");
|
|
55
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("writes a holder naming this process", async () => {
|
|
59
|
+
const root = await makeRoot();
|
|
60
|
+
const lockPath = CodegenLock.pathIn(root);
|
|
61
|
+
const holder = await CodegenLock.run(root, "scan:minimal", async () => await readFile(lockPath, "utf8"));
|
|
62
|
+
expect(JSON.parse(holder)).toMatchObject({ pid: process.pid, label: "scan:minimal" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reclaims a lock whose holder is gone", async () => {
|
|
66
|
+
const root = await makeRoot();
|
|
67
|
+
const lockPath = await seedHolder(root, { pid: deadPid, at: Date.now(), label: "crashed" });
|
|
68
|
+
const started = Date.now();
|
|
69
|
+
const holder = await CodegenLock.run(root, "next", async () => await readFile(lockPath, "utf8"));
|
|
70
|
+
expect(JSON.parse(holder).pid).toBe(process.pid);
|
|
71
|
+
expect(Date.now() - started).toBeLessThan(CodegenLock.waitTimeoutMs);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("respects a live holder until the wait expires, then proceeds without the lock", async () => {
|
|
75
|
+
const root = await makeRoot();
|
|
76
|
+
const lockPath = await seedHolder(root, { pid: process.pid, at: Date.now(), label: "other-session" });
|
|
77
|
+
const waitTimeoutMs = CodegenLock.waitTimeoutMs;
|
|
78
|
+
Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: 150, configurable: true });
|
|
79
|
+
try {
|
|
80
|
+
let ran = false;
|
|
81
|
+
await CodegenLock.run(root, "blocked", async () => {
|
|
82
|
+
ran = true;
|
|
83
|
+
// The foreign holder is left in place: nothing may delete a lock it does not hold.
|
|
84
|
+
expect(JSON.parse(await readFile(lockPath, "utf8")).label).toBe("other-session");
|
|
85
|
+
});
|
|
86
|
+
expect(ran).toBe(true);
|
|
87
|
+
expect(await Bun.file(lockPath).exists()).toBe(true);
|
|
88
|
+
} finally {
|
|
89
|
+
Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("keeps a young unreadable lock but reclaims a stale one", async () => {
|
|
94
|
+
const young = await makeRoot();
|
|
95
|
+
await seedHolder(young, "");
|
|
96
|
+
const waitTimeoutMs = CodegenLock.waitTimeoutMs;
|
|
97
|
+
Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: 150, configurable: true });
|
|
98
|
+
try {
|
|
99
|
+
await CodegenLock.run(young, "young", async () => undefined);
|
|
100
|
+
expect(await Bun.file(CodegenLock.pathIn(young)).exists()).toBe(true);
|
|
101
|
+
} finally {
|
|
102
|
+
Object.defineProperty(CodegenLock, "waitTimeoutMs", { value: waitTimeoutMs, configurable: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stale = await makeRoot();
|
|
106
|
+
const stalePath = await seedHolder(stale, "");
|
|
107
|
+
const aged = new Date(Date.now() - CodegenLock.unknownHolderStaleMs - 1_000);
|
|
108
|
+
await utimes(stalePath, aged, aged);
|
|
109
|
+
const holder = await CodegenLock.run(stale, "stale", async () => await readFile(stalePath, "utf8"));
|
|
110
|
+
expect(JSON.parse(holder).pid).toBe(process.pid);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("blocks a second process for as long as it holds the lock", async () => {
|
|
114
|
+
const root = await makeRoot();
|
|
115
|
+
const lockPath = CodegenLock.pathIn(root);
|
|
116
|
+
const script = path.join(root, "holder.ts");
|
|
117
|
+
await writeFile(
|
|
118
|
+
script,
|
|
119
|
+
`import { CodegenLock } from ${JSON.stringify(path.resolve(import.meta.dir, "codegenLock.ts"))};
|
|
120
|
+
await CodegenLock.run(${JSON.stringify(root)}, "child", async () => {
|
|
121
|
+
process.stdout.write("held\\n");
|
|
122
|
+
await Bun.sleep(400);
|
|
123
|
+
});
|
|
124
|
+
`,
|
|
125
|
+
);
|
|
126
|
+
const child = Bun.spawn(["bun", script], { stdio: ["ignore", "pipe", "inherit"] });
|
|
127
|
+
const reader = child.stdout.getReader();
|
|
128
|
+
await reader.read();
|
|
129
|
+
reader.releaseLock();
|
|
130
|
+
|
|
131
|
+
const started = Date.now();
|
|
132
|
+
await CodegenLock.run(root, "parent", async () => undefined);
|
|
133
|
+
expect(Date.now() - started).toBeGreaterThan(100);
|
|
134
|
+
await child.exited;
|
|
135
|
+
expect(await stat(lockPath).catch(() => null)).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
package/codegenLock.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Logger } from "akanjs/common";
|
|
4
|
+
|
|
5
|
+
interface LockHolder {
|
|
6
|
+
pid: number;
|
|
7
|
+
at: number;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A workspace-wide mutex over the generated source files every dev server in the workspace rewrites.
|
|
13
|
+
*
|
|
14
|
+
* `WatchRootResolver` narrows each dev server to its own app and its own lib dependencies, but two apps
|
|
15
|
+
* that share a lib still both watch it, so a save there reaches both builders and both regenerate the
|
|
16
|
+
* same barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is
|
|
17
|
+
* a rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
|
|
18
|
+
*
|
|
19
|
+
* A wait that expires proceeds *without* the lock rather than failing: this sits on the dev server's
|
|
20
|
+
* hot path, and stalling the file watcher is worse than the torn read `FileSys.writeTextAtomic` already
|
|
21
|
+
* prevents on its own.
|
|
22
|
+
*/
|
|
23
|
+
export class CodegenLock {
|
|
24
|
+
static readonly fileName = "codegen.lock";
|
|
25
|
+
static readonly waitTimeoutMs = 10_000;
|
|
26
|
+
/**
|
|
27
|
+
* How long an unreadable lock file is respected. It covers the window between the exclusive create
|
|
28
|
+
* and the holder write, where the file exists but names no pid yet — a young one is somebody else
|
|
29
|
+
* mid-acquire, not a corpse.
|
|
30
|
+
*/
|
|
31
|
+
static readonly unknownHolderStaleMs = 60_000;
|
|
32
|
+
static readonly #pollMs = 25;
|
|
33
|
+
static readonly #logger = new Logger("CodegenLock");
|
|
34
|
+
/** Serializes callers inside this process, which one `O_EXCL` file cannot tell apart. */
|
|
35
|
+
static #queue: Promise<void> = Promise.resolve();
|
|
36
|
+
|
|
37
|
+
static pathIn(workspaceRoot: string) {
|
|
38
|
+
return path.join(workspaceRoot, "local", ".akan", CodegenLock.fileName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static async run<T>(workspaceRoot: string, label: string, fn: () => Promise<T>): Promise<T> {
|
|
42
|
+
const ahead = CodegenLock.#queue;
|
|
43
|
+
let done!: () => void;
|
|
44
|
+
CodegenLock.#queue = new Promise<void>((resolve) => {
|
|
45
|
+
done = resolve;
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
await CodegenLock.#waitForQueue(ahead, label);
|
|
49
|
+
return await CodegenLock.#withFileLock(workspaceRoot, label, fn);
|
|
50
|
+
} finally {
|
|
51
|
+
done();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static async #waitForQueue(ahead: Promise<void>, label: string) {
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
const expired = new Promise<"expired">((resolve) => {
|
|
58
|
+
timer = setTimeout(() => resolve("expired"), CodegenLock.waitTimeoutMs);
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
if ((await Promise.race([ahead.then(() => "done" as const), expired])) === "expired")
|
|
62
|
+
CodegenLock.#logger.warn(
|
|
63
|
+
`codegen lock queued past ${CodegenLock.waitTimeoutMs}ms in this process; continuing without waiting (${label})`,
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
if (timer) clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static async #withFileLock<T>(workspaceRoot: string, label: string, fn: () => Promise<T>): Promise<T> {
|
|
71
|
+
const lockPath = CodegenLock.pathIn(workspaceRoot);
|
|
72
|
+
await mkdir(path.dirname(lockPath), { recursive: true }).catch(() => undefined);
|
|
73
|
+
const held = await CodegenLock.#acquire(lockPath, label);
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
} finally {
|
|
77
|
+
if (held) await rm(lockPath, { force: true }).catch(() => undefined);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static async #acquire(lockPath: string, label: string): Promise<boolean> {
|
|
82
|
+
const deadline = Date.now() + CodegenLock.waitTimeoutMs;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const handle = await open(lockPath, "wx").catch(() => null);
|
|
85
|
+
if (handle) {
|
|
86
|
+
await handle
|
|
87
|
+
.writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies LockHolder))
|
|
88
|
+
.catch(() => undefined);
|
|
89
|
+
await handle.close().catch(() => undefined);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (await CodegenLock.#reclaimIfAbandoned(lockPath)) continue;
|
|
93
|
+
if (Date.now() >= deadline) {
|
|
94
|
+
CodegenLock.#logger.warn(
|
|
95
|
+
`codegen lock at ${lockPath} held past ${CodegenLock.waitTimeoutMs}ms; continuing without it (${label})`,
|
|
96
|
+
);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
await Bun.sleep(CodegenLock.#pollMs);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A live holder is never reclaimed — the wait timeout is what bounds a pathologically slow one. */
|
|
104
|
+
static async #reclaimIfAbandoned(lockPath: string): Promise<boolean> {
|
|
105
|
+
const info = await stat(lockPath).catch(() => null);
|
|
106
|
+
if (!info) return true;
|
|
107
|
+
const holder = CodegenLock.#parseHolder(await readFile(lockPath, "utf8").catch(() => ""));
|
|
108
|
+
if (holder) {
|
|
109
|
+
if (CodegenLock.#isAlive(holder.pid)) return false;
|
|
110
|
+
} else if (Date.now() - info.mtimeMs < CodegenLock.unknownHolderStaleMs) return false;
|
|
111
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static #parseHolder(raw: string): LockHolder | null {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(raw) as Partial<LockHolder>;
|
|
118
|
+
if (typeof parsed.pid !== "number" || typeof parsed.at !== "number") return null;
|
|
119
|
+
return { pid: parsed.pid, at: parsed.at, label: typeof parsed.label === "string" ? parsed.label : "" };
|
|
120
|
+
} catch {
|
|
121
|
+
// A truncated holder file names no pid, so it is aged by mtime instead.
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #isAlive(pid: number): boolean {
|
|
127
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// EPERM is a pid that exists under another user, which still holds the lock.
|
|
133
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -12,7 +12,7 @@ import { COMMAND_META, type CommandCls } from "./targetMeta";
|
|
|
12
12
|
export const argTypes = ["Argument", "Option"] as const;
|
|
13
13
|
export type ArgType = (typeof argTypes)[number];
|
|
14
14
|
|
|
15
|
-
export const internalArgTypes = ["Workspace", "App", "Lib", "Sys", "Pkg", "Module", "Exec"] as const;
|
|
15
|
+
export const internalArgTypes = ["Workspace", "App", "Apps", "Lib", "Sys", "Pkg", "Module", "Exec"] as const;
|
|
16
16
|
export type InternalArgType = (typeof internalArgTypes)[number];
|
|
17
17
|
|
|
18
18
|
export type PrimitiveArgType = StringConstructor | NumberConstructor | BooleanConstructor;
|
|
@@ -83,6 +83,14 @@ export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPri
|
|
|
83
83
|
export const App = createInternalArgToken<AppExecutor, "App">("App");
|
|
84
84
|
export type App = AppExecutor;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* One or more apps, from a variadic positional (`akan start a b`, `akan start a,b`, `akan start all`)
|
|
88
|
+
* or a checkbox when none is named. Reach for it only where running several is meaningful — every other
|
|
89
|
+
* command takes `App`, whose single-select is unchanged.
|
|
90
|
+
*/
|
|
91
|
+
export const Apps = createInternalArgToken<AppExecutor[], "Apps">("Apps");
|
|
92
|
+
export type Apps = AppExecutor[];
|
|
93
|
+
|
|
86
94
|
export const Lib = createInternalArgToken<LibExecutor, "Lib">("Lib");
|
|
87
95
|
export type Lib = LibExecutor;
|
|
88
96
|
|