diff --git a/dbal/ts/src/core/validation.ts b/dbal/ts/src/core/validation.ts index 443a7e338..1f188a7aa 100644 --- a/dbal/ts/src/core/validation.ts +++ b/dbal/ts/src/core/validation.ts @@ -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): 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): 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): 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): 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' diff --git a/dbal/ts/src/core/validation/is-valid-email.ts b/dbal/ts/src/core/validation/is-valid-email.ts new file mode 100644 index 000000000..fac6d9cee --- /dev/null +++ b/dbal/ts/src/core/validation/is-valid-email.ts @@ -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) +} diff --git a/dbal/ts/src/core/validation/is-valid-level.ts b/dbal/ts/src/core/validation/is-valid-level.ts new file mode 100644 index 000000000..22fa1ba8a --- /dev/null +++ b/dbal/ts/src/core/validation/is-valid-level.ts @@ -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 +} diff --git a/dbal/ts/src/core/validation/is-valid-slug.ts b/dbal/ts/src/core/validation/is-valid-slug.ts new file mode 100644 index 000000000..49a8c4b43 --- /dev/null +++ b/dbal/ts/src/core/validation/is-valid-slug.ts @@ -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) +} diff --git a/dbal/ts/src/core/validation/is-valid-title.ts b/dbal/ts/src/core/validation/is-valid-title.ts new file mode 100644 index 000000000..4d680de60 --- /dev/null +++ b/dbal/ts/src/core/validation/is-valid-title.ts @@ -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 +} diff --git a/dbal/ts/src/core/validation/is-valid-username.ts b/dbal/ts/src/core/validation/is-valid-username.ts new file mode 100644 index 000000000..e18e74e71 --- /dev/null +++ b/dbal/ts/src/core/validation/is-valid-username.ts @@ -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) +} diff --git a/dbal/ts/src/core/validation/validate-id.ts b/dbal/ts/src/core/validation/validate-id.ts new file mode 100644 index 000000000..87b2ccc39 --- /dev/null +++ b/dbal/ts/src/core/validation/validate-id.ts @@ -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 +} diff --git a/dbal/ts/src/core/validation/validate-page-create.ts b/dbal/ts/src/core/validation/validate-page-create.ts new file mode 100644 index 000000000..81899b44b --- /dev/null +++ b/dbal/ts/src/core/validation/validate-page-create.ts @@ -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): 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 +} diff --git a/dbal/ts/src/core/validation/validate-page-update.ts b/dbal/ts/src/core/validation/validate-page-update.ts new file mode 100644 index 000000000..a4daef3f3 --- /dev/null +++ b/dbal/ts/src/core/validation/validate-page-update.ts @@ -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): 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 +} diff --git a/dbal/ts/src/core/validation/validate-user-create.ts b/dbal/ts/src/core/validation/validate-user-create.ts new file mode 100644 index 000000000..20e8c813e --- /dev/null +++ b/dbal/ts/src/core/validation/validate-user-create.ts @@ -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): 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 +} diff --git a/dbal/ts/src/core/validation/validate-user-update.ts b/dbal/ts/src/core/validation/validate-user-update.ts new file mode 100644 index 000000000..2b63f15b7 --- /dev/null +++ b/dbal/ts/src/core/validation/validate-user-update.ts @@ -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): 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 +} diff --git a/frontends/nextjs/src/components/CssClassBuilder.tsx b/frontends/nextjs/src/components/CssClassBuilder.tsx index 8640a02cc..94f17363a 100644 --- a/frontends/nextjs/src/components/CssClassBuilder.tsx +++ b/frontends/nextjs/src/components/CssClassBuilder.tsx @@ -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') diff --git a/frontends/nextjs/src/components/CssClassManager.tsx b/frontends/nextjs/src/components/CssClassManager.tsx index b544617b6..da6db320c 100644 --- a/frontends/nextjs/src/components/CssClassManager.tsx +++ b/frontends/nextjs/src/components/CssClassManager.tsx @@ -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() { diff --git a/frontends/nextjs/src/lib/lua/sandboxed-lua-engine.ts b/frontends/nextjs/src/lib/lua/sandboxed-lua-engine.ts index e8ca448ae..737144996 100644 --- a/frontends/nextjs/src/lib/lua/sandboxed-lua-engine.ts +++ b/frontends/nextjs/src/lib/lua/sandboxed-lua-engine.ts @@ -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 { - 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 { - 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' diff --git a/frontends/nextjs/src/lib/rendering/declarative-component-renderer/renderer-state.ts b/frontends/nextjs/src/lib/rendering/declarative-component-renderer/renderer-state.ts index 9917e4ab1..6f23f559d 100644 --- a/frontends/nextjs/src/lib/rendering/declarative-component-renderer/renderer-state.ts +++ b/frontends/nextjs/src/lib/rendering/declarative-component-renderer/renderer-state.ts @@ -1,4 +1,4 @@ -import { LuaEngine } from '../lua-engine' +import { LuaEngine } from '../../lua-engine' import type { DeclarativeComponentConfig, LuaScriptDefinition } from './types' export interface DeclarativeRendererState { diff --git a/frontends/nextjs/src/lib/workflow/create-workflow-engine.ts b/frontends/nextjs/src/lib/workflow/create-workflow-engine.ts index 2ac18bec9..c8d732027 100644 --- a/frontends/nextjs/src/lib/workflow/create-workflow-engine.ts +++ b/frontends/nextjs/src/lib/workflow/create-workflow-engine.ts @@ -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() +} diff --git a/frontends/nextjs/src/lib/workflow/execute-workflow-instance.ts b/frontends/nextjs/src/lib/workflow/execute-workflow-instance.ts index 0616f1e07..e49040e98 100644 --- a/frontends/nextjs/src/lib/workflow/execute-workflow-instance.ts +++ b/frontends/nextjs/src/lib/workflow/execute-workflow-instance.ts @@ -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 => executeWorkflow(workflow, context) +): Promise { + return executeWorkflow(workflow, context) +} diff --git a/frontends/nextjs/src/lib/workflow/workflow-engine.ts b/frontends/nextjs/src/lib/workflow/workflow-engine.ts index 6dd5774a5..96f028557 100644 --- a/frontends/nextjs/src/lib/workflow/workflow-engine.ts +++ b/frontends/nextjs/src/lib/workflow/workflow-engine.ts @@ -10,10 +10,10 @@ export { logToWorkflow, WorkflowEngine, createWorkflowEngine, -} from './workflow' +} from './index' export type { WorkflowExecutionContext, WorkflowExecutionResult, WorkflowState, -} from './workflow' +} from './index' diff --git a/tools/autopusher.sh b/tools/autopusher.sh new file mode 100755 index 000000000..28478d2d6 --- /dev/null +++ b/tools/autopusher.sh @@ -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 +