fluncle 0.68.0 → 0.69.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.
Files changed (2) hide show
  1. package/bin/fluncle.mjs +224 -19
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -423,7 +423,7 @@ function parseVersion(version) {
423
423
  var currentVersion;
424
424
  var init_version = __esm(() => {
425
425
  init_output();
426
- currentVersion = "0.68.0".trim() ? "0.68.0".trim() : "0.1.0";
426
+ currentVersion = "0.69.0".trim() ? "0.69.0".trim() : "0.1.0";
427
427
  });
428
428
 
429
429
  // src/update-notifier.ts
@@ -1212,6 +1212,177 @@ var init_mixtape_mixcloud = __esm(() => {
1212
1212
  PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1213
1213
  });
1214
1214
 
1215
+ // src/commands/mixtape-set-video.ts
1216
+ var exports_mixtape_set_video = {};
1217
+ __export(exports_mixtape_set_video, {
1218
+ stageSetVideo: () => stageSetVideo,
1219
+ renditionFfmpegArgs: () => renditionFfmpegArgs,
1220
+ planMultipart: () => planMultipart,
1221
+ buildCompleteXml: () => buildCompleteXml,
1222
+ SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
1223
+ MIN_PART_SIZE: () => MIN_PART_SIZE,
1224
+ MAX_PARTS: () => MAX_PARTS,
1225
+ DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
1226
+ });
1227
+ import { randomUUID } from "node:crypto";
1228
+ import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
1229
+ import { tmpdir } from "node:os";
1230
+ import { join as join4 } from "node:path";
1231
+ function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
1232
+ if (!Number.isInteger(contentLength) || contentLength <= 0) {
1233
+ throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
1234
+ }
1235
+ let effective = Math.max(partSize, MIN_PART_SIZE);
1236
+ if (Math.ceil(contentLength / effective) > MAX_PARTS) {
1237
+ effective = Math.ceil(contentLength / MAX_PARTS);
1238
+ }
1239
+ const parts = [];
1240
+ let start = 0;
1241
+ let partNumber = 1;
1242
+ while (start < contentLength) {
1243
+ const end = Math.min(start + effective, contentLength);
1244
+ parts.push({ end, partNumber, size: end - start, start });
1245
+ start = end;
1246
+ partNumber += 1;
1247
+ }
1248
+ return { partCount: parts.length, partSize: effective, parts };
1249
+ }
1250
+ function escapeXml(value) {
1251
+ return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
1252
+ }
1253
+ function buildCompleteXml(parts) {
1254
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
1255
+ const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
1256
+ return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
1257
+ }
1258
+ function renditionFfmpegArgs(inputPath, outputPath) {
1259
+ return [
1260
+ "-y",
1261
+ "-i",
1262
+ inputPath,
1263
+ "-vf",
1264
+ `scale=-2:${SET_VIDEO_RENDITION.height}`,
1265
+ "-c:v",
1266
+ "libx264",
1267
+ "-preset",
1268
+ "medium",
1269
+ "-crf",
1270
+ String(SET_VIDEO_RENDITION.crf),
1271
+ "-force_key_frames",
1272
+ `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
1273
+ "-pix_fmt",
1274
+ "yuv420p",
1275
+ "-c:a",
1276
+ "aac",
1277
+ "-b:a",
1278
+ SET_VIDEO_RENDITION.audioBitrate,
1279
+ "-movflags",
1280
+ "+faststart",
1281
+ outputPath
1282
+ ];
1283
+ }
1284
+ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1285
+ if (!existsSync(masterPath)) {
1286
+ throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1287
+ }
1288
+ await assertFfmpeg();
1289
+ const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
1290
+ onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
1291
+ await deriveRendition(masterPath, renditionPath);
1292
+ try {
1293
+ const size = statSync2(renditionPath).size;
1294
+ const plan = planMultipart(size);
1295
+ onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1296
+ const presign = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, { partCount: plan.partCount });
1297
+ const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1298
+ const completed = [];
1299
+ try {
1300
+ for (const part of plan.parts) {
1301
+ const url = urlByPart.get(part.partNumber);
1302
+ if (!url) {
1303
+ throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
1304
+ }
1305
+ onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
1306
+ const etag = await putPart(url, renditionPath, part);
1307
+ completed.push({ etag, partNumber: part.partNumber });
1308
+ }
1309
+ await completeUpload(presign.completeUrl, completed);
1310
+ } catch (error) {
1311
+ await abortUpload(presign.abortUrl).catch(() => {});
1312
+ throw error;
1313
+ }
1314
+ await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, { setVideoAt: new Date().toISOString() });
1315
+ onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1316
+ return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1317
+ } finally {
1318
+ rmSync2(renditionPath, { force: true });
1319
+ }
1320
+ }
1321
+ async function putPart(url, path2, part) {
1322
+ const response = await fetch(url, {
1323
+ body: Bun.file(path2).slice(part.start, part.end),
1324
+ method: "PUT"
1325
+ });
1326
+ if (!response.ok) {
1327
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
1328
+ throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
1329
+ }
1330
+ const etag = response.headers.get("etag");
1331
+ if (!etag) {
1332
+ throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
1333
+ }
1334
+ return etag;
1335
+ }
1336
+ async function completeUpload(url, parts) {
1337
+ const response = await fetch(url, {
1338
+ body: buildCompleteXml(parts),
1339
+ headers: { "content-type": "application/xml" },
1340
+ method: "POST"
1341
+ });
1342
+ const text = await response.text().catch(() => "");
1343
+ if (!response.ok || text.includes("<Error>")) {
1344
+ throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
1345
+ }
1346
+ }
1347
+ async function abortUpload(url) {
1348
+ await fetch(url, { method: "DELETE" });
1349
+ }
1350
+ async function assertFfmpeg() {
1351
+ try {
1352
+ const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
1353
+ await proc.exited;
1354
+ if (proc.exitCode !== 0) {
1355
+ throw new Error("ffmpeg -version exited non-zero");
1356
+ }
1357
+ } catch {
1358
+ throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
1359
+ }
1360
+ }
1361
+ async function deriveRendition(inputPath, outputPath) {
1362
+ const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
1363
+ stderr: "pipe",
1364
+ stdout: "ignore"
1365
+ });
1366
+ await proc.exited;
1367
+ if (proc.exitCode !== 0) {
1368
+ const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
1369
+ throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
1370
+ }
1371
+ }
1372
+ var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
1373
+ var init_mixtape_set_video = __esm(() => {
1374
+ init_api();
1375
+ init_output();
1376
+ SET_VIDEO_RENDITION = {
1377
+ audioBitrate: "192k",
1378
+ crf: 20,
1379
+ gopSeconds: 2,
1380
+ height: 1080
1381
+ };
1382
+ DEFAULT_PART_SIZE = 100 * 1024 * 1024;
1383
+ MIN_PART_SIZE = 5 * 1024 * 1024;
1384
+ });
1385
+
1215
1386
  // src/commands/mixtapes.ts
1216
1387
  var exports_mixtapes = {};
1217
1388
  __export(exports_mixtapes, {
@@ -1225,7 +1396,7 @@ __export(exports_mixtapes, {
1225
1396
  mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1226
1397
  mixtapeCreateCommand: () => mixtapeCreateCommand
1227
1398
  });
1228
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1399
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1229
1400
  async function mixtapesCommand() {
1230
1401
  const response = await publicApiGet("/api/mixtapes");
1231
1402
  return response.mixtapes;
@@ -1266,15 +1437,19 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1266
1437
  if (!mixtape.id) {
1267
1438
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1268
1439
  }
1269
- const both = !options.youtube && !options.mixcloud;
1440
+ const both = !options.youtube && !options.mixcloud && !options.setVideo;
1270
1441
  const doYoutube = both || Boolean(options.youtube);
1271
1442
  const doMixcloud = both || Boolean(options.mixcloud);
1443
+ const doSetVideo = Boolean(options.setVideo);
1272
1444
  if (doYoutube && !options.video) {
1273
1445
  throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1274
1446
  }
1275
1447
  if (doMixcloud && !options.audio) {
1276
1448
  throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1277
1449
  }
1450
+ if (doSetVideo && !options.video) {
1451
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1452
+ }
1278
1453
  const mixtapeId = mixtape.id;
1279
1454
  let logId = mixtape.logId;
1280
1455
  if (!mixtape.durationMs) {
@@ -1319,6 +1494,16 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1319
1494
  results.push({ platform: "mixcloud", url: result.url });
1320
1495
  onProgress(`Mixcloud: ${result.url}`);
1321
1496
  }
1497
+ if (doSetVideo) {
1498
+ if (!options.video) {
1499
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1500
+ }
1501
+ onProgress("Set video: staging the 1080p rendition…");
1502
+ const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1503
+ const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1504
+ results.push({ platform: "set-video", url: result.url });
1505
+ onProgress(`Set video: ${result.url}`);
1506
+ }
1322
1507
  return { logId, mixtapeId, results };
1323
1508
  }
1324
1509
  async function probeDurationMs(filePath) {
@@ -1353,7 +1538,7 @@ function buildBody(options) {
1353
1538
  return body;
1354
1539
  }
1355
1540
  function parseCueFile(filePath) {
1356
- if (!existsSync(filePath)) {
1541
+ if (!existsSync2(filePath)) {
1357
1542
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1358
1543
  }
1359
1544
  const text = readFileSync2(filePath, "utf-8");
@@ -2138,7 +2323,7 @@ function parseVersion2(version) {
2138
2323
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2139
2324
  var init_version2 = __esm(() => {
2140
2325
  init_output();
2141
- currentVersion2 = "0.68.0".trim() ? "0.68.0".trim() : "0.1.0";
2326
+ currentVersion2 = "0.69.0".trim() ? "0.69.0".trim() : "0.1.0";
2142
2327
  });
2143
2328
 
2144
2329
  // ../../packages/registry/src/index.ts
@@ -2673,10 +2858,8 @@ var init_src = __esm(() => {
2673
2858
  ],
2674
2859
  kind: "extension",
2675
2860
  name: "extension.lens",
2676
- operatorNotes: "Fluncle Lens (apps/extension), MV3. PENDING Chrome Web Store review DARK until approval, then flip `pending` + set the assigned listing URL. The placeholder `url` is the store search for the listing; probeConfig is the post-approval /status check.",
2677
- pending: true,
2678
- probeConfig: { cadenceMs: PROBE_CADENCE_MS, kind: "http", timeoutMs: PROBE_TIMEOUT_MS },
2679
- url: "https://chromewebstore.google.com/search/Fluncle%20Lens",
2861
+ operatorNotes: "Fluncle Lens (apps/extension), MV3, LIVE on the Chrome Web Store (published 2026-06-29). Store listing reachability is Google's, not ours, so it is not on the /status board.",
2862
+ url: "https://chromewebstore.google.com/detail/efkkceaofendabikblfjhoepgejfpakk",
2680
2863
  weights: { web: "secondary" }
2681
2864
  },
2682
2865
  {
@@ -2894,7 +3077,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
2894
3077
  const detail = (await response.text().catch(() => "")).slice(0, 300);
2895
3078
  throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
2896
3079
  }
2897
- urls[spec.field] = `${FOUND_BASE}/${upload.key}`;
3080
+ urls[spec.field] = `${FOUND_BASE2}/${upload.key}`;
2898
3081
  }
2899
3082
  const manifest = files.render ? await readManifestFields(files.render) : {};
2900
3083
  const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
@@ -3004,7 +3187,7 @@ async function trackNoteCommand(idOrLogId, options) {
3004
3187
  const body = { note: options.note };
3005
3188
  return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
3006
3189
  }
3007
- var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS;
3190
+ var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE2 = "https://found.fluncle.com", VIDEO_FIELDS;
3008
3191
  var init_track = __esm(() => {
3009
3192
  init_api();
3010
3193
  init_output();
@@ -3437,7 +3620,7 @@ __export(exports_mixtape_youtube2, {
3437
3620
  distributeYoutube: () => distributeYoutube2,
3438
3621
  authYoutubeCommand: () => authYoutubeCommand2
3439
3622
  });
3440
- import { statSync as statSync2 } from "node:fs";
3623
+ import { statSync as statSync3 } from "node:fs";
3441
3624
  async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
3442
3625
  const contentLength = fileSize2(videoPath);
3443
3626
  const contentType = "video/mp4";
@@ -3569,7 +3752,7 @@ async function resolveMixtapeId2(idOrLogId) {
3569
3752
  }
3570
3753
  function fileSize2(path2) {
3571
3754
  try {
3572
- return statSync2(path2).size;
3755
+ return statSync3(path2).size;
3573
3756
  } catch {
3574
3757
  throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
3575
3758
  }
@@ -3595,9 +3778,10 @@ __export(exports_newsletter, {
3595
3778
  newsletterUpdateCommand: () => newsletterUpdateCommand,
3596
3779
  newsletterSendCommand: () => newsletterSendCommand,
3597
3780
  newsletterListCommand: () => newsletterListCommand,
3598
- newsletterDraftCommand: () => newsletterDraftCommand
3781
+ newsletterDraftCommand: () => newsletterDraftCommand,
3782
+ newsletterDeleteCommand: () => newsletterDeleteCommand
3599
3783
  });
3600
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
3784
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3601
3785
  function buildBody2(options, { requireContent }) {
3602
3786
  const body = {};
3603
3787
  if (options.contentFile !== undefined) {
@@ -3617,7 +3801,7 @@ function buildBody2(options, { requireContent }) {
3617
3801
  return body;
3618
3802
  }
3619
3803
  function readContentFile(filePath) {
3620
- if (!existsSync2(filePath)) {
3804
+ if (!existsSync3(filePath)) {
3621
3805
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
3622
3806
  }
3623
3807
  const text = readFileSync3(filePath, "utf-8");
@@ -3640,6 +3824,9 @@ async function newsletterListCommand() {
3640
3824
  const response = await adminApiGet("/api/admin/newsletter/editions");
3641
3825
  return response.editions;
3642
3826
  }
3827
+ async function newsletterDeleteCommand(id) {
3828
+ return adminApiDelete(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`);
3829
+ }
3643
3830
  var init_newsletter = __esm(() => {
3644
3831
  init_api();
3645
3832
  init_output();
@@ -4254,7 +4441,7 @@ var COORD_FALLBACK2 = "—";
4254
4441
  var init_format2 = () => {};
4255
4442
 
4256
4443
  // src/cli.ts
4257
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
4444
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
4258
4445
  import path2 from "path";
4259
4446
 
4260
4447
  // ../../node_modules/commander/lib/error.js
@@ -6614,7 +6801,7 @@ function addAdminCommands(program2) {
6614
6801
  const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6615
6802
  await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
6616
6803
  });
6617
- adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6804
+ adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--set-video", "Also stage a 1080p set-video rendition (from --video) to R2 + flip setVideoAt for the /log player").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6618
6805
  const { mixtapeDistributeCommand: mixtapeDistributeCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6619
6806
  await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2);
6620
6807
  });
