feat: implement validation utilities for user and page entities; add email, username, slug, title, level validation functions

This commit is contained in:
2025-12-25 19:01:56 +00:00
parent 38a4236a22
commit 4d0cbfc5d7
19 changed files with 415 additions and 328 deletions

View File

@@ -1,153 +1,10 @@
import type { User, PageView } from './types'
/**
* Validation utilities for DBAL operations
* Mirrors the validation logic from C++ implementation
*/
// Email validation using regex (RFC-compliant)
// TODO: add tests for isValidEmail (valid/invalid formats).
export function isValidEmail(email: string): boolean {
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailPattern.test(email)
}
// Username validation: alphanumeric, underscore, hyphen only (1-50 chars)
// TODO: add tests for isValidUsername (length, allowed chars).
export function isValidUsername(username: string): boolean {
if (!username || username.length === 0 || username.length > 50) {
return false
}
const usernamePattern = /^[a-zA-Z0-9_-]+$/
return usernamePattern.test(username)
}
// Slug validation: lowercase alphanumeric with hyphens (1-100 chars)
// TODO: add tests for isValidSlug (length, lowercase, invalid chars).
export function isValidSlug(slug: string): boolean {
if (!slug || slug.length === 0 || slug.length > 100) {
return false
}
const slugPattern = /^[a-z0-9-]+$/
return slugPattern.test(slug)
}
// Title validation: 1-200 characters
// TODO: add tests for isValidTitle (min/max bounds).
export function isValidTitle(title: string): boolean {
return title.length > 0 && title.length <= 200
}
// Level validation: 0-5 range
// TODO: add tests for isValidLevel (min/max bounds).
export function isValidLevel(level: number): boolean {
return level >= 0 && level <= 5
}
/**
* Validation rules for User entity
*/
// TODO: add tests for validateUserCreate (required fields, invalid formats, role validation).
export function validateUserCreate(data: Partial<User>): string[] {
const errors: string[] = []
if (!data.username) {
errors.push('Username is required')
} else if (!isValidUsername(data.username)) {
errors.push('Invalid username format (alphanumeric, underscore, hyphen only, 1-50 chars)')
}
if (!data.email) {
errors.push('Email is required')
} else if (!isValidEmail(data.email)) {
errors.push('Invalid email format')
}
if (!data.role) {
errors.push('Role is required')
} else if (!['user', 'admin', 'god', 'supergod'].includes(data.role)) {
errors.push('Invalid role')
}
return errors
}
// TODO: add tests for validateUserUpdate (optional field validation).
export function validateUserUpdate(data: Partial<User>): string[] {
const errors: string[] = []
if (data.username !== undefined && !isValidUsername(data.username)) {
errors.push('Invalid username format')
}
if (data.email !== undefined && !isValidEmail(data.email)) {
errors.push('Invalid email format')
}
if (data.role !== undefined && !['user', 'admin', 'god', 'supergod'].includes(data.role)) {
errors.push('Invalid role')
}
return errors
}
/**
* Validation rules for PageView entity
*/
// TODO: add tests for validatePageCreate (required fields, invalid formats, bounds).
export function validatePageCreate(data: Partial<PageView>): string[] {
const errors: string[] = []
if (!data.slug) {
errors.push('Slug is required')
} else if (!isValidSlug(data.slug)) {
errors.push('Invalid slug format (lowercase alphanumeric with hyphens, 1-100 chars)')
}
if (!data.title) {
errors.push('Title is required')
} else if (!isValidTitle(data.title)) {
errors.push('Invalid title (must be 1-200 characters)')
}
if (data.level === undefined) {
errors.push('Level is required')
} else if (!isValidLevel(data.level)) {
errors.push('Invalid level (must be 0-5)')
}
return errors
}
// TODO: add tests for validatePageUpdate (optional field validation).
export function validatePageUpdate(data: Partial<PageView>): string[] {
const errors: string[] = []
if (data.slug !== undefined && !isValidSlug(data.slug)) {
errors.push('Invalid slug format')
}
if (data.title !== undefined && !isValidTitle(data.title)) {
errors.push('Invalid title (must be 1-200 characters)')
}
if (data.level !== undefined && !isValidLevel(data.level)) {
errors.push('Invalid level (must be 0-5)')
}
return errors
}
/**
* Generic ID validation
*/
// TODO: add tests for validateId (empty/whitespace).
export function validateId(id: string): string[] {
const errors: string[] = []
if (!id || id.trim().length === 0) {
errors.push('ID cannot be empty')
}
return errors
}
export { isValidEmail } from './validation/is-valid-email'
export { isValidUsername } from './validation/is-valid-username'
export { isValidSlug } from './validation/is-valid-slug'
export { isValidTitle } from './validation/is-valid-title'
export { isValidLevel } from './validation/is-valid-level'
export { validateUserCreate } from './validation/validate-user-create'
export { validateUserUpdate } from './validation/validate-user-update'
export { validatePageCreate } from './validation/validate-page-create'
export { validatePageUpdate } from './validation/validate-page-update'
export { validateId } from './validation/validate-id'

