@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/client.mjs ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Create a configured ZeyOS client from the loaded config.
3
+ * Also provides a helper that persists refreshed tokens back to the config file.
4
+ */
5
+ import { createZeyosClient, MemoryTokenStore } from '@zeyos/client';
6
+ import { loadConfigWithSource, saveConfig, requireConfig } from './config.mjs';
7
+
8
+ /** @typedef {import('./types.mjs').CliConfig} CliConfig */
9
+
10
+ /**
11
+ * Build a ready-to-use ZeyOS API client.
12
+ * Throws a friendly error if required config keys are missing.
13
+ *
14
+ * @param {CliConfig} [overrides] Extra config values (e.g. from CLI flags)
15
+ * @returns {{ client: ReturnType<typeof createZeyosClient>, config: CliConfig, tokenStore: MemoryTokenStore, configSource: 'local'|'global'|null }}
16
+ */
17
+ export function buildClient(overrides = {}) {
18
+ const loaded = loadConfigWithSource();
19
+ const config = { ...loaded.config, ...overrides };
20
+ requireConfig(['baseUrl', 'clientId', 'clientSecret', 'accessToken'], config);
21
+
22
+ const tokenStore = new MemoryTokenStore({
23
+ accessToken: config.accessToken,
24
+ refreshToken: config.refreshToken,
25
+ expiresAt: config.expiresAt,
26
+ refreshTokenExpiresAt: config.refreshTokenExpiresAt,
27
+ });
28
+
29
+ const client = createZeyosClient({
30
+ platform: config.baseUrl,
31
+ auth: {
32
+ mode: 'oauth',
33
+ oauth: {
34
+ clientId: config.clientId,
35
+ clientSecret: config.clientSecret,
36
+ tokenStore,
37
+ autoRefresh: true,
38
+ },
39
+ },
40
+ });
41
+
42
+ return { client, config, tokenStore, configSource: loaded.source };
43
+ }
44
+
45
+ /**
46
+ * Persist any refreshed tokens back to the credential store.
47
+ * Call this after API operations to keep tokens up-to-date.
48
+ *
49
+ * @param {MemoryTokenStore} tokenStore
50
+ * @param {'local'|'global'|null} scope
51
+ */
52
+ export async function syncTokens(tokenStore, scope = 'local') {
53
+ if (!scope) {
54
+ return;
55
+ }
56
+ try {
57
+ const ts = await tokenStore.get();
58
+ if (ts?.accessToken) {
59
+ saveConfig({
60
+ accessToken: ts.accessToken,
61
+ refreshToken: ts.refreshToken,
62
+ expiresAt: ts.expiresAt,
63
+ refreshTokenExpiresAt: ts.refreshTokenExpiresAt,
64
+ }, scope);
65
+ }
66
+ } catch {
67
+ // non-critical
68
+ }
69
+ }
@@ -0,0 +1,148 @@
1
+ import { buildClient, syncTokens } from './client.mjs';
2
+ import { collectFieldFlags } from './flags.mjs';
3
+ import { resolveResource } from './resources.mjs';
4
+ import { error, info, warn } from './output.mjs';
5
+
6
+ export function fail(message) {
7
+ error(message);
8
+ process.exit(1);
9
+ }
10
+
11
+ export function requireResource(resourceName, usage, capability, unsupportedAction) {
12
+ if (!resourceName) {
13
+ fail(`Missing resource name. Usage: ${usage}`);
14
+ }
15
+
16
+ const resource = resolveResource(resourceName);
17
+ if (!resource) {
18
+ fail(`Unknown resource: "${resourceName}". Run 'zeyos resources' to see available types.`);
19
+ }
20
+
21
+ if (capability && !resource[capability]) {
22
+ fail(`Resource "${resourceName}" does not support ${unsupportedAction}.`);
23
+ }
24
+
25
+ return resource;
26
+ }
27
+
28
+ export function requireRecordId(id, usage) {
29
+ if (!id) {
30
+ fail(`Missing record ID. Usage: ${usage}`);
31
+ }
32
+ }
33
+
34
+ export function buildCliClient() {
35
+ try {
36
+ return buildClient();
37
+ } catch (err) {
38
+ fail(err.message);
39
+ }
40
+ }
41
+
42
+ export function parseJsonOption(value, flagName) {
43
+ if (!value) return undefined;
44
+
45
+ try {
46
+ return JSON.parse(value);
47
+ } catch {
48
+ fail(`--${flagName} must be valid JSON. Got: ${value}`);
49
+ }
50
+ }
51
+
52
+ /** Cheap structural check: does this string look like an intended JSON object? */
53
+ function looksLikeJsonObject(value) {
54
+ return typeof value === 'string' && value.trim().startsWith('{');
55
+ }
56
+
57
+ /**
58
+ * Parse a string as a JSON object.
59
+ *
60
+ * @param {string} [value]
61
+ * @returns {Record<string, unknown> | undefined} the object, or `undefined` if
62
+ * the value is absent, malformed, or not a plain (non-array) object.
63
+ */
64
+ function tryParseJsonObject(value) {
65
+ if (!looksLikeJsonObject(value)) return undefined;
66
+ try {
67
+ const parsed = JSON.parse(value);
68
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
69
+ return parsed;
70
+ }
71
+ } catch {
72
+ // not valid JSON — fall through
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Build a record payload for `create`/`update` from `--data`, individual
79
+ * `--<field>` flags, or — as a fallback — a JSON object passed positionally.
80
+ *
81
+ * Coding agents frequently run `zeyos create tickets '{"name":"x"}'`, passing
82
+ * the body positionally (often alongside the `--json` output flag). When no
83
+ * `--data`/`--<field>` values were given and that positional argument parses as
84
+ * a JSON object, adopt it as the payload instead of failing. If it only *looks*
85
+ * like JSON (e.g. malformed), point the caller at `--data` explicitly rather
86
+ * than emitting the generic "No fields provided" error.
87
+ *
88
+ * @param {Record<string, string|boolean>} values - parsed CLI flag values
89
+ * @param {string} [positionalData] - candidate positional JSON body
90
+ * @returns {Record<string, unknown>}
91
+ */
92
+ export function buildRecordPayload(values, positionalData) {
93
+ const parsed = parseJsonOption(values.data, 'data');
94
+ const data = parsed === undefined ? {} : parsed;
95
+
96
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
97
+ fail(`--data must be a JSON object. Got: ${values.data}`);
98
+ }
99
+
100
+ Object.assign(data, collectFieldFlags(values));
101
+
102
+ if (Object.keys(data).length > 0) {
103
+ // Explicit --data / --<field> values win; surface an ignored positional
104
+ // JSON body so it isn't silently dropped.
105
+ if (looksLikeJsonObject(positionalData)) {
106
+ warn('Ignoring positional JSON argument; using --data / --<field> values instead.');
107
+ }
108
+ return data;
109
+ }
110
+
111
+ // No --data and no --<field> flags. A JSON object passed positionally is a
112
+ // common agent mistake — adopt it rather than rejecting the command.
113
+ if (looksLikeJsonObject(positionalData)) {
114
+ const positionalObject = tryParseJsonObject(positionalData);
115
+ if (positionalObject && Object.keys(positionalObject).length > 0) {
116
+ info("Treating positional JSON argument as --data. Tip: pass it as --data '<json>'.");
117
+ return positionalObject;
118
+ }
119
+ if (!positionalObject) {
120
+ fail("It looks like you passed a malformed JSON object positionally; use --data '<json>' with valid JSON.");
121
+ }
122
+ // Parsed to an empty object — genuinely no fields.
123
+ }
124
+
125
+ fail('No fields provided. Use --data or individual --<field> flags.');
126
+ }
127
+
128
+ export function requireApiMethod(clientState, operationId) {
129
+ const fn = clientState.client.api[operationId];
130
+ if (typeof fn !== 'function') {
131
+ fail(`Operation "${operationId}" is not available on this client.`);
132
+ }
133
+ return fn;
134
+ }
135
+
136
+ export async function callApi(clientState, operationId, input, options = {}) {
137
+ const fn = requireApiMethod(clientState, operationId);
138
+ try {
139
+ const result = await fn(input);
140
+ await syncTokens(clientState.tokenStore, clientState.configSource);
141
+ return result;
142
+ } catch (err) {
143
+ if (err.status === 404 && options.notFoundMessage) {
144
+ fail(options.notFoundMessage);
145
+ }
146
+ fail(`${options.errorPrefix ?? 'API error'}: ${err.message}`);
147
+ }
148
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Credential configuration — cascade:
3
+ * 1. Environment variables (ZEYOS_BASE_URL, ZEYOS_TOKEN, …)
4
+ * 2. .zeyos/auth.json (walk up from CWD, like .gitconfig)
5
+ * 3. ~/.config/zeyos/credentials.json
6
+ *
7
+ * The auth file stores connection params AND tokens.
8
+ * Add .zeyos/auth.json to .gitignore.
9
+ */
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+
14
+ /** @typedef {import('./types.mjs').CliConfig} CliConfig */
15
+
16
+ // ── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ const LOCAL_DIR = '.zeyos';
19
+ const LOCAL_FILE = 'auth.json';
20
+ const GLOBAL_DIR = join(homedir(), '.config', 'zeyos');
21
+ const GLOBAL_FILE = join(GLOBAL_DIR, 'credentials.json');
22
+
23
+ // ── Load ─────────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Load the full config object using the cascade.
27
+ * Returns a merged object; env vars always win over file values.
28
+ */
29
+ export function loadConfig() {
30
+ return loadConfigWithSource().config;
31
+ }
32
+
33
+ /**
34
+ * Load config and identify the credential file scope that should receive
35
+ * refreshed tokens. Local config overrides global config field-by-field, so a
36
+ * partial local file cannot shadow global connection parameters.
37
+ */
38
+ export function loadConfigWithSource() {
39
+ const localPath = _findLocalPath();
40
+ const globalFile = _readGlobal();
41
+ const localFile = localPath ? _readJson(localPath) : {};
42
+ const env = _fromEnv();
43
+
44
+ return {
45
+ config: { ...globalFile, ...localFile, ...env },
46
+ source: localPath ? 'local' : (existsSync(GLOBAL_FILE) ? 'global' : null)
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Require specific keys to be present; throws a human-friendly error if not.
52
+ * @param {string[]} keys
53
+ * @param {CliConfig} config
54
+ */
55
+ export function requireConfig(keys, config) {
56
+ const missing = keys.filter(k => config[k] == null || config[k] === '');
57
+ if (missing.length === 0) return;
58
+
59
+ const hints = {
60
+ baseUrl: 'Set ZEYOS_BASE_URL or run: zeyos login --base-url <url>',
61
+ clientId: 'Set ZEYOS_CLIENT_ID or run: zeyos login --client-id <id>',
62
+ clientSecret: 'Set ZEYOS_CLIENT_SECRET or run: zeyos login --secret <secret>',
63
+ accessToken: 'Run: zeyos login',
64
+ };
65
+
66
+ const messages = missing.map(k => ` • ${k}: ${hints[k] ?? 'not set'}`);
67
+ throw new Error(`Missing required configuration:\n${messages.join('\n')}`);
68
+ }
69
+
70
+ // ── Save ─────────────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Save (merge) config values into the nearest .zeyos/auth.json found while
74
+ * walking up, or create one in the current directory. Falls back to the
75
+ * global file when `scope === 'global'`.
76
+ *
77
+ * @param {CliConfig} updates
78
+ * @param {'local'|'global'} scope
79
+ */
80
+ export function saveConfig(updates, scope = 'local') {
81
+ if (scope === 'global') {
82
+ _writeGlobal({ ..._readGlobal(), ...updates });
83
+ return;
84
+ }
85
+ const existing = _findLocalPath();
86
+ const path = existing ?? join(process.cwd(), LOCAL_DIR, LOCAL_FILE);
87
+ const current = existing ? _readJson(path) : {};
88
+ _writeJson(path, { ...current, ...updates });
89
+ }
90
+
91
+ /** Remove the stored tokens (leave connection params intact). */
92
+ export function clearTokens(scope = 'local') {
93
+ const strip = o => {
94
+ const { accessToken, refreshToken, expiresAt, refreshTokenExpiresAt, ...rest } = o;
95
+ return rest;
96
+ };
97
+ if (scope === 'global') {
98
+ _writeGlobal(strip(_readGlobal()));
99
+ return;
100
+ }
101
+ const path = _findLocalPath();
102
+ if (path) _writeJson(path, strip(_readJson(path)));
103
+ }
104
+
105
+ /** Return the path of the active local .zeyos/auth.json (if any). */
106
+ export function localConfigPath() {
107
+ return _findLocalPath();
108
+ }
109
+
110
+ /** Return the global credentials file path. */
111
+ export function globalConfigPath() {
112
+ return GLOBAL_FILE;
113
+ }
114
+
115
+ // ── Internals ────────────────────────────────────────────────────────────────
116
+
117
+ function _fromEnv() {
118
+ const e = process.env;
119
+ const out = {};
120
+ if (e.ZEYOS_BASE_URL) out.baseUrl = e.ZEYOS_BASE_URL;
121
+ if (e.ZEYOS_INSTANCE) out.instance = e.ZEYOS_INSTANCE;
122
+ if (e.ZEYOS_CLIENT_ID) out.clientId = e.ZEYOS_CLIENT_ID;
123
+ if (e.ZEYOS_CLIENT_SECRET) out.clientSecret = e.ZEYOS_CLIENT_SECRET;
124
+ if (e.ZEYOS_TOKEN) out.accessToken = e.ZEYOS_TOKEN;
125
+ if (e.ZEYOS_REFRESH_TOKEN) out.refreshToken = e.ZEYOS_REFRESH_TOKEN;
126
+ return out;
127
+ }
128
+
129
+ function _findLocalPath() {
130
+ let dir = process.cwd();
131
+ for (let i = 0; i < 20; i++) {
132
+ const candidate = join(dir, LOCAL_DIR, LOCAL_FILE);
133
+ if (existsSync(candidate)) return candidate;
134
+ const parent = dirname(dir);
135
+ if (parent === dir) break;
136
+ dir = parent;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function _readGlobal() {
142
+ return existsSync(GLOBAL_FILE) ? _readJson(GLOBAL_FILE) : {};
143
+ }
144
+
145
+ function _writeGlobal(data) {
146
+ mkdirSync(GLOBAL_DIR, { recursive: true });
147
+ _writeJson(GLOBAL_FILE, data);
148
+ }
149
+
150
+ function _readJson(path) {
151
+ try {
152
+ return JSON.parse(readFileSync(path, 'utf8'));
153
+ } catch (err) {
154
+ if (err?.code === 'ENOENT') {
155
+ return {};
156
+ }
157
+ throw new Error(`Failed to read ${path}: ${err.message || err}`);
158
+ }
159
+ }
160
+
161
+ function _writeJson(path, data) {
162
+ mkdirSync(dirname(path), { recursive: true });
163
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
164
+ }
package/lib/flags.mjs ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Helpers for turning loose `--<field> <value>` CLI flags into a record payload
3
+ * for the create/update commands.
4
+ */
5
+
6
+ // Global CLI flags that are never record fields. Any other --flag on
7
+ // create/update is treated as a field on the record being written.
8
+ const RESERVED_FLAGS = new Set([
9
+ 'data', 'json', 'yaml', 'help', 'h',
10
+ 'no-color', 'force', 'fields', 'filter', 'sort',
11
+ 'limit', 'offset', 'expand', 'base-url', 'client-id',
12
+ 'secret', 'scope', 'global', 'port', 'manual', 'show-token',
13
+ 'extdata', 'tags', 'all', 'clean',
14
+ ]);
15
+
16
+ /**
17
+ * Coerce a raw string flag value to its natural JS type
18
+ * (boolean, null, or number) where it unambiguously looks like one.
19
+ *
20
+ * @param {string|boolean} value
21
+ * @returns {string|number|boolean|null}
22
+ */
23
+ function coerceFlagValue(value) {
24
+ if (value === 'true') return true;
25
+ if (value === 'false') return false;
26
+ if (value === 'null') return null;
27
+ if (typeof value === 'string' && value !== '' && !isNaN(Number(value))) return Number(value);
28
+ return value;
29
+ }
30
+
31
+ /**
32
+ * Collect non-reserved `--<field> <value>` flags into a record-field object,
33
+ * coercing each value to its natural JS type.
34
+ *
35
+ * @param {Record<string, string|boolean>} values - parsed CLI flag values
36
+ * @returns {Record<string, string|number|boolean|null>}
37
+ */
38
+ export function collectFieldFlags(values) {
39
+ const data = {};
40
+ for (const [key, value] of Object.entries(values)) {
41
+ if (!RESERVED_FLAGS.has(key)) data[key] = coerceFlagValue(value);
42
+ }
43
+ return data;
44
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Lightweight OAuth callback server.
3
+ *
4
+ * Starts a temporary HTTP server on localhost:PORT, opens the authorization
5
+ * URL in the system browser, waits for the redirect, extracts `?code=`, then
6
+ * shuts itself down.
7
+ *
8
+ * Falls back gracefully when no browser is available (CI, SSH, headless).
9
+ */
10
+
11
+ import { createServer } from 'node:http';
12
+ import { exec } from 'node:child_process';
13
+
14
+ const DEFAULT_PORT = 9005;
15
+ const CALLBACK_PATH = '/callback';
16
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
17
+
18
+ // ── Browser opener ────────────────────────────────────────────────────────────
19
+
20
+ /** Open a URL in the system default browser. Returns true if a command was spawned. */
21
+ function openBrowser(url) {
22
+ const cmd =
23
+ process.platform === 'darwin' ? `open "${url}"` :
24
+ process.platform === 'win32' ? `start "" "${url}"` :
25
+ /* linux/other */ `xdg-open "${url}"`;
26
+
27
+ return new Promise(resolve => {
28
+ exec(cmd, err => resolve(!err));
29
+ });
30
+ }
31
+
32
+ // ── Server ────────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Start a temporary local server, open the browser, and resolve with the
36
+ * authorization code once ZeyOS redirects back.
37
+ *
38
+ * @param {string} authUrl - Full authorization URL to open
39
+ * @param {number} [port] - Local port to listen on
40
+ * @param {string} [expectedState] - Expected OAuth state param for CSRF validation
41
+ * @returns {Promise<string>} - Resolves with the `code` query param
42
+ */
43
+ export async function waitForCallback(authUrl, port = DEFAULT_PORT, expectedState = undefined) {
44
+ return new Promise((resolve, reject) => {
45
+ let server;
46
+ let timer;
47
+
48
+ const cleanup = () => {
49
+ clearTimeout(timer);
50
+ try { server?.close(); } catch { /* ignore */ }
51
+ };
52
+
53
+ server = createServer((req, res) => {
54
+ const u = new URL(req.url, `http://localhost:${port}`);
55
+
56
+ // Only handle the callback path; ignore favicon etc.
57
+ if (u.pathname !== CALLBACK_PATH) {
58
+ res.writeHead(404);
59
+ res.end();
60
+ return;
61
+ }
62
+
63
+ const code = u.searchParams.get('code');
64
+ const error = u.searchParams.get('error');
65
+ const returnedState = u.searchParams.get('state');
66
+
67
+ // CSRF check: validate state matches if we sent one
68
+ if (expectedState && returnedState !== expectedState) {
69
+ res.writeHead(400, { 'Content-Type': 'text/html' });
70
+ res.end(_html('State mismatch', '<p>OAuth state parameter does not match. This may be a CSRF attack.</p><p>Please try again.</p>', true));
71
+ cleanup();
72
+ reject(new Error('OAuth state mismatch — possible CSRF attack. Please retry login.'));
73
+ return;
74
+ }
75
+
76
+ if (error) {
77
+ res.writeHead(200, { 'Content-Type': 'text/html' });
78
+ res.end(_html('Authorization failed', `<p>Error: <strong>${_esc(error)}</strong></p><p>You may close this tab.</p>`, true));
79
+ cleanup();
80
+ reject(new Error(`OAuth error: ${error} — ${u.searchParams.get('error_description') ?? ''}`));
81
+ return;
82
+ }
83
+
84
+ if (!code) {
85
+ res.writeHead(400, { 'Content-Type': 'text/html' });
86
+ res.end(_html('Bad request', '<p>No authorization code in callback.</p>', true));
87
+ cleanup();
88
+ reject(new Error('No authorization code received'));
89
+ return;
90
+ }
91
+
92
+ res.writeHead(200, { 'Content-Type': 'text/html' });
93
+ res.end(_html('Authorized', '<p>You are now logged in. You may close this tab.</p>', false));
94
+ cleanup();
95
+ resolve(code);
96
+ });
97
+
98
+ server.on('error', err => {
99
+ cleanup();
100
+ reject(err);
101
+ });
102
+
103
+ server.listen(port, '127.0.0.1', async () => {
104
+ // Set a hard timeout so the process doesn't hang forever
105
+ timer = setTimeout(() => {
106
+ cleanup();
107
+ reject(new Error('Login timed out after 5 minutes'));
108
+ }, TIMEOUT_MS);
109
+
110
+ const opened = await openBrowser(authUrl);
111
+ if (!opened) {
112
+ // Can't open browser — caller should fall back to manual flow
113
+ cleanup();
114
+ reject(new BrowserUnavailableError(authUrl));
115
+ }
116
+ });
117
+ });
118
+ }
119
+
120
+ /** @returns {string} The redirect URI for this server. */
121
+ export function callbackUri(port = DEFAULT_PORT) {
122
+ return `http://127.0.0.1:${port}${CALLBACK_PATH}`;
123
+ }
124
+
125
+ // ── Custom error type ─────────────────────────────────────────────────────────
126
+
127
+ export class BrowserUnavailableError extends Error {
128
+ constructor(authUrl) {
129
+ super('Could not open browser');
130
+ this.name = 'BrowserUnavailableError';
131
+ this.authUrl = authUrl;
132
+ }
133
+ }
134
+
135
+ // ── Helpers ───────────────────────────────────────────────────────────────────
136
+
137
+ function _esc(s) {
138
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
139
+ }
140
+
141
+ function _html(title, body, isError) {
142
+ const color = isError ? '#c0392b' : '#27ae60';
143
+ return `<!DOCTYPE html><html><head><meta charset="utf-8">
144
+ <title>${_esc(title)} — ZeyOS</title>
145
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
146
+ .box{background:#fff;border-radius:8px;padding:2rem 3rem;box-shadow:0 2px 8px rgba(0,0,0,.12);text-align:center;max-width:400px}
147
+ h1{color:${color};margin-bottom:.5rem}p{color:#555;line-height:1.5}</style>
148
+ </head><body><div class="box"><h1>${_esc(title)}</h1>${body}</div></body></html>`;
149
+ }