@@ -6642,6 +6829,10 @@ function addAdminCommands(program2) {
6642
6829
  const { newsletterListCommand: newsletterListCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
6643
6830
  await runNewsletterList(options, newsletterListCommand2);
6644
6831
  });
6832
+ adminNewsletter.command("delete").description("Delete an edition (draft or sent) \u2014 OPERATOR only; reopens the send window").argument("[id]").option("--yes", "Confirm the delete", false).option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
6833
+ const { newsletterDeleteCommand: newsletterDeleteCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
6834
+ await runNewsletterDelete(id, options, newsletterDeleteCommand2);
6835
+ });
6645
6836
  const submissions = configureCommand(admin.command("submissions").description("Review listener submissions"));
6646
6837
  submissions.option("--json", "Print JSON", false).action(async (options) => {
6647
6838
  const { listSubmissionsCommand: listSubmissionsCommand2 } = await Promise.resolve().then(() => (init_submissions(), exports_submissions));
@@ -6923,7 +7114,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
6923
7114
  return;
6924
7115
  }
6925
7116
  const candidate = path2.join(dir, name);
6926
- return existsSync3(candidate) ? candidate : undefined;
7117
+ return existsSync4(candidate) ? candidate : undefined;
6927
7118
  };
6928
7119
  const resolveFile = (explicit, name) => {
6929
7120
  if (explicit) {
@@ -7260,6 +7451,20 @@ async function runNewsletterSend(id, options, newsletterSendCommand2) {
7260
7451
  const number = result.edition.number;
7261
7452
  console.log(number === undefined ? `Sent edition ${result.edition.id}.` : `Sent edition #${number} \u2014 it's out to the list and in the archive.`);
7262
7453
  }
7454
+ async function runNewsletterDelete(id, options, newsletterDeleteCommand2) {
7455
+ if (!id) {
7456
+ throw new Error("Missing edition id. Usage: fluncle admin newsletter delete <id> --yes");
7457
+ }
7458
+ if (!options.yes) {
7459
+ throw new Error("Pass --yes to confirm \u2014 this hard-deletes the edition (draft or sent).");
7460
+ }
7461
+ const result = await newsletterDeleteCommand2(id);
7462
+ if (options.json) {
7463
+ printJson({ ...result, ok: true });
7464
+ return;
7465
+ }
7466
+ console.log(`Deleted edition ${result.id}. The send window reopens to cover its finds.`);
7467
+ }
7263
7468
  async function runNewsletterList(options, newsletterListCommand2) {
7264
7469
  const editions = await newsletterListCommand2();
7265
7470
  if (options.json) {
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.68.0"
34
+ "version": "0.69.0"
35
35
  }