futura-scion 0.2.4 → 0.2.5
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/bin/scion.js +37 -1
- package/package.json +1 -1
- package/src/kernel/safety.js +5 -1
- package/src/kernel/workflow.js +25 -1
- package/src/mind/plan-fullstack.js +164 -0
package/bin/scion.js
CHANGED
|
@@ -805,10 +805,46 @@ switch (cmd || '') {
|
|
|
805
805
|
const positional = restArgs.filter((a) => !a.startsWith('--'));
|
|
806
806
|
const target = positional[0]; // output directory
|
|
807
807
|
if (!target) {
|
|
808
|
-
console.error('usage: scion scaffold <template|list> <target-dir> [--name x] [--
|
|
808
|
+
console.error('usage: scion scaffold <template|fullstack|list> <target-dir> [--name x] [--port N] [--concurrency N] [--no-verify] [--dry-run]');
|
|
809
809
|
process.exitCode = 2;
|
|
810
810
|
break;
|
|
811
811
|
}
|
|
812
|
+
if (arg === 'fullstack') {
|
|
813
|
+
// COMPOSABLE CONSTRUCTION: a workflow DAG that scaffolds backend,
|
|
814
|
+
// frontend, and CLI in parallel, then wires them into one repo.
|
|
815
|
+
const { planFullstack } = await import('../src/mind/plan-fullstack.js');
|
|
816
|
+
const params = {};
|
|
817
|
+
for (const f of flags) {
|
|
818
|
+
const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
819
|
+
if (m) params[m[1]] = m[2];
|
|
820
|
+
}
|
|
821
|
+
const manifest = planFullstack(resolve(target), {
|
|
822
|
+
name: params.name,
|
|
823
|
+
...(params.port !== undefined ? { port: Number(params.port) } : {}),
|
|
824
|
+
});
|
|
825
|
+
if (flags.includes('--dry-run')) {
|
|
826
|
+
console.log(`fullstack plan for ${target}:`);
|
|
827
|
+
for (const t of manifest.tasks) console.log(` ${t.id.padEnd(20)} deps: ${(t.depends_on ?? []).join(', ') || '(parallel)'}`);
|
|
828
|
+
console.log('\nrun with --approve-as <reviewer> to construct');
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
const approveIdx = restArgs.indexOf('--approve-as');
|
|
832
|
+
const reviewer = approveIdx !== -1 ? restArgs[approveIdx + 1] : null;
|
|
833
|
+
if (!reviewer) {
|
|
834
|
+
console.error('fullstack construction is a mutating workflow — approve it: --approve-as <reviewer> (or inspect first with --dry-run)');
|
|
835
|
+
process.exitCode = 2;
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
const { runWorkflow } = await import('../src/kernel/workflow.js');
|
|
839
|
+
const conc = Number(flags.find((f) => f.startsWith('--concurrency='))?.split('=')[1]) || 3;
|
|
840
|
+
const r = await runWorkflow(manifest, { concurrency: conc, approval: { reviewer } });
|
|
841
|
+
console.log(`fullstack ${r.status}: ${r.completed}/${r.total} steps (concurrency ${conc})`);
|
|
842
|
+
for (const t of r.tasks ?? []) {
|
|
843
|
+
if (t.status !== 'completed') console.log(` ${t.status.toUpperCase()}: ${t.manifestId ?? t.id} ${t.error ?? ''}`);
|
|
844
|
+
}
|
|
845
|
+
if (r.status !== 'completed') process.exitCode = 1;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
812
848
|
const tpl = loadTemplate(arg);
|
|
813
849
|
const params = {};
|
|
814
850
|
for (const f of flags) {
|
package/package.json
CHANGED
package/src/kernel/safety.js
CHANGED
|
@@ -86,8 +86,12 @@ export function assertPathAllowed(p) {
|
|
|
86
86
|
throw new Error('safety: path must be a non-empty string');
|
|
87
87
|
}
|
|
88
88
|
const norm = p.replace(/\\/g, '/');
|
|
89
|
+
const base = norm.split('/').pop(); // final segment decides secrets, not substrings
|
|
89
90
|
for (const d of denylist()) {
|
|
90
|
-
|
|
91
|
+
// Segment-aware matching: `.env` denies the secrets file itself, not
|
|
92
|
+
// `.env.example` or `backend-service/` (substring matching blocked the
|
|
93
|
+
// scaffolded env CONTRACT — a template file, not a secret).
|
|
94
|
+
if (d === '.env' ? base === '.env' : norm.includes(d)) {
|
|
91
95
|
throw new Error(`safety: path denied by muscle.path_deny ("${d}"): ${p}`);
|
|
92
96
|
}
|
|
93
97
|
}
|
package/src/kernel/workflow.js
CHANGED
|
@@ -231,11 +231,35 @@ async function kernelCallable(fn) {
|
|
|
231
231
|
if (fn === 'ocrImage') {
|
|
232
232
|
return (args) => ocrImage(args?.image, { ...(args?.engine ? { engine: args.engine } : {}) });
|
|
233
233
|
}
|
|
234
|
+
if (fn === 'scaffold') {
|
|
235
|
+
return async (args) => {
|
|
236
|
+
const { scaffold } = await import('../mind/scaffold.js');
|
|
237
|
+
const r = await scaffold(args?.template, {
|
|
238
|
+
params: args?.params ?? {},
|
|
239
|
+
...(args?.cwd !== undefined ? { cwd: args.cwd } : {}),
|
|
240
|
+
...(args?.verify !== undefined ? { verify: args.verify } : {}),
|
|
241
|
+
});
|
|
242
|
+
if (!r.ok) throw new Error(r.error ?? `scaffold ${args?.template} failed (gate: ${r.gate?.verified})`);
|
|
243
|
+
return { template: r.template, files: r.files, gate: r.gate?.verified ?? null };
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (fn === 'scaffold.file') {
|
|
247
|
+
// Wiring step: write one glue file through the transactional applier
|
|
248
|
+
// (snapshot + rollback) with the same path guardrails as any mutation.
|
|
249
|
+
return async (args) => {
|
|
250
|
+
const { transactionalApplier } = await import('./apply.js');
|
|
251
|
+
const tx = transactionalApplier({
|
|
252
|
+
files: [{ file: args?.file, content: args?.content ?? '', description: args?.description ?? `wire ${args?.file}` }],
|
|
253
|
+
});
|
|
254
|
+
const description = tx.apply();
|
|
255
|
+
return { description, file: args?.file };
|
|
256
|
+
};
|
|
257
|
+
}
|
|
234
258
|
return null;
|
|
235
259
|
}
|
|
236
260
|
|
|
237
261
|
function callableSurface() {
|
|
238
|
-
return 'boostConsensus, ocrImage';
|
|
262
|
+
return 'boostConsensus, ocrImage, scaffold, scaffold.file';
|
|
239
263
|
}
|
|
240
264
|
|
|
241
265
|
async function execShellStep(step) {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/plan-fullstack.js — COMPOSABLE CONSTRUCTION: a fullstack scaffold is
|
|
3
|
+
* a WORKFLOW DAG (the plan-fix lineage applied to building).
|
|
4
|
+
*
|
|
5
|
+
* Wave 1 (parallel): scaffold the backend, the frontend, and the CLI into
|
|
6
|
+
* their subdirectories — three independent `scaffold` kernel calls.
|
|
7
|
+
* Wave 2 (wiring, after all of wave 1): generate the cross-component glue
|
|
8
|
+
* that only makes sense once all three exist:
|
|
9
|
+
*
|
|
10
|
+
* - frontend/src/api.js — a typed-ish fetch client for the backend
|
|
11
|
+
* - README.md — the repo-level map of all three components
|
|
12
|
+
* - .env.example — shared env contract (PORT for the backend)
|
|
13
|
+
*
|
|
14
|
+
* The wiring steps are shell-independent: they are emitted as additional
|
|
15
|
+
* `scaffold`-adjacent `call` steps writing files through the same
|
|
16
|
+
* transactional path the Gate judges. Resumability inherits from the
|
|
17
|
+
* workflow engine: a failed wiring step can be re-driven; completed
|
|
18
|
+
* scaffolds never re-run.
|
|
19
|
+
*
|
|
20
|
+
* @module mind/plan-fullstack
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
import * as trail from '../kernel/trail.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate the fullstack workflow manifest.
|
|
29
|
+
* @param {string} targetDir — repo root to construct into
|
|
30
|
+
* @param {object} [opts] — { name?, backend?, frontend?, cli?, port? }
|
|
31
|
+
* backend/frontend/cli: { template, params } overrides; defaults build
|
|
32
|
+
* node-api + static-web + node-cli.
|
|
33
|
+
* @returns {object} a workflow manifest ({ name, tasks })
|
|
34
|
+
*/
|
|
35
|
+
export function planFullstack(targetDir, opts = {}) {
|
|
36
|
+
if (!targetDir || typeof targetDir !== 'string') {
|
|
37
|
+
throw new Error('planFullstack: targetDir is required');
|
|
38
|
+
}
|
|
39
|
+
const name = opts.name ?? targetDir.split(/[\\/]/).pop() ?? 'fullstack-app';
|
|
40
|
+
const port = String(opts.port ?? 3000);
|
|
41
|
+
const backend = { template: 'node-api', dir: 'backend', params: { name: `${name}-api`, port }, ...(opts.backend ?? {}) };
|
|
42
|
+
const frontend = { template: 'static-web', dir: 'frontend', params: { name: `${name}-web`, title: name }, ...(opts.frontend ?? {}) };
|
|
43
|
+
const cli = { template: 'node-cli', dir: 'cli', params: { name: `${name}-cli` }, ...(opts.cli ?? {}) };
|
|
44
|
+
const base = String(targetDir).replace(/\\/g, '/').replace(/\/$/, '');
|
|
45
|
+
|
|
46
|
+
const tasks = [];
|
|
47
|
+
|
|
48
|
+
// ── Wave 1: the three scaffolds, mutually independent → parallel ──
|
|
49
|
+
tasks.push(
|
|
50
|
+
{ id: 'scaffold-backend', type: 'call', fn: 'scaffold', args: { template: backend.template, params: backend.params, cwd: `${base}/${backend.dir}` } },
|
|
51
|
+
{ id: 'scaffold-frontend', type: 'call', fn: 'scaffold', args: { template: frontend.template, params: frontend.params, cwd: `${base}/${frontend.dir}` } },
|
|
52
|
+
{ id: 'scaffold-cli', type: 'call', fn: 'scaffold', args: { template: cli.template, params: cli.params, cwd: `${base}/${cli.dir}` } },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// ── Wave 2: wiring — depends on ALL of wave 1 ──
|
|
56
|
+
tasks.push(
|
|
57
|
+
{
|
|
58
|
+
id: 'wire-frontend-api',
|
|
59
|
+
type: 'call',
|
|
60
|
+
fn: 'scaffold.file',
|
|
61
|
+
args: {
|
|
62
|
+
file: `${base}/${frontend.dir}/js/api.js`,
|
|
63
|
+
content: apiClientModule(port),
|
|
64
|
+
description: 'frontend API client for the backend',
|
|
65
|
+
},
|
|
66
|
+
depends_on: ['scaffold-backend', 'scaffold-frontend'],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'wire-env-contract',
|
|
70
|
+
type: 'call',
|
|
71
|
+
fn: 'scaffold.file',
|
|
72
|
+
args: {
|
|
73
|
+
file: `${base}/.env.example`,
|
|
74
|
+
content: envContract(port),
|
|
75
|
+
description: 'shared env contract',
|
|
76
|
+
},
|
|
77
|
+
depends_on: ['scaffold-backend'],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'wire-root-readme',
|
|
81
|
+
type: 'call',
|
|
82
|
+
fn: 'scaffold.file',
|
|
83
|
+
args: {
|
|
84
|
+
file: `${base}/README.md`,
|
|
85
|
+
content: rootReadme(name, { backend, frontend, cli, port }),
|
|
86
|
+
description: 'repo-level map of the three components',
|
|
87
|
+
},
|
|
88
|
+
depends_on: ['scaffold-backend', 'scaffold-frontend', 'scaffold-cli'],
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const manifest = {
|
|
93
|
+
name: `fullstack-${name}`,
|
|
94
|
+
description: `Fullstack construct: ${backend.template} backend + ${frontend.template} frontend + ${cli.template} CLI, wired into one repo`,
|
|
95
|
+
tasks,
|
|
96
|
+
};
|
|
97
|
+
trail.journal('scaffold.fullstack-plan', { name, target: base, tasks: tasks.length });
|
|
98
|
+
return manifest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The frontend's generated API client — fetch wrapper over the backend. */
|
|
102
|
+
function apiClientModule(port) {
|
|
103
|
+
return `/**
|
|
104
|
+
* api.js — generated by FS fullstack scaffold. The ONLY place the frontend
|
|
105
|
+
* knows the backend's address; change API_URL here and nowhere else.
|
|
106
|
+
*/
|
|
107
|
+
const API_URL = 'http://localhost:${port}';
|
|
108
|
+
|
|
109
|
+
async function request(path, opts = {}) {
|
|
110
|
+
const res = await fetch(\`\${API_URL}\${path}\`, {
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
...opts,
|
|
113
|
+
});
|
|
114
|
+
const body = await res.json().catch(() => ({}));
|
|
115
|
+
if (!res.ok) throw new Error(body.error ?? \`HTTP \${res.status}\`);
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const api = {
|
|
120
|
+
health: () => request('/health'),
|
|
121
|
+
listItems: () => request('/items'),
|
|
122
|
+
addItem: (title) => request('/items', { method: 'POST', body: JSON.stringify({ title }) }),
|
|
123
|
+
};
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The shared env contract. */
|
|
128
|
+
function envContract(port) {
|
|
129
|
+
return `# Generated by FS fullstack scaffold — the env contract shared by all components.
|
|
130
|
+
PORT=${port}
|
|
131
|
+
NODE_ENV=development
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The repo-level README. */
|
|
136
|
+
function rootReadme(name, { backend, frontend, cli, port }) {
|
|
137
|
+
return `# ${name}
|
|
138
|
+
|
|
139
|
+
Fullstack repo constructed by FS scaffold — three gate-verified components
|
|
140
|
+
wired into one tree. Every file passed the stack's verifier recipe (real
|
|
141
|
+
exit codes) before this run reported success.
|
|
142
|
+
|
|
143
|
+
## Layout
|
|
144
|
+
|
|
145
|
+
| Component | Template | Location |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| Backend API | \`${backend.template}\` | \`${backend.dir}/\` — serves on :${port} |
|
|
148
|
+
| Frontend | \`${frontend.template}\` | \`${frontend.dir}/\` — open \`index.html\` |
|
|
149
|
+
| CLI | \`${cli.template}\` | \`${cli.dir}/\` |
|
|
150
|
+
|
|
151
|
+
## Run
|
|
152
|
+
|
|
153
|
+
\`\`\`bash
|
|
154
|
+
cd ${backend.dir} && npm install && npm start # API on :${port}
|
|
155
|
+
cd ${frontend.dir} # open index.html (js/api.js talks to :${port})
|
|
156
|
+
cd ${cli.dir} && npm install && npm test
|
|
157
|
+
\`\`\`
|
|
158
|
+
|
|
159
|
+
## Contract
|
|
160
|
+
|
|
161
|
+
- The frontend's \`${frontend.dir}/js/api.js\` is the single seam to the backend.
|
|
162
|
+
- \`.env.example\` is the shared environment contract.
|
|
163
|
+
`;
|
|
164
|
+
}
|