funoteka 0.1.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/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { entryPoint } from "./entry.js";
|
|
4
|
+
function pidPath(dbPath) {
|
|
5
|
+
return `${dbPath}.pid`;
|
|
6
|
+
}
|
|
7
|
+
function logPath(dbPath) {
|
|
8
|
+
return `${dbPath}.log`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Whether a process with this pid exists right now. Signal 0 asks and sends nothing.
|
|
12
|
+
*
|
|
13
|
+
* `EPERM` is the answer that matters and the one an obvious implementation gets
|
|
14
|
+
* wrong: it means the process is *there* and this one may not signal it. Reading
|
|
15
|
+
* it as "gone" would report a running server as stopped and — worse — let the
|
|
16
|
+
* next start overwrite a claim that was true. The rule is borrowed from
|
|
17
|
+
* `agensyn`'s runner, where the same check guards the same decision.
|
|
18
|
+
*/
|
|
19
|
+
export function alive(pid) {
|
|
20
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return err.code === 'EPERM';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What the pid file says, if it says anything and the process it names is there.
|
|
32
|
+
*
|
|
33
|
+
* Null for a file that is missing, unreadable, or not laid out the way this
|
|
34
|
+
* wrote it — a file this cannot parse describes nothing it can act on, and
|
|
35
|
+
* guessing at its contents is how a stop command signals the wrong process.
|
|
36
|
+
*/
|
|
37
|
+
export function running(dbPath) {
|
|
38
|
+
let record;
|
|
39
|
+
try {
|
|
40
|
+
record = JSON.parse(readFileSync(pidPath(dbPath), 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof record?.pid !== 'number' || !alive(record.pid))
|
|
46
|
+
return null;
|
|
47
|
+
return record;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Write the claim, once the socket is actually listening.
|
|
51
|
+
*
|
|
52
|
+
* Best-effort, and deliberately so: this runs inside the server's `listen`
|
|
53
|
+
* callback, where a throw would take down a server that is up, listening and
|
|
54
|
+
* answering. What is lost when the write fails is `stop` and `status` finding
|
|
55
|
+
* it — which is bad, and is not as bad as the server itself — so the failure is
|
|
56
|
+
* said on stderr rather than swallowed, and the server keeps serving.
|
|
57
|
+
*/
|
|
58
|
+
export function claim(dbPath, record) {
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(pidPath(dbPath), `${JSON.stringify(record, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
process.stderr.write(`funoteka: serving, but could not write ${pidPath(dbPath)}: ${err.message}\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function release(dbPath) {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(pidPath(dbPath), { force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// A file that cannot be removed leaves a claim the next reader will check
|
|
72
|
+
// against the process table and find false, which is the answer anyway.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Start a server that does not belong to this shell.
|
|
77
|
+
*
|
|
78
|
+
* The child is the same program with the same arguments minus `--daemon`, so
|
|
79
|
+
* there is exactly one implementation of serving and no second path that could
|
|
80
|
+
* drift from it. It is detached and its output goes to the log rather than to a
|
|
81
|
+
* terminal that will be gone in a moment — a daemon whose output goes nowhere
|
|
82
|
+
* cannot say why it died.
|
|
83
|
+
*
|
|
84
|
+
* Readiness is the child's own report, not a timer: it writes the pid file when
|
|
85
|
+
* the socket is listening, so this waits for that file rather than for a
|
|
86
|
+
* number of milliseconds that would be wrong on both a slow and a fast machine.
|
|
87
|
+
* A child that exits instead is a failure, and its complaint is in the log.
|
|
88
|
+
*/
|
|
89
|
+
export async function start(dbPath, argv, timeoutMs = 15_000) {
|
|
90
|
+
const existing = running(dbPath);
|
|
91
|
+
if (existing !== null) {
|
|
92
|
+
return { ok: false, message: `already running: pid ${existing.pid} on port ${existing.port}` };
|
|
93
|
+
}
|
|
94
|
+
// A pid file naming a process that is gone describes a server that is not
|
|
95
|
+
// there; it is cleared so that this start's readiness check cannot pass on it.
|
|
96
|
+
release(dbPath);
|
|
97
|
+
// Where this attempt's output starts. Everything before it belongs to an
|
|
98
|
+
// earlier run, and a failure reported with a previous run's complaint in it
|
|
99
|
+
// would send whoever reads it after the wrong problem.
|
|
100
|
+
const mark = logSize(dbPath);
|
|
101
|
+
const log = openSync(logPath(dbPath), 'a');
|
|
102
|
+
const child = spawn(process.execPath,
|
|
103
|
+
// The entry point as *this* build spells it — `.ts` beside the sources, `.js`
|
|
104
|
+
// in the compiled build the npm package ships (see `cli/entry.ts`).
|
|
105
|
+
[entryPoint('../cli'), 'serve', ...argv], {
|
|
106
|
+
detached: true,
|
|
107
|
+
// Nothing on stdin: a daemon that could read a terminal would be waiting
|
|
108
|
+
// on a prompt nobody is there to answer.
|
|
109
|
+
stdio: ['ignore', log, log],
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
env: process.env,
|
|
112
|
+
});
|
|
113
|
+
closeSync(log);
|
|
114
|
+
let exited = null;
|
|
115
|
+
child.on('exit', (code) => {
|
|
116
|
+
exited = code ?? -1;
|
|
117
|
+
});
|
|
118
|
+
child.unref();
|
|
119
|
+
const deadline = Date.now() + timeoutMs;
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
if (exited !== null)
|
|
122
|
+
break;
|
|
123
|
+
const record = running(dbPath);
|
|
124
|
+
if (record !== null)
|
|
125
|
+
return { ok: true, message: 'started', record };
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
127
|
+
}
|
|
128
|
+
// What it said on the way out, quoted into the message. A start that failed
|
|
129
|
+
// with "see the log file" makes the operator open the log to learn a sentence
|
|
130
|
+
// the daemon already knows, and the sentence is usually the whole answer —
|
|
131
|
+
// "no credentials", "address already in use".
|
|
132
|
+
const complaint = lastLine(dbPath, mark);
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
message: exited === null
|
|
136
|
+
? `did not start within ${Math.round(timeoutMs / 1000)}s${complaint} — see ${logPath(dbPath)}`
|
|
137
|
+
: `exited with ${exited}${complaint} — see ${logPath(dbPath)}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** The last thing this attempt wrote to the log, or nothing if it wrote nothing. */
|
|
141
|
+
function lastLine(dbPath, from) {
|
|
142
|
+
try {
|
|
143
|
+
const written = readFileSync(logPath(dbPath), 'utf8').slice(from);
|
|
144
|
+
const lines = written.split(/\r?\n/).filter((line) => line.trim() !== '');
|
|
145
|
+
const last = lines.at(-1);
|
|
146
|
+
return last === undefined ? '' : `: ${last.trim()}`;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Stop it.
|
|
154
|
+
*
|
|
155
|
+
* The process is killed rather than asked to stop, and on Windows there is no
|
|
156
|
+
* choice: a signal has no handler to reach, so `SIGTERM` terminates the process
|
|
157
|
+
* on the spot. That is safe here, and not by luck — the meta layer is SQLite in
|
|
158
|
+
* WAL mode, which is built to survive a process that stops mid-write, and the
|
|
159
|
+
* server holds no state that is not already on disk.
|
|
160
|
+
*/
|
|
161
|
+
export async function stop(dbPath, timeoutMs = 10_000) {
|
|
162
|
+
const record = running(dbPath);
|
|
163
|
+
if (record === null) {
|
|
164
|
+
release(dbPath);
|
|
165
|
+
return { ok: false, message: 'not running' };
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
process.kill(record.pid);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
return { ok: false, message: `could not stop pid ${record.pid}: ${err.message}` };
|
|
172
|
+
}
|
|
173
|
+
const deadline = Date.now() + timeoutMs;
|
|
174
|
+
while (Date.now() < deadline && alive(record.pid)) {
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
176
|
+
}
|
|
177
|
+
if (alive(record.pid)) {
|
|
178
|
+
return { ok: false, message: `pid ${record.pid} is still there after ${timeoutMs / 1000}s` };
|
|
179
|
+
}
|
|
180
|
+
release(dbPath);
|
|
181
|
+
return { ok: true, message: `stopped pid ${record.pid}` };
|
|
182
|
+
}
|
|
183
|
+
/** How big the log has grown, for `status` to say where the server has been talking. */
|
|
184
|
+
export function logSize(dbPath) {
|
|
185
|
+
try {
|
|
186
|
+
return statSync(logPath(dbPath)).size;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export { logPath, pidPath };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/**
|
|
4
|
+
* A sibling entry point, named as *this* build spells it.
|
|
5
|
+
*
|
|
6
|
+
* Two places spawn this program as a child: the scanner the admin surface starts
|
|
7
|
+
* (`POST /scan` and the interval timer), and the daemon's own start. Both used to
|
|
8
|
+
* name `cli.ts`, which is right in the repository — Node strips the types itself —
|
|
9
|
+
* and wrong in the compiled build the npm package ships, where the file is
|
|
10
|
+
* `cli.js` and no `cli.ts` exists. The package would install, answer `--help`, and
|
|
11
|
+
* then fail to scan or to daemonise, which is the kind of breakage that looks
|
|
12
|
+
* like a missing feature rather than a missing file.
|
|
13
|
+
*
|
|
14
|
+
* Asking the filesystem instead of inferring the answer from the caller's own
|
|
15
|
+
* extension makes it checkable rather than assumed: the emitted file is preferred
|
|
16
|
+
* because a tree that has one is a built tree, the source is the fallback, and
|
|
17
|
+
* neither existing names the source — so the failure reads as "the build did not
|
|
18
|
+
* run" rather than as a `.js` that was never going to be there.
|
|
19
|
+
*/
|
|
20
|
+
export function entryPoint(relative, from = import.meta.url) {
|
|
21
|
+
const emitted = fileURLToPath(new URL(`${relative}.js`, from));
|
|
22
|
+
if (existsSync(emitted))
|
|
23
|
+
return emitted;
|
|
24
|
+
return fileURLToPath(new URL(`${relative}.ts`, from));
|
|
25
|
+
}
|
package/dist/cli/keys.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { addKey, listKeys, revokeKey } from "../api/keys.js";
|
|
2
|
+
/**
|
|
3
|
+
* The `keys` command, as text a person reads.
|
|
4
|
+
*
|
|
5
|
+
* Kept out of the entry point for the same reason the other reports are: what
|
|
6
|
+
* the command *says* is testable without spawning a process, and the wording is
|
|
7
|
+
* the part of a key registry that decides whether anyone dares use it.
|
|
8
|
+
*
|
|
9
|
+
* **The environment's key is listed, and it is listed first.** A person asking
|
|
10
|
+
* "which keys does this server accept" is owed the answer in full — a listing
|
|
11
|
+
* that showed only the registered ones would say "one key" to a server that
|
|
12
|
+
* accepts two, and the one it left out is the one every existing client is
|
|
13
|
+
* probably using. What it says about that key is where it lives, because that is
|
|
14
|
+
* where it is revoked.
|
|
15
|
+
*/
|
|
16
|
+
export function reportKeys(db, environmentKey) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
if (environmentKey !== '') {
|
|
19
|
+
lines.push('from the environment (FUNOTEKA_APIKEY) — always valid, revoked by editing start.cmd:', ` ${environmentKey}`, '');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
// **Said even though there is nothing to show.** The daemon is started by
|
|
23
|
+
// `start.cmd`, which sets this variable in its own process; a person running
|
|
24
|
+
// this command in an ordinary shell has it unset while the server they are
|
|
25
|
+
// asking about has it set. Silence here reads as "none" — which is how you
|
|
26
|
+
// spend an afternoon looking for a key that is working exactly as intended.
|
|
27
|
+
lines.push('from the environment (FUNOTEKA_APIKEY): not set in this shell.', ' The daemon is started by start.cmd, which sets it in its own process — this', ' command does not read that file. If a client is using a key that is not listed', ' below, that is where it is, and editing that file is how it is revoked.', '');
|
|
28
|
+
}
|
|
29
|
+
const keys = listKeys(db);
|
|
30
|
+
const active = keys.filter((key) => key.revokedAt === null);
|
|
31
|
+
lines.push(active.length === 0
|
|
32
|
+
? 'registered keys: none'
|
|
33
|
+
: `registered keys (${active.length} active):`, ...active.map((key) => ` #${key.id} ${key.label} added ${key.createdAt}\n ${key.secret}`));
|
|
34
|
+
const revoked = keys.filter((key) => key.revokedAt !== null);
|
|
35
|
+
if (revoked.length > 0) {
|
|
36
|
+
lines.push('', `revoked (${revoked.length}) — kept so that one taken back cannot be added again:`, ...revoked.map((key) => ` #${key.id} ${key.label} revoked ${key.revokedAt}`));
|
|
37
|
+
}
|
|
38
|
+
return `${lines.join('\n')}\n`;
|
|
39
|
+
}
|
|
40
|
+
/** What `keys add` says: the secret, once, and the id to revoke it by. */
|
|
41
|
+
export function addedKey(key) {
|
|
42
|
+
return (`#${key.id} ${key.label}\n` +
|
|
43
|
+
` ${key.secret}\n` +
|
|
44
|
+
' A client presents this as `apiKey`, on its own — no `u` beside it.\n' +
|
|
45
|
+
` Take it back with: funoteka keys revoke ${key.id}\n`);
|
|
46
|
+
}
|
|
47
|
+
/** What `keys revoke` says: the key that stopped being accepted. */
|
|
48
|
+
export function revokedKey(key) {
|
|
49
|
+
return (`#${key.id} ${key.label} is revoked as of ${key.revokedAt}.\n` +
|
|
50
|
+
' Any client still using it is refused from now on, with no restart.\n');
|
|
51
|
+
}
|
|
52
|
+
export { addKey, revokeKey };
|