@gotillit/tllt 0.3.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/AGENTS.md +59 -0
- package/README.MD +106 -0
- package/configure.js +243 -0
- package/deploy.js +289 -0
- package/diff.js +158 -0
- package/docs/apis.md +73 -0
- package/docs/authentication.md +53 -0
- package/docs/commands.md +135 -0
- package/docs/scheduler.md +173 -0
- package/docs/schemas.md +73 -0
- package/docs/workflow.md +86 -0
- package/import.js +580 -0
- package/lib/apis.js +39 -0
- package/lib/auth.js +123 -0
- package/lib/client.js +52 -0
- package/lib/config.js +209 -0
- package/lib/output.js +51 -0
- package/lib/scheduler-entities.js +404 -0
- package/lib/schema.js +65 -0
- package/package.json +37 -0
- package/schema.js +79 -0
- package/tllt.js +112 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# TilliT CLI — agent guide
|
|
2
|
+
|
|
3
|
+
You are driving `tllt` (`@gotillit/tllt`), a tool for managing TilliT
|
|
4
|
+
configuration across two APIs: **Digital Operations (DO)** and **Scheduler**.
|
|
5
|
+
|
|
6
|
+
## How to use this tool effectively
|
|
7
|
+
|
|
8
|
+
1. **Always pass `--json`.** Structured results go to **stdout**; human progress
|
|
9
|
+
goes to stderr. On failure you get `{"ok": false, "error": "..."}` and a
|
|
10
|
+
non-zero exit code. (Or set `TILLIT_JSON=1`.)
|
|
11
|
+
2. **Everything is flag-driven** — never rely on interactive prompts. Supply all
|
|
12
|
+
required flags and commands run non-interactively.
|
|
13
|
+
3. **Select a connection with `--profile <name>`** (or rely on the default).
|
|
14
|
+
|
|
15
|
+
## The golden path
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
tllt configure --tenant <t> --environment <env> --auth basic \
|
|
19
|
+
--username <u> --password <p> --default # bootstrap a connection (once)
|
|
20
|
+
|
|
21
|
+
tllt --json import --profile <t>-<env> # snapshot live config to files
|
|
22
|
+
tllt --json diff --from <A> --to <B> # changeset to make B look like A
|
|
23
|
+
tllt --json deploy --from <A> --to <B> --dry-run # preview
|
|
24
|
+
tllt --json deploy --from <A> --to <B> --yes # apply
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Read these for detail
|
|
28
|
+
|
|
29
|
+
- [`docs/commands.md`](docs/commands.md) — every command, flag, and JSON shape
|
|
30
|
+
- [`docs/schemas.md`](docs/schemas.md) — **how to learn each entity's data model** (read before editing records)
|
|
31
|
+
- [`docs/authentication.md`](docs/authentication.md) — basic vs api-key/Cognito
|
|
32
|
+
- [`docs/workflow.md`](docs/workflow.md) — snapshot layout, diff/deploy semantics
|
|
33
|
+
- [`docs/apis.md`](docs/apis.md) — the two APIs and what each owns
|
|
34
|
+
- [`docs/scheduler.md`](docs/scheduler.md) — how scheduler config differs (important & different)
|
|
35
|
+
|
|
36
|
+
## Things that will bite you if you forget
|
|
37
|
+
|
|
38
|
+
- Before creating/editing a record, **read its schema** — either the
|
|
39
|
+
`_schema.json` in the same folder or `tllt schema <Entity>`. It tells you
|
|
40
|
+
required fields, enums, and what each nested `{name}` reference points at.
|
|
41
|
+
- A **connection = environment + tenant**; the profile name is `{tenant}-{environment}`.
|
|
42
|
+
- `deploy` matches records across environments by **natural key** (the `name`,
|
|
43
|
+
used as the filename), because volatile `id`/`createdAt`/`updatedAt` are
|
|
44
|
+
stripped on import.
|
|
45
|
+
- `deploy` **never deletes** unless you pass `--prune`.
|
|
46
|
+
- **Scheduler config is organised per `DataTemplate`** and driven through the
|
|
47
|
+
normalized REST API (`/{locationCode}/{dataTemplateId}/{entity}`). Snapshots
|
|
48
|
+
nest under the template name; global entities (`Location`, `DataTemplate`,
|
|
49
|
+
`OptimisationProfile`) sit at the top. Deploy creates a missing template and
|
|
50
|
+
applies records in dependency order (parents first). See `docs/scheduler.md`.
|
|
51
|
+
- **For scheduler records, `schema` IS authoritative.** Write shapes match the
|
|
52
|
+
published schema. Nested references are stored by natural key
|
|
53
|
+
(`{ "name": "Bottling" }`) and the CLI resolves them to the target's id on
|
|
54
|
+
deploy. A referenced parent must be in the same deploy or already on the target.
|
|
55
|
+
- **Changeover set binds to equipment via the `changeoverSet` ref on create** (a
|
|
56
|
+
required FK — equipment can't be created without it); it persists, no separate
|
|
57
|
+
step. **Availability does NOT** bind via the normalized API — use the gateway
|
|
58
|
+
`availabilities` + `equipment-availabilities` endpoints (≈1yr horizon). See
|
|
59
|
+
`docs/scheduler.md` → "Binding changeover sets & availability to equipment".
|
package/README.MD
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# TilliT CLI
|
|
2
|
+
|
|
3
|
+
Manage TilliT configuration across the **Digital Operations (DO)** and **Scheduler**
|
|
4
|
+
APIs. Built to be friendly for both humans and AI agents: bootstrap a connection once,
|
|
5
|
+
then `import` → `diff` → `deploy`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @gotillit/tllt # or run ad-hoc with: npx @gotillit/tllt <command>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## First run — bootstrap a connection
|
|
14
|
+
|
|
15
|
+
A **connection is identified by environment + tenant**, so you can keep several side by
|
|
16
|
+
side (e.g. `client1` on prod and `client2` on stage). Profiles are stored in
|
|
17
|
+
`~/.tillit/config.json` and named `{tenant}-{environment}`.
|
|
18
|
+
|
|
19
|
+
Interactive:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
tllt configure
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Non-interactive (agent-friendly) — basic auth:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
tllt configure --tenant client1 --environment prod \
|
|
29
|
+
--auth basic --username alice --password '••••' --default
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Non-interactive — API key + secret (Cognito `client_credentials` → bearer token):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
tllt configure --tenant client2 --environment stage \
|
|
36
|
+
--auth apikey --api-key <cognitoClientId> --api-secret <secret> \
|
|
37
|
+
--token-url https://<domain>.auth.<region>.amazoncognito.com/oauth2/token \
|
|
38
|
+
--scopes 'api/*.read,api/*.write'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
List connections:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
tllt profiles
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Authentication
|
|
48
|
+
|
|
49
|
+
| Method | How it authenticates | APIs |
|
|
50
|
+
| -------- | ------------------------------------------------------------------------------- | ----------- |
|
|
51
|
+
| `basic` | `Authorization: Basic base64(username@{tenant}.tillit.cloud:password)` | DO |
|
|
52
|
+
| `apikey` | Cognito `client_credentials` → cached bearer token; `tillit-tenant` header set | DO + Scheduler |
|
|
53
|
+
|
|
54
|
+
Tokens obtained via `apikey` are cached in `~/.tillit/tokens.json` until shortly before
|
|
55
|
+
they expire.
|
|
56
|
+
|
|
57
|
+
## Workflow
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 1. Snapshot the live configuration into ./{profile}/ (the current directory)
|
|
61
|
+
tllt import --profile client2-stage # use --dir <path> to write elsewhere
|
|
62
|
+
|
|
63
|
+
# 2. Compare two snapshots (desired -> target) and review the changeset
|
|
64
|
+
tllt diff --from client2-stage --to client1-prod
|
|
65
|
+
|
|
66
|
+
# 3. Apply the difference to the live target
|
|
67
|
+
tllt deploy --from client2-stage --to client1-prod --dry-run # preview
|
|
68
|
+
tllt deploy --from client2-stage --to client1-prod # apply (asks to confirm)
|
|
69
|
+
tllt deploy --from client2-stage --to client1-prod --prune -y # also delete extras, no prompt
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Snapshots are written to the **current directory** (override with `--dir`), so you can keep
|
|
73
|
+
config in its own git repo. They live under `./{profile}/{api}/{Entity}/…` with a `_meta.json` per entity
|
|
74
|
+
recording how to route writes. Volatile fields (`id`, `createdAt`, `updatedAt`) are
|
|
75
|
+
stripped on import, so a content difference reflects a real configuration change. Records
|
|
76
|
+
are matched across environments by their natural key (the `name`, used as the filename).
|
|
77
|
+
|
|
78
|
+
## Agent-friendly notes
|
|
79
|
+
|
|
80
|
+
- Add `--json` to any command for machine-readable output on **stdout** (progress goes to
|
|
81
|
+
stderr). Errors are emitted as `{ "ok": false, "error": "…" }` with a non-zero exit code.
|
|
82
|
+
- All commands are fully flag-driven — no interactive prompts when the required flags are
|
|
83
|
+
supplied. Set `TILLIT_JSON=1` to force JSON mode.
|
|
84
|
+
- `--profile <name>` selects a connection; otherwise the default profile is used.
|
|
85
|
+
|
|
86
|
+
## Documentation (for humans and AI agents)
|
|
87
|
+
|
|
88
|
+
Agent-readable guides live alongside the code:
|
|
89
|
+
|
|
90
|
+
- [`AGENTS.md`](AGENTS.md) — start here: golden path + gotchas
|
|
91
|
+
- [`docs/commands.md`](docs/commands.md) — full command/flag/JSON reference
|
|
92
|
+
- [`docs/schemas.md`](docs/schemas.md) — how to learn each entity's data model
|
|
93
|
+
- [`docs/authentication.md`](docs/authentication.md) — basic vs api-key/Cognito
|
|
94
|
+
- [`docs/workflow.md`](docs/workflow.md) — snapshot layout + diff/deploy semantics
|
|
95
|
+
- [`docs/apis.md`](docs/apis.md) — the DO and Scheduler APIs
|
|
96
|
+
- [`docs/scheduler.md`](docs/scheduler.md) — Scheduler's dataTemplate scoping
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
| Command | Description |
|
|
101
|
+
| ----------- | ----------------------------------------------------------------- |
|
|
102
|
+
| `configure` | Bootstrap or update a connection (alias: `init`) |
|
|
103
|
+
| `profiles` | List configured connections (alias: `connections`) |
|
|
104
|
+
| `import` | Snapshot a connection's current configuration into local files |
|
|
105
|
+
| `diff` | Compare two snapshots and show the changeset |
|
|
106
|
+
| `deploy` | Apply the difference between two snapshots to the live target |
|
package/configure.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {input, select, password, confirm} from '@inquirer/prompts';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ENVIRONMENTS,
|
|
5
|
+
ENTERPRISE_TIERS,
|
|
6
|
+
defaultBaseUrl,
|
|
7
|
+
profileName,
|
|
8
|
+
saveProfile,
|
|
9
|
+
listProfiles,
|
|
10
|
+
} from './lib/config.js';
|
|
11
|
+
import {apiClient} from './lib/client.js';
|
|
12
|
+
import {info, result, isJsonMode} from './lib/output.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bootstrap (or update) a connection. A connection is identified by
|
|
16
|
+
* environment + tenant, so you can keep several side by side, e.g. client1 on
|
|
17
|
+
* prod and client2 on stage.
|
|
18
|
+
*
|
|
19
|
+
* Fully interactive with no flags (first-run bootstrap); fully non-interactive
|
|
20
|
+
* when the required flags are supplied (agent-friendly).
|
|
21
|
+
*
|
|
22
|
+
* Flags: --tenant --environment --auth basic|apikey
|
|
23
|
+
* basic: --username --password
|
|
24
|
+
* apikey: --api-key --api-secret --token-url [--scopes a,b]
|
|
25
|
+
* common: --base-url (override) --default
|
|
26
|
+
*/
|
|
27
|
+
const doConfigure = async (opts = {}) => {
|
|
28
|
+
const answers = await collectAnswers(opts);
|
|
29
|
+
|
|
30
|
+
const {tenant, environment, enterprise, tier} = answers;
|
|
31
|
+
const baseUrl = answers.baseUrl || defaultBaseUrl(tenant, environment, {enterprise, tier});
|
|
32
|
+
const name = profileName(tenant, environment, {enterprise, tier});
|
|
33
|
+
|
|
34
|
+
const profile = {tenant, environment, baseUrl, auth: buildAuth(answers)};
|
|
35
|
+
if (environment === 'enterprise') {
|
|
36
|
+
profile.enterprise = enterprise;
|
|
37
|
+
profile.tier = tier || 'stage';
|
|
38
|
+
}
|
|
39
|
+
saveProfile(name, profile, {makeDefault: answers.makeDefault});
|
|
40
|
+
|
|
41
|
+
info(`\nSaved connection "${name}" (${answers.method} auth) → ${baseUrl}`);
|
|
42
|
+
|
|
43
|
+
// Smoke-test the connection so a bad host/credential is caught now, not on
|
|
44
|
+
// the first import. Querying sites is a cheap, always-present DO endpoint.
|
|
45
|
+
const test = await testConnection({name, ...profile});
|
|
46
|
+
if (test.ok) {
|
|
47
|
+
info(`Connection test: OK — ${test.count} site(s) returned.`);
|
|
48
|
+
} else {
|
|
49
|
+
info(`Connection test: FAILED — ${test.error}`);
|
|
50
|
+
info('The connection was saved anyway; fix the details and re-run configure if needed.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
info('\nNext steps:');
|
|
54
|
+
info(` tllt import --profile ${name} # snapshot the current configuration`);
|
|
55
|
+
info(` tllt diff --profile ${name} # see local vs live changes`);
|
|
56
|
+
info(` tllt deploy --profile ${name} # apply the changes`);
|
|
57
|
+
|
|
58
|
+
result(`Connection "${name}" configured.`, {
|
|
59
|
+
ok: true,
|
|
60
|
+
profile: name,
|
|
61
|
+
tenant,
|
|
62
|
+
environment,
|
|
63
|
+
baseUrl,
|
|
64
|
+
authMethod: answers.method,
|
|
65
|
+
test,
|
|
66
|
+
profiles: listProfiles(),
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify a freshly-saved connection by querying sites (a small, always-present
|
|
72
|
+
* DO endpoint). Resolves credentials/token too, so it catches bad hosts, wrong
|
|
73
|
+
* passwords, and Cognito misconfig. Never throws — returns {ok, count|error}.
|
|
74
|
+
*/
|
|
75
|
+
async function testConnection(profile) {
|
|
76
|
+
try {
|
|
77
|
+
const client = await apiClient(profile, 'do');
|
|
78
|
+
const data = await client.get('core/sites', {searchParams: {size: 1000}}).json();
|
|
79
|
+
const sites = Array.isArray(data) ? data : data?.content ?? [];
|
|
80
|
+
return {ok: true, count: sites.length};
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const status = err.response?.statusCode;
|
|
83
|
+
return {ok: false, error: status ? `HTTP ${status}: ${err.message}` : err.message};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildAuth(a) {
|
|
88
|
+
if (a.method === 'basic') {
|
|
89
|
+
return {method: 'basic', username: a.username, password: a.password};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
method: 'apikey',
|
|
93
|
+
apiKey: a.apiKey,
|
|
94
|
+
apiSecret: a.apiSecret,
|
|
95
|
+
tokenUrl: a.tokenUrl,
|
|
96
|
+
scopes: a.scopes,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function collectAnswers(opts) {
|
|
101
|
+
const flags = normalizeFlags(opts);
|
|
102
|
+
// If the essentials are present we run fully non-interactive.
|
|
103
|
+
if (canRunNonInteractive(flags)) {
|
|
104
|
+
validateNonInteractive(flags);
|
|
105
|
+
return flags;
|
|
106
|
+
}
|
|
107
|
+
if (isJsonMode()) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'Missing required flags for non-interactive configure. Provide --tenant, --environment, --auth and the matching credential flags.'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return promptForAnswers(flags);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeFlags(opts) {
|
|
116
|
+
const scopes = opts.scopes
|
|
117
|
+
? String(opts.scopes)
|
|
118
|
+
.split(',')
|
|
119
|
+
.map((s) => s.trim())
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
: undefined;
|
|
122
|
+
return {
|
|
123
|
+
tenant: opts.tenant,
|
|
124
|
+
environment: opts.environment,
|
|
125
|
+
enterprise: opts.enterprise,
|
|
126
|
+
tier: opts.tier,
|
|
127
|
+
method: opts.auth,
|
|
128
|
+
username: opts.username,
|
|
129
|
+
password: opts.password,
|
|
130
|
+
apiKey: opts.apiKey,
|
|
131
|
+
apiSecret: opts.apiSecret,
|
|
132
|
+
tokenUrl: opts.tokenUrl,
|
|
133
|
+
scopes,
|
|
134
|
+
baseUrl: opts.baseUrl,
|
|
135
|
+
makeDefault: Boolean(opts.default),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function canRunNonInteractive(f) {
|
|
140
|
+
if (!f.tenant || !f.environment || !f.method) return false;
|
|
141
|
+
if (f.environment === 'enterprise' && !f.enterprise) return false;
|
|
142
|
+
if (f.method === 'basic') return Boolean(f.username && f.password);
|
|
143
|
+
if (f.method === 'apikey') return Boolean(f.apiKey && f.apiSecret && f.tokenUrl);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateNonInteractive(f) {
|
|
148
|
+
if (!ENVIRONMENTS.includes(f.environment)) {
|
|
149
|
+
throw new Error(`--environment must be one of: ${ENVIRONMENTS.join(', ')}`);
|
|
150
|
+
}
|
|
151
|
+
if (f.environment === 'enterprise') {
|
|
152
|
+
if (!f.enterprise) throw new Error('--enterprise <name> is required when --environment enterprise');
|
|
153
|
+
if (f.tier && !ENTERPRISE_TIERS.includes(f.tier)) {
|
|
154
|
+
throw new Error(`--tier must be one of: ${ENTERPRISE_TIERS.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!['basic', 'apikey'].includes(f.method)) {
|
|
158
|
+
throw new Error('--auth must be "basic" or "apikey"');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function promptForAnswers(flags) {
|
|
163
|
+
const merged = {...flags};
|
|
164
|
+
|
|
165
|
+
if (!merged.tenant) {
|
|
166
|
+
merged.tenant = await input({message: 'Tenant', validate: req});
|
|
167
|
+
}
|
|
168
|
+
if (!merged.environment) {
|
|
169
|
+
merged.environment = await select({
|
|
170
|
+
message: 'Environment',
|
|
171
|
+
choices: ENVIRONMENTS.map((v) => ({name: v, value: v})),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (!merged.method) {
|
|
175
|
+
merged.method = await select({
|
|
176
|
+
message: 'Authentication method',
|
|
177
|
+
choices: [
|
|
178
|
+
{name: 'Username + password (basic auth)', value: 'basic'},
|
|
179
|
+
{name: 'API key + secret (Cognito bearer token)', value: 'apikey'},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Enterprise (dedicated) environments need an enterprise name + tier so the
|
|
185
|
+
// base URL becomes {tenant}.{enterprise}.tillit-{tier}.cloud.
|
|
186
|
+
if (merged.environment === 'enterprise') {
|
|
187
|
+
if (!merged.enterprise) {
|
|
188
|
+
merged.enterprise = await input({message: 'Enterprise name (e.g. acme)', validate: req});
|
|
189
|
+
}
|
|
190
|
+
if (!merged.tier) {
|
|
191
|
+
merged.tier = await select({
|
|
192
|
+
message: 'Underlying tier',
|
|
193
|
+
choices: ENTERPRISE_TIERS.map((v) => ({name: v, value: v})),
|
|
194
|
+
default: 'stage',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (merged.method === 'basic') {
|
|
200
|
+
if (!merged.username) {
|
|
201
|
+
merged.username = await input({message: 'Username', validate: req});
|
|
202
|
+
}
|
|
203
|
+
if (!merged.password) {
|
|
204
|
+
merged.password = await password({message: 'Password', validate: req});
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
if (!merged.apiKey) {
|
|
208
|
+
merged.apiKey = await input({message: 'API key (Cognito app client id)', validate: req});
|
|
209
|
+
}
|
|
210
|
+
if (!merged.apiSecret) {
|
|
211
|
+
merged.apiSecret = await password({message: 'API secret', validate: req});
|
|
212
|
+
}
|
|
213
|
+
if (!merged.tokenUrl) {
|
|
214
|
+
merged.tokenUrl = await input({
|
|
215
|
+
message: 'Cognito OAuth2 token URL (…/oauth2/token)',
|
|
216
|
+
validate: req,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!merged.baseUrl) {
|
|
222
|
+
const value = await input({
|
|
223
|
+
message: `Base URL [default ${defaultBaseUrl(merged.tenant, merged.environment, {enterprise: merged.enterprise, tier: merged.tier})}]`,
|
|
224
|
+
default: '',
|
|
225
|
+
});
|
|
226
|
+
merged.baseUrl = value.trim() || undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!merged.makeDefault) {
|
|
230
|
+
merged.makeDefault = await confirm({
|
|
231
|
+
message: 'Make this the default connection?',
|
|
232
|
+
default: true,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return merged;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function req(value) {
|
|
240
|
+
return value && String(value).trim() ? true : 'Required';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export default doConfigure;
|
package/deploy.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import {confirm} from '@inquirer/prompts';
|
|
3
|
+
|
|
4
|
+
import {resolveProfile} from './lib/config.js';
|
|
5
|
+
import {apiClient} from './lib/client.js';
|
|
6
|
+
import {computeChangeset} from './diff.js';
|
|
7
|
+
import {info, result, isJsonMode} from './lib/output.js';
|
|
8
|
+
import {
|
|
9
|
+
getSchedulerEntity,
|
|
10
|
+
idRefs,
|
|
11
|
+
resolveRefs,
|
|
12
|
+
matchKey,
|
|
13
|
+
expandParam,
|
|
14
|
+
scopeRows,
|
|
15
|
+
schedulerDeployOrder,
|
|
16
|
+
} from './lib/scheduler-entities.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Apply the difference between two snapshots to a live target. The target is
|
|
20
|
+
* the `--to` profile (the system being changed); the desired state is the
|
|
21
|
+
* `--from` snapshot.
|
|
22
|
+
*
|
|
23
|
+
* tllt deploy --from acme-stage --to acme-prod # apply
|
|
24
|
+
* tllt deploy --from acme-stage --to acme-prod --dry-run # plan only
|
|
25
|
+
* tllt deploy --changeset plan.json --to acme-prod # from a saved plan
|
|
26
|
+
*
|
|
27
|
+
* Because volatile ids are stripped on import, deploy resolves the target id
|
|
28
|
+
* for updates/deletes by matching the record's natural key (its `name`, the
|
|
29
|
+
* same value used as the snapshot filename) against the live collection.
|
|
30
|
+
*
|
|
31
|
+
* Deletes are opt-in (`--prune`) so a partial snapshot never wipes live config.
|
|
32
|
+
*/
|
|
33
|
+
const doDeploy = async (opts = {}) => {
|
|
34
|
+
const to = opts.to;
|
|
35
|
+
if (!to) throw new Error('deploy requires --to <profile> (the target to change).');
|
|
36
|
+
|
|
37
|
+
let changes;
|
|
38
|
+
if (opts.changeset) {
|
|
39
|
+
changes = JSON.parse(fs.readFileSync(opts.changeset, 'utf8')).changes;
|
|
40
|
+
} else if (opts.from) {
|
|
41
|
+
changes = computeChangeset(opts.from, to, opts);
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error('deploy requires either --from <profile> or --changeset <file>.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Drop deletes unless explicitly pruning.
|
|
47
|
+
if (!opts.prune) changes = changes.filter((c) => c.op !== 'delete');
|
|
48
|
+
if (opts.api) changes = changes.filter((c) => c.api === opts.api);
|
|
49
|
+
|
|
50
|
+
if (changes.length === 0) {
|
|
51
|
+
info('Nothing to deploy — target is already in sync.');
|
|
52
|
+
return result('Nothing to deploy.', {ok: true, to, applied: [], counts: empty()});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
info(`Deploy to ${to}`);
|
|
56
|
+
for (const c of changes) {
|
|
57
|
+
const glyph = c.op === 'create' ? '+' : c.op === 'delete' ? '-' : '~';
|
|
58
|
+
info(` ${glyph} ${c.api}/${c.entity}/${c.key}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A dry-run only inspects local snapshots — no live credentials required.
|
|
62
|
+
if (opts.dryRun) {
|
|
63
|
+
info('\n--dry-run: no changes applied.');
|
|
64
|
+
return result('Dry run complete.', {ok: true, dryRun: true, to, planned: summarize(changes)});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const targetProfile = resolveProfile(to);
|
|
68
|
+
|
|
69
|
+
if (!opts.yes && !isJsonMode()) {
|
|
70
|
+
const confirmed = await confirm({
|
|
71
|
+
message: `Apply ${changes.length} change(s) to ${to}?`,
|
|
72
|
+
default: false,
|
|
73
|
+
});
|
|
74
|
+
if (!confirmed) {
|
|
75
|
+
info('Aborted.');
|
|
76
|
+
return result('Aborted.', {ok: false, aborted: true});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const applied = await applyChanges({targetProfile, to, changes});
|
|
81
|
+
const counts = applied.reduce(
|
|
82
|
+
(acc, a) => {
|
|
83
|
+
acc[a.ok ? a.op : 'failed']++;
|
|
84
|
+
return acc;
|
|
85
|
+
},
|
|
86
|
+
{...empty(), failed: 0}
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
info(`\nDone: +${counts.create} ~${counts.update} -${counts.delete} (${counts.failed} failed)`);
|
|
90
|
+
if (counts.failed > 0) process.exitCode = 1;
|
|
91
|
+
return result('Deploy complete.', {ok: counts.failed === 0, to, counts, applied});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
async function applyChanges({targetProfile, to, changes}) {
|
|
95
|
+
const clients = new Map(); // api -> got client
|
|
96
|
+
const getClient = async (api) => {
|
|
97
|
+
if (!clients.has(api)) clients.set(api, await apiClient(targetProfile, api));
|
|
98
|
+
return clients.get(api);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const tplCache = {map: null}; // dataTemplate name -> {id, locationCode}
|
|
102
|
+
const liveMaps = new Map(); // `${prefix}|${entity}` -> Map(naturalKey -> id)
|
|
103
|
+
|
|
104
|
+
// Apply in dependency order: creates/updates ascending (parents before
|
|
105
|
+
// children), deletes descending (children before parents). DO changes are
|
|
106
|
+
// independent of scheduler ordering and keep their original order.
|
|
107
|
+
const order = (c) => (c.api === 'scheduler' ? schedulerDeployOrder(c.entity) : 0);
|
|
108
|
+
const writes = changes.filter((c) => c.op !== 'delete').sort((a, b) => order(a) - order(b));
|
|
109
|
+
const deletes = changes.filter((c) => c.op === 'delete').sort((a, b) => order(b) - order(a));
|
|
110
|
+
|
|
111
|
+
// Group consecutive changes by api+entity+dataTemplate so each live
|
|
112
|
+
// collection is fetched once. Grouping preserves the dependency ordering.
|
|
113
|
+
const applied = [];
|
|
114
|
+
for (const group of groupChanges([...writes, ...deletes])) {
|
|
115
|
+
const {api, gk, meta, items} = group;
|
|
116
|
+
if (!meta) {
|
|
117
|
+
for (const c of items) applied.push({...ref(c), ok: false, error: `No routing metadata for ${gk}; re-import.`});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let ctx;
|
|
122
|
+
try {
|
|
123
|
+
const client = await getClient(api);
|
|
124
|
+
ctx = await resolveContext({client, api, meta, tplCache, liveMaps});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
for (const c of items) applied.push({...ref(c), ok: false, error: err.message});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const client = await getClient(api);
|
|
131
|
+
const {collPath, desc, lookups, prefix, dataTemplateId} = ctx;
|
|
132
|
+
|
|
133
|
+
// Natural-key → id map (from live) for updates/deletes.
|
|
134
|
+
const needsLive = items.some((c) => c.op !== 'create');
|
|
135
|
+
const ownMap = needsLive
|
|
136
|
+
? await liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, entity: meta.entity, dataTemplateId})
|
|
137
|
+
: new Map();
|
|
138
|
+
|
|
139
|
+
for (const c of items) {
|
|
140
|
+
try {
|
|
141
|
+
const body = desc ? resolveRefs(c.record, desc, lookups) : c.record;
|
|
142
|
+
if (c.op === 'create') {
|
|
143
|
+
await client.post(collPath, {json: body});
|
|
144
|
+
} else {
|
|
145
|
+
const id = ownMap.get(c.key);
|
|
146
|
+
if (id == null) throw new Error(`No live ${meta.entity} keyed "${c.key}" to ${c.op}.`);
|
|
147
|
+
if (c.op === 'update') await client.put(`${collPath}/${id}`, {json: body});
|
|
148
|
+
else await client.delete(`${collPath}/${id}`);
|
|
149
|
+
}
|
|
150
|
+
applied.push({...ref(c), ok: true});
|
|
151
|
+
info(` ok ${c.op} ${gk}/${c.key}`);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
applied.push({...ref(c), ok: false, error: err.message});
|
|
154
|
+
info(` FAIL ${c.op} ${gk}/${c.key}: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return applied;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Group changes by api+entity+dataTemplate, preserving input (dependency) order. */
|
|
162
|
+
function groupChanges(changes) {
|
|
163
|
+
const groups = new Map();
|
|
164
|
+
for (const c of changes) {
|
|
165
|
+
const gk = [c.api, c.entity, c.meta?.dataTemplate].filter(Boolean).join('/');
|
|
166
|
+
if (!groups.has(gk)) groups.set(gk, {api: c.api, gk, meta: null, items: []});
|
|
167
|
+
const g = groups.get(gk);
|
|
168
|
+
if (!g.meta && c.meta) g.meta = c.meta;
|
|
169
|
+
g.items.push(c);
|
|
170
|
+
}
|
|
171
|
+
return groups.values();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve where and how a group of changes is written. For DO this is just the
|
|
176
|
+
* `microservice/endpoint` path. For scheduler it resolves the descriptor, the
|
|
177
|
+
* scope path (`{locationCode}/{dataTemplateId}/{endpoint}` for template-scoped
|
|
178
|
+
* entities, bare `{endpoint}` for global ones), and the live key→id lookup maps
|
|
179
|
+
* needed to resolve id references in the body.
|
|
180
|
+
*/
|
|
181
|
+
async function resolveContext({client, api, meta, tplCache, liveMaps}) {
|
|
182
|
+
if (api !== 'scheduler') {
|
|
183
|
+
return {collPath: `${meta.microservice}/${meta.endpoint}`, desc: null, lookups: new Map(), prefix: '', dataTemplateId: null};
|
|
184
|
+
}
|
|
185
|
+
const desc = getSchedulerEntity(meta.entity);
|
|
186
|
+
if (!desc) throw new Error(`Unknown scheduler entity "${meta.entity}"; re-import.`);
|
|
187
|
+
|
|
188
|
+
let prefix = '';
|
|
189
|
+
let dataTemplateId = null;
|
|
190
|
+
if (desc.scope === 'template') {
|
|
191
|
+
const tpl = await resolveTemplate(client, meta.dataTemplate, tplCache);
|
|
192
|
+
prefix = `${tpl.locationCode}/${tpl.id}`;
|
|
193
|
+
dataTemplateId = tpl.id;
|
|
194
|
+
}
|
|
195
|
+
const collPath = prefix ? `${prefix}/${desc.endpoint}` : desc.endpoint;
|
|
196
|
+
|
|
197
|
+
// Build a key→id map for every id-reference's target collection. Each lookup
|
|
198
|
+
// is scoped to its own entity: template-scoped refs share this template's
|
|
199
|
+
// path + dataTemplate filter; global refs (e.g. Location) are unscoped.
|
|
200
|
+
const lookups = new Map();
|
|
201
|
+
for (const r of idRefs(desc)) {
|
|
202
|
+
const lookDesc = getSchedulerEntity(r.lookupEntity);
|
|
203
|
+
if (!lookDesc) throw new Error(`${desc.entityName}.${r.field} references unknown entity ${r.lookupEntity}.`);
|
|
204
|
+
const global = lookDesc.scope === 'global';
|
|
205
|
+
const lookPrefix = global ? '' : prefix;
|
|
206
|
+
const lookPath = lookPrefix ? `${lookPrefix}/${lookDesc.endpoint}` : lookDesc.endpoint;
|
|
207
|
+
const map = await liveKeyIdMap({
|
|
208
|
+
client, api, collPath: lookPath, desc: lookDesc, liveMaps,
|
|
209
|
+
prefix: lookPrefix, entity: r.lookupEntity, dataTemplateId: global ? null : dataTemplateId,
|
|
210
|
+
});
|
|
211
|
+
lookups.set(r.lookupEntity, map);
|
|
212
|
+
}
|
|
213
|
+
return {collPath, desc, lookups, prefix, dataTemplateId};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve a dataTemplate name (the stable cross-environment key) to the target's
|
|
218
|
+
* numeric id and location code — both needed to build the scheduler path.
|
|
219
|
+
*/
|
|
220
|
+
async function resolveTemplate(client, name, tplCache) {
|
|
221
|
+
if (!name) throw new Error('Scheduler change is missing its dataTemplate name; re-import.');
|
|
222
|
+
if (!tplCache.map) {
|
|
223
|
+
const data = await client.get('data-templates', {searchParams: {size: 10000}}).json();
|
|
224
|
+
const templates = Array.isArray(data) ? data : data?.content ?? [];
|
|
225
|
+
tplCache.map = new Map();
|
|
226
|
+
tplCache.live = null;
|
|
227
|
+
for (const t of templates) {
|
|
228
|
+
const entry = {id: t.id, locationCode: t.location?.code, name: t.name};
|
|
229
|
+
if (t.name != null) tplCache.map.set(String(t.name), entry);
|
|
230
|
+
// Remember the live template as a fallback for missing names.
|
|
231
|
+
if (t.isLive && tplCache.live == null) tplCache.live = entry;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let tpl = tplCache.map.get(String(name));
|
|
235
|
+
if (!tpl) {
|
|
236
|
+
// The named template doesn't exist on the target — fall back to the live
|
|
237
|
+
// one (mirrors the scheduler API's own "no dataTemplate → live" behaviour).
|
|
238
|
+
if (!tplCache.live) {
|
|
239
|
+
throw new Error(`Target has no dataTemplate named "${name}" and no live template to fall back to.`);
|
|
240
|
+
}
|
|
241
|
+
info(` dataTemplate "${name}" not found on target; defaulting to the live template "${tplCache.live.name}".`);
|
|
242
|
+
tpl = tplCache.live;
|
|
243
|
+
}
|
|
244
|
+
if (tpl.locationCode == null) {
|
|
245
|
+
throw new Error(`Target dataTemplate "${tpl.name ?? name}" has no location code.`);
|
|
246
|
+
}
|
|
247
|
+
return tpl;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Build (and cache) a natural-key → id map from a live collection. Scheduler
|
|
252
|
+
* keys are computed with the entity descriptor (matching the snapshot keys) and
|
|
253
|
+
* request the `expand` relations those keys depend on; DO falls back to name/code.
|
|
254
|
+
*/
|
|
255
|
+
async function liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, entity, dataTemplateId}) {
|
|
256
|
+
const cacheKey = `${prefix}|${entity}`;
|
|
257
|
+
if (liveMaps.has(cacheKey)) return liveMaps.get(cacheKey);
|
|
258
|
+
|
|
259
|
+
const expand = api === 'scheduler' && desc ? expandParam(desc) : undefined;
|
|
260
|
+
const searchParams = {size: 10000, ...(expand ? {expand} : {})};
|
|
261
|
+
const data = await client.get(collPath, {searchParams}).json();
|
|
262
|
+
let records = Array.isArray(data) ? data : data?.content ?? [];
|
|
263
|
+
// Some leaf endpoints ignore the path scope; filter to this template so keys
|
|
264
|
+
// don't collide with identically-named records under other templates.
|
|
265
|
+
if (desc) records = scopeRows(records, desc, dataTemplateId);
|
|
266
|
+
|
|
267
|
+
const map = new Map();
|
|
268
|
+
for (const r of records) {
|
|
269
|
+
if (r?.id == null) continue;
|
|
270
|
+
const key = api === 'scheduler' && desc ? matchKey(r, desc) : r?.name ?? r?.code;
|
|
271
|
+
if (key != null) map.set(String(key), r.id);
|
|
272
|
+
}
|
|
273
|
+
liveMaps.set(cacheKey, map);
|
|
274
|
+
return map;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ref(c) {
|
|
278
|
+
return {op: c.op, api: c.api, entity: c.entity, key: c.key};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function summarize(changes) {
|
|
282
|
+
return changes.map(ref);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function empty() {
|
|
286
|
+
return {create: 0, update: 0, delete: 0};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export default doDeploy;
|