Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b85aabf
feat(app-builder): add typed generator catalog
kattsushi Aug 11, 2026
76aab96
feat(app-builder): add generation surface adapters
kattsushi Aug 11, 2026
70c732c
feat(app-builder): add atomic Todo renderers
kattsushi Aug 11, 2026
21b92cc
feat(app-builder): materialize atomic Todo plans
kattsushi Aug 11, 2026
731cbc1
feat(app-builder): preserve Todo v1 parity
kattsushi Aug 12, 2026
66bfa63
test(app-builder): bound semantic compilation proof
kattsushi Aug 12, 2026
8d7a2c9
refactor(app-builder): use declarative generation templates
kattsushi Aug 12, 2026
5515285
fix(app-builder): make generated template bytes authoritative
kattsushi Aug 12, 2026
027f002
fix(app-builder): use real ejs rendering
kattsushi Aug 12, 2026
90fb80e
chore(app-builder): parameterize template identities
kattsushi Aug 12, 2026
40a951a
refactor(app-builder): generalize reusable generation kernel
kattsushi Aug 12, 2026
2ad43e0
refactor(app-builder): use declarative generation templates (#145)
kattsushi Aug 13, 2026
dc97f7a
feat(app-builder): prove installed CLI generation
kattsushi Aug 13, 2026
54b9677
test(app-builder): prove CLI lifecycle cleanup
kattsushi Aug 13, 2026
7d3493c
feat(app-builder): gate showcase regeneration
kattsushi Aug 14, 2026
32d1562
feat(app-builder): gate showcase regeneration (#151)
kattsushi Aug 14, 2026
fa88569
test(app-builder): prove CLI lifecycle cleanup (#149)
kattsushi Aug 14, 2026
8cfeaa0
feat(app-builder): prove installed CLI generation (#147)
kattsushi Aug 14, 2026
5ac25f7
feat(app-builder): preserve Todo v1 parity (#143)
kattsushi Aug 14, 2026
d8cb781
feat(app-builder): materialize atomic Todo plans (#141)
kattsushi Aug 14, 2026
e82e9dc
feat(app-builder): add atomic Todo renderers (#139)
kattsushi Aug 14, 2026
c4c9931
feat(app-builder): add generation surface adapters (#137)
kattsushi Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/app-builder/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "@effectify/app-builder-cli",
"version": "0.0.0",
"type": "module",
"bin": {
"effectify-app-builder": "./dist/src/main.js"
},
"dependencies": {
"@effectify/app-builder-generation": "workspace:*",
"tslib": "catalog:"
Expand Down
6 changes: 3 additions & 3 deletions packages/app-builder/cli/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ const workspacePath = (workspace: string): Effect.Effect<string, GenerateInputEr
: Effect.succeed(target)
}

const outputPath = (workspace: string, path: string): string => {
const outputPath = (workspace: string, path: string, roots: ReadonlyArray<string>): string => {
const segments = safeSegments(path, safeOutputSegment)
const isOwned = TodoPreset.TodoTopologyRoots.some((root) => path.startsWith(`${root}/`))
const isOwned = TodoPreset.isTodoTopologyPath(path, roots)
if (segments === undefined || !isOwned) {
throw new GenerateHostError({ reason: "generated topology contains an unsafe output path" })
}
Expand Down Expand Up @@ -118,7 +118,7 @@ const prevalidate = async (
for (const file of [...topology.files].sort((left, right) => left.path.localeCompare(right.path))) {
if (paths.has(file.path)) throw new GenerateConflictError({ path: file.path })
paths.add(file.path)
const target = outputPath(workspace, file.path)
const target = outputPath(workspace, file.path, topology.roots)
const output = { content: file.content, path: file.path, target }
await requireSafeDirectories(workspace, output)
const existing = await existingEntry(target)
Expand Down
15 changes: 15 additions & 0 deletions packages/app-builder/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import {
composeCatalog,
type AtomicGenerator,
type FiniteCatalog,
type GeneratorId,
} from "@effectify/app-builder-generation"
export { renderTodoPlan } from "./plan.js"

export const composeGeneration = <
const Generators extends readonly [AtomicGenerator<unknown>, ...AtomicGenerator<unknown>[]],
>(options: {
readonly catalog: FiniteCatalog<Generators>
readonly context: unknown
readonly input: Generators[number] extends AtomicGenerator<infer Input> ? Input : never
readonly selected: ReadonlyArray<GeneratorId>
}) => composeCatalog(options)
export { generateTodo } from "./generate.js"
export { runCli, runNodeCli } from "./main.js"
export {
Expand Down
8 changes: 8 additions & 0 deletions packages/app-builder/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
#!/usr/bin/env node

import { realpathSync } from "node:fs"
import { readFile } from "node:fs/promises"
import { pathToFileURL } from "node:url"
import * as Effect from "effect/Effect"
import {
type CliCommand,
Expand Down Expand Up @@ -171,3 +175,7 @@ export const runNodeCli = (args: ReadonlyArray<string> = process.argv.slice(2)):
process.exitCode = code
return code
})

if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
await runNodeCli()
}
60 changes: 58 additions & 2 deletions packages/app-builder/cli/tests/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, it } from "vitest"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { join } from "node:path"
import * as Effect from "effect/Effect"
import { surfaceRequest } from "../../generation/tests/surface-request.js"

interface CommandDispatcher {
readonly dispatch: (request: unknown) => Effect.Effect<unknown, unknown>
Expand All @@ -27,6 +28,9 @@ interface ReplayModule {
readonly captureReplayProvenance: (candidate: unknown) => Effect.Effect<unknown, unknown>
}

type PublicModule = typeof import("../src/index.js")
type GenerationModule = typeof import("@effectify/app-builder-generation")

const request = {
version: "effectify.app-builder-cli-request/1",
command: "plan",
Expand All @@ -45,12 +49,34 @@ const generateRequest = (workspace: string) => ({
workspace,
},
})
const customGenerateRequest = (workspace: string) => ({
...generateRequest(workspace),
payload: {
...generateRequest(workspace).payload,
intent: {
...request.payload,
naming: {
workspace: "operations-workspace",
npmScope: "@acme",
domain: { id: "operations", name: "Operations" },
entity: { id: "task", singular: "Task", plural: "Tasks" },
entrypoint: { id: "admin-console", name: "AdminConsole" },
},
},
},
})

const main = () => Effect.promise<MainModule>(() => import(new URL("../src/main.js", import.meta.url).href))

const replay = () =>
Effect.promise<ReplayModule>(() => import(new URL("../../generation/src/replay.js", import.meta.url).href))

const publicSurface = () =>
Effect.all({
Cli: Effect.promise<PublicModule>(() => import(new URL("../src/index.js", import.meta.url).href)),
Generation: Effect.promise<GenerationModule>(() => import("@effectify/app-builder-generation")),
})

const invoke = (
args: ReadonlyArray<string>,
stdin: string,
Expand Down Expand Up @@ -117,6 +143,22 @@ effect("S16 and S18 execute the trusted plan command from stdin or an explicit J
}),
)

effect("generic CLI planning behaviorally composes actual surface catalogs", () =>
Effect.gen(function* () {
const { Cli, Generation } = yield* publicSurface()
for (const [scope, workspace] of [
["@acme", "task-workspace"],
["@globex", "console"],
]) {
const options = surfaceRequest(Generation, scope, workspace)
const direct = yield* Generation.composeCatalog(options)
const adapter = yield* Cli.composeGeneration(options)

expect(adapter).toEqual(direct)
}
}),
)

effect("S19 emits JSON Lines only when selected and always ends with one terminal envelope", () =>
Effect.gen(function* () {
const result = yield* invoke(["plan", "--events=jsonl"], JSON.stringify(request))
Expand All @@ -140,7 +182,7 @@ effect("R16 prerequisite generates deterministic consumer output through the pub

return Effect.gen(function* () {
yield* Effect.promise(() => rm(workspacePath, { force: true, recursive: true }))
const input = JSON.stringify(generateRequest(workspace))
const input = JSON.stringify(customGenerateRequest(workspace))

return yield* Effect.gen(function* () {
const first = yield* invoke(["generate", "--events=jsonl"], input)
Expand Down Expand Up @@ -169,9 +211,23 @@ effect("R16 prerequisite generates deterministic consumer output through the pub
})
expect(firstTerminal.result.writtenPaths).not.toEqual([])
expect(
yield* Effect.promise(() => readFile(join(workspacePath, "apps/todo-cli/src/index.ts"), "utf8")),
yield* Effect.promise(() => readFile(join(workspacePath, "apps/admin-console/src/index.ts"), "utf8")),
).toContain("createLiveRuntime")

const { Generation } = yield* publicSurface()
const direct = yield* Generation.TodoPreset.createTodoTopology(
yield* Generation.Planner.planTodo(customGenerateRequest(workspace).payload.intent),
)
const rootFiles = direct.files.filter((file) =>
Generation.TodoGeneration.WorkspaceRootFiles.some((path) => path === file.path),
)

expect(rootFiles).toHaveLength(5)
expect(firstTerminal.result.writtenPaths).toEqual(expect.arrayContaining(rootFiles.map((file) => file.path)))
for (const file of rootFiles) {
expect(yield* Effect.promise(() => readFile(join(workspacePath, file.path), "utf8"))).toBe(file.content)
}

const second = yield* invoke(["generate"], input)
const secondTerminal = JSON.parse(second.stdout).terminal

Expand Down
6 changes: 6 additions & 0 deletions packages/app-builder/e2e/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
},
"test": {
"executor": "nx:run-commands",
"dependsOn": [
{
"projects": ["@effectify/app-builder-cli"],
"target": "build"
}
],
"options": {
"command": "../../../node_modules/.bin/vitest run --config vitest.config.mts",
"cwd": "packages/app-builder/e2e"
Expand Down
Loading
Loading