insightfulpipe 0.1.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/SECURITY.md +23 -0
- package/bin/cli.js +13 -0
- package/package.json +38 -0
- package/src/api.js +217 -0
- package/src/cli/commands/auth.js +30 -0
- package/src/cli/commands/config.js +62 -0
- package/src/cli/commands/discovery.js +353 -0
- package/src/cli/commands/execution.js +50 -0
- package/src/cli/errors.js +22 -0
- package/src/cli/input.js +139 -0
- package/src/cli/options.js +88 -0
- package/src/cli/program.js +48 -0
- package/src/cli/query-contexts.js +222 -0
- package/src/config.js +194 -0
- package/src/keyring.js +65 -0
- package/src/output.js +33 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { apiGet, apiPost, validatePlatformSlug } from '../api.js';
|
|
2
|
+
import { fail, handleApiError } from './errors.js';
|
|
3
|
+
import { buildQueryString, parseIntegerOption, parseStringList } from './options.js';
|
|
4
|
+
|
|
5
|
+
const SOURCE_NAME_KEYS = [
|
|
6
|
+
'source_name',
|
|
7
|
+
'account_name',
|
|
8
|
+
'ad_account_name',
|
|
9
|
+
'page_name',
|
|
10
|
+
'property_name',
|
|
11
|
+
'site_name',
|
|
12
|
+
'shop_name',
|
|
13
|
+
'company_name',
|
|
14
|
+
'channel_name',
|
|
15
|
+
'organization_name',
|
|
16
|
+
'portal_name',
|
|
17
|
+
'team_name',
|
|
18
|
+
'brand_name',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SOURCE_ID_KEYS = [
|
|
22
|
+
'account_id',
|
|
23
|
+
'ad_account_id',
|
|
24
|
+
'customer_id',
|
|
25
|
+
'advertiser_id',
|
|
26
|
+
'property_id',
|
|
27
|
+
'page_id',
|
|
28
|
+
'site_url',
|
|
29
|
+
'channel_id',
|
|
30
|
+
'spreadsheet_id',
|
|
31
|
+
'sheet_id',
|
|
32
|
+
'location_id',
|
|
33
|
+
'organization_id',
|
|
34
|
+
'bot_id',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function pickFirst(source, keys) {
|
|
38
|
+
for (const key of keys) {
|
|
39
|
+
const value = source[key];
|
|
40
|
+
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function groupSourcesByPlatform(sources) {
|
|
48
|
+
const grouped = {};
|
|
49
|
+
|
|
50
|
+
for (const source of sources) {
|
|
51
|
+
const platform = source.platform || 'unknown';
|
|
52
|
+
if (!grouped[platform]) {
|
|
53
|
+
grouped[platform] = [];
|
|
54
|
+
}
|
|
55
|
+
grouped[platform].push(source);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Object.fromEntries(Object.entries(grouped).sort(([left], [right]) => left.localeCompare(right)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sourceDisplayName(source) {
|
|
62
|
+
return pickFirst(source, SOURCE_NAME_KEYS) || '-';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sourceIdentifier(source) {
|
|
66
|
+
return pickFirst(source, SOURCE_ID_KEYS) || '-';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function fetchConnectedSources(platform, prod = false) {
|
|
70
|
+
if (platform) {
|
|
71
|
+
validatePlatformSlug(platform);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const payload = {};
|
|
75
|
+
if (platform) {
|
|
76
|
+
payload.platform = platform;
|
|
77
|
+
}
|
|
78
|
+
if (prod) {
|
|
79
|
+
payload.prod = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return handleApiError(await apiPost('/connected-sources/', payload));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function fetchBrandMetadata(workspaceId, brandIds) {
|
|
86
|
+
const payload = { workspace_id: workspaceId };
|
|
87
|
+
|
|
88
|
+
if (brandIds && brandIds.length > 0) {
|
|
89
|
+
payload.brand_ids = brandIds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return handleApiError(await apiPost('/brand-details/', payload));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function fetchHelperPayload(platform, actions) {
|
|
96
|
+
validatePlatformSlug(platform);
|
|
97
|
+
const data = handleApiError(
|
|
98
|
+
await apiGet(
|
|
99
|
+
`/helper/${buildQueryString({
|
|
100
|
+
platform,
|
|
101
|
+
actions: actions && actions.length > 0 ? actions.join(',') : undefined,
|
|
102
|
+
})}`
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Rewrite next_step from MCP language to CLI commands
|
|
107
|
+
if (data && typeof data === 'object' && !actions) {
|
|
108
|
+
// List response — next step is actions_details
|
|
109
|
+
data.next_step = {
|
|
110
|
+
request: 'actions_details',
|
|
111
|
+
command: `insightfulpipe helper ${platform} --actions <action1>,<action2>`,
|
|
112
|
+
description: 'Pick actions from the list above and pass them via --actions',
|
|
113
|
+
};
|
|
114
|
+
} else if (data && typeof data === 'object' && actions) {
|
|
115
|
+
// Detail response — next step is query or action
|
|
116
|
+
data.next_step = {
|
|
117
|
+
read: 'query_data',
|
|
118
|
+
write: 'execute_action',
|
|
119
|
+
read_command: `insightfulpipe query ${platform} -b '<request body JSON>'`,
|
|
120
|
+
write_command: `insightfulpipe action ${platform} -b '<request body JSON>'`,
|
|
121
|
+
description: 'Use query to fetch reports, analytics, and listings. Use action to create, update, delete, or publish.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function runQueryContexts(requestName, opts) {
|
|
129
|
+
switch (requestName) {
|
|
130
|
+
case 'accounts': {
|
|
131
|
+
const data = await fetchConnectedSources(opts.platform);
|
|
132
|
+
const sources = Array.isArray(data.sources) ? data.sources : [];
|
|
133
|
+
|
|
134
|
+
if (opts.platform) {
|
|
135
|
+
// Try to include actions so agent can skip available_actions step
|
|
136
|
+
let availableActions;
|
|
137
|
+
try {
|
|
138
|
+
const actionsData = await fetchHelperPayload(opts.platform);
|
|
139
|
+
availableActions = actionsData?.actions || [];
|
|
140
|
+
} catch {
|
|
141
|
+
availableActions = [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = {
|
|
145
|
+
...data,
|
|
146
|
+
sources,
|
|
147
|
+
platform: opts.platform,
|
|
148
|
+
next_step: {
|
|
149
|
+
request: 'actions_details',
|
|
150
|
+
command: `insightfulpipe helper ${opts.platform} --actions <action1>,<action2>`,
|
|
151
|
+
description: 'Pick actions from the list above and pass them via --actions',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
if (availableActions.length > 0) {
|
|
155
|
+
result.available_actions = availableActions;
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const groupedSources = groupSourcesByPlatform(sources);
|
|
161
|
+
const platformsWithSources = Object.keys(groupedSources);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...data,
|
|
165
|
+
sources: groupedSources,
|
|
166
|
+
platforms_with_sources: platformsWithSources,
|
|
167
|
+
total_sources: sources.length,
|
|
168
|
+
next_step: {
|
|
169
|
+
request: 'accounts',
|
|
170
|
+
command: 'insightfulpipe query_contexts accounts --platform <platform>',
|
|
171
|
+
description: 'Pick a platform from the list above',
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
case 'brands': {
|
|
176
|
+
if (!opts.platform) {
|
|
177
|
+
fail('platform is required for request "brands".');
|
|
178
|
+
}
|
|
179
|
+
if (!opts.workspaceId) {
|
|
180
|
+
fail('workspace_id is required for request "brands".');
|
|
181
|
+
}
|
|
182
|
+
if (!opts.brandId) {
|
|
183
|
+
fail('brand_id is required for request "brands".');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
validatePlatformSlug(opts.platform);
|
|
187
|
+
const data = await fetchBrandMetadata(
|
|
188
|
+
parseIntegerOption('workspace_id', opts.workspaceId),
|
|
189
|
+
[parseIntegerOption('brand_id', opts.brandId)]
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
platform: opts.platform,
|
|
194
|
+
...data,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
case 'available_actions': {
|
|
198
|
+
if (!opts.platform) {
|
|
199
|
+
fail('platform is required for request "available_actions".');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return fetchHelperPayload(opts.platform);
|
|
203
|
+
}
|
|
204
|
+
case 'actions_details': {
|
|
205
|
+
if (!opts.platform) {
|
|
206
|
+
fail('platform is required for request "actions_details".');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const actions = opts.actions ? parseStringList(opts.actions) : [];
|
|
210
|
+
if (actions.length === 0) {
|
|
211
|
+
fail('actions is required for request "actions_details". Use a comma-separated list.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return fetchHelperPayload(opts.platform, actions);
|
|
215
|
+
}
|
|
216
|
+
default:
|
|
217
|
+
fail(
|
|
218
|
+
`Unknown query_contexts request "${requestName}".`,
|
|
219
|
+
'Use one of: accounts, brands, available_actions, actions_details.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { getTokenFromKeyring, saveTokenToKeyring, isKeyringAvailable } from './keyring.js';
|
|
5
|
+
|
|
6
|
+
const ENV_API_URL = 'INSIGHTFULPIPE_API_URL';
|
|
7
|
+
const ENV_TOKEN = 'INSIGHTFULPIPE_TOKEN';
|
|
8
|
+
const ENV_TIMEOUT_MS = 'INSIGHTFULPIPE_TIMEOUT_MS';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
api_url: 'https://app.insightfulpipe.com',
|
|
12
|
+
token: null,
|
|
13
|
+
timeout_ms: 30000,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function getConfigDir() {
|
|
17
|
+
return join(homedir(), '.insightfulpipe');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getConfigFile() {
|
|
21
|
+
return join(getConfigDir(), 'config.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureConfigDir() {
|
|
25
|
+
const configDir = getConfigDir();
|
|
26
|
+
if (!existsSync(configDir)) {
|
|
27
|
+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function configError(message) {
|
|
32
|
+
console.error(`Error: ${message}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeConfig(rawConfig, sourceLabel) {
|
|
37
|
+
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
38
|
+
configError(`${sourceLabel} must contain a JSON object.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const normalized = {};
|
|
42
|
+
|
|
43
|
+
if ('api_url' in rawConfig) {
|
|
44
|
+
if (typeof rawConfig.api_url !== 'string' || !rawConfig.api_url.trim()) {
|
|
45
|
+
configError(`${sourceLabel} field "api_url" must be a non-empty string.`);
|
|
46
|
+
}
|
|
47
|
+
normalized.api_url = rawConfig.api_url;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ('token' in rawConfig) {
|
|
51
|
+
if (rawConfig.token !== null && typeof rawConfig.token !== 'string') {
|
|
52
|
+
configError(`${sourceLabel} field "token" must be a string or null.`);
|
|
53
|
+
}
|
|
54
|
+
normalized.token = rawConfig.token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ('timeout_ms' in rawConfig) {
|
|
58
|
+
normalized.timeout_ms = normalizeTimeoutMs(rawConfig.timeout_ms, `${sourceLabel} field "timeout_ms"`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readStoredConfig() {
|
|
65
|
+
ensureConfigDir();
|
|
66
|
+
const configFile = getConfigFile();
|
|
67
|
+
|
|
68
|
+
if (!existsSync(configFile)) {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return normalizeConfig(JSON.parse(readFileSync(configFile, 'utf-8')), configFile);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
configError(`Unable to read ${configFile}: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sourceLabel(kind, envName) {
|
|
80
|
+
if (kind === 'env') {
|
|
81
|
+
return `environment variable ${envName}`;
|
|
82
|
+
}
|
|
83
|
+
if (kind === 'file') {
|
|
84
|
+
return 'config file';
|
|
85
|
+
}
|
|
86
|
+
return 'default';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readEnvOverride(name) {
|
|
90
|
+
const value = process.env[name];
|
|
91
|
+
if (value === undefined || value === '') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildConfigSnapshot() {
|
|
98
|
+
const stored = readStoredConfig();
|
|
99
|
+
const values = { ...DEFAULT_CONFIG, ...stored };
|
|
100
|
+
const sources = {
|
|
101
|
+
api_url: Object.hasOwn(stored, 'api_url') ? sourceLabel('file') : sourceLabel('default'),
|
|
102
|
+
token: Object.hasOwn(stored, 'token') && stored.token ? sourceLabel('file') : sourceLabel('default'),
|
|
103
|
+
timeout_ms: Object.hasOwn(stored, 'timeout_ms') ? sourceLabel('file') : sourceLabel('default'),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Token priority: env var > keyring > config file
|
|
107
|
+
const tokenOverride = readEnvOverride(ENV_TOKEN);
|
|
108
|
+
if (tokenOverride) {
|
|
109
|
+
values.token = tokenOverride;
|
|
110
|
+
sources.token = sourceLabel('env', ENV_TOKEN);
|
|
111
|
+
} else {
|
|
112
|
+
const keyringToken = getTokenFromKeyring();
|
|
113
|
+
if (keyringToken) {
|
|
114
|
+
values.token = keyringToken;
|
|
115
|
+
sources.token = 'keyring';
|
|
116
|
+
}
|
|
117
|
+
// else: keep token from config file (already in values from stored)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const apiUrlOverride = readEnvOverride(ENV_API_URL);
|
|
121
|
+
if (apiUrlOverride) {
|
|
122
|
+
values.api_url = apiUrlOverride;
|
|
123
|
+
sources.api_url = sourceLabel('env', ENV_API_URL);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const timeoutOverride = readEnvOverride(ENV_TIMEOUT_MS);
|
|
127
|
+
if (timeoutOverride) {
|
|
128
|
+
values.timeout_ms = normalizeTimeoutMs(timeoutOverride, `environment variable ${ENV_TIMEOUT_MS}`);
|
|
129
|
+
sources.timeout_ms = sourceLabel('env', ENV_TIMEOUT_MS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
values.timeout_ms = normalizeTimeoutMs(values.timeout_ms, 'timeout_ms');
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
configFile: getConfigFile(),
|
|
136
|
+
rawValues: stored,
|
|
137
|
+
values,
|
|
138
|
+
sources,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function normalizeTimeoutMs(value, sourceLabel = 'timeout_ms') {
|
|
143
|
+
const parsed = Number(value);
|
|
144
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
145
|
+
configError(`${sourceLabel} must be a positive integer.`);
|
|
146
|
+
}
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function loadConfig() {
|
|
151
|
+
return buildConfigSnapshot().values;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function saveConfig(config, { insecure = false } = {}) {
|
|
155
|
+
ensureConfigDir();
|
|
156
|
+
const configFile = getConfigFile();
|
|
157
|
+
const nextConfig = normalizeConfig(config, 'config');
|
|
158
|
+
|
|
159
|
+
// Try to save token to OS keyring; fall back to config file
|
|
160
|
+
let tokenInKeyring = false;
|
|
161
|
+
if (nextConfig.token && !insecure) {
|
|
162
|
+
tokenInKeyring = saveTokenToKeyring(nextConfig.token);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Write config file — exclude token if it's in the keyring
|
|
166
|
+
const fileConfig = { ...nextConfig };
|
|
167
|
+
if (tokenInKeyring) {
|
|
168
|
+
delete fileConfig.token;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tempFile = `${configFile}.tmp`;
|
|
172
|
+
writeFileSync(tempFile, JSON.stringify(fileConfig, null, 2) + '\n', { mode: 0o600 });
|
|
173
|
+
renameSync(tempFile, configFile);
|
|
174
|
+
|
|
175
|
+
return { tokenInKeyring };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function getConfigSnapshot() {
|
|
179
|
+
return buildConfigSnapshot();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function getToken() {
|
|
183
|
+
const config = loadConfig();
|
|
184
|
+
return config.token;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getApiUrl() {
|
|
188
|
+
const config = loadConfig();
|
|
189
|
+
return config.api_url;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getRequestTimeoutMs() {
|
|
193
|
+
return loadConfig().timeout_ms;
|
|
194
|
+
}
|
package/src/keyring.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
|
|
3
|
+
const SERVICE = 'insightfulpipe-cli';
|
|
4
|
+
const ACCOUNT = 'api-token';
|
|
5
|
+
|
|
6
|
+
let _Entry;
|
|
7
|
+
let _loaded = false;
|
|
8
|
+
|
|
9
|
+
function loadEntry() {
|
|
10
|
+
if (_loaded) return _Entry;
|
|
11
|
+
_loaded = true;
|
|
12
|
+
try {
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
_Entry = require('@napi-rs/keyring').Entry;
|
|
15
|
+
} catch {
|
|
16
|
+
_Entry = null;
|
|
17
|
+
}
|
|
18
|
+
return _Entry;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getEntry() {
|
|
22
|
+
const Entry = loadEntry();
|
|
23
|
+
if (!Entry) return null;
|
|
24
|
+
try {
|
|
25
|
+
return new Entry(SERVICE, ACCOUNT);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveTokenToKeyring(token) {
|
|
32
|
+
const entry = getEntry();
|
|
33
|
+
if (!entry) return false;
|
|
34
|
+
try {
|
|
35
|
+
entry.setPassword(token);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getTokenFromKeyring() {
|
|
43
|
+
const entry = getEntry();
|
|
44
|
+
if (!entry) return null;
|
|
45
|
+
try {
|
|
46
|
+
return entry.getPassword() || null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deleteTokenFromKeyring() {
|
|
53
|
+
const entry = getEntry();
|
|
54
|
+
if (!entry) return false;
|
|
55
|
+
try {
|
|
56
|
+
entry.deletePassword();
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isKeyringAvailable() {
|
|
64
|
+
return getEntry() !== null;
|
|
65
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function printJson(data) {
|
|
2
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function wantsJson(command) {
|
|
6
|
+
return Boolean(command?.optsWithGlobals?.().json);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function printTable(rows, columns) {
|
|
10
|
+
if (!rows || rows.length === 0) {
|
|
11
|
+
console.log('No data.');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const widths = {};
|
|
16
|
+
for (const col of columns) {
|
|
17
|
+
widths[col.key] = col.label.length;
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
const val = String(row[col.key] ?? '');
|
|
20
|
+
widths[col.key] = Math.max(widths[col.key], val.length);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const header = columns.map(c => c.label.padEnd(widths[c.key])).join(' ');
|
|
25
|
+
const separator = columns.map(c => '-'.repeat(widths[c.key])).join(' ');
|
|
26
|
+
|
|
27
|
+
console.log(header);
|
|
28
|
+
console.log(separator);
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
const line = columns.map(c => String(row[c.key] ?? '').padEnd(widths[c.key])).join(' ');
|
|
31
|
+
console.log(line);
|
|
32
|
+
}
|
|
33
|
+
}
|