@thinger-io/cli-core 0.1.1 → 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.
package/lib/docs.js ADDED
@@ -0,0 +1,182 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import api from './api.js';
4
+ import { apiError, inputError } from './errors.js';
5
+
6
+ /**
7
+ * Documentation the server publishes for agents and app developers: a
8
+ * guide to the platform's concepts, the dashboard SDK reference, its
9
+ * TypeScript declarations… listed in the server's own index
10
+ * (`/sdk/docs.json`), so a new guide reaches the model by being added
11
+ * there, without a new CLI release. Always read from the server the profile
12
+ * points at, so it matches the version it runs.
13
+ *
14
+ * @typedef {object} Doc
15
+ * @property {string} id
16
+ * @property {string} title
17
+ * @property {string} [description]
18
+ * @property {string} [path] Path on the same server (`/sdk/…`), for the server's documents.
19
+ * @property {string} [file] Local file, for the documents a CLI ships.
20
+ * @property {string} [format] markdown, typescript, json…
21
+ */
22
+
23
+ /** @type {Doc[]} */
24
+ let cliDocs = [];
25
+
26
+ /**
27
+ * Documents the CLI itself ships (its own platform's guides), listed next
28
+ * to the server's. They travel with the CLI because they describe what the
29
+ * CLI implements on top of the server. Ids must not clash with the server's:
30
+ * the CLI's win.
31
+ *
32
+ * @param {Array<{ id: string, title: string, description?: string, format?: string, file: string | URL }>} docs
33
+ */
34
+ export function defineCliDocs(docs = []) {
35
+ for (const d of docs) {
36
+ if (!d || typeof d.id !== 'string' || !d.title || !d.file) {
37
+ throw new Error(`CLI document needs id, title and file: ${JSON.stringify(d)}`);
38
+ }
39
+ }
40
+ cliDocs = docs.map((d) => ({
41
+ ...d,
42
+ file: d.file instanceof URL ? d.file.pathname : String(d.file),
43
+ format: d.format || 'markdown',
44
+ }));
45
+ }
46
+
47
+ const INDEX = '/sdk/docs.json';
48
+
49
+ /** Documents servers published before the index existed: looked for when there is no index. */
50
+ /** @type {Doc[]} */
51
+ export const FALLBACK_DOCS = [
52
+ {
53
+ id: 'concepts',
54
+ title: 'Thinger concepts',
55
+ description: 'How the platform pieces relate, and which data source to use for each need.',
56
+ path: '/sdk/thinger-concepts.md',
57
+ format: 'markdown',
58
+ },
59
+ {
60
+ id: 'dashboard-sdk',
61
+ title: 'Dashboard SDK reference',
62
+ description: 'Build url dashboards and web apps that run inside the console.',
63
+ path: '/sdk/thinger-dashboard.md',
64
+ format: 'markdown',
65
+ },
66
+ {
67
+ id: 'dashboard-sdk-types',
68
+ title: 'Dashboard SDK TypeScript declarations',
69
+ description: 'Type declarations for the global Thinger object.',
70
+ path: '/sdk/thinger-dashboard.d.ts',
71
+ format: 'typescript',
72
+ },
73
+ ];
74
+
75
+ /** A path on the profile's own server: never another host, never a parent folder. */
76
+ const LOCAL_PATH = /^\/(?!\/)[^?#\\]*$/;
77
+
78
+ export function validDoc(d) {
79
+ return (
80
+ d &&
81
+ typeof d.id === 'string' &&
82
+ /^[a-z0-9][a-z0-9._-]*$/i.test(d.id) &&
83
+ typeof d.path === 'string' &&
84
+ LOCAL_PATH.test(d.path) &&
85
+ !d.path.split('/').includes('..')
86
+ );
87
+ }
88
+
89
+ /** Text of a file on the server, whatever type it is labelled with (dev servers say video/mp2t for .ts). */
90
+ async function readText(path) {
91
+ const res = await api.get(path, { transformResponse: [(x) => x], responseType: 'text' });
92
+ return String(res.data ?? '');
93
+ }
94
+
95
+ /**
96
+ * The documents the server publishes, from its index; the known ones when
97
+ * it has no index.
98
+ *
99
+ * @returns {Promise<Doc[]>}
100
+ */
101
+ async function listServerDocs() {
102
+ let raw;
103
+ try {
104
+ raw = await readText(INDEX);
105
+ } catch (e) {
106
+ if (e.response?.status === 404) return publishedFallbacks();
107
+ throw apiError(e);
108
+ }
109
+ let docs;
110
+ try {
111
+ docs = JSON.parse(raw).docs;
112
+ } catch {
113
+ throw inputError(`The server's documentation index (${INDEX}) is not valid JSON`);
114
+ }
115
+ return Array.isArray(docs)
116
+ ? docs.filter(validDoc).map((d) => ({ ...d, title: d.title || d.id }))
117
+ : [];
118
+ }
119
+
120
+ /**
121
+ * Every document available: the CLI's own, then the server's. When the
122
+ * server cannot be read, the CLI's alone (and the error if there are none).
123
+ *
124
+ * @returns {Promise<Doc[]>}
125
+ */
126
+ export async function listDocs() {
127
+ let server;
128
+ try {
129
+ server = await listServerDocs();
130
+ } catch (e) {
131
+ if (!cliDocs.length) throw e;
132
+ server = [];
133
+ }
134
+ const own = new Set(cliDocs.map((d) => d.id));
135
+ return [...cliDocs, ...server.filter((d) => !own.has(d.id))];
136
+ }
137
+
138
+ /** Without an index, the known documents this server actually serves. */
139
+ async function publishedFallbacks() {
140
+ const served = await Promise.all(
141
+ FALLBACK_DOCS.map((d) =>
142
+ readText(d.path).then(
143
+ () => true,
144
+ () => false,
145
+ ),
146
+ ),
147
+ );
148
+ return FALLBACK_DOCS.filter((_, i) => served[i]);
149
+ }
150
+
151
+ /**
152
+ * One document by id.
153
+ *
154
+ * @param {string} id
155
+ * @returns {Promise<{ doc: Doc, text: string }>}
156
+ */
157
+ export async function readDoc(id) {
158
+ const docs = await listDocs();
159
+ const doc = docs.find((d) => d.id === id);
160
+ if (!doc) {
161
+ throw inputError(
162
+ `Unknown document "${id}". Available: ${docs.map((d) => d.id).join(', ') || 'none'}.`,
163
+ );
164
+ }
165
+ if (doc.file) return { doc, text: fs.readFileSync(doc.file, 'utf8') };
166
+ try {
167
+ return { doc, text: await readText(doc.path) };
168
+ } catch (e) {
169
+ if (e.response?.status === 404) {
170
+ throw inputError(`This server does not publish "${doc.title}" (${doc.path}).`);
171
+ }
172
+ throw apiError(e);
173
+ }
174
+ }
175
+
176
+ /** MIME type for a document format. */
177
+ export function docMimeType(format) {
178
+ return (
179
+ { markdown: 'text/markdown', json: 'application/json', html: 'text/html' }[format] ||
180
+ 'text/plain'
181
+ );
182
+ }
package/lib/errors.js CHANGED
@@ -22,15 +22,29 @@ export function apiError(error, messages = {}) {
22
22
  if (error && error.response) {
23
23
  const status = error.response.status;
24
24
  if (status === 404) {
25
- const err = new Error(messages.notFound || 'Not found');
25
+ // The caller names what it looked for; the server may say that
26
+ // something else was missing ("file not found" on an import).
27
+ const detail = serverMessage(error.response.data);
28
+ const base = messages.notFound || 'Not found';
29
+ const err = new Error(
30
+ detail && !/^(resource )?not found\.?$/i.test(detail)
31
+ ? `${base} (server: ${detail})`
32
+ : base,
33
+ );
26
34
  err.code = 'not_found';
27
35
  err.status = 404;
28
36
  return err;
29
37
  }
30
38
  if (status === 401 || status === 403) {
39
+ // 401: the token is not valid (expired, revoked). 403: it is,
40
+ // but lacks the permission, or the account the feature: logging
41
+ // in again would not help, so don't suggest it.
42
+ const detail = serverMessage(error.response.data);
31
43
  const err = new Error(
32
44
  messages.unauthorized ||
33
- 'Unauthorized. Your token may have expired. Please reconfigure.',
45
+ (status === 401
46
+ ? 'Unauthorized. Your token may have expired. Please reconfigure.'
47
+ : `Forbidden: the token or the account is not allowed to do this${detail ? ` (${detail})` : ''}.`),
34
48
  );
35
49
  err.code = 'unauthorized';
36
50
  err.status = status;
@@ -47,6 +61,15 @@ export function apiError(error, messages = {}) {
47
61
  err.status = status;
48
62
  return err;
49
63
  }
64
+ // Axios gave up waiting: the server may still be doing the work.
65
+ if (error?.code === 'ECONNABORTED' && /timeout/i.test(error.message) && error.config) {
66
+ const seconds = Math.round((error.config.timeout || 0) / 1000);
67
+ const err = new Error(
68
+ `No answer from the server within ${seconds} s. It may still be working on the request: check its state before retrying.`,
69
+ );
70
+ err.code = 'timeout';
71
+ return err;
72
+ }
50
73
  if (error && error.request) {
51
74
  const err = new Error('No response from server. Please check your connection.');
52
75
  err.code = 'network_error';
@@ -58,14 +81,16 @@ export function apiError(error, messages = {}) {
58
81
  }
59
82
 
60
83
  /** The message a server error body carries, if any: `{ error: { message } }`,
61
- * `{ message }`, or a short plain-text body ("cannot create more products"). */
84
+ * `{ message }`, `{ error: "…" }` (what device resources answer, relayed
85
+ * as is), or a short plain-text body ("cannot create more products"). */
62
86
  function serverMessage(data) {
63
87
  if (typeof data === 'string') {
64
88
  const text = data.trim().replace(/^"|"$/g, '');
65
89
  return text && text.length <= 300 && !text.startsWith('<') ? text : null;
66
90
  }
67
91
  if (!data || typeof data !== 'object') return null;
68
- const message = data.error?.message ?? data.message;
92
+ const message =
93
+ typeof data.error === 'string' ? data.error : (data.error?.message ?? data.message);
69
94
  return typeof message === 'string' && message ? message : null;
70
95
  }
71
96