feat: manage function dependencies from UI
Deploy / Build (pull_request) Successful in 1m8s
Deploy / Test & Lint (pull_request) Successful in 45s
Deploy / Generate OpenAPI Release (pull_request) Has been skipped
Deploy / Build and Push Docker Image (pull_request) Has been skipped

This commit is contained in:
Space-Banane
2026-07-10 20:56:11 +02:00
parent 6837b686ae
commit 6566374f85
7 changed files with 391 additions and 29 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
getFunctionRuntime,
isDependencyFilename,
} from "../lib/FunctionDependencies";
describe("FunctionDependencies", () => {
it("recognizes the supported runtime families", () => {
expect(getFunctionRuntime("python:3.12")).toBe("python");
expect(getFunctionRuntime("golang:1.23")).toBe("golang");
expect(getFunctionRuntime("mcr.microsoft.com/dotnet/sdk:8.0")).toBe("dotnet");
expect(getFunctionRuntime("node:22")).toBe("unsupported");
});
it("recognizes dependency manifests for all supported runtimes", () => {
expect(isDependencyFilename("requirements.txt")).toBe(true);
expect(isDependencyFilename("go.mod")).toBe(true);
expect(isDependencyFilename("go.sum")).toBe(true);
expect(isDependencyFilename("src/MyFunction.csproj")).toBe(true);
expect(isDependencyFilename("main.py")).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
export type FunctionRuntime = "python" | "golang" | "dotnet" | "unsupported";
export function getFunctionRuntime(image: string): FunctionRuntime {
if (image.startsWith("python")) return "python";
if (image.startsWith("golang")) return "golang";
if (image.startsWith("mcr.microsoft.com/dotnet/sdk:")) return "dotnet";
return "unsupported";
}
export function isDependencyFilename(filename: string): boolean {
const normalized = filename.trim().replaceAll("\\", "/").toLowerCase();
const basename = normalized.split("/").pop() ?? normalized;
return (
basename === "requirements.txt" ||
basename === "go.mod" ||
basename === "go.sum" ||
basename.endsWith(".csproj")
);
}
+37 -26
View File
@@ -12,6 +12,7 @@ import { OpenAPITags } from "../../lib/openapi";
import { getFunctionAppDir } from "../../lib/StoragePaths";
import { createLogger } from "../../lib/logger";
import { listGitAppFiles } from "../../lib/GitOps";
import { isDependencyFilename } from "../../lib/FunctionDependencies";
const log = createLogger("files");
@@ -40,8 +41,9 @@ async function updateContainerDependencies(
filename: string,
_content: string,
) {
// Only process dependency files
if (filename !== "requirements.txt" && filename !== "package.json") {
// Restart the runtime when a manifest changes. Python and Go apply their
// manifests during init; .NET picks up the updated project on the next build.
if (!isDependencyFilename(filename) && filename !== "package.json") {
return;
}
@@ -212,10 +214,7 @@ export = new fileRouter.Path("/")
encoding: "utf-8",
});
// If this is a dependency file, ensure container dependencies are updated using the replaced content
if (
data.filename === "requirements.txt" ||
data.filename === "package.json"
) {
if (isDependencyFilename(data.filename) || data.filename === "package.json") {
await updateContainerDependencies(
functionId,
data.filename,
@@ -559,29 +558,41 @@ export = new fileRouter.Path("/")
// Handle renames of dependency files, which requires updating files on disk too
if (
oldFile &&
(oldFile.name === "requirements.txt" ||
(isDependencyFilename(oldFile.name) ||
oldFile.name === "package.json" ||
data.newFilename === "requirements.txt" ||
isDependencyFilename(data.newFilename) ||
data.newFilename === "package.json")
) {
const funcAppDir = getFunctionAppDir(functionId);
try {
// If renaming away from a dependency file, create an empty one
if (
oldFile.name === "requirements.txt" ||
oldFile.name === "package.json"
) {
const oldPath = path.join(funcAppDir, oldFile.name);
const newPath = path.join(funcAppDir, data.newFilename);
const isLegacyDependency =
oldFile.name === "requirements.txt" || oldFile.name === "package.json";
// Remove the old path before writing the renamed file. Keep the
// historical placeholders only for the legacy Python/Node manifests.
if (oldFile.name !== data.newFilename) {
try {
await fs.unlink(oldPath);
} catch {
// The file may not exist yet for a newly-created database record.
}
}
if (isLegacyDependency) {
await fs.writeFile(
path.join(funcAppDir, oldFile.name),
oldPath,
"# File was renamed\n",
);
log.info({ fileName: oldFile.name }, "Created empty file after rename to prevent broken deployments");
}
// If renaming to a dependency file, write the content and update dependencies
// Keep the host app directory synchronized for dependency and
// dependency-adjacent renames.
if (
data.newFilename === "requirements.txt" ||
data.newFilename === "package.json"
isDependencyFilename(data.newFilename) ||
isDependencyFilename(oldFile.name) ||
isLegacyDependency
) {
try {
const funcInfo = await getFunctionExecInfo(functionId);
@@ -593,15 +604,15 @@ export = new fileRouter.Path("/")
funcInfo.executionId,
);
}
await fs.writeFile(
path.join(funcAppDir, data.newFilename),
contentToWrite,
);
await updateContainerDependencies(
functionId,
data.newFilename,
contentToWrite as string,
);
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.writeFile(newPath, contentToWrite);
if (isDependencyFilename(data.newFilename) || data.newFilename === "package.json") {
await updateContainerDependencies(
functionId,
data.newFilename,
contentToWrite as string,
);
}
} catch (err) {
log.error({ err, fileName: data.newFilename }, "Error writing renamed dependency file");
}
+3 -2
View File
@@ -10,6 +10,7 @@
- [x] Shift Enter Submits on modals (any modal) (add as a agent rule for the future)
- [ ] Fix SHSF Global & Redo it
- [x] Built-in MCP Server & ready to copy Agentic commands ("claude mcp xxxx", "openclaw mcp add xxxxx", and codex ofc) // Seperate Agents Page & usecases for agents using shsf
- [ ] Add cron and more mcp tools
## P1 - Priority 1 (High)
- [x] Replace data transport layer between backend and functions with a more robust and safe solution.
@@ -32,7 +33,7 @@
## P3 - Priority 3 (Low)
- [x] Account Wide Environment Variables
- [ ] Add a way to manage function dependencies (eg. requirements.txt) from the UI
- [x] Add a way to manage function dependencies (eg. requirements.txt) from the UI
- [x] Runner & Backend: Implement a Block for interactions on Functions while “Container ready.” not reached (pretty much wait for “[SHSF] Container ready.”). Message would be something like “Function is not ready yet.”
- [ ] Function Logs Update
- Investigate (Shows only Errors)
@@ -47,4 +48,4 @@
## AI Slop
- [ ] AI Performance Analytics (Code Check)
- [ ] Improve AI's capeability
- [ ] Support OpenAI API Keys
- [ ] Support OpenAI API Keys
@@ -0,0 +1,45 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import DependencyManagerModal from "./DependencyManagerModal";
const dotnetProject = {
id: 1,
name: "app.csproj",
content: "<Project />",
functionId: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
describe("DependencyManagerModal", () => {
it.each([
["python:3.12", "Python Dependencies", "requirements.txt"],
["golang:1.23", "Go Dependencies", "go.mod"],
["mcr.microsoft.com/dotnet/sdk:8.0", ".NET Dependencies", "app.csproj"],
])("supports %s dependency manifests", async (image, title, filename) => {
const onSave = jest.fn().mockResolvedValue(true);
const files = image.startsWith("mcr.microsoft.com") ? [dotnetProject] : [];
render(React.createElement(DependencyManagerModal, {
isOpen: true,
onClose: jest.fn(),
functionId: 1,
image,
files,
onSave,
}));
expect(screen.getByRole("heading", { name: title })).toBeTruthy();
const selector = screen.getByRole("combobox");
expect(selector.value).toBe(filename);
fireEvent.change(screen.getByRole("textbox", { name: `${filename} content` }), {
target: { value: "updated dependency content" },
});
fireEvent.click(screen.getByRole("button", { name: "Save Dependencies" }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(filename, "updated dependency content");
});
});
});
@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from "react";
import Modal from "../Modal";
import {
cancelBtnClass,
inputClass,
labelClass,
ModalError,
ModalFooter,
primaryBtnClass,
textareaClass,
} from "../Modal";
import { useShiftEnterSubmit } from "../../../hooks/useShiftEnterSubmit";
import { FunctionFile, isDotnetImage } from "../../../types/Prisma";
interface DependencyManagerModalProps {
isOpen: boolean;
onClose: () => void;
functionId: number;
image: string;
files: FunctionFile[];
onSave: (filename: string, content: string) => Promise<boolean>;
}
type DependencyRuntime = "python" | "golang" | "dotnet";
function getRuntime(image: string): DependencyRuntime | null {
if (isDotnetImage(image)) return "dotnet";
if (image.startsWith("python")) return "python";
if (image.startsWith("golang")) return "golang";
return null;
}
function getDefaultContent(
runtime: DependencyRuntime,
filename: string,
functionId: number,
image: string,
): string {
if (filename === "requirements.txt") {
return "# Add one Python package per line\n";
}
if (filename === "go.mod") {
return `module shsf_function_${functionId}\n\ngo 1.23\n`;
}
if (filename === "go.sum") {
return "";
}
if (runtime === "dotnet" && filename.endsWith(".csproj")) {
const targetFramework = image.split(":").pop() || "8.0";
return `<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net${targetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
`;
}
return "";
}
function getManifestDescription(runtime: DependencyRuntime, filename: string): string {
if (filename === "requirements.txt") {
return "One Python package or pinned version per line. SHSF installs it during runtime setup.";
}
if (filename === "go.mod") {
return "The Go module definition. SHSF downloads modules and rebuilds the function when it runs.";
}
if (filename === "go.sum") {
return "Go module checksums. It is normally generated and maintained by the Go toolchain.";
}
if (runtime === "dotnet") {
return "The .NET project file. Add NuGet PackageReference entries here, then run .NET Build.";
}
return "Runtime dependency manifest.";
}
function DependencyManagerModal({
isOpen,
onClose,
functionId,
image,
files,
onSave,
}: DependencyManagerModalProps) {
const runtime = getRuntime(image);
const dependencyFiles = useMemo(() => {
if (!runtime) return [];
if (runtime === "python") {
return ["requirements.txt"];
}
if (runtime === "golang") {
return ["go.mod", "go.sum"];
}
const projectFiles = files
.map((file) => file.name)
.filter((filename) => filename.toLowerCase().endsWith(".csproj"));
return projectFiles.length > 0 ? projectFiles : ["project.csproj"];
}, [files, runtime]);
const [selectedFilename, setSelectedFilename] = useState("");
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const selectedFile = files.find((file) => file.name === selectedFilename);
useEffect(() => {
if (!isOpen || dependencyFiles.length === 0) return;
setSelectedFilename((current) =>
dependencyFiles.includes(current) ? current : dependencyFiles[0],
);
}, [dependencyFiles, isOpen]);
useEffect(() => {
if (!isOpen || !runtime || !selectedFilename) return;
setDraft(
selectedFile?.content ??
getDefaultContent(runtime, selectedFilename, functionId, image),
);
setError(null);
setSaved(false);
}, [functionId, image, isOpen, runtime, selectedFile, selectedFilename]);
const handleSave = async () => {
if (!selectedFilename) return;
setLoading(true);
setError(null);
setSaved(false);
try {
if (await onSave(selectedFilename, draft)) {
setSaved(true);
} else {
setError("The dependency file could not be saved.");
}
} finally {
setLoading(false);
}
};
useShiftEnterSubmit(() => handleSave(), isOpen && !loading);
if (!runtime) return null;
const runtimeLabel = runtime === "golang" ? "Go" : runtime === "dotnet" ? ".NET" : "Python";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`${runtimeLabel} Dependencies`}
maxWidth="xl"
isLoading={loading}
>
<div className="space-y-5">
<div>
<p className="text-sm text-text/80">
Manage the dependency manifest used by this {runtimeLabel} function.
</p>
<p className="mt-1 text-xs text-muted">
{runtime === "python"
? "Use requirements.txt for pip packages."
: runtime === "golang"
? "Use go.mod for modules; go.sum is kept for checksums."
: "Use the .csproj file for NuGet PackageReference entries."}
</p>
</div>
<ModalError message={error} />
{saved && (
<div className="rounded-lg border border-emerald-400/20 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
{selectedFilename} saved. {runtime === "python" ? "Use Install requirements.txt to apply it now." : runtime === "dotnet" ? "Run .NET Build to apply project changes." : "The next function run will resolve the module changes."}
</div>
)}
<div>
<label className={labelClass}>Dependency file</label>
<select
className={inputClass}
value={selectedFilename}
onChange={(event) => setSelectedFilename(event.target.value)}
disabled={loading}
>
{dependencyFiles.map((filename) => (
<option key={filename} value={filename}>
{filename}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>File content</label>
<textarea
className={`${textareaClass} min-h-[360px] font-mono text-xs`}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setSaved(false);
}}
spellCheck={false}
disabled={loading}
aria-label={`${selectedFilename} content`}
/>
<p className="mt-1.5 text-xs text-muted/70">
{getManifestDescription(runtime, selectedFilename)}
</p>
</div>
<ModalFooter>
<button className={cancelBtnClass} onClick={onClose} disabled={loading}>
Close
</button>
<button className={primaryBtnClass} onClick={handleSave} disabled={loading || !selectedFilename}>
Save Dependencies
</button>
</ModalFooter>
</div>
</Modal>
);
}
export default DependencyManagerModal;
+38 -1
View File
@@ -18,6 +18,7 @@ import LoadDefaultModal from "../../components/modals/functionFiles/LoadDefaultM
import AIGenerateModal from "../../components/modals/AIGenerateModal";
import GitVersionControlModal from "../../components/modals/functions/GitVersionControlModal";
import DependencyModal from "../../components/modals/functionDetail/DependencyModal";
import DependencyManagerModal from "../../components/modals/functionDetail/DependencyManagerModal";
import ResultModal from "../../components/modals/functionDetail/ResultModal";
import HtmlResultModal from "../../components/modals/functionDetail/HtmlResultModal";
import ImageResultModal from "../../components/modals/functionDetail/ImageResultModal";
@@ -144,6 +145,7 @@ function FunctionDetail() {
const [, setShowAllImageHeaders] = useState<boolean>(false);
const [serveHtmlOnly, setServeHtmlOnly] = useState<boolean>(false);
const [showDepModal, setShowDepModal] = useState(false);
const [showDependencyManager, setShowDependencyManager] = useState(false);
const [depModalContent, setDepModalContent] = useState<{
title: string;
message: string;
@@ -439,7 +441,17 @@ function FunctionDetail() {
});
if (data.status === "OK") {
setFiles((prev) => [...prev, { ...data.data, content }]);
setFiles((prev) => {
const alreadyExists = prev.some((file) => file.id === data.data.id);
return alreadyExists
? prev.map((file) =>
file.id === data.data.id ? { ...file, content } : file,
)
: [...prev, { ...data.data, content }];
});
if (activeFile?.id === data.data.id) {
setCode(content);
}
return { success: true, name: filename };
}
@@ -1818,6 +1830,16 @@ function FunctionDetail() {
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-primary/10 pt-4">
{!serveHtmlOnly && (isDotnetRuntime || functionData.image.startsWith("python") || functionData.image.startsWith("golang")) && (
<button
className="h-9 px-3 text-sm rounded-lg bg-background/45 border border-primary/20 text-primary hover:border-primary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
onClick={() => setShowDependencyManager(true)}
disabled={running || saving || Boolean(functionData.git_url)}
title={functionData.git_url ? "Dependency files are managed by the linked git repository" : "Edit runtime dependency manifests"}
>
Manage Dependencies
</button>
)}
{/* Show Pip Install button if requirements.txt exists or if it's a git-based function (since we don't know the files) */}
{(files.find((file) => file.name === "requirements.txt") || Boolean(functionData.git_url)) && (
<button
@@ -1980,6 +2002,21 @@ function FunctionDetail() {
{/* Modals */}
<div>
<DependencyManagerModal
isOpen={showDependencyManager}
onClose={() => setShowDependencyManager(false)}
functionId={functionData?.id ?? 0}
image={functionData?.image ?? ""}
files={files}
onSave={async (filename, content) => {
const result = await persistFile(filename, content);
if (!result.success) {
toast.error(`Error saving ${filename}: ${result.message}`);
return false;
}
return true;
}}
/>
<CreateFileModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}