fidelizare-integrate 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/README.md +55 -0
- package/package.json +33 -0
- package/sample-pos/package.json +7 -0
- package/sample-pos/pos.js +36 -0
- package/sample-pos/products.js +16 -0
- package/sample-pos-php/composer.json +1 -0
- package/sample-pos-php/index.php +21 -0
- package/sample-pos-php/products.php +3 -0
- package/sample-pos-python/pos.py +20 -0
- package/sample-pos-python/products.py +3 -0
- package/sample-pos-python/requirements.txt +1 -0
- package/src/cli.js +188 -0
- package/src/core/agent-loop.js +150 -0
- package/src/core/apply.js +117 -0
- package/src/core/detect.js +70 -0
- package/src/core/propose.js +91 -0
- package/src/core/scan.js +50 -0
- package/src/core/snippets.js +187 -0
- package/src/core/vault.js +30 -0
- package/src/ui/anim.js +146 -0
- package/src/ui/ansi.js +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @fidelizare/integrate
|
|
2
|
+
|
|
3
|
+
Interactive CLI that integrates the Fidelizare loyalty API into a management /
|
|
4
|
+
POS codebase. It scans the project, finds where the register detects a scanned
|
|
5
|
+
code, and proposes the integration as a reviewed diff, applied only after you
|
|
6
|
+
confirm. Modeled on how mature setup wizards (PostHog) work: a deterministic
|
|
7
|
+
harness around an optional coding agent, with strict permissions.
|
|
8
|
+
|
|
9
|
+
## Run
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# visual demo on a bundled sample POS (no key, nothing real touched)
|
|
13
|
+
npx fidelizare-integrate --demo
|
|
14
|
+
|
|
15
|
+
# on a real project (deterministic, works offline)
|
|
16
|
+
cd /path/to/soft-de-gestiune
|
|
17
|
+
npx fidelizare-integrate
|
|
18
|
+
|
|
19
|
+
# agentic mode: a real tool-use coding agent (Opus 4.8 via OpenRouter)
|
|
20
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
21
|
+
npx fidelizare-integrate --agent
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Flags: `--demo`, `--dir <path>`, `--agent`, `--model <id>` (default `anthropic/claude-opus-4.8`), `--yes`, `--no-anim`.
|
|
25
|
+
|
|
26
|
+
## Two modes
|
|
27
|
+
|
|
28
|
+
- **Deterministic** (default, no key): detect stack -> scan -> propose a helper + the call at the fidelity-card branch -> diff + confirm -> apply. Verified across Node, PHP, Python.
|
|
29
|
+
- **Agentic** (`--agent`, OpenRouter): a real tool-use loop (`src/core/agent-loop.js`) where the model autonomously calls `list_files / read_file / grep / create_file / insert_code / check_syntax / finish`. Every write is gated by an opencode-style permission harness (`y` / `a`=always / `N`), cwd-jailed, secret-redacted, `.bak` backups.
|
|
30
|
+
|
|
31
|
+
## Tests
|
|
32
|
+
|
|
33
|
+
`npm test` runs the suite (`node --test`): integration across Node/PHP/Python (with `node --check`, `php -l`, `python -m py_compile`), vault redaction, and the cwd-jail. All green.
|
|
34
|
+
|
|
35
|
+
## What it does
|
|
36
|
+
|
|
37
|
+
1. Hand-built ASCII intro + animations (logo sweep, codebase scan beam, spinners, progress) - all in `src/ui/anim.js`, zero animation libraries.
|
|
38
|
+
2. Takes the API key into a **secret vault** (`src/core/vault.js`): the raw key never enters a diff or the agent's context; it is written only to `.env` host-side.
|
|
39
|
+
3. **Detects the stack** (`src/core/detect.js`): C#/.NET, PHP, Delphi, Java, Python, Node.
|
|
40
|
+
4. **Scans** (`src/core/scan.js`) for the safest integration point: where a scanned code is handled / the sale is finalized / a fidelity card is detected.
|
|
41
|
+
5. **Proposes** a minimal change (`src/core/propose.js`, or `agent.js` with Opus): a helper file + the `acumuleazaPuncte(codCard, items, total)` call inserted exactly at the card-detection branch + the import.
|
|
42
|
+
6. Renders a **colored diff**, checks git state, asks to confirm.
|
|
43
|
+
7. **Applies safely** (`src/core/apply.js`): cwd-jailed, read-before-write, `.bak` backups, writes `.env`.
|
|
44
|
+
|
|
45
|
+
## Safety (what it can and cannot do)
|
|
46
|
+
|
|
47
|
+
- Only edits files inside the target project (cwd jail; refuses paths outside).
|
|
48
|
+
- Read-before-write; `.bak` backup per edited file; warns if git is dirty / not a repo.
|
|
49
|
+
- The API key is never written into source (verified) - only into `.env`.
|
|
50
|
+
- The generated helper catches API errors so a failed call **never blocks a sale**.
|
|
51
|
+
- Nothing is written until you confirm; `node --check` on the result passes (no broken code).
|
|
52
|
+
|
|
53
|
+
## Verified
|
|
54
|
+
|
|
55
|
+
`node src/cli.js --demo --no-anim --yes` produces: a `fidelizare.js` helper, the call inserted in the `isCardFidelitate` branch of `pos.js`, the import added, `.env` with the key (absent from source), both files pass `node --check`, and the integrated POS runs end to end with a graceful API failure.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fidelizare-integrate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Asistent de integrare Fidelizare pentru softuri de gestiune si case de marcat. Scaneaza codul, gaseste locul potrivit si propune integrarea API-ului, in siguranta.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fidelizare-integrate": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node src/cli.js",
|
|
14
|
+
"demo": "node src/cli.js --demo",
|
|
15
|
+
"test": "node --test tests/*.test.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"sample-pos",
|
|
20
|
+
"sample-pos-php",
|
|
21
|
+
"sample-pos-python",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"fidelizare",
|
|
26
|
+
"loyalty",
|
|
27
|
+
"pos",
|
|
28
|
+
"integration",
|
|
29
|
+
"casa-de-marcat",
|
|
30
|
+
"soft-de-gestiune"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Casa de marcat (demo). Scannerul apeleaza onScan() pentru fiecare cod citit.
|
|
2
|
+
import { isProduct, priceOf, nameOf } from './products.js';
|
|
3
|
+
|
|
4
|
+
const cart = [];
|
|
5
|
+
let total = 0;
|
|
6
|
+
|
|
7
|
+
// Apelat de scannerul de la casa pentru fiecare cod scanat.
|
|
8
|
+
export async function onScan(code) {
|
|
9
|
+
if (isProduct(code)) {
|
|
10
|
+
cart.push({ name: nameOf(code), qty: 1, total: priceOf(code) });
|
|
11
|
+
total += priceOf(code);
|
|
12
|
+
console.log('Produs adaugat:', nameOf(code), '->', total, 'lei');
|
|
13
|
+
} else if (isCardFidelitate(code)) {
|
|
14
|
+
// card de fidelitate detectat (nu este produs)
|
|
15
|
+
console.log('Card de fidelitate scanat:', code);
|
|
16
|
+
} else {
|
|
17
|
+
console.log('Cod necunoscut:', code);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Cardurile de fidelitate au 5 cifre si incep cu 1 (ex: 12333).
|
|
22
|
+
function isCardFidelitate(code) {
|
|
23
|
+
return /^1\d{4}$/.test(code);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function finalizeSale() {
|
|
27
|
+
printReceipt(cart, total);
|
|
28
|
+
cart.length = 0;
|
|
29
|
+
total = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function printReceipt(items, sum) {
|
|
33
|
+
console.log('--- BON ---');
|
|
34
|
+
for (const it of items) console.log(it.name, it.total);
|
|
35
|
+
console.log('TOTAL', sum, 'lei');
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Catalog minimal de produse pentru demo.
|
|
2
|
+
const CATALOG = {
|
|
3
|
+
'5941234567890': { name: 'Cafea', price: 9.0 },
|
|
4
|
+
'5941000000011': { name: 'Apa plata', price: 6.0 },
|
|
5
|
+
'2000000012456': { name: 'Paine', price: 4.5 },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function isProduct(code) {
|
|
9
|
+
return Object.prototype.hasOwnProperty.call(CATALOG, code);
|
|
10
|
+
}
|
|
11
|
+
export function priceOf(code) {
|
|
12
|
+
return CATALOG[code]?.price ?? 0;
|
|
13
|
+
}
|
|
14
|
+
export function nameOf(code) {
|
|
15
|
+
return CATALOG[code]?.name ?? code;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "casa-demo-php", "description": "Casa de marcat demo PHP", "require": {} }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
require_once __DIR__ . '/products.php';
|
|
3
|
+
|
|
4
|
+
$cart = [];
|
|
5
|
+
$total = 0.0;
|
|
6
|
+
|
|
7
|
+
// Apelat de scannerul de la casa pentru fiecare cod scanat.
|
|
8
|
+
function onScan($code) {
|
|
9
|
+
global $cart, $total;
|
|
10
|
+
if (isProduct($code)) {
|
|
11
|
+
$cart[] = $code;
|
|
12
|
+
$total += priceOf($code);
|
|
13
|
+
} elseif (isCardFidelitate($code)) {
|
|
14
|
+
// card de fidelitate detectat (nu este produs)
|
|
15
|
+
echo "Card de fidelitate: $code\n";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isCardFidelitate($code) {
|
|
20
|
+
return preg_match('/^1\d{4}$/', $code) === 1;
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from products import is_product, price_of
|
|
3
|
+
|
|
4
|
+
cart = []
|
|
5
|
+
total = 0.0
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Apelat de scannerul de la casa pentru fiecare cod scanat.
|
|
9
|
+
def on_scan(code):
|
|
10
|
+
global total
|
|
11
|
+
if is_product(code):
|
|
12
|
+
cart.append(code)
|
|
13
|
+
total += price_of(code)
|
|
14
|
+
elif is_card_fidelitate(code):
|
|
15
|
+
# card de fidelitate detectat (nu este produs)
|
|
16
|
+
print("Card de fidelitate:", code)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_card_fidelitate(code):
|
|
20
|
+
return re.match(r"^1\d{4}$", code) is not None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# casa de marcat demo (python) - fara dependinte externe
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @fidelizare/integrate — asistent interactiv de integrare a API-ului
|
|
3
|
+
// Fidelizare in softuri de gestiune si case de marcat. Scaneaza codul, gaseste
|
|
4
|
+
// locul potrivit, propune modificarea si o aplica DOAR dupa confirmare.
|
|
5
|
+
import { mkdtempSync, cpSync, writeFileSync, appendFileSync, existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { join, resolve } from 'path';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import readline from 'node:readline/promises';
|
|
10
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
11
|
+
|
|
12
|
+
import { ansi, C, paint, bold, out, sleep } from './ui/ansi.js';
|
|
13
|
+
import { logoIntro, withSpinner, scanSweep, heading, note, rule, setAnim } from './ui/anim.js';
|
|
14
|
+
import { detectStack, listSourceFiles } from './core/detect.js';
|
|
15
|
+
import { scanForIntegrationPoints } from './core/scan.js';
|
|
16
|
+
import { proposeChange } from './core/propose.js';
|
|
17
|
+
import { runAgentLoop } from './core/agent-loop.js';
|
|
18
|
+
import { renderPlan, applyPlan, gitState, undoHint } from './core/apply.js';
|
|
19
|
+
import { vaultPut, vaultResolve } from './core/vault.js';
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const has = (f) => args.includes(f);
|
|
23
|
+
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
|
|
24
|
+
if (has('--no-anim')) setAnim(false);
|
|
25
|
+
const DEMO = has('--demo');
|
|
26
|
+
const AUTO = has('--yes') || has('-y');
|
|
27
|
+
const HERE = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
await logoIntro();
|
|
31
|
+
|
|
32
|
+
// 1. Target directory
|
|
33
|
+
let root;
|
|
34
|
+
if (DEMO) {
|
|
35
|
+
const tmp = mkdtempSync(join(tmpdir(), 'fidelizare-demo-'));
|
|
36
|
+
cpSync(join(HERE, 'sample-pos'), tmp, { recursive: true });
|
|
37
|
+
root = tmp;
|
|
38
|
+
note('Mod demo: proiect de test copiat in ' + paint(root, C.white), C.ink);
|
|
39
|
+
} else {
|
|
40
|
+
root = resolve(val('--dir', process.cwd()));
|
|
41
|
+
note('Proiect: ' + paint(root, C.white), C.ink);
|
|
42
|
+
}
|
|
43
|
+
rule();
|
|
44
|
+
|
|
45
|
+
const rl = readline.createInterface({ input, output });
|
|
46
|
+
const ask = async (q) => (await rl.question(' ' + paint(q, C.white) + ' ')).trim();
|
|
47
|
+
|
|
48
|
+
// 2. API key into the vault (never written to diffs/agent context)
|
|
49
|
+
heading('Autentificare');
|
|
50
|
+
let rawKey;
|
|
51
|
+
if (DEMO) {
|
|
52
|
+
rawKey = 's2s_beta_demo_0000000000000000000000000000';
|
|
53
|
+
note('Cheie API demo incarcata in seif (nu apare niciodata in cod sau in agent).', C.ink);
|
|
54
|
+
} else {
|
|
55
|
+
rawKey = await ask('Lipeste cheia ta API Fidelizare:');
|
|
56
|
+
if (!rawKey) { note('Fara cheie API. Inchidem.', C.amber); rl.close(); return; }
|
|
57
|
+
}
|
|
58
|
+
const keyRef = vaultPut(rawKey, 'Fidelizare API key');
|
|
59
|
+
note('Seif: ' + paint(keyRef, C.green) + ' (valoarea reala ramane doar local)', C.dim);
|
|
60
|
+
rule();
|
|
61
|
+
|
|
62
|
+
// 3. Detect stack
|
|
63
|
+
heading('Analiza codului');
|
|
64
|
+
const det = await withSpinner('Detectez limbajul si structura proiectului', async () => {
|
|
65
|
+
await sleep(500);
|
|
66
|
+
return detectStack(root);
|
|
67
|
+
});
|
|
68
|
+
note('Limbaj: ' + bold(paint(det.label, C.red)) + paint(' (' + (det.via || '?') + ')', C.dim));
|
|
69
|
+
|
|
70
|
+
// 4. Scan for integration points
|
|
71
|
+
await scanSweep('Caut unde se citeste codul scanat si se finalizeaza bonul…', 1700);
|
|
72
|
+
const files = listSourceFiles(root);
|
|
73
|
+
const candidates = await withSpinner('Evaluez cele mai sigure locuri de integrare', async () => {
|
|
74
|
+
await sleep(400);
|
|
75
|
+
return scanForIntegrationPoints(files, root);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (candidates.length === 0) {
|
|
79
|
+
note('Nu am gasit un punct clar de integrare. Ruleaza cu --dir spre folderul softului.', C.amber);
|
|
80
|
+
rl.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const top = candidates[0];
|
|
84
|
+
note('Punct gasit: ' + bold(paint(top.rel, C.white)) +
|
|
85
|
+
paint(' linia ' + top.topLine.line + ' (' + top.topLine.reasons.join(', ') + ')', C.dim));
|
|
86
|
+
rule();
|
|
87
|
+
|
|
88
|
+
const git = gitState(root);
|
|
89
|
+
|
|
90
|
+
// 5a. Agentic tool-loop (real coding agent): the model uses tools and
|
|
91
|
+
// applies the integration itself, each write gated by confirmation.
|
|
92
|
+
if (has('--agent') && (process.env.OPENROUTER_API_KEY || process.env.FIDELIZARE_LLM_KEY)) {
|
|
93
|
+
heading('Agent de integrare');
|
|
94
|
+
if (!git.isRepo) note('• Nu e repo git. Fac copii .bak la fiecare modificare.', C.amber);
|
|
95
|
+
else if (!git.clean) note('• Repo git cu modificari necomise (le poti anula cu git checkout).', C.amber);
|
|
96
|
+
else note('• Repo git curat. Orice modificare e reversibila.', C.green);
|
|
97
|
+
note('• Agent: ' + bold(paint(val('--model') || 'Opus 4.8', C.red)) +
|
|
98
|
+
paint(' tool-uri: list/read/grep/create/insert/check. Fiecare scriere cere confirmare.', C.dim));
|
|
99
|
+
out('\n');
|
|
100
|
+
const r = await runAgentLoop({
|
|
101
|
+
stack: det.stack, rootDir: root, files,
|
|
102
|
+
askRaw: ask, auto: AUTO, model: val('--model'), log: out,
|
|
103
|
+
});
|
|
104
|
+
if (r.ok) {
|
|
105
|
+
writeEnv(root, vaultResolve(keyRef));
|
|
106
|
+
rule(); heading('Gata');
|
|
107
|
+
note('Rezumat agent: ' + r.summary, C.white);
|
|
108
|
+
note('Fisiere atinse: ' + (r.changes.join(', ') || '—'), C.ink);
|
|
109
|
+
note('Configuratie: .env (cheia API, nu in cod). Anulare: fisiere .bak / git checkout.', C.dim);
|
|
110
|
+
note('Test: scaneaza un cod de fidelitate (ex 12333) si verifica punctele.', C.ink);
|
|
111
|
+
out('\n ' + paint('Integrare pregatita.', C.green) + ' ' + paint('Fidelizare', C.red) + '\n\n');
|
|
112
|
+
rl.close();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
note('Agent indisponibil (' + r.reason + '). Trec la propunerea determinista.', C.amber);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 5b. Deterministic proposer (fallback / no key).
|
|
119
|
+
heading('Propunere de integrare');
|
|
120
|
+
let plan = null;
|
|
121
|
+
if (!plan) {
|
|
122
|
+
plan = await withSpinner('Construiesc modificarea minima si sigura', async () => {
|
|
123
|
+
await sleep(500);
|
|
124
|
+
return proposeChange({ stack: det.stack, candidate: top, rootDir: root });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!plan) {
|
|
128
|
+
note('Nu pot genera o propunere pentru acest limbaj inca: ' + det.label, C.amber);
|
|
129
|
+
rl.close();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
note(plan.summary, C.ink);
|
|
133
|
+
renderPlan(plan, root);
|
|
134
|
+
rule();
|
|
135
|
+
|
|
136
|
+
// 6. Safety: git state + confirmation (git computed above)
|
|
137
|
+
heading('Siguranta');
|
|
138
|
+
if (!git.isRepo) note('• Nu e repo git. Vom face copii .bak pentru fiecare fisier modificat.', C.amber);
|
|
139
|
+
else if (!git.clean) note('• Repo git cu modificari necomise. Recomandat: commit inainte (le poti anula cu git checkout).', C.amber);
|
|
140
|
+
else note('• Repo git curat. Orice modificare poate fi anulata cu git checkout.', C.green);
|
|
141
|
+
note('• Modific doar fisiere din proiect. Cheia API merge in .env, nu in cod.', C.green);
|
|
142
|
+
|
|
143
|
+
let go = AUTO;
|
|
144
|
+
if (!AUTO) {
|
|
145
|
+
const a = (await ask('\n Aplic modificarile? [y/N]')).toLowerCase();
|
|
146
|
+
go = a === 'y' || a === 'yes' || a === 'da' || a === 'd';
|
|
147
|
+
}
|
|
148
|
+
if (!go) { note('Anulat. Nimic nu a fost scris.', C.amber); rl.close(); return; }
|
|
149
|
+
|
|
150
|
+
// 7. Apply + write .env (secret resolved host-side from the vault)
|
|
151
|
+
const res = await withSpinner('Aplic modificarile si scriu configuratia', async () => {
|
|
152
|
+
await sleep(500);
|
|
153
|
+
const r = applyPlan(plan, root);
|
|
154
|
+
const envPath = join(root, '.env');
|
|
155
|
+
const keyVal = vaultResolve(keyRef);
|
|
156
|
+
const block = `\n# Fidelizare\nFIDELIZARE_API_KEY=${keyVal}\nFIDELIZARE_STORE_ID=MAGAZINUL-TAU\nFIDELIZARE_POS_ID=CASA-1\n`;
|
|
157
|
+
if (!existsSync(envPath) || !readFileSync(envPath, 'utf8').includes('FIDELIZARE_API_KEY')) {
|
|
158
|
+
appendFileSync(envPath, block);
|
|
159
|
+
}
|
|
160
|
+
r.written.push(envPath);
|
|
161
|
+
return r;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
rule();
|
|
165
|
+
heading('Gata');
|
|
166
|
+
note('Fisiere scrise:', C.white);
|
|
167
|
+
for (const w of res.written) out(' ' + paint('•', C.green) + ' ' + paint(w.replace(root, '.'), C.ink) + '\n');
|
|
168
|
+
out('\n');
|
|
169
|
+
undoHint(root, res);
|
|
170
|
+
note('Test: ruleaza un cod de fidelitate (ex 12333) prin scanner si verifica punctele in panoul tau.', C.ink);
|
|
171
|
+
note('Documentatie completa: cere acces pe fidelizare.ro/integrare', C.dim);
|
|
172
|
+
out('\n ' + paint('Integrare pregatita.', C.green) + ' ' + paint('Fidelizare', C.red) + '\n\n');
|
|
173
|
+
rl.close();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Write the Fidelizare config block to .env (idempotent). The key comes from
|
|
177
|
+
// the host-side vault, never from a diff or the model.
|
|
178
|
+
function writeEnv(root, keyVal) {
|
|
179
|
+
const envPath = join(root, '.env');
|
|
180
|
+
if (existsSync(envPath) && readFileSync(envPath, 'utf8').includes('FIDELIZARE_API_KEY')) return;
|
|
181
|
+
appendFileSync(envPath, `\n# Fidelizare\nFIDELIZARE_API_KEY=${keyVal}\nFIDELIZARE_STORE_ID=MAGAZINUL-TAU\nFIDELIZARE_POS_ID=CASA-1\n`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
main().catch((e) => {
|
|
185
|
+
out('\n ' + paint('Eroare: ', C.redBright) + (e?.message || String(e)) + '\n');
|
|
186
|
+
out(ansi.showCursor);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// A real agentic tool-use loop (like a coding agent: opencode / Claude Code).
|
|
2
|
+
// The model autonomously calls tools to explore the repo and apply the
|
|
3
|
+
// integration. Every tool is gated by a permission harness:
|
|
4
|
+
// - reads are cwd-jailed and secret-redacted
|
|
5
|
+
// - writes go through a diff + confirmation (auto only with --yes), .bak backup
|
|
6
|
+
// - the only command it can run is a syntax check (no arbitrary shell)
|
|
7
|
+
// - a hard step cap prevents runaway
|
|
8
|
+
// Runs on OpenRouter (function-calling), default Opus 4.8.
|
|
9
|
+
import { readFileSync, existsSync } from 'fs';
|
|
10
|
+
import { relative, join, resolve, extname } from 'path';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import { redact } from './vault.js';
|
|
13
|
+
import { safeCreateFile, safeInsert, insideRoot } from './apply.js';
|
|
14
|
+
import { C, paint, out } from '../ui/ansi.js';
|
|
15
|
+
|
|
16
|
+
const URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
17
|
+
const DEFAULT_MODEL = 'anthropic/claude-opus-4.8';
|
|
18
|
+
const MAX_STEPS = 24;
|
|
19
|
+
|
|
20
|
+
const SYSTEM = `Esti un agent de integrare care lucreaza DIRECT in codul unui soft de
|
|
21
|
+
gestiune / casa de marcat, prin apeluri de tool. Scopul: integreaza API-ul
|
|
22
|
+
Fidelizare astfel incat, cand scannerul citeste un cod de fidelitate (nu un
|
|
23
|
+
produs), softul sa apeleze POST https://fidelizare.ro/api/integration/s2s/submitReceipt.
|
|
24
|
+
|
|
25
|
+
Lucreaza in pasi: foloseste tool-urile pentru a citi codul (list_files, read_file,
|
|
26
|
+
grep), apoi creeaza un fisier helper (create_file) care apeleaza API-ul folosind
|
|
27
|
+
cheia din variabila de mediu FIDELIZARE_API_KEY (NICIODATA cheie in cod), intr-un
|
|
28
|
+
try/catch ce nu blocheaza vanzarea; apoi insereaza apelul (insert_code) exact la
|
|
29
|
+
ramura unde se detecteaza cardul, plus importul. Verifica cu check_syntax. La final
|
|
30
|
+
apeleaza finish.
|
|
31
|
+
|
|
32
|
+
Reguli: modificari minime, fara refactor; pastreaza stilul; nu inventa cai de fisier.`;
|
|
33
|
+
|
|
34
|
+
const TOOLS = [
|
|
35
|
+
{ type: 'function', function: { name: 'list_files', description: 'Listeaza fisierele sursa din proiect.', parameters: { type: 'object', properties: {} } } },
|
|
36
|
+
{ type: 'function', function: { name: 'read_file', description: 'Citeste un fisier (cu numere de linie).', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
|
|
37
|
+
{ type: 'function', function: { name: 'grep', description: 'Cauta un pattern (regex) in tot codul.', parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } } },
|
|
38
|
+
{ type: 'function', function: { name: 'create_file', description: 'Creeaza un fisier nou (gated: confirmare + backup).', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } } },
|
|
39
|
+
{ type: 'function', function: { name: 'insert_code', description: 'Insereaza linii dupa atLine (gated: diff + confirmare + backup).', parameters: { type: 'object', properties: { path: { type: 'string' }, atLine: { type: 'number' }, lines: { type: 'array', items: { type: 'string' } }, reason: { type: 'string' } }, required: ['path', 'atLine', 'lines'] } } },
|
|
40
|
+
{ type: 'function', function: { name: 'check_syntax', description: 'Verifica sintaxa unui fisier (doar Node).', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },
|
|
41
|
+
{ type: 'function', function: { name: 'finish', description: 'Termina integrarea.', parameters: { type: 'object', properties: { summary: { type: 'string' } }, required: ['summary'] } } },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export async function runAgentLoop({ stack, rootDir, files, askRaw, auto, model, log }) {
|
|
45
|
+
const key = process.env.OPENROUTER_API_KEY || process.env.FIDELIZARE_LLM_KEY;
|
|
46
|
+
if (!key) return { ok: false, reason: 'no_key' };
|
|
47
|
+
const usedModel = model || DEFAULT_MODEL;
|
|
48
|
+
|
|
49
|
+
const rel = (p) => relative(rootDir, p);
|
|
50
|
+
const abs = (p) => resolve(join(rootDir, p));
|
|
51
|
+
const changes = [];
|
|
52
|
+
let finished = null;
|
|
53
|
+
|
|
54
|
+
// Permission harness (opencode-style): read/grep/list/check are auto-allowed;
|
|
55
|
+
// writes ask, with an "always" reply that approves that action for the run.
|
|
56
|
+
const policy = { create_file: 'ask', insert_code: 'ask' };
|
|
57
|
+
const permit = async (action, label) => {
|
|
58
|
+
if (auto || policy[action] === 'allow') return true;
|
|
59
|
+
const a = (await askRaw(label + ' [y=da / a=mereu / N=nu]')).toLowerCase();
|
|
60
|
+
if (a === 'a' || a === 'mereu' || a === 'always') { policy[action] = 'allow'; return true; }
|
|
61
|
+
return a === 'y' || a === 'yes' || a === 'da' || a === 'd';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const tools = {
|
|
65
|
+
list_files: () => files.map(rel).join('\n') || '(niciun fisier)',
|
|
66
|
+
read_file: ({ path }) => {
|
|
67
|
+
const a = abs(path);
|
|
68
|
+
if (!insideRoot(rootDir, a) || !existsSync(a)) return 'EROARE: fisier inexistent';
|
|
69
|
+
const body = redact(readFileSync(a, 'utf8')).split('\n').slice(0, 300);
|
|
70
|
+
return body.map((l, i) => `${i + 1}: ${l}`).join('\n');
|
|
71
|
+
},
|
|
72
|
+
grep: ({ pattern }) => {
|
|
73
|
+
let re; try { re = new RegExp(pattern, 'i'); } catch { return 'regex invalid'; }
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
let t; try { t = readFileSync(f, 'utf8'); } catch { continue; }
|
|
77
|
+
t.split('\n').forEach((l, i) => { if (re.test(l)) hits.push(`${rel(f)}:${i + 1}: ${redact(l.trim()).slice(0, 120)}`); });
|
|
78
|
+
if (hits.length > 60) break;
|
|
79
|
+
}
|
|
80
|
+
return hits.slice(0, 60).join('\n') || '(fara potriviri)';
|
|
81
|
+
},
|
|
82
|
+
create_file: async ({ path, content }) => {
|
|
83
|
+
const a = abs(path);
|
|
84
|
+
if (!insideRoot(rootDir, a)) return 'REFUZAT: in afara proiectului';
|
|
85
|
+
log('\n ' + paint('+ fisier nou ', C.green) + paint(rel(a), C.white) + '\n');
|
|
86
|
+
content.split('\n').slice(0, 12).forEach((l) => log(' ' + paint('+ ', C.green) + paint(redact(l), C.ink) + '\n'));
|
|
87
|
+
if (!(await permit('create_file', 'Creez acest fisier?'))) return 'REFUZAT de utilizator';
|
|
88
|
+
const r = safeCreateFile(rootDir, a, content);
|
|
89
|
+
changes.push(rel(a)); return 'OK creat (' + r.lines + ' linii)';
|
|
90
|
+
},
|
|
91
|
+
insert_code: async ({ path, atLine, lines, reason }) => {
|
|
92
|
+
const a = abs(path);
|
|
93
|
+
if (!insideRoot(rootDir, a) || !existsSync(a)) return 'REFUZAT: fisier inexistent';
|
|
94
|
+
const src = readFileSync(a, 'utf8').split('\n');
|
|
95
|
+
log('\n ' + paint('✎ ' + rel(a) + ' @ linia ' + atLine, C.amber) + (reason ? paint(' ' + reason, C.dim) : '') + '\n');
|
|
96
|
+
for (let i = Math.max(0, atLine - 2); i < atLine; i++) log(' ' + paint(' ' + (src[i] ?? ''), C.dim) + '\n');
|
|
97
|
+
lines.forEach((l) => log(' ' + paint('+ ' + l, C.green) + '\n'));
|
|
98
|
+
if (!(await permit('insert_code', 'Aplic aceasta modificare?'))) return 'REFUZAT de utilizator';
|
|
99
|
+
const r = safeInsert(rootDir, a, atLine, lines);
|
|
100
|
+
changes.push(rel(a)); return 'OK inserat ' + r.added + ' linii dupa ' + r.atLine;
|
|
101
|
+
},
|
|
102
|
+
check_syntax: ({ path }) => {
|
|
103
|
+
const a = abs(path);
|
|
104
|
+
if (!existsSync(a)) return 'fisier inexistent';
|
|
105
|
+
if (!['.js', '.mjs', '.cjs'].includes(extname(a))) return 'omis (verificare doar pentru Node)';
|
|
106
|
+
try { execFileSync('node', ['--check', a], { stdio: 'pipe' }); return 'OK sintaxa valida'; }
|
|
107
|
+
catch (e) { return 'EROARE sintaxa: ' + (e.stderr?.toString() || e.message).slice(0, 300); }
|
|
108
|
+
},
|
|
109
|
+
finish: ({ summary }) => { finished = summary || 'gata'; return 'gata'; },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const messages = [
|
|
113
|
+
{ role: 'system', content: SYSTEM },
|
|
114
|
+
{ role: 'user', content: `Stack: ${stack}. Proiect cu ${files.length} fisiere sursa. Integreaza API-ul Fidelizare. Incepe prin a te orienta in cod.` },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
for (let step = 0; step < MAX_STEPS && !finished; step++) {
|
|
118
|
+
let data;
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(URL, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json', 'X-Title': 'Fidelizare Integrate' },
|
|
123
|
+
body: JSON.stringify({ model: usedModel, temperature: 0, tools: TOOLS, messages }),
|
|
124
|
+
signal: AbortSignal.timeout(90_000),
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok) return { ok: false, reason: 'http_' + res.status };
|
|
127
|
+
data = await res.json();
|
|
128
|
+
} catch (e) { return { ok: false, reason: 'fetch:' + (e?.message || e) }; }
|
|
129
|
+
|
|
130
|
+
const msg = data.choices?.[0]?.message;
|
|
131
|
+
if (!msg) return { ok: false, reason: 'no_message' };
|
|
132
|
+
messages.push(msg);
|
|
133
|
+
const calls = msg.tool_calls || [];
|
|
134
|
+
if (calls.length === 0) {
|
|
135
|
+
// model spoke without a tool call; nudge once, else stop
|
|
136
|
+
if (step > 0 && !finished) break;
|
|
137
|
+
messages.push({ role: 'user', content: 'Continua folosind tool-urile. Cand ai terminat, apeleaza finish.' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
for (const call of calls) {
|
|
141
|
+
let arg = {};
|
|
142
|
+
try { arg = JSON.parse(call.function.arguments || '{}'); } catch { /* */ }
|
|
143
|
+
const fn = tools[call.function.name];
|
|
144
|
+
const result = fn ? await fn(arg) : 'tool necunoscut';
|
|
145
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: String(result) });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { ok: true, changes: [...new Set(changes)], summary: finished || 'oprit', model: usedModel };
|
|
150
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Safe application of a change plan. Guarantees:
|
|
2
|
+
// - cwd jail: refuses to touch anything outside the target root.
|
|
3
|
+
// - read-before-write: edits re-read the file immediately before writing.
|
|
4
|
+
// - reversible: a .bak copy is written for every edited file; new files are
|
|
5
|
+
// listed so they can be removed.
|
|
6
|
+
// - dry-run by default at the plan level; nothing is written without confirm.
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'fs';
|
|
8
|
+
import { resolve, relative, dirname } from 'path';
|
|
9
|
+
import { execFileSync } from 'child_process';
|
|
10
|
+
import { ansi, C, paint, out } from '../ui/ansi.js';
|
|
11
|
+
|
|
12
|
+
export function gitState(dir) {
|
|
13
|
+
try {
|
|
14
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, stdio: 'ignore' });
|
|
15
|
+
} catch {
|
|
16
|
+
return { isRepo: false, clean: false };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const o = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString();
|
|
20
|
+
return { isRepo: true, clean: o.trim().length === 0 };
|
|
21
|
+
} catch {
|
|
22
|
+
return { isRepo: true, clean: false };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function insideRoot(root, p) {
|
|
27
|
+
const rel = relative(resolve(root), resolve(p));
|
|
28
|
+
return rel && !rel.startsWith('..') && !resolve(p).includes('\0');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Render a colored preview of the plan. No writes.
|
|
32
|
+
export function renderPlan(plan, rootDir) {
|
|
33
|
+
for (const nf of plan.newFiles) {
|
|
34
|
+
out('\n ' + paint('+ fisier nou ', C.green) + paint(relative(rootDir, nf.path), C.white) + '\n');
|
|
35
|
+
const lines = nf.content.split('\n').slice(0, 14);
|
|
36
|
+
for (const l of lines) out(' ' + paint('+ ', C.green) + paint(l, C.ink) + '\n');
|
|
37
|
+
if (nf.content.split('\n').length > 14) out(' ' + paint(' …', C.dim) + '\n');
|
|
38
|
+
}
|
|
39
|
+
// group edits by file
|
|
40
|
+
const byFile = new Map();
|
|
41
|
+
for (const e of plan.edits) {
|
|
42
|
+
if (!byFile.has(e.path)) byFile.set(e.path, []);
|
|
43
|
+
byFile.get(e.path).push(e);
|
|
44
|
+
}
|
|
45
|
+
for (const [file, edits] of byFile) {
|
|
46
|
+
out('\n ' + paint('✎ modific ', C.amber) + paint(relative(rootDir, file), C.white) + '\n');
|
|
47
|
+
const src = readFileSync(file, 'utf8').split('\n');
|
|
48
|
+
for (const e of edits.sort((a, b) => a.atLine - b.atLine)) {
|
|
49
|
+
const ctx = Math.max(0, e.atLine - 2);
|
|
50
|
+
out(' ' + paint(`@@ linia ${e.atLine} ${e.reason}`, C.dim) + '\n');
|
|
51
|
+
for (let i = ctx; i < e.atLine; i++) out(' ' + paint(' ' + (src[i] ?? ''), C.dim) + '\n');
|
|
52
|
+
for (const ins of e.insert) out(' ' + paint('+ ' + ins, C.green) + '\n');
|
|
53
|
+
if (src[e.atLine] !== undefined) out(' ' + paint(' ' + src[e.atLine], C.dim) + '\n');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Apply the plan. Returns a summary of what was written + how to undo.
|
|
59
|
+
export function applyPlan(plan, rootDir) {
|
|
60
|
+
const written = [];
|
|
61
|
+
const backups = [];
|
|
62
|
+
|
|
63
|
+
for (const nf of plan.newFiles) {
|
|
64
|
+
if (!insideRoot(rootDir, nf.path)) throw new Error('refuz scriere in afara proiectului: ' + nf.path);
|
|
65
|
+
if (existsSync(nf.path)) {
|
|
66
|
+
copyFileSync(nf.path, nf.path + '.bak');
|
|
67
|
+
backups.push(nf.path + '.bak');
|
|
68
|
+
}
|
|
69
|
+
writeFileSync(nf.path, nf.content, 'utf8');
|
|
70
|
+
written.push(nf.path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// group edits per file, apply bottom-up so line numbers stay valid
|
|
74
|
+
const byFile = new Map();
|
|
75
|
+
for (const e of plan.edits) {
|
|
76
|
+
if (!insideRoot(rootDir, e.path)) throw new Error('refuz scriere in afara proiectului: ' + e.path);
|
|
77
|
+
if (!byFile.has(e.path)) byFile.set(e.path, []);
|
|
78
|
+
byFile.get(e.path).push(e);
|
|
79
|
+
}
|
|
80
|
+
for (const [file, edits] of byFile) {
|
|
81
|
+
// read-before-write
|
|
82
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
83
|
+
copyFileSync(file, file + '.bak');
|
|
84
|
+
backups.push(file + '.bak');
|
|
85
|
+
for (const e of edits.sort((a, b) => b.atLine - a.atLine)) {
|
|
86
|
+
lines.splice(e.atLine, 0, ...e.insert);
|
|
87
|
+
}
|
|
88
|
+
writeFileSync(file, lines.join('\n'), 'utf8');
|
|
89
|
+
written.push(file);
|
|
90
|
+
}
|
|
91
|
+
return { written, backups };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Granular, gated primitives used by the agent tool-loop. Each is
|
|
95
|
+
// cwd-jailed, read-before-write, and leaves a .bak. ---
|
|
96
|
+
export function safeCreateFile(rootDir, absPath, content) {
|
|
97
|
+
if (!insideRoot(rootDir, absPath)) throw new Error('in afara proiectului');
|
|
98
|
+
if (existsSync(absPath)) { copyFileSync(absPath, absPath + '.bak'); }
|
|
99
|
+
writeFileSync(absPath, content, 'utf8');
|
|
100
|
+
return { path: absPath, lines: content.split('\n').length };
|
|
101
|
+
}
|
|
102
|
+
export function safeInsert(rootDir, absPath, atLine, insertLines) {
|
|
103
|
+
if (!insideRoot(rootDir, absPath)) throw new Error('in afara proiectului');
|
|
104
|
+
if (!existsSync(absPath)) throw new Error('fisier inexistent');
|
|
105
|
+
const lines = readFileSync(absPath, 'utf8').split('\n'); // read-before-write
|
|
106
|
+
copyFileSync(absPath, absPath + '.bak');
|
|
107
|
+
const at = Math.max(0, Math.min(atLine, lines.length));
|
|
108
|
+
lines.splice(at, 0, ...insertLines);
|
|
109
|
+
writeFileSync(absPath, lines.join('\n'), 'utf8');
|
|
110
|
+
return { path: absPath, atLine: at, added: insertLines.length };
|
|
111
|
+
}
|
|
112
|
+
export { insideRoot };
|
|
113
|
+
|
|
114
|
+
export function undoHint(rootDir, result) {
|
|
115
|
+
out(' ' + paint('Anulare:', C.ink) + ' restaureaza din fisierele .bak sau ' + paint('git checkout .', C.white) + '\n');
|
|
116
|
+
void rootDir; void result;
|
|
117
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Detect the vendor's stack from manifest/extension signals. Read-only.
|
|
2
|
+
import { readdirSync, existsSync, statSync } from 'fs';
|
|
3
|
+
import { join, extname } from 'path';
|
|
4
|
+
|
|
5
|
+
const MANIFESTS = [
|
|
6
|
+
{ file: 'composer.json', stack: 'php', label: 'PHP' },
|
|
7
|
+
{ file: 'package.json', stack: 'node', label: 'Node.js' },
|
|
8
|
+
{ file: 'requirements.txt', stack: 'python', label: 'Python' },
|
|
9
|
+
{ file: 'pyproject.toml', stack: 'python', label: 'Python' },
|
|
10
|
+
{ file: 'pom.xml', stack: 'java', label: 'Java' },
|
|
11
|
+
{ file: 'build.gradle', stack: 'java', label: 'Java' },
|
|
12
|
+
];
|
|
13
|
+
const EXT_STACK = {
|
|
14
|
+
'.cs': { stack: 'csharp', label: 'C# / .NET' },
|
|
15
|
+
'.csproj': { stack: 'csharp', label: 'C# / .NET' },
|
|
16
|
+
'.php': { stack: 'php', label: 'PHP' },
|
|
17
|
+
'.dpr': { stack: 'delphi', label: 'Delphi' },
|
|
18
|
+
'.dproj': { stack: 'delphi', label: 'Delphi' },
|
|
19
|
+
'.pas': { stack: 'delphi', label: 'Delphi' },
|
|
20
|
+
'.java': { stack: 'java', label: 'Java' },
|
|
21
|
+
'.py': { stack: 'python', label: 'Python' },
|
|
22
|
+
'.ts': { stack: 'node', label: 'Node.js' },
|
|
23
|
+
'.js': { stack: 'node', label: 'Node.js' },
|
|
24
|
+
};
|
|
25
|
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'bin', 'obj', 'vendor', '.next', '__pycache__']);
|
|
26
|
+
|
|
27
|
+
export function listSourceFiles(dir, max = 4000) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const walk = (d) => {
|
|
30
|
+
if (out.length >= max) return;
|
|
31
|
+
let entries;
|
|
32
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (out.length >= max) return;
|
|
35
|
+
if (e.name.startsWith('.') && e.name !== '.') continue;
|
|
36
|
+
if (IGNORE.has(e.name)) continue;
|
|
37
|
+
const p = join(d, e.name);
|
|
38
|
+
if (e.isDirectory()) walk(p);
|
|
39
|
+
else if (EXT_STACK[extname(e.name).toLowerCase()]) out.push(p);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
walk(dir);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function detectStack(dir) {
|
|
47
|
+
for (const m of MANIFESTS) {
|
|
48
|
+
if (existsSync(join(dir, m.file))) return { stack: m.stack, label: m.label, via: m.file };
|
|
49
|
+
}
|
|
50
|
+
// fall back to extension frequency
|
|
51
|
+
const files = listSourceFiles(dir, 2000);
|
|
52
|
+
const counts = {};
|
|
53
|
+
for (const f of files) {
|
|
54
|
+
const s = EXT_STACK[extname(f).toLowerCase()];
|
|
55
|
+
if (s) counts[s.stack] = (counts[s.stack] || 0) + 1;
|
|
56
|
+
}
|
|
57
|
+
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
58
|
+
if (!best) return { stack: 'unknown', label: 'necunoscut', via: null };
|
|
59
|
+
const label = Object.values(EXT_STACK).find((x) => x.stack === best[0])?.label || best[0];
|
|
60
|
+
return { stack: best[0], label, via: 'extensii (' + best[1] + ' fisiere)' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isGitClean(dir) {
|
|
64
|
+
// Best-effort: if not a git repo, treat as "unknown" (caller warns).
|
|
65
|
+
try {
|
|
66
|
+
const { execSync } = require('child_process');
|
|
67
|
+
void execSync;
|
|
68
|
+
} catch { /* esm: use dynamic */ }
|
|
69
|
+
return null; // resolved in cli via child_process
|
|
70
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Deterministic proposal: given the detected stack and the best candidate
|
|
2
|
+
// location, build a safe change plan (a new helper file + the inserted call +
|
|
3
|
+
// an import). Language-aware indentation and import anchoring so the result
|
|
4
|
+
// stays syntactically valid. Runs without an LLM; the agent mode (agent-loop)
|
|
5
|
+
// replaces it for arbitrary real codebases.
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { HELPERS } from './snippets.js';
|
|
9
|
+
|
|
10
|
+
export function proposeChange({ stack, candidate, rootDir }) {
|
|
11
|
+
const helper = HELPERS[stack];
|
|
12
|
+
if (!helper) return null;
|
|
13
|
+
|
|
14
|
+
const plan = { stack, newFiles: [], edits: [], summary: '' };
|
|
15
|
+
const targetDir = candidate ? dirname(candidate.file) : rootDir;
|
|
16
|
+
if (helper.content) {
|
|
17
|
+
plan.newFiles.push({ path: join(targetDir, helper.filename), content: helper.content });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (candidate) {
|
|
21
|
+
const lines = readFileSync(candidate.file, 'utf8').split('\n');
|
|
22
|
+
const at = findFidelityLine(lines) ?? candidate.topLine.line;
|
|
23
|
+
const bodyIndent = blockBodyIndent(lines, at);
|
|
24
|
+
const codVar = inferCodeVar(lines[at - 1], stack);
|
|
25
|
+
const cmt = stack === 'python' ? '#' : '//';
|
|
26
|
+
|
|
27
|
+
plan.edits.push({
|
|
28
|
+
path: candidate.file,
|
|
29
|
+
atLine: at,
|
|
30
|
+
insert: [
|
|
31
|
+
bodyIndent + cmt + ' Fidelizare: cod de fidelitate detectat -> acumuleaza puncte',
|
|
32
|
+
bodyIndent + helper.call(codVar),
|
|
33
|
+
],
|
|
34
|
+
reason: 'Apel API la detectarea cardului de fidelitate',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (helper.importLine) {
|
|
38
|
+
const anchor = importAnchor(lines, stack);
|
|
39
|
+
plan.edits.push({ path: candidate.file, atLine: anchor, insert: [helper.importLine], reason: 'Import helper Fidelizare' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
plan.summary = `${plan.newFiles.length} fisier nou + ${plan.edits.length} modificare in ` +
|
|
44
|
+
(candidate ? candidate.rel : 'proiect');
|
|
45
|
+
return plan;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The safest place to call the API is exactly where the code detects a
|
|
49
|
+
// fidelity card (a branch on it).
|
|
50
|
+
function findFidelityLine(lines) {
|
|
51
|
+
const FID = /(fidelit|fideliz|loyalty|loyaltycard|cardclient)/i;
|
|
52
|
+
const BRANCH = /\b(if|elif|else|when|case|switch)\b/i;
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
if (FID.test(lines[i]) && BRANCH.test(lines[i]) && !/^\s*(\/\/|#|\*)/.test(lines[i])) return i + 1;
|
|
55
|
+
}
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
if (FID.test(lines[i]) && !/^\s*(\/\/|#|\*)/.test(lines[i])) return i + 1;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Match the indentation of the block opened at `at` by looking at the next
|
|
63
|
+
// non-empty line that is more indented; else the line indent + one step.
|
|
64
|
+
function blockBodyIndent(lines, at) {
|
|
65
|
+
const own = lines[at - 1].match(/^\s*/)?.[0] || '';
|
|
66
|
+
for (let i = at; i < lines.length; i++) {
|
|
67
|
+
if (!lines[i].trim()) continue;
|
|
68
|
+
const ind = lines[i].match(/^\s*/)?.[0] || '';
|
|
69
|
+
if (ind.length > own.length) return ind;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
return own + (own.includes('\t') ? '\t' : ' ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inferCodeVar(line, stack) {
|
|
76
|
+
const matches = [...line.matchAll(/\(\s*(\$?[a-zA-Z_][\w$]*)\s*\)/g)];
|
|
77
|
+
if (matches.length) return matches[matches.length - 1][1];
|
|
78
|
+
return stack === 'php' ? '$code' : 'code';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function importAnchor(lines, stack) {
|
|
82
|
+
const IMP = /^\s*(import |from .+ import|require_once|require|use |#include|uses )/i;
|
|
83
|
+
let last = 0;
|
|
84
|
+
lines.forEach((l, i) => { if (IMP.test(l)) last = i + 1; });
|
|
85
|
+
if (last > 0) return last;
|
|
86
|
+
if (stack === 'php') {
|
|
87
|
+
const tag = lines.findIndex((l) => /<\?php/.test(l));
|
|
88
|
+
return tag >= 0 ? tag + 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
package/src/core/scan.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Find the most reliable place to call the Fidelizare API: where the POS
|
|
2
|
+
// handles a scanned code / finalizes a sale. Read-only, pattern + heuristic
|
|
3
|
+
// ranking. Returns ranked candidates with file, line, and why.
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { relative } from 'path';
|
|
6
|
+
|
|
7
|
+
// Signals that a line is near the scan / sale-finalize logic. Weighted.
|
|
8
|
+
const SIGNALS = [
|
|
9
|
+
{ re: /(on_?scan|scannerinput|barcodescanned|onbarcode|citirecod|codscanat|handlescan)/i, w: 10, why: 'handler de scanare' },
|
|
10
|
+
{ re: /(fidelit|fideliz|loyalty|loyaltycard|cardclient)/i, w: 9, why: 'card de fidelitate' },
|
|
11
|
+
{ re: /(barcod|scanner|scaner|ean13|cod_?bare|cod de bare)/i, w: 6, why: 'cod de bare / scanner' },
|
|
12
|
+
{ re: /(finalize|finalizeaz|inchidebon|closereceipt|emitebon|printreceipt|tipareste)/i, w: 7, why: 'finalizare bon' },
|
|
13
|
+
{ re: /(additem|adaugaprodus|scanitem|isproduct|esteprodus)/i, w: 4, why: 'adaugare produs' },
|
|
14
|
+
{ re: /(receipt|totalbon|coscumparaturi|\bcart\b)/i, w: 3, why: 'context bon' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function scanForIntegrationPoints(files, rootDir) {
|
|
18
|
+
const candidates = [];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
let text;
|
|
21
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
22
|
+
if (text.length > 600_000) continue; // skip huge/minified
|
|
23
|
+
const lines = text.split('\n');
|
|
24
|
+
let fileScore = 0;
|
|
25
|
+
const hits = [];
|
|
26
|
+
lines.forEach((line, idx) => {
|
|
27
|
+
let lineScore = 0;
|
|
28
|
+
const reasons = [];
|
|
29
|
+
for (const s of SIGNALS) {
|
|
30
|
+
if (s.re.test(line)) { lineScore += s.w; reasons.push(s.why); }
|
|
31
|
+
}
|
|
32
|
+
if (lineScore > 0) {
|
|
33
|
+
fileScore += lineScore;
|
|
34
|
+
hits.push({ line: idx + 1, text: line.trim().slice(0, 100), score: lineScore, reasons });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
if (fileScore > 0) {
|
|
38
|
+
hits.sort((a, b) => b.score - a.score);
|
|
39
|
+
candidates.push({
|
|
40
|
+
file,
|
|
41
|
+
rel: relative(rootDir, file),
|
|
42
|
+
score: fileScore,
|
|
43
|
+
topLine: hits[0],
|
|
44
|
+
hits: hits.slice(0, 4),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
49
|
+
return candidates;
|
|
50
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Canonical integration artifacts per stack: a helper file that wraps the
|
|
2
|
+
// Fidelizare API call, and the call inserted where the POS detects a fidelity
|
|
3
|
+
// card. The API key is referenced from the environment, never inlined.
|
|
4
|
+
|
|
5
|
+
export const HELPERS = {
|
|
6
|
+
node: {
|
|
7
|
+
filename: 'fidelizare.js',
|
|
8
|
+
importLine: `import { acumuleazaPuncte } from "./fidelizare.js";`,
|
|
9
|
+
call: (v) => `await acumuleazaPuncte(${v}, cart, total);`,
|
|
10
|
+
content: `// Generat de fidelizare-integrate. Cheia din mediu, niciodata in cod.
|
|
11
|
+
const FIDELIZARE_API = "https://fidelizare.ro/api/integration/s2s/submitReceipt";
|
|
12
|
+
|
|
13
|
+
export async function acumuleazaPuncte(codFidelitate, items, total) {
|
|
14
|
+
try {
|
|
15
|
+
const bon = "BF" + Date.now();
|
|
16
|
+
const res = await fetch(FIDELIZARE_API, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: "Bearer " + process.env.FIDELIZARE_API_KEY,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
"Idempotency-Key": "BON-" + bon,
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
store_id: process.env.FIDELIZARE_STORE_ID || "MAGAZINUL-TAU",
|
|
25
|
+
pos_id: process.env.FIDELIZARE_POS_ID || "CASA-1",
|
|
26
|
+
receipt_number: bon,
|
|
27
|
+
fidelity_card: codFidelitate,
|
|
28
|
+
total,
|
|
29
|
+
items,
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
return await res.json();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { success: false }; // nu blocheaza vanzarea
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
php: {
|
|
41
|
+
filename: 'Fidelizare.php',
|
|
42
|
+
importLine: `require_once __DIR__ . '/Fidelizare.php';`,
|
|
43
|
+
call: (v) => `Fidelizare::acumuleazaPuncte(${v}, $cart, $total);`,
|
|
44
|
+
content: `<?php
|
|
45
|
+
// Generat de fidelizare-integrate. Cheia din mediu, niciodata in cod.
|
|
46
|
+
class Fidelizare {
|
|
47
|
+
public static function acumuleazaPuncte($codFidelitate, $items, $total) {
|
|
48
|
+
try {
|
|
49
|
+
$bon = 'BF' . time();
|
|
50
|
+
$ch = curl_init('https://fidelizare.ro/api/integration/s2s/submitReceipt');
|
|
51
|
+
curl_setopt_array($ch, [
|
|
52
|
+
CURLOPT_POST => true,
|
|
53
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
54
|
+
CURLOPT_TIMEOUT => 3,
|
|
55
|
+
CURLOPT_HTTPHEADER => [
|
|
56
|
+
'Authorization: Bearer ' . getenv('FIDELIZARE_API_KEY'),
|
|
57
|
+
'Content-Type: application/json',
|
|
58
|
+
'Idempotency-Key: BON-' . $bon,
|
|
59
|
+
],
|
|
60
|
+
CURLOPT_POSTFIELDS => json_encode([
|
|
61
|
+
'store_id' => getenv('FIDELIZARE_STORE_ID') ?: 'MAGAZINUL-TAU',
|
|
62
|
+
'pos_id' => getenv('FIDELIZARE_POS_ID') ?: 'CASA-1',
|
|
63
|
+
'receipt_number' => $bon,
|
|
64
|
+
'fidelity_card' => $codFidelitate,
|
|
65
|
+
'total' => $total,
|
|
66
|
+
'items' => $items,
|
|
67
|
+
]),
|
|
68
|
+
]);
|
|
69
|
+
$res = curl_exec($ch);
|
|
70
|
+
curl_close($ch);
|
|
71
|
+
return json_decode($res, true);
|
|
72
|
+
} catch (\\Throwable $e) {
|
|
73
|
+
return ['success' => false]; // nu blocheaza vanzarea
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
python: {
|
|
81
|
+
filename: 'fidelizare.py',
|
|
82
|
+
importLine: `from fidelizare import acumuleaza_puncte`,
|
|
83
|
+
call: (v) => `acumuleaza_puncte(${v}, cart, total)`,
|
|
84
|
+
content: `# Generat de fidelizare-integrate. Cheia din mediu, niciodata in cod.
|
|
85
|
+
import os, time, json, urllib.request
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def acumuleaza_puncte(cod_fidelitate, items, total):
|
|
89
|
+
try:
|
|
90
|
+
bon = "BF" + str(int(time.time()))
|
|
91
|
+
body = json.dumps({
|
|
92
|
+
"store_id": os.environ.get("FIDELIZARE_STORE_ID", "MAGAZINUL-TAU"),
|
|
93
|
+
"pos_id": os.environ.get("FIDELIZARE_POS_ID", "CASA-1"),
|
|
94
|
+
"receipt_number": bon,
|
|
95
|
+
"fidelity_card": cod_fidelitate,
|
|
96
|
+
"total": total,
|
|
97
|
+
"items": items,
|
|
98
|
+
}).encode()
|
|
99
|
+
req = urllib.request.Request(
|
|
100
|
+
"https://fidelizare.ro/api/integration/s2s/submitReceipt",
|
|
101
|
+
data=body, method="POST",
|
|
102
|
+
headers={
|
|
103
|
+
"Authorization": "Bearer " + os.environ.get("FIDELIZARE_API_KEY", ""),
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
"Idempotency-Key": "BON-" + bon,
|
|
106
|
+
})
|
|
107
|
+
with urllib.request.urlopen(req, timeout=3) as r:
|
|
108
|
+
return json.loads(r.read())
|
|
109
|
+
except Exception:
|
|
110
|
+
return {"success": False} # nu blocheaza vanzarea
|
|
111
|
+
`,
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
csharp: {
|
|
115
|
+
filename: 'Fidelizare.cs',
|
|
116
|
+
call: (v) => `await Fidelizare.AcumuleazaPuncte(${v});`,
|
|
117
|
+
content: `// Generat de fidelizare-integrate. Cheia din mediu, niciodata in cod.
|
|
118
|
+
using System;
|
|
119
|
+
using System.Net.Http;
|
|
120
|
+
using System.Text;
|
|
121
|
+
using System.Threading.Tasks;
|
|
122
|
+
|
|
123
|
+
public static class Fidelizare {
|
|
124
|
+
static readonly HttpClient Http = new HttpClient();
|
|
125
|
+
public static async Task AcumuleazaPuncte(string codFidelitate) {
|
|
126
|
+
try {
|
|
127
|
+
var bon = "BF" + DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
128
|
+
var json = "{\\"fidelity_card\\":\\"" + codFidelitate + "\\"}";
|
|
129
|
+
var req = new HttpRequestMessage(HttpMethod.Post,
|
|
130
|
+
"https://fidelizare.ro/api/integration/s2s/submitReceipt");
|
|
131
|
+
req.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("FIDELIZARE_API_KEY"));
|
|
132
|
+
req.Headers.Add("Idempotency-Key", "BON-" + bon);
|
|
133
|
+
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
134
|
+
await Http.SendAsync(req);
|
|
135
|
+
} catch { /* nu blocheaza vanzarea */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
java: {
|
|
142
|
+
filename: 'Fidelizare.java',
|
|
143
|
+
call: (v) => `Fidelizare.acumuleazaPuncte(${v});`,
|
|
144
|
+
content: `// Generat de fidelizare-integrate. Cheia din mediu, niciodata in cod.
|
|
145
|
+
import java.net.URI;
|
|
146
|
+
import java.net.http.*;
|
|
147
|
+
|
|
148
|
+
public class Fidelizare {
|
|
149
|
+
public static void acumuleazaPuncte(String codFidelitate) {
|
|
150
|
+
try {
|
|
151
|
+
String body = "{\\"fidelity_card\\":\\"" + codFidelitate + "\\"}";
|
|
152
|
+
HttpRequest req = HttpRequest.newBuilder()
|
|
153
|
+
.uri(URI.create("https://fidelizare.ro/api/integration/s2s/submitReceipt"))
|
|
154
|
+
.header("Authorization", "Bearer " + System.getenv("FIDELIZARE_API_KEY"))
|
|
155
|
+
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
|
156
|
+
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
|
|
157
|
+
} catch (Exception e) { /* nu blocheaza vanzarea */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
`,
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
delphi: {
|
|
164
|
+
filename: 'Fidelizare.pas',
|
|
165
|
+
call: (v) => `AcumuleazaPuncte(${v});`,
|
|
166
|
+
content: `// Generat de fidelizare-integrate. Cheia din mediu, niciodata in cod.
|
|
167
|
+
unit Fidelizare;
|
|
168
|
+
interface
|
|
169
|
+
procedure AcumuleazaPuncte(const CodFidelitate: string);
|
|
170
|
+
implementation
|
|
171
|
+
uses System.Net.HttpClient, System.Classes, System.SysUtils;
|
|
172
|
+
procedure AcumuleazaPuncte(const CodFidelitate: string);
|
|
173
|
+
var Http: THTTPClient; Body: TStringStream;
|
|
174
|
+
begin
|
|
175
|
+
try
|
|
176
|
+
Http := THTTPClient.Create;
|
|
177
|
+
Body := TStringStream.Create('{"fidelity_card":"' + CodFidelitate + '"}', TEncoding.UTF8);
|
|
178
|
+
Http.CustomHeaders['Authorization'] := 'Bearer ' + GetEnvironmentVariable('FIDELIZARE_API_KEY');
|
|
179
|
+
Http.Post('https://fidelizare.ro/api/integration/s2s/submitReceipt', Body);
|
|
180
|
+
except
|
|
181
|
+
// nu blocheaza vanzarea
|
|
182
|
+
end;
|
|
183
|
+
end;
|
|
184
|
+
end.
|
|
185
|
+
`,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Session secret vault. The API key is stored host-side and never placed into
|
|
2
|
+
// the agent/LLM conversation: tools see only an opaque ref. Mirrors the
|
|
3
|
+
// approach used by mature setup wizards (PostHog) so secrets cannot leak into
|
|
4
|
+
// model context or generated source.
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
|
|
7
|
+
const store = new Map();
|
|
8
|
+
|
|
9
|
+
export function vaultPut(value, label = 'API key') {
|
|
10
|
+
const ref = 'secret:' + randomUUID();
|
|
11
|
+
store.set(ref, { value, label });
|
|
12
|
+
return ref;
|
|
13
|
+
}
|
|
14
|
+
export function vaultResolve(ref) {
|
|
15
|
+
return store.get(ref)?.value;
|
|
16
|
+
}
|
|
17
|
+
export function vaultHas(ref) {
|
|
18
|
+
return store.has(ref);
|
|
19
|
+
}
|
|
20
|
+
// Redact any stored secret value if it appears in a string (defense in depth).
|
|
21
|
+
export function redact(text) {
|
|
22
|
+
let t = text;
|
|
23
|
+
for (const { value } of store.values()) {
|
|
24
|
+
if (value && value.length > 6) t = t.split(value).join('‹FIDELIZARE_API_KEY›');
|
|
25
|
+
}
|
|
26
|
+
return t;
|
|
27
|
+
}
|
|
28
|
+
export function vaultClear() {
|
|
29
|
+
store.clear();
|
|
30
|
+
}
|
package/src/ui/anim.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Hand-built ASCII animations. No animation libraries: every frame is drawn
|
|
2
|
+
// with raw ANSI cursor control. Honors --no-anim / non-TTY by degrading to
|
|
3
|
+
// plain static lines.
|
|
4
|
+
import { ansi, C, paint, bold, out, isTTY, sleep } from './ansi.js';
|
|
5
|
+
|
|
6
|
+
let ANIM = isTTY && !process.argv.includes('--no-anim');
|
|
7
|
+
export const setAnim = (v) => { ANIM = v && isTTY; };
|
|
8
|
+
|
|
9
|
+
// --- The Fix pin logo in ASCII, drawn line by line with a red sweep. ---
|
|
10
|
+
const LOGO = [
|
|
11
|
+
' ▄▄███████▄▄ ',
|
|
12
|
+
' ▄██▀▀ ▀▀██▄ ',
|
|
13
|
+
' ██▀ ▟█▙ ▀██ ',
|
|
14
|
+
' ██ ▟█ █▙ ██ ',
|
|
15
|
+
' ██ ▟█ █▙ ██ ',
|
|
16
|
+
' ██▖ ▜█████▛ ▗██ ',
|
|
17
|
+
' ▀██▄▖ ▗▄██▀ ',
|
|
18
|
+
' ▀▀██▄▄▄██▀▀ ',
|
|
19
|
+
' ▀█▀ ',
|
|
20
|
+
' ▀ ',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export async function logoIntro() {
|
|
24
|
+
if (!ANIM) {
|
|
25
|
+
out(paint(' Fidelizare', C.red) + paint(' · asistent de integrare\n\n', C.ink));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
out(ansi.hideCursor);
|
|
29
|
+
out('\n');
|
|
30
|
+
for (const line of LOGO) {
|
|
31
|
+
// colour the filled blocks red, leave spacing dim
|
|
32
|
+
out(' ' + paint(line, C.red) + '\n');
|
|
33
|
+
await sleep(34);
|
|
34
|
+
}
|
|
35
|
+
// wordmark types in
|
|
36
|
+
out('\n ');
|
|
37
|
+
await typewrite('FIDELIZARE', 30, C.red, true);
|
|
38
|
+
out(paint(' · asistent de integrare\n\n', C.ink));
|
|
39
|
+
out(ansi.showCursor);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function typewrite(text, msPerChar = 24, color = C.white, isBold = false) {
|
|
43
|
+
if (!ANIM) { out((isBold ? bold(paint(text, color)) : paint(text, color))); return; }
|
|
44
|
+
for (const ch of text) {
|
|
45
|
+
out(isBold ? ansi.bold + color + ch + ansi.reset : color + ch + ansi.reset);
|
|
46
|
+
await sleep(msPerChar);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Spinner that runs while an async task works. ---
|
|
51
|
+
const DOTS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
52
|
+
export async function withSpinner(label, task) {
|
|
53
|
+
if (!ANIM) {
|
|
54
|
+
out(' ' + paint('•', C.red) + ' ' + label + ' ');
|
|
55
|
+
try { const r = await task(); out(paint('ok\n', C.green)); return r; }
|
|
56
|
+
catch (e) { out(paint('esuat\n', C.redBright)); throw e; }
|
|
57
|
+
}
|
|
58
|
+
out(ansi.hideCursor);
|
|
59
|
+
let i = 0, running = true;
|
|
60
|
+
const tick = async () => {
|
|
61
|
+
while (running) {
|
|
62
|
+
out(ansi.toLineStart + ansi.clearLine + ' ' + paint(DOTS[i % DOTS.length], C.red) + ' ' + paint(label, C.white));
|
|
63
|
+
i++;
|
|
64
|
+
await sleep(70);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const spin = tick();
|
|
68
|
+
try {
|
|
69
|
+
const result = await task();
|
|
70
|
+
running = false; await spin;
|
|
71
|
+
out(ansi.toLineStart + ansi.clearLine + ' ' + paint('✓', C.green) + ' ' + paint(label, C.ink) + '\n');
|
|
72
|
+
out(ansi.showCursor);
|
|
73
|
+
return result;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
running = false; await spin;
|
|
76
|
+
out(ansi.toLineStart + ansi.clearLine + ' ' + paint('✗', C.redBright) + ' ' + paint(label, C.ink) + '\n');
|
|
77
|
+
out(ansi.showCursor);
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Codebase "scan" animation: a red beam sweeps over faux code lines,
|
|
83
|
+
// like a barcode scanner reading the project. ---
|
|
84
|
+
const FAUX = [
|
|
85
|
+
'function onScan(code) {',
|
|
86
|
+
' if (isProduct(code)) addItem(code);',
|
|
87
|
+
' else if (isFidelityCard(code)) { /* ? */ }',
|
|
88
|
+
' total += price; printReceipt(items);',
|
|
89
|
+
'}',
|
|
90
|
+
'class PosController { finalizeSale() { ... } }',
|
|
91
|
+
];
|
|
92
|
+
export async function scanSweep(label, ms = 1500) {
|
|
93
|
+
if (!ANIM) { out(' ' + paint('•', C.red) + ' ' + label + '\n'); return; }
|
|
94
|
+
out(ansi.hideCursor);
|
|
95
|
+
out(' ' + paint(label, C.white) + '\n');
|
|
96
|
+
const rows = FAUX.length;
|
|
97
|
+
// draw faux code dim
|
|
98
|
+
for (const l of FAUX) out(' ' + paint(l, C.dim) + '\n');
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
let beam = 0;
|
|
101
|
+
while (Date.now() - start < ms) {
|
|
102
|
+
// redraw with the beam line highlighted red
|
|
103
|
+
out(ansi.up(rows));
|
|
104
|
+
for (let r = 0; r < rows; r++) {
|
|
105
|
+
out(ansi.clearLine);
|
|
106
|
+
if (r === beam % rows) out(' ' + paint('▶ ', C.red) + paint(FAUX[r], C.redBright) + '\n');
|
|
107
|
+
else out(' ' + paint(FAUX[r], C.dim) + '\n');
|
|
108
|
+
}
|
|
109
|
+
beam++;
|
|
110
|
+
await sleep(130);
|
|
111
|
+
}
|
|
112
|
+
// settle: highlight the fidelity-card line as the found spot
|
|
113
|
+
out(ansi.up(rows));
|
|
114
|
+
for (let r = 0; r < rows; r++) {
|
|
115
|
+
out(ansi.clearLine);
|
|
116
|
+
if (r === 2) out(' ' + paint('◆ ', C.green) + paint(FAUX[r], C.white) + '\n');
|
|
117
|
+
else out(' ' + paint(FAUX[r], C.dim) + '\n');
|
|
118
|
+
}
|
|
119
|
+
out(ansi.showCursor);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Progress bar. ---
|
|
123
|
+
export async function progress(label, steps = 20, ms = 1200) {
|
|
124
|
+
if (!ANIM) { out(' ' + label + ' ...\n'); return; }
|
|
125
|
+
out(ansi.hideCursor);
|
|
126
|
+
const per = ms / steps;
|
|
127
|
+
for (let i = 0; i <= steps; i++) {
|
|
128
|
+
const filled = '█'.repeat(i);
|
|
129
|
+
const empty = '░'.repeat(steps - i);
|
|
130
|
+
const pct = Math.round((i / steps) * 100);
|
|
131
|
+
out(ansi.toLineStart + ansi.clearLine + ' ' + paint(filled, C.red) + paint(empty, C.dim) +
|
|
132
|
+
' ' + paint(String(pct).padStart(3) + '%', C.ink) + ' ' + paint(label, C.white));
|
|
133
|
+
await sleep(per);
|
|
134
|
+
}
|
|
135
|
+
out('\n' + ansi.showCursor);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function rule() {
|
|
139
|
+
out(' ' + paint('─'.repeat(54), C.dim) + '\n');
|
|
140
|
+
}
|
|
141
|
+
export function heading(s) {
|
|
142
|
+
out('\n ' + bold(paint(s, C.white)) + '\n');
|
|
143
|
+
}
|
|
144
|
+
export function note(s, color = C.ink) {
|
|
145
|
+
out(' ' + paint(s, color) + '\n');
|
|
146
|
+
}
|
package/src/ui/ansi.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Zero-dependency ANSI helpers. Truecolor where supported, graceful when not.
|
|
2
|
+
const ESC = '\x1b[';
|
|
3
|
+
|
|
4
|
+
export const ansi = {
|
|
5
|
+
reset: ESC + '0m',
|
|
6
|
+
bold: ESC + '1m',
|
|
7
|
+
dim: ESC + '2m',
|
|
8
|
+
italic: ESC + '3m',
|
|
9
|
+
hideCursor: ESC + '?25l',
|
|
10
|
+
showCursor: ESC + '?25h',
|
|
11
|
+
clearLine: ESC + '2K',
|
|
12
|
+
toLineStart: '\r',
|
|
13
|
+
up: (n = 1) => ESC + n + 'A',
|
|
14
|
+
down: (n = 1) => ESC + n + 'B',
|
|
15
|
+
// 24-bit colour
|
|
16
|
+
fg: (r, g, b) => `${ESC}38;2;${r};${g};${b}m`,
|
|
17
|
+
bg: (r, g, b) => `${ESC}48;2;${r};${g};${b}m`,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Brand palette (Fidelizare red + warm orange + inks).
|
|
21
|
+
export const C = {
|
|
22
|
+
red: ansi.fg(160, 12, 8),
|
|
23
|
+
redBright: ansi.fg(220, 38, 30),
|
|
24
|
+
orange: ansi.fg(232, 90, 44),
|
|
25
|
+
white: ansi.fg(245, 245, 245),
|
|
26
|
+
ink: ansi.fg(120, 120, 128),
|
|
27
|
+
dim: ansi.fg(90, 90, 96),
|
|
28
|
+
green: ansi.fg(34, 168, 90),
|
|
29
|
+
amber: ansi.fg(214, 158, 46),
|
|
30
|
+
onRed: ansi.bg(160, 12, 8) + ansi.fg(255, 255, 255),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const paint = (s, color) => color + s + ansi.reset;
|
|
34
|
+
export const bold = (s) => ansi.bold + s + ansi.reset;
|
|
35
|
+
|
|
36
|
+
const supportsColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
|
|
37
|
+
export function out(s) {
|
|
38
|
+
process.stdout.write(supportsColor ? s : s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''));
|
|
39
|
+
}
|
|
40
|
+
export const isTTY = Boolean(process.stdout.isTTY);
|
|
41
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|