@thinger-io/cli-core 0.4.1 → 0.5.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +82 -31
  3. package/docs/thinger-concepts.md +7 -7
  4. package/docs/thinger-product-code.md +2 -2
  5. package/docs/thinger-server-admin.md +2 -2
  6. package/lib/bucket-export.js +91 -0
  7. package/lib/cli.js +106 -50
  8. package/lib/commands/_shared.js +89 -1
  9. package/lib/commands/api.js +90 -0
  10. package/lib/commands/arguments.js +62 -0
  11. package/lib/commands/bucket.js +52 -44
  12. package/lib/commands/dashboard.js +5 -2
  13. package/lib/commands/data.js +539 -0
  14. package/lib/commands/device.js +128 -241
  15. package/lib/commands/grammar.js +279 -0
  16. package/lib/commands/product.js +104 -214
  17. package/lib/commands/profile.js +1 -0
  18. package/lib/commands/resources.js +975 -0
  19. package/lib/dashboard-init.js +4 -3
  20. package/lib/dashboard.js +10 -2
  21. package/lib/mcp/catalog.js +303 -65
  22. package/lib/mcp/core-types.js +36 -0
  23. package/lib/mcp/helpers.js +23 -2
  24. package/lib/mcp/naming.js +20 -0
  25. package/lib/mcp/registry.js +18 -67
  26. package/lib/mcp/resource-hooks.js +19 -5
  27. package/lib/mcp/server.js +43 -11
  28. package/lib/mcp/tools-resource.js +1232 -330
  29. package/lib/mcp/types/bucket-data.js +151 -0
  30. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  31. package/lib/mcp/types/device-resource.js +117 -0
  32. package/lib/mcp/types/doc.js +35 -0
  33. package/lib/mcp/types/plugin-call.js +75 -0
  34. package/lib/mcp/types/product-api.js +44 -0
  35. package/lib/mcp/types/profile.js +37 -0
  36. package/lib/mcp/types/storage-file.js +170 -0
  37. package/lib/openapi.js +18 -0
  38. package/lib/product-apply.js +121 -0
  39. package/lib/raw-api.js +111 -0
  40. package/lib/storage.js +41 -13
  41. package/lib/tokens.js +16 -2
  42. package/package.json +1 -1
  43. package/lib/mcp/tools-buckets.js +0 -130
  44. package/lib/mcp/tools-devices.js +0 -134
  45. package/lib/mcp/tools-misc.js +0 -48
  46. package/lib/mcp/tools-plugins.js +0 -63
  47. package/lib/mcp/tools-products.js +0 -52
  48. package/lib/mcp/tools-storage.js +0 -173
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ import api from '../../api.js';
3
+ import { TIMEOUTS } from '../../constants.js';
4
+ import { apiError } from '../../errors.js';
5
+ import { v1 } from '../../paths.js';
6
+
7
+ /**
8
+ * A product API resource called for the product as a whole: an action of
9
+ * the catalog type product_profile_api.
10
+ *
11
+ * @type {import('../tools-resource.js').LocalAction}
12
+ */
13
+ export const apiCall = {
14
+ description:
15
+ "Call a product API resource for the product as a whole, as an external system feeding data in would, with `body` as its payload. The entry needs a `device_id_resolver` (a function of the product code that returns each payload's device); the body may be an array, one payload per device. To call it for one device, use {op:device_resource.call}.",
16
+ op: 'Product_AccessResources',
17
+ examples: [
18
+ {
19
+ tool: 'resource_call',
20
+ arguments: {
21
+ type: 'product_profile_api',
22
+ action: 'call',
23
+ ids: { product: 'boiler_kit', resource: 'uplink' },
24
+
25
+ body: [{ dev_eui: 'a1b2c3', temperature: 21.5 }],
26
+ },
27
+ does: 'Feed one payload in; the device_id_resolver of the entry finds its device.',
28
+ },
29
+ ],
30
+ async run(body, ctx) {
31
+ try {
32
+ const res = await api.post(
33
+ `${v1(ctx.user)}/products/${encodeURIComponent(String(ctx.parent))}/resources/${encodeURIComponent(String(ctx.id))}`,
34
+ body ?? {},
35
+ { timeout: TIMEOUTS.API_LONG_MS },
36
+ );
37
+ return res.data ?? null;
38
+ } catch (e) {
39
+ throw apiError(e, {
40
+ notFound: `Not found: product ${ctx.parent} or its resource ${ctx.id}`,
41
+ });
42
+ }
43
+ },
44
+ };
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+ import { getActiveProfile, getProfile, listProfiles } from '../../config.js';
3
+
4
+ /**
5
+ * The profiles of this CLI as a type of the resource tools: the lookup of
6
+ * the profiles themselves, so it takes no `profile` (nor `user`).
7
+ *
8
+ * @type {import('../tools-resource.js').LocalType}
9
+ */
10
+ export const profile = {
11
+ description:
12
+ 'Profiles of this CLI: each one a server with its own credentials. Pass its name as `profile` to any tool to use that server for one call.',
13
+ id: 'profile',
14
+ profile: false,
15
+ user: false,
16
+ local: true,
17
+ notes: 'Profiles are not accounts: another account on the same server is reached with `user` (admin only), not with a profile.',
18
+ examples: [
19
+ {
20
+ tool: 'resource_list',
21
+ arguments: { type: 'profile' },
22
+ does: 'The configured servers, the default one marked active.',
23
+ },
24
+ ],
25
+ list() {
26
+ const active = getActiveProfile();
27
+ return listProfiles().map((name) => {
28
+ const data = getProfile(name) || {};
29
+ return {
30
+ profile: name,
31
+ server: data.server || name,
32
+ username: data.username || '',
33
+ active: name === active,
34
+ };
35
+ });
36
+ },
37
+ };
@@ -0,0 +1,170 @@
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 { identity } from '../../identity.js';
8
+ import { v1, v2 } from '../../paths.js';
9
+ import { decodeBody } from '../../raw-api.js';
10
+ import { deleteStorageFile, storageFilePath, writeStorageFile } from '../../storage.js';
11
+
12
+ /** Files of a storage (the storages themselves are the catalog type "storage"). */
13
+
14
+ /** A path inside the storage, without leading slashes (they would double up in the URL). */
15
+ const clean = (p) => String(p).replace(/^\/+/, '');
16
+
17
+ /** Largest single local file uploaded at once. */
18
+ const MAX_FILE = 50 * 1024 * 1024;
19
+ /** Largest local folder uploaded in one call. */
20
+ const MAX_FOLDER = 200 * 1024 * 1024;
21
+
22
+ /** Upload a local file (to `path`, default its name) or folder (under `path`). */
23
+ async function uploadLocal(body, ctx) {
24
+ if (body.content !== undefined) throw inputError('pass body.content or body.local, not both');
25
+ const local = nodePath.resolve(String(body.local));
26
+ if (!fs.existsSync(local)) throw inputError(`No such local file or folder: ${body.local}`);
27
+ const path = ctx.id;
28
+ /** @type {Record<string, Buffer>} */
29
+ let files;
30
+ if (fs.statSync(local).isDirectory()) {
31
+ const prefix = path ? `${clean(path).replace(/\/+$/, '')}/` : '';
32
+ files = Object.fromEntries(
33
+ Object.entries(
34
+ collectFiles(local, {
35
+ skip: new Set(['.git']),
36
+ maxBytes: MAX_FOLDER,
37
+ tooLarge: 'upload its subfolders or files in separate calls',
38
+ }),
39
+ ).map(([f, c]) => [prefix + f, c]),
40
+ );
41
+ if (!Object.keys(files).length) throw inputError(`${body.local} has no files`);
42
+ } else {
43
+ if (fs.statSync(local).size > MAX_FILE) {
44
+ throw inputError(`${body.local} is larger than ${MAX_FILE / 1024 / 1024} MB`);
45
+ }
46
+ files = { [path ? clean(path) : nodePath.basename(local)]: fs.readFileSync(local) };
47
+ }
48
+ await uploadFiles(String(ctx.parent), files, ctx.user);
49
+ const names = Object.keys(files);
50
+ const bytes = Object.values(files).reduce((n, c) => n + c.length, 0);
51
+ return { storage: ctx.parent, files: names, bytes };
52
+ }
53
+
54
+ /** Write `content` (text) at the path, or upload `local`. */
55
+ async function write(body, ctx) {
56
+ if (body.local !== undefined) return uploadLocal(body, ctx);
57
+ if (!ctx.id) throw inputError('missing file: the path to write (or body.local)');
58
+ if (typeof body.content !== 'string') {
59
+ throw inputError('body.content (text) or body.local (a local file or folder) is required');
60
+ }
61
+ const path = clean(ctx.id);
62
+ await writeStorageFile(String(ctx.parent), path, body.content, ctx.user);
63
+ return { storage: ctx.parent, files: [path], bytes: Buffer.byteLength(body.content) };
64
+ }
65
+
66
+ /** @type {import('../tools-resource.js').LocalType} */
67
+ export const storageFile = {
68
+ description:
69
+ 'Files of a file storage: list a folder, read, write (text, or files and folders of this machine) and delete.',
70
+ parent: 'storage',
71
+ id: 'file',
72
+ recordId: 'path',
73
+
74
+ optionalId: ['create', 'update'],
75
+ ops: {
76
+ list: 'Storage_ListFiles',
77
+ get: 'Storage_ReadFilesFiles(?<file>.*$)',
78
+ create: 'Storage_UpdateFilesFiles(?<file>.*$)',
79
+ update: 'Storage_UpdateFilesFiles(?<file>.*$)',
80
+ delete: 'Storage_DeleteFilesFiles(?<file>.*$)',
81
+ },
82
+ filter: {
83
+ type: 'object',
84
+ additionalProperties: false,
85
+ properties: { path: { type: 'string', description: 'Folder to list.' } },
86
+ },
87
+ schema: {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ properties: {
91
+ content: { type: 'string', description: 'Text of the file at ids.file.' },
92
+ local: {
93
+ type: 'string',
94
+ description:
95
+ 'Or a file or folder of the machine running this CLI, uploaded as is (binary too; up to 50 MB per file and 200 MB per call): a file goes to ids.file (default its own name), a folder under ids.file (default the root).',
96
+ },
97
+ },
98
+ },
99
+ notes: 'ids.file is the path inside the storage ("web/index.html"). create and update both write the file whether it exists or not, creating the folders on the path. body.local runs on the machine of this CLI: use it for anything big or binary (CSV to import, images, product code with its node_modules). A get of a binary file says how to download it instead of returning it.',
100
+ examples: [
101
+ {
102
+ tool: 'resource_create',
103
+ arguments: {
104
+ type: 'storage_file',
105
+ ids: { storage: 'web_app' },
106
+ body: { local: './site' },
107
+ },
108
+ does: 'Upload the local folder ./site (binary files too) to the root of the storage.',
109
+ },
110
+ {
111
+ tool: 'resource_get',
112
+ arguments: {
113
+ type: 'storage_file',
114
+ ids: { storage: 'web_app', file: 'web/index.html' },
115
+ },
116
+ does: 'Read a text file.',
117
+ },
118
+ ],
119
+ async list(ctx) {
120
+ const path = ctx.filter?.path;
121
+ try {
122
+ const res = await api.get(
123
+ `${v1(ctx.user)}/storages/${encodeURIComponent(String(ctx.parent))}/files`,
124
+ { params: path ? { path: clean(path) } : undefined },
125
+ );
126
+ if (!Array.isArray(res.data)) return [];
127
+ // The server answers paths relative to the folder listed: make
128
+ // them whole, so a record's ids.file reads and writes that file.
129
+ const folder = path ? clean(path).replace(/\/+$/, '') : '';
130
+ if (!folder) return res.data;
131
+ return res.data.map((f) =>
132
+ typeof f?.path === 'string' && !f.path.startsWith(`${folder}/`)
133
+ ? { ...f, path: `${folder}/${f.path.replace(/^\/+/, '')}` }
134
+ : f,
135
+ );
136
+ } catch (e) {
137
+ // The server keeps files on disk and creates a storage's folder
138
+ // with its first file: an empty storage answers 404 too.
139
+ if (e.response?.status === 404) return [];
140
+ throw apiError(e);
141
+ }
142
+ },
143
+ async get(ctx) {
144
+ const path = clean(String(ctx.id));
145
+ try {
146
+ const res = await api.get(
147
+ `${v2(ctx.user)}/storages/${encodeURIComponent(String(ctx.parent))}/files/${storageFilePath(path)}`,
148
+ { responseType: 'arraybuffer', transformResponse: [(x) => x] },
149
+ );
150
+ const raw = Buffer.from(res.data ?? []);
151
+ const type = String(res.headers?.['content-type'] || '');
152
+ const { kind } = decodeBody(raw, type);
153
+ if (kind === 'binary') {
154
+ return `Binary file of ${raw.length} bytes (${type || 'unknown type'}): download it with \`${identity().name} storage file download ${ctx.parent} ${path} -o <file>\`.`;
155
+ }
156
+ return raw.toString('utf8');
157
+ } catch (e) {
158
+ throw apiError(e, { notFound: `File not found in storage "${ctx.parent}": ${path}` });
159
+ }
160
+ },
161
+ create: write,
162
+ update: write,
163
+ async delete(ctx) {
164
+ const path = clean(String(ctx.id));
165
+ if (!(await deleteStorageFile(String(ctx.parent), path, ctx.user))) {
166
+ throw Object.assign(inputError(`No such file: ${path}`), { code: 'not_found' });
167
+ }
168
+ return { path, deleted: true };
169
+ },
170
+ };
package/lib/openapi.js CHANGED
@@ -136,7 +136,25 @@ export function findOperation(doc, method, template) {
136
136
  return best;
137
137
  }
