@pokelo/cli 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/src/config.mjs ADDED
@@ -0,0 +1,148 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import { fail } from './exit.mjs';
6
+
7
+ const LEGACY_CONFIG_PATH = join(homedir(), '.pokelo-cli.json');
8
+
9
+ /** @typedef {{ url: string, token: string, defaultProjectId?: string | null }} PokeloConfig */
10
+
11
+ export function xdgConfigPath() {
12
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
13
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
14
+ return join(base, 'pokelo', 'config.json');
15
+ }
16
+
17
+ /**
18
+ * Normalize instance URL to API base ending with /v1 (no trailing slash beyond that).
19
+ * Accepts https://host, https://host/, https://host/v1, https://host/v1/.
20
+ */
21
+ export function normalizeApiBaseUrl(raw) {
22
+ const trimmed = String(raw ?? '').trim().replace(/\/+$/, '');
23
+ if (!trimmed) fail('url is required');
24
+ let url;
25
+ try {
26
+ url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
27
+ } catch {
28
+ fail(`invalid url: ${raw}`);
29
+ }
30
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
31
+ fail(`url must be http(s): ${raw}`);
32
+ }
33
+ let path = url.pathname.replace(/\/+$/, '') || '';
34
+ if (path === '' || path === '/') {
35
+ path = '/v1';
36
+ } else if (!path.endsWith('/v1')) {
37
+ path = `${path}/v1`.replace(/\/+/g, '/');
38
+ }
39
+ return `${url.origin}${path}`;
40
+ }
41
+
42
+ function readJsonFile(path) {
43
+ try {
44
+ const raw = readFileSync(path, 'utf8');
45
+ return JSON.parse(raw);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Load persisted config (XDG first, then legacy ~/.pokelo-cli.json). */
52
+ export function loadPersistedConfig() {
53
+ const xdg = readJsonFile(xdgConfigPath());
54
+ if (xdg && typeof xdg === 'object') return sanitizeFileConfig(xdg);
55
+ const legacy = readJsonFile(LEGACY_CONFIG_PATH);
56
+ if (legacy && typeof legacy === 'object') {
57
+ return sanitizeFileConfig(migrateLegacy(legacy));
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function migrateLegacy(legacy) {
63
+ const url = typeof legacy.url === 'string' ? legacy.url : '';
64
+ // Old PoC stored MCP URL (.../v1/mcp) — strip /mcp for REST base.
65
+ const cleaned = url.replace(/\/mcp\/?$/i, '');
66
+ return {
67
+ url: cleaned,
68
+ token: legacy.token,
69
+ defaultProjectId: legacy.defaultProjectId ?? null,
70
+ };
71
+ }
72
+
73
+ function sanitizeFileConfig(obj) {
74
+ const url = typeof obj.url === 'string' ? obj.url.trim() : '';
75
+ const token = typeof obj.token === 'string' ? obj.token.trim() : '';
76
+ const defaultProjectId =
77
+ typeof obj.defaultProjectId === 'string' && obj.defaultProjectId.trim()
78
+ ? obj.defaultProjectId.trim()
79
+ : null;
80
+ if (!url && !token) return null;
81
+ return { url, token, defaultProjectId };
82
+ }
83
+
84
+ /**
85
+ * Resolve runtime config.
86
+ * @param {{ url?: string, token?: string, project?: string }} flags
87
+ */
88
+ export function resolveConfig(flags = {}) {
89
+ const file = loadPersistedConfig();
90
+ const urlRaw =
91
+ (typeof flags.url === 'string' && flags.url.trim()) ||
92
+ process.env.POKELO_URL?.trim() ||
93
+ file?.url ||
94
+ '';
95
+ const token =
96
+ (typeof flags.token === 'string' && flags.token.trim()) ||
97
+ process.env.POKELO_TOKEN?.trim() ||
98
+ file?.token ||
99
+ '';
100
+ const project =
101
+ (typeof flags.project === 'string' && flags.project.trim()) ||
102
+ process.env.POKELO_PROJECT?.trim() ||
103
+ file?.defaultProjectId ||
104
+ null;
105
+
106
+ if (!urlRaw) fail('missing Pokelo URL (set --url, POKELO_URL, or run: pokelo auth login)');
107
+ if (!token) fail('missing access token (set --token, POKELO_TOKEN, or run: pokelo auth login)');
108
+
109
+ return {
110
+ url: normalizeApiBaseUrl(urlRaw),
111
+ token,
112
+ defaultProjectId: project,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Persist config to XDG path. Never logs the token.
118
+ * @param {{ url: string, token: string, defaultProjectId?: string | null }} cfg
119
+ */
120
+ export function saveConfig(cfg) {
121
+ const path = xdgConfigPath();
122
+ mkdirSync(dirname(path), { recursive: true });
123
+ const payload = {
124
+ url: normalizeApiBaseUrl(cfg.url),
125
+ token: String(cfg.token).trim(),
126
+ defaultProjectId: cfg.defaultProjectId?.trim() || null,
127
+ };
128
+ writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
129
+ try {
130
+ chmodSync(path, 0o600);
131
+ } catch {
132
+ /* best-effort on platforms without chmod */
133
+ }
134
+ return path;
135
+ }
136
+
137
+ export function maskToken(token) {
138
+ const t = String(token ?? '');
139
+ if (!t) return null;
140
+ if (t.length <= 8) return `${t.slice(0, 2)}…`;
141
+ return `${t.slice(0, 6)}…${t.slice(-4)}`;
142
+ }
143
+
144
+ export function configExists() {
145
+ return existsSync(xdgConfigPath()) || existsSync(LEGACY_CONFIG_PATH);
146
+ }
147
+
148
+ export { LEGACY_CONFIG_PATH };
package/src/exit.mjs ADDED
@@ -0,0 +1,13 @@
1
+ /** Usage / config errors. */
2
+ export const EXIT_USAGE = 1;
3
+ /** HTTP / API errors. */
4
+ export const EXIT_API = 2;
5
+
6
+ export function fail(message, code = EXIT_USAGE) {
7
+ process.stderr.write(`Error: ${message}\n`);
8
+ process.exit(code);
9
+ }
10
+
11
+ export function writeJson(data) {
12
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
13
+ }
package/src/http.mjs ADDED
@@ -0,0 +1,71 @@
1
+ import { EXIT_API, fail } from './exit.mjs';
2
+
3
+ /**
4
+ * @param {import('./config.mjs').PokeloConfig} config
5
+ * @param {string} method
6
+ * @param {string} path - path under /v1, e.g. "/projects" or "projects"
7
+ * @param {{ body?: unknown, query?: Record<string, string | number | undefined | null> }} [opts]
8
+ */
9
+ export async function apiRequest(config, method, path, opts = {}) {
10
+ const rel = path.startsWith('/') ? path : `/${path}`;
11
+ const url = new URL(`${config.url}${rel}`);
12
+ if (opts.query) {
13
+ for (const [k, v] of Object.entries(opts.query)) {
14
+ if (v === undefined || v === null || v === '') continue;
15
+ url.searchParams.set(k, String(v));
16
+ }
17
+ }
18
+
19
+ let res;
20
+ try {
21
+ res = await fetch(url, {
22
+ method,
23
+ headers: {
24
+ Authorization: `Bearer ${config.token}`,
25
+ Accept: 'application/json',
26
+ ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
27
+ },
28
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
29
+ });
30
+ } catch (err) {
31
+ const msg = err instanceof Error ? err.message : String(err);
32
+ fail(`network request failed: ${msg}`, EXIT_API);
33
+ }
34
+
35
+ if (res.status === 204) {
36
+ return null;
37
+ }
38
+
39
+ const text = await res.text().catch(() => '');
40
+ let json = null;
41
+ if (text) {
42
+ try {
43
+ json = JSON.parse(text);
44
+ } catch {
45
+ json = null;
46
+ }
47
+ }
48
+
49
+ if (json && typeof json === 'object' && 'success' in json) {
50
+ if (json.success === false) {
51
+ const err = json.error;
52
+ const message =
53
+ typeof err?.message === 'string'
54
+ ? err.message
55
+ : Array.isArray(err?.message)
56
+ ? err.message.join('; ')
57
+ : err?.code
58
+ ? String(err.code)
59
+ : `HTTP ${res.status}`;
60
+ fail(message, EXIT_API);
61
+ }
62
+ return json.data;
63
+ }
64
+
65
+ if (!res.ok) {
66
+ const snippet = text && text.length < 500 ? text : `HTTP ${res.status} ${res.statusText}`;
67
+ fail(snippet, EXIT_API);
68
+ }
69
+
70
+ return json ?? text;
71
+ }