@onpeek/nuvio 1.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 (48) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +83 -0
  3. package/assets/nuvio-wordmark-dark.png +0 -0
  4. package/assets/nuvio-wordmark-light.png +0 -0
  5. package/dist/cli/confirmation.js +45 -0
  6. package/dist/cli/registry.js +14 -0
  7. package/dist/commands/account.js +66 -0
  8. package/dist/commands/addons.js +84 -0
  9. package/dist/commands/collections.js +147 -0
  10. package/dist/commands/helpers.js +186 -0
  11. package/dist/commands/index.js +26 -0
  12. package/dist/commands/library.js +105 -0
  13. package/dist/commands/plugins.js +58 -0
  14. package/dist/commands/profiles.js +126 -0
  15. package/dist/commands/providers.js +57 -0
  16. package/dist/commands/sessions.js +46 -0
  17. package/dist/commands/settings.js +43 -0
  18. package/dist/commands/trackers.js +84 -0
  19. package/dist/commands/undo.js +165 -0
  20. package/dist/config.js +60 -0
  21. package/dist/index.js +195 -0
  22. package/dist/mask.js +33 -0
  23. package/dist/nuvio/auth.js +123 -0
  24. package/dist/nuvio/call-context.js +20 -0
  25. package/dist/nuvio/client.js +149 -0
  26. package/dist/nuvio/errors.js +43 -0
  27. package/dist/nuvio/keys.js +23 -0
  28. package/dist/nuvio/ops/account.js +91 -0
  29. package/dist/nuvio/ops/addons.js +126 -0
  30. package/dist/nuvio/ops/collections.js +158 -0
  31. package/dist/nuvio/ops/library.js +139 -0
  32. package/dist/nuvio/ops/plan.js +728 -0
  33. package/dist/nuvio/ops/plugins.js +127 -0
  34. package/dist/nuvio/ops/profiles.js +383 -0
  35. package/dist/nuvio/ops/providers.js +103 -0
  36. package/dist/nuvio/ops/readers.js +57 -0
  37. package/dist/nuvio/ops/sessions.js +36 -0
  38. package/dist/nuvio/ops/settings.js +143 -0
  39. package/dist/nuvio/ops/trackers.js +80 -0
  40. package/dist/nuvio/ops/transitions.js +100 -0
  41. package/dist/nuvio/paths.js +74 -0
  42. package/dist/nuvio/safe-fetch.js +131 -0
  43. package/dist/nuvio/schemas.js +143 -0
  44. package/dist/nuvio/snapshots.js +664 -0
  45. package/dist/nuvio/types.js +1 -0
  46. package/dist/version.js +12 -0
  47. package/package.json +62 -0
  48. package/skills/nuvio/SKILL.md +19 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wiktorekdev
