79 Commits
v0.2.0 ... main

Author SHA1 Message Date
971d1ecf90 chore(changelog): update changelog for v0.5.1
All checks were successful
Upload Assets / upload-assets (arm64, linux) (release) Successful in 1m31s
Upload Assets / upload-assets (amd64, linux) (release) Successful in 1m34s
2025-06-15 14:52:14 +00:00
6bd93223f6 chore(version): bump to 0.5.1
All checks were successful
Auto Changelog & Release / release (push) Successful in 6s
CI / build (push) Successful in 15s
2025-06-15 16:52:05 +02:00
b3f9075a01 chore(changelog): update unreleased changelog 2025-06-15 14:51:30 +00:00
05f3b519f5 chore(workflows): simplify release workflow with auto-changelog
All checks were successful
Auto Changelog & Release / release (push) Successful in 6s
CI / build (push) Successful in 23s
- Replaces custom release and changelog logic with auto-changelog action
- Reduces complexity and maintenance effort in the release process
- Ensures consistent changelog generation and release creation
2025-06-15 16:51:21 +02:00
3a1ee0cfd6 feat(install): add support for compressed .zst archives
- Introduces detection for 'zstd' to handle .zst archives if available
- Adds checksum verification and decompression for .zst files
- Falls back to uncompressed binary download if 'zstd' is unavailable
- Improves installation flexibility and reduces binary download size
2025-06-15 16:51:21 +02:00
d9183d2f04 feat(workflows): add zstd compression for build artifacts
- Introduce zstd compression for build artifacts to reduce file size
- Generate and upload SHA256 checksum for compressed archives
- Enhance artifact handling in release workflows
2025-06-15 16:51:21 +02:00
88bae5ef3e chore(changelog): update unreleased changelog 2025-06-14 15:42:16 +00:00
6efc1515ed chore(workflows): add locked flag to cargo install for git-cliff
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Successful in 12s
CI / build (push) Successful in 22s
Auto Changelog & Release / release (push) Has been skipped
- Ensures reproducible builds by adding the `--locked` flag to cargo install
- Improves dependency resolution consistency during git-cliff installation
2025-06-14 17:41:59 +02:00
1dee6111bd chore(changelog): update unreleased changelog 2025-05-30 11:01:07 +00:00
ce78cf3a9a docs(readme): add input validation and multilingual support
All checks were successful
CI / build (push) Successful in 13s
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Successful in 7s
Auto Changelog & Release / release (push) Has been skipped
- Document validation of inputs, e.g., systemd `OnCalendar` expressions
- Highlight added multilingual support for English and German
2025-05-30 13:00:38 +02:00
a7f18f0b80 chore(changelog): update changelog for v0.5.0
All checks were successful
Upload Assets / upload-assets (amd64, linux) (release) Successful in 28s
Upload Assets / upload-assets (arm64, linux) (release) Successful in 28s
2025-05-30 10:48:57 +00:00
871d0e26a7 chore(version): bump version to 0.5.0
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
Auto Changelog & Release / changelog-only (push) Has been skipped
CI / build (push) Successful in 13s
Auto Changelog & Release / release (push) Successful in 8s
2025-05-30 12:48:43 +02:00
f984f79452 chore(changelog): update unreleased changelog 2025-05-30 10:47:56 +00:00
3f3ce2ca0d chore(changelog): update unreleased changelog
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
CI / build (push) Successful in 13s
2025-05-30 12:47:32 +02:00
28b23cf947 chore(lock): update dependencies to latest versions 2025-05-30 12:47:24 +02:00
1c07af402b feat(cli): add validation for command options 2025-05-30 12:47:24 +02:00
3d95706d68 feat(validation): add CLI validation helpers for input checks
- Introduce functions to validate CLI inputs like paths, identifiers, and environment variables
- Add error messages for invalid inputs to support user feedback
- Include unit tests to ensure correctness and robustness of validation logic
2025-05-30 12:47:23 +02:00
ccb04e4982 feat(build): add run permissions to compiled binaries 2025-05-30 12:47:23 +02:00
2abe90e9aa chore(changelog): update unreleased changelog 2025-05-30 10:24:22 +00:00
531a02a6e1 feat(ci): add compile steps to CI workflow
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 7s
CI / build (push) Successful in 15s
- Introduces compile steps for amd64 and arm64 targets in CI workflow
- Updates failure condition to include compile step outcomes
- Enhances local CI task to include amd64 build process
2025-05-30 12:24:10 +02:00
f3f2c61da0 fix(build): update included files to use .jsonc format 2025-05-30 12:23:51 +02:00
32d472a606 chore(pr): Support JSONC locale files and update i18n handling #4
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
Auto Changelog & Release / release (push) Has been skipped
CI / build (push) Successful in 7s
Auto Changelog & Release / changelog-only (push) Successful in 5s
- Add `@std/jsonc` as a dependency in `deno.jsonc` and `deno.lock`
- Update i18n loader to support `.jsonc` files with comments
- Rename locale files from `.json` to `.jsonc` and add inline comments
- Set VSCode to use Deno JSONC formatter for `jsonc` files

This change enables the i18n module to load translation files written in JSONC format, allowing for inline comments and improved readability. The `@std/jsonc` library is added as a dependency and used to parse both JSON and JSONC files. The loader prioritizes `.jsonc` files when both `.json` and `.jsonc` exist. VSCode settings are updated to use the Deno formatter for JSONC files. Locale files are renamed and enhanced with comments for clarity.

Merged from feature/change-language-json-to-jsonc into main
2025-05-30 11:46:33 +02:00
c6d5bf60fc chore(changelog): update unreleased changelog
All checks were successful
CI / build (pull_request) Successful in 7s
2025-05-30 09:45:37 +00:00
c7af1fb6ca feat(vscode): add JSONC formatter configuration
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
2025-05-30 11:45:15 +02:00
5226269ec2 style(i18n): add comments for clarity and rename files
- Adds inline comments to JSON files to improve readability
- Renames `.json` files to `.jsonc` to support comments
- Improves organization of translation entries
2025-05-30 11:45:00 +02:00
4ac5dd4c88 feat(i18n): support loading JSONC translation files
- Adds support for `.jsonc` format alongside `.json` for locale files
- Prioritizes `.jsonc` format when both file types are available
2025-05-30 11:44:09 +02:00
8f1cb3fad7 feat(config): add @std/jsonc dependency
- Adds @std/jsonc dependency to improve JSONC handling capabilities
- Updates lock file to include integrity and dependency data for @std/jsonc
2025-05-30 11:43:46 +02:00
f4c7b2e18f chore(pr): add rollback tests and update test descriptions #3
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
CI / build (push) Successful in 6s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 5s
- Add tests for rollback behavior in `writeUnitFiles` on errors
- Simulate file write and delete failures in test scenarios
- Log rollback failures when file deletion is not possible
- Update all test descriptions and comments to English
- Add `testing/mock.ts` dependency in `deno.lock`

This change adds comprehensive tests for the `writeUnitFiles` function to verify that files are properly rolled back if errors occur during their creation. The tests simulate failures when writing `.service` or `.timer` files, as well as when file deletion is blocked, and check that the function responds by cleaning up as expected or logging rollback failures. All test comments and descriptions have been updated to English for clarity. The `testing/mock.ts` dependency was added to enable function stubbing in tests.

#2

Merged from test/fail-write-and-rollback into main
2025-05-30 11:21:41 +02:00
84a883fd8f chore(changelog): update unreleased changelog 2025-05-30 09:21:20 +00:00
e5f9f2c45a chore(changelog): update unreleased changelog
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
Auto Changelog & Release / release (push) Has been skipped
CI / build (pull_request) Successful in 6s
Auto Changelog & Release / changelog-only (push) Successful in 7s
2025-05-30 11:21:10 +02:00
f3c46e1222 chore(deps): update deno.lock with new dependency
- Adds a new dependency to deno.lock for testing utilities
2025-05-30 11:21:10 +02:00
c4f4614a2d test(fs): update test descriptions and comments to English
- Translate all test descriptions and inline comments from German to English
2025-05-30 11:21:10 +02:00
6039d236eb test(fs): add rollback tests for writeUnitFiles errors
- Add tests to verify rollback behavior when file writing fails
- Simulate various error scenarios to ensure proper cleanup
- Log rollback failures when file deletion is prohibited
2025-05-30 11:21:10 +02:00
333341d3fd chore(pr): add default merge message template
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
CI / build (push) Successful in 7s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Introduces a template for merge messages in pull requests
- Standardizes format with placeholders for PR details
2025-05-30 11:20:46 +02:00
38afcf210e chore(changelog): update changelog for v0.4.1
All checks were successful
Upload Assets / upload-assets (amd64, linux) (release) Successful in 28s
Upload Assets / upload-assets (arm64, linux) (release) Successful in 29s
2025-05-28 16:34:22 +00:00
28b18dc994 chore(version): bump version to 0.4.1
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Has been skipped
CI / build (push) Successful in 7s
Auto Changelog & Release / release (push) Successful in 7s
2025-05-28 18:34:09 +02:00
ede012317b chore(changelog): update unreleased changelog 2025-05-28 16:34:01 +00:00
a22c156dd3 fix(tasks): add read permissions to build scripts
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
CI / build (push) Successful in 6s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Allow read permissions in build scripts for both amd64 and arm64 targets
- Ensure compatibility with files required during the build process
2025-05-28 18:33:47 +02:00
d648d5a3f1 chore(changelog): update changelog for v0.4.0
All checks were successful
Upload Assets / upload-assets (amd64, linux) (release) Successful in 28s
Upload Assets / upload-assets (arm64, linux) (release) Successful in 27s
2025-05-28 16:17:26 +00:00
bb51982f6e chore(version): bump to 0.4.0
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Has been skipped
CI / build (push) Successful in 6s
Auto Changelog & Release / release (push) Successful in 13s
2025-05-28 18:17:07 +02:00
3416610486 chore(changelog): update unreleased changelog 2025-05-28 16:15:06 +00:00
54d71ba3f0 style(i18n): add missing newline at EOF in JSON files
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
CI / build (push) Successful in 7s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Ensures JSON files adhere to standard formatting by adding a newline
  at the end of the file.
