@thinger-io/cli-core 0.3.0 → 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 +67 -0
- package/README.md +19 -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 +4 -2
- 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 +2 -2
- package/lib/mcp/registry.js +4 -6
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +2 -2
- 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,16 +9,17 @@ 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,
|
|
@@ -81,6 +82,7 @@ export async function runCli({
|
|
|
81
82
|
device: registerDeviceCommands(program),
|
|
82
83
|
product: registerProductCommands(program),
|
|
83
84
|
bucket: registerBucketCommands(program),
|
|
85
|
+
dashboard: registerDashboardCommands(program),
|
|
84
86
|
};
|
|
85
87
|
registerAuthCommands(program);
|
|
86
88
|
registerProfileCommands(program);
|
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) {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import {
|
|
5
|
+
collectFiles,
|
|
6
|
+
destinationContext,
|
|
7
|
+
manifestName,
|
|
8
|
+
parseDestination,
|
|
9
|
+
publishApp,
|
|
10
|
+
publishSummary,
|
|
11
|
+
readManifest,
|
|
12
|
+
} from '../dashboard.js';
|
|
13
|
+
import { DEV_HOURS, devLink } from '../dashboard-dev.js';
|
|
14
|
+
import { DEFAULT_TEMPLATE, initApp } from '../dashboard-init.js';
|
|
15
|
+
import { inputError } from '../errors.js';
|
|
16
|
+
import { accent, hint, warning } from '../format.js';
|
|
17
|
+
import { identity } from '../identity.js';
|
|
18
|
+
import { isJsonMode, printOk } from '../output.js';
|
|
19
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser, runCommand } from './_shared.js';
|
|
20
|
+
|
|
21
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
22
|
+
|
|
23
|
+
/** Destinations of the command line, each with the shared --entry / --route / --name. */
|
|
24
|
+
function flagDestinations(opts) {
|
|
25
|
+
return (opts.to || []).map((value) => ({
|
|
26
|
+
...parseDestination(value),
|
|
27
|
+
...(opts.entry ? { entry: opts.entry } : {}),
|
|
28
|
+
...(opts.route ? { route: opts.route } : {}),
|
|
29
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `<cli> dashboard dev|publish` — url-source dashboard apps. */
|
|
34
|
+
export function registerDashboardCommands(program) {
|
|
35
|
+
const cli = identity().name;
|
|
36
|
+
const dashboard = program
|
|
37
|
+
.command('dashboard')
|
|
38
|
+
.helpGroup('Operations:')
|
|
39
|
+
.description(
|
|
40
|
+
'Dashboard apps (HTML + the dashboard SDK): develop them on their dev server and publish them',
|
|
41
|
+
)
|
|
42
|
+
.addHelpText(
|
|
43
|
+
'after',
|
|
44
|
+
`
|
|
45
|
+
Both commands read defaults from ${manifestName()} in the working folder (or --config):
|
|
46
|
+
{ "dashboard": { "dir": "dist/greenhouse/browser", "storage": "app_greenhouse", "prune": true,
|
|
47
|
+
"to": [{ "type": "device", "id": "gh_01" }],
|
|
48
|
+
"dev": { "url": "http://localhost:4200/", "context": { "device": "gh_01" } } } }
|
|
49
|
+
|
|
50
|
+
In CI, ${identity().envPrefix}_TOKEN and ${identity().envPrefix}_SERVER replace the saved profile (unless --profile or ${identity().envPrefix}_PROFILE names one).`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
dashboard
|
|
54
|
+
.command('init <dir>')
|
|
55
|
+
.description(
|
|
56
|
+
'Create a dashboard app (TypeScript, on @thinger-io/dashboard-sdk) from a template of the SDK',
|
|
57
|
+
)
|
|
58
|
+
.option(
|
|
59
|
+
'-t, --template <name>',
|
|
60
|
+
`Template of the SDK package: react, angular… (default ${DEFAULT_TEMPLATE})`,
|
|
61
|
+
)
|
|
62
|
+
.option(
|
|
63
|
+
'--to <destination>',
|
|
64
|
+
'Where it publishes: device:<id>, product:<id>, dashboard:<id>…',
|
|
65
|
+
)
|
|
66
|
+
.option('-s, --storage <id>', 'Storage it publishes to (default app_<folder>_<random>)')
|
|
67
|
+
.option(
|
|
68
|
+
'--from <package>',
|
|
69
|
+
'SDK package to take the template from: a version or tag on npm, or a folder or .tgz',
|
|
70
|
+
)
|
|
71
|
+
.option('--ci', 'Add a GitHub workflow that publishes it on every push to main')
|
|
72
|
+
.option('--install', 'Run npm install')
|
|
73
|
+
.option('-j, --json', 'Output as JSON')
|
|
74
|
+
.action(async (dir, opts) => {
|
|
75
|
+
applyJsonFlag(opts);
|
|
76
|
+
await runCommand({
|
|
77
|
+
start: 'Creating the app...',
|
|
78
|
+
fn: () =>
|
|
79
|
+
initApp({
|
|
80
|
+
dir,
|
|
81
|
+
template: opts.template,
|
|
82
|
+
to: opts.to ? parseDestination(opts.to) : undefined,
|
|
83
|
+
storage: opts.storage,
|
|
84
|
+
from: opts.from,
|
|
85
|
+
ci: !!opts.ci,
|
|
86
|
+
install: !!opts.install,
|
|
87
|
+
}),
|
|
88
|
+
success: (r) =>
|
|
89
|
+
`Created ${r.name} (${r.template}, ${r.sdk ? `SDK ${r.sdk}` : 'SDK'})`,
|
|
90
|
+
failure: 'Failed to create the app',
|
|
91
|
+
onSuccess: (r) => {
|
|
92
|
+
if (isJsonMode()) {
|
|
93
|
+
printOk(r);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(
|
|
97
|
+
hint(
|
|
98
|
+
` ${r.dir}: ${r.template} app on ${r.dependency.startsWith('file:') ? r.dependency : `@thinger-io/dashboard-sdk ${r.dependency}`}, ${manifestName()} (publishes ${r.build} to ${r.storage}) and AGENTS.md.`,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
console.log(`\n cd ${dir}`);
|
|
102
|
+
if (!r.installed) console.log(' npm install');
|
|
103
|
+
console.log(` ${r.devCommand}`);
|
|
104
|
+
console.log(
|
|
105
|
+
` ${cli} dashboard dev${Object.keys(r.context).length ? '' : ' --device <id>'} ${hint('# link to the running app against the server')}`,
|
|
106
|
+
);
|
|
107
|
+
console.log(` npm run build && ${cli} dashboard publish`);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
dashboard
|
|
113
|
+
.command('dev [url]')
|
|
114
|
+
.description(
|
|
115
|
+
'Link that opens the app on its own dev server (ng serve, Vite…) against the server, with a temporary token',
|
|
116
|
+
)
|
|
117
|
+
.option(
|
|
118
|
+
'--to <destination>',
|
|
119
|
+
'Context of a destination: device:<id>, product:<id>, dashboard:<id>…',
|
|
120
|
+
)
|
|
121
|
+
.option('--device <id>', 'Device in the SDK context')
|
|
122
|
+
.option('--product <id>', 'Product in the SDK context')
|
|
123
|
+
.option('--asset-type <id>', 'Asset type in the SDK context')
|
|
124
|
+
.option('--asset-group <id>', 'Asset group in the SDK context')
|
|
125
|
+
.option('--project <id>', 'Project the app runs in')
|
|
126
|
+
.option('--route <route>', 'Initial route, e.g. /devices')
|
|
127
|
+
.option('--hours <hours>', `Hours the token lasts (default ${DEV_HOURS})`, Number)
|
|
128
|
+
.option('--open', 'Open the link in the browser')
|
|
129
|
+
.option(
|
|
130
|
+
'--allow-remote',
|
|
131
|
+
'Allow an app url that is not on this machine (the link carries a full-access token)',
|
|
132
|
+
)
|
|
133
|
+
.option('--config <file>', `Manifest (default ${manifestName()})`)
|
|
134
|
+
.option('-j, --json', 'Output as JSON')
|
|
135
|
+
.addHelpText(
|
|
136
|
+
'after',
|
|
137
|
+
`
|
|
138
|
+
Start the app's dev server first (the command warns when nothing answers at the url).
|
|
139
|
+
The app bundles @thinger-io/dashboard-sdk, which takes the server, a temporary token and the
|
|
140
|
+
context from the link: its dev server needs no proxy. Each run replaces the previous link's
|
|
141
|
+
token (one per project).`,
|
|
142
|
+
)
|
|
143
|
+
.action(async (url, opts, cmd) => {
|
|
144
|
+
applyJsonFlag(opts);
|
|
145
|
+
ensureConfigured();
|
|
146
|
+
const user = getGlobalUser(cmd);
|
|
147
|
+
await runCommand({
|
|
148
|
+
start: 'Creating the dev link...',
|
|
149
|
+
fn: async () => {
|
|
150
|
+
const manifest = readManifest(opts.config);
|
|
151
|
+
const dev = manifest?.dev || {};
|
|
152
|
+
const app = url || dev.url;
|
|
153
|
+
if (!app) {
|
|
154
|
+
throw inputError(
|
|
155
|
+
`pass the app's dev server url (e.g. ${cli} dashboard dev http://localhost:4200/ --device gw1), or set dev.url in ${manifestName()}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const explicit = {
|
|
159
|
+
device: opts.device,
|
|
160
|
+
product: opts.product,
|
|
161
|
+
type: opts.assetType,
|
|
162
|
+
group: opts.assetGroup,
|
|
163
|
+
project: opts.project,
|
|
164
|
+
};
|
|
165
|
+
const to = opts.to ? parseDestination(opts.to) : manifest?.to?.[0];
|
|
166
|
+
const context = Object.values(explicit).some(Boolean)
|
|
167
|
+
? explicit
|
|
168
|
+
: !opts.to && dev.context
|
|
169
|
+
? dev.context
|
|
170
|
+
: destinationContext(to);
|
|
171
|
+
return devLink({
|
|
172
|
+
url: app,
|
|
173
|
+
context,
|
|
174
|
+
route: opts.route ?? dev.route ?? to?.route,
|
|
175
|
+
hours: opts.hours ?? dev.hours,
|
|
176
|
+
user,
|
|
177
|
+
dir: manifest ? path.dirname(manifest.file) : process.cwd(),
|
|
178
|
+
allowRemote: !!opts.allowRemote,
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
success: () => 'Dev link ready',
|
|
182
|
+
failure: 'Failed to create the dev link',
|
|
183
|
+
onSuccess: async (dev) => {
|
|
184
|
+
if (isJsonMode()) printOk(dev);
|
|
185
|
+
else {
|
|
186
|
+
const ctx = Object.entries(dev.context).map(([k, v]) => `${k} ${v}`);
|
|
187
|
+
const until = new Date(dev.expire).toTimeString().slice(0, 5);
|
|
188
|
+
console.log(` ${accent(dev.link)}`);
|
|
189
|
+
console.log(
|
|
190
|
+
hint(
|
|
191
|
+
` ${dev.app} → ${dev.server} as ${dev.user}${ctx.length ? `, ${ctx.join(', ')}` : ''}. Temporary token ${dev.token} until ${until}: writes are real.`,
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
for (const note of dev.notes) console.log(warning(` ${note}`));
|
|
195
|
+
}
|
|
196
|
+
if (opts.open) await open(dev.link).catch(() => {});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
dashboard
|
|
202
|
+
.command('publish [dir]')
|
|
203
|
+
.description('Upload an app to a public storage and point dashboards at it')
|
|
204
|
+
.option('-s, --storage <id>', 'Storage of the app (created public if missing)')
|
|
205
|
+
.option(
|
|
206
|
+
'--to <destination>',
|
|
207
|
+
'Dashboard to point (repeatable): dashboard:<id>, device:<id>, product:<id>, asset_type:<id>, asset_group:<id>, project:<id>',
|
|
208
|
+
collect,
|
|
209
|
+
)
|
|
210
|
+
.option('--entry <page>', 'Page the destinations open (default index.html)')
|
|
211
|
+
.option('--route <route>', 'Initial route of the destinations')
|
|
212
|
+
.option('--name <name>', 'Name of a collection dashboard')
|
|
213
|
+
.option('--prune', 'Delete files of the storage that are not in this upload')
|
|
214
|
+
.option('--no-prune', "Keep the storage's other files (overrides the manifest)")
|
|
215
|
+
.option('--bump', 'Make consoles that have the app open reload it at once')
|
|
216
|
+
.option(
|
|
217
|
+
'--preview [hours]',
|
|
218
|
+
'Also print standalone preview links (read-only, 1 h by default)',
|
|
219
|
+
)
|
|
220
|
+
.option('--no-point', 'Upload without pointing the destinations (preview links only)')
|
|
221
|
+
.option('--config <file>', `Manifest (default ${manifestName()})`)
|
|
222
|
+
.option('-j, --json', 'Output as JSON')
|
|
223
|
+
.action(async (dir, opts, cmd) => {
|
|
224
|
+
applyJsonFlag(opts);
|
|
225
|
+
ensureConfigured();
|
|
226
|
+
const user = getGlobalUser(cmd);
|
|
227
|
+
await runCommand({
|
|
228
|
+
start: 'Publishing...',
|
|
229
|
+
fn: async () => {
|
|
230
|
+
const manifest = readManifest(opts.config);
|
|
231
|
+
const folder = dir || manifest?.dir;
|
|
232
|
+
const to = opts.to ? flagDestinations(opts) : manifest?.to || [];
|
|
233
|
+
const prune = opts.prune ?? manifest?.prune;
|
|
234
|
+
const preview =
|
|
235
|
+
opts.preview === undefined
|
|
236
|
+
? false
|
|
237
|
+
: opts.preview === true
|
|
238
|
+
? true
|
|
239
|
+
: Number(opts.preview);
|
|
240
|
+
return publishApp({
|
|
241
|
+
storage: opts.storage || manifest?.storage,
|
|
242
|
+
files: folder ? collectFiles(folder) : undefined,
|
|
243
|
+
prune: !!prune,
|
|
244
|
+
to,
|
|
245
|
+
preview,
|
|
246
|
+
point: opts.point,
|
|
247
|
+
bump: !!opts.bump,
|
|
248
|
+
user,
|
|
249
|
+
dir: folder,
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
success: (r) => `Published ${r.storage}`,
|
|
253
|
+
failure: 'Failed to publish',
|
|
254
|
+
onSuccess: (result) => {
|
|
255
|
+
if (isJsonMode()) printOk(result);
|
|
256
|
+
else console.log(publishSummary(result, { point: opts.point }));
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return dashboard;
|
|
262
|
+
}
|