@feralfile/cli 1.1.1
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 +21 -0
- package/README.md +96 -0
- package/config.json.example +96 -0
- package/dist/index.js +54 -0
- package/dist/src/ai-orchestrator/index.js +1019 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/commands/build.js +69 -0
- package/dist/src/commands/chat.js +189 -0
- package/dist/src/commands/config.js +68 -0
- package/dist/src/commands/device.js +278 -0
- package/dist/src/commands/helpers/config-files.js +62 -0
- package/dist/src/commands/helpers/device-discovery.js +111 -0
- package/dist/src/commands/helpers/playlist-display.js +161 -0
- package/dist/src/commands/helpers/prompt.js +65 -0
- package/dist/src/commands/helpers/ssh-helpers.js +44 -0
- package/dist/src/commands/play.js +110 -0
- package/dist/src/commands/publish.js +115 -0
- package/dist/src/commands/setup.js +225 -0
- package/dist/src/commands/sign.js +41 -0
- package/dist/src/commands/ssh.js +108 -0
- package/dist/src/commands/status.js +126 -0
- package/dist/src/commands/validate.js +18 -0
- package/dist/src/config.js +441 -0
- package/dist/src/intent-parser/index.js +1382 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +82 -0
- package/dist/src/main.js +459 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/device-default.js +36 -0
- package/dist/src/utilities/device-lookup.js +107 -0
- package/dist/src/utilities/device-normalize.js +62 -0
- package/dist/src/utilities/device-upsert.js +91 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/ed25519-key-derive.js +155 -0
- package/dist/src/utilities/feed-fetcher.js +471 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +250 -0
- package/dist/src/utilities/ff1-discovery.js +330 -0
- package/dist/src/utilities/functions.js +308 -0
- package/dist/src/utilities/index.js +469 -0
- package/dist/src/utilities/nft-indexer.js +1024 -0
- package/dist/src/utilities/playlist-builder.js +523 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +260 -0
- package/dist/src/utilities/playlist-signer.js +204 -0
- package/dist/src/utilities/playlist-signing-role.js +41 -0
- package/dist/src/utilities/playlist-source.js +128 -0
- package/dist/src/utilities/playlist-verifier.js +274 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +206 -0
- package/docs/EXAMPLES.md +390 -0
- package/docs/FUNCTION_CALLING.md +96 -0
- package/docs/PROJECT_SPEC.md +228 -0
- package/docs/README.md +348 -0
- package/docs/RELEASING.md +73 -0
- package/package.json +76 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.confirmPlaylistForSending = confirmPlaylistForSending;
|
|
40
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
41
|
+
const playlist_source_1 = require("./playlist-source");
|
|
42
|
+
/**
|
|
43
|
+
* Get available FF1 devices from config
|
|
44
|
+
*
|
|
45
|
+
* @returns {Promise<Array>} Array of device objects
|
|
46
|
+
*/
|
|
47
|
+
async function getAvailableDevices() {
|
|
48
|
+
try {
|
|
49
|
+
const configModule = (await Promise.resolve().then(() => __importStar(require('../config'))));
|
|
50
|
+
const getFF1DeviceConfig = configModule.getFF1DeviceConfig ||
|
|
51
|
+
(configModule.default && configModule.default.getFF1DeviceConfig) ||
|
|
52
|
+
configModule.default;
|
|
53
|
+
if (typeof getFF1DeviceConfig !== 'function') {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const deviceConfig = getFF1DeviceConfig();
|
|
57
|
+
if (deviceConfig.devices && Array.isArray(deviceConfig.devices)) {
|
|
58
|
+
return deviceConfig.devices
|
|
59
|
+
.filter((d) => d && d.host)
|
|
60
|
+
.map((d) => ({
|
|
61
|
+
name: d.name || d.host,
|
|
62
|
+
host: d.host,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (process.env.DEBUG) {
|
|
68
|
+
console.log(chalk_1.default.dim(`[DEBUG] Error loading devices: ${error.message}`));
|
|
69
|
+
}
|
|
70
|
+
// Silently fail if config can't be loaded
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Confirm playlist file path and validate the playlist
|
|
76
|
+
*
|
|
77
|
+
* Reads the playlist file, runs parse/structure validation (same as `validate`),
|
|
78
|
+
* and returns confirmation result for user review. Does not verify signatures.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} filePath - Playlist file path or URL
|
|
81
|
+
* @param {string} [deviceName] - Device name (optional)
|
|
82
|
+
* @returns {Promise<PlaylistSendConfirmation>} Validation result
|
|
83
|
+
*/
|
|
84
|
+
async function confirmPlaylistForSending(filePath, deviceName) {
|
|
85
|
+
const defaultPath = './playlist.json';
|
|
86
|
+
const resolvedPath = filePath || defaultPath;
|
|
87
|
+
// Convert string "null" to undefined (in case model passes it literally)
|
|
88
|
+
const actualDeviceName = deviceName === 'null' || deviceName === '' ? undefined : deviceName;
|
|
89
|
+
if (process.env.DEBUG) {
|
|
90
|
+
console.error(chalk_1.default.dim(`[DEBUG] confirmPlaylistForSending called with: filePath="${filePath}", deviceName="${deviceName}" -> "${actualDeviceName}"`));
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
// Load playlist from file or URL
|
|
94
|
+
console.log(chalk_1.default.cyan(`Playlist source: ${resolvedPath}`));
|
|
95
|
+
let playlist;
|
|
96
|
+
let fileExists = false;
|
|
97
|
+
let loadedFrom = 'file';
|
|
98
|
+
try {
|
|
99
|
+
const loaded = await (0, playlist_source_1.loadPlaylistSource)(resolvedPath);
|
|
100
|
+
playlist = loaded.playlist;
|
|
101
|
+
fileExists = loaded.sourceType === 'file';
|
|
102
|
+
loadedFrom = loaded.sourceType;
|
|
103
|
+
console.log(chalk_1.default.green(`Loaded from ${loadedFrom}: ${resolvedPath}`));
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const message = error.message;
|
|
107
|
+
const isUrl = (0, playlist_source_1.isPlaylistSourceUrl)(resolvedPath);
|
|
108
|
+
if (isUrl) {
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
filePath: resolvedPath,
|
|
112
|
+
fileExists: false,
|
|
113
|
+
playlistValid: false,
|
|
114
|
+
error: `Could not load playlist URL: ${resolvedPath}`,
|
|
115
|
+
message: `${message}\n\nHint:\n • Check the URL is reachable\n • Confirm it returns JSON\n • Use "send ./path/to/playlist.json" for local files`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (message.includes('Invalid JSON in')) {
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
filePath: resolvedPath,
|
|
122
|
+
fileExists: true,
|
|
123
|
+
playlistValid: false,
|
|
124
|
+
error: message,
|
|
125
|
+
message: `${message}\n\nHint:\n • Check the JSON payload is valid DP-1 format\n • Check local path points to a playlist file`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
filePath: resolvedPath,
|
|
131
|
+
fileExists: false,
|
|
132
|
+
playlistValid: false,
|
|
133
|
+
error: `Playlist file not found at ${resolvedPath}`,
|
|
134
|
+
message: `Could not find playlist file. Try:\n • Run a playlist build first\n • Check the file path is correct\n • Use "send ./path/to/playlist.json"`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (!playlist) {
|
|
138
|
+
return {
|
|
139
|
+
success: false,
|
|
140
|
+
filePath: resolvedPath,
|
|
141
|
+
fileExists,
|
|
142
|
+
playlistValid: false,
|
|
143
|
+
error: `Playlist source is empty: ${loadedFrom}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Parse / structure validation only (same as `validate`; use `verify` CLI for signatures)
|
|
147
|
+
console.log(chalk_1.default.cyan('Validation'));
|
|
148
|
+
// Dynamic import to avoid circular dependency
|
|
149
|
+
const { validatePlaylist } = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
|
|
150
|
+
const validateResult = await validatePlaylist(playlist);
|
|
151
|
+
if (!validateResult.valid) {
|
|
152
|
+
console.log(chalk_1.default.red('Playlist validation failed'));
|
|
153
|
+
const detailLines = validateResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n') ||
|
|
154
|
+
validateResult.error;
|
|
155
|
+
const detailPaths = validateResult.details?.map((d) => d.path) || [];
|
|
156
|
+
const hints = [];
|
|
157
|
+
if (detailPaths.some((path) => path.includes('signature'))) {
|
|
158
|
+
hints.push('Add `playlist.privateKey` (or `PLAYLIST_PRIVATE_KEY`) and rebuild the playlist to include signatures.');
|
|
159
|
+
}
|
|
160
|
+
if (detailPaths.some((path) => path.includes('defaults.display.margin'))) {
|
|
161
|
+
hints.push('Rebuild the playlist with the latest CLI defaults (margin must be numeric).');
|
|
162
|
+
}
|
|
163
|
+
const hintText = hints.length > 0 ? `\n\nHint:\n${hints.map((h) => ` • ${h}`).join('\n')}` : '';
|
|
164
|
+
return {
|
|
165
|
+
success: false,
|
|
166
|
+
filePath: resolvedPath,
|
|
167
|
+
fileExists: true,
|
|
168
|
+
playlistValid: false,
|
|
169
|
+
playlist,
|
|
170
|
+
deviceName: actualDeviceName,
|
|
171
|
+
error: `Playlist is invalid: ${validateResult.error}`,
|
|
172
|
+
message: `This playlist doesn't match DP-1 specification.\n\nErrors:\n${detailLines}${hintText}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
console.log(chalk_1.default.green('Valid DP-1 playlist'));
|
|
176
|
+
// Display confirmation details
|
|
177
|
+
const itemCount = playlist.items?.length || 0;
|
|
178
|
+
const title = playlist.title || 'Untitled';
|
|
179
|
+
// Handle device selection
|
|
180
|
+
let selectedDevice = actualDeviceName;
|
|
181
|
+
let needsDeviceSelection = false;
|
|
182
|
+
let availableDevices = [];
|
|
183
|
+
if (!selectedDevice) {
|
|
184
|
+
// Get available devices
|
|
185
|
+
availableDevices = await getAvailableDevices();
|
|
186
|
+
if (process.env.DEBUG) {
|
|
187
|
+
console.error(chalk_1.default.dim(`[DEBUG] selectedDevice is null/undefined`));
|
|
188
|
+
console.error(chalk_1.default.dim(`[DEBUG] Available devices found: ${availableDevices.length}`));
|
|
189
|
+
availableDevices.forEach((d) => {
|
|
190
|
+
console.error(chalk_1.default.dim(`[DEBUG] Device: ${d.name} (${d.host})`));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (availableDevices.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
success: false,
|
|
196
|
+
filePath: resolvedPath,
|
|
197
|
+
fileExists: true,
|
|
198
|
+
playlistValid: true,
|
|
199
|
+
playlist,
|
|
200
|
+
error: 'No FF1 devices configured',
|
|
201
|
+
message: `No FF1 devices found in your configuration.\n\nPlease add devices to your config.json:\n{\n "devices": [{\n "name": "Living Room",\n "host": "192.168.1.100"\n }]\n}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
else if (availableDevices.length === 1) {
|
|
205
|
+
// Auto-select single device
|
|
206
|
+
selectedDevice = availableDevices[0].name || availableDevices[0].host;
|
|
207
|
+
console.log(chalk_1.default.cyan(`Device: ${selectedDevice} (auto)`));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Multiple devices - need user to choose
|
|
211
|
+
needsDeviceSelection = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
console.log();
|
|
215
|
+
console.log(chalk_1.default.bold('Send Summary'));
|
|
216
|
+
console.log(chalk_1.default.dim(` Title: ${title}`));
|
|
217
|
+
console.log(chalk_1.default.dim(` Items: ${itemCount}`));
|
|
218
|
+
if (selectedDevice) {
|
|
219
|
+
console.log(chalk_1.default.dim(` Device: ${selectedDevice}`));
|
|
220
|
+
}
|
|
221
|
+
else if (availableDevices.length > 1) {
|
|
222
|
+
console.log(chalk_1.default.dim(' Device: select one'));
|
|
223
|
+
}
|
|
224
|
+
console.log();
|
|
225
|
+
// If multiple devices, return needsDeviceSelection flag
|
|
226
|
+
if (needsDeviceSelection) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
filePath: resolvedPath,
|
|
230
|
+
fileExists: true,
|
|
231
|
+
playlistValid: true,
|
|
232
|
+
playlist,
|
|
233
|
+
needsDeviceSelection: true,
|
|
234
|
+
availableDevices,
|
|
235
|
+
error: 'Multiple devices available - please choose one',
|
|
236
|
+
message: `Which device would you like to display on?\n\nAvailable devices:\n${availableDevices.map((d, i) => ` ${i + 1}. ${d.name || d.host}`).join('\n')}\n\nSay: "send to [device name]" or "send to device 1"`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
success: true,
|
|
241
|
+
filePath: resolvedPath,
|
|
242
|
+
fileExists: true,
|
|
243
|
+
playlistValid: true,
|
|
244
|
+
playlist,
|
|
245
|
+
deviceName: selectedDevice,
|
|
246
|
+
message: `Ready to send "${title}" (${itemCount} items) to ${selectedDevice}!`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
const errorMsg = error.message;
|
|
251
|
+
console.log(chalk_1.default.red(`Error: ${errorMsg}`));
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
filePath: resolvedPath,
|
|
255
|
+
fileExists: false,
|
|
256
|
+
playlistValid: false,
|
|
257
|
+
error: errorMsg,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playlist Signing Utility.
|
|
3
|
+
* Uses the DP-1 v1.1.0 signing contract via the `dp1-js` package.
|
|
4
|
+
*/
|
|
5
|
+
const { getPlaylistConfig } = require('../config');
|
|
6
|
+
const { isDp1PlaylistSigningRole } = require('./playlist-signing-role');
|
|
7
|
+
/**
|
|
8
|
+
* Sign a playlist using the DP-1 signing API.
|
|
9
|
+
* The signed payload excludes any pre-existing signature fields so the output
|
|
10
|
+
* is stable across re-signing and matches the library's canonical digest.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} playlist - Playlist object without signature
|
|
13
|
+
* @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
|
|
14
|
+
* @param {string} [roleOverride] - DP-1 signing role override (optional, uses config if not provided)
|
|
15
|
+
* @returns {Promise<Object>} DP-1 signature envelope
|
|
16
|
+
* @throws {Error} If private key is invalid or signing fails
|
|
17
|
+
*/
|
|
18
|
+
async function signPlaylist(playlist, privateKeyBase64, roleOverride) {
|
|
19
|
+
// Get private key from config if not provided
|
|
20
|
+
let privateKey = privateKeyBase64;
|
|
21
|
+
if (!privateKey) {
|
|
22
|
+
const config = getPlaylistConfig();
|
|
23
|
+
privateKey = config.privateKey;
|
|
24
|
+
}
|
|
25
|
+
if (!privateKey) {
|
|
26
|
+
throw new Error('Private key is required for signing');
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const playlistToSign = { ...playlist };
|
|
30
|
+
delete playlistToSign.signature;
|
|
31
|
+
delete playlistToSign.signatures;
|
|
32
|
+
const dp1 = await loadDp1();
|
|
33
|
+
const raw = Buffer.from(JSON.stringify(playlistToSign));
|
|
34
|
+
const config = getPlaylistConfig();
|
|
35
|
+
const role = resolvePlaylistSigningRole(roleOverride || config.role);
|
|
36
|
+
if (typeof dp1.SignMultiEd25519 === 'function') {
|
|
37
|
+
return dp1.SignMultiEd25519(raw, privateKey, role, currentTimestamp());
|
|
38
|
+
}
|
|
39
|
+
throw new Error('dp1-js does not expose SignMultiEd25519');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`Failed to sign playlist: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Verify a playlist signature with the DP-1 verification API.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} playlist - Playlist object with signature field
|
|
49
|
+
* @param {string} publicKeyHex - Ed25519 public key in hex format (with or without 0x prefix)
|
|
50
|
+
* @returns {Promise<boolean>} True if signature is valid, false otherwise
|
|
51
|
+
* @throws {Error} If verification process fails
|
|
52
|
+
*/
|
|
53
|
+
async function verifyPlaylist(playlist, publicKeyHex) {
|
|
54
|
+
if (!publicKeyHex) {
|
|
55
|
+
throw new Error('Public key is required for verification');
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const dp1 = await loadDp1();
|
|
59
|
+
const verifyFn = dp1.verifyPlaylist;
|
|
60
|
+
if (typeof verifyFn !== 'function') {
|
|
61
|
+
throw new Error('dp1-js does not expose verifyPlaylist');
|
|
62
|
+
}
|
|
63
|
+
const isValid = await verifyFn(playlist, publicKeyHex);
|
|
64
|
+
return isValid;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new Error(`Failed to verify playlist signature: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Sign a playlist file
|
|
72
|
+
* Reads playlist from file, signs it, and writes back
|
|
73
|
+
*
|
|
74
|
+
* @param {string} playlistPath - Path to playlist JSON file
|
|
75
|
+
* @param {string} [privateKeyBase64] - Ed25519 private key in hex or base64 format (optional, uses config if not provided)
|
|
76
|
+
* @param {string} [outputPath] - Output path (optional, overwrites input if not provided)
|
|
77
|
+
* @returns {Promise<Object>} Result with signed playlist
|
|
78
|
+
* @returns {boolean} returns.success - Whether signing succeeded
|
|
79
|
+
* @returns {Object} [returns.playlist] - Signed playlist object
|
|
80
|
+
* @returns {string} [returns.error] - Error message if failed
|
|
81
|
+
*/
|
|
82
|
+
async function signPlaylistFile(playlistPath, privateKeyBase64, outputPath, roleOverride) {
|
|
83
|
+
const fs = require('fs');
|
|
84
|
+
const path = require('path');
|
|
85
|
+
try {
|
|
86
|
+
// Read playlist file
|
|
87
|
+
if (!fs.existsSync(playlistPath)) {
|
|
88
|
+
throw new Error(`Playlist file not found: ${playlistPath}`);
|
|
89
|
+
}
|
|
90
|
+
const playlistContent = fs.readFileSync(playlistPath, 'utf-8');
|
|
91
|
+
const playlist = JSON.parse(playlistContent);
|
|
92
|
+
const config = getPlaylistConfig();
|
|
93
|
+
const privateKey = privateKeyBase64 || config.privateKey;
|
|
94
|
+
const role = resolvePlaylistSigningRole(roleOverride || config.role);
|
|
95
|
+
const validation = await validatePlaylistForSigning(playlist);
|
|
96
|
+
if (!validation.valid) {
|
|
97
|
+
throw new Error(`Playlist validation failed: ${validation.error}`);
|
|
98
|
+
}
|
|
99
|
+
const dp1 = await loadDp1();
|
|
100
|
+
if (!privateKey) {
|
|
101
|
+
throw new Error('Private key is required for signing');
|
|
102
|
+
}
|
|
103
|
+
const signedPlaylist = await buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role);
|
|
104
|
+
const verification = await verifySignedPlaylistEnvelope(signedPlaylist, dp1);
|
|
105
|
+
if (!verification.valid) {
|
|
106
|
+
throw new Error(`Signed playlist verification failed: ${verification.error}`);
|
|
107
|
+
}
|
|
108
|
+
// Write to output file
|
|
109
|
+
const output = outputPath || playlistPath;
|
|
110
|
+
fs.writeFileSync(output, JSON.stringify(signedPlaylist, null, 2), 'utf-8');
|
|
111
|
+
console.log(`✓ Playlist signed and saved to: ${path.resolve(output)}`);
|
|
112
|
+
return {
|
|
113
|
+
success: true,
|
|
114
|
+
playlist: signedPlaylist,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
error: error.message,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
module.exports = {
|
|
125
|
+
signPlaylist,
|
|
126
|
+
verifyPlaylist,
|
|
127
|
+
signPlaylistFile,
|
|
128
|
+
};
|
|
129
|
+
function resolvePlaylistSigningRole(role) {
|
|
130
|
+
const candidate = typeof role === 'string' ? role.trim() : '';
|
|
131
|
+
const effectiveRole = candidate || 'agent';
|
|
132
|
+
if (!isDp1PlaylistSigningRole(effectiveRole)) {
|
|
133
|
+
throw new Error(`Unsupported DP-1 playlist signing role "${effectiveRole}". Expected one of: agent, feed, curator, institution, licensor`);
|
|
134
|
+
}
|
|
135
|
+
return effectiveRole;
|
|
136
|
+
}
|
|
137
|
+
async function validatePlaylistForSigning(playlist) {
|
|
138
|
+
const dp1 = await loadDp1();
|
|
139
|
+
const parseFn = dp1.parseDP1Playlist;
|
|
140
|
+
if (typeof parseFn !== 'function') {
|
|
141
|
+
throw new Error('dp1-js does not expose parseDP1Playlist');
|
|
142
|
+
}
|
|
143
|
+
const result = parseFn(playlist);
|
|
144
|
+
if (result && result.error) {
|
|
145
|
+
return { valid: false, error: result.error.message };
|
|
146
|
+
}
|
|
147
|
+
return { valid: true };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Produce a DP-1 v1.1.0 playlist object with a new multi-signature appended.
|
|
151
|
+
* The digest uses JSON with top-level `signature` and `signatures` removed (same
|
|
152
|
+
* as dp1-js/dp1-go §7.1); prior `signatures[]` entries are kept on the returned
|
|
153
|
+
* object so repeated `sign` runs accumulate endorsements instead of replacing them.
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} playlist - Parsed playlist (may already include `signatures[]`)
|
|
156
|
+
* @param {string} privateKey - Private key material forwarded to dp1-js
|
|
157
|
+
* @param {Object} dp1 - Loaded dp1-js module
|
|
158
|
+
* @param {string} role - DP-1 signing role
|
|
159
|
+
* @returns {Promise<Object>} Playlist with legacy `signature` cleared and merged `signatures[]`
|
|
160
|
+
*/
|
|
161
|
+
async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
|
|
162
|
+
const playlistToSign = { ...playlist };
|
|
163
|
+
delete playlistToSign.signature;
|
|
164
|
+
delete playlistToSign.signatures;
|
|
165
|
+
const existingSignatures = Array.isArray(playlist.signatures)
|
|
166
|
+
? playlist.signatures.filter((entry) => Boolean(entry))
|
|
167
|
+
: [];
|
|
168
|
+
if (typeof dp1.SignMultiEd25519 === 'function') {
|
|
169
|
+
const signature = await dp1.SignMultiEd25519(Buffer.from(JSON.stringify(playlistToSign)), privateKey, role, currentTimestamp());
|
|
170
|
+
return {
|
|
171
|
+
...playlist,
|
|
172
|
+
signature: undefined,
|
|
173
|
+
signatures: [...existingSignatures, signature],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
throw new Error('dp1-js does not expose SignMultiEd25519');
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Verify a signed playlist envelope with dp1-js before it is persisted.
|
|
180
|
+
* The sign command must only write outputs that the same verifier path accepts;
|
|
181
|
+
* otherwise it can succeed while immediately generating a broken artifact.
|
|
182
|
+
*
|
|
183
|
+
* @param {Object} signedPlaylist - Playlist envelope with signatures attached
|
|
184
|
+
* @param {Object} dp1 - Loaded dp1-js module
|
|
185
|
+
* @returns {Promise<{ valid: boolean; error?: string }>} Verification result
|
|
186
|
+
*/
|
|
187
|
+
async function verifySignedPlaylistEnvelope(signedPlaylist, dp1) {
|
|
188
|
+
const verifyFn = dp1.verifyPlaylist;
|
|
189
|
+
if (typeof verifyFn !== 'function') {
|
|
190
|
+
throw new Error('dp1-js does not expose verifyPlaylist');
|
|
191
|
+
}
|
|
192
|
+
const isValid = await verifyFn(signedPlaylist);
|
|
193
|
+
if (!isValid) {
|
|
194
|
+
return { valid: false, error: 'signed playlist is not verifiable' };
|
|
195
|
+
}
|
|
196
|
+
return { valid: true };
|
|
197
|
+
}
|
|
198
|
+
/** Loads `dp1-js`; env overrides are not supported (see playlist-verifier). */
|
|
199
|
+
async function loadDp1() {
|
|
200
|
+
return require('dp1-js');
|
|
201
|
+
}
|
|
202
|
+
function currentTimestamp() {
|
|
203
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
204
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported DP-1 playlist signing roles.
|
|
3
|
+
*
|
|
4
|
+
* Keep this list aligned with the published dp1-js role constants so guided
|
|
5
|
+
* setup, config validation, and signing all agree on the same contract.
|
|
6
|
+
*/
|
|
7
|
+
const DP1_PLAYLIST_SIGNING_ROLES = Object.freeze([
|
|
8
|
+
'agent',
|
|
9
|
+
'feed',
|
|
10
|
+
'curator',
|
|
11
|
+
'institution',
|
|
12
|
+
'licensor',
|
|
13
|
+
]);
|
|
14
|
+
function isDp1PlaylistSigningRole(role) {
|
|
15
|
+
return typeof role === 'string' && DP1_PLAYLIST_SIGNING_ROLES.includes(role);
|
|
16
|
+
}
|
|
17
|
+
function formatUnsupportedPlaylistSigningRoleError(role) {
|
|
18
|
+
return `Unsupported DP-1 playlist signing role "${role}". Expected one of: ${DP1_PLAYLIST_SIGNING_ROLES.join(', ')}`;
|
|
19
|
+
}
|
|
20
|
+
function normalizeDp1PlaylistSigningRole(value, defaultRole = 'agent') {
|
|
21
|
+
const trimmedValue = typeof value === 'string' ? value.trim() : '';
|
|
22
|
+
if (trimmedValue) {
|
|
23
|
+
return trimmedValue;
|
|
24
|
+
}
|
|
25
|
+
const trimmedDefault = typeof defaultRole === 'string' ? defaultRole.trim() : '';
|
|
26
|
+
return trimmedDefault || 'agent';
|
|
27
|
+
}
|
|
28
|
+
function resolveDp1PlaylistSigningRole(role, fallbackRole = 'agent') {
|
|
29
|
+
const candidate = normalizeDp1PlaylistSigningRole(role, fallbackRole);
|
|
30
|
+
if (!isDp1PlaylistSigningRole(candidate)) {
|
|
31
|
+
throw new Error(formatUnsupportedPlaylistSigningRoleError(candidate));
|
|
32
|
+
}
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
module.exports = {
|
|
36
|
+
DP1_PLAYLIST_SIGNING_ROLES,
|
|
37
|
+
isDp1PlaylistSigningRole,
|
|
38
|
+
formatUnsupportedPlaylistSigningRoleError,
|
|
39
|
+
normalizeDp1PlaylistSigningRole,
|
|
40
|
+
resolveDp1PlaylistSigningRole,
|
|
41
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPlaylistSourceUrl = isPlaylistSourceUrl;
|
|
4
|
+
exports.loadPlaylistSource = loadPlaylistSource;
|
|
5
|
+
exports.resolvePlaySource = resolvePlaySource;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
// playlist-builder is still CommonJS; require keeps the interop simple.
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
|
+
const { buildUrlItem, buildDP1Playlist } = require('./playlist-builder');
|
|
10
|
+
/**
|
|
11
|
+
* Determine whether a playlist source is an HTTP(S) URL.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} source - Playlist source value
|
|
14
|
+
* @returns {boolean} Whether the value parses as http:// or https:// URL
|
|
15
|
+
*/
|
|
16
|
+
function isPlaylistSourceUrl(source) {
|
|
17
|
+
const trimmed = source.trim();
|
|
18
|
+
if (!trimmed) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(trimmed);
|
|
23
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load a DP-1 playlist from a local file or hosted URL.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} source - Playlist file path or URL
|
|
33
|
+
* @returns {Promise<LoadedPlaylist>} Loaded playlist payload with source metadata
|
|
34
|
+
* @throws {Error} When source is empty, cannot be loaded, or JSON is invalid
|
|
35
|
+
*/
|
|
36
|
+
async function loadPlaylistSource(source) {
|
|
37
|
+
const trimmedSource = source.trim();
|
|
38
|
+
if (!trimmedSource) {
|
|
39
|
+
throw new Error('Playlist source is required');
|
|
40
|
+
}
|
|
41
|
+
if (isPlaylistSourceUrl(trimmedSource)) {
|
|
42
|
+
const response = await fetch(trimmedSource);
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
let playlistText;
|
|
47
|
+
try {
|
|
48
|
+
playlistText = await response.text();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new Error(`Failed to read playlist response from ${trimmedSource}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return {
|
|
55
|
+
playlist: JSON.parse(playlistText),
|
|
56
|
+
source: trimmedSource,
|
|
57
|
+
sourceType: 'url',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw new Error(`Invalid JSON from playlist URL ${trimmedSource}: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let fileText;
|
|
65
|
+
try {
|
|
66
|
+
fileText = await fs_1.promises.readFile(trimmedSource, 'utf-8');
|
|
67
|
+
}
|
|
68
|
+
catch (_error) {
|
|
69
|
+
throw new Error(`Playlist file not found at ${trimmedSource}`);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return {
|
|
73
|
+
playlist: JSON.parse(fileText),
|
|
74
|
+
source: trimmedSource,
|
|
75
|
+
sourceType: 'file',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`Invalid JSON in ${trimmedSource}: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a `play` command argument to a playable source.
|
|
84
|
+
*
|
|
85
|
+
* Files are always treated as playlists. URLs are tried as playlists first;
|
|
86
|
+
* if loading fails (network, non-JSON, etc.), the URL is wrapped in a
|
|
87
|
+
* synthesized single-item playlist so direct media URLs still work.
|
|
88
|
+
*
|
|
89
|
+
* The "URL → playlist or media" path uses throw-and-fallback because there
|
|
90
|
+
* is no cheap way to distinguish a 200-OK media file from a malformed
|
|
91
|
+
* playlist response without trying to parse it. Keeping the fallback
|
|
92
|
+
* scoped to one helper means the play action no longer carries the
|
|
93
|
+
* `loadedAsPlaylist` boolean dance.
|
|
94
|
+
*
|
|
95
|
+
* @param source - User-supplied path or URL
|
|
96
|
+
* @param defaultDuration - Duration (seconds) for the synthesized media item
|
|
97
|
+
* @returns Resolved playlist + metadata describing how it was loaded
|
|
98
|
+
* @throws Error When `source` is empty, or is a non-URL file path that cannot be loaded
|
|
99
|
+
*/
|
|
100
|
+
async function resolvePlaySource(source, defaultDuration) {
|
|
101
|
+
const trimmed = source.trim();
|
|
102
|
+
if (!trimmed) {
|
|
103
|
+
throw new Error('Playlist source is required');
|
|
104
|
+
}
|
|
105
|
+
if (!isPlaylistSourceUrl(trimmed)) {
|
|
106
|
+
const loaded = await loadPlaylistSource(trimmed);
|
|
107
|
+
return {
|
|
108
|
+
kind: 'playlist',
|
|
109
|
+
playlist: loaded.playlist,
|
|
110
|
+
sourceType: loaded.sourceType,
|
|
111
|
+
source: loaded.source,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const loaded = await loadPlaylistSource(trimmed);
|
|
116
|
+
return {
|
|
117
|
+
kind: 'playlist',
|
|
118
|
+
playlist: loaded.playlist,
|
|
119
|
+
sourceType: loaded.sourceType,
|
|
120
|
+
source: loaded.source,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
const item = buildUrlItem(trimmed, defaultDuration);
|
|
125
|
+
const playlist = (await buildDP1Playlist({ items: [item], title: item.title }));
|
|
126
|
+
return { kind: 'media', playlist, source: trimmed };
|
|
127
|
+
}
|
|
128
|
+
}
|