@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/lib/auth.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import got from 'got';
|
|
4
|
+
|
|
5
|
+
import {TILLIT_DIR} from './config.js';
|
|
6
|
+
import {getApi} from './apis.js';
|
|
7
|
+
|
|
8
|
+
const TOKEN_CACHE_PATH = path.join(TILLIT_DIR, 'tokens.json');
|
|
9
|
+
// Refresh a little before the real expiry to avoid edge-of-validity failures.
|
|
10
|
+
const EXPIRY_SKEW_MS = 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the auth + tenant headers for a profile against a given API.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} profile resolved profile (see lib/config.js)
|
|
16
|
+
* @param {string} apiId 'do' | 'scheduler'
|
|
17
|
+
* @returns {Promise<Record<string,string>>} headers to attach to every request
|
|
18
|
+
*/
|
|
19
|
+
export async function authHeaders(profile, apiId) {
|
|
20
|
+
getApi(apiId); // validate the API id
|
|
21
|
+
const method = profile.auth?.method;
|
|
22
|
+
const headers = {};
|
|
23
|
+
|
|
24
|
+
switch (method) {
|
|
25
|
+
case 'basic': {
|
|
26
|
+
const {username, password} = profile.auth;
|
|
27
|
+
const creds = `${username}@${profile.tenant}.tillit.cloud:${password}`;
|
|
28
|
+
headers.Authorization = 'Basic ' + Buffer.from(creds).toString('base64');
|
|
29
|
+
headers['tillit-tenant'] = profile.tenant;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case 'basic-token': {
|
|
33
|
+
// Legacy .env profiles already store the base64 basic blob.
|
|
34
|
+
headers.Authorization = 'Basic ' + profile.auth.basicToken;
|
|
35
|
+
headers['tillit-tenant'] = profile.tenant;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'apikey': {
|
|
39
|
+
const token = await getBearerToken(profile);
|
|
40
|
+
headers.Authorization = 'Bearer ' + token;
|
|
41
|
+
headers['tillit-tenant'] = profile.tenant;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Profile "${profile.name}" has no usable auth method (got "${method}").`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Obtain a Cognito access token via the OAuth2 client_credentials flow,
|
|
53
|
+
* caching it on disk (keyed by profile + token URL) until shortly before it
|
|
54
|
+
* expires. The api key is the Cognito app-client id; the secret is its secret.
|
|
55
|
+
*/
|
|
56
|
+
export async function getBearerToken(profile) {
|
|
57
|
+
const {apiKey, apiSecret, tokenUrl, scopes} = profile.auth;
|
|
58
|
+
if (!tokenUrl) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Profile "${profile.name}" is missing auth.tokenUrl (the Cognito OAuth2 token endpoint).`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const cacheKey = `${profile.name}|${tokenUrl}`;
|
|
65
|
+
const cached = readTokenCache()[cacheKey];
|
|
66
|
+
if (cached && cached.expiresAt - EXPIRY_SKEW_MS > absoluteNow()) {
|
|
67
|
+
return cached.accessToken;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const body = new URLSearchParams({grant_type: 'client_credentials'});
|
|
71
|
+
if (scopes && scopes.length) {
|
|
72
|
+
body.set('scope', scopes.join(' '));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await got
|
|
78
|
+
.post(tokenUrl, {
|
|
79
|
+
headers: {
|
|
80
|
+
Authorization: 'Basic ' + Buffer.from(`${apiKey}:${apiSecret}`).toString('base64'),
|
|
81
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
82
|
+
},
|
|
83
|
+
body: body.toString(),
|
|
84
|
+
})
|
|
85
|
+
.json();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const detail = err.response?.body ? ` — ${err.response.body}` : '';
|
|
88
|
+
throw new Error(`Cognito token request failed (${err.message})${detail}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!response.access_token) {
|
|
92
|
+
throw new Error('Cognito token response did not contain an access_token.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const expiresInMs = (response.expires_in ?? 3600) * 1000;
|
|
96
|
+
writeTokenCacheEntry(cacheKey, {
|
|
97
|
+
accessToken: response.access_token,
|
|
98
|
+
expiresAt: absoluteNow() + expiresInMs,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return response.access_token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function absoluteNow() {
|
|
105
|
+
// new Date() is intentionally avoided in workflow scripts but is fine here in
|
|
106
|
+
// a normal CLI process. Kept in one place so caching logic is easy to follow.
|
|
107
|
+
return new Date().getTime();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readTokenCache() {
|
|
111
|
+
if (!fs.existsSync(TOKEN_CACHE_PATH)) return {};
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(fs.readFileSync(TOKEN_CACHE_PATH, 'utf8') || '{}');
|
|
114
|
+
} catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeTokenCacheEntry(key, value) {
|
|
120
|
+
const cache = readTokenCache();
|
|
121
|
+
cache[key] = value;
|
|
122
|
+
fs.writeFileSync(TOKEN_CACHE_PATH, JSON.stringify(cache, null, 2) + '\n', {mode: 0o600});
|
|
123
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
|
|
3
|
+
import {authHeaders} from './auth.js';
|
|
4
|
+
import {getApi} from './apis.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a `got` instance bound to a profile + API surface. The returned client
|
|
8
|
+
* has `prefixUrl` set to https://{tenant}.tillit.cloud{prefix} and the resolved
|
|
9
|
+
* auth + tenant headers attached, so callers issue relative paths:
|
|
10
|
+
*
|
|
11
|
+
* const api = await apiClient(profile, 'do');
|
|
12
|
+
* await api.get('core/assets').json();
|
|
13
|
+
*
|
|
14
|
+
* const sched = await apiClient(profile, 'scheduler');
|
|
15
|
+
* await sched.get('equipment').json();
|
|
16
|
+
*/
|
|
17
|
+
export async function apiClient(profile, apiId) {
|
|
18
|
+
const api = getApi(apiId);
|
|
19
|
+
const headers = await authHeaders(profile, apiId);
|
|
20
|
+
const baseUrl = (profile.baseUrl || `https://${profile.tenant}.tillit.cloud`).replace(/\/+$/, '');
|
|
21
|
+
|
|
22
|
+
return got.extend({
|
|
23
|
+
prefixUrl: `${baseUrl}${api.prefix}`,
|
|
24
|
+
headers,
|
|
25
|
+
responseType: 'json',
|
|
26
|
+
retry: {limit: 1},
|
|
27
|
+
hooks: {
|
|
28
|
+
beforeError: [
|
|
29
|
+
(error) => {
|
|
30
|
+
const {response} = error;
|
|
31
|
+
if (response && response.body) {
|
|
32
|
+
const body =
|
|
33
|
+
typeof response.body === 'string' ? response.body : JSON.stringify(response.body);
|
|
34
|
+
error.message = `${error.message}: ${body}`;
|
|
35
|
+
}
|
|
36
|
+
return error;
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Raw GET against the tenant base URL (no API prefix) using the profile's auth.
|
|
45
|
+
* Used to fetch published asset files such as entity schemas.
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchAsset(profile, apiId, assetPath) {
|
|
48
|
+
const headers = await authHeaders(profile, apiId);
|
|
49
|
+
const baseUrl = (profile.baseUrl || `https://${profile.tenant}.tillit.cloud`).replace(/\/+$/, '');
|
|
50
|
+
const url = `${baseUrl}${assetPath.startsWith('/') ? '' : '/'}${assetPath}`;
|
|
51
|
+
return got.get(url, {headers, responseType: 'json'}).json();
|
|
52
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration store for the TilliT CLI.
|
|
7
|
+
*
|
|
8
|
+
* Profiles live in ~/.tillit/config.json and look like:
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "defaultProfile": "client1-prod",
|
|
12
|
+
* "profiles": {
|
|
13
|
+
* "client1-prod": {
|
|
14
|
+
* "tenant": "client1",
|
|
15
|
+
* "environment": "prod",
|
|
16
|
+
* "baseUrl": "https://client1.tillit.cloud",
|
|
17
|
+
* "auth": {
|
|
18
|
+
* "method": "basic",
|
|
19
|
+
* "username": "alice",
|
|
20
|
+
* "password": "..."
|
|
21
|
+
* }
|
|
22
|
+
* },
|
|
23
|
+
* "acme-stage": {
|
|
24
|
+
* "tenant": "acme",
|
|
25
|
+
* "environment": "stage",
|
|
26
|
+
* "baseUrl": "https://acme.tillit-stage.cloud",
|
|
27
|
+
* "auth": {
|
|
28
|
+
* "method": "apikey",
|
|
29
|
+
* "apiKey": "<cognito client id>",
|
|
30
|
+
* "apiSecret": "<cognito client secret>",
|
|
31
|
+
* "tokenUrl": "https://....amazoncognito.com/oauth2/token",
|
|
32
|
+
* "scopes": ["api/*.read", "api/*.write"]
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* Legacy ~/.tillit/.env files written by older CLI versions are still read so
|
|
39
|
+
* existing setups keep working.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export const TILLIT_DIR = path.join(os.homedir(), '.tillit');
|
|
43
|
+
export const CONFIG_PATH = path.join(TILLIT_DIR, 'config.json');
|
|
44
|
+
export const LEGACY_ENV_PATH = path.join(TILLIT_DIR, '.env');
|
|
45
|
+
|
|
46
|
+
function ensureDir() {
|
|
47
|
+
if (!fs.existsSync(TILLIT_DIR)) {
|
|
48
|
+
fs.mkdirSync(TILLIT_DIR, {recursive: true});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ENVIRONMENTS = ['prod', 'stage', 'sandbox', 'dev', 'enterprise'];
|
|
53
|
+
|
|
54
|
+
// Tiers an enterprise (dedicated) environment can sit on.
|
|
55
|
+
export const ENTERPRISE_TIERS = ['prod', 'stage', 'dev'];
|
|
56
|
+
const DEFAULT_TIER = 'stage';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-environment domain templates. A connection lives on a per-tenant
|
|
60
|
+
* subdomain whose root domain depends on the environment, e.g.
|
|
61
|
+
* prod -> https://{tenant}.tillit.cloud
|
|
62
|
+
* stage -> https://{tenant}.tillit-stage.cloud
|
|
63
|
+
* dev -> https://{tenant}.tillit-dev.cloud
|
|
64
|
+
*
|
|
65
|
+
* `enterprise` environments are dedicated deployments outside the shared
|
|
66
|
+
* cluster: the enterprise name becomes an extra subdomain segment on the chosen
|
|
67
|
+
* tier, e.g. tenant `client1` on the `acme` enterprise (stage tier) ->
|
|
68
|
+
* https://client1.acme.tillit-stage.cloud
|
|
69
|
+
*
|
|
70
|
+
* Override per profile with an explicit `baseUrl` if a tenant differs.
|
|
71
|
+
*/
|
|
72
|
+
const ENV_DOMAINS = {
|
|
73
|
+
prod: 'tillit.cloud',
|
|
74
|
+
stage: 'tillit-stage.cloud',
|
|
75
|
+
sandbox: 'tillit-sandbox.cloud',
|
|
76
|
+
dev: 'tillit-dev.cloud',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Default base URL for a tenant in a given environment. For `enterprise`
|
|
81
|
+
* environments pass `{enterprise, tier}` (tier defaults to stage).
|
|
82
|
+
*/
|
|
83
|
+
export function defaultBaseUrl(tenant, environment = 'prod', {enterprise, tier} = {}) {
|
|
84
|
+
if (environment === 'enterprise') {
|
|
85
|
+
if (!enterprise) throw new Error('An enterprise environment requires an enterprise name.');
|
|
86
|
+
const domain = ENV_DOMAINS[tier ?? DEFAULT_TIER] ?? ENV_DOMAINS[DEFAULT_TIER];
|
|
87
|
+
return `https://${tenant}.${enterprise}.${domain}`;
|
|
88
|
+
}
|
|
89
|
+
const domain = ENV_DOMAINS[environment] ?? ENV_DOMAINS.prod;
|
|
90
|
+
return `https://${tenant}.${domain}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Canonical profile name for a connection. A connection is uniquely identified
|
|
95
|
+
* by environment + tenant, so the profile name is `{tenant}-{environment}`
|
|
96
|
+
* (e.g. client1 on prod -> "client1-prod"). Enterprise environments include the
|
|
97
|
+
* enterprise + tier, e.g. client1 on acme/stage -> "client1-acme-stage".
|
|
98
|
+
*/
|
|
99
|
+
export function profileName(tenant, environment, {enterprise, tier} = {}) {
|
|
100
|
+
if (environment === 'enterprise') {
|
|
101
|
+
return `${tenant}-${enterprise}-${tier ?? DEFAULT_TIER}`;
|
|
102
|
+
}
|
|
103
|
+
return `${tenant}-${environment}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadConfig() {
|
|
107
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
108
|
+
return {defaultProfile: null, profiles: {}};
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
112
|
+
const parsed = JSON.parse(raw || '{}');
|
|
113
|
+
return {
|
|
114
|
+
defaultProfile: parsed.defaultProfile ?? null,
|
|
115
|
+
profiles: parsed.profiles ?? {},
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error(`Could not parse ${CONFIG_PATH}: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function saveConfig(config) {
|
|
123
|
+
ensureDir();
|
|
124
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', {mode: 0o600});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Create or overwrite a named profile and persist it. */
|
|
128
|
+
export function saveProfile(name, profile, {makeDefault = false} = {}) {
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
config.profiles[name] = profile;
|
|
131
|
+
if (makeDefault || !config.defaultProfile) {
|
|
132
|
+
config.defaultProfile = name;
|
|
133
|
+
}
|
|
134
|
+
saveConfig(config);
|
|
135
|
+
return config;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function listProfiles() {
|
|
139
|
+
const config = loadConfig();
|
|
140
|
+
return Object.entries(config.profiles).map(([name, profile]) => ({
|
|
141
|
+
name,
|
|
142
|
+
tenant: profile.tenant,
|
|
143
|
+
baseUrl: profile.baseUrl,
|
|
144
|
+
method: profile.auth?.method,
|
|
145
|
+
isDefault: name === config.defaultProfile,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a profile by name, falling back to the default profile, and finally
|
|
151
|
+
* to a legacy .env entry (keyed ENV_TENANT, e.g. PROD_BOTTLING). Returns the
|
|
152
|
+
* profile object augmented with its resolved `name`.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveProfile(name) {
|
|
155
|
+
const config = loadConfig();
|
|
156
|
+
const profileName = name || config.defaultProfile;
|
|
157
|
+
|
|
158
|
+
if (profileName && config.profiles[profileName]) {
|
|
159
|
+
return {name: profileName, ...config.profiles[profileName]};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Back-compat: allow "<env> <tenant>" or "<env>_<tenant>" against legacy .env.
|
|
163
|
+
const legacy = loadLegacyProfile(profileName);
|
|
164
|
+
if (legacy) return legacy;
|
|
165
|
+
|
|
166
|
+
if (!profileName) {
|
|
167
|
+
throw new Error('No profile specified and no default profile configured. Run `tllt configure`.');
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Profile "${profileName}" not found. Run \`tllt configure\` or \`tllt profiles\`.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read a legacy ~/.tillit/.env profile of the shape
|
|
174
|
+
* {ENV}_{TENANT}_AUTH=<base64 basic creds>
|
|
175
|
+
* {ENV}_{TENANT}_REGION=<region>
|
|
176
|
+
* Accepts a key like "prod_bottling" / "PROD_BOTTLING".
|
|
177
|
+
*/
|
|
178
|
+
function loadLegacyProfile(key) {
|
|
179
|
+
if (!key || !fs.existsSync(LEGACY_ENV_PATH)) return null;
|
|
180
|
+
const env = parseEnvFile(LEGACY_ENV_PATH);
|
|
181
|
+
const prefix = key.toUpperCase().replace(/[\s/]+/g, '_');
|
|
182
|
+
const auth = env[`${prefix}_AUTH`];
|
|
183
|
+
if (!auth) return null;
|
|
184
|
+
// The legacy AUTH value is base64(username@tenant.tillit.cloud:password).
|
|
185
|
+
// Legacy keys are {ENV}_{TENANT}_AUTH, so the first segment is the environment.
|
|
186
|
+
const parts = prefix.split('_');
|
|
187
|
+
const environment = parts[0].toLowerCase();
|
|
188
|
+
const tenant = parts.slice(1).join('-').toLowerCase() || key;
|
|
189
|
+
return {
|
|
190
|
+
name: key,
|
|
191
|
+
tenant,
|
|
192
|
+
environment,
|
|
193
|
+
baseUrl: defaultBaseUrl(tenant, environment),
|
|
194
|
+
legacy: true,
|
|
195
|
+
auth: {method: 'basic-token', basicToken: auth},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseEnvFile(file) {
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
204
|
+
const idx = trimmed.indexOf('=');
|
|
205
|
+
if (idx === -1) continue;
|
|
206
|
+
out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
package/lib/output.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output helpers that keep the CLI friendly for both humans and AI agents.
|
|
3
|
+
*
|
|
4
|
+
* When `--json` is set (or TILLIT_JSON=1), structured results go to stdout as
|
|
5
|
+
* JSON and nothing else is printed there, so an agent can parse stdout cleanly.
|
|
6
|
+
* Human-readable progress always goes to stderr. Errors are emitted as
|
|
7
|
+
* structured JSON on stderr in JSON mode.
|
|
8
|
+
*/
|
|
9
|
+
let jsonMode = false;
|
|
10
|
+
|
|
11
|
+
export function setJsonMode(value) {
|
|
12
|
+
jsonMode = Boolean(value) || process.env.TILLIT_JSON === '1';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isJsonMode() {
|
|
16
|
+
return jsonMode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Progress / status lines for humans — never pollute stdout in JSON mode. */
|
|
20
|
+
export function info(message) {
|
|
21
|
+
if (!jsonMode) process.stderr.write(message + '\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The command's primary result. In JSON mode this is the only stdout output. */
|
|
25
|
+
export function result(humanMessage, data) {
|
|
26
|
+
if (jsonMode) {
|
|
27
|
+
process.stdout.write(JSON.stringify(data ?? {}, null, 2) + '\n');
|
|
28
|
+
} else if (humanMessage) {
|
|
29
|
+
process.stdout.write(humanMessage + '\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Report a failure and exit non-zero. */
|
|
34
|
+
export function fail(error, code = 1) {
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
if (jsonMode) {
|
|
37
|
+
process.stderr.write(JSON.stringify({ok: false, error: message}, null, 2) + '\n');
|
|
38
|
+
} else {
|
|
39
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
40
|
+
}
|
|
41
|
+
process.exitCode = code;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Wrap an async command action with uniform error handling + exit codes. */
|
|
45
|
+
export function run(action) {
|
|
46
|
+
return (...args) => {
|
|
47
|
+
Promise.resolve()
|
|
48
|
+
.then(() => action(...args))
|
|
49
|
+
.catch((err) => fail(err));
|
|
50
|
+
};
|
|
51
|
+
}
|