@simonfestl/husky-cli 1.13.0 → 1.15.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.
@@ -9,6 +9,7 @@ import { execSync } from "child_process";
9
9
  import { resolveProject, fetchProjects, formatProjectList } from "../lib/project-resolver.js";
10
10
  import { requirePermission } from "../lib/permissions.js";
11
11
  import { ErrorHelpers, errorWithHint, ExplainTopic } from "../lib/error-hints.js";
12
+ import { AgentLock, AgentLockError } from "../lib/agent-lock.js";
12
13
  export const taskCommand = new Command("task")
13
14
  .description("Manage tasks");
14
15
  // Helper: Get task ID from --id flag or HUSKY_TASK_ID env var
@@ -245,6 +246,17 @@ taskCommand
245
246
  if (worktreeInfo) {
246
247
  console.log(`✓ Created worktree: ${worktreeInfo.path}`);
247
248
  console.log(` Branch: ${worktreeInfo.branch}`);
249
+ // Acquire agent lock for the worktree
250
+ try {
251
+ const agentLock = new AgentLock(process.cwd(), worktreeInfo.path, workerId, sessionId);
252
+ agentLock.acquire();
253
+ console.log(`✓ Acquired agent lock`);
254
+ }
255
+ catch (error) {
256
+ if (error instanceof AgentLockError) {
257
+ console.warn(`⚠ Warning: ${error.message}`);
258
+ }
259
+ }
248
260
  }
249
261
  }
