@memberjunction/metadata-sync 5.40.2 → 5.42.0

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,74 @@
1
+ /**
2
+ * Pluggable `mj sync` commands (plan §3/§5).
3
+ *
4
+ * These classes extend {@link BaseCLIPlugin} so they inherit `--format`,
5
+ * `--verbose`, `--no-banner`, the runtime advisory, and uniform result emission.
6
+ * They live behind the `@memberjunction/metadata-sync/plugins` subpath — NOT the
7
+ * package's main entry — so that loading them for oclif manifest generation or
8
+ * `mj usage` enumeration does NOT pull in the heavy sync engine. The engine is
9
+ * dynamic-imported inside `Execute()`, exactly as the original commands did, so
10
+ * the per-command lazy-loading contract is preserved.
11
+ *
12
+ * Stdio discipline (plan D2): no `ora`/`console` here. Progress goes through
13
+ * `this.Host.*`; rich human text is built with the engine's `FormattingService`
14
+ * and handed to `this.Host.Log` (which suppresses/redirects it in JSON mode).
15
+ */
16
+ import { type Interfaces } from '@oclif/core';
17
+ import { BaseCLIPlugin, type MJCLIResult, type PluginUsage } from '@memberjunction/cli-core';
18
+ export declare class SyncPushPlugin extends BaseCLIPlugin {
19
+ static description: string;
20
+ static examples: string[];
21
+ static flags: {
22
+ dir: Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
23
+ 'dry-run': Interfaces.BooleanFlag<boolean>;
24
+ ci: Interfaces.BooleanFlag<boolean>;
25
+ 'no-validate': Interfaces.BooleanFlag<boolean>;
26
+ 'delete-db-only': Interfaces.BooleanFlag<boolean>;
27
+ 'parallel-batch-size': Interfaces.OptionFlag<number, Interfaces.CustomOptions>;
28
+ include: Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
29
+ exclude: Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
30
+ incremental: Interfaces.BooleanFlag<boolean>;
31
+ 'change-detail': Interfaces.BooleanFlag<boolean>;
32
+ };
33
+ static Usage: PluginUsage;
34
+ protected Execute(): Promise<MJCLIResult>;
35
+ /** Reproduces the original rich text summary (recap, change-detail file, box, status). */
36
+ private renderPushTextSummary;
37
+ private fail;
38
+ private cancelled;
39
+ /**
40
+ * Reset singletons + close the DB pool so the process can exit cleanly, then
41
+ * force-exit. Without this the pg.Pool/mssql pool — and any embedding ONNX
42
+ * worker threads — keep the event loop alive and the CLI hangs after a push.
43
+ */
44
+ protected Cleanup(result: MJCLIResult): Promise<void>;
45
+ }
46
+ export declare class SyncPullPlugin extends BaseCLIPlugin {
47
+ static description: string;
48
+ static examples: string[];
49
+ static flags: {
50
+ entity: Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
51
+ filter: Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
52
+ 'dry-run': Interfaces.BooleanFlag<boolean>;
53
+ 'multi-file': Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
54
+ 'no-validate': Interfaces.BooleanFlag<boolean>;
55
+ 'update-existing': Interfaces.BooleanFlag<boolean>;
56
+ 'create-new': Interfaces.BooleanFlag<boolean>;
57
+ 'backup-before-update': Interfaces.BooleanFlag<boolean>;
58
+ 'merge-strategy': Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
59
+ 'backup-directory': Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
60
+ 'preserve-fields': Interfaces.OptionFlag<string[], Interfaces.CustomOptions>;
61
+ 'exclude-fields': Interfaces.OptionFlag<string[], Interfaces.CustomOptions>;
62
+ 'target-dir': Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
63
+ incremental: Interfaces.BooleanFlag<boolean>;
64
+ since: Interfaces.OptionFlag<string, Interfaces.CustomOptions>;
65
+ };
66
+ static Usage: PluginUsage;
67
+ protected Execute(): Promise<MJCLIResult>;
68
+ private findEntityDirectories;
69
+ private findAllEntityDirectoriesRecursive;
70
+ protected Cleanup(result: MJCLIResult): Promise<void>;
71
+ }
72
+ /** Tree-shaking / dynamic-load anchor — referenced by the CLI plugin loader. */
73
+ export declare function LoadMetadataSyncPlugins(): void;
74
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAGrD,OAAO,EAAE,aAAa,EAAE,KAAK,WAAW,EAAyB,KAAK,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAqCpH,qBACa,cAAe,SAAQ,aAAa;IAC/C,MAAM,CAAC,WAAW,SAA6C;IAE/D,MAAM,CAAC,QAAQ,WAKb;IAEF,MAAM,CAAC,KAAK;;;;;;;;;;;MAyBV;IAEF,MAAM,CAAC,KAAK,EAAE,WAAW,CAgBvB;cAEc,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC;IAuK/C,0FAA0F;IAC1F,OAAO,CAAC,qBAAqB;IAqD7B,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,SAAS;IAKjB;;;;OAIG;cACa,OAAO,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAU5D;AAMD,qBACa,cAAe,SAAQ,aAAa;IAC/C,MAAM,CAAC,WAAW,SAAgD;IAElE,MAAM,CAAC,QAAQ,WAIb;IAEF,MAAM,CAAC,KAAK;;;;;;;;;;;;;;;;MAgBV;IAEF,MAAM,CAAC,KAAK,EAAE,WAAW,CAevB;cAEc,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC;YAwJjC,qBAAqB;IAoBnC,OAAO,CAAC,iCAAiC;cAezB,OAAO,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAU5D;AAED,gFAAgF;AAChF,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C"}
@@ -0,0 +1,569 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ /**
8
+ * Pluggable `mj sync` commands (plan §3/§5).
9
+ *
10
+ * These classes extend {@link BaseCLIPlugin} so they inherit `--format`,
11
+ * `--verbose`, `--no-banner`, the runtime advisory, and uniform result emission.
12
+ * They live behind the `@memberjunction/metadata-sync/plugins` subpath — NOT the
13
+ * package's main entry — so that loading them for oclif manifest generation or
14
+ * `mj usage` enumeration does NOT pull in the heavy sync engine. The engine is
15
+ * dynamic-imported inside `Execute()`, exactly as the original commands did, so
16
+ * the per-command lazy-loading contract is preserved.
17
+ *
18
+ * Stdio discipline (plan D2): no `ora`/`console` here. Progress goes through
19
+ * `this.Host.*`; rich human text is built with the engine's `FormattingService`
20
+ * and handed to `this.Host.Log` (which suppresses/redirects it in JSON mode).
21
+ */
22
+ import { Flags } from '@oclif/core';
23
+ import { confirm, select } from '@inquirer/prompts';
24
+ import { RegisterClass } from '@memberjunction/global';
25
+ import { BaseCLIPlugin } from '@memberjunction/cli-core';
26
+ import fs from 'fs';
27
+ import path from 'path';
28
+ import { writeFileSync } from 'fs';
29
+ /** Shared loader for the heavy sync engine. Dynamic so the plugin module stays light. */
30
+ async function loadEngine() {
31
+ return import('../index.js');
32
+ }
33
+ /**
34
+ * Flush stdout, then terminate. A buffered JSON result (e.g. piped to `jq`)
35
+ * would be truncated by a bare `process.exit()`, so we exit from the flush
36
+ * callback. An unref'd hard-exit timer guarantees termination even if the flush
37
+ * callback never fires or a lingering handle (mssql socket, embedding ONNX
38
+ * worker) keeps the event loop alive. Used by both push and pull cleanup so
39
+ * neither command can hang after emitting its result.
40
+ */
41
+ function flushAndExit(code) {
42
+ const hardExit = setTimeout(() => process.exit(code), 2000);
43
+ hardExit.unref();
44
+ process.stdout.write('', () => process.exit(code));
45
+ }
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // mj sync push
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ let SyncPushPlugin = class SyncPushPlugin extends BaseCLIPlugin {
50
+ static { this.description = 'Push local file changes to the database'; }
51
+ static { this.examples = [
52
+ `<%= config.bin %> <%= command.id %>`,
53
+ `<%= config.bin %> <%= command.id %> --dry-run`,
54
+ `<%= config.bin %> <%= command.id %> --dir="ai-prompts"`,
55
+ `<%= config.bin %> <%= command.id %> --ci --format=json`,
56
+ ]; }
57
+ static { this.flags = {
58
+ dir: Flags.string({ description: 'Specific entity directory to push' }),
59
+ 'dry-run': Flags.boolean({ description: 'Show what would be pushed without actually pushing' }),
60
+ ci: Flags.boolean({ description: 'CI mode - no prompts, fail on issues' }),
61
+ 'no-validate': Flags.boolean({ description: 'Skip validation before push' }),
62
+ 'delete-db-only': Flags.boolean({
63
+ description: 'Delete database-only records that reference records being deleted (prevents FK errors)',
64
+ default: false,
65
+ }),
66
+ 'parallel-batch-size': Flags.integer({
67
+ description: 'Number of records to process in parallel (default: 10)',
68
+ default: 10,
69
+ min: 1,
70
+ max: 50,
71
+ }),
72
+ include: Flags.string({ description: 'Only process these directories (comma-separated, supports patterns)' }),
73
+ exclude: Flags.string({ description: 'Skip these directories (comma-separated, supports patterns)' }),
74
+ incremental: Flags.boolean({
75
+ description: 'Skip unchanged files using stored checksums from last push',
76
+ default: false,
77
+ }),
78
+ 'change-detail': Flags.boolean({
79
+ description: 'Write a detailed per-record change report (primary keys + field diffs) to a file',
80
+ default: false,
81
+ }),
82
+ }; }
83
+ static { this.Usage = {
84
+ domain: 'sync',
85
+ command: 'sync:push',
86
+ summary: 'Push local metadata files to the database (upsert).',
87
+ description: 'Reads entity directories under --dir and creates/updates matching records. Runs validation first unless --no-validate.',
88
+ flags: [
89
+ { name: '--dir', type: 'string', description: 'Entity directory to push' },
90
+ { name: '--dry-run', type: 'boolean', description: 'Report what would change without writing' },
91
+ { name: '--ci', type: 'boolean', description: 'No prompts; non-zero exit on error' },
92
+ { name: '--no-validate', type: 'boolean', description: 'Skip pre-push validation' },
93
+ { name: '--incremental', type: 'boolean', description: 'Skip unchanged files using stored checksums' },
94
+ { name: '--format', type: 'text|json|md', description: 'Output format (json for machine-readable result)' },
95
+ ],
96
+ examples: ['mj sync push --dir=ai-agents', 'mj sync push --ci --format=json'],
97
+ runtime: { class: 'variable', typicalSeconds: 20, note: 'scales with number of records in --dir' },
98
+ }; }
99
+ async Execute() {
100
+ const startTime = Date.now();
101
+ const flags = this.GetFlags();
102
+ const isText = this.Host.Format === 'text';
103
+ const nonInteractive = flags.ci || !isText;
104
+ const engine = await loadEngine();
105
+ const { PushService, ValidationService, FormattingService, loadMJConfig, loadSyncConfig, initializeProvider, getSyncEngine, getSystemUser, configManager, SyncStateManager, } = engine;
106
+ const errors = [];
107
+ const warnings = [];
108
+ try {
109
+ this.Host.StartStep('Loading configuration');
110
+ const mjConfig = loadMJConfig();
111
+ if (!mjConfig) {
112
+ this.Host.FailStep('Loading configuration', 'No mj.config.cjs found');
113
+ return this.fail(startTime, [{ message: 'No mj.config.cjs found in current directory or parent directories' }]);
114
+ }
115
+ const baseDir = configManager.getOriginalCwd();
116
+ const syncConfigDir = flags.dir ? path.resolve(baseDir, flags.dir) : baseDir;
117
+ await loadSyncConfig(syncConfigDir);
118
+ await initializeProvider(mjConfig);
119
+ await getSyncEngine(getSystemUser());
120
+ this.Host.SucceedStep('Configuration and metadata loaded');
121
+ const includeFilter = flags.include ? flags.include.split(',').map((s) => s.trim()) : undefined;
122
+ const excludeFilter = flags.exclude ? flags.exclude.split(',').map((s) => s.trim()) : undefined;
123
+ const formatter = new FormattingService();
124
+ // ── Validation ────────────────────────────────────────────────────────────
125
+ if (!flags['no-validate']) {
126
+ this.Host.StartStep('Validating metadata');
127
+ const validator = new ValidationService({ verbose: this.Host.Verbose, include: includeFilter, exclude: excludeFilter });
128
+ const targetDir = flags.dir ? path.resolve(baseDir, flags.dir) : baseDir;
129
+ const validationResult = await validator.validateDirectory(targetDir);
130
+ if (!validationResult.isValid || validationResult.warnings.length > 0) {
131
+ this.Host.FailStep('Validation reported issues');
132
+ if (isText)
133
+ this.Host.Log('\n' + formatter.formatValidationResult(validationResult, this.Host.Verbose));
134
+ if (!validationResult.isValid) {
135
+ if (nonInteractive) {
136
+ return this.fail(startTime, [{ context: 'validation', message: 'Validation failed. Cannot proceed with push.' }]);
137
+ }
138
+ const shouldContinue = await confirm({
139
+ message: 'Validation failed with errors. Do you want to continue anyway?',
140
+ default: false,
141
+ });
142
+ if (!shouldContinue) {
143
+ // Clean cancel (exit 0) — matches the original interactive behavior.
144
+ return this.cancelled(startTime, 'Push cancelled due to validation errors.');
145
+ }
146
+ }
147
+ }
148
+ else {
149
+ this.Host.SucceedStep('Validation passed');
150
+ }
151
+ }
152
+ // ── Push ────────────────────────────────────────────────────────────────��─
153
+ const pushService = new PushService(await getSyncEngine(getSystemUser()), getSystemUser());
154
+ if (flags.incremental) {
155
+ const stateDir = flags.dir ? path.resolve(baseDir, flags.dir) : baseDir;
156
+ const stateManager = new SyncStateManager(stateDir);
157
+ await stateManager.load();
158
+ pushService.setStateManager(stateManager);
159
+ }
160
+ const result = await pushService.push({
161
+ dir: flags.dir,
162
+ dryRun: flags['dry-run'],
163
+ verbose: this.Host.Verbose,
164
+ noValidate: flags['no-validate'],
165
+ deleteDbOnly: flags['delete-db-only'],
166
+ parallelBatchSize: flags['parallel-batch-size'],
167
+ include: includeFilter,
168
+ exclude: excludeFilter,
169
+ incremental: flags.incremental,
170
+ }, {
171
+ onProgress: (message) => this.Host.StartStep(message),
172
+ onSuccess: (message) => this.Host.SucceedStep(message),
173
+ // onError is multi-line HUMAN logging — route to the host, do NOT treat
174
+ // each line as a discrete error. Structured per-record failures arrive
175
+ // via onRecordError (below), so errors[] stays clean (plan §1a/§6/D6).
176
+ onError: (message) => {
177
+ if (message.includes('Rolling back database') || message.includes('rolled back successfully')) {
178
+ warnings.push(message);
179
+ this.Host.Log(message, 'warn');
180
+ }
181
+ else {
182
+ this.Host.Log(message, 'error');
183
+ }
184
+ },
185
+ onRecordError: (d) => {
186
+ const context = d.path ? `${d.path}${d.entityName ? ` [${d.entityName}]` : ''}` : d.entityName || undefined;
187
+ const message = d.primaryKey ? `${d.message} (pk: ${d.primaryKey})` : d.message;
188
+ errors.push({ context, message });
189
+ },
190
+ onWarn: (message) => {
191
+ warnings.push(message);
192
+ this.Host.Log(message, 'warn');
193
+ },
194
+ onLog: (message) => this.Host.Log(message),
195
+ onConfirm: async (message) => (nonInteractive ? true : confirm({ message })),
196
+ });
197
+ const endTime = Date.now();
198
+ for (const w of result.warnings)
199
+ if (!warnings.includes(w))
200
+ warnings.push(w);
201
+ // Recovery decision: with non-fatal errors, the original CLI asked whether to
202
+ // keep the successfully-committed changes. Preserve that for interactive text
203
+ // mode. CI / non-interactive keeps the failure (no prompt possible).
204
+ let success = result.errors === 0 && errors.length === 0;
205
+ if (!success && result.errors > 0 && !flags.ci && isText) {
206
+ const commit = await confirm({
207
+ message: 'Push completed with errors. Do you want to commit the successful changes?',
208
+ default: false,
209
+ });
210
+ if (commit)
211
+ success = true;
212
+ else
213
+ warnings.push('Push cancelled due to errors.');
214
+ }
215
+ if (isText) {
216
+ this.renderPushTextSummary(formatter, result, flags['change-detail'], startTime, endTime);
217
+ }
218
+ return {
219
+ success,
220
+ command: 'sync:push',
221
+ durationSeconds: (endTime - startTime) / 1000,
222
+ data: {
223
+ created: result.created,
224
+ updated: result.updated,
225
+ unchanged: result.unchanged,
226
+ deleted: result.deleted ?? 0,
227
+ skipped: result.skipped ?? 0,
228
+ deferred: result.deferred ?? 0,
229
+ // The raw engine error COUNT is always present, even when fail-fast threw
230
+ // before every failing record could be enumerated into errors[].
231
+ errorCount: result.errors,
232
+ dryRun: !!flags['dry-run'],
233
+ sqlLogPath: result.sqlLogPath,
234
+ },
235
+ errors,
236
+ warnings,
237
+ };
238
+ }
239
+ catch (error) {
240
+ // Infrastructure failures (config load, DB connect, etc.) — surface them
241
+ // as a structured result so JSON consumers always get an output, and so
242
+ // Cleanup() still runs to close the pool. If a per-record failure already
243
+ // populated errors[] (fail-fast throws AFTER onRecordError fires), don't
244
+ // duplicate it with the rethrown wrapper message.
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ this.Host.FailStep('Push failed', message);
247
+ if (errors.length === 0)
248
+ errors.push({ message });
249
+ return { success: false, command: 'sync:push', durationSeconds: (Date.now() - startTime) / 1000, errors, warnings };
250
+ }
251
+ }
252
+ /** Reproduces the original rich text summary (recap, change-detail file, box, status). */
253
+ renderPushTextSummary(formatter, result, changeDetail, startTime, endTime) {
254
+ const recap = formatter.formatChangesRecap(result.changeLog);
255
+ if (recap)
256
+ this.Host.Log('\n' + recap);
257
+ if (changeDetail && result.changeLog.length > 0) {
258
+ const stamp = new Date(endTime).toISOString().replace(/[:.]/g, '-');
259
+ const reportDir = result.sqlLogPath ? path.dirname(result.sqlLogPath) : process.cwd();
260
+ const reportPath = path.join(reportDir, `MetadataSync_Changes_${stamp}.log`);
261
+ try {
262
+ writeFileSync(reportPath, formatter.formatChangesReport(result.changeLog, new Date(endTime).toISOString()));
263
+ this.Host.Log(`\n📄 Detailed change report: ${path.relative(process.cwd(), reportPath)}`);
264
+ }
265
+ catch (writeErr) {
266
+ this.Host.Log(`\n⚠️ Could not write change report: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`, 'warn');
267
+ }
268
+ }
269
+ const changed = result.created > 0 || result.updated > 0 || (result.deleted || 0) > 0 || result.errors > 0;
270
+ if (changed || this.Host.Verbose) {
271
+ this.Host.Log('\n' +
272
+ formatter.formatSyncSummary('push', {
273
+ created: result.created,
274
+ updated: result.updated,
275
+ unchanged: result.unchanged,
276
+ deleted: result.deleted || 0,
277
+ skipped: result.skipped || 0,
278
+ deferred: result.deferred || 0,
279
+ errors: result.errors,
280
+ duration: endTime - startTime,
281
+ }));
282
+ }
283
+ if (result.warnings.length > 0) {
284
+ this.Host.Log(`\n⚠️ ${result.warnings.length} warning${result.warnings.length > 1 ? 's' : ''} during push`, 'warn');
285
+ if (this.Host.Verbose)
286
+ result.warnings.forEach((w) => this.Host.Log(` - ${w}`, 'warn'));
287
+ }
288
+ const extras = changed || this.Host.Verbose ? '' : ` · no changes · ${formatter.formatDuration(endTime - startTime)}`;
289
+ if (result.errors === 0) {
290
+ this.Host.Log(`\n✓ Push completed successfully${extras}`);
291
+ }
292
+ else {
293
+ this.Host.Log('\n⚠️ Push completed with errors', 'warn');
294
+ }
295
+ if (result.sqlLogPath)
296
+ this.Host.Log(`\n📄 SQL log saved to: ${path.relative(process.cwd(), result.sqlLogPath)}`);
297
+ }
298
+ fail(startTime, errors) {
299
+ return { success: false, command: 'sync:push', durationSeconds: (Date.now() - startTime) / 1000, errors };
300
+ }
301
+ cancelled(startTime, message) {
302
+ if (this.Host.Format === 'text')
303
+ this.Host.Log(`\n⚠️ ${message}`, 'warn');
304
+ return { success: true, command: 'sync:push', durationSeconds: (Date.now() - startTime) / 1000, warnings: [message] };
305
+ }
306
+ /**
307
+ * Reset singletons + close the DB pool so the process can exit cleanly, then
308
+ * force-exit. Without this the pg.Pool/mssql pool — and any embedding ONNX
309
+ * worker threads — keep the event loop alive and the CLI hangs after a push.
310
+ */
311
+ async Cleanup(result) {
312
+ try {
313
+ const { resetSyncEngine, cleanupProvider } = await loadEngine();
314
+ resetSyncEngine();
315
+ await cleanupProvider();
316
+ }
317
+ catch {
318
+ // best-effort
319
+ }
320
+ flushAndExit(result.success ? 0 : 1);
321
+ }
322
+ };
323
+ SyncPushPlugin = __decorate([
324
+ RegisterClass(BaseCLIPlugin, 'sync:push')
325
+ ], SyncPushPlugin);
326
+ export { SyncPushPlugin };
327
+ // ─────────────────────────────────────────────────────────────────────────────
328
+ // mj sync pull
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ let SyncPullPlugin = class SyncPullPlugin extends BaseCLIPlugin {
331
+ static { this.description = 'Pull metadata from database to local files'; }
332
+ static { this.examples = [
333
+ `<%= config.bin %> <%= command.id %> --entity="MJ: AI Prompts"`,
334
+ `<%= config.bin %> <%= command.id %> --entity="MJ: AI Agents" --merge-strategy=overwrite`,
335
+ `<%= config.bin %> <%= command.id %> --entity="Templates" --dry-run --verbose`,
336
+ ]; }
337
+ static { this.flags = {
338
+ entity: Flags.string({ description: 'Entity name to pull', required: true }),
339
+ filter: Flags.string({ description: 'Additional filter for pulling specific records' }),
340
+ 'dry-run': Flags.boolean({ description: 'Show what would be pulled without actually pulling' }),
341
+ 'multi-file': Flags.string({ description: 'Create a single file with multiple records (provide filename)' }),
342
+ 'no-validate': Flags.boolean({ description: 'Skip validation before pull' }),
343
+ 'update-existing': Flags.boolean({ description: 'Update existing records during pull', default: true }),
344
+ 'create-new': Flags.boolean({ description: 'Create new files for records not found locally', default: true }),
345
+ 'backup-before-update': Flags.boolean({ description: 'Create backups before updating files', default: true }),
346
+ 'merge-strategy': Flags.string({ description: 'Merge strategy for updates', options: ['merge', 'overwrite', 'skip'], default: 'merge' }),
347
+ 'backup-directory': Flags.string({ description: 'Custom backup directory (default: .backups)' }),
348
+ 'preserve-fields': Flags.string({ description: 'Comma-separated list of fields to preserve during updates', multiple: true }),
349
+ 'exclude-fields': Flags.string({ description: 'Comma-separated list of fields to exclude from pull', multiple: true }),
350
+ 'target-dir': Flags.string({ description: 'Specific target directory (overrides auto-discovery)' }),
351
+ incremental: Flags.boolean({ description: 'Only pull records updated since last successful pull', default: false }),
352
+ since: Flags.string({ description: 'Only pull records updated after this ISO 8601 timestamp' }),
353
+ }; }
354
+ static { this.Usage = {
355
+ domain: 'sync',
356
+ command: 'sync:pull',
357
+ summary: 'Pull database records into local metadata files.',
358
+ description: 'Writes records of --entity into the matching entity directory, creating or updating files per the merge strategy.',
359
+ flags: [
360
+ { name: '--entity', type: 'string', description: 'Entity name to pull (required)' },
361
+ { name: '--filter', type: 'string', description: 'Extra SQL filter for which records to pull' },
362
+ { name: '--merge-strategy', type: 'merge|overwrite|skip', description: 'How to reconcile existing files' },
363
+ { name: '--dry-run', type: 'boolean', description: 'Report what would be pulled without writing' },
364
+ { name: '--format', type: 'text|json|md', description: 'Output format (json for machine-readable result)' },
365
+ ],
366
+ examples: ['mj sync pull --entity="MJ: AI Prompts"', 'mj sync pull --entity="Actions" --format=json'],
367
+ runtime: { class: 'variable', typicalSeconds: 15, note: 'scales with number of records matched for --entity' },
368
+ }; }
369
+ async Execute() {
370
+ const startTime = Date.now();
371
+ const flags = this.GetFlags();
372
+ const isText = this.Host.Format === 'text';
373
+ const nonInteractive = !isText;
374
+ const engine = await loadEngine();
375
+ const { PullService, ValidationService, FormattingService, loadMJConfig, initializeProvider, getSyncEngine, getSystemUser, configManager, loadEntityConfig, FileBackupManager, SyncStateManager, } = engine;
376
+ const errors = [];
377
+ const warnings = [];
378
+ let backupManager = null;
379
+ try {
380
+ this.Host.StartStep('Loading configuration');
381
+ const mjConfig = loadMJConfig();
382
+ if (!mjConfig) {
383
+ this.Host.FailStep('Loading configuration', 'No mj.config.cjs found');
384
+ return { success: false, command: 'sync:pull', durationSeconds: (Date.now() - startTime) / 1000, errors: [{ message: 'No mj.config.cjs found in current directory or parent directories' }] };
385
+ }
386
+ await initializeProvider(mjConfig);
387
+ const syncEngine = await getSyncEngine(getSystemUser());
388
+ this.Host.SucceedStep('Configuration and metadata loaded');
389
+ const entityDirectories = await this.findEntityDirectories(flags.entity, configManager, loadEntityConfig, warnings);
390
+ if (entityDirectories.length === 0) {
391
+ return { success: false, command: 'sync:pull', durationSeconds: (Date.now() - startTime) / 1000, errors: [{ context: flags.entity, message: `No directories found for entity "${flags.entity}". Ensure its .mj-sync.json config exists.` }] };
392
+ }
393
+ let targetDir = flags['target-dir'];
394
+ if (!targetDir) {
395
+ if (entityDirectories.length === 1) {
396
+ targetDir = entityDirectories[0];
397
+ }
398
+ else if (nonInteractive) {
399
+ return { success: false, command: 'sync:pull', durationSeconds: (Date.now() - startTime) / 1000, errors: [{ context: flags.entity, message: `Multiple directories found for "${flags.entity}"; pass --target-dir to choose one in non-interactive mode.` }] };
400
+ }
401
+ else {
402
+ targetDir = await select({
403
+ message: `Multiple directories found for entity "${flags.entity}". Which one to use?`,
404
+ choices: entityDirectories.map((dir) => ({ name: dir, value: dir })),
405
+ });
406
+ }
407
+ }
408
+ const formatter = new FormattingService();
409
+ if (!flags['no-validate']) {
410
+ this.Host.StartStep('Validating target directory');
411
+ const validator = new ValidationService({ verbose: this.Host.Verbose });
412
+ const validationResult = await validator.validateDirectory(targetDir);
413
+ if (!validationResult.isValid || validationResult.warnings.length > 0) {
414
+ this.Host.FailStep('Validation reported issues');
415
+ if (isText)
416
+ this.Host.Log('\n' + formatter.formatValidationResult(validationResult, this.Host.Verbose));
417
+ if (!validationResult.isValid) {
418
+ if (nonInteractive) {
419
+ return { success: false, command: 'sync:pull', durationSeconds: (Date.now() - startTime) / 1000, errors: [{ context: 'validation', message: 'Validation failed. Cannot proceed with pull.' }] };
420
+ }
421
+ const shouldContinue = await confirm({ message: 'Validation failed with errors. Continue anyway?', default: false });
422
+ if (!shouldContinue) {
423
+ if (isText)
424
+ this.Host.Log('\n⚠️ Pull cancelled due to validation errors.', 'warn');
425
+ return { success: true, command: 'sync:pull', durationSeconds: (Date.now() - startTime) / 1000, warnings: ['Pull cancelled due to validation errors.'] };
426
+ }
427
+ }
428
+ }
429
+ else {
430
+ this.Host.SucceedStep('Validation passed');
431
+ }
432
+ }
433
+ if (flags['backup-before-update']) {
434
+ backupManager = new FileBackupManager();
435
+ await backupManager.initialize();
436
+ }
437
+ const pullService = new PullService(syncEngine, getSystemUser());
438
+ if (flags.incremental || flags.since) {
439
+ const stateManager = new SyncStateManager(targetDir);
440
+ await stateManager.load();
441
+ pullService.setStateManager(stateManager);
442
+ }
443
+ // Only the fields PullService.pull() actually reads are forwarded; the
444
+ // backup/merge/preserve/exclude flags it ignores are kept for help/CLI
445
+ // compatibility but were no-ops in the original (passed via `any`).
446
+ const pullOptions = { entity: flags.entity, targetDir, dryRun: flags['dry-run'], verbose: this.Host.Verbose, noValidate: flags['no-validate'] };
447
+ if (flags.filter !== undefined)
448
+ pullOptions.filter = flags.filter;
449
+ if (flags['multi-file'] !== undefined)
450
+ pullOptions.multiFile = flags['multi-file'];
451
+ if (flags['update-existing'] !== undefined)
452
+ pullOptions.updateExistingRecords = flags['update-existing'];
453
+ if (flags['create-new'] !== undefined)
454
+ pullOptions.createNewFileIfNotFound = flags['create-new'];
455
+ if (flags.incremental)
456
+ pullOptions.incremental = flags.incremental;
457
+ if (flags.since !== undefined)
458
+ pullOptions.since = flags.since;
459
+ const result = await pullService.pull(pullOptions, {
460
+ onProgress: (message) => this.Host.StartStep(message),
461
+ onSuccess: (message) => this.Host.SucceedStep(message),
462
+ onError: (message) => {
463
+ errors.push({ message });
464
+ this.Host.FailStep(message);
465
+ },
466
+ onWarn: (message) => {
467
+ warnings.push(message);
468
+ this.Host.Log(message, 'warn');
469
+ },
470
+ onLog: (message) => this.Host.Log(message),
471
+ });
472
+ if (backupManager && !flags['dry-run']) {
473
+ await backupManager.cleanup();
474
+ }
475
+ if (!flags['dry-run']) {
476
+ try {
477
+ await pullService.cleanupBackupFiles();
478
+ }
479
+ catch (cleanupError) {
480
+ warnings.push(`Failed to cleanup persistent backup files: ${cleanupError}`);
481
+ }
482
+ }
483
+ if (isText && !flags['dry-run'])
484
+ this.Host.Log('\n✅ Pull completed successfully');
485
+ return {
486
+ success: errors.length === 0,
487
+ command: 'sync:pull',
488
+ durationSeconds: (Date.now() - startTime) / 1000,
489
+ data: {
490
+ entity: flags.entity,
491
+ targetDir: result.targetDir,
492
+ processed: result.processed,
493
+ created: result.created,
494
+ updated: result.updated,
495
+ skipped: result.skipped,
496
+ dryRun: !!flags['dry-run'],
497
+ },
498
+ errors,
499
+ warnings,
500
+ };
501
+ }
502
+ catch (error) {
503
+ if (backupManager) {
504
+ try {
505
+ await backupManager.rollback();
506
+ }
507
+ catch (rollbackError) {
508
+ warnings.push(`Failed to rollback backup files: ${rollbackError}`);
509
+ }
510
+ }
511
+ const message = error instanceof Error ? error.message : String(error);
512
+ this.Host.FailStep('Pull failed', message);
513
+ errors.push({ context: flags.entity, message });
514
+ return { success: false, command: 'sync:pull', durationSeconds: (Date.now() - startTime) / 1000, errors, warnings };
515
+ }
516
+ }
517
+ async findEntityDirectories(entityName, configMgr, loadConfig, warnings) {
518
+ const workingDir = configMgr.getOriginalCwd();
519
+ const allDirs = this.findAllEntityDirectoriesRecursive(workingDir);
520
+ const entityDirs = [];
521
+ for (const dir of allDirs) {
522
+ try {
523
+ const config = await loadConfig(dir);
524
+ if (config && config.entity === entityName)
525
+ entityDirs.push(dir);
526
+ }
527
+ catch (error) {
528
+ warnings.push(`Skipping directory ${dir}: invalid configuration (${error})`);
529
+ }
530
+ }
531
+ return entityDirs;
532
+ }
533
+ findAllEntityDirectoriesRecursive(dir) {
534
+ const directories = [];
535
+ try {
536
+ if (fs.existsSync(path.join(dir, '.mj-sync.json')))
537
+ directories.push(dir);
538
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
539
+ if (item.isDirectory() && !item.name.startsWith('.')) {
540
+ directories.push(...this.findAllEntityDirectoriesRecursive(path.join(dir, item.name)));
541
+ }
542
+ }
543
+ }
544
+ catch {
545
+ // Skip directories we can't read
546
+ }
547
+ return directories;
548
+ }
549
+ async Cleanup(result) {
550
+ try {
551
+ const { resetSyncEngine, cleanupProvider } = await loadEngine();
552
+ resetSyncEngine();
553
+ await cleanupProvider();
554
+ }
555
+ catch {
556
+ // best-effort
557
+ }
558
+ flushAndExit(result.success ? 0 : 1);
559
+ }
560
+ };
561
+ SyncPullPlugin = __decorate([
562
+ RegisterClass(BaseCLIPlugin, 'sync:pull')
563
+ ], SyncPullPlugin);
564
+ export { SyncPullPlugin };
565
+ /** Tree-shaking / dynamic-load anchor — referenced by the CLI plugin loader. */
566
+ export function LoadMetadataSyncPlugins() {
567
+ // no-op; importing this module triggers the @RegisterClass decorators above.
568
+ }
569
+ //# sourceMappingURL=index.js.map