@jskit-ai/jskit-cli 0.2.129 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-cli",
3
- "version": "0.2.129",
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.124",
24
- "@jskit-ai/kernel": "0.1.117",
23
+ "@jskit-ai/jskit-catalog": "0.1.125",
24
+ "@jskit-ai/kernel": "0.1.118",
25
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
+ };