@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.
- package/README.md +23 -3
- package/dist/index.js +7162 -76
- package/package.json +6 -9
- package/skill/backreel/SKILL.md +171 -0
- package/skill/backreel/reference/crowdfunding.md +82 -0
- package/skill/backreel/reference/enquiries.md +80 -0
- package/skill/backreel/reference/episodes.md +104 -0
- package/skill/backreel/reference/images.md +76 -0
- package/skill/npc-character/SKILL.md +111 -0
- package/skill/npc-character/examples/nagi.character.md +117 -0
- package/skill/npc-character/examples/nagi.stage.md +34 -0
- package/skill/npc-character/examples/yuzu.character.md +110 -0
- package/skill/npc-character/examples/yuzu.stage.md +30 -0
- package/skill/npc-character/reference/character-craft.md +153 -0
- package/skill/npc-character/reference/document-format.md +151 -0
- package/skill/npc-character/reference/interview.md +44 -0
- package/skill/npc-character/reference/life.md +92 -0
- package/skill/npc-character/reference/runtime.md +95 -0
- package/skill/npc-character/reference/stage-craft.md +109 -0
- package/skill/npc-character/reference/visual-kit.md +81 -0
- package/skill/npc-character/reference/voice.md +101 -0
- package/skill/wji/SKILL.md +1 -1
- package/skill/wji/reference/commands.md +25 -5
- package/dist/commands/agent.js +0 -294
- package/dist/commands/asset.js +0 -241
- package/dist/commands/auth.js +0 -216
- package/dist/commands/backreel.js +0 -320
- package/dist/commands/canvas.js +0 -1706
- package/dist/commands/class.js +0 -395
- package/dist/commands/config.js +0 -113
- package/dist/commands/crew.js +0 -205
- package/dist/commands/fsnode.js +0 -288
- package/dist/commands/gen.js +0 -352
- package/dist/commands/model.js +0 -136
- package/dist/commands/npc.js +0 -413
- package/dist/commands/org.js +0 -85
- package/dist/commands/preset.js +0 -176
- package/dist/commands/profile.js +0 -129
- package/dist/commands/skill.js +0 -272
- package/dist/commands/workspace.js +0 -470
- package/dist/core/archive.js +0 -95
- package/dist/core/client.js +0 -252
- package/dist/core/config.js +0 -95
- package/dist/core/crew.js +0 -546
- package/dist/core/members.js +0 -54
- package/dist/core/npc.js +0 -108
- package/dist/core/output.js +0 -174
- package/dist/core/prompt.js +0 -118
- package/dist/core/zip.js +0 -87
package/dist/commands/auth.js
DELETED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
}
|