@worca/ui 0.1.0-rc.5 → 0.1.1

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.
@@ -0,0 +1,260 @@
1
+ // server/versions.js — version fetching + caching for worca-cc and @worca/ui
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { readPreferences } from './preferences.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ /** Cache: { data, timestamp } */
10
+ let _cache = null;
11
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
12
+
13
+ /**
14
+ * Compare two semver-ish strings. Returns -1, 0, or 1.
15
+ * Strips pre-release suffixes for comparison (e.g. "rc", "dev", "alpha").
16
+ * @param {string} a
17
+ * @param {string} b
18
+ * @returns {number}
19
+ */
20
+ export function compareVersions(a, b) {
21
+ if (!a || !b) return 0;
22
+ const parse = (v) =>
23
+ v.split('.').map((s) => {
24
+ const n = parseInt(s, 10);
25
+ return Number.isNaN(n) ? 0 : n;
26
+ });
27
+ const pa = parse(a);
28
+ const pb = parse(b);
29
+ const len = Math.max(pa.length, pb.length);
30
+ for (let i = 0; i < len; i++) {
31
+ const x = pa[i] || 0;
32
+ const y = pb[i] || 0;
33
+ if (x > y) return 1;
34
+ if (x < y) return -1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ /**
40
+ * Parse a PyPI pre-release version like "0.6.0rc7" → { base: "0.6.0", rc: 7 }
41
+ * Returns null if not an RC version.
42
+ * @param {string} versionStr
43
+ * @returns {{ base: string, rc: number } | null}
44
+ */
45
+ export function parsePyPIPreRelease(versionStr) {
46
+ const match = versionStr.match(/^(.+?)rc(\d+)$/);
47
+ if (!match) return null;
48
+ return { base: match[1], rc: parseInt(match[2], 10) };
49
+ }
50
+
51
+ /**
52
+ * Fetch latest + latestRc versions from npm registry.
53
+ * @param {string} packageName
54
+ * @returns {Promise<{ latest: string|null, latestRc: string|null }>}
55
+ */
56
+ export async function fetchNpmVersions(packageName) {
57
+ const nullResult = { latest: null, latestRc: null };
58
+ try {
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), 3000);
61
+ let res;
62
+ try {
63
+ res = await fetch(`https://registry.npmjs.org/${packageName}`, {
64
+ signal: controller.signal,
65
+ });
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ if (!res.ok) return nullResult;
70
+ const data = await res.json();
71
+ const latest = data['dist-tags']?.latest || null;
72
+ let latestRc = data['dist-tags']?.rc || null;
73
+ if (!latestRc && data.versions) {
74
+ // Scan version keys for highest *-rc.* pattern
75
+ let bestRc = null;
76
+ let bestRcNum = -1;
77
+ for (const ver of Object.keys(data.versions)) {
78
+ const rcMatch = ver.match(/^(.+)-rc\.(\d+)$/);
79
+ if (rcMatch) {
80
+ const rcNum = parseInt(rcMatch[2], 10);
81
+ const base = rcMatch[1];
82
+ // Compare base+rcNum to find the highest RC
83
+ if (
84
+ !bestRc ||
85
+ compareVersions(base, bestRc.base) > 0 ||
86
+ (compareVersions(base, bestRc.base) === 0 && rcNum > bestRcNum)
87
+ ) {
88
+ bestRc = { base, full: ver };
89
+ bestRcNum = rcNum;
90
+ }
91
+ }
92
+ }
93
+ if (bestRc) latestRc = bestRc.full;
94
+ }
95
+ return { latest, latestRc };
96
+ } catch {
97
+ return nullResult;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Fetch latest + latestRc versions from PyPI.
103
+ * @param {string} packageName
104
+ * @returns {Promise<{ latest: string|null, latestRc: string|null }>}
105
+ */
106
+ export async function fetchPyPIVersions(packageName) {
107
+ const nullResult = { latest: null, latestRc: null };
108
+ try {
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => controller.abort(), 3000);
111
+ let res;
112
+ try {
113
+ res = await fetch(`https://pypi.org/pypi/${packageName}/json`, {
114
+ signal: controller.signal,
115
+ });
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ if (!res.ok) return nullResult;
120
+ const data = await res.json();
121
+ const latest = data.info?.version || null;
122
+ // Scan releases for highest rc version
123
+ let latestRc = null;
124
+ if (data.releases) {
125
+ let bestRcNum = -1;
126
+ let bestBase = null;
127
+ for (const ver of Object.keys(data.releases)) {
128
+ const parsed = parsePyPIPreRelease(ver);
129
+ if (parsed) {
130
+ if (
131
+ !bestBase ||
132
+ compareVersions(parsed.base, bestBase) > 0 ||
133
+ (compareVersions(parsed.base, bestBase) === 0 &&
134
+ parsed.rc > bestRcNum)
135
+ ) {
136
+ bestBase = parsed.base;
137
+ bestRcNum = parsed.rc;
138
+ latestRc = ver;
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return { latest, latestRc };
144
+ } catch {
145
+ return nullResult;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Read versions from local dev path.
151
+ * @param {string} sourceRepo - path to local worca-cc repo
152
+ * @returns {{ worcaCc: string|null, worcaUi: string|null }}
153
+ */
154
+ export function getDevPathVersions(sourceRepo) {
155
+ const result = { worcaCc: null, worcaUi: null };
156
+ if (!sourceRepo) return result;
157
+ try {
158
+ const pyproject = readFileSync(join(sourceRepo, 'pyproject.toml'), 'utf8');
159
+ const match = pyproject.match(/^version\s*=\s*"([^"]+)"/m);
160
+ if (match) result.worcaCc = match[1];
161
+ } catch {
162
+ // pyproject.toml not found or unreadable
163
+ }
164
+ try {
165
+ const pkg = JSON.parse(
166
+ readFileSync(join(sourceRepo, 'worca-ui', 'package.json'), 'utf8'),
167
+ );
168
+ if (pkg.version) result.worcaUi = pkg.version;
169
+ } catch {
170
+ // package.json not found or unreadable
171
+ }
172
+ return result;
173
+ }
174
+
175
+ /**
176
+ * Get installed @worca/ui version from own package.json.
177
+ * @returns {string|null}
178
+ */
179
+ function getInstalledUiVersion() {
180
+ try {
181
+ const pkg = JSON.parse(
182
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
183
+ );
184
+ return pkg.version || null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Main orchestrator: fetch all version info with caching.
192
+ * @param {{ prefsPath?: string|null, worcaVersion?: object|null, force?: boolean }} options
193
+ * @returns {Promise<object>}
194
+ */
195
+ export async function getVersionInfo({ prefsPath, worcaVersion, force } = {}) {
196
+ // Return cached result if fresh
197
+ if (!force && _cache && Date.now() - _cache.timestamp < CACHE_TTL_MS) {
198
+ return _cache.data;
199
+ }
200
+
201
+ // Read source_repo from preferences
202
+ let sourceRepo = null;
203
+ if (prefsPath) {
204
+ const prefs = readPreferences(prefsPath);
205
+ sourceRepo = prefs.source_repo || null;
206
+ }
207
+
208
+ // Fetch in parallel
209
+ const [npmResult, pypiResult] = await Promise.allSettled([
210
+ fetchNpmVersions('@worca/ui'),
211
+ fetchPyPIVersions('worca-cc'),
212
+ ]);
213
+
214
+ const npm =
215
+ npmResult.status === 'fulfilled'
216
+ ? npmResult.value
217
+ : { latest: null, latestRc: null };
218
+ const pypi =
219
+ pypiResult.status === 'fulfilled'
220
+ ? pypiResult.value
221
+ : { latest: null, latestRc: null };
222
+
223
+ // Dev path versions
224
+ const devVersions = sourceRepo ? getDevPathVersions(sourceRepo) : null;
225
+
226
+ // Installed versions
227
+ const installedUi = getInstalledUiVersion();
228
+ const installedCc = worcaVersion?.installed || null;
229
+
230
+ const data = {
231
+ ok: true,
232
+ worcaCc: {
233
+ installed: installedCc,
234
+ latest: pypi.latest,
235
+ latestRc: pypi.latestRc,
236
+ },
237
+ worcaUi: {
238
+ installed: installedUi,
239
+ latest: npm.latest,
240
+ latestRc: npm.latestRc,
241
+ },
242
+ devPath: devVersions
243
+ ? {
244
+ path: sourceRepo,
245
+ worcaCc: devVersions.worcaCc,
246
+ worcaUi: devVersions.worcaUi,
247
+ }
248
+ : null,
249
+ activeWorcaCc: devVersions?.worcaCc || installedCc,
250
+ cachedAt: new Date().toISOString(),
251
+ };
252
+
253
+ _cache = { data, timestamp: Date.now() };
254
+ return data;
255
+ }
256
+
257
+ /** Clear the cache (for testing). */
258
+ export function clearCache() {
259
+ _cache = null;
260
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { spawn } from 'node:child_process';
12
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
 
15
15
  /**
@@ -19,6 +19,23 @@ export function checkWorcaInstalled(projectPath) {
19
19
  return existsSync(join(projectPath, '.claude', 'worca'));
20
20
  }
21
21
 
22
+ /**
23
+ * Read the worca-cc version from a project's .claude/worca/__init__.py.
24
+ * Returns the version string or null if not found.
25
+ */
26
+ export function readProjectWorcaVersion(projectPath) {
27
+ try {
28
+ const initPy = readFileSync(
29
+ join(projectPath, '.claude', 'worca', '__init__.py'),
30
+ 'utf8',
31
+ );
32
+ const match = initPy.match(/^__version__\s*=\s*["']([^"']+)["']/m);
33
+ return match ? match[1] : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Spawn `worca init --upgrade` in the target project directory.
24
41
  * Optionally passes --source if a source repo path is provided.
@@ -8,7 +8,7 @@ import { existsSync, watch } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { listIssues } from './beads-reader.js';
10
10
 
11
- const BEADS_DEBOUNCE_MS = 200;
11
+ const BEADS_DEBOUNCE_MS = 300;
12
12
 
13
13
  /**
14
14
  * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps