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:
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>