@bretwardjames/tw-bridge 0.5.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 CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs4 from "fs";
5
- import path5 from "path";
6
- import os4 from "os";
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(path6, token, options = {}) {
245
- const url = `${API_BASE}${path6}`;
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(path6, token) {
261
+ async function asanaFetchAll(path7, token) {
262
262
  const results = [];
263
- let nextPage = path6;
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]) {
@@ -708,21 +1017,21 @@ function updateTaskDescription(existing, newDescription) {
708
1017
  }
709
1018
 
710
1019
  // src/tracking.ts
711
- import fs3 from "fs";
712
- import path4 from "path";
713
- import os3 from "os";
1020
+ import fs4 from "fs";
1021
+ import path5 from "path";
1022
+ import os4 from "os";
714
1023
  import { spawnSync as spawnSync3 } from "child_process";
715
- var TRACKING_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", "tracking.json");
1024
+ var TRACKING_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", "tracking.json");
716
1025
  function loadTracking() {
717
1026
  try {
718
- return JSON.parse(fs3.readFileSync(TRACKING_FILE, "utf-8"));
1027
+ return JSON.parse(fs4.readFileSync(TRACKING_FILE, "utf-8"));
719
1028
  } catch {
720
1029
  return {};
721
1030
  }
722
1031
  }
723
1032
  function saveTracking(state) {
724
- fs3.mkdirSync(path4.dirname(TRACKING_FILE), { recursive: true });
725
- fs3.writeFileSync(TRACKING_FILE, JSON.stringify(state));
1033
+ fs4.mkdirSync(path5.dirname(TRACKING_FILE), { recursive: true });
1034
+ fs4.writeFileSync(TRACKING_FILE, JSON.stringify(state));
726
1035
  }
727
1036
  function mergedTags(state) {
728
1037
  const all = /* @__PURE__ */ new Set();
@@ -760,7 +1069,7 @@ function getActiveMeetings() {
760
1069
  }
761
1070
 
762
1071
  // src/cli.ts
763
- var HOOKS_DIR = path5.join(os4.homedir(), ".task", "hooks");
1072
+ var HOOKS_DIR = path6.join(os5.homedir(), ".task", "hooks");
764
1073
  var commands = {
765
1074
  add: addBackend,
766
1075
  install,
@@ -854,18 +1163,18 @@ Added backend "${name}" (adapter: ${adapterType})`);
854
1163
  Use 'task context ${matchTag}' to switch to this project.`);
855
1164
  }
856
1165
  async function install() {
857
- fs4.mkdirSync(HOOKS_DIR, { recursive: true });
858
- const hookSource = path5.resolve(
859
- path5.dirname(new URL(import.meta.url).pathname),
1166
+ fs5.mkdirSync(HOOKS_DIR, { recursive: true });
1167
+ const hookSource = path6.resolve(
1168
+ path6.dirname(new URL(import.meta.url).pathname),
860
1169
  "hooks",
861
1170
  "on-modify.js"
862
1171
  );
863
- const hookTarget = path5.join(HOOKS_DIR, "on-modify.tw-bridge");
864
- if (fs4.existsSync(hookTarget)) {
865
- fs4.unlinkSync(hookTarget);
1172
+ const hookTarget = path6.join(HOOKS_DIR, "on-modify.tw-bridge");
1173
+ if (fs5.existsSync(hookTarget)) {
1174
+ fs5.unlinkSync(hookTarget);
866
1175
  }
867
- fs4.symlinkSync(hookSource, hookTarget);
868
- fs4.chmodSync(hookSource, 493);
1176
+ fs5.symlinkSync(hookSource, hookTarget);
1177
+ fs5.chmodSync(hookSource, 493);
869
1178
  console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
870
1179
  console.log("\nAdd these to your .taskrc:\n");
871
1180
  console.log("# --- tw-bridge UDAs ---");
@@ -882,26 +1191,26 @@ async function install() {
882
1191
  console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
883
1192
  console.log("urgency.user.tag.in_beta.coefficient=-6.0");
884
1193
  installTimewExtension();
885
- if (fs4.existsSync(STANDARD_TIMEW_HOOK)) {
1194
+ if (fs5.existsSync(STANDARD_TIMEW_HOOK)) {
886
1195
  console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
887
1196
  console.log(" tw-bridge timewarrior enable");
888
1197
  }
889
1198
  installShellFunction();
890
1199
  }
891
- var TIMEW_EXT_DIR = path5.join(os4.homedir(), ".timewarrior", "extensions");
1200
+ var TIMEW_EXT_DIR = path6.join(os5.homedir(), ".timewarrior", "extensions");
892
1201
  function installTimewExtension() {
893
- fs4.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
894
- const extSource = path5.resolve(
895
- path5.dirname(new URL(import.meta.url).pathname),
1202
+ fs5.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
1203
+ const extSource = path6.resolve(
1204
+ path6.dirname(new URL(import.meta.url).pathname),
896
1205
  "extensions",
897
1206
  "bridge.js"
898
1207
  );
899
- const extTarget = path5.join(TIMEW_EXT_DIR, "bridge");
900
- if (fs4.existsSync(extTarget)) {
901
- fs4.unlinkSync(extTarget);
1208
+ const extTarget = path6.join(TIMEW_EXT_DIR, "bridge");
1209
+ if (fs5.existsSync(extTarget)) {
1210
+ fs5.unlinkSync(extTarget);
902
1211
  }
903
- fs4.symlinkSync(extSource, extTarget);
904
- fs4.chmodSync(extSource, 493);
1212
+ fs5.symlinkSync(extSource, extTarget);
1213
+ fs5.chmodSync(extSource, 493);
905
1214
  console.log(`
906
1215
  Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
907
1216
  console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
@@ -915,8 +1224,8 @@ function detectProjectContext() {
915
1224
  for (const [, backend] of Object.entries(config.backends)) {
916
1225
  const backendCwd = backend.config?.cwd;
917
1226
  if (!backendCwd) continue;
918
- const resolved = path5.resolve(backendCwd);
919
- if (cwd === resolved || cwd.startsWith(resolved + path5.sep)) {
1227
+ const resolved = path6.resolve(backendCwd);
1228
+ if (cwd === resolved || cwd.startsWith(resolved + path6.sep)) {
920
1229
  return backend.match.tags?.[0] ?? null;
921
1230
  }
922
1231
  }
@@ -1008,7 +1317,7 @@ async function meetingCmd() {
1008
1317
  console.error(`Unknown subcommand: ${sub}`);
1009
1318
  process.exit(1);
1010
1319
  }
1011
- var STANDARD_TIMEW_HOOK = path5.join(HOOKS_DIR, "on-modify.timewarrior");
1320
+ var STANDARD_TIMEW_HOOK = path6.join(HOOKS_DIR, "on-modify.timewarrior");
1012
1321
  async function timewarriorCmd() {
1013
1322
  const sub = process.argv[3];
1014
1323
  if (!sub || sub === "--help") {
@@ -1031,8 +1340,8 @@ async function timewarriorCmd() {
1031
1340
  console.log("Timewarrior: enabled");
1032
1341
  console.log(" Parallel tracking: use `task start <id> --parallel`");
1033
1342
  }
1034
- const hookExists = fs4.existsSync(STANDARD_TIMEW_HOOK);
1035
- const hookDisabled = fs4.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
1343
+ const hookExists = fs5.existsSync(STANDARD_TIMEW_HOOK);
1344
+ const hookDisabled = fs5.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
1036
1345
  if (hookExists) {
1037
1346
  console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
1038
1347
  if (tw?.enabled) {
@@ -1050,9 +1359,9 @@ async function timewarriorCmd() {
1050
1359
  const configPath = saveConfig(config);
1051
1360
  console.log("Timewarrior tracking enabled");
1052
1361
  console.log(`Config: ${configPath}`);
1053
- if (fs4.existsSync(STANDARD_TIMEW_HOOK)) {
1362
+ if (fs5.existsSync(STANDARD_TIMEW_HOOK)) {
1054
1363
  const disabled = STANDARD_TIMEW_HOOK + ".disabled";
1055
- fs4.renameSync(STANDARD_TIMEW_HOOK, disabled);
1364
+ fs5.renameSync(STANDARD_TIMEW_HOOK, disabled);
1056
1365
  console.log(`
1057
1366
  Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
1058
1367
  console.log("tw-bridge will handle Timewarrior tracking directly.");
@@ -1065,8 +1374,8 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
1065
1374
  console.log("Timewarrior tracking disabled");
1066
1375
  console.log(`Config: ${configPath}`);
1067
1376
  const disabled = STANDARD_TIMEW_HOOK + ".disabled";
1068
- if (fs4.existsSync(disabled)) {
1069
- fs4.renameSync(disabled, STANDARD_TIMEW_HOOK);
1377
+ if (fs5.existsSync(disabled)) {
1378
+ fs5.renameSync(disabled, STANDARD_TIMEW_HOOK);
1070
1379
  console.log(`
1071
1380
  Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
1072
1381
  }
@@ -1096,28 +1405,28 @@ task() {
1096
1405
  var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
1097
1406
  function installShellFunction() {
1098
1407
  const shell = process.env.SHELL ?? "/bin/bash";
1099
- const home = os4.homedir();
1408
+ const home = os5.homedir();
1100
1409
  let rcFile;
1101
1410
  if (shell.endsWith("zsh")) {
1102
- rcFile = path5.join(home, ".zshrc");
1411
+ rcFile = path6.join(home, ".zshrc");
1103
1412
  } else {
1104
- rcFile = path5.join(home, ".bashrc");
1413
+ rcFile = path6.join(home, ".bashrc");
1105
1414
  }
1106
- const existing = fs4.existsSync(rcFile) ? fs4.readFileSync(rcFile, "utf-8") : "";
1415
+ const existing = fs5.existsSync(rcFile) ? fs5.readFileSync(rcFile, "utf-8") : "";
1107
1416
  if (existing.includes(SHELL_MARKER)) {
1108
1417
  console.log(`
1109
1418
  Shell integration already installed in ${rcFile}`);
1110
1419
  return;
1111
1420
  }
1112
- fs4.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
1421
+ fs5.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
1113
1422
  console.log(`
1114
1423
  Shell integration installed in ${rcFile}`);
1115
1424
  console.log("Restart your shell or run: source " + rcFile);
1116
1425
  }
1117
- var SEEN_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", ".seen-dirs");
1426
+ var SEEN_FILE = path6.join(os5.homedir(), ".config", "tw-bridge", ".seen-dirs");
1118
1427
  function loadSeenDirs() {
1119
1428
  try {
1120
- const raw = fs4.readFileSync(SEEN_FILE, "utf-8");
1429
+ const raw = fs5.readFileSync(SEEN_FILE, "utf-8");
1121
1430
  return new Set(raw.split("\n").filter(Boolean));
1122
1431
  } catch {
1123
1432
  return /* @__PURE__ */ new Set();
@@ -1127,15 +1436,15 @@ function markDirSeen(dir) {
1127
1436
  const seen = loadSeenDirs();
1128
1437
  if (seen.has(dir)) return;
1129
1438
  seen.add(dir);
1130
- fs4.mkdirSync(path5.dirname(SEEN_FILE), { recursive: true });
1131
- fs4.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
1439
+ fs5.mkdirSync(path6.dirname(SEEN_FILE), { recursive: true });
1440
+ fs5.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
1132
1441
  }
1133
1442
  function isGitRepo(dir) {
1134
1443
  try {
1135
1444
  let current = dir;
1136
- while (current !== path5.dirname(current)) {
1137
- if (fs4.existsSync(path5.join(current, ".git"))) return true;
1138
- current = path5.dirname(current);
1445
+ while (current !== path6.dirname(current)) {
1446
+ if (fs5.existsSync(path6.join(current, ".git"))) return true;
1447
+ current = path6.dirname(current);
1139
1448
  }
1140
1449
  return false;
1141
1450
  } catch {
@@ -1148,8 +1457,8 @@ async function which() {
1148
1457
  for (const [_name, backend] of Object.entries(config.backends)) {
1149
1458
  const backendCwd = backend.config?.cwd;
1150
1459
  if (!backendCwd) continue;
1151
- const resolved = path5.resolve(backendCwd);
1152
- if (cwd === resolved || cwd.startsWith(resolved + path5.sep)) {
1460
+ const resolved = path6.resolve(backendCwd);
1461
+ if (cwd === resolved || cwd.startsWith(resolved + path6.sep)) {
1153
1462
  const contextTag = backend.match.tags?.[0];
1154
1463
  if (contextTag) {
1155
1464
  process.stdout.write(contextTag);
@@ -1165,15 +1474,15 @@ async function which() {
1165
1474
  const seen = loadSeenDirs();
1166
1475
  let gitRoot = cwd;
1167
1476
  let current = cwd;
1168
- while (current !== path5.dirname(current)) {
1169
- if (fs4.existsSync(path5.join(current, ".git"))) {
1477
+ while (current !== path6.dirname(current)) {
1478
+ if (fs5.existsSync(path6.join(current, ".git"))) {
1170
1479
  gitRoot = current;
1171
1480
  break;
1172
1481
  }
1173
- current = path5.dirname(current);
1482
+ current = path6.dirname(current);
1174
1483
  }
1175
1484
  if (!seen.has(gitRoot)) {
1176
- const dirName = path5.basename(gitRoot);
1485
+ const dirName = path6.basename(gitRoot);
1177
1486
  process.stderr.write(
1178
1487
  `tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
1179
1488
  `
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/hooks/on-modify.ts
4
- import fs4 from "fs";
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(path5, token, options = {}) {
238
- const url = `${API_BASE}${path5}`;
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(path5, token) {
254
+ async function asanaFetchAll(path6, token) {
255
255
  const results = [];
256
- let nextPage = path5;
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 fs3 from "fs";
604
- import path4 from "path";
605
- import os3 from "os";
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 = path4.join(os3.homedir(), ".config", "tw-bridge", "tracking.json");
916
+ var TRACKING_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", "tracking.json");
608
917
  function loadTracking() {
609
918
  try {
610
- return JSON.parse(fs3.readFileSync(TRACKING_FILE, "utf-8"));
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
- fs3.mkdirSync(path4.dirname(TRACKING_FILE), { recursive: true });
617
- fs3.writeFileSync(TRACKING_FILE, JSON.stringify(state));
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
- fs4.writeSync(ttyFd, lines.join("\n"));
693
- fs4.writeSync(ttyFd, " > ");
1001
+ fs5.writeSync(ttyFd, lines.join("\n"));
1002
+ fs5.writeSync(ttyFd, " > ");
694
1003
  const buf = Buffer.alloc(64);
695
- const bytesRead = fs4.readSync(ttyFd, buf, 0, 64, null);
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 = fs4.readFileSync("/dev/stdin", "utf-8").trim().split("\n");
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 = fs4.openSync("/dev/tty", "r+");
1061
+ ttyFd = fs5.openSync("/dev/tty", "r+");
753
1062
  } catch {
754
1063
  }
755
1064
  }
@@ -777,7 +1086,7 @@ async function main() {
777
1086
  process.stderr.write(`tw-bridge: ${msg}
778
1087
  `);
779
1088
  } finally {
780
- if (ttyFd !== null) fs4.closeSync(ttyFd);
1089
+ if (ttyFd !== null) fs5.closeSync(ttyFd);
781
1090
  }
782
1091
  }
783
1092
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bretwardjames/tw-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Taskwarrior backend bridge — unified sync and hooks for multiple task management platforms",
5
5
  "type": "module",
6
6
  "license": "MIT",