sh3-core 0.19.1 → 0.19.5

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.
Files changed (84) hide show
  1. package/dist/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/api.d.ts +4 -0
  5. package/dist/api.js +3 -0
  6. package/dist/app/admin/ApiKeysView.svelte +6 -5
  7. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  8. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  9. package/dist/app/store/StoreView.svelte +6 -1
  10. package/dist/chrome/CompactChrome.svelte +34 -1
  11. package/dist/chrome/CompactChrome.svelte.test.js +11 -6
  12. package/dist/chrome/FloatsSheet.svelte +236 -0
  13. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  14. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  15. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  16. package/dist/env/client.d.ts +5 -4
  17. package/dist/env/client.js +11 -17
  18. package/dist/env/serverUrl.d.ts +2 -0
  19. package/dist/env/serverUrl.js +8 -0
  20. package/dist/gestures/index.d.ts +17 -0
  21. package/dist/gestures/index.js +27 -0
  22. package/dist/keys/client.js +6 -7
  23. package/dist/keys/revocation-bus.svelte.js +11 -1
  24. package/dist/layout/compact/CarouselTabs.svelte +150 -14
  25. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  26. package/dist/layout/compact/CompactRenderer.svelte +9 -3
  27. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  28. package/dist/layout/compact/derive.js +7 -16
  29. package/dist/layout/compact/derive.test.js +30 -9
  30. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  31. package/dist/layout/compact/rootStore.svelte.js +59 -0
  32. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  33. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  34. package/dist/layout/drag.svelte.js +16 -3
  35. package/dist/layout/floats.d.ts +27 -0
  36. package/dist/layout/floats.js +20 -0
  37. package/dist/layout/floats.test.js +34 -1
  38. package/dist/layout/inspection.d.ts +20 -9
  39. package/dist/layout/inspection.js +91 -13
  40. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  41. package/dist/layout/inspection.svelte.test.js +163 -0
  42. package/dist/layout/store.schemaVersion.test.js +2 -2
  43. package/dist/layout/types.d.ts +11 -8
  44. package/dist/layout/types.js +1 -1
  45. package/dist/layout/types.test.js +2 -2
  46. package/dist/overlays/FloatFrame.svelte +93 -22
  47. package/dist/overlays/FloatLayer.svelte +12 -1
  48. package/dist/overlays/float.d.ts +7 -0
  49. package/dist/overlays/float.js +76 -6
  50. package/dist/overlays/float.test.js +170 -0
  51. package/dist/primitives/ResizableSplitter.svelte +42 -8
  52. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  53. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  56. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  58. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  60. package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
  61. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
  62. package/dist/registry/checkFetch.d.ts +6 -0
  63. package/dist/registry/checkFetch.js +23 -0
  64. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  65. package/dist/shards/activate-runtime.test.js +99 -1
  66. package/dist/shards/activate.svelte.js +12 -3
  67. package/dist/shards/registry.d.ts +8 -1
  68. package/dist/shards/registry.js +13 -2
  69. package/dist/shards/registry.test.js +25 -4
  70. package/dist/shards/types.d.ts +14 -1
  71. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  72. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  74. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  75. package/dist/shell-shard/dispatch.js +9 -1
  76. package/dist/shell-shard/registry-resolve.test.js +50 -0
  77. package/dist/shell-shard/registry.d.ts +2 -1
  78. package/dist/shell-shard/registry.js +12 -2
  79. package/dist/shell-shard/verbs/help.js +5 -4
  80. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  81. package/dist/verbs/types.d.ts +10 -5
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +1 -1
@@ -0,0 +1,337 @@
1
+ <script lang="ts">
2
+ import {
3
+ buildTree,
4
+ formatSize,
5
+ formatDate,
6
+ iconForFile,
7
+ breadcrumbSegments,
8
+ type DocEntry,
9
+ type OpenerValue,
10
+ type SaverValue,
11
+ type FileItem,
12
+ } from './DocumentFilePicker';
13
+
14
+ let {
15
+ mode,
16
+ docs,
17
+ onCommit,
18
+ onCancel,
19
+ close,
20
+ }: {
21
+ mode: 'open' | 'save';
22
+ docs: DocEntry[];
23
+ onCommit: (value: OpenerValue | SaverValue) => void;
24
+ onCancel: () => void;
25
+ close: () => void;
26
+ } = $props();
27
+
28
+ let shardId = $state<string | null>(null);
29
+ let prefix = $state('');
30
+ let selectedFile = $state<DocEntry | null>(null);
31
+ let filename = $state('');
32
+ let activeIdx = $state(0);
33
+ let listEl = $state<HTMLElement | undefined>(undefined);
34
+
35
+ const items = $derived(buildTree(docs, shardId, prefix));
36
+ const crumbs = $derived(breadcrumbSegments(shardId, prefix));
37
+
38
+ $effect(() => {
39
+ items;
40
+ activeIdx = Math.min(activeIdx, items.length - 1);
41
+ });
42
+
43
+ function navigateShard(id: string) {
44
+ shardId = id;
45
+ prefix = '';
46
+ selectedFile = null;
47
+ filename = '';
48
+ activeIdx = 0;
49
+ }
50
+
51
+ function navigatePrefix(p: string) {
52
+ if (shardId === null) {
53
+ navigateShard(p);
54
+ } else {
55
+ prefix = p;
56
+ selectedFile = null;
57
+ filename = '';
58
+ activeIdx = 0;
59
+ }
60
+ }
61
+
62
+ function selectFile(item: FileItem) {
63
+ if (item.kind === 'folder') {
64
+ navigatePrefix(item.fullPath);
65
+ } else {
66
+ if (mode === 'open') {
67
+ selectedFile = item.doc;
68
+ }
69
+ if (mode === 'save') {
70
+ filename = item.name;
71
+ }
72
+ }
73
+ }
74
+
75
+ function commit() {
76
+ if (mode === 'open' && selectedFile) {
77
+ onCommit({ shardId: selectedFile.shardId, path: selectedFile.path });
78
+ close();
79
+ } else if (mode === 'save' && filename.trim() && shardId) {
80
+ const p = prefix ? `${shardId}/${prefix}/${filename}` : `${shardId}/${filename}`;
81
+ onCommit(p);
82
+ close();
83
+ }
84
+ }
85
+
86
+ function cancel() {
87
+ onCancel();
88
+ close();
89
+ }
90
+
91
+ function canCommit(): boolean {
92
+ if (mode === 'open') return selectedFile !== null;
93
+ return filename.trim().length > 0 && shardId !== null;
94
+ }
95
+
96
+ function onKey(e: KeyboardEvent) {
97
+ if (e.target instanceof HTMLInputElement) return;
98
+ switch (e.key) {
99
+ case 'ArrowDown':
100
+ e.preventDefault();
101
+ activeIdx = Math.min(activeIdx + 1, items.length - 1);
102
+ break;
103
+ case 'ArrowUp':
104
+ e.preventDefault();
105
+ activeIdx = Math.max(activeIdx - 1, 0);
106
+ break;
107
+ case 'Enter':
108
+ e.preventDefault();
109
+ if (activeIdx >= 0 && activeIdx < items.length) {
110
+ selectFile(items[activeIdx]);
111
+ }
112
+ break;
113
+ case 'Escape':
114
+ e.preventDefault();
115
+ cancel();
116
+ break;
117
+ case 'Backspace':
118
+ if (prefix) {
119
+ e.preventDefault();
120
+ const parts = prefix.split('/');
121
+ parts.pop();
122
+ prefix = parts.join('/');
123
+ activeIdx = 0;
124
+ } else if (shardId) {
125
+ e.preventDefault();
126
+ shardId = null;
127
+ activeIdx = 0;
128
+ }
129
+ break;
130
+ }
131
+ }
132
+
133
+ function onDblClick(item: FileItem) {
134
+ if (item.kind === 'folder') {
135
+ navigatePrefix(item.fullPath);
136
+ } else if (mode === 'open') {
137
+ onCommit({ shardId: item.doc.shardId, path: item.doc.path });
138
+ close();
139
+ }
140
+ }
141
+ </script>
142
+
143
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
144
+ <div class="sh3-doc-browser" onkeydown={onKey} tabindex="-1">
145
+ <div class="sh3-doc-browser__head">
146
+ <span class="sh3-doc-browser__title">
147
+ {mode === 'open' ? 'Open Document' : 'Save Document'}
148
+ </span>
149
+ </div>
150
+
151
+ <nav class="sh3-doc-browser__crumbs">
152
+ {#each crumbs as seg, i}
153
+ {#if i > 0}<span class="sh3-doc-browser__crumb-sep">/</span>{/if}
154
+ <button
155
+ type="button"
156
+ class="sh3-doc-browser__crumb"
157
+ class:sh3-doc-browser__crumb--last={i === crumbs.length - 1}
158
+ onclick={() => {
159
+ shardId = seg.targetShard;
160
+ prefix = seg.targetPrefix;
161
+ selectedFile = null;
162
+ filename = '';
163
+ activeIdx = 0;
164
+ }}
165
+ >{seg.label}</button>
166
+ {/each}
167
+ </nav>
168
+
169
+ <div class="sh3-doc-browser__list" bind:this={listEl}>
170
+ {#if items.length === 0}
171
+ <div class="sh3-doc-browser__empty">
172
+ {shardId === null ? 'No shards available.' : prefix ? 'Empty directory.' : 'No documents in this shard.'}
173
+ </div>
174
+ {:else}
175
+ {#each items as item, i}
176
+ {@const isActive = i === activeIdx}
177
+ {@const isSelected = item.kind === 'file' && mode === 'open' && selectedFile?.path === item.doc.path && selectedFile?.shardId === item.doc.shardId}
178
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
179
+ <div
180
+ class="sh3-doc-browser__item"
181
+ class:sh3-doc-browser__item--active={isActive}
182
+ class:sh3-doc-browser__item--selected={isSelected}
183
+ class:sh3-doc-browser__item--folder={item.kind === 'folder'}
184
+ role="option"
185
+ tabindex="-1"
186
+ aria-selected={isSelected}
187
+ onclick={() => selectFile(item)}
188
+ onkeydown={(e) => { if (e.key === 'Enter') selectFile(item); }}
189
+ ondblclick={() => onDblClick(item)}
190
+ onmouseenter={() => activeIdx = i}
191
+ >
192
+ {#if item.kind === 'folder'}
193
+ <span class="sh3-doc-browser__icon sh3-doc-browser__icon--folder" aria-hidden="true">📁</span>
194
+ <span class="sh3-doc-browser__name">{item.name}</span>
195
+ <span class="sh3-doc-browser__meta"></span>
196
+ {:else}
197
+ <span class="sh3-doc-browser__icon" aria-hidden="true">{iconForFile(item.name)}</span>
198
+ <span class="sh3-doc-browser__name">{item.name}</span>
199
+ <span class="sh3-doc-browser__meta">
200
+ {formatSize(item.doc.size)}
201
+ <span class="sh3-doc-browser__date">{formatDate(item.doc.lastModified)}</span>
202
+ </span>
203
+ {/if}
204
+ </div>
205
+ {/each}
206
+ {/if}
207
+ </div>
208
+
209
+ {#if mode === 'save' && shardId !== null}
210
+ <div class="sh3-doc-browser__save-row">
211
+ <span class="sh3-doc-browser__save-label">Filename:</span>
212
+ <input
213
+ class="sh3-doc-browser__save-input"
214
+ type="text"
215
+ placeholder="filename.ext"
216
+ bind:value={filename}
217
+ onkeydown={(e) => { if (e.key === 'Enter' && canCommit()) commit(); }}
218
+ />
219
+ </div>
220
+ {/if}
221
+
222
+ <div class="sh3-doc-browser__footer">
223
+ <button type="button" class="sh3-doc-browser__btn sh3-doc-browser__btn--cancel" onclick={cancel}>Cancel</button>
224
+ <button
225
+ type="button"
226
+ class="sh3-doc-browser__btn sh3-doc-browser__btn--primary"
227
+ disabled={!canCommit()}
228
+ onclick={commit}
229
+ >
230
+ {mode === 'open' ? 'Open' : 'Save'}
231
+ </button>
232
+ </div>
233
+ </div>
234
+
235
+ <style>
236
+ .sh3-doc-browser {
237
+ background: var(--sh3-bg-elevated);
238
+ border: 1px solid var(--sh3-border-strong);
239
+ border-radius: var(--sh3-widget-radius);
240
+ box-shadow: var(--sh3-shadow-lg);
241
+ width: 420px;
242
+ max-height: 480px;
243
+ display: flex; flex-direction: column;
244
+ overflow: hidden;
245
+ color: var(--sh3-fg);
246
+ font-size: 0.8125rem;
247
+ outline: none;
248
+ }
249
+ .sh3-doc-browser__head {
250
+ display: flex; align-items: center; justify-content: space-between;
251
+ padding: 8px 12px;
252
+ border-bottom: 1px solid var(--sh3-border);
253
+ }
254
+ .sh3-doc-browser__title { font-weight: 600; }
255
+ .sh3-doc-browser__crumbs {
256
+ display: flex; align-items: center; gap: 2px;
257
+ padding: 4px 8px;
258
+ border-bottom: 1px solid var(--sh3-border);
259
+ overflow-x: auto;
260
+ flex-shrink: 0;
261
+ }
262
+ .sh3-doc-browser__crumb {
263
+ display: inline-flex; align-items: center;
264
+ padding: 1px 4px;
265
+ border: none; background: none;
266
+ color: var(--sh3-fg-muted);
267
+ font: inherit; font-size: 0.75rem;
268
+ cursor: pointer;
269
+ border-radius: var(--sh3-radius-sm);
270
+ white-space: nowrap;
271
+ flex-shrink: 0;
272
+ }
273
+ .sh3-doc-browser__crumb:hover { background: var(--sh3-bg); color: var(--sh3-fg); }
274
+ .sh3-doc-browser__crumb--last { color: var(--sh3-fg); font-weight: 600; }
275
+ .sh3-doc-browser__crumb-sep { color: var(--sh3-fg-subtle); font-size: 0.75rem; flex-shrink: 0; }
276
+ .sh3-doc-browser__list { flex: 1; overflow-y: auto; padding: 4px 0; min-height: 0; }
277
+ .sh3-doc-browser__item { display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; }
278
+ .sh3-doc-browser__item--active { background: var(--sh3-bg); }
279
+ .sh3-doc-browser__item--selected { background: var(--sh3-accent); color: var(--sh3-fg-on-accent); }
280
+ .sh3-doc-browser__item--active.sh3-doc-browser__item--selected { background: var(--sh3-accent); }
281
+ .sh3-doc-browser__icon { flex-shrink: 0; width: 18px; text-align: center; font-size: 0.875rem; }
282
+ .sh3-doc-browser__icon--folder { font-size: 0.75rem; }
283
+ .sh3-doc-browser__name {
284
+ flex: 1; min-width: 0;
285
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
286
+ font-family: var(--sh3-font-mono); font-size: 0.75rem;
287
+ }
288
+ .sh3-doc-browser__item--folder .sh3-doc-browser__name { font-family: inherit; font-size: 0.8125rem; }
289
+ .sh3-doc-browser__meta {
290
+ display: flex; align-items: center; gap: 10px;
291
+ flex-shrink: 0; font-size: 0.6875rem; color: var(--sh3-fg-muted);
292
+ }
293
+ .sh3-doc-browser__item--selected .sh3-doc-browser__meta { color: var(--sh3-fg-on-accent); opacity: 0.8; }
294
+ .sh3-doc-browser__date { color: var(--sh3-fg-subtle); }
295
+ .sh3-doc-browser__item--selected .sh3-doc-browser__date { color: var(--sh3-fg-on-accent); opacity: 0.65; }
296
+ .sh3-doc-browser__empty { padding: 20px 12px; text-align: center; color: var(--sh3-fg-muted); font-style: italic; }
297
+ .sh3-doc-browser__save-row {
298
+ display: flex; align-items: center; gap: 8px;
299
+ padding: 6px 12px; border-top: 1px solid var(--sh3-border);
300
+ }
301
+ .sh3-doc-browser__save-label { font-size: 0.75rem; color: var(--sh3-fg-muted); flex-shrink: 0; }
302
+ .sh3-doc-browser__save-input {
303
+ flex: 1;
304
+ height: var(--sh3-field-height-sm);
305
+ padding: 0 8px;
306
+ background: var(--sh3-input-bg);
307
+ border: 1px solid var(--sh3-border);
308
+ border-radius: var(--sh3-radius-sm);
309
+ color: var(--sh3-fg);
310
+ font: inherit; font-family: var(--sh3-font-mono); font-size: 0.75rem;
311
+ outline: none;
312
+ }
313
+ .sh3-doc-browser__save-input:focus { border-color: var(--sh3-input-border-focus); box-shadow: var(--sh3-focus-ring); }
314
+ .sh3-doc-browser__footer {
315
+ display: flex; justify-content: flex-end; gap: 8px;
316
+ padding: 8px 12px; border-top: 1px solid var(--sh3-border);
317
+ }
318
+ .sh3-doc-browser__btn {
319
+ display: inline-flex; align-items: center;
320
+ height: 26px; padding: 0 12px;
321
+ border: 1px solid var(--sh3-border);
322
+ border-radius: var(--sh3-radius-sm);
323
+ background: var(--sh3-bg-elevated);
324
+ color: var(--sh3-fg);
325
+ font: inherit; font-size: 0.75rem;
326
+ cursor: pointer;
327
+ }
328
+ .sh3-doc-browser__btn:hover { background: var(--sh3-bg); }
329
+ .sh3-doc-browser__btn--primary {
330
+ background: var(--sh3-accent);
331
+ color: var(--sh3-fg-on-accent);
332
+ border-color: var(--sh3-accent);
333
+ }
334
+ .sh3-doc-browser__btn--primary:hover { filter: brightness(1.1); }
335
+ .sh3-doc-browser__btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
336
+ .sh3-doc-browser__btn--primary:disabled:hover { filter: none; }
337
+ </style>
@@ -0,0 +1,11 @@
1
+ import { type DocEntry, type OpenerValue, type SaverValue } from './DocumentFilePicker';
2
+ type $$ComponentProps = {
3
+ mode: 'open' | 'save';
4
+ docs: DocEntry[];
5
+ onCommit: (value: OpenerValue | SaverValue) => void;
6
+ onCancel: () => void;
7
+ close: () => void;
8
+ };
9
+ declare const DocumentBrowser: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type DocumentBrowser = ReturnType<typeof DocumentBrowser>;
11
+ export default DocumentBrowser;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Client-side mirror of sh3-validate's checkFetch heuristic.
3
+ * Takes a decoded bundle string, returns advisory warning messages.
4
+ * Returns at most one message — enough for the install modal UX.
5
+ */
6
+ export declare function checkBundleFetch(bundleText: string): string[];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Client-side mirror of sh3-validate's checkFetch heuristic.
3
+ * Takes a decoded bundle string, returns advisory warning messages.
4
+ * Returns at most one message — enough for the install modal UX.
5
+ */
6
+ export function checkBundleFetch(bundleText) {
7
+ const apiPositions = [];
8
+ const apiPattern = /\/api\//g;
9
+ let m;
10
+ while ((m = apiPattern.exec(bundleText)) !== null) {
11
+ apiPositions.push(m.index);
12
+ }
13
+ if (apiPositions.length === 0)
14
+ return [];
15
+ const fetchPattern = /(?<!\.)fetch\s*\(/g;
16
+ while ((m = fetchPattern.exec(bundleText)) !== null) {
17
+ const pos = m.index;
18
+ if (apiPositions.some((ap) => Math.abs(ap - pos) <= 200)) {
19
+ return ['raw fetch() call with /api/ path — use ctx.fetch() for cross-origin compatibility'];
20
+ }
21
+ }
22
+ return [];
23
+ }
@@ -9,6 +9,8 @@
9
9
 
10
10
  import type { ApiKeyPublic } from '../../keys/types';
11
11
  import { subscribe as subscribeBus } from '../../keys/revocation-bus.svelte';
12
+ import { apiFetch } from '../../transport/apiFetch';
13
+ import { getEnvServerUrl } from '../../env/serverUrl';
12
14
 
13
15
  let rows = $state<ApiKeyPublic[]>([]);
14
16
  let loadError = $state<string | null>(null);
@@ -19,7 +21,7 @@
19
21
  loadError = null;
20
22
  loading = true;
21
23
  try {
22
- const res = await fetch('/api/keys', { credentials: 'include' });
24
+ const res = await apiFetch(`${getEnvServerUrl()}/api/keys`);
23
25
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
24
26
  rows = await res.json();
25
27
  } catch (err) {
@@ -31,9 +33,8 @@
31
33
 
32
34
  async function revoke(id: string): Promise<void> {
33
35
  confirmingId = null;
34
- const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
36
+ const res = await apiFetch(`${getEnvServerUrl()}/api/keys/${encodeURIComponent(id)}`, {
35
37
  method: 'DELETE',
36
- credentials: 'include',
37
38
  });
38
39
  if (res.ok || res.status === 404) {
39
40
  rows = rows.filter((r) => r.id !== id);
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { MemoryDocumentBackend } from '../documents/backends';
3
3
  import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
4
  import { registerShard, activateShard, __resetShardRegistryForTest, } from './activate.svelte';
@@ -198,4 +198,102 @@ describe('ctx.listVerbs / ctx.runVerb (integration)', () => {
198
198
  },
199
199
  });
200
200
  });
201
+ it('verbNamespace overrides the shard-id prefix', async () => {
202
+ registerShard({
203
+ manifest: {
204
+ id: 'sh3-dirt-viewer',
205
+ label: 'Dirt',
206
+ version: '0.0.0',
207
+ views: [],
208
+ verbNamespace: 'dirt',
209
+ },
210
+ activate(ctx) {
211
+ ctx.registerVerb(plainVerb('publish', 'publish runtime'));
212
+ },
213
+ });
214
+ let consumerCtx = null;
215
+ registerShard({
216
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
217
+ activate(ctx) { consumerCtx = ctx; },
218
+ });
219
+ await activateShard('sh3-dirt-viewer');
220
+ await activateShard('consumer');
221
+ const names = consumerCtx.sh3.listVerbs().map((v) => v.name);
222
+ expect(names).toContain('dirt:publish');
223
+ expect(names.find((n) => n.startsWith('sh3-dirt-viewer:'))).toBeUndefined();
224
+ });
225
+ it('verbNamespace collision across shards: first wins, second warns', async () => {
226
+ var _a;
227
+ registerShard({
228
+ manifest: {
229
+ id: 'shard-a',
230
+ label: 'A',
231
+ version: '0.0.0',
232
+ views: [],
233
+ verbNamespace: 'shared',
234
+ },
235
+ activate(ctx) {
236
+ ctx.registerVerb(plainVerb('go', 'A go'));
237
+ },
238
+ });
239
+ registerShard({
240
+ manifest: {
241
+ id: 'shard-b',
242
+ label: 'B',
243
+ version: '0.0.0',
244
+ views: [],
245
+ verbNamespace: 'shared',
246
+ },
247
+ activate(ctx) {
248
+ // Collides with shard-a's 'shared:go'; should be skipped.
249
+ ctx.registerVerb(plainVerb('go', 'B go'));
250
+ // Distinct name in same namespace should still register.
251
+ ctx.registerVerb(plainVerb('only-b', 'B-only verb'));
252
+ },
253
+ });
254
+ let consumerCtx = null;
255
+ registerShard({
256
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
257
+ activate(ctx) { consumerCtx = ctx; },
258
+ });
259
+ await activateShard('shard-a');
260
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
261
+ await activateShard('shard-b');
262
+ await activateShard('consumer');
263
+ expect(warn).toHaveBeenCalled();
264
+ expect(warn.mock.calls.some((c) => /shared:go/.test(String(c[0])))).toBe(true);
265
+ warn.mockRestore();
266
+ const list = consumerCtx.sh3.listVerbs();
267
+ const shared = list.find((v) => v.name === 'shared:go');
268
+ // The losing registration must not have been tracked; deactivating
269
+ // shard-b later (covered elsewhere) would otherwise remove A's verb.
270
+ expect(shared === null || shared === void 0 ? void 0 : shared.summary).toBe('A go');
271
+ expect(shared === null || shared === void 0 ? void 0 : shared.shardId).toBe('shard-a');
272
+ expect((_a = list.find((v) => v.name === 'shared:only-b')) === null || _a === void 0 ? void 0 : _a.shardId).toBe('shard-b');
273
+ });
274
+ it('shell shard registers bare names regardless of verbNamespace setting', async () => {
275
+ registerShard({
276
+ manifest: {
277
+ id: 'shell',
278
+ label: 'Shell',
279
+ version: '0.0.0',
280
+ views: [],
281
+ // Intentionally set — should be ignored for the shell shard.
282
+ verbNamespace: 'ignored',
283
+ },
284
+ activate(ctx) {
285
+ ctx.registerVerb(plainVerb('clear', 'clear scrollback'));
286
+ },
287
+ });
288
+ let consumerCtx = null;
289
+ registerShard({
290
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
291
+ activate(ctx) { consumerCtx = ctx; },
292
+ });
293
+ await activateShard('shell');
294
+ await activateShard('consumer');
295
+ const names = consumerCtx.sh3.listVerbs().map((v) => v.name);
296
+ expect(names).toContain('clear');
297
+ expect(names.find((n) => n.startsWith('ignored:'))).toBeUndefined();
298
+ });
201
299
  });
@@ -150,9 +150,18 @@ export async function activateShard(id, opts) {
150
150
  entry.viewIds.add(viewId);
151
151
  },
152
152
  registerVerb: (verb) => {
153
- const prefixed = id === 'shell' ? verb.name : `${id}:${verb.name}`;
154
- fwRegisterVerb(prefixed, Object.assign(Object.assign({}, verb), { name: prefixed }), id);
155
- entry.verbNames.add(prefixed);
153
+ var _a;
154
+ let prefixed;
155
+ if (id === 'shell') {
156
+ prefixed = verb.name;
157
+ }
158
+ else {
159
+ const ns = (_a = shard.manifest.verbNamespace) !== null && _a !== void 0 ? _a : id;
160
+ prefixed = `${ns}:${verb.name}`;
161
+ }
162
+ if (fwRegisterVerb(prefixed, Object.assign(Object.assign({}, verb), { name: prefixed }), id)) {
163
+ entry.verbNames.add(prefixed);
164
+ }
156
165
  },
157
166
  documents: (options) => {
158
167
  const handle = createDocumentHandle(getTenantId(), id, getDocumentBackend(), options);
@@ -6,7 +6,14 @@ export declare function getView(viewId: string): ViewFactory | undefined;
6
6
  export declare function getShardForView(viewId: string): string | undefined;
7
7
  export declare function unregisterView(viewId: string): void;
8
8
  import type { Verb } from '../verbs/types';
9
- export declare function registerVerb(name: string, verb: Verb, shardId: string): void;
9
+ /**
10
+ * Register a verb under its fully-qualified name. Collisions log a
11
+ * `console.warn` and skip the second registration — first-wins. Returns
12
+ * `true` when the registration took effect, `false` when it was a
13
+ * duplicate (so callers like activate() can avoid tracking a name they
14
+ * don't own and would otherwise unregister out from under the original).
15
+ */
16
+ export declare function registerVerb(name: string, verb: Verb, shardId: string): boolean;
10
17
  export declare function getVerb(name: string): Verb | undefined;
11
18
  export declare function unregisterVerb(name: string): void;
12
19
  export declare function listVerbs(): Verb[];
@@ -46,11 +46,22 @@ export function unregisterView(viewId) {
46
46
  viewToShard.delete(viewId);
47
47
  }
48
48
  const verbs = new Map();
49
+ /**
50
+ * Register a verb under its fully-qualified name. Collisions log a
51
+ * `console.warn` and skip the second registration — first-wins. Returns
52
+ * `true` when the registration took effect, `false` when it was a
53
+ * duplicate (so callers like activate() can avoid tracking a name they
54
+ * don't own and would otherwise unregister out from under the original).
55
+ */
49
56
  export function registerVerb(name, verb, shardId) {
50
- if (verbs.has(name)) {
51
- throw new Error(`Verb "${name}" is already registered`);
57
+ const existing = verbs.get(name);
58
+ if (existing) {
59
+ console.warn(`[sh3] verb "${name}" already registered by shard "${existing.shardId}"; ` +
60
+ `skipping registration from "${shardId}"`);
61
+ return false;
52
62
  }
53
63
  verbs.set(name, { verb, shardId });
64
+ return true;
54
65
  }
55
66
  export function getVerb(name) {
56
67
  var _a;
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { registerVerb, getVerb, unregisterVerb, listVerbs, listVerbsWithShard, registerView, unregisterView, getShardForView, __resetViewRegistryForTest, } from './registry';
3
3
  function makeStubVerb(name) {
4
4
  return { name, summary: `stub ${name}`, run: async () => { } };
@@ -22,9 +22,30 @@ describe('verb registry', () => {
22
22
  it('returns undefined for unknown verb', () => {
23
23
  expect(getVerb('nope')).toBeUndefined();
24
24
  });
25
- it('throws on duplicate verb name', () => {
26
- trackVerb('dup', makeStubVerb('dup'), 'shell');
27
- expect(() => trackVerb('dup', makeStubVerb('dup'), 'shell')).toThrowError('Verb "dup" is already registered');
25
+ it('first-wins on duplicate verb name; logs a warn and keeps the original', () => {
26
+ var _a;
27
+ const first = makeStubVerb('dup');
28
+ const second = makeStubVerb('dup');
29
+ second.summary = 'second registration';
30
+ trackVerb('dup', first, 'shardA');
31
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
32
+ const ok = registerVerb('dup', second, 'shardB');
33
+ expect(ok).toBe(false);
34
+ expect(warn).toHaveBeenCalledOnce();
35
+ expect(warn.mock.calls[0][0]).toMatch(/already registered by shard "shardA"/);
36
+ expect(warn.mock.calls[0][0]).toMatch(/skipping registration from "shardB"/);
37
+ // Original survives — its identity, its shardId.
38
+ expect(getVerb('dup')).toBe(first);
39
+ expect((_a = listVerbsWithShard().find((e) => e.verb.name === 'dup')) === null || _a === void 0 ? void 0 : _a.shardId).toBe('shardA');
40
+ warn.mockRestore();
41
+ });
42
+ it('registerVerb returns true on success', () => {
43
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
44
+ const ok = registerVerb('fresh', makeStubVerb('fresh'), 'shell');
45
+ expect(ok).toBe(true);
46
+ expect(warn).not.toHaveBeenCalled();
47
+ warn.mockRestore();
48
+ registered.push('fresh');
28
49
  });
29
50
  it('unregisters a verb', () => {
30
51
  trackVerb('gone', makeStubVerb('gone'), 'shell');
@@ -158,6 +158,16 @@ export interface ShardManifest {
158
158
  * registry visibility (observer-class shards, e.g. file-explorer).
159
159
  */
160
160
  permissions?: string[];
161
+ /**
162
+ * Namespace used as the prefix for verbs this shard registers in the
163
+ * terminal. Defaults to `id`. Two shards may attempt to claim the same
164
+ * namespace; collisions on `${verbNamespace}:${verbName}` are resolved
165
+ * first-wins with a `console.warn`. The shell shard's namespace is
166
+ * implicit (empty prefix) — `verbNamespace` is ignored for `id === 'shell'`.
167
+ * Reserved values (`'sh3'`, `'core'`, `'shell'`, empty string) are flagged
168
+ * by sh3-validate at build time.
169
+ */
170
+ verbNamespace?: string;
161
171
  }
162
172
  /**
163
173
  * Source-declared shape of a shard manifest — what external package authors
@@ -197,7 +207,10 @@ export interface ShardContext {
197
207
  registerView(viewId: string, factory: ViewFactory): void;
198
208
  /**
199
209
  * Register a verb that users can invoke from the sh3 terminal.
200
- * The verb name is auto-prefixed with `shardId:` for non-sh3 shards.
210
+ * The verb name is auto-prefixed with `${ns}:` where `ns` is the
211
+ * shard's `manifest.verbNamespace ?? manifest.id` (the shell shard
212
+ * keeps bare names). Collisions on the resulting full name log a
213
+ * `console.warn` and skip the second registration — first-wins.
201
214
  * Automatically unregistered when the shard deactivates.
202
215
  *
203
216
  * @param verb - The verb definition (name, summary, run function).