@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
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import { createInterface } from 'node:readline/promises';
|
|
2
1
|
import { randomUUID } from 'node:crypto';
|
|
3
2
|
import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
|
|
4
3
|
import { callTool, httpRequest } from '../runtime/transport.js';
|
|
5
|
-
import {
|
|
6
|
-
DEFAULT_PROFILE,
|
|
7
|
-
ensureProfile,
|
|
8
|
-
loadConfig,
|
|
9
|
-
saveConfig,
|
|
10
|
-
} from '../runtime/profiles.js';
|
|
11
4
|
|
|
12
5
|
export function parseJson(value, label) {
|
|
13
6
|
try {
|
|
@@ -41,16 +34,6 @@ export function normalizeToolkits(value) {
|
|
|
41
34
|
.filter(Boolean);
|
|
42
35
|
}
|
|
43
36
|
|
|
44
|
-
export async function promptForJwt() {
|
|
45
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
-
try {
|
|
47
|
-
const jwt = await rl.question('Paste your Notis JWT: ');
|
|
48
|
-
return jwt.trim();
|
|
49
|
-
} finally {
|
|
50
|
-
rl.close();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
37
|
export function nextIdempotencyKey(globalOptions) {
|
|
55
38
|
return globalOptions.idempotencyKey || randomUUID();
|
|
56
39
|
}
|
|
@@ -105,33 +88,15 @@ export async function healthCheck(runtime) {
|
|
|
105
88
|
});
|
|
106
89
|
}
|
|
107
90
|
|
|
108
|
-
export function
|
|
109
|
-
|
|
110
|
-
const resolvedName = profileName || DEFAULT_PROFILE;
|
|
111
|
-
config.profiles[resolvedName] = {
|
|
112
|
-
...config.profiles[resolvedName],
|
|
113
|
-
...(jwt ? { jwt } : {}),
|
|
114
|
-
...(apiBase ? { api_base: apiBase } : {}),
|
|
115
|
-
};
|
|
116
|
-
if (setCurrent) {
|
|
117
|
-
config.current_profile = resolvedName;
|
|
118
|
-
}
|
|
119
|
-
saveConfig(config);
|
|
120
|
-
return config;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function clearStoredJwt(profileName) {
|
|
124
|
-
const config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
|
|
125
|
-
delete config.profiles[profileName || DEFAULT_PROFILE].jwt;
|
|
126
|
-
saveConfig(config);
|
|
127
|
-
return config;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export async function fetchToolSchema(runtime, toolName) {
|
|
91
|
+
export async function fetchToolSchema(runtime, toolName, rawToolkits) {
|
|
92
|
+
const toolkits = normalizeToolkits(rawToolkits);
|
|
131
93
|
const result = await runToolCommand({
|
|
132
94
|
runtime,
|
|
133
95
|
toolName: 'notis_find_tools',
|
|
134
|
-
arguments_: {
|
|
96
|
+
arguments_: {
|
|
97
|
+
query: toolName,
|
|
98
|
+
...(toolkits.length ? { toolkits } : {}),
|
|
99
|
+
},
|
|
135
100
|
});
|
|
136
101
|
const tools = result.payload.tools || [];
|
|
137
102
|
const match = tools.find((t) => t.name === toolName);
|
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import { authCommandSpecs } from './auth.js';
|
|
2
1
|
import { appsCommandSpecs } from './apps.js';
|
|
3
|
-
import { dbCommandSpecs } from './db.js';
|
|
4
2
|
import { toolsCommandSpecs } from './tools.js';
|
|
5
3
|
import { metaCommandSpecs } from './meta.js';
|
|
6
4
|
|
|
7
5
|
export const GROUP_SUMMARIES = {
|
|
8
|
-
|
|
9
|
-
apps: 'Build, preview, and deploy Notis Apps.',
|
|
10
|
-
db: 'List, query, and update native Notis Databases.',
|
|
6
|
+
apps: 'Develop, build, and deploy Notis Apps.',
|
|
11
7
|
tools: 'Discover and execute generic tools exposed through Notis.',
|
|
12
8
|
};
|
|
13
9
|
|
|
14
10
|
export const COMMAND_SPECS = [
|
|
15
|
-
...authCommandSpecs,
|
|
16
11
|
...appsCommandSpecs,
|
|
17
|
-
...dbCommandSpecs,
|
|
18
12
|
...toolsCommandSpecs,
|
|
19
13
|
...metaCommandSpecs,
|
|
20
14
|
];
|
|
@@ -29,10 +29,10 @@ async function doctorHandler(ctx) {
|
|
|
29
29
|
|
|
30
30
|
const hints = [];
|
|
31
31
|
if (checks.auth === 'missing') {
|
|
32
|
-
hints.push({ command: '
|
|
32
|
+
hints.push({ command: 'Open the Notis desktop app and sign in', reason: 'Configure CLI credentials' });
|
|
33
33
|
}
|
|
34
34
|
if (checks.health === 'error') {
|
|
35
|
-
hints.push({ command: '
|
|
35
|
+
hints.push({ command: 'Open the Notis desktop app and sign in again', reason: 'Refresh local CLI configuration' });
|
|
36
36
|
}
|
|
37
37
|
if (checks.tool_roundtrip === 'error') {
|
|
38
38
|
hints.push({ command: 'notis whoami', reason: 'Verify your account and permissions' });
|
|
@@ -117,7 +117,7 @@ export const metaCommandSpecs = [
|
|
|
117
117
|
output_schema: 'Returns profile, api_base, user_id, toolkit count, and CLI version.',
|
|
118
118
|
mutates: false,
|
|
119
119
|
idempotent: true,
|
|
120
|
-
related_commands: ['notis
|
|
120
|
+
related_commands: ['notis doctor'],
|
|
121
121
|
backend_call: { type: 'tool', name: 'notis_find_toolkits' },
|
|
122
122
|
handler: whoamiHandler,
|
|
123
123
|
},
|
|
@@ -130,7 +130,8 @@ export const metaCommandSpecs = [
|
|
|
130
130
|
output_schema: 'Returns config, auth, health, and roundtrip check statuses.',
|
|
131
131
|
mutates: false,
|
|
132
132
|
idempotent: true,
|
|
133
|
-
|
|
133
|
+
require_auth: false,
|
|
134
|
+
related_commands: ['notis tools toolkits'],
|
|
134
135
|
backend_call: { type: 'health+tool_roundtrip' },
|
|
135
136
|
handler: doctorHandler,
|
|
136
137
|
},
|
|
@@ -139,10 +140,10 @@ export const metaCommandSpecs = [
|
|
|
139
140
|
summary: 'Describe a first-class CLI command in detail.',
|
|
140
141
|
when_to_use: 'Use this when an agent or human needs the exact shape, examples, and semantics of a command.',
|
|
141
142
|
args_schema: {
|
|
142
|
-
arguments: [{ token: '<command...>', key: 'commandPath', description: 'Command path to describe, such as "apps
|
|
143
|
+
arguments: [{ token: '<command...>', key: 'commandPath', description: 'Command path to describe, such as "apps deploy".' }],
|
|
143
144
|
options: [],
|
|
144
145
|
},
|
|
145
|
-
examples: ['notis describe apps
|
|
146
|
+
examples: ['notis describe apps deploy', 'notis describe tools exec'],
|
|
146
147
|
output_schema: 'Returns the command spec metadata for the requested command.',
|
|
147
148
|
mutates: false,
|
|
148
149
|
idempotent: true,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
nextIdempotencyKey,
|
|
7
7
|
parseJson,
|
|
8
8
|
parseMaybeJson,
|
|
9
|
+
normalizeToolkits,
|
|
9
10
|
resolveSearchToolkits,
|
|
10
11
|
runToolCommand,
|
|
11
12
|
validateArguments,
|
|
@@ -33,6 +34,22 @@ async function resolveJsonInput(value, label) {
|
|
|
33
34
|
return parseJson(value, label);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
const LONG_RUNNING_TOOL_TIMEOUT_MS = 600000;
|
|
38
|
+
const LONG_RUNNING_TOOL_NAMES = new Set([
|
|
39
|
+
'notis-default-generate_image_openai',
|
|
40
|
+
'notis-default-edit_image_openai',
|
|
41
|
+
'notis-default-generate_image_gemini',
|
|
42
|
+
'notis-default-edit_image_gemini',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function ensureLongRunningToolTimeout(ctx, toolNames) {
|
|
46
|
+
const names = Array.isArray(toolNames) ? toolNames : [toolNames];
|
|
47
|
+
if (!names.some((name) => LONG_RUNNING_TOOL_NAMES.has(name))) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
ctx.runtime.timeoutMs = Math.max(ctx.runtime.timeoutMs || 0, LONG_RUNNING_TOOL_TIMEOUT_MS);
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
async function toolsToolkitsHandler(ctx) {
|
|
37
54
|
const toolkits = await fetchToolkits(ctx.runtime);
|
|
38
55
|
return ctx.output.emitSuccess({
|
|
@@ -105,11 +122,14 @@ async function toolsDescribeHandler(ctx) {
|
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
async function toolsExecHandler(ctx) {
|
|
125
|
+
ensureLongRunningToolTimeout(ctx, ctx.args.toolName);
|
|
126
|
+
const toolkits = normalizeToolkits(ctx.options.toolkits);
|
|
127
|
+
|
|
108
128
|
if (ctx.options.getSchema) {
|
|
109
|
-
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName);
|
|
129
|
+
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName, ctx.options.toolkits);
|
|
110
130
|
return ctx.output.emitSuccess({
|
|
111
131
|
command: ctx.spec.command_path.join(' '),
|
|
112
|
-
data: { tool },
|
|
132
|
+
data: { tool, toolkits },
|
|
113
133
|
humanSummary: `Schema for ${ctx.args.toolName}`,
|
|
114
134
|
hints: [
|
|
115
135
|
{ command: `notis tools exec ${ctx.args.toolName} --dry-run --arguments '{}'`, reason: 'Validate arguments before executing' },
|
|
@@ -124,13 +144,13 @@ async function toolsExecHandler(ctx) {
|
|
|
124
144
|
: parseMaybeJson(rawArguments, 'arguments') || {};
|
|
125
145
|
|
|
126
146
|
if (ctx.options.dryRun) {
|
|
127
|
-
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName);
|
|
147
|
+
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName, ctx.options.toolkits);
|
|
128
148
|
const errors = validateArguments(tool.parameters, args);
|
|
129
149
|
|
|
130
150
|
if (errors.length) {
|
|
131
151
|
return ctx.output.emitSuccess({
|
|
132
152
|
command: ctx.spec.command_path.join(' '),
|
|
133
|
-
data: { valid: false, errors, tool: ctx.args.toolName },
|
|
153
|
+
data: { valid: false, errors, tool: ctx.args.toolName, toolkits },
|
|
134
154
|
humanSummary: `Dry run failed: ${errors.length} validation error(s)`,
|
|
135
155
|
hints: [
|
|
136
156
|
{ command: `notis tools exec ${ctx.args.toolName} --get-schema`, reason: 'Review the full parameter schema' },
|
|
@@ -141,7 +161,7 @@ async function toolsExecHandler(ctx) {
|
|
|
141
161
|
|
|
142
162
|
return ctx.output.emitSuccess({
|
|
143
163
|
command: ctx.spec.command_path.join(' '),
|
|
144
|
-
data: { valid: true, tool: ctx.args.toolName, arguments: args },
|
|
164
|
+
data: { valid: true, tool: ctx.args.toolName, arguments: args, toolkits },
|
|
145
165
|
humanSummary: `Dry run passed for ${ctx.args.toolName}`,
|
|
146
166
|
hints: [
|
|
147
167
|
{ command: `notis tools exec ${ctx.args.toolName} --arguments '${JSON.stringify(args)}'`, reason: 'Execute with these arguments' },
|
|
@@ -151,39 +171,13 @@ async function toolsExecHandler(ctx) {
|
|
|
151
171
|
|
|
152
172
|
const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
|
|
153
173
|
|
|
154
|
-
if (ctx.options.watch) {
|
|
155
|
-
const intervalSec = Math.max(1, parseInt(ctx.options.watch, 10));
|
|
156
|
-
const intervalMs = intervalSec * 1000;
|
|
157
|
-
process.on('SIGINT', () => process.exit(0));
|
|
158
|
-
|
|
159
|
-
while (true) {
|
|
160
|
-
try {
|
|
161
|
-
const result = await runToolCommand({
|
|
162
|
-
runtime: ctx.runtime,
|
|
163
|
-
toolName: 'notis_execute_tool',
|
|
164
|
-
arguments_: { tool_name: ctx.args.toolName, arguments: args },
|
|
165
|
-
mutating: true,
|
|
166
|
-
idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
|
|
167
|
-
});
|
|
168
|
-
ctx.output.emitSuccess({
|
|
169
|
-
command: ctx.spec.command_path.join(' '),
|
|
170
|
-
data: result.payload,
|
|
171
|
-
humanSummary: `[${new Date().toISOString()}] ${ctx.args.toolName}`,
|
|
172
|
-
renderHuman: () => JSON.stringify(result.payload, null, 2),
|
|
173
|
-
});
|
|
174
|
-
} catch (error) {
|
|
175
|
-
process.stderr.write(`[${new Date().toISOString()}] Error: ${error.message}\n`);
|
|
176
|
-
}
|
|
177
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
174
|
const result = await runToolCommand({
|
|
182
175
|
runtime: ctx.runtime,
|
|
183
176
|
toolName: 'notis_execute_tool',
|
|
184
177
|
arguments_: {
|
|
185
178
|
tool_name: ctx.args.toolName,
|
|
186
179
|
arguments: args,
|
|
180
|
+
...(toolkits.length ? { toolkits } : {}),
|
|
187
181
|
},
|
|
188
182
|
mutating: true,
|
|
189
183
|
idempotencyKey,
|
|
@@ -209,6 +203,7 @@ async function toolsExecParallelHandler(ctx) {
|
|
|
209
203
|
throw usageError(`calls[${i}] missing required "tool_name" string`);
|
|
210
204
|
}
|
|
211
205
|
}
|
|
206
|
+
ensureLongRunningToolTimeout(ctx, calls.map((call) => call.tool_name));
|
|
212
207
|
|
|
213
208
|
const data = await Promise.all(
|
|
214
209
|
calls.map((call) =>
|
|
@@ -333,16 +328,16 @@ export const toolsCommandSpecs = [
|
|
|
333
328
|
{ flags: '--arguments <json>', description: 'JSON object, @file path, or - for stdin.' },
|
|
334
329
|
{ flags: '--get-schema', description: 'Display the tool parameter schema without executing.' },
|
|
335
330
|
{ flags: '--dry-run', description: 'Validate arguments against the tool schema without executing.' },
|
|
336
|
-
{ flags: '--
|
|
331
|
+
{ flags: '--toolkits <csv-or-json>', description: 'Optional toolkit namespace(s) to use when resolving the tool.' },
|
|
337
332
|
],
|
|
338
333
|
},
|
|
339
334
|
examples: [
|
|
340
335
|
'notis tools exec notis-default-query --arguments \'{"database_slug":"tasks","query":{}}\'',
|
|
336
|
+
'notis tools exec notis-default-get_database --arguments \'{"database_slug":"tasks"}\'',
|
|
341
337
|
'notis tools exec notis-default-query --get-schema',
|
|
342
338
|
'notis tools exec notis-default-query --dry-run --arguments \'{"database_slug":"tasks","query":{}}\'',
|
|
343
339
|
'notis tools exec notis-default-query --arguments @query.json',
|
|
344
340
|
'notis tools exec notis-default-query --arguments - < query.json',
|
|
345
|
-
'notis tools exec notis-default-query --watch 10 --arguments \'{"database_slug":"tasks","query":{}}\'',
|
|
346
341
|
],
|
|
347
342
|
output_schema: 'Returns the raw tool execution payload.',
|
|
348
343
|
mutates: true,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function delay(ms) {
|
|
6
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function commandError(phase, result) {
|
|
10
|
+
return {
|
|
11
|
+
phase,
|
|
12
|
+
exit_code: result.exitCode,
|
|
13
|
+
stdout: result.stdout.trim(),
|
|
14
|
+
stderr: result.stderr.trim(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
|
|
19
|
+
return new Promise((resolvePromise) => {
|
|
20
|
+
const child = spawn('agent-browser', args, {
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
env: process.env,
|
|
23
|
+
});
|
|
24
|
+
let stdout = '';
|
|
25
|
+
let stderr = '';
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
timedOut = true;
|
|
29
|
+
child.kill('SIGTERM');
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
|
|
32
|
+
child.stdout.on('data', (chunk) => {
|
|
33
|
+
stdout += chunk.toString();
|
|
34
|
+
});
|
|
35
|
+
child.stderr.on('data', (chunk) => {
|
|
36
|
+
stderr += chunk.toString();
|
|
37
|
+
});
|
|
38
|
+
child.on('error', (error) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolvePromise({
|
|
41
|
+
exitCode: 1,
|
|
42
|
+
stdout,
|
|
43
|
+
stderr: stderr || error.message,
|
|
44
|
+
timedOut,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
child.on('close', (code) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
resolvePromise({
|
|
50
|
+
exitCode: timedOut ? 124 : (code ?? 1),
|
|
51
|
+
stdout,
|
|
52
|
+
stderr,
|
|
53
|
+
timedOut,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseAgentBrowserJson(stdout) {
|
|
60
|
+
const trimmed = stdout.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return JSON.parse(trimmed);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseHarnessFromEval(stdout) {
|
|
68
|
+
const payload = parseAgentBrowserJson(stdout);
|
|
69
|
+
const rawResult = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
70
|
+
if (rawResult == null || rawResult === 'null') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return typeof rawResult === 'string' ? JSON.parse(rawResult) : rawResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readHarness(sessionName, timeoutMs) {
|
|
77
|
+
const result = await runAgentBrowser(
|
|
78
|
+
[
|
|
79
|
+
'--session',
|
|
80
|
+
sessionName,
|
|
81
|
+
'--json',
|
|
82
|
+
'eval',
|
|
83
|
+
'JSON.stringify(window.__harness || null)',
|
|
84
|
+
],
|
|
85
|
+
{ timeoutMs },
|
|
86
|
+
);
|
|
87
|
+
if (result.exitCode !== 0) {
|
|
88
|
+
return { ok: false, toolError: commandError('eval', result) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return { ok: true, harness: parseHarnessFromEval(result.stdout) };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
toolError: {
|
|
97
|
+
phase: 'eval_parse',
|
|
98
|
+
message: error instanceof Error ? error.message : String(error),
|
|
99
|
+
stdout: result.stdout.trim(),
|
|
100
|
+
stderr: result.stderr.trim(),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isAgentBrowserAvailable() {
|
|
107
|
+
const result = spawnSync('agent-browser', ['--version'], {
|
|
108
|
+
stdio: 'ignore',
|
|
109
|
+
env: process.env,
|
|
110
|
+
});
|
|
111
|
+
return result.status === 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function runHarnessRoute({
|
|
115
|
+
url,
|
|
116
|
+
sessionName,
|
|
117
|
+
timeoutMs = 10_000,
|
|
118
|
+
snapshotPath = null,
|
|
119
|
+
}) {
|
|
120
|
+
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
121
|
+
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
122
|
+
});
|
|
123
|
+
if (opened.exitCode !== 0) {
|
|
124
|
+
return {
|
|
125
|
+
mounted: false,
|
|
126
|
+
renderStarted: false,
|
|
127
|
+
errors: [],
|
|
128
|
+
runtimeCalls: [],
|
|
129
|
+
snapshotPath: null,
|
|
130
|
+
tool_error: commandError('open', opened),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
const lastRead = await readHarness(sessionName, 5000);
|
|
137
|
+
if (!lastRead.ok) {
|
|
138
|
+
await delay(250);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const harness = lastRead.harness;
|
|
142
|
+
if (harness?.mounted || (Array.isArray(harness?.errors) && harness.errors.length > 0)) {
|
|
143
|
+
await delay(250);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
await delay(250);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const finalRead = await readHarness(sessionName, 5000);
|
|
150
|
+
if (!finalRead.ok) {
|
|
151
|
+
return {
|
|
152
|
+
mounted: false,
|
|
153
|
+
renderStarted: false,
|
|
154
|
+
errors: [],
|
|
155
|
+
runtimeCalls: [],
|
|
156
|
+
snapshotPath: null,
|
|
157
|
+
tool_error: finalRead.toolError,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const harness = finalRead.harness || {};
|
|
162
|
+
let savedSnapshotPath = null;
|
|
163
|
+
if (snapshotPath) {
|
|
164
|
+
const snapshot = await runAgentBrowser(['--session', sessionName, 'snapshot', '-i'], {
|
|
165
|
+
timeoutMs: 10_000,
|
|
166
|
+
});
|
|
167
|
+
if (snapshot.exitCode === 0) {
|
|
168
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
169
|
+
writeFileSync(snapshotPath, snapshot.stdout);
|
|
170
|
+
savedSnapshotPath = snapshotPath;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const timedOut = !harness.mounted && Date.now() >= deadline;
|
|
175
|
+
return {
|
|
176
|
+
mounted: Boolean(harness.mounted),
|
|
177
|
+
renderStarted: Boolean(harness.renderStarted),
|
|
178
|
+
errors: Array.isArray(harness.errors) ? harness.errors : [],
|
|
179
|
+
runtimeCalls: Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [],
|
|
180
|
+
snapshotPath: savedSnapshotPath,
|
|
181
|
+
timed_out: timedOut,
|
|
182
|
+
tool_error: null,
|
|
183
|
+
raw: harness,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function closeAgentBrowserSession(sessionName) {
|
|
188
|
+
const result = await runAgentBrowser(['--session', sessionName, 'close'], {
|
|
189
|
+
timeoutMs: 5000,
|
|
190
|
+
});
|
|
191
|
+
return result.exitCode === 0;
|
|
192
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { usageError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const RULES_PATH = resolve(
|
|
8
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
9
|
+
'../../../../server/config/notis_app_boundary_rules.json',
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.pcss']);
|
|
13
|
+
const IGNORED_SOURCE_DIRS = new Set([
|
|
14
|
+
'node_modules',
|
|
15
|
+
'.git',
|
|
16
|
+
'.next',
|
|
17
|
+
'.notis',
|
|
18
|
+
'dist',
|
|
19
|
+
'build',
|
|
20
|
+
'coverage',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function loadBoundaryRules() {
|
|
24
|
+
return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compileRules(entries) {
|
|
28
|
+
return (entries || []).map((entry) => ({
|
|
29
|
+
regex: new RegExp(entry.pattern, 'm'),
|
|
30
|
+
message: entry.message,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const boundaryRules = loadBoundaryRules();
|
|
35
|
+
const javascriptRules = compileRules(boundaryRules.javascript);
|
|
36
|
+
const cssRules = compileRules(boundaryRules.css);
|
|
37
|
+
|
|
38
|
+
function collectProjectFiles(projectDir, dir, results) {
|
|
39
|
+
if (!existsSync(dir)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry.name.startsWith('.')) {
|
|
46
|
+
if (entry.name !== '.storybook') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fullPath = join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (IGNORED_SOURCE_DIRS.has(entry.name)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
collectProjectFiles(projectDir, fullPath, results);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!SOURCE_FILE_EXTENSIONS.has(extname(entry.name))) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
results.push({
|
|
65
|
+
path: fullPath,
|
|
66
|
+
relPath: fullPath.slice(projectDir.length + 1),
|
|
67
|
+
content: readFileSync(fullPath, 'utf-8'),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function applyRules(content, rules, relPath) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
if (rule.regex.test(content)) {
|
|
76
|
+
errors.push(`${relPath}: ${rule.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateTextFile(relPath, content) {
|
|
83
|
+
const extension = extname(relPath);
|
|
84
|
+
if (extension === '.css' || extension === '.scss' || extension === '.sass' || extension === '.pcss') {
|
|
85
|
+
return applyRules(content, cssRules, relPath);
|
|
86
|
+
}
|
|
87
|
+
if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
|
|
88
|
+
return applyRules(content, javascriptRules, relPath);
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function collectProjectBoundaryViolations(projectDir) {
|
|
94
|
+
const files = [];
|
|
95
|
+
collectProjectFiles(projectDir, projectDir, files);
|
|
96
|
+
return files.flatMap((file) => validateTextFile(file.relPath, file.content));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function collectArtifactBoundaryViolations(files) {
|
|
100
|
+
return Object.entries(files).flatMap(([relPath, rawContent]) => {
|
|
101
|
+
const extension = extname(relPath);
|
|
102
|
+
if (!['.js', '.css', '.scss', '.sass', '.pcss'].includes(extension)) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const content = Buffer.isBuffer(rawContent)
|
|
107
|
+
? rawContent.toString('utf-8')
|
|
108
|
+
: typeof rawContent === 'string'
|
|
109
|
+
? rawContent
|
|
110
|
+
: String(rawContent ?? '');
|
|
111
|
+
|
|
112
|
+
return validateTextFile(relPath, content);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validateProjectBoundary(projectDir) {
|
|
117
|
+
const violations = collectProjectBoundaryViolations(projectDir);
|
|
118
|
+
if (violations.length > 0) {
|
|
119
|
+
throw usageError(
|
|
120
|
+
`Project violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function validateArtifactBoundary(files) {
|
|
126
|
+
const violations = collectArtifactBoundaryViolations(files);
|
|
127
|
+
if (violations.length > 0) {
|
|
128
|
+
throw usageError(
|
|
129
|
+
`Built app violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|