sh3-core 0.25.0 → 0.25.1

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.
@@ -10,6 +10,13 @@ export interface ArtifactManifest {
10
10
  id: string;
11
11
  /** Whether this is a shard or app (or both via combo bundle). */
12
12
  type: 'shard' | 'app' | 'combo';
13
+ /**
14
+ * Shard kind. Carried over from the source shard manifest so the server's
15
+ * project-allowlist middleware can auto-allow system-kind shards and
16
+ * resolve service-kind shards from `project.shardAllowlist`. Only set
17
+ * for `type: 'shard'`; apps and combos omit it.
18
+ */
19
+ kind?: 'system' | 'service';
13
20
  /** Human-readable display name. */
14
21
  label: string;
15
22
  /** Version string (semver). */
package/dist/build.d.ts CHANGED
@@ -82,6 +82,14 @@ export declare function composeArtifactVersion(pkgVersion: string, suffix: strin
82
82
  * Exported for testing; used internally by sh3Artifact.
83
83
  */
84
84
  export declare function extractRequiredShardsFromBundle(bundleSource: string): string[];
85
+ /**
86
+ * Read a shard `kind` field (`system` or `service`) from a single manifest
87
+ * block. Returns undefined when absent or set to any other value. Exported
88
+ * for testing; used by `sh3Artifact` to propagate the shard's runtime kind
89
+ * into the emitted `manifest.json` so server-side allowlist resolution can
90
+ * see it.
91
+ */
92
+ export declare function extractShardKindFromBlock(block: string): 'system' | 'service' | undefined;
85
93
  /**
86
94
  * Collect all shard ids present in a bundle by scanning every `views: [` block.
87
95
  * Exported for testing.
package/dist/build.js CHANGED
@@ -159,6 +159,17 @@ export function extractRequiredShardsFromBundle(bundleSource) {
159
159
  ids.push(m[1]);
160
160
  return ids;
161
161
  }
162
+ /**
163
+ * Read a shard `kind` field (`system` or `service`) from a single manifest
164
+ * block. Returns undefined when absent or set to any other value. Exported
165
+ * for testing; used by `sh3Artifact` to propagate the shard's runtime kind
166
+ * into the emitted `manifest.json` so server-side allowlist resolution can
167
+ * see it.
168
+ */
169
+ export function extractShardKindFromBlock(block) {
170
+ const m = block.match(/\bkind\s*:\s*["'](system|service)["']/);
171
+ return m ? m[1] : undefined;
172
+ }
162
173
  /**
163
174
  * Collect all shard ids present in a bundle by scanning every `views: [` block.
164
175
  * Exported for testing.
@@ -301,15 +312,14 @@ export function sh3Artifact(options = {}) {
301
312
  };
302
313
  // Extract the requiredShards string-id array from the app manifest block.
303
314
  const requiredShards = extractRequiredShardsFromBundle(block);
304
- return {
305
- id: get(/\bid\s*:\s*["']([^"']+)["']/),
306
- label: get(/\blabel\s*:\s*["']([^"']+)["']/),
307
- requiredShards,
308
- };
315
+ // Extract shard `kind` (system/service). Only meaningful when the
316
+ // anchored block is a shard block; app blocks won't carry it.
317
+ const kind = extractShardKindFromBlock(block);
318
+ return Object.assign(Object.assign({ id: get(/\bid\s*:\s*["']([^"']+)["']/), label: get(/\blabel\s*:\s*["']([^"']+)["']/) }, (kind ? { kind } : {})), { requiredShards });
309
319
  }
310
320
  // App first, then Shard.
311
321
  const extracted = (_a = extractFromBlock(/\brequiredShards\s*:\s*\[/)) !== null && _a !== void 0 ? _a : extractFromBlock(/\bviews\s*:\s*\[/);
312
- const { id, label } = extracted;
322
+ const { id, label, kind } = extracted;
313
323
  let { requiredShards } = extracted;
314
324
  // Strip any shard ids that are bundled in this artifact from requiredShards.
315
325
  const bundledShardIds = extractBundledShardIds(source);
@@ -362,7 +372,7 @@ export function sh3Artifact(options = {}) {
362
372
  if (!finalAuthor) {
363
373
  throw new Error('[sh3-artifact] Missing "author". Add it to package.json or pass it via sh3Artifact({ manifest: { author } }).');
364
374
  }
365
- const manifest = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), (type === 'combo' && bundledShardIds.size > 0 ? { bundledShards: [...bundledShardIds] } : {})), overrides);
375
+ const manifest = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (type === 'shard' && kind ? { kind } : {})), (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), (type === 'combo' && bundledShardIds.size > 0 ? { bundledShards: [...bundledShardIds] } : {})), overrides);
366
376
  // Read the emitted JS files as bytes for the archive
367
377
  const clientBytes = readFileSync(join(outDir, 'client.js'));
368
378
  const serverBytes = hasServer ? readFileSync(join(outDir, 'server.js')) : undefined;
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { composeArtifactVersion, extractRequiredShardsFromBundle, extractBundledShardIds } from './build';
2
+ import { composeArtifactVersion, extractRequiredShardsFromBundle, extractBundledShardIds, extractShardKindFromBlock } from './build';
3
3
  describe('composeArtifactVersion', () => {
4
4
  it('returns pkgVersion unchanged when suffix is undefined', () => {
5
5
  expect(composeArtifactVersion('1.2.3', undefined)).toBe('1.2.3');
@@ -84,3 +84,29 @@ describe('extractBundledShardIds', () => {
84
84
  expect(filtered).toEqual(['external-dep']);
85
85
  });
86
86
  });
87
+ describe('extractShardKindFromBlock', () => {
88
+ it('extracts kind: "system"', () => {
89
+ const block = `{ id: "ai", label: "AI", kind: "system", views: [] }`;
90
+ expect(extractShardKindFromBlock(block)).toBe('system');
91
+ });
92
+ it('extracts kind: "service"', () => {
93
+ const block = `{ id: "registry", kind: "service", views: [] }`;
94
+ expect(extractShardKindFromBlock(block)).toBe('service');
95
+ });
96
+ it('handles single-quoted kind', () => {
97
+ const block = `{ id: 'ai', kind: 'system', views: [] }`;
98
+ expect(extractShardKindFromBlock(block)).toBe('system');
99
+ });
100
+ it('handles minified format without spaces', () => {
101
+ const block = `{id:"ai",kind:"system",views:[]}`;
102
+ expect(extractShardKindFromBlock(block)).toBe('system');
103
+ });
104
+ it('returns undefined when kind is absent', () => {
105
+ const block = `{ id: "plain-shard", label: "Plain", views: [] }`;
106
+ expect(extractShardKindFromBlock(block)).toBeUndefined();
107
+ });
108
+ it('returns undefined for unrecognized kind values', () => {
109
+ const block = `{ id: "weird", kind: "widget", views: [] }`;
110
+ expect(extractShardKindFromBlock(block)).toBeUndefined();
111
+ });
112
+ });
@@ -0,0 +1,7 @@
1
+ export interface TauriEnvProbe {
2
+ hasTauriInternals: boolean;
3
+ protocol: string;
4
+ hostname: string;
5
+ }
6
+ export declare function checkLocalTauriDesktop(env: TauriEnvProbe): boolean;
7
+ export declare function isLocalTauriDesktop(): boolean;
@@ -0,0 +1,24 @@
1
+ /*
2
+ * isLocalTauriDesktop — true iff we're in a Tauri webview AND the page
3
+ * was served by the local sh3-server sidecar (not the bundled frontend
4
+ * in remote-mode, and not Android's http://tauri.localhost shim).
5
+ *
6
+ * Synchronous: safe to call from action `disabled` predicates that run
7
+ * on every palette render. Logic is exposed via the pure
8
+ * `checkLocalTauriDesktop` so it can be unit-tested in the node project
9
+ * without a window/location shim.
10
+ */
11
+ export function checkLocalTauriDesktop(env) {
12
+ return env.hasTauriInternals
13
+ && env.protocol === 'http:'
14
+ && env.hostname === 'localhost';
15
+ }
16
+ export function isLocalTauriDesktop() {
17
+ if (typeof window === 'undefined' || typeof location === 'undefined')
18
+ return false;
19
+ return checkLocalTauriDesktop({
20
+ hasTauriInternals: '__TAURI_INTERNALS__' in window,
21
+ protocol: location.protocol,
22
+ hostname: location.hostname,
23
+ });
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { checkLocalTauriDesktop } from './localSidecar';
3
+ describe('checkLocalTauriDesktop', () => {
4
+ it('returns true for Tauri desktop sidecar (http://localhost)', () => {
5
+ expect(checkLocalTauriDesktop({
6
+ hasTauriInternals: true,
7
+ protocol: 'http:',
8
+ hostname: 'localhost',
9
+ })).toBe(true);
10
+ });
11
+ it('returns false in pure web (no Tauri)', () => {
12
+ expect(checkLocalTauriDesktop({
13
+ hasTauriInternals: false,
14
+ protocol: 'https:',
15
+ hostname: 'example.com',
16
+ })).toBe(false);
17
+ });
18
+ it('returns false in Tauri remote-mode desktop (tauri://localhost)', () => {
19
+ expect(checkLocalTauriDesktop({
20
+ hasTauriInternals: true,
21
+ protocol: 'tauri:',
22
+ hostname: 'localhost',
23
+ })).toBe(false);
24
+ });
25
+ it('returns false on Tauri Android (http://tauri.localhost)', () => {
26
+ expect(checkLocalTauriDesktop({
27
+ hasTauriInternals: true,
28
+ protocol: 'http:',
29
+ hostname: 'tauri.localhost',
30
+ })).toBe(false);
31
+ });
32
+ it('returns false for https://localhost (not the sidecar)', () => {
33
+ expect(checkLocalTauriDesktop({
34
+ hasTauriInternals: true,
35
+ protocol: 'https:',
36
+ hostname: 'localhost',
37
+ })).toBe(false);
38
+ });
39
+ });
@@ -0,0 +1,15 @@
1
+ import type { ShardContext } from '../shards/types';
2
+ /** Build the docs path for a user-or-project tenant id. Pure. */
3
+ export declare function buildDocumentsPath(root: string, tenantId: string, join: (...parts: string[]) => string): string;
4
+ export declare function disabledForApp(g: {
5
+ isLocal: boolean;
6
+ }): boolean;
7
+ export declare function disabledForUser(g: {
8
+ isLocal: boolean;
9
+ userId: string | null;
10
+ }): boolean;
11
+ export declare function disabledForProject(g: {
12
+ isLocal: boolean;
13
+ projectId: string | null;
14
+ }): boolean;
15
+ export declare function registerFolderActions(ctx: ShardContext): () => void;
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Palette-only "Open X folder" actions for the Tauri desktop sidecar.
3
+ * All three actions are registered unconditionally; visibility is gated
4
+ * by per-frame `disabled` predicates so changes in project scope or auth
5
+ * state take effect without re-registration.
6
+ *
7
+ * Targets:
8
+ * sh3.openAppDataFolder → appDataDir()
9
+ * sh3.openUserDocumentsFolder → appDataDir()/server/docs/<userId>
10
+ * sh3.openProjectDocumentsFolder → appDataDir()/server/docs/<activeProjectId>
11
+ *
12
+ * The `server/docs/<tenant>` layout matches sh3-server's doc-store on
13
+ * disk (see packages/sh3-server/src/doc-store/store.ts). The Tauri
14
+ * sidecar passes `appDataDir()/server` as sh3-server's --data arg.
15
+ *
16
+ * The runner uses dynamic `import()` for `@tauri-apps/api/path` and
17
+ * `@tauri-apps/plugin-opener` so Vite code-splits the Tauri deps into
18
+ * a separate chunk that only loads when the action runs. Mirrors the
19
+ * pattern used by `platform/index.ts` and `sh3Api/window.ts`.
20
+ */
21
+ import { isLocalTauriDesktop } from '../platform/localSidecar';
22
+ import { getUser } from '../auth/auth.svelte';
23
+ import { sessionState } from '../projects/session-state.svelte';
24
+ import { toastManager } from '../overlays/toast';
25
+ /** Build the docs path for a user-or-project tenant id. Pure. */
26
+ export function buildDocumentsPath(root, tenantId, join) {
27
+ return join(root, 'server', 'docs', tenantId);
28
+ }
29
+ export function disabledForApp(g) {
30
+ return !g.isLocal;
31
+ }
32
+ export function disabledForUser(g) {
33
+ return !g.isLocal || g.userId == null;
34
+ }
35
+ export function disabledForProject(g) {
36
+ return !g.isLocal || g.projectId == null;
37
+ }
38
+ async function openFolder(kind) {
39
+ try {
40
+ const [{ appDataDir, join }, { openPath }] = await Promise.all([
41
+ import('@tauri-apps/api/path'),
42
+ import('@tauri-apps/plugin-opener'),
43
+ ]);
44
+ const root = await appDataDir();
45
+ let target = root;
46
+ if (kind === 'user') {
47
+ const u = getUser();
48
+ if (!u)
49
+ return;
50
+ target = await join(root, 'server', 'docs', u.id);
51
+ }
52
+ else if (kind === 'project') {
53
+ const id = sessionState.activeProjectId;
54
+ if (!id)
55
+ return;
56
+ target = await join(root, 'server', 'docs', id);
57
+ }
58
+ await openPath(target);
59
+ }
60
+ catch (e) {
61
+ const msg = e instanceof Error ? e.message : String(e);
62
+ toastManager.notify(`Couldn't open folder: ${msg}`, { level: 'error', duration: 4000 });
63
+ }
64
+ }
65
+ export function registerFolderActions(ctx) {
66
+ const actions = [
67
+ {
68
+ id: 'sh3.openAppDataFolder',
69
+ label: 'Open SH3 data folder',
70
+ scope: ['home', 'app'],
71
+ contextItem: false,
72
+ paletteItem: true,
73
+ group: 'folders',
74
+ disabled: () => disabledForApp({ isLocal: isLocalTauriDesktop() }),
75
+ run: (_ctx) => openFolder('app'),
76
+ },
77
+ {
78
+ id: 'sh3.openUserDocumentsFolder',
79
+ label: 'Open user documents folder',
80
+ scope: ['home', 'app'],
81
+ contextItem: false,
82
+ paletteItem: true,
83
+ group: 'folders',
84
+ disabled: () => {
85
+ var _a, _b;
86
+ return disabledForUser({
87
+ isLocal: isLocalTauriDesktop(),
88
+ userId: (_b = (_a = getUser()) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null,
89
+ });
90
+ },
91
+ run: (_ctx) => openFolder('user'),
92
+ },
93
+ {
94
+ id: 'sh3.openProjectDocumentsFolder',
95
+ label: 'Open project documents folder',
96
+ scope: ['home', 'app'],
97
+ contextItem: false,
98
+ paletteItem: true,
99
+ group: 'folders',
100
+ disabled: () => disabledForProject({
101
+ isLocal: isLocalTauriDesktop(),
102
+ projectId: sessionState.activeProjectId,
103
+ }),
104
+ run: (_ctx) => openFolder('project'),
105
+ },
106
+ ];
107
+ const disposers = actions.map((a) => ctx.actions.register(a));
108
+ return () => disposers.forEach((d) => d());
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { posix } from 'node:path';
3
+ import { buildDocumentsPath, disabledForApp, disabledForUser, disabledForProject, } from './folderActions';
4
+ const join = (...p) => posix.join(...p);
5
+ describe('buildDocumentsPath', () => {
6
+ it('joins server/docs/<tenantId> under root', () => {
7
+ expect(buildDocumentsPath('/data', 'alice', join)).toBe('/data/server/docs/alice');
8
+ });
9
+ it('uses the supplied join, not posix hard-coding', () => {
10
+ const out = buildDocumentsPath('C:\\data', 'bob', (...p) => p.join('\\'));
11
+ expect(out).toBe('C:\\data\\server\\docs\\bob');
12
+ });
13
+ });
14
+ describe('disabledForApp', () => {
15
+ it('disabled when not local Tauri desktop', () => {
16
+ expect(disabledForApp({ isLocal: false })).toBe(true);
17
+ });
18
+ it('enabled when local Tauri desktop', () => {
19
+ expect(disabledForApp({ isLocal: true })).toBe(false);
20
+ });
21
+ });
22
+ describe('disabledForUser', () => {
23
+ it('disabled when not local Tauri desktop', () => {
24
+ expect(disabledForUser({ isLocal: false, userId: 'alice' })).toBe(true);
25
+ });
26
+ it('disabled when no signed-in user', () => {
27
+ expect(disabledForUser({ isLocal: true, userId: null })).toBe(true);
28
+ });
29
+ it('enabled when local + user present', () => {
30
+ expect(disabledForUser({ isLocal: true, userId: 'alice' })).toBe(false);
31
+ });
32
+ });
33
+ describe('disabledForProject', () => {
34
+ it('disabled when not local Tauri desktop', () => {
35
+ expect(disabledForProject({ isLocal: false, projectId: 'acme' })).toBe(true);
36
+ });
37
+ it('disabled when no active project', () => {
38
+ expect(disabledForProject({ isLocal: true, projectId: null })).toBe(true);
39
+ });
40
+ it('enabled when local + project active', () => {
41
+ expect(disabledForProject({ isLocal: true, projectId: 'acme' })).toBe(false);
42
+ });
43
+ });
@@ -33,6 +33,7 @@ import { resetActivePresetToDefault } from '../layout/store.svelte';
33
33
  import { modalManager } from '../overlays/modal';
34
34
  import { floatManager } from '../overlays/float';
35
35
  import { registerAppActions } from './appActions';
36
+ import { registerFolderActions } from './folderActions';
36
37
  import { openPalette } from '../actions/listeners';
37
38
  /**
38
39
  * Build the palette-only float-maximize toggle action. Targets the topmost
@@ -122,6 +123,7 @@ export const sh3coreShard = {
122
123
  ctx.registerView('sh3core:home', factory);
123
124
  ctx.registerView('sh3:keys-and-peers', keysFactory);
124
125
  registerAppActions(ctx);
126
+ registerFolderActions(ctx);
125
127
  // Launcher parent — submenu drill host. No `run` needed: the
126
128
  // dispatcher's default behavior opens a sub-palette filtered to
127
129
  // `submenuOf === 'sh3.app.launch'`. The single parent replaces the
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.25.0";
2
+ export declare const VERSION = "0.25.1";
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.25.0';
2
+ export const VERSION = '0.25.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"