windmill-cli 1.506.0 → 1.507.1

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.
@@ -0,0 +1,812 @@
1
+ import * as dntShim from "./_dnt.shims.js";
2
+ import { colors, Command, log, yamlStringify } from "./deps.js";
3
+ import { requireLogin, resolveWorkspace } from "./context.js";
4
+ import * as wmill from "./gen/services.gen.js";
5
+ import { DEFAULT_SYNC_OPTIONS, getEffectiveSettings, readConfigFile, } from "./conf.js";
6
+ import { deepEqual, selectRepository } from "./utils.js";
7
+ // Constants for git-sync fields to avoid duplication
8
+ const GIT_SYNC_FIELDS = [
9
+ "includes",
10
+ "excludes",
11
+ "extraIncludes",
12
+ "skipScripts",
13
+ "skipFlows",
14
+ "skipApps",
15
+ "skipFolders",
16
+ "skipVariables",
17
+ "skipResources",
18
+ "skipResourceTypes",
19
+ "skipSecrets",
20
+ "includeSchedules",
21
+ "includeTriggers",
22
+ "includeUsers",
23
+ "includeGroups",
24
+ "includeSettings",
25
+ "includeKey",
26
+ ];
27
+ // Helper to normalize repository path by removing $res: prefix
28
+ function normalizeRepoPath(path) {
29
+ return path.replace(/^\$res:/, "");
30
+ }
31
+ // Helper to get typed field value from SyncOptions
32
+ function getFieldValue(opts, field) {
33
+ return opts[field];
34
+ }
35
+ // Construct override key using the single format: baseUrl:workspaceId:repo
36
+ function constructOverrideKey(baseUrl, workspaceId, repoPath, workspaceLevel = false) {
37
+ // Validate that components don't contain colons to avoid key collisions
38
+ if (baseUrl.includes(':') && !baseUrl.startsWith('http')) {
39
+ throw new Error(`Invalid baseUrl contains colon: ${baseUrl}`);
40
+ }
41
+ if (workspaceId.includes(':')) {
42
+ throw new Error(`Invalid workspaceId contains colon: ${workspaceId}`);
43
+ }
44
+ if (repoPath.includes(':') && !repoPath.startsWith('$res:')) {
45
+ throw new Error(`Invalid repoPath contains colon: ${repoPath}`);
46
+ }
47
+ if (workspaceLevel) {
48
+ return `${baseUrl}:${workspaceId}:*`;
49
+ }
50
+ return `${baseUrl}:${workspaceId}:${repoPath}`;
51
+ }
52
+ // Helper to compare string arrays (used for includes/excludes/extraIncludes)
53
+ function arraysEqual(arr1, arr2) {
54
+ if (arr1.length !== arr2.length) {
55
+ return false;
56
+ }
57
+ const sorted1 = [...arr1].sort();
58
+ const sorted2 = [...arr2].sort();
59
+ return sorted1.every((item, index) => item === sorted2[index]);
60
+ }
61
+ // Normalize SyncOptions for semantic comparison - treat undefined arrays as empty arrays
62
+ function normalizeSyncOptions(opts) {
63
+ return {
64
+ ...opts,
65
+ includes: opts.includes || [],
66
+ excludes: opts.excludes || [],
67
+ extraIncludes: opts.extraIncludes || [],
68
+ };
69
+ }
70
+ // Extract only git-sync relevant fields for comparison
71
+ function extractGitSyncFields(opts) {
72
+ return {
73
+ includes: opts.includes || [],
74
+ excludes: opts.excludes || [],
75
+ extraIncludes: opts.extraIncludes || [],
76
+ skipScripts: opts.skipScripts,
77
+ skipFlows: opts.skipFlows,
78
+ skipApps: opts.skipApps,
79
+ skipFolders: opts.skipFolders,
80
+ skipVariables: opts.skipVariables,
81
+ skipResources: opts.skipResources,
82
+ skipResourceTypes: opts.skipResourceTypes,
83
+ skipSecrets: opts.skipSecrets,
84
+ includeSchedules: opts.includeSchedules,
85
+ includeTriggers: opts.includeTriggers,
86
+ includeUsers: opts.includeUsers,
87
+ includeGroups: opts.includeGroups,
88
+ includeSettings: opts.includeSettings,
89
+ includeKey: opts.includeKey,
90
+ };
91
+ }
92
+ // Helper function to determine current settings based on write mode and conflicts
93
+ function getCurrentSettings(localConfig, writeMode, overrideKey) {
94
+ if (writeMode === "override" &&
95
+ overrideKey &&
96
+ localConfig.overrides?.[overrideKey]) {
97
+ return {
98
+ ...DEFAULT_SYNC_OPTIONS,
99
+ ...localConfig,
100
+ ...localConfig.overrides[overrideKey],
101
+ };
102
+ }
103
+ else {
104
+ // For "replace" mode, exclude overrides since they're never accessed
105
+ const { overrides, ...configWithoutOverrides } = localConfig;
106
+ return { ...DEFAULT_SYNC_OPTIONS, ...configWithoutOverrides };
107
+ }
108
+ }
109
+ // Convert backend include_type array to SyncOptions boolean flags
110
+ function includeTypeToSyncOptions(includeTypes) {
111
+ return {
112
+ skipScripts: !includeTypes.includes("script"),
113
+ skipFlows: !includeTypes.includes("flow"),
114
+ skipApps: !includeTypes.includes("app"),
115
+ skipFolders: !includeTypes.includes("folder"),
116
+ skipVariables: !includeTypes.includes("variable"),
117
+ skipResources: !includeTypes.includes("resource"),
118
+ skipResourceTypes: !includeTypes.includes("resourcetype"),
119
+ skipSecrets: !includeTypes.includes("secret"),
120
+ includeSchedules: includeTypes.includes("schedule"),
121
+ includeTriggers: includeTypes.includes("trigger"),
122
+ includeUsers: includeTypes.includes("user"),
123
+ includeGroups: includeTypes.includes("group"),
124
+ includeSettings: includeTypes.includes("settings"),
125
+ includeKey: includeTypes.includes("key"),
126
+ };
127
+ }
128
+ // Convert SyncOptions boolean flags to backend include_type array
129
+ function syncOptionsToIncludeType(opts) {
130
+ const includeTypes = [];
131
+ if (!opts.skipScripts)
132
+ includeTypes.push("script");
133
+ if (!opts.skipFlows)
134
+ includeTypes.push("flow");
135
+ if (!opts.skipApps)
136
+ includeTypes.push("app");
137
+ if (!opts.skipFolders)
138
+ includeTypes.push("folder");
139
+ if (!opts.skipVariables)
140
+ includeTypes.push("variable");
141
+ if (!opts.skipResources)
142
+ includeTypes.push("resource");
143
+ if (!opts.skipResourceTypes)
144
+ includeTypes.push("resourcetype");
145
+ if (!opts.skipSecrets)
146
+ includeTypes.push("secret");
147
+ if (opts.includeSchedules)
148
+ includeTypes.push("schedule");
149
+ if (opts.includeTriggers)
150
+ includeTypes.push("trigger");
151
+ if (opts.includeUsers)
152
+ includeTypes.push("user");
153
+ if (opts.includeGroups)
154
+ includeTypes.push("group");
155
+ if (opts.includeSettings)
156
+ includeTypes.push("settings");
157
+ if (opts.includeKey)
158
+ includeTypes.push("key");
159
+ return includeTypes;
160
+ }
161
+ // Convert SyncOptions to backend format used by both Windmill backend and UI
162
+ function syncOptionsToBackendFormat(opts) {
163
+ return {
164
+ include_path: opts.includes || [],
165
+ exclude_path: opts.excludes || [],
166
+ extra_include_path: opts.extraIncludes || [],
167
+ include_type: syncOptionsToIncludeType(opts),
168
+ };
169
+ }
170
+ // Select repository interactively if multiple exist
171
+ // Generate structured diff showing field changes
172
+ function generateStructuredDiff(current, backend) {
173
+ const diff = {};
174
+ // Get all unique keys from both objects
175
+ const allKeys = new Set([...Object.keys(current), ...Object.keys(backend)]);
176
+ for (const key of allKeys) {
177
+ const currentValue = current[key];
178
+ const backendValue = backend[key];
179
+ if (!deepEqual(currentValue, backendValue)) {
180
+ diff[key] = {
181
+ from: currentValue,
182
+ to: backendValue,
183
+ };
184
+ }
185
+ }
186
+ return diff;
187
+ }
188
+ // Helper to generate changes between two normalized SyncOptions objects
189
+ function generateChanges(normalizedCurrent, normalizedNew) {
190
+ const changes = {};
191
+ for (const field of GIT_SYNC_FIELDS) {
192
+ const currentValue = getFieldValue(normalizedCurrent, field);
193
+ const newValue = getFieldValue(normalizedNew, field);
194
+ if (!deepEqual(currentValue, newValue)) {
195
+ changes[field] = {
196
+ from: currentValue,
197
+ to: newValue,
198
+ };
199
+ }
200
+ }
201
+ return changes;
202
+ }
203
+ // Helper to display changes in human-readable format
204
+ function displayChanges(changes) {
205
+ for (const [field, change] of Object.entries(changes)) {
206
+ if (Array.isArray(change.from) ||
207
+ Array.isArray(change.to)) {
208
+ console.log(colors.yellow(` ${field}:`));
209
+ console.log(colors.red(` - ${JSON.stringify(change.from)}`));
210
+ console.log(colors.green(` + ${JSON.stringify(change.to)}`));
211
+ }
212
+ else {
213
+ console.log(colors.yellow(` ${field}: `) +
214
+ colors.red(`${change.from}`) +
215
+ " → " +
216
+ colors.green(`${change.to}`));
217
+ }
218
+ }
219
+ }
220
+ async function pullGitSyncSettings(opts) {
221
+ const workspace = await resolveWorkspace(opts);
222
+ await requireLogin(opts);
223
+ try {
224
+ // Parse and validate --with-backend-settings if provided
225
+ let settings;
226
+ if (opts.withBackendSettings) {
227
+ try {
228
+ const parsedSettings = JSON.parse(opts.withBackendSettings);
229
+ // Validate the structure matches expected test format (raw settings object)
230
+ if (!parsedSettings.include_path ||
231
+ !Array.isArray(parsedSettings.include_path)) {
232
+ throw new Error("Invalid settings format. Expected include_path array");
233
+ }
234
+ if (!parsedSettings.include_type ||
235
+ !Array.isArray(parsedSettings.include_type)) {
236
+ throw new Error("Invalid settings format. Expected include_type array");
237
+ }
238
+ // Create mock backend response with single repository using provided settings
239
+ const mockRepositoryPath = opts.repository || "u/mock/repo";
240
+ settings = {
241
+ git_sync: {
242
+ repositories: [{
243
+ git_repo_resource_path: mockRepositoryPath,
244
+ settings: {
245
+ include_path: parsedSettings.include_path,
246
+ include_type: parsedSettings.include_type,
247
+ exclude_path: parsedSettings.exclude_path || [],
248
+ extra_include_path: parsedSettings.extra_include_path || [],
249
+ },
250
+ }],
251
+ },
252
+ };
253
+ }
254
+ catch (parseError) {
255
+ const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
256
+ if (opts.jsonOutput) {
257
+ console.log(JSON.stringify({
258
+ success: false,
259
+ error: `Failed to parse --with-backend-settings JSON: ${errorMessage}`,
260
+ }));
261
+ }
262
+ else {
263
+ log.error(colors.red(`Failed to parse --with-backend-settings JSON: ${errorMessage}`));
264
+ }
265
+ return;
266
+ }
267
+ }
268
+ else {
269
+ // Fetch workspace settings to get git-sync configuration
270
+ try {
271
+ settings = await wmill.getSettings({
272
+ workspace: workspace.workspaceId,
273
+ });
274
+ }
275
+ catch (apiError) {
276
+ const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
277
+ if (opts.jsonOutput) {
278
+ console.log(JSON.stringify({
279
+ success: false,
280
+ error: `Failed to fetch workspace settings: ${errorMessage}`,
281
+ }));
282
+ }
283
+ else {
284
+ log.error(colors.red(`Failed to fetch workspace settings: ${errorMessage}`));
285
+ }
286
+ return;
287
+ }
288
+ }
289
+ if (!settings.git_sync?.repositories ||
290
+ settings.git_sync.repositories.length === 0) {
291
+ if (opts.jsonOutput) {
292
+ console.log(JSON.stringify({
293
+ success: false,
294
+ error: "No git-sync repositories configured",
295
+ }));
296
+ }
297
+ else {
298
+ log.error(colors.red("No git-sync repositories configured in workspace"));
299
+ }
300
+ return;
301
+ }
302
+ // Find the repository to work with
303
+ let selectedRepo;
304
+ if (opts.repository) {
305
+ const found = settings.git_sync.repositories.find((r) => r.git_repo_resource_path === opts.repository ||
306
+ r.git_repo_resource_path === `$res:${opts.repository}`);
307
+ if (!found) {
308
+ throw new Error(`Repository ${opts.repository} not found`);
309
+ }
310
+ selectedRepo = found;
311
+ }
312
+ else {
313
+ selectedRepo = await selectRepository(settings.git_sync.repositories);
314
+ }
315
+ // Convert backend settings to SyncOptions format
316
+ const backendSyncOptions = {
317
+ includes: selectedRepo.settings.include_path || [],
318
+ excludes: selectedRepo.settings.exclude_path || [],
319
+ extraIncludes: selectedRepo.settings.extra_include_path || [],
320
+ ...includeTypeToSyncOptions(selectedRepo.settings.include_type || []),
321
+ };
322
+ // Check if wmill.yaml exists - require it for git-sync settings commands
323
+ try {
324
+ await dntShim.Deno.stat("wmill.yaml");
325
+ }
326
+ catch (error) {
327
+ log.error(colors.red("No wmill.yaml file found. Please run 'wmill init' first to create the configuration file."));
328
+ dntShim.Deno.exit(1);
329
+ }
330
+ // Read current local configuration
331
+ const localConfig = await readConfigFile();
332
+ // Determine where to write the settings for diff display
333
+ let overrideKey;
334
+ let writeMode = "replace";
335
+ // For diff mode, determine what the write mode would be without interactive prompts
336
+ if (opts.default) {
337
+ writeMode = "replace";
338
+ }
339
+ else if (opts.replace) {
340
+ writeMode = "replace";
341
+ }
342
+ else if (opts.override || opts.workspaceLevel) {
343
+ writeMode = "override";
344
+ if (opts.workspaceLevel) {
345
+ overrideKey = constructOverrideKey(workspace.remote, workspace.workspaceId, "", true);
346
+ }
347
+ else {
348
+ const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
349
+ overrideKey = constructOverrideKey(workspace.remote, workspace.workspaceId, repoPath);
350
+ }
351
+ }
352
+ else {
353
+ // Default behavior for existing files with no explicit flags
354
+ // Use same logic as diff to determine if there's a real conflict
355
+ const currentSettings = getCurrentSettings(localConfig, "replace", // Check against replace mode
356
+ undefined);
357
+ const gitSyncBackend = extractGitSyncFields(normalizeSyncOptions(backendSyncOptions));
358
+ const gitSyncCurrent = extractGitSyncFields(normalizeSyncOptions(currentSettings));
359
+ const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent);
360
+ if (hasConflict) {
361
+ // For diff mode, show what override would look like
362
+ writeMode = "override";
363
+ const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
364
+ overrideKey = constructOverrideKey(workspace.remote, workspace.workspaceId, repoPath);
365
+ }
366
+ else {
367
+ writeMode = "replace";
368
+ }
369
+ }
370
+ if (opts.diff) {
371
+ // Show differences between local and backend
372
+ const currentSettings = getCurrentSettings(localConfig, writeMode, overrideKey);
373
+ const normalizedCurrent = normalizeSyncOptions(currentSettings);
374
+ const normalizedBackend = normalizeSyncOptions(backendSyncOptions);
375
+ const gitSyncCurrent = extractGitSyncFields(normalizedCurrent);
376
+ const gitSyncBackend = extractGitSyncFields(normalizedBackend);
377
+ const hasChanges = !deepEqual(gitSyncBackend, gitSyncCurrent);
378
+ if (opts.jsonOutput) {
379
+ const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
380
+ // Generate structured diff using the same normalized objects
381
+ const structuredDiff = hasChanges
382
+ ? generateStructuredDiff(gitSyncCurrent, gitSyncBackend)
383
+ : {};
384
+ console.log(JSON.stringify({
385
+ success: true,
386
+ hasChanges,
387
+ local: syncOptionsToBackendFormat(normalizedCurrent),
388
+ backend: syncOptionsToBackendFormat(normalizedBackend),
389
+ repository: selectedRepo.git_repo_resource_path,
390
+ diff: structuredDiff,
391
+ }));
392
+ }
393
+ else {
394
+ if (hasChanges) {
395
+ log.info("Changes that would be made:");
396
+ const changes = generateChanges(normalizedCurrent, normalizedBackend);
397
+ if (Object.keys(changes).length === 0) {
398
+ log.info(colors.green("No differences found"));
399
+ }
400
+ else {
401
+ displayChanges(changes);
402
+ }
403
+ }
404
+ else {
405
+ log.info(colors.green("No differences found"));
406
+ }
407
+ }
408
+ return;
409
+ }
410
+ // For non-diff mode, handle interactive logic if needed
411
+ // Only show interactive prompts for existing files with conflicts
412
+ if (!opts.diff &&
413
+ !opts.default &&
414
+ !opts.replace &&
415
+ !opts.override &&
416
+ !opts.workspaceLevel) {
417
+ // Use the same logic as diff to determine current settings
418
+ const currentSettings = getCurrentSettings(localConfig, writeMode, overrideKey);
419
+ const gitSyncBackend = extractGitSyncFields(normalizeSyncOptions(backendSyncOptions));
420
+ const gitSyncCurrent = extractGitSyncFields(normalizeSyncOptions(currentSettings));
421
+ const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent);
422
+ if (hasConflict && dntShim.Deno.stdin.isTerminal()) {
423
+ // Interactive mode - ask user
424
+ const { Select } = await import("./deps.js");
425
+ const choice = await Select.prompt({
426
+ message: "Settings conflict detected. How would you like to proceed?",
427
+ options: [
428
+ {
429
+ name: "Replace existing settings",
430
+ value: "replace",
431
+ },
432
+ {
433
+ name: "Add repository-specific override",
434
+ value: "override",
435
+ },
436
+ { name: "Cancel", value: "cancel" },
437
+ ],
438
+ });
439
+ if (choice === "cancel") {
440
+ log.info("Operation cancelled");
441
+ return;
442
+ }
443
+ writeMode = choice;
444
+ if (writeMode === "override") {
445
+ const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
446
+ overrideKey = constructOverrideKey(workspace.remote, workspace.workspaceId, repoPath);
447
+ }
448
+ }
449
+ else if (hasConflict) {
450
+ // Non-interactive mode with conflicts - show message and exit
451
+ if (opts.jsonOutput) {
452
+ console.log(JSON.stringify({
453
+ success: false,
454
+ error: "Settings conflict detected. Use --replace or --override flags to resolve.",
455
+ hasConflict: true,
456
+ }));
457
+ }
458
+ else {
459
+ log.error(colors.red("Settings conflict detected."));
460
+ log.info("Use --replace to overwrite existing settings or --override to add repository-specific override.");
461
+ }
462
+ return;
463
+ }
464
+ }
465
+ // Check if there are actually any changes before writing
466
+ const currentSettingsForCheck = getCurrentSettings(localConfig, writeMode, overrideKey);
467
+ const gitSyncBackend = extractGitSyncFields(normalizeSyncOptions(backendSyncOptions));
468
+ const gitSyncCurrent = extractGitSyncFields(normalizeSyncOptions(currentSettingsForCheck));
469
+ const hasActualChanges = !deepEqual(gitSyncBackend, gitSyncCurrent);
470
+ if (!hasActualChanges) {
471
+ if (opts.jsonOutput) {
472
+ console.log(JSON.stringify({
473
+ success: true,
474
+ message: "No changes needed",
475
+ repository: selectedRepo.git_repo_resource_path,
476
+ }));
477
+ }
478
+ else {
479
+ log.info(colors.green("No changes needed - settings are already up to date"));
480
+ }
481
+ return;
482
+ }
483
+ // Apply the settings based on write mode
484
+ let updatedConfig;
485
+ if (writeMode === "replace") {
486
+ // Preserve existing local config and update only git-sync fields
487
+ updatedConfig = { ...localConfig };
488
+ // Remove overrides since we're in replace mode
489
+ delete updatedConfig.overrides;
490
+ // Update with backend git-sync settings
491
+ Object.assign(updatedConfig, backendSyncOptions);
492
+ }
493
+ else if (writeMode === "override" && overrideKey) {
494
+ // Add repository-specific override
495
+ updatedConfig = { ...localConfig };
496
+ if (!updatedConfig.overrides) {
497
+ updatedConfig.overrides = {};
498
+ }
499
+ // Only store the delta - settings that differ from current effective settings
500
+ const currentEffective = getCurrentSettings(localConfig, "replace");
501
+ const deltaSettings = {};
502
+ // Compare each setting and only include differences
503
+ for (const [key, value] of Object.entries(backendSyncOptions)) {
504
+ if (key === "overrides")
505
+ continue; // Skip overrides field
506
+ const currentValue = currentEffective[key];
507
+ const newValue = value;
508
+ // Compare arrays by content, primitives by value
509
+ const isDifferent = Array.isArray(currentValue) && Array.isArray(newValue)
510
+ ? !arraysEqual(currentValue, newValue)
511
+ : currentValue !== newValue;
512
+ if (isDifferent) {
513
+ deltaSettings[key] = newValue;
514
+ }
515
+ }
516
+ updatedConfig.overrides[overrideKey] = deltaSettings;
517
+ }
518
+ else {
519
+ // Replace top-level settings
520
+ updatedConfig = { ...localConfig };
521
+ // Copy all backend settings to top level, excluding overrides
522
+ const { overrides, ...topLevelSettings } = backendSyncOptions;
523
+ Object.assign(updatedConfig, topLevelSettings);
524
+ }
525
+ // Write updated configuration
526
+ await dntShim.Deno.writeTextFile("wmill.yaml", yamlStringify(updatedConfig));
527
+ if (opts.jsonOutput) {
528
+ console.log(JSON.stringify({
529
+ success: true,
530
+ message: `Git-sync settings pulled successfully`,
531
+ repository: selectedRepo.git_repo_resource_path,
532
+ overrideKey,
533
+ }));
534
+ }
535
+ else {
536
+ log.info(colors.green(`Git-sync settings pulled successfully from ${selectedRepo.git_repo_resource_path}`));
537
+ if (writeMode === "override" && overrideKey) {
538
+ log.info(colors.gray(`Settings written to override key: ${overrideKey}`));
539
+ }
540
+ else if (writeMode === "replace") {
541
+ log.info(colors.gray(`Settings written as simple configuration`));
542
+ }
543
+ else {
544
+ log.info(colors.gray(`Settings written to top-level defaults`));
545
+ }
546
+ }
547
+ }
548
+ catch (error) {
549
+ const errorMessage = error instanceof Error ? error.message : String(error);
550
+ if (opts.jsonOutput) {
551
+ console.log(JSON.stringify({ success: false, error: errorMessage }));
552
+ }
553
+ else {
554
+ log.error(colors.red(`Failed to pull git-sync settings: ${errorMessage}`));
555
+ }
556
+ }
557
+ }
558
+ async function pushGitSyncSettings(opts) {
559
+ const workspace = await resolveWorkspace(opts);
560
+ await requireLogin(opts);
561
+ try {
562
+ // Check if wmill.yaml exists - require it for git-sync settings commands
563
+ try {
564
+ await dntShim.Deno.stat("wmill.yaml");
565
+ }
566
+ catch (error) {
567
+ log.error(colors.red("No wmill.yaml file found. Please run 'wmill init' first to create the configuration file."));
568
+ dntShim.Deno.exit(1);
569
+ }
570
+ // Read local configuration
571
+ const localConfig = await readConfigFile();
572
+ // Parse and validate --with-backend-settings if provided, otherwise fetch from backend
573
+ let settings;
574
+ if (opts.withBackendSettings) {
575
+ try {
576
+ const parsedSettings = JSON.parse(opts.withBackendSettings);
577
+ // Validate the structure matches expected test format (raw settings object)
578
+ if (!parsedSettings.include_path ||
579
+ !Array.isArray(parsedSettings.include_path)) {
580
+ throw new Error("Invalid settings format. Expected include_path array");
581
+ }
582
+ if (!parsedSettings.include_type ||
583
+ !Array.isArray(parsedSettings.include_type)) {
584
+ throw new Error("Invalid settings format. Expected include_type array");
585
+ }
586
+ // Create mock backend response with single repository using provided settings
587
+ const mockRepositoryPath = opts.repository || "u/mock/repo";
588
+ settings = {
589
+ git_sync: {
590
+ repositories: [{
591
+ git_repo_resource_path: mockRepositoryPath,
592
+ settings: {
593
+ include_path: parsedSettings.include_path,
594
+ include_type: parsedSettings.include_type,
595
+ exclude_path: parsedSettings.exclude_path || [],
596
+ extra_include_path: parsedSettings.extra_include_path || [],
597
+ },
598
+ }],
599
+ },
600
+ };
601
+ }
602
+ catch (parseError) {
603
+ const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
604
+ if (opts.jsonOutput) {
605
+ console.log(JSON.stringify({
606
+ success: false,
607
+ error: `Failed to parse --with-backend-settings JSON: ${errorMessage}`,
608
+ }));
609
+ }
610
+ else {
611
+ log.error(colors.red(`Failed to parse --with-backend-settings JSON: ${errorMessage}`));
612
+ }
613
+ return;
614
+ }
615
+ }
616
+ else {
617
+ // Fetch current backend settings
618
+ try {
619
+ settings = await wmill.getSettings({
620
+ workspace: workspace.workspaceId,
621
+ });
622
+ }
623
+ catch (apiError) {
624
+ const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
625
+ if (opts.jsonOutput) {
626
+ console.log(JSON.stringify({
627
+ success: false,
628
+ error: `Failed to fetch workspace settings: ${errorMessage}`,
629
+ }));
630
+ }
631
+ else {
632
+ log.error(colors.red(`Failed to fetch workspace settings: ${errorMessage}`));
633
+ }
634
+ return;
635
+ }
636
+ }
637
+ if (!settings.git_sync?.repositories ||
638
+ settings.git_sync.repositories.length === 0) {
639
+ if (opts.jsonOutput) {
640
+ console.log(JSON.stringify({
641
+ success: false,
642
+ error: "No git-sync repositories configured",
643
+ }));
644
+ }
645
+ else {
646
+ log.error(colors.red("No git-sync repositories configured in workspace"));
647
+ }
648
+ return;
649
+ }
650
+ // Find the repository to work with
651
+ let selectedRepo;
652
+ if (opts.repository) {
653
+ const found = settings.git_sync.repositories.find((r) => r.git_repo_resource_path === opts.repository ||
654
+ r.git_repo_resource_path === `$res:${opts.repository}`);
655
+ if (!found) {
656
+ throw new Error(`Repository ${opts.repository} not found`);
657
+ }
658
+ selectedRepo = found;
659
+ }
660
+ else {
661
+ selectedRepo = await selectRepository(settings.git_sync.repositories);
662
+ }
663
+ // Get effective settings for this workspace/repo
664
+ const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
665
+ const effectiveSettings = getEffectiveSettings(localConfig, workspace.remote, workspace.workspaceId, repoPath);
666
+ // Convert to backend format
667
+ const backendFormat = {
668
+ include_path: effectiveSettings.includes || [],
669
+ include_type: syncOptionsToIncludeType(effectiveSettings),
670
+ exclude_path: effectiveSettings.excludes || [],
671
+ extra_include_path: effectiveSettings.extraIncludes || [],
672
+ };
673
+ if (opts.diff) {
674
+ // Show what would be pushed
675
+ const currentBackend = selectedRepo.settings;
676
+ // Convert current backend settings to SyncOptions for user-friendly display
677
+ const currentSyncOptions = {
678
+ includes: currentBackend.include_path || [],
679
+ excludes: currentBackend.exclude_path || [],
680
+ extraIncludes: currentBackend.extra_include_path || [],
681
+ ...includeTypeToSyncOptions(currentBackend.include_type || []),
682
+ };
683
+ const normalizedCurrent = normalizeSyncOptions(currentSyncOptions);
684
+ const normalizedEffective = normalizeSyncOptions(effectiveSettings);
685
+ const gitSyncCurrent = extractGitSyncFields(normalizedCurrent);
686
+ const gitSyncEffective = extractGitSyncFields(normalizedEffective);
687
+ const hasChanges = !deepEqual(gitSyncEffective, gitSyncCurrent);
688
+ if (opts.jsonOutput) {
689
+ // Generate structured diff using the same normalized objects
690
+ const structuredDiff = hasChanges
691
+ ? generateStructuredDiff(gitSyncCurrent, gitSyncEffective)
692
+ : {};
693
+ console.log(JSON.stringify({
694
+ success: true,
695
+ hasChanges,
696
+ local: syncOptionsToBackendFormat(normalizedEffective),
697
+ backend: syncOptionsToBackendFormat(normalizedCurrent),
698
+ repository: selectedRepo.git_repo_resource_path,
699
+ diff: structuredDiff,
700
+ }));
701
+ }
702
+ else {
703
+ if (hasChanges) {
704
+ log.info("Changes that would be pushed:");
705
+ const changes = generateChanges(normalizedCurrent, normalizedEffective);
706
+ if (Object.keys(changes).length === 0) {
707
+ log.info(colors.green("No changes to push"));
708
+ }
709
+ else {
710
+ displayChanges(changes);
711
+ }
712
+ }
713
+ else {
714
+ log.info(colors.green("No changes to push"));
715
+ }
716
+ }
717
+ return;
718
+ }
719
+ if (opts.withBackendSettings) {
720
+ // Skip backend update when using simulated settings
721
+ if (opts.jsonOutput) {
722
+ console.log(JSON.stringify({
723
+ success: true,
724
+ message: `Git-sync settings push simulated (--with-backend-settings used)`,
725
+ repository: selectedRepo.git_repo_resource_path,
726
+ simulated: true,
727
+ }));
728
+ }
729
+ else {
730
+ log.info(colors.green(`Git-sync settings push simulated for ${selectedRepo.git_repo_resource_path} (--with-backend-settings used)`));
731
+ }
732
+ }
733
+ else {
734
+ // Update the specific repository settings
735
+ const updatedRepos = settings.git_sync.repositories.map((repo) => {
736
+ if (repo.git_repo_resource_path ===
737
+ selectedRepo.git_repo_resource_path) {
738
+ return {
739
+ ...repo,
740
+ settings: backendFormat,
741
+ };
742
+ }
743
+ return repo;
744
+ });
745
+ // Push updated settings to backend
746
+ try {
747
+ await wmill.editWorkspaceGitSyncConfig({
748
+ workspace: workspace.workspaceId,
749
+ requestBody: {
750
+ git_sync_settings: {
751
+ repositories: updatedRepos,
752
+ },
753
+ },
754
+ });
755
+ }
756
+ catch (apiError) {
757
+ const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
758
+ if (opts.jsonOutput) {
759
+ console.log(JSON.stringify({
760
+ success: false,
761
+ error: `Failed to update workspace git-sync config: ${errorMessage}`,
762
+ }));
763
+ }
764
+ else {
765
+ log.error(colors.red(`Failed to update workspace git-sync config: ${errorMessage}`));
766
+ }
767
+ return;
768
+ }
769
+ if (opts.jsonOutput) {
770
+ console.log(JSON.stringify({
771
+ success: true,
772
+ message: `Git-sync settings pushed successfully`,
773
+ repository: selectedRepo.git_repo_resource_path,
774
+ }));
775
+ }
776
+ else {
777
+ log.info(colors.green(`Git-sync settings pushed successfully to ${selectedRepo.git_repo_resource_path}`));
778
+ }
779
+ }
780
+ }
781
+ catch (error) {
782
+ const errorMessage = error instanceof Error ? error.message : String(error);
783
+ if (opts.jsonOutput) {
784
+ console.log(JSON.stringify({ success: false, error: errorMessage }));
785
+ }
786
+ else {
787
+ log.error(colors.red(`Failed to push git-sync settings: ${errorMessage}`));
788
+ }
789
+ }
790
+ }
791
+ const command = new Command()
792
+ .description("Manage git-sync settings between local wmill.yaml and Windmill backend")
793
+ .command("pull")
794
+ .description("Pull git-sync settings from Windmill backend to local wmill.yaml")
795
+ .option("--repository <repo:string>", "Specify repository path (e.g., u/user/repo)")
796
+ .option("--workspace-level", "Write settings to workspace:* override instead of workspace:repo")
797
+ .option("--default", "Write settings to top-level defaults instead of overrides")
798
+ .option("--replace", "Replace existing settings (non-interactive mode)")
799
+ .option("--override", "Add repository-specific override (non-interactive mode)")
800
+ .option("--diff", "Show differences without applying changes")
801
+ .option("--json-output", "Output in JSON format")
802
+ .option("--with-backend-settings <json:string>", "Use provided JSON settings instead of querying backend (for testing)")
803
+ .action(pullGitSyncSettings)
804
+ .command("push")
805
+ .description("Push git-sync settings from local wmill.yaml to Windmill backend")
806
+ .option("--repository <repo:string>", "Specify repository path (e.g., u/user/repo)")
807
+ .option("--diff", "Show what would be pushed without applying changes")
808
+ .option("--json-output", "Output in JSON format")
809
+ .option("--with-backend-settings <json:string>", "Use provided JSON settings instead of querying backend (for testing)")
810
+ .action(pushGitSyncSettings);
811
+ export { pullGitSyncSettings, pushGitSyncSettings };
812
+ export default command;