@tongsh6/aief-init 0.1.0
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 +15 -0
- package/bin/aief-init.mjs +355 -0
- package/package.json +29 -0
- package/templates/minimal/AGENTS.md +19 -0
- package/templates/minimal/context/INDEX.md +15 -0
- package/templates/minimal/context/business/.gitkeep +0 -0
- package/templates/minimal/context/experience/.gitkeep +0 -0
- package/templates/minimal/context/tech/.gitkeep +0 -0
- package/templates/retrofit/context/tech/REPO_SNAPSHOT.md +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# aief-init
|
|
2
|
+
|
|
3
|
+
Optional bootstrap CLI for AI Engineering Framework (AIEF).
|
|
4
|
+
|
|
5
|
+
Run without copying any AIEF files into your repo:
|
|
6
|
+
|
|
7
|
+
npx --yes @tongsh6/aief-init@latest new
|
|
8
|
+
|
|
9
|
+
npx --yes @tongsh6/aief-init@latest retrofit --level L0
|
|
10
|
+
|
|
11
|
+
npx --yes @tongsh6/aief-init@latest retrofit --level L0+
|
|
12
|
+
|
|
13
|
+
Notes:
|
|
14
|
+
- It only writes AIEF entry files (AGENTS.md + context/*). It does not modify your existing code structure.
|
|
15
|
+
- By default it will not overwrite existing files. Use --force to overwrite.
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = path.dirname(__filename)
|
|
10
|
+
|
|
11
|
+
function printHelp() {
|
|
12
|
+
process.stdout.write(
|
|
13
|
+
[
|
|
14
|
+
'aief-init (optional bootstrap)',
|
|
15
|
+
'',
|
|
16
|
+
'Usage:',
|
|
17
|
+
' aief-init new',
|
|
18
|
+
' aief-init retrofit --level L0',
|
|
19
|
+
' aief-init retrofit --level L0+',
|
|
20
|
+
'',
|
|
21
|
+
'Options:',
|
|
22
|
+
' --level <L0|L0+|L1> Migration level for retrofit (default: L0+)',
|
|
23
|
+
' --force Overwrite existing files',
|
|
24
|
+
' --dry-run Print actions without writing',
|
|
25
|
+
' -h, --help Show help',
|
|
26
|
+
'',
|
|
27
|
+
].join('\n')
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const args = argv.slice(2)
|
|
33
|
+
|
|
34
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
35
|
+
return { help: true }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const command = args[0]
|
|
39
|
+
const opts = {
|
|
40
|
+
command,
|
|
41
|
+
level: 'L0+',
|
|
42
|
+
force: false,
|
|
43
|
+
dryRun: false,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
47
|
+
const a = args[i]
|
|
48
|
+
if (a === '--force') opts.force = true
|
|
49
|
+
else if (a === '--dry-run') opts.dryRun = true
|
|
50
|
+
else if (a === '--level') {
|
|
51
|
+
const v = args[i + 1]
|
|
52
|
+
if (!v) throw new Error('Missing value for --level')
|
|
53
|
+
opts.level = v
|
|
54
|
+
i += 1
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error(`Unknown option: ${a}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return opts
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function exists(p) {
|
|
64
|
+
try {
|
|
65
|
+
fs.accessSync(p, fs.constants.F_OK)
|
|
66
|
+
return true
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureDir(dirPath, { dryRun } = {}) {
|
|
73
|
+
if (dryRun) {
|
|
74
|
+
process.stdout.write(`[dry-run] mkdir -p ${dirPath}\n`)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
fs.mkdirSync(dirPath, { recursive: true })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeFile(filePath, content, { force, dryRun } = {}) {
|
|
81
|
+
if (dryRun) {
|
|
82
|
+
process.stdout.write(`[dry-run] write ${filePath}\n`)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ensureDir(path.dirname(filePath))
|
|
87
|
+
const flags = force ? 'w' : 'wx'
|
|
88
|
+
fs.writeFileSync(filePath, content, { encoding: 'utf8', flag: flags })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readText(filePath) {
|
|
92
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function copyFile(src, dst, { force, dryRun } = {}) {
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
process.stdout.write(`[dry-run] copy ${src} -> ${dst}\n`)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
ensureDir(path.dirname(dst))
|
|
101
|
+
const flags = force ? 0 : fs.constants.COPYFILE_EXCL
|
|
102
|
+
fs.copyFileSync(src, dst, flags)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function listTopLevel(rootDir) {
|
|
106
|
+
const ignore = new Set([
|
|
107
|
+
'.git',
|
|
108
|
+
'node_modules',
|
|
109
|
+
'dist',
|
|
110
|
+
'build',
|
|
111
|
+
'out',
|
|
112
|
+
'target',
|
|
113
|
+
'.venv',
|
|
114
|
+
'venv',
|
|
115
|
+
'__pycache__',
|
|
116
|
+
'.idea',
|
|
117
|
+
'.vscode',
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
const entries = fs.readdirSync(rootDir, { withFileTypes: true })
|
|
121
|
+
return entries
|
|
122
|
+
.filter((e) => !ignore.has(e.name))
|
|
123
|
+
.map((e) => ({ name: e.name, isDir: e.isDirectory() }))
|
|
124
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function detectTech(rootDir) {
|
|
128
|
+
const f = (name) => exists(path.join(rootDir, name))
|
|
129
|
+
const d = (name) => exists(path.join(rootDir, name)) && fs.statSync(path.join(rootDir, name)).isDirectory()
|
|
130
|
+
|
|
131
|
+
const tech = {
|
|
132
|
+
languages: new Set(),
|
|
133
|
+
frameworks: new Set(),
|
|
134
|
+
buildTools: new Set(),
|
|
135
|
+
runtimes: new Set(),
|
|
136
|
+
packageManagers: new Set(),
|
|
137
|
+
ci: new Set(),
|
|
138
|
+
docker: new Set(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (f('package.json')) {
|
|
142
|
+
tech.languages.add('JavaScript/TypeScript')
|
|
143
|
+
tech.buildTools.add('npm-compatible')
|
|
144
|
+
tech.runtimes.add('Node.js')
|
|
145
|
+
tech.packageManagers.add('npm')
|
|
146
|
+
|
|
147
|
+
if (f('pnpm-lock.yaml')) tech.packageManagers.add('pnpm')
|
|
148
|
+
if (f('yarn.lock')) tech.packageManagers.add('yarn')
|
|
149
|
+
if (f('bun.lockb')) tech.packageManagers.add('bun')
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const pkg = JSON.parse(readText(path.join(rootDir, 'package.json')))
|
|
153
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
154
|
+
const has = (k) => Boolean(deps && Object.prototype.hasOwnProperty.call(deps, k))
|
|
155
|
+
if (has('react')) tech.frameworks.add('React')
|
|
156
|
+
if (has('next')) tech.frameworks.add('Next.js')
|
|
157
|
+
if (has('vue')) tech.frameworks.add('Vue')
|
|
158
|
+
if (has('nuxt')) tech.frameworks.add('Nuxt')
|
|
159
|
+
if (has('@nestjs/core')) tech.frameworks.add('NestJS')
|
|
160
|
+
if (has('express')) tech.frameworks.add('Express')
|
|
161
|
+
if (has('fastify')) tech.frameworks.add('Fastify')
|
|
162
|
+
} catch {
|
|
163
|
+
// best-effort only
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (f('pyproject.toml') || f('requirements.txt') || f('poetry.lock')) {
|
|
168
|
+
tech.languages.add('Python')
|
|
169
|
+
tech.buildTools.add('pip/poetry')
|
|
170
|
+
if (f('poetry.lock')) tech.packageManagers.add('poetry')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (f('go.mod')) {
|
|
174
|
+
tech.languages.add('Go')
|
|
175
|
+
tech.buildTools.add('go')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (f('Cargo.toml')) {
|
|
179
|
+
tech.languages.add('Rust')
|
|
180
|
+
tech.buildTools.add('cargo')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (f('pom.xml')) {
|
|
184
|
+
tech.languages.add('Java')
|
|
185
|
+
tech.buildTools.add('maven')
|
|
186
|
+
}
|
|
187
|
+
if (f('build.gradle') || f('build.gradle.kts')) {
|
|
188
|
+
tech.languages.add('Java/Kotlin')
|
|
189
|
+
tech.buildTools.add('gradle')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (d('.github') && d('.github/workflows')) tech.ci.add('GitHub Actions')
|
|
193
|
+
if (f('.gitlab-ci.yml')) tech.ci.add('GitLab CI')
|
|
194
|
+
if (f('Jenkinsfile')) tech.ci.add('Jenkins')
|
|
195
|
+
if (d('.circleci')) tech.ci.add('CircleCI')
|
|
196
|
+
if (f('azure-pipelines.yml')) tech.ci.add('Azure Pipelines')
|
|
197
|
+
|
|
198
|
+
if (f('Dockerfile')) tech.docker.add('Dockerfile')
|
|
199
|
+
if (f('docker-compose.yml') || f('compose.yml')) tech.docker.add('Docker Compose')
|
|
200
|
+
|
|
201
|
+
return tech
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function detectModules(rootDir) {
|
|
205
|
+
const candidates = ['apps', 'services', 'packages', 'modules']
|
|
206
|
+
const modules = []
|
|
207
|
+
|
|
208
|
+
for (const base of candidates) {
|
|
209
|
+
const basePath = path.join(rootDir, base)
|
|
210
|
+
if (!exists(basePath)) continue
|
|
211
|
+
if (!fs.statSync(basePath).isDirectory()) continue
|
|
212
|
+
|
|
213
|
+
const children = fs
|
|
214
|
+
.readdirSync(basePath, { withFileTypes: true })
|
|
215
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
216
|
+
.map((e) => e.name)
|
|
217
|
+
.sort((a, b) => a.localeCompare(b))
|
|
218
|
+
|
|
219
|
+
for (const name of children) {
|
|
220
|
+
modules.push({ name, path: `./${base}/${name}` })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return modules
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatRepoSnapshot(rootDir) {
|
|
228
|
+
const tech = detectTech(rootDir)
|
|
229
|
+
const topLevel = listTopLevel(rootDir)
|
|
230
|
+
const modules = detectModules(rootDir)
|
|
231
|
+
|
|
232
|
+
const fmtList = (set) => {
|
|
233
|
+
const arr = Array.from(set)
|
|
234
|
+
return arr.length ? arr.join(', ') : ''
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const lines = []
|
|
238
|
+
lines.push('# Repo Snapshot')
|
|
239
|
+
lines.push('')
|
|
240
|
+
lines.push('This file is generated as a retrofit starter draft. Edit freely.')
|
|
241
|
+
lines.push('')
|
|
242
|
+
lines.push('## Tech Stack (Detected)')
|
|
243
|
+
lines.push(`- Language: ${fmtList(tech.languages) || 'unknown'}`)
|
|
244
|
+
lines.push(`- Framework: ${fmtList(tech.frameworks) || 'unknown'}`)
|
|
245
|
+
lines.push(`- Build Tool: ${fmtList(tech.buildTools) || 'unknown'}`)
|
|
246
|
+
lines.push(`- Runtime: ${fmtList(tech.runtimes) || 'unknown'}`)
|
|
247
|
+
if (tech.packageManagers.size) lines.push(`- Package Manager: ${fmtList(tech.packageManagers)}`)
|
|
248
|
+
lines.push('')
|
|
249
|
+
lines.push('## Repo Layout (Top Level)')
|
|
250
|
+
for (const e of topLevel) {
|
|
251
|
+
lines.push(`- ${e.isDir ? e.name + '/' : e.name}`)
|
|
252
|
+
}
|
|
253
|
+
lines.push('')
|
|
254
|
+
lines.push('## Modules / Services (Heuristics)')
|
|
255
|
+
if (!modules.length) {
|
|
256
|
+
lines.push('- (none detected)')
|
|
257
|
+
} else {
|
|
258
|
+
for (const m of modules) {
|
|
259
|
+
lines.push(`- ${m.name} (${m.path})`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
lines.push('')
|
|
263
|
+
lines.push('## Infra & CI (Detected)')
|
|
264
|
+
lines.push(`- CI: ${fmtList(tech.ci) || 'unknown'}`)
|
|
265
|
+
lines.push(`- Docker: ${fmtList(tech.docker) || 'unknown'}`)
|
|
266
|
+
lines.push('')
|
|
267
|
+
lines.push('## Commands (Fill In)')
|
|
268
|
+
lines.push('- build:')
|
|
269
|
+
lines.push('- test:')
|
|
270
|
+
lines.push('- run:')
|
|
271
|
+
lines.push('')
|
|
272
|
+
|
|
273
|
+
return lines.join('\n')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function repoPath(rel) {
|
|
277
|
+
return path.resolve(process.cwd(), rel)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function templatePath(rel) {
|
|
281
|
+
return path.resolve(__dirname, '..', rel)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function initMinimal({ force, dryRun } = {}) {
|
|
285
|
+
const agentsSrc = templatePath('templates/minimal/AGENTS.md')
|
|
286
|
+
const indexSrc = templatePath('templates/minimal/context/INDEX.md')
|
|
287
|
+
const bKeep = templatePath('templates/minimal/context/business/.gitkeep')
|
|
288
|
+
const tKeep = templatePath('templates/minimal/context/tech/.gitkeep')
|
|
289
|
+
const eKeep = templatePath('templates/minimal/context/experience/.gitkeep')
|
|
290
|
+
|
|
291
|
+
if (exists(agentsSrc)) copyFile(agentsSrc, repoPath('AGENTS.md'), { force, dryRun })
|
|
292
|
+
else writeFile(repoPath('AGENTS.md'), '# AI Guide\n', { force, dryRun })
|
|
293
|
+
|
|
294
|
+
if (exists(indexSrc)) copyFile(indexSrc, repoPath('context/INDEX.md'), { force, dryRun })
|
|
295
|
+
else writeFile(repoPath('context/INDEX.md'), '# Context Index\n', { force, dryRun })
|
|
296
|
+
|
|
297
|
+
ensureDir(repoPath('context/business'), { dryRun })
|
|
298
|
+
ensureDir(repoPath('context/tech'), { dryRun })
|
|
299
|
+
ensureDir(repoPath('context/experience'), { dryRun })
|
|
300
|
+
|
|
301
|
+
if (exists(bKeep)) copyFile(bKeep, repoPath('context/business/.gitkeep'), { force, dryRun })
|
|
302
|
+
if (exists(tKeep)) copyFile(tKeep, repoPath('context/tech/.gitkeep'), { force, dryRun })
|
|
303
|
+
if (exists(eKeep)) copyFile(eKeep, repoPath('context/experience/.gitkeep'), { force, dryRun })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function initRetrofit({ level, force, dryRun } = {}) {
|
|
307
|
+
initMinimal({ force, dryRun })
|
|
308
|
+
|
|
309
|
+
if (level === 'L0') return
|
|
310
|
+
if (level !== 'L0+' && level !== 'L1') {
|
|
311
|
+
throw new Error(`Unsupported level: ${level}`)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const snapshotPath = repoPath('context/tech/REPO_SNAPSHOT.md')
|
|
315
|
+
const snapshotContent = formatRepoSnapshot(process.cwd())
|
|
316
|
+
writeFile(snapshotPath, snapshotContent, { force, dryRun })
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function main() {
|
|
320
|
+
let opts
|
|
321
|
+
try {
|
|
322
|
+
opts = parseArgs(process.argv)
|
|
323
|
+
} catch (err) {
|
|
324
|
+
process.stderr.write(`${err.message}\n`)
|
|
325
|
+
printHelp()
|
|
326
|
+
process.exit(1)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (opts.help || !opts.command) {
|
|
330
|
+
printHelp()
|
|
331
|
+
process.exit(0)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
if (opts.command === 'new') {
|
|
336
|
+
initMinimal({ force: opts.force, dryRun: opts.dryRun })
|
|
337
|
+
process.stdout.write('Done. Created minimal AIEF entry (AGENTS.md + context/).\n')
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (opts.command === 'retrofit') {
|
|
342
|
+
initRetrofit({ level: opts.level, force: opts.force, dryRun: opts.dryRun })
|
|
343
|
+
process.stdout.write(`Done. Retrofit init at level ${opts.level}.\n`)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
throw new Error(`Unknown command: ${opts.command}`)
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const msg = err && err.message ? err.message : String(err)
|
|
350
|
+
process.stderr.write(`${msg}\n`)
|
|
351
|
+
process.exit(1)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tongsh6/aief-init",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Optional bootstrap CLI for AI Engineering Framework (AIEF)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"aief-init": "./bin/aief-init.mjs"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"templates/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/tongsh6/ai-engineering-framework.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/tongsh6/ai-engineering-framework/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/tongsh6/ai-engineering-framework#readme"
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# [Project Name] AI Guide
|
|
2
|
+
|
|
3
|
+
This is the project-level entry for AI-assisted engineering.
|
|
4
|
+
|
|
5
|
+
Language:
|
|
6
|
+
- Use Chinese for communication by default
|
|
7
|
+
- Keep code/commands/identifiers in English
|
|
8
|
+
|
|
9
|
+
Project:
|
|
10
|
+
- One-liner:
|
|
11
|
+
- Core value:
|
|
12
|
+
|
|
13
|
+
Quick Commands:
|
|
14
|
+
- build:
|
|
15
|
+
- test:
|
|
16
|
+
- run:
|
|
17
|
+
|
|
18
|
+
Context Entry:
|
|
19
|
+
- context/INDEX.md
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Context Index
|
|
2
|
+
|
|
3
|
+
This is the navigation entry for long-term project context.
|
|
4
|
+
|
|
5
|
+
Directories:
|
|
6
|
+
|
|
7
|
+
context/
|
|
8
|
+
business/
|
|
9
|
+
tech/
|
|
10
|
+
experience/
|
|
11
|
+
|
|
12
|
+
Suggested next documents (optional):
|
|
13
|
+
- context/business/DOMAIN.md
|
|
14
|
+
- context/tech/ARCHITECTURE.md
|
|
15
|
+
- context/experience/INDEX.md
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Repo Snapshot
|
|
2
|
+
|
|
3
|
+
## Tech Stack
|
|
4
|
+
- Language:
|
|
5
|
+
- Framework:
|
|
6
|
+
- Build Tool:
|
|
7
|
+
- Runtime:
|
|
8
|
+
|
|
9
|
+
## Repo Layout (Top Level)
|
|
10
|
+
- /
|
|
11
|
+
|
|
12
|
+
## Modules / Services
|
|
13
|
+
- name:
|
|
14
|
+
- path:
|
|
15
|
+
- responsibility:
|
|
16
|
+
- owner (optional):
|
|
17
|
+
|
|
18
|
+
## Infra & CI
|
|
19
|
+
- CI:
|
|
20
|
+
- Docker:
|
|
21
|
+
- Deploy:
|
|
22
|
+
|
|
23
|
+
## Commands (If Known)
|
|
24
|
+
- build:
|
|
25
|
+
- test:
|
|
26
|
+
- run:
|