@feralfile/cli 3.0.0 → 4.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/README.md +20 -17
- package/config.json.example +1 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/device.js +84 -39
- package/dist/src/commands/enrich.js +2 -2
- package/dist/src/commands/find.js +71 -37
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/play.js +39 -26
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/config.js +162 -6
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/playlist-builder.js +3 -3
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +7 -7
- package/dist/src/utilities/raster-client.js +1 -1
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/docs/CONFIGURATION.md +270 -28
- package/docs/EXAMPLES.md +31 -31
- package/docs/PROJECT_SPEC.md +32 -19
- package/docs/README.md +113 -36
- package/package.json +5 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hiddenAliasHelp = void 0;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
/**
|
|
6
|
+
* dropAlias removes `cmd`'s first alias from a help term commander built, turning `display|play` back
|
|
7
|
+
* into `display`.
|
|
8
|
+
*
|
|
9
|
+
* Commander adds the alias as `name|alias` in exactly these two terms and nowhere else, so replacing
|
|
10
|
+
* that one token is enough; rebuilding the terms from scratch would copy commander internals that
|
|
11
|
+
* are not exported (humanReadableArgName).
|
|
12
|
+
*
|
|
13
|
+
* @param {string} term - The term commander built
|
|
14
|
+
* @param {Command} cmd - The command it describes
|
|
15
|
+
* @returns {string} The term without the alias
|
|
16
|
+
*/
|
|
17
|
+
function dropAlias(term, cmd) {
|
|
18
|
+
const alias = cmd.alias();
|
|
19
|
+
return alias ? term.replace(`${cmd.name()}|${alias}`, cmd.name()) : term;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* hiddenAliasHelp is a commander help configuration that names a command by its name only, never
|
|
23
|
+
* by its alias.
|
|
24
|
+
*
|
|
25
|
+
* An alias in ff-cli is a retired command name kept so existing scripts and agents keep working:
|
|
26
|
+
* `play` became `display` (B&F weekly 2026-11-03; Canon reference/naming-brand.md §3.4). Commander
|
|
27
|
+
* would print it in the command list and the usage line (`display|play`), and help is where people
|
|
28
|
+
* and agents learn the CLI's words, so printing it would teach the retired verb again. The alias
|
|
29
|
+
* still parses and `ff-cli help play` still works; only the printed help drops it.
|
|
30
|
+
*
|
|
31
|
+
* Apply it to the program (the command list uses subcommandTerm) and to every aliased command (its
|
|
32
|
+
* own usage line uses commandUsage): `addCommand` does not copy help configuration to subcommands.
|
|
33
|
+
*/
|
|
34
|
+
exports.hiddenAliasHelp = {
|
|
35
|
+
subcommandTerm: (cmd) => dropAlias(new commander_1.Help().subcommandTerm(cmd), cmd),
|
|
36
|
+
commandUsage: (cmd) => dropAlias(new commander_1.Help().commandUsage(cmd), cmd),
|
|
37
|
+
};
|
|
@@ -3,7 +3,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.displayPlaylistSummary = displayPlaylistSummary;
|
|
7
6
|
exports.printPlaylistSourceLoadFailure = printPlaylistSourceLoadFailure;
|
|
8
7
|
exports.printPlaylistVerificationFailure = printPlaylistVerificationFailure;
|
|
9
8
|
exports.validatePlaylistSource = validatePlaylistSource;
|
|
@@ -11,18 +10,6 @@ exports.runValidateCommand = runValidateCommand;
|
|
|
11
10
|
exports.runVerifyCommand = runVerifyCommand;
|
|
12
11
|
const chalk_1 = __importDefault(require("chalk"));
|
|
13
12
|
const playlist_source_1 = require("../../utilities/playlist-source");
|
|
14
|
-
/**
|
|
15
|
-
* Print the standard "playlist saved" footer with the next-step hint.
|
|
16
|
-
*
|
|
17
|
-
* Used by `chat` and `build` after they write a playlist to disk, so the
|
|
18
|
-
* surface message stays consistent across both commands.
|
|
19
|
-
*/
|
|
20
|
-
function displayPlaylistSummary(playlist, outputPath) {
|
|
21
|
-
console.log(chalk_1.default.green('\nPlaylist saved'));
|
|
22
|
-
console.log(chalk_1.default.dim(` Output: ./${outputPath}`));
|
|
23
|
-
console.log(chalk_1.default.dim(' Next: play last | publish playlist'));
|
|
24
|
-
console.log();
|
|
25
|
-
}
|
|
26
13
|
/**
|
|
27
14
|
* Print a focused failure for playlist source loading problems with a
|
|
28
15
|
* URL-vs-file aware hint block.
|
|
@@ -45,7 +32,7 @@ function printPlaylistSourceLoadFailure(source, error) {
|
|
|
45
32
|
}
|
|
46
33
|
/**
|
|
47
34
|
* Print playlist validation failure details consistently for commands that
|
|
48
|
-
* validate structure before sending (for example `
|
|
35
|
+
* validate structure before sending (for example `display`).
|
|
49
36
|
*/
|
|
50
37
|
function printPlaylistVerificationFailure(verifyResult, source) {
|
|
51
38
|
console.error(chalk_1.default.red(`\nPlaylist validation failed:${source ? ` (${source})` : ''}`), verifyResult.error);
|
|
@@ -55,7 +42,7 @@ function printPlaylistVerificationFailure(verifyResult, source) {
|
|
|
55
42
|
console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
|
|
56
43
|
});
|
|
57
44
|
}
|
|
58
|
-
console.log(chalk_1.default.yellow('\n Use --skip-verify to
|
|
45
|
+
console.log(chalk_1.default.yellow('\n Use --skip-verify to display anyway (not recommended)\n'));
|
|
59
46
|
}
|
|
60
47
|
/**
|
|
61
48
|
* Load a playlist from a path or URL and run parse-only validation (`validatePlaylist`).
|
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.displayCommand = void 0;
|
|
7
7
|
exports.describeDiscoveryInput = describeDiscoveryInput;
|
|
8
8
|
exports.probeDiscoveryUrlAsPlaylist = probeDiscoveryUrlAsPlaylist;
|
|
9
9
|
const commander_1 = require("commander");
|
|
@@ -13,20 +13,21 @@ const config_1 = require("../config");
|
|
|
13
13
|
const playlist_source_1 = require("../utilities/playlist-source");
|
|
14
14
|
const playlist_cast_1 = require("../utilities/playlist-cast");
|
|
15
15
|
const playlist_display_1 = require("./helpers/playlist-display");
|
|
16
|
+
const hidden_alias_help_1 = require("./helpers/hidden-alias-help");
|
|
16
17
|
/**
|
|
17
|
-
* Human label for a discovery input that `
|
|
18
|
+
* Human label for a discovery input that `display` cannot cast directly.
|
|
18
19
|
*
|
|
19
|
-
* `
|
|
20
|
-
* playlist URL, or a direct media/web URL). A marketplace URL, on-chain
|
|
20
|
+
* `display` casts an exact source it can send as it is (a playlist file, a
|
|
21
|
+
* hosted playlist URL, or a direct media/web URL). A marketplace URL, on-chain
|
|
21
22
|
* coordinates, or a wallet address must be *resolved* through the indexer
|
|
22
|
-
* first — that is `find`'s job. Returns a label for those inputs so `
|
|
23
|
+
* first — that is `find`'s job. Returns a label for those inputs so `display`
|
|
23
24
|
* can redirect the user instead of silently wrapping, say, an Art Blocks
|
|
24
|
-
* collection *page* as a web item. Returns null for anything `
|
|
25
|
+
* collection *page* as a web item. Returns null for anything `display` should
|
|
25
26
|
* handle itself (plain URLs and file paths parse to null here).
|
|
26
27
|
*
|
|
27
28
|
* A recognized-but-`unsupported` result (e.g. a legacy Art Blocks
|
|
28
29
|
* `/projects/{id}` URL, or an unsupported-chain OpenSea link) is also a
|
|
29
|
-
* discovery input — it's a marketplace URL, not a
|
|
30
|
+
* discovery input — it's a marketplace URL, not a file it can send — so it is
|
|
30
31
|
* redirected to `find` too, which surfaces the specific reason.
|
|
31
32
|
*/
|
|
32
33
|
function describeDiscoveryInput(source) {
|
|
@@ -72,11 +73,11 @@ function describeDiscoveryInput(source) {
|
|
|
72
73
|
* which wrongly redirected DP-1 feed playlist URLs
|
|
73
74
|
* (https://feed.feralfile.com/api/v1/playlists/{slug}, exactly the URLs the
|
|
74
75
|
* feed hands out) to `find`, which has no feed route either; the documented
|
|
75
|
-
* "hosted playlist URL
|
|
76
|
+
* "hosted playlist URL displays directly" contract was unreachable for them
|
|
76
77
|
* (found 2026-08-02 casting a published playlist to an FF1).
|
|
77
78
|
*
|
|
78
79
|
* Probing the document keeps both behaviors without a host allowlist: a URL
|
|
79
|
-
* that serves DP-1 JSON (`dpVersion` marker)
|
|
80
|
+
* that serves DP-1 JSON (`dpVersion` marker) displays directly; a marketplace
|
|
80
81
|
* *page* fails the probe (non-JSON, or JSON with no marker) and still
|
|
81
82
|
* redirects to `find` instead of being wrapped as a web item. The probe
|
|
82
83
|
* costs one fetch before a redirect, only for discovery inputs that are
|
|
@@ -84,7 +85,7 @@ function describeDiscoveryInput(source) {
|
|
|
84
85
|
*
|
|
85
86
|
* A URL that does not exist (404/410, no such host, a broken `.json` link)
|
|
86
87
|
* is refused here rather than redirected: the redirect also suggests
|
|
87
|
-
* `
|
|
88
|
+
* `display --web`, which would cast the error page.
|
|
88
89
|
*
|
|
89
90
|
* @throws {Error} When the URL does not exist (refusalForLoadFailure)
|
|
90
91
|
*/
|
|
@@ -114,14 +115,14 @@ async function probeDiscoveryUrlAsPlaylist(source) {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
/**
|
|
117
|
-
* resolveWebPageSource wraps `source` as a web page for `
|
|
118
|
+
* resolveWebPageSource wraps `source` as a web page for `display --web`, without
|
|
118
119
|
* the discovery redirect or the playlist probe.
|
|
119
120
|
*
|
|
120
121
|
* `--web` exists because the discovery parser claims whole hosts: any page on
|
|
121
122
|
* feralfile.com, opensea.io, objkt.com and the like is redirected to `find`,
|
|
122
123
|
* and `find` rejects every page that is not an artwork it can resolve (a
|
|
123
124
|
* homepage, an exhibition overview, a profile). Before `--web` there was no
|
|
124
|
-
* way to put such a page on
|
|
125
|
+
* way to put such a page on display; the two commands pointed at each other.
|
|
125
126
|
*
|
|
126
127
|
* @param {string} source - The URL the caller passed
|
|
127
128
|
* @returns {Promise<PlaySource>} A `media` source with a `text/html` item
|
|
@@ -135,12 +136,24 @@ async function resolveWebPageSource(source) {
|
|
|
135
136
|
const note = await (0, playlist_source_1.checkWebPageUrl)(url);
|
|
136
137
|
return (0, playlist_source_1.buildMediaPlaySource)(url, { asWebPage: true, note });
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
/**
|
|
140
|
+
* displayCommand puts a playlist, media URL, or web page on an FF1.
|
|
141
|
+
*
|
|
142
|
+
* The verb for art is display, not play (B&F weekly 2026-11-03; Canon
|
|
143
|
+
* reference/naming-brand.md §3.4): people play games and video, they display
|
|
144
|
+
* art. `play` was this command's name, so it stays as an alias that existing
|
|
145
|
+
* scripts and agents keep calling; hiddenAliasHelp keeps it out of every help
|
|
146
|
+
* screen so help teaches only `display`. Do not drop the alias without a
|
|
147
|
+
* decision to break those callers.
|
|
148
|
+
*/
|
|
149
|
+
exports.displayCommand = new commander_1.Command('display')
|
|
150
|
+
.alias('play')
|
|
151
|
+
.configureHelp(hidden_alias_help_1.hiddenAliasHelp)
|
|
152
|
+
.description('Display a playlist file, playlist URL, media URL, or web page on an FF1 device')
|
|
140
153
|
.argument('<source>', 'Playlist file, playlist URL, or any media or web page URL')
|
|
141
154
|
.option('-d, --device <name>', 'Device name (uses first device if not specified)')
|
|
142
|
-
.option('--web', '
|
|
143
|
-
.option('--skip-verify', 'Skip signature verification and auto-sign gating before
|
|
155
|
+
.option('--web', 'Display the URL as a web page, even on a marketplace site where display would otherwise send you to find')
|
|
156
|
+
.option('--skip-verify', 'Skip signature verification and auto-sign gating before displaying; use only when you accept malformed envelopes')
|
|
144
157
|
.action(async (source, options) => {
|
|
145
158
|
try {
|
|
146
159
|
const resolved = options.web
|
|
@@ -159,14 +172,14 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
159
172
|
}
|
|
160
173
|
});
|
|
161
174
|
/**
|
|
162
|
-
* resolveDefaultSource resolves a `
|
|
175
|
+
* resolveDefaultSource resolves a `display` argument without `--web`: discovery
|
|
163
176
|
* inputs go to `find`, everything else loads as a playlist or wraps as media.
|
|
164
177
|
*
|
|
165
|
-
* `
|
|
178
|
+
* `display` casts an exact source as it is. A marketplace URL / on-chain coords /
|
|
166
179
|
* wallet must be resolved through the indexer first, so it redirects to `find`
|
|
167
180
|
* rather than silently wrapping the *page* as a web item. Exception: a
|
|
168
181
|
* discovery-classified URL that actually serves a DP-1 playlist document (the
|
|
169
|
-
* discovery parser claims whole hosts, catching feed playlist URLs)
|
|
182
|
+
* discovery parser claims whole hosts, catching feed playlist URLs) displays
|
|
170
183
|
* directly; see probeDiscoveryUrlAsPlaylist. The redirect names `--web` too,
|
|
171
184
|
* for the caller who did mean the page.
|
|
172
185
|
*
|
|
@@ -181,12 +194,12 @@ async function resolveDefaultSource(source, device) {
|
|
|
181
194
|
probed = await probeDiscoveryUrlAsPlaylist(source);
|
|
182
195
|
}
|
|
183
196
|
if (discovery && !probed) {
|
|
184
|
-
console.error(chalk_1.default.red('\n`
|
|
197
|
+
console.error(chalk_1.default.red('\n`display` casts an exact playlist or media URL — it does not search marketplaces.'));
|
|
185
198
|
console.error(chalk_1.default.dim(` "${source}" looks like ${discovery}, which needs to be resolved first.`));
|
|
186
199
|
const deviceFlag = device ? ` -d "${device}"` : '';
|
|
187
|
-
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --
|
|
200
|
+
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --display -y${deviceFlag}`));
|
|
188
201
|
if ((0, playlist_source_1.isPlaylistSourceUrl)(source)) {
|
|
189
|
-
console.log(chalk_1.default.dim(`To show the page itself: ff-cli
|
|
202
|
+
console.log(chalk_1.default.dim(`To show the page itself: ff-cli display --web "${source}"${deviceFlag}`));
|
|
190
203
|
}
|
|
191
204
|
process.exit(1);
|
|
192
205
|
}
|
|
@@ -199,7 +212,7 @@ async function resolveDefaultSource(source, device) {
|
|
|
199
212
|
* a resolved source, then reports the outcome. Exits 1 on any failure.
|
|
200
213
|
*
|
|
201
214
|
* @param {PlaySource} resolved - What to cast
|
|
202
|
-
* @param {Object} options - The `
|
|
215
|
+
* @param {Object} options - The `display` options
|
|
203
216
|
* @returns {Promise<void>}
|
|
204
217
|
*/
|
|
205
218
|
async function castResolved(resolved, options) {
|
|
@@ -207,7 +220,7 @@ async function castResolved(resolved, options) {
|
|
|
207
220
|
const sourceLabel = isPlaylistSource
|
|
208
221
|
? `${resolved.sourceType}: ${resolved.source}`
|
|
209
222
|
: resolved.source;
|
|
210
|
-
console.log(chalk_1.default.blue('\
|
|
223
|
+
console.log(chalk_1.default.blue('\nDisplay on FF1\n'));
|
|
211
224
|
if (resolved.kind === 'media' && resolved.note) {
|
|
212
225
|
console.log(chalk_1.default.yellow(resolved.note));
|
|
213
226
|
}
|
|
@@ -236,7 +249,7 @@ async function castResolved(resolved, options) {
|
|
|
236
249
|
console.error(chalk_1.default.red(`\n${result.error}`));
|
|
237
250
|
}
|
|
238
251
|
else {
|
|
239
|
-
console.error(chalk_1.default.red('\
|
|
252
|
+
console.error(chalk_1.default.red('\nDisplay failed:'), result.error);
|
|
240
253
|
if (result.deviceDetails) {
|
|
241
254
|
console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
|
|
242
255
|
}
|
|
@@ -246,7 +259,7 @@ async function castResolved(resolved, options) {
|
|
|
246
259
|
if (result.verified) {
|
|
247
260
|
console.log(chalk_1.default.green(result.signed ? '✓ Signed and verified\n' : '✓ Verified\n'));
|
|
248
261
|
}
|
|
249
|
-
console.log(chalk_1.default.green('✓
|
|
262
|
+
console.log(chalk_1.default.green('✓ Displaying'));
|
|
250
263
|
if (result.deviceName) {
|
|
251
264
|
console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
|
|
252
265
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.reportCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const ff1_compatibility_1 = require("../utilities/ff1-compatibility");
|
|
12
|
+
const support_report_1 = require("../utilities/support-report");
|
|
13
|
+
/** REPORT_HELP says what a report carries, so a caller knows what leaves the machine before sending. */
|
|
14
|
+
const REPORT_HELP = `
|
|
15
|
+
The report text comes from --message or --file (not both), or from stdin when
|
|
16
|
+
neither is given and stdin is not a terminal. It goes privately to Feral File
|
|
17
|
+
support with this ff-cli version, the OS, and the Node version, and with
|
|
18
|
+
--email when given. Nothing else leaves this computer: no config, keys, or
|
|
19
|
+
playlists.
|
|
20
|
+
|
|
21
|
+
With -d, the report also carries the Art Computer's device ID, FF OS version and
|
|
22
|
+
branch, and once it is in, the Art Computer is asked to upload its logs under
|
|
23
|
+
the same reference. It uploads in the background and needs internet. On FF OS
|
|
24
|
+
with secure control, that request needs ff-cli paired with the owner role.
|
|
25
|
+
|
|
26
|
+
Exit status: 0 when the report reached Feral File support, even if the Art
|
|
27
|
+
Computer did not take the log request; 1 when it did not.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
ff-cli report --message "The work displays black on the Art Computer; in a desktop browser it renders." -d Office
|
|
31
|
+
ff-cli report --file report.txt --email you@example.com
|
|
32
|
+
printf '%s\\n' "$REPORT" | ff-cli report -d Office --dry-run
|
|
33
|
+
`;
|
|
34
|
+
/**
|
|
35
|
+
* `ff-cli report` sends a problem report to Feral File support, the same
|
|
36
|
+
* pipeline the app's Support & Feedback feeds, so an agent driving ff-cli can
|
|
37
|
+
* report what went wrong without a person pressing send. The wire contract and
|
|
38
|
+
* what may be sent live in utilities/support-report.ts.
|
|
39
|
+
*/
|
|
40
|
+
exports.reportCommand = new commander_1.Command('report')
|
|
41
|
+
.description("Send a problem report to Feral File support, with an Art Computer's logs when one is involved")
|
|
42
|
+
.option('--message <text>', 'The report text')
|
|
43
|
+
.option('--file <path>', 'Read the report text from this file')
|
|
44
|
+
.option('--email <address>', 'Where Feral File support may reply: a bare address, name@example.com')
|
|
45
|
+
.option('-d, --device <name>', 'The Art Computer involved: adds its device ID, version and branch, and asks it to upload its logs')
|
|
46
|
+
.option('--dry-run', 'Print the JSON that would be sent, and send nothing')
|
|
47
|
+
.addHelpText('after', REPORT_HELP)
|
|
48
|
+
.action(async (options, command) => {
|
|
49
|
+
try {
|
|
50
|
+
// The program's own version, so the report carries exactly what
|
|
51
|
+
// `ff-cli --version` prints (index.ts reads the root package.json).
|
|
52
|
+
await runReport(options, command.parent?.version() || 'unknown');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* runReport checks the report, reads the Art Computer's status, posts the
|
|
61
|
+
* report, then asks the Art Computer for its logs. Every refusal before the
|
|
62
|
+
* post exits 1 with nothing sent; after the post the exit status is 0.
|
|
63
|
+
*
|
|
64
|
+
* @param options - Command options
|
|
65
|
+
* @param ffCliVersion - The version `ff-cli --version` prints
|
|
66
|
+
* @returns Promise that resolves when the report is done
|
|
67
|
+
*/
|
|
68
|
+
async function runReport(options, ffCliVersion) {
|
|
69
|
+
const report = await readReport(options);
|
|
70
|
+
const email = options.email?.trim();
|
|
71
|
+
checkReport(report, email);
|
|
72
|
+
const { baseUrl } = (0, config_1.getSupportLogsConfig)();
|
|
73
|
+
const device = options.device !== undefined ? selectDevice(options.device) : undefined;
|
|
74
|
+
const deviceBlock = device ? await describeDevice(device) : undefined;
|
|
75
|
+
// One serialization, so --dry-run prints the very bytes a real run posts.
|
|
76
|
+
const payload = (0, support_report_1.buildReportPayload)({ report, ffCliVersion, email, device: deviceBlock });
|
|
77
|
+
const body = JSON.stringify(payload, null, 2);
|
|
78
|
+
if (options.dryRun) {
|
|
79
|
+
printDryRun(body, baseUrl, device);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const posted = await (0, support_report_1.postReport)(baseUrl, body);
|
|
83
|
+
if (posted.ok === false) {
|
|
84
|
+
exitWith(posted);
|
|
85
|
+
}
|
|
86
|
+
printReceipt(posted.receipt, email);
|
|
87
|
+
await askForLogs(device, posted.receipt);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* readReport returns the trimmed report text from exactly one source. Flags
|
|
91
|
+
* win and stdin is read only when neither is given: an agent's stdin is often
|
|
92
|
+
* a pipe that carries nothing, and reading it beside a flag would wait on it.
|
|
93
|
+
*
|
|
94
|
+
* @param options - Command options
|
|
95
|
+
* @returns The report text, possibly empty
|
|
96
|
+
*/
|
|
97
|
+
async function readReport(options) {
|
|
98
|
+
if (options.message !== undefined && options.file !== undefined) {
|
|
99
|
+
exitWith({ error: 'Give the report once: --message or --file, not both.' });
|
|
100
|
+
}
|
|
101
|
+
if (options.message !== undefined) {
|
|
102
|
+
return options.message.trim();
|
|
103
|
+
}
|
|
104
|
+
if (options.file !== undefined) {
|
|
105
|
+
try {
|
|
106
|
+
return (await fs_1.promises.readFile(options.file, 'utf-8')).trim();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
exitWith({ error: `Could not read --file ${options.file}: ${error.message}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (process.stdin.isTTY) {
|
|
113
|
+
exitWith({
|
|
114
|
+
error: 'No report given.',
|
|
115
|
+
details: 'Pass --message "<text>" or --file <path>, or pipe the text on stdin.',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return (await readStdin()).trim();
|
|
119
|
+
}
|
|
120
|
+
/** readStdin reads all of stdin as UTF-8. */
|
|
121
|
+
function readStdin() {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
let data = '';
|
|
124
|
+
process.stdin.setEncoding('utf8');
|
|
125
|
+
process.stdin.on('data', (chunk) => {
|
|
126
|
+
data += chunk;
|
|
127
|
+
});
|
|
128
|
+
process.stdin.on('end', () => resolve(data));
|
|
129
|
+
process.stdin.on('error', reject);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* checkReport refuses a report support-logs would refuse, a reply address
|
|
134
|
+
* that is not one, and a report carrying this computer's signing key.
|
|
135
|
+
*
|
|
136
|
+
* @param report - Trimmed report text
|
|
137
|
+
* @param email - Trimmed --email value; an empty one is refused, not dropped
|
|
138
|
+
*/
|
|
139
|
+
function checkReport(report, email) {
|
|
140
|
+
if (!report) {
|
|
141
|
+
exitWith({
|
|
142
|
+
error: 'The report is empty.',
|
|
143
|
+
details: 'If the text came from a shell variable, check that it is set.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const bytes = Buffer.byteLength(report, 'utf8');
|
|
147
|
+
if (bytes > support_report_1.MAX_REPORT_BYTES) {
|
|
148
|
+
exitWith({
|
|
149
|
+
error: `The report is ${bytes} bytes; Feral File support takes at most ${support_report_1.MAX_REPORT_BYTES}.`,
|
|
150
|
+
details: "Shorten it. With -d the Art Computer's logs travel separately, so leave log excerpts out.",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (email !== undefined && !(0, support_report_1.isBareAddress)(email)) {
|
|
154
|
+
exitWith({
|
|
155
|
+
error: `--email "${email}" is not an address Feral File support can reply to.`,
|
|
156
|
+
details: 'Give a bare ASCII address such as name@example.com, at most 254 characters. ' +
|
|
157
|
+
"Before the @: 1-64 characters of letters, digits and ! # $ % & ' * + / = ? ^ _ ` { | } ~ -, with single dots only between them. " +
|
|
158
|
+
'After it: two or more dot-separated labels of letters, digits and hyphens, no label starting or ending with a hyphen. ' +
|
|
159
|
+
'No display name, angle brackets, quotes, spaces, IP address, underscore in the domain, or non-ASCII characters.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (email !== undefined && (0, support_report_1.isReservedAddress)(email)) {
|
|
163
|
+
exitWith({
|
|
164
|
+
error: `--email "${email}" is an address Feral File support does not reply to.`,
|
|
165
|
+
details: "Support's own address and postmaster or mailer-daemon mailboxes are refused. Give the address where you want the reply, or leave --email out.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if ((0, support_report_1.containsSigningKey)(report, (0, config_1.getPlaylistConfig)().privateKey)) {
|
|
169
|
+
exitWith({
|
|
170
|
+
error: "The report contains this computer's playlist signing key.",
|
|
171
|
+
details: 'Remove the key and run the command again. Nothing was sent.',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** selectDevice resolves -d the way `display -d` does, or exits 1 naming the configured devices. */
|
|
176
|
+
function selectDevice(name) {
|
|
177
|
+
const selected = (0, ff1_compatibility_1.resolveConfiguredDevice)(name);
|
|
178
|
+
if (!selected.success || !selected.device) {
|
|
179
|
+
exitWith({ error: selected.error || 'The Art Computer is not configured correctly.' });
|
|
180
|
+
}
|
|
181
|
+
return selected.device;
|
|
182
|
+
}
|
|
183
|
+
/** describeDevice reads the Art Computer block, noting a failed status read without stopping. */
|
|
184
|
+
async function describeDevice(device) {
|
|
185
|
+
const status = await (0, support_report_1.readDeviceStatus)(device);
|
|
186
|
+
if (status.error) {
|
|
187
|
+
console.error(chalk_1.default.yellow(`Could not read the status of ${deviceLabel(device)} (${status.error}); the report goes without its version and branch.`));
|
|
188
|
+
}
|
|
189
|
+
return status.device;
|
|
190
|
+
}
|
|
191
|
+
/** printDryRun prints the body on stdout and what a real run would do on stderr. */
|
|
192
|
+
function printDryRun(body, baseUrl, device) {
|
|
193
|
+
process.stdout.write(`${body}\n`);
|
|
194
|
+
console.error(chalk_1.default.dim(`Dry run: nothing was sent. A real run posts this to ${baseUrl}/v2/cli/reports`));
|
|
195
|
+
if (device) {
|
|
196
|
+
console.error(chalk_1.default.dim(`Then it asks ${deviceLabel(device)} to upload its logs under the reference that comes back.`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** printReceipt reports the reference in the app's `Support [<id>]` form and who support can reply to. */
|
|
200
|
+
function printReceipt(receipt, email) {
|
|
201
|
+
console.log(chalk_1.default.green('✓ Report sent to Feral File support'));
|
|
202
|
+
console.log(` Reference: Support [${receipt.supportBundleId}]`);
|
|
203
|
+
console.log(chalk_1.default.dim(email
|
|
204
|
+
? ` Support may reply to ${email}`
|
|
205
|
+
: ' No --email given, so support has no address to reply to'));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* askForLogs asks the Art Computer to upload its logs under the report's
|
|
209
|
+
* reference and prints the outcome. Best effort: a failure prints a line and
|
|
210
|
+
* leaves the exit status at 0, since the report already reached support.
|
|
211
|
+
*
|
|
212
|
+
* @param device - Art Computer named with -d, if any
|
|
213
|
+
* @param receipt - The posted report's receipt
|
|
214
|
+
*/
|
|
215
|
+
async function askForLogs(device, receipt) {
|
|
216
|
+
if (!device) {
|
|
217
|
+
console.log(chalk_1.default.dim(' No Art Computer named (-d), so no device logs were requested'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const label = deviceLabel(device);
|
|
221
|
+
if (!receipt.uploadToken) {
|
|
222
|
+
console.log(chalk_1.default.yellow(`${label} was not asked to upload its logs: support returned no upload token.`));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const result = await (0, support_report_1.requestLogUpload)(device, {
|
|
226
|
+
supportBundleId: receipt.supportBundleId,
|
|
227
|
+
uploadToken: receipt.uploadToken,
|
|
228
|
+
});
|
|
229
|
+
if (result.success) {
|
|
230
|
+
console.log(chalk_1.default.green(`✓ Asked ${label} to upload its logs under the same reference`));
|
|
231
|
+
console.log(chalk_1.default.dim(' It uploads in the background and needs internet to do it'));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
console.log(chalk_1.default.yellow(`${label} did not take the request to upload its logs: ${result.error}`));
|
|
235
|
+
if (result.details) {
|
|
236
|
+
console.log(chalk_1.default.dim(` ${result.details}`));
|
|
237
|
+
}
|
|
238
|
+
console.log(chalk_1.default.dim(' The report itself reached support.'));
|
|
239
|
+
}
|
|
240
|
+
/** deviceLabel names a device in output. */
|
|
241
|
+
function deviceLabel(device) {
|
|
242
|
+
return device.name || device.host;
|
|
243
|
+
}
|
|
244
|
+
/** exitWith prints a failure and exits 1. */
|
|
245
|
+
function exitWith(failure) {
|
|
246
|
+
console.error(chalk_1.default.red(`\n${failure.error}`));
|
|
247
|
+
if (failure.details) {
|
|
248
|
+
console.error(chalk_1.default.dim(` ${failure.details}`));
|
|
249
|
+
}
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
@@ -318,7 +318,7 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
318
318
|
console.log(chalk_1.default.yellow(' • Add an FF1 device host'));
|
|
319
319
|
}
|
|
320
320
|
}
|
|
321
|
-
console.log(chalk_1.default.dim('\nRun: ff-cli
|
|
321
|
+
console.log(chalk_1.default.dim('\nRun: ff-cli display <url-or-playlist>'));
|
|
322
322
|
}
|
|
323
323
|
catch (error) {
|
|
324
324
|
console.error(chalk_1.default.red('\nSetup failed:'), error.message);
|