4
+ Copyright (c) 2026 onpeek
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ <div align="center">
2
+
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="assets/nuvio-wordmark-dark.png" />
5
+ <source media="(prefers-color-scheme: light)" srcset="assets/nuvio-wordmark-light.png" />
6
+ <img src="assets/nuvio-wordmark-light.png" alt="Nuvio CLI" width="400" />
7
+ </picture>
8
+
9
+ <p>
10
+ <strong>Manage your Nuvio account from a terminal or coding agent.</strong><br />
11
+ Profiles · Addons · Plugins · Settings · Collections · Library · Providers · Trackers
12
+ </p>
13
+
14
+ <p>
15
+ <a href="https://github.com/onpeek/nuvio/actions/workflows/ci.yml"><img src="https://github.com/onpeek/nuvio/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
16
+ <img src="https://img.shields.io/badge/node-%3E%3D20-339933?style=flat&logo=node.js&logoColor=white" alt="Node.js 20 or newer" />
17
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/onpeek/nuvio?style=flat&color=6366f1" alt="MIT license" /></a>
18
+ </p>
19
+
20
+ <p><em>Unofficial community project. Not affiliated with or endorsed by Nuvio.</em></p>
21
+
22
+ </div>
23
+
24
+ ---
25
+
26
+ ## Features
27
+
28
+ - 64 commands, discovered on demand with <code>nuvio commands</code> and <code>nuvio help</code>
29
+ - Manage profiles, addons, plugins, settings, collections, library, watch history, providers, and trackers
30
+ - Preview changes, keep snapshots, and undo reversible writes
31
+ - Confirm irreversible operations with a short-lived token
32
+ - Save large results to a file instead of filling agent context
33
+ - Requires Node.js 20 or newer.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ git clone https://github.com/onpeek/nuvio.git
39
+ cd nuvio
40
+ npm ci
41
+ npm run build
42
+ npm link
43
+ npx skills add onpeek/nuvio --skill nuvio
44
+ ```
45
+
46
+ Set <code>NUVIO_EMAIL</code> and <code>NUVIO_PASSWORD</code>, or set
47
+ <code>NUVIO_REFRESH_TOKEN</code>. The CLI stores its session locally. Use
48
+ <code>NUVIO_BACKEND_URL</code> for a self-hosted backend.
49
+
50
+ ## Use
51
+
52
+ ```bash
53
+ nuvio commands profile
54
+ nuvio help update-settings
55
+ nuvio list-profiles
56
+ nuvio list-addons --profile-id 1
57
+ ```
58
+
59
+ Pass simple values as flags. For arrays, objects, PINs, or provider keys, use
60
+ <code>--input FILE</code> or <code>--input -</code> with JSON. Keep credentials
61
+ and secrets out of shell arguments.
62
+
63
+ Preview a change, then apply it:
64
+
65
+ ```bash
66
+ nuvio update-settings --input settings-edit.json --dry-run
67
+ nuvio update-settings --input settings-edit.json
68
+ nuvio undo --snapshot-id SNAPSHOT_ID
69
+ ```
70
+
71
+ Normal stdout is capped at 12,000 bytes. Use <code>--output FILE</code> to save
72
+ a full result. See [SECURITY.md](SECURITY.md) for local data and confirmation details.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ npm ci
78
+ npm run typecheck
79
+ npm run lint
80
+ npm test
81
+ ```
82
+
83
+ [MIT](LICENSE)
Binary file
Binary file
@@ -0,0 +1,45 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ const TTL_MS = 5 * 60_000;
5
+ function fingerprint(command, args) {
6
+ const clean = Object.fromEntries(Object.entries(args)
7
+ .filter(([key]) => !['confirmation_token', 'confirm', 'dry_run'].includes(key))
8
+ .sort(([a], [b]) => a.localeCompare(b)));
9
+ return createHash('sha256')
10
+ .update(JSON.stringify({ command, args: clean }))
11
+ .digest('hex');
12
+ }
13
+ function pendingPath(cfg, token) {
14
+ const id = createHash('sha256').update(token).digest('hex');
15
+ return join(dirname(cfg.sessionFile), 'confirm-' + id + '.json');
16
+ }
17
+ export function prepareConfirmation(cfg, command, args) {
18
+ const token = randomBytes(24).toString('base64url');
19
+ const expires = Date.now() + TTL_MS;
20
+ const file = pendingPath(cfg, token);
21
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
22
+ writeFileSync(file, JSON.stringify({
23
+ command,
24
+ fingerprint: fingerprint(command, args),
25
+ expires_at: expires,
26
+ }), { flag: 'wx', mode: 0o600 });
27
+ return { confirmation_token: token, expires_at: new Date(expires).toISOString() };
28
+ }
29
+ export function verifyConfirmation(cfg, command, args, token) {
30
+ if (typeof token !== 'string' || token.length < 20)
31
+ throw new Error('Missing confirmation token. Run the command once without it to preview.');
32
+ const file = pendingPath(cfg, token);
33
+ let pending;
34
+ try {
35
+ pending = JSON.parse(readFileSync(file, 'utf8'));
36
+ unlinkSync(file);
37
+ }
38
+ catch {
39
+ throw new Error('Confirmation token not found or already used. Preview the command again.');
40
+ }
41
+ if (pending.command !== command || pending.fingerprint !== fingerprint(command, args))
42
+ throw new Error('Confirmation token does not match the command arguments.');
43
+ if (pending.expires_at <= Date.now())
44
+ throw new Error('Confirmation token expired. Preview the command again.');
45
+ }
@@ -0,0 +1,14 @@
1
+ export class CommandRegistry {
2
+ commands = new Map();
3
+ register(command) {
4
+ if (this.commands.has(command.name))
5
+ throw new Error(`Duplicate command: ${command.name}`);
6
+ this.commands.set(command.name, command);
7
+ }
8
+ get(name) {
9
+ return this.commands.get(name);
10
+ }
11
+ list() {
12
+ return [...this.commands.values()];
13
+ }
14
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ import { defineMutation, defineRead } from './helpers.js';
3
+ import * as account from '../nuvio/ops/account.js';
4
+ export function registerAccountCommands(registry, client, cfg) {
5
+ defineRead(registry, client, cfg, {
6
+ name: 'nuvio_whoami',
7
+ title: 'Get current account',
8
+ description: 'Return the signed-in Nuvio account and the backend URL.',
9
+ risk: 'read',
10
+ schema: {},
11
+ handler: async () => ({ backend_url: client.backendUrl, account: await account.getAccount(client) }),
12
+ });
13
+ defineRead(registry, client, cfg, {
14
+ name: 'nuvio_sync_overview',
15
+ title: 'Get data overview',
16
+ description: 'Summary counts of profiles, addons, plugins, library items, watch progress and history.',
17
+ risk: 'read',
18
+ schema: {},
19
+ handler: () => account.getSyncOverview(client),
20
+ });
21
+ defineRead(registry, client, cfg, {
22
+ name: 'nuvio_list_avatars',
23
+ title: 'List avatars',
24
+ description: 'List avatar catalog ids available for profile avatars.',
25
+ risk: 'read',
26
+ schema: {},
27
+ handler: () => account.listAvatars(client),
28
+ });
29
+ defineRead(registry, client, cfg, {
30
+ name: 'nuvio_health',
31
+ title: 'Backend health check',
32
+ description: 'Ping the Nuvio backend database.',
33
+ risk: 'read',
34
+ schema: {},
35
+ handler: () => account.health(client),
36
+ });
37
+ defineRead(registry, client, cfg, {
38
+ name: 'nuvio_export_backup',
39
+ title: 'Export account backup',
40
+ description: 'Export account data as a JSON backup (profiles, addons, plugins, library, progress, history, settings, ' +
41
+ 'collections). Credentials and tokens are excluded server-side. With no scope this is a full backup; pass ' +
42
+ 'scope/profile_ids/platforms to narrow it.',
43
+ risk: 'read',
44
+ schema: {
45
+ scope: z.array(z.string()).optional().describe('Sections to include (e.g. settings, addons)'),
46
+ profile_ids: z.array(z.number().int().min(1).max(6)).optional().describe('Only these profiles'),
47
+ platforms: z.array(z.string()).optional().describe('Only these setting platforms'),
48
+ },
49
+ handler: (args) => account.exportBackup(client, {
50
+ scope: args.scope,
51
+ profile_ids: args.profile_ids,
52
+ platforms: args.platforms,
53
+ }),
54
+ });
55
+ defineMutation(registry, client, cfg, {
56
+ name: 'nuvio_restore_backup',
57
+ title: 'Restore account backup',
58
+ description: 'Replace account data from a backup produced by nuvio_export_backup. Only available on self-hosted backends that expose sync_restore_account_backup. High risk: overwrites profiles/addons/library/settings.',
59
+ risk: 'destructive',
60
+ resource: { kind: 'profiles' },
61
+ reversible: false,
62
+ note: 'restore replaces wide account state; revert is not attempted',
63
+ schema: { backup: z.record(z.string(), z.unknown()).describe('Backup JSON from nuvio_export_backup') },
64
+ handler: (args, ctx) => account.restoreBackup(client, args.backup, ctx.apply),
65
+ });
66
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from 'zod';
2
+ import { fetchJsonFromPublicUrl } from '../nuvio/safe-fetch.js';
3
+ import { defineMutation, defineRead } from './helpers.js';
4
+ import { addonAddShape, addonRemoveShape, addonReorderShape, addonUpdateShape, assertTarget, profile, } from '../nuvio/schemas.js';
5
+ import * as addons from '../nuvio/ops/addons.js';
6
+ export function registerAddonCommands(registry, client, cfg) {
7
+ const originId = cfg.originClientId;
8
+ defineRead(registry, client, cfg, {
9
+ name: 'nuvio_list_addons',
10
+ title: 'List addons',
11
+ description: 'List the addons installed on a profile (url, name, enabled, sort_order).',
12
+ risk: 'read',
13
+ schema: { profile_id: profile },
14
+ handler: (args) => addons.listAddons(client, args.profile_id),
15
+ });
16
+ defineRead(registry, client, cfg, {
17
+ name: 'nuvio_inspect_addon',
18
+ title: 'Inspect an addon manifest',
19
+ description: 'Fetch and summarise a Stremio/Nuvio addon manifest (name, version, resources, catalog types) before installing.',
20
+ risk: 'read',
21
+ schema: { url: z.url() },
22
+ handler: async (args) => {
23
+ const manifest = (await fetchJsonFromPublicUrl(args.url));
24
+ return {
25
+ id: manifest.id,
26
+ name: manifest.name,
27
+ version: manifest.version,
28
+ description: manifest.description,
29
+ types: manifest.types,
30
+ resources: manifest.resources,
31
+ catalogs: Array.isArray(manifest.catalogs)
32
+ ? manifest.catalogs.map((c) => ({
33
+ type: c.type,
34
+ id: c.id,
35
+ name: c.name,
36
+ }))
37
+ : [],
38
+ };
39
+ },
40
+ });
41
+ defineMutation(registry, client, cfg, {
42
+ name: 'nuvio_add_addon',
43
+ title: 'Install an addon',
44
+ description: 'Install an addon on a profile by manifest URL. The current addon list is read and preserved; only the new addon is appended.',
45
+ risk: 'write',
46
+ resource: (args) => ({ kind: 'addons', profile_id: args.profile_id }),
47
+ schema: addonAddShape,
48
+ handler: (args, ctx) => addons.addAddon(client, args.profile_id, args, originId, ctx.apply),
49
+ });
50
+ defineMutation(registry, client, cfg, {
51
+ name: 'nuvio_update_addon',
52
+ title: 'Update an addon',
53
+ description: 'Change an addon name, enabled flag or sort order. Identify it by url or table id.',
54
+ risk: 'write',
55
+ resource: (args) => ({ kind: 'addons', profile_id: args.profile_id }),
56
+ schema: addonUpdateShape,
57
+ handler: (args, ctx) => {
58
+ assertTarget('nuvio_update_addon', args);
59
+ const { profile_id, url, id, ...changes } = args;
60
+ return addons.updateAddon(client, profile_id, { url, id }, changes, originId, ctx.apply);
61
+ },
62
+ });
63
+ defineMutation(registry, client, cfg, {
64
+ name: 'nuvio_reorder_addons',
65
+ title: 'Reorder addons',
66
+ description: "Set the display order of a profile's addons. Provide every installed addon URL exactly once, in the desired order.",
67
+ risk: 'write',
68
+ resource: (args) => ({ kind: 'addons', profile_id: args.profile_id }),
69
+ schema: addonReorderShape,
70
+ handler: (args, ctx) => addons.reorderAddons(client, args.profile_id, args.ordered_urls, originId, ctx.apply),
71
+ });
72
+ defineMutation(registry, client, cfg, {
73
+ name: 'nuvio_remove_addon',
74
+ title: 'Remove an addon',
75
+ description: 'Uninstall one addon from a profile.',
76
+ risk: 'destructive',
77
+ resource: (args) => ({ kind: 'addons', profile_id: args.profile_id }),
78
+ schema: addonRemoveShape,
79
+ handler: (args, ctx) => {
80
+ assertTarget('nuvio_remove_addon', args);
81
+ return addons.removeAddon(client, args.profile_id, { url: args.url, id: args.id }, originId, ctx.apply);
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,147 @@
1
+ import { z } from 'zod';
2
+ import { defineMutation, defineRead } from './helpers.js';
3
+ import * as collections from '../nuvio/ops/collections.js';
4
+ const sourceSchema = z.object({
5
+ addonId: z.string(),
6
+ type: z.string(),
7
+ catalogId: z.string(),
8
+ });
9
+ const folderSchema = z.object({
10
+ id: z.string(),
11
+ title: z.string(),
12
+ coverImageUrl: z.string().optional(),
13
+ coverEmoji: z.string().optional(),
14
+ tileShape: z.enum(['POSTER', 'LANDSCAPE', 'SQUARE']).optional(),
15
+ hideTitle: z.boolean().optional(),
16
+ catalogSources: z.array(sourceSchema).optional(),
17
+ });
18
+ const collectionSchema = z.object({
19
+ id: z.string(),
20
+ title: z.string(),
21
+ backdropImageUrl: z.string().optional(),
22
+ pinToTop: z.boolean().optional(),
23
+ viewMode: z.enum(['TABBED_GRID', 'ROWS', 'FOLLOW_LAYOUT']).optional(),
24
+ showAllTab: z.boolean().optional(),
25
+ folders: z.array(folderSchema).optional(),
26
+ });
27
+ const normalizeFolder = (folder) => ({ ...folder, catalogSources: folder.catalogSources ?? [] });
28
+ const normalizeCollection = (collection) => ({
29
+ ...collection,
30
+ folders: (collection.folders ?? []).map(normalizeFolder),
31
+ });
32
+ export function registerCollectionCommands(registry, client, cfg) {
33
+ const originId = cfg.originClientId;
34
+ const profile = z.number().int().min(1).max(6).default(1);
35
+ const resource = (args) => ({ kind: 'collections', profile_id: args.profile_id });
36
+ defineRead(registry, client, cfg, {
37
+ name: 'nuvio_list_collections',
38
+ title: 'List collections',
39
+ description: "List a profile's custom collections (titles, view mode, folders, catalog sources).",
40
+ risk: 'read',
41
+ schema: { profile_id: profile },
42
+ handler: (args) => collections.getCollections(client, args.profile_id),
43
+ });
44
+ defineMutation(registry, client, cfg, {
45
+ name: 'nuvio_create_collection',
46
+ title: 'Create a collection',
47
+ description: 'Add a new collection to a profile.',
48
+ risk: 'write',
49
+ resource,
50
+ schema: { profile_id: profile, collection: collectionSchema },
51
+ handler: (args, ctx) => collections.createCollection(client, args.profile_id, normalizeCollection(args.collection), originId, ctx.apply),
52
+ });
53
+ defineMutation(registry, client, cfg, {
54
+ name: 'nuvio_update_collection',
55
+ title: 'Update a collection',
56
+ description: "Edit a collection's title, view mode, pin state or replace its folder list.",
57
+ risk: 'write',
58
+ resource,
59
+ schema: {
60
+ profile_id: profile,
61
+ collection_id: z.string(),
62
+ changes: collectionSchema.omit({ id: true }).partial(),
63
+ },
64
+ handler: (args, ctx) => {
65
+ const { folders, ...rest } = args.changes;
66
+ const changes = folders ? { ...rest, folders: folders.map(normalizeFolder) } : rest;
67
+ return collections.updateCollection(client, args.profile_id, args.collection_id, changes, originId, ctx.apply);
68
+ },
69
+ });
70
+ defineMutation(registry, client, cfg, {
71
+ name: 'nuvio_delete_collection',
72
+ title: 'Delete a collection',
73
+ description: 'Remove a collection from a profile.',
74
+ risk: 'destructive',
75
+ resource,
76
+ schema: { profile_id: profile, collection_id: z.string() },
77
+ handler: (args, ctx) => collections.deleteCollection(client, args.profile_id, args.collection_id, originId, ctx.apply),
78
+ });
79
+ defineMutation(registry, client, cfg, {
80
+ name: 'nuvio_add_collection_folder',
81
+ title: 'Add a folder to a collection',
82
+ description: 'Append a folder (with catalog sources) to an existing collection.',
83
+ risk: 'write',
84
+ resource,
85
+ schema: { profile_id: profile, collection_id: z.string(), folder: folderSchema },
86
+ handler: (args, ctx) => collections.addFolder(client, args.profile_id, args.collection_id, normalizeFolder(args.folder), originId, ctx.apply),
87
+ });
88
+ defineMutation(registry, client, cfg, {
89
+ name: 'nuvio_update_collection_folder',
90
+ title: 'Update a collection folder',
91
+ description: "Edit a folder's title, cover, tile shape or catalog sources.",
92
+ risk: 'write',
93
+ resource,
94
+ schema: {
95
+ profile_id: profile,
96
+ collection_id: z.string(),
97
+ folder_id: z.string(),
98
+ changes: folderSchema.omit({ id: true }).partial(),
99
+ },
100
+ handler: (args, ctx) => collections.updateFolder(client, args.profile_id, args.collection_id, args.folder_id, args.changes, originId, ctx.apply),
101
+ });
102
+ defineMutation(registry, client, cfg, {
103
+ name: 'nuvio_remove_collection_folder',
104
+ title: 'Remove a collection folder',
105
+ description: 'Remove a folder from a collection.',
106
+ risk: 'destructive',
107
+ resource,
108
+ schema: { profile_id: profile, collection_id: z.string(), folder_id: z.string() },
109
+ handler: (args, ctx) => collections.removeFolder(client, args.profile_id, args.collection_id, args.folder_id, originId, ctx.apply),
110
+ });
111
+ defineMutation(registry, client, cfg, {
112
+ name: 'nuvio_reorder_collections',
113
+ title: 'Reorder collections',
114
+ description: 'Set collection order. Provide every collection id exactly once, in the desired order.',
115
+ risk: 'write',
116
+ resource,
117
+ schema: { profile_id: profile, ordered_ids: z.array(z.string()).min(1) },
118
+ handler: (args, ctx) => collections.reorderCollections(client, args.profile_id, args.ordered_ids, originId, ctx.apply),
119
+ });
120
+ defineMutation(registry, client, cfg, {
121
+ name: 'nuvio_duplicate_collection',
122
+ title: 'Duplicate a collection',
123
+ description: 'Copy a collection (including folders and catalog sources) under a new id, placed right after the source.',
124
+ risk: 'write',
125
+ resource,
126
+ schema: {
127
+ profile_id: profile,
128
+ collection_id: z.string(),
129
+ new_id: z.string(),
130
+ new_title: z.string().optional(),
131
+ },
132
+ handler: (args, ctx) => collections.duplicateCollection(client, args.profile_id, args.collection_id, args.new_id, args.new_title, originId, ctx.apply),
133
+ });
134
+ defineMutation(registry, client, cfg, {
135
+ name: 'nuvio_reorder_collection_folders',
136
+ title: 'Reorder folders in a collection',
137
+ description: 'Set folder order. Provide every folder id in the collection exactly once, in the desired order.',
138
+ risk: 'write',
139
+ resource,
140
+ schema: {
141
+ profile_id: profile,
142
+ collection_id: z.string(),
143
+ ordered_folder_ids: z.array(z.string()).min(1),
144
+ },
145
+ handler: (args, ctx) => collections.reorderCollectionFolders(client, args.profile_id, args.collection_id, args.ordered_folder_ids, originId, ctx.apply),
146
+ });
147
+ }
@@ -0,0 +1,186 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { prepareConfirmation, verifyConfirmation } from '../cli/confirmation.js';
5
+ import { withCallCache } from '../nuvio/call-context.js';
6
+ import { capture, captureComposite, readResource, removeSnapshot, restoreResource, } from '../nuvio/snapshots.js';
7
+ import { applyPlan } from '../nuvio/ops/plan.js';
8
+ import { maskDeep } from '../mask.js';
9
+ const AUDIT_MAX_BYTES = 5 * 1024 * 1024;
10
+ function audit(cfg, entry) {
11
+ try {
12
+ mkdirSync(dirname(cfg.auditFile), { recursive: true, mode: 0o700 });
13
+ try {
14
+ if (statSync(cfg.auditFile).size > AUDIT_MAX_BYTES)
15
+ renameSync(cfg.auditFile, cfg.auditFile + '.1');
16
+ }
17
+ catch {
18
+ /* first write */
19
+ }
20
+ appendFileSync(cfg.auditFile, JSON.stringify({ ts: new Date().toISOString(), ...maskDeep(entry) }) +
21
+ '\n', { mode: 0o600 });
22
+ }
23
+ catch {
24
+ /* best effort */
25
+ }
26
+ }
27
+ function ctx(client, cfg, apply, dryRun, before) {
28
+ return { client, config: cfg, apply, dryRun, before, read: (ref) => readResource(client, ref) };
29
+ }
30
+ export function defineRead(registry, client, cfg, spec) {
31
+ registry.register({
32
+ name: spec.name,
33
+ title: spec.title,
34
+ description: spec.description,
35
+ risk: spec.risk,
36
+ schema: z.object(spec.schema),
37
+ run: async (raw) => withCallCache(async () => maskDeep(await spec.handler(raw, ctx(client, cfg, true, false)))),
38
+ });
39
+ }
40
+ export function defineMutation(registry, client, cfg, spec) {
41
+ const reversible = spec.reversible ?? true;
42
+ const schema = z.object({
43
+ ...spec.schema,
44
+ dry_run: z.boolean().optional(),
45
+ ...(!reversible
46
+ ? { confirmation_token: z.string().optional() }
47
+ : spec.risk === 'destructive'
48
+ ? { confirm: z.boolean().optional() }
49
+ : {}),
50
+ });
51
+ registry.register({
52
+ name: spec.name,
53
+ title: spec.title,
54
+ description: spec.description,
55
+ risk: spec.risk,
56
+ schema,
57
+ run: async (raw) => withCallCache(async () => {
58
+ const args = raw;
59
+ const dryRun = raw.dry_run === true;
60
+ const apply = !dryRun &&
61
+ (reversible
62
+ ? spec.risk !== 'destructive' || raw.confirm === true
63
+ : typeof raw.confirmation_token === 'string');
64
+ if (apply && !reversible)
65
+ verifyConfirmation(cfg, spec.name, raw, raw.confirmation_token);
66
+ const resource = typeof spec.resource === 'function' ? spec.resource(args) : spec.resource;
67
+ let before;
68
+ let snapshot;
69
+ if (apply && reversible && !cfg.disableSnapshots) {
70
+ before = await readResource(client, resource);
71
+ snapshot = capture(cfg, client, {
72
+ tool: spec.name,
73
+ resource,
74
+ before,
75
+ reversible: true,
76
+ note: spec.note,
77
+ scope: spec.scope?.(args),
78
+ });
79
+ }
80
+ let result;
81
+ try {
82
+ result = await spec.handler(args, ctx(client, cfg, apply, dryRun, before));
83
+ }
84
+ catch (error) {
85
+ if (snapshot)
86
+ removeSnapshot(cfg, snapshot.id);
87
+ throw error;
88
+ }
89
+ if (snapshot && !(result.applied && result.changed))
90
+ removeSnapshot(cfg, snapshot.id);
91
+ if (result.applied && result.changed)
92
+ audit(cfg, { command: spec.name, resource, args, diff: result.diff, snapshot: snapshot?.id });
93
+ const confirmation = !reversible && !apply && !dryRun && result.changed
94
+ ? prepareConfirmation(cfg, spec.name, raw)
95
+ : undefined;
96
+ return {
97
+ status: !result.changed ? 'unchanged' : result.applied ? 'applied' : 'preview',
98
+ changed: result.changed,
99
+ diff: maskDeep(result.diff),
100
+ ...(snapshot && result.applied && result.changed ? { snapshot_id: snapshot.id } : {}),
101
+ ...(confirmation
102
+ ? {
103
+ warning: 'Irreversible. Run again with --confirmation-token TOKEN and the same arguments to apply.',
104
+ ...confirmation,
105
+ }
106
+ : {}),
107
+ ...(spec.risk === 'destructive' && !apply && !dryRun
108
+ ? { hint: 'Run again with --confirm to apply.' }
109
+ : {}),
110
+ };
111
+ }),
112
+ });
113
+ }
114
+ export function defineLocalMutation(registry, client, cfg, spec) {
115
+ registry.register({
116
+ name: spec.name,
117
+ title: spec.title,
118
+ description: spec.description,
119
+ risk: spec.risk,
120
+ schema: z.object({ ...spec.schema, dry_run: z.boolean().optional(), confirm: z.boolean().optional() }),
121
+ run: async (raw) => {
122
+ const apply = raw.confirm === true && raw.dry_run !== true;
123
+ const result = await spec.handler(raw, ctx(client, cfg, apply, !apply));
124
+ if (result.applied && result.changed)
125
+ audit(cfg, { command: spec.name, args: raw, diff: result.diff });
126
+ return {
127
+ status: !result.changed ? 'unchanged' : result.applied ? 'applied' : 'preview',
128
+ changed: result.changed,
129
+ diff: maskDeep(result.diff),
130
+ };
131
+ },
132
+ });
133
+ }
134
+ export function definePlanMutation(registry, client, cfg) {
135
+ registry.register({
136
+ name: 'nuvio_apply_plan',
137
+ title: 'Apply a plan of operations',
138
+ description: 'Apply supported operations together with a composite snapshot and rollback. Preview by default.',
139
+ risk: 'write',
140
+ schema: z.object({
141
+ operations: z
142
+ .array(z.object({ tool: z.string().min(1), args: z.record(z.string(), z.unknown()).default({}) }))
143
+ .min(1),
144
+ dry_run: z.boolean().optional().default(true),
145
+ }),
146
+ run: async (raw) => withCallCache(async () => {
147
+ const operations = raw.operations.map((op) => ({
148
+ ...op,
149
+ tool: 'nuvio_' + op.tool.replaceAll('-', '_'),
150
+ }));
151
+ const result = await applyPlan(operations, raw.dry_run !== false, {
152
+ client,
153
+ originId: cfg.originClientId,
154
+ snapshotsDisabled: cfg.disableSnapshots,
155
+ captureComposite: (entries) => cfg.disableSnapshots
156
+ ? undefined
157
+ : captureComposite(cfg, client, {
158
+ tool: 'nuvio_apply_plan',
159
+ entries,
160
+ reversible: true,
161
+ note: 'composite snapshot for a plan',
162
+ }).id,
163
+ restoreEntry: async (entry) => {
164
+ await restoreResource(client, cfg, entry, {
165
+ id: 'nuvio_apply_plan',
166
+ tool: 'nuvio_apply_plan',
167
+ reversible: true,
168
+ });
169
+ },
170
+ });
171
+ if (['applied', 'rolled_back', 'partially_applied'].includes(result.status))
172
+ audit(cfg, { command: 'nuvio_apply_plan', operations, result });
173
+ return maskDeep({
174
+ ...result,
175
+ operations: result.operations.map((op) => ({
176
+ ...op,
177
+ tool: op.tool.replace(/^nuvio_/, '').replaceAll('_', '-'),
178
+ })),
179
+ failed_operation: result.failed_operation && {
180
+ ...result.failed_operation,
181
+ tool: result.failed_operation.tool.replace(/^nuvio_/, '').replaceAll('_', '-'),
182
+ },
183
+ });
184
+ }),
185
+ });
186
+ }