sh3-core 0.5.0 → 0.5.2

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/api.d.ts CHANGED
@@ -23,4 +23,5 @@ export declare const capabilities: {
23
23
  readonly hotInstall: boolean;
24
24
  };
25
25
  export type { ServerShard, ServerShardContext } from './server-shard/types';
26
+ export { VERSION } from './version';
26
27
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
package/dist/api.js CHANGED
@@ -44,5 +44,7 @@ export const capabilities = {
44
44
  /** Whether this target supports hot-installing packages via dynamic import from blob URL. */
45
45
  hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
46
46
  };
47
+ // Package version.
48
+ export { VERSION } from './version';
47
49
  // Theme token override API (shell-level theming support).
48
50
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
@@ -11,6 +11,7 @@
11
11
  * Reads through the public sh3 API surface only.
12
12
  */
13
13
 
14
+ import { onMount } from 'svelte';
14
15
  import {
15
16
  listRegisteredApps,
16
17
  getActiveApp,
@@ -25,11 +26,30 @@
25
26
  const regShards = $derived(Array.from(registeredShards.values()));
26
27
  const actShards = $derived(Array.from(activeShards.keys()));
27
28
 
29
+ let serverVersion = $state<string | null>(null);
30
+
31
+ onMount(async () => {
32
+ try {
33
+ const res = await fetch('/api/version');
34
+ if (res.ok) {
35
+ const data = await res.json();
36
+ serverVersion = data.version;
37
+ }
38
+ } catch {
39
+ // Server unreachable — leave null.
40
+ }
41
+ });
42
+
28
43
  </script>
29
44
 
30
45
  <div class="diagnostic">
31
46
  <h2>Diagnostic</h2>
32
47
 
48
+ <section>
49
+ <h3>Server version</h3>
50
+ <p>{serverVersion ?? '—'}</p>
51
+ </section>
52
+
33
53
  <section>
34
54
  <h3>Active app</h3>
35
55
  <p>{active ? `${active.label} (${active.id})` : 'none'}</p>
@@ -6,7 +6,7 @@
6
6
  * 3. Elevate prompt — shown when not elevated
7
7
  */
8
8
 
9
- import { listRegisteredApps, launchApp, isAdmin } from '../api';
9
+ import { listRegisteredApps, launchApp, isAdmin, VERSION } from '../api';
10
10
  import { elevate, deescalate } from '../auth/index';
11
11
 
12
12
  const apps = $derived(listRegisteredApps());
@@ -38,6 +38,7 @@
38
38
  <div class="shell-home">
39
39
  <header class="shell-home-header">
40
40
  <h1>SH3</h1>
41
+ <span class="shell-home-version">v{VERSION}</span>
41
42
  <span class="shell-home-alpha">alpha</span>
42
43
  {#if elevated}
43
44
  <button type="button" class="shell-home-deescalate" onclick={handleDeescalate}>
@@ -153,6 +154,11 @@
153
154
  color: var(--shell-accent);
154
155
  letter-spacing: 2px;
155
156
  }
157
+ .shell-home-version {
158
+ font-size: 13px;
159
+ color: var(--shell-fg-subtle);
160
+ letter-spacing: 0.04em;
161
+ }
156
162
  .shell-home-alpha {
157
163
  font-size: 11px;
158
164
  font-weight: 700;
@@ -14,6 +14,8 @@
14
14
  const ctx = storeContext;
15
15
 
16
16
  let uninstallingIds = $state<Set<string>>(new Set());
17
+ let updatingIds = $state<Set<string>>(new Set());
18
+ let updateError = $state<string | null>(null);
17
19
 
18
20
  async function handleUninstall(id: string) {
19
21
  if (uninstallingIds.has(id)) return;
@@ -32,6 +34,23 @@
32
34
  }
33
35
  }
34
36
 
37
+ async function handleUpdate(id: string) {
38
+ if (updatingIds.has(id)) return;
39
+
40
+ updatingIds = new Set([...updatingIds, id]);
41
+ updateError = null;
42
+
43
+ try {
44
+ await ctx.updatePackage(id);
45
+ } catch (err) {
46
+ updateError = err instanceof Error ? err.message : String(err);
47
+ } finally {
48
+ const next = new Set(updatingIds);
49
+ next.delete(id);
50
+ updatingIds = next;
51
+ }
52
+ }
53
+
35
54
  function handleRefresh() {
36
55
  ctx.refreshInstalled();
37
56
  }
@@ -55,6 +74,10 @@
55
74
  <button class="installed-refresh" onclick={handleRefresh}>Refresh</button>
56
75
  </header>
57
76
 
77
+ {#if updateError}
78
+ <div class="installed-error">{updateError}</div>
79
+ {/if}
80
+
58
81
  {#if ctx.state.ephemeral.installed.length === 0}
59
82
  <div class="installed-empty">No packages installed.</div>
60
83
  {:else}
@@ -75,6 +98,17 @@
75
98
  <span>Installed: {formatDate(pkg.installedAt)}</span>
76
99
  </div>
77
100
  <div class="installed-item-actions">
101
+ {#if pkg.id in ctx.state.ephemeral.updatable}
102
+ {@const target = ctx.state.ephemeral.updatable[pkg.id]}
103
+ {@const updating = updatingIds.has(pkg.id)}
104
+ <button
105
+ class="installed-update-btn"
106
+ onclick={() => handleUpdate(pkg.id)}
107
+ disabled={updating || uninstalling}
108
+ >
109
+ {updating ? 'Updating...' : `Update -> ${target.latest.version}`}
110
+ </button>
111
+ {/if}
78
112
  <button
79
113
  class="installed-uninstall-btn"
80
114
  onclick={() => handleUninstall(pkg.id)}
@@ -182,6 +216,7 @@
182
216
  .installed-item-actions {
183
217
  display: flex;
184
218
  justify-content: flex-end;
219
+ gap: 8px;
185
220
  }
186
221
  .installed-uninstall-btn {
187
222
  padding: 4px 12px;
@@ -200,4 +235,30 @@
200
235
  opacity: 0.6;
201
236
  cursor: not-allowed;
202
237
  }
238
+ .installed-update-btn {
239
+ padding: 4px 12px;
240
+ background: var(--shell-warning, #ff9800);
241
+ color: #fff;
242
+ border: none;
243
+ border-radius: 4px;
244
+ cursor: pointer;
245
+ font-family: inherit;
246
+ font-size: 0.8125rem;
247
+ }
248
+ .installed-update-btn:hover:not(:disabled) {
249
+ filter: brightness(1.1);
250
+ }
251
+ .installed-update-btn:disabled {
252
+ opacity: 0.6;
253
+ cursor: not-allowed;
254
+ }
255
+ .installed-error {
256
+ padding: 8px 12px;
257
+ margin-bottom: 12px;
258
+ background: color-mix(in srgb, var(--shell-error, #d32f2f) 15%, transparent);
259
+ color: var(--shell-error, #d32f2f);
260
+ border: 1px solid var(--shell-error, #d32f2f);
261
+ border-radius: 4px;
262
+ font-size: 0.8125rem;
263
+ }
203
264
  </style>
@@ -17,6 +17,7 @@
17
17
  let search = $state('');
18
18
  let typeFilter = $state<'all' | 'shard' | 'app'>('all');
19
19
  let installingIds = $state<Set<string>>(new Set());
20
+ let updatingIds = $state<Set<string>>(new Set());
20
21
  let installError = $state<string | null>(null);
21
22
  let newRegistryUrl = $state('');
22
23
 
@@ -66,6 +67,31 @@
66
67
  return String(pkg.latest.contractVersion) !== String(contract.version);
67
68
  }
68
69
 
70
+ function hasUpdate(id: string): boolean {
71
+ return id in ctx.state.ephemeral.updatable;
72
+ }
73
+
74
+ function installedVersion(id: string): string {
75
+ return ctx.state.ephemeral.installed.find((p: InstalledPackage) => p.id === id)?.version ?? '';
76
+ }
77
+
78
+ async function handleUpdate(id: string) {
79
+ if (updatingIds.has(id)) return;
80
+
81
+ updatingIds = new Set([...updatingIds, id]);
82
+ installError = null;
83
+
84
+ try {
85
+ await ctx.updatePackage(id);
86
+ } catch (err) {
87
+ installError = err instanceof Error ? err.message : String(err);
88
+ } finally {
89
+ const next = new Set(updatingIds);
90
+ next.delete(id);
91
+ updatingIds = next;
92
+ }
93
+ }
94
+
69
95
  async function handleInstall(pkg: ResolvedPackage) {
70
96
  const id = pkg.entry.id;
71
97
  if (installingIds.has(id)) return;
@@ -181,6 +207,8 @@
181
207
  {@const installed = isInstalled(pkg.entry.id)}
182
208
  {@const mismatch = hasContractMismatch(pkg)}
183
209
  {@const installing = installingIds.has(pkg.entry.id)}
210
+ {@const updatable = hasUpdate(pkg.entry.id)}
211
+ {@const updating = updatingIds.has(pkg.entry.id)}
184
212
  <div class="store-card">
185
213
  <div class="store-card-header">
186
214
  <div class="store-card-icon">
@@ -208,7 +236,15 @@
208
236
  </div>
209
237
  {/if}
210
238
  <div class="store-card-actions">
211
- {#if installed}
239
+ {#if installed && updatable}
240
+ <button
241
+ class="store-update-btn"
242
+ onclick={() => handleUpdate(pkg.entry.id)}
243
+ disabled={updating}
244
+ >
245
+ {updating ? 'Updating...' : `Update ${installedVersion(pkg.entry.id)} -> ${pkg.latest.version}`}
246
+ </button>
247
+ {:else if installed}
212
248
  <span class="store-installed-label">Installed</span>
213
249
  {:else}
214
250
  <button
@@ -423,6 +459,23 @@
423
459
  color: var(--shell-success, #4caf50);
424
460
  font-weight: 600;
425
461
  }
462
+ .store-update-btn {
463
+ padding: 5px 14px;
464
+ background: var(--shell-warning, #ff9800);
465
+ color: #fff;
466
+ border: none;
467
+ border-radius: 4px;
468
+ cursor: pointer;
469
+ font-family: inherit;
470
+ font-size: 0.8125rem;
471
+ }
472
+ .store-update-btn:hover:not(:disabled) {
473
+ filter: brightness(1.1);
474
+ }
475
+ .store-update-btn:disabled {
476
+ opacity: 0.6;
477
+ cursor: not-allowed;
478
+ }
426
479
  .store-empty {
427
480
  text-align: center;
428
481
  padding: 32px 16px;
@@ -10,7 +10,7 @@ export const storeApp = {
10
10
  manifest: {
11
11
  id: 'sh3-store-app',
12
12
  label: 'Package Store',
13
- version: '0.1.0',
13
+ version: '0.2.0',
14
14
  requiredShards: ['sh3-store'],
15
15
  layoutVersion: 1,
16
16
  admin: true,
@@ -13,6 +13,7 @@ interface StoreZoneSchema {
13
13
  ephemeral: {
14
14
  catalog: ResolvedPackage[];
15
15
  installed: InstalledPackage[];
16
+ updatable: Record<string, ResolvedPackage>;
16
17
  loading: boolean;
17
18
  error: string | null;
18
19
  };
@@ -24,6 +25,7 @@ export interface StoreContext {
24
25
  isAdmin: boolean;
25
26
  refreshCatalog(): Promise<void>;
26
27
  refreshInstalled(): Promise<void>;
28
+ updatePackage(id: string): Promise<void>;
27
29
  addRegistry(url: string): Promise<void>;
28
30
  removeRegistry(url: string): Promise<void>;
29
31
  }
@@ -14,9 +14,31 @@
14
14
  import { mount, unmount } from 'svelte';
15
15
  import StoreView from './StoreView.svelte';
16
16
  import InstalledView from './InstalledView.svelte';
17
- import { fetchRegistries } from '../registry/client';
18
- import { listInstalledPackages } from '../registry/installer';
19
- import { fetchServerPackages } from '../env/client';
17
+ import { fetchRegistries, fetchBundle, buildPackageMeta } from '../registry/client';
18
+ import { installPackage, listInstalledPackages } from '../registry/installer';
19
+ import { loadBundle, savePackage } from '../registry/storage';
20
+ import { serverInstallPackage, fetchServerPackages } from '../env/client';
21
+ /**
22
+ * Compare two semver-like version strings.
23
+ * Returns true only if `available` is strictly greater than `installed`.
24
+ * Compares major.minor.patch left-to-right as integers.
25
+ * Non-numeric segments are treated as 0.
26
+ */
27
+ function isNewerVersion(available, installed) {
28
+ var _a, _b;
29
+ const a = available.split('.').map((s) => parseInt(s, 10) || 0);
30
+ const b = installed.split('.').map((s) => parseInt(s, 10) || 0);
31
+ const len = Math.max(a.length, b.length);
32
+ for (let i = 0; i < len; i++) {
33
+ const av = (_a = a[i]) !== null && _a !== void 0 ? _a : 0;
34
+ const bv = (_b = b[i]) !== null && _b !== void 0 ? _b : 0;
35
+ if (av > bv)
36
+ return true;
37
+ if (av < bv)
38
+ return false;
39
+ }
40
+ return false;
41
+ }
20
42
  /**
21
43
  * Module-level context set during activate(). Imported by the Svelte
22
44
  * view components so they can read/write store state and trigger refreshes.
@@ -26,7 +48,7 @@ export const storeShard = {
26
48
  manifest: {
27
49
  id: 'sh3-store',
28
50
  label: 'Package Store',
29
- version: '0.1.0',
51
+ version: '0.2.0',
30
52
  views: [
31
53
  { id: 'sh3-store:browse', label: 'Store' },
32
54
  { id: 'sh3-store:installed', label: 'Installed' },
@@ -38,16 +60,28 @@ export const storeShard = {
38
60
  ephemeral: {
39
61
  catalog: [],
40
62
  installed: [],
63
+ updatable: {},
41
64
  loading: false,
42
65
  error: null,
43
66
  },
44
67
  });
68
+ function recomputeUpdatable() {
69
+ const result = {};
70
+ for (const pkg of state.ephemeral.installed) {
71
+ const catalogEntry = state.ephemeral.catalog.find((c) => c.entry.id === pkg.id);
72
+ if (catalogEntry && isNewerVersion(catalogEntry.latest.version, pkg.version)) {
73
+ result[pkg.id] = catalogEntry;
74
+ }
75
+ }
76
+ state.ephemeral.updatable = result;
77
+ }
45
78
  async function refreshCatalog() {
46
79
  state.ephemeral.loading = true;
47
80
  state.ephemeral.error = null;
48
81
  try {
49
82
  const results = await fetchRegistries(env.registries);
50
83
  state.ephemeral.catalog = results;
84
+ recomputeUpdatable();
51
85
  }
52
86
  catch (err) {
53
87
  state.ephemeral.error =
@@ -71,6 +105,7 @@ export const storeShard = {
71
105
  installedAt: (_c = p.installedAt) !== null && _c !== void 0 ? _c : '',
72
106
  });
73
107
  });
108
+ recomputeUpdatable();
74
109
  }
75
110
  catch (err) {
76
111
  console.warn('[sh3-store] Failed to list installed packages:', err instanceof Error ? err.message : err);
@@ -78,6 +113,7 @@ export const storeShard = {
78
113
  try {
79
114
  const packages = await listInstalledPackages();
80
115
  state.ephemeral.installed = packages;
116
+ recomputeUpdatable();
81
117
  }
82
118
  catch (_a) {
83
119
  // Nothing to show.
@@ -95,12 +131,57 @@ export const storeShard = {
95
131
  const registries = env.registries.filter((r) => r !== url);
96
132
  await ctx.envUpdate({ registries });
97
133
  }
134
+ async function updatePackage(id) {
135
+ var _a, _b;
136
+ const catalogEntry = state.ephemeral.updatable[id];
137
+ if (!catalogEntry)
138
+ return;
139
+ const installedRecord = state.ephemeral.installed.find((p) => p.id === id);
140
+ if (!installedRecord)
141
+ return;
142
+ // 1. Fetch new bundle.
143
+ const bundle = await fetchBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
144
+ const meta = buildPackageMeta(catalogEntry, catalogEntry.latest);
145
+ // 2. Snapshot current state for rollback.
146
+ const oldBundle = await loadBundle(id);
147
+ const oldRecord = Object.assign({}, installedRecord);
148
+ // 3. Push to server.
149
+ const manifest = {
150
+ id: meta.id,
151
+ type: meta.type,
152
+ label: catalogEntry.entry.label,
153
+ version: meta.version,
154
+ contractVersion: meta.contractVersion,
155
+ sourceRegistry: meta.sourceRegistry,
156
+ installedAt: new Date().toISOString(),
157
+ };
158
+ const serverResult = await serverInstallPackage(manifest, bundle);
159
+ if (!serverResult.ok) {
160
+ throw new Error((_a = serverResult.error) !== null && _a !== void 0 ? _a : 'Server update failed');
161
+ }
162
+ // 4. Install locally (overwrites IndexedDB + re-registers).
163
+ const result = await installPackage(bundle, meta);
164
+ if (!result.success) {
165
+ // Rollback: restore old bundle and metadata.
166
+ if (oldBundle) {
167
+ try {
168
+ await savePackage(id, oldBundle, oldRecord);
169
+ }
170
+ catch (rollbackErr) {
171
+ console.warn(`[sh3-store] Rollback failed for "${id}":`, rollbackErr instanceof Error ? rollbackErr.message : rollbackErr);
172
+ }
173
+ }
174
+ throw new Error((_b = result.error) !== null && _b !== void 0 ? _b : 'Local install failed during update');
175
+ }
176
+ await refreshInstalled();
177
+ }
98
178
  storeContext = {
99
179
  env,
100
180
  state,
101
181
  get isAdmin() { return ctx.isAdmin; },
102
182
  refreshCatalog,
103
183
  refreshInstalled,
184
+ updatePackage,
104
185
  addRegistry,
105
186
  removeRegistry,
106
187
  };
@@ -0,0 +1,2 @@
1
+ /** sh3-core package version. Keep in sync with package.json. */
2
+ export declare const VERSION = "0.5.1";
@@ -0,0 +1,2 @@
1
+ /** sh3-core package version. Keep in sync with package.json. */
2
+ export const VERSION = '0.5.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"