@notis_ai/cli 0.2.0-beta.16.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.
- package/README.md +335 -0
- package/bin/notis.js +2 -0
- package/package.json +38 -0
- package/src/cli.js +147 -0
- package/src/command-specs/apps.js +496 -0
- package/src/command-specs/auth.js +178 -0
- package/src/command-specs/db.js +163 -0
- package/src/command-specs/helpers.js +193 -0
- package/src/command-specs/index.js +20 -0
- package/src/command-specs/meta.js +154 -0
- package/src/command-specs/tools.js +391 -0
- package/src/runtime/app-platform.js +624 -0
- package/src/runtime/app-preview-server.js +312 -0
- package/src/runtime/errors.js +55 -0
- package/src/runtime/help.js +60 -0
- package/src/runtime/output.js +180 -0
- package/src/runtime/profiles.js +202 -0
- package/src/runtime/transport.js +198 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +7 -0
- package/template/app/page.tsx +55 -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/notis.config.ts +18 -0
- package/template/package.json +32 -0
- package/template/packages/notis-sdk/package.json +26 -0
- package/template/packages/notis-sdk/src/config.ts +48 -0
- package/template/packages/notis-sdk/src/helpers.ts +131 -0
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +50 -0
- package/template/packages/notis-sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +58 -0
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +87 -0
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +61 -0
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +31 -0
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTool.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +57 -0
- package/template/packages/notis-sdk/src/index.ts +47 -0
- package/template/packages/notis-sdk/src/provider.tsx +44 -0
- package/template/packages/notis-sdk/src/runtime.ts +159 -0
- package/template/packages/notis-sdk/src/styles.css +123 -0
- package/template/packages/notis-sdk/src/ui.ts +15 -0
- package/template/packages/notis-sdk/src/vite.ts +54 -0
- package/template/packages/notis-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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local preview server for built Notis app bundles.
|
|
3
|
+
*
|
|
4
|
+
* Serves a single index.html that loads the app bundle via <script type="module">.
|
|
5
|
+
* Injects mock window.__NOTIS_RUNTIME__ before the bundle loads. Route switching
|
|
6
|
+
* uses pathname navigation between route pages.
|
|
7
|
+
*
|
|
8
|
+
* Usage: `notis apps preview [dir]` starts this server on localhost:8787.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { extname, resolve, sep } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { readManifest } from './app-platform.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Static file serving
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function contentTypeFor(path) {
|
|
22
|
+
const types = {
|
|
23
|
+
'.html': 'text/html; charset=utf-8',
|
|
24
|
+
'.css': 'text/css; charset=utf-8',
|
|
25
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
26
|
+
'.json': 'application/json; charset=utf-8',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.png': 'image/png',
|
|
29
|
+
'.jpg': 'image/jpeg',
|
|
30
|
+
'.jpeg': 'image/jpeg',
|
|
31
|
+
'.ico': 'image/x-icon',
|
|
32
|
+
'.woff': 'font/woff',
|
|
33
|
+
'.woff2': 'font/woff2',
|
|
34
|
+
};
|
|
35
|
+
return types[extname(path)] || 'application/octet-stream';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Mock runtime generation
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function buildSeedDocuments(databases) {
|
|
43
|
+
const state = {};
|
|
44
|
+
for (const slug of databases) {
|
|
45
|
+
state[slug] = Array.from({ length: 3 }, (_, i) => {
|
|
46
|
+
return {
|
|
47
|
+
id: `${slug}-seed-${i + 1}`,
|
|
48
|
+
databaseSlug: slug,
|
|
49
|
+
title: `${slug} ${i + 1}`,
|
|
50
|
+
properties: {},
|
|
51
|
+
icon: null,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function escapeHtml(value) {
|
|
59
|
+
return String(value)
|
|
60
|
+
.replaceAll('&', '&')
|
|
61
|
+
.replaceAll('<', '<')
|
|
62
|
+
.replaceAll('>', '>')
|
|
63
|
+
.replaceAll('"', '"')
|
|
64
|
+
.replaceAll("'", ''');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeBundlePath(value, fallback) {
|
|
68
|
+
if (typeof value !== 'string') return fallback;
|
|
69
|
+
const normalized = value.trim().replaceAll('\\', '/').replace(/^\/+/, '');
|
|
70
|
+
if (!normalized || normalized.includes('..')) return fallback;
|
|
71
|
+
return /^[A-Za-z0-9._/-]+$/.test(normalized) ? normalized : fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function serializeForInlineScript(value) {
|
|
75
|
+
return JSON.stringify(value).replaceAll('</', '<\\/').replaceAll('<!--', '<\\!--');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildPreviewHtml({ manifest, route, databases }) {
|
|
79
|
+
const app = manifest.app;
|
|
80
|
+
const seedState = buildSeedDocuments(databases);
|
|
81
|
+
const bundleJs = sanitizeBundlePath(manifest.bundle?.js, 'bundle/app.js');
|
|
82
|
+
const bundleCss = sanitizeBundlePath(manifest.bundle?.css, 'bundle/app.css');
|
|
83
|
+
const exportName = typeof route.export_name === 'string' && route.export_name ? route.export_name : 'index';
|
|
84
|
+
const pageTitle = escapeHtml(`${app.name} - ${route.name}`);
|
|
85
|
+
const bundleCssHref = escapeHtml(`/${bundleCss}`);
|
|
86
|
+
const bundleJsPathLiteral = JSON.stringify(`/${bundleJs}`);
|
|
87
|
+
const exportNameLiteral = JSON.stringify(exportName);
|
|
88
|
+
|
|
89
|
+
return `<!DOCTYPE html>
|
|
90
|
+
<html lang="en">
|
|
91
|
+
<head>
|
|
92
|
+
<meta charset="UTF-8" />
|
|
93
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
94
|
+
<title>${pageTitle}</title>
|
|
95
|
+
<link rel="stylesheet" href="${bundleCssHref}" />
|
|
96
|
+
<script>
|
|
97
|
+
(function() {
|
|
98
|
+
var DB = ${serializeForInlineScript(databases.map(function(slug) {
|
|
99
|
+
return { slug: slug, title: slug, properties: [] };
|
|
100
|
+
}))};
|
|
101
|
+
var STATE = ${serializeForInlineScript(seedState)};
|
|
102
|
+
var COLLECTION = ${serializeForInlineScript(route.collection || null)};
|
|
103
|
+
|
|
104
|
+
function titleProp(slug) {
|
|
105
|
+
var db = DB.find(function(d) { return d.slug === slug; });
|
|
106
|
+
return db && db.properties ? (db.properties.find(function(p) { return p.type === 'title'; }) || {}).name || 'title' : 'title';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dbSlug(v) { return v || (COLLECTION && COLLECTION.database) || null; }
|
|
110
|
+
|
|
111
|
+
window.__NOTIS_RUNTIME__ = {
|
|
112
|
+
app: ${serializeForInlineScript(app)},
|
|
113
|
+
route: ${serializeForInlineScript(route)},
|
|
114
|
+
databases: DB,
|
|
115
|
+
|
|
116
|
+
navigate: function(payload) {
|
|
117
|
+
console.log('[notis-preview] Navigate:', payload);
|
|
118
|
+
if (payload.kind === 'route' && payload.path) {
|
|
119
|
+
window.location.assign(payload.path);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
listTools: function() {
|
|
124
|
+
return Promise.resolve(${serializeForInlineScript((manifest.tools || []).map((name) => ({ name })))});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
callTool: function(name, args) {
|
|
128
|
+
console.log('[notis-preview] Tool call:', name, args);
|
|
129
|
+
return Promise.reject(new Error('Tool calls are not available in local preview. Deploy the app to use real tools.'));
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
queryDatabase: function(args) {
|
|
133
|
+
var slug = dbSlug(args && args.databaseSlug);
|
|
134
|
+
var docs = (STATE[slug] || []).slice(args && args.offset || 0);
|
|
135
|
+
return Promise.resolve({ documents: docs });
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
getDocument: function(args) {
|
|
139
|
+
var id = args && args.documentId;
|
|
140
|
+
for (var slug in STATE) {
|
|
141
|
+
var match = STATE[slug].find(function(d) { return d.id === id; });
|
|
142
|
+
if (match) return Promise.resolve(match);
|
|
143
|
+
}
|
|
144
|
+
return Promise.reject(new Error('Document not found in preview.'));
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
upsertDocument: function(args) {
|
|
148
|
+
var slug = dbSlug(args && args.databaseSlug);
|
|
149
|
+
if (!slug) return Promise.reject(new Error('No database for upsert.'));
|
|
150
|
+
var docs = STATE[slug] || [];
|
|
151
|
+
var tp = titleProp(slug);
|
|
152
|
+
var existing = docs.find(function(d) { return d.id === (args && args.documentId); });
|
|
153
|
+
var props = Object.assign({}, existing && existing.properties, args && args.properties);
|
|
154
|
+
if (args && args.title) props[tp] = args.title;
|
|
155
|
+
var doc = {
|
|
156
|
+
id: existing ? existing.id : slug + '-' + Date.now(),
|
|
157
|
+
databaseSlug: slug,
|
|
158
|
+
title: props[tp] || 'Untitled',
|
|
159
|
+
properties: props,
|
|
160
|
+
icon: null
|
|
161
|
+
};
|
|
162
|
+
if (existing) {
|
|
163
|
+
var idx = docs.indexOf(existing);
|
|
164
|
+
docs[idx] = doc;
|
|
165
|
+
} else {
|
|
166
|
+
docs.unshift(doc);
|
|
167
|
+
}
|
|
168
|
+
STATE[slug] = docs;
|
|
169
|
+
return Promise.resolve({ status: 'success', document: doc });
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
listCollectionItems: function(args) {
|
|
173
|
+
var slug = dbSlug(args && args.databaseSlug);
|
|
174
|
+
if (!slug) return Promise.resolve({ items: [] });
|
|
175
|
+
var tp = (args && args.titleProperty) || titleProp(slug);
|
|
176
|
+
var docs = (STATE[slug] || []).slice(0, (args && args.pageSize) || 100);
|
|
177
|
+
return Promise.resolve({
|
|
178
|
+
items: docs.map(function(d) {
|
|
179
|
+
return { id: d.id, title: (d.properties && d.properties[tp]) || d.title || 'Untitled', icon: d.icon };
|
|
180
|
+
})
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
request: function() {
|
|
185
|
+
return Promise.reject(new Error('Backend requests are not available in local preview.'));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
})();
|
|
189
|
+
</script>
|
|
190
|
+
</head>
|
|
191
|
+
<body class="min-h-screen bg-background text-foreground antialiased">
|
|
192
|
+
<div id="notis-app-root"></div>
|
|
193
|
+
<script type="importmap">
|
|
194
|
+
{
|
|
195
|
+
"imports": {
|
|
196
|
+
"react": "https://esm.sh/react@19",
|
|
197
|
+
"react-dom": "https://esm.sh/react-dom@19?external=react",
|
|
198
|
+
"react-dom/client": "https://esm.sh/react-dom@19/client?external=react",
|
|
199
|
+
"react/jsx-runtime": "https://esm.sh/react@19/jsx-runtime"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
</script>
|
|
203
|
+
<script type="module">
|
|
204
|
+
import React from 'react';
|
|
205
|
+
import { createRoot } from 'react-dom/client';
|
|
206
|
+
import * as AppBundle from ${bundleJsPathLiteral};
|
|
207
|
+
|
|
208
|
+
// Try to render: AppShell wrapping the route component, or just the route component
|
|
209
|
+
const exportName = ${exportNameLiteral};
|
|
210
|
+
const RouteComponent = AppBundle[exportName] || AppBundle['default'];
|
|
211
|
+
const AppShell = AppBundle['__AppShell'];
|
|
212
|
+
|
|
213
|
+
if (RouteComponent) {
|
|
214
|
+
const root = document.getElementById('notis-app-root');
|
|
215
|
+
if (root) {
|
|
216
|
+
const reactRoot = createRoot(root);
|
|
217
|
+
if (AppShell) {
|
|
218
|
+
reactRoot.render(React.createElement(AppShell, null, React.createElement(RouteComponent)));
|
|
219
|
+
} else {
|
|
220
|
+
reactRoot.render(React.createElement(RouteComponent));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
document.getElementById('notis-app-root').innerHTML =
|
|
225
|
+
'<p style="padding:2rem;color:red;">No component found for export "' + exportName + '". Available exports: ' +
|
|
226
|
+
Object.keys(AppBundle).join(', ') + '</p>';
|
|
227
|
+
}
|
|
228
|
+
</script>
|
|
229
|
+
</body>
|
|
230
|
+
</html>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Server
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function normalizePathname(pathname) {
|
|
238
|
+
if (!pathname || pathname === '/') return '/';
|
|
239
|
+
return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function resolveSafePath(baseDir, pathname) {
|
|
243
|
+
const normalizedBaseDir = resolve(baseDir);
|
|
244
|
+
const resolvedPath = resolve(normalizedBaseDir, pathname.replace(/^\/+/, ''));
|
|
245
|
+
if (resolvedPath === normalizedBaseDir || resolvedPath.startsWith(`${normalizedBaseDir}${sep}`)) {
|
|
246
|
+
return resolvedPath;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function startPreviewServer({ projectDir, port }) {
|
|
252
|
+
const manifest = readManifest(projectDir);
|
|
253
|
+
const outputDir = resolve(projectDir, '.notis/output');
|
|
254
|
+
const bundleDir = resolve(projectDir, '.notis/output/bundle');
|
|
255
|
+
const databases = (manifest.databases || []).filter((slug) => typeof slug === 'string' && slug);
|
|
256
|
+
const routesByPath = new Map((manifest.routes || []).map((r) => [normalizePathname(r.path), r]));
|
|
257
|
+
const defaultRoute = (manifest.routes || []).find((r) => r.default) || manifest.routes?.[0];
|
|
258
|
+
|
|
259
|
+
if (!defaultRoute) {
|
|
260
|
+
throw new Error('Manifest contains no routes.');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const server = createServer((request, response) => {
|
|
264
|
+
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
265
|
+
const pathname = normalizePathname(url.pathname);
|
|
266
|
+
|
|
267
|
+
// Serve bundle files directly
|
|
268
|
+
if (pathname.startsWith('/bundle/')) {
|
|
269
|
+
const filePath = resolveSafePath(outputDir, pathname);
|
|
270
|
+
if (filePath && existsSync(filePath)) {
|
|
271
|
+
response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store' });
|
|
272
|
+
response.end(readFileSync(filePath));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Redirect / to default route if it's not /
|
|
278
|
+
if (pathname === '/' && defaultRoute.path !== '/') {
|
|
279
|
+
response.writeHead(302, { Location: defaultRoute.path });
|
|
280
|
+
response.end();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Serve route HTML with runtime injection
|
|
285
|
+
const route = routesByPath.get(pathname) || (pathname === '/' ? defaultRoute : null);
|
|
286
|
+
if (route) {
|
|
287
|
+
const html = buildPreviewHtml({ manifest, route, databases });
|
|
288
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
289
|
+
response.end(html);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Serve static files from bundle dir
|
|
294
|
+
const filePath = resolveSafePath(bundleDir, pathname);
|
|
295
|
+
if (filePath && existsSync(filePath)) {
|
|
296
|
+
response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store' });
|
|
297
|
+
response.end(readFileSync(filePath));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
302
|
+
response.end('Not found');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await new Promise((resolveP, rejectP) => {
|
|
306
|
+
server.on('error', rejectP);
|
|
307
|
+
server.listen(port, '127.0.0.1', resolveP);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Keep running until interrupted
|
|
311
|
+
await new Promise(() => {});
|
|
312
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const EXIT_CODES = {
|
|
2
|
+
ok: 0,
|
|
3
|
+
usage: 2,
|
|
4
|
+
auth: 3,
|
|
5
|
+
network: 4,
|
|
6
|
+
conflict: 5,
|
|
7
|
+
backend: 6,
|
|
8
|
+
unexpected: 7,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class CliError extends Error {
|
|
12
|
+
constructor({
|
|
13
|
+
code,
|
|
14
|
+
message,
|
|
15
|
+
exitCode = EXIT_CODES.unexpected,
|
|
16
|
+
retryable = false,
|
|
17
|
+
details = {},
|
|
18
|
+
hints = [],
|
|
19
|
+
warnings = [],
|
|
20
|
+
cause,
|
|
21
|
+
}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'CliError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.exitCode = exitCode;
|
|
26
|
+
this.retryable = retryable;
|
|
27
|
+
this.details = details;
|
|
28
|
+
this.hints = hints;
|
|
29
|
+
this.warnings = warnings;
|
|
30
|
+
this.cause = cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function asCliError(error) {
|
|
35
|
+
if (error instanceof CliError) {
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new CliError({
|
|
40
|
+
code: 'unexpected_error',
|
|
41
|
+
message: error instanceof Error ? error.message : String(error),
|
|
42
|
+
exitCode: EXIT_CODES.unexpected,
|
|
43
|
+
cause: error,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function usageError(message, details = {}) {
|
|
48
|
+
return new CliError({
|
|
49
|
+
code: 'usage_error',
|
|
50
|
+
message,
|
|
51
|
+
details,
|
|
52
|
+
exitCode: EXIT_CODES.usage,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { usageError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export function canonicalCommandName(spec) {
|
|
4
|
+
return spec.display_name || spec.command_path.join(' ');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatDescribe(spec) {
|
|
8
|
+
const lines = [
|
|
9
|
+
`${canonicalCommandName(spec)}`,
|
|
10
|
+
'',
|
|
11
|
+
spec.summary,
|
|
12
|
+
'',
|
|
13
|
+
'When to use:',
|
|
14
|
+
` ${spec.when_to_use}`,
|
|
15
|
+
'',
|
|
16
|
+
`Mutates state: ${spec.mutates ? 'yes' : 'no'}`,
|
|
17
|
+
`Idempotent: ${spec.idempotent ? 'yes' : 'no'}`,
|
|
18
|
+
'',
|
|
19
|
+
'Arguments and options:',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const args = spec.args_schema?.arguments || [];
|
|
23
|
+
const options = spec.args_schema?.options || [];
|
|
24
|
+
if (!args.length && !options.length) {
|
|
25
|
+
lines.push(' None');
|
|
26
|
+
} else {
|
|
27
|
+
for (const arg of args) {
|
|
28
|
+
lines.push(` ${arg.token} ${arg.description}`);
|
|
29
|
+
}
|
|
30
|
+
for (const option of options) {
|
|
31
|
+
lines.push(` ${option.flags} ${option.description}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines.push('', 'Examples:');
|
|
36
|
+
for (const example of spec.examples || []) {
|
|
37
|
+
lines.push(` ${example}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
lines.push('', 'Output shape:', ` ${spec.output_schema}`);
|
|
41
|
+
lines.push('', 'Backend call:', ` ${JSON.stringify(spec.backend_call)}`);
|
|
42
|
+
|
|
43
|
+
if (spec.related_commands?.length) {
|
|
44
|
+
lines.push('', 'Related commands:');
|
|
45
|
+
for (const related of spec.related_commands) {
|
|
46
|
+
lines.push(` ${related}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function findCommandSpec(specs, inputPath) {
|
|
54
|
+
const normalized = inputPath.join(' ').trim().toLowerCase();
|
|
55
|
+
const spec = specs.find((entry) => entry.command_path.join(' ').toLowerCase() === normalized);
|
|
56
|
+
if (!spec) {
|
|
57
|
+
throw usageError(`Unknown command path: ${inputPath.join(' ')}`);
|
|
58
|
+
}
|
|
59
|
+
return spec;
|
|
60
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import { EXIT_CODES } from './errors.js';
|
|
3
|
+
|
|
4
|
+
function pad(value, width) {
|
|
5
|
+
return String(value).padEnd(width);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatTable(rows, columns) {
|
|
9
|
+
if (!rows.length) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const widths = columns.map((column) => {
|
|
14
|
+
const headerWidth = column.label.length;
|
|
15
|
+
const valueWidth = Math.max(...rows.map((row) => String(column.value(row) ?? '').length), 0);
|
|
16
|
+
return Math.max(headerWidth, valueWidth);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const header = columns.map((column, index) => pad(column.label, widths[index])).join(' ');
|
|
20
|
+
const divider = columns.map((_, index) => '-'.repeat(widths[index])).join(' ');
|
|
21
|
+
const body = rows.map((row) => columns.map((column, index) => pad(column.value(row) ?? '', widths[index])).join(' '));
|
|
22
|
+
return [header, divider, ...body].join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function yamlScalar(value) {
|
|
26
|
+
if (value === null || value === undefined) {
|
|
27
|
+
return 'null';
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
return JSON.stringify(String(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toYaml(value, indent = 0) {
|
|
36
|
+
const prefix = ' '.repeat(indent);
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
if (!value.length) {
|
|
40
|
+
return `${prefix}[]`;
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
.map((item) => {
|
|
44
|
+
if (item && typeof item === 'object') {
|
|
45
|
+
const nested = toYaml(item, indent + 2);
|
|
46
|
+
return `${prefix}-\n${nested}`;
|
|
47
|
+
}
|
|
48
|
+
return `${prefix}- ${yamlScalar(item)}`;
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (value && typeof value === 'object') {
|
|
54
|
+
const entries = Object.entries(value);
|
|
55
|
+
if (!entries.length) {
|
|
56
|
+
return `${prefix}{}`;
|
|
57
|
+
}
|
|
58
|
+
return entries
|
|
59
|
+
.map(([key, nestedValue]) => {
|
|
60
|
+
if (nestedValue && typeof nestedValue === 'object') {
|
|
61
|
+
return `${prefix}${key}:\n${toYaml(nestedValue, indent + 2)}`;
|
|
62
|
+
}
|
|
63
|
+
return `${prefix}${key}: ${yamlScalar(nestedValue)}`;
|
|
64
|
+
})
|
|
65
|
+
.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return `${prefix}${yamlScalar(value)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function serializeMachineEnvelope(envelope, outputMode) {
|
|
72
|
+
if (outputMode === 'yaml') {
|
|
73
|
+
return `${toYaml(envelope)}\n`;
|
|
74
|
+
}
|
|
75
|
+
if (outputMode === 'ndjson') {
|
|
76
|
+
return `${JSON.stringify(envelope)}\n`;
|
|
77
|
+
}
|
|
78
|
+
return `${JSON.stringify(envelope, null, 2)}\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class OutputManager {
|
|
82
|
+
constructor(runtime) {
|
|
83
|
+
this.runtime = runtime;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
isMachineMode() {
|
|
87
|
+
return this.runtime.outputMode !== 'table';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
writeWarning(message) {
|
|
91
|
+
if (this.isMachineMode()) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
process.stderr.write(`${message}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
emitSuccess({
|
|
98
|
+
command,
|
|
99
|
+
data = {},
|
|
100
|
+
humanSummary,
|
|
101
|
+
hints = [],
|
|
102
|
+
warnings = [],
|
|
103
|
+
requestId = null,
|
|
104
|
+
meta = {},
|
|
105
|
+
renderHuman,
|
|
106
|
+
}) {
|
|
107
|
+
const envelope = {
|
|
108
|
+
ok: true,
|
|
109
|
+
command,
|
|
110
|
+
data,
|
|
111
|
+
human_summary: humanSummary,
|
|
112
|
+
hints,
|
|
113
|
+
warnings,
|
|
114
|
+
request_id: requestId,
|
|
115
|
+
meta: {
|
|
116
|
+
profile: this.runtime.profileName,
|
|
117
|
+
api_base: this.runtime.apiBase,
|
|
118
|
+
mutating: Boolean(meta.mutating),
|
|
119
|
+
...meta,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (this.isMachineMode()) {
|
|
124
|
+
process.stdout.write(serializeMachineEnvelope(envelope, this.runtime.outputMode));
|
|
125
|
+
return EXIT_CODES.ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const warning of warnings) {
|
|
129
|
+
this.writeWarning(`Warning: ${warning}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (typeof renderHuman === 'function') {
|
|
133
|
+
const rendered = renderHuman();
|
|
134
|
+
if (rendered) {
|
|
135
|
+
process.stdout.write(`${rendered}\n`);
|
|
136
|
+
}
|
|
137
|
+
} else if (humanSummary) {
|
|
138
|
+
process.stdout.write(`${humanSummary}\n`);
|
|
139
|
+
} else if (Object.keys(data).length) {
|
|
140
|
+
process.stdout.write(`${inspect(data, { depth: null, colors: this.runtime.color })}\n`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (hints.length) {
|
|
144
|
+
const lines = hints.map((hint) => ` ${hint.command} ${hint.reason}`);
|
|
145
|
+
process.stdout.write(`\nNext:\n${lines.join('\n')}\n`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return EXIT_CODES.ok;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
emitError({ command, error, requestId = null }) {
|
|
152
|
+
const envelope = {
|
|
153
|
+
ok: false,
|
|
154
|
+
command,
|
|
155
|
+
error: {
|
|
156
|
+
code: error.code,
|
|
157
|
+
message: error.message,
|
|
158
|
+
retryable: Boolean(error.retryable),
|
|
159
|
+
details: error.details || {},
|
|
160
|
+
},
|
|
161
|
+
hints: error.hints || [],
|
|
162
|
+
warnings: error.warnings || [],
|
|
163
|
+
request_id: requestId,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (this.isMachineMode()) {
|
|
167
|
+
process.stdout.write(serializeMachineEnvelope(envelope, this.runtime.outputMode));
|
|
168
|
+
return error.exitCode || EXIT_CODES.unexpected;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
172
|
+
for (const hint of error.hints || []) {
|
|
173
|
+
process.stderr.write(` ${hint.command} ${hint.reason}\n`);
|
|
174
|
+
}
|
|
175
|
+
return error.exitCode || EXIT_CODES.unexpected;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export { formatTable };
|
|
180
|
+
|