@bretwardjames/tw-bridge 0.4.0 → 0.6.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 +519 -54
- package/dist/hooks/on-modify.js +352 -39
- 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(path5, 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]) {
|
|
@@ -707,12 +1016,65 @@ function updateTaskDescription(existing, newDescription) {
|
|
|
707
1016
|
return result.status === 0;
|
|
708
1017
|
}
|
|
709
1018
|
|
|
1019
|
+
// src/tracking.ts
|
|
1020
|
+
import fs4 from "fs";
|
|
1021
|
+
import path5 from "path";
|
|
1022
|
+
import os4 from "os";
|
|
1023
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1024
|
+
var TRACKING_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", "tracking.json");
|
|
1025
|
+
function loadTracking() {
|
|
1026
|
+
try {
|
|
1027
|
+
return JSON.parse(fs4.readFileSync(TRACKING_FILE, "utf-8"));
|
|
1028
|
+
} catch {
|
|
1029
|
+
return {};
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
function saveTracking(state) {
|
|
1033
|
+
fs4.mkdirSync(path5.dirname(TRACKING_FILE), { recursive: true });
|
|
1034
|
+
fs4.writeFileSync(TRACKING_FILE, JSON.stringify(state));
|
|
1035
|
+
}
|
|
1036
|
+
function mergedTags(state) {
|
|
1037
|
+
const all = /* @__PURE__ */ new Set();
|
|
1038
|
+
for (const tags of Object.values(state)) {
|
|
1039
|
+
for (const tag of tags) all.add(tag);
|
|
1040
|
+
}
|
|
1041
|
+
return [...all];
|
|
1042
|
+
}
|
|
1043
|
+
function startParallel(key, tags) {
|
|
1044
|
+
const tracking = loadTracking();
|
|
1045
|
+
tracking[key] = tags;
|
|
1046
|
+
saveTracking(tracking);
|
|
1047
|
+
const merged = mergedTags(tracking);
|
|
1048
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
1049
|
+
spawnSync3("timew", ["start", ...merged], { stdio: "pipe" });
|
|
1050
|
+
}
|
|
1051
|
+
function startSwitch(key, tags) {
|
|
1052
|
+
saveTracking({ [key]: tags });
|
|
1053
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
1054
|
+
spawnSync3("timew", ["start", ...tags], { stdio: "pipe" });
|
|
1055
|
+
}
|
|
1056
|
+
function stopEntry(key) {
|
|
1057
|
+
const tracking = loadTracking();
|
|
1058
|
+
delete tracking[key];
|
|
1059
|
+
saveTracking(tracking);
|
|
1060
|
+
const remaining = mergedTags(tracking);
|
|
1061
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
1062
|
+
if (remaining.length > 0) {
|
|
1063
|
+
spawnSync3("timew", ["start", ...remaining], { stdio: "pipe" });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function getActiveMeetings() {
|
|
1067
|
+
const tracking = loadTracking();
|
|
1068
|
+
return Object.entries(tracking).filter(([key]) => key.startsWith("meeting:")).map(([key, tags]) => ({ key, tags }));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
710
1071
|
// src/cli.ts
|
|
711
|
-
var HOOKS_DIR =
|
|
1072
|
+
var HOOKS_DIR = path6.join(os5.homedir(), ".task", "hooks");
|
|
712
1073
|
var commands = {
|
|
713
1074
|
add: addBackend,
|
|
714
1075
|
install,
|
|
715
1076
|
sync,
|
|
1077
|
+
meeting: meetingCmd,
|
|
716
1078
|
timewarrior: timewarriorCmd,
|
|
717
1079
|
which,
|
|
718
1080
|
config: showConfig
|
|
@@ -722,9 +1084,10 @@ async function main() {
|
|
|
722
1084
|
if (!command || command === "--help") {
|
|
723
1085
|
console.log("Usage: tw-bridge <command>\n");
|
|
724
1086
|
console.log("Commands:");
|
|
725
|
-
console.log(" add
|
|
726
|
-
console.log(" install
|
|
1087
|
+
console.log(" add Add a new backend instance");
|
|
1088
|
+
console.log(" install Install Taskwarrior hooks and shell integration");
|
|
727
1089
|
console.log(" sync Pull tasks from all backends");
|
|
1090
|
+
console.log(" meeting Track meetings in Timewarrior (no task created)");
|
|
728
1091
|
console.log(" timewarrior Manage Timewarrior integration");
|
|
729
1092
|
console.log(" which Print the context for the current directory");
|
|
730
1093
|
console.log(" config Show current configuration");
|
|
@@ -800,18 +1163,18 @@ Added backend "${name}" (adapter: ${adapterType})`);
|
|
|
800
1163
|
Use 'task context ${matchTag}' to switch to this project.`);
|
|
801
1164
|
}
|
|
802
1165
|
async function install() {
|
|
803
|
-
|
|
804
|
-
const hookSource =
|
|
805
|
-
|
|
1166
|
+
fs5.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
1167
|
+
const hookSource = path6.resolve(
|
|
1168
|
+
path6.dirname(new URL(import.meta.url).pathname),
|
|
806
1169
|
"hooks",
|
|
807
1170
|
"on-modify.js"
|
|
808
1171
|
);
|
|
809
|
-
const hookTarget =
|
|
810
|
-
if (
|
|
811
|
-
|
|
1172
|
+
const hookTarget = path6.join(HOOKS_DIR, "on-modify.tw-bridge");
|
|
1173
|
+
if (fs5.existsSync(hookTarget)) {
|
|
1174
|
+
fs5.unlinkSync(hookTarget);
|
|
812
1175
|
}
|
|
813
|
-
|
|
814
|
-
|
|
1176
|
+
fs5.symlinkSync(hookSource, hookTarget);
|
|
1177
|
+
fs5.chmodSync(hookSource, 493);
|
|
815
1178
|
console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
|
|
816
1179
|
console.log("\nAdd these to your .taskrc:\n");
|
|
817
1180
|
console.log("# --- tw-bridge UDAs ---");
|
|
@@ -828,31 +1191,133 @@ async function install() {
|
|
|
828
1191
|
console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
|
|
829
1192
|
console.log("urgency.user.tag.in_beta.coefficient=-6.0");
|
|
830
1193
|
installTimewExtension();
|
|
831
|
-
if (
|
|
1194
|
+
if (fs5.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
832
1195
|
console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
|
|
833
1196
|
console.log(" tw-bridge timewarrior enable");
|
|
834
1197
|
}
|
|
835
1198
|
installShellFunction();
|
|
836
1199
|
}
|
|
837
|
-
var TIMEW_EXT_DIR =
|
|
1200
|
+
var TIMEW_EXT_DIR = path6.join(os5.homedir(), ".timewarrior", "extensions");
|
|
838
1201
|
function installTimewExtension() {
|
|
839
|
-
|
|
840
|
-
const extSource =
|
|
841
|
-
|
|
1202
|
+
fs5.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
|
|
1203
|
+
const extSource = path6.resolve(
|
|
1204
|
+
path6.dirname(new URL(import.meta.url).pathname),
|
|
842
1205
|
"extensions",
|
|
843
1206
|
"bridge.js"
|
|
844
1207
|
);
|
|
845
|
-
const extTarget =
|
|
846
|
-
if (
|
|
847
|
-
|
|
1208
|
+
const extTarget = path6.join(TIMEW_EXT_DIR, "bridge");
|
|
1209
|
+
if (fs5.existsSync(extTarget)) {
|
|
1210
|
+
fs5.unlinkSync(extTarget);
|
|
848
1211
|
}
|
|
849
|
-
|
|
850
|
-
|
|
1212
|
+
fs5.symlinkSync(extSource, extTarget);
|
|
1213
|
+
fs5.chmodSync(extSource, 493);
|
|
851
1214
|
console.log(`
|
|
852
1215
|
Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
|
|
853
1216
|
console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
|
|
854
1217
|
}
|
|
855
|
-
|
|
1218
|
+
function sanitizeMeetingName(name) {
|
|
1219
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1220
|
+
}
|
|
1221
|
+
function detectProjectContext() {
|
|
1222
|
+
const cwd = process.cwd();
|
|
1223
|
+
const config = loadConfig();
|
|
1224
|
+
for (const [, backend] of Object.entries(config.backends)) {
|
|
1225
|
+
const backendCwd = backend.config?.cwd;
|
|
1226
|
+
if (!backendCwd) continue;
|
|
1227
|
+
const resolved = path6.resolve(backendCwd);
|
|
1228
|
+
if (cwd === resolved || cwd.startsWith(resolved + path6.sep)) {
|
|
1229
|
+
return backend.match.tags?.[0] ?? null;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
async function meetingCmd() {
|
|
1235
|
+
const sub = process.argv[3];
|
|
1236
|
+
if (!sub || sub === "--help") {
|
|
1237
|
+
console.log("Usage: tw-bridge meeting <subcommand>\n");
|
|
1238
|
+
console.log("Subcommands:");
|
|
1239
|
+
console.log(" start <name> [--switch] Start tracking a meeting");
|
|
1240
|
+
console.log(" stop [name] Stop a meeting (or all if no name)");
|
|
1241
|
+
console.log(" list Show active meetings");
|
|
1242
|
+
console.log("\nMeetings are tracked in Timewarrior only \u2014 no Taskwarrior task is created.");
|
|
1243
|
+
console.log("By default, meetings run in parallel with active tasks.");
|
|
1244
|
+
console.log("Use --switch to pause the current task instead.");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const config = loadConfig();
|
|
1248
|
+
if (!config.timewarrior?.enabled) {
|
|
1249
|
+
console.error("Timewarrior is not enabled. Run: tw-bridge timewarrior enable");
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
if (sub === "start") {
|
|
1253
|
+
const nameArg = process.argv.slice(4).filter((a) => !a.startsWith("--")).join(" ");
|
|
1254
|
+
if (!nameArg) {
|
|
1255
|
+
console.error("Usage: tw-bridge meeting start <name>");
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
}
|
|
1258
|
+
const switchMode = process.argv.includes("--switch") || process.argv.includes("-s");
|
|
1259
|
+
const tag = sanitizeMeetingName(nameArg);
|
|
1260
|
+
const key = `meeting:${tag}`;
|
|
1261
|
+
const tags = ["meeting", tag];
|
|
1262
|
+
const project = detectProjectContext();
|
|
1263
|
+
if (project) tags.push(project);
|
|
1264
|
+
if (switchMode) {
|
|
1265
|
+
startSwitch(key, tags);
|
|
1266
|
+
} else {
|
|
1267
|
+
startParallel(key, tags);
|
|
1268
|
+
}
|
|
1269
|
+
console.log(`Meeting started: ${nameArg}`);
|
|
1270
|
+
console.log(` Tags: ${tags.join(" ")}`);
|
|
1271
|
+
if (!switchMode) {
|
|
1272
|
+
console.log(" Mode: parallel (active tasks continue tracking)");
|
|
1273
|
+
} else {
|
|
1274
|
+
console.log(" Mode: switch (active tasks paused)");
|
|
1275
|
+
}
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (sub === "stop") {
|
|
1279
|
+
const nameArg = process.argv.slice(4).join(" ").trim();
|
|
1280
|
+
const active = getActiveMeetings();
|
|
1281
|
+
if (active.length === 0) {
|
|
1282
|
+
console.log("No active meetings.");
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (nameArg) {
|
|
1286
|
+
const tag = sanitizeMeetingName(nameArg);
|
|
1287
|
+
const key = `meeting:${tag}`;
|
|
1288
|
+
const match = active.find((m) => m.key === key);
|
|
1289
|
+
if (!match) {
|
|
1290
|
+
console.error(`No active meeting matching "${nameArg}".`);
|
|
1291
|
+
console.error(`Active meetings: ${active.map((m) => m.key.replace("meeting:", "")).join(", ")}`);
|
|
1292
|
+
process.exit(1);
|
|
1293
|
+
}
|
|
1294
|
+
stopEntry(key);
|
|
1295
|
+
console.log(`Meeting stopped: ${nameArg}`);
|
|
1296
|
+
} else {
|
|
1297
|
+
for (const m of active) {
|
|
1298
|
+
stopEntry(m.key);
|
|
1299
|
+
}
|
|
1300
|
+
console.log(`Stopped ${active.length} meeting(s): ${active.map((m) => m.key.replace("meeting:", "")).join(", ")}`);
|
|
1301
|
+
}
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (sub === "list") {
|
|
1305
|
+
const active = getActiveMeetings();
|
|
1306
|
+
if (active.length === 0) {
|
|
1307
|
+
console.log("No active meetings.");
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
console.log("Active meetings:");
|
|
1311
|
+
for (const m of active) {
|
|
1312
|
+
const name = m.key.replace("meeting:", "");
|
|
1313
|
+
console.log(` ${name} (${m.tags.join(" ")})`);
|
|
1314
|
+
}
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
1318
|
+
process.exit(1);
|
|
1319
|
+
}
|
|
1320
|
+
var STANDARD_TIMEW_HOOK = path6.join(HOOKS_DIR, "on-modify.timewarrior");
|
|
856
1321
|
async function timewarriorCmd() {
|
|
857
1322
|
const sub = process.argv[3];
|
|
858
1323
|
if (!sub || sub === "--help") {
|
|
@@ -875,8 +1340,8 @@ async function timewarriorCmd() {
|
|
|
875
1340
|
console.log("Timewarrior: enabled");
|
|
876
1341
|
console.log(" Parallel tracking: use `task start <id> --parallel`");
|
|
877
1342
|
}
|
|
878
|
-
const hookExists =
|
|
879
|
-
const hookDisabled =
|
|
1343
|
+
const hookExists = fs5.existsSync(STANDARD_TIMEW_HOOK);
|
|
1344
|
+
const hookDisabled = fs5.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
|
|
880
1345
|
if (hookExists) {
|
|
881
1346
|
console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
|
|
882
1347
|
if (tw?.enabled) {
|
|
@@ -894,9 +1359,9 @@ async function timewarriorCmd() {
|
|
|
894
1359
|
const configPath = saveConfig(config);
|
|
895
1360
|
console.log("Timewarrior tracking enabled");
|
|
896
1361
|
console.log(`Config: ${configPath}`);
|
|
897
|
-
if (
|
|
1362
|
+
if (fs5.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
898
1363
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
899
|
-
|
|
1364
|
+
fs5.renameSync(STANDARD_TIMEW_HOOK, disabled);
|
|
900
1365
|
console.log(`
|
|
901
1366
|
Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
902
1367
|
console.log("tw-bridge will handle Timewarrior tracking directly.");
|
|
@@ -909,8 +1374,8 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
|
909
1374
|
console.log("Timewarrior tracking disabled");
|
|
910
1375
|
console.log(`Config: ${configPath}`);
|
|
911
1376
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
912
|
-
if (
|
|
913
|
-
|
|
1377
|
+
if (fs5.existsSync(disabled)) {
|
|
1378
|
+
fs5.renameSync(disabled, STANDARD_TIMEW_HOOK);
|
|
914
1379
|
console.log(`
|
|
915
1380
|
Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
916
1381
|
}
|
|
@@ -940,28 +1405,28 @@ task() {
|
|
|
940
1405
|
var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
|
|
941
1406
|
function installShellFunction() {
|
|
942
1407
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
943
|
-
const home =
|
|
1408
|
+
const home = os5.homedir();
|
|
944
1409
|
let rcFile;
|
|
945
1410
|
if (shell.endsWith("zsh")) {
|
|
946
|
-
rcFile =
|
|
1411
|
+
rcFile = path6.join(home, ".zshrc");
|
|
947
1412
|
} else {
|
|
948
|
-
rcFile =
|
|
1413
|
+
rcFile = path6.join(home, ".bashrc");
|
|
949
1414
|
}
|
|
950
|
-
const existing =
|
|
1415
|
+
const existing = fs5.existsSync(rcFile) ? fs5.readFileSync(rcFile, "utf-8") : "";
|
|
951
1416
|
if (existing.includes(SHELL_MARKER)) {
|
|
952
1417
|
console.log(`
|
|
953
1418
|
Shell integration already installed in ${rcFile}`);
|
|
954
1419
|
return;
|
|
955
1420
|
}
|
|
956
|
-
|
|
1421
|
+
fs5.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
|
|
957
1422
|
console.log(`
|
|
958
1423
|
Shell integration installed in ${rcFile}`);
|
|
959
1424
|
console.log("Restart your shell or run: source " + rcFile);
|
|
960
1425
|
}
|
|
961
|
-
var SEEN_FILE =
|
|
1426
|
+
var SEEN_FILE = path6.join(os5.homedir(), ".config", "tw-bridge", ".seen-dirs");
|
|
962
1427
|
function loadSeenDirs() {
|
|
963
1428
|
try {
|
|
964
|
-
const raw =
|
|
1429
|
+
const raw = fs5.readFileSync(SEEN_FILE, "utf-8");
|
|
965
1430
|
return new Set(raw.split("\n").filter(Boolean));
|
|
966
1431
|
} catch {
|
|
967
1432
|
return /* @__PURE__ */ new Set();
|
|
@@ -971,15 +1436,15 @@ function markDirSeen(dir) {
|
|
|
971
1436
|
const seen = loadSeenDirs();
|
|
972
1437
|
if (seen.has(dir)) return;
|
|
973
1438
|
seen.add(dir);
|
|
974
|
-
|
|
975
|
-
|
|
1439
|
+
fs5.mkdirSync(path6.dirname(SEEN_FILE), { recursive: true });
|
|
1440
|
+
fs5.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
|
|
976
1441
|
}
|
|
977
1442
|
function isGitRepo(dir) {
|
|
978
1443
|
try {
|
|
979
1444
|
let current = dir;
|
|
980
|
-
while (current !==
|
|
981
|
-
if (
|
|
982
|
-
current =
|
|
1445
|
+
while (current !== path6.dirname(current)) {
|
|
1446
|
+
if (fs5.existsSync(path6.join(current, ".git"))) return true;
|
|
1447
|
+
current = path6.dirname(current);
|
|
983
1448
|
}
|
|
984
1449
|
return false;
|
|
985
1450
|
} catch {
|
|
@@ -992,8 +1457,8 @@ async function which() {
|
|
|
992
1457
|
for (const [_name, backend] of Object.entries(config.backends)) {
|
|
993
1458
|
const backendCwd = backend.config?.cwd;
|
|
994
1459
|
if (!backendCwd) continue;
|
|
995
|
-
const resolved =
|
|
996
|
-
if (cwd === resolved || cwd.startsWith(resolved +
|
|
1460
|
+
const resolved = path6.resolve(backendCwd);
|
|
1461
|
+
if (cwd === resolved || cwd.startsWith(resolved + path6.sep)) {
|
|
997
1462
|
const contextTag = backend.match.tags?.[0];
|
|
998
1463
|
if (contextTag) {
|
|
999
1464
|
process.stdout.write(contextTag);
|
|
@@ -1009,15 +1474,15 @@ async function which() {
|
|
|
1009
1474
|
const seen = loadSeenDirs();
|
|
1010
1475
|
let gitRoot = cwd;
|
|
1011
1476
|
let current = cwd;
|
|
1012
|
-
while (current !==
|
|
1013
|
-
if (
|
|
1477
|
+
while (current !== path6.dirname(current)) {
|
|
1478
|
+
if (fs5.existsSync(path6.join(current, ".git"))) {
|
|
1014
1479
|
gitRoot = current;
|
|
1015
1480
|
break;
|
|
1016
1481
|
}
|
|
1017
|
-
current =
|
|
1482
|
+
current = path6.dirname(current);
|
|
1018
1483
|
}
|
|
1019
1484
|
if (!seen.has(gitRoot)) {
|
|
1020
|
-
const dirName =
|
|
1485
|
+
const dirName = path6.basename(gitRoot);
|
|
1021
1486
|
process.stderr.write(
|
|
1022
1487
|
`tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
|
|
1023
1488
|
`
|
package/dist/hooks/on-modify.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/hooks/on-modify.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import os3 from "os";
|
|
7
|
-
import { spawnSync as spawnSync2 } from "child_process";
|
|
4
|
+
import fs5 from "fs";
|
|
5
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
8
6
|
|
|
9
7
|
// src/config.ts
|
|
10
8
|
import fs from "fs";
|
|
@@ -236,8 +234,8 @@ async function oauthLogin(clientId, clientSecret, rl) {
|
|
|
236
234
|
saveTokens(tokens);
|
|
237
235
|
return tokens;
|
|
238
236
|
}
|
|
239
|
-
async function asanaFetch(
|
|
240
|
-
const url = `${API_BASE}${
|
|
237
|
+
async function asanaFetch(path6, token, options = {}) {
|
|
238
|
+
const url = `${API_BASE}${path6}`;
|
|
241
239
|
const res = await fetch(url, {
|
|
242
240
|
...options,
|
|
243
241
|
headers: {
|
|
@@ -253,9 +251,9 @@ async function asanaFetch(path5, token, options = {}) {
|
|
|
253
251
|
const json = await res.json();
|
|
254
252
|
return json.data;
|
|
255
253
|
}
|
|
256
|
-
async function asanaFetchAll(
|
|
254
|
+
async function asanaFetchAll(path6, token) {
|
|
257
255
|
const results = [];
|
|
258
|
-
let nextPage =
|
|
256
|
+
let nextPage = path6;
|
|
259
257
|
while (nextPage) {
|
|
260
258
|
const url = `${API_BASE}${nextPage}`;
|
|
261
259
|
const res = await fetch(url, {
|
|
@@ -567,10 +565,319 @@ function mapAsanaPriority(fields) {
|
|
|
567
565
|
return void 0;
|
|
568
566
|
}
|
|
569
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
|
+
|
|
570
876
|
// src/registry.ts
|
|
571
877
|
var BUILTIN_ADAPTERS = {
|
|
572
878
|
ghp: () => new GhpAdapter(),
|
|
573
|
-
asana: () => new AsanaAdapter()
|
|
879
|
+
asana: () => new AsanaAdapter(),
|
|
880
|
+
strety: () => new StretyAdapter()
|
|
574
881
|
};
|
|
575
882
|
function matchBackend(task, config) {
|
|
576
883
|
if (task.backend && config.backends[task.backend]) {
|
|
@@ -601,18 +908,22 @@ async function resolveAdapter(task, config) {
|
|
|
601
908
|
return adapter;
|
|
602
909
|
}
|
|
603
910
|
|
|
604
|
-
// src/
|
|
605
|
-
|
|
911
|
+
// src/tracking.ts
|
|
912
|
+
import fs4 from "fs";
|
|
913
|
+
import path5 from "path";
|
|
914
|
+
import os4 from "os";
|
|
915
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
916
|
+
var TRACKING_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", "tracking.json");
|
|
606
917
|
function loadTracking() {
|
|
607
918
|
try {
|
|
608
|
-
return JSON.parse(
|
|
919
|
+
return JSON.parse(fs4.readFileSync(TRACKING_FILE, "utf-8"));
|
|
609
920
|
} catch {
|
|
610
921
|
return {};
|
|
611
922
|
}
|
|
612
923
|
}
|
|
613
924
|
function saveTracking(state) {
|
|
614
|
-
|
|
615
|
-
|
|
925
|
+
fs4.mkdirSync(path5.dirname(TRACKING_FILE), { recursive: true });
|
|
926
|
+
fs4.writeFileSync(TRACKING_FILE, JSON.stringify(state));
|
|
616
927
|
}
|
|
617
928
|
function mergedTags(state) {
|
|
618
929
|
const all = /* @__PURE__ */ new Set();
|
|
@@ -621,19 +932,6 @@ function mergedTags(state) {
|
|
|
621
932
|
}
|
|
622
933
|
return [...all];
|
|
623
934
|
}
|
|
624
|
-
function timewTags(task) {
|
|
625
|
-
const tags = [];
|
|
626
|
-
if (task.backend) tags.push(task.backend);
|
|
627
|
-
if (task.backend_id) tags.push(`#${task.backend_id}`);
|
|
628
|
-
if (task.project) tags.push(task.project);
|
|
629
|
-
for (const tag of task.tags ?? []) {
|
|
630
|
-
if (!tag.includes("_")) tags.push(tag);
|
|
631
|
-
}
|
|
632
|
-
return [...new Set(tags)];
|
|
633
|
-
}
|
|
634
|
-
function taskKey(task) {
|
|
635
|
-
return task.backend_id ? `#${task.backend_id}` : task.uuid;
|
|
636
|
-
}
|
|
637
935
|
function getCurrentInterval() {
|
|
638
936
|
const result = spawnSync2("timew", ["export"], {
|
|
639
937
|
encoding: "utf-8",
|
|
@@ -647,6 +945,21 @@ function getCurrentInterval() {
|
|
|
647
945
|
return null;
|
|
648
946
|
}
|
|
649
947
|
}
|
|
948
|
+
|
|
949
|
+
// src/hooks/on-modify.ts
|
|
950
|
+
function timewTags(task) {
|
|
951
|
+
const tags = [];
|
|
952
|
+
if (task.backend) tags.push(task.backend);
|
|
953
|
+
if (task.backend_id) tags.push(`#${task.backend_id}`);
|
|
954
|
+
if (task.project) tags.push(task.project);
|
|
955
|
+
for (const tag of task.tags ?? []) {
|
|
956
|
+
if (!tag.includes("_")) tags.push(tag);
|
|
957
|
+
}
|
|
958
|
+
return [...new Set(tags)];
|
|
959
|
+
}
|
|
960
|
+
function taskKey(task) {
|
|
961
|
+
return task.backend_id ? `#${task.backend_id}` : task.uuid;
|
|
962
|
+
}
|
|
650
963
|
function formatDuration(isoStart) {
|
|
651
964
|
const start = new Date(
|
|
652
965
|
isoStart.replace(
|
|
@@ -685,10 +998,10 @@ tw-bridge: Currently tracking: ${tags} (${duration})`
|
|
|
685
998
|
lines.push(" [s] Switch \u2014 stop current, start new task (default)");
|
|
686
999
|
lines.push(" [p] Parallel \u2014 add new task to current tracking");
|
|
687
1000
|
lines.push("");
|
|
688
|
-
|
|
689
|
-
|
|
1001
|
+
fs5.writeSync(ttyFd, lines.join("\n"));
|
|
1002
|
+
fs5.writeSync(ttyFd, " > ");
|
|
690
1003
|
const buf = Buffer.alloc(64);
|
|
691
|
-
const bytesRead =
|
|
1004
|
+
const bytesRead = fs5.readSync(ttyFd, buf, 0, 64, null);
|
|
692
1005
|
const answer = buf.toString("utf-8", 0, bytesRead).trim().toLowerCase();
|
|
693
1006
|
return answer.startsWith("p") ? "parallel" : "switch";
|
|
694
1007
|
}
|
|
@@ -713,12 +1026,12 @@ function handleTimewarriorStart(config, newTask, ttyFd) {
|
|
|
713
1026
|
tracking[key] = newTags;
|
|
714
1027
|
saveTracking(tracking);
|
|
715
1028
|
const merged = mergedTags(tracking);
|
|
716
|
-
|
|
717
|
-
|
|
1029
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
1030
|
+
spawnSync3("timew", ["start", ...merged], { stdio: "pipe" });
|
|
718
1031
|
} else {
|
|
719
1032
|
saveTracking({ [key]: newTags });
|
|
720
|
-
|
|
721
|
-
|
|
1033
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
1034
|
+
spawnSync3("timew", ["start", ...newTags], { stdio: "pipe" });
|
|
722
1035
|
}
|
|
723
1036
|
}
|
|
724
1037
|
function handleTimewarriorStop(config, task) {
|
|
@@ -728,13 +1041,13 @@ function handleTimewarriorStop(config, task) {
|
|
|
728
1041
|
delete tracking[key];
|
|
729
1042
|
saveTracking(tracking);
|
|
730
1043
|
const remaining = mergedTags(tracking);
|
|
731
|
-
|
|
1044
|
+
spawnSync3("timew", ["stop"], { stdio: "pipe" });
|
|
732
1045
|
if (remaining.length > 0) {
|
|
733
|
-
|
|
1046
|
+
spawnSync3("timew", ["start", ...remaining], { stdio: "pipe" });
|
|
734
1047
|
}
|
|
735
1048
|
}
|
|
736
1049
|
async function main() {
|
|
737
|
-
const input =
|
|
1050
|
+
const input = fs5.readFileSync("/dev/stdin", "utf-8").trim().split("\n");
|
|
738
1051
|
const oldTask = JSON.parse(input[0]);
|
|
739
1052
|
const newTask = JSON.parse(input[1]);
|
|
740
1053
|
process.stdout.write(JSON.stringify(newTask) + "\n");
|
|
@@ -745,7 +1058,7 @@ async function main() {
|
|
|
745
1058
|
let ttyFd = null;
|
|
746
1059
|
if (wasStarted) {
|
|
747
1060
|
try {
|
|
748
|
-
ttyFd =
|
|
1061
|
+
ttyFd = fs5.openSync("/dev/tty", "r+");
|
|
749
1062
|
} catch {
|
|
750
1063
|
}
|
|
751
1064
|
}
|
|
@@ -773,7 +1086,7 @@ async function main() {
|
|
|
773
1086
|
process.stderr.write(`tw-bridge: ${msg}
|
|
774
1087
|
`);
|
|
775
1088
|
} finally {
|
|
776
|
-
if (ttyFd !== null)
|
|
1089
|
+
if (ttyFd !== null) fs5.closeSync(ttyFd);
|
|
777
1090
|
}
|
|
778
1091
|
}
|
|
779
1092
|
main();
|