pi-cicd 0.3.0 → 1.0.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +34 -40
  3. package/docs/API.md +61 -0
  4. package/docs/COMMANDS.md +138 -0
  5. package/docs/CONFIG.md +123 -0
  6. package/docs/GUIDE.md +171 -0
  7. package/docs/PATTERNS.md +49 -0
  8. package/docs/QUICKSTART.md +99 -0
  9. package/{dist/index.d.ts → index.ts} +26 -4
  10. package/install.mjs +34 -0
  11. package/package.json +21 -21
  12. package/skills/intelligent-deploy/SKILL.md +229 -0
  13. package/src/ci/pipeline.ts +130 -0
  14. package/src/ci/pr-creator.ts +74 -0
  15. package/src/ci/report.ts +65 -0
  16. package/src/ci/test-runner.ts +129 -0
  17. package/src/config.ts +99 -0
  18. package/src/deploy/canary-deploy.ts +211 -0
  19. package/src/deploy/landing-queue.ts +222 -0
  20. package/src/headless/answer-injector.ts +99 -0
  21. package/src/headless/exit-codes.ts +32 -0
  22. package/src/headless/idle-detector.ts +76 -0
  23. package/src/headless/jsonl-stream.ts +90 -0
  24. package/src/headless/orchestrator.ts +207 -0
  25. package/{dist/index.js → src/index.ts} +30 -9
  26. package/src/tools/ci_status.ts +137 -0
  27. package/src/types.ts +149 -0
  28. package/src/workflow/deployment-workflow.ts +153 -0
  29. package/dist/ci/pipeline.d.ts +0 -43
  30. package/dist/ci/pipeline.d.ts.map +0 -1
  31. package/dist/ci/pipeline.js +0 -107
  32. package/dist/ci/pipeline.js.map +0 -1
  33. package/dist/ci/pr-creator.d.ts +0 -17
  34. package/dist/ci/pr-creator.d.ts.map +0 -1
  35. package/dist/ci/pr-creator.js +0 -67
  36. package/dist/ci/pr-creator.js.map +0 -1
  37. package/dist/ci/report.d.ts +0 -14
  38. package/dist/ci/report.d.ts.map +0 -1
  39. package/dist/ci/report.js +0 -51
  40. package/dist/ci/report.js.map +0 -1
  41. package/dist/ci/test-runner.d.ts +0 -10
  42. package/dist/ci/test-runner.d.ts.map +0 -1
  43. package/dist/ci/test-runner.js +0 -111
  44. package/dist/ci/test-runner.js.map +0 -1
  45. package/dist/config.d.ts +0 -33
  46. package/dist/config.d.ts.map +0 -1
  47. package/dist/config.js +0 -67
  48. package/dist/config.js.map +0 -1
  49. package/dist/deploy/canary-deploy.d.ts +0 -80
  50. package/dist/deploy/canary-deploy.d.ts.map +0 -1
  51. package/dist/deploy/canary-deploy.js +0 -145
  52. package/dist/deploy/canary-deploy.js.map +0 -1
  53. package/dist/deploy/landing-queue.d.ts +0 -83
  54. package/dist/deploy/landing-queue.d.ts.map +0 -1
  55. package/dist/deploy/landing-queue.js +0 -172
  56. package/dist/deploy/landing-queue.js.map +0 -1
  57. package/dist/headless/answer-injector.d.ts +0 -27
  58. package/dist/headless/answer-injector.d.ts.map +0 -1
  59. package/dist/headless/answer-injector.js +0 -80
  60. package/dist/headless/answer-injector.js.map +0 -1
  61. package/dist/headless/exit-codes.d.ts +0 -13
  62. package/dist/headless/exit-codes.d.ts.map +0 -1
  63. package/dist/headless/exit-codes.js +0 -29
  64. package/dist/headless/exit-codes.js.map +0 -1
  65. package/dist/headless/idle-detector.d.ts +0 -32
  66. package/dist/headless/idle-detector.d.ts.map +0 -1
  67. package/dist/headless/idle-detector.js +0 -62
  68. package/dist/headless/idle-detector.js.map +0 -1
  69. package/dist/headless/jsonl-stream.d.ts +0 -28
  70. package/dist/headless/jsonl-stream.d.ts.map +0 -1
  71. package/dist/headless/jsonl-stream.js +0 -65
  72. package/dist/headless/jsonl-stream.js.map +0 -1
  73. package/dist/headless/orchestrator.d.ts +0 -63
  74. package/dist/headless/orchestrator.d.ts.map +0 -1
  75. package/dist/headless/orchestrator.js +0 -156
  76. package/dist/headless/orchestrator.js.map +0 -1
  77. package/dist/index.d.ts.map +0 -1
  78. package/dist/index.js.map +0 -1
  79. package/dist/tools/ci_status.d.ts +0 -40
  80. package/dist/tools/ci_status.d.ts.map +0 -1
  81. package/dist/tools/ci_status.js +0 -110
  82. package/dist/tools/ci_status.js.map +0 -1
  83. package/dist/types.d.ts +0 -93
  84. package/dist/types.d.ts.map +0 -1
  85. package/dist/types.js +0 -17
  86. package/dist/types.js.map +0 -1
  87. package/dist/workflow/deployment-workflow.d.ts +0 -56
  88. package/dist/workflow/deployment-workflow.d.ts.map +0 -1
  89. package/dist/workflow/deployment-workflow.js +0 -95
  90. package/dist/workflow/deployment-workflow.js.map +0 -1
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Landing Queue - Process deployments in order
3
+ * Based on gstack /landing-report pattern
4
+ */
5
+
6
+ export type DeployStatus = 'pending' | 'deploying' | 'deployed' | 'failed' | 'cancelled';
7
+ export type DeployEnvironment = 'staging' | 'production';
8
+
9
+ export interface QueuedDeploy {
10
+ id: string;
11
+ version: string;
12
+ environment: DeployEnvironment;
13
+ status: DeployStatus;
14
+ createdAt: number;
15
+ deployedAt?: number;
16
+ message?: string;
17
+ logs: string[];
18
+ }
19
+
20
+ export interface LandingQueueStats {
21
+ total: number;
22
+ pending: number;
23
+ deploying: number;
24
+ deployed: number;
25
+ failed: number;
26
+ }
27
+
28
+ /**
29
+ * Landing Queue Manager
30
+ */
31
+ export class LandingQueue {
32
+ private queue: QueuedDeploy[] = [];
33
+ private current: QueuedDeploy | null = null;
34
+
35
+ /**
36
+ * Add deployment to queue
37
+ */
38
+ enqueue(version: string, environment: DeployEnvironment, message?: string): QueuedDeploy {
39
+ const deploy: QueuedDeploy = {
40
+ id: `deploy-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
41
+ version,
42
+ environment,
43
+ status: 'pending',
44
+ createdAt: Date.now(),
45
+ message,
46
+ logs: [],
47
+ };
48
+
49
+ this.queue.push(deploy);
50
+ this.log(deploy.id, `Added to queue: ${version} -> ${environment}`);
51
+
52
+ return deploy;
53
+ }
54
+
55
+ /**
56
+ * Get next pending deployment
57
+ */
58
+ peek(): QueuedDeploy | undefined {
59
+ return this.queue.find((d) => d.status === 'pending');
60
+ }
61
+
62
+ /**
63
+ * Start deploying next item
64
+ */
65
+ async startNext(): Promise<QueuedDeploy | null> {
66
+ const next = this.peek();
67
+ if (!next) return null;
68
+
69
+ // Mark as deploying
70
+ next.status = 'deploying';
71
+ this.current = next;
72
+ this.log(next.id, 'Starting deployment');
73
+
74
+ return next;
75
+ }
76
+
77
+ /**
78
+ * Mark deployment as complete
79
+ */
80
+ complete(id: string, success: boolean): void {
81
+ const deploy = this.queue.find((d) => d.id === id);
82
+ if (!deploy) return;
83
+
84
+ deploy.status = success ? 'deployed' : 'failed';
85
+ deploy.deployedAt = Date.now();
86
+ this.log(id, success ? 'Deployment successful' : 'Deployment failed');
87
+
88
+ if (this.current?.id === id) {
89
+ this.current = null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Cancel a deployment
95
+ */
96
+ cancel(id: string): void {
97
+ const deploy = this.queue.find((d) => d.id === id);
98
+ if (!deploy) return;
99
+
100
+ if (deploy.status === 'deploying') {
101
+ this.log(id, 'Cannot cancel - deployment in progress');
102
+ return;
103
+ }
104
+
105
+ deploy.status = 'cancelled';
106
+ this.log(id, 'Cancelled');
107
+ }
108
+
109
+ /**
110
+ * Get deployment by ID
111
+ */
112
+ get(id: string): QueuedDeploy | undefined {
113
+ return this.queue.find((d) => d.id === id);
114
+ }
115
+
116
+ /**
117
+ * Get queue status
118
+ */
119
+ getStats(): LandingQueueStats {
120
+ return {
121
+ total: this.queue.length,
122
+ pending: this.queue.filter((d) => d.status === 'pending').length,
123
+ deploying: this.queue.filter((d) => d.status === 'deploying').length,
124
+ deployed: this.queue.filter((d) => d.status === 'deployed').length,
125
+ failed: this.queue.filter((d) => d.status === 'failed').length,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Get all deployments
131
+ */
132
+ getAll(): QueuedDeploy[] {
133
+ return [...this.queue].sort((a, b) => b.createdAt - a.createdAt);
134
+ }
135
+
136
+ /**
137
+ * Get pending deployments
138
+ */
139
+ getPending(): QueuedDeploy[] {
140
+ return this.queue
141
+ .filter((d) => d.status === 'pending')
142
+ .sort((a, b) => a.createdAt - b.createdAt);
143
+ }
144
+
145
+ /**
146
+ * Get current deploying
147
+ */
148
+ getCurrent(): QueuedDeploy | null {
149
+ return this.current;
150
+ }
151
+
152
+ /**
153
+ * Add log entry
154
+ */
155
+ private log(id: string, message: string): void {
156
+ const deploy = this.queue.find((d) => d.id === id);
157
+ if (deploy) {
158
+ deploy.logs.push(`[${new Date().toISOString()}] ${message}`);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Clear completed deployments
164
+ */
165
+ clearCompleted(): void {
166
+ this.queue = this.queue.filter(
167
+ (d) => d.status === 'pending' || d.status === 'deploying'
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Format queue as markdown report
173
+ */
174
+ formatReport(): string {
175
+ const stats = this.getStats();
176
+ const lines: string[] = [];
177
+
178
+ lines.push('## Landing Queue Report\n');
179
+ lines.push(`**Total:** ${stats.total} | **Pending:** ${stats.pending} | **Deploying:** ${stats.deploying} | **Deployed:** ${stats.deployed} | **Failed:** ${stats.failed}\n`);
180
+
181
+ const current = this.getCurrent();
182
+ if (current) {
183
+ lines.push('### Currently Deploying\n');
184
+ lines.push(`**${current.version}** -> ${current.environment}`);
185
+ lines.push(`Status: ${current.status}`);
186
+ lines.push('');
187
+ }
188
+
189
+ const pending = this.getPending();
190
+ if (pending.length > 0) {
191
+ lines.push('### Queue\n');
192
+ lines.push('| # | Version | Environment | Message |');
193
+ lines.push('|---|--------|------------|---------|');
194
+
195
+ pending.forEach((d, i) => {
196
+ lines.push(`| ${i + 1} | ${d.version} | ${d.environment} | ${d.message || '-'} |`);
197
+ });
198
+ lines.push('');
199
+ }
200
+
201
+ const recent = this.queue
202
+ .filter((d) => d.status === 'deployed' || d.status === 'failed')
203
+ .slice(0, 5);
204
+
205
+ if (recent.length > 0) {
206
+ lines.push('### Recent\n');
207
+ lines.push('| Version | Environment | Status | Time |');
208
+ lines.push('|---------|------------|--------|------|');
209
+
210
+ for (const d of recent) {
211
+ const icon = d.status === 'deployed' ? '✅' : '❌';
212
+ const time = d.deployedAt
213
+ ? new Date(d.deployedAt).toLocaleTimeString()
214
+ : '-';
215
+ lines.push(`| ${d.version} | ${d.environment} | ${icon} ${d.status} | ${time} |`);
216
+ }
217
+ lines.push('');
218
+ }
219
+
220
+ return lines.join('\n');
221
+ }
222
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * pi-ci — Answer injection from a JSON file.
3
+ *
4
+ * When Pi encounters an interactive prompt in CI mode, it consults an
5
+ * answers file for a pre-supplied response.
6
+ */
7
+
8
+ import type { AnswerEntry, AnswerFile } from "../types.ts";
9
+ import * as fs from "fs";
10
+
11
+ /**
12
+ * Read and validate an answers JSON file.
13
+ *
14
+ * - Returns an empty array if the file cannot be read.
15
+ * - Skips entries that are missing `match` or `answer` fields.
16
+ * - Throws on invalid JSON.
17
+ */
18
+ export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
19
+ let text: string;
20
+ try {
21
+ text = fs.readFileSync(filePath, "utf-8");
22
+ } catch {
23
+ return [];
24
+ }
25
+
26
+ const raw: unknown = JSON.parse(text);
27
+
28
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
29
+ throw new Error(`Answers file must contain a JSON object with an "answers" array`);
30
+ }
31
+
32
+ const obj = raw as Record<string, unknown>;
33
+ if (!Array.isArray(obj.answers)) {
34
+ throw new Error(`Answers file must contain an "answers" array`);
35
+ }
36
+
37
+ const entries: AnswerEntry[] = [];
38
+ for (const item of obj.answers) {
39
+ if (
40
+ typeof item === "object" &&
41
+ item !== null &&
42
+ typeof (item as Record<string, unknown>).match === "string" &&
43
+ typeof (item as Record<string, unknown>).answer === "string"
44
+ ) {
45
+ entries.push(item as AnswerEntry);
46
+ }
47
+ // Silently skip malformed entries
48
+ }
49
+
50
+ return entries;
51
+ }
52
+
53
+ /**
54
+ * Synchronous variant that reads from a string (useful for testing).
55
+ */
56
+ export function parseAnswers(jsonText: string): AnswerEntry[] {
57
+ const raw: unknown = JSON.parse(jsonText);
58
+
59
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
60
+ throw new Error(`Answers file must contain a JSON object with an "answers" array`);
61
+ }
62
+
63
+ const obj = raw as Record<string, unknown>;
64
+ if (!Array.isArray(obj.answers)) {
65
+ throw new Error(`Answers file must contain an "answers" array`);
66
+ }
67
+
68
+ const entries: AnswerEntry[] = [];
69
+ for (const item of obj.answers) {
70
+ if (
71
+ typeof item === "object" &&
72
+ item !== null &&
73
+ typeof (item as Record<string, unknown>).match === "string" &&
74
+ typeof (item as Record<string, unknown>).answer === "string"
75
+ ) {
76
+ entries.push(item as AnswerEntry);
77
+ }
78
+ }
79
+
80
+ return entries;
81
+ }
82
+
83
+ /**
84
+ * Find a matching answer for the given prompt using substring matching.
85
+ *
86
+ * Returns the first answer whose `match` is found as a substring of `prompt`,
87
+ * or `undefined` if no match is found.
88
+ */
89
+ export function matchAnswer(
90
+ entries: AnswerEntry[],
91
+ prompt: string,
92
+ ): string | undefined {
93
+ for (const entry of entries) {
94
+ if (prompt.includes(entry.match)) {
95
+ return entry.answer;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * pi-ci — Exit code resolution helpers.
3
+ */
4
+
5
+ import { EXIT_CODES, type ExitCode } from "../types.ts";
6
+
7
+ /**
8
+ * Map a symbolic status string to a numeric exit code.
9
+ *
10
+ * Unknown / unexpected statuses resolve to ERROR (1).
11
+ */
12
+ export function resolveExitCode(status: string): ExitCode {
13
+ switch (status) {
14
+ case "success":
15
+ return EXIT_CODES.SUCCESS;
16
+ case "error":
17
+ case "timeout":
18
+ return EXIT_CODES.ERROR;
19
+ case "blocked":
20
+ return EXIT_CODES.BLOCKED;
21
+ case "cancelled":
22
+ return EXIT_CODES.CANCELLED;
23
+ case "needs_input":
24
+ case "needs-input":
25
+ return EXIT_CODES.NEEDS_INPUT;
26
+ default:
27
+ return EXIT_CODES.ERROR;
28
+ }
29
+ }
30
+
31
+ export { EXIT_CODES };
32
+ export type { ExitCode };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * pi-ci — Idle timeout detection.
3
+ *
4
+ * If no activity is detected within the configured timeout, the callback fires.
5
+ */
6
+
7
+ export interface IdleDetectorOptions {
8
+ /** Timeout in milliseconds. Default: 15 000. */
9
+ idleTimeoutMs?: number;
10
+ /** Called when the idle timeout is reached. */
11
+ onTimeout: () => void;
12
+ }
13
+
14
+ const DEFAULT_IDLE_TIMEOUT_MS = 15_000;
15
+
16
+ export class IdleDetector {
17
+ private readonly idleTimeoutMs: number;
18
+ private readonly onTimeout: () => void;
19
+ private timer: ReturnType<typeof setTimeout> | null = null;
20
+ private _running = false;
21
+ private _fired = false;
22
+
23
+ constructor(options: IdleDetectorOptions) {
24
+ this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
25
+ this.onTimeout = options.onTimeout;
26
+ }
27
+
28
+ /** Whether the detector is currently running. */
29
+ get running(): boolean {
30
+ return this._running;
31
+ }
32
+
33
+ /** Whether the timeout has already fired. */
34
+ get fired(): boolean {
35
+ return this._fired;
36
+ }
37
+
38
+ /** Start the idle timer. Safe to call multiple times (no-op if already running). */
39
+ start(): void {
40
+ if (this._running) return;
41
+ this._running = true;
42
+ this._fired = false;
43
+ this.scheduleTimer();
44
+ }
45
+
46
+ /** Reset the idle timer. If not running, this is a no-op. */
47
+ reset(): void {
48
+ if (!this._running) return;
49
+ this.clearTimer();
50
+ this.scheduleTimer();
51
+ }
52
+
53
+ /** Stop the idle timer. */
54
+ stop(): void {
55
+ this._running = false;
56
+ this._fired = false;
57
+ this.clearTimer();
58
+ }
59
+
60
+ private scheduleTimer(): void {
61
+ this.timer = setTimeout(() => {
62
+ if (this._running) {
63
+ this._running = false;
64
+ this._fired = true;
65
+ this.onTimeout();
66
+ }
67
+ }, this.idleTimeoutMs);
68
+ }
69
+
70
+ private clearTimer(): void {
71
+ if (this.timer !== null) {
72
+ clearTimeout(this.timer);
73
+ this.timer = null;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * pi-ci — JSONL event stream utilities.
3
+ *
4
+ * Writes CI events as single-line JSON to a writable stream and provides
5
+ * type-guard helpers for event discrimination.
6
+ */
7
+
8
+ import type { Writable } from "node:stream";
9
+ import type {
10
+ CIEvent,
11
+ CIStartEvent,
12
+ CIProgressEvent,
13
+ CIEditEvent,
14
+ CITestEvent,
15
+ CICostEvent,
16
+ CIEndEvent,
17
+ } from "../types.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Write helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Ensure an event has a timestamp, injecting `now` if missing.
25
+ */
26
+ function ensureTimestamp<T extends CIEvent>(event: T): T & { timestamp: string } {
27
+ if (!event.timestamp) {
28
+ return { ...event, timestamp: new Date().toISOString() };
29
+ }
30
+ return event as T & { timestamp: string };
31
+ }
32
+
33
+ /**
34
+ * Serialise a CI event and write it as a single JSONL line to the stream.
35
+ */
36
+ export function writeCIEvent(stream: Writable, event: CIEvent): void {
37
+ const stamped = ensureTimestamp(event);
38
+ stream.write(JSON.stringify(stamped) + "\n");
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Event emitter (collects events for reporting)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export class CIEventCollector {
46
+ private readonly events: CIEvent[] = [];
47
+
48
+ /** Record an event. Auto-fills timestamp if missing. */
49
+ emit(event: CIEvent): void {
50
+ this.events.push(ensureTimestamp(event));
51
+ }
52
+
53
+ /** Return all collected events in order. */
54
+ all(): CIEvent[] {
55
+ return [...this.events];
56
+ }
57
+
58
+ /** Reset the collector. */
59
+ clear(): void {
60
+ this.events.length = 0;
61
+ }
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Type guards
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export function isCIStartEvent(e: CIEvent): e is CIStartEvent {
69
+ return e.type === "ci_start";
70
+ }
71
+
72
+ export function isCIProgressEvent(e: CIEvent): e is CIProgressEvent {
73
+ return e.type === "ci_progress";
74
+ }
75
+
76
+ export function isCIEditEvent(e: CIEvent): e is CIEditEvent {
77
+ return e.type === "ci_edit";
78
+ }
79
+
80
+ export function isCITestEvent(e: CIEvent): e is CITestEvent {
81
+ return e.type === "ci_test";
82
+ }
83
+
84
+ export function isCICostEvent(e: CIEvent): e is CICostEvent {
85
+ return e.type === "ci_cost";
86
+ }
87
+
88
+ export function isCIEndEvent(e: CIEvent): e is CIEndEvent {
89
+ return e.type === "ci_end";
90
+ }