@notis_ai/cli 0.2.0-beta.21.1 → 0.2.0-beta.32.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 (35) hide show
  1. package/README.md +36 -9
  2. package/package.json +2 -1
  3. package/src/command-specs/apps.js +780 -42
  4. package/src/command-specs/helpers.js +28 -3
  5. package/src/command-specs/index.js +1 -1
  6. package/src/command-specs/tools.js +33 -6
  7. package/src/runtime/agent-browser.js +192 -0
  8. package/src/runtime/app-boundary-validator.js +132 -0
  9. package/src/runtime/app-dev-server.js +577 -0
  10. package/src/runtime/app-dev-sessions.js +87 -0
  11. package/src/runtime/app-platform.js +352 -17
  12. package/src/runtime/cli-mode.generated.js +4 -0
  13. package/src/runtime/cli-mode.js +29 -0
  14. package/src/runtime/output.js +2 -2
  15. package/src/runtime/ports.js +15 -0
  16. package/src/runtime/profiles.js +34 -3
  17. package/src/runtime/transport.js +129 -4
  18. package/template/.harness/index.html.tmpl +260 -0
  19. package/template/app/layout.tsx +1 -2
  20. package/template/notis.config.ts +16 -1
  21. package/template/package.json +1 -2
  22. package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
  23. package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  24. package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  25. package/template/packages/notis-sdk/src/config.ts +24 -1
  26. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +2 -1
  27. package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
  28. package/template/packages/notis-sdk/src/hooks/useNotis.ts +5 -3
  29. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +1 -1
  30. package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
  31. package/template/packages/notis-sdk/src/index.ts +20 -0
  32. package/template/packages/notis-sdk/src/provider.tsx +23 -24
  33. package/template/packages/notis-sdk/src/runtime.ts +43 -26
  34. package/template/packages/notis-sdk/src/styles.css +6 -91
  35. package/src/runtime/app-preview-server.js +0 -336
