jackherd 0.0.1 → 1.0.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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- 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,94 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import {
|
|
3
|
+
type CommandContext,
|
|
4
|
+
defaultContext,
|
|
5
|
+
GLOBAL_FLAG,
|
|
6
|
+
globalTargetNote,
|
|
7
|
+
runWithKit,
|
|
8
|
+
usageError,
|
|
9
|
+
} from './command-context'
|
|
10
|
+
import { errorMessage } from './error-message'
|
|
11
|
+
import { dailyUpdateNotice, defaultUpdateCheck, lockedVersion, syncNeeded, type UpdateCheck } from './if-changed'
|
|
12
|
+
import { SYNC_EXIT } from './report'
|
|
13
|
+
import { migrateGlobalSettings } from './settings-migration'
|
|
14
|
+
import { runGlobalSync, runSync } from './sync'
|
|
15
|
+
|
|
16
|
+
export type SyncArgs =
|
|
17
|
+
| { ok: true; accept: string[]; global: boolean; ifChanged: boolean }
|
|
18
|
+
| { ok: false; message: string }
|
|
19
|
+
|
|
20
|
+
const ACCEPT_FLAG = '--accept'
|
|
21
|
+
const IF_CHANGED_FLAG = '--if-changed'
|
|
22
|
+
|
|
23
|
+
const SYNC_USAGE = `Usage: ${CLI_NAME} sync [--global [${IF_CHANGED_FLAG}]] [--accept <path>]...`
|
|
24
|
+
|
|
25
|
+
export const parseSyncArgs = (args: string[]): SyncArgs => {
|
|
26
|
+
const accept: string[] = []
|
|
27
|
+
let global = false
|
|
28
|
+
let ifChanged = false
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
const arg = args[i]
|
|
31
|
+
if (arg === GLOBAL_FLAG) {
|
|
32
|
+
global = true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (arg === IF_CHANGED_FLAG) {
|
|
36
|
+
ifChanged = true
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (arg.startsWith(`${ACCEPT_FLAG}=`)) {
|
|
40
|
+
accept.push(arg.slice(ACCEPT_FLAG.length + 1))
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (arg !== ACCEPT_FLAG) return { ok: false, message: `Unknown argument: ${arg}\n${SYNC_USAGE}` }
|
|
44
|
+
const path = args[++i]
|
|
45
|
+
if (path === undefined) return { ok: false, message: `${ACCEPT_FLAG} needs a path\n${SYNC_USAGE}` }
|
|
46
|
+
accept.push(path)
|
|
47
|
+
}
|
|
48
|
+
if (ifChanged && !global) return { ok: false, message: `${IF_CHANGED_FLAG} goes with ${GLOBAL_FLAG}\n${SYNC_USAGE}` }
|
|
49
|
+
return { ok: true, accept, global, ifChanged }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const syncGlobal = (context: CommandContext, accept: string[]): Promise<number> =>
|
|
53
|
+
runWithKit(context, 'sync', async (kit) => {
|
|
54
|
+
const result = await runGlobalSync({ home: context.home, kit, accept, configHome: context.configHome })
|
|
55
|
+
const settings = await migrateGlobalSettings(context.home, context.cliOnPath())
|
|
56
|
+
return { notes: [globalTargetNote(context.home), ...settings], result }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `sync --global --if-changed`, meant for a `SessionStart` hook: syncs `~/.claude` only when the installed package
|
|
61
|
+
* is not the version the global lock was written by, and once a day names a newer release on npm in one line.
|
|
62
|
+
* Prints nothing when there is nothing to do, since a hook's output reaches the session. Installs nothing.
|
|
63
|
+
*/
|
|
64
|
+
const syncGlobalIfChanged = async (context: CommandContext, accept: string[], check: UpdateCheck): Promise<number> => {
|
|
65
|
+
let installed: string
|
|
66
|
+
try {
|
|
67
|
+
installed = (await context.locate()).version
|
|
68
|
+
} catch (err) {
|
|
69
|
+
context.log(`${CLI_NAME} sync failed: ${errorMessage(err)}`)
|
|
70
|
+
return SYNC_EXIT.error
|
|
71
|
+
}
|
|
72
|
+
const exitCode = syncNeeded(installed, await lockedVersion(context.home)) ? await syncGlobal(context, accept) : 0
|
|
73
|
+
const notice = await dailyUpdateNotice(context.home, installed, check)
|
|
74
|
+
if (notice) context.log(notice)
|
|
75
|
+
return exitCode
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const runSyncCommand = async (
|
|
79
|
+
args: string[],
|
|
80
|
+
context: CommandContext = defaultContext(),
|
|
81
|
+
check: UpdateCheck = defaultUpdateCheck(),
|
|
82
|
+
): Promise<number> => {
|
|
83
|
+
const parsed = parseSyncArgs(args)
|
|
84
|
+
if (!parsed.ok) return usageError(context, parsed.message)
|
|
85
|
+
const { accept, global, ifChanged } = parsed
|
|
86
|
+
if (ifChanged) return syncGlobalIfChanged(context, accept, check)
|
|
87
|
+
if (global) return syncGlobal(context, accept)
|
|
88
|
+
return runWithKit(context, 'sync', async (kit) => ({
|
|
89
|
+
notes: [],
|
|
90
|
+
result: await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept }),
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const run = (args: string[]): Promise<number> => runSyncCommand(args)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const errorMessage = (err: unknown): string => (err instanceof Error ? err.message : String(err))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { lstat, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
|
+
|
|
5
|
+
/** What the kit's skills write per lane and nobody commits: the hand-over notes of a lane that changes chat. */
|
|
6
|
+
export const KIT_IGNORED_PATHS: readonly string[] = [`${claudePath(HANDOVER_DIRECTORY)}/`]
|
|
7
|
+
|
|
8
|
+
const GITIGNORE = '.gitignore'
|
|
9
|
+
|
|
10
|
+
const isMissing = (err: unknown): boolean =>
|
|
11
|
+
typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
|
|
12
|
+
|
|
13
|
+
const readExisting = async (path: string): Promise<string | undefined> => {
|
|
14
|
+
try {
|
|
15
|
+
const entry = await lstat(path)
|
|
16
|
+
if (!entry.isFile()) throw new Error(`${GITIGNORE} is not a regular file`)
|
|
17
|
+
return await readFile(path, 'utf8')
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (isMissing(err)) return undefined
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const missingLines = (text: string, lines: readonly string[]): string[] => {
|
|
25
|
+
const present = new Set(text.split('\n').map((line) => line.trim()))
|
|
26
|
+
return lines.filter((line) => !present.has(line))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Adds the lines the project's `.gitignore` lacks, at its end; a file that does not exist is created. A line
|
|
31
|
+
* that is already there (whatever surrounds it) is left alone, so a second run changes nothing. Returns the
|
|
32
|
+
* lines it added. Throws when `.gitignore` is a symlink or a directory: nothing is written through it.
|
|
33
|
+
*/
|
|
34
|
+
export const ensureGitignoreLines = async (
|
|
35
|
+
projectRoot: string,
|
|
36
|
+
lines: readonly string[] = KIT_IGNORED_PATHS,
|
|
37
|
+
): Promise<string[]> => {
|
|
38
|
+
const path = join(projectRoot, GITIGNORE)
|
|
39
|
+
const existing = await readExisting(path)
|
|
40
|
+
const missing = missingLines(existing ?? '', lines)
|
|
41
|
+
if (missing.length === 0) return []
|
|
42
|
+
const separator = existing === undefined || existing === '' || existing.endsWith('\n') ? '' : '\n'
|
|
43
|
+
await writeFile(path, `${existing ?? ''}${separator}${missing.join('\n')}\n`)
|
|
44
|
+
return missing
|
|
45
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { AGENTS_DIRECTORY, claudePath, RULES_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
|
|
2
|
+
import { SKILL_PREFIX } from '../config'
|
|
3
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
4
|
+
import { REPOSITORY_TOKENS } from '../config/repository-values'
|
|
5
|
+
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
6
|
+
import type { KitFile, KitSource } from './kit'
|
|
7
|
+
import { TOOL_ROOT } from './kit-path'
|
|
8
|
+
import { listPlaceholders } from './render'
|
|
9
|
+
|
|
10
|
+
export type EntryKind = 'skill' | 'agent'
|
|
11
|
+
|
|
12
|
+
/** Where the kit's rules land: in `~/.jackherd/rules/`, outside `~/.claude/`, so Claude Code does not load them in every project. */
|
|
13
|
+
const GLOBAL_RULES_DIRECTORY = `${TOOL_ROOT}/${RULES_DIRECTORY}`
|
|
14
|
+
|
|
15
|
+
/** Put at the top of a global file that names values each repository decides; `sync --global` renders them as `<name>`. */
|
|
16
|
+
export const REPOSITORY_VALUES_NOTE = `Values in angle brackets such as \`<test>\`, \`<install>\` or \`<baseBranch>\` are the current repository's own: run \`${CLI_NAME} config\` once in it to read them all, or \`${CLI_NAME} config <name>\` for one.`
|
|
17
|
+
|
|
18
|
+
const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
|
|
19
|
+
const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
|
|
20
|
+
const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/[^/]+/SKILL\\.md$`)
|
|
21
|
+
const RULE_PATH = new RegExp(`^${RULES_DIRECTORY}/kit/(.+)$`)
|
|
22
|
+
|
|
23
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/
|
|
24
|
+
const NAME_LINE = /^name: (.+)$/m
|
|
25
|
+
const DESCRIPTION_LINE = /^description: '(.*)'$/m
|
|
26
|
+
|
|
27
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
28
|
+
|
|
29
|
+
export const globalNote = (name: string, kind: EntryKind): string =>
|
|
30
|
+
`Global ${PACKAGE_NAME} copy. In a project that has its own ${name} ${kind}, use that one instead. `
|
|
31
|
+
|
|
32
|
+
const assertUsablePrefix = (prefix: string): void => {
|
|
33
|
+
if (!SKILL_PREFIX.test(prefix)) {
|
|
34
|
+
throw new Error(`skillPrefix "${prefix}" is not usable: lower-case letters and digits ending in a dash, e.g. jh-`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where the status line lands: `~/.jackherd/statusline.sh`; `init --global` offers it from there. */
|
|
39
|
+
const GLOBAL_STATUS_LINE_PATH = `${TOOL_ROOT}/statusline.sh`
|
|
40
|
+
|
|
41
|
+
/** The path in a project's `.claude/`: skills and agents prefixed, everything else where the kit puts it. */
|
|
42
|
+
const projectPath = (path: string, prefix: string): string => {
|
|
43
|
+
const skill = SKILL_PATH.exec(path)
|
|
44
|
+
if (skill) return `${SKILLS_DIRECTORY}/${prefix}${skill[1]}/${skill[2]}`
|
|
45
|
+
const agent = AGENT_PATH.exec(path)
|
|
46
|
+
return agent ? `${AGENTS_DIRECTORY}/${prefix}${agent[1]}.md` : path
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The path in `~/.claude`, or null for what stays out of a global install (the hooks). */
|
|
50
|
+
const globalPath = (path: string, prefix: string): string | null => {
|
|
51
|
+
if (path === 'statusline.sh') return GLOBAL_STATUS_LINE_PATH
|
|
52
|
+
const rule = RULE_PATH.exec(path)
|
|
53
|
+
if (rule) return `${GLOBAL_RULES_DIRECTORY}/${rule[1]}`
|
|
54
|
+
return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? projectPath(path, prefix) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entryKind = (path: string): EntryKind | null => {
|
|
58
|
+
if (SKILL_ENTRY.test(path)) return 'skill'
|
|
59
|
+
if (AGENT_PATH.test(path)) return 'agent'
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Skill and agent names the kit ships, longest first so `pre-commit` wins over a shorter name inside it. */
|
|
64
|
+
const kitNames = (files: KitFile[]): string[] => {
|
|
65
|
+
const names = files.flatMap(({ path }) => {
|
|
66
|
+
const match = SKILL_PATH.exec(path) ?? AGENT_PATH.exec(path)
|
|
67
|
+
return match ? [match[1]] : []
|
|
68
|
+
})
|
|
69
|
+
return [...new Set(names)].sort((left, right) => right.length - left.length)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Names as a skill or agent calls another: slash commands, `subagent_type`, backticked names, relative links. */
|
|
73
|
+
const rewriteNames = (text: string, alternation: string, prefix: string): string =>
|
|
74
|
+
text
|
|
75
|
+
.replace(
|
|
76
|
+
new RegExp(`\\.\\./${SKILLS_DIRECTORY}/(${alternation})(?![\\w-])`, 'g'),
|
|
77
|
+
`../${SKILLS_DIRECTORY}/${prefix}$1`,
|
|
78
|
+
)
|
|
79
|
+
.replace(new RegExp(`(?<![\\w.~/-])/(${alternation})(?![\\w/-])`, 'g'), `/${prefix}$1`)
|
|
80
|
+
.replace(new RegExp(`subagent_type: '(${alternation})'`, 'g'), `subagent_type: '${prefix}$1'`)
|
|
81
|
+
.replace(new RegExp(`\`(${alternation})\``, 'g'), `\`${prefix}$1\``)
|
|
82
|
+
|
|
83
|
+
const PROJECT_SKILLS = claudePath(SKILLS_DIRECTORY)
|
|
84
|
+
const GLOBAL_SKILLS = `~/${PROJECT_SKILLS}`
|
|
85
|
+
|
|
86
|
+
/** `.claude/skills/<name>/` as the copy in `skillsDirectory` (`~/.claude/skills` or the project's own). */
|
|
87
|
+
const rewriteSkillPaths = (text: string, alternation: string, prefix: string, skillsDirectory: string): string =>
|
|
88
|
+
text.replace(
|
|
89
|
+
new RegExp(`(?<![~\\w/])${escapeRegExp(PROJECT_SKILLS)}/(${alternation})/`, 'g'),
|
|
90
|
+
`${skillsDirectory}/${prefix}$1/`,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const rewriteReferences = (text: string, names: string[], prefix: string): string => {
|
|
94
|
+
const alternation = names.map(escapeRegExp).join('|')
|
|
95
|
+
return rewriteNames(rewriteSkillPaths(text, alternation, prefix, GLOBAL_SKILLS), alternation, prefix).replace(
|
|
96
|
+
new RegExp(`(?<![~\\w/])${escapeRegExp(claudePath(RULES_DIRECTORY))}/kit/(?=[\\w-]+\\.md)`, 'g'),
|
|
97
|
+
`~/${TOOL_DIRECTORY}/${RULES_DIRECTORY}/`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rewriteFrontmatter = (file: KitFile, prefix: string, note: (name: string) => string): string => {
|
|
102
|
+
const block = FRONTMATTER.exec(file.template)
|
|
103
|
+
if (!block) throw new Error(`Kit file ${file.path} has no frontmatter`)
|
|
104
|
+
const name = NAME_LINE.exec(block[1])?.[1].trim()
|
|
105
|
+
if (!name || !DESCRIPTION_LINE.test(block[1])) {
|
|
106
|
+
throw new Error(`Kit file ${file.path} needs a name line and a single-quoted description line`)
|
|
107
|
+
}
|
|
108
|
+
const head = block[1]
|
|
109
|
+
.replace(NAME_LINE, `name: ${prefix}${name}`)
|
|
110
|
+
.replace(DESCRIPTION_LINE, (_line, text: string) => `description: '${note(name)}${text}'`)
|
|
111
|
+
return `---\n${head}\n---\n${file.template.slice(block[0].length)}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const usesRepositoryValues = (template: string): boolean =>
|
|
115
|
+
listPlaceholders(template).some((name) => REPOSITORY_TOKENS.has(name))
|
|
116
|
+
|
|
117
|
+
/** The note right after the frontmatter, or first when there is none. */
|
|
118
|
+
const withRepositoryNote = (template: string): string => {
|
|
119
|
+
const block = FRONTMATTER.exec(template)
|
|
120
|
+
const head = block ? block[0] : ''
|
|
121
|
+
const rest = template.slice(head.length).replace(/^\n+/, '')
|
|
122
|
+
return `${head}${head ? '\n' : ''}${REPOSITORY_VALUES_NOTE}\n\n${rest}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const toGlobalFile = (file: KitFile, path: string, names: string[], prefix: string): KitFile => {
|
|
126
|
+
if (!file.path.endsWith('.md')) return { ...file, path }
|
|
127
|
+
const kind = entryKind(file.path)
|
|
128
|
+
const noted =
|
|
129
|
+
kind === 'skill' || usesRepositoryValues(file.template) ? withRepositoryNote(file.template) : file.template
|
|
130
|
+
const body = rewriteReferences(noted, names, prefix)
|
|
131
|
+
const template = kind
|
|
132
|
+
? rewriteFrontmatter({ ...file, template: body }, prefix, (name) => globalNote(name, kind))
|
|
133
|
+
: body
|
|
134
|
+
return { ...file, path, template }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The kit as `~/.claude` receives it: skills and agents renamed with the prefix, rules and the status line
|
|
139
|
+
* moved into `~/.jackherd/`, hooks left out. Inside Markdown files, every reference
|
|
140
|
+
* follows: slash commands, `subagent_type`, backticked names, skill and rule paths. A global copy
|
|
141
|
+
* needs the prefix because a personal skill shadows a project skill of the same name.
|
|
142
|
+
*/
|
|
143
|
+
export const toGlobalKit = (kit: KitSource, prefix: string): KitSource => {
|
|
144
|
+
assertUsablePrefix(prefix)
|
|
145
|
+
const names = kitNames(kit.files)
|
|
146
|
+
const files = kit.files.flatMap((file) => {
|
|
147
|
+
const path = globalPath(file.path, prefix)
|
|
148
|
+
return path ? [toGlobalFile(file, path, names, prefix)] : []
|
|
149
|
+
})
|
|
150
|
+
return { version: kit.version, files, hooksTemplate: undefined }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const toProjectFile = (file: KitFile, names: string[], prefix: string): KitFile => {
|
|
154
|
+
const path = projectPath(file.path, prefix)
|
|
155
|
+
if (!file.path.endsWith('.md') || file.path.startsWith('hooks/')) return { ...file, path }
|
|
156
|
+
const alternation = names.map(escapeRegExp).join('|')
|
|
157
|
+
const body = rewriteNames(rewriteSkillPaths(file.template, alternation, prefix, PROJECT_SKILLS), alternation, prefix)
|
|
158
|
+
const kind = entryKind(file.path)
|
|
159
|
+
return {
|
|
160
|
+
...file,
|
|
161
|
+
path,
|
|
162
|
+
template: kind ? rewriteFrontmatter({ ...file, template: body }, prefix, () => '') : body,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The kit as a project with `skillPrefix` receives it: skills and agents renamed, every reference to them
|
|
168
|
+
* following (as in `toGlobalKit`), rules, hooks and the status line in place. A project skill of the same name
|
|
169
|
+
* then stays the project's, and the kit skills call each other, not it. Without a prefix the kit is returned as it is.
|
|
170
|
+
*/
|
|
171
|
+
export const toProjectKit = (kit: KitSource, prefix: string): KitSource => {
|
|
172
|
+
if (prefix === '') return kit
|
|
173
|
+
assertUsablePrefix(prefix)
|
|
174
|
+
const names = kitNames(kit.files)
|
|
175
|
+
return { ...kit, files: kit.files.map((file) => toProjectFile(file, names, prefix)) }
|
|
176
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { isLinkedWorktree } from '../plans/git'
|
|
4
|
+
import { herdrBinary, spawnTool, type ToolRun } from './tools'
|
|
5
|
+
|
|
6
|
+
export const HERDR_PLUGIN_ID = 'crossdelta.jackherd'
|
|
7
|
+
|
|
8
|
+
/** The plugin id before the rename; `sync` unlinks it once the current id is linked, so it is replaced, not doubled. */
|
|
9
|
+
export const LEGACY_HERDR_PLUGIN_ID = 'crossdelta.agent-kit'
|
|
10
|
+
|
|
11
|
+
/** The plugin's directory below the package root; `package.json` ships it through `files`. */
|
|
12
|
+
export const HERDR_PLUGIN_DIRECTORY = 'herdr-plugin'
|
|
13
|
+
|
|
14
|
+
/** Why the plugin is linked again: herdr does not list it, it points at another directory, or its manifest is unreadable. */
|
|
15
|
+
export const RELINK_REASON = { missing: 'missing', elsewhere: 'elsewhere', manifest: 'manifest' } as const
|
|
16
|
+
|
|
17
|
+
export type RelinkReason = (typeof RELINK_REASON)[keyof typeof RELINK_REASON]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where the running package lies: a checkout of the kit's source, a lane's worktree (whose path goes away with the
|
|
21
|
+
* lane), or an installed package (global, `bunx` cache, a project's dependency).
|
|
22
|
+
*/
|
|
23
|
+
export const PACKAGE_COPY = { checkout: 'checkout', laneWorktree: 'laneWorktree', installed: 'installed' } as const
|
|
24
|
+
|
|
25
|
+
export type PackageCopy = (typeof PACKAGE_COPY)[keyof typeof PACKAGE_COPY]
|
|
26
|
+
|
|
27
|
+
/** One herdr command; `missing` is true when there is no `herdr` to run at all. */
|
|
28
|
+
export type HerdrRun = (args: string[]) => ToolRun
|
|
29
|
+
|
|
30
|
+
export type LinkRequest = {
|
|
31
|
+
packageRoot: string
|
|
32
|
+
env: Record<string, string | undefined>
|
|
33
|
+
run: HerdrRun
|
|
34
|
+
/** What this copy may link: see `linkHerdrPlugin`. */
|
|
35
|
+
copy: PackageCopy
|
|
36
|
+
/** The id an install before the rename linked; `LEGACY_HERDR_PLUGIN_ID` unless a test names another. */
|
|
37
|
+
legacyPluginId?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The kit plugin as herdr has it linked. */
|
|
41
|
+
export type LinkedPlugin = { root: string; warnings: string[] }
|
|
42
|
+
|
|
43
|
+
type ListedPlugin = { plugin_id?: unknown; plugin_root?: unknown; warnings?: unknown }
|
|
44
|
+
|
|
45
|
+
/** The kit plugin in a `herdr plugin list --json` answer, null when it is not there; throws on another shape. */
|
|
46
|
+
export const linkedPluginOf = (json: string, id: string = HERDR_PLUGIN_ID): LinkedPlugin | null => {
|
|
47
|
+
const plugins = (JSON.parse(json) as { result?: { plugins?: unknown } }).result?.plugins
|
|
48
|
+
if (!Array.isArray(plugins)) throw new Error('herdr plugin list: unexpected answer')
|
|
49
|
+
const listed = (plugins as ListedPlugin[]).find((plugin) => plugin?.plugin_id === id)
|
|
50
|
+
if (!listed) return null
|
|
51
|
+
const warnings = Array.isArray(listed.warnings) ? listed.warnings.filter((each) => typeof each === 'string') : []
|
|
52
|
+
return { root: String(listed.plugin_root), warnings }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const manifestWarningOf = (linked: LinkedPlugin): string | undefined =>
|
|
56
|
+
linked.warnings.find((warning) => warning.startsWith('manifest'))
|
|
57
|
+
|
|
58
|
+
/** Why the link has to be made again, null when it points at `expected` and herdr can read its manifest. */
|
|
59
|
+
export const relinkReasonOf = (linked: LinkedPlugin | null, expected: string): RelinkReason | null => {
|
|
60
|
+
if (!linked) return RELINK_REASON.missing
|
|
61
|
+
if (manifestWarningOf(linked)) return RELINK_REASON.manifest
|
|
62
|
+
return resolve(linked.root) === resolve(expected) ? null : RELINK_REASON.elsewhere
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The reason as the output line says it: herdr's own warning for the manifest, else what made the link wrong. */
|
|
66
|
+
const reasonText = (reason: RelinkReason, linked: LinkedPlugin): string =>
|
|
67
|
+
reason === RELINK_REASON.manifest ? (manifestWarningOf(linked) ?? 'manifest') : 'it pointed at another package'
|
|
68
|
+
|
|
69
|
+
/** A lane's worktree never links or unlinks: it names a broken link to this copy's directory, nothing else. */
|
|
70
|
+
const laneNote = (linked: LinkedPlugin | null, packageRoot: string): string | null =>
|
|
71
|
+
relinkReasonOf(linked, join(packageRoot, HERDR_PLUGIN_DIRECTORY)) === null ? null : staleLinkNote(linked)
|
|
72
|
+
|
|
73
|
+
/** What a lane's worktree says about the link: only a broken one is worth a line, since it never relinks from there. */
|
|
74
|
+
const staleLinkNote = (linked: LinkedPlugin | null): string | null => {
|
|
75
|
+
const warning = linked && manifestWarningOf(linked)
|
|
76
|
+
return linked && warning
|
|
77
|
+
? `The herdr plugin link to ${linked.root} is stale (${warning}); not relinked from a lane worktree, run sync outside one.`
|
|
78
|
+
: null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the package root lies below a `node_modules` directory, i.e. was installed rather than checked out. */
|
|
82
|
+
export const isInstalledCopy = (packageRoot: string): boolean => packageRoot.split(/[\\/]/).includes('node_modules')
|
|
83
|
+
|
|
84
|
+
type Listing = { missing: boolean; linked: LinkedPlugin | null; legacyLinked: boolean }
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The plugin herdr lists and whether the old id is linked beside it; null and false when the list failed or has
|
|
88
|
+
* another shape: then the link is simply made and nothing is unlinked.
|
|
89
|
+
*/
|
|
90
|
+
const listedPlugin = (run: HerdrRun, legacyId: string): Listing => {
|
|
91
|
+
const listing = run(['plugin', 'list', '--json'])
|
|
92
|
+
if (listing.missing || listing.exitCode !== 0) return { missing: listing.missing, linked: null, legacyLinked: false }
|
|
93
|
+
try {
|
|
94
|
+
const legacyLinked = legacyId !== HERDR_PLUGIN_ID && linkedPluginOf(listing.output, legacyId) !== null
|
|
95
|
+
return { missing: false, linked: linkedPluginOf(listing.output), legacyLinked }
|
|
96
|
+
} catch {
|
|
97
|
+
return { missing: false, linked: null, legacyLinked: false }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Unlinks the old id once the current one is linked; the line to print. */
|
|
102
|
+
const unlinkLegacy = (run: HerdrRun, legacyId: string): string => {
|
|
103
|
+
const result = run(['plugin', 'unlink', legacyId])
|
|
104
|
+
return result.exitCode === 0
|
|
105
|
+
? `Unlinked the herdr plugin ${legacyId}, which the new one replaces.`
|
|
106
|
+
: `Could not unlink the old herdr plugin ${legacyId}, herdr said: ${result.output || `exit ${result.exitCode}`}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const joinLines = (...lines: (string | null)[]): string | null => {
|
|
110
|
+
const present = lines.filter((line): line is string => line !== null)
|
|
111
|
+
return present.length > 0 ? present.join('\n') : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Hooks the kit's herdr plugin into herdr when `init` or `sync` runs inside herdr (`HERDR_ENV=1`), and links it
|
|
116
|
+
* again when herdr's link points at another directory or herdr cannot read its manifest (`herdr plugin link`
|
|
117
|
+
* replaces a plugin of the same id). What each copy may link:
|
|
118
|
+
* - a checkout takes over any link, so a repository that develops the kit keeps its link;
|
|
119
|
+
* - an installed package only replaces a missing or unreadable link and leaves a working one standing, also one to
|
|
120
|
+
* another installed package: otherwise the global package and a project's dependency would take it from each other;
|
|
121
|
+
* - a lane's worktree never links, since its path goes away with the lane, and only names a broken link.
|
|
122
|
+
* Returns the line to print, or null when there is nothing to say. A failed link is a hint in the returned line,
|
|
123
|
+
* never an error: herdr is optional.
|
|
124
|
+
*/
|
|
125
|
+
export const linkHerdrPlugin = ({
|
|
126
|
+
packageRoot,
|
|
127
|
+
env,
|
|
128
|
+
run,
|
|
129
|
+
copy,
|
|
130
|
+
legacyPluginId = LEGACY_HERDR_PLUGIN_ID,
|
|
131
|
+
}: LinkRequest): string | null => {
|
|
132
|
+
if (env.HERDR_ENV !== '1') return null
|
|
133
|
+
const { missing, linked, legacyLinked } = listedPlugin(run, legacyPluginId)
|
|
134
|
+
if (missing || copy === PACKAGE_COPY.laneWorktree) return missing ? null : laneNote(linked, packageRoot)
|
|
135
|
+
const unlink = () => (legacyLinked ? unlinkLegacy(run, legacyPluginId) : null)
|
|
136
|
+
const directory = join(packageRoot, HERDR_PLUGIN_DIRECTORY)
|
|
137
|
+
const reason = relinkReasonOf(linked, directory)
|
|
138
|
+
if (reason === null) return unlink()
|
|
139
|
+
if (copy === PACKAGE_COPY.installed && reason === RELINK_REASON.elsewhere) return null
|
|
140
|
+
const result = run(['plugin', 'link', directory])
|
|
141
|
+
if (result.exitCode !== 0)
|
|
142
|
+
return `Could not link the herdr plugin, herdr said: ${result.output || `exit ${result.exitCode}`}`
|
|
143
|
+
const linkedLine = linked
|
|
144
|
+
? `Relinked the herdr plugin from ${linked.root} to ${directory} (${reasonText(reason, linked)}).`
|
|
145
|
+
: 'Linked the herdr plugin (the pane and the action for the plans view).'
|
|
146
|
+
return joinLines(linkedLine, unlink())
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** herdr as `HerdrRun`: the binary of `herdrBinary`, run without a shell. */
|
|
150
|
+
const spawnHerdr =
|
|
151
|
+
(env: Record<string, string | undefined>): HerdrRun =>
|
|
152
|
+
(args) =>
|
|
153
|
+
spawnTool(env)(herdrBinary(env), args)
|
|
154
|
+
|
|
155
|
+
const realPathOf = (path: string): string => {
|
|
156
|
+
try {
|
|
157
|
+
return realpathSync(path)
|
|
158
|
+
} catch {
|
|
159
|
+
return path
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A lane's worktree first, so a package installed into one never links either; a workspace link resolves to its checkout. */
|
|
164
|
+
const packageCopyOf = (packageRoot: string): PackageCopy => {
|
|
165
|
+
if (isLinkedWorktree(packageRoot)) return PACKAGE_COPY.laneWorktree
|
|
166
|
+
return isInstalledCopy(realPathOf(packageRoot)) ? PACKAGE_COPY.installed : PACKAGE_COPY.checkout
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const linkHerdrPluginFrom = (packageRoot: string): string | null =>
|
|
170
|
+
linkHerdrPlugin({
|
|
171
|
+
packageRoot,
|
|
172
|
+
env: process.env,
|
|
173
|
+
run: spawnHerdr(process.env),
|
|
174
|
+
copy: packageCopyOf(packageRoot),
|
|
175
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { LEGACY_MANIFEST_PATH } from '../config/legacy-names'
|
|
4
|
+
import { MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
5
|
+
import { CLI_NAME, isNewerVersion, PACKAGE_NAME } from '../version/release'
|
|
6
|
+
import { parseManifest } from './manifest'
|
|
7
|
+
import { FILE_MODE } from './plan-file'
|
|
8
|
+
import { assertOwnDirectoriesInProject, writeFileSafely } from './project-fs'
|
|
9
|
+
|
|
10
|
+
/** When the npm registry was last asked for a newer version, in epoch milliseconds. */
|
|
11
|
+
export type UpdateState = { checkedAt: number }
|
|
12
|
+
|
|
13
|
+
/** How `--if-changed` learns the time and the newest version on npm; null when npm gave no answer. */
|
|
14
|
+
export type UpdateCheck = { now: () => number; latestVersion: () => Promise<string | null> }
|
|
15
|
+
|
|
16
|
+
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
const REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
|
|
19
|
+
const REGISTRY_TIMEOUT_MS = 3_000
|
|
20
|
+
const UPDATE_STATE_FILE = 'update-check.json'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A sync is due when the installed package is not the version the global lock was written by. Without a lock
|
|
24
|
+
* there is no global install to keep current: that takes `init --global`, never a hook.
|
|
25
|
+
*/
|
|
26
|
+
export const syncNeeded = (installed: string, locked: string | null): boolean => locked !== null && installed !== locked
|
|
27
|
+
|
|
28
|
+
/** npm is asked at most once a day; a check stamped in the future (a clock set back) does not block it. */
|
|
29
|
+
export const updateCheckDue = (state: UpdateState | null, now: number): boolean =>
|
|
30
|
+
state === null || state.checkedAt > now || now - state.checkedAt >= UPDATE_CHECK_INTERVAL_MS
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The line a `SessionStart` hook hands the session as context when npm has a newer release. It tells the session
|
|
34
|
+
* to ask the user through a dialog before running `jh update`; the hook installs nothing itself. Only
|
|
35
|
+
* a plain release version is interpolated, since this text becomes part of the session's prompt.
|
|
36
|
+
*/
|
|
37
|
+
export const updateNotice = (latest: string | null, installed: string): string | null =>
|
|
38
|
+
latest && isNewerVersion(latest, installed)
|
|
39
|
+
? `${CLI_NAME} ${latest} is on npm, ${installed} is installed. Ask the user with AskUserQuestion whether to run \`${CLI_NAME} update\` now (update now / not now), and run it only after a yes.`
|
|
40
|
+
: null
|
|
41
|
+
|
|
42
|
+
const readJson = async (path: string): Promise<unknown> => {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const versionOfLock = async (path: string): Promise<string | null> => {
|
|
51
|
+
const parsed = parseManifest(await readJson(path))
|
|
52
|
+
return parsed.ok ? parsed.manifest.kitVersion : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The kit version the global lock was written by, under its old name while the global sync has not moved it. */
|
|
56
|
+
export const lockedVersion = async (home: string): Promise<string | null> =>
|
|
57
|
+
(await versionOfLock(join(home, TOOL_DIRECTORY, MANIFEST_FILE))) ?? versionOfLock(join(home, LEGACY_MANIFEST_PATH))
|
|
58
|
+
|
|
59
|
+
const readUpdateState = async (path: string): Promise<UpdateState | null> => {
|
|
60
|
+
const raw = await readJson(path)
|
|
61
|
+
const checkedAt = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>).checkedAt : undefined
|
|
62
|
+
return typeof checkedAt === 'number' ? { checkedAt } : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stamps the check without following a link at `~/.jackherd` or the file; false when it was refused. */
|
|
66
|
+
const stampUpdateCheck = async (home: string, now: number): Promise<boolean> => {
|
|
67
|
+
try {
|
|
68
|
+
await assertOwnDirectoriesInProject(home)
|
|
69
|
+
await writeFileSafely(
|
|
70
|
+
join(home, TOOL_DIRECTORY),
|
|
71
|
+
UPDATE_STATE_FILE,
|
|
72
|
+
`${JSON.stringify({ checkedAt: now })}\n`,
|
|
73
|
+
FILE_MODE.regular,
|
|
74
|
+
)
|
|
75
|
+
return true
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The update line, when the daily check is due and npm has a newer release. The check is stamped before npm is
|
|
83
|
+
* asked, so a registry that does not answer is asked again tomorrow, not in every session.
|
|
84
|
+
*/
|
|
85
|
+
export const dailyUpdateNotice = async (
|
|
86
|
+
home: string,
|
|
87
|
+
installed: string,
|
|
88
|
+
check: UpdateCheck,
|
|
89
|
+
): Promise<string | null> => {
|
|
90
|
+
const path = join(home, TOOL_DIRECTORY, UPDATE_STATE_FILE)
|
|
91
|
+
const now = check.now()
|
|
92
|
+
if (!updateCheckDue(await readUpdateState(path), now)) return null
|
|
93
|
+
if (!(await stampUpdateCheck(home, now))) return null
|
|
94
|
+
return updateNotice(await check.latestVersion(), installed)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Asks the npm registry for the `latest` tag; null on any failure, after a few seconds at most. */
|
|
98
|
+
const registryLatestVersion = async (): Promise<string | null> => {
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch(REGISTRY_LATEST, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) })
|
|
101
|
+
if (!response.ok) return null
|
|
102
|
+
const { version } = (await response.json()) as { version?: unknown }
|
|
103
|
+
return typeof version === 'string' ? version : null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const defaultUpdateCheck = (): UpdateCheck => ({ now: () => Date.now(), latestVersion: registryLatestVersion })
|