@dinoxx/dinox-cli 1.1.1 → 2.0.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.
Files changed (104) hide show
  1. package/README.md +44 -3
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/auth/index.js +1 -2
  4. package/dist/commands/boxes/index.js +37 -13
  5. package/dist/commands/boxes/operations.d.ts +3 -3
  6. package/dist/commands/boxes/operations.js +51 -28
  7. package/dist/commands/info/index.js +30 -5
  8. package/dist/commands/meta/repo.js +2 -1
  9. package/dist/commands/notes/actions.js +20 -37
  10. package/dist/commands/notes/bulk.d.ts +2 -2
  11. package/dist/commands/notes/bulk.js +3 -18
  12. package/dist/commands/notes/contentPatch.d.ts +5 -1
  13. package/dist/commands/notes/contentPatch.js +12 -1
  14. package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
  15. package/dist/commands/notes/contentPatchCommand.js +29 -8
  16. package/dist/commands/notes/descriptors.js +19 -16
  17. package/dist/commands/notes/export.js +3 -0
  18. package/dist/commands/notes/repo.d.ts +9 -0
  19. package/dist/commands/notes/repo.js +65 -15
  20. package/dist/commands/notes/searchOutput.d.ts +2 -1
  21. package/dist/commands/notes/searchOutput.js +2 -1
  22. package/dist/commands/notes/searchSql.js +9 -3
  23. package/dist/commands/prompt/index.d.ts +1 -0
  24. package/dist/commands/prompt/index.js +26 -11
  25. package/dist/commands/savedViews/index.d.ts +7 -0
  26. package/dist/commands/savedViews/index.js +458 -0
  27. package/dist/commands/savedViews/model.d.ts +87 -0
  28. package/dist/commands/savedViews/model.js +306 -0
  29. package/dist/commands/savedViews/repo.d.ts +123 -0
  30. package/dist/commands/savedViews/repo.js +512 -0
  31. package/dist/commands/savedViews/semantics.d.ts +46 -0
  32. package/dist/commands/savedViews/semantics.js +738 -0
  33. package/dist/commands/schema/renderSkills.d.ts +2 -0
  34. package/dist/commands/schema/renderSkills.js +69 -10
  35. package/dist/commands/schema/spec.js +4 -0
  36. package/dist/commands/skills/index.d.ts +54 -0
  37. package/dist/commands/skills/index.js +468 -0
  38. package/dist/commands/storage/index.js +2 -2
  39. package/dist/commands/sync.js +2 -1
  40. package/dist/commands/tags/index.js +30 -11
  41. package/dist/commands/tags/operations.d.ts +3 -3
  42. package/dist/commands/tags/operations.js +45 -20
  43. package/dist/commands/templates/model.d.ts +50 -0
  44. package/dist/commands/templates/model.js +239 -0
  45. package/dist/commands/todo/index.js +3 -3
  46. package/dist/commands/update.d.ts +1 -0
  47. package/dist/commands/update.js +46 -9
  48. package/dist/commands/writeConfirmation.d.ts +13 -0
  49. package/dist/commands/writeConfirmation.js +54 -0
  50. package/dist/config/resolve.js +5 -10
  51. package/dist/config/types.d.ts +3 -6
  52. package/dist/constants/links.d.ts +18 -1
  53. package/dist/constants/links.js +26 -1
  54. package/dist/daemon/client.d.ts +2 -2
  55. package/dist/daemon/client.js +106 -52
  56. package/dist/daemon/index.d.ts +5 -3
  57. package/dist/daemon/index.js +28 -5
  58. package/dist/daemon/lifecycle.d.ts +7 -2
  59. package/dist/daemon/lifecycle.js +53 -13
  60. package/dist/daemon/pid.d.ts +1 -1
  61. package/dist/daemon/pid.js +6 -3
  62. package/dist/dinox.js +9 -1
  63. package/dist/powersync/exclusiveRuntime.d.ts +1 -0
  64. package/dist/powersync/exclusiveRuntime.js +26 -8
  65. package/dist/powersync/runtime.d.ts +3 -2
  66. package/dist/powersync/runtime.js +19 -3
  67. package/dist/powersync/schema/content.d.ts +1 -78
  68. package/dist/powersync/schema/content.js +1 -78
  69. package/dist/powersync/schema/electronCanonical.d.ts +1561 -0
  70. package/dist/powersync/schema/electronCanonical.js +1154 -0
  71. package/dist/powersync/schema/index.d.ts +573 -129
  72. package/dist/powersync/schema/index.js +3 -32
  73. package/dist/powersync/schema/local.d.ts +1 -56
  74. package/dist/powersync/schema/local.js +1 -60
  75. package/dist/powersync/schema/note.d.ts +1 -35
  76. package/dist/powersync/schema/note.js +1 -35
  77. package/dist/powersync/schema/notesExtras.d.ts +1 -62
  78. package/dist/powersync/schema/notesExtras.js +1 -71
  79. package/dist/powersync/schema/projects.d.ts +1 -104
  80. package/dist/powersync/schema/projects.js +1 -104
  81. package/dist/powersync/schema/savedViews.d.ts +1 -0
  82. package/dist/powersync/schema/savedViews.js +1 -0
  83. package/dist/powersync/schema/storage.d.ts +1 -24
  84. package/dist/powersync/schema/storage.js +1 -24
  85. package/dist/powersync/schema/tags.d.ts +1 -37
  86. package/dist/powersync/schema/tags.js +1 -37
  87. package/dist/powersync/syncGate.d.ts +20 -2
  88. package/dist/powersync/uploader.d.ts +16 -2
  89. package/dist/powersync/uploader.js +152 -158
  90. package/dist/skills/check.d.ts +1 -0
  91. package/dist/skills/check.js +538 -70
  92. package/dist/skills/retired.d.ts +60 -0
  93. package/dist/skills/retired.js +58 -0
  94. package/dist/utils/deadline.d.ts +8 -0
  95. package/dist/utils/deadline.js +31 -0
  96. package/dist/utils/dryRun.d.ts +20 -1
  97. package/dist/utils/dryRun.js +22 -3
  98. package/dist/utils/errors.d.ts +2 -0
  99. package/dist/utils/errors.js +2 -0
  100. package/dist/utils/processExit.d.ts +1 -0
  101. package/dist/utils/processExit.js +33 -0
  102. package/dist/utils/updateNotice.d.ts +1 -0
  103. package/dist/utils/updateNotice.js +1 -1
  104. package/package.json +8 -5
