Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README

commit f4b3d7d66922b7567167e5c48e8b4feb3be87967
parent 59e4c1058b54e63ec778dfcdb844c200b06344a5
Author: arjoonn <arjoonn@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 16:30:00 +0000

Multi crons (!5)

Reviewed-on: https://gitea.midpathsoftware.com/midpath/jayporeci/pulls/5
Co-authored-by: arjoonn <arjoonn@midpathsoftware.com>
Co-committed-by: arjoonn <arjoonn@midpathsoftware.com>

Diffstat:
M.gitignore | 1+
A.jci/crontab | 4++++
M.jci/run.sh | 1+
D.pylintrc | 518-------------------------------------------------------------------------------
MAGENTS.md | 1-
MREADME.md | 102+++++++++++++++++++++++++++++++++----------------------------------------------
Dgit-jci | 0
Ainternal/jci/cron.go | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/jci/cron_parser.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/jci/cron_types.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/jci/web.go | 16++++++++--------
Awww_jci/public/assets/logo.png | 0
Awww_jci/public/index.html | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 663 insertions(+), 587 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -5,3 +5,4 @@ dist/ .hypothesis/ .coverage prof/ +./git-jci diff --git a/.jci/crontab b/.jci/crontab @@ -0,0 +1,4 @@ +# JCI cron schedules +# Midnight CI run and prune +0 0 * * * git jci run +5 0 * * * git jci prune --commit --older-than=14d diff --git a/.jci/run.sh b/.jci/run.sh @@ -1,4 +1,5 @@ #!/bin/bash + # CI script for git-jci # This runs in .jci/<commit>/ directory # Environment variables available: diff --git a/.pylintrc b/.pylintrc @@ -1,518 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Specify a score threshold to be exceeded before program exits with error. -fail-under=10.0 - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=0 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=missing-module-docstring, - missing-function-docstring, - missing-class-docstring, - too-few-public-methods, - too-many-ancestors, - invalid-name, - raising-bad-type, - cyclic-import, - R0801 - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'error', 'warning', 'refactor', and 'convention' -# which contain the number of messages in each category, as well as 'statement' -# which is the total number of statements analyzed. This score is used by the -# global evaluation report (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Bad variable names regexes, separated by a comma. If names match any regex, -# they will always be refused -bad-names-rgxs= - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Good variable names regexes, separated by a comma. If names match any regex, -# they will always be accepted -good-names-rgxs= - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis). It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - -# List of decorators that change the signature of a decorated function. -signature-mutators= - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no - -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )?<?https?://\S+>?$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it work, -# install the python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - -# Regular expression of note tags to take in consideration. -#notes-rgx= - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement (see R0916). -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[IMPORTS] - -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp, - __post_init__ - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=builtins.BaseException, - builtins.Exception diff --git a/AGENTS.md b/AGENTS.md @@ -1 +0,0 @@ -Make sure to use the cli_clean branch for your work. diff --git a/README.md b/README.md @@ -1,4 +1,4 @@ -# git-jci +# Jaypore CI A local-first CI system that stores results in git's custom refs. @@ -11,18 +11,6 @@ go build -o git-jci ./cmd/git-jci sudo mv git-jci /usr/local/bin/ ``` -### From CI artifacts - -If CI has run, you can download the pre-built static binary: - -```bash -# One-liner: download and install from running JCI web server -curl -fsSL http://localhost:8000/jci/$(git rev-parse HEAD)/git-jci -o /tmp/git-jci && sudo install /tmp/git-jci /usr/local/bin/ - -# Or from a specific commit -curl -fsSL http://localhost:8000/jci/<commit>/git-jci -o /tmp/git-jci && sudo install /tmp/git-jci /usr/local/bin/ -``` - The binary is fully static (no dependencies) and works on any Linux system. Once installed, git will automatically find it as a subcommand: @@ -64,55 +52,19 @@ Your `run.sh` script has access to: The script runs with `cwd` set to `JCI_OUTPUT_DIR`. Any files created there become CI artifacts. -## Commands - -### `git jci run` - -Run CI for the current commit: +## Minimal workflow ```bash -git commit -m "My changes" -git jci run -``` - -This will: -1. Execute `.jci/run.sh` -2. Capture stdout/stderr to `run.output.txt` -3. Store all output files (artifacts) in `refs/jci/<commit>` -4. Generate an `index.html` with results - -### `git jci web [port]` - -Start a web server to view CI results. Default port is 8000. - -```bash -git jci web -git jci web 3000 -``` - -### `git jci push [remote]` - -Push CI results to a remote. Default remote is `origin`. - -```bash -git jci push -git jci push upstream -``` - -### `git jci pull [remote]` - -Fetch CI results from a remote. - -```bash -git jci pull -``` - -### `git jci prune` - -Remove CI results for commits that no longer exist in the repository. - -```bash -git jci prune +cd repo-dir && git status # enter the repository and check the working tree +git add -A # stage every modified, deleted, or new file +git commit -m "..." # record the staged changes in a new commit +git jci run # execute .jci/run.sh manually and capture artifacts for this commit. You could also use git hooks to run this automatically on commit. +git jci web # launch the local viewer to inspect the latest CI results +git jci push # push the commit's CI artifacts to the default remote +git jci pull # fetch updated CI artifacts from the remote +git jci prune # delete CI refs for commits that no longer exist locally +git jci cron ls # list cron jobs that are there in .jci/crontab +git jci cron sync # sync local machine's crontab with the current contents of .jci/crontab ``` ## How it works @@ -125,3 +77,33 @@ part of the git repository. - They can be pushed/pulled like any other refs - They are garbage collected when the original commit is gone (via `prune`) - Each commit's CI output is stored as a separate commit object + +## Use cases + +- [ ] Automate unit, integration, and end-to-end test suites on every commit +- [ ] Run linting and static analysis to enforce coding standards +- [ ] Produce code coverage reports and surface regressions +- [ ] Build, package, and archive release artifacts across target platforms +- [ ] Perform dependency and source code security scans (SCA/SAST) +- [ ] Execute performance and regression benchmarks with historical comparisons +- [ ] Generate documentation sites and preview environments for review +- [ ] Validate infrastructure-as-code changes and deployment pipelines via dry runs +- [ ] Schedule recurring workflows (cron-style) for maintenance tasks +- [ ] Notify developers and stakeholders when CI statuses change or regress + + +## Platform features + +- [x] Complex pipeline definitions +- [x] Artifacts +- [x] Debug CI locally +- [ ] Build farms / remote runners on cloud +- [ ] Community / marketplace runners contributed by external teams +- [ ] Shared runner pools across repositories and organizations +- [ ] Deploy keys / scoped access tokens so runners can securely pull & push repos +- [ ] Built-in secrets management with masking, rotation, and per-environment scoping +- [ ] Merge request / PR status reporting, required-check gating, and review UIs +- [ ] Line-by-line coverage overlays and annotations directly on PR/MR diffs +- [ ] Deployment environments with history, approvals, and promotion policies +- [ ] First-class integration with observability / error tracking tools (e.g., Sentry) +- [ ] Ecosystem of reusable actions/tasks with versioned catalogs and templates diff --git a/git-jci b/git-jci Binary files differ. diff --git a/internal/jci/cron.go b/internal/jci/cron.go @@ -0,0 +1,185 @@ +package jci + +import ( + "bufio" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func Cron(args []string) error { + if len(args) == 0 { + return errors.New("usage: git jci cron <ls|sync>") + } + + repoRoot, err := GetRepoRoot() + if err != nil { + return err + } + + switch args[0] { + case "ls", "list": + return cronList(repoRoot) + case "sync": + return cronSync(repoRoot) + default: + return fmt.Errorf("unknown cron subcommand: %s", args[0]) + } +} + +func cronList(repoRoot string) error { + entries, err := LoadCronEntries(repoRoot) + if err != nil { + return err + } + + if len(entries) == 0 { + fmt.Println("No cron jobs defined. Create .jci/crontab to add jobs.") + return nil + } + + fmt.Printf("%-10s %-17s %-10s %s\n", "ID", "SCHEDULE", "TYPE", "COMMAND") + for _, entry := range entries { + job := newCronJob(entry, repoRoot) + fmt.Printf("%-10s %-17s %-10s %s\n", job.ID[:8], job.Schedule, job.Type, job.Command) + if job.Type == CronJobBinary { + if _, err := os.Stat(job.BinaryPath); os.IsNotExist(err) { + fmt.Printf(" warning: binary %s does not exist\n", job.BinaryPath) + } + } + } + return nil +} + +func cronSync(repoRoot string) error { + entries, err := LoadCronEntries(repoRoot) + if err != nil { + return err + } + + if len(entries) == 0 { + return errors.New("no cron jobs defined in .jci/crontab") + } + + var jobs []CronJob + for _, entry := range entries { + jobs = append(jobs, newCronJob(entry, repoRoot)) + } + + block, err := buildCronBlock(repoRoot, jobs) + if err != nil { + return err + } + + existing, err := readCrontab() + if err != nil { + return err + } + + updated := applyCronBlock(existing, block, repoRoot) + if err := installCrontab(updated); err != nil { + return err + } + + fmt.Printf("Synced %d cron job(s).\n", len(jobs)) + return nil +} + +func newCronJob(entry CronEntry, repoRoot string) CronJob { + jobType, binPath, binArgs := classifyCronCommand(entry.Command, repoRoot) + id := cronJobID(entry.Schedule, entry.Command) + logPath := filepath.Join(repoRoot, ".jci", fmt.Sprintf("cron-%s.log", id[:8])) + return CronJob{ + ID: id, + Schedule: entry.Schedule, + Command: entry.Command, + Type: jobType, + BinaryPath: binPath, + BinaryArgs: binArgs, + Line: entry.Line, + CronLog: logPath, + } +} + +func buildCronBlock(repoRoot string, jobs []CronJob) (string, error) { + if err := os.MkdirAll(filepath.Join(repoRoot, ".jci"), 0755); err != nil { + return "", fmt.Errorf("failed to create .jci directory: %w", err) + } + + blockID := cronBlockMarker(repoRoot) + var lines []string + lines = append(lines, fmt.Sprintf("# BEGIN %s", blockID)) + + for _, job := range jobs { + line := fmt.Sprintf("%s %s", job.Schedule, job.shellCommand(repoRoot)) + lines = append(lines, line) + } + + lines = append(lines, fmt.Sprintf("# END %s", blockID)) + return strings.Join(lines, "\n"), nil +} + +func cronBlockMarker(repoRoot string) string { + hash := sha1.Sum([]byte(repoRoot)) + return fmt.Sprintf("git-jci %s %s", repoRoot, hex.EncodeToString(hash[:8])) +} + +func readCrontab() (string, error) { + if _, err := exec.LookPath("crontab"); err != nil { + return "", fmt.Errorf("crontab command not found: %w", err) + } + + cmd := exec.Command("crontab", "-l") + out, err := cmd.CombinedOutput() + if err != nil { + if strings.Contains(string(out), "no crontab for") { + return "", nil + } + return "", fmt.Errorf("crontab -l: %v", err) + } + return string(out), nil +} + +func applyCronBlock(existing, block, repoRoot string) string { + begin := fmt.Sprintf("# BEGIN %s", cronBlockMarker(repoRoot)) + end := fmt.Sprintf("# END %s", cronBlockMarker(repoRoot)) + + var result []string + scanner := bufio.NewScanner(strings.NewReader(existing)) + skip := false + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == begin { + skip = true + continue + } + if trimmed == end { + skip = false + continue + } + if !skip { + result = append(result, line) + } + } + + if len(result) != 0 && strings.TrimSpace(result[len(result)-1]) != "" { + result = append(result, "") + } + result = append(result, block) + return strings.Join(result, "\n") + "\n" +} + +func installCrontab(content string) error { + cmd := exec.Command("crontab", "-") + cmd.Stdin = strings.NewReader(content) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to install crontab: %v (%s)", err, string(out)) + } + return nil +} diff --git a/internal/jci/cron_parser.go b/internal/jci/cron_parser.go @@ -0,0 +1,86 @@ +package jci + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// CronEntry represents a single line from .jci/crontab +// It contains the parsed schedule as well as the command portion. +type CronEntry struct { + Line int + Schedule string + Command string + Raw string +} + +// LoadCronEntries opens .jci/crontab (if it exists) and parses all entries. +func LoadCronEntries(repoRoot string) ([]CronEntry, error) { + cronPath := filepath.Join(repoRoot, ".jci", "crontab") + f, err := os.Open(cronPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + + return parseCronEntries(f) +} + +func parseCronEntries(r io.Reader) ([]CronEntry, error) { + scanner := bufio.NewScanner(r) + var entries []CronEntry + lineNum := 0 + + for scanner.Scan() { + lineNum++ + raw := strings.TrimSpace(scanner.Text()) + if raw == "" || strings.HasPrefix(raw, "#") { + continue + } + + schedule, command, err := splitCronLine(raw) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNum, err) + } + + entries = append(entries, CronEntry{ + Line: lineNum, + Schedule: schedule, + Command: command, + Raw: raw, + }) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return entries, nil +} + +func splitCronLine(raw string) (string, string, error) { + if strings.HasPrefix(raw, "@") { + parts := strings.Fields(raw) + if len(parts) < 2 { + return "", "", errors.New("missing command for cron entry") + } + return parts[0], strings.Join(parts[1:], " "), nil + } + + parts := strings.Fields(raw) + if len(parts) < 6 { + return "", "", errors.New("cron entry must have 5 time fields and a command") + } + + schedule := strings.Join(parts[:5], " ") + command := strings.Join(parts[5:], " ") + return schedule, command, nil +} diff --git a/internal/jci/cron_types.go b/internal/jci/cron_types.go @@ -0,0 +1,94 @@ +package jci + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "path/filepath" + "strings" +) + +type CronJobType string + +const ( + CronJobGitJCI CronJobType = "git-jci" + CronJobBinary CronJobType = "binary" + CronJobShell CronJobType = "shell" +) + +type CronJob struct { + ID string + Schedule string + Command string + Type CronJobType + BinaryPath string + BinaryArgs string + Line int + CronLog string +} + +func classifyCronCommand(command string, repoRoot string) (CronJobType, string, string) { + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return CronJobShell, "", "" + } + + if strings.HasPrefix(trimmed, "git jci") { + return CronJobGitJCI, "", "" + } + + head, tail := splitCommandHeadTail(trimmed) + cleaned := strings.TrimPrefix(head, "./") + if strings.HasPrefix(cleaned, ".jci/") { + abs := filepath.Join(repoRoot, cleaned) + return CronJobBinary, abs, tail + } + + if !strings.Contains(head, "/") { + candidate := filepath.Join(repoRoot, ".jci", head) + return CronJobBinary, candidate, tail + } + + return CronJobShell, trimmed, tail +} + +func cronJobID(schedule, command string) string { + sum := sha1.Sum([]byte(schedule + "\x00" + command)) + return hex.EncodeToString(sum[:]) +} + +func (job CronJob) shellCommand(repoRoot string) string { + var command string + switch job.Type { + case CronJobBinary: + command = shellEscape(job.BinaryPath) + if job.BinaryArgs != "" { + command += " " + job.BinaryArgs + } + default: + command = job.Command + } + + full := fmt.Sprintf("cd %s && %s", shellEscape(repoRoot), command) + if job.CronLog != "" { + full = fmt.Sprintf("%s >> %s 2>&1", full, shellEscape(job.CronLog)) + } + return full +} + +func shellEscape(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func splitCommandHeadTail(cmd string) (string, string) { + parts := strings.Fields(cmd) + if len(parts) == 0 { + return "", "" + } + head := parts[0] + tail := strings.Join(parts[1:], " ") + return head, tail +} diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -19,12 +19,12 @@ type BranchInfo struct { // CommitInfo holds commit data for the UI type CommitInfo struct { - Hash string `json:"hash"` - ShortHash string `json:"shortHash"` - Message string `json:"message"` - HasCI bool `json:"hasCI"` - CIStatus string `json:"ciStatus"` // "success", "failed", or "" - CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote + Hash string `json:"hash"` + ShortHash string `json:"shortHash"` + Message string `json:"message"` + HasCI bool `json:"hasCI"` + CIStatus string `json:"ciStatus"` // "success", "failed", or "" + CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote } // Web starts a web server to view CI results @@ -105,7 +105,7 @@ func getLocalBranches() ([]string, error) { // getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote func getRemoteJCIRefs(remote string) map[string]bool { remoteCI := make(map[string]bool) - + // Get remote JCI refs out, err := git("ls-remote", "--refs", remote, "refs/jci/*") if err != nil { @@ -114,7 +114,7 @@ func getRemoteJCIRefs(remote string) map[string]bool { if out == "" { return remoteCI } - + for _, line := range strings.Split(out, "\n") { parts := strings.Fields(line) if len(parts) >= 2 { diff --git a/www_jci/public/assets/logo.png b/www_jci/public/assets/logo.png Binary files differ. diff --git a/www_jci/public/index.html b/www_jci/public/index.html @@ -0,0 +1,242 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>JCI Project Overview</title> + <style> + :root { + color-scheme: light dark; + --bg: #f8f9fb; + --surface: #ffffff; + --text: #111217; + --muted: #5b5f6d; + --border: rgba(15, 19, 39, 0.08); + --accent: #111827; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, + BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + + header { + padding: 2rem clamp(1.5rem, 5vw, 5rem) 0; + } + + nav { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4rem; + font-size: 0.92rem; + } + + nav .brand { + font-weight: 600; + letter-spacing: 0.02em; + } + + nav ul { + list-style: none; + display: flex; + gap: 1.5rem; + margin: 0; + padding: 0; + } + + nav a { + text-decoration: none; + color: var(--muted); + transition: color 0.2s ease; + } + + nav a:hover, + nav a:focus-visible { + color: var(--text); + } + + .hero { + max-width: 720px; + } + + .hero h1 { + font-size: clamp(2.5rem, 4vw, 3.6rem); + line-height: 1.1; + margin-bottom: 1rem; + } + + .hero p { + font-size: 1.08rem; + color: var(--muted); + margin-bottom: 2.5rem; + } + + .cta-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + } + + .cta-primary { + padding: 0.95rem 1.75rem; + border-radius: 999px; + border: none; + cursor: pointer; + background: var(--accent); + color: white; + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.01em; + } + + .cta-note { + font-size: 0.9rem; + color: var(--muted); + } + + section { + padding: 4rem clamp(1.5rem, 5vw, 5rem); + } + + .grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } + + .card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 1.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 220px; + } + + .card h3 { + margin: 0; + font-size: 1.2rem; + } + + .card p { + margin: 0; + color: var(--muted); + } + + .meta { + padding-top: 0; + } + + .meta-list { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin: 2rem 0 0; + padding: 0; + list-style: none; + color: var(--muted); + font-size: 0.95rem; + } + + footer { + padding: 2rem clamp(1.5rem, 5vw, 5rem) 3rem; + color: var(--muted); + font-size: 0.88rem; + } + + @media (max-width: 600px) { + nav { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .card { + min-height: auto; + } + } + </style> + </head> + <body> + <header> + <nav> + <div class="brand">JCI</div> + <ul> + <li><a href="#source">Source code</a></li> + <li><a href="#docs">Docs</a></li> + </ul> + </nav> + <div class="hero"> + <h1>Jaypore CI</h1> + <p> + Minimal. Offline. Local. + </p> + <div class="cta-row"> + <button class="cta-primary" aria-label="Buy this version for INR 100"> + Buy for $5 + </button> + <span class="cta-note">Razorpay integration placeholder</span> + </div> + </div> + </header> + + <section id="source"> + <div class="grid"> + <article class="card"> + <h3>Source code</h3> + <p> + Explore a modular Go + Node toolchain designed for command-line and + web surfaces. Every component is documented and version-controlled to + make auditing and contributions straightforward. + </p> + <p> + <strong>Highlights:</strong><br /> + Clear folder layout, reproducible builds, thoughtful CLI ergonomics. + </p> + </article> + <article class="card" id="docs"> + <h3>Docs</h3> + <p> + Lightweight documentation outlines project concepts, build targets, + and extension hooks. Follow concise guides inspired by UV and Exe to + move from setup to contribution in minutes. + </p> + <p> + <strong>Includes:</strong><br /> + Architecture notes, extension patterns, release checklists. + </p> + </article> + </div> + </section> + + <section class="meta"> + <h2>Project focus</h2> + <ul class="meta-list"> + <li>Minimal surface area, maximum clarity.</li> + <li>Composable CLI experiences with clean UX.</li> + <li>Self-hosted assets ready for static deployment.</li> + </ul> + </section> + + <footer> + © <span id="year"></span> JCI. Built for teams who prefer fewer tabs and + faster ships. + </footer> + <script> + document.getElementById("year").textContent = new Date().getFullYear(); + </script> + </body> +</html>