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.
@@ -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
@@ -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 setupSource = readyEntry ? readyEntry[1] : entry[2];
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
- if (readyWhen && (readyWhen === '.' || !isCanonicalProjectWorkdir(readyWhen))) {
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) {
@@ -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 missing = missingStackResources({ environment, resources: stack.resources });
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
- /** Read the Stack's workspace preparation recipe without executing it. */
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 && stack.workspaceSetup.steps.length > 0) status = 'ready';
45
+ else if (!waiting && applicableSteps.length > 0) status = 'ready';
40
46
  const recipeHash = status === 'ready'
41
- ? sha256(stableJson({ version: 1, steps: stack.workspaceSetup.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
- stack.workspaceSetup.steps.flatMap((step) => step.runtimeRequirements),
56
+ applicableSteps.flatMap((step) => step.runtimeRequirements),
51
57
  ),
52
- steps: stack.workspaceSetup.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
+ }