250
262
  const res = await fetch(`${config.apiUrl}/api/tasks/${id}/start`, {
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import * as path from "path";
3
3
  import { WorktreeManager } from "../lib/worktree.js";
4
4
  import { MergeLock, withMergeLock } from "../lib/merge-lock.js";
5
+ import { AgentLock } from "../lib/agent-lock.js";
5
6
  import { getConfig } from "./config.js";
6
7
  export const worktreeCommand = new Command("worktree")
7
8
  .description("Manage Git worktrees for isolated agent workspaces");
@@ -87,13 +88,24 @@ worktreeCommand
87
88
  .option("--json", "Output as JSON")
88
89
  .action(async (options) => {
89
90
  try {
91
+ const projectDir = getProjectDir(options);
90
92
  const manager = getManager(options);
91
93
  const worktrees = manager.listWorktrees();
94
+ const agentLocks = AgentLock.listLocks(projectDir);
95
+ // Build a map of worktree path to lock info
96
+ const locksByPath = new Map();
97
+ for (const lock of agentLocks) {
98
+ locksByPath.set(lock.worktree, { info: lock.info, isStale: lock.isStale });
99
+ }
92
100
  if (options.json) {
93
- console.log(JSON.stringify({ worktrees, baseBranch: manager.getBaseBranch() }, null, 2));
101
+ const worktreesWithLocks = worktrees.map(wt => ({
102
+ ...wt,
103
+ agentLock: locksByPath.get(wt.path) || null,
104
+ }));
105
+ console.log(JSON.stringify({ worktrees: worktreesWithLocks, baseBranch: manager.getBaseBranch(), agentLocks }, null, 2));
94
106
  }
95
107
  else {
96
- printWorktreeList(worktrees, manager.getBaseBranch());
108
+ printWorktreeList(worktrees, manager.getBaseBranch(), locksByPath);
97
109
  }
98
110
  }
99
111
  catch (error) {
@@ -109,6 +121,7 @@ worktreeCommand
109
121
  .option("--json", "Output as JSON")
110
122
  .action(async (sessionName, options) => {
111
123
  try {
124
+ const projectDir = getProjectDir(options);
112
125
  const manager = getManager(options);
113
126
  const info = manager.getWorktree(sessionName);
114
127
  if (!info) {
@@ -117,11 +130,14 @@ worktreeCommand
117
130
  }
118
131
  const changedFiles = manager.getChangedFiles(sessionName);
119
132
  const hasUncommitted = manager.hasUncommittedChanges(sessionName);
133
+ // Check for agent lock
134
+ const agentLocks = AgentLock.listLocks(projectDir);
135
+ const agentLock = agentLocks.find(l => l.worktree === info.path) || null;
120
136
  if (options.json) {
121
- console.log(JSON.stringify({ ...info, changedFiles, hasUncommittedChanges: hasUncommitted }, null, 2));
137
+ console.log(JSON.stringify({ ...info, changedFiles, hasUncommittedChanges: hasUncommitted, agentLock }, null, 2));
122
138
  }
123
139
  else {
124
- printWorktreeDetail(info, changedFiles, hasUncommitted);
140
+ printWorktreeDetail(info, changedFiles, hasUncommitted, agentLock);
125
141
  }
126
142
  }
127
143
  catch (error) {
@@ -316,14 +332,18 @@ worktreeCommand
316
332
  else {
317
333
  // Just cleanup stale
318
334
  manager.cleanupStale();
319
- const staleLocks = MergeLock.cleanupStale(projectDir);
335
+ const staleMergeLocks = MergeLock.cleanupStale(projectDir);
336
+ const staleAgentLocks = AgentLock.cleanupStale(projectDir);
320
337
  if (options.json) {
321
- console.log(JSON.stringify({ staleLocksRemoved: staleLocks, type: "stale" }, null, 2));
338
+ console.log(JSON.stringify({ staleMergeLocksRemoved: staleMergeLocks, staleAgentLocksRemoved: staleAgentLocks, type: "stale" }, null, 2));
322
339
  }
323
340
  else {
324
341
  console.log("Cleaned up stale worktrees and locks");
325
- if (staleLocks > 0) {
326
- console.log(` Removed ${staleLocks} stale lock(s)`);
342
+ if (staleMergeLocks > 0) {
343
+ console.log(` Removed ${staleMergeLocks} stale merge lock(s)`);
344
+ }
345
+ if (staleAgentLocks > 0) {
346
+ console.log(` Removed ${staleAgentLocks} stale agent lock(s)`);
327
347
  }
328
348
  }
329
349
  }
@@ -376,24 +396,34 @@ worktreeCommand
376
396
  }
377
397
  });
378
398
  // Print helpers
379
- function printWorktreeList(worktrees, baseBranch) {
399
+ function printWorktreeList(worktrees, baseBranch, locksByPath) {
380
400
  console.log(`\n Base branch: ${baseBranch}`);
381
- console.log(" " + "-".repeat(80));
401
+ console.log(" " + "-".repeat(90));
382
402
  if (worktrees.length === 0) {
383
403
  console.log(" No worktrees found.");
384
404
  console.log(" Create one with: husky worktree create <session-name>");
385
405
  }
386
406
  else {
387
- console.log(` ${"SESSION".padEnd(20)} ${"BRANCH".padEnd(25)} ${"COMMITS".padEnd(8)} ${"CHANGES"}`);
388
- console.log(" " + "-".repeat(80));
407
+ console.log(` ${"SESSION".padEnd(20)} ${"BRANCH".padEnd(25)} ${"COMMITS".padEnd(8)} ${"CHANGES".padEnd(20)} ${"AGENT"}`);
408
+ console.log(" " + "-".repeat(90));
389
409
  for (const wt of worktrees) {
390
410
  const changes = `+${wt.stats.additions}/-${wt.stats.deletions} (${wt.stats.filesChanged} files)`;
391
- console.log(` ${wt.sessionName.padEnd(20)} ${wt.branch.padEnd(25)} ${String(wt.stats.commitCount).padEnd(8)} ${changes}`);
411
+ const lock = locksByPath.get(wt.path);
412
+ let agentInfo = "";
413
+ if (lock) {
414
+ if (lock.isStale) {
415
+ agentInfo = `[stale] ${lock.info.workerId}`;
416
+ }
417
+ else {
418
+ agentInfo = `${lock.info.workerId} (PID: ${lock.info.pid})`;
419
+ }
420
+ }
421
+ console.log(` ${wt.sessionName.padEnd(20)} ${wt.branch.padEnd(25)} ${String(wt.stats.commitCount).padEnd(8)} ${changes.padEnd(20)} ${agentInfo}`);
392
422
  }
393
423
  }
394
424
  console.log("");
395
425
  }
396
- function printWorktreeDetail(info, changedFiles, hasUncommitted) {
426
+ function printWorktreeDetail(info, changedFiles, hasUncommitted, agentLock) {
397
427
  console.log(`\n Worktree: ${info.sessionName}`);
398
428
  console.log(" " + "-".repeat(60));
399
429
  console.log(` Path: ${info.path}`);
@@ -405,6 +435,20 @@ function printWorktreeDetail(info, changedFiles, hasUncommitted) {
405
435
  console.log(` Files: ${info.stats.filesChanged}`);
406
436
  console.log(` Added: +${info.stats.additions}`);
407
437
  console.log(` Removed: -${info.stats.deletions}`);
438
+ if (agentLock) {
439
+ console.log(`\n Agent Lock:`);
440
+ if (agentLock.isStale) {
441
+ console.log(` Status: STALE (process no longer running)`);
442
+ }
443
+ else {
444
+ console.log(` Status: ACTIVE`);
445
+ }
446
+ console.log(` Worker: ${agentLock.info.workerId}`);
447
+ console.log(` Session: ${agentLock.info.sessionId}`);
448
+ console.log(` PID: ${agentLock.info.pid}`);
449
+ console.log(` Host: ${agentLock.info.hostname}`);
450
+ console.log(` Since: ${new Date(agentLock.info.timestamp).toISOString()}`);
451
+ }
408
452
  if (hasUncommitted) {
409
453
  console.log(`\n ⚠ Has uncommitted changes`);
410
454
  }
package/dist/index.js CHANGED
@@ -89,3 +89,4 @@ if (process.argv.length <= 2) {
89
89
  else {
90
90
  program.parse();
91
91
  }
92
+ // trigger CI
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Agent Lock Mechanism for Husky Worktrees
3
+ *
4
+ * Prevents multiple agents from working in the same worktree simultaneously.
5
+ * Tracks agent identity (workerId, sessionId) alongside PID for detection.
6
+ */
7
+ export declare class AgentLockError extends Error {
8
+ lockInfo?: AgentLockInfo | undefined;
9
+ constructor(message: string, lockInfo?: AgentLockInfo | undefined);
10
+ }
11
+ export interface AgentLockInfo {
12
+ workerId: string;
13
+ sessionId: string;
14
+ pid: number;
15
+ timestamp: number;
16
+ hostname: string;
17
+ worktreePath: string;
18
+ cwd: string;
19
+ }
20
+ export declare class AgentLock {
21
+ private projectDir;
22
+ private worktreePath;
23
+ private lockFile;
24
+ private lockDir;
25
+ private isHeldByMe;
26
+ private workerId;
27
+ private sessionId;
28
+ static readonly STALE_TIMEOUT: number;
29
+ constructor(projectDir: string, worktreePath: string, workerId: string, sessionId: string);
30
+ /**
31
+ * Create a simple hash of a path for lock file naming.
32
+ */
33
+ private hashPath;
34
+ /**
35
+ * Check if another agent is working in this worktree.
36
+ * Returns the lock info if locked by another agent, null otherwise.
37
+ */
38
+ checkForConflict(): AgentLockInfo | null;
39
+ /**
40
+ * Acquire the agent lock for this worktree.
41
+ */
42
+ acquire(): boolean;
43
+ /**
44
+ * Release the agent lock.
45
+ */
46
+ release(): void;
47
+ /**
48
+ * Update the lock timestamp (heartbeat).
49
+ */
50
+ heartbeat(): void;
51
+ /**
52
+ * Check if a process exists.
53
+ */
54
+ private processExists;
55
+ /**
56
+ * Check if a lock is stale.
57
+ */
58
+ private isStale;
59
+ /**
60
+ * Get the current lock info.
61
+ */
62
+ getLockInfo(): AgentLockInfo | null;
63
+ /**
64
+ * Check if the lock file exists.
65
+ */
66
+ isLocked(): boolean;
67
+ /**
68
+ * List all agent locks in a project.
69
+ */
70
+ static listLocks(projectDir: string): Array<{
71
+ worktree: string;
72
+ info: AgentLockInfo;
73
+ isStale: boolean;
74
+ }>;
75
+ /**
76
+ * Clean up all stale agent locks.
77
+ */
78
+ static cleanupStale(projectDir: string): number;
79
+ }
80
+ /**
81
+ * Helper to check if a worktree is in use by another agent.
82
+ */
83
+ export declare function checkWorktreeConflict(projectDir: string, worktreePath: string, workerId: string, sessionId: string): AgentLockInfo | null;
84
+ /**
85
+ * Helper to acquire an agent lock and run a function.
86
+ */
87
+ export declare function withAgentLock<T>(projectDir: string, worktreePath: string, workerId: string, sessionId: string, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Agent Lock Mechanism for Husky Worktrees
3
+ *
4
+ * Prevents multiple agents from working in the same worktree simultaneously.
5
+ * Tracks agent identity (workerId, sessionId) alongside PID for detection.
6
+ */
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import * as os from "os";
10
+ export class AgentLockError extends Error {
11
+ lockInfo;
12
+ constructor(message, lockInfo) {
13
+ super(message);
14
+ this.lockInfo = lockInfo;
15
+ this.name = "AgentLockError";
16
+ }
17
+ }
18
+ export class AgentLock {
19
+ projectDir;
20
+ worktreePath;
21
+ lockFile;
22
+ lockDir;
23
+ isHeldByMe = false;
24
+ workerId;
25
+ sessionId;
26
+ static STALE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
27
+ constructor(projectDir, worktreePath, workerId, sessionId) {
28
+ this.projectDir = path.resolve(projectDir);
29
+ this.worktreePath = worktreePath;
30
+ this.workerId = workerId;
31
+ this.sessionId = sessionId;
32
+ this.lockDir = path.join(this.projectDir, ".husky", ".locks");
33
+ // Use worktree path hash to create unique lock file name
34
+ const worktreeHash = this.hashPath(worktreePath);
35
+ this.lockFile = path.join(this.lockDir, `agent-${worktreeHash}.lock`);
36
+ }
37
+ /**
38
+ * Create a simple hash of a path for lock file naming.
39
+ */
40
+ hashPath(p) {
41
+ // Simple hash: use last two path segments
42
+ const parts = p.split(path.sep).filter(Boolean);
43
+ if (parts.length >= 2) {
44
+ return `${parts[parts.length - 2]}-${parts[parts.length - 1]}`;
45
+ }
46
+ return parts[parts.length - 1] || "default";
47
+ }
48
+ /**
49
+ * Check if another agent is working in this worktree.
50
+ * Returns the lock info if locked by another agent, null otherwise.
51
+ */
52
+ checkForConflict() {
53
+ if (!fs.existsSync(this.lockFile)) {
54
+ return null;
55
+ }
56
+ try {
57
+ const content = fs.readFileSync(this.lockFile, "utf-8");
58
+ const lockInfo = JSON.parse(content);
59
+ // Same agent, same session - no conflict
60
+ if (lockInfo.workerId === this.workerId && lockInfo.sessionId === this.sessionId) {
61
+ return null;
62
+ }
63
+ // Check if the locking process is still running
64
+ if (!this.processExists(lockInfo.pid)) {
65
+ // Process is dead, lock is stale
66
+ return null;
67
+ }
68
+ // Check if lock is too old
69
+ if (this.isStale(lockInfo)) {
70
+ return null;
71
+ }
72
+ // Another agent is actively working here
73
+ return lockInfo;
74
+ }
75
+ catch {
76
+ // Can't read lock file, assume no conflict
77
+ return null;
78
+ }
79
+ }
80
+ /**
81
+ * Acquire the agent lock for this worktree.
82
+ */
83
+ acquire() {
84
+ // First check for conflicts
85
+ const conflict = this.checkForConflict();
86
+ if (conflict) {
87
+ throw new AgentLockError(`Worktree is already in use by another agent!\n` +
88
+ ` Worker: ${conflict.workerId}\n` +
89
+ ` Session: ${conflict.sessionId}\n` +
90
+ ` PID: ${conflict.pid}\n` +
91
+ ` Since: ${new Date(conflict.timestamp).toISOString()}\n` +
92
+ ` Host: ${conflict.hostname}`, conflict);
93
+ }
94
+ // Clean up stale lock if exists
95
+ if (fs.existsSync(this.lockFile)) {
96
+ try {
97
+ const content = fs.readFileSync(this.lockFile, "utf-8");
98
+ const lockInfo = JSON.parse(content);
99
+ if (!this.processExists(lockInfo.pid) || this.isStale(lockInfo)) {
100
+ fs.unlinkSync(this.lockFile);
101
+ }
102
+ }
103
+ catch {
104
+ // Ignore errors, try to acquire anyway
105
+ }
106
+ }
107
+ // Create lock directory
108
+ fs.mkdirSync(this.lockDir, { recursive: true });
109
+ const lockInfo = {
110
+ workerId: this.workerId,
111
+ sessionId: this.sessionId,
112
+ pid: process.pid,
113
+ timestamp: Date.now(),
114
+ hostname: os.hostname(),
115
+ worktreePath: this.worktreePath,
116
+ cwd: process.cwd(),
117
+ };
118
+ try {
119
+ // Use exclusive flag for atomic creation
120
+ const fd = fs.openSync(this.lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
121
+ fs.writeSync(fd, JSON.stringify(lockInfo, null, 2));
122
+ fs.closeSync(fd);
123
+ this.isHeldByMe = true;
124
+ return true;
125
+ }
126
+ catch (error) {
127
+ if (error.code === "EEXIST") {
128
+ // File was created between our check and acquire attempt
129
+ // Re-check for conflict
130
+ const newConflict = this.checkForConflict();
131
+ if (newConflict) {
132
+ throw new AgentLockError(`Worktree was just locked by another agent!\n` +
133
+ ` Worker: ${newConflict.workerId}\n` +
134
+ ` Session: ${newConflict.sessionId}`, newConflict);
135
+ }
136
+ // Stale lock, try to remove and retry
137
+ try {
138
+ fs.unlinkSync(this.lockFile);
139
+ return this.acquire();
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+ /**
149
+ * Release the agent lock.
150
+ */
151
+ release() {
152
+ if (!this.isHeldByMe) {
153
+ return;
154
+ }
155
+ try {
156
+ fs.unlinkSync(this.lockFile);
157
+ }
158
+ catch {
159
+ // Ignore errors during cleanup
160
+ }
161
+ this.isHeldByMe = false;
162
+ }
163
+ /**
164
+ * Update the lock timestamp (heartbeat).
165
+ */
166
+ heartbeat() {
167
+ if (!this.isHeldByMe || !fs.existsSync(this.lockFile)) {
168
+ return;
169
+ }
170
+ try {
171
+ const content = fs.readFileSync(this.lockFile, "utf-8");
172
+ const lockInfo = JSON.parse(content);
173
+ lockInfo.timestamp = Date.now();
174
+ fs.writeFileSync(this.lockFile, JSON.stringify(lockInfo, null, 2));
175
+ }
176
+ catch {
177
+ // Ignore heartbeat errors
178
+ }
179
+ }
180
+ /**
181
+ * Check if a process exists.
182
+ */
183
+ processExists(pid) {
184
+ try {
185
+ process.kill(pid, 0);
186
+ return true;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ /**
193
+ * Check if a lock is stale.
194
+ */
195
+ isStale(lockInfo) {
196
+ const age = Date.now() - lockInfo.timestamp;
197
+ return age > AgentLock.STALE_TIMEOUT;
198
+ }
199
+ /**
200
+ * Get the current lock info.
201
+ */
202
+ getLockInfo() {
203
+ if (!fs.existsSync(this.lockFile)) {
204
+ return null;
205
+ }
206
+ try {
207
+ const content = fs.readFileSync(this.lockFile, "utf-8");
208
+ return JSON.parse(content);
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ /**
215
+ * Check if the lock file exists.
216
+ */
217
+ isLocked() {
218
+ return fs.existsSync(this.lockFile);
219
+ }
220
+ /**
221
+ * List all agent locks in a project.
222
+ */
223
+ static listLocks(projectDir) {
224
+ const lockDir = path.join(projectDir, ".husky", ".locks");
225
+ if (!fs.existsSync(lockDir)) {
226
+ return [];
227
+ }
228
+ const locks = [];
229
+ const entries = fs.readdirSync(lockDir);
230
+ for (const entry of entries) {
231
+ if (!entry.startsWith("agent-") || !entry.endsWith(".lock")) {
232
+ continue;
233
+ }
234
+ const lockFile = path.join(lockDir, entry);
235
+ try {
236
+ const content = fs.readFileSync(lockFile, "utf-8");
237
+ const info = JSON.parse(content);
238
+ // Check if stale
239
+ let isStale = false;
240
+ try {
241
+ process.kill(info.pid, 0);
242
+ // Process exists, check age
243
+ isStale = Date.now() - info.timestamp > AgentLock.STALE_TIMEOUT;
244
+ }
245
+ catch {
246
+ // Process doesn't exist
247
+ isStale = true;
248
+ }
249
+ locks.push({
250
+ worktree: info.worktreePath,
251
+ info,
252
+ isStale,
253
+ });
254
+ }
255
+ catch {
256
+ // Skip invalid lock files
257
+ }
258
+ }
259
+ return locks;
260
+ }
261
+ /**
262
+ * Clean up all stale agent locks.
263
+ */
264
+ static cleanupStale(projectDir) {
265
+ const lockDir = path.join(projectDir, ".husky", ".locks");
266
+ if (!fs.existsSync(lockDir)) {
267
+ return 0;
268
+ }
269
+ let cleaned = 0;
270
+ const entries = fs.readdirSync(lockDir);
271
+ for (const entry of entries) {
272
+ if (!entry.startsWith("agent-") || !entry.endsWith(".lock")) {
273
+ continue;
274
+ }
275
+ const lockFile = path.join(lockDir, entry);
276
+ try {
277
+ const content = fs.readFileSync(lockFile, "utf-8");
278
+ const info = JSON.parse(content);
279
+ let isStale = false;
280
+ try {
281
+ process.kill(info.pid, 0);
282
+ isStale = Date.now() - info.timestamp > AgentLock.STALE_TIMEOUT;
283
+ }
284
+ catch {
285
+ isStale = true;
286
+ }
287
+ if (isStale) {
288
+ fs.unlinkSync(lockFile);
289
+ cleaned++;
290
+ }
291
+ }
292
+ catch {
293
+ // Try to remove invalid lock files
294
+ try {
295
+ fs.unlinkSync(lockFile);
296
+ cleaned++;
297
+ }
298
+ catch {
299
+ // Ignore
300
+ }
301
+ }
302
+ }
303
+ return cleaned;
304
+ }
305
+ }
306
+ /**
307
+ * Helper to check if a worktree is in use by another agent.
308
+ */
309
+ export function checkWorktreeConflict(projectDir, worktreePath, workerId, sessionId) {
310
+ const lock = new AgentLock(projectDir, worktreePath, workerId, sessionId);
311
+ return lock.checkForConflict();
312
+ }
313
+ /**
314
+ * Helper to acquire an agent lock and run a function.
315
+ */
316
+ export async function withAgentLock(projectDir, worktreePath, workerId, sessionId, fn) {
317
+ const lock = new AgentLock(projectDir, worktreePath, workerId, sessionId);
318
+ if (!lock.acquire()) {
319
+ throw new AgentLockError(`Could not acquire agent lock for ${worktreePath}`);
320
+ }
321
+ // Set up heartbeat interval
322
+ const heartbeatInterval = setInterval(() => {
323
+ lock.heartbeat();
324
+ }, 60000); // Every minute
325
+ try {
326
+ return await fn();
327
+ }
328
+ finally {
329
+ clearInterval(heartbeatInterval);
330
+ lock.release();
331
+ }
332
+ }
@@ -115,7 +115,6 @@ export class WattizPlaywrightClient {
115
115
  if (!passwordInput) {
116
116
  throw new Error('Could not find password input field');
117
117
  }
118
- console.log(`Using selectors: email="${emailInput}", password="${passwordInput}"`);
119
118
  // Fill in login form
120
119
  await page.fill(emailInput, this.config.username);
121
120
  await page.fill(passwordInput, this.config.password);
@@ -142,24 +141,13 @@ export class WattizPlaywrightClient {
142
141
  await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { });
143
142
  // Check if logged in by looking for my-account page or logged-in indicators
144
143
  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
144
  const isLoggedIn = currentUrl.includes('/my-account') ||
152
145
  currentUrl.includes('/mon-compte') ||
153
146
  await page.locator('.account-link').count() > 0 ||
154
147
  await page.locator('[data-link-action="sign-out"]').count() > 0 ||
155
148
  await page.locator('.logout, .sign-out').count() > 0 ||
156
149
  await page.locator('a[href*="logout"]').count() > 0;
157
- console.log('Is logged in:', isLoggedIn);
158
150
  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
151
  return {
164
152
  success: false,
165
153
  cookies: '',
@@ -200,16 +188,38 @@ export class WattizPlaywrightClient {
200
188
  const count = await orderRows.count();
201
189
  for (let i = 0; i < count; i++) {
202
190
  const row = orderRows.nth(i);
203
- // PrestaShop order structure
191
+ // PrestaShop order structure - get link for order ID
204
192
  const orderLinkEl = row.locator('a[href*="order-detail"]').first();
205
193
  const orderLink = await orderLinkEl.getAttribute('href').catch(() => '');
206
194
  // Extract order ID from PrestaShop controller URL
207
195
  const orderIdMatch = orderLink?.match(/id_order=(\d+)/);
208
196
  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(() => '')) || '';
197
+ // Get all table cells
198
+ const cells = row.locator('td');
199
+ const cellCount = await cells.count();
200
+ // Wattiz order-history table structure (customized PrestaShop):
201
+ // Col 0: Date, Col 1: Total, Col 2: ?, Col 3: ?, Col 4: Status, Col 5: Actions
202
+ // Order reference is in the link or we use order ID
203
+ let orderNumber = orderId;
204
+ let date = '';
205
+ let total = '';
206
+ let status = '';
207
+ if (cellCount >= 2) {
208
+ // First cell contains Date
209
+ date = (await cells.nth(0).textContent().catch(() => '')) || '';
210
+ // Second cell contains Total
211
+ total = (await cells.nth(1).textContent().catch(() => '')) || '';
212
+ }
213
+ if (cellCount >= 5) {
214
+ // Status is in column 5 (index 4)
215
+ status = (await cells.nth(4).textContent().catch(() => '')) || '';
216
+ }
217
+ // Try to find actual order reference in row text
218
+ const rowText = await row.textContent().catch(() => '') || '';
219
+ const refMatch = rowText.match(/WATTIZ[A-Z0-9]+|[A-Z]{2,}[0-9]{6,}/i);
220
+ if (refMatch) {
221
+ orderNumber = refMatch[0];
222
+ }
213
223
  if (orderId) {
214
224
  orders.push({
215
225
  id: orderId,
@@ -226,62 +236,202 @@ export class WattizPlaywrightClient {
226
236
  async getOrder(orderId) {
227
237
  const page = await this.ensureBrowser();
228
238
  // 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');
239
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`, {
240
+ waitUntil: 'domcontentloaded',
241
+ timeout: 30000
242
+ });
243
+ await page.waitForLoadState('domcontentloaded').catch(() => { });
231
244
  // Check if we need to login
232
- const needsLogin = await page.url().includes('/login');
245
+ const needsLogin = page.url().includes('/login');
233
246
  if (needsLogin) {
234
247
  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');
248
+ await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`, {
249
+ waitUntil: 'domcontentloaded',
250
+ timeout: 30000
251
+ });
252
+ await page.waitForLoadState('domcontentloaded').catch(() => { });
237
253
  }
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
254
+ // Extract order information from Wattiz order detail page
255
+ // Use order ID as the order number (Wattiz doesn't display a separate reference)
256
+ const orderNumber = orderId;
257
+ let orderDate = '';
258
+ let orderStatus = '';
259
+ // Get visible page text for pattern matching
260
+ const pageText = await page.locator('body').textContent().catch(() => '') || '';
261
+ // Find date - look for format DD/MM/YYYY or YYYY-MM-DD
262
+ const dateMatches = pageText.match(/\b(\d{2}[\/\-]\d{2}[\/\-]20\d{2}|20\d{2}[\/\-]\d{2}[\/\-]\d{2})\b/g);
263
+ if (dateMatches && dateMatches.length > 0 && dateMatches[0]) {
264
+ orderDate = dateMatches[0];
265
+ }
266
+ // Get status - look for common shipping status text
267
+ const statusPatterns = ['Shipped', 'Delivered', 'Processing', 'Pending', 'Cancelled', 'En cours', 'Expédié', 'Livré'];
268
+ for (const pattern of statusPatterns) {
269
+ if (pageText.includes(pattern)) {
270
+ orderStatus = pattern;
271
+ break;
272
+ }
273
+ }
274
+ // Also try status elements if no pattern found
275
+ if (!orderStatus) {
276
+ const statusSelectors = ['.label.bright', '.badge', '.order-status'];
277
+ for (const selector of statusSelectors) {
278
+ const statusText = await page.locator(selector).first().textContent().catch(() => '');
279
+ if (statusText && statusText.length > 2 && statusText.length < 25 && !statusText.includes('€')) {
280
+ orderStatus = statusText.trim();
281
+ break;
282
+ }
283
+ }
284
+ }
285
+ // Extract customer info from address blocks
243
286
  let customerName = '';
244
287
  let customerAddress = '';
245
288
  let customerEmail = '';
246
289
  let customerPhone = '';
247
290
  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(() => '') || '';
291
+ // PrestaShop uses .address class for address blocks
292
+ const addressBlocks = page.locator('.address, article.address');
293
+ const blockCount = await addressBlocks.count();
294
+ for (let i = 0; i < blockCount; i++) {
295
+ const block = addressBlocks.nth(i);
296
+ const addressText = await block.textContent() || '';
297
+ const lines = addressText.split('\n').map(l => l.trim()).filter(l => l && l.length > 1);
298
+ // First line is usually the name
299
+ if (lines.length > 0 && !customerName) {
300
+ customerName = lines[0];
301
+ }
302
+ // Combine address lines (skip name, email, phone)
303
+ if (lines.length > 1 && !customerAddress) {
304
+ customerAddress = lines.slice(1)
305
+ .filter(l => !l.includes('@') && !l.match(/^\+?\d[\d\s\-]+$/))
306
+ .join(', ');
307
+ }
308
+ }
309
+ // Try to find customer email (exclude wattiz domain)
310
+ const mailLinks = await page.locator('[href^="mailto:"]').all();
311
+ for (const link of mailLinks) {
312
+ const href = await link.getAttribute('href').catch(() => '') || '';
313
+ const email = href.replace('mailto:', '');
314
+ if (email && !email.includes('wattiz')) {
315
+ customerEmail = email;
316
+ break;
317
+ }
318
+ }
319
+ // Fallback: look for email pattern in address blocks (exclude wattiz)
320
+ if (!customerEmail) {
321
+ for (let i = 0; i < blockCount; i++) {
322
+ const blockText = await addressBlocks.nth(i).textContent().catch(() => '') || '';
323
+ const emailMatch = blockText.match(/[\w.+-]+@[\w.-]+\.\w+/);
324
+ if (emailMatch && !emailMatch[0].includes('wattiz')) {
325
+ customerEmail = emailMatch[0];
326
+ break;
327
+ }
328
+ }
329
+ }
257
330
  customerPhone = await page.locator('[href^="tel:"]').first().textContent().catch(() => '') || '';
258
331
  }
259
332
  catch (error) {
260
333
  // Customer details not available
261
334
  }
262
- // Extract line items
335
+ // Extract line items from product table
336
+ // Look for the products section specifically
263
337
  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
- });
338
+ // Try different selectors for the product table
339
+ const productSelectors = [
340
+ '#order-products table tbody tr',
341
+ '.order-products tbody tr',
342
+ '[id*="product"] table tbody tr',
343
+ 'table.table-striped tbody tr'
344
+ ];
345
+ let productRows = null;
346
+ for (const selector of productSelectors) {
347
+ const rows = page.locator(selector);
348
+ const count = await rows.count();
349
+ if (count > 0) {
350
+ // Check if first row looks like a product (not a date or header)
351
+ const firstRowText = await rows.first().textContent().catch(() => '') || '';
352
+ if (!firstRowText.match(/^\d{4}[\/\-]\d{2}/) && firstRowText.length > 10) {
353
+ productRows = rows;
354
+ break;
355
+ }
356
+ }
357
+ }
358
+ if (productRows) {
359
+ const itemCount = await productRows.count();
360
+ for (let i = 0; i < itemCount; i++) {
361
+ const itemRow = productRows.nth(i);
362
+ const rowText = await itemRow.textContent() || '';
363
+ // Skip rows that look like dates or headers
364
+ if (rowText.match(/^\s*\d{4}[\/\-]\d{2}/) || rowText.includes('Product') || rowText.trim().length < 5) {
365
+ continue;
366
+ }
367
+ const cells = itemRow.locator('td');
368
+ const cellCount = await cells.count();
369
+ if (cellCount >= 2) {
370
+ const nameCell = await cells.nth(0).textContent() || '';
371
+ const name = nameCell.replace(/\s+/g, ' ').trim();
372
+ let qty = '1';
373
+ let total = '';
374
+ // Find quantity and price cells
375
+ for (let c = 1; c < cellCount; c++) {
376
+ const cellText = (await cells.nth(c).textContent() || '').trim();
377
+ if (cellText.match(/^\d+$/) && parseInt(cellText, 10) < 1000) {
378
+ qty = cellText;
379
+ }
380
+ else if (cellText.includes('€') || cellText.includes('$')) {
381
+ total = cellText;
382
+ }
383
+ }
384
+ // Only add if name looks like a product
385
+ if (name && name.length > 5 && !name.match(/^\d{4}[\/\-]/)) {
386
+ items.push({
387
+ sku: '',
388
+ name: name,
389
+ quantity: parseInt(qty, 10) || 1,
390
+ price: '',
391
+ total: total,
392
+ });
393
+ }
394
+ }
395
+ }
278
396
  }
279
397
  // 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(() => '');
398
+ const invoiceLink = await page.locator('a[href*="pdf-invoice"], a[href*="get-invoice"], a[href*="invoice"]').first().getAttribute('href').catch(() => '');
399
+ // Extract total - look for price patterns in page
400
+ let totalText = '';
401
+ // Look for total amount in page text
402
+ const priceMatches = pageText.match(/€\s*[\d,]+\.?\d*/g) || [];
403
+ if (priceMatches.length > 0) {
404
+ // Get the largest price as total (usually the order total)
405
+ let maxPrice = 0;
406
+ let maxPriceText = '';
407
+ for (const priceText of priceMatches) {
408
+ const value = parseFloat(priceText.replace('€', '').replace(/\s/g, '').replace(',', '.'));
409
+ if (value > maxPrice) {
410
+ maxPrice = value;
411
+ maxPriceText = priceText.trim();
412
+ }
413
+ }
414
+ if (maxPriceText) {
415
+ totalText = maxPriceText;
416
+ }
417
+ }
418
+ // Fallback: try specific selectors
419
+ if (!totalText) {
420
+ const totalSelectors = [
421
+ '.order-totals tr:last-child td:last-child',
422
+ '.total-value',
423
+ 'table tfoot td:last-child'
424
+ ];
425
+ for (const selector of totalSelectors) {
426
+ const text = await page.locator(selector).last().textContent().catch(() => '');
427
+ if (text && text.includes('€')) {
428
+ totalText = text.trim();
429
+ break;
430
+ }
431
+ }
432
+ }
283
433
  // Extract tracking number if available
284
- const trackingNumber = await page.locator('.tracking-number, [href*="track"]').first().textContent().catch(() => '');
434
+ const trackingNumber = await page.locator('.tracking-number, [href*="track"], [data-tracking]').first().textContent().catch(() => '');
285
435
  return {
286
436
  id: orderId,
287
437
  orderNumber: orderNumber?.trim() || orderId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {