@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.197.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.
Files changed (107) hide show
  1. package/README.md +485 -86
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32663 -6357
  4. package/dist/base-skills/notis-apps/SKILL.md +88 -67
  5. package/dist/base-skills/notis-apps/references/architecture.md +114 -167
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +104 -142
  10. package/dist/base-skills/notis-apps/references/sdk.md +113 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +142 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +4 -4
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  17. package/dist/base-skills/notis-query/SKILL.md +34 -60
  18. package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
  19. package/dist/base-skills/notis-query/references/documents.md +63 -50
  20. package/dist/base-skills/notis-query/references/query.md +57 -524
  21. package/dist/sdk/src/bundleModule.ts +79 -0
  22. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  23. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  24. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  25. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  26. package/dist/sdk/src/config.ts +4 -26
  27. package/dist/sdk/src/documents.ts +2 -0
  28. package/dist/sdk/src/hooks/useAction.ts +28 -0
  29. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  30. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  31. package/dist/sdk/src/hooks/useHandover.ts +5 -4
  32. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  33. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  34. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  35. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  36. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  37. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  38. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  39. package/dist/sdk/src/index.ts +36 -0
  40. package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
  41. package/dist/sdk/src/nativeData.ts +39 -0
  42. package/dist/sdk/src/presentation.tsx +82 -0
  43. package/dist/sdk/src/provider.tsx +9 -4
  44. package/dist/sdk/src/queryCache.ts +5 -3
  45. package/dist/sdk/src/queryErrors.ts +51 -0
  46. package/dist/sdk/src/runtime.ts +146 -8
  47. package/dist/sdk/src/space.ts +425 -0
  48. package/dist/sdk/src/styles.css +96 -72
  49. package/dist/sdk/src/vite.ts +11 -1
  50. package/dist/skill-sync/index.js +458 -68
  51. package/dist/skill-sync/index.js.map +4 -4
  52. package/dist/skill-sync-worker.mjs +451 -77
  53. package/dist/space-harness/host.js +66 -0
  54. package/dist/view-renderer/dist/host.css +1 -0
  55. package/dist/view-renderer/dist/host.js +1042 -0
  56. package/dist/view-renderer/dist/theme.css +73 -0
  57. package/dist/view-renderer/src/dom.js +180 -0
  58. package/dist/view-renderer/src/errors.js +8 -0
  59. package/dist/view-renderer/src/host.tsx +173 -0
  60. package/dist/view-renderer/src/index.js +4 -0
  61. package/dist/view-renderer/src/pool.js +76 -0
  62. package/dist/view-renderer/src/remote-broker.js +59 -0
  63. package/dist/view-renderer/src/renderer.js +201 -0
  64. package/package.json +11 -5
  65. package/skills/notis-apps/cli.md +270 -163
  66. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  67. package/skills/notis-onboarding/BRIEF.md +7 -3
  68. package/src/command-specs/apps.js +1 -8
  69. package/src/command-specs/files.js +29 -0
  70. package/src/command-specs/handover.js +45 -16
  71. package/src/command-specs/helpers.js +1 -0
  72. package/src/command-specs/index.js +10 -4
  73. package/src/command-specs/skills.js +229 -0
  74. package/src/command-specs/spaces.js +572 -0
  75. package/src/command-specs/views.js +69 -0
  76. package/src/runtime/agent-browser.js +30 -7
  77. package/src/runtime/app-boundary-validator.js +7 -4
  78. package/src/runtime/app-data-literals.js +82 -0
  79. package/src/runtime/app-platform.js +321 -253
  80. package/src/runtime/confined-space-build.js +74 -0
  81. package/src/runtime/confined-space-verify.js +49 -0
  82. package/src/runtime/native-record-files.js +47 -0
  83. package/src/runtime/retired-sdk-files.js +20 -0
  84. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  85. package/src/runtime/skill-sync/index.ts +154 -35
  86. package/src/runtime/skill-sync/local-scanner.ts +125 -17
  87. package/src/runtime/skill-sync/native-identity.ts +102 -0
  88. package/src/runtime/skill-sync/native-plan.ts +59 -0
  89. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  90. package/src/runtime/skill-sync/types.ts +32 -6
  91. package/src/runtime/source-root.js +32 -0
  92. package/src/runtime/space-collection-scaffold.js +66 -0
  93. package/src/runtime/space-database-structures.js +229 -0
  94. package/src/runtime/space-fixture-broker.js +50 -0
  95. package/src/runtime/space-lock.js +337 -0
  96. package/src/runtime/space-platform.js +105 -0
  97. package/src/runtime/space-source.js +281 -0
  98. package/src/runtime/space-test-server.js +327 -0
  99. package/src/runtime/space-verification.js +89 -0
  100. package/src/runtime/space-verify-browser-worker.js +51 -0
  101. package/src/runtime/space-view-manifest.js +254 -0
  102. package/src/runtime/sync-skills.js +1 -1
  103. package/src/runtime/transport.js +8 -1
  104. package/src/runtime/view-render.js +49 -0
  105. package/src/space-harness-host.ts +103 -0
  106. package/template/app/globals.css +1 -0
  107. package/src/command-specs/reports.js +0 -97
