Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,395 changes: 1,859 additions & 1,536 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

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;
}
}
97 changes: 97 additions & 0 deletions src/lib/server/utils/cli/cli-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { env } from '$env/dynamic/private';
import { CliError } from '@utils-server/cli/cli-error';
import child_process from 'node:child_process';

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');
}
process.kill(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);
if (res.status === null) {
throw new CliError(`Executable ${env.NF_CLI_PATH} cannot be found or executed`);
}
if (res.status !== 0) {
console.log(res.stdout.toString());
console.error(res.stderr.toString());
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) => {
if (code !== 0 && code !== null) {
console.log(res.stdout.read()?.toString());
console.log(res.stderr.read()?.toString());
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>
10 changes: 9 additions & 1 deletion src/routes/load-project/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ export const load: PageServerLoad = async ({ url, cookies, locals }) => {
}
absoluteProjectPath = (await serverProjectPath.json())['projectPath'];
} else {
return { success: false, errorMsg: 'No project provided' };
return {
success: false,
creationPanel: env.API_URL ? 'api' : 'local',
errorMsg: `No project provided: ${
env.API_URL
? 'Go back to the NanoForge project manager to access a project'
: 'Select or create a local project'
}`,
};
}

try {
Expand Down
Loading
Loading