feinai 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db.ts CHANGED
@@ -16,7 +16,7 @@ const SCHEMA_STATEMENTS: string[] = [
16
16
  id TEXT PRIMARY KEY,
17
17
  numero INTEGER,
18
18
  title TEXT NOT NULL,
19
- status TEXT NOT NULL DEFAULT 'lista',
19
+ status TEXT NOT NULL DEFAULT 'pending',
20
20
  content TEXT,
21
21
  pr TEXT,
22
22
  merged_date TEXT,
@@ -133,6 +133,20 @@ const MIGRATIONS: Array<{ version: number; stmts: string[] }> = [
133
133
  `ALTER TABLE tasks ADD COLUMN worktree TEXT`,
134
134
  ],
135
135
  },
136
+ {
137
+ version: 3,
138
+ stmts: [
139
+ // Normalize spec status names from Spanish to English.
140
+ `UPDATE specs SET status = CASE status
141
+ WHEN 'lista' THEN 'pending'
142
+ WHEN 'en_progreso' THEN 'in_progress'
143
+ WHEN 'hecha' THEN 'completed'
144
+ WHEN 'archivada' THEN 'archived'
145
+ ELSE status
146
+ END
147
+ WHERE status IN ('lista', 'en_progreso', 'hecha', 'archivada')`,
148
+ ],
149
+ },
136
150
  ];
137
151
 
