@wji.ai/cli 0.7.0 → 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 +4 -1
- package/dist/index.js +7162 -76
- package/package.json +6 -9
- 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-enquiry.js +0 -171
- package/dist/commands/backreel-episode.js +0 -243
- package/dist/commands/backreel-fund.js +0 -299
- package/dist/commands/backreel.js +0 -443
- 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 -356
- package/dist/commands/model.js +0 -136
- package/dist/commands/npc-album.js +0 -201
- package/dist/commands/npc-live.js +0 -316
- package/dist/commands/npc.js +0 -437
- 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/backreel.js +0 -23
- package/dist/core/client.js +0 -269
- package/dist/core/config.js +0 -95
- package/dist/core/crew.js +0 -546
- package/dist/core/image.js +0 -106
- 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/vod.js +0 -149
- package/dist/core/zip.js +0 -87
package/dist/commands/class.js
DELETED
|
@@ -1,395 +0,0 @@
|
|
|
1
|
-
import { mkdir, stat } from 'node:fs/promises';
|
|
2
|
-
import { createWriteStream } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { Readable } from 'node:stream';
|
|
5
|
-
import { pipeline } from 'node:stream/promises';
|
|
6
|
-
import { Command } from 'commander';
|
|
7
|
-
import { WjiClient } from '../core/client.js';
|
|
8
|
-
import { confirm } from '../core/prompt.js';
|
|
9
|
-
import { color, info, isJsonMode, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
|
|
10
|
-
import { loadMembers, mapLimit, memberLabel, resolveMember } from '../core/members.js';
|
|
11
|
-
import { COLOR_TAGS, canvasesFolders, copyNodes, copyPlacement, copySource, createCanvasFolder, describe, linkedState, listCanvases, loadCanvas, parseVisibility, tagText, updateCanvasFolder, } from './canvas.js';
|
|
12
|
-
const WORKERS = 4;
|
|
13
|
-
async function loadClass(c, wsId) {
|
|
14
|
-
const [{ workspace, members }, canvases] = await Promise.all([loadMembers(c, wsId), listCanvases(c, wsId)]);
|
|
15
|
-
const students = members.filter((m) => m.role !== 'owner');
|
|
16
|
-
const seats = students.map((member) => ({
|
|
17
|
-
member,
|
|
18
|
-
canvases: canvases.filter((cv) => cv.ownerUserId === member.userId),
|
|
19
|
-
}));
|
|
20
|
-
return { workspace, members, canvases, seats };
|
|
21
|
-
}
|
|
22
|
-
/** `--student` narrows the roster; otherwise every non-owner member. */
|
|
23
|
-
function pickSeats(seats, members, tokens) {
|
|
24
|
-
if (!tokens?.length)
|
|
25
|
-
return seats;
|
|
26
|
-
const wanted = new Set(tokens.map((t) => resolveMember(members, t).userId));
|
|
27
|
-
const picked = seats.filter((s) => wanted.has(s.member.userId));
|
|
28
|
-
const owners = members.filter((m) => m.role === 'owner' && wanted.has(m.userId));
|
|
29
|
-
if (owners.length)
|
|
30
|
-
warn(`${owners.map(memberLabel).join(', ')} is the teacher (workspace owner), not a student — skipped`);
|
|
31
|
-
return picked;
|
|
32
|
-
}
|
|
33
|
-
function seatCanvas(seat) {
|
|
34
|
-
if (seat.canvases.length > 1) {
|
|
35
|
-
warn(`${memberLabel(seat.member)} owns ${seat.canvases.length} canvases — using the most recently updated "${[...seat.canvases].sort(byUpdated)[0].name}"`);
|
|
36
|
-
}
|
|
37
|
-
return [...seat.canvases].sort(byUpdated)[0];
|
|
38
|
-
}
|
|
39
|
-
const byUpdated = (a, b) => Date.parse(b.updatedAt || '0') - Date.parse(a.updatedAt || '0');
|
|
40
|
-
/** Color ids a `--tag` value means on this canvas; a label the canvas lacks matches nothing (no error per student). */
|
|
41
|
-
function tagColors(canvas, tags) {
|
|
42
|
-
if (!tags?.length)
|
|
43
|
-
return undefined;
|
|
44
|
-
const colors = new Set();
|
|
45
|
-
for (const raw of tags) {
|
|
46
|
-
const v = raw.trim().toLowerCase();
|
|
47
|
-
for (const t of COLOR_TAGS) {
|
|
48
|
-
if (t.id === v || t.alias === v || canvas.tagNames[t.id]?.toLowerCase() === v)
|
|
49
|
-
colors.add(t.id);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return colors;
|
|
53
|
-
}
|
|
54
|
-
function parseSince(value) {
|
|
55
|
-
if (!value)
|
|
56
|
-
return undefined;
|
|
57
|
-
const m = /^(\d+)\s*(m|h|d)$/i.exec(value.trim());
|
|
58
|
-
if (m) {
|
|
59
|
-
const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
|
|
60
|
-
return Date.now() - Number(m[1]) * unit;
|
|
61
|
-
}
|
|
62
|
-
const t = Date.parse(value);
|
|
63
|
-
if (Number.isNaN(t))
|
|
64
|
-
throw new Error('--since takes 30m, 2h, 1d or a date');
|
|
65
|
-
return t;
|
|
66
|
-
}
|
|
67
|
-
function requireStudents(seats, filtered) {
|
|
68
|
-
if (seats.length)
|
|
69
|
-
return;
|
|
70
|
-
if (filtered?.length)
|
|
71
|
-
throw new Error('No student matches --student');
|
|
72
|
-
throw new Error('No students in this workspace yet. Invite them with `wji ws member invite <emails...>`.');
|
|
73
|
-
}
|
|
74
|
-
export function classCommand(globals) {
|
|
75
|
-
const cls = new Command('class')
|
|
76
|
-
.aliases(['classroom'])
|
|
77
|
-
.description('Teach with a workspace: one canvas per student, copy work to everyone, check progress and results');
|
|
78
|
-
const client = () => new WjiClient(globals());
|
|
79
|
-
const workspaceOf = async (c, explicit) => explicit || (await linkedState(c))?.workspaceId || c.requireWorkspace();
|
|
80
|
-
cls
|
|
81
|
-
.command('roster')
|
|
82
|
-
.description('Students (non-owner members) with their role and canvas')
|
|
83
|
-
.option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
|
|
84
|
-
.action(async (opts) => {
|
|
85
|
-
const c = client();
|
|
86
|
-
const wsId = await workspaceOf(c, opts.workspace);
|
|
87
|
-
const { workspace, members, seats } = await loadClass(c, wsId);
|
|
88
|
-
const teacher = members.filter((m) => m.role === 'owner').map(memberLabel).join(', ');
|
|
89
|
-
const rows = seats.map((s) => ({
|
|
90
|
-
userId: s.member.userId,
|
|
91
|
-
name: s.member.name,
|
|
92
|
-
email: s.member.email,
|
|
93
|
-
role: s.member.role,
|
|
94
|
-
canvases: s.canvases.map(({ folder, ...cv }) => cv),
|
|
95
|
-
}));
|
|
96
|
-
render({ workspace: { id: wsId, name: workspace.name }, teacher, students: rows }, () => {
|
|
97
|
-
info(color.dim(`${workspace.name} (${wsId}) · teacher ${teacher} · ${rows.length} student(s)`));
|
|
98
|
-
table(rows, [
|
|
99
|
-
{ header: 'name', value: (r) => r.name },
|
|
100
|
-
{ header: 'email', value: (r) => r.email },
|
|
101
|
-
{ header: 'role', value: (r) => (r.role === 'viewer' ? color.yellow('viewer') : r.role) },
|
|
102
|
-
{ header: 'canvas', value: (r) => (r.canvases.length ? r.canvases.map((cv) => cv.name).join(', ') : color.red('none')) },
|
|
103
|
-
{ header: 'visibility', value: (r) => r.canvases.map((cv) => (cv.visibility === 'owner' ? 'private' : 'shared')).join(', ') },
|
|
104
|
-
]);
|
|
105
|
-
if (rows.some((r) => r.role === 'viewer'))
|
|
106
|
-
info(color.dim('Viewers cannot generate; make them editors with `wji ws member role <name> editor`.'));
|
|
107
|
-
if (rows.some((r) => !r.canvases.length))
|
|
108
|
-
info(color.dim('Give everyone a canvas with `wji class canvases --create`.'));
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
cls
|
|
112
|
-
.command('canvases')
|
|
113
|
-
.description('Each student\'s own canvas; --create makes the missing ones (named after the student, owned by them)')
|
|
114
|
-
.option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
|
|
115
|
-
.option('--create', 'create a canvas for every student who has none')
|
|
116
|
-
.option('--visibility <v>', 'owner (private: the student and the teacher) | members (the whole class sees it)', 'owner')
|
|
117
|
-
.option('--update', 'also apply --visibility to the students\' existing canvases')
|
|
118
|
-
.option('--name <template>', 'canvas name; {name} and {email} are filled in', '{name}')
|
|
119
|
-
.option('--student <members...>', 'only these students (name, email or user id)')
|
|
120
|
-
.option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
|
|
121
|
-
.action(async (opts) => {
|
|
122
|
-
const c = client();
|
|
123
|
-
const wsId = await workspaceOf(c, opts.workspace);
|
|
124
|
-
const visibility = parseVisibility(opts.visibility);
|
|
125
|
-
const { members, seats: all } = await loadClass(c, wsId);
|
|
126
|
-
const seats = pickSeats(all, members, opts.student);
|
|
127
|
-
requireStudents(seats, opts.student);
|
|
128
|
-
const created = [];
|
|
129
|
-
const updated = [];
|
|
130
|
-
if (opts.create && seats.some((s) => !s.canvases.length)) {
|
|
131
|
-
const folders = await canvasesFolders(c, wsId);
|
|
132
|
-
const target = opts.app ? folders.find((f) => f.appId === opts.app) : folders[0];
|
|
133
|
-
if (!target)
|
|
134
|
-
throw new Error(`Workspace ${wsId} has no InfiniteCanvas app${opts.app ? ` ${opts.app}` : ''}. Add one with \`wji ws app add canvas\`.`);
|
|
135
|
-
for (const seat of seats.filter((s) => !s.canvases.length)) {
|
|
136
|
-
const m = seat.member;
|
|
137
|
-
const name = opts.name.replace(/\{name\}/g, m.name || m.email || m.userId).replace(/\{email\}/g, m.email);
|
|
138
|
-
const raw = await createCanvasFolder(c, target.folderId, name, { ownerUserId: m.userId, visibility });
|
|
139
|
-
seat.canvases.push({ id: raw.id, name: raw.name, appId: target.appId, ownerUserId: m.userId, visibility, updatedAt: raw.updatedAt, folder: raw });
|
|
140
|
-
created.push({ userId: m.userId, canvasId: raw.id, name: raw.name });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (opts.update) {
|
|
144
|
-
for (const seat of seats) {
|
|
145
|
-
for (const cv of seat.canvases.filter((x) => x.visibility !== visibility && !created.some((k) => k.canvasId === x.id))) {
|
|
146
|
-
await updateCanvasFolder(c, cv.id, (env) => {
|
|
147
|
-
env.metadata.visibility = visibility;
|
|
148
|
-
});
|
|
149
|
-
cv.visibility = visibility;
|
|
150
|
-
updated.push(cv.id);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const rows = seats.map((s) => ({ student: memberLabel(s.member), userId: s.member.userId, canvases: s.canvases.map(({ folder, ...cv }) => cv) }));
|
|
155
|
-
render({ created, updated, students: rows }, () => {
|
|
156
|
-
if (created.length)
|
|
157
|
-
success(`Created ${created.length} canvas(es)`);
|
|
158
|
-
if (updated.length)
|
|
159
|
-
success(`Set ${updated.length} canvas(es) to ${visibility === 'owner' ? 'private' : 'shared'}`);
|
|
160
|
-
table(rows, [
|
|
161
|
-
{ header: 'student', value: (r) => r.student },
|
|
162
|
-
{ header: 'canvas', value: (r) => (r.canvases.length ? r.canvases.map((cv) => cv.name).join(', ') : color.red('none')) },
|
|
163
|
-
{ header: 'id', value: (r) => color.dim(r.canvases.map((cv) => cv.id).join(', ')) },
|
|
164
|
-
{ header: 'visibility', value: (r) => r.canvases.map((cv) => (cv.visibility === 'owner' ? 'private' : 'shared')).join(', ') },
|
|
165
|
-
]);
|
|
166
|
-
if (!opts.create && rows.some((r) => !r.canvases.length))
|
|
167
|
-
info(color.dim('Pass --create to give the others a canvas.'));
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
cls
|
|
171
|
-
.command('copy [nodeIds...]')
|
|
172
|
-
.description('Copy nodes into every student\'s canvas (default: the nodes selected in the linked browser canvas). ' +
|
|
173
|
-
'Connections between them come along; finished media is shared, generate nodes keep their prompt and settings.')
|
|
174
|
-
.option('-c, --canvas <id>', 'source canvas (defaults to the linked canvas)')
|
|
175
|
-
.option('--selected', 'add the linked browser selection to the node ids given')
|
|
176
|
-
.option('--student <members...>', 'only these students (name, email or user id)')
|
|
177
|
-
.option('--at <x,y>', 'top-left of the copied group in each canvas; default is right of what is already there')
|
|
178
|
-
.option('--same-position', 'keep the original coordinates')
|
|
179
|
-
.option('-y, --yes', 'skip confirmation')
|
|
180
|
-
.action(async (ids, opts) => {
|
|
181
|
-
const c = client();
|
|
182
|
-
const { source, nodes } = await copySource(c, ids, opts);
|
|
183
|
-
const { members, seats: all } = await loadClass(c, source.workspaceId);
|
|
184
|
-
const seats = pickSeats(all, members, opts.student);
|
|
185
|
-
requireStudents(seats, opts.student);
|
|
186
|
-
const missing = seats.filter((s) => !s.canvases.length);
|
|
187
|
-
if (missing.length)
|
|
188
|
-
warn(`No canvas yet, skipped: ${missing.map((s) => memberLabel(s.member)).join(', ')} (create them with \`wji class canvases --create\`)`);
|
|
189
|
-
const targets = seats
|
|
190
|
-
.filter((s) => s.canvases.length)
|
|
191
|
-
.map((s) => ({ seat: s, canvas: seatCanvas(s) }))
|
|
192
|
-
.filter((t) => t.canvas.id !== source.id);
|
|
193
|
-
if (!targets.length)
|
|
194
|
-
throw new Error('No student canvas to copy into');
|
|
195
|
-
const list = nodes.map((n) => `${n.kind} "${n.name}"`).join(', ');
|
|
196
|
-
const question = `Copy ${nodes.length} node(s) (${truncate(list, 120)}) from "${source.name}" into ${targets.length} student canvas(es)?`;
|
|
197
|
-
if (!(await confirm(question, opts.yes)))
|
|
198
|
-
return info('Aborted');
|
|
199
|
-
const placement = copyPlacement(opts);
|
|
200
|
-
const results = await mapLimit(targets, WORKERS, async ({ seat, canvas }) => {
|
|
201
|
-
try {
|
|
202
|
-
const target = await loadCanvas(c, canvas.id);
|
|
203
|
-
const r = await copyNodes(c, source, nodes, target, placement);
|
|
204
|
-
if (!isJsonMode())
|
|
205
|
-
success(`${memberLabel(seat.member)}: ${r.nodes.length} node(s), ${r.edges} connection(s) → ${canvas.name}`);
|
|
206
|
-
return { student: memberLabel(seat.member), userId: seat.member.userId, ok: true, ...r };
|
|
207
|
-
}
|
|
208
|
-
catch (err) {
|
|
209
|
-
if (!isJsonMode())
|
|
210
|
-
warn(`${memberLabel(seat.member)}: ${err.message}`);
|
|
211
|
-
return { student: memberLabel(seat.member), userId: seat.member.userId, ok: false, canvasId: canvas.id, error: err.message };
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
const failed = results.filter((r) => !r.ok);
|
|
215
|
-
render(results, () => {
|
|
216
|
-
const skipped = results.find((r) => r.ok)?.skippedEdges;
|
|
217
|
-
if (skipped)
|
|
218
|
-
info(color.dim(`${skipped} connection(s) from nodes outside the copy were left out`));
|
|
219
|
-
if (failed.length)
|
|
220
|
-
warn(`${failed.length} failed — rerun with --student ${failed.map((r) => JSON.stringify(r.student)).join(' ')}`);
|
|
221
|
-
else
|
|
222
|
-
success(`Copied into ${results.length} canvas(es)`);
|
|
223
|
-
});
|
|
224
|
-
if (failed.length)
|
|
225
|
-
process.exitCode = 1;
|
|
226
|
-
});
|
|
227
|
-
cls
|
|
228
|
-
.command('status')
|
|
229
|
-
.description('Progress per student: generate nodes done / running / failed / not started, generations and credits')
|
|
230
|
-
.option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
|
|
231
|
-
.option('--tag <tags...>', 'only count nodes with these color tags (label like HW1, or a color)')
|
|
232
|
-
.option('-k, --kind <kind>', 'only count this node kind (image | video | audio)')
|
|
233
|
-
.option('--student <members...>', 'only these students')
|
|
234
|
-
.option('-s, --sort <by>', 'name | recent (last generation first) | progress (fewest done first)', 'name')
|
|
235
|
-
.action(async (opts) => {
|
|
236
|
-
const c = client();
|
|
237
|
-
const wsId = await workspaceOf(c, opts.workspace);
|
|
238
|
-
const { members, seats: all } = await loadClass(c, wsId);
|
|
239
|
-
const seats = pickSeats(all, members, opts.student);
|
|
240
|
-
requireStudents(seats, opts.student);
|
|
241
|
-
const usage = await c.get(`/workspace/list/${wsId}/quota-usage`).catch(() => null);
|
|
242
|
-
const credits = new Map((usage?.byUserTotal || []).map((u) => [String(u.userId), Number(u.amount) || 0]));
|
|
243
|
-
const rows = await mapLimit(seats, WORKERS, async (seat) => {
|
|
244
|
-
const base = { student: memberLabel(seat.member), userId: seat.member.userId, email: seat.member.email, credits: credits.get(seat.member.userId) ?? 0 };
|
|
245
|
-
if (!seat.canvases.length)
|
|
246
|
-
return { ...base, canvas: null, nodes: 0, done: 0, running: 0, failed: 0, todo: 0, generations: 0, lastGeneratedAt: null };
|
|
247
|
-
const cvRow = seatCanvas(seat);
|
|
248
|
-
const cv = await loadCanvas(c, cvRow.id);
|
|
249
|
-
const colors = tagColors(cv, opts.tag);
|
|
250
|
-
const nodes = cv.nodes
|
|
251
|
-
.filter((n) => !opts.kind || n.kind === opts.kind)
|
|
252
|
-
.filter((n) => !colors || colors.has(n.content.colorTag?.colorId))
|
|
253
|
-
.map((n) => describe(cv, n));
|
|
254
|
-
const gen = nodes.filter((n) => n.mode === 'generate');
|
|
255
|
-
const history = await c.getFresh('/ai/assets/history/search', {
|
|
256
|
-
workspace: wsId,
|
|
257
|
-
scope: 'canvas',
|
|
258
|
-
scopeId: cv.id,
|
|
259
|
-
page: 1,
|
|
260
|
-
limit: 1,
|
|
261
|
-
sort: { createdAt: 'desc' },
|
|
262
|
-
});
|
|
263
|
-
return {
|
|
264
|
-
...base,
|
|
265
|
-
canvas: { id: cv.id, name: cv.name },
|
|
266
|
-
nodes: nodes.length,
|
|
267
|
-
done: gen.filter((n) => n.status === 'done').length,
|
|
268
|
-
running: gen.filter((n) => n.status === 'generating' || n.status === 'pending').length,
|
|
269
|
-
failed: gen.filter((n) => n.status === 'failed').length,
|
|
270
|
-
todo: gen.filter((n) => !n.status).length,
|
|
271
|
-
generations: Number(history?.pagination?.total ?? history?.data?.length ?? 0),
|
|
272
|
-
lastGeneratedAt: history?.data?.[0]?.createdAt || null,
|
|
273
|
-
};
|
|
274
|
-
});
|
|
275
|
-
const sorters = {
|
|
276
|
-
name: (a, b) => a.student.localeCompare(b.student),
|
|
277
|
-
recent: (a, b) => Date.parse(b.lastGeneratedAt || '0') - Date.parse(a.lastGeneratedAt || '0'),
|
|
278
|
-
progress: (a, b) => a.done - b.done || a.student.localeCompare(b.student),
|
|
279
|
-
};
|
|
280
|
-
if (!sorters[opts.sort])
|
|
281
|
-
throw new Error('--sort must be name, recent or progress');
|
|
282
|
-
rows.sort(sorters[opts.sort]);
|
|
283
|
-
render(rows, () => {
|
|
284
|
-
if (opts.tag)
|
|
285
|
-
info(color.dim(`Counting nodes tagged ${opts.tag.join(', ')}`));
|
|
286
|
-
table(rows, [
|
|
287
|
-
{ header: 'student', value: (r) => r.student },
|
|
288
|
-
{ header: 'canvas', value: (r) => (r.canvas ? truncate(r.canvas.name, 20) : color.red('none')) },
|
|
289
|
-
{ header: 'nodes', value: (r) => String(r.nodes) },
|
|
290
|
-
{ header: 'done', value: (r) => (r.done ? color.green(String(r.done)) : '0') },
|
|
291
|
-
{ header: 'running', value: (r) => (r.running ? color.yellow(String(r.running)) : '') },
|
|
292
|
-
{ header: 'failed', value: (r) => (r.failed ? color.red(String(r.failed)) : '') },
|
|
293
|
-
{ header: 'not run', value: (r) => (r.todo ? String(r.todo) : '') },
|
|
294
|
-
{ header: 'gens', value: (r) => String(r.generations) },
|
|
295
|
-
{ header: 'last gen', value: (r) => (r.lastGeneratedAt ? relativeTime(r.lastGeneratedAt) : '') },
|
|
296
|
-
{ header: 'credits', value: (r) => (r.credits ? String(Math.round(r.credits * 10) / 10) : '') },
|
|
297
|
-
]);
|
|
298
|
-
if (!usage)
|
|
299
|
-
info(color.dim('Credits hidden: this account cannot read the workspace credit usage.'));
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
cls
|
|
303
|
-
.command('results')
|
|
304
|
-
.description('Finished generations across student canvases, newest first; --download saves them into one folder per student')
|
|
305
|
-
.option('-w, --workspace <id>', 'class workspace (defaults to the linked one, then the profile default)')
|
|
306
|
-
.option('--student <members...>', 'only these students')
|
|
307
|
-
.option('--tag <tags...>', 'only nodes with these color tags (label like HW1, or a color)')
|
|
308
|
-
.option('-k, --kind <kind>', 'image | video | audio')
|
|
309
|
-
.option('-n, --name <regex>', 'only nodes whose name matches (case-insensitive)')
|
|
310
|
-
.option('--since <when>', 'finished after: 30m, 2h, 1d or a date')
|
|
311
|
-
.option('--all', 'include display nodes (uploaded or copied media), not only generate nodes')
|
|
312
|
-
.option('-s, --sort <by>', 'recent | name (student, then newest)', 'recent')
|
|
313
|
-
.option('-l, --limit <n>', 'at most this many rows (0 = all)', '0')
|
|
314
|
-
.option('-d, --download <dir>', 'save every listed file to <dir>/<student>/')
|
|
315
|
-
.action(async (opts) => {
|
|
316
|
-
const c = client();
|
|
317
|
-
const wsId = await workspaceOf(c, opts.workspace);
|
|
318
|
-
const since = parseSince(opts.since);
|
|
319
|
-
const nameRe = opts.name ? new RegExp(opts.name, 'i') : undefined;
|
|
320
|
-
const { members, seats: all } = await loadClass(c, wsId);
|
|
321
|
-
const seats = pickSeats(all, members, opts.student).filter((s) => s.canvases.length);
|
|
322
|
-
requireStudents(seats, opts.student);
|
|
323
|
-
const perSeat = await mapLimit(seats, WORKERS, async (seat) => {
|
|
324
|
-
const cv = await loadCanvas(c, seatCanvas(seat).id);
|
|
325
|
-
const colors = tagColors(cv, opts.tag);
|
|
326
|
-
return cv.nodes
|
|
327
|
-
.filter((n) => ['image', 'video', 'audio'].includes(n.kind))
|
|
328
|
-
.filter((n) => !opts.kind || n.kind === opts.kind)
|
|
329
|
-
.filter((n) => !colors || colors.has(n.content.colorTag?.colorId))
|
|
330
|
-
.filter((n) => !nameRe || nameRe.test(n.name))
|
|
331
|
-
.map((n) => ({ n: describe(cv, n), asset: n.file ? cv.assets.get(n.file) : undefined }))
|
|
332
|
-
.filter(({ n }) => !!n.url && n.status !== 'generating' && (opts.all || n.mode === 'generate'))
|
|
333
|
-
.map(({ n, asset }) => ({
|
|
334
|
-
student: memberLabel(seat.member),
|
|
335
|
-
userId: seat.member.userId,
|
|
336
|
-
canvasId: cv.id,
|
|
337
|
-
canvasName: cv.name,
|
|
338
|
-
nodeId: n.id,
|
|
339
|
-
kind: n.kind,
|
|
340
|
-
name: n.name,
|
|
341
|
-
tag: n.tag,
|
|
342
|
-
model: n.generate?.model,
|
|
343
|
-
prompt: n.generate?.prompt,
|
|
344
|
-
assetId: n.assetId,
|
|
345
|
-
url: n.url,
|
|
346
|
-
finishedAt: (asset?.updatedAt || asset?.createdAt || null),
|
|
347
|
-
}))
|
|
348
|
-
.filter((r) => !since || Date.parse(r.finishedAt || '0') >= since);
|
|
349
|
-
});
|
|
350
|
-
let rows = perSeat.flat();
|
|
351
|
-
const newest = (a, b) => Date.parse(b.finishedAt || '0') - Date.parse(a.finishedAt || '0');
|
|
352
|
-
rows.sort(opts.sort === 'name' ? (a, b) => a.student.localeCompare(b.student) || newest(a, b) : newest);
|
|
353
|
-
const limit = Number(opts.limit) || 0;
|
|
354
|
-
if (limit > 0)
|
|
355
|
-
rows = rows.slice(0, limit);
|
|
356
|
-
if (opts.download) {
|
|
357
|
-
const isDir = await stat(opts.download).then((s) => s.isDirectory()).catch(() => false);
|
|
358
|
-
if (!isDir)
|
|
359
|
-
await mkdir(opts.download, { recursive: true });
|
|
360
|
-
await mapLimit(rows, WORKERS, async (r) => {
|
|
361
|
-
const dir = join(opts.download, safeName(r.student));
|
|
362
|
-
await mkdir(dir, { recursive: true });
|
|
363
|
-
const res = await fetch(r.url);
|
|
364
|
-
if (!res.ok || !res.body)
|
|
365
|
-
return warn(`${r.student} ${r.name}: download failed (${res.status})`);
|
|
366
|
-
const file = join(dir, `${safeName(r.name || r.kind)}-${r.nodeId.slice(-6)}${extensionOf(r.url)}`);
|
|
367
|
-
await pipeline(Readable.fromWeb(res.body), createWriteStream(file));
|
|
368
|
-
r.path = file;
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
render(rows, () => {
|
|
372
|
-
if (!rows.length)
|
|
373
|
-
return info('No finished results match');
|
|
374
|
-
table(rows, [
|
|
375
|
-
{ header: 'student', value: (r) => r.student },
|
|
376
|
-
{ header: 'kind', value: (r) => r.kind },
|
|
377
|
-
{ header: 'name', value: (r) => truncate(r.name, 18) },
|
|
378
|
-
{ header: 'tag', value: (r) => truncate(tagText(r.tag), 12) },
|
|
379
|
-
{ header: 'when', value: (r) => relativeTime(r.finishedAt) },
|
|
380
|
-
{ header: 'model', value: (r) => truncate(r.model || '', 18) },
|
|
381
|
-
{ header: opts.download ? 'saved' : 'url', value: (r) => truncate(r.path || r.url, 60) },
|
|
382
|
-
]);
|
|
383
|
-
if (opts.download)
|
|
384
|
-
success(`Saved ${rows.filter((r) => r.path).length} file(s) under ${opts.download}`);
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
return cls;
|
|
388
|
-
}
|
|
389
|
-
function safeName(value) {
|
|
390
|
-
return value.replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || 'untitled';
|
|
391
|
-
}
|
|
392
|
-
function extensionOf(url) {
|
|
393
|
-
const match = /\.([a-z0-9]{2,5})(?:\?|$)/i.exec(url);
|
|
394
|
-
return match ? `.${match[1]}` : '';
|
|
395
|
-
}
|
package/dist/commands/config.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import { WjiClient } from '../core/client.js';
|
|
3
|
-
import { DEFAULT_ENDPOINT, configPath, getProfile, readConfig, updateProfile, writeConfig, } from '../core/config.js';
|
|
4
|
-
import { confirm } from '../core/prompt.js';
|
|
5
|
-
import { color, detail, info, render, success, table } from '../core/output.js';
|
|
6
|
-
const REDACTED_KEYS = new Set(['accessToken', 'refreshToken', 'apiKey']);
|
|
7
|
-
function redact(profile) {
|
|
8
|
-
const out = {};
|
|
9
|
-
for (const [k, v] of Object.entries(profile)) {
|
|
10
|
-
out[k] = REDACTED_KEYS.has(k) && typeof v === 'string' ? `${v.slice(0, 8)}…` : v;
|
|
11
|
-
}
|
|
12
|
-
return out;
|
|
13
|
-
}
|
|
14
|
-
export function configCommand(globals) {
|
|
15
|
-
const config = new Command('config').description('Manage CLI profiles and defaults');
|
|
16
|
-
config
|
|
17
|
-
.command('show', { isDefault: true })
|
|
18
|
-
.description('Show the active profile (secrets redacted)')
|
|
19
|
-
.action(() => {
|
|
20
|
-
const g = globals();
|
|
21
|
-
const { name, profile } = getProfile(g.profile);
|
|
22
|
-
const view = redact(profile);
|
|
23
|
-
// The endpoint is resolved at request time from flag > env > profile >
|
|
24
|
-
// built-in default, so show what will actually be used, not just what is
|
|
25
|
-
// stored — an unset profile would otherwise print nothing here.
|
|
26
|
-
const client = new WjiClient(g);
|
|
27
|
-
const effective = client.endpoint + (profile.endpoint ? '' : ` ${color.dim('(default)')}`);
|
|
28
|
-
render({ profile: name, path: configPath(), endpoint: client.endpoint, ...view }, () => {
|
|
29
|
-
detail([
|
|
30
|
-
['profile', name],
|
|
31
|
-
['file', configPath()],
|
|
32
|
-
['endpoint', effective],
|
|
33
|
-
...Object.entries(view).filter(([k]) => k !== 'endpoint'),
|
|
34
|
-
['org', client.orgId || 'personal'],
|
|
35
|
-
]);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
config
|
|
39
|
-
.command('set <key> <value>')
|
|
40
|
-
.description('Set a profile field (endpoint, orgId, workspace, locale, apiKey)')
|
|
41
|
-
.action((key, value) => {
|
|
42
|
-
const allowed = ['endpoint', 'orgId', 'workspace', 'locale', 'apiKey', 'email', 'name'];
|
|
43
|
-
if (!allowed.includes(key)) {
|
|
44
|
-
throw new Error(`Unknown key "${key}". Allowed: ${allowed.join(', ')}`);
|
|
45
|
-
}
|
|
46
|
-
updateProfile(globals().profile, { [key]: value === 'null' ? null : value });
|
|
47
|
-
success(`${key} = ${value}`);
|
|
48
|
-
});
|
|
49
|
-
config
|
|
50
|
-
.command('unset <key>')
|
|
51
|
-
.description('Remove a profile field')
|
|
52
|
-
.action((key) => {
|
|
53
|
-
updateProfile(globals().profile, { [key]: undefined });
|
|
54
|
-
success(`Cleared ${key}`);
|
|
55
|
-
});
|
|
56
|
-
config
|
|
57
|
-
.command('profiles')
|
|
58
|
-
.description('List all profiles')
|
|
59
|
-
.action(() => {
|
|
60
|
-
const file = readConfig();
|
|
61
|
-
// Spread first: a profile's own `name` field is the account display name,
|
|
62
|
-
// which must not shadow the profile key.
|
|
63
|
-
const rows = Object.entries(file.profiles).map(([key, p]) => ({ ...p, key }));
|
|
64
|
-
render({ current: file.current, profiles: rows.map((r) => redact(r)) }, () => table(rows, [
|
|
65
|
-
{ header: '', value: (r) => (r.key === file.current ? color.green('*') : ' ') },
|
|
66
|
-
{ header: 'profile', value: (r) => r.key },
|
|
67
|
-
{ header: 'endpoint', value: (r) => r.endpoint || `${DEFAULT_ENDPOINT} (default)` },
|
|
68
|
-
{ header: 'account', value: (r) => r.email },
|
|
69
|
-
{ header: 'org', value: (r) => r.orgId || 'personal' },
|
|
70
|
-
]));
|
|
71
|
-
});
|
|
72
|
-
config
|
|
73
|
-
.command('use <profile>')
|
|
74
|
-
.description('Switch the active profile (creates it when new)')
|
|
75
|
-
.action((name) => {
|
|
76
|
-
const file = readConfig();
|
|
77
|
-
if (!file.profiles[name])
|
|
78
|
-
file.profiles[name] = {};
|
|
79
|
-
file.current = name;
|
|
80
|
-
writeConfig(file);
|
|
81
|
-
success(`Using profile ${name}`);
|
|
82
|
-
});
|
|
83
|
-
config
|
|
84
|
-
.command('rm <profile>')
|
|
85
|
-
.description('Delete a profile')
|
|
86
|
-
.option('-y, --yes', 'skip confirmation')
|
|
87
|
-
.action(async (name, opts) => {
|
|
88
|
-
const file = readConfig();
|
|
89
|
-
if (!file.profiles[name])
|
|
90
|
-
throw new Error(`No such profile: ${name}`);
|
|
91
|
-
const p = file.profiles[name];
|
|
92
|
-
// A profile is often the only copy of an sk_ secret — say so before it goes.
|
|
93
|
-
const holds = [p.apiKey && 'API key', p.refreshToken && 'login'].filter(Boolean).join(' and ');
|
|
94
|
-
const prompt = `Delete profile "${name}"${holds ? ` and the ${holds} stored in it` : ''}?`;
|
|
95
|
-
if (!(await confirm(prompt, opts.yes)))
|
|
96
|
-
return info('Aborted');
|
|
97
|
-
delete file.profiles[name];
|
|
98
|
-
if (file.current === name)
|
|
99
|
-
file.current = Object.keys(file.profiles)[0] || 'default';
|
|
100
|
-
if (!file.profiles[file.current])
|
|
101
|
-
file.profiles[file.current] = {};
|
|
102
|
-
writeConfig(file);
|
|
103
|
-
success(`Deleted profile ${name}`);
|
|
104
|
-
info(color.dim(`Active profile is now ${file.current}`));
|
|
105
|
-
});
|
|
106
|
-
config
|
|
107
|
-
.command('path')
|
|
108
|
-
.description('Print the config file path')
|
|
109
|
-
.action(() => {
|
|
110
|
-
process.stdout.write(configPath() + '\n');
|
|
111
|
-
});
|
|
112
|
-
return config;
|
|
113
|
-
}
|