keystone-cli 1.1.1 → 1.2.0
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.
- package/README.md +46 -20
- package/package.json +1 -1
- package/src/commands/init.ts +2 -0
- package/src/parser/schema.ts +51 -17
- package/src/runner/executors/file-executor.test.ts +7 -5
- package/src/runner/executors/file-executor.ts +2 -2
- package/src/runner/executors/git-executor.test.ts +278 -0
- package/src/runner/executors/git-executor.ts +100 -0
- package/src/runner/executors/security.test.ts +69 -0
- package/src/runner/executors/shell-executor.ts +30 -5
- package/src/runner/memoization-leak.test.ts +83 -0
- package/src/runner/recovery-security.test.ts +132 -0
- package/src/runner/services/context-builder.ts +110 -7
- package/src/runner/services/secret-manager.ts +12 -6
- package/src/runner/step-executor.ts +4 -0
- package/src/runner/workflow-runner.ts +20 -182
- package/src/templates/basics/git-worktree.yaml +25 -0
- package/src/utils/env-constants.ts +19 -0
package/README.md
CHANGED
|
@@ -134,22 +134,23 @@ keystone ui
|
|
|
134
134
|
|
|
135
135
|
`keystone init` seeds these workflows under `.keystone/workflows/` (and the agents they rely on under `.keystone/workflows/agents/`):
|
|
136
136
|
|
|
137
|
-
Top-level workflows:
|
|
138
|
-
- `scaffold-feature`: Interactive workflow scaffolder. Prompts for requirements, plans files, generates content, and writes them.
|
|
139
|
-
- `decompose-problem`: Decomposes a problem into research/implementation/review tasks, waits for approval, runs sub-workflows, and summarizes.
|
|
140
|
-
- `dev`: Self-bootstrapping DevMode workflow for an interactive plan/implement/verify loop.
|
|
141
|
-
- `agent-handoff`: Demonstrates agent handoffs and tool-driven context updates.
|
|
142
|
-
- `
|
|
143
|
-
- `
|
|
144
|
-
- `
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
-
|
|
148
|
-
- `scaffold-
|
|
149
|
-
- `
|
|
150
|
-
- `decompose-
|
|
151
|
-
- `decompose-
|
|
152
|
-
- `review
|
|
137
|
+
Top-level workflows (seeded in `.keystone/workflows/`):
|
|
138
|
+
- `scaffold-feature.yaml`: Interactive workflow scaffolder. Prompts for requirements, plans files, generates content, and writes them.
|
|
139
|
+
- `decompose-problem.yaml`: Decomposes a problem into research/implementation/review tasks, waits for approval, runs sub-workflows, and summarizes.
|
|
140
|
+
- `dev.yaml`: Self-bootstrapping DevMode workflow for an interactive plan/implement/verify loop.
|
|
141
|
+
- `agent-handoff.yaml`: Demonstrates agent handoffs and tool-driven context updates.
|
|
142
|
+
- `full-feature-demo.yaml`: A comprehensive workflow demonstrating multiple step types (shell, file, request, etc.).
|
|
143
|
+
- `script-example.yaml`: Demonstrates sandboxed JavaScript execution.
|
|
144
|
+
- `artifact-example.yaml`: Demonstrates artifact upload and download between steps.
|
|
145
|
+
- `idempotency-example.yaml`: Demonstrates safe retries for side-effecting steps.
|
|
146
|
+
|
|
147
|
+
Sub-workflows (seeded in `.keystone/workflows/`):
|
|
148
|
+
- `scaffold-plan.yaml`: Generates a file plan from `requirements` input.
|
|
149
|
+
- `scaffold-generate.yaml`: Generates file contents from `requirements` plus a `files` plan.
|
|
150
|
+
- `decompose-research.yaml`: Runs a single research task (`task`) with optional `context`/`constraints`.
|
|
151
|
+
- `decompose-implement.yaml`: Runs a single implementation task (`task`) with optional `research` findings.
|
|
152
|
+
- `decompose-review.yaml`: Reviews a single implementation task (`task`) with optional `implementation` results.
|
|
153
|
+
- `review-loop.yaml`: Reusable generate → critique → refine loop with a quality gate.
|
|
153
154
|
|
|
154
155
|
Example runs:
|
|
155
156
|
```bash
|
|
@@ -198,7 +199,7 @@ providers:
|
|
|
198
199
|
google-gemini:
|
|
199
200
|
type: google-gemini
|
|
200
201
|
base_url: https://cloudcode-pa.googleapis.com
|
|
201
|
-
default_model: gemini-
|
|
202
|
+
default_model: gemini-1.5-pro
|
|
202
203
|
groq:
|
|
203
204
|
type: openai
|
|
204
205
|
base_url: https://api.groq.com/openai/v1
|
|
@@ -481,7 +482,8 @@ Keystone supports several specialized step types:
|
|
|
481
482
|
```yaml
|
|
482
483
|
outputMapping:
|
|
483
484
|
final_result: result_from_subflow
|
|
484
|
-
|
|
485
|
+
# 'from' can be used for explicit mapping or expression
|
|
486
|
+
# status: { from: "steps.some_step.status" }
|
|
485
487
|
```
|
|
486
488
|
- `join`: Aggregate outputs from dependencies and enforce a completion condition.
|
|
487
489
|
- `condition`: `'all'` (default), `'any'`, or a number.
|
|
@@ -499,6 +501,7 @@ Keystone supports several specialized step types:
|
|
|
499
501
|
- `op: store`: Store text with metadata.
|
|
500
502
|
- `op: search`: Search for similar text using vector embeddings.
|
|
501
503
|
- `text` / `query`: The content to store or search for.
|
|
504
|
+
- `model`: Optional embedding model (defaults to `local`). Currently only local embeddings (via `Transformers.js`) are supported.
|
|
502
505
|
- `metadata`: Optional object for filtering or additional context.
|
|
503
506
|
- `limit`: Number of results to return (default `5`).
|
|
504
507
|
```yaml
|
|
@@ -551,8 +554,8 @@ All steps support common features:
|
|
|
551
554
|
- `retry`: `{ count, backoff: 'linear'|'exponential', baseDelay }`.
|
|
552
555
|
- `timeout`: Maximum execution time in milliseconds (best-effort; supported steps receive an abort signal).
|
|
553
556
|
- `foreach`: Iterate over an array in parallel.
|
|
554
|
-
- `concurrency`: Limit parallel items for `foreach` (must be a positive integer).
|
|
555
|
-
- `strategy.matrix`:
|
|
557
|
+
- `concurrency`: Limit parallel items for `foreach` (must be a positive integer). Defaults to `50`.
|
|
558
|
+
- `strategy.matrix`: Multi-axis expansion into `foreach` at parse-time.
|
|
556
559
|
- `pool`: Assign step to a resource pool.
|
|
557
560
|
- `breakpoint`: Pause before executing the step when running with `--debug`.
|
|
558
561
|
- `compensate`: Step to run if the workflow rolls back.
|
|
@@ -806,6 +809,24 @@ Upload and download files between steps without hardcoded artifact paths.
|
|
|
806
809
|
|
|
807
810
|
Upload outputs include `artifactPath` and `files` for downstream references.
|
|
808
811
|
|
|
812
|
+
- `git`: Perform git operations (clone, worktree, checkout, pull, push, commit).
|
|
813
|
+
- `op`: Required operation (`clone`, `worktree_add`, `worktree_remove`, `checkout`, `pull`, `push`, `commit`).
|
|
814
|
+
- `path`: Local path for clone or worktree.
|
|
815
|
+
- `url`: Repository URL for clone.
|
|
816
|
+
- `branch`: Branch name for clone, checkout, push, pull, or worktree.
|
|
817
|
+
- `message`: Commit message.
|
|
818
|
+
- `cwd`: Directory to run the git command in.
|
|
819
|
+
- `allowOutsideCwd`: Boolean (default `false`). Set `true` to allow operations outside the project root.
|
|
820
|
+
- `allowInsecure`: Boolean (default `false`). Set `true` to allow git commands that fail the security whitelist.
|
|
821
|
+
|
|
822
|
+
```yaml
|
|
823
|
+
- id: setup_feat
|
|
824
|
+
type: git
|
|
825
|
+
op: worktree_add
|
|
826
|
+
path: ../feat-branch
|
|
827
|
+
branch: feature/x
|
|
828
|
+
```
|
|
829
|
+
|
|
809
830
|
### Structured Events
|
|
810
831
|
|
|
811
832
|
Emit NDJSON events for step and workflow lifecycle updates:
|
|
@@ -1224,6 +1245,11 @@ graph TD
|
|
|
1224
1245
|
EX --> Script[Script Step]
|
|
1225
1246
|
EX --> Sleep[Sleep Step]
|
|
1226
1247
|
EX --> Memory[Memory operations]
|
|
1248
|
+
EX --> Artifact[Artifact operations]
|
|
1249
|
+
EX --> Git[Git operations]
|
|
1250
|
+
EX --> Wait[Wait Step]
|
|
1251
|
+
EX --> Join[Join Step]
|
|
1252
|
+
EX --> Blueprint[Blueprint Step]
|
|
1227
1253
|
|
|
1228
1254
|
LLM --> Adapters[LLM Adapters]
|
|
1229
1255
|
Adapters --> Providers[OpenAI, Anthropic, Gemini, Copilot, etc.]
|
package/package.json
CHANGED
package/src/commands/init.ts
CHANGED
|
@@ -17,6 +17,7 @@ import architectAgent from '../templates/agents/keystone-architect.md' with { ty
|
|
|
17
17
|
import softwareEngineerAgent from '../templates/agents/software-engineer.md' with { type: 'text' };
|
|
18
18
|
import summarizerAgent from '../templates/agents/summarizer.md' with { type: 'text' };
|
|
19
19
|
import testerAgent from '../templates/agents/tester.md' with { type: 'text' };
|
|
20
|
+
import fullFeatureDemo from '../templates/basics/full-feature-demo.yaml' with { type: 'text' };
|
|
20
21
|
import idempotencyExample from '../templates/control-flow/idempotency-example.yaml' with {
|
|
21
22
|
type: 'text',
|
|
22
23
|
};
|
|
@@ -118,6 +119,7 @@ const SEEDS = [
|
|
|
118
119
|
{ path: '.keystone/workflows/script-example.yaml', content: scriptExample },
|
|
119
120
|
{ path: '.keystone/workflows/artifact-example.yaml', content: artifactExample },
|
|
120
121
|
{ path: '.keystone/workflows/idempotency-example.yaml', content: idempotencyExample },
|
|
122
|
+
{ path: '.keystone/workflows/full-feature-demo.yaml', content: fullFeatureDemo },
|
|
121
123
|
];
|
|
122
124
|
|
|
123
125
|
export function registerInitCommand(program: Command): void {
|
package/src/parser/schema.ts
CHANGED
|
@@ -395,6 +395,19 @@ const ArtifactStepSchema = BaseStepSchema.extend({
|
|
|
395
395
|
allowOutsideCwd: z.boolean().optional(),
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
+
const GitStepSchema = BaseStepSchema.extend({
|
|
399
|
+
type: z.literal('git'),
|
|
400
|
+
op: z.enum(['clone', 'worktree_add', 'worktree_remove', 'checkout', 'pull', 'push', 'commit']),
|
|
401
|
+
path: z.string().optional(), // Local path for clone or worktree
|
|
402
|
+
url: z.string().optional(), // Repo URL for clone
|
|
403
|
+
branch: z.string().optional(),
|
|
404
|
+
message: z.string().optional(), // For commit
|
|
405
|
+
cwd: z.string().optional(), // Working directory for the git command
|
|
406
|
+
env: z.record(z.string()).optional(),
|
|
407
|
+
allowOutsideCwd: z.boolean().optional(),
|
|
408
|
+
allowInsecure: z.boolean().optional(),
|
|
409
|
+
});
|
|
410
|
+
|
|
398
411
|
const WaitStepSchema = BaseStepSchema.extend({
|
|
399
412
|
type: z.literal('wait'),
|
|
400
413
|
event: z.string(),
|
|
@@ -404,23 +417,24 @@ const WaitStepSchema = BaseStepSchema.extend({
|
|
|
404
417
|
|
|
405
418
|
// ===== Discriminated Union for Steps =====
|
|
406
419
|
|
|
407
|
-
export const StepSchema: z.ZodType<
|
|
420
|
+
export const StepSchema: z.ZodType<any> = z.lazy(() =>
|
|
408
421
|
z.discriminatedUnion('type', [
|
|
409
|
-
ShellStepSchema,
|
|
410
|
-
LlmStepSchema,
|
|
411
|
-
PlanStepSchema,
|
|
412
|
-
WorkflowStepSchema,
|
|
413
|
-
FileStepSchema,
|
|
414
|
-
RequestStepSchema,
|
|
415
|
-
HumanStepSchema,
|
|
416
|
-
SleepStepSchema,
|
|
417
|
-
ScriptStepSchema,
|
|
418
|
-
EngineStepSchema,
|
|
419
|
-
MemoryStepSchema,
|
|
420
|
-
JoinStepSchema,
|
|
421
|
-
BlueprintStepSchema,
|
|
422
|
-
ArtifactStepSchema,
|
|
423
|
-
WaitStepSchema,
|
|
422
|
+
ShellStepSchema as any,
|
|
423
|
+
LlmStepSchema as any,
|
|
424
|
+
PlanStepSchema as any,
|
|
425
|
+
WorkflowStepSchema as any,
|
|
426
|
+
FileStepSchema as any,
|
|
427
|
+
RequestStepSchema as any,
|
|
428
|
+
HumanStepSchema as any,
|
|
429
|
+
SleepStepSchema as any,
|
|
430
|
+
ScriptStepSchema as any,
|
|
431
|
+
EngineStepSchema as any,
|
|
432
|
+
MemoryStepSchema as any,
|
|
433
|
+
JoinStepSchema as any,
|
|
434
|
+
BlueprintStepSchema as any,
|
|
435
|
+
ArtifactStepSchema as any,
|
|
436
|
+
WaitStepSchema as any,
|
|
437
|
+
GitStepSchema as any,
|
|
424
438
|
])
|
|
425
439
|
);
|
|
426
440
|
|
|
@@ -487,7 +501,25 @@ export const AgentSchema = z.object({
|
|
|
487
501
|
|
|
488
502
|
export type WorkflowInput = z.infer<typeof InputSchema>;
|
|
489
503
|
export type RetryConfig = z.infer<typeof RetrySchema>;
|
|
490
|
-
|
|
504
|
+
|
|
505
|
+
export type Step =
|
|
506
|
+
| z.infer<typeof ShellStepSchema>
|
|
507
|
+
| z.infer<typeof LlmStepSchema>
|
|
508
|
+
| z.infer<typeof PlanStepSchema>
|
|
509
|
+
| z.infer<typeof WorkflowStepSchema>
|
|
510
|
+
| z.infer<typeof FileStepSchema>
|
|
511
|
+
| z.infer<typeof RequestStepSchema>
|
|
512
|
+
| z.infer<typeof HumanStepSchema>
|
|
513
|
+
| z.infer<typeof SleepStepSchema>
|
|
514
|
+
| z.infer<typeof ScriptStepSchema>
|
|
515
|
+
| z.infer<typeof EngineStepSchema>
|
|
516
|
+
| z.infer<typeof MemoryStepSchema>
|
|
517
|
+
| z.infer<typeof JoinStepSchema>
|
|
518
|
+
| z.infer<typeof BlueprintStepSchema>
|
|
519
|
+
| z.infer<typeof ArtifactStepSchema>
|
|
520
|
+
| z.infer<typeof WaitStepSchema>
|
|
521
|
+
| z.infer<typeof GitStepSchema>;
|
|
522
|
+
|
|
491
523
|
export type ShellStep = z.infer<typeof ShellStepSchema>;
|
|
492
524
|
export type LlmStep = z.infer<typeof LlmStepSchema>;
|
|
493
525
|
export type PlanStep = z.infer<typeof PlanStepSchema>;
|
|
@@ -502,6 +534,7 @@ export type EngineStep = z.infer<typeof EngineStepSchema>;
|
|
|
502
534
|
export type JoinStep = z.infer<typeof JoinStepSchema>;
|
|
503
535
|
export type BlueprintStep = z.infer<typeof BlueprintStepSchema>;
|
|
504
536
|
export type ArtifactStep = z.infer<typeof ArtifactStepSchema>;
|
|
537
|
+
export type GitStep = z.infer<typeof GitStepSchema>;
|
|
505
538
|
export type Blueprint = z.infer<typeof BlueprintSchema>;
|
|
506
539
|
export type Workflow = z.infer<typeof WorkflowSchema>;
|
|
507
540
|
export type AgentTool = z.infer<typeof AgentToolSchema>;
|
|
@@ -531,5 +564,6 @@ export {
|
|
|
531
564
|
BlueprintStepSchema,
|
|
532
565
|
MemoryStepSchema,
|
|
533
566
|
ArtifactStepSchema,
|
|
567
|
+
GitStepSchema,
|
|
534
568
|
};
|
|
535
569
|
export type Agent = z.infer<typeof AgentSchema>;
|
|
@@ -36,13 +36,15 @@ describe('file-executor', () => {
|
|
|
36
36
|
const blocks: SearchReplaceBlock[] = [
|
|
37
37
|
{ search: ' indent', replace: 'dedent' }, // one space vs two
|
|
38
38
|
];
|
|
39
|
-
// Should not find ' indent' because actual is ' indent' (double space) if strict?
|
|
40
|
-
// Actually ' indent'.split(' indent') -> [' ', ''] -> found once!
|
|
41
|
-
// Wait ' indent'.includes(' indent') is true.
|
|
42
|
-
// ' indent'.split(' indent') -> [' ', ''] (length 2).
|
|
43
|
-
// So it works.
|
|
44
39
|
const result = applySearchReplaceBlocks(content, blocks);
|
|
45
40
|
expect(result).toBe(' dedent');
|
|
46
41
|
});
|
|
42
|
+
|
|
43
|
+
it('should handle CRLF line endings by treating them as LF', () => {
|
|
44
|
+
const content = 'line1\r\nline2\r\nline3';
|
|
45
|
+
const blocks: SearchReplaceBlock[] = [{ search: 'line2', replace: 'modified' }];
|
|
46
|
+
const result = applySearchReplaceBlocks(content, blocks);
|
|
47
|
+
expect(result).toBe('line1\nmodified\nline3');
|
|
48
|
+
});
|
|
47
49
|
});
|
|
48
50
|
});
|
|
@@ -116,7 +116,7 @@ export function applyUnifiedDiff(content: string, patch: string, targetPath: str
|
|
|
116
116
|
const diff = parseUnifiedDiff(patch);
|
|
117
117
|
assertDiffMatchesTarget(diff.newFile || diff.originalFile, targetPath);
|
|
118
118
|
|
|
119
|
-
const lines = content.split('\n');
|
|
119
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
120
120
|
const resultLines = [...lines];
|
|
121
121
|
|
|
122
122
|
// Apply hunks in reverse order to keep line numbers valid
|
|
@@ -188,7 +188,7 @@ export function parseSearchReplaceBlocks(patch: string): SearchReplaceBlock[] {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
export function applySearchReplaceBlocks(content: string, blocks: SearchReplaceBlock[]): string {
|
|
191
|
-
let result = content;
|
|
191
|
+
let result = content.replace(/\r\n/g, '\n');
|
|
192
192
|
|
|
193
193
|
for (const block of blocks) {
|
|
194
194
|
const parts = result.split(block.search);
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from 'bun:test';
|
|
2
|
+
import type { GitStep } from '../../parser/schema.ts';
|
|
3
|
+
import { ConsoleLogger } from '../../utils/logger.ts';
|
|
4
|
+
import { executeGitStep } from './git-executor.ts';
|
|
5
|
+
import * as shellExecutor from './shell-executor.ts';
|
|
6
|
+
|
|
7
|
+
describe('git-executor', () => {
|
|
8
|
+
const logger = new ConsoleLogger();
|
|
9
|
+
const context = {
|
|
10
|
+
env: {},
|
|
11
|
+
inputs: {},
|
|
12
|
+
steps: {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let executeShellSpy: any;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
executeShellSpy = spyOn(shellExecutor, 'executeShell').mockResolvedValue({
|
|
19
|
+
stdout: 'git output',
|
|
20
|
+
stderr: '',
|
|
21
|
+
exitCode: 0,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
executeShellSpy.mockRestore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should execute clone operation', async () => {
|
|
30
|
+
const step: GitStep = {
|
|
31
|
+
id: 'git-clone',
|
|
32
|
+
type: 'git',
|
|
33
|
+
op: 'clone',
|
|
34
|
+
url: 'https://github.com/mhingston/keystone-cli.git',
|
|
35
|
+
needs: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const result = await executeGitStep(step, context, logger);
|
|
39
|
+
expect(result.status).toBe('success');
|
|
40
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
run: 'git clone https://github.com/mhingston/keystone-cli.git ',
|
|
43
|
+
}),
|
|
44
|
+
context,
|
|
45
|
+
logger,
|
|
46
|
+
undefined,
|
|
47
|
+
'git clone https://github.com/mhingston/keystone-cli.git '
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should execute clone with branch', async () => {
|
|
52
|
+
const step: GitStep = {
|
|
53
|
+
id: 'git-clone-branch',
|
|
54
|
+
type: 'git',
|
|
55
|
+
op: 'clone',
|
|
56
|
+
url: 'https://github.com/mhingston/keystone-cli.git',
|
|
57
|
+
branch: 'main',
|
|
58
|
+
needs: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await executeGitStep(step, context, logger);
|
|
62
|
+
expect(result.status).toBe('success');
|
|
63
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
run: 'git clone -b main https://github.com/mhingston/keystone-cli.git ',
|
|
66
|
+
}),
|
|
67
|
+
context,
|
|
68
|
+
logger,
|
|
69
|
+
undefined,
|
|
70
|
+
'git clone -b main https://github.com/mhingston/keystone-cli.git '
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should execute worktree_add operation', async () => {
|
|
75
|
+
const step: GitStep = {
|
|
76
|
+
id: 'git-worktree-add',
|
|
77
|
+
type: 'git',
|
|
78
|
+
op: 'worktree_add',
|
|
79
|
+
path: './tmp-worktree',
|
|
80
|
+
branch: 'feature-branch',
|
|
81
|
+
needs: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const result = await executeGitStep(step, context, logger);
|
|
85
|
+
expect(result.status).toBe('success');
|
|
86
|
+
expect((result.output as any).worktreePath).toBe('./tmp-worktree');
|
|
87
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
88
|
+
expect.objectContaining({
|
|
89
|
+
run: 'git worktree add ./tmp-worktree feature-branch',
|
|
90
|
+
}),
|
|
91
|
+
context,
|
|
92
|
+
logger,
|
|
93
|
+
undefined,
|
|
94
|
+
'git worktree add ./tmp-worktree feature-branch'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should execute worktree_remove operation', async () => {
|
|
99
|
+
const step: GitStep = {
|
|
100
|
+
id: 'git-worktree-remove',
|
|
101
|
+
type: 'git',
|
|
102
|
+
op: 'worktree_remove',
|
|
103
|
+
path: './tmp-worktree',
|
|
104
|
+
needs: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const result = await executeGitStep(step, context, logger);
|
|
108
|
+
expect(result.status).toBe('success');
|
|
109
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
run: 'git worktree remove ./tmp-worktree',
|
|
112
|
+
}),
|
|
113
|
+
context,
|
|
114
|
+
logger,
|
|
115
|
+
undefined,
|
|
116
|
+
'git worktree remove ./tmp-worktree'
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should execute checkout operation', async () => {
|
|
121
|
+
const step: GitStep = {
|
|
122
|
+
id: 'git-checkout',
|
|
123
|
+
type: 'git',
|
|
124
|
+
op: 'checkout',
|
|
125
|
+
branch: 'main',
|
|
126
|
+
needs: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = await executeGitStep(step, context, logger);
|
|
130
|
+
expect(result.status).toBe('success');
|
|
131
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
run: 'git checkout main',
|
|
134
|
+
}),
|
|
135
|
+
context,
|
|
136
|
+
logger,
|
|
137
|
+
undefined,
|
|
138
|
+
'git checkout main'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should execute pull operation', async () => {
|
|
143
|
+
const step: GitStep = {
|
|
144
|
+
id: 'git-pull',
|
|
145
|
+
type: 'git',
|
|
146
|
+
op: 'pull',
|
|
147
|
+
branch: 'main',
|
|
148
|
+
needs: [],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const result = await executeGitStep(step, context, logger);
|
|
152
|
+
expect(result.status).toBe('success');
|
|
153
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
run: 'git pull origin main',
|
|
156
|
+
}),
|
|
157
|
+
context,
|
|
158
|
+
logger,
|
|
159
|
+
undefined,
|
|
160
|
+
'git pull origin main'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should execute push operation', async () => {
|
|
165
|
+
const step: GitStep = {
|
|
166
|
+
id: 'git-push',
|
|
167
|
+
type: 'git',
|
|
168
|
+
op: 'push',
|
|
169
|
+
branch: 'main',
|
|
170
|
+
needs: [],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const result = await executeGitStep(step, context, logger);
|
|
174
|
+
expect(result.status).toBe('success');
|
|
175
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
run: 'git push origin main',
|
|
178
|
+
}),
|
|
179
|
+
context,
|
|
180
|
+
logger,
|
|
181
|
+
undefined,
|
|
182
|
+
'git push origin main'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should execute commit operation', async () => {
|
|
187
|
+
const step: GitStep = {
|
|
188
|
+
id: 'git-commit',
|
|
189
|
+
type: 'git',
|
|
190
|
+
op: 'commit',
|
|
191
|
+
message: 'test commit',
|
|
192
|
+
needs: [],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const result = await executeGitStep(step, context, logger);
|
|
196
|
+
expect(result.status).toBe('success');
|
|
197
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
run: 'git add . && git commit -m "test commit"',
|
|
200
|
+
}),
|
|
201
|
+
context,
|
|
202
|
+
logger,
|
|
203
|
+
undefined,
|
|
204
|
+
'git add . && git commit -m "test commit"'
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should handle failed operations', async () => {
|
|
209
|
+
executeShellSpy.mockResolvedValue({
|
|
210
|
+
stdout: '',
|
|
211
|
+
stderr: 'git error',
|
|
212
|
+
exitCode: 1,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const step: GitStep = {
|
|
216
|
+
id: 'git-fail',
|
|
217
|
+
type: 'git',
|
|
218
|
+
op: 'pull',
|
|
219
|
+
needs: [],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const result = await executeGitStep(step, context, logger);
|
|
223
|
+
expect(result.status).toBe('failed');
|
|
224
|
+
expect(result.error).toContain('Git pull failed with code 1: git error');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should throw error for missing url in clone', async () => {
|
|
228
|
+
const step: GitStep = {
|
|
229
|
+
id: 'git-clone-no-url',
|
|
230
|
+
type: 'git',
|
|
231
|
+
op: 'clone',
|
|
232
|
+
needs: [],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
236
|
+
'Git clone requires a "url"'
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should throw error for missing path in worktree_add', async () => {
|
|
241
|
+
const step: GitStep = {
|
|
242
|
+
id: 'git-worktree-no-path',
|
|
243
|
+
type: 'git',
|
|
244
|
+
op: 'worktree_add',
|
|
245
|
+
needs: [],
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
249
|
+
'Git worktree_add requires a "path"'
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should throw error for missing branch in checkout', async () => {
|
|
254
|
+
const step: GitStep = {
|
|
255
|
+
id: 'git-checkout-no-branch',
|
|
256
|
+
type: 'git',
|
|
257
|
+
op: 'checkout',
|
|
258
|
+
needs: [],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
262
|
+
'Git checkout requires a "branch"'
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should throw error for missing message in commit', async () => {
|
|
267
|
+
const step: GitStep = {
|
|
268
|
+
id: 'git-commit-no-msg',
|
|
269
|
+
type: 'git',
|
|
270
|
+
op: 'commit',
|
|
271
|
+
needs: [],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
275
|
+
'Git commit requires a "message"'
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ExpressionEvaluator } from '../../expression/evaluator.ts';
|
|
2
|
+
import type { ExpressionContext } from '../../expression/evaluator.ts';
|
|
3
|
+
import type { GitStep } from '../../parser/schema.ts';
|
|
4
|
+
import { ConsoleLogger, type Logger } from '../../utils/logger.ts';
|
|
5
|
+
import { PathResolver } from '../../utils/paths.ts';
|
|
6
|
+
import { type ShellResult, executeShell } from './shell-executor.ts';
|
|
7
|
+
import type { StepResult } from './types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Git command executor
|
|
11
|
+
*/
|
|
12
|
+
export async function executeGitStep(
|
|
13
|
+
step: GitStep,
|
|
14
|
+
context: ExpressionContext,
|
|
15
|
+
logger: Logger = new ConsoleLogger(),
|
|
16
|
+
abortSignal?: AbortSignal
|
|
17
|
+
): Promise<StepResult> {
|
|
18
|
+
const op = step.op;
|
|
19
|
+
const cwd = step.cwd ? ExpressionEvaluator.evaluateString(step.cwd, context) : undefined;
|
|
20
|
+
|
|
21
|
+
if (cwd) {
|
|
22
|
+
PathResolver.assertWithinCwd(cwd, step.allowOutsideCwd, 'CWD');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Pre-process common inputs
|
|
26
|
+
const path = step.path ? ExpressionEvaluator.evaluateString(step.path, context) : undefined;
|
|
27
|
+
const url = step.url ? ExpressionEvaluator.evaluateString(step.url, context) : undefined;
|
|
28
|
+
const branch = step.branch ? ExpressionEvaluator.evaluateString(step.branch, context) : undefined;
|
|
29
|
+
const message = step.message
|
|
30
|
+
? ExpressionEvaluator.evaluateString(step.message, context)
|
|
31
|
+
: undefined;
|
|
32
|
+
|
|
33
|
+
let command = '';
|
|
34
|
+
switch (op) {
|
|
35
|
+
case 'clone':
|
|
36
|
+
if (!url) throw new Error('Git clone requires a "url"');
|
|
37
|
+
command = `git clone ${branch ? `-b ${branch} ` : ''}${url} ${path || ''}`;
|
|
38
|
+
break;
|
|
39
|
+
case 'worktree_add':
|
|
40
|
+
if (!path) throw new Error('Git worktree_add requires a "path"');
|
|
41
|
+
command = `git worktree add ${path} ${branch || ''}`;
|
|
42
|
+
break;
|
|
43
|
+
case 'worktree_remove':
|
|
44
|
+
if (!path) throw new Error('Git worktree_remove requires a "path"');
|
|
45
|
+
command = `git worktree remove ${path}`;
|
|
46
|
+
break;
|
|
47
|
+
case 'checkout':
|
|
48
|
+
if (!branch) throw new Error('Git checkout requires a "branch"');
|
|
49
|
+
command = `git checkout ${branch}`;
|
|
50
|
+
break;
|
|
51
|
+
case 'pull':
|
|
52
|
+
command = `git pull origin ${branch || ''}`;
|
|
53
|
+
break;
|
|
54
|
+
case 'push':
|
|
55
|
+
command = `git push origin ${branch || ''}`;
|
|
56
|
+
break;
|
|
57
|
+
case 'commit':
|
|
58
|
+
if (!message) throw new Error('Git commit requires a "message"');
|
|
59
|
+
command = `git add . && git commit -m "${message.replace(/"/g, '\\"')}"`;
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`Unsupported git operation: ${op}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Use executeShell to leverage its security checks and output handling
|
|
66
|
+
// We explicitly allow insecure if the user set it, but by default executeShell
|
|
67
|
+
// will check the command against the whitelist.
|
|
68
|
+
// Note: Git commands often contain spaces/slashes which are in the whitelist.
|
|
69
|
+
const result: ShellResult = await executeShell(
|
|
70
|
+
{
|
|
71
|
+
...step,
|
|
72
|
+
type: 'shell',
|
|
73
|
+
run: command,
|
|
74
|
+
dir: cwd,
|
|
75
|
+
},
|
|
76
|
+
context,
|
|
77
|
+
logger,
|
|
78
|
+
abortSignal,
|
|
79
|
+
command
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
return {
|
|
84
|
+
output: result,
|
|
85
|
+
status: 'failed',
|
|
86
|
+
error: `Git ${op} failed with code ${result.exitCode}: ${result.stderr}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Provide some structured output based on the operation
|
|
91
|
+
const output: any = { ...result };
|
|
92
|
+
if (op === 'worktree_add') {
|
|
93
|
+
output.worktreePath = path;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
output,
|
|
98
|
+
status: 'success',
|
|
99
|
+
};
|
|
100
|
+
}
|