@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,83 @@
1
+ import type { RoleGroupRecord } from '../types.ts';
2
+ import type { RevoClient } from '../client.ts';
3
+ export declare const ROLE_GROUPS_DIRECTORY = "RoleGroups";
4
+ export declare const ROLE_GROUP_MANIFEST_FILE = "role-group.json";
5
+ export type RoleGroupManifest = {
6
+ kind: 'role-group';
7
+ stableKey: string;
8
+ name: string;
9
+ category?: string | null;
10
+ description?: string;
11
+ metadata: Record<string, unknown>;
12
+ roles: string[];
13
+ };
14
+ export type RoleGroupPullResult = {
15
+ pulled: Array<{
16
+ manifest: RoleGroupManifest;
17
+ targetPath: string;
18
+ }>;
19
+ skipped: Array<{
20
+ name: string;
21
+ targetPath: string;
22
+ reason: string;
23
+ }>;
24
+ };
25
+ export type RoleGroupPlanStatus = 'clean' | 'create' | 'safe-update' | 'remote-changed' | 'conflict' | 'missing-lock' | 'orphan' | 'collision' | 'skipped' | 'delete';
26
+ export type RoleGroupPlanItem = {
27
+ status: RoleGroupPlanStatus;
28
+ stableKey?: string;
29
+ name: string;
30
+ reason?: string;
31
+ changedFields?: string[];
32
+ rolesToAdd?: string[];
33
+ rolesToRemove?: string[];
34
+ desiredHash?: string;
35
+ currentHash?: string;
36
+ baselineHash?: string;
37
+ };
38
+ export type RoleGroupsPlan = {
39
+ resourceType: 'role-group';
40
+ env: string;
41
+ counts: Record<RoleGroupPlanStatus, number>;
42
+ blockers: string[];
43
+ items: RoleGroupPlanItem[];
44
+ exclusions: string[];
45
+ };
46
+ export type RoleGroupPushResult = {
47
+ stableKey: string;
48
+ action: 'create' | 'update' | 'noop' | 'orphan' | 'skipped';
49
+ status: 'deployed' | 'skipped' | 'failed';
50
+ targetPath: string;
51
+ reason?: string;
52
+ error?: string;
53
+ };
54
+ export declare function roleGroupManifestFromRecord(record: RoleGroupRecord, stableKeyName: string): RoleGroupManifest | null;
55
+ export declare function hashPortableRoleGroup(manifest: RoleGroupManifest): string;
56
+ export declare function roleGroupManifestPath(workspaceRoot: string, manifest: RoleGroupManifest): string;
57
+ export declare function readWorkspaceRoleGroups(cwd: string): {
58
+ manifestPath: string;
59
+ manifest: RoleGroupManifest;
60
+ }[];
61
+ export declare function buildRoleGroupsPlan(input: {
62
+ client: RevoClient;
63
+ cwd: string;
64
+ envName: string;
65
+ stableKeyName: string;
66
+ }): Promise<RoleGroupsPlan>;
67
+ export declare function pullRoleGroupsToWorkspace(input: {
68
+ client: RevoClient;
69
+ cwd: string;
70
+ envName: string;
71
+ stableKeyName: string;
72
+ force?: boolean;
73
+ }): Promise<RoleGroupPullResult>;
74
+ export declare function pushRoleGroups(input: {
75
+ client: RevoClient;
76
+ cwd: string;
77
+ envName: string;
78
+ stableKeyName: string;
79
+ force?: boolean;
80
+ }): Promise<{
81
+ plan: RoleGroupsPlan;
82
+ results: RoleGroupPushResult[];
83
+ }>;
@@ -0,0 +1,597 @@
1
+ import path from 'node:path';
2
+ import { hashStable, readComponentLock, writeComponentLock, } from "../component-lock.js";
3
+ import { normalizeStableKeyValue, 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 ROLE_GROUPS_DIRECTORY = 'RoleGroups';
7
+ export const ROLE_GROUP_MANIFEST_FILE = 'role-group.json';
8
+ function roleGroupIdOf(record) {
9
+ return String(record.roleGroupId || record.id || '');
10
+ }
11
+ function roleKeyOf(value) {
12
+ if (typeof value === 'string')
13
+ return value.trim();
14
+ if (!isRecord(value))
15
+ return '';
16
+ for (const key of ['roleKey', 'key', 'name', 'code']) {
17
+ if (typeof value[key] === 'string' && value[key].trim())
18
+ return value[key].trim();
19
+ }
20
+ return '';
21
+ }
22
+ function roleKeysOf(record) {
23
+ return [...new Set((record.roles || []).map(roleKeyOf).filter(Boolean))]
24
+ .sort((left, right) => left.localeCompare(right));
25
+ }
26
+ export function roleGroupManifestFromRecord(record, stableKeyName) {
27
+ const stableKey = readConfiguredResourceMetadataStableKey(record, stableKeyName);
28
+ if (!stableKey)
29
+ return null;
30
+ return {
31
+ kind: 'role-group',
32
+ stableKey,
33
+ name: String(record.name || ''),
34
+ ...(record.category === undefined ? {} : { category: record.category }),
35
+ ...(typeof record.description === 'string'
36
+ ? { description: record.description }
37
+ : typeof record.desc === 'string' ? { description: record.desc } : {}),
38
+ metadata: sanitizeUserMetadata(record.metadata),
39
+ roles: roleKeysOf(record),
40
+ };
41
+ }
42
+ export function hashPortableRoleGroup(manifest) {
43
+ return hashStable({
44
+ ...manifest,
45
+ category: manifest.category ?? null,
46
+ description: manifest.description ?? '',
47
+ });
48
+ }
49
+ function roleGroupHint(value) {
50
+ const category = typeof value.category === 'string' && value.category.trim() ? value.category : '__no_category__';
51
+ return normalizeStableKeyValue(`${category}/${String(value.name || '')}`);
52
+ }
53
+ function diffRoleGroups(desired, target) {
54
+ const changedFields = ['name', 'category', 'description', 'metadata']
55
+ .filter((field) => {
56
+ const desiredValue = field === 'category' ? desired.category ?? null : field === 'description' ? desired.description ?? '' : desired[field];
57
+ const targetValue = field === 'category' ? target.category ?? null : field === 'description' ? target.description ?? '' : target[field];
58
+ return hashStable({ value: desiredValue }) !== hashStable({ value: targetValue });
59
+ })
60
+ .sort((left, right) => left.localeCompare(right));
61
+ const desiredRoles = new Set(desired.roles);
62
+ const targetRoles = new Set(target.roles);
63
+ return {
64
+ changedFields,
65
+ rolesToAdd: desired.roles.filter((roleKey) => !targetRoles.has(roleKey)),
66
+ rolesToRemove: target.roles.filter((roleKey) => !desiredRoles.has(roleKey)),
67
+ };
68
+ }
69
+ export function roleGroupManifestPath(workspaceRoot, manifest) {
70
+ const category = manifest.category == null || manifest.category === '' ? '__no_category__' : sanitizeSegment(manifest.category);
71
+ return path.join(workspaceRoot, ROLE_GROUPS_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, ROLE_GROUP_MANIFEST_FILE);
72
+ }
73
+ export function readWorkspaceRoleGroups(cwd) {
74
+ const { workspaceRoot } = requireProjectLayout(cwd, 'Role-group');
75
+ return findManifestFiles(path.join(workspaceRoot, ROLE_GROUPS_DIRECTORY), ROLE_GROUP_MANIFEST_FILE)
76
+ .map((manifestPath) => ({
77
+ manifestPath,
78
+ manifest: readJsonFile(manifestPath),
79
+ }));
80
+ }
81
+ async function loadRemoteRoleGroups(client) {
82
+ const { roleGroups } = await client.listAllRoleGroups();
83
+ const records = [];
84
+ for (const summary of roleGroups) {
85
+ const id = roleGroupIdOf(summary);
86
+ records.push(id ? unwrapData(await client.getRoleGroup(id)) : summary);
87
+ }
88
+ return records;
89
+ }
90
+ function validateManifest(manifest, stableKeyName) {
91
+ const errors = [];
92
+ if (!isRecord(manifest)) {
93
+ return ['manifest must be a JSON object'];
94
+ }
95
+ if (manifest.kind !== 'role-group'
96
+ || typeof manifest.stableKey !== 'string'
97
+ || !manifest.stableKey.trim()
98
+ || typeof manifest.name !== 'string'
99
+ || !manifest.name.trim()
100
+ || !isRecord(manifest.metadata)) {
101
+ errors.push('manifest must contain kind, stableKey, name and metadata');
102
+ }
103
+ if (manifest.category !== undefined && manifest.category !== null && typeof manifest.category !== 'string') {
104
+ errors.push('category must be a string, null, or omitted');
105
+ }
106
+ if (manifest.description !== undefined && typeof manifest.description !== 'string') {
107
+ errors.push('description must be a string or omitted');
108
+ }
109
+ const metadataStableKey = isRecord(manifest.metadata)
110
+ ? readConfiguredResourceMetadataStableKey(manifest, stableKeyName)
111
+ : null;
112
+ if (typeof manifest.stableKey === 'string' && metadataStableKey !== manifest.stableKey) {
113
+ errors.push(`metadata.${stableKeyName} must exactly equal stableKey`);
114
+ }
115
+ if (!Array.isArray(manifest.roles)
116
+ || manifest.roles.some((roleKey) => typeof roleKey !== 'string' || !roleKey.trim())) {
117
+ errors.push('roles must contain only non-empty strings');
118
+ }
119
+ else {
120
+ const roles = manifest.roles;
121
+ if (new Set(roles).size !== roles.length)
122
+ errors.push('roles must not contain duplicates');
123
+ if (roles.some((roleKey, index) => index > 0 && roles[index - 1].localeCompare(roleKey) > 0)) {
124
+ errors.push('roles must be sorted');
125
+ }
126
+ }
127
+ return errors;
128
+ }
129
+ function emptyPlanCounts() {
130
+ return {
131
+ clean: 0,
132
+ create: 0,
133
+ 'safe-update': 0,
134
+ 'remote-changed': 0,
135
+ conflict: 0,
136
+ 'missing-lock': 0,
137
+ orphan: 0,
138
+ collision: 0,
139
+ skipped: 0,
140
+ delete: 0,
141
+ };
142
+ }
143
+ export async function buildRoleGroupsPlan(input) {
144
+ const localEntries = readWorkspaceRoleGroups(input.cwd);
145
+ const records = await loadRemoteRoleGroups(input.client);
146
+ const { projectRoot } = requireProjectLayout(input.cwd, 'Role-group');
147
+ const lock = readComponentLock(projectRoot);
148
+ const lockByNormalizedKey = new Map(Object.values(lock.roleGroups)
149
+ .map((entry) => [normalizeStableKeyValue(entry.stableKey), entry]));
150
+ const blockers = [];
151
+ const items = [];
152
+ const localByKey = new Map();
153
+ for (const entry of localEntries) {
154
+ const errors = validateManifest(entry.manifest, input.stableKeyName);
155
+ if (errors.length > 0) {
156
+ blockers.push(`Invalid role-group manifest ${path.relative(projectRoot, entry.manifestPath)}: ${errors.join('; ')}.`);
157
+ const invalidName = isRecord(entry.manifest) && typeof entry.manifest.name === 'string' && entry.manifest.name
158
+ ? entry.manifest.name
159
+ : path.basename(path.dirname(entry.manifestPath));
160
+ items.push({
161
+ status: 'conflict',
162
+ name: invalidName,
163
+ reason: 'invalid role-group manifest',
164
+ });
165
+ continue;
166
+ }
167
+ const normalized = normalizeStableKeyValue(entry.manifest.stableKey || '');
168
+ localByKey.set(normalized, [...(localByKey.get(normalized) || []), entry]);
169
+ }
170
+ const remoteByKey = new Map();
171
+ const unmanaged = [];
172
+ for (const record of records) {
173
+ const manifest = roleGroupManifestFromRecord(record, input.stableKeyName);
174
+ if (!manifest) {
175
+ unmanaged.push(record);
176
+ continue;
177
+ }
178
+ const normalized = normalizeStableKeyValue(manifest.stableKey);
179
+ remoteByKey.set(normalized, [...(remoteByKey.get(normalized) || []), {
180
+ record,
181
+ manifest,
182
+ hash: hashPortableRoleGroup(manifest),
183
+ }]);
184
+ }
185
+ const validLocalEntries = [...localByKey.values()].flat();
186
+ const roleKeys = validLocalEntries.flatMap((entry) => entry.manifest.roles);
187
+ const targetRoleKeys = new Set(roleKeys.length > 0
188
+ ? (await input.client.listAllSystemRoles()).map(roleKeyOf).filter(Boolean)
189
+ : []);
190
+ const unknownRoleKeys = [...new Set(roleKeys.filter((roleKey) => !targetRoleKeys.has(roleKey)))].sort();
191
+ if (unknownRoleKeys.length > 0)
192
+ blockers.push(`Unknown target system role key(s): ${unknownRoleKeys.join(', ')}.`);
193
+ const consumedRemoteKeys = new Set();
194
+ const consumedUnmanaged = new Set();
195
+ for (const [normalized, entries] of localByKey) {
196
+ if (entries.length > 1) {
197
+ const stableKey = entries[0].manifest.stableKey;
198
+ blockers.push(`Duplicate desired role-group stableKey "${stableKey}".`);
199
+ items.push({ status: 'collision', stableKey, name: entries[0].manifest.name, reason: 'duplicate desired stableKey' });
200
+ continue;
201
+ }
202
+ const desired = entries[0].manifest;
203
+ const desiredHash = hashPortableRoleGroup(desired);
204
+ const matches = remoteByKey.get(normalized) || [];
205
+ if (matches.length > 1) {
206
+ blockers.push(`Duplicate target role-group stableKey "${desired.stableKey}".`);
207
+ consumedRemoteKeys.add(normalized);
208
+ items.push({ status: 'collision', stableKey: desired.stableKey, name: desired.name, reason: 'duplicate target stableKey', desiredHash });
209
+ continue;
210
+ }
211
+ if (unknownRoleKeys.some((roleKey) => desired.roles.includes(roleKey))) {
212
+ items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, reason: 'unknown target system role key', desiredHash });
213
+ continue;
214
+ }
215
+ if (matches.length === 0) {
216
+ const hinted = unmanaged.filter((record) => roleGroupHint(record) === roleGroupHint(desired));
217
+ if (hinted.length > 0) {
218
+ hinted.forEach((record) => consumedUnmanaged.add(record));
219
+ const reason = `target role-group matching category/name is missing ${input.stableKeyName}`;
220
+ blockers.push(`Cannot create "${desired.stableKey}": ${reason}.`);
221
+ items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, reason, desiredHash });
222
+ }
223
+ else {
224
+ items.push({ status: 'create', stableKey: desired.stableKey, name: desired.name, desiredHash });
225
+ }
226
+ continue;
227
+ }
228
+ consumedRemoteKeys.add(normalized);
229
+ const target = matches[0];
230
+ const diff = diffRoleGroups(desired, target.manifest);
231
+ const common = {
232
+ stableKey: desired.stableKey,
233
+ name: desired.name,
234
+ desiredHash,
235
+ currentHash: target.hash,
236
+ ...(diff.changedFields.length > 0 ? { changedFields: diff.changedFields } : {}),
237
+ ...(diff.rolesToAdd.length > 0 ? { rolesToAdd: diff.rolesToAdd } : {}),
238
+ ...(diff.rolesToRemove.length > 0 ? { rolesToRemove: diff.rolesToRemove } : {}),
239
+ };
240
+ if (desiredHash === target.hash) {
241
+ items.push({ status: 'clean', ...common });
242
+ continue;
243
+ }
244
+ const baselineHash = lockByNormalizedKey.get(normalized)?.environments[input.envName]?.remoteHash;
245
+ if (!baselineHash) {
246
+ items.push({ status: 'missing-lock', ...common, reason: `missing ${input.envName} lock baseline` });
247
+ }
248
+ else if (target.hash === baselineHash) {
249
+ items.push({ status: 'safe-update', ...common, baselineHash });
250
+ }
251
+ else if (desiredHash === baselineHash) {
252
+ items.push({ status: 'remote-changed', ...common, baselineHash, reason: 'target changed since lock baseline' });
253
+ }
254
+ else {
255
+ items.push({ status: 'conflict', ...common, baselineHash, reason: 'desired and target changed since lock baseline' });
256
+ }
257
+ }
258
+ for (const [normalized, matches] of remoteByKey) {
259
+ if (consumedRemoteKeys.has(normalized) || localByKey.has(normalized))
260
+ continue;
261
+ if (matches.length > 1) {
262
+ const stableKey = matches[0].manifest.stableKey;
263
+ blockers.push(`Duplicate target role-group stableKey "${stableKey}".`);
264
+ items.push({ status: 'collision', stableKey, name: matches[0].manifest.name, reason: 'duplicate target stableKey' });
265
+ continue;
266
+ }
267
+ const target = matches[0];
268
+ items.push({ status: 'orphan', stableKey: target.manifest.stableKey, name: target.manifest.name, currentHash: target.hash, reason: 'target-only role-group' });
269
+ }
270
+ for (const record of unmanaged) {
271
+ if (consumedUnmanaged.has(record))
272
+ continue;
273
+ items.push({ status: 'skipped', name: String(record.name || 'unknown'), reason: `missing ${input.stableKeyName}; unmanaged` });
274
+ }
275
+ const rank = {
276
+ create: 0,
277
+ 'safe-update': 1,
278
+ 'remote-changed': 2,
279
+ conflict: 3,
280
+ 'missing-lock': 4,
281
+ clean: 5,
282
+ orphan: 6,
283
+ collision: 7,
284
+ skipped: 8,
285
+ delete: 9,
286
+ };
287
+ items.sort((left, right) => rank[left.status] - rank[right.status]
288
+ || (left.stableKey || left.name).localeCompare(right.stableKey || right.name));
289
+ const counts = emptyPlanCounts();
290
+ for (const item of items)
291
+ counts[item.status] += 1;
292
+ return {
293
+ resourceType: 'role-group',
294
+ env: input.envName,
295
+ counts,
296
+ blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
297
+ items,
298
+ exclusions: ['members/users are always skipped', 'UUID and version are resolved live and never persisted'],
299
+ };
300
+ }
301
+ export async function pullRoleGroupsToWorkspace(input) {
302
+ const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Role-group');
303
+ const records = await loadRemoteRoleGroups(input.client);
304
+ const skipped = [];
305
+ const managed = [];
306
+ const normalizedKeys = new Set();
307
+ for (const record of records) {
308
+ const manifest = roleGroupManifestFromRecord(record, input.stableKeyName);
309
+ if (!manifest) {
310
+ const name = String(record.name || roleGroupIdOf(record) || 'unknown');
311
+ skipped.push({ name, targetPath: name, reason: `missing ${input.stableKeyName}; unmanaged` });
312
+ continue;
313
+ }
314
+ const normalized = normalizeStableKeyValue(manifest.stableKey);
315
+ if (normalizedKeys.has(normalized)) {
316
+ throw new Error(`Duplicate role-group stableKey "${manifest.stableKey}" on ${input.envName}; no files were changed.`);
317
+ }
318
+ normalizedKeys.add(normalized);
319
+ managed.push(manifest);
320
+ }
321
+ managed.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
322
+ const lock = readComponentLock(projectRoot);
323
+ const existingByStableKey = new Map();
324
+ for (const entry of readWorkspaceRoleGroups(input.cwd)) {
325
+ const errors = validateManifest(entry.manifest, input.stableKeyName);
326
+ if (errors.length > 0) {
327
+ throw new Error(`Cannot pull over invalid role-group manifest ${path.relative(projectRoot, entry.manifestPath)}: ${errors.join('; ')}.`);
328
+ }
329
+ const normalized = normalizeStableKeyValue(entry.manifest.stableKey || '');
330
+ if (existingByStableKey.has(normalized)) {
331
+ throw new Error(`Duplicate local role-group stableKey "${entry.manifest.stableKey}"; no files were changed.`);
332
+ }
333
+ existingByStableKey.set(normalized, entry);
334
+ }
335
+ const writes = [];
336
+ const outputPaths = new Set();
337
+ for (const manifest of managed) {
338
+ const hash = hashPortableRoleGroup(manifest);
339
+ const normalized = normalizeStableKeyValue(manifest.stableKey);
340
+ const existing = existingByStableKey.get(normalized);
341
+ if (existing && !input.force) {
342
+ const localHash = hashPortableRoleGroup(existing.manifest);
343
+ const lockEntry = Object.values(lock.roleGroups)
344
+ .find((entry) => normalizeStableKeyValue(entry.stableKey) === normalized);
345
+ if (localHash !== hash && (!lockEntry || localHash !== lockEntry.sourceHash)) {
346
+ throw new Error(`Cannot pull role-group "${manifest.stableKey}": local manifest changed since the lock baseline. Re-run with --force.`);
347
+ }
348
+ }
349
+ const filePath = roleGroupManifestPath(workspaceRoot, manifest);
350
+ if (outputPaths.has(filePath)) {
351
+ throw new Error(`Multiple role-groups resolve to workspace path "${path.relative(projectRoot, filePath)}"; no files were changed.`);
352
+ }
353
+ outputPaths.add(filePath);
354
+ writes.push({
355
+ filePath,
356
+ ...(existing && existing.manifestPath !== filePath ? { previousPath: existing.manifestPath } : {}),
357
+ manifest,
358
+ hash,
359
+ });
360
+ }
361
+ const roleGroupsRoot = path.join(workspaceRoot, ROLE_GROUPS_DIRECTORY);
362
+ let lockChanged = false;
363
+ for (const write of writes) {
364
+ writePulledManifest({
365
+ filePath: write.filePath,
366
+ manifest: write.manifest,
367
+ previousPath: write.previousPath,
368
+ root: roleGroupsRoot,
369
+ force: input.force,
370
+ });
371
+ const normalized = normalizeStableKeyValue(write.manifest.stableKey);
372
+ const previousPair = Object.entries(lock.roleGroups)
373
+ .find(([, entry]) => normalizeStableKeyValue(entry.stableKey) === normalized);
374
+ const previous = previousPair?.[1];
375
+ if (previousPair && previousPair[0] !== write.manifest.stableKey) {
376
+ delete lock.roleGroups[previousPair[0]];
377
+ lockChanged = true;
378
+ }
379
+ const previousBaseline = previous?.environments[input.envName];
380
+ const entry = {
381
+ stableKey: write.manifest.stableKey,
382
+ path: toPortableRelativePath(projectRoot, write.filePath),
383
+ sourceHash: write.hash,
384
+ environments: {
385
+ ...(previous?.environments || {}),
386
+ [input.envName]: previousBaseline?.remoteHash === write.hash
387
+ ? previousBaseline
388
+ : { remoteHash: write.hash, syncedAt: new Date().toISOString() },
389
+ },
390
+ };
391
+ if (!previous || hashStable(previous) !== hashStable(entry))
392
+ lockChanged = true;
393
+ lock.roleGroups[write.manifest.stableKey] = entry;
394
+ }
395
+ if (lockChanged)
396
+ writeComponentLock(projectRoot, lock);
397
+ return {
398
+ pulled: writes.map((write) => ({
399
+ manifest: write.manifest,
400
+ targetPath: localTargetPath(input.cwd, write.filePath),
401
+ })),
402
+ skipped,
403
+ };
404
+ }
405
+ function buildCreatePayload(manifest) {
406
+ return {
407
+ name: manifest.name,
408
+ ...(manifest.category === undefined ? {} : { category: manifest.category }),
409
+ ...(manifest.description === undefined ? {} : { desc: manifest.description }),
410
+ metadata: sanitizeUserMetadata(manifest.metadata),
411
+ roles: manifest.roles,
412
+ };
413
+ }
414
+ function buildBaseUpdatePayload(manifest, target) {
415
+ if (!Number.isInteger(target.version))
416
+ throw new Error('target role-group version is required');
417
+ return {
418
+ name: manifest.name,
419
+ ...(manifest.category === undefined && target.category === undefined ? {} : { category: manifest.category ?? null }),
420
+ ...(manifest.description === undefined && target.description === undefined && target.desc === undefined
421
+ ? {}
422
+ : { desc: manifest.description ?? '' }),
423
+ metadata: sanitizeUserMetadata(manifest.metadata),
424
+ version: target.version,
425
+ };
426
+ }
427
+ function updateEnvironmentBaseline(input) {
428
+ const { projectRoot } = requireProjectLayout(input.cwd, 'Role-group');
429
+ const lock = readComponentLock(projectRoot);
430
+ const normalized = normalizeStableKeyValue(input.stableKey);
431
+ const localMatches = readWorkspaceRoleGroups(input.cwd)
432
+ .filter(({ manifest }) => typeof manifest.stableKey === 'string'
433
+ && normalizeStableKeyValue(manifest.stableKey) === normalized);
434
+ if (localMatches.length !== 1) {
435
+ throw new Error(`Cannot update lock baseline for "${input.stableKey}": expected one local role-group manifest, found ${localMatches.length}.`);
436
+ }
437
+ const local = localMatches[0];
438
+ const pair = Object.entries(lock.roleGroups)
439
+ .find(([, entry]) => normalizeStableKeyValue(entry.stableKey) === normalized);
440
+ const lockKey = pair?.[0] || local.manifest.stableKey;
441
+ const entry = pair?.[1];
442
+ const existingBaseline = entry?.environments[input.envName];
443
+ const nextEntry = {
444
+ stableKey: local.manifest.stableKey,
445
+ path: toPortableRelativePath(projectRoot, local.manifestPath),
446
+ sourceHash: hashPortableRoleGroup(local.manifest),
447
+ environments: {
448
+ ...(entry?.environments || {}),
449
+ [input.envName]: existingBaseline?.remoteHash === input.remoteHash
450
+ ? existingBaseline
451
+ : { remoteHash: input.remoteHash, syncedAt: new Date().toISOString() },
452
+ },
453
+ };
454
+ if (entry && hashStable(entry) === hashStable(nextEntry))
455
+ return;
456
+ if (lockKey !== local.manifest.stableKey)
457
+ delete lock.roleGroups[lockKey];
458
+ lock.roleGroups[local.manifest.stableKey] = nextEntry;
459
+ writeComponentLock(projectRoot, lock);
460
+ }
461
+ function localManifestByStableKey(cwd) {
462
+ return new Map(readWorkspaceRoleGroups(cwd).map((entry) => [
463
+ normalizeStableKeyValue(entry.manifest.stableKey),
464
+ entry,
465
+ ]));
466
+ }
467
+ async function liveTargetByStableKey(client, stableKeyName, stableKey) {
468
+ const normalized = normalizeStableKeyValue(stableKey);
469
+ const records = await loadRemoteRoleGroups(client);
470
+ return records.filter((record) => {
471
+ const value = readConfiguredResourceMetadataStableKey(record, stableKeyName);
472
+ return value ? normalizeStableKeyValue(value) === normalized : false;
473
+ });
474
+ }
475
+ async function assertCreateStillSafe(client, stableKeyName, desired) {
476
+ const normalized = normalizeStableKeyValue(desired.stableKey);
477
+ const records = await loadRemoteRoleGroups(client);
478
+ for (const record of records) {
479
+ const key = readConfiguredResourceMetadataStableKey(record, stableKeyName);
480
+ if (key && normalizeStableKeyValue(key) === normalized) {
481
+ throw new Error('matching stableKey appeared after planning');
482
+ }
483
+ if (!key && roleGroupHint(record) === roleGroupHint(desired)) {
484
+ throw new Error(`target role-group matching category/name appeared without ${stableKeyName} after planning`);
485
+ }
486
+ }
487
+ }
488
+ export async function pushRoleGroups(input) {
489
+ const plan = await buildRoleGroupsPlan(input);
490
+ if (plan.blockers.length > 0) {
491
+ throw new Error(`Role-group push blocked: ${plan.blockers.join(' ')}`);
492
+ }
493
+ const contentBlockers = plan.items.filter((item) => ['missing-lock', 'remote-changed', 'conflict'].includes(item.status));
494
+ if (contentBlockers.length > 0 && !input.force) {
495
+ const statuses = [...new Set(contentBlockers.map((item) => item.status))].sort().join(', ');
496
+ throw new Error(`Role-group push blocked by ${statuses}; review the plan and re-run with --force only to adopt or overwrite reviewed content drift.`);
497
+ }
498
+ if (plan.items.some((item) => item.status === 'collision')) {
499
+ throw new Error('Role-group push blocked by stableKey collision; --force cannot bypass identity ambiguity.');
500
+ }
501
+ const localByKey = localManifestByStableKey(input.cwd);
502
+ const results = [];
503
+ for (const item of plan.items.filter((candidate) => candidate.status === 'clean')) {
504
+ if (!item.stableKey || !item.currentHash)
505
+ continue;
506
+ const local = localByKey.get(normalizeStableKeyValue(item.stableKey));
507
+ updateEnvironmentBaseline({ cwd: input.cwd, envName: input.envName, stableKey: item.stableKey, remoteHash: item.currentHash });
508
+ results.push({
509
+ stableKey: item.stableKey,
510
+ action: 'noop',
511
+ status: 'skipped',
512
+ targetPath: local ? localTargetPath(input.cwd, local.manifestPath) : item.stableKey,
513
+ reason: 'no local changes',
514
+ });
515
+ }
516
+ const writeItems = plan.items.filter((item) => item.status === 'create')
517
+ .concat(plan.items.filter((item) => ['safe-update', 'missing-lock', 'remote-changed', 'conflict'].includes(item.status)));
518
+ for (const item of writeItems) {
519
+ const stableKey = item.stableKey || '';
520
+ const local = localByKey.get(normalizeStableKeyValue(stableKey));
521
+ if (!local) {
522
+ results.push({ stableKey, action: item.status === 'create' ? 'create' : 'update', status: 'failed', targetPath: stableKey, error: 'desired manifest disappeared after planning' });
523
+ continue;
524
+ }
525
+ const targetPath = localTargetPath(input.cwd, local.manifestPath);
526
+ const liveDesiredHash = hashPortableRoleGroup(local.manifest);
527
+ if (liveDesiredHash !== item.desiredHash) {
528
+ results.push({ stableKey, action: item.status === 'create' ? 'create' : 'update', status: 'failed', targetPath, error: 'desired manifest changed after planning' });
529
+ continue;
530
+ }
531
+ if (item.status === 'create') {
532
+ try {
533
+ await assertCreateStillSafe(input.client, input.stableKeyName, local.manifest);
534
+ const created = unwrapData(await input.client.createRoleGroup(buildCreatePayload(local.manifest)));
535
+ const createdId = roleGroupIdOf(created);
536
+ if (!createdId)
537
+ throw new Error('create response did not include roleGroupId');
538
+ const verified = unwrapData(await input.client.getRoleGroup(createdId));
539
+ const verifiedManifest = roleGroupManifestFromRecord(verified, input.stableKeyName);
540
+ if (!verifiedManifest || hashPortableRoleGroup(verifiedManifest) !== liveDesiredHash) {
541
+ throw new Error('created role-group did not match desired portable hash');
542
+ }
543
+ updateEnvironmentBaseline({ cwd: input.cwd, envName: input.envName, stableKey, remoteHash: liveDesiredHash });
544
+ results.push({ stableKey, action: 'create', status: 'deployed', targetPath });
545
+ }
546
+ catch (error) {
547
+ results.push({ stableKey, action: 'create', status: 'failed', targetPath, error: error instanceof Error ? error.message : String(error) });
548
+ }
549
+ continue;
550
+ }
551
+ try {
552
+ const matches = await liveTargetByStableKey(input.client, input.stableKeyName, stableKey);
553
+ if (matches.length !== 1)
554
+ throw new Error(`expected one live target match, found ${matches.length}`);
555
+ const targetId = roleGroupIdOf(matches[0]);
556
+ if (!targetId)
557
+ throw new Error('live target did not include roleGroupId');
558
+ let target = unwrapData(await input.client.getRoleGroup(targetId));
559
+ let targetManifest = roleGroupManifestFromRecord(target, input.stableKeyName);
560
+ if (!targetManifest || hashPortableRoleGroup(targetManifest) !== item.currentHash) {
561
+ throw new Error('target changed after planning');
562
+ }
563
+ const diff = diffRoleGroups(local.manifest, targetManifest);
564
+ if (diff.changedFields.length > 0) {
565
+ await input.client.updateRoleGroup(targetId, buildBaseUpdatePayload(local.manifest, target));
566
+ target = unwrapData(await input.client.getRoleGroup(targetId));
567
+ targetManifest = roleGroupManifestFromRecord(target, input.stableKeyName);
568
+ if (!targetManifest)
569
+ throw new Error(`updated target is missing ${input.stableKeyName}`);
570
+ }
571
+ const roleDiff = diffRoleGroups(local.manifest, targetManifest);
572
+ for (const roleKey of roleDiff.rolesToRemove) {
573
+ await input.client.removeRoleGroupRole(targetId, roleKey);
574
+ }
575
+ for (const roleKey of roleDiff.rolesToAdd) {
576
+ await input.client.addRoleGroupRole(targetId, roleKey);
577
+ }
578
+ const verified = unwrapData(await input.client.getRoleGroup(targetId));
579
+ const verifiedManifest = roleGroupManifestFromRecord(verified, input.stableKeyName);
580
+ if (!verifiedManifest || hashPortableRoleGroup(verifiedManifest) !== liveDesiredHash) {
581
+ throw new Error('updated role-group did not match desired portable hash');
582
+ }
583
+ updateEnvironmentBaseline({ cwd: input.cwd, envName: input.envName, stableKey, remoteHash: liveDesiredHash });
584
+ results.push({ stableKey, action: 'update', status: 'deployed', targetPath });
585
+ }
586
+ catch (error) {
587
+ results.push({ stableKey, action: 'update', status: 'failed', targetPath, error: error instanceof Error ? error.message : String(error) });
588
+ }
589
+ }
590
+ for (const item of plan.items.filter((candidate) => candidate.status === 'orphan')) {
591
+ results.push({ stableKey: item.stableKey || item.name, action: 'orphan', status: 'skipped', targetPath: item.stableKey || item.name, reason: 'target-only orphan' });
592
+ }
593
+ for (const item of plan.items.filter((candidate) => candidate.status === 'skipped')) {
594
+ results.push({ stableKey: item.stableKey || item.name, action: 'skipped', status: 'skipped', targetPath: item.stableKey || item.name, reason: item.reason || 'unmanaged' });
595
+ }
596
+ return { plan, results };
597
+ }