@thinger-io/cli-core 0.2.1 → 0.4.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/CHANGELOG.md +87 -0
- package/README.md +31 -6
- package/docs/thinger-concepts.md +389 -0
- package/docs/thinger-product-code.md +159 -0
- package/docs/thinger-server-admin.md +165 -0
- package/lib/cli.js +13 -3
- package/lib/commands/auth.js +20 -1
- package/lib/commands/dashboard.js +262 -0
- package/lib/config.js +42 -0
- package/lib/dashboard-dev.js +180 -0
- package/lib/dashboard-init.js +417 -0
- package/lib/dashboard-sdk.js +184 -0
- package/lib/dashboard.js +171 -28
- package/lib/docs.js +110 -110
- package/lib/identity.js +2 -0
- package/lib/mcp/catalog.js +12 -2
- package/lib/mcp/registry.js +8 -6
- package/lib/mcp/server.js +11 -2
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +387 -50
- package/lib/tgz.js +66 -0
- package/package.json +3 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Product code
|
|
2
|
+
|
|
3
|
+
JavaScript that a product's profile calls to compute, transform or route data for its devices:
|
|
4
|
+
resource values (including simulated devices), payload transforms, the device a product call is
|
|
5
|
+
for. Platform concepts: the document `concepts`. It describes Thinger 7.5; what needs a later
|
|
6
|
+
version says "since Thinger X" (a server's version: `GET /v1/server/version`).
|
|
7
|
+
|
|
8
|
+
## Where it lives
|
|
9
|
+
|
|
10
|
+
`PUT /v1/users/{user}/products/{product}/profile/code` with
|
|
11
|
+
`{ "code": "<JavaScript>", "storage": "" }`, or `{ "storage": "<storage id>" }` to load
|
|
12
|
+
`<storage>/index.js` from a file storage (with its `node_modules`). Since Thinger 7.5, add
|
|
13
|
+
`"path": "<folder>"` to
|
|
14
|
+
load `<storage>/<folder>/index.js` (and that folder's `node_modules`) instead: one storage can then
|
|
15
|
+
hold a whole application, e.g. the dashboard in `app/` and each product's code in
|
|
16
|
+
`code/<product>/`. Read it back with `GET` on the same path.
|
|
17
|
+
|
|
18
|
+
The code takes effect at once: every change to the profile (code, any category or entry, or
|
|
19
|
+
enabling the product) reloads it. Editing `index.js` in the storage does **not**: `PUT
|
|
20
|
+
profile/code` again afterwards.
|
|
21
|
+
|
|
22
|
+
## Runtime
|
|
23
|
+
|
|
24
|
+
- Node.js embedded in the server, one instance per product.
|
|
25
|
+
- Functions are found by name as globals: declare them with `function name(…) {}` (or
|
|
26
|
+
`var name = …`, `globalThis.name = …`). Top-level `const` / `let` / `class` names and
|
|
27
|
+
`module.exports` are not visible to the profile.
|
|
28
|
+
- **Synchronous only.** There is no event loop: promises, `async` functions, timers, `fetch` and
|
|
29
|
+
network modules never complete (an `async` function returns `{}`). Synchronous built-ins
|
|
30
|
+
(`Math`, `Date`, `JSON`, `require('crypto')`…) work.
|
|
31
|
+
- `Date` runs in **UTC** (`getHours()` is the UTC hour). For local time use `Intl`, which knows
|
|
32
|
+
time zones and daylight saving:
|
|
33
|
+
`new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Madrid', hour: 'numeric', hourCycle: 'h23' }).formatToParts(new Date())`
|
|
34
|
+
gives the hour in Madrid.
|
|
35
|
+
- Return JSON values: objects, arrays, numbers, strings, booleans. A `Date` or other class
|
|
36
|
+
instance becomes `{}`: return `Date.now()` (milliseconds) instead.
|
|
37
|
+
- A function that throws returns `null`. There is no time limit: keep functions short.
|
|
38
|
+
- No access to the filesystem except reading user storages, no processes, no environment.
|
|
39
|
+
- The code cannot call Thinger directly (no API object). It talks to the platform only through
|
|
40
|
+
the profile: placeholders bring data in, targets send results out (below).
|
|
41
|
+
|
|
42
|
+
`console.log` / `info` / `warn` / `error` are sent as `product_script_log` events while someone
|
|
43
|
+
listens: the product's console in the web console, or the websocket
|
|
44
|
+
`/v1/users/{user}/products/{product}/profile/console`.
|
|
45
|
+
|
|
46
|
+
## Where functions are called
|
|
47
|
+
|
|
48
|
+
A function receives one argument (two for `device_id_resolver`) and returns the new value.
|
|
49
|
+
|
|
50
|
+
| Hook | Config | Receives | Returns |
|
|
51
|
+
|---|---|---|---|
|
|
52
|
+
| API resource, request target `function` | `api.<r>.request.data` `{ "target": "function", "function": "<name>" }` | the request payload, shaped by `payload_type` (below) | the resource result (with response `source: "request_response"`) |
|
|
53
|
+
| API resource, response source `function` | `api.<r>.response.data` `{ "source": "function", "function": "<name>" }` | `{ user, device, resource, product }` | the resource result |
|
|
54
|
+
| Payload transform | `payload_function: "<name>"` in a request, response, bucket, property or flow `data` | the payload after `payload_type` | the new payload (an empty result skips a bucket / property write or a flow) |
|
|
55
|
+
| Device of a product call | `device_id_resolver: "<name>"` at the root of an API resource or flow | `(payload, placeholders)` | a device id (string) |
|
|
56
|
+
| Placeholder converter | `{{<placeholder>:<name>}}` inside any template | the placeholder value | the replacement |
|
|
57
|
+
|
|
58
|
+
`payload_type` decides what a function or target gets:
|
|
59
|
+
|
|
60
|
+
- `template_payload` (default): the `payload` template with placeholders filled, parsed as JSON.
|
|
61
|
+
Without a `payload` it is empty, so a bucket or property entry writes nothing.
|
|
62
|
+
- `source_payload`: the incoming value as is (in a response, the target's result); the same as
|
|
63
|
+
`"payload": "{{payload}}"`.
|
|
64
|
+
- `source_event`: everything: `{ user, device, resource, product, payload }`.
|
|
65
|
+
- `none`: nothing.
|
|
66
|
+
|
|
67
|
+
Placeholders available in templates: `{{device}}`, `{{user}}`, `{{product}}`, `{{resource}}`,
|
|
68
|
+
`{{payload}}` / `{{payload.<field>}}`, `{{property.<id>}}` (a device property's value, inherited
|
|
69
|
+
like any device property: device → asset type → asset group → product, the first that defines it,
|
|
70
|
+
whole value),
|
|
71
|
+
`{{meta.<field>}}` (a field of the device document), `{{api.<resource>}}` (another API resource),
|
|
72
|
+
and `{{x=default}}` for defaults.
|
|
73
|
+
|
|
74
|
+
## Resources computed by code
|
|
75
|
+
|
|
76
|
+
An API resource whose request target is a function makes a value out of nothing: the basis of
|
|
77
|
+
simulated devices.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
"api": {
|
|
81
|
+
"climate": {
|
|
82
|
+
"enabled": true,
|
|
83
|
+
"request": { "data": { "target": "function", "function": "simulateClimate", "payload_type": "source_event" } },
|
|
84
|
+
"response": { "data": { "source": "request_response", "payload_type": "source_payload" } }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// ctx = { user, device, resource, product, payload }
|
|
91
|
+
function simulateClimate(ctx) {
|
|
92
|
+
var day = Math.sin(((Date.now() / 3600000) % 24 - 6) / 24 * 2 * Math.PI); // day/night cycle
|
|
93
|
+
return {
|
|
94
|
+
temperature: Math.round((22 + 6 * day + (Math.random() - 0.5)) * 10) / 10,
|
|
95
|
+
humidity: Math.round((60 - 15 * day + (Math.random() - 0.5) * 4) * 10) / 10
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`GET /v3/users/{user}/devices/{device}/resources/climate` then returns the object for any device
|
|
101
|
+
of the product, including `Virtual` devices, which have no hardware and get every resource from
|
|
102
|
+
their product. A profile bucket or property with `source: "resource"` and an interval calls the
|
|
103
|
+
resource for every connected device of the product on that schedule (see
|
|
104
|
+
the document `concepts`, Buckets).
|
|
105
|
+
|
|
106
|
+
Without `"response": { "source": "request_response" }` the function runs but the resource
|
|
107
|
+
returns nothing; without `payload_type: "source_payload"` (or `"payload": "{{payload}}"`) in the
|
|
108
|
+
response, it returns `null`.
|
|
109
|
+
|
|
110
|
+
## State between calls
|
|
111
|
+
|
|
112
|
+
- **In memory**: globals survive between calls of the same product, so
|
|
113
|
+
`globalThis.state = globalThis.state || {}; state[ctx.device] = …` keeps per-device state.
|
|
114
|
+
It is lost when the profile reloads (any profile change) and when the server restarts. Code
|
|
115
|
+
that keeps "what happened since the last call" in memory starts over after each reload: it can
|
|
116
|
+
repeat or skip one step (e.g. emit an event twice). Keep that cursor in a property if it matters.
|
|
117
|
+
- **Persistent**: keep the state in a device property. Read it with `{{property.<id>}}` in the
|
|
118
|
+
request payload template and write the function's result back with request target
|
|
119
|
+
`property`:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
"api": {
|
|
123
|
+
"tank": {
|
|
124
|
+
"enabled": true,
|
|
125
|
+
"request": { "data": {
|
|
126
|
+
"target": "property", "property": "tank",
|
|
127
|
+
"payload": "{\"prev\": {{property.tank}}, \"in\": {{payload}} }",
|
|
128
|
+
"payload_function": "stepTank" } },
|
|
129
|
+
"response": { "data": { "source": "request_response", "payload_type": "source_payload" } }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
// p = { prev: <last tank property or {}>, in: <input sent to the resource or null> }
|
|
136
|
+
function stepTank(p) {
|
|
137
|
+
var s = typeof p.prev.level === 'number' ? p.prev : { level: 50, inflow: 1, outflow: 0.8, ts: Date.now() };
|
|
138
|
+
var now = Date.now(), minutes = (now - s.ts) / 60000;
|
|
139
|
+
var inflow = p.in && p.in.inflow !== undefined ? p.in.inflow : s.inflow;
|
|
140
|
+
var outflow = p.in && p.in.outflow !== undefined ? p.in.outflow : s.outflow;
|
|
141
|
+
var level = Math.min(100, Math.max(0, s.level + (inflow - outflow) * minutes));
|
|
142
|
+
return { level: level, inflow: inflow, outflow: outflow, ts: now }; // saved as property "tank"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each call advances the level from the stored state; calling the resource with
|
|
147
|
+
`{ "inflow": 3 }` changes the flow. Do not combine it with a `{{payload=property.tank}}`
|
|
148
|
+
default: defaults that read a property re-run the resource when that property changes, and this
|
|
149
|
+
resource writes it.
|
|
150
|
+
|
|
151
|
+
## Limits and pitfalls
|
|
152
|
+
|
|
153
|
+
- No asynchronous work and no outgoing requests from code: to call an external service use an
|
|
154
|
+
endpoint (request target `endpoint_call`).
|
|
155
|
+
- One argument per function (two for `device_id_resolver`); use `payload_type: "source_event"`
|
|
156
|
+
to get the device id and the rest of the context.
|
|
157
|
+
- Keep per-call work small: functions run synchronously in the server, without a timeout.
|
|
158
|
+
- A product must be `enabled`, and every API / bucket / property / flow entry needs
|
|
159
|
+
`"enabled": true`, or it is skipped.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Thinger server administration
|
|
2
|
+
|
|
3
|
+
For agents acting as a server **admin**: host settings, domains and brands, user accounts,
|
|
4
|
+
OAuth clients, identity providers and proxies. Every route here needs an admin session or
|
|
5
|
+
token. Read the OpenAPI document for each body; this guide covers what the schemas cannot say:
|
|
6
|
+
order of steps, side effects and risks. It describes Thinger 7.5; what needs a later version says
|
|
7
|
+
"since Thinger X" (a server's version: `GET /v1/server/version`).
|
|
8
|
+
|
|
9
|
+
Before changing anything, read the current value and tell the user what will change. Several
|
|
10
|
+
actions here can take the server or a domain offline.
|
|
11
|
+
|
|
12
|
+
## Hosts and server settings
|
|
13
|
+
|
|
14
|
+
A host is one server process; its id is the machine's hostname (`GET /v1/hosts`).
|
|
15
|
+
|
|
16
|
+
- `GET /v1/hosts/{host}/settings` returns the editable settings (database, plugins and similar
|
|
17
|
+
internals are hidden).
|
|
18
|
+
- `PUT /v1/hosts/{host}/settings` is a **merge patch** (RFC 7386) over the host's custom
|
|
19
|
+
settings: send only the sections you change; `null` removes a custom value, so the provider
|
|
20
|
+
default applies again. Values are validated against the schema.
|
|
21
|
+
- **Settings apply only after a restart**: `POST /v1/hosts/{host}/restart`. The process exits
|
|
22
|
+
and the container restarts it: every connection drops (devices, consoles, websockets) for a
|
|
23
|
+
few seconds or more. Ask the user before restarting, and prefer a quiet moment.
|
|
24
|
+
- Ports are **strings** (`"1883"`), as the server reads them.
|
|
25
|
+
- Logs: `GET /v1/hosts/{host}/logs?lines=200` returns the last lines as text; the same route
|
|
26
|
+
over a websocket streams them live.
|
|
27
|
+
|
|
28
|
+
Main sections: `http_server`, `thing_server` (IOTMP), `mqtt_server`, `proxies` (dynamic port
|
|
29
|
+
range), `email` (server mail: `smtp` or `ses`), `buckets.storage` / `buckets.export`, `ssl`,
|
|
30
|
+
`accounts` (sign-up, login methods, password rules, `limits.<collection>.max_count` with -1 =
|
|
31
|
+
unlimited and 0 = disabled), `deployment` (`contact_email`: whoever registers with it becomes
|
|
32
|
+
admin), `scripting_engine`.
|
|
33
|
+
|
|
34
|
+
Some features are off by default (`max_count` 0): projects, project members, domains, brands and
|
|
35
|
+
identity providers. On Thinger cloud instances the subscription plan sets these limits; on a
|
|
36
|
+
self-hosted server raise `accounts.limits.<collection>.max_count` (`projects`,
|
|
37
|
+
`projects_members`, `domains`, `brands`, `identity_providers`) and restart. The domains and
|
|
38
|
+
brands limits are not shown by `GET settings`.
|
|
39
|
+
|
|
40
|
+
On cloud instances the plan owns `accounts.limits.{accounts,brands,domains,members}`, `database`,
|
|
41
|
+
`plugins`, `rate_limiter` and `http_server.hosts`: since Thinger 7.5, writing them returns 403.
|
|
42
|
+
To raise them, the customer changes the plan.
|
|
43
|
+
|
|
44
|
+
## Custom domain with its own brand
|
|
45
|
+
|
|
46
|
+
A **brand** is the look and login behaviour of the console for one hostname. Its id **is** the
|
|
47
|
+
hostname (`console.example.com`); there is no other link between a domain and a brand. The
|
|
48
|
+
**domain** resource only manages the TLS certificate of that hostname.
|
|
49
|
+
|
|
50
|
+
1. **DNS (done by the user).** Make the hostname resolve to this server: an `A` / `AAAA` record
|
|
51
|
+
with the server's public IP, or a `CNAME` to the server's existing hostname (the one the user
|
|
52
|
+
already uses to reach the console). There is no special verification value or TXT record.
|
|
53
|
+
Port **80** must be reachable from the internet. Tell the user exactly which record to
|
|
54
|
+
create, then wait until it resolves (it can take minutes; up to the record's TTL).
|
|
55
|
+
2. **Create the domain**: `POST /v1/domains` `{ "domain": "console.example.com" }`. No side
|
|
56
|
+
effects yet.
|
|
57
|
+
3. **Verify**: `POST /v1/domains/{domain}/verify`. The server asks Let's Encrypt for a
|
|
58
|
+
certificate (HTTP-01: Let's Encrypt fetches `http://<domain>/.well-known/acme-challenge/...`
|
|
59
|
+
from this server). On failure the error carries Let's Encrypt's reason (DNS not found,
|
|
60
|
+
connection refused, rate limit…): report it to the user as is. A certificate with more than
|
|
61
|
+
30 days left counts as verified. Wildcards (`*.example.com`) use DNS-01 and only work on
|
|
62
|
+
instances hosted by Thinger.
|
|
63
|
+
4. **Create the brand**: `POST /v1/brands` `{ "brand": "console.example.com", "name": ..., "config": {...} }`.
|
|
64
|
+
5. **Configure it**: texts, logos, colors, login page, PWA, account options (`config`), and its
|
|
65
|
+
email (`email`). See below.
|
|
66
|
+
6. Open `https://console.example.com` to check.
|
|
67
|
+
|
|
68
|
+
Certificates renew automatically (daily check, 30 days before expiry). If the DNS record is
|
|
69
|
+
later changed or removed, renewal fails silently and HTTPS breaks when the current certificate
|
|
70
|
+
expires. Deleting a domain removes its certificate at once.
|
|
71
|
+
|
|
72
|
+
Local or development builds may use Let's Encrypt **staging**, whose certificates browsers do not
|
|
73
|
+
trust: verification "works" but the site shows a warning.
|
|
74
|
+
|
|
75
|
+
### Brand configuration
|
|
76
|
+
|
|
77
|
+
- `PUT /v1/brands/{brand}` **replaces** `config` and `email` as a whole, and deletes the logo
|
|
78
|
+
files missing from the new `config`. Use it only with the full object.
|
|
79
|
+
- `PATCH /v1/brands/{brand}` **merges** into the stored ones (`null` removes a key). Prefer it:
|
|
80
|
+
`{ "config": { "title": "Acme IoT" } }` changes only the title.
|
|
81
|
+
- Logos and icons (`favicon`, `share_image`, `dark_logo` for login and exports, `bright_logo`
|
|
82
|
+
for the menu, `icon` for the folded menu, `pwa.icons[].src`) are sent as **data URLs**
|
|
83
|
+
(`data:image/png;base64,...` or webp). The server stores the file and keeps a
|
|
84
|
+
`/brand/images/...` URL. Sending that URL back keeps the image; `null` or `""` deletes it.
|
|
85
|
+
- `config.accounts` overrides the server's account settings for this hostname: public sign-up
|
|
86
|
+
(and its role), password / passkey login, 2FA, `cross_sign_in` (false: only users registered
|
|
87
|
+
on this hostname can log in), reCAPTCHA (`no_robot`), federated login and its identity
|
|
88
|
+
providers.
|
|
89
|
+
- The brand is readable without login (login pages need it); secrets in it are only returned to
|
|
90
|
+
admins.
|
|
91
|
+
|
|
92
|
+
### Brand email
|
|
93
|
+
|
|
94
|
+
`email.custom: true` with `email.settings` makes this brand send its emails (account
|
|
95
|
+
verification, password reset, account creation, device disabled) through its own server:
|
|
96
|
+
`type: "smtp"` with `smtp.host`, `smtp.port` (string), `username`, `password`, `secure` (true
|
|
97
|
+
for implicit TLS, e.g. 465; otherwise STARTTLS), or `type: "ses"` with AWS keys and region.
|
|
98
|
+
`settings.from` / `from_name` are the sender. `email.templates.<name>` can override `subject`
|
|
99
|
+
and `body` (HTML). Start from the defaults (`GET /v1/brands/{brand}/email` returns them) and keep
|
|
100
|
+
their links:
|
|
101
|
+
|
|
102
|
+
| Template | Link in the body |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `email_verification` | `{{host_url}}/verify_email?code={{code}}` |
|
|
105
|
+
| `forgot_password` | `{{host_url}}/password?code={{code}}` |
|
|
106
|
+
| `create_password` | `{{host_url}}/create_password?code={{code}}` |
|
|
107
|
+
| `device_disabled` | (no link; `{{device}}` is the device) |
|
|
108
|
+
|
|
109
|
+
Placeholders filled when sending: `{{code}}`, `{{user}}`, `{{email}}`, `{{host}}`, `{{admin}}`
|
|
110
|
+
(server contact email), `{{host_url}}`. `{{name}}`, `{{url}}`, `{{logo}}` and `{{description}}`
|
|
111
|
+
only exist in the built-in defaults: in a custom template write the brand name and the logo URL
|
|
112
|
+
(`https://<brand>/brand/images/dark_logo.png`) directly. Email clients do not load `data:` images,
|
|
113
|
+
so use that public URL. The `enabled` flag of a template is not enforced yet.
|
|
114
|
+
|
|
115
|
+
`POST /v1/brands/{brand}/email/test` `{ "template": "email_verification" }` sends that template to
|
|
116
|
+
the **calling admin's** email address with the **saved** settings: save first, then test.
|
|
117
|
+
|
|
118
|
+
## User accounts
|
|
119
|
+
|
|
120
|
+
`POST /v1/users` creates an account; `role` is `user`, `member` (only inside projects, needs a
|
|
121
|
+
default project), `domain_admin` (admin of the hostnames in its `host` list) or `admin`. Only
|
|
122
|
+
admins create admins. An empty password sends the "create password" email instead. Per-user
|
|
123
|
+
`limits` override the server limits. Deleting a user deletes **all** its resources (devices,
|
|
124
|
+
buckets, plugins, files): confirm explicitly.
|
|
125
|
+
|
|
126
|
+
## OAuth clients
|
|
127
|
+
|
|
128
|
+
Applications that log users in with this server (authorization code or device flow).
|
|
129
|
+
`redirect_uri` is a **newline-separated** list of exact URLs. The `secret` is stored and returned
|
|
130
|
+
in plain text to admins: handle it as a credential. `enabled: false` blocks the client.
|
|
131
|
+
|
|
132
|
+
## Identity providers (log in with Google, Microsoft…)
|
|
133
|
+
|
|
134
|
+
OIDC providers users can log in with.
|
|
135
|
+
|
|
136
|
+
1. Allow them: `accounts.limits.identity_providers.max_count` in the host settings (0 by
|
|
137
|
+
default), then restart.
|
|
138
|
+
2. Register this server in the provider: callback URL `https://<brand or console host>/oauth/callback`.
|
|
139
|
+
3. Create the provider with its `domain` (issuer), `client_id`, `client_secret`, provisioning
|
|
140
|
+
(`auto_provision` creates accounts, `match_only` only links existing ones; `allowed_domains`
|
|
141
|
+
limits email domains) and `email_trust_mode`.
|
|
142
|
+
4. Test it: `POST /v1/identity_providers/{id}/test` checks discovery and keys.
|
|
143
|
+
5. Turn on `federated_login` (host `accounts` or the brand's `config.accounts`) and optionally
|
|
144
|
+
restrict `identity_providers` to some ids.
|
|
145
|
+
|
|
146
|
+
Auto-provisioning creates accounts even when public sign-up is off.
|
|
147
|
+
|
|
148
|
+
## Proxies
|
|
149
|
+
|
|
150
|
+
Expose a TCP / UDP service, a plugin, or a device service through this server.
|
|
151
|
+
|
|
152
|
+
- Created and updated proxies start or replace a **live listener** at once; deleting one closes
|
|
153
|
+
the port. A wrong port can collide with other services.
|
|
154
|
+
- `source.routing: "subdomain"` serves `https://<proxy id>.<server hostname>` and needs a
|
|
155
|
+
wildcard certificate for that hostname.
|
|
156
|
+
- Send the full `config` on update: a partial one works until the next restart and then breaks.
|
|
157
|
+
|
|
158
|
+
## Safety checklist
|
|
159
|
+
|
|
160
|
+
- Read before write; describe the change; keep the previous value to undo it.
|
|
161
|
+
- Never restart, delete a domain, delete a user or change login settings without the user's
|
|
162
|
+
explicit confirmation.
|
|
163
|
+
- Changing `accounts` login options (password, 2FA, cross sign-in, sign-up) can lock users out:
|
|
164
|
+
make sure an admin can still log in.
|
|
165
|
+
- Do not echo secrets (SMTP passwords, client secrets, AWS keys) back to the user unless asked.
|
package/lib/cli.js
CHANGED
|
@@ -9,27 +9,32 @@ import { registerProfileCommands } from './commands/profile.js';
|
|
|
9
9
|
import { registerDeviceCommands } from './commands/device.js';
|
|
10
10
|
import { registerProductCommands } from './commands/product.js';
|
|
11
11
|
import { registerBucketCommands } from './commands/bucket.js';
|
|
12
|
+
import { registerDashboardCommands } from './commands/dashboard.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Build and run a CLI on top of the core: global options, the generic
|
|
15
|
-
* commands (login, logout, profile, device, product, bucket) and `mcp`.
|
|
16
|
+
* commands (login, logout, profile, device, product, bucket, dashboard) and `mcp`.
|
|
16
17
|
* The identity must be defined before calling it.
|
|
17
18
|
*
|
|
18
19
|
* @param {{
|
|
19
20
|
* version: string,
|
|
20
21
|
* argv?: string[],
|
|
21
|
-
* register?: (program: Command, groups: { device: Command, product: Command, bucket: Command }) => void,
|
|
22
|
+
* register?: (program: Command, groups: { device: Command, product: Command, bucket: Command, dashboard: Command }) => void,
|
|
22
23
|
* onNoCommand?: (program: Command) => Promise<void> | void,
|
|
23
24
|
* mcpTools?: Array<object>,
|
|
24
25
|
* mcpInstructions?: string,
|
|
25
26
|
* resourceHooks?: Record<string, import('./mcp/resource-hooks.js').ResourceHooks>,
|
|
27
|
+
* resourceTypes?: Record<string, import('./mcp/tools-resource.js').LocalType>,
|
|
28
|
+
* resourceActions?: Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>,
|
|
26
29
|
* docs?: Parameters<typeof import('./docs.js').defineCliDocs>[0],
|
|
27
30
|
* }} opts
|
|
28
31
|
* `register` adds the CLI's own commands, at the top level or inside the
|
|
29
32
|
* generic groups. `onNoCommand` runs when the CLI is called without
|
|
30
33
|
* arguments (default: print the help). `mcpTools` are the CLI's own MCP
|
|
31
34
|
* tools (see buildTools), and `resourceHooks` its hooks on the
|
|
32
|
-
* catalog types the generic `resource_*` tools serve
|
|
35
|
+
* catalog types the generic `resource_*` tools serve; `resourceTypes` and
|
|
36
|
+
* `resourceActions` are types and actions it adds to that catalog (its
|
|
37
|
+
* platform's own things, served by the same tools). `docs` are guides
|
|
33
38
|
* the CLI ships, served by `doc_read` and as MCP resources next to the
|
|
34
39
|
* server's documentation.
|
|
35
40
|
*/
|
|
@@ -41,6 +46,8 @@ export async function runCli({
|
|
|
41
46
|
mcpTools = [],
|
|
42
47
|
mcpInstructions,
|
|
43
48
|
resourceHooks,
|
|
49
|
+
resourceTypes,
|
|
50
|
+
resourceActions,
|
|
44
51
|
docs,
|
|
45
52
|
}) {
|
|
46
53
|
const id = identity();
|
|
@@ -75,6 +82,7 @@ export async function runCli({
|
|
|
75
82
|
device: registerDeviceCommands(program),
|
|
76
83
|
product: registerProductCommands(program),
|
|
77
84
|
bucket: registerBucketCommands(program),
|
|
85
|
+
dashboard: registerDashboardCommands(program),
|
|
78
86
|
};
|
|
79
87
|
registerAuthCommands(program);
|
|
80
88
|
registerProfileCommands(program);
|
|
@@ -91,6 +99,8 @@ export async function runCli({
|
|
|
91
99
|
tools: mcpTools,
|
|
92
100
|
instructions: mcpInstructions,
|
|
93
101
|
resourceHooks,
|
|
102
|
+
resourceTypes,
|
|
103
|
+
resourceActions,
|
|
94
104
|
docs,
|
|
95
105
|
});
|
|
96
106
|
} catch (error) {
|
package/lib/commands/auth.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
setActiveProfile,
|
|
11
11
|
} from '../config.js';
|
|
12
12
|
import { hint, info, warning } from '../format.js';
|
|
13
|
+
import { env, identity } from '../identity.js';
|
|
13
14
|
import { classifyError, createSpinner, printErr } from '../output.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -46,6 +47,13 @@ export function registerAuthCommands(program) {
|
|
|
46
47
|
clientId: opts.clientId,
|
|
47
48
|
});
|
|
48
49
|
console.log(info(`Signed in (profile ${getActiveProfile()}).`));
|
|
50
|
+
if (env('TOKEN')) {
|
|
51
|
+
console.log(
|
|
52
|
+
warning(
|
|
53
|
+
`${identity().envPrefix}_TOKEN is set: commands use it instead of this profile until you unset it.`,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
49
57
|
} catch (error) {
|
|
50
58
|
const { message, code } = classifyError(error);
|
|
51
59
|
printErr(message, { code });
|
|
@@ -65,7 +73,18 @@ export function registerAuthCommands(program) {
|
|
|
65
73
|
console.log(warning('No configuration found. Already logged out.'));
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
68
|
-
const
|
|
76
|
+
const active = getActiveProfile();
|
|
77
|
+
if (!opts.all && !active && env('TOKEN')) {
|
|
78
|
+
// <PREFIX>_TOKEN stands for the profile: there is nothing saved to forget.
|
|
79
|
+
const prefix = identity().envPrefix;
|
|
80
|
+
console.log(
|
|
81
|
+
warning(
|
|
82
|
+
`${prefix}_TOKEN is set and stands for the profile: unset it, or pass --profile to log out of a saved one.`,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const names = opts.all ? listProfiles() : [active];
|
|
69
88
|
for (const name of names) {
|
|
70
89
|
const spinner = createSpinner(`Logging out of ${name}...`).start();
|
|
71
90
|
if (!opts.keepToken) {
|