gitship-core 0.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.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "gitship-core",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "echo \"No tests for core\""
10
+ },
11
+ "dependencies": {
12
+ "gitship-shared": "*",
13
+ "better-sqlite3": "^11.0.0",
14
+ "execa": "^9.2.0",
15
+ "nanoid": "^5.0.7",
16
+ "octokit": "^3.2.1",
17
+ "zod": "^3.23.8"
18
+ },
19
+ "devDependencies": {
20
+ "@types/better-sqlite3": "^7.6.10",
21
+ "@types/node": "^22.0.0",
22
+ "typescript": "^5.4.5"
23
+ }
24
+ }
package/src/db.ts ADDED
@@ -0,0 +1,425 @@
1
+ import Database from "better-sqlite3";
2
+ import fs from "fs";
3
+ import { DB_PATH, ensureDirsExist } from "./paths.js";
4
+ import {
5
+ Project,
6
+ Webhook,
7
+ Deployment,
8
+ DeploymentStep,
9
+ DeploymentLog,
10
+ DeploymentStatus,
11
+ StepStatus,
12
+ } from "gitship-shared";
13
+
14
+ let dbInstance: Database.Database | null = null;
15
+
16
+ export function getDb(): Database.Database {
17
+ if (dbInstance) return dbInstance;
18
+ ensureDirsExist();
19
+ dbInstance = new Database(DB_PATH);
20
+ try {
21
+ fs.chmodSync(DB_PATH, 0o600);
22
+ } catch {}
23
+ dbInstance.pragma("journal_mode = WAL");
24
+ dbInstance.pragma("foreign_keys = ON");
25
+ initDb(dbInstance);
26
+ return dbInstance;
27
+ }
28
+
29
+ function initDb(db: Database.Database) {
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS projects (
32
+ id TEXT PRIMARY KEY,
33
+ name TEXT UNIQUE NOT NULL,
34
+ owner TEXT NOT NULL,
35
+ repo TEXT NOT NULL,
36
+ branch TEXT NOT NULL,
37
+ target_type TEXT NOT NULL,
38
+ target_host TEXT,
39
+ target_path TEXT NOT NULL,
40
+ install_cmd TEXT,
41
+ build_cmd TEXT,
42
+ restart_cmd TEXT,
43
+ healthcheck_path TEXT,
44
+ healthcheck_port INTEGER,
45
+ healthcheck_retries INTEGER,
46
+ healthcheck_interval_ms INTEGER,
47
+ healthcheck_timeout_ms INTEGER,
48
+ webhook_secret TEXT NOT NULL,
49
+ created_at INTEGER NOT NULL,
50
+ updated_at INTEGER NOT NULL
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS webhooks (
54
+ id TEXT PRIMARY KEY,
55
+ project_id TEXT NOT NULL,
56
+ github_webhook_id INTEGER,
57
+ url TEXT NOT NULL,
58
+ secret TEXT NOT NULL,
59
+ active INTEGER NOT NULL DEFAULT 1,
60
+ created_at INTEGER NOT NULL,
61
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS deployments (
65
+ id TEXT PRIMARY KEY,
66
+ project_id TEXT NOT NULL,
67
+ branch TEXT NOT NULL,
68
+ commit_sha TEXT,
69
+ commit_message TEXT,
70
+ author TEXT,
71
+ status TEXT NOT NULL,
72
+ started_at INTEGER,
73
+ finished_at INTEGER,
74
+ total_duration_ms INTEGER,
75
+ rollback_of_id TEXT,
76
+ created_at INTEGER NOT NULL,
77
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS deployment_steps (
81
+ id TEXT PRIMARY KEY,
82
+ deployment_id TEXT NOT NULL,
83
+ step_name TEXT NOT NULL,
84
+ status TEXT NOT NULL,
85
+ started_at INTEGER,
86
+ finished_at INTEGER,
87
+ duration_ms INTEGER,
88
+ FOREIGN KEY (deployment_id) REFERENCES deployments(id) ON DELETE CASCADE
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS deployment_logs (
92
+ deployment_id TEXT PRIMARY KEY,
93
+ log_data TEXT NOT NULL,
94
+ FOREIGN KEY (deployment_id) REFERENCES deployments(id) ON DELETE CASCADE
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
98
+ id TEXT PRIMARY KEY,
99
+ created_at INTEGER NOT NULL
100
+ );
101
+ `);
102
+ }
103
+
104
+ // Project Repositories
105
+ export function addProject(project: Omit<Project, "created_at" | "updated_at">): Project {
106
+ const db = getDb();
107
+ const now = Date.now();
108
+ const fullProject = { ...project, created_at: now, updated_at: now };
109
+ const stmt = db.prepare(`
110
+ INSERT INTO projects (id, name, owner, repo, branch, target_type, target_host, target_path, install_cmd, build_cmd, restart_cmd, healthcheck_path, healthcheck_port, healthcheck_retries, healthcheck_interval_ms, healthcheck_timeout_ms, webhook_secret, created_at, updated_at)
111
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
112
+ ON CONFLICT(name) DO UPDATE SET
113
+ owner = excluded.owner,
114
+ repo = excluded.repo,
115
+ branch = excluded.branch,
116
+ target_type = excluded.target_type,
117
+ target_host = excluded.target_host,
118
+ target_path = excluded.target_path,
119
+ install_cmd = excluded.install_cmd,
120
+ build_cmd = excluded.build_cmd,
121
+ restart_cmd = excluded.restart_cmd,
122
+ healthcheck_path = excluded.healthcheck_path,
123
+ healthcheck_port = excluded.healthcheck_port,
124
+ healthcheck_retries = excluded.healthcheck_retries,
125
+ healthcheck_interval_ms = excluded.healthcheck_interval_ms,
126
+ healthcheck_timeout_ms = excluded.healthcheck_timeout_ms,
127
+ webhook_secret = excluded.webhook_secret,
128
+ updated_at = excluded.updated_at
129
+ `);
130
+ stmt.run(
131
+ fullProject.id,
132
+ fullProject.name,
133
+ fullProject.owner,
134
+ fullProject.repo,
135
+ fullProject.branch,
136
+ fullProject.target_type,
137
+ fullProject.target_host || null,
138
+ fullProject.target_path,
139
+ fullProject.install_cmd || null,
140
+ fullProject.build_cmd || null,
141
+ fullProject.restart_cmd || null,
142
+ fullProject.healthcheck_path || null,
143
+ fullProject.healthcheck_port || null,
144
+ fullProject.healthcheck_retries || null,
145
+ fullProject.healthcheck_interval_ms || null,
146
+ fullProject.healthcheck_timeout_ms || null,
147
+ fullProject.webhook_secret,
148
+ fullProject.created_at,
149
+ fullProject.updated_at
150
+ );
151
+ return fullProject;
152
+ }
153
+
154
+ export function getProject(idOrName: string): Project | null {
155
+ const db = getDb();
156
+ const stmt = db.prepare("SELECT * FROM projects WHERE id = ? OR name = ?");
157
+ const res = stmt.get(idOrName, idOrName) as Project | undefined;
158
+ return res || null;
159
+ }
160
+
161
+ export function getProjects(): Project[] {
162
+ const db = getDb();
163
+ const stmt = db.prepare("SELECT * FROM projects ORDER BY name ASC");
164
+ return stmt.all() as Project[];
165
+ }
166
+
167
+ export function removeProject(id: string): void {
168
+ const db = getDb();
169
+ const stmt = db.prepare("DELETE FROM projects WHERE id = ?");
170
+ stmt.run(id);
171
+ }
172
+
173
+ // Webhook Repositories
174
+ export function saveWebhook(webhook: Webhook): void {
175
+ const db = getDb();
176
+ const stmt = db.prepare(`
177
+ INSERT INTO webhooks (id, project_id, github_webhook_id, url, secret, active, created_at)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?)
179
+ ON CONFLICT(id) DO UPDATE SET
180
+ github_webhook_id = excluded.github_webhook_id,
181
+ url = excluded.url,
182
+ secret = excluded.secret,
183
+ active = excluded.active
184
+ `);
185
+ stmt.run(
186
+ webhook.id,
187
+ webhook.project_id,
188
+ webhook.github_webhook_id,
189
+ webhook.url,
190
+ webhook.secret,
191
+ webhook.active ? 1 : 0,
192
+ webhook.created_at
193
+ );
194
+ }
195
+
196
+ export function getWebhookByProjectId(projectId: string): Webhook | null {
197
+ const db = getDb();
198
+ const stmt = db.prepare("SELECT * FROM webhooks WHERE project_id = ?");
199
+ const res = stmt.get(projectId) as any;
200
+ if (!res) return null;
201
+ return {
202
+ ...res,
203
+ active: res.active === 1,
204
+ };
205
+ }
206
+
207
+ // Deployment Repositories
208
+ export function createDeployment(deployment: Omit<Deployment, "created_at">): Deployment {
209
+ const db = getDb();
210
+ const now = Date.now();
211
+ const fullDeployment = { ...deployment, created_at: now };
212
+ const stmt = db.prepare(`
213
+ INSERT INTO deployments (id, project_id, branch, commit_sha, commit_message, author, status, started_at, finished_at, total_duration_ms, rollback_of_id, created_at)
214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
215
+ `);
216
+ stmt.run(
217
+ fullDeployment.id,
218
+ fullDeployment.project_id,
219
+ fullDeployment.branch,
220
+ fullDeployment.commit_sha || null,
221
+ fullDeployment.commit_message || null,
222
+ fullDeployment.author || null,
223
+ fullDeployment.status,
224
+ fullDeployment.started_at || null,
225
+ fullDeployment.finished_at || null,
226
+ fullDeployment.total_duration_ms || null,
227
+ fullDeployment.rollback_of_id || null,
228
+ fullDeployment.created_at
229
+ );
230
+
231
+ // Initialize empty logs for this deployment
232
+ const logStmt = db.prepare("INSERT INTO deployment_logs (deployment_id, log_data) VALUES (?, ?)");
233
+ logStmt.run(fullDeployment.id, "");
234
+
235
+ return fullDeployment;
236
+ }
237
+
238
+ export function updateDeploymentStatus(
239
+ id: string,
240
+ status: DeploymentStatus,
241
+ fields: Partial<Deployment> = {}
242
+ ): void {
243
+ const db = getDb();
244
+ const updates: string[] = ["status = ?"];
245
+ const params: any[] = [status];
246
+
247
+ for (const [key, val] of Object.entries(fields)) {
248
+ if (key !== "id" && key !== "status") {
249
+ updates.push(`${key} = ?`);
250
+ params.push(val);
251
+ }
252
+ }
253
+
254
+ params.push(id);
255
+ const stmt = db.prepare(`UPDATE deployments SET ${updates.join(", ")} WHERE id = ?`);
256
+ stmt.run(...params);
257
+ }
258
+
259
+ export function getDeployment(id: string): Deployment | null {
260
+ const db = getDb();
261
+ const stmt = db.prepare("SELECT * FROM deployments WHERE id = ?");
262
+ const res = stmt.get(id) as Deployment | undefined;
263
+ return res || null;
264
+ }
265
+
266
+ export function getDeployments(projectId?: string, limit?: number): Deployment[] {
267
+ const db = getDb();
268
+ let query = "SELECT * FROM deployments";
269
+ const params: any[] = [];
270
+
271
+ if (projectId) {
272
+ query += " WHERE project_id = ?";
273
+ params.push(projectId);
274
+ }
275
+
276
+ query += " ORDER BY created_at DESC";
277
+
278
+ if (limit) {
279
+ query += " LIMIT ?";
280
+ params.push(limit);
281
+ }
282
+
283
+ const stmt = db.prepare(query);
284
+ return stmt.all(...params) as Deployment[];
285
+ }
286
+
287
+ export function getQueuedDeployments(projectId: string): Deployment[] {
288
+ const db = getDb();
289
+ const stmt = db.prepare("SELECT * FROM deployments WHERE project_id = ? AND status = 'QUEUED' ORDER BY created_at ASC");
290
+ return stmt.all(projectId) as Deployment[];
291
+ }
292
+
293
+ export function getRunningDeployment(projectId: string): Deployment | null {
294
+ const db = getDb();
295
+ const stmt = db.prepare("SELECT * FROM deployments WHERE project_id = ? AND status = 'RUNNING'");
296
+ const res = stmt.get(projectId) as Deployment | undefined;
297
+ return res || null;
298
+ }
299
+
300
+ // Steps Repositories
301
+ export function createDeploymentStep(step: DeploymentStep): void {
302
+ const db = getDb();
303
+ const stmt = db.prepare(`
304
+ INSERT INTO deployment_steps (id, deployment_id, step_name, status, started_at, finished_at, duration_ms)
305
+ VALUES (?, ?, ?, ?, ?, ?, ?)
306
+ `);
307
+ stmt.run(step.id, step.deployment_id, step.step_name, step.status, step.started_at, step.finished_at, step.duration_ms);
308
+ }
309
+
310
+ export function updateDeploymentStep(id: string, fields: Partial<DeploymentStep>): void {
311
+ const db = getDb();
312
+ const updates: string[] = [];
313
+ const params: any[] = [];
314
+
315
+ for (const [key, val] of Object.entries(fields)) {
316
+ if (key !== "id") {
317
+ updates.push(`${key} = ?`);
318
+ params.push(val);
319
+ }
320
+ }
321
+
322
+ params.push(id);
323
+ const stmt = db.prepare(`UPDATE deployment_steps SET ${updates.join(", ")} WHERE id = ?`);
324
+ stmt.run(...params);
325
+ }
326
+
327
+ export function getDeploymentSteps(deploymentId: string): DeploymentStep[] {
328
+ const db = getDb();
329
+ const stmt = db.prepare("SELECT * FROM deployment_steps WHERE deployment_id = ? ORDER BY started_at ASC");
330
+ return stmt.all(deploymentId) as DeploymentStep[];
331
+ }
332
+
333
+ // Log Repositories
334
+ export function appendDeploymentLog(deploymentId: string, text: string): void {
335
+ const db = getDb();
336
+ const stmt = db.prepare(`
337
+ UPDATE deployment_logs
338
+ SET log_data = log_data || ?
339
+ WHERE deployment_id = ?
340
+ `);
341
+ stmt.run(text, deploymentId);
342
+ }
343
+
344
+ export function getDeploymentLog(deploymentId: string): string | null {
345
+ const db = getDb();
346
+ const stmt = db.prepare("SELECT log_data FROM deployment_logs WHERE deployment_id = ?");
347
+ const res = stmt.get(deploymentId) as { log_data: string } | undefined;
348
+ return res ? res.log_data : null;
349
+ }
350
+
351
+ // Stats Repository
352
+ export interface ProjectStats {
353
+ totalDeployments: number;
354
+ successRate: number;
355
+ avgDeployTimeMs: number;
356
+ avgBuildTimeMs: number;
357
+ slowestDeployMs: number;
358
+ fastestDeployMs: number;
359
+ }
360
+
361
+ export function getStats(projectId?: string): ProjectStats {
362
+ const db = getDb();
363
+ const filter = projectId ? "WHERE project_id = ?" : "WHERE 1=1";
364
+ const params = projectId ? [projectId] : [];
365
+
366
+ const totalRow = db.prepare(`SELECT count(*) as count FROM deployments ${filter}`).get(...params) as { count: number };
367
+ const total = totalRow.count;
368
+
369
+ if (total === 0) {
370
+ return {
371
+ totalDeployments: 0,
372
+ successRate: 0,
373
+ avgDeployTimeMs: 0,
374
+ avgBuildTimeMs: 0,
375
+ slowestDeployMs: 0,
376
+ fastestDeployMs: 0,
377
+ };
378
+ }
379
+
380
+ const successRow = db.prepare(`SELECT count(*) as count FROM deployments ${filter} AND status = 'SUCCESS'`).get(...params) as { count: number };
381
+ const successRate = total > 0 ? (successRow.count / total) * 100 : 0;
382
+
383
+ const durationRow = db.prepare(`
384
+ SELECT
385
+ avg(total_duration_ms) as avg_duration,
386
+ max(total_duration_ms) as max_duration,
387
+ min(total_duration_ms) as min_duration
388
+ FROM deployments
389
+ ${filter} AND status = 'SUCCESS'
390
+ `).get(...params) as { avg_duration: number | null; max_duration: number | null; min_duration: number | null };
391
+
392
+ const buildDurationRow = db.prepare(`
393
+ SELECT avg(ds.duration_ms) as avg_build
394
+ FROM deployment_steps ds
395
+ JOIN deployments d ON ds.deployment_id = d.id
396
+ ${projectId ? "WHERE d.project_id = ?" : "WHERE 1=1"} AND ds.step_name = 'build' AND ds.status = 'SUCCESS'
397
+ `).get(projectId ? [projectId] : []) as { avg_build: number | null };
398
+
399
+ return {
400
+ totalDeployments: total,
401
+ successRate: parseFloat(successRate.toFixed(1)),
402
+ avgDeployTimeMs: Math.round(durationRow.avg_duration || 0),
403
+ avgBuildTimeMs: Math.round(buildDurationRow.avg_build || 0),
404
+ slowestDeployMs: durationRow.max_duration || 0,
405
+ fastestDeployMs: durationRow.min_duration || 0,
406
+ };
407
+ }
408
+
409
+ export function isWebhookDeliveryProcessed(id: string): boolean {
410
+ const db = getDb();
411
+ const stmt = db.prepare("SELECT count(*) as count FROM webhook_deliveries WHERE id = ?");
412
+ const res = stmt.get(id) as { count: number };
413
+ return res.count > 0;
414
+ }
415
+
416
+ export function recordWebhookDelivery(id: string): void {
417
+ const db = getDb();
418
+ const now = Date.now();
419
+ const insertStmt = db.prepare("INSERT OR IGNORE INTO webhook_deliveries (id, created_at) VALUES (?, ?)");
420
+ insertStmt.run(id, now);
421
+
422
+ // Prune older than 24 hours (86,400,000 ms)
423
+ const pruneStmt = db.prepare("DELETE FROM webhook_deliveries WHERE created_at < ?");
424
+ pruneStmt.run(now - 86400000);
425
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,477 @@
1
+ import { execa } from "execa";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { nanoid } from "nanoid";
5
+ import {
6
+ Project,
7
+ Deployment,
8
+ DeploymentStep,
9
+ StepStatus,
10
+ DeploymentStatus,
11
+ } from "gitship-shared";
12
+ import {
13
+ updateDeploymentStatus,
14
+ createDeploymentStep,
15
+ updateDeploymentStep,
16
+ appendDeploymentLog,
17
+ getDeployment,
18
+ } from "./db.js";
19
+ import { BUILDS_DIR, readAuthConfig } from "./paths.js";
20
+ import { activeProcesses } from "./queue.js";
21
+
22
+ interface StepResult {
23
+ status: StepStatus;
24
+ durationMs: number;
25
+ }
26
+
27
+ export async function runDeploymentPipeline(
28
+ project: Project,
29
+ deployment: Deployment
30
+ ): Promise<void> {
31
+ const auth = readAuthConfig();
32
+ const token = auth.github_token;
33
+
34
+ if (!token) {
35
+ const errorMsg = "Error: GitHub authentication token is missing. Please run 'deploykit auth github'.\n";
36
+ appendDeploymentLog(deployment.id, errorMsg);
37
+ updateDeploymentStatus(deployment.id, "FAILED", {
38
+ finished_at: Date.now(),
39
+ total_duration_ms: 0,
40
+ });
41
+ return;
42
+ }
43
+
44
+ // Prevent shell command injection via branch or commit SHA inputs
45
+ const safeBranchRegex = /^[a-zA-Z0-9/_.-]+$/;
46
+ if (!safeBranchRegex.test(deployment.branch)) {
47
+ const errorMsg = `Error: Unsafe branch name format: "${deployment.branch}". Deployment aborted.\n`;
48
+ appendDeploymentLog(deployment.id, errorMsg);
49
+ updateDeploymentStatus(deployment.id, "FAILED", {
50
+ finished_at: Date.now(),
51
+ total_duration_ms: 0,
52
+ });
53
+ return;
54
+ }
55
+
56
+ if (deployment.commit_sha && !/^[a-zA-Z0-9]+$/.test(deployment.commit_sha)) {
57
+ const errorMsg = `Error: Unsafe commit SHA format: "${deployment.commit_sha}". Deployment aborted.\n`;
58
+ appendDeploymentLog(deployment.id, errorMsg);
59
+ updateDeploymentStatus(deployment.id, "FAILED", {
60
+ finished_at: Date.now(),
61
+ total_duration_ms: 0,
62
+ });
63
+ return;
64
+ }
65
+
66
+ const startTime = Date.now();
67
+ updateDeploymentStatus(deployment.id, "RUNNING", { started_at: startTime });
68
+
69
+ appendDeploymentLog(
70
+ deployment.id,
71
+ `=== Starting deployment ${deployment.id} for project "${project.name}" ===\n` +
72
+ `Branch: ${deployment.branch}\n` +
73
+ `Commit: ${deployment.commit_sha || "latest"}\n` +
74
+ `Author: ${deployment.author || "system"}\n` +
75
+ `Target: ${project.target_type} (${
76
+ project.target_type === "ssh"
77
+ ? `${project.target_host}:${project.target_path}`
78
+ : project.target_path
79
+ })\n\n`
80
+ );
81
+
82
+ let status: DeploymentStatus = "SUCCESS";
83
+
84
+ try {
85
+ // Step 1: Clone or Pull
86
+ const cloneSuccess = await runStep(
87
+ deployment.id,
88
+ "clone",
89
+ async (log) => {
90
+ // Double check cancellation
91
+ checkCancelled(deployment.id);
92
+
93
+ const repoUrl = `https://${token}@github.com/${project.owner}/${project.repo}.git`;
94
+ if (project.target_type === "local") {
95
+ const projectPath = path.join(BUILDS_DIR, project.name);
96
+ if (!fs.existsSync(projectPath)) {
97
+ log(`Cloning repository into local path ${projectPath}...\n`);
98
+ await execLocal(
99
+ deployment.id,
100
+ "git",
101
+ ["clone", "--branch", deployment.branch, repoUrl, "."],
102
+ BUILDS_DIR,
103
+ project.name, // create dir
104
+ log
105
+ );
106
+ } else {
107
+ log(`Repository directory exists. Fetching latest changes...\n`);
108
+ await execLocal(deployment.id, "git", ["fetch", "origin"], projectPath, undefined, log);
109
+
110
+ const checkoutTarget = deployment.commit_sha || `origin/${deployment.branch}`;
111
+ log(`Checking out target: ${checkoutTarget}...\n`);
112
+ await execLocal(deployment.id, "git", ["checkout", "-B", deployment.branch], projectPath, undefined, log);
113
+ await execLocal(deployment.id, "git", ["reset", "--hard", checkoutTarget], projectPath, undefined, log);
114
+ }
115
+ } else {
116
+ // SSH Target
117
+ const port = project.target_host?.split(":")[1] || "22";
118
+ const host = project.target_host?.split(":")[0] || "";
119
+
120
+ log(`Ensuring target path ${project.target_path} exists on remote server...\n`);
121
+ await execSSH(deployment.id, host, port, `mkdir -p ${project.target_path}`, log);
122
+
123
+ // Check if git repository exists on the remote target path
124
+ log(`Checking if repository is already initialized on remote...\n`);
125
+ let isInitialized = false;
126
+ try {
127
+ await execSSH(deployment.id, host, port, `cd ${project.target_path} && git rev-parse --is-inside-work-tree`, () => {});
128
+ isInitialized = true;
129
+ } catch {
130
+ isInitialized = false;
131
+ }
132
+
133
+ checkCancelled(deployment.id);
134
+
135
+ if (!isInitialized) {
136
+ log(`Cloning repository on remote server into ${project.target_path}...\n`);
137
+ await execSSH(deployment.id, host, port, `git clone --branch ${deployment.branch} ${repoUrl} ${project.target_path}`, log);
138
+ } else {
139
+ log(`Fetching updates on remote server...\n`);
140
+ await execSSH(deployment.id, host, port, `cd ${project.target_path} && git fetch origin`, log);
141
+
142
+ const checkoutTarget = deployment.commit_sha || `origin/${deployment.branch}`;
143
+ log(`Checking out target on remote server: ${checkoutTarget}...\n`);
144
+ await execSSH(deployment.id, host, port, `cd ${project.target_path} && git checkout -B ${deployment.branch} && git reset --hard ${checkoutTarget}`, log);
145
+ }
146
+ }
147
+ }
148
+ );
149
+
150
+ if (!cloneSuccess) throw new Error("Step 'clone' failed.");
151
+
152
+ // Step 2: Install dependencies
153
+ if (project.install_cmd) {
154
+ const installSuccess = await runStep(
155
+ deployment.id,
156
+ "install",
157
+ async (log) => {
158
+ checkCancelled(deployment.id);
159
+ log(`Running install command: ${project.install_cmd}\n`);
160
+ if (project.target_type === "local") {
161
+ const projectPath = path.join(BUILDS_DIR, project.name);
162
+ await execShell(deployment.id, project.install_cmd!, projectPath, log);
163
+ } else {
164
+ const port = project.target_host?.split(":")[1] || "22";
165
+ const host = project.target_host?.split(":")[0] || "";
166
+ await execSSH(deployment.id, host, port, `cd ${project.target_path} && ${project.install_cmd}`, log);
167
+ }
168
+ }
169
+ );
170
+ if (!installSuccess) throw new Error("Step 'install' failed.");
171
+ } else {
172
+ skipStep(deployment.id, "install");
173
+ }
174
+
175
+ // Step 3: Build project
176
+ if (project.build_cmd) {
177
+ const buildSuccess = await runStep(
178
+ deployment.id,
179
+ "build",
180
+ async (log) => {
181
+ checkCancelled(deployment.id);
182
+ log(`Running build command: ${project.build_cmd}\n`);
183
+ if (project.target_type === "local") {
184
+ const projectPath = path.join(BUILDS_DIR, project.name);
185
+ await execShell(deployment.id, project.build_cmd!, projectPath, log);
186
+ } else {
187
+ const port = project.target_host?.split(":")[1] || "22";
188
+ const host = project.target_host?.split(":")[0] || "";
189
+ await execSSH(deployment.id, host, port, `cd ${project.target_path} && ${project.build_cmd}`, log);
190
+ }
191
+ }
192
+ );
193
+ if (!buildSuccess) throw new Error("Step 'build' failed.");
194
+ } else {
195
+ skipStep(deployment.id, "build");
196
+ }
197
+
198
+ // Step 4: Restart application
199
+ if (project.restart_cmd) {
200
+ const restartSuccess = await runStep(
201
+ deployment.id,
202
+ "restart",
203
+ async (log) => {
204
+ checkCancelled(deployment.id);
205
+ log(`Running restart command: ${project.restart_cmd}\n`);
206
+ if (project.target_type === "local") {
207
+ const projectPath = path.join(BUILDS_DIR, project.name);
208
+ await execShell(deployment.id, project.restart_cmd!, projectPath, log);
209
+ } else {
210
+ const port = project.target_host?.split(":")[1] || "22";
211
+ const host = project.target_host?.split(":")[0] || "";
212
+ await execSSH(deployment.id, host, port, `cd ${project.target_path} && ${project.restart_cmd}`, log);
213
+ }
214
+ }
215
+ );
216
+ if (!restartSuccess) throw new Error("Step 'restart' failed.");
217
+ } else {
218
+ skipStep(deployment.id, "restart");
219
+ }
220
+
221
+ // Step 5: Health Check
222
+ if (project.healthcheck_path) {
223
+ const healthSuccess = await runStep(
224
+ deployment.id,
225
+ "healthcheck",
226
+ async (log) => {
227
+ checkCancelled(deployment.id);
228
+ const pathStr = project.healthcheck_path || "/";
229
+ const portNum = project.healthcheck_port;
230
+ const retries = project.healthcheck_retries || 5;
231
+ const interval = project.healthcheck_interval_ms || 1000;
232
+ const timeout = project.healthcheck_timeout_ms || 2000;
233
+
234
+ const url = portNum ? `http://localhost:${portNum}${pathStr}` : `http://localhost${pathStr}`;
235
+ log(`Performing health check on: ${url} (Retries: ${retries}, Interval: ${interval}ms)\n`);
236
+
237
+ let lastError: any = null;
238
+ for (let i = 1; i <= retries; i++) {
239
+ checkCancelled(deployment.id);
240
+ log(`Attempt ${i} of ${retries}... `);
241
+
242
+ try {
243
+ if (project.target_type === "local") {
244
+ const controller = new AbortController();
245
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
246
+ const res = await fetch(url, { signal: controller.signal });
247
+ clearTimeout(timeoutId);
248
+ if (res.ok) {
249
+ log(`OK (${res.status})\n`);
250
+ return;
251
+ } else {
252
+ throw new Error(`HTTP status ${res.status}`);
253
+ }
254
+ } else {
255
+ // SSH Target
256
+ const sshPort = project.target_host?.split(":")[1] || "22";
257
+ const host = project.target_host?.split(":")[0] || "";
258
+ const maxTimeSecs = Math.max(1, Math.round(timeout / 1000));
259
+ await execSSH(
260
+ deployment.id,
261
+ host,
262
+ sshPort,
263
+ `curl -s -f --max-time ${maxTimeSecs} ${url}`,
264
+ () => {}
265
+ );
266
+ log(`OK (exit code 0)\n`);
267
+ return;
268
+ }
269
+ } catch (err: any) {
270
+ lastError = err;
271
+ log(`FAILED: ${err.message || err}\n`);
272
+ if (i < retries) {
273
+ await new Promise((r) => setTimeout(r, interval));
274
+ }
275
+ }
276
+ }
277
+ throw new Error(`Health check failed after ${retries} retries. Last error: ${lastError?.message || lastError}`);
278
+ }
279
+ );
280
+ if (!healthSuccess) throw new Error("Step 'healthcheck' failed.");
281
+ } else {
282
+ skipStep(deployment.id, "healthcheck");
283
+ }
284
+
285
+ // Check if cancelled at the very end
286
+ const currentDep = getDeployment(deployment.id);
287
+ if (currentDep?.status === "CANCELLED") {
288
+ status = "CANCELLED";
289
+ } else {
290
+ appendDeploymentLog(deployment.id, `\n=== Deployment SUCCESS ===\n`);
291
+ }
292
+ } catch (err: any) {
293
+ const currentDep = getDeployment(deployment.id);
294
+ if (currentDep?.status === "CANCELLED") {
295
+ status = "CANCELLED";
296
+ appendDeploymentLog(deployment.id, `\n=== Deployment CANCELLED ===\n`);
297
+ } else {
298
+ status = "FAILED";
299
+ appendDeploymentLog(deployment.id, `\n=== Deployment FAILED ===\nReason: ${err.message || err}\n`);
300
+ }
301
+ } finally {
302
+ const endTime = Date.now();
303
+ const duration = endTime - startTime;
304
+ updateDeploymentStatus(deployment.id, status, {
305
+ finished_at: endTime,
306
+ total_duration_ms: duration,
307
+ });
308
+ }
309
+ }
310
+
311
+ function checkCancelled(deploymentId: string) {
312
+ const currentDep = getDeployment(deploymentId);
313
+ if (currentDep?.status === "CANCELLED") {
314
+ throw new Error("Deployment cancelled by user.");
315
+ }
316
+ }
317
+
318
+ // Execa locally helper
319
+ async function execLocal(
320
+ deploymentId: string,
321
+ cmd: string,
322
+ args: string[],
323
+ cwd: string,
324
+ createDir?: string,
325
+ log?: (chunk: string) => void
326
+ ): Promise<void> {
327
+ const finalCwd = createDir ? cwd : cwd;
328
+ if (createDir) {
329
+ fs.mkdirSync(path.join(cwd, createDir), { recursive: true });
330
+ }
331
+
332
+ const proc = execa(cmd, args, {
333
+ cwd: createDir ? path.join(cwd, createDir) : finalCwd,
334
+ all: true,
335
+ });
336
+
337
+ activeProcesses.set(deploymentId, proc);
338
+
339
+ if (log && proc.all) {
340
+ proc.all.on("data", (chunk) => {
341
+ log(chunk.toString());
342
+ });
343
+ }
344
+
345
+ try {
346
+ await proc;
347
+ } finally {
348
+ activeProcesses.delete(deploymentId);
349
+ }
350
+ }
351
+
352
+ // Execa locally with shell helper
353
+ async function execShell(
354
+ deploymentId: string,
355
+ cmd: string,
356
+ cwd: string,
357
+ log: (chunk: string) => void
358
+ ): Promise<void> {
359
+ const proc = execa({ shell: true, cwd, all: true })`${cmd}`;
360
+
361
+ activeProcesses.set(deploymentId, proc);
362
+
363
+ if (proc.all) {
364
+ proc.all.on("data", (chunk) => {
365
+ log(chunk.toString());
366
+ });
367
+ }
368
+
369
+ try {
370
+ await proc;
371
+ } finally {
372
+ activeProcesses.delete(deploymentId);
373
+ }
374
+ }
375
+
376
+ // Execa SSH command helper
377
+ async function execSSH(
378
+ deploymentId: string,
379
+ host: string,
380
+ port: string,
381
+ cmd: string,
382
+ log: (chunk: string) => void
383
+ ): Promise<void> {
384
+ // Execute via SSH CLI
385
+ const proc = execa("ssh", ["-o", "StrictHostKeyChecking=no", "-p", port, host, cmd], {
386
+ all: true,
387
+ });
388
+
389
+ activeProcesses.set(deploymentId, proc);
390
+
391
+ if (proc.all) {
392
+ proc.all.on("data", (chunk) => {
393
+ log(chunk.toString());
394
+ });
395
+ }
396
+
397
+ try {
398
+ await proc;
399
+ } finally {
400
+ activeProcesses.delete(deploymentId);
401
+ }
402
+ }
403
+
404
+ async function runStep(
405
+ deploymentId: string,
406
+ name: "clone" | "install" | "build" | "restart" | "healthcheck",
407
+ fn: (log: (text: string) => void) => Promise<void>
408
+ ): Promise<boolean> {
409
+ const stepId = nanoid();
410
+ const startTime = Date.now();
411
+
412
+ const step: DeploymentStep = {
413
+ id: stepId,
414
+ deployment_id: deploymentId,
415
+ step_name: name,
416
+ status: "RUNNING",
417
+ started_at: startTime,
418
+ finished_at: null,
419
+ duration_ms: null,
420
+ };
421
+
422
+ createDeploymentStep(step);
423
+ appendDeploymentLog(deploymentId, `[Step: ${name}] Started...\n`);
424
+
425
+ try {
426
+ await fn((text: string) => {
427
+ appendDeploymentLog(deploymentId, text);
428
+ });
429
+
430
+ // Check cancellation again
431
+ checkCancelled(deploymentId);
432
+
433
+ const endTime = Date.now();
434
+ const duration = endTime - startTime;
435
+
436
+ updateDeploymentStep(stepId, {
437
+ status: "SUCCESS",
438
+ finished_at: endTime,
439
+ duration_ms: duration,
440
+ });
441
+ appendDeploymentLog(deploymentId, `[Step: ${name}] Completed successfully (${Math.round(duration / 1000)}s).\n\n`);
442
+ return true;
443
+ } catch (err: any) {
444
+ const endTime = Date.now();
445
+ const duration = endTime - startTime;
446
+
447
+ const currentDep = getDeployment(deploymentId);
448
+ const isCancelled = currentDep?.status === "CANCELLED";
449
+
450
+ updateDeploymentStep(stepId, {
451
+ status: isCancelled ? "FAILED" : "FAILED", // or can mark as cancelled if step structure supported it, but FAILED is robust
452
+ finished_at: endTime,
453
+ duration_ms: duration,
454
+ });
455
+
456
+ appendDeploymentLog(deploymentId, `[Step: ${name}] ${isCancelled ? 'Cancelled' : 'Failed'} (${Math.round(duration / 1000)}s).\nError detail: ${err.message || err}\n\n`);
457
+ return false;
458
+ }
459
+ }
460
+
461
+ function skipStep(
462
+ deploymentId: string,
463
+ name: "clone" | "install" | "build" | "restart" | "healthcheck"
464
+ ) {
465
+ const stepId = nanoid();
466
+ const step: DeploymentStep = {
467
+ id: stepId,
468
+ deployment_id: deploymentId,
469
+ step_name: name,
470
+ status: "SUCCESS",
471
+ started_at: Date.now(),
472
+ finished_at: Date.now(),
473
+ duration_ms: 0,
474
+ };
475
+ createDeploymentStep(step);
476
+ appendDeploymentLog(deploymentId, `[Step: ${name}] Skipped (not configured).\n\n`);
477
+ }
package/src/github.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { Octokit } from "octokit";
2
+
3
+ export function getOctokit(token: string): Octokit {
4
+ return new Octokit({ auth: token });
5
+ }
6
+
7
+ export async function validateToken(token: string): Promise<{ username: string; name: string | null }> {
8
+ const octokit = getOctokit(token);
9
+ const { data } = await octokit.rest.users.getAuthenticated();
10
+ return {
11
+ username: data.login,
12
+ name: data.name || null,
13
+ };
14
+ }
15
+
16
+ export interface GitRepo {
17
+ owner: string;
18
+ name: string;
19
+ fullName: string;
20
+ url: string;
21
+ }
22
+
23
+ export async function listRepositories(token: string): Promise<GitRepo[]> {
24
+ const octokit = getOctokit(token);
25
+ const repos: GitRepo[] = [];
26
+
27
+ // Fetch up to 100 repositories. In a real-world scenario we could paginate further,
28
+ // but for an MVP 100 is typically enough or we can list top repos.
29
+ const response = await octokit.rest.repos.listForAuthenticatedUser({
30
+ per_page: 100,
31
+ sort: "updated",
32
+ });
33
+
34
+ for (const repo of response.data) {
35
+ repos.push({
36
+ owner: repo.owner.login,
37
+ name: repo.name,
38
+ fullName: repo.full_name,
39
+ url: repo.clone_url,
40
+ });
41
+ }
42
+
43
+ return repos;
44
+ }
45
+
46
+ export async function listBranches(token: string, owner: string, repo: string): Promise<string[]> {
47
+ const octokit = getOctokit(token);
48
+ const response = await octokit.rest.repos.listBranches({
49
+ owner,
50
+ repo,
51
+ per_page: 100,
52
+ });
53
+ return response.data.map(b => b.name);
54
+ }
55
+
56
+ export interface WebhookConfig {
57
+ owner: string;
58
+ repo: string;
59
+ webhookUrl: string;
60
+ secret: string;
61
+ }
62
+
63
+ export async function setupWebhook(
64
+ token: string,
65
+ config: WebhookConfig
66
+ ): Promise<{ id: number; url: string }> {
67
+ const octokit = getOctokit(config.webhookUrl ? token : token); // ensure unused warning bypass
68
+
69
+ // 1. List existing webhooks to find a duplicate
70
+ const webhooks = await octokit.rest.repos.listWebhooks({
71
+ owner: config.owner,
72
+ repo: config.repo,
73
+ per_page: 100,
74
+ });
75
+
76
+ const existing = webhooks.data.find(h => h.config.url === config.webhookUrl);
77
+
78
+ if (existing) {
79
+ // 2. Update webhook
80
+ await octokit.rest.repos.updateWebhook({
81
+ owner: config.owner,
82
+ repo: config.repo,
83
+ hook_id: existing.id,
84
+ config: {
85
+ url: config.webhookUrl,
86
+ content_type: "json",
87
+ secret: config.secret,
88
+ insecure_ssl: "1",
89
+ },
90
+ events: ["push"],
91
+ active: true,
92
+ });
93
+ return { id: existing.id, url: config.webhookUrl };
94
+ } else {
95
+ // 3. Create webhook
96
+ const response = await octokit.rest.repos.createWebhook({
97
+ owner: config.owner,
98
+ repo: config.repo,
99
+ name: "web",
100
+ active: true,
101
+ events: ["push"],
102
+ config: {
103
+ url: config.webhookUrl,
104
+ content_type: "json",
105
+ secret: config.secret,
106
+ insecure_ssl: "1",
107
+ },
108
+ });
109
+ return { id: response.data.id, url: config.webhookUrl };
110
+ }
111
+ }
112
+
113
+ import { execa } from "execa";
114
+
115
+ export async function openBrowser(url: string): Promise<void> {
116
+ const platform = process.platform;
117
+ if (platform === "darwin") {
118
+ await execa("open", [url]);
119
+ } else if (platform === "win32") {
120
+ await execa("cmd", ["/c", "start", url]);
121
+ } else {
122
+ await execa("xdg-open", [url]);
123
+ }
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./paths.js";
2
+ export * from "./db.js";
3
+ export * from "./github.js";
4
+ export * from "./queue.js";
5
+ export * from "./engine.js";
package/src/paths.ts ADDED
@@ -0,0 +1,35 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ import fs from "fs";
4
+
5
+ export const DEPLOYKIT_DIR = process.env.DEPLOYKIT_DIR || path.join(os.homedir(), ".deploykit");
6
+ export const CONFIG_PATH = path.join(DEPLOYKIT_DIR, "config.json");
7
+ export const DB_PATH = path.join(DEPLOYKIT_DIR, "deploykit.db");
8
+ export const BUILDS_DIR = path.join(DEPLOYKIT_DIR, "builds");
9
+
10
+ export function ensureDirsExist() {
11
+ if (!fs.existsSync(DEPLOYKIT_DIR)) {
12
+ fs.mkdirSync(DEPLOYKIT_DIR, { recursive: true });
13
+ }
14
+ if (!fs.existsSync(BUILDS_DIR)) {
15
+ fs.mkdirSync(BUILDS_DIR, { recursive: true });
16
+ }
17
+ }
18
+
19
+ export function readAuthConfig(): { github_token?: string; github_username?: string } {
20
+ ensureDirsExist();
21
+ if (!fs.existsSync(CONFIG_PATH)) {
22
+ return {};
23
+ }
24
+ try {
25
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ export function writeAuthConfig(config: { github_token: string; github_username?: string }) {
33
+ ensureDirsExist();
34
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
35
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { nanoid } from "nanoid";
2
+ import { Deployment, DeploymentStatus } from "gitship-shared";
3
+ import {
4
+ createDeployment,
5
+ getProject,
6
+ getQueuedDeployments,
7
+ getRunningDeployment,
8
+ updateDeploymentStatus,
9
+ getDeployment,
10
+ appendDeploymentLog,
11
+ } from "./db.js";
12
+ import { runDeploymentPipeline } from "./engine.js";
13
+
14
+ // Global map to track active execa processes for cancelling running tasks
15
+ export const activeProcesses = new Map<string, { kill: () => void }>();
16
+
17
+ export async function enqueueDeployment(
18
+ projectId: string,
19
+ branch: string,
20
+ commitSha: string | null,
21
+ commitMessage: string | null,
22
+ author: string | null,
23
+ rollbackOfId: string | null = null
24
+ ): Promise<Deployment> {
25
+ const deploymentId = `dep_${nanoid(10)}`;
26
+ const deployment = createDeployment({
27
+ id: deploymentId,
28
+ project_id: projectId,
29
+ branch,
30
+ commit_sha: commitSha,
31
+ commit_message: commitMessage,
32
+ author,
33
+ status: "QUEUED",
34
+ started_at: null,
35
+ finished_at: null,
36
+ total_duration_ms: null,
37
+ rollback_of_id: rollbackOfId,
38
+ });
39
+
40
+ // Trigger queue processing asynchronously
41
+ processQueue(projectId).catch(err => {
42
+ console.error(`Error processing queue for project ${projectId}:`, err);
43
+ });
44
+
45
+ return deployment;
46
+ }
47
+
48
+ export async function processQueue(projectId: string): Promise<void> {
49
+ // 1. Check if there is already a running deployment for this project
50
+ const running = getRunningDeployment(projectId);
51
+ if (running) {
52
+ // A deployment is already running, wait for it to finish.
53
+ // It will trigger processQueue again upon completion.
54
+ return;
55
+ }
56
+
57
+ // 2. Get the next queued deployment
58
+ const queued = getQueuedDeployments(projectId);
59
+ if (queued.length === 0) {
60
+ return;
61
+ }
62
+
63
+ const nextDeployment = queued[0];
64
+ const project = getProject(projectId);
65
+
66
+ if (!project) {
67
+ console.error(`Project ${projectId} not found for deployment ${nextDeployment.id}`);
68
+ updateDeploymentStatus(nextDeployment.id, "FAILED", {
69
+ finished_at: Date.now(),
70
+ total_duration_ms: 0,
71
+ });
72
+ return;
73
+ }
74
+
75
+ try {
76
+ // Execute the deployment pipeline
77
+ await runDeploymentPipeline(project, nextDeployment);
78
+ } catch (err) {
79
+ console.error(`Pipeline exception for deployment ${nextDeployment.id}:`, err);
80
+ } finally {
81
+ // Process the next item in the queue
82
+ processQueue(projectId).catch(err => {
83
+ console.error(`Error in queue recursion for project ${projectId}:`, err);
84
+ });
85
+ }
86
+ }
87
+
88
+ export function cancelDeployment(id: string): { success: boolean; message: string } {
89
+ const dep = getDeployment(id);
90
+ if (!dep) {
91
+ return { success: false, message: "Deployment not found" };
92
+ }
93
+
94
+ if (dep.status === "SUCCESS" || dep.status === "FAILED" || dep.status === "CANCELLED") {
95
+ return { success: false, message: `Deployment already finished with status: ${dep.status}` };
96
+ }
97
+
98
+ if (dep.status === "QUEUED") {
99
+ updateDeploymentStatus(id, "CANCELLED", {
100
+ finished_at: Date.now(),
101
+ total_duration_ms: 0,
102
+ });
103
+ appendDeploymentLog(id, "\n=== Deployment CANCELLED while in queue ===\n");
104
+ return { success: true, message: "Queued deployment cancelled successfully" };
105
+ }
106
+
107
+ if (dep.status === "RUNNING") {
108
+ // Set status to CANCELLED in DB first
109
+ updateDeploymentStatus(id, "CANCELLED", {
110
+ finished_at: Date.now(),
111
+ });
112
+ appendDeploymentLog(id, "\n=== Deployment CANCELLATION REQUESTED ===\n");
113
+
114
+ // Check if we have an active process to kill
115
+ const proc = activeProcesses.get(id);
116
+ if (proc) {
117
+ try {
118
+ proc.kill();
119
+ activeProcesses.delete(id);
120
+ return { success: true, message: "Running deployment cancelled and terminated" };
121
+ } catch (err: any) {
122
+ return { success: true, message: `Running deployment status set to CANCELLED, but failed to kill process: ${err.message}` };
123
+ }
124
+ }
125
+
126
+ return { success: true, message: "Running deployment marked as CANCELLED" };
127
+ }
128
+
129
+ return { success: false, message: "Unknown status" };
130
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }