@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,882 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hashStable, readComponentLock, writeComponentLock, } from "../component-lock.js";
|
|
3
|
+
import { readConfiguredResourceMetadataStableKey, sanitizeUserMetadata, } from "../resource-metadata.js";
|
|
4
|
+
import { readJsonFile, sanitizeSegment } from "../utils.js";
|
|
5
|
+
import { findManifestFiles, isRecord, localTargetPath, requireProjectLayout, stableKeySuffix, toPortableRelativePath, unwrapData, writePulledManifest, } from "./util.js";
|
|
6
|
+
export const GROUPS_DIRECTORY = 'Groups';
|
|
7
|
+
export const GROUP_MANIFEST_FILE = 'group.json';
|
|
8
|
+
function groupIdOf(record) {
|
|
9
|
+
return String(record.groupId || record.id || '');
|
|
10
|
+
}
|
|
11
|
+
function groupMetadataOf(record) {
|
|
12
|
+
return {
|
|
13
|
+
...sanitizeUserMetadata(record.resourceMetadata),
|
|
14
|
+
...sanitizeUserMetadata(record.metaData),
|
|
15
|
+
...sanitizeUserMetadata(record.metadata),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function groupMemberCount(record) {
|
|
19
|
+
const members = Array.isArray(record.members) ? record.members.length : 0;
|
|
20
|
+
const users = Array.isArray(record.users) ? record.users.length : 0;
|
|
21
|
+
const declared = Number.isInteger(record.memberCount) && Number(record.memberCount) > 0
|
|
22
|
+
? Number(record.memberCount)
|
|
23
|
+
: 0;
|
|
24
|
+
return Math.max(members, users, declared);
|
|
25
|
+
}
|
|
26
|
+
function groupHint(value) {
|
|
27
|
+
return JSON.stringify([
|
|
28
|
+
value.category ?? null,
|
|
29
|
+
typeof value.name === 'string' ? value.name : '',
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
function stableKeyOf(record, stableKeyName) {
|
|
33
|
+
return readConfiguredResourceMetadataStableKey(record, stableKeyName);
|
|
34
|
+
}
|
|
35
|
+
function targetLocalWarnings(stableKey, record, preserveOwner) {
|
|
36
|
+
const warnings = [];
|
|
37
|
+
if (preserveOwner && (typeof record.ownerId === 'string' || typeof record.ownerType === 'string')) {
|
|
38
|
+
warnings.push(`Group "${stableKey}": target owner is preserved.`);
|
|
39
|
+
}
|
|
40
|
+
const memberCount = groupMemberCount(record);
|
|
41
|
+
if (memberCount > 0) {
|
|
42
|
+
warnings.push(`Group "${stableKey}": target members preserved: ${memberCount}.`);
|
|
43
|
+
}
|
|
44
|
+
return warnings;
|
|
45
|
+
}
|
|
46
|
+
export function groupManifestFromRecord(record, stableKeyName) {
|
|
47
|
+
const stableKey = stableKeyOf(record, stableKeyName);
|
|
48
|
+
if (!stableKey) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
kind: 'group',
|
|
53
|
+
stableKey,
|
|
54
|
+
name: String(record.name || ''),
|
|
55
|
+
...(record.category === undefined ? {} : { category: record.category }),
|
|
56
|
+
...(typeof record.description === 'string'
|
|
57
|
+
? { description: record.description }
|
|
58
|
+
: typeof record.desc === 'string' ? { description: record.desc } : {}),
|
|
59
|
+
metadata: groupMetadataOf(record),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function hashPortableGroup(manifest) {
|
|
63
|
+
return hashStable({
|
|
64
|
+
...manifest,
|
|
65
|
+
category: manifest.category ?? null,
|
|
66
|
+
description: manifest.description ?? '',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function diffGroups(desired, target) {
|
|
70
|
+
return ['name', 'category', 'description', 'metadata']
|
|
71
|
+
.filter((field) => {
|
|
72
|
+
const desiredValue = field === 'category'
|
|
73
|
+
? desired.category ?? null
|
|
74
|
+
: field === 'description' ? desired.description ?? '' : desired[field];
|
|
75
|
+
const targetValue = field === 'category'
|
|
76
|
+
? target.category ?? null
|
|
77
|
+
: field === 'description' ? target.description ?? '' : target[field];
|
|
78
|
+
return hashStable({ value: desiredValue }) !== hashStable({ value: targetValue });
|
|
79
|
+
})
|
|
80
|
+
.sort((left, right) => left.localeCompare(right));
|
|
81
|
+
}
|
|
82
|
+
export function groupManifestPath(workspaceRoot, manifest) {
|
|
83
|
+
const category = manifest.category == null || manifest.category === ''
|
|
84
|
+
? '__no_category__'
|
|
85
|
+
: sanitizeSegment(manifest.category);
|
|
86
|
+
return path.join(workspaceRoot, GROUPS_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, GROUP_MANIFEST_FILE);
|
|
87
|
+
}
|
|
88
|
+
export function readWorkspaceGroups(cwd) {
|
|
89
|
+
const { workspaceRoot } = requireProjectLayout(cwd, 'Group');
|
|
90
|
+
return findManifestFiles(path.join(workspaceRoot, GROUPS_DIRECTORY), GROUP_MANIFEST_FILE)
|
|
91
|
+
.map((manifestPath) => ({
|
|
92
|
+
manifestPath,
|
|
93
|
+
manifest: readJsonFile(manifestPath),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
async function loadRemoteGroups(client) {
|
|
97
|
+
const { groups } = await client.listAllGroups();
|
|
98
|
+
const records = [];
|
|
99
|
+
for (const summary of groups) {
|
|
100
|
+
const id = groupIdOf(summary);
|
|
101
|
+
if (id) {
|
|
102
|
+
records.push(unwrapData(await client.getGroup(id)));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
records.push(summary);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return records;
|
|
109
|
+
}
|
|
110
|
+
function validateManifest(manifest, stableKeyName) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
if (!isRecord(manifest)) {
|
|
113
|
+
return ['manifest must be a JSON object'];
|
|
114
|
+
}
|
|
115
|
+
if (manifest.kind !== 'group'
|
|
116
|
+
|| typeof manifest.stableKey !== 'string'
|
|
117
|
+
|| !manifest.stableKey.trim()
|
|
118
|
+
|| typeof manifest.name !== 'string'
|
|
119
|
+
|| !manifest.name.trim()
|
|
120
|
+
|| !isRecord(manifest.metadata)) {
|
|
121
|
+
errors.push('manifest must contain kind, stableKey, name and metadata');
|
|
122
|
+
}
|
|
123
|
+
if (manifest.category !== undefined && manifest.category !== null && typeof manifest.category !== 'string') {
|
|
124
|
+
errors.push('category must be a string, null, or omitted');
|
|
125
|
+
}
|
|
126
|
+
if (manifest.description !== undefined && typeof manifest.description !== 'string') {
|
|
127
|
+
errors.push('description must be a string or omitted');
|
|
128
|
+
}
|
|
129
|
+
if (isRecord(manifest.metadata) && Object.keys(manifest.metadata).some((key) => key.startsWith('__'))) {
|
|
130
|
+
errors.push('metadata must not contain backend-reserved keys');
|
|
131
|
+
}
|
|
132
|
+
const metadataStableKey = isRecord(manifest.metadata)
|
|
133
|
+
? readConfiguredResourceMetadataStableKey(manifest, stableKeyName)
|
|
134
|
+
: null;
|
|
135
|
+
if (typeof manifest.stableKey === 'string' && metadataStableKey !== manifest.stableKey) {
|
|
136
|
+
errors.push(`metadata.${stableKeyName} must exactly equal stableKey`);
|
|
137
|
+
}
|
|
138
|
+
return errors;
|
|
139
|
+
}
|
|
140
|
+
function emptyPlanCounts() {
|
|
141
|
+
return {
|
|
142
|
+
clean: 0,
|
|
143
|
+
create: 0,
|
|
144
|
+
'manual-owner': 0,
|
|
145
|
+
'safe-update': 0,
|
|
146
|
+
'remote-changed': 0,
|
|
147
|
+
conflict: 0,
|
|
148
|
+
'missing-lock': 0,
|
|
149
|
+
orphan: 0,
|
|
150
|
+
collision: 0,
|
|
151
|
+
skipped: 0,
|
|
152
|
+
delete: 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function sourceOwnerPolicy(input) {
|
|
156
|
+
const ownerType = typeof input.record.ownerType === 'string'
|
|
157
|
+
? input.record.ownerType.trim().toUpperCase()
|
|
158
|
+
: '';
|
|
159
|
+
const ownerId = typeof input.record.ownerId === 'string' ? input.record.ownerId.trim() : '';
|
|
160
|
+
if (ownerType === 'USER' && ownerId) {
|
|
161
|
+
return { type: 'manual-user' };
|
|
162
|
+
}
|
|
163
|
+
if (ownerType === 'GROUP' && ownerId) {
|
|
164
|
+
const owner = input.recordsById.get(ownerId);
|
|
165
|
+
const ownerStableKey = owner ? stableKeyOf(owner, input.stableKeyName) : null;
|
|
166
|
+
if (ownerStableKey) {
|
|
167
|
+
return { type: 'group', stableKey: ownerStableKey };
|
|
168
|
+
}
|
|
169
|
+
return { type: 'unresolved', reason: `source GROUP owner is missing ${input.stableKeyName}` };
|
|
170
|
+
}
|
|
171
|
+
if (ownerType === 'USER') {
|
|
172
|
+
return { type: 'unresolved', reason: 'source USER owner is missing ownerId' };
|
|
173
|
+
}
|
|
174
|
+
if (ownerType === 'GROUP') {
|
|
175
|
+
return { type: 'unresolved', reason: 'source GROUP owner is missing ownerId' };
|
|
176
|
+
}
|
|
177
|
+
return { type: 'unresolved', reason: 'source ownerType is missing or unsupported' };
|
|
178
|
+
}
|
|
179
|
+
function resolveCreateDependencies(input) {
|
|
180
|
+
const createByKey = new Map(input.items
|
|
181
|
+
.filter((item) => item.status === 'create' && item.stableKey)
|
|
182
|
+
.map((item) => [item.stableKey, item]));
|
|
183
|
+
const manualKeys = new Set(input.items
|
|
184
|
+
.filter((item) => item.status === 'manual-owner' && item.stableKey)
|
|
185
|
+
.map((item) => item.stableKey));
|
|
186
|
+
for (const item of createByKey.values()) {
|
|
187
|
+
const ownerStableKey = item.ownerStableKey || '';
|
|
188
|
+
const targetOwners = input.remoteByKey.get(ownerStableKey) || [];
|
|
189
|
+
if (targetOwners.length === 1) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (targetOwners.length > 1) {
|
|
193
|
+
item.status = 'conflict';
|
|
194
|
+
item.reason = `owner Group "${ownerStableKey}" has duplicate target stableKey`;
|
|
195
|
+
input.blockers.push(`Cannot create Group "${item.stableKey}": ${item.reason}.`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (manualKeys.has(ownerStableKey)) {
|
|
199
|
+
item.status = 'manual-owner';
|
|
200
|
+
item.reason = `owner Group "${ownerStableKey}" must be created manually first`;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!createByKey.has(ownerStableKey)) {
|
|
204
|
+
item.status = 'conflict';
|
|
205
|
+
item.reason = `owner Group "${ownerStableKey}" is missing from workspace and target`;
|
|
206
|
+
input.blockers.push(`Cannot create Group "${item.stableKey}": ${item.reason}.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const visiting = new Set();
|
|
210
|
+
const visited = new Set();
|
|
211
|
+
const cycleKeys = new Set();
|
|
212
|
+
const visit = (stableKey, trail) => {
|
|
213
|
+
if (visiting.has(stableKey)) {
|
|
214
|
+
const start = trail.indexOf(stableKey);
|
|
215
|
+
for (const key of trail.slice(start)) {
|
|
216
|
+
cycleKeys.add(key);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (visited.has(stableKey)) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
visiting.add(stableKey);
|
|
224
|
+
const ownerStableKey = createByKey.get(stableKey)?.ownerStableKey;
|
|
225
|
+
if (ownerStableKey && createByKey.get(ownerStableKey)?.status === 'create') {
|
|
226
|
+
visit(ownerStableKey, [...trail, stableKey]);
|
|
227
|
+
}
|
|
228
|
+
visiting.delete(stableKey);
|
|
229
|
+
visited.add(stableKey);
|
|
230
|
+
};
|
|
231
|
+
for (const stableKey of createByKey.keys()) {
|
|
232
|
+
visit(stableKey, []);
|
|
233
|
+
}
|
|
234
|
+
if (cycleKeys.size > 0) {
|
|
235
|
+
const cycle = [...cycleKeys].sort((left, right) => left.localeCompare(right));
|
|
236
|
+
input.blockers.push(`Cannot create Groups: circular Group owner dependency: ${cycle.join(', ')}.`);
|
|
237
|
+
for (const stableKey of cycle) {
|
|
238
|
+
const item = createByKey.get(stableKey);
|
|
239
|
+
if (item) {
|
|
240
|
+
item.status = 'conflict';
|
|
241
|
+
item.reason = 'circular Group owner dependency';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function consumeCollisionCandidates(input) {
|
|
247
|
+
for (const record of input.records) {
|
|
248
|
+
const stableKey = stableKeyOf(record, input.stableKeyName);
|
|
249
|
+
if (stableKey) {
|
|
250
|
+
input.consumedRemoteKeys.add(stableKey);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
input.consumedUnmanaged.add(record);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export async function buildGroupsPlan(input) {
|
|
258
|
+
const localEntries = readWorkspaceGroups(input.cwd);
|
|
259
|
+
const records = await loadRemoteGroups(input.client);
|
|
260
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Group');
|
|
261
|
+
const lock = readComponentLock(projectRoot);
|
|
262
|
+
const lockByStableKey = new Map(Object.values(lock.groups)
|
|
263
|
+
.map((entry) => [entry.stableKey, entry]));
|
|
264
|
+
const blockers = [];
|
|
265
|
+
const warnings = new Set();
|
|
266
|
+
const items = [];
|
|
267
|
+
const localByKey = new Map();
|
|
268
|
+
for (const entry of localEntries) {
|
|
269
|
+
const errors = validateManifest(entry.manifest, input.stableKeyName);
|
|
270
|
+
if (errors.length > 0) {
|
|
271
|
+
blockers.push(`Invalid Group manifest ${path.relative(projectRoot, entry.manifestPath)}: ${errors.join('; ')}.`);
|
|
272
|
+
const invalidName = isRecord(entry.manifest) && typeof entry.manifest.name === 'string' && entry.manifest.name
|
|
273
|
+
? entry.manifest.name
|
|
274
|
+
: path.basename(path.dirname(entry.manifestPath));
|
|
275
|
+
items.push({ status: 'conflict', name: invalidName, reason: 'invalid Group manifest' });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const key = entry.manifest.stableKey;
|
|
279
|
+
localByKey.set(key, [...(localByKey.get(key) || []), entry]);
|
|
280
|
+
}
|
|
281
|
+
const remoteByKey = new Map();
|
|
282
|
+
const unmanaged = [];
|
|
283
|
+
for (const record of records) {
|
|
284
|
+
const manifest = groupManifestFromRecord(record, input.stableKeyName);
|
|
285
|
+
if (!manifest) {
|
|
286
|
+
unmanaged.push(record);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
remoteByKey.set(manifest.stableKey, [...(remoteByKey.get(manifest.stableKey) || []), {
|
|
290
|
+
record,
|
|
291
|
+
manifest,
|
|
292
|
+
hash: hashPortableGroup(manifest),
|
|
293
|
+
}]);
|
|
294
|
+
}
|
|
295
|
+
const consumedRemoteKeys = new Set();
|
|
296
|
+
const consumedUnmanaged = new Set();
|
|
297
|
+
for (const [stableKey, entries] of localByKey) {
|
|
298
|
+
if (entries.length > 1) {
|
|
299
|
+
const desired = entries[0].manifest;
|
|
300
|
+
blockers.push(`Duplicate desired Group stableKey "${stableKey}".`);
|
|
301
|
+
items.push({ status: 'collision', stableKey, name: desired.name, reason: 'duplicate desired stableKey' });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const desired = entries[0].manifest;
|
|
305
|
+
const desiredHash = hashPortableGroup(desired);
|
|
306
|
+
const matches = remoteByKey.get(desired.stableKey) || [];
|
|
307
|
+
if (matches.length > 1) {
|
|
308
|
+
blockers.push(`Duplicate target Group stableKey "${desired.stableKey}".`);
|
|
309
|
+
consumedRemoteKeys.add(desired.stableKey);
|
|
310
|
+
items.push({ status: 'collision', stableKey: desired.stableKey, name: desired.name, reason: 'duplicate target stableKey', desiredHash });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const identityCollisions = records.filter((record) => (groupHint(record) === groupHint(desired)
|
|
314
|
+
&& stableKeyOf(record, input.stableKeyName) !== desired.stableKey));
|
|
315
|
+
if (identityCollisions.length > 0) {
|
|
316
|
+
consumeCollisionCandidates({
|
|
317
|
+
records: identityCollisions,
|
|
318
|
+
stableKeyName: input.stableKeyName,
|
|
319
|
+
consumedRemoteKeys,
|
|
320
|
+
consumedUnmanaged,
|
|
321
|
+
});
|
|
322
|
+
const reason = `target Group matching category/name does not have exact ${input.stableKeyName}`;
|
|
323
|
+
blockers.push(`Cannot synchronize "${desired.stableKey}": ${reason}.`);
|
|
324
|
+
items.push({ status: 'collision', stableKey: desired.stableKey, name: desired.name, reason, desiredHash });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const lockEntry = lockByStableKey.get(desired.stableKey);
|
|
328
|
+
if (matches.length === 0) {
|
|
329
|
+
const owner = lockEntry?.owner;
|
|
330
|
+
if (!owner) {
|
|
331
|
+
blockers.push(`Cannot create Group "${desired.stableKey}": missing portable source owner policy; pull from source first.`);
|
|
332
|
+
items.push({
|
|
333
|
+
status: 'missing-lock',
|
|
334
|
+
stableKey: desired.stableKey,
|
|
335
|
+
name: desired.name,
|
|
336
|
+
desiredHash,
|
|
337
|
+
reason: 'missing portable source owner policy',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else if (owner.type === 'manual-user') {
|
|
341
|
+
items.push({
|
|
342
|
+
status: 'manual-owner',
|
|
343
|
+
stableKey: desired.stableKey,
|
|
344
|
+
name: desired.name,
|
|
345
|
+
desiredHash,
|
|
346
|
+
reason: 'source USER owner requires manual target creation with the exact stableKey',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
else if (owner.type === 'unresolved') {
|
|
350
|
+
blockers.push(`Cannot create Group "${desired.stableKey}": ${owner.reason}.`);
|
|
351
|
+
items.push({
|
|
352
|
+
status: 'conflict',
|
|
353
|
+
stableKey: desired.stableKey,
|
|
354
|
+
name: desired.name,
|
|
355
|
+
desiredHash,
|
|
356
|
+
reason: owner.reason,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
items.push({
|
|
361
|
+
status: 'create',
|
|
362
|
+
stableKey: desired.stableKey,
|
|
363
|
+
name: desired.name,
|
|
364
|
+
desiredHash,
|
|
365
|
+
ownerStableKey: owner.stableKey,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
consumedRemoteKeys.add(desired.stableKey);
|
|
371
|
+
const target = matches[0];
|
|
372
|
+
if (lockEntry?.owner?.type === 'manual-user') {
|
|
373
|
+
const hasTargetUserOwner = String(target.record.ownerType || '').toUpperCase() === 'USER'
|
|
374
|
+
&& typeof target.record.ownerId === 'string'
|
|
375
|
+
&& target.record.ownerId.length > 0;
|
|
376
|
+
if (!hasTargetUserOwner) {
|
|
377
|
+
items.push({
|
|
378
|
+
status: 'manual-owner',
|
|
379
|
+
stableKey: desired.stableKey,
|
|
380
|
+
name: desired.name,
|
|
381
|
+
desiredHash,
|
|
382
|
+
currentHash: target.hash,
|
|
383
|
+
reason: 'target Group requires a target-local USER owner',
|
|
384
|
+
});
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
let ownerChanged = false;
|
|
389
|
+
let ownerStableKey;
|
|
390
|
+
if (lockEntry?.owner?.type === 'group') {
|
|
391
|
+
ownerStableKey = lockEntry.owner.stableKey;
|
|
392
|
+
const ownerMatches = remoteByKey.get(ownerStableKey) || [];
|
|
393
|
+
if (ownerMatches.length > 1) {
|
|
394
|
+
const reason = `owner Group "${ownerStableKey}" has duplicate target stableKey`;
|
|
395
|
+
blockers.push(`Cannot synchronize Group "${desired.stableKey}": ${reason}.`);
|
|
396
|
+
items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, desiredHash, reason, ownerStableKey });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (ownerMatches.length === 0 && !localByKey.has(ownerStableKey)) {
|
|
400
|
+
const reason = `owner Group "${ownerStableKey}" is missing from workspace and target`;
|
|
401
|
+
blockers.push(`Cannot synchronize Group "${desired.stableKey}": ${reason}.`);
|
|
402
|
+
items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, desiredHash, reason, ownerStableKey });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const targetOwnerId = ownerMatches.length === 1 ? groupIdOf(ownerMatches[0].record) : '';
|
|
406
|
+
ownerChanged = String(target.record.ownerType || '').toUpperCase() !== 'GROUP'
|
|
407
|
+
|| !targetOwnerId
|
|
408
|
+
|| target.record.ownerId !== targetOwnerId;
|
|
409
|
+
}
|
|
410
|
+
for (const warning of targetLocalWarnings(desired.stableKey, target.record, lockEntry?.owner?.type !== 'group')) {
|
|
411
|
+
warnings.add(warning);
|
|
412
|
+
}
|
|
413
|
+
const changedFields = [
|
|
414
|
+
...diffGroups(desired, target.manifest),
|
|
415
|
+
...(ownerChanged ? ['owner'] : []),
|
|
416
|
+
].sort((left, right) => left.localeCompare(right));
|
|
417
|
+
const common = {
|
|
418
|
+
stableKey: desired.stableKey,
|
|
419
|
+
name: desired.name,
|
|
420
|
+
desiredHash,
|
|
421
|
+
currentHash: target.hash,
|
|
422
|
+
...(ownerStableKey ? { ownerStableKey } : {}),
|
|
423
|
+
...(changedFields.length > 0 ? { changedFields } : {}),
|
|
424
|
+
};
|
|
425
|
+
if (desiredHash === target.hash && !ownerChanged) {
|
|
426
|
+
items.push({ status: 'clean', ...common });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (desiredHash === target.hash && ownerChanged) {
|
|
430
|
+
items.push({ status: 'safe-update', ...common, reason: 'target GROUP owner differs from source owner Stable Key' });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const baselineHash = lockEntry?.environments[input.envName]?.remoteHash;
|
|
434
|
+
if (!baselineHash) {
|
|
435
|
+
items.push({ status: 'missing-lock', ...common, reason: `missing ${input.envName} lock baseline` });
|
|
436
|
+
}
|
|
437
|
+
else if (target.hash === baselineHash) {
|
|
438
|
+
items.push({ status: 'safe-update', ...common, baselineHash });
|
|
439
|
+
}
|
|
440
|
+
else if (desiredHash === baselineHash) {
|
|
441
|
+
items.push({ status: 'remote-changed', ...common, baselineHash, reason: 'target changed since lock baseline' });
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
items.push({ status: 'conflict', ...common, baselineHash, reason: 'desired and target changed since lock baseline' });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
resolveCreateDependencies({ items, remoteByKey, blockers });
|
|
448
|
+
for (const [stableKey, matches] of remoteByKey) {
|
|
449
|
+
if (consumedRemoteKeys.has(stableKey) || localByKey.has(stableKey)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (matches.length > 1) {
|
|
453
|
+
blockers.push(`Duplicate target Group stableKey "${stableKey}".`);
|
|
454
|
+
items.push({ status: 'collision', stableKey, name: matches[0].manifest.name, reason: 'duplicate target stableKey' });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const target = matches[0];
|
|
458
|
+
items.push({
|
|
459
|
+
status: 'orphan',
|
|
460
|
+
stableKey: target.manifest.stableKey,
|
|
461
|
+
name: target.manifest.name,
|
|
462
|
+
currentHash: target.hash,
|
|
463
|
+
reason: 'target-only Group',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
for (const record of unmanaged) {
|
|
467
|
+
if (consumedUnmanaged.has(record)) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
items.push({
|
|
471
|
+
status: 'skipped',
|
|
472
|
+
name: String(record.name || groupIdOf(record) || 'unknown'),
|
|
473
|
+
reason: `missing ${input.stableKeyName}; unmanaged`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const rank = {
|
|
477
|
+
create: 0,
|
|
478
|
+
'manual-owner': 1,
|
|
479
|
+
'safe-update': 2,
|
|
480
|
+
'remote-changed': 3,
|
|
481
|
+
conflict: 4,
|
|
482
|
+
'missing-lock': 5,
|
|
483
|
+
clean: 6,
|
|
484
|
+
orphan: 7,
|
|
485
|
+
collision: 8,
|
|
486
|
+
skipped: 9,
|
|
487
|
+
delete: 10,
|
|
488
|
+
};
|
|
489
|
+
items.sort((left, right) => rank[left.status] - rank[right.status]
|
|
490
|
+
|| (left.stableKey || left.name).localeCompare(right.stableKey || right.name));
|
|
491
|
+
const counts = emptyPlanCounts();
|
|
492
|
+
for (const item of items) {
|
|
493
|
+
counts[item.status] += 1;
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
resourceType: 'group',
|
|
497
|
+
env: input.envName,
|
|
498
|
+
counts,
|
|
499
|
+
blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
|
|
500
|
+
items,
|
|
501
|
+
warnings: [...warnings].sort((left, right) => left.localeCompare(right)),
|
|
502
|
+
exclusions: [
|
|
503
|
+
'members/users are always skipped',
|
|
504
|
+
'USER owners, leader, avatar, UUID and version are target-local and never persisted',
|
|
505
|
+
'GROUP owner dependencies are persisted only as stableKey references',
|
|
506
|
+
],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
export async function pullGroupsToWorkspace(input) {
|
|
510
|
+
const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Group');
|
|
511
|
+
const records = await loadRemoteGroups(input.client);
|
|
512
|
+
const skipped = [];
|
|
513
|
+
const notices = [];
|
|
514
|
+
const managed = [];
|
|
515
|
+
const stableKeys = new Set();
|
|
516
|
+
const recordsById = new Map(records
|
|
517
|
+
.map((record) => [groupIdOf(record), record])
|
|
518
|
+
.filter(([id]) => Boolean(id)));
|
|
519
|
+
for (const record of records) {
|
|
520
|
+
const manifest = groupManifestFromRecord(record, input.stableKeyName);
|
|
521
|
+
if (!manifest) {
|
|
522
|
+
const name = String(record.name || groupIdOf(record) || 'unknown');
|
|
523
|
+
skipped.push({ name, targetPath: name, reason: `missing ${input.stableKeyName}; unmanaged` });
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const errors = validateManifest(manifest, input.stableKeyName);
|
|
527
|
+
if (errors.length > 0) {
|
|
528
|
+
throw new Error(`Invalid managed Group "${manifest.stableKey}" on ${input.envName}: ${errors.join('; ')}; no files were changed.`);
|
|
529
|
+
}
|
|
530
|
+
if (stableKeys.has(manifest.stableKey)) {
|
|
531
|
+
throw new Error(`Duplicate Group stableKey "${manifest.stableKey}" on ${input.envName}; no files were changed.`);
|
|
532
|
+
}
|
|
533
|
+
stableKeys.add(manifest.stableKey);
|
|
534
|
+
const memberCount = groupMemberCount(record);
|
|
535
|
+
if (memberCount > 0) {
|
|
536
|
+
notices.push({ stableKey: manifest.stableKey, reason: `source members excluded: ${memberCount}` });
|
|
537
|
+
}
|
|
538
|
+
const owner = sourceOwnerPolicy({ record, recordsById, stableKeyName: input.stableKeyName });
|
|
539
|
+
if (owner.type === 'manual-user') {
|
|
540
|
+
notices.push({ stableKey: manifest.stableKey, reason: 'source USER owner excluded; target creation is manual' });
|
|
541
|
+
}
|
|
542
|
+
else if (owner.type === 'unresolved') {
|
|
543
|
+
notices.push({ stableKey: manifest.stableKey, reason: `${owner.reason}; automatic create is blocked` });
|
|
544
|
+
}
|
|
545
|
+
managed.push({ manifest, owner });
|
|
546
|
+
}
|
|
547
|
+
managed.sort((left, right) => left.manifest.stableKey.localeCompare(right.manifest.stableKey));
|
|
548
|
+
const lock = readComponentLock(projectRoot);
|
|
549
|
+
const existingByStableKey = new Map();
|
|
550
|
+
for (const entry of readWorkspaceGroups(input.cwd)) {
|
|
551
|
+
const errors = validateManifest(entry.manifest, input.stableKeyName);
|
|
552
|
+
if (errors.length > 0) {
|
|
553
|
+
throw new Error(`Cannot pull over invalid Group manifest ${path.relative(projectRoot, entry.manifestPath)}: ${errors.join('; ')}.`);
|
|
554
|
+
}
|
|
555
|
+
if (existingByStableKey.has(entry.manifest.stableKey)) {
|
|
556
|
+
throw new Error(`Duplicate local Group stableKey "${entry.manifest.stableKey}"; no files were changed.`);
|
|
557
|
+
}
|
|
558
|
+
existingByStableKey.set(entry.manifest.stableKey, entry);
|
|
559
|
+
}
|
|
560
|
+
const writes = [];
|
|
561
|
+
const outputPaths = new Set();
|
|
562
|
+
for (const managedGroup of managed) {
|
|
563
|
+
const { manifest, owner } = managedGroup;
|
|
564
|
+
const hash = hashPortableGroup(manifest);
|
|
565
|
+
const existing = existingByStableKey.get(manifest.stableKey);
|
|
566
|
+
if (existing && !input.force) {
|
|
567
|
+
const localHash = hashPortableGroup(existing.manifest);
|
|
568
|
+
const lockEntry = lock.groups[manifest.stableKey];
|
|
569
|
+
if (localHash !== hash && (!lockEntry || localHash !== lockEntry.sourceHash)) {
|
|
570
|
+
throw new Error(`Cannot pull Group "${manifest.stableKey}": local manifest changed since the lock baseline. Re-run with --force.`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const filePath = groupManifestPath(workspaceRoot, manifest);
|
|
574
|
+
if (outputPaths.has(filePath)) {
|
|
575
|
+
throw new Error(`Multiple Groups resolve to workspace path "${path.relative(projectRoot, filePath)}"; no files were changed.`);
|
|
576
|
+
}
|
|
577
|
+
outputPaths.add(filePath);
|
|
578
|
+
writes.push({
|
|
579
|
+
filePath,
|
|
580
|
+
...(existing && existing.manifestPath !== filePath ? { previousPath: existing.manifestPath } : {}),
|
|
581
|
+
manifest,
|
|
582
|
+
hash,
|
|
583
|
+
owner,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const groupsRoot = path.join(workspaceRoot, GROUPS_DIRECTORY);
|
|
587
|
+
let lockChanged = false;
|
|
588
|
+
for (const write of writes) {
|
|
589
|
+
writePulledManifest({
|
|
590
|
+
filePath: write.filePath,
|
|
591
|
+
manifest: write.manifest,
|
|
592
|
+
previousPath: write.previousPath,
|
|
593
|
+
root: groupsRoot,
|
|
594
|
+
force: input.force,
|
|
595
|
+
});
|
|
596
|
+
const previous = lock.groups[write.manifest.stableKey];
|
|
597
|
+
const previousBaseline = previous?.environments[input.envName];
|
|
598
|
+
const entry = {
|
|
599
|
+
stableKey: write.manifest.stableKey,
|
|
600
|
+
path: toPortableRelativePath(projectRoot, write.filePath),
|
|
601
|
+
sourceHash: write.hash,
|
|
602
|
+
owner: write.owner,
|
|
603
|
+
environments: {
|
|
604
|
+
...(previous?.environments || {}),
|
|
605
|
+
[input.envName]: previousBaseline?.remoteHash === write.hash
|
|
606
|
+
? previousBaseline
|
|
607
|
+
: { remoteHash: write.hash, syncedAt: new Date().toISOString() },
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
if (!previous || hashStable(previous) !== hashStable(entry)) {
|
|
611
|
+
lockChanged = true;
|
|
612
|
+
}
|
|
613
|
+
lock.groups[write.manifest.stableKey] = entry;
|
|
614
|
+
}
|
|
615
|
+
if (lockChanged) {
|
|
616
|
+
writeComponentLock(projectRoot, lock);
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
pulled: writes.map((write) => ({
|
|
620
|
+
manifest: write.manifest,
|
|
621
|
+
targetPath: localTargetPath(input.cwd, write.filePath),
|
|
622
|
+
})),
|
|
623
|
+
skipped,
|
|
624
|
+
notices,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function buildCreatePayload(manifest, ownerId) {
|
|
628
|
+
return {
|
|
629
|
+
name: manifest.name,
|
|
630
|
+
...(manifest.category === undefined ? {} : { category: manifest.category }),
|
|
631
|
+
...(manifest.description === undefined ? {} : { desc: manifest.description }),
|
|
632
|
+
metadata: sanitizeUserMetadata(manifest.metadata),
|
|
633
|
+
ownerType: 'GROUP',
|
|
634
|
+
ownerId,
|
|
635
|
+
members: [],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function buildUpdatePayload(manifest, target, ownerId) {
|
|
639
|
+
if (!Number.isInteger(target.version)) {
|
|
640
|
+
throw new Error('target Group version is required');
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
name: manifest.name,
|
|
644
|
+
...(manifest.category === undefined && target.category === undefined
|
|
645
|
+
? {}
|
|
646
|
+
: { category: manifest.category ?? null }),
|
|
647
|
+
...(manifest.description === undefined && target.description === undefined && target.desc === undefined
|
|
648
|
+
? {}
|
|
649
|
+
: { desc: manifest.description ?? '' }),
|
|
650
|
+
metadata: sanitizeUserMetadata(manifest.metadata),
|
|
651
|
+
version: target.version,
|
|
652
|
+
...(ownerId ? { ownerType: 'GROUP', ownerId } : {}),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function updateEnvironmentBaseline(input) {
|
|
656
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Group');
|
|
657
|
+
const lock = readComponentLock(projectRoot);
|
|
658
|
+
const localMatches = readWorkspaceGroups(input.cwd)
|
|
659
|
+
.filter(({ manifest }) => manifest.stableKey === input.stableKey);
|
|
660
|
+
if (localMatches.length !== 1) {
|
|
661
|
+
throw new Error(`Cannot update lock baseline for "${input.stableKey}": expected one local Group manifest, found ${localMatches.length}.`);
|
|
662
|
+
}
|
|
663
|
+
const local = localMatches[0];
|
|
664
|
+
const entry = lock.groups[input.stableKey];
|
|
665
|
+
const existingBaseline = entry?.environments[input.envName];
|
|
666
|
+
const nextEntry = {
|
|
667
|
+
stableKey: local.manifest.stableKey,
|
|
668
|
+
path: toPortableRelativePath(projectRoot, local.manifestPath),
|
|
669
|
+
sourceHash: hashPortableGroup(local.manifest),
|
|
670
|
+
...(entry?.owner ? { owner: entry.owner } : {}),
|
|
671
|
+
environments: {
|
|
672
|
+
...(entry?.environments || {}),
|
|
673
|
+
[input.envName]: existingBaseline?.remoteHash === input.remoteHash
|
|
674
|
+
? existingBaseline
|
|
675
|
+
: { remoteHash: input.remoteHash, syncedAt: new Date().toISOString() },
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
if (entry && hashStable(entry) === hashStable(nextEntry)) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
lock.groups[input.stableKey] = nextEntry;
|
|
682
|
+
writeComponentLock(projectRoot, lock);
|
|
683
|
+
}
|
|
684
|
+
function localManifestByStableKey(cwd) {
|
|
685
|
+
return new Map(readWorkspaceGroups(cwd).map((entry) => [entry.manifest.stableKey, entry]));
|
|
686
|
+
}
|
|
687
|
+
async function liveTargetByStableKey(client, stableKeyName, stableKey) {
|
|
688
|
+
const records = await loadRemoteGroups(client);
|
|
689
|
+
return records.filter((record) => stableKeyOf(record, stableKeyName) === stableKey);
|
|
690
|
+
}
|
|
691
|
+
async function assertCreateStillSafe(client, stableKeyName, desired) {
|
|
692
|
+
const records = await loadRemoteGroups(client);
|
|
693
|
+
for (const record of records) {
|
|
694
|
+
const key = stableKeyOf(record, stableKeyName);
|
|
695
|
+
if (key === desired.stableKey) {
|
|
696
|
+
throw new Error('matching stableKey appeared after planning');
|
|
697
|
+
}
|
|
698
|
+
if (groupHint(record) === groupHint(desired) && key !== desired.stableKey) {
|
|
699
|
+
throw new Error(`target Group matching category/name appeared without exact ${stableKeyName} after planning`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function resultTargetPath(cwd, localByKey, item) {
|
|
704
|
+
const local = item.stableKey ? localByKey.get(item.stableKey) : undefined;
|
|
705
|
+
return local ? localTargetPath(cwd, local.manifestPath) : item.stableKey || item.name;
|
|
706
|
+
}
|
|
707
|
+
export async function pushGroups(input) {
|
|
708
|
+
const plan = await buildGroupsPlan(input);
|
|
709
|
+
if (plan.blockers.length > 0) {
|
|
710
|
+
throw new Error(`Group push blocked: ${plan.blockers.join(' ')}`);
|
|
711
|
+
}
|
|
712
|
+
const contentBlockers = plan.items
|
|
713
|
+
.filter((item) => ['missing-lock', 'remote-changed', 'conflict'].includes(item.status));
|
|
714
|
+
if (contentBlockers.length > 0 && !input.force) {
|
|
715
|
+
const statuses = [...new Set(contentBlockers.map((item) => item.status))].sort().join(', ');
|
|
716
|
+
throw new Error(`Group push blocked by ${statuses}; review the plan and re-run with --force only to adopt or overwrite reviewed content drift.`);
|
|
717
|
+
}
|
|
718
|
+
if (plan.items.some((item) => item.status === 'collision')) {
|
|
719
|
+
throw new Error('Group push blocked by stableKey collision; --force cannot bypass identity ambiguity.');
|
|
720
|
+
}
|
|
721
|
+
const localByKey = localManifestByStableKey(input.cwd);
|
|
722
|
+
const results = [];
|
|
723
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'clean')) {
|
|
724
|
+
if (!item.stableKey || !item.currentHash) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
updateEnvironmentBaseline({
|
|
728
|
+
cwd: input.cwd,
|
|
729
|
+
envName: input.envName,
|
|
730
|
+
stableKey: item.stableKey,
|
|
731
|
+
remoteHash: item.currentHash,
|
|
732
|
+
});
|
|
733
|
+
results.push({
|
|
734
|
+
stableKey: item.stableKey,
|
|
735
|
+
action: 'noop',
|
|
736
|
+
status: 'skipped',
|
|
737
|
+
targetPath: resultTargetPath(input.cwd, localByKey, item),
|
|
738
|
+
reason: 'no local changes',
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const pendingCreates = new Map(plan.items
|
|
742
|
+
.filter((item) => item.status === 'create' && item.stableKey)
|
|
743
|
+
.map((item) => [item.stableKey, item]));
|
|
744
|
+
while (pendingCreates.size > 0) {
|
|
745
|
+
let progressed = false;
|
|
746
|
+
for (const [stableKey, item] of [...pendingCreates.entries()]
|
|
747
|
+
.sort(([left], [right]) => left.localeCompare(right))) {
|
|
748
|
+
const ownerMatches = await liveTargetByStableKey(input.client, input.stableKeyName, item.ownerStableKey || '');
|
|
749
|
+
if (ownerMatches.length === 0) {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
pendingCreates.delete(stableKey);
|
|
753
|
+
progressed = true;
|
|
754
|
+
const local = localByKey.get(stableKey);
|
|
755
|
+
const targetPath = resultTargetPath(input.cwd, localByKey, item);
|
|
756
|
+
if (!local) {
|
|
757
|
+
results.push({ stableKey, action: 'create', status: 'failed', targetPath, error: 'desired manifest disappeared after planning' });
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (ownerMatches.length !== 1) {
|
|
761
|
+
results.push({ stableKey, action: 'create', status: 'failed', targetPath, error: `expected one target owner Group, found ${ownerMatches.length}` });
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const ownerId = groupIdOf(ownerMatches[0]);
|
|
765
|
+
if (!ownerId) {
|
|
766
|
+
results.push({ stableKey, action: 'create', status: 'failed', targetPath, error: 'target owner Group did not include groupId' });
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const liveDesiredHash = hashPortableGroup(local.manifest);
|
|
770
|
+
if (liveDesiredHash !== item.desiredHash) {
|
|
771
|
+
results.push({ stableKey, action: 'create', status: 'failed', targetPath, error: 'desired manifest changed after planning' });
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
try {
|
|
775
|
+
await assertCreateStillSafe(input.client, input.stableKeyName, local.manifest);
|
|
776
|
+
const created = unwrapData(await input.client.createGroup(buildCreatePayload(local.manifest, ownerId)));
|
|
777
|
+
const createdId = groupIdOf(created);
|
|
778
|
+
if (!createdId) {
|
|
779
|
+
throw new Error('create response did not include groupId');
|
|
780
|
+
}
|
|
781
|
+
const verified = unwrapData(await input.client.getGroup(createdId));
|
|
782
|
+
const verifiedManifest = groupManifestFromRecord(verified, input.stableKeyName);
|
|
783
|
+
if (!verifiedManifest || hashPortableGroup(verifiedManifest) !== liveDesiredHash) {
|
|
784
|
+
throw new Error('created Group did not match desired portable hash');
|
|
785
|
+
}
|
|
786
|
+
if (String(verified.ownerType || '').toUpperCase() !== 'GROUP' || verified.ownerId !== ownerId) {
|
|
787
|
+
throw new Error('created Group did not retain the resolved target owner');
|
|
788
|
+
}
|
|
789
|
+
updateEnvironmentBaseline({ cwd: input.cwd, envName: input.envName, stableKey, remoteHash: liveDesiredHash });
|
|
790
|
+
results.push({ stableKey, action: 'create', status: 'deployed', targetPath });
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
results.push({ stableKey, action: 'create', status: 'failed', targetPath, error: error instanceof Error ? error.message : String(error) });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (!progressed) {
|
|
797
|
+
for (const [stableKey, item] of pendingCreates) {
|
|
798
|
+
results.push({
|
|
799
|
+
stableKey,
|
|
800
|
+
action: 'create',
|
|
801
|
+
status: 'failed',
|
|
802
|
+
targetPath: resultTargetPath(input.cwd, localByKey, item),
|
|
803
|
+
error: `target owner Group "${item.ownerStableKey || 'unknown'}" is unavailable`,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
pendingCreates.clear();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const updates = plan.items
|
|
810
|
+
.filter((item) => ['safe-update', 'missing-lock', 'remote-changed', 'conflict'].includes(item.status));
|
|
811
|
+
for (const item of updates) {
|
|
812
|
+
const stableKey = item.stableKey || '';
|
|
813
|
+
const local = localByKey.get(stableKey);
|
|
814
|
+
const targetPath = resultTargetPath(input.cwd, localByKey, item);
|
|
815
|
+
if (!local) {
|
|
816
|
+
results.push({ stableKey, action: 'update', status: 'failed', targetPath, error: 'desired manifest disappeared after planning' });
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const liveDesiredHash = hashPortableGroup(local.manifest);
|
|
820
|
+
if (liveDesiredHash !== item.desiredHash) {
|
|
821
|
+
results.push({ stableKey, action: 'update', status: 'failed', targetPath, error: 'desired manifest changed after planning' });
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const matches = await liveTargetByStableKey(input.client, input.stableKeyName, stableKey);
|
|
826
|
+
if (matches.length !== 1) {
|
|
827
|
+
throw new Error(`expected one live target match, found ${matches.length}`);
|
|
828
|
+
}
|
|
829
|
+
const targetId = groupIdOf(matches[0]);
|
|
830
|
+
if (!targetId) {
|
|
831
|
+
throw new Error('live target did not include groupId');
|
|
832
|
+
}
|
|
833
|
+
const target = unwrapData(await input.client.getGroup(targetId));
|
|
834
|
+
const targetManifest = groupManifestFromRecord(target, input.stableKeyName);
|
|
835
|
+
if (!targetManifest || hashPortableGroup(targetManifest) !== item.currentHash) {
|
|
836
|
+
throw new Error('target changed after planning');
|
|
837
|
+
}
|
|
838
|
+
let desiredOwnerId;
|
|
839
|
+
if (item.ownerStableKey) {
|
|
840
|
+
const ownerMatches = await liveTargetByStableKey(input.client, input.stableKeyName, item.ownerStableKey);
|
|
841
|
+
if (ownerMatches.length !== 1) {
|
|
842
|
+
throw new Error(`expected one live target owner Group, found ${ownerMatches.length}`);
|
|
843
|
+
}
|
|
844
|
+
desiredOwnerId = groupIdOf(ownerMatches[0]);
|
|
845
|
+
if (!desiredOwnerId) {
|
|
846
|
+
throw new Error('live target owner Group did not include groupId');
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
await input.client.updateGroup(targetId, buildUpdatePayload(local.manifest, target, desiredOwnerId));
|
|
850
|
+
const verified = unwrapData(await input.client.getGroup(targetId));
|
|
851
|
+
const verifiedManifest = groupManifestFromRecord(verified, input.stableKeyName);
|
|
852
|
+
if (!verifiedManifest || hashPortableGroup(verifiedManifest) !== liveDesiredHash) {
|
|
853
|
+
throw new Error('updated Group did not match desired portable hash');
|
|
854
|
+
}
|
|
855
|
+
if (desiredOwnerId && (String(verified.ownerType || '').toUpperCase() !== 'GROUP'
|
|
856
|
+
|| verified.ownerId !== desiredOwnerId)) {
|
|
857
|
+
throw new Error('updated Group did not retain the resolved target owner');
|
|
858
|
+
}
|
|
859
|
+
updateEnvironmentBaseline({ cwd: input.cwd, envName: input.envName, stableKey, remoteHash: liveDesiredHash });
|
|
860
|
+
results.push({ stableKey, action: 'update', status: 'deployed', targetPath });
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
results.push({ stableKey, action: 'update', status: 'failed', targetPath, error: error instanceof Error ? error.message : String(error) });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'manual-owner')) {
|
|
867
|
+
results.push({
|
|
868
|
+
stableKey: item.stableKey || item.name,
|
|
869
|
+
action: 'manual-owner',
|
|
870
|
+
status: 'skipped',
|
|
871
|
+
targetPath: resultTargetPath(input.cwd, localByKey, item),
|
|
872
|
+
reason: item.reason || 'manual target owner required',
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'orphan')) {
|
|
876
|
+
results.push({ stableKey: item.stableKey || item.name, action: 'orphan', status: 'skipped', targetPath: item.stableKey || item.name, reason: 'target-only orphan' });
|
|
877
|
+
}
|
|
878
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'skipped')) {
|
|
879
|
+
results.push({ stableKey: item.stableKey || item.name, action: 'skipped', status: 'skipped', targetPath: item.stableKey || item.name, reason: item.reason || 'unmanaged' });
|
|
880
|
+
}
|
|
881
|
+
return { plan, results };
|
|
882
|
+
}
|