Skip to content

Commit be94bb8

Browse files
committed
attempt to add the cli
1 parent 7e47c57 commit be94bb8

File tree

5 files changed

+730
-1
lines changed

5 files changed

+730
-1
lines changed

src/cli/index.ts

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import type { KernelJson } from '@onkernel/sdk';
2+
import { Command } from 'commander';
3+
import fs from 'fs';
4+
import getPort from 'get-port';
5+
import os from 'os';
6+
import path from 'path';
7+
import { packageApp } from './lib/package';
8+
import { getPackageVersion, isPnpmInstalled, isUvInstalled } from './lib/util';
9+
10+
const program = new Command();
11+
12+
// When we package a ts app, we have the option to use a custom kernel sdk dependency in package.json.
13+
// This is useful for local dev.
14+
// KERNEL_NODE_SDK_OVERRIDE=/Users/rafaelgarcia/code/onkernel/kernel/packages/sdk-node
15+
// KERNEL_NODE_SDK_OVERRIDE_VERSION=0.0.1alpha.1
16+
const KERNEL_NODE_SDK_OVERRIDE = process.env.KERNEL_NODE_SDK_OVERRIDE || undefined;
17+
// Same for python...
18+
// KERNEL_PYTHON_SDK_OVERRIDE=/Users/rafaelgarcia/code/onkernel/kernel/packages/sdk-python
19+
// KERNEL_PYTHON_SDK_OVERRIDE_VERSION=0.0.1alpha.1
20+
const KERNEL_PYTHON_SDK_OVERRIDE = process.env.KERNEL_PYTHON_SDK_OVERRIDE || undefined;
21+
22+
// Point to a local version of the boot loader or a specific version
23+
const KERNEL_NODE_BOOT_LOADER_OVERRIDE = process.env.KERNEL_NODE_BOOT_LOADER_OVERRIDE;
24+
const KERNEL_PYTHON_BOOT_LOADER_OVERRIDE = process.env.KERNEL_PYTHON_BOOT_LOADER_OVERRIDE;
25+
26+
program
27+
.name('kernel')
28+
.description('CLI for Kernel deployment and invocation')
29+
.version(getPackageVersion());
30+
31+
program
32+
.command('deploy')
33+
.description('Deploy a Kernel application')
34+
.argument('<entrypoint>', 'Path to entrypoint file (TypeScript or Python)')
35+
.option(
36+
'--local',
37+
'Does not publish the app to Kernel, but installs it on disk for invoking locally',
38+
)
39+
.action(async (entrypoint, options) => {
40+
const resolvedEntrypoint = path.resolve(entrypoint);
41+
if (!fs.existsSync(resolvedEntrypoint)) {
42+
console.error(`Error: Entrypoint ${resolvedEntrypoint} doesn't exist`);
43+
process.exit(1);
44+
}
45+
46+
// package up the app for either uploading or local deployment
47+
const dotKernelDir = await packageApp({
48+
sourceDir: path.dirname(resolvedEntrypoint), // TODO: handle nested entrypoint, i.e. ./src/entrypoint.ts
49+
entrypoint: resolvedEntrypoint,
50+
sdkOverrides: {
51+
node: KERNEL_NODE_SDK_OVERRIDE,
52+
python: KERNEL_PYTHON_SDK_OVERRIDE,
53+
},
54+
bootLoaderOverrides: {
55+
node: KERNEL_NODE_BOOT_LOADER_OVERRIDE,
56+
python: KERNEL_PYTHON_BOOT_LOADER_OVERRIDE,
57+
},
58+
});
59+
60+
if (options.local) {
61+
const kernelJson = JSON.parse(
62+
fs.readFileSync(path.join(dotKernelDir, 'app', 'kernel.json'), 'utf8'),
63+
) as KernelJson;
64+
for (const app of kernelJson.apps) {
65+
if (!app.actions || app.actions.length === 0) {
66+
console.error(`App "${app.name}" has no actions`);
67+
process.exit(1);
68+
}
69+
console.log(
70+
`App "${app.name}" successfully deployed locally and ready to \`kernel invoke --local ${quoteIfNeeded(app.name)} ${quoteIfNeeded(app.actions[0]!.name)}\``,
71+
);
72+
}
73+
} else {
74+
console.log(`Deploying ${resolvedEntrypoint} as "${options.name}"...`);
75+
console.error('TODO: implement cloud :-p');
76+
process.exit(1);
77+
}
78+
});
79+
80+
function quoteIfNeeded(str: string) {
81+
if (str.includes(' ')) {
82+
return `"${str}"`;
83+
}
84+
return str;
85+
}
86+
87+
program
88+
.command('invoke')
89+
.description('Invoke a deployed Kernel application')
90+
.option('--local', 'Invoke a locally deployed application')
91+
.argument('<app_name>', 'Name of the application to invoke')
92+
.argument('<action_name>', 'Name of the action to invoke')
93+
.argument('<payload>', 'JSON payload to send to the application')
94+
.action(async (appName, actionName, payload, options) => {
95+
let parsedPayload;
96+
try {
97+
parsedPayload = JSON.parse(payload);
98+
} catch (error) {
99+
console.error('Error: Invalid JSON payload');
100+
process.exit(1);
101+
}
102+
103+
if (!options.local) {
104+
console.log(`Invoking "${options.name}" in the cloud is not implemented yet`);
105+
process.exit(1);
106+
}
107+
108+
console.log(`Invoking "${appName}" with action "${actionName}" and payload:`);
109+
console.log(JSON.stringify(parsedPayload, null, 2));
110+
111+
// Get the app directory
112+
const cacheFile = path.join(
113+
os.homedir(),
114+
'.local',
115+
'state',
116+
'kernel',
117+
'deploy',
118+
'local',
119+
appName,
120+
);
121+
if (!fs.existsSync(cacheFile)) {
122+
console.error(`Error: App "${appName}" local deployment not found. `);
123+
console.error('Did you `kernel deploy --local <entrypoint>`?');
124+
process.exit(1);
125+
}
126+
const kernelLocalDir = fs.readFileSync(cacheFile, 'utf8').trim();
127+
if (!fs.existsSync(kernelLocalDir)) {
128+
console.error(
129+
`Error: App "${appName}" local deployment has been corrupted, please re-deploy.`,
130+
);
131+
process.exit(1);
132+
}
133+
134+
const isPythonApp = fs.existsSync(path.join(kernelLocalDir, 'pyproject.toml'));
135+
const isTypeScriptApp = fs.existsSync(path.join(kernelLocalDir, 'package.json'));
136+
const invokeOptions: InvokeLocalOptions = {
137+
kernelLocalDir,
138+
appName,
139+
actionName,
140+
parsedPayload,
141+
};
142+
try {
143+
if (isPythonApp) {
144+
await invokeLocalPython(invokeOptions);
145+
} else if (isTypeScriptApp) {
146+
await invokeLocalNode(invokeOptions);
147+
} else {
148+
throw new Error(`Unsupported app type in ${kernelLocalDir}`);
149+
}
150+
} catch (error) {
151+
console.error('Error invoking application:', error);
152+
process.exit(1);
153+
}
154+
});
155+
156+
/**
157+
* Waits for a process to output a startup message while echoing stderr
158+
*/
159+
async function waitForStartupMessage(
160+
childProcess: { stderr: ReadableStream },
161+
timeoutMs: number = 30000,
162+
): Promise<void> {
163+
return new Promise<void>(async (resolve, reject) => {
164+
const timeout = setTimeout(() => {
165+
reject(new Error('Timeout waiting for application startup.'));
166+
}, timeoutMs);
167+
168+
const reader = childProcess.stderr.getReader();
169+
const decoder = new TextDecoder();
170+
171+
try {
172+
while (true) {
173+
const { done, value } = await reader.read();
174+
if (done) break;
175+
176+
const text = decoder.decode(value);
177+
process.stderr.write(text);
178+
179+
if (
180+
text.includes('Application startup complete.') ||
181+
text.includes('Kernel application running')
182+
) {
183+
clearTimeout(timeout);
184+
resolve();
185+
break;
186+
}
187+
}
188+
} finally {
189+
reader.releaseLock();
190+
}
191+
});
192+
}
193+
194+
type InvokeLocalOptions = {
195+
kernelLocalDir: string;
196+
appName: string;
197+
actionName: string;
198+
parsedPayload: any;
199+
};
200+
201+
/**
202+
* Invokes a locally deployed Python app action
203+
*/
204+
async function invokeLocalPython({
205+
kernelLocalDir,
206+
appName,
207+
actionName,
208+
parsedPayload,
209+
}: InvokeLocalOptions) {
210+
const uvInstalled = await isUvInstalled();
211+
if (!uvInstalled) {
212+
console.error('Error: uv is not installed. Please install it with:');
213+
console.error(' curl -LsSf https://astral.sh/uv/install.sh | sh');
214+
process.exit(1);
215+
}
216+
217+
// load kernel.json for entrypoint
218+
const kernelJson = JSON.parse(
219+
fs.readFileSync(path.join(kernelLocalDir, 'app', 'kernel.json'), 'utf8'),
220+
) as KernelJson;
221+
const entrypoint = kernelJson.entrypoint;
222+
if (!entrypoint) {
223+
throw new Error('Local deployment does not have an entrypoint, please try re-deploying.');
224+
}
225+
226+
// Find an available port and start the boot loader
227+
const port = await getPort();
228+
const pythonProcess = Bun.spawn(
229+
['uv', 'run', '--no-cache', 'python', 'main.py', './app', '--port', port.toString()],
230+
{
231+
cwd: kernelLocalDir,
232+
stdio: ['inherit', 'inherit', 'pipe'],
233+
env: process.env,
234+
},
235+
);
236+
try {
237+
await waitForStartupMessage(pythonProcess);
238+
} catch (error) {
239+
console.error('Error while waiting for application to start:', error);
240+
pythonProcess.kill();
241+
process.exit(1);
242+
}
243+
244+
try {
245+
await requestAppAction({ port, appName, actionName, parsedPayload });
246+
} catch (error) {
247+
console.error('Error invoking application:', error);
248+
} finally {
249+
console.log('Shutting down boot server...');
250+
pythonProcess.kill();
251+
}
252+
}
253+
254+
/**
255+
* Invokes a locally deployed TypeScript app action
256+
*/
257+
async function invokeLocalNode({
258+
kernelLocalDir,
259+
appName,
260+
actionName,
261+
parsedPayload,
262+
}: InvokeLocalOptions) {
263+
const pnpmInstalled = await isPnpmInstalled();
264+
if (!pnpmInstalled) {
265+
console.error('Error: pnpm is not installed. Please install it with:');
266+
console.error(' npm install -g pnpm');
267+
process.exit(1);
268+
}
269+
270+
// load kernel.json for entrypoint
271+
const kernelJson = JSON.parse(
272+
fs.readFileSync(path.join(kernelLocalDir, 'app', 'kernel.json'), 'utf8'),
273+
) as KernelJson;
274+
const entrypoint = kernelJson.entrypoint;
275+
if (!entrypoint) {
276+
throw new Error('Local deployment does not have an entrypoint, please try re-deploying.');
277+
}
278+
279+
// Find an available port and start the boot loader
280+
const port = await getPort();
281+
const tsProcess = Bun.spawn(
282+
[
283+
'pnpm',
284+
'exec',
285+
'tsx',
286+
'index.ts',
287+
'--port',
288+
port.toString(),
289+
path.join(kernelLocalDir, 'app'),
290+
],
291+
{
292+
cwd: kernelLocalDir,
293+
stdio: ['inherit', 'inherit', 'pipe'],
294+
env: process.env,
295+
},
296+
);
297+
298+
try {
299+
await waitForStartupMessage(tsProcess);
300+
} catch (error) {
301+
console.error('Error while waiting for application to start:', error);
302+
tsProcess.kill();
303+
process.exit(1);
304+
}
305+
306+
try {
307+
await requestAppAction({ port, appName, actionName, parsedPayload });
308+
} catch (error) {
309+
console.error('Error invoking application:', error);
310+
} finally {
311+
console.log('Shutting down boot server...');
312+
tsProcess.kill();
313+
}
314+
}
315+
316+
async function requestAppAction({
317+
port,
318+
appName,
319+
actionName,
320+
parsedPayload,
321+
}: {
322+
port: number;
323+
appName: string;
324+
actionName: string;
325+
parsedPayload: any;
326+
}): Promise<any> {
327+
let serverReached = false;
328+
try {
329+
const healthCheck = await fetch(`http://localhost:${port}/`, {
330+
method: 'GET',
331+
}).catch(() => null);
332+
if (!healthCheck) {
333+
throw new Error(`Could not connect to boot server at http://localhost:${port}/`);
334+
}
335+
serverReached = true;
336+
} catch (error) {
337+
console.error('Error connecting to boot server:', error);
338+
console.error('The boot server might not have started correctly.');
339+
process.exit(1);
340+
}
341+
342+
const response = await fetch(`http://localhost:${port}/apps/${appName}/actions/${actionName}`, {
343+
method: 'POST',
344+
headers: {
345+
'Content-Type': 'application/json',
346+
},
347+
body: JSON.stringify(parsedPayload),
348+
}).catch((error) => {
349+
console.error(`Failed to connect to action endpoint: ${error.message}`);
350+
throw new Error(
351+
`Could not connect to action endpoint at http://localhost:${port}/apps/${appName}/actions/${actionName}`,
352+
);
353+
});
354+
355+
if (!response.ok) {
356+
const errorText = await response.text().catch(() => 'Unknown error');
357+
throw new Error(`HTTP error ${response.status}: ${errorText}`);
358+
}
359+
360+
const result = await response.json();
361+
console.log('Result:', JSON.stringify(result, null, 2));
362+
363+
return result;
364+
}
365+
366+
program.parse();

src/cli/lib/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Constants for package names (subject to change)
2+
export const PYTHON_PACKAGE_NAME = 'kernel';
3+
export const NODE_PACKAGE_NAME = '@onkernel/sdk';

0 commit comments

Comments
 (0)