Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ NODE_ENV=development

# Leaving this empty will generate a new unique random session secret at start
SESSION_SECRET=

# Change if your nf cli executable isn't in the path
NF_CLI_PATH=nf
2 changes: 2 additions & 0 deletions .idea/prettier.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
]
},
"dependencies": {
"svelte-kit-sessions": "catalog:core"
"svelte-kit-sessions": "catalog:core",
"tree-kill": "catalog:core"
}
}
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ catalogs:
svelte-check: ^4.3.5
vite: ^7.3.1
svelte-kit-sessions: "^0.4.0"
tree-kill: ^1.2.2
css:
'@alexanderniebuhr/prettier-plugin-unocss': ^0.0.4
'@unocss/extractor-svelte': ^66.6.3
Expand Down
3 changes: 3 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import child_process from 'node:child_process';

declare global {
namespace App {
// interface Error {}
Expand All @@ -13,6 +15,7 @@ declare global {
declare module 'svelte-kit-sessions' {
interface SessionData {
path: string;
projectPid?: number;
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ const sessionHandle = sveltekitSessionHandle({
});

const checkAuthorizationHandle: Handle = async ({ event, resolve }) => {
if (!event.locals.session.data.path && event.url.pathname !== '/load-project') {
if (
!event.locals.session.data.path &&
event.url.pathname !== '/load-project' &&
event.url.pathname + event.url.search !== '/cli?/createProject'
) {
throw redirect(302, '/load-project');
}
return resolve(event);
Expand Down
7 changes: 7 additions & 0 deletions src/lib/server/utils/cli/cli-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class CliError extends Error {
message: string;
constructor(message: string) {
super();
this.message = message;
}
}
98 changes: 98 additions & 0 deletions src/lib/server/utils/cli/cli-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { env } from '$env/dynamic/private';
import { CliError } from '@utils-server/cli/cli-error';
import child_process from 'node:child_process';
import treeKill from 'tree-kill';

export class CliInterface {
private readonly projectPath: string;

constructor(projectPath: string) {
this.projectPath = projectPath;
}

createProject(
projectName: string,
packageManager: 'npm' | 'yarn' | 'pnpm' | 'bun',
language: 'js' | 'ts',
strictTypeChecking: boolean,
multiplayerServer: boolean,
skipDependencyInstallation: boolean,
dockerContainerization: boolean,
) {
this.runCliSync([
`new`,
`-d`,
this.projectPath,
`--name`,
projectName,
`--package-manager`,
packageManager,
`--language`,
language,
strictTypeChecking ? '--strict' : '--no-strict',
multiplayerServer ? '--server' : '--no-server',
skipDependencyInstallation ? '--skip-install' : '--no-skip-install',
dockerContainerization ? '--docker' : '--no-docker',
]);
}

startDevProject(pid: number): number {
if (this.isProjectRunning(pid) && pid != -1) {
throw new CliError('Project already running');
}
this.runCliSync([`build`, `-d`, this.projectPath]);
return this.runCliAsync([`dev`, `-d`, this.projectPath]);
}

stopProject(pid: number) {
if (!this.isProjectRunning(pid)) {
throw new CliError('Project not running');
}
treeKill(pid, 'SIGTERM');
}

isProjectRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

private runCliSync(params: string[]) {
const res = child_process.spawnSync(env.NF_CLI_PATH, params);
console.log(res.stdout.toString());
console.error(res.stderr.toString());
if (res.status === null) {
throw new CliError(`Executable ${env.NF_CLI_PATH} cannot be found or executed`);
}
if (res.status !== 0) {
throw new CliError(res.stderr.toString());
}
}

private runCliAsync(params: string[]): number {
const res = child_process.spawn(env.NF_CLI_PATH, params);

const startTime = Date.now();
while (res.pid === undefined && Date.now() - startTime < 100) {
/* if I remove this comment the linter is crying */
}
if (res.pid === undefined) {
throw new CliError('Failed to start process: pid not available');
}

res.on('error', () => {
throw new CliError(res.stderr.toString());
});
res.on('exit', (code) => {
console.log(res.stdout.read()?.toString());
console.log(res.stderr.read()?.toString());
if (code !== 0 && code !== null) {
throw new CliError(`Process exited with code ${code}`);
}
});
return res.pid;
}
}
108 changes: 108 additions & 0 deletions src/routes/cli/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { fail } from '@sveltejs/kit';
import { CliError } from '@utils-server/cli/cli-error';
import { CliInterface } from '@utils-server/cli/cli-interface';

import type { Actions } from './$types';

export const actions = {
// Create project
// Run project
// Export project
createProject: async ({ request }) => {
const data = await request.json();

if (!data.projectPath) {
return fail(403, { success: false, errorMsg: "Missing arg: 'projectPath'" });
}
if (!data.projectName) {
return fail(403, { success: false, errorMsg: "Missing arg: 'projectName'" });
}
if (!data.packageManager) {
return fail(403, { success: false, errorMsg: "Missing arg: 'packageManager'" });
}
if (!data.language) {
return fail(403, { success: false, errorMsg: "Missing arg: 'language'" });
}

try {
new CliInterface(data.projectPath).createProject(
data.projectName,
data.packageManager,
data.language,
data.strictTypeChecking,
data.multiplayerServer,
data.skipDependencyInstallation,
data.dockerContainerization,
);
return {
success: true,
};
} catch (e: unknown) {
if (e instanceof CliError) {
return fail(403, { success: false, errorMsg: e.message });
}
throw e;
}
},

startDevProject: async ({ locals }) => {
try {
const childProcess = new CliInterface(locals.session.data.path).startDevProject(
locals.session?.data?.projectPid || -1,
);
const session = locals.session;

session.data.projectPid = childProcess;
await session.save();

return {
success: true,
};
} catch (e: unknown) {
if (e instanceof CliError) {
return fail(403, { success: false, errorMsg: e.message });
}
throw e;
}
},

stopProject: async ({ locals }) => {
try {
if (!locals.session.data.projectPid) {
throw new CliError('Project not running');
}
new CliInterface(locals.session.data.path).stopProject(locals.session.data.projectPid);
locals.session.data.projectPid = -1;
return {
success: true,
};
} catch (e: unknown) {
if (e instanceof CliError) {
return fail(403, { success: false, errorMsg: e.message });
}
throw e;
}
},

isProjectRunning: async ({ locals }) => {
try {
if (!locals.session.data.projectPid) {
return {
success: true,
projectRunning: false,
};
}
return {
success: true,
projectRunning: new CliInterface(locals.session.data.path).isProjectRunning(
locals.session.data.projectPid,
),
};
} catch (e: unknown) {
if (e instanceof CliError) {
return fail(403, { success: false, errorMsg: e.message });
}
throw e;
}
},
} satisfies Actions;
67 changes: 67 additions & 0 deletions src/routes/cli/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Logo from '$lib/assets/logo.png';
import type { PageData } from './$types';
import { deserialize } from '$app/forms';

let { data }: { data: PageData } = $props();
let projectRunning = $state(false);
</script>

<div class="h-screen flex flex-col gap-1">
<header class="h-16 flex bg-neutral-900">
<div class="h-full w-full flex">
<a href={resolve('/')} class="h-full px-3 pb-1 pt-2">
<img src={Logo} alt="Logo" class="h-full rounded-full" />
</a>
<div class="h-full w-full flex flex-col justify-between">
{projectRunning || data.projectRunning ? 'Project running' : 'Project not running'}
<form
onsubmit={async (e) => {
e.preventDefault();
const response = await fetch('/cli?/isProjectRunning', {
method: 'POST',
body: JSON.stringify({}),
});
const result = deserialize(await response.text());
if (result.type === 'success' && result.data) {
projectRunning = result.data.projectRunning;
}
}}
>
<input type="submit" value="Check running status" />
</form>
<form
onsubmit={async (e) => {
e.preventDefault();
const response = await fetch('/cli?/startDevProject', {
method: 'POST',
body: JSON.stringify({}),
});
const result = deserialize(await response.text());
if (result.type === 'success' && result.data) {
projectRunning = true;
}
}}
>
<input type="submit" value="Start Project" />
</form>
<form
onsubmit={async (e) => {
e.preventDefault();
const response = await fetch('/cli?/stopProject', {
method: 'POST',
body: JSON.stringify({}),
});
const result = deserialize(await response.text());
if (result.type === 'success' && result.data) {
projectRunning = false;
}
}}
>
<input type="submit" value="Stop Project" />
</form>
</div>
</div>
</header>
</div>
Loading
Loading