@@ -0,0 +1,468 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { SKILLS_INSTALL_AGENTS, SKILLS_INSTALLER_NAME, SKILLS_INSTALLER_VERSION, } from '../../constants/links.js';
6
+ import { RETIRED_SKILLS } from '../../skills/retired.js';
7
+ import { DinoxError } from '../../utils/errors.js';
8
+ import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
9
+ import { registerCommandGroup } from '../descriptor/register.js';
10
+ import { FORMAT_OPTIONS } from '../schema/globalOptions.js';
11
+ const MAX_CAPTURED_INSTALLER_OUTPUT_BYTES = 64 * 1024;
12
+ function isRecord(value) {
13
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+ function defaultLockFilePath() {
16
+ return path.join(homedir(), '.agents', '.skill-lock.json');
17
+ }
18
+ async function defaultReadLockFile(lockFilePath) {
19
+ return readFile(lockFilePath, 'utf8');
20
+ }
21
+ function absentScan(lockFilePath) {
22
+ const skills = RETIRED_SKILLS.map((skill) => ({
23
+ name: skill.name,
24
+ replacement: skill.replacement,
25
+ ...('replacementNote' in skill ? { replacementNote: skill.replacementNote } : {}),
26
+ status: 'absent',
27
+ expected: {
28
+ source: skill.source,
29
+ skillFolderHash: skill.skillFolderHash,
30
+ },
31
+ observed: null,
32
+ mismatches: [],
33
+ }));
34
+ return {
35
+ lockFile: lockFilePath,
36
+ lockFound: false,
37
+ lockVersion: null,
38
+ summary: {
39
+ total: skills.length,
40
+ absent: skills.length,
41
+ safeToRemove: 0,
42
+ conflict: 0,
43
+ },
44
+ skills,
45
+ };
46
+ }
47
+ function invalidLockError(lockFilePath, message, cause) {
48
+ return new DinoxError(`Invalid skills installer lock at ${lockFilePath}: ${message}`, {
49
+ code: 'SKILLS_LOCK_INVALID',
50
+ cause,
51
+ details: { lockFile: lockFilePath },
52
+ suggestion: 'Inspect the lock file without deleting it, then rerun `dino skills doctor --format json`.',
53
+ recoverable: true,
54
+ });
55
+ }
56
+ export async function scanRetiredSkillsLock(lockFilePath = defaultLockFilePath(), readLockFile = defaultReadLockFile) {
57
+ let raw;
58
+ try {
59
+ raw = await readLockFile(lockFilePath);
60
+ }
61
+ catch (error) {
62
+ if (error?.code === 'ENOENT') {
63
+ return absentScan(lockFilePath);
64
+ }
65
+ throw new DinoxError(`Failed to read skills installer lock at ${lockFilePath}`, {
66
+ code: 'SKILLS_LOCK_READ_FAILED',
67
+ cause: error,
68
+ details: { lockFile: lockFilePath },
69
+ suggestion: 'Check file permissions, then rerun `dino skills doctor --format json`.',
70
+ recoverable: true,
71
+ });
72
+ }
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ }
77
+ catch (error) {
78
+ throw invalidLockError(lockFilePath, 'file is not valid JSON', error);
79
+ }
80
+ if (!isRecord(parsed) || !isRecord(parsed.skills)) {
81
+ throw invalidLockError(lockFilePath, 'expected an object with a skills map');
82
+ }
83
+ const entries = parsed.skills;
84
+ const skills = RETIRED_SKILLS.map((skill) => {
85
+ if (!Object.prototype.hasOwnProperty.call(entries, skill.name)) {
86
+ return {
87
+ name: skill.name,
88
+ replacement: skill.replacement,
89
+ ...('replacementNote' in skill ? { replacementNote: skill.replacementNote } : {}),
90
+ status: 'absent',
91
+ expected: {
92
+ source: skill.source,
93
+ skillFolderHash: skill.skillFolderHash,
94
+ },
95
+ observed: null,
96
+ mismatches: [],
97
+ };
98
+ }
99
+ const rawEntry = entries[skill.name];
100
+ const entry = isRecord(rawEntry) ? rawEntry : {};
101
+ const observed = {
102
+ source: typeof entry.source === 'string' ? entry.source : null,
103
+ skillFolderHash: typeof entry.skillFolderHash === 'string' ? entry.skillFolderHash : null,
104
+ };
105
+ const mismatches = [];
106
+ if (observed.source !== skill.source)
107
+ mismatches.push('source');
108
+ if (observed.skillFolderHash !== skill.skillFolderHash)
109
+ mismatches.push('skillFolderHash');
110
+ return {
111
+ name: skill.name,
112
+ replacement: skill.replacement,
113
+ ...('replacementNote' in skill ? { replacementNote: skill.replacementNote } : {}),
114
+ status: mismatches.length === 0 ? 'safe-to-remove' : 'conflict',
115
+ expected: {
116
+ source: skill.source,
117
+ skillFolderHash: skill.skillFolderHash,
118
+ },
119
+ observed,
120
+ mismatches,
121
+ };
122
+ });
123
+ return {
124
+ lockFile: lockFilePath,
125
+ lockFound: true,
126
+ lockVersion: typeof parsed.version === 'number' ? parsed.version : null,
127
+ summary: {
128
+ total: skills.length,
129
+ absent: skills.filter((skill) => skill.status === 'absent').length,
130
+ safeToRemove: skills.filter((skill) => skill.status === 'safe-to-remove').length,
131
+ conflict: skills.filter((skill) => skill.status === 'conflict').length,
132
+ },
133
+ skills,
134
+ };
135
+ }
136
+ function appendBoundedOutput(current, chunk) {
137
+ const currentBytes = Buffer.byteLength(current, 'utf8');
138
+ if (currentBytes >= MAX_CAPTURED_INSTALLER_OUTPUT_BYTES)
139
+ return current;
140
+ const remaining = MAX_CAPTURED_INSTALLER_OUTPUT_BYTES - currentBytes;
141
+ return current + chunk.subarray(0, remaining).toString('utf8');
142
+ }
143
+ export function resolveSkillsRemoveInvocation(skillNames, platform = process.platform) {
144
+ const args = [
145
+ '--yes',
146
+ `${SKILLS_INSTALLER_NAME}@${SKILLS_INSTALLER_VERSION}`,
147
+ 'remove',
148
+ '-g',
149
+ ...SKILLS_INSTALL_AGENTS.flatMap((agent) => ['-a', agent]),
150
+ '--skill',
151
+ ...skillNames,
152
+ '-y',
153
+ ];
154
+ const displayCommand = ['npx', ...args].join(' ');
155
+ if (platform === 'win32') {
156
+ return {
157
+ command: ['npx.cmd', ...args].join(' '),
158
+ args: [],
159
+ shell: true,
160
+ displayCommand,
161
+ };
162
+ }
163
+ return {
164
+ command: 'npx',
165
+ args,
166
+ shell: false,
167
+ displayCommand,
168
+ };
169
+ }
170
+ export function runSkillsRemoval(skillNames) {
171
+ const invocation = resolveSkillsRemoveInvocation(skillNames);
172
+ return new Promise((resolve, reject) => {
173
+ let stdout = '';
174
+ let stderr = '';
175
+ const child = spawn(invocation.command, invocation.args, {
176
+ shell: invocation.shell,
177
+ stdio: ['ignore', 'pipe', 'pipe'],
178
+ });
179
+ child.stdout.on('data', (chunk) => {
180
+ stdout = appendBoundedOutput(stdout, chunk);
181
+ });
182
+ child.stderr.on('data', (chunk) => {
183
+ stderr = appendBoundedOutput(stderr, chunk);
184
+ });
185
+ child.once('error', (error) => {
186
+ reject(new DinoxError(`Failed to start the skills installer: ${error.message}`, {
187
+ code: 'SKILLS_MIGRATION_FAILED',
188
+ cause: error,
189
+ details: {
190
+ command: invocation.displayCommand,
191
+ },
192
+ suggestion: 'Verify that Node.js and npx are available, then rerun `dino skills migrate --confirm --format json`.',
193
+ recoverable: true,
194
+ }));
195
+ });
196
+ child.once('close', (exitCode) => {
197
+ resolve({ invocation, exitCode, stdout, stderr });
198
+ });
199
+ });
200
+ }
201
+ function resolveDependencies(overrides) {
202
+ return {
203
+ getLockFilePath: defaultLockFilePath,
204
+ readLockFile: defaultReadLockFile,
205
+ runRemoval: runSkillsRemoval,
206
+ ...overrides,
207
+ };
208
+ }
209
+ function replacementLabel(skill) {
210
+ if (skill.replacement)
211
+ return skill.replacement;
212
+ return skill.replacementNote ?? 'no direct replacement';
213
+ }
214
+ function printScan(scan) {
215
+ printHumanReadableLine(`skills lock: ${scan.lockFile} (${scan.lockFound ? 'found' : 'not found'})`);
216
+ for (const skill of scan.skills) {
217
+ if (skill.status === 'conflict') {
218
+ printHumanReadableLine(`${skill.name}: conflict; replacement: ${replacementLabel(skill)}; observed source=${skill.observed?.source ?? 'missing'} hash=${skill.observed?.skillFolderHash ?? 'missing'}`);
219
+ continue;
220
+ }
221
+ printHumanReadableLine(`${skill.name}: ${skill.status}; replacement: ${replacementLabel(skill)}`);
222
+ }
223
+ printHumanReadableLine(`summary: safe-to-remove=${scan.summary.safeToRemove} conflict=${scan.summary.conflict} absent=${scan.summary.absent}`);
224
+ }
225
+ function installerPayload(skillNames) {
226
+ return {
227
+ name: SKILLS_INSTALLER_NAME,
228
+ version: SKILLS_INSTALLER_VERSION,
229
+ scope: 'global',
230
+ agents: [...SKILLS_INSTALL_AGENTS],
231
+ command: skillNames.length > 0
232
+ ? resolveSkillsRemoveInvocation(skillNames).displayCommand
233
+ : null,
234
+ };
235
+ }
236
+ function safeSkillNames(scan) {
237
+ return scan.skills
238
+ .filter((skill) => skill.status === 'safe-to-remove')
239
+ .map((skill) => skill.name);
240
+ }
241
+ function conflictSkillNames(scan) {
242
+ return scan.skills
243
+ .filter((skill) => skill.status === 'conflict')
244
+ .map((skill) => skill.name);
245
+ }
246
+ function scanStatus(scan, name) {
247
+ return scan.skills.find((skill) => skill.name === name)?.status ?? null;
248
+ }
249
+ async function rescanForVerification(dependencies, lockFilePath) {
250
+ try {
251
+ return await scanRetiredSkillsLock(lockFilePath, dependencies.readLockFile);
252
+ }
253
+ catch (error) {
254
+ throw new DinoxError('Failed to verify the retired skills migration', {
255
+ code: 'SKILLS_MIGRATION_VERIFICATION_FAILED',
256
+ cause: error,
257
+ details: { lockFile: lockFilePath },
258
+ suggestion: 'Run `dino skills doctor --format json` and inspect the lock before retrying.',
259
+ recoverable: true,
260
+ });
261
+ }
262
+ }
263
+ export function createSkillsCommandGroup(overrides = {}) {
264
+ const dependencies = resolveDependencies(overrides);
265
+ return {
266
+ name: 'skills',
267
+ description: 'Inspect and safely migrate installed Dinox agent skills',
268
+ commands: [
269
+ {
270
+ name: 'doctor',
271
+ description: 'Inspect the skills installer lock for safely removable retired Dinox skills',
272
+ schema: {
273
+ path: 'skills.doctor',
274
+ risk: 'read',
275
+ auth: 'none',
276
+ globalOptions: FORMAT_OPTIONS,
277
+ output: {
278
+ type: 'structured',
279
+ summary: 'Retired skill status from the global skills installer lock',
280
+ fields: ['lockFile', 'lockFound', 'lockVersion', 'summary', 'skills'],
281
+ },
282
+ examples: ['dino skills doctor', 'dino skills doctor --format json'],
283
+ },
284
+ action: async (_context) => {
285
+ const scan = await scanRetiredSkillsLock(dependencies.getLockFilePath(), dependencies.readLockFile);
286
+ if (getRequestedStructuredOutputFormat()) {
287
+ printSuccess(scan);
288
+ return;
289
+ }
290
+ printScan(scan);
291
+ },
292
+ },
293
+ {
294
+ name: 'migrate',
295
+ description: 'Preview or confirm safe removal of retired Dinox skills',
296
+ options: [
297
+ {
298
+ flags: '--dry-run',
299
+ description: 'Preview safe removals without changing installed skills (default)',
300
+ type: 'boolean',
301
+ },
302
+ {
303
+ flags: '--confirm',
304
+ description: 'Execute the reviewed migration using the pinned public skills installer',
305
+ type: 'boolean',
306
+ },
307
+ ],
308
+ schema: {
309
+ path: 'skills.migrate',
310
+ risk: 'high-risk-write',
311
+ auth: 'none',
312
+ globalOptions: FORMAT_OPTIONS,
313
+ output: {
314
+ type: 'structured',
315
+ summary: 'Retired skill migration plan or verified removal result',
316
+ fields: [
317
+ 'mode',
318
+ 'confirmed',
319
+ 'installer',
320
+ 'before',
321
+ 'plannedRemoval',
322
+ 'conflicts',
323
+ 'removed',
324
+ 'after',
325
+ 'verified',
326
+ ],
327
+ },
328
+ examples: [
329
+ 'dino skills migrate --dry-run --format json',
330
+ 'dino skills migrate --confirm --format json',
331
+ ],
332
+ },
333
+ action: async (context) => {
334
+ const explicitDryRun = Boolean(context.options.dryRun);
335
+ const confirmed = Boolean(context.options.confirm);
336
+ if (explicitDryRun && confirmed) {
337
+ throw new DinoxError('--dry-run cannot be used together with --confirm', {
338
+ code: 'USAGE_ERROR',
339
+ suggestion: 'Run `dino skills migrate --dry-run --format json`, review the plan, then rerun with `--confirm`.',
340
+ recoverable: true,
341
+ });
342
+ }
343
+ const lockFilePath = dependencies.getLockFilePath();
344
+ const before = await scanRetiredSkillsLock(lockFilePath, dependencies.readLockFile);
345
+ const plannedRemoval = safeSkillNames(before);
346
+ const conflicts = conflictSkillNames(before);
347
+ const installer = installerPayload(plannedRemoval);
348
+ if (!confirmed) {
349
+ const payload = {
350
+ mode: 'dry-run',
351
+ confirmed: false,
352
+ installer,
353
+ before,
354
+ plannedRemoval,
355
+ conflicts,
356
+ removed: [],
357
+ after: null,
358
+ verified: false,
359
+ };
360
+ if (getRequestedStructuredOutputFormat()) {
361
+ printSuccess(payload);
362
+ return;
363
+ }
364
+ printHumanReadableLine('mode: dry-run');
365
+ printScan(before);
366
+ printHumanReadableLine(`would remove: ${plannedRemoval.join(', ') || 'none'}`);
367
+ printHumanReadableLine('no changes made; use --confirm only after reviewing this plan');
368
+ return;
369
+ }
370
+ if (plannedRemoval.length === 0) {
371
+ const after = await rescanForVerification(dependencies, lockFilePath);
372
+ const payload = {
373
+ mode: 'migrate',
374
+ confirmed: true,
375
+ installer,
376
+ before,
377
+ plannedRemoval,
378
+ conflicts,
379
+ removed: [],
380
+ after,
381
+ verified: true,
382
+ };
383
+ if (getRequestedStructuredOutputFormat()) {
384
+ printSuccess(payload);
385
+ return;
386
+ }
387
+ printHumanReadableLine('mode: migrate');
388
+ printScan(after);
389
+ printHumanReadableLine('removed: none');
390
+ printHumanReadableLine('verified: yes');
391
+ return;
392
+ }
393
+ const preExecution = await scanRetiredSkillsLock(lockFilePath, dependencies.readLockFile);
394
+ const changedCandidates = plannedRemoval.filter((name) => scanStatus(preExecution, name) !== 'safe-to-remove');
395
+ if (changedCandidates.length > 0) {
396
+ throw new DinoxError('The skills installer lock changed before migration execution', {
397
+ code: 'SKILLS_MIGRATION_PRECONDITION_FAILED',
398
+ exitCode: 5,
399
+ details: {
400
+ lockFile: lockFilePath,
401
+ changedCandidates,
402
+ before,
403
+ preExecution,
404
+ },
405
+ suggestion: 'Run `dino skills doctor --format json`, review the new status, then retry the migration.',
406
+ recoverable: true,
407
+ });
408
+ }
409
+ const execution = await dependencies.runRemoval(plannedRemoval);
410
+ const after = await rescanForVerification(dependencies, lockFilePath);
411
+ if (execution.exitCode !== 0) {
412
+ throw new DinoxError(`The skills installer exited with code ${execution.exitCode ?? 'unknown'}`, {
413
+ code: 'SKILLS_MIGRATION_FAILED',
414
+ details: {
415
+ command: execution.invocation.displayCommand,
416
+ exitCode: execution.exitCode,
417
+ ...(execution.stdout.trim() ? { stdout: execution.stdout.trim() } : {}),
418
+ ...(execution.stderr.trim() ? { stderr: execution.stderr.trim() } : {}),
419
+ after,
420
+ },
421
+ suggestion: 'Run `dino skills doctor --format json` before deciding whether to retry.',
422
+ recoverable: true,
423
+ });
424
+ }
425
+ const remainingCandidates = plannedRemoval.filter((name) => scanStatus(after, name) !== 'absent');
426
+ const unexpectedlyRemovedConflicts = conflicts.filter((name) => scanStatus(after, name) === 'absent');
427
+ if (remainingCandidates.length > 0 || unexpectedlyRemovedConflicts.length > 0) {
428
+ throw new DinoxError('Retired skills migration verification failed', {
429
+ code: 'SKILLS_MIGRATION_VERIFICATION_FAILED',
430
+ details: {
431
+ command: execution.invocation.displayCommand,
432
+ remainingCandidates,
433
+ unexpectedlyRemovedConflicts,
434
+ before,
435
+ after,
436
+ },
437
+ suggestion: 'Run `dino skills doctor --format json` and inspect every conflict before taking further action.',
438
+ recoverable: true,
439
+ });
440
+ }
441
+ const payload = {
442
+ mode: 'migrate',
443
+ confirmed: true,
444
+ installer,
445
+ before,
446
+ plannedRemoval,
447
+ conflicts,
448
+ removed: [...plannedRemoval],
449
+ after,
450
+ verified: true,
451
+ };
452
+ if (getRequestedStructuredOutputFormat()) {
453
+ printSuccess(payload);
454
+ return;
455
+ }
456
+ printHumanReadableLine('mode: migrate');
457
+ printHumanReadableLine(`removed: ${plannedRemoval.join(', ')}`);
458
+ printHumanReadableLine(`conflicts preserved: ${conflicts.join(', ') || 'none'}`);
459
+ printHumanReadableLine('verified: yes');
460
+ },
461
+ },
462
+ ],
463
+ };
464
+ }
465
+ export const skillsCommandGroup = createSkillsCommandGroup();
466
+ export function registerSkillsCommands(program, overrides = {}) {
467
+ registerCommandGroup(program, createSkillsCommandGroup(overrides));
468
+ }
@@ -53,7 +53,7 @@ async function runStorageUpload(context) {
53
53
  storageUrl: plan.storageUrl,
54
54
  selectionSource: plan.selectionSource,
55
55
  overwrite: options.overwrite === true,
56
- }));
56
+ }), runtime);
57
57
  return;
