@bretwardjames/tw-bridge 0.5.0 → 0.7.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 +471 -61
- package/dist/hooks/on-modify.js +329 -19
- 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 fs5 from "fs";
|
|
5
|
+
import path6 from "path";
|
|
6
|
+
import os5 from "os";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
9
9
|
import fs from "fs";
|
|
@@ -241,8 +241,8 @@ async function oauthLogin(clientId, clientSecret, rl) {
|
|
|
241
241
|
saveTokens(tokens);
|
|
242
242
|
return tokens;
|
|
243
243
|
}
|
|
244
|
-
async function asanaFetch(
|
|
245
|
-
const url = `${API_BASE}${
|
|
244
|
+
async function asanaFetch(path7, token, options = {}) {
|
|
245
|
+
const url = `${API_BASE}${path7}`;
|
|
246
246
|
const res = await fetch(url, {
|
|
247
247
|
...options,
|
|
248
248
|
headers: {
|
|
@@ -258,9 +258,9 @@ async function asanaFetch(path6, token, options = {}) {
|
|
|
258
258
|
const json = await res.json();
|
|
259
259
|
return json.data;
|
|
260
260
|
}
|
|
261
|
-
async function asanaFetchAll(
|
|
261
|
+
async function asanaFetchAll(path7, token) {
|
|
262
262
|
const results = [];
|
|
263
|
-
let nextPage =
|
|
263
|
+
let nextPage = path7;
|
|
264
264
|
while (nextPage) {
|
|
265
265
|
const url = `${API_BASE}${nextPage}`;
|
|
266
266
|
const res = await fetch(url, {
|
|
@@ -572,10 +572,319 @@ function mapAsanaPriority(fields) {
|
|
|
572
572
|
return void 0;
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
// src/adapters/strety.ts
|
|
576
|
+
import fs3 from "fs";
|
|
577
|
+
import path4 from "path";
|
|
578
|
+
import os3 from "os";
|
|
579
|
+
import { execSync as execSync2 } from "child_process";
|
|
580
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
581
|
+
import { stdin as stdin2, stdout as stdout2 } from "process";
|
|
582
|
+
var API_BASE2 = "https://2.strety.com/api/v1";
|
|
583
|
+
var OAUTH_AUTHORIZE2 = `${API_BASE2}/oauth/authorize`;
|
|
584
|
+
var OAUTH_TOKEN2 = `${API_BASE2}/oauth/token`;
|
|
585
|
+
var TOKEN_FILE2 = path4.join(os3.homedir(), ".config", "tw-bridge", "strety-tokens.json");
|
|
586
|
+
function loadTokens2() {
|
|
587
|
+
try {
|
|
588
|
+
return JSON.parse(fs3.readFileSync(TOKEN_FILE2, "utf-8"));
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function saveTokens2(tokens) {
|
|
594
|
+
fs3.mkdirSync(path4.dirname(TOKEN_FILE2), { recursive: true });
|
|
595
|
+
fs3.writeFileSync(TOKEN_FILE2, JSON.stringify(tokens, null, 2), { mode: 384 });
|
|
596
|
+
}
|
|
597
|
+
async function refreshAccessToken2(tokens) {
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
if (tokens.access_token && tokens.expires_at > now + 3e5) {
|
|
600
|
+
return tokens.access_token;
|
|
601
|
+
}
|
|
602
|
+
const res = await fetch(OAUTH_TOKEN2, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
605
|
+
body: new URLSearchParams({
|
|
606
|
+
grant_type: "refresh_token",
|
|
607
|
+
refresh_token: tokens.refresh_token,
|
|
608
|
+
client_id: tokens.client_id,
|
|
609
|
+
client_secret: tokens.client_secret
|
|
610
|
+
})
|
|
611
|
+
});
|
|
612
|
+
if (!res.ok) {
|
|
613
|
+
const body = await res.text();
|
|
614
|
+
throw new Error(`Token refresh failed (${res.status}): ${body}`);
|
|
615
|
+
}
|
|
616
|
+
const data = await res.json();
|
|
617
|
+
tokens.access_token = data.access_token;
|
|
618
|
+
if (data.refresh_token) {
|
|
619
|
+
tokens.refresh_token = data.refresh_token;
|
|
620
|
+
}
|
|
621
|
+
tokens.expires_at = now + (data.expires_in ?? 7200) * 1e3;
|
|
622
|
+
saveTokens2(tokens);
|
|
623
|
+
return tokens.access_token;
|
|
624
|
+
}
|
|
625
|
+
var REDIRECT_URI = "https://localhost/callback";
|
|
626
|
+
async function oauthLogin2(clientId, clientSecret, rl) {
|
|
627
|
+
const authUrl = `${OAUTH_AUTHORIZE2}?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent("read write")}`;
|
|
628
|
+
console.log(`
|
|
629
|
+
Opening browser for authorization...`);
|
|
630
|
+
console.log(` If it doesn't open, visit:
|
|
631
|
+
${authUrl}
|
|
632
|
+
`);
|
|
633
|
+
try {
|
|
634
|
+
execSync2(`xdg-open "${authUrl}" 2>/dev/null || open "${authUrl}" 2>/dev/null`, {
|
|
635
|
+
stdio: "ignore"
|
|
636
|
+
});
|
|
637
|
+
} catch {
|
|
638
|
+
}
|
|
639
|
+
console.log(" After authorizing, the browser will redirect to a page that");
|
|
640
|
+
console.log(" won't load. Copy the full URL from the address bar and paste it here.\n");
|
|
641
|
+
const pasted = (await rl.question("Paste the callback URL: ")).trim();
|
|
642
|
+
let code;
|
|
643
|
+
try {
|
|
644
|
+
const url = new URL(pasted);
|
|
645
|
+
const error = url.searchParams.get("error");
|
|
646
|
+
if (error) {
|
|
647
|
+
throw new Error(`OAuth error: ${error}`);
|
|
648
|
+
}
|
|
649
|
+
const authCode = url.searchParams.get("code");
|
|
650
|
+
if (!authCode) {
|
|
651
|
+
throw new Error("No authorization code found in URL");
|
|
652
|
+
}
|
|
653
|
+
code = authCode;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (err instanceof Error && err.message.startsWith("OAuth error:")) throw err;
|
|
656
|
+
if (err instanceof Error && err.message.startsWith("No authorization")) throw err;
|
|
657
|
+
throw new Error(`Could not parse callback URL: ${pasted}`);
|
|
658
|
+
}
|
|
659
|
+
const tokenRes = await fetch(OAUTH_TOKEN2, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
662
|
+
body: new URLSearchParams({
|
|
663
|
+
grant_type: "authorization_code",
|
|
664
|
+
code,
|
|
665
|
+
redirect_uri: REDIRECT_URI,
|
|
666
|
+
client_id: clientId,
|
|
667
|
+
client_secret: clientSecret
|
|
668
|
+
})
|
|
669
|
+
});
|
|
670
|
+
if (!tokenRes.ok) {
|
|
671
|
+
const body = await tokenRes.text();
|
|
672
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
|
|
673
|
+
}
|
|
674
|
+
const data = await tokenRes.json();
|
|
675
|
+
const tokens = {
|
|
676
|
+
access_token: data.access_token,
|
|
677
|
+
refresh_token: data.refresh_token,
|
|
678
|
+
expires_at: Date.now() + (data.expires_in ?? 7200) * 1e3,
|
|
679
|
+
client_id: clientId,
|
|
680
|
+
client_secret: clientSecret
|
|
681
|
+
};
|
|
682
|
+
saveTokens2(tokens);
|
|
683
|
+
return tokens;
|
|
684
|
+
}
|
|
685
|
+
async function stretyFetch(urlOrPath, token, options = {}) {
|
|
686
|
+
const url = urlOrPath.startsWith("http") ? urlOrPath : `${API_BASE2}${urlOrPath}`;
|
|
687
|
+
const res = await fetch(url, {
|
|
688
|
+
...options,
|
|
689
|
+
headers: {
|
|
690
|
+
Authorization: `Bearer ${token}`,
|
|
691
|
+
Accept: "application/vnd.api+json",
|
|
692
|
+
"Content-Type": "application/vnd.api+json",
|
|
693
|
+
...options.headers
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
if (!res.ok) {
|
|
697
|
+
const body = await res.text();
|
|
698
|
+
throw new Error(`Strety API ${res.status}: ${body}`);
|
|
699
|
+
}
|
|
700
|
+
return res.json();
|
|
701
|
+
}
|
|
702
|
+
async function stretyFetchAll(path7, token) {
|
|
703
|
+
const results = [];
|
|
704
|
+
let url = `${API_BASE2}${path7}`;
|
|
705
|
+
while (url) {
|
|
706
|
+
const json = await stretyFetch(url, token);
|
|
707
|
+
results.push(...json.data);
|
|
708
|
+
url = json.links?.next ?? null;
|
|
709
|
+
}
|
|
710
|
+
return results;
|
|
711
|
+
}
|
|
712
|
+
async function pickOne2(rl, label, items) {
|
|
713
|
+
console.log(`
|
|
714
|
+
${label}:`);
|
|
715
|
+
for (let i = 0; i < items.length; i++) {
|
|
716
|
+
const name = items[i].attributes.name ?? items[i].attributes.title ?? items[i].id;
|
|
717
|
+
console.log(` ${i + 1}) ${name}`);
|
|
718
|
+
}
|
|
719
|
+
while (true) {
|
|
720
|
+
const answer = await rl.question(`Choose (1-${items.length}): `);
|
|
721
|
+
const idx = parseInt(answer, 10) - 1;
|
|
722
|
+
if (idx >= 0 && idx < items.length) {
|
|
723
|
+
const item = items[idx];
|
|
724
|
+
return { id: item.id, name: item.attributes.name ?? item.attributes.title ?? item.id };
|
|
725
|
+
}
|
|
726
|
+
console.log(" Invalid choice, try again.");
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
var StretyAdapter = class {
|
|
730
|
+
name = "strety";
|
|
731
|
+
config;
|
|
732
|
+
accessToken = null;
|
|
733
|
+
async resolveToken() {
|
|
734
|
+
const auth = this.config.auth;
|
|
735
|
+
if (auth === "oauth") {
|
|
736
|
+
const tokens = loadTokens2();
|
|
737
|
+
if (!tokens) throw new Error("No OAuth tokens found. Run `tw-bridge add` to authenticate.");
|
|
738
|
+
this.accessToken = await refreshAccessToken2(tokens);
|
|
739
|
+
return this.accessToken;
|
|
740
|
+
}
|
|
741
|
+
if (this.accessToken) return this.accessToken;
|
|
742
|
+
if (auth.startsWith("env:")) {
|
|
743
|
+
const envVar = auth.slice(4);
|
|
744
|
+
const val = process.env[envVar];
|
|
745
|
+
if (!val) throw new Error(`Environment variable ${envVar} not set`);
|
|
746
|
+
this.accessToken = val;
|
|
747
|
+
return val;
|
|
748
|
+
}
|
|
749
|
+
this.accessToken = auth;
|
|
750
|
+
return auth;
|
|
751
|
+
}
|
|
752
|
+
defaultConfig(_cwd) {
|
|
753
|
+
return {
|
|
754
|
+
auth: "env:STRETY_TOKEN",
|
|
755
|
+
assignee_id: "",
|
|
756
|
+
assignee_name: ""
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
async setup(_cwd) {
|
|
760
|
+
const rl = createInterface2({ input: stdin2, output: stdout2 });
|
|
761
|
+
try {
|
|
762
|
+
console.log("\n\u2500\u2500 Strety Adapter Setup \u2500\u2500\n");
|
|
763
|
+
console.log("Authentication:");
|
|
764
|
+
console.log(" 1) OAuth (browser login)");
|
|
765
|
+
console.log(" 2) Environment variable");
|
|
766
|
+
const authChoice = (await rl.question("\nChoose (1-2): ")).trim();
|
|
767
|
+
let authConfig;
|
|
768
|
+
let token;
|
|
769
|
+
if (authChoice === "1") {
|
|
770
|
+
console.log(`
|
|
771
|
+
Register a Strety app at My Integrations > My Apps.`);
|
|
772
|
+
console.log(` Set the Redirect URI to: ${REDIRECT_URI}
|
|
773
|
+
`);
|
|
774
|
+
const clientId = (await rl.question("Strety App Client ID: ")).trim();
|
|
775
|
+
const clientSecret = (await rl.question("Strety App Client Secret: ")).trim();
|
|
776
|
+
if (!clientId || !clientSecret) {
|
|
777
|
+
console.error("\n Client ID and Secret are required.");
|
|
778
|
+
rl.close();
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
const tokens = await oauthLogin2(clientId, clientSecret, rl);
|
|
782
|
+
token = tokens.access_token;
|
|
783
|
+
authConfig = "oauth";
|
|
784
|
+
} else {
|
|
785
|
+
const envVar = await rl.question("Environment variable name [STRETY_TOKEN]: ");
|
|
786
|
+
const varName = envVar.trim() || "STRETY_TOKEN";
|
|
787
|
+
authConfig = `env:${varName}`;
|
|
788
|
+
token = process.env[varName] ?? "";
|
|
789
|
+
if (!token) {
|
|
790
|
+
console.error(`
|
|
791
|
+
Cannot complete setup without a valid token.`);
|
|
792
|
+
console.error(` Set ${varName} in your environment and try again.`);
|
|
793
|
+
rl.close();
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
console.log("\nVerifying...");
|
|
798
|
+
let people;
|
|
799
|
+
try {
|
|
800
|
+
people = await stretyFetchAll("/people?page[size]=20", token);
|
|
801
|
+
} catch (err) {
|
|
802
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
803
|
+
console.error(` Authentication failed: ${msg}`);
|
|
804
|
+
rl.close();
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|
|
807
|
+
console.log(` Connected! Found ${people.length} people.`);
|
|
808
|
+
const person = await pickOne2(rl, "Which person are you? (for filtering your todos)", people);
|
|
809
|
+
rl.close();
|
|
810
|
+
console.log(`
|
|
811
|
+
Assignee: ${person.name}`);
|
|
812
|
+
console.log(" Syncing: Todos assigned to you");
|
|
813
|
+
return {
|
|
814
|
+
auth: authConfig,
|
|
815
|
+
assignee_id: person.id,
|
|
816
|
+
assignee_name: person.name
|
|
817
|
+
};
|
|
818
|
+
} catch (err) {
|
|
819
|
+
rl.close();
|
|
820
|
+
throw err;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async init(config) {
|
|
824
|
+
this.config = config;
|
|
825
|
+
if (!this.config.assignee_id) {
|
|
826
|
+
throw new Error('strety adapter requires "assignee_id" in config');
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async pull() {
|
|
830
|
+
const token = await this.resolveToken();
|
|
831
|
+
const raw = await stretyFetchAll(
|
|
832
|
+
`/todos?filter[assignee_id]=${this.config.assignee_id}&page[size]=20`,
|
|
833
|
+
token
|
|
834
|
+
);
|
|
835
|
+
const tasks = [];
|
|
836
|
+
for (const item of raw) {
|
|
837
|
+
const attrs = item.attributes;
|
|
838
|
+
if (attrs.completed_at) continue;
|
|
839
|
+
const priority = mapStretyPriority(attrs.priority);
|
|
840
|
+
const task = {
|
|
841
|
+
uuid: "",
|
|
842
|
+
description: attrs.title,
|
|
843
|
+
status: "pending",
|
|
844
|
+
entry: attrs.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
845
|
+
tags: [],
|
|
846
|
+
priority,
|
|
847
|
+
backend: "",
|
|
848
|
+
backend_id: item.id,
|
|
849
|
+
...attrs.due_date && { due: new Date(attrs.due_date).toISOString() }
|
|
850
|
+
};
|
|
851
|
+
tasks.push(task);
|
|
852
|
+
}
|
|
853
|
+
return tasks;
|
|
854
|
+
}
|
|
855
|
+
async onDone(task) {
|
|
856
|
+
const todoId = task.backend_id;
|
|
857
|
+
if (!todoId) return;
|
|
858
|
+
const token = await this.resolveToken();
|
|
859
|
+
process.stderr.write(`tw-bridge [strety]: marking todo complete
|
|
860
|
+
`);
|
|
861
|
+
await stretyFetch(`/todos/${todoId}`, token, {
|
|
862
|
+
method: "PATCH",
|
|
863
|
+
headers: { "If-Match": "*" },
|
|
864
|
+
body: JSON.stringify({
|
|
865
|
+
data: {
|
|
866
|
+
type: "todo",
|
|
867
|
+
attributes: {
|
|
868
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
function mapStretyPriority(priority) {
|
|
876
|
+
if (!priority || priority === "none") return void 0;
|
|
877
|
+
if (priority === "highest" || priority === "high") return "H";
|
|
878
|
+
if (priority === "medium") return "M";
|
|
879
|
+
if (priority === "low" || priority === "lowest") return "L";
|
|
880
|
+
return void 0;
|
|
881
|
+
}
|
|
882
|
+
|
|
575
883
|
// src/registry.ts
|
|
576
884
|
var BUILTIN_ADAPTERS = {
|
|
577
885
|
ghp: () => new GhpAdapter(),
|
|
578
|
-
asana: () => new AsanaAdapter()
|
|
886
|
+
asana: () => new AsanaAdapter(),
|
|
887
|
+
strety: () => new StretyAdapter()
|
|
579
888
|
};
|
|
580
889
|
function matchBackend(task, config) {
|
|
581
890
|
if (task.backend && config.backends[task.backend]) {
|
|
@@ -697,6 +1006,34 @@ function completeTask(existing, keepTags) {
|
|
|
697
1006
|
);
|
|
698
1007
|
return result.status === 0;
|
|
699
1008
|
}
|
|
1009
|
+
function findTaskByBackendId(backend, backendId) {
|
|
1010
|
+
const existing = getExistingTasks(backend);
|
|
1011
|
+
return existing.get(backendId) ?? null;
|
|
1012
|
+
}
|
|
1013
|
+
function startTask(uuid) {
|
|
1014
|
+
const result = spawnSync2(
|
|
1015
|
+
"task",
|
|
1016
|
+
["rc.confirmation=off", uuid, "start"],
|
|
1017
|
+
{
|
|
1018
|
+
encoding: "utf-8",
|
|
1019
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1020
|
+
env: { ...process.env, TW_BRIDGE_REVERSE_SYNC: "1" }
|
|
1021
|
+
}
|
|
1022
|
+
);
|
|
1023
|
+
return result.status === 0;
|
|
1024
|
+
}
|
|
1025
|
+
function doneTask(uuid) {
|
|
1026
|
+
const result = spawnSync2(
|
|
1027
|
+
"task",
|
|
1028
|
+
["rc.confirmation=off", uuid, "done"],
|
|
1029
|
+
{
|
|
1030
|
+
encoding: "utf-8",
|
|
1031
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1032
|
+
env: { ...process.env, TW_BRIDGE_REVERSE_SYNC: "1" }
|
|
1033
|
+
}
|
|
1034
|
+
);
|
|
1035
|
+
return result.status === 0;
|
|
1036
|
+
}
|
|
700
1037
|
function updateTaskDescription(existing, newDescription) {
|
|
701
1038
|
if (existing.description === newDescription) return false;
|
|
702
1039
|
const result = spawnSync2(
|
|
@@ -708,21 +1045,21 @@ function updateTaskDescription(existing, newDescription) {
|
|
|
708
1045
|
}
|
|
709
1046
|
|
|
710
1047
|
// src/tracking.ts
|
|
711
|
-
import
|
|
712
|
-
import
|
|
713
|
-
import
|
|
1048
|
+
import fs4 from "fs";
|
|
1049
|
+
import path5 from "path";
|
|
1050
|
+
import os4 from "os";
|
|
714
1051
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
715
|
-
var TRACKING_FILE =
|
|
1052
|
+
var TRACKING_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", "tracking.json");
|
|
716
1053
|
function loadTracking() {
|
|
717
1054
|
try {
|
|
718
|
-
return JSON.parse(
|
|
1055
|
+
return JSON.parse(fs4.readFileSync(TRACKING_FILE, "utf-8"));
|
|
719
1056
|
} catch {
|
|
720
1057
|
return {};
|
|
721
1058
|
}
|
|
722
1059
|
}
|
|
723
1060
|
function saveTracking(state) {
|
|
724
|
-
|
|
725
|
-
|
|
1061
|
+
fs4.mkdirSync(path5.dirname(TRACKING_FILE), { recursive: true });
|
|
1062
|
+
fs4.writeFileSync(TRACKING_FILE, JSON.stringify(state));
|
|
726
1063
|
}
|
|
727
1064
|
function mergedTags(state) {
|
|
728
1065
|
const all = /* @__PURE__ */ new Set();
|
|
@@ -760,11 +1097,13 @@ function getActiveMeetings() {
|
|
|
760
1097
|
}
|
|
761
1098
|
|
|
762
1099
|
// src/cli.ts
|
|
763
|
-
var HOOKS_DIR =
|
|
1100
|
+
var HOOKS_DIR = path6.join(os5.homedir(), ".task", "hooks");
|
|
764
1101
|
var commands = {
|
|
765
1102
|
add: addBackend,
|
|
766
1103
|
install,
|
|
767
1104
|
sync,
|
|
1105
|
+
start: startCmd,
|
|
1106
|
+
done: doneCmd,
|
|
768
1107
|
meeting: meetingCmd,
|
|
769
1108
|
timewarrior: timewarriorCmd,
|
|
770
1109
|
which,
|
|
@@ -778,6 +1117,8 @@ async function main() {
|
|
|
778
1117
|
console.log(" add Add a new backend instance");
|
|
779
1118
|
console.log(" install Install Taskwarrior hooks and shell integration");
|
|
780
1119
|
console.log(" sync Pull tasks from all backends");
|
|
1120
|
+
console.log(" start Start a task by backend ID (e.g., tw-bridge start ghp#123)");
|
|
1121
|
+
console.log(" done Complete a task by backend ID (e.g., tw-bridge done ghp#123)");
|
|
781
1122
|
console.log(" meeting Track meetings in Timewarrior (no task created)");
|
|
782
1123
|
console.log(" timewarrior Manage Timewarrior integration");
|
|
783
1124
|
console.log(" which Print the context for the current directory");
|
|
@@ -854,18 +1195,18 @@ Added backend "${name}" (adapter: ${adapterType})`);
|
|
|
854
1195
|
Use 'task context ${matchTag}' to switch to this project.`);
|
|
855
1196
|
}
|
|
856
1197
|
async function install() {
|
|
857
|
-
|
|
858
|
-
const hookSource =
|
|
859
|
-
|
|
1198
|
+
fs5.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
1199
|
+
const hookSource = path6.resolve(
|
|
1200
|
+
path6.dirname(new URL(import.meta.url).pathname),
|
|
860
1201
|
"hooks",
|
|
861
1202
|
"on-modify.js"
|
|
862
1203
|
);
|
|
863
|
-
const hookTarget =
|
|
864
|
-
if (
|
|
865
|
-
|
|
1204
|
+
const hookTarget = path6.join(HOOKS_DIR, "on-modify.tw-bridge");
|
|
1205
|
+
if (fs5.existsSync(hookTarget)) {
|
|
1206
|
+
fs5.unlinkSync(hookTarget);
|
|
866
1207
|
}
|
|
867
|
-
|
|
868
|
-
|
|
1208
|
+
fs5.symlinkSync(hookSource, hookTarget);
|
|
1209
|
+
fs5.chmodSync(hookSource, 493);
|
|
869
1210
|
console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
|
|
870
1211
|
console.log("\nAdd these to your .taskrc:\n");
|
|
871
1212
|
console.log("# --- tw-bridge UDAs ---");
|
|
@@ -882,26 +1223,26 @@ async function install() {
|
|
|
882
1223
|
console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
|
|
883
1224
|
console.log("urgency.user.tag.in_beta.coefficient=-6.0");
|
|
884
1225
|
installTimewExtension();
|
|
885
|
-
if (
|
|
1226
|
+
if (fs5.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
886
1227
|
console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
|
|
887
1228
|
console.log(" tw-bridge timewarrior enable");
|
|
888
1229
|
}
|
|
889
1230
|
installShellFunction();
|
|
890
1231
|
}
|
|
891
|
-
var TIMEW_EXT_DIR =
|
|
1232
|
+
var TIMEW_EXT_DIR = path6.join(os5.homedir(), ".timewarrior", "extensions");
|
|
892
1233
|
function installTimewExtension() {
|
|
893
|
-
|
|
894
|
-
const extSource =
|
|
895
|
-
|
|
1234
|
+
fs5.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
|
|
1235
|
+
const extSource = path6.resolve(
|
|
1236
|
+
path6.dirname(new URL(import.meta.url).pathname),
|
|
896
1237
|
"extensions",
|
|
897
1238
|
"bridge.js"
|
|
898
1239
|
);
|
|
899
|
-
const extTarget =
|
|
900
|
-
if (
|
|
901
|
-
|
|
1240
|
+
const extTarget = path6.join(TIMEW_EXT_DIR, "bridge");
|
|
1241
|
+
if (fs5.existsSync(extTarget)) {
|
|
1242
|
+
fs5.unlinkSync(extTarget);
|
|
902
1243
|
}
|
|
903
|
-
|
|
904
|
-
|
|
1244
|
+
fs5.symlinkSync(extSource, extTarget);
|
|
1245
|
+
fs5.chmodSync(extSource, 493);
|
|
905
1246
|
console.log(`
|
|
906
1247
|
Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
|
|
907
1248
|
console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
|
|
@@ -915,8 +1256,8 @@ function detectProjectContext() {
|
|
|
915
1256
|
for (const [, backend] of Object.entries(config.backends)) {
|
|
916
1257
|
const backendCwd = backend.config?.cwd;
|
|
917
1258
|
if (!backendCwd) continue;
|
|
918
|
-
const resolved =
|
|
919
|
-
if (cwd === resolved || cwd.startsWith(resolved +
|
|
1259
|
+
const resolved = path6.resolve(backendCwd);
|
|
1260
|
+
if (cwd === resolved || cwd.startsWith(resolved + path6.sep)) {
|
|
920
1261
|
return backend.match.tags?.[0] ?? null;
|
|
921
1262
|
}
|
|
922
1263
|
}
|
|
@@ -1008,7 +1349,7 @@ async function meetingCmd() {
|
|
|
1008
1349
|
console.error(`Unknown subcommand: ${sub}`);
|
|
1009
1350
|
process.exit(1);
|
|
1010
1351
|
}
|
|
1011
|
-
var STANDARD_TIMEW_HOOK =
|
|
1352
|
+
var STANDARD_TIMEW_HOOK = path6.join(HOOKS_DIR, "on-modify.timewarrior");
|
|
1012
1353
|
async function timewarriorCmd() {
|
|
1013
1354
|
const sub = process.argv[3];
|
|
1014
1355
|
if (!sub || sub === "--help") {
|
|
@@ -1031,8 +1372,8 @@ async function timewarriorCmd() {
|
|
|
1031
1372
|
console.log("Timewarrior: enabled");
|
|
1032
1373
|
console.log(" Parallel tracking: use `task start <id> --parallel`");
|
|
1033
1374
|
}
|
|
1034
|
-
const hookExists =
|
|
1035
|
-
const hookDisabled =
|
|
1375
|
+
const hookExists = fs5.existsSync(STANDARD_TIMEW_HOOK);
|
|
1376
|
+
const hookDisabled = fs5.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
|
|
1036
1377
|
if (hookExists) {
|
|
1037
1378
|
console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
|
|
1038
1379
|
if (tw?.enabled) {
|
|
@@ -1050,9 +1391,9 @@ async function timewarriorCmd() {
|
|
|
1050
1391
|
const configPath = saveConfig(config);
|
|
1051
1392
|
console.log("Timewarrior tracking enabled");
|
|
1052
1393
|
console.log(`Config: ${configPath}`);
|
|
1053
|
-
if (
|
|
1394
|
+
if (fs5.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
1054
1395
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
1055
|
-
|
|
1396
|
+
fs5.renameSync(STANDARD_TIMEW_HOOK, disabled);
|
|
1056
1397
|
console.log(`
|
|
1057
1398
|
Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
1058
1399
|
console.log("tw-bridge will handle Timewarrior tracking directly.");
|
|
@@ -1065,8 +1406,8 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
|
1065
1406
|
console.log("Timewarrior tracking disabled");
|
|
1066
1407
|
console.log(`Config: ${configPath}`);
|
|
1067
1408
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
1068
|
-
if (
|
|
1069
|
-
|
|
1409
|
+
if (fs5.existsSync(disabled)) {
|
|
1410
|
+
fs5.renameSync(disabled, STANDARD_TIMEW_HOOK);
|
|
1070
1411
|
console.log(`
|
|
1071
1412
|
Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
1072
1413
|
}
|
|
@@ -1096,28 +1437,28 @@ task() {
|
|
|
1096
1437
|
var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
|
|
1097
1438
|
function installShellFunction() {
|
|
1098
1439
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
1099
|
-
const home =
|
|
1440
|
+
const home = os5.homedir();
|
|
1100
1441
|
let rcFile;
|
|
1101
1442
|
if (shell.endsWith("zsh")) {
|
|
1102
|
-
rcFile =
|
|
1443
|
+
rcFile = path6.join(home, ".zshrc");
|
|
1103
1444
|
} else {
|
|
1104
|
-
rcFile =
|
|
1445
|
+
rcFile = path6.join(home, ".bashrc");
|
|
1105
1446
|
}
|
|
1106
|
-
const existing =
|
|
1447
|
+
const existing = fs5.existsSync(rcFile) ? fs5.readFileSync(rcFile, "utf-8") : "";
|
|
1107
1448
|
if (existing.includes(SHELL_MARKER)) {
|
|
1108
1449
|
console.log(`
|
|
1109
1450
|
Shell integration already installed in ${rcFile}`);
|
|
1110
1451
|
return;
|
|
1111
1452
|
}
|
|
1112
|
-
|
|
1453
|
+
fs5.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
|
|
1113
1454
|
console.log(`
|
|
1114
1455
|
Shell integration installed in ${rcFile}`);
|
|
1115
1456
|
console.log("Restart your shell or run: source " + rcFile);
|
|
1116
1457
|
}
|
|
1117
|
-
var SEEN_FILE =
|
|
1458
|
+
var SEEN_FILE = path6.join(os5.homedir(), ".config", "tw-bridge", ".seen-dirs");
|
|
1118
1459
|
function loadSeenDirs() {
|
|
1119
1460
|
try {
|
|
1120
|
-
const raw =
|
|
1461
|
+
const raw = fs5.readFileSync(SEEN_FILE, "utf-8");
|
|
1121
1462
|
return new Set(raw.split("\n").filter(Boolean));
|
|
1122
1463
|
} catch {
|
|
1123
1464
|
return /* @__PURE__ */ new Set();
|
|
@@ -1127,15 +1468,15 @@ function markDirSeen(dir) {
|
|
|
1127
1468
|
const seen = loadSeenDirs();
|
|
1128
1469
|
if (seen.has(dir)) return;
|
|
1129
1470
|
seen.add(dir);
|
|
1130
|
-
|
|
1131
|
-
|
|
1471
|
+
fs5.mkdirSync(path6.dirname(SEEN_FILE), { recursive: true });
|
|
1472
|
+
fs5.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
|
|
1132
1473
|
}
|
|
1133
1474
|
function isGitRepo(dir) {
|
|
1134
1475
|
try {
|
|
1135
1476
|
let current = dir;
|
|
1136
|
-
while (current !==
|
|
1137
|
-
if (
|
|
1138
|
-
current =
|
|
1477
|
+
while (current !== path6.dirname(current)) {
|
|
1478
|
+
if (fs5.existsSync(path6.join(current, ".git"))) return true;
|
|
1479
|
+
current = path6.dirname(current);
|
|
1139
1480
|
}
|
|
1140
1481
|
return false;
|
|
1141
1482
|
} catch {
|
|
@@ -1148,8 +1489,8 @@ async function which() {
|
|
|
1148
1489
|
for (const [_name, backend] of Object.entries(config.backends)) {
|
|
1149
1490
|
const backendCwd = backend.config?.cwd;
|
|
1150
1491
|
if (!backendCwd) continue;
|
|
1151
|
-
const resolved =
|
|
1152
|
-
if (cwd === resolved || cwd.startsWith(resolved +
|
|
1492
|
+
const resolved = path6.resolve(backendCwd);
|
|
1493
|
+
if (cwd === resolved || cwd.startsWith(resolved + path6.sep)) {
|
|
1153
1494
|
const contextTag = backend.match.tags?.[0];
|
|
1154
1495
|
if (contextTag) {
|
|
1155
1496
|
process.stdout.write(contextTag);
|
|
@@ -1165,15 +1506,15 @@ async function which() {
|
|
|
1165
1506
|
const seen = loadSeenDirs();
|
|
1166
1507
|
let gitRoot = cwd;
|
|
1167
1508
|
let current = cwd;
|
|
1168
|
-
while (current !==
|
|
1169
|
-
if (
|
|
1509
|
+
while (current !== path6.dirname(current)) {
|
|
1510
|
+
if (fs5.existsSync(path6.join(current, ".git"))) {
|
|
1170
1511
|
gitRoot = current;
|
|
1171
1512
|
break;
|
|
1172
1513
|
}
|
|
1173
|
-
current =
|
|
1514
|
+
current = path6.dirname(current);
|
|
1174
1515
|
}
|
|
1175
1516
|
if (!seen.has(gitRoot)) {
|
|
1176
|
-
const dirName =
|
|
1517
|
+
const dirName = path6.basename(gitRoot);
|
|
1177
1518
|
process.stderr.write(
|
|
1178
1519
|
`tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
|
|
1179
1520
|
`
|
|
@@ -1193,6 +1534,75 @@ async function sync() {
|
|
|
1193
1534
|
await syncBackend(name, config.backends[name], config);
|
|
1194
1535
|
}
|
|
1195
1536
|
}
|
|
1537
|
+
function parseBackendRef(ref) {
|
|
1538
|
+
const match = ref.match(/^([^#]+)#(.+)$/);
|
|
1539
|
+
if (!match) return null;
|
|
1540
|
+
return { backend: match[1], id: match[2] };
|
|
1541
|
+
}
|
|
1542
|
+
async function resolveTaskByRef(ref) {
|
|
1543
|
+
const config = loadConfig();
|
|
1544
|
+
const backendName = Object.keys(config.backends).find(
|
|
1545
|
+
(name) => name === ref.backend || config.backends[name].adapter === ref.backend
|
|
1546
|
+
);
|
|
1547
|
+
if (!backendName) {
|
|
1548
|
+
console.error(`No backend found matching "${ref.backend}".`);
|
|
1549
|
+
console.error(`Configured backends: ${Object.keys(config.backends).join(", ")}`);
|
|
1550
|
+
process.exit(1);
|
|
1551
|
+
}
|
|
1552
|
+
let task = findTaskByBackendId(backendName, ref.id);
|
|
1553
|
+
if (!task) {
|
|
1554
|
+
console.log(`Task #${ref.id} not in Taskwarrior yet, syncing ${backendName}...`);
|
|
1555
|
+
await syncBackend(backendName, config.backends[backendName], config);
|
|
1556
|
+
task = findTaskByBackendId(backendName, ref.id);
|
|
1557
|
+
}
|
|
1558
|
+
if (!task) {
|
|
1559
|
+
console.error(`Task #${ref.id} not found in backend "${backendName}" after sync.`);
|
|
1560
|
+
process.exit(1);
|
|
1561
|
+
}
|
|
1562
|
+
return task;
|
|
1563
|
+
}
|
|
1564
|
+
async function startCmd() {
|
|
1565
|
+
const ref = process.argv[3];
|
|
1566
|
+
if (!ref || ref.startsWith("--")) {
|
|
1567
|
+
console.error("Usage: tw-bridge start <backend>#<id> (e.g., tw-bridge start ghp#123)");
|
|
1568
|
+
process.exit(1);
|
|
1569
|
+
}
|
|
1570
|
+
const parsed = parseBackendRef(ref);
|
|
1571
|
+
if (!parsed) {
|
|
1572
|
+
console.error(`Invalid reference "${ref}". Expected format: backend#id (e.g., ghp#123)`);
|
|
1573
|
+
process.exit(1);
|
|
1574
|
+
}
|
|
1575
|
+
const task = await resolveTaskByRef(parsed);
|
|
1576
|
+
if (task.start) {
|
|
1577
|
+
console.log(`Task #${parsed.id} is already started (${task.uuid.slice(0, 8)})`);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (startTask(task.uuid)) {
|
|
1581
|
+
console.log(`Started: [#${parsed.id}] ${task.description} (${task.uuid.slice(0, 8)})`);
|
|
1582
|
+
} else {
|
|
1583
|
+
console.error(`Failed to start task ${task.uuid}`);
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
async function doneCmd() {
|
|
1588
|
+
const ref = process.argv[3];
|
|
1589
|
+
if (!ref || ref.startsWith("--")) {
|
|
1590
|
+
console.error("Usage: tw-bridge done <backend>#<id> (e.g., tw-bridge done ghp#123)");
|
|
1591
|
+
process.exit(1);
|
|
1592
|
+
}
|
|
1593
|
+
const parsed = parseBackendRef(ref);
|
|
1594
|
+
if (!parsed) {
|
|
1595
|
+
console.error(`Invalid reference "${ref}". Expected format: backend#id (e.g., ghp#123)`);
|
|
1596
|
+
process.exit(1);
|
|
1597
|
+
}
|
|
1598
|
+
const task = await resolveTaskByRef(parsed);
|
|
1599
|
+
if (doneTask(task.uuid)) {
|
|
1600
|
+
console.log(`Completed: [#${parsed.id}] ${task.description} (${task.uuid.slice(0, 8)})`);
|
|
1601
|
+
} else {
|
|
1602
|
+
console.error(`Failed to complete task ${task.uuid}`);
|
|
1603
|
+
process.exit(1);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1196
1606
|
async function syncBackend(name, backend, config) {
|
|
1197
1607
|
const adapter = await resolveAdapter(
|
|
1198
1608
|
{ backend: name },
|
package/dist/hooks/on-modify.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/hooks/on-modify.ts
|
|
4
|
-
import
|
|
4
|
+
import fs5 from "fs";
|
|
5
5
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
6
6
|
|
|
7
7
|
// src/config.ts
|
|
@@ -234,8 +234,8 @@ async function oauthLogin(clientId, clientSecret, rl) {
|
|
|
234
234
|
saveTokens(tokens);
|
|
235
235
|
return tokens;
|
|
236
236
|
}
|
|
237
|
-
async function asanaFetch(
|
|
238
|
-
const url = `${API_BASE}${
|
|
237
|
+
async function asanaFetch(path6, token, options = {}) {
|
|
238
|
+
const url = `${API_BASE}${path6}`;
|
|
239
239
|
const res = await fetch(url, {
|
|
240
240
|
...options,
|
|
241
241
|
headers: {
|
|
@@ -251,9 +251,9 @@ async function asanaFetch(path5, token, options = {}) {
|
|
|
251
251
|
const json = await res.json();
|
|
252
252
|
return json.data;
|
|
253
253
|
}
|
|
254
|
-
async function asanaFetchAll(
|
|
254
|
+
async function asanaFetchAll(path6, token) {
|
|
255
255
|
const results = [];
|
|
256
|
-
let nextPage =
|
|
256
|
+
let nextPage = path6;
|
|
257
257
|
while (nextPage) {
|
|
258
258
|
const url = `${API_BASE}${nextPage}`;
|
|
259
259
|
const res = await fetch(url, {
|
|
@@ -565,10 +565,319 @@ function mapAsanaPriority(fields) {
|
|
|
565
565
|
return void 0;
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
// src/adapters/strety.ts
|
|
569
|
+
import fs3 from "fs";
|
|
570
|
+
import path4 from "path";
|
|
571
|
+
import os3 from "os";
|
|
572
|
+
import { execSync as execSync2 } from "child_process";
|
|
573
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
574
|
+
import { stdin as stdin2, stdout as stdout2 } from "process";
|
|
575
|
+
var API_BASE2 = "https://2.strety.com/api/v1";
|
|
576
|
+
var OAUTH_AUTHORIZE2 = `${API_BASE2}/oauth/authorize`;
|
|
577
|
+
var OAUTH_TOKEN2 = `${API_BASE2}/oauth/token`;
|
|
578
|
+
var TOKEN_FILE2 = path4.join(os3.homedir(), ".config", "tw-bridge", "strety-tokens.json");
|
|
579
|
+
function loadTokens2() {
|
|
580
|
+
try {
|
|
581
|
+
return JSON.parse(fs3.readFileSync(TOKEN_FILE2, "utf-8"));
|
|
582
|
+
} catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function saveTokens2(tokens) {
|
|
587
|
+
fs3.mkdirSync(path4.dirname(TOKEN_FILE2), { recursive: true });
|
|
588
|
+
fs3.writeFileSync(TOKEN_FILE2, JSON.stringify(tokens, null, 2), { mode: 384 });
|
|
589
|
+
}
|
|
590
|
+
async function refreshAccessToken2(tokens) {
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
if (tokens.access_token && tokens.expires_at > now + 3e5) {
|
|
593
|
+
return tokens.access_token;
|
|
594
|
+
}
|
|
595
|
+
const res = await fetch(OAUTH_TOKEN2, {
|
|
596
|
+
method: "POST",
|
|
597
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
598
|
+
body: new URLSearchParams({
|
|
599
|
+
grant_type: "refresh_token",
|
|
600
|
+
refresh_token: tokens.refresh_token,
|
|
601
|
+
client_id: tokens.client_id,
|
|
602
|
+
client_secret: tokens.client_secret
|
|
603
|
+
})
|
|
604
|
+
});
|
|
605
|
+
if (!res.ok) {
|
|
606
|
+
const body = await res.text();
|
|
607
|
+
throw new Error(`Token refresh failed (${res.status}): ${body}`);
|
|
608
|
+
}
|
|
609
|
+
const data = await res.json();
|
|
610
|
+
tokens.access_token = data.access_token;
|
|
611
|
+
if (data.refresh_token) {
|
|
612
|
+
tokens.refresh_token = data.refresh_token;
|
|
613
|
+
}
|
|
614
|
+
tokens.expires_at = now + (data.expires_in ?? 7200) * 1e3;
|
|
615
|
+
saveTokens2(tokens);
|
|
616
|
+
return tokens.access_token;
|
|
617
|
+
}
|
|
618
|
+
var REDIRECT_URI = "https://localhost/callback";
|
|
619
|
+
async function oauthLogin2(clientId, clientSecret, rl) {
|
|
620
|
+
const authUrl = `${OAUTH_AUTHORIZE2}?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent("read write")}`;
|
|
621
|
+
console.log(`
|
|
622
|
+
Opening browser for authorization...`);
|
|
623
|
+
console.log(` If it doesn't open, visit:
|
|
624
|
+
${authUrl}
|
|
625
|
+
`);
|
|
626
|
+
try {
|
|
627
|
+
execSync2(`xdg-open "${authUrl}" 2>/dev/null || open "${authUrl}" 2>/dev/null`, {
|
|
628
|
+
stdio: "ignore"
|
|
629
|
+
});
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
632
|
+
console.log(" After authorizing, the browser will redirect to a page that");
|
|
633
|
+
console.log(" won't load. Copy the full URL from the address bar and paste it here.\n");
|
|
634
|
+
const pasted = (await rl.question("Paste the callback URL: ")).trim();
|
|
635
|
+
let code;
|
|
636
|
+
try {
|
|
637
|
+
const url = new URL(pasted);
|
|
638
|
+
const error = url.searchParams.get("error");
|
|
639
|
+
if (error) {
|
|
640
|
+
throw new Error(`OAuth error: ${error}`);
|
|
641
|
+
}
|
|
642
|
+
const authCode = url.searchParams.get("code");
|
|
643
|
+
if (!authCode) {
|
|
644
|
+
throw new Error("No authorization code found in URL");
|
|
645
|
+
}
|
|
646
|
+
code = authCode;
|
|
647
|
+
} catch (err) {
|
|
648
|
+
if (err instanceof Error && err.message.startsWith("OAuth error:")) throw err;
|
|
649
|
+
if (err instanceof Error && err.message.startsWith("No authorization")) throw err;
|
|
650
|
+
throw new Error(`Could not parse callback URL: ${pasted}`);
|
|
651
|
+
}
|
|
652
|
+
const tokenRes = await fetch(OAUTH_TOKEN2, {
|
|
653
|
+
method: "POST",
|
|
654
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
655
|
+
body: new URLSearchParams({
|
|
656
|
+
grant_type: "authorization_code",
|
|
657
|
+
code,
|
|
658
|
+
redirect_uri: REDIRECT_URI,
|
|
659
|
+
client_id: clientId,
|
|
660
|
+
client_secret: clientSecret
|
|
661
|
+
})
|
|
662
|
+
});
|
|
663
|
+
if (!tokenRes.ok) {
|
|
664
|
+
const body = await tokenRes.text();
|
|
665
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
|
|
666
|
+
}
|
|
667
|
+
const data = await tokenRes.json();
|
|
668
|
+
const tokens = {
|
|
669
|
+
access_token: data.access_token,
|
|
670
|
+
refresh_token: data.refresh_token,
|
|
671
|
+
expires_at: Date.now() + (data.expires_in ?? 7200) * 1e3,
|
|
672
|
+
client_id: clientId,
|
|
673
|
+
client_secret: clientSecret
|
|
674
|
+
};
|
|
675
|
+
saveTokens2(tokens);
|
|
676
|
+
return tokens;
|
|
677
|
+
}
|
|
678
|
+
async function stretyFetch(urlOrPath, token, options = {}) {
|
|
679
|
+
const url = urlOrPath.startsWith("http") ? urlOrPath : `${API_BASE2}${urlOrPath}`;
|
|
680
|
+
const res = await fetch(url, {
|
|
681
|
+
...options,
|
|
682
|
+
headers: {
|
|
683
|
+
Authorization: `Bearer ${token}`,
|
|
684
|
+
Accept: "application/vnd.api+json",
|
|
685
|
+
"Content-Type": "application/vnd.api+json",
|
|
686
|
+
...options.headers
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
if (!res.ok) {
|
|
690
|
+
const body = await res.text();
|
|
691
|
+
throw new Error(`Strety API ${res.status}: ${body}`);
|
|
692
|
+
}
|
|
693
|
+
return res.json();
|
|
694
|
+
}
|
|
695
|
+
async function stretyFetchAll(path6, token) {
|
|
696
|
+
const results = [];
|
|
697
|
+
let url = `${API_BASE2}${path6}`;
|
|
698
|
+
while (url) {
|
|
699
|
+
const json = await stretyFetch(url, token);
|
|
700
|
+
results.push(...json.data);
|
|
701
|
+
url = json.links?.next ?? null;
|
|
702
|
+
}
|
|
703
|
+
return results;
|
|
704
|
+
}
|
|
705
|
+
async function pickOne2(rl, label, items) {
|
|
706
|
+
console.log(`
|
|
707
|
+
${label}:`);
|
|
708
|
+
for (let i = 0; i < items.length; i++) {
|
|
709
|
+
const name = items[i].attributes.name ?? items[i].attributes.title ?? items[i].id;
|
|
710
|
+
console.log(` ${i + 1}) ${name}`);
|
|
711
|
+
}
|
|
712
|
+
while (true) {
|
|
713
|
+
const answer = await rl.question(`Choose (1-${items.length}): `);
|
|
714
|
+
const idx = parseInt(answer, 10) - 1;
|
|
715
|
+
if (idx >= 0 && idx < items.length) {
|
|
716
|
+
const item = items[idx];
|
|
717
|
+
return { id: item.id, name: item.attributes.name ?? item.attributes.title ?? item.id };
|
|
718
|
+
}
|
|
719
|
+
console.log(" Invalid choice, try again.");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
var StretyAdapter = class {
|
|
723
|
+
name = "strety";
|
|
724
|
+
config;
|
|
725
|
+
accessToken = null;
|
|
726
|
+
async resolveToken() {
|
|
727
|
+
const auth = this.config.auth;
|
|
728
|
+
if (auth === "oauth") {
|
|
729
|
+
const tokens = loadTokens2();
|
|
730
|
+
if (!tokens) throw new Error("No OAuth tokens found. Run `tw-bridge add` to authenticate.");
|
|
731
|
+
this.accessToken = await refreshAccessToken2(tokens);
|
|
732
|
+
return this.accessToken;
|
|
733
|
+
}
|
|
734
|
+
if (this.accessToken) return this.accessToken;
|
|
735
|
+
if (auth.startsWith("env:")) {
|
|
736
|
+
const envVar = auth.slice(4);
|
|
737
|
+
const val = process.env[envVar];
|
|
738
|
+
if (!val) throw new Error(`Environment variable ${envVar} not set`);
|
|
739
|
+
this.accessToken = val;
|
|
740
|
+
return val;
|
|
741
|
+
}
|
|
742
|
+
this.accessToken = auth;
|
|
743
|
+
return auth;
|
|
744
|
+
}
|
|
745
|
+
defaultConfig(_cwd) {
|
|
746
|
+
return {
|
|
747
|
+
auth: "env:STRETY_TOKEN",
|
|
748
|
+
assignee_id: "",
|
|
749
|
+
assignee_name: ""
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
async setup(_cwd) {
|
|
753
|
+
const rl = createInterface2({ input: stdin2, output: stdout2 });
|
|
754
|
+
try {
|
|
755
|
+
console.log("\n\u2500\u2500 Strety Adapter Setup \u2500\u2500\n");
|
|
756
|
+
console.log("Authentication:");
|
|
757
|
+
console.log(" 1) OAuth (browser login)");
|
|
758
|
+
console.log(" 2) Environment variable");
|
|
759
|
+
const authChoice = (await rl.question("\nChoose (1-2): ")).trim();
|
|
760
|
+
let authConfig;
|
|
761
|
+
let token;
|
|
762
|
+
if (authChoice === "1") {
|
|
763
|
+
console.log(`
|
|
764
|
+
Register a Strety app at My Integrations > My Apps.`);
|
|
765
|
+
console.log(` Set the Redirect URI to: ${REDIRECT_URI}
|
|
766
|
+
`);
|
|
767
|
+
const clientId = (await rl.question("Strety App Client ID: ")).trim();
|
|
768
|
+
const clientSecret = (await rl.question("Strety App Client Secret: ")).trim();
|
|
769
|
+
if (!clientId || !clientSecret) {
|
|
770
|
+
console.error("\n Client ID and Secret are required.");
|
|
771
|
+
rl.close();
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
const tokens = await oauthLogin2(clientId, clientSecret, rl);
|
|
775
|
+
token = tokens.access_token;
|
|
776
|
+
authConfig = "oauth";
|
|
777
|
+
} else {
|
|
778
|
+
const envVar = await rl.question("Environment variable name [STRETY_TOKEN]: ");
|
|
779
|
+
const varName = envVar.trim() || "STRETY_TOKEN";
|
|
780
|
+
authConfig = `env:${varName}`;
|
|
781
|
+
token = process.env[varName] ?? "";
|
|
782
|
+
if (!token) {
|
|
783
|
+
console.error(`
|
|
784
|
+
Cannot complete setup without a valid token.`);
|
|
785
|
+
console.error(` Set ${varName} in your environment and try again.`);
|
|
786
|
+
rl.close();
|
|
787
|
+
process.exit(1);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
console.log("\nVerifying...");
|
|
791
|
+
let people;
|
|
792
|
+
try {
|
|
793
|
+
people = await stretyFetchAll("/people?page[size]=20", token);
|
|
794
|
+
} catch (err) {
|
|
795
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
796
|
+
console.error(` Authentication failed: ${msg}`);
|
|
797
|
+
rl.close();
|
|
798
|
+
process.exit(1);
|
|
799
|
+
}
|
|
800
|
+
console.log(` Connected! Found ${people.length} people.`);
|
|
801
|
+
const person = await pickOne2(rl, "Which person are you? (for filtering your todos)", people);
|
|
802
|
+
rl.close();
|
|
803
|
+
console.log(`
|
|
804
|
+
Assignee: ${person.name}`);
|
|
805
|
+
console.log(" Syncing: Todos assigned to you");
|
|
806
|
+
return {
|
|
807
|
+
auth: authConfig,
|
|
808
|
+
assignee_id: person.id,
|
|
809
|
+
assignee_name: person.name
|
|
810
|
+
};
|
|
811
|
+
} catch (err) {
|
|
812
|
+
rl.close();
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async init(config) {
|
|
817
|
+
this.config = config;
|
|
818
|
+
if (!this.config.assignee_id) {
|
|
819
|
+
throw new Error('strety adapter requires "assignee_id" in config');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async pull() {
|
|
823
|
+
const token = await this.resolveToken();
|
|
824
|
+
const raw = await stretyFetchAll(
|
|
825
|
+
`/todos?filter[assignee_id]=${this.config.assignee_id}&page[size]=20`,
|
|
826
|
+
token
|
|
827
|
+
);
|
|
828
|
+
const tasks = [];
|
|
829
|
+
for (const item of raw) {
|
|
830
|
+
const attrs = item.attributes;
|
|
831
|
+
if (attrs.completed_at) continue;
|
|
832
|
+
const priority = mapStretyPriority(attrs.priority);
|
|
833
|
+
const task = {
|
|
834
|
+
uuid: "",
|
|
835
|
+
description: attrs.title,
|
|
836
|
+
status: "pending",
|
|
837
|
+
entry: attrs.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
838
|
+
tags: [],
|
|
839
|
+
priority,
|
|
840
|
+
backend: "",
|
|
841
|
+
backend_id: item.id,
|
|
842
|
+
...attrs.due_date && { due: new Date(attrs.due_date).toISOString() }
|
|
843
|
+
};
|
|
844
|
+
tasks.push(task);
|
|
845
|
+
}
|
|
846
|
+
return tasks;
|
|
847
|
+
}
|
|
848
|
+
async onDone(task) {
|
|
849
|
+
const todoId = task.backend_id;
|
|
850
|
+
if (!todoId) return;
|
|
851
|
+
const token = await this.resolveToken();
|
|
852
|
+
process.stderr.write(`tw-bridge [strety]: marking todo complete
|
|
853
|
+
`);
|
|
854
|
+
await stretyFetch(`/todos/${todoId}`, token, {
|
|
855
|
+
method: "PATCH",
|
|
856
|
+
headers: { "If-Match": "*" },
|
|
857
|
+
body: JSON.stringify({
|
|
858
|
+
data: {
|
|
859
|
+
type: "todo",
|
|
860
|
+
attributes: {
|
|
861
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
function mapStretyPriority(priority) {
|
|
869
|
+
if (!priority || priority === "none") return void 0;
|
|
870
|
+
if (priority === "highest" || priority === "high") return "H";
|
|
871
|
+
if (priority === "medium") return "M";
|
|
872
|
+
if (priority === "low" || priority === "lowest") return "L";
|
|
873
|
+
return void 0;
|
|
874
|
+
}
|
|
875
|
+
|
|
568
876
|
// src/registry.ts
|
|
569
877
|
var BUILTIN_ADAPTERS = {
|
|
570
878
|
ghp: () => new GhpAdapter(),
|
|
571
|
-
asana: () => new AsanaAdapter()
|
|
879
|
+
asana: () => new AsanaAdapter(),
|
|
880
|
+
strety: () => new StretyAdapter()
|
|
572
881
|
};
|
|
573
882
|
function matchBackend(task, config) {
|
|
574
883
|
if (task.backend && config.backends[task.backend]) {
|
|
@@ -600,21 +909,21 @@ async function resolveAdapter(task, config) {
|
|
|
600
909
|
}
|
|
601
910
|
|
|
602
911
|
// src/tracking.ts
|
|
603
|
-
import
|
|
604
|
-
import
|
|
605
|
-
import
|
|
912
|
+
import fs4 from "fs";
|
|
913
|
+
import path5 from "path";
|
|
914
|
+
import os4 from "os";
|
|
606
915
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
607
|
-
var TRACKING_FILE =
|
|
916
|
+
var TRACKING_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", "tracking.json");
|
|
608
917
|
function loadTracking() {
|
|
609
918
|
try {
|
|
610
|
-
return JSON.parse(
|
|
919
|
+
return JSON.parse(fs4.readFileSync(TRACKING_FILE, "utf-8"));
|
|
611
920
|
} catch {
|
|
612
921
|
return {};
|
|
613
922
|
}
|
|
614
923
|
}
|
|
615
924
|
function saveTracking(state) {
|
|
616
|
-
|
|
617
|
-
|
|
925
|
+
fs4.mkdirSync(path5.dirname(TRACKING_FILE), { recursive: true });
|
|
926
|
+
fs4.writeFileSync(TRACKING_FILE, JSON.stringify(state));
|
|
618
927
|
}
|
|
619
928
|
function mergedTags(state) {
|
|
620
929
|
const all = /* @__PURE__ */ new Set();
|
|
@@ -689,10 +998,10 @@ tw-bridge: Currently tracking: ${tags} (${duration})`
|
|
|
689
998
|
lines.push(" [s] Switch \u2014 stop current, start new task (default)");
|
|
690
999
|
lines.push(" [p] Parallel \u2014 add new task to current tracking");
|
|
691
1000
|
lines.push("");
|
|
692
|
-
|
|
693
|
-
|
|
1001
|
+
fs5.writeSync(ttyFd, lines.join("\n"));
|
|
1002
|
+
fs5.writeSync(ttyFd, " > ");
|
|
694
1003
|
const buf = Buffer.alloc(64);
|
|
695
|
-
const bytesRead =
|
|
1004
|
+
const bytesRead = fs5.readSync(ttyFd, buf, 0, 64, null);
|
|
696
1005
|
const answer = buf.toString("utf-8", 0, bytesRead).trim().toLowerCase();
|
|
697
1006
|
return answer.startsWith("p") ? "parallel" : "switch";
|
|
698
1007
|
}
|
|
@@ -738,7 +1047,7 @@ function handleTimewarriorStop(config, task) {
|
|
|
738
1047
|
}
|
|
739
1048
|
}
|
|
740
1049
|
async function main() {
|
|
741
|
-
const input =
|
|
1050
|
+
const input = fs5.readFileSync("/dev/stdin", "utf-8").trim().split("\n");
|
|
742
1051
|
const oldTask = JSON.parse(input[0]);
|
|
743
1052
|
const newTask = JSON.parse(input[1]);
|
|
744
1053
|
process.stdout.write(JSON.stringify(newTask) + "\n");
|
|
@@ -749,7 +1058,7 @@ async function main() {
|
|
|
749
1058
|
let ttyFd = null;
|
|
750
1059
|
if (wasStarted) {
|
|
751
1060
|
try {
|
|
752
|
-
ttyFd =
|
|
1061
|
+
ttyFd = fs5.openSync("/dev/tty", "r+");
|
|
753
1062
|
} catch {
|
|
754
1063
|
}
|
|
755
1064
|
}
|
|
@@ -759,6 +1068,7 @@ async function main() {
|
|
|
759
1068
|
} else if (wasStopped || wasCompleted) {
|
|
760
1069
|
handleTimewarriorStop(config, oldTask);
|
|
761
1070
|
}
|
|
1071
|
+
if (process.env.TW_BRIDGE_REVERSE_SYNC) return;
|
|
762
1072
|
const adapter = await resolveAdapter(newTask, config);
|
|
763
1073
|
if (!adapter) return;
|
|
764
1074
|
if (wasStarted && adapter.onStart) {
|
|
@@ -777,7 +1087,7 @@ async function main() {
|
|
|
777
1087
|
process.stderr.write(`tw-bridge: ${msg}
|
|
778
1088
|
`);
|
|
779
1089
|
} finally {
|
|
780
|
-
if (ttyFd !== null)
|
|
1090
|
+
if (ttyFd !== null) fs5.closeSync(ttyFd);
|
|
781
1091
|
}
|
|
782
1092
|
}
|
|
783
1093
|
main();
|