@@ -0,0 +1,89 @@
1
+ /** Frozen offline fixtures rendered by the same read-only library as CLI/service. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { appFilesDigest } from './app-platform.js';
6
+ import { createSpaceFixtureBroker } from './space-fixture-broker.js';
7
+
8
+ // Kept for the standalone legacy worker's lifecycle tests while that diagnostic
9
+ // entrypoint is retired. The primary verifier below never starts that worker.
10
+ export function spaceVerificationEnvironment(environment, { configPath }) {
11
+ const names = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'SystemRoot', 'WINDIR', 'DISPLAY', 'XDG_RUNTIME_DIR'];
12
+ return { ...Object.fromEntries(names.filter(name => typeof environment[name] === 'string').map(name => [name, environment[name]])),
13
+ AGENT_BROWSER_CONFIG: configPath, AGENT_BROWSER_NO_WEBMCP: '1' };
14
+ }
15
+
16
+ export async function loadSpaceVerificationRenderer() {
17
+ const candidates = [new URL('../../dist/view-renderer/', import.meta.url), new URL('../../../view-renderer/', import.meta.url)];
18
+ for (const root of candidates) {
19
+ const entry = new URL('src/index.js', root);
20
+ if (!existsSync(fileURLToPath(entry))) continue;
21
+ const digest = createHash('sha256');
22
+ for (const file of ['src/renderer.js', 'src/dom.js', 'src/pool.js', 'dist/host.js', 'dist/host.css', 'dist/theme.css']) {
23
+ digest.update(file); digest.update(readFileSync(new URL(file, root)));
24
+ }
25
+ return { ...(await import(entry.href)), rendererDigest: digest.digest('hex') };
26
+ }
27
+ throw new Error('The shared view renderer is missing. Rebuild or reinstall this CLI package.');
28
+ }
29
+
30
+ // One browser per verification, two widths concurrently; no worker creates an
31
+ // unbounded browser fanout. Up to two invocations may run simultaneously.
32
+ let active = 0;
33
+ const waiting = [];
34
+ async function acquire(signal) {
35
+ if (signal?.aborted) throw new Error('Verification cancelled.');
36
+ if (active < 2) { active++; return; }
37
+ if (waiting.length >= 8) throw new Error('The local verification queue is full.');
38
+ await new Promise((accept, reject) => {
39
+ const entry = { accept: () => { signal?.removeEventListener('abort', abort); accept(); } };
40
+ const abort = () => { const index = waiting.indexOf(entry); if(index >= 0) waiting.splice(index, 1); reject(new Error('Verification cancelled.')); };
41
+ waiting.push(entry); signal?.addEventListener('abort', abort, { once: true });
42
+ });
43
+ }
44
+ function releaseSlot() { const next = waiting.shift(); if (next) next.accept(); else active--; }
45
+
46
+ /** Initial deployment checks the exact captured bytes at both product widths. */
47
+ export async function verifySpaceRelease(release, { capabilities, timeoutMs = 45_000, signal, loadRenderer = loadSpaceVerificationRenderer,
48
+ authorizationMode = 'authorize_missing', unavailableActions = [] } = {}) {
49
+ const diagnostic = { schema: 'notis-space-render/v1', local_key: release.manifest.local_key,
50
+ source_digest: appFilesDigest(release.sourceFiles), artifact_digest: appFilesDigest(release.files),
51
+ mode: 'offline_fixtures', checked_at: new Date().toISOString(),
52
+ ...(authorizationMode === 'reuse_only' ? { authorization_mode: authorizationMode,
53
+ unavailable_actions: structuredClone(unavailableActions) } : {}) };
54
+ if (signal?.aborted) return { ...diagnostic, status: 'failed', errors: [{ phase: 'browser', message: 'Verification cancelled.' }] };
55
+ if (release.manifest.kind === 'container') return { ...diagnostic, status: 'not_applicable_container' };
56
+ let pool, held = false, rendererDigest;
57
+ const brokers = [390, 1440].map(width => ({ width, broker: null }));
58
+ try {
59
+ // Validate fixture authority before opening a browser or acquiring a slot.
60
+ for (const item of brokers) item.broker = createSpaceFixtureBroker({ release, capabilities, width: item.width,
61
+ authorizationMode, unavailableActions });
62
+ await acquire(signal); held = true;
63
+ const library = await loadRenderer(); rendererDigest = library.rendererDigest;
64
+ pool = library.createBrowserPool({ concurrency: 2, maxQueue: 2 });
65
+ const observations = await Promise.allSettled(brokers.map(async ({ width, broker }) => {
66
+ const result = await library.renderView({ broker, pool, outputs: ['markdown', 'screenshot'], width, timeoutMs, signal });
67
+ const blocked = result.blocked_calls || [];
68
+ return { width, mounted: true, status: blocked.length ? 'failed' : 'passed',
69
+ markdown_sha256: createHash('sha256').update(result.markdown).digest('hex'),
70
+ screenshot_sha256: createHash('sha256').update(Buffer.from(result.screenshot.data_base64, 'base64')).digest('hex'),
71
+ screenshot_width: result.screenshot.width, screenshot_height: result.screenshot.height,
72
+ rendered_at: result.rendered_at, blocked_calls: blocked };
73
+ }));
74
+ const renders = observations.map((value, index) => value.status === 'fulfilled' ? value.value : {
75
+ width: brokers[index].width, mounted: false, status: 'failed', error: value.reason?.code || 'render_failed',
76
+ });
77
+ const runtimeCalls = brokers.flatMap(({ width, broker }) => broker.calls.map(call => ({ width, ...call })));
78
+ const passed = renders.every(value => value.status === 'passed') && runtimeCalls.every(call => call.ok);
79
+ return { ...diagnostic, status: passed ? 'passed' : 'failed', mounted: renders.every(value => value.mounted),
80
+ fixture_digest: brokers[0].broker.fixtureDigest, renderer_digest: rendererDigest,
81
+ render_count: renders.length, browser_count: 1, renders, runtimeCalls,
82
+ errors: passed ? [] : [{ phase: 'renderer', message: 'At least one width or read-only fixture failed verification.' }] };
83
+ } catch (error) {
84
+ return { ...diagnostic, status: 'failed', ...(rendererDigest ? { renderer_digest: rendererDigest } : {}),
85
+ errors: [{ phase: 'harness', message: error.message }] };
86
+ } finally {
87
+ try { await pool?.close(); } finally { if (held) releaseSlot(); }
88
+ }
89
+ }
@@ -0,0 +1,51 @@
1
+ /** Runs in a credential-free child process, never in the authenticated CLI. */
2
+ import { closeAgentBrowserSessionResult, isAgentBrowserAvailable, runHarnessRoute } from './agent-browser.js';
3
+
4
+ let sessionName;
5
+ let closing;
6
+ let started = false;
7
+ const controller = new AbortController();
8
+ let exitCode = 0;
9
+ const cleanup = () => closing ||= sessionName ? closeAgentBrowserSessionResult(sessionName) : Promise.resolve({ ok: true });
10
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
11
+ exitCode = signal === 'SIGINT' ? 130 : 143;
12
+ controller.abort();
13
+ if (!started) process.exit(exitCode);
14
+ });
15
+ process.on('disconnect', () => { exitCode = 1; controller.abort(); if (!started) process.exit(1); });
16
+
17
+ process.once('message', async input => {
18
+ started = true;
19
+ let result;
20
+ try {
21
+ const url = new URL(input.url);
22
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port
23
+ || !/^notis-space-[a-f0-9-]+$/.test(input.sessionName)) throw new Error('Invalid offline verification session.');
24
+ sessionName = input.sessionName;
25
+ if (!isAgentBrowserAvailable()) throw new Error('Install agent-browser and its Chromium browser to verify a presentation.');
26
+ const observed = await runHarnessRoute({ url: url.href, sessionName, timeoutMs: input.timeoutMs,
27
+ designViewports: [], waitForRuntime: true, signal: controller.signal });
28
+ // Never retain raw eval/stdout or action inputs/results in a diagnostic receipt.
29
+ result = {
30
+ mounted: observed.mounted,
31
+ renderStarted: observed.renderStarted,
32
+ timed_out: Boolean(observed.timed_out),
33
+ errors: (observed.errors || []).map(error => ({ phase: String(error.phase).slice(0, 100), message: String(error.message).slice(0, 2000) })),
34
+ runtimeCalls: (observed.runtimeCalls || []).map(call => ({ actionId: call.actionId, ok: call.ok,
35
+ ...(call.error ? { error: String(call.error).slice(0, 2000) } : {}) })),
36
+ ...(observed.tool_error ? { tool_error: { phase: observed.tool_error.phase,
37
+ message: String(observed.tool_error.stderr || observed.tool_error.message
38
+ || 'The isolated verification browser could not complete this operation.').slice(0, 2000) } } : {}),
39
+ };
40
+ } catch (error) {
41
+ result = { mounted: false, errors: [{ phase: 'browser', message: error.message }], runtimeCalls: [] };
42
+ } finally {
43
+ if (process.connected) {
44
+ try { process.send({ phase: 'cleanup' }, () => {}); } catch { /* Cleanup still owns the browser after IPC loss. */ }
45
+ }
46
+ const closed = await cleanup();
47
+ result = { ...result, cleanup: closed, ...(!closed.ok ? { cleanup_failed: true } : {}) };
48
+ }
49
+ if (process.connected) process.send(result, () => process.exit(exitCode));
50
+ else process.exit(exitCode);
51
+ });
@@ -0,0 +1,254 @@
1
+ /**
2
+ * What a view declares (V4): path, typed params, shows, memory, chrome and a Markdown override.
3
+ * Mirrors server/lib/space_view_manifest.py, which re-checks every release; property-level checks
4
+ * need the linked schemas and run on the server during `spaces verify` and deploy.
5
+ * Every presentation requires path, description and memory, including sources that omit
6
+ * specVersion. Containers remain non-executable; explicit specVersion 2 is unchanged.
7
+ */
8
+ import { usageError } from './errors.js';
9
+ import portalRouteRoots from '../../config/portal-route-roots.json' with { type: 'json' };
10
+ const RESERVED_PATHS = new Set(portalRouteRoots);
11
+
12
+ export const VIEW_SPEC_VERSION = 2;
13
+ export const VIEW_DEFINITION_FIELDS = Object.freeze(['specVersion', 'path', 'params', 'shows', 'memory', 'chrome', 'markdown']);
14
+ export const VIEW_PARAM_TYPES = Object.freeze(['record', 'enum', 'text', 'number', 'date', 'boolean']);
15
+ const RESERVED_PARAMS = new Set(['record', 'assistantThread']);
16
+ const PAGE_ONLY = ['params', 'shows', 'memory', 'chrome', 'markdown'];
17
+ const NAME = /^[a-z][A-Za-z0-9_]{0,63}$/;
18
+ // Credentials never ride in links, so no view param may look like one (the Portal drops these names).
19
+ const CREDENTIAL_PARAM = /^(?:jwt|code|state|key)$|token|secret|password|credential|session|auth|api_?key/i;
20
+ const WORD = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ const ID_TAIL = /(?:^|-)(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
22
+ const PLACEHOLDER = /^\{([a-z][A-Za-z0-9_]{0,63})(?:\.([^{}.\s][^{}]{0,199}))?\}$/;
23
+ const RECORD_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
25
+ const SERVICE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
26
+ // eslint-disable-next-line no-control-regex
27
+ const CONTROL = /[\u0000-\u001f\u007f]/;
28
+ const FIELD_KEYS = ['property', 'property_id', 'column'];
29
+ const OPS = new Set(['equals', 'not_equals', 'in', 'contains', 'not_contains', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty',
30
+ 'greater_than', 'less_than', 'greater_than_or_equal_to', 'less_than_or_equal_to', 'does_not_contain', 'before', 'after',
31
+ 'on_or_before', 'on_or_after']);
32
+ const CRON_BOUNDS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
33
+ const DEBOUNCE = [60, 86400];
34
+
35
+ const plain = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
36
+ const text = (value, limit) => typeof value === 'string' && value.trim().length >= 1 && value.trim().length <= limit && !CONTROL.test(value);
37
+ const isoDate = value => typeof value === 'string' && DATE.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
38
+ && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
39
+
40
+ export function validateViewPath(value) {
41
+ if (typeof value !== 'string' || value.length < 1 || value.length > 100) {
42
+ throw usageError('Declare path as the words of this view\'s links, such as "notes" or "seo/keywords".');
43
+ }
44
+ const segments = value.split('/');
45
+ if (segments.length > 4 || segments.some(segment => !WORD.test(segment) || ID_TAIL.test(segment))) {
46
+ throw usageError('A path is up to four lowercase words (a-z, 0-9, dashes) separated by "/", without ids.');
47
+ }
48
+ if (RESERVED_PATHS.has(segments[0])) throw usageError(`The path "${segments[0]}" belongs to the Portal. Choose another first word.`);
49
+ return value;
50
+ }
51
+
52
+ function validCron(value) {
53
+ const fields = typeof value === 'string' ? value.split(' ') : [];
54
+ if (fields.length !== 5) return false;
55
+ return fields.every((field, index) => {
56
+ const [low, high] = CRON_BOUNDS[index];
57
+ const inRange = number => /^\d+$/.test(number) && Number(number) >= low && Number(number) <= high;
58
+ if (index === 0) return inRange(field);
59
+ return field.split(',').every(part => {
60
+ const [base, step, extra] = part.split('/');
61
+ if (extra !== undefined || (step !== undefined && !(/^\d+$/.test(step) && Number(step) >= 1 && Number(step) <= high))) return false;
62
+ if (base === '*') return true;
63
+ const [start, end, more] = base.split('-');
64
+ if (more !== undefined || !inRange(start) || (end !== undefined && (!inRange(end) || Number(start) > Number(end)))) return false;
65
+ return true;
66
+ });
67
+ });
68
+ }
69
+
70
+ function validateMemory(memory) {
71
+ if (!plain(memory) || Object.keys(memory).sort().join(',') !== 'attachments,markdown,screenshot,snapshots') {
72
+ throw usageError('Declare memory as { markdown, attachments, screenshot, snapshots }; see the memory guide.');
73
+ }
74
+ if (['markdown', 'attachments', 'screenshot'].some(name => typeof memory[name] !== 'boolean')) {
75
+ throw usageError('memory.markdown, memory.attachments and memory.screenshot are true or false.');
76
+ }
77
+ if (!Array.isArray(memory.snapshots) || memory.snapshots.length > 3) {
78
+ throw usageError('memory.snapshots lists on_render, { scheduled } and { on_change }, each at most once ([] for none).');
79
+ }
80
+ const seen = new Set();
81
+ for (const strategy of memory.snapshots) {
82
+ let kind;
83
+ if (strategy === 'on_render') kind = 'on_render';
84
+ else if (plain(strategy) && Object.keys(strategy).join(',') === 'scheduled') {
85
+ kind = 'scheduled';
86
+ if (!validCron(strategy.scheduled)) throw usageError('A scheduled snapshot uses a five-field UTC cron with a fixed minute, such as "0 7 * * 1".');
87
+ } else if (plain(strategy) && Object.keys(strategy).join(',') === 'on_change') {
88
+ kind = 'on_change';
89
+ const debounce = strategy.on_change?.debounceSeconds;
90
+ if (!plain(strategy.on_change) || Object.keys(strategy.on_change).join(',') !== 'debounceSeconds' || !Number.isInteger(debounce)
91
+ || debounce < DEBOUNCE[0] || debounce > DEBOUNCE[1]) {
92
+ throw usageError(`An on_change snapshot waits debounceSeconds (${DEBOUNCE[0]} to ${DEBOUNCE[1]}) after the last change.`);
93
+ }
94
+ } else throw usageError('memory.snapshots lists on_render, { scheduled: "<cron>" } or { on_change: { debounceSeconds } }.');
95
+ if (seen.has(kind)) throw usageError('Declare each memory snapshot strategy at most once.');
96
+ seen.add(kind);
97
+ }
98
+ }
99
+
100
+ /** One typed param value, as the SDK and the server accept it. */
101
+ export function viewParamValue(declaration, value) {
102
+ switch (declaration.type) {
103
+ case 'text': return typeof value === 'string' && value.length <= 500 && !CONTROL.test(value);
104
+ case 'enum': return typeof value === 'string' && (declaration.values || []).includes(value);
105
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
106
+ case 'boolean': return typeof value === 'boolean';
107
+ case 'date': return isoDate(value);
108
+ case 'record': return typeof value === 'string' && RECORD_KEY.test(value);
109
+ default: return false;
110
+ }
111
+ }
112
+
113
+ function validateParam(name, declaration, resources) {
114
+ if (!NAME.test(name) || RESERVED_PARAMS.has(name) || CREDENTIAL_PARAM.test(name)) throw usageError(`Param names are camelCase words; "${name}" is not available.`);
115
+ const allowed = new Set(['type', 'description', 'required', 'default', 'values', 'database', 'main', 'slugProperty']);
116
+ if (!plain(declaration) || Object.keys(declaration).some(field => !allowed.has(field)) || !VIEW_PARAM_TYPES.includes(declaration.type)) {
117
+ throw usageError(`Declare param "${name}" as { type, description, ... } with type ${VIEW_PARAM_TYPES.join(', ')}.`);
118
+ }
119
+ if (!text(declaration.description, 500)) throw usageError(`Describe param "${name}": what it selects, for agents and people.`);
120
+ if ('required' in declaration && typeof declaration.required !== 'boolean') throw usageError(`Param "${name}": required is true or false.`);
121
+ if (declaration.type === 'enum') {
122
+ const values = declaration.values;
123
+ if (!Array.isArray(values) || values.length < 1 || values.length > 100 || new Set(values).size !== values.length
124
+ || values.some(value => !text(value, 100))) throw usageError(`Param "${name}": an enum lists its distinct values.`);
125
+ } else if ('values' in declaration) throw usageError(`Param "${name}": only enum params list values.`);
126
+ if (declaration.type === 'record') {
127
+ if (resources[declaration.database]?.kind !== 'database') {
128
+ throw usageError(`Param "${name}": a record param names a database this Space declares in resources.`);
129
+ }
130
+ if ('main' in declaration && typeof declaration.main !== 'boolean') throw usageError(`Param "${name}": main is true or false.`);
131
+ if ('slugProperty' in declaration && !text(declaration.slugProperty, 200)) throw usageError(`Param "${name}": slugProperty names a property of its database.`);
132
+ if ('default' in declaration) throw usageError(`Param "${name}": a record param has no default record.`);
133
+ } else if (['database', 'main', 'slugProperty'].some(field => field in declaration)) {
134
+ throw usageError(`Param "${name}": database, main and slugProperty belong to record params.`);
135
+ }
136
+ if ('default' in declaration) {
137
+ if (declaration.required === true) throw usageError(`Param "${name}": a required param has no default.`);
138
+ if (!viewParamValue(declaration, declaration.default)) throw usageError(`Param "${name}": its default must be a valid ${declaration.type} value.`);
139
+ }
140
+ }
141
+
142
+ function checkPlaceholder(value, params, collection, where) {
143
+ const match = typeof value === 'string' ? PLACEHOLDER.exec(value) : null;
144
+ if (!match) return;
145
+ const [, name, property] = match;
146
+ if (name === 'record') {
147
+ const records = Object.values(params).filter(declaration => declaration.type === 'record');
148
+ if (records.length === 1 || (!records.length && collection)) return;
149
+ throw usageError('Use {record} only in a view with exactly one record param (or a collection); otherwise name the param, such as {project}.');
150
+ }
151
+ if (!Object.hasOwn(params, name)) throw usageError(`${where}: "{${name}}" names no declared param.`);
152
+ if (property !== undefined && params[name].type !== 'record') throw usageError(`${where}: only record params have properties, such as {${name}}.`);
153
+ }
154
+
155
+ function walkFilter(node, visit, where, depth = 0, budget = { left: 200 }) {
156
+ budget.left -= 1;
157
+ if (depth > 12 || budget.left < 0) throw usageError(`${where}: the filter is too complex; use fewer conditions.`);
158
+ if (depth === 0 && plain(node) && !Object.keys(node).length) return;
159
+ if (!plain(node)) throw usageError(`${where}: a filter is an object.`);
160
+ for (const group of ['and', 'or']) {
161
+ if (!Object.hasOwn(node, group)) continue;
162
+ const children = node[group];
163
+ if (Object.keys(node).length !== 1 || !Array.isArray(children) || !children.length) throw usageError(`${where}: filter groups need at least one condition.`);
164
+ children.forEach(child => walkFilter(child, visit, where, depth + 1, budget));
165
+ return;
166
+ }
167
+ const field = node.field;
168
+ if (Object.keys(node).some(key => !['field', 'op', 'value'].includes(key)) || !('field' in node) || !('op' in node)
169
+ || !plain(field) || Object.keys(field).length !== 1 || !FIELD_KEYS.includes(Object.keys(field)[0])
170
+ || typeof Object.values(field)[0] !== 'string' || !OPS.has(node.op)) {
171
+ throw usageError(`${where}: each condition is { field: { property }, op, value } in the native filter language.`);
172
+ }
173
+ visit(node);
174
+ }
175
+
176
+ function validateShows(name, declaration, params, resources, collection) {
177
+ const where = `shows.${name}`;
178
+ if (!NAME.test(name)) throw usageError(`Shows names are camelCase words; "${name}" is not available.`);
179
+ if (plain(declaration) && 'about' in declaration) {
180
+ const services = declaration.services ?? [];
181
+ if (Object.keys(declaration).some(field => !['about', 'services'].includes(field)) || !text(declaration.about, 500)
182
+ || !Array.isArray(services) || services.length > 20 || services.some(service => typeof service !== 'string' || !SERVICE.test(service))) {
183
+ throw usageError(`${where}: describe what it shows as { about, services? }.`);
184
+ }
185
+ return;
186
+ }
187
+ if (!plain(declaration) || Object.keys(declaration).some(field => !['database', 'where', 'open'].includes(field)) || !('where' in declaration)) {
188
+ throw usageError(`${where}: declare { database, where, open? } for Notis data or { about, services? } otherwise.`);
189
+ }
190
+ if (resources[declaration.database]?.kind !== 'database') {
191
+ throw usageError(`${where}: "${declaration.database}" is not a database this Space declares and links.`);
192
+ }
193
+ if (declaration.open !== undefined && (params[declaration.open]?.type !== 'record' || params[declaration.open].database !== declaration.database)) {
194
+ throw usageError(`${where}: open names a record param over the same database.`);
195
+ }
196
+ walkFilter(declaration.where, condition => {
197
+ if (!('value' in condition)) return;
198
+ const values = condition.op === 'in' && Array.isArray(condition.value) ? condition.value : [condition.value];
199
+ values.forEach(value => checkPlaceholder(value, params, collection, where));
200
+ }, where);
201
+ }
202
+
203
+ /**
204
+ * Validates V4 declarations independently of the optional version marker, then returns
205
+ * their manifest form without inventing a version or page fields for containers.
206
+ */
207
+ export function viewManifestFields(definition, { entry, resources }) {
208
+ const container = !entry;
209
+ const version = definition.specVersion;
210
+ if (version !== undefined && version !== VIEW_SPEC_VERSION) throw usageError(`specVersion is ${VIEW_SPEC_VERSION} for sources that declare the view manifest.`);
211
+ if (container && PAGE_ONLY.some(field => definition[field] !== undefined)) {
212
+ throw usageError('A container has no page: params, shows, memory, chrome and markdown belong to its views.');
213
+ }
214
+ if (!container || version === VIEW_SPEC_VERSION) {
215
+ const missing = ['path', 'description'].filter(field => !definition[field]);
216
+ if (!container && definition.memory === undefined) missing.push('memory');
217
+ if (missing.length) throw usageError(`This view needs ${missing.join(', ')} (specVersion 2).`);
218
+ if (!text(definition.description, 300)) throw usageError('Describe this view in one line (up to 300 characters).');
219
+ }
220
+ if (definition.path !== undefined) validateViewPath(definition.path);
221
+ const params = definition.params ?? {};
222
+ if (definition.params !== undefined) {
223
+ if (!plain(params) || Object.keys(params).length > 50) throw usageError('Declare params as { name: { type, description, ... } }.');
224
+ const mains = new Map();
225
+ for (const [name, declaration] of Object.entries(params)) {
226
+ validateParam(name, declaration, resources);
227
+ if (declaration.type !== 'record' || declaration.main !== true) continue;
228
+ if (mains.has(declaration.database)) {
229
+ throw usageError(`Params "${mains.get(declaration.database)}" and "${name}" both claim main for database "${declaration.database}"; a database has one main view.`);
230
+ }
231
+ mains.set(declaration.database, name);
232
+ }
233
+ }
234
+ if (definition.shows !== undefined) {
235
+ if (!plain(definition.shows) || Object.keys(definition.shows).length > 50) {
236
+ throw usageError('Declare shows as { name: { database, where, open? } } or { name: { about, services? } }.');
237
+ }
238
+ for (const [name, declaration] of Object.entries(definition.shows)) validateShows(name, declaration, params, resources, definition.collection);
239
+ }
240
+ if (definition.memory !== undefined) validateMemory(definition.memory);
241
+ if (definition.chrome !== undefined && !['portal', 'hidden'].includes(definition.chrome)) throw usageError("chrome is 'portal' (default) or 'hidden'.");
242
+ if (definition.markdown !== undefined && (typeof definition.markdown !== 'string' || !definition.markdown)) {
243
+ throw usageError('markdown names a source module whose default export renders this view as Markdown.');
244
+ }
245
+ return {
246
+ ...(version !== undefined ? { spec_version: version } : {}),
247
+ ...(definition.path !== undefined ? { path: definition.path } : {}),
248
+ ...(definition.params !== undefined ? { params } : {}),
249
+ ...(definition.shows !== undefined ? { shows: definition.shows } : {}),
250
+ ...(definition.memory !== undefined ? { memory: definition.memory } : {}),
251
+ ...(definition.chrome !== undefined ? { chrome: definition.chrome } : {}),
252
+ ...(definition.markdown !== undefined ? { markdown: { export_name: 'SpaceMarkdown' } } : {}),
253
+ };
254
+ }
@@ -218,7 +218,7 @@ export async function reconcileAllSkills({
218
218
  serverUrl,
219
219
  jwt,
220
220
  {},
221
- { honorSyncEnabled },
221
+ { honorSyncEnabled, ...(home ? { syncHome: home } : {}) },
222
222
  );
223
223
  return {
224
224
  ...account,
@@ -82,10 +82,15 @@ function createMultipartFileUpload(payload, bindingMetadata, fileBindings) {
82
82
  };
83
83
  }
84
84
 
85
- function normalizeBackendError(status, payload, runtime) {
85
+ export function normalizeBackendError(status, payload, runtime) {
86
86
  const backendError = payload?.error;
87
+ // Space errors answer {error: <stable code>, message: <readable text>}: show
88
+ // the text (it can name the Space involved); the code stays in details.
89
+ const codedMessage = typeof backendError === 'string' && /^[a-z][a-z0-9_]*$/.test(backendError)
90
+ && typeof payload?.message === 'string' && payload.message ? payload.message : null;
87
91
  const message =
88
92
  backendError?.message ||
93
+ codedMessage ||
89
94
  backendError ||
90
95
  payload?.message ||
91
96
  payload?.error ||
@@ -258,6 +263,7 @@ export async function httpRequest({
258
263
  body,
259
264
  multipart = false,
260
265
  requireAuth = true,
266
+ spacesProtocol,
261
267
  }) {
262
268
  // Refresh before spending the credential rather than after a rejection: the
263
269
  // rotating refresh token is shared with every other `notis` process reading
@@ -293,6 +299,7 @@ export async function httpRequest({
293
299
  'X-Notis-CLI-Version': runtime.cliVersion,
294
300
  'X-Notis-Request-Id': requestId,
295
301
  };
302
+ if (spacesProtocol !== undefined) headers['X-Notis-Spaces-Protocol'] = String(spacesProtocol);
296
303
  if (!multipart) {
297
304
  headers['Content-Type'] = 'application/json';
298
305
  }
@@ -0,0 +1,49 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, writeFile, unlink } from 'node:fs/promises';
3
+ import { resolve, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { httpRequest } from './transport.js';
6
+ import { CliError, EXIT_CODES, usageError } from './errors.js';
7
+
8
+ async function rendererLibrary() {
9
+ const packaged = new URL('../../dist/view-renderer/src/index.js', import.meta.url);
10
+ if (existsSync(fileURLToPath(packaged))) return import(packaged.href);
11
+ const development = new URL('../../../view-renderer/src/index.js', import.meta.url);
12
+ if (existsSync(fileURLToPath(development))) return import(development.href);
13
+ throw usageError('The shared view renderer is missing. Rebuild or reinstall this CLI package.');
14
+ }
15
+
16
+ /** Root command registration supplies flags/output formatting; this adapter holds no browser JWT. */
17
+ export async function renderViewWithCliRuntime({ runtime, url, outputs = ['markdown', 'screenshot'], width = 1440,
18
+ directory, signal, loadLibrary = rendererLibrary, request = httpRequest }) {
19
+ if (typeof url !== 'string' || !url.trim() || ![390, 1440].includes(width)) throw usageError('Choose a view link and width 390 or 1440.');
20
+ const ticket = (await request({ runtime, method: 'POST', path: '/portal_view_render/prepare', spacesProtocol: 1,
21
+ body: { url, outputs, width }, signal })).payload;
22
+ if (typeof ticket?.credential !== 'string') throw usageError('The short-lived render ticket is missing.');
23
+ const library = await loadLibrary();
24
+ const broker = library.createRemoteRenderBroker({ apiBase: runtime.apiBase, credential: ticket.credential });
25
+ let result;
26
+ try {
27
+ result = await library.renderView({ broker, outputs, width, signal });
28
+ await broker.complete(result, { signal });
29
+ }
30
+ catch (error) {
31
+ if (error instanceof library.ViewRenderError) throw new CliError({ code: error.code,
32
+ message: error.message, exitCode: EXIT_CODES.backend, retryable: error.status >= 500 || error.status === 429, cause: error });
33
+ throw error;
34
+ }
35
+ if (!directory) return { result, artifacts: null };
36
+ const root = resolve(directory), created = [], artifacts = {};
37
+ await mkdir(root, { recursive: true });
38
+ try {
39
+ for (const [kind, name, bytes] of [
40
+ ['markdown', 'view.md', result.markdown === undefined ? undefined : Buffer.from(result.markdown, 'utf8')],
41
+ ['screenshot', 'view.png', result.screenshot ? Buffer.from(result.screenshot.data_base64, 'base64') : undefined],
42
+ ]) {
43
+ if (bytes === undefined) continue;
44
+ const path = join(root, name);
45
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }); created.push(path); artifacts[kind] = path;
46
+ }
47
+ } catch (error) { await Promise.allSettled(created.map(path => unlink(path))); throw error; }
48
+ return { result, artifacts };
49
+ }
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import * as ReactDOM from 'react-dom';
3
+ import * as ReactDOMClient from 'react-dom/client';
4
+ import * as JSXRuntime from 'react/jsx-runtime';
5
+ import { importBundleSource, resolveBundleRouteExport } from '../../sdk/src/bundleModule';
6
+ import { SdkPresentation } from '../../sdk/src/presentation';
7
+ import { createPrefetchQueue, createQueryClient } from '../../sdk/src/queryCache';
8
+ import type { NotisRuntime } from '../../sdk/src/runtime';
9
+ import { SPACE_VIEWER_READ_OPERATIONS } from '../../sdk/src/space';
10
+ import type { SpaceActionOptions, SpaceDocumentBodyRequest, SpaceViewerReadOperation } from '../../sdk/src/space';
11
+
12
+ type Call = { op: string; actionId?: string; ok: boolean | null; error?: string };
13
+ const state = { mounted: false, renderStarted: false, errors: [] as Array<{ phase: string; message: string }>, runtimeCalls: [] as Call[] };
14
+ Object.assign(window, { __harness: state, React, ReactDOM, ReactDOMClient, __ReactJSXRuntime: JSXRuntime });
15
+ const diagnostic = (phase: string, reason: unknown) => state.errors.push({ phase,
16
+ message: reason instanceof Error ? reason.message : String(reason || 'Verification failed.') });
17
+ window.addEventListener('error', event => diagnostic('window_error', event.error || event.message));
18
+ window.addEventListener('unhandledrejection', event => diagnostic('unhandled_rejection', event.reason));
19
+ window.addEventListener('securitypolicyviolation', event => diagnostic('security_policy', `Blocked ${event.violatedDirective}`));
20
+
21
+ async function start() {
22
+ const response = await fetch('/snapshot', { credentials: 'omit' });
23
+ if (!response.ok) throw new Error('The frozen verification snapshot could not be loaded.');
24
+ const snapshot = await response.json();
25
+ const authority = structuredClone(snapshot.descriptor.space);
26
+ const runFixture = async (call: Call, input: unknown) => {
27
+ state.runtimeCalls.push(call);
28
+ try {
29
+ const result = await fetch('/fixture', { method: 'POST', credentials: 'omit',
30
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(5_000) });
31
+ const payload = await result.json();
32
+ if (!result.ok) throw new Error(payload.message || 'No matching synthetic fixture.');
33
+ call.ok = true; return payload.result;
34
+ } catch (error) {
35
+ call.ok = false; call.error = error instanceof Error ? error.message : 'Verification request failed.';
36
+ throw error;
37
+ }
38
+ };
39
+ const protocolFailure = (message: string): Promise<never> => { diagnostic('protocol', message); return Promise.reject(new Error(message)); };
40
+ const deny = () => protocolFailure('Use a declared Space action.');
41
+ const queue = createPrefetchQueue(2);
42
+ const runtime: NotisRuntime = {
43
+ ...snapshot.descriptor, databases: [],
44
+ context: { ...snapshot.descriptor.context,
45
+ resourceId: snapshot.descriptor.context?.resource_id ?? snapshot.descriptor.context?.resourceId ?? null,
46
+ collectionItem: snapshot.descriptor.context?.collection_item ?? snapshot.descriptor.context?.collectionItem ?? null },
47
+ queryClient: createQueryClient({ maxEntries: 100, schedule: queue }),
48
+ listTools: deny, callTool: deny, request: deny,
49
+ callAction<T>(id: string, inputs: Record<string, unknown> = {}, options: SpaceActionOptions = {}): Promise<T> {
50
+ if (!Object.hasOwn(authority.actions, id)) return protocolFailure('Undeclared verification action.');
51
+ if ((options.readOnly || options.dedupe) && authority.actions[id].readOnly !== true) {
52
+ return protocolFailure('A write action cannot run as a cached read.');
53
+ }
54
+ return runFixture({ op: 'callAction', actionId: id, ok: null }, { action_id: id, inputs });
55
+ },
56
+ viewerRead: authority.viewerReads?.length ? <T,>(operation: SpaceViewerReadOperation, input: Record<string, unknown> = {}): Promise<T> => {
57
+ const family = Object.hasOwn(SPACE_VIEWER_READ_OPERATIONS, operation) ? SPACE_VIEWER_READ_OPERATIONS[operation] : null;
58
+ if (!family || !authority.viewerReads.includes(family)) return protocolFailure('Undeclared verification viewer read.');
59
+ return runFixture({ op: 'viewerRead', actionId: operation, ok: null }, { viewer_read: { operation, input } });
60
+ } : undefined,
61
+ shown: Object.values(authority.shows || {}).some(list => 'database' in list) ? <T,>(name: string, params: Record<string, unknown> = {}): Promise<T> => {
62
+ const list = authority.shows && Object.hasOwn(authority.shows, name) ? authority.shows[name] : null;
63
+ if (!list || !('database' in list)) return protocolFailure('Undeclared verification list.');
64
+ return runFixture({ op: 'shown', actionId: name, ok: null }, { shown: { name, params } });
65
+ } : undefined,
66
+ documentBody(request: SpaceDocumentBodyRequest) {
67
+ return runFixture({ op: 'documentBody', actionId: request?.readAction, ok: null }, { document_body: request });
68
+ },
69
+ navigate(payload) {
70
+ if (payload.kind !== 'space-reference' || typeof payload.alias !== 'string' || !authority.navigation?.includes(payload.alias)) {
71
+ return protocolFailure('Offline navigation requires a declared synthetic destination.');
72
+ }
73
+ // This observes a declaration only; no live navigation or access is claimed.
74
+ state.runtimeCalls.push({ op: 'navigate', actionId: payload.alias, ok: true });
75
+ },
76
+ publishActiveResource() {}, captureContextSelection() {},
77
+ addContext: async () => false, updateContext: async () => false, removeContext: async () => false,
78
+ registerTopBarSearch() {}, setTopBarSearchValue() {}, setTopBarSearchLoading() {},
79
+ };
80
+ const module = await importBundleSource(snapshot.source);
81
+ const view = resolveBundleRouteExport(module, ['SpaceView'], true);
82
+ if (!view) throw new Error('The SpaceView export is missing from this presentation.');
83
+ const host = document.createElement('div'); host.style.cssText = 'min-height:100vh;height:100vh';
84
+ const shadow = host.attachShadow({ mode: 'open' });
85
+ const css = document.createElement('style'); css.textContent = snapshot.css; shadow.appendChild(css);
86
+ const mount = document.createElement('div'); mount.setAttribute('data-notis-app-mount', 'true');
87
+ mount.style.cssText = 'min-height:0;height:100%'; shadow.appendChild(mount);
88
+ document.body.appendChild(host);
89
+ const controller = document.createElement('div'); document.body.appendChild(controller);
90
+ const mode = new URLSearchParams(location.search).get('theme') === 'dark' ? 'dark' : 'light';
91
+ document.documentElement.classList.toggle('dark', mode === 'dark');
92
+ state.renderStarted = true;
93
+ ReactDOMClient.createRoot(controller).render(React.createElement(SdkPresentation, {
94
+ mount, runtime, component: view.component, shell: module.__AppShell, theme: { mode, style: { colorScheme: mode } },
95
+ resetKey: snapshot.cacheKey,
96
+ onCommit: (hasContent: boolean) => { state.mounted = hasContent; },
97
+ onError: (error: Error) => { state.mounted = false; state.errors.push({ phase: 'render', message: error.message }); },
98
+ onProtocolError: (error: Error) => diagnostic('protocol', error),
99
+ fallback: (error: Error) => React.createElement('div', { role: 'alert' }, error.message),
100
+ }));
101
+ }
102
+
103
+ void start().catch(reason => state.errors.push({ phase: 'boot', message: reason instanceof Error ? reason.message : 'Verification did not start.' }));
@@ -3,6 +3,7 @@
3
3
  @config "../tailwind.config.ts";
4
4
  @source "./";
5
5
  @source "../components";
6
+ @source "../spaces";
6
7
  @source "../lib";
7
8
  @source "../packages/sdk/src";
8
9