@thinger-io/cli-core 0.4.2 → 0.5.1
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 +207 -0
- package/README.md +82 -31
- package/lib/auth.js +25 -1
- package/lib/bucket-export.js +91 -0
- package/lib/cli.js +106 -50
- package/lib/commands/_shared.js +89 -1
- package/lib/commands/api.js +90 -0
- package/lib/commands/arguments.js +62 -0
- package/lib/commands/bucket.js +52 -44
- package/lib/commands/dashboard.js +5 -2
- package/lib/commands/data.js +539 -0
- package/lib/commands/device.js +128 -241
- package/lib/commands/grammar.js +279 -0
- package/lib/commands/product.js +104 -214
- package/lib/commands/profile.js +1 -0
- package/lib/commands/resources.js +975 -0
- package/lib/dashboard-init.js +4 -3
- package/lib/dashboard.js +10 -2
- package/lib/mcp/catalog.js +302 -64
- package/lib/mcp/core-types.js +36 -0
- package/lib/mcp/helpers.js +23 -2
- package/lib/mcp/naming.js +20 -0
- package/lib/mcp/registry.js +18 -67
- package/lib/mcp/resource-hooks.js +19 -5
- package/lib/mcp/server.js +43 -11
- package/lib/mcp/tools-resource.js +1234 -330
- package/lib/mcp/types/bucket-data.js +151 -0
- package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
- package/lib/mcp/types/device-resource.js +117 -0
- package/lib/mcp/types/doc.js +35 -0
- package/lib/mcp/types/plugin-call.js +75 -0
- package/lib/mcp/types/product-api.js +44 -0
- package/lib/mcp/types/profile.js +37 -0
- package/lib/mcp/types/storage-file.js +170 -0
- package/lib/openapi.js +18 -0
- package/lib/product-apply.js +121 -0
- package/lib/raw-api.js +111 -0
- package/lib/storage.js +41 -13
- package/lib/tokens.js +16 -2
- package/package.json +1 -1
- package/lib/mcp/tools-buckets.js +0 -130
- package/lib/mcp/tools-devices.js +0 -134
- package/lib/mcp/tools-misc.js +0 -48
- package/lib/mcp/tools-plugins.js +0 -63
- package/lib/mcp/tools-products.js +0 -52
- 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(
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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