@pixlcore/xycli 1.0.8 → 1.1.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.
@@ -127,6 +127,37 @@ test('downsync replaces changed files and preserves the server timestamp', async
127
127
  assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 150);
128
128
  });
129
129
 
130
+ test('down-only bucket deletion removes local files absent from the remote manifest', async t => {
131
+ const keep = { filename: 'keep.txt', path: 'files/bucket/bucket_unit/key/keep.txt', size: 4, date: 80 };
132
+ const env = fixture(t, { localData: { keep: true }, remoteData: { keep: true }, remoteFiles: [keep] });
133
+ const keep_path = Path.join(env.paths.files, 'keep.txt');
134
+ const stale_path = Path.join(env.paths.files, 'stale.txt');
135
+ fs.writeFileSync(keep_path, 'keep');
136
+ fs.utimesSync(keep_path, 80, 80);
137
+ fs.writeFileSync(stale_path, 'stale');
138
+
139
+ await env.app.syncBucketContents([env.item], { down: ['buckets'], delete: ['buckets'] });
140
+ assert.equal(env.app.errors.length, 0);
141
+ assert.equal(fs.existsSync(keep_path), true);
142
+ assert.equal(fs.existsSync(stale_path), false);
143
+ assert.equal(env.app.downSynced, true);
144
+ assert.deepEqual(env.calls, []);
145
+ });
146
+
147
+ test('down-only bucket deletion previews files and skips them after a failed download', async t => {
148
+ const remote_file = { filename: 'remote.txt', path: 'files/bucket/bucket_unit/key/remote.txt', size: 6, date: 80 };
149
+ for (const dry of [true, false]) {
150
+ const env = fixture(t, { localData: { keep: true }, remoteData: { keep: true },
151
+ remoteFiles: [remote_file], dry });
152
+ const stale_path = Path.join(env.paths.files, 'stale.txt');
153
+ fs.writeFileSync(stale_path, 'stale');
154
+ await env.app.syncBucketContents([env.item], { down: ['buckets'], delete: ['buckets'] });
155
+ assert.equal(fs.existsSync(stale_path), true);
156
+ if (dry) assert.equal(env.app.errors.length, 0);
157
+ else assert.match(env.app.errors[0], /Missing remote bytes/);
158
+ }
159
+ });
160
+
130
161
  test('two-way sync chooses the newer data and file copies independently', async t => {
131
162
  const old = { filename: 'remote.txt', path: 'files/bucket/bucket_unit/key/remote.txt', size: 6, date: 140 };
132
163
  const env = fixture(t, {
@@ -115,6 +115,42 @@ test('bucket filesystem sync', async t => {
115
115
  assert.equal(Math.floor(fs.statSync(file).mtimeMs / 1000), remoteFile.date);
116
116
  assert.equal(Math.floor(fs.statSync(dataFile).mtimeMs / 1000), bucket.meta.mod);
117
117
  });
118
+
119
+ await check('down-only delete removes a local Bucket file absent from xyOps', async () => {
120
+ const content = Path.join(activeRoot, 'buckets', slug);
121
+ const extra = Path.join(content, 'files', 'LocalOnly.txt');
122
+ fs.writeFileSync(extra, 'only in the local sync tree');
123
+ const before = await call('getBucket', { id });
124
+
125
+ const preview = xy(['sync', activeRoot, '--up', 'false', '--down', 'buckets', '--delete', 'buckets', '--dry']);
126
+ assert.match(preview, /Would delete local bucket file/);
127
+ assert.ok(fs.existsSync(extra), 'Dry run preserves the local file');
128
+
129
+ const output = xy(['sync', activeRoot, '--up', 'false', '--down', 'buckets', '--delete', 'buckets']);
130
+ assert.match(output, /Deleting local bucket file/);
131
+ assert.ok(!fs.existsSync(extra), 'Missing remote file was removed locally');
132
+ assert.ok(fs.existsSync(Path.join(content, 'files', 'seed.json')), 'Existing remote file remains');
133
+ assert.deepEqual((await call('getBucket', { id })).files, before.files, 'Remote Bucket files are unchanged');
134
+ });
135
+
136
+ await check('down-only delete removes a deleted Bucket and its local content', async () => {
137
+ const source = Path.join(activeRoot, 'buckets', slug + '.json');
138
+ const content = Path.join(activeRoot, 'buckets', slug);
139
+ await call('deleteBucket', { id });
140
+ created = false;
141
+ assert.ok(fs.existsSync(source), 'Local source remains before down-delete');
142
+ assert.ok(fs.existsSync(content), 'Local content remains before down-delete');
143
+
144
+ const preview = xy(['sync', activeRoot, '--up', 'false', '--down', 'buckets', '--delete', 'buckets', '--dry']);
145
+ assert.match(preview, /Would delete local bucket/);
146
+ assert.ok(fs.existsSync(source), 'Dry run preserves the Bucket source');
147
+ assert.ok(fs.existsSync(content), 'Dry run preserves Bucket content');
148
+
149
+ const output = xy(['sync', activeRoot, '--up', 'false', '--down', 'buckets', '--delete', 'buckets']);
150
+ assert.match(output, /Deleting local bucket/);
151
+ assert.ok(!fs.existsSync(source), 'Deleted Bucket source was removed');
152
+ assert.ok(!fs.existsSync(content), 'Deleted Bucket content was removed');
153
+ });
118
154
  }
119
155
  finally {
120
156
  // Ordinary sync writes a shared management map. Restore it exactly, then
@@ -0,0 +1,231 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const Path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ function makeTree(t) {
9
+ var dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-syshook-test-'));
10
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
11
+ return dir;
12
+ }
13
+
14
+ function writeSource(dir, name, type, data) {
15
+ var file = Path.join(dir, name);
16
+ fs.mkdirSync(Path.dirname(file), { recursive: true });
17
+ fs.writeFileSync(file, JSON.stringify({
18
+ type: 'xypdf', version: '1.0', items: [{ type, data }]
19
+ }));
20
+ return file;
21
+ }
22
+
23
+ function runHook(dir, action, payload, extra = [], use_default_lock = false) {
24
+ // Strip xyOps connection variables to match the conductor's shell-hook
25
+ // environment. The hook must complete without credentials or API access.
26
+ var env = { ...process.env, HOME: dir };
27
+ for (const key of Object.keys(env)) {
28
+ if (key.startsWith('XYOPS_')) delete env[key];
29
+ }
30
+
31
+ var args = [Path.join(__dirname, '..', 'index.js'), 'sync', dir, '--syshook', action];
32
+ if (!use_default_lock) args.push('--lock_file', Path.join(dir, '.sync-lock'));
33
+ args.push(...extra);
34
+
35
+ return spawnSync(process.execPath, args, {
36
+ input: payload === null ? '' : JSON.stringify(payload) + '\n',
37
+ encoding: 'utf8',
38
+ timeout: 10000,
39
+ env
40
+ });
41
+ }
42
+
43
+ function markerCommand(name) {
44
+ var code = `require('node:fs').writeFileSync(${JSON.stringify(name)}, process.cwd())`;
45
+ return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(code)}`;
46
+ }
47
+
48
+ test('syshook deletes only the exact Event and its owned neighbors', t => {
49
+ var dir = makeTree(t);
50
+ var event = writeSource(dir, 'events/Ops/Nightly.json', 'event', {
51
+ id: 'evt_one', title: 'Nightly', params: { script: '(External)' }
52
+ });
53
+ var neighbor = Path.join(dir, 'events/Ops/Nightly-params.script.sh');
54
+ fs.writeFileSync(neighbor, 'echo one\n');
55
+ var other = writeSource(dir, 'events/Ops/Nightly-V2.json', 'event', {
56
+ id: 'evt_two', title: 'Nightly V2', params: { script: '(External)' }
57
+ });
58
+ var other_neighbor = Path.join(dir, 'events/Ops/Nightly-V2-params.script.sh');
59
+ fs.writeFileSync(other_neighbor, 'echo two\n');
60
+ var new_event = writeSource(dir, 'events/Ops/New.json', 'event', { id: 'evt_new', title: 'New' });
61
+ var same_id_plugin = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'evt_one', title: 'Shared' });
62
+ var marker = Path.join(dir, 'completed.txt');
63
+
64
+ var result = runHook(dir, 'event_delete', {
65
+ xy: 1, action: 'event_delete', event: { id: 'evt_one', title: 'Nightly' }
66
+ }, ['--down_cmd', markerCommand('completed.txt')]);
67
+
68
+ assert.ifError(result.error);
69
+ assert.equal(result.status, 0, result.stderr);
70
+ assert.equal(fs.existsSync(event), false);
71
+ assert.equal(fs.existsSync(neighbor), false);
72
+ assert.equal(fs.existsSync(other), true);
73
+ assert.equal(fs.existsSync(other_neighbor), true);
74
+ assert.equal(fs.existsSync(new_event), true);
75
+ assert.equal(fs.existsSync(same_id_plugin), true);
76
+ assert.equal(fs.readFileSync(marker, 'utf8'), fs.realpathSync(dir));
77
+ });
78
+
79
+ test('syshook uses its default lock without a configured xyOps URL or API Key', t => {
80
+ var dir = makeTree(t);
81
+ var event = writeSource(dir, 'events/Delete-Me.json', 'event', { id: 'evt_no_config' });
82
+ var result = runHook(dir, 'event_delete', {
83
+ xy: 1, action: 'event_delete', event: { id: 'evt_no_config' }
84
+ }, [], true);
85
+
86
+ assert.equal(result.status, 0, result.stderr);
87
+ assert.equal(fs.existsSync(event), false);
88
+ });
89
+
90
+ test('syshook rejects a mismatched action without deleting files', t => {
91
+ var dir = makeTree(t);
92
+ var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
93
+ var result = runHook(dir, 'plugin_delete', {
94
+ xy: 1, action: 'event_delete', plugin: { id: 'plugin_one' }
95
+ }, ['--down_cmd', markerCommand('completed.txt')]);
96
+
97
+ assert.equal(result.status, 1);
98
+ assert.match(result.stderr, /does not match --syshook plugin_delete/);
99
+ assert.equal(fs.existsSync(file), true);
100
+ assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
101
+ });
102
+
103
+ test('syshook deletes a matching Plugin without changing another Plugin', t => {
104
+ var dir = makeTree(t);
105
+ var target = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
106
+ var other = writeSource(dir, 'plugins/Other.json', 'plugin', { id: 'plugin_two', title: 'Other' });
107
+ var result = runHook(dir, 'plugin_delete', {
108
+ xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
109
+ });
110
+
111
+ assert.equal(result.status, 0, result.stderr);
112
+ assert.equal(fs.existsSync(target), false);
113
+ assert.equal(fs.existsSync(other), true);
114
+ });
115
+
116
+ // Each remaining standard sync type uses the same exact type-and-ID lookup.
117
+ // Include another Event with the target ID to catch an accidental ID-only
118
+ // match, especially for the API Key action's different spelling.
119
+ for (const [action, type] of [
120
+ ['alert_delete', 'alert'],
121
+ ['apikey_delete', 'api_key'],
122
+ ['category_delete', 'category'],
123
+ ['channel_delete', 'channel'],
124
+ ['group_delete', 'group'],
125
+ ['monitor_delete', 'monitor'],
126
+ ['tag_delete', 'tag'],
127
+ ['web_hook_delete', 'web_hook']
128
+ ]) {
129
+ test('syshook deletes one ' + type + ' for ' + action, t => {
130
+ var dir = makeTree(t);
131
+ var target = writeSource(dir, type + '/Target.json', type, { id: 'shared_id' });
132
+ var other = writeSource(dir, type + '/Other.json', type, { id: 'other_id' });
133
+ var event = writeSource(dir, 'events/Shared.json', 'event', { id: 'shared_id' });
134
+ var result = runHook(dir, action, {
135
+ xy: 1, action, [type]: { id: 'shared_id' }
136
+ });
137
+
138
+ assert.equal(result.status, 0, result.stderr);
139
+ assert.equal(fs.existsSync(target), false);
140
+ assert.equal(fs.existsSync(other), true);
141
+ assert.equal(fs.existsSync(event), true);
142
+ });
143
+ }
144
+
145
+ test('syshook rejects bucket deletion and preserves its source', t => {
146
+ var dir = makeTree(t);
147
+ var bucket = writeSource(dir, 'buckets/Shared.json', 'bucket', { id: 'bucket_one' });
148
+ var result = runHook(dir, 'bucket_delete', {
149
+ xy: 1, action: 'bucket_delete', bucket: { id: 'bucket_one' }
150
+ });
151
+
152
+ assert.equal(result.status, 1);
153
+ assert.match(result.stderr, /Invalid --syshook action/);
154
+ assert.equal(fs.existsSync(bucket), true);
155
+ });
156
+
157
+ test('syshook checks neighboring files before deleting the source', t => {
158
+ var dir = makeTree(t);
159
+ var target = writeSource(dir, 'events/Ops/Nightly.json', 'event', { id: 'evt_one', title: 'Nightly' });
160
+ var unrelated = Path.join(dir, 'events/Ops/Nightly-other.txt');
161
+ fs.writeFileSync(unrelated, 'keep me\n');
162
+ var result = runHook(dir, 'event_delete', {
163
+ xy: 1, action: 'event_delete', event: { id: 'evt_one' }
164
+ });
165
+
166
+ assert.equal(result.status, 1);
167
+ assert.match(result.stderr, /Neighbor file does not match a string property/);
168
+ assert.equal(fs.existsSync(target), true);
169
+ assert.equal(fs.existsSync(unrelated), true);
170
+ });
171
+
172
+ test('syshook stops on malformed JSON before deleting a matching source', t => {
173
+ var dir = makeTree(t);
174
+ var target = writeSource(dir, 'events/Ops/Nightly.json', 'event', { id: 'evt_one', title: 'Nightly' });
175
+ fs.writeFileSync(Path.join(dir, 'events/Ops/Broken.json'), '{bad json');
176
+ var result = runHook(dir, 'event_delete', {
177
+ xy: 1, action: 'event_delete', event: { id: 'evt_one' }
178
+ });
179
+
180
+ assert.equal(result.status, 1);
181
+ assert.match(result.stderr, /Failed to parse JSON file/);
182
+ assert.equal(fs.existsSync(target), true);
183
+ });
184
+
185
+ test('syshook refuses duplicate XYPDF sources before changing either file', t => {
186
+ var dir = makeTree(t);
187
+ var data = { id: 'plugin_one', title: 'Shared', script: '(External)' };
188
+ var first = writeSource(dir, 'plugins/First.json', 'plugin', data);
189
+ var second = writeSource(dir, 'plugins/Second.json', 'plugin', data);
190
+ var result = runHook(dir, 'plugin_delete', {
191
+ xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
192
+ });
193
+
194
+ assert.equal(result.status, 1);
195
+ assert.match(result.stderr, /Duplicate XYPDF sources/);
196
+ assert.equal(fs.existsSync(first), true);
197
+ assert.equal(fs.existsSync(second), true);
198
+ });
199
+
200
+ test('syshook dry run and absent source never run down_cmd', t => {
201
+ var dir = makeTree(t);
202
+ var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
203
+ var command = markerCommand('completed.txt');
204
+ var payload = { xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' } };
205
+ var preview = runHook(dir, 'plugin_delete', payload, ['--dry', '--down_cmd', command]);
206
+
207
+ assert.equal(preview.status, 0, preview.stderr);
208
+ assert.match(preview.stdout, /Would delete:/);
209
+ assert.equal(fs.existsSync(file), true);
210
+ assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
211
+
212
+ var absent = runHook(dir, 'plugin_delete', {
213
+ xy: 1, action: 'plugin_delete', plugin: { id: 'other' }
214
+ }, ['--down_cmd', command]);
215
+ assert.equal(absent.status, 0, absent.stderr);
216
+ assert.equal(fs.existsSync(file), true);
217
+ assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
218
+ });
219
+
220
+ test('syshook reports a failed down_cmd after deleting the matched source', t => {
221
+ var dir = makeTree(t);
222
+ var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
223
+ var command = `${JSON.stringify(process.execPath)} -e "process.exit(2)"`;
224
+ var result = runHook(dir, 'plugin_delete', {
225
+ xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
226
+ }, ['--down_cmd', command]);
227
+
228
+ assert.equal(result.status, 1);
229
+ assert.match(result.stderr, /Command failed/);
230
+ assert.equal(fs.existsSync(file), false);
231
+ });
@@ -15,26 +15,30 @@ const script = `
15
15
  const options = JSON.parse(process.argv[1]);
16
16
  cli.args.quiet = options.quiet !== false;
17
17
  const sync = require('./lib/sync.js');
18
+ const bucketSync = require('./lib/sync-buckets.js');
18
19
  if (options.setupCwd) process.chdir(options.setupCwd);
19
20
  const calls = [];
20
21
  const requests = [];
21
22
  const apiOptions = [];
22
23
  const app = {
23
24
  ...sync,
25
+ ...bucketSync,
24
26
  args: options.args,
25
27
  config: {
26
28
  base_url: 'https://fixture.invalid',
27
29
  sync: options.config || {},
28
30
  ui: {
29
- list_list: ['categories', 'events', 'plugins'].map(id => ({ id })),
30
- data_types: { category: { list: 'categories' }, event: { list: 'events' }, plugin: { list: 'plugins' } }
31
+ list_list: (options.includeBuckets ? ['buckets', 'categories', 'events', 'plugins'] : ['categories', 'events', 'plugins']).map(id => ({ id })),
32
+ data_types: { bucket: { list: 'buckets' }, category: { list: 'categories' }, event: { list: 'events' }, plugin: { list: 'plugins' } }
31
33
  }
32
34
  },
33
- state: {},
35
+ state: options.state || {},
36
+ buckets: options.buckets || [],
34
37
  categories: options.categories || [],
35
38
  events: options.events || [],
36
39
  plugins: options.plugins || [],
37
40
  version: 'test',
41
+ xyopsVersion: '1.1.2',
38
42
  dry: !!options.args.dry,
39
43
  color() { return cli.chalk.white; },
40
44
  markdown(text) { return text; },
@@ -95,6 +99,15 @@ function runSync(t, options) {
95
99
  items: [{ type: source.type, data: source.data }]
96
100
  });
97
101
  fs.writeFileSync(file, contents);
102
+ if (source.bucketFiles) {
103
+ var bucket_dir = file.replace(/\.json$/i, '');
104
+ fs.mkdirSync(Path.join(bucket_dir, 'files'), { recursive: true });
105
+ fs.writeFileSync(Path.join(bucket_dir, 'data.json'), JSON.stringify(source.bucketData || {}));
106
+ for (const [name, contents] of Object.entries(source.bucketFiles)) {
107
+ fs.writeFileSync(Path.join(bucket_dir, 'files', name), contents);
108
+ }
109
+ if (source.bucketExtra) fs.writeFileSync(Path.join(bucket_dir, source.bucketExtra), 'keep');
110
+ }
98
111
  });
99
112
  }
100
113
  if (options.missingDir) args.other = [Path.join(dir, 'missing')];
@@ -189,16 +202,16 @@ test('sync update guard rejects malformed confirmation values', async () => {
189
202
  );
190
203
  });
