@notis_ai/cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +122 -158
  2. package/dist/scaffolds/notes/app/globals.css +3 -0
  3. package/dist/scaffolds/notes/app/layout.tsx +6 -0
  4. package/dist/scaffolds/notes/app/page.tsx +1465 -0
  5. package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
  6. package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
  7. package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
  8. package/dist/scaffolds/notes/components.json +20 -0
  9. package/dist/scaffolds/notes/lib/utils.ts +6 -0
  10. package/dist/scaffolds/notes/notis.config.ts +31 -0
  11. package/dist/scaffolds/notes/package.json +31 -0
  12. package/dist/scaffolds/notes/postcss.config.mjs +8 -0
  13. package/dist/scaffolds/notes/tailwind.config.ts +58 -0
  14. package/dist/scaffolds/notes/tsconfig.json +22 -0
  15. package/dist/scaffolds/notes/vite.config.ts +10 -0
  16. package/dist/scaffolds.json +13 -0
  17. package/package.json +12 -2
  18. package/skills/notis-apps/SKILL.md +162 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +258 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +1029 -59
  24. package/src/command-specs/helpers.js +6 -41
  25. package/src/command-specs/index.js +1 -7
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +29 -34
  28. package/src/runtime/agent-browser.js +192 -0
  29. package/src/runtime/app-boundary-validator.js +132 -0
  30. package/src/runtime/app-dev-server.js +605 -0
  31. package/src/runtime/app-dev-sessions.js +87 -0
  32. package/src/runtime/app-platform.js +1037 -82
  33. package/src/runtime/cli-mode.generated.js +4 -0
  34. package/src/runtime/cli-mode.js +29 -0
  35. package/src/runtime/output.js +2 -2
  36. package/src/runtime/ports.js +15 -0
  37. package/src/runtime/profiles.js +36 -5
  38. package/src/runtime/transport.js +131 -6
  39. package/template/.harness/index.html.tmpl +211 -0
  40. package/template/app/globals.css +3 -0
  41. package/template/app/layout.tsx +6 -0
  42. package/template/app/page.tsx +90 -0
  43. package/template/components/ui/badge.tsx +28 -0
  44. package/template/components/ui/button.tsx +53 -0
  45. package/template/components/ui/card.tsx +56 -0
  46. package/template/components.json +20 -0
  47. package/template/lib/utils.ts +6 -0
  48. package/template/metadata/cover.png +0 -0
  49. package/template/metadata/screenshot-1.png +0 -0
  50. package/template/metadata/screenshot-2.png +0 -0
  51. package/template/metadata/screenshot-3.png +0 -0
  52. package/template/notis.config.ts +37 -0
  53. package/template/package.json +31 -0
  54. package/template/packages/sdk/package.json +32 -0
  55. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  56. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  57. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  58. package/template/packages/sdk/src/config.ts +71 -0
  59. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  60. package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  62. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  63. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  64. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  65. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  66. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  67. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  68. package/template/packages/sdk/src/index.ts +54 -0
  69. package/template/packages/sdk/src/provider.tsx +43 -0
  70. package/template/packages/sdk/src/runtime.ts +161 -0
  71. package/template/packages/sdk/src/styles.css +38 -0
  72. package/template/packages/sdk/src/ui.ts +15 -0
  73. package/template/packages/sdk/src/vite.ts +56 -0
  74. package/template/packages/sdk/tsconfig.json +15 -0
  75. package/template/postcss.config.mjs +8 -0
  76. package/template/tailwind.config.ts +58 -0
  77. package/template/tsconfig.json +22 -0
  78. package/template/vite.config.ts +10 -0
  79. package/src/command-specs/auth.js +0 -178
  80. package/src/command-specs/db.js +0 -163
  81. package/src/runtime/app-preview-server.js +0 -272
