@simonfestl/husky-cli 1.10.0 → 1.13.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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Wattiz Client with Playwright
3
+ *
4
+ * Uses real browser automation for PrestaShop platform
5
+ */
6
+ import { chromium } from 'playwright';
7
+ import { getConfig } from '../../commands/config.js';
8
+ import * as fs from 'fs';
9
+ export class WattizPlaywrightClient {
10
+ config;
11
+ browser;
12
+ page;
13
+ contextCreated = false;
14
+ constructor(config) {
15
+ this.config = config;
16
+ }
17
+ static fromConfig() {
18
+ const config = getConfig();
19
+ const env = process.env.HUSKY_ENV || 'PROD';
20
+ const wattizConfig = {
21
+ username: process.env[`${env}_WATTIZ_USERNAME`] ||
22
+ process.env.WATTIZ_USERNAME ||
23
+ config.wattizUsername || '',
24
+ password: process.env[`${env}_WATTIZ_PASSWORD`] ||
25
+ process.env.WATTIZ_PASSWORD ||
26
+ config.wattizPassword || '',
27
+ baseUrl: process.env[`${env}_WATTIZ_BASE_URL`] ||
28
+ process.env.WATTIZ_BASE_URL ||
29
+ config.wattizBaseUrl ||
30
+ 'https://www.wattiz.fr',
31
+ language: (process.env[`${env}_WATTIZ_LANGUAGE`] ||
32
+ process.env.WATTIZ_LANGUAGE ||
33
+ config.wattizLanguage ||
34
+ 'gb'),
35
+ };
36
+ if (!wattizConfig.username || !wattizConfig.password) {
37
+ throw new Error('Missing Wattiz credentials. Configure with:\n' +
38
+ ' husky config set wattiz-username <username>\n' +
39
+ ' husky config set wattiz-password <password>');
40
+ }
41
+ return new WattizPlaywrightClient(wattizConfig);
42
+ }
43
+ async ensureBrowser() {
44
+ if (!this.browser || !this.page) {
45
+ this.browser = await chromium.launch({
46
+ headless: true,
47
+ args: [
48
+ '--no-sandbox',
49
+ '--disable-setuid-sandbox',
50
+ '--disable-dev-shm-usage',
51
+ '--disable-accelerated-2d-canvas',
52
+ '--disable-gpu'
53
+ ]
54
+ });
55
+ if (!this.contextCreated) {
56
+ const context = await this.browser.newContext({
57
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
58
+ viewport: { width: 1920, height: 1080 },
59
+ locale: this.config.language === 'gb' ? 'en-GB' : 'fr-FR',
60
+ });
61
+ this.page = await context.newPage();
62
+ this.contextCreated = true;
63
+ }
64
+ }
65
+ return this.page;
66
+ }
67
+ async login() {
68
+ try {
69
+ const page = await this.ensureBrowser();
70
+ // Navigate to login page (PrestaShop - language-specific URLs)
71
+ // English uses /login, French uses /connexion
72
+ const loginPath = this.config.language === 'gb' ? 'login' : 'connexion';
73
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/${loginPath}`, {
74
+ waitUntil: 'domcontentloaded',
75
+ timeout: 30000
76
+ });
77
+ await page.waitForLoadState('domcontentloaded').catch(() => { });
78
+ // PrestaShop can use different field patterns - try multiple selectors
79
+ const emailSelectors = [
80
+ 'input[name="email"]',
81
+ 'input[type="email"]',
82
+ 'input#email',
83
+ 'input[id*="email"]',
84
+ 'input[name*="email"]'
85
+ ];
86
+ const passwordSelectors = [
87
+ 'input[name="password"]',
88
+ 'input[type="password"]',
89
+ 'input#password',
90
+ 'input[id*="password"]'
91
+ ];
92
+ // Find email input
93
+ let emailInput = null;
94
+ for (const selector of emailSelectors) {
95
+ const count = await page.locator(selector).count();
96
+ if (count > 0) {
97
+ emailInput = selector;
98
+ break;
99
+ }
100
+ }
101
+ if (!emailInput) {
102
+ const html = await page.content();
103
+ await fs.promises.writeFile('/tmp/wattiz-login-debug.html', html);
104
+ throw new Error('Could not find email input field. Debug HTML saved to /tmp/wattiz-login-debug.html');
105
+ }
106
+ // Find password input
107
+ let passwordInput = null;
108
+ for (const selector of passwordSelectors) {
109
+ const count = await page.locator(selector).count();
110
+ if (count > 0) {
111
+ passwordInput = selector;
112
+ break;
113
+ }
114
+ }
115
+ if (!passwordInput) {
116
+ throw new Error('Could not find password input field');
117
+ }
118
+ console.log(`Using selectors: email="${emailInput}", password="${passwordInput}"`);
119
+ // Fill in login form
120
+ await page.fill(emailInput, this.config.username);
121
+ await page.fill(passwordInput, this.config.password);
122
+ // Find and click submit button
123
+ const submitSelectors = [
124
+ 'button[type="submit"]',
125
+ 'button[name="submit"]',
126
+ 'input[type="submit"]',
127
+ 'button.btn-primary'
128
+ ];
129
+ let submitClicked = false;
130
+ for (const selector of submitSelectors) {
131
+ const count = await page.locator(selector).count();
132
+ if (count > 0) {
133
+ await page.click(selector);
134
+ submitClicked = true;
135
+ break;
136
+ }
137
+ }
138
+ if (!submitClicked) {
139
+ throw new Error('Could not find submit button');
140
+ }
141
+ // Wait for navigation
142
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { });
143
+ // Check if logged in by looking for my-account page or logged-in indicators
144
+ const currentUrl = page.url();
145
+ console.log('After login, current URL:', currentUrl);
146
+ // Check for error messages first
147
+ const errorMsg = await page.locator('.alert-danger, .error-message, .ps-alert-error').textContent().catch(() => '');
148
+ if (errorMsg) {
149
+ console.log('Error message on page:', errorMsg.trim());
150
+ }
151
+ const isLoggedIn = currentUrl.includes('/my-account') ||
152
+ currentUrl.includes('/mon-compte') ||
153
+ await page.locator('.account-link').count() > 0 ||
154
+ await page.locator('[data-link-action="sign-out"]').count() > 0 ||
155
+ await page.locator('.logout, .sign-out').count() > 0 ||
156
+ await page.locator('a[href*="logout"]').count() > 0;
157
+ console.log('Is logged in:', isLoggedIn);
158
+ if (!isLoggedIn) {
159
+ // Save debug HTML
160
+ const debugHtml = await page.content();
161
+ await fs.promises.writeFile('/tmp/wattiz-after-login.html', debugHtml);
162
+ console.log('Debug HTML saved to /tmp/wattiz-after-login.html');
163
+ return {
164
+ success: false,
165
+ cookies: '',
166
+ error: `Login failed - check credentials. Current URL: ${currentUrl}`
167
+ };
168
+ }
169
+ // Extract cookies
170
+ const cookies = await page.context().cookies();
171
+ const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
172
+ return {
173
+ success: true,
174
+ cookies: cookieString
175
+ };
176
+ }
177
+ catch (error) {
178
+ return {
179
+ success: false,
180
+ cookies: '',
181
+ error: `Login error: ${error.message}`
182
+ };
183
+ }
184
+ }
185
+ async listOrders() {
186
+ const page = await this.ensureBrowser();
187
+ // Navigate to orders page (PrestaShop order-history)
188
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/order-history`);
189
+ await page.waitForLoadState('networkidle');
190
+ // Check if we need to login
191
+ const needsLogin = await page.url().includes('/login');
192
+ if (needsLogin) {
193
+ await this.login();
194
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/order-history`);
195
+ await page.waitForLoadState('networkidle');
196
+ }
197
+ const orders = [];
198
+ // Find all order rows (PrestaShop structure)
199
+ const orderRows = page.locator('table tbody tr, .order-line');
200
+ const count = await orderRows.count();
201
+ for (let i = 0; i < count; i++) {
202
+ const row = orderRows.nth(i);
203
+ // PrestaShop order structure
204
+ const orderLinkEl = row.locator('a[href*="order-detail"]').first();
205
+ const orderLink = await orderLinkEl.getAttribute('href').catch(() => '');
206
+ // Extract order ID from PrestaShop controller URL
207
+ const orderIdMatch = orderLink?.match(/id_order=(\d+)/);
208
+ const orderId = orderIdMatch ? orderIdMatch[1] : '';
209
+ const orderNumber = await orderLinkEl.textContent().catch(() => '') || orderId;
210
+ const date = (await row.locator('.order-date, td:nth-child(2)').textContent().catch(() => '')) || '';
211
+ const status = (await row.locator('.order-status, td:nth-child(4), .label').textContent().catch(() => '')) || '';
212
+ const total = (await row.locator('.order-total, td:nth-child(3)').textContent().catch(() => '')) || '';
213
+ if (orderId) {
214
+ orders.push({
215
+ id: orderId,
216
+ orderNumber: orderNumber.trim(),
217
+ date: date.trim(),
218
+ status: status.trim(),
219
+ total: total.trim(),
220
+ itemCount: 0,
221
+ });
222
+ }
223
+ }
224
+ return orders;
225
+ }
226
+ async getOrder(orderId) {
227
+ const page = await this.ensureBrowser();
228
+ // PrestaShop controller-based URL
229
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`);
230
+ await page.waitForLoadState('networkidle');
231
+ // Check if we need to login
232
+ const needsLogin = await page.url().includes('/login');
233
+ if (needsLogin) {
234
+ await this.login();
235
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`);
236
+ await page.waitForLoadState('networkidle');
237
+ }
238
+ // Extract order information
239
+ const orderNumber = (await page.locator('.order-reference, h3').first().textContent().catch(() => orderId)) || orderId;
240
+ const orderDate = (await page.locator('.order-date, .date').first().textContent().catch(() => '')) || '';
241
+ const orderStatus = (await page.locator('.order-status, .label').first().textContent().catch(() => '')) || '';
242
+ // Extract customer info
243
+ let customerName = '';
244
+ let customerAddress = '';
245
+ let customerEmail = '';
246
+ let customerPhone = '';
247
+ try {
248
+ const addressBlock = page.locator('.address, .delivery-address').first();
249
+ const addressText = await addressBlock.textContent() || '';
250
+ const lines = addressText.split('\n').map(l => l.trim()).filter(l => l);
251
+ if (lines.length > 0)
252
+ customerName = lines[0];
253
+ if (lines.length > 1)
254
+ customerAddress = lines.slice(1).filter(l => !l.includes('@') && !l.startsWith('+')).join(', ');
255
+ // Try to find email and phone
256
+ customerEmail = await page.locator('[href^="mailto:"]').first().textContent().catch(() => '') || '';
257
+ customerPhone = await page.locator('[href^="tel:"]').first().textContent().catch(() => '') || '';
258
+ }
259
+ catch (error) {
260
+ // Customer details not available
261
+ }
262
+ // Extract line items
263
+ const items = [];
264
+ const itemRows = page.locator('.order-products table tbody tr, .product-line-row');
265
+ const itemCount = await itemRows.count();
266
+ for (let i = 0; i < itemCount; i++) {
267
+ const itemRow = itemRows.nth(i);
268
+ const name = await itemRow.locator('.product-name, td:first-child').textContent() || '';
269
+ const qty = await itemRow.locator('.qty, td:nth-child(2)').textContent() || '1';
270
+ const total = await itemRow.locator('.price, td:last-child').textContent() || '';
271
+ items.push({
272
+ sku: '',
273
+ name: name.trim(),
274
+ quantity: parseInt(qty.replace(/\D/g, ''), 10) || 1,
275
+ price: '',
276
+ total: total.trim(),
277
+ });
278
+ }
279
+ // Look for invoice link
280
+ const invoiceLink = await page.locator('a[href*="invoice"], a.btn-primary[href*="pdf"]').first().getAttribute('href').catch(() => '');
281
+ // Extract total
282
+ const totalText = await page.locator('.order-total, .total-value').last().textContent().catch(() => '');
283
+ // Extract tracking number if available
284
+ const trackingNumber = await page.locator('.tracking-number, [href*="track"]').first().textContent().catch(() => '');
285
+ return {
286
+ id: orderId,
287
+ orderNumber: orderNumber?.trim() || orderId,
288
+ date: orderDate?.trim() || '',
289
+ status: orderStatus?.trim() || '',
290
+ total: totalText?.trim() || '',
291
+ itemCount: items.length,
292
+ invoiceUrl: invoiceLink || undefined,
293
+ trackingNumber: trackingNumber?.trim() || undefined,
294
+ customer: {
295
+ name: customerName.trim(),
296
+ address: customerAddress.trim(),
297
+ city: '',
298
+ postcode: '',
299
+ email: customerEmail.trim(),
300
+ phone: customerPhone.trim(),
301
+ },
302
+ items,
303
+ subtotal: '',
304
+ shipping: '',
305
+ tax: '',
306
+ paymentMethod: '',
307
+ };
308
+ }
309
+ async downloadInvoice(orderId, savePath) {
310
+ try {
311
+ const order = await this.getOrder(orderId);
312
+ if (!order.invoiceUrl) {
313
+ return false;
314
+ }
315
+ const page = await this.ensureBrowser();
316
+ // Construct full URL
317
+ const invoiceUrl = order.invoiceUrl.startsWith('http')
318
+ ? order.invoiceUrl
319
+ : `${this.config.baseUrl}${order.invoiceUrl}`;
320
+ // Set up download listener before navigating
321
+ const downloadPromise = page.waitForEvent('download', { timeout: 30000 });
322
+ // Navigate to invoice URL (this triggers the download)
323
+ await page.goto(invoiceUrl, { waitUntil: 'commit' }).catch(() => {
324
+ // Ignore navigation error since download starts immediately
325
+ });
326
+ // Wait for the download to start
327
+ const download = await downloadPromise;
328
+ // Save the downloaded file
329
+ await download.saveAs(savePath);
330
+ return true;
331
+ }
332
+ catch (error) {
333
+ console.error('Invoice download error:', error);
334
+ return false;
335
+ }
336
+ }
337
+ async searchProducts(query) {
338
+ const page = await this.ensureBrowser();
339
+ const searchUrl = `${this.config.baseUrl}/${this.config.language}/search?s=${encodeURIComponent(query)}`;
340
+ await page.goto(searchUrl);
341
+ await page.waitForLoadState('networkidle');
342
+ // Check if we need to login to see prices (B2B feature)
343
+ const needsLogin = await page.url().includes('/login');
344
+ if (needsLogin) {
345
+ await this.login();
346
+ await page.goto(searchUrl);
347
+ await page.waitForLoadState('networkidle');
348
+ }
349
+ const products = [];
350
+ // Find all product items (PrestaShop structure)
351
+ const productItems = page.locator('.product-miniature, .js-product-miniature, article.product');
352
+ const count = await productItems.count();
353
+ for (let i = 0; i < count; i++) {
354
+ const item = productItems.nth(i);
355
+ const link = item.locator('a.product-thumbnail, h3 a').first();
356
+ const url = await link.getAttribute('href') || '';
357
+ const name = await item.locator('.product-title, h3 a, h2 a').first().textContent() || '';
358
+ const price = await item.locator('.price, .product-price-and-shipping').first().textContent().catch(() => undefined);
359
+ const img = await item.locator('img').first().getAttribute('src');
360
+ if (name && url) {
361
+ products.push({
362
+ id: '',
363
+ name: name.trim(),
364
+ url,
365
+ price: price?.trim(),
366
+ imageUrl: img || undefined,
367
+ });
368
+ }
369
+ }
370
+ return products;
371
+ }
372
+ async close() {
373
+ if (this.browser) {
374
+ await this.browser.close();
375
+ this.browser = undefined;
376
+ this.page = undefined;
377
+ }
378
+ }
379
+ }
380
+ export default WattizPlaywrightClient;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Wattiz Types
3
+ *
4
+ * TypeScript interfaces for wattiz.fr (B2B e-mobility parts supplier - PrestaShop)
5
+ */
6
+ export interface WattizConfig {
7
+ username: string;
8
+ password: string;
9
+ baseUrl: string;
10
+ language: 'gb' | 'fr' | 'de' | 'es';
11
+ }
12
+ export interface WattizOrder {
13
+ id: string;
14
+ orderNumber: string;
15
+ date: string;
16
+ status: string;
17
+ total: string;
18
+ itemCount: number;
19
+ invoiceUrl?: string;
20
+ }
21
+ export interface WattizOrderDetails extends WattizOrder {
22
+ customer: {
23
+ name: string;
24
+ company?: string;
25
+ address: string;
26
+ city: string;
27
+ postcode: string;
28
+ email: string;
29
+ phone?: string;
30
+ };
31
+ items: WattizLineItem[];
32
+ subtotal: string;
33
+ shipping: string;
34
+ tax: string;
35
+ paymentMethod: string;
36
+ shippingMethod?: string;
37
+ trackingNumber?: string;
38
+ }
39
+ export interface WattizLineItem {
40
+ sku: string;
41
+ name: string;
42
+ quantity: number;
43
+ price: string;
44
+ total: string;
45
+ }
46
+ export interface WattizProduct {
47
+ id: string;
48
+ name: string;
49
+ sku?: string;
50
+ ean?: string;
51
+ price?: string;
52
+ url: string;
53
+ imageUrl?: string;
54
+ stockStatus?: string;
55
+ category?: string;
56
+ }
57
+ export interface WattizLoginResult {
58
+ success: boolean;
59
+ cookies: string;
60
+ error?: string;
61
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Wattiz Types
3
+ *
4
+ * TypeScript interfaces for wattiz.fr (B2B e-mobility parts supplier - PrestaShop)
5
+ */
6
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.10.0",
3
+ "version": "1.13.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  "@inquirer/prompts": "^8.1.0",
26
26
  "commander": "^12.1.0",
27
27
  "firebase-admin": "^13.6.0",
28
+ "playwright": "^1.57.0",
28
29
  "sharp": "^0.34.5",
29
30
  "youtube-transcript": "^1.2.1",
30
31
  "zod": "^4.3.5"