View File

@@ -0,0 +1,6 @@
// Email validation using regex (RFC-compliant)
// TODO: add tests for isValidEmail (valid/invalid formats).
export function isValidEmail(email: string): boolean {
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailPattern.test(email)
}

View File

@@ -0,0 +1,5 @@
// Level validation: 0-5 range
// TODO: add tests for isValidLevel (min/max bounds).
export function isValidLevel(level: number): boolean {
return level >= 0 && level <= 5
}

View File

@@ -0,0 +1,9 @@
// Slug validation: lowercase alphanumeric with hyphens (1-100 chars)
// TODO: add tests for isValidSlug (length, lowercase, invalid chars).
export function isValidSlug(slug: string): boolean {
if (!slug || slug.length === 0 || slug.length > 100) {
return false
}
const slugPattern = /^[a-z0-9-]+$/
return slugPattern.test(slug)
}

View File

@@ -0,0 +1,5 @@
// Title validation: 1-200 characters
// TODO: add tests for isValidTitle (min/max bounds).
export function isValidTitle(title: string): boolean {
return title.length > 0 && title.length <= 200
}

View File

@@ -0,0 +1,9 @@
// Username validation: alphanumeric, underscore, hyphen only (1-50 chars)
// TODO: add tests for isValidUsername (length, allowed chars).
export function isValidUsername(username: string): boolean {
if (!username || username.length === 0 || username.length > 50) {
return false
}
const usernamePattern = /^[a-zA-Z0-9_-]+$/
return usernamePattern.test(username)
}

View File

@@ -0,0 +1,10 @@
// TODO: add tests for validateId (empty/whitespace).
export function validateId(id: string): string[] {
const errors: string[] = []
if (!id || id.trim().length === 0) {
errors.push('ID cannot be empty')
}
return errors
}

View File

@@ -0,0 +1,32 @@
import type { PageView } from '../types'
import { isValidLevel } from './is-valid-level'
import { isValidSlug } from './is-valid-slug'
import { isValidTitle } from './is-valid-title'
/**
* Validation rules for PageView entity
*/
// TODO: add tests for validatePageCreate (required fields, invalid formats, bounds).
export function validatePageCreate(data: Partial<PageView>): string[] {
const errors: string[] = []
if (!data.slug) {
errors.push('Slug is required')
} else if (!isValidSlug(data.slug)) {
errors.push('Invalid slug format (lowercase alphanumeric with hyphens, 1-100 chars)')
}
if (!data.title) {
errors.push('Title is required')
} else if (!isValidTitle(data.title)) {
errors.push('Invalid title (must be 1-200 characters)')
}
if (data.level === undefined) {
errors.push('Level is required')
} else if (!isValidLevel(data.level)) {
errors.push('Invalid level (must be 0-5)')
}
return errors
}

View File

@@ -0,0 +1,23 @@
import type { PageView } from '../types'
import { isValidLevel } from './is-valid-level'
import { isValidSlug } from './is-valid-slug'
import { isValidTitle } from './is-valid-title'
// TODO: add tests for validatePageUpdate (optional field validation).
export function validatePageUpdate(data: Partial<PageView>): string[] {
const errors: string[] = []
if (data.slug !== undefined && !isValidSlug(data.slug)) {
errors.push('Invalid slug format')
}
if (data.title !== undefined && !isValidTitle(data.title)) {
errors.push('Invalid title (must be 1-200 characters)')
}
if (data.level !== undefined && !isValidLevel(data.level)) {
errors.push('Invalid level (must be 0-5)')
}
return errors
}

