@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NuvioError } from '../nuvio/errors.js';
|
|
3
|
+
import { maskDeep } from '../mask.js';
|
|
4
|
+
import { defineLocalMutation, defineRead } from './helpers.js';
|
|
5
|
+
import { capture, captureComposite, describe, findLastChange, findLastUndo, getSnapshot, listSnapshots, pruneSnapshots, readResource, restore, snapshotResources, } from '../nuvio/snapshots.js';
|
|
6
|
+
async function captureBefore(client, cfg, tool, snapshot) {
|
|
7
|
+
if (cfg.disableSnapshots)
|
|
8
|
+
return;
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const entry of snapshotResources(snapshot)) {
|
|
11
|
+
entries.push({
|
|
12
|
+
resource: entry.resource,
|
|
13
|
+
before: await readResource(client, entry.resource),
|
|
14
|
+
// Preserve the scope so a redo can target exactly the same records.
|
|
15
|
+
scope: entry.scope,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (entries.length === 0)
|
|
19
|
+
return;
|
|
20
|
+
if (entries.length === 1) {
|
|
21
|
+
capture(cfg, client, {
|
|
22
|
+
tool,
|
|
23
|
+
resource: entries[0].resource,
|
|
24
|
+
before: entries[0].before,
|
|
25
|
+
scope: entries[0].scope,
|
|
26
|
+
reversible: true,
|
|
27
|
+
note: `state before ${tool === 'nuvio_undo' ? 'undoing' : 'redoing'} ${snapshot.id}`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
captureComposite(cfg, client, {
|
|
32
|
+
tool,
|
|
33
|
+
entries,
|
|
34
|
+
reversible: true,
|
|
35
|
+
note: `state before ${tool === 'nuvio_undo' ? 'undoing' : 'redoing'} ${snapshot.id}`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function formatReport(report) {
|
|
40
|
+
return report.outcomes
|
|
41
|
+
.map((o) => {
|
|
42
|
+
const where = `${o.resource}${o.profile_id !== undefined ? ` profile ${o.profile_id}` : ''}${o.platform ? `/${o.platform}` : ''}`;
|
|
43
|
+
return `${o.ok ? '✓' : '✗'} ${where}: ${o.message}`;
|
|
44
|
+
})
|
|
45
|
+
.join('\n');
|
|
46
|
+
}
|
|
47
|
+
export function registerUndoCommands(registry, client, cfg) {
|
|
48
|
+
defineRead(registry, client, cfg, {
|
|
49
|
+
name: 'nuvio_list_undo',
|
|
50
|
+
title: 'List available undos',
|
|
51
|
+
description: 'List recent automatically-captured snapshots. Each can be reverted with nuvio_undo.',
|
|
52
|
+
risk: 'read',
|
|
53
|
+
schema: { limit: z.number().int().min(1).max(200).default(25) },
|
|
54
|
+
handler: (args) => {
|
|
55
|
+
const snapshots = listSnapshots(cfg, args.limit);
|
|
56
|
+
if (snapshots.length === 0)
|
|
57
|
+
return 'No snapshots recorded yet.';
|
|
58
|
+
return snapshots.map(describe).join('\n');
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
defineRead(registry, client, cfg, {
|
|
62
|
+
name: 'nuvio_inspect_snapshot',
|
|
63
|
+
title: 'Inspect a snapshot',
|
|
64
|
+
description: 'Show the captured previous state of a snapshot (secrets masked).',
|
|
65
|
+
risk: 'read',
|
|
66
|
+
annotations: { readOnlyHint: true },
|
|
67
|
+
schema: { snapshot_id: z.string() },
|
|
68
|
+
handler: (args) => {
|
|
69
|
+
const snapshot = getSnapshot(cfg, args.snapshot_id);
|
|
70
|
+
if (!snapshot)
|
|
71
|
+
throw new NuvioError(`Snapshot ${args.snapshot_id} not found.`);
|
|
72
|
+
return {
|
|
73
|
+
id: snapshot.id,
|
|
74
|
+
ts: snapshot.ts,
|
|
75
|
+
tool: snapshot.tool,
|
|
76
|
+
composite: snapshot.composite ?? false,
|
|
77
|
+
resources: snapshotResources(snapshot).map((e) => ({
|
|
78
|
+
resource: e.resource,
|
|
79
|
+
scope: e.scope === undefined ? undefined : maskDeep(e.scope),
|
|
80
|
+
before: maskDeep(e.before),
|
|
81
|
+
})),
|
|
82
|
+
reversible: snapshot.reversible,
|
|
83
|
+
sensitive: snapshot.sensitive,
|
|
84
|
+
note: snapshot.note,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
defineRead(registry, client, cfg, {
|
|
89
|
+
name: 'nuvio_undo',
|
|
90
|
+
title: 'Undo a change',
|
|
91
|
+
description: 'Revert a previous change using its snapshot (single or composite). Defaults to the most recent change. ' +
|
|
92
|
+
'Snapshots the current state first, so an undo can itself be undone.',
|
|
93
|
+
risk: 'write',
|
|
94
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
95
|
+
schema: {
|
|
96
|
+
snapshot_id: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('Snapshot id from nuvio_list_undo. Omit to undo the most recent change.'),
|
|
100
|
+
},
|
|
101
|
+
handler: async (args) => {
|
|
102
|
+
const target = args.snapshot_id ? getSnapshot(cfg, args.snapshot_id) : findLastChange(cfg);
|
|
103
|
+
if (!target) {
|
|
104
|
+
throw new NuvioError(args.snapshot_id ? `Snapshot ${args.snapshot_id} not found.` : 'There is nothing to undo.');
|
|
105
|
+
}
|
|
106
|
+
if (!target.reversible) {
|
|
107
|
+
throw new NuvioError(`Snapshot ${target.id} (${target.tool}) is not reversible${target.note ? ` — ${target.note}` : ''}.`);
|
|
108
|
+
}
|
|
109
|
+
await captureBefore(client, cfg, 'nuvio_undo', target);
|
|
110
|
+
const report = await restore(client, cfg, target);
|
|
111
|
+
const suffix = cfg.disableSnapshots
|
|
112
|
+
? ' Snapshots are disabled, so this undo cannot itself be undone.'
|
|
113
|
+
: '';
|
|
114
|
+
const failed = report.outcomes.filter((o) => !o.ok).length;
|
|
115
|
+
return (`${report.ok ? 'Reverted' : `Partially reverted (${failed} failed)`} snapshot ${target.id} (${target.tool}).` +
|
|
116
|
+
`\n${formatReport(report)}${suffix}`);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
defineRead(registry, client, cfg, {
|
|
120
|
+
name: 'nuvio_redo',
|
|
121
|
+
title: 'Redo an undone change',
|
|
122
|
+
description: 'Re-apply the change that the most recent nuvio_undo reverted. Snapshots current state first.',
|
|
123
|
+
risk: 'write',
|
|
124
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
125
|
+
schema: {},
|
|
126
|
+
handler: async () => {
|
|
127
|
+
const target = findLastUndo(cfg);
|
|
128
|
+
if (!target)
|
|
129
|
+
throw new NuvioError('There is nothing to redo.');
|
|
130
|
+
await captureBefore(client, cfg, 'nuvio_redo', target);
|
|
131
|
+
const report = await restore(client, cfg, target);
|
|
132
|
+
const suffix = cfg.disableSnapshots ? ' Snapshot writing is disabled; this redo was not recorded.' : '';
|
|
133
|
+
const failed = report.outcomes.filter((o) => !o.ok).length;
|
|
134
|
+
return (`${report.ok ? 'Re-applied' : `Partially re-applied (${failed} failed)`} snapshot ${target.id}.` +
|
|
135
|
+
`\n${formatReport(report)}${suffix}`);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
defineLocalMutation(registry, client, cfg, {
|
|
139
|
+
name: 'nuvio_prune_snapshots',
|
|
140
|
+
title: 'Prune snapshots',
|
|
141
|
+
description: 'Delete old snapshot files according to retention limits (age, count, total size). Local-only; defaults to dry_run.',
|
|
142
|
+
risk: 'destructive',
|
|
143
|
+
schema: {
|
|
144
|
+
older_than_days: z
|
|
145
|
+
.number()
|
|
146
|
+
.int()
|
|
147
|
+
.min(0)
|
|
148
|
+
.optional()
|
|
149
|
+
.describe('Delete snapshots older than N days (0 disables).'),
|
|
150
|
+
keep_last: z.number().int().min(0).optional().describe('Always keep the newest N snapshots.'),
|
|
151
|
+
max_total_bytes: z.number().int().min(0).optional().describe('Keep total snapshot bytes under this.'),
|
|
152
|
+
},
|
|
153
|
+
handler: async (args, ctx) => {
|
|
154
|
+
const result = pruneSnapshots(cfg, {
|
|
155
|
+
olderThanDays: args.older_than_days,
|
|
156
|
+
keepLast: args.keep_last,
|
|
157
|
+
maxCount: 0,
|
|
158
|
+
maxTotalBytes: args.max_total_bytes,
|
|
159
|
+
dryRun: !ctx.apply,
|
|
160
|
+
});
|
|
161
|
+
const diff = result.removed.map((r) => `- ${r.id} (${r.reason}, ${r.bytes} bytes)`);
|
|
162
|
+
return { changed: result.removed.length > 0, applied: ctx.apply, diff };
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const OFFICIAL_PUBLISHABLE_KEY = 'sb_publishable_1Clq8rlTVACkdcZuqr6_AD__xUUC_EN';
|
|
6
|
+
function loadEnvFiles() {
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
// Only package-relative .env files: never the current working directory.
|
|
9
|
+
const candidates = [resolve(here, '..', '.env'), resolve(here, '..', '..', '.env')];
|
|
10
|
+
const load = process.loadEnvFile;
|
|
11
|
+
if (typeof load !== 'function')
|
|
12
|
+
return;
|
|
13
|
+
for (const file of candidates) {
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
load(file);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* ignore malformed env files */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function bool(value, fallback) {
|
|
25
|
+
if (value === undefined || value === '')
|
|
26
|
+
return fallback;
|
|
27
|
+
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
function int(value, fallback) {
|
|
30
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
31
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
32
|
+
}
|
|
33
|
+
/** Like `int`, but `0` is a valid value (used where 0 means "disabled"). */
|
|
34
|
+
function nonNegativeInt(value, fallback) {
|
|
35
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
36
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
37
|
+
}
|
|
38
|
+
export function loadConfig() {
|
|
39
|
+
loadEnvFiles();
|
|
40
|
+
const dataDir = process.env.NUVIO_DATA_DIR?.trim() ||
|
|
41
|
+
(process.env.XDG_DATA_HOME
|
|
42
|
+
? join(process.env.XDG_DATA_HOME, 'nuvio')
|
|
43
|
+
: join(homedir(), '.local', 'share', 'nuvio'));
|
|
44
|
+
return {
|
|
45
|
+
backendUrl: (process.env.NUVIO_BACKEND_URL ?? 'https://api.nuvio.tv').replace(/\/+$/, ''),
|
|
46
|
+
publishableKey: process.env.NUVIO_PUBLISHABLE_KEY?.trim() || OFFICIAL_PUBLISHABLE_KEY,
|
|
47
|
+
email: process.env.NUVIO_EMAIL?.trim() || undefined,
|
|
48
|
+
password: process.env.NUVIO_PASSWORD || undefined,
|
|
49
|
+
refreshToken: process.env.NUVIO_REFRESH_TOKEN?.trim() || undefined,
|
|
50
|
+
originClientId: process.env.NUVIO_ORIGIN_CLIENT_ID?.trim() || 'nuvio-cli',
|
|
51
|
+
sessionFile: process.env.NUVIO_SESSION_FILE?.trim() || join(dataDir, 'session.json'),
|
|
52
|
+
auditFile: process.env.NUVIO_AUDIT_FILE?.trim() || join(dataDir, 'audit.jsonl'),
|
|
53
|
+
snapshotDir: process.env.NUVIO_SNAPSHOT_DIR?.trim() || join(dataDir, 'snapshots'),
|
|
54
|
+
backendTimeoutMs: int(process.env.NUVIO_BACKEND_TIMEOUT_MS, 30_000),
|
|
55
|
+
disableSnapshots: bool(process.env.NUVIO_DISABLE_SNAPSHOTS, false),
|
|
56
|
+
snapshotMaxAgeDays: nonNegativeInt(process.env.NUVIO_SNAPSHOT_MAX_AGE_DAYS, 30),
|
|
57
|
+
snapshotMaxCount: nonNegativeInt(process.env.NUVIO_SNAPSHOT_MAX_COUNT, 250),
|
|
58
|
+
snapshotMaxTotalBytes: nonNegativeInt(process.env.NUVIO_SNAPSHOT_MAX_TOTAL_BYTES, 50 * 1024 * 1024),
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { closeSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { ZodError, z } from 'zod';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { AuthManager } from './nuvio/auth.js';
|
|
6
|
+
import { NuvioClient } from './nuvio/client.js';
|
|
7
|
+
import { CommandRegistry } from './cli/registry.js';
|
|
8
|
+
import { registerAllCommands } from './commands/index.js';
|
|
9
|
+
import { isPlanSupported } from './nuvio/ops/plan.js';
|
|
10
|
+
import { VERSION } from './version.js';
|
|
11
|
+
const MAX_STDOUT_BYTES = 12_000;
|
|
12
|
+
function displayName(name) {
|
|
13
|
+
return name.replace(/^nuvio_/, '').replaceAll('_', '-');
|
|
14
|
+
}
|
|
15
|
+
function commandName(name) {
|
|
16
|
+
return 'nuvio_' + name.replaceAll('-', '_');
|
|
17
|
+
}
|
|
18
|
+
function fail(message, details) {
|
|
19
|
+
process.stderr.write(JSON.stringify({ error: message, ...(details === undefined ? {} : { details }) }) + '\n');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
function parseValue(text, type) {
|
|
23
|
+
if (type === 'string')
|
|
24
|
+
return text;
|
|
25
|
+
if (type === 'integer' || type === 'number')
|
|
26
|
+
return Number(text);
|
|
27
|
+
if (type === 'boolean')
|
|
28
|
+
return text === 'true' ? true : text === 'false' ? false : text;
|
|
29
|
+
if (type === 'array' || type === 'object' || text.startsWith('{') || text.startsWith('[')) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
function parseFlags(argv, command) {
|
|
40
|
+
const args = {};
|
|
41
|
+
const properties = z.toJSONSchema(command.schema).properties ?? {};
|
|
42
|
+
let output;
|
|
43
|
+
let input;
|
|
44
|
+
let apply = false;
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const token = argv[i];
|
|
47
|
+
if (!token.startsWith('--'))
|
|
48
|
+
fail('Unexpected positional argument: ' + token);
|
|
49
|
+
const equal = token.indexOf('=');
|
|
50
|
+
const flag = (equal >= 0 ? token.slice(2, equal) : token.slice(2)).replaceAll('-', '_');
|
|
51
|
+
if (flag === 'apply') {
|
|
52
|
+
apply = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (flag === 'confirm' || flag === 'dry_run') {
|
|
56
|
+
args[flag] = equal >= 0 ? parseValue(token.slice(equal + 1), 'boolean') : true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const value = equal >= 0 ? token.slice(equal + 1) : argv[++i];
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
fail('Missing value for --' + flag.replaceAll('_', '-'));
|
|
62
|
+
if (flag === 'input')
|
|
63
|
+
input = value;
|
|
64
|
+
else if (flag === 'output')
|
|
65
|
+
output = value;
|
|
66
|
+
else
|
|
67
|
+
args[flag] = parseValue(value, properties[flag]?.type);
|
|
68
|
+
}
|
|
69
|
+
if (input) {
|
|
70
|
+
const raw = input === '-' ? readFileSync(0, 'utf8') : readFileSync(input, 'utf8');
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
|
73
|
+
fail('Input must be a JSON object');
|
|
74
|
+
return { args: { ...parsed, ...args }, output, apply };
|
|
75
|
+
}
|
|
76
|
+
return { args, output, apply };
|
|
77
|
+
}
|
|
78
|
+
function help(command) {
|
|
79
|
+
const json = z.toJSONSchema(command.schema);
|
|
80
|
+
return {
|
|
81
|
+
command: 'nuvio ' + displayName(command.name),
|
|
82
|
+
title: command.title,
|
|
83
|
+
description: command.description,
|
|
84
|
+
risk: command.risk,
|
|
85
|
+
parameters: json.properties ?? {},
|
|
86
|
+
required: json.required ?? [],
|
|
87
|
+
input: 'Use flags for simple values or --input FILE / --input - for a JSON object.',
|
|
88
|
+
output: 'JSON on stdout. Use --output FILE for large results.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function print(data, output) {
|
|
92
|
+
const json = JSON.stringify(data);
|
|
93
|
+
if (output) {
|
|
94
|
+
writeFileSync(output.fd, json + '\n');
|
|
95
|
+
process.stdout.write(JSON.stringify({ saved_to: output.path, bytes: Buffer.byteLength(json) }) + '\n');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (Buffer.byteLength(json) > MAX_STDOUT_BYTES) {
|
|
99
|
+
const result = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
100
|
+
process.stdout.write(JSON.stringify({
|
|
101
|
+
truncated: true,
|
|
102
|
+
bytes: Buffer.byteLength(json),
|
|
103
|
+
status: result.status,
|
|
104
|
+
snapshot_id: result.snapshot_id,
|
|
105
|
+
hint: 'Repeat a read with --output FILE for the full result.',
|
|
106
|
+
}) + '\n');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
process.stdout.write(json + '\n');
|
|
110
|
+
}
|
|
111
|
+
async function main() {
|
|
112
|
+
const [verb, ...rest] = process.argv.slice(2);
|
|
113
|
+
if (verb === '--version' || verb === 'version') {
|
|
114
|
+
print({ version: VERSION });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const cfg = loadConfig();
|
|
118
|
+
const auth = new AuthManager({
|
|
119
|
+
backendUrl: cfg.backendUrl,
|
|
120
|
+
publishableKey: cfg.publishableKey,
|
|
121
|
+
email: cfg.email,
|
|
122
|
+
password: cfg.password,
|
|
123
|
+
refreshToken: cfg.refreshToken,
|
|
124
|
+
sessionFile: cfg.sessionFile,
|
|
125
|
+
timeoutMs: Math.min(cfg.backendTimeoutMs, 15_000),
|
|
126
|
+
});
|
|
127
|
+
const client = new NuvioClient(cfg, auth);
|
|
128
|
+
const registry = new CommandRegistry();
|
|
129
|
+
registerAllCommands(registry, client, cfg);
|
|
130
|
+
if (!verb || verb === 'help' || verb === '--help') {
|
|
131
|
+
const selected = rest[0] && registry.get(commandName(rest[0]));
|
|
132
|
+
print(selected
|
|
133
|
+
? help(selected)
|
|
134
|
+
: {
|
|
135
|
+
usage: 'nuvio <command> [--flags] | nuvio help <command> | nuvio commands [search] | nuvio plan-operations',
|
|
136
|
+
examples: [
|
|
137
|
+
'nuvio list-profiles',
|
|
138
|
+
'nuvio list-addons --profile-id 1',
|
|
139
|
+
'nuvio help update-settings',
|
|
140
|
+
],
|
|
141
|
+
commands: 'Run nuvio commands <search> to discover commands by name or description.',
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (verb === 'commands') {
|
|
146
|
+
const query = rest.join(' ').toLowerCase();
|
|
147
|
+
print(registry
|
|
148
|
+
.list()
|
|
149
|
+
.filter((c) => (c.name + ' ' + c.title + ' ' + c.description).toLowerCase().includes(query))
|
|
150
|
+
.map((c) => ({ command: displayName(c.name), title: c.title, risk: c.risk })));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (verb === 'plan-operations') {
|
|
154
|
+
print(registry
|
|
155
|
+
.list()
|
|
156
|
+
.filter((c) => isPlanSupported(c.name))
|
|
157
|
+
.map((c) => displayName(c.name)));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const command = registry.get(commandName(verb));
|
|
161
|
+
if (!command)
|
|
162
|
+
fail('Unknown command. Run nuvio commands <search> to find one.');
|
|
163
|
+
if (rest.includes('--help')) {
|
|
164
|
+
print(help(command));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const { args, output, apply } = parseFlags(rest, command);
|
|
168
|
+
if (apply) {
|
|
169
|
+
if (command.name !== 'nuvio_apply_plan')
|
|
170
|
+
fail('--apply is only valid for apply-plan');
|
|
171
|
+
args.dry_run = false;
|
|
172
|
+
}
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = command.schema.strict().parse(args);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof ZodError)
|
|
179
|
+
fail('Invalid command arguments', error.issues);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const fd = output ? openSync(output, 'wx', 0o600) : undefined;
|
|
183
|
+
let completed = false;
|
|
184
|
+
try {
|
|
185
|
+
print(await command.run(parsed), output && fd !== undefined ? { path: output, fd } : undefined);
|
|
186
|
+
completed = true;
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
if (fd !== undefined)
|
|
190
|
+
closeSync(fd);
|
|
191
|
+
if (!completed && output)
|
|
192
|
+
unlinkSync(output);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
package/dist/mask.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const SECRET_KEY = /(password|passwd|secret|token|api[_-]?key|credential|authorization)/i;
|
|
2
|
+
/** Exact keys that are secret but not matched by SECRET_KEY (e.g. PIN). */
|
|
3
|
+
const SECRET_KEYS = new Set(['pin', 'current_pin', 'new_pin', 'old_pin', 'pincode', 'passcode']);
|
|
4
|
+
const NOT_SECRET = new Set(['origin_client_id']);
|
|
5
|
+
function shouldMask(key) {
|
|
6
|
+
const lower = key.toLowerCase();
|
|
7
|
+
if (NOT_SECRET.has(lower))
|
|
8
|
+
return false;
|
|
9
|
+
if (SECRET_KEYS.has(lower))
|
|
10
|
+
return true;
|
|
11
|
+
return SECRET_KEY.test(lower);
|
|
12
|
+
}
|
|
13
|
+
/** Mask a single secret, keeping a short suffix so operators can still tell values apart. */
|
|
14
|
+
export function maskSecret(value) {
|
|
15
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
16
|
+
return '****';
|
|
17
|
+
if (value.length <= 4)
|
|
18
|
+
return '****';
|
|
19
|
+
return `****${value.slice(-4)}`;
|
|
20
|
+
}
|
|
21
|
+
/** Recursively mask values under sensitive keys. Used before any output or log. */
|
|
22
|
+
export function maskDeep(value) {
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value.map(maskDeep);
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [key, val] of Object.entries(value)) {
|
|
28
|
+
out[key] = shouldMask(key) ? maskSecret(val) : maskDeep(val);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { NuvioError, errorFromResponse } from './errors.js';
|
|
4
|
+
const REFRESH_SKEW_SECONDS = 60;
|
|
5
|
+
/**
|
|
6
|
+
* Owns the Nuvio session: signs in, persists the refresh token, and transparently
|
|
7
|
+
* refreshes the short-lived access token.
|
|
8
|
+
*/
|
|
9
|
+
export class AuthManager {
|
|
10
|
+
cfg;
|
|
11
|
+
session;
|
|
12
|
+
constructor(cfg) {
|
|
13
|
+
this.cfg = cfg;
|
|
14
|
+
this.session = { backendUrl: cfg.backendUrl };
|
|
15
|
+
this.load();
|
|
16
|
+
}
|
|
17
|
+
load() {
|
|
18
|
+
if (!existsSync(this.cfg.sessionFile))
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(readFileSync(this.cfg.sessionFile, 'utf8'));
|
|
22
|
+
if (parsed.backendUrl !== this.cfg.backendUrl)
|
|
23
|
+
return; // session belongs to a different backend
|
|
24
|
+
this.session = parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* ignore corrupt session file */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
persist() {
|
|
31
|
+
const target = this.cfg.sessionFile;
|
|
32
|
+
const tmp = `${target}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
35
|
+
writeFileSync(tmp, JSON.stringify(this.session, null, 2), { mode: 0o600 });
|
|
36
|
+
renameSync(tmp, target);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* session persistence is best-effort; the in-memory session still works */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
get userId() {
|
|
43
|
+
return this.session.userId;
|
|
44
|
+
}
|
|
45
|
+
get email() {
|
|
46
|
+
return this.session.email ?? this.cfg.email;
|
|
47
|
+
}
|
|
48
|
+
clear() {
|
|
49
|
+
this.session = { backendUrl: this.cfg.backendUrl };
|
|
50
|
+
this.persist();
|
|
51
|
+
}
|
|
52
|
+
capture(token) {
|
|
53
|
+
this.session.accessToken = token.access_token;
|
|
54
|
+
if (token.refresh_token)
|
|
55
|
+
this.session.refreshToken = token.refresh_token;
|
|
56
|
+
const expiresAt = token.expires_at ?? (token.expires_in ? Math.floor(Date.now() / 1000) + token.expires_in : undefined);
|
|
57
|
+
this.session.expiresAt = expiresAt;
|
|
58
|
+
if (token.user?.id)
|
|
59
|
+
this.session.userId = token.user.id;
|
|
60
|
+
if (token.user?.email)
|
|
61
|
+
this.session.email = token.user.email;
|
|
62
|
+
this.persist();
|
|
63
|
+
}
|
|
64
|
+
async postToken(query, body) {
|
|
65
|
+
const res = await fetch(`${this.cfg.backendUrl}/auth/v1/token?grant_type=${query}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
apikey: this.cfg.publishableKey,
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 15_000),
|
|
73
|
+
});
|
|
74
|
+
const json = await res.json().catch(() => null);
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
throw errorFromResponse(res.status, json);
|
|
77
|
+
return json;
|
|
78
|
+
}
|
|
79
|
+
async signInWithPassword() {
|
|
80
|
+
if (!this.cfg.email || !this.cfg.password) {
|
|
81
|
+
throw new NuvioError('No session available and NUVIO_EMAIL / NUVIO_PASSWORD (or NUVIO_REFRESH_TOKEN) are not set.');
|
|
82
|
+
}
|
|
83
|
+
const token = await this.postToken('password', {
|
|
84
|
+
email: this.cfg.email,
|
|
85
|
+
password: this.cfg.password,
|
|
86
|
+
});
|
|
87
|
+
this.capture(token);
|
|
88
|
+
}
|
|
89
|
+
async refresh() {
|
|
90
|
+
const refreshToken = this.session.refreshToken ?? this.cfg.refreshToken;
|
|
91
|
+
if (!refreshToken)
|
|
92
|
+
throw new NuvioError('Session expired and no refresh token is available.');
|
|
93
|
+
const token = await this.postToken('refresh_token', { refresh_token: refreshToken });
|
|
94
|
+
this.capture(token);
|
|
95
|
+
}
|
|
96
|
+
/** Force a refresh (used after a 401 from the API). */
|
|
97
|
+
async forceRefresh() {
|
|
98
|
+
await this.refresh();
|
|
99
|
+
}
|
|
100
|
+
/** Returns a valid bearer token, signing in or refreshing as needed. */
|
|
101
|
+
async getAccessToken() {
|
|
102
|
+
const now = Math.floor(Date.now() / 1000);
|
|
103
|
+
const token = this.session.accessToken;
|
|
104
|
+
const expiring = !this.session.expiresAt || this.session.expiresAt - REFRESH_SKEW_SECONDS <= now;
|
|
105
|
+
if (token && !expiring)
|
|
106
|
+
return token;
|
|
107
|
+
if (this.session.refreshToken || this.cfg.refreshToken) {
|
|
108
|
+
try {
|
|
109
|
+
await this.refresh();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// A rotated/expired refresh token falls back to password auth if we have it.
|
|
113
|
+
if (!this.cfg.email || !this.cfg.password)
|
|
114
|
+
throw error;
|
|
115
|
+
await this.signInWithPassword();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
await this.signInWithPassword();
|
|
120
|
+
}
|
|
121
|
+
return this.session.accessToken;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
const storage = new AsyncLocalStorage();
|
|
3
|
+
export function withCallCache(fn) {
|
|
4
|
+
return storage.run({ cache: new Map() }, fn);
|
|
5
|
+
}
|
|
6
|
+
/** Memoize a read for the duration of the current call. Falls back to a plain call with no scope. */
|
|
7
|
+
export function callCache(key, loader) {
|
|
8
|
+
const store = storage.getStore();
|
|
9
|
+
if (!store)
|
|
10
|
+
return loader();
|
|
11
|
+
const existing = store.cache.get(key);
|
|
12
|
+
if (existing)
|
|
13
|
+
return existing;
|
|
14
|
+
const pending = loader().catch((error) => {
|
|
15
|
+
store.cache.delete(key);
|
|
16
|
+
throw error;
|
|
17
|
+
});
|
|
18
|
+
store.cache.set(key, pending);
|
|
19
|
+
return pending;
|
|
20
|
+
}
|