@shardflux/sdk 0.7.0 → 0.9.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/CHANGELOG.md +220 -1
- package/README.md +247 -10
- package/dist/account.d.ts +469 -0
- package/dist/account.js +620 -0
- package/dist/cell.d.ts +197 -8
- package/dist/cell.js +449 -31
- package/dist/client.d.ts +76 -5
- package/dist/client.js +114 -6
- package/dist/errors.d.ts +62 -3
- package/dist/errors.js +65 -1
- package/dist/executions.d.ts +120 -0
- package/dist/executions.js +99 -0
- package/dist/feedback.d.ts +67 -0
- package/dist/feedback.js +39 -0
- package/dist/generated/app-api.d.ts +12323 -8072
- package/dist/generated/cell-api.d.ts +463 -8
- package/dist/http.d.ts +7 -1
- package/dist/http.js +26 -7
- package/dist/index.d.ts +18 -7
- package/dist/index.js +5 -1
- package/dist/lifecycle.d.ts +27 -2
- package/dist/lifecycle.js +5 -0
- package/dist/progress.js +4 -2
- package/dist/templates.js +2 -2
- package/dist/tools.d.ts +72 -22
- package/dist/tools.js +156 -23
- package/dist/version-check.d.ts +101 -0
- package/dist/version-check.js +191 -0
- package/dist/workspace.d.ts +77 -5
- package/dist/workspace.js +164 -12
- package/package.json +2 -1
package/dist/tools.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* providers' formats and executeToolCall dispatches a model's tool call.
|
|
12
12
|
*/
|
|
13
13
|
import { CAPTURE_BARRIER } from "./cell.js";
|
|
14
|
+
import { newExecutionId } from "./executions.js";
|
|
14
15
|
export class ToolArgumentError extends Error {
|
|
15
16
|
tool;
|
|
16
17
|
issues;
|
|
@@ -51,6 +52,8 @@ export function validateArgs(schema, value, path = '$') {
|
|
|
51
52
|
issues.push(`${path} is too short`);
|
|
52
53
|
if (schema.maxLength !== undefined && value.length > schema.maxLength)
|
|
53
54
|
issues.push(`${path} is too long`);
|
|
55
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value))
|
|
56
|
+
issues.push(`${path} must match ${schema.pattern}`);
|
|
54
57
|
}
|
|
55
58
|
else if (t === 'integer' || t === 'number') {
|
|
56
59
|
if (typeof value !== 'number' || !Number.isFinite(value) || (t === 'integer' && !Number.isInteger(value)))
|
|
@@ -79,6 +82,12 @@ export function validateArgs(schema, value, path = '$') {
|
|
|
79
82
|
return issues;
|
|
80
83
|
}
|
|
81
84
|
const ALL = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
|
|
85
|
+
/** The tool permissions whose tools work on a file-first workspace (contracts §29.8): files and executions. */
|
|
86
|
+
const FILE_FIRST_TOOLS = ['exec', 'files'];
|
|
87
|
+
/** Changed paths returned to the model per execution (the rest is flagged `changed_truncated`). */
|
|
88
|
+
const MAX_CHANGED_LISTED = 200;
|
|
89
|
+
/** Tools served from a sleeping workspace's disk without waking it (contracts §26.4): the runner sends no hint. */
|
|
90
|
+
const DISK_READS = new Set(['read_file', 'list_files', 'search_files']);
|
|
82
91
|
const obj = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
83
92
|
const path = (description = 'Absolute path inside the workspace, e.g. /home/user/project/main.py') => ({ type: 'string', minLength: 1, maxLength: 4096, description });
|
|
84
93
|
const signal = { type: 'string', description: 'Signal name such as SIGTERM, SIGINT or SIGKILL.', minLength: 2, maxLength: 12 };
|
|
@@ -97,29 +106,70 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
97
106
|
});
|
|
98
107
|
const max = opts.maxOutputBytes ?? 65_536;
|
|
99
108
|
const prefix = opts.prefix ?? '';
|
|
100
|
-
|
|
109
|
+
// A file-first workspace (contracts §29) has files and executions only: no processes, terminals, version control or
|
|
110
|
+
// browser between calls, so those tools are not offered, and exec runs each command as an execution.
|
|
111
|
+
const fileFirst = (opts.mode ?? workspace.mode) === 'file_first';
|
|
112
|
+
const allowed = new Set((opts.tools ?? workspace.grantedTools ?? ALL).filter((t) => !fileFirst || FILE_FIRST_TOOLS.includes(t)));
|
|
113
|
+
const execParameters = obj({
|
|
114
|
+
command: { type: 'string', minLength: 1, maxLength: 100_000, description: 'Shell command line, e.g. "pip install -r requirements.txt && pytest -q".' },
|
|
115
|
+
cwd: { type: 'string', maxLength: 4096, description: 'Working directory (absolute).' },
|
|
116
|
+
timeout_ms: { type: 'integer', minimum: 1000, maximum: 3_600_000, description: 'Kill the command after this long (default 600000).' },
|
|
117
|
+
stdin: { type: 'string', maxLength: 1_000_000, description: 'Text written to stdin.' },
|
|
118
|
+
}, ['command']);
|
|
101
119
|
const defs = [
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
fileFirst
|
|
121
|
+
? {
|
|
122
|
+
name: 'exec',
|
|
123
|
+
permission: 'exec',
|
|
124
|
+
description: 'Run a shell command (Linux; bash -lc) in a fresh VM on the workspace’s files and return its exit code, stdout, stderr, the files it changed and the new tree revision. Only files under /home/user persist between calls: processes, background jobs and changes elsewhere (e.g. system packages) do not, so install dependencies into /home/user (e.g. a virtualenv) and start servers within the same command.',
|
|
125
|
+
parameters: execParameters,
|
|
126
|
+
run: async (a, o) => {
|
|
127
|
+
const executionId = newExecutionId();
|
|
128
|
+
opts.onExecution?.(executionId);
|
|
129
|
+
const r = await cell().executions.run(['bash', '-lc', String(a.command)], {
|
|
130
|
+
executionId,
|
|
131
|
+
...(typeof a.cwd === 'string' ? { cwd: a.cwd } : opts.defaultCwd ? { cwd: opts.defaultCwd } : {}),
|
|
132
|
+
timeoutMs: typeof a.timeout_ms === 'number' ? a.timeout_ms : 600_000,
|
|
133
|
+
...(typeof a.stdin === 'string' ? { stdin: a.stdin } : {}),
|
|
134
|
+
// The cell captures what the model can be given, and flags the rest as truncated.
|
|
135
|
+
outputLimitBytes: Math.max(1, Math.min(16_777_216, max)),
|
|
136
|
+
...(o.signal ? { signal: o.signal } : {}),
|
|
137
|
+
});
|
|
138
|
+
const out = clip(r.stdoutText, max);
|
|
139
|
+
const err = clip(r.stderrText, max);
|
|
140
|
+
const changed = r.changed.slice(0, MAX_CHANGED_LISTED).map((c) => ({ path: c.path, change: c.change, type: c.type }));
|
|
141
|
+
return {
|
|
142
|
+
exit_code: r.exitCode,
|
|
143
|
+
term_signal: r.termSignal,
|
|
144
|
+
timed_out: r.timedOut,
|
|
145
|
+
stdout: out.text,
|
|
146
|
+
stderr: err.text,
|
|
147
|
+
truncated: r.stdoutTruncated || r.stderrTruncated || out.truncated || err.truncated,
|
|
148
|
+
execution_id: r.executionId,
|
|
149
|
+
state: r.state,
|
|
150
|
+
tree_revision: r.treeRevision,
|
|
151
|
+
changed,
|
|
152
|
+
changed_truncated: r.changedTruncated || r.changed.length > changed.length,
|
|
153
|
+
...(r.error ? { error: { code: r.error.code, message: r.error.message, reason: r.errorReason } } : {}),
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
: {
|
|
158
|
+
name: 'exec',
|
|
159
|
+
permission: 'exec',
|
|
160
|
+
description: 'Run a shell command in the persistent remote workspace (Linux; bash -lc) and return its exit code, stdout and stderr. Files, installed packages and background processes persist between calls.',
|
|
161
|
+
parameters: execParameters,
|
|
162
|
+
run: async (a, o) => {
|
|
163
|
+
const r = await cell().exec.run(['bash', '-lc', String(a.command)], {
|
|
164
|
+
...(typeof a.cwd === 'string' ? { cwd: a.cwd } : opts.defaultCwd ? { cwd: opts.defaultCwd } : {}),
|
|
165
|
+
timeoutMs: typeof a.timeout_ms === 'number' ? a.timeout_ms : 600_000,
|
|
166
|
+
...(typeof a.stdin === 'string' ? { stdin: a.stdin } : {}),
|
|
167
|
+
maxOutputBytes: max,
|
|
168
|
+
...(o.signal ? { signal: o.signal } : {}),
|
|
169
|
+
});
|
|
170
|
+
return { exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, session_id: r.sessionId };
|
|
171
|
+
},
|
|
121
172
|
},
|
|
122
|
-
},
|
|
123
173
|
{
|
|
124
174
|
name: 'read_file',
|
|
125
175
|
permission: 'files',
|
|
@@ -159,6 +209,77 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
159
209
|
return { entries: r.entries.map((e) => ({ name: e.name, path: e.path, type: e.type, size: e.size, modified_at: e.modified_at })), truncated: r.truncated };
|
|
160
210
|
},
|
|
161
211
|
},
|
|
212
|
+
{
|
|
213
|
+
name: 'search_files',
|
|
214
|
+
permission: 'files',
|
|
215
|
+
description: 'Search file contents under a directory (or in one file) in the workspace (like grep -rn) and return the matching lines with path, line and column. The pattern is literal text unless regex is true (RE2 syntax). Binary files, symbolic links and .git/node_modules directories are skipped.',
|
|
216
|
+
parameters: obj({
|
|
217
|
+
path: path('Directory (or one file) to search, absolute, e.g. /home/user/project.'),
|
|
218
|
+
pattern: { type: 'string', minLength: 1, maxLength: 1000, description: 'Text to find, or an RE2 regular expression when regex is true.' },
|
|
219
|
+
regex: { type: 'boolean', description: 'Treat pattern as an RE2 regular expression.' },
|
|
220
|
+
case_insensitive: { type: 'boolean', description: 'Ignore case.' },
|
|
221
|
+
include: { type: 'array', maxItems: 32, items: { type: 'string', minLength: 1, maxLength: 256 }, description: 'Only files matching one of these gitignore-style globs: "*.py" matches the name at any depth, "src/**/*.ts" the path relative to path.' },
|
|
222
|
+
exclude: { type: 'array', maxItems: 32, items: { type: 'string', minLength: 1, maxLength: 256 }, description: 'Skip files and directories matching these globs, e.g. "build/" (default .git and node_modules; [] searches everything).' },
|
|
223
|
+
max_matches: { type: 'integer', minimum: 1, maximum: 5000, description: 'Stop after this many matches (default 200).' },
|
|
224
|
+
context_lines: { type: 'integer', minimum: 0, maximum: 5, description: 'Lines of context to return before and after each match.' },
|
|
225
|
+
}, ['path', 'pattern']),
|
|
226
|
+
run: async (a, o) => {
|
|
227
|
+
const r = await cell().files.search(String(a.path), String(a.pattern), {
|
|
228
|
+
...(typeof a.regex === 'boolean' ? { regex: a.regex } : {}),
|
|
229
|
+
...(typeof a.case_insensitive === 'boolean' ? { caseInsensitive: a.case_insensitive } : {}),
|
|
230
|
+
...(Array.isArray(a.include) ? { include: a.include } : {}),
|
|
231
|
+
...(Array.isArray(a.exclude) ? { exclude: a.exclude } : {}),
|
|
232
|
+
...(typeof a.max_matches === 'number' ? { maxMatches: a.max_matches } : {}),
|
|
233
|
+
...(typeof a.context_lines === 'number' ? { contextLines: a.context_lines } : {}),
|
|
234
|
+
...(o.signal ? { signal: o.signal } : {}),
|
|
235
|
+
});
|
|
236
|
+
// Whole matches only, up to the output budget; the rest is counted.
|
|
237
|
+
const matches = [];
|
|
238
|
+
let used = 0;
|
|
239
|
+
for (const m of r.matches) {
|
|
240
|
+
used += Buffer.byteLength(JSON.stringify(m), 'utf8');
|
|
241
|
+
if (used > max)
|
|
242
|
+
break;
|
|
243
|
+
matches.push(m);
|
|
244
|
+
}
|
|
245
|
+
const omitted = r.matches.length - matches.length;
|
|
246
|
+
return { matches, truncated: r.truncated || omitted > 0, stop_reason: r.stop_reason ?? null, omitted_matches: omitted, files_scanned: r.files_scanned };
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: 'edit_file',
|
|
251
|
+
permission: 'files',
|
|
252
|
+
description: 'Edit a text file in the workspace by replacing exact text. Each old_text must occur exactly once in the file (include enough surrounding lines to make it unique) unless replace_all is true. The edits apply in order and atomically: all of them or none. Fails without changing anything if the file changed since expected_revision; read it again then.',
|
|
253
|
+
parameters: obj({
|
|
254
|
+
path: path(),
|
|
255
|
+
edits: {
|
|
256
|
+
type: 'array',
|
|
257
|
+
minItems: 1,
|
|
258
|
+
maxItems: 100,
|
|
259
|
+
description: 'Edits applied in order.',
|
|
260
|
+
items: obj({
|
|
261
|
+
old_text: { type: 'string', minLength: 1, maxLength: 1_048_576, description: 'Exact text to replace, including whitespace and indentation.' },
|
|
262
|
+
new_text: { type: 'string', maxLength: 1_048_576, description: 'Replacement text (empty to delete).' },
|
|
263
|
+
replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring exactly one.' },
|
|
264
|
+
}, ['old_text', 'new_text']),
|
|
265
|
+
},
|
|
266
|
+
expected_revision: {
|
|
267
|
+
type: 'string',
|
|
268
|
+
pattern: '^[0-9a-f]{64}$',
|
|
269
|
+
description: 'The revision your edits are based on (the revision returned by the previous edit_file of this file). Default: the file’s revision read just before editing.',
|
|
270
|
+
},
|
|
271
|
+
}, ['path', 'edits']),
|
|
272
|
+
run: async (a, o) => {
|
|
273
|
+
const c = cell();
|
|
274
|
+
const file = String(a.path);
|
|
275
|
+
// Without a revision from the model, pin the edit to the content current now, so a concurrent change between
|
|
276
|
+
// this read and the patch is refused (revision_mismatch) instead of edited blindly.
|
|
277
|
+
const expected = typeof a.expected_revision === 'string' ? a.expected_revision : (await c.files.stat(file, { revision: true })).revision;
|
|
278
|
+
const edits = a.edits.map((e) => ({ oldText: e.old_text, newText: e.new_text, ...(e.replace_all !== undefined ? { replaceAll: e.replace_all } : {}) }));
|
|
279
|
+
const r = await c.files.patch({ path: file, edits, ...(expected !== undefined ? { expectedRevision: expected } : {}) }, o.signal ? { signal: o.signal } : {});
|
|
280
|
+
return { path: r.path, revision: r.revision, previous_revision: r.previous_revision, replacements: r.replacements ?? null, bytes_written: r.bytes_written };
|
|
281
|
+
},
|
|
282
|
+
},
|
|
162
283
|
{
|
|
163
284
|
name: 'list_processes',
|
|
164
285
|
permission: 'process',
|
|
@@ -296,6 +417,17 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
296
417
|
const issues = validateArgs(d.parameters, args);
|
|
297
418
|
if (issues.length > 0)
|
|
298
419
|
throw new ToolArgumentError(`${prefix}${d.name}`, issues);
|
|
420
|
+
// Fire and forget: a parked workspace starts restoring while this call is prepared (contracts §26.6). Not for
|
|
421
|
+
// the reads a sleeping workspace serves from its disk (§26.4): the hint would wake it for nothing.
|
|
422
|
+
if (opts.hint !== false && !DISK_READS.has(d.name)) {
|
|
423
|
+
workspace
|
|
424
|
+
.hint({
|
|
425
|
+
...(opts.agentLabel !== undefined ? { agentLabel: opts.agentLabel } : {}),
|
|
426
|
+
...(opts.wake !== undefined ? { wake: opts.wake } : {}),
|
|
427
|
+
...(opts.transitionTimeoutMs !== undefined ? { wakeTimeoutMs: opts.transitionTimeoutMs } : {}),
|
|
428
|
+
})
|
|
429
|
+
.catch(() => undefined);
|
|
430
|
+
}
|
|
299
431
|
// Read-your-writes: tool calls captured before this one are in the workspace before it runs (bounded).
|
|
300
432
|
const barrier = workspace[CAPTURE_BARRIER];
|
|
301
433
|
const pending = typeof barrier === 'function' ? barrier.call(workspace) : undefined;
|
|
@@ -305,7 +437,6 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
305
437
|
},
|
|
306
438
|
}));
|
|
307
439
|
}
|
|
308
|
-
/** OpenAI tool definitions: Chat Completions (`{type:'function', function:{...}}`) or Responses API. */
|
|
309
440
|
export function toOpenAITools(tools, opts = {}) {
|
|
310
441
|
return opts.api === 'responses'
|
|
311
442
|
? tools.map((t) => ({ type: 'function', name: t.name, description: t.description, parameters: t.parameters, strict: false }))
|
|
@@ -318,7 +449,9 @@ export function toAnthropicTools(tools) {
|
|
|
318
449
|
/**
|
|
319
450
|
* Runs a model's tool call: `arguments` may be the JSON string (OpenAI) or an object (Anthropic
|
|
320
451
|
* `input`). Throws for unknown tools and invalid arguments (ToolArgumentError). The call's `id` / `call_id` is passed
|
|
321
|
-
* to `execute` as `toolCallId` (0.7.0+), so `executeToolCall(capture.tools(tools), call)` records it.
|
|
452
|
+
* to `execute` as `toolCallId` (0.7.0+), so `executeToolCall(capture.tools(tools), call)` records it. `input` is
|
|
453
|
+
* `unknown` (0.8.0+), as in the Anthropic SDK's `ToolUseBlock`, so a tool_use block is passed as it is; `execute`
|
|
454
|
+
* validates it.
|
|
322
455
|
*/
|
|
323
456
|
export async function executeToolCall(tools, call, options = {}) {
|
|
324
457
|
const tool = tools.find((t) => t.name === call.name);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client version check (contracts §30.4; 0.9.0+). `GET /v1/client-versions` (unauthenticated) lists every published
|
|
3
|
+
* client with its `latest` and `minimum_supported` version.
|
|
4
|
+
*
|
|
5
|
+
* const s = await checkClientVersion(); // @shardflux/sdk at SDK_VERSION
|
|
6
|
+
* if (s.status !== 'current') console.error(s.message ?? s.status);
|
|
7
|
+
*
|
|
8
|
+
* Automatically, the first successful API response of a `Shardflux` or `ShardfluxAccount` client starts the same check
|
|
9
|
+
* in the background, once per process per package identity (one request, 3 s timeout, every error swallowed), and
|
|
10
|
+
* emits `process.emitWarning(message, { type: 'ShardfluxUpdateWarning', code: 'SHARDFLUX_UPDATE_AVAILABLE' })` when
|
|
11
|
+
* the package is outdated or unsupported. Opt out with the option `versionCheck: false`, or the environment
|
|
12
|
+
* `SHARDFLUX_NO_UPDATE_CHECK=1` (also `true`, `yes`, `on`) or `NO_UPDATE_NOTIFIER=1`. Tools built on the SDK pass their
|
|
13
|
+
* own identity (`versionCheck: { package, version }`) or `false`.
|
|
14
|
+
*/
|
|
15
|
+
import type { operations } from './generated/app-api.js';
|
|
16
|
+
type JsonOf<R> = R extends {
|
|
17
|
+
content: {
|
|
18
|
+
'application/json': infer T;
|
|
19
|
+
};
|
|
20
|
+
} ? T : never;
|
|
21
|
+
type Ok<Op> = Op extends {
|
|
22
|
+
responses: infer R;
|
|
23
|
+
} ? {
|
|
24
|
+
[K in keyof R]: K extends 200 | 201 | 202 ? JsonOf<R[K]> : never;
|
|
25
|
+
}[keyof R] : never;
|
|
26
|
+
/** The body of GET /v1/client-versions. */
|
|
27
|
+
export type ClientVersions = Ok<operations['getV1ClientVersions']>;
|
|
28
|
+
export type ClientVersionEntry = ClientVersions['clients'][number];
|
|
29
|
+
export type ClientEcosystem = ClientVersionEntry['ecosystem'];
|
|
30
|
+
export declare const DEFAULT_BASE_URL = "https://api.shardflux.dev";
|
|
31
|
+
export declare const SDK_PACKAGE = "@shardflux/sdk";
|
|
32
|
+
/** The check's request timeout (ms). */
|
|
33
|
+
export declare const VERSION_CHECK_TIMEOUT_MS = 3000;
|
|
34
|
+
/**
|
|
35
|
+
* `unsupported` (below `minimum_supported`), `outdated` (below `latest`), `current` (otherwise) or `unknown` (no entry
|
|
36
|
+
* for the package, `latest` null (not distributed yet), a version that does not parse, or the request failed).
|
|
37
|
+
*/
|
|
38
|
+
export type ClientVersionStatusKind = 'current' | 'outdated' | 'unsupported' | 'unknown';
|
|
39
|
+
export interface ClientVersionStatus {
|
|
40
|
+
status: ClientVersionStatusKind;
|
|
41
|
+
package: string;
|
|
42
|
+
ecosystem: ClientEcosystem;
|
|
43
|
+
/** The version this process runs. */
|
|
44
|
+
current: string;
|
|
45
|
+
latest: string | null;
|
|
46
|
+
minimumSupported: string | null;
|
|
47
|
+
upgradeCommand: string | null;
|
|
48
|
+
releaseNotesUrl: string | null;
|
|
49
|
+
/** The one-line notice, for `outdated` and `unsupported` only. */
|
|
50
|
+
message?: string;
|
|
51
|
+
}
|
|
52
|
+
/** A tool's own identity for the check (a CLI or server built on the SDK). */
|
|
53
|
+
export interface VersionCheckIdentity {
|
|
54
|
+
package: string;
|
|
55
|
+
version: string;
|
|
56
|
+
/** Default `npm`. */
|
|
57
|
+
ecosystem?: ClientEcosystem;
|
|
58
|
+
}
|
|
59
|
+
/** `true` (default): `@shardflux/sdk` at SDK_VERSION; `false`: no automatic check; or the tool's own identity. */
|
|
60
|
+
export type VersionCheckOption = boolean | VersionCheckIdentity;
|
|
61
|
+
export interface CheckClientVersionOptions {
|
|
62
|
+
/** Default https://api.shardflux.dev. */
|
|
63
|
+
baseUrl?: string;
|
|
64
|
+
/** Default: the SDK's default fetch. */
|
|
65
|
+
fetch?: typeof fetch;
|
|
66
|
+
/** Default `@shardflux/sdk`. */
|
|
67
|
+
package?: string;
|
|
68
|
+
/** Default SDK_VERSION (when `package` is `@shardflux/sdk`). */
|
|
69
|
+
version?: string;
|
|
70
|
+
/** Default `npm`. */
|
|
71
|
+
ecosystem?: ClientEcosystem;
|
|
72
|
+
/** Default 3000 ms. */
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
userAgent?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* -1, 0 or 1 as `a` is older than, the same as, or newer than `b`: `major.minor.patch` compare numerically, a
|
|
79
|
+
* pre-release (`0.8.0-rc.1`) sorts before its release, build metadata (`+local`) is ignored. `null` when either string
|
|
80
|
+
* is not such a version (the check then reports `unknown`).
|
|
81
|
+
*/
|
|
82
|
+
export declare function compareVersions(a: string, b: string): -1 | 0 | 1 | null;
|
|
83
|
+
/** The status of `package` at `version` from a GET /v1/client-versions body (no request). */
|
|
84
|
+
export declare function clientVersionStatus(body: unknown, identity: VersionCheckIdentity): ClientVersionStatus;
|
|
85
|
+
/**
|
|
86
|
+
* Asks the API whether this client is current (GET /v1/client-versions; one request, 3 s timeout by default). Never
|
|
87
|
+
* throws: a failed request, a non-2xx answer or a body that is not the documented JSON is `unknown`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function checkClientVersion(opts?: CheckClientVersionOptions): Promise<ClientVersionStatus>;
|
|
90
|
+
/** True when the environment turns the automatic check off (SHARDFLUX_NO_UPDATE_CHECK truthy, NO_UPDATE_NOTIFIER set). */
|
|
91
|
+
export declare function versionCheckDisabledByEnv(env?: Record<string, string | undefined> | undefined): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The HttpClient hook a client installs: after its first successful response, start the check for its identity
|
|
94
|
+
* unless it already ran in this process or is turned off. Returns undefined when the option turns the check off.
|
|
95
|
+
*/
|
|
96
|
+
export declare function versionCheckHook(option: VersionCheckOption | undefined, baseUrl: string, f: typeof fetch, userAgent: string): (() => void) | undefined;
|
|
97
|
+
/** Tests: resolves when every started background check has finished. */
|
|
98
|
+
export declare function settleVersionChecks(): Promise<void>;
|
|
99
|
+
/** Tests: forget which identities were checked in this process. */
|
|
100
|
+
export declare function resetVersionChecks(): void;
|
|
101
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { SDK_VERSION, buildUrl, defaultFetch } from "./http.js";
|
|
2
|
+
export const DEFAULT_BASE_URL = 'https://api.shardflux.dev';
|
|
3
|
+
export const SDK_PACKAGE = '@shardflux/sdk';
|
|
4
|
+
/** The check's request timeout (ms). */
|
|
5
|
+
export const VERSION_CHECK_TIMEOUT_MS = 3_000;
|
|
6
|
+
// major.minor.patch, then a SemVer pre-release (-rc.1) or a PEP 440 one (rc1, .dev0), then +build metadata (ignored).
|
|
7
|
+
// The same grammar as the Python SDK's compare_versions.
|
|
8
|
+
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)|\.?([A-Za-z][0-9A-Za-z]*(?:\.[0-9A-Za-z]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
9
|
+
function versionKey(version) {
|
|
10
|
+
if (typeof version !== 'string')
|
|
11
|
+
return null;
|
|
12
|
+
const m = VERSION.exec(version.trim());
|
|
13
|
+
if (!m)
|
|
14
|
+
return null;
|
|
15
|
+
const core = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
16
|
+
if (!core.every(Number.isSafeInteger))
|
|
17
|
+
return null;
|
|
18
|
+
const pre = m[4] ?? m[5];
|
|
19
|
+
if (pre === undefined)
|
|
20
|
+
return { core, pre: null };
|
|
21
|
+
// Numeric runs compare as numbers and sort before alphanumeric ones; "rc10" sorts after "rc2".
|
|
22
|
+
const tokens = [];
|
|
23
|
+
for (const ident of pre.split('.')) {
|
|
24
|
+
for (const part of ident.match(/\d+|\D+/g) ?? [])
|
|
25
|
+
tokens.push(/^\d+$/.test(part) ? [0, Number(part)] : [1, part]);
|
|
26
|
+
}
|
|
27
|
+
return { core, pre: tokens };
|
|
28
|
+
}
|
|
29
|
+
const sign = (n) => (n < 0 ? -1 : n > 0 ? 1 : 0);
|
|
30
|
+
function compareKeys(a, b) {
|
|
31
|
+
for (let i = 0; i < 3; i += 1)
|
|
32
|
+
if (a.core[i] !== b.core[i])
|
|
33
|
+
return sign(a.core[i] - b.core[i]);
|
|
34
|
+
if (a.pre === null || b.pre === null)
|
|
35
|
+
return a.pre === b.pre ? 0 : a.pre === null ? 1 : -1;
|
|
36
|
+
const n = Math.min(a.pre.length, b.pre.length);
|
|
37
|
+
for (let i = 0; i < n; i += 1) {
|
|
38
|
+
const [ka, va] = a.pre[i];
|
|
39
|
+
const [kb, vb] = b.pre[i];
|
|
40
|
+
if (ka !== kb)
|
|
41
|
+
return ka < kb ? -1 : 1;
|
|
42
|
+
if (va !== vb)
|
|
43
|
+
return va < vb ? -1 : 1;
|
|
44
|
+
}
|
|
45
|
+
return sign(a.pre.length - b.pre.length);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* -1, 0 or 1 as `a` is older than, the same as, or newer than `b`: `major.minor.patch` compare numerically, a
|
|
49
|
+
* pre-release (`0.8.0-rc.1`) sorts before its release, build metadata (`+local`) is ignored. `null` when either string
|
|
50
|
+
* is not such a version (the check then reports `unknown`).
|
|
51
|
+
*/
|
|
52
|
+
export function compareVersions(a, b) {
|
|
53
|
+
const ka = versionKey(a);
|
|
54
|
+
const kb = versionKey(b);
|
|
55
|
+
return ka && kb ? compareKeys(ka, kb) : null;
|
|
56
|
+
}
|
|
57
|
+
const str = (v) => (typeof v === 'string' && v.length > 0 ? v : null);
|
|
58
|
+
/** The status of `package` at `version` from a GET /v1/client-versions body (no request). */
|
|
59
|
+
export function clientVersionStatus(body, identity) {
|
|
60
|
+
const ecosystem = identity.ecosystem ?? 'npm';
|
|
61
|
+
const base = {
|
|
62
|
+
status: 'unknown',
|
|
63
|
+
package: identity.package,
|
|
64
|
+
ecosystem,
|
|
65
|
+
current: identity.version,
|
|
66
|
+
latest: null,
|
|
67
|
+
minimumSupported: null,
|
|
68
|
+
upgradeCommand: null,
|
|
69
|
+
releaseNotesUrl: null,
|
|
70
|
+
};
|
|
71
|
+
const clients = typeof body === 'object' && body !== null ? body.clients : undefined;
|
|
72
|
+
const entry = Array.isArray(clients)
|
|
73
|
+
? clients.find((c) => typeof c === 'object' && c !== null && c.package === identity.package && c.ecosystem === ecosystem)
|
|
74
|
+
: undefined;
|
|
75
|
+
if (entry === undefined)
|
|
76
|
+
return base;
|
|
77
|
+
const latest = str(entry.latest);
|
|
78
|
+
const minimum = str(entry.minimum_supported);
|
|
79
|
+
const upgrade = str(entry.upgrade_command) ?? (ecosystem === 'npm' ? `npm install ${identity.package}@latest` : `pip install --upgrade ${identity.package}`);
|
|
80
|
+
const out = { ...base, latest, minimumSupported: minimum, upgradeCommand: upgrade, releaseNotesUrl: str(entry.release_notes_url) };
|
|
81
|
+
// latest null: not distributed yet, so stay silent (also about minimum_supported).
|
|
82
|
+
if (latest === null)
|
|
83
|
+
return out;
|
|
84
|
+
const vsLatest = compareVersions(identity.version, latest);
|
|
85
|
+
if (vsLatest === null)
|
|
86
|
+
return out;
|
|
87
|
+
const vsMinimum = minimum === null ? null : compareVersions(identity.version, minimum);
|
|
88
|
+
if (vsMinimum === -1) {
|
|
89
|
+
return { ...out, status: 'unsupported', message: `${identity.package} ${identity.version} is no longer supported by the Shardflux API (minimum ${minimum}). Update: ${upgrade}` };
|
|
90
|
+
}
|
|
91
|
+
if (vsLatest === -1)
|
|
92
|
+
return { ...out, status: 'outdated', message: `${identity.package} ${identity.version} is outdated: ${latest} is available. Update: ${upgrade}` };
|
|
93
|
+
return { ...out, status: 'current' };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Asks the API whether this client is current (GET /v1/client-versions; one request, 3 s timeout by default). Never
|
|
97
|
+
* throws: a failed request, a non-2xx answer or a body that is not the documented JSON is `unknown`.
|
|
98
|
+
*/
|
|
99
|
+
export async function checkClientVersion(opts = {}) {
|
|
100
|
+
const pkg = opts.package ?? SDK_PACKAGE;
|
|
101
|
+
const version = opts.version ?? (pkg === SDK_PACKAGE ? SDK_VERSION : undefined);
|
|
102
|
+
const identity = { package: pkg, version: version ?? '', ecosystem: opts.ecosystem ?? 'npm' };
|
|
103
|
+
// Another package without its version cannot be compared.
|
|
104
|
+
if (version === undefined)
|
|
105
|
+
return clientVersionStatus(null, identity);
|
|
106
|
+
try {
|
|
107
|
+
const f = opts.fetch ?? defaultFetch();
|
|
108
|
+
const timeout = AbortSignal.timeout(opts.timeoutMs ?? VERSION_CHECK_TIMEOUT_MS);
|
|
109
|
+
const signal = opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout;
|
|
110
|
+
const res = await f(buildUrl(opts.baseUrl ?? DEFAULT_BASE_URL, '/v1/client-versions'), {
|
|
111
|
+
method: 'GET',
|
|
112
|
+
headers: { accept: 'application/json', 'user-agent': opts.userAgent ?? `shardflux-sdk-ts/${SDK_VERSION}` },
|
|
113
|
+
signal,
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
await res.body?.cancel().catch(() => undefined);
|
|
117
|
+
return clientVersionStatus(null, identity);
|
|
118
|
+
}
|
|
119
|
+
return clientVersionStatus(JSON.parse(await res.text()), identity);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return clientVersionStatus(null, identity);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
126
|
+
/** True when the environment turns the automatic check off (SHARDFLUX_NO_UPDATE_CHECK truthy, NO_UPDATE_NOTIFIER set). */
|
|
127
|
+
export function versionCheckDisabledByEnv(env = globalThis.process?.env) {
|
|
128
|
+
if (!env)
|
|
129
|
+
return false;
|
|
130
|
+
const own = env.SHARDFLUX_NO_UPDATE_CHECK;
|
|
131
|
+
if (own !== undefined && TRUTHY.has(own.trim().toLowerCase()))
|
|
132
|
+
return true;
|
|
133
|
+
const npm = env.NO_UPDATE_NOTIFIER;
|
|
134
|
+
return npm !== undefined && npm !== '';
|
|
135
|
+
}
|
|
136
|
+
/** Package identities already checked (or being checked) in this process. */
|
|
137
|
+
const started = new Set();
|
|
138
|
+
const inFlight = new Set();
|
|
139
|
+
function emitUpdateWarning(message) {
|
|
140
|
+
const p = globalThis.process;
|
|
141
|
+
if (p && typeof p.emitWarning === 'function')
|
|
142
|
+
p.emitWarning(message, { type: 'ShardfluxUpdateWarning', code: 'SHARDFLUX_UPDATE_AVAILABLE' });
|
|
143
|
+
else
|
|
144
|
+
console.warn(message);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The HttpClient hook a client installs: after its first successful response, start the check for its identity
|
|
148
|
+
* unless it already ran in this process or is turned off. Returns undefined when the option turns the check off.
|
|
149
|
+
*/
|
|
150
|
+
export function versionCheckHook(option, baseUrl, f, userAgent) {
|
|
151
|
+
if (option === false)
|
|
152
|
+
return undefined;
|
|
153
|
+
const identity = option === undefined || option === true ? { package: SDK_PACKAGE, version: SDK_VERSION } : option;
|
|
154
|
+
let fired = false;
|
|
155
|
+
return () => {
|
|
156
|
+
if (fired)
|
|
157
|
+
return;
|
|
158
|
+
fired = true;
|
|
159
|
+
startVersionCheck(identity, baseUrl, f, userAgent);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function startVersionCheck(identity, baseUrl, f, userAgent) {
|
|
163
|
+
try {
|
|
164
|
+
if (versionCheckDisabledByEnv())
|
|
165
|
+
return;
|
|
166
|
+
const key = `${identity.ecosystem ?? 'npm'}\u0000${identity.package}\u0000${identity.version}`;
|
|
167
|
+
if (started.has(key))
|
|
168
|
+
return;
|
|
169
|
+
started.add(key);
|
|
170
|
+
const run = checkClientVersion({ baseUrl, fetch: f, userAgent, package: identity.package, version: identity.version, ecosystem: identity.ecosystem ?? 'npm' })
|
|
171
|
+
.then((s) => {
|
|
172
|
+
if ((s.status === 'outdated' || s.status === 'unsupported') && s.message)
|
|
173
|
+
emitUpdateWarning(s.message);
|
|
174
|
+
})
|
|
175
|
+
.catch(() => undefined);
|
|
176
|
+
inFlight.add(run);
|
|
177
|
+
void run.finally(() => inFlight.delete(run));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Never fails the caller.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Tests: resolves when every started background check has finished. */
|
|
184
|
+
export async function settleVersionChecks() {
|
|
185
|
+
while (inFlight.size > 0)
|
|
186
|
+
await Promise.all([...inFlight]);
|
|
187
|
+
}
|
|
188
|
+
/** Tests: forget which identities were checked in this process. */
|
|
189
|
+
export function resetVersionChecks() {
|
|
190
|
+
started.clear();
|
|
191
|
+
}
|