@wji.ai/cli 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +23 -3
  2. package/dist/index.js +7162 -76
  3. package/package.json +6 -9
  4. package/skill/backreel/SKILL.md +171 -0
  5. package/skill/backreel/reference/crowdfunding.md +82 -0
  6. package/skill/backreel/reference/enquiries.md +80 -0
  7. package/skill/backreel/reference/episodes.md +104 -0
  8. package/skill/backreel/reference/images.md +76 -0
  9. package/skill/npc-character/SKILL.md +111 -0
  10. package/skill/npc-character/examples/nagi.character.md +117 -0
  11. package/skill/npc-character/examples/nagi.stage.md +34 -0
  12. package/skill/npc-character/examples/yuzu.character.md +110 -0
  13. package/skill/npc-character/examples/yuzu.stage.md +30 -0
  14. package/skill/npc-character/reference/character-craft.md +153 -0
  15. package/skill/npc-character/reference/document-format.md +151 -0
  16. package/skill/npc-character/reference/interview.md +44 -0
  17. package/skill/npc-character/reference/life.md +92 -0
  18. package/skill/npc-character/reference/runtime.md +95 -0
  19. package/skill/npc-character/reference/stage-craft.md +109 -0
  20. package/skill/npc-character/reference/visual-kit.md +81 -0
  21. package/skill/npc-character/reference/voice.md +101 -0
  22. package/skill/wji/SKILL.md +1 -1
  23. package/skill/wji/reference/commands.md +25 -5
  24. package/dist/commands/agent.js +0 -294
  25. package/dist/commands/asset.js +0 -241
  26. package/dist/commands/auth.js +0 -216
  27. package/dist/commands/backreel.js +0 -320
  28. package/dist/commands/canvas.js +0 -1706
  29. package/dist/commands/class.js +0 -395
  30. package/dist/commands/config.js +0 -113
  31. package/dist/commands/crew.js +0 -205
  32. package/dist/commands/fsnode.js +0 -288
  33. package/dist/commands/gen.js +0 -352
  34. package/dist/commands/model.js +0 -136
  35. package/dist/commands/npc.js +0 -413
  36. package/dist/commands/org.js +0 -85
  37. package/dist/commands/preset.js +0 -176
  38. package/dist/commands/profile.js +0 -129
  39. package/dist/commands/skill.js +0 -272
  40. package/dist/commands/workspace.js +0 -470
  41. package/dist/core/archive.js +0 -95
  42. package/dist/core/client.js +0 -252
  43. package/dist/core/config.js +0 -95
  44. package/dist/core/crew.js +0 -546
  45. package/dist/core/members.js +0 -54
  46. package/dist/core/npc.js +0 -108
  47. package/dist/core/output.js +0 -174
  48. package/dist/core/prompt.js +0 -118
  49. package/dist/core/zip.js +0 -87