2025-05-28 18:14:51 +02:00
c02da70902 feat(vscode): enable Deno support and configure JSON formatting
- Enables Deno integration in VS Code settings
- Sets the default JSON formatter to Deno's extension
- Activates format-on-save for improved workflow
2025-05-28 18:14:51 +02:00
9ad407e531 feat(ci): add CI workflow with format, lint, and test steps
- Introduces a CI workflow triggered on push, pull requests, and manual dispatch
- Includes steps for code formatting, linting, and testing using Deno
- Fails the workflow if any of the steps do not succeed
2025-05-28 18:14:51 +02:00
07730e5761 feat(tasks): add formatting, linting, and CI tasks
- Introduces new tasks for code formatting and linting
- Adds a CI task combining formatting, linting, and testing
2025-05-28 18:14:51 +02:00
1f79c1a15a chore(changelog): update unreleased changelog 2025-05-28 16:10:19 +00:00
440130f782 feat(tasks): include localization files in build commands
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Add German and English localization files to build outputs
- Ensure compiled binaries include required resources for i18n support
2025-05-28 18:10:04 +02:00
2a13ee2539 refactor(cli): integrate i18n support across commands
- Centralize CLI text strings using the i18n module for localization
- Refactor `createCommand` and `createCli` to improve modularity
- Update logging and error messages to use translated strings
2025-05-28 18:09:52 +02:00
8efbee1ba9 test(i18n): add unit tests for localization functions
- Add tests for `loadLocale` to verify translations load correctly
- Add tests for `t` to ensure fallback behavior for missing keys
- Add tests for `getCurrentLanguage` to validate language detection logic
2025-05-28 18:09:24 +02:00
bd5ea80aff feat(i18n): add German and English translations for CLI tool
- Introduce localization files for German and English languages
- Provide translations for CLI tool options and error messages
2025-05-28 18:09:16 +02:00
c9b4c8bd71 feat(i18n): add i18n module for localization support
- Introduce functions to initialize and load locale files dynamically
- Add support for translation keys with placeholder replacements
- Default to English if locale files are missing or not found
- Determine system language using environment variables
2025-05-28 18:09:04 +02:00
dfa92d8069 chore(vscode): update folder listener with i18n directory
- Adds the /src/i18n directory to the folder listener configuration
- Ensures changes in the i18n folder are tracked as part of the setup
2025-05-28 18:08:37 +02:00
ef052e9f66 chore(changelog): update changelog for v0.3.1
All checks were successful
Upload Assets / upload-assets (arm64, linux) (release) Successful in 29s
Upload Assets / upload-assets (amd64, linux) (release) Successful in 43s
2025-05-28 12:40:53 +00:00
c53576a700 chore(version): bump version to 0.3.1
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Has been skipped
Auto Changelog & Release / release (push) Successful in 8s
2025-05-28 14:40:42 +02:00
3d74ec37e4 chore(changelog): update unreleased changelog 2025-05-28 12:40:16 +00:00
c4855ed3fb style(workflows): fix formatting and whitespace issues
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 7s
- Standardize quotes in branch matching patterns
- Remove unnecessary trailing whitespace
- Add export of author and committer dates for consistent tags
- Ensure proper environment variable alignment
2025-05-28 14:39:42 +02:00
07ee03b6be chore(version): bump version to 0.3.0
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Has been skipped
Auto Changelog & Release / release (push) Successful in 8s
2025-05-28 14:26:02 +02:00
fb2a62d984 docs(readme): expand CLI option descriptions for clarity
- Add detailed explanations for each CLI option in README files
- Improve consistency and formatting for better readability
- Highlight supported features and usage comprehensively
2025-05-28 14:26:02 +02:00
113103f368 feat(cli): add options for user, home, and working directory
- Add `--run-as` option to specify user for system-wide timers
- Add `--home` option to set the HOME environment variable
- Add `--cwd` option to define the working directory
- Update tests to validate new options and behavior
2025-05-28 14:26:02 +02:00
f112002249 chore(changelog): update unreleased changelog 2025-05-26 15:27:10 +00:00
0c1d8be79f chore(workflows): consolidate release sync into upload workflow
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Remove separate workflow for syncing releases to GitHub
- Integrate release sync steps into the upload-assets workflow
- Simplify workflow management and reduce duplication
2025-05-26 17:27:00 +02:00
e3caf0bba9 chore(changelog): update changelog for v0.2.3
All checks were successful
Sync Release to GitHub / sync-github (release) Successful in 6s
Upload Assets / upload-assets (amd64, linux) (release) Successful in 13s
Upload Assets / upload-assets (arm64, linux) (release) Successful in 12s
2025-05-26 15:24:44 +00:00
287cd741b4 chore(version): bump version to 0.2.3
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
Auto Changelog & Release / changelog-only (push) Has been skipped
Auto Changelog & Release / release (push) Successful in 8s
2025-05-26 17:24:28 +02:00
8b29105686 chore(changelog): update unreleased changelog 2025-05-26 15:22:28 +00:00
27c7367ef1 feat(workflows): add GitHub release synchronization workflow
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 2s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Introduces a workflow to sync Gitea releases with GitHub
- Supports manual dispatch and reacts to release events
- Utilizes secrets for secure token management
2025-05-26 17:22:17 +02:00
f9cef4b70d chore(changelog): update unreleased changelog 2025-05-22 18:52:03 +00:00
e3a3e61bce docs(readme): update project time badge interval
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 29s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 32s
- Adjusts the project time badge interval from "today" to "any"
2025-05-22 20:50:59 +02:00
bcec1f8f90 chore(changelog): update unreleased changelog 2025-05-22 11:25:35 +00:00
cf483de06b docs: add Englisch README
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 4s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
2025-05-22 13:25:23 +02:00
d9c9eda823 chore(changelog): update changelog for v0.2.2
All checks were successful
Upload Assets / upload-assets (amd64, linux) (release) Successful in 9s
Upload Assets / upload-assets (arm64, linux) (release) Successful in 9s
2025-05-22 08:59:11 +00:00
4737dbb60d chore(version): bump version to 0.2.2
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Has been skipped
Auto Changelog & Release / release (push) Successful in 6s
2025-05-22 10:58:59 +02:00
f82249d10f chore(changelog): update changelog for v0.2.1
All checks were successful
Upload Assets / upload-assets (arm64, linux) (release) Successful in 11s
Upload Assets / upload-assets (amd64, linux) (release) Successful in 14s
2025-05-22 08:39:39 +00:00
d46ad2c2b4 chore(version): bump version to 0.2.1
Some checks failed
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / changelog-only (push) Has been skipped
Auto Changelog & Release / release (push) Failing after 7s
2025-05-22 10:39:24 +02:00
7911a7ed6f chore(changelog): update unreleased changelog 2025-05-22 08:38:13 +00:00
9853f854c9 docs(readme): update installation instructions with script
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 5s
- Add installation instructions using a shell script for convenience
- Highlight automatic platform detection and binary integrity verification
- Provide guidance for manual inspection and alternative installation options
2025-05-22 10:38:03 +02:00
a2c8ff0f84 chore(changelog): update unreleased changelog 2025-05-22 08:37:28 +00:00
0ca8ed94cc fix(install): enhance checksum validation with detailed comparison
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 4s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 6s
- Replaces `sha256sum` with a detailed checksum comparison using OpenSSL
- Improves error messaging by displaying both expected and actual hashes
2025-05-22 10:37:15 +02:00
227a0426b5 chore(changelog): update unreleased changelog 2025-05-22 08:35:32 +00:00
20d143035e fix(install): ensure compatibility with non-bash shells
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 5s
- Add conditional to enable fail-safe mode only for bash shells
2025-05-22 10:35:17 +02:00
29 changed files with 1376 additions and 324 deletions

View File

@@ -0,0 +1,5 @@
chore(pr): ${PullRequestTitle} ${PullRequestReference}
${PullRequestDescription}
Merged from ${HeadBranch} into ${BaseBranch}

54
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,54 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Format
id: format
continue-on-error: true
run: deno task fmt
- name: Lint
id: lint
continue-on-error: true
run: deno task lint
- name: Test
id: test
continue-on-error: true
run: deno task test
- name: Compile
id: compile
continue-on-error: true
run: |
deno task build:amd64
deno task build:arm64
- name: Fail if any step failed
if: |
steps.format.outcome != 'success' ||
steps.lint.outcome != 'success' ||
steps.test.outcome != 'success' ||
steps.compile.outcome != 'success'
run: |
echo "::error::One or more steps failed"
exit 1

View File

