ossclip 0.1.30 → 0.1.33
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 +2 -0
- package/editor-dist/assets/index-DA7vDWn_.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-report.ts +26 -1
- package/src/edit.ts +490 -1
- package/src/produce.ts +287 -19
- package/src/program.ts +63 -0
- package/src/publish.ts +352 -0
- package/src/replay-argv.ts +16 -0
- package/src/ui/animation.ts +5 -1
- package/editor-dist/assets/index-DfTreNoE.js +0 -166
package/src/publish.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { InvalidArgumentError } from "commander";
|
|
5
|
+
import {
|
|
6
|
+
YOUTUBE_APPROVED_BASENAME,
|
|
7
|
+
YoutubePackSchema,
|
|
8
|
+
buildPostsPayload,
|
|
9
|
+
captionForProvider,
|
|
10
|
+
createPostizProvider,
|
|
11
|
+
loadConfig,
|
|
12
|
+
type OssclipConfig,
|
|
13
|
+
type PublishPost,
|
|
14
|
+
type PublishProvider,
|
|
15
|
+
type PublishReceipt,
|
|
16
|
+
type PublishTarget,
|
|
17
|
+
type PublishWhen,
|
|
18
|
+
type YoutubePack,
|
|
19
|
+
} from "@ossclip/core";
|
|
20
|
+
import { readRecordedCommand, recordedOutPath } from "./cover";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `ossclip publish` — push the finished render to the user's own social
|
|
24
|
+
* accounts through their self-hosted Postiz instance (publish/postiz.ts has
|
|
25
|
+
* the API contract). This file is the CLI orchestration; every decision that
|
|
26
|
+
* can be pure IS pure and exported, so the edit server's /api/publish speaks
|
|
27
|
+
* the same code instead of a second spelling of it.
|
|
28
|
+
*
|
|
29
|
+
* Publishing is ALWAYS an explicit act: the human-review doctrine (README's
|
|
30
|
+
* "review the output before publishing") means nothing here runs at the end
|
|
31
|
+
* of produce, and a workdir that already published refuses to double-post
|
|
32
|
+
* without --force.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The double-post guard's file — holds the last `PublishReceipt`. */
|
|
36
|
+
export const PUBLISH_RECEIPT_BASENAME = "publish-receipt.json";
|
|
37
|
+
|
|
38
|
+
export function publishReceiptPath(workdir: string): string {
|
|
39
|
+
return join(workdir, PUBLISH_RECEIPT_BASENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `--at <iso>` → a validated FUTURE instant (§93a: reject, never coerce — a
|
|
44
|
+
* typo'd date must not schedule a post for 1970 or fire immediately).
|
|
45
|
+
* Exported so the rejection matrix is testable without commander's exit.
|
|
46
|
+
*/
|
|
47
|
+
export function atFlag(v: string, now: () => number = Date.now): string {
|
|
48
|
+
const ms = Date.parse(v);
|
|
49
|
+
if (Number.isNaN(ms)) {
|
|
50
|
+
throw new InvalidArgumentError(
|
|
51
|
+
`--at wants an ISO-8601 time like 2026-09-01T08:00:00+02:00, got "${v}"`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (ms <= now()) {
|
|
55
|
+
throw new InvalidArgumentError(`--at wants a time in the future, got "${v}" (already passed)`);
|
|
56
|
+
}
|
|
57
|
+
return new Date(ms).toISOString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `--platforms a,b` → trimmed, lowercased, deduped identifiers. Rejects an
|
|
61
|
+
* empty list; whether an identifier MATCHES anything is checked against the
|
|
62
|
+
* live integrations list, where the error can name what actually exists. */
|
|
63
|
+
export function platformsFlag(v: string): string[] {
|
|
64
|
+
const list = [...new Set(v.split(",").map((p) => p.trim().toLowerCase()).filter((p) => p.length > 0))];
|
|
65
|
+
if (list.length === 0) {
|
|
66
|
+
throw new InvalidArgumentError(`--platforms wants a comma-separated list like "linkedin,instagram", got "${v}"`);
|
|
67
|
+
}
|
|
68
|
+
return list;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `--accounts id1,id2` → trimmed ids (matched against integration ids). */
|
|
72
|
+
export function accountsFlag(v: string): string[] {
|
|
73
|
+
const list = v.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
74
|
+
if (list.length === 0) {
|
|
75
|
+
throw new InvalidArgumentError(`--accounts wants a comma-separated list of integration ids, got "${v}"`);
|
|
76
|
+
}
|
|
77
|
+
return list;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Postiz configuration, resolved: `postizUrl` from config.json (non-secret),
|
|
82
|
+
* the API key from the environment ONLY (env.ts's rule — secrets never live
|
|
83
|
+
* in config.json). The error names exactly what's missing and where it goes,
|
|
84
|
+
* because "publish isn't configured" without the fix is a support ticket.
|
|
85
|
+
*/
|
|
86
|
+
export type PublishConfig =
|
|
87
|
+
| { ok: true; baseUrl: string; apiKey: string }
|
|
88
|
+
| { ok: false; message: string };
|
|
89
|
+
|
|
90
|
+
export const POSTIZ_API_KEY_ENV = "OSSCLIP_POSTIZ_API_KEY";
|
|
91
|
+
|
|
92
|
+
export function publishConfigured(
|
|
93
|
+
config: Pick<OssclipConfig, "postizUrl">,
|
|
94
|
+
env: NodeJS.ProcessEnv,
|
|
95
|
+
): PublishConfig {
|
|
96
|
+
const url = typeof config.postizUrl === "string" ? config.postizUrl.trim() : "";
|
|
97
|
+
const key = env[POSTIZ_API_KEY_ENV]?.trim() ?? "";
|
|
98
|
+
const missing: string[] = [];
|
|
99
|
+
if (url.length === 0) missing.push(`"postizUrl" in ~/.ossclip/config.json (your Postiz instance's URL)`);
|
|
100
|
+
if (key.length === 0) missing.push(`${POSTIZ_API_KEY_ENV} in the environment or ~/.ossclip/.env (Postiz → Settings → Public API)`);
|
|
101
|
+
if (missing.length > 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
message: `publish needs a self-hosted Postiz instance (https://postiz.com). Missing: ${missing.join("; ")}.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, baseUrl: url, apiKey: key };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Which connected accounts this run posts to — the pure selection rules the
|
|
112
|
+
* CLI flags and the editor's checkbox list both compile down to.
|
|
113
|
+
*
|
|
114
|
+
* - `accounts` picks by integration id, and an unknown id is an ERROR naming
|
|
115
|
+
* the known ones (a typo must not silently post to fewer places);
|
|
116
|
+
* - `platforms` filters by provider identifier, and a platform with no
|
|
117
|
+
* connected account is an ERROR naming what IS connected;
|
|
118
|
+
* - `all` (or the editor sending explicit ids) takes what's left.
|
|
119
|
+
* An empty result is always an error — "publish to nothing" is never intent.
|
|
120
|
+
*/
|
|
121
|
+
export function selectTargets(
|
|
122
|
+
targets: PublishTarget[],
|
|
123
|
+
opts: { platforms?: string[]; accounts?: string[]; all?: boolean },
|
|
124
|
+
): PublishTarget[] {
|
|
125
|
+
const available = () =>
|
|
126
|
+
targets.map((t) => ` ${t.id} ${t.provider} ${t.name}`).join("\n") || " (none connected)";
|
|
127
|
+
let pool = targets;
|
|
128
|
+
if (opts.platforms) {
|
|
129
|
+
for (const p of opts.platforms) {
|
|
130
|
+
if (!targets.some((t) => t.provider === p)) {
|
|
131
|
+
throw new Error(`no connected ${p} account in Postiz. Connected:\n${available()}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
pool = pool.filter((t) => opts.platforms!.includes(t.provider));
|
|
135
|
+
}
|
|
136
|
+
if (opts.accounts) {
|
|
137
|
+
const picked: PublishTarget[] = [];
|
|
138
|
+
for (const id of opts.accounts) {
|
|
139
|
+
const hit = pool.find((t) => t.id === id);
|
|
140
|
+
if (!hit) throw new Error(`no integration with id "${id}". Connected:\n${available()}`);
|
|
141
|
+
picked.push(hit);
|
|
142
|
+
}
|
|
143
|
+
return picked;
|
|
144
|
+
}
|
|
145
|
+
if (opts.all) {
|
|
146
|
+
if (pool.length === 0) throw new Error(`no connected accounts in Postiz. Connected:\n${available()}`);
|
|
147
|
+
return pool;
|
|
148
|
+
}
|
|
149
|
+
return pool;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Targets → the posts a publish sends: caption per platform from the pack
|
|
154
|
+
* (captionForProvider owns the authored-else-derived rule), and YouTube gets
|
|
155
|
+
* the pack's first title — the one required settings field Postiz won't
|
|
156
|
+
* default.
|
|
157
|
+
*/
|
|
158
|
+
export function buildPublishPosts(pack: YoutubePack, targets: PublishTarget[]): PublishPost[] {
|
|
159
|
+
return targets.map((target) => ({
|
|
160
|
+
target,
|
|
161
|
+
caption: captionForProvider(pack, target.provider),
|
|
162
|
+
...(target.provider === "youtube" ? { title: pack.titles[0] } : {}),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The pack this publish reads: the APPROVED file when the editor wrote one
|
|
168
|
+
* (an edited pack is the user's decision), else the provider-keyed cache
|
|
169
|
+
* produce wrote. No pack, no publish — the copy is the pack's job, and a
|
|
170
|
+
* publish that invents captions on the spot would end-run the review gate.
|
|
171
|
+
*/
|
|
172
|
+
export async function loadPublishPack(workdir: string): Promise<YoutubePack | null> {
|
|
173
|
+
const approved = join(workdir, YOUTUBE_APPROVED_BASENAME);
|
|
174
|
+
const candidates = [approved];
|
|
175
|
+
try {
|
|
176
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
177
|
+
const caches: Array<{ path: string; mtime: number }> = [];
|
|
178
|
+
for (const name of await readdir(workdir)) {
|
|
179
|
+
if (/^youtube-.+\.json$/.test(name) && name !== YOUTUBE_APPROVED_BASENAME) {
|
|
180
|
+
const path = join(workdir, name);
|
|
181
|
+
caches.push({ path, mtime: (await stat(path)).mtimeMs });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Newest cache first — the edit server's currentYoutubePack rule: what
|
|
185
|
+
// the LAST produce generated, not whichever key readdir happens to list.
|
|
186
|
+
caches.sort((a, b) => b.mtime - a.mtime);
|
|
187
|
+
candidates.push(...caches.map((c) => c.path));
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
for (const path of candidates) {
|
|
192
|
+
if (!existsSync(path)) continue;
|
|
193
|
+
try {
|
|
194
|
+
const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
195
|
+
// Cache files wrap the pack ({pack: ...}) in some produce versions;
|
|
196
|
+
// accept both spellings and validate either way.
|
|
197
|
+
const inner =
|
|
198
|
+
typeof raw === "object" && raw !== null && "pack" in (raw as Record<string, unknown>)
|
|
199
|
+
? (raw as { pack: unknown }).pack
|
|
200
|
+
: raw;
|
|
201
|
+
const parsed = YoutubePackSchema.safeParse(inner);
|
|
202
|
+
if (parsed.success) return parsed.data;
|
|
203
|
+
} catch {
|
|
204
|
+
// unreadable candidate — try the next
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function readPublishReceipt(workdir: string): Promise<PublishReceipt | null> {
|
|
211
|
+
const path = publishReceiptPath(workdir);
|
|
212
|
+
if (!existsSync(path)) return null;
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(await readFile(path, "utf8")) as PublishReceipt;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One line per outgoing post, for the confirm prompt and --dry-run. */
|
|
221
|
+
export function summarizePosts(posts: PublishPost[], when: PublishWhen): string {
|
|
222
|
+
const head =
|
|
223
|
+
when.kind === "now" ? "publish NOW" : `schedule for ${when.iso}`;
|
|
224
|
+
const rows = posts.map((p) => {
|
|
225
|
+
const firstLine = p.caption.split("\n")[0] ?? "";
|
|
226
|
+
const preview = firstLine.length > 60 ? `${firstLine.slice(0, 57)}...` : firstLine;
|
|
227
|
+
return ` ${p.target.provider.padEnd(10)} ${p.target.name.padEnd(20)} ${p.caption.length} chars "${preview}"`;
|
|
228
|
+
});
|
|
229
|
+
return [`▸ ${head} → ${posts.length} account(s):`, ...rows].join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface PublishFlags {
|
|
233
|
+
at?: string;
|
|
234
|
+
platforms?: string[];
|
|
235
|
+
accounts?: string[];
|
|
236
|
+
all?: boolean;
|
|
237
|
+
dryRun?: boolean;
|
|
238
|
+
yes?: boolean;
|
|
239
|
+
force?: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The command. Everything above is the logic; this is the I/O shell —
|
|
244
|
+
* resolve artifacts, pick targets (interactive when nothing selected them),
|
|
245
|
+
* confirm, publish, write the receipt.
|
|
246
|
+
*/
|
|
247
|
+
export async function runPublish(
|
|
248
|
+
workdir: string,
|
|
249
|
+
flags: PublishFlags,
|
|
250
|
+
deps: {
|
|
251
|
+
provider?: PublishProvider;
|
|
252
|
+
config?: OssclipConfig;
|
|
253
|
+
env?: NodeJS.ProcessEnv;
|
|
254
|
+
} = {},
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
const config = deps.config ?? loadConfig();
|
|
257
|
+
const configured = publishConfigured(config, deps.env ?? process.env);
|
|
258
|
+
if (!configured.ok) throw new Error(configured.message);
|
|
259
|
+
|
|
260
|
+
const cmd = await readRecordedCommand(workdir);
|
|
261
|
+
const out = cmd ? recordedOutPath(cmd) : null;
|
|
262
|
+
if (out === null || !existsSync(out)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`no finished render in ${workdir} — run \`ossclip produce\` (or re-render in the editor) first`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const pack = await loadPublishPack(workdir);
|
|
268
|
+
if (pack === null) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
"no YouTube pack in this workdir — publish reads its captions from the pack. " +
|
|
271
|
+
"Run produce with --youtube (or approve a pack in the editor) first",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const receipt = await readPublishReceipt(workdir);
|
|
276
|
+
if (receipt !== null && flags.force !== true) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`this workdir already published on ${receipt.publishedAt} to ` +
|
|
279
|
+
`${receipt.targets.map((t) => t.provider).join(", ")} — pass --force to publish again`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const provider =
|
|
284
|
+
deps.provider ??
|
|
285
|
+
createPostizProvider({ baseUrl: configured.baseUrl, apiKey: configured.apiKey });
|
|
286
|
+
const targets = await provider.listTargets();
|
|
287
|
+
|
|
288
|
+
let picked = selectTargets(targets, {
|
|
289
|
+
platforms: flags.platforms,
|
|
290
|
+
accounts: flags.accounts,
|
|
291
|
+
all: flags.all,
|
|
292
|
+
});
|
|
293
|
+
const nothingExplicit = !flags.accounts && !flags.all;
|
|
294
|
+
if (nothingExplicit) {
|
|
295
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
296
|
+
if (!isInteractive()) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"no accounts selected — pass --all, --accounts <ids> or --platforms <list>. Connected:\n" +
|
|
299
|
+
targets.map((t) => ` ${t.id} ${t.provider} ${t.name}`).join("\n"),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const { multiselect, unwrap } = await import("./interactive/prompts");
|
|
303
|
+
picked = unwrap(
|
|
304
|
+
await multiselect({
|
|
305
|
+
message: "Publish to which accounts?",
|
|
306
|
+
options: picked.map((t) => ({ value: t, label: `${t.provider} — ${t.name}` })),
|
|
307
|
+
required: true,
|
|
308
|
+
}),
|
|
309
|
+
) as PublishTarget[];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const when: PublishWhen = flags.at ? { kind: "at", iso: flags.at } : { kind: "now" };
|
|
313
|
+
const posts = buildPublishPosts(pack, picked);
|
|
314
|
+
console.log(summarizePosts(posts, when));
|
|
315
|
+
|
|
316
|
+
if (flags.dryRun === true) {
|
|
317
|
+
const payload = buildPostsPayload({
|
|
318
|
+
posts,
|
|
319
|
+
when,
|
|
320
|
+
dateIso: new Date().toISOString(),
|
|
321
|
+
media: { id: "<uploaded-media-id>", path: "<uploaded-media-path>" },
|
|
322
|
+
});
|
|
323
|
+
console.log(`\n▸ dry run — the exact /posts payload (media uploads first, then this):`);
|
|
324
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (flags.yes !== true) {
|
|
329
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
330
|
+
if (!isInteractive()) {
|
|
331
|
+
throw new Error("not a TTY — pass --yes to publish without the confirmation prompt");
|
|
332
|
+
}
|
|
333
|
+
const { confirm, unwrap } = await import("./interactive/prompts");
|
|
334
|
+
const go = unwrap(await confirm({ message: "Send it?" }));
|
|
335
|
+
if (go !== true) {
|
|
336
|
+
console.log("▸ publish cancelled — nothing sent");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log(`▸ uploading ${out} to Postiz...`);
|
|
342
|
+
const result = await provider.publish({ videoPath: out, posts, when });
|
|
343
|
+
await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
|
|
344
|
+
const where = configured.baseUrl.replace(/\/+$/, "");
|
|
345
|
+
console.log(
|
|
346
|
+
when.kind === "now"
|
|
347
|
+
? `✓ published to ${picked.length} account(s)`
|
|
348
|
+
: `✓ scheduled for ${when.iso} on ${picked.length} account(s)`,
|
|
349
|
+
);
|
|
350
|
+
if (result.postIds.length > 0) console.log(` posts: ${result.postIds.join(", ")}`);
|
|
351
|
+
console.log(` track it: ${where}/launches`);
|
|
352
|
+
}
|
package/src/replay-argv.ts
CHANGED
|
@@ -58,6 +58,8 @@ export function recordedProduceArgs(pins: {
|
|
|
58
58
|
llmEffort?: "low" | "medium" | "high";
|
|
59
59
|
clipWindow?: string;
|
|
60
60
|
watermark?: boolean;
|
|
61
|
+
/** The RESOLVED cover-overlay switch — config-dependent, so pinned both ways. */
|
|
62
|
+
coverInVideo?: boolean;
|
|
61
63
|
captions?: boolean;
|
|
62
64
|
jumpCuts?: JumpCutsMode;
|
|
63
65
|
/** The RESOLVED dictionary terms — pinned only when non-empty. */
|
|
@@ -110,6 +112,20 @@ export function recordedProduceArgs(pins: {
|
|
|
110
112
|
if (pins.watermark !== undefined && !args.includes("--watermark") && !args.includes("--no-watermark")) {
|
|
111
113
|
args.push(pins.watermark ? "--watermark" : "--no-watermark");
|
|
112
114
|
}
|
|
115
|
+
// The cover overlay, the watermark's rationale VERBATIM and for a live
|
|
116
|
+
// reason, not future-proofing: its effective default is config-dependent
|
|
117
|
+
// (`coverInVideo: true` in ~/.ossclip/config.json), so an unpinned record
|
|
118
|
+
// would gain — or lose — an overlay on the first frames of the replayed
|
|
119
|
+
// video the moment that config is edited, or the moment Render runs on
|
|
120
|
+
// another machine. Both directions, includes-guarded on BOTH spellings so a
|
|
121
|
+
// typed flag is never doubled.
|
|
122
|
+
if (
|
|
123
|
+
pins.coverInVideo !== undefined &&
|
|
124
|
+
!args.includes("--cover-in-video") &&
|
|
125
|
+
!args.includes("--no-cover-in-video")
|
|
126
|
+
) {
|
|
127
|
+
args.push(pins.coverInVideo ? "--cover-in-video" : "--no-cover-in-video");
|
|
128
|
+
}
|
|
113
129
|
// The captions flag, pinned both ways like the watermark above — but here
|
|
114
130
|
// as future-proofing plus consistency rather than a live bug: captions'
|
|
115
131
|
// default is ON independent of any config today, so an argv without the
|
package/src/ui/animation.ts
CHANGED
|
@@ -204,7 +204,11 @@ export class StageAnimator {
|
|
|
204
204
|
const spinner = SPINNERS.neural[this.frameIndex % SPINNERS.neural.length];
|
|
205
205
|
const dots = SPINNERS.dots[this.frameIndex % SPINNERS.dots.length];
|
|
206
206
|
visual =
|
|
207
|
-
|
|
207
|
+
// The caller's title, not a hardcoded model name: this header once
|
|
208
|
+
// said "GEMINI 3.7 FLASH" over a claude-cli run (2026-08-26 field
|
|
209
|
+
// report) — the subtitle names the real provider, and the header
|
|
210
|
+
// must not contradict it.
|
|
211
|
+
`${ansi.bold}${ansi.brightMagenta}⚡ ${this.title}${ansi.reset} ` +
|
|
208
212
|
`${ansi.cyan}[${spinner}]${ansi.reset} ${ansi.dim}(${elapsedSec}s)${ansi.reset}\n` +
|
|
209
213
|
` ${ansi.brightCyan}${dots}${ansi.reset} ${ansi.brightWhite}${clamp(this.subtitle)}${ansi.reset}`;
|
|
210
214
|
} else if (this.type === "whisper") {
|