191
204
 
192
- test('sync rejects deletion with down-only, two-way, or mixed directions before writes', t => {
205
+ test('sync rejects deletion without exactly one direction before writes', t => {
193
206
  for (const args of [
194
- { down: 'categories', delete: 'categories' },
195
207
  { up: 'categories', down: 'categories', delete: 'categories', dry: true },
196
208
  { up: 'categories', down: 'events', delete: 'categories' },
197
- { up: [], delete: 'categories' }
209
+ { up: [], delete: 'categories' },
210
+ { down: [], delete: 'categories' }
198
211
  ]) {
199
212
  const result = runSync(t, { args });
200
213
  assert.equal(result.status, 1);
201
- assert.match(result.stderr, /Delete mode requires up-only sync/);
214
+ assert.match(result.stderr, /Delete mode requires exactly one sync direction/);
202
215
  assert.deepEqual(result.report.calls, []);
203
216
  }
204
217
  });
@@ -218,6 +231,101 @@ test('sync validates inherited deletion and direction settings after CLI overrid
218
231
  assert.equal(emptyDown.status, 0);
219
232
  });
220
233
 
234
+ test('down-only delete removes one missing local source and its neighbors', t => {
235
+ const result = runSync(t, {
236
+ categories: [{ id: 'keep', title: 'Keep', notes: 'Same' }],
237
+ events: [{ id: 'missing', title: 'Same ID on another type' }],
238
+ existingSources: [
239
+ { path: 'categories/Stale.json', type: 'category', data: { id: 'missing', title: 'Stale', notes: '(External)' } },
240
+ { path: 'categories/Stale-notes.txt', contents: 'Old note\n' },
241
+ { path: 'categories/Keep.json', type: 'category', data: { id: 'keep', title: 'Keep', notes: 'Same' } },
242
+ { path: 'events/Other.json', type: 'event', data: { id: 'missing', title: 'Same ID on another type' } }
243
+ ],
244
+ args: { down: 'categories', delete: 'categories', down_cmd: getSetupMarkerCommand('completed.txt') }
245
+ });
246
+ assert.equal(result.status, 0, result.stderr);
247
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Stale.json')), false);
248
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Stale-notes.txt')), false);
249
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Keep.json')), true);
250
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events/Other.json')), true);
251
+ assert.equal(fs.readFileSync(Path.join(result.dir, 'completed.txt'), 'utf8'), fs.realpathSync(result.dir));
252
+ assert.deepEqual(result.report.requests.filter(req => req.method.startsWith('delete_')), []);
253
+ });
254
+
255
+ test('down-only delete previews local files without changing them or running down_cmd', t => {
256
+ const result = runSync(t, {
257
+ quiet: false,
258
+ existingSources: [
259
+ { path: 'categories/Stale.json', type: 'category', data: { id: 'missing', title: 'Stale' } }
260
+ ],
261
+ args: { down: 'categories', delete: 'categories', dry: true, down_cmd: getSetupMarkerCommand('completed.txt') }
262
+ });
263
+ assert.equal(result.status, 0, result.stderr);
264
+ assert.match(result.stdout, /Would delete local/);
265
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Stale.json')), true);
266
+ assert.equal(fs.existsSync(Path.join(result.dir, 'completed.txt')), false);
267
+ });
268
+
269
+ test('down-only delete removes an orphan Bucket and all its local content', t => {
270
+ const result = runSync(t, {
271
+ includeBuckets: true,
272
+ existingSources: [
273
+ { path: 'buckets/Stale.json', type: 'bucket', data: { id: 'missing', title: 'Stale' },
274
+ bucketData: { old: true }, bucketFiles: { 'old.txt': 'contents' } }
275
+ ],
276
+ args: { down: 'buckets', delete: 'buckets' }
277
+ });
278
+ assert.equal(result.status, 0, result.stderr);
279
+ assert.equal(fs.existsSync(Path.join(result.dir, 'buckets/Stale.json')), false);
280
+ assert.equal(fs.existsSync(Path.join(result.dir, 'buckets/Stale')), false);
281
+ });
282
+
283
+ test('down-only delete preflights all Bucket content before removing any source', t => {
284
+ const result = runSync(t, {
285
+ includeBuckets: true,
286
+ existingSources: [
287
+ { path: 'buckets/First.json', type: 'bucket', data: { id: 'first', title: 'First' }, bucketFiles: {} },
288
+ { path: 'buckets/Second.json', type: 'bucket', data: { id: 'second', title: 'Second' },
289
+ bucketFiles: {}, bucketExtra: 'untracked.txt' }
290
+ ],
291
+ args: { down: 'buckets', delete: 'buckets' }
292
+ });
293
+ assert.equal(result.status, 1);
294
+ assert.match(result.report.errors[0], /Local delete preflight failed/);
295
+ assert.equal(fs.existsSync(Path.join(result.dir, 'buckets/First.json')), true);
296
+ assert.equal(fs.existsSync(Path.join(result.dir, 'buckets/First')), true);
297
+ assert.equal(fs.existsSync(Path.join(result.dir, 'buckets/Second.json')), true);
298
+ });
299
+
300
+ test('down-only delete protects local stock and Marketplace definitions', t => {
301
+ const result = runSync(t, {
302
+ existingSources: [
303
+ { path: 'categories/Stock.json', type: 'category', data: { id: 'stock', title: 'Stock', stock: false } },
304
+ { path: 'categories/Marketplace.json', type: 'category', data: { id: 'marketplace', title: 'Marketplace', marketplace: null } },
305
+ { path: 'categories/Custom.json', type: 'category', data: { id: 'custom', title: 'Custom' } }
306
+ ],
307
+ args: { down: 'categories', delete: 'categories' }
308
+ });
309
+ assert.equal(result.status, 0, result.stderr);
310
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Stock.json')), true);
311
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Marketplace.json')), true);
312
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Custom.json')), false);
313
+ });
314
+
315
+ test('down-only delete skips local removals after a sync-state error', t => {
316
+ const result = runSync(t, {
317
+ state: { sync: { 'category-old': true } },
318
+ failAPI: 'update_global_state',
319
+ existingSources: [
320
+ { path: 'categories/Stale.json', type: 'category', data: { id: 'stale', title: 'Stale' } }
321
+ ],
322
+ args: { down: 'categories', delete: 'categories' }
323
+ });
324
+ assert.equal(result.status, 1);
325
+ assert.match(result.report.warnings[0], /Delete pass skipped/);
326
+ assert.equal(fs.existsSync(Path.join(result.dir, 'categories/Stale.json')), true);
327
+ });
328
+
221
329
  test('sync errors exit nonzero even in quiet mode or a dry run', t => {
222
330
  for (const dry of [false, true]) {
223
331
  const result = runSync(t, { missingDir: true, args: { up: 'categories', dry, quiet: true } });
@@ -227,6 +335,73 @@ test('sync errors exit nonzero even in quiet mode or a dry run', t => {
227
335
  }
228
336
  });
229
337
 
338
+ test('sync ignore-missing skips only absent remote IDs and continues updates', t => {
339
+ const remote = { id: 'present', title: 'Present', notes: 'Remote' };
340
+ const local = { ...remote, notes: 'Local' };
341
+ const missing = { id: 'gone', title: 'Gone', notes: 'Keep this file' };
342
+ const result = runSync(t, {
343
+ categories: [remote],
344
+ existingSources: [
345
+ { path: 'Gone.json', type: 'category', data: missing },
346
+ { path: 'Present.json', type: 'category', data: local }
347
+ ],
348
+ args: { up: 'categories', down: 'categories', delete: false, ignore_missing: true }
349
+ });
350
+
351
+ assert.equal(result.status, 0);
352
+ assert.deepEqual(result.report.warnings, []);
353
+ assert.equal(result.report.calls.includes('update_category'), true);
354
+ assert.equal(fs.existsSync(Path.join(result.dir, 'Gone.json')), true);
355
+ assert.equal(JSON.parse(fs.readFileSync(Path.join(result.dir, 'Gone.json'), 'utf8')).items[0].data.notes, 'Keep this file');
356
+ });
357
+
358
+ test('sync ignore-missing does not suppress malformed source warnings', t => {
359
+ const result = runSync(t, {
360
+ source: { id: 'missing', title: 'Missing' },
361
+ emptyItems: true,
362
+ args: { up: 'categories', ignore_missing: true }
363
+ });
364
+
365
+ assert.equal(result.status, 1);
366
+ assert.match(result.report.warnings[0], /empty, malformed or missing items array/);
367
+ assert.deepEqual(result.report.calls, []);
368
+ });
369
+
370
+ test('sync ignore-missing still validates neighbors of missing sources', t => {
371
+ const result = runSync(t, {
372
+ source: { id: 'missing', title: 'Missing' },
373
+ invalidNeighbor: true,
374
+ args: { up: 'categories', ignore_missing: true }
375
+ });
376
+
377
+ assert.equal(result.status, 1);
378
+ assert.match(result.report.warnings[0], /Neighbor file does not match/);
379
+ assert.deepEqual(result.report.calls, []);
380
+ });
381
+
382
+ test('sync ignore-missing still rejects duplicate missing sources', t => {
383
+ const result = runSync(t, {
384
+ source: { id: 'missing', title: 'Missing' },
385
+ duplicateSource: true,
386
+ args: { up: 'categories', ignore_missing: true }
387
+ });
388
+
389
+ assert.equal(result.status, 1);
390
+ assert.match(result.report.warnings[0], /Duplicate source item found/);
391
+ assert.deepEqual(result.report.calls, []);
392
+ });
393
+
394
+ test('sync ignore-missing can be disabled over a saved setting', t => {
395
+ const result = runSync(t, {
396
+ source: { id: 'missing', title: 'Missing' },
397
+ config: { up: 'categories', ignore_missing: true },
398
+ args: { ignore_missing: false }
399
+ });
400
+
401
+ assert.equal(result.status, 1);
402
+ assert.match(result.report.warnings[0], /Cannot find category in xyOps/);
403
+ });
404
+
230
405
  test('sync sends existing Plugin properties through the sync API path', t => {
231
406
  const remote = { id: 'legacy', title: 'Legacy', type: 'event', command: 'node', cwd: '/opt/legacy', custom_field: { keep: true }, notes: 'Remote' };
232
407
  const local = { ...remote, notes: 'Local' };
@@ -313,6 +488,39 @@ test('sync setup new exports only remote definitions missing from the local tree
313
488
  assert.equal(fs.existsSync(Path.join(result.dir, 'custom', 'layout', 'Renamed-Existing.json')), true);
314
489
  });
315
490
 
491
+ test('sync setup new ignore-missing leaves stale files and exports new definitions', t => {
492
+ const missing = { id: 'gone', title: 'Gone' };
493
+ const added = { id: 'added', title: 'Added Plugin', command: 'node' };
494
+ const result = runSync(t, {
495
+ setup: ['plugins'],
496
+ plugins: [added],
497
+ existingSources: [{ path: 'old/Gone.json', type: 'plugin', data: missing }],
498
+ args: { new: true, ignore_missing: true }
499
+ });
500
+
501
+ assert.equal(result.status, 0);
502
+ assert.deepEqual(result.report.warnings, []);
503
+ assert.equal(fs.existsSync(Path.join(result.dir, 'old', 'Gone.json')), true);
504
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), true);
505
+ });
506
+
507
+ test('sync setup new ignore-missing still rejects duplicate missing sources', t => {
508
+ const missing = { id: 'gone', title: 'Gone' };
509
+ const result = runSync(t, {
510
+ setup: ['plugins'],
511
+ plugins: [{ id: 'added', title: 'Added Plugin', command: 'node' }],
512
+ existingSources: [
513
+ { path: 'old/Gone.json', type: 'plugin', data: missing },
514
+ { path: 'other/Gone.json', type: 'plugin', data: missing }
515
+ ],
516
+ args: { new: true, ignore_missing: true }
517
+ });
518
+
519
+ assert.equal(result.status, 1);
520
+ assert.match(result.stderr, /Duplicate source item found/);
521
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), false);
522
+ });
523
+
316
524
  test('sync setup runs down_cmd after writing files from the setup directory', t => {
317
525
  const marker = 'setup-complete.txt';
318
526
  const result = runSync(t, {
package/test/sync.test.js CHANGED
@@ -510,6 +510,48 @@ test('sync', async t => {
510
510
  assert.ok(fixtures.category && fixtures.plugin && fixtures.event, 'Resources outside the missing fixture remain');
511
511
  assert.deepEqual(fixtures.syncState, deleteSyncState, 'Up-only delete mode tracks the remaining Category sources');
512
512
  });
513
+
514
+ await check('remove disposable remote Event and Plugin for down-only deletion', async () => {
515
+ // The local sources and external property files remain in the isolated
516
+ // sync tree. Delete only these two server fixtures so down-delete has
517
+ // exactly two local candidates and no unrelated remote mutations.
518
+ await call('deleteEvent', { id: eventID });
519
+ await call('deletePlugin', { id: pluginID });
520
+ const fixtures = await getFixtures();
521
+ assert.equal(fixtures.event, undefined);
522
+ assert.equal(fixtures.plugin, undefined);
523
+ assert.ok(fixtures.category, 'Unrelated Category remains in xyOps');
524
+ for (const file of [eventFile, eventScriptFile, pluginFile, pluginScriptFile, categoryFile]) {
525
+ assert.ok(fs.existsSync(file), 'Local fixture remains before down-delete: ' + file);
526
+ }
527
+ });
528
+
529
+ await check('dry down-only delete preserves local Event and Plugin files', () => {
530
+ const output = xy([
531
+ 'sync', syncRoot, '--up', 'false', '--down', 'events,plugins', '--delete', 'events,plugins', '--dry'
532
+ ], { cwd: temp });
533
+ assert.match(output, /Would delete local event/);
534
+ assert.match(output, /Would delete local plugin/);
535
+ for (const file of [eventFile, eventScriptFile, pluginFile, pluginScriptFile, categoryFile]) {
536
+ assert.ok(fs.existsSync(file), 'Dry run preserves local fixture: ' + file);
537
+ }
538
+ });
539
+
540
+ await check('down-only delete removes only missing Event and Plugin files', async () => {
541
+ const output = xy([
542
+ 'sync', syncRoot, '--up', 'false', '--down', 'events,plugins', '--delete', 'events,plugins'
543
+ ], { cwd: temp });
544
+ assert.match(output, /Deleting local event/);
545
+ assert.match(output, /Deleting local plugin/);
546
+ for (const file of [eventFile, eventScriptFile, pluginFile, pluginScriptFile]) {
547
+ assert.ok(!fs.existsSync(file), 'Down-delete removes local fixture: ' + file);
548
+ }
549
+ assert.ok(fs.existsSync(categoryFile), 'Unrelated local Category remains');
550
+ const fixtures = await getFixtures();
551
+ assert.ok(fixtures.category, 'Down-delete preserves unrelated remote Category');
552
+ assert.equal(fixtures.event, undefined);
553
+ assert.equal(fixtures.plugin, undefined);
554
+ });
513
555
  }
514
556
  finally {
515
557
  const cleanupErrors = [];
@@ -23,6 +23,7 @@ test('tickets', async t => {
23
23
  const body = '# Test Ticket\n\n- **Bold item** with `code`\n';
24
24
  let ticketCreated = false;
25
25
  let tagCreated = false;
26
+ let commentAuthor = '';
26
27
 
27
28
  fs.writeFileSync(bodyFile, body);
28
29
  fs.writeFileSync(firstFile, 'First Ticket attachment.\n');
@@ -110,6 +111,8 @@ test('tickets', async t => {
110
111
  const saved = row.changes.filter( change => change.type == 'comment' );
111
112
  assert.equal(saved.length, 1);
112
113
  assert.equal(saved[0].body, comment);
114
+ // The API key can belong to any user, so use the author saved by xyOps.
115
+ commentAuthor = saved[0].username || '(Unknown)';
113
116
  assert.equal(row.status, 'draft');
114
117
  });
115
118
 
@@ -159,7 +162,7 @@ test('tickets', async t => {
159
162
  'TICKET #' + num, 'Number', '#' + num, 'Ticket ID', id, subject,
160
163
  'TICKET BODY', 'Test Ticket', '• Bold item with code',
161
164
  'TICKET FILES', Path.basename(firstFile), Path.basename(secondFile), 'Download a file',
162
- 'Comment by admin', 'Investigation update', '• Checked the logs'
165
+ 'Comment by ' + commentAuthor, 'Investigation update', '• Checked the logs'
163
166
  ]) assert.ok(out.includes(text), text);
164
167
  if (event) {
165
168
  assert.match(out, /TICKET EVENTS/);