@standchat/cli 0.1.0 → 0.1.2
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 +27 -7
- package/dist/commands.js +41 -2
- package/dist/config.js +9 -99
- package/dist/index.js +27 -8
- package/dist/multipart.js +7 -2
- package/dist/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,15 +4,16 @@ Command-line access for installing and managing Stand Chat. The CLI covers the 1
|
|
|
4
4
|
|
|
5
5
|
## Installation and browser handoff
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Install the official package from npm’s `latest` tag after the release is publicly published. Record `stand --version` for troubleshooting; if the official package is unavailable, stop rather than substituting an unrelated package:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global @standchat/cli
|
|
11
|
+
stand --version
|
|
11
12
|
stand auth login --setup --json
|
|
12
13
|
stand setup status --json
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
`auth login` emits a `device_authorization` event on stderr with a trusted verification URL and a separate public user code. Keep the command running while the human opens the URL, signs up or signs in, accepts terms and approves access. Do not ask the human to paste access tokens, cookies or refresh tokens into the conversation. Browser approval never permits the agent to invent identity details or accept terms for the human.
|
|
16
|
+
`auth login` emits a `device_authorization` event on stderr with a trusted verification URL and a separate public user code. Keep the command running while the human opens the URL, signs up or signs in, accepts terms and approves access. Do not ask the human to paste access tokens, cookies or refresh tokens into the conversation. Browser approval never permits the agent to invent identity details or accept terms for the human. Successful device authorization ends on “Stand CLI authorized”; the human can close that page and return to the terminal. The CLI confirms credential storage separately; existing browser onboarding may still be required.
|
|
16
17
|
|
|
17
18
|
`setup status` performs reads only. If it reports `browser_onboarding_required`, the human must open the indicated dashboard using the same account and complete the existing profile/organization onboarding form. Run `setup status` again afterward. A ready result means configuration may begin; it does not mean that a widget or chat has been verified. Disabled membership and unreadable account data fail closed. For a custom environment, provide its verified dashboard origin with `--app-url`; no dashboard address is guessed from an arbitrary API hostname.
|
|
18
19
|
|
|
@@ -49,7 +50,7 @@ stand standins set-enabled STANDIN_ID --enabled true --yes --json
|
|
|
49
50
|
stand standins status --json
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
Use only approved public website content, or approved project content within the existing instruction limits. Free Base-plan installation must work without paid uploads or sitemap features. Do not crawl localhost or private addresses. Check task completion and ingested documents before claiming that knowledge is ready. Attachment writes replace the complete list: read existing attachments and preserve those the user has not authorized removing.
|
|
53
|
+
Use only approved public website content, or approved project content within the existing instruction limits. On plans whose effective `capabilities.documentUploadAllowed` is true, upload an approved local document directly with `stand knowledge upload KNOWLEDGE_BASE_ID --file /absolute/path/approved-document.md --yes --json`; there is no need to publish it as a web page to crawl it. Explicitly approve sharing any private content with Stand and exposing its facts to visitors. Check ingestion availability, remaining capacity and `graceReadOnly` first. Free Base-plan installation must work without paid uploads or sitemap features. Do not crawl localhost or private addresses. Check task completion and ingested documents before claiming that knowledge is ready. Attachment writes replace the complete list: read existing attachments and preserve those the user has not authorized removing.
|
|
53
54
|
|
|
54
55
|
The account-wide switch is separate from each Stand-in's enablement. `stand standins master --enabled true --yes --json` affects existing enabled Stand-ins as well. Inspect them and obtain approval for that shared effect on an existing account. Site and existing Stand-in configuration updates are supported through `stand sites update` and `stand standins update`. Read the current resource and its request schema first: some settings and collections are complete replacements, and concurrent writes have no conditional revision protection.
|
|
55
56
|
|
|
@@ -70,16 +71,35 @@ stand standins update STANDIN_ID --body-file standin.json --yes --json
|
|
|
70
71
|
|
|
71
72
|
Account deletion, subscription/payment management, team-member mutations, initial account bootstrap and JavaScript experimentation opt-in are excluded. An owner must enable experimentation in the dashboard before an authorized client can edit scripts. API calls cannot enable either `advancedBehaviorEnabled` or `allowStandAdminBehavior`; a request that tries to enable them fails before other Site changes are saved. Read the current Site and preserve unrelated behavior. Supplying legacy `embedOptions` replaces behavior rules and resets experimentation consent.
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
Knowledge management has named commands over the same published API operations. Read `stand knowledge list --json` for effective plan capabilities and current ingestion constraints. Document uploads require `documentUploadAllowed` (normally Pro or Business); sitemap creation requires `sitemapAllowed`, and enabling nightly refresh requires `automaticRefreshAllowed` (Business). Disabling a preserved nightly setting remains possible after a downgrade. Backend ownership, worker availability, capacity and grace-state checks still apply.
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
stand knowledge upload KNOWLEDGE_BASE_ID --file /absolute/path/approved-document.md --yes --json
|
|
78
|
+
stand knowledge upload KNOWLEDGE_BASE_ID --file /absolute/path/replacement.md --source SOURCE_ID --yes --json
|
|
79
|
+
stand knowledge sitemap KNOWLEDGE_BASE_ID --body-file sitemap.json --yes --json
|
|
80
|
+
stand knowledge sources refresh SOURCE_ID --enabled true --yes --json
|
|
81
|
+
stand knowledge sources recrawl SOURCE_ID --yes --json
|
|
82
|
+
stand knowledge documents get DOCUMENT_ID --json
|
|
83
|
+
stand knowledge documents chunks DOCUMENT_ID --json
|
|
84
|
+
stand knowledge documents search DOCUMENT_ID --query 'approved question' --top-k 5 --json
|
|
85
|
+
stand knowledge search KNOWLEDGE_BASE_ID --query 'approved question' --top-k 5 --json
|
|
86
|
+
stand knowledge tasks cancel TASK_ID --yes --json
|
|
87
|
+
stand knowledge update KNOWLEDGE_BASE_ID --body-file knowledge.json --yes --json
|
|
88
|
+
stand knowledge agents KNOWLEDGE_BASE_ID --json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Uploads accept a nonempty `.pdf`, `.docx`, `.md`, `.markdown` or `.txt` file up to **10 MiB (10,485,760 bytes)**. `stand commands --json` exposes those limits, and `stand schema uploadKnowledgeBaseSource` describes the multipart request. `--source` replaces an existing uploaded source in the same knowledge base; it cannot replace a website or sitemap source. A source with an open indexing task must finish or be cancelled first. Existing completed content remains active during replacement; a queued task alone does not prove successful indexing. Inspect tasks and documents before using the new content. Sitemap input is `{"sitemapUrl":"https://example.com/sitemap.xml","dailyRefreshEnabled":false}`; request nightly refresh only when authorized and allowed.
|
|
92
|
+
|
|
93
|
+
`stand knowledge sources delete SOURCE_ID --yes` removes the source and its indexed documents; `stand knowledge documents delete DOCUMENT_ID --yes` removes the selected document. `stand knowledge delete KNOWLEDGE_BASE_ID --yes` first reads and displays connected Stand-ins. It requires both `knowledge:read` and `knowledge:write`; deleting an attached base additionally requires explicit `--force-detach` after approving the effect on those Stand-ins. These are destructive operations, so inspect the targets first and do not remove unrelated resources. The preview is a snapshot, not a revision guard: `--force-detach` removes all attachments present when deletion executes, including any added afterward. Stop if concurrent attachment changes make that effect uncertain; without force-detach, the server refuses deletion if attachments are present.
|
|
94
|
+
|
|
95
|
+
Multipart API operations remain available through `stand api … --form-file file=PATH`. Explicit bounded inspection is also available through the published API:
|
|
74
96
|
|
|
75
97
|
```sh
|
|
76
|
-
stand api POST /v1/knowledge-bases/KNOWLEDGE_BASE_ID/uploads \
|
|
77
|
-
--form-file file=/absolute/path/approved-document.pdf --yes --json
|
|
78
98
|
stand api GET /v1/knowledge-bases/KNOWLEDGE_BASE_ID/tasks \
|
|
79
99
|
--query-param view=inspection --query-param limit=20 --json
|
|
80
100
|
```
|
|
81
101
|
|
|
82
|
-
Explicit knowledge inspection returns bounded metadata pages; follow `nextCursor` as `after` with the same filters. The ordinary knowledge list commands preserve existing full list responses.
|
|
102
|
+
Explicit knowledge inspection returns bounded metadata pages; follow `nextCursor` as `after` with the same filters. The ordinary knowledge list commands preserve existing full list responses. Attachment writes replace the full list; preserve attachments outside the authorized change.
|
|
83
103
|
|
|
84
104
|
History export streams JSON to a new private file and validates the entire result before making it available. It never overwrites an existing file:
|
|
85
105
|
|
package/dist/commands.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { CliError, isObject, usage } from './errors.js';
|
|
3
3
|
import { BUNDLED_SCHEMA } from './contract.js';
|
|
4
|
+
import { KNOWLEDGE_UPLOAD, multipartBody } from './multipart.js';
|
|
4
5
|
const COMMAND_DEFINITIONS = [
|
|
5
6
|
{ "command": "org plan", "args": [], "description": "Read the current plan.", "scope": "org:read", "method": "GET", "path": "/v1/org/plan", "options": [], "confirmation": false, "bodyRequired": false },
|
|
6
7
|
{ "command": "org usage", "args": [], "description": "Read current plan usage.", "scope": "org:read", "method": "GET", "path": "/v1/org/usage", "options": [], "confirmation": false, "bodyRequired": false },
|
|
@@ -13,6 +14,20 @@ const COMMAND_DEFINITIONS = [
|
|
|
13
14
|
{ "command": "knowledge documents", "args": ["knowledgeBaseId"], "description": "Read indexed documents.", "scope": "knowledge:read", "method": "GET", "path": "/v1/knowledge-bases/{knowledgeBaseId}/documents", "options": [], "confirmation": false, "bodyRequired": false },
|
|
14
15
|
{ "command": "standins knowledge get", "args": ["standinProfileId"], "description": "Read knowledge attachments.", "scope": "knowledge:read", "method": "GET", "path": "/v1/reps/me/standins/{standinProfileId}/knowledge-bases", "options": [], "confirmation": false, "bodyRequired": false },
|
|
15
16
|
{ "command": "standins knowledge set", "args": ["standinProfileId"], "description": "Replace the complete approved attachment list.", "scope": "knowledge:write", "method": "PUT", "path": "/v1/reps/me/standins/{standinProfileId}/knowledge-bases", "options": ["body-file"], "confirmation": true, "bodyRequired": true },
|
|
17
|
+
{ command: 'knowledge upload', args: ['knowledgeBaseId'], description: 'Upload .pdf, .docx, .md, .markdown or .txt, at most 10 MiB. Requires --file PATH; --source SOURCE_ID replaces an existing uploaded source.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/{knowledgeBaseId}/uploads', options: ['file', 'source'], confirmation: true, upload: { fileOption: 'file', replacementSourceOption: 'source', ...KNOWLEDGE_UPLOAD } },
|
|
18
|
+
{ command: 'knowledge sitemap', args: ['knowledgeBaseId'], description: 'Ingest an approved public sitemap; body contains sitemapUrl and optional dailyRefreshEnabled.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/{knowledgeBaseId}/sitemaps', options: ['body-file'], confirmation: true, bodyRequired: true },
|
|
19
|
+
{ command: 'knowledge update', args: ['knowledgeBaseId'], description: 'Replace name and description; read and preserve the description when renaming.', scope: 'knowledge:write', method: 'PUT', path: '/v1/knowledge-bases/{knowledgeBaseId}', options: ['body-file'], confirmation: true, bodyRequired: true },
|
|
20
|
+
{ command: 'knowledge delete', args: ['knowledgeBaseId'], description: 'Show connected Stand-ins, then delete the base. --force-detach explicitly permits detaching them.', scope: 'knowledge:write', additionalScopes: ['knowledge:read'], method: 'DELETE', path: '/v1/knowledge-bases/{knowledgeBaseId}', options: ['force-detach'], confirmation: true },
|
|
21
|
+
{ command: 'knowledge agents', args: ['knowledgeBaseId'], description: 'Read the Stand-ins connected to a knowledge base.', scope: 'knowledge:read', method: 'GET', path: '/v1/knowledge-bases/{knowledgeBaseId}/connected-agents', options: [] },
|
|
22
|
+
{ command: 'knowledge search', args: ['knowledgeBaseId'], description: 'Search indexed knowledge with --query TEXT; --top-k is 1–20, default 5. Read-only.', scope: 'knowledge:read', method: 'POST', path: '/v1/knowledge-bases/{knowledgeBaseId}/search', options: ['query', 'top-k'], confirmation: false },
|
|
23
|
+
{ command: 'knowledge sources refresh', args: ['sourceId'], description: 'Set daily sitemap refresh with --enabled true|false; existing plan gates apply.', scope: 'knowledge:write', method: 'PUT', path: '/v1/knowledge-bases/sources/{sourceId}/sitemap-refresh', options: ['enabled'], confirmation: true },
|
|
24
|
+
{ command: 'knowledge sources recrawl', args: ['sourceId'], description: 'Recrawl an existing website or sitemap source.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/sources/{sourceId}/recrawl', options: [], confirmation: true },
|
|
25
|
+
{ command: 'knowledge sources delete', args: ['sourceId'], description: 'Delete a source and its indexed content.', scope: 'knowledge:write', method: 'DELETE', path: '/v1/knowledge-bases/sources/{sourceId}', options: [], confirmation: true },
|
|
26
|
+
{ command: 'knowledge documents get', args: ['documentId'], description: 'Read an indexed document and its details.', scope: 'knowledge:read', method: 'GET', path: '/v1/knowledge-bases/documents/{documentId}', options: [] },
|
|
27
|
+
{ command: 'knowledge documents chunks', args: ['documentId'], description: 'Read the indexed chunks of a document.', scope: 'knowledge:read', method: 'GET', path: '/v1/knowledge-bases/documents/{documentId}/chunks', options: [] },
|
|
28
|
+
{ command: 'knowledge documents search', args: ['documentId'], description: 'Search one document with --query TEXT; --top-k is 1–20, default 5. Read-only.', scope: 'knowledge:read', method: 'POST', path: '/v1/knowledge-bases/documents/{documentId}/search', options: ['query', 'top-k'], confirmation: false },
|
|
29
|
+
{ command: 'knowledge documents delete', args: ['documentId'], description: 'Delete one document and its indexed chunks.', scope: 'knowledge:write', method: 'DELETE', path: '/v1/knowledge-bases/documents/{documentId}', options: [], confirmation: true },
|
|
30
|
+
{ command: 'knowledge tasks cancel', args: ['taskId'], description: 'Cancel a knowledge ingestion task.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/tasks/{taskId}/cancel', options: [], confirmation: true },
|
|
16
31
|
{ command: 'standins master', args: [], description: 'Change the account-wide switch; affects other enabled Stand-ins.', scope: 'standins:write', method: 'POST', path: '/v1/reps/me/standin/toggle', options: ['enabled'], confirmation: true },
|
|
17
32
|
{ command: 'whoami', args: [], description: 'Get the authenticated user.', scope: 'profile:read', method: 'GET', path: '/v1/reps/me', options: [] },
|
|
18
33
|
{ command: 'profile get', args: [], description: 'Get your representative profile.', scope: 'profile:read', method: 'GET', path: '/v1/reps/profile', options: [] },
|
|
@@ -155,7 +170,9 @@ export async function requestBody(flags, stdin, signal) {
|
|
|
155
170
|
return body;
|
|
156
171
|
}
|
|
157
172
|
export function findCommand(words) {
|
|
158
|
-
|
|
173
|
+
// Prefer a specific subcommand over an existing shorter list alias.
|
|
174
|
+
const definition = COMMANDS.filter(item => item.command.split(' ').every((word, i) => words[i] === word))
|
|
175
|
+
.sort((left, right) => right.command.length - left.command.length)[0];
|
|
159
176
|
if (!definition)
|
|
160
177
|
return undefined;
|
|
161
178
|
const args = words.slice(definition.command.split(' ').length);
|
|
@@ -234,6 +251,28 @@ export async function executeCommand(client, command, args, flags, stdin, option
|
|
|
234
251
|
const body = preparedBody ?? await requestBody(flags, stdin, options.signal);
|
|
235
252
|
if (command.bodyRequired && !body)
|
|
236
253
|
usage('This command requires --body-file FILE (or - for stdin). Run stand schema OPERATION_ID for its request schema.');
|
|
254
|
+
if (command.command === 'knowledge upload') {
|
|
255
|
+
const file = stringFlag(flags, 'file');
|
|
256
|
+
if (!file)
|
|
257
|
+
usage('Knowledge upload requires --file PATH.');
|
|
258
|
+
const source = stringFlag(flags, 'source');
|
|
259
|
+
if (source !== undefined && (!source || source === '.' || source === '..' || /[\x00-\x20\x7f/\\]/.test(source)))
|
|
260
|
+
usage('--source must be a nonempty source identifier.');
|
|
261
|
+
const path = resolvePath(command, args);
|
|
262
|
+
const multipart = await multipartBody(path, command.method, source === undefined ? [] : [`sourceId=${source}`], [`file=${file}`], options.signal);
|
|
263
|
+
return client.request(path, { ...options, method: command.method, multipart });
|
|
264
|
+
}
|
|
265
|
+
if (command.command === 'knowledge delete') {
|
|
266
|
+
const path = resolvePath(command, args);
|
|
267
|
+
return client.request(flags['force-detach'] ? `${path}?forceDetach=true` : path, { ...options, method: command.method });
|
|
268
|
+
}
|
|
269
|
+
if (command.command === 'knowledge search' || command.command === 'knowledge documents search') {
|
|
270
|
+
const query = stringFlag(flags, 'query');
|
|
271
|
+
if (!query?.trim())
|
|
272
|
+
usage('Knowledge search requires --query with nonempty text.');
|
|
273
|
+
return client.request(resolvePath(command, args), { ...options, method: command.method,
|
|
274
|
+
body: { query, topK: integerFlag(flags, 'top-k', 5, 1, 20) } });
|
|
275
|
+
}
|
|
237
276
|
if (command.command === 'history export') {
|
|
238
277
|
const file = stringFlag(flags, 'output');
|
|
239
278
|
if (!file || file === '-')
|
|
@@ -285,7 +324,7 @@ export async function executeCommand(client, command, args, flags, stdin, option
|
|
|
285
324
|
usage('--email must contain an email address.');
|
|
286
325
|
return client.request(`${command.path}?${new URLSearchParams({ email })}`, options);
|
|
287
326
|
}
|
|
288
|
-
if (['standins set-enabled', 'standins master'].includes(command.command) || command.command === 'sites preferences update') {
|
|
327
|
+
if (['standins set-enabled', 'standins master', 'knowledge sources refresh'].includes(command.command) || command.command === 'sites preferences update') {
|
|
289
328
|
const enabled = stringFlag(flags, 'enabled');
|
|
290
329
|
if (enabled !== 'true' && enabled !== 'false')
|
|
291
330
|
usage('--enabled must be true or false.');
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { isAbsolute, join } from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
@@ -82,39 +82,6 @@ export async function writePrivateJson(path, data) {
|
|
|
82
82
|
await rm(temporary, { force: true });
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
// Keep the observed inode alive until recovery finishes. A pathname can be
|
|
86
|
-
// released and reused by another legacy client while we check the first PID.
|
|
87
|
-
async function observeCredentialLock(path) {
|
|
88
|
-
let file;
|
|
89
|
-
try {
|
|
90
|
-
file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
91
|
-
const stat = await file.stat({ bigint: true });
|
|
92
|
-
if (!stat.isFile() || stat.size > 128n * 1024n || (process.platform !== 'win32'
|
|
93
|
-
&& ((stat.mode & 63n) !== 0n || (process.getuid && stat.uid !== BigInt(process.getuid()))))) {
|
|
94
|
-
throw new CliError('UNSAFE_CONFIG', 'Invalid credential lock type, size, ownership, or permissions.', 4);
|
|
95
|
-
}
|
|
96
|
-
const buffer = Buffer.alloc(Number(stat.size) + 1);
|
|
97
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
98
|
-
let owner;
|
|
99
|
-
// A size change or short read makes this observation opaque.
|
|
100
|
-
if (BigInt(bytesRead) === stat.size) {
|
|
101
|
-
try {
|
|
102
|
-
owner = JSON.parse(buffer.toString('utf8', 0, bytesRead));
|
|
103
|
-
}
|
|
104
|
-
catch { /* Wait for an opaque owner. */ }
|
|
105
|
-
}
|
|
106
|
-
return { file, dev: stat.dev, ino: stat.ino, owner };
|
|
107
|
-
}
|
|
108
|
-
catch (error) {
|
|
109
|
-
await file?.close();
|
|
110
|
-
if (isObject(error) && error.code === 'ENOENT')
|
|
111
|
-
return undefined;
|
|
112
|
-
if (isObject(error) && error.code === 'ELOOP') {
|
|
113
|
-
throw new CliError('UNSAFE_CONFIG', 'Credential locks must be regular files, not symbolic links.', 4);
|
|
114
|
-
}
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
85
|
async function withPrivateFileLock(root, path, resource, fn, signal) {
|
|
119
86
|
const busyCode = resource === 'credential' ? 'CREDENTIAL_BUSY' : 'CONFIG_BUSY';
|
|
120
87
|
const title = resource === 'credential' ? 'Credential' : 'Configuration';
|
|
@@ -126,12 +93,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
|
|
|
126
93
|
await privateDirectory(queue);
|
|
127
94
|
const id = `${process.pid}-${randomUUID()}.json`;
|
|
128
95
|
const claim = join(queue, id);
|
|
129
|
-
const candidate = `${claim}.lock`;
|
|
130
96
|
const deadline = performance.now() + 120_000;
|
|
131
97
|
const wait = async () => {
|
|
132
98
|
cancelled();
|
|
133
99
|
if (performance.now() >= deadline)
|
|
134
|
-
throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes.
|
|
100
|
+
throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes. Queue: ${queue}. Repair this directory only after verifying no Stand CLI process is running; never remove a live process’s lock.`, 4);
|
|
135
101
|
await sleep(50, signal);
|
|
136
102
|
};
|
|
137
103
|
const dead = (pid) => {
|
|
@@ -154,12 +120,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
|
|
|
154
120
|
continue;
|
|
155
121
|
if (!isObject(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
|
|
156
122
|
|| name.split('-')[0] !== String(value.pid) || !Number.isSafeInteger(value.ticket) || Number(value.ticket) < 0) {
|
|
157
|
-
throw new CliError(busyCode, `${title} lock ownership is invalid.
|
|
123
|
+
throw new CliError(busyCode, `${title} lock ownership is invalid. Queue: ${queue}. Verify no Stand CLI process is running before repairing these files.`, 4);
|
|
158
124
|
}
|
|
159
125
|
if (dead(Number(value.pid))) {
|
|
160
126
|
// Names are unique and never reused. A competing cleanup cannot delete
|
|
161
127
|
// another process's replacement claim as it could with a fixed lock path.
|
|
162
|
-
await rm(`${file}.lock`, { force: true });
|
|
163
128
|
await rm(file, { force: true });
|
|
164
129
|
}
|
|
165
130
|
else
|
|
@@ -167,12 +132,12 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
|
|
|
167
132
|
}
|
|
168
133
|
return active;
|
|
169
134
|
};
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// peers;
|
|
173
|
-
|
|
174
|
-
await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
|
|
135
|
+
// Each attempt owns a unique claim for its whole lifetime, so stale cleanup
|
|
136
|
+
// never unlinks a new owner's claim. Publish choosing=0 atomically before
|
|
137
|
+
// inspecting peers; arrivals then observe us or wait for our ticket. The
|
|
138
|
+
// bakery queue is the only lock: there is no reusable PID-file pathname.
|
|
175
139
|
try {
|
|
140
|
+
await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
|
|
176
141
|
const ticket = Math.max(0, ...(await claims()).map(peer => peer.ticket)) + 1;
|
|
177
142
|
if (!Number.isSafeInteger(ticket))
|
|
178
143
|
throw new CliError(busyCode, `${title} lock ticket limit reached.`, 4);
|
|
@@ -180,65 +145,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
|
|
|
180
145
|
while ((await claims()).some(peer => peer.id !== id
|
|
181
146
|
&& (peer.ticket === 0 || peer.ticket < ticket || (peer.ticket === ticket && peer.id < id))))
|
|
182
147
|
await wait();
|
|
183
|
-
// Credential users retain the prior PID-file protocol for N-1 clients.
|
|
184
|
-
// Configuration serialization requires all writers to use this new lock.
|
|
185
|
-
// A complete candidate is linked exclusively, avoiding new empty locks
|
|
186
|
-
// if this process dies between creation and writing its PID.
|
|
187
|
-
const file = await open(candidate, 'wx', 0o600);
|
|
188
|
-
try {
|
|
189
|
-
await file.writeFile(String(process.pid));
|
|
190
|
-
await file.sync();
|
|
191
|
-
}
|
|
192
|
-
finally {
|
|
193
|
-
await file.close();
|
|
194
|
-
}
|
|
195
|
-
for (;;) {
|
|
196
|
-
cancelled();
|
|
197
|
-
try {
|
|
198
|
-
await link(candidate, path);
|
|
199
|
-
acquired = true;
|
|
200
|
-
break;
|
|
201
|
-
}
|
|
202
|
-
catch (error) {
|
|
203
|
-
if (!isObject(error) || error.code !== 'EEXIST')
|
|
204
|
-
throw error;
|
|
205
|
-
}
|
|
206
|
-
const observed = await observeCredentialLock(path);
|
|
207
|
-
try {
|
|
208
|
-
if (observed && Number.isSafeInteger(observed.owner) && Number(observed.owner) > 0 && dead(Number(observed.owner))) {
|
|
209
|
-
const current = await observeCredentialLock(path);
|
|
210
|
-
try {
|
|
211
|
-
// Holding the original descriptor prevents inode reuse. Only
|
|
212
|
-
// remove that same dead owner's lock, never a replacement owner.
|
|
213
|
-
// Bakery arbitration excludes another new-client recoverer;
|
|
214
|
-
// legacy clients never remove a lock owned by another process.
|
|
215
|
-
if (current && current.dev === observed.dev && current.ino === observed.ino && current.owner === observed.owner) {
|
|
216
|
-
cancelled();
|
|
217
|
-
await rm(path, { force: true });
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
finally {
|
|
221
|
-
await current?.file.close();
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
finally {
|
|
226
|
-
await observed?.file.close();
|
|
227
|
-
}
|
|
228
|
-
await wait();
|
|
229
|
-
}
|
|
230
148
|
cancelled();
|
|
231
149
|
return await fn();
|
|
232
150
|
}
|
|
233
151
|
finally {
|
|
234
|
-
|
|
235
|
-
if (acquired)
|
|
236
|
-
await rm(path, { force: true });
|
|
237
|
-
}
|
|
238
|
-
finally {
|
|
239
|
-
await rm(candidate, { force: true });
|
|
240
|
-
await rm(claim, { force: true });
|
|
241
|
-
}
|
|
152
|
+
await rm(claim, { force: true });
|
|
242
153
|
}
|
|
243
154
|
}
|
|
244
155
|
export class ConfigStore {
|
|
@@ -290,7 +201,6 @@ export class ConfigStore {
|
|
|
290
201
|
}
|
|
291
202
|
// Every mutation of the shared file must derive its result after acquiring
|
|
292
203
|
// this global lock. A per-profile lock cannot protect the other profile rows.
|
|
293
|
-
// Older CLI versions do not take this lock and must not write concurrently.
|
|
294
204
|
async update(name, change, signal) {
|
|
295
205
|
const selected = profileName(name);
|
|
296
206
|
return withPrivateFileLock(this.root, join(this.root, 'config.json.lock'), 'configuration', async () => {
|
package/dist/index.js
CHANGED
|
@@ -6,15 +6,15 @@ import { realpathSync } from 'node:fs';
|
|
|
6
6
|
import { CLI_VERSION } from './version.js';
|
|
7
7
|
import { AuthSession, ALLOWED_SCOPES, DEFAULT_SCOPES, SETUP_SCOPES, deviceLogin, scopesForLogin } from './auth.js';
|
|
8
8
|
import { ConfigStore, CredentialVault, profileName } from './config.js';
|
|
9
|
-
import { COMMANDS, executeCommand, findCommand, integerFlag, readInput, readSchema, requestBody, stringFlag } from './commands.js';
|
|
9
|
+
import { COMMANDS, executeCommand, findCommand, integerFlag, readInput, readSchema, requestBody, resolvePath, stringFlag } from './commands.js';
|
|
10
10
|
import { CliError, isObject, terminalJson, terminalText, usage } from './errors.js';
|
|
11
11
|
import { multipartBody } from './multipart.js';
|
|
12
12
|
import { publicOperation } from './contract.js';
|
|
13
13
|
import { setupStatus } from './setup.js';
|
|
14
14
|
import { ApiClient, apiUrl, safeUrl, sleep } from './transport.js';
|
|
15
15
|
const GLOBAL_OPTIONS = ['profile', 'json', 'no-input', 'yes', 'help', 'timeout', 'retries'];
|
|
16
|
-
const STRING_OPTIONS = ['profile', 'timeout', 'retries', 'api-url', 'issuer', 'scopes', 'credential-store', 'body-file', 'domain', 'name', 'enabled', 'language', 'query', 'page', 'size', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir', 'max-pages', 'interval', 'count', 'email', 'output', 'max-bytes', 'app-url'];
|
|
17
|
-
const BOOLEAN_OPTIONS = ['json', 'no-input', 'yes', 'help', 'all', 'watch', 'token-stdin', 'local-only', 'check', 'version', 'setup'];
|
|
16
|
+
const STRING_OPTIONS = ['profile', 'timeout', 'retries', 'api-url', 'issuer', 'scopes', 'credential-store', 'body-file', 'domain', 'name', 'enabled', 'language', 'query', 'page', 'size', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir', 'max-pages', 'interval', 'count', 'email', 'output', 'max-bytes', 'app-url', 'file', 'source', 'top-k'];
|
|
17
|
+
const BOOLEAN_OPTIONS = ['json', 'no-input', 'yes', 'help', 'all', 'watch', 'token-stdin', 'local-only', 'check', 'version', 'setup', 'force-detach'];
|
|
18
18
|
const HELP = `Stand Chat CLI (beta)\n\nUsage: stand <resource> <command> [arguments] [options]\n\n` +
|
|
19
19
|
COMMANDS.map(command => ` ${`${command.command}${command.args.map(arg => ` <${arg}>`).join(' ')}`.padEnd(34)} ${command.description}`).join('\n') +
|
|
20
20
|
`\n\n auth login Sign in using a browser and device code; read-only scopes by default.\n` +
|
|
@@ -73,7 +73,7 @@ const LEGACY_EMBED_WARNING = 'Legacy embedOptions merges option keys but replace
|
|
|
73
73
|
function legacyEmbedWarning(body) {
|
|
74
74
|
return body && isObject(body.embedOptions) ? LEGACY_EMBED_WARNING : undefined;
|
|
75
75
|
}
|
|
76
|
-
async function confirm(label, flags, signal, warning) {
|
|
76
|
+
async function confirm(label, flags, signal, warning, notSent = 'No request was sent.') {
|
|
77
77
|
if (flags.yes) {
|
|
78
78
|
if (warning)
|
|
79
79
|
process.stderr.write(Boolean(flags.json) || !process.stdout.isTTY
|
|
@@ -84,7 +84,7 @@ async function confirm(label, flags, signal, warning) {
|
|
|
84
84
|
if (warning)
|
|
85
85
|
label = `${label}. ${warning}`;
|
|
86
86
|
if (flags['no-input'] || !process.stdin.isTTY || !process.stderr.isTTY || flags['body-file'] === '-') {
|
|
87
|
-
throw new CliError('CONFIRMATION_REQUIRED', `${label} requires --yes.
|
|
87
|
+
throw new CliError('CONFIRMATION_REQUIRED', `${label} requires --yes. ${notSent}`, 2);
|
|
88
88
|
}
|
|
89
89
|
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
90
90
|
const cancellation = new AbortController();
|
|
@@ -97,11 +97,11 @@ async function confirm(label, flags, signal, warning) {
|
|
|
97
97
|
try {
|
|
98
98
|
const answer = await prompt.question(`${terminalText(label)}? Type yes to continue: `, { signal: cancellation.signal });
|
|
99
99
|
if (answer !== 'yes')
|
|
100
|
-
throw new CliError('CANCELLED',
|
|
100
|
+
throw new CliError('CANCELLED', `Operation cancelled. ${notSent}`, 130);
|
|
101
101
|
}
|
|
102
102
|
catch (error) {
|
|
103
103
|
if (cancellation.signal.aborted)
|
|
104
|
-
throw new CliError('CANCELLED',
|
|
104
|
+
throw new CliError('CANCELLED', `Operation cancelled. ${notSent}`, 130);
|
|
105
105
|
throw error;
|
|
106
106
|
}
|
|
107
107
|
finally {
|
|
@@ -310,8 +310,27 @@ export async function main(args = process.argv.slice(2), env = process.env, sign
|
|
|
310
310
|
usage('Unknown command. Run stand --help or stand commands --json.');
|
|
311
311
|
checkOptions(flags, match.definition.options);
|
|
312
312
|
const preparedBody = match.definition.command === 'sites update' ? await requestBody(flags, process.stdin, signal) : undefined;
|
|
313
|
+
let confirmationLabel = `${match.definition.command} ${match.args.join(' ')}`.trim();
|
|
314
|
+
const knowledgeDelete = match.definition.command === 'knowledge delete';
|
|
315
|
+
if (knowledgeDelete) {
|
|
316
|
+
// Keep the no-request guarantee for noninteractive invocations without --yes.
|
|
317
|
+
if (!flags.yes && (flags['no-input'] || !process.stdin.isTTY || !process.stderr.isTTY))
|
|
318
|
+
await confirm(confirmationLabel, flags, signal);
|
|
319
|
+
const connected = await client.request(`${resolvePath(match.definition, match.args)}/connected-agents`, options);
|
|
320
|
+
if (!isObject(connected) || !Array.isArray(connected.agents) || connected.agents.some(agent => !isObject(agent) || typeof agent.standinProfileId !== 'string' || !agent.standinProfileId.trim()
|
|
321
|
+
|| typeof agent.name !== 'string' || typeof agent.displayName !== 'string')) {
|
|
322
|
+
throw new CliError('INVALID_RESPONSE', 'Could not inspect connected Stand-ins. No delete request was sent.', 5);
|
|
323
|
+
}
|
|
324
|
+
process.stderr.write(`${terminalJson({ event: 'knowledge_delete_preview', knowledgeBaseId: match.args[0],
|
|
325
|
+
agents: connected.agents, forceDetach: Boolean(flags['force-detach']) }, !json)}\n`);
|
|
326
|
+
if (connected.agents.length && !flags['force-detach']) {
|
|
327
|
+
throw new CliError('KNOWLEDGE_ATTACHED', 'This knowledge base is connected to Stand-ins. Review the connected agents, then use --force-detach only if their detachment is authorized. No delete request was sent.', 2);
|
|
328
|
+
}
|
|
329
|
+
if (flags['force-detach'])
|
|
330
|
+
confirmationLabel += ` and detach all connected Stand-ins (${connected.agents.length} currently listed)`;
|
|
331
|
+
}
|
|
313
332
|
if (match.definition.confirmation)
|
|
314
|
-
await confirm(
|
|
333
|
+
await confirm(confirmationLabel, flags, signal, legacyEmbedWarning(preparedBody), knowledgeDelete ? 'No delete request was sent.' : undefined);
|
|
315
334
|
if ((flags.interval !== undefined || flags.count !== undefined) && !flags.watch)
|
|
316
335
|
usage('--interval and --count require --watch.');
|
|
317
336
|
const count = flags.watch ? integerFlag(flags, 'count', Number.MAX_SAFE_INTEGER, 1, 1_000_000) : 1;
|
package/dist/multipart.js
CHANGED
|
@@ -3,6 +3,10 @@ import { open } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, extname } from 'node:path';
|
|
4
4
|
import { CliError, isObject, usage } from './errors.js';
|
|
5
5
|
import { publicOperation } from './contract.js';
|
|
6
|
+
export const KNOWLEDGE_UPLOAD = {
|
|
7
|
+
extensions: ['.pdf', '.docx', '.md', '.markdown', '.txt'],
|
|
8
|
+
maxFileBytes: 10 * 1024 * 1024,
|
|
9
|
+
};
|
|
6
10
|
const MAX_TEXT_BYTES = 1024 * 1024;
|
|
7
11
|
const MAX_FORM_BYTES = 11 * 1024 * 1024; // Leave room inside the server's 12 MiB multipart envelope.
|
|
8
12
|
const IMAGE_UPLOAD_LIMITS = {
|
|
@@ -70,13 +74,14 @@ export async function multipartBody(path, method, strings, files, signal) {
|
|
|
70
74
|
const contentType = MIME[extname(filename).toLowerCase()] ?? 'application/octet-stream';
|
|
71
75
|
const feedback = operation.operationId === 'submitFeedback';
|
|
72
76
|
const imageLimit = typeof operation.operationId === 'string' ? IMAGE_UPLOAD_LIMITS[operation.operationId] : undefined;
|
|
73
|
-
if (operation.operationId === 'uploadKnowledgeBaseSource' && !
|
|
77
|
+
if (operation.operationId === 'uploadKnowledgeBaseSource' && !KNOWLEDGE_UPLOAD.extensions.some(extension => extension === extname(filename).toLowerCase()))
|
|
74
78
|
usage('Knowledge uploads support PDF, Markdown, text and Word .docx files.');
|
|
75
79
|
if (feedback && !contentType.startsWith('image/'))
|
|
76
80
|
usage('Feedback attachments must use a .png, .jpg, .jpeg, .webp or .gif image filename.');
|
|
77
81
|
if (imageLimit && !contentType.startsWith('image/'))
|
|
78
82
|
usage('Image uploads must use a .png, .jpg, .jpeg, .webp or .gif filename.');
|
|
79
|
-
const limit =
|
|
83
|
+
const limit = operation.operationId === 'uploadKnowledgeBaseSource' ? KNOWLEDGE_UPLOAD.maxFileBytes
|
|
84
|
+
: (feedback ? 4 : imageLimit ?? 10) * 1024 * 1024;
|
|
80
85
|
let handle;
|
|
81
86
|
try {
|
|
82
87
|
handle = await open(part.input, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|