View File

@@ -0,0 +1,31 @@
import type { User } from '../types'
import { isValidEmail } from './is-valid-email'
import { isValidUsername } from './is-valid-username'
/**
* Validation rules for User entity
*/
// TODO: add tests for validateUserCreate (required fields, invalid formats, role validation).
export function validateUserCreate(data: Partial<User>): string[] {
const errors: string[] = []
if (!data.username) {
errors.push('Username is required')
} else if (!isValidUsername(data.username)) {
errors.push('Invalid username format (alphanumeric, underscore, hyphen only, 1-50 chars)')
}
if (!data.email) {
errors.push('Email is required')
} else if (!isValidEmail(data.email)) {
errors.push('Invalid email format')
}
if (!data.role) {
errors.push('Role is required')
} else if (!['user', 'admin', 'god', 'supergod'].includes(data.role)) {
errors.push('Invalid role')
}
return errors
}

View File

@@ -0,0 +1,22 @@
import type { User } from '../types'
import { isValidEmail } from './is-valid-email'
import { isValidUsername } from './is-valid-username'
// TODO: add tests for validateUserUpdate (optional field validation).
export function validateUserUpdate(data: Partial<User>): string[] {
const errors: string[] = []
if (data.username !== undefined && !isValidUsername(data.username)) {
errors.push('Invalid username format')
}
if (data.email !== undefined && !isValidEmail(data.email)) {
errors.push('Invalid email format')
}
if (data.role !== undefined && !['user', 'admin', 'god', 'supergod'].includes(data.role)) {
errors.push('Invalid role')
}
return errors
}

View File

