@zeyos/cli 0.1.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.
package/lib/output.mjs ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Output formatters: pretty table, JSON, YAML.
3
+ * ANSI colors are stripped when stdout is not a TTY or --no-color is set.
4
+ *
5
+ * All public functions write to stdout; errors write to stderr.
6
+ */
7
+
8
+ /** @typedef {import('./types.mjs').JsonValue} JsonValue */
9
+ /** @typedef {import('./types.mjs').JsonObject} JsonObject */
10
+ /** @typedef {import('./types.mjs').ValueFormatter} ValueFormatter */
11
+
12
+ // ── Colors ────────────────────────────────────────────────────────────────────
13
+
14
+ const USE_COLOR = process.stdout.isTTY && !process.argv.includes('--no-color') && !process.env.NO_COLOR;
15
+
16
+ const c = {
17
+ bold: s => USE_COLOR ? `\x1b[1m${s}\x1b[0m` : s,
18
+ dim: s => USE_COLOR ? `\x1b[2m${s}\x1b[0m` : s,
19
+ green: s => USE_COLOR ? `\x1b[32m${s}\x1b[0m` : s,
20
+ red: s => USE_COLOR ? `\x1b[31m${s}\x1b[0m` : s,
21
+ yellow: s => USE_COLOR ? `\x1b[33m${s}\x1b[0m` : s,
22
+ cyan: s => USE_COLOR ? `\x1b[36m${s}\x1b[0m` : s,
23
+ };
24
+
25
+ export { c as colors };
26
+
27
+ // ── Output mode ───────────────────────────────────────────────────────────────
28
+
29
+ /** Determine output mode from parsed CLI values. */
30
+ export function outputMode(values) {
31
+ if (values.json) return 'json';
32
+ if (values.yaml) return 'yaml';
33
+ return 'table';
34
+ }
35
+
36
+ // ── JSON ──────────────────────────────────────────────────────────────────────
37
+
38
+ export function printJson(data) {
39
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
40
+ }
41
+
42
+ // ── YAML ──────────────────────────────────────────────────────────────────────
43
+
44
+ export function printYaml(data) {
45
+ process.stdout.write(toYaml(data).replace(/^\n/, '') + '\n');
46
+ }
47
+
48
+ function toYaml(value, indent = 0) {
49
+ const pad = ' '.repeat(indent);
50
+ if (value === null || value === undefined) return 'null';
51
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
52
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
53
+ if (typeof value === 'string') {
54
+ if (value === '') return '""';
55
+ if (value.includes('\n')) {
56
+ const lines = value.split('\n').map(l => `${pad} ${l}`);
57
+ return `|\n${lines.join('\n')}`;
58
+ }
59
+ // Quote strings that contain YAML-special characters, leading/trailing whitespace,
60
+ // look like numbers (would be parsed as number by YAML loaders), or are YAML 1.1
61
+ // boolean / null keywords (true, false, null, yes, no, on, off and their variants).
62
+ if (
63
+ /[:#\[\]{}&*!,|>'"@`%]/.test(value) ||
64
+ /^\s|\s$/.test(value) ||
65
+ /^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(value) ||
66
+ /^(true|false|null|yes|no|on|off|y|n)$/i.test(value)
67
+ ) {
68
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
69
+ }
70
+ return value;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ if (value.length === 0) return '[]';
74
+ return value.map(item => {
75
+ const rendered = toYaml(item, indent + 1);
76
+ if (typeof item === 'object' && item !== null) {
77
+ // First key on same line as dash
78
+ const inner = rendered.trimStart();
79
+ return `\n${pad}- ${inner}`;
80
+ }
81
+ return `\n${pad}- ${rendered}`;
82
+ }).join('');
83
+ }
84
+ if (typeof value === 'object') {
85
+ const entries = Object.entries(value);
86
+ if (entries.length === 0) return '{}';
87
+ return entries.map(([k, v]) => {
88
+ if (typeof v === 'object' && v !== null && !Array.isArray(v) && Object.keys(v).length > 0) {
89
+ return `\n${pad}${k}:\n${pad} ${toYaml(v, indent + 1).trimStart()}`;
90
+ }
91
+ const rendered = toYaml(v, indent + 1);
92
+ if (typeof v === 'object' && v !== null) {
93
+ return `\n${pad}${k}:${rendered}`;
94
+ }
95
+ return `\n${pad}${k}: ${rendered}`;
96
+ }).join('');
97
+ }
98
+ return String(value);
99
+ }
100
+
101
+ // ── Table ─────────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Print a list of objects as a plain-text table.
105
+ *
106
+ * @param {JsonObject[]} rows
107
+ * @param {string[]} columns - ordered list of keys to display
108
+ * @param {Record<string,string>} [labels] - optional column header overrides
109
+ * @param {Record<string,ValueFormatter>} [formatters] - optional per-key formatters
110
+ */
111
+ export function printTable(rows, columns, labels = {}, formatters = {}) {
112
+ if (rows.length === 0) {
113
+ process.stdout.write(c.dim(' (no records)\n'));
114
+ return;
115
+ }
116
+
117
+ const headers = columns.map(k => labels[k] ?? k.toUpperCase());
118
+
119
+ const stringify = (key, val, row) => {
120
+ if (formatters[key]) return String(formatters[key](val, row));
121
+ if (val === null || val === undefined) return '';
122
+ if (typeof val === 'object') return JSON.stringify(val);
123
+ return String(val);
124
+ };
125
+
126
+ const data = rows.map(row => columns.map(k => stringify(k, row[k], row)));
127
+
128
+ const widths = columns.map((_, i) =>
129
+ Math.max(headers[i].length, ...data.map(row => _visibleLength(row[i])))
130
+ );
131
+
132
+ const headerRow = headers.map((h, i) => _pad(c.bold(h), widths[i])).join(' ');
133
+ const separator = widths.map(w => '─'.repeat(w)).join(' ');
134
+
135
+ process.stdout.write('\n');
136
+ process.stdout.write(' ' + headerRow + '\n');
137
+ process.stdout.write(' ' + c.dim(separator) + '\n');
138
+ for (const row of data) {
139
+ process.stdout.write(' ' + row.map((v, i) => _pad(v, widths[i])).join(' ') + '\n');
140
+ }
141
+ process.stdout.write('\n');
142
+ }
143
+
144
+ /**
145
+ * Print a single record as a vertical key-value list.
146
+ *
147
+ * @param {JsonObject} record
148
+ * @param {string[]} [keys] - subset of keys to show (default: all)
149
+ * @param {Record<string,string>} [labels]
150
+ * @param {Record<string,ValueFormatter>} [formatters]
151
+ */
152
+ export function printRecord(record, keys, labels = {}, formatters = {}) {
153
+ const keyList = keys ?? Object.keys(record);
154
+ const maxLabel = Math.max(...keyList.map(k => (labels[k] ?? k).length));
155
+
156
+ process.stdout.write('\n');
157
+ for (const key of keyList) {
158
+ const val = record[key];
159
+ if (val === undefined) continue;
160
+
161
+ const label = _pad(labels[key] ?? key, maxLabel);
162
+ let display;
163
+
164
+ if (formatters[key]) {
165
+ display = String(formatters[key](val, record));
166
+ } else if (val === null) {
167
+ display = c.dim('—');
168
+ } else if (typeof val === 'object') {
169
+ display = JSON.stringify(val);
170
+ } else {
171
+ display = String(val);
172
+ }
173
+
174
+ // Handle multi-line display values: indent continuation lines
175
+ // so they align with the first line (after the label column).
176
+ if (display.includes('\n')) {
177
+ const indent = ' '.repeat(maxLabel + 4); // " label " padding
178
+ const lines = display.split('\n');
179
+ process.stdout.write(` ${c.dim(label)} ${lines[0]}\n`);
180
+ for (let li = 1; li < lines.length; li++) {
181
+ process.stdout.write(`${indent}${lines[li]}\n`);
182
+ }
183
+ } else {
184
+ process.stdout.write(` ${c.dim(label)} ${display}\n`);
185
+ }
186
+ }
187
+ process.stdout.write('\n');
188
+ }
189
+
190
+ // ── Messages ──────────────────────────────────────────────────────────────────
191
+
192
+ export function success(msg) {
193
+ process.stderr.write(c.green('✓') + ' ' + msg + '\n');
194
+ }
195
+
196
+ export function warn(msg) {
197
+ process.stderr.write(c.yellow('⚠') + ' ' + msg + '\n');
198
+ }
199
+
200
+ export function error(msg) {
201
+ process.stderr.write(c.red('✗') + ' ' + msg + '\n');
202
+ }
203
+
204
+ export function info(msg) {
205
+ process.stderr.write(c.dim('·') + ' ' + msg + '\n');
206
+ }
207
+
208
+ // ── Date formatting ──────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Format a Unix timestamp (seconds) to a date string.
212
+ * Supports tokens: YYYY, MM, DD, HH, mm, ss.
213
+ * Returns '' for null/undefined/0 values.
214
+ *
215
+ * @param {number|string|null|undefined} timestamp - Unix timestamp in seconds
216
+ * @param {string} format - e.g. 'YYYY-MM-DD' or 'YYYY-MM-DD HH:mm'
217
+ * @returns {string}
218
+ */
219
+ export function formatDate(timestamp, format = 'YYYY-MM-DD') {
220
+ if (timestamp == null || timestamp === 0 || timestamp === '') return '';
221
+ const n = Number(timestamp);
222
+ if (!Number.isFinite(n)) return String(timestamp);
223
+ const d = new Date(n * 1000);
224
+ if (isNaN(d.getTime())) return String(timestamp);
225
+ return format
226
+ .replace('YYYY', String(d.getFullYear()))
227
+ .replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
228
+ .replace('DD', String(d.getDate()).padStart(2, '0'))
229
+ .replace('HH', String(d.getHours()).padStart(2, '0'))
230
+ .replace('mm', String(d.getMinutes()).padStart(2, '0'))
231
+ .replace('ss', String(d.getSeconds()).padStart(2, '0'));
232
+ }
233
+
234
+ /** Well-known date field names in ZeyOS. */
235
+ const DATE_FIELDS = new Set([
236
+ 'duedate', 'lastmodified', 'creationdate', 'created',
237
+ 'date', 'startdate', 'enddate',
238
+ ]);
239
+
240
+ /**
241
+ * Check whether a field name represents a date.
242
+ * @param {string} name
243
+ * @returns {boolean}
244
+ */
245
+ export function isDateField(name) {
246
+ const lower = name.toLowerCase();
247
+ return DATE_FIELDS.has(lower) || lower.endsWith('date') || lower.endsWith('modified');
248
+ }
249
+
250
+ /**
251
+ * Build a formatters object for known date fields.
252
+ * For list views where alias names differ from API paths, pass the
253
+ * aliasToPath map so date detection works on the API path.
254
+ *
255
+ * @param {string[]} columns - display column keys
256
+ * @param {string} dateFormat - format string (default 'YYYY-MM-DD')
257
+ * @param {Record<string,string>} [aliasToPath] - alias → API field path
258
+ * @returns {Record<string, ValueFormatter>}
259
+ */
260
+ export function buildDateFormatters(columns, dateFormat = 'YYYY-MM-DD', aliasToPath) {
261
+ const formatters = {};
262
+ for (const col of columns) {
263
+ const fieldPath = aliasToPath?.[col] ?? col;
264
+ const leaf = fieldPath.includes('.') ? fieldPath.split('.').pop() : fieldPath;
265
+ if (isDateField(leaf)) {
266
+ formatters[col] = (val) => formatDate(val, dateFormat);
267
+ }
268
+ }
269
+ return formatters;
270
+ }
271
+
272
+ // ── Helpers ───────────────────────────────────────────────────────────────────
273
+
274
+ /** String length ignoring ANSI escape codes. */
275
+ function _visibleLength(str) {
276
+ return str.replace(/\x1b\[[0-9;]*m/g, '').length;
277
+ }
278
+
279
+ /** Pad a string to a visible width, accounting for ANSI escape codes. */
280
+ function _pad(str, len) {
281
+ const visible = _visibleLength(str);
282
+ if (visible >= len) return str;
283
+ return str + ' '.repeat(len - visible);
284
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Per-resource field configuration loader.
3
+ *
4
+ * Resolves field configs via a cascade (first match wins):
5
+ * 1. .zeyos/api/<resource>.json (project-local, walks up from CWD)
6
+ * 2. ~/.zeyos/api/<resource>.json (global user overrides)
7
+ * 3. cli/config/<resource>.json (shipped defaults)
8
+ *
9
+ * A user override file replaces the shipped config for that resource
10
+ * entirely (no field-by-field merge).
11
+ */
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { error } from './output.mjs';
17
+
18
+ /** @typedef {import('./types.mjs').ResourceDef} ResourceDef */
19
+ /** @typedef {import('./types.mjs').ResourceFieldConfig} ResourceFieldConfig */
20
+ /** @typedef {import('./types.mjs').ListFieldSelection} ListFieldSelection */
21
+ /** @typedef {import('./types.mjs').GetFieldSelection} GetFieldSelection */
22
+ /** @typedef {import('./types.mjs').JsonValue} JsonValue */
23
+
24
+ // ── Paths ────────────────────────────────────────────────────────────────────
25
+
26
+ const __dir = dirname(fileURLToPath(import.meta.url));
27
+ const SHIPPED_DIR = join(__dir, '..', 'config');
28
+ const GLOBAL_DIR = join(homedir(), '.zeyos', 'api');
29
+ const LOCAL_NAME = '.zeyos';
30
+ const LOCAL_SUB = 'api';
31
+
32
+ // ── Cache ────────────────────────────────────────────────────────────────────
33
+
34
+ const _cache = new Map();
35
+
36
+ // ── Load ─────────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Load the field config for a resource by canonical name.
40
+ * Returns the parsed JSON object, or null if no config exists.
41
+ *
42
+ * @param {string} name - canonical resource name (e.g. "ticket")
43
+ * @returns {ResourceFieldConfig|null}
44
+ */
45
+ export function loadResourceConfig(name) {
46
+ if (_cache.has(name)) return _cache.get(name);
47
+
48
+ const config = _resolveConfig(name);
49
+ _cache.set(name, config);
50
+ return config;
51
+ }
52
+
53
+ // ── List fields ──────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Get the effective list fields for a resource.
57
+ *
58
+ * Priority:
59
+ * 1. --fields CLI override
60
+ * 2. Config file list.fields object
61
+ * 3. Registry res.fields array (display-only default, no API field selection)
62
+ *
63
+ * The --fields flag supports three formats:
64
+ * - Comma-separated: "ID,name,status" → self-aliased object
65
+ * - JSON object: '{"Id":"ID","Name":"name"}' → aliased object
66
+ * - JSON array: '["ID","name","status"]' → self-aliased object
67
+ *
68
+ * @param {ResourceDef} res - ResourceDef from registry
69
+ * @param {string} name - canonical resource name
70
+ * @param {string} [override] - raw --fields flag value
71
+ * @returns {ListFieldSelection}
72
+ */
73
+ export function getListFields(res, name, override) {
74
+ // 1. CLI override
75
+ if (override) {
76
+ return _parseFieldsOverride(override);
77
+ }
78
+
79
+ // 2. Config file
80
+ const config = loadResourceConfig(name);
81
+ if (config?.list?.fields && typeof config.list.fields === 'object') {
82
+ const apiFields = _toFieldAliasMap(config.list.fields);
83
+ const displayColumns = Object.keys(apiFields);
84
+ return { apiFields, displayColumns };
85
+ }
86
+
87
+ // 3. Registry fallback — display-only (don't send fields to APIs that may not support it)
88
+ if (res?.fields) {
89
+ return { apiFields: undefined, displayColumns: [...res.fields] };
90
+ }
91
+
92
+ return { apiFields: undefined, displayColumns: [] };
93
+ }
94
+
95
+ // ── Get fields ───────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Get the effective get/show display fields for a resource.
99
+ *
100
+ * Priority:
101
+ * 1. --fields CLI override
102
+ * 2. Config file get.fields array
103
+ * 3. undefined (show all keys — current behavior)
104
+ *
105
+ * Returns an object { keys, labels } where:
106
+ * - keys: array of API field names to display from the record
107
+ * - labels: mapping from API field name → display alias
108
+ *
109
+ * When --fields is a JSON object like {"Id": "ID", "Name": "name"},
110
+ * keys are the values (API paths) and labels map those back to the aliases.
111
+ *
112
+ * @param {string} name - canonical resource name
113
+ * @param {string} [override] - raw --fields flag value
114
+ * @returns {GetFieldSelection | undefined}
115
+ */
116
+ export function getGetFields(name, override) {
117
+ if (override) {
118
+ const trimmed = override.trim();
119
+
120
+ // JSON object: {"Alias": "api.field", ...}
121
+ if (trimmed.startsWith('{')) {
122
+ try {
123
+ const parsed = JSON.parse(trimmed);
124
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
125
+ // Keys = alias names, Values = API field paths
126
+ const keys = Object.values(parsed).map(String);
127
+ const labels = {};
128
+ for (const [alias, field] of Object.entries(parsed)) {
129
+ labels[String(field)] = alias;
130
+ }
131
+ return { keys, labels };
132
+ }
133
+ } catch {
134
+ // Fall through
135
+ }
136
+ }
137
+
138
+ // JSON array: ["field1", "field2"]
139
+ if (trimmed.startsWith('[')) {
140
+ try {
141
+ const parsed = JSON.parse(trimmed);
142
+ if (Array.isArray(parsed)) return { keys: parsed.map(String), labels: {} };
143
+ } catch {
144
+ // Fall through
145
+ }
146
+ }
147
+
148
+ // Comma-separated: "field1,field2"
149
+ return { keys: trimmed.split(',').map(s => s.trim()).filter(Boolean), labels: {} };
150
+ }
151
+
152
+ const config = loadResourceConfig(name);
153
+ if (config?.get?.fields && Array.isArray(config.get.fields)) {
154
+ return { keys: config.get.fields, labels: {} };
155
+ }
156
+
157
+ return undefined;
158
+ }
159
+
160
+ // ── Get params ───────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Get the default query parameters for GET operations from a resource's
164
+ * `get.params` config (e.g. `{ extdata: 1, tags: 1 }`). These are sent as URL
165
+ * query parameters; explicit CLI flags override them on the caller's side.
166
+ *
167
+ * @param {string} name - canonical resource name
168
+ * @returns {Record<string, number|string|boolean>} query parameters for the GET request
169
+ */
170
+ export function getGetParams(name) {
171
+ const config = loadResourceConfig(name);
172
+ if (config?.get?.params && typeof config.get.params === 'object') {
173
+ return { ...config.get.params };
174
+ }
175
+ return {};
176
+ }
177
+
178
+ // ── Helpers ──────────────────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Parse a --fields override string.
182
+ * Supports: comma-separated, JSON object, JSON array.
183
+ */
184
+ function _parseFieldsOverride(raw) {
185
+ const trimmed = raw.trim();
186
+
187
+ // JSON object: {"Alias": "path", ...}
188
+ if (trimmed.startsWith('{')) {
189
+ try {
190
+ const obj = JSON.parse(trimmed);
191
+ if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
192
+ const apiFields = _toFieldAliasMap(obj);
193
+ return { apiFields, displayColumns: Object.keys(apiFields) };
194
+ }
195
+ } catch (e) {
196
+ error(`--fields JSON is invalid: ${e.message}\n Got: ${trimmed}\n Expected format: '{"Alias": "field.path", ...}'`);
197
+ process.exit(1);
198
+ }
199
+ }
200
+
201
+ // JSON array: ["field1", "field2", ...]
202
+ if (trimmed.startsWith('[')) {
203
+ try {
204
+ const arr = JSON.parse(trimmed);
205
+ if (Array.isArray(arr)) {
206
+ const paths = arr.map(String);
207
+ const apiFields = {};
208
+ for (const p of paths) apiFields[p] = p;
209
+ return { apiFields, displayColumns: paths };
210
+ }
211
+ } catch (e) {
212
+ error(`--fields JSON is invalid: ${e.message}\n Got: ${trimmed}\n Expected format: '["field1", "field2", ...]'`);
213
+ process.exit(1);
214
+ }
215
+ }
216
+
217
+ // Comma-separated: "ID,name,status"
218
+ const paths = trimmed.split(',').map(s => s.trim()).filter(Boolean);
219
+ const apiFields = {};
220
+ for (const p of paths) apiFields[p] = p;
221
+ return { apiFields, displayColumns: paths };
222
+ }
223
+
224
+ /**
225
+ * Normalize an alias-to-field-path object into the documented string map shape.
226
+ *
227
+ * @param {Record<string, JsonValue>} value
228
+ * @returns {Record<string,string>}
229
+ */
230
+ function _toFieldAliasMap(value) {
231
+ const fields = {};
232
+ for (const [alias, field] of Object.entries(value)) {
233
+ fields[String(alias)] = String(field);
234
+ }
235
+ return fields;
236
+ }
237
+
238
+ // ── Internals ────────────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Walk the config cascade for a resource name.
242
+ * Returns the first matching config object, or null.
243
+ *
244
+ * @returns {ResourceFieldConfig|null}
245
+ */
246
+ function _resolveConfig(name) {
247
+ const filename = `${name}.json`;
248
+
249
+ // 1. Project-local: walk up from CWD looking for .zeyos/api/<name>.json
250
+ const localPath = _findLocalConfig(filename);
251
+ if (localPath) return _readJson(localPath);
252
+
253
+ // 2. Global user: ~/.zeyos/api/<name>.json
254
+ const globalPath = join(GLOBAL_DIR, filename);
255
+ if (existsSync(globalPath)) return _readJson(globalPath);
256
+
257
+ // 3. Shipped defaults: cli/config/<name>.json
258
+ const shippedPath = join(SHIPPED_DIR, filename);
259
+ if (existsSync(shippedPath)) return _readJson(shippedPath);
260
+
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Walk up from CWD looking for .zeyos/api/<filename>.
266
+ * Returns the full path if found, null otherwise.
267
+ */
268
+ function _findLocalConfig(filename) {
269
+ let dir = process.cwd();
270
+ for (let i = 0; i < 20; i++) {
271
+ const candidate = join(dir, LOCAL_NAME, LOCAL_SUB, filename);
272
+ if (existsSync(candidate)) return candidate;
273
+ const parent = dirname(dir);
274
+ if (parent === dir) break;
275
+ dir = parent;
276
+ }
277
+ return null;
278
+ }
279
+
280
+ function _readJson(path) {
281
+ try {
282
+ return JSON.parse(readFileSync(path, 'utf8'));
283
+ } catch (err) {
284
+ if (err?.code === 'ENOENT') {
285
+ return null;
286
+ }
287
+ throw new Error(`Failed to read resource config ${path}: ${err.message || err}`);
288
+ }
289
+ }