138
138
 
139
+ /**
140
+ * Find an operation by its operationId (the core's types name the
141
+ * operations they run, not their paths).
142
+ *
143
+ * @param {any} doc
144
+ * @param {string} operationId
145
+ * @returns {any} The operation, or null.
146
+ */
147
+ export function findOperationById(doc, operationId) {
148
+ for (const item of Object.values(doc?.paths || {})) {
149
+ for (const operation of Object.values(item || {})) {
150
+ if (operation?.operationId === operationId) return operation;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
139
156
  const SCALARS = new Set(['string', 'number', 'integer', 'boolean']);
157
+
140
158
  const VERBOSE = new Set(['description', 'example', 'examples', 'title']);
141
159
 
142
160
  /**
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+ import api from './api.js';
3
+ import { apiError, inputError } from './errors.js';
4
+ import { v1 } from './paths.js';
5
+
6
+ /** Fields of an export the server sets itself. */
7
+ const SERVER_FIELDS = ['created', 'modified', 'user', '_id'];
8
+
9
+ /** What a product update (PUT) takes; the rest is written apart. */
10
+ const UPDATE_FIELDS = ['name', 'description', 'enabled', 'config'];
11
+
12
+ /** Profile categories written whole with PUT …/profile/{category}. */
13
+ const PROFILE_CATEGORIES = ['properties', 'buckets', 'flows', 'endpoints', 'api', 'autoprovisions'];
14
+
15
+ const exists = async (url) => {
16
+ try {
17
+ await api.get(url);
18
+ return true;
19
+ } catch (e) {
20
+ if (e.response?.status === 404) return false;
21
+ throw apiError(e);
22
+ }
23
+ };
24
+
25
+ /**
26
+ * Make a product match a document as `product export` writes it (the
27
+ * product, its profile and, under `_resources.properties`, its properties):
28
+ * created when missing, updated when it exists. Each profile category the
29
+ * document has replaces the product's (entries it leaves out are removed);
30
+ * product properties and services it leaves out are kept.
31
+ *
32
+ * @param {Record<string, any>} doc
33
+ * @param {string | null} [user]
34
+ * @returns {Promise<{ product: string, created: boolean, properties: { created: string[], updated: string[] }, ignored?: string[] }>}
35
+ */
36
+ export async function applyProduct(doc, user) {
37
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
38
+ throw inputError('the product document must be a JSON object');
39
+ }
40
+ const id = doc.product;
41
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(id)) {
42
+ throw inputError('the document needs its "product" id (letters, digits, _ and -)');
43
+ }
44
+ const { _resources, ...rest } = doc;
45
+ const product = Object.fromEntries(
46
+ Object.entries(rest).filter(([k]) => !SERVER_FIELDS.includes(k)),
47
+ );
48
+ const properties = _resources?.properties ?? [];
49
+ if (!Array.isArray(properties)) throw inputError('_resources.properties must be an array');
50
+ for (const p of properties) {
51
+ if (!p || typeof p.property !== 'string' || !p.property) {
52
+ throw inputError('every entry of _resources.properties needs its "property" id');
53
+ }
54
+ }
55
+
56
+ const base = `${v1(user)}/products`;
57
+ const item = `${base}/${encodeURIComponent(id)}`;
58
+ const found = await exists(item);
59
+ /** One request, its 404 named after what it wrote. */
60
+ const write = async (method, url, body, what) => {
61
+ try {
62
+ await api.request({ method, url, data: body });
63
+ } catch (e) {
64
+ throw apiError(e, { notFound: `Not found: ${what} (${method.toUpperCase()} ${url})` });
65
+ }
66
+ };
67
+ /** Profile categories this server takes no PUT for: left as they are. */
68
+ const ignored = [];
69
+ if (!found) {
70
+ await write('post', base, product, `products of the account`);
71
+ } else {
72
+ // An update takes the record's own fields; the profile goes
73
+ // category by category (each PUT replaces the whole category),
74
+ // and the services one by one.
75
+ const fields = Object.fromEntries(
76
+ UPDATE_FIELDS.filter((k) => k in product).map((k) => [k, product[k]]),
77
+ );
78
+ if (Object.keys(fields).length) await write('put', item, fields, `product ${id}`);
79
+ for (const [category, value] of Object.entries(product.profile || {})) {
80
+ if (!PROFILE_CATEGORIES.includes(category) && category !== 'code') {
81
+ ignored.push(`profile.${category}`);
82
+ continue;
83
+ }
84
+ await write('put', `${item}/profile/${category}`, value ?? {}, `profile ${category}`);
85
+ }
86
+ for (const [service, value] of Object.entries(product.services || {})) {
87
+ await write(
88
+ 'put',
89
+ `${item}/services/${encodeURIComponent(service)}`,
90
+ value,
91
+ `service ${service}`,
92
+ );
93
+ }
94
+ }
95
+
96
+ const done = { created: [], updated: [] };
97
+ for (const p of properties) {
98
+ const record = Object.fromEntries(
99
+ Object.entries(p).filter(([k]) => !SERVER_FIELDS.includes(k)),
100
+ );
101
+ const url = `${item}/properties/${encodeURIComponent(p.property)}`;
102
+ try {
103
+ if (found && (await exists(url))) {
104
+ const { property: _id, ...fields } = record;
105
+ await api.put(url, fields);
106
+ done.updated.push(p.property);
107
+ } else {
108
+ await api.post(`${item}/properties`, record);
109
+ done.created.push(p.property);
110
+ }
111
+ } catch (e) {
112
+ throw apiError(e, { notFound: `Product property not found: ${p.property}` });
113
+ }
114
+ }
115
+ return {
116
+ product: id,
117
+ created: !found,
118
+ properties: done,
119
+ ...(ignored.length ? { ignored } : {}),
120
+ };
121
+ }
package/lib/raw-api.js ADDED
@@ -0,0 +1,111 @@
1
+ // @ts-check
2
+ import api from './api.js';
3
+ import { resolveUser } from './config.js';
4
+ import { TIMEOUTS } from './constants.js';
5
+ import { apiError, inputError } from './errors.js';
6
+
7
+ const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
8
+
9
+ /**
10
+ * The path of a raw API call, with `{user}` replaced by the account (the
11
+ * profile's, or `user`). Only paths on the profile's server: a full URL
12
+ * would send its token to another host.
13
+ *
14
+ * @param {string} path
15
+ * @param {string | null} [user]
16
+ */
17
+ export function apiPath(path, user) {
18
+ const p = String(path ?? '').trim();
19
+ if (!p.startsWith('/') || p.startsWith('//')) {
20
+ throw inputError(`the path must start with "/" (e.g. /v1/users/{user}/devices): ${p}`);
21
+ }
22
+ // eslint-disable-next-line no-control-regex -- control characters are what it looks for
23
+ if (/[\x00-\x1f\s]/.test(p))
24
+ throw inputError('the path cannot hold spaces or control characters');
25
+ return p.includes('{user}') ? p.replaceAll('{user}', encodeURIComponent(resolveUser(user))) : p;
26
+ }
27
+
28
+ /**
29
+ * Method of a raw call: the one given, else POST when it carries a body
30
+ * and GET otherwise (as `gh api` does).
31
+ *
32
+ * @param {string | undefined} method
33
+ * @param {boolean} hasBody
34
+ */
35
+ export function apiMethod(method, hasBody) {
36
+ const m = (method || (hasBody ? 'POST' : 'GET')).toUpperCase();
37
+ if (!METHODS.has(m))
38
+ throw inputError(`unknown method ${method}: use ${[...METHODS].join(', ')}`);
39
+ return m;
40
+ }
41
+
42
+ /**
43
+ * The answer's body as the caller should see it: parsed JSON, text, or the
44
+ * raw bytes when it is neither.
45
+ *
46
+ * @param {Buffer} data
47
+ * @param {string} [contentType]
48
+ * @returns {{ kind: 'json' | 'text' | 'binary' | 'empty', body: any }}
49
+ */
50
+ export function decodeBody(data, contentType = '') {
51
+ if (!data || !data.length) return { kind: 'empty', body: null };
52
+ const type = contentType.toLowerCase();
53
+ const textual =
54
+ type.includes('json') || type.startsWith('text/') || type.includes('xml') || !type;
55
+ if (!textual) return { kind: 'binary', body: data };
56
+ const text = data.toString('utf8');
57
+ if (type.includes('json') || !type) {
58
+ try {
59
+ return { kind: 'json', body: JSON.parse(text) };
60
+ } catch {
61
+ // Streamed answers (one JSON object per line) and plain text.
62
+ }
63
+ }
64
+ // Without a content type, bytes that are not text stay bytes.
65
+ if (!type && text.includes('�')) return { kind: 'binary', body: data };
66
+ return { kind: 'text', body: text };
67
+ }
68
+
69
+ /**
70
+ * Send a request to the profile's server as is: the escape hatch for what
71
+ * no command covers.
72
+ *
73
+ * @param {{
74
+ * method?: string,
75
+ * path: string,
76
+ * query?: Record<string, any>,
77
+ * body?: any,
78
+ * user?: string | null,
79
+ * timeout?: number,
80
+ * }} opts
81
+ */
82
+ export async function rawApiCall({ method, path, query, body, user, timeout }) {
83
+ const url = apiPath(path, user);
84
+ const m = apiMethod(method, body !== undefined);
85
+ try {
86
+ const res = await api.request({
87
+ method: m,
88
+ url,
89
+ params: query,
90
+ data: body,
91
+ responseType: 'arraybuffer',
92
+ timeout: timeout ?? TIMEOUTS.API_LONG_MS,
93
+ // Plain bodies (a string) go as text; objects as JSON.
94
+ headers: typeof body === 'string' ? { 'Content-Type': 'text/plain' } : undefined,
95
+ });
96
+ const raw = Buffer.from(res.data);
97
+ const decoded = decodeBody(raw, String(res.headers['content-type'] || ''));
98
+ // `raw`: the bytes as they came, for files (decoding is for showing).
99
+ return { status: res.status, contentType: res.headers['content-type'], ...decoded, raw };
100
+ } catch (e) {
101
+ // The error body arrives as bytes too: decode it so its message shows.
102
+ if (e?.response?.data instanceof ArrayBuffer || Buffer.isBuffer(e?.response?.data)) {
103
+ const { body: errBody } = decodeBody(
104
+ Buffer.from(e.response.data),
105
+ String(e.response.headers?.['content-type'] || ''),
106
+ );
107
+ e.response.data = errBody;
108
+ }
109
+ throw apiError(e, { notFound: `Not found: ${m} ${url}` });
110
+ }
111
+ }
package/lib/storage.js CHANGED
@@ -1,8 +1,37 @@
1
1
  import api from './api.js';
2
2
  import { TIMEOUTS } from './constants.js';
3
- import { apiError } from './errors.js';
3
+ import { apiError, inputError } from './errors.js';
4
4
  import { v1, v2 } from './paths.js';
5
5
 
6
+ /**
7
+ * A file path inside a storage as a URL path: each segment escaped, and
8
+ * none empty, `.` or `..` (the URL would point at another file, or at the
9
+ * storage itself).
10
+ *
11
+ * @param {string} filePath
12
+ */
13
+ export function storageFilePath(filePath) {
14
+ const parts = String(filePath ?? '')
15
+ .replace(/^\/+/, '')
16
+ .split('/');
17
+ if (!parts.length || parts.some((p) => !p || p === '.' || p === '..')) {
18
+ throw inputError(`invalid file path: "${filePath}"`);
19
+ }
20
+ return parts.map(encodeURIComponent).join('/');
21
+ }
22
+
23
+ /** A storage id, checked: it is a segment of every URL here. */
24
+ function sid(storageId) {
25
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(storageId ?? ''))) {
26
+ throw inputError(`invalid storage id: "${storageId}"`);
27
+ }
28
+ return storageId;
29
+ }
30
+
31
+ /** `/v2/users/{user}/storages/{storage}/files/{path}`, escaped. */
32
+ const fileUrl = (storageId, filePath, user) =>
33
+ `${v2(user)}/storages/${sid(storageId)}/files/${storageFilePath(filePath)}`;
34
+
6
35
  /**
7
36
  * Helpers for file storages — independent buckets that the
8
37
  * platform exposes alongside products. The product script workflow
@@ -16,7 +45,7 @@ import { v1, v2 } from './paths.js';
16
45
 
17
46
  export async function getStorage(storageId, user) {
18
47
  try {
19
- const res = await api.get(`${v1(user)}/storages/${storageId}`);
48
+ const res = await api.get(`${v1(user)}/storages/${sid(storageId)}`);
20
49
  return res.data || {};
21
50
  } catch (e) {
22
51
  throw apiError(e, { notFound: `Storage not found: ${storageId}` });
@@ -25,7 +54,7 @@ export async function getStorage(storageId, user) {
25
54
 
26
55
  export async function storageExists(storageId, user) {
27
56
  try {
28
- await api.get(`${v1(user)}/storages/${storageId}`);
57
+ await api.get(`${v1(user)}/storages/${sid(storageId)}`);
29
58
  return true;
30
59
  } catch (e) {
31
60
  if (e.response?.status === 404) return false;
@@ -52,7 +81,7 @@ export async function createStorage(
52
81
 
53
82
  export async function deleteStorage(storageId, user) {
54
83
  try {
55
- await api.delete(`${v1(user)}/storages/${storageId}`);
84
+ await api.delete(`${v1(user)}/storages/${sid(storageId)}`);
56
85
  return true;
57
86
  } catch (e) {
58
87
  if (e.response?.status === 404) return false;
@@ -62,7 +91,7 @@ export async function deleteStorage(storageId, user) {
62
91
 
63
92
  export async function listStorageFiles(storageId, user) {
64
93
  try {
65
- const res = await api.get(`${v1(user)}/storages/${storageId}/files`);
94
+ const res = await api.get(`${v1(user)}/storages/${sid(storageId)}/files`);
66
95
  return res.data || [];
67
96
  } catch (e) {
68
97
  if (e.response?.status === 404) return [];
@@ -76,7 +105,7 @@ export async function listStorageFiles(storageId, user) {
76
105
  */
