@revoengine/cli 1.0.9 → 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.
Files changed (81) hide show
  1. package/README.md +464 -8
  2. package/dist/src/cli.js +65 -6
  3. package/dist/src/client.d.ts +366 -5
  4. package/dist/src/client.js +954 -13
  5. package/dist/src/commands/auth.js +4 -2
  6. package/dist/src/commands/component.js +839 -412
  7. package/dist/src/commands/database-schemas.d.ts +2 -0
  8. package/dist/src/commands/database-schemas.js +188 -0
  9. package/dist/src/commands/database-views.d.ts +2 -0
  10. package/dist/src/commands/database-views.js +123 -0
  11. package/dist/src/commands/endpoints.js +114 -0
  12. package/dist/src/commands/env.d.ts +2 -0
  13. package/dist/src/commands/env.js +380 -0
  14. package/dist/src/commands/events.d.ts +2 -0
  15. package/dist/src/commands/events.js +146 -0
  16. package/dist/src/commands/groups.d.ts +2 -0
  17. package/dist/src/commands/groups.js +169 -0
  18. package/dist/src/commands/index.d.ts +10 -0
  19. package/dist/src/commands/index.js +10 -0
  20. package/dist/src/commands/job-templates.d.ts +2 -0
  21. package/dist/src/commands/job-templates.js +101 -0
  22. package/dist/src/commands/metadata.d.ts +2 -0
  23. package/dist/src/commands/metadata.js +159 -0
  24. package/dist/src/commands/project.js +82 -1
  25. package/dist/src/commands/role-groups.d.ts +2 -0
  26. package/dist/src/commands/role-groups.js +152 -0
  27. package/dist/src/commands/schedules.d.ts +2 -0
  28. package/dist/src/commands/schedules.js +141 -0
  29. package/dist/src/commands/terminal-service.d.ts +38 -0
  30. package/dist/src/commands/terminal-service.js +210 -0
  31. package/dist/src/commands/terminal.d.ts +22 -0
  32. package/dist/src/commands/terminal.js +511 -0
  33. package/dist/src/component-lock.d.ts +126 -2
  34. package/dist/src/component-lock.js +378 -15
  35. package/dist/src/config.d.ts +20 -0
  36. package/dist/src/config.js +121 -6
  37. package/dist/src/database-schema-artifacts.d.ts +7 -0
  38. package/dist/src/database-schema-artifacts.js +8 -0
  39. package/dist/src/env-sync.d.ts +83 -0
  40. package/dist/src/env-sync.js +315 -0
  41. package/dist/src/metadata-backfill.d.ts +56 -0
  42. package/dist/src/metadata-backfill.js +1176 -0
  43. package/dist/src/project.d.ts +17 -9
  44. package/dist/src/project.js +87 -11
  45. package/dist/src/prompt.js +10 -18
  46. package/dist/src/resource-metadata.d.ts +25 -0
  47. package/dist/src/resource-metadata.js +132 -0
  48. package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
  49. package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
  50. package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
  51. package/dist/src/resource-syncs/database-view-sync.js +1317 -0
  52. package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
  53. package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
  54. package/dist/src/resource-syncs/event-sync.d.ts +99 -0
  55. package/dist/src/resource-syncs/event-sync.js +949 -0
  56. package/dist/src/resource-syncs/group-sync.d.ts +86 -0
  57. package/dist/src/resource-syncs/group-sync.js +882 -0
  58. package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
  59. package/dist/src/resource-syncs/job-template-sync.js +782 -0
  60. package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
  61. package/dist/src/resource-syncs/role-group-sync.js +597 -0
  62. package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
  63. package/dist/src/resource-syncs/schedule-sync.js +1302 -0
  64. package/dist/src/resource-syncs/util.d.ts +19 -0
  65. package/dist/src/resource-syncs/util.js +116 -0
  66. package/dist/src/runtime-view.d.ts +1 -0
  67. package/dist/src/runtime-view.js +6 -1
  68. package/dist/src/sync-output.d.ts +38 -0
  69. package/dist/src/sync-output.js +131 -0
  70. package/dist/src/tracked-resources.d.ts +7 -0
  71. package/dist/src/tracked-resources.js +61 -0
  72. package/dist/src/types.d.ts +227 -0
  73. package/dist/src/ui.d.ts +3 -0
  74. package/dist/src/ui.js +68 -10
  75. package/dist/src/utils.d.ts +2 -0
  76. package/dist/src/utils.js +64 -0
  77. package/dist/src/workspace-component.d.ts +2 -0
  78. package/dist/src/workspace-component.js +34 -0
  79. package/dist/src/workspace-resource.d.ts +2 -0
  80. package/dist/src/workspace-resource.js +52 -0
  81. 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
+ }