@worca/ui 0.1.0-rc.5 → 0.1.0-rc.6

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/app/styles.css CHANGED
@@ -3895,3 +3895,22 @@ sl-details.learnings-panel::part(content) {
3895
3895
  color: var(--fg);
3896
3896
  }
3897
3897
 
3898
+ /* ─── Version info rows ─────────────────────────────────────────────── */
3899
+ .version-row { display: flex; align-items: center; padding: 4px 0; gap: 8px; }
3900
+ .version-row-label { font-size: 12px; color: var(--muted); white-space: nowrap; min-width: 64px; }
3901
+ .version-row-value { font-size: 13px; font-weight: 500; color: var(--fg); font-family: var(--sl-font-mono); white-space: nowrap; margin-left: auto; display: flex; align-items: center; gap: 6px; }
3902
+ .settings-card-header > sl-badge { margin-left: auto; }
3903
+ .version-title-exact { text-transform: none; }
3904
+ .version-copy-btn {
3905
+ background: none; border: 1px solid var(--border); border-radius: 4px;
3906
+ padding: 2px 4px; cursor: pointer; color: var(--muted);
3907
+ display: inline-flex; align-items: center; margin-left: 4px;
3908
+ transition: var(--transition-fast);
3909
+ }
3910
+ .version-copy-btn:hover { background: var(--bg-tertiary); color: var(--fg); }
3911
+ .version-copy-icon { display: inline-flex; align-items: center; min-width: 12px; }
3912
+ .version-refresh { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
3913
+ .version-refresh-hint { font-size: 11px; color: var(--muted); }
3914
+ .project-worca-version { font-size: 11px; color: var(--muted); font-family: var(--sl-font-mono); margin-top: 2px; }
3915
+ .project-worca-version--behind { color: var(--status-failed, #dc2626); }
3916
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0-rc.6",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/server/app.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  createProjectScopedRoutes,
14
14
  projectResolver,
15
15
  } from './project-routes.js';
16
+ import { getVersionInfo } from './versions.js';
16
17
  import { createInbox } from './webhook-inbox.js';
17
18
 
18
19
  export function createApp(options = {}) {
@@ -403,6 +404,19 @@ export function createApp(options = {}) {
403
404
  }
404
405
  });
405
406
 
407
+ // GET /api/versions — installed + registry version info
408
+ app.get('/api/versions', async (req, res) => {
409
+ const force = req.query.force === '1';
410
+ const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
411
+ const worcaVersion = app.locals.worcaVersion || null;
412
+ try {
413
+ const data = await getVersionInfo({ prefsPath, worcaVersion, force });
414
+ res.json(data);
415
+ } catch (err) {
416
+ res.status(500).json({ ok: false, error: err.message });
417
+ }
418
+ });
419
+
406
420
  // ─── Multi-project routes ──────────────────────────────────────────────
407
421
  if (prefsDir) {
408
422
  app.use('/api/projects', createProjectRoutes({ prefsDir, projectRoot }));
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
 
4
- const DEFAULTS = { theme: 'light', source_repo: '' };
4
+ const DEFAULTS = { theme: 'light', source_repo: '', notifications: null };
5
5
 
6
6
  export function readPreferences(path) {
7
7
  try {
@@ -37,7 +37,11 @@ import {
37
37
  } from './settings-merge.js';
38
38
  import { validateSettingsPayload } from './settings-validator.js';
39
39
  import { discoverRuns } from './watcher.js';
40
- import { checkWorcaInstalled, runWorcaSetup } from './worca-setup.js';
40
+ import {
41
+ checkWorcaInstalled,
42
+ readProjectWorcaVersion,
43
+ runWorcaSetup,
44
+ } from './worca-setup.js';
41
45
 
42
46
  /** Validate a runId — must not contain path traversal characters */
43
47
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
@@ -133,13 +137,16 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
133
137
 
134
138
  // GET /api/projects — list all projects (or synthesized default)
135
139
  router.get('/', (_req, res) => {
136
- const projects = readProjects(prefsDir);
137
- if (projects.length > 0) {
138
- return res.json({ ok: true, projects });
139
- }
140
- // No registered projects synthesize from cwd
141
- const synth = synthesizeDefaultProject(projectRoot);
142
- res.json({ ok: true, projects: [synth] });
140
+ let projects = readProjects(prefsDir);
141
+ if (projects.length === 0) {
142
+ projects = [synthesizeDefaultProject(projectRoot)];
143
+ }
144
+ // Enrich each project with its worca-cc version
145
+ const enriched = projects.map((p) => ({
146
+ ...p,
147
+ worcaVersion: readProjectWorcaVersion(p.path),
148
+ }));
149
+ res.json({ ok: true, projects: enriched });
143
150
  });
144
151
 
145
152
  // POST /api/projects — create a new project
@@ -203,7 +210,17 @@ export function createProjectScopedRoutes() {
203
210
  router.get('/runs', requireWorcaDir, (req, res) => {
204
211
  try {
205
212
  const runs = discoverRuns(req.project.worcaDir);
206
- res.json({ ok: true, runs });
213
+ const response = { ok: true, runs };
214
+ // Include settings so multi-project clients can use loop limits, etc.
215
+ const { settingsPath } = req.project;
216
+ if (settingsPath && existsSync(settingsPath)) {
217
+ try {
218
+ response.settings = readMergedSettings(settingsPath);
219
+ } catch {
220
+ /* non-fatal — runs still returned */
221
+ }
222
+ }
223
+ res.json(response);
207
224
  } catch (err) {
208
225
  res.status(500).json({ ok: false, error: err.message });
209
226
  }
@@ -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.