@skippercorp/skipper 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.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @skippercorp/skipper
2
+
3
+ Published npm package: `@skippercorp/skipper`.
4
+ CLI command name stays `skipper`.
5
+
6
+ Install dependencies:
7
+
8
+ ```bash
9
+ bun install
10
+ ```
11
+
12
+ Run locally:
13
+
14
+ ```bash
15
+ bun run cli
16
+ ```
17
+
18
+ Validate source standards:
19
+
20
+ ```bash
21
+ bun run lint
22
+ ```
23
+
24
+ Run e2e issue subscription verification (creates temp issue, waits for ECS task, verifies issue fetch logs, then closes issue + stops task):
25
+
26
+ ```bash
27
+ SKIPPER_E2E_GITHUB_REPO=blntrsz/skipper SKIPPER_E2E_REGION=eu-central-1 bun run test:e2e:issue-subscription
28
+ ```
29
+
30
+ ## Release flow (Changesets)
31
+
32
+ 1. Add a release note: `bun run changeset`
33
+ 2. Check pending releases: `bunx changeset status`
34
+ 3. Push to `main`: `.github/workflows/release.yml` opens/updates a version PR
35
+ 4. Merge version PR: workflow publishes to npm with `NPM_TOKEN`
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@skippercorp/skipper",
4
+ "version": "1.0.1",
5
+ "type": "module",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "src/**/*.ts",
12
+ "!src/**/*.test.ts"
13
+ ],
14
+ "bin": {
15
+ "skipper": "./src/index.ts"
16
+ },
17
+ "scripts": {
18
+ "cli": "bun run ./src/index.ts",
19
+ "typecheck": "bun x tsc --noEmit",
20
+ "lint": "bun run typecheck && bun run ./scripts/check-source-standards.ts",
21
+ "test": "bun test",
22
+ "test:e2e:issue-subscription": "bun run ./scripts/verify-issue-subscription-e2e.ts",
23
+ "build": "bun build ./src/index.ts --compile --outfile bin/skipper",
24
+ "changeset": "changeset",
25
+ "version-packages": "changeset version",
26
+ "release": "changeset publish"
27
+ },
28
+ "devDependencies": {
29
+ "@changesets/cli": "^2.29.0",
30
+ "@types/bun": "latest"
31
+ },
32
+ "peerDependencies": {
33
+ "typescript": "^5"
34
+ },
35
+ "dependencies": {
36
+ "@aws-sdk/client-cloudformation": "^3.888.0",
37
+ "@aws-sdk/client-cloudwatch-logs": "^3.1000.0",
38
+ "@aws-sdk/client-ec2": "^3.1000.0",
39
+ "@aws-sdk/client-ecs": "^3.1000.0",
40
+ "@aws-sdk/client-s3": "^3.1000.0",
41
+ "@aws-sdk/client-sts": "^3.1000.0",
42
+ "@octokit/webhooks": "^13.7.1",
43
+ "@octokit/webhooks-types": "^7.6.1",
44
+ "@pulumi/aws": "^7.20.0",
45
+ "@pulumi/pulumi": "^3.120.0",
46
+ "commander": "^14.0.3"
47
+ }
48
+ }
package/src/app/cli.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { Command } from "commander";
2
+ import packageJson from "../../package.json";
3
+ import { registerCommands } from "./register-commands.js";
4
+
5
+ const packageVersion =
6
+ typeof packageJson.version === "string" && packageJson.version.length > 0
7
+ ? packageJson.version
8
+ : "0.0.0";
9
+
10
+ /**
11
+ * Build configured CLI program.
12
+ *
13
+ * @since 1.0.0
14
+ * @category CLI
15
+ */
16
+ export function createProgram(): Command {
17
+ const program = new Command();
18
+ program.name("skipper").description("CLI tool").version(packageVersion);
19
+ registerCommands(program);
20
+ return program;
21
+ }
22
+
23
+ /**
24
+ * Run CLI program with argv.
25
+ *
26
+ * @since 1.0.0
27
+ * @category CLI
28
+ */
29
+ export async function runCli(argv = process.argv): Promise<void> {
30
+ await createProgram().parseAsync(argv);
31
+ }
@@ -0,0 +1,37 @@
1
+ import type { Command } from "commander";
2
+ import { registerCloneCommand } from "../command/clone.js";
3
+ import { registerAddCommand } from "../command/a.js";
4
+ import { registerRemoveCommand } from "../command/rm.js";
5
+ import { registerRunCommand } from "../command/run.js";
6
+ import { registerAwsCommand } from "../command/aws/index.js";
7
+
8
+ /**
9
+ * Register all skipper commands.
10
+ *
11
+ * @since 1.0.0
12
+ * @category CLI
13
+ */
14
+ export function registerCommands(program: Command): void {
15
+ registerHelloCommand(program);
16
+ registerCloneCommand(program);
17
+ registerAddCommand(program);
18
+ registerRemoveCommand(program);
19
+ registerRunCommand(program);
20
+ registerAwsCommand(program);
21
+ }
22
+
23
+ /**
24
+ * Register simple health command.
25
+ *
26
+ * @since 1.0.0
27
+ * @category CLI
28
+ */
29
+ function registerHelloCommand(program: Command): void {
30
+ program
31
+ .command("hello")
32
+ .description("Say hello")
33
+ .argument("[name]", "name to greet")
34
+ .action((name) => {
35
+ console.log(`Hello ${name || "World"}!`);
36
+ });
37
+ }
@@ -0,0 +1,213 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ assertNonEmpty,
4
+ listDirectory,
5
+ selectOrQueryWithFzf,
6
+ selectWithFzf,
7
+ } from "../shared/command/interactive.js";
8
+
9
+ type WorktreeSelection = {
10
+ repo: string;
11
+ worktree: string;
12
+ targetWorktreePath: string;
13
+ repoPath: string;
14
+ };
15
+
16
+ /**
17
+ * Register add/attach worktree command.
18
+ *
19
+ * @since 1.0.0
20
+ * @category Worktree
21
+ */
22
+ export function registerAddCommand(program: Command): void {
23
+ program
24
+ .command("a")
25
+ .description("Add/attach to a worktree in a repository")
26
+ .action(runAddCommand);
27
+ }
28
+
29
+ /**
30
+ * Execute add/attach flow.
31
+ *
32
+ * @since 1.0.0
33
+ * @category Worktree
34
+ */
35
+ async function runAddCommand(): Promise<void> {
36
+ const githubDir = `${process.env.HOME}/.local/share/github`;
37
+ const worktreeBaseDir = `${process.env.HOME}/.local/share/skipper/worktree`;
38
+ const selection = await selectWorktree(githubDir, worktreeBaseDir);
39
+ await ensureWorktreeExists(selection);
40
+ await attachOrStartTmux(selection);
41
+ }
42
+
43
+ /**
44
+ * Select repo and worktree names.
45
+ *
46
+ * @since 1.0.0
47
+ * @category Worktree
48
+ */
49
+ async function selectWorktree(
50
+ githubDir: string,
51
+ worktreeBaseDir: string,
52
+ ): Promise<WorktreeSelection> {
53
+ const repoList = await listDirectory(githubDir);
54
+ assertNonEmpty(repoList, "No repositories found in ~/.local/share/github");
55
+ const repo = await selectWithFzf(repoList, "Select repository: ");
56
+ if (!repo) {
57
+ console.log("No repository selected");
58
+ process.exit(0);
59
+ }
60
+
61
+ const worktreeDir = `${worktreeBaseDir}/${repo}`;
62
+ await Bun.$`mkdir -p ${worktreeDir}`;
63
+ const existingWorktrees = await listDirectory(worktreeDir);
64
+ const input = await selectOrQueryWithFzf(
65
+ existingWorktrees,
66
+ existingWorktrees.length > 0
67
+ ? "Select or type new worktree: "
68
+ : "Enter new worktree name: ",
69
+ );
70
+ const worktree = resolveWorktreeName(input);
71
+ if (!worktree) {
72
+ console.log("No worktree selected or entered");
73
+ process.exit(0);
74
+ }
75
+
76
+ return {
77
+ repo,
78
+ worktree,
79
+ targetWorktreePath: `${worktreeDir}/${worktree}`,
80
+ repoPath: `${githubDir}/${repo}`,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Ensure selected worktree exists.
86
+ *
87
+ * @since 1.0.0
88
+ * @category Worktree
89
+ */
90
+ async function ensureWorktreeExists(selection: WorktreeSelection): Promise<void> {
91
+ const exists = await directoryExists(selection.targetWorktreePath);
92
+ if (exists) {
93
+ console.log(`Attaching to existing worktree: ${selection.targetWorktreePath}`);
94
+ return;
95
+ }
96
+
97
+ console.log(`Creating new worktree: ${selection.worktree}`);
98
+ const branchExists = await gitBranchExists(selection.repoPath, selection.worktree);
99
+ if (branchExists) {
100
+ await Bun.$`git -C ${selection.repoPath} worktree add ${selection.targetWorktreePath} ${selection.worktree}`;
101
+ return;
102
+ }
103
+ await Bun.$`git -C ${selection.repoPath} worktree add ${selection.targetWorktreePath} -b ${selection.worktree}`;
104
+ }
105
+
106
+ /**
107
+ * Attach to existing tmux session or create one.
108
+ *
109
+ * @since 1.0.0
110
+ * @category Worktree
111
+ */
112
+ async function attachOrStartTmux(selection: WorktreeSelection): Promise<void> {
113
+ const sessionName = `${selection.repo}-${selection.worktree}`;
114
+ const running = await tmuxSessionExists(sessionName);
115
+ const inTmux = process.env.TMUX !== undefined;
116
+ if (running) {
117
+ await switchOrAttachSession(sessionName, inTmux);
118
+ return;
119
+ }
120
+ await createTmuxSession(sessionName, selection.targetWorktreePath, inTmux);
121
+ }
122
+
123
+ /**
124
+ * Resolve typed or selected worktree name.
125
+ *
126
+ * @since 1.0.0
127
+ * @category Worktree
128
+ */
129
+ function resolveWorktreeName(worktreeInput: string | undefined): string | undefined {
130
+ if (!worktreeInput) return undefined;
131
+ const lines = worktreeInput.trim().split("\n");
132
+ const query = lines[0] || "";
133
+ const selection = lines[1] || "";
134
+ if (selection && selection !== "[new]") {
135
+ return selection;
136
+ }
137
+ if (query) {
138
+ return query;
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /**
144
+ * Check whether directory exists.
145
+ *
146
+ * @since 1.0.0
147
+ * @category Worktree
148
+ */
149
+ async function directoryExists(path: string): Promise<boolean> {
150
+ const result = await Bun.$`test -d ${path} && echo "yes" || echo "no"`.text();
151
+ return result.trim() === "yes";
152
+ }
153
+
154
+ /**
155
+ * Check whether git branch exists.
156
+ *
157
+ * @since 1.0.0
158
+ * @category Worktree
159
+ */
160
+ async function gitBranchExists(repoPath: string, branch: string): Promise<boolean> {
161
+ const ref = `refs/heads/${branch}`;
162
+ const result = await Bun.$`git -C ${repoPath} show-ref --verify --quiet ${ref}`.nothrow();
163
+ if (result.exitCode === 0) return true;
164
+ if (result.exitCode === 1) return false;
165
+ const stderr = result.stderr.toString().trim();
166
+ throw new Error(stderr || `git show-ref failed with code ${result.exitCode}`);
167
+ }
168
+
169
+ /**
170
+ * Check whether tmux session exists.
171
+ *
172
+ * @since 1.0.0
173
+ * @category Worktree
174
+ */
175
+ async function tmuxSessionExists(sessionName: string): Promise<boolean> {
176
+ const output = await Bun.$`tmux has-session -t ${sessionName} 2>/dev/null && echo "yes" || echo "no"`.text();
177
+ return output.trim() === "yes";
178
+ }
179
+
180
+ /**
181
+ * Switch or attach to tmux session.
182
+ *
183
+ * @since 1.0.0
184
+ * @category Worktree
185
+ */
186
+ async function switchOrAttachSession(sessionName: string, inTmux: boolean): Promise<void> {
187
+ if (inTmux) {
188
+ console.log(`Switching to tmux session: ${sessionName}`);
189
+ await Bun.$`tmux switch-client -t ${sessionName}`;
190
+ return;
191
+ }
192
+ console.log(`Attaching to existing tmux session: ${sessionName}`);
193
+ await Bun.$`tmux attach-session -t ${sessionName}`;
194
+ }
195
+
196
+ /**
197
+ * Create tmux session and attach/switch.
198
+ *
199
+ * @since 1.0.0
200
+ * @category Worktree
201
+ */
202
+ async function createTmuxSession(
203
+ sessionName: string,
204
+ worktreePath: string,
205
+ inTmux: boolean,
206
+ ): Promise<void> {
207
+ console.log(`Starting new tmux session: ${sessionName}`);
208
+ if (inTmux) {
209
+ await Bun.$`tmux new-session -s ${sessionName} -c ${worktreePath} -d && tmux switch-client -t ${sessionName}`;
210
+ return;
211
+ }
212
+ await Bun.$`tmux new-session -s ${sessionName} -c ${worktreePath}`;
213
+ }