jackherd 0.0.1 → 1.0.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/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1121 -2
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { escapeRegExp } from '../plan-format/markdown'
|
|
2
|
+
import { CHECK_STATUS, type CheckResult } from './result'
|
|
3
|
+
|
|
4
|
+
export const TERM_KIND = {
|
|
5
|
+
neighbourRepository: 'neighbour repository',
|
|
6
|
+
gitOrganisation: 'git organisation',
|
|
7
|
+
ticketKey: 'ticket key',
|
|
8
|
+
projectName: 'project name',
|
|
9
|
+
packageScope: 'package scope',
|
|
10
|
+
domain: 'domain',
|
|
11
|
+
localTerm: 'local term',
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
/** What a term names; a hit reports this, never the term. */
|
|
15
|
+
export type TermKind = (typeof TERM_KIND)[keyof typeof TERM_KIND]
|
|
16
|
+
|
|
17
|
+
/** A word that must not reach published files. It lives for one run: never written, never printed. */
|
|
18
|
+
export type Term = { kind: TermKind; text: string }
|
|
19
|
+
|
|
20
|
+
export type Neighbour = {
|
|
21
|
+
/** The directory name of a repository beside this one or in a work directory. */
|
|
22
|
+
name: string
|
|
23
|
+
/** Remote URLs as `git remote -v` lists them. */
|
|
24
|
+
remotes: string[]
|
|
25
|
+
/** Local and remote branch names. */
|
|
26
|
+
branches: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the repository and its neighbours tell about the people and projects the published files must not name. */
|
|
30
|
+
export type PublicContentFacts = {
|
|
31
|
+
neighbours: Neighbour[]
|
|
32
|
+
/** Folder names of the project's apps and services. */
|
|
33
|
+
apps: string[]
|
|
34
|
+
/** Scopes of the project's own package names (`@scope`). */
|
|
35
|
+
scopes: string[]
|
|
36
|
+
/** Scopes of the published packages themselves; their names are what they are published under. */
|
|
37
|
+
ownScopes: string[]
|
|
38
|
+
/** The text of the project's env example files. */
|
|
39
|
+
envExamples: string[]
|
|
40
|
+
/** Terms from the user's local file outside every repository. */
|
|
41
|
+
localTerms: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type AddedLine = { path: string; line: number; text: string }
|
|
45
|
+
|
|
46
|
+
/** A hit names where and what kind of name, never the name itself. */
|
|
47
|
+
export type PublicLeak = { path: string; line: number; kind: TermKind }
|
|
48
|
+
|
|
49
|
+
/** Below this length a name is too likely to be an ordinary word or an abbreviation. */
|
|
50
|
+
const MIN_TERM_LENGTH = 4
|
|
51
|
+
|
|
52
|
+
/** Repository and folder names too generic to tell anyone anything. */
|
|
53
|
+
const GENERIC_NAMES = new Set([
|
|
54
|
+
'agent',
|
|
55
|
+
'agents',
|
|
56
|
+
'apps',
|
|
57
|
+
'backend',
|
|
58
|
+
'config',
|
|
59
|
+
'configs',
|
|
60
|
+
'demo',
|
|
61
|
+
'docs',
|
|
62
|
+
'dotfiles',
|
|
63
|
+
'example',
|
|
64
|
+
'examples',
|
|
65
|
+
'frontend',
|
|
66
|
+
'infra',
|
|
67
|
+
'infrastructure',
|
|
68
|
+
'master',
|
|
69
|
+
'main',
|
|
70
|
+
'notes',
|
|
71
|
+
'packages',
|
|
72
|
+
'platform',
|
|
73
|
+
'playground',
|
|
74
|
+
'sandbox',
|
|
75
|
+
'scripts',
|
|
76
|
+
'server',
|
|
77
|
+
'services',
|
|
78
|
+
'shared',
|
|
79
|
+
'skills',
|
|
80
|
+
'test',
|
|
81
|
+
'tests',
|
|
82
|
+
'tools',
|
|
83
|
+
'website',
|
|
84
|
+
'workspace',
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
/** Hosts every project names; a domain is the project's only when it is none of these. */
|
|
88
|
+
const PUBLIC_HOSTS = new Set(['localhost', 'example.com', 'example.org', 'example.net', 'github.com', 'gitlab.com'])
|
|
89
|
+
|
|
90
|
+
const REMOTE_PATH = /(?:[:/])([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/
|
|
91
|
+
|
|
92
|
+
const TICKET_KEY = /(?:^|[/_-])([A-Z][A-Z0-9]{1,9})-\d+/
|
|
93
|
+
|
|
94
|
+
const HOST = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi
|
|
95
|
+
|
|
96
|
+
const isUsefulName = (name: string): boolean => name.length >= MIN_TERM_LENGTH && !GENERIC_NAMES.has(name.toLowerCase())
|
|
97
|
+
|
|
98
|
+
const remoteNames = (remote: string): Term[] => {
|
|
99
|
+
const match = REMOTE_PATH.exec(remote.trim())
|
|
100
|
+
if (!match) return []
|
|
101
|
+
return [
|
|
102
|
+
{ kind: TERM_KIND.gitOrganisation, text: match[1] },
|
|
103
|
+
{ kind: TERM_KIND.neighbourRepository, text: match[2] },
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ticketKeys = (branches: readonly string[]): Term[] =>
|
|
108
|
+
branches.flatMap((branch) => {
|
|
109
|
+
const match = TICKET_KEY.exec(branch)
|
|
110
|
+
return match ? [{ kind: TERM_KIND.ticketKey, text: match[1] }] : []
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const neighbourTerms = (neighbour: Neighbour): Term[] => [
|
|
114
|
+
{ kind: TERM_KIND.neighbourRepository, text: neighbour.name },
|
|
115
|
+
...neighbour.remotes.flatMap(remoteNames),
|
|
116
|
+
...ticketKeys(neighbour.branches),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
/** The last two labels of a host: `stage.shop.example.io` → `example.io`. */
|
|
120
|
+
const registrableDomain = (host: string): string => host.toLowerCase().split('.').slice(-2).join('.')
|
|
121
|
+
|
|
122
|
+
const domainTerms = (envExamples: readonly string[]): Term[] =>
|
|
123
|
+
envExamples
|
|
124
|
+
.flatMap((text) => text.match(HOST) ?? [])
|
|
125
|
+
.map(registrableDomain)
|
|
126
|
+
.filter((domain) => !PUBLIC_HOSTS.has(domain))
|
|
127
|
+
.map((text) => ({ kind: TERM_KIND.domain, text }))
|
|
128
|
+
|
|
129
|
+
const scopeName = (scope: string): string => scope.replace(/^@/, '')
|
|
130
|
+
|
|
131
|
+
const projectTerms = (facts: PublicContentFacts): Term[] => {
|
|
132
|
+
const own = new Set(facts.ownScopes.map(scopeName))
|
|
133
|
+
return [
|
|
134
|
+
...facts.apps.map((text): Term => ({ kind: TERM_KIND.projectName, text })),
|
|
135
|
+
...facts.scopes
|
|
136
|
+
.map(scopeName)
|
|
137
|
+
.filter((scope) => !own.has(scope))
|
|
138
|
+
.map((text): Term => ({ kind: TERM_KIND.packageScope, text })),
|
|
139
|
+
...domainTerms(facts.envExamples),
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A ticket key is kept short (`AB`): it only ever matches together with its number. */
|
|
144
|
+
const isKept = (term: Term, own: ReadonlySet<string>): boolean => {
|
|
145
|
+
if (own.has(term.text.toLowerCase())) return false
|
|
146
|
+
if (term.kind === TERM_KIND.ticketKey || term.kind === TERM_KIND.localTerm) return term.text.length > 0
|
|
147
|
+
return isUsefulName(term.text)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const dedupe = (terms: readonly Term[]): Term[] => {
|
|
151
|
+
const seen = new Set<string>()
|
|
152
|
+
return terms.filter((term) => {
|
|
153
|
+
const key = term.text.toLowerCase()
|
|
154
|
+
if (seen.has(key)) return false
|
|
155
|
+
seen.add(key)
|
|
156
|
+
return true
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every term the facts yield, short and generic names and the published packages' own scopes left out. */
|
|
161
|
+
export const deriveTerms = (facts: PublicContentFacts): Term[] => {
|
|
162
|
+
const own = new Set(facts.ownScopes.map((scope) => scopeName(scope).toLowerCase()))
|
|
163
|
+
const terms: Term[] = [
|
|
164
|
+
...facts.neighbours.flatMap(neighbourTerms),
|
|
165
|
+
...projectTerms(facts),
|
|
166
|
+
...facts.localTerms.map((text): Term => ({ kind: TERM_KIND.localTerm, text: text.trim() })),
|
|
167
|
+
]
|
|
168
|
+
return dedupe(terms.filter((term) => isKept(term, own)))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Matches the term as a whole word in any case; letters and digits of any script bound a word. */
|
|
172
|
+
const termPattern = (term: Term): RegExp =>
|
|
173
|
+
term.kind === TERM_KIND.ticketKey
|
|
174
|
+
? new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(term.text)}-\\d+`, 'u')
|
|
175
|
+
: new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(term.text)}(?![\\p{L}\\p{N}])`, 'iu')
|
|
176
|
+
|
|
177
|
+
/** Drops the terms the published files already use: those are the kit's own words, whatever else they name. */
|
|
178
|
+
export const withoutVocabulary = (terms: readonly Term[], vocabulary: string): Term[] =>
|
|
179
|
+
terms.filter((term) => !termPattern(term).test(vocabulary))
|
|
180
|
+
|
|
181
|
+
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
182
|
+
|
|
183
|
+
/** The added lines of a `git diff --unified=0`, with their line numbers in the new file. */
|
|
184
|
+
export const addedLines = (diff: string): AddedLine[] => {
|
|
185
|
+
const lines: AddedLine[] = []
|
|
186
|
+
let path = ''
|
|
187
|
+
let next = 0
|
|
188
|
+
for (const line of diff.split('\n')) {
|
|
189
|
+
if (line.startsWith('+++ ')) path = line.replace(/^\+\+\+ (b\/)?/, '')
|
|
190
|
+
else if (HUNK.test(line)) next = Number(HUNK.exec(line)?.[1])
|
|
191
|
+
else if (line.startsWith('+')) lines.push({ path, line: next++, text: line.slice(1) })
|
|
192
|
+
}
|
|
193
|
+
return lines
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Every added line against every term; a hit carries the term's kind, not the term. */
|
|
197
|
+
export const findPublicLeaks = (lines: readonly AddedLine[], terms: readonly Term[]): PublicLeak[] => {
|
|
198
|
+
const patterns = terms.map((term) => ({ kind: term.kind, pattern: termPattern(term) }))
|
|
199
|
+
return lines.flatMap(({ path, line, text }) =>
|
|
200
|
+
patterns.filter(({ pattern }) => pattern.test(text)).map(({ kind }) => ({ path, line, kind })),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The report line of the scan: where each hit is and what kind of name it is, never the name. */
|
|
205
|
+
export const publicContentResult = (
|
|
206
|
+
leaks: readonly PublicLeak[],
|
|
207
|
+
scope: { files: number; terms: number },
|
|
208
|
+
durationMs: number,
|
|
209
|
+
): CheckResult => ({
|
|
210
|
+
name: 'Public content',
|
|
211
|
+
command: `${scope.files} published files changed, ${scope.terms} names of neighbouring projects and of this one`,
|
|
212
|
+
status: leaks.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.fail,
|
|
213
|
+
detail:
|
|
214
|
+
leaks.map(({ path, line, kind }) => `${path}:${line} names a ${kind}`).join('\n') || 'no added line names them',
|
|
215
|
+
durationMs,
|
|
216
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ChangedFile, STATUS_COMMAND } from './changes'
|
|
2
|
+
import type { CheckPlan } from './plan'
|
|
3
|
+
import { CHECK_STATUS, type CheckResult, SKIP_SLOW_FLAG } from './result'
|
|
4
|
+
|
|
5
|
+
const STATUS_ICON = { pass: '✅', fail: '❌', warn: '⚠️ ', skipped: '⏭️ ' } as const
|
|
6
|
+
|
|
7
|
+
export const artifactResult = (artifacts: readonly string[]): CheckResult => ({
|
|
8
|
+
name: 'Staged build artifacts',
|
|
9
|
+
command: STATUS_COMMAND.join(' '),
|
|
10
|
+
status: artifacts.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.fail,
|
|
11
|
+
detail: artifacts.join('\n') || 'none staged',
|
|
12
|
+
durationMs: 0,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const skippedSlowResult = (): CheckResult => ({
|
|
16
|
+
name: 'Types & tests',
|
|
17
|
+
command: SKIP_SLOW_FLAG,
|
|
18
|
+
status: CHECK_STATUS.skipped,
|
|
19
|
+
detail: 'skipped on request',
|
|
20
|
+
durationMs: 0,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const formatPlanHeader = (files: readonly ChangedFile[], plan: CheckPlan): string[] => [
|
|
24
|
+
`Changed files (${files.length}, ${files.filter((file) => file.staged).length} staged):`,
|
|
25
|
+
...files.map((file) => ` ${file.staged ? 'S' : ' '} ${file.path}`),
|
|
26
|
+
'',
|
|
27
|
+
`Package roots: ${plan.roots.join(', ') || 'none'}`,
|
|
28
|
+
`Reviewers: ${plan.reviewers.join(', ') || 'none'}`,
|
|
29
|
+
...(plan.generated.length > 0
|
|
30
|
+
? [
|
|
31
|
+
'Generator-owned files (must come from the generator, not from an editor):',
|
|
32
|
+
...plan.generated.map(({ path, generator }) => ` ${path} → ${generator}`),
|
|
33
|
+
]
|
|
34
|
+
: []),
|
|
35
|
+
'',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export const formatResults = (results: readonly CheckResult[]): string[] =>
|
|
39
|
+
results.flatMap((result) => [
|
|
40
|
+
`${STATUS_ICON[result.status]} ${result.name} (${result.command}, ${result.durationMs}ms)`,
|
|
41
|
+
...(result.status !== CHECK_STATUS.pass && result.detail
|
|
42
|
+
? result.detail.split('\n').map((line) => ` ${line}`)
|
|
43
|
+
: []),
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const warnings = (count: number): string => (count > 0 ? `${count} ${count === 1 ? 'warning' : 'warnings'}, ` : '')
|
|
47
|
+
|
|
48
|
+
export const summarize = (results: readonly CheckResult[]): { line: string; failed: number } => {
|
|
49
|
+
const count = (status: CheckResult['status']) => results.filter((result) => result.status === status).length
|
|
50
|
+
const failed = count(CHECK_STATUS.fail)
|
|
51
|
+
return {
|
|
52
|
+
failed,
|
|
53
|
+
line: `${failed === 0 ? '✅' : '❌'} ${count(CHECK_STATUS.pass)} passed, ${failed} failed, ${warnings(count(CHECK_STATUS.warn))}${count(CHECK_STATUS.skipped)} skipped`,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PlannedCheck } from './plan'
|
|
2
|
+
import { CHECK_KIND } from './plan'
|
|
3
|
+
|
|
4
|
+
/** `warn` reports a finding without failing the run. */
|
|
5
|
+
export const CHECK_STATUS = { pass: 'pass', fail: 'fail', warn: 'warn', skipped: 'skipped' } as const
|
|
6
|
+
|
|
7
|
+
export type CheckStatus = (typeof CHECK_STATUS)[keyof typeof CHECK_STATUS]
|
|
8
|
+
|
|
9
|
+
export type CheckResult = {
|
|
10
|
+
name: string
|
|
11
|
+
command: string
|
|
12
|
+
status: CheckStatus
|
|
13
|
+
detail: string
|
|
14
|
+
durationMs: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ProcessResult = { ok: boolean; output: string }
|
|
18
|
+
|
|
19
|
+
export const SKIP_SLOW_FLAG = '--skip-slow'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bun reports "there was nothing to run" in two shapes: a package whose files hold no test cases
|
|
23
|
+
* summarises as `0 tests`, one with no matching files exits non-zero with `0 test files matching`.
|
|
24
|
+
* Both are an absent suite, not a failing one.
|
|
25
|
+
*/
|
|
26
|
+
export const reportsNoTests = (output: string): boolean => /^0 tests|no tests found|0 test files/im.test(output)
|
|
27
|
+
|
|
28
|
+
export const lastLines = (output: string, count: number): string =>
|
|
29
|
+
output.split('\n').filter(Boolean).slice(-count).join('\n')
|
|
30
|
+
|
|
31
|
+
/** How a finished process reads for its check: an absent test suite is skipped, not failed. */
|
|
32
|
+
export const outcomeOf = (
|
|
33
|
+
check: PlannedCheck,
|
|
34
|
+
{ ok, output }: ProcessResult,
|
|
35
|
+
): Pick<CheckResult, 'status' | 'detail'> => {
|
|
36
|
+
if (check.kind === CHECK_KIND.tests && reportsNoTests(output)) {
|
|
37
|
+
return { status: CHECK_STATUS.skipped, detail: 'no tests in this directory' }
|
|
38
|
+
}
|
|
39
|
+
return ok
|
|
40
|
+
? { status: CHECK_STATUS.pass, detail: lastLines(output, check.kind === CHECK_KIND.tests ? 3 : 2) }
|
|
41
|
+
: { status: CHECK_STATUS.fail, detail: lastLines(output, 20) }
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The nearest ancestor holding a `package.json`. Tests and `tsc` run from there and nowhere else:
|
|
5
|
+
* Bun resolves `tsconfig.json` from the working directory, so descending below a package root
|
|
6
|
+
* silently compiles `.tsx` against the wrong JSX runtime.
|
|
7
|
+
*/
|
|
8
|
+
export const packageRootOf = (path: string, hasPackageJson: (dir: string) => boolean): string | null => {
|
|
9
|
+
let dir = dirname(path)
|
|
10
|
+
while (dir !== '.' && dir !== '/' && dir !== '') {
|
|
11
|
+
if (hasPackageJson(dir)) return dir
|
|
12
|
+
dir = dirname(dir)
|
|
13
|
+
}
|
|
14
|
+
return hasPackageJson('.') ? '.' : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const packageRootsFor = (paths: readonly string[], hasPackageJson: (dir: string) => boolean): string[] => [
|
|
18
|
+
...new Set(paths.map((path) => packageRootOf(path, hasPackageJson)).filter((root): root is string => root !== null)),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Directories with test files that belong to no package root, which would otherwise be tested by
|
|
23
|
+
* nobody. The workspace root is excluded: a test run from there walks the whole monorepo.
|
|
24
|
+
*/
|
|
25
|
+
export const looseDirectories = (
|
|
26
|
+
paths: readonly string[],
|
|
27
|
+
roots: readonly string[],
|
|
28
|
+
hasTests: (dir: string) => boolean,
|
|
29
|
+
): string[] => [
|
|
30
|
+
...new Set(
|
|
31
|
+
paths
|
|
32
|
+
.filter((path) => !roots.some((root) => path.startsWith(`${root}/`)))
|
|
33
|
+
.map((path) => dirname(path))
|
|
34
|
+
.filter((dir) => dir !== '.' && hasTests(dir)),
|
|
35
|
+
),
|
|
36
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { matchesAnyGlob } from './glob'
|
|
2
|
+
import type { ProjectCheck, PublicContentConfig, ReviewerRoute } from './schema'
|
|
3
|
+
|
|
4
|
+
/** Reviewers whose route matches at least one changed path, in route order. */
|
|
5
|
+
export const reviewersFor = (paths: readonly string[], routes: readonly ReviewerRoute[]): string[] =>
|
|
6
|
+
routes.filter((route) => paths.some((path) => matchesAnyGlob(path, route.globs))).map((route) => route.reviewer)
|
|
7
|
+
|
|
8
|
+
/** Whether a published package ships the path, by `check.publicContent`; never without it. */
|
|
9
|
+
export const isPublicPath = (path: string, config: PublicContentConfig | undefined): boolean =>
|
|
10
|
+
config !== undefined && matchesAnyGlob(path, config.globs) && !matchesAnyGlob(path, config.exclude)
|
|
11
|
+
|
|
12
|
+
/** The changed paths a published package ships. */
|
|
13
|
+
export const publicPathsOf = (paths: readonly string[], config: PublicContentConfig | undefined): string[] =>
|
|
14
|
+
paths.filter((path) => isPublicPath(path, config))
|
|
15
|
+
|
|
16
|
+
/** The public-content reviewer when a change touches a published file. */
|
|
17
|
+
export const publicContentReviewers = (paths: readonly string[], config: PublicContentConfig | undefined): string[] =>
|
|
18
|
+
config && publicPathsOf(paths, config).length > 0 ? [config.reviewer] : []
|
|
19
|
+
|
|
20
|
+
/** Project checks a change triggers: those without globs always, those with globs when a path matches. */
|
|
21
|
+
export const projectChecksFor = (paths: readonly string[], checks: readonly ProjectCheck[]): ProjectCheck[] =>
|
|
22
|
+
checks.filter(({ globs }) => !globs || paths.some((path) => matchesAnyGlob(path, globs)))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { BUN_COMMANDS } from '../config/detect'
|
|
3
|
+
import { tokenize } from './command-line'
|
|
4
|
+
|
|
5
|
+
const nonEmpty = z.string().min(1)
|
|
6
|
+
|
|
7
|
+
const isTokenizable = (command: string): boolean => {
|
|
8
|
+
try {
|
|
9
|
+
tokenize(command)
|
|
10
|
+
return true
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const command = nonEmpty.refine(isTokenizable, 'Unterminated quote in command')
|
|
17
|
+
|
|
18
|
+
/** Supports `*`, `**` and `?`; braces and brackets would silently match nothing, so they are refused. */
|
|
19
|
+
const glob = nonEmpty.refine((value) => !/[{}[\]]/.test(value), 'Braces and brackets are not supported in globs')
|
|
20
|
+
|
|
21
|
+
const globs = z.array(glob).min(1)
|
|
22
|
+
|
|
23
|
+
/** Staged output that belongs to a build, never to a commit. */
|
|
24
|
+
export const DEFAULT_BUILD_ARTIFACT_GLOBS = [
|
|
25
|
+
'**/tsup.config.bundled_*',
|
|
26
|
+
'**/dist/**',
|
|
27
|
+
'**/.turbo/**',
|
|
28
|
+
'**/node_modules/**',
|
|
29
|
+
'**/*.tsbuildinfo',
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
const reviewerRoute = z.strictObject({
|
|
33
|
+
reviewer: nonEmpty,
|
|
34
|
+
globs,
|
|
35
|
+
why: nonEmpty,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const projectCheck = z.strictObject({
|
|
39
|
+
name: nonEmpty,
|
|
40
|
+
command,
|
|
41
|
+
/** Without globs the check runs on every change; with them, only when a changed path matches. */
|
|
42
|
+
globs: globs.optional(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const generatorOwned = z.strictObject({
|
|
46
|
+
glob,
|
|
47
|
+
generator: nonEmpty,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** Files a published package ships, so they never name the projects, people or teams around it. */
|
|
51
|
+
const publicContent = z.strictObject({
|
|
52
|
+
/** The published files: a change here gets the reviewer and the scan for names of neighbouring projects. */
|
|
53
|
+
globs,
|
|
54
|
+
/** Files among them that never ship, such as tests. */
|
|
55
|
+
exclude: z.array(glob).default([]),
|
|
56
|
+
/** The agent that judges such a change; a global install names it with its prefix. */
|
|
57
|
+
reviewer: nonEmpty.default('public-content'),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The `check` block of `.jackherd/config.json`. Test and type commands come from the top-level
|
|
62
|
+
* `commands` block; this block holds what only the check runner reads.
|
|
63
|
+
*/
|
|
64
|
+
export const checkConfigSchema = z.strictObject({
|
|
65
|
+
/**
|
|
66
|
+
* Run once over the changed files, which are appended as arguments. Separate from `commands.lint`,
|
|
67
|
+
* which lints the whole project.
|
|
68
|
+
*/
|
|
69
|
+
lintFilesCommand: command.default(BUN_COMMANDS.lintFiles),
|
|
70
|
+
/** Project checks, run alongside lint; add repository-specific scripts here. */
|
|
71
|
+
checks: z.array(projectCheck).default([]),
|
|
72
|
+
generatorOwned: z.array(generatorOwned).default([]),
|
|
73
|
+
buildArtifacts: z.array(glob).default([...DEFAULT_BUILD_ARTIFACT_GLOBS]),
|
|
74
|
+
reviewerRoutes: z.array(reviewerRoute).default([]),
|
|
75
|
+
publicContent: publicContent.optional(),
|
|
76
|
+
/** Also run tests in directories that hold test files but belong to no package. */
|
|
77
|
+
looseTests: z.boolean().default(false),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export type CheckConfig = z.infer<typeof checkConfigSchema>
|
|
81
|
+
export type ReviewerRoute = CheckConfig['reviewerRoutes'][number]
|
|
82
|
+
export type ProjectCheck = CheckConfig['checks'][number]
|
|
83
|
+
export type PublicContentConfig = NonNullable<CheckConfig['publicContent']>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { jackherdConfigSchema } from '../config'
|
|
3
|
+
import { type RepositoryFacts, withDetected } from '../config/detect'
|
|
4
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
6
|
+
import type { PackageCommands } from './plan'
|
|
7
|
+
import { type CheckConfig, checkConfigSchema } from './schema'
|
|
8
|
+
|
|
9
|
+
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
10
|
+
|
|
11
|
+
/** `notes` name the values the repository did not decide (see `withDetected`). */
|
|
12
|
+
export type CheckSettings = {
|
|
13
|
+
commands: PackageCommands
|
|
14
|
+
check: CheckConfig
|
|
15
|
+
planDirectory: string
|
|
16
|
+
planLanguage: PlanLanguage
|
|
17
|
+
/** The branch on the remote whose plans count as merged. */
|
|
18
|
+
baseBranch: string
|
|
19
|
+
notes: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const commandsSchema = jackherdConfigSchema.shape.commands
|
|
23
|
+
const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
|
|
24
|
+
const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
25
|
+
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
|
|
29
|
+
* `check` block, with what the file leaves out detected from the repository. The rest of the file is
|
|
30
|
+
* validated by the commands that use it, not here.
|
|
31
|
+
*/
|
|
32
|
+
export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckSettings => {
|
|
33
|
+
const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
|
|
34
|
+
const object = z.record(z.string(), z.unknown()).parse(detected.raw)
|
|
35
|
+
const { test, typecheck } = commandsSchema.parse(object.commands)
|
|
36
|
+
return {
|
|
37
|
+
commands: { test, typecheck },
|
|
38
|
+
check: checkConfigSchema.parse(object.check ?? {}),
|
|
39
|
+
planDirectory: planDirectorySchema.parse(object.planDirectory),
|
|
40
|
+
planLanguage: planLanguageSchema.parse(object.planLanguage),
|
|
41
|
+
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
42
|
+
notes: detected.notes,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Where Claude Code itself reads skills, agents, rules, hooks and session logs: the one place that names them. */
|
|
2
|
+
export const CLAUDE_DIRECTORY = '.claude'
|
|
3
|
+
export const SETTINGS_FILE = 'settings.json'
|
|
4
|
+
export const LOCAL_SETTINGS_FILE = 'settings.local.json'
|
|
5
|
+
|
|
6
|
+
export const SKILLS_DIRECTORY = 'skills'
|
|
7
|
+
export const AGENTS_DIRECTORY = 'agents'
|
|
8
|
+
export const RULES_DIRECTORY = 'rules'
|
|
9
|
+
export const HANDOVER_DIRECTORY = 'handover'
|
|
10
|
+
export const WORKTREES_DIRECTORY = 'worktrees'
|
|
11
|
+
export const SESSION_LOGS_DIRECTORY = 'projects'
|
|
12
|
+
|
|
13
|
+
/** `.claude/<directory>` as a path in a project and in a text that names it. */
|
|
14
|
+
export const claudePath = (...segments: string[]): string => [CLAUDE_DIRECTORY, ...segments].join('/')
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { CLI_NAME } from './version/release'
|
|
4
|
+
|
|
5
|
+
export type CommandModule = { run: (args: string[]) => Promise<number> | number }
|
|
6
|
+
|
|
7
|
+
export type CommandTable = Record<string, () => Promise<CommandModule>>
|
|
8
|
+
|
|
9
|
+
export type Resolution =
|
|
10
|
+
| { kind: 'run'; name: string; args: string[] }
|
|
11
|
+
| { kind: 'usage'; exitCode: number; message: string }
|
|
12
|
+
|
|
13
|
+
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
14
|
+
export const COMMANDS: CommandTable = {
|
|
15
|
+
check: () => import('./check/command'),
|
|
16
|
+
config: () => import('./config/command'),
|
|
17
|
+
context: () => import('./context/command'),
|
|
18
|
+
init: () => import('./sync/init-command'),
|
|
19
|
+
plans: () => import('./plans/command'),
|
|
20
|
+
sync: () => import('./sync/command'),
|
|
21
|
+
update: () => import('./update/command'),
|
|
22
|
+
watch: () => import('./watch/command'),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const resolveCommand = (argv: string[], table: CommandTable): Resolution => {
|
|
26
|
+
const [name, ...args] = argv
|
|
27
|
+
const names = Object.keys(table)
|
|
28
|
+
const usage = `Usage: ${CLI_NAME} <command> [args]\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
|
|
29
|
+
if (!name || name === '--help' || name === '-h') return { kind: 'usage', exitCode: name ? 0 : 1, message: usage }
|
|
30
|
+
if (!names.includes(name)) return { kind: 'usage', exitCode: 1, message: `Unknown command: ${name}\n${usage}` }
|
|
31
|
+
return { kind: 'run', name, args }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const main = async (argv: string[], table: CommandTable = COMMANDS): Promise<number> => {
|
|
35
|
+
const resolution = resolveCommand(argv, table)
|
|
36
|
+
if (resolution.kind === 'usage') {
|
|
37
|
+
console.log(resolution.message)
|
|
38
|
+
return resolution.exitCode
|
|
39
|
+
}
|
|
40
|
+
const command = await table[resolution.name]()
|
|
41
|
+
return command.run(resolution.args)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
45
|
+
process.exitCode = await main(process.argv.slice(2))
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
3
|
+
import { CLI_NAME } from '../version/release'
|
|
4
|
+
import { readResolvedConfig } from './read-config'
|
|
5
|
+
import { formatRepositoryValues, isRepositoryValue, REPOSITORY_VALUE, repositoryValuesOf } from './repository-values'
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: ${CLI_NAME} config [${Object.values(REPOSITORY_VALUE).join(' | ')}]`
|
|
8
|
+
|
|
9
|
+
const USAGE_EXIT = 2
|
|
10
|
+
|
|
11
|
+
export type ConfigOutput = { log: (line: string) => void; error: (line: string) => void }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Prints the values this repository decides, as the config sets them or detection finds them: all of them, or
|
|
15
|
+
* the one `key` names. Notes about values the repository did not decide go to `error`, so a key's output stays
|
|
16
|
+
* the bare value.
|
|
17
|
+
*/
|
|
18
|
+
export const printRepositoryValues = (args: string[], roots: string[], home: string, output: ConfigOutput): number => {
|
|
19
|
+
const [key, extra] = args
|
|
20
|
+
if (extra !== undefined) {
|
|
21
|
+
output.error(USAGE)
|
|
22
|
+
return USAGE_EXIT
|
|
23
|
+
}
|
|
24
|
+
if (key !== undefined && !isRepositoryValue(key)) {
|
|
25
|
+
output.error(`Unknown key: ${key}\n${USAGE}`)
|
|
26
|
+
return USAGE_EXIT
|
|
27
|
+
}
|
|
28
|
+
const { config, notes } = readResolvedConfig(roots, home)
|
|
29
|
+
for (const note of notes) output.error(note)
|
|
30
|
+
const values = repositoryValuesOf(config)
|
|
31
|
+
const lines = key === undefined ? formatRepositoryValues(values) : [values[key]]
|
|
32
|
+
for (const line of lines) output.log(line)
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const run = (args: string[]): number => {
|
|
37
|
+
const output = { log: (line: string) => console.log(line), error: (line: string) => console.error(line) }
|
|
38
|
+
try {
|
|
39
|
+
const cwd = process.cwd()
|
|
40
|
+
return printRepositoryValues(args, [worktreeRoot(cwd), mainCheckout(cwd)], homedir(), output)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
output.error(`${CLI_NAME} config failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
}
|