genesis-compiler 1.2.0 → 1.2.2
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/README.md +47 -18
- package/docs/stack-components.md +70 -14
- package/package.json +1 -1
- package/plugins/genesis/.codex-plugin/plugin.json +1 -1
- package/prompts/start.txt +5 -0
- package/skills/genesis-project/SKILL.md +6 -3
- package/src/index/check.js +5 -1
- package/src/index/deployment.js +31 -0
- package/src/index/environment-files.js +10 -3
- package/src/index/launch.js +6 -1
- package/src/index/prompt.js +5 -1
- package/src/index/stack-catalog.js +1 -0
- package/src/index/stack-deployment.js +204 -0
- package/src/index/stack-environment-defaults.js +79 -0
- package/src/index/stack-launch.js +37 -0
- package/src/index/stack-piece.js +16 -2
- package/src/index/stack-workspace-setup.js +7 -3
- package/src/index/stack.js +27 -0
- package/src/index/verification.js +31 -1
- package/src/index/workspace-setup.js +25 -10
- package/src/index.js +5 -0
- package/stacks/pieces/jskit-mysql.md +10 -1
- package/stacks/pieces/jskit-postgresql.md +46 -0
- package/stacks/pieces/jskit.md +19 -9
- package/stacks/pieces/postgresql.md +18 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { GenesisError } from './errors.js';
|
|
2
|
+
import {
|
|
3
|
+
isCanonicalProjectWorkdir,
|
|
4
|
+
isSafeProcessExecutable,
|
|
5
|
+
parseBacktickedArguments,
|
|
6
|
+
} from './stack-process.js';
|
|
7
|
+
|
|
8
|
+
const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
9
|
+
const STEP_LINE = /^- (Prepare|Build|Migrate|Serve) `([^`\r\n]+)`:[ \t]+(.+)$/u;
|
|
10
|
+
const STEP_ROLE_ORDER = Object.freeze({
|
|
11
|
+
prepare: 0,
|
|
12
|
+
build: 1,
|
|
13
|
+
migrate: 2,
|
|
14
|
+
serve: 3,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function invalid(deploymentPath, message, line, details = {}) {
|
|
18
|
+
throw new GenesisError('STACK_DEPLOYMENT_INVALID', message, {
|
|
19
|
+
path: deploymentPath,
|
|
20
|
+
...(line === undefined ? {} : { line }),
|
|
21
|
+
...details,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function oneToken(source, prefix, deploymentPath, label, line) {
|
|
26
|
+
const values = parseBacktickedArguments(source.slice(prefix.length));
|
|
27
|
+
if (!values || values.length !== 1) {
|
|
28
|
+
invalid(deploymentPath, `${label} accepts exactly one backticked value.`, line);
|
|
29
|
+
}
|
|
30
|
+
return values[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseRuntimes(source, deploymentPath, line) {
|
|
34
|
+
const values = parseBacktickedArguments(source.slice('- Runtimes: '.length));
|
|
35
|
+
if (!values || values.some((value) => !RUNTIME_ID.test(value))) {
|
|
36
|
+
invalid(
|
|
37
|
+
deploymentPath,
|
|
38
|
+
'Deployment runtimes must be separate backticked technology ids.',
|
|
39
|
+
line,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (new Set(values).size !== values.length) {
|
|
43
|
+
invalid(deploymentPath, 'Deployment contains a duplicate runtime.', line);
|
|
44
|
+
}
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseUrlPath(value, deploymentPath, line) {
|
|
49
|
+
const normalized = String(value || '').trim();
|
|
50
|
+
if (!normalized.startsWith('/') || normalized.startsWith('//') || /[\\?#]/u.test(normalized)) {
|
|
51
|
+
invalid(
|
|
52
|
+
deploymentPath,
|
|
53
|
+
'Deployment readiness path must begin with one slash and contain no query or fragment.',
|
|
54
|
+
line,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseReadiness(source, deploymentPath, line) {
|
|
61
|
+
const match = source.match(/^- Ready when: `GET` `([^`\r\n]+)` returns `([0-9]{3})`$/u);
|
|
62
|
+
if (!match) {
|
|
63
|
+
invalid(
|
|
64
|
+
deploymentPath,
|
|
65
|
+
'Deployment readiness must use `- Ready when: `GET` `/path` returns `200``.',
|
|
66
|
+
line,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const status = Number(match[2]);
|
|
70
|
+
if (!Number.isInteger(status) || status < 200 || status > 399) {
|
|
71
|
+
invalid(deploymentPath, 'Deployment readiness status must be from 200 through 399.', line);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
kind: 'http',
|
|
75
|
+
method: 'GET',
|
|
76
|
+
path: parseUrlPath(match[1], deploymentPath, line),
|
|
77
|
+
status,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseStep(match, deploymentPath, line) {
|
|
82
|
+
const label = match[2].trim();
|
|
83
|
+
const argv = parseBacktickedArguments(match[3]);
|
|
84
|
+
if (!label || /[\0\r\n]/u.test(label)) {
|
|
85
|
+
invalid(deploymentPath, 'Deployment step labels must be non-empty single lines.', line);
|
|
86
|
+
}
|
|
87
|
+
if (!argv || argv.some((value) => value.includes('\0')) || !isSafeProcessExecutable(argv[0])) {
|
|
88
|
+
invalid(
|
|
89
|
+
deploymentPath,
|
|
90
|
+
'Deployment command and arguments must be separate non-empty backticked values with a safe executable.',
|
|
91
|
+
line,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
label,
|
|
96
|
+
argv,
|
|
97
|
+
role: match[1].toLowerCase(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseStackDeploymentLines(lines, {
|
|
102
|
+
path: deploymentPath = 'stack deployment',
|
|
103
|
+
} = {}) {
|
|
104
|
+
if (lines === undefined) return null;
|
|
105
|
+
const entries = lines.map((line) => line.trim()).filter(Boolean);
|
|
106
|
+
if (entries.length === 1 && entries[0] === '- Nothing.') return { version: 1, steps: [] };
|
|
107
|
+
|
|
108
|
+
const result = {
|
|
109
|
+
version: 1,
|
|
110
|
+
workdir: '.',
|
|
111
|
+
runtimeRequirements: [],
|
|
112
|
+
readiness: null,
|
|
113
|
+
steps: [],
|
|
114
|
+
};
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
117
|
+
const source = lines[index].trim();
|
|
118
|
+
if (!source) continue;
|
|
119
|
+
const line = index + 1;
|
|
120
|
+
if (source.startsWith('- Workdir: ')) {
|
|
121
|
+
if (seen.has('workdir')) invalid(deploymentPath, 'Duplicate Deployment Workdir.', line);
|
|
122
|
+
seen.add('workdir');
|
|
123
|
+
const workdir = oneToken(source, '- Workdir: ', deploymentPath, 'Deployment Workdir', line);
|
|
124
|
+
if (!isCanonicalProjectWorkdir(workdir)) {
|
|
125
|
+
invalid(deploymentPath, 'Deployment workdir must be a canonical project-relative directory.', line);
|
|
126
|
+
}
|
|
127
|
+
result.workdir = workdir;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (source.startsWith('- Runtimes: ')) {
|
|
131
|
+
if (seen.has('runtimes')) invalid(deploymentPath, 'Duplicate Deployment Runtimes.', line);
|
|
132
|
+
seen.add('runtimes');
|
|
133
|
+
result.runtimeRequirements = parseRuntimes(source, deploymentPath, line);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (source.startsWith('- Ready when: ')) {
|
|
137
|
+
if (seen.has('readiness')) invalid(deploymentPath, 'Duplicate Deployment Ready when.', line);
|
|
138
|
+
seen.add('readiness');
|
|
139
|
+
result.readiness = parseReadiness(source, deploymentPath, line);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const step = source.match(STEP_LINE);
|
|
143
|
+
if (step) {
|
|
144
|
+
result.steps.push(parseStep(step, deploymentPath, line));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
invalid(deploymentPath, `Unknown Deployment entry: ${source}.`, line);
|
|
148
|
+
}
|
|
149
|
+
if (result.steps.length === 0) {
|
|
150
|
+
invalid(deploymentPath, '## Deployment needs at least one command or exactly `- Nothing.`.');
|
|
151
|
+
}
|
|
152
|
+
const serveSteps = result.steps.filter(({ role }) => role === 'serve');
|
|
153
|
+
if (serveSteps.length !== 1 || result.steps.at(-1).role !== 'serve') {
|
|
154
|
+
invalid(deploymentPath, 'Deployment needs exactly one final Serve step.');
|
|
155
|
+
}
|
|
156
|
+
for (let index = 1; index < result.steps.length; index += 1) {
|
|
157
|
+
if (STEP_ROLE_ORDER[result.steps[index].role] < STEP_ROLE_ORDER[result.steps[index - 1].role]) {
|
|
158
|
+
invalid(
|
|
159
|
+
deploymentPath,
|
|
160
|
+
'Deployment steps must be ordered Prepare, Build, Migrate, then Serve.',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!result.readiness) {
|
|
165
|
+
invalid(deploymentPath, 'Deployment needs one Ready when entry.');
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function composeStackDeployment(components, projectDeployment) {
|
|
171
|
+
if (projectDeployment) return { ...projectDeployment, source: 'project', diagnostics: [] };
|
|
172
|
+
const declarations = components
|
|
173
|
+
.filter((component) => component.deployment?.steps?.length > 0)
|
|
174
|
+
.map((component) => ({
|
|
175
|
+
...component.deployment,
|
|
176
|
+
source: `component:${component.id}`,
|
|
177
|
+
}));
|
|
178
|
+
if (declarations.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
version: 1,
|
|
181
|
+
workdir: '.',
|
|
182
|
+
runtimeRequirements: [],
|
|
183
|
+
readiness: null,
|
|
184
|
+
steps: [],
|
|
185
|
+
source: null,
|
|
186
|
+
diagnostics: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (declarations.length === 1) return { ...declarations[0], diagnostics: [] };
|
|
190
|
+
const sources = declarations.map(({ source }) => source).sort();
|
|
191
|
+
return {
|
|
192
|
+
version: 1,
|
|
193
|
+
workdir: '.',
|
|
194
|
+
runtimeRequirements: [],
|
|
195
|
+
readiness: null,
|
|
196
|
+
steps: [],
|
|
197
|
+
source: null,
|
|
198
|
+
diagnostics: [{
|
|
199
|
+
code: 'STACK_DEPLOYMENT_AMBIGUOUS',
|
|
200
|
+
message: `Selected Stack components provide competing Deployment recipes: ${sources.join(', ')}. Add one project ## Deployment section to choose the exact recipe.`,
|
|
201
|
+
details: { sources },
|
|
202
|
+
}],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { GenesisError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_LINE = /^- Default `([A-Za-z_][A-Za-z0-9_]*)`: `([^`\r\n]+)`[ \t]*$/u;
|
|
4
|
+
|
|
5
|
+
function invalid(defaultsPath, message, line, details = {}) {
|
|
6
|
+
throw new GenesisError('STACK_ENVIRONMENT_DEFAULTS_INVALID', message, {
|
|
7
|
+
path: defaultsPath,
|
|
8
|
+
...(line === undefined ? {} : { line }),
|
|
9
|
+
...details,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseStackEnvironmentDefaultLines(lines, {
|
|
14
|
+
path: defaultsPath = 'stack environment defaults',
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (lines === undefined) return [];
|
|
17
|
+
const defaults = [];
|
|
18
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
19
|
+
const source = lines[index].trim();
|
|
20
|
+
if (!source) continue;
|
|
21
|
+
const entry = source.match(DEFAULT_LINE);
|
|
22
|
+
if (!entry) {
|
|
23
|
+
invalid(
|
|
24
|
+
defaultsPath,
|
|
25
|
+
'Every Environment defaults entry must be a Default bullet with a backticked name and value.',
|
|
26
|
+
index + 1,
|
|
27
|
+
{ observed: source },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
defaults.push({ name: entry[1], value: entry[2] });
|
|
31
|
+
}
|
|
32
|
+
if (defaults.length === 0) {
|
|
33
|
+
invalid(defaultsPath, '## Environment defaults needs at least one Default entry.');
|
|
34
|
+
}
|
|
35
|
+
if (new Set(defaults.map(({ name }) => name)).size !== defaults.length) {
|
|
36
|
+
invalid(defaultsPath, '## Environment defaults contains a duplicate name.');
|
|
37
|
+
}
|
|
38
|
+
return defaults;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function composeStackEnvironmentDefaults(components) {
|
|
42
|
+
const defaults = new Map();
|
|
43
|
+
for (const component of components) {
|
|
44
|
+
for (const declaration of component.environmentDefaults) {
|
|
45
|
+
const existing = defaults.get(declaration.name);
|
|
46
|
+
if (existing && existing.value !== declaration.value) {
|
|
47
|
+
invalid(
|
|
48
|
+
`component:${component.id}`,
|
|
49
|
+
`Stack components declare incompatible defaults for ${declaration.name}.`,
|
|
50
|
+
undefined,
|
|
51
|
+
{
|
|
52
|
+
name: declaration.name,
|
|
53
|
+
sources: [...existing.sources, `component:${component.id}`],
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (existing) {
|
|
58
|
+
existing.sources.push(`component:${component.id}`);
|
|
59
|
+
} else {
|
|
60
|
+
defaults.set(declaration.name, {
|
|
61
|
+
...declaration,
|
|
62
|
+
sources: [`component:${component.id}`],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...defaults.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Apply public Stack constants beneath explicit host or user environment. */
|
|
71
|
+
export function withStackEnvironmentDefaults(environment, defaults = []) {
|
|
72
|
+
if (!environment || typeof environment !== 'object' || Array.isArray(environment)) {
|
|
73
|
+
throw new TypeError('Stack environment resolution requires an environment object.');
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...Object.fromEntries(defaults.map(({ name, value }) => [name, value])),
|
|
77
|
+
...environment,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -118,6 +118,7 @@ function targetDraft(match, launchPath, line) {
|
|
|
118
118
|
workdir: '.',
|
|
119
119
|
preferredPort: null,
|
|
120
120
|
urlPath: '/',
|
|
121
|
+
readiness: null,
|
|
121
122
|
runtimeRequirements: [],
|
|
122
123
|
steps: [],
|
|
123
124
|
previewIdentity: null,
|
|
@@ -126,6 +127,27 @@ function targetDraft(match, launchPath, line) {
|
|
|
126
127
|
};
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
function parseReadiness(source, launchPath, line) {
|
|
131
|
+
const match = source.match(/^- Ready when: `GET` `([^`\r\n]+)` returns `([0-9]{3})`$/u);
|
|
132
|
+
if (!match) {
|
|
133
|
+
invalid(
|
|
134
|
+
launchPath,
|
|
135
|
+
'Launch target readiness must use `- Ready when: `GET` `/path` returns `200``.',
|
|
136
|
+
line,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const status = Number(match[2]);
|
|
140
|
+
if (!Number.isInteger(status) || status < 200 || status > 399) {
|
|
141
|
+
invalid(launchPath, 'Launch target readiness status must be from 200 through 399.', line);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
kind: 'http',
|
|
145
|
+
method: 'GET',
|
|
146
|
+
path: parseUrlPath(match[1], launchPath, line),
|
|
147
|
+
status,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
function parsePreferredPort(value, launchPath, line) {
|
|
130
152
|
if (!/^[0-9]+$/u.test(value)) {
|
|
131
153
|
invalid(launchPath, 'Launch target preferred port must be an integer from 1024 through 65535.', line);
|
|
@@ -204,6 +226,17 @@ function parseTargetLine(draft, source, launchPath, line) {
|
|
|
204
226
|
);
|
|
205
227
|
return;
|
|
206
228
|
}
|
|
229
|
+
if (source.startsWith('- Ready when: ')) {
|
|
230
|
+
setOnce(
|
|
231
|
+
draft,
|
|
232
|
+
'readiness',
|
|
233
|
+
parseReadiness(source, launchPath, line),
|
|
234
|
+
launchPath,
|
|
235
|
+
line,
|
|
236
|
+
'Launch target Ready when entry',
|
|
237
|
+
);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
207
240
|
const step = source.match(/^- (Prepare|Serve) `([^`\r\n]+)`:[ \t]+(.+)$/u);
|
|
208
241
|
if (step) {
|
|
209
242
|
draft.steps.push({
|
|
@@ -336,6 +369,9 @@ function normalizeTarget(draft, launchPath) {
|
|
|
336
369
|
if (serverSteps.length !== 1 || draft.steps.at(-1).role !== 'server') {
|
|
337
370
|
invalid(launchPath, `Launch target ${draft.id} needs exactly one final Serve step.`, draft.line);
|
|
338
371
|
}
|
|
372
|
+
if (!draft.readiness) {
|
|
373
|
+
invalid(launchPath, `Launch target ${draft.id} needs one Ready when entry.`, draft.line);
|
|
374
|
+
}
|
|
339
375
|
return {
|
|
340
376
|
id: draft.id,
|
|
341
377
|
label: draft.label,
|
|
@@ -343,6 +379,7 @@ function normalizeTarget(draft, launchPath) {
|
|
|
343
379
|
workdir: draft.workdir,
|
|
344
380
|
preferredPort: draft.preferredPort,
|
|
345
381
|
urlPath: draft.urlPath,
|
|
382
|
+
readiness: draft.readiness,
|
|
346
383
|
runtimeRequirements: draft.runtimeRequirements,
|
|
347
384
|
steps: draft.steps,
|
|
348
385
|
...(draft.previewIdentity
|
package/src/index/stack-piece.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { GenesisError } from './errors.js';
|
|
2
2
|
import { parseStackCommandLines } from './stack-command.js';
|
|
3
|
+
import { parseStackDeploymentLines } from './stack-deployment.js';
|
|
4
|
+
import { parseStackEnvironmentDefaultLines } from './stack-environment-defaults.js';
|
|
3
5
|
import { parseStackEnvironmentFileLines } from './stack-environment-files.js';
|
|
4
6
|
import { parseStackLaunchLines } from './stack-launch.js';
|
|
5
7
|
import { parseStackWorkspaceSetupLines } from './stack-workspace-setup.js';
|
|
@@ -165,10 +167,12 @@ function resources(all, piecePath) {
|
|
|
165
167
|
!resource
|
|
166
168
|
|| typeof resource.id !== 'string'
|
|
167
169
|
|| !STACK_PIECE_ID_PATTERN.test(resource.id)
|
|
170
|
+
|| typeof resource.kind !== 'string'
|
|
171
|
+
|| !STACK_PIECE_ID_PATTERN.test(resource.kind)
|
|
168
172
|
|| !Array.isArray(resource.environmentAlternatives)
|
|
169
173
|
|| resource.environmentAlternatives.length === 0
|
|
170
174
|
) {
|
|
171
|
-
invalid(piecePath, 'A Stack resource needs an id and at least one environment alternative.');
|
|
175
|
+
invalid(piecePath, 'A Stack resource needs an id, kind, and at least one environment alternative.');
|
|
172
176
|
}
|
|
173
177
|
const environmentAlternatives = resource.environmentAlternatives.map((alternative) => {
|
|
174
178
|
const required = alternative?.required;
|
|
@@ -189,7 +193,7 @@ function resources(all, piecePath) {
|
|
|
189
193
|
) invalid(piecePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
|
|
190
194
|
return { required: [...required], allowEmpty: [...allowEmpty] };
|
|
191
195
|
});
|
|
192
|
-
return { id: resource.id, environmentAlternatives };
|
|
196
|
+
return { id: resource.id, kind: resource.kind, environmentAlternatives };
|
|
193
197
|
});
|
|
194
198
|
if (new Set(parsed.map(({ id }) => id)).size !== parsed.length) {
|
|
195
199
|
invalid(piecePath, '## Resources contains a duplicate resource id.');
|
|
@@ -216,6 +220,10 @@ export function parseStackPieceSource(value, {
|
|
|
216
220
|
const deslop = textSection(all, 'Deslop');
|
|
217
221
|
if (!description) invalid(piecePath, 'Stack piece needs Description.');
|
|
218
222
|
const commands = parseStackCommandLines(all.get('Commands') || [], { path: piecePath });
|
|
223
|
+
const environmentDefaults = parseStackEnvironmentDefaultLines(
|
|
224
|
+
all.has('Environment defaults') ? all.get('Environment defaults') : undefined,
|
|
225
|
+
{ path: piecePath },
|
|
226
|
+
);
|
|
219
227
|
const environmentFiles = parseStackEnvironmentFileLines(
|
|
220
228
|
all.has('Environment files') ? all.get('Environment files') : undefined,
|
|
221
229
|
{ path: piecePath },
|
|
@@ -223,6 +231,10 @@ export function parseStackPieceSource(value, {
|
|
|
223
231
|
const launch = parseStackLaunchLines(all.has('Launch') ? all.get('Launch') : undefined, {
|
|
224
232
|
path: piecePath,
|
|
225
233
|
});
|
|
234
|
+
const deployment = parseStackDeploymentLines(
|
|
235
|
+
all.has('Deployment') ? all.get('Deployment') : undefined,
|
|
236
|
+
{ path: piecePath },
|
|
237
|
+
);
|
|
226
238
|
const workspaceSetup = parseStackWorkspaceSetupLines(
|
|
227
239
|
all.has('Workspace setup') ? all.get('Workspace setup') : undefined,
|
|
228
240
|
{ path: piecePath },
|
|
@@ -237,8 +249,10 @@ export function parseStackPieceSource(value, {
|
|
|
237
249
|
skill: skill(all, piecePath),
|
|
238
250
|
resources: resources(all, piecePath),
|
|
239
251
|
commands: commands.map(({ line: _line, ...command }) => command),
|
|
252
|
+
environmentDefaults,
|
|
240
253
|
environmentFiles: environmentFiles || [],
|
|
241
254
|
launchTargets: launch?.targets || [],
|
|
255
|
+
deployment,
|
|
242
256
|
workspaceSetupSteps: workspaceSetup || [],
|
|
243
257
|
deslop,
|
|
244
258
|
};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
const PREPARE_LINE = /^- Prepare `([^`\r\n]+)` with (.+?):[ \t]+(.+)$/u;
|
|
9
9
|
const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
10
10
|
const READY_WHEN_SUFFIX = /^(.*?)[ \t]+when `([^`\r\n]+)` exists$/u;
|
|
11
|
+
const OPTIONAL_WHEN_SUFFIX = /^(.*?)[ \t]+if `([^`\r\n]+)` exists$/u;
|
|
11
12
|
const WORKDIR_SUFFIX = /^(.*?)[ \t]+in `([^`\r\n]+)`$/u;
|
|
12
13
|
|
|
13
14
|
function invalid(setupPath, message, line, details = {}) {
|
|
@@ -59,7 +60,7 @@ export function parseStackWorkspaceSetupLines(lines, {
|
|
|
59
60
|
if (!entry) {
|
|
60
61
|
invalid(
|
|
61
62
|
setupPath,
|
|
62
|
-
'Every Workspace setup entry must use `- Prepare `label` with `runtime` [in `workdir`] [when `path` exists]: `command` `argument`...`.',
|
|
63
|
+
'Every Workspace setup entry must use `- Prepare `label` with `runtime` [in `workdir`] [when|if `path` exists]: `command` `argument`...`.',
|
|
63
64
|
line,
|
|
64
65
|
{ observed: source },
|
|
65
66
|
);
|
|
@@ -69,9 +70,11 @@ export function parseStackWorkspaceSetupLines(lines, {
|
|
|
69
70
|
invalid(setupPath, 'Workspace setup labels must be non-empty single lines.', line);
|
|
70
71
|
}
|
|
71
72
|
const readyEntry = entry[2].match(READY_WHEN_SUFFIX);
|
|
72
|
-
const
|
|
73
|
+
const optionalEntry = readyEntry ? null : entry[2].match(OPTIONAL_WHEN_SUFFIX);
|
|
74
|
+
const setupSource = readyEntry ? readyEntry[1] : optionalEntry ? optionalEntry[1] : entry[2];
|
|
73
75
|
const readyWhen = readyEntry ? readyEntry[2] : '';
|
|
74
|
-
|
|
76
|
+
const runWhen = optionalEntry ? optionalEntry[2] : '';
|
|
77
|
+
if ((readyWhen || runWhen) && ((readyWhen || runWhen) === '.' || !isCanonicalProjectWorkdir(readyWhen || runWhen))) {
|
|
75
78
|
invalid(
|
|
76
79
|
setupPath,
|
|
77
80
|
'Workspace setup readiness paths must be canonical project-relative paths.',
|
|
@@ -98,6 +101,7 @@ export function parseStackWorkspaceSetupLines(lines, {
|
|
|
98
101
|
runtimeRequirements: parseRuntimeRequirements(runtimeSource, setupPath, line),
|
|
99
102
|
workdir,
|
|
100
103
|
...(readyWhen ? { readyWhen } : {}),
|
|
104
|
+
...(runWhen ? { runWhen } : {}),
|
|
101
105
|
});
|
|
102
106
|
}
|
|
103
107
|
if (steps.length === 0) {
|
package/src/index/stack.js
CHANGED
|
@@ -5,7 +5,11 @@ import { syncProjectSkills } from './agent-skills.js';
|
|
|
5
5
|
import { GenesisError } from './errors.js';
|
|
6
6
|
import { readBuiltinStackCatalog } from './stack-catalog.js';
|
|
7
7
|
import { parseStackCommandLines } from './stack-command.js';
|
|
8
|
+
import { composeStackDeployment, parseStackDeploymentLines } from './stack-deployment.js';
|
|
8
9
|
import { resolveStackPieces } from './stack-composition.js';
|
|
10
|
+
import {
|
|
11
|
+
composeStackEnvironmentDefaults,
|
|
12
|
+
} from './stack-environment-defaults.js';
|
|
9
13
|
import {
|
|
10
14
|
composeStackEnvironmentFiles,
|
|
11
15
|
parseStackEnvironmentFileLines,
|
|
@@ -30,6 +34,7 @@ const STACK_SECTIONS = new Set([
|
|
|
30
34
|
'Environment files',
|
|
31
35
|
'Workspace setup',
|
|
32
36
|
'Commands',
|
|
37
|
+
'Deployment',
|
|
33
38
|
'Launch',
|
|
34
39
|
]);
|
|
35
40
|
const STACK_CUSTOMIZATION_ROOT = 'genesis/stack';
|
|
@@ -97,6 +102,7 @@ function withoutOuterBlankLines(lines = []) {
|
|
|
97
102
|
function renderStack({
|
|
98
103
|
commandLines = [],
|
|
99
104
|
componentIds: componentIdsValue,
|
|
105
|
+
deploymentLines = null,
|
|
100
106
|
environmentFileLines = null,
|
|
101
107
|
launchLines = null,
|
|
102
108
|
workspaceSetupLines = null,
|
|
@@ -114,6 +120,9 @@ function renderStack({
|
|
|
114
120
|
: ['', '## Workspace setup', '', ...withoutOuterBlankLines(workspaceSetupLines)]),
|
|
115
121
|
...(commandLines.length > 0 ? ['', '## Commands', ...commandLines] : []),
|
|
116
122
|
...(launchLines === null ? [] : ['', '## Launch', '', ...withoutOuterBlankLines(launchLines)]),
|
|
123
|
+
...(deploymentLines === null
|
|
124
|
+
? []
|
|
125
|
+
: ['', '## Deployment', '', ...withoutOuterBlankLines(deploymentLines)]),
|
|
117
126
|
'',
|
|
118
127
|
].join('\n');
|
|
119
128
|
}
|
|
@@ -152,6 +161,11 @@ export async function addStackPieces({ pieces, projectRoot }) {
|
|
|
152
161
|
);
|
|
153
162
|
const launchLines = sections.has('Launch') ? sections.get('Launch') : null;
|
|
154
163
|
parseStackLaunchLines(launchLines === null ? undefined : launchLines, { path: STACK_PATH });
|
|
164
|
+
const deploymentLines = sections.has('Deployment') ? sections.get('Deployment') : null;
|
|
165
|
+
parseStackDeploymentLines(
|
|
166
|
+
deploymentLines === null ? undefined : deploymentLines,
|
|
167
|
+
{ path: STACK_PATH },
|
|
168
|
+
);
|
|
155
169
|
const workspaceSetupLines = sections.has('Workspace setup')
|
|
156
170
|
? sections.get('Workspace setup')
|
|
157
171
|
: null;
|
|
@@ -162,6 +176,7 @@ export async function addStackPieces({ pieces, projectRoot }) {
|
|
|
162
176
|
const rendered = renderStack({
|
|
163
177
|
commandLines,
|
|
164
178
|
componentIds: selected,
|
|
179
|
+
deploymentLines,
|
|
165
180
|
environmentFileLines,
|
|
166
181
|
launchLines,
|
|
167
182
|
workspaceSetupLines,
|
|
@@ -250,6 +265,7 @@ export async function readStack(projectRoot) {
|
|
|
250
265
|
const componentCommands = components.flatMap((piece) => piece.commands);
|
|
251
266
|
const commands = distinctCommands(projectCommands.length > 0 ? projectCommands : componentCommands);
|
|
252
267
|
const resources = resourceDeclarations(components);
|
|
268
|
+
const environmentDefaults = composeStackEnvironmentDefaults(components);
|
|
253
269
|
const projectEnvironmentFiles = parseStackEnvironmentFileLines(
|
|
254
270
|
sections.has('Environment files') ? sections.get('Environment files') : undefined,
|
|
255
271
|
{ path: STACK_PATH },
|
|
@@ -260,6 +276,11 @@ export async function readStack(projectRoot) {
|
|
|
260
276
|
{ path: STACK_PATH },
|
|
261
277
|
);
|
|
262
278
|
const launchTargets = composeStackLaunchTargets(components, projectLaunch);
|
|
279
|
+
const projectDeployment = parseStackDeploymentLines(
|
|
280
|
+
sections.has('Deployment') ? sections.get('Deployment') : undefined,
|
|
281
|
+
{ path: STACK_PATH },
|
|
282
|
+
);
|
|
283
|
+
const deployment = composeStackDeployment(components, projectDeployment);
|
|
263
284
|
const projectWorkspaceSetup = parseStackWorkspaceSetupLines(
|
|
264
285
|
sections.has('Workspace setup') ? sections.get('Workspace setup') : undefined,
|
|
265
286
|
{ path: STACK_PATH },
|
|
@@ -270,15 +291,19 @@ export async function readStack(projectRoot) {
|
|
|
270
291
|
identityHash: sha256(stableJson({
|
|
271
292
|
components: components.map(({ id }) => id),
|
|
272
293
|
commands: commands.map(({ label, argv }) => ({ label, argv })),
|
|
294
|
+
environmentDefaults,
|
|
273
295
|
environmentFiles,
|
|
274
296
|
launchTargets,
|
|
297
|
+
deployment,
|
|
275
298
|
resources,
|
|
276
299
|
workspaceSetup,
|
|
277
300
|
})),
|
|
278
301
|
components,
|
|
279
302
|
commands,
|
|
303
|
+
environmentDefaults,
|
|
280
304
|
environmentFiles,
|
|
281
305
|
launchTargets,
|
|
306
|
+
deployment,
|
|
282
307
|
workspaceSetup,
|
|
283
308
|
resources,
|
|
284
309
|
guidance: composedProse(components, 'guidance'),
|
|
@@ -295,8 +320,10 @@ export function stackPromptContext(stack) {
|
|
|
295
320
|
requires: piece.requires,
|
|
296
321
|
})),
|
|
297
322
|
verifyCommands: stack.commands.map(({ label, argv }) => ({ label, argv })),
|
|
323
|
+
environmentDefaults: stack.environmentDefaults,
|
|
298
324
|
environmentFiles: stack.environmentFiles,
|
|
299
325
|
launchTargets: stack.launchTargets,
|
|
326
|
+
deployment: stack.deployment,
|
|
300
327
|
workspaceSetup: stack.workspaceSetup,
|
|
301
328
|
};
|
|
302
329
|
}
|
|
@@ -2,8 +2,10 @@ import { runProcess } from './process.js';
|
|
|
2
2
|
import { asDiagnostic } from './errors.js';
|
|
3
3
|
import { gitContext } from './git.js';
|
|
4
4
|
import { clearVerification, writeVerification } from './project-state.js';
|
|
5
|
+
import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
|
|
5
6
|
import { missingStackResources } from './stack-preflight.js';
|
|
6
7
|
import { readStack } from './stack.js';
|
|
8
|
+
import { inspectWorkspaceSetupForStack } from './workspace-setup.js';
|
|
7
9
|
|
|
8
10
|
async function emit(onEvent, event) {
|
|
9
11
|
try { await onEvent?.(event); } catch { /* Progress observers do not control verification. */ }
|
|
@@ -17,7 +19,34 @@ export async function verifyProject({
|
|
|
17
19
|
} = {}) {
|
|
18
20
|
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
19
21
|
const stack = await readStack(root);
|
|
20
|
-
const
|
|
22
|
+
const workspaceSetup = await inspectWorkspaceSetupForStack({ projectRoot: root, stack });
|
|
23
|
+
if (workspaceSetup.status === 'blocked') {
|
|
24
|
+
return {
|
|
25
|
+
status: 'blocked',
|
|
26
|
+
summary: workspaceSetup.diagnostics.map(({ message }) => message).join(' '),
|
|
27
|
+
commands: [],
|
|
28
|
+
diagnostics: workspaceSetup.diagnostics,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const waiting = workspaceSetup.diagnostics.filter(
|
|
32
|
+
({ code }) => code === 'STACK_WORKSPACE_SETUP_WAITING',
|
|
33
|
+
);
|
|
34
|
+
if (waiting.length > 0) {
|
|
35
|
+
return {
|
|
36
|
+
status: 'unconfigured',
|
|
37
|
+
summary: waiting.map(({ message }) => message).join(' '),
|
|
38
|
+
commands: [],
|
|
39
|
+
diagnostics: waiting,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const resolvedEnvironment = withStackEnvironmentDefaults(
|
|
43
|
+
environment,
|
|
44
|
+
stack.environmentDefaults,
|
|
45
|
+
);
|
|
46
|
+
const missing = missingStackResources({
|
|
47
|
+
environment: resolvedEnvironment,
|
|
48
|
+
resources: stack.resources,
|
|
49
|
+
});
|
|
21
50
|
if (missing.length > 0) {
|
|
22
51
|
return {
|
|
23
52
|
status: 'blocked',
|
|
@@ -47,6 +76,7 @@ export async function verifyProject({
|
|
|
47
76
|
});
|
|
48
77
|
await processRunner(command.argv[0], command.argv.slice(1), {
|
|
49
78
|
cwd: root,
|
|
79
|
+
env: resolvedEnvironment,
|
|
50
80
|
maxBytes: 32 * 1024 * 1024,
|
|
51
81
|
code: 'VERIFICATION_FAILED',
|
|
52
82
|
});
|
|
@@ -5,16 +5,22 @@ import { gitContext } from './git.js';
|
|
|
5
5
|
import { readStack } from './stack.js';
|
|
6
6
|
import { sha256, stableJson, uniqueSorted } from './utils.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
export async function inspectProjectWorkspaceSetup({
|
|
10
|
-
projectRoot,
|
|
11
|
-
} = {}) {
|
|
12
|
-
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
13
|
-
const stack = await readStack(root);
|
|
8
|
+
export async function inspectWorkspaceSetupForStack({ projectRoot: root, stack }) {
|
|
14
9
|
const diagnostics = [...stack.workspaceSetup.diagnostics];
|
|
10
|
+
const applicableSteps = [];
|
|
15
11
|
if (diagnostics.length === 0) {
|
|
16
12
|
const waitingFor = [];
|
|
17
13
|
for (const step of stack.workspaceSetup.steps) {
|
|
14
|
+
if (step.runWhen) {
|
|
15
|
+
try {
|
|
16
|
+
await access(path.join(root, step.runWhen));
|
|
17
|
+
applicableSteps.push(step);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error;
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
applicableSteps.push(step);
|
|
18
24
|
if (!step.readyWhen) continue;
|
|
19
25
|
try {
|
|
20
26
|
await access(path.join(root, step.readyWhen));
|
|
@@ -36,9 +42,9 @@ export async function inspectProjectWorkspaceSetup({
|
|
|
36
42
|
const blocked = diagnostics.some(({ code }) => code !== 'STACK_WORKSPACE_SETUP_WAITING');
|
|
37
43
|
let status = 'unconfigured';
|
|
38
44
|
if (blocked) status = 'blocked';
|
|
39
|
-
else if (!waiting &&
|
|
45
|
+
else if (!waiting && applicableSteps.length > 0) status = 'ready';
|
|
40
46
|
const recipeHash = status === 'ready'
|
|
41
|
-
? sha256(stableJson({ version: 1, steps:
|
|
47
|
+
? sha256(stableJson({ version: 1, steps: applicableSteps }))
|
|
42
48
|
: '';
|
|
43
49
|
return {
|
|
44
50
|
status,
|
|
@@ -47,9 +53,18 @@ export async function inspectProjectWorkspaceSetup({
|
|
|
47
53
|
components: stack.components.map(({ id }) => id),
|
|
48
54
|
source: stack.workspaceSetup.source,
|
|
49
55
|
runtimeRequirements: uniqueSorted(
|
|
50
|
-
|
|
56
|
+
applicableSteps.flatMap((step) => step.runtimeRequirements),
|
|
51
57
|
),
|
|
52
|
-
steps:
|
|
58
|
+
steps: applicableSteps,
|
|
53
59
|
diagnostics,
|
|
54
60
|
};
|
|
55
61
|
}
|
|
62
|
+
|
|
63
|
+
/** Read the Stack's workspace preparation recipe without executing it. */
|
|
64
|
+
export async function inspectProjectWorkspaceSetup({
|
|
65
|
+
projectRoot,
|
|
66
|
+
} = {}) {
|
|
67
|
+
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
68
|
+
const stack = await readStack(root);
|
|
69
|
+
return inspectWorkspaceSetupForStack({ projectRoot: root, stack });
|
|
70
|
+
}
|