@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.
- package/LICENSE +22 -0
- package/README.md +83 -0
- package/assets/nuvio-wordmark-dark.png +0 -0
- package/assets/nuvio-wordmark-light.png +0 -0
- package/dist/cli/confirmation.js +45 -0
- package/dist/cli/registry.js +14 -0
- package/dist/commands/account.js +66 -0
- package/dist/commands/addons.js +84 -0
- package/dist/commands/collections.js +147 -0
- package/dist/commands/helpers.js +186 -0
- package/dist/commands/index.js +26 -0
- package/dist/commands/library.js +105 -0
- package/dist/commands/plugins.js +58 -0
- package/dist/commands/profiles.js +126 -0
- package/dist/commands/providers.js +57 -0
- package/dist/commands/sessions.js +46 -0
- package/dist/commands/settings.js +43 -0
- package/dist/commands/trackers.js +84 -0
- package/dist/commands/undo.js +165 -0
- package/dist/config.js +60 -0
- package/dist/index.js +195 -0
- package/dist/mask.js +33 -0
- package/dist/nuvio/auth.js +123 -0
- package/dist/nuvio/call-context.js +20 -0
- package/dist/nuvio/client.js +149 -0
- package/dist/nuvio/errors.js +43 -0
- package/dist/nuvio/keys.js +23 -0
- package/dist/nuvio/ops/account.js +91 -0
- package/dist/nuvio/ops/addons.js +126 -0
- package/dist/nuvio/ops/collections.js +158 -0
- package/dist/nuvio/ops/library.js +139 -0
- package/dist/nuvio/ops/plan.js +728 -0
- package/dist/nuvio/ops/plugins.js +127 -0
- package/dist/nuvio/ops/profiles.js +383 -0
- package/dist/nuvio/ops/providers.js +103 -0
- package/dist/nuvio/ops/readers.js +57 -0
- package/dist/nuvio/ops/sessions.js +36 -0
- package/dist/nuvio/ops/settings.js +143 -0
- package/dist/nuvio/ops/trackers.js +80 -0
- package/dist/nuvio/ops/transitions.js +100 -0
- package/dist/nuvio/paths.js +74 -0
- package/dist/nuvio/safe-fetch.js +131 -0
- package/dist/nuvio/schemas.js +143 -0
- package/dist/nuvio/snapshots.js +664 -0
- package/dist/nuvio/types.js +1 -0
- package/dist/version.js +12 -0
- package/package.json +62 -0
- 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
|
+
}
|