@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.
@@ -1,269 +0,0 @@
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
- /**
7
- * Content types for multipart uploads. Some endpoints (Backreel private files)
8
- * accept a file by its declared type alone, and a bare Blob is sent as
9
- * application/octet-stream — which they reject as an unsupported type.
10
- */
11
- const MIME_TYPES = {
12
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif',
13
- mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm',
14
- mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4',
15
- pdf: 'application/pdf', csv: 'text/csv', txt: 'text/plain', md: 'text/markdown', json: 'application/json', zip: 'application/zip',
16
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
17
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
18
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
19
- };
20
- export const mimeTypeOf = (filename) => MIME_TYPES[filename.split('.').pop()?.toLowerCase() || ''] || 'application/octet-stream';
21
- export const isObjectId = (value) => /^[a-f0-9]{24}$/i.test(value);
22
- export class ApiError extends Error {
23
- status;
24
- path;
25
- body;
26
- constructor(message, status, path, body) {
27
- super(message);
28
- this.status = status;
29
- this.path = path;
30
- this.body = body;
31
- this.name = 'ApiError';
32
- }
33
- }
34
- /**
35
- * Mirrors the backend's `qs` parser (allowDots + bracket arrays), so nested
36
- * search filters like `sort.createdAt=-1` survive the round trip.
37
- */
38
- export function serializeQuery(params, prefix = '') {
39
- const out = [];
40
- for (const [rawKey, value] of Object.entries(params)) {
41
- if (value === undefined || value === null)
42
- continue;
43
- const key = prefix ? `${prefix}.${rawKey}` : rawKey;
44
- if (Array.isArray(value)) {
45
- for (const item of value) {
46
- if (item === undefined || item === null)
47
- continue;
48
- out.push(`${encodeURIComponent(key + '[]')}=${encodeURIComponent(String(item))}`);
49
- }
50
- }
51
- else if (typeof value === 'object') {
52
- out.push(...serializeQuery(value, key));
53
- }
54
- else {
55
- out.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
56
- }
57
- }
58
- return out;
59
- }
60
- export class WjiClient {
61
- globals;
62
- profileName;
63
- profile;
64
- constructor(globals = {}) {
65
- this.globals = globals;
66
- const { name, profile } = getProfile(globals.profile);
67
- this.profileName = name;
68
- this.profile = profile;
69
- }
70
- get endpoint() {
71
- const raw = this.globals.endpoint ||
72
- process.env.WJI_ENDPOINT ||
73
- this.profile.endpoint ||
74
- DEFAULT_ENDPOINT;
75
- return raw.replace(/\/+$/, '');
76
- }
77
- get orgId() {
78
- if (this.globals.personal)
79
- return undefined;
80
- return this.globals.org || process.env.WJI_ORG_ID || this.profile.orgId || undefined;
81
- }
82
- get token() {
83
- return (this.globals.token ||
84
- process.env.WJI_TOKEN ||
85
- process.env.WJI_API_KEY ||
86
- this.profile.apiKey ||
87
- this.profile.accessToken);
88
- }
89
- /** True when the active credential is a long-lived `sk_` key (never refreshed). */
90
- get usingApiKey() {
91
- return !!this.token?.startsWith('sk_');
92
- }
93
- requireToken() {
94
- const token = this.token;
95
- if (!token) {
96
- throw new Error('Not authenticated. Run `wji auth login` or set WJI_TOKEN.');
97
- }
98
- return token;
99
- }
100
- headers(opts) {
101
- const headers = {
102
- Accept: 'application/json',
103
- 'X-Locale': this.globals.locale || process.env.WJI_LOCALE || this.profile.locale || 'cht',
104
- };
105
- if (!opts.anonymous)
106
- headers.Authorization = `Bearer ${this.requireToken()}`;
107
- const org = this.orgId;
108
- if (org)
109
- headers['X-Org-Id'] = org;
110
- if (this.globals.app)
111
- headers['X-App'] = this.globals.app;
112
- return headers;
113
- }
114
- async request(method, path, opts = {}) {
115
- const query = opts.query ? serializeQuery(opts.query) : [];
116
- const url = `${this.endpoint}${path.startsWith('/') ? path : `/${path}`}${query.length ? `?${query.join('&')}` : ''}`;
117
- const headers = this.headers(opts);
118
- let payload;
119
- if (opts.form) {
120
- payload = opts.form;
121
- }
122
- else if (opts.body !== undefined) {
123
- headers['Content-Type'] = 'application/json';
124
- payload = JSON.stringify(opts.body);
125
- }
126
- const res = await fetch(url, { method, headers, body: payload });
127
- if (res.status === 401 && !opts.noRetry && !opts.anonymous && !this.usingApiKey) {
128
- if (await this.tryRefresh()) {
129
- return this.request(method, path, { ...opts, noRetry: true });
130
- }
131
- }
132
- const text = await res.text();
133
- let parsed = undefined;
134
- let isJson = false;
135
- if (text) {
136
- try {
137
- parsed = JSON.parse(text);
138
- isJson = true;
139
- }
140
- catch {
141
- parsed = text;
142
- }
143
- }
144
- // A non-JSON 200 almost always means the endpoint points at something that
145
- // is not the API (the Nuxt frontend, a proxy, a captive portal).
146
- if (res.ok && text && !isJson) {
147
- 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);
148
- }
149
- if (!res.ok) {
150
- const body = parsed;
151
- let message = typeof body === 'string'
152
- ? body
153
- : Array.isArray(body?.message)
154
- ? String(body?.message[0])
155
- : typeof body?.message === 'string'
156
- ? body.message
157
- : res.statusText;
158
- if (res.status === 401)
159
- message = `${message} (token expired or invalid — run \`wji auth login\`)`;
160
- throw new ApiError(message, res.status, path, parsed);
161
- }
162
- return parsed;
163
- }
164
- get(path, query) {
165
- return this.request('GET', path, { query });
166
- }
167
- /**
168
- * Several GETs are wrapped in a 10s server-side cache keyed on the full URL,
169
- * so a read taken right after a write can be stale. Use this whenever a
170
- * subsequent write depends on what comes back.
171
- */
172
- getFresh(path, query) {
173
- return this.request('GET', path, { query: { ...query, _: Date.now() } });
174
- }
175
- post(path, body, query) {
176
- return this.request('POST', path, { body, query });
177
- }
178
- put(path, body) {
179
- return this.request('PUT', path, { body });
180
- }
181
- patch(path, body) {
182
- return this.request('PATCH', path, { body });
183
- }
184
- delete(path, query) {
185
- return this.request('DELETE', path, { query });
186
- }
187
- /** Multipart upload of a local file — Node's built-in FormData/Blob, no extra dependency. */
188
- async upload(path, file, fields) {
189
- const buffer = await readFile(file);
190
- return this.uploadBuffer(path, buffer, basename(file), fields);
191
- }
192
- /** Multipart upload of an in-memory buffer (e.g. a zip built on the fly). */
193
- async uploadBuffer(path, buffer, filename, fields = {}, field = 'file') {
194
- const form = new FormData();
195
- form.append(field, new Blob([new Uint8Array(buffer)], { type: mimeTypeOf(filename) }), filename);
196
- for (const [key, value] of Object.entries(fields)) {
197
- if (value !== undefined && value !== null && value !== '')
198
- form.append(key, value);
199
- }
200
- return this.request('POST', path, { form });
201
- }
202
- /** `POST /assets/check-status` accepts at most 100 ids, so larger lists are batched. */
203
- async checkAssetStatus(ids) {
204
- const out = [];
205
- for (let i = 0; i < ids.length; i += ASSET_BATCH_MAX_IDS) {
206
- const rows = await this.post('/assets/check-status', {
207
- ids: ids.slice(i, i + ASSET_BATCH_MAX_IDS),
208
- });
209
- if (Array.isArray(rows))
210
- out.push(...rows);
211
- }
212
- return out;
213
- }
214
- /**
215
- * Rotate the stored JWT pair.
216
- *
217
- * The backend treats a refresh token presented twice as stolen and revokes
218
- * every session of that user. Parallel `wji` processes (an agent running
219
- * several commands) can all hit a 401 at the same moment, so refreshes are
220
- * serialized with a lock file and each process re-reads the config under the
221
- * lock: if another process already rotated the pair, it adopts that pair
222
- * instead of replaying the now-dead refresh token.
223
- */
224
- async refreshSession() {
225
- const stale = this.profile.accessToken;
226
- const lock = refreshLockPath(this.profileName);
227
- const locked = await acquireLock(lock);
228
- try {
229
- const latest = getProfile(this.globals.profile).profile;
230
- if (latest.accessToken && latest.accessToken !== stale) {
231
- this.profile = latest;
232
- return latest;
233
- }
234
- if (!latest.refreshToken) {
235
- throw new Error('No refresh token stored. Run `wji auth login`.');
236
- }
237
- const result = await this.request('POST', '/auth/refresh', { body: { refreshToken: latest.refreshToken }, anonymous: true, noRetry: true });
238
- this.profile = updateProfile(this.globals.profile, {
239
- accessToken: result.tokens.accessToken,
240
- refreshToken: result.tokens.refreshToken || latest.refreshToken,
241
- });
242
- return this.profile;
243
- }
244
- finally {
245
- if (locked)
246
- releaseLock(lock);
247
- }
248
- }
249
- async tryRefresh() {
250
- try {
251
- await this.refreshSession();
252
- return true;
253
- }
254
- catch {
255
- return false;
256
- }
257
- }
258
- /** Resolve `--workspace` with a fallback to the profile default. */
259
- workspace(explicit) {
260
- return explicit || process.env.WJI_WORKSPACE || this.profile.workspace || undefined;
261
- }
262
- requireWorkspace(explicit) {
263
- const id = this.workspace(explicit);
264
- if (!id) {
265
- throw new Error('No workspace. Pass --workspace <id> or set a default with `wji ws use <id>`.');
266
- }
267
- return id;
268
- }
269
- }
@@ -1,95 +0,0 @@
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
- }