@@ -4,219 +4,16 @@ on:
push:
branches:
- main
- '**'
- "**"
jobs:
detect-version-change:
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.set.outputs.version_changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if VERSION file changed
if: github.ref == 'refs/heads/main'
run: |
echo "🔍 Vergleich mit github.event.before:"
echo "Before: ${{ github.event.before }}"
echo "After: ${{ github.sha }}"
echo "📄 Changed files between before and after:"
git diff --name-only ${{ github.event.before }} ${{ github.sha }} || echo "(diff failed)"
if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q '^VERSION$'; then
echo "✅ VERSION file was changed"
echo "VERSION_CHANGED=true" >> $GITHUB_ENV
else
echo "ℹ️ VERSION file not changed"
echo "VERSION_CHANGED=false" >> $GITHUB_ENV
fi
- name: Set output (always)
id: set
run: |
echo "version_changed=${VERSION_CHANGED:-false}" >> $GITHUB_OUTPUT
changelog-only:
needs: detect-version-change
if: github.ref != 'refs/heads/main' || needs.detect-version-change.outputs.version_changed == 'false'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set Git Author
run: |
git config user.name "$CI_COMMIT_AUTHOR_NAME"
git config user.email "$CI_COMMIT_AUTHOR_EMAIL"
- name: Read CLIFF_VERSION from cliff.toml
id: cliff_version
run: |
echo "version=$(awk -F '=' '/^# CLIFF_VERSION=/ { gsub(/[" ]/, "", $2); print $2 }' cliff.toml)" >> $GITHUB_OUTPUT
- name: Restore git-cliff cache
id: restore-cliff
uses: https://git.0xmax42.io/actions/cache@v1
with:
key: cargo-cliff-${{ steps.cliff_version.outputs.version }}
paths: |
/root/.cargo/bin
- name: Install git-cliff
if: steps.restore-cliff.outputs.cache-hit != 'true'
run: |
cargo install git-cliff --version "${{ steps.cliff_version.outputs.version }}" --features gitea
- name: Generate unreleased changelog (if file exists or on main)
run: |
if [[ -f CHANGELOG.md || "${GITHUB_REF##refs/heads/}" == "main" ]]; then
echo "Generating CHANGELOG.md..."
git-cliff -c cliff.toml -o CHANGELOG.md
else
echo "CHANGELOG.md does not exist and this is not 'main'. Skipping generation."
fi
- name: Commit updated CHANGELOG
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore(changelog): update unreleased changelog"
git push origin "${GITHUB_REF##refs/heads/}"
fi
release:
needs: detect-version-change
if: needs.detect-version-change.outputs.version_changed == 'true' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set Git Author
run: |
git config user.name "$CI_COMMIT_AUTHOR_NAME"
git config user.email "$CI_COMMIT_AUTHOR_EMAIL"
- name: Read VERSION
id: version
run: echo "value=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Read CLIFF_VERSION from cliff.toml
id: cliff_version
run: |
echo "version=$(awk -F '=' '/^# CLIFF_VERSION=/ { gsub(/[" ]/, "", $2); print $2 }' cliff.toml)" >> $GITHUB_OUTPUT
- name: Restore git-cliff cache
id: restore-cliff
uses: https://git.0xmax42.io/actions/cache@v1
- name: Release
uses: https://git.0xmax42.io/actions/auto-changelog-release-action@v0
with:
key: cargo-cliff-${{ steps.cliff_version.outputs.version }}
paths: |
/root/.cargo/bin
- name: Install git-cliff
if: steps.restore-cliff.outputs.cache-hit != 'true'
run: |
cargo install git-cliff --version "${{ steps.cliff_version.outputs.version }}" --features gitea
- name: Generate changelog for release and tag
id: generate-changelog
run: |
VERSION=${{ steps.version.outputs.value }}
git-cliff -c cliff.toml -t "v$VERSION" -o CHANGELOG.md
BODY=$(mktemp)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/\./\\./g')
awk -v ver="$ESCAPED_VERSION" '
$0 ~ "^## \\[" ver "\\]" {
print_flag=1
line = $0
sub(/^## /, "", line)
sub(/\\s*\\(.*\\)/, "", line) # entfernt z. B. "(...)" oder "(*)"
print line
next
}
$0 ~ "^## \\[" && $0 !~ "^## \\[" ver "\\]" {
print_flag=0
}
print_flag
' CHANGELOG.md > "$BODY"
echo "changelog_body_path=$BODY" >> $GITHUB_OUTPUT
- name: Commit updated CHANGELOG
run: |
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore(changelog): update changelog for v${{ steps.version.outputs.value }}"
git push origin main
fi
- name: Create Git tag (if not exists)
run: |
VERSION=${{ steps.version.outputs.value }}
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists, skipping tag creation."
else
git tag -a "v$VERSION" -F "${{ steps.generate-changelog.outputs.changelog_body_path }}" --cleanup=verbatim
git push origin "v$VERSION"
fi
- name: Create Gitea release
env:
RELEASE_PUBLISH_TOKEN: ${{ secrets.RELEASE_PUBLISH_TOKEN }}
run: |
VERSION=${{ steps.version.outputs.value }}
BODY_FILE="${{ steps.generate-changelog.outputs.changelog_body_path }}"
OWNER=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
REPO=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f2)
# Token-Auswahl
TOKEN="${RELEASE_PUBLISH_TOKEN:-$ACTIONS_RUNTIME_TOKEN}"
if [[ -z "${RELEASE_PUBLISH_TOKEN:-}" ]]; then
echo "::warning title=Limited Release Propagation::"
echo "RELEASE_PUBLISH_TOKEN is not set. Using ACTIONS_RUNTIME_TOKEN instead."
echo "⚠️ Release events may not trigger other workflows if created with the runtime token."
echo
fi
# Prüfe, ob der Release schon existiert
if curl -sf "$GITHUB_API_URL/repos/$OWNER/$REPO/releases/tags/v$VERSION" \
-H "Authorization: token $TOKEN" > /dev/null; then
echo "🔁 Release for tag v$VERSION already exists, skipping."
exit 0
fi
echo "🚀 Creating Gitea release for v$VERSION"
# Release-Beschreibung vorbereiten
RELEASE_BODY=$(tail -n +2 "$BODY_FILE" | jq -Rs .)
curl -X POST "$GITHUB_API_URL/repos/$OWNER/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"tag_name": "v$VERSION",
"target_commitish": "main",
"name": "Release v$VERSION",
"body": $RELEASE_BODY,
"draft": false,
"prerelease": false
}
EOF
echo "✅ Release for tag $VERSION created successfully."
token: ${{ secrets.RELEASE_PUBLISH_TOKEN }}

View File

@@ -42,3 +42,31 @@ jobs:
- name: Upload SHA256 for ${{ matrix.target }}-${{ matrix.arch }}
run: .gitea/scripts/upload-asset.sh ./dist/systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.sha256 systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.sha256
- name: Create zstd compressed archive
run: |
FILE="./dist/systemd-timer-${{ matrix.target }}-${{ matrix.arch }}"
zstd -q -19 -T0 -f "$FILE" -o "$FILE.zst"
- name: Generate SHA256 for zstd compressed archive
run: |
FILE="./dist/systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.zst"
sha256sum "$FILE" > "$FILE.sha256"
- name: Upload zstd compressed archive for ${{ matrix.target }}-${{ matrix.arch }}
run: .gitea/scripts/upload-asset.sh ./dist/systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.zst systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.zst
- name: Upload SHA256 for zstd compressed archive
run: .gitea/scripts/upload-asset.sh ./dist/systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.zst.sha256 systemd-timer-${{ matrix.target }}-${{ matrix.arch }}.zst.sha256
- name: Run Releases Sync Action
uses: https://git.0xmax42.io/actions/releases-sync@main
with:
gitea_token: ${{ secrets.RELEASE_PUBLISH_TOKEN }}
gitea_url: https://git.0xmax42.io
gitea_owner: maxp
gitea_repo: systemd-timer
tag_name: ${{ github.event.release.tag_name }}
github_token: ${{ secrets.SYNC_GITHUB_TOKEN }}
github_owner: 0xmax42
github_repo: systemd-timer

13
.vscode/settings.json vendored
View File

@@ -10,6 +10,15 @@
"peacock.color": "#c75c5c",
"exportall.config.folderListener": [
"/src/utils",
"/src/types"
]
"/src/types",
"/src/i18n"
],
"deno.enable": true,
"[json]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[jsonc]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"editor.formatOnSave": true
}

View File

