@codepassion/skills 1.4.0 → 1.6.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 +44 -8
- package/SKILLS.md +44 -8
- package/bin/c9n-skills.js +4 -60
- package/bin/c9n.js +25 -0
- package/bin/deploy.js +114 -0
- package/bin/skill.js +207 -0
- package/bin/util.js +45 -0
- package/package.json +3 -2
- package/skills/c9n/SKILL.md +57 -14
- package/skills/c9n-pipeline/SKILL.md +57 -14
- package/skills/c9n-pipeline/templates/Dockerfile.web +5 -0
- package/skills/c9n-pipeline/templates/workflows/ci.yml +13 -1
package/README.md
CHANGED
|
@@ -1,21 +1,57 @@
|
|
|
1
1
|
# C9N Agent Skills
|
|
2
2
|
|
|
3
|
-
Agent skills (SKILL.md) based on the CodePassion (C9N) conventions
|
|
3
|
+
Agent skills (SKILL.md) based on the CodePassion (C9N) conventions, plus the `c9n` CLI
|
|
4
|
+
that installs them and drives tag-based deployments.
|
|
4
5
|
|
|
5
6
|
## Install
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
npm i -g @codepassion/skills # provides the c9n command
|
|
9
|
+
c9n skill install claude # ~/.claude/skills (default target)
|
|
10
|
+
|
|
11
|
+
Or without a global install:
|
|
12
|
+
|
|
13
|
+
npx -p @codepassion/skills c9n skill install claude
|
|
12
14
|
|
|
13
15
|
Or: ./install.sh {claude|codex|project|combined|cowork}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
## CLI
|
|
18
|
+
|
|
19
|
+
### c9n skill
|
|
20
|
+
|
|
21
|
+
c9n skill install [target] # copy package skills into the target (skips existing; --force overwrites)
|
|
22
|
+
c9n skill list [target] # per-skill state: ok | drift | missing
|
|
23
|
+
c9n skill update [target] # overwrite drifted skills from the package (confirms; --yes skips)
|
|
24
|
+
c9n skill uninstall [target] # remove installed c9n skills (confirms; --yes skips)
|
|
25
|
+
c9n skill doctor [target] # diagnose skills dirs, drift, frontmatter, gh/zip tooling
|
|
26
|
+
|
|
27
|
+
Targets: `claude` (default, ~/.claude/skills) | `codex` (~/.codex/skills) |
|
|
28
|
+
`project` (./.agents/skills) | `combined` (one merged skill) | `cowork` (upload zips).
|
|
29
|
+
`--dest <dir>` overrides the directory; `--dry-run` previews any write.
|
|
30
|
+
|
|
31
|
+
State is a runtime content-hash comparison against the package — "drift" covers both
|
|
32
|
+
stale and hand-edited copies (see docs/adr/0001). `update` treats the package as the
|
|
33
|
+
source of truth; skill edits belong in this repo, not in installed copies.
|
|
34
|
+
|
|
35
|
+
claude.ai and Claude Cowork share a personal skill library with no upload API, so
|
|
36
|
+
`c9n skill install cowork` packages upload-ready zips into dist/cowork/ — upload them at
|
|
17
37
|
claude.ai → Settings → Capabilities → Upload skill.
|
|
18
38
|
|
|
39
|
+
`c9n-skills` (the old bin) still works as a deprecated alias for `c9n skill`.
|
|
40
|
+
|
|
41
|
+
### c9n deploy
|
|
42
|
+
|
|
43
|
+
Creates and pushes an annotated tag in any C9N project with a tag-driven pipeline —
|
|
44
|
+
the CLI never builds or ships artifacts itself.
|
|
45
|
+
|
|
46
|
+
c9n deploy -e alpha # tags vX.Y.Z-alpha.N (version from package.json, N auto-increments)
|
|
47
|
+
c9n deploy -e alpha -v 1.2.3 # explicit version — always bare X.Y.Z, never the -alpha.N suffix
|
|
48
|
+
c9n deploy -e production -v 1.2.3 # tags v1.2.3 (requires a prior alpha of the same version)
|
|
49
|
+
|
|
50
|
+
Guards (all must pass before the tag is pushed): clean working tree, on `main` and in
|
|
51
|
+
sync with origin, tag ordering (production requires a prior alpha; versions must move
|
|
52
|
+
forward), CI green on HEAD via `gh` (`--skip-ci` to bypass), and a confirmation prompt
|
|
53
|
+
(`--yes` for CI). `--dry-run` runs every guard and shows the tag without creating it.
|
|
54
|
+
|
|
19
55
|
## Edit
|
|
20
56
|
|
|
21
57
|
Per-topic SKILL.md files in skills/c9n-* are the source of truth.
|
package/SKILLS.md
CHANGED
|
@@ -1,21 +1,57 @@
|
|
|
1
1
|
# C9N Agent Skills
|
|
2
2
|
|
|
3
|
-
Agent skills (SKILL.md) based on the CodePassion (C9N) conventions
|
|
3
|
+
Agent skills (SKILL.md) based on the CodePassion (C9N) conventions, plus the `c9n` CLI
|
|
4
|
+
that installs them and drives tag-based deployments.
|
|
4
5
|
|
|
5
6
|
## Install
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
npm i -g @codepassion/skills # provides the c9n command
|
|
9
|
+
c9n skill install claude # ~/.claude/skills (default target)
|
|
10
|
+
|
|
11
|
+
Or without a global install:
|
|
12
|
+
|
|
13
|
+
npx -p @codepassion/skills c9n skill install claude
|
|
12
14
|
|
|
13
15
|
Or: ./install.sh {claude|codex|project|combined|cowork}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
## CLI
|
|
18
|
+
|
|
19
|
+
### c9n skill
|
|
20
|
+
|
|
21
|
+
c9n skill install [target] # copy package skills into the target (skips existing; --force overwrites)
|
|
22
|
+
c9n skill list [target] # per-skill state: ok | drift | missing
|
|
23
|
+
c9n skill update [target] # overwrite drifted skills from the package (confirms; --yes skips)
|
|
24
|
+
c9n skill uninstall [target] # remove installed c9n skills (confirms; --yes skips)
|
|
25
|
+
c9n skill doctor [target] # diagnose skills dirs, drift, frontmatter, gh/zip tooling
|
|
26
|
+
|
|
27
|
+
Targets: `claude` (default, ~/.claude/skills) | `codex` (~/.codex/skills) |
|
|
28
|
+
`project` (./.agents/skills) | `combined` (one merged skill) | `cowork` (upload zips).
|
|
29
|
+
`--dest <dir>` overrides the directory; `--dry-run` previews any write.
|
|
30
|
+
|
|
31
|
+
State is a runtime content-hash comparison against the package — "drift" covers both
|
|
32
|
+
stale and hand-edited copies (see docs/adr/0001). `update` treats the package as the
|
|
33
|
+
source of truth; skill edits belong in this repo, not in installed copies.
|
|
34
|
+
|
|
35
|
+
claude.ai and Claude Cowork share a personal skill library with no upload API, so
|
|
36
|
+
`c9n skill install cowork` packages upload-ready zips into dist/cowork/ — upload them at
|
|
17
37
|
claude.ai → Settings → Capabilities → Upload skill.
|
|
18
38
|
|
|
39
|
+
`c9n-skills` (the old bin) still works as a deprecated alias for `c9n skill`.
|
|
40
|
+
|
|
41
|
+
### c9n deploy
|
|
42
|
+
|
|
43
|
+
Creates and pushes an annotated tag in any C9N project with a tag-driven pipeline —
|
|
44
|
+
the CLI never builds or ships artifacts itself.
|
|
45
|
+
|
|
46
|
+
c9n deploy -e alpha # tags vX.Y.Z-alpha.N (version from package.json, N auto-increments)
|
|
47
|
+
c9n deploy -e alpha -v 1.2.3 # explicit version — always bare X.Y.Z, never the -alpha.N suffix
|
|
48
|
+
c9n deploy -e production -v 1.2.3 # tags v1.2.3 (requires a prior alpha of the same version)
|
|
49
|
+
|
|
50
|
+
Guards (all must pass before the tag is pushed): clean working tree, on `main` and in
|
|
51
|
+
sync with origin, tag ordering (production requires a prior alpha; versions must move
|
|
52
|
+
forward), CI green on HEAD via `gh` (`--skip-ci` to bypass), and a confirmation prompt
|
|
53
|
+
(`--yes` for CI). `--dry-run` runs every guard and shows the tag without creating it.
|
|
54
|
+
|
|
19
55
|
## Edit
|
|
20
56
|
|
|
21
57
|
Per-topic SKILL.md files in skills/c9n-* are the source of truth.
|
package/bin/c9n-skills.js
CHANGED
|
@@ -1,61 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import { execFile } from 'node:child_process'
|
|
8
|
-
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
|
|
9
|
-
const [,, cmd='install', target='claude', ...rest] = process.argv
|
|
10
|
-
if (cmd==='help'||cmd==='--help'||cmd==='-h'){ help(); process.exit(0) }
|
|
11
|
-
if (cmd!=='install'){ help(); process.exit(1) }
|
|
12
|
-
const flags={dest:null,force:false,dryRun:false}
|
|
13
|
-
for (let i=0;i<rest.length;i++){ const a=rest[i]
|
|
14
|
-
if (a==='--force') flags.force=true
|
|
15
|
-
else if (a==='--dry-run') flags.dryRun=true
|
|
16
|
-
else if (a==='--dest'){ const v=rest[++i]
|
|
17
|
-
if (!v){ console.error('--dest requires a value'); process.exit(1) }
|
|
18
|
-
flags.dest=resolve(v) } }
|
|
19
|
-
// cowork: claude.ai / Cowork share a personal skill library with NO upload API —
|
|
20
|
-
// this packages upload-ready zips for manual drag-drop in Settings → Capabilities.
|
|
21
|
-
if (target==='cowork'){ await packageForCowork(flags); process.exit(0) }
|
|
22
|
-
const dest = flags.dest ?? defaultDest(target)
|
|
23
|
-
if (!dest){ help(); process.exit(1) }
|
|
24
|
-
const sources = await resolveSources(target)
|
|
25
|
-
if (!sources.length){ console.error('No skills found'); process.exit(1) }
|
|
26
|
-
console.log(`Installing ${sources.length} skill(s) into ${dest}${flags.dryRun?' (dry-run)':''}`)
|
|
27
|
-
if (!flags.dryRun) await mkdir(dest,{recursive:true})
|
|
28
|
-
for (const src of sources){ const n=src.split(/[\\/]/).pop(); const t=join(dest,n)
|
|
29
|
-
if (existsSync(t) && !flags.force){ console.log(` skip ${n}`); continue }
|
|
30
|
-
console.log(` copy ${n}`); if (!flags.dryRun) await cp(src,t,{recursive:true,force:true}) }
|
|
31
|
-
console.log('Done.')
|
|
32
|
-
async function resolveSources(t){
|
|
33
|
-
if (t==='combined'){ const c=join(SRC,'c9n'); return existsSync(c)?[c]:[] }
|
|
34
|
-
const out=[]; for (const e of await readdir(SRC)){ if (!e.startsWith('c9n-')) continue
|
|
35
|
-
const f=join(SRC,e); if ((await stat(f)).isDirectory()) out.push(f) }
|
|
36
|
-
return out }
|
|
37
|
-
function defaultDest(t){ const h=homedir()
|
|
38
|
-
if (t==='claude'||t==='combined') return join(h,'.claude','skills')
|
|
39
|
-
if (t==='codex') return join(h,'.codex','skills')
|
|
40
|
-
if (t==='project') return join(process.cwd(),'.agents','skills')
|
|
41
|
-
return null }
|
|
42
|
-
async function packageForCowork(flags){
|
|
43
|
-
const ROOT=resolve(SRC,'..')
|
|
44
|
-
// only auto-clean the default output dir — never delete a user-supplied --dest
|
|
45
|
-
const isDefaultOut=!flags.dest
|
|
46
|
-
const out=flags.dest ?? join(ROOT,'dist','cowork')
|
|
47
|
-
const sources=[...await resolveSources('combined'), ...await resolveSources('cowork')]
|
|
48
|
-
if (!sources.length){ console.error('No skills found'); process.exit(1) }
|
|
49
|
-
console.log(`Packaging ${sources.length} skill zip(s) into ${out}${flags.dryRun?' (dry-run)':''}`)
|
|
50
|
-
if (!flags.dryRun){ if (isDefaultOut) await rm(out,{recursive:true,force:true}); await mkdir(out,{recursive:true}) }
|
|
51
|
-
for (const src of sources){ const n=src.split(/[\\/]/).pop()
|
|
52
|
-
console.log(` zip ${n}.zip`)
|
|
53
|
-
if (!flags.dryRun) await zipDir(SRC,n,join(out,`${n}.zip`)) }
|
|
54
|
-
console.log('Done. Upload at claude.ai → Settings → Capabilities → Upload skill (shared with Cowork).')
|
|
55
|
-
console.log('Action skills (c9n-spec, c9n-deliverables) bundle their templates/scripts in the zip.')
|
|
56
|
-
}
|
|
57
|
-
function zipDir(cwd,name,outFile){
|
|
58
|
-
return new Promise((res,rej)=>{ execFile('zip',['-qr',outFile,name],{cwd},(e)=>{
|
|
59
|
-
if (e && e.code==='ENOENT') rej(new Error("'zip' command not found — install zip and retry"))
|
|
60
|
-
else if (e) rej(e); else res() }) }) }
|
|
61
|
-
function help(){ console.log('c9n-skills install [claude|codex|project|combined|cowork] [--dest <dir>] [--force] [--dry-run]') }
|
|
2
|
+
// Deprecated alias: `c9n-skills <cmd> ...` → `c9n skill <cmd> ...`
|
|
3
|
+
console.error('note: `c9n-skills` is deprecated — use `c9n skill <command>` instead')
|
|
4
|
+
process.argv.splice(2, 0, 'skill')
|
|
5
|
+
await import('./c9n.js')
|
package/bin/c9n.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { skillCommand, skillHelp } from './skill.js'
|
|
3
|
+
import { deployCommand, deployHelp } from './deploy.js'
|
|
4
|
+
|
|
5
|
+
const [, , cmd, ...rest] = process.argv
|
|
6
|
+
try {
|
|
7
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') help(cmd ? 0 : 1)
|
|
8
|
+
else if (cmd === 'skill') await skillCommand(rest)
|
|
9
|
+
else if (cmd === 'deploy') await deployCommand(rest)
|
|
10
|
+
else { console.error(`unknown command: ${cmd}`); help(1) }
|
|
11
|
+
} catch (e) {
|
|
12
|
+
console.error(`error: ${e.message}`)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function help(code) {
|
|
17
|
+
console.log(`c9n — CodePassion (C9N) engineering CLI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
c9n skill <install|list|update|uninstall|doctor> [target] [options]
|
|
21
|
+
c9n deploy -e <alpha|production> [-v <X.Y.Z>] [options]
|
|
22
|
+
|
|
23
|
+
Run \`c9n skill help\` or \`c9n deploy help\` for details.`)
|
|
24
|
+
process.exit(code)
|
|
25
|
+
}
|
package/bin/deploy.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { confirm, has, parseArgs, run } from './util.js'
|
|
4
|
+
|
|
5
|
+
const RELEASE_BRANCH = 'main'
|
|
6
|
+
|
|
7
|
+
export async function deployCommand(argv) {
|
|
8
|
+
if (['help', '--help', '-h'].includes(argv[0])) return deployHelp(0)
|
|
9
|
+
const flags = parseArgs(argv, { flags: ['yes', 'skip-ci', 'dry-run'], values: { env: 'e', version: 'v' } })
|
|
10
|
+
if (flags._.length) return deployHelp(1)
|
|
11
|
+
const env = flags.env
|
|
12
|
+
if (!env) throw new Error('missing -e <alpha|production>')
|
|
13
|
+
if (env !== 'alpha' && env !== 'production') throw new Error(`unknown environment: ${env} (use alpha | production)`)
|
|
14
|
+
|
|
15
|
+
const root = await git('rev-parse', '--show-toplevel').catch(() => { throw new Error('not inside a git repository') })
|
|
16
|
+
const version = await resolveVersion(flags.version, root)
|
|
17
|
+
|
|
18
|
+
// Guards: clean tree, on release branch, in sync with origin
|
|
19
|
+
const branch = await git('rev-parse', '--abbrev-ref', 'HEAD')
|
|
20
|
+
if (branch !== RELEASE_BRANCH) throw new Error(`deploy runs from ${RELEASE_BRANCH} — you are on ${branch}`)
|
|
21
|
+
if (await git('status', '--porcelain')) throw new Error('working tree is not clean — commit or stash first')
|
|
22
|
+
await git('fetch', 'origin', RELEASE_BRANCH, '--tags')
|
|
23
|
+
const head = await git('rev-parse', 'HEAD')
|
|
24
|
+
const remote = await git('rev-parse', `origin/${RELEASE_BRANCH}`)
|
|
25
|
+
if (head !== remote) throw new Error(`${RELEASE_BRANCH} is not in sync with origin/${RELEASE_BRANCH} — pull or push first`)
|
|
26
|
+
|
|
27
|
+
// Guard: tag ordering
|
|
28
|
+
const tags = (await git('tag', '-l', 'v*')).split('\n').filter(Boolean)
|
|
29
|
+
const prod = tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t)).map((t) => t.slice(1))
|
|
30
|
+
const latestProd = prod.sort(cmpVer).at(-1)
|
|
31
|
+
let tag
|
|
32
|
+
if (env === 'alpha') {
|
|
33
|
+
if (prod.includes(version)) throw new Error(`v${version} is already released to production — bump the version`)
|
|
34
|
+
if (latestProd && cmpVer(version, latestProd) < 0) throw new Error(`v${version} is older than latest production release v${latestProd}`)
|
|
35
|
+
const ns = tags.map((t) => t.match(new RegExp(`^v${version.replaceAll('.', '\\.')}-alpha\\.(\\d+)$`))).filter(Boolean).map((m) => Number(m[1]))
|
|
36
|
+
tag = `v${version}-alpha.${Math.max(0, ...ns) + 1}`
|
|
37
|
+
} else {
|
|
38
|
+
tag = `v${version}`
|
|
39
|
+
if (tags.includes(tag)) throw new Error(`${tag} already exists`)
|
|
40
|
+
if (!tags.some((t) => t.startsWith(`${tag}-alpha.`))) throw new Error(`no alpha of ${version} found — deploy \`-e alpha\` first`)
|
|
41
|
+
if (latestProd && cmpVer(version, latestProd) <= 0) throw new Error(`v${version} is not newer than latest production release v${latestProd}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Guard: CI green on HEAD
|
|
45
|
+
if (!flags['skip-ci']) await checkCi(head)
|
|
46
|
+
else console.log('· CI check skipped (--skip-ci)')
|
|
47
|
+
|
|
48
|
+
const subject = await git('log', '-1', '--format=%h %s')
|
|
49
|
+
console.log(`\nDeploy ${env}`)
|
|
50
|
+
console.log(` tag ${tag}`)
|
|
51
|
+
console.log(` commit ${subject}`)
|
|
52
|
+
if (flags['dry-run']) { console.log('\n(dry-run) would create and push the tag.'); return }
|
|
53
|
+
if (!flags.yes && !(await confirm(`\nCreate and push ${tag}?`))) { console.log('Aborted.'); return }
|
|
54
|
+
|
|
55
|
+
await git('tag', '-a', tag, '-m', tag)
|
|
56
|
+
try {
|
|
57
|
+
await git('push', 'origin', tag)
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new Error(`tag ${tag} created locally but push failed — retry with \`git push origin ${tag}\`\n${e.stderr ?? e.message}`)
|
|
60
|
+
}
|
|
61
|
+
console.log(`Pushed ${tag} — the pipeline takes it from here.`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveVersion(raw, root) {
|
|
65
|
+
let v = raw
|
|
66
|
+
if (!v) {
|
|
67
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8').catch(() => { throw new Error('no -v given and no package.json at repo root') }))
|
|
68
|
+
v = pkg.version
|
|
69
|
+
if (!v) throw new Error('no -v given and package.json has no version')
|
|
70
|
+
}
|
|
71
|
+
v = v.replace(/^v/, '')
|
|
72
|
+
if (!/^\d+\.\d+\.\d+$/.test(v)) throw new Error(`version must be bare X.Y.Z (got ${raw ?? v}) — the CLI appends -alpha.N itself`)
|
|
73
|
+
return v
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function checkCi(sha) {
|
|
77
|
+
if (!(await has('gh'))) throw new Error('gh CLI required for the CI check — install GitHub CLI or pass --skip-ci')
|
|
78
|
+
let runs
|
|
79
|
+
try {
|
|
80
|
+
runs = JSON.parse(await run('gh', ['run', 'list', '--commit', sha, '--json', 'name,status,conclusion', '--limit', '50']))
|
|
81
|
+
} catch (e) {
|
|
82
|
+
throw new Error(`could not query CI runs via gh — ${String(e.stderr ?? e.message).trim()} (or pass --skip-ci)`)
|
|
83
|
+
}
|
|
84
|
+
if (!runs.length) throw new Error('no CI runs found for HEAD — wait for CI to start or pass --skip-ci')
|
|
85
|
+
const pending = runs.filter((r) => r.status !== 'completed')
|
|
86
|
+
if (pending.length) throw new Error(`CI still running for HEAD: ${pending.map((r) => r.name).join(', ')}`)
|
|
87
|
+
const failed = runs.filter((r) => !['success', 'skipped', 'neutral'].includes(r.conclusion))
|
|
88
|
+
if (failed.length) throw new Error(`CI not green for HEAD: ${failed.map((r) => `${r.name} (${r.conclusion})`).join(', ')}`)
|
|
89
|
+
console.log(`· CI green on HEAD (${runs.length} run(s))`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cmpVer(a, b) {
|
|
93
|
+
const [ax, ay, az] = a.split('.').map(Number); const [bx, by, bz] = b.split('.').map(Number)
|
|
94
|
+
return ax - bx || ay - by || az - bz
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function git(...args) {
|
|
98
|
+
return run('git', args)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function deployHelp(code) {
|
|
102
|
+
console.log(`Usage: c9n deploy -e <alpha|production> [-v <X.Y.Z>] [options]
|
|
103
|
+
|
|
104
|
+
Creates and pushes an annotated tag — the tag-driven pipeline does the rest.
|
|
105
|
+
alpha tags vX.Y.Z-alpha.N (N auto-increments)
|
|
106
|
+
production tags vX.Y.Z (requires a prior alpha of the same version)
|
|
107
|
+
|
|
108
|
+
Options:
|
|
109
|
+
-v <X.Y.Z> version to deploy (default: package.json version; never include -alpha.N)
|
|
110
|
+
--yes skip the confirmation prompt
|
|
111
|
+
--skip-ci skip the CI-green check (no gh needed)
|
|
112
|
+
--dry-run run all guards, show the tag, don't create it`)
|
|
113
|
+
process.exit(code)
|
|
114
|
+
}
|
package/bin/skill.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { confirm, has, parseArgs } from './util.js'
|
|
9
|
+
|
|
10
|
+
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
|
|
11
|
+
const TARGETS = ['claude', 'codex', 'project', 'combined', 'cowork']
|
|
12
|
+
|
|
13
|
+
export async function skillCommand(argv) {
|
|
14
|
+
if (['help', '--help', '-h'].includes(argv[0])) return skillHelp(0)
|
|
15
|
+
const flags = parseArgs(argv, { flags: ['force', 'dry-run', 'yes'], values: { dest: null } })
|
|
16
|
+
const [sub = 'install', target = 'claude'] = flags._
|
|
17
|
+
if (!TARGETS.includes(target)) throw new Error(`unknown target: ${target} (use ${TARGETS.join(' | ')})`)
|
|
18
|
+
const cmds = { install, list, update, uninstall, doctor }
|
|
19
|
+
if (!cmds[sub]) { console.error(`unknown skill command: ${sub}`); return skillHelp(1) }
|
|
20
|
+
if (sub !== 'install' && target === 'cowork') {
|
|
21
|
+
throw new Error(`cowork is packaged as zips, not installed — only \`c9n skill install cowork\` applies`)
|
|
22
|
+
}
|
|
23
|
+
await cmds[sub](target, { ...flags, dryRun: flags['dry-run'], dest: flags.dest ? resolve(flags.dest) : null })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function install(target, flags) {
|
|
27
|
+
if (target === 'cowork') return packageForCowork(flags)
|
|
28
|
+
const dest = flags.dest ?? defaultDest(target)
|
|
29
|
+
const sources = await resolveSources(target)
|
|
30
|
+
if (!sources.length) throw new Error('No skills found')
|
|
31
|
+
console.log(`Installing ${sources.length} skill(s) into ${dest}${flags.dryRun ? ' (dry-run)' : ''}`)
|
|
32
|
+
if (!flags.dryRun) await mkdir(dest, { recursive: true })
|
|
33
|
+
for (const src of sources) {
|
|
34
|
+
const n = src.split(/[\\/]/).pop(); const t = join(dest, n)
|
|
35
|
+
if (existsSync(t) && !flags.force) { console.log(` skip ${n}`); continue }
|
|
36
|
+
console.log(` copy ${n}`)
|
|
37
|
+
if (!flags.dryRun) await cp(src, t, { recursive: true, force: true })
|
|
38
|
+
}
|
|
39
|
+
console.log('Done.')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function list(target, flags) {
|
|
43
|
+
const dest = flags.dest ?? defaultDest(target)
|
|
44
|
+
const rows = await status(target, dest)
|
|
45
|
+
console.log(`Skills in ${dest}:`)
|
|
46
|
+
for (const r of rows) console.log(` ${r.state.padEnd(9)} ${r.name}`)
|
|
47
|
+
const drifted = rows.filter((r) => r.state === 'drift').length
|
|
48
|
+
if (drifted) console.log(`\n${drifted} skill(s) differ from the package — \`c9n skill update ${target}\` overwrites them.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function update(target, flags) {
|
|
52
|
+
const dest = flags.dest ?? defaultDest(target)
|
|
53
|
+
const drifted = (await status(target, dest)).filter((r) => r.state === 'drift')
|
|
54
|
+
if (!drifted.length) { console.log('All installed skills match the package — nothing to update.'); return }
|
|
55
|
+
console.log(`Package differs from ${drifted.length} installed skill(s) in ${dest}:`)
|
|
56
|
+
for (const r of drifted) console.log(` ${r.name}`)
|
|
57
|
+
console.log('Local edits will be overwritten — the package is the source of truth.')
|
|
58
|
+
if (flags.dryRun) { console.log('(dry-run) no changes made.'); return }
|
|
59
|
+
if (!flags.yes && !(await confirm(`Overwrite ${drifted.length} skill(s)?`))) { console.log('Aborted.'); return }
|
|
60
|
+
for (const r of drifted) {
|
|
61
|
+
console.log(` copy ${r.name}`)
|
|
62
|
+
await cp(join(SRC, r.name), join(dest, r.name), { recursive: true, force: true })
|
|
63
|
+
}
|
|
64
|
+
console.log('Done.')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function uninstall(target, flags) {
|
|
68
|
+
const dest = flags.dest ?? defaultDest(target)
|
|
69
|
+
const installed = (await status(target, dest)).filter((r) => r.state !== 'missing')
|
|
70
|
+
if (!installed.length) { console.log(`No c9n skills installed in ${dest}.`); return }
|
|
71
|
+
console.log(`Removing ${installed.length} skill(s) from ${dest}:`)
|
|
72
|
+
for (const r of installed) console.log(` ${r.name}`)
|
|
73
|
+
if (flags.dryRun) { console.log('(dry-run) no changes made.'); return }
|
|
74
|
+
if (!flags.yes && !(await confirm(`Remove ${installed.length} skill(s)?`))) { console.log('Aborted.'); return }
|
|
75
|
+
for (const r of installed) await rm(join(dest, r.name), { recursive: true, force: true })
|
|
76
|
+
console.log('Done.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function doctor(target, flags) {
|
|
80
|
+
const dest = flags.dest ?? defaultDest(target)
|
|
81
|
+
let problems = 0
|
|
82
|
+
const check = (ok, label, hint) => {
|
|
83
|
+
console.log(` ${ok ? '✓' : '✗'} ${label}${ok || !hint ? '' : ` — ${hint}`}`)
|
|
84
|
+
if (!ok) problems++
|
|
85
|
+
}
|
|
86
|
+
console.log(`Doctor (target: ${target})`)
|
|
87
|
+
check(existsSync(dest), `skills dir exists: ${dest}`, `run \`c9n skill install ${target}\``)
|
|
88
|
+
if (existsSync(dest)) {
|
|
89
|
+
const rows = await status(target, dest)
|
|
90
|
+
const fresh = rows.filter((r) => r.state === 'ok').length
|
|
91
|
+
const drift = rows.filter((r) => r.state === 'drift')
|
|
92
|
+
const missing = rows.filter((r) => r.state === 'missing')
|
|
93
|
+
check(!drift.length, `installed skills match package (${fresh}/${rows.length} fresh)`,
|
|
94
|
+
`${drift.length} differ (${drift.map((r) => r.name).join(', ')}) — \`c9n skill update ${target}\``)
|
|
95
|
+
if (missing.length) console.log(` · ${missing.length} not installed (${missing.map((r) => r.name).join(', ')})`)
|
|
96
|
+
for (const r of rows) {
|
|
97
|
+
if (r.state === 'missing') continue
|
|
98
|
+
const f = join(dest, r.name, 'SKILL.md')
|
|
99
|
+
const ok = existsSync(f) && /^---\r?\n[\s\S]*?\bname:/.test(await readFile(f, 'utf8'))
|
|
100
|
+
if (!ok) check(false, `${r.name}/SKILL.md has valid frontmatter`, 'reinstall with --force')
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
check(await has('gh'), 'gh CLI available (needed by `c9n deploy` CI check)', 'install GitHub CLI or use --skip-ci')
|
|
104
|
+
check(await hasZip(), 'zip available (needed by `c9n skill install cowork`)', 'install zip')
|
|
105
|
+
if (problems) { console.log(`\n${problems} problem(s) found.`); process.exitCode = 1 }
|
|
106
|
+
else console.log('\nAll good.')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function status(target, dest) {
|
|
110
|
+
const rows = []
|
|
111
|
+
for (const src of await resolveSources(target)) {
|
|
112
|
+
const name = src.split(/[\\/]/).pop(); const t = join(dest, name)
|
|
113
|
+
if (!existsSync(t)) { rows.push({ name, state: 'missing' }); continue }
|
|
114
|
+
rows.push({ name, state: (await hashDir(src)) === (await hashDir(t)) ? 'ok' : 'drift' })
|
|
115
|
+
}
|
|
116
|
+
return rows
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function hashDir(dir) {
|
|
120
|
+
const h = createHash('sha256')
|
|
121
|
+
for (const rel of (await walk(dir)).sort()) {
|
|
122
|
+
h.update(rel); h.update('\0'); h.update(await readFile(join(dir, rel))); h.update('\0')
|
|
123
|
+
}
|
|
124
|
+
return h.digest('hex')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function walk(dir, prefix = '') {
|
|
128
|
+
const out = []
|
|
129
|
+
for (const e of await readdir(dir)) {
|
|
130
|
+
if (e === '.DS_Store') continue
|
|
131
|
+
const f = join(dir, e); const rel = prefix ? `${prefix}/${e}` : e
|
|
132
|
+
if ((await stat(f)).isDirectory()) out.push(...await walk(f, rel))
|
|
133
|
+
else out.push(rel)
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function resolveSources(t) {
|
|
139
|
+
if (t === 'combined') { const c = join(SRC, 'c9n'); return existsSync(c) ? [c] : [] }
|
|
140
|
+
const out = []
|
|
141
|
+
for (const e of await readdir(SRC)) {
|
|
142
|
+
if (!e.startsWith('c9n-')) continue
|
|
143
|
+
const f = join(SRC, e); if ((await stat(f)).isDirectory()) out.push(f)
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function defaultDest(t) {
|
|
149
|
+
const h = homedir()
|
|
150
|
+
if (t === 'claude' || t === 'combined') return join(h, '.claude', 'skills')
|
|
151
|
+
if (t === 'codex') return join(h, '.codex', 'skills')
|
|
152
|
+
if (t === 'project') return join(process.cwd(), '.agents', 'skills')
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// cowork: claude.ai / Cowork share a personal skill library with NO upload API —
|
|
157
|
+
// this packages upload-ready zips for manual drag-drop in Settings → Capabilities.
|
|
158
|
+
async function packageForCowork(flags) {
|
|
159
|
+
const ROOT = resolve(SRC, '..')
|
|
160
|
+
// only auto-clean the default output dir — never delete a user-supplied --dest
|
|
161
|
+
const isDefaultOut = !flags.dest
|
|
162
|
+
const out = flags.dest ?? join(ROOT, 'dist', 'cowork')
|
|
163
|
+
const sources = [...await resolveSources('combined'), ...await resolveSources('cowork')]
|
|
164
|
+
if (!sources.length) throw new Error('No skills found')
|
|
165
|
+
console.log(`Packaging ${sources.length} skill zip(s) into ${out}${flags.dryRun ? ' (dry-run)' : ''}`)
|
|
166
|
+
if (!flags.dryRun) { if (isDefaultOut) await rm(out, { recursive: true, force: true }); await mkdir(out, { recursive: true }) }
|
|
167
|
+
for (const src of sources) {
|
|
168
|
+
const n = src.split(/[\\/]/).pop()
|
|
169
|
+
console.log(` zip ${n}.zip`)
|
|
170
|
+
if (!flags.dryRun) await zipDir(SRC, n, join(out, `${n}.zip`))
|
|
171
|
+
}
|
|
172
|
+
console.log('Done. Upload at claude.ai → Settings → Capabilities → Upload skill (shared with Cowork).')
|
|
173
|
+
console.log('Action skills (c9n-spec, c9n-deliverables) bundle their templates/scripts in the zip.')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function zipDir(cwd, name, outFile) {
|
|
177
|
+
return new Promise((res, rej) => {
|
|
178
|
+
execFile('zip', ['-qr', outFile, name], { cwd }, (e) => {
|
|
179
|
+
if (e && e.code === 'ENOENT') rej(new Error("'zip' command not found — install zip and retry"))
|
|
180
|
+
else if (e) rej(e); else res()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function hasZip() {
|
|
186
|
+
return new Promise((res) => execFile('zip', ['-v'], (e) => res(!e || e.code !== 'ENOENT')))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function skillHelp(code) {
|
|
190
|
+
console.log(`Usage: c9n skill <command> [target] [options]
|
|
191
|
+
|
|
192
|
+
Commands:
|
|
193
|
+
install copy package skills into the target (default command)
|
|
194
|
+
list show installed/missing/drift state per skill
|
|
195
|
+
update overwrite drifted installed skills from the package
|
|
196
|
+
uninstall remove installed c9n skills from the target
|
|
197
|
+
doctor diagnose skills dirs and required tooling
|
|
198
|
+
|
|
199
|
+
Targets: ${TARGETS.join(' | ')} (default: claude)
|
|
200
|
+
|
|
201
|
+
Options:
|
|
202
|
+
--dest <dir> override the target directory
|
|
203
|
+
--force install: overwrite existing skills
|
|
204
|
+
--yes update/uninstall: skip confirmation
|
|
205
|
+
--dry-run show what would happen without writing`)
|
|
206
|
+
process.exit(code)
|
|
207
|
+
}
|
package/bin/util.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { createInterface } from 'node:readline'
|
|
3
|
+
|
|
4
|
+
export function run(cmd, args, opts = {}) {
|
|
5
|
+
return new Promise((res, rej) => {
|
|
6
|
+
execFile(cmd, args, opts, (e, stdout, stderr) => {
|
|
7
|
+
if (e) { e.stdout = stdout; e.stderr = stderr; rej(e) }
|
|
8
|
+
else res(String(stdout).trim())
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function has(cmd) {
|
|
14
|
+
try { await run(cmd, ['--version']); return true } catch { return false }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function confirm(question) {
|
|
18
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
19
|
+
const answer = await new Promise((r) => rl.question(`${question} [y/N] `, r))
|
|
20
|
+
rl.close()
|
|
21
|
+
return /^y(es)?$/i.test(answer.trim())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseArgs(argv, spec) {
|
|
25
|
+
// spec: { flags: ['force', ...], values: { dest: null, env: 'e', version: 'v' } }
|
|
26
|
+
const out = { _: [] }
|
|
27
|
+
for (const k of spec.flags ?? []) out[k] = false
|
|
28
|
+
for (const k of Object.keys(spec.values ?? {})) out[k] = null
|
|
29
|
+
const shorts = Object.fromEntries(Object.entries(spec.values ?? {}).filter(([, s]) => s).map(([k, s]) => [`-${s}`, k]))
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i]
|
|
32
|
+
const flag = a.startsWith('--') ? a.slice(2) : null
|
|
33
|
+
if (flag && (spec.flags ?? []).includes(flag)) { out[flag] = true; continue }
|
|
34
|
+
const key = flag && flag in (spec.values ?? {}) ? flag : shorts[a]
|
|
35
|
+
if (key) {
|
|
36
|
+
const v = argv[++i]
|
|
37
|
+
if (v === undefined) throw new Error(`${a} requires a value`)
|
|
38
|
+
out[key] = v
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (a.startsWith('-')) throw new Error(`unknown option: ${a}`)
|
|
42
|
+
out._.push(a)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codepassion/skills",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Agent skills (SKILL.md) for CodePassion (C9N) engineering conventions.",
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Agent skills (SKILL.md) for CodePassion (C9N) engineering conventions, plus the c9n CLI for skill installs and tag-based deploys.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
+
"c9n": "./bin/c9n.js",
|
|
8
9
|
"c9n-skills": "./bin/c9n-skills.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
package/skills/c9n/SKILL.md
CHANGED
|
@@ -271,12 +271,20 @@ already owns, not a file drop (see Phase 2).
|
|
|
271
271
|
|
|
272
272
|
Run in order; this is a table of contents — rules are below.
|
|
273
273
|
|
|
274
|
-
1. **Detect (Phase 1).**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
274
|
+
1. **Detect (Phase 1).** First decide the repo shape:
|
|
275
|
+
- **Monorepo** — `apps/` and/or `turbo.json` exist. Scan `apps/*/package.json`, classify each:
|
|
276
|
+
`next`→web, `elysia`→api, else→service. `{{APP_DIR}}` = `apps/<name>`.
|
|
277
|
+
- **Single-app** — no `apps/`, no `turbo.json`; the deployable app is the repo root. Classify the
|
|
278
|
+
root `package.json` (`next`→web, etc.). `{{APP_DIR}}` = `.`, `{{PACKAGE_NAME}}` = the repo name
|
|
279
|
+
(no `-web` suffix needed), and the **single-app template variants** apply (see "Single-app repos"
|
|
280
|
+
below) — drop the workspace-manifest copies, `outputFileTracingRoot`, `transpilePackages`, and
|
|
281
|
+
the `apps/web/` path segments. The generated `ci.yml` also drops Turbo (see that template's note).
|
|
282
|
+
|
|
283
|
+
Then detect DB by **real artifacts** — a migration-generate script in some `package.json` AND a
|
|
284
|
+
migrations dir on disk — capture their actual names; never assume `drizzle:generate` /
|
|
285
|
+
`packages/supabase/migrations`. Read the app's `package.json` for the Bun version / lockfile name
|
|
286
|
+
(but **pin ≥ 1.3.10** — see `{{BUN_VERSION}}`). Ask which environments exist (Alpha / Beta /
|
|
287
|
+
Production). **Print the detected plan and confirm before writing anything.**
|
|
280
288
|
2. **Generate (Phase 2).** Copy templates with tokens substituted. **Diff/confirm per file** — if a
|
|
281
289
|
target exists, show the diff and ask overwrite/skip/merge; never blind-clobber; leave unrelated
|
|
282
290
|
files (a hand-kept `staging.yaml`) alone. Then do the **web edits** (below). Run the web build
|
|
@@ -291,11 +299,11 @@ Run in order; this is a table of contents — rules are below.
|
|
|
291
299
|
| Token | Source |
|
|
292
300
|
|-------|--------|
|
|
293
301
|
| `{{PACKAGE_NAME}}` | `<project>-<appkey>`, e.g. `smiley-web`. Lowercase. This is the ghcr image name and the `package_name` workflow input. |
|
|
294
|
-
| `{{APP_DIR}}` | The app's path, e.g. `apps/web`. |
|
|
302
|
+
| `{{APP_DIR}}` | The app's path, e.g. `apps/web`. **Single-app repo: `.`** (the app is the repo root — this leaves valid `./.next/...` paths in the template). |
|
|
295
303
|
| `{{APP_KEY}}` | Short lowercase id unique per app: `api`, `web`, `worker`. Used in job names. |
|
|
296
304
|
| `{{APP_PORT}}` | `3001` api · `3000` web · service's listen port (drop `EXPOSE` for a portless worker). |
|
|
297
|
-
| `{{BUN_VERSION}}` | Pin
|
|
298
|
-
| `{{WORKSPACE_MANIFEST_COPIES}}` | **web template only:** one `COPY <app>/package.json ./<app>/package.json` line **per app** — the `deps` stage runs `bun install` before `COPY . .`, so it needs every workspace manifest to resolve the graph. The api/service compile templates copy only the built app's manifest (matches Smiley's api Dockerfile; avoids pulling a sibling app's postinstall without its src). |
|
|
305
|
+
| `{{BUN_VERSION}}` | **Pin ≥ 1.3.10** (same across all Dockerfiles). Do **not** copy the host's `bun --version` — 1.2.x breaks `next build` on Linux with `TypeError: Expected CommonJS module to have a function wrapper` (edge/jest-worker; only shows on Linux, not macOS). |
|
|
306
|
+
| `{{WORKSPACE_MANIFEST_COPIES}}` | **web template only:** one `COPY <app>/package.json ./<app>/package.json` line **per app** — the `deps` stage runs `bun install` before `COPY . .`, so it needs every workspace manifest to resolve the graph. The api/service compile templates copy only the built app's manifest (matches Smiley's api Dockerfile; avoids pulling a sibling app's postinstall without its src). **Single-app repo: empty string** (root `package.json` + `bun.lock` are already copied; there is no `packages/` — also delete the `COPY packages/ ./packages/` line). |
|
|
299
307
|
| `{{MIGRATION_CHECK}}` | `_migration-check.partial.yml` with `{{MIGRATION_SCRIPT}}`/`{{MIGRATIONS_DIR}}` filled, **or empty string** if no DB. |
|
|
300
308
|
| `{{ORG_REF}}` | The org reusable-workflow ref — currently `turborepo`. One place; bump here if the org repo retags. |
|
|
301
309
|
| `{{BUILD_JOBS}}` / `{{DEPLOY_JOBS}}` | Stamp `_build-job.partial` once per app (Alpha only) and `_deploy-job.partial` once per app per environment, pruning unused envs. |
|
|
@@ -306,17 +314,46 @@ Run in order; this is a table of contents — rules are below.
|
|
|
306
314
|
For each **web** app, edit the files the dev owns; fail loudly if they can't be parsed rather than
|
|
307
315
|
overwrite:
|
|
308
316
|
|
|
309
|
-
- `next.config.js` — add `output: "standalone"
|
|
310
|
-
`transpilePackages` for the workspace packages the app imports.
|
|
317
|
+
- `next.config.js` — add `output: "standalone"`. **Monorepo only:** also add `outputFileTracingRoot`
|
|
318
|
+
(monorepo root) and `transpilePackages` for the workspace packages the app imports. **Single-app:
|
|
319
|
+
omit both** (root app traces natively; no workspace packages).
|
|
311
320
|
- `app/layout.tsx` — add `<head><PublicEnvScript /></head>` (`import { PublicEnvScript } from
|
|
312
|
-
"next-runtime-env"`).
|
|
313
|
-
|
|
321
|
+
"next-runtime-env"`).
|
|
322
|
+
- **Convert EVERY `process.env.NEXT_PUBLIC_*` read to `env("NEXT_PUBLIC_X")`** — not just client
|
|
323
|
+
components. Next **inlines every `process.env.NEXT_PUBLIC_*` literal at build time in SERVER code and
|
|
324
|
+
middleware too**, so any read left as `process.env` bakes the build value (or a build-time
|
|
325
|
+
placeholder) into the server/middleware bundle and runtime injection can't override it. This is the
|
|
326
|
+
#1 time-sink: a stray `process.env.NEXT_PUBLIC_SUPABASE_URL` in `middleware.ts` bakes the placeholder
|
|
327
|
+
→ `supabase.auth.getUser()` fails → **auth redirect loop** that only shows in the container, not
|
|
328
|
+
locally. Convert: client components, **server components, route handlers (`app/api/**`), middleware,
|
|
329
|
+
and any t3-env `runtimeEnv` mapping**. Non-public server vars (`DATABASE_URL`, `*_SERVICE_KEY`) stay
|
|
330
|
+
on `process.env` — they aren't inlined and are present at runtime.
|
|
331
|
+
- **Phase-2 gate — must pass before you call the web edits done:**
|
|
332
|
+
`grep -rn 'process.env.NEXT_PUBLIC' {{APP_DIR}}` (the app dir — `.` for single-app, `apps/web` for a
|
|
333
|
+
monorepo app; not every repo has a `src/`) returns **nothing**. Any hit is a latent runtime bug.
|
|
314
334
|
- Flag `next-runtime-env` as a dependency to add — **do not run `bun add` yourself**; tell the dev.
|
|
315
335
|
- **Caveat:** runtime values only reach the browser on non-statically-prerendered pages. A page that
|
|
316
336
|
reads runtime public env must opt out of static caching (`export const dynamic = "force-dynamic"`)
|
|
317
337
|
or rely on `<PublicEnvScript>` keeping the document dynamic. Note this so future feature work
|
|
318
338
|
doesn't silently bake a static page.
|
|
319
339
|
|
|
340
|
+
## Single-app repos (no `apps/`, no `turbo.json`)
|
|
341
|
+
|
|
342
|
+
The templates are monorepo-shaped; one set of token substitutions makes them serve a root-level single
|
|
343
|
+
app. Apply these deltas (verified on `ddg-jewelry-admin`):
|
|
344
|
+
|
|
345
|
+
- **Tokens:** `{{APP_DIR}}` = `.`, `{{PACKAGE_NAME}}` = repo name (no `-web` suffix),
|
|
346
|
+
`{{WORKSPACE_MANIFEST_COPIES}}` = empty.
|
|
347
|
+
- **`Dockerfile.web`:** delete the `COPY packages/ ./packages/` line (no `packages/`). The runner
|
|
348
|
+
`COPY` lines collapse to `.next/standalone ./`, `.next/static ./.next/static`, `public ./public`
|
|
349
|
+
(the `{{APP_DIR}}=.` substitution yields valid `./.next/...` — fine to simplify).
|
|
350
|
+
- **`next.config`:** `output: "standalone"` only — no `outputFileTracingRoot`, no `transpilePackages`.
|
|
351
|
+
- **`ci.yml`:** drop the Turbo `env:`, the `.turbo` cache step, and swap `bun run check-types` for
|
|
352
|
+
`bunx tsc --noEmit` if the repo has no `check-types` script (single apps usually don't).
|
|
353
|
+
- **Runner (optional but recommended):** a single Next app deploys cleanly on a Node runner
|
|
354
|
+
(`node:22-alpine` or `gcr.io/distroless/nodejs22-debian12`, `CMD ["server.js"]`). Distroless cuts the
|
|
355
|
+
image ~4× and drops the runner CVEs (seen on `ddg-jewelry-admin`).
|
|
356
|
+
|
|
320
357
|
## Phase 3 — interactive ops checklist (the deliverable)
|
|
321
358
|
|
|
322
359
|
Derived from the actual `codepassion-team/cicd` reusable workflows. Walk these one at a time:
|
|
@@ -342,8 +379,14 @@ visibility), or the image pull silently fails.
|
|
|
342
379
|
**Web runtime env in Coolify** (the scars):
|
|
343
380
|
- `PORT=3000` — Next standalone obeys `PORT`; Coolify defaults it to `80`, which 502s behind a
|
|
344
381
|
Caddy `:3000` proxy. This is the single most common web-deploy failure.
|
|
345
|
-
- `
|
|
382
|
+
- **Coolify `Ports Exposes` (General page) is a SEPARATE setting from the `PORT` env** — it defaults
|
|
383
|
+
to `80` and must be set to the app port (`3000`). With `PORT=3000` but Ports Exposes still `80`, the
|
|
384
|
+
deploy log warns `PORT (3000) does not match configured ports_exposes: 80` and the proxy 502s. Fix
|
|
385
|
+
both.
|
|
386
|
+
- `HOSTNAME=0.0.0.0` — the Dockerfile already bakes this; only set it if something overrides it.
|
|
346
387
|
- The app's `NEXT_PUBLIC_*` values — injected here at runtime, not baked into the image.
|
|
388
|
+
- **Set ALL env values UNQUOTED** (public and server alike) — Coolify passes values literally, so a
|
|
389
|
+
quoted `DATABASE_URL="…"` keeps the quotes and breaks `new URL()` (`ERR_INVALID_URL`).
|
|
347
390
|
|
|
348
391
|
## Phase 4 — verify
|
|
349
392
|
|
|
@@ -31,12 +31,20 @@ already owns, not a file drop (see Phase 2).
|
|
|
31
31
|
|
|
32
32
|
Run in order; this is a table of contents — rules are below.
|
|
33
33
|
|
|
34
|
-
1. **Detect (Phase 1).**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
1. **Detect (Phase 1).** First decide the repo shape:
|
|
35
|
+
- **Monorepo** — `apps/` and/or `turbo.json` exist. Scan `apps/*/package.json`, classify each:
|
|
36
|
+
`next`→web, `elysia`→api, else→service. `{{APP_DIR}}` = `apps/<name>`.
|
|
37
|
+
- **Single-app** — no `apps/`, no `turbo.json`; the deployable app is the repo root. Classify the
|
|
38
|
+
root `package.json` (`next`→web, etc.). `{{APP_DIR}}` = `.`, `{{PACKAGE_NAME}}` = the repo name
|
|
39
|
+
(no `-web` suffix needed), and the **single-app template variants** apply (see "Single-app repos"
|
|
40
|
+
below) — drop the workspace-manifest copies, `outputFileTracingRoot`, `transpilePackages`, and
|
|
41
|
+
the `apps/web/` path segments. The generated `ci.yml` also drops Turbo (see that template's note).
|
|
42
|
+
|
|
43
|
+
Then detect DB by **real artifacts** — a migration-generate script in some `package.json` AND a
|
|
44
|
+
migrations dir on disk — capture their actual names; never assume `drizzle:generate` /
|
|
45
|
+
`packages/supabase/migrations`. Read the app's `package.json` for the Bun version / lockfile name
|
|
46
|
+
(but **pin ≥ 1.3.10** — see `{{BUN_VERSION}}`). Ask which environments exist (Alpha / Beta /
|
|
47
|
+
Production). **Print the detected plan and confirm before writing anything.**
|
|
40
48
|
2. **Generate (Phase 2).** Copy templates with tokens substituted. **Diff/confirm per file** — if a
|
|
41
49
|
target exists, show the diff and ask overwrite/skip/merge; never blind-clobber; leave unrelated
|
|
42
50
|
files (a hand-kept `staging.yaml`) alone. Then do the **web edits** (below). Run the web build
|
|
@@ -51,11 +59,11 @@ Run in order; this is a table of contents — rules are below.
|
|
|
51
59
|
| Token | Source |
|
|
52
60
|
|-------|--------|
|
|
53
61
|
| `{{PACKAGE_NAME}}` | `<project>-<appkey>`, e.g. `smiley-web`. Lowercase. This is the ghcr image name and the `package_name` workflow input. |
|
|
54
|
-
| `{{APP_DIR}}` | The app's path, e.g. `apps/web`. |
|
|
62
|
+
| `{{APP_DIR}}` | The app's path, e.g. `apps/web`. **Single-app repo: `.`** (the app is the repo root — this leaves valid `./.next/...` paths in the template). |
|
|
55
63
|
| `{{APP_KEY}}` | Short lowercase id unique per app: `api`, `web`, `worker`. Used in job names. |
|
|
56
64
|
| `{{APP_PORT}}` | `3001` api · `3000` web · service's listen port (drop `EXPOSE` for a portless worker). |
|
|
57
|
-
| `{{BUN_VERSION}}` | Pin
|
|
58
|
-
| `{{WORKSPACE_MANIFEST_COPIES}}` | **web template only:** one `COPY <app>/package.json ./<app>/package.json` line **per app** — the `deps` stage runs `bun install` before `COPY . .`, so it needs every workspace manifest to resolve the graph. The api/service compile templates copy only the built app's manifest (matches Smiley's api Dockerfile; avoids pulling a sibling app's postinstall without its src). |
|
|
65
|
+
| `{{BUN_VERSION}}` | **Pin ≥ 1.3.10** (same across all Dockerfiles). Do **not** copy the host's `bun --version` — 1.2.x breaks `next build` on Linux with `TypeError: Expected CommonJS module to have a function wrapper` (edge/jest-worker; only shows on Linux, not macOS). |
|
|
66
|
+
| `{{WORKSPACE_MANIFEST_COPIES}}` | **web template only:** one `COPY <app>/package.json ./<app>/package.json` line **per app** — the `deps` stage runs `bun install` before `COPY . .`, so it needs every workspace manifest to resolve the graph. The api/service compile templates copy only the built app's manifest (matches Smiley's api Dockerfile; avoids pulling a sibling app's postinstall without its src). **Single-app repo: empty string** (root `package.json` + `bun.lock` are already copied; there is no `packages/` — also delete the `COPY packages/ ./packages/` line). |
|
|
59
67
|
| `{{MIGRATION_CHECK}}` | `_migration-check.partial.yml` with `{{MIGRATION_SCRIPT}}`/`{{MIGRATIONS_DIR}}` filled, **or empty string** if no DB. |
|
|
60
68
|
| `{{ORG_REF}}` | The org reusable-workflow ref — currently `turborepo`. One place; bump here if the org repo retags. |
|
|
61
69
|
| `{{BUILD_JOBS}}` / `{{DEPLOY_JOBS}}` | Stamp `_build-job.partial` once per app (Alpha only) and `_deploy-job.partial` once per app per environment, pruning unused envs. |
|
|
@@ -66,17 +74,46 @@ Run in order; this is a table of contents — rules are below.
|
|
|
66
74
|
For each **web** app, edit the files the dev owns; fail loudly if they can't be parsed rather than
|
|
67
75
|
overwrite:
|
|
68
76
|
|
|
69
|
-
- `next.config.js` — add `output: "standalone"
|
|
70
|
-
`transpilePackages` for the workspace packages the app imports.
|
|
77
|
+
- `next.config.js` — add `output: "standalone"`. **Monorepo only:** also add `outputFileTracingRoot`
|
|
78
|
+
(monorepo root) and `transpilePackages` for the workspace packages the app imports. **Single-app:
|
|
79
|
+
omit both** (root app traces natively; no workspace packages).
|
|
71
80
|
- `app/layout.tsx` — add `<head><PublicEnvScript /></head>` (`import { PublicEnvScript } from
|
|
72
|
-
"next-runtime-env"`).
|
|
73
|
-
|
|
81
|
+
"next-runtime-env"`).
|
|
82
|
+
- **Convert EVERY `process.env.NEXT_PUBLIC_*` read to `env("NEXT_PUBLIC_X")`** — not just client
|
|
83
|
+
components. Next **inlines every `process.env.NEXT_PUBLIC_*` literal at build time in SERVER code and
|
|
84
|
+
middleware too**, so any read left as `process.env` bakes the build value (or a build-time
|
|
85
|
+
placeholder) into the server/middleware bundle and runtime injection can't override it. This is the
|
|
86
|
+
#1 time-sink: a stray `process.env.NEXT_PUBLIC_SUPABASE_URL` in `middleware.ts` bakes the placeholder
|
|
87
|
+
→ `supabase.auth.getUser()` fails → **auth redirect loop** that only shows in the container, not
|
|
88
|
+
locally. Convert: client components, **server components, route handlers (`app/api/**`), middleware,
|
|
89
|
+
and any t3-env `runtimeEnv` mapping**. Non-public server vars (`DATABASE_URL`, `*_SERVICE_KEY`) stay
|
|
90
|
+
on `process.env` — they aren't inlined and are present at runtime.
|
|
91
|
+
- **Phase-2 gate — must pass before you call the web edits done:**
|
|
92
|
+
`grep -rn 'process.env.NEXT_PUBLIC' {{APP_DIR}}` (the app dir — `.` for single-app, `apps/web` for a
|
|
93
|
+
monorepo app; not every repo has a `src/`) returns **nothing**. Any hit is a latent runtime bug.
|
|
74
94
|
- Flag `next-runtime-env` as a dependency to add — **do not run `bun add` yourself**; tell the dev.
|
|
75
95
|
- **Caveat:** runtime values only reach the browser on non-statically-prerendered pages. A page that
|
|
76
96
|
reads runtime public env must opt out of static caching (`export const dynamic = "force-dynamic"`)
|
|
77
97
|
or rely on `<PublicEnvScript>` keeping the document dynamic. Note this so future feature work
|
|
78
98
|
doesn't silently bake a static page.
|
|
79
99
|
|
|
100
|
+
## Single-app repos (no `apps/`, no `turbo.json`)
|
|
101
|
+
|
|
102
|
+
The templates are monorepo-shaped; one set of token substitutions makes them serve a root-level single
|
|
103
|
+
app. Apply these deltas (verified on `ddg-jewelry-admin`):
|
|
104
|
+
|
|
105
|
+
- **Tokens:** `{{APP_DIR}}` = `.`, `{{PACKAGE_NAME}}` = repo name (no `-web` suffix),
|
|
106
|
+
`{{WORKSPACE_MANIFEST_COPIES}}` = empty.
|
|
107
|
+
- **`Dockerfile.web`:** delete the `COPY packages/ ./packages/` line (no `packages/`). The runner
|
|
108
|
+
`COPY` lines collapse to `.next/standalone ./`, `.next/static ./.next/static`, `public ./public`
|
|
109
|
+
(the `{{APP_DIR}}=.` substitution yields valid `./.next/...` — fine to simplify).
|
|
110
|
+
- **`next.config`:** `output: "standalone"` only — no `outputFileTracingRoot`, no `transpilePackages`.
|
|
111
|
+
- **`ci.yml`:** drop the Turbo `env:`, the `.turbo` cache step, and swap `bun run check-types` for
|
|
112
|
+
`bunx tsc --noEmit` if the repo has no `check-types` script (single apps usually don't).
|
|
113
|
+
- **Runner (optional but recommended):** a single Next app deploys cleanly on a Node runner
|
|
114
|
+
(`node:22-alpine` or `gcr.io/distroless/nodejs22-debian12`, `CMD ["server.js"]`). Distroless cuts the
|
|
115
|
+
image ~4× and drops the runner CVEs (seen on `ddg-jewelry-admin`).
|
|
116
|
+
|
|
80
117
|
## Phase 3 — interactive ops checklist (the deliverable)
|
|
81
118
|
|
|
82
119
|
Derived from the actual `codepassion-team/cicd` reusable workflows. Walk these one at a time:
|
|
@@ -102,8 +139,14 @@ visibility), or the image pull silently fails.
|
|
|
102
139
|
**Web runtime env in Coolify** (the scars):
|
|
103
140
|
- `PORT=3000` — Next standalone obeys `PORT`; Coolify defaults it to `80`, which 502s behind a
|
|
104
141
|
Caddy `:3000` proxy. This is the single most common web-deploy failure.
|
|
105
|
-
- `
|
|
142
|
+
- **Coolify `Ports Exposes` (General page) is a SEPARATE setting from the `PORT` env** — it defaults
|
|
143
|
+
to `80` and must be set to the app port (`3000`). With `PORT=3000` but Ports Exposes still `80`, the
|
|
144
|
+
deploy log warns `PORT (3000) does not match configured ports_exposes: 80` and the proxy 502s. Fix
|
|
145
|
+
both.
|
|
146
|
+
- `HOSTNAME=0.0.0.0` — the Dockerfile already bakes this; only set it if something overrides it.
|
|
106
147
|
- The app's `NEXT_PUBLIC_*` values — injected here at runtime, not baked into the image.
|
|
148
|
+
- **Set ALL env values UNQUOTED** (public and server alike) — Coolify passes values literally, so a
|
|
149
|
+
quoted `DATABASE_URL="…"` keeps the quotes and breaks `new URL()` (`ERR_INVALID_URL`).
|
|
107
150
|
|
|
108
151
|
## Phase 4 — verify
|
|
109
152
|
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
# Public env is read at RUNTIME via next-runtime-env (<PublicEnvScript>), so this
|
|
4
4
|
# image bakes no NEXT_PUBLIC_* — Coolify injects them at run, like the API image.
|
|
5
5
|
# Generated by c9n-pipeline; see the ADR this skill points you to.
|
|
6
|
+
#
|
|
7
|
+
# {{BUN_VERSION}} must be >= 1.3.10 — 1.2.x breaks `next build` on Linux.
|
|
8
|
+
# Single-app repo: {{APP_DIR}}=. and {{WORKSPACE_MANIFEST_COPIES}}= empty; also
|
|
9
|
+
# delete the `COPY packages/ ./packages/` line below (no packages/ dir). See the
|
|
10
|
+
# "Single-app repos" section in SKILL.md.
|
|
6
11
|
# -----------------------------------------------------------------------------
|
|
7
12
|
|
|
8
13
|
FROM oven/bun:{{BUN_VERSION}} AS base
|
|
@@ -34,5 +34,17 @@ jobs:
|
|
|
34
34
|
- name: Check types
|
|
35
35
|
run: bun run check-types
|
|
36
36
|
- name: Build
|
|
37
|
-
|
|
37
|
+
# Placeholder NEXT_PUBLIC_* values satisfy module-init that reads them at import
|
|
38
|
+
# time (e.g. Supabase clients) — CI has no .env files, so without these
|
|
39
|
+
# `next build` throws "Error: supabaseUrl is required" collecting page
|
|
40
|
+
# data. Real values are injected at runtime via next-runtime-env, never
|
|
41
|
+
# baked. Mirrors the Dockerfile build stage. Add one line per public var
|
|
42
|
+
# the app constructs a client from at import.
|
|
43
|
+
run: SKIP_ENV_VALIDATION=1 bun run build
|
|
44
|
+
env:
|
|
45
|
+
NEXT_PUBLIC_SUPABASE_URL: http://placeholder.invalid
|
|
46
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder
|
|
38
47
|
{{MIGRATION_CHECK}}
|
|
48
|
+
# Single-app repos (no turbo.json): remove the TURBO_TOKEN/TURBO_TEAM `env:` and
|
|
49
|
+
# the `.turbo` cache step above, and swap `bun run check-types` for
|
|
50
|
+
# `bunx tsc --noEmit` if the repo has no check-types script.
|