@@ -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',
@@ -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,260 @@
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
+ databases: descriptor.databases || [],
111
+ context: descriptor.context || { collectionItem: null },
112
+ navigate: (args) => record('navigate', args),
113
+ registerTopBarSearch: () => {},
114
+ setTopBarSearchValue: () => {},
115
+ setTopBarSearchLoading: () => {},
116
+ listTools: async () => {
117
+ record('listTools', {});
118
+ return descriptor.tools || [];
119
+ },
120
+ callTool: async (name, args) => {
121
+ record('callTool', { name, arguments: args || {} });
122
+ return { ok: true, result: null };
123
+ },
124
+ queryDatabase: async (args) => {
125
+ record('queryDatabase', args || {});
126
+ return { documents: [], next_offset: null };
127
+ },
128
+ getDocument: async (args) => {
129
+ record('getDocument', args || {});
130
+ return null;
131
+ },
132
+ upsertDocument: async (args) => {
133
+ record('upsertDocument', args || {});
134
+ return {
135
+ status: 'ok',
136
+ document: {
137
+ id: (args && args.documentId) || 'mock-doc',
138
+ title: (args && args.title) || '',
139
+ properties: (args && args.properties) || {},
140
+ databaseSlug: args && args.databaseSlug,
141
+ },
142
+ };
143
+ },
144
+ listCollectionItems: async (args) => {
145
+ record('listCollectionItems', args || {});
146
+ return { items: [] };
147
+ },
148
+ listCollectionTree: async (args) => {
149
+ record('listCollectionTree', args || {});
150
+ return { items: [] };
151
+ },
152
+ request: async (path, options) => {
153
+ record('request', { path, options });
154
+ return null;
155
+ },
156
+ };
157
+ }
158
+
159
+ function liveRuntime() {
160
+ return {
161
+ ...stubRuntime(),
162
+ listTools: async () => {
163
+ record('listTools', {});
164
+ const result = await runtimeQuery({ method: 'tools/list' });
165
+ return result.tools || [];
166
+ },
167
+ callTool: async (name, args) => {
168
+ record('callTool', { name, arguments: args || {} });
169
+ return runtimeQuery({ method: 'tools/call', name, arguments: args || {} });
170
+ },
171
+ queryDatabase: async (args) => {
172
+ record('queryDatabase', args || {});
173
+ const result = await runtimeQuery({ method: 'tools/call', name: 'notis_query_database', arguments: args || {} });
174
+ return result.result || result;
175
+ },
176
+ getDocument: async (args) => {
177
+ record('getDocument', args || {});
178
+ const result = await runtimeQuery({ method: 'tools/call', name: 'notis_get_document', arguments: args || {} });
179
+ return result.result || result;
180
+ },
181
+ upsertDocument: async (args) => {
182
+ record('upsertDocument', args || {});
183
+ const result = await runtimeQuery({ method: 'tools/call', name: 'notis_upsert_document', arguments: args || {} });
184
+ return result.result || result;
185
+ },
186
+ listCollectionItems: async (args) => {
187
+ record('listCollectionItems', args || {});
188
+ const result = await runtimeQuery({ method: 'tools/call', name: 'notis_list_collection_items', arguments: args || {} });
189
+ return result.result || result;
190
+ },
191
+ request: async (path, options) => {
192
+ record('request', { path, options });
193
+ const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
194
+ method: (options && options.method) || 'GET',
195
+ headers: {
196
+ Authorization: `Bearer ${jwt}`,
197
+ ...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
198
+ ...((options && options.headers) || {}),
199
+ },
200
+ body: options && options.body ? JSON.stringify(options.body) : undefined,
201
+ });
202
+ const { payload, snippet } = await readJsonOrSnippet(response);
203
+ if (!response.ok) {
204
+ throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
205
+ }
206
+ return payload;
207
+ },
208
+ };
209
+ }
210
+
211
+ try {
212
+ setStatus('loading react');
213
+ const React = await import('react');
214
+ const { createRoot } = await import('react-dom/client');
215
+
216
+ setStatus('loading bundle');
217
+ const bundle = await import('./bundle/app.js');
218
+
219
+ setStatus('binding runtime context');
220
+ const RuntimeContext = globalThis[Symbol.for('notis.sdk.runtime_context')];
221
+ if (!RuntimeContext) {
222
+ throw new Error('runtime context Symbol not initialized by bundle');
223
+ }
224
+
225
+ const Page = bundle[routeExport];
226
+ if (!Page) {
227
+ throw new Error(`bundle has no ${routeExport} export`);
228
+ }
229
+ const Shell = bundle.__AppShell;
230
+ const runtime = mode === 'live' ? liveRuntime() : stubRuntime();
231
+ const Root = () =>
232
+ React.createElement(
233
+ RuntimeContext.Provider,
234
+ { value: runtime },
235
+ Shell
236
+ ? React.createElement(Shell, null, React.createElement(Page, null))
237
+ : React.createElement(Page, null),
238
+ );
239
+
240
+ setStatus('mounting');
241
+ window.__harness.renderStarted = true;
242
+ createRoot(document.getElementById('root')).render(React.createElement(Root));
243
+
244
+ requestAnimationFrame(() => requestAnimationFrame(() => {
245
+ window.__harness.mounted = true;
246
+ const errors = window.__harness.errors.length;
247
+ const calls = window.__harness.runtimeCalls.length;
248
+ setStatus(`mounted (errors=${errors}, runtimeCalls=${calls})`, errors ? '#f55' : '#0f0');
249
+ }));
250
+ } catch (error) {
251
+ window.__harness.errors.push({
252
+ type: 'boot',
253
+ message: error && error.message ? error.message : String(error),
254
+ stack: error && error.stack,
255
+ });
256
+ setStatus(`boot failed: ${error && error.message ? error.message : String(error)}`, '#f55');
257
+ }
258
+ </script>
259
+ </body>
260
+ </html>
@@ -1,7 +1,6 @@
1
- import { NotisProvider } from '@notis/sdk';
2
1
  import '@notis/sdk/styles.css';
3
2
  import './globals.css';
4
3
 
5
4
  export default function AppShell({ children }: { children: React.ReactNode }) {
6
- return <NotisProvider>{children}</NotisProvider>;
5
+ return children;
7
6
  }
@@ -8,7 +8,22 @@ export default defineNotisApp({
8
8
  databases: ['items'],
9
9
 
10
10
  routes: [
11
- { path: '/', name: 'Home', icon: 'lucide:home', default: true },
11
+ { path: '/', slug: 'home', name: 'Home', icon: 'lucide:home', default: true },
12
+ // Example collection tree route:
13
+ // {
14
+ // path: '/notes',
15
+ // slug: 'notes',
16
+ // name: 'Notes',
17
+ // collection: {
18
+ // database: 'notes',
19
+ // titleProperty: 'Name',
20
+ // parentProperty: 'Parent',
21
+ // sidebar: {
22
+ // mode: 'tree',
23
+ // allowCreate: true,
24
+ // },
25
+ // },
26
+ // },
12
27
  ],
13
28
 
14
29
  tools: [
@@ -5,8 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
- "build": "vite build",
9
- "preview": "vite preview"
8
+ "build": "vite build"
10
9
  },
11
10
  "dependencies": {
12
11
  "@notis/sdk": "file:./packages/notis-sdk",