@wji.ai/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/README.md +242 -0
- package/dist/commands/agent.js +274 -0
- package/dist/commands/asset.js +241 -0
- package/dist/commands/auth.js +216 -0
- package/dist/commands/canvas.js +915 -0
- package/dist/commands/config.js +113 -0
- package/dist/commands/fsnode.js +252 -0
- package/dist/commands/gen.js +366 -0
- package/dist/commands/model.js +134 -0
- package/dist/commands/org.js +85 -0
- package/dist/commands/preset.js +176 -0
- package/dist/commands/profile.js +129 -0
- package/dist/commands/skill.js +272 -0
- package/dist/commands/workspace.js +305 -0
- package/dist/core/client.js +252 -0
- package/dist/core/config.js +95 -0
- package/dist/core/output.js +143 -0
- package/dist/core/prompt.js +118 -0
- package/dist/core/zip.js +87 -0
- package/dist/index.js +94 -0
- package/package.json +49 -0
- package/skill/wji/SKILL.md +398 -0
- package/skill/wji/reference/canvas-recipes.md +179 -0
- package/skill/wji/reference/commands.md +381 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { WjiClient } from '../core/client.js';
|
|
3
|
+
import { updateProfile } from '../core/config.js';
|
|
4
|
+
import { confirm, parseKeyValues } from '../core/prompt.js';
|
|
5
|
+
import { color, detail, info, relativeTime, render, success, table, truncate } from '../core/output.js';
|
|
6
|
+
/** App tokens accepted by `--app`; mirrors WorkspaceApplication on the backend. */
|
|
7
|
+
const APPS = {
|
|
8
|
+
dataset: 'Dataset',
|
|
9
|
+
datasetimagegeneration: 'DatasetImageGeneration',
|
|
10
|
+
infinitecanvas: 'InfiniteCanvas',
|
|
11
|
+
canvas: 'InfiniteCanvas',
|
|
12
|
+
document: 'Document',
|
|
13
|
+
draw: 'Draw',
|
|
14
|
+
files: 'Files',
|
|
15
|
+
playscript: 'PlayScript',
|
|
16
|
+
batch: 'Batch',
|
|
17
|
+
digitalhost: 'DigitalHost',
|
|
18
|
+
};
|
|
19
|
+
const APP_TYPES = {
|
|
20
|
+
Dataset: 'DATASET',
|
|
21
|
+
DatasetImageGeneration: 'DATASET_IMAGE_GENERATION',
|
|
22
|
+
InfiniteCanvas: 'INFINITE_CANVAS',
|
|
23
|
+
Document: 'DOCUMENT',
|
|
24
|
+
Draw: 'DRAW',
|
|
25
|
+
Files: 'FILES',
|
|
26
|
+
PlayScript: 'PLAYSCRIPT',
|
|
27
|
+
Batch: 'BATCH',
|
|
28
|
+
DigitalHost: 'DIGITAL_HOST',
|
|
29
|
+
};
|
|
30
|
+
function resolveApp(token) {
|
|
31
|
+
const app = APPS[token.toLowerCase().replace(/[-_\s]/g, '')];
|
|
32
|
+
if (!app) {
|
|
33
|
+
throw new Error(`Unknown app "${token}". Known: ${Object.keys(APPS).sort().join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
return { app, type: APP_TYPES[app] };
|
|
36
|
+
}
|
|
37
|
+
export function workspaceCommand(globals) {
|
|
38
|
+
const ws = new Command('ws')
|
|
39
|
+
.aliases(['workspace'])
|
|
40
|
+
.description('Manage workspaces');
|
|
41
|
+
ws.command('list', { isDefault: true })
|
|
42
|
+
.description('List workspaces you can access')
|
|
43
|
+
.option('-k, --keyword <text>', 'match name or description')
|
|
44
|
+
.option('-s, --scope <scope>', 'all | me | shared | favorite', 'all')
|
|
45
|
+
.option('--page <n>', 'page number', '1')
|
|
46
|
+
.option('--limit <n>', 'page size (max 100; -1 returns at most 500)', '20')
|
|
47
|
+
.action(async (opts) => {
|
|
48
|
+
const client = new WjiClient(globals());
|
|
49
|
+
const result = await client.get('/workspace/search', {
|
|
50
|
+
keyword: opts.keyword,
|
|
51
|
+
scope: opts.scope,
|
|
52
|
+
page: Number(opts.page),
|
|
53
|
+
limit: Number(opts.limit),
|
|
54
|
+
});
|
|
55
|
+
const current = client.workspace();
|
|
56
|
+
render(result, (r) => {
|
|
57
|
+
table(r.data || [], [
|
|
58
|
+
{ header: '', value: (w) => (w.id === current ? color.green('*') : ' ') },
|
|
59
|
+
{ header: 'id', value: (w) => w.id },
|
|
60
|
+
{ header: 'name', value: (w) => truncate(w.name, 32) },
|
|
61
|
+
{ header: 'type', value: (w) => w.workspaceType },
|
|
62
|
+
{ header: 'ratio', value: (w) => w.aspectRatio },
|
|
63
|
+
{ header: 'fav', value: (w) => (w.favoritedByMe ? '★' : '') },
|
|
64
|
+
{ header: 'updated', value: (w) => relativeTime(w.updatedAt) },
|
|
65
|
+
]);
|
|
66
|
+
info(color.dim(`${r.pagination?.total ?? 0} total · page ${r.pagination?.page}`));
|
|
67
|
+
// The server clamps page size, so "everything" may still span pages.
|
|
68
|
+
if (r.pagination?.hasNext) {
|
|
69
|
+
info(color.dim(`more: --page ${Number(r.pagination.page) + 1}`));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
ws.command('get [id]')
|
|
74
|
+
.description('Show a workspace (defaults to the selected one)')
|
|
75
|
+
.action(async (id) => {
|
|
76
|
+
const client = new WjiClient(globals());
|
|
77
|
+
const w = await client.get(`/workspace/list/${client.requireWorkspace(id)}`);
|
|
78
|
+
render(w, () => detail([
|
|
79
|
+
['id', w.id],
|
|
80
|
+
['name', w.name],
|
|
81
|
+
['description', w.description],
|
|
82
|
+
['type', w.workspaceType],
|
|
83
|
+
['aspectRatio', w.aspectRatio],
|
|
84
|
+
['language', w.language],
|
|
85
|
+
['uiVersion', w.uiVersion],
|
|
86
|
+
['organization', w.organization?.id || w.organization || 'personal'],
|
|
87
|
+
['deductionMode', w.deductionMode],
|
|
88
|
+
['apps', (w.apps || []).map((a) => `${a.app}(${a.id})`).join(', ')],
|
|
89
|
+
['createdAt', w.createdAt],
|
|
90
|
+
]));
|
|
91
|
+
});
|
|
92
|
+
ws.command('use <id>')
|
|
93
|
+
.description('Set the default workspace for this profile')
|
|
94
|
+
.action(async (id) => {
|
|
95
|
+
const client = new WjiClient(globals());
|
|
96
|
+
const w = await client.get(`/workspace/list/${id}/basic`);
|
|
97
|
+
updateProfile(globals().profile, { workspace: id });
|
|
98
|
+
success(`Default workspace: ${w.name} (${id})`);
|
|
99
|
+
});
|
|
100
|
+
ws.command('create <name>')
|
|
101
|
+
.description('Create a workspace')
|
|
102
|
+
.option('-d, --description <text>', 'description')
|
|
103
|
+
.option('-r, --aspect-ratio <ratio>', '16:9 | 9:16 | 21:9', '16:9')
|
|
104
|
+
.option('-l, --language <lang>', 'dialogue language', 'auto')
|
|
105
|
+
.option('-t, --type <type>', 'workspaceType')
|
|
106
|
+
.option('-a, --app <app...>', 'apps to create, e.g. dataset files canvas')
|
|
107
|
+
.action(async (name, opts) => {
|
|
108
|
+
const client = new WjiClient(globals());
|
|
109
|
+
const apps = (opts.app || []).map((token) => {
|
|
110
|
+
const { app, type } = resolveApp(token);
|
|
111
|
+
return { app, type, name: app };
|
|
112
|
+
});
|
|
113
|
+
const created = await client.post('/workspace/create', {
|
|
114
|
+
name,
|
|
115
|
+
description: opts.description,
|
|
116
|
+
aspectRatio: opts.aspectRatio,
|
|
117
|
+
language: opts.language,
|
|
118
|
+
workspaceType: opts.type,
|
|
119
|
+
apps: apps.length ? apps : undefined,
|
|
120
|
+
});
|
|
121
|
+
render(created, (w) => success(`Created workspace ${w.name} (${w.id})`));
|
|
122
|
+
});
|
|
123
|
+
ws.command('update [id]')
|
|
124
|
+
.description('Update workspace fields')
|
|
125
|
+
.option('-n, --name <name>', 'new name')
|
|
126
|
+
.option('-d, --description <text>', 'new description')
|
|
127
|
+
.option('-r, --aspect-ratio <ratio>', 'new aspect ratio')
|
|
128
|
+
.option('-l, --language <lang>', 'new language')
|
|
129
|
+
.option('--set <key=value...>', 'arbitrary field, dotted keys allowed')
|
|
130
|
+
.action(async (id, opts) => {
|
|
131
|
+
const client = new WjiClient(globals());
|
|
132
|
+
const body = {
|
|
133
|
+
name: opts.name,
|
|
134
|
+
description: opts.description,
|
|
135
|
+
aspectRatio: opts.aspectRatio,
|
|
136
|
+
language: opts.language,
|
|
137
|
+
...parseKeyValues(opts.set),
|
|
138
|
+
};
|
|
139
|
+
const updated = await client.put(`/workspace/list/${client.requireWorkspace(id)}`, body);
|
|
140
|
+
render(updated, () => success('Workspace updated'));
|
|
141
|
+
});
|
|
142
|
+
ws.command('rm <id>')
|
|
143
|
+
.description('Delete a workspace (owner only)')
|
|
144
|
+
.option('-y, --yes', 'skip confirmation')
|
|
145
|
+
.action(async (id, opts) => {
|
|
146
|
+
const client = new WjiClient(globals());
|
|
147
|
+
const w = await client.get(`/workspace/list/${id}/basic`).catch(() => ({ name: id }));
|
|
148
|
+
if (!(await confirm(`Delete workspace "${w.name}" (${id})?`, opts.yes))) {
|
|
149
|
+
return info('Aborted');
|
|
150
|
+
}
|
|
151
|
+
const result = await client.delete(`/workspace/list/${id}`);
|
|
152
|
+
render(result, () => success(`Deleted ${id}`));
|
|
153
|
+
});
|
|
154
|
+
ws.command('favorite <id>')
|
|
155
|
+
.description('Favorite or unfavorite a workspace')
|
|
156
|
+
.option('--off', 'remove from favorites')
|
|
157
|
+
.action(async (id, opts) => {
|
|
158
|
+
const client = new WjiClient(globals());
|
|
159
|
+
const result = await client.put(`/workspace/list/${id}/favorite`, { favorite: !opts.off });
|
|
160
|
+
render(result, () => success(opts.off ? 'Removed from favorites' : 'Favorited'));
|
|
161
|
+
});
|
|
162
|
+
ws.command('fs [id]')
|
|
163
|
+
.description('Show the workspace file tree (optionally for one app)')
|
|
164
|
+
.option('-a, --app <appId>', 'workspace app id to scope the tree')
|
|
165
|
+
.action(async (id, opts) => {
|
|
166
|
+
const client = new WjiClient(globals());
|
|
167
|
+
const wsId = client.requireWorkspace(id);
|
|
168
|
+
const path = opts.app
|
|
169
|
+
? `/workspace/list/${wsId}/fs/${opts.app}`
|
|
170
|
+
: `/workspace/list/${wsId}/fs`;
|
|
171
|
+
const root = await client.get(path);
|
|
172
|
+
render(root, (node) => printTree(node));
|
|
173
|
+
});
|
|
174
|
+
// ── apps ───────────────────────────────────────────────────────────────────
|
|
175
|
+
const apps = ws.command('app').description('Manage workspace apps');
|
|
176
|
+
apps
|
|
177
|
+
.command('list [id]')
|
|
178
|
+
.description('List apps in a workspace')
|
|
179
|
+
.action(async (id) => {
|
|
180
|
+
const client = new WjiClient(globals());
|
|
181
|
+
const w = await client.get(`/workspace/list/${client.requireWorkspace(id)}`);
|
|
182
|
+
render(w.apps || [], (rows) => table(rows, [
|
|
183
|
+
{ header: 'id', value: (a) => a.id },
|
|
184
|
+
{ header: 'app', value: (a) => a.app },
|
|
185
|
+
{ header: 'type', value: (a) => a.type },
|
|
186
|
+
{ header: 'name', value: (a) => a.name },
|
|
187
|
+
]));
|
|
188
|
+
});
|
|
189
|
+
apps
|
|
190
|
+
.command('add <app> [id]')
|
|
191
|
+
.description('Add an app (dataset, files, canvas, document, draw, …)')
|
|
192
|
+
.option('-n, --name <name>', 'display name')
|
|
193
|
+
.action(async (app, id, opts) => {
|
|
194
|
+
const client = new WjiClient(globals());
|
|
195
|
+
const resolved = resolveApp(app);
|
|
196
|
+
const created = await client.post(`/workspace/list/${client.requireWorkspace(id)}/apps`, { app: resolved.app, type: resolved.type, name: opts.name || resolved.app });
|
|
197
|
+
render(created, (a) => success(`Added ${a.app} (${a.id})`));
|
|
198
|
+
});
|
|
199
|
+
apps
|
|
200
|
+
.command('rm <appId> [id]')
|
|
201
|
+
.description('Remove an app from a workspace')
|
|
202
|
+
.option('-y, --yes', 'skip confirmation')
|
|
203
|
+
.action(async (appId, id, opts) => {
|
|
204
|
+
if (!(await confirm(`Remove app ${appId}?`, opts.yes)))
|
|
205
|
+
return info('Aborted');
|
|
206
|
+
const client = new WjiClient(globals());
|
|
207
|
+
const result = await client.delete(`/workspace/list/${client.requireWorkspace(id)}/apps/${appId}`);
|
|
208
|
+
render(result, () => success(`Removed ${appId}`));
|
|
209
|
+
});
|
|
210
|
+
// ── members ────────────────────────────────────────────────────────────────
|
|
211
|
+
const members = ws.command('member').description('Manage workspace members');
|
|
212
|
+
members
|
|
213
|
+
.command('list [id]')
|
|
214
|
+
.description('List members')
|
|
215
|
+
.action(async (id) => {
|
|
216
|
+
const client = new WjiClient(globals());
|
|
217
|
+
const w = await client.get(`/workspace/list/${client.requireWorkspace(id)}`);
|
|
218
|
+
render(w.members || [], (rows) => table(rows, [
|
|
219
|
+
{ header: 'memberId', value: (m) => m.id },
|
|
220
|
+
{ header: 'user', value: (m) => m.user?.id || m.user },
|
|
221
|
+
{ header: 'name', value: (m) => m.user?.name },
|
|
222
|
+
{ header: 'role', value: (m) => m.role },
|
|
223
|
+
]));
|
|
224
|
+
});
|
|
225
|
+
members
|
|
226
|
+
.command('add <userId> [id]')
|
|
227
|
+
.description('Add a member (editor by default)')
|
|
228
|
+
.option('-r, --role <role>', 'editor | viewer', 'editor')
|
|
229
|
+
.action(async (userId, id, opts) => {
|
|
230
|
+
const client = new WjiClient(globals());
|
|
231
|
+
const result = await client.post(`/workspace/list/${client.requireWorkspace(id)}/member/add`, { user: userId, role: opts.role });
|
|
232
|
+
render(result, () => success(`Added ${userId} as ${opts.role}`));
|
|
233
|
+
});
|
|
234
|
+
members
|
|
235
|
+
.command('role <memberId> <role> [id]')
|
|
236
|
+
.description('Change a member role')
|
|
237
|
+
.action(async (memberId, role, id) => {
|
|
238
|
+
const client = new WjiClient(globals());
|
|
239
|
+
const result = await client.put(`/workspace/list/${client.requireWorkspace(id)}/member/${memberId}/role`, { role });
|
|
240
|
+
render(result, () => success(`${memberId} is now ${role}`));
|
|
241
|
+
});
|
|
242
|
+
members
|
|
243
|
+
.command('rm <memberId> [id]')
|
|
244
|
+
.description('Remove a member')
|
|
245
|
+
.option('-y, --yes', 'skip confirmation')
|
|
246
|
+
.action(async (memberId, id, opts) => {
|
|
247
|
+
if (!(await confirm(`Remove member ${memberId}?`, opts.yes)))
|
|
248
|
+
return info('Aborted');
|
|
249
|
+
const client = new WjiClient(globals());
|
|
250
|
+
const result = await client.delete(`/workspace/list/${client.requireWorkspace(id)}/member/${memberId}`);
|
|
251
|
+
render(result, () => success(`Removed ${memberId}`));
|
|
252
|
+
});
|
|
253
|
+
// ── quota / stats ──────────────────────────────────────────────────────────
|
|
254
|
+
ws.command('quota [id]')
|
|
255
|
+
.description('Show credit quota usage')
|
|
256
|
+
.option('--set <key=value...>', 'update quota, e.g. monthlyLimit=1000')
|
|
257
|
+
.action(async (id, opts) => {
|
|
258
|
+
const client = new WjiClient(globals());
|
|
259
|
+
const wsId = client.requireWorkspace(id);
|
|
260
|
+
if (opts.set?.length) {
|
|
261
|
+
const updated = await client.put(`/workspace/list/${wsId}/quota`, parseKeyValues(opts.set));
|
|
262
|
+
return render(updated, () => success('Quota updated'));
|
|
263
|
+
}
|
|
264
|
+
const usage = await client.get(`/workspace/list/${wsId}/quota-usage`);
|
|
265
|
+
render(usage, (u) => detail(Object.entries(u)));
|
|
266
|
+
});
|
|
267
|
+
ws.command('stats [id]')
|
|
268
|
+
.description('Show workspace stats (owner only)')
|
|
269
|
+
.action(async (id) => {
|
|
270
|
+
const client = new WjiClient(globals());
|
|
271
|
+
const stats = await client.get(`/workspace/list/${client.requireWorkspace(id)}/stats`);
|
|
272
|
+
render(stats, (s) => detail(Object.entries(s)));
|
|
273
|
+
});
|
|
274
|
+
ws.command('templates')
|
|
275
|
+
.description('List workspace templates you can clone')
|
|
276
|
+
.action(async () => {
|
|
277
|
+
const client = new WjiClient(globals());
|
|
278
|
+
const templates = await client.get('/workspace/start/templates');
|
|
279
|
+
render(templates, (rows) => table(rows || [], [
|
|
280
|
+
{ header: 'id', value: (t) => t.id },
|
|
281
|
+
{ header: 'name', value: (t) => t.name },
|
|
282
|
+
{ header: 'type', value: (t) => t.workspaceType },
|
|
283
|
+
{ header: 'description', value: (t) => truncate(t.description, 40) },
|
|
284
|
+
]));
|
|
285
|
+
});
|
|
286
|
+
ws.command('clone <templateId>')
|
|
287
|
+
.description('Create a workspace from a template')
|
|
288
|
+
.action(async (templateId) => {
|
|
289
|
+
const client = new WjiClient(globals());
|
|
290
|
+
const created = await client.post(`/workspace/create/${templateId}`);
|
|
291
|
+
render(created, (w) => success(`Created workspace ${w.name} (${w.id})`));
|
|
292
|
+
});
|
|
293
|
+
return ws;
|
|
294
|
+
}
|
|
295
|
+
export function printTree(node, prefix = '', connector = '') {
|
|
296
|
+
if (!node)
|
|
297
|
+
return;
|
|
298
|
+
const label = node.nickname ? `${node.name} ${color.dim(`(${node.nickname})`)}` : node.name;
|
|
299
|
+
const kind = node.type === 'folder' ? color.blue('/') : '';
|
|
300
|
+
process.stdout.write(`${prefix}${connector}${label}${kind} ${color.dim(node.id)}\n`);
|
|
301
|
+
const children = node.children || [];
|
|
302
|
+
// The root prints flush left; every level below it gets the box-drawing gutter.
|
|
303
|
+
const childPrefix = connector ? prefix + (connector === '└─ ' ? ' ' : '│ ') : prefix;
|
|
304
|
+
children.forEach((child, i) => printTree(child, childPrefix, i === children.length - 1 ? '└─ ' : '├─ '));
|
|
305
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { DEFAULT_ENDPOINT, acquireLock, getProfile, refreshLockPath, releaseLock, updateProfile, } from './config.js';
|
|
4
|
+
/** Server cap on `ids` for `/assets/check-status`, `/assets/summary/batch` and friends. */
|
|
5
|
+
export const ASSET_BATCH_MAX_IDS = 100;
|
|
6
|
+
export const isObjectId = (value) => /^[a-f0-9]{24}$/i.test(value);
|
|
7
|
+
export class ApiError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
path;
|
|
10
|
+
body;
|
|
11
|
+
constructor(message, status, path, body) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.path = path;
|
|
15
|
+
this.body = body;
|
|
16
|
+
this.name = 'ApiError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Mirrors the backend's `qs` parser (allowDots + bracket arrays), so nested
|
|
21
|
+
* search filters like `sort.createdAt=-1` survive the round trip.
|
|
22
|
+
*/
|
|
23
|
+
export function serializeQuery(params, prefix = '') {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const [rawKey, value] of Object.entries(params)) {
|
|
26
|
+
if (value === undefined || value === null)
|
|
27
|
+
continue;
|
|
28
|
+
const key = prefix ? `${prefix}.${rawKey}` : rawKey;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
if (item === undefined || item === null)
|
|
32
|
+
continue;
|
|
33
|
+
out.push(`${encodeURIComponent(key + '[]')}=${encodeURIComponent(String(item))}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (typeof value === 'object') {
|
|
37
|
+
out.push(...serializeQuery(value, key));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
out.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export class WjiClient {
|
|
46
|
+
globals;
|
|
47
|
+
profileName;
|
|
48
|
+
profile;
|
|
49
|
+
constructor(globals = {}) {
|
|
50
|
+
this.globals = globals;
|
|
51
|
+
const { name, profile } = getProfile(globals.profile);
|
|
52
|
+
this.profileName = name;
|
|
53
|
+
this.profile = profile;
|
|
54
|
+
}
|
|
55
|
+
get endpoint() {
|
|
56
|
+
const raw = this.globals.endpoint ||
|
|
57
|
+
process.env.WJI_ENDPOINT ||
|
|
58
|
+
this.profile.endpoint ||
|
|
59
|
+
DEFAULT_ENDPOINT;
|
|
60
|
+
return raw.replace(/\/+$/, '');
|
|
61
|
+
}
|
|
62
|
+
get orgId() {
|
|
63
|
+
if (this.globals.personal)
|
|
64
|
+
return undefined;
|
|
65
|
+
return this.globals.org || process.env.WJI_ORG_ID || this.profile.orgId || undefined;
|
|
66
|
+
}
|
|
67
|
+
get token() {
|
|
68
|
+
return (this.globals.token ||
|
|
69
|
+
process.env.WJI_TOKEN ||
|
|
70
|
+
process.env.WJI_API_KEY ||
|
|
71
|
+
this.profile.apiKey ||
|
|
72
|
+
this.profile.accessToken);
|
|
73
|
+
}
|
|
74
|
+
/** True when the active credential is a long-lived `sk_` key (never refreshed). */
|
|
75
|
+
get usingApiKey() {
|
|
76
|
+
return !!this.token?.startsWith('sk_');
|
|
77
|
+
}
|
|
78
|
+
requireToken() {
|
|
79
|
+
const token = this.token;
|
|
80
|
+
if (!token) {
|
|
81
|
+
throw new Error('Not authenticated. Run `wji auth login` or set WJI_TOKEN.');
|
|
82
|
+
}
|
|
83
|
+
return token;
|
|
84
|
+
}
|
|
85
|
+
headers(opts) {
|
|
86
|
+
const headers = {
|
|
87
|
+
Accept: 'application/json',
|
|
88
|
+
'X-Locale': this.globals.locale || process.env.WJI_LOCALE || this.profile.locale || 'cht',
|
|
89
|
+
};
|
|
90
|
+
if (!opts.anonymous)
|
|
91
|
+
headers.Authorization = `Bearer ${this.requireToken()}`;
|
|
92
|
+
const org = this.orgId;
|
|
93
|
+
if (org)
|
|
94
|
+
headers['X-Org-Id'] = org;
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
async request(method, path, opts = {}) {
|
|
98
|
+
const query = opts.query ? serializeQuery(opts.query) : [];
|
|
99
|
+
const url = `${this.endpoint}${path.startsWith('/') ? path : `/${path}`}${query.length ? `?${query.join('&')}` : ''}`;
|
|
100
|
+
const headers = this.headers(opts);
|
|
101
|
+
let payload;
|
|
102
|
+
if (opts.form) {
|
|
103
|
+
payload = opts.form;
|
|
104
|
+
}
|
|
105
|
+
else if (opts.body !== undefined) {
|
|
106
|
+
headers['Content-Type'] = 'application/json';
|
|
107
|
+
payload = JSON.stringify(opts.body);
|
|
108
|
+
}
|
|
109
|
+
const res = await fetch(url, { method, headers, body: payload });
|
|
110
|
+
if (res.status === 401 && !opts.noRetry && !opts.anonymous && !this.usingApiKey) {
|
|
111
|
+
if (await this.tryRefresh()) {
|
|
112
|
+
return this.request(method, path, { ...opts, noRetry: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const text = await res.text();
|
|
116
|
+
let parsed = undefined;
|
|
117
|
+
let isJson = false;
|
|
118
|
+
if (text) {
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(text);
|
|
121
|
+
isJson = true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
parsed = text;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// A non-JSON 200 almost always means the endpoint points at something that
|
|
128
|
+
// is not the API (the Nuxt frontend, a proxy, a captive portal).
|
|
129
|
+
if (res.ok && text && !isJson) {
|
|
130
|
+
throw new ApiError(`Expected JSON from ${this.endpoint} but got ${res.headers.get('content-type') || 'an unknown content type'}. Is --endpoint pointing at the API and not the web app?`, res.status, path, typeof parsed === 'string' ? parsed.slice(0, 200) : parsed);
|
|
131
|
+
}
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const body = parsed;
|
|
134
|
+
let message = typeof body === 'string'
|
|
135
|
+
? body
|
|
136
|
+
: Array.isArray(body?.message)
|
|
137
|
+
? String(body?.message[0])
|
|
138
|
+
: typeof body?.message === 'string'
|
|
139
|
+
? body.message
|
|
140
|
+
: res.statusText;
|
|
141
|
+
if (res.status === 401)
|
|
142
|
+
message = `${message} (token expired or invalid — run \`wji auth login\`)`;
|
|
143
|
+
throw new ApiError(message, res.status, path, parsed);
|
|
144
|
+
}
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
get(path, query) {
|
|
148
|
+
return this.request('GET', path, { query });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Several GETs are wrapped in a 10s server-side cache keyed on the full URL,
|
|
152
|
+
* so a read taken right after a write can be stale. Use this whenever a
|
|
153
|
+
* subsequent write depends on what comes back.
|
|
154
|
+
*/
|
|
155
|
+
getFresh(path, query) {
|
|
156
|
+
return this.request('GET', path, { query: { ...query, _: Date.now() } });
|
|
157
|
+
}
|
|
158
|
+
post(path, body, query) {
|
|
159
|
+
return this.request('POST', path, { body, query });
|
|
160
|
+
}
|
|
161
|
+
put(path, body) {
|
|
162
|
+
return this.request('PUT', path, { body });
|
|
163
|
+
}
|
|
164
|
+
patch(path, body) {
|
|
165
|
+
return this.request('PATCH', path, { body });
|
|
166
|
+
}
|
|
167
|
+
delete(path, query) {
|
|
168
|
+
return this.request('DELETE', path, { query });
|
|
169
|
+
}
|
|
170
|
+
/** Multipart upload of a local file — Node's built-in FormData/Blob, no extra dependency. */
|
|
171
|
+
async upload(path, file, fields) {
|
|
172
|
+
const buffer = await readFile(file);
|
|
173
|
+
return this.uploadBuffer(path, buffer, basename(file), fields);
|
|
174
|
+
}
|
|
175
|
+
/** Multipart upload of an in-memory buffer (e.g. a zip built on the fly). */
|
|
176
|
+
async uploadBuffer(path, buffer, filename, fields = {}, field = 'file') {
|
|
177
|
+
const form = new FormData();
|
|
178
|
+
form.append(field, new Blob([new Uint8Array(buffer)]), filename);
|
|
179
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
180
|
+
if (value !== undefined && value !== null && value !== '')
|
|
181
|
+
form.append(key, value);
|
|
182
|
+
}
|
|
183
|
+
return this.request('POST', path, { form });
|
|
184
|
+
}
|
|
185
|
+
/** `POST /assets/check-status` accepts at most 100 ids, so larger lists are batched. */
|
|
186
|
+
async checkAssetStatus(ids) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (let i = 0; i < ids.length; i += ASSET_BATCH_MAX_IDS) {
|
|
189
|
+
const rows = await this.post('/assets/check-status', {
|
|
190
|
+
ids: ids.slice(i, i + ASSET_BATCH_MAX_IDS),
|
|
191
|
+
});
|
|
192
|
+
if (Array.isArray(rows))
|
|
193
|
+
out.push(...rows);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Rotate the stored JWT pair.
|
|
199
|
+
*
|
|
200
|
+
* The backend treats a refresh token presented twice as stolen and revokes
|
|
201
|
+
* every session of that user. Parallel `wji` processes (an agent running
|
|
202
|
+
* several commands) can all hit a 401 at the same moment, so refreshes are
|
|
203
|
+
* serialized with a lock file and each process re-reads the config under the
|
|
204
|
+
* lock: if another process already rotated the pair, it adopts that pair
|
|
205
|
+
* instead of replaying the now-dead refresh token.
|
|
206
|
+
*/
|
|
207
|
+
async refreshSession() {
|
|
208
|
+
const stale = this.profile.accessToken;
|
|
209
|
+
const lock = refreshLockPath(this.profileName);
|
|
210
|
+
const locked = await acquireLock(lock);
|
|
211
|
+
try {
|
|
212
|
+
const latest = getProfile(this.globals.profile).profile;
|
|
213
|
+
if (latest.accessToken && latest.accessToken !== stale) {
|
|
214
|
+
this.profile = latest;
|
|
215
|
+
return latest;
|
|
216
|
+
}
|
|
217
|
+
if (!latest.refreshToken) {
|
|
218
|
+
throw new Error('No refresh token stored. Run `wji auth login`.');
|
|
219
|
+
}
|
|
220
|
+
const result = await this.request('POST', '/auth/refresh', { body: { refreshToken: latest.refreshToken }, anonymous: true, noRetry: true });
|
|
221
|
+
this.profile = updateProfile(this.globals.profile, {
|
|
222
|
+
accessToken: result.tokens.accessToken,
|
|
223
|
+
refreshToken: result.tokens.refreshToken || latest.refreshToken,
|
|
224
|
+
});
|
|
225
|
+
return this.profile;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (locked)
|
|
229
|
+
releaseLock(lock);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async tryRefresh() {
|
|
233
|
+
try {
|
|
234
|
+
await this.refreshSession();
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Resolve `--workspace` with a fallback to the profile default. */
|
|
242
|
+
workspace(explicit) {
|
|
243
|
+
return explicit || process.env.WJI_WORKSPACE || this.profile.workspace || undefined;
|
|
244
|
+
}
|
|
245
|
+
requireWorkspace(explicit) {
|
|
246
|
+
const id = this.workspace(explicit);
|
|
247
|
+
if (!id) {
|
|
248
|
+
throw new Error('No workspace. Pass --workspace <id> or set a default with `wji ws use <id>`.');
|
|
249
|
+
}
|
|
250
|
+
return id;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const DEFAULT_ENDPOINT = 'https://api.wji.ai';
|
|
5
|
+
const EMPTY = { current: 'default', profiles: { default: {} } };
|
|
6
|
+
export function configDir() {
|
|
7
|
+
return process.env.WJI_CONFIG_DIR || join(homedir(), '.wji');
|
|
8
|
+
}
|
|
9
|
+
export function configPath() {
|
|
10
|
+
return process.env.WJI_CONFIG_FILE || join(configDir(), 'config.json');
|
|
11
|
+
}
|
|
12
|
+
export function readConfig() {
|
|
13
|
+
const path = configPath();
|
|
14
|
+
if (!existsSync(path))
|
|
15
|
+
return structuredClone(EMPTY);
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
if (!parsed.profiles)
|
|
19
|
+
parsed.profiles = {};
|
|
20
|
+
if (!parsed.current)
|
|
21
|
+
parsed.current = 'default';
|
|
22
|
+
if (!parsed.profiles[parsed.current])
|
|
23
|
+
parsed.profiles[parsed.current] = {};
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Config file is not valid JSON: ${path}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function writeConfig(config) {
|
|
31
|
+
const path = configPath();
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
// Write-then-rename so a concurrent reader never sees a half-written file:
|
|
34
|
+
// several wji processes can rotate tokens at the same moment.
|
|
35
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
36
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
37
|
+
// The file holds bearer tokens — keep it owner-only even if it pre-existed.
|
|
38
|
+
chmodSync(tmp, 0o600);
|
|
39
|
+
renameSync(tmp, path);
|
|
40
|
+
}
|
|
41
|
+
export function getProfile(name) {
|
|
42
|
+
const config = readConfig();
|
|
43
|
+
const key = name || process.env.WJI_PROFILE || config.current || 'default';
|
|
44
|
+
return { name: key, profile: config.profiles[key] || {} };
|
|
45
|
+
}
|
|
46
|
+
export function updateProfile(name, patch) {
|
|
47
|
+
const config = readConfig();
|
|
48
|
+
const key = name || process.env.WJI_PROFILE || config.current || 'default';
|
|
49
|
+
config.profiles[key] = { ...(config.profiles[key] || {}), ...patch };
|
|
50
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
delete config.profiles[key][k];
|
|
53
|
+
}
|
|
54
|
+
writeConfig(config);
|
|
55
|
+
return config.profiles[key];
|
|
56
|
+
}
|
|
57
|
+
/** A lock older than this belongs to a process that died mid-refresh. */
|
|
58
|
+
const LOCK_STALE_MS = 30_000;
|
|
59
|
+
export function refreshLockPath(profile) {
|
|
60
|
+
return join(dirname(configPath()), `.refresh-${profile.replace(/[^\w.-]/g, '_')}.lock`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Take an exclusive lock file (`O_EXCL`). Waits for a live holder and breaks a
|
|
64
|
+
* stale one. Returns false only if the lock could not be taken in time, in
|
|
65
|
+
* which case the caller proceeds unlocked rather than failing the command.
|
|
66
|
+
*/
|
|
67
|
+
export async function acquireLock(path, timeoutMs = LOCK_STALE_MS + 5_000) {
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
for (;;) {
|
|
71
|
+
try {
|
|
72
|
+
closeSync(openSync(path, 'wx', 0o600));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code !== 'EEXIST')
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
if (Date.now() - statSync(path).mtimeMs > LOCK_STALE_MS) {
|
|
81
|
+
rmSync(path, { force: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue; // released between our open and stat
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() > deadline)
|
|
89
|
+
return false;
|
|
90
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function releaseLock(path) {
|
|
94
|
+
rmSync(path, { force: true });
|
|
95
|
+
}
|