genesis-compiler 1.2.1 → 1.2.3
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 +52 -17
- package/docs/assurance-model.md +15 -0
- package/docs/stack-components.md +113 -14
- package/package.json +2 -2
- 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/cli.js +24 -1
- package/src/index/agent-skills.js +25 -4
- package/src/index/check.js +5 -1
- package/src/index/city-presentation.js +83 -0
- package/src/index/code-index.js +24 -3
- package/src/index/deployment.js +31 -0
- package/src/index/environment-files.js +10 -3
- package/src/index/init.js +1 -1
- package/src/index/launch.js +6 -1
- package/src/index/prompt.js +5 -1
- package/src/index/stack-catalog.js +3 -0
- package/src/index/stack-city-presentation.js +178 -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 +19 -0
- package/src/index/stack-workspace-setup.js +7 -3
- package/src/index/stack.js +35 -0
- package/src/index/utils.js +1 -1
- package/src/index/verification.js +31 -1
- package/src/index/workspace-setup.js +101 -10
- package/src/index.js +13 -1
- package/stacks/pieces/jskit-mysql.md +5 -1
- package/stacks/pieces/jskit-postgresql.md +5 -1
- package/stacks/pieces/jskit.md +30 -9
package/src/index/code-index.js
CHANGED
|
@@ -3,6 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { astGrepCodeIndexers } from './code-indexers/ast-grep.js';
|
|
5
5
|
import { asDiagnostic } from './errors.js';
|
|
6
|
+
import { buildCityPresentation } from './city-presentation.js';
|
|
7
|
+
import { cityPathExcluded } from './stack-city-presentation.js';
|
|
6
8
|
import { gitContext } from './git.js';
|
|
7
9
|
import { isProjectContentPath } from './paths.js';
|
|
8
10
|
import { inspectProgram } from './program.js';
|
|
@@ -128,8 +130,14 @@ function mergeIndexedFiles(contributions) {
|
|
|
128
130
|
};
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
function machineCity({ components, contributions, diagnostics, indexers }) {
|
|
133
|
+
function machineCity({ cityRegions, components, contributions, diagnostics, indexers }) {
|
|
132
134
|
const merged = mergeIndexedFiles(contributions);
|
|
135
|
+
const structuralDistricts = directoryRecords(merged.files.map(({ path: filePath }) => filePath));
|
|
136
|
+
const presentation = buildCityPresentation({
|
|
137
|
+
districts: structuralDistricts,
|
|
138
|
+
files: merged.files,
|
|
139
|
+
regions: cityRegions,
|
|
140
|
+
});
|
|
133
141
|
const visibility = new Map(merged.functions.map((entry) => [entry.id, entry.visibility]));
|
|
134
142
|
const codeHash = sha256(stableJson(merged.files.map(({ path: filePath, hash, mode }) => ({
|
|
135
143
|
path: filePath,
|
|
@@ -141,13 +149,19 @@ function machineCity({ components, contributions, diagnostics, indexers }) {
|
|
|
141
149
|
schemaVersion: 1,
|
|
142
150
|
status: indexers.length === 0 ? 'unconfigured' : diagnostics.length > 0 ? 'completed-with-warning' : 'current',
|
|
143
151
|
codeHash,
|
|
152
|
+
presentationRegions: presentation.regions,
|
|
153
|
+
presentationCampuses: presentation.campuses,
|
|
144
154
|
stackComponents: components,
|
|
145
155
|
indexers,
|
|
146
156
|
diagnostics,
|
|
147
157
|
functions: merged.functions,
|
|
148
|
-
districts:
|
|
158
|
+
districts: structuralDistricts.map((district) => ({
|
|
159
|
+
...district,
|
|
160
|
+
...presentation.placementForPath(district.path),
|
|
161
|
+
})),
|
|
149
162
|
buildings: merged.files.map((file) => ({
|
|
150
163
|
...file,
|
|
164
|
+
...presentation.placementForPath(file.path),
|
|
151
165
|
districtId: directoryId(parentDirectory(file.path)),
|
|
152
166
|
title: path.posix.basename(file.path),
|
|
153
167
|
publicFunctionCount: file.functionIds.filter((id) => visibility.get(id) === 'public').length,
|
|
@@ -229,7 +243,13 @@ export async function buildProjectIndex({ projectRoot, queries = [], write = tru
|
|
|
229
243
|
const stack = await readStack(root);
|
|
230
244
|
const states = await gitVisibleFileStates(root, { includePath: isProjectContentPath });
|
|
231
245
|
const files = [...states]
|
|
232
|
-
.filter(([, state]) =>
|
|
246
|
+
.filter(([filePath, state]) => (
|
|
247
|
+
state.exists
|
|
248
|
+
&& !state.symlink
|
|
249
|
+
&& !state.special
|
|
250
|
+
&& state.hash
|
|
251
|
+
&& !cityPathExcluded(filePath, stack.cityExclusions)
|
|
252
|
+
))
|
|
233
253
|
.map(([filePath, state]) => ({ path: filePath, hash: state.hash, mode: state.mode }));
|
|
234
254
|
const indexers = [...new Set(stack.components.flatMap((component) => component.indexers || []))].sort();
|
|
235
255
|
const contributions = [];
|
|
@@ -249,6 +269,7 @@ export async function buildProjectIndex({ projectRoot, queries = [], write = tru
|
|
|
249
269
|
}
|
|
250
270
|
}
|
|
251
271
|
const machine = machineCity({
|
|
272
|
+
cityRegions: stack.cityRegions,
|
|
252
273
|
components: stack.components.map(({ id }) => id),
|
|
253
274
|
contributions,
|
|
254
275
|
diagnostics,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { gitContext } from './git.js';
|
|
2
|
+
import { readStack } from './stack.js';
|
|
3
|
+
import { sha256, stableJson, uniqueSorted } from './utils.js';
|
|
4
|
+
|
|
5
|
+
/** Read the Stack's production recipe without provisioning or publishing anything. */
|
|
6
|
+
export async function inspectProjectDeployment({ projectRoot } = {}) {
|
|
7
|
+
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
8
|
+
const stack = await readStack(root);
|
|
9
|
+
const diagnostics = [...stack.deployment.diagnostics];
|
|
10
|
+
let status = 'unconfigured';
|
|
11
|
+
if (diagnostics.length > 0) status = 'blocked';
|
|
12
|
+
else if (stack.deployment.steps.length > 0) status = 'ready';
|
|
13
|
+
const recipe = {
|
|
14
|
+
version: stack.deployment.version,
|
|
15
|
+
workdir: stack.deployment.workdir,
|
|
16
|
+
runtimeRequirements: stack.deployment.runtimeRequirements,
|
|
17
|
+
readiness: stack.deployment.readiness,
|
|
18
|
+
steps: stack.deployment.steps,
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
status,
|
|
22
|
+
stackHash: stack.identityHash,
|
|
23
|
+
recipeHash: status === 'ready' ? sha256(stableJson(recipe)) : '',
|
|
24
|
+
components: stack.components.map(({ id }) => id),
|
|
25
|
+
source: stack.deployment.source,
|
|
26
|
+
runtimeRequirements: uniqueSorted(stack.deployment.runtimeRequirements),
|
|
27
|
+
resources: stack.resources,
|
|
28
|
+
...recipe,
|
|
29
|
+
diagnostics,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { gitContext } from './git.js';
|
|
2
2
|
import { readStack } from './stack.js';
|
|
3
|
+
import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
|
|
3
4
|
import { missingStackResources } from './stack-preflight.js';
|
|
4
5
|
|
|
5
|
-
/** Inspect Stack environment requirements without returning
|
|
6
|
+
/** Inspect Stack environment requirements without returning supplied host values. */
|
|
6
7
|
export async function inspectProjectEnvironment({ environment = process.env, projectRoot } = {}) {
|
|
7
8
|
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
8
9
|
const stack = await readStack(root);
|
|
9
|
-
const diagnostics = missingStackResources({
|
|
10
|
-
|
|
10
|
+
const diagnostics = missingStackResources({
|
|
11
|
+
environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
|
|
12
|
+
resources: stack.resources,
|
|
13
|
+
});
|
|
14
|
+
const configured = stack.environmentDefaults.length > 0
|
|
15
|
+
|| stack.environmentFiles.length > 0
|
|
16
|
+
|| stack.resources.length > 0;
|
|
11
17
|
return {
|
|
12
18
|
status: !configured ? 'unconfigured' : diagnostics.length > 0 ? 'missing-inputs' : 'ready',
|
|
13
19
|
stackHash: stack.identityHash,
|
|
14
20
|
components: stack.components.map(({ id }) => id),
|
|
21
|
+
environmentDefaults: stack.environmentDefaults,
|
|
15
22
|
files: stack.environmentFiles,
|
|
16
23
|
resources: stack.resources,
|
|
17
24
|
diagnostics,
|
package/src/index/init.js
CHANGED
|
@@ -12,7 +12,7 @@ async function createIfMissing(projectRoot, relativePath, source) {
|
|
|
12
12
|
const location = path.join(projectRoot, relativePath);
|
|
13
13
|
await mkdir(path.dirname(location), { recursive: true });
|
|
14
14
|
try {
|
|
15
|
-
await writeFile(location, source, { flag: 'wx', mode:
|
|
15
|
+
await writeFile(location, source, { flag: 'wx', mode: 0o666 });
|
|
16
16
|
return relativePath;
|
|
17
17
|
} catch (error) {
|
|
18
18
|
if (error?.code === 'EEXIST') return null;
|
package/src/index/launch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { gitContext } from './git.js';
|
|
2
2
|
import { missingStackResources } from './stack-preflight.js';
|
|
3
|
+
import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
|
|
3
4
|
import { readStack } from './stack.js';
|
|
4
5
|
import { uniqueSorted } from './utils.js';
|
|
5
6
|
|
|
@@ -10,7 +11,10 @@ export async function inspectProjectLaunch({
|
|
|
10
11
|
} = {}) {
|
|
11
12
|
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
12
13
|
const stack = await readStack(root);
|
|
13
|
-
const diagnostics = missingStackResources({
|
|
14
|
+
const diagnostics = missingStackResources({
|
|
15
|
+
environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
|
|
16
|
+
resources: stack.resources,
|
|
17
|
+
});
|
|
14
18
|
const disabledReason = diagnostics.length === 0
|
|
15
19
|
? null
|
|
16
20
|
: diagnostics.map(({ message }) => message).join(' ');
|
|
@@ -26,6 +30,7 @@ export async function inspectProjectLaunch({
|
|
|
26
30
|
status,
|
|
27
31
|
stackHash: stack.identityHash,
|
|
28
32
|
components: stack.components.map(({ id }) => id),
|
|
33
|
+
environmentDefaults: stack.environmentDefaults,
|
|
29
34
|
runtimeRequirements: uniqueSorted(targets.flatMap((target) => target.runtimeRequirements)),
|
|
30
35
|
resources: stack.resources,
|
|
31
36
|
targets,
|
package/src/index/prompt.js
CHANGED
|
@@ -9,6 +9,7 @@ import { gitContext } from './git.js';
|
|
|
9
9
|
import { inspectProgram } from './program.js';
|
|
10
10
|
import { inspectVerification } from './project-state.js';
|
|
11
11
|
import { missingStackResources } from './stack-preflight.js';
|
|
12
|
+
import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
|
|
12
13
|
import { stableJson } from './utils.js';
|
|
13
14
|
import { gitVisibleFileStates } from './project-files.js';
|
|
14
15
|
import { isProjectContentPath } from './paths.js';
|
|
@@ -305,7 +306,10 @@ export async function generateProjectPrompt({
|
|
|
305
306
|
buildProjectIndex({ projectRoot: root, write: false }),
|
|
306
307
|
inspectProjectSkills({ projectRoot: root, stack }),
|
|
307
308
|
]);
|
|
308
|
-
const missing = missingStackResources({
|
|
309
|
+
const missing = missingStackResources({
|
|
310
|
+
environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
|
|
311
|
+
resources: stack.resources,
|
|
312
|
+
});
|
|
309
313
|
const warnings = [
|
|
310
314
|
...(program.diagnostic ? [program.diagnostic] : []),
|
|
311
315
|
...missing,
|
|
@@ -63,7 +63,10 @@ export async function listBuiltinStackPieces() {
|
|
|
63
63
|
guidance: piece.guidance,
|
|
64
64
|
requires: piece.requires,
|
|
65
65
|
conflicts: piece.conflicts,
|
|
66
|
+
cityExclusions: piece.cityExclusions,
|
|
67
|
+
cityRegions: piece.cityRegions,
|
|
66
68
|
indexers: piece.indexers,
|
|
69
|
+
environmentDefaults: piece.environmentDefaults,
|
|
67
70
|
launchTargets: piece.launchTargets.map(({ id }) => id),
|
|
68
71
|
workspaceSetup: piece.workspaceSetupSteps,
|
|
69
72
|
skill: piece.skill === null ? null : piece.skill.path,
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { GenesisError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
4
|
+
const IGNORE_LINE = /^- Ignore `([^`]+)`[ \t]*$/u;
|
|
5
|
+
const MATCH_LINE = /^- Match `([^`]+)` as `([^`]+)`: `([^`]+)\/\*\*`[ \t]*$/u;
|
|
6
|
+
const FALLBACK_LINE = /^- Fallback `([^`]+)` as `([^`]+)`[ \t]*$/u;
|
|
7
|
+
const REGEXP_SPECIAL = /[.+^${}()|[\]\\]/u;
|
|
8
|
+
const compiledGlobPatterns = new Map();
|
|
9
|
+
|
|
10
|
+
function invalid(piecePath, message) {
|
|
11
|
+
throw new GenesisError('STACK_PIECE_INVALID', message, { path: piecePath });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizedPrefix(value, piecePath) {
|
|
15
|
+
const prefix = String(value || '').trim();
|
|
16
|
+
if (
|
|
17
|
+
!prefix
|
|
18
|
+
|| prefix.startsWith('/')
|
|
19
|
+
|| prefix.includes('\\')
|
|
20
|
+
|| /[*?\[\]{}!]/u.test(prefix)
|
|
21
|
+
|| prefix.split('/').some((segment) => !segment || ['.', '..'].includes(segment))
|
|
22
|
+
) {
|
|
23
|
+
invalid(piecePath, '## City regions match paths must be canonical project-relative prefixes.');
|
|
24
|
+
}
|
|
25
|
+
return prefix;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizedGlob(value, piecePath) {
|
|
29
|
+
const pattern = String(value || '').trim();
|
|
30
|
+
if (
|
|
31
|
+
!pattern
|
|
32
|
+
|| pattern.startsWith('/')
|
|
33
|
+
|| pattern.includes('\\')
|
|
34
|
+
|| pattern.includes('***')
|
|
35
|
+
|| /[?\[\]{}!]/u.test(pattern)
|
|
36
|
+
|| pattern.split('/').some((segment) => !segment || ['.', '..'].includes(segment))
|
|
37
|
+
) {
|
|
38
|
+
invalid(
|
|
39
|
+
piecePath,
|
|
40
|
+
'## City regions ignore patterns must be canonical project-relative globs using only * and ** wildcards.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return pattern;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function regionIdentity(id, title, piecePath) {
|
|
47
|
+
if (!ID_PATTERN.test(id)) {
|
|
48
|
+
invalid(piecePath, '## City regions ids must use lowercase letters, digits, and single hyphens.');
|
|
49
|
+
}
|
|
50
|
+
if (!title.trim()) invalid(piecePath, '## City regions titles must not be empty.');
|
|
51
|
+
return { id, title: title.trim() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function globExpression(pattern) {
|
|
55
|
+
if (compiledGlobPatterns.has(pattern)) return compiledGlobPatterns.get(pattern);
|
|
56
|
+
let expression = '^';
|
|
57
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
58
|
+
const character = pattern[index];
|
|
59
|
+
if (character === '*' && pattern[index + 1] === '*') {
|
|
60
|
+
if (pattern[index + 2] === '/') {
|
|
61
|
+
expression += '(?:[^/]+/)*';
|
|
62
|
+
index += 2;
|
|
63
|
+
} else {
|
|
64
|
+
expression += '.*';
|
|
65
|
+
index += 1;
|
|
66
|
+
}
|
|
67
|
+
} else if (character === '*') {
|
|
68
|
+
expression += '[^/]*';
|
|
69
|
+
} else {
|
|
70
|
+
expression += REGEXP_SPECIAL.test(character) ? `\\${character}` : character;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const compiled = new RegExp(`${expression}$`, 'u');
|
|
74
|
+
compiledGlobPatterns.set(pattern, compiled);
|
|
75
|
+
return compiled;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function cityPathExcluded(filePath, exclusions = []) {
|
|
79
|
+
return exclusions.some((pattern) => globExpression(pattern).test(filePath));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A City presentation declaration is one complete, ordered contract. Ignore
|
|
84
|
+
* globs remove non-product files before Machine City is emitted. Matching
|
|
85
|
+
* prefixes are explicit Stack facts; one final fallback owns every remaining
|
|
86
|
+
* path. Genesis resolves both region and campus placement, so renderers never
|
|
87
|
+
* classify or filter repository paths.
|
|
88
|
+
*/
|
|
89
|
+
export function parseStackCityPresentationLines(lines, { path = 'stack piece' } = {}) {
|
|
90
|
+
if (lines === undefined) return { exclusions: [], regions: [] };
|
|
91
|
+
const content = lines.filter((line) => line.trim());
|
|
92
|
+
if (content.length === 0 || (content.length === 1 && content[0].trim() === '- Nothing.')) {
|
|
93
|
+
return { exclusions: [], regions: [] };
|
|
94
|
+
}
|
|
95
|
+
const exclusions = [];
|
|
96
|
+
const regions = [];
|
|
97
|
+
let regionDeclarationStarted = false;
|
|
98
|
+
content.forEach((line, index) => {
|
|
99
|
+
const ignore = line.match(IGNORE_LINE);
|
|
100
|
+
if (ignore) {
|
|
101
|
+
if (regionDeclarationStarted) {
|
|
102
|
+
invalid(path, '## City regions Ignore entries must precede Match and Fallback entries.');
|
|
103
|
+
}
|
|
104
|
+
exclusions.push(normalizedGlob(ignore[1], path));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
regionDeclarationStarted = true;
|
|
108
|
+
const match = line.match(MATCH_LINE);
|
|
109
|
+
if (match) {
|
|
110
|
+
const identity = regionIdentity(match[1], match[2], path);
|
|
111
|
+
regions.push({
|
|
112
|
+
...identity,
|
|
113
|
+
fallback: false,
|
|
114
|
+
pathPrefix: normalizedPrefix(match[3], path),
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const fallback = line.match(FALLBACK_LINE);
|
|
119
|
+
if (fallback) {
|
|
120
|
+
if (index !== content.length - 1) {
|
|
121
|
+
invalid(path, '## City regions fallback must be the final declaration.');
|
|
122
|
+
}
|
|
123
|
+
regions.push({
|
|
124
|
+
...regionIdentity(fallback[1], fallback[2], path),
|
|
125
|
+
fallback: true,
|
|
126
|
+
pathPrefix: null,
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
invalid(
|
|
131
|
+
path,
|
|
132
|
+
'## City regions entries must use Ignore with a glob, Match with an id, title, and path/**, or one final Fallback.',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
if (new Set(exclusions).size !== exclusions.length) {
|
|
136
|
+
invalid(path, '## City regions contains a duplicate ignore pattern.');
|
|
137
|
+
}
|
|
138
|
+
if (new Set(regions.map(({ id }) => id)).size !== regions.length) {
|
|
139
|
+
invalid(path, '## City regions contains a duplicate region id.');
|
|
140
|
+
}
|
|
141
|
+
const matches = regions.filter(({ fallback }) => !fallback);
|
|
142
|
+
if (new Set(matches.map(({ pathPrefix }) => pathPrefix)).size !== matches.length) {
|
|
143
|
+
invalid(path, '## City regions contains a duplicate path prefix.');
|
|
144
|
+
}
|
|
145
|
+
if (regions.filter(({ fallback }) => fallback).length !== 1) {
|
|
146
|
+
invalid(path, '## City regions requires exactly one final fallback.');
|
|
147
|
+
}
|
|
148
|
+
for (const [index, left] of matches.entries()) {
|
|
149
|
+
if (matches.slice(index + 1).some((right) => (
|
|
150
|
+
left.pathPrefix.startsWith(`${right.pathPrefix}/`)
|
|
151
|
+
|| right.pathPrefix.startsWith(`${left.pathPrefix}/`)
|
|
152
|
+
))) {
|
|
153
|
+
invalid(path, '## City regions match prefixes must not overlap.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { exclusions, regions };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function composeStackCityPresentation(components = []) {
|
|
160
|
+
const owners = components.filter((component) => (
|
|
161
|
+
component.cityRegions.length > 0 || component.cityExclusions.length > 0
|
|
162
|
+
));
|
|
163
|
+
if (owners.length === 0) return { exclusions: [], regions: [] };
|
|
164
|
+
if (owners.length > 1) {
|
|
165
|
+
throw new GenesisError(
|
|
166
|
+
'STACK_CITY_PRESENTATION_AMBIGUOUS',
|
|
167
|
+
`Stack components ${owners.map(({ id }) => id).join(', ')} declare competing City presentation contracts.`,
|
|
168
|
+
{ components: owners.map(({ id }) => id) },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
exclusions: [...owners[0].cityExclusions],
|
|
173
|
+
regions: owners[0].cityRegions.map((region) => ({
|
|
174
|
+
...region,
|
|
175
|
+
component: owners[0].id,
|
|
176
|
+
})),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -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
|
+
}
|