@@ -0,0 +1,4 @@
1
+ // Auto-generated on npm publish.
2
+ // Committed value is 'local' so in-repo runs target localhost.
3
+ // scripts/set-cli-mode.js rewrites this to 'published' before the npm publish step.
4
+ export const MODE = 'local';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * CLI mode detection for `notis apps dev`.
3
+ *
4
+ * The repo-local CLI package targets localhost; the published npm CLI package
5
+ * targets app.notis.ai. Mode is baked into `cli-mode.generated.js` at publish
6
+ * time — no runtime flag is needed (and none is exposed to end users, so a
7
+ * crafted env var can't trick a published CLI into opening a localhost portal
8
+ * that isn't running).
9
+ *
10
+ * The `NOTIS_CLI_MODE` env var is honored for internal testing only.
11
+ */
12
+
13
+ import { MODE as BAKED_MODE } from './cli-mode.generated.js';
14
+
15
+ export function getCliMode() {
16
+ const override = process.env.NOTIS_CLI_MODE;
17
+ if (override === 'local' || override === 'published') {
18
+ return override;
19
+ }
20
+ return BAKED_MODE === 'published' ? 'published' : 'local';
21
+ }
22
+
23
+ export function getDefaultApiBase(mode = getCliMode()) {
24
+ return mode === 'local' ? 'http://localhost:3001' : 'https://api.notis.ai';
25
+ }
26
+
27
+ export function getDefaultPortalOrigin(mode = getCliMode()) {
28
+ return mode === 'local' ? 'http://localhost:3000' : 'https://app.notis.ai';
29
+ }
@@ -95,6 +95,7 @@ export class OutputManager {
95
95
  }
96
96
 
97
97
  emitSuccess({
98
+ ok = true,
98
99
  command,
99
100
  data = {},
100
101
  humanSummary,
@@ -105,7 +106,7 @@ export class OutputManager {
105
106
  renderHuman,
106
107
  }) {
107
108
  const envelope = {
108
- ok: true,
109
+ ok: Boolean(ok),
109
110
  command,
110
111
  data,
111
112
  human_summary: humanSummary,
@@ -177,4 +178,3 @@ export class OutputManager {
177
178
  }
178
179
 
179
180
  export { formatTable };
180
-
@@ -0,0 +1,15 @@
1
+ import { createServer } from 'node:http';
2
+
3
+ export async function getAvailablePort() {
4
+ const server = createServer();
5
+ await new Promise((resolvePromise, rejectPromise) => {
6
+ server.once('error', rejectPromise);
7
+ server.listen(0, '127.0.0.1', () => {
8
+ server.off('error', rejectPromise);
9
+ resolvePromise();
10
+ });
11
+ });
12
+ const { port } = server.address();
13
+ await new Promise((resolvePromise) => server.close(resolvePromise));
14
+ return port;
15
+ }
@@ -12,6 +12,7 @@ const LOCAL_DEFAULT_API_BASES = new Set([
12
12
  'http://localhost:3001',
13
13
  'http://127.0.0.1:3001',
14
14
  ]);
15
+ const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
15
16
 
16
17
  function clone(value) {
17
18
  return JSON.parse(JSON.stringify(value));
@@ -29,6 +30,13 @@ export function normalizeConfig(rawConfig = {}) {
29
30
  profiles[name] = {
30
31
  jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
31
32
  api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
33
+ auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
34
+ refresh_token:
35
+ typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
36
+ access_expires_at:
37
+ typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
38
+ refresh_expires_at:
39
+ typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
32
40
  };
33
41
  }
34
42
 
@@ -51,6 +59,12 @@ export function normalizeConfig(rawConfig = {}) {
51
59
  [DEFAULT_PROFILE]: {
52
60
  jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
53
61
  api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
62
+ auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
63
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
64
+ access_expires_at:
65
+ typeof raw.access_expires_at === 'number' ? raw.access_expires_at : undefined,
66
+ refresh_expires_at:
67
+ typeof raw.refresh_expires_at === 'number' ? raw.refresh_expires_at : undefined,
54
68
  },
55
69
  },
56
70
  };
@@ -90,6 +104,18 @@ export function ensureProfile(config, profileName) {
90
104
  return normalized;
91
105
  }
92
106
 
107
+ function isLocalApiBase(value) {
108
+ if (typeof value !== 'string' || !value) {
109
+ return false;
110
+ }
111
+ try {
112
+ const parsed = new URL(value);
113
+ return ['http:', 'https:'].includes(parsed.protocol) && LOCAL_API_HOSTS.has(parsed.hostname);
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
93
119
  export function getApiBase(config, profileName, override) {
94
120
  if (override) {
95
121
  return override;
@@ -103,12 +129,12 @@ export function getApiBase(config, profileName, override) {
103
129
  const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
104
130
 
105
131
  // Conductor assigns dynamic local ports. When a workspace is running under
106
- // Conductor, treat the legacy localhost:3001 profile value as stale and
107
- // transparently retarget the CLI at the active backend port.
132
+ // Conductor, treat saved localhost profile values as stale and transparently
133
+ // retarget the CLI at the active backend port.
108
134
  if (
109
135
  Number.isInteger(conductorPort) &&
110
136
  conductorPort > 0 &&
111
- (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase))
137
+ (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
112
138
  ) {
113
139
  return `http://localhost:${conductorPort + 1}`;
114
140
  }
@@ -166,6 +192,7 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
166
192
  const profileName = getCurrentProfileName(config, globalOptions.profile);
167
193
  const apiBase = getApiBase(config, profileName, globalOptions.apiBase);
168
194
  const jwt = getJwt(config, profileName);
195
+ const profile = getProfile(config, profileName);
169
196
  const agentMode = isAgentMode(globalOptions);
170
197
  const nonInteractive = isNonInteractive(globalOptions);
171
198
  const outputMode = resolveOutputMode(globalOptions);
@@ -178,8 +205,8 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
178
205
  exitCode: EXIT_CODES.auth,
179
206
  hints: [
180
207
  {
181
- command: 'notis auth login --jwt <token>',
182
- reason: 'Configure a token for non-interactive use',
208
+ command: 'Open the Notis desktop app and sign in',
209
+ reason: 'Set NOTIS_JWT or sign in through the Notis desktop app',
183
210
  },
184
211
  ],
185
212
  });
@@ -190,6 +217,10 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
190
217
  profileName,
191
218
  apiBase,
192
219
  jwt,
220
+ authMode: profile.auth_mode,
221
+ refreshToken: profile.refresh_token,
222
+ accessExpiresAt: profile.access_expires_at,
223
+ refreshExpiresAt: profile.refresh_expires_at,
193
224
  agentMode,
194
225
  nonInteractive,
195
226
  outputMode,
@@ -1,5 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { CliError, EXIT_CODES } from './errors.js';
3
+ import {
4
+ DEFAULT_PROFILE,
5
+ ensureProfile,
6
+ getProfile,
7
+ loadConfig,
8
+ saveConfig,
9
+ } from './profiles.js';
3
10
 
4
11
  const META_TOOL_NAMES = new Set([
5
12
  'notis_find_toolkits',
@@ -44,8 +51,8 @@ function normalizeBackendError(status, payload) {
44
51
  exitCode: EXIT_CODES.auth,
45
52
  hints: [
46
53
  {
47
- command: 'notis auth login --jwt <token>',
48
- reason: 'Refresh the stored JWT for this profile',
54
+ command: 'Open the Notis desktop app and sign in',
55
+ reason: 'Open the Notis desktop app to refresh CLI auth, or set NOTIS_JWT',
49
56
  },
50
57
  ],
51
58
  details: payload || {},
@@ -97,6 +104,94 @@ function normalizeBackendError(status, payload) {
97
104
  });
98
105
  }
99
106
 
107
+ function shouldAttemptDevPortalRefresh(runtime, { force = false } = {}) {
108
+ if (runtime.authMode !== 'dev_portal' || !runtime.refreshToken || !runtime.apiBase) {
109
+ return false;
110
+ }
111
+
112
+ if (force) {
113
+ return true;
114
+ }
115
+
116
+ if (typeof runtime.accessExpiresAt !== 'number' || runtime.accessExpiresAt <= 0) {
117
+ return false;
118
+ }
119
+
120
+ const thresholdSeconds = 60;
121
+ return runtime.accessExpiresAt - Math.floor(Date.now() / 1000) <= thresholdSeconds;
122
+ }
123
+
124
+ function persistDevPortalRuntimeAuth(runtime, session) {
125
+ const config = ensureProfile(loadConfig(), runtime.profileName || DEFAULT_PROFILE);
126
+ const profileName = runtime.profileName || DEFAULT_PROFILE;
127
+ config.current_profile = profileName;
128
+ config.profiles[profileName] = {
129
+ ...config.profiles[profileName],
130
+ jwt: session.access_token,
131
+ api_base: runtime.apiBase,
132
+ auth_mode: 'dev_portal',
133
+ refresh_token: session.refresh_token,
134
+ access_expires_at: session.access_expires_at,
135
+ refresh_expires_at: session.refresh_expires_at,
136
+ };
137
+ saveConfig(config);
138
+
139
+ runtime.jwt = session.access_token;
140
+ runtime.authMode = 'dev_portal';
141
+ runtime.refreshToken = session.refresh_token;
142
+ runtime.accessExpiresAt = session.access_expires_at;
143
+ runtime.refreshExpiresAt = session.refresh_expires_at;
144
+ }
145
+
146
+ function reloadJwtFromConfig(runtime) {
147
+ const profileName = runtime.profileName || DEFAULT_PROFILE;
148
+ let profile;
149
+ try {
150
+ profile = getProfile(loadConfig(), profileName);
151
+ } catch {
152
+ return false;
153
+ }
154
+ const nextJwt = typeof profile.jwt === 'string' && profile.jwt ? profile.jwt : null;
155
+ if (!nextJwt || nextJwt === runtime.jwt) {
156
+ return false;
157
+ }
158
+ runtime.jwt = nextJwt;
159
+ runtime.authMode = profile.auth_mode;
160
+ runtime.refreshToken = profile.refresh_token;
161
+ runtime.accessExpiresAt = profile.access_expires_at;
162
+ runtime.refreshExpiresAt = profile.refresh_expires_at;
163
+ return true;
164
+ }
165
+
166
+ async function maybeRefreshDevPortalAuth(runtime, { force = false } = {}) {
167
+ if (!shouldAttemptDevPortalRefresh(runtime, { force })) {
168
+ return false;
169
+ }
170
+
171
+ const response = await fetch(`${runtime.apiBase}/portal_auth/dev-refresh`, {
172
+ method: 'POST',
173
+ headers: {
174
+ 'Content-Type': 'application/json',
175
+ 'X-Notis-CLI-Version': runtime.cliVersion,
176
+ },
177
+ body: JSON.stringify({ refresh_token: runtime.refreshToken }),
178
+ });
179
+
180
+ let payload = null;
181
+ try {
182
+ payload = await response.json();
183
+ } catch {
184
+ payload = null;
185
+ }
186
+
187
+ if (!response.ok || !payload?.session?.access_token || !payload?.session?.refresh_token) {
188
+ throw normalizeBackendError(response.status, payload);
189
+ }
190
+
191
+ persistDevPortalRuntimeAuth(runtime, payload.session);
192
+ return true;
193
+ }
194
+
100
195
  export async function httpRequest({
101
196
  runtime,
102
197
  method = 'POST',
@@ -104,9 +199,16 @@ export async function httpRequest({
104
199
  body,
105
200
  requireAuth = true,
106
201
  }) {
107
- const controller = new AbortController();
108
- const timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
202
+ await maybeRefreshDevPortalAuth(runtime);
203
+
204
+ let controller = new AbortController();
205
+ let timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
109
206
  const requestId = `req_${randomUUID().replace(/-/g, '')}`;
207
+ const resetTimeout = () => {
208
+ clearTimeout(timeout);
209
+ controller = new AbortController();
210
+ timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
211
+ };
110
212
 
111
213
  const headers = {
112
214
  'Content-Type': 'application/json',
@@ -119,13 +221,12 @@ export async function httpRequest({
119
221
  }
120
222
 
121
223
  try {
122
- const response = await fetch(`${runtime.apiBase}${path}`, {
224
+ let response = await fetch(`${runtime.apiBase}${path}`, {
123
225
  method,
124
226
  headers,
125
227
  body: body ? JSON.stringify(body) : undefined,
126
228
  signal: controller.signal,
127
229
  });
128
- clearTimeout(timeout);
129
230
 
130
231
  let payload = null;
131
232
  try {
@@ -134,6 +235,30 @@ export async function httpRequest({
134
235
  payload = null;
135
236
  }
136
237
 
238
+ if (response.status === 401) {
239
+ const refreshed = (await maybeRefreshDevPortalAuth(runtime, { force: true }))
240
+ || reloadJwtFromConfig(runtime);
241
+ if (refreshed) {
242
+ if (requireAuth && runtime.jwt) {
243
+ headers.Authorization = `Bearer ${runtime.jwt}`;
244
+ }
245
+ resetTimeout();
246
+ response = await fetch(`${runtime.apiBase}${path}`, {
247
+ method,
248
+ headers,
249
+ body: body ? JSON.stringify(body) : undefined,
250
+ signal: controller.signal,
251
+ });
252
+ try {
253
+ payload = await response.json();
254
+ } catch {
255
+ payload = null;
256
+ }
257
+ }
258
+ }
259
+
260
+ clearTimeout(timeout);
261
+
137
262
  if (!response.ok) {
138
263
  throw normalizeBackendError(response.status, payload);
139
264
  }
@@ -0,0 +1,211 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Notis App Harness</title>
6
+ <link rel="stylesheet" href="./bundle/app.css">
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "react": "https://esm.sh/react@{{REACT_VERSION}}",
11
+ "react-dom": "https://esm.sh/react-dom@{{REACT_VERSION}}",
12
+ "react/jsx-runtime": "https://esm.sh/react@{{REACT_VERSION}}/jsx-runtime",
13
+ "react-dom/client": "https://esm.sh/react-dom@{{REACT_VERSION}}/client"
14
+ }
15
+ }
16
+ </script>
17
+ <style>
18
+ body { margin: 0; font-family: system-ui, sans-serif; }
19
+ #harness-status { position: fixed; top: 0; left: 0; right: 0; padding: 4px 8px; font-size: 11px; background: #111; color: #0f0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; z-index: 9999; }
20
+ #root { padding-top: 24px; min-height: 100vh; }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div id="harness-status">harness: booting</div>
25
+ <div id="root"></div>
26
+ <script type="module">
27
+ const routeExport = {{ROUTE_EXPORT}};
28
+ const descriptor = {{RUNTIME_DESCRIPTOR}};
29
+ const mode = {{MODE}};
30
+ const apiBase = {{API_BASE}};
31
+ const jwt = {{JWT}};
32
+ const status = document.getElementById('harness-status');
33
+ const setStatus = (msg, color = '#0f0') => {
34
+ status.textContent = `harness: ${msg}`;
35
+ status.style.color = color;
36
+ };
37
+
38
+ window.__harness = {
39
+ errors: [],
40
+ runtimeCalls: [],
41
+ renderStarted: false,
42
+ mounted: false,
43
+ mode,
44
+ route: descriptor.route && descriptor.route.slug,
45
+ };
46
+
47
+ window.addEventListener('error', (event) => {
48
+ window.__harness.errors.push({
49
+ type: 'window.error',
50
+ message: event.message,
51
+ stack: event.error && event.error.stack,
52
+ });
53
+ });
54
+ window.addEventListener('unhandledrejection', (event) => {
55
+ window.__harness.errors.push({
56
+ type: 'unhandledrejection',
57
+ reason: String((event.reason && event.reason.stack) || event.reason),
58
+ });
59
+ });
60
+
61
+ function record(op, args) {
62
+ window.__harness.runtimeCalls.push({ op, args });
63
+ }
64
+
65
+ async function readJsonOrSnippet(response) {
66
+ const text = await response.text().catch(() => '');
67
+ if (!text) return { payload: null, snippet: null };
68
+ try {
69
+ return { payload: JSON.parse(text), snippet: null };
70
+ } catch {
71
+ const trimmed = text.trim();
72
+ return { payload: null, snippet: trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed };
73
+ }
74
+ }
75
+
76
+ async function runtimeQuery(body) {
77
+ if (!apiBase || !jwt || !descriptor.app || !descriptor.app.id) {
78
+ throw new Error('live harness mode requires apiBase, jwt, and app id');
79
+ }
80
+ const response = await fetch(`${String(apiBase).replace(/\/$/, '')}/portal_views/runtime_query`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ Authorization: `Bearer ${jwt}`,
85
+ },
86
+ body: JSON.stringify({
87
+ app_id: descriptor.app.id,
88
+ route_slug: descriptor.route && descriptor.route.slug,
89
+ ...body,
90
+ }),
91
+ });
92
+ const { payload, snippet } = await readJsonOrSnippet(response);
93
+ if (!response.ok) {
94
+ const message = (payload && (payload.error || payload.message)) || snippet || `status ${response.status}`;
95
+ throw new Error(`Runtime request failed: ${message}`);
96
+ }
97
+ if (!payload) {
98
+ throw new Error(snippet ? `Runtime response was not JSON: ${snippet}` : 'Runtime response was empty');
99
+ }
100
+ if (payload.status === 'error' || payload.error) {
101
+ throw new Error(payload.message || payload.error || 'Runtime request failed');
102
+ }
103
+ return payload;
104
+ }
105
+
106
+ function stubRuntime() {
107
+ return {
108
+ app: descriptor.app,
109
+ route: descriptor.route,
110
+ context: descriptor.context || {},
111
+ navigate: (args) => record('navigate', args),
112
+ registerTopBarSearch: () => {},
113
+ setTopBarSearchValue: () => {},
114
+ setTopBarSearchLoading: () => {},
115
+ listTools: async () => {
116
+ record('listTools', {});
117
+ return descriptor.tools || [];
118
+ },
119
+ callTool: async (name, args) => {
120
+ record('callTool', { name, arguments: args || {} });
121
+ return { ok: true, result: null };
122
+ },
123
+ request: async (path, options) => {
124
+ record('request', { path, options });
125
+ return null;
126
+ },
127
+ };
128
+ }
129
+
130
+ function liveRuntime() {
131
+ return {
132
+ ...stubRuntime(),
133
+ listTools: async () => {
134
+ record('listTools', {});
135
+ const result = await runtimeQuery({ method: 'tools/list' });
136
+ return result.tools || [];
137
+ },
138
+ callTool: async (name, args) => {
139
+ record('callTool', { name, arguments: args || {} });
140
+ return runtimeQuery({ method: 'tools/call', name, arguments: args || {} });
141
+ },
142
+ request: async (path, options) => {
143
+ record('request', { path, options });
144
+ const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
145
+ method: (options && options.method) || 'GET',
146
+ headers: {
147
+ Authorization: `Bearer ${jwt}`,
148
+ ...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
149
+ ...((options && options.headers) || {}),
150
+ },
151
+ body: options && options.body ? JSON.stringify(options.body) : undefined,
152
+ });
153
+ const { payload, snippet } = await readJsonOrSnippet(response);
154
+ if (!response.ok) {
155
+ throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
156
+ }
157
+ return payload;
158
+ },
159
+ };
160
+ }
161
+
162
+ try {
163
+ setStatus('loading react');
164
+ const React = await import('react');
165
+ const { createRoot } = await import('react-dom/client');
166
+
167
+ setStatus('loading bundle');
168
+ const bundle = await import('./bundle/app.js');
169
+
170
+ setStatus('binding runtime context');
171
+ const RuntimeContext = globalThis[Symbol.for('notis.sdk.runtime_context')];
172
+ if (!RuntimeContext) {
173
+ throw new Error('runtime context Symbol not initialized by bundle');
174
+ }
175
+
176
+ const Page = bundle[routeExport];
177
+ if (!Page) {
178
+ throw new Error(`bundle has no ${routeExport} export`);
179
+ }
180
+ const Shell = bundle.__AppShell;
181
+ const runtime = mode === 'live' ? liveRuntime() : stubRuntime();
182
+ const Root = () =>
183
+ React.createElement(
184
+ RuntimeContext.Provider,
185
+ { value: runtime },
186
+ Shell
187
+ ? React.createElement(Shell, null, React.createElement(Page, null))
188
+ : React.createElement(Page, null),
189
+ );
190
+
191
+ setStatus('mounting');
192
+ window.__harness.renderStarted = true;
193
+ createRoot(document.getElementById('root')).render(React.createElement(Root));
194
+
195
+ requestAnimationFrame(() => requestAnimationFrame(() => {
196
+ window.__harness.mounted = true;
197
+ const errors = window.__harness.errors.length;
198
+ const calls = window.__harness.runtimeCalls.length;
199
+ setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors ? '#f55' : '#0f0');
200
+ }));
201
+ } catch (error) {
202
+ window.__harness.errors.push({
203
+ type: 'boot',
204
+ message: error && error.message ? error.message : String(error),
205
+ stack: error && error.stack,
206
+ });
207
+ setStatus(`boot failed: ${error && error.message ? error.message : String(error)}`, '#f55');
208
+ }
209
+ </script>
210
+ </body>
211
+ </html>
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
@@ -0,0 +1,6 @@
1
+ import '@notis/sdk/styles.css';
2
+ import './globals.css';
3
+
4
+ export default function AppShell({ children }: { children: React.ReactNode }) {
5
+ return children;
6
+ }
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useNotis, useTool } from '@notis/sdk';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7
+
8
+ type ItemDocument = {
9
+ id?: string;
10
+ document_id?: string;
11
+ title?: string;
12
+ properties?: Record<string, unknown>;
13
+ };
14
+
15
+ type QueryItemsArgs = {
16
+ database_slug: string;
17
+ query: {
18
+ page_size?: number;
19
+ };
20
+ };
21
+
22
+ type QueryItemsResult = {
23
+ documents?: ItemDocument[];
24
+ };
25
+
26
+ export default function HomePage() {
27
+ const { app, ready } = useNotis();
28
+ const queryItems = useTool<QueryItemsArgs, QueryItemsResult>('notis-default-query');
29
+ const [documents, setDocuments] = useState<ItemDocument[]>([]);
30
+
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ queryItems
34
+ .call({ database_slug: 'items', query: { page_size: 25 } })
35
+ .then((result) => {
36
+ if (!cancelled) setDocuments(result.documents || []);
37
+ })
38
+ .catch(() => {
39
+ if (!cancelled) setDocuments([]);
40
+ });
41
+ return () => {
42
+ cancelled = true;
43
+ };
44
+ }, [queryItems.call]);
45
+
46
+ return (
47
+ <main className="notis-app-shell space-y-6">
48
+ <Card>
49
+ <CardHeader className="space-y-3">
50
+ <Badge variant="secondary" className="w-fit">Installed app</Badge>
51
+ <div className="space-y-2">
52
+ <CardTitle>{ready ? app?.name : 'Loading...'}</CardTitle>
53
+ <CardDescription>
54
+ {ready ? app?.description : 'Loading app metadata...'}
55
+ </CardDescription>
56
+ </div>
57
+ </CardHeader>
58
+ </Card>
59
+
60
+ <Card>
61
+ <CardHeader>
62
+ <CardTitle>Items</CardTitle>
63
+ <CardDescription>Use shadcn surfaces and portal tokens so the app feels native inside Notis.</CardDescription>
64
+ </CardHeader>
65
+ <CardContent>
66
+ {queryItems.loading ? (
67
+ <p className="text-sm text-muted-foreground">Loading...</p>
68
+ ) : documents.length === 0 ? (
69
+ <div className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
70
+ No items yet. Deploy the app and create some.
71
+ </div>
72
+ ) : (
73
+ <div className="space-y-3">
74
+ {documents.map((doc) => (
75
+ <div key={doc.id || doc.document_id || doc.title} className="rounded-xl border border-border bg-background px-4 py-3">
76
+ <div className="flex items-center justify-between gap-3">
77
+ <p className="font-medium">{doc.title || 'Untitled'}</p>
78
+ {doc.properties?.status ? (
79
+ <Badge variant="outline">{String(doc.properties.status)}</Badge>
80
+ ) : null}
81
+ </div>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ )}
86
+ </CardContent>
87
+ </Card>
88
+ </main>
89
+ );
90
+ }