@standchat/cli 0.0.0-stage → 0.1.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 +101 -2
- package/dist/auth.js +319 -0
- package/dist/commands.js +385 -0
- package/dist/config.js +444 -0
- package/dist/contract.js +37 -0
- package/dist/dpop.js +63 -0
- package/dist/errors.js +30 -0
- package/dist/export-validation.js +164 -0
- package/dist/index.js +349 -0
- package/dist/multipart.js +115 -0
- package/dist/openapi.json +1 -0
- package/dist/setup.js +48 -0
- package/dist/transport.js +322 -0
- package/dist/version.js +3 -0
- package/package.json +32 -4
package/dist/setup.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CliError, isObject, usage } from './errors.js';
|
|
2
|
+
import { safeUrl } from './transport.js';
|
|
3
|
+
function browserOnboardingUrl(profile, supplied) {
|
|
4
|
+
if (supplied !== undefined) {
|
|
5
|
+
const url = safeUrl(supplied);
|
|
6
|
+
if (new URL(supplied).pathname !== '/')
|
|
7
|
+
usage('--app-url must be the dashboard origin.');
|
|
8
|
+
return `${url.origin}/`;
|
|
9
|
+
}
|
|
10
|
+
const defaults = {
|
|
11
|
+
'https://api.stand.chat': 'https://app.stand.chat/',
|
|
12
|
+
'https://api.staging.stand.chat': 'https://app.staging.stand.chat/',
|
|
13
|
+
'http://localhost:8080': 'http://localhost:3000/',
|
|
14
|
+
'http://127.0.0.1:8080': 'http://127.0.0.1:3000/',
|
|
15
|
+
};
|
|
16
|
+
return defaults[profile.apiUrl] ?? null;
|
|
17
|
+
}
|
|
18
|
+
/** Readiness is separate from widget verification; this command never writes. */
|
|
19
|
+
export async function setupStatus(client, profile, options, appUrl) {
|
|
20
|
+
const onboardingUrl = browserOnboardingUrl(profile, appUrl);
|
|
21
|
+
const user = await client.request('/v1/reps/me', options);
|
|
22
|
+
if (!isObject(user) || typeof user.userId !== 'string' || !user.userId.trim())
|
|
23
|
+
throw new CliError('INVALID_RESPONSE', 'The API did not return an authenticated identity.', 5);
|
|
24
|
+
const result = await client.request('/v1/reps/profile', options);
|
|
25
|
+
if (!isObject(result) || typeof result.completed !== 'boolean')
|
|
26
|
+
throw new CliError('INVALID_RESPONSE', 'The API did not return browser onboarding status.', 5);
|
|
27
|
+
if (result.accountDisabled === true)
|
|
28
|
+
throw new CliError('ACCOUNT_DISABLED', 'Your organization membership is disabled. Ask the organization owner to restore access; no setup changes were made.', 4);
|
|
29
|
+
if (!result.completed)
|
|
30
|
+
return {
|
|
31
|
+
ready: false, status: 'browser_onboarding_required', userId: user.userId, onboardingUrl,
|
|
32
|
+
nextAction: onboardingUrl
|
|
33
|
+
? `Open ${onboardingUrl} in the browser with the same account, complete the existing onboarding form, then run stand setup status again.`
|
|
34
|
+
: 'Open the dashboard for this API environment with the same account and complete onboarding. Use --app-url with its verified origin to show the link, then run stand setup status again.',
|
|
35
|
+
configurationCreated: false, chatVerified: false,
|
|
36
|
+
};
|
|
37
|
+
if (!isObject(result.profile) || result.profile.userId !== user.userId
|
|
38
|
+
|| typeof result.profile.orgId !== 'string' || !result.profile.orgId.trim())
|
|
39
|
+
throw new CliError('INVALID_RESPONSE', 'The completed profile did not identify the authenticated user and organization. No setup changes were made.', 5);
|
|
40
|
+
// A completed profile alone does not establish active membership. This
|
|
41
|
+
// member-scoped read must succeed before offering configuration work.
|
|
42
|
+
const plan = await client.request('/v1/org/plan', options);
|
|
43
|
+
if (!isObject(plan))
|
|
44
|
+
throw new CliError('INVALID_RESPONSE', 'The API did not return the organization plan.', 5);
|
|
45
|
+
return { ready: true, status: 'ready_for_configuration', userId: user.userId, orgId: result.profile.orgId, plan,
|
|
46
|
+
nextAction: 'Inspect existing sites, Stand-ins and knowledge before creating the approved installation. Complete a real visitor chat before reporting success.',
|
|
47
|
+
configurationCreated: false, chatVerified: false };
|
|
48
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { CliError, isObject, usage } from './errors.js';
|
|
2
|
+
import { DELEGATED_SCOPES, operationScope } from './contract.js';
|
|
3
|
+
import { validateHistoryExport } from './export-validation.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { CLI_VERSION } from './version.js';
|
|
6
|
+
import { link, open, rm } from 'node:fs/promises';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
const WRITE_OUTCOME_WARNING = ' The write may have completed, but its response did not confirm the result. Read the affected resource before a separately approved retry; do not automatically retry.';
|
|
10
|
+
function unknownWrite(error) {
|
|
11
|
+
const message = error.message.replace(' A write may have succeeded; inspect state before retrying.', '');
|
|
12
|
+
return new CliError(error.code, message + WRITE_OUTCOME_WARNING, error.exitCode, error.status, error.retryAfter, error.reconciliation);
|
|
13
|
+
}
|
|
14
|
+
export function safeUrl(value, kind = 'api') {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return usage(`Invalid ${kind} URL.`);
|
|
21
|
+
}
|
|
22
|
+
const loopback = ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
|
|
23
|
+
if (url.username || url.password || url.search || url.hash
|
|
24
|
+
|| /[\s\\]/.test(value)
|
|
25
|
+
|| (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))) {
|
|
26
|
+
usage(`${kind} URL must use HTTPS (HTTP is allowed only on loopback), without credentials, query, or fragment.`);
|
|
27
|
+
}
|
|
28
|
+
if (kind === 'api') {
|
|
29
|
+
if (!['/', '/v1', '/v1/'].includes(url.pathname))
|
|
30
|
+
usage('API URL must be an origin, optionally ending in /v1.');
|
|
31
|
+
url.pathname = '/';
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
35
|
+
}
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
export function apiUrl(base, path) {
|
|
39
|
+
const origin = safeUrl(base).origin;
|
|
40
|
+
if (!path.startsWith('/v1/') || /[\\\x00-\x20\x7f]/.test(path) || path.includes('#')) {
|
|
41
|
+
usage('API path must start with /v1/ and contain no URL fragment, whitespace, or backslash.');
|
|
42
|
+
}
|
|
43
|
+
let decoded;
|
|
44
|
+
try {
|
|
45
|
+
decoded = decodeURIComponent(path.split('?')[0]);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return usage('Invalid encoded API path.');
|
|
49
|
+
}
|
|
50
|
+
if (decoded.split('/').some(part => part === '..' || part === '.') || decoded.includes('\\') || decoded.includes('//') || decoded.includes(';')) {
|
|
51
|
+
usage('API path must not contain traversal or ambiguous separators.');
|
|
52
|
+
}
|
|
53
|
+
const url = new URL(path, `${origin}/`);
|
|
54
|
+
if (url.origin !== origin || !url.pathname.startsWith('/v1/'))
|
|
55
|
+
usage('API path must stay on the configured API origin.');
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
export function issuerEndpoint(issuer, endpoint) {
|
|
59
|
+
if (typeof endpoint !== 'string')
|
|
60
|
+
throw new CliError('OIDC_METADATA', 'Issuer discovery is missing a required endpoint.', 4);
|
|
61
|
+
const base = safeUrl(issuer, 'issuer');
|
|
62
|
+
const url = safeUrl(endpoint, 'issuer');
|
|
63
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(`${base.pathname}/`)) {
|
|
64
|
+
throw new CliError('OIDC_METADATA', 'Issuer endpoint is outside the configured issuer. No credential was sent.', 4);
|
|
65
|
+
}
|
|
66
|
+
return url.href.replace(/\/$/, '');
|
|
67
|
+
}
|
|
68
|
+
export async function readBounded(response, maxBytes = MAX_RESPONSE_BYTES) {
|
|
69
|
+
if (Number(response.headers.get('content-length')) > maxBytes) {
|
|
70
|
+
await response.body?.cancel();
|
|
71
|
+
throw new CliError('RESPONSE_TOO_LARGE', `Response exceeds the ${maxBytes}-byte client limit. Use a smaller page or filter.`, 2);
|
|
72
|
+
}
|
|
73
|
+
if (!response.body)
|
|
74
|
+
return '';
|
|
75
|
+
const reader = response.body.getReader();
|
|
76
|
+
const chunks = [];
|
|
77
|
+
let size = 0;
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
const part = await reader.read();
|
|
81
|
+
if (part.done)
|
|
82
|
+
break;
|
|
83
|
+
size += part.value.length;
|
|
84
|
+
if (size > maxBytes) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
throw new CliError('RESPONSE_TOO_LARGE', `Response exceeds the ${maxBytes}-byte client limit. Use a smaller page or filter.`, 2);
|
|
87
|
+
}
|
|
88
|
+
chunks.push(part.value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
reader.releaseLock();
|
|
93
|
+
}
|
|
94
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
95
|
+
}
|
|
96
|
+
export function retryDelay(value, now = Date.now()) {
|
|
97
|
+
if (!value)
|
|
98
|
+
return null;
|
|
99
|
+
const seconds = /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - now;
|
|
100
|
+
return Number.isFinite(seconds) ? Math.max(0, seconds) : null;
|
|
101
|
+
}
|
|
102
|
+
export async function sleep(ms, signal) {
|
|
103
|
+
if (signal?.aborted)
|
|
104
|
+
throw new CliError('CANCELLED', 'Operation cancelled.', 130);
|
|
105
|
+
await new Promise((resolve, reject) => {
|
|
106
|
+
const cancel = () => { clearTimeout(timer); reject(new CliError('CANCELLED', 'Operation cancelled.', 130)); };
|
|
107
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', cancel); resolve(); }, ms);
|
|
108
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function fetchText(url, init, timeoutMs, maxBytes = MAX_RESPONSE_BYTES, onResponse) {
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const cancel = () => controller.abort();
|
|
114
|
+
let timedOut = false;
|
|
115
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
116
|
+
init.signal?.addEventListener('abort', cancel, { once: true });
|
|
117
|
+
if (init.signal?.aborted)
|
|
118
|
+
controller.abort();
|
|
119
|
+
try {
|
|
120
|
+
// Even same-origin redirects can point outside the API boundary; never follow.
|
|
121
|
+
const response = await fetch(url, { ...init, signal: controller.signal, redirect: 'error' });
|
|
122
|
+
onResponse?.(response);
|
|
123
|
+
const text = await readBounded(response, maxBytes);
|
|
124
|
+
return { response, text };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof CliError)
|
|
128
|
+
throw error;
|
|
129
|
+
if (init.signal?.aborted)
|
|
130
|
+
throw new CliError('CANCELLED', 'Operation cancelled.', 130);
|
|
131
|
+
if (timedOut)
|
|
132
|
+
throw new CliError('TIMEOUT', 'Request timed out. A write may have succeeded; inspect state before retrying.', 5);
|
|
133
|
+
throw new CliError('NETWORK', 'Request failed. Check connectivity and the configured endpoint. Redirects are not followed.', 5);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
init.signal?.removeEventListener('abort', cancel);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export class ApiClient {
|
|
141
|
+
base;
|
|
142
|
+
token;
|
|
143
|
+
loginHint;
|
|
144
|
+
constructor(base, token, loginHint) {
|
|
145
|
+
this.base = base;
|
|
146
|
+
this.token = token;
|
|
147
|
+
this.loginHint = loginHint;
|
|
148
|
+
safeUrl(base);
|
|
149
|
+
}
|
|
150
|
+
async download(path, destination, maxBytes, options = {}) {
|
|
151
|
+
const url = apiUrl(this.base, path);
|
|
152
|
+
const token = await this.token(options.signal);
|
|
153
|
+
if (!token || /\s/.test(token))
|
|
154
|
+
throw new CliError('AUTH_REQUIRED', 'A valid access token is required. Run stand auth login.', 4);
|
|
155
|
+
const target = resolve(destination);
|
|
156
|
+
const temporary = `${target}.${randomUUID()}.part`;
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
let timedOut = false;
|
|
159
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 30_000);
|
|
160
|
+
const cancel = () => controller.abort();
|
|
161
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
162
|
+
if (options.signal?.aborted)
|
|
163
|
+
controller.abort();
|
|
164
|
+
try {
|
|
165
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'User-Agent': `stand-cli/${CLI_VERSION}` }, signal: controller.signal, redirect: 'error' });
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
await response.body?.cancel();
|
|
168
|
+
throw new CliError(`HTTP_${response.status}`, `Export failed (${response.status}). No file was published.`, [401, 403].includes(response.status) ? 4 : 1, response.status, response.headers.get('retry-after') ?? undefined);
|
|
169
|
+
}
|
|
170
|
+
const contentType = response.headers.get('content-type')?.split(';')[0].trim() ?? '';
|
|
171
|
+
if (!/^application\/(?:[a-z0-9.-]+\+)?json$/i.test(contentType)) {
|
|
172
|
+
await response.body?.cancel();
|
|
173
|
+
throw new CliError('EXPORT_INVALID', 'Export response is not JSON. No file was published.', 5);
|
|
174
|
+
}
|
|
175
|
+
if (Number(response.headers.get('content-length')) > maxBytes) {
|
|
176
|
+
await response.body?.cancel();
|
|
177
|
+
throw new CliError('RESPONSE_TOO_LARGE', 'Export exceeds --max-bytes. Narrow the filters or raise the explicit limit.', 2);
|
|
178
|
+
}
|
|
179
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
180
|
+
let bytes = 0;
|
|
181
|
+
try {
|
|
182
|
+
const reader = response.body?.getReader();
|
|
183
|
+
if (reader) {
|
|
184
|
+
try {
|
|
185
|
+
while (true) {
|
|
186
|
+
const part = await reader.read();
|
|
187
|
+
if (part.done)
|
|
188
|
+
break;
|
|
189
|
+
bytes += part.value.length;
|
|
190
|
+
if (bytes > maxBytes) {
|
|
191
|
+
await reader.cancel();
|
|
192
|
+
throw new CliError('RESPONSE_TOO_LARGE', 'Export exceeds --max-bytes. Narrow the filters or raise the explicit limit.', 2);
|
|
193
|
+
}
|
|
194
|
+
await file.writeFile(part.value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
reader.releaseLock();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
await file.sync();
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await file.close();
|
|
205
|
+
}
|
|
206
|
+
const completion = await validateHistoryExport(temporary, controller.signal);
|
|
207
|
+
// link is exclusive: unlike rename, it cannot overwrite an existing file.
|
|
208
|
+
await link(temporary, target);
|
|
209
|
+
return { path: target, bytes, ...completion };
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (timedOut)
|
|
213
|
+
throw new CliError('TIMEOUT', 'Export timed out. No file was published.', 5);
|
|
214
|
+
if (error instanceof CliError)
|
|
215
|
+
throw error;
|
|
216
|
+
if (options.signal?.aborted)
|
|
217
|
+
throw new CliError('CANCELLED', 'Export cancelled. No file was published.', 130);
|
|
218
|
+
if (isObject(error) && error.code === 'EEXIST')
|
|
219
|
+
throw new CliError('OUTPUT_EXISTS', 'Output file already exists. Choose a new filename.', 2);
|
|
220
|
+
throw new CliError('EXPORT_FAILED', 'Export could not be downloaded or saved. Check connectivity, file permissions, and disk space.', 5);
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
// Also cancel on local open/write/link failures so the server stops producing
|
|
224
|
+
// customer data and releases its export slot promptly.
|
|
225
|
+
controller.abort();
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
228
|
+
await rm(temporary, { force: true });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async request(path, options = {}) {
|
|
232
|
+
const url = apiUrl(this.base, path);
|
|
233
|
+
const method = options.method ?? 'GET';
|
|
234
|
+
if (options.multipart && (options.body !== undefined || !['POST', 'PUT', 'PATCH'].includes(method)))
|
|
235
|
+
usage('Multipart requires a write method and cannot be combined with a JSON body.');
|
|
236
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
237
|
+
const maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BYTES;
|
|
238
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > MAX_RESPONSE_BYTES)
|
|
239
|
+
usage('Invalid response byte limit.');
|
|
240
|
+
const attempts = ['GET', 'HEAD'].includes(method) ? (options.retries ?? 2) + 1 : 1;
|
|
241
|
+
const accessToken = await this.token(options.signal);
|
|
242
|
+
if (!accessToken || /\s/.test(accessToken))
|
|
243
|
+
throw new CliError('AUTH_REQUIRED', 'A valid access token is required. Run stand auth login.', 4);
|
|
244
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
245
|
+
if (options.signal?.aborted)
|
|
246
|
+
throw new CliError('CANCELLED', 'Operation cancelled. No request was sent.', 130);
|
|
247
|
+
// Local serialization/approval failures are not dispatched writes.
|
|
248
|
+
const requestBody = options.multipart ?? (options.body === undefined ? undefined : JSON.stringify(options.body));
|
|
249
|
+
options.onDispatch?.();
|
|
250
|
+
if (options.signal?.aborted)
|
|
251
|
+
throw new CliError('CANCELLED', 'Operation cancelled. No request was sent.', 130);
|
|
252
|
+
let responseStatus;
|
|
253
|
+
let result;
|
|
254
|
+
try {
|
|
255
|
+
result = await fetchText(url, {
|
|
256
|
+
method,
|
|
257
|
+
headers: {
|
|
258
|
+
Accept: 'application/json, application/vnd.stand.knowledge-inspection+json',
|
|
259
|
+
Authorization: `Bearer ${accessToken}`,
|
|
260
|
+
'User-Agent': `stand-cli/${CLI_VERSION}`,
|
|
261
|
+
...(options.multipart || options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
262
|
+
},
|
|
263
|
+
body: requestBody,
|
|
264
|
+
signal: options.signal,
|
|
265
|
+
}, timeout, maxResponseBytes, response => { responseStatus = response.status; });
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
// A received 4xx is a definitive refusal even if its body is unreadable.
|
|
269
|
+
// Otherwise dispatch without a valid result cannot prove non-commit.
|
|
270
|
+
if (!['GET', 'HEAD'].includes(method)
|
|
271
|
+
&& (responseStatus === undefined || responseStatus < 400 || responseStatus >= 500)
|
|
272
|
+
&& error instanceof CliError)
|
|
273
|
+
throw unknownWrite(error);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
const { response, text } = result;
|
|
277
|
+
const writeWarning = !['GET', 'HEAD'].includes(method) && (response.ok || response.status >= 500)
|
|
278
|
+
? WRITE_OUTCOME_WARNING : '';
|
|
279
|
+
const inspection = method === 'GET' && /^\/v1\/knowledge-bases\/[^/]+\/(tasks|sources|documents)$/.test(url.pathname)
|
|
280
|
+
&& url.searchParams.get('view') === 'inspection';
|
|
281
|
+
if (response.ok && inspection && response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/vnd.stand.knowledge-inspection+json')
|
|
282
|
+
throw new CliError('INVALID_RESPONSE', 'The API did not return the requested inspection representation. No legacy list fallback is permitted.', 5);
|
|
283
|
+
let data = text || null;
|
|
284
|
+
if (text && response.headers.get('content-type')?.includes('json')) {
|
|
285
|
+
try {
|
|
286
|
+
data = JSON.parse(text);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
throw new CliError('INVALID_RESPONSE', `Server returned invalid JSON.${writeWarning}`, 5);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (response.ok)
|
|
293
|
+
return data;
|
|
294
|
+
const retryAfter = response.headers.get('retry-after');
|
|
295
|
+
const wait = retryDelay(retryAfter) ?? 250 * 2 ** attempt;
|
|
296
|
+
if ([408, 429, 502, 503, 504].includes(response.status) && attempt + 1 < attempts && wait <= 30_000) {
|
|
297
|
+
await sleep(wait, options.signal);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const apiError = isObject(data) && isObject(data.error) ? data.error : null;
|
|
301
|
+
const code = typeof apiError?.code === 'string' && /^[A-Za-z0-9_:-]{1,100}$/.test(apiError.code)
|
|
302
|
+
? apiError.code : `HTTP_${response.status}`;
|
|
303
|
+
// Never echo server bodies: a proxy or validation error can reflect secrets.
|
|
304
|
+
const requiredScope = code === 'INSUFFICIENT_SCOPE' && isObject(apiError?.details)
|
|
305
|
+
? apiError.details.requiredScope : undefined;
|
|
306
|
+
// Conditional permissions (for example organization rename within profile
|
|
307
|
+
// editing) are accepted only from the bundled scope allowlist.
|
|
308
|
+
const scope = typeof requiredScope === 'string' && DELEGATED_SCOPES.includes(requiredScope)
|
|
309
|
+
? requiredScope : operationScope(path, method);
|
|
310
|
+
const consentRequired = response.status === 403 && code === 'EXPERIMENTATION_CONSENT_REQUIRED';
|
|
311
|
+
const recovery = !consentRequired && [401, 403].includes(response.status)
|
|
312
|
+
? await this.loginHint?.(scope).catch(() => 'Run stand auth login with the permissions previously granted to this profile.') : undefined;
|
|
313
|
+
const hint = consentRequired ? 'The organization owner must enable custom JavaScript in the Stand dashboard under Sites > Chat widget behavior > Experimentation (Advanced) and accept the warning. API/CLI clients cannot enable experimentation or grant Stand-admin experimentation access. After owner enablement, authorized OAuth clients can manage scripts. Reload the site before editing; do not automatically retry. Logging in again or requesting broader scopes cannot enable it.'
|
|
314
|
+
: response.status === 401 ? `Authentication expired or was rejected. ${recovery ?? 'Run stand auth login; existing profile permissions are preserved.'}`
|
|
315
|
+
: response.status === 403 ? `Access denied. ${scope ? `This operation requires ${scope}. ` : ''}Check active membership and resource permissions. ${recovery ?? 'Run stand auth login with the required permissions.'}`
|
|
316
|
+
: response.status === 429 ? 'Rate limit reached. Retry after the reported interval.'
|
|
317
|
+
: `API request failed (${response.status}, ${code}).${writeWarning || ' Inspect the documented error code and request fields.'}`;
|
|
318
|
+
throw new CliError(code, hint, response.status === 401 || response.status === 403 ? 4 : 1, response.status, retryAfter ?? undefined);
|
|
319
|
+
}
|
|
320
|
+
throw new CliError('NETWORK', 'Request failed.', 5);
|
|
321
|
+
}
|
|
322
|
+
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@standchat/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Stand Chat command-line client for humans and agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stand": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "node scripts/clean.mjs && tsc && node scripts/copy-contract.mjs",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
20
|
+
"prepack": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "20.12.7",
|
|
24
|
+
"typescript": "5.4.5"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
|
+
},
|
|
30
|
+
"license": "UNLICENSED",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"stream-json": "3.7.0"
|
|
33
|
+
}
|
|
34
|
+
}
|