Skip to content
Open
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
17 changes: 10 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,23 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14

- name: Set version from tag
shell: bash
run: |
VERSION="${{ inputs.version || github.ref_name }}"
VERSION="${VERSION#v}"
npm version "$VERSION" --no-git-tag-version

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun pm pkg set version="$VERSION"

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile

- name: Validate
run: bun run release:validate

- name: Build for Windows
if: matrix.platform == 'win'
Expand Down
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A standalone Electron application for uploading mods to the Steam Workshop for "

## Prerequisites

- [Bun](https://bun.sh/) (v1.0 or higher) - or Node.js v18+
- [Bun](https://bun.sh/) 1.3 or newer
- Steam client must be running
- You must own "Ascend from Nine Mountains" on Steam

Expand All @@ -41,12 +41,7 @@ bun run dev

## Building

### For Windows (Portable):
```bash
bun run build:portable
```

### For Windows (Installer):
### For Windows (Installer + Portable):
```bash
bun run build:win
```
Expand Down Expand Up @@ -90,8 +85,11 @@ bun run cli:upload -- --workshop-id <published-file-id> --zip /absolute/path/to/
Notes:
- Steam still must be running and logged into the account that owns the workshop item.
- `--workshop-id` is required for updates. Omit it only when intentionally creating a new item and include `--allow-create`.
- On updates, omitting `--visibility` preserves the item's current Workshop visibility. On new item creation, the default remains `private`.
- Workshop ID `0` and malformed IDs are rejected.
- On updates, omitting `--visibility` preserves the item's current Workshop visibility. New items default to `public`.
- If `--preview` is supplied, the file must exist.
- `--json` prints machine-readable output for wrapper scripts.
- `--version` prints the uploader version.

## Mod Structure

Expand Down Expand Up @@ -147,12 +145,22 @@ ModUploader-AFNM/

## Technical Details

- Built with Electron 40, React 19, TypeScript 5.9, and Vite 7
- Built with Electron 43, React 19.2, TypeScript 7, and Vite 8/Rolldown
- Uses [`@pipelab/steamworks.js`](https://github.com/CynToolkit/steamworks.js) for Steam Workshop integration (actively maintained community fork)
- Styled to match the game's visual theme
- Supports Windows and Linux platforms
- Uses Bun as the package manager and runtime

## Release validation

```bash
bun install --frozen-lockfile
bun run release:validate
```

The release workflow targets the upstream `Lyeeedar/ModUploader` release
repository and produces Windows NSIS/portable plus Linux AppImage artifacts.

## Troubleshooting

- **Steam not detected**: Make sure Steam is running before launching the app. The app will show connection status in the header.
Expand Down
13 changes: 13 additions & 0 deletions RELEASE_NOTES_1.8.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Mod Uploader 1.8.0

- Upgrades to Electron 43, Vite 8/Rolldown, TypeScript 7, React 19.2,
steamworks.js 0.12.2, electron-builder 26.15, and electron-updater 6.8.
- Uses Bun exclusively and removes the npm lockfile.
- Externalizes native/runtime Electron dependencies with
`vite-plugin-electron` 1.x `notBundle()`.
- Adds full-project typechecking, Bun tests, and production validation.
- Adds testable CLI/metadata/upload-detail parsers and `--version`.
- Rejects Workshop ID `0`, missing previews, and invalid inputs before Steam.
- Preserves visibility on updates and defaults new items to public.
- Produces Windows installer/portable and Linux AppImage artifacts while keeping
updater metadata pointed at upstream `Lyeeedar/ModUploader`.
482 changes: 262 additions & 220 deletions bun.lock

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions electron/main/cli-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test';
import {
buildUploadData,
parseUploadArgs,
validateUploadArgs,
} from './cli-options';

describe('CLI parsing', () => {
test('parses existing flags and additive --version', () => {
expect(parseUploadArgs(['electron', '.', '--cli-upload', '--version']).version)
.toBe(true);
const args = parseUploadArgs([
'electron',
'.',
'--cli-upload',
'--zip',
'mod.zip',
'--workshop-id',
'123',
'--visibility',
'unlisted',
'--json',
]);
expect(args.json).toBe(true);
expect(buildUploadData(args, '/tmp')).toEqual(
expect.objectContaining({
zipPath: '/tmp/mod.zip',
workshopId: '123',
visibility: 'unlisted',
}),
);
});

test('rejects JSON upload failures before touching Steam', () => {
const args = parseUploadArgs([
'electron',
'.',
'--cli-upload',
'--zip',
'missing.zip',
'--workshop-id',
'123',
'--json',
]);
expect(() => validateUploadArgs(args, () => false, '/tmp')).toThrow(
'ZIP file not found',
);
});

test('rejects a requested preview that does not exist', () => {
const args = parseUploadArgs([
'electron',
'.',
'--cli-upload',
'--zip',
'mod.zip',
'--workshop-id',
'123',
'--preview',
'missing.png',
]);
expect(() =>
validateUploadArgs(args, (value) => value.endsWith('mod.zip'), '/tmp'),
).toThrow('Preview image not found');
});
});
137 changes: 137 additions & 0 deletions electron/main/cli-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as path from 'node:path';
import type { ModUploadData, ModVisibility } from '../../src/types';
import { parseWorkshopId } from './upload-details';

export interface UploadCliArgs {
zipPath?: string;
workshopId?: string;
title?: string;
description?: string;
tags?: string;
visibility?: ModVisibility;
previewImagePath?: string;
changeNotes?: string;
allowCreate: boolean;
json: boolean;
openWorkshopPage: boolean;
help: boolean;
version: boolean;
}

const VALID_VISIBILITY = new Set<ModVisibility>([
'public',
'friends',
'private',
'unlisted',
]);

function consumeValue(args: string[], index: number, flag: string): string {
const value = args[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${flag}`);
}
return value;
}

export function parseUploadArgs(argv: string[]): UploadCliArgs {
const cliIndex = argv.indexOf('--cli-upload');
const args = cliIndex >= 0 ? argv.slice(cliIndex + 1) : argv.slice(2);
const parsed: UploadCliArgs = {
allowCreate: false,
json: false,
openWorkshopPage: false,
help: false,
version: false,
};

for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (
[
'--zip',
'--workshop-id',
'--change-note',
'--title',
'--description',
'--tags',
'--visibility',
'--preview',
].includes(arg)
) {
const value = consumeValue(args, index, arg);
index += 1;
switch (arg) {
case '--zip': parsed.zipPath = value; break;
case '--workshop-id': parsed.workshopId = value; break;
case '--change-note': parsed.changeNotes = value; break;
case '--title': parsed.title = value; break;
case '--description': parsed.description = value; break;
case '--tags': parsed.tags = value; break;
case '--preview': parsed.previewImagePath = value; break;
case '--visibility':
if (!VALID_VISIBILITY.has(value as ModVisibility)) {
throw new Error(`Invalid visibility "${value}"`);
}
parsed.visibility = value as ModVisibility;
break;
}
} else {
switch (arg) {
case '--help': parsed.help = true; break;
case '--version': parsed.version = true; break;
case '--allow-create': parsed.allowCreate = true; break;
case '--json': parsed.json = true; break;
case '--open-workshop-page': parsed.openWorkshopPage = true; break;
default: throw new Error(`Unknown argument: ${arg}`);
}
}
}
return parsed;
}

export function toAbsolutePath(
filePath: string,
cwd = process.cwd(),
): string {
return path.isAbsolute(filePath)
? path.normalize(filePath)
: path.resolve(cwd, filePath);
}

export function buildUploadData(
args: UploadCliArgs,
cwd = process.cwd(),
): ModUploadData {
return {
zipPath: args.zipPath ? toAbsolutePath(args.zipPath, cwd) : undefined,
workshopId: args.workshopId?.trim(),
title: args.title || '',
description: args.description || '',
tags: args.tags,
visibility: args.visibility,
previewImagePath: args.previewImagePath
? toAbsolutePath(args.previewImagePath, cwd)
: undefined,
changeNotes: args.changeNotes,
};
}

export function validateUploadArgs(
args: UploadCliArgs,
exists: (filePath: string) => boolean,
cwd = process.cwd(),
): void {
if (!args.zipPath) throw new Error('Missing required --zip argument');
const zip = toAbsolutePath(args.zipPath, cwd);
if (!exists(zip)) throw new Error(`ZIP file not found: ${zip}`);
if (!args.workshopId && !args.allowCreate) {
throw new Error(
'Missing --workshop-id. Use --allow-create only for an intentional new item.',
);
}
if (args.workshopId) parseWorkshopId(args.workshopId, '--workshop-id');
if (args.previewImagePath) {
const preview = toAbsolutePath(args.previewImagePath, cwd);
if (!exists(preview)) throw new Error(`Preview image not found: ${preview}`);
}
}
Loading