@panelwave/mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ import { Client, type CallToolResult, type FetchLike, type ReadResourceResult, type Resource, type Tool, type Transport, type VersionNegotiationOptions } from '@modelcontextprotocol/client';
2
+ /** A resource template as `resources/templates/list` returns it. */
3
+ export type RemoteResourceTemplate = {
4
+ uriTemplate: string;
5
+ name: string;
6
+ [key: string]: unknown;
7
+ };
8
+ export interface RemoteProxyOptions {
9
+ url: string;
10
+ token: string;
11
+ version: string;
12
+ /** Transport override (tests: an in-memory pair). Default: Streamable HTTP with the bearer header. */
13
+ transportFactory?: () => Transport;
14
+ /** fetch override for the default Streamable HTTP transport. */
15
+ fetch?: FetchLike;
16
+ versionNegotiation?: VersionNegotiationOptions;
17
+ /** Called after the remote tool list changed (and was re-read). */
18
+ onToolsChanged?: () => void;
19
+ /** Called after the remote resource list changed. */
20
+ onResourcesChanged?: () => void;
21
+ log?: (message: string) => void;
22
+ /** Per-request timeout; reset on every progress notification. Default 5 min. */
23
+ requestTimeoutMs?: number;
24
+ /** Hard cap for one tool call including progress resets. Default 30 min. */
25
+ maxCallMs?: number;
26
+ }
27
+ export interface CallOptions {
28
+ signal?: AbortSignal;
29
+ onprogress?: (p: {
30
+ progress: number;
31
+ total?: number;
32
+ message?: string;
33
+ }) => void;
34
+ }
35
+ /**
36
+ * Mirrors the hosted PanelWave MCP server: tools/list (names untouched),
37
+ * tools/call, resources/list, resources/templates/list and resources/read.
38
+ * Connects lazily and reconnects after transport failures, so the bridge
39
+ * starts (and its local tools work) even while the remote is unreachable.
40
+ */
41
+ export declare class RemoteProxy {
42
+ private readonly opts;
43
+ private client?;
44
+ private connecting?;
45
+ private toolCache?;
46
+ instructions?: string;
47
+ serverVersion?: {
48
+ name: string;
49
+ version: string;
50
+ };
51
+ /** Called after the remote tool list changed (the bridge re-announces it downstream). */
52
+ onToolsChanged?: () => void;
53
+ /** Called after the remote resource list changed. */
54
+ onResourcesChanged?: () => void;
55
+ constructor(opts: RemoteProxyOptions);
56
+ private log;
57
+ get connected(): boolean;
58
+ /** Connect (once); concurrent callers share the attempt, a failure is retried by the next call. */
59
+ connect(): Promise<Client>;
60
+ private transport;
61
+ private open;
62
+ /**
63
+ * Transport-level failures become BridgeErrors (UNAUTHENTICATED /
64
+ * UPSTREAM_UNAVAILABLE) and drop the connection; JSON-RPC errors from the
65
+ * remote pass through unchanged.
66
+ */
67
+ private mapFailure;
68
+ private drop;
69
+ private withClient;
70
+ /** All remote tools (every page), cached until the remote signals list_changed. */
71
+ listTools(): Promise<Tool[]>;
72
+ /** Whether `name` is a known remote tool (from the last listing). */
73
+ hasTool(name: string): boolean;
74
+ callTool(name: string, args: Record<string, unknown> | undefined, opts?: CallOptions): Promise<CallToolResult>;
75
+ listResources(): Promise<Resource[]>;
76
+ listResourceTemplates(): Promise<RemoteResourceTemplate[]>;
77
+ readResource(uri: string, signal?: AbortSignal): Promise<ReadResourceResult>;
78
+ close(): Promise<void>;
79
+ }
package/dist/remote.js ADDED
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RemoteProxy = void 0;
4
+ const client_1 = require("@modelcontextprotocol/client");
5
+ const errors_1 = require("./errors");
6
+ /**
7
+ * Mirrors the hosted PanelWave MCP server: tools/list (names untouched),
8
+ * tools/call, resources/list, resources/templates/list and resources/read.
9
+ * Connects lazily and reconnects after transport failures, so the bridge
10
+ * starts (and its local tools work) even while the remote is unreachable.
11
+ */
12
+ class RemoteProxy {
13
+ opts;
14
+ client;
15
+ connecting;
16
+ toolCache;
17
+ instructions;
18
+ serverVersion;
19
+ /** Called after the remote tool list changed (the bridge re-announces it downstream). */
20
+ onToolsChanged;
21
+ /** Called after the remote resource list changed. */
22
+ onResourcesChanged;
23
+ constructor(opts) {
24
+ this.opts = opts;
25
+ this.onToolsChanged = opts.onToolsChanged;
26
+ this.onResourcesChanged = opts.onResourcesChanged;
27
+ }
28
+ log(message) {
29
+ this.opts.log?.(message);
30
+ }
31
+ get connected() {
32
+ return !!this.client;
33
+ }
34
+ /** Connect (once); concurrent callers share the attempt, a failure is retried by the next call. */
35
+ async connect() {
36
+ if (this.client)
37
+ return this.client;
38
+ if (!this.connecting) {
39
+ this.connecting = this.open().finally(() => {
40
+ this.connecting = undefined;
41
+ });
42
+ }
43
+ return this.connecting;
44
+ }
45
+ transport() {
46
+ if (this.opts.transportFactory)
47
+ return this.opts.transportFactory();
48
+ return new client_1.StreamableHTTPClientTransport(new URL(this.opts.url), {
49
+ requestInit: { headers: { Authorization: `Bearer ${this.opts.token}`, 'User-Agent': `panelwave-mcp/${this.opts.version}` } },
50
+ ...(this.opts.fetch ? { fetch: this.opts.fetch } : {}),
51
+ });
52
+ }
53
+ async open() {
54
+ const client = new client_1.Client({ name: 'panelwave-mcp-bridge', version: this.opts.version }, {
55
+ capabilities: {},
56
+ versionNegotiation: this.opts.versionNegotiation ?? { mode: 'auto' },
57
+ listChanged: {
58
+ tools: {
59
+ onChanged: (error, items) => {
60
+ if (error) {
61
+ this.log(`remote tool list refresh failed: ${error.message}`);
62
+ this.toolCache = undefined;
63
+ }
64
+ else if (items) {
65
+ this.toolCache = items;
66
+ }
67
+ this.onToolsChanged?.();
68
+ },
69
+ },
70
+ resources: {
71
+ onChanged: () => this.onResourcesChanged?.(),
72
+ },
73
+ },
74
+ });
75
+ client.onclose = () => {
76
+ if (this.client === client) {
77
+ this.client = undefined;
78
+ this.toolCache = undefined;
79
+ }
80
+ };
81
+ client.onerror = (e) => this.log(`remote transport error: ${e.message}`);
82
+ try {
83
+ await client.connect(this.transport());
84
+ }
85
+ catch (e) {
86
+ await client.close().catch(() => undefined);
87
+ throw this.mapFailure(e, 'connect');
88
+ }
89
+ this.client = client;
90
+ this.instructions = client.getInstructions();
91
+ this.serverVersion = client.getServerVersion();
92
+ return client;
93
+ }
94
+ /**
95
+ * Transport-level failures become BridgeErrors (UNAUTHENTICATED /
96
+ * UPSTREAM_UNAVAILABLE) and drop the connection; JSON-RPC errors from the
97
+ * remote pass through unchanged.
98
+ */
99
+ mapFailure(e, what) {
100
+ if (e instanceof client_1.ProtocolError)
101
+ return e;
102
+ if (e instanceof errors_1.BridgeError)
103
+ return e;
104
+ const status = e instanceof client_1.SdkHttpError ? e.status : undefined;
105
+ if (e instanceof client_1.UnauthorizedError || status === 401) {
106
+ this.drop();
107
+ return new errors_1.BridgeError('UNAUTHENTICATED', 'PanelWave rejected PANELWAVE_TOKEN (expired, revoked or mistyped). Create a new personal access token in PanelWave (Profile → Connected apps & access tokens) and update the MCP client configuration.');
108
+ }
109
+ if (status === 403) {
110
+ return new errors_1.BridgeError('UNAUTHENTICATED', `PanelWave refused the request (HTTP 403). The token may lack the scopes this tool needs.`);
111
+ }
112
+ if (e instanceof client_1.SdkError && e.code === 'REQUEST_TIMEOUT') {
113
+ return new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', `The PanelWave server did not answer in time (${what}). Check the job with pw_jobs_get or retry.`);
114
+ }
115
+ if (e instanceof client_1.SdkError && e.code === 'INVALID_RESULT') {
116
+ return new errors_1.BridgeError('INTERNAL', `The PanelWave server sent a result the bridge could not validate: ${e.message}`);
117
+ }
118
+ this.drop();
119
+ const reason = e instanceof Error ? e.message : String(e);
120
+ return new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', `Cannot reach the PanelWave MCP server at ${this.opts.url} (${reason}). Local tools still work; retry in a moment.`);
121
+ }
122
+ drop() {
123
+ const c = this.client;
124
+ this.client = undefined;
125
+ this.toolCache = undefined;
126
+ void c?.close().catch(() => undefined);
127
+ }
128
+ async withClient(what, fn) {
129
+ const client = await this.connect();
130
+ try {
131
+ return await fn(client);
132
+ }
133
+ catch (e) {
134
+ throw this.mapFailure(e, what);
135
+ }
136
+ }
137
+ /** All remote tools (every page), cached until the remote signals list_changed. */
138
+ async listTools() {
139
+ if (this.client && this.toolCache)
140
+ return this.toolCache;
141
+ const tools = await this.withClient('tools/list', async (c) => {
142
+ const all = [];
143
+ let cursor;
144
+ do {
145
+ const page = await c.listTools(cursor ? { cursor } : undefined);
146
+ all.push(...page.tools);
147
+ cursor = page.nextCursor;
148
+ } while (cursor);
149
+ return all;
150
+ });
151
+ this.toolCache = tools;
152
+ return tools;
153
+ }
154
+ /** Whether `name` is a known remote tool (from the last listing). */
155
+ hasTool(name) {
156
+ return !!this.toolCache?.some((t) => t.name === name);
157
+ }
158
+ async callTool(name, args, opts = {}) {
159
+ // The client validates structured results against the listed output schemas — make sure it has them.
160
+ if (!this.toolCache || !this.client)
161
+ await this.listTools();
162
+ return this.withClient(`tools/call ${name}`, (c) => c.callTool({ name, arguments: args ?? {} }, {
163
+ signal: opts.signal,
164
+ onprogress: opts.onprogress,
165
+ timeout: this.opts.requestTimeoutMs ?? 5 * 60_000,
166
+ resetTimeoutOnProgress: true,
167
+ maxTotalTimeout: this.opts.maxCallMs ?? 30 * 60_000,
168
+ }));
169
+ }
170
+ async listResources() {
171
+ return this.withClient('resources/list', async (c) => {
172
+ if (!c.getServerCapabilities()?.resources)
173
+ return [];
174
+ const all = [];
175
+ let cursor;
176
+ do {
177
+ const page = await c.listResources(cursor ? { cursor } : undefined);
178
+ all.push(...page.resources);
179
+ cursor = page.nextCursor;
180
+ } while (cursor);
181
+ return all;
182
+ });
183
+ }
184
+ async listResourceTemplates() {
185
+ return this.withClient('resources/templates/list', async (c) => {
186
+ if (!c.getServerCapabilities()?.resources)
187
+ return [];
188
+ const all = [];
189
+ let cursor;
190
+ do {
191
+ const page = await c.listResourceTemplates(cursor ? { cursor } : undefined);
192
+ all.push(...page.resourceTemplates);
193
+ cursor = page.nextCursor;
194
+ } while (cursor);
195
+ return all;
196
+ });
197
+ }
198
+ async readResource(uri, signal) {
199
+ return this.withClient(`resources/read ${uri}`, (c) => c.readResource({ uri }, { signal }));
200
+ }
201
+ async close() {
202
+ const c = this.client;
203
+ this.client = undefined;
204
+ await c?.close().catch(() => undefined);
205
+ }
206
+ }
207
+ exports.RemoteProxy = RemoteProxy;
@@ -0,0 +1,7 @@
1
+ export interface ResolveOptions {
2
+ /** Allow path segments starting with "." (dotfiles, .ssh, .git …). Default true; pw_local_read_text passes false. */
3
+ allowHidden?: boolean;
4
+ }
5
+ /** `\\server\share`, `\\?\…`, `\\.\…` (and the forward-slash spellings Windows accepts). */
6
+ export declare function isUncOrDevicePath(p: string): boolean;
7
+ export declare function resolveAllowed(requested: string, allowedDirs: string[], opts?: ResolveOptions): string;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isUncOrDevicePath = isUncOrDevicePath;
37
+ exports.resolveAllowed = resolveAllowed;
38
+ const fs = __importStar(require("node:fs"));
39
+ const path = __importStar(require("node:path"));
40
+ const errors_1 = require("./errors");
41
+ /**
42
+ * Path sandbox of the local tools: every path must resolve — symlinks
43
+ * followed — inside one of the allowed directories (PANELWAVE_ALLOWED_DIRS).
44
+ * Relative paths are taken relative to the first allowed directory.
45
+ *
46
+ * The containment check runs on the resolved TEXT first, before any
47
+ * filesystem call: on Windows even a realpath of `\\host\share\x` opens an SMB
48
+ * connection and sends the user's NTLM hash to that host (security review
49
+ * 2026-09-30). UNC and device paths (`\\?\`, `\\.\`) are refused unless an
50
+ * allowed directory is itself on that share. Only then are symlinks resolved
51
+ * and the check repeated on the real path.
52
+ */
53
+ const caseInsensitive = process.platform === 'win32' || process.platform === 'darwin';
54
+ function real(p) {
55
+ try {
56
+ return fs.realpathSync.native(p);
57
+ }
58
+ catch {
59
+ // Not there (yet): resolve the nearest existing parent so a symlinked
60
+ // parent cannot smuggle the path out of the sandbox.
61
+ const parent = path.dirname(p);
62
+ if (parent === p)
63
+ return p;
64
+ return path.join(real(parent), path.basename(p));
65
+ }
66
+ }
67
+ function norm(p) {
68
+ return caseInsensitive ? p.toLowerCase() : p;
69
+ }
70
+ function relativeInside(child, parent) {
71
+ const rel = path.relative(norm(parent), norm(child));
72
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) ? rel : undefined;
73
+ }
74
+ /** `\\server\share`, `\\?\…`, `\\.\…` (and the forward-slash spellings Windows accepts). */
75
+ function isUncOrDevicePath(p) {
76
+ return /^[\\/]{2}/.test(p);
77
+ }
78
+ function outside(requested, allowedDirs) {
79
+ return new errors_1.BridgeError('INVALID_INPUT', `${requested} is outside the folders this bridge may read (${allowedDirs.join(', ')}). Add the folder to PANELWAVE_ALLOWED_DIRS in the MCP client configuration.`);
80
+ }
81
+ function resolveAllowed(requested, allowedDirs, opts = {}) {
82
+ if (!requested || typeof requested !== 'string')
83
+ throw new errors_1.BridgeError('INVALID_INPUT', 'Pass a path.');
84
+ if (requested.includes('\0'))
85
+ throw new errors_1.BridgeError('INVALID_INPUT', 'Invalid path.');
86
+ if (!allowedDirs.length) {
87
+ throw new errors_1.BridgeError('PRECONDITION_FAILED', 'No local folders are allowed. Set PANELWAVE_ALLOWED_DIRS in the MCP client configuration to use the local tools.');
88
+ }
89
+ const absolute = path.resolve(allowedDirs[0], requested);
90
+ // 1. Text-only containment, before touching the filesystem.
91
+ const unc = process.platform === 'win32' && isUncOrDevicePath(absolute);
92
+ const textual = allowedDirs.find((d) => relativeInside(absolute, path.resolve(d)) !== undefined);
93
+ if (!textual || (unc && !isUncOrDevicePath(path.resolve(textual))))
94
+ throw outside(requested, allowedDirs);
95
+ // 2. Symlinks and junctions resolved; the real path must still be inside a real allowed directory.
96
+ const resolved = real(absolute);
97
+ for (const d of allowedDirs) {
98
+ const rel = relativeInside(resolved, real(d));
99
+ if (rel === undefined)
100
+ continue;
101
+ if (opts.allowHidden === false && rel.split(/[\\/]/).some((seg) => seg.startsWith('.') && seg !== '.' && seg !== '..')) {
102
+ throw new errors_1.BridgeError('INVALID_INPUT', `${requested} is a hidden file or inside a hidden folder; the bridge does not read those.`);
103
+ }
104
+ return resolved;
105
+ }
106
+ throw outside(requested, allowedDirs);
107
+ }
package/dist/tool.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ /** A CallToolResult as the bridge produces it (text summary + structured data). */
3
+ export interface LocalToolResult {
4
+ [key: string]: unknown;
5
+ content: Array<{
6
+ type: 'text';
7
+ text: string;
8
+ }>;
9
+ structuredContent?: Record<string, unknown>;
10
+ isError?: boolean;
11
+ }
12
+ /** The subset of a remote CallToolResult the local tools read. */
13
+ export interface RemoteResult {
14
+ content?: unknown[];
15
+ structuredContent?: unknown;
16
+ isError?: boolean;
17
+ }
18
+ /** Calls a tool of the hosted PanelWave server (the bridge wires this to the RemoteProxy). */
19
+ export type RemoteCaller = (name: string, args: Record<string, unknown>, signal?: AbortSignal) => Promise<RemoteResult>;
20
+ export interface LocalToolContext {
21
+ allowedDirs: string[];
22
+ signal?: AbortSignal;
23
+ /** Progress reporter — a no-op when the client did not ask for progress. */
24
+ progress?: (progress: number, total?: number, message?: string) => Promise<void>;
25
+ /** Hosted tools, for local tools that combine local files with PanelWave (uploads). */
26
+ callRemote?: RemoteCaller;
27
+ }
28
+ export interface LocalTool {
29
+ name: string;
30
+ title: string;
31
+ description: string;
32
+ inputSchema: Record<string, unknown>;
33
+ outputSchema?: Record<string, unknown>;
34
+ annotations: Record<string, unknown>;
35
+ call(args: unknown, ctx: LocalToolContext): Promise<LocalToolResult>;
36
+ }
37
+ export declare const READ_ONLY: {
38
+ readOnlyHint: boolean;
39
+ destructiveHint: boolean;
40
+ idempotentHint: boolean;
41
+ openWorldHint: boolean;
42
+ };
43
+ export declare function errorResult(e: unknown): LocalToolResult;
44
+ /** Define a local tool: zod input/output, JSON Schemas derived, errors mapped to isError results. */
45
+ export declare function defineLocalTool<I extends z.ZodType, O extends z.ZodType>(def: {
46
+ name: string;
47
+ title: string;
48
+ description: string;
49
+ input: I;
50
+ output: O;
51
+ annotations?: Record<string, unknown>;
52
+ run(args: z.output<I>, ctx: LocalToolContext): Promise<{
53
+ text: string;
54
+ data: z.input<O>;
55
+ }>;
56
+ }): LocalTool;
57
+ export declare function formatBytes(n: number): string;
58
+ /** Run `fn` over `items` with at most `limit` in flight; results keep the input order. */
59
+ export declare function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
package/dist/tool.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.READ_ONLY = void 0;
4
+ exports.errorResult = errorResult;
5
+ exports.defineLocalTool = defineLocalTool;
6
+ exports.formatBytes = formatBytes;
7
+ exports.mapLimit = mapLimit;
8
+ const zod_1 = require("zod");
9
+ const errors_1 = require("./errors");
10
+ exports.READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
11
+ function jsonSchema(schema, io) {
12
+ const s = zod_1.z.toJSONSchema(schema, { io, unrepresentable: 'any' });
13
+ delete s.$schema;
14
+ return s;
15
+ }
16
+ function errorResult(e) {
17
+ const err = (0, errors_1.toBridgeError)(e);
18
+ const body = { code: err.code, message: err.message };
19
+ if (err.details !== undefined)
20
+ body.details = err.details;
21
+ return { isError: true, content: [{ type: 'text', text: `${err.code}: ${err.message}` }], structuredContent: body };
22
+ }
23
+ function parseArgs(schema, args) {
24
+ const parsed = schema.safeParse(args ?? {});
25
+ if (parsed.success)
26
+ return parsed.data;
27
+ const issues = parsed.error.issues.slice(0, 10).map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
28
+ throw new errors_1.BridgeError('INVALID_INPUT', `Invalid arguments — ${issues.join('; ')}`, { issues });
29
+ }
30
+ /** Define a local tool: zod input/output, JSON Schemas derived, errors mapped to isError results. */
31
+ function defineLocalTool(def) {
32
+ return {
33
+ name: def.name,
34
+ title: def.title,
35
+ description: def.description,
36
+ inputSchema: jsonSchema(def.input, 'input'),
37
+ outputSchema: jsonSchema(def.output, 'output'),
38
+ annotations: { title: def.title, ...(def.annotations ?? exports.READ_ONLY) },
39
+ async call(args, ctx) {
40
+ try {
41
+ const out = await def.run(parseArgs(def.input, args), ctx);
42
+ return { content: [{ type: 'text', text: out.text }], structuredContent: out.data };
43
+ }
44
+ catch (e) {
45
+ return errorResult(e);
46
+ }
47
+ },
48
+ };
49
+ }
50
+ function formatBytes(n) {
51
+ if (n < 1024)
52
+ return `${n} B`;
53
+ if (n < 1024 * 1024)
54
+ return `${(n / 1024).toFixed(1)} KB`;
55
+ if (n < 1024 * 1024 * 1024)
56
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
57
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
58
+ }
59
+ /** Run `fn` over `items` with at most `limit` in flight; results keep the input order. */
60
+ async function mapLimit(items, limit, fn) {
61
+ const out = new Array(items.length);
62
+ let next = 0;
63
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
64
+ while (next < items.length) {
65
+ const i = next++;
66
+ out[i] = await fn(items[i], i);
67
+ }
68
+ });
69
+ await Promise.all(workers);
70
+ return out;
71
+ }
@@ -0,0 +1,76 @@
1
+ import { type FileKind } from './files';
2
+ import { type RemoteCaller } from './tool';
3
+ /** Kinds the asset library accepts. */
4
+ export declare const UPLOADABLE_KINDS: ReadonlySet<FileKind>;
5
+ export declare const MAX_FILES = 500;
6
+ export declare const MAX_ATTEMPTS = 3;
7
+ export declare function sha256File(file: string, signal?: AbortSignal): Promise<string>;
8
+ /** Call a hosted tool and return its structured result; an isError result is re-raised with the remote code. */
9
+ export declare function remoteData(call: RemoteCaller, name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<Record<string, unknown>>;
10
+ export interface UploadFile {
11
+ /** Absolute path (inside the sandbox). */
12
+ abs: string;
13
+ /** How the caller named it (relative path or as given). */
14
+ display: string;
15
+ size: number;
16
+ }
17
+ export interface UploadRow {
18
+ path: string;
19
+ filename: string;
20
+ ok: boolean;
21
+ status: 'uploaded' | 'exists' | 'failed' | 'cancelled';
22
+ assetId?: string;
23
+ deduplicated: boolean;
24
+ kind?: string;
25
+ mimeType: string;
26
+ sizeBytes: number;
27
+ sha256?: string;
28
+ width?: number;
29
+ height?: number;
30
+ mode?: 'single' | 'multipart';
31
+ attempts?: number;
32
+ processing?: boolean;
33
+ warnings: string[];
34
+ error?: {
35
+ code: string;
36
+ message: string;
37
+ };
38
+ }
39
+ export interface UploadOptions {
40
+ workId: string;
41
+ files: UploadFile[];
42
+ callRemote: RemoteCaller;
43
+ folderId?: string;
44
+ tags?: string[];
45
+ skipDuplicates: boolean;
46
+ concurrency: number;
47
+ signal?: AbortSignal;
48
+ /** Monotonic byte progress over the whole batch. */
49
+ onProgress?: (doneBytes: number, totalBytes: number, message: string) => void;
50
+ fetch?: typeof fetch;
51
+ /** Tests only: allow plain-http upload hosts beyond loopback. */
52
+ allowInsecureUploadHosts?: boolean;
53
+ /** Base backoff between attempts (tripled per attempt). Default 500 ms. */
54
+ retryDelayMs?: number;
55
+ }
56
+ /**
57
+ * Upload URLs come from the hosted server. Accept only https, or plain http to a
58
+ * loopback host (a local MinIO in development) — never another scheme or a
59
+ * plain-http host on the network (security review 2026-09-30).
60
+ */
61
+ export declare function assertUploadTarget(url: string, opts?: Pick<UploadOptions, 'allowInsecureUploadHosts'>): string;
62
+ /** Headers for the storage PUT: never credentials or hop-by-hop headers, whatever the server suggests. */
63
+ export declare function safeUploadHeaders(headers: Record<string, string> | undefined): Record<string, string>;
64
+ /**
65
+ * Upload local files into a work's asset library through the hosted tools
66
+ * (pw_assets_begin_upload → PUT / multipart parts → pw_assets_complete_upload).
67
+ * Files run `concurrency` at a time; parts of one file run in order, each read
68
+ * from disk just before its PUT, so memory stays at one part per file.
69
+ */
70
+ export declare function uploadBatch(opts: UploadOptions): Promise<UploadRow[]>;
71
+ /** Resolve (and create when missing) a folder path like "Chapter 1/Panels". */
72
+ export declare function ensureFolder(call: RemoteCaller, workId: string, folderPath: string, signal?: AbortSignal): Promise<{
73
+ folderId: string;
74
+ created: boolean;
75
+ }>;
76
+ export declare const uploadFiles: import("./tool").LocalTool;