58
58
  }
59
59
  const result = await uploadFileToStorage(db, {
@@ -117,7 +117,7 @@ async function runStorageTest(context) {
117
117
  storageUrl: plan.storageUrl,
118
118
  sizeBytes: plan.sizeBytes,
119
119
  selectionSource: plan.selectionSource,
120
- }));
120
+ }), runtime);
121
121
  return;
122
122
  }
123
123
  const result = await testStorageUpload(db, {
@@ -42,7 +42,8 @@ export async function executeSyncCommand(context, overrides = {}) {
42
42
  config,
43
43
  offline: false,
44
44
  timeoutMs: remainingTimeoutMs(),
45
- deadlineAtMs: operationDeadlineAtMs,
45
+ deadlineAtMs,
46
+ operationDeadlineAtMs,
46
47
  waitForInitialSync: false,
47
48
  expectedTokenFingerprint: config.auth.tokenFingerprint ?? null,
48
49
  cleanupFailureMode: 'throw',
@@ -6,6 +6,7 @@ import { printDryRunPreview } from '../../utils/dryRun.js';
6
6
  import { DinoxError } from '../../utils/errors.js';
7
7
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
8
8
  import { commandStale, printStaleCacheWarning, syncBeforeCommand, waitForMutationUpload, withCommandRuntime, } from '../runtime.js';
9
+ import { assertWriteConfirmationProvided, parseExpectedCount } from '../writeConfirmation.js';
9
10
  import { addTag, inspectAddTag, listTags } from './repo.js';
10
11
  import { getTagStats, getTagTree, inspectTagCleanup, mergeTag, moveTag, renameTag, suggestTagMerges, } from './operations.js';
11
12
  const TAG_GLOBAL_OPTIONS = [...FORMAT_OPTIONS, ...OFFLINE_OPTIONS, ...REQUIRE_SYNC_OPTIONS, ...SYNC_TIMEOUT_OPTIONS];
@@ -67,7 +68,7 @@ async function runTagAdd(context) {
67
68
  userId: identity.userId,
68
69
  emoji,
69
70
  })),
70
- }));
71
+ }), runtime);
71
72
  return;
