neoctl-web 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.
@@ -1,147 +1,147 @@
1
- import fs from 'node:fs';
2
- import fsp from 'node:fs/promises';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
-
6
- export class DownloadRegistry {
7
- constructor() {
8
- this.entries = new Map();
9
- }
10
-
11
- add(entry) {
12
- const id = crypto.randomUUID();
13
- const full = {
14
- id,
15
- createdAt: Date.now(),
16
- ...entry,
17
- };
18
- this.entries.set(id, full);
19
- return full;
20
- }
21
-
22
- get(id) {
23
- const entry = this.entries.get(String(id || ''));
24
- if (!entry) return undefined;
25
- if (entry.expiresAt && Date.now() > entry.expiresAt) {
26
- this.entries.delete(entry.id);
27
- return undefined;
28
- }
29
- return entry;
30
- }
31
- }
32
-
33
- export function createExposeDownloadsTool(options) {
34
- const ttlMs = options.ttlMs ?? 30 * 60 * 1000;
35
-
36
- return {
37
- name: 'expose_downloads',
38
- description:
39
- 'Expose one or more existing local files for the web user to download. Input must be absolute file paths. The result follows neoctl.resource-link.v1: copy each downloads[].markdown value verbatim into the final response, without rewriting or prefixing its link.',
40
- inputSchema: {
41
- type: 'object',
42
- properties: {
43
- paths: {
44
- type: 'array',
45
- items: { type: 'string' },
46
- description: 'Absolute file paths to expose for browser download.',
47
- },
48
- },
49
- required: ['paths'],
50
- additionalProperties: false,
51
- },
52
- metadata: {
53
- readOnly: true,
54
- concurrent: true,
55
- visible: true,
56
- requiresApproval: false,
57
- maxResultSizeChars: 12000,
58
- },
59
- validate(input) {
60
- const paths = Array.isArray(input?.paths)
61
- ? input.paths.map((item) => String(item || '').trim()).filter(Boolean)
62
- : [];
63
- if (!paths.length) throw new Error('paths must be a non-empty array');
64
- if (paths.length > 20) throw new Error('too many paths; maximum is 20');
65
- for (const filePath of paths) {
66
- if (!path.isAbsolute(filePath)) throw new Error(`path must be absolute: ${filePath}`);
67
- }
68
- return { paths };
69
- },
70
- async execute(input, context) {
71
- const downloads = [];
72
- for (const rawPath of input.paths) {
73
- const absolutePath = path.resolve(rawPath);
74
- const stat = await fsp.stat(absolutePath).catch(() => undefined);
75
- if (!stat) throw new Error(`file does not exist: ${absolutePath}`);
76
- if (!stat.isFile()) throw new Error(`path is not a file: ${absolutePath}`);
77
- const entry = options.registry.add({
78
- absolutePath,
79
- filename: path.basename(absolutePath),
80
- sizeBytes: stat.size,
81
- expiresAt: Date.now() + ttlMs,
82
- sessionId: context.session?.sessionId,
83
- });
84
- const url = `/api/downloads/${encodeURIComponent(entry.id)}`;
85
- const reference = `sandbox:${url}`;
86
- downloads.push({
87
- id: entry.id,
88
- filename: entry.filename,
89
- sizeBytes: entry.sizeBytes,
90
- url,
91
- reference,
92
- markdown: `[${entry.filename.replace(/([\\\]])/g, '\\$1')}](${reference})`,
93
- expiresAt: new Date(entry.expiresAt).toISOString(),
94
- expiresAtEpochMs: entry.expiresAt,
95
- });
96
- }
97
- await options.onExpose?.({ sessionId: context.session?.sessionId, downloads });
98
- return {
99
- ok: true,
100
- output: {
101
- resourceProtocol: 'neoctl.resource-link.v1',
102
- usage: 'Copy downloads[].markdown verbatim when linking the resource. Do not alter the reference URI.',
103
- downloads,
104
- _ui: {
105
- resources: downloads.map((item) => ({
106
- kind: 'download',
107
- url: item.url,
108
- reference: item.reference,
109
- label: item.filename,
110
- downloadName: item.filename,
111
- sizeBytes: item.sizeBytes,
112
- expiresAt: item.expiresAtEpochMs,
113
- })),
114
- },
115
- },
116
- summary: `Exposed ${downloads.length} file(s) for browser download.`,
117
- };
118
- },
119
- };
120
- }
121
-
122
- export function serveDownload(registry, req, res, id) {
123
- const entry = registry.get(id);
124
- if (!entry) {
125
- res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
126
- res.end(JSON.stringify({ error: 'download not found or expired' }));
127
- return;
128
- }
129
-
130
- res.writeHead(200, {
131
- 'Content-Type': 'application/octet-stream',
132
- 'Content-Length': String(entry.sizeBytes),
133
- 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`,
134
- 'Cache-Control': 'no-store',
135
- });
136
-
137
- fs.createReadStream(entry.absolutePath)
138
- .on('error', () => {
139
- if (!res.headersSent) {
140
- res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
141
- res.end('failed to read file');
142
- } else {
143
- res.destroy();
144
- }
145
- })
146
- .pipe(res);
147
- }
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export class DownloadRegistry {
7
+ constructor() {
8
+ this.entries = new Map();
9
+ }
10
+
11
+ add(entry) {
12
+ const id = crypto.randomUUID();
13
+ const full = {
14
+ id,
15
+ createdAt: Date.now(),
16
+ ...entry,
17
+ };
18
+ this.entries.set(id, full);
19
+ return full;
20
+ }
21
+
22
+ get(id) {
23
+ const entry = this.entries.get(String(id || ''));
24
+ if (!entry) return undefined;
25
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
26
+ this.entries.delete(entry.id);
27
+ return undefined;
28
+ }
29
+ return entry;
30
+ }
31
+ }
32
+
33
+ export function createExposeDownloadsTool(options) {
34
+ const ttlMs = options.ttlMs ?? 30 * 60 * 1000;
35
+
36
+ return {
37
+ name: 'expose_downloads',
38
+ description:
39
+ 'Expose one or more existing local files for the web user to download. Input must be absolute file paths. The result follows neoctl.resource-link.v1: copy each downloads[].markdown value verbatim into the final response, without rewriting or prefixing its link.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ paths: {
44
+ type: 'array',
45
+ items: { type: 'string' },
46
+ description: 'Absolute file paths to expose for browser download.',
47
+ },
48
+ },
49
+ required: ['paths'],
50
+ additionalProperties: false,
51
+ },
52
+ metadata: {
53
+ readOnly: true,
54
+ concurrent: true,
55
+ visible: true,
56
+ requiresApproval: false,
57
+ maxResultSizeChars: 12000,
58
+ },
59
+ validate(input) {
60
+ const paths = Array.isArray(input?.paths)
61
+ ? input.paths.map((item) => String(item || '').trim()).filter(Boolean)
62
+ : [];
63
+ if (!paths.length) throw new Error('paths must be a non-empty array');
64
+ if (paths.length > 20) throw new Error('too many paths; maximum is 20');
65
+ for (const filePath of paths) {
66
+ if (!path.isAbsolute(filePath)) throw new Error(`path must be absolute: ${filePath}`);
67
+ }
68
+ return { paths };
69
+ },
70
+ async execute(input, context) {
71
+ const downloads = [];
72
+ for (const rawPath of input.paths) {
73
+ const absolutePath = path.resolve(rawPath);
74
+ const stat = await fsp.stat(absolutePath).catch(() => undefined);
75
+ if (!stat) throw new Error(`file does not exist: ${absolutePath}`);
76
+ if (!stat.isFile()) throw new Error(`path is not a file: ${absolutePath}`);
77
+ const entry = options.registry.add({
78
+ absolutePath,
79
+ filename: path.basename(absolutePath),
80
+ sizeBytes: stat.size,
81
+ expiresAt: Date.now() + ttlMs,
82
+ sessionId: context.session?.sessionId,
83
+ });
84
+ const url = `/api/downloads/${encodeURIComponent(entry.id)}`;
85
+ const reference = `sandbox:${url}`;
86
+ downloads.push({
87
+ id: entry.id,
88
+ filename: entry.filename,
89
+ sizeBytes: entry.sizeBytes,
90
+ url,
91
+ reference,
92
+ markdown: `[${entry.filename.replace(/([\\\]])/g, '\\$1')}](${reference})`,
93
+ expiresAt: new Date(entry.expiresAt).toISOString(),
94
+ expiresAtEpochMs: entry.expiresAt,
95
+ });
96
+ }
97
+ await options.onExpose?.({ sessionId: context.session?.sessionId, downloads });
98
+ return {
99
+ ok: true,
100
+ output: {
101
+ resourceProtocol: 'neoctl.resource-link.v1',
102
+ usage: 'Copy downloads[].markdown verbatim when linking the resource. Do not alter the reference URI.',
103
+ downloads,
104
+ _ui: {
105
+ resources: downloads.map((item) => ({
106
+ kind: 'download',
107
+ url: item.url,
108
+ reference: item.reference,
109
+ label: item.filename,
110
+ downloadName: item.filename,
111
+ sizeBytes: item.sizeBytes,
112
+ expiresAt: item.expiresAtEpochMs,
113
+ })),
114
+ },
115
+ },
116
+ summary: `Exposed ${downloads.length} file(s) for browser download.`,
117
+ };
118
+ },
119
+ };
120
+ }
121
+
122
+ export function serveDownload(registry, req, res, id) {
123
+ const entry = registry.get(id);
124
+ if (!entry) {
125
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
126
+ res.end(JSON.stringify({ error: 'download not found or expired' }));
127
+ return;
128
+ }
129
+
130
+ res.writeHead(200, {
131
+ 'Content-Type': 'application/octet-stream',
132
+ 'Content-Length': String(entry.sizeBytes),
133
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`,
134
+ 'Cache-Control': 'no-store',
135
+ });
136
+
137
+ fs.createReadStream(entry.absolutePath)
138
+ .on('error', () => {
139
+ if (!res.headersSent) {
140
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
141
+ res.end('failed to read file');
142
+ } else {
143
+ res.destroy();
144
+ }
145
+ })
146
+ .pipe(res);
147
+ }
@@ -1,19 +1,19 @@
1
- import { createExposeDownloadsTool, DownloadRegistry, serveDownload } from './downloads.mjs';
2
-
3
- export function createPlugin() {
4
- const registry = new DownloadRegistry();
5
- return {
6
- tools: [createExposeDownloadsTool({ registry })],
7
- promptSections: [{
8
- name: 'Web Downloads',
9
- cacheStable: true,
10
- content: 'When you create, modify, export, package, or identify local files that the web user should receive, call expose_downloads with the relevant absolute paths before the final response. For every link in your response, copy the returned downloads[].markdown value verbatim. This is the neoctl.resource-link.v1 protocol: never construct a link, alter the reference URI, or add/remove a sandbox: prefix. The tool also returns expiresAt as an ISO timestamp.',
11
- }],
12
- async route(req, res, url) {
13
- if (req.method !== 'GET' || !url.pathname.startsWith('/api/downloads/')) return false;
14
- const id = decodeURIComponent(url.pathname.slice('/api/downloads/'.length));
15
- await serveDownload(registry, req, res, id);
16
- return true;
17
- },
18
- };
19
- }
1
+ import { createExposeDownloadsTool, DownloadRegistry, serveDownload } from './downloads.mjs';
2
+
3
+ export function createPlugin() {
4
+ const registry = new DownloadRegistry();
5
+ return {
6
+ tools: [createExposeDownloadsTool({ registry })],
7
+ promptSections: [{
8
+ name: 'Web Downloads',
9
+ cacheStable: true,
10
+ content: 'When you create, modify, export, package, or identify local files that the web user should receive, call expose_downloads with the relevant absolute paths before the final response. For every link in your response, copy the returned downloads[].markdown value verbatim. This is the neoctl.resource-link.v1 protocol: never construct a link, alter the reference URI, or add/remove a sandbox: prefix. The tool also returns expiresAt as an ISO timestamp.',
11
+ }],
12
+ async route(req, res, url) {
13
+ if (req.method !== 'GET' || !url.pathname.startsWith('/api/downloads/')) return false;
14
+ const id = decodeURIComponent(url.pathname.slice('/api/downloads/'.length));
15
+ await serveDownload(registry, req, res, id);
16
+ return true;
17
+ },
18
+ };
19
+ }
@@ -1,9 +1,9 @@
1
- {
2
- "protocol": "neo-plugin/v1",
3
- "id": "downloads",
4
- "name": "文件下载",
5
- "version": "1.0.0",
6
- "entry": "index.mjs",
7
- "defaultEnabled": true,
8
- "description": "将本地文件安全地暴露为临时浏览器下载链接。"
9
- }
1
+ {
2
+ "protocol": "neo-plugin/v1",
3
+ "id": "downloads",
4
+ "name": "文件下载",
5
+ "version": "1.0.0",
6
+ "entry": "index.mjs",
7
+ "defaultEnabled": true,
8
+ "description": "将本地文件安全地暴露为临时浏览器下载链接。"
9
+ }