juzpost-cli 0.1.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/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/api.js +64 -0
- package/dist/auth.js +58 -0
- package/dist/config.js +39 -0
- package/dist/index.js +182 -0
- package/dist/list.js +28 -0
- package/dist/output.js +22 -0
- package/dist/upload.js +28 -0
- package/package.json +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jiiva Durai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# juzpost-cli
|
|
2
|
+
|
|
3
|
+
CLI for [JuzPost](https://www.juzpost.com) — login and smart-schedule clips from the terminal.
|
|
4
|
+
Shells out from the ASMR clipper: `queue draft → juzpost schedule --group <name> --min-per-day <N>`.
|
|
5
|
+
|
|
6
|
+
> **Skeleton.** Command tree is wired; backend endpoints under `/api/cli/v1/*` are not built
|
|
7
|
+
> yet (see the prerequisite milestone in the JuzPost repo). Commands will 404 until they land.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install
|
|
13
|
+
npm run dev -- --help # run from source (Node 22 type-stripping via tsx)
|
|
14
|
+
npm test # config store self-check
|
|
15
|
+
npm run build && node dist/index.js --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Auth model
|
|
19
|
+
|
|
20
|
+
- `juzpost auth login` — browser device-code flow; stores a revocable CLI token.
|
|
21
|
+
Single login method by design (no paste-token path).
|
|
22
|
+
- Tokens are tagged (`type=cli` + browser/device) and **revocable in JuzPost settings**
|
|
23
|
+
(hard delete → next request 401). Manual API keys stay in the dashboard, unchanged.
|
|
24
|
+
- The CLI only talks to the **token-only** `/api/cli/v1/*` namespace.
|
|
25
|
+
|
|
26
|
+
## Commands (skeleton)
|
|
27
|
+
|
|
28
|
+
| Command | Purpose | Backend |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `auth login` / `logout [--revoke]` / `whoami` | device-code login, identity | NEW |
|
|
31
|
+
| `account status` | plan + paid-tier entitlements | NEW (`/me`) |
|
|
32
|
+
| `accounts [--platform/--group-id]` | list connected social accounts | reuse |
|
|
33
|
+
| `groups [--name]` | list channel groups (account presets) | reuse |
|
|
34
|
+
| `workspace` | timezone + default times | reuse |
|
|
35
|
+
| `posts list [--status/--account-id/--from/--to]` | list posts (cursor pagination) | NEW (list route) |
|
|
36
|
+
| `posts create [--file/--content/--title/--hashtags…]` | upload media + create draft | NEW |
|
|
37
|
+
| `posts schedule <id> --account [--at]` | schedule one draft (low-level) | reuse `/posts/:id/schedule` |
|
|
38
|
+
| `schedule --group --min-per-day [--dry-run]` | smart-spread drafts across days | NEW + reuse `/posts/schedule` |
|
|
39
|
+
|
|
40
|
+
List commands share `--limit/--cursor/--sort/--order/--all` (cursor pagination, spec §3).
|
|
41
|
+
|
|
42
|
+
Config lives at `$XDG_CONFIG_HOME/juzpost/config.json` (`~/.config/juzpost`).
|
|
43
|
+
Override base url with `--base <url>` or `JUZPOST_URL`.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Thin HTTP client for the token-only /api/cli/v1/* namespace.
|
|
2
|
+
// ponytail: native fetch (Node 22), no axios. Token from config, Bearer auth.
|
|
3
|
+
import { baseUrl, loadConfig } from './config.js';
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function api(path, opts = {}) {
|
|
12
|
+
const { method = 'GET', body, query, auth = true } = opts;
|
|
13
|
+
const url = new URL(path.replace(/^\//, ''), baseUrl().replace(/\/?$/, '/'));
|
|
14
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
15
|
+
if (v !== undefined)
|
|
16
|
+
url.searchParams.set(k, String(v));
|
|
17
|
+
}
|
|
18
|
+
const headers = { 'content-type': 'application/json' };
|
|
19
|
+
if (auth) {
|
|
20
|
+
const token = loadConfig().token;
|
|
21
|
+
if (!token)
|
|
22
|
+
throw new ApiError(401, 'Not logged in. Run `juzpost auth login`.');
|
|
23
|
+
headers.authorization = `Bearer ${token}`;
|
|
24
|
+
}
|
|
25
|
+
const res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
const data = text ? safeJson(text) : undefined;
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
// /api/cli/v1/* returns { error: { code, message } }; the forwarded schedule routes
|
|
30
|
+
// return { error: "string" }. Handle both shapes.
|
|
31
|
+
const errField = data?.error;
|
|
32
|
+
const msg = typeof errField === 'string'
|
|
33
|
+
? errField
|
|
34
|
+
: errField?.message || res.statusText || 'request failed';
|
|
35
|
+
throw new ApiError(res.status, `${msg} (HTTP ${res.status})`);
|
|
36
|
+
}
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
function safeJson(text) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { raw: text };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Fetch a single page of a list endpoint. */
|
|
48
|
+
export function apiList(path, params = {}) {
|
|
49
|
+
return api(path, { query: params });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Follow `nextCursor` until exhausted or `max` rows collected.
|
|
53
|
+
* ponytail: hard cap (default 1000) so a runaway server can't loop us forever.
|
|
54
|
+
*/
|
|
55
|
+
export async function apiListAll(path, params = {}, max = 1000) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let cursor = params.cursor;
|
|
58
|
+
do {
|
|
59
|
+
const page = await apiList(path, { ...params, cursor });
|
|
60
|
+
out.push(...page.data);
|
|
61
|
+
cursor = page.pagination.hasMore ? page.pagination.nextCursor ?? undefined : undefined;
|
|
62
|
+
} while (cursor && out.length < max);
|
|
63
|
+
return out.slice(0, max);
|
|
64
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Auth flows: device-code login, logout, whoami. Logic lives here (not in the command
|
|
2
|
+
// wiring) so it's unit-testable with a mocked fetch + injectable sleep.
|
|
3
|
+
import { api, ApiError } from './api.js';
|
|
4
|
+
import { saveConfig, clearToken, loadConfig } from './config.js';
|
|
5
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
/**
|
|
7
|
+
* Device-code login: start → print verify URL → poll auth/token until approved.
|
|
8
|
+
* Polls handle `428 pending` (keep waiting) and `410 gone` (expired). Stores the token.
|
|
9
|
+
*/
|
|
10
|
+
export async function login(opts = {}) {
|
|
11
|
+
const sleep = opts.sleep ?? realSleep;
|
|
12
|
+
const start = await api('/api/cli/v1/auth/start', { method: 'POST', auth: false, body: { deviceName: opts.deviceName } });
|
|
13
|
+
opts.onPrompt?.(start.verifyUrl);
|
|
14
|
+
const interval = start.interval ?? 3;
|
|
15
|
+
const maxWait = opts.maxWaitSec ?? start.expiresIn ?? 600;
|
|
16
|
+
let waited = 0;
|
|
17
|
+
while (waited < maxWait) {
|
|
18
|
+
await sleep(interval * 1000);
|
|
19
|
+
waited += interval;
|
|
20
|
+
try {
|
|
21
|
+
const r = await api('/api/cli/v1/auth/token', {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
auth: false,
|
|
24
|
+
body: { deviceCode: start.deviceCode },
|
|
25
|
+
});
|
|
26
|
+
if (r.token) {
|
|
27
|
+
saveConfig({ token: r.token });
|
|
28
|
+
return { token: r.token };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
if (e instanceof ApiError && e.status === 428)
|
|
33
|
+
continue; // pending approval
|
|
34
|
+
if (e instanceof ApiError && e.status === 410)
|
|
35
|
+
throw new Error('Login code expired. Run `juzpost auth login` again.');
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error('Login timed out waiting for browser approval.');
|
|
40
|
+
}
|
|
41
|
+
/** Clear the local token; with `revoke`, also hard-delete the server-side row first. */
|
|
42
|
+
export async function logout(opts = {}) {
|
|
43
|
+
if (opts.revoke && loadConfig().token) {
|
|
44
|
+
try {
|
|
45
|
+
await api('/api/cli/v1/auth/token', { method: 'DELETE' });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// best-effort: a revoked/expired token still gets cleared locally below
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
clearToken();
|
|
52
|
+
}
|
|
53
|
+
/** Identity behind the stored token, or null if logged out. */
|
|
54
|
+
export function whoami() {
|
|
55
|
+
if (!loadConfig().token)
|
|
56
|
+
return null;
|
|
57
|
+
return api('/api/cli/v1/me');
|
|
58
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Token + base-url store. Plain JSON at $XDG_CONFIG_HOME/juzpost/config.json
|
|
2
|
+
// (defaults to ~/.config/juzpost). ponytail: fs + JSON, not a config library.
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
6
|
+
const DEFAULT_BASE = 'https://www.juzpost.com';
|
|
7
|
+
function dir() {
|
|
8
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'juzpost');
|
|
9
|
+
}
|
|
10
|
+
export function configPath() {
|
|
11
|
+
return join(dir(), 'config.json');
|
|
12
|
+
}
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
const p = configPath();
|
|
15
|
+
if (!existsSync(p))
|
|
16
|
+
return {};
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {}; // ponytail: corrupt file = treat as empty, login rewrites it
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveConfig(patch) {
|
|
25
|
+
const next = { ...loadConfig(), ...patch };
|
|
26
|
+
mkdirSync(dir(), { recursive: true });
|
|
27
|
+
writeFileSync(configPath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
export function clearToken() {
|
|
31
|
+
const cfg = loadConfig();
|
|
32
|
+
delete cfg.token;
|
|
33
|
+
mkdirSync(dir(), { recursive: true });
|
|
34
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
/** Resolution order: --base flag (via env we set) > config file > prod default. */
|
|
37
|
+
export function baseUrl() {
|
|
38
|
+
return process.env.JUZPOST_URL || loadConfig().baseUrl || DEFAULT_BASE;
|
|
39
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// JuzPost CLI — command tree mirrors the Higgsfield CLI shape (subcommand groups,
|
|
3
|
+
// global --json, --wait-style polling). Talks to the token-only /api/cli/v1/* namespace.
|
|
4
|
+
// Backend endpoints live in the JuzPost repo's /api/cli/v1/* namespace.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import { api, ApiError } from './api.js';
|
|
10
|
+
import { saveConfig } from './config.js';
|
|
11
|
+
import { render } from './output.js';
|
|
12
|
+
import * as auth from './auth.js';
|
|
13
|
+
import { runList } from './list.js';
|
|
14
|
+
import { uploadMedia } from './upload.js';
|
|
15
|
+
// Single source of truth for the version — ../package.json (resolves from both dist/ and src/).
|
|
16
|
+
const { version } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'));
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name('juzpost')
|
|
20
|
+
.description('Schedule clips to JuzPost from the terminal')
|
|
21
|
+
.version(version)
|
|
22
|
+
.option('--json', 'print raw JSON responses')
|
|
23
|
+
.option('--base <url>', 'override API base url (persists to config)');
|
|
24
|
+
// --base is sugar for persisting baseUrl before any command runs.
|
|
25
|
+
program.hook('preAction', (thisCmd) => {
|
|
26
|
+
const base = thisCmd.opts().base;
|
|
27
|
+
if (base)
|
|
28
|
+
saveConfig({ baseUrl: base });
|
|
29
|
+
});
|
|
30
|
+
const out = (data) => console.log(render(data, !!program.opts().json));
|
|
31
|
+
// Attach the shared cursor-pagination flags to a list command.
|
|
32
|
+
const withListOpts = (c) => c
|
|
33
|
+
.option('--limit <n>', 'page size (1–100)')
|
|
34
|
+
.option('--cursor <c>', 'pagination cursor from a previous page')
|
|
35
|
+
.option('--sort <field>', 'sort field')
|
|
36
|
+
.option('--order <dir>', 'asc | desc')
|
|
37
|
+
.option('--all', 'follow cursors and return everything');
|
|
38
|
+
const printList = (r) => {
|
|
39
|
+
if (program.opts().json)
|
|
40
|
+
return console.log(JSON.stringify(r, null, 2));
|
|
41
|
+
console.log(render(r.data, false));
|
|
42
|
+
if (r.hasMore && r.nextCursor)
|
|
43
|
+
console.log(`\n… more — pass --cursor ${r.nextCursor} (or --all)`);
|
|
44
|
+
};
|
|
45
|
+
// ── auth ─────────────────────────────────────────────────────────────────────
|
|
46
|
+
const authCmd = program.command('auth').description('Login, logout, identity');
|
|
47
|
+
authCmd
|
|
48
|
+
.command('login')
|
|
49
|
+
.description('Browser device-code login; stores a revocable CLI token')
|
|
50
|
+
.option('--device-name <name>', 'label this token in JuzPost settings')
|
|
51
|
+
.action(async (opts) => {
|
|
52
|
+
// Single login method by design — no paste-token path. Manual tokens stay in the dashboard.
|
|
53
|
+
await auth.login({
|
|
54
|
+
deviceName: opts.deviceName,
|
|
55
|
+
onPrompt: (url) => console.log(`Open this URL in your browser to approve:\n ${url}\n`),
|
|
56
|
+
});
|
|
57
|
+
out('Logged in. Token stored (revoke anytime in JuzPost settings).');
|
|
58
|
+
});
|
|
59
|
+
authCmd
|
|
60
|
+
.command('logout')
|
|
61
|
+
.description('Clear the stored token locally')
|
|
62
|
+
.option('--revoke', 'also hard-delete the token server-side')
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
await auth.logout({ revoke: opts.revoke });
|
|
65
|
+
out(opts.revoke ? 'Logged out and token revoked.' : 'Logged out (token still valid server-side until deleted in settings).');
|
|
66
|
+
});
|
|
67
|
+
authCmd
|
|
68
|
+
.command('whoami')
|
|
69
|
+
.description('Show the identity behind the stored token')
|
|
70
|
+
.action(async () => {
|
|
71
|
+
const me = auth.whoami();
|
|
72
|
+
out(me === null ? 'Not logged in.' : await me);
|
|
73
|
+
});
|
|
74
|
+
// ── account ──────────────────────────────────────────────────────────────────
|
|
75
|
+
program
|
|
76
|
+
.command('account')
|
|
77
|
+
.description('Plan, entitlements, status')
|
|
78
|
+
.command('status')
|
|
79
|
+
.description('Show plan + paid-tier entitlements')
|
|
80
|
+
.action(async () => {
|
|
81
|
+
out(await api('/api/cli/v1/me')); // TODO(api): GET /api/cli/v1/me (plan + entitlement gate)
|
|
82
|
+
});
|
|
83
|
+
// ── accounts (list) ───────────────────────────────────────────────────────────
|
|
84
|
+
withListOpts(program
|
|
85
|
+
.command('accounts')
|
|
86
|
+
.description('List connected social accounts')
|
|
87
|
+
.option('--platform <p>', 'filter: tiktok | youtube | instagram | x')
|
|
88
|
+
.option('--group-id <id>', 'filter: only accounts in this channel group')).action(async (opts) => printList(await runList('/api/cli/v1/accounts', opts, ['platform', 'groupId'])));
|
|
89
|
+
// ── groups (list) ─────────────────────────────────────────────────────────────
|
|
90
|
+
withListOpts(program
|
|
91
|
+
.command('groups')
|
|
92
|
+
.description('List channel groups (account presets)')
|
|
93
|
+
.option('--name <q>', 'filter: name contains')).action(async (opts) => printList(await runList('/api/cli/v1/groups', opts, ['name'])));
|
|
94
|
+
// ── workspace (single object) ─────────────────────────────────────────────────
|
|
95
|
+
program
|
|
96
|
+
.command('workspace')
|
|
97
|
+
.description('Show workspace timezone + default times')
|
|
98
|
+
.action(async () => out(await api('/api/cli/v1/workspace')));
|
|
99
|
+
// ── posts (group: list | create | schedule) ──────────────────────────────────
|
|
100
|
+
const posts = program.command('posts').description('List, create, and schedule posts');
|
|
101
|
+
withListOpts(posts
|
|
102
|
+
.command('list')
|
|
103
|
+
.description('List posts')
|
|
104
|
+
.option('--status <s>', 'filter: draft | scheduled | posted | failed')
|
|
105
|
+
.option('--account-id <id>', 'filter: posts targeting this account')
|
|
106
|
+
.option('--from <date>', 'filter: ISO date lower bound')
|
|
107
|
+
.option('--to <date>', 'filter: ISO date upper bound')).action(async (opts) => printList(await runList('/api/cli/v1/posts', opts, ['status', 'accountId', 'from', 'to'])));
|
|
108
|
+
posts
|
|
109
|
+
.command('create')
|
|
110
|
+
.description('Create a draft post (optionally uploading media)')
|
|
111
|
+
.option('--file <path>', 'media file to upload (presign → R2)')
|
|
112
|
+
.option('--content <text>', 'post body / caption')
|
|
113
|
+
.option('--title <title>', 'title')
|
|
114
|
+
.option('--description <desc>', 'description')
|
|
115
|
+
.option('--hashtags <tag...>', 'hashtags (no # prefix)')
|
|
116
|
+
.option('--type <type>', 'text | image | video | story')
|
|
117
|
+
.option('--cover-key <key>', 'R2 key of an already-uploaded cover')
|
|
118
|
+
.action(async (opts) => {
|
|
119
|
+
const mediaUrls = opts.file ? [await uploadMedia(opts.file)] : undefined;
|
|
120
|
+
const res = await api('/api/cli/v1/posts', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
body: {
|
|
123
|
+
content: opts.content,
|
|
124
|
+
mediaUrls,
|
|
125
|
+
title: opts.title,
|
|
126
|
+
description: opts.description,
|
|
127
|
+
hashtags: opts.hashtags,
|
|
128
|
+
type: opts.type,
|
|
129
|
+
coverR2Key: opts.coverKey,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
out(res);
|
|
133
|
+
});
|
|
134
|
+
posts
|
|
135
|
+
.command('schedule <postId>')
|
|
136
|
+
.description('Schedule one draft (low-level)')
|
|
137
|
+
.requiredOption('--account <id...>', 'social account id(s), 1–10')
|
|
138
|
+
.option('--at <iso>', 'publish time, ISO 8601 UTC; omit = post now')
|
|
139
|
+
.action(async (postId, opts) => {
|
|
140
|
+
const res = await api(`/api/cli/v1/posts/${postId}/schedule`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
body: { publishAt: opts.at, socialAccountIds: opts.account },
|
|
143
|
+
});
|
|
144
|
+
out(res);
|
|
145
|
+
});
|
|
146
|
+
// ── schedule (the headline) ───────────────────────────────────────────────────
|
|
147
|
+
program
|
|
148
|
+
.command('schedule')
|
|
149
|
+
.description('Smart-schedule draft posts across a group at preset times')
|
|
150
|
+
.requiredOption('--group <name>', 'account group (channel group) name')
|
|
151
|
+
.requiredOption('--min-per-day <n>', 'minimum posts per day', (v) => parseInt(v, 10))
|
|
152
|
+
.option('--post-id <id...>', 'specific draft post id(s); default = all drafts')
|
|
153
|
+
.option('--start-date <date>', 'first day to schedule (ISO date); default = tomorrow')
|
|
154
|
+
.option('--times <hh:mm...>', 'override workspace default times')
|
|
155
|
+
.option('--dry-run', 'compute and print the plan without scheduling')
|
|
156
|
+
.action(async (opts) => {
|
|
157
|
+
const res = await api('/api/cli/v1/schedule', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
body: {
|
|
160
|
+
group: opts.group,
|
|
161
|
+
minPerDay: opts.minPerDay,
|
|
162
|
+
postIds: opts.postId,
|
|
163
|
+
startDate: opts.startDate,
|
|
164
|
+
times: opts.times,
|
|
165
|
+
dryRun: opts.dryRun,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (program.opts().json)
|
|
169
|
+
return out(res);
|
|
170
|
+
if (opts.dryRun)
|
|
171
|
+
console.log('DRY RUN — nothing scheduled\n');
|
|
172
|
+
if (res.plan)
|
|
173
|
+
console.log(render(res.plan, false) + '\n');
|
|
174
|
+
console.log(render(res.scheduled ?? [], false));
|
|
175
|
+
if (res.skipped?.length)
|
|
176
|
+
console.log('\nSkipped:\n' + render(res.skipped, false));
|
|
177
|
+
});
|
|
178
|
+
program.parseAsync().catch((e) => {
|
|
179
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
180
|
+
console.error(`Error: ${msg}`);
|
|
181
|
+
process.exitCode = e instanceof ApiError && e.status >= 400 && e.status < 500 ? 1 : 3;
|
|
182
|
+
});
|
package/dist/list.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared plumbing for list commands: map commander options → ListParams and run a
|
|
2
|
+
// single cursor page (default) or follow all pages (--all). Normalizes both to one shape.
|
|
3
|
+
import { apiList, apiListAll } from './api.js';
|
|
4
|
+
export function toParams(opts, filterKeys) {
|
|
5
|
+
const p = {};
|
|
6
|
+
if (opts.limit)
|
|
7
|
+
p.limit = parseInt(opts.limit, 10);
|
|
8
|
+
if (opts.cursor)
|
|
9
|
+
p.cursor = opts.cursor;
|
|
10
|
+
if (opts.sort)
|
|
11
|
+
p.sort = opts.sort;
|
|
12
|
+
if (opts.order)
|
|
13
|
+
p.order = opts.order;
|
|
14
|
+
for (const k of filterKeys) {
|
|
15
|
+
const v = opts[k];
|
|
16
|
+
if (v != null)
|
|
17
|
+
p[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
export async function runList(path, opts, filterKeys = []) {
|
|
22
|
+
const params = toParams(opts, filterKeys);
|
|
23
|
+
if (opts.all) {
|
|
24
|
+
return { data: await apiListAll(path, params), nextCursor: null, hasMore: false };
|
|
25
|
+
}
|
|
26
|
+
const page = await apiList(path, params);
|
|
27
|
+
return { data: page.data, nextCursor: page.pagination.nextCursor, hasMore: page.pagination.hasMore };
|
|
28
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Output rendering: --json (pretty JSON) or a human table for list data.
|
|
2
|
+
// ponytail: hand-rolled fixed-width table, no cli-table dep — values are short.
|
|
3
|
+
export function render(data, json) {
|
|
4
|
+
if (json)
|
|
5
|
+
return JSON.stringify(data, null, 2);
|
|
6
|
+
if (typeof data === 'string')
|
|
7
|
+
return data;
|
|
8
|
+
if (Array.isArray(data))
|
|
9
|
+
return data.length ? table(data) : '(none)';
|
|
10
|
+
return JSON.stringify(data, null, 2); // single object → JSON is the clearest plain view
|
|
11
|
+
}
|
|
12
|
+
/** Render an array of flat objects as an aligned table. Columns = keys of the first row. */
|
|
13
|
+
export function table(rows, columns) {
|
|
14
|
+
const objs = rows.filter((r) => !!r && typeof r === 'object');
|
|
15
|
+
if (!objs.length)
|
|
16
|
+
return '(none)';
|
|
17
|
+
const cols = columns ?? Object.keys(objs[0]);
|
|
18
|
+
const cell = (v) => v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
19
|
+
const widths = cols.map((c) => Math.max(c.length, ...objs.map((o) => cell(o[c]).length)));
|
|
20
|
+
const line = (cells) => cells.map((s, i) => s.padEnd(widths[i])).join(' ').trimEnd();
|
|
21
|
+
return [line(cols), line(cols.map((_, i) => '-'.repeat(widths[i]))), ...objs.map((o) => line(cols.map((c) => cell(o[c]))))].join('\n');
|
|
22
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Media upload: presign → PUT raw bytes to R2 → return the workspace-scoped key.
|
|
2
|
+
// The PUT goes to the signed URL directly (absolute, no auth header — the signature IS the auth).
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, extname } from 'node:path';
|
|
5
|
+
import { api, ApiError } from './api.js';
|
|
6
|
+
const CONTENT_TYPES = {
|
|
7
|
+
'.mp4': 'video/mp4',
|
|
8
|
+
'.mov': 'video/quicktime',
|
|
9
|
+
'.webm': 'video/webm',
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
'.jpg': 'image/jpeg',
|
|
12
|
+
'.jpeg': 'image/jpeg',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
};
|
|
15
|
+
export function contentTypeFor(file) {
|
|
16
|
+
return CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
17
|
+
}
|
|
18
|
+
/** Upload a local file to R2 via presign+PUT; returns the resolved (workspace-scoped) key. */
|
|
19
|
+
export async function uploadMedia(filePath, opts = {}) {
|
|
20
|
+
const contentType = contentTypeFor(filePath);
|
|
21
|
+
const relativeKey = opts.relativeKey ?? `media/${basename(filePath)}`;
|
|
22
|
+
const { signedUrl, resolvedKey } = await api('/api/cli/v1/storage/presign', { method: 'POST', body: { relativeKey, contentType, operation: 'upload' } });
|
|
23
|
+
const bytes = readFileSync(filePath);
|
|
24
|
+
const res = await fetch(signedUrl, { method: 'PUT', headers: { 'content-type': contentType }, body: bytes });
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
throw new ApiError(res.status, `R2 upload failed (HTTP ${res.status})`);
|
|
27
|
+
return resolvedKey;
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "juzpost-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for JuzPost — login + smart-schedule short-form clips from the terminal",
|
|
5
|
+
"keywords": ["juzpost", "cli", "social", "scheduler", "tiktok", "youtube", "shorts", "clips"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jiiva Durai <jackstiffer1@gmail.com>",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": { "juzpost": "dist/index.js" },
|
|
10
|
+
"files": ["dist"],
|
|
11
|
+
"engines": { "node": ">=18" },
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/index.ts",
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
16
|
+
"prepublishOnly": "npm run build && npm test"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"commander": "^13.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"tsx": "^4.19.0",
|
|
24
|
+
"typescript": "^5.6.0"
|
|
25
|
+
}
|
|
26
|
+
}
|