@thinger-io/cli-core 0.3.0 → 0.4.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.
@@ -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);
@@ -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 names = opts.all ? listProfiles() : [getActiveProfile()];
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
+ }