@socialized.dev/cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,10 @@
8
8
  npm install -g @socialized.dev/cli
9
9
  ```
10
10
 
11
- Inside the monorepo:
11
+ This installs the `socialized.dev` command:
12
12
 
13
13
  ```sh
14
- pnpm -F @socialized.dev/cli cli -- --help
14
+ socialized.dev --help
15
15
  ```
16
16
 
17
17
  ## Auth
@@ -43,6 +43,11 @@ socialized.dev publications create \
43
43
  `publications create` uploads local video and cover files internally, then creates the
44
44
  publication. You can also pass an existing `asset_...` id to `--video` or `--cover`.
45
45
 
46
+ YouTube targets take `--youtube-tags`, `--youtube-category`, `--youtube-language`,
47
+ `--youtube-audio-language`, `--youtube-privacy`, `--youtube-made-for-kids`,
48
+ `--youtube-synthetic-media` and `--youtube-publish-at` (schedule the release); run
49
+ `socialized.dev publications create --help` for the full list.
50
+
46
51
  ## Brands
47
52
 
48
53
  If your account has multiple brands, you can switch between them:
package/dist/bin.d.ts CHANGED
@@ -1 +1 @@
1
- export { };
1
+ export {}
package/dist/bin.js CHANGED
@@ -6,7 +6,7 @@ import { basename, extname, join } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { Readable } from "node:stream";
8
8
  //#region package.json
9
- var version = "0.1.1";
9
+ var version = "0.2.0";
10
10
  //#endregion
11
11
  //#region src/api.ts
12
12
  var ApiError = class extends Error {
@@ -45,7 +45,7 @@ const createApiClient = (config) => {
45
45
  if (!res.ok) throw new ApiError(res.status, await res.text(), init.method ?? "GET", path);
46
46
  return res;
47
47
  };
48
- const json = (path, init) => request(path, init).then((res) => res.json());
48
+ const json = async (path, init) => (await request(path, init)).json();
49
49
  return {
50
50
  listBrands: () => json("/brands"),
51
51
  startAssetUpload: (body) => json("/assets", {
@@ -139,7 +139,8 @@ const loginCommand = buildCommand({
139
139
  const piped = flags.key === void 0 && !process.stdin.isTTY ? (await readStdin()).trim() : void 0;
140
140
  const key = (flags.key ?? piped)?.trim();
141
141
  if (!key) throw new UsageError("Pass an API key with `--key sk_...` or pipe one to stdin.");
142
- const { brands } = await createApiClient(resolveConfig({ apiKey: key })).listBrands();
142
+ const config = resolveConfig({ apiKey: key });
143
+ const { brands } = await createApiClient(config).listBrands();
143
144
  saveConfig({
144
145
  apiKey: key,
145
146
  brandId: brands.length === 1 ? brands[0]?.id : void 0
@@ -187,43 +188,45 @@ const resolveBrand = (brands, selector) => {
187
188
  if (matches.length > 1) throw new UsageError(`Brand name is ambiguous: ${selector}`);
188
189
  throw new UsageError(`Brand not found: ${selector}`);
189
190
  };
191
+ const listCommand = buildCommand({
192
+ docs: { brief: "List brands available to the API key" },
193
+ parameters: { flags: { json: {
194
+ kind: "boolean",
195
+ brief: "Print JSON output"
196
+ } } },
197
+ async func(flags) {
198
+ requireAuth(this);
199
+ const { brands } = await this.api.listBrands();
200
+ if (flags.json) return jsonOut(this, { brands });
201
+ if (brands.length === 0) {
202
+ out(this, "No brands found. Go to the web dashboard to create one.");
203
+ return;
204
+ }
205
+ for (const brand of brands) out(this, `${brand.id}\t${brand.name}\t${brand.connectedPlatforms.join(",") || "-"}`);
206
+ }
207
+ });
208
+ const useCommand = buildCommand({
209
+ docs: { brief: "Persist the default brand for publication commands" },
210
+ parameters: { positional: {
211
+ kind: "tuple",
212
+ parameters: [{
213
+ brief: "Brand id or exact brand name",
214
+ parse: String
215
+ }]
216
+ } },
217
+ async func(_flags, selector) {
218
+ requireAuth(this);
219
+ const { brands } = await this.api.listBrands();
220
+ if (brands.length === 0) throw new UsageError("No brands found. Go to the web dashboard to create one.");
221
+ const brand = resolveBrand(brands, selector);
222
+ saveConfig({ brandId: brand.id });
223
+ out(this, `Using ${brand.name} (${brand.id}).`);
224
+ }
225
+ });
190
226
  const brandsRoutes = buildRouteMap({
191
227
  routes: {
192
- list: buildCommand({
193
- docs: { brief: "List brands available to the API key" },
194
- parameters: { flags: { json: {
195
- kind: "boolean",
196
- brief: "Print JSON output"
197
- } } },
198
- async func(flags) {
199
- requireAuth(this);
200
- const { brands } = await this.api.listBrands();
201
- if (flags.json) return jsonOut(this, { brands });
202
- if (brands.length === 0) {
203
- out(this, "No brands found. Go to the web dashboard to create one.");
204
- return;
205
- }
206
- for (const brand of brands) out(this, `${brand.id}\t${brand.name}\t${brand.connectedPlatforms.join(",") || "-"}`);
207
- }
208
- }),
209
- use: buildCommand({
210
- docs: { brief: "Persist the default brand for publication commands" },
211
- parameters: { positional: {
212
- kind: "tuple",
213
- parameters: [{
214
- brief: "Brand id or exact brand name",
215
- parse: String
216
- }]
217
- } },
218
- async func(_flags, selector) {
219
- requireAuth(this);
220
- const { brands } = await this.api.listBrands();
221
- if (brands.length === 0) throw new UsageError("No brands found. Go to the web dashboard to create one.");
222
- const brand = resolveBrand(brands, selector);
223
- saveConfig({ brandId: brand.id });
224
- out(this, `Using ${brand.name} (${brand.id}).`);
225
- }
226
- })
228
+ list: listCommand,
229
+ use: useCommand
227
230
  },
228
231
  docs: { brief: "Manage default brand selection" }
229
232
  });
@@ -240,6 +243,30 @@ const parseTargets = (value) => {
240
243
  }
241
244
  return targets;
242
245
  };
246
+ const parseTags = (value) => value.split(",").map((tag) => tag.trim()).filter(Boolean);
247
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/;
248
+ /** A date-time the user typed (an offset-less one reads as local time) → the UTC ISO string the API
249
+ * takes. Strict: `Date` alone would silently roll 2026-02-30 over to March 2 — the wrong release day. */
250
+ const parseDateTime = (value) => {
251
+ const fields = ISO_DATE_TIME.exec(value)?.slice(1, 7).map((field) => Number(field ?? "0"));
252
+ const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = fields ?? [];
253
+ const calendar = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
254
+ const date = new Date(value);
255
+ if (fields === void 0 || calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day || calendar.getUTCHours() !== hour || calendar.getUTCMinutes() !== minute || calendar.getUTCSeconds() !== second || Number.isNaN(date.getTime())) throw new UsageError(`Not a date-time: ${value} (e.g. 2026-10-01T09:00 or 2026-10-01T09:00Z)`);
256
+ return date.toISOString();
257
+ };
258
+ /** The YouTube target's `options` bag from the `--youtube-*` flags. Only set fields are sent, so the
259
+ * API's defaults (public, not made for kids) and its validation stay the single source of truth. */
260
+ const youtubeOptions = (flags) => ({
261
+ ...flags.youtubeTags !== void 0 ? { tags: flags.youtubeTags } : {},
262
+ ...flags.youtubeCategory !== void 0 ? { categoryId: flags.youtubeCategory } : {},
263
+ ...flags.youtubeLanguage !== void 0 ? { defaultLanguage: flags.youtubeLanguage } : {},
264
+ ...flags.youtubeAudioLanguage !== void 0 ? { defaultAudioLanguage: flags.youtubeAudioLanguage } : {},
265
+ ...flags.youtubePrivacy !== void 0 ? { privacyStatus: flags.youtubePrivacy } : {},
266
+ ...flags.youtubeMadeForKids !== void 0 ? { madeForKids: flags.youtubeMadeForKids } : {},
267
+ ...flags.youtubePublishAt !== void 0 ? { publishAt: flags.youtubePublishAt } : {},
268
+ ...flags.youtubeSyntheticMedia !== void 0 ? { containsSyntheticMedia: flags.youtubeSyntheticMedia } : {}
269
+ });
243
270
  const resolveBrandId = async (ctx, flagBrand) => {
244
271
  let brandId = flagBrand ?? ctx.config.brandId;
245
272
  if (!brandId) {
@@ -256,12 +283,13 @@ const resolveBrandId = async (ctx, flagBrand) => {
256
283
  const uploadAsset = async (ctx, filePath, kind, jsonMode) => {
257
284
  const ext = extname(filePath).toLowerCase();
258
285
  let contentType = "application/octet-stream";
259
- if (kind === "video") if (ext === ".mp4") contentType = "video/mp4";
260
- else if (ext === ".mov") contentType = "video/quicktime";
261
- else if (ext === ".m4v") contentType = "video/mp4";
262
- else if (ext === ".webm") contentType = "video/webm";
263
- else contentType = "video/mp4";
264
- else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
286
+ if (kind === "video") {
287
+ if (ext === ".mp4") contentType = "video/mp4";
288
+ else if (ext === ".mov") contentType = "video/quicktime";
289
+ else if (ext === ".m4v") contentType = "video/mp4";
290
+ else if (ext === ".webm") contentType = "video/webm";
291
+ else contentType = "video/mp4";
292
+ } else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
265
293
  else if (ext === ".png") contentType = "image/png";
266
294
  else if (ext === ".webp") contentType = "image/webp";
267
295
  else contentType = "application/octet-stream";
@@ -338,161 +366,218 @@ const printPublicationSummary = (ctx, pub) => {
338
366
  out(ctx, "Targets:");
339
367
  for (const target of pub.targets) out(ctx, ` - ${target.platform}: ${target.status}${target.externalUrl ? ` (${target.externalUrl})` : ""}${target.error ? ` - Error: ${target.error}` : ""}`);
340
368
  };
341
- const publicationsRoutes = buildRouteMap({
342
- routes: {
343
- create: buildCommand({
344
- docs: { brief: "Create a publication, uploading local video/cover files as needed" },
345
- parameters: { flags: {
346
- video: {
347
- kind: "parsed",
348
- parse: String,
349
- brief: "Video file path or asset id"
350
- },
351
- cover: {
352
- kind: "parsed",
353
- parse: String,
354
- optional: true,
355
- brief: "Cover image path or asset id"
356
- },
357
- to: {
358
- kind: "parsed",
359
- parse: parseTargets,
360
- brief: "Comma-separated platforms"
361
- },
362
- title: {
363
- kind: "parsed",
364
- parse: String,
365
- brief: "Publication title"
366
- },
367
- description: {
368
- kind: "parsed",
369
- parse: String,
370
- optional: true,
371
- brief: "Publication description"
372
- },
373
- brand: {
374
- kind: "parsed",
375
- parse: String,
376
- optional: true,
377
- brief: "Brand id"
378
- },
379
- idempotencyKey: {
380
- kind: "parsed",
381
- parse: String,
382
- optional: true,
383
- brief: "Idempotency-Key value"
384
- },
385
- watch: {
386
- kind: "boolean",
387
- brief: "Poll until final status"
388
- },
389
- json: {
390
- kind: "boolean",
391
- brief: "Print JSON output"
392
- }
393
- } },
394
- async func(flags) {
395
- requireAuth(this);
396
- const brandId = await resolveBrandId(this, flags.brand);
397
- let videoAssetId;
398
- if (flags.video.startsWith("asset_")) videoAssetId = flags.video;
399
- else videoAssetId = await uploadAsset(this, flags.video, "video", flags.json);
400
- let coverAssetId = null;
401
- if (flags.cover) if (flags.cover.startsWith("asset_")) coverAssetId = flags.cover;
402
- else coverAssetId = await uploadAsset(this, flags.cover, "cover", flags.json);
403
- const idempotencyKey = flags.idempotencyKey ?? randomUUID();
404
- const result = await this.api.createPublication(brandId, {
405
- videoAssetId,
406
- coverAssetId,
407
- title: flags.title,
408
- description: flags.description ?? "",
409
- targets: flags.to.map((platform) => ({
410
- platform,
411
- options: {}
412
- }))
413
- }, idempotencyKey);
414
- if (flags.watch) {
415
- const finalResult = await pollPublication(this, brandId, result.publicationId, flags.json);
416
- if (flags.json) return jsonOut(this, finalResult);
417
- printPublicationSummary(this, finalResult.publication);
418
- } else {
419
- if (flags.json) return jsonOut(this, result);
420
- printDispatchResult(this, result);
421
- }
422
- }
423
- }),
424
- get: buildCommand({
425
- docs: { brief: "Read a publication" },
426
- parameters: {
427
- flags: {
428
- brand: {
429
- kind: "parsed",
430
- parse: String,
431
- optional: true,
432
- brief: "Brand id"
433
- },
434
- json: {
435
- kind: "boolean",
436
- brief: "Print JSON output"
437
- }
438
- },
439
- positional: {
440
- kind: "tuple",
441
- parameters: [{
442
- brief: "Publication id",
443
- parse: String
444
- }]
445
- }
369
+ const createCommand = buildCommand({
370
+ docs: { brief: "Create a publication, uploading local video/cover files as needed" },
371
+ parameters: { flags: {
372
+ video: {
373
+ kind: "parsed",
374
+ parse: String,
375
+ brief: "Video file path or asset id"
376
+ },
377
+ cover: {
378
+ kind: "parsed",
379
+ parse: String,
380
+ optional: true,
381
+ brief: "Cover image path or asset id"
382
+ },
383
+ to: {
384
+ kind: "parsed",
385
+ parse: parseTargets,
386
+ brief: "Comma-separated platforms"
387
+ },
388
+ title: {
389
+ kind: "parsed",
390
+ parse: String,
391
+ brief: "Publication title"
392
+ },
393
+ description: {
394
+ kind: "parsed",
395
+ parse: String,
396
+ optional: true,
397
+ brief: "Publication description"
398
+ },
399
+ youtubeTags: {
400
+ kind: "parsed",
401
+ parse: parseTags,
402
+ optional: true,
403
+ brief: "YouTube: comma-separated tags (500 characters total)"
404
+ },
405
+ youtubeCategory: {
406
+ kind: "parsed",
407
+ parse: String,
408
+ optional: true,
409
+ brief: "YouTube: numeric category id, e.g. 27 (Education)"
410
+ },
411
+ youtubeLanguage: {
412
+ kind: "parsed",
413
+ parse: String,
414
+ optional: true,
415
+ brief: "YouTube: language of the title and description, e.g. en-GB"
416
+ },
417
+ youtubeAudioLanguage: {
418
+ kind: "parsed",
419
+ parse: String,
420
+ optional: true,
421
+ brief: "YouTube: language spoken in the video, e.g. en"
422
+ },
423
+ youtubePrivacy: {
424
+ kind: "enum",
425
+ values: [
426
+ "public",
427
+ "unlisted",
428
+ "private"
429
+ ],
430
+ optional: true,
431
+ brief: "YouTube: privacy status (default public)"
432
+ },
433
+ youtubeMadeForKids: {
434
+ kind: "boolean",
435
+ optional: true,
436
+ brief: "YouTube: declare the video made for kids"
437
+ },
438
+ youtubePublishAt: {
439
+ kind: "parsed",
440
+ parse: parseDateTime,
441
+ optional: true,
442
+ brief: "YouTube: upload now as private, go public at this time (local unless offset given)"
443
+ },
444
+ youtubeSyntheticMedia: {
445
+ kind: "boolean",
446
+ optional: true,
447
+ brief: "YouTube: disclose realistic altered or synthetic content"
448
+ },
449
+ brand: {
450
+ kind: "parsed",
451
+ parse: String,
452
+ optional: true,
453
+ brief: "Brand id"
454
+ },
455
+ idempotencyKey: {
456
+ kind: "parsed",
457
+ parse: String,
458
+ optional: true,
459
+ brief: "Idempotency-Key value"
460
+ },
461
+ watch: {
462
+ kind: "boolean",
463
+ brief: "Poll until final status"
464
+ },
465
+ json: {
466
+ kind: "boolean",
467
+ brief: "Print JSON output"
468
+ }
469
+ } },
470
+ async func(flags) {
471
+ requireAuth(this);
472
+ const youtube = youtubeOptions(flags);
473
+ if (Object.keys(youtube).length > 0 && !flags.to.includes("youtube")) throw new UsageError("`--youtube-*` flags need `youtube` in `--to`.");
474
+ const brandId = await resolveBrandId(this, flags.brand);
475
+ let videoAssetId;
476
+ if (flags.video.startsWith("asset_")) videoAssetId = flags.video;
477
+ else videoAssetId = await uploadAsset(this, flags.video, "video", flags.json);
478
+ let coverAssetId = null;
479
+ if (flags.cover) {
480
+ if (flags.cover.startsWith("asset_")) coverAssetId = flags.cover;
481
+ else coverAssetId = await uploadAsset(this, flags.cover, "cover", flags.json);
482
+ }
483
+ const idempotencyKey = flags.idempotencyKey ?? randomUUID();
484
+ const result = await this.api.createPublication(brandId, {
485
+ videoAssetId,
486
+ coverAssetId,
487
+ title: flags.title,
488
+ description: flags.description ?? "",
489
+ targets: flags.to.map((platform) => ({
490
+ platform,
491
+ options: platform === "youtube" ? youtube : {}
492
+ }))
493
+ }, idempotencyKey);
494
+ if (flags.watch) {
495
+ const finalResult = await pollPublication(this, brandId, result.publicationId, flags.json);
496
+ if (flags.json) return jsonOut(this, finalResult);
497
+ printPublicationSummary(this, finalResult.publication);
498
+ } else {
499
+ if (flags.json) return jsonOut(this, result);
500
+ printDispatchResult(this, result);
501
+ }
502
+ }
503
+ });
504
+ const getCommand = buildCommand({
505
+ docs: { brief: "Read a publication" },
506
+ parameters: {
507
+ flags: {
508
+ brand: {
509
+ kind: "parsed",
510
+ parse: String,
511
+ optional: true,
512
+ brief: "Brand id"
446
513
  },
447
- async func(flags, id) {
448
- requireAuth(this);
449
- const brandId = await resolveBrandId(this, flags.brand);
450
- const result = await this.api.getPublication(brandId, id);
451
- if (flags.json) return jsonOut(this, result);
452
- printPublicationSummary(this, result.publication);
514
+ json: {
515
+ kind: "boolean",
516
+ brief: "Print JSON output"
453
517
  }
454
- }),
455
- status: buildCommand({
456
- docs: { brief: "Show publication status" },
457
- parameters: {
458
- flags: {
459
- brand: {
460
- kind: "parsed",
461
- parse: String,
462
- optional: true,
463
- brief: "Brand id"
464
- },
465
- watch: {
466
- kind: "boolean",
467
- brief: "Poll until final status"
468
- },
469
- json: {
470
- kind: "boolean",
471
- brief: "Print JSON output"
472
- }
473
- },
474
- positional: {
475
- kind: "tuple",
476
- parameters: [{
477
- brief: "Publication id",
478
- parse: String
479
- }]
480
- }
518
+ },
519
+ positional: {
520
+ kind: "tuple",
521
+ parameters: [{
522
+ brief: "Publication id",
523
+ parse: String
524
+ }]
525
+ }
526
+ },
527
+ async func(flags, id) {
528
+ requireAuth(this);
529
+ const brandId = await resolveBrandId(this, flags.brand);
530
+ const result = await this.api.getPublication(brandId, id);
531
+ if (flags.json) return jsonOut(this, result);
532
+ printPublicationSummary(this, result.publication);
533
+ }
534
+ });
535
+ const statusCommand = buildCommand({
536
+ docs: { brief: "Show publication status" },
537
+ parameters: {
538
+ flags: {
539
+ brand: {
540
+ kind: "parsed",
541
+ parse: String,
542
+ optional: true,
543
+ brief: "Brand id"
481
544
  },
482
- async func(flags, id) {
483
- requireAuth(this);
484
- const brandId = await resolveBrandId(this, flags.brand);
485
- if (flags.watch) {
486
- const result = await pollPublication(this, brandId, id, flags.json);
487
- if (flags.json) return jsonOut(this, result);
488
- printPublicationSummary(this, result.publication);
489
- } else {
490
- const result = await this.api.getPublication(brandId, id);
491
- if (flags.json) return jsonOut(this, result);
492
- printPublicationSummary(this, result.publication);
493
- }
545
+ watch: {
546
+ kind: "boolean",
547
+ brief: "Poll until final status"
548
+ },
549
+ json: {
550
+ kind: "boolean",
551
+ brief: "Print JSON output"
494
552
  }
495
- })
553
+ },
554
+ positional: {
555
+ kind: "tuple",
556
+ parameters: [{
557
+ brief: "Publication id",
558
+ parse: String
559
+ }]
560
+ }
561
+ },
562
+ async func(flags, id) {
563
+ requireAuth(this);
564
+ const brandId = await resolveBrandId(this, flags.brand);
565
+ if (flags.watch) {
566
+ const result = await pollPublication(this, brandId, id, flags.json);
567
+ if (flags.json) return jsonOut(this, result);
568
+ printPublicationSummary(this, result.publication);
569
+ } else {
570
+ const result = await this.api.getPublication(brandId, id);
571
+ if (flags.json) return jsonOut(this, result);
572
+ printPublicationSummary(this, result.publication);
573
+ }
574
+ }
575
+ });
576
+ const publicationsRoutes = buildRouteMap({
577
+ routes: {
578
+ create: createCommand,
579
+ get: getCommand,
580
+ status: statusCommand
496
581
  },
497
582
  docs: { brief: "Create and inspect publications" }
498
583
  });
@@ -504,7 +589,7 @@ const text = {
504
589
  exceptionWhileRunningCommand: cleanError,
505
590
  commandErrorResult: cleanError
506
591
  };
507
- const app = buildApplication(buildRouteMap({
592
+ const root = buildRouteMap({
508
593
  routes: {
509
594
  login: loginCommand,
510
595
  logout: logoutCommand,
@@ -513,7 +598,8 @@ const app = buildApplication(buildRouteMap({
513
598
  publications: publicationsRoutes
514
599
  },
515
600
  docs: { brief: "socialized.dev — publish videos from the terminal" }
516
- }), {
601
+ });
602
+ const app = buildApplication(root, {
517
603
  name: "socialized.dev",
518
604
  versionInfo: { currentVersion: version },
519
605
  scanner: { caseStyle: "allow-kebab-for-camel" },
package/package.json CHANGED
@@ -1,19 +1,11 @@
1
1
  {
2
2
  "name": "@socialized.dev/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "description": "socialized.dev CLI — publish a video once, everywhere, from the terminal.",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/fishballapp/fishballapps.git",
11
- "directory": "projects/socialized.dev/apps/cli"
12
- },
13
8
  "homepage": "https://socialized.dev",
14
- "bugs": {
15
- "url": "https://github.com/fishballapp/fishballapps/issues"
16
- },
17
9
  "bin": {
18
10
  "socialized.dev": "./dist/bin.js"
19
11
  },
@@ -25,12 +17,12 @@
25
17
  "access": "public"
26
18
  },
27
19
  "dependencies": {
28
- "@stricli/core": "^1.2.7"
20
+ "@stricli/core": "^1.3.0"
29
21
  },
30
22
  "devDependencies": {
31
- "@types/node": "^24.0.0",
32
- "tsdown": "^0.22.3",
33
- "vitest": "^4.1.5"
23
+ "@types/node": "^24.13.4",
24
+ "tsdown": "^0.23.0",
25
+ "vitest": "^4.1.8"
34
26
  },
35
27
  "scripts": {
36
28
  "build": "tsdown",