72
73
  }
73
74
  const result = await addTag(runtime.db, {
@@ -114,7 +115,7 @@ async function runTagRename(context) {
114
115
  summary: `Rename tag "${result.fromPath}" to "${result.toPath}".`,
115
116
  inputs: { path, new_name: newName },
116
117
  changes: { updated_tags: result.updatedTags, updated_notes: result.updatedNotes },
117
- }));
118
+ }), runtime);
118
119
  return;
119
120
  }
120
121
  await waitForMutationUpload(runtime);
@@ -139,7 +140,7 @@ async function runTagMove(context) {
139
140
  summary: `Move tag "${result.fromPath}" to "${result.toPath}".`,
140
141
  inputs: { path, to },
141
142
  changes: { updated_tags: result.updatedTags, updated_notes: result.updatedNotes },
142
- }));
143
+ }), runtime);
143
144
  return;
144
145
  }
145
146
  await waitForMutationUpload(runtime);
@@ -149,9 +150,17 @@ async function runTagMove(context) {
149
150
  async function runTagMerge(context) {
150
151
  const from = requireArg(context.args.from, 'Source tag path is required');
151
152
  const to = requireArg(context.args.to, 'Target tag path is required');
153
+ const dryRun = Boolean(context.options.dryRun);
154
+ const expectedCount = parseExpectedCount(context.options.expectedCount);
155
+ const writeOptions = {
156
+ dryRun,
157
+ confirm: Boolean(context.options.confirm),
158
+ expectedCount,
159
+ };
160
+ assertWriteConfirmationProvided('Tag merge', writeOptions);
152
161
  await withTagWriteRuntime(context, async ({ runtime, userId }) => {
153
- const result = await mergeTag(runtime.db, { from, to, userId }, { dryRun: Boolean(context.options.dryRun) });
154
- if (Boolean(context.options.dryRun)) {
162
+ const result = await mergeTag(runtime.db, { from, to, userId }, writeOptions);
163
+ if (dryRun) {
155
164
  printDryRunPreview(buildTagMutationDryRunPreview({
156
165
  config: runtime.config,
157
166
  offline: runtime.offline,
@@ -159,12 +168,15 @@ async function runTagMerge(context) {
159
168
  action: 'merge_tag',
160
169
  risk: 'high-risk-write',
161
170
  summary: `Merge tag subtree "${result.fromPath}" into "${result.toPath}".`,
162
- inputs: { from, to },
171
+ inputs: { from, to, expected_count: expectedCount },
163
172
  changes: {
173
+ affected_count: result.affectedCount,
174
+ soft_deleted_tag_count: result.softDeletedTagIds.length,
175
+ updated_note_count: result.updatedNotes.length,
164
176
  soft_deleted_tag_ids: result.softDeletedTagIds,
165
177
  updated_notes: result.updatedNotes,
166
178
  },
167
- }));
179
+ }), runtime);
168
180
  return;
169
181
  }
170
182
  await waitForMutationUpload(runtime);
@@ -193,7 +205,7 @@ async function runTagCleanup(context) {
193
205
  summary: `Inspect tag cleanup candidates (${result.issues.length} issues).`,
194
206
  inputs: {},
195
207
  targets: result.issues,
196
- }));
208
+ }), runtime);
197
209
  return;
