@deerdesign/deertools 0.0.0-stage → 1.0.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/README.md +62 -2
- package/bin/deertools.js +3 -0
- package/package.json +41 -4
- package/src/cli.js +43 -0
- package/src/commands.js +203 -0
- package/src/credentials.js +68 -0
- package/src/http.js +97 -0
- package/src/login.js +189 -0
- package/src/openapi.js +27 -0
- package/src/registry.js +62 -0
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# deertools — DeerTools CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Logujesz się raz z laptopa, a Twój agent AI dostaje dostęp do API produktów DeerTools: **w Twoim imieniu, nie szerzej niż Ty, w ograniczonym zakresie**.
|
|
4
|
+
|
|
5
|
+
Produkty: `kosztorys`, `katalog`, `dostawy`, `supplier`, `rendermind`. Adresy produkcyjne: Kosztorys i Katalog na `https://connect.deerdesign.pl/kosztorys/` i `/katalog/`, Dostawy `https://ship.deergroup.pl/`, Supplier Hub `https://supplier.deergroup.pl/`, RenderMind `https://rendermind.deergroup.pl/`.
|
|
6
|
+
|
|
7
|
+
## Instalacja na dowolnym komputerze
|
|
8
|
+
|
|
9
|
+
Wymaga Node.js ≥ 18. Pakiet jest opublikowany w publicznym rejestrze npm:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Opcja 1: Uruchomienie bezpośrednio bez wcześniejszej instalacji:
|
|
13
|
+
npx @deerdesign/deertools login kosztorys
|
|
14
|
+
|
|
15
|
+
# Opcja 2: Instalacja globalna w systemie (udostępnia komendy 'deertools' oraz 'deer'):
|
|
16
|
+
npm install -g @deerdesign/deertools
|
|
17
|
+
deertools --help
|
|
18
|
+
# lub krócej:
|
|
19
|
+
deer whoami
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Logowanie
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
deertools login kosztorys # jeden produkt, zakres: tylko odczyt (read)
|
|
26
|
+
deertools login katalog --write # odczyt i zapis
|
|
27
|
+
deertools login all # kolejno wszystkie produkty
|
|
28
|
+
deertools whoami # status, zakres, ważność
|
|
29
|
+
deertools logout kosztorys
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Jak to działa (OAuth 2.1, kod autoryzacyjny + PKCE, RFC 8252):
|
|
33
|
+
|
|
34
|
+
1. CLI uruchamia serwer na `127.0.0.1` (losowy port), otwiera przeglądarkę na stronie `…/oauth/authorize` produktu.
|
|
35
|
+
2. Logujesz się w produkcie (Microsoft w Kosztorysie; kod lub link z e-maila gdzie indziej) i klikasz „Zezwól na dostęp”. Możesz obniżyć zakres do samego odczytu.
|
|
36
|
+
3. Przeglądarka wraca do CLI wyłącznie z **jednorazowym kodem** (ważnym 60 s). Klucza API nie ma w adresie.
|
|
37
|
+
4. CLI wymienia kod na klucz bezpośrednio z serwerem produktu (dowód PKCE) i zapisuje go w `~/.deertools/credentials.json` z uprawnieniami `600`.
|
|
38
|
+
|
|
39
|
+
Klucz: `deer_<produkt>_live_…`, ważny 90 dni, unieważnisz go w produkcie (Ustawienia → Dostęp API). Zakres domyślny to **read**; `write` wymaga `--write` i zgody w przeglądarce. Adresy autoryzacji i wymiany kodu CLI pobiera z `…/.well-known/oauth-authorization-server` produktu (RFC 8414) i odmawia logowania, gdy produkt nie deklaruje PKCE (S256).
|
|
40
|
+
|
|
41
|
+
## Użycie przez agenta
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
deertools docs <produkt> # llms.txt: czym jest produkt, jak się uwierzytelnić (publiczne)
|
|
45
|
+
deertools docs <produkt> openapi # pełna specyfikacja OpenAPI
|
|
46
|
+
deertools endpoints <produkt> # lista endpointów z zakresem (read/write)
|
|
47
|
+
deertools api <produkt> GET /ścieżka --query k=v
|
|
48
|
+
deertools api <produkt> POST /ścieżka --data '{"a":1}'
|
|
49
|
+
deertools skill install [produkt|all] # instaluje skill agenta z produktu do ~/.gemini/config/skills
|
|
50
|
+
deertools estimate --file rzut.pdf # wycena rzutu (Kosztorys)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`deertools api` wysyła klucz **wyłącznie** pod adres API produktu (tylko ścieżki względne) i podpowiada, co zrobić przy 401, 403 i 429. Kody wyjścia: `0` ok · `1` błąd żądania · `2` błędne użycie · `3` brak lub wygasłe logowanie · `4` brak dokumentacji lub adresu API.
|
|
54
|
+
|
|
55
|
+
Zmienne środowiskowe dla CI i agentów: `DEERTOOLS_<PRODUKT>_KEY` (np. `DEERTOOLS_KATALOG_KEY`), `DEERTOOLS_HOME` (katalog poświadczeń).
|
|
56
|
+
|
|
57
|
+
## Rozwój i publikacja
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm test # testy węzła (node --test), bez zależności
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Publikacja (GitHub Packages): zawartość tego katalogu jest korzeniem repozytorium `DeerDesign/deertools-cli`. Zwiększ wersję w `package.json`, zatwierdź i wypchnij tag `v<wersja>`; workflow `.github/workflows/publish-package.yml` uruchomi testy, sprawdzi zgodność tagu z wersją i opublikuje pakiet. Ręcznie: `npm login --scope=@deerdesign --registry=https://npm.pkg.github.com`, potem `npm publish` (token z `write:packages`).
|
package/bin/deertools.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deerdesign/deertools",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public",
|
|
6
|
+
"registry": "https://registry.npmjs.org/"
|
|
7
|
+
},
|
|
8
|
+
"description": "DeerTools CLI: logowanie z laptopa i dostęp agenta AI do API produktów DeerTools (Kosztorys, Katalog, Dostawy, Supplier Hub, RenderMind)",
|
|
9
|
+
"bin": {
|
|
10
|
+
"deertools": "bin/deertools.js",
|
|
11
|
+
"deer": "bin/deertools.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/cli.js",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node ./bin/deertools.js",
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"deerdesign",
|
|
20
|
+
"deertools",
|
|
21
|
+
"kosztorys",
|
|
22
|
+
"katalog",
|
|
23
|
+
"dostawy",
|
|
24
|
+
"rendermind",
|
|
25
|
+
"antigravity",
|
|
26
|
+
"oauth",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"author": "Deer Design AI Team",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/deerdesign/deertools-cli.git"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin",
|
|
37
|
+
"src",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const c = require('./commands');
|
|
3
|
+
|
|
4
|
+
function parseArgs(argv) {
|
|
5
|
+
const pos = [];
|
|
6
|
+
const flags = {};
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const a = argv[i];
|
|
9
|
+
if (a.startsWith('--')) {
|
|
10
|
+
const eq = a.indexOf('=');
|
|
11
|
+
const key = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
12
|
+
let val = eq === -1 ? undefined : a.slice(eq + 1);
|
|
13
|
+
if (val === undefined) {
|
|
14
|
+
const next = argv[i + 1];
|
|
15
|
+
if (next !== undefined && !next.startsWith('--') && !['write', 'json', 'raw', 'no-open'].includes(key)) { val = next; i++; } else val = true;
|
|
16
|
+
}
|
|
17
|
+
if (flags[key] === undefined) flags[key] = val;
|
|
18
|
+
else flags[key] = [].concat(flags[key], val);
|
|
19
|
+
} else pos.push(a);
|
|
20
|
+
}
|
|
21
|
+
return { pos, flags };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main(argv) {
|
|
25
|
+
const { pos, flags } = parseArgs(argv);
|
|
26
|
+
const cmd = pos.shift() || 'help';
|
|
27
|
+
const table = {
|
|
28
|
+
login: c.cmdLogin, logout: c.cmdLogout, whoami: c.cmdWhoami, docs: c.cmdDocs,
|
|
29
|
+
endpoints: c.cmdEndpoints, api: c.cmdApi, estimate: c.cmdEstimate, skill: c.cmdSkill,
|
|
30
|
+
};
|
|
31
|
+
if (cmd === 'help' || cmd === '--help' || flags.help) { process.stdout.write(c.HELP); return 0; }
|
|
32
|
+
if (cmd === '--version' || cmd === 'version') { process.stdout.write(`${require('../package.json').version}\n`); return 0; }
|
|
33
|
+
if (!table[cmd]) { process.stderr.write(`Nieznana komenda „${cmd}”.\n${c.HELP}`); return 2; }
|
|
34
|
+
try {
|
|
35
|
+
await table[cmd](pos, flags);
|
|
36
|
+
return 0;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (!e.silent) process.stderr.write(`Błąd: ${e.message}\n`);
|
|
39
|
+
return e.exitCode || 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { main, parseArgs };
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const credentials = require('./credentials');
|
|
6
|
+
const { getProduct, resolveProducts, ALL_IDS } = require('./registry');
|
|
7
|
+
const { login, normalizeScope } = require('./login');
|
|
8
|
+
const { discover, fetchText, apiRequest, explain } = require('./http');
|
|
9
|
+
const { listOperations, parseFrontmatterName } = require('./openapi');
|
|
10
|
+
|
|
11
|
+
const out = (s = '') => process.stdout.write(`${s}\n`);
|
|
12
|
+
const err = (s = '') => process.stderr.write(`${s}\n`);
|
|
13
|
+
|
|
14
|
+
function fail(message, exitCode = 1) {
|
|
15
|
+
const e = new Error(message);
|
|
16
|
+
e.exitCode = exitCode;
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function needProduct(flags, pos) {
|
|
21
|
+
if (!pos[0]) fail(`Podaj produkt: ${ALL_IDS.join(' | ')}`, 2);
|
|
22
|
+
return getProduct(pos[0]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function cmdLogin(pos, flags) {
|
|
26
|
+
const products = resolveProducts(pos[0]);
|
|
27
|
+
const scope = flags.write ? 'read write' : normalizeScope(flags.scope || 'read');
|
|
28
|
+
let failures = 0;
|
|
29
|
+
for (const product of products) {
|
|
30
|
+
out(`\n=== ${product.name} ===`);
|
|
31
|
+
try {
|
|
32
|
+
const entry = await login(product, { scope, port: flags.port ? Number(flags.port) : 0, clientName: flags.name, openBrowser: flags['no-open'] ? null : undefined, log: out });
|
|
33
|
+
out(`✓ Zalogowano${entry.email ? ` jako ${entry.email}` : ''}. Zakres: ${entry.scope}${entry.expires_at ? `, ważny do ${entry.expires_at.slice(0, 10)}` : ''}.`);
|
|
34
|
+
out(` Dokumentacja dla agenta: deertools docs ${product.id} | skill: deertools skill install ${product.id}`);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
failures += 1;
|
|
37
|
+
err(`✗ ${product.name}: ${e.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (failures) fail(`Nie udało się zalogować do ${failures} z ${products.length} produktów.`, 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function cmdLogout(pos) {
|
|
44
|
+
for (const product of resolveProducts(pos[0])) {
|
|
45
|
+
out(credentials.removeProduct(product.id) ? `✓ Wylogowano z ${product.name} (klucz usunięty lokalnie).` : `– ${product.name}: nie byłeś zalogowany.`);
|
|
46
|
+
}
|
|
47
|
+
out('Klucz możesz też unieważnić w produkcie: Ustawienia → Dostęp API.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function cmdWhoami(pos, flags) {
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const product of resolveProducts(pos[0])) {
|
|
53
|
+
const found = credentials.getKey(product);
|
|
54
|
+
if (!found) { rows.push({ id: product.id, status: 'niezalogowany' }); continue; }
|
|
55
|
+
const exp = credentials.expiryInfo(found.entry);
|
|
56
|
+
const row = {
|
|
57
|
+
id: product.id,
|
|
58
|
+
status: exp.expired ? 'WYGASŁ' : 'zalogowany',
|
|
59
|
+
key: credentials.mask(found.key),
|
|
60
|
+
email: found.entry && found.entry.email,
|
|
61
|
+
scope: found.entry ? found.entry.scope : '(z zmiennej środowiskowej)',
|
|
62
|
+
expires: found.entry && found.entry.expires_at ? `${found.entry.expires_at.slice(0, 10)}${exp.daysLeft != null ? ` (${exp.daysLeft} dni)` : ''}` : null,
|
|
63
|
+
source: found.source,
|
|
64
|
+
};
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
if (flags.json) { out(JSON.stringify(rows, null, 2)); return; }
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
out(`${r.id.padEnd(11)} ${r.status}`);
|
|
70
|
+
if (r.key) out(`${''.padEnd(11)} klucz ${r.key} · zakres ${r.scope}${r.email ? ` · ${r.email}` : ''}${r.expires ? ` · ważny do ${r.expires}` : ''} · źródło ${r.source}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function cmdDocs(pos, flags) {
|
|
75
|
+
const product = needProduct(flags, pos);
|
|
76
|
+
const what = (pos[1] || 'llms').toLowerCase();
|
|
77
|
+
if (what === 'llms') {
|
|
78
|
+
const r = await fetchText(`${product.site}/llms.txt`);
|
|
79
|
+
if (!r.ok) fail(`Brak dokumentacji dla agenta (${product.site}/llms.txt zwraca ${r.status}). Produkt jeszcze jej nie opublikował.`, 4);
|
|
80
|
+
out(r.text);
|
|
81
|
+
} else if (what === 'openapi') {
|
|
82
|
+
const d = await discover(product);
|
|
83
|
+
if (!d.openapi) fail(`Brak openapi.json produktu ${product.name}.`, 4);
|
|
84
|
+
out(JSON.stringify(d.openapi, null, 2));
|
|
85
|
+
} else if (what === 'skill') {
|
|
86
|
+
const r = await fetchText(`${product.site}/skill/SKILL.md`);
|
|
87
|
+
if (!r.ok) fail(`Brak skilla (${product.site}/skill/SKILL.md zwraca ${r.status}).`, 4);
|
|
88
|
+
out(r.text);
|
|
89
|
+
} else {
|
|
90
|
+
fail('Użycie: deertools docs <produkt> [llms|openapi|skill]', 2);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function cmdEndpoints(pos, flags) {
|
|
95
|
+
const product = needProduct(flags, pos);
|
|
96
|
+
const d = await discover(product);
|
|
97
|
+
if (!d.openapi) fail(`Produkt ${product.name} nie udostępnia jeszcze openapi.json (sprawdź: deertools docs ${product.id}).`, 4);
|
|
98
|
+
const ops = listOperations(d.openapi);
|
|
99
|
+
if (flags.json) { out(JSON.stringify({ apiBase: d.apiBase, operations: ops }, null, 2)); return; }
|
|
100
|
+
out(`${product.name} — ${d.apiBase}`);
|
|
101
|
+
for (const o of ops) out(` ${o.method.padEnd(6)} ${o.path.padEnd(48)} [${o.scope}] ${o.summary}`.trimEnd());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseQuery(list) {
|
|
105
|
+
const q = {};
|
|
106
|
+
for (const item of [].concat(list || [])) {
|
|
107
|
+
const i = String(item).indexOf('=');
|
|
108
|
+
if (i < 1) fail(`Zły parametr zapytania „${item}” — użyj --query klucz=wartość`, 2);
|
|
109
|
+
q[item.slice(0, i)] = item.slice(i + 1);
|
|
110
|
+
}
|
|
111
|
+
return q;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function cmdApi(pos, flags) {
|
|
115
|
+
const product = needProduct(flags, pos);
|
|
116
|
+
const method = String(pos[1] || '').toUpperCase();
|
|
117
|
+
const p = pos[2];
|
|
118
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method) || !p) fail('Użycie: deertools api <produkt> <GET|POST|PUT|PATCH|DELETE> </ścieżka> [--query k=v] [--data \'{"a":1}\' | --data-file plik.json]', 2);
|
|
119
|
+
let body;
|
|
120
|
+
if (flags['data-file']) body = fs.readFileSync(flags['data-file'], 'utf8');
|
|
121
|
+
else if (flags.data !== undefined && flags.data !== true) body = String(flags.data);
|
|
122
|
+
const res = await apiRequest(product, { method, path: p, query: parseQuery(flags.query), body });
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
err(explain(res.status, res.json, product) + (res.retryAfter ? ` Spróbuj za ${res.retryAfter} s.` : ''));
|
|
125
|
+
if (res.text) err(res.text.slice(0, 1000));
|
|
126
|
+
const e = new Error('');
|
|
127
|
+
e.exitCode = res.status === 401 ? 3 : 1;
|
|
128
|
+
e.silent = true;
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
out(res.json ? JSON.stringify(res.json, null, flags.raw ? 0 : 2) : res.text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function cmdEstimate(pos, flags) {
|
|
135
|
+
const file = flags.file || pos[0];
|
|
136
|
+
if (!file) fail('Użycie: deertools estimate --file <rzut.pdf> [--kitchen 6]', 2);
|
|
137
|
+
if (!fs.existsSync(file)) fail(`Plik nie istnieje: ${file}`, 2);
|
|
138
|
+
const bytes = fs.readFileSync(file);
|
|
139
|
+
if (bytes.length > 10 * 1024 * 1024) fail('Plik jest większy niż 10 MB (limit analizy rzutu).', 2);
|
|
140
|
+
const ext = path.extname(file).toLowerCase().slice(1);
|
|
141
|
+
const mime = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }[ext];
|
|
142
|
+
if (!mime) fail('Obsługiwane formaty: PDF, PNG, JPG, WEBP.', 2);
|
|
143
|
+
const kitchen = flags.kitchen ? Number(String(flags.kitchen).replace(',', '.')) : 6;
|
|
144
|
+
if (!(kitchen >= 2 && kitchen <= 30)) fail('--kitchen musi być w zakresie 2–30 m².', 2);
|
|
145
|
+
const product = getProduct('kosztorys');
|
|
146
|
+
out(`Analizuję ${path.basename(file)} (aneks ${kitchen} m²) — to trwa do ok. minuty…`);
|
|
147
|
+
const res = await apiRequest(product, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
path: '/estimate-from-floor-plan',
|
|
150
|
+
body: { file_url: `data:${mime};base64,${bytes.toString('base64')}`, file_name: path.basename(file), file_type: mime, kitchen_open_area_m2: kitchen },
|
|
151
|
+
base: product.fallbackApiBase,
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok) fail(explain(res.status, res.json, product), res.status === 401 ? 3 : 1);
|
|
154
|
+
if (flags.json) { out(JSON.stringify(res.json, null, 2)); return; }
|
|
155
|
+
const d = res.json || {};
|
|
156
|
+
const s = d.summary || {};
|
|
157
|
+
out(`\nŁączny metraż: ${s.total_area_m2} m² · pokoje: ${s.rooms} · aneks wydzielony: ${s.kitchen_open_area_m2} m²`);
|
|
158
|
+
for (const r of d.rooms || []) out(` • ${String(r.name).padEnd(26)} ${r.area_m2} m²`);
|
|
159
|
+
out('\nPakiety (brutto / netto):');
|
|
160
|
+
const fmt = (n) => new Intl.NumberFormat('pl-PL', { maximumFractionDigits: 0 }).format(Math.round(n));
|
|
161
|
+
for (const p of d.packages || []) out(` ▶ ${String(p.name).padEnd(12)} ${fmt(p.total_gross)} zł / ${fmt(p.total_net)} zł${p.is_lump_sum ? ' (ryczałt < 45 m²)' : ''}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function cmdSkill(pos, flags) {
|
|
165
|
+
if ((pos[0] || 'install') !== 'install') fail('Użycie: deertools skill install [produkt|all] [--dir <katalog skilli>]', 2);
|
|
166
|
+
const products = resolveProducts(pos[1]);
|
|
167
|
+
const dir = flags.dir || path.join(os.homedir(), '.gemini', 'config', 'skills');
|
|
168
|
+
let installed = 0;
|
|
169
|
+
for (const product of products) {
|
|
170
|
+
const r = await fetchText(`${product.site}/skill/SKILL.md`);
|
|
171
|
+
if (!r.ok) { err(`– ${product.name}: brak skilla (${product.site}/skill/SKILL.md → ${r.status})`); continue; }
|
|
172
|
+
const name = parseFrontmatterName(r.text) || `deer-${product.id}`;
|
|
173
|
+
const target = path.join(dir, name);
|
|
174
|
+
fs.mkdirSync(target, { recursive: true });
|
|
175
|
+
fs.writeFileSync(path.join(target, 'SKILL.md'), r.text);
|
|
176
|
+
out(`✓ ${product.name}: ${path.join(target, 'SKILL.md')}`);
|
|
177
|
+
installed += 1;
|
|
178
|
+
}
|
|
179
|
+
if (!installed) fail('Nie zainstalowano żadnego skilla.', 4);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const HELP = `
|
|
183
|
+
DeerTools CLI — logowanie z laptopa i dostęp agenta AI do API produktów DeerTools
|
|
184
|
+
|
|
185
|
+
Produkty: ${ALL_IDS.join(', ')}
|
|
186
|
+
|
|
187
|
+
deertools login <produkt|all> [--write] [--scope "read write"] [--no-open] [--name <komputer>]
|
|
188
|
+
Loguje przez przeglądarkę i zapisuje klucz API (zakres domyślnie tylko odczyt: read).
|
|
189
|
+
Klucz działa w Twoim imieniu, nie ma więcej uprawnień niż Ty, wygasa po 90 dniach.
|
|
190
|
+
deertools logout [produkt|all] Usuwa lokalny klucz.
|
|
191
|
+
deertools whoami [produkt|all] [--json] Status logowania, zakres, ważność.
|
|
192
|
+
deertools docs <produkt> [llms|openapi|skill] Dokumentacja dla agenta (publiczna).
|
|
193
|
+
deertools endpoints <produkt> [--json] Lista endpointów z zakresem (z openapi.json).
|
|
194
|
+
deertools api <produkt> <METODA> </ścieżka> [--query k=v] [--data JSON | --data-file plik]
|
|
195
|
+
Wywołuje API produktu z Twoim kluczem (tylko adres produktu, klucz nie trafia nigdzie indziej).
|
|
196
|
+
deertools estimate --file <rzut.pdf> [--kitchen 6] [--json] Wycena rzutu (Kosztorys).
|
|
197
|
+
deertools skill install [produkt|all] [--dir <katalog>] Instaluje skill agenta z produktu.
|
|
198
|
+
|
|
199
|
+
Zmienne środowiskowe (CI/agenty): DEERTOOLS_<PRODUKT>_KEY, DEERTOOLS_HOME.
|
|
200
|
+
Kody wyjścia: 0 ok · 1 błąd żądania · 2 błędne użycie · 3 brak/wygasłe logowanie · 4 brak dokumentacji lub adresu API.
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
module.exports = { cmdLogin, cmdLogout, cmdWhoami, cmdDocs, cmdEndpoints, cmdApi, cmdEstimate, cmdSkill, HELP, parseQuery };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function homeDir() {
|
|
7
|
+
return process.env.DEERTOOLS_HOME || path.join(os.homedir(), '.deertools');
|
|
8
|
+
}
|
|
9
|
+
function credentialsFile() {
|
|
10
|
+
return path.join(homeDir(), 'credentials.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readAll() {
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(fs.readFileSync(credentialsFile(), 'utf8'));
|
|
16
|
+
if (data && data.version === 2 && data.products && typeof data.products === 'object') return data;
|
|
17
|
+
} catch (_) { /* brak pliku lub stary format */ }
|
|
18
|
+
return { version: 2, products: {} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeAll(data) {
|
|
22
|
+
const dir = homeDir();
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
try { fs.chmodSync(dir, 0o700); } catch (_) { /* Windows */ }
|
|
25
|
+
const file = credentialsFile();
|
|
26
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
try { fs.chmodSync(file, 0o600); } catch (_) { /* Windows */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function saveProduct(productId, entry) {
|
|
33
|
+
const data = readAll();
|
|
34
|
+
data.products[productId] = entry;
|
|
35
|
+
writeAll(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function removeProduct(productId) {
|
|
39
|
+
const data = readAll();
|
|
40
|
+
const existed = Boolean(data.products[productId]);
|
|
41
|
+
delete data.products[productId];
|
|
42
|
+
writeAll(data);
|
|
43
|
+
return existed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Klucz z zmiennej środowiskowej ma pierwszeństwo (CI, agenty); zwraca też źródło. */
|
|
47
|
+
function getKey(product) {
|
|
48
|
+
const envName = `DEERTOOLS_${product.id.toUpperCase()}_KEY`;
|
|
49
|
+
const fromEnv = process.env[envName] || (process.env.DEER_API_KEY && process.env.DEER_API_KEY.startsWith(product.keyPrefix) ? process.env.DEER_API_KEY : '');
|
|
50
|
+
if (fromEnv) return { key: fromEnv, source: `env:${envName}`, entry: null };
|
|
51
|
+
const entry = readAll().products[product.id];
|
|
52
|
+
if (!entry || !entry.key) return null;
|
|
53
|
+
return { key: entry.key, source: 'file', entry };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function expiryInfo(entry) {
|
|
57
|
+
if (!entry || !entry.expires_at) return { expired: false, daysLeft: null };
|
|
58
|
+
const ms = Date.parse(entry.expires_at) - Date.now();
|
|
59
|
+
if (Number.isNaN(ms)) return { expired: false, daysLeft: null };
|
|
60
|
+
return { expired: ms <= 0, daysLeft: Math.floor(ms / 86400000) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mask(key) {
|
|
64
|
+
if (!key) return '';
|
|
65
|
+
return `${key.slice(0, 12)}…${key.slice(-4)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { homeDir, credentialsFile, readAll, saveProduct, removeProduct, getKey, expiryInfo, mask };
|
package/src/http.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const credentials = require('./credentials');
|
|
3
|
+
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 120000;
|
|
5
|
+
|
|
6
|
+
async function fetchText(url, { headers = {}, timeoutMs = 30000 } = {}) {
|
|
7
|
+
const ctrl = new AbortController();
|
|
8
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'deertools-cli', ...headers }, signal: ctrl.signal });
|
|
11
|
+
return { status: res.status, ok: res.ok, headers: res.headers, text: await res.text() };
|
|
12
|
+
} finally {
|
|
13
|
+
clearTimeout(t);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Wykrywa openapi.json z publicznego llms.txt produktu i czyta z niego adres bazowy API. */
|
|
18
|
+
async function discover(product) {
|
|
19
|
+
const result = { llmsTxt: null, openapiUrl: null, openapi: null, apiBase: product.fallbackApiBase };
|
|
20
|
+
try {
|
|
21
|
+
const llms = await fetchText(`${product.site}/llms.txt`);
|
|
22
|
+
if (llms.ok) {
|
|
23
|
+
result.llmsTxt = llms.text;
|
|
24
|
+
const m = llms.text.match(/https?:\/\/[^\s)>"'`]+openapi\.json/i);
|
|
25
|
+
if (m) result.openapiUrl = m[0];
|
|
26
|
+
}
|
|
27
|
+
} catch (_) { /* brak sieci — zostaje fallback */ }
|
|
28
|
+
if (result.openapiUrl) {
|
|
29
|
+
try {
|
|
30
|
+
const spec = await fetchText(result.openapiUrl);
|
|
31
|
+
if (spec.ok) {
|
|
32
|
+
result.openapi = JSON.parse(spec.text);
|
|
33
|
+
const server = result.openapi.servers && result.openapi.servers[0] && result.openapi.servers[0].url;
|
|
34
|
+
if (server) result.apiBase = server.replace(/\/+$/, '');
|
|
35
|
+
}
|
|
36
|
+
} catch (_) { /* uszkodzony openapi — zostaje fallback */ }
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensureRelativePath(p) {
|
|
42
|
+
if (typeof p !== 'string' || !p.startsWith('/') || p.startsWith('//') || /^\/[a-z][a-z0-9+.-]*:/i.test(p) || p.includes('..')) {
|
|
43
|
+
const err = new Error('Podaj ścieżkę względną zaczynającą się od „/”, np. /products. CLI nie wysyła klucza pod obce adresy.');
|
|
44
|
+
err.exitCode = 2;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function explain(status, json, product) {
|
|
51
|
+
const msg = json && json.error ? `: ${json.error}` : '';
|
|
52
|
+
if (status === 401) return `401 Brak dostępu${msg}. Zaloguj się: deertools login ${product.id}`;
|
|
53
|
+
if (status === 403) return `403 Za mały zakres klucza${msg}. Zaloguj się z zapisem: deertools login ${product.id} --write`;
|
|
54
|
+
if (status === 429) return `429 Przekroczono limit żądań${msg}.`;
|
|
55
|
+
return `${status}${msg}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function apiRequest(product, { method = 'GET', path, query, body, base }) {
|
|
59
|
+
const found = credentials.getKey(product);
|
|
60
|
+
if (!found) {
|
|
61
|
+
const err = new Error(`Nie jesteś zalogowany do produktu ${product.name}. Użyj: deertools login ${product.id}`);
|
|
62
|
+
err.exitCode = 3;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
const { expired } = credentials.expiryInfo(found.entry);
|
|
66
|
+
if (expired) {
|
|
67
|
+
const err = new Error(`Klucz do produktu ${product.name} wygasł. Zaloguj się ponownie: deertools login ${product.id}`);
|
|
68
|
+
err.exitCode = 3;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
ensureRelativePath(path);
|
|
72
|
+
const apiBase = base || (await discover(product)).apiBase;
|
|
73
|
+
if (!apiBase) {
|
|
74
|
+
const err = new Error(`Nie znam adresu API produktu ${product.name} (brak openapi.json w ${product.site}/llms.txt).`);
|
|
75
|
+
err.exitCode = 4;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
const url = new URL(apiBase + path);
|
|
79
|
+
for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
|
|
80
|
+
const headers = { 'x-api-key': found.key, Accept: 'application/json' };
|
|
81
|
+
let payload;
|
|
82
|
+
if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = typeof body === 'string' ? body : JSON.stringify(body); }
|
|
83
|
+
|
|
84
|
+
const ctrl = new AbortController();
|
|
85
|
+
const t = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(url, { method, headers, body: payload, signal: ctrl.signal });
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
let json = null;
|
|
90
|
+
try { json = JSON.parse(text); } catch (_) { /* nie-JSON */ }
|
|
91
|
+
return { status: res.status, ok: res.ok, json, text, retryAfter: res.headers.get('retry-after') };
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(t);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { fetchText, discover, ensureRelativePath, explain, apiRequest };
|
package/src/login.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { exec } = require('child_process');
|
|
6
|
+
const credentials = require('./credentials');
|
|
7
|
+
|
|
8
|
+
const CLIENT_ID = 'deertools-cli';
|
|
9
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
const VALID_SCOPES = ['read', 'write'];
|
|
11
|
+
|
|
12
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
13
|
+
|
|
14
|
+
/** PKCE (RFC 7636): verifier 43 znaki, challenge = BASE64URL(SHA256(verifier)). */
|
|
15
|
+
function createPkce(verifier = b64url(crypto.randomBytes(32))) {
|
|
16
|
+
return { verifier, challenge: b64url(crypto.createHash('sha256').update(verifier).digest()) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function escapeHtml(s) {
|
|
20
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeScope(input) {
|
|
24
|
+
const list = String(input || 'read').split(/[\s,]+/).filter(Boolean);
|
|
25
|
+
const bad = list.filter((s) => !VALID_SCOPES.includes(s));
|
|
26
|
+
if (bad.length) {
|
|
27
|
+
const err = new Error(`Nieznany zakres: ${bad.join(', ')}. Dozwolone: ${VALID_SCOPES.join(', ')}.`);
|
|
28
|
+
err.exitCode = 2;
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
const set = new Set(list.length ? list : ['read']);
|
|
32
|
+
if (set.has('write')) set.add('read'); // zapis zakłada odczyt
|
|
33
|
+
return VALID_SCOPES.filter((s) => set.has(s)).join(' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildAuthorizeUrl(authorizationEndpoint, { redirectUri, state, scope, clientName, challenge }) {
|
|
37
|
+
const u = new URL(authorizationEndpoint);
|
|
38
|
+
u.searchParams.set('client_id', CLIENT_ID);
|
|
39
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
40
|
+
u.searchParams.set('response_type', 'code');
|
|
41
|
+
u.searchParams.set('code_challenge', challenge);
|
|
42
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
43
|
+
u.searchParams.set('state', state);
|
|
44
|
+
u.searchParams.set('scope', scope);
|
|
45
|
+
u.searchParams.set('client_name', String(clientName).slice(0, 60));
|
|
46
|
+
return u.toString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function timingSafeEqualStr(a, b) {
|
|
50
|
+
const x = Buffer.from(String(a));
|
|
51
|
+
const y = Buffer.from(String(b));
|
|
52
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Czysta funkcja: ocenia parametry powrotu z przeglądarki (oczekujemy wyłącznie jednorazowego kodu). */
|
|
56
|
+
function evaluateCallback(query, { expectedState }) {
|
|
57
|
+
if (!query.state || !timingSafeEqualStr(query.state, expectedState)) {
|
|
58
|
+
return { ok: false, status: 400, message: 'Nieprawidłowy parametr state — logowanie przerwano ze względów bezpieczeństwa.' };
|
|
59
|
+
}
|
|
60
|
+
if (query.error) {
|
|
61
|
+
return { ok: false, status: 400, message: query.error === 'access_denied' ? 'Odmówiono dostępu w przeglądarce.' : `Błąd logowania: ${query.error}` };
|
|
62
|
+
}
|
|
63
|
+
if (!query.code || query.code.length < 16) {
|
|
64
|
+
return { ok: false, status: 400, message: 'Produkt nie zwrócił kodu autoryzacyjnego.' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, status: 200, code: query.code };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Wymiana kodu na klucz: bezpośrednie zapytanie CLI -> serwer (klucz nigdy nie przechodzi przez przeglądarkę). */
|
|
70
|
+
async function exchangeCode(product, tokenEndpoint, { code, verifier, redirectUri, fetchImpl = fetch }) {
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetchImpl(tokenEndpoint, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
76
|
+
body: JSON.stringify({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirectUri, client_id: CLIENT_ID }),
|
|
77
|
+
});
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new Error(`Nie udało się połączyć z serwerem produktu ${product.name}: ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
let json = null;
|
|
82
|
+
try { json = await res.json(); } catch (_) { /* nie-JSON */ }
|
|
83
|
+
if (!res.ok || !json || !json.access_token) {
|
|
84
|
+
throw new Error(`Wymiana kodu na klucz nie powiodła się (${res.status}${json && json.error ? `: ${json.error}` : ''}). Spróbuj zalogować się ponownie.`);
|
|
85
|
+
}
|
|
86
|
+
if (!String(json.access_token).startsWith(product.keyPrefix)) {
|
|
87
|
+
throw new Error(`Produkt zwrócił nieoczekiwany klucz (oczekiwano prefiksu ${product.keyPrefix}…).`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
key: json.access_token,
|
|
91
|
+
email: json.email || null,
|
|
92
|
+
scope: json.scope || 'read',
|
|
93
|
+
expires_at: json.expires_at || null,
|
|
94
|
+
logged_at: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Adresy OAuth z metadanych serwera (RFC 8414) opublikowanych przez produkt. */
|
|
99
|
+
async function discoverOAuth(product, fetchImpl = fetch) {
|
|
100
|
+
const url = `${product.site}/.well-known/oauth-authorization-server`;
|
|
101
|
+
let res;
|
|
102
|
+
try { res = await fetchImpl(url, { headers: { Accept: 'application/json', 'User-Agent': 'deertools-cli' } }); } catch (e) {
|
|
103
|
+
throw new Error(`Nie można pobrać metadanych OAuth produktu ${product.name} (${url}): ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
if (!res.ok) throw new Error(`Produkt ${product.name} nie opublikował jeszcze metadanych OAuth (${url} → ${res.status}). Logowanie z CLI nie jest tam dostępne.`);
|
|
106
|
+
let meta;
|
|
107
|
+
try { meta = JSON.parse(await res.text()); } catch (_) { throw new Error(`Metadane OAuth produktu ${product.name} nie są poprawnym JSON-em.`); }
|
|
108
|
+
if (!meta.authorization_endpoint || !meta.token_endpoint) throw new Error(`Metadane OAuth produktu ${product.name} nie zawierają authorization_endpoint / token_endpoint.`);
|
|
109
|
+
if (!(meta.code_challenge_methods_supported || []).includes('S256')) throw new Error(`Produkt ${product.name} nie obsługuje PKCE (S256) — odmawiam logowania.`);
|
|
110
|
+
for (const k of ['authorization_endpoint', 'token_endpoint']) {
|
|
111
|
+
if (new URL(meta[k]).protocol !== 'https:') throw new Error(`Adres ${k} produktu ${product.name} nie jest HTTPS.`);
|
|
112
|
+
}
|
|
113
|
+
return meta;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function page(title, body, ok) {
|
|
117
|
+
const color = ok ? '#10b981' : '#ef4444';
|
|
118
|
+
return `<!doctype html><html lang="pl"><meta charset="utf-8"><title>DeerTools CLI</title><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#09090b;color:#f4f4f5;text-align:center;padding:60px 20px"><div style="max-width:480px;margin:0 auto;background:#18181b;border:1px solid #27272a;border-radius:16px;padding:40px"><h1 style="color:${color};font-size:22px">${escapeHtml(title)}</h1><p style="color:#a1a1aa;line-height:1.5">${escapeHtml(body)}</p></div></body></html>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function openBrowser(url) {
|
|
122
|
+
const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
|
123
|
+
exec(cmd, () => { /* gdy się nie uda, użytkownik ma adres wypisany w terminalu */ });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Logowanie OAuth 2.1 dla aplikacji natywnej (RFC 8252): kod autoryzacyjny + PKCE, powrót na 127.0.0.1 (losowy port).
|
|
128
|
+
* opts.metadata pozwala podać adresy bez pobierania (testy). Zwraca wpis poświadczeń zapisany na dysku.
|
|
129
|
+
*/
|
|
130
|
+
async function login(product, opts = {}) {
|
|
131
|
+
const scope = normalizeScope(opts.scope);
|
|
132
|
+
const meta = opts.metadata || (await discoverOAuth(product, opts.fetchImpl));
|
|
133
|
+
const state = b64url(crypto.randomBytes(24));
|
|
134
|
+
const pkce = createPkce();
|
|
135
|
+
const clientName = opts.clientName || os.hostname();
|
|
136
|
+
const timeoutMs = opts.timeoutMs || LOGIN_TIMEOUT_MS;
|
|
137
|
+
const open = opts.openBrowser === undefined ? openBrowser : opts.openBrowser;
|
|
138
|
+
const log = opts.log || (() => {});
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
let finished = false;
|
|
142
|
+
let redirectUri = null;
|
|
143
|
+
|
|
144
|
+
const server = http.createServer(async (req, res) => {
|
|
145
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
146
|
+
if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return; }
|
|
147
|
+
const result = evaluateCallback(Object.fromEntries(url.searchParams.entries()), { expectedState: state });
|
|
148
|
+
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
res.writeHead(result.status, headers);
|
|
151
|
+
res.end(page('Nie udało się zalogować', result.message, false));
|
|
152
|
+
done(new Error(result.message));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const entry = await exchangeCode(product, meta.token_endpoint, { code: result.code, verifier: pkce.verifier, redirectUri, fetchImpl: opts.fetchImpl });
|
|
157
|
+
credentials.saveProduct(product.id, entry);
|
|
158
|
+
res.writeHead(200, headers);
|
|
159
|
+
res.end(page('Zalogowano', `Klucz do produktu ${product.name} został zapisany w terminalu. Możesz zamknąć to okno.`, true));
|
|
160
|
+
done(null, entry);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
res.writeHead(502, headers);
|
|
163
|
+
res.end(page('Nie udało się zalogować', e.message, false));
|
|
164
|
+
done(e);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const timer = setTimeout(() => done(new Error(`Upłynął czas na zalogowanie (${Math.round(timeoutMs / 60000) || '<1'} min). Spróbuj ponownie.`)), timeoutMs);
|
|
169
|
+
|
|
170
|
+
function done(err, entry) {
|
|
171
|
+
if (finished) return;
|
|
172
|
+
finished = true;
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
server.close();
|
|
175
|
+
if (err) reject(err); else resolve(entry);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
server.on('error', (e) => done(e));
|
|
179
|
+
server.listen(opts.port || 0, '127.0.0.1', () => {
|
|
180
|
+
redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
|
|
181
|
+
const url = buildAuthorizeUrl(meta.authorization_endpoint, { redirectUri, state, scope, clientName, challenge: pkce.challenge });
|
|
182
|
+
log(`Zakres: ${scope}. Otwieram przeglądarkę — zaloguj się w produkcie ${product.name} i kliknij „Zezwól na dostęp”.`);
|
|
183
|
+
log(`Jeśli przeglądarka się nie otworzy, wejdź na:\n${url}\n`);
|
|
184
|
+
if (open) open(url);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { CLIENT_ID, createPkce, normalizeScope, buildAuthorizeUrl, evaluateCallback, exchangeCode, discoverOAuth, login };
|
package/src/openapi.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const METHODS = ['get', 'put', 'post', 'patch', 'delete', 'head'];
|
|
3
|
+
|
|
4
|
+
function scopeFor(method, op) {
|
|
5
|
+
if (op && op['x-required-scope']) return op['x-required-scope'];
|
|
6
|
+
return ['get', 'head', 'options'].includes(method) ? 'read' : 'write';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function listOperations(spec) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const [p, item] of Object.entries((spec && spec.paths) || {})) {
|
|
12
|
+
for (const m of METHODS) {
|
|
13
|
+
if (!item[m]) continue;
|
|
14
|
+
out.push({ method: m.toUpperCase(), path: p, scope: scopeFor(m, item[m]), summary: item[m].summary || item[m].description || '' });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out.sort((a, b) => (a.path === b.path ? a.method.localeCompare(b.method) : a.path.localeCompare(b.path)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseFrontmatterName(text) {
|
|
21
|
+
const m = String(text).match(/^---\s*\n([\s\S]*?)\n---/);
|
|
22
|
+
if (!m) return null;
|
|
23
|
+
const n = m[1].match(/^name:\s*["']?([a-zA-Z0-9._-]+)["']?\s*$/m);
|
|
24
|
+
return n ? n[1] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { listOperations, scopeFor, parseFrontmatterName };
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Rejestr produktów DeerTools. Każdy produkt ma własny adres autoryzacji, własny prefiks klucza
|
|
4
|
+
* i własne API. Adres bazowy API i lista endpointów pochodzą z publicznego openapi.json produktu
|
|
5
|
+
* (wykrywanego z llms.txt), więc CLI nie zna endpointów na sztywno.
|
|
6
|
+
*/
|
|
7
|
+
const PRODUCTS = {
|
|
8
|
+
kosztorys: {
|
|
9
|
+
name: 'Kosztorys',
|
|
10
|
+
site: 'https://connect.deerdesign.pl/kosztorys',
|
|
11
|
+
authorizeUrl: 'https://connect.deerdesign.pl/kosztorys/oauth/authorize',
|
|
12
|
+
keyPrefix: 'deer_kosztorys_live_',
|
|
13
|
+
fallbackApiBase: 'https://vlemuxlubrowuuxpgllk.supabase.co/functions/v1',
|
|
14
|
+
},
|
|
15
|
+
katalog: {
|
|
16
|
+
name: 'Katalog Connect',
|
|
17
|
+
site: 'https://connect.deerdesign.pl/katalog',
|
|
18
|
+
authorizeUrl: 'https://connect.deerdesign.pl/katalog/oauth/authorize',
|
|
19
|
+
keyPrefix: 'deer_catalog_live_',
|
|
20
|
+
fallbackApiBase: null,
|
|
21
|
+
},
|
|
22
|
+
dostawy: {
|
|
23
|
+
name: 'Dostawy',
|
|
24
|
+
site: 'https://ship.deergroup.pl',
|
|
25
|
+
authorizeUrl: 'https://ship.deergroup.pl/oauth/authorize',
|
|
26
|
+
keyPrefix: 'deer_dostawy_live_',
|
|
27
|
+
fallbackApiBase: null,
|
|
28
|
+
},
|
|
29
|
+
supplier: {
|
|
30
|
+
name: 'Supplier Hub',
|
|
31
|
+
site: 'https://supplier.deergroup.pl',
|
|
32
|
+
authorizeUrl: 'https://supplier.deergroup.pl/oauth/authorize',
|
|
33
|
+
keyPrefix: 'deer_supplier_live_',
|
|
34
|
+
fallbackApiBase: null,
|
|
35
|
+
},
|
|
36
|
+
rendermind: {
|
|
37
|
+
name: 'RenderMind',
|
|
38
|
+
site: 'https://rendermind.deergroup.pl',
|
|
39
|
+
authorizeUrl: 'https://rendermind.deergroup.pl/oauth/authorize',
|
|
40
|
+
keyPrefix: 'deer_rendermind_live_',
|
|
41
|
+
fallbackApiBase: null,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ALL_IDS = Object.keys(PRODUCTS);
|
|
46
|
+
|
|
47
|
+
function getProduct(id) {
|
|
48
|
+
const key = String(id || '').toLowerCase();
|
|
49
|
+
if (!PRODUCTS[key]) {
|
|
50
|
+
const err = new Error(`Nieznany produkt „${id}”. Dostępne: ${ALL_IDS.join(', ')}.`);
|
|
51
|
+
err.exitCode = 2;
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
return { id: key, ...PRODUCTS[key] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveProducts(arg) {
|
|
58
|
+
if (!arg || arg === 'all') return ALL_IDS.map(getProduct);
|
|
59
|
+
return String(arg).split(',').map((s) => getProduct(s.trim()));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { PRODUCTS, ALL_IDS, getProduct, resolveProducts };
|