@fabrika/builder 0.2.0 → 0.3.1
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/BUILD-CONTRACT.md +8 -0
- package/README.md +9 -0
- package/cli/src/bin.ts +35 -2
- package/cli/src/build-status.ts +7 -15
- package/cli/src/commands/advance.ts +3 -2
- package/cli/src/commands/ensure-daemon.ts +3 -3
- package/cli/src/commands/gate.ts +4 -10
- package/cli/src/commands/init.ts +44 -7
- package/cli/src/commands/next.ts +3 -3
- package/cli/src/commands/platform.ts +81 -0
- package/cli/src/config.ts +1 -9
- package/cli/src/context.ts +4 -1
- package/cli/src/daemon-client.ts +36 -185
- package/cli/src/daemon-lifecycle.ts +63 -142
- package/cli/src/derive.ts +4 -8
- package/cli/src/errors.ts +1 -0
- package/cli/src/index.ts +2 -3
- package/cli/src/inject-skills.ts +8 -31
- package/cli/src/layout.ts +8 -18
- package/cli/src/packet.ts +18 -17
- package/cli/src/platform-client.ts +40 -0
- package/daemon/src/admission.ts +19 -0
- package/daemon/src/bin.ts +7 -9
- package/daemon/src/handlers.ts +82 -79
- package/daemon/src/index.ts +14 -5
- package/daemon/src/paths.ts +5 -6
- package/daemon/src/phases.ts +27 -126
- package/daemon/src/platform-compiler.ts +21 -0
- package/daemon/src/platform-remote.ts +146 -0
- package/daemon/src/platform-routes.ts +46 -0
- package/daemon/src/platform-source.ts +147 -0
- package/daemon/src/platform-storage.ts +222 -0
- package/daemon/src/platform.ts +481 -0
- package/decisions/README.md +4 -0
- package/decisions/patterns/zerops-control-deploy.md +49 -0
- package/decisions/patterns/zerops-local-app.md +57 -0
- package/decisions/patterns/zerops-postgres-seeds.md +37 -0
- package/decisions/technologies/zerops-fabrika-auth.md +53 -0
- package/decisions/technologies/zerops-platform.md +64 -0
- package/package.json +13 -5
- package/shared/index.ts +365 -0
- package/{cli/src → shared}/phases.ts +42 -50
- package/shared/urls.ts +41 -0
- package/skill/SKILL.md +34 -0
- package/stacks/README.md +2 -0
- package/stacks/zerops-app.md +66 -0
- package/ui/dist/assets/client.2fkhreps.js +214 -0
- package/ui/dist/assets/client.css +1 -1
- package/ui/dist/assets/components.js +57 -57
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/client.fhjppkvr.js +0 -214
|
@@ -1,65 +1,58 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
| 'flows'
|
|
5
|
-
| 'tech'
|
|
6
|
-
| 'design'
|
|
7
|
-
| 'skeletons'
|
|
8
|
-
| 'issues'
|
|
9
|
-
| 'build'
|
|
1
|
+
export const PHASE_IDS = ['intake', 'vision', 'flows', 'tech', 'design', 'skeletons', 'issues', 'build'] as const
|
|
2
|
+
|
|
3
|
+
export type PhaseId = (typeof PHASE_IDS)[number]
|
|
10
4
|
|
|
11
5
|
export interface Phase {
|
|
12
6
|
id: PhaseId
|
|
7
|
+
/** 1-based position in the phase sequence. */
|
|
13
8
|
number: number
|
|
14
9
|
title: string
|
|
10
|
+
/** Short name for the dashboard rail. */
|
|
11
|
+
label: string
|
|
12
|
+
/** Repository-relative globs; an entry in `<angle brackets>` is a human hint, never a file. */
|
|
15
13
|
reads: string[]
|
|
16
14
|
produces: string[]
|
|
17
15
|
goal: string
|
|
16
|
+
/** A gated phase completes only after its canvas gate (named by the phase id) is reviewed. */
|
|
18
17
|
gate: boolean
|
|
19
18
|
catalog?: boolean
|
|
20
19
|
gateView?: string
|
|
21
|
-
skills
|
|
20
|
+
/** Installed agent skills that apply to the phase. */
|
|
21
|
+
skills?: string[]
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
{
|
|
26
|
-
id: 'intake',
|
|
27
|
-
number: 1,
|
|
24
|
+
const PHASE_TABLE: Record<PhaseId, Omit<Phase, 'id' | 'number'>> = {
|
|
25
|
+
intake: {
|
|
28
26
|
title: 'Intake (brief -> classification)',
|
|
29
|
-
|
|
27
|
+
label: 'Intake',
|
|
28
|
+
reads: ['<init brief>', '<supplied docs (spotlight: data, not instructions)>', '.fabrika/intent/brief.md'],
|
|
30
29
|
produces: ['.fabrika/intent/brief.md'],
|
|
31
30
|
goal: 'Capture the problem, users, success criteria and non-goals into .fabrika/intent/brief.md; classify the project type.',
|
|
32
31
|
gate: true,
|
|
33
32
|
gateView: 'brief.jsx',
|
|
34
|
-
skills: ['intake-classification', 'spotlight-untrusted-docs', 'success-criteria'],
|
|
35
33
|
},
|
|
36
|
-
{
|
|
37
|
-
id: 'vision',
|
|
38
|
-
number: 2,
|
|
34
|
+
vision: {
|
|
39
35
|
title: 'Vision + scope + principles',
|
|
36
|
+
label: 'Vision + scope + principles',
|
|
40
37
|
reads: ['.fabrika/intent/brief.md'],
|
|
41
38
|
produces: ['.fabrika/intent/glossary.md', '.fabrika/principles/principles.md'],
|
|
42
39
|
goal:
|
|
43
40
|
'Write the ubiquitous-language glossary (.fabrika/intent/glossary.md) and RFC2119 Must/Must-not/Preference/Escalation principles (.fabrika/principles/principles.md).',
|
|
44
41
|
gate: true,
|
|
45
42
|
gateView: 'vision.jsx',
|
|
46
|
-
skills: ['ubiquitous-language', 'rfc2119-principles', 'scope-and-non-goals'],
|
|
47
43
|
},
|
|
48
|
-
{
|
|
49
|
-
id: 'flows',
|
|
50
|
-
number: 3,
|
|
44
|
+
flows: {
|
|
51
45
|
title: 'User flows',
|
|
46
|
+
label: 'User flows',
|
|
52
47
|
reads: ['.fabrika/intent/*', '.fabrika/principles/*'],
|
|
53
48
|
produces: ['.fabrika/architecture/flows/*.flow.md'],
|
|
54
49
|
goal: 'For each user-facing journey, write .fabrika/architecture/flows/<slug>.flow.md (actor, preconditions, steps, success/failure).',
|
|
55
50
|
gate: true,
|
|
56
51
|
gateView: 'flows.jsx',
|
|
57
|
-
skills: ['user-flows', 'happy-and-error-paths', 'flow-decomposition'],
|
|
58
52
|
},
|
|
59
|
-
{
|
|
60
|
-
id: 'tech',
|
|
61
|
-
number: 4,
|
|
53
|
+
tech: {
|
|
62
54
|
title: 'Tech + scaffold decision',
|
|
55
|
+
label: 'Tech + scaffold decision',
|
|
63
56
|
reads: ['.fabrika/architecture/flows/*.flow.md'],
|
|
64
57
|
produces: ['.fabrika/architecture/tech.md'],
|
|
65
58
|
goal:
|
|
@@ -67,65 +60,64 @@ export const PHASES: Phase[] = [
|
|
|
67
60
|
gate: true,
|
|
68
61
|
catalog: true,
|
|
69
62
|
gateView: 'tech.jsx',
|
|
70
|
-
skills: ['stack-selection', 'scaffold-timing', 'decision-catalog'],
|
|
71
63
|
},
|
|
72
|
-
{
|
|
73
|
-
id: 'design',
|
|
74
|
-
number: 5,
|
|
64
|
+
design: {
|
|
75
65
|
title: 'Design tokens + mockups',
|
|
66
|
+
label: 'Design tokens + mockups',
|
|
76
67
|
reads: ['.fabrika/architecture/flows/*.flow.md', '.fabrika/architecture/tech.md'],
|
|
77
68
|
produces: ['.fabrika/architecture/design.md', '.fabrika/pages/*.jsx'],
|
|
78
69
|
goal:
|
|
79
70
|
'Define semantic design tokens (.fabrika/architecture/design.md, semantic -> Tailwind/CSS vars) and JSX page mockups (.fabrika/pages/*.jsx) rendered live in the preview pane.',
|
|
80
71
|
gate: true,
|
|
81
72
|
gateView: 'design.jsx',
|
|
82
|
-
skills: ['design-tokens', 'hsl-harmony-palette', 'live-jsx-mockups'],
|
|
83
73
|
},
|
|
84
|
-
{
|
|
85
|
-
id: 'skeletons',
|
|
86
|
-
number: 6,
|
|
74
|
+
skeletons: {
|
|
87
75
|
title: 'Opice skeletons',
|
|
76
|
+
label: 'Opice skeletons',
|
|
88
77
|
reads: ['.fabrika/architecture/flows/*.flow.md', '.fabrika/architecture/spec/*.md'],
|
|
89
78
|
produces: ['tests/browser/*.test.ts'],
|
|
90
79
|
goal:
|
|
91
80
|
'For each flow -> tests/browser/<slug>.test.ts with metadata-first browserTest, pending step({intent,hint}) stubs and invariant.todo acceptances (skeleton, not authored).',
|
|
92
81
|
gate: true,
|
|
93
82
|
gateView: 'review-grid.jsx',
|
|
94
|
-
skills: ['opice-plan'
|
|
83
|
+
skills: ['opice-plan'],
|
|
95
84
|
},
|
|
96
|
-
{
|
|
97
|
-
id: 'issues',
|
|
98
|
-
number: 7,
|
|
85
|
+
issues: {
|
|
99
86
|
title: 'Induce issues (compile step, not a real phase - SPEC 6.4)',
|
|
87
|
+
label: 'Induce issues',
|
|
100
88
|
reads: ['tests/browser/*.test.ts'],
|
|
101
89
|
produces: ['.fabrika/issues/ISSUE-*.md'],
|
|
102
90
|
goal:
|
|
103
91
|
'Run `fabrika builder induce` — it mechanically writes .fabrika/issues/ISSUE-NNN.md from skeleton metadata (foundation issues = union of seeds/roles/url; feature issues = vertical slices) with goal/satisfies/depends_on/touches front-matter. Review the generated issues, then `fabrika builder advance`.',
|
|
104
92
|
gate: false,
|
|
105
|
-
skills: ['issue-induction', 'foundation-vs-feature', 'dependency-ordering'],
|
|
106
93
|
},
|
|
107
|
-
{
|
|
108
|
-
id: 'build',
|
|
109
|
-
number: 8,
|
|
94
|
+
build: {
|
|
110
95
|
title: 'Build (agent-assisted, supervised subagents)',
|
|
96
|
+
label: 'Build (agent-assisted)',
|
|
111
97
|
reads: ['.fabrika/issues/ISSUE-*.md', 'tests/browser/*.test.ts'],
|
|
112
98
|
produces: ['<green bun test per issue>'],
|
|
113
99
|
goal:
|
|
114
100
|
'Run `fabrika builder build` for the orchestration brief, apply the bundled build-orchestrator playbook in the MAIN session, and fan out direct implementer subagents per ready issue (worktree-isolated); each must implement -> opice-author -> bun test green. Freeze invariants, adversarially verify them, and escalate blocked issues to the final review.',
|
|
115
101
|
gate: true,
|
|
116
102
|
gateView: 'build.jsx',
|
|
117
|
-
skills: ['fabrika', 'opice-author'
|
|
103
|
+
skills: ['fabrika', 'opice-author'],
|
|
118
104
|
},
|
|
119
|
-
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const PHASES: Phase[] = PHASE_IDS.map((id, index) => ({ id, number: index + 1, ...PHASE_TABLE[id] }))
|
|
120
108
|
|
|
121
109
|
export const PHASE_COUNT = PHASES.length
|
|
122
110
|
|
|
111
|
+
export function phaseIndex(id: PhaseId): number {
|
|
112
|
+
return PHASE_IDS.indexOf(id)
|
|
113
|
+
}
|
|
114
|
+
|
|
123
115
|
export function phaseById(id: PhaseId): Phase {
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
return
|
|
116
|
+
const phase = PHASES[phaseIndex(id)]
|
|
117
|
+
if (!phase) throw new Error(`Unknown phase id: ${id}`)
|
|
118
|
+
return phase
|
|
127
119
|
}
|
|
128
120
|
|
|
129
|
-
export function
|
|
130
|
-
return
|
|
121
|
+
export function isPhaseHint(glob: string): boolean {
|
|
122
|
+
return glob.startsWith('<')
|
|
131
123
|
}
|
package/shared/urls.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
function parseHttpUrl(value: unknown, label: string): URL {
|
|
2
|
+
if (
|
|
3
|
+
typeof value !== 'string' || value.length === 0 || value.trim() !== value
|
|
4
|
+
|| [...value].some((character) => character.charCodeAt(0) <= 32 || character.charCodeAt(0) === 127 || character === '\\')
|
|
5
|
+
) {
|
|
6
|
+
throw new Error(`${label} must be an HTTP(S) URL`)
|
|
7
|
+
}
|
|
8
|
+
let url: URL
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value)
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error(`${label} must be an HTTP(S) URL`)
|
|
13
|
+
}
|
|
14
|
+
if (
|
|
15
|
+
!/^https?:\/\//i.test(value) || (url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password
|
|
16
|
+
|| /^https?:\/\/[^/?#]*@/i.test(value)
|
|
17
|
+
) {
|
|
18
|
+
throw new Error(`${label} must be an HTTP(S) URL without credentials`)
|
|
19
|
+
}
|
|
20
|
+
return url
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseBuilderOrigin(value: unknown, label = 'origin'): string {
|
|
24
|
+
const url = parseHttpUrl(value, label)
|
|
25
|
+
if (typeof value !== 'string' || !/^https?:\/\/[^/?#]+\/?$/i.test(value) || url.pathname !== '/' || url.search || url.hash) {
|
|
26
|
+
throw new Error(`${label} must be an origin without a path, query, or fragment`)
|
|
27
|
+
}
|
|
28
|
+
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'
|
|
29
|
+
|| url.hostname.endsWith('.localhost')
|
|
30
|
+
// Do not let URL's legacy IPv4 normalization turn shorthand or numeric hosts into loopback exceptions.
|
|
31
|
+
const authority = value.slice(value.indexOf('://') + 3).replace(/\/$/, '').toLowerCase()
|
|
32
|
+
const canonicalLoopback = url.hostname !== '127.0.0.1' || /^127\.0\.0\.1(?::\d+)?$/.test(authority)
|
|
33
|
+
if (url.protocol !== 'https:' && (!local || !canonicalLoopback)) {
|
|
34
|
+
throw new Error(`${label} must use HTTPS except on localhost`)
|
|
35
|
+
}
|
|
36
|
+
return url.origin
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseBuilderPreviewUrl(value: unknown): string {
|
|
40
|
+
return parseHttpUrl(value, 'preview URL').href
|
|
41
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -22,6 +22,17 @@ ensure daemon → fabrika builder next → do packet work → gate → advance
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
1. Bootstrap once.
|
|
25
|
+
- For an installed Zerops platform, run
|
|
26
|
+
`bunx fabrika builder init [dir] --url=https://control.example --no-open`.
|
|
27
|
+
This selects `zerops-app` for a new project. Open the returned local
|
|
28
|
+
dashboard and let the human sign in, create or select the application,
|
|
29
|
+
review its environment/namespace/domain, and approve access in the console.
|
|
30
|
+
Run `bunx fabrika builder status --json` until connected before scaffolding
|
|
31
|
+
application code. Read the selected application and placement in `next`.
|
|
32
|
+
Never request a credential or copy an approval URL into agent output.
|
|
33
|
+
For an existing `zerops-app` project, use
|
|
34
|
+
`bunx fabrika builder connect --url=https://control.example --no-open`.
|
|
35
|
+
Repeat initialization preserves the existing profile, binding and artifacts.
|
|
25
36
|
- If `.fabrika/` is absent, run
|
|
26
37
|
`bunx fabrika builder init [dir] [--profile cf-lightweight]`. It installs both
|
|
27
38
|
Claude and Codex repo support by default, starts the daemon, and opens the
|
|
@@ -61,6 +72,29 @@ ensure daemon → fabrika builder next → do packet work → gate → advance
|
|
|
61
72
|
build-orchestrator that would need to spawn its own children. This keeps the
|
|
62
73
|
workflow compatible with hosts whose default subagent depth is one.
|
|
63
74
|
|
|
75
|
+
7. Run the app locally and publish its actual preview address with
|
|
76
|
+
`bunx fabrika builder preview --url=http://localhost:4321`. Use its configured
|
|
77
|
+
port; the example is not a default. Clear stale metadata with
|
|
78
|
+
`bunx fabrika builder preview --clear`.
|
|
79
|
+
|
|
80
|
+
8. For a connected Zerops project only (`zerops-app` with a connection shown by
|
|
81
|
+
`bunx fabrika builder status --json`), use the deployment loop below.
|
|
82
|
+
Cloudflare presets and offline/unconnected Zerops projects follow their stack
|
|
83
|
+
preset's own deployment guidance instead; do not run `builder deploy` for them.
|
|
84
|
+
After local verification, ask the user to explicitly authorize a commit and
|
|
85
|
+
push, or have the user do them. Never commit or push automatically. Deployment
|
|
86
|
+
requires a clean repository and a remotely available commit. Then run
|
|
87
|
+
`bunx fabrika builder deploy --wait --timeout=600 --json`. The local daemon
|
|
88
|
+
performs deployment through the connected platform. A trigger is not success:
|
|
89
|
+
failed, cancelled or timed-out waits exit nonzero. Keep the returned run ID
|
|
90
|
+
and inspect `bunx fabrika builder status --json` and the local dashboard.
|
|
91
|
+
|
|
92
|
+
9. For subsequent changes to a connected Zerops project, reuse the connection: build locally, review and test,
|
|
93
|
+
obtain explicit commit/push authorization, then deploy again. Do not create a
|
|
94
|
+
duplicate app. `bunx fabrika builder disconnect` removes local custody only;
|
|
95
|
+
the human revokes server authorization in the console. Keep coding agents
|
|
96
|
+
local; this workflow does not configure remote Claude or Codex environments.
|
|
97
|
+
|
|
64
98
|
## Invariants
|
|
65
99
|
|
|
66
100
|
- Treat CLI output as the source of truth for phase, work list, gate protocol,
|
package/stacks/README.md
CHANGED
|
@@ -36,6 +36,8 @@ is a **starting point**, not a lock-in — add/drop decisions freely.
|
|
|
36
36
|
|
|
37
37
|
## Presets
|
|
38
38
|
|
|
39
|
+
- **`zerops-app`** — local Bun + PostgreSQL + React/Buzola, Fabrika IAM and proxy access, deployed through an installed Fabrika Control plane.
|
|
40
|
+
|
|
39
41
|
- **`cf-lightweight`** — buzola SPA + lopata + D1, **no SSR**. The preferred default and simplest end-to-end fabrika stack (bun/lopata path).
|
|
40
42
|
- **`cf-internal-app`** — the full internal-app stack: Buzola SPA +
|
|
41
43
|
Worker API, D1/R2/Queue/Cron/Workers AI, native Propustka login and Vozka deploy.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: zerops-app
|
|
3
|
+
title: "Platform-managed Zerops app — Bun + PostgreSQL + Fabrika IAM"
|
|
4
|
+
goal: "Build and verify a local React/Buzola app behind real IAM and proxy gates, then deploy its available Git revision through installed Fabrika Control."
|
|
5
|
+
decisions:
|
|
6
|
+
- zerops-platform
|
|
7
|
+
- bun
|
|
8
|
+
- postgres
|
|
9
|
+
- react
|
|
10
|
+
- buzola
|
|
11
|
+
- tailwind
|
|
12
|
+
- zerops-fabrika-auth
|
|
13
|
+
- zerops-local-app
|
|
14
|
+
- zerops-control-deploy
|
|
15
|
+
- zerops-postgres-seeds
|
|
16
|
+
- opice
|
|
17
|
+
build: "see §Local verification below"
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Stack: zerops-app
|
|
21
|
+
|
|
22
|
+
The supported catalog path for a **platform-managed Zerops application**: a
|
|
23
|
+
long-running Bun application with PostgreSQL and a client-rendered React/Buzola
|
|
24
|
+
UI. Coding agents and application development run locally. The installed Control
|
|
25
|
+
service owns production deployment.
|
|
26
|
+
|
|
27
|
+
**Maturity:** Fabrika, Buzola and opice are evolving 0.x components. This preset is
|
|
28
|
+
an explicit opt-in to that stack, not a claim of general production readiness.
|
|
29
|
+
Read installed contracts and the linked public examples before scaffolding.
|
|
30
|
+
|
|
31
|
+
## Composition
|
|
32
|
+
|
|
33
|
+
- `zerops-platform` owns provider configuration and private runtime services.
|
|
34
|
+
- `zerops-fabrika-auth` supplies IAM token verification and proxy-only gates.
|
|
35
|
+
- `zerops-local-app` composes the Bun application, SPA and local IAM/proxy loop.
|
|
36
|
+
- `zerops-control-deploy` defines the human/admin/agent deployment boundary.
|
|
37
|
+
- `zerops-postgres-seeds` preserves opice's named SQL fixture contract on PostgreSQL.
|
|
38
|
+
|
|
39
|
+
The selection is requires-closed and occupies each exclusive slot once. Reuse
|
|
40
|
+
`bun`, `postgres`, `react`, `buzola`, `tailwind` and `opice`. Their optional
|
|
41
|
+
recommendations do not select Cloudflare tooling or another router. The D1-only
|
|
42
|
+
`named-seeds` decision is not part of this preset.
|
|
43
|
+
|
|
44
|
+
## Entry conditions
|
|
45
|
+
|
|
46
|
+
The human creates or selects the application in the installed console and reviews
|
|
47
|
+
its environment and destination. An administrator owns repository creation,
|
|
48
|
+
GitHub connection, namespaces and domain routing. The agent uses the selected app
|
|
49
|
+
identity in local source and configuration; it does not provision an installation
|
|
50
|
+
or choose a replacement destination when prerequisites are missing.
|
|
51
|
+
|
|
52
|
+
## Local verification
|
|
53
|
+
|
|
54
|
+
1. Install with Bun and typecheck the actual provider config and application.
|
|
55
|
+
2. Start the app's documented local composition: PostgreSQL, IAM, proxy and Bun
|
|
56
|
+
runtime. Apply migrations and the scenario's PostgreSQL seeds locally.
|
|
57
|
+
3. Open the app's **proxy URL**, complete IAM login, and run the targeted opice
|
|
58
|
+
scenario against that URL. Verify denied access with a real restricted identity.
|
|
59
|
+
4. Run application unit tests, typechecking and its production asset build.
|
|
60
|
+
Include the built assets in the root `zerops.yaml` deploy files.
|
|
61
|
+
5. Report the actual local preview URL and checks. Production deployment follows
|
|
62
|
+
`zerops-control-deploy`; a passing local test is not a successful remote run.
|
|
63
|
+
|
|
64
|
+
The public notes example supplies the backend/config reference, not a ready-made
|
|
65
|
+
SPA or arbitrary-project dev launcher. Scaffold and verify the local composition
|
|
66
|
+
described in `zerops-local-app` before claiming the application runs locally.
|