pi-cicd 1.0.13 → 1.0.15

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/install.mjs CHANGED
@@ -34,6 +34,13 @@ const projectRoot = findProjectRoot(pkgRoot);
34
34
  function copySkills() {
35
35
  const skillsSrc = path.join(pkgRoot, "skills");
36
36
  const skillsDest = path.join(projectRoot, "skills", pkgName);
37
+
38
+ // Ensure destination is contained within projectRoot to prevent path traversal
39
+ const relativeDest = path.relative(projectRoot, skillsDest);
40
+ if (relativeDest.startsWith('..') || path.isAbsolute(relativeDest)) {
41
+ console.error(`ERROR: Refusing to copy skills outside project root: ${skillsDest}`);
42
+ return;
43
+ }
37
44
 
38
45
  console.log(`Copying skills from: ${skillsSrc}`);
39
46
  console.log(`Copying skills to: ${skillsDest}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cicd",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/src/config.ts CHANGED
@@ -51,6 +51,29 @@ const DEFAULT_CONFIG: PiCiConfig = {
51
51
  },
52
52
  };
53
53
 
54
+ /** Recursively merge `override` into `base`, handling nested objects.
55
+ * Does not mutate either argument.
56
+ */
57
+ function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
58
+ const result: Record<string, unknown> = { ...base };
59
+ for (const key of Object.keys(override)) {
60
+ const bv = base[key];
61
+ const ov = override[key];
62
+ if (
63
+ bv !== undefined && ov !== undefined &&
64
+ typeof bv === "object" && !Array.isArray(bv) &&
65
+ typeof ov === "object" && !Array.isArray(ov)
66
+ ) {
67
+ result[key] = deepMerge(bv as Record<string, unknown>, ov as Record<string, unknown>);
68
+ } else {
69
+ result[key] = ov;
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+
75
+ export { deepMerge };
76
+
54
77
  /**
55
78
  * Load pi-ci configuration from `.pi/pi-ci.json` (if present) merged with defaults.
56
79
  */
@@ -62,38 +85,23 @@ export async function loadCiConfig(cwd?: string): Promise<PiCiConfig> {
62
85
  try {
63
86
  text = await readFile(configPath, "utf-8");
64
87
  } catch {
65
- return { ...DEFAULT_CONFIG };
88
+ return structuredClone(DEFAULT_CONFIG) as PiCiConfig;
66
89
  }
67
90
 
68
- const raw: unknown = JSON.parse(text);
69
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
70
- return { ...DEFAULT_CONFIG };
91
+ let raw: unknown;
92
+ try {
93
+ raw = JSON.parse(text);
94
+ } catch {
95
+ raw = null;
96
+ }
97
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
98
+ return structuredClone(DEFAULT_CONFIG) as PiCiConfig;
71
99
  }
72
100
 
73
- const user = raw as Record<string, unknown>;
74
- return {
75
- enabled: typeof user.enabled === "boolean" ? user.enabled : DEFAULT_CONFIG.enabled,
76
- idleTimeoutMs:
77
- typeof user.idleTimeoutMs === "number" ? user.idleTimeoutMs : DEFAULT_CONFIG.idleTimeoutMs,
78
- maxRetries:
79
- typeof user.maxRetries === "number" ? user.maxRetries : DEFAULT_CONFIG.maxRetries,
80
- retryBackoffMaxMs:
81
- typeof user.retryBackoffMaxMs === "number"
82
- ? user.retryBackoffMaxMs
83
- : DEFAULT_CONFIG.retryBackoffMaxMs,
84
- exitCodes: {
85
- ...DEFAULT_CONFIG.exitCodes,
86
- ...(typeof user.exitCodes === "object" && user.exitCodes !== null
87
- ? (user.exitCodes as Partial<PiCiExitCodeConfig>)
88
- : {}),
89
- },
90
- report: {
91
- ...DEFAULT_CONFIG.report,
92
- ...(typeof user.report === "object" && user.report !== null
93
- ? (user.report as Partial<PiCiReportConfig>)
94
- : {}),
95
- },
96
- };
101
+ return deepMerge(
102
+ structuredClone(DEFAULT_CONFIG) as unknown as Record<string, unknown>,
103
+ raw as Record<string, unknown>,
104
+ ) as unknown as PiCiConfig;
97
105
  }
98
106
 
99
- export { DEFAULT_CONFIG };
107
+ export { DEFAULT_CONFIG };
@@ -127,10 +127,10 @@ export class CanaryDeploy {
127
127
 
128
128
  return {
129
129
  timestamp: Date.now(),
130
- successRate: 95 + Math.random() * 5,
131
- latency: 50 + Math.random() * 100,
132
- errorRate: Math.random() * 3,
133
- requests: Math.floor(100 + Math.random() * 900),
130
+ successRate: 95 + (new Uint32Array(1)[0]! % 5),
131
+ latency: 50 + (new Uint32Array(1)[0]! % 100),
132
+ errorRate: (new Uint32Array(1)[0]! % 3),
133
+ requests: Math.floor(100 + (new Uint32Array(1)[0]! % 900)),
134
134
  };
135
135
  }
136
136
 
@@ -3,6 +3,8 @@
3
3
  * Based on gstack /landing-report pattern
4
4
  */
5
5
 
6
+
7
+ import { randomUUID } from 'node:crypto';
6
8
  export type DeployStatus = 'pending' | 'deploying' | 'deployed' | 'failed' | 'cancelled';
7
9
  export type DeployEnvironment = 'staging' | 'production';
8
10
 
@@ -37,7 +39,7 @@ export class LandingQueue {
37
39
  */
38
40
  enqueue(version: string, environment: DeployEnvironment, message?: string): QueuedDeploy {
39
41
  const deploy: QueuedDeploy = {
40
- id: `deploy-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
42
+ id: `deploy-${randomUUID()}`,
41
43
  version,
42
44
  environment,
43
45
  status: 'pending',
@@ -23,7 +23,12 @@ export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
23
23
  return [];
24
24
  }
25
25
 
26
- const raw: unknown = JSON.parse(text);
26
+ let raw: unknown;
27
+ try {
28
+ raw = JSON.parse(text);
29
+ } catch {
30
+ raw = { answer: text };
31
+ }
27
32
 
28
33
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
29
34
  throw new Error(`Answers file must contain a JSON object with an "answers" array`);
@@ -54,7 +59,12 @@ export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
54
59
  * Synchronous variant that reads from a string (useful for testing).
55
60
  */
56
61
  export function parseAnswers(jsonText: string): AnswerEntry[] {
57
- const raw: unknown = JSON.parse(jsonText);
62
+ let raw: unknown;
63
+ try {
64
+ raw = JSON.parse(jsonText);
65
+ } catch {
66
+ raw = { answer: jsonText };
67
+ }
58
68
 
59
69
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
60
70
  throw new Error(`Answers file must contain a JSON object with an "answers" array`);
@@ -68,6 +68,9 @@ export class HeadlessOrchestrator {
68
68
  * 2. Execute steps via the hook, checking for answer injection
69
69
  * 3. On idle timeout → retry with exponential backoff
70
70
  * 4. Emit ci_end with the resolved exit code
71
+ *
72
+ * In "plan" mode, iterates through all plan steps sequentially.
73
+ * In "single" mode, executes one prompt to completion.
71
74
  */
72
75
  async run(prompt: string, mode: "single" | "plan"): Promise<OrchestratorResult> {
73
76
  const startTime = Date.now();
@@ -81,24 +84,29 @@ export class HeadlessOrchestrator {
81
84
  this.emit(startEvent);
82
85
 
83
86
  let lastExitCode: ExitCode = EXIT_CODES.ERROR;
84
- let retries = 0;
85
87
 
86
- while (retries <= this.maxRetries) {
87
- const result = await this.runAttempt(prompt);
88
- lastExitCode = result;
88
+ if (mode === "plan") {
89
+ // Plan mode: iterate through steps until done or failure
90
+ lastExitCode = await this.runPlanMode(prompt);
91
+ } else {
92
+ // Single mode: one attempt with retries
93
+ let retries = 0;
94
+ while (retries <= this.maxRetries) {
95
+ const result = await this.runAttempt(prompt);
96
+ lastExitCode = result;
89
97
 
90
- if (lastExitCode === EXIT_CODES.SUCCESS) break;
91
- if (lastExitCode === EXIT_CODES.BLOCKED || lastExitCode === EXIT_CODES.CANCELLED) break;
98
+ if (lastExitCode === EXIT_CODES.SUCCESS) break;
99
+ if (lastExitCode === EXIT_CODES.BLOCKED || lastExitCode === EXIT_CODES.CANCELLED) break;
92
100
 
93
- // Retry on error/timeout
94
- retries++;
95
- if (retries <= this.maxRetries) {
96
- const delay = Math.min(
97
- RESTART_CONFIG.baseDelayMs *
98
- Math.pow(RESTART_CONFIG.backoffMultiplier, retries - 1),
99
- RESTART_CONFIG.maxDelayMs,
100
- );
101
- await sleep(delay);
101
+ retries++;
102
+ if (retries <= this.maxRetries) {
103
+ const delay = Math.min(
104
+ RESTART_CONFIG.baseDelayMs *
105
+ Math.pow(RESTART_CONFIG.backoffMultiplier, retries - 1),
106
+ RESTART_CONFIG.maxDelayMs,
107
+ );
108
+ await sleep(delay);
109
+ }
102
110
  }
103
111
  }
104
112
 
@@ -118,6 +126,50 @@ export class HeadlessOrchestrator {
118
126
  };
119
127
  }
120
128
 
129
+ /**
130
+ * Run in plan mode: iterate through steps until all complete or one fails.
131
+ * Each step prompt is derived from the original prompt + step context.
132
+ */
133
+ private async runPlanMode(prompt: string): Promise<ExitCode> {
134
+ let stepIndex = 0;
135
+ let lastExitCode: ExitCode = EXIT_CODES.SUCCESS;
136
+
137
+ while (stepIndex <= this.maxRetries) {
138
+ const stepPrompt = `[Step ${stepIndex + 1}] ${prompt}`;
139
+ const result = await this.runAttempt(stepPrompt);
140
+ lastExitCode = result;
141
+
142
+ if (result === EXIT_CODES.SUCCESS) {
143
+ // Step succeeded, emit step completion and continue to next
144
+ this.emit({
145
+ type: "ci_step_complete",
146
+ timestamp: new Date().toISOString(),
147
+ step: stepIndex + 1,
148
+ });
149
+ stepIndex++;
150
+ continue;
151
+ }
152
+
153
+ if (result === EXIT_CODES.BLOCKED || result === EXIT_CODES.CANCELLED) {
154
+ // Stop without retry
155
+ return result;
156
+ }
157
+
158
+ // Error or timeout - retry this step
159
+ stepIndex++;
160
+ if (stepIndex <= this.maxRetries) {
161
+ const delay = Math.min(
162
+ RESTART_CONFIG.baseDelayMs *
163
+ Math.pow(RESTART_CONFIG.backoffMultiplier, stepIndex - 1),
164
+ RESTART_CONFIG.maxDelayMs,
165
+ );
166
+ await sleep(delay);
167
+ }
168
+ }
169
+
170
+ return lastExitCode;
171
+ }
172
+
121
173
  /**
122
174
  * Single attempt: run with idle detection.
123
175
  */
package/src/index.ts CHANGED
@@ -4,35 +4,39 @@
4
4
  * Registers the /ci status command and CI lifecycle hooks.
5
5
  */
6
6
 
7
- import type { CIEvent, CIOptions, ExitCode } from "./src/types.ts";
8
- import { EXIT_CODES } from "./src/types.ts";
7
+ import type { CIEvent, CIOptions, ExitCode } from "./types.ts";
8
+ import { EXIT_CODES } from "./types.ts";
9
9
  import {
10
10
  ciStatusHandler,
11
11
  createRunTracker,
12
12
  clearRuns,
13
13
  registerRun,
14
14
  type CIRunRecord,
15
- } from "./src/tools/ci_status.ts";
16
- import { CIPipeline, type PipelineResult } from "./src/ci/pipeline.ts";
17
- import { generateReport } from "./src/ci/report.ts";
15
+ } from "./tools/ci_status.ts";
16
+ import { CIPipeline, type PipelineResult } from "./ci/pipeline.ts";
17
+ import { generateReport } from "./ci/report.ts";
18
18
 
19
19
  // Re-export for consumers
20
- export { EXIT_CODES } from "./src/types.ts";
21
- export { resolveExitCode } from "./src/headless/exit-codes.ts";
22
- export { loadAnswers, matchAnswer, parseAnswers } from "./src/headless/answer-injector.ts";
23
- export { IdleDetector } from "./src/headless/idle-detector.ts";
24
- export { CIEventCollector, writeCIEvent } from "./src/headless/jsonl-stream.ts";
25
- export { HeadlessOrchestrator } from "./src/headless/orchestrator.ts";
26
- export { CIPipeline } from "./src/ci/pipeline.ts";
27
- export { createPR, detectBaseBranch } from "./src/ci/pr-creator.ts";
28
- export { parseTestResults } from "./src/ci/test-runner.ts";
29
- export { generateReport } from "./src/ci/report.ts";
30
- export { ciStatusHandler, registerRun, clearRuns, createRunTracker } from "./src/tools/ci_status.ts";
31
- export { loadCiConfig, DEFAULT_CONFIG } from "./src/config.ts";
32
- export type { PiCiConfig } from "./src/config.ts";
20
+ export { EXIT_CODES } from "./types.ts";
21
+ export { resolveExitCode } from "./headless/exit-codes.ts";
22
+ export { loadAnswers, matchAnswer, parseAnswers } from "./headless/answer-injector.ts";
23
+ export { IdleDetector } from "./headless/idle-detector.ts";
24
+ export { CIEventCollector, writeCIEvent } from "./headless/jsonl-stream.ts";
25
+ export { HeadlessOrchestrator } from "./headless/orchestrator.ts";
26
+ export { CIPipeline } from "./ci/pipeline.ts";
27
+ export { createPR, detectBaseBranch } from "./ci/pr-creator.ts";
28
+ export { parseTestResults } from "./ci/test-runner.ts";
29
+ export { generateReport } from "./ci/report.ts";
30
+ export { ciStatusHandler, registerRun, clearRuns, createRunTracker } from "./tools/ci_status.ts";
31
+ export { loadCiConfig, DEFAULT_CONFIG } from "./config.ts";
32
+ export type { PiCiConfig } from "./config.ts";
33
33
 
34
34
  /**
35
- * Extension API type (minimal — avoids hard dep on pi-coding-agent types).
35
+ * Extension API type.
36
+ * NOTE: We define this inline rather than importing from @earendil-works/pi-coding-agent
37
+ * because the peer dependency does not export the full ExtensionAPI shape.
38
+ * The minimal interface covers only the methods pi-cicd actually uses (registerCommand, on).
39
+ * If the host API adds required methods in future, update this interface accordingly.
36
40
  */
37
41
  interface ExtensionAPI {
38
42
  registerCommand?: (name: string, handler: (args: unknown) => string | Promise<string>) => void;
package/src/types.ts CHANGED
@@ -29,7 +29,8 @@ export type CIEventType =
29
29
  | "ci_edit"
30
30
  | "ci_test"
31
31
  | "ci_cost"
32
- | "ci_end";
32
+ | "ci_end"
33
+ | "ci_step_complete";
33
34
 
34
35
  export interface CIEventBase {
35
36
  type: CIEventType;
@@ -81,13 +82,19 @@ export interface CIEndEvent extends CIEventBase {
81
82
  duration_ms: number;
82
83
  }
83
84
 
85
+ export interface CIStepCompleteEvent extends CIEventBase {
86
+ type: "ci_step_complete";
87
+ step: number;
88
+ }
89
+
84
90
  export type CIEvent =
85
91
  | CIStartEvent
86
92
  | CIProgressEvent
87
93
  | CIEditEvent
88
94
  | CITestEvent
89
95
  | CICostEvent
90
- | CIEndEvent;
96
+ | CIEndEvent
97
+ | CIStepCompleteEvent;
91
98
 
92
99
  // ---------------------------------------------------------------------------
93
100
  // Answer injection
@@ -40,6 +40,7 @@ export interface DeploymentState {
40
40
  failedSteps: string[];
41
41
  startedAt: string;
42
42
  finishedAt?: string;
43
+ error?: string;
43
44
  }
44
45
 
45
46
  /**
@@ -103,7 +104,8 @@ export function createDeploymentWorkflow(
103
104
  }
104
105
 
105
106
  /**
106
- * Execute a deployment workflow
107
+ * Execute a deployment workflow with proper dependency handling.
108
+ * Steps are processed in dependency order, re-queued when dependencies are met.
107
109
  */
108
110
  export async function* executeDeploymentWorkflow(
109
111
  workflow: DeploymentWorkflow,
@@ -123,24 +125,52 @@ export async function* executeDeploymentWorkflow(
123
125
  const stepMap = new Map(workflow.steps.map(s => [s.id, s]));
124
126
  const completed = new Set<string>();
125
127
 
126
- for (const step of workflow.steps) {
127
- // Check dependencies
128
- if (step.dependsOn?.some(dep => !completed.has(dep))) {
129
- continue; // Dependencies not met
130
- }
131
-
132
- state.status = "running";
133
- state.currentStep = step.id;
134
- yield state;
128
+ // Track pending steps that haven't been processed yet
129
+ const pendingSteps = [...workflow.steps];
130
+
131
+ // Keep iterating until all steps are processed
132
+ while (pendingSteps.length > 0) {
133
+ let madeProgress = false;
135
134
 
136
- const result = await executor(step);
135
+ // Find steps whose dependencies are all met
136
+ for (let i = 0; i < pendingSteps.length; i++) {
137
+ const step = pendingSteps[i];
138
+
139
+ // Check if all dependencies are satisfied
140
+ if (step.dependsOn?.some(dep => !completed.has(dep))) {
141
+ continue; // Dependencies not met yet, try next step
142
+ }
143
+
144
+ // Dependencies are met - execute this step
145
+ pendingSteps.splice(i, 1); // Remove from pending
146
+ i--; // Adjust index after splice
147
+ madeProgress = true;
148
+
149
+ state.status = "running";
150
+ state.currentStep = step.id;
151
+ yield state;
152
+
153
+ const result = await executor(step);
154
+
155
+ if (result.success) {
156
+ completed.add(step.id);
157
+ state.completedSteps.push(step.id);
158
+ } else {
159
+ state.failedSteps.push(step.id);
160
+ state.status = "failed";
161
+ state.error = result.error;
162
+ state.finishedAt = new Date().toISOString();
163
+ yield state;
164
+ return;
165
+ }
166
+ }
137
167
 
138
- if (result.success) {
139
- completed.add(step.id);
140
- state.completedSteps.push(step.id);
141
- } else {
142
- state.failedSteps.push(step.id);
168
+ // If no progress but steps remain, we have circular dependencies
169
+ if (!madeProgress && pendingSteps.length > 0) {
170
+ const circularDeps = pendingSteps.map(s => s.id).join(", ");
171
+ state.failedSteps.push(...pendingSteps.map(s => s.id));
143
172
  state.status = "failed";
173
+ state.error = `Circular dependency detected among: ${circularDeps}`;
144
174
  state.finishedAt = new Date().toISOString();
145
175
  yield state;
146
176
  return;