198
210
  }
199
211
  printSuccess({ stale: commandStale(runtime), ...result });
@@ -328,7 +340,11 @@ export const tagCommandGroup = {
328
340
  { syntax: '<from>', description: 'Source tag path' },
329
341
  { syntax: '<to>', description: 'Target tag path' },
330
342
  ],
331
- options: [{ flags: '--dry-run', description: 'Preview the write without executing it' }],
343
+ options: [
344
+ { flags: '--expected-count <n>', description: 'Required for real writes; must equal source tags plus notes affected', type: 'number' },
345
+ { flags: '--confirm', description: 'Required for real writes after reviewing a dry run', type: 'boolean' },
346
+ { flags: '--dry-run', description: 'Preview the write without executing it', type: 'boolean' },
347
+ ],
332
348
  schema: {
333
349
  path: 'tag.merge',
334
350
  risk: 'high-risk-write',
@@ -337,9 +353,12 @@ export const tagCommandGroup = {
337
353
  output: {
338
354
  type: 'structured',
339
355
  summary: 'Tag merge result',
340
- fields: ['fromId', 'toId', 'fromPath', 'toPath', 'softDeletedTagIds', 'updatedNotes', 'stale'],
356
+ fields: ['fromId', 'toId', 'fromPath', 'toPath', 'softDeletedTagIds', 'updatedNotes', 'affectedCount', 'stale'],
341
357
  },
342
- examples: ['dino tag merge "ai" "AI" --format json --dry-run'],
358
+ examples: [
359
+ 'dino tag merge "ai" "AI" --format json --dry-run',
360
+ 'dino tag merge "ai" "AI" --expected-count 4 --confirm --format json',
361
+ ],
343
362
  },
344
363
  action: runTagMerge,
345
364
  },
