versioncam 0.4.0 → 0.5.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.
@@ -0,0 +1,891 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join, relative, sep } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { CloudError, cloudClient, } from "../../cloud/client.js";
6
+ import { apiOrigin, credentialsPath, readCredential, removeCredential, saveCredential, } from "../../cloud/credentials.js";
7
+ import { accountName, originRepository, projectName, } from "../../cloud/repository.js";
8
+ import { realClock, waitForChange, waitForToken, waitLimit, } from "../../cloud/wait.js";
9
+ import { inheritedEnv, loadedFrom } from "../../env.js";
10
+ import { DEFAULT_API } from "../../publish/protocol.js";
11
+ import { USAGE } from "../usage.js";
12
+ /**
13
+ * What these commands read from the environment that a file could
14
+ * otherwise set: where the API is, and where the sign-in is kept.
15
+ */
16
+ const SHELL_ONLY = ["VERSIONCAM_API", "XDG_CONFIG_HOME"];
17
+ /** The environment a cloud command runs in, and what the files tried to add to it. */
18
+ export function shellEnvironment() {
19
+ return {
20
+ env: inheritedEnv(),
21
+ ignored: SHELL_ONLY.flatMap((name) => {
22
+ const file = loadedFrom(name);
23
+ return file ? [{ name, file, value: process.env[name] ?? "" }] : [];
24
+ }),
25
+ };
26
+ }
27
+ export function cloud(argv) {
28
+ return runCloud(argv, {
29
+ ...shellEnvironment(),
30
+ cwd: process.cwd(),
31
+ out: (text) => void process.stdout.write(text),
32
+ err: (text) => void process.stderr.write(text),
33
+ fetch: globalThis.fetch,
34
+ clock: realClock,
35
+ hostname,
36
+ });
37
+ }
38
+ const SUBCOMMANDS = {
39
+ login: {
40
+ usage: "versioncam cloud login [--label <name>]",
41
+ flags: ["label"],
42
+ args: [0, 0],
43
+ run: login,
44
+ },
45
+ logout: {
46
+ usage: "versioncam cloud logout",
47
+ flags: [],
48
+ args: [0, 0],
49
+ run: logout,
50
+ },
51
+ status: {
52
+ usage: "versioncam cloud status [owner/repo] [--json]",
53
+ flags: ["json", "project"],
54
+ args: [0, 1],
55
+ run: status,
56
+ },
57
+ failure: {
58
+ usage: "versioncam cloud failure <clip> [--project owner/repo] [--json]",
59
+ flags: ["json", "project"],
60
+ args: [1, 1],
61
+ run: failure,
62
+ },
63
+ ack: {
64
+ usage: "versioncam cloud ack <clip> [--project owner/repo]",
65
+ flags: ["project"],
66
+ args: [1, 1],
67
+ run: ack,
68
+ },
69
+ embed: {
70
+ usage: "versioncam cloud embed <clip> [--project owner/repo] [--json]",
71
+ flags: ["json", "project"],
72
+ args: [1, 1],
73
+ run: embed,
74
+ },
75
+ set: {
76
+ usage: "versioncam cloud set <setting> <value> [--project owner/repo]",
77
+ flags: ["project"],
78
+ args: [2, 3],
79
+ run: set,
80
+ },
81
+ plan: {
82
+ usage: "versioncam cloud plan [account] [--json]",
83
+ flags: ["json"],
84
+ args: [0, 1],
85
+ run: plan,
86
+ },
87
+ };
88
+ export async function runCloud(argv, io) {
89
+ const secrets = [];
90
+ const scrubbed = scrubbing(io, secrets);
91
+ let parsed;
92
+ try {
93
+ parsed = parseCloudArgs(argv);
94
+ }
95
+ catch (error) {
96
+ // Strict, unlike the recording commands: a misspelt `--project` on a
97
+ // command that changes a setting must not quietly mean this directory's.
98
+ scrubbed.err(`${error.message}\n\n${cloudUsage()}`);
99
+ return 1;
100
+ }
101
+ const { values, positionals } = parsed;
102
+ const [name, ...args] = positionals;
103
+ if (!name) {
104
+ scrubbed.out(cloudUsage());
105
+ return 1;
106
+ }
107
+ // Its own names only: `constructor` is on every object, and no command.
108
+ const command = Object.hasOwn(SUBCOMMANDS, name) ? SUBCOMMANDS[name] : null;
109
+ if (!command) {
110
+ scrubbed.err(`Unknown command "cloud ${name}".\n\n${cloudUsage()}`);
111
+ return 1;
112
+ }
113
+ const extra = Object.keys(values).filter((flag) => !command.flags.includes(flag));
114
+ if (extra.length > 0) {
115
+ scrubbed.err(`cloud ${name} takes no --${extra.join(", --")}: ${command.usage}\n`);
116
+ return 1;
117
+ }
118
+ const [least, most] = command.args;
119
+ if (args.length < least || args.length > most) {
120
+ scrubbed.err(`Usage: ${command.usage}\n`);
121
+ return 1;
122
+ }
123
+ // Before anything is sent, so that whoever put the value in the file
124
+ // knows at once why it changed nothing. On stderr, out of `--json`'s way.
125
+ for (const each of io.ignored ?? [])
126
+ scrubbed.err(ignoredText(each));
127
+ const api = (io.env.VERSIONCAM_API || DEFAULT_API).replace(/\/+$/, "");
128
+ let origin;
129
+ try {
130
+ origin = apiOrigin(api);
131
+ if (!/^https?:$/.test(new URL(api).protocol))
132
+ throw new Error();
133
+ }
134
+ catch {
135
+ scrubbed.err(`VERSIONCAM_API is not a web address: ${plain(api)}\n`);
136
+ return 1;
137
+ }
138
+ try {
139
+ return await command.run({
140
+ io: scrubbed,
141
+ api,
142
+ origin,
143
+ args,
144
+ json: values.json === true,
145
+ project: values.project,
146
+ label: values.label,
147
+ secrets,
148
+ });
149
+ }
150
+ catch (error) {
151
+ scrubbed.err(`${refusal(error)}\n`);
152
+ return 1;
153
+ }
154
+ }
155
+ function parseCloudArgs(argv) {
156
+ return parseArgs({
157
+ args: argv,
158
+ options: {
159
+ json: { type: "boolean" },
160
+ project: { type: "string" },
161
+ label: { type: "string" },
162
+ },
163
+ strict: true,
164
+ allowPositionals: true,
165
+ });
166
+ }
167
+ /**
168
+ * Why a file's value did nothing, and how to set it for real. The value is
169
+ * the repository's, so it is never offered back as a command to paste: an
170
+ * address is shown only as the origin it parses to, which cannot carry
171
+ * anything a shell would run, and a path not at all.
172
+ */
173
+ function ignoredText({ name, file, value }) {
174
+ if (name === "VERSIONCAM_API") {
175
+ let origin = null;
176
+ try {
177
+ const url = new URL(value);
178
+ if (/^https?:$/.test(url.protocol))
179
+ origin = url.origin;
180
+ }
181
+ catch {
182
+ // Not an address: said without it.
183
+ }
184
+ return (`${file} sets VERSIONCAM_API${origin ? ` to ${origin}` : ""}, which versioncam cloud ignores: ` +
185
+ "it takes the API's address only from the shell, so that no repository can send a sign-in, or a command holding a token, to a server of its choosing. " +
186
+ "To use another address, set it in the shell: export VERSIONCAM_API=<address>\n");
187
+ }
188
+ return (`${file} sets ${name}, which versioncam cloud ignores: ` +
189
+ "it keeps its sign-in where the shell's environment says, so that no repository can choose where a token is written. " +
190
+ `To keep it elsewhere, set ${name} in the shell.\n`);
191
+ }
192
+ /** The `Manage` lines of the usage text, which are this command's. */
193
+ function cloudUsage() {
194
+ const group = USAGE.split("\n\n").find((block) => block.startsWith("Manage\n"));
195
+ return `${group ?? "versioncam cloud <command>"}\n`;
196
+ }
197
+ /** The output, with every token this run has held taken out of it. */
198
+ function scrubbing(io, secrets) {
199
+ const scrub = (text) => secrets.reduce((written, secret) => secret ? written.split(secret).join("[token]") : written, text);
200
+ return {
201
+ ...io,
202
+ out: (text) => io.out(scrub(text)),
203
+ err: (text) => io.err(scrub(text)),
204
+ };
205
+ }
206
+ /** A refusal in words: the service's sentence, and what it carried with it. */
207
+ function refusal(error) {
208
+ if (!(error instanceof CloudError))
209
+ return error?.message ?? "";
210
+ const lines = [plain(error.message)];
211
+ if (error.code === "plan" && error.upgrade) {
212
+ lines.push(`Upgrade: ${plain(error.upgrade)}`);
213
+ }
214
+ if (error.code === "rate_limited" && error.retryAfter !== null) {
215
+ lines.push(`Try again in ${error.retryAfter} seconds.`);
216
+ }
217
+ return lines.join("\n");
218
+ }
219
+ // --- Signing in --------------------------------------------------------------
220
+ /** The longest label the approval page shows. */
221
+ const LABEL_MAX = 60;
222
+ async function login(c) {
223
+ const label = plainLine(c.label ?? c.io.hostname()).trim();
224
+ if (c.label !== undefined &&
225
+ (label.length === 0 || label.length > LABEL_MAX)) {
226
+ c.io.err(`--label takes a name of 1 to ${LABEL_MAX} characters.\n`);
227
+ return 1;
228
+ }
229
+ const client = cloudClient({ api: c.api, fetch: c.io.fetch });
230
+ const device = await client.device(label.slice(0, LABEL_MAX) || null);
231
+ // Said before the code, while it can still change the person's mind. No
232
+ // repository file can set VERSIONCAM_API for these commands, but a shell
233
+ // still can, a script's or an agent's included, and a service in the
234
+ // middle could pass on a real code and keep the token it buys.
235
+ const own = apiOrigin(DEFAULT_API);
236
+ c.io.out((c.origin === own
237
+ ? ""
238
+ : `This signs in to ${c.origin}, which VERSIONCAM_API names, rather than ${own}.\n`) +
239
+ "To sign in this command line to version.cam, open this address on any device:\n" +
240
+ ` ${plain(device.verification_uri_complete)}\n` +
241
+ `and approve the code ${plainLine(device.user_code)}. Only approve a code you started yourself.\n` +
242
+ `(Or open ${plain(device.verification_uri)} and enter the code there.)\n` +
243
+ `Waiting for the approval, for up to ${minutes(waitLimit(device.expires_in))}.\n`);
244
+ const outcome = await waitForToken(client, device, c.io.clock);
245
+ if (outcome.kind === "denied") {
246
+ c.io.err("The sign-in was denied on the approval page. Nothing was saved.\n");
247
+ return 1;
248
+ }
249
+ if (outcome.kind === "expired" || outcome.kind === "timeout") {
250
+ c.io.err(`Nobody approved the code in time, and it has expired. Run \`versioncam cloud login\` again for a new one.\n`);
251
+ return 1;
252
+ }
253
+ const { token, login: who, expires_at } = outcome.token;
254
+ c.secrets.push(token);
255
+ const before = readCredential(c.io.env, c.api);
256
+ const path = saveCredential(c.io.env, c.api, {
257
+ token,
258
+ login: who,
259
+ expiresAt: expires_at,
260
+ });
261
+ // The sign-in it replaces would otherwise stay good for the rest of its
262
+ // seven days, held by nothing: revoked, and quietly, since the new one is
263
+ // saved whatever the answer.
264
+ if (before && before.token !== token) {
265
+ c.secrets.push(before.token);
266
+ await cloudClient({ api: c.api, token: before.token, fetch: c.io.fetch })
267
+ .revoke()
268
+ .catch(() => undefined);
269
+ }
270
+ c.io.out(`Signed in to ${c.origin} as ${plainLine(who)}, until ${when(expires_at, c.io)}.\n` +
271
+ `The token is kept in ${shown(path, c.io)}, readable only by you.\n`);
272
+ return 0;
273
+ }
274
+ async function logout(c) {
275
+ const credential = readCredential(c.io.env, c.api);
276
+ const path = shown(credentialsPath(c.io.env), c.io);
277
+ if (!credential) {
278
+ c.io.out(`Not signed in to ${c.origin}.\n`);
279
+ return 0;
280
+ }
281
+ c.secrets.push(credential.token);
282
+ const lapsed = expired(credential, c.io);
283
+ if (!lapsed) {
284
+ try {
285
+ await signedClient(c, credential).revoke();
286
+ }
287
+ catch (error) {
288
+ // Unauthorized is the service saying the token is already no good,
289
+ // which is what signing out wants. Anything else may have left it
290
+ // good, so it stays on disk for a second try.
291
+ if (!(error instanceof CloudError) || error.status !== 401) {
292
+ c.io.err(`${refusal(error)}\nThe token was not revoked, and is still in ${path}: run \`versioncam cloud logout\` again.\n`);
293
+ return 1;
294
+ }
295
+ }
296
+ }
297
+ removeCredential(c.io.env, c.api);
298
+ c.io.out(lapsed
299
+ ? `Signed out of ${c.origin}: the sign-in had expired, and is removed from ${path}.\n`
300
+ : `Signed out of ${c.origin}: the token is revoked, and removed from ${path}.\n`);
301
+ return 0;
302
+ }
303
+ /**
304
+ * This API's sign-in, or null once the person has been told how to get one.
305
+ * A token past its own expiry is not sent: the answer is known.
306
+ */
307
+ function signIn(c) {
308
+ const credential = readCredential(c.io.env, c.api);
309
+ if (!credential) {
310
+ c.io.err(`Not signed in to ${c.origin}. Run \`versioncam cloud login\` first.\n`);
311
+ return null;
312
+ }
313
+ c.secrets.push(credential.token);
314
+ if (expired(credential, c.io)) {
315
+ c.io.err(`The sign-in to ${c.origin} as ${plainLine(credential.login)} expired on ${when(credential.expiresAt, c.io)}. Run \`versioncam cloud login\` to sign in again.\n`);
316
+ return null;
317
+ }
318
+ return { credential, client: signedClient(c, credential) };
319
+ }
320
+ function signedClient(c, credential) {
321
+ return cloudClient({
322
+ api: c.api,
323
+ token: credential.token,
324
+ fetch: c.io.fetch,
325
+ });
326
+ }
327
+ function expired(credential, io) {
328
+ const at = Date.parse(credential.expiresAt);
329
+ return Number.isFinite(at) && at <= io.clock.now();
330
+ }
331
+ // --- Which project ----------------------------------------------------------
332
+ /**
333
+ * The project a command is about: the one named, else the GitHub repository
334
+ * this directory is a clone of. Undefined, after saying why, when neither
335
+ * gives one; null for `status` alone, which then lists every project.
336
+ */
337
+ function projectOf(c, named, optional = false) {
338
+ if (named !== undefined) {
339
+ const project = projectName(named);
340
+ if (!project) {
341
+ c.io.err(`"${plainLine(named)}" is not a project: name one as owner/repo.\n`);
342
+ return undefined;
343
+ }
344
+ return project;
345
+ }
346
+ const origin = originRepository(c.io.cwd, c.io.env);
347
+ if (origin)
348
+ return origin;
349
+ if (optional)
350
+ return null;
351
+ c.io.err("This directory is not a clone of a GitHub repository, so name the project: --project owner/repo\n");
352
+ return undefined;
353
+ }
354
+ /** A clip's id as version.cam takes one: a name, never a path. */
355
+ const CLIP_ID = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,120}$/;
356
+ function clipOf(c) {
357
+ const clip = c.args[0];
358
+ if (!CLIP_ID.test(clip)) {
359
+ c.io.err(`"${plainLine(clip)}" is not a clip id: letters, digits, dots, dashes and underscores.\n`);
360
+ return undefined;
361
+ }
362
+ return clip;
363
+ }
364
+ // --- Reading ----------------------------------------------------------------
365
+ async function status(c) {
366
+ if (c.args[0] !== undefined &&
367
+ c.project !== undefined &&
368
+ c.args[0] !== c.project) {
369
+ c.io.err(`Name the project once: ${SUBCOMMANDS.status.usage}\n`);
370
+ return 1;
371
+ }
372
+ const project = projectOf(c, c.args[0] ?? c.project, true);
373
+ if (project === undefined)
374
+ return 1;
375
+ const signed = signIn(c);
376
+ if (!signed)
377
+ return 1;
378
+ if (project === null) {
379
+ const me = await signed.client.me();
380
+ c.io.out(c.json ? json(me) : meText(me));
381
+ return 0;
382
+ }
383
+ const answer = await signed.client.project(project);
384
+ c.io.out(c.json ? json(answer) : statusText(answer, c.io));
385
+ return 0;
386
+ }
387
+ async function failure(c) {
388
+ const clip = clipOf(c);
389
+ if (clip === undefined)
390
+ return 1;
391
+ const project = projectOf(c, c.project);
392
+ if (!project)
393
+ return 1;
394
+ const signed = signIn(c);
395
+ if (!signed)
396
+ return 1;
397
+ const answer = await signed.client.clip(project, clip);
398
+ let saved = null;
399
+ let problem = null;
400
+ if (answer.failure?.picture) {
401
+ try {
402
+ saved = await savePicture(c, answer.failure.picture, clip);
403
+ }
404
+ catch (error) {
405
+ problem = `The picture could not be saved: ${error.message}`;
406
+ }
407
+ }
408
+ c.io.out(c.json
409
+ ? json({ ...answer, savedPicture: saved })
410
+ : failureText(project, answer, saved, c.io));
411
+ if (problem) {
412
+ c.io.err(`${problem}\n`);
413
+ return 1;
414
+ }
415
+ return 0;
416
+ }
417
+ /**
418
+ * The page as the failing run left it, into `.versioncam/failures/`, which
419
+ * every repository set up for versioncam already keeps out of git. Fetched
420
+ * without the token: a private project's picture comes signed for an hour
421
+ * and needs nothing more, and the token goes to the API and nowhere else.
422
+ */
423
+ async function savePicture(c, address, clip) {
424
+ const { io } = c;
425
+ // An address without a host is the API's.
426
+ const url = new URL(address, `${c.api}/`);
427
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
428
+ throw new Error("its address is not a web address");
429
+ }
430
+ const response = await io.fetch(url);
431
+ if (!response.ok)
432
+ throw new Error(`its address answered ${response.status}`);
433
+ const bytes = new Uint8Array(await response.arrayBuffer());
434
+ const dir = join(io.cwd, ".versioncam", "failures");
435
+ mkdirSync(dir, { recursive: true });
436
+ const path = join(dir, `${clip}.png`);
437
+ writeFileSync(path, bytes);
438
+ return relative(io.cwd, path);
439
+ }
440
+ async function ack(c) {
441
+ const clip = clipOf(c);
442
+ if (clip === undefined)
443
+ return 1;
444
+ const project = projectOf(c, c.project);
445
+ if (!project)
446
+ return 1;
447
+ const signed = signIn(c);
448
+ if (!signed)
449
+ return 1;
450
+ const { acknowledged } = await signed.client.ack(project, clip);
451
+ // Whoever said it first is who it stays said by, and the service answers
452
+ // with theirs: someone else's means it was said already, not just now.
453
+ // GitHub's logins are the same name in any case.
454
+ const already = acknowledged.by.toLowerCase() !== signed.credential.login.toLowerCase();
455
+ // What keeps playing is the project's answer, not the acknowledgment's,
456
+ // and an acknowledgment that went through is worth saying without it.
457
+ let plays;
458
+ try {
459
+ const answer = await signed.client.project(project);
460
+ plays = answer.clips.find((each) => each.clip === clip)?.plays ?? null;
461
+ }
462
+ catch {
463
+ plays = undefined;
464
+ }
465
+ // The service's rule: one more reminder thirty days after the
466
+ // acknowledgment, if the clip is still broken then.
467
+ const at = Date.parse(acknowledged.at);
468
+ const reminder = Number.isFinite(at)
469
+ ? `on ${day(new Date(at + 30 * DAY_MS).toISOString(), c.io)}`
470
+ : "in thirty days";
471
+ const who = `${plainLine(acknowledged.by)} on ${when(acknowledged.at, c.io)}`;
472
+ c.io.out((already
473
+ ? `${clip} was acknowledged already, by ${who}.\n`
474
+ : `Acknowledged ${clip}, as ${who}.\n`) +
475
+ (plays === undefined
476
+ ? ""
477
+ : plays === null
478
+ ? "Nothing plays meanwhile: no version of it has passed yet.\n"
479
+ : `Your pages keep playing the version recorded ${when(plays.recordedAt, c.io)} (${commitOf(plays.commit)}).\n`) +
480
+ `${already ? "The reminders stopped then" : "The reminders stop"}. If it is still broken ${reminder}, version.cam reminds the team once more.\n`);
481
+ return 0;
482
+ }
483
+ async function embed(c) {
484
+ const clip = clipOf(c);
485
+ if (clip === undefined)
486
+ return 1;
487
+ const project = projectOf(c, c.project);
488
+ if (!project)
489
+ return 1;
490
+ const signed = signIn(c);
491
+ if (!signed)
492
+ return 1;
493
+ if (c.json) {
494
+ c.io.out(json(await signed.client.clip(project, clip)));
495
+ return 0;
496
+ }
497
+ // The project for its visibility: a private clip's snippets play for
498
+ // nobody on a public page, which is the one thing about them worth
499
+ // saying before anyone pastes one.
500
+ const [answer, status] = await Promise.all([
501
+ signed.client.clip(project, clip),
502
+ signed.client.project(project),
503
+ ]);
504
+ c.io.out(embedText(answer, status.visibility, project));
505
+ return 0;
506
+ }
507
+ async function plan(c) {
508
+ const named = c.args[0];
509
+ if (named !== undefined && !accountName(named)) {
510
+ c.io.err(`"${plainLine(named)}" is not an account's name.\n`);
511
+ return 1;
512
+ }
513
+ const signed = signIn(c);
514
+ if (!signed)
515
+ return 1;
516
+ // This repository's owner, whose plan its project is on; outside a
517
+ // clone, the person's own account.
518
+ const account = named ??
519
+ originRepository(c.io.cwd, c.io.env)?.split("/")[0] ??
520
+ signed.credential.login;
521
+ const answer = await signed.client.plan(account);
522
+ c.io.out(c.json ? json(answer) : planText(answer, c.io));
523
+ return 0;
524
+ }
525
+ // --- Changing a setting -------------------------------------------------------
526
+ const SETTINGS = "visibility <public|unlisted|private>, retention <days|off>, viewers <on|off>, " +
527
+ "alerts email <address|off>, alerts slack <address|off>, alerts issues <on|off>";
528
+ /** The setting and value as the API takes them, or why not. */
529
+ export function changeRequest(args) {
530
+ const [setting, ...rest] = args;
531
+ const onOff = (value) => value === "on" ? true : value === "off" ? false : null;
532
+ const wrong = `cloud set takes one of: ${SETTINGS}.`;
533
+ if (setting === "alerts") {
534
+ const [kind, value, ...more] = rest;
535
+ if (value === undefined || more.length > 0)
536
+ return wrong;
537
+ if (kind === "email") {
538
+ if (value === "off")
539
+ return { alertEmail: null };
540
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
541
+ ? { alertEmail: value }
542
+ : `"${plainLine(value)}" is not an email address.`;
543
+ }
544
+ if (kind === "slack") {
545
+ if (value === "off")
546
+ return { slackWebhook: null };
547
+ // Only its shape: the service says which hooks it takes. And the
548
+ // address is never repeated back, since anyone who has it can post.
549
+ return /^https:\/\/\S+$/.test(value)
550
+ ? { slackWebhook: value }
551
+ : "A Slack webhook is an https:// address, from Slack's incoming webhooks.";
552
+ }
553
+ if (kind === "issues") {
554
+ const issues = onOff(value);
555
+ return issues === null ? wrong : { staleIssues: issues };
556
+ }
557
+ return wrong;
558
+ }
559
+ const [value, ...more] = rest;
560
+ if (value === undefined || more.length > 0)
561
+ return wrong;
562
+ if (setting === "visibility") {
563
+ return ["public", "unlisted", "private"].includes(value)
564
+ ? { visibility: value }
565
+ : `Visibility is public, unlisted or private, not "${plainLine(value)}".`;
566
+ }
567
+ if (setting === "retention") {
568
+ // What the settings form takes: whole days from one, or nothing, which
569
+ // keeps every version the plan allows.
570
+ if (value === "off")
571
+ return { retentionDays: null };
572
+ return /^\d{1,4}$/.test(value) && Number(value) >= 1
573
+ ? { retentionDays: Number(value) }
574
+ : "Retention is a number of days, from 1 to 9999, or off.";
575
+ }
576
+ if (setting === "viewers") {
577
+ const viewers = onOff(value);
578
+ return viewers === null ? wrong : { showBroken: viewers };
579
+ }
580
+ return wrong;
581
+ }
582
+ async function set(c) {
583
+ const request = changeRequest(c.args);
584
+ if (typeof request === "string") {
585
+ c.io.err(`${request}\n`);
586
+ return 1;
587
+ }
588
+ const project = projectOf(c, c.project);
589
+ if (!project)
590
+ return 1;
591
+ const signed = signIn(c);
592
+ if (!signed)
593
+ return 1;
594
+ const pending = await signed.client.change(project, request);
595
+ c.io.out(`${plain(pending.describe)}\n\n` +
596
+ `To approve it, open this address, signed in as ${plainLine(signed.credential.login)}:\n` +
597
+ ` ${plain(pending.verification_uri_complete)}\n` +
598
+ `and check that the page shows the code ${plainLine(pending.user_code)} and this change.\n` +
599
+ `Nothing changes until it is approved. Waiting, for up to ${minutes(waitLimit(pending.expires_in))}.\n`);
600
+ let outcome;
601
+ try {
602
+ outcome = await waitForChange(signed.client, project, pending, c.io.clock);
603
+ }
604
+ catch (error) {
605
+ // The change was asked for, and its person may have approved it while
606
+ // the service could not say so: whether it applied is the settings'
607
+ // to tell, not this command's to guess.
608
+ c.io.err(`${refusal(error)}\nWhether the change was approved meanwhile, the project's settings on app.version.cam say.\n`);
609
+ return 1;
610
+ }
611
+ if (outcome === "applied") {
612
+ c.io.out("Approved, and applied.\n" +
613
+ ("alertEmail" in request && request.alertEmail
614
+ ? "The address is sent a link first, and told nothing until its owner opens it and says yes.\n"
615
+ : ""));
616
+ return 0;
617
+ }
618
+ c.io.err(outcome === "denied"
619
+ ? "Denied on the approval page: nothing changed.\n"
620
+ : "Nobody approved the change in time, and it has expired: nothing changed.\n");
621
+ return 1;
622
+ }
623
+ // --- Words ------------------------------------------------------------------
624
+ const DAY_MS = 24 * 60 * 60 * 1000;
625
+ /** Who can see the clips, in the README's words for each visibility. */
626
+ const VISIBILITY = {
627
+ public: "public (anyone, and search engines)",
628
+ unlisted: "unlisted (anyone with the address, kept out of search)",
629
+ private: "private (whoever GitHub lets read the repository, signed in)",
630
+ };
631
+ function statusText(answer, io) {
632
+ const name = plainLine(answer.project);
633
+ const broken = answer.clips.filter((clip) => clip.status === "broken");
634
+ const lines = [
635
+ answer.clips.length === 0
636
+ ? `${name} has no clips yet: they appear when it first publishes.`
637
+ : `${name}: ${count(answer.clips.length, "clip")}, ${broken.length} broken.`,
638
+ ];
639
+ if (answer.clips.length > 0)
640
+ lines.push("");
641
+ // A private project's clip addresses are signed, and open for anyone who
642
+ // has one, for an hour. Listed as a clip's address, one would be pasted
643
+ // into a page, or a pull request, and leak the clip; so they are not.
644
+ const signed = answer.visibility === "private";
645
+ const ids = answer.clips.map((clip) => plainLine(clip.clip));
646
+ const width = Math.max(0, ...ids.map((id) => id.length));
647
+ const pad = " ".repeat(width + 4);
648
+ answer.clips.forEach((clip, i) => {
649
+ lines.push(` ${ids[i].padEnd(width)} ${clipState(clip, answer, io)}`);
650
+ lines.push(`${pad}${playsText(clip.plays, io)}`);
651
+ if (!signed)
652
+ lines.push(`${pad}${plainLine(clip.url)}`);
653
+ });
654
+ lines.push("", `Visibility: ${VISIBILITY[answer.visibility] ?? plainLine(answer.visibility)}`, ...(signed && answer.clips.length > 0
655
+ ? [
656
+ "Private clips' addresses are signed for an hour, and open for anyone who has one, so none is shown here: watch the clips from the project's page.",
657
+ ]
658
+ : []), `Plan: ${plainLine(answer.plan.title)} (${answer.plan.alerts
659
+ ? "version.cam tells the team when a clip breaks"
660
+ : "CI says when a clip breaks; version.cam tells nobody"})`,
661
+ // The setting's own name on the dashboard, in `set viewers`'s words.
662
+ `Tell viewers when a clip is broken: ${answer.showBroken ? "on" : "off"}`);
663
+ if (answer.plan.alerts)
664
+ lines.push(`Alerts: ${alertsText(answer.alerts)}`);
665
+ lines.push(`This month: ${count(answer.usage.publishes, "publish", "publishes")}, ` +
666
+ `${round(answer.usage.renderSeconds / 60)} render minutes, ` +
667
+ `${size(answer.usage.storageBytes)} stored`, answer.permission === "admin"
668
+ ? "You may change its settings."
669
+ : "You may see it; its administrators change its settings.", `Manage it: ${plain(answer.manage)}`);
670
+ return `${lines.join("\n")}\n`;
671
+ }
672
+ function clipState(clip, answer, io) {
673
+ if (clip.status !== "broken") {
674
+ return clip.checkedAt
675
+ ? `current, checked ${when(clip.checkedAt, io)}`
676
+ : "current";
677
+ }
678
+ const since = clip.brokenSince ? ` since ${when(clip.brokenSince, io)}` : "";
679
+ if (clip.acknowledged) {
680
+ return `broken${since}; acknowledged by ${plainLine(clip.acknowledged.by)} on ${when(clip.acknowledged.at, io)}`;
681
+ }
682
+ // Free has nothing to acknowledge: nobody is reminded.
683
+ return `broken${since}${answer.plan.alerts ? "; not acknowledged" : ""}`;
684
+ }
685
+ function playsText(plays, io) {
686
+ return plays
687
+ ? `plays the version recorded ${when(plays.recordedAt, io)} (${commitOf(plays.commit)})`
688
+ : "plays nothing: no version of it has passed yet";
689
+ }
690
+ function alertsText(alerts) {
691
+ if (!alerts)
692
+ return "the repository's administrators see who is told";
693
+ const told = [
694
+ alerts.email
695
+ ? `email to ${plainLine(alerts.email)}${alerts.emailConfirmed ? "" : " (waiting for its owner to confirm it)"}`
696
+ : null,
697
+ alerts.slack ? "Slack" : null,
698
+ alerts.issues ? "GitHub issues" : null,
699
+ ].filter((each) => each !== null);
700
+ return told.length > 0 ? told.join(", ") : "none";
701
+ }
702
+ function meText(me) {
703
+ const lines = [
704
+ `Signed in as ${plainLine(me.login)}. No project named, and this directory is not a clone of a GitHub repository, so here is every project you can see:`,
705
+ ];
706
+ for (const account of me.accounts) {
707
+ // No plan is an account whose plan the dashboard would not show this
708
+ // person either: named, and nothing said of its plan.
709
+ lines.push("", `${plainLine(account.login)}${account.plan ? `, on ${plainLine(account.plan.title)}` : ""}`);
710
+ const names = account.projects.map((p) => plainLine(p.project));
711
+ const width = Math.max(0, ...names.map((name) => name.length));
712
+ account.projects.forEach((project, i) => {
713
+ lines.push(` ${names[i].padEnd(width)} ${plainLine(project.visibility)}, ${count(project.clips, "clip")}` +
714
+ (project.broken > 0 ? `, ${project.broken} broken` : ""));
715
+ });
716
+ if (account.projects.length === 0)
717
+ lines.push(" no projects yet");
718
+ }
719
+ if (me.accounts.length === 0)
720
+ lines.push("", "No projects yet.");
721
+ lines.push("", "versioncam cloud status owner/repo says more about one.");
722
+ return `${lines.join("\n")}\n`;
723
+ }
724
+ function failureText(project, answer, saved, io) {
725
+ const failure = answer.failure;
726
+ const id = plainLine(answer.clip);
727
+ if (!failure) {
728
+ return answer.status === "broken"
729
+ ? `${id} is broken, and version.cam has no failure for it.\n`
730
+ : `${id} is not broken: its last check on ${project}'s default branch passed.\n`;
731
+ }
732
+ const lines = [
733
+ `${id} broke on ${when(failure.since, io)}, at ${commitOf(failure.commit)}.`,
734
+ failure.step
735
+ ? `It stopped at step ${failure.step.index}, ${plainLine(failure.step.call)}:`
736
+ : "It stopped before its first step:",
737
+ ...plain(failure.message)
738
+ .split("\n")
739
+ .map((line) => ` ${line}`),
740
+ ];
741
+ if (saved)
742
+ lines.push(`The page when it stopped: ${saved}`);
743
+ else if (!failure.picture)
744
+ lines.push("There is no picture: the run stopped before a page was there.");
745
+ lines.push(`To see it here: npx versioncam record --draft ${id}`);
746
+ return `${lines.join("\n")}\n`;
747
+ }
748
+ function embedText(answer, visibility, project) {
749
+ // Nothing of the clip has passed on the default branch yet, so its page
750
+ // has nothing to play, and there is nothing to embed: a plain answer,
751
+ // not a failure, since nothing is wrong with asking.
752
+ if (!answer.embed) {
753
+ const id = plainLine(answer.clip);
754
+ return (`${id} has no version to embed yet: none has passed on ${project}'s default branch.\n` +
755
+ `Once one passes, versioncam cloud embed ${id} prints its snippets.\n`);
756
+ }
757
+ const snippets = [
758
+ [
759
+ "A player that sizes itself, for a page that runs scripts",
760
+ answer.embed.script,
761
+ ],
762
+ ["An iframe, for a page that does not", answer.embed.iframe],
763
+ ["The video alone", answer.embed.video],
764
+ ["Markdown, for a README", answer.embed.markdown],
765
+ ];
766
+ const lines = [`${plainLine(answer.clip)}, at ${plainLine(answer.url)}`];
767
+ for (const [what, snippet] of snippets) {
768
+ if (snippet)
769
+ lines.push("", `${what}:`, plain(snippet));
770
+ }
771
+ const files = Object.entries(answer.files ?? {}).filter((entry) => typeof entry[1] === "string");
772
+ if (files.length > 0) {
773
+ const width = Math.max(...files.map(([kind]) => kind.length));
774
+ lines.push("", "Files:");
775
+ for (const [kind, url] of files)
776
+ lines.push(` ${kind.padEnd(width)} ${plain(url)}`);
777
+ }
778
+ // The snippets play the unsigned living address, which opens only for
779
+ // whoever may read the repository; every address in the answer itself,
780
+ // the clip's own and the GIF's among the files', is signed instead, and
781
+ // opens for anyone who has it until the hour is up.
782
+ if (visibility === "private") {
783
+ lines.push("", `${project}'s clips are private. These snippets play only for people signed in at app.version.cam who may read the repository, so never on a public page.`, "The clip's address above and its files', the GIF's included, are signed: each opens for anyone who has it, for an hour. Use them to look, and never put one in a page, a pull request or an issue.", "To show the clip on a public page, its clips have to be public or unlisted: versioncam cloud set visibility unlisted");
784
+ }
785
+ return `${lines.join("\n")}\n`;
786
+ }
787
+ /** Why the account is on its plan, which says who can change it. */
788
+ const SOURCE = {
789
+ free: "",
790
+ beta: ", during the beta",
791
+ subscription: ", by subscription",
792
+ override: ", set by version.cam",
793
+ };
794
+ function planText(answer, io) {
795
+ const { plan } = answer;
796
+ // The private projects the plan covers, as the service counts them: Free
797
+ // one, Pro as many as it pays for (ten in the beta or when version.cam
798
+ // sets it), Max up to ten, which is a ceiling, and null for any number.
799
+ const quantity = plan.quantity === null
800
+ ? " for any number of private projects"
801
+ : typeof plan.quantity === "number"
802
+ ? ` for ${plan.name === "max" ? "up to " : ""}${count(plan.quantity, "private project")}`
803
+ : "";
804
+ const until = plan.until ? `, until ${day(plan.until, io)}` : "";
805
+ const lines = [
806
+ `${plainLine(answer.account)}: ${plainLine(plan.title)}${quantity}${SOURCE[plan.source] ?? ""}${until}.`,
807
+ `Upgrade or change plans: ${plain(answer.upgrade)}`,
808
+ answer.billing
809
+ ? `Billing: ${plain(answer.billing)}`
810
+ : `Billing: for whoever may bill ${plainLine(answer.account)}.`,
811
+ ];
812
+ return `${lines.join("\n")}\n`;
813
+ }
814
+ /**
815
+ * What came from the service, safe to write to a terminal. Clip titles and
816
+ * failure messages are written by whoever published, and a control
817
+ * character in one could rewrite the screen it is printed on, or reverse
818
+ * the text around it. Newlines and tabs stay.
819
+ */
820
+ function plain(text) {
821
+ return (String(text)
822
+ // A terminal's escape sequences whole, colours and cursor moves, so
823
+ // that a message from a test runner that colours its errors reads as
824
+ // words rather than as `[31m`.
825
+ .replace(/(?![\n\t])\p{Cc}\[[0-?]*[ -/]*[@-~]/gu, "")
826
+ // Then every other control character but those two, and the marks
827
+ // that set the direction text runs in.
828
+ .replace(/(?![\n\t])[\p{Cc}\p{Bidi_Control}]/gu, ""));
829
+ }
830
+ /** The same, on one line. */
831
+ function plainLine(text) {
832
+ return plain(text).replace(/[\n\t]+/g, " ");
833
+ }
834
+ function json(value) {
835
+ return `${JSON.stringify(value, null, 2)}\n`;
836
+ }
837
+ /** "5 Oct 2026, 14:02", in the machine's own time zone. */
838
+ function when(iso, io) {
839
+ const date = new Date(iso);
840
+ if (Number.isNaN(date.getTime()))
841
+ return plainLine(iso);
842
+ return new Intl.DateTimeFormat("en-GB", {
843
+ day: "numeric",
844
+ month: "short",
845
+ year: "numeric",
846
+ hour: "2-digit",
847
+ minute: "2-digit",
848
+ hourCycle: "h23",
849
+ timeZone: io.timeZone,
850
+ }).format(date);
851
+ }
852
+ /** "5 Oct 2026". */
853
+ function day(iso, io) {
854
+ const date = new Date(iso);
855
+ if (Number.isNaN(date.getTime()))
856
+ return plainLine(iso);
857
+ return new Intl.DateTimeFormat("en-GB", {
858
+ day: "numeric",
859
+ month: "short",
860
+ year: "numeric",
861
+ timeZone: io.timeZone,
862
+ }).format(date);
863
+ }
864
+ function minutes(ms) {
865
+ return count(Math.round(ms / 60000), "minute");
866
+ }
867
+ function commitOf(commit) {
868
+ return plainLine(commit).slice(0, 7);
869
+ }
870
+ function count(n, one, many = `${one}s`) {
871
+ return `${n} ${n === 1 ? one : many}`;
872
+ }
873
+ function round(n) {
874
+ return String(Math.round(n * 10) / 10);
875
+ }
876
+ function size(bytes) {
877
+ if (bytes >= 1e9)
878
+ return `${round(bytes / 1e9)} GB`;
879
+ if (bytes >= 1e6)
880
+ return `${round(bytes / 1e6)} MB`;
881
+ return `${Math.round(bytes / 1e3)} kB`;
882
+ }
883
+ /** A path as a person would type it: under `~` when it is in the home directory. */
884
+ function shown(path, io) {
885
+ const home = io.env.HOME;
886
+ if (home && (path === home || path.startsWith(`${home}${sep}`))) {
887
+ return `~${path.slice(home.length)}`;
888
+ }
889
+ return path;
890
+ }
891
+ //# sourceMappingURL=cloud.js.map