@jskit-ai/jskit-cli 0.2.128 → 0.2.130
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/package.json +6 -5
- package/src/server/cliRuntime/appState.js +5 -2
- package/src/server/cliRuntime/ci/composer.js +171 -0
- package/src/server/cliRuntime/ci/contract.js +218 -0
- package/src/server/cliRuntime/ci/githubWorkflow.js +136 -0
- package/src/server/cliRuntime/ci/managedWorkflow.js +549 -0
- package/src/server/cliRuntime/descriptorValidation.js +11 -0
- package/src/server/commandHandlers/app.js +4 -5
- package/src/server/commandHandlers/appCommandCatalog.js +20 -51
- package/src/server/commandHandlers/appCommands/syncCi.js +40 -0
- package/src/server/commandHandlers/appCommands/updatePackages.js +9 -1
- package/src/server/commandHandlers/appCommands/verify.js +12 -1
- package/src/server/commandHandlers/health.js +8 -0
- package/src/server/commandHandlers/packageCommands/add.js +28 -0
- package/src/server/commandHandlers/packageCommands/remove.js +13 -0
- package/src/server/core/argParser.js +0 -5
- package/src/server/core/buildCommandDeps.js +7 -0
- package/src/server/core/createCliRunner.js +16 -0
- package/src/server/index.js +11 -0
- package/src/server/commandHandlers/appCommands/preparePreviewUser.js +0 -215
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/jskit-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.130",
|
|
4
4
|
"description": "Bundle and package orchestration CLI for JSKIT apps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
"test": "node --test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@jskit-ai/jskit-catalog": "0.1.
|
|
24
|
-
"@jskit-ai/kernel": "0.1.
|
|
25
|
-
"@jskit-ai/shell-web": "0.1.
|
|
23
|
+
"@jskit-ai/jskit-catalog": "0.1.125",
|
|
24
|
+
"@jskit-ai/kernel": "0.1.118",
|
|
25
|
+
"@jskit-ai/shell-web": "0.1.116",
|
|
26
26
|
"@vue/compiler-sfc": "^3.5.29",
|
|
27
|
-
"ts-morph": "^28.0.0"
|
|
27
|
+
"ts-morph": "^28.0.0",
|
|
28
|
+
"yaml": "^2.8.3"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=20 <23"
|
|
@@ -66,7 +66,8 @@ async function loadAppPackageJson(appRoot) {
|
|
|
66
66
|
function createDefaultLock() {
|
|
67
67
|
return {
|
|
68
68
|
lockVersion: LOCK_VERSION,
|
|
69
|
-
installedPackages: {}
|
|
69
|
+
installedPackages: {},
|
|
70
|
+
managed: {}
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -81,12 +82,14 @@ async function loadLockFile(appRoot) {
|
|
|
81
82
|
|
|
82
83
|
const lock = await readJsonFile(lockPath);
|
|
83
84
|
const installedPackages = ensureObject(lock?.installedPackages);
|
|
85
|
+
const managed = ensureObject(lock?.managed);
|
|
84
86
|
const lockVersion = Number(lock?.lockVersion);
|
|
85
87
|
return {
|
|
86
88
|
lockPath,
|
|
87
89
|
lock: {
|
|
88
90
|
lockVersion: Number.isFinite(lockVersion) && lockVersion > 0 ? lockVersion : LOCK_VERSION,
|
|
89
|
-
installedPackages
|
|
91
|
+
installedPackages,
|
|
92
|
+
managed
|
|
90
93
|
}
|
|
91
94
|
};
|
|
92
95
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { normalizeCiContribution } from "./contract.js";
|
|
2
|
+
|
|
3
|
+
class CiCompositionError extends Error {
|
|
4
|
+
constructor(code, message, details = {}) {
|
|
5
|
+
super(`[ci:${code}] ${message}`);
|
|
6
|
+
this.name = "CiCompositionError";
|
|
7
|
+
this.code = `ci:${code}`;
|
|
8
|
+
this.details = details && typeof details === "object" ? { ...details } : {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function canonicalValue(value) {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return value.map((entry) => canonicalValue(entry));
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === "object") {
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.keys(value)
|
|
19
|
+
.sort((left, right) => left.localeCompare(right))
|
|
20
|
+
.map((key) => [key, canonicalValue(value[key])])
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function valuesMatch(left, right) {
|
|
27
|
+
return JSON.stringify(canonicalValue(left)) === JSON.stringify(canonicalValue(right));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatConflictValue(value) {
|
|
31
|
+
return JSON.stringify(canonicalValue(value));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizePackageEntries(packageEntries = []) {
|
|
35
|
+
const entries = packageEntries instanceof Map ? [...packageEntries.values()] : packageEntries;
|
|
36
|
+
if (!Array.isArray(entries)) {
|
|
37
|
+
throw new TypeError("composeCiContributions requires package entries as an array or Map.");
|
|
38
|
+
}
|
|
39
|
+
return entries
|
|
40
|
+
.map((entry) => ({
|
|
41
|
+
packageId: String(entry?.packageId || entry?.descriptor?.packageId || "").trim(),
|
|
42
|
+
descriptor: entry?.descriptor && typeof entry.descriptor === "object" ? entry.descriptor : {}
|
|
43
|
+
}))
|
|
44
|
+
.filter((entry) => Boolean(entry.packageId))
|
|
45
|
+
.sort((left, right) => left.packageId.localeCompare(right.packageId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function appendSource(sourceMap, key, packageId) {
|
|
49
|
+
if (!sourceMap.has(key)) {
|
|
50
|
+
sourceMap.set(key, new Set());
|
|
51
|
+
}
|
|
52
|
+
sourceMap.get(key).add(packageId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function conflict({ kind, id, existing, incoming, existingPackages, incomingPackage }) {
|
|
56
|
+
const label = kind === "environment" ? "environment key" : `${kind} ID`;
|
|
57
|
+
throw new CiCompositionError(
|
|
58
|
+
`${kind}-conflict`,
|
|
59
|
+
`Conflicting CI ${label} "${id}" from ${[...existingPackages].sort().join(", ")} and ${incomingPackage}. ` +
|
|
60
|
+
`Existing value: ${formatConflictValue(existing)}. Incoming value: ${formatConflictValue(incoming)}. ` +
|
|
61
|
+
"Align the package descriptors or remove one of the conflicting packages.",
|
|
62
|
+
{
|
|
63
|
+
kind,
|
|
64
|
+
id,
|
|
65
|
+
existing,
|
|
66
|
+
incoming,
|
|
67
|
+
existingPackages: [...existingPackages].sort(),
|
|
68
|
+
incomingPackage
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sourcesToObject(sourceMap) {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
[...sourceMap.entries()]
|
|
76
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
77
|
+
.map(([key, packageIds]) => [key, [...packageIds].sort((left, right) => left.localeCompare(right))])
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function composeCiContributions(packageEntries = []) {
|
|
82
|
+
const environmentByKey = new Map();
|
|
83
|
+
const servicesById = new Map();
|
|
84
|
+
const stepsById = new Map();
|
|
85
|
+
const environmentSources = new Map();
|
|
86
|
+
const serviceSources = new Map();
|
|
87
|
+
const stepSources = new Map();
|
|
88
|
+
const contributingPackages = new Set();
|
|
89
|
+
|
|
90
|
+
for (const packageEntry of normalizePackageEntries(packageEntries)) {
|
|
91
|
+
const { packageId, descriptor } = packageEntry;
|
|
92
|
+
const contribution = normalizeCiContribution(descriptor.ci, {
|
|
93
|
+
packageId,
|
|
94
|
+
descriptorPath: packageId
|
|
95
|
+
});
|
|
96
|
+
const contributes =
|
|
97
|
+
Object.keys(contribution.environment).length > 0 ||
|
|
98
|
+
contribution.services.length > 0 ||
|
|
99
|
+
contribution.steps.length > 0;
|
|
100
|
+
if (contributes) {
|
|
101
|
+
contributingPackages.add(packageId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const [key, value] of Object.entries(contribution.environment)) {
|
|
105
|
+
if (environmentByKey.has(key) && environmentByKey.get(key) !== value) {
|
|
106
|
+
conflict({
|
|
107
|
+
kind: "environment",
|
|
108
|
+
id: key,
|
|
109
|
+
existing: environmentByKey.get(key),
|
|
110
|
+
incoming: value,
|
|
111
|
+
existingPackages: environmentSources.get(key),
|
|
112
|
+
incomingPackage: packageId
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
environmentByKey.set(key, value);
|
|
116
|
+
appendSource(environmentSources, key, packageId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const service of contribution.services) {
|
|
120
|
+
if (servicesById.has(service.id) && !valuesMatch(servicesById.get(service.id), service)) {
|
|
121
|
+
conflict({
|
|
122
|
+
kind: "service",
|
|
123
|
+
id: service.id,
|
|
124
|
+
existing: servicesById.get(service.id),
|
|
125
|
+
incoming: service,
|
|
126
|
+
existingPackages: serviceSources.get(service.id),
|
|
127
|
+
incomingPackage: packageId
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
servicesById.set(service.id, service);
|
|
131
|
+
appendSource(serviceSources, service.id, packageId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const step of contribution.steps) {
|
|
135
|
+
if (stepsById.has(step.id) && !valuesMatch(stepsById.get(step.id), step)) {
|
|
136
|
+
conflict({
|
|
137
|
+
kind: "step",
|
|
138
|
+
id: step.id,
|
|
139
|
+
existing: stepsById.get(step.id),
|
|
140
|
+
incoming: step,
|
|
141
|
+
existingPackages: stepSources.get(step.id),
|
|
142
|
+
incomingPackage: packageId
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
stepsById.set(step.id, step);
|
|
146
|
+
appendSource(stepSources, step.id, packageId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
environment: Object.fromEntries(
|
|
152
|
+
[...environmentByKey.entries()].sort(([left], [right]) => left.localeCompare(right))
|
|
153
|
+
),
|
|
154
|
+
services: [...servicesById.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
|
155
|
+
steps: [...stepsById.values()].sort((left, right) => {
|
|
156
|
+
const phaseComparison = left.phase.localeCompare(right.phase);
|
|
157
|
+
return phaseComparison || left.id.localeCompare(right.id);
|
|
158
|
+
}),
|
|
159
|
+
sources: {
|
|
160
|
+
environment: sourcesToObject(environmentSources),
|
|
161
|
+
services: sourcesToObject(serviceSources),
|
|
162
|
+
steps: sourcesToObject(stepSources)
|
|
163
|
+
},
|
|
164
|
+
packages: [...contributingPackages].sort((left, right) => left.localeCompare(right))
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
CiCompositionError,
|
|
170
|
+
composeCiContributions
|
|
171
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createCliError } from "../../shared/cliError.js";
|
|
2
|
+
|
|
3
|
+
const CI_STEP_PHASE_BEFORE_VERIFY = "before-verify";
|
|
4
|
+
const CI_STEP_PHASES = Object.freeze([
|
|
5
|
+
CI_STEP_PHASE_BEFORE_VERIFY
|
|
6
|
+
]);
|
|
7
|
+
const CI_ID_PATTERN = /^[a-z][a-z0-9-]*$/u;
|
|
8
|
+
const CI_SERVICE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/u;
|
|
9
|
+
const RESERVED_CI_STEP_IDS = new Set([
|
|
10
|
+
"checkout",
|
|
11
|
+
"install-dependencies",
|
|
12
|
+
"setup-node",
|
|
13
|
+
"verify"
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function invalidCiContract(message, { descriptorPath = "", packageId = "" } = {}) {
|
|
21
|
+
const location = String(descriptorPath || packageId || "package descriptor").trim();
|
|
22
|
+
return createCliError(`Invalid package descriptor at ${location}: ${message}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requirePlainObject(value, label, context) {
|
|
26
|
+
if (!isPlainObject(value)) {
|
|
27
|
+
throw invalidCiContract(`${label} must be an object.`, context);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rejectUnknownKeys(value, allowedKeys, label, context) {
|
|
33
|
+
const unknownKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
|
|
34
|
+
if (unknownKeys.length > 0) {
|
|
35
|
+
throw invalidCiContract(
|
|
36
|
+
`${label} contains unsupported field${unknownKeys.length === 1 ? "" : "s"}: ${unknownKeys.sort().join(", ")}.`,
|
|
37
|
+
context
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeCiScalar(value, label, context) {
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === "boolean") {
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
throw invalidCiContract(`${label} must be a string, boolean, or finite number.`, context);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeCiEnvironment(value, label, context) {
|
|
56
|
+
if (typeof value === "undefined") {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
const environment = requirePlainObject(value, label, context);
|
|
60
|
+
const normalized = {};
|
|
61
|
+
for (const key of Object.keys(environment).sort((left, right) => left.localeCompare(right))) {
|
|
62
|
+
const normalizedKey = String(key || "").trim();
|
|
63
|
+
if (!normalizedKey) {
|
|
64
|
+
throw invalidCiContract(`${label} contains an empty key.`, context);
|
|
65
|
+
}
|
|
66
|
+
normalized[normalizedKey] = normalizeCiScalar(
|
|
67
|
+
environment[key],
|
|
68
|
+
`${label}.${normalizedKey}`,
|
|
69
|
+
context
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeCiPorts(value, label, context) {
|
|
76
|
+
if (typeof value === "undefined") {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(value)) {
|
|
80
|
+
throw invalidCiContract(`${label} must be an array.`, context);
|
|
81
|
+
}
|
|
82
|
+
const ports = value.map((port, index) => {
|
|
83
|
+
const normalized = String(port || "").trim();
|
|
84
|
+
if (!normalized) {
|
|
85
|
+
throw invalidCiContract(`${label}[${index}] must be a non-empty string.`, context);
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
});
|
|
89
|
+
return [...new Set(ports)].sort((left, right) => left.localeCompare(right));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeCiHealthCheck(value, label, context) {
|
|
93
|
+
if (typeof value === "undefined") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const healthCheck = requirePlainObject(value, label, context);
|
|
97
|
+
rejectUnknownKeys(healthCheck, ["command", "interval", "timeout", "retries"], label, context);
|
|
98
|
+
const command = String(healthCheck.command || "").trim();
|
|
99
|
+
if (!command) {
|
|
100
|
+
throw invalidCiContract(`${label}.command is required.`, context);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const normalized = { command };
|
|
104
|
+
for (const propertyName of ["interval", "timeout"]) {
|
|
105
|
+
if (typeof healthCheck[propertyName] === "undefined") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const propertyValue = String(healthCheck[propertyName] || "").trim();
|
|
109
|
+
if (!propertyValue) {
|
|
110
|
+
throw invalidCiContract(`${label}.${propertyName} must be a non-empty string.`, context);
|
|
111
|
+
}
|
|
112
|
+
normalized[propertyName] = propertyValue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof healthCheck.retries !== "undefined") {
|
|
116
|
+
const retries = Number(healthCheck.retries);
|
|
117
|
+
if (!Number.isInteger(retries) || retries < 1) {
|
|
118
|
+
throw invalidCiContract(`${label}.retries must be a positive integer.`, context);
|
|
119
|
+
}
|
|
120
|
+
normalized.retries = retries;
|
|
121
|
+
}
|
|
122
|
+
return normalized;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeCiService(value, index, context) {
|
|
126
|
+
const label = `ci.services[${index}]`;
|
|
127
|
+
const service = requirePlainObject(value, label, context);
|
|
128
|
+
rejectUnknownKeys(service, ["id", "image", "environment", "ports", "healthCheck"], label, context);
|
|
129
|
+
const id = String(service.id || "").trim();
|
|
130
|
+
const image = String(service.image || "").trim();
|
|
131
|
+
if (!CI_SERVICE_ID_PATTERN.test(id)) {
|
|
132
|
+
throw invalidCiContract(
|
|
133
|
+
`${label}.id must match ${CI_SERVICE_ID_PATTERN.source}.`,
|
|
134
|
+
context
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!image) {
|
|
138
|
+
throw invalidCiContract(`${label}.image is required.`, context);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const normalized = {
|
|
142
|
+
id,
|
|
143
|
+
image,
|
|
144
|
+
environment: normalizeCiEnvironment(service.environment, `${label}.environment`, context),
|
|
145
|
+
ports: normalizeCiPorts(service.ports, `${label}.ports`, context)
|
|
146
|
+
};
|
|
147
|
+
const healthCheck = normalizeCiHealthCheck(service.healthCheck, `${label}.healthCheck`, context);
|
|
148
|
+
if (healthCheck) {
|
|
149
|
+
normalized.healthCheck = healthCheck;
|
|
150
|
+
}
|
|
151
|
+
return normalized;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeCiStep(value, index, context) {
|
|
155
|
+
const label = `ci.steps[${index}]`;
|
|
156
|
+
const step = requirePlainObject(value, label, context);
|
|
157
|
+
rejectUnknownKeys(step, ["id", "phase", "label", "command"], label, context);
|
|
158
|
+
const id = String(step.id || "").trim();
|
|
159
|
+
const phase = String(step.phase || "").trim();
|
|
160
|
+
const stepLabel = String(step.label || "").trim();
|
|
161
|
+
const command = String(step.command || "").trim();
|
|
162
|
+
|
|
163
|
+
if (!CI_ID_PATTERN.test(id)) {
|
|
164
|
+
throw invalidCiContract(`${label}.id must match ${CI_ID_PATTERN.source}.`, context);
|
|
165
|
+
}
|
|
166
|
+
if (RESERVED_CI_STEP_IDS.has(id)) {
|
|
167
|
+
throw invalidCiContract(`${label}.id "${id}" is reserved by the JSKIT workflow.`, context);
|
|
168
|
+
}
|
|
169
|
+
if (!CI_STEP_PHASES.includes(phase)) {
|
|
170
|
+
throw invalidCiContract(
|
|
171
|
+
`${label}.phase must be one of: ${CI_STEP_PHASES.join(", ")}.`,
|
|
172
|
+
context
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (!stepLabel) {
|
|
176
|
+
throw invalidCiContract(`${label}.label is required.`, context);
|
|
177
|
+
}
|
|
178
|
+
if (!command) {
|
|
179
|
+
throw invalidCiContract(`${label}.command is required.`, context);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
phase,
|
|
185
|
+
label: stepLabel,
|
|
186
|
+
command
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeCiContribution(value, context = {}) {
|
|
191
|
+
if (typeof value === "undefined") {
|
|
192
|
+
return {
|
|
193
|
+
environment: {},
|
|
194
|
+
services: [],
|
|
195
|
+
steps: []
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const ci = requirePlainObject(value, "ci", context);
|
|
199
|
+
rejectUnknownKeys(ci, ["environment", "services", "steps"], "ci", context);
|
|
200
|
+
if (typeof ci.services !== "undefined" && !Array.isArray(ci.services)) {
|
|
201
|
+
throw invalidCiContract("ci.services must be an array.", context);
|
|
202
|
+
}
|
|
203
|
+
if (typeof ci.steps !== "undefined" && !Array.isArray(ci.steps)) {
|
|
204
|
+
throw invalidCiContract("ci.steps must be an array.", context);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
environment: normalizeCiEnvironment(ci.environment, "ci.environment", context),
|
|
209
|
+
services: (ci.services || []).map((service, index) => normalizeCiService(service, index, context)),
|
|
210
|
+
steps: (ci.steps || []).map((step, index) => normalizeCiStep(step, index, context))
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export {
|
|
215
|
+
CI_STEP_PHASE_BEFORE_VERIFY,
|
|
216
|
+
CI_STEP_PHASES,
|
|
217
|
+
normalizeCiContribution
|
|
218
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import { CI_STEP_PHASE_BEFORE_VERIFY } from "./contract.js";
|
|
3
|
+
|
|
4
|
+
const JSKIT_CI_WORKFLOW_RELATIVE_PATH = ".github/workflows/jskit-verify.yml";
|
|
5
|
+
const LEGACY_CI_WORKFLOW_RELATIVE_PATH = ".github/workflows/verify.yml";
|
|
6
|
+
const GENERATED_WORKFLOW_HEADER = [
|
|
7
|
+
"# Generated and managed by JSKIT. Run `npx jskit app sync-ci` to regenerate.",
|
|
8
|
+
"# Put application-specific CI in a separate workflow; edits to this file are not merged.",
|
|
9
|
+
""
|
|
10
|
+
].join("\n");
|
|
11
|
+
const LEGACY_VERIFY_WORKFLOW_HASH = "9f274b8400ca51004446f98a9cf55e74180f11b016ee48b60f6d86e3b434bdb6";
|
|
12
|
+
|
|
13
|
+
function quoteDockerOptionValue(value) {
|
|
14
|
+
return JSON.stringify(String(value || ""));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderGithubServiceOptions(service = {}) {
|
|
18
|
+
const healthCheck = service?.healthCheck;
|
|
19
|
+
if (!healthCheck || typeof healthCheck !== "object") {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
const options = [`--health-cmd=${quoteDockerOptionValue(healthCheck.command)}`];
|
|
23
|
+
if (healthCheck.interval) {
|
|
24
|
+
options.push(`--health-interval=${healthCheck.interval}`);
|
|
25
|
+
}
|
|
26
|
+
if (healthCheck.timeout) {
|
|
27
|
+
options.push(`--health-timeout=${healthCheck.timeout}`);
|
|
28
|
+
}
|
|
29
|
+
if (healthCheck.retries) {
|
|
30
|
+
options.push(`--health-retries=${healthCheck.retries}`);
|
|
31
|
+
}
|
|
32
|
+
return options.join(" ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderGithubService(service = {}) {
|
|
36
|
+
const rendered = {
|
|
37
|
+
image: service.image
|
|
38
|
+
};
|
|
39
|
+
if (Object.keys(service.environment || {}).length > 0) {
|
|
40
|
+
rendered.env = service.environment;
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(service.ports) && service.ports.length > 0) {
|
|
43
|
+
rendered.ports = service.ports;
|
|
44
|
+
}
|
|
45
|
+
const options = renderGithubServiceOptions(service);
|
|
46
|
+
if (options) {
|
|
47
|
+
rendered.options = options;
|
|
48
|
+
}
|
|
49
|
+
return rendered;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildGithubWorkflowDocument(model = {}) {
|
|
53
|
+
const beforeVerifySteps = (Array.isArray(model.steps) ? model.steps : [])
|
|
54
|
+
.filter((step) => step.phase === CI_STEP_PHASE_BEFORE_VERIFY)
|
|
55
|
+
.map((step) => ({
|
|
56
|
+
id: step.id,
|
|
57
|
+
name: step.label,
|
|
58
|
+
run: step.command
|
|
59
|
+
}));
|
|
60
|
+
const steps = [
|
|
61
|
+
{
|
|
62
|
+
id: "checkout",
|
|
63
|
+
name: "Checkout",
|
|
64
|
+
uses: "actions/checkout@v4"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "setup-node",
|
|
68
|
+
name: "Setup Node",
|
|
69
|
+
uses: "actions/setup-node@v4",
|
|
70
|
+
with: {
|
|
71
|
+
"node-version": 20,
|
|
72
|
+
cache: "npm"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "install-dependencies",
|
|
77
|
+
name: "Install dependencies",
|
|
78
|
+
run: "npm ci"
|
|
79
|
+
},
|
|
80
|
+
...beforeVerifySteps,
|
|
81
|
+
{
|
|
82
|
+
id: "verify",
|
|
83
|
+
name: "Run verification",
|
|
84
|
+
run: "npm run verify"
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
const verifyJob = {
|
|
88
|
+
"runs-on": "ubuntu-latest"
|
|
89
|
+
};
|
|
90
|
+
if (Object.keys(model.environment || {}).length > 0) {
|
|
91
|
+
verifyJob.env = model.environment;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(model.services) && model.services.length > 0) {
|
|
94
|
+
verifyJob.services = Object.fromEntries(
|
|
95
|
+
model.services.map((service) => [service.id, renderGithubService(service)])
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
verifyJob.steps = steps;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
name: "JSKIT Verify",
|
|
102
|
+
on: {
|
|
103
|
+
pull_request: null,
|
|
104
|
+
push: {
|
|
105
|
+
branches: ["main"]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
permissions: {
|
|
109
|
+
contents: "read"
|
|
110
|
+
},
|
|
111
|
+
jobs: {
|
|
112
|
+
verify: verifyJob
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderGithubWorkflow(model = {}) {
|
|
118
|
+
return `${GENERATED_WORKFLOW_HEADER}${YAML.stringify(buildGithubWorkflowDocument(model), {
|
|
119
|
+
lineWidth: 0
|
|
120
|
+
})}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseGithubWorkflow(source = "") {
|
|
124
|
+
return YAML.parse(String(source || ""));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export {
|
|
128
|
+
GENERATED_WORKFLOW_HEADER,
|
|
129
|
+
JSKIT_CI_WORKFLOW_RELATIVE_PATH,
|
|
130
|
+
LEGACY_CI_WORKFLOW_RELATIVE_PATH,
|
|
131
|
+
LEGACY_VERIFY_WORKFLOW_HASH,
|
|
132
|
+
buildGithubWorkflowDocument,
|
|
133
|
+
parseGithubWorkflow,
|
|
134
|
+
renderGithubServiceOptions,
|
|
135
|
+
renderGithubWorkflow
|
|
136
|
+
};
|