@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.3
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 +52 -1
- package/akanContext.ts +1 -3
- package/akanMcpContract.ts +5 -4
- package/artifact/storeBootstrap.ts +3 -2
- package/codeAgent/skills/akan-scaffold/SKILL.md +2 -1
- package/commandDecorators/command.ts +35 -18
- package/commandDecorators/commandDecorators.test.ts +75 -3
- package/commandDecorators/targetMeta.ts +2 -0
- package/constantFieldNames.test.ts +34 -0
- package/constantFieldNames.ts +18 -0
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/clientEntryDiscovery.test.ts +52 -0
- package/frontendBuild/clientEntryDiscovery.ts +28 -3
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/package.json +3 -3
- package/preview/PreviewView.ts +2 -0
- package/preview/previewView.test.ts +46 -9
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/workflow/artifacts.ts +17 -0
- package/workflow/index.ts +1 -0
- package/workflow/plainName.ts +15 -0
- package/workflow/plan.ts +55 -21
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,43 @@
|
|
|
34
34
|
- **`pkgs/akanjs/ARCHITECTURE.md`** pairs a hand-kept walk through the processes and a request with a generated part between markers. `akan sync-architecture` regenerates that part, and a devkit test fails when it is stale, when a channel is gone, when two facets load values from each other, when a message lacks a recognised sender or handler outside a short list whose other end lies beyond the framework source, or when the hand-kept part names a class the source no longer has.
|
|
35
35
|
- **`AkanIpcMessage` no longer lists `backend-ready`.** The gateway sends it to the dev host, which reads it as a `BuilderMessage`, where it stays; no gateway or child ever handled it.
|
|
36
36
|
|
|
37
|
+
- 4d61170: The Akan Graph follows an app's adaptor wiring further. `applyAdaptor(LlmAdaptorRole, ChatCompletionsLlm)` in an app's or lib's `option.ts` draws a `binds` edge from the role, `adaptor::LlmAdaptorRole`, to the implementation it binds, labelled with the app or lib and located at the call, so a method that calls a plugged role reaches the class that answers it. `get_graph` scoped to an app keeps that app's own bindings, and a lib's binding of a role the app does not bind itself; scoped to a lib or a module it keeps every binding of a role it reaches. A Change Map carries every binding of a role it shows, changed or not, so the binding an app runs stays in view; a binding the app removed no longer hides the lib binding it now runs; and Studio's Change Map for one app keeps only the bindings that app runs. An app service holds the fields its module's lib services plug (`...user.services`), so `this.notifier.getMemo()` in an app method links to the `Notifier` a lib's `MemoService` plugs, where it linked to the `memo` model; a service whose own builder declares the field (`notifier: use<…>()`) inherits nothing. A plug imported from a package outside the workspace, `plug(Stripe)` from `stripe-sdk`, or through a lib's re-export of it (`export { Stripe }`, `export { default as Stripe }`, or `export * from "stripe-sdk"` where the lib declares no `Stripe` itself), is `adaptor:stripe-sdk:Stripe` with the package in `detail`, where it linked to a same-name adaptor the app declares or to an unlocated `adaptor:libs/util:Stripe`. Two adaptors with the same class name in one app or lib are two nodes, each with its own location, digest and `plugs`, and a plug reaches the one its import names. A job's `detail` lists only the options its kind honours: `once` on an `initialize` job, `unlocked` on an `interval` job (`Bun.cron` never overlaps a cron job, so `lock: false` changes nothing there); `cron("0 * * * *" as const)` reads `0 * * * *`.
|
|
38
|
+
|
|
39
|
+
Ids and details that change for a saved graph or a Change Map base:
|
|
40
|
+
|
|
41
|
+
- While two adaptors of one owner share a class name, each is keyed by its file, `adaptor:apps/demo:srvkit/clockB.ts#Clock`; an adaptor whose name no other adaptor of its owner shares keeps `adaptor:<owner>:<Name>`. The Change Map treats the two forms of one declaration as one node, so a second same-name adaptor shows as one added node, not a removal of the first.
|
|
42
|
+
- A plug from a non-workspace package is `adaptor:<package>:<Name>` with the package as `detail`, where it was `adaptor::<Name>` or a workspace adaptor of that name.
|
|
43
|
+
- A job's `detail` drops `once` and `unlocked` where the runtime ignores them, and `as const` from a cron expression.
|
|
44
|
+
|
|
45
|
+
- **`binds`** joins `AkanGraphEdgeKind` in `akanjs/common`.
|
|
46
|
+
- **`FileFacts.adaptorBindings`** in `@akanjs/language`: each `applyAdaptor(role, adaptor)` call a file makes; `AkanIndex.setFile` also reads non-test files whose text mentions `applyAdaptor`. **`ServiceClassFact.fields`**: the names a service's builder declares.
|
|
47
|
+
|
|
48
|
+
- c723713: The Akan Graph keys a guard function by the file that declares it, locates more of a workspace's guards, and stops counting things that are not guards.
|
|
49
|
+
|
|
50
|
+
A guard function a route or layout imports is keyed by its file, as one the page declares already was: `guard:libs/shared:webkit/signedIn.ts#signedIn`. A `canEdit` in `webkit/` and one in `common/` are two nodes, each at its own declaration, and an edit to either marks only that one changed in the Change Map. A guard called through a namespace import (`auth.signedIn()`), or imported under a name a file re-exports (`export { signedIn as hasSession } from "./auth"`), reaches its declaration. A function the guard declares, or a parameter it binds, shadows an import of the same name only inside its own scope, so `[1].forEach((signedIn) => …)` no longer hides a real `signedIn()` beside it. A guard declared after another declarator (`export const ready = true, signedIn = …`) is found, and an overloaded guard's digest covers every signature. Only a file whose text declares or re-exports the name is parsed, so a guard that calls `usePage()` from the generated client no longer parses every page that names it.
|
|
51
|
+
|
|
52
|
+
A function is a guard factory only when it returns a guard, a class that implements `Guard` (also one handed to a wrapper, `withName(class implements Guard {…}, field)`) or a call to a factory, so `const guardCount = countGuards()` is no longer a guard. An import from a package outside the workspace (`@vendor/auth`) resolves to no guard: it no longer links a same-name workspace guard, and a vendor's `Public` no longer reads as the framework's. A factory imported from akanjs or a package makes no guard. A lib's `export *` chain, walked from the path the import names, is followed only when it reaches exactly one workspace lib declaring the name, none of its `export *` names a package, and the lib does not declare, destructure or export the name itself; otherwise the guard stays `guard:<lib>:<Name>` without a location, so a vendor shim no longer links another lib's guard. A class extending a base that is not a known guard is no guard, even when a lib declares a guard of that name.
|
|
53
|
+
|
|
54
|
+
More guards carry their location: a name a lib re-exports under another name (`export { Admin as AdminGuard }`) reaches the class, and one it re-exports from akanjs is `guard::Public`; `srvkit.SSO.Kakao` reads through the namespace to the lib's map; a map entry a factory makes (`SSO = { Kakao: ssoGuardOf("kakao") }`) is the guard `SSO.Kakao`; a local wrapper of an imported factory, a factory called through a namespace (`srvkit.resourceGuard("pin")`) and a class that extends a guard (`class DemoAdmin extends Admin`) and a factory a lib re-exports under another name (`export { resourceGuard as rg }`) make guards; a guard made in place, `guards: [canEdit("memo")]`, is a node at the call, keyed by its member, callee and ordinal so an edit to its arguments, or to another member's guards, keeps the id. Of two same-name guards in one owner, an import reaches the one its path names. A wrapper's digest covers every factory it can return, and an edit to a factory in another file marks the guards it makes changed instead of listing the file as unmapped.
|
|
55
|
+
|
|
56
|
+
In the Change Map, a declaration whose id moves between `kind:owner:Name` and `kind:owner:<file>#Name` at the same file is one node, so a second same-name guard shows as one addition, not the first one removed and re-added.
|
|
57
|
+
|
|
58
|
+
`get_graph` past the node limit names owners by the nodes it would answer with, changed pages included, so 650 changed routes no longer hide behind one changed lib method. A guard on `const route = page()…; export default route` is no longer read: `RouteSourceValidator` rejects that shape.
|
|
59
|
+
|
|
60
|
+
Ids and details that change for a saved graph or a Change Map base:
|
|
61
|
+
|
|
62
|
+
- An imported guard function's id names its file: `guard:libs/shared:signedIn` is now `guard:libs/shared:webkit/signedIn.ts#signedIn`.
|
|
63
|
+
- A guard name two files of one owner declare is keyed by file, `guard:apps/demo:srvkit/admin.ts#Admin`.
|
|
64
|
+
- A guard made in place is `guard:<owner>:<file>#<model>.<key>:<callee>@<n>`, the `n`th in-place call of that callee in that slice or endpoint key (`…/memo.signal.ts#memo.shareMemo:resourceGuard@1`). Its label, an endpoint's `detail` and the editor hover name the call: `resourceGuard("memo")` where `detail` read `resourceGuard`, or `allow(…)` when the arguments run past 32 characters or a line. A guard read through a namespace's map reads `srvkit.SSO.Kakao` where it read `Kakao`.
|
|
65
|
+
- A map entry a factory makes is `guard:libs/shared:SSO.Kakao` where it was `guard::Kakao` or `guard:<lib>:Kakao`, and a local map's `Roles.Owner` is `guard:apps/demo:Roles.Owner` where it linked `guard:apps/demo:Owner`.
|
|
66
|
+
- A guard a lib re-exports, under its own name or another, or from akanjs, takes the declaring owner's id (`guard:libs/util:Admin`, `guard::Public`) where it took the re-exporting lib's.
|
|
67
|
+
- A guard imported from an unknown package draws no edge.
|
|
68
|
+
- A name the old "sole visible lib" fallback linked through a lib that does not declare it (`guard:libs/billing:Admin` behind a vendor `export *`) now reads `guard:<lib>:<Name>` without a location, unless the lib's `export *` chain reaches exactly one workspace lib declaring it.
|
|
69
|
+
- A class extending a base that is not a known guard (`class Thing extends Base` with an ordinary `Base`) no longer has a guard node; an endpoint naming it in `guards` links an unlocated `guard:<owner>:Thing`.
|
|
70
|
+
|
|
71
|
+
- **`AkanGraphNode.sources`** in `akanjs/common`: the files besides `at.file` that a node's digest reads, set only with digests. The Change Map uses it to map those files and leaves it out of its answer.
|
|
72
|
+
- **The fact reader** in `@akanjs/language`: `GuardFactoryFact.via` is a list and gains `direct`; `GuardCallFact` gains `kind` (`call` or `extends`) and `scope`; `FileFacts` gains `guardLabels`, an in-place guard's name to its call. A slice's or endpoint's guard made in place is read as `<model>.<key>:<callee>@<n>`, and every in-place call is a `GuardCallFact`.
|
|
73
|
+
|
|
37
74
|
- 0351ad4: The Akan Graph keys each guard by the app or lib that declares it, `guard:libs/shared:Admin`, and follows the import that names it there, so `Admin as AdminGuard` reaches `Admin`, `srvkit.Admin` reaches the lib's class, and two libs' `Admin` stay two nodes. A guard read out of an object map, `SSO.Kakao`, resolves through the imports of the file that declares the map. A name a lib only re-exports reaches the class it re-exports. A guard a factory makes is a guard too, whether the factory sits in the same file, wraps another factory there, or is imported from another lib. A guard node carries its source location, `doc` (the comment above its declaration that says what it checks) and, in `detail`, its `scope` (`account` or `resource`). A factory-made guard takes the factory's scope, and takes its doc when no guard of that factory has its own. With digests, an edit to a guard or to the factory that makes it marks the guard changed in the Change Map. Every guard a workspace declares is a node, referenced or not. `Public` and `None` come from `akanjs`, carry no location and say what they do. Studio shows a node's `doc` in its tooltip.
|
|
38
75
|
|
|
39
76
|
Ids and details that change for a saved graph or a Change Map base:
|
|
@@ -128,6 +165,7 @@
|
|
|
128
165
|
- **`@akanjs/devkit`**: `applyTemplate` takes `dryRun` to list the files a template would write without writing them, and `WorkspaceExecutor.commit` takes an `identity` to commit under.
|
|
129
166
|
|
|
130
167
|
- 1870fcd: `akan verify` adds a `fix:` line to a module file's two-parent import that names the layer barrel to import from instead, such as `import from "@apps/<app>/common"`. The lint message itself now points at the layer barrel too.
|
|
168
|
+
- 76ab8d4: `akan doctor` exits with code 1 when its report says `"status": "failed"`, in text or JSON, with or without `--strict`, and for `--ios`, so the `agent:doctor` script (`akan doctor --strict --format json`) or a CI step fails on it. Before, it always exited 0. The full report still prints on stdout. A warning never changes the exit code: `--strict` still only decides which conventions count as errors, today a missing module abstract. Doctor's `dictionary-label-missing` now reads a constant's fields from its syntax, so a field that appears only in a comment or a string no longer asks for a label. A new `--sample` workspace no longer warns about `task`'s `priority`, `tags`, `assignee` and `completedAt` or `workHistory`'s `actor` and `previousStatus`, which its constants suggest only in comments.
|
|
131
169
|
- 245d318: A font declared on a root layout now gets its metric-adjusted fallback faces: `@font-face` rules named `"<font> fallback"` that draw a local system font with `size-adjust`, `ascent-override` and `descent-override` matched to the web font. The variable `--font-<name>` already named that family, but the build never wrote it, because fontaine's `readMetrics` takes a URL and returned nothing for the file path it was given. While a web font loaded (`font-display: swap`), text drew in the browser's default font, usually a serif, and moved when the web font arrived. The metrics are read from the source font, not the subset, so a subset without Latin letters (`subsets: ["ks-x-1001"]`, or only `subsetText`) still sizes the fallback correctly. A font whose metrics cannot be read keeps its face and gets no fallback, as before. Set `adjustFontFallback: false` on a font to keep the old output. Cached font output from `akan start` is regenerated once.
|
|
132
170
|
- 6f6cc2a: A fresh clone of a workspace now builds and starts after `bun install` alone. The env files that hold secrets stay gitignored, so a clone has none of them: before, `akan typecheck`, `akan build` and `akan verify` failed with `Cannot find module './env.server.local'`, and with no `.env` the CLI picked the `debug` environment, so `akan build` stopped asking for `AKAN_PUBLIC_REPO_NAME` and `AKAN_PUBLIC_SERVE_DOMAIN`.
|
|
133
171
|
|
|
@@ -177,11 +215,14 @@
|
|
|
177
215
|
- **The CSR build and its MCP App views** rewrite `akanjs/ui` imports to the components a file names, as the SSR route builds already did, so a page that imports `Link` no longer bundles every component.
|
|
178
216
|
- **Animations use `@react-spring/web`** instead of the `react-spring` umbrella package, which also installed the native, three.js, konva and zdog renderers.
|
|
179
217
|
|
|
218
|
+
- 58b7329: `akan create-scalar`, `remove-scalar`, `create-module`, `create-service`, `create-application`, `create-library`, `install-library` and `create-workspace --app` refuse a name that is not one plain path segment before they touch a file, such as `"../.." is no scalar name; use letters, digits, - and _, starting with a letter`. Before, the name went into the path unchecked: `akan remove-scalar ../.. <app>` deleted `apps/<app>` and exited 0, and the create commands wrote their files outside `lib/`, `apps/` or `libs/`. A scalar, module or service name is checked as a workflow's `module` input already was, an app or lib name as its `app` input; both checks now live in `@akanjs/devkit/workflow/plainName`, with `assertPlainName`.
|
|
219
|
+
- b4b8f85: `akan preview` and the code agent's page preview give a browser that died or was closed the full `startTimeoutMs` (120 s by default) for its next start. Before, after one page had run out of the start bound, a browser that then died while another page waited for it stayed marked as slow to start. The next page started a fresh browser within only its own `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
|
|
180
220
|
- 686e0b8: `akan preview` and the code agent's page preview bound a page only once the browser is up. Before, a process's first page shared its `timeoutMs` with starting the browser. On a cold machine where Chrome took longer than that to start, the first page failed as `Timed out loading …; the dev server may be rebuilding or stuck.`
|
|
181
221
|
|
|
182
222
|
Starting the browser now has its own bound of 120 s, `startTimeoutMs` on `PreviewView.probe`. Running out of it reads `Timed out starting the <engine> browser after 120 s.`, where the engine is `chrome` or `webkit`. A browser that misses its bound may still come up late, so later pages in that process try again, but each waits only its own `timeoutMs` for the browser. Once the browser answers, the full bound applies again. So a browser that never starts holds the first page for 120 s, where before it held it for `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
|
|
183
223
|
|
|
184
224
|
- eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`, `sitemap.xml` and `manifest.webmanifest` now open as written: no locale, and no CSR boot query under `--csr`. Every other path, including one that already starts with a locale or ends in a dotted segment such as `/release/1.2.3`, is a page and gets the default locale as before. The `akan-debug` skill and the `akan preview` reference say so.
|
|
225
|
+
- b101fcf: `akan remove-application`, `remove-library`, `remove-package`, `remove-module` and `remove-scalar` no longer delete a target they picked themselves. Left out, or named but not in the workspace, the target is refused in a terminal or not, with the choices and the line to rerun, such as `` Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-application <app>` (choices: pokedex). `` Before, `akan remove-application` in a one-app workspace removed `apps/<app>` without asking, and without a terminal `akan remove-scalar <name>` removed the scalar from the only app or lib. A terminal is no longer offered a select for these commands. Other commands still take the only app, or the only app or lib without a terminal, as before. A command declares this with `destructive: true` on its target.
|
|
185
226
|
- 4fcf44f: A new `rename-key` workflow renames a slice key or a Filter query key everywhere it is spelled. That covers the key itself, every name derived from it (`initMemoInPublic`, `memoListInPublic`, `queryByName`) in the app and every lib, and its module dictionary entry. It is the same rename the editor extension's F2 runs, so agents get it through `plan_workflow` / `apply_workflow` and the CLI through `akan workflow plan rename-key --app <app> --module <module> --slice <key> --to <newKey>` (or `--query <key>`). The plan lists each file it will change. A use that could also be another model's is left alone, and `akan verify` then reports it.
|
|
186
227
|
- 9a9e1fa: A server page from `akan build` now preloads its client modules from `<head>`, so a cold load stops waiting on chains of imports.
|
|
187
228
|
|
|
@@ -263,8 +304,13 @@
|
|
|
263
304
|
- **Bootstrap.** Each route's `"use client"` entry, every generated root layout and the SSR dev registry start with `import "@apps/<app>/lib/useClient"` and `import "@apps/<app>/lib/st"`, which load the client runtime and the store. They used to import `@apps/<app>/client` bare, and that bundled the whole barrel.
|
|
264
305
|
- **Narrowed barrel imports.** A named import from an app or lib `client` barrel is still narrowed to the files that define the names. This includes `import { st } from "@apps/<app>/client"` in a component and the generated `import { RootStore as <lib> } from "@libs/<lib>/client"` in `lib/st.ts`.
|
|
265
306
|
- What the rewrite keeps of the barrel is now the files that define its `usePage` and `st`, normally `lib/useClient` and `lib/st`. It used to keep a bare import of the barrel itself, which put every module's UI of the app and of each merged lib on every route.
|
|
266
|
-
- A barrel that
|
|
307
|
+
- A barrel that lacks either name keeps its bare import, as before. One that defines only one of them is not the generated shape, so what else it runs on import is unknown.
|
|
267
308
|
- **Namespace re-exports.** A name the barrel re-exports as a namespace (`export * as cnst`, `export * as store`) is now narrowed too. `import { cnst } from "@libs/<lib>/client"` becomes `import * as cnst from "@libs/<lib>/lib/cnst"`, where it used to load the whole barrel.
|
|
309
|
+
- **`akan start`.** The dev module registry narrows app and lib barrels the same way, so a page loads in dev the module UI it loads in production. The narrowed imports are written as relative paths, so a save that adds a barrel import still updates the page without a full rebuild. `akanjs` barrels stay whole in dev: in an installed workspace their files are npm modules, and a save that reached a new one would rebuild the whole registry.
|
|
310
|
+
- On `apps/minimal`, the registry behind `/en/explore`, `/en/memo` and `/en/lab/data` holds 505 modules (2.5MB app, 1.2MB vendor), down from 949 (2.6MB app, 6.0MB vendor).
|
|
311
|
+
- Moving an export to another file of a barrel compiles again the importers whose names now point at that file, and only those. This works during `akan start` and on the next start after a move made while it was stopped.
|
|
312
|
+
- Client entry discovery under `akan start` reads a barrel again when one of its files changes. An export moved into a `"use client"` file is found without a restart.
|
|
313
|
+
- **CSR shell and MCP App views.** The single-file CSR build that native apps load, and each MCP App view, narrow app and lib barrels too; they used to narrow only `akanjs/ui`. On `apps/minimal` the CSR shell is 1.58MB raw / 411.9K br, down from 3.51MB / 828.8K, and its MCP App views are 20-29% smaller.
|
|
268
314
|
- **Discovery and Tailwind.** Client entry discovery and Tailwind's candidate scan follow the same imports. A lib `"use client"` component is no longer a client entry of every route, and a module UI file that no route imports is no longer scanned.
|
|
269
315
|
- **Size.** Production build of `apps/minimal`:
|
|
270
316
|
- A route that renders only `Link` ships 53.1K raw / 17.2K br of route code, down from 226.6K / 67.1K.
|
|
@@ -288,11 +334,14 @@
|
|
|
288
334
|
- The rule's own rewrite is removed. Under `--unsafe` it renamed a `#field` where it was called but not where it was declared.
|
|
289
335
|
- **A raw `Error` in an `srvkit/` adapter names its fix.** `akan verify` prints the `Err` import path from that file, and where the key goes: the app's or lib's own service dictionary, `lib/_<name>/<name>.dictionary.ts`, as `<name>.error.<key>`.
|
|
290
336
|
|
|
337
|
+
- d2b2291: A failed workflow no longer reports success to its caller. `akan workflow plan` exits 1 when the plan has an error, `akan workflow apply` when the apply or its `--dry-run` fails, and `akan workflow validate` when its status is `failed` (which includes `passed-with-baseline-blockers`); all three exited 0 before. A dry run of a plan with an error, such as `add-field --type Number`, reports `failed` and lists no changed or generated files, where it used to list the plan's edits under "Automatically Modified". Over MCP, `plan_workflow` names `apply_workflow` in `next` and `approval.canApplyWith` only for a plan without an error, and `apply_workflow` names `run_validation` only when the apply did not fail; a refused plan also drops its "apply this plan" and "validate the apply report" recommendations. The workflows that write into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`) now refuse a service module, or any folder without its `<module>.constant.ts`, at plan time and again at apply. The `workflow-module-not-database` error lists the target's database modules and points at `create-module`, and for a field at `create-scalar`. Before, `add-field` on a service module passed its plan and dry run and failed only at apply, and `create-ui` wrote a misnamed `_<service>.View.tsx` into it. The `workflow-module-missing` error now lists only database modules too, says a name that matches a service module (with or without its `_`) is one instead of asking "Did you mean \_<service>?", and points at another app or library only when it holds a database module of that name.
|
|
291
338
|
- adcdf3b: `akan workflow plan` and the `plan_workflow` MCP tool refuse a module the target app or library lacks, for every workflow that writes into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`). The `workflow-module-missing` error names the module, lists the modules the target has, and points at the module whose name differs only in case or by a leading `_`, at a scalar of that name, or at the app or library that holds it; only when none exists does it point at `create-module`. Before, the plan passed and its dry run reported `passed`. `add-mutation` used to plan a missing module with a warning so that a `create-module` plan could precede it; it now refuses too, so plan it after applying `create-module`. Every workflow now refuses an unknown `app` at plan time, listing the workspace's apps and libraries. `apply` and `--dry-run` check the target again before anything else, so a plan written earlier for a module that is gone fails instead of passing. `plan_workflow` now inspects the MCP server's workspace rather than the process directory.
|
|
292
339
|
- Updated dependencies [bc792ab]
|
|
293
340
|
- Updated dependencies [61fbd7d]
|
|
294
341
|
- Updated dependencies [b3f85fa]
|
|
295
342
|
- Updated dependencies [a707354]
|
|
343
|
+
- Updated dependencies [4d61170]
|
|
344
|
+
- Updated dependencies [c723713]
|
|
296
345
|
- Updated dependencies [0351ad4]
|
|
297
346
|
- Updated dependencies [ba417c6]
|
|
298
347
|
- Updated dependencies [bb40785]
|
|
@@ -346,7 +395,9 @@
|
|
|
346
395
|
- Updated dependencies [5e08722]
|
|
347
396
|
- Updated dependencies [df32c46]
|
|
348
397
|
- Updated dependencies [5f809e8]
|
|
398
|
+
- Updated dependencies [76705dd]
|
|
349
399
|
- Updated dependencies [206bb69]
|
|
400
|
+
- Updated dependencies [e4c7e5c]
|
|
350
401
|
- Updated dependencies [6291fec]
|
|
351
402
|
- Updated dependencies [0c480c6]
|
|
352
403
|
- Updated dependencies [d9eaacc]
|
package/akanContext.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
|
|
4
4
|
import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
|
|
5
|
+
import { constantFieldNames } from "./constantFieldNames";
|
|
5
6
|
import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
|
|
6
7
|
import { FileSys } from "./fileSys";
|
|
7
8
|
import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
|
|
@@ -337,9 +338,6 @@ const moduleShapeFiles = (module: AkanModuleContext) => {
|
|
|
337
338
|
];
|
|
338
339
|
};
|
|
339
340
|
|
|
340
|
-
const constantFieldNames = (content: string) =>
|
|
341
|
-
[...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
|
|
342
|
-
|
|
343
341
|
const safeReadDir = async (dirPath: string) =>
|
|
344
342
|
(await readdir(dirPath, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
|
|
345
343
|
|
package/akanMcpContract.ts
CHANGED
|
@@ -199,7 +199,7 @@ export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanIn
|
|
|
199
199
|
export const applyFirstPolicy = {
|
|
200
200
|
mode: "apply-first",
|
|
201
201
|
directSourceEdits: "fallback-only",
|
|
202
|
-
applyRequiredWhen: ["plan_workflow returns
|
|
202
|
+
applyRequiredWhen: ["plan_workflow returns next.tool=apply_workflow"],
|
|
203
203
|
approvalMeaning:
|
|
204
204
|
"requiresApproval is an agent/user review signal before apply_workflow mutates files; it is not a separate MCP permission gate.",
|
|
205
205
|
validationRequiredAfterApply: ["validationTarget", "applyReportPath"],
|
|
@@ -324,7 +324,7 @@ export const readonlyMcpTools: McpToolDefinition[] = [
|
|
|
324
324
|
{
|
|
325
325
|
name: "get_graph",
|
|
326
326
|
description:
|
|
327
|
-
"Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it,
|
|
327
|
+
"Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it (an app service holds the fields its module's lib services plug, unless it declares the field itself), an adaptor links to the adaptors it plugs itself, a class from a package outside the workspace is adaptor:<package>:<Name>, and a role an app binds with applyAdaptor(Role, Impl) links to that implementation (binds, labelled with the app; a scope keeps its app's bindings). Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
|
|
328
328
|
inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty, since: stringProperty } },
|
|
329
329
|
},
|
|
330
330
|
{
|
|
@@ -371,7 +371,7 @@ export const planMcpTools: McpToolDefinition[] = [
|
|
|
371
371
|
{
|
|
372
372
|
name: "plan_workflow",
|
|
373
373
|
description:
|
|
374
|
-
"Create a read-only workflow plan after context inspection and return planPath plus next.tool=apply_workflow.",
|
|
374
|
+
"Create a read-only workflow plan after context inspection and return planPath, plus next.tool=apply_workflow unless a diagnostic is an error.",
|
|
375
375
|
inputSchema: {
|
|
376
376
|
type: "object",
|
|
377
377
|
properties: { workflow: stringProperty, inputs: objectProperty, out: stringProperty },
|
|
@@ -383,7 +383,8 @@ export const planMcpTools: McpToolDefinition[] = [
|
|
|
383
383
|
export const applyMcpTools: McpToolDefinition[] = [
|
|
384
384
|
{
|
|
385
385
|
name: "apply_workflow",
|
|
386
|
-
description:
|
|
386
|
+
description:
|
|
387
|
+
"Apply a stored workflow plan before direct edits and return validationTarget, plus next.tool=run_validation unless the apply failed.",
|
|
387
388
|
inputSchema: {
|
|
388
389
|
type: "object",
|
|
389
390
|
properties: { planPath: stringProperty, dryRun: booleanProperty },
|
|
@@ -14,8 +14,9 @@ export class StoreBootstrap {
|
|
|
14
14
|
if (!/^@(?:apps|libs)\/[^/]+\/client$/.test(specifier)) return null;
|
|
15
15
|
const runtime = exports.get("usePage");
|
|
16
16
|
const store = exports.get("st");
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
//? A barrel with only one of the two is not the generated shape, so what else it runs on import is unknown.
|
|
18
|
+
if (!runtime || runtime.namespace || !store || store.namespace) return [specifier];
|
|
19
|
+
return [...new Set([runtime.subpath, store.subpath])];
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
static #lines(specifiers: string[]): string[] {
|
|
@@ -37,7 +37,8 @@ Then pick the workflow that matches the intent:
|
|
|
37
37
|
|
|
38
38
|
## The chain
|
|
39
39
|
|
|
40
|
-
Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
40
|
+
Each result names the next call in `next.tool`. Follow it; do not improvise. A result with an error diagnostic, or
|
|
41
|
+
an apply that failed, names none: do what the diagnostic says, then plan again.
|
|
41
42
|
|
|
42
43
|
1. `plan_workflow({ workflow: "create-module", inputs: { app: "<app>", module: "comment" } })` — read-only.
|
|
43
44
|
Returns `planPath` and `next.tool=apply_workflow`. (`list_workflows` / `explain_workflow` give the names
|
|
@@ -138,16 +138,22 @@ const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
|
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
// On a piped stdin a prompt takes the pipe as its answer and echoes it: a piped secret printed as the secret's name.
|
|
141
|
-
export const assertPromptable = (
|
|
142
|
-
problem: string,
|
|
143
|
-
{ choices = [], remedy = "pass it on the command line" }: { choices?: readonly unknown[]; remedy?: string } = {},
|
|
144
|
-
) => {
|
|
141
|
+
export const assertPromptable = (problem: string, refusal: Refusal = {}) => {
|
|
145
142
|
if (process.stdin.isTTY) return;
|
|
146
|
-
throw
|
|
147
|
-
`${problem}: stdin is not a terminal to prompt on, so ${remedy}${choices.length ? ` (choices: ${choices.join(", ")})` : ""}.`,
|
|
148
|
-
);
|
|
143
|
+
throw refusalOf(problem, "stdin is not a terminal to prompt on", refusal);
|
|
149
144
|
};
|
|
150
145
|
|
|
146
|
+
interface Refusal {
|
|
147
|
+
choices?: readonly unknown[];
|
|
148
|
+
remedy?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const refusalOf = (
|
|
152
|
+
problem: string,
|
|
153
|
+
reason: string,
|
|
154
|
+
{ choices = [], remedy = "pass it on the command line" }: Refusal,
|
|
155
|
+
) => new Error(`${problem}: ${reason}, so ${remedy}${choices.length ? ` (choices: ${choices.join(", ")})` : ""}.`);
|
|
156
|
+
|
|
151
157
|
const inputMessageOf = ({ name, argsOption: { desc, example, ask } }: ArgMeta) =>
|
|
152
158
|
ask ? `${ask}: ` : desc ? `${desc}: ` : `Enter the ${name} value${example ? ` (example: ${example})` : ""}: `;
|
|
153
159
|
|
|
@@ -293,11 +299,18 @@ export const getInternalArgumentValue = async (
|
|
|
293
299
|
value: string | undefined,
|
|
294
300
|
workspace: WorkspaceExecutor,
|
|
295
301
|
rerun?: RerunOf,
|
|
302
|
+
{ destructive = false }: { destructive?: boolean } = {},
|
|
296
303
|
) => {
|
|
297
304
|
if (argMeta.type === "Workspace") return workspace;
|
|
298
305
|
const sysType = argMeta.type.toLowerCase();
|
|
299
306
|
const [appNames, libNames, pkgNames] = await workspace.getExecs();
|
|
300
307
|
const problemOf = (kind: string) => (value ? `Unknown ${kind} '${value}'` : `Missing ${kind}`);
|
|
308
|
+
//? Refused in a terminal too: a select deletes on a stray Enter, its cursor resting on the first choice.
|
|
309
|
+
const assertAskable = (kind: string, choices: readonly unknown[], placeholder: string) => {
|
|
310
|
+
const refusal = { choices, ...rerunRemedyOf(rerun, placeholder) };
|
|
311
|
+
if (destructive) throw refusalOf(problemOf(kind), "this command deletes files and never picks its target", refusal);
|
|
312
|
+
assertPromptable(problemOf(kind), refusal);
|
|
313
|
+
};
|
|
301
314
|
if (sysType === "sys" || sysType === "exec") {
|
|
302
315
|
const pkgChoices = sysType === "exec" ? pkgNames : [];
|
|
303
316
|
const execOf = (name: string) => {
|
|
@@ -308,14 +321,14 @@ export const getInternalArgumentValue = async (
|
|
|
308
321
|
};
|
|
309
322
|
const found = value ? execOf(value) : null;
|
|
310
323
|
if (found) return found;
|
|
311
|
-
if (!value && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
|
|
324
|
+
if (!value && !destructive && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
|
|
312
325
|
return AppExecutor.from(workspace, appNames[0]);
|
|
313
326
|
const [kind, placeholder] =
|
|
314
327
|
sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
|
|
315
328
|
const choices = [...appNames, ...libNames, ...pkgChoices];
|
|
316
329
|
const only = choices.length === 1 ? execOf(choices[0] ?? "") : null;
|
|
317
330
|
//? Not for Lib, Pkg or Module, which name what a command acts on: `akan remove-library` would delete the only one.
|
|
318
|
-
if (!value && only && !process.stdin.isTTY) {
|
|
331
|
+
if (!value && !destructive && only && !process.stdin.isTTY) {
|
|
319
332
|
const how = rerun ? `: ${rerun(only.name)}` : "";
|
|
320
333
|
Logger.rawLog(
|
|
321
334
|
`No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
|
|
@@ -324,7 +337,7 @@ export const getInternalArgumentValue = async (
|
|
|
324
337
|
);
|
|
325
338
|
return only;
|
|
326
339
|
}
|
|
327
|
-
|
|
340
|
+
assertAskable(kind, choices, placeholder);
|
|
328
341
|
const name = await select<string>({
|
|
329
342
|
message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
|
|
330
343
|
choices,
|
|
@@ -334,19 +347,19 @@ export const getInternalArgumentValue = async (
|
|
|
334
347
|
return picked;
|
|
335
348
|
} else if (sysType === "app") {
|
|
336
349
|
if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
|
|
337
|
-
if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
|
|
338
|
-
|
|
350
|
+
if (!value && !destructive && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
|
|
351
|
+
assertAskable("app", appNames, "<app>");
|
|
339
352
|
const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
|
|
340
353
|
return AppExecutor.from(workspace, appName);
|
|
341
354
|
} else if (sysType === "lib") {
|
|
342
355
|
if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
|
|
343
|
-
|
|
356
|
+
assertAskable("lib", libNames, "<lib>");
|
|
344
357
|
const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
|
|
345
358
|
return LibExecutor.from(workspace, libName);
|
|
346
359
|
} else if (sysType === "pkg") {
|
|
347
360
|
const pkgs = await workspace.getPkgs();
|
|
348
361
|
if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
|
|
349
|
-
|
|
362
|
+
assertAskable("pkg", pkgs, "<pkg>");
|
|
350
363
|
const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
|
|
351
364
|
return PkgExecutor.from(workspace, pkgName);
|
|
352
365
|
} else if (sysType === "module") {
|
|
@@ -368,10 +381,13 @@ export const getInternalArgumentValue = async (
|
|
|
368
381
|
...appNames.map((name) => AppExecutor.from(workspace, name)),
|
|
369
382
|
...libNames.map((name) => LibExecutor.from(workspace, name)),
|
|
370
383
|
];
|
|
371
|
-
const choices =
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
384
|
+
const choices =
|
|
385
|
+
process.stdin.isTTY && !destructive
|
|
386
|
+
? []
|
|
387
|
+
: (
|
|
388
|
+
await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))
|
|
389
|
+
).flat();
|
|
390
|
+
assertAskable("module", choices, "<sys:module>");
|
|
375
391
|
const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
|
|
376
392
|
message: `select the App or Lib name`,
|
|
377
393
|
choices: [
|
|
@@ -503,6 +519,7 @@ export const runCommands = async (...commands: CommandCls[]) => {
|
|
|
503
519
|
cmdArgs[argMeta.idx] as string,
|
|
504
520
|
workspace,
|
|
505
521
|
rerun,
|
|
522
|
+
{ destructive: targetMeta.targetOption.destructive },
|
|
506
523
|
);
|
|
507
524
|
if (commandArgs[argMeta.idx] instanceof AppExecutor)
|
|
508
525
|
process.env.AKAN_PUBLIC_APP_NAME = (commandArgs[argMeta.idx] as AppExecutor).name;
|
|
@@ -551,6 +551,63 @@ describe("a Sys that defaults to the only app", () => {
|
|
|
551
551
|
);
|
|
552
552
|
expect(asked).toEqual([]);
|
|
553
553
|
});
|
|
554
|
+
|
|
555
|
+
test("a destructive command never takes the only app, lib, pkg or module, nor asks, terminal or not", async () => {
|
|
556
|
+
const workspace = workspaceWith([]);
|
|
557
|
+
workspace.getExecs = async () => [["web"], ["util"], []];
|
|
558
|
+
workspace.getPkgs = async () => ["tools"];
|
|
559
|
+
workspace.getDirInModule = async (basePath) => (basePath === "apps/web/lib" ? ["pokemon"] : []);
|
|
560
|
+
const refusedAs = (rerun: string, choices: string) =>
|
|
561
|
+
`: this command deletes files and never picks its target, so rerun it as \`${rerun}\` (choices: ${choices}).`;
|
|
562
|
+
const resolve = (type: InternalArgMeta["type"], commandName: string, operands: (string | undefined)[]) =>
|
|
563
|
+
getInternalArgumentValue(
|
|
564
|
+
{ key: "", idx: operands.length - 1, type },
|
|
565
|
+
operands.at(-1),
|
|
566
|
+
workspace,
|
|
567
|
+
rerunCommandOf(
|
|
568
|
+
[commandName, ...operands.filter((operand): operand is string => operand !== undefined)],
|
|
569
|
+
operands,
|
|
570
|
+
operands.length - 1,
|
|
571
|
+
),
|
|
572
|
+
{ destructive: true },
|
|
573
|
+
);
|
|
574
|
+
for (const isTTY of [true, undefined]) {
|
|
575
|
+
setTTY(isTTY);
|
|
576
|
+
answer = "web";
|
|
577
|
+
await expect(resolve("App", "remove-application", [undefined])).rejects.toThrow(
|
|
578
|
+
`Missing app${refusedAs("akan remove-application <app>", "web")}`,
|
|
579
|
+
);
|
|
580
|
+
await expect(resolve("Lib", "remove-library", [undefined])).rejects.toThrow(
|
|
581
|
+
`Missing lib${refusedAs("akan remove-library <lib>", "util")}`,
|
|
582
|
+
);
|
|
583
|
+
await expect(resolve("Pkg", "remove-package", [undefined])).rejects.toThrow(
|
|
584
|
+
`Missing pkg${refusedAs("akan remove-package <pkg>", "tools")}`,
|
|
585
|
+
);
|
|
586
|
+
await expect(resolve("Module", "remove-module", [undefined])).rejects.toThrow(
|
|
587
|
+
`Missing module${refusedAs("akan remove-module <sys:module>", "web:pokemon")}`,
|
|
588
|
+
);
|
|
589
|
+
await expect(resolve("Sys", "remove-scalar", ["place", undefined])).rejects.toThrow(
|
|
590
|
+
`Missing app or lib${refusedAs("akan remove-scalar place <app-or-lib>", "web, util")}`,
|
|
591
|
+
);
|
|
592
|
+
await expect(resolve("App", "remove-application", ["webb"])).rejects.toThrow(
|
|
593
|
+
`Unknown app 'webb'${refusedAs("akan remove-application <app>", "web")}`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
expect(asked).toEqual([]);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("a destructive command takes the target it is named, while another still takes the only app", async () => {
|
|
600
|
+
setTTY(undefined);
|
|
601
|
+
const workspace = workspaceWith([]);
|
|
602
|
+
workspace.getExecs = async () => [["web"], [], []];
|
|
603
|
+
const named = await getInternalArgumentValue({ key: "", idx: 0, type: "App" }, "web", workspace, undefined, {
|
|
604
|
+
destructive: true,
|
|
605
|
+
});
|
|
606
|
+
expect(named instanceof AppExecutor ? named.name : null).toBe("web");
|
|
607
|
+
const taken = await getInternalArgumentValue(sysMetaOf("typecheck"), undefined, workspace);
|
|
608
|
+
expect(taken instanceof AppExecutor ? taken.name : null).toBe("web");
|
|
609
|
+
expect(asked).toEqual([]);
|
|
610
|
+
});
|
|
554
611
|
});
|
|
555
612
|
|
|
556
613
|
describe("the line a refused target is rerun with", () => {
|
|
@@ -573,7 +630,7 @@ describe("the line a refused target is rerun with", () => {
|
|
|
573
630
|
|
|
574
631
|
describe("a target left out where no terminal can ask for it", () => {
|
|
575
632
|
const track = tempRoots();
|
|
576
|
-
const run = async (apps: string[]) => {
|
|
633
|
+
const run = async (apps: string[], args = ["create-thing", "photo", "--force"]) => {
|
|
577
634
|
const { root } = track(await makeCliTempWorkspace());
|
|
578
635
|
await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
|
|
579
636
|
await writeText(path.join(root, "tsconfig.json"), "{}\n");
|
|
@@ -582,7 +639,7 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
582
639
|
await writeText(
|
|
583
640
|
entry,
|
|
584
641
|
[
|
|
585
|
-
`import { command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
642
|
+
`import { App, command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
|
|
586
643
|
'class ThingCommand extends command("thing", ({ public: target }) => ({',
|
|
587
644
|
' createThing: target({ desc: "creates a thing" })',
|
|
588
645
|
' .arg("thing", String, { desc: "name of the thing" })',
|
|
@@ -591,12 +648,17 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
591
648
|
" .exec(async function (thing, sys, force) {",
|
|
592
649
|
' process.stdout.write(thing + " in " + sys.name + ", force " + force + "\\n");',
|
|
593
650
|
" }),",
|
|
651
|
+
' removeThing: target({ desc: "removes an app", destructive: true })',
|
|
652
|
+
" .with(App)",
|
|
653
|
+
" .exec(async function (app) {",
|
|
654
|
+
' process.stdout.write("removed " + app.name + "\\n");',
|
|
655
|
+
" }),",
|
|
594
656
|
"})) {}",
|
|
595
657
|
"await runCommands(ThingCommand);",
|
|
596
658
|
"",
|
|
597
659
|
].join("\n"),
|
|
598
660
|
);
|
|
599
|
-
const proc = Bun.spawn(["bun", entry,
|
|
661
|
+
const proc = Bun.spawn(["bun", entry, ...args], {
|
|
600
662
|
cwd: root,
|
|
601
663
|
stdin: "ignore",
|
|
602
664
|
stdout: "pipe",
|
|
@@ -628,6 +690,16 @@ describe("a target left out where no terminal can ask for it", () => {
|
|
|
628
690
|
expect(refused.stdout).toMatch(/\(choices: (web, admin|admin, web)\)\./);
|
|
629
691
|
expect(refused.stdout).not.toContain("photo in");
|
|
630
692
|
}, 30_000);
|
|
693
|
+
|
|
694
|
+
test("a destructive command exits with the line to rerun even when the workspace has one app", async () => {
|
|
695
|
+
const refused = await run(["web"], ["remove-thing"]);
|
|
696
|
+
expect(refused.code).toBe(1);
|
|
697
|
+
expect(refused.stdout).toContain(
|
|
698
|
+
"Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-thing <app>` (choices: web).",
|
|
699
|
+
);
|
|
700
|
+
expect(refused.stdout).not.toContain("removed");
|
|
701
|
+
expect((await run(["web"], ["remove-thing", "web"])).stdout).toContain("removed web\n");
|
|
702
|
+
}, 30_000);
|
|
631
703
|
});
|
|
632
704
|
|
|
633
705
|
describe("the exit code of a command that failed", () => {
|
|
@@ -45,6 +45,8 @@ export interface TargetOption {
|
|
|
45
45
|
stdio?: boolean;
|
|
46
46
|
failureExitCode?: number;
|
|
47
47
|
usageExitCode?: number;
|
|
48
|
+
/** It deletes files in its app, lib, pkg or module, so one left out or unknown is refused, never picked or asked. */
|
|
49
|
+
destructive?: boolean;
|
|
48
50
|
/** Runs after the CLI printed a failure and before it exits, so what it writes ends the output. */
|
|
49
51
|
onFailure?: (error: unknown, failure: TargetFailure) => void | Promise<void>;
|
|
50
52
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { constantFieldNames } from "./constantFieldNames";
|
|
3
|
+
|
|
4
|
+
describe("constantFieldNames", () => {
|
|
5
|
+
test("names each field the code declares, chained or not", () => {
|
|
6
|
+
const content = `export class TaskInput extends via((field) => ({
|
|
7
|
+
title: field(String),
|
|
8
|
+
tags: field(String).list().optional(),
|
|
9
|
+
workHistory: field([WorkHistory]),
|
|
10
|
+
})) {}
|
|
11
|
+
export class TaskObject extends via(TaskInput, (field) => ({
|
|
12
|
+
due: field(Date).optional(),
|
|
13
|
+
total: resolve<number>(),
|
|
14
|
+
})) {}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
expect(constantFieldNames(content)).toEqual(["title", "tags", "workHistory", "due"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("leaves out a field that appears only in a comment or a string", () => {
|
|
21
|
+
const content = `export class TaskInput extends via((field) => ({
|
|
22
|
+
title: field(String),
|
|
23
|
+
// priority: field(TaskPriority, { default: "medium" })
|
|
24
|
+
/* assignee: field(ID).optional() */
|
|
25
|
+
note: field(String, { default: "completedAt: field(Date)" }),
|
|
26
|
+
})) {}
|
|
27
|
+
// ---- Expandable additional fields: ----
|
|
28
|
+
// - actor: field(String).optional() — who performed the action
|
|
29
|
+
export const example = \`previousStatus: field(TaskStatus)\`;
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
expect(constantFieldNames(content)).toEqual(["title", "note"]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
const isFieldCall = (node: ts.Expression): boolean =>
|
|
4
|
+
ts.isCallExpression(node) &&
|
|
5
|
+
(ts.isIdentifier(node.expression)
|
|
6
|
+
? node.expression.text === "field"
|
|
7
|
+
: ts.isPropertyAccessExpression(node.expression) && isFieldCall(node.expression.expression));
|
|
8
|
+
|
|
9
|
+
export const constantFieldNames = (content: string) => {
|
|
10
|
+
const names: string[] = [];
|
|
11
|
+
const visit = (node: ts.Node) => {
|
|
12
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && isFieldCall(node.initializer))
|
|
13
|
+
names.push(node.name.text);
|
|
14
|
+
ts.forEachChild(node, visit);
|
|
15
|
+
};
|
|
16
|
+
visit(ts.createSourceFile("constant.ts", content, ts.ScriptTarget.Latest));
|
|
17
|
+
return names;
|
|
18
|
+
};
|
|
@@ -343,6 +343,49 @@ describe("route client store bootstrap", () => {
|
|
|
343
343
|
expect(bundled).not.toContain("lib-ui-marker");
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
+
test("a route's bundle runs the client runtime and the stores before the UI it renders, which reads them on load", async () => {
|
|
347
|
+
const { appDir, app } = await writeBarrelFixture();
|
|
348
|
+
const record = (label: string) =>
|
|
349
|
+
`((globalThis as { __akanOrder?: string[] }).__akanOrder ??= []).push("${label}");`;
|
|
350
|
+
await write(
|
|
351
|
+
path.join(appDir, "lib/useClient.ts"),
|
|
352
|
+
`${record("runtime")}\nexport const { msg, Err, usePage, fetch, sig } = { usePage: () => "page" } as Record<string, unknown>;\n`,
|
|
353
|
+
);
|
|
354
|
+
await write(
|
|
355
|
+
path.join(appDir, "../../libs/kit/lib/st.ts"),
|
|
356
|
+
`${record("lib-store")}\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "lib-store-marker" });\n`,
|
|
357
|
+
);
|
|
358
|
+
await write(
|
|
359
|
+
path.join(appDir, "lib/st.ts"),
|
|
360
|
+
`import { RootStore as kit } from "@libs/kit/client";\n${record("store")}\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "store-marker", kit });\n`,
|
|
361
|
+
);
|
|
362
|
+
await write(
|
|
363
|
+
path.join(appDir, "lib/memo/Memo.Zone.tsx"),
|
|
364
|
+
`"use client";\nimport { Memo, st } from "@apps/demo/client";\n${record("memo-ui")}\nexport const seen = [st.mark, typeof Memo];\n`,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await new RouteClientBuilder({
|
|
368
|
+
app,
|
|
369
|
+
seeds: [],
|
|
370
|
+
entries: [path.join(appDir, "lib/memo/Memo.Zone.tsx")],
|
|
371
|
+
artifact: {} as never,
|
|
372
|
+
browser: "chunks",
|
|
373
|
+
}).build();
|
|
374
|
+
|
|
375
|
+
const clientOut = path.join(appDir, ".akan/artifact/client");
|
|
376
|
+
const entries = [...new Bun.Glob("*.js").scanSync(clientOut)];
|
|
377
|
+
expect(entries).toHaveLength(1);
|
|
378
|
+
const loaded = (await import(path.join(clientOut, entries[0] ?? ""))) as { seen?: unknown };
|
|
379
|
+
|
|
380
|
+
expect(loaded.seen).toEqual(["store-marker", "object"]);
|
|
381
|
+
expect((globalThis as { __akanOrder?: string[] }).__akanOrder).toEqual([
|
|
382
|
+
"runtime",
|
|
383
|
+
"lib-store",
|
|
384
|
+
"store",
|
|
385
|
+
"memo-ui",
|
|
386
|
+
]);
|
|
387
|
+
});
|
|
388
|
+
|
|
346
389
|
test("a route reaching a lib's cnst through its client barrel carries no module UI", async () => {
|
|
347
390
|
const { root, appDir, app } = await writeBarrelFixture();
|
|
348
391
|
|