Add optional FTP deploy-to-console (CLI flags + GUI checkbox, off by default)

This commit is contained in:
Claude Code committed 2026-07-22 10:09:19 +02:00
1 parent 66e8bedf23
commit 96225e370e
8 files changed
+195 -12

No files matched your search

+7 -2
View File
@@ -27,8 +27,13 @@ A Windows GUI wizard and a cross-platform CLI both scaffold the same project lay
target helpers), a `build.bat` that shells into WSL, and a `build.sh` for native Linux/WSL.
- **Automatic sanity check** — every packaged target runs `readoelf` over its own output as a
post-build step, catching malformed builds before you ever copy them to hardware.
- **GUI wizard (Windows)** — pick a name, type, output folder, and optional `.pkg` packaging;
detects your WSL distro and the OpenOrbis toolchain automatically.
- **Optional FTP deploy** — `deploy.bat`/`deploy.sh` (curl-based, no extra dependency) upload the
built artifact straight to your PS4's homebrew FTP server. Off by default; opt in via `--deploy-host`
or the GUI checkbox. No credentials baked in by design — most PS4 FTP servers run anonymous.
- **GUI wizard (Windows)** — pick a name, type, output folder, optional `.pkg` packaging, and
optional FTP deploy target; detects your WSL distro and the OpenOrbis toolchain automatically.
- **VS Code integration** — generated projects include a build task (Ctrl+Shift+B) and IntelliSense
configuration for the toolchain's unusual target triple.
- **CLI scaffolder (Windows + Linux + WSL)** — `orbisforge new --name foo --type sprx`.
## Requirements
+20
View File
@@ -16,12 +16,17 @@ var help = """
when running on Windows (where that env var normally
only exists inside WSL). Point this at the toolchain's
\\wsl.localhost\... path if using --pkg from Windows.
--deploy-host <host> Console IP/hostname to FTP the built artifact to. Presence
of this flag is what enables deploy.bat/deploy.sh generation.
--deploy-port <port> FTP port (default: 21)
--deploy-path <path> Remote directory to upload into (default: /)
-h, --help Show this help
Examples:
orbisforge new --name hello_cmake --type exe
orbisforge new --name my_plugin --type sprx --output E:\Projects\PS4
orbisforge new --name my_game --type exe --pkg
orbisforge new --name my_game --type exe --deploy-host 192.168.1.50 --deploy-path /data/homebrew/my_game/
""";
if (args.Length == 0 || args[0] is "-h" or "--help" or "help")
@@ -42,6 +47,9 @@ string outputDir = Directory.GetCurrentDirectory();
bool includePackaging = false;
string? ptype = null;
string? toolchainPath = null;
string? deployHost = null;
int deployPort = 21;
string deployPath = "/";
for (int i = 1; i < args.Length; i++)
{
@@ -65,6 +73,15 @@ for (int i = 1; i < args.Length; i++)
case "--toolchain-path":
toolchainPath = RequireValue(args, ref i, "--toolchain-path");
break;
case "--deploy-host":
deployHost = RequireValue(args, ref i, "--deploy-host");
break;
case "--deploy-port":
deployPort = int.Parse(RequireValue(args, ref i, "--deploy-port"));
break;
case "--deploy-path":
deployPath = RequireValue(args, ref i, "--deploy-path");
break;
case "-h":
case "--help":
Console.WriteLine(help);
@@ -101,6 +118,9 @@ var options = new ProjectOptions
IncludePackaging = includePackaging,
PType = ptype,
ToolchainPathOverride = toolchainPath,
DeployHost = deployHost,
DeployPort = deployPort,
DeployRemotePath = deployPath,
};
var result = ProjectGenerator.Generate(options);
+10
View File
@@ -29,6 +29,16 @@ public static class OrbisTargetTypeExtensions
public static bool IsLibrary(this OrbisTargetType type) =>
type is OrbisTargetType.Prx or OrbisTargetType.Sprx;
/// <summary>Path to the packaged artifact create-fself produces, relative to the
/// project root (i.e. under the CMake build directory).</summary>
public static string ArtifactRelativePath(this OrbisTargetType type, string name) => type switch
{
OrbisTargetType.Exe => "build/eboot.bin",
OrbisTargetType.Prx => $"build/{name}.prx",
OrbisTargetType.Sprx => $"build/{name}.sprx",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
};
public static bool TryParse(string text, out OrbisTargetType type)
{
switch (text.Trim().ToUpperInvariant())
+6
View File
@@ -48,6 +48,12 @@ public static class ProjectGenerator
CopyPackagingAssets(root, options.ToolchainPathOverride, files, warnings);
}
if (options.IncludeDeploy)
{
WriteFile(Path.Combine(root, "deploy.sh"), Unix(DeployScriptsTemplate.BuildSh(options)), files);
WriteFile(Path.Combine(root, "deploy.bat"), Windows(DeployScriptsTemplate.BuildBat(options)), files);
}
return new GeneratedProject
{
RootDirectory = root,
+15
View File
@@ -27,6 +27,21 @@ public sealed class ProjectOptions
/// \\wsl.localhost\... UNC equivalent here instead.</summary>
public string? ToolchainPathOverride { get; set; }
/// <summary>Console IP/hostname to deploy the built artifact to via FTP. Null/empty
/// disables deploy script generation entirely -- this is the opt-in flag, there's no
/// separate boolean, since a deploy target without a host is meaningless. Deliberately
/// no username/password fields: most PS4 homebrew FTP servers run anonymous, and baking
/// credentials into a script that might get committed to git is bad hygiene -- edit the
/// generated script by hand if your setup needs auth.</summary>
public string? DeployHost { get; set; }
public int DeployPort { get; set; } = 21;
/// <summary>Remote directory the artifact is uploaded into, e.g. "/data/homebrew/my_game/".</summary>
public string DeployRemotePath { get; set; } = "/";
public bool IncludeDeploy => !string.IsNullOrWhiteSpace(DeployHost);
public string SanitizedName => Sanitize(Name);
private static string Sanitize(string name)
@@ -0,0 +1,53 @@
namespace OrbisForge.Core.Templates;
internal static class DeployScriptsTemplate
{
// Uses curl (built into Windows 10 1803+/System32, and present on essentially every
// Linux/WSL install) for a single dependency-free tool on both platforms, rather than
// hand-rolling FTP with FtpWebRequest/lftp/ncftp. --ftp-create-dirs makes the first
// deploy work even if the remote directory doesn't exist yet. No credentials here on
// purpose -- see ProjectOptions.DeployHost's doc comment.
public static string BuildBat(ProjectOptions options)
{
var artifact = options.Type.ArtifactRelativePath(options.SanitizedName).Replace('/', '\\');
return $"""
@echo off
setlocal
set "SCRIPTDIR=%~dp0"
if "%SCRIPTDIR:~-1%"=="\" set "SCRIPTDIR=%SCRIPTDIR:~0,-1%"
set "ARTIFACT=%SCRIPTDIR%\{artifact}"
set "DEPLOY_URL=ftp://{options.DeployHost}:{options.DeployPort}{options.DeployRemotePath}"
if not exist "%ARTIFACT%" (
echo Build artifact not found: %ARTIFACT%
echo Build the project first ^(build.bat^).
exit /b 1
)
curl -T "%ARTIFACT%" "%DEPLOY_URL%" --ftp-create-dirs
""";
}
public static string BuildSh(ProjectOptions options)
{
var artifact = options.Type.ArtifactRelativePath(options.SanitizedName);
return $$"""
#!/bin/bash
set -e
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARTIFACT="{{artifact}}"
DEPLOY_URL="ftp://{{options.DeployHost}}:{{options.DeployPort}}{{options.DeployRemotePath}}"
if [ ! -f "$ARTIFACT" ]; then
echo "Build artifact not found: $ARTIFACT" >&2
echo "Build the project first (bash build.sh)." >&2
exit 1
fi
curl -T "$ARTIFACT" "$DEPLOY_URL" --ftp-create-dirs
""";
}
}
@@ -5,19 +5,26 @@ internal static class ReadmeTemplate
public static string Render(ProjectOptions options)
{
var name = options.SanitizedName;
var outputs = options.Type switch
{
OrbisTargetType.Exe => "build/eboot.bin",
OrbisTargetType.Prx => $"build/{name}.prx",
OrbisTargetType.Sprx => $"build/{name}.sprx",
_ => "build/",
};
var outputs = options.Type.ArtifactRelativePath(name);
var pkgNote = options.IncludePackaging && options.Type == OrbisTargetType.Exe
? "\nTo build the full retail-style installer: `cmake --build build --target {0}_pkg` (Windows: `wsl -- bash -c \"cd '<wsl-path>' && cmake --build build --target {0}_pkg\"`). Produces `build/pkg_root/*.pkg`.\n"
: "";
pkgNote = string.Format(pkgNote, name);
var deployNote = options.IncludeDeploy
? $"""
## Deploying
`deploy.bat` / `bash deploy.sh` FTP the built `{outputs}` to `{options.DeployHost}:{options.DeployPort}{options.DeployRemotePath}`.
Build first -- deploy just uploads whatever's already in `build/`. No credentials are
baked in (most PS4 homebrew FTP servers run anonymous); edit the script by hand if yours
needs auth.
"""
: "";
return $$"""
# {{options.Name}}
@@ -35,7 +42,7 @@ internal static class ReadmeTemplate
**Windows:** double-click `build.bat`, or `build.bat clean` to wipe the build directory.
**Linux / WSL (native):** `bash build.sh`, or `bash build.sh clean` (use `bash` rather than `./build.sh` since files written from Windows don't carry the executable bit).
{{pkgNote}}
{{pkgNote}}{{deployNote}}
## VS Code
Open this folder in VS Code and press **Ctrl+Shift+B** to build (`.vscode/tasks.json` runs
@@ -54,7 +61,7 @@ internal static class ReadmeTemplate
- `cmake/OrbisTargets.cmake` - `add_orbis_target()` / `add_orbis_package()` helpers
- `CMakeLists.txt` - project definition
- `.vscode/` - build task + IntelliSense configuration
- `.gitignore` - excludes `build/`
- `.gitignore` - excludes `build/`{{(options.IncludeDeploy ? "\n - `deploy.bat` / `deploy.sh` - FTP the built artifact to your PS4" : "")}}
""";
}
}
+68 -1
View File
@@ -16,6 +16,10 @@ namespace OrbisForge.Gui
private readonly SkinComboBox _typeBox = new SkinComboBox();
private readonly SkinTextBox _outputBox = new SkinTextBox();
private readonly CheckBox _pkgCheck = new CheckBox();
private readonly CheckBox _deployCheck = new CheckBox();
private readonly SkinTextBox _deployHostBox = new SkinTextBox();
private readonly SkinTextBox _deployPortBox = new SkinTextBox();
private readonly SkinTextBox _deployPathBox = new SkinTextBox();
private readonly SkinLabel _envLabel = new SkinLabel();
private readonly SkinButton _generateButton = new SkinButton();
private readonly TextBox _statusBox = new TextBox();
@@ -25,7 +29,7 @@ namespace OrbisForge.Gui
public WizardForm()
{
Text = "OrbisForge — OpenOrbis PS4 Project Wizard";
ClientSize = new Size(520, 460);
ClientSize = new Size(520, 590);
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Dpi;
AutoScaleDimensions = new SizeF(96F, 96F);
@@ -69,6 +73,30 @@ namespace OrbisForge.Gui
_pkgCheck.AutoSize = true;
y += 28;
_deployCheck.Text = "Deploy to PS4 via FTP after build";
_deployCheck.Location = new Point(left, y);
_deployCheck.AutoSize = true;
_deployCheck.CheckedChanged += (s, e) => UpdateDeployFieldsEnabled();
y += 24;
var deployHostLabel = new SkinLabel { Text = "Host:", Location = new Point(left, y), AutoSize = true };
var deployPortLabel = new SkinLabel { Text = "Port:", Location = new Point(left + 220, y), AutoSize = true };
y += 20;
_deployHostBox.Location = new Point(left, y);
_deployHostBox.Size = new Size(200, 24);
_deployHostBox.PlaceholderText = "192.168.1.100";
_deployPortBox.Location = new Point(left + 220, y);
_deployPortBox.Size = new Size(84, 24);
_deployPortBox.Text = "21";
y += 32;
var deployPathLabel = new SkinLabel { Text = "Remote path:", Location = new Point(left, y), AutoSize = true };
y += 20;
_deployPathBox.Location = new Point(left, y);
_deployPathBox.Size = new Size(fieldWidth, 24);
_deployPathBox.PlaceholderText = "/data/homebrew/my_project/";
y += 36;
_envLabel.Location = new Point(left, y);
_envLabel.AutoSize = true;
_envLabel.Text = "Checking WSL / OpenOrbis toolchain…";
@@ -95,11 +123,19 @@ namespace OrbisForge.Gui
root.Controls.Add(_outputBox);
root.Controls.Add(browseButton);
root.Controls.Add(_pkgCheck);
root.Controls.Add(_deployCheck);
root.Controls.Add(deployHostLabel);
root.Controls.Add(deployPortLabel);
root.Controls.Add(_deployHostBox);
root.Controls.Add(_deployPortBox);
root.Controls.Add(deployPathLabel);
root.Controls.Add(_deployPathBox);
root.Controls.Add(_envLabel);
root.Controls.Add(_generateButton);
root.Controls.Add(_statusBox);
UpdatePkgCheckEnabled();
UpdateDeployFieldsEnabled();
Shown += (s, e) => DetectEnvironment();
}
@@ -110,6 +146,14 @@ namespace OrbisForge.Gui
if (!isExe) _pkgCheck.Checked = false;
}
private void UpdateDeployFieldsEnabled()
{
bool enabled = _deployCheck.Checked;
_deployHostBox.Enabled = enabled;
_deployPortBox.Enabled = enabled;
_deployPathBox.Enabled = enabled;
}
private OrbisTargetType SelectedType()
{
switch (_typeBox.SelectedIndex)
@@ -177,6 +221,26 @@ namespace OrbisForge.Gui
toolchainOverride = _wsl.ToolchainUncPath;
}
string deployHost = null;
int deployPort = 21;
string deployPath = "/";
if (_deployCheck.Checked)
{
deployHost = _deployHostBox.Text.Trim();
if (deployHost.Length == 0)
{
MessageBox.Show(this, "Enter a host/IP for FTP deploy, or uncheck the deploy option.", "OrbisForge", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (!int.TryParse(_deployPortBox.Text.Trim(), out deployPort) || deployPort <= 0)
{
MessageBox.Show(this, "FTP port must be a positive number.", "OrbisForge", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
deployPath = _deployPathBox.Text.Trim();
if (deployPath.Length == 0) deployPath = "/";
}
var options = new ProjectOptions
{
Name = name,
@@ -184,6 +248,9 @@ namespace OrbisForge.Gui
OutputDirectory = _outputBox.Text,
IncludePackaging = includePkg,
ToolchainPathOverride = toolchainOverride,
DeployHost = deployHost,
DeployPort = deployPort,
DeployRemotePath = deployPath,
};
try