138
152
  function applyMigrations(db: DbInstance): void {
package/src/format.ts CHANGED
@@ -26,10 +26,7 @@ const STATUS_COLOR: Record<string, keyof typeof COLORS> = {
26
26
  completed: "green",
27
27
  failed: "red",
28
28
  deleted: "gray",
29
- lista: "yellow",
30
- en_progreso: "blue",
31
- hecha: "green",
32
- archivada: "gray",
29
+ archived: "gray",
33
30
  };
34
31
 
35
32
  export function formatTask(task: Task, format: OutputFormat): string {
@@ -1,5 +1,6 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { resolve } from "node:path";
3
4
  import type { DbInstance } from "./db";
4
5
 
5
6
  export interface ServerState {
@@ -105,17 +106,16 @@ function isPidRunning(pid: number): boolean {
105
106
  * Get the port that a specific pid is listening on.
106
107
  * Returns the port number or null if the pid has no tcp listeners.
107
108
  */
108
- function portOfPid(pid: number): number | null {
109
+ export function portOfPid(pid: number): number | null {
109
110
  try {
110
- const result = spawnSync("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-p", String(pid)]);
111
+ // Use -a so that -p and -i are ANDed together, otherwise lsof returns
112
+ // all internet listeners OR all files of pid.
113
+ const result = spawnSync("lsof", ["-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
111
114
  if (result.status !== 0) return null;
112
- // Parse lsof output: find "TCP *:{port}" pattern
113
115
  const out = result.stdout.toString();
114
- const match = out.match(/TCP\s+\*:(\d+)/);
116
+ // Parse "TCP {host}:{port} (LISTEN)" or "TCP *:{port} (LISTEN)"
117
+ const match = out.match(/TCP\s+[^\s:]+:(\d+)\s+\(LISTEN\)/);
115
118
  if (match) return parseInt(match[1]!, 10);
116
- // Alternate format: "LISTEN" followed by port
117
- const altMatch = out.match(/:(\d+)\s.*LISTEN/);
118
- if (altMatch) return parseInt(altMatch[1]!, 10);
119
119
  return null;
120
120
  } catch {
121
121
  return null;
@@ -178,3 +178,67 @@ export function repairServerState(db: DbInstance): ServerState | null {
178
178
  const { valid, record } = validateServerState(db);
179
179
  return valid ? record : null;
180
180
  }
181
+
182
+ export interface OrphanKillResult {
183
+ pid: number;
184
+ port: number | null;
185
+ }
186
+
187
+ /**
188
+ * Kill any TCP-listening processes whose current working directory matches
189
+ * the project directory, regardless of whether they are recorded in the DB.
190
+ *
191
+ * Used by `feinai server --down` to clean up orphan/unregistered servers.
192
+ * Returns a list of killed pids (and their listening port, when detectable).
193
+ */
194
+ export function killOrphansByProjectDir(projectDir: string): OrphanKillResult[] {
195
+ const killed: OrphanKillResult[] = [];
196
+ const normalizedProject = resolve(projectDir);
197
+
198
+ // Collect all pids that are listening on any TCP port.
199
+ const listenerPids = new Set<number>();
200
+ try {
201
+ const result = spawnSync("lsof", ["-t", "-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
202
+ if (result.status !== 0) return killed;
203
+ result.stdout
204
+ .toString()
205
+ .trim()
206
+ .split("\n")
207
+ .forEach((line) => {
208
+ const pid = parseInt(line.trim(), 10);
209
+ if (!isNaN(pid)) listenerPids.add(pid);
210
+ });
211
+ } catch {
212
+ return killed;
213
+ }
214
+
215
+ for (const pid of listenerPids) {
216
+ if (pid === process.pid) continue;
217
+
218
+ let cwd: string | null = null;
219
+ try {
220
+ if (process.platform === "linux") {
221
+ cwd = realpathSync(`/proc/${pid}/cwd`, { encoding: "utf8" });
222
+ } else {
223
+ const result = spawnSync("lsof", ["-a", "-d", "cwd", "-p", String(pid), "-F", "n"]);
224
+ if (result.status !== 0) continue;
225
+ const match = result.stdout.toString().match(/^n(.+)$/m);
226
+ if (match) cwd = match[1]!;
227
+ }
228
+ } catch {
229
+ continue;
230
+ }
231
+
232
+ if (!cwd) continue;
233
+ if (resolve(cwd) !== normalizedProject) continue;
234
+
235
+ try {
236
+ process.kill(pid, "SIGTERM");
237
+ killed.push({ pid, port: portOfPid(pid) });
238
+ } catch {
239
+ // Process may have exited between discovery and signal; ignore.
240
+ }
241
+ }
242
+
243
+ return killed;
244
+ }
package/src/specs.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DbInstance } from "./db";
2
2
  import { recordEvent } from "./db";
3
3
 
4
- export type SpecStatus = "lista" | "en_progreso" | "hecha" | "archivada";
4
+ export type SpecStatus = "pending" | "in_progress" | "completed" | "archived";
5
5
 
6
6
  export interface Spec {
7
7
  id: string;
@@ -51,9 +51,9 @@ export function addSpec(db: DbInstance, input: AddSpecInput, actor: string | nul
51
51
  }
52
52
 
53
53
  db.prepare(
54
- `INSERT INTO specs (id, numero, title, content)
55
- VALUES (?, ?, ?, ?)`,
56
- ).run(input.id, numero ?? null, input.title, input.content ?? null);
54
+ `INSERT INTO specs (id, numero, title, status, content)
55
+ VALUES (?, ?, ?, ?, ?)`,
56
+ ).run(input.id, numero ?? null, input.title, "pending", input.content ?? null);
57
57
 
58
58
  recordEvent(db, "spec", input.id, "created", { title: input.title }, actor);
59
59
 
@@ -63,8 +63,8 @@ export function addSpec(db: DbInstance, input: AddSpecInput, actor: string | nul
63
63
  export function startSpec(db: DbInstance, id: string, actor: string | null = null): Spec {
64
64
  const upd = db
65
65
  .prepare(
66
- `UPDATE specs SET status = 'en_progreso', updated_at = datetime('now')
67
- WHERE id = ? AND status = 'lista'`,
66
+ `UPDATE specs SET status = 'in_progress', updated_at = datetime('now')
67
+ WHERE id = ? AND status = 'pending'`,
68
68
  )
69
69
  .run(id);
70
70
 
@@ -86,7 +86,7 @@ export function doneSpec(
86
86
  ): Spec {
87
87
  const upd = db
88
88
  .prepare(
89
- `UPDATE specs SET status = 'hecha',
89
+ `UPDATE specs SET status = 'completed',
90
90
  pr = COALESCE(?, pr),
91
91
  merged_date = COALESCE(?, merged_date),
92
92
  updated_at = datetime('now')
@@ -228,7 +228,7 @@ export function archiveSpec(
228
228
 
229
229
  const upd = db
230
230
  .prepare(
231
- `UPDATE specs SET status = 'archivada', updated_at = datetime('now') WHERE id = ?`,
231
+ `UPDATE specs SET status = 'archived', updated_at = datetime('now') WHERE id = ?`,
232
232
  )
233
233
  .run(id);
234
234
 
@@ -256,7 +256,7 @@ export function unarchiveSpec(
256
256
 
257
257
  const upd = db
258
258
  .prepare(
259
- `UPDATE specs SET status = 'lista', updated_at = datetime('now') WHERE id = ? AND status = 'archivada'`,
259
+ `UPDATE specs SET status = 'pending', updated_at = datetime('now') WHERE id = ? AND status = 'archived'`,
260
260
  )
261
261
  .run(id);
262
262
 
package/src/tasks.ts CHANGED
@@ -80,7 +80,7 @@ export function listTasks(db: DbInstance, filter: ListFilter = {}): Task[] {
80
80
  }
81
81
 
82
82
  // Exclude tasks from archived specs so archived work leaves the active task list
83
- where.push("(spec_id IS NULL OR spec_id NOT IN (SELECT id FROM specs WHERE status = 'archivada'))");
83
+ where.push("(spec_id IS NULL OR spec_id NOT IN (SELECT id FROM specs WHERE status = 'archived'))");
84
84
 
85
85
  const sql =
86
86
  `SELECT * FROM tasks` +
@@ -163,7 +163,7 @@ export function takeTask(
163
163
 
164
164
  if (task.spec_id) {
165
165
  const spec = getSpec(db, task.spec_id);
166
- if (spec && spec.status === 'lista') {
166
+ if (spec && spec.status === 'pending') {
167
167
  startSpec(db, task.spec_id, owner);
168
168
  }
169
169
  }