@revoengine/cli 1.0.10 → 1.0.11
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 +312 -33
- package/dist/src/cli.js +58 -7
- package/dist/src/client.d.ts +356 -5
- package/dist/src/client.js +803 -19
- package/dist/src/commands/auth.js +4 -2
- package/dist/src/commands/component.js +260 -141
- package/dist/src/commands/database-schemas.d.ts +2 -0
- package/dist/src/commands/database-schemas.js +188 -0
- package/dist/src/commands/database-views.d.ts +2 -0
- package/dist/src/commands/database-views.js +123 -0
- package/dist/src/commands/endpoints.js +114 -0
- package/dist/src/commands/env.js +44 -24
- package/dist/src/commands/events.d.ts +2 -0
- package/dist/src/commands/events.js +146 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +169 -0
- package/dist/src/commands/index.d.ts +8 -0
- package/dist/src/commands/index.js +8 -0
- package/dist/src/commands/job-templates.d.ts +2 -0
- package/dist/src/commands/job-templates.js +101 -0
- package/dist/src/commands/metadata.js +29 -7
- package/dist/src/commands/project.js +10 -3
- package/dist/src/commands/role-groups.d.ts +2 -0
- package/dist/src/commands/role-groups.js +152 -0
- package/dist/src/commands/schedules.d.ts +2 -0
- package/dist/src/commands/schedules.js +141 -0
- package/dist/src/commands/terminal-service.d.ts +38 -0
- package/dist/src/commands/terminal-service.js +210 -0
- package/dist/src/commands/terminal.d.ts +22 -0
- package/dist/src/commands/terminal.js +511 -0
- package/dist/src/component-lock.d.ts +100 -2
- package/dist/src/component-lock.js +304 -15
- package/dist/src/config.d.ts +10 -2
- package/dist/src/config.js +49 -14
- package/dist/src/database-schema-artifacts.d.ts +7 -0
- package/dist/src/database-schema-artifacts.js +8 -0
- package/dist/src/env-sync.d.ts +0 -3
- package/dist/src/env-sync.js +3 -19
- package/dist/src/metadata-backfill.d.ts +16 -6
- package/dist/src/metadata-backfill.js +1069 -18
- package/dist/src/project.d.ts +2 -0
- package/dist/src/project.js +4 -17
- package/dist/src/prompt.js +10 -18
- package/dist/src/resource-metadata.d.ts +1 -0
- package/dist/src/resource-metadata.js +3 -0
- package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
- package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
- package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
- package/dist/src/resource-syncs/database-view-sync.js +1317 -0
- package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
- package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
- package/dist/src/resource-syncs/event-sync.d.ts +99 -0
- package/dist/src/resource-syncs/event-sync.js +949 -0
- package/dist/src/resource-syncs/group-sync.d.ts +86 -0
- package/dist/src/resource-syncs/group-sync.js +882 -0
- package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
- package/dist/src/resource-syncs/job-template-sync.js +782 -0
- package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
- package/dist/src/resource-syncs/role-group-sync.js +597 -0
- package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
- package/dist/src/resource-syncs/schedule-sync.js +1302 -0
- package/dist/src/resource-syncs/util.d.ts +19 -0
- package/dist/src/resource-syncs/util.js +116 -0
- package/dist/src/runtime-view.d.ts +1 -0
- package/dist/src/runtime-view.js +6 -1
- package/dist/src/sync-output.d.ts +38 -0
- package/dist/src/sync-output.js +131 -0
- package/dist/src/tracked-resources.d.ts +1 -1
- package/dist/src/tracked-resources.js +26 -2
- package/dist/src/types.d.ts +224 -0
- package/dist/src/ui.d.ts +3 -0
- package/dist/src/ui.js +67 -18
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +64 -0
- package/dist/src/workspace-component.d.ts +2 -0
- package/dist/src/workspace-component.js +34 -0
- package/dist/src/workspace-resource.d.ts +2 -0
- package/dist/src/workspace-resource.js +52 -0
- package/package.json +8 -3
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hashPortableComponentSource, hashStable, readComponentLock, writeComponentLock } from "../component-lock.js";
|
|
3
|
+
import { readConfiguredResourceMetadataStableKey, sanitizeUserMetadata } from "../resource-metadata.js";
|
|
4
|
+
import { readJsonFile, sanitizeSegment } from "../utils.js";
|
|
5
|
+
import { hydrateWorkspaceComponentElementDetails } from "../workspace-component.js";
|
|
6
|
+
import { findManifestFiles, isRecord, localTargetPath, requireProjectLayout, stableKeySuffix, toPortableRelativePath, unwrapData, writePulledManifest, } from "./util.js";
|
|
7
|
+
export const ENDPOINTS_DIRECTORY = 'Endpoints';
|
|
8
|
+
export const ENDPOINT_MANIFEST_FILE = 'endpoint.json';
|
|
9
|
+
const ENDPOINT_DEFAULT_TIMEOUT_SECONDS = 60;
|
|
10
|
+
const ENDPOINT_DEFAULT_MEMORY_MB = 32;
|
|
11
|
+
const ENDPOINT_PLAN_STATUS_ORDER = [
|
|
12
|
+
'create', 'restore', 'safe-update', 'remote-changed', 'conflict', 'missing-lock',
|
|
13
|
+
'restore-blocked', 'collision', 'clean', 'orphan', 'skipped',
|
|
14
|
+
];
|
|
15
|
+
function canonicalValue(value) {
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
return value.map((item) => canonicalValue(item));
|
|
18
|
+
}
|
|
19
|
+
if (!isRecord(value)) {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
return Object.fromEntries(Object.keys(value).sort((left, right) => left.localeCompare(right))
|
|
23
|
+
.map((key) => [key, canonicalValue(value[key])]));
|
|
24
|
+
}
|
|
25
|
+
function endpointIdOf(record) {
|
|
26
|
+
return String(record.endpointId || record.id || '');
|
|
27
|
+
}
|
|
28
|
+
function componentIdOf(record) {
|
|
29
|
+
return String(record.componentId || record.id || '');
|
|
30
|
+
}
|
|
31
|
+
function canonicalMetadata(record, stableKeyName, stableKey) {
|
|
32
|
+
return canonicalValue({
|
|
33
|
+
...sanitizeUserMetadata(record.resourceMetadata),
|
|
34
|
+
...sanitizeUserMetadata(record.metaData),
|
|
35
|
+
...sanitizeUserMetadata(record.metadata),
|
|
36
|
+
[stableKeyName]: stableKey,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function rawConfiguredStableKey(record, stableKeyName) {
|
|
40
|
+
for (const metadata of [record.metadata, record.metaData, record.resourceMetadata]) {
|
|
41
|
+
if (isRecord(metadata) && Object.hasOwn(metadata, stableKeyName)) {
|
|
42
|
+
return { present: true, value: metadata[stableKeyName] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { present: false, value: undefined };
|
|
46
|
+
}
|
|
47
|
+
function canonicalRoute(record) {
|
|
48
|
+
const method = typeof record.method === 'string' ? record.method.trim().toUpperCase() : '';
|
|
49
|
+
const rawPath = typeof record.path === 'string' ? record.path.trim() : '';
|
|
50
|
+
const pathValue = rawPath.replace(/\/{2,}/g, '/').replace(/\/+$/g, '') || '/';
|
|
51
|
+
if (!method || !rawPath) {
|
|
52
|
+
throw new Error(`Endpoint "${String(record.name || endpointIdOf(record) || 'unknown')}" has no complete route.`);
|
|
53
|
+
}
|
|
54
|
+
return { method, path: pathValue };
|
|
55
|
+
}
|
|
56
|
+
function portableOptions(record) {
|
|
57
|
+
if (record.options === undefined || record.options === null) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (!isRecord(record.options)) {
|
|
61
|
+
throw new Error(`Endpoint "${String(record.name || endpointIdOf(record) || 'unknown')}" has invalid options.`);
|
|
62
|
+
}
|
|
63
|
+
const options = { ...record.options };
|
|
64
|
+
delete options.componentSchemaId;
|
|
65
|
+
delete options.componentSchemaVersion;
|
|
66
|
+
delete options.componentSchemaElement;
|
|
67
|
+
return Object.keys(options).length > 0 ? canonicalValue(options) : undefined;
|
|
68
|
+
}
|
|
69
|
+
function canonicalPortableOptions(options) {
|
|
70
|
+
if (options === undefined) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return canonicalValue({
|
|
74
|
+
...options,
|
|
75
|
+
...(options.memory === null ? { memory: ENDPOINT_DEFAULT_MEMORY_MB } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function endpointLabel(record, fallback = 'remote-id') {
|
|
79
|
+
const category = typeof record.category === 'string' && record.category.trim()
|
|
80
|
+
? record.category.trim()
|
|
81
|
+
: 'Uncategorized';
|
|
82
|
+
const name = typeof record.name === 'string' && record.name.trim()
|
|
83
|
+
? record.name.trim()
|
|
84
|
+
: fallback === 'remote-id'
|
|
85
|
+
? endpointIdOf(record) || 'unknown'
|
|
86
|
+
: 'unknown';
|
|
87
|
+
return `${category}/${name}`;
|
|
88
|
+
}
|
|
89
|
+
function endpointManifestPath(workspaceRoot, manifest) {
|
|
90
|
+
const category = typeof manifest.category === 'string' && manifest.category.trim()
|
|
91
|
+
? sanitizeSegment(manifest.category)
|
|
92
|
+
: 'Uncategorized';
|
|
93
|
+
return path.join(workspaceRoot, ENDPOINTS_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, ENDPOINT_MANIFEST_FILE);
|
|
94
|
+
}
|
|
95
|
+
function portablePathIdentity(filePath) {
|
|
96
|
+
return path.resolve(filePath)
|
|
97
|
+
.normalize('NFC')
|
|
98
|
+
.split(path.sep)
|
|
99
|
+
.map((segment) => segment.replace(/[. ]+$/g, '').toLowerCase())
|
|
100
|
+
.join(path.sep);
|
|
101
|
+
}
|
|
102
|
+
function disambiguateManifestPath(filePath, stableKey) {
|
|
103
|
+
const directory = path.dirname(filePath);
|
|
104
|
+
return path.join(path.dirname(directory), `${path.basename(directory)}-${hashStable(stableKey).slice(0, 8)}`, path.basename(filePath));
|
|
105
|
+
}
|
|
106
|
+
const ENDPOINT_MANIFEST_FIELDS = new Set([
|
|
107
|
+
'kind', 'stableKey', 'name', 'category', 'description', 'metadata', 'active', 'timeout',
|
|
108
|
+
'route', 'options', 'execution', 'schema',
|
|
109
|
+
]);
|
|
110
|
+
const INSTANCE_LOCAL_SCHEMA_OPTION_FIELDS = new Set([
|
|
111
|
+
'componentSchemaId', 'componentSchemaVersion', 'componentSchemaElement',
|
|
112
|
+
]);
|
|
113
|
+
function hasOnlyKeys(value, allowed) {
|
|
114
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
115
|
+
}
|
|
116
|
+
function isNonEmptyString(value) {
|
|
117
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
118
|
+
}
|
|
119
|
+
function hasValidBinding(value) {
|
|
120
|
+
if (!isRecord(value) || !hasOnlyKeys(value, new Set(['stableKey', 'binding', 'pinnedDigest']))) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (!isNonEmptyString(value.stableKey) || (value.binding !== 'latest' && value.binding !== 'pinned')) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (value.binding === 'latest') {
|
|
127
|
+
return value.pinnedDigest === undefined;
|
|
128
|
+
}
|
|
129
|
+
return typeof value.pinnedDigest === 'string' && /^[0-9a-f]{64}$/i.test(value.pinnedDigest);
|
|
130
|
+
}
|
|
131
|
+
function hasValidSchemaBinding(value) {
|
|
132
|
+
if (!isRecord(value) || !hasOnlyKeys(value, new Set(['stableKey', 'binding', 'pinnedDigest', 'element']))) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const { element, ...binding } = value;
|
|
136
|
+
return hasValidBinding(binding) && (element === undefined || isNonEmptyString(element));
|
|
137
|
+
}
|
|
138
|
+
function validateManifest(manifest, stableKeyName) {
|
|
139
|
+
if (!isRecord(manifest) || !hasOnlyKeys(manifest, ENDPOINT_MANIFEST_FIELDS)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (manifest.kind !== 'endpoint' || !isNonEmptyString(manifest.stableKey) || !isNonEmptyString(manifest.name)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (!isRecord(manifest.metadata) || typeof manifest.active !== 'boolean') {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (manifest.category !== undefined && manifest.category !== null && typeof manifest.category !== 'string') {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (manifest.description !== undefined && typeof manifest.description !== 'string') {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (manifest.timeout !== undefined && (typeof manifest.timeout !== 'number' || !Number.isFinite(manifest.timeout))) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (!isRecord(manifest.route) || !hasOnlyKeys(manifest.route, new Set(['method', 'path']))
|
|
158
|
+
|| !isNonEmptyString(manifest.route.method) || !isNonEmptyString(manifest.route.path)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (manifest.options !== undefined && !isRecord(manifest.options)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (isRecord(manifest.options)
|
|
165
|
+
&& Object.keys(manifest.options).some((key) => INSTANCE_LOCAL_SCHEMA_OPTION_FIELDS.has(key))) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (manifest.execution !== undefined && !hasValidBinding(manifest.execution)) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (manifest.schema !== undefined && !hasValidSchemaBinding(manifest.schema)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return readConfiguredResourceMetadataStableKey(manifest, stableKeyName) === manifest.stableKey;
|
|
175
|
+
}
|
|
176
|
+
export function hashPortableEndpoint(manifest) {
|
|
177
|
+
return hashStable({
|
|
178
|
+
kind: manifest.kind,
|
|
179
|
+
stableKey: manifest.stableKey,
|
|
180
|
+
name: manifest.name,
|
|
181
|
+
category: manifest.category ?? null,
|
|
182
|
+
description: manifest.description ?? '',
|
|
183
|
+
metadata: canonicalValue(manifest.metadata),
|
|
184
|
+
active: manifest.active,
|
|
185
|
+
timeout: manifest.timeout ?? ENDPOINT_DEFAULT_TIMEOUT_SECONDS,
|
|
186
|
+
route: canonicalValue(manifest.route),
|
|
187
|
+
...(manifest.options === undefined ? {} : { options: canonicalPortableOptions(manifest.options) }),
|
|
188
|
+
...(manifest.execution === undefined ? {} : { execution: canonicalValue(manifest.execution) }),
|
|
189
|
+
...(manifest.schema === undefined ? {} : { schema: canonicalValue(manifest.schema) }),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function readWorkspaceEndpoints(cwd) {
|
|
193
|
+
const { workspaceRoot } = requireProjectLayout(cwd, 'Endpoint');
|
|
194
|
+
return findManifestFiles(path.join(workspaceRoot, ENDPOINTS_DIRECTORY), ENDPOINT_MANIFEST_FILE).map((manifestPath) => ({
|
|
195
|
+
manifestPath,
|
|
196
|
+
manifest: readJsonFile(manifestPath),
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
function readWorkspaceComponents(cwd, stableKeyName) {
|
|
200
|
+
const { workspaceRoot } = requireProjectLayout(cwd, 'Endpoint');
|
|
201
|
+
const result = new Map();
|
|
202
|
+
for (const manifestPath of findManifestFiles(path.join(workspaceRoot, 'Components'), 'component.json')) {
|
|
203
|
+
const component = hydrateWorkspaceComponentElementDetails(manifestPath, readJsonFile(manifestPath));
|
|
204
|
+
const stableKey = readConfiguredResourceMetadataStableKey(component, stableKeyName);
|
|
205
|
+
if (stableKey) {
|
|
206
|
+
result.set(stableKey, [...(result.get(stableKey) || []), component]);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
async function loadRemoteEndpoints(client) {
|
|
212
|
+
const { endpoints } = await client.listAllEndpoints();
|
|
213
|
+
return Promise.all(endpoints.map(async (summary) => {
|
|
214
|
+
const endpointId = endpointIdOf(summary);
|
|
215
|
+
if (!endpointId) {
|
|
216
|
+
return summary;
|
|
217
|
+
}
|
|
218
|
+
return unwrapData(await client.getEndpoint(endpointId));
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
async function loadRemoteComponents(client) {
|
|
222
|
+
const { components } = await client.listAllComponents();
|
|
223
|
+
return Promise.all(components.map(async (summary) => {
|
|
224
|
+
const componentId = componentIdOf(summary);
|
|
225
|
+
if (!componentId) {
|
|
226
|
+
return summary;
|
|
227
|
+
}
|
|
228
|
+
return unwrapData(await client.getComponent(componentId));
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
async function componentBinding(input) {
|
|
232
|
+
const component = input.componentsById.get(input.componentId);
|
|
233
|
+
if (!component) {
|
|
234
|
+
throw new Error(`Endpoint "${input.endpointStableKey}" ${input.relation} component is missing from the source.`);
|
|
235
|
+
}
|
|
236
|
+
const stableKey = readConfiguredResourceMetadataStableKey(component, input.stableKeyName);
|
|
237
|
+
if (!stableKey) {
|
|
238
|
+
throw new Error(`Endpoint "${input.endpointStableKey}" ${input.relation} component is unmanaged: missing ${input.stableKeyName}.`);
|
|
239
|
+
}
|
|
240
|
+
if (typeof input.componentVersion !== 'number') {
|
|
241
|
+
return { stableKey, binding: 'latest' };
|
|
242
|
+
}
|
|
243
|
+
const snapshot = unwrapData(await input.client.getComponentSnapshotVersion(input.componentId, input.componentVersion));
|
|
244
|
+
return { stableKey, binding: 'pinned', pinnedDigest: hashPortableComponentSource(snapshot) };
|
|
245
|
+
}
|
|
246
|
+
async function manifestFromRecord(input) {
|
|
247
|
+
const { record } = input;
|
|
248
|
+
if (typeof record.name !== 'string' || !record.name.trim()) {
|
|
249
|
+
throw new Error(`Endpoint "${input.stableKey}" has no name.`);
|
|
250
|
+
}
|
|
251
|
+
if (record.category !== undefined && record.category !== null && typeof record.category !== 'string') {
|
|
252
|
+
throw new Error(`Endpoint "${input.stableKey}" has an invalid category.`);
|
|
253
|
+
}
|
|
254
|
+
if (record.active !== undefined && typeof record.active !== 'boolean') {
|
|
255
|
+
throw new Error(`Endpoint "${input.stableKey}" has an invalid active value.`);
|
|
256
|
+
}
|
|
257
|
+
if (record.timeout !== undefined && (typeof record.timeout !== 'number' || !Number.isFinite(record.timeout))) {
|
|
258
|
+
throw new Error(`Endpoint "${input.stableKey}" has an invalid timeout.`);
|
|
259
|
+
}
|
|
260
|
+
const executionComponentId = typeof record.componentId === 'string' ? record.componentId : '';
|
|
261
|
+
const execution = executionComponentId
|
|
262
|
+
? await componentBinding({
|
|
263
|
+
client: input.client,
|
|
264
|
+
componentsById: input.componentsById,
|
|
265
|
+
componentId: executionComponentId,
|
|
266
|
+
componentVersion: record.componentVersion,
|
|
267
|
+
stableKeyName: input.stableKeyName,
|
|
268
|
+
endpointStableKey: input.stableKey,
|
|
269
|
+
relation: 'execution',
|
|
270
|
+
})
|
|
271
|
+
: undefined;
|
|
272
|
+
const schemaComponentId = isRecord(record.options) && typeof record.options.componentSchemaId === 'string'
|
|
273
|
+
? record.options.componentSchemaId
|
|
274
|
+
: '';
|
|
275
|
+
const schemaElement = isRecord(record.options) && typeof record.options.componentSchemaElement === 'string'
|
|
276
|
+
? record.options.componentSchemaElement.trim()
|
|
277
|
+
: '';
|
|
278
|
+
const schema = schemaComponentId
|
|
279
|
+
? {
|
|
280
|
+
...await componentBinding({
|
|
281
|
+
client: input.client,
|
|
282
|
+
componentsById: input.componentsById,
|
|
283
|
+
componentId: schemaComponentId,
|
|
284
|
+
componentVersion: record.options?.componentSchemaVersion,
|
|
285
|
+
stableKeyName: input.stableKeyName,
|
|
286
|
+
endpointStableKey: input.stableKey,
|
|
287
|
+
relation: 'schema',
|
|
288
|
+
}),
|
|
289
|
+
...(schemaElement ? { element: schemaElement } : {}),
|
|
290
|
+
}
|
|
291
|
+
: undefined;
|
|
292
|
+
const options = portableOptions(record);
|
|
293
|
+
const manifest = {
|
|
294
|
+
kind: 'endpoint',
|
|
295
|
+
stableKey: input.stableKey,
|
|
296
|
+
name: String(record.name || ''),
|
|
297
|
+
...(record.category === undefined ? {} : { category: record.category }),
|
|
298
|
+
...(typeof record.desc === 'string' ? { description: record.desc } : typeof record.description === 'string' ? { description: record.description } : {}),
|
|
299
|
+
metadata: canonicalMetadata(record, input.stableKeyName, input.stableKey),
|
|
300
|
+
active: record.active !== false,
|
|
301
|
+
timeout: typeof record.timeout === 'number' ? record.timeout : ENDPOINT_DEFAULT_TIMEOUT_SECONDS,
|
|
302
|
+
route: canonicalRoute(record),
|
|
303
|
+
...(options === undefined ? {} : { options }),
|
|
304
|
+
...(execution ? { execution } : {}),
|
|
305
|
+
...(schema ? { schema } : {}),
|
|
306
|
+
};
|
|
307
|
+
if (!validateManifest(manifest, input.stableKeyName)) {
|
|
308
|
+
throw new Error(`Endpoint "${input.stableKey}" has invalid portable state.`);
|
|
309
|
+
}
|
|
310
|
+
return manifest;
|
|
311
|
+
}
|
|
312
|
+
export async function pullEndpointsToWorkspace(input) {
|
|
313
|
+
const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Endpoint');
|
|
314
|
+
const [records, components] = await Promise.all([
|
|
315
|
+
loadRemoteEndpoints(input.client),
|
|
316
|
+
loadRemoteComponents(input.client),
|
|
317
|
+
]);
|
|
318
|
+
const componentsById = new Map(components.map((component) => [componentIdOf(component), component]));
|
|
319
|
+
const manifests = [];
|
|
320
|
+
const skipped = [];
|
|
321
|
+
const stableKeys = new Set();
|
|
322
|
+
for (const record of records) {
|
|
323
|
+
if (record.deletedAt || record.deletedBy) {
|
|
324
|
+
skipped.push({ targetPath: endpointLabel(record), reason: 'deleted endpoint is excluded' });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const rawStableKey = rawConfiguredStableKey(record, input.stableKeyName);
|
|
328
|
+
if (rawStableKey.present && !isNonEmptyString(rawStableKey.value)) {
|
|
329
|
+
throw new Error(`Invalid endpoint stableKey on ${endpointIdOf(record) || endpointLabel(record)}; no files were changed.`);
|
|
330
|
+
}
|
|
331
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
332
|
+
if (!stableKey) {
|
|
333
|
+
skipped.push({ targetPath: endpointLabel(record), reason: `unmanaged: missing ${input.stableKeyName}` });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (stableKeys.has(stableKey)) {
|
|
337
|
+
throw new Error(`Duplicate endpoint stableKey "${stableKey}" on ${input.envName}; no files were changed.`);
|
|
338
|
+
}
|
|
339
|
+
stableKeys.add(stableKey);
|
|
340
|
+
manifests.push(await manifestFromRecord({
|
|
341
|
+
client: input.client,
|
|
342
|
+
record,
|
|
343
|
+
stableKey,
|
|
344
|
+
stableKeyName: input.stableKeyName,
|
|
345
|
+
componentsById,
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
manifests.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
|
|
349
|
+
skipped.sort((left, right) => left.targetPath.localeCompare(right.targetPath));
|
|
350
|
+
const lock = readComponentLock(projectRoot);
|
|
351
|
+
const existingByKey = new Map();
|
|
352
|
+
const existingByPath = new Map();
|
|
353
|
+
for (const entry of readWorkspaceEndpoints(input.cwd)) {
|
|
354
|
+
if (!validateManifest(entry.manifest, input.stableKeyName)) {
|
|
355
|
+
throw new Error(`Cannot pull over invalid endpoint manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
|
|
356
|
+
}
|
|
357
|
+
if (existingByKey.has(entry.manifest.stableKey)) {
|
|
358
|
+
throw new Error(`Duplicate local endpoint stableKey "${entry.manifest.stableKey}"; no files were changed.`);
|
|
359
|
+
}
|
|
360
|
+
existingByKey.set(entry.manifest.stableKey, entry);
|
|
361
|
+
const identity = portablePathIdentity(entry.manifestPath);
|
|
362
|
+
existingByPath.set(identity, [...(existingByPath.get(identity) || []), entry]);
|
|
363
|
+
}
|
|
364
|
+
const writes = manifests.map((manifest) => {
|
|
365
|
+
const hash = hashPortableEndpoint(manifest);
|
|
366
|
+
const existing = existingByKey.get(manifest.stableKey);
|
|
367
|
+
if (existing && !input.force) {
|
|
368
|
+
const localHash = hashPortableEndpoint(existing.manifest);
|
|
369
|
+
const baseline = lock.endpoints[manifest.stableKey];
|
|
370
|
+
if (localHash !== hash && (!baseline || localHash !== baseline.sourceHash)) {
|
|
371
|
+
throw new Error(`Cannot pull endpoint "${manifest.stableKey}": local manifest changed since the lock baseline. Re-run with --force.`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
manifest,
|
|
376
|
+
hash,
|
|
377
|
+
filePath: endpointManifestPath(workspaceRoot, manifest),
|
|
378
|
+
...(existing ? { previousPath: existing.manifestPath } : {}),
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
const candidatePathCounts = new Map();
|
|
382
|
+
for (const write of writes) {
|
|
383
|
+
const identity = portablePathIdentity(write.filePath);
|
|
384
|
+
candidatePathCounts.set(identity, (candidatePathCounts.get(identity) || 0) + 1);
|
|
385
|
+
}
|
|
386
|
+
for (const write of writes) {
|
|
387
|
+
if ((candidatePathCounts.get(portablePathIdentity(write.filePath)) || 0) > 1) {
|
|
388
|
+
write.filePath = disambiguateManifestPath(write.filePath, write.manifest.stableKey);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const paths = new Set();
|
|
392
|
+
for (const write of writes) {
|
|
393
|
+
const identity = portablePathIdentity(write.filePath);
|
|
394
|
+
if (paths.has(identity)) {
|
|
395
|
+
throw new Error('Multiple endpoints resolve to the same workspace path; no files were changed.');
|
|
396
|
+
}
|
|
397
|
+
paths.add(identity);
|
|
398
|
+
const pathOwner = (existingByPath.get(identity) || [])
|
|
399
|
+
.find((entry) => entry.manifest.stableKey !== write.manifest.stableKey);
|
|
400
|
+
if (pathOwner) {
|
|
401
|
+
throw new Error(`Endpoint workspace path "${path.relative(projectRoot, write.filePath)}" is occupied by different stableKey "${pathOwner.manifest.stableKey}"; no files were changed.`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const root = path.join(workspaceRoot, ENDPOINTS_DIRECTORY);
|
|
405
|
+
let lockChanged = false;
|
|
406
|
+
for (const write of writes) {
|
|
407
|
+
writePulledManifest({
|
|
408
|
+
filePath: write.filePath,
|
|
409
|
+
manifest: write.manifest,
|
|
410
|
+
previousPath: write.previousPath,
|
|
411
|
+
root: root,
|
|
412
|
+
force: input.force,
|
|
413
|
+
});
|
|
414
|
+
const previous = lock.endpoints[write.manifest.stableKey];
|
|
415
|
+
const previousBaseline = previous?.environments[input.envName];
|
|
416
|
+
const entry = {
|
|
417
|
+
stableKey: write.manifest.stableKey,
|
|
418
|
+
path: toPortableRelativePath(projectRoot, write.filePath),
|
|
419
|
+
sourceHash: write.hash,
|
|
420
|
+
environments: {
|
|
421
|
+
...(previous?.environments || {}),
|
|
422
|
+
[input.envName]: previousBaseline?.remoteHash === write.hash
|
|
423
|
+
? previousBaseline
|
|
424
|
+
: { remoteHash: write.hash, syncedAt: new Date().toISOString() },
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
if (!previous || hashStable(previous) !== hashStable(entry)) {
|
|
428
|
+
lockChanged = true;
|
|
429
|
+
}
|
|
430
|
+
lock.endpoints[write.manifest.stableKey] = entry;
|
|
431
|
+
}
|
|
432
|
+
if (lockChanged) {
|
|
433
|
+
writeComponentLock(projectRoot, lock);
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
pulled: writes.map((write) => ({
|
|
437
|
+
manifest: write.manifest,
|
|
438
|
+
targetPath: localTargetPath(input.cwd, write.filePath),
|
|
439
|
+
})),
|
|
440
|
+
skipped,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function endpointPlanCounts() {
|
|
444
|
+
return Object.fromEntries(ENDPOINT_PLAN_STATUS_ORDER.map((status) => [status, 0]));
|
|
445
|
+
}
|
|
446
|
+
function endpointRouteKey(route) {
|
|
447
|
+
const method = route.method.trim().toUpperCase();
|
|
448
|
+
const rawPath = route.path.trim();
|
|
449
|
+
const pathValue = rawPath.replace(/\/{2,}/g, '/').replace(/\/+$/g, '') || '/';
|
|
450
|
+
return `${method} ${pathValue}`;
|
|
451
|
+
}
|
|
452
|
+
function endpointRecordRouteKey(record) {
|
|
453
|
+
try {
|
|
454
|
+
return endpointRouteKey(canonicalRoute(record));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return '';
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function diffEndpoints(desired, current) {
|
|
461
|
+
const fields = [
|
|
462
|
+
['name', desired.name, current.name],
|
|
463
|
+
['category', desired.category ?? null, current.category ?? null],
|
|
464
|
+
['description', desired.description ?? '', current.description ?? ''],
|
|
465
|
+
['metadata', canonicalValue(desired.metadata), canonicalValue(current.metadata)],
|
|
466
|
+
['active', desired.active, current.active],
|
|
467
|
+
['timeout', desired.timeout ?? ENDPOINT_DEFAULT_TIMEOUT_SECONDS, current.timeout ?? ENDPOINT_DEFAULT_TIMEOUT_SECONDS],
|
|
468
|
+
['route.method', desired.route.method, current.route.method],
|
|
469
|
+
['route.path', desired.route.path, current.route.path],
|
|
470
|
+
['options', canonicalPortableOptions(desired.options) ?? null, canonicalPortableOptions(current.options) ?? null],
|
|
471
|
+
['execution', canonicalValue(desired.execution ?? null), canonicalValue(current.execution ?? null)],
|
|
472
|
+
['schema', canonicalValue(desired.schema ?? null), canonicalValue(current.schema ?? null)],
|
|
473
|
+
];
|
|
474
|
+
return fields
|
|
475
|
+
.filter(([, desiredValue, currentValue]) => hashStable(desiredValue) !== hashStable(currentValue))
|
|
476
|
+
.map(([field]) => field)
|
|
477
|
+
.sort((left, right) => left.localeCompare(right));
|
|
478
|
+
}
|
|
479
|
+
function componentType(component) {
|
|
480
|
+
return String(component.type || '').trim().toUpperCase();
|
|
481
|
+
}
|
|
482
|
+
function hasComponentElement(component, elementKey) {
|
|
483
|
+
return (component.elements || []).some((element) => element.key === elementKey);
|
|
484
|
+
}
|
|
485
|
+
function componentDependencyDiagnostics(input) {
|
|
486
|
+
const blockers = [];
|
|
487
|
+
const details = [];
|
|
488
|
+
const bindings = [
|
|
489
|
+
...(input.endpoint.execution ? [{ relation: 'execution', binding: input.endpoint.execution }] : []),
|
|
490
|
+
...(input.endpoint.schema ? [{ relation: 'schema', binding: input.endpoint.schema }] : []),
|
|
491
|
+
];
|
|
492
|
+
for (const { relation, binding } of bindings) {
|
|
493
|
+
const workspaceMatches = input.workspaceComponents.get(binding.stableKey) || [];
|
|
494
|
+
const targetMatches = input.targetComponents.get(binding.stableKey) || [];
|
|
495
|
+
if (workspaceMatches.length !== 1 || targetMatches.length !== 1) {
|
|
496
|
+
const location = workspaceMatches.length !== 1 ? 'source' : 'target';
|
|
497
|
+
const count = location === 'source' ? workspaceMatches.length : targetMatches.length;
|
|
498
|
+
blockers.push(`Endpoint "${input.endpoint.stableKey}" ${relation} Component "${binding.stableKey}" is ${count === 0 ? 'missing' : 'ambiguous'} in ${location}; align Components before Endpoints.`);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const workspace = workspaceMatches[0];
|
|
502
|
+
const target = targetMatches[0];
|
|
503
|
+
const workspaceDigest = hashPortableComponentSource(workspace);
|
|
504
|
+
const targetDigest = hashPortableComponentSource(target);
|
|
505
|
+
if (binding.binding === 'pinned') {
|
|
506
|
+
details.push({ label: `${relation} A (pinned)`, values: binding.pinnedDigest || '' });
|
|
507
|
+
}
|
|
508
|
+
details.push({ label: `${relation} B (source)`, values: workspaceDigest }, { label: `${relation} C (target)`, values: targetDigest });
|
|
509
|
+
if (relation === 'execution') {
|
|
510
|
+
const allowed = new Set(['CODE_JS', 'CODE_TS']);
|
|
511
|
+
if (!allowed.has(componentType(workspace)) || !allowed.has(componentType(target))) {
|
|
512
|
+
blockers.push(`Endpoint "${input.endpoint.stableKey}" execution Component "${binding.stableKey}" must be CODE_JS or CODE_TS in source and target.`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
if (componentType(workspace) !== 'JSON_VALIDATOR' || componentType(target) !== 'JSON_VALIDATOR') {
|
|
517
|
+
blockers.push(`Endpoint "${input.endpoint.stableKey}" schema Component "${binding.stableKey}" must be JSON_VALIDATOR in source and target.`);
|
|
518
|
+
}
|
|
519
|
+
const schema = binding;
|
|
520
|
+
if (schema.element
|
|
521
|
+
&& (!hasComponentElement(workspace, schema.element) || !hasComponentElement(target, schema.element))) {
|
|
522
|
+
blockers.push(`Endpoint "${input.endpoint.stableKey}" schema element "${schema.element}" is missing from Component "${binding.stableKey}" in source or target.`);
|
|
523
|
+
}
|
|
524
|
+
else if (!schema.element && ((workspace.elements || []).length === 0 || (target.elements || []).length === 0)) {
|
|
525
|
+
blockers.push(`Endpoint "${input.endpoint.stableKey}" schema Component "${binding.stableKey}" has no default first element in source or target.`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const digestMatches = binding.binding === 'latest'
|
|
529
|
+
? workspaceDigest === targetDigest
|
|
530
|
+
: binding.pinnedDigest === workspaceDigest && workspaceDigest === targetDigest;
|
|
531
|
+
if (!digestMatches) {
|
|
532
|
+
blockers.push(`Endpoint "${input.endpoint.stableKey}" ${relation} Component "${binding.stableKey}" portable content does not match (${binding.binding}); align Components before Endpoints.`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return { blockers, details };
|
|
536
|
+
}
|
|
537
|
+
function targetComponentsByStableKey(components, stableKeyName) {
|
|
538
|
+
const result = new Map();
|
|
539
|
+
for (const component of components) {
|
|
540
|
+
const stableKey = readConfiguredResourceMetadataStableKey(component, stableKeyName);
|
|
541
|
+
if (stableKey) {
|
|
542
|
+
result.set(stableKey, [...(result.get(stableKey) || []), component]);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
function endpointRevisionHash(record) {
|
|
548
|
+
return hashStable({
|
|
549
|
+
stableKeyMetadata: canonicalValue(record.metadata ?? record.metaData ?? record.resourceMetadata ?? null),
|
|
550
|
+
name: record.name ?? null,
|
|
551
|
+
category: record.category ?? null,
|
|
552
|
+
desc: record.desc ?? record.description ?? null,
|
|
553
|
+
active: record.active ?? null,
|
|
554
|
+
timeout: record.timeout ?? null,
|
|
555
|
+
path: record.path ?? null,
|
|
556
|
+
method: record.method ?? null,
|
|
557
|
+
componentId: record.componentId ?? null,
|
|
558
|
+
componentVersion: record.componentVersion ?? null,
|
|
559
|
+
options: canonicalValue(record.options ?? null),
|
|
560
|
+
version: record.version ?? null,
|
|
561
|
+
deletedAt: record.deletedAt ?? null,
|
|
562
|
+
deletedBy: record.deletedBy ?? null,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async function activeDeletedEndpointRestoreBlockers(input) {
|
|
566
|
+
if (!input.record.active) {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
const blockers = [];
|
|
570
|
+
const loadBinding = async (componentId, version, relation) => {
|
|
571
|
+
const current = input.componentsById.get(componentId);
|
|
572
|
+
if (!current) {
|
|
573
|
+
blockers.push(`old ${relation} Component "${componentId}" is missing`);
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
if (current.active !== true) {
|
|
577
|
+
blockers.push(`old ${relation} Component "${componentId}" is inactive`);
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const component = typeof version === 'number'
|
|
582
|
+
? unwrapData(await input.client.getComponentSnapshotVersion(componentId, version))
|
|
583
|
+
: current;
|
|
584
|
+
if (!component) {
|
|
585
|
+
blockers.push(`old ${relation} Component "${componentId}" is missing`);
|
|
586
|
+
}
|
|
587
|
+
return component;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
blockers.push(`old ${relation} Component "${componentId}" version ${String(version)} is missing`);
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
if (typeof input.record.componentId === 'string' && input.record.componentId) {
|
|
595
|
+
const execution = await loadBinding(input.record.componentId, input.record.componentVersion, 'execution');
|
|
596
|
+
if (execution && !new Set(['CODE_JS', 'CODE_TS']).has(componentType(execution))) {
|
|
597
|
+
blockers.push(`old execution Component "${input.record.componentId}" is not executable`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const options = isRecord(input.record.options) ? input.record.options : {};
|
|
601
|
+
const schemaId = typeof options.componentSchemaId === 'string' ? options.componentSchemaId : '';
|
|
602
|
+
if (schemaId) {
|
|
603
|
+
const schema = await loadBinding(schemaId, options.componentSchemaVersion, 'schema');
|
|
604
|
+
if (schema && componentType(schema) !== 'JSON_VALIDATOR') {
|
|
605
|
+
blockers.push(`old schema Component "${schemaId}" is not JSON_VALIDATOR`);
|
|
606
|
+
}
|
|
607
|
+
const element = typeof options.componentSchemaElement === 'string'
|
|
608
|
+
? options.componentSchemaElement.trim()
|
|
609
|
+
: '';
|
|
610
|
+
if (schema && element && !hasComponentElement(schema, element)) {
|
|
611
|
+
blockers.push(`old schema Component "${schemaId}" element "${element}" is missing`);
|
|
612
|
+
}
|
|
613
|
+
if (schema && !element && (schema.elements || []).length === 0) {
|
|
614
|
+
blockers.push(`old schema Component "${schemaId}" has no default first element`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return blockers;
|
|
618
|
+
}
|
|
619
|
+
export async function buildEndpointsPlan(input) {
|
|
620
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Endpoint');
|
|
621
|
+
const items = [];
|
|
622
|
+
const blockers = [];
|
|
623
|
+
const exclusions = [];
|
|
624
|
+
const localByStableKey = new Map();
|
|
625
|
+
for (const entry of readWorkspaceEndpoints(input.cwd)) {
|
|
626
|
+
if (!validateManifest(entry.manifest, input.stableKeyName)) {
|
|
627
|
+
const relativePath = path.relative(projectRoot, entry.manifestPath);
|
|
628
|
+
blockers.push(`Invalid Endpoint manifest "${relativePath}".`);
|
|
629
|
+
items.push({ status: 'conflict', name: relativePath, reason: 'invalid portable manifest' });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
localByStableKey.set(entry.manifest.stableKey, [
|
|
633
|
+
...(localByStableKey.get(entry.manifest.stableKey) || []),
|
|
634
|
+
{ manifest: entry.manifest, manifestPath: entry.manifestPath },
|
|
635
|
+
]);
|
|
636
|
+
}
|
|
637
|
+
for (const [stableKey, entries] of localByStableKey) {
|
|
638
|
+
if (entries.length > 1) {
|
|
639
|
+
blockers.push(`Duplicate source Endpoint stableKey "${stableKey}".`);
|
|
640
|
+
for (const entry of entries) {
|
|
641
|
+
items.push({ status: 'collision', stableKey, name: entry.manifest.name, reason: 'duplicate source stableKey' });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const [targetEndpoints, targetComponents] = await Promise.all([
|
|
646
|
+
loadRemoteEndpoints(input.client),
|
|
647
|
+
loadRemoteComponents(input.client),
|
|
648
|
+
]);
|
|
649
|
+
const workspaceComponents = readWorkspaceComponents(input.cwd, input.stableKeyName);
|
|
650
|
+
const targetComponentsByKey = targetComponentsByStableKey(targetComponents, input.stableKeyName);
|
|
651
|
+
for (const [stableKey, matches] of workspaceComponents) {
|
|
652
|
+
if (matches.length > 1) {
|
|
653
|
+
blockers.push(`Duplicate source Component stableKey "${stableKey}"; align Components before Endpoints.`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
for (const [stableKey, matches] of targetComponentsByKey) {
|
|
657
|
+
if (matches.length > 1) {
|
|
658
|
+
blockers.push(`Duplicate target Component stableKey "${stableKey}"; align Components before Endpoints.`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const componentsById = new Map(targetComponents.map((component) => [componentIdOf(component), component]));
|
|
662
|
+
const liveTargetsByStableKey = new Map();
|
|
663
|
+
const deletedTargetsByStableKey = new Map();
|
|
664
|
+
const targetRouteOwners = new Map();
|
|
665
|
+
const targetRecordsByStableKey = new Map();
|
|
666
|
+
for (const record of targetEndpoints) {
|
|
667
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
668
|
+
if (stableKey) {
|
|
669
|
+
targetRecordsByStableKey.set(stableKey, [...(targetRecordsByStableKey.get(stableKey) || []), record]);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const ambiguousTargetKeys = new Set();
|
|
673
|
+
for (const [stableKey, records] of targetRecordsByStableKey) {
|
|
674
|
+
if (records.length <= 1) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
ambiguousTargetKeys.add(stableKey);
|
|
678
|
+
blockers.push(`Duplicate target Endpoint stableKey "${stableKey}".`);
|
|
679
|
+
for (const record of records) {
|
|
680
|
+
items.push({ status: 'collision', stableKey, name: String(record.name || stableKey), reason: 'duplicate target stableKey' });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const targetIssueKeys = new Set();
|
|
684
|
+
for (const record of targetEndpoints) {
|
|
685
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
686
|
+
const routeKey = endpointRecordRouteKey(record);
|
|
687
|
+
if (routeKey) {
|
|
688
|
+
targetRouteOwners.set(routeKey, [
|
|
689
|
+
...(targetRouteOwners.get(routeKey) || []),
|
|
690
|
+
{ ...(stableKey ? { stableKey } : {}), name: endpointLabel(record, 'unknown') },
|
|
691
|
+
]);
|
|
692
|
+
}
|
|
693
|
+
if (record.deletedAt || record.deletedBy) {
|
|
694
|
+
if (stableKey) {
|
|
695
|
+
deletedTargetsByStableKey.set(stableKey, [...(deletedTargetsByStableKey.get(stableKey) || []), record]);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
const warning = `${endpointLabel(record, 'unknown')}: deleted unmanaged target Endpoint skipped`;
|
|
699
|
+
exclusions.push(warning);
|
|
700
|
+
items.push({ status: 'skipped', name: endpointLabel(record, 'unknown'), reason: 'deleted unmanaged target Endpoint' });
|
|
701
|
+
}
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (!stableKey) {
|
|
705
|
+
const warning = `${endpointLabel(record, 'unknown')}: unmanaged target Endpoint missing ${input.stableKeyName}`;
|
|
706
|
+
exclusions.push(warning);
|
|
707
|
+
items.push({ status: 'skipped', name: endpointLabel(record, 'unknown'), reason: `unmanaged: missing ${input.stableKeyName}` });
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const manifest = await manifestFromRecord({
|
|
712
|
+
client: input.client,
|
|
713
|
+
record,
|
|
714
|
+
stableKey,
|
|
715
|
+
stableKeyName: input.stableKeyName,
|
|
716
|
+
componentsById,
|
|
717
|
+
});
|
|
718
|
+
liveTargetsByStableKey.set(stableKey, [
|
|
719
|
+
...(liveTargetsByStableKey.get(stableKey) || []),
|
|
720
|
+
{ manifest, hash: hashPortableEndpoint(manifest) },
|
|
721
|
+
]);
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
725
|
+
targetIssueKeys.add(stableKey);
|
|
726
|
+
blockers.push(`Cannot plan target Endpoint "${stableKey}": ${reason}`);
|
|
727
|
+
items.push({ status: 'conflict', stableKey, name: String(record.name || stableKey), reason });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const targetRouteBlockedKeys = new Set();
|
|
731
|
+
for (const [routeKey, owners] of targetRouteOwners) {
|
|
732
|
+
if (owners.length <= 1) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const ownerNames = owners.map((owner) => owner.stableKey || owner.name).sort();
|
|
736
|
+
blockers.push(`Target Endpoint route collision on ${routeKey}: ${ownerNames.join(', ')}.`);
|
|
737
|
+
for (const owner of owners) {
|
|
738
|
+
if (owner.stableKey) {
|
|
739
|
+
targetRouteBlockedKeys.add(owner.stableKey);
|
|
740
|
+
}
|
|
741
|
+
items.push({
|
|
742
|
+
status: 'collision',
|
|
743
|
+
...(owner.stableKey ? { stableKey: owner.stableKey } : {}),
|
|
744
|
+
name: owner.name,
|
|
745
|
+
reason: `duplicate target route ${routeKey}`,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const desiredRouteOwners = new Map();
|
|
750
|
+
for (const [stableKey, entries] of localByStableKey) {
|
|
751
|
+
if (entries.length === 1) {
|
|
752
|
+
const routeKey = endpointRouteKey(entries[0].manifest.route);
|
|
753
|
+
desiredRouteOwners.set(routeKey, [...(desiredRouteOwners.get(routeKey) || []), stableKey]);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const routeBlockedKeys = new Set(targetRouteBlockedKeys);
|
|
757
|
+
for (const [routeKey, stableKeys] of desiredRouteOwners) {
|
|
758
|
+
if (stableKeys.length > 1) {
|
|
759
|
+
blockers.push(`Endpoint route collision on ${routeKey}: ${stableKeys.join(', ')}.`);
|
|
760
|
+
for (const stableKey of stableKeys) {
|
|
761
|
+
routeBlockedKeys.add(stableKey);
|
|
762
|
+
items.push({ status: 'collision', stableKey, reason: `duplicate source route ${routeKey}` });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
for (const stableKey of stableKeys) {
|
|
766
|
+
const collisions = (targetRouteOwners.get(routeKey) || []).filter((owner) => owner.stableKey !== stableKey);
|
|
767
|
+
if (collisions.length > 0) {
|
|
768
|
+
routeBlockedKeys.add(stableKey);
|
|
769
|
+
const collisionNames = collisions.map((owner) => owner.stableKey || owner.name).sort();
|
|
770
|
+
blockers.push(`Endpoint route collision on ${routeKey}: "${stableKey}" conflicts with target ${collisionNames.join(', ')}.`);
|
|
771
|
+
items.push({ status: 'collision', stableKey, reason: `target route ${routeKey} is owned by another Endpoint` });
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const lock = readComponentLock(projectRoot);
|
|
776
|
+
const consumedTargets = new Set();
|
|
777
|
+
for (const [stableKey, localEntries] of localByStableKey) {
|
|
778
|
+
if (localEntries.length !== 1) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
const desired = localEntries[0].manifest;
|
|
782
|
+
const dependency = componentDependencyDiagnostics({
|
|
783
|
+
endpoint: desired,
|
|
784
|
+
workspaceComponents,
|
|
785
|
+
targetComponents: targetComponentsByKey,
|
|
786
|
+
});
|
|
787
|
+
const desiredHash = hashPortableEndpoint(desired);
|
|
788
|
+
if (ambiguousTargetKeys.has(stableKey) || targetIssueKeys.has(stableKey)) {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const deletedTargets = deletedTargetsByStableKey.get(stableKey) || [];
|
|
792
|
+
if (deletedTargets.length === 1) {
|
|
793
|
+
blockers.push(...dependency.blockers);
|
|
794
|
+
const restoreSafety = await activeDeletedEndpointRestoreBlockers({
|
|
795
|
+
client: input.client,
|
|
796
|
+
record: deletedTargets[0],
|
|
797
|
+
componentsById,
|
|
798
|
+
});
|
|
799
|
+
if (restoreSafety.length > 0) {
|
|
800
|
+
blockers.push(...restoreSafety.map((reason) => `Cannot restore Endpoint "${stableKey}": ${reason}.`));
|
|
801
|
+
}
|
|
802
|
+
if (routeBlockedKeys.has(stableKey)) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const restoreBlocked = dependency.blockers.length > 0 || restoreSafety.length > 0;
|
|
806
|
+
items.push({
|
|
807
|
+
status: restoreBlocked ? 'restore-blocked' : 'restore',
|
|
808
|
+
stableKey,
|
|
809
|
+
name: desired.name,
|
|
810
|
+
desiredHash,
|
|
811
|
+
currentHash: endpointRevisionHash(deletedTargets[0]),
|
|
812
|
+
targetRevisionHash: endpointRevisionHash(deletedTargets[0]),
|
|
813
|
+
...(restoreBlocked
|
|
814
|
+
? { reason: restoreSafety[0] || 'Component compatibility gate failed; align Components before Endpoints.' }
|
|
815
|
+
: { reason: 'exact soft-deleted target Stable Key match' }),
|
|
816
|
+
details: dependency.details,
|
|
817
|
+
});
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (dependency.blockers.length > 0) {
|
|
821
|
+
blockers.push(...dependency.blockers);
|
|
822
|
+
}
|
|
823
|
+
if (routeBlockedKeys.has(stableKey)) {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const targets = liveTargetsByStableKey.get(stableKey) || [];
|
|
827
|
+
if (targets.length > 1) {
|
|
828
|
+
consumedTargets.add(stableKey);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (targets.length === 0) {
|
|
832
|
+
items.push({
|
|
833
|
+
status: 'create',
|
|
834
|
+
stableKey,
|
|
835
|
+
name: desired.name,
|
|
836
|
+
desiredHash,
|
|
837
|
+
...(dependency.blockers.length > 0
|
|
838
|
+
? { reason: 'Component compatibility gate failed; align Components before Endpoints.' }
|
|
839
|
+
: {}),
|
|
840
|
+
details: dependency.details,
|
|
841
|
+
});
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
consumedTargets.add(stableKey);
|
|
845
|
+
const current = targets[0];
|
|
846
|
+
const baselineHash = lock.endpoints[stableKey]?.environments[input.envName]?.remoteHash;
|
|
847
|
+
const common = {
|
|
848
|
+
stableKey,
|
|
849
|
+
name: desired.name,
|
|
850
|
+
desiredHash,
|
|
851
|
+
currentHash: current.hash,
|
|
852
|
+
...(baselineHash ? { baselineHash } : {}),
|
|
853
|
+
details: dependency.details,
|
|
854
|
+
};
|
|
855
|
+
if (desiredHash === current.hash) {
|
|
856
|
+
items.push({ status: 'clean', ...common });
|
|
857
|
+
}
|
|
858
|
+
else if (!baselineHash) {
|
|
859
|
+
items.push({
|
|
860
|
+
status: 'missing-lock',
|
|
861
|
+
...common,
|
|
862
|
+
changedFields: diffEndpoints(desired, current.manifest),
|
|
863
|
+
reason: `missing ${input.envName} lock baseline`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
else if (current.hash === baselineHash) {
|
|
867
|
+
items.push({ status: 'safe-update', ...common, changedFields: diffEndpoints(desired, current.manifest), reason: 'local manifest changed; target matches lock baseline' });
|
|
868
|
+
}
|
|
869
|
+
else if (desiredHash === baselineHash) {
|
|
870
|
+
items.push({ status: 'remote-changed', ...common, changedFields: diffEndpoints(desired, current.manifest), reason: 'target changed; local manifest matches lock baseline' });
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
items.push({ status: 'conflict', ...common, changedFields: diffEndpoints(desired, current.manifest), reason: 'local manifest and target both changed since lock baseline' });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
for (const [stableKey, targets] of liveTargetsByStableKey) {
|
|
877
|
+
if (consumedTargets.has(stableKey) || localByStableKey.has(stableKey)
|
|
878
|
+
|| targetRouteBlockedKeys.has(stableKey) || targets.length !== 1) {
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
items.push({ status: 'orphan', stableKey, name: targets[0].manifest.name, currentHash: targets[0].hash, reason: 'target-only Endpoint' });
|
|
882
|
+
}
|
|
883
|
+
for (const [stableKey, targets] of deletedTargetsByStableKey) {
|
|
884
|
+
if (localByStableKey.has(stableKey) || ambiguousTargetKeys.has(stableKey) || targets.length !== 1) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
items.push({
|
|
888
|
+
status: 'orphan',
|
|
889
|
+
stableKey,
|
|
890
|
+
name: String(targets[0].name || stableKey),
|
|
891
|
+
currentHash: endpointRevisionHash(targets[0]),
|
|
892
|
+
reason: 'target-only soft-deleted Endpoint',
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
const rank = new Map(ENDPOINT_PLAN_STATUS_ORDER.map((status, index) => [status, index]));
|
|
896
|
+
items.sort((left, right) => (Number(rank.get(left.status)) - Number(rank.get(right.status))
|
|
897
|
+
|| String(left.stableKey || left.name || '').localeCompare(String(right.stableKey || right.name || ''))
|
|
898
|
+
|| String(left.name || '').localeCompare(String(right.name || ''))
|
|
899
|
+
|| String(left.reason || '').localeCompare(String(right.reason || ''))));
|
|
900
|
+
const counts = endpointPlanCounts();
|
|
901
|
+
for (const item of items) {
|
|
902
|
+
counts[item.status] += 1;
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
resourceType: 'endpoint',
|
|
906
|
+
env: input.envName,
|
|
907
|
+
counts,
|
|
908
|
+
blockers: [...new Set(blockers)].sort(),
|
|
909
|
+
items,
|
|
910
|
+
exclusions: [...new Set(exclusions)].sort(),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function readValidWorkspaceEndpoints(cwd, stableKeyName) {
|
|
914
|
+
const result = new Map();
|
|
915
|
+
for (const entry of readWorkspaceEndpoints(cwd)) {
|
|
916
|
+
if (validateManifest(entry.manifest, stableKeyName)) {
|
|
917
|
+
result.set(entry.manifest.stableKey, { manifest: entry.manifest, manifestPath: entry.manifestPath });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return result;
|
|
921
|
+
}
|
|
922
|
+
function endpointWriteOptions(manifest, schemaComponent) {
|
|
923
|
+
const options = { ...(manifest.options || {}) };
|
|
924
|
+
if (manifest.schema && schemaComponent) {
|
|
925
|
+
const schemaId = componentIdOf(schemaComponent);
|
|
926
|
+
if (!schemaId) {
|
|
927
|
+
throw new Error(`schema dependency "${manifest.schema.stableKey}" has no target component id`);
|
|
928
|
+
}
|
|
929
|
+
options.componentSchemaId = schemaId;
|
|
930
|
+
options.componentSchemaVersion = manifest.schema.binding === 'pinned'
|
|
931
|
+
? schemaComponent.version
|
|
932
|
+
: null;
|
|
933
|
+
options.componentSchemaElement = manifest.schema.element ?? null;
|
|
934
|
+
}
|
|
935
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
936
|
+
}
|
|
937
|
+
async function resolveEndpointWritePayload(input) {
|
|
938
|
+
const targetComponents = await loadRemoteComponents(input.client);
|
|
939
|
+
const targetByKey = targetComponentsByStableKey(targetComponents, input.stableKeyName);
|
|
940
|
+
const workspaceComponents = input.workspaceComponents || readWorkspaceComponents(input.cwd, input.stableKeyName);
|
|
941
|
+
const diagnostics = componentDependencyDiagnostics({
|
|
942
|
+
endpoint: input.manifest,
|
|
943
|
+
workspaceComponents,
|
|
944
|
+
targetComponents: targetByKey,
|
|
945
|
+
});
|
|
946
|
+
if (diagnostics.blockers.length > 0) {
|
|
947
|
+
throw new Error(diagnostics.blockers.join(' '));
|
|
948
|
+
}
|
|
949
|
+
const resolve = (binding) => {
|
|
950
|
+
if (!binding) {
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
const matches = targetByKey.get(binding.stableKey) || [];
|
|
954
|
+
if (matches.length !== 1) {
|
|
955
|
+
throw new Error(`dependency "${binding.stableKey}" expected one active target Component, found ${matches.length}`);
|
|
956
|
+
}
|
|
957
|
+
return matches[0];
|
|
958
|
+
};
|
|
959
|
+
const execution = resolve(input.manifest.execution);
|
|
960
|
+
const schema = resolve(input.manifest.schema);
|
|
961
|
+
if (input.manifest.execution?.binding === 'pinned' && typeof execution?.version !== 'number') {
|
|
962
|
+
throw new Error(`pinned execution dependency "${input.manifest.execution.stableKey}" has no live target version`);
|
|
963
|
+
}
|
|
964
|
+
if (input.manifest.schema?.binding === 'pinned' && typeof schema?.version !== 'number') {
|
|
965
|
+
throw new Error(`pinned schema dependency "${input.manifest.schema.stableKey}" has no live target version`);
|
|
966
|
+
}
|
|
967
|
+
const componentId = execution ? componentIdOf(execution) : '';
|
|
968
|
+
if (execution && !componentId) {
|
|
969
|
+
throw new Error(`execution dependency "${input.manifest.execution?.stableKey}" has no target component id`);
|
|
970
|
+
}
|
|
971
|
+
const options = endpointWriteOptions(input.manifest, schema);
|
|
972
|
+
const common = {
|
|
973
|
+
name: input.manifest.name,
|
|
974
|
+
category: input.manifest.category ?? null,
|
|
975
|
+
desc: input.manifest.description ?? '',
|
|
976
|
+
metadata: input.manifest.metadata,
|
|
977
|
+
...(execution
|
|
978
|
+
? {
|
|
979
|
+
componentId,
|
|
980
|
+
componentVersion: input.manifest.execution?.binding === 'pinned' ? execution.version : null,
|
|
981
|
+
}
|
|
982
|
+
: { componentId: null, componentVersion: null }),
|
|
983
|
+
timeout: input.manifest.timeout ?? ENDPOINT_DEFAULT_TIMEOUT_SECONDS,
|
|
984
|
+
path: input.manifest.route.path,
|
|
985
|
+
method: input.manifest.route.method,
|
|
986
|
+
...(options === undefined ? {} : { options }),
|
|
987
|
+
};
|
|
988
|
+
return common;
|
|
989
|
+
}
|
|
990
|
+
async function updateEndpointConfigurationAndActivation(input) {
|
|
991
|
+
const latest = unwrapData(await input.client.getEndpoint(input.endpointId));
|
|
992
|
+
const latestStableKey = readConfiguredResourceMetadataStableKey(latest, input.stableKeyName);
|
|
993
|
+
if (latestStableKey !== input.stableKey || latest.deletedAt || latest.deletedBy) {
|
|
994
|
+
throw new Error(`${input.targetLabel} identity changed before update`);
|
|
995
|
+
}
|
|
996
|
+
if (endpointRevisionHash(latest) !== input.expectedRevisionHash) {
|
|
997
|
+
throw new Error(`${input.targetLabel} changed before update`);
|
|
998
|
+
}
|
|
999
|
+
if (typeof latest.version !== 'number') {
|
|
1000
|
+
throw new Error(`${input.targetLabel} did not include a current version`);
|
|
1001
|
+
}
|
|
1002
|
+
const payload = await resolveEndpointWritePayload(input);
|
|
1003
|
+
await input.client.updateEndpoint(input.endpointId, {
|
|
1004
|
+
...payload,
|
|
1005
|
+
options: payload.options ?? null,
|
|
1006
|
+
version: latest.version,
|
|
1007
|
+
});
|
|
1008
|
+
const configured = unwrapData(await input.client.getEndpoint(input.endpointId));
|
|
1009
|
+
if (Boolean(configured.active) !== input.manifest.active) {
|
|
1010
|
+
if (input.manifest.active) {
|
|
1011
|
+
await input.client.activateEndpoint(input.endpointId);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
await input.client.disableEndpoint(input.endpointId);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
async function verifyEndpointWrite(input) {
|
|
1019
|
+
const verified = unwrapData(await input.client.getEndpoint(input.endpointId));
|
|
1020
|
+
const verifiedStableKey = readConfiguredResourceMetadataStableKey(verified, input.stableKeyName);
|
|
1021
|
+
if (verifiedStableKey !== input.stableKey) {
|
|
1022
|
+
throw new Error(`written target is missing or changed ${input.stableKeyName}`);
|
|
1023
|
+
}
|
|
1024
|
+
const targetComponents = await loadRemoteComponents(input.client);
|
|
1025
|
+
const componentsById = new Map(targetComponents.map((component) => [componentIdOf(component), component]));
|
|
1026
|
+
const verifiedManifest = await manifestFromRecord({
|
|
1027
|
+
client: input.client,
|
|
1028
|
+
record: verified,
|
|
1029
|
+
stableKey: verifiedStableKey,
|
|
1030
|
+
stableKeyName: input.stableKeyName,
|
|
1031
|
+
componentsById,
|
|
1032
|
+
});
|
|
1033
|
+
const remoteHash = hashPortableEndpoint(verifiedManifest);
|
|
1034
|
+
if (remoteHash !== input.desiredHash) {
|
|
1035
|
+
const changedFields = diffEndpoints(input.desiredManifest, verifiedManifest);
|
|
1036
|
+
throw new Error(`written endpoint did not match desired portable hash (fields: ${changedFields.join(', ') || 'unknown'}; desired: ${input.desiredHash}; actual: ${remoteHash})`);
|
|
1037
|
+
}
|
|
1038
|
+
return remoteHash;
|
|
1039
|
+
}
|
|
1040
|
+
function updateEndpointEnvironmentBaseline(input) {
|
|
1041
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Endpoint');
|
|
1042
|
+
const lock = readComponentLock(projectRoot);
|
|
1043
|
+
const stableKey = input.manifest.stableKey;
|
|
1044
|
+
const existingPair = Object.entries(lock.endpoints).find(([, entry]) => entry.stableKey === stableKey);
|
|
1045
|
+
const existing = existingPair?.[1];
|
|
1046
|
+
if (existingPair && existingPair[0] !== stableKey) {
|
|
1047
|
+
delete lock.endpoints[existingPair[0]];
|
|
1048
|
+
}
|
|
1049
|
+
const previousBaseline = existing?.environments[input.envName];
|
|
1050
|
+
lock.endpoints[stableKey] = {
|
|
1051
|
+
stableKey,
|
|
1052
|
+
path: toPortableRelativePath(projectRoot, input.manifestPath),
|
|
1053
|
+
sourceHash: hashPortableEndpoint(input.manifest),
|
|
1054
|
+
environments: {
|
|
1055
|
+
...(existing?.environments || {}),
|
|
1056
|
+
[input.envName]: previousBaseline?.remoteHash === input.remoteHash
|
|
1057
|
+
? previousBaseline
|
|
1058
|
+
: { remoteHash: input.remoteHash, syncedAt: new Date().toISOString() },
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
writeComponentLock(projectRoot, lock);
|
|
1062
|
+
}
|
|
1063
|
+
async function executeEndpointPushItem(input) {
|
|
1064
|
+
const stableKey = input.item.stableKey || '';
|
|
1065
|
+
const local = input.localByKey.get(stableKey);
|
|
1066
|
+
const targetPath = local ? localTargetPath(input.cwd, local.manifestPath) : stableKey;
|
|
1067
|
+
try {
|
|
1068
|
+
if (!local) {
|
|
1069
|
+
throw new Error('desired manifest disappeared after planning');
|
|
1070
|
+
}
|
|
1071
|
+
const desiredHash = hashPortableEndpoint(local.manifest);
|
|
1072
|
+
if (desiredHash !== input.item.desiredHash) {
|
|
1073
|
+
throw new Error('desired manifest changed after planning');
|
|
1074
|
+
}
|
|
1075
|
+
const prepared = {
|
|
1076
|
+
stableKey,
|
|
1077
|
+
manifest: local.manifest,
|
|
1078
|
+
};
|
|
1079
|
+
const endpointId = await input.mutate(prepared);
|
|
1080
|
+
const remoteHash = await verifyEndpointWrite({
|
|
1081
|
+
client: input.client,
|
|
1082
|
+
endpointId,
|
|
1083
|
+
stableKey,
|
|
1084
|
+
desiredManifest: local.manifest,
|
|
1085
|
+
desiredHash,
|
|
1086
|
+
stableKeyName: input.stableKeyName,
|
|
1087
|
+
});
|
|
1088
|
+
updateEndpointEnvironmentBaseline({
|
|
1089
|
+
cwd: input.cwd,
|
|
1090
|
+
envName: input.envName,
|
|
1091
|
+
manifest: local.manifest,
|
|
1092
|
+
manifestPath: local.manifestPath,
|
|
1093
|
+
remoteHash,
|
|
1094
|
+
});
|
|
1095
|
+
return { stableKey, action: input.action, status: 'deployed', targetPath };
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
return {
|
|
1099
|
+
stableKey,
|
|
1100
|
+
action: input.action,
|
|
1101
|
+
status: 'failed',
|
|
1102
|
+
targetPath,
|
|
1103
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
export async function pushEndpoints(input) {
|
|
1108
|
+
const plan = await buildEndpointsPlan(input);
|
|
1109
|
+
if (plan.blockers.length > 0) {
|
|
1110
|
+
throw new Error(`Endpoint push blocked: ${plan.blockers.join(' ')}`);
|
|
1111
|
+
}
|
|
1112
|
+
const contentBlockers = plan.items.filter((item) => ['missing-lock', 'remote-changed', 'conflict'].includes(item.status));
|
|
1113
|
+
if (contentBlockers.length > 0 && !input.force) {
|
|
1114
|
+
const statuses = [...new Set(contentBlockers.map((item) => item.status))].sort().join(', ');
|
|
1115
|
+
throw new Error(`Endpoint push blocked by ${statuses}; review the plan and re-run with --force only to adopt or overwrite reviewed content drift.`);
|
|
1116
|
+
}
|
|
1117
|
+
const localByKey = readValidWorkspaceEndpoints(input.cwd, input.stableKeyName);
|
|
1118
|
+
const workspaceComponents = readWorkspaceComponents(input.cwd, input.stableKeyName);
|
|
1119
|
+
const results = [];
|
|
1120
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'create')) {
|
|
1121
|
+
results.push(await executeEndpointPushItem({
|
|
1122
|
+
...input,
|
|
1123
|
+
item,
|
|
1124
|
+
localByKey,
|
|
1125
|
+
action: 'create',
|
|
1126
|
+
mutate: async (prepared) => {
|
|
1127
|
+
const payload = await resolveEndpointWritePayload({
|
|
1128
|
+
...input,
|
|
1129
|
+
manifest: prepared.manifest,
|
|
1130
|
+
workspaceComponents,
|
|
1131
|
+
});
|
|
1132
|
+
const createPayload = { ...payload, active: false };
|
|
1133
|
+
if (!prepared.manifest.execution) {
|
|
1134
|
+
delete createPayload.componentId;
|
|
1135
|
+
delete createPayload.componentVersion;
|
|
1136
|
+
}
|
|
1137
|
+
const created = unwrapData(await input.client.createEndpoint(createPayload));
|
|
1138
|
+
const createdId = endpointIdOf(created);
|
|
1139
|
+
if (!createdId) {
|
|
1140
|
+
throw new Error('create response did not include endpointId');
|
|
1141
|
+
}
|
|
1142
|
+
if (prepared.manifest.active) {
|
|
1143
|
+
await input.client.activateEndpoint(createdId);
|
|
1144
|
+
}
|
|
1145
|
+
return createdId;
|
|
1146
|
+
},
|
|
1147
|
+
}));
|
|
1148
|
+
}
|
|
1149
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'restore')) {
|
|
1150
|
+
results.push(await executeEndpointPushItem({
|
|
1151
|
+
...input,
|
|
1152
|
+
item,
|
|
1153
|
+
localByKey,
|
|
1154
|
+
action: 'restore',
|
|
1155
|
+
mutate: async (prepared) => {
|
|
1156
|
+
const liveEndpoints = await loadRemoteEndpoints(input.client);
|
|
1157
|
+
const matches = liveEndpoints.filter((record) => (Boolean(record.deletedAt || record.deletedBy)
|
|
1158
|
+
&& readConfiguredResourceMetadataStableKey(record, input.stableKeyName) === prepared.stableKey));
|
|
1159
|
+
if (matches.length !== 1) {
|
|
1160
|
+
throw new Error(`expected one deleted target match, found ${matches.length}`);
|
|
1161
|
+
}
|
|
1162
|
+
const target = matches[0];
|
|
1163
|
+
const targetId = endpointIdOf(target);
|
|
1164
|
+
if (!targetId) {
|
|
1165
|
+
throw new Error('deleted target did not include endpointId');
|
|
1166
|
+
}
|
|
1167
|
+
if (endpointRevisionHash(target) !== item.targetRevisionHash) {
|
|
1168
|
+
throw new Error('deleted target changed after planning');
|
|
1169
|
+
}
|
|
1170
|
+
const targetComponents = await loadRemoteComponents(input.client);
|
|
1171
|
+
const restoreBlockers = await activeDeletedEndpointRestoreBlockers({
|
|
1172
|
+
client: input.client,
|
|
1173
|
+
record: target,
|
|
1174
|
+
componentsById: new Map(targetComponents.map((component) => [componentIdOf(component), component])),
|
|
1175
|
+
});
|
|
1176
|
+
if (restoreBlockers.length > 0) {
|
|
1177
|
+
throw new Error(`restore safety changed after planning: ${restoreBlockers.join('; ')}`);
|
|
1178
|
+
}
|
|
1179
|
+
await input.client.restoreEndpoint(targetId);
|
|
1180
|
+
const restored = unwrapData(await input.client.getEndpoint(targetId));
|
|
1181
|
+
const restoredStableKey = readConfiguredResourceMetadataStableKey(restored, input.stableKeyName);
|
|
1182
|
+
if (restoredStableKey !== prepared.stableKey) {
|
|
1183
|
+
throw new Error(`restored target is missing or changed ${input.stableKeyName}`);
|
|
1184
|
+
}
|
|
1185
|
+
if (restored.deletedAt || restored.deletedBy) {
|
|
1186
|
+
throw new Error('target remained soft-deleted after restore');
|
|
1187
|
+
}
|
|
1188
|
+
if (typeof restored.version !== 'number') {
|
|
1189
|
+
throw new Error('restored target did not include version');
|
|
1190
|
+
}
|
|
1191
|
+
await updateEndpointConfigurationAndActivation({
|
|
1192
|
+
client: input.client,
|
|
1193
|
+
cwd: input.cwd,
|
|
1194
|
+
stableKeyName: input.stableKeyName,
|
|
1195
|
+
endpointId: targetId,
|
|
1196
|
+
stableKey: prepared.stableKey,
|
|
1197
|
+
manifest: prepared.manifest,
|
|
1198
|
+
expectedRevisionHash: endpointRevisionHash(restored),
|
|
1199
|
+
targetLabel: 'restored target',
|
|
1200
|
+
workspaceComponents,
|
|
1201
|
+
});
|
|
1202
|
+
return targetId;
|
|
1203
|
+
},
|
|
1204
|
+
}));
|
|
1205
|
+
}
|
|
1206
|
+
const updateItems = plan.items.filter((candidate) => (['safe-update', 'remote-changed', 'conflict', 'missing-lock'].includes(candidate.status)));
|
|
1207
|
+
for (const item of updateItems) {
|
|
1208
|
+
results.push(await executeEndpointPushItem({
|
|
1209
|
+
...input,
|
|
1210
|
+
item,
|
|
1211
|
+
localByKey,
|
|
1212
|
+
action: 'update',
|
|
1213
|
+
mutate: async (prepared) => {
|
|
1214
|
+
const liveEndpoints = await loadRemoteEndpoints(input.client);
|
|
1215
|
+
const matches = liveEndpoints.filter((record) => (!record.deletedAt
|
|
1216
|
+
&& !record.deletedBy
|
|
1217
|
+
&& readConfiguredResourceMetadataStableKey(record, input.stableKeyName) === prepared.stableKey));
|
|
1218
|
+
if (matches.length !== 1) {
|
|
1219
|
+
throw new Error(`expected one live target match, found ${matches.length}`);
|
|
1220
|
+
}
|
|
1221
|
+
const target = matches[0];
|
|
1222
|
+
const targetId = endpointIdOf(target);
|
|
1223
|
+
if (!targetId) {
|
|
1224
|
+
throw new Error('live target did not include endpointId');
|
|
1225
|
+
}
|
|
1226
|
+
const targetComponents = await loadRemoteComponents(input.client);
|
|
1227
|
+
const targetManifest = await manifestFromRecord({
|
|
1228
|
+
client: input.client,
|
|
1229
|
+
record: target,
|
|
1230
|
+
stableKey: prepared.stableKey,
|
|
1231
|
+
stableKeyName: input.stableKeyName,
|
|
1232
|
+
componentsById: new Map(targetComponents.map((component) => [componentIdOf(component), component])),
|
|
1233
|
+
});
|
|
1234
|
+
const targetHash = hashPortableEndpoint(targetManifest);
|
|
1235
|
+
if (targetHash !== item.currentHash) {
|
|
1236
|
+
throw new Error('target changed after planning');
|
|
1237
|
+
}
|
|
1238
|
+
if (typeof target.version !== 'number') {
|
|
1239
|
+
throw new Error('live target did not include version');
|
|
1240
|
+
}
|
|
1241
|
+
await updateEndpointConfigurationAndActivation({
|
|
1242
|
+
client: input.client,
|
|
1243
|
+
cwd: input.cwd,
|
|
1244
|
+
stableKeyName: input.stableKeyName,
|
|
1245
|
+
endpointId: targetId,
|
|
1246
|
+
stableKey: prepared.stableKey,
|
|
1247
|
+
manifest: prepared.manifest,
|
|
1248
|
+
expectedRevisionHash: endpointRevisionHash(target),
|
|
1249
|
+
targetLabel: 'target',
|
|
1250
|
+
workspaceComponents,
|
|
1251
|
+
});
|
|
1252
|
+
return targetId;
|
|
1253
|
+
},
|
|
1254
|
+
}));
|
|
1255
|
+
}
|
|
1256
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'clean')) {
|
|
1257
|
+
const stableKey = item.stableKey || '';
|
|
1258
|
+
const local = localByKey.get(stableKey);
|
|
1259
|
+
if (local && item.currentHash) {
|
|
1260
|
+
updateEndpointEnvironmentBaseline({
|
|
1261
|
+
cwd: input.cwd,
|
|
1262
|
+
envName: input.envName,
|
|
1263
|
+
manifest: local.manifest,
|
|
1264
|
+
manifestPath: local.manifestPath,
|
|
1265
|
+
remoteHash: item.currentHash,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
results.push({
|
|
1269
|
+
stableKey,
|
|
1270
|
+
action: 'noop',
|
|
1271
|
+
status: 'skipped',
|
|
1272
|
+
targetPath: local ? localTargetPath(input.cwd, local.manifestPath) : stableKey,
|
|
1273
|
+
reason: 'no local changes',
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'orphan')) {
|
|
1277
|
+
results.push({ stableKey: item.stableKey || item.name || '', action: 'orphan', status: 'skipped', targetPath: item.stableKey || item.name || '', reason: 'target-only orphan' });
|
|
1278
|
+
}
|
|
1279
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'skipped')) {
|
|
1280
|
+
results.push({ stableKey: item.stableKey || item.name || '', action: 'skipped', status: 'skipped', targetPath: item.stableKey || item.name || '', reason: item.reason || 'unmanaged' });
|
|
1281
|
+
}
|
|
1282
|
+
return { plan, results };
|
|
1283
|
+
}
|