sh3-core 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BrandSlot.svelte +62 -3
- package/dist/BrandSlot.test.js +52 -0
- package/dist/apps/types.d.ts +8 -0
- package/dist/layout/store.svelte.js +1 -1
- package/dist/overlays/presets.d.ts +17 -2
- package/dist/overlays/presets.js +28 -2
- package/dist/overlays/presets.test.js +29 -0
- package/dist/registry/installer.js +50 -10
- package/dist/registry/installer.test.d.ts +1 -0
- package/dist/registry/installer.test.js +146 -0
- package/dist/registry/types.d.ts +19 -0
- package/dist/runtime/runVerb.test.js +87 -0
- package/dist/shards/lifecycle.svelte.d.ts +8 -0
- package/dist/shards/lifecycle.svelte.js +17 -0
- package/dist/shell-shard/verbs/xfer.js +66 -4
- package/dist/shell-shard/verbs/xfer.test.js +74 -0
- package/dist/verbs/types.d.ts +49 -12
- package/dist/verbs/types.test.d.ts +1 -0
- package/dist/verbs/types.test.js +43 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/BrandSlot.svelte
CHANGED
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
import { getBreadcrumbAppId, getRegisteredApp } from './apps/registry.svelte';
|
|
16
16
|
import { sessionState, switchProjectScope } from './projects/session-state.svelte';
|
|
17
17
|
import { projectsState } from './projects-shard/projectsShard.svelte';
|
|
18
|
+
import { presetManager } from './overlays/presets';
|
|
19
|
+
|
|
20
|
+
function titleCasePresetName(name: string): string {
|
|
21
|
+
return name
|
|
22
|
+
.split(/[-_]/)
|
|
23
|
+
.filter((part) => part.length > 0)
|
|
24
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
25
|
+
.join(' ');
|
|
26
|
+
}
|
|
18
27
|
|
|
19
28
|
const activeAppId = $derived(getLiveDispatcherState().activeAppId);
|
|
20
29
|
const breadcrumbId = $derived(getBreadcrumbAppId());
|
|
@@ -30,15 +39,44 @@
|
|
|
30
39
|
projectId ? projectsState.projects.find((p) => p.id === projectId)?.name ?? projectId : null,
|
|
31
40
|
);
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
// Preset state is only meaningful while an app is attached. Existing
|
|
43
|
+
// BrandSlot tests set activeAppId without binding a preset blob, so the
|
|
44
|
+
// try/catch here is necessary, not just defensive.
|
|
45
|
+
const offDefault = $derived.by(() => {
|
|
46
|
+
if (activeAppId === null) return false;
|
|
47
|
+
try {
|
|
48
|
+
return presetManager.active() !== presetManager.default();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const presetLabel = $derived.by(() => {
|
|
55
|
+
if (!offDefault) return null;
|
|
56
|
+
try {
|
|
57
|
+
return titleCasePresetName(presetManager.active());
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type Mode =
|
|
64
|
+
| 'brand'
|
|
65
|
+
| 'app'
|
|
66
|
+
| 'app-off-default'
|
|
67
|
+
| 'breadcrumb'
|
|
68
|
+
| 'project-home'
|
|
69
|
+
| 'project-app'
|
|
70
|
+
| 'project-app-off-default'
|
|
71
|
+
| 'project-breadcrumb';
|
|
34
72
|
|
|
35
73
|
const mode: Mode = $derived.by(() => {
|
|
36
74
|
if (projectId) {
|
|
37
|
-
if (activeAppId) return 'project-app';
|
|
75
|
+
if (activeAppId) return offDefault ? 'project-app-off-default' : 'project-app';
|
|
38
76
|
if (breadcrumbId) return 'project-breadcrumb';
|
|
39
77
|
return 'project-home';
|
|
40
78
|
}
|
|
41
|
-
if (activeAppId) return 'app';
|
|
79
|
+
if (activeAppId) return offDefault ? 'app-off-default' : 'app';
|
|
42
80
|
if (breadcrumbId) return 'breadcrumb';
|
|
43
81
|
return 'brand';
|
|
44
82
|
});
|
|
@@ -54,6 +92,14 @@
|
|
|
54
92
|
function reenterProjectHome() {
|
|
55
93
|
if (activeAppId) void returnToHome();
|
|
56
94
|
}
|
|
95
|
+
|
|
96
|
+
function backToDefaultPreset() {
|
|
97
|
+
try {
|
|
98
|
+
presetManager.switchToDefault();
|
|
99
|
+
} catch {
|
|
100
|
+
// Blob unbound mid-click — nothing to do.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
57
103
|
</script>
|
|
58
104
|
|
|
59
105
|
<div class="sh3-brand-slot">
|
|
@@ -61,6 +107,10 @@
|
|
|
61
107
|
<span class="sh3-brand">SH3</span>
|
|
62
108
|
{:else if mode === 'app'}
|
|
63
109
|
<span class="sh3-brand sh3-brand-app">{activeLabel}</span>
|
|
110
|
+
{:else if mode === 'app-off-default'}
|
|
111
|
+
<button type="button" class="sh3-brand sh3-brand-app sh3-brand-clickable" onclick={backToDefaultPreset} title="Return to default layout">{activeLabel}</button>
|
|
112
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
113
|
+
<span class="sh3-brand-preset">{presetLabel}</span>
|
|
64
114
|
{:else if mode === 'breadcrumb'}
|
|
65
115
|
<span class="sh3-brand">SH3</span>
|
|
66
116
|
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
@@ -73,6 +123,12 @@
|
|
|
73
123
|
<span class="sh3-brand sh3-brand-project">{projectLabel}</span>
|
|
74
124
|
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
75
125
|
<span class="sh3-brand sh3-brand-app">{activeLabel}</span>
|
|
126
|
+
{:else if mode === 'project-app-off-default'}
|
|
127
|
+
<span class="sh3-brand sh3-brand-project">{projectLabel}</span>
|
|
128
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
129
|
+
<button type="button" class="sh3-brand sh3-brand-app sh3-brand-clickable" onclick={backToDefaultPreset} title="Return to default layout">{activeLabel}</button>
|
|
130
|
+
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
131
|
+
<span class="sh3-brand-preset">{presetLabel}</span>
|
|
76
132
|
{:else if mode === 'project-breadcrumb'}
|
|
77
133
|
<button type="button" class="sh3-brand sh3-brand-clickable" onclick={exitProject} title="Exit project">SH3</button>
|
|
78
134
|
<span class="sh3-brand-sep" aria-hidden="true">›</span>
|
|
@@ -126,4 +182,7 @@
|
|
|
126
182
|
.sh3-brand-clickable:hover {
|
|
127
183
|
background: var(--sh3-bg-elevated);
|
|
128
184
|
}
|
|
185
|
+
.sh3-brand-preset {
|
|
186
|
+
color: var(--sh3-fg-muted);
|
|
187
|
+
}
|
|
129
188
|
</style>
|
package/dist/BrandSlot.test.js
CHANGED
|
@@ -11,6 +11,7 @@ import BrandSlot from './BrandSlot.svelte';
|
|
|
11
11
|
import { setActiveApp, __resetDispatcherStateForTest } from './actions/state.svelte';
|
|
12
12
|
import { registerApp, __resetAppRegistryForTest, __resetBreadcrumbForTest, breadcrumbApp, } from './apps/registry.svelte';
|
|
13
13
|
import { launchApp } from './apps/lifecycle';
|
|
14
|
+
import { __bindPresetBlobForTest, __resetPresetManagerForTest, } from './overlays/presets';
|
|
14
15
|
let host;
|
|
15
16
|
let cmp = null;
|
|
16
17
|
function makeApp(id, label) {
|
|
@@ -27,6 +28,7 @@ beforeEach(() => {
|
|
|
27
28
|
document.body.appendChild(host);
|
|
28
29
|
__resetBreadcrumbForTest();
|
|
29
30
|
__resetDispatcherStateForTest();
|
|
31
|
+
__resetPresetManagerForTest();
|
|
30
32
|
});
|
|
31
33
|
afterEach(() => {
|
|
32
34
|
if (cmp) {
|
|
@@ -37,7 +39,20 @@ afterEach(() => {
|
|
|
37
39
|
__resetAppRegistryForTest();
|
|
38
40
|
__resetDispatcherStateForTest();
|
|
39
41
|
vi.clearAllMocks();
|
|
42
|
+
__resetPresetManagerForTest();
|
|
40
43
|
});
|
|
44
|
+
function bindBlobWithPresets(activePreset, names, defaultHint) {
|
|
45
|
+
const blob = {
|
|
46
|
+
layoutVersion: 1,
|
|
47
|
+
activePreset,
|
|
48
|
+
presets: Object.fromEntries(names.map((n) => [
|
|
49
|
+
n,
|
|
50
|
+
{ default: { docked: { type: 'slot', slotId: `${n}-s`, viewId: 'v' }, floats: [] } },
|
|
51
|
+
])),
|
|
52
|
+
};
|
|
53
|
+
__bindPresetBlobForTest(blob, defaultHint);
|
|
54
|
+
return blob;
|
|
55
|
+
}
|
|
41
56
|
describe('BrandSlot', () => {
|
|
42
57
|
it('renders SH3 when no app has launched this session', async () => {
|
|
43
58
|
var _a;
|
|
@@ -68,4 +83,41 @@ describe('BrandSlot', () => {
|
|
|
68
83
|
btn.click();
|
|
69
84
|
expect(launchApp).toHaveBeenCalledWith('app.a');
|
|
70
85
|
});
|
|
86
|
+
it('renders [App Name] (no preset chip) when active app is on default preset', async () => {
|
|
87
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
88
|
+
breadcrumbApp.id = 'app.a';
|
|
89
|
+
setActiveApp('app.a', new Set());
|
|
90
|
+
bindBlobWithPresets('home', ['home', 'editing'], 'home');
|
|
91
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
92
|
+
await tick();
|
|
93
|
+
expect(host.textContent).toContain('My App');
|
|
94
|
+
expect(host.textContent).not.toContain('Home');
|
|
95
|
+
expect(host.textContent).not.toContain('Editing');
|
|
96
|
+
// App segment is plain text (no button) in the default-preset case.
|
|
97
|
+
expect(host.querySelector('button')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
it('renders [App Name] › [Preset] with App clickable when off default', async () => {
|
|
100
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
101
|
+
breadcrumbApp.id = 'app.a';
|
|
102
|
+
setActiveApp('app.a', new Set());
|
|
103
|
+
const blob = bindBlobWithPresets('editing', ['home', 'editing'], 'home');
|
|
104
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
105
|
+
await tick();
|
|
106
|
+
expect(host.textContent).toMatch(/My App.*Editing/);
|
|
107
|
+
const btn = host.querySelector('button');
|
|
108
|
+
expect(btn).not.toBeNull();
|
|
109
|
+
expect(btn.textContent).toContain('My App');
|
|
110
|
+
btn.click();
|
|
111
|
+
await tick();
|
|
112
|
+
expect(blob.activePreset).toBe('home');
|
|
113
|
+
});
|
|
114
|
+
it('titlecases multi-word preset names with hyphens or underscores', async () => {
|
|
115
|
+
registerApp(makeApp('app.a', 'My App'));
|
|
116
|
+
breadcrumbApp.id = 'app.a';
|
|
117
|
+
setActiveApp('app.a', new Set());
|
|
118
|
+
bindBlobWithPresets('my-cool-preset', ['home', 'my-cool-preset'], 'home');
|
|
119
|
+
cmp = mount(BrandSlot, { target: host, props: {} });
|
|
120
|
+
await tick();
|
|
121
|
+
expect(host.textContent).toContain('My Cool Preset');
|
|
122
|
+
});
|
|
71
123
|
});
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export interface AppManifest {
|
|
|
95
95
|
* Optional default home-card color default to transparent if not set
|
|
96
96
|
*/
|
|
97
97
|
color?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Name of the preset (from `initialLayout: LayoutPreset[]`) that is treated
|
|
100
|
+
* as the entry/home preset. When set, clicking the app segment in the
|
|
101
|
+
* breadcrumb returns the user here; framework helpers also use it as the
|
|
102
|
+
* "back to default" target. When omitted, the first preset in declaration
|
|
103
|
+
* order is used. Ignored when `initialLayout` is not an array of presets.
|
|
104
|
+
*/
|
|
105
|
+
defaultPreset?: string;
|
|
98
106
|
}
|
|
99
107
|
/**
|
|
100
108
|
* Context object passed to `App.activate`. Provides app-scoped state zones
|
|
@@ -142,7 +142,7 @@ export function attachApp(app) {
|
|
|
142
142
|
// their view factories. Binding the preset manager proxy happens here
|
|
143
143
|
// so shards can read/switch presets from their activate() hook.
|
|
144
144
|
appEntry = { appId: app.manifest.id, proxy, heldSlotIds: [] };
|
|
145
|
-
bindPresetBlob(proxy);
|
|
145
|
+
bindPresetBlob(proxy, app.manifest.defaultPreset);
|
|
146
146
|
bindDrawerStoreToBlob(proxy);
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
@@ -6,16 +6,31 @@ export interface PresetManager {
|
|
|
6
6
|
active(): string;
|
|
7
7
|
/** Switch to the named preset. Throws if unknown. */
|
|
8
8
|
switch(name: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Resolved default preset name. Returns the hint passed at bind time
|
|
11
|
+
* when it matches a known preset, otherwise the first preset in
|
|
12
|
+
* declaration order. Throws when no app is attached.
|
|
13
|
+
*/
|
|
14
|
+
default(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Switch to the resolved default. No-op when already on default.
|
|
17
|
+
* Throws when no app is attached.
|
|
18
|
+
*/
|
|
19
|
+
switchToDefault(): void;
|
|
9
20
|
}
|
|
10
21
|
/**
|
|
11
22
|
* Bind the manager to the attached app's `AppLayoutBlob` workspace-zone
|
|
12
23
|
* proxy. Called from `attachApp` in the layout store.
|
|
24
|
+
*
|
|
25
|
+
* @param defaultPresetHint - Optional name from `AppManifest.defaultPreset`.
|
|
26
|
+
* Used by `default()` / `switchToDefault()`. Falls back to first preset
|
|
27
|
+
* when omitted or when the hint doesn't match any preset.
|
|
13
28
|
*/
|
|
14
|
-
export declare function bindPresetBlob(blob: AppLayoutBlob): void;
|
|
29
|
+
export declare function bindPresetBlob(blob: AppLayoutBlob, defaultPresetHint?: string): void;
|
|
15
30
|
/** Unbind on detach. Called from `detachApp`. */
|
|
16
31
|
export declare function unbindPresetBlob(): void;
|
|
17
32
|
/** Test-only bind alias for tests that build a synthetic blob. */
|
|
18
|
-
export declare function __bindPresetBlobForTest(blob: AppLayoutBlob): void;
|
|
33
|
+
export declare function __bindPresetBlobForTest(blob: AppLayoutBlob, defaultPresetHint?: string): void;
|
|
19
34
|
/** Test-only reset. Clears the binding. */
|
|
20
35
|
export declare function __resetPresetManagerForTest(): void;
|
|
21
36
|
export declare const presetManager: PresetManager;
|
package/dist/overlays/presets.js
CHANGED
|
@@ -18,24 +18,33 @@
|
|
|
18
18
|
* all methods throw — there is no pre-boot fallback.
|
|
19
19
|
*/
|
|
20
20
|
let boundBlob = null;
|
|
21
|
+
let boundDefaultHint = null;
|
|
21
22
|
/**
|
|
22
23
|
* Bind the manager to the attached app's `AppLayoutBlob` workspace-zone
|
|
23
24
|
* proxy. Called from `attachApp` in the layout store.
|
|
25
|
+
*
|
|
26
|
+
* @param defaultPresetHint - Optional name from `AppManifest.defaultPreset`.
|
|
27
|
+
* Used by `default()` / `switchToDefault()`. Falls back to first preset
|
|
28
|
+
* when omitted or when the hint doesn't match any preset.
|
|
24
29
|
*/
|
|
25
|
-
export function bindPresetBlob(blob) {
|
|
30
|
+
export function bindPresetBlob(blob, defaultPresetHint) {
|
|
26
31
|
boundBlob = blob;
|
|
32
|
+
boundDefaultHint = defaultPresetHint !== null && defaultPresetHint !== void 0 ? defaultPresetHint : null;
|
|
27
33
|
}
|
|
28
34
|
/** Unbind on detach. Called from `detachApp`. */
|
|
29
35
|
export function unbindPresetBlob() {
|
|
30
36
|
boundBlob = null;
|
|
37
|
+
boundDefaultHint = null;
|
|
31
38
|
}
|
|
32
39
|
/** Test-only bind alias for tests that build a synthetic blob. */
|
|
33
|
-
export function __bindPresetBlobForTest(blob) {
|
|
40
|
+
export function __bindPresetBlobForTest(blob, defaultPresetHint) {
|
|
34
41
|
boundBlob = blob;
|
|
42
|
+
boundDefaultHint = defaultPresetHint !== null && defaultPresetHint !== void 0 ? defaultPresetHint : null;
|
|
35
43
|
}
|
|
36
44
|
/** Test-only reset. Clears the binding. */
|
|
37
45
|
export function __resetPresetManagerForTest() {
|
|
38
46
|
boundBlob = null;
|
|
47
|
+
boundDefaultHint = null;
|
|
39
48
|
}
|
|
40
49
|
function requireBlob() {
|
|
41
50
|
if (!boundBlob) {
|
|
@@ -56,8 +65,25 @@ function switchPreset(name) {
|
|
|
56
65
|
}
|
|
57
66
|
blob.activePreset = name;
|
|
58
67
|
}
|
|
68
|
+
function resolveDefault() {
|
|
69
|
+
const blob = requireBlob();
|
|
70
|
+
const keys = Object.keys(blob.presets);
|
|
71
|
+
if (boundDefaultHint && boundDefaultHint in blob.presets) {
|
|
72
|
+
return boundDefaultHint;
|
|
73
|
+
}
|
|
74
|
+
return keys[0];
|
|
75
|
+
}
|
|
76
|
+
function switchToDefault() {
|
|
77
|
+
const target = resolveDefault();
|
|
78
|
+
const blob = requireBlob();
|
|
79
|
+
if (blob.activePreset === target)
|
|
80
|
+
return;
|
|
81
|
+
blob.activePreset = target;
|
|
82
|
+
}
|
|
59
83
|
export const presetManager = {
|
|
60
84
|
list: listPresets,
|
|
61
85
|
active: activePreset,
|
|
62
86
|
switch: switchPreset,
|
|
87
|
+
default: resolveDefault,
|
|
88
|
+
switchToDefault,
|
|
63
89
|
};
|
|
@@ -37,4 +37,33 @@ describe('presetManager', () => {
|
|
|
37
37
|
it('list() throws when no blob is bound', () => {
|
|
38
38
|
expect(() => presetManager.list()).toThrow(/no app attached/);
|
|
39
39
|
});
|
|
40
|
+
it('default() returns the explicit hint when it matches a known preset', () => {
|
|
41
|
+
__bindPresetBlobForTest(makeBlob('author', ['author', 'review', 'inspect']), 'review');
|
|
42
|
+
expect(presetManager.default()).toBe('review');
|
|
43
|
+
});
|
|
44
|
+
it('default() falls back to first preset when no hint is provided', () => {
|
|
45
|
+
__bindPresetBlobForTest(makeBlob('review', ['author', 'review']));
|
|
46
|
+
expect(presetManager.default()).toBe('author');
|
|
47
|
+
});
|
|
48
|
+
it('default() falls back to first preset when hint is unknown', () => {
|
|
49
|
+
__bindPresetBlobForTest(makeBlob('author', ['author', 'review']), 'nope');
|
|
50
|
+
expect(presetManager.default()).toBe('author');
|
|
51
|
+
});
|
|
52
|
+
it('default() throws when no blob is bound', () => {
|
|
53
|
+
expect(() => presetManager.default()).toThrow(/no app attached/);
|
|
54
|
+
});
|
|
55
|
+
it('switchToDefault() switches to the resolved default preset', () => {
|
|
56
|
+
const blob = makeBlob('review', ['author', 'review']);
|
|
57
|
+
__bindPresetBlobForTest(blob, 'author');
|
|
58
|
+
presetManager.switchToDefault();
|
|
59
|
+
expect(blob.activePreset).toBe('author');
|
|
60
|
+
expect(presetManager.active()).toBe('author');
|
|
61
|
+
});
|
|
62
|
+
it('switchToDefault() is a no-op when already on default', () => {
|
|
63
|
+
const blob = makeBlob('author', ['author', 'review']);
|
|
64
|
+
__bindPresetBlobForTest(blob, 'author');
|
|
65
|
+
const before = blob.activePreset;
|
|
66
|
+
presetManager.switchToDefault();
|
|
67
|
+
expect(blob.activePreset).toBe(before);
|
|
68
|
+
});
|
|
40
69
|
});
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* packages from IndexedDB and registers them.
|
|
16
16
|
*/
|
|
17
17
|
import { loadBundleModule } from './loader';
|
|
18
|
-
import { savePackage, loadBundle, listInstalled, removePackage } from './storage';
|
|
19
|
-
import { deactivateShard } from '../shards/lifecycle.svelte';
|
|
18
|
+
import { savePackage, loadBundle, loadMeta, listInstalled, removePackage } from './storage';
|
|
19
|
+
import { deactivateShard, unregisterShard } from '../shards/lifecycle.svelte';
|
|
20
20
|
import { unregisterApp } from '../apps/lifecycle';
|
|
21
21
|
import { registerLoadedBundle } from './register';
|
|
22
22
|
import { extractBundlePermissions } from './permission-descriptions';
|
|
@@ -35,6 +35,7 @@ import { fetchServerPackages } from '../env/client';
|
|
|
35
35
|
* @returns Result object indicating success/failure and hot-load status.
|
|
36
36
|
*/
|
|
37
37
|
export async function installPackage(bundle, meta, options) {
|
|
38
|
+
var _a, _b;
|
|
38
39
|
// 1. Load the module from bytes (or reuse the caller's copy).
|
|
39
40
|
// Archive integrity is verified upstream in fetchArchive() before extraction.
|
|
40
41
|
let loaded;
|
|
@@ -68,7 +69,33 @@ export async function installPackage(bundle, meta, options) {
|
|
|
68
69
|
error: `Package "${meta.id}" declared type "app" but bundle contains no valid app`,
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
|
-
// 4.
|
|
72
|
+
// 4. Compute the new bundle's contributed ids. Used both to populate the
|
|
73
|
+
// new InstalledPackage record AND to diff against the previous record
|
|
74
|
+
// so we can drop shards/apps the new bundle no longer ships.
|
|
75
|
+
const contributedShards = loaded.shards.map((s) => s.manifest.id);
|
|
76
|
+
const contributedApps = loaded.apps.map((a) => a.manifest.id);
|
|
77
|
+
// 5. Diff against the previous record (if any) and unregister anything the
|
|
78
|
+
// new bundle no longer ships. Without this, a combo update that drops
|
|
79
|
+
// an app leaves the dropped app sitting in `registeredApps` until the
|
|
80
|
+
// user clears the webview cache (Bug A).
|
|
81
|
+
//
|
|
82
|
+
// Legacy records written before `contributedShards`/`contributedApps`
|
|
83
|
+
// existed treat the missing field as the empty set — no diff is
|
|
84
|
+
// possible for that one upgrade, and the fields populate on this save.
|
|
85
|
+
const prior = await loadMeta(meta.id).catch(() => null);
|
|
86
|
+
if (prior) {
|
|
87
|
+
const newShardSet = new Set(contributedShards);
|
|
88
|
+
const newAppSet = new Set(contributedApps);
|
|
89
|
+
for (const id of (_a = prior.contributedShards) !== null && _a !== void 0 ? _a : []) {
|
|
90
|
+
if (!newShardSet.has(id))
|
|
91
|
+
unregisterShard(id);
|
|
92
|
+
}
|
|
93
|
+
for (const id of (_b = prior.contributedApps) !== null && _b !== void 0 ? _b : []) {
|
|
94
|
+
if (!newAppSet.has(id))
|
|
95
|
+
unregisterApp(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 6. Persist to IndexedDB. Permissions captured from manifest(s).
|
|
72
99
|
const record = {
|
|
73
100
|
id: meta.id,
|
|
74
101
|
type: meta.type,
|
|
@@ -77,6 +104,8 @@ export async function installPackage(bundle, meta, options) {
|
|
|
77
104
|
contractVersion: meta.contractVersion,
|
|
78
105
|
installedAt: new Date().toISOString(),
|
|
79
106
|
permissions: extractBundlePermissions(loaded),
|
|
107
|
+
contributedShards,
|
|
108
|
+
contributedApps,
|
|
80
109
|
};
|
|
81
110
|
try {
|
|
82
111
|
await savePackage(meta.id, bundle, record);
|
|
@@ -88,15 +117,16 @@ export async function installPackage(bundle, meta, options) {
|
|
|
88
117
|
error: `Failed to persist package: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
118
|
};
|
|
90
119
|
}
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
120
|
+
// 7. Evict any existing registration for the package id itself before
|
|
121
|
+
// re-registering. Step 5's diff covers ids the new bundle drops; this
|
|
122
|
+
// handles the case where the package id IS one of the new bundle's
|
|
123
|
+
// shard/app ids (single-shard or single-app packages) and we need to
|
|
124
|
+
// tear down its active state before re-registering the new module.
|
|
95
125
|
if (meta.type === 'shard' || meta.type === 'combo') {
|
|
96
126
|
try {
|
|
97
127
|
deactivateShard(meta.id);
|
|
98
128
|
}
|
|
99
|
-
catch ( /* not active or not a shard */
|
|
129
|
+
catch ( /* not active or not a shard */_c) { /* not active or not a shard */ }
|
|
100
130
|
}
|
|
101
131
|
if (meta.type === 'app' || meta.type === 'combo') {
|
|
102
132
|
unregisterApp(meta.id);
|
|
@@ -175,11 +205,21 @@ export async function loadInstalledPackages() {
|
|
|
175
205
|
await removePackage(pkg.id).catch(() => { });
|
|
176
206
|
}
|
|
177
207
|
}
|
|
178
|
-
// Load packages from server — use IndexedDB cache if available
|
|
208
|
+
// Load packages from server — use IndexedDB cache if available AND the
|
|
209
|
+
// cached version matches the server's. The server is the source of truth
|
|
210
|
+
// for installed versions: if another device updated the package, this
|
|
211
|
+
// device's cache holds a stale bundle and must refetch (Bug B). Without
|
|
212
|
+
// this, device B opens the env, sees `id` is in local cache, loads the
|
|
213
|
+
// old bytes, and never notices it's behind.
|
|
179
214
|
for (const serverPkg of serverPackages) {
|
|
180
215
|
if (localIds.has(serverPkg.id)) {
|
|
181
216
|
const localPkg = localPackages.find(p => p.id === serverPkg.id);
|
|
182
|
-
|
|
217
|
+
if (localPkg.version !== serverPkg.version) {
|
|
218
|
+
await _fetchAndCacheFromServer(serverPkg);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
await _loadFromIndexedDB(localPkg);
|
|
222
|
+
}
|
|
183
223
|
}
|
|
184
224
|
else {
|
|
185
225
|
await _fetchAndCacheFromServer(serverPkg);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
import { resetFramework } from '../__test__/reset';
|
|
4
|
+
import { makeApp, makeAppManifest, makeShard, makeShardManifest } from '../__test__/fixtures';
|
|
5
|
+
import { installPackage, loadInstalledPackages } from './installer';
|
|
6
|
+
import { savePackage } from './storage';
|
|
7
|
+
import { registeredApps } from '../apps/registry.svelte';
|
|
8
|
+
import { registeredShards } from '../shards/lifecycle.svelte';
|
|
9
|
+
// Mock the network for loadInstalledPackages tests.
|
|
10
|
+
vi.mock('../env/client', () => ({
|
|
11
|
+
fetchServerPackages: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
import { fetchServerPackages } from '../env/client';
|
|
14
|
+
async function wipeIndexedDB() {
|
|
15
|
+
await new Promise((resolve) => {
|
|
16
|
+
const req = indexedDB.deleteDatabase('sh3-packages');
|
|
17
|
+
req.onsuccess = () => resolve();
|
|
18
|
+
req.onerror = () => resolve();
|
|
19
|
+
req.onblocked = () => resolve();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function makeLoaded(parts = {}) {
|
|
23
|
+
var _a, _b;
|
|
24
|
+
return {
|
|
25
|
+
shards: ((_a = parts.shards) !== null && _a !== void 0 ? _a : []).map((id) => makeShard({ manifest: makeShardManifest({ id }) })),
|
|
26
|
+
apps: ((_b = parts.apps) !== null && _b !== void 0 ? _b : []).map((id) => makeApp({ manifest: makeAppManifest({ id }) })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function meta(id, type, version) {
|
|
30
|
+
return { id, type, version, sourceRegistry: '', contractVersion: '1' };
|
|
31
|
+
}
|
|
32
|
+
function installedRecord(id, type, version) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
type,
|
|
36
|
+
version,
|
|
37
|
+
sourceRegistry: '',
|
|
38
|
+
contractVersion: '1',
|
|
39
|
+
installedAt: '2026-01-01T00:00:00.000Z',
|
|
40
|
+
permissions: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Bug A — installPackage must drop apps/shards the new bundle no longer ships
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('installPackage — diff-based unregistration (Bug A)', () => {
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
resetFramework();
|
|
49
|
+
await wipeIndexedDB();
|
|
50
|
+
});
|
|
51
|
+
it('unregisters apps from the previous bundle that the new bundle no longer ships', async () => {
|
|
52
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
53
|
+
loaded: makeLoaded({ apps: ['bar', 'baz'] }),
|
|
54
|
+
});
|
|
55
|
+
expect(registeredApps.has('bar')).toBe(true);
|
|
56
|
+
expect(registeredApps.has('baz')).toBe(true);
|
|
57
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
58
|
+
loaded: makeLoaded({ apps: ['bar'] }),
|
|
59
|
+
});
|
|
60
|
+
expect(registeredApps.has('bar')).toBe(true);
|
|
61
|
+
expect(registeredApps.has('baz')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('unregisters shards from the previous bundle that the new bundle no longer ships', async () => {
|
|
64
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
65
|
+
loaded: makeLoaded({ shards: ['shard-a', 'shard-b'] }),
|
|
66
|
+
});
|
|
67
|
+
expect(registeredShards.has('shard-a')).toBe(true);
|
|
68
|
+
expect(registeredShards.has('shard-b')).toBe(true);
|
|
69
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
70
|
+
loaded: makeLoaded({ shards: ['shard-a'] }),
|
|
71
|
+
});
|
|
72
|
+
expect(registeredShards.has('shard-a')).toBe(true);
|
|
73
|
+
expect(registeredShards.has('shard-b')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('preserves entries present in both old and new bundles', async () => {
|
|
76
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
77
|
+
loaded: makeLoaded({ shards: ['s1'], apps: ['a1'] }),
|
|
78
|
+
});
|
|
79
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
80
|
+
loaded: makeLoaded({ shards: ['s1'], apps: ['a1'] }),
|
|
81
|
+
});
|
|
82
|
+
expect(registeredShards.has('s1')).toBe(true);
|
|
83
|
+
expect(registeredApps.has('a1')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('does not touch unrelated packages on first install of a new package', async () => {
|
|
86
|
+
await installPackage(new ArrayBuffer(0), meta('pkg-other', 'app', '1.0.0'), {
|
|
87
|
+
loaded: makeLoaded({ apps: ['other-app'] }),
|
|
88
|
+
});
|
|
89
|
+
await installPackage(new ArrayBuffer(0), meta('pkg-new', 'app', '1.0.0'), {
|
|
90
|
+
loaded: makeLoaded({ apps: ['new-app'] }),
|
|
91
|
+
});
|
|
92
|
+
expect(registeredApps.has('other-app')).toBe(true);
|
|
93
|
+
expect(registeredApps.has('new-app')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Bug B — loadInstalledPackages must refetch when local version != server
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
describe('loadInstalledPackages — version reconciliation (Bug B)', () => {
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
resetFramework();
|
|
102
|
+
await wipeIndexedDB();
|
|
103
|
+
vi.resetAllMocks();
|
|
104
|
+
// Global fetch — used by _fetchAndCacheFromServer.
|
|
105
|
+
globalThis.fetch = vi.fn();
|
|
106
|
+
// Silence expected warnings: empty-byte bundles can't be dynamic-imported
|
|
107
|
+
// in node, so loadBundleModule throws and the installer warns. The
|
|
108
|
+
// routing decision under test happens before that, so the warnings are
|
|
109
|
+
// noise we don't want polluting test output.
|
|
110
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
111
|
+
});
|
|
112
|
+
it('refetches from server when local cached version differs from server version', async () => {
|
|
113
|
+
await savePackage('foo', new ArrayBuffer(0), installedRecord('foo', 'app', '1.0.0'));
|
|
114
|
+
fetchServerPackages.mockResolvedValue([
|
|
115
|
+
{
|
|
116
|
+
id: 'foo',
|
|
117
|
+
type: 'app',
|
|
118
|
+
version: '1.1.0',
|
|
119
|
+
bundleUrl: 'http://server.test/packages/foo/client.js',
|
|
120
|
+
sourceRegistry: '',
|
|
121
|
+
contractVersion: '1',
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
globalThis.fetch.mockResolvedValue({
|
|
125
|
+
ok: true,
|
|
126
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
127
|
+
});
|
|
128
|
+
await loadInstalledPackages();
|
|
129
|
+
expect(globalThis.fetch).toHaveBeenCalledWith('http://server.test/packages/foo/client.js');
|
|
130
|
+
});
|
|
131
|
+
it('uses local cache when local version matches server version', async () => {
|
|
132
|
+
await savePackage('foo', new ArrayBuffer(0), installedRecord('foo', 'app', '1.0.0'));
|
|
133
|
+
fetchServerPackages.mockResolvedValue([
|
|
134
|
+
{
|
|
135
|
+
id: 'foo',
|
|
136
|
+
type: 'app',
|
|
137
|
+
version: '1.0.0',
|
|
138
|
+
bundleUrl: 'http://server.test/packages/foo/client.js',
|
|
139
|
+
sourceRegistry: '',
|
|
140
|
+
contractVersion: '1',
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
await loadInstalledPackages();
|
|
144
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
package/dist/registry/types.d.ts
CHANGED
|
@@ -183,6 +183,25 @@ export interface InstalledPackage {
|
|
|
183
183
|
* must treat a missing value as `[]`.
|
|
184
184
|
*/
|
|
185
185
|
permissions: string[];
|
|
186
|
+
/**
|
|
187
|
+
* Shard ids this package contributed at install time. Populated from
|
|
188
|
+
* `LoadedBundle.shards[].manifest.id`. Used at reinstall to diff against
|
|
189
|
+
* the new bundle and unregister shards the new version no longer ships.
|
|
190
|
+
*
|
|
191
|
+
* Optional for backwards compatibility with records written before this
|
|
192
|
+
* field existed. Treat a missing value as "unknown" — diffing is skipped
|
|
193
|
+
* for that record and the field is populated on the next install.
|
|
194
|
+
*/
|
|
195
|
+
contributedShards?: string[];
|
|
196
|
+
/**
|
|
197
|
+
* App ids this package contributed at install time. Populated from
|
|
198
|
+
* `LoadedBundle.apps[].manifest.id`. Same diff-and-unregister role as
|
|
199
|
+
* `contributedShards`.
|
|
200
|
+
*
|
|
201
|
+
* Optional for backwards compatibility with records written before this
|
|
202
|
+
* field existed.
|
|
203
|
+
*/
|
|
204
|
+
contributedApps?: string[];
|
|
186
205
|
}
|
|
187
206
|
/**
|
|
188
207
|
* Result of an install operation.
|
|
@@ -175,4 +175,91 @@ describe('runVerbProgrammatic', () => {
|
|
|
175
175
|
await runVerbProgrammatic('docs-probe-2', 'docs-probe-2:peek', []);
|
|
176
176
|
expect(seenDocs).toBeUndefined();
|
|
177
177
|
});
|
|
178
|
+
it('surfaces the verb return value as result', async () => {
|
|
179
|
+
registerShard({
|
|
180
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
181
|
+
register(ctx) {
|
|
182
|
+
ctx.registerVerb({
|
|
183
|
+
name: 'returnObj',
|
|
184
|
+
summary: 'returns an object',
|
|
185
|
+
programmatic: true,
|
|
186
|
+
async run() {
|
|
187
|
+
return { answer: 'ok', count: 3 };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await activateShard('tester');
|
|
193
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnObj', []);
|
|
194
|
+
expect(out.result).toEqual({ answer: 'ok', count: 3 });
|
|
195
|
+
});
|
|
196
|
+
it('surfaces a primitive verb return value as result', async () => {
|
|
197
|
+
registerShard({
|
|
198
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
199
|
+
register(ctx) {
|
|
200
|
+
ctx.registerVerb({
|
|
201
|
+
name: 'returnNumber',
|
|
202
|
+
summary: 'returns 42',
|
|
203
|
+
programmatic: true,
|
|
204
|
+
schema: {
|
|
205
|
+
input: { type: 'object' },
|
|
206
|
+
output: { type: 'integer' },
|
|
207
|
+
},
|
|
208
|
+
async run() {
|
|
209
|
+
return 42;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
await activateShard('tester');
|
|
215
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnNumber', []);
|
|
216
|
+
expect(out.result).toBe(42);
|
|
217
|
+
});
|
|
218
|
+
it('surfaces undefined as result for a verb that returns nothing', async () => {
|
|
219
|
+
registerShard({
|
|
220
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
221
|
+
register(ctx) {
|
|
222
|
+
ctx.registerVerb({
|
|
223
|
+
name: 'returnVoid',
|
|
224
|
+
summary: 'returns nothing',
|
|
225
|
+
programmatic: true,
|
|
226
|
+
async run() {
|
|
227
|
+
// no return
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
await activateShard('tester');
|
|
233
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnVoid', []);
|
|
234
|
+
expect(out.result).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
it('round-trips an sh3-document handle through result', async () => {
|
|
237
|
+
registerShard({
|
|
238
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
239
|
+
register(ctx) {
|
|
240
|
+
ctx.registerVerb({
|
|
241
|
+
name: 'returnDoc',
|
|
242
|
+
summary: 'returns a document handle',
|
|
243
|
+
programmatic: true,
|
|
244
|
+
schema: {
|
|
245
|
+
input: { type: 'object' },
|
|
246
|
+
output: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
format: 'sh3-document',
|
|
249
|
+
properties: {
|
|
250
|
+
shardId: { type: 'string' },
|
|
251
|
+
path: { type: 'string' },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
async run() {
|
|
256
|
+
return { shardId: 'notes', path: 'inbox/today.md' };
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await activateShard('tester');
|
|
262
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnDoc', []);
|
|
263
|
+
expect(out.result).toEqual({ shardId: 'notes', path: 'inbox/today.md' });
|
|
264
|
+
});
|
|
178
265
|
});
|
|
@@ -112,3 +112,11 @@ export declare function activateShard(id: string): Promise<void>;
|
|
|
112
112
|
* that explicitly want to verify cleanup paths).
|
|
113
113
|
*/
|
|
114
114
|
export declare function deactivateShard(id: string): void;
|
|
115
|
+
/**
|
|
116
|
+
* Remove a shard from the registry entirely. Deactivates it first if active,
|
|
117
|
+
* then drops it from `registeredShards` and clears any error record.
|
|
118
|
+
*
|
|
119
|
+
* Called by the package installer when a bundle update no longer ships a
|
|
120
|
+
* shard that the previous version contributed.
|
|
121
|
+
*/
|
|
122
|
+
export declare function unregisterShard(id: string): void;
|
|
@@ -607,3 +607,20 @@ export function deactivateShard(id) {
|
|
|
607
607
|
shardEntries.delete(id);
|
|
608
608
|
activeShards.delete(id);
|
|
609
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Remove a shard from the registry entirely. Deactivates it first if active,
|
|
612
|
+
* then drops it from `registeredShards` and clears any error record.
|
|
613
|
+
*
|
|
614
|
+
* Called by the package installer when a bundle update no longer ships a
|
|
615
|
+
* shard that the previous version contributed.
|
|
616
|
+
*/
|
|
617
|
+
export function unregisterShard(id) {
|
|
618
|
+
if (!registeredShards.has(id))
|
|
619
|
+
return;
|
|
620
|
+
try {
|
|
621
|
+
deactivateShard(id);
|
|
622
|
+
}
|
|
623
|
+
catch ( /* not active */_a) { /* not active */ }
|
|
624
|
+
registeredShards.delete(id);
|
|
625
|
+
erroredShards.delete(id);
|
|
626
|
+
}
|
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
import { parseScopePath, resolveScope } from './scope-parse';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Path helpers
|
|
4
|
+
//
|
|
5
|
+
// xfer follows cp-style semantics for the destination:
|
|
6
|
+
// - dst path ending with `/` (or empty) is a directory; the file is placed
|
|
7
|
+
// inside with the source's filename (or, for -R, with the source-relative
|
|
8
|
+
// path rebased under the dst directory).
|
|
9
|
+
// - dst path without trailing slash is treated literally.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function basename(p) {
|
|
12
|
+
const i = p.lastIndexOf('/');
|
|
13
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
14
|
+
}
|
|
15
|
+
/** Compose a final dst path from a (possibly-directory) dst dir plus a relative segment. */
|
|
16
|
+
function joinDst(dstDir, relative) {
|
|
17
|
+
if (!dstDir)
|
|
18
|
+
return relative;
|
|
19
|
+
if (dstDir.endsWith('/'))
|
|
20
|
+
return dstDir + relative;
|
|
21
|
+
return `${dstDir}/${relative}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Strip a folder prefix off a doc path, returning the remainder. For the
|
|
25
|
+
* single-file case (doc.path equals prefix), returns the filename. The
|
|
26
|
+
* caller's filter guarantees doc.path is either exactly `prefix` or starts
|
|
27
|
+
* with `prefix + '/'`, so this never produces a stray leading slash.
|
|
28
|
+
*/
|
|
29
|
+
function rebaseFromPrefix(prefix, docPath) {
|
|
30
|
+
if (!prefix)
|
|
31
|
+
return docPath;
|
|
32
|
+
if (docPath === prefix)
|
|
33
|
+
return basename(docPath);
|
|
34
|
+
return docPath.slice(prefix.length + 1); // skip prefix and the separating '/'
|
|
35
|
+
}
|
|
2
36
|
export const xferVerb = {
|
|
3
37
|
name: 'xfer',
|
|
4
38
|
summary: [
|
|
@@ -7,6 +41,7 @@ export const xferVerb = {
|
|
|
7
41
|
' Either side may be @me or @project-<slug>; bare paths resolve to the active scope.',
|
|
8
42
|
' -R recursive (src is a folder prefix)',
|
|
9
43
|
' -C copy only, do not delete source',
|
|
44
|
+
' Trailing `/` on dst means "into directory" (cp-style); without it dst is a literal path.',
|
|
10
45
|
].join('\n'),
|
|
11
46
|
programmatic: true,
|
|
12
47
|
async run(ctx, args) {
|
|
@@ -60,25 +95,52 @@ export const xferVerb = {
|
|
|
60
95
|
ctx.scrollback.push({ kind: 'status', text: 'xfer: path required (use -R for folder recursion)', level: 'error', ts });
|
|
61
96
|
return;
|
|
62
97
|
}
|
|
63
|
-
|
|
98
|
+
// cp-style: trailing slash (or empty path) means "into this directory" —
|
|
99
|
+
// append the source filename so the file lands inside instead of trying
|
|
100
|
+
// to overwrite the directory itself.
|
|
101
|
+
const dstIsDir = !dstParsed.path || dstParsed.path.endsWith('/');
|
|
102
|
+
const dstFinalPath = dstIsDir
|
|
103
|
+
? joinDst(dstParsed.path, basename(srcParsed.path))
|
|
104
|
+
: dstParsed.path;
|
|
105
|
+
if (srcTenant === dstTenant && srcParsed.shardId === dstParsed.shardId && srcParsed.path === dstFinalPath) {
|
|
64
106
|
ctx.scrollback.push({ kind: 'status', text: 'xfer: source and destination are the same', level: 'error', ts });
|
|
65
107
|
return;
|
|
66
108
|
}
|
|
67
|
-
await ctx.sh3.docs.transferBetweenScopes(srcTenant, srcParsed.shardId, srcParsed.path, dstTenant, dstParsed.shardId,
|
|
109
|
+
await ctx.sh3.docs.transferBetweenScopes(srcTenant, srcParsed.shardId, srcParsed.path, dstTenant, dstParsed.shardId, dstFinalPath, moveOpts);
|
|
68
110
|
const verb = copy ? 'copied' : 'moved';
|
|
69
111
|
ctx.scrollback.push({ kind: 'status', text: `xfer: ${verb} ${positional[0]} → ${positional[1]}`, level: 'info', ts });
|
|
70
112
|
return;
|
|
71
113
|
}
|
|
72
114
|
const prefix = srcParsed.path;
|
|
73
115
|
const allDocs = await ctx.sh3.docs.listDocumentsIn(srcTenant);
|
|
74
|
-
|
|
116
|
+
// Folder-boundary filter: when `prefix` is set, a doc matches only if its
|
|
117
|
+
// path equals the prefix (single-file case) or sits inside the prefix
|
|
118
|
+
// folder (`prefix + '/'` ...). Plain `startsWith(prefix)` would also
|
|
119
|
+
// match sibling files whose names happen to share the prefix string
|
|
120
|
+
// (e.g. prefix `notes` matching `notesheet.md`).
|
|
121
|
+
const matching = allDocs.filter((d) => {
|
|
122
|
+
if (d.shardId !== srcParsed.shardId)
|
|
123
|
+
return false;
|
|
124
|
+
if (!prefix)
|
|
125
|
+
return true;
|
|
126
|
+
if (d.path === prefix)
|
|
127
|
+
return true;
|
|
128
|
+
return d.path.startsWith(`${prefix}/`);
|
|
129
|
+
});
|
|
75
130
|
if (matching.length === 0) {
|
|
76
131
|
ctx.scrollback.push({ kind: 'status', text: `xfer: no documents found under ${positional[0]}`, level: 'info', ts });
|
|
77
132
|
return;
|
|
78
133
|
}
|
|
79
134
|
let count = 0;
|
|
80
135
|
for (const doc of matching) {
|
|
81
|
-
|
|
136
|
+
// Per-doc dst: rebase the doc's path relative to the src prefix, then
|
|
137
|
+
// place it under the dst directory. Without this the dst directory is
|
|
138
|
+
// ignored and the file lands at its source path in dst shard, which
|
|
139
|
+
// (a) silently misplaces the file and (b) blows up on mounts where
|
|
140
|
+
// `mounts/<unknown-segment>/...` fails to resolve to a real mount.
|
|
141
|
+
const relative = rebaseFromPrefix(prefix, doc.path);
|
|
142
|
+
const dstFinalPath = joinDst(dstParsed.path, relative);
|
|
143
|
+
await ctx.sh3.docs.transferBetweenScopes(srcTenant, doc.shardId, doc.path, dstTenant, dstParsed.shardId, dstFinalPath, moveOpts);
|
|
82
144
|
count++;
|
|
83
145
|
}
|
|
84
146
|
const verb = copy ? 'copied' : 'moved';
|
|
@@ -104,4 +104,78 @@ describe('xfer verb', () => {
|
|
|
104
104
|
expect(transferBetweenScopes).toHaveBeenCalledTimes(2);
|
|
105
105
|
expect(transferBetweenScopes).toHaveBeenCalledWith('proj-abc', 'notes', 'ideas/a.md', 'user-me', 'notes', 'ideas/a.md', expect.objectContaining({ delete: true }));
|
|
106
106
|
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Bug repro — xfer -R discards dst directory; dst with trailing slash should
|
|
109
|
+
// preserve it; substring-prefix filter false-matches.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
it('-R preserves dst directory when transferring into a different folder', async () => {
|
|
112
|
+
// User's exact scenario: a single file under svg-designer goes into a
|
|
113
|
+
// mount subdirectory. Previously dst.path was discarded and the file
|
|
114
|
+
// landed at `mounts/sh3_dirt.svg`, which the mount resolver interpreted
|
|
115
|
+
// as `mountId=sh3_dirt.svg` → 500.
|
|
116
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
117
|
+
const allDocs = [
|
|
118
|
+
{ shardId: 'svg-designer', path: 'sh3_dirt.svg', size: 1, lastModified: 0 },
|
|
119
|
+
];
|
|
120
|
+
const docs = makeDocs({
|
|
121
|
+
transferBetweenScopes,
|
|
122
|
+
listDocumentsIn: vi.fn(async () => allDocs),
|
|
123
|
+
});
|
|
124
|
+
const sh3 = makeSh3(personalScope);
|
|
125
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
126
|
+
await xferVerb.run(ctx, ['-R', '-C', '@me:svg-designer/sh3_dirt.svg', 'mounts/square-survivor/']);
|
|
127
|
+
expect(transferBetweenScopes).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'svg-designer', 'sh3_dirt.svg', 'user-me', 'mounts', 'square-survivor/sh3_dirt.svg', expect.objectContaining({ delete: false }));
|
|
129
|
+
});
|
|
130
|
+
it('-R rebases doc paths under a folder prefix into the dst directory', async () => {
|
|
131
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
132
|
+
const allDocs = [
|
|
133
|
+
{ shardId: 'notes', path: 'ideas/a.md', size: 1, lastModified: 0 },
|
|
134
|
+
{ shardId: 'notes', path: 'ideas/sub/b.md', size: 1, lastModified: 0 },
|
|
135
|
+
];
|
|
136
|
+
const docs = makeDocs({
|
|
137
|
+
transferBetweenScopes,
|
|
138
|
+
listDocumentsIn: vi.fn(async () => allDocs),
|
|
139
|
+
});
|
|
140
|
+
const sh3 = makeSh3(personalScope);
|
|
141
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
142
|
+
await xferVerb.run(ctx, ['-R', '@me:notes/ideas', '@me:notes/archived/']);
|
|
143
|
+
expect(transferBetweenScopes).toHaveBeenCalledTimes(2);
|
|
144
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'ideas/a.md', 'user-me', 'notes', 'archived/a.md', expect.anything());
|
|
145
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'ideas/sub/b.md', 'user-me', 'notes', 'archived/sub/b.md', expect.anything());
|
|
146
|
+
});
|
|
147
|
+
it('-R filter respects folder boundary — does not match substring prefixes', async () => {
|
|
148
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
149
|
+
const allDocs = [
|
|
150
|
+
{ shardId: 'notes', path: 'notes/draft.md', size: 1, lastModified: 0 },
|
|
151
|
+
{ shardId: 'notes', path: 'notesheet.md', size: 1, lastModified: 0 },
|
|
152
|
+
];
|
|
153
|
+
const docs = makeDocs({
|
|
154
|
+
transferBetweenScopes,
|
|
155
|
+
listDocumentsIn: vi.fn(async () => allDocs),
|
|
156
|
+
});
|
|
157
|
+
const sh3 = makeSh3(personalScope);
|
|
158
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
159
|
+
await xferVerb.run(ctx, ['-R', '@me:notes/notes', '@me:notes/archived/']);
|
|
160
|
+
// Only notes/draft.md matches the folder prefix `notes`. notesheet.md
|
|
161
|
+
// shares the substring but is a sibling file, not a child.
|
|
162
|
+
expect(transferBetweenScopes).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'notes/draft.md', 'user-me', 'notes', 'archived/draft.md', expect.anything());
|
|
164
|
+
});
|
|
165
|
+
it('non-recursive: trailing-slash dst appends source filename', async () => {
|
|
166
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
167
|
+
const docs = makeDocs({ transferBetweenScopes });
|
|
168
|
+
const sh3 = makeSh3(personalScope);
|
|
169
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
170
|
+
await xferVerb.run(ctx, ['@me:notes/foo.md', '@me:notes/archived/']);
|
|
171
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'foo.md', 'user-me', 'notes', 'archived/foo.md', expect.anything());
|
|
172
|
+
});
|
|
173
|
+
it('non-recursive: literal dst path is used verbatim (no trailing slash)', async () => {
|
|
174
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
175
|
+
const docs = makeDocs({ transferBetweenScopes });
|
|
176
|
+
const sh3 = makeSh3(personalScope);
|
|
177
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
178
|
+
await xferVerb.run(ctx, ['@me:notes/foo.md', '@me:notes/archived/renamed.md']);
|
|
179
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'foo.md', 'user-me', 'notes', 'archived/renamed.md', expect.anything());
|
|
180
|
+
});
|
|
107
181
|
});
|
package/dist/verbs/types.d.ts
CHANGED
|
@@ -209,14 +209,14 @@ export interface VerbContext {
|
|
|
209
209
|
signal?: AbortSignal;
|
|
210
210
|
}
|
|
211
211
|
/**
|
|
212
|
-
* Portable JSON Schema subset accepted by sh3-core for `Verb.schema.input
|
|
213
|
-
* Documented as the intersection of what
|
|
214
|
-
* tool-call APIs accept natively. sh3-core
|
|
215
|
-
* actual schema stays within this subset —
|
|
216
|
-
* `$ref`, or other Draft 2020-12 features
|
|
217
|
-
* risk. The type is intentionally
|
|
218
|
-
* and `items` so authors can express
|
|
219
|
-
* fighting the type system.
|
|
212
|
+
* Portable JSON Schema subset accepted by sh3-core for `Verb.schema.input`
|
|
213
|
+
* and `Verb.schema.output`. Documented as the intersection of what
|
|
214
|
+
* Anthropic, OpenAI, and Gemini tool-call APIs accept natively. sh3-core
|
|
215
|
+
* does NOT validate that the actual schema stays within this subset —
|
|
216
|
+
* authors who reach for `oneOf`, `$ref`, or other Draft 2020-12 features
|
|
217
|
+
* do so at their own portability risk. The type is intentionally
|
|
218
|
+
* `unknown`-permissive on `properties` and `items` so authors can express
|
|
219
|
+
* object/array shapes without fighting the type system.
|
|
220
220
|
*/
|
|
221
221
|
export interface PortableJSONSchema {
|
|
222
222
|
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
|
|
@@ -229,14 +229,39 @@ export interface PortableJSONSchema {
|
|
|
229
229
|
required?: string[];
|
|
230
230
|
/** Item schema for `type: 'array'`. */
|
|
231
231
|
items?: PortableJSONSchema;
|
|
232
|
+
/**
|
|
233
|
+
* JSON Schema `format` hint. sh3-core blesses one value:
|
|
234
|
+
* - 'sh3-document' — runtime payload is an SH3 document handle
|
|
235
|
+
* ({ shardId: string; path: string }). Combine with type: 'object'.
|
|
236
|
+
* Other `format` values pass through untouched (portability with
|
|
237
|
+
* JSON Schema validators and tool-call APIs is preserved).
|
|
238
|
+
*/
|
|
239
|
+
format?: string;
|
|
232
240
|
}
|
|
233
241
|
/**
|
|
234
|
-
* Optional schema attached to a verb.
|
|
235
|
-
*
|
|
236
|
-
*
|
|
242
|
+
* Optional schema attached to a verb. `input` is consumed by sh3-ai's
|
|
243
|
+
* tool-call dispatcher and by sh3-pipeline's verb-adapter. `output`
|
|
244
|
+
* describes the shape of the verb's return value (the `result` field
|
|
245
|
+
* of `Sh3Api.runVerb`'s Promise resolution) and is consumed by
|
|
246
|
+
* sh3-pipeline to derive output ports.
|
|
237
247
|
*/
|
|
238
248
|
export interface VerbSchema {
|
|
239
249
|
input: PortableJSONSchema;
|
|
250
|
+
/**
|
|
251
|
+
* Shape of the verb's return value. When omitted, callers treat the
|
|
252
|
+
* verb as side-effect-only and read the scrollback instead.
|
|
253
|
+
*
|
|
254
|
+
* - {type:'object', properties:{…}} → result is an object whose
|
|
255
|
+
* own properties match the schema. Each top-level property is
|
|
256
|
+
* one logical output (one port for sh3-pipeline).
|
|
257
|
+
* - any other type → result IS that value.
|
|
258
|
+
*
|
|
259
|
+
* Document outputs are declared as
|
|
260
|
+
* { type: 'object', format: 'sh3-document',
|
|
261
|
+
* properties: { shardId: {type:'string'}, path: {type:'string'} } }
|
|
262
|
+
* or inlined inside a parent object's properties.
|
|
263
|
+
*/
|
|
264
|
+
output?: PortableJSONSchema;
|
|
240
265
|
}
|
|
241
266
|
export interface Verb {
|
|
242
267
|
name: string;
|
|
@@ -271,7 +296,19 @@ export interface Verb {
|
|
|
271
296
|
* whether to read it or fall back to `args[]`.
|
|
272
297
|
*/
|
|
273
298
|
schema?: VerbSchema;
|
|
274
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Returns the verb's structured result. The return value MUST match
|
|
301
|
+
* the shape declared by `schema.output` when both are present:
|
|
302
|
+
* - schema.output {type:'object', properties:{…}} → return object with those keys
|
|
303
|
+
* - schema.output primitive → return that primitive
|
|
304
|
+
* - schema.output undefined → return value is ignored
|
|
305
|
+
*
|
|
306
|
+
* Verbs that don't declare schema.output may return undefined; the
|
|
307
|
+
* Sh3Api.runVerb resolution surfaces it as `result: undefined`.
|
|
308
|
+
* Existing verbs that returned Promise<void> remain assignable —
|
|
309
|
+
* `void` is a subtype of `unknown` for return-type widening.
|
|
310
|
+
*/
|
|
311
|
+
run(ctx: VerbContext, args: string[]): Promise<unknown>;
|
|
275
312
|
}
|
|
276
313
|
export type Resolution = {
|
|
277
314
|
kind: 'local';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
describe('VerbSchema with output', () => {
|
|
3
|
+
it('accepts output as PortableJSONSchema', () => {
|
|
4
|
+
const schema = {
|
|
5
|
+
input: { type: 'object', properties: { topic: { type: 'string' } } },
|
|
6
|
+
output: { type: 'object', properties: { answer: { type: 'string' } } },
|
|
7
|
+
};
|
|
8
|
+
expectTypeOf(schema.output).toEqualTypeOf();
|
|
9
|
+
});
|
|
10
|
+
it('accepts format on PortableJSONSchema', () => {
|
|
11
|
+
const docSchema = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
format: 'sh3-document',
|
|
14
|
+
properties: {
|
|
15
|
+
shardId: { type: 'string' },
|
|
16
|
+
path: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
expectTypeOf(docSchema.format).toEqualTypeOf();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('Verb.run return type', () => {
|
|
23
|
+
it('accepts a verb returning Promise<unknown>', () => {
|
|
24
|
+
const v = {
|
|
25
|
+
name: 'demo',
|
|
26
|
+
summary: 's',
|
|
27
|
+
async run() {
|
|
28
|
+
return { answer: 'ok' };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
expectTypeOf(v.run).returns.toEqualTypeOf();
|
|
32
|
+
});
|
|
33
|
+
it('accepts a legacy verb returning Promise<void>', () => {
|
|
34
|
+
const v = {
|
|
35
|
+
name: 'legacy',
|
|
36
|
+
summary: 's',
|
|
37
|
+
async run() {
|
|
38
|
+
// returns nothing
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
expectTypeOf(v.run).returns.toEqualTypeOf();
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.25.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.25.0';
|