@@ -1,216 +0,0 @@
1
- import { Command } from 'commander';
2
- import { ApiError, WjiClient } from '../core/client.js';
3
- import { getProfile, updateProfile } from '../core/config.js';
4
- import { askSecret, ask, confirm } from '../core/prompt.js';
5
- import { color, detail, info, isJsonMode, printJson, relativeTime, render, success, table, warn } from '../core/output.js';
6
- export function authCommand(globals) {
7
- const auth = new Command('auth').description('Sign in and manage credentials');
8
- auth
9
- .command('login')
10
- .description('Sign in with email + password and store the tokens')
11
- .option('-e, --email <email>', 'account email')
12
- .option('-p, --password <password>', 'account password (prompted when omitted)')
13
- .action(async (opts) => {
14
- const g = globals();
15
- const client = new WjiClient(g);
16
- const email = opts.email || (await ask('Email: '));
17
- const password = opts.password || (await askSecret('Password: '));
18
- const result = await client.request('POST', '/auth/signin', {
19
- body: { email, password },
20
- anonymous: true,
21
- });
22
- updateProfile(g.profile, {
23
- endpoint: client.endpoint,
24
- accessToken: result.tokens.accessToken,
25
- refreshToken: result.tokens.refreshToken,
26
- userId: result.user.id,
27
- email: result.user.email,
28
- name: result.user.name,
29
- });
30
- if (isJsonMode())
31
- return printJson(result);
32
- success(`Signed in as ${result.user.name || result.user.email} (profile: ${client.profileName})`);
33
- info(color.dim(`Endpoint ${client.endpoint} · balance ${result.user.balance ?? '-'}`));
34
- });
35
- auth
36
- .command('signup')
37
- .description('Register a new account (needs an email verification code)')
38
- .requiredOption('-e, --email <email>', 'account email')
39
- .requiredOption('-n, --name <name>', 'display name')
40
- .requiredOption('-c, --code <code>', 'email verification code')
41
- .option('-p, --password <password>', 'password (prompted when omitted)')
42
- .action(async (opts) => {
43
- const g = globals();
44
- const client = new WjiClient(g);
45
- const password = opts.password || (await askSecret('Password: '));
46
- const result = await client.request('POST', '/auth/signup', {
47
- body: { email: opts.email, name: opts.name, code: opts.code, password },
48
- anonymous: true,
49
- });
50
- updateProfile(g.profile, {
51
- endpoint: client.endpoint,
52
- accessToken: result.tokens.accessToken,
53
- refreshToken: result.tokens.refreshToken,
54
- userId: result.user.id,
55
- email: result.user.email,
56
- name: result.user.name,
57
- });
58
- render(result, () => success(`Registered and signed in as ${result.user.email}`));
59
- });
60
- auth
61
- .command('token')
62
- .description('Print the active bearer token (use for scripting)')
63
- .action(() => {
64
- const client = new WjiClient(globals());
65
- process.stdout.write(client.requireToken() + '\n');
66
- });
67
- auth
68
- .command('whoami')
69
- .description('Show the signed-in account')
70
- .action(async () => {
71
- const client = new WjiClient(globals());
72
- const me = await client.get('/user/me');
73
- render(me, (u) => detail([
74
- ['id', u.id],
75
- ['name', u.name],
76
- ['email', u.email],
77
- ['balance', u.balance],
78
- ['vip', u.vip?.level],
79
- ['endpoint', client.endpoint],
80
- ['org', client.orgId || 'personal'],
81
- ['profile', client.profileName],
82
- ['credential', client.usingApiKey ? 'api key' : 'jwt'],
83
- ]));
84
- });
85
- auth
86
- .command('refresh')
87
- .description('Exchange the stored refresh token for a fresh access token')
88
- .action(async () => {
89
- const client = new WjiClient(globals());
90
- // Goes through the same locked path as the automatic refresh, so a manual
91
- // refresh can't race a parallel command into replaying the old token.
92
- const profile = await client.refreshSession();
93
- render({ accessToken: profile.accessToken, refreshToken: profile.refreshToken }, () => success('Access token refreshed'));
94
- });
95
- auth
96
- .command('logout')
97
- .description('Clear stored credentials for the current profile')
98
- .action(async () => {
99
- const g = globals();
100
- const { profile } = getProfile(g.profile);
101
- if (profile.refreshToken && profile.accessToken) {
102
- // POST with the token in the body — `GET /auth/signout?rt=` is deprecated
103
- // and put the refresh token into access logs.
104
- const client = new WjiClient(g);
105
- const signout = (refreshToken) => client.request('POST', '/auth/signout', { body: { refreshToken }, noRetry: true });
106
- try {
107
- try {
108
- await signout(profile.refreshToken);
109
- }
110
- catch (err) {
111
- // An expired access token: refreshing rotates the refresh token, so the
112
- // one to revoke is the new one — revoking the old one would be a no-op.
113
- if (!(err instanceof ApiError && err.status === 401))
114
- throw err;
115
- const rotated = await client.refreshSession();
116
- if (rotated.refreshToken)
117
- await signout(rotated.refreshToken);
118
- }
119
- }
120
- catch {
121
- // Server-side signout is best effort; local credentials still go.
122
- }
123
- }
124
- updateProfile(g.profile, {
125
- accessToken: undefined,
126
- refreshToken: undefined,
127
- apiKey: undefined,
128
- userId: undefined,
129
- email: undefined,
130
- name: undefined,
131
- });
132
- success('Signed out');
133
- });
134
- // ── API keys ───────────────────────────────────────────────────────────────
135
- const key = auth.command('key').description('Manage long-lived sk_ API keys');
136
- key
137
- .command('list')
138
- .description('List your API keys')
139
- .option('--limit <n>', 'page size', '20')
140
- .action(async (opts) => {
141
- const client = new WjiClient(globals());
142
- const result = await client.get('/api-key/search', { limit: Number(opts.limit) });
143
- render(result, (r) => table(r.data || [], [
144
- { header: 'id', value: (k) => k.id },
145
- { header: 'name', value: (k) => k.name },
146
- { header: 'prefix', value: (k) => k.keyPrefix },
147
- { header: 'revoked', value: (k) => (k.revokedAt ? 'yes' : '') },
148
- { header: 'last used', value: (k) => relativeTime(k.lastUsedAt) },
149
- { header: 'created', value: (k) => relativeTime(k.createdAt) },
150
- ]));
151
- });
152
- key
153
- .command('create <name>')
154
- .description('Create an API key; the secret is shown once')
155
- .option('--expires <iso>', 'expiry date, e.g. 2027-01-01')
156
- .option('--save', 'store the new key in this profile and use it for future calls')
157
- .action(async (name, opts) => {
158
- const g = globals();
159
- const client = new WjiClient(g);
160
- // The endpoint answers { apiKey, secret }; the plaintext secret is only ever
161
- // returned here.
162
- const created = await client.post('/api-key/create', {
163
- name,
164
- expiresAt: opts.expires ? new Date(opts.expires).toISOString() : undefined,
165
- });
166
- const secret = created.secret;
167
- if (opts.save && secret)
168
- updateProfile(g.profile, { apiKey: secret });
169
- if (isJsonMode())
170
- return printJson(created);
171
- success(`Created API key "${name}" (${created.apiKey?.id ?? ''})`);
172
- if (secret) {
173
- process.stdout.write(secret + '\n');
174
- info(color.dim('Store this now — it is not retrievable later.'));
175
- }
176
- else {
177
- warn('The server did not return a secret.');
178
- }
179
- if (opts.save)
180
- info(color.dim(`Saved to profile ${client.profileName}`));
181
- });
182
- key
183
- .command('revoke <id>')
184
- .description('Revoke an API key')
185
- .option('-y, --yes', 'skip confirmation')
186
- .action(async (id, opts) => {
187
- const prompt = `Revoke API key ${id}? Anything still using it stops working immediately.`;
188
- if (!(await confirm(prompt, opts.yes)))
189
- return info('Aborted');
190
- const client = new WjiClient(globals());
191
- const result = await client.post(`/api-key/list/${id}/revoke`);
192
- render(result, () => success(`Revoked ${id}`));
193
- });
194
- key
195
- .command('rm <id>')
196
- .description('Delete an API key')
197
- .option('-y, --yes', 'skip confirmation')
198
- .action(async (id, opts) => {
199
- if (!(await confirm(`Delete API key ${id}?`, opts.yes)))
200
- return info('Aborted');
201
- const client = new WjiClient(globals());
202
- const result = await client.delete(`/api-key/list/${id}`);
203
- render(result, () => success(`Deleted ${id}`));
204
- });
205
- key
206
- .command('use <secret>')
207
- .description('Store an existing sk_ key in this profile')
208
- .action((secret) => {
209
- const g = globals();
210
- if (!secret.startsWith('sk_'))
211
- throw new Error('An API key must start with sk_');
212
- updateProfile(g.profile, { apiKey: secret });
213
- success('API key saved');
214
- });
215
- return auth;
216
- }
@@ -1,320 +0,0 @@
1
- import { Command } from 'commander';
2
- import { WjiClient } from '../core/client.js';
3
- import { confirm, readJson, readValue } from '../core/prompt.js';
4
- import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
5
- import { NPC_JOB_TYPES, npcLabel, parseJob, parsePositions, resolveNpc, today, updateJobs } from '../core/npc.js';
6
- const POSTS = '/backreel/post';
7
- const KINDS = ['showcase', 'crowdfunding'];
8
- const PROJECT_STATUSES = ['planning', 'pre_production', 'in_production', 'post_production', 'completed'];
9
- const CREDIT_KINDS = ['creative', 'crew', 'cast'];
10
- /** Backend LOGLINE_MAX (src/backreel/post/descriptionHtml.ts). */
11
- const LOGLINE_MAX = 200;
12
- /** BackreelVerificationKind — what `creators --role` filters on. */
13
- const VERIFICATION_KINDS = ['investor', 'production_company', 'brand', 'distributor', 'sales_agent'];
14
- /** Credit fields the create / update DTO accepts; the rest of a read-back credit is dropped. */
15
- const CREDIT_FIELDS = ['kind', 'name', 'role', 'user', 'photo', 'avatarUrl', 'link', 'source', 'npcId', 'order'];
16
- export function backreelCommand(globals) {
17
- const br = new Command('backreel')
18
- .aliases(['br'])
19
- .description('Manage your Backreel posts (works) and their credits, including NPC cast');
20
- br.command('list', { isDefault: true })
21
- .description('Your posts, drafts included; --all for everyone\'s published posts')
22
- .option('--all', 'search all published posts instead of yours')
23
- .option('-k, --keyword <text>', 'search text')
24
- .option('--page <n>', 'page number', '1')
25
- .option('--limit <n>', 'page size', '20')
26
- .action(async (opts) => {
27
- const client = new WjiClient(globals());
28
- const result = await client.get(`${POSTS}/${opts.all ? 'search' : 'search/my'}`, {
29
- keyword: opts.keyword,
30
- page: Number(opts.page),
31
- limit: Number(opts.limit),
32
- });
33
- render(result, (r) => {
34
- table(r?.data || [], [
35
- { header: 'id', value: (p) => p.id },
36
- { header: 'title', value: (p) => truncate(p.title, 28) },
37
- { header: 'kind', value: (p) => p.kind },
38
- { header: 'publish', value: (p) => p.publishStatus },
39
- { header: 'project', value: (p) => p.projectStatus },
40
- { header: 'credits', value: (p) => (p.credits || []).length },
41
- { header: 'updated', value: (p) => relativeTime(p.updatedAt) },
42
- ]);
43
- const p = r?.pagination;
44
- if (p)
45
- info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
46
- });
47
- });
48
- br.command('get <postId>')
49
- .description('Show a post and its credits')
50
- .action(async (id) => {
51
- const client = new WjiClient(globals());
52
- const post = await client.getFresh(`${POSTS}/list/${id}`);
53
- render(post, () => printPost(post));
54
- });
55
- postOptions(br.command('create <title>').description('Create a post (always a draft until `backreel publish`)')).action(async (title, opts) => {
56
- const client = new WjiClient(globals());
57
- // The create DTO validates kind and description although the entity has defaults.
58
- const body = { kind: 'showcase', description: '', ...(await postBody(opts)), title };
59
- const created = await client.post(`${POSTS}/create`, body);
60
- render(created, (p) => success(`Created draft "${p.title}" — ${p.id}`));
61
- });
62
- postOptions(br.command('set <postId>')
63
- .description('Update a post; only the fields you pass change')
64
- .option('--title <text>', 'title')).action(async (id, opts) => {
65
- const client = new WjiClient(globals());
66
- const body = await postBody(opts);
67
- if (opts.title)
68
- body.title = opts.title;
69
- if (!Object.keys(body).length)
70
- throw new Error('Nothing to change — pass at least one field flag');
71
- const updated = await client.put(`${POSTS}/list/${id}`, body);
72
- render(updated, () => success(`Updated "${updated.title}": ${Object.keys(body).join(', ')}`));
73
- });
74
- for (const action of ['publish', 'unpublish']) {
75
- br.command(`${action} <postId>`)
76
- .description(action === 'publish' ? 'Publish a post' : 'Take a post offline')
77
- .action(async (id) => {
78
- const client = new WjiClient(globals());
79
- const post = await client.post(`${POSTS}/list/${id}/${action}`);
80
- render(post, (p) => success(`"${p.title}" is now ${p.publishStatus}`));
81
- });
82
- }
83
- br.command('rm <postId>')
84
- .description('Delete a post')
85
- .option('-y, --yes', 'skip confirmation')
86
- .action(async (id, opts) => {
87
- const client = new WjiClient(globals());
88
- const post = await client.getFresh(`${POSTS}/list/${id}`);
89
- if (!(await confirm(`Delete post "${post.title}" (${id})?`, opts.yes)))
90
- return info('Aborted');
91
- const result = await client.delete(`${POSTS}/list/${id}`);
92
- render(result, () => success(`Deleted "${post.title}"`));
93
- });
94
- br.command('creators')
95
- .description('Browse Backreel creators: editor picks, then ranked by recent likes / favorites')
96
- .option('-k, --keyword <text>', 'match name or @handle')
97
- .option('--role <kind>', `only creators verified as: ${VERIFICATION_KINDS.join(' | ')}`)
98
- .option('--verified', 'only verified creators')
99
- .option('--no-featured', 'skip the editor-picked creators')
100
- .option('--page <n>', 'page number', '1')
101
- .option('--limit <n>', 'page size (max 48)', '20')
102
- .action(async (opts) => {
103
- const client = new WjiClient(globals());
104
- if (opts.role)
105
- oneOf(opts.role, VERIFICATION_KINDS, '--role');
106
- const result = await client.get('/backreel/creator/list', {
107
- keyword: opts.keyword,
108
- role: opts.role,
109
- verifiedOnly: opts.verified ? 'true' : undefined,
110
- withFeatured: opts.featured ? undefined : 'false',
111
- page: Number(opts.page),
112
- limit: Number(opts.limit),
113
- });
114
- render(result, (r) => {
115
- const columns = [
116
- { header: 'id', value: (c) => c.id },
117
- { header: 'name', value: (c) => truncate(c.name, 24) },
118
- { header: 'handle', value: (c) => (c.handle ? `@${c.handle}` : '') },
119
- { header: 'reels', value: (c) => c.reelCount },
120
- { header: 'followers', value: (c) => c.followerCount },
121
- { header: 'likes', value: (c) => c.likeCount },
122
- { header: 'verified', value: (c) => (c.verifiedKinds || []).join(', ') },
123
- ];
124
- if (r?.featured?.length) {
125
- info(color.bold('Featured'));
126
- table(r.featured, [...columns, { header: 'note', value: (c) => truncate(c.featuredNote, 30) }]);
127
- process.stdout.write('\n');
128
- info(color.bold('Hot'));
129
- }
130
- table(r?.data || [], columns);
131
- const p = r?.pagination;
132
- if (p) {
133
- const more = p.page * p.limit < p.total ? ` · more: --page ${p.page + 1}` : '';
134
- info(color.dim(`${p.total} ranked · page ${p.page}${more}`));
135
- }
136
- });
137
- });
138
- // ── credits ────────────────────────────────────────────────────────────
139
- const credit = br.command('credit').aliases(['credits']).description('Cast and crew of a post (max 30)');
140
- credit
141
- .command('list <postId>', { isDefault: true })
142
- .description('Numbered credits')
143
- .action(async (id) => {
144
- const client = new WjiClient(globals());
145
- const post = await client.getFresh(`${POSTS}/list/${id}`);
146
- render(post.credits || [], (credits) => printCredits(credits));
147
- });
148
- credit
149
- .command('add <postId>')
150
- .description('Add a credit; --npc casts one of your NPCs')
151
- .option('--name <name>', 'display name (defaults to the NPC or user name)')
152
- .requiredOption('--role <role>', 'job or character, e.g. 導演 or 飾 阿明')
153
- .option('--kind <kind>', CREDIT_KINDS.join(' | '), 'cast')
154
- .option('--npc <npc>', 'link an NPC City NPC (id, @handle or name)')
155
- .option('--user <userId>', 'link a WJI user (their name and avatar win)')
156
- .option('--link <url>', 'profile link')
157
- .option('--job <type>', `also add a work-history job to the NPC: ${NPC_JOB_TYPES.join(' | ')}`)
158
- .action(async (id, opts) => {
159
- if (!CREDIT_KINDS.includes(opts.kind))
160
- throw new Error(`--kind must be one of ${CREDIT_KINDS.join(', ')}`);
161
- if (opts.job && !opts.npc)
162
- throw new Error('--job needs --npc');
163
- if (opts.job && !NPC_JOB_TYPES.includes(opts.job)) {
164
- throw new Error(`--job must be one of ${NPC_JOB_TYPES.join(', ')}`);
165
- }
166
- const client = new WjiClient(globals());
167
- const npc = opts.npc ? await resolveNpc(client, opts.npc) : undefined;
168
- // Any public NPC can be credited, but only your own gets a job. The soul
169
- // route is owner-only, so it checks that before anything is written.
170
- if (npc && opts.job) {
171
- await client.get(`/npc-city/residents/${npc.id}/soul`).catch(() => {
172
- throw new Error(`${npcLabel(npc)} is not yours, so --job can't add to its work history (the credit alone works without --job)`);
173
- });
174
- }
175
- const name = opts.name || npc?.name;
176
- if (!name && !opts.user)
177
- throw new Error('Pass --name, --npc or --user');
178
- const entry = { kind: opts.kind, name: name || '-', role: opts.role };
179
- if (opts.user)
180
- entry.user = opts.user;
181
- if (opts.link)
182
- entry.link = opts.link;
183
- if (npc) {
184
- entry.npcId = npc.id;
185
- if (npc.image)
186
- entry.avatarUrl = npc.image;
187
- }
188
- const post = await updateCredits(client, id, (credits) => [...credits, entry]);
189
- // The job is written after the credit, dated today and titled after the post.
190
- let job;
191
- if (npc && opts.job) {
192
- job = parseJob({ type: opts.job, title: post.title, detail: opts.role, date: today() });
193
- await updateJobs(client, npc.id, (jobs) => [...jobs, job]);
194
- }
195
- render({ post: post.id, credits: post.credits, job: job ?? null }, () => {
196
- success(`Credited ${name || opts.user} as ${opts.role} on "${post.title}" (#${post.credits.length})`);
197
- if (job)
198
- success(`Added ${job.type} "${job.title}" to ${npcLabel(npc)}'s work history`);
199
- });
200
- });
201
- credit
202
- .command('rm <postId> <n...>')
203
- .description('Remove credits by number')
204
- .option('-y, --yes', 'skip confirmation')
205
- .action(async (id, ns, opts) => {
206
- const client = new WjiClient(globals());
207
- const post = await client.getFresh(`${POSTS}/list/${id}`);
208
- const credits = post.credits || [];
209
- const positions = parsePositions(ns, credits.length);
210
- const names = positions.map((i) => `#${i + 1} ${credits[i].name}`).join(', ');
211
- if (!(await confirm(`Remove ${names} from "${post.title}"?`, opts.yes)))
212
- return info('Aborted');
213
- const updated = await updateCredits(client, id, (current) => {
214
- if (JSON.stringify(current) !== JSON.stringify(credits.map(toCreditDto))) {
215
- throw new Error('The credits changed since they were read — list them again and retry');
216
- }
217
- return current.filter((_, i) => !positions.includes(i));
218
- });
219
- render(updated.credits, () => success(`Removed ${positions.length} credit(s); ${updated.credits.length} left`));
220
- });
221
- return br;
222
- }
223
- // ── helpers ──────────────────────────────────────────────────────────────
224
- function postOptions(cmd) {
225
- return cmd
226
- .option('--kind <kind>', KINDS.join(' | '))
227
- .option('--logline <text>', `one-line hook, max ${LOGLINE_MAX} chars; public even when the description is locked ("" clears it)`)
228
- .option('--category <category>', 'category')
229
- .option('--project-status <status>', PROJECT_STATUSES.join(' | '))
230
- .option('--tags <tags>', 'comma-separated tags (replaces the list)')
231
- .option('-d, --description <text>', 'description, plain text or HTML (@file / - supported)')
232
- .option('--cover <assetId>', 'landscape cover asset')
233
- .option('--cover-portrait <assetId>', 'portrait cover asset')
234
- .option('--trailer <assetId>', 'trailer video asset')
235
- .option('--body <json>', 'any other post fields as JSON (@file / - supported); flags win');
236
- }
237
- async function postBody(opts) {
238
- const body = { ...((await readJson(opts.body)) || {}) };
239
- if (opts.kind)
240
- body.kind = oneOf(opts.kind, KINDS, '--kind');
241
- if (opts.logline !== undefined) {
242
- // Same normalisation the server applies: plain text, one line.
243
- const logline = String(await readValue(opts.logline)).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
244
- if (logline.length > LOGLINE_MAX)
245
- throw new Error(`--logline is ${logline.length} chars; the limit is ${LOGLINE_MAX}`);
246
- body.logline = logline;
247
- }
248
- if (opts.category)
249
- body.category = opts.category;
250
- if (opts.projectStatus)
251
- body.projectStatus = oneOf(opts.projectStatus, PROJECT_STATUSES, '--project-status');
252
- if (opts.tags !== undefined)
253
- body.tags = String(opts.tags).split(',').map((t) => t.trim()).filter(Boolean);
254
- if (opts.description !== undefined)
255
- body.description = toHtml(await readValue(opts.description));
256
- if (opts.cover)
257
- body.cover = opts.cover;
258
- if (opts.coverPortrait)
259
- body.coverPortrait = opts.coverPortrait;
260
- if (opts.trailer)
261
- body.trailer = opts.trailer;
262
- return body;
263
- }
264
- /** Descriptions are stored as sanitized HTML; plain text becomes paragraphs. */
265
- function toHtml(text) {
266
- if (/<\/?(p|br|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|div|span|img|hr)\b[^>]*>/i.test(text))
267
- return text;
268
- const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
269
- return text
270
- .trim()
271
- .split(/\n{2,}/)
272
- .map((para) => `<p>${escape(para).replace(/\n/g, '<br>')}</p>`)
273
- .join('');
274
- }
275
- const toCreditDto = (c) => Object.fromEntries(CREDIT_FIELDS.filter((k) => c[k] !== undefined && c[k] !== null).map((k) => [k, c[k]]));
276
- /** Credits have no ids: read fresh, change, write the whole array back in order. */
277
- async function updateCredits(client, id, fn) {
278
- const post = await client.getFresh(`${POSTS}/list/${id}`);
279
- const next = fn((post.credits || []).map(toCreditDto)).map((c, i) => ({ ...c, order: i }));
280
- if (next.length > 30)
281
- throw new Error('A post can have at most 30 credits');
282
- return client.put(`${POSTS}/list/${id}`, { credits: next });
283
- }
284
- const assetLabel = (a) => (a ? a.url || a.id || a : undefined);
285
- function oneOf(value, allowed, flag) {
286
- if (!allowed.includes(value))
287
- throw new Error(`${flag} must be one of ${allowed.join(', ')}`);
288
- return value;
289
- }
290
- function printPost(p) {
291
- detail([
292
- ['id', p.id],
293
- ['title', p.title],
294
- ['kind', p.kind],
295
- ['publish', p.publishStatus],
296
- ['project', p.projectStatus],
297
- ['category', p.category],
298
- ['tags', (p.tags || []).join(', ')],
299
- ['logline', p.logline],
300
- ['description', truncate(p.descriptionText || p.description, 120)],
301
- ['cover', assetLabel(p.cover)],
302
- ['coverPortrait', assetLabel(p.coverPortrait)],
303
- ['trailer', p.trailer ? `${assetLabel(p.trailer)}${p.trailerDurationSec ? ` (${p.trailerDurationSec}s)` : ''}` : undefined],
304
- ['creator', p.creator?.name],
305
- ['likes', p.likeCount],
306
- ['updated', relativeTime(p.updatedAt)],
307
- ]);
308
- process.stdout.write('\n');
309
- printCredits(p.credits || []);
310
- }
311
- function printCredits(credits) {
312
- table(credits.map((c, i) => ({ ...c, n: i + 1 })), [
313
- { header: '#', value: (c) => c.n },
314
- { header: 'kind', value: (c) => c.kind },
315
- { header: 'name', value: (c) => truncate(c.name, 20) },
316
- { header: 'role', value: (c) => truncate(c.role, 24) },
317
- { header: 'source', value: (c) => c.source },
318
- { header: 'npc / user', value: (c) => c.npcId || c.user },
319
- ]);
320
- }