@@ -2,6 +2,124 @@
All notable changes to this project will be documented in this file.
## [0.5.1](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.5.0..v0.5.1) - 2025-06-15
### 🚀 Features
- *(install)* Add support for compressed .zst archives - ([3a1ee0c](https://git.0xmax42.io/maxp/systemd-timer/commit/3a1ee0cfd6ddba8734cde09196803e9ccdd14c5f))
- *(workflows)* Add zstd compression for build artifacts - ([d9183d2](https://git.0xmax42.io/maxp/systemd-timer/commit/d9183d2f04b03cbdaf9e11bf05f7ce66111123e5))
### 📚 Documentation
- *(readme)* Add input validation and multilingual support - ([ce78cf3](https://git.0xmax42.io/maxp/systemd-timer/commit/ce78cf3a9a14028114e0fb6fcc0973d86c2b2227))
### ⚙️ Miscellaneous Tasks
- *(workflows)* Simplify release workflow with auto-changelog - ([05f3b51](https://git.0xmax42.io/maxp/systemd-timer/commit/05f3b519f56d893a0135736d8b722302c7cdd3f6))
- *(workflows)* Add locked flag to cargo install for git-cliff - ([6efc151](https://git.0xmax42.io/maxp/systemd-timer/commit/6efc1515ed4f4fb8802405529e4a6e04870ec02a))
## [0.5.0](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.4.1..v0.5.0) - 2025-05-30
### 🚀 Features
- *(cli)* Add validation for command options - ([1c07af4](https://git.0xmax42.io/maxp/systemd-timer/commit/1c07af402b35ba4c2abf89ff5816e791994db536))
- *(validation)* Add CLI validation helpers for input checks - ([3d95706](https://git.0xmax42.io/maxp/systemd-timer/commit/3d95706d683cac8d279dfb718541a17c42fa5d71))
- *(build)* Add run permissions to compiled binaries - ([ccb04e4](https://git.0xmax42.io/maxp/systemd-timer/commit/ccb04e49820ff8238d7e7ad853aab5db06d5dc8a))
- *(ci)* Add compile steps to CI workflow - ([531a02a](https://git.0xmax42.io/maxp/systemd-timer/commit/531a02a6e11a769f2e05888d49ea2b4808d974e3))
- *(vscode)* Add JSONC formatter configuration - ([c7af1fb](https://git.0xmax42.io/maxp/systemd-timer/commit/c7af1fb6caa46c22b84229745067d05bf60b6f64))
- *(i18n)* Support loading JSONC translation files - ([4ac5dd4](https://git.0xmax42.io/maxp/systemd-timer/commit/4ac5dd4c88324f99cb6827283ad85bb9718abbeb))
- *(config)* Add @std/jsonc dependency - ([8f1cb3f](https://git.0xmax42.io/maxp/systemd-timer/commit/8f1cb3fad71ead365d93087963ddb6c7202a9b4f))
### 🐛 Bug Fixes
- *(build)* Update included files to use .jsonc format - ([f3f2c61](https://git.0xmax42.io/maxp/systemd-timer/commit/f3f2c61da0785dce4c6b8c7d8ef0ae9abf098172))
### 🎨 Styling
- *(i18n)* Add comments for clarity and rename files - ([5226269](https://git.0xmax42.io/maxp/systemd-timer/commit/5226269ec2a0b76dfa30ac8d614c3789ff3a837b))
### 🧪 Testing
- *(fs)* Update test descriptions and comments to English - ([c4f4614](https://git.0xmax42.io/maxp/systemd-timer/commit/c4f4614a2daee68f9b33b9676106214c65a1a427))
- *(fs)* Add rollback tests for writeUnitFiles errors - ([6039d23](https://git.0xmax42.io/maxp/systemd-timer/commit/6039d236eb7de449ce22b1d9ea718389a3e2261d))
### ⚙️ Miscellaneous Tasks
- *(lock)* Update dependencies to latest versions - ([28b23cf](https://git.0xmax42.io/maxp/systemd-timer/commit/28b23cf947ecc57eb7fde541b29c68b41e508e7d))
## [0.4.1](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.4.0..v0.4.1) - 2025-05-28
### 🐛 Bug Fixes
- *(tasks)* Add read permissions to build scripts - ([a22c156](https://git.0xmax42.io/maxp/systemd-timer/commit/a22c156dd3d2cf4a24f0eed699f7dfabfae3837a))
## [0.4.0](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.3.1..v0.4.0) - 2025-05-28
### 🚀 Features
- *(vscode)* Enable Deno support and configure JSON formatting - ([c02da70](https://git.0xmax42.io/maxp/systemd-timer/commit/c02da709028e1fbb175d5091fbd9d3ed2940cdcd))
- *(ci)* Add CI workflow with format, lint, and test steps - ([9ad407e](https://git.0xmax42.io/maxp/systemd-timer/commit/9ad407e531270445d9657402fa3e826a7dabd880))
- *(tasks)* Add formatting, linting, and CI tasks - ([07730e5](https://git.0xmax42.io/maxp/systemd-timer/commit/07730e576180be3f6a16b0fda6c6554a86844eee))
- *(tasks)* Include localization files in build commands - ([440130f](https://git.0xmax42.io/maxp/systemd-timer/commit/440130f782b1fc51053164410ead29397b867892))
- *(i18n)* Add German and English translations for CLI tool - ([bd5ea80](https://git.0xmax42.io/maxp/systemd-timer/commit/bd5ea80aff5092118920ea897af6c3f5f9fb2a3b))
- *(i18n)* Add i18n module for localization support - ([c9b4c8b](https://git.0xmax42.io/maxp/systemd-timer/commit/c9b4c8bd71029976fe900b40a2297b52200a216b))
### 🚜 Refactor
- *(cli)* Integrate i18n support across commands - ([2a13ee2](https://git.0xmax42.io/maxp/systemd-timer/commit/2a13ee2539d96d161a9ee398629fa79822d856f2))
### 🎨 Styling
- *(i18n)* Add missing newline at EOF in JSON files - ([54d71ba](https://git.0xmax42.io/maxp/systemd-timer/commit/54d71ba3f00ced25313036d9f10f6fb01feba52a))
### 🧪 Testing
- *(i18n)* Add unit tests for localization functions - ([8efbee1](https://git.0xmax42.io/maxp/systemd-timer/commit/8efbee1ba9b4fc564f5a32fcbc101ff256c5555b))
### ⚙️ Miscellaneous Tasks
- *(vscode)* Update folder listener with i18n directory - ([dfa92d8](https://git.0xmax42.io/maxp/systemd-timer/commit/dfa92d80694b5b104c26e131d1ee7c5cf69ad94c))
## [0.3.1](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.2.3..v0.3.1) - 2025-05-28
### 🚀 Features
- *(cli)* Add options for user, home, and working directory - ([113103f](https://git.0xmax42.io/maxp/systemd-timer/commit/113103f368ead3014165cc708f016a04749f59be))
### 📚 Documentation
- *(readme)* Expand CLI option descriptions for clarity - ([fb2a62d](https://git.0xmax42.io/maxp/systemd-timer/commit/fb2a62d984615caa4035fd5c1e8e64d245499e47))
### 🎨 Styling
- *(workflows)* Fix formatting and whitespace issues - ([c4855ed](https://git.0xmax42.io/maxp/systemd-timer/commit/c4855ed3fbc0ada208690f90932710983daef392))
### ⚙️ Miscellaneous Tasks
- *(workflows)* Consolidate release sync into upload workflow - ([0c1d8be](https://git.0xmax42.io/maxp/systemd-timer/commit/0c1d8be79f0cc331db9029beb46384659f465f6e))
## [0.2.3](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.2.2..v0.2.3) - 2025-05-26
### 🚀 Features
- *(workflows)* Add GitHub release synchronization workflow - ([27c7367](https://git.0xmax42.io/maxp/systemd-timer/commit/27c7367ef1799428cc5a491b25036f77b65758af))
### 📚 Documentation
- *(readme)* Update project time badge interval - ([e3a3e61](https://git.0xmax42.io/maxp/systemd-timer/commit/e3a3e61bce0e62c2397bbc5bde3eff81b915c94a))
- Add Englisch README - ([cf483de](https://git.0xmax42.io/maxp/systemd-timer/commit/cf483de06b555599052b1d9f97ee98e9233e5a86))
## [0.2.2](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.2.0..v0.2.2) - 2025-05-22
### 🐛 Bug Fixes
- *(install)* Enhance checksum validation with detailed comparison - ([0ca8ed9](https://git.0xmax42.io/maxp/systemd-timer/commit/0ca8ed94ccc4b9fe4ccac331957f01f852999094))
- *(install)* Ensure compatibility with non-bash shells - ([20d1430](https://git.0xmax42.io/maxp/systemd-timer/commit/20d143035ec6893f680b68dc4a2f6319ca7a5b81))
### 📚 Documentation
- *(readme)* Update installation instructions with script - ([9853f85](https://git.0xmax42.io/maxp/systemd-timer/commit/9853f854c991d87b12cd4fb5e19fce55e7246024))
## [0.2.0](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.1.0..v0.2.0) - 2025-05-22
### 🚀 Features

110
README.DE.md Normal file
View File

@@ -0,0 +1,110 @@
# systemd-timer
![Project time](https://waka.0xmax42.io/api/badge/0XMax42/interval:any/project:systemd-timer?label=Project%20time)
Ein einfaches CLI-Tool zum schnellen Erzeugen von systemd `.service` und `.timer` Units – als Ersatz oder moderne Ergänzung zu klassischen `cron`-Jobs.
---
## 🚀 Features
- Erzeugt `.service` und `.timer` Dateien per CLI
- Unterstützt `--user` Timer (für `~/.config/systemd/user/`)
- Optionales Logging (`StandardOutput/StandardError`)
- Unterstützt:
- `--calendar`: Zeitplan für den Timer (systemd `OnCalendar`)
- `--exec`: Auszuführendes Kommando (`ExecStart`)
- `--description`: Beschreibung für die Unit
- `--after`: `After=`-Abhängigkeiten in der Service-Unit
- `--environment`: Beliebige `Environment=KEY=VALUE` Einträge
- `--output`: Zielverzeichnis für die generierten Unit-Dateien
- `--run-as`: Setzt `User=` in der Service-Unit (nur systemweite Timer)
- `--home`: Setzt `Environment=HOME=…`
- `--cwd`: Arbeitsverzeichnis des Prozesses (`WorkingDirectory`)
- `--dry-run`: Gibt nur die generierten Inhalte aus, ohne sie zu schreiben
- Getestet und typisiert mit **Deno** + **Cliffy**
- Eingaben werden validiert und auf Fehler geprüft;
- z.B. muss `--calendar` ein gültiger systemd `OnCalendar` Ausdruck sein
- Mehrsprachig (Englisch, Deutsch)
---
## 🛠️ Installation
Du kannst `systemd-timer` direkt per Shell-Skript installieren:
```bash
curl -fsSL https://git.0xmax42.io/maxp/systemd-timer/raw/branch/main/scripts/install.sh | sh
```
Das Skript erkennt automatisch deine Plattform (Linux `amd64` oder `arm64`) und installiert die passende Binary nach `/usr/local/bin`, sofern dies erlaubt ist (ggf. mit `sudo`).
**Hinweis:**
- Für die Installation ist eine funktionierende Internetverbindung notwendig.
- Die Integrität der Binary wird mittels SHA256-Prüfsumme verifiziert.
- Du kannst das Skript vor der Ausführung auch manuell inspizieren:
```bash
curl -fsSL https://git.0xmax42.io/maxp/systemd-timer/raw/branch/main/scripts/install.sh -o install.sh
less install.sh
```
Weitere Optionen und manuelle Installationswege findest du unter [`scripts/install.sh`](scripts/install.sh).
---
## 📦 Beispiel
```bash
./systemd-timer create \
--exec "/usr/local/bin/backup.sh" \
--calendar "Mon..Fri 02:00" \
--description "Backup Job" \
--user \
--logfile "/var/log/backup.log"
```
Erzeugt:
- `~/.config/systemd/user/backup.service`
- `~/.config/systemd/user/backup.timer`
Anschließend aktivieren:
```bash
systemctl --user daemon-reload
systemctl --user enable --now backup.timer
```
---
## 🧪 Tests ausführen
```bash
deno task test
```
---
## 🧰 Entwickeln
```bash
deno task start create --exec "/bin/true" --calendar "daily" --dry-run
```
---
## 🔒 Rechte / Flags
Das Tool benötigt beim Ausführen bzw. Kompilieren:
- `--allow-env` (für `$HOME`)
- `--allow-write` (zum Schreiben von `.service`/`.timer`)
Beim Entwickeln wird meist `-A` (allow all) verwendet.
---
## 📝 Lizenz
[MIT License](LICENSE)

View File

@@ -1,41 +1,61 @@
# systemd-timer
![Project time](https://waka.0xmax42.io/api/badge/0XMax42/interval:today/project:systemd-timer?label=Project%20time)
- ![Project time](https://waka.0xmax42.io/api/badge/0XMax42/interval:any/project:systemd-timer?label=Project%20time)
- [Deutsche Version dieser Readme](README.DE.md)
Ein einfaches CLI-Tool zum schnellen Erzeugen von systemd `.service` und `.timer` Units als Ersatz oder moderne Ergänzung zu klassischen `cron`-Jobs.
A simple CLI tool for quickly generating systemd `.service` and `.timer` units as a replacement or modern supplement to classic `cron` jobs.
---
## 🚀 Features
- Erzeugt `.service` und `.timer` Dateien per CLI
- Unterstützt `--user` Timer (für `~/.config/systemd/user/`)
- Optionales Logging (`StandardOutput/StandardError`)
- Unterstützt:
- `--calendar`
- `--exec`
- `--after`
- `--environment`
- `--output`
- `--dry-run`
- Getestet und typisiert mit Deno + Cliffy
* Generates `.service` and `.timer` files via CLI
* Supports `--user` timers (for `~/.config/systemd/user/`)
* Optional logging (`StandardOutput/StandardError`)
* Supports:
* `--calendar`: Timer schedule (systemd `OnCalendar`)
* `--exec`: Command to execute (`ExecStart`)
* `--description`: Description for the unit
* `--after`: `After=` dependencies in the service unit
* `--environment`: Arbitrary `Environment=KEY=VALUE` entries
* `--output`: Target directory for the generated unit files
* `--run-as`: Sets `User=` in the service unit (only for system-level timers)
* `--home`: Sets `Environment=HOME=…`
* `--cwd`: Working directory for the process (`WorkingDirectory`)
* `--dry-run`: Outputs unit content without writing to disk
* Tested and fully typed with **Deno** + **Cliffy**
* Inputs are validated and checked for errors;
* e.g., `--calendar` must be a valid systemd `OnCalendar` expression
* Multilingual (English, German)
---
## 🛠️ Installation
```bash
git clone https://git.0xmax42.io/maxp/systemd-timer.git
cd systemd-timer
deno task build
You can install `systemd-timer` directly via shell script:
# Binary liegt nun unter ./systemd-timer
./systemd-timer --help
```bash
curl -fsSL https://git.0xmax42.io/maxp/systemd-timer/raw/branch/main/scripts/install.sh | sh
```
The script automatically detects your platform (Linux `amd64` or `arm64`) and installs the appropriate binary to `/usr/local/bin`, if permitted (possibly using `sudo`).
**Note:**
* A working internet connection is required for installation.
* The integrity of the binary is verified using a SHA256 checksum.
* You can manually inspect the script before execution:
```bash
curl -fsSL https://git.0xmax42.io/maxp/systemd-timer/raw/branch/main/scripts/install.sh -o install.sh
less install.sh
```
Additional options and manual installation methods are available under [`scripts/install.sh`](scripts/install.sh).
---
## 📦 Beispiel
## 📦 Example
```bash
./systemd-timer create \
@@ -46,11 +66,12 @@ deno task build
--logfile "/var/log/backup.log"
```
Erzeugt:
- `~/.config/systemd/user/backup.service`
- `~/.config/systemd/user/backup.timer`
This creates:
Anschließend aktivieren:
* `~/.config/systemd/user/backup.service`
* `~/.config/systemd/user/backup.timer`
Activate afterwards:
```bash
systemctl --user daemon-reload
@@ -59,7 +80,7 @@ systemctl --user enable --now backup.timer
---
## 🧪 Tests ausführen
## 🧪 Running Tests
```bash
deno task test
@@ -67,7 +88,7 @@ deno task test
---
## 🧰 Entwickeln
## 🧰 Development
```bash
deno task start create --exec "/bin/true" --calendar "daily" --dry-run
@@ -75,17 +96,17 @@ deno task start create --exec "/bin/true" --calendar "daily" --dry-run
---
## 🔒 Rechte / Flags
## 🔒 Permissions / Flags
Das Tool benötigt beim Ausführen bzw. Kompilieren:
The tool requires the following permissions when running or compiling:
- `--allow-env` (für `$HOME`)
- `--allow-write` (zum Schreiben von `.service`/`.timer`)
* `--allow-env` (for `$HOME`)
* `--allow-write` (to write `.service`/`.timer` files)
Beim Entwickeln wird meist `-A` (allow all) verwendet.
During development, usually `-A` (allow all) is used.
---
## 📝 Lizenz
## 📝 License
[MIT License](LICENSE)

View File

@@ -1 +1 @@
0.2.0
0.5.1

View File

@@ -2,8 +2,11 @@
"tasks": {
"start": "deno run -A src/mod.ts",
"test": "deno test -A --coverage **/__tests__/*.test.ts",
"build:amd64": "deno compile --target x86_64-unknown-linux-gnu --include=VERSION --allow-env --allow-write --output dist/systemd-timer-linux-amd64 src/mod.ts",
"build:arm64": "deno compile --target aarch64-unknown-linux-gnu --include=VERSION --allow-env --allow-write --output dist/systemd-timer-linux-arm64 src/mod.ts"
"fmt": "deno fmt --check",
"lint": "deno lint",
"ci": "deno task fmt && deno task lint && deno task test && build:amd64", // For local CI checks
"build:amd64": "deno compile --target x86_64-unknown-linux-gnu --include VERSION --include src/i18n/de.jsonc --include src/i18n/en.jsonc --allow-env --allow-write --allow-read --allow-run --output dist/systemd-timer-linux-amd64 src/mod.ts",
"build:arm64": "deno compile --target aarch64-unknown-linux-gnu --include VERSION --include src/i18n/de.jsonc --include src/i18n/en.jsonc --allow-env --allow-write --allow-read --allow-run --output dist/systemd-timer-linux-arm64 src/mod.ts"
},
"compilerOptions": {},
"fmt": {
@@ -20,6 +23,7 @@
},
"exclude": [],
"imports": {
"@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.7"
"@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.7",
"@std/jsonc": "jsr:@std/jsonc@^1.0.2"
}
}

21
deno.lock generated
View File

@@ -6,6 +6,8 @@
"jsr:@cliffy/internal@1.0.0-rc.7": "1.0.0-rc.7",
"jsr:@cliffy/table@1.0.0-rc.7": "1.0.0-rc.7",
"jsr:@std/fmt@~1.0.2": "1.0.7",
"jsr:@std/json@^1.0.2": "1.0.2",
"jsr:@std/jsonc@^1.0.2": "1.0.2",
"jsr:@std/text@~1.0.7": "1.0.13"
},
"jsr": {
@@ -37,11 +39,24 @@
"@std/fmt@1.0.7": {
"integrity": "2a727c043d8df62cd0b819b3fb709b64dd622e42c3b1bb817ea7e6cc606360fb"
},
"@std/json@1.0.2": {
"integrity": "d9e5497801c15fb679f55a2c01c7794ad7a5dfda4dd1bebab5e409cb5e0d34d4"
},
"@std/jsonc@1.0.2": {
"integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7",
"dependencies": [
"jsr:@std/json"
]
},
"@std/text@1.0.13": {
"integrity": "2191c90e6e667b0c3b7dea1cd082137580a93b3c136bad597c0212d5fe006eb1"
}
},
"remote": {
"https://deno.land/std@0.192.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e",
"https://deno.land/std@0.192.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea",
"https://deno.land/std@0.192.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7",
"https://deno.land/std@0.192.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f",
"https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
"https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293",
@@ -168,11 +183,13 @@
"https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7",
"https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972",
"https://deno.land/std@0.224.0/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e",
"https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c"
"https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c",
"https://deno.land/std@0.224.0/testing/mock.ts": "a963181c2860b6ba3eb60e08b62c164d33cf5da7cd445893499b2efda20074db"
},
"workspace": {
"dependencies": [
"jsr:@cliffy/command@1.0.0-rc.7"
"jsr:@cliffy/command@1.0.0-rc.7",
"jsr:@std/jsonc@^1.0.2"
]
}
}

View File

@@ -1,5 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Fail-safe bash mode
if [ -n "$BASH_VERSION" ]; then
set -euo pipefail
else
set -eu
fi
# === Konfiguration ===
REPO_URL="https://git.0xmax42.io/maxp/systemd-timer/releases/download/latest"
@@ -20,28 +26,71 @@ case "$OS" in
*) echo "Unsupported OS: $OS" >&2; exit 1 ;;
esac
# === Download-URL zusammensetzen ===
BINARY_FILE="${BINARY_NAME}-${OS}-${ARCH}"
DOWNLOAD_URL="${REPO_URL}/${BINARY_FILE}"
# === Datei- und URL-Namen ===
BASE_NAME="${BINARY_NAME}-${OS}-${ARCH}"
ZST_NAME="${BASE_NAME}.zst"
ZST_URL="${REPO_URL}/${ZST_NAME}"
BIN_URL="${REPO_URL}/${BASE_NAME}"
echo "📦 Installing ${BINARY_NAME} for ${OS}/${ARCH}..."
echo "🌐 Downloading from: ${DOWNLOAD_URL}"
# === Binary herunterladen ===
TMP_FILE=$(mktemp)
curl -fsSL "${DOWNLOAD_URL}" -o "${TMP_FILE}"
chmod +x "${TMP_FILE}"
USE_ZSTD=false
if command -v zstd >/dev/null; then
echo "✅ 'zstd' found – will use compressed .zst archive"
USE_ZSTD=true
fi
# === Optional: SHA256-Check ===
curl -fsSL "${DOWNLOAD_URL}.sha256" -o "${TMP_FILE}.sha256"
echo "$(cat ${TMP_FILE}.sha256) ${TMP_FILE}" | sha256sum -c -
TMP_DIR=$(mktemp -d)
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
# === Installation ===
if [ "$USE_ZSTD" = true ]; then
echo "🌐 Downloading: ${ZST_URL}"
curl -fsSL "$ZST_URL" -o "$TMP_DIR/${ZST_NAME}"
echo "🌐 Downloading checksum: ${ZST_URL}.sha256"
curl -fsSL "$ZST_URL.sha256" -o "$TMP_DIR/zst.sha256"
EXPECTED_HASH=$(cut -d ' ' -f1 "$TMP_DIR/zst.sha256")
ACTUAL_HASH=$(openssl dgst -sha256 "$TMP_DIR/${ZST_NAME}" | awk '{print $2}')
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
echo "⚠️ Checksum mismatch for .zst archive!"
echo "Expected: $EXPECTED_HASH"
echo "Actual: $ACTUAL_HASH"
exit 1
fi
echo "📥 Decompressing..."
zstd -d -q "$TMP_DIR/${ZST_NAME}" -o "$TMP_DIR/${BASE_NAME}"
TMP_FILE="$TMP_DIR/${BASE_NAME}"
else
echo "🌐 Downloading uncompressed binary: ${BIN_URL}"
curl -fsSL "$BIN_URL" -o "$TMP_DIR/${BASE_NAME}"
echo "🌐 Downloading checksum: ${BIN_URL}.sha256"
curl -fsSL "$BIN_URL.sha256" -o "$TMP_DIR/binary.sha256"
EXPECTED_HASH=$(cut -d ' ' -f1 "$TMP_DIR/binary.sha256")
ACTUAL_HASH=$(openssl dgst -sha256 "$TMP_DIR/${BASE_NAME}" | awk '{print $2}')
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
echo "⚠️ Checksum mismatch!"
echo "Expected: $EXPECTED_HASH"
echo "Actual: $ACTUAL_HASH"
exit 1
fi
TMP_FILE="$TMP_DIR/${BASE_NAME}"
fi
chmod +x "$TMP_FILE"
echo "🚀 Installing to ${INSTALL_PATH}/${BINARY_NAME}"
if [ -w "$INSTALL_PATH" ]; then
install -m 755 "${TMP_FILE}" "${INSTALL_PATH}/${BINARY_NAME}"
install -m 755 "$TMP_FILE" "${INSTALL_PATH}/${BINARY_NAME}"
else
sudo install -m 755 "${TMP_FILE}" "${INSTALL_PATH}/${BINARY_NAME}"
sudo install -m 755 "$TMP_FILE" "${INSTALL_PATH}/${BINARY_NAME}"
fi
echo "✅ Installation complete: $(command -v ${BINARY_NAME})"

View File

@@ -1,39 +1,73 @@
import { Command } from '@cliffy/command';
import { generateUnitFiles } from '../templates/unit-generator.ts';
import { TimerOptions } from '../types/options.ts';
import { t } from '../i18n/mod.ts';
import {
collectAndValidateAfter,
collectAndValidateEnv,
validateIdentifier,
validateNotEmpty,
validatePath,
validateSystemdCalendar,
} from '../utils/mod.ts';
export const createCommand = new Command()
.description('Erzeugt eine systemd .service und .timer Unit')
.option(
'--name <name:string>',
'Name der Unit-Dateien (optional, wird sonst aus dem Exec generiert)',
)
.option(
'--exec <cmd:string>',
'Kommando, das durch systemd ausgeführt werden soll',
{ required: true },
)
.option('--calendar <time:string>', 'OnCalendar-Ausdruck für den Timer', {
required: true,
})
.option('--description <desc:string>', 'Beschreibung des Timers')
.option('--user', 'Erstellt die Unit als User-Timer')
.option('--output <dir:string>', 'Zielverzeichnis der Unit-Dateien')
.option(
'--after <target:string>',
'Optionales After= für die Service-Unit',
{ collect: true },
)
.option(
'--environment <env:string>',
'Environment-Variablen im Format KEY=VALUE',
{ collect: true },
)
.option(
'--logfile <file:string>',
'Dateipfad für Log-Ausgabe (stdout/stderr)',
)
.option('--dry-run', 'Gibt die Unit-Dateien nur aus, ohne sie zu schreiben')
.action(async (options: TimerOptions) => {
await generateUnitFiles(options);
});
export function createCommand() {
return new Command()
.description(t('cli_create_description'))
.option(
'--name <name:string>',
t('option_name'),
{ value: (v) => validateIdentifier(v, '--name') },
)
.option(
'--exec <cmd:string>',
t('option_exec'),
{
required: true,
value: (v) => validateNotEmpty(v, '--exec'),
},
)
.option('--calendar <time:string>', t('option_calendar'), {
required: true,
value: validateSystemdCalendar,
})
.option('--description <desc:string>', t('option_description'))
.option('--user', t('option_user'))
.option(
'--run-as <user:string>',
t('option_run_as'),
{ value: (v) => validateNotEmpty(v, '--run-as') },
)
.option(
'--home <path:string>',
t('option_home'),
{ value: (v) => validatePath(v, true) },
)
.option(
'--cwd <path:string>',
t('option_cwd'),
{ value: (v) => validatePath(v, true) },
)
.option('--output <dir:string>', t('option_output'), {
value: (v) => validatePath(v, false),
})
.option(
'--after <target:string>',
t('option_after'),
{ collect: true, value: collectAndValidateAfter },
)
.option(
'--environment <env:string>',
t('option_environment'),
{ collect: true, value: collectAndValidateEnv },
)
.option(
'--logfile <file:string>',
t('option_logfile'),
{ value: (v) => validatePath(v, false) },
)
.option('--dry-run', t('option_dry_run'))
.action(async (options: TimerOptions) => {
await generateUnitFiles(options);
});
}

View File

@@ -1,10 +1,12 @@
import { Command } from '@cliffy/command';
import { createCommand } from './create.ts';
import { getVersion } from '../utils/mod.ts';
import { t } from '../i18n/mod.ts';
import { createCommand } from './mod.ts';
await new Command()
.name('systemd-timer')
.version(await getVersion())
.description('CLI Tool zum Erzeugen von systemd .timer und .service Units')
.command('create', createCommand)
.parse(Deno.args);
export async function createCli() {
return new Command()
.name('systemd-timer')
.version(await getVersion())
.description(t('cli_description'))
.command('create', createCommand());
}

2
src/cli/mod.ts Normal file
View File

@@ -0,0 +1,2 @@
export { createCommand } from './create.ts';
export { createCli } from './main.ts';

View File

@@ -0,0 +1,32 @@
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
import { getCurrentLanguage, loadLocale, t } from '../mod.ts';
Deno.test('loadLocale lade Deutsche Übersetzung', async () => {
await loadLocale('de');
assertEquals(
t('error_write_units'),
'Fehler beim Schreiben der Units:',
);
assertEquals(
t('unit_written_service', { path: '/tmp/service' }),
'Service Unit geschrieben in: /tmp/service',
);
});
Deno.test('t gibt den Schlüssel zurück, wenn dieser nicht gefunden wird', () => {
const result = t('non_existent_key');
assertEquals(result, 'non_existent_key');
});
Deno.test('getCurrentLanguage gibt die Fallback Sprache zurück', () => {
Deno.env.delete('SYSTEMD_TIMER_LANG');
Deno.env.delete('LC_ALL');
Deno.env.delete('LC_MESSAGES');
Deno.env.delete('LANG');
assertEquals(getCurrentLanguage(), 'en');
});
Deno.test('getCurrentLanguage nutz die `SYSTEMD_TIMER_LANG`, wenn gesetzt', () => {
Deno.env.set('SYSTEMD_TIMER_LANG', 'de_DE.UTF-8');
assertEquals(getCurrentLanguage(), 'de');
});

32
src/i18n/de.jsonc Normal file
View File

@@ -0,0 +1,32 @@
{
// General
"cli_description": "CLI-Tool zum Erzeugen von systemd .timer und .service Units",
"cli_create_description": "Erzeugt eine systemd .service und .timer Unit",
// Options
"option_name": "Name der Unit-Dateien (optional, wird sonst aus dem Exec generiert)",
"option_exec": "Kommando, das durch systemd ausgeführt werden soll",
"option_calendar": "OnCalendar-Ausdruck für den Timer",
"option_description": "Beschreibung des Timers",
"option_user": "Erstellt die Unit als User-Timer",
"option_run_as": "Führe den systemweiten Timer als bestimmter Benutzer aus (setzt User= in der Service-Unit)",
"option_home": "HOME-Variable für den Service setzen",
"option_cwd": "Arbeitsverzeichnis (WorkingDirectory) für den Service-Prozess",
"option_output": "Zielverzeichnis der Unit-Dateien",
"option_after": "Optionales After= für die Service-Unit",
"option_environment": "Environment-Variablen im Format KEY=VALUE",
"option_logfile": "Dateipfad für Log-Ausgabe (stdout/stderr)",
"option_dry_run": "Gibt die Unit-Dateien nur aus, ohne sie zu schreiben",
// Messages
"unit_written_service": "Service Unit geschrieben in: {path}",
"unit_written_timer": "Timer Unit geschrieben in: {path}",
"hint_header": "\nℹ️ Hinweis:",
// Error messages
"error_write_units": "Fehler beim Schreiben der Units:",
"rollback_failed": "Rollback fehlgeschlagen:",
"error_invalid_env": "Ungültiges Environment-Format. Verwende KEY=VALUE.",
"error_path_schold_not_be_empty": "Pfad darf nicht leer sein.",
"error_path_not_found": "Pfad nicht gefunden: {path}",
"error_value_should_not_be_empty": "Wert ({label}) sollte nicht leer sein.",
"error_invalid_identifier": "Ungültiger Bezeichner: '{value}' für {identifier}. Erlaubt sind nur alphanumerische Zeichen, Unterstriche und Bindestriche.",
"error_invalid_calendar": "Ungültiger OnCalendar-Ausdruck: {value}. Bitte überprüfe die Syntax."
}

32
src/i18n/en.jsonc Normal file
View File

@@ -0,0 +1,32 @@
{
// General
"cli_description": "CLI tool for generating systemd .timer and .service units",
"cli_create_description": "Generates a systemd .service and .timer unit",
// Options
"option_name": "Name of the unit files (optional, otherwise derived from the exec command)",
"option_exec": "Command to be executed by systemd",
"option_calendar": "OnCalendar expression for the timer",
"option_description": "Description of the timer",
"option_user": "Creates the unit as a user timer",
"option_run_as": "Runs the system-wide timer as a specific user (sets User= in the service unit)",
"option_home": "Sets the HOME variable for the service",
"option_cwd": "Working directory (WorkingDirectory) for the service process",
"option_output": "Target directory for the unit files",
"option_after": "Optional After= directive for the service unit",
"option_environment": "Environment variables in the format KEY=VALUE",
"option_logfile": "File path for log output (stdout/stderr)",
"option_dry_run": "Only outputs the unit files without writing them",
// Messages
"unit_written_service": "Service unit written to: {path}",
"unit_written_timer": "Timer unit written to: {path}",
"hint_header": "\nℹ️ Note:",
// Error messages
"error_write_units": "Error while writing unit files:",
"rollback_failed": "Rollback failed:",
"error_invalid_env": "Invalid environment format. Use KEY=VALUE.",
"error_path_schold_not_be_empty": "Path should not be empty.",
"error_path_not_found": "Path not found: {path}",
"error_value_should_not_be_empty": "Value ({label}) should not be empty.",
"error_invalid_identifier": "Invalid identifier: '{value}' for {identifier}. Only alphanumeric characters, underscores, and hyphens are allowed.",
"error_invalid_calendar": "Invalid OnCalendar expression: {value}. Please check the syntax."
}

82
src/i18n/i18n.ts Normal file
View File

@@ -0,0 +1,82 @@
import { parse as parseJsonc } from '@std/jsonc';
/**
* Initializes the i18n module by loading
* the appropriate locale file based on the system language.
*/
export async function initI18n(): Promise<void> {
await loadLocale(getCurrentLanguage());
}
/**
* Contains the loaded translations for the current language.
* The keys represent i18n identifiers, the values are the localized strings.
*/
let translations: Record<string, string> = {};
/**
* Loads the translation file for the specified locale.
*
* Accepts both `.jsonc` (JSON with comments) and plain `.json`.
* When both exist, `.jsonc` takes precedence.
* Falls back to English ('en') if the specified file does not exist.
*
* @param locale - The language code (e.g., 'de', 'en') to load
* @returns Promise that resolves once the translations have been loaded
*/
export async function loadLocale(locale: string): Promise<void> {
const extensions = ['jsonc', 'json'];
for (const ext of extensions) {
try {
const localeUrl = new URL(`./${locale}.${ext}`, import.meta.url);
const raw = await Deno.readTextFile(localeUrl);
// parseJsonc tolerates both pure JSON and JSONC, so we can use it for either.
translations = parseJsonc(raw) as Record<string, string>;
return;
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
// Continue with next extension.
continue;
}
console.error(`Error parsing locale '${locale}.${ext}':`, err);
break;
}
}
if (locale !== 'en') {
console.warn(`Locale '${locale}' not found – falling back to 'en'.`);
await loadLocale('en');
}
}
/**
* Translates a given key using the loaded translation data.
* Replaces placeholders in the format `{variable}` with the provided values.
*
* @param key - The i18n key (e.g., 'timer_created')
* @param vars - Optional replacements for placeholders in the string
* @returns The translated string, or the key itself if no translation is found
*/
export function t(key: string, vars: Record<string, string> = {}): string {
let text = translations[key] ?? key;
for (const [k, v] of Object.entries(vars)) {
text = text.replace(`{${k}}`, v);
}
return text;
}
/**
* Determines the current language from the system environment.
*
* Priority: SYSTEMD_TIMER_LANG > LC_ALL > LC_MESSAGES > LANG.
*
* @returns The language code (e.g., 'de', 'en'), defaults to 'en'
*/
export function getCurrentLanguage(): string {
return (
Deno.env.get('SYSTEMD_TIMER_LANG') ??
Deno.env.get('LC_ALL') ??
Deno.env.get('LC_MESSAGES') ??
Deno.env.get('LANG') ??
'en'
).split('.')[0].split('_')[0].toLowerCase();
}

1
src/i18n/mod.ts Normal file
View File

@@ -0,0 +1 @@
export { getCurrentLanguage, initI18n, loadLocale, t } from './i18n.ts';

View File

@@ -1,5 +1,8 @@
import './cli/main.ts';
import { createCli } from './cli/mod.ts';
import { initI18n } from './i18n/mod.ts';
// ────────────────────────────────────────────────
// Entry Point for CLI
// Delegates to src/cli/main.ts, which registers all CLI commands
await initI18n();
await (await createCli()).parse(Deno.args);

View File

@@ -1,4 +1,5 @@
import {
assert,
assertStringIncludes,
} from 'https://deno.land/std@0.224.0/assert/mod.ts';
import { TimerOptions } from '../../types/mod.ts';
@@ -53,3 +54,63 @@ Deno.test('generateUnits berücksichtigt environment und logfile', () => {
assertStringIncludes(serviceUnit, 'StandardOutput=append:/var/log/job.log');
assertStringIncludes(serviceUnit, 'StandardError=append:/var/log/job.log');
});
Deno.test('generateUnits berücksichtigt runAs', () => {
const opts: TimerOptions = {
exec: '/bin/true',
calendar: 'daily',
runAs: 'myuser',
};
const { serviceUnit } = generateUnits('job', opts);
assertStringIncludes(serviceUnit, 'User=myuser');
});
Deno.test('generateUnits berücksichtigt home', () => {
const opts: TimerOptions = {
exec: '/bin/true',
calendar: 'daily',
home: '/home/myuser',
};
const { serviceUnit } = generateUnits('job', opts);
assertStringIncludes(serviceUnit, 'Environment=HOME=/home/myuser');
});
Deno.test('generateUnits berücksichtigt cwd', () => {
const opts: TimerOptions = {
exec: '/bin/true',
calendar: 'daily',
cwd: '/srv/app',
};
const { serviceUnit } = generateUnits('job', opts);
assertStringIncludes(serviceUnit, 'WorkingDirectory=/srv/app');
});
Deno.test('generateUnits verwendet default.target bei User-Timern', () => {
const opts: TimerOptions = {
exec: '/bin/true',
calendar: 'daily',
user: true,
};
const { timerUnit } = generateUnits('job', opts);
assertStringIncludes(timerUnit, 'WantedBy=default.target');
});
Deno.test('generateUnits ignoriert runAs bei --user', () => {
const opts = {
exec: '/bin/true',
calendar: 'daily',
user: true,
runAs: 'should-not-appear',
};
const { serviceUnit } = generateUnits('job', opts);
assert(
!serviceUnit.includes('User=should-not-appear'),
'User= sollte bei --user nicht enthalten sein',
);
});

View File

@@ -1,3 +1,4 @@
import { t } from '../i18n/mod.ts';
import { TimerOptions } from '../types/mod.ts';
import { deriveNameFromExec, writeUnitFiles } from '../utils/mod.ts';
@@ -21,13 +22,13 @@ export async function generateUnitFiles(options: TimerOptions): Promise<void> {
if (result) {
const { servicePath, timerPath } = result;
console.log(`Service Unit geschrieben in: ${servicePath}`);
console.log(`Timer Unit geschrieben in: ${timerPath}`);
console.log(t('unit_written_service', { path: servicePath }));
console.log(t('unit_written_timer', { path: timerPath }));
} else {
return;
}
console.log(`\nℹ️ Hinweis:`);
console.log(t('hint_header'));
if (options.user) {
console.log(` systemctl --user daemon-reload`);
@@ -51,14 +52,18 @@ export function generateUnits(name: string, options: TimerOptions): {
`[Service]`,
`Type=oneshot`,
`ExecStart=${options.exec}`,
...(options.cwd ? [`WorkingDirectory=${options.cwd}`] : []),
...(options.environment?.map((e) => `Environment=${e}`) ?? []),
...(options.home ? [`Environment=HOME=${options.home}`] : []),
...(options.logfile
? [
`StandardOutput=append:${options.logfile}`,
`StandardError=append:${options.logfile}`,
]
: []),
...(options.runAs && !options.user ? [`User=${options.runAs}`] : []),
];
if (options.logfile) {
unitParts.push(`StandardOutput=append:${options.logfile}`);
unitParts.push(`StandardError=append:${options.logfile}`);
}
const serviceUnit = unitParts.join('\n');
const timerParts = [
@@ -70,7 +75,7 @@ export function generateUnits(name: string, options: TimerOptions): {
`Persistent=true`,
``,
`[Install]`,
`WantedBy=timers.target`,
`WantedBy=${options.user ? 'default.target' : 'timers.target'}`,
];
const timerUnit = timerParts.join('\n');

View File

@@ -4,6 +4,9 @@ export interface TimerOptions {
calendar: string;
description?: string;
user?: boolean;
runAs?: string;
home?: string;
cwd?: string;
output?: string;
after?: string[];
environment?: string[];

View File

@@ -0,0 +1,145 @@
import { resolve } from 'https://deno.land/std@0.224.0/path/mod.ts';
import { ValidationError } from '@cliffy/command';
import {
collectAndValidateAfter,
collectAndValidateEnv,
validateIdentifier,
validateNotEmpty,
validatePath,
validateSystemdCalendar,
} from '../mod.ts';
import { t } from '../../i18n/mod.ts';
import {
assertEquals,
assertThrows,
} from 'https://deno.land/std@0.224.0/assert/mod.ts';
Deno.test(
'collectAndValidateEnv: throws ValidationError for invalid env format',
() => {
const invalidEnv = 'INVALID_ENV';
assertThrows(
() => collectAndValidateEnv(invalidEnv),
ValidationError,
t('error_invalid_env', { value: invalidEnv }),
);
},
);
Deno.test(
'collectAndValidateEnv: returns collected array for valid env format',
() => {
const validEnv = 'KEY=value';
const previous = ['EXISTING=env'];
const result = collectAndValidateEnv(validEnv, previous);
assertEquals(result, ['EXISTING=env', 'KEY=value']);
},
);
Deno.test('collectAndValidateEnv: aggregates multiple calls', () => {
const first = collectAndValidateEnv('FOO=bar');
const second = collectAndValidateEnv('BAZ=qux', first);
assertEquals(second, ['FOO=bar', 'BAZ=qux']);
});
Deno.test(
'validatePath: returns absolute path for valid path without existence check',
() => {
const tmpDir = Deno.makeTempDirSync();
const relativePath = `${tmpDir}/testfile.txt`;
const result = validatePath(relativePath, false);
assertEquals(result, resolve(relativePath));
},
);
Deno.test(
'validatePath: throws ValidationError for non‑existent path with existence check',
() => {
const tmpDir = Deno.makeTempDirSync();
const nonExistentPath = `${tmpDir}/nonexistent.txt`;
assertThrows(
() => validatePath(nonExistentPath, true),
ValidationError,
t('error_path_not_found', { path: resolve(nonExistentPath) }),
);
},
);
Deno.test(
'validatePath: returns absolute path for existing path with existence check',
() => {
const tmpDir = Deno.makeTempDirSync();
const existingPath = `${tmpDir}/existing.txt`;
Deno.writeTextFileSync(existingPath, 'test content');
const result = validatePath(existingPath, true);
assertEquals(result, resolve(existingPath));
},
);
Deno.test('validatePath: throws ValidationError for empty path', () => {
const invalidPath = '';
assertThrows(
() => validatePath(invalidPath, true),
ValidationError,
t('error_path_schold_not_be_empty'),
);
});
Deno.test('validateNotEmpty: returns value for non‑empty string', () => {
const input = 'some-value';
const result = validateNotEmpty(input, '--exec');
assertEquals(result, input);
});
Deno.test('validateNotEmpty: throws ValidationError for empty string', () => {
const input = '';
assertThrows(
() => validateNotEmpty(input, '--exec'),
ValidationError,
t('error_value_should_not_be_empty', { label: '--exec' }),
);
});
Deno.test('collectAndValidateAfter: returns aggregated array', () => {
const first = collectAndValidateAfter('network.target');
const second = collectAndValidateAfter('postgres.service', first);
assertEquals(second, ['network.target', 'postgres.service']);
});
Deno.test('collectAndValidateAfter: throws ValidationError for empty value', () => {
assertThrows(
() => collectAndValidateAfter(''),
ValidationError,
t('error_value_should_not_be_empty', { label: '--after' }),
);
});
Deno.test('validateIdentifier: returns value for valid identifier', () => {
const id = 'backup_job-1';
const result = validateIdentifier(id, '--name');
assertEquals(result, id);
});
Deno.test('validateIdentifier: throws ValidationError for invalid identifier', () => {
const id = 'invalid$';
assertThrows(
() => validateIdentifier(id, '--name'),
ValidationError,
t('error_invalid_identifier', { label: '--name', value: id }),
);
});
Deno.test('validateSystemdCalendar: accepts valid expression', async () => {
const valid = 'Mon..Fri 12:00';
const result = await validateSystemdCalendar(valid);
assertEquals(result, valid);
});
Deno.test('validateSystemdCalendar: rejects invalid expression', async () => {
const invalid = 'Mo..Fr 12:00';
await assertThrows(
() => validateSystemdCalendar(invalid),
ValidationError,
t('error_invalid_calendar', { value: invalid }),
);
});

View File

@@ -3,11 +3,13 @@ import {
assertExists,
assertStringIncludes,
} from 'https://deno.land/std@0.224.0/assert/mod.ts';
import { stub } from 'https://deno.land/std@0.224.0/testing/mock.ts';
import { exists } from 'https://deno.land/std@0.224.0/fs/mod.ts';
import { join } from 'https://deno.land/std@0.224.0/path/mod.ts';
import { resolveUnitTargetPath, writeUnitFiles } from '../mod.ts';
import { TimerOptions } from '../../types/options.ts';
Deno.test('writeUnitFiles schreibt .service und .timer korrekt', async () => {
Deno.test('writeUnitFiles: writes .service and .timer files correctly', async () => {
const tmp = await Deno.makeTempDir({ prefix: 'test-units-' });
const options = {
@@ -26,15 +28,15 @@ Deno.test('writeUnitFiles schreibt .service und .timer korrekt', async () => {
options as TimerOptions,
) as { servicePath: string; timerPath: string };
// Überprüfe Pfade
// Check file paths
assertEquals(servicePath, join(tmp, 'test-backup.service'));
assertEquals(timerPath, join(tmp, 'test-backup.timer'));
// Existieren Dateien?
// Check if files exist
assertExists(await Deno.stat(servicePath));
assertExists(await Deno.stat(timerPath));
// Enthält die Datei den erwarteten Inhalt?
// Check if file contents match expectations
const readService = await Deno.readTextFile(servicePath);
const readTimer = await Deno.readTextFile(timerPath);
@@ -42,18 +44,237 @@ Deno.test('writeUnitFiles schreibt .service und .timer korrekt', async () => {
assertStringIncludes(readTimer, 'OnCalendar=daily');
});
Deno.test('resolveUnitTargetPath mit --output', () => {
Deno.test('resolveUnitTargetPath: with --output', () => {
const result = resolveUnitTargetPath({ output: '/tmp/units', user: false });
assertEquals(result, '/tmp/units');
});
Deno.test('resolveUnitTargetPath mit --user ohne output', () => {
Deno.test('resolveUnitTargetPath: with --user and no output', () => {
Deno.env.set('HOME', '/home/maxp');
const result = resolveUnitTargetPath({ output: undefined, user: true });
assertEquals(result, '/home/maxp/.config/systemd/user');
});
Deno.test('resolveUnitTargetPath ohne output und ohne user', () => {
Deno.test('resolveUnitTargetPath: with no output and no user', () => {
const result = resolveUnitTargetPath({ output: undefined, user: false });
assertEquals(result, '/etc/systemd/system');
});
Deno.test('writeUnitFiles: error writing .timer file triggers rollback', async () => {
const tmp = await Deno.makeTempDir();
const options: TimerOptions = {
output: tmp,
user: false,
exec: '/bin/true',
calendar: 'never',
};
const name = 'fail-timer';
const serviceContent = '[Service]\nExecStart=/bin/true';
const timerContent = '[Timer]\nOnCalendar=never';
const servicePath = join(tmp, `${name}.service`);
const timerPath = join(tmp, `${name}.timer`);
// Simulate: writing the .timer file fails
const originalWrite = Deno.writeTextFile;
const writeStub = stub(
Deno,
'writeTextFile',
async (path: string | URL, data: string | ReadableStream<string>) => {
if (typeof path === 'string' && path.endsWith('.timer')) {
throw new Error('Simulated write error');
} else {
return await originalWrite(path, data);
}
},
);
const result = await writeUnitFiles(
name,
serviceContent,
timerContent,
options,
);
// Expect: function returns undefined
assertEquals(result, undefined);
// Expect: both files have been deleted (rollback)
assertEquals(await exists(servicePath), false);
assertEquals(await exists(timerPath), false);
writeStub.restore();
});
Deno.test('writeUnitFiles: error writing .service file prevents further actions', async () => {
const tmp = await Deno.makeTempDir();
const options: TimerOptions = {
output: tmp,
user: false,
exec: '/bin/true',
calendar: 'weekly',
};
const name = 'fail-service';
const serviceContent = '[Service]\nExecStart=/bin/true';
const timerContent = '[Timer]\nOnCalendar=weekly';
const servicePath = join(tmp, `${name}.service`);
const timerPath = join(tmp, `${name}.timer`);
// Simulate: error writing the .service file
const writeStub = stub(
Deno,
'writeTextFile',
(path: string | URL, _data: string | ReadableStream<string>) => {
if (typeof path === 'string' && path.endsWith('.service')) {
throw new Error('Simulated service write error');
}
return Promise.resolve();
},
);
const result = await writeUnitFiles(
name,
serviceContent,
timerContent,
options,
);
// Expect: function returns undefined
assertEquals(result, undefined);
// Expect: no files were created
assertEquals(await exists(servicePath), false);
assertEquals(await exists(timerPath), false);
writeStub.restore();
});
Deno.test('writeUnitFiles: both files written, then error → full rollback', async () => {
const tmp = await Deno.makeTempDir();
const options: TimerOptions = {
output: tmp,
user: false,
exec: '/bin/true',
calendar: 'never',
};
const name = 'fail-after-write';
const serviceContent = '[Service]\nExecStart=/bin/true';
const timerContent = '[Timer]\nOnCalendar=never';
const servicePath = join(tmp, `${name}.service`);
const timerPath = join(tmp, `${name}.timer`);
let writeCount = 0;
const originalWriteTextFile = Deno.writeTextFile;
const writeStub = stub(
Deno,
'writeTextFile',
async (path: string | URL, data: string | ReadableStream<string>) => {
if (typeof path !== 'string') {
throw new Error('Unexpected path type');
}
// Simulate both writes, then throw an error after the second
writeCount++;
await originalWriteTextFile(path, data);
if (writeCount === 2) {
throw new Error('Simulated error after full write');
}
},
);
const result = await writeUnitFiles(
name,
serviceContent,
timerContent,
options,
);
// Expect: function returns undefined
assertEquals(result, undefined);
// Expect: both files were removed
assertEquals(await exists(servicePath), false);
assertEquals(await exists(timerPath), false);
writeStub.restore();
});
Deno.test('writeUnitFiles: rollback fails if files cannot be deleted', async () => {
const tmp = await Deno.makeTempDir();
const options: TimerOptions = {
output: tmp,
user: false,
exec: '/bin/true',
calendar: 'never',
};
const name = 'rollback-error';
const serviceContent = '[Service]\nExecStart=/bin/true';
const timerContent = '[Timer]\nOnCalendar=never';
const servicePath = join(tmp, `${name}.service`);
const timerPath = join(tmp, `${name}.timer`);
const originalWriteTextFile = Deno.writeTextFile;
const _originalRemove = Deno.remove;
let writeCount = 0;
const writeStub = stub(
Deno,
'writeTextFile',
async (path: string | URL, data: string | ReadableStream<string>) => {
writeCount++;
await originalWriteTextFile(path, data);
if (writeCount === 2) {
throw new Error('Error after full write');
}
},
);
const removeStub = stub(
Deno,
'remove',
// deno-lint-ignore require-await
async (_path: string | URL, _opts?: Deno.RemoveOptions) => {
throw new Error('Deletion forbidden!');
},
);
const logs: string[] = [];
const consoleStub = stub(console, 'error', (...args) => {
logs.push(args.map((a) => String(a)).join(' '));
});
const result = await writeUnitFiles(
name,
serviceContent,
timerContent,
options,
);
assertEquals(result, undefined);
// Files still exist because deletion failed
assertEquals(await exists(servicePath), true);
assertEquals(await exists(timerPath), true);
// Error output contains "rollback_failed"
const combinedLogs = logs.join('\n');
assertStringIncludes(combinedLogs, 'rollback_failed');
writeStub.restore();
removeStub.restore();
consoleStub.restore();
});

View File

@@ -0,0 +1,176 @@
import { ValidationError } from '@cliffy/command';
import { t } from '../i18n/mod.ts';
import { existsSync } from 'https://deno.land/std@0.224.0/fs/mod.ts';
import { resolve } from 'https://deno.land/std@0.224.0/path/mod.ts';
/**
* Collects repeated occurrences of the `--environment` CLI option and validates
* that every entry adheres to `KEY=value` syntax.
*
* Cliffy calls this handler for *each* `--environment` argument. The previously
* accumulated values are passed in via the `previous` parameter, allowing us to
* build up the final array manually.
*
* @example
* ```ts
* // CLI invocation:
* // mycli --environment FOO=bar --environment BAZ=qux
* const env = collectAndValidateEnv("FOO=bar");
* const env2 = collectAndValidateEnv("BAZ=qux", env);
* console.log(env2); // => ["FOO=bar", "BAZ=qux"]
* ```
*
* @param value - Current `KEY=value` string supplied by the user.
* @param previous - Array of values collected so far (defaults to an empty
* array on the first call).
* @returns The updated array containing all validated `KEY=value` pairs.
*
* @throws {ValidationError} If the input does not match the required syntax
* The resulting error is caught by Cliffy, which
* will print a help message and terminate with a non-zero exit code.
*/
export function collectAndValidateEnv(
value: string,
previous: string[] = [],
): string[] {
if (!/^\w+=.+/.test(value)) {
throw new ValidationError(t('error_invalid_env', { value }));
}
previous.push(value);
return previous;
}
/**
* Normalises a given path to its absolute representation and optionally checks
* whether it exists on the filesystem.
*
* @remarks
* Because `--home`, `--cwd`, `--output`, and similar options may refer to files
* or directories that *must* already exist, this helper performs the common
* validation in a single place.
*
* @param value - Path provided by the user (absolute or relative).
* @param mustExist - When `true`, the function asserts that the resolved path
* exists; otherwise, existence is not verified.
* @returns The absolute path derived via {@linkcode resolve}.
*
* @throws {ValidationError}
* - If `value` is empty or not a string.
* - If `mustExist` is `true` **and** the resolved path cannot be found.
*/
export function validatePath(value: string, mustExist: boolean): string {
if (!value || typeof value !== 'string') {
throw new ValidationError(t('error_path_schold_not_be_empty'));
}
const abs = resolve(value);
if (mustExist && !existsSync(abs)) {
throw new ValidationError(t('error_path_not_found', { path: abs }));
}
return abs;
}
/**
* Ensures that a mandatory CLI argument is not empty.
*
* @param value - Raw string supplied by the user.
* @param label - Human-readable label identifying the option (used in error
* messages). Example: `"--exec"`.
* @returns The original `value` if the validation passes.
*
* @throws {ValidationError} When `value` is `""`, `null`, `undefined`, or a
* non-string.
*/
export function validateNotEmpty(value: string, label: string): string {
if (!value || typeof value !== 'string') {
throw new ValidationError(
t('error_value_should_not_be_empty', { label }),
);
}
return value;
}
/**
* Collects repeated occurrences of the `--after` CLI option, validating each
* target unit name and returning the aggregated list.
*
* The validation performed here is intentionally minimal – it merely checks
* that the argument is a non-empty string. Detailed identifier rules (ASCII
* characters, digits, etc.) are enforced elsewhere by
* {@link validateIdentifier} when appropriate.
*
* @param value - Unit name provided with the current `--after` occurrence.
* @param previous - Accumulated array of unit names (defaults to an empty
* array).
* @returns An array containing all validated `--after` values.
*
* @throws {ValidationError} If `value` is empty.
*/
export function collectAndValidateAfter(
value: string,
previous: string[] = [],
): string[] {
if (!value || typeof value !== 'string') {
throw new ValidationError(
t('error_value_should_not_be_empty', { label: '--after' }),
);
}
previous.push(value);
return previous;
}
/**
* Validates identifiers used in generated systemd unit file names (e.g.
* service or timer names) and option flags like `--run-as`.
*
* @example
* ```ts
* validateIdentifier("backup_job", "--name"); // OK
* validateIdentifier("foo.bar-baz", "--after"); // OK
* validateIdentifier("oops$", "--name"); // throws ValidationError
* ```
*
* @param value - The identifier string to validate.
* @param label - The relevant option flag, forwarded to the error message so
* the user immediately sees which argument is wrong.
* @returns The unchanged `value` when it satisfies the pattern.
*
* @throws {ValidationError} If `value` contains characters outside the allowed
* set of ASCII letters, digits, `.`, `_`, and `-`.
*/
export function validateIdentifier(value: string, label: string): string {
if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
throw new ValidationError(
t('error_invalid_identifier', { identifier: label, value }),
);
}
return value;
}
/**
* Validates a systemd OnCalendar expression by invoking `systemd-analyze calendar`
* as a subprocess using a synchronous call. This avoids async overhead.
*
* @remarks
* Requires a Linux environment with systemd available.
*
* @param value - Calendar expression to validate
* @returns The original `value` if valid
* @throws {ValidationError} If the expression is invalid or systemd rejects it
*/
export function validateSystemdCalendar(value: string): string {
const command = new Deno.Command('systemd-analyze', {
args: ['calendar', value],
stdout: 'null',
stderr: 'null',
});
const { success } = command.outputSync();
if (!success) {
throw new ValidationError(
t('error_invalid_calendar', { value }),
);
}
return value;
}

View File

@@ -1,6 +1,7 @@
import { ensureDir, exists } from 'https://deno.land/std@0.224.0/fs/mod.ts';
import { join } from 'https://deno.land/std@0.224.0/path/mod.ts';
import { TimerOptions } from '../types/mod.ts';
import { t } from '../i18n/mod.ts';
export async function writeUnitFiles(
name: string,
@@ -28,9 +29,9 @@ export async function writeUnitFiles(
await Deno.remove(timerPath);
}
} catch (rollbackError) {
console.error('Rollback fehlgeschlagen:', rollbackError);
console.error(t('rollback_failed'), rollbackError);
}
console.error('Fehler beim Schreiben der Units:', error);
console.error(t('error_write_units'), error);
return undefined;
}

View File

@@ -1,3 +1,11 @@
export {
collectAndValidateAfter,
collectAndValidateEnv,
validateIdentifier,
validateNotEmpty,
validatePath,
validateSystemdCalendar,
} from './cliValidationHelper.ts';
export { resolveUnitTargetPath, writeUnitFiles } from './fs.ts';
export { deriveNameFromExec } from './misc.ts';
export { getVersion } from './version.ts';