@@ -1,4 +1,5 @@
1
1
  import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import { type ConfirmedWriteOptions } from '../writeConfirmation.js';
2
3
  import type { TagListItem } from './model.js';
3
4
  export type TagTreeNode = TagListItem & {
4
5
  isActual: boolean;
@@ -63,6 +64,7 @@ export type TagMergeResult = {
63
64
  before: string[];
64
65
  after: string[];
65
66
  }>;
67
+ affectedCount: number;
66
68
  };
67
69
  export type TagSuggestResult = {
68
70
  suggestions: Array<{
@@ -98,8 +100,6 @@ export declare function renameTag(db: AbstractPowerSyncDatabase, input: TagRenam
98
100
  export declare function moveTag(db: AbstractPowerSyncDatabase, input: TagMoveInput, options?: {
99
101
  dryRun?: boolean;
100
102
  }): Promise<TagRenameMoveResult>;
101
- export declare function mergeTag(db: AbstractPowerSyncDatabase, input: TagMergeInput, options?: {
102
- dryRun?: boolean;
103
- }): Promise<TagMergeResult>;
103
+ export declare function mergeTag(db: AbstractPowerSyncDatabase, input: TagMergeInput, options?: ConfirmedWriteOptions): Promise<TagMergeResult>;
104
104
  export declare function inspectTagCleanup(db: AbstractPowerSyncDatabase, input?: TagReadInput): Promise<TagCleanupResult>;
105
105
  export declare function suggestTagMerges(db: AbstractPowerSyncDatabase, input?: TagReadInput): Promise<TagSuggestResult>;