sh3-core 0.4.3 → 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/README.md +9 -0
- package/dist/api.d.ts +4 -1
- package/dist/api.js +3 -0
- package/dist/apps/lifecycle.js +7 -0
- package/dist/apps/types.d.ts +14 -1
- package/dist/createShell.js +5 -0
- package/dist/diagnostic/DiagnosticPanel.svelte +20 -0
- package/dist/env/client.d.ts +44 -0
- package/dist/env/client.js +106 -0
- package/dist/env/index.d.ts +2 -0
- package/dist/env/index.js +1 -0
- package/dist/env/types.d.ts +12 -0
- package/dist/env/types.js +8 -0
- package/dist/host-entry.d.ts +1 -0
- package/dist/host-entry.js +1 -0
- package/dist/shards/activate.svelte.js +53 -2
- package/dist/shards/types.d.ts +34 -1
- package/dist/shell-shard/ShellHome.svelte +7 -1
- package/dist/state/manage.d.ts +14 -0
- package/dist/state/manage.js +40 -0
- package/dist/state/types.d.ts +17 -0
- package/dist/state/types.js +2 -0
- package/dist/state/zones.svelte.d.ts +1 -0
- package/dist/state/zones.svelte.js +1 -1
- package/dist/store/InstalledView.svelte +63 -0
- package/dist/store/StoreView.svelte +110 -29
- package/dist/store/storeApp.js +1 -1
- package/dist/store/storeShard.svelte.d.ts +12 -3
- package/dist/store/storeShard.svelte.js +129 -11
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# sh3-core
|
|
2
|
+
|
|
3
|
+
Core library for [SH3](https://github.com/Unfinished-Lair/sh3) — types, layout engine, state zones, overlays, shard/app lifecycle, and the shell component.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install sh3-core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Part of the [SH3 monorepo](https://github.com/Unfinished-Lair/sh3).
|
package/dist/api.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ export { shell } from './shellRuntime.svelte';
|
|
|
2
2
|
export type { Shell } from './shellRuntime.svelte';
|
|
3
3
|
export type { Shard, ShardManifest, ShardContext, ViewDeclaration, ViewFactory, ViewHandle, MountContext, } from './shards/types';
|
|
4
4
|
export type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, SplitDirection, SizeMode, } from './layout/types';
|
|
5
|
-
export type { ZoneSchema, ZoneName } from './state/types';
|
|
5
|
+
export type { ZoneSchema, ZoneName, ZoneManager } from './state/types';
|
|
6
|
+
export { PERMISSION_STATE_MANAGE } from './state/types';
|
|
6
7
|
export type { StateZones } from './state/zones.svelte';
|
|
8
|
+
export type { EnvState } from './env/types';
|
|
7
9
|
export type { App, AppManifest, AppContext } from './apps/types';
|
|
8
10
|
export { listRegisteredApps, getActiveApp } from './apps/registry.svelte';
|
|
9
11
|
export { launchApp, returnToHome } from './apps/lifecycle';
|
|
@@ -21,4 +23,5 @@ export declare const capabilities: {
|
|
|
21
23
|
readonly hotInstall: boolean;
|
|
22
24
|
};
|
|
23
25
|
export type { ServerShard, ServerShardContext } from './server-shard/types';
|
|
26
|
+
export { VERSION } from './version';
|
|
24
27
|
export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
|
package/dist/api.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
// Runtime singleton.
|
|
25
25
|
export { shell } from './shellRuntime.svelte';
|
|
26
|
+
export { PERMISSION_STATE_MANAGE } from './state/types';
|
|
26
27
|
// Host actions callable from inside views (shell home, status bar, etc.).
|
|
27
28
|
export { listRegisteredApps, getActiveApp } from './apps/registry.svelte';
|
|
28
29
|
export { launchApp, returnToHome } from './apps/lifecycle';
|
|
@@ -43,5 +44,7 @@ export const capabilities = {
|
|
|
43
44
|
/** Whether this target supports hot-installing packages via dynamic import from blob URL. */
|
|
44
45
|
hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
|
|
45
46
|
};
|
|
47
|
+
// Package version.
|
|
48
|
+
export { VERSION } from './version';
|
|
46
49
|
// Theme token override API (shell-level theming support).
|
|
47
50
|
export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -15,6 +15,8 @@ import { createStateZones } from '../state/zones.svelte';
|
|
|
15
15
|
import { activateShard, deactivateShard, registeredShards, } from '../shards/activate.svelte';
|
|
16
16
|
import { attachApp, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
|
|
17
17
|
import { activeApp, getRegisteredApp } from './registry.svelte';
|
|
18
|
+
import { createZoneManager } from '../state/manage';
|
|
19
|
+
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
18
20
|
// ---------- last-active-app user zone ------------------------------------
|
|
19
21
|
/**
|
|
20
22
|
* Framework-reserved user-zone slot storing which app to boot into on
|
|
@@ -39,10 +41,15 @@ function writeLastApp(id) {
|
|
|
39
41
|
// ---------- app-context state factories ----------------------------------
|
|
40
42
|
const appContexts = new Map();
|
|
41
43
|
function getOrCreateAppContext(appId) {
|
|
44
|
+
var _a;
|
|
42
45
|
let ctx = appContexts.get(appId);
|
|
43
46
|
if (!ctx) {
|
|
47
|
+
const app = getRegisteredApp(appId);
|
|
44
48
|
ctx = {
|
|
45
49
|
state: (schema) => createStateZones(`__app__:${appId}`, schema),
|
|
50
|
+
zones: ((_a = app === null || app === void 0 ? void 0 : app.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
|
|
51
|
+
? createZoneManager()
|
|
52
|
+
: undefined,
|
|
46
53
|
};
|
|
47
54
|
appContexts.set(appId, ctx);
|
|
48
55
|
}
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LayoutNode } from '../layout/types';
|
|
2
|
-
import type { ZoneSchema } from '../state/types';
|
|
2
|
+
import type { ZoneSchema, ZoneManager } from '../state/types';
|
|
3
3
|
import type { StateZones } from '../state/zones.svelte';
|
|
4
4
|
/**
|
|
5
5
|
* Static description of an app. Declared by every app module and read by
|
|
@@ -34,6 +34,13 @@ export interface AppManifest {
|
|
|
34
34
|
* independently. Defaults to false (visible to everyone).
|
|
35
35
|
*/
|
|
36
36
|
admin?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Optional permissions this app requests beyond the default sandbox.
|
|
39
|
+
* Declared in the manifest and surfaced to the user at install time
|
|
40
|
+
* by the store app. Currently recognized: `'state:manage'` — cross-
|
|
41
|
+
* shard zone access.
|
|
42
|
+
*/
|
|
43
|
+
permissions?: string[];
|
|
37
44
|
}
|
|
38
45
|
/**
|
|
39
46
|
* Context object passed to `App.activate`. Provides app-scoped state zones
|
|
@@ -48,6 +55,12 @@ export interface AppContext {
|
|
|
48
55
|
* of the same name.
|
|
49
56
|
*/
|
|
50
57
|
state<T extends ZoneSchema>(schema: T): StateZones<T>;
|
|
58
|
+
/**
|
|
59
|
+
* Cross-shard zone management API. Only present when the app's
|
|
60
|
+
* manifest declares the `'state:manage'` permission. Check with
|
|
61
|
+
* `if (ctx.zones)` before use.
|
|
62
|
+
*/
|
|
63
|
+
zones?: ZoneManager;
|
|
51
64
|
}
|
|
52
65
|
/**
|
|
53
66
|
* An app module. An app is a composition document — it declares required
|
package/dist/createShell.js
CHANGED
|
@@ -11,6 +11,7 @@ import { Shell } from './index';
|
|
|
11
11
|
import { registerShard, registerApp, bootstrap, __setBackend, setLocalOwner, } from './host';
|
|
12
12
|
import { resolvePlatform } from './platform/index';
|
|
13
13
|
import { hydrateTokenOverrides } from './theme';
|
|
14
|
+
import { __setEnvServerUrl } from './env/index';
|
|
14
15
|
export async function createShell(config) {
|
|
15
16
|
var _a, _b;
|
|
16
17
|
// 1. Platform detection — must run before bootstrap so state zones
|
|
@@ -24,6 +25,10 @@ export async function createShell(config) {
|
|
|
24
25
|
if (platform.localOwner) {
|
|
25
26
|
setLocalOwner();
|
|
26
27
|
}
|
|
28
|
+
// 1d. Default env state to same-origin. The server frontend overrides
|
|
29
|
+
// this if it knows a specific URL, but this ensures env() works for
|
|
30
|
+
// same-origin deployments without explicit configuration.
|
|
31
|
+
__setEnvServerUrl('');
|
|
27
32
|
// 1c. Apply persisted theme token overrides before any component mounts,
|
|
28
33
|
// so the first frame renders with the user's chosen theme.
|
|
29
34
|
hydrateTokenOverrides();
|
|
@@ -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>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env state client — fetches and updates per-shard environment state
|
|
3
|
+
* from the server.
|
|
4
|
+
*/
|
|
5
|
+
/** Configure the server URL for env state operations. */
|
|
6
|
+
export declare function __setEnvServerUrl(url: string): void;
|
|
7
|
+
/** Return the configured server URL. */
|
|
8
|
+
export declare function getEnvServerUrl(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Fetch env state for a shard from the server.
|
|
11
|
+
* Returns an empty object if the server has no stored state.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetchEnvState(shardId: string): Promise<Record<string, unknown>>;
|
|
14
|
+
/**
|
|
15
|
+
* Write env state for a shard to the server. Requires admin auth.
|
|
16
|
+
* Throws if not admin or if the server rejects the write.
|
|
17
|
+
*/
|
|
18
|
+
export declare function putEnvState(shardId: string, state: Record<string, unknown>): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Install a package on the server via multipart upload.
|
|
21
|
+
* The client has already fetched and integrity-verified the bundle.
|
|
22
|
+
*/
|
|
23
|
+
export declare function serverInstallPackage(manifest: Record<string, unknown>, clientBundle: ArrayBuffer): Promise<{
|
|
24
|
+
ok: boolean;
|
|
25
|
+
id: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Uninstall a package from the server.
|
|
30
|
+
*/
|
|
31
|
+
export declare function serverUninstallPackage(id: string): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Fetch the list of packages installed on the server.
|
|
34
|
+
*/
|
|
35
|
+
export declare function fetchServerPackages(): Promise<Array<{
|
|
36
|
+
id: string;
|
|
37
|
+
type: string;
|
|
38
|
+
label: string;
|
|
39
|
+
version: string;
|
|
40
|
+
bundleUrl: string;
|
|
41
|
+
sourceRegistry?: string;
|
|
42
|
+
contractVersion?: string;
|
|
43
|
+
installedAt?: string;
|
|
44
|
+
}>>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env state client — fetches and updates per-shard environment state
|
|
3
|
+
* from the server.
|
|
4
|
+
*/
|
|
5
|
+
import { getAuthHeader, isAdmin } from '../auth/index';
|
|
6
|
+
/** Server base URL, set once during configuration. */
|
|
7
|
+
let serverUrl = '';
|
|
8
|
+
/** Configure the server URL for env state operations. */
|
|
9
|
+
export function __setEnvServerUrl(url) {
|
|
10
|
+
serverUrl = url;
|
|
11
|
+
}
|
|
12
|
+
/** Return the configured server URL. */
|
|
13
|
+
export function getEnvServerUrl() {
|
|
14
|
+
return serverUrl;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Fetch env state for a shard from the server.
|
|
18
|
+
* Returns an empty object if the server has no stored state.
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchEnvState(shardId) {
|
|
21
|
+
const res = await fetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`);
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
console.warn(`[sh3] Failed to fetch env state for "${shardId}": HTTP ${res.status}`);
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
return await res.json();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Write env state for a shard to the server. Requires admin auth.
|
|
30
|
+
* Throws if not admin or if the server rejects the write.
|
|
31
|
+
*/
|
|
32
|
+
export async function putEnvState(shardId, state) {
|
|
33
|
+
var _a;
|
|
34
|
+
if (!isAdmin()) {
|
|
35
|
+
throw new Error('Cannot update env state: not elevated to admin');
|
|
36
|
+
}
|
|
37
|
+
const auth = getAuthHeader();
|
|
38
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
39
|
+
if (auth)
|
|
40
|
+
headers['Authorization'] = auth;
|
|
41
|
+
const res = await fetch(`${serverUrl}/api/env-state/${encodeURIComponent(shardId)}`, {
|
|
42
|
+
method: 'PUT',
|
|
43
|
+
headers,
|
|
44
|
+
body: JSON.stringify(state),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const body = await res.json().catch(() => ({}));
|
|
48
|
+
throw new Error(`Env state update failed: HTTP ${res.status} — ${(_a = body.error) !== null && _a !== void 0 ? _a : 'unknown'}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Install a package on the server via multipart upload.
|
|
53
|
+
* The client has already fetched and integrity-verified the bundle.
|
|
54
|
+
*/
|
|
55
|
+
export async function serverInstallPackage(manifest, clientBundle) {
|
|
56
|
+
var _a;
|
|
57
|
+
if (!isAdmin())
|
|
58
|
+
throw new Error('Cannot install: not elevated to admin');
|
|
59
|
+
const auth = getAuthHeader();
|
|
60
|
+
const form = new FormData();
|
|
61
|
+
form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
|
|
62
|
+
form.append('client', new Blob([clientBundle], { type: 'application/javascript' }), 'client.js');
|
|
63
|
+
const headers = {};
|
|
64
|
+
if (auth)
|
|
65
|
+
headers['Authorization'] = auth;
|
|
66
|
+
const res = await fetch(`${serverUrl}/api/packages/install`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers,
|
|
69
|
+
body: form,
|
|
70
|
+
});
|
|
71
|
+
const body = await res.json();
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
return { ok: false, id: String(manifest.id), error: (_a = body.error) !== null && _a !== void 0 ? _a : `HTTP ${res.status}` };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, id: String(manifest.id) };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Uninstall a package from the server.
|
|
79
|
+
*/
|
|
80
|
+
export async function serverUninstallPackage(id) {
|
|
81
|
+
var _a;
|
|
82
|
+
if (!isAdmin())
|
|
83
|
+
throw new Error('Cannot uninstall: not elevated to admin');
|
|
84
|
+
const auth = getAuthHeader();
|
|
85
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
86
|
+
if (auth)
|
|
87
|
+
headers['Authorization'] = auth;
|
|
88
|
+
const res = await fetch(`${serverUrl}/api/packages/uninstall`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers,
|
|
91
|
+
body: JSON.stringify({ id }),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const body = await res.json().catch(() => ({}));
|
|
95
|
+
throw new Error(`Uninstall failed: HTTP ${res.status} — ${(_a = body.error) !== null && _a !== void 0 ? _a : 'unknown'}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Fetch the list of packages installed on the server.
|
|
100
|
+
*/
|
|
101
|
+
export async function fetchServerPackages() {
|
|
102
|
+
const res = await fetch(`${serverUrl}/api/packages`);
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
return [];
|
|
105
|
+
return await res.json();
|
|
106
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { __setEnvServerUrl, getEnvServerUrl, fetchEnvState, putEnvState, serverInstallPackage, serverUninstallPackage, fetchServerPackages, } from './client';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment state types — server-authoritative per-shard config.
|
|
3
|
+
*
|
|
4
|
+
* Env state is fetched once at shard activation and written back via
|
|
5
|
+
* explicit admin action. Not a state zone — backed by the server, not
|
|
6
|
+
* localStorage.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Reactive environment state proxy. Reads are local (from hydrated
|
|
10
|
+
* snapshot). Writes go through envUpdate().
|
|
11
|
+
*/
|
|
12
|
+
export type EnvState<T extends Record<string, unknown>> = T;
|
package/dist/host-entry.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { __setTenantId, __setDocumentBackend } from './host';
|
|
|
4
4
|
export type { Backend } from './state/types';
|
|
5
5
|
export type { DocumentBackend } from './documents/types';
|
|
6
6
|
export { HttpDocumentBackend } from './documents/http-backend';
|
|
7
|
+
export { __setEnvServerUrl } from './env/index';
|
|
7
8
|
export { installPackage, uninstallPackage, listInstalledPackages, loadInstalledPackages, } from './registry/index';
|
|
8
9
|
export type { InstalledPackage, InstallResult, PackageMeta } from './registry/types';
|
|
9
10
|
export { initAuth, elevate, deescalate } from './auth/index';
|
package/dist/host-entry.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export { registerShard, registerApp, bootstrap, __setBackend, setLocalOwner } from './host';
|
|
9
9
|
export { __setTenantId, __setDocumentBackend } from './host';
|
|
10
10
|
export { HttpDocumentBackend } from './documents/http-backend';
|
|
11
|
+
export { __setEnvServerUrl } from './env/index';
|
|
11
12
|
// Install API (host-only).
|
|
12
13
|
export { installPackage, uninstallPackage, listInstalledPackages, loadInstalledPackages, } from './registry/index';
|
|
13
14
|
// Admin mode (host-only — elevate/deescalate drive the shell UI, initAuth runs at boot).
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
import { shell } from '../shellRuntime.svelte';
|
|
20
20
|
import { registerView, unregisterView } from './registry';
|
|
21
21
|
import { createDocumentHandle, getTenantId, getDocumentBackend } from '../documents';
|
|
22
|
+
import { fetchEnvState, putEnvState } from '../env/client';
|
|
23
|
+
import { isAdmin as checkIsAdmin } from '../auth/index';
|
|
24
|
+
import { createZoneManager } from '../state/manage';
|
|
25
|
+
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
22
26
|
/**
|
|
23
27
|
* Reactive registry of every shard known to the host. Keys are shard ids.
|
|
24
28
|
* Populated once at boot by the glob-discovery loop in main.ts (through
|
|
@@ -59,7 +63,7 @@ export function registerShard(shard) {
|
|
|
59
63
|
* @throws If the shard is not registered, or if a manifest view has no factory after activation.
|
|
60
64
|
*/
|
|
61
65
|
export async function activateShard(id) {
|
|
62
|
-
var _a;
|
|
66
|
+
var _a, _b;
|
|
63
67
|
const shard = registeredShards.get(id);
|
|
64
68
|
if (!shard) {
|
|
65
69
|
throw new Error(`Cannot activate shard "${id}": not registered`);
|
|
@@ -70,6 +74,12 @@ export async function activateShard(id) {
|
|
|
70
74
|
return;
|
|
71
75
|
}
|
|
72
76
|
const entry = { shard, viewIds: new Set(), cleanupFns: [] };
|
|
77
|
+
// envState holds the reactive env data for this shard.
|
|
78
|
+
// Must be declared with $state at variable declaration time (Svelte 5 rule).
|
|
79
|
+
const envState = $state({
|
|
80
|
+
proxy: null,
|
|
81
|
+
defaults: null,
|
|
82
|
+
});
|
|
73
83
|
const ctx = {
|
|
74
84
|
state: (schema) => shell.state(id, schema),
|
|
75
85
|
registerView: (viewId, factory) => {
|
|
@@ -81,6 +91,36 @@ export async function activateShard(id) {
|
|
|
81
91
|
entry.cleanupFns.push(() => handle.dispose());
|
|
82
92
|
return handle;
|
|
83
93
|
},
|
|
94
|
+
env(defaults) {
|
|
95
|
+
if (envState.proxy) {
|
|
96
|
+
console.warn(`[sh3] Shard "${id}" called ctx.env() more than once; extra calls are ignored.`);
|
|
97
|
+
return envState.proxy;
|
|
98
|
+
}
|
|
99
|
+
envState.defaults = defaults;
|
|
100
|
+
envState.proxy = Object.assign({}, defaults);
|
|
101
|
+
return envState.proxy;
|
|
102
|
+
},
|
|
103
|
+
async envUpdate(patch) {
|
|
104
|
+
if (!envState.proxy || !envState.defaults) {
|
|
105
|
+
throw new Error(`Shard "${id}" called envUpdate() without declaring env state`);
|
|
106
|
+
}
|
|
107
|
+
const previous = $state.snapshot(envState.proxy);
|
|
108
|
+
Object.assign(envState.proxy, patch);
|
|
109
|
+
try {
|
|
110
|
+
const snapshot = $state.snapshot(envState.proxy);
|
|
111
|
+
await putEnvState(id, snapshot);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
Object.assign(envState.proxy, previous);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
get isAdmin() {
|
|
119
|
+
return checkIsAdmin();
|
|
120
|
+
},
|
|
121
|
+
zones: ((_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
|
|
122
|
+
? createZoneManager()
|
|
123
|
+
: undefined,
|
|
84
124
|
};
|
|
85
125
|
active.set(id, entry);
|
|
86
126
|
activeShards.set(id, shard);
|
|
@@ -90,7 +130,18 @@ export async function activateShard(id) {
|
|
|
90
130
|
throw new Error(`Shard "${id}" declared view "${view.id}" in its manifest but registered no factory for it.`);
|
|
91
131
|
}
|
|
92
132
|
}
|
|
93
|
-
|
|
133
|
+
// Hydrate env state if the shard declared it via ctx.env().
|
|
134
|
+
if (envState.proxy && envState.defaults) {
|
|
135
|
+
try {
|
|
136
|
+
const stored = await fetchEnvState(id);
|
|
137
|
+
const merged = Object.assign({}, envState.defaults, stored);
|
|
138
|
+
Object.assign(envState.proxy, merged);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
void ((_b = shard.autostart) === null || _b === void 0 ? void 0 : _b.call(shard, ctx));
|
|
94
145
|
}
|
|
95
146
|
/**
|
|
96
147
|
* Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
|
package/dist/shards/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { StateZones } from '../state/zones.svelte';
|
|
2
|
-
import type { ZoneSchema } from '../state/types';
|
|
2
|
+
import type { ZoneSchema, ZoneManager } from '../state/types';
|
|
3
3
|
import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
|
|
4
|
+
import type { EnvState } from '../env/types';
|
|
4
5
|
/**
|
|
5
6
|
* The object returned by `ViewFactory.mount`. The framework calls
|
|
6
7
|
* `unmount()` when the slot goes away, and `onResize(w, h)` whenever the
|
|
@@ -103,6 +104,12 @@ export interface ShardManifest {
|
|
|
103
104
|
* shipped shards do not use this field.
|
|
104
105
|
*/
|
|
105
106
|
serverBundle?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Optional permissions this shard requests beyond the default sandbox.
|
|
109
|
+
* Declared in the manifest and surfaced to the user at install time.
|
|
110
|
+
* Currently recognized: `'state:manage'` — cross-shard zone access.
|
|
111
|
+
*/
|
|
112
|
+
permissions?: string[];
|
|
106
113
|
}
|
|
107
114
|
/**
|
|
108
115
|
* Handed to `shard.activate`. The shard uses it to declare state and
|
|
@@ -130,6 +137,32 @@ export interface ShardContext {
|
|
|
130
137
|
registerView(viewId: string, factory: ViewFactory): void;
|
|
131
138
|
/** Obtain a file-oriented document handle scoped to this shard. */
|
|
132
139
|
documents(options: DocumentHandleOptions): DocumentHandle;
|
|
140
|
+
/**
|
|
141
|
+
* Declare environment state for this shard and receive a hydrated snapshot.
|
|
142
|
+
* Env state is server-authoritative, fetched once at activation, and
|
|
143
|
+
* shallow-merged with defaults (new fields get defaults even if the server
|
|
144
|
+
* has an older entry). Read-only for non-admin sessions.
|
|
145
|
+
*
|
|
146
|
+
* @param defaults - Default values for each env state field.
|
|
147
|
+
* @returns A reactive proxy of the env state.
|
|
148
|
+
*/
|
|
149
|
+
env<T extends Record<string, unknown>>(defaults: T): EnvState<T>;
|
|
150
|
+
/**
|
|
151
|
+
* Update this shard's environment state on the server. Merges the patch
|
|
152
|
+
* into the current env state, writes to the server, and updates the local
|
|
153
|
+
* reactive proxy. Throws if the session is not admin-elevated.
|
|
154
|
+
*
|
|
155
|
+
* @param patch - Partial env state to merge.
|
|
156
|
+
*/
|
|
157
|
+
envUpdate<T extends Record<string, unknown>>(patch: Partial<T>): Promise<void>;
|
|
158
|
+
/** Whether the current session has admin privileges. */
|
|
159
|
+
isAdmin: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Cross-shard zone management API. Only present when the shard's
|
|
162
|
+
* manifest declares the `'state:manage'` permission. Check with
|
|
163
|
+
* `if (ctx.zones)` before use.
|
|
164
|
+
*/
|
|
165
|
+
zones?: ZoneManager;
|
|
133
166
|
}
|
|
134
167
|
/**
|
|
135
168
|
* A shard module. Shards are the fundamental unit of contribution in SH3.
|
|
@@ -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;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ZoneName, ZoneManager } from './types';
|
|
2
|
+
/** Return all shard IDs that have data in the given zone. */
|
|
3
|
+
export declare function listZoneEntries(zone: ZoneName): string[];
|
|
4
|
+
/** Read a raw zone entry without creating a reactive proxy. */
|
|
5
|
+
export declare function peekZoneEntry(zone: ZoneName, shardId: string): unknown | undefined;
|
|
6
|
+
/** Delete one shard's data from a zone. */
|
|
7
|
+
export declare function clearZoneEntry(zone: ZoneName, shardId: string): void;
|
|
8
|
+
/** Delete all entries in a zone. Safe because `list()` returns a snapshot array. */
|
|
9
|
+
export declare function clearAllZoneEntries(zone: ZoneName): void;
|
|
10
|
+
/**
|
|
11
|
+
* Build a `ZoneManager` object. Called by context factories when the
|
|
12
|
+
* manifest declares the `state:manage` permission.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createZoneManager(): ZoneManager;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Zone management — public wrappers over the private backends for
|
|
3
|
+
* cross-shard state inspection and cleanup.
|
|
4
|
+
*
|
|
5
|
+
* These functions are not part of the shard-facing API directly.
|
|
6
|
+
* They are consumed by `createZoneManager()` which builds the
|
|
7
|
+
* `ZoneManager` object conditionally attached to contexts when
|
|
8
|
+
* the manifest declares the `state:manage` permission.
|
|
9
|
+
*/
|
|
10
|
+
import { backends } from './zones.svelte';
|
|
11
|
+
/** Return all shard IDs that have data in the given zone. */
|
|
12
|
+
export function listZoneEntries(zone) {
|
|
13
|
+
return backends[zone].list();
|
|
14
|
+
}
|
|
15
|
+
/** Read a raw zone entry without creating a reactive proxy. */
|
|
16
|
+
export function peekZoneEntry(zone, shardId) {
|
|
17
|
+
return backends[zone].read(shardId);
|
|
18
|
+
}
|
|
19
|
+
/** Delete one shard's data from a zone. */
|
|
20
|
+
export function clearZoneEntry(zone, shardId) {
|
|
21
|
+
backends[zone].delete(shardId);
|
|
22
|
+
}
|
|
23
|
+
/** Delete all entries in a zone. Safe because `list()` returns a snapshot array. */
|
|
24
|
+
export function clearAllZoneEntries(zone) {
|
|
25
|
+
for (const id of backends[zone].list()) {
|
|
26
|
+
backends[zone].delete(id);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a `ZoneManager` object. Called by context factories when the
|
|
31
|
+
* manifest declares the `state:manage` permission.
|
|
32
|
+
*/
|
|
33
|
+
export function createZoneManager() {
|
|
34
|
+
return {
|
|
35
|
+
list: listZoneEntries,
|
|
36
|
+
peek: peekZoneEntry,
|
|
37
|
+
clear: clearZoneEntry,
|
|
38
|
+
clearAll: clearAllZoneEntries,
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/state/types.d.ts
CHANGED
|
@@ -36,3 +36,20 @@ export interface Backend {
|
|
|
36
36
|
export type ZoneSchema = {
|
|
37
37
|
[K in ZoneName]?: Record<string, unknown>;
|
|
38
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* Cross-shard zone management API. Allows enumeration, inspection, and
|
|
41
|
+
* cleanup of zone data across all shards. Only available on contexts
|
|
42
|
+
* whose manifest declares the `state:manage` permission.
|
|
43
|
+
*/
|
|
44
|
+
export interface ZoneManager {
|
|
45
|
+
/** Return all shard IDs that have data in the given zone. */
|
|
46
|
+
list(zone: ZoneName): string[];
|
|
47
|
+
/** Read a raw zone entry without creating a reactive proxy. Returns undefined if absent. */
|
|
48
|
+
peek(zone: ZoneName, shardId: string): unknown | undefined;
|
|
49
|
+
/** Delete one shard's data from a zone. */
|
|
50
|
+
clear(zone: ZoneName, shardId: string): void;
|
|
51
|
+
/** Delete all entries in a zone. */
|
|
52
|
+
clearAll(zone: ZoneName): void;
|
|
53
|
+
}
|
|
54
|
+
/** Permission string for cross-shard zone management access. */
|
|
55
|
+
export declare const PERMISSION_STATE_MANAGE = "state:manage";
|
package/dist/state/types.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Backend, ZoneName, ZoneSchema } from './types';
|
|
2
|
+
export declare const backends: Record<ZoneName, Backend>;
|
|
2
3
|
/**
|
|
3
4
|
* Live reactive state object returned by `createStateZones`. Each key
|
|
4
5
|
* mirrors a zone declared in the schema; the value is a deeply-reactive
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { MemoryBackend, LocalStorageBackend } from './backends';
|
|
34
34
|
import { PERSISTENT_ZONES } from './types';
|
|
35
|
-
const backends = {
|
|
35
|
+
export const backends = {
|
|
36
36
|
ephemeral: new MemoryBackend(),
|
|
37
37
|
session: new MemoryBackend(),
|
|
38
38
|
workspace: new LocalStorageBackend('sh3:workspace:'),
|
|
@@ -9,16 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
import { storeContext } from './storeShard.svelte';
|
|
11
11
|
import { uninstallPackage } from '../registry/installer';
|
|
12
|
+
import { serverUninstallPackage } from '../env/client';
|
|
12
13
|
|
|
13
14
|
const ctx = storeContext;
|
|
14
15
|
|
|
15
16
|
let uninstallingIds = $state<Set<string>>(new Set());
|
|
17
|
+
let updatingIds = $state<Set<string>>(new Set());
|
|
18
|
+
let updateError = $state<string | null>(null);
|
|
16
19
|
|
|
17
20
|
async function handleUninstall(id: string) {
|
|
18
21
|
if (uninstallingIds.has(id)) return;
|
|
19
22
|
|
|
20
23
|
uninstallingIds = new Set([...uninstallingIds, id]);
|
|
21
24
|
try {
|
|
25
|
+
await serverUninstallPackage(id);
|
|
22
26
|
await uninstallPackage(id);
|
|
23
27
|
await ctx.refreshInstalled();
|
|
24
28
|
} catch (err) {
|
|
@@ -30,6 +34,23 @@
|
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
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
|
+
|
|
33
54
|
function handleRefresh() {
|
|
34
55
|
ctx.refreshInstalled();
|
|
35
56
|
}
|
|
@@ -53,6 +74,10 @@
|
|
|
53
74
|
<button class="installed-refresh" onclick={handleRefresh}>Refresh</button>
|
|
54
75
|
</header>
|
|
55
76
|
|
|
77
|
+
{#if updateError}
|
|
78
|
+
<div class="installed-error">{updateError}</div>
|
|
79
|
+
{/if}
|
|
80
|
+
|
|
56
81
|
{#if ctx.state.ephemeral.installed.length === 0}
|
|
57
82
|
<div class="installed-empty">No packages installed.</div>
|
|
58
83
|
{:else}
|
|
@@ -73,6 +98,17 @@
|
|
|
73
98
|
<span>Installed: {formatDate(pkg.installedAt)}</span>
|
|
74
99
|
</div>
|
|
75
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}
|
|
76
112
|
<button
|
|
77
113
|
class="installed-uninstall-btn"
|
|
78
114
|
onclick={() => handleUninstall(pkg.id)}
|
|
@@ -180,6 +216,7 @@
|
|
|
180
216
|
.installed-item-actions {
|
|
181
217
|
display: flex;
|
|
182
218
|
justify-content: flex-end;
|
|
219
|
+
gap: 8px;
|
|
183
220
|
}
|
|
184
221
|
.installed-uninstall-btn {
|
|
185
222
|
padding: 4px 12px;
|
|
@@ -198,4 +235,30 @@
|
|
|
198
235
|
opacity: 0.6;
|
|
199
236
|
cursor: not-allowed;
|
|
200
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
|
+
}
|
|
201
264
|
</style>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { storeContext } from './storeShard.svelte';
|
|
10
10
|
import { fetchBundle, buildPackageMeta } from '../registry/client';
|
|
11
11
|
import { installPackage } from '../registry/installer';
|
|
12
|
+
import { serverInstallPackage } from '../env/client';
|
|
12
13
|
import { contract } from '../contract';
|
|
13
14
|
import type { ResolvedPackage } from '../registry/client';
|
|
14
15
|
import type { InstalledPackage } from '../registry/types';
|
|
@@ -16,25 +17,33 @@
|
|
|
16
17
|
let search = $state('');
|
|
17
18
|
let typeFilter = $state<'all' | 'shard' | 'app'>('all');
|
|
18
19
|
let installingIds = $state<Set<string>>(new Set());
|
|
20
|
+
let updatingIds = $state<Set<string>>(new Set());
|
|
19
21
|
let installError = $state<string | null>(null);
|
|
20
22
|
let newRegistryUrl = $state('');
|
|
21
23
|
|
|
22
24
|
const ctx = storeContext;
|
|
23
25
|
|
|
24
|
-
function handleAddRegistry() {
|
|
26
|
+
async function handleAddRegistry() {
|
|
25
27
|
const url = newRegistryUrl.trim();
|
|
26
28
|
if (!url) return;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
if (ctx.env.registries.includes(url)) return;
|
|
30
|
+
try {
|
|
31
|
+
await ctx.addRegistry(url);
|
|
32
|
+
newRegistryUrl = '';
|
|
33
|
+
ctx.refreshCatalog();
|
|
34
|
+
ctx.refreshInstalled();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
installError = err instanceof Error ? err.message : String(err);
|
|
37
|
+
}
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
function handleRemoveRegistry(url: string) {
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
async function handleRemoveRegistry(url: string) {
|
|
41
|
+
try {
|
|
42
|
+
await ctx.removeRegistry(url);
|
|
43
|
+
ctx.refreshCatalog();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
installError = err instanceof Error ? err.message : String(err);
|
|
46
|
+
}
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
const filtered = $derived.by(() => {
|
|
@@ -58,6 +67,31 @@
|
|
|
58
67
|
return String(pkg.latest.contractVersion) !== String(contract.version);
|
|
59
68
|
}
|
|
60
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
|
+
|
|
61
95
|
async function handleInstall(pkg: ResolvedPackage) {
|
|
62
96
|
const id = pkg.entry.id;
|
|
63
97
|
if (installingIds.has(id)) return;
|
|
@@ -66,14 +100,32 @@
|
|
|
66
100
|
installError = null;
|
|
67
101
|
|
|
68
102
|
try {
|
|
103
|
+
// 1. Fetch and integrity-verify the bundle from the registry.
|
|
69
104
|
const bundle = await fetchBundle(pkg.latest, pkg.sourceRegistry);
|
|
70
105
|
const meta = buildPackageMeta(pkg, pkg.latest);
|
|
106
|
+
|
|
107
|
+
// 2. Upload to server for persistent storage.
|
|
108
|
+
const manifest = {
|
|
109
|
+
id: meta.id,
|
|
110
|
+
type: meta.type,
|
|
111
|
+
label: pkg.entry.label,
|
|
112
|
+
version: meta.version,
|
|
113
|
+
contractVersion: meta.contractVersion,
|
|
114
|
+
sourceRegistry: meta.sourceRegistry,
|
|
115
|
+
installedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
const serverResult = await serverInstallPackage(manifest, bundle);
|
|
118
|
+
if (!serverResult.ok) {
|
|
119
|
+
installError = serverResult.error ?? 'Server install failed';
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. Also install locally for immediate hot-load.
|
|
71
124
|
const result = await installPackage(bundle, meta);
|
|
72
125
|
if (!result.success) {
|
|
73
|
-
|
|
74
|
-
return;
|
|
126
|
+
console.warn(`[sh3-store] Server install ok but local hot-load failed: ${result.error}`);
|
|
75
127
|
}
|
|
76
|
-
|
|
128
|
+
|
|
77
129
|
await ctx.refreshInstalled();
|
|
78
130
|
} catch (err) {
|
|
79
131
|
installError = err instanceof Error ? err.message : String(err);
|
|
@@ -115,9 +167,9 @@
|
|
|
115
167
|
</div>
|
|
116
168
|
</header>
|
|
117
169
|
|
|
118
|
-
{#if ctx.
|
|
170
|
+
{#if ctx.isAdmin && ctx.env.registries.length > 0}
|
|
119
171
|
<div class="store-registries">
|
|
120
|
-
{#each ctx.
|
|
172
|
+
{#each ctx.env.registries as url}
|
|
121
173
|
<div class="store-registry-entry">
|
|
122
174
|
<span class="store-registry-url">{url}</span>
|
|
123
175
|
<button class="store-registry-remove" onclick={() => handleRemoveRegistry(url)}>
|
|
@@ -128,17 +180,19 @@
|
|
|
128
180
|
</div>
|
|
129
181
|
{/if}
|
|
130
182
|
|
|
131
|
-
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
183
|
+
{#if ctx.isAdmin}
|
|
184
|
+
<form class="store-add-registry" onsubmit={(e) => { e.preventDefault(); handleAddRegistry(); }}>
|
|
185
|
+
<input
|
|
186
|
+
class="store-registry-input"
|
|
187
|
+
type="url"
|
|
188
|
+
placeholder="Registry URL (e.g. https://sh3.example.com/registry.json)"
|
|
189
|
+
bind:value={newRegistryUrl}
|
|
190
|
+
/>
|
|
191
|
+
<button type="submit" class="store-add-btn" disabled={!newRegistryUrl.trim()}>
|
|
192
|
+
Add
|
|
193
|
+
</button>
|
|
194
|
+
</form>
|
|
195
|
+
{/if}
|
|
142
196
|
|
|
143
197
|
{#if ctx.state.ephemeral.error}
|
|
144
198
|
<div class="store-error">{ctx.state.ephemeral.error}</div>
|
|
@@ -153,6 +207,8 @@
|
|
|
153
207
|
{@const installed = isInstalled(pkg.entry.id)}
|
|
154
208
|
{@const mismatch = hasContractMismatch(pkg)}
|
|
155
209
|
{@const installing = installingIds.has(pkg.entry.id)}
|
|
210
|
+
{@const updatable = hasUpdate(pkg.entry.id)}
|
|
211
|
+
{@const updating = updatingIds.has(pkg.entry.id)}
|
|
156
212
|
<div class="store-card">
|
|
157
213
|
<div class="store-card-header">
|
|
158
214
|
<div class="store-card-icon">
|
|
@@ -180,7 +236,15 @@
|
|
|
180
236
|
</div>
|
|
181
237
|
{/if}
|
|
182
238
|
<div class="store-card-actions">
|
|
183
|
-
{#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}
|
|
184
248
|
<span class="store-installed-label">Installed</span>
|
|
185
249
|
{:else}
|
|
186
250
|
<button
|
|
@@ -198,8 +262,8 @@
|
|
|
198
262
|
|
|
199
263
|
{#if !ctx.state.ephemeral.loading && filtered.length === 0}
|
|
200
264
|
<div class="store-empty">
|
|
201
|
-
{#if ctx.
|
|
202
|
-
No packages found. Add a registry URL above to get started.
|
|
265
|
+
{#if ctx.env.registries.length === 0}
|
|
266
|
+
No packages found. {#if ctx.isAdmin}Add a registry URL above to get started.{:else}No registries configured.{/if}
|
|
203
267
|
{:else}
|
|
204
268
|
No packages match the current filter.
|
|
205
269
|
{/if}
|
|
@@ -395,6 +459,23 @@
|
|
|
395
459
|
color: var(--shell-success, #4caf50);
|
|
396
460
|
font-weight: 600;
|
|
397
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
|
+
}
|
|
398
479
|
.store-empty {
|
|
399
480
|
text-align: center;
|
|
400
481
|
padding: 32px 16px;
|
package/dist/store/storeApp.js
CHANGED
|
@@ -2,23 +2,32 @@ import type { Shard } from '../shards/types';
|
|
|
2
2
|
import type { StateZones } from '../state/zones.svelte';
|
|
3
3
|
import type { ResolvedPackage } from '../registry/client';
|
|
4
4
|
import type { InstalledPackage } from '../registry/types';
|
|
5
|
+
import type { EnvState } from '../env/types';
|
|
6
|
+
/** Env state shape — server-authoritative config. */
|
|
7
|
+
interface StoreEnvSchema {
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
registries: string[];
|
|
10
|
+
}
|
|
5
11
|
/** Schema shape for state zone typing. */
|
|
6
12
|
interface StoreZoneSchema {
|
|
7
|
-
user: {
|
|
8
|
-
registries: string[];
|
|
9
|
-
};
|
|
10
13
|
ephemeral: {
|
|
11
14
|
catalog: ResolvedPackage[];
|
|
12
15
|
installed: InstalledPackage[];
|
|
16
|
+
updatable: Record<string, ResolvedPackage>;
|
|
13
17
|
loading: boolean;
|
|
14
18
|
error: string | null;
|
|
15
19
|
};
|
|
16
20
|
}
|
|
17
21
|
/** Reactive context exposed to the view components. */
|
|
18
22
|
export interface StoreContext {
|
|
23
|
+
env: EnvState<StoreEnvSchema>;
|
|
19
24
|
state: StateZones<StoreZoneSchema>;
|
|
25
|
+
isAdmin: boolean;
|
|
20
26
|
refreshCatalog(): Promise<void>;
|
|
21
27
|
refreshInstalled(): Promise<void>;
|
|
28
|
+
updatePackage(id: string): Promise<void>;
|
|
29
|
+
addRegistry(url: string): Promise<void>;
|
|
30
|
+
removeRegistry(url: string): Promise<void>;
|
|
22
31
|
}
|
|
23
32
|
/**
|
|
24
33
|
* Module-level context set during activate(). Imported by the Svelte
|
|
@@ -6,16 +6,39 @@
|
|
|
6
6
|
* - `sh3-store:browse` — searchable/filterable catalog of available packages
|
|
7
7
|
* - `sh3-store:installed` — list of installed packages with uninstall
|
|
8
8
|
*
|
|
9
|
-
* Uses
|
|
10
|
-
* for the live catalog / installed list / loading / error state.
|
|
9
|
+
* Uses env state for registries (server-authoritative, admin-writable) and
|
|
10
|
+
* an ephemeral zone for the live catalog / installed list / loading / error state.
|
|
11
11
|
*
|
|
12
12
|
* `.svelte.ts` because mounting Svelte components requires rune access.
|
|
13
13
|
*/
|
|
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';
|
|
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
|
+
}
|
|
19
42
|
/**
|
|
20
43
|
* Module-level context set during activate(). Imported by the Svelte
|
|
21
44
|
* view components so they can read/write store state and trigger refreshes.
|
|
@@ -25,28 +48,40 @@ export const storeShard = {
|
|
|
25
48
|
manifest: {
|
|
26
49
|
id: 'sh3-store',
|
|
27
50
|
label: 'Package Store',
|
|
28
|
-
version: '0.
|
|
51
|
+
version: '0.2.0',
|
|
29
52
|
views: [
|
|
30
53
|
{ id: 'sh3-store:browse', label: 'Store' },
|
|
31
54
|
{ id: 'sh3-store:installed', label: 'Installed' },
|
|
32
55
|
],
|
|
33
56
|
},
|
|
34
57
|
activate(ctx) {
|
|
58
|
+
const env = ctx.env({ registries: [] });
|
|
35
59
|
const state = ctx.state({
|
|
36
|
-
user: { registries: [] },
|
|
37
60
|
ephemeral: {
|
|
38
61
|
catalog: [],
|
|
39
62
|
installed: [],
|
|
63
|
+
updatable: {},
|
|
40
64
|
loading: false,
|
|
41
65
|
error: null,
|
|
42
66
|
},
|
|
43
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
|
+
}
|
|
44
78
|
async function refreshCatalog() {
|
|
45
79
|
state.ephemeral.loading = true;
|
|
46
80
|
state.ephemeral.error = null;
|
|
47
81
|
try {
|
|
48
|
-
const results = await fetchRegistries(
|
|
82
|
+
const results = await fetchRegistries(env.registries);
|
|
49
83
|
state.ephemeral.catalog = results;
|
|
84
|
+
recomputeUpdatable();
|
|
50
85
|
}
|
|
51
86
|
catch (err) {
|
|
52
87
|
state.ephemeral.error =
|
|
@@ -58,15 +93,98 @@ export const storeShard = {
|
|
|
58
93
|
}
|
|
59
94
|
async function refreshInstalled() {
|
|
60
95
|
try {
|
|
61
|
-
const
|
|
62
|
-
state.ephemeral.installed =
|
|
96
|
+
const serverPkgs = await fetchServerPackages();
|
|
97
|
+
state.ephemeral.installed = serverPkgs.map((p) => {
|
|
98
|
+
var _a, _b, _c;
|
|
99
|
+
return ({
|
|
100
|
+
id: p.id,
|
|
101
|
+
type: p.type,
|
|
102
|
+
version: p.version,
|
|
103
|
+
sourceRegistry: (_a = p.sourceRegistry) !== null && _a !== void 0 ? _a : '',
|
|
104
|
+
contractVersion: (_b = p.contractVersion) !== null && _b !== void 0 ? _b : '',
|
|
105
|
+
installedAt: (_c = p.installedAt) !== null && _c !== void 0 ? _c : '',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
recomputeUpdatable();
|
|
63
109
|
}
|
|
64
110
|
catch (err) {
|
|
65
111
|
console.warn('[sh3-store] Failed to list installed packages:', err instanceof Error ? err.message : err);
|
|
112
|
+
// Fall back to local list.
|
|
113
|
+
try {
|
|
114
|
+
const packages = await listInstalledPackages();
|
|
115
|
+
state.ephemeral.installed = packages;
|
|
116
|
+
recomputeUpdatable();
|
|
117
|
+
}
|
|
118
|
+
catch (_a) {
|
|
119
|
+
// Nothing to show.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function addRegistry(url) {
|
|
124
|
+
const registries = [...env.registries];
|
|
125
|
+
if (registries.includes(url))
|
|
126
|
+
return;
|
|
127
|
+
registries.push(url);
|
|
128
|
+
await ctx.envUpdate({ registries });
|
|
129
|
+
}
|
|
130
|
+
async function removeRegistry(url) {
|
|
131
|
+
const registries = env.registries.filter((r) => r !== url);
|
|
132
|
+
await ctx.envUpdate({ registries });
|
|
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');
|
|
66
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();
|
|
67
177
|
}
|
|
68
|
-
|
|
69
|
-
|
|
178
|
+
storeContext = {
|
|
179
|
+
env,
|
|
180
|
+
state,
|
|
181
|
+
get isAdmin() { return ctx.isAdmin; },
|
|
182
|
+
refreshCatalog,
|
|
183
|
+
refreshInstalled,
|
|
184
|
+
updatePackage,
|
|
185
|
+
addRegistry,
|
|
186
|
+
removeRegistry,
|
|
187
|
+
};
|
|
70
188
|
// --- View factories ---
|
|
71
189
|
const browseFactory = {
|
|
72
190
|
mount(container, _context) {
|
package/dist/version.js
ADDED