@justanarthur/just-github-actions-n-workflows-cli 0.0.0-beta.5
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 +35 -0
- package/src/commands/init.ts +265 -0
- package/src/github.ts +113 -0
- package/src/index.ts +6 -0
- package/src/init.ts +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@justanarthur/just-github-actions-n-workflows-cli",
|
|
3
|
+
"version": "0.0.0-beta.5",
|
|
4
|
+
"description": "Release automation toolkit — version bumping, npm publishing, docker publishing, VPS deployment via GitHub Actions composite actions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"just-github-actions-n-workflows": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"publish": "bun publish --access public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/**/*.ts",
|
|
14
|
+
"package.json",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/justAnArthur/just-github-actions-n-workflows"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@oclif/core": "^4",
|
|
26
|
+
"@inquirer/prompts": "^7"
|
|
27
|
+
},
|
|
28
|
+
"oclif": {
|
|
29
|
+
"commands": "./src/commands",
|
|
30
|
+
"default": "init"
|
|
31
|
+
},
|
|
32
|
+
"properties": {
|
|
33
|
+
"gitCommitScopeRelatedNames": "cli"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { Args, Command, Flags, ux } from "@oclif/core"
|
|
2
|
+
import { checkbox, select } from "@inquirer/prompts"
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
REPO,
|
|
8
|
+
enrichWorkflows,
|
|
9
|
+
fetchTags,
|
|
10
|
+
fetchWorkflowContent,
|
|
11
|
+
fetchWorkflowList,
|
|
12
|
+
type WorkflowEntry,
|
|
13
|
+
} from "../github.js"
|
|
14
|
+
|
|
15
|
+
export default class Init extends Command {
|
|
16
|
+
static override description = "Scaffold workflow files into .github/workflows/ of the current repo"
|
|
17
|
+
|
|
18
|
+
static override examples = [
|
|
19
|
+
"<%= config.bin %> init",
|
|
20
|
+
"<%= config.bin %> init bump-version",
|
|
21
|
+
"<%= config.bin %> init --ref v1.0.0",
|
|
22
|
+
"<%= config.bin %> init --list",
|
|
23
|
+
"<%= config.bin %> init --yes --force",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
static override args = {
|
|
27
|
+
workflows: Args.string({
|
|
28
|
+
description: "Specific workflow names to install (space-separated)",
|
|
29
|
+
required: false,
|
|
30
|
+
}),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static override strict = false
|
|
34
|
+
|
|
35
|
+
static override flags = {
|
|
36
|
+
list: Flags.boolean({
|
|
37
|
+
char: "l",
|
|
38
|
+
description: "Show available workflows",
|
|
39
|
+
default: false,
|
|
40
|
+
}),
|
|
41
|
+
force: Flags.boolean({
|
|
42
|
+
char: "f",
|
|
43
|
+
description: "Overwrite existing workflow files",
|
|
44
|
+
default: false,
|
|
45
|
+
}),
|
|
46
|
+
yes: Flags.boolean({
|
|
47
|
+
char: "y",
|
|
48
|
+
description: "Skip interactive prompts, install all workflows",
|
|
49
|
+
default: false,
|
|
50
|
+
}),
|
|
51
|
+
ref: Flags.string({
|
|
52
|
+
description: "Git ref to fetch from (branch, tag, or sha)",
|
|
53
|
+
default: "main",
|
|
54
|
+
}),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async run(): Promise<void> {
|
|
58
|
+
const { argv, flags } = await this.parse(Init)
|
|
59
|
+
const positional = argv as string[]
|
|
60
|
+
|
|
61
|
+
// --- list mode ---
|
|
62
|
+
|
|
63
|
+
if (flags.list) {
|
|
64
|
+
await this.listWorkflows(flags.ref)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- interactive / install mode ---
|
|
69
|
+
|
|
70
|
+
this.log()
|
|
71
|
+
this.log(ux.colorize("bold", " just-github-actions-n-workflows"))
|
|
72
|
+
this.log(ux.colorize("dim", " release automation toolkit\n"))
|
|
73
|
+
|
|
74
|
+
const ref = await this.resolveRef(flags, positional)
|
|
75
|
+
const selected = await this.selectWorkflows(ref, flags, positional)
|
|
76
|
+
|
|
77
|
+
if (selected.length === 0) {
|
|
78
|
+
this.log(ux.colorize("yellow", "\n no workflows selected — nothing to do.\n"))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { created, skipped } = await this.installWorkflows(selected, ref, flags)
|
|
83
|
+
|
|
84
|
+
this.log(`\n done — ${ux.colorize("green", `${created} created`)}, ${skipped} skipped\n`)
|
|
85
|
+
|
|
86
|
+
this.printSecretsReminder(selected)
|
|
87
|
+
this.printNextSteps(created, selected)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── step 1: resolve ref ────────────────────────────────
|
|
91
|
+
|
|
92
|
+
private async resolveRef(
|
|
93
|
+
flags: { ref: string; yes: boolean },
|
|
94
|
+
positional: string[]
|
|
95
|
+
): Promise<string> {
|
|
96
|
+
if (flags.ref !== "main" || positional.length > 0 || flags.yes) {
|
|
97
|
+
return flags.ref
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.log(ux.colorize("bold", " step 1 — select version\n"))
|
|
101
|
+
|
|
102
|
+
const tags = await fetchTags()
|
|
103
|
+
|
|
104
|
+
if (tags.length === 0) return "main"
|
|
105
|
+
|
|
106
|
+
const choices = [
|
|
107
|
+
{ name: `main ${ux.colorize("dim", "(latest)")}`, value: "main" },
|
|
108
|
+
...tags.slice(0, 9).map((tag) => ({ name: tag, value: tag })),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
const ref = await select({
|
|
112
|
+
message: "Pick a version",
|
|
113
|
+
choices,
|
|
114
|
+
default: "main",
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
this.log(ux.colorize("green", `\n → using ${ref}\n`))
|
|
118
|
+
return ref
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── step 2: select workflows ───────────────────────────
|
|
122
|
+
|
|
123
|
+
private async selectWorkflows(
|
|
124
|
+
ref: string,
|
|
125
|
+
flags: { yes: boolean },
|
|
126
|
+
positional: string[]
|
|
127
|
+
): Promise<WorkflowEntry[]> {
|
|
128
|
+
let available = await fetchWorkflowList(ref)
|
|
129
|
+
available = await enrichWorkflows(available, ref)
|
|
130
|
+
|
|
131
|
+
if (positional.length > 0) {
|
|
132
|
+
return positional.map((name) => {
|
|
133
|
+
const key = name.replace(/\.yml$/, "")
|
|
134
|
+
const wf = available.find((w) => w.name === key)
|
|
135
|
+
if (!wf) {
|
|
136
|
+
this.error(`Unknown workflow "${name}". Available: ${available.map((w) => w.name).join(", ")}`)
|
|
137
|
+
}
|
|
138
|
+
return wf!
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (flags.yes) return available
|
|
143
|
+
|
|
144
|
+
this.log(ux.colorize("bold", " step 2 — select workflows\n"))
|
|
145
|
+
|
|
146
|
+
const selected = await checkbox({
|
|
147
|
+
message: "Select workflows to install",
|
|
148
|
+
choices: available.map((wf) => {
|
|
149
|
+
const secretNames = wf.secrets?.map((s) => s.split(/\s+[—–]\s*/)[0]).join(", ")
|
|
150
|
+
const hint = [
|
|
151
|
+
wf.description,
|
|
152
|
+
secretNames ? `requires: ${secretNames}` : null,
|
|
153
|
+
].filter(Boolean).join(" · ")
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
name: `${wf.name.padEnd(26)} ${ux.colorize("dim", hint || wf.file)}`,
|
|
157
|
+
value: wf.name,
|
|
158
|
+
checked: true,
|
|
159
|
+
}
|
|
160
|
+
}),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return available.filter((w) => selected.includes(w.name))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── step 3: install ────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
private async installWorkflows(
|
|
169
|
+
selected: WorkflowEntry[],
|
|
170
|
+
ref: string,
|
|
171
|
+
flags: { force: boolean; yes: boolean }
|
|
172
|
+
): Promise<{ created: number; skipped: number }> {
|
|
173
|
+
const targetDir = join(process.cwd(), ".github", "workflows")
|
|
174
|
+
mkdirSync(targetDir, { recursive: true })
|
|
175
|
+
|
|
176
|
+
if (!flags.yes) {
|
|
177
|
+
this.log(ux.colorize("bold", " step 3 — install\n"))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.log(ux.colorize("dim", ` fetching from ${REPO} @ ${ref}\n`))
|
|
181
|
+
|
|
182
|
+
let created = 0
|
|
183
|
+
let skipped = 0
|
|
184
|
+
|
|
185
|
+
for (const workflow of selected) {
|
|
186
|
+
const targetPath = join(targetDir, workflow.file)
|
|
187
|
+
|
|
188
|
+
if (!flags.force && existsSync(targetPath)) {
|
|
189
|
+
this.log(` ${ux.colorize("yellow", "skip")} ${workflow.file} ${ux.colorize("dim", "(already exists, use --force)")}`)
|
|
190
|
+
skipped++
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const content = await fetchWorkflowContent(workflow.file, ref)
|
|
196
|
+
writeFileSync(targetPath, content, "utf-8")
|
|
197
|
+
this.log(` ${ux.colorize("green", "create")} .github/workflows/${workflow.file}`)
|
|
198
|
+
created++
|
|
199
|
+
} catch (error: any) {
|
|
200
|
+
this.log(` ${ux.colorize("red", "error")} ${workflow.file}: ${error.message}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { created, skipped }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── step 4: secrets reminder ───────────────────────────
|
|
208
|
+
|
|
209
|
+
private printSecretsReminder(selected: WorkflowEntry[]): void {
|
|
210
|
+
const allSecrets = new Map<string, string>()
|
|
211
|
+
for (const wf of selected) {
|
|
212
|
+
for (const s of wf.secrets || []) {
|
|
213
|
+
const [name, ...descParts] = s.split(/\s+[—–]\s*/)
|
|
214
|
+
if (name && !allSecrets.has(name.trim())) {
|
|
215
|
+
allSecrets.set(name.trim(), descParts.join(" — ").trim())
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (allSecrets.size === 0) return
|
|
221
|
+
|
|
222
|
+
this.log(ux.colorize("bold", " required secrets:\n"))
|
|
223
|
+
for (const [name, desc] of allSecrets) {
|
|
224
|
+
this.log(` • ${ux.colorize("cyan", name.padEnd(18))} ${ux.colorize("dim", desc)}`)
|
|
225
|
+
}
|
|
226
|
+
this.log(ux.colorize("dim", `\n set these in your repo → Settings → Secrets → Actions\n`))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── next steps ─────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
private printNextSteps(created: number, selected: WorkflowEntry[]): void {
|
|
232
|
+
if (created === 0) return
|
|
233
|
+
|
|
234
|
+
const hasSecrets = selected.some((w) => w.secrets && w.secrets.length > 0)
|
|
235
|
+
|
|
236
|
+
this.log(ux.colorize("bold", " next steps:\n"))
|
|
237
|
+
this.log(` 1. ${hasSecrets ? "set the secrets listed above" : "set the GH_TOKEN secret in your repo settings"}`)
|
|
238
|
+
this.log(` 2. adjust push.branches / push.tags triggers for your repo`)
|
|
239
|
+
this.log(` 3. commit and push:`)
|
|
240
|
+
this.log(ux.colorize("dim", ` git add .github/ && git commit -m "ci: add workflows" && git push`))
|
|
241
|
+
this.log()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── list mode ──────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
private async listWorkflows(ref: string): Promise<void> {
|
|
247
|
+
const workflows = await enrichWorkflows(await fetchWorkflowList(ref), ref)
|
|
248
|
+
|
|
249
|
+
this.log(`\n${ux.colorize("bold", "available workflows")} ${ux.colorize("dim", `(${REPO} @ ${ref})`)}\n`)
|
|
250
|
+
|
|
251
|
+
for (const w of workflows) {
|
|
252
|
+
this.log(` ${ux.colorize("cyan", w.name.padEnd(28))} → .github/workflows/${w.file}`)
|
|
253
|
+
if (w.description) {
|
|
254
|
+
this.log(` ${"".padEnd(28)} ${ux.colorize("dim", w.description)}`)
|
|
255
|
+
}
|
|
256
|
+
if (w.secrets?.length) {
|
|
257
|
+
const names = w.secrets.map((s) => s.split(/\s+[—–]\s*/)[0]).join(", ")
|
|
258
|
+
this.log(` ${"".padEnd(28)} ${ux.colorize("dim", `secrets: ${names}`)}`)
|
|
259
|
+
}
|
|
260
|
+
this.log()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import pkg from "../package.json"
|
|
2
|
+
|
|
3
|
+
const REPO = pkg.repository.url
|
|
4
|
+
.replace(/^https?:\/\/github\.com\//, "")
|
|
5
|
+
.replace(/\.git$/, "")
|
|
6
|
+
|
|
7
|
+
const API_BASE = `https://api.github.com/repos/${REPO}`
|
|
8
|
+
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}`
|
|
9
|
+
|
|
10
|
+
export { REPO }
|
|
11
|
+
|
|
12
|
+
// --- types ---
|
|
13
|
+
|
|
14
|
+
export type WorkflowEntry = {
|
|
15
|
+
name: string
|
|
16
|
+
file: string
|
|
17
|
+
description?: string
|
|
18
|
+
secrets?: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- parse workflow header ---
|
|
22
|
+
// extracts description and required secrets from the yaml header comment block.
|
|
23
|
+
|
|
24
|
+
export function parseWorkflowHeader(content: string): { description: string; secrets: string[] } {
|
|
25
|
+
const lines = content.split("\n")
|
|
26
|
+
let description = ""
|
|
27
|
+
const secrets: string[] = []
|
|
28
|
+
let inHeader = false
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (line.startsWith("# ---")) {
|
|
32
|
+
if (inHeader) break
|
|
33
|
+
inHeader = true
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (!inHeader) continue
|
|
37
|
+
if (!line.startsWith("#")) break
|
|
38
|
+
|
|
39
|
+
const text = line.replace(/^#\s?/, "").trim()
|
|
40
|
+
|
|
41
|
+
if (/^required secrets:/i.test(text)) continue
|
|
42
|
+
if (/^\w+\s+[—–]/.test(text)) {
|
|
43
|
+
secrets.push(text)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!description && text && !/^copy this|^can also|^actions used|^paste this|^this one/i.test(text)) {
|
|
48
|
+
description = text
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { description, secrets }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- api ---
|
|
56
|
+
|
|
57
|
+
export async function fetchTags(): Promise<string[]> {
|
|
58
|
+
const url = `${API_BASE}/tags?per_page=20`
|
|
59
|
+
const res = await fetch(url, {
|
|
60
|
+
headers: { Accept: "application/vnd.github.v3+json" }
|
|
61
|
+
})
|
|
62
|
+
if (!res.ok) return []
|
|
63
|
+
const tags: any[] = await res.json()
|
|
64
|
+
return tags.map((t) => t.name)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function fetchWorkflowList(gitRef: string): Promise<WorkflowEntry[]> {
|
|
68
|
+
const url = `${API_BASE}/contents/workflows?ref=${gitRef}`
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
headers: { Accept: "application/vnd.github.v3+json" }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(`failed to list workflows from ${REPO} @ ${gitRef} (${res.status})`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const entries: any[] = await res.json()
|
|
78
|
+
|
|
79
|
+
return entries
|
|
80
|
+
.filter((e) => e.type === "file" && e.name.endsWith(".yml"))
|
|
81
|
+
.map((e) => ({
|
|
82
|
+
name: e.name.replace(/\.yml$/, ""),
|
|
83
|
+
file: e.name
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function fetchWorkflowContent(file: string, gitRef: string): Promise<string> {
|
|
88
|
+
const url = `${RAW_BASE}/${gitRef}/workflows/${file}`
|
|
89
|
+
const res = await fetch(url)
|
|
90
|
+
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
throw new Error(`failed to fetch workflows/${file} from ${REPO} @ ${gitRef} (${res.status})`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return await res.text()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function enrichWorkflows(workflows: WorkflowEntry[], gitRef: string): Promise<WorkflowEntry[]> {
|
|
99
|
+
const enriched: WorkflowEntry[] = []
|
|
100
|
+
|
|
101
|
+
for (const wf of workflows) {
|
|
102
|
+
try {
|
|
103
|
+
const content = await fetchWorkflowContent(wf.file, gitRef)
|
|
104
|
+
const { description, secrets } = parseWorkflowHeader(content)
|
|
105
|
+
enriched.push({ ...wf, description, secrets })
|
|
106
|
+
} catch {
|
|
107
|
+
enriched.push(wf)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return enriched
|
|
112
|
+
}
|
|
113
|
+
|
package/src/index.ts
ADDED