@bretwardjames/tw-bridge 0.3.0 → 0.5.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/dist/cli.js +686 -69
- package/dist/extensions/bridge.js +219 -0
- package/dist/hooks/on-modify.js +570 -27
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
4
|
+
import fs4 from "fs";
|
|
5
|
+
import path5 from "path";
|
|
6
|
+
import os4 from "os";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
9
9
|
import fs from "fs";
|
|
@@ -30,7 +30,7 @@ function loadConfig() {
|
|
|
30
30
|
function saveConfig(config) {
|
|
31
31
|
const configPath = findConfigPath() ?? CONFIG_PATHS[0];
|
|
32
32
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
33
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
33
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
34
34
|
return configPath;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -125,9 +125,11 @@ var GhpAdapter = class {
|
|
|
125
125
|
process.stderr.write("tw-bridge [ghp]: task has no backend_id, skipping ghp start\n");
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
128
|
-
process.
|
|
128
|
+
const parallel = process.env.TW_BRIDGE_MODE === "parallel";
|
|
129
|
+
const args = parallel ? ["start", issueNumber, "--parallel"] : ["start", issueNumber];
|
|
130
|
+
process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}${parallel ? " (parallel)" : ""}
|
|
129
131
|
`);
|
|
130
|
-
const result = spawnSync("ghp",
|
|
132
|
+
const result = spawnSync("ghp", args, {
|
|
131
133
|
cwd: this.config.cwd,
|
|
132
134
|
stdio: [ttyFd, ttyFd, ttyFd]
|
|
133
135
|
});
|
|
@@ -150,9 +152,430 @@ var GhpAdapter = class {
|
|
|
150
152
|
}
|
|
151
153
|
};
|
|
152
154
|
|
|
155
|
+
// src/adapters/asana.ts
|
|
156
|
+
import fs2 from "fs";
|
|
157
|
+
import path3 from "path";
|
|
158
|
+
import os2 from "os";
|
|
159
|
+
import { execSync } from "child_process";
|
|
160
|
+
import { createInterface } from "readline/promises";
|
|
161
|
+
import { stdin, stdout } from "process";
|
|
162
|
+
var API_BASE = "https://app.asana.com/api/1.0";
|
|
163
|
+
var OAUTH_AUTHORIZE = "https://app.asana.com/-/oauth_authorize";
|
|
164
|
+
var OAUTH_TOKEN = "https://app.asana.com/-/oauth_token";
|
|
165
|
+
var OOB_REDIRECT = "urn:ietf:wg:oauth:2.0:oob";
|
|
166
|
+
var TOKEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", "asana-tokens.json");
|
|
167
|
+
function loadTokens() {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(fs2.readFileSync(TOKEN_FILE, "utf-8"));
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function saveTokens(tokens) {
|
|
175
|
+
fs2.mkdirSync(path3.dirname(TOKEN_FILE), { recursive: true });
|
|
176
|
+
fs2.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 384 });
|
|
177
|
+
}
|
|
178
|
+
async function refreshAccessToken(tokens) {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (tokens.access_token && tokens.expires_at > now + 3e5) {
|
|
181
|
+
return tokens.access_token;
|
|
182
|
+
}
|
|
183
|
+
const res = await fetch(OAUTH_TOKEN, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
186
|
+
body: new URLSearchParams({
|
|
187
|
+
grant_type: "refresh_token",
|
|
188
|
+
client_id: tokens.client_id,
|
|
189
|
+
client_secret: tokens.client_secret,
|
|
190
|
+
refresh_token: tokens.refresh_token
|
|
191
|
+
})
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
const body = await res.text();
|
|
195
|
+
throw new Error(`Token refresh failed (${res.status}): ${body}`);
|
|
196
|
+
}
|
|
197
|
+
const data = await res.json();
|
|
198
|
+
tokens.access_token = data.access_token;
|
|
199
|
+
tokens.expires_at = now + (data.expires_in ?? 3600) * 1e3;
|
|
200
|
+
saveTokens(tokens);
|
|
201
|
+
return tokens.access_token;
|
|
202
|
+
}
|
|
203
|
+
async function oauthLogin(clientId, clientSecret, rl) {
|
|
204
|
+
const authUrl = `${OAUTH_AUTHORIZE}?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(OOB_REDIRECT)}&response_type=code`;
|
|
205
|
+
console.log(`
|
|
206
|
+
Opening browser for authorization...`);
|
|
207
|
+
console.log(` If it doesn't open, visit:
|
|
208
|
+
${authUrl}
|
|
209
|
+
`);
|
|
210
|
+
try {
|
|
211
|
+
execSync(`xdg-open "${authUrl}" 2>/dev/null || open "${authUrl}" 2>/dev/null`, {
|
|
212
|
+
stdio: "ignore"
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
const code = (await rl.question(" Paste the authorization code: ")).trim();
|
|
217
|
+
if (!code) throw new Error("No authorization code provided");
|
|
218
|
+
const tokenRes = await fetch(OAUTH_TOKEN, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
221
|
+
body: new URLSearchParams({
|
|
222
|
+
grant_type: "authorization_code",
|
|
223
|
+
client_id: clientId,
|
|
224
|
+
client_secret: clientSecret,
|
|
225
|
+
redirect_uri: OOB_REDIRECT,
|
|
226
|
+
code
|
|
227
|
+
})
|
|
228
|
+
});
|
|
229
|
+
if (!tokenRes.ok) {
|
|
230
|
+
const body = await tokenRes.text();
|
|
231
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
|
|
232
|
+
}
|
|
233
|
+
const data = await tokenRes.json();
|
|
234
|
+
const tokens = {
|
|
235
|
+
access_token: data.access_token,
|
|
236
|
+
refresh_token: data.refresh_token,
|
|
237
|
+
expires_at: Date.now() + (data.expires_in ?? 3600) * 1e3,
|
|
238
|
+
client_id: clientId,
|
|
239
|
+
client_secret: clientSecret
|
|
240
|
+
};
|
|
241
|
+
saveTokens(tokens);
|
|
242
|
+
return tokens;
|
|
243
|
+
}
|
|
244
|
+
async function asanaFetch(path6, token, options = {}) {
|
|
245
|
+
const url = `${API_BASE}${path6}`;
|
|
246
|
+
const res = await fetch(url, {
|
|
247
|
+
...options,
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${token}`,
|
|
250
|
+
"Content-Type": "application/json",
|
|
251
|
+
...options.headers
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
const body = await res.text();
|
|
256
|
+
throw new Error(`Asana API ${res.status}: ${body}`);
|
|
257
|
+
}
|
|
258
|
+
const json = await res.json();
|
|
259
|
+
return json.data;
|
|
260
|
+
}
|
|
261
|
+
async function asanaFetchAll(path6, token) {
|
|
262
|
+
const results = [];
|
|
263
|
+
let nextPage = path6;
|
|
264
|
+
while (nextPage) {
|
|
265
|
+
const url = `${API_BASE}${nextPage}`;
|
|
266
|
+
const res = await fetch(url, {
|
|
267
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
268
|
+
});
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
const body = await res.text();
|
|
271
|
+
throw new Error(`Asana API ${res.status}: ${body}`);
|
|
272
|
+
}
|
|
273
|
+
const json = await res.json();
|
|
274
|
+
results.push(...json.data);
|
|
275
|
+
if (json.next_page?.path) {
|
|
276
|
+
nextPage = json.next_page.path;
|
|
277
|
+
} else {
|
|
278
|
+
nextPage = null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
async function prompt(rl, question) {
|
|
284
|
+
return rl.question(question);
|
|
285
|
+
}
|
|
286
|
+
async function pickOne(rl, label, items) {
|
|
287
|
+
console.log(`
|
|
288
|
+
${label}:`);
|
|
289
|
+
for (let i = 0; i < items.length; i++) {
|
|
290
|
+
console.log(` ${i + 1}) ${items[i].name}`);
|
|
291
|
+
}
|
|
292
|
+
while (true) {
|
|
293
|
+
const answer = await prompt(rl, `Choose (1-${items.length}): `);
|
|
294
|
+
const idx = parseInt(answer, 10) - 1;
|
|
295
|
+
if (idx >= 0 && idx < items.length) return items[idx];
|
|
296
|
+
console.log(" Invalid choice, try again.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
var AsanaAdapter = class {
|
|
300
|
+
name = "asana";
|
|
301
|
+
config;
|
|
302
|
+
accessToken = null;
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the access token based on auth config.
|
|
305
|
+
* - "oauth": load from token file, refresh if needed
|
|
306
|
+
* - "env:VAR": read from environment
|
|
307
|
+
* - anything else: treat as raw PAT
|
|
308
|
+
*/
|
|
309
|
+
async resolveToken() {
|
|
310
|
+
const auth = this.config.auth;
|
|
311
|
+
if (auth === "oauth") {
|
|
312
|
+
const tokens = loadTokens();
|
|
313
|
+
if (!tokens) throw new Error("No OAuth tokens found. Run `tw-bridge add` to authenticate.");
|
|
314
|
+
this.accessToken = await refreshAccessToken(tokens);
|
|
315
|
+
return this.accessToken;
|
|
316
|
+
}
|
|
317
|
+
if (this.accessToken) return this.accessToken;
|
|
318
|
+
if (auth.startsWith("env:")) {
|
|
319
|
+
const envVar = auth.slice(4);
|
|
320
|
+
const val = process.env[envVar];
|
|
321
|
+
if (!val) throw new Error(`Environment variable ${envVar} not set`);
|
|
322
|
+
this.accessToken = val;
|
|
323
|
+
return val;
|
|
324
|
+
}
|
|
325
|
+
this.accessToken = auth;
|
|
326
|
+
return auth;
|
|
327
|
+
}
|
|
328
|
+
defaultConfig(_cwd) {
|
|
329
|
+
return {
|
|
330
|
+
auth: "env:ASANA_TOKEN",
|
|
331
|
+
workspace_gid: "",
|
|
332
|
+
project_gid: "",
|
|
333
|
+
project_name: "",
|
|
334
|
+
sections: [],
|
|
335
|
+
done_sections: []
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async setup(_cwd) {
|
|
339
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
340
|
+
try {
|
|
341
|
+
console.log("\n\u2500\u2500 Asana Adapter Setup \u2500\u2500\n");
|
|
342
|
+
console.log("Authentication:");
|
|
343
|
+
console.log(" 1) OAuth (browser login)");
|
|
344
|
+
console.log(" 2) Personal Access Token");
|
|
345
|
+
console.log(" 3) Environment variable");
|
|
346
|
+
const authChoice = await prompt(rl, "\nChoose (1-3): ");
|
|
347
|
+
let authConfig;
|
|
348
|
+
let token;
|
|
349
|
+
if (authChoice.trim() === "1") {
|
|
350
|
+
const clientId = (await prompt(rl, "\nAsana App Client ID: ")).trim();
|
|
351
|
+
const clientSecret = (await prompt(rl, "Asana App Client Secret: ")).trim();
|
|
352
|
+
if (!clientId || !clientSecret) {
|
|
353
|
+
console.error("\n Client ID and Secret are required.");
|
|
354
|
+
console.error(" Create an app at: https://app.asana.com/0/my-apps");
|
|
355
|
+
console.error(' Check "This is a native or command-line app"');
|
|
356
|
+
rl.close();
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
const tokens = await oauthLogin(clientId, clientSecret, rl);
|
|
360
|
+
token = tokens.access_token;
|
|
361
|
+
authConfig = "oauth";
|
|
362
|
+
} else if (authChoice.trim() === "3") {
|
|
363
|
+
const envVar = await prompt(rl, "Environment variable name [ASANA_TOKEN]: ");
|
|
364
|
+
const varName = envVar.trim() || "ASANA_TOKEN";
|
|
365
|
+
authConfig = `env:${varName}`;
|
|
366
|
+
token = process.env[varName] ?? "";
|
|
367
|
+
if (!token) {
|
|
368
|
+
console.log(`
|
|
369
|
+
Warning: ${varName} is not currently set.`);
|
|
370
|
+
const tempToken = await prompt(rl, " Paste a temporary token for setup (or Enter to skip): ");
|
|
371
|
+
token = tempToken.trim();
|
|
372
|
+
if (!token) {
|
|
373
|
+
console.error(`
|
|
374
|
+
Cannot complete setup without a valid token.`);
|
|
375
|
+
console.error(` Set ${varName} in your environment and try again.`);
|
|
376
|
+
rl.close();
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
token = (await prompt(rl, "Paste your Personal Access Token: ")).trim();
|
|
382
|
+
authConfig = token;
|
|
383
|
+
}
|
|
384
|
+
console.log("\nVerifying...");
|
|
385
|
+
let me;
|
|
386
|
+
try {
|
|
387
|
+
me = await asanaFetch("/users/me", token);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
390
|
+
console.error(` Authentication failed: ${msg}`);
|
|
391
|
+
rl.close();
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
console.log(` Authenticated as: ${me.name} (${me.email})`);
|
|
395
|
+
const workspaces = await asanaFetch("/workspaces", token);
|
|
396
|
+
let workspace;
|
|
397
|
+
if (workspaces.length === 1) {
|
|
398
|
+
workspace = workspaces[0];
|
|
399
|
+
console.log(`
|
|
400
|
+
Workspace: ${workspace.name}`);
|
|
401
|
+
} else {
|
|
402
|
+
workspace = await pickOne(rl, "Select workspace", workspaces);
|
|
403
|
+
}
|
|
404
|
+
const projects = await asanaFetchAll(
|
|
405
|
+
`/workspaces/${workspace.gid}/projects?limit=100&archived=false`,
|
|
406
|
+
token
|
|
407
|
+
);
|
|
408
|
+
if (projects.length === 0) {
|
|
409
|
+
console.error("\n No projects found in this workspace.");
|
|
410
|
+
rl.close();
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
const project = await pickOne(rl, "Select project", projects);
|
|
414
|
+
const sections = await asanaFetch(
|
|
415
|
+
`/projects/${project.gid}/sections`,
|
|
416
|
+
token
|
|
417
|
+
);
|
|
418
|
+
console.log(`
|
|
419
|
+
Sections in "${project.name}":`);
|
|
420
|
+
console.log("Map each section to a Taskwarrior tag (or press Enter for default).\n");
|
|
421
|
+
const sectionMappings = [];
|
|
422
|
+
const doneSections = [];
|
|
423
|
+
for (const section of sections) {
|
|
424
|
+
const defaultTag = section.name.toLowerCase().replace(/\s+/g, "_");
|
|
425
|
+
const tag = await prompt(rl, ` "${section.name}" \u2192 tag [${defaultTag}]: `);
|
|
426
|
+
const finalTag = tag.trim() || defaultTag;
|
|
427
|
+
sectionMappings.push({
|
|
428
|
+
gid: section.gid,
|
|
429
|
+
name: section.name,
|
|
430
|
+
tag: finalTag
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
console.log("\nWhich sections represent completed tasks?");
|
|
434
|
+
for (let i = 0; i < sectionMappings.length; i++) {
|
|
435
|
+
console.log(` ${i + 1}) ${sectionMappings[i].name} (${sectionMappings[i].tag})`);
|
|
436
|
+
}
|
|
437
|
+
const doneAnswer = await prompt(rl, "Enter numbers separated by commas (e.g., 4,5): ");
|
|
438
|
+
for (const num of doneAnswer.split(",")) {
|
|
439
|
+
const idx = parseInt(num.trim(), 10) - 1;
|
|
440
|
+
if (idx >= 0 && idx < sectionMappings.length) {
|
|
441
|
+
doneSections.push(sectionMappings[idx].gid);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
console.log("\nWhich section should tasks move to on `task start`?");
|
|
445
|
+
for (let i = 0; i < sectionMappings.length; i++) {
|
|
446
|
+
console.log(` ${i + 1}) ${sectionMappings[i].name}`);
|
|
447
|
+
}
|
|
448
|
+
console.log(` 0) Don't move (no action on start)`);
|
|
449
|
+
const startAnswer = await prompt(rl, "Choose: ");
|
|
450
|
+
const startIdx = parseInt(startAnswer.trim(), 10) - 1;
|
|
451
|
+
const startSectionGid = startIdx >= 0 && startIdx < sectionMappings.length ? sectionMappings[startIdx].gid : void 0;
|
|
452
|
+
rl.close();
|
|
453
|
+
console.log(`
|
|
454
|
+
Project: ${project.name}`);
|
|
455
|
+
console.log(` Sections: ${sectionMappings.map((s) => s.tag).join(", ")}`);
|
|
456
|
+
console.log(` Done: ${doneSections.length} section(s)`);
|
|
457
|
+
if (startSectionGid) {
|
|
458
|
+
const startSection = sectionMappings.find((s) => s.gid === startSectionGid);
|
|
459
|
+
console.log(` On start: move to "${startSection?.name}"`);
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
auth: authConfig,
|
|
463
|
+
workspace_gid: workspace.gid,
|
|
464
|
+
project_gid: project.gid,
|
|
465
|
+
project_name: project.name,
|
|
466
|
+
sections: sectionMappings,
|
|
467
|
+
done_sections: doneSections,
|
|
468
|
+
start_section_gid: startSectionGid
|
|
469
|
+
};
|
|
470
|
+
} catch (err) {
|
|
471
|
+
rl.close();
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async init(config) {
|
|
476
|
+
this.config = config;
|
|
477
|
+
if (!this.config.project_gid) {
|
|
478
|
+
throw new Error('asana adapter requires "project_gid" in config');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async pull() {
|
|
482
|
+
const token = await this.resolveToken();
|
|
483
|
+
const fields = [
|
|
484
|
+
"gid",
|
|
485
|
+
"name",
|
|
486
|
+
"completed",
|
|
487
|
+
"completed_at",
|
|
488
|
+
"assignee",
|
|
489
|
+
"assignee.name",
|
|
490
|
+
"memberships.project",
|
|
491
|
+
"memberships.project.name",
|
|
492
|
+
"memberships.section",
|
|
493
|
+
"memberships.section.name",
|
|
494
|
+
"permalink_url",
|
|
495
|
+
"custom_fields",
|
|
496
|
+
"custom_fields.name",
|
|
497
|
+
"custom_fields.display_value"
|
|
498
|
+
].join(",");
|
|
499
|
+
const asanaTasks = await asanaFetchAll(
|
|
500
|
+
`/projects/${this.config.project_gid}/tasks?opt_fields=${fields}&limit=100`,
|
|
501
|
+
token
|
|
502
|
+
);
|
|
503
|
+
const tasks = [];
|
|
504
|
+
const sectionMap = new Map(
|
|
505
|
+
this.config.sections.map((s) => [s.gid, s])
|
|
506
|
+
);
|
|
507
|
+
for (const at of asanaTasks) {
|
|
508
|
+
if (at.completed) continue;
|
|
509
|
+
const membership = at.memberships?.find(
|
|
510
|
+
(m) => m.project.gid === this.config.project_gid
|
|
511
|
+
);
|
|
512
|
+
const sectionGid = membership?.section?.gid;
|
|
513
|
+
const sectionMapping = sectionGid ? sectionMap.get(sectionGid) : null;
|
|
514
|
+
const tags = [];
|
|
515
|
+
if (sectionMapping) tags.push(sectionMapping.tag);
|
|
516
|
+
const priority = mapAsanaPriority(at.custom_fields);
|
|
517
|
+
const task = {
|
|
518
|
+
uuid: "",
|
|
519
|
+
description: at.name,
|
|
520
|
+
status: "pending",
|
|
521
|
+
entry: (/* @__PURE__ */ new Date()).toISOString(),
|
|
522
|
+
project: this.config.project_name,
|
|
523
|
+
tags,
|
|
524
|
+
priority,
|
|
525
|
+
backend: "",
|
|
526
|
+
backend_id: at.gid,
|
|
527
|
+
annotations: at.permalink_url ? [{ entry: (/* @__PURE__ */ new Date()).toISOString(), description: at.permalink_url }] : void 0
|
|
528
|
+
};
|
|
529
|
+
tasks.push(task);
|
|
530
|
+
}
|
|
531
|
+
return tasks;
|
|
532
|
+
}
|
|
533
|
+
async onStart(task, _ttyFd) {
|
|
534
|
+
if (!this.config.start_section_gid) return;
|
|
535
|
+
const taskGid = task.backend_id;
|
|
536
|
+
if (!taskGid) return;
|
|
537
|
+
const token = await this.resolveToken();
|
|
538
|
+
const startSection = this.config.sections.find(
|
|
539
|
+
(s) => s.gid === this.config.start_section_gid
|
|
540
|
+
);
|
|
541
|
+
process.stderr.write(
|
|
542
|
+
`tw-bridge [asana]: moving task to "${startSection?.name ?? "In Progress"}"
|
|
543
|
+
`
|
|
544
|
+
);
|
|
545
|
+
await asanaFetch(`/sections/${this.config.start_section_gid}/addTask`, token, {
|
|
546
|
+
method: "POST",
|
|
547
|
+
body: JSON.stringify({ data: { task: taskGid } })
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async onDone(task) {
|
|
551
|
+
const taskGid = task.backend_id;
|
|
552
|
+
if (!taskGid) return;
|
|
553
|
+
const token = await this.resolveToken();
|
|
554
|
+
process.stderr.write(`tw-bridge [asana]: marking task complete
|
|
555
|
+
`);
|
|
556
|
+
await asanaFetch(`/tasks/${taskGid}`, token, {
|
|
557
|
+
method: "PUT",
|
|
558
|
+
body: JSON.stringify({ data: { completed: true } })
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
function mapAsanaPriority(fields) {
|
|
563
|
+
if (!fields) return void 0;
|
|
564
|
+
const pField = fields.find(
|
|
565
|
+
(f) => f.name.toLowerCase() === "priority" && f.display_value
|
|
566
|
+
);
|
|
567
|
+
if (!pField?.display_value) return void 0;
|
|
568
|
+
const val = pField.display_value.toLowerCase();
|
|
569
|
+
if (val.startsWith("high") || val === "urgent" || val === "p0" || val === "p1") return "H";
|
|
570
|
+
if (val.startsWith("med") || val === "p2") return "M";
|
|
571
|
+
if (val.startsWith("low") || val === "p3" || val === "p4") return "L";
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
574
|
+
|
|
153
575
|
// src/registry.ts
|
|
154
576
|
var BUILTIN_ADAPTERS = {
|
|
155
|
-
ghp: () => new GhpAdapter()
|
|
577
|
+
ghp: () => new GhpAdapter(),
|
|
578
|
+
asana: () => new AsanaAdapter()
|
|
156
579
|
};
|
|
157
580
|
function matchBackend(task, config) {
|
|
158
581
|
if (task.backend && config.backends[task.backend]) {
|
|
@@ -284,12 +707,65 @@ function updateTaskDescription(existing, newDescription) {
|
|
|
284
707
|
return result.status === 0;
|
|
285
708
|
}
|
|
286
709
|
|
|
710
|
+
// src/tracking.ts
|
|
711
|
+
import fs3 from "fs";
|
|
712
|
+
import path4 from "path";
|
|
713
|
+
import os3 from "os";
|
|
714
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
715
|
+
var TRACKING_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", "tracking.json");
|
|
716
|
+
function loadTracking() {
|
|
717
|
+
try {
|
|
718
|
+
return JSON.parse(fs3.readFileSync(TRACKING_FILE, "utf-8"));
|
|
719
|
+
} catch {
|
|
720
|
+
return {};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function saveTracking(state) {
|
|
724
|
+
fs3.mkdirSync(path4.dirname(TRACKING_FILE), { recursive: true });
|
|
725
|
+
fs3.writeFileSync(TRACKING_FILE, JSON.stringify(state));
|
|
726
|
+
}
|
|
727
|
+
function mergedTags(state) {
|
|
728
|
+
const all = /* @__PURE__ */ new Set();
|
|
729
|
+
for (const tags of Object.values(state)) {
|
|
730
|
+
for (const tag of tags) all.add(tag);
|
|
731
|
+
}
|
|
732
|
+
return [...all];
|
|
733
|
+
}
|
|
734
|
+
function startParallel(key, tags) {
|
|
735
|
+
const tracking = loadTracking();
|
|
736
|
+
tracking[key] = tags;
|
|
737
|
+
saveTracking(tracking);
|
|
738
|
+
const merged = mergedTags(tracking);
|
|
739
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
740
|
+
spawnSync3("timew", ["start", ...merged], { stdio: "pipe" });
|
|
741
|
+
}
|
|
742
|
+
function startSwitch(key, tags) {
|
|
743
|
+
saveTracking({ [key]: tags });
|
|
744
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
745
|
+
spawnSync3("timew", ["start", ...tags], { stdio: "pipe" });
|
|
746
|
+
}
|
|
747
|
+
function stopEntry(key) {
|
|
748
|
+
const tracking = loadTracking();
|
|
749
|
+
delete tracking[key];
|
|
750
|
+
saveTracking(tracking);
|
|
751
|
+
const remaining = mergedTags(tracking);
|
|
752
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
753
|
+
if (remaining.length > 0) {
|
|
754
|
+
spawnSync3("timew", ["start", ...remaining], { stdio: "pipe" });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function getActiveMeetings() {
|
|
758
|
+
const tracking = loadTracking();
|
|
759
|
+
return Object.entries(tracking).filter(([key]) => key.startsWith("meeting:")).map(([key, tags]) => ({ key, tags }));
|
|
760
|
+
}
|
|
761
|
+
|
|
287
762
|
// src/cli.ts
|
|
288
|
-
var HOOKS_DIR =
|
|
763
|
+
var HOOKS_DIR = path5.join(os4.homedir(), ".task", "hooks");
|
|
289
764
|
var commands = {
|
|
290
765
|
add: addBackend,
|
|
291
766
|
install,
|
|
292
767
|
sync,
|
|
768
|
+
meeting: meetingCmd,
|
|
293
769
|
timewarrior: timewarriorCmd,
|
|
294
770
|
which,
|
|
295
771
|
config: showConfig
|
|
@@ -299,9 +775,10 @@ async function main() {
|
|
|
299
775
|
if (!command || command === "--help") {
|
|
300
776
|
console.log("Usage: tw-bridge <command>\n");
|
|
301
777
|
console.log("Commands:");
|
|
302
|
-
console.log(" add
|
|
303
|
-
console.log(" install
|
|
778
|
+
console.log(" add Add a new backend instance");
|
|
779
|
+
console.log(" install Install Taskwarrior hooks and shell integration");
|
|
304
780
|
console.log(" sync Pull tasks from all backends");
|
|
781
|
+
console.log(" meeting Track meetings in Timewarrior (no task created)");
|
|
305
782
|
console.log(" timewarrior Manage Timewarrior integration");
|
|
306
783
|
console.log(" which Print the context for the current directory");
|
|
307
784
|
console.log(" config Show current configuration");
|
|
@@ -346,14 +823,23 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
|
|
|
346
823
|
}
|
|
347
824
|
const matchTag = tagOverride ?? name;
|
|
348
825
|
const cwd = process.cwd();
|
|
349
|
-
const adapterConfig = adapter.defaultConfig(cwd);
|
|
826
|
+
const adapterConfig = adapter.setup ? await adapter.setup(cwd) : adapter.defaultConfig(cwd);
|
|
827
|
+
let doneStatuses;
|
|
828
|
+
const sections = adapterConfig.sections;
|
|
829
|
+
const doneSectionGids = adapterConfig.done_sections;
|
|
830
|
+
if (sections?.length && doneSectionGids?.length) {
|
|
831
|
+
const gidSet = new Set(doneSectionGids);
|
|
832
|
+
doneStatuses = sections.filter((s) => gidSet.has(s.gid)).map((s) => s.tag);
|
|
833
|
+
}
|
|
350
834
|
config.backends[name] = {
|
|
351
835
|
adapter: adapterType,
|
|
352
836
|
match: { tags: [matchTag] },
|
|
837
|
+
...doneStatuses?.length && { done_statuses: doneStatuses },
|
|
353
838
|
config: adapterConfig
|
|
354
839
|
};
|
|
355
840
|
const configPath = saveConfig(config);
|
|
356
|
-
console.log(`
|
|
841
|
+
console.log(`
|
|
842
|
+
Added backend "${name}" (adapter: ${adapterType})`);
|
|
357
843
|
console.log(`Config: ${configPath}`);
|
|
358
844
|
console.log(`Match tag: +${matchTag}`);
|
|
359
845
|
if (adapterConfig.cwd) {
|
|
@@ -368,18 +854,18 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
|
|
|
368
854
|
Use 'task context ${matchTag}' to switch to this project.`);
|
|
369
855
|
}
|
|
370
856
|
async function install() {
|
|
371
|
-
|
|
372
|
-
const hookSource =
|
|
373
|
-
|
|
857
|
+
fs4.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
858
|
+
const hookSource = path5.resolve(
|
|
859
|
+
path5.dirname(new URL(import.meta.url).pathname),
|
|
374
860
|
"hooks",
|
|
375
861
|
"on-modify.js"
|
|
376
862
|
);
|
|
377
|
-
const hookTarget =
|
|
378
|
-
if (
|
|
379
|
-
|
|
863
|
+
const hookTarget = path5.join(HOOKS_DIR, "on-modify.tw-bridge");
|
|
864
|
+
if (fs4.existsSync(hookTarget)) {
|
|
865
|
+
fs4.unlinkSync(hookTarget);
|
|
380
866
|
}
|
|
381
|
-
|
|
382
|
-
|
|
867
|
+
fs4.symlinkSync(hookSource, hookTarget);
|
|
868
|
+
fs4.chmodSync(hookSource, 493);
|
|
383
869
|
console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
|
|
384
870
|
console.log("\nAdd these to your .taskrc:\n");
|
|
385
871
|
console.log("# --- tw-bridge UDAs ---");
|
|
@@ -395,22 +881,145 @@ async function install() {
|
|
|
395
881
|
console.log("urgency.user.tag.in_review.coefficient=-2.0");
|
|
396
882
|
console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
|
|
397
883
|
console.log("urgency.user.tag.in_beta.coefficient=-6.0");
|
|
398
|
-
|
|
884
|
+
installTimewExtension();
|
|
885
|
+
if (fs4.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
399
886
|
console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
|
|
400
|
-
console.log(" tw-bridge timewarrior enable
|
|
887
|
+
console.log(" tw-bridge timewarrior enable");
|
|
401
888
|
}
|
|
402
889
|
installShellFunction();
|
|
403
890
|
}
|
|
404
|
-
var
|
|
891
|
+
var TIMEW_EXT_DIR = path5.join(os4.homedir(), ".timewarrior", "extensions");
|
|
892
|
+
function installTimewExtension() {
|
|
893
|
+
fs4.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
|
|
894
|
+
const extSource = path5.resolve(
|
|
895
|
+
path5.dirname(new URL(import.meta.url).pathname),
|
|
896
|
+
"extensions",
|
|
897
|
+
"bridge.js"
|
|
898
|
+
);
|
|
899
|
+
const extTarget = path5.join(TIMEW_EXT_DIR, "bridge");
|
|
900
|
+
if (fs4.existsSync(extTarget)) {
|
|
901
|
+
fs4.unlinkSync(extTarget);
|
|
902
|
+
}
|
|
903
|
+
fs4.symlinkSync(extSource, extTarget);
|
|
904
|
+
fs4.chmodSync(extSource, 493);
|
|
905
|
+
console.log(`
|
|
906
|
+
Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
|
|
907
|
+
console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
|
|
908
|
+
}
|
|
909
|
+
function sanitizeMeetingName(name) {
|
|
910
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
911
|
+
}
|
|
912
|
+
function detectProjectContext() {
|
|
913
|
+
const cwd = process.cwd();
|
|
914
|
+
const config = loadConfig();
|
|
915
|
+
for (const [, backend] of Object.entries(config.backends)) {
|
|
916
|
+
const backendCwd = backend.config?.cwd;
|
|
917
|
+
if (!backendCwd) continue;
|
|
918
|
+
const resolved = path5.resolve(backendCwd);
|
|
919
|
+
if (cwd === resolved || cwd.startsWith(resolved + path5.sep)) {
|
|
920
|
+
return backend.match.tags?.[0] ?? null;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
async function meetingCmd() {
|
|
926
|
+
const sub = process.argv[3];
|
|
927
|
+
if (!sub || sub === "--help") {
|
|
928
|
+
console.log("Usage: tw-bridge meeting <subcommand>\n");
|
|
929
|
+
console.log("Subcommands:");
|
|
930
|
+
console.log(" start <name> [--switch] Start tracking a meeting");
|
|
931
|
+
console.log(" stop [name] Stop a meeting (or all if no name)");
|
|
932
|
+
console.log(" list Show active meetings");
|
|
933
|
+
console.log("\nMeetings are tracked in Timewarrior only \u2014 no Taskwarrior task is created.");
|
|
934
|
+
console.log("By default, meetings run in parallel with active tasks.");
|
|
935
|
+
console.log("Use --switch to pause the current task instead.");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const config = loadConfig();
|
|
939
|
+
if (!config.timewarrior?.enabled) {
|
|
940
|
+
console.error("Timewarrior is not enabled. Run: tw-bridge timewarrior enable");
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
if (sub === "start") {
|
|
944
|
+
const nameArg = process.argv.slice(4).filter((a) => !a.startsWith("--")).join(" ");
|
|
945
|
+
if (!nameArg) {
|
|
946
|
+
console.error("Usage: tw-bridge meeting start <name>");
|
|
947
|
+
process.exit(1);
|
|
948
|
+
}
|
|
949
|
+
const switchMode = process.argv.includes("--switch") || process.argv.includes("-s");
|
|
950
|
+
const tag = sanitizeMeetingName(nameArg);
|
|
951
|
+
const key = `meeting:${tag}`;
|
|
952
|
+
const tags = ["meeting", tag];
|
|
953
|
+
const project = detectProjectContext();
|
|
954
|
+
if (project) tags.push(project);
|
|
955
|
+
if (switchMode) {
|
|
956
|
+
startSwitch(key, tags);
|
|
957
|
+
} else {
|
|
958
|
+
startParallel(key, tags);
|
|
959
|
+
}
|
|
960
|
+
console.log(`Meeting started: ${nameArg}`);
|
|
961
|
+
console.log(` Tags: ${tags.join(" ")}`);
|
|
962
|
+
if (!switchMode) {
|
|
963
|
+
console.log(" Mode: parallel (active tasks continue tracking)");
|
|
964
|
+
} else {
|
|
965
|
+
console.log(" Mode: switch (active tasks paused)");
|
|
966
|
+
}
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (sub === "stop") {
|
|
970
|
+
const nameArg = process.argv.slice(4).join(" ").trim();
|
|
971
|
+
const active = getActiveMeetings();
|
|
972
|
+
if (active.length === 0) {
|
|
973
|
+
console.log("No active meetings.");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (nameArg) {
|
|
977
|
+
const tag = sanitizeMeetingName(nameArg);
|
|
978
|
+
const key = `meeting:${tag}`;
|
|
979
|
+
const match = active.find((m) => m.key === key);
|
|
980
|
+
if (!match) {
|
|
981
|
+
console.error(`No active meeting matching "${nameArg}".`);
|
|
982
|
+
console.error(`Active meetings: ${active.map((m) => m.key.replace("meeting:", "")).join(", ")}`);
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
stopEntry(key);
|
|
986
|
+
console.log(`Meeting stopped: ${nameArg}`);
|
|
987
|
+
} else {
|
|
988
|
+
for (const m of active) {
|
|
989
|
+
stopEntry(m.key);
|
|
990
|
+
}
|
|
991
|
+
console.log(`Stopped ${active.length} meeting(s): ${active.map((m) => m.key.replace("meeting:", "")).join(", ")}`);
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (sub === "list") {
|
|
996
|
+
const active = getActiveMeetings();
|
|
997
|
+
if (active.length === 0) {
|
|
998
|
+
console.log("No active meetings.");
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
console.log("Active meetings:");
|
|
1002
|
+
for (const m of active) {
|
|
1003
|
+
const name = m.key.replace("meeting:", "");
|
|
1004
|
+
console.log(` ${name} (${m.tags.join(" ")})`);
|
|
1005
|
+
}
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
1009
|
+
process.exit(1);
|
|
1010
|
+
}
|
|
1011
|
+
var STANDARD_TIMEW_HOOK = path5.join(HOOKS_DIR, "on-modify.timewarrior");
|
|
405
1012
|
async function timewarriorCmd() {
|
|
406
1013
|
const sub = process.argv[3];
|
|
407
1014
|
if (!sub || sub === "--help") {
|
|
408
1015
|
console.log("Usage: tw-bridge timewarrior <subcommand>\n");
|
|
409
1016
|
console.log("Subcommands:");
|
|
410
|
-
console.log(" enable
|
|
411
|
-
console.log("
|
|
412
|
-
console.log("
|
|
413
|
-
console.log("
|
|
1017
|
+
console.log(" enable Enable Timewarrior tracking");
|
|
1018
|
+
console.log(" disable Disable Timewarrior tracking");
|
|
1019
|
+
console.log(" status Show current Timewarrior configuration");
|
|
1020
|
+
console.log("\nParallel time tracking is controlled per-invocation:");
|
|
1021
|
+
console.log(" task start <id> --parallel Track in parallel with current task");
|
|
1022
|
+
console.log(" task start <id> --switch Stop current task, start new one");
|
|
414
1023
|
return;
|
|
415
1024
|
}
|
|
416
1025
|
const config = loadConfig();
|
|
@@ -419,10 +1028,11 @@ async function timewarriorCmd() {
|
|
|
419
1028
|
if (!tw?.enabled) {
|
|
420
1029
|
console.log("Timewarrior: disabled");
|
|
421
1030
|
} else {
|
|
422
|
-
console.log(
|
|
1031
|
+
console.log("Timewarrior: enabled");
|
|
1032
|
+
console.log(" Parallel tracking: use `task start <id> --parallel`");
|
|
423
1033
|
}
|
|
424
|
-
const hookExists =
|
|
425
|
-
const hookDisabled =
|
|
1034
|
+
const hookExists = fs4.existsSync(STANDARD_TIMEW_HOOK);
|
|
1035
|
+
const hookDisabled = fs4.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
|
|
426
1036
|
if (hookExists) {
|
|
427
1037
|
console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
|
|
428
1038
|
if (tw?.enabled) {
|
|
@@ -435,18 +1045,14 @@ async function timewarriorCmd() {
|
|
|
435
1045
|
}
|
|
436
1046
|
return;
|
|
437
1047
|
}
|
|
438
|
-
if (sub === "enable"
|
|
439
|
-
|
|
440
|
-
config.timewarrior = {
|
|
441
|
-
enabled: true,
|
|
442
|
-
allow_overlaps: allowOverlaps
|
|
443
|
-
};
|
|
1048
|
+
if (sub === "enable") {
|
|
1049
|
+
config.timewarrior = { enabled: true };
|
|
444
1050
|
const configPath = saveConfig(config);
|
|
445
|
-
console.log(
|
|
1051
|
+
console.log("Timewarrior tracking enabled");
|
|
446
1052
|
console.log(`Config: ${configPath}`);
|
|
447
|
-
if (
|
|
1053
|
+
if (fs4.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
448
1054
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
449
|
-
|
|
1055
|
+
fs4.renameSync(STANDARD_TIMEW_HOOK, disabled);
|
|
450
1056
|
console.log(`
|
|
451
1057
|
Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
452
1058
|
console.log("tw-bridge will handle Timewarrior tracking directly.");
|
|
@@ -454,16 +1060,13 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
|
454
1060
|
return;
|
|
455
1061
|
}
|
|
456
1062
|
if (sub === "disable") {
|
|
457
|
-
config.timewarrior = {
|
|
458
|
-
enabled: false,
|
|
459
|
-
allow_overlaps: false
|
|
460
|
-
};
|
|
1063
|
+
config.timewarrior = { enabled: false };
|
|
461
1064
|
const configPath = saveConfig(config);
|
|
462
1065
|
console.log("Timewarrior tracking disabled");
|
|
463
1066
|
console.log(`Config: ${configPath}`);
|
|
464
1067
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
465
|
-
if (
|
|
466
|
-
|
|
1068
|
+
if (fs4.existsSync(disabled)) {
|
|
1069
|
+
fs4.renameSync(disabled, STANDARD_TIMEW_HOOK);
|
|
467
1070
|
console.log(`
|
|
468
1071
|
Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
469
1072
|
}
|
|
@@ -475,40 +1078,46 @@ Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
|
475
1078
|
var SHELL_FUNCTION = `
|
|
476
1079
|
# tw-bridge: auto-context task wrapper
|
|
477
1080
|
task() {
|
|
478
|
-
local ctx
|
|
1081
|
+
local ctx mode=""
|
|
1082
|
+
local args=()
|
|
1083
|
+
|
|
1084
|
+
for arg in "$@"; do
|
|
1085
|
+
case "$arg" in
|
|
1086
|
+
--parallel|-p) mode="parallel" ;;
|
|
1087
|
+
--switch|-s) mode="switch" ;;
|
|
1088
|
+
*) args+=("$arg") ;;
|
|
1089
|
+
esac
|
|
1090
|
+
done
|
|
1091
|
+
|
|
479
1092
|
ctx=$(tw-bridge which 2>/dev/null)
|
|
480
|
-
|
|
481
|
-
command task "rc.context=$ctx" "$@"
|
|
482
|
-
else
|
|
483
|
-
command task "$@"
|
|
484
|
-
fi
|
|
1093
|
+
TW_BRIDGE_MODE="$mode" command task \${ctx:+"rc.context=$ctx"} "\${args[@]}"
|
|
485
1094
|
}
|
|
486
1095
|
`.trim();
|
|
487
1096
|
var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
|
|
488
1097
|
function installShellFunction() {
|
|
489
1098
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
490
|
-
const home =
|
|
1099
|
+
const home = os4.homedir();
|
|
491
1100
|
let rcFile;
|
|
492
1101
|
if (shell.endsWith("zsh")) {
|
|
493
|
-
rcFile =
|
|
1102
|
+
rcFile = path5.join(home, ".zshrc");
|
|
494
1103
|
} else {
|
|
495
|
-
rcFile =
|
|
1104
|
+
rcFile = path5.join(home, ".bashrc");
|
|
496
1105
|
}
|
|
497
|
-
const existing =
|
|
1106
|
+
const existing = fs4.existsSync(rcFile) ? fs4.readFileSync(rcFile, "utf-8") : "";
|
|
498
1107
|
if (existing.includes(SHELL_MARKER)) {
|
|
499
1108
|
console.log(`
|
|
500
1109
|
Shell integration already installed in ${rcFile}`);
|
|
501
1110
|
return;
|
|
502
1111
|
}
|
|
503
|
-
|
|
1112
|
+
fs4.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
|
|
504
1113
|
console.log(`
|
|
505
1114
|
Shell integration installed in ${rcFile}`);
|
|
506
1115
|
console.log("Restart your shell or run: source " + rcFile);
|
|
507
1116
|
}
|
|
508
|
-
var SEEN_FILE =
|
|
1117
|
+
var SEEN_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", ".seen-dirs");
|
|
509
1118
|
function loadSeenDirs() {
|
|
510
1119
|
try {
|
|
511
|
-
const raw =
|
|
1120
|
+
const raw = fs4.readFileSync(SEEN_FILE, "utf-8");
|
|
512
1121
|
return new Set(raw.split("\n").filter(Boolean));
|
|
513
1122
|
} catch {
|
|
514
1123
|
return /* @__PURE__ */ new Set();
|
|
@@ -518,15 +1127,15 @@ function markDirSeen(dir) {
|
|
|
518
1127
|
const seen = loadSeenDirs();
|
|
519
1128
|
if (seen.has(dir)) return;
|
|
520
1129
|
seen.add(dir);
|
|
521
|
-
|
|
522
|
-
|
|
1130
|
+
fs4.mkdirSync(path5.dirname(SEEN_FILE), { recursive: true });
|
|
1131
|
+
fs4.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
|
|
523
1132
|
}
|
|
524
1133
|
function isGitRepo(dir) {
|
|
525
1134
|
try {
|
|
526
1135
|
let current = dir;
|
|
527
|
-
while (current !==
|
|
528
|
-
if (
|
|
529
|
-
current =
|
|
1136
|
+
while (current !== path5.dirname(current)) {
|
|
1137
|
+
if (fs4.existsSync(path5.join(current, ".git"))) return true;
|
|
1138
|
+
current = path5.dirname(current);
|
|
530
1139
|
}
|
|
531
1140
|
return false;
|
|
532
1141
|
} catch {
|
|
@@ -539,8 +1148,8 @@ async function which() {
|
|
|
539
1148
|
for (const [_name, backend] of Object.entries(config.backends)) {
|
|
540
1149
|
const backendCwd = backend.config?.cwd;
|
|
541
1150
|
if (!backendCwd) continue;
|
|
542
|
-
const resolved =
|
|
543
|
-
if (cwd === resolved || cwd.startsWith(resolved +
|
|
1151
|
+
const resolved = path5.resolve(backendCwd);
|
|
1152
|
+
if (cwd === resolved || cwd.startsWith(resolved + path5.sep)) {
|
|
544
1153
|
const contextTag = backend.match.tags?.[0];
|
|
545
1154
|
if (contextTag) {
|
|
546
1155
|
process.stdout.write(contextTag);
|
|
@@ -556,15 +1165,15 @@ async function which() {
|
|
|
556
1165
|
const seen = loadSeenDirs();
|
|
557
1166
|
let gitRoot = cwd;
|
|
558
1167
|
let current = cwd;
|
|
559
|
-
while (current !==
|
|
560
|
-
if (
|
|
1168
|
+
while (current !== path5.dirname(current)) {
|
|
1169
|
+
if (fs4.existsSync(path5.join(current, ".git"))) {
|
|
561
1170
|
gitRoot = current;
|
|
562
1171
|
break;
|
|
563
1172
|
}
|
|
564
|
-
current =
|
|
1173
|
+
current = path5.dirname(current);
|
|
565
1174
|
}
|
|
566
1175
|
if (!seen.has(gitRoot)) {
|
|
567
|
-
const dirName =
|
|
1176
|
+
const dirName = path5.basename(gitRoot);
|
|
568
1177
|
process.stderr.write(
|
|
569
1178
|
`tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
|
|
570
1179
|
`
|
|
@@ -628,6 +1237,14 @@ async function syncBackend(name, backend, config) {
|
|
|
628
1237
|
}
|
|
629
1238
|
} else if (isDone) {
|
|
630
1239
|
if (completeTask(existingTask, matchTags)) {
|
|
1240
|
+
if (adapter.onDone) {
|
|
1241
|
+
try {
|
|
1242
|
+
await adapter.onDone(task);
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1245
|
+
console.error(` \u26A0 [#${backendId}] failed to push completion: ${msg}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
631
1248
|
console.log(` \u2713 [#${backendId}] ${task.description}`);
|
|
632
1249
|
completed++;
|
|
633
1250
|
}
|