rvlib-shipkit 0.19.0 → 0.20.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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +1 -0
- package/src/cli/cmd_Hooks.ts +3 -3
- package/src/cli/cmd_Init.ts +2 -2
- package/src/cli/cmd_Start.ts +4 -3
- package/src/core/run-ci.ts +3 -1
- package/src/core/run-fixers.ts +15 -0
- package/src/hooks/commit-message.ts +32 -2
- package/src/hooks/run-hooks.ts +52 -6
- package/src/init-repo.ts +17 -13
- package/src/start-repo.ts +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.1
|
|
4
|
+
|
|
5
|
+
- **Fix: `start --skip` means shipkit never touches the repo.** 0.20.0's adoption block ran after the skip branch, so `ship <job-repo>` would have scaffolded `repo.config.ts` into a repo on the never-wire list (caught by review in a sandbox). The whole wiring block sits behind the skip list now, frozen by test.
|
|
6
|
+
- **Fix: a failed adoption never breaks the cd contract.** A broken `package.json` in the target repo downgrades wiring to a WARN on stderr, and `start` still prints the dir. `initRepo` gained a `print` sink, so `start` reports on stderr without touching the console global.
|
|
7
|
+
- **Fix: arming only ever happens at the work-tree ROOT.** `armHooks` in a subdirectory used to set the PARENT repo's `core.hooksPath` onto a dir that does not exist there, disarming the parent's gates. It compares `git rev-parse --show-toplevel` now and declines with a named state.
|
|
8
|
+
- **Fix: arming never rewrites existing hook bodies.** A repo with `core.hooksPath=hooks` and both hook files present is `current`, whatever its bodies say: arming means making dark gates live, not normalizing another repo's `--bin` choice. `hooks install` with no `--bin` now defaults to the same resolution as arming (the repo's own node_modules copy first), one invocation policy instead of two.
|
|
9
|
+
- A failure to arm prints as a WARN, never as a skip, and a successful arm says to commit the `hooks/` dir.
|
|
10
|
+
|
|
11
|
+
## 0.20.0
|
|
12
|
+
|
|
13
|
+
- **Commit gates arm themselves.** An interactive `shipkit ci` run now installs the git hooks whenever the repo has a `repo.config.ts` and `core.hooksPath` is unset or already `hooks`: hook bodies written, leak-guard keyword starter seeded, `core.hooksPath` set, idempotent. The field case: a whole drill session committed with dark gates because init was treated as "create repo.config.ts". A FOREIGN `core.hooksPath` (husky and friends) is respected and named, never taken. Hook bodies prefer the repo's own `node_modules/rvlib-shipkit` copy over the cli that happens to be running, so a repo keeps its pinned version. Scoped to the interactive gate on purpose: CI verify mode, `bump` and `release` gate runs never mutate the tree they are about to ship, and arming reports on its own line, never as a fixer rewrite.
|
|
14
|
+
- **`start` is the from-anywhere bootstrap, `init` says it is cwd-only.** His friction: "no idea what shipkit start does" and init never said where it acts. `shipkit start <name>` now also WIRES the project (runs the init scaffold) when `~/dev/<name>` already has a package.json, so a pre-existing repo goes zero to shipkit'd with one command from any cwd; an empty new dir prints the next step on stderr. stdout stays the dir alone (the cd-alias contract, frozen by test). Both usage texts state their scope and point at each other.
|
|
15
|
+
- **The commit-message gate blocks French.** Git surfaces are English, always. New `language` offense in the shared predicate: an accented letter, or two distinct French function words on one line, trips it. Quoted spans (backticks, double quotes, guillemets) are exempt as product copy under discussion. Single quotes stay in scope because they are French apostrophes. `hooks.languageAllow` in repo.config exempts listed words (proper nouns that carry accents without being a language choice), and `hooks.disable: ['commit-language']` lifts only the French check, never the dash and attribution guards. Write-time only: SK009 history audits still scan attribution and dashes, so no repo retro-fails on old French commits.
|
|
16
|
+
|
|
3
17
|
## 0.19.0
|
|
4
18
|
|
|
5
19
|
- **The attribution tell list covers session trailers and assistant share urls.** `<assistant>-Session:` trailers (claude, chatgpt, copilot, cursor, aider, devin, gemini, codex) and claude.ai/claude.com/chatgpt.com/chat.openai.com urls now trip the shared predicate (commit-msg hook + SK009 history scan). Field case: 162 session links sat in a history both gates called clean.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli/cmd_Ci.ts
CHANGED
package/src/cli/cmd_Hooks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// shipkit hooks, install the git hooks, run their gates, or list what
|
|
2
2
|
// blocks a commit. Thin clipanion surface; logic in hooks/run-hooks.ts.
|
|
3
3
|
import { Command, Option } from 'clipanion'
|
|
4
|
-
import { installHooks, listGates, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
|
|
4
|
+
import { hookBinInvocation, installHooks, listGates, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
|
|
5
5
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
6
6
|
|
|
7
7
|
const report = (out: GateOutcome[], write: (s: string) => void): number => {
|
|
@@ -17,11 +17,11 @@ export class HooksInstallCommand extends Command {
|
|
|
17
17
|
examples: [['wire the hooks', 'shipkit hooks install']],
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
bin = Option.String('--bin',
|
|
20
|
+
bin = Option.String('--bin', { description: 'how the hook body invokes shipkit (default: the repo\'s own node_modules copy, else the running cli — in-repo consumers pass `bun <path to cli.ts>`)' })
|
|
21
21
|
|
|
22
22
|
async execute(): Promise<number> {
|
|
23
23
|
const config = await loadRepoConfig(process.cwd()).catch(() => null)
|
|
24
|
-
const r = installHooks(process.cwd(), this.bin, config?.hooks?.keywordFile)
|
|
24
|
+
const r = installHooks(process.cwd(), this.bin ?? hookBinInvocation(process.cwd()), config?.hooks?.keywordFile)
|
|
25
25
|
for (const w of r.written) this.context.stdout.write(`ok: wrote ${w}\n`)
|
|
26
26
|
if (r.written.length === 0) this.context.stdout.write('ok: hook bodies already current\n')
|
|
27
27
|
this.context.stdout.write(`${r.note.startsWith('WARN') ? r.note : `ok: ${r.note}`}\n`)
|
package/src/cli/cmd_Init.ts
CHANGED
|
@@ -8,9 +8,9 @@ export class InitCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static override usage = Command.Usage({
|
|
10
10
|
category: 'Setup',
|
|
11
|
-
description: '
|
|
11
|
+
description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. Scaffolds repo.config.ts + ci/check/contracts package.json scripts, then prints how the floor resolved. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
|
|
12
12
|
examples: [
|
|
13
|
-
['
|
|
13
|
+
['wire the repo you are standing in', 'shipkit init'],
|
|
14
14
|
['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
|
|
15
15
|
],
|
|
16
16
|
})
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,10 +9,11 @@ export class StartCommand extends Command {
|
|
|
9
9
|
|
|
10
10
|
static override usage = Command.Usage({
|
|
11
11
|
category: 'Setup',
|
|
12
|
-
description: '
|
|
12
|
+
description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines, and wires shipkit on the spot (repo.config.ts via init) when the project already has a package.json. An empty new dir prints the next step instead. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
|
|
13
13
|
examples: [
|
|
14
|
-
['
|
|
14
|
+
['create or enter ~/dev/pong, wiring it if it is already a project', 'shipkit start pong'],
|
|
15
15
|
['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
|
|
16
|
+
['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
|
|
16
17
|
],
|
|
17
18
|
})
|
|
18
19
|
|
|
@@ -20,7 +21,7 @@ export class StartCommand extends Command {
|
|
|
20
21
|
root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
|
|
21
22
|
include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
|
|
22
23
|
alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
|
|
23
|
-
skip = Option.Array('--skip', { description: 'repo names
|
|
24
|
+
skip = Option.Array('--skip', { description: 'repo names shipkit never touches: no CLAUDE.md include, no wiring' })
|
|
24
25
|
|
|
25
26
|
async execute(): Promise<number> {
|
|
26
27
|
return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip })
|
package/src/core/run-ci.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type CiOpts = {
|
|
|
27
27
|
fix?: boolean
|
|
28
28
|
/** also apply the structural SK rule fixers during the fix pass */
|
|
29
29
|
fixRules?: boolean
|
|
30
|
+
/** self-arm the git hooks in the fix pass (interactive ci entry only) */
|
|
31
|
+
armHooks?: boolean
|
|
30
32
|
/** run only the checks that can block (core/run-check onlyBlocking) */
|
|
31
33
|
onlyBlockingChecks?: boolean
|
|
32
34
|
/** task ids the caller does not need. A sub-unit release skips the tasks
|
|
@@ -76,7 +78,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
76
78
|
// mechanical failures are not information, so they never cost a round trip
|
|
77
79
|
let fixFailed = false
|
|
78
80
|
if (o.fix !== false)
|
|
79
|
-
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
|
|
81
|
+
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
|
|
80
82
|
else sink('skip: fix pass (--no-fix)')
|
|
81
83
|
const all = allTasks(c, root)
|
|
82
84
|
const { disabled, unfilled } = all
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
|
+
import { armHooks } from '../hooks/run-hooks.ts'
|
|
14
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
15
16
|
import { runFix } from '../rules/run-fix.ts'
|
|
16
17
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
@@ -105,6 +106,10 @@ function treeState(root: string): Map<string, string> | null {
|
|
|
105
106
|
export type FixPassOpts = {
|
|
106
107
|
/** also apply the structural SK rule fixers (default false) */
|
|
107
108
|
rules?: boolean
|
|
109
|
+
/** also self-arm the git hooks (default false — ONLY the interactive
|
|
110
|
+
* `shipkit ci` entry passes true; bump and release gates must never
|
|
111
|
+
* mutate the tree they are about to ship) */
|
|
112
|
+
armHooks?: boolean
|
|
108
113
|
print: (line: string) => void
|
|
109
114
|
}
|
|
110
115
|
|
|
@@ -120,6 +125,16 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
120
125
|
// verify mode: same fixers, but rewriting anything means the COMMIT was
|
|
121
126
|
// not clean, and the gate says so instead of quietly repairing it
|
|
122
127
|
const verify = process.env.CI != null && process.env.CI !== ''
|
|
128
|
+
// commit gates arm themselves on the interactive gate: a repo.config with
|
|
129
|
+
// dark hooks is the half-wired state that let a whole drill session
|
|
130
|
+
// commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
|
|
131
|
+
// its own reported concern, never counted as a fixer rewrite.
|
|
132
|
+
if (o.armHooks === true && !verify) {
|
|
133
|
+
const armed = armHooks(root, config)
|
|
134
|
+
if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the hooks/ dir`)
|
|
135
|
+
else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
|
|
136
|
+
else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
|
|
137
|
+
}
|
|
123
138
|
const { slots, unfilled, disabled } = resolveFixers(root, config)
|
|
124
139
|
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
125
140
|
for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
|
|
@@ -4,9 +4,37 @@
|
|
|
4
4
|
//
|
|
5
5
|
// mechanical text rules only, so this stays publishable.
|
|
6
6
|
|
|
7
|
-
export type OffenseKind = 'dash' | 'attribution'
|
|
7
|
+
export type OffenseKind = 'dash' | 'attribution' | 'language'
|
|
8
8
|
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
9
9
|
|
|
10
|
+
/** git surfaces are English, always (identity/writing-voice.md). cheap French
|
|
11
|
+
* detector: an accented letter, or two distinct French function words on one
|
|
12
|
+
* line. quoted spans are exempt: they carry product copy under discussion
|
|
13
|
+
* ("Modification refusée"), not the message's own voice. single quotes stay
|
|
14
|
+
* in (they are French apostrophes: stripping l'écran would hide the French).
|
|
15
|
+
* `languageAllow` (repo.config hooks) exempts listed words, for proper nouns
|
|
16
|
+
* that carry accents without being a language choice. */
|
|
17
|
+
const QUOTED_SPANS = /`[^`\n]*`|"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g
|
|
18
|
+
const FRENCH_ACCENTS = /[àâçéèêëîïôùûœÀÂÇÉÈÊËÎÏÔÙÛŒ]/
|
|
19
|
+
const FRENCH_STOPWORDS =
|
|
20
|
+
/\b(?:le|la|les|un|une|des|du|au|aux|et|est|sont|avec|pour|dans|sur|pas|que|qui|ne|se|ce|cette|ces|chaque|tout|toute|tous|toutes|jamais|toujours|mais|donc|sans|sous|vers|chez|depuis|encore|aussi|devient|deviennent|nouvelle|ancienne)\b/gi
|
|
21
|
+
|
|
22
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
23
|
+
|
|
24
|
+
export function frenchHits(line: string, languageAllow: string[] = []): boolean {
|
|
25
|
+
let prose = line.replace(QUOTED_SPANS, ' ')
|
|
26
|
+
for (const word of languageAllow) prose = prose.replace(new RegExp(`(?:^|(?<=[^\\p{L}]))${escapeRe(word)}(?:'s)?(?=$|[^\\p{L}])`, 'giu'), ' ')
|
|
27
|
+
if (FRENCH_ACCENTS.test(prose)) return true
|
|
28
|
+
const words = new Set<string>()
|
|
29
|
+
// an ALL-CAPS token is an acronym (UN, EST, CE), not a French word
|
|
30
|
+
for (const m of prose.matchAll(FRENCH_STOPWORDS)) {
|
|
31
|
+
const w = m[0]
|
|
32
|
+
if (w.length > 1 && w === w.toUpperCase()) continue
|
|
33
|
+
words.add(w.toLowerCase())
|
|
34
|
+
}
|
|
35
|
+
return words.size >= 2
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
/** trailers and footers that name an assistant, assistant session trailers,
|
|
11
39
|
* assistant share-domain urls (a session link is a louder tell than any
|
|
12
40
|
* trailer), plus the robot emoji. the session/url family lists every
|
|
@@ -25,7 +53,7 @@ export function stripNonMessage(message: string): string {
|
|
|
25
53
|
* `#` comment lines never land in the message and are ignored; a `Revert "…"`
|
|
26
54
|
* title quotes the reverted subject verbatim and is exempt; a line-start
|
|
27
55
|
* `- ` bullet is a list marker, not punctuation. */
|
|
28
|
-
export function commitMessageOffenses(message: string): Offense[] {
|
|
56
|
+
export function commitMessageOffenses(message: string, opts?: { languageAllow?: string[] }): Offense[] {
|
|
29
57
|
const offenses: Offense[] = []
|
|
30
58
|
stripNonMessage(message).split('\n').forEach((raw, i) => {
|
|
31
59
|
if (raw.startsWith('#')) return
|
|
@@ -37,6 +65,8 @@ export function commitMessageOffenses(message: string): Offense[] {
|
|
|
37
65
|
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
38
66
|
if (AI_ATTRIBUTION.test(raw))
|
|
39
67
|
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
|
|
68
|
+
if (frenchHits(raw, opts?.languageAllow))
|
|
69
|
+
offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
|
|
40
70
|
})
|
|
41
71
|
return offenses
|
|
42
72
|
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// hand-rolled hooks are invisible to `shipkit check`: a repo reports green
|
|
4
4
|
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
5
|
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
|
+
import { fileURLToPath } from 'url'
|
|
8
9
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
10
|
import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
|
|
10
11
|
import { runRootIdentity } from './root-identity.ts'
|
|
@@ -23,13 +24,56 @@ export type HooksConfig = {
|
|
|
23
24
|
/** staged-path prefixes that may never enter git (local state like
|
|
24
25
|
* '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
|
|
25
26
|
blockedPaths?: string[]
|
|
26
|
-
/**
|
|
27
|
-
|
|
27
|
+
/** words the commit-message language gate never counts as French
|
|
28
|
+
* (proper nouns that carry accents without being a language choice) */
|
|
29
|
+
languageAllow?: string[]
|
|
30
|
+
/** disable a built-in gate, visible in `hooks list`, never silent.
|
|
31
|
+
* 'commit-language' lifts ONLY the French check, keeping the dash and
|
|
32
|
+
* AI-attribution guards armed. */
|
|
33
|
+
disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
const HOOK_DIR = 'hooks'
|
|
31
37
|
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
32
38
|
|
|
39
|
+
/** how a self-written hook body should invoke shipkit in this repo: the
|
|
40
|
+
* repo's own node_modules copy when it has one (survives shim churn, pins
|
|
41
|
+
* the repo's version), else the cli that is running right now. */
|
|
42
|
+
export function hookBinInvocation(root: string): string {
|
|
43
|
+
const local = join(root, 'node_modules/rvlib-shipkit/src/cli.ts')
|
|
44
|
+
if (existsSync(local)) return 'bun node_modules/rvlib-shipkit/src/cli.ts'
|
|
45
|
+
const running = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.ts')
|
|
46
|
+
const prefix = root.endsWith('/') ? root : root + '/'
|
|
47
|
+
return running.startsWith(prefix) ? `bun ${running.slice(prefix.length)}` : `bun ${running}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git' | 'not-root' | 'failed'; detail: string }
|
|
51
|
+
|
|
52
|
+
/** self-arming: a repo.config with dark commit gates is the half-wired state
|
|
53
|
+
* that let a whole drill session commit ungated (2026-08-04). Arming means
|
|
54
|
+
* making DARK gates live: hooks that already exist under an armed hooksPath
|
|
55
|
+
* are never rewritten (the repo's own --bin choice stands). A FOREIGN
|
|
56
|
+
* core.hooksPath is respected and named, and only the work-tree ROOT may
|
|
57
|
+
* arm (a subdir would set the parent's hooksPath onto a dir that does not
|
|
58
|
+
* exist there, disarming the parent's gates). */
|
|
59
|
+
export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
60
|
+
const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
|
|
61
|
+
if (top.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
|
|
62
|
+
const topDir = top.stdout.toString().trim()
|
|
63
|
+
try {
|
|
64
|
+
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `work tree root is ${topDir}, not here` }
|
|
65
|
+
} catch {
|
|
66
|
+
return { state: 'no-git', detail: 'work tree root unresolvable' }
|
|
67
|
+
}
|
|
68
|
+
const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
|
|
69
|
+
const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
70
|
+
if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
|
|
71
|
+
if (current === HOOK_DIR && HOOKS.every((h) => existsSync(join(root, HOOK_DIR, h)))) return { state: 'current', detail: 'hooks already armed' }
|
|
72
|
+
const r = installHooks(root, hookBinInvocation(root), config.hooks?.keywordFile)
|
|
73
|
+
if (r.note.startsWith('WARN')) return { state: 'failed', detail: r.note }
|
|
74
|
+
return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
|
|
75
|
+
}
|
|
76
|
+
|
|
33
77
|
/** wire `core.hooksPath` and write the two hook bodies. Idempotent.
|
|
34
78
|
* `keywordFile` = the repo's configured leak-guard file, seeding target. */
|
|
35
79
|
export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
|
|
@@ -149,9 +193,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
149
193
|
const out: GateOutcome[] = []
|
|
150
194
|
const message = readFileSync(messageFile, 'utf8')
|
|
151
195
|
if (!(h.disable ?? []).includes('commit-message')) {
|
|
152
|
-
const
|
|
196
|
+
const languageOff = (h.disable ?? []).includes('commit-language')
|
|
197
|
+
const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
|
|
198
|
+
.filter((o) => o.kind !== 'language' || !languageOff)
|
|
153
199
|
out.push(offenses.length === 0
|
|
154
|
-
? { id: 'commit-message', ok: true, lines: [
|
|
200
|
+
? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation, no AI attribution${languageOff ? '' : ', English'})`] }
|
|
155
201
|
: {
|
|
156
202
|
id: 'commit-message',
|
|
157
203
|
ok: false,
|
|
@@ -192,7 +238,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
192
238
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
193
239
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
194
240
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
195
|
-
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
|
241
|
+
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
|
|
196
242
|
`commit-msg leak-guard${leakState} — the message text`,
|
|
197
243
|
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
198
244
|
]
|
package/src/init-repo.ts
CHANGED
|
@@ -35,20 +35,24 @@ export default defineRepo({
|
|
|
35
35
|
export type InitOpts = {
|
|
36
36
|
/** in-repo-source consumers: path to shipkit's cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>` */
|
|
37
37
|
bin?: string
|
|
38
|
+
/** report sink, default console.log (startRepo passes console.error: its
|
|
39
|
+
* stdout contract is the resolved dir alone) */
|
|
40
|
+
print?: (line: string) => void
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
|
|
44
|
+
const p = opts.print ?? console.log
|
|
41
45
|
const configPath = join(root, 'repo.config.ts')
|
|
42
46
|
if (existsSync(configPath)) {
|
|
43
|
-
|
|
47
|
+
p(`skip: repo.config.ts already exists`)
|
|
44
48
|
// adoption path: re-running init on an existing repo still converges
|
|
45
49
|
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
46
50
|
// with the repo's own contributed lines honored
|
|
47
51
|
const cfg = await loadRepoConfig(root)
|
|
48
52
|
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
49
|
-
|
|
53
|
+
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
50
54
|
const mh = reportToMotherhome(root, cfg)
|
|
51
|
-
if (mh.note != null)
|
|
55
|
+
if (mh.note != null) p(mh.note)
|
|
52
56
|
return 0
|
|
53
57
|
}
|
|
54
58
|
const pkgPath = join(root, 'package.json')
|
|
@@ -57,7 +61,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
57
61
|
const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
|
|
58
62
|
const name = pkg.name ?? 'my-repo'
|
|
59
63
|
writeFileSync(configPath, CONFIG_TEMPLATE(name))
|
|
60
|
-
|
|
64
|
+
p('ok: repo.config.ts created')
|
|
61
65
|
const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
62
66
|
pkg.scripts ??= {}
|
|
63
67
|
let added = 0
|
|
@@ -69,32 +73,32 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
69
73
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
70
74
|
if (k === 'test' && !hasTestFiles(root)) continue
|
|
71
75
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
72
|
-
else if (pkg.scripts[k] !== v)
|
|
76
|
+
else if (pkg.scripts[k] !== v) p(`skip: package.json script '${k}' exists — left untouched`)
|
|
73
77
|
}
|
|
74
78
|
if (added > 0) {
|
|
75
79
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
76
|
-
|
|
80
|
+
p(`ok: ${added} package.json script(s) added`)
|
|
77
81
|
}
|
|
78
82
|
// the .gitignore policy block (contract git policy) — same fixer as
|
|
79
83
|
// `shipkit check --fix SK004`, one code path
|
|
80
84
|
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
81
|
-
|
|
85
|
+
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
82
86
|
// show what the gate actually is — a thin floor must be seen at init
|
|
83
87
|
// time, not discovered after a bad commit slipped through
|
|
84
88
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
85
|
-
for (const t of floor.tasks)
|
|
86
|
-
for (const u of floor.unfilled)
|
|
89
|
+
for (const t of floor.tasks) p(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
|
|
90
|
+
for (const u of floor.unfilled) p(`skip: ${u.id} (${u.reason})`)
|
|
87
91
|
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
88
92
|
// package is actually installed (bunx runs without adding the dep)
|
|
89
93
|
if (opts.bin == null) {
|
|
90
94
|
try {
|
|
91
95
|
Bun.resolveSync('rvlib-shipkit', root)
|
|
92
96
|
} catch {
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
p('WARN rvlib-shipkit not installed here — the scripts above call its bin and will exit 127')
|
|
98
|
+
p(' fix: bun add -d rvlib-shipkit')
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
p('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
102
|
+
p('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
|
|
99
103
|
return 0
|
|
100
104
|
}
|
package/src/start-repo.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
8
|
import { homedir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
+
import { initRepo } from './init-repo.ts'
|
|
10
11
|
|
|
11
12
|
export type StartOpts = {
|
|
12
13
|
/** parent dir for repos — default ~/dev */
|
|
@@ -19,7 +20,7 @@ export type StartOpts = {
|
|
|
19
20
|
skip?: string[]
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function startRepo(name: string, opts: StartOpts = {}): number {
|
|
23
|
+
export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
|
|
23
24
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
24
25
|
console.error(`FAIL: bad repo name '${name}' — letters/digits/._- only, no leading dot, no slashes`)
|
|
25
26
|
return 2
|
|
@@ -55,6 +56,21 @@ export function startRepo(name: string, opts: StartOpts = {}): number {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
// start is the from-anywhere verb: a dir that is already a real project
|
|
60
|
+
// (package.json) gets wired on the spot, so a pre-existing repo goes
|
|
61
|
+
// 0 → shipkit'd with the one command. init itself stays cwd-only, like
|
|
62
|
+
// git init. A brand-new empty dir cannot be wired yet, say what is next.
|
|
63
|
+
// --skip means shipkit NEVER TOUCHES that repo (his job repo lives on
|
|
64
|
+
// this list), not just no CLAUDE.md. And the cd contract survives a
|
|
65
|
+
// failed adoption: the dir always prints, wiring trouble is a WARN.
|
|
66
|
+
if (!(opts.skip ?? []).includes(name) && !existsSync(join(dir, 'repo.config.ts'))) {
|
|
67
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
68
|
+
try { await initRepo(dir, { print: (l) => console.error(l) }) }
|
|
69
|
+
catch (e) { console.error(`WARN could not wire ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
70
|
+
} else {
|
|
71
|
+
console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
58
74
|
console.log(dir)
|
|
59
75
|
return 0
|
|
60
76
|
}
|