77
106
  export async function readStorageFile(storageId, filePath, user) {
78
107
  try {
79
- const res = await api.get(`${v2(user)}/storages/${storageId}/files/${filePath}`, {
108
+ const res = await api.get(fileUrl(storageId, filePath, user), {
80
109
  transformResponse: [(x) => x],
81
110
  });
82
111
  return res.data;
@@ -87,7 +116,7 @@ export async function readStorageFile(storageId, filePath, user) {
87
116
 
88
117
  export async function storageFileExists(storageId, filePath, user) {
89
118
  try {
90
- await api.get(`${v2(user)}/storages/${storageId}/files/${filePath}`);
119
+ await api.get(fileUrl(storageId, filePath, user));
91
120
  return true;
92
121
  } catch (e) {
93
122
  if (e.response?.status === 404) return false;
@@ -97,11 +126,10 @@ export async function storageFileExists(storageId, filePath, user) {
97
126
 
98
127
  export async function writeStorageFile(storageId, filePath, content, user) {
99
128
  try {
100
- await api.put(
101
- `${v2(user)}/storages/${storageId}/files/${filePath}?overwrite=true`,
102
- content,
103
- { headers: { 'Content-Type': 'text/plain' }, timeout: TIMEOUTS.API_LONG_MS },
104
- );
129
+ await api.put(`${fileUrl(storageId, filePath, user)}?overwrite=true`, content, {
130
+ headers: { 'Content-Type': 'text/plain' },
131
+ timeout: TIMEOUTS.API_LONG_MS,
132
+ });
105
133
  } catch (e) {
106
134
  throw apiError(e);
107
135
  }
@@ -109,7 +137,7 @@ export async function writeStorageFile(storageId, filePath, content, user) {
109
137
 
110
138
  export async function deleteStorageFile(storageId, filePath, user) {
111
139
  try {
112
- await api.delete(`${v2(user)}/storages/${storageId}/files/${filePath}`);
140
+ await api.delete(fileUrl(storageId, filePath, user));
113
141
  return true;
114
142
  } catch (e) {
115
143
  if (e.response?.status === 404) return false;
package/lib/tokens.js CHANGED
@@ -105,6 +105,20 @@ export function resolveExpiry(value) {
105
105
  return Math.floor(Date.now() / 1000) + n * DURATION_MULT[unit];
106
106
  }
107
107
 
108
+ /**
109
+ * The expiry of an account token, which the server takes in unix
110
+ * milliseconds (device tokens take seconds: resolveExpiry). A duration
111
+ * ("30d") counts from now; a timestamp is taken as milliseconds, or as
112
+ * seconds when it is too small to be milliseconds (before 2001), as
113
+ * earlier versions of this CLI took it.
114
+ */
115
+ export function resolveExpiryMs(value) {
116
+ const resolved = resolveExpiry(value);
117
+ if (resolved === null) return null;
118
+ const duration = typeof value === 'string' && DURATION_RE.test(value.trim());
119
+ return duration || resolved < 1e12 ? resolved * 1000 : resolved;
120
+ }
121
+
108
122
  // ─── User-level token validators ────────────────────────────────────
109
123
 
110
124
  export const TOKEN_ID_RE = /^[a-zA-Z0-9_]{1,50}$/;
@@ -238,7 +252,7 @@ export async function createToken(body, user) {
238
252
  };
239
253
  if (body.deny) payload.deny = body.deny;
240
254
  if (body.description !== undefined) payload.description = String(body.description);
241
- const expire = resolveExpiry(body.expire);
255
+ const expire = resolveExpiryMs(body.expire);
242
256
  if (expire !== null && expire !== undefined) payload.expire = expire;
243
257
 
244
258
  try {
@@ -262,7 +276,7 @@ export async function updateToken(tokenId, patch, user) {
262
276
  if (patch.description !== undefined) body.description = String(patch.description);
263
277
  if (patch.enabled !== undefined) body.enabled = !!patch.enabled;
264
278
  if (patch.expire !== undefined) {
265
- const expire = resolveExpiry(patch.expire);
279
+ const expire = resolveExpiryMs(patch.expire);
266
280
  if (expire !== null && expire !== undefined) body.expire = expire;
267
281
  }
268
282
  if (patch.allow !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinger-io/cli-core",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Shared core of the Thinger.io CLIs and their MCP servers: API client, auth, profiles, generic commands and MCP tools",
5
5
  "type": "module",
6
6
  "exports": {