@@ -22,7 +22,7 @@ interface CssCategory {
classes: string[]
}
const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.[\]()%#!,=+-]+$/
const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/
const parseClassList = (value: string) => Array.from(new Set(value.split(/\s+/).filter(Boolean)))
export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: CssClassBuilderProps) {
@@ -80,6 +80,10 @@ export function CssClassBuilder({ open, onClose, initialValue = '', onSave }: Cs
return
}
if (activeTab === 'custom') {
return
}
const hasActiveTab = filteredCategories.some((category) => category.name === activeTab)
if (!hasActiveTab) {
setActiveTab(filteredCategories[0]?.name ?? 'custom')

View File

@@ -11,7 +11,7 @@ import { Database, CssCategory } from '@/lib/database'
import { Plus, X, Pencil, Trash, FloppyDisk } from '@phosphor-icons/react'
import { toast } from 'sonner'
const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.[\]()%#!,=+-]+$/
const CLASS_TOKEN_PATTERN = /^[A-Za-z0-9:_/.\[\]()%#!,=+-]+$/
const uniqueClasses = (classes: string[]) => Array.from(new Set(classes))
export function CssClassManager() {

View File

@@ -1,10 +1,12 @@
import { LuaEngine, LuaExecutionContext, LuaExecutionResult } from './lua-engine'
import { securityScanner, type SecurityScanResult } from '../security'
import * as fengari from 'fengari-web'
const lua = fengari.lua
const lauxlib = fengari.lauxlib
const lualib = fengari.lualib
import { LuaEngine } from './lua-engine'
import type { LuaExecutionResult } from './lua-engine'
import type { SecurityScanResult } from '../security-scanner'
import { executeWithSandbox } from './functions/sandbox/execute-with-sandbox'
import { disableDangerousFunctions } from './functions/sandbox/disable-dangerous-functions'
import { setupSandboxedEnvironment } from './functions/sandbox/setup-sandboxed-environment'
import { executeWithTimeout } from './functions/sandbox/execute-with-timeout'
import { setExecutionTimeout } from './functions/sandbox/set-execution-timeout'
import { destroy } from './functions/sandbox/destroy'
export interface SandboxedLuaResult {
execution: LuaExecutionResult
@@ -12,170 +14,21 @@ export interface SandboxedLuaResult {
}
export class SandboxedLuaEngine {
private engine: LuaEngine | null = null
private executionTimeout: number = 5000
private maxMemory: number = 10 * 1024 * 1024
engine: LuaEngine | null = null
executionTimeout = 5000
// TODO: Enforce maxMemory limit in sandbox execution.
maxMemory = 10 * 1024 * 1024
constructor(timeout: number = 5000) {
this.executionTimeout = timeout
}
async executeWithSandbox(code: string, context: LuaExecutionContext = {}): Promise<SandboxedLuaResult> {
const securityResult = securityScanner.scanLua(code)
if (securityResult.severity === 'critical') {
return {
execution: {
success: false,
error: 'Code blocked due to critical security issues. Please review the security warnings.',
logs: []
},
security: securityResult
}
}
this.engine = new LuaEngine()
this.disableDangerousFunctions()
this.setupSandboxedEnvironment()
const executionPromise = this.executeWithTimeout(code, context)
try {
const result = await executionPromise
return {
execution: result,
security: securityResult
}
} catch (error) {
return {
execution: {
success: false,
error: error instanceof Error ? error.message : 'Execution failed',
logs: []
},
security: securityResult
}
} finally {
if (this.engine) {
this.engine.destroy()
this.engine = null
}
}
}
private disableDangerousFunctions() {
if (!this.engine) return
const L = (this.engine as any).L
lua.lua_pushnil(L)
lua.lua_setglobal(L, fengari.to_luastring('os'))
lua.lua_pushnil(L)
lua.lua_setglobal(L, fengari.to_luastring('io'))
lua.lua_pushnil(L)
lua.lua_setglobal(L, fengari.to_luastring('loadfile'))
lua.lua_pushnil(L)
lua.lua_setglobal(L, fengari.to_luastring('dofile'))
lua.lua_getglobal(L, fengari.to_luastring('package'))
if (!lua.lua_isnil(L, -1)) {
lua.lua_pushnil(L)
lua.lua_setfield(L, -2, fengari.to_luastring('loadlib'))
lua.lua_pushnil(L)
lua.lua_setfield(L, -2, fengari.to_luastring('searchpath'))
lua.lua_pushstring(L, fengari.to_luastring(''))
lua.lua_setfield(L, -2, fengari.to_luastring('cpath'))
}
lua.lua_pop(L, 1)
lua.lua_getglobal(L, fengari.to_luastring('debug'))
if (!lua.lua_isnil(L, -1)) {
lua.lua_pushnil(L)
lua.lua_setfield(L, -2, fengari.to_luastring('getfenv'))
lua.lua_pushnil(L)
lua.lua_setfield(L, -2, fengari.to_luastring('setfenv'))
}
lua.lua_pop(L, 1)
}
private setupSandboxedEnvironment() {
if (!this.engine) return
const L = (this.engine as any).L
lua.lua_newtable(L)
const safeFunctions = [
'assert', 'error', 'ipairs', 'next', 'pairs', 'pcall', 'select',
'tonumber', 'tostring', 'type', 'unpack', 'xpcall',
'string', 'table', 'math', 'bit32'
]
for (const funcName of safeFunctions) {
lua.lua_getglobal(L, fengari.to_luastring(funcName))
lua.lua_setfield(L, -2, fengari.to_luastring(funcName))
}
lua.lua_getglobal(L, fengari.to_luastring('print'))
lua.lua_setfield(L, -2, fengari.to_luastring('print'))
lua.lua_getglobal(L, fengari.to_luastring('log'))
lua.lua_setfield(L, -2, fengari.to_luastring('log'))
lua.lua_pushvalue(L, -1)
lua.lua_setfield(L, -2, fengari.to_luastring('_G'))
lua.lua_setglobal(L, fengari.to_luastring('_ENV'))
}
private async executeWithTimeout(code: string, context: LuaExecutionContext): Promise<LuaExecutionResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.engine) {
this.engine.destroy()
this.engine = null
}
reject(new Error(`Execution timeout: exceeded ${this.executionTimeout}ms limit`))
}, this.executionTimeout)
if (!this.engine) {
clearTimeout(timeout)
reject(new Error('Engine not initialized'))
return
}
this.engine.execute(code, context)
.then(result => {
clearTimeout(timeout)
resolve(result)
})
.catch(error => {
clearTimeout(timeout)
reject(error)
})
})
}
setExecutionTimeout(timeout: number) {
this.executionTimeout = timeout
}
destroy() {
if (this.engine) {
this.engine.destroy()
this.engine = null
}
}
executeWithSandbox = executeWithSandbox
disableDangerousFunctions = disableDangerousFunctions
setupSandboxedEnvironment = setupSandboxedEnvironment
executeWithTimeout = executeWithTimeout
setExecutionTimeout = setExecutionTimeout
destroy = destroy
}
export function createSandboxedLuaEngine(timeout: number = 5000): SandboxedLuaEngine {
return new SandboxedLuaEngine(timeout)
}
export { createSandboxedLuaEngine } from './create-sandboxed-lua-engine'

