@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/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/zeyos.mjs +280 -0
- package/commands/count.mjs +63 -0
- package/commands/create.mjs +62 -0
- package/commands/delete.mjs +68 -0
- package/commands/describe.mjs +102 -0
- package/commands/get.mjs +127 -0
- package/commands/list.mjs +162 -0
- package/commands/login.mjs +223 -0
- package/commands/logout.mjs +63 -0
- package/commands/resources.mjs +49 -0
- package/commands/skills.mjs +363 -0
- package/commands/update.mjs +71 -0
- package/commands/whoami.mjs +100 -0
- package/config/account.json +18 -0
- package/config/item.json +16 -0
- package/config/project.json +16 -0
- package/config/task.json +18 -0
- package/config/ticket.json +19 -0
- package/lib/client.mjs +69 -0
- package/lib/command.mjs +148 -0
- package/lib/config.mjs +164 -0
- package/lib/flags.mjs +44 -0
- package/lib/login-server.mjs +149 -0
- package/lib/output.mjs +284 -0
- package/lib/resource-config.mjs +289 -0
- package/lib/resources.mjs +234 -0
- package/lib/types.mjs +46 -0
- package/package.json +47 -0
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
|
+
}
|
package/lib/command.mjs
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|