typefully 0.2.0 → 0.3.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 +18 -1
- package/dist/index.js +410 -135
- package/package.json +1 -1
- package/skills/spec.md +91 -2
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ typefully setup
|
|
|
48
48
|
tfly # interactive — pick text, platform, schedule
|
|
49
49
|
tfly "Hello, world!" # instant draft from text
|
|
50
50
|
|
|
51
|
+
tfly schedule # interactive — browse drafts and schedule one
|
|
51
52
|
tfly rm # interactive — pick drafts to delete
|
|
52
53
|
tfly rm <draft_id> # delete a specific draft
|
|
53
54
|
|
|
@@ -104,6 +105,8 @@ typefully config set-default 123 --location global
|
|
|
104
105
|
typefully config set-default 123 --scope local # --scope is an alias for --location
|
|
105
106
|
typefully config set-platforms # interactive multiselect
|
|
106
107
|
typefully config set-platforms --platforms x,linkedin,threads
|
|
108
|
+
typefully config set-timezone # interactive — pick from common IANA timezones
|
|
109
|
+
typefully config set-timezone --timezone America/New_York
|
|
107
110
|
```
|
|
108
111
|
|
|
109
112
|
### User & Social Sets
|
|
@@ -114,6 +117,19 @@ typefully social-sets list
|
|
|
114
117
|
typefully social-sets get [social_set_id]
|
|
115
118
|
```
|
|
116
119
|
|
|
120
|
+
### Interactive Scheduler
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
tfly schedule
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Browse all drafts and schedule one with a keyboard-only flow:
|
|
127
|
+
|
|
128
|
+
- **Happy path** — `↑↓ Enter` pick draft → `Enter` next free slot → `Enter` confirm → `Enter` open in browser
|
|
129
|
+
- **Custom time** — select "Custom date & time", enter date (`YYYY-MM-DD`) and time (`HH:MM`), confirm
|
|
130
|
+
|
|
131
|
+
Timezone defaults to PST. Change it with `tfly config set-timezone`.
|
|
132
|
+
|
|
117
133
|
### Drafts
|
|
118
134
|
|
|
119
135
|
```bash
|
|
@@ -207,7 +223,8 @@ Config files are stored as JSON with `0600` permissions:
|
|
|
207
223
|
{
|
|
208
224
|
"apiKey": "typ_xxxx",
|
|
209
225
|
"defaultSocialSetId": 12345,
|
|
210
|
-
"defaultPlatforms": ["x", "linkedin"]
|
|
226
|
+
"defaultPlatforms": ["x", "linkedin"],
|
|
227
|
+
"defaultTimezone": "America/Los_Angeles"
|
|
211
228
|
}
|
|
212
229
|
```
|
|
213
230
|
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,8 @@ import { z } from "zod/v4";
|
|
|
21
21
|
var ConfigSchema = z.object({
|
|
22
22
|
apiKey: z.string().optional(),
|
|
23
23
|
defaultSocialSetId: z.union([z.string(), z.number()]).optional(),
|
|
24
|
-
defaultPlatforms: z.array(z.string()).optional()
|
|
24
|
+
defaultPlatforms: z.array(z.string()).optional(),
|
|
25
|
+
defaultTimezone: z.string().optional()
|
|
25
26
|
});
|
|
26
27
|
var PLATFORMS = ["x", "linkedin", "threads", "bluesky", "mastodon"];
|
|
27
28
|
|
|
@@ -165,6 +166,26 @@ function getGlobalConfigFile() {
|
|
|
165
166
|
function getLocalConfigFile() {
|
|
166
167
|
return path.join(process.cwd(), LOCAL_CONFIG_FILE);
|
|
167
168
|
}
|
|
169
|
+
var DEFAULT_TIMEZONE = "America/Los_Angeles";
|
|
170
|
+
function getDefaultTimezone() {
|
|
171
|
+
const localPath = path.join(process.cwd(), LOCAL_CONFIG_FILE);
|
|
172
|
+
const localConfig = readConfigFile(localPath);
|
|
173
|
+
if (localConfig?.defaultTimezone) return localConfig.defaultTimezone;
|
|
174
|
+
const globalConfig = readConfigFile(GLOBAL_CONFIG_FILE);
|
|
175
|
+
if (globalConfig?.defaultTimezone) return globalConfig.defaultTimezone;
|
|
176
|
+
return DEFAULT_TIMEZONE;
|
|
177
|
+
}
|
|
178
|
+
function tzLabel(tz) {
|
|
179
|
+
try {
|
|
180
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
181
|
+
timeZone: tz,
|
|
182
|
+
timeZoneName: "short"
|
|
183
|
+
}).formatToParts(/* @__PURE__ */ new Date());
|
|
184
|
+
return parts.find((p4) => p4.type === "timeZoneName")?.value ?? tz;
|
|
185
|
+
} catch {
|
|
186
|
+
return tz;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
168
189
|
|
|
169
190
|
// src/utils/output.ts
|
|
170
191
|
import ora from "ora";
|
|
@@ -196,9 +217,9 @@ var NOOP_SPINNER = {
|
|
|
196
217
|
fail() {
|
|
197
218
|
}
|
|
198
219
|
};
|
|
199
|
-
function spin(
|
|
220
|
+
function spin(text6) {
|
|
200
221
|
if (isJsonMode()) return NOOP_SPINNER;
|
|
201
|
-
return ora({ text:
|
|
222
|
+
return ora({ text: text6, color: "cyan" });
|
|
202
223
|
}
|
|
203
224
|
|
|
204
225
|
// src/utils/api.ts
|
|
@@ -219,12 +240,12 @@ async function apiRequest(method, endpoint, body, opts = {}) {
|
|
|
219
240
|
}
|
|
220
241
|
const res = await fetch(url, fetchOpts);
|
|
221
242
|
if (!res.ok) {
|
|
222
|
-
const
|
|
243
|
+
const text7 = await res.text().catch(() => "");
|
|
223
244
|
let parsed;
|
|
224
245
|
try {
|
|
225
|
-
parsed = JSON.parse(
|
|
246
|
+
parsed = JSON.parse(text7);
|
|
226
247
|
} catch {
|
|
227
|
-
parsed =
|
|
248
|
+
parsed = text7;
|
|
228
249
|
}
|
|
229
250
|
if (exitOnError) {
|
|
230
251
|
console.error(JSON.stringify({ error: `HTTP ${res.status}`, response: parsed }, null, 2));
|
|
@@ -235,12 +256,12 @@ async function apiRequest(method, endpoint, body, opts = {}) {
|
|
|
235
256
|
err.status = res.status;
|
|
236
257
|
throw err;
|
|
237
258
|
}
|
|
238
|
-
const
|
|
239
|
-
if (!
|
|
259
|
+
const text6 = await res.text();
|
|
260
|
+
if (!text6) return {};
|
|
240
261
|
try {
|
|
241
|
-
return JSON.parse(
|
|
262
|
+
return JSON.parse(text6);
|
|
242
263
|
} catch {
|
|
243
|
-
return
|
|
264
|
+
return text6;
|
|
244
265
|
}
|
|
245
266
|
}
|
|
246
267
|
function sleep(ms) {
|
|
@@ -250,8 +271,8 @@ function sleep(ms) {
|
|
|
250
271
|
// src/utils/helpers.ts
|
|
251
272
|
import path2 from "path";
|
|
252
273
|
import pc2 from "picocolors";
|
|
253
|
-
function splitThreadText(
|
|
254
|
-
return
|
|
274
|
+
function splitThreadText(text6) {
|
|
275
|
+
return text6.split(/\r?\n[ \t]*---[ \t]*\r?\n/).filter((t) => t.trim());
|
|
255
276
|
}
|
|
256
277
|
function sanitizeFilename(filename) {
|
|
257
278
|
const ext = path2.extname(filename).toLowerCase();
|
|
@@ -430,10 +451,10 @@ function registerDraftsCommand(program2) {
|
|
|
430
451
|
if (opts.status) params.set("status", opts.status);
|
|
431
452
|
if (opts.tag) params.set("tag", opts.tag);
|
|
432
453
|
if (opts.sort) params.set("order_by", opts.sort);
|
|
433
|
-
const
|
|
434
|
-
|
|
454
|
+
const spinner4 = spin("Fetching drafts\u2026");
|
|
455
|
+
spinner4.start();
|
|
435
456
|
const data = await apiRequest("GET", `/social-sets/${id}/drafts?${params}`);
|
|
436
|
-
|
|
457
|
+
spinner4.stop();
|
|
437
458
|
display(data, () => renderDraftsList(data));
|
|
438
459
|
});
|
|
439
460
|
cmd.command("get").description("Get a specific draft").argument("[first_arg]", "social_set_id or draft_id").argument("[second_arg]", "draft_id (when first arg is social_set_id)").option("--social-set-id <id>", "Social set ID via flag (first arg becomes draft_id)").option("--use-default", "Confirm using default social set").action(
|
|
@@ -445,22 +466,22 @@ function registerDraftsCommand(program2) {
|
|
|
445
466
|
!!opts.useDefault,
|
|
446
467
|
opts.socialSetId
|
|
447
468
|
);
|
|
448
|
-
const
|
|
449
|
-
|
|
469
|
+
const spinner4 = spin("Fetching draft\u2026");
|
|
470
|
+
spinner4.start();
|
|
450
471
|
const data = await apiRequest("GET", `/social-sets/${socialSetId}/drafts/${draftId}`);
|
|
451
|
-
|
|
472
|
+
spinner4.stop();
|
|
452
473
|
display(data, () => renderDraft(data));
|
|
453
474
|
}
|
|
454
475
|
);
|
|
455
476
|
cmd.command("create").description("Create a new draft").argument("[social_set_id]", "Social set ID (uses default if omitted)").option("--social-set-id <id>", "Social set ID via flag (overrides positional)").option("--text <text>", "Post content (use --- on its own line for threads)").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--all", "Post to all connected platforms").option("--media <media_ids>", "Comma-separated media IDs").option("--title <title>", "Draft title (internal only)").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--reply-to <url>", "URL of X post to reply to").option("--community <id>", "X community ID").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").action(async (socialSetId, opts) => {
|
|
456
477
|
const id = resolveSocialSetId(opts.socialSetId ?? socialSetId);
|
|
457
|
-
let
|
|
478
|
+
let text6 = opts.text;
|
|
458
479
|
if (opts.file) {
|
|
459
480
|
const filePath = opts.file;
|
|
460
481
|
if (!fs2.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
|
|
461
|
-
|
|
482
|
+
text6 = fs2.readFileSync(filePath, "utf-8");
|
|
462
483
|
}
|
|
463
|
-
if (!
|
|
484
|
+
if (!text6) exitWithError("--text or --file is required");
|
|
464
485
|
if (opts.all && opts.platform) {
|
|
465
486
|
exitWithError("Cannot use both --all and --platform flags");
|
|
466
487
|
}
|
|
@@ -470,12 +491,12 @@ function registerDraftsCommand(program2) {
|
|
|
470
491
|
if (allPlatforms.length === 0) exitWithError("No connected platforms found");
|
|
471
492
|
platformList = [...allPlatforms];
|
|
472
493
|
} else if (opts.platform) {
|
|
473
|
-
platformList = opts.platform.split(",").map((
|
|
494
|
+
platformList = opts.platform.split(",").map((p4) => p4.trim());
|
|
474
495
|
} else {
|
|
475
496
|
const saved = getDefaultPlatforms();
|
|
476
497
|
if (saved?.length) {
|
|
477
498
|
const connected = await getAllConnectedPlatforms(id);
|
|
478
|
-
platformList = saved.filter((
|
|
499
|
+
platformList = saved.filter((p4) => connected.includes(p4));
|
|
479
500
|
if (platformList.length === 0) platformList = [...connected];
|
|
480
501
|
} else {
|
|
481
502
|
const defaultPlatform = await getFirstConnectedPlatform(id);
|
|
@@ -483,7 +504,7 @@ function registerDraftsCommand(program2) {
|
|
|
483
504
|
platformList = [defaultPlatform];
|
|
484
505
|
}
|
|
485
506
|
}
|
|
486
|
-
const posts = splitThreadText(
|
|
507
|
+
const posts = splitThreadText(text6);
|
|
487
508
|
const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
|
|
488
509
|
const postsArray = posts.map((postText, index) => {
|
|
489
510
|
const post = { text: postText };
|
|
@@ -510,10 +531,10 @@ function registerDraftsCommand(program2) {
|
|
|
510
531
|
if (opts.share) body.share = true;
|
|
511
532
|
const scratchpad = opts.notes ?? opts.scratchpad;
|
|
512
533
|
if (scratchpad) body.scratchpad_text = scratchpad;
|
|
513
|
-
const
|
|
514
|
-
|
|
534
|
+
const spinner4 = spin("Creating draft\u2026");
|
|
535
|
+
spinner4.start();
|
|
515
536
|
const data = await apiRequest("POST", `/social-sets/${id}/drafts`, body);
|
|
516
|
-
|
|
537
|
+
spinner4.stop();
|
|
517
538
|
display(data, () => renderDraft(data, "Draft created"));
|
|
518
539
|
});
|
|
519
540
|
cmd.command("update").description("Update an existing draft").argument("[first_arg]", "social_set_id or draft_id").argument("[second_arg]", "draft_id (when first arg is social_set_id)").option("--social-set-id <id>", "Social set ID via flag (first arg becomes draft_id)").option("--text <text>", "New post content").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--media <media_ids>", "Comma-separated media IDs").option("-a, --append", "Append to existing thread").option("--title <title>", "New draft title").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").option("--use-default", "Confirm using default social set").action(
|
|
@@ -525,14 +546,14 @@ function registerDraftsCommand(program2) {
|
|
|
525
546
|
!!opts.useDefault,
|
|
526
547
|
opts.socialSetId
|
|
527
548
|
);
|
|
528
|
-
let
|
|
549
|
+
let text6 = opts.text;
|
|
529
550
|
if (opts.file) {
|
|
530
551
|
const filePath = opts.file;
|
|
531
552
|
if (!fs2.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
|
|
532
|
-
|
|
553
|
+
text6 = fs2.readFileSync(filePath, "utf-8");
|
|
533
554
|
}
|
|
534
555
|
const body = {};
|
|
535
|
-
if (
|
|
556
|
+
if (text6) {
|
|
536
557
|
const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
|
|
537
558
|
const existing = await apiRequest(
|
|
538
559
|
"GET",
|
|
@@ -540,7 +561,7 @@ function registerDraftsCommand(program2) {
|
|
|
540
561
|
);
|
|
541
562
|
let platformList;
|
|
542
563
|
if (opts.platform) {
|
|
543
|
-
platformList = opts.platform.split(",").map((
|
|
564
|
+
platformList = opts.platform.split(",").map((p4) => p4.trim());
|
|
544
565
|
} else {
|
|
545
566
|
const existingPlatforms = existing.platforms;
|
|
546
567
|
platformList = Object.entries(existingPlatforms ?? {}).filter(([, config]) => config.enabled).map(([platform]) => platform);
|
|
@@ -560,11 +581,11 @@ function registerDraftsCommand(program2) {
|
|
|
560
581
|
break;
|
|
561
582
|
}
|
|
562
583
|
}
|
|
563
|
-
const newPost = { text:
|
|
584
|
+
const newPost = { text: text6 };
|
|
564
585
|
if (mediaIds.length > 0) newPost.media_ids = mediaIds;
|
|
565
586
|
postsArray = [...existingPosts, newPost];
|
|
566
587
|
} else {
|
|
567
|
-
const posts = splitThreadText(
|
|
588
|
+
const posts = splitThreadText(text6);
|
|
568
589
|
postsArray = posts.map((postText, index) => {
|
|
569
590
|
const post = { text: postText };
|
|
570
591
|
if (index === 0 && mediaIds.length > 0) post.media_ids = mediaIds;
|
|
@@ -572,8 +593,8 @@ function registerDraftsCommand(program2) {
|
|
|
572
593
|
});
|
|
573
594
|
}
|
|
574
595
|
const platformsObj = {};
|
|
575
|
-
for (const
|
|
576
|
-
platformsObj[
|
|
596
|
+
for (const p4 of platformList) {
|
|
597
|
+
platformsObj[p4] = { enabled: true, posts: postsArray };
|
|
577
598
|
}
|
|
578
599
|
body.platforms = platformsObj;
|
|
579
600
|
}
|
|
@@ -590,14 +611,14 @@ function registerDraftsCommand(program2) {
|
|
|
590
611
|
"At least one option is required (--text, --file, --title, --schedule, --share, --scratchpad/--notes, or --tags)"
|
|
591
612
|
);
|
|
592
613
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
614
|
+
const spinner4 = spin("Updating draft\u2026");
|
|
615
|
+
spinner4.start();
|
|
595
616
|
const data = await apiRequest(
|
|
596
617
|
"PATCH",
|
|
597
618
|
`/social-sets/${socialSetId}/drafts/${draftId}`,
|
|
598
619
|
body
|
|
599
620
|
);
|
|
600
|
-
|
|
621
|
+
spinner4.stop();
|
|
601
622
|
display(data, () => renderDraft(data, "Draft updated"));
|
|
602
623
|
}
|
|
603
624
|
);
|
|
@@ -610,10 +631,10 @@ function registerDraftsCommand(program2) {
|
|
|
610
631
|
!!opts.useDefault,
|
|
611
632
|
opts.socialSetId
|
|
612
633
|
);
|
|
613
|
-
const
|
|
614
|
-
|
|
634
|
+
const spinner4 = spin("Deleting draft\u2026");
|
|
635
|
+
spinner4.start();
|
|
615
636
|
await apiRequest("DELETE", `/social-sets/${socialSetId}/drafts/${draftId}`);
|
|
616
|
-
|
|
637
|
+
spinner4.succeed("Draft deleted");
|
|
617
638
|
display({ success: true, message: "Draft deleted" }, () => {
|
|
618
639
|
});
|
|
619
640
|
}
|
|
@@ -628,12 +649,12 @@ function registerDraftsCommand(program2) {
|
|
|
628
649
|
opts.socialSetId
|
|
629
650
|
);
|
|
630
651
|
if (!opts.time) exitWithError('--time is required (use "next-free-slot" or ISO datetime)');
|
|
631
|
-
const
|
|
632
|
-
|
|
652
|
+
const spinner4 = spin("Scheduling draft\u2026");
|
|
653
|
+
spinner4.start();
|
|
633
654
|
const data = await apiRequest("PATCH", `/social-sets/${socialSetId}/drafts/${draftId}`, {
|
|
634
655
|
publish_at: opts.time
|
|
635
656
|
});
|
|
636
|
-
|
|
657
|
+
spinner4.stop();
|
|
637
658
|
display(data, () => renderDraft(data, "Draft scheduled"));
|
|
638
659
|
}
|
|
639
660
|
);
|
|
@@ -646,12 +667,12 @@ function registerDraftsCommand(program2) {
|
|
|
646
667
|
!!opts.useDefault,
|
|
647
668
|
opts.socialSetId
|
|
648
669
|
);
|
|
649
|
-
const
|
|
650
|
-
|
|
670
|
+
const spinner4 = spin("Publishing draft\u2026");
|
|
671
|
+
spinner4.start();
|
|
651
672
|
const data = await apiRequest("PATCH", `/social-sets/${socialSetId}/drafts/${draftId}`, {
|
|
652
673
|
publish_at: "now"
|
|
653
674
|
});
|
|
654
|
-
|
|
675
|
+
spinner4.stop();
|
|
655
676
|
display(data, () => renderDraft(data, "Draft published"));
|
|
656
677
|
}
|
|
657
678
|
);
|
|
@@ -661,15 +682,15 @@ function registerDraftsCommand(program2) {
|
|
|
661
682
|
function registerAliasCommands(program2) {
|
|
662
683
|
program2.command("create-draft").description('Create a draft \u2014 alias for "drafts create" with positional text').argument("[text]", "Draft text (or use --text/--file)").option("--social-set-id <id>", "Social set ID (uses default if omitted)").option("--text <text>", "Post content (overrides positional text)").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--all", "Post to all connected platforms").option("--media <media_ids>", "Comma-separated media IDs").option("--title <title>", "Draft title (internal only)").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--reply-to <url>", "URL of X post to reply to").option("--community <id>", "X community ID").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").action(async (positionalText, opts) => {
|
|
663
684
|
const id = requireSocialSetId(opts.socialSetId ?? null);
|
|
664
|
-
let
|
|
685
|
+
let text6;
|
|
665
686
|
if (opts.file) {
|
|
666
687
|
const filePath = opts.file;
|
|
667
688
|
if (!fs3.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
|
|
668
|
-
|
|
689
|
+
text6 = fs3.readFileSync(filePath, "utf-8");
|
|
669
690
|
} else {
|
|
670
|
-
|
|
691
|
+
text6 = opts.text ?? positionalText;
|
|
671
692
|
}
|
|
672
|
-
if (!
|
|
693
|
+
if (!text6)
|
|
673
694
|
exitWithError("Draft text is required (provide as argument, or use --text/--file)");
|
|
674
695
|
if (opts.all && opts.platform) exitWithError("Cannot use both --all and --platform flags");
|
|
675
696
|
let platformList;
|
|
@@ -678,12 +699,12 @@ function registerAliasCommands(program2) {
|
|
|
678
699
|
if (allPlatforms.length === 0) exitWithError("No connected platforms found");
|
|
679
700
|
platformList = [...allPlatforms];
|
|
680
701
|
} else if (opts.platform) {
|
|
681
|
-
platformList = opts.platform.split(",").map((
|
|
702
|
+
platformList = opts.platform.split(",").map((p4) => p4.trim());
|
|
682
703
|
} else {
|
|
683
704
|
const saved = getDefaultPlatforms();
|
|
684
705
|
if (saved?.length) {
|
|
685
706
|
const connected = await getAllConnectedPlatforms(id);
|
|
686
|
-
platformList = saved.filter((
|
|
707
|
+
platformList = saved.filter((p4) => connected.includes(p4));
|
|
687
708
|
if (platformList.length === 0) platformList = [...connected];
|
|
688
709
|
} else {
|
|
689
710
|
const defaultPlatform = await getFirstConnectedPlatform(id);
|
|
@@ -691,7 +712,7 @@ function registerAliasCommands(program2) {
|
|
|
691
712
|
platformList = [defaultPlatform];
|
|
692
713
|
}
|
|
693
714
|
}
|
|
694
|
-
const posts = splitThreadText(
|
|
715
|
+
const posts = splitThreadText(text6);
|
|
695
716
|
const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
|
|
696
717
|
const postsArray = posts.map((postText, index) => {
|
|
697
718
|
const post = { text: postText };
|
|
@@ -716,25 +737,25 @@ function registerAliasCommands(program2) {
|
|
|
716
737
|
if (opts.share) body.share = true;
|
|
717
738
|
const scratchpad = opts.notes ?? opts.scratchpad;
|
|
718
739
|
if (scratchpad) body.scratchpad_text = scratchpad;
|
|
719
|
-
const
|
|
720
|
-
|
|
740
|
+
const spinner4 = spin("Creating draft\u2026");
|
|
741
|
+
spinner4.start();
|
|
721
742
|
const data = await apiRequest("POST", `/social-sets/${id}/drafts`, body);
|
|
722
|
-
|
|
743
|
+
spinner4.stop();
|
|
723
744
|
display(data, () => renderDraft(data, "Draft created"));
|
|
724
745
|
});
|
|
725
746
|
program2.command("update-draft").description('Update a draft \u2014 alias for "drafts update" with positional draft_id').argument("<draft_id>", "Draft ID").argument("[text]", "New draft text (or use --text/--file)").option("--social-set-id <id>", "Social set ID (uses default if omitted)").option("--text <text>", "New post content (overrides positional text)").option("-f, --file <path>", "Read content from file").option("--platform <platforms>", "Comma-separated platforms").option("--media <media_ids>", "Comma-separated media IDs").option("-a, --append", "Append to existing thread").option("--title <title>", "New draft title").option("--schedule <time>", '"now", "next-free-slot", or ISO datetime').option("--tags <tags>", "Comma-separated tag slugs").option("--share", "Generate a public share URL").option("--scratchpad <text>", "Internal notes/scratchpad").option("--notes <text>", "Internal notes/scratchpad (alias for --scratchpad)").action(
|
|
726
747
|
async (draftId, positionalText, opts) => {
|
|
727
748
|
const socialSetId = requireSocialSetId(opts.socialSetId ?? null);
|
|
728
|
-
let
|
|
749
|
+
let text6;
|
|
729
750
|
if (opts.file) {
|
|
730
751
|
const filePath = opts.file;
|
|
731
752
|
if (!fs3.existsSync(filePath)) exitWithError(`File not found: ${filePath}`);
|
|
732
|
-
|
|
753
|
+
text6 = fs3.readFileSync(filePath, "utf-8");
|
|
733
754
|
} else {
|
|
734
|
-
|
|
755
|
+
text6 = opts.text ?? positionalText;
|
|
735
756
|
}
|
|
736
757
|
const body = {};
|
|
737
|
-
if (
|
|
758
|
+
if (text6) {
|
|
738
759
|
const mediaIds = opts.media ? opts.media.split(",").map((m) => m.trim()) : [];
|
|
739
760
|
const existing = await apiRequest(
|
|
740
761
|
"GET",
|
|
@@ -742,7 +763,7 @@ function registerAliasCommands(program2) {
|
|
|
742
763
|
);
|
|
743
764
|
let platformList;
|
|
744
765
|
if (opts.platform) {
|
|
745
|
-
platformList = opts.platform.split(",").map((
|
|
766
|
+
platformList = opts.platform.split(",").map((p4) => p4.trim());
|
|
746
767
|
} else {
|
|
747
768
|
const existingPlatforms = existing.platforms;
|
|
748
769
|
platformList = Object.entries(existingPlatforms ?? {}).filter(([, config]) => config.enabled).map(([platform]) => platform);
|
|
@@ -762,11 +783,11 @@ function registerAliasCommands(program2) {
|
|
|
762
783
|
break;
|
|
763
784
|
}
|
|
764
785
|
}
|
|
765
|
-
const newPost = { text:
|
|
786
|
+
const newPost = { text: text6 };
|
|
766
787
|
if (mediaIds.length > 0) newPost.media_ids = mediaIds;
|
|
767
788
|
postsArray = [...existingPosts, newPost];
|
|
768
789
|
} else {
|
|
769
|
-
const posts = splitThreadText(
|
|
790
|
+
const posts = splitThreadText(text6);
|
|
770
791
|
postsArray = posts.map((postText, index) => {
|
|
771
792
|
const post = { text: postText };
|
|
772
793
|
if (index === 0 && mediaIds.length > 0) post.media_ids = mediaIds;
|
|
@@ -774,8 +795,8 @@ function registerAliasCommands(program2) {
|
|
|
774
795
|
});
|
|
775
796
|
}
|
|
776
797
|
const platformsObj = {};
|
|
777
|
-
for (const
|
|
778
|
-
platformsObj[
|
|
798
|
+
for (const p4 of platformList) {
|
|
799
|
+
platformsObj[p4] = { enabled: true, posts: postsArray };
|
|
779
800
|
}
|
|
780
801
|
body.platforms = platformsObj;
|
|
781
802
|
}
|
|
@@ -790,24 +811,24 @@ function registerAliasCommands(program2) {
|
|
|
790
811
|
"At least one option is required (--text, --file, --title, --schedule, --share, --scratchpad/--notes, or --tags)"
|
|
791
812
|
);
|
|
792
813
|
}
|
|
793
|
-
const
|
|
794
|
-
|
|
814
|
+
const spinner4 = spin("Updating draft\u2026");
|
|
815
|
+
spinner4.start();
|
|
795
816
|
const data = await apiRequest(
|
|
796
817
|
"PATCH",
|
|
797
818
|
`/social-sets/${socialSetId}/drafts/${draftId}`,
|
|
798
819
|
body
|
|
799
820
|
);
|
|
800
|
-
|
|
821
|
+
spinner4.stop();
|
|
801
822
|
display(data, () => renderDraft(data, "Draft updated"));
|
|
802
823
|
}
|
|
803
824
|
);
|
|
804
825
|
program2.command("rm").description("Delete a draft \u2014 provide draft_id or pick interactively").argument("[draft_id]", "Draft ID to delete (omit for interactive picker)").option("--social-set-id <id>", "Social set ID (uses default if omitted)").option("--status <status>", "Filter drafts by status in picker (default: draft)").option("--limit <n>", "Max drafts to show in picker (default: 20)").action(async (draftId, opts) => {
|
|
805
826
|
const socialSetId = requireSocialSetId(opts.socialSetId ?? null);
|
|
806
827
|
if (draftId) {
|
|
807
|
-
const
|
|
808
|
-
|
|
828
|
+
const spinner4 = spin("Deleting draft\u2026");
|
|
829
|
+
spinner4.start();
|
|
809
830
|
await apiRequest("DELETE", `/social-sets/${socialSetId}/drafts/${draftId}`);
|
|
810
|
-
|
|
831
|
+
spinner4.succeed("Draft deleted");
|
|
811
832
|
display({ success: true, message: "Draft deleted" }, () => {
|
|
812
833
|
});
|
|
813
834
|
return;
|
|
@@ -854,11 +875,11 @@ function registerAliasCommands(program2) {
|
|
|
854
875
|
process.exit(0);
|
|
855
876
|
}
|
|
856
877
|
const ids = selected;
|
|
857
|
-
const
|
|
878
|
+
const confirm4 = await p.confirm({
|
|
858
879
|
message: `Delete ${ids.length} draft${ids.length !== 1 ? "s" : ""}?`,
|
|
859
880
|
initialValue: false
|
|
860
881
|
});
|
|
861
|
-
if (p.isCancel(
|
|
882
|
+
if (p.isCancel(confirm4) || !confirm4) {
|
|
862
883
|
p.cancel("Cancelled.");
|
|
863
884
|
process.exit(0);
|
|
864
885
|
}
|
|
@@ -901,6 +922,8 @@ function renderConfigShow(data) {
|
|
|
901
922
|
} else {
|
|
902
923
|
console.log(pc5.dim(" Default platforms: not set \xB7 Run: typefully config set-platforms"));
|
|
903
924
|
}
|
|
925
|
+
const tz = getDefaultTimezone();
|
|
926
|
+
console.log(pc5.dim(` Default timezone: ${tz} (${tzLabel(tz)})`));
|
|
904
927
|
console.log("");
|
|
905
928
|
}
|
|
906
929
|
function registerConfigCommand(program2) {
|
|
@@ -942,10 +965,10 @@ function registerConfigCommand(program2) {
|
|
|
942
965
|
let socialSetId = socialSetIdArg;
|
|
943
966
|
let location = opts.scope ?? opts.location;
|
|
944
967
|
if (!socialSetId) {
|
|
945
|
-
const
|
|
946
|
-
|
|
968
|
+
const spinner4 = spin("Fetching social sets\u2026");
|
|
969
|
+
spinner4.start();
|
|
947
970
|
const socialSets = await apiRequest("GET", "/social-sets?limit=50");
|
|
948
|
-
|
|
971
|
+
spinner4.stop();
|
|
949
972
|
const results = socialSets.results;
|
|
950
973
|
if (!results || results.length === 0) {
|
|
951
974
|
exitWithError("No social sets found. Create one at typefully.com first.");
|
|
@@ -1016,10 +1039,100 @@ function registerConfigCommand(program2) {
|
|
|
1016
1039
|
console.log("");
|
|
1017
1040
|
});
|
|
1018
1041
|
});
|
|
1042
|
+
cmd.command("set-timezone").description("Set default timezone for scheduling").option("--timezone <tz>", "IANA timezone name (skips interactive)").option("--location <location>", "Storage location: global or local").option("--scope <scope>", "Alias for --location").action(async (opts) => {
|
|
1043
|
+
const COMMON_TIMEZONES = [
|
|
1044
|
+
{ value: "America/Los_Angeles", label: "America/Los_Angeles", hint: "PST/PDT" },
|
|
1045
|
+
{ value: "America/Denver", label: "America/Denver", hint: "MST/MDT" },
|
|
1046
|
+
{ value: "America/Chicago", label: "America/Chicago", hint: "CST/CDT" },
|
|
1047
|
+
{ value: "America/New_York", label: "America/New_York", hint: "EST/EDT" },
|
|
1048
|
+
{ value: "America/Sao_Paulo", label: "America/Sao_Paulo", hint: "BRT" },
|
|
1049
|
+
{ value: "Europe/London", label: "Europe/London", hint: "GMT/BST" },
|
|
1050
|
+
{ value: "Europe/Paris", label: "Europe/Paris", hint: "CET/CEST" },
|
|
1051
|
+
{ value: "Europe/Istanbul", label: "Europe/Istanbul", hint: "TRT" },
|
|
1052
|
+
{ value: "Asia/Dubai", label: "Asia/Dubai", hint: "GST" },
|
|
1053
|
+
{ value: "Asia/Karachi", label: "Asia/Karachi", hint: "PKT" },
|
|
1054
|
+
{ value: "Asia/Kolkata", label: "Asia/Kolkata", hint: "IST" },
|
|
1055
|
+
{ value: "Asia/Shanghai", label: "Asia/Shanghai", hint: "CST" },
|
|
1056
|
+
{ value: "Asia/Tokyo", label: "Asia/Tokyo", hint: "JST" },
|
|
1057
|
+
{ value: "Australia/Sydney", label: "Australia/Sydney", hint: "AEST/AEDT" },
|
|
1058
|
+
{ value: "custom", label: "Custom IANA timezone\u2026", hint: "enter manually" }
|
|
1059
|
+
];
|
|
1060
|
+
let timezone;
|
|
1061
|
+
if (opts.timezone) {
|
|
1062
|
+
timezone = opts.timezone;
|
|
1063
|
+
} else {
|
|
1064
|
+
const current = getDefaultTimezone();
|
|
1065
|
+
const tzChoice = await clack2.select({
|
|
1066
|
+
message: "Default timezone for scheduling",
|
|
1067
|
+
initialValue: COMMON_TIMEZONES.some((t) => t.value === current) ? current : "custom",
|
|
1068
|
+
options: COMMON_TIMEZONES
|
|
1069
|
+
});
|
|
1070
|
+
if (clack2.isCancel(tzChoice)) {
|
|
1071
|
+
clack2.cancel("Cancelled.");
|
|
1072
|
+
process.exit(0);
|
|
1073
|
+
}
|
|
1074
|
+
if (tzChoice === "custom") {
|
|
1075
|
+
const customTz = await clack2.text({
|
|
1076
|
+
message: "Enter IANA timezone name",
|
|
1077
|
+
placeholder: DEFAULT_TIMEZONE,
|
|
1078
|
+
validate: (v = "") => {
|
|
1079
|
+
if (!v.trim()) return "Timezone is required";
|
|
1080
|
+
try {
|
|
1081
|
+
Intl.DateTimeFormat(void 0, { timeZone: v });
|
|
1082
|
+
} catch {
|
|
1083
|
+
return `Invalid timezone: ${v}`;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
if (clack2.isCancel(customTz)) {
|
|
1088
|
+
clack2.cancel("Cancelled.");
|
|
1089
|
+
process.exit(0);
|
|
1090
|
+
}
|
|
1091
|
+
timezone = customTz;
|
|
1092
|
+
} else {
|
|
1093
|
+
timezone = tzChoice;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
let location = opts.scope ?? opts.location;
|
|
1097
|
+
if (!location) {
|
|
1098
|
+
const choice = await clack2.select({
|
|
1099
|
+
message: "Where should this be stored?",
|
|
1100
|
+
options: [
|
|
1101
|
+
{
|
|
1102
|
+
value: "global",
|
|
1103
|
+
label: `Global ${pc5.dim("(~/.config/typefully/)")}`,
|
|
1104
|
+
hint: "all projects"
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
value: "local",
|
|
1108
|
+
label: `Local ${pc5.dim("(./.typefully/)")}`,
|
|
1109
|
+
hint: "this project only"
|
|
1110
|
+
}
|
|
1111
|
+
]
|
|
1112
|
+
});
|
|
1113
|
+
if (clack2.isCancel(choice)) {
|
|
1114
|
+
clack2.cancel("Cancelled.");
|
|
1115
|
+
process.exit(0);
|
|
1116
|
+
}
|
|
1117
|
+
location = choice;
|
|
1118
|
+
}
|
|
1119
|
+
const isLocal = location === "local";
|
|
1120
|
+
const configPath = isLocal ? getLocalConfigFile() : getGlobalConfigFile();
|
|
1121
|
+
const existingConfig = readConfigFile(configPath) ?? {};
|
|
1122
|
+
writeConfig(configPath, { ...existingConfig, defaultTimezone: timezone });
|
|
1123
|
+
display({ success: true, default_timezone: timezone, config_path: configPath }, () => {
|
|
1124
|
+
console.log("");
|
|
1125
|
+
console.log(
|
|
1126
|
+
` ${pc5.green("\u2713")} Default timezone saved: ${pc5.bold(timezone)} ${pc5.dim(`(${tzLabel(timezone)})`)}`
|
|
1127
|
+
);
|
|
1128
|
+
console.log(pc5.dim(` Config: ${configPath}`));
|
|
1129
|
+
console.log("");
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1019
1132
|
cmd.command("set-platforms").description("Set default platforms for new drafts").option("--platforms <platforms>", "Comma-separated platforms (skips interactive)").option("--location <location>", "Storage location: global or local").option("--scope <scope>", "Alias for --location").action(async (opts) => {
|
|
1020
1133
|
let platformList;
|
|
1021
1134
|
if (opts.platforms) {
|
|
1022
|
-
platformList = opts.platforms.split(",").map((
|
|
1135
|
+
platformList = opts.platforms.split(",").map((p4) => p4.trim());
|
|
1023
1136
|
} else {
|
|
1024
1137
|
const selected = await clack2.multiselect({
|
|
1025
1138
|
message: "Default platforms for new drafts",
|
|
@@ -1075,28 +1188,28 @@ function registerConfigCommand(program2) {
|
|
|
1075
1188
|
|
|
1076
1189
|
// src/commands/interactive.ts
|
|
1077
1190
|
import * as p2 from "@clack/prompts";
|
|
1078
|
-
async function createDraftDirect(
|
|
1191
|
+
async function createDraftDirect(text6) {
|
|
1079
1192
|
const id = requireSocialSetId(null);
|
|
1080
1193
|
let platformList;
|
|
1081
1194
|
const saved = getDefaultPlatforms();
|
|
1082
1195
|
if (saved?.length) {
|
|
1083
1196
|
const connected = await getAllConnectedPlatforms(id);
|
|
1084
|
-
platformList = saved.filter((
|
|
1197
|
+
platformList = saved.filter((p4) => connected.includes(p4));
|
|
1085
1198
|
if (platformList.length === 0) platformList = [...connected];
|
|
1086
1199
|
} else {
|
|
1087
1200
|
const platform = await getFirstConnectedPlatform(id);
|
|
1088
1201
|
if (!platform) exitWithError("No connected platforms found. Run: typefully setup");
|
|
1089
1202
|
platformList = [platform];
|
|
1090
1203
|
}
|
|
1091
|
-
const posts = splitThreadText(
|
|
1204
|
+
const posts = splitThreadText(text6);
|
|
1092
1205
|
const postsArray = posts.map((postText) => ({ text: postText }));
|
|
1093
1206
|
const platformsObj = {};
|
|
1094
|
-
for (const
|
|
1207
|
+
for (const p4 of platformList) platformsObj[p4] = { enabled: true, posts: postsArray };
|
|
1095
1208
|
const body = { platforms: platformsObj };
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1209
|
+
const spinner4 = spin("Creating draft\u2026");
|
|
1210
|
+
spinner4.start();
|
|
1098
1211
|
const data = await apiRequest("POST", `/social-sets/${id}/drafts`, body);
|
|
1099
|
-
|
|
1212
|
+
spinner4.stop();
|
|
1100
1213
|
display(data, () => renderDraft(data, "Draft created"));
|
|
1101
1214
|
}
|
|
1102
1215
|
async function createDraftInteractive() {
|
|
@@ -1110,14 +1223,14 @@ async function createDraftInteractive() {
|
|
|
1110
1223
|
p2.cancel("Cancelled.");
|
|
1111
1224
|
process.exit(0);
|
|
1112
1225
|
}
|
|
1113
|
-
const
|
|
1226
|
+
const text6 = textResult;
|
|
1114
1227
|
let availablePlatforms = [];
|
|
1115
1228
|
try {
|
|
1116
1229
|
availablePlatforms = await getAllConnectedPlatforms(id);
|
|
1117
1230
|
} catch {
|
|
1118
1231
|
}
|
|
1119
1232
|
const savedPlatforms = getDefaultPlatforms();
|
|
1120
|
-
const defaultInitial = savedPlatforms?.length ? availablePlatforms.filter((
|
|
1233
|
+
const defaultInitial = savedPlatforms?.length ? availablePlatforms.filter((p4) => savedPlatforms.includes(p4)) : [availablePlatforms[0]];
|
|
1121
1234
|
let platformList;
|
|
1122
1235
|
if (availablePlatforms.length > 1) {
|
|
1123
1236
|
const selected = await p2.multiselect({
|
|
@@ -1155,7 +1268,7 @@ async function createDraftInteractive() {
|
|
|
1155
1268
|
p2.cancel("Cancelled.");
|
|
1156
1269
|
process.exit(0);
|
|
1157
1270
|
}
|
|
1158
|
-
const posts = splitThreadText(
|
|
1271
|
+
const posts = splitThreadText(text6);
|
|
1159
1272
|
const postsArray = posts.map((postText) => ({ text: postText }));
|
|
1160
1273
|
const platformsObj = {};
|
|
1161
1274
|
for (const platform of platformList) {
|
|
@@ -1169,9 +1282,9 @@ async function createDraftInteractive() {
|
|
|
1169
1282
|
s.stop("");
|
|
1170
1283
|
display(data, () => renderDraft(data, "Draft created"));
|
|
1171
1284
|
}
|
|
1172
|
-
async function runDraft(
|
|
1173
|
-
if (
|
|
1174
|
-
await createDraftDirect(
|
|
1285
|
+
async function runDraft(text6) {
|
|
1286
|
+
if (text6) {
|
|
1287
|
+
await createDraftDirect(text6);
|
|
1175
1288
|
} else {
|
|
1176
1289
|
await createDraftInteractive();
|
|
1177
1290
|
}
|
|
@@ -1216,10 +1329,10 @@ function renderMe(data) {
|
|
|
1216
1329
|
}
|
|
1217
1330
|
function registerMeCommand(program2) {
|
|
1218
1331
|
program2.command("me").description("Get authenticated user info").action(async () => {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1332
|
+
const spinner4 = spin("Fetching user info\u2026");
|
|
1333
|
+
spinner4.start();
|
|
1221
1334
|
const data = await apiRequest("GET", "/me");
|
|
1222
|
-
|
|
1335
|
+
spinner4.stop();
|
|
1223
1336
|
display(data, () => renderMe(data));
|
|
1224
1337
|
});
|
|
1225
1338
|
}
|
|
@@ -1244,8 +1357,8 @@ function registerMediaCommand(program2) {
|
|
|
1244
1357
|
const n = Number.parseInt(raw, 10);
|
|
1245
1358
|
return Number.isFinite(n) && n >= 0 ? n : 2e3;
|
|
1246
1359
|
})();
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1360
|
+
const spinner4 = spin(`Uploading ${pc7.bold(rawFilename)}\u2026`);
|
|
1361
|
+
spinner4.start();
|
|
1249
1362
|
const presignedResponse = await apiRequest("POST", `/social-sets/${id}/media/upload`, {
|
|
1250
1363
|
file_name: filename
|
|
1251
1364
|
});
|
|
@@ -1256,14 +1369,14 @@ function registerMediaCommand(program2) {
|
|
|
1256
1369
|
const fileBuffer = fs4.readFileSync(filePath);
|
|
1257
1370
|
const uploadResponse = await fetch(uploadUrl, { method: "PUT", body: fileBuffer });
|
|
1258
1371
|
if (!uploadResponse.ok) {
|
|
1259
|
-
|
|
1372
|
+
spinner4.fail("Upload failed");
|
|
1260
1373
|
exitWithError("Failed to upload file to S3", {
|
|
1261
1374
|
http_code: uploadResponse.status,
|
|
1262
1375
|
status_text: uploadResponse.statusText
|
|
1263
1376
|
});
|
|
1264
1377
|
}
|
|
1265
1378
|
if (opts.wait === false) {
|
|
1266
|
-
|
|
1379
|
+
spinner4.succeed("Uploaded");
|
|
1267
1380
|
display(
|
|
1268
1381
|
{ media_id: mediaId, message: "Upload complete. Use media status to check processing." },
|
|
1269
1382
|
() => {
|
|
@@ -1275,7 +1388,7 @@ function registerMediaCommand(program2) {
|
|
|
1275
1388
|
);
|
|
1276
1389
|
return;
|
|
1277
1390
|
}
|
|
1278
|
-
|
|
1391
|
+
spinner4.text = "Processing\u2026";
|
|
1279
1392
|
const startTime = Date.now();
|
|
1280
1393
|
while (Date.now() - startTime < timeout) {
|
|
1281
1394
|
const statusResponse = await apiRequest(
|
|
@@ -1283,7 +1396,7 @@ function registerMediaCommand(program2) {
|
|
|
1283
1396
|
`/social-sets/${id}/media/${mediaId}`
|
|
1284
1397
|
);
|
|
1285
1398
|
if (statusResponse.status === "ready") {
|
|
1286
|
-
|
|
1399
|
+
spinner4.succeed("Media ready");
|
|
1287
1400
|
display({ media_id: mediaId, status: "ready", message: "Media uploaded and ready" }, () => {
|
|
1288
1401
|
console.log("");
|
|
1289
1402
|
console.log(` ${pc7.green("\u2713")} Media ready \xB7 ID: ${pc7.bold(mediaId)}`);
|
|
@@ -1292,12 +1405,12 @@ function registerMediaCommand(program2) {
|
|
|
1292
1405
|
return;
|
|
1293
1406
|
}
|
|
1294
1407
|
if (statusResponse.status === "error" || statusResponse.status === "failed") {
|
|
1295
|
-
|
|
1408
|
+
spinner4.fail("Processing failed");
|
|
1296
1409
|
exitWithError("Media processing failed", { status: statusResponse });
|
|
1297
1410
|
}
|
|
1298
1411
|
await sleep(pollIntervalMs);
|
|
1299
1412
|
}
|
|
1300
|
-
|
|
1413
|
+
spinner4.stop();
|
|
1301
1414
|
const timeoutResult = {
|
|
1302
1415
|
media_id: mediaId,
|
|
1303
1416
|
status: "processing",
|
|
@@ -1316,10 +1429,10 @@ function registerMediaCommand(program2) {
|
|
|
1316
1429
|
async (mediaId, socialSetId, opts) => {
|
|
1317
1430
|
const flagId = opts.socialSetId;
|
|
1318
1431
|
const id = requireSocialSetId(flagId ?? socialSetId ?? null);
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1432
|
+
const spinner4 = spin("Checking media status\u2026");
|
|
1433
|
+
spinner4.start();
|
|
1321
1434
|
const data = await apiRequest("GET", `/social-sets/${id}/media/${mediaId}`);
|
|
1322
|
-
|
|
1435
|
+
spinner4.stop();
|
|
1323
1436
|
display(data, () => {
|
|
1324
1437
|
const d = data;
|
|
1325
1438
|
const status = String(d.status ?? "unknown");
|
|
@@ -1515,21 +1628,21 @@ function registerSetupCommand(program2) {
|
|
|
1515
1628
|
import pc9 from "picocolors";
|
|
1516
1629
|
var PLATFORM_ORDER = ["x", "linkedin", "threads", "bluesky", "mastodon"];
|
|
1517
1630
|
function renderPlatforms(platforms, indent = " ") {
|
|
1518
|
-
for (const
|
|
1519
|
-
const cfg = platforms[
|
|
1631
|
+
for (const p4 of PLATFORM_ORDER) {
|
|
1632
|
+
const cfg = platforms[p4];
|
|
1520
1633
|
if (!cfg) continue;
|
|
1521
1634
|
const isConnected = cfg.connected !== false;
|
|
1522
1635
|
const dot = isConnected ? pc9.green("\u25CF") : pc9.dim("\u25CB");
|
|
1523
1636
|
const handle = cfg.username ? pc9.dim(` @${cfg.username}`) : "";
|
|
1524
1637
|
const status = isConnected ? "" : pc9.dim(" (not connected)");
|
|
1525
|
-
console.log(`${indent}${dot} ${
|
|
1638
|
+
console.log(`${indent}${dot} ${p4.padEnd(9)}${handle}${status}`);
|
|
1526
1639
|
}
|
|
1527
|
-
for (const [
|
|
1528
|
-
if (PLATFORM_ORDER.includes(
|
|
1640
|
+
for (const [p4, cfg] of Object.entries(platforms)) {
|
|
1641
|
+
if (PLATFORM_ORDER.includes(p4)) continue;
|
|
1529
1642
|
const isConnected = cfg.connected !== false;
|
|
1530
1643
|
const dot = isConnected ? pc9.green("\u25CF") : pc9.dim("\u25CB");
|
|
1531
1644
|
const handle = cfg.username ? pc9.dim(` @${cfg.username}`) : "";
|
|
1532
|
-
console.log(`${indent}${dot} ${
|
|
1645
|
+
console.log(`${indent}${dot} ${p4.padEnd(9)}${handle}`);
|
|
1533
1646
|
}
|
|
1534
1647
|
}
|
|
1535
1648
|
function renderSocialSetCard(set, index) {
|
|
@@ -1572,38 +1685,199 @@ function renderSocialSet(data) {
|
|
|
1572
1685
|
function registerSocialSetsCommand(program2) {
|
|
1573
1686
|
const cmd = program2.command("social-sets").description("Manage social sets");
|
|
1574
1687
|
cmd.command("list").description("List all social sets").action(async () => {
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1688
|
+
const spinner4 = spin("Fetching social sets\u2026");
|
|
1689
|
+
spinner4.start();
|
|
1577
1690
|
const data = await apiRequest("GET", "/social-sets?limit=50");
|
|
1578
|
-
|
|
1691
|
+
spinner4.stop();
|
|
1579
1692
|
display(data, () => renderSocialSetsList(data));
|
|
1580
1693
|
});
|
|
1581
1694
|
cmd.command("get").description("Get social set details").argument("[social_set_id]", "Social set ID (uses default if omitted)").action(async (socialSetId) => {
|
|
1582
1695
|
const id = requireSocialSetId(socialSetId ?? null);
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1696
|
+
const spinner4 = spin("Fetching social set\u2026");
|
|
1697
|
+
spinner4.start();
|
|
1585
1698
|
const data = await apiRequest("GET", `/social-sets/${id}`);
|
|
1586
|
-
|
|
1699
|
+
spinner4.stop();
|
|
1587
1700
|
display(data, () => renderSocialSet(data));
|
|
1588
1701
|
});
|
|
1589
1702
|
}
|
|
1590
1703
|
|
|
1591
|
-
// src/commands/
|
|
1704
|
+
// src/commands/schedule.ts
|
|
1705
|
+
import { exec } from "child_process";
|
|
1706
|
+
import * as p3 from "@clack/prompts";
|
|
1592
1707
|
import pc10 from "picocolors";
|
|
1708
|
+
function toUTCIso(dateStr, timeStr, tz) {
|
|
1709
|
+
const naiveUTC = /* @__PURE__ */ new Date(`${dateStr}T${timeStr}:00Z`);
|
|
1710
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1711
|
+
timeZone: tz,
|
|
1712
|
+
year: "numeric",
|
|
1713
|
+
month: "2-digit",
|
|
1714
|
+
day: "2-digit",
|
|
1715
|
+
hour: "2-digit",
|
|
1716
|
+
minute: "2-digit",
|
|
1717
|
+
second: "2-digit",
|
|
1718
|
+
hour12: false
|
|
1719
|
+
}).formatToParts(naiveUTC);
|
|
1720
|
+
const get = (type) => parts.find((pt) => pt.type === type)?.value ?? "00";
|
|
1721
|
+
const localISO = `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}Z`;
|
|
1722
|
+
const localDate = new Date(localISO);
|
|
1723
|
+
const offset = naiveUTC.getTime() - localDate.getTime();
|
|
1724
|
+
return new Date(naiveUTC.getTime() + offset).toISOString();
|
|
1725
|
+
}
|
|
1726
|
+
function todayInTimezone(tz) {
|
|
1727
|
+
return (/* @__PURE__ */ new Date()).toLocaleDateString("en-CA", { timeZone: tz });
|
|
1728
|
+
}
|
|
1729
|
+
function formatScheduledAt(draft, tz) {
|
|
1730
|
+
if (!draft.scheduled_at) return "";
|
|
1731
|
+
try {
|
|
1732
|
+
return new Date(String(draft.scheduled_at)).toLocaleString("en-US", {
|
|
1733
|
+
timeZone: tz,
|
|
1734
|
+
month: "short",
|
|
1735
|
+
day: "numeric",
|
|
1736
|
+
hour: "numeric",
|
|
1737
|
+
minute: "2-digit",
|
|
1738
|
+
hour12: true
|
|
1739
|
+
});
|
|
1740
|
+
} catch {
|
|
1741
|
+
return String(draft.scheduled_at);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
async function runScheduler() {
|
|
1745
|
+
const id = requireSocialSetId(null);
|
|
1746
|
+
const tz = getDefaultTimezone();
|
|
1747
|
+
const tz_label = tzLabel(tz);
|
|
1748
|
+
const fetchSpinner = p3.spinner();
|
|
1749
|
+
fetchSpinner.start("Fetching drafts\u2026");
|
|
1750
|
+
const data = await apiRequest("GET", `/social-sets/${id}/drafts?limit=50`);
|
|
1751
|
+
const results = data.results ?? [];
|
|
1752
|
+
const schedulable = results.filter((d) => d.status === "draft" || d.status === "scheduled");
|
|
1753
|
+
if (schedulable.length === 0) {
|
|
1754
|
+
fetchSpinner.stop("No drafts found.");
|
|
1755
|
+
p3.cancel("No drafts available to schedule.");
|
|
1756
|
+
process.exit(0);
|
|
1757
|
+
}
|
|
1758
|
+
const fullDrafts = await Promise.all(
|
|
1759
|
+
schedulable.map(
|
|
1760
|
+
(draft) => apiRequest("GET", `/social-sets/${id}/drafts/${draft.id}`).catch(() => draft)
|
|
1761
|
+
)
|
|
1762
|
+
);
|
|
1763
|
+
fetchSpinner.stop(`${fullDrafts.length} draft${fullDrafts.length !== 1 ? "s" : ""} loaded`);
|
|
1764
|
+
const draftChoice = await p3.select({
|
|
1765
|
+
message: "Pick a draft",
|
|
1766
|
+
options: fullDrafts.map((draft) => {
|
|
1767
|
+
const preview = firstPostText(draft, 65) || pc10.dim("(no text)");
|
|
1768
|
+
const status = String(draft.status ?? "draft");
|
|
1769
|
+
const scheduled = formatScheduledAt(draft, tz);
|
|
1770
|
+
const hint = scheduled ? `${status} \xB7 ${scheduled} ${tz_label}` : status;
|
|
1771
|
+
return {
|
|
1772
|
+
value: String(draft.id),
|
|
1773
|
+
label: `${preview}
|
|
1774
|
+
`,
|
|
1775
|
+
hint
|
|
1776
|
+
};
|
|
1777
|
+
})
|
|
1778
|
+
});
|
|
1779
|
+
if (p3.isCancel(draftChoice)) {
|
|
1780
|
+
p3.cancel("Cancelled.");
|
|
1781
|
+
process.exit(0);
|
|
1782
|
+
}
|
|
1783
|
+
const scheduleType = await p3.select({
|
|
1784
|
+
message: "When to publish?",
|
|
1785
|
+
initialValue: "next-free-slot",
|
|
1786
|
+
options: [
|
|
1787
|
+
{ value: "next-free-slot", label: "Next free slot" },
|
|
1788
|
+
{ value: "custom", label: `Custom date & time ${pc10.dim(`(${tz_label})`)}` }
|
|
1789
|
+
]
|
|
1790
|
+
});
|
|
1791
|
+
if (p3.isCancel(scheduleType)) {
|
|
1792
|
+
p3.cancel("Cancelled.");
|
|
1793
|
+
process.exit(0);
|
|
1794
|
+
}
|
|
1795
|
+
let publishAt;
|
|
1796
|
+
if (scheduleType === "next-free-slot") {
|
|
1797
|
+
publishAt = "next-free-slot";
|
|
1798
|
+
} else {
|
|
1799
|
+
const today = todayInTimezone(tz);
|
|
1800
|
+
const dateInput = await p3.text({
|
|
1801
|
+
message: `Date ${pc10.dim(`(${tz_label}, YYYY-MM-DD)`)}`,
|
|
1802
|
+
placeholder: today,
|
|
1803
|
+
initialValue: today,
|
|
1804
|
+
validate: (v = "") => {
|
|
1805
|
+
if (!v.trim()) return "Date is required";
|
|
1806
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(v.trim())) return "Use YYYY-MM-DD format";
|
|
1807
|
+
if (Number.isNaN(Date.parse(v.trim()))) return "Invalid date";
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
if (p3.isCancel(dateInput)) {
|
|
1811
|
+
p3.cancel("Cancelled.");
|
|
1812
|
+
process.exit(0);
|
|
1813
|
+
}
|
|
1814
|
+
const timeInput = await p3.text({
|
|
1815
|
+
message: `Time ${pc10.dim(`(${tz_label}, HH:MM 24h)`)}`,
|
|
1816
|
+
placeholder: "09:00",
|
|
1817
|
+
initialValue: "09:00",
|
|
1818
|
+
validate: (v = "") => {
|
|
1819
|
+
if (!v.trim()) return "Time is required";
|
|
1820
|
+
if (!/^\d{2}:\d{2}$/.test(v.trim())) return "Use HH:MM format";
|
|
1821
|
+
const [h, m] = v.trim().split(":").map(Number);
|
|
1822
|
+
if (h > 23 || m > 59) return "Invalid time";
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
if (p3.isCancel(timeInput)) {
|
|
1826
|
+
p3.cancel("Cancelled.");
|
|
1827
|
+
process.exit(0);
|
|
1828
|
+
}
|
|
1829
|
+
publishAt = toUTCIso(dateInput.trim(), timeInput.trim(), tz);
|
|
1830
|
+
}
|
|
1831
|
+
const confirmMsg = scheduleType === "next-free-slot" ? "Schedule to next free slot?" : `Schedule for ${new Date(publishAt).toLocaleString("en-US", { timeZone: tz, month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", hour12: true })} ${tz_label}?`;
|
|
1832
|
+
const confirmed = await p3.confirm({
|
|
1833
|
+
message: confirmMsg,
|
|
1834
|
+
initialValue: true
|
|
1835
|
+
});
|
|
1836
|
+
if (p3.isCancel(confirmed) || !confirmed) {
|
|
1837
|
+
p3.cancel("Cancelled.");
|
|
1838
|
+
process.exit(0);
|
|
1839
|
+
}
|
|
1840
|
+
const s = p3.spinner();
|
|
1841
|
+
s.start("Scheduling\u2026");
|
|
1842
|
+
const result = await apiRequest(
|
|
1843
|
+
"PATCH",
|
|
1844
|
+
`/social-sets/${id}/drafts/${draftChoice}`,
|
|
1845
|
+
{ publish_at: publishAt }
|
|
1846
|
+
);
|
|
1847
|
+
s.stop("");
|
|
1848
|
+
display(result, () => renderDraft(result, "Draft scheduled"));
|
|
1849
|
+
const draftUrl = String(result.share_url ?? `https://typefully.com/?d=${draftChoice}`);
|
|
1850
|
+
const openBrowser = await p3.confirm({
|
|
1851
|
+
message: "Open in browser?",
|
|
1852
|
+
initialValue: true
|
|
1853
|
+
});
|
|
1854
|
+
if (!p3.isCancel(openBrowser) && openBrowser) {
|
|
1855
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1856
|
+
exec(`${cmd} "${draftUrl}"`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
function registerScheduleCommand(program2) {
|
|
1860
|
+
program2.command("schedule").description("Interactively browse drafts and schedule one").action(async () => {
|
|
1861
|
+
await runScheduler();
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/commands/tags.ts
|
|
1866
|
+
import pc11 from "picocolors";
|
|
1593
1867
|
function renderTagsList(data) {
|
|
1594
1868
|
const results = data.results ?? [];
|
|
1595
1869
|
if (results.length === 0) {
|
|
1596
|
-
console.log(
|
|
1870
|
+
console.log(pc11.yellow("\n No tags found.\n"));
|
|
1597
1871
|
return;
|
|
1598
1872
|
}
|
|
1599
1873
|
console.log("");
|
|
1600
|
-
console.log(
|
|
1874
|
+
console.log(pc11.dim(` ${results.length} tag${results.length !== 1 ? "s" : ""}`));
|
|
1601
1875
|
console.log("");
|
|
1602
1876
|
for (let i = 0; i < results.length; i++) {
|
|
1603
1877
|
const tag = results[i];
|
|
1604
|
-
const num =
|
|
1605
|
-
const name =
|
|
1606
|
-
const slug = tag.slug ?
|
|
1878
|
+
const num = pc11.dim(`${String(i + 1)}.`.padStart(3));
|
|
1879
|
+
const name = pc11.bold(String(tag.name ?? ""));
|
|
1880
|
+
const slug = tag.slug ? pc11.dim(` (${tag.slug})`) : "";
|
|
1607
1881
|
console.log(` ${num} ${name}${slug}`);
|
|
1608
1882
|
}
|
|
1609
1883
|
console.log("");
|
|
@@ -1612,37 +1886,37 @@ function registerTagsCommand(program2) {
|
|
|
1612
1886
|
const cmd = program2.command("tags").description("Manage tags");
|
|
1613
1887
|
cmd.command("list").description("List all tags").argument("[social_set_id]", "Social set ID (uses default if omitted)").action(async (socialSetId) => {
|
|
1614
1888
|
const id = requireSocialSetId(socialSetId ?? null);
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1889
|
+
const spinner4 = spin("Fetching tags\u2026");
|
|
1890
|
+
spinner4.start();
|
|
1617
1891
|
const data = await apiRequest("GET", `/social-sets/${id}/tags?limit=50`);
|
|
1618
|
-
|
|
1892
|
+
spinner4.stop();
|
|
1619
1893
|
display(data, () => renderTagsList(data));
|
|
1620
1894
|
});
|
|
1621
1895
|
cmd.command("create").description("Create a new tag").argument("[social_set_id]", "Social set ID (uses default if omitted)").option("--name <name>", "Tag name (required)").action(async (socialSetId, opts) => {
|
|
1622
1896
|
const id = requireSocialSetId(socialSetId ?? null);
|
|
1623
1897
|
if (!opts.name) exitWithError("--name is required");
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1898
|
+
const spinner4 = spin("Creating tag\u2026");
|
|
1899
|
+
spinner4.start();
|
|
1626
1900
|
const data = await apiRequest("POST", `/social-sets/${id}/tags`, { name: opts.name });
|
|
1627
|
-
|
|
1901
|
+
spinner4.stop();
|
|
1628
1902
|
display(data, () => {
|
|
1629
1903
|
const tag = data;
|
|
1630
1904
|
console.log("");
|
|
1631
|
-
console.log(` ${
|
|
1632
|
-
if (tag.slug) console.log(
|
|
1905
|
+
console.log(` ${pc11.green("\u2713")} Tag created: ${pc11.bold(String(tag.name ?? opts.name))}`);
|
|
1906
|
+
if (tag.slug) console.log(pc11.dim(` Slug: ${tag.slug}`));
|
|
1633
1907
|
console.log("");
|
|
1634
1908
|
});
|
|
1635
1909
|
});
|
|
1636
1910
|
}
|
|
1637
1911
|
|
|
1638
1912
|
// src/utils/banner.ts
|
|
1639
|
-
import
|
|
1913
|
+
import pc12 from "picocolors";
|
|
1640
1914
|
var BANNER = `
|
|
1641
1915
|
\u281B\u28FF\u281B \u28FF\u28E4\u28FF \u28FF\u281B\u28FF \u28FF\u281B\u281B \u28FF\u281B\u281B \u28FF \u28FF \u28FF \u28FF \u28FF\u28E4\u28FF
|
|
1642
1916
|
\u28FF \u28FF \u28FF\u281B\u281B \u28FF\u28FF\u28E4 \u28FF\u281B \u28FF\u28E4\u28FF \u28FF\u28E4\u28E4 \u28FF\u28E4\u28E4 \u28FF
|
|
1643
1917
|
`;
|
|
1644
1918
|
function showBanner() {
|
|
1645
|
-
console.error(
|
|
1919
|
+
console.error(pc12.dim(BANNER));
|
|
1646
1920
|
}
|
|
1647
1921
|
|
|
1648
1922
|
// src/cli.ts
|
|
@@ -1650,8 +1924,8 @@ var require2 = createRequire(import.meta.url);
|
|
|
1650
1924
|
var pkg = require2("../package.json");
|
|
1651
1925
|
function createCli() {
|
|
1652
1926
|
const program2 = new Command();
|
|
1653
|
-
program2.name("typefully").description("Manage social media posts via the Typefully API").version(pkg.version, "-v, --version").option("-j, --json", "Output raw JSON instead of human-readable text").argument("[text]", "Post text \u2014 creates a draft directly, or omit for interactive mode").action(async (
|
|
1654
|
-
await runDraft(
|
|
1927
|
+
program2.name("typefully").description("Manage social media posts via the Typefully API").version(pkg.version, "-v, --version").option("-j, --json", "Output raw JSON instead of human-readable text").argument("[text]", "Post text \u2014 creates a draft directly, or omit for interactive mode").action(async (text6) => {
|
|
1928
|
+
await runDraft(text6);
|
|
1655
1929
|
}).hook("preAction", (_thisCommand) => {
|
|
1656
1930
|
const jsonMode = !!program2.opts().json;
|
|
1657
1931
|
setJsonMode(jsonMode);
|
|
@@ -1668,6 +1942,7 @@ function createCli() {
|
|
|
1668
1942
|
registerTagsCommand(program2);
|
|
1669
1943
|
registerMediaCommand(program2);
|
|
1670
1944
|
registerConfigCommand(program2);
|
|
1945
|
+
registerScheduleCommand(program2);
|
|
1671
1946
|
return program2;
|
|
1672
1947
|
}
|
|
1673
1948
|
|
package/package.json
CHANGED
package/skills/spec.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **This document is the authoritative contract for the Typefully CLI.**
|
|
4
4
|
> All commands, options, arguments, output shapes, and behaviors described here must be satisfied by any conforming implementation.
|
|
5
5
|
|
|
6
|
-
Last updated: 2026-02-
|
|
6
|
+
Last updated: 2026-02-19
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -35,6 +35,8 @@ Last updated: 2026-02-18
|
|
|
35
35
|
- [config show](#config-show)
|
|
36
36
|
- [config set-default](#config-set-default)
|
|
37
37
|
- [config set-platforms](#config-set-platforms)
|
|
38
|
+
- [config set-timezone](#config-set-timezone)
|
|
39
|
+
- [schedule](#schedule)
|
|
38
40
|
- [rm (alias)](#rm-alias)
|
|
39
41
|
- [Default command (tfly)](#default-command-tfly)
|
|
40
42
|
7. [Thread Syntax](#thread-syntax)
|
|
@@ -96,7 +98,8 @@ The banner is shown once per invocation before the first command action, **unles
|
|
|
96
98
|
{
|
|
97
99
|
"apiKey": "typ_xxxx",
|
|
98
100
|
"defaultSocialSetId": 12345,
|
|
99
|
-
"defaultPlatforms": ["x", "linkedin"]
|
|
101
|
+
"defaultPlatforms": ["x", "linkedin"],
|
|
102
|
+
"defaultTimezone": "America/Los_Angeles"
|
|
100
103
|
}
|
|
101
104
|
```
|
|
102
105
|
|
|
@@ -787,6 +790,92 @@ tfly config set-platforms [options]
|
|
|
787
790
|
|
|
788
791
|
---
|
|
789
792
|
|
|
793
|
+
### `config set-timezone`
|
|
794
|
+
|
|
795
|
+
Sets the default timezone used when entering custom schedule times.
|
|
796
|
+
|
|
797
|
+
```
|
|
798
|
+
typefully config set-timezone [options]
|
|
799
|
+
tfly config set-timezone [options]
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
**Options**
|
|
803
|
+
|
|
804
|
+
| Flag | Description |
|
|
805
|
+
|------|-------------|
|
|
806
|
+
| `--timezone <tz>` | IANA timezone name (skips interactive). |
|
|
807
|
+
| `--location <global\|local>` | Where to save: `global` or `local`. Interactive if omitted. |
|
|
808
|
+
| `--scope <global\|local>` | Alias for `--location`. |
|
|
809
|
+
|
|
810
|
+
**Behavior**
|
|
811
|
+
|
|
812
|
+
- If `--timezone` is omitted: shows a clack `select` with 14 common IANA timezones plus a "Custom" option that prompts for free-text input.
|
|
813
|
+
- The entered value is validated via `Intl.DateTimeFormat` before saving.
|
|
814
|
+
- Default when not configured: `America/Los_Angeles` (PST/PDT).
|
|
815
|
+
- Stored as `defaultTimezone` in the config JSON.
|
|
816
|
+
|
|
817
|
+
**JSON output shape**
|
|
818
|
+
|
|
819
|
+
```json
|
|
820
|
+
{
|
|
821
|
+
"success": true,
|
|
822
|
+
"default_timezone": "America/Los_Angeles",
|
|
823
|
+
"config_path": "/Users/you/.config/typefully/config.json"
|
|
824
|
+
}
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
---
|
|
828
|
+
|
|
829
|
+
### `schedule`
|
|
830
|
+
|
|
831
|
+
Interactively browse drafts and schedule one. Optimised for keyboard-only use.
|
|
832
|
+
|
|
833
|
+
```
|
|
834
|
+
tfly schedule
|
|
835
|
+
typefully schedule
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**No arguments or options.**
|
|
839
|
+
|
|
840
|
+
**Behavior**
|
|
841
|
+
|
|
842
|
+
1. Fetches up to 50 drafts (status `draft` or `scheduled`) for the default social set.
|
|
843
|
+
2. Fetches each draft's full details in parallel to load post text.
|
|
844
|
+
3. clack `select` — pick a draft. Each option shows the post preview and current status / scheduled time.
|
|
845
|
+
4. clack `select` — choose when to publish, with **Next free slot** pre-selected:
|
|
846
|
+
- `Next free slot` — sends `publish_at: "next-free-slot"` to the API.
|
|
847
|
+
- `Custom date & time` — prompts for date (`YYYY-MM-DD`) and time (`HH:MM`, 24h) in the configured timezone, then converts to UTC ISO 8601 before sending.
|
|
848
|
+
5. clack `confirm` — confirm the schedule (defaults to yes).
|
|
849
|
+
6. Calls `PATCH /social-sets/:id/drafts/:draft_id` with `{ publish_at }`.
|
|
850
|
+
7. Displays the updated draft.
|
|
851
|
+
8. clack `confirm` — **Open in browser?** (defaults to yes). Opens `share_url` or `https://typefully.com/?d=<id>` using the platform-native open command.
|
|
852
|
+
|
|
853
|
+
**Happy path (keyboard flow)**
|
|
854
|
+
|
|
855
|
+
```
|
|
856
|
+
↑↓ Enter — pick draft
|
|
857
|
+
Enter — confirm "Next free slot" (pre-selected)
|
|
858
|
+
Enter — confirm schedule
|
|
859
|
+
Enter — open in browser
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
**Custom time flow** (breaks the enter-only path intentionally)
|
|
863
|
+
|
|
864
|
+
```
|
|
865
|
+
↑↓ Enter — pick draft
|
|
866
|
+
↓ Enter — select "Custom date & time"
|
|
867
|
+
<date> Enter
|
|
868
|
+
<time> Enter
|
|
869
|
+
Enter — confirm schedule
|
|
870
|
+
Enter — open in browser
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
Cancelling at any prompt (Ctrl+C or selecting No on confirm) exits cleanly with code 0.
|
|
874
|
+
|
|
875
|
+
**JSON output shape** — single draft object (same as `drafts schedule`).
|
|
876
|
+
|
|
877
|
+
---
|
|
878
|
+
|
|
790
879
|
### `rm` (alias)
|
|
791
880
|
|
|
792
881
|
Deletes a draft. Provide a draft ID for direct deletion, or omit it for an interactive clack multiselect picker.
|