@thinger-io/cli-core 0.1.0 → 0.2.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,173 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import nodePath from 'node:path';
4
+ import api from '../api.js';
5
+ import { collectFiles, uploadFiles } from '../dashboard.js';
6
+ import { apiError, inputError } from '../errors.js';
7
+ import { v1 } from '../paths.js';
8
+ import { deleteStorageFile, readStorageFile, writeStorageFile } from '../storage.js';
9
+
10
+ /** Files of a storage (the storages themselves are the catalog type "storage"). */
11
+
12
+ const text = (t) => ({ content: [{ type: 'text', text: t }], isError: false });
13
+ const userParam = { type: 'string', description: 'Account to act on (admin only).' };
14
+ const storageParam = { type: 'string', description: 'Storage id.' };
15
+
16
+ function required(args, ...names) {
17
+ for (const name of names) {
18
+ if (args[name] === undefined || args[name] === '') throw inputError(`${name} is required`);
19
+ }
20
+ }
21
+
22
+ /** A path inside the storage, without leading slashes (they would double up in the URL). */
23
+ const clean = (p) => String(p).replace(/^\/+/, '');
24
+
25
+ async function toolList(args) {
26
+ required(args, 'storage');
27
+ const params = args.path ? { path: clean(args.path) } : undefined;
28
+ try {
29
+ const res = await api.get(
30
+ `${v1(args.user)}/storages/${encodeURIComponent(args.storage)}/files`,
31
+ { params },
32
+ );
33
+ const files = Array.isArray(res.data) ? res.data : [];
34
+ return text(files.length ? files.map((f) => JSON.stringify(f)).join('\n') : 'No files.');
35
+ } catch (e) {
36
+ // The server keeps files on disk and creates a storage's folder with
37
+ // its first file: an empty storage answers 404 too.
38
+ if (e.response?.status === 404) {
39
+ return text(
40
+ `No files${args.path ? ` under ${clean(args.path)}` : ''} (or no storage ${args.storage}).`,
41
+ );
42
+ }
43
+ throw apiError(e);
44
+ }
45
+ }
46
+
47
+ async function toolRead(args) {
48
+ required(args, 'storage', 'path');
49
+ return text(await readStorageFile(args.storage, clean(args.path), args.user));
50
+ }
51
+
52
+ /** Largest single local file uploaded at once. */
53
+ const MAX_FILE = 50 * 1024 * 1024;
54
+ /** Largest local folder uploaded in one call. */
55
+ const MAX_FOLDER = 200 * 1024 * 1024;
56
+
57
+ /** Upload a local file (to `path`, default its name) or folder (under `path`). */
58
+ async function uploadLocal(args) {
59
+ if (args.content !== undefined) throw inputError('pass content or local, not both');
60
+ const local = nodePath.resolve(String(args.local));
61
+ if (!fs.existsSync(local)) throw inputError(`No such local file or folder: ${args.local}`);
62
+ /** @type {Record<string, Buffer>} */
63
+ let files;
64
+ if (fs.statSync(local).isDirectory()) {
65
+ const prefix = args.path ? `${clean(args.path).replace(/\/+$/, '')}/` : '';
66
+ files = Object.fromEntries(
67
+ Object.entries(
68
+ collectFiles(local, {
69
+ skip: new Set(['.git']),
70
+ maxBytes: MAX_FOLDER,
71
+ tooLarge: 'upload its subfolders or files in separate calls',
72
+ }),
73
+ ).map(([f, c]) => [prefix + f, c]),
74
+ );
75
+ if (!Object.keys(files).length) throw inputError(`${args.local} has no files`);
76
+ } else {
77
+ if (fs.statSync(local).size > MAX_FILE) {
78
+ throw inputError(`${args.local} is larger than ${MAX_FILE / 1024 / 1024} MB`);
79
+ }
80
+ files = {
81
+ [args.path ? clean(args.path) : nodePath.basename(local)]: fs.readFileSync(local),
82
+ };
83
+ }
84
+ await uploadFiles(args.storage, files, args.user);
85
+ const names = Object.keys(files);
86
+ const bytes = Object.values(files).reduce((n, c) => n + c.length, 0);
87
+ const shown =
88
+ names.slice(0, 20).join(', ') + (names.length > 20 ? `… (+${names.length - 20})` : '');
89
+ return text(
90
+ `Uploaded ${names.length} file(s), ${bytes} bytes, to storage ${args.storage}: ${shown}.`,
91
+ );
92
+ }
93
+
94
+ async function toolWrite(args) {
95
+ required(args, 'storage');
96
+ if (args.local !== undefined) return uploadLocal(args);
97
+ required(args, 'path');
98
+ if (typeof args.content !== 'string') throw inputError('content must be a string');
99
+ await writeStorageFile(args.storage, clean(args.path), args.content, args.user);
100
+ return text(
101
+ `Wrote ${clean(args.path)} (${Buffer.byteLength(args.content)} bytes) to storage ${args.storage}.`,
102
+ );
103
+ }
104
+
105
+ async function toolDelete(args) {
106
+ required(args, 'storage', 'path');
107
+ const deleted = await deleteStorageFile(args.storage, clean(args.path), args.user);
108
+ return text(deleted ? `Deleted ${clean(args.path)}.` : `No such file: ${clean(args.path)}.`);
109
+ }
110
+
111
+ const pathParam = {
112
+ type: 'string',
113
+ description: 'File path inside the storage, e.g. "web/index.html".',
114
+ };
115
+
116
+ export const tools = [
117
+ {
118
+ name: 'storage_file_list',
119
+ description:
120
+ 'List the files of a storage (path, size, modification time), optionally only under a folder.',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ storage: storageParam,
125
+ path: { type: 'string', description: 'Folder to list.' },
126
+ user: userParam,
127
+ },
128
+ required: ['storage'],
129
+ },
130
+ handler: toolList,
131
+ },
132
+ {
133
+ name: 'storage_file_read',
134
+ description: 'Read a text file of a storage.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: { storage: storageParam, path: pathParam, user: userParam },
138
+ required: ['storage', 'path'],
139
+ },
140
+ handler: toolRead,
141
+ },
142
+ {
143
+ name: 'storage_file_write',
144
+ description:
145
+ 'Create or overwrite files in a storage; folders on the path are created. Either `content` (text) at `path`, or `local`: a file or folder on this machine, uploaded as is (binary too) to `path` (a file defaults to its own name; a folder goes under `path`, or the root). Use `local` for anything big: CSV to import, images, product code with its node_modules.',
146
+ inputSchema: {
147
+ type: 'object',
148
+ properties: {
149
+ storage: storageParam,
150
+ path: pathParam,
151
+ content: { type: 'string', description: 'Text content of the file at `path`.' },
152
+ local: {
153
+ type: 'string',
154
+ description:
155
+ 'Local file or folder to upload instead of `content` (up to 50 MB per file and 200 MB per call).',
156
+ },
157
+ user: userParam,
158
+ },
159
+ required: ['storage'],
160
+ },
161
+ handler: toolWrite,
162
+ },
163
+ {
164
+ name: 'storage_file_delete',
165
+ description: 'Delete a file of a storage. Permanent: confirm with the user first.',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: { storage: storageParam, path: pathParam, user: userParam },
169
+ required: ['storage', 'path'],
170
+ },
171
+ handler: toolDelete,
172
+ },
173
+ ];
package/lib/openapi.js ADDED
@@ -0,0 +1,169 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import api from './api.js';
5
+ import { cacheDir, readConfig, serverURL } from './config.js';
6
+
7
+ /**
8
+ * The server's own description of its API.
9
+ *
10
+ * Every Thinger server publishes its OpenAPI document without a token at
11
+ * `/v1/server/swagger.json`, and its version at `/v1/server/version`. The
12
+ * document is what `resource_describe` reads the request schemas and
13
+ * examples from, so they always match the server the profile points at
14
+ * rather than whichever version this package was built against.
15
+ *
16
+ * It is cached per server and version: in memory for the life of the
17
+ * process and on disk under the CLI's cache folder, so a new MCP session
18
+ * costs one small version request instead of a 300 KB download. A server
19
+ * with the document disabled (`deployment.disable_swagger`) falls back to
20
+ * the copy bundled with this package, flagged as such.
21
+ */
22
+
23
+ const BUNDLED_FILE = new URL('../openapi/swagger.json', import.meta.url);
24
+ /** Server version the bundled copy was taken from. */
25
+ export const BUNDLED_VERSION = 'develop 2026-10-11 (98)';
26
+
27
+ /** @type {Map<string, Promise<Spec>>} */
28
+ const memory = new Map();
29
+
30
+ /**
31
+ * @typedef {object} Spec
32
+ * @property {any} doc The OpenAPI document, or null when none is available.
33
+ * @property {string} source Where it came from: "server 7.2.4-beta", "bundled copy (…)".
34
+ */
35
+
36
+ /** The bundled OpenAPI document (also the fixture the tests check the catalog against). */
37
+ export function bundledSpec() {
38
+ return JSON.parse(fs.readFileSync(BUNDLED_FILE, 'utf8'));
39
+ }
40
+
41
+ /**
42
+ * OpenAPI document of the active profile's server.
43
+ *
44
+ * @returns {Promise<Spec>}
45
+ */
46
+ export async function serverSpec() {
47
+ const base = serverURL(readConfig().server) || '';
48
+ let version = 'unknown';
49
+ try {
50
+ const res = await api.get('/v1/server/version');
51
+ version = String(res.data?.version || 'unknown');
52
+ } catch {
53
+ // An unreachable server fails the real request soon enough; the
54
+ // description can still come from the bundled copy.
55
+ }
56
+ const key = `${base}@${version}`;
57
+ if (!memory.has(key)) {
58
+ const pending = loadSpec(base, version);
59
+ // A failed load must not stick: the next call tries again.
60
+ pending.catch(() => memory.delete(key));
61
+ memory.set(key, pending);
62
+ }
63
+ return memory.get(key);
64
+ }
65
+
66
+ async function loadSpec(base, version) {
67
+ const file = path.join(cacheDir(), `openapi-${slug(base)}-${slug(version)}.json`);
68
+ if (version !== 'unknown') {
69
+ try {
70
+ return { doc: JSON.parse(fs.readFileSync(file, 'utf8')), source: `server ${version}` };
71
+ } catch {
72
+ // Not cached yet (or unreadable): fetch it.
73
+ }
74
+ }
75
+ try {
76
+ const res = await api.get('/v1/server/swagger.json');
77
+ if (res.data && typeof res.data === 'object' && res.data.paths) {
78
+ if (version !== 'unknown') {
79
+ try {
80
+ fs.writeFileSync(file, JSON.stringify(res.data));
81
+ } catch {
82
+ // A read-only home only costs the next session a download.
83
+ }
84
+ }
85
+ return { doc: res.data, source: `server ${version}` };
86
+ }
87
+ } catch {
88
+ // Disabled or unreachable: fall through to the bundled copy.
89
+ }
90
+ return {
91
+ doc: bundledSpec(),
92
+ source: `bundled copy (server ${BUNDLED_VERSION}; this server, ${version}, does not publish its own)`,
93
+ };
94
+ }
95
+
96
+ function slug(text) {
97
+ return String(text)
98
+ .replace(/^[a-z]+:\/\//i, '')
99
+ .replace(/[^a-zA-Z0-9.-]+/g, '_');
100
+ }
101
+
102
+ /**
103
+ * Find the operation a method and path template refer to. Ids in the
104
+ * template are written `{}`; a server path parameter matches any segment,
105
+ * and the path with the most literal matches wins, so `profile/code`
106
+ * finds its own entry instead of `profile/{category}`.
107
+ *
108
+ * @param {any} doc
109
+ * @param {string} method
110
+ * @param {string} template
111
+ * @returns {{ path: string, operation: any } | null}
112
+ */
113
+ export function findOperation(doc, method, template) {
114
+ const want = template.split('/');
115
+ let best = null;
116
+ let bestScore = -1;
117
+ for (const [p, item] of Object.entries(doc?.paths || {})) {
118
+ const have = p.split('/');
119
+ if (have.length !== want.length || !item[method]) continue;
120
+ let score = 0;
121
+ let ok = true;
122
+ for (let i = 0; i < have.length; i++) {
123
+ const param = have[i].startsWith('{');
124
+ if (param) continue;
125
+ if (have[i] !== want[i]) {
126
+ ok = false;
127
+ break;
128
+ }
129
+ score++;
130
+ }
131
+ if (ok && score > bestScore) {
132
+ best = { path: p, operation: item[method] };
133
+ bestScore = score;
134
+ }
135
+ }
136
+ return best;
137
+ }
138
+
139
+ const SCALARS = new Set(['string', 'number', 'integer', 'boolean']);
140
+ const VERBOSE = new Set(['description', 'example', 'examples', 'title']);
141
+
142
+ /**
143
+ * Copy of `node` with every local `$ref` replaced by what it points to.
144
+ * A schema that refers to itself is cut at the second visit.
145
+ *
146
+ * @param {any} doc
147
+ * @param {any} node
148
+ */
149
+ export function resolveRefs(doc, node, seen = new Set()) {
150
+ if (Array.isArray(node)) return node.map((n) => resolveRefs(doc, n, seen));
151
+ if (!node || typeof node !== 'object') return node;
152
+ if (typeof node.$ref === 'string' && node.$ref.startsWith('#/')) {
153
+ if (seen.has(node.$ref)) return { description: `(recursive: ${node.$ref})` };
154
+ const target = node.$ref
155
+ .slice(2)
156
+ .split('/')
157
+ .reduce((n, key) => n?.[key], doc);
158
+ // A shared scalar (resource_id, resource_name…) appears once per
159
+ // field that uses it: its constraints say all there is to say, the
160
+ // prose and examples repeated on every field would only bury them.
161
+ if (target && typeof target === 'object' && SCALARS.has(target.type)) {
162
+ return Object.fromEntries(Object.entries(target).filter(([key]) => !VERBOSE.has(key)));
163
+ }
164
+ return resolveRefs(doc, target, new Set([...seen, node.$ref]));
165
+ }
166
+ return Object.fromEntries(
167
+ Object.entries(node).map(([key, value]) => [key, resolveRefs(doc, value, seen)]),
168
+ );
169
+ }
package/lib/paths.js CHANGED
@@ -30,3 +30,11 @@ export function v3(user) {
30
30
  export function device(user, deviceId) {
31
31
  return `${v3(user)}/devices/${deviceId}`;
32
32
  }
33
+
34
+ /**
35
+ * `/users/{account}/plugins/{plugin}` — where a plugin serves its own API
36
+ * (no version prefix: the plugin defines the rest of the path).
37
+ */
38
+ export function pluginApi(user, plugin) {
39
+ return `/users/${resolveUser(user)}/plugins/${encodeURIComponent(plugin)}`;
40
+ }
package/lib/resource.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolveUser } from './config.js';
2
2
  import { device } from './paths.js';
3
3
  import api from './api.js';
4
+ import { TIMEOUTS } from './constants.js';
4
5
  import { apiError } from './errors.js';
5
6
  import { info, warning, hint } from './format.js';
6
7
 
@@ -69,6 +70,7 @@ export async function executeDeviceResource(deviceId, resourceId, inputs, option
69
70
  const response = await api.post(
70
71
  `${device(user, deviceId)}/resources/${resourceId}`,
71
72
  inputs,
73
+ { timeout: TIMEOUTS.API_LONG_MS },
72
74
  );
73
75
  return response.data;
74
76
  } catch (error) {
package/lib/storage.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import api from './api.js';
2
+ import { TIMEOUTS } from './constants.js';
2
3
  import { apiError } from './errors.js';
3
4
  import { v1, v2 } from './paths.js';
4
5
 
@@ -99,7 +100,7 @@ export async function writeStorageFile(storageId, filePath, content, user) {
99
100
  await api.put(
100
101
  `${v2(user)}/storages/${storageId}/files/${filePath}?overwrite=true`,
101
102
  content,
102
- { headers: { 'Content-Type': 'text/plain' } },
103
+ { headers: { 'Content-Type': 'text/plain' }, timeout: TIMEOUTS.API_LONG_MS },
103
104
  );
104
105
  } catch (e) {
105
106
  throw apiError(e);