@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.
- package/README.md +122 -158
- package/dist/scaffolds/notes/app/globals.css +3 -0
- package/dist/scaffolds/notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notes/app/page.tsx +1465 -0
- package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notes/components.json +20 -0
- package/dist/scaffolds/notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notes/notis.config.ts +31 -0
- package/dist/scaffolds/notes/package.json +31 -0
- package/dist/scaffolds/notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notes/tsconfig.json +22 -0
- package/dist/scaffolds/notes/vite.config.ts +10 -0
- package/dist/scaffolds.json +13 -0
- package/package.json +12 -2
- package/skills/notis-apps/SKILL.md +162 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +258 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +1029 -59
- package/src/command-specs/helpers.js +6 -41
- package/src/command-specs/index.js +1 -7
- package/src/command-specs/meta.js +7 -6
- package/src/command-specs/tools.js +29 -34
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +605 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +1037 -82
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +36 -5
- package/src/runtime/transport.js +131 -6
- package/template/.harness/index.html.tmpl +211 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +90 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/metadata/cover.png +0 -0
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
- package/template/notis.config.ts +37 -0
- package/template/package.json +31 -0
- package/template/packages/sdk/package.json +32 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/config.ts +71 -0
- package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/sdk/src/hooks/useTool.ts +64 -0
- package/template/packages/sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
- package/template/packages/sdk/src/index.ts +54 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +161 -0
- package/template/packages/sdk/src/styles.css +38 -0
- package/template/packages/sdk/src/ui.ts +15 -0
- package/template/packages/sdk/src/vite.ts +56 -0
- package/template/packages/sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +10 -0
- package/src/command-specs/auth.js +0 -178
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -272
|
@@ -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
|
+
}
|
package/src/runtime/output.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -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
|
|
107
|
-
//
|
|
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: '
|
|
182
|
-
reason: '
|
|
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,
|
package/src/runtime/transport.js
CHANGED
|
@@ -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: '
|
|
48
|
-
reason: '
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|