honeytree 1.2.7 → 1.3.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/bin/honeydew.js CHANGED
@@ -64,15 +64,12 @@ if (command === "init") {
64
64
  console.log(" Done.");
65
65
  } else if (command === "rewards") {
66
66
  const { isLoggedIn } = await import("../src/auth.js");
67
- const { syncRewards, printRewardsStatus } = await import("../src/rewards.js");
68
- const { readForest } = await import("../src/state.js");
67
+ const { syncRewards, printRewardsStatus, getRewards } = await import("../src/rewards.js");
69
68
  if (isLoggedIn()) {
70
69
  console.log(" Fetching rewards...");
71
70
  await syncRewards();
72
71
  }
73
- const forest = readForest();
74
- const realTrees = 0; // local doesn't know real trees; printRewardsStatus uses cached data
75
- printRewardsStatus(realTrees);
72
+ printRewardsStatus(getRewards().realTrees || 0);
76
73
  } else if (command === "status") {
77
74
  const { getAuth } = await import("../src/auth.js");
78
75
  const auth = getAuth();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeytree",
3
- "version": "1.2.7",
3
+ "version": "1.3.0",
4
4
  "description": "code with claude, and watch your forest grow! ",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -29,6 +29,51 @@ export function isLoggedIn() {
29
29
  return !!(auth && auth.access_token);
30
30
  }
31
31
 
32
+ // Supabase access tokens live ~1 hour. Trade the stored refresh token for a
33
+ // fresh pair so syncs keep working between logins.
34
+ export async function refreshAuth(apiUrl) {
35
+ apiUrl = apiUrl || process.env.HONEYTREE_API_URL || "https://www.tryhoney.xyz";
36
+ const auth = getAuth();
37
+ if (!auth || !auth.refresh_token) return null;
38
+ try {
39
+ const res = await fetch(`${apiUrl}/api/auth/refresh`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ refresh_token: auth.refresh_token }),
43
+ });
44
+ if (!res.ok) return null;
45
+ const data = await res.json();
46
+ if (!data.access_token) return null;
47
+ const next = {
48
+ ...auth,
49
+ access_token: data.access_token,
50
+ refresh_token: data.refresh_token || auth.refresh_token,
51
+ };
52
+ saveAuth(next);
53
+ return next;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Bearer fetch that retries once through a token refresh on 401.
60
+ // Returns null when not logged in.
61
+ export async function authedFetch(url, options = {}) {
62
+ const auth = getAuth();
63
+ if (!auth || !auth.access_token) return null;
64
+ const doFetch = (token) =>
65
+ fetch(url, {
66
+ ...options,
67
+ headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` },
68
+ });
69
+ let res = await doFetch(auth.access_token);
70
+ if (res.status === 401) {
71
+ const refreshed = await refreshAuth();
72
+ if (refreshed) res = await doFetch(refreshed.access_token);
73
+ }
74
+ return res;
75
+ }
76
+
32
77
  export async function loginWithDevice(apiUrl = process.env.HONEYTREE_API_URL || "https://www.tryhoney.xyz") {
33
78
  const res = await fetch(`${apiUrl}/api/auth/device`, { method: "POST" });
34
79
  const contentType = res.headers.get("content-type") || "";
@@ -74,6 +119,7 @@ export async function loginWithDevice(apiUrl = process.env.HONEYTREE_API_URL ||
74
119
  if (data.status === "complete") {
75
120
  saveAuth({
76
121
  access_token: data.access_token,
122
+ refresh_token: data.refresh_token || null,
77
123
  user_id: data.user.id,
78
124
  username: data.user.username,
79
125
  });
package/src/plant.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getSprite } from "./sprites.js";
2
2
  import { getUnlockedVarietyKeys } from "./rewards.js";
3
- import { unlockedPool, pickSpecies } from "./varieties.js";
3
+ import { eraSpecies, pickSpecies } from "./varieties.js";
4
4
  import { createEmptyForest, readForest, writeForest } from "./state.js";
5
5
  import { findBadgeFile, writeBadgeSVG } from "./badge.js";
6
6
  import { migrateLayout } from "./migrate.js";
@@ -107,7 +107,7 @@ export async function tick(shape = null) {
107
107
  let type = shape?.type;
108
108
  let variant = shape?.variant ?? null;
109
109
  if (!type) {
110
- const species = pickSpecies(unlockedPool(getUnlockedVarietyKeys()));
110
+ const species = pickSpecies(eraSpecies(getUnlockedVarietyKeys()));
111
111
  type = species.type;
112
112
  variant = species.variant ?? null;
113
113
  }
package/src/rewards.js CHANGED
@@ -46,16 +46,19 @@ export async function syncRewards(apiUrl) {
46
46
  if (!auth || !auth.access_token) return null;
47
47
 
48
48
  try {
49
- const res = await fetch(`${apiUrl}/api/rewards`, {
50
- headers: { Authorization: `Bearer ${auth.access_token}` },
51
- });
49
+ const { authedFetch } = await import("./auth.js");
50
+ // authedFetch refreshes the 1-hour access token and retries once on 401.
51
+ const res = await authedFetch(`${apiUrl}/api/rewards`);
52
+ if (!res || !res.ok) return null;
52
53
 
53
- if (!res.ok) return null;
54
-
55
- const { rewards } = await res.json();
54
+ const { rewards, real_trees_planted } = await res.json();
56
55
  const unlocked = rewards.filter((r) => r.unlocked);
57
56
  const slugs = unlocked.map((r) => r.slug);
58
57
 
58
+ const prev = getRewards();
59
+ const prevReal = typeof prev.realTrees === "number" ? prev.realTrees : null;
60
+ const serverReal = typeof real_trees_planted === "number" ? real_trees_planted : 0;
61
+
59
62
  const data = {
60
63
  badges: unlocked.map((r) => ({ slug: r.slug, label: r.label, description: r.description })),
61
64
  cherry: slugs.includes("cherry"),
@@ -63,17 +66,41 @@ export async function syncRewards(apiUrl) {
63
66
  oak: slugs.includes("oak"),
64
67
  ancient: slugs.includes("ancient"),
65
68
  mythic: slugs.includes("mythic"),
66
- celebrated: getRewards().celebrated,
69
+ celebrated: prev.celebrated,
70
+ realTrees: serverReal,
67
71
  username: auth.username || "",
68
72
  };
69
73
 
70
74
  saveRewards(data);
75
+
76
+ // A real tree was paid for since we last looked: the old forest is cleared
77
+ // and a fresh era begins (all new trees take the latest unlocked variety).
78
+ // The cumulative prompt counter survives, so "virtual trees grown" on the
79
+ // site keeps its total. First sync after upgrade only sets the baseline.
80
+ if (prevReal !== null && serverReal > prevReal) {
81
+ await wipeForestForNewEra();
82
+ }
83
+
71
84
  return data;
72
85
  } catch {
73
86
  return null;
74
87
  }
75
88
  }
76
89
 
90
+ // Clear all trees locally and push the empty forest up so the dashboard
91
+ // mirrors the reset. totalPrompts/streak are kept.
92
+ async function wipeForestForNewEra() {
93
+ const { readForest, writeForest } = await import("./state.js");
94
+ const forest = readForest();
95
+ if (!forest) return;
96
+ forest.trees = [];
97
+ writeForest(forest);
98
+ try {
99
+ const { syncToCloud } = await import("./sync.js");
100
+ await syncToCloud(forest);
101
+ } catch {}
102
+ }
103
+
77
104
  // Print rewards status to console
78
105
  export function printRewardsStatus(realTreesPlanted = 0) {
79
106
  const rewards = getRewards();
package/src/sync.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getAuth, saveAuth } from "./auth.js";
1
+ import { getAuth, saveAuth, authedFetch } from "./auth.js";
2
2
  import { readForest } from "./state.js";
3
3
 
4
4
  const API_URL = process.env.HONEYTREE_API_URL || "https://www.tryhoney.xyz";
@@ -18,12 +18,10 @@ export async function syncToCloud(forest) {
18
18
  }));
19
19
 
20
20
  try {
21
- const res = await fetch(`${API_URL}/api/sync`, {
21
+ // authedFetch refreshes the access token and retries once on 401.
22
+ const res = await authedFetch(`${API_URL}/api/sync`, {
22
23
  method: "POST",
23
- headers: {
24
- "Content-Type": "application/json",
25
- Authorization: `Bearer ${auth.access_token}`,
26
- },
24
+ headers: { "Content-Type": "application/json" },
27
25
  body: JSON.stringify({
28
26
  count: forest.totalPrompts,
29
27
  streak: forest.streak || 0,
@@ -31,9 +29,10 @@ export async function syncToCloud(forest) {
31
29
  }),
32
30
  });
33
31
 
34
- if (res.ok) {
35
- auth.lastSyncedPrompts = forest.totalPrompts;
36
- saveAuth(auth);
32
+ if (res && res.ok) {
33
+ const fresh = getAuth() || auth;
34
+ fresh.lastSyncedPrompts = forest.totalPrompts;
35
+ saveAuth(fresh);
37
36
  }
38
37
  } catch {
39
38
  // Silently fail — sync is best-effort
package/src/turn.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { readForest, createEmptyForest } from "./state.js";
3
3
  import { getUnlockedVarietyKeys } from "./rewards.js";
4
- import { unlockedPool, pickSpecies } from "./varieties.js";
4
+ import { eraSpecies, pickSpecies } from "./varieties.js";
5
5
  import { getPlantWidth, findOpenX } from "./plant.js";
6
6
  import { writeActiveSession, readActiveSession, clearActiveSession } from "./session.js";
7
7
  import { readTurnTokens } from "./transcript.js";
@@ -22,7 +22,7 @@ export function startTurn(payload) {
22
22
  if (!transcript_path) return;
23
23
 
24
24
  const forest = readForest() ?? createEmptyForest();
25
- const species = pickSpecies(unlockedPool(getUnlockedVarietyKeys()));
25
+ const species = pickSpecies(eraSpecies(getUnlockedVarietyKeys()));
26
26
  const type = species.type;
27
27
  const variant = species.variant ?? null;
28
28
  const width = getPlantWidth(forest);
package/src/varieties.js CHANGED
@@ -11,6 +11,17 @@ export const VARIETIES = [
11
11
  { key: "mythic", threshold: 10, label: "Mythic", species: [{ type: "mythic" }] },
12
12
  ];
13
13
 
14
+ // The forest grows one variety at a time: each real-tree payment wipes the
15
+ // forest and starts a new era where every tree takes the LATEST unlocked
16
+ // variety. Standard (birch/willow) before any unlock.
17
+ export function eraSpecies(unlockedKeys = []) {
18
+ const keys = new Set(unlockedKeys);
19
+ for (let i = VARIETIES.length - 1; i >= 1; i--) {
20
+ if (keys.has(VARIETIES[i].key)) return VARIETIES[i].species;
21
+ }
22
+ return VARIETIES[0].species;
23
+ }
24
+
14
25
  // Build the species pool from the set of unlocked variety keys.
15
26
  // `standard` is always included.
16
27
  export function unlockedPool(unlockedKeys = []) {