View File

@@ -1,4 +1,4 @@
import { LuaEngine } from '../lua-engine'
import { LuaEngine } from '../../lua-engine'
import type { DeclarativeComponentConfig, LuaScriptDefinition } from './types'
export interface DeclarativeRendererState {

View File

@@ -3,4 +3,6 @@ import { WorkflowEngine } from './workflow-engine-class'
/**
* @deprecated Use WorkflowEngine.execute() directly
*/
export const createWorkflowEngine = (): WorkflowEngine => new WorkflowEngine()
export function createWorkflowEngine(): WorkflowEngine {
return new WorkflowEngine()
}

View File

@@ -1,9 +1,16 @@
import type { Workflow } from '../types/level-types'
import type { WorkflowExecutionContext } from './workflow-execution-context'
import type { WorkflowExecutionResult } from './workflow-execution-result'
import type { WorkflowEngine } from './workflow-engine-class'
import { executeWorkflow } from './execute-workflow'
export const executeWorkflowInstance = (
/**
* Convenience instance method for legacy workflow execution
*/
export async function executeWorkflowInstance(
this: WorkflowEngine,
workflow: Workflow,
context: WorkflowExecutionContext
): Promise<WorkflowExecutionResult> => executeWorkflow(workflow, context)
): Promise<WorkflowExecutionResult> {
return executeWorkflow(workflow, context)
}

View File

@@ -10,10 +10,10 @@ export {
logToWorkflow,
WorkflowEngine,
createWorkflowEngine,
} from './workflow'
} from './index'
export type {
WorkflowExecutionContext,
WorkflowExecutionResult,
WorkflowState,
} from './workflow'
} from './index'

212
tools/autopusher.sh Executable file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env bash
set -euo pipefail
SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
REMOTE="${REMOTE:-origin}"
NO_VERIFY="${NO_VERIFY:-1}" # set to 0 to enable hooks
PUSH_RETRIES="${PUSH_RETRIES:-1}" # per loop; keep low since loop repeats
die() { echo "ERROR: $*" >&2; exit 1; }
in_git_repo() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1
}
current_branch() {
git rev-parse --abbrev-ref HEAD
}
has_unpushed_commits() {
# Returns 0 if ahead of upstream, else 1
local upstream
if ! upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; then
return 1
fi
[ "$(git rev-list --count "${upstream}..HEAD" 2>/dev/null || echo 0)" -gt 0 ]
}
ensure_upstream() {
local branch upstream
branch="$(current_branch)"
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
return 0
fi
echo "No upstream set for '$branch'; setting to ${REMOTE}/${branch}"
git push -u "$REMOTE" "$branch"
}
safe_git_commit() {
local msg="$1"
if [ "$NO_VERIFY" = "1" ]; then
git commit --no-verify -m "$msg"
else
git commit -m "$msg"
fi
}
safe_git_push() {
local branch tries=0
branch="$(current_branch)"
ensure_upstream
while :; do
if git push "$REMOTE" "$branch"; then
return 0
fi
tries=$((tries + 1))
if [ "$tries" -ge "$PUSH_RETRIES" ]; then
return 1
fi
sleep "$SLEEP_SECONDS"
done
}
sanitize_token() {
local s="$1"
s="${s//_/ }"
s="${s//-/ }"
s="${s//./ }"
s="$(echo "$s" | tr -s ' ' | sed -e 's/^ *//' -e 's/ *$//')"
echo "$s"
}
top_tokens_from_paths() {
# Prints up to 3 best-guess tokens (dirs/files/exts) from a newline list of paths.
local paths="$1"
local tmp
tmp="$(mktemp)"
printf "%s\n" "$paths" | awk '
function emit(tok) {
gsub(/[^A-Za-z0-9]+/, " ", tok)
gsub(/^ +| +$/, "", tok)
if (length(tok) > 0) print tok
}
{
path=$0
n=split(path, parts, "/")
if (n >= 2) emit(parts[1])
if (n >= 3) emit(parts[2])
file=parts[n]
# extension
m=split(file, fp, ".")
if (m >= 2) emit(fp[m])
# basename (no ext)
base=file
sub(/\.[^.]+$/, "", base)
emit(base)
}
' | tr '[:upper:]' '[:lower:]' | awk 'length($0) >= 3' >"$tmp"
# Collapse to frequency, prefer non-generic words.
awk '
BEGIN {
split("test tests spec specs docs doc readme license changelog vendor thirdparty third_party build dist out tmp temp cache node_modules .git", bad, " ")
for (i in bad) isbad[bad[i]]=1
}
{
for (i=1; i<=NF; i++) {
w=$i
if (!isbad[w]) freq[w]++
}
}
END {
for (w in freq) print freq[w], w
}
' "$tmp" | sort -rn | awk 'NR<=3 {print $2}' | paste -sd',' - || true
rm -f "$tmp"
}
detect_verb() {
# Simple verb heuristic from changed file types
local paths="$1"
if echo "$paths" | grep -qiE '\.(md|rst|txt)$'; then
echo "docs"
return
fi
if echo "$paths" | grep -qiE '\.(yml|yaml|json|toml|ini|cfg)$'; then
echo "config"
return
fi
if echo "$paths" | grep -qiE '\.(sh|ps1|bat|cmd)$'; then
echo "scripts"
return
fi
if echo "$paths" | grep -qiE '\.(py|js|ts|tsx|java|kt|c|cc|cpp|h|hpp|rs|go)$'; then
echo "code"
return
fi
echo "update"
}
generate_message() {
local name_status paths changed_count verb tokens
name_status="$(git diff --cached --name-status --diff-filter=ACDMRT || true)"
if [ -z "$name_status" ]; then
echo "chore: update"
return
fi
paths="$(echo "$name_status" | awk '{print $NF}' | sed '/^$/d')"
changed_count="$(echo "$paths" | sed '/^$/d' | wc -l | tr -d ' ')"
verb="$(detect_verb "$paths")"
tokens="$(top_tokens_from_paths "$paths")"
if [ -n "$tokens" ]; then
echo "${verb}: ${tokens} (${changed_count} files)"
else
echo "${verb}: ${changed_count} files"
fi
}
stage_if_needed() {
# Stage all changes; returns 0 if anything staged, else 1
git add -A
git diff --cached --quiet && return 1
return 0
}
main_loop() {
in_git_repo || die "Not inside a git repository."
while :; do
if has_unpushed_commits; then
echo "Unpushed commits detected; attempting push..."
if ! safe_git_push; then
echo "Push failed; retrying after ${SLEEP_SECONDS}s..."
sleep "$SLEEP_SECONDS"
continue
fi
echo "Push succeeded."
sleep "$SLEEP_SECONDS"
continue
fi
if ! stage_if_needed; then
sleep "$SLEEP_SECONDS"
continue
fi
local msg
msg="$(generate_message)"
echo "Committing: $msg"
safe_git_commit "$msg" || {
echo "Commit failed; retrying after ${SLEEP_SECONDS}s..."
sleep "$SLEEP_SECONDS"
continue
}
echo "Pushing..."
if ! safe_git_push; then
echo "Push failed; will retry after ${SLEEP_SECONDS}s (commit preserved)."
sleep "$SLEEP_SECONDS"
continue
fi
sleep "$SLEEP_SECONDS"
done
}
main_loop