@erclx/aitk 0.8.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/LICENSE +21 -0
- package/README.md +99 -0
- package/claude/.claude-plugin/plugin.json +19 -0
- package/claude/skills/bash-script/SKILL.md +96 -0
- package/claude/skills/bash-script/references/patterns.md +349 -0
- package/claude/skills/ci-workflow/SKILL.md +52 -0
- package/claude/skills/ci-workflow/references/workflows.md +74 -0
- package/claude/skills/claude-address-review/SKILL.md +107 -0
- package/claude/skills/claude-autoship/SKILL.md +137 -0
- package/claude/skills/claude-design-extract/SKILL.md +69 -0
- package/claude/skills/claude-design-propose/SKILL.md +75 -0
- package/claude/skills/claude-diagram/SKILL.md +126 -0
- package/claude/skills/claude-docs/SKILL.md +204 -0
- package/claude/skills/claude-feature/REQUIREMENT.md +35 -0
- package/claude/skills/claude-feature/SKILL.md +148 -0
- package/claude/skills/claude-groundwork/SKILL.md +148 -0
- package/claude/skills/claude-groundwork/references/folder-format.md +107 -0
- package/claude/skills/claude-memory-capture/SKILL.md +80 -0
- package/claude/skills/claude-memory-review/REQUIREMENT.md +37 -0
- package/claude/skills/claude-memory-review/SKILL.md +206 -0
- package/claude/skills/claude-orchestrate/SKILL.md +125 -0
- package/claude/skills/claude-pr-review/SKILL.md +150 -0
- package/claude/skills/claude-review/SKILL.md +136 -0
- package/claude/skills/claude-roadmap/SKILL.md +61 -0
- package/claude/skills/claude-roadmap/references/roadmap.md +52 -0
- package/claude/skills/claude-screencast/SKILL.md +160 -0
- package/claude/skills/claude-seed-sync/SKILL.md +141 -0
- package/claude/skills/claude-slides-draft/SKILL.md +62 -0
- package/claude/skills/claude-standards-audit/REQUIREMENT.md +33 -0
- package/claude/skills/claude-standards-audit/SKILL.md +79 -0
- package/claude/skills/claude-standards-audit/references/branch.md +49 -0
- package/claude/skills/claude-standards-audit/references/pr.md +124 -0
- package/claude/skills/claude-tasks/SKILL.md +123 -0
- package/claude/skills/claude-ui-test/SKILL.md +75 -0
- package/claude/skills/claude-ux-audit/SKILL.md +78 -0
- package/claude/skills/claude-worktree/SKILL.md +79 -0
- package/claude/skills/cli-script/SKILL.md +47 -0
- package/claude/skills/cli-script/references/template.md +43 -0
- package/claude/skills/create-rule/SKILL.md +68 -0
- package/claude/skills/create-skill/REQUIREMENT.md +30 -0
- package/claude/skills/create-skill/SKILL.md +24 -0
- package/claude/skills/create-snippet/SKILL.md +28 -0
- package/claude/skills/create-snippet/references/snippets.md +57 -0
- package/claude/skills/create-standard/SKILL.md +31 -0
- package/claude/skills/create-standard/references/standard.md +52 -0
- package/claude/skills/docs-sync/SKILL.md +87 -0
- package/claude/skills/git-branch/REQUIREMENT.md +38 -0
- package/claude/skills/git-branch/SKILL.md +60 -0
- package/claude/skills/git-branch/references/branch.md +49 -0
- package/claude/skills/git-commit/REQUIREMENT.md +33 -0
- package/claude/skills/git-commit/SKILL.md +51 -0
- package/claude/skills/git-commit/references/commit.md +63 -0
- package/claude/skills/git-followup/REQUIREMENT.md +38 -0
- package/claude/skills/git-followup/SKILL.md +35 -0
- package/claude/skills/git-issue/REQUIREMENT.md +38 -0
- package/claude/skills/git-issue/SKILL.md +69 -0
- package/claude/skills/git-issue/references/issue.md +84 -0
- package/claude/skills/git-pr/REQUIREMENT.md +37 -0
- package/claude/skills/git-pr/SKILL.md +119 -0
- package/claude/skills/git-pr/references/branch.md +49 -0
- package/claude/skills/git-pr/references/pr.md +124 -0
- package/claude/skills/git-ship/REQUIREMENT.md +34 -0
- package/claude/skills/git-ship/SKILL.md +39 -0
- package/claude/skills/git-split/REQUIREMENT.md +38 -0
- package/claude/skills/git-split/SKILL.md +160 -0
- package/claude/skills/git-split/references/branch.md +49 -0
- package/claude/skills/git-split/references/pr.md +124 -0
- package/claude/skills/git-stage/REQUIREMENT.md +36 -0
- package/claude/skills/git-stage/SKILL.md +74 -0
- package/claude/skills/git-stage/references/commit.md +63 -0
- package/claude/skills/git-worktree/REQUIREMENT.md +37 -0
- package/claude/skills/git-worktree/SKILL.md +120 -0
- package/claude/skills/migration-claude-md/SKILL.md +74 -0
- package/claude/skills/migration-context/SKILL.md +93 -0
- package/claude/skills/migration-standards/SKILL.md +75 -0
- package/claude/skills/project-commands/SKILL.md +73 -0
- package/claude/skills/session-resume/SKILL.md +36 -0
- package/claude/skills/setup-gov/SKILL.md +71 -0
- package/claude/skills/setup-indexes/SKILL.md +148 -0
- package/claude/skills/setup-init/SKILL.md +114 -0
- package/claude/skills/setup-plugins/SKILL.md +81 -0
- package/claude/skills/setup-plugins/references/plugin-catalog.md +53 -0
- package/claude/skills/setup-verify/SKILL.md +51 -0
- package/claude/skills/systematic-debugging/SKILL.md +70 -0
- package/claude/skills/toolkit-cli/SKILL.md +38 -0
- package/claude/skills/toolkit-feedback/SKILL.md +80 -0
- package/claude/skills/toolkit-operator/SKILL.md +45 -0
- package/claude/skills/toolkit-triage/SKILL.md +63 -0
- package/claude/skills/youtube-transcripts/SKILL.md +34 -0
- package/docs/agents.md +371 -0
- package/docs/ai-workflow.md +157 -0
- package/docs/index.md +20 -0
- package/docs/operating-model.md +106 -0
- package/docs/target-projects.md +183 -0
- package/docs/visual-design-workflow.md +136 -0
- package/docs/zshrc-aliases.md +65 -0
- package/governance/rules/claude/500-prose.md +12 -0
- package/governance/rules/claude/510-context.md +16 -0
- package/governance/rules/claude/520-wireframes.md +12 -0
- package/governance/rules/claude/530-requirements.md +11 -0
- package/governance/rules/claude/540-architecture.md +11 -0
- package/governance/rules/claude/550-design.md +11 -0
- package/governance/rules/claude/555-tasks.md +12 -0
- package/governance/rules/claude/560-diagrams.md +11 -0
- package/governance/rules/claude/570-skill.md +18 -0
- package/governance/rules/claude/580-readme.md +11 -0
- package/governance/rules/claude/590-rule-authoring.md +12 -0
- package/governance/rules/core/000-constitution.md +30 -0
- package/governance/rules/core/010-testing.md +35 -0
- package/governance/rules/core/020-concurrency.md +22 -0
- package/governance/rules/core/030-error-handling.md +31 -0
- package/governance/rules/core/040-performance.md +20 -0
- package/governance/rules/core/050-logging.md +20 -0
- package/governance/rules/core/060-naming.md +19 -0
- package/governance/rules/core/070-planning.md +13 -0
- package/governance/rules/core/080-config-comments.md +22 -0
- package/governance/rules/framework/200-react.md +51 -0
- package/governance/rules/framework/210-astro.md +41 -0
- package/governance/rules/framework/220-fastapi.md +43 -0
- package/governance/rules/framework/230-nextjs.md +48 -0
- package/governance/rules/framework/250-tailwind.md +32 -0
- package/governance/rules/framework/260-shadcn.md +34 -0
- package/governance/rules/lang/100-typescript.md +40 -0
- package/governance/rules/lang/110-python.md +42 -0
- package/governance/rules/lib/300-testing-ts.md +38 -0
- package/governance/rules/lib/310-zod.md +25 -0
- package/governance/rules/lib/320-tanstack-query.md +32 -0
- package/governance/rules/lib/330-testing-py.md +44 -0
- package/governance/rules/lib/340-pydantic.md +38 -0
- package/governance/rules/lib/350-security-web.md +31 -0
- package/governance/rules/ui/400-ui.md +34 -0
- package/governance/rules/ui/410-a11y.md +44 -0
- package/governance/rules/ui/420-forms.md +34 -0
- package/governance/rules/ui/430-ux-completeness.md +62 -0
- package/governance/stacks/astro.toml +2 -0
- package/governance/stacks/base.toml +2 -0
- package/governance/stacks/node.toml +2 -0
- package/governance/stacks/python-fastapi.toml +2 -0
- package/governance/stacks/python.toml +2 -0
- package/governance/stacks/react.toml +2 -0
- package/package.json +67 -0
- package/scripts/config.sh +11 -0
- package/scripts/core/bootstrap.sh +81 -0
- package/scripts/core/check-plugin-boundary.sh +45 -0
- package/scripts/core/check-skill-paths.sh +25 -0
- package/scripts/core/clean.sh +36 -0
- package/scripts/core/install-check.sh +93 -0
- package/scripts/core/regen-claude-copies.sh +26 -0
- package/scripts/core/regen-indexes.sh +10 -0
- package/scripts/core/regen-skill-references.sh +27 -0
- package/scripts/core/snapshot.sh +132 -0
- package/scripts/core/update.sh +35 -0
- package/scripts/core/verify.sh +201 -0
- package/scripts/docs/list.sh +129 -0
- package/scripts/gov/list.sh +234 -0
- package/scripts/lib/frontmatter.sh +30 -0
- package/scripts/lib/gov.sh +14 -0
- package/scripts/lib/sandbox-fixtures.sh +109 -0
- package/scripts/lib/sandbox-git.sh +73 -0
- package/scripts/lib/tooling.sh +24 -0
- package/scripts/lib/ui.sh +173 -0
- package/scripts/lib/worktree.sh +20 -0
- package/scripts/manage-sandbox.sh +453 -0
- package/scripts/snippets/create.sh +156 -0
- package/scripts/standards/list.sh +91 -0
- package/scripts/tooling/create.sh +109 -0
- package/scripts/tooling/ref.sh +141 -0
- package/scripts/tooling/verify.sh +179 -0
- package/snippets/claude/decision-memo.md +39 -0
- package/snippets/claude/feature-recap.md +19 -0
- package/snippets/claude/figma-steps.md +18 -0
- package/snippets/claude/orchestrator-resume.md +23 -0
- package/snippets/claude/orchestrator-sweep.md +10 -0
- package/snippets/compact-summary.md +5 -0
- package/snippets/create-snippet.md +7 -0
- package/snippets/decision-help.md +6 -0
- package/snippets/meta-prompt.md +14 -0
- package/snippets/research-prompt.md +7 -0
- package/snippets/session-notes.md +11 -0
- package/snippets/snippets.toml +6 -0
- package/snippets/step-by-step.md +10 -0
- package/snippets/web-research.md +21 -0
- package/src/claude/gitignore.ts +51 -0
- package/src/claude/seeds-list.ts +47 -0
- package/src/claude/seeds.ts +124 -0
- package/src/claude/settings.ts +151 -0
- package/src/cli-run.ts +43 -0
- package/src/cli.ts +111 -0
- package/src/commands/claude.ts +349 -0
- package/src/commands/design.ts +39 -0
- package/src/commands/docs.ts +59 -0
- package/src/commands/feedback-format.ts +23 -0
- package/src/commands/feedback.ts +103 -0
- package/src/commands/gov.ts +268 -0
- package/src/commands/indexes.ts +182 -0
- package/src/commands/init.ts +117 -0
- package/src/commands/pass-through.ts +39 -0
- package/src/commands/sandbox.ts +315 -0
- package/src/commands/slides.ts +106 -0
- package/src/commands/snippets.ts +236 -0
- package/src/commands/standards.ts +89 -0
- package/src/commands/sync.ts +211 -0
- package/src/commands/tasks.ts +185 -0
- package/src/commands/tooling.ts +432 -0
- package/src/commands/transcripts.ts +48 -0
- package/src/commands/wiki.ts +101 -0
- package/src/copy.ts +30 -0
- package/src/design/parse.ts +79 -0
- package/src/design/render.ts +170 -0
- package/src/docs/read.ts +59 -0
- package/src/exec.ts +17 -0
- package/src/frontmatter.ts +13 -0
- package/src/github-format.ts +13 -0
- package/src/github.ts +20 -0
- package/src/gov/adapter.ts +73 -0
- package/src/gov/install.ts +103 -0
- package/src/gov/payload.ts +39 -0
- package/src/gov/stacks.ts +124 -0
- package/src/indexes/frontmatter.ts +46 -0
- package/src/indexes/regen.ts +84 -0
- package/src/indexes/render.ts +181 -0
- package/src/indexes/walk.ts +111 -0
- package/src/init/flags.ts +66 -0
- package/src/init/plan.ts +128 -0
- package/src/init/run.ts +46 -0
- package/src/init/steps.ts +91 -0
- package/src/sandbox/coverage.ts +115 -0
- package/src/sandbox/expect.ts +412 -0
- package/src/slides/layouts.ts +391 -0
- package/src/slides/open.ts +18 -0
- package/src/slides/parse.ts +84 -0
- package/src/slides/render.ts +88 -0
- package/src/slides/styles.ts +44 -0
- package/src/snippets/adapter.ts +34 -0
- package/src/snippets/categories.ts +59 -0
- package/src/snippets/install.ts +125 -0
- package/src/snippets/list.ts +32 -0
- package/src/snippets/presets.ts +50 -0
- package/src/standards/adapter.ts +51 -0
- package/src/standards/index-refresh.ts +44 -0
- package/src/standards/install.ts +52 -0
- package/src/sync/check.ts +269 -0
- package/src/sync/engine.ts +416 -0
- package/src/sync/git.ts +204 -0
- package/src/sync/stamp.ts +202 -0
- package/src/sync/target.ts +79 -0
- package/src/sync/workflow.ts +201 -0
- package/src/target.ts +43 -0
- package/src/tasks/archive.ts +304 -0
- package/src/tooling/gitignore.ts +122 -0
- package/src/tooling/inject.ts +214 -0
- package/src/tooling/list.ts +39 -0
- package/src/tooling/manifest.ts +178 -0
- package/src/tooling/package.ts +166 -0
- package/src/tooling/scan.ts +186 -0
- package/src/transcripts/fetch.ts +156 -0
- package/src/transcripts/metadata.ts +54 -0
- package/src/transcripts/vtt.ts +114 -0
- package/src/ui.ts +187 -0
- package/src/wiki/init.ts +85 -0
- package/standards/architecture.md +41 -0
- package/standards/bundled/branch.md +49 -0
- package/standards/bundled/commit.md +63 -0
- package/standards/bundled/issue.md +84 -0
- package/standards/bundled/pr.md +124 -0
- package/standards/bundled/roadmap.md +52 -0
- package/standards/bundled/snippets.md +57 -0
- package/standards/bundled/standard.md +52 -0
- package/standards/context.md +132 -0
- package/standards/design.md +31 -0
- package/standards/diagrams.md +55 -0
- package/standards/index.md +21 -0
- package/standards/prose.md +122 -0
- package/standards/readme.md +163 -0
- package/standards/requirements.md +61 -0
- package/standards/rule.md +79 -0
- package/standards/skill.md +257 -0
- package/standards/tasks.md +140 -0
- package/standards/versioning.md +47 -0
- package/standards/wireframes.md +67 -0
- package/tooling/astro/configs/astro.config.mjs +17 -0
- package/tooling/astro/configs/eslint.config.js +79 -0
- package/tooling/astro/configs/playwright.config.ts +25 -0
- package/tooling/astro/configs/tsconfig.json +12 -0
- package/tooling/astro/configs/vitest.config.ts +22 -0
- package/tooling/astro/manifest.toml +32 -0
- package/tooling/astro/reference.md +60 -0
- package/tooling/base/configs/.editorconfig +5 -0
- package/tooling/base/configs/.github/pull_request_template.md +18 -0
- package/tooling/base/configs/.github/workflows/verify.yml +35 -0
- package/tooling/base/configs/.husky/commit-msg +1 -0
- package/tooling/base/configs/.husky/post-merge +61 -0
- package/tooling/base/configs/.husky/post-rewrite +21 -0
- package/tooling/base/configs/.husky/pre-commit +1 -0
- package/tooling/base/configs/.husky/pre-push +1 -0
- package/tooling/base/configs/.prettierrc +12 -0
- package/tooling/base/configs/.shellcheckrc +1 -0
- package/tooling/base/configs/.vscode/extensions.json +9 -0
- package/tooling/base/configs/.vscode/settings.json +3 -0
- package/tooling/base/configs/commitlint.config.js +11 -0
- package/tooling/base/configs/scripts/clean.sh +45 -0
- package/tooling/base/configs/scripts/update.sh +49 -0
- package/tooling/base/configs/scripts/verify.sh +64 -0
- package/tooling/base/manifest.toml +32 -0
- package/tooling/base/reference.md +114 -0
- package/tooling/base/seeds/.claude/context/ci.md +27 -0
- package/tooling/base/seeds/.claude/context/development.md +32 -0
- package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +19 -0
- package/tooling/base/seeds/.lintstagedrc +8 -0
- package/tooling/base/seeds/.prettierignore +0 -0
- package/tooling/base/seeds/cspell.json +20 -0
- package/tooling/claude/manifest.toml +11 -0
- package/tooling/claude/reference.md +62 -0
- package/tooling/claude/seeds/.claude/ARCHITECTURE.md +13 -0
- package/tooling/claude/seeds/.claude/DESIGN.md +62 -0
- package/tooling/claude/seeds/.claude/REQUIREMENTS.md +18 -0
- package/tooling/claude/seeds/.claude/context/index.md +8 -0
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +43 -0
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +45 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +61 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +60 -0
- package/tooling/claude/seeds/.claude/settings.json +39 -0
- package/tooling/claude/seeds/.claude/tasks/index.md +8 -0
- package/tooling/claude/seeds/.claude/wireframes/index.md +8 -0
- package/tooling/claude/seeds/CLAUDE.md +87 -0
- package/tooling/claude/user/settings.template.json +10 -0
- package/tooling/claude/user/statusline-command.sh +53 -0
- package/tooling/python/configs/.coveragerc +14 -0
- package/tooling/python/configs/.python-version +1 -0
- package/tooling/python/configs/mypy.ini +6 -0
- package/tooling/python/configs/pytest.ini +4 -0
- package/tooling/python/configs/ruff.toml +15 -0
- package/tooling/python/configs/scripts/verify.sh +77 -0
- package/tooling/python/manifest.toml +16 -0
- package/tooling/python/reference.md +66 -0
- package/tooling/python/seeds/.cspell/tech-stack.txt +19 -0
- package/tooling/python/seeds/tests/test_smoke.py +2 -0
- package/tooling/vite-react/configs/playwright.config.ts +25 -0
- package/tooling/vite-react/configs/tsconfig.json +35 -0
- package/tooling/vite-react/configs/vite.config.ts +14 -0
- package/tooling/vite-react/configs/vitest.config.ts +26 -0
- package/tooling/vite-react/manifest.toml +21 -0
- package/tooling/vite-react/reference.md +55 -0
- package/tooling/vite-react/seeds/.cspell/project-terms.txt +1 -0
- package/tooling/vite-react/seeds/.cspell/tech-stack.txt +1 -0
- package/tooling/web/configs/.github/workflows/verify.yml +134 -0
- package/tooling/web/configs/.vscode/extensions.json +13 -0
- package/tooling/web/configs/.vscode/settings.json +10 -0
- package/tooling/web/configs/e2e/home.spec.ts +6 -0
- package/tooling/web/configs/e2e/screenshot.ts +41 -0
- package/tooling/web/configs/eslint.config.js +82 -0
- package/tooling/web/configs/scripts/screenshot.sh +28 -0
- package/tooling/web/configs/scripts/verify.sh +80 -0
- package/tooling/web/configs/src/test/setup.ts +8 -0
- package/tooling/web/manifest.toml +57 -0
- package/tooling/web/reference.md +95 -0
- package/tooling/web/seeds/.cspell/tech-stack.txt +18 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { rm } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
|
+
import { copyPreservingMode } from '@/copy'
|
|
5
|
+
import {
|
|
6
|
+
type DomainHashes,
|
|
7
|
+
hashFile,
|
|
8
|
+
readStamp,
|
|
9
|
+
type StampSource,
|
|
10
|
+
stampedHashes,
|
|
11
|
+
toStampKey,
|
|
12
|
+
writeStamp,
|
|
13
|
+
} from '@/sync/stamp'
|
|
14
|
+
import { isDirectory } from '@/target'
|
|
15
|
+
import {
|
|
16
|
+
intro,
|
|
17
|
+
isNonInteractive,
|
|
18
|
+
logAdd,
|
|
19
|
+
logError,
|
|
20
|
+
logInfo,
|
|
21
|
+
logStep,
|
|
22
|
+
logWarn,
|
|
23
|
+
outro,
|
|
24
|
+
select,
|
|
25
|
+
} from '@/ui'
|
|
26
|
+
|
|
27
|
+
const GREEN = '\x1b[0;32m'
|
|
28
|
+
const NC = '\x1b[0m'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One installed file in three path flavours: absolute, relative to the
|
|
32
|
+
* domain's installed root, and relative to the target. Adapters match on
|
|
33
|
+
* `relToRoot` and every log line prints `rel`.
|
|
34
|
+
*/
|
|
35
|
+
export interface InstalledFile {
|
|
36
|
+
readonly path: string
|
|
37
|
+
readonly relToRoot: string
|
|
38
|
+
readonly rel: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RetiredSurface {
|
|
42
|
+
readonly path: string
|
|
43
|
+
readonly rel: string
|
|
44
|
+
readonly notice: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SyncChange =
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'copy'
|
|
50
|
+
readonly source: string
|
|
51
|
+
readonly dest: string
|
|
52
|
+
readonly rel: string
|
|
53
|
+
}
|
|
54
|
+
| { readonly kind: 'delete'; readonly dest: string; readonly rel: string }
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How an installed file compares to its toolkit source, and when it differs,
|
|
58
|
+
* who moved it. `stale` and `customized` need the stamp to tell apart, so
|
|
59
|
+
* `drifted` stays the verdict for a difference no stamp covers.
|
|
60
|
+
*
|
|
61
|
+
* `orphaned` and `stranded` both mean the walk found no source, and they are
|
|
62
|
+
* separate because they need opposite treatment. A project-authored file is
|
|
63
|
+
* orphaned and stays that way forever. A stamped file the toolkit no longer
|
|
64
|
+
* installs to is stranded, which is a relocation waiting on a decision.
|
|
65
|
+
*/
|
|
66
|
+
export type EntryState =
|
|
67
|
+
| 'matching'
|
|
68
|
+
| 'stale'
|
|
69
|
+
| 'customized'
|
|
70
|
+
| 'drifted'
|
|
71
|
+
| 'orphaned'
|
|
72
|
+
| 'stranded'
|
|
73
|
+
|
|
74
|
+
export interface ScanEntry {
|
|
75
|
+
readonly state: EntryState
|
|
76
|
+
readonly rel: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SyncPlan {
|
|
80
|
+
readonly entries: readonly ScanEntry[]
|
|
81
|
+
readonly retired: readonly RetiredSurface[]
|
|
82
|
+
readonly changes: readonly SyncChange[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a headless run does once the plan has changes. Domains whose files the
|
|
87
|
+
* toolkit owns apply them. Domains whose files a project is expected to edit
|
|
88
|
+
* refuse, because an unattended overwrite of a customized file is data loss
|
|
89
|
+
* with no prompt in front of it.
|
|
90
|
+
*/
|
|
91
|
+
export type NonInteractivePolicy =
|
|
92
|
+
| { readonly kind: 'apply' }
|
|
93
|
+
| {
|
|
94
|
+
readonly kind: 'refuse'
|
|
95
|
+
readonly message: string
|
|
96
|
+
readonly hint: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The two holes every domain sync leaves open: where a destination file's
|
|
101
|
+
* source lives, and what counts as a change beyond a plain content diff.
|
|
102
|
+
* Everything else in a sync is identical across gov, snippets, and standards,
|
|
103
|
+
* so it lives in the engine.
|
|
104
|
+
*
|
|
105
|
+
* The optional members exist because one adapter needed each of them. Leaving
|
|
106
|
+
* all three unset reproduces the behavior gov and snippets already had.
|
|
107
|
+
*/
|
|
108
|
+
export interface SyncAdapter {
|
|
109
|
+
readonly banner: string
|
|
110
|
+
/** Rendered as `Scanning <label>`. */
|
|
111
|
+
readonly label: string
|
|
112
|
+
readonly missingMessage: string
|
|
113
|
+
/** Noun for the completion count, e.g. `changes`. */
|
|
114
|
+
readonly unit: string
|
|
115
|
+
installedRoot(target: string): string
|
|
116
|
+
locateSource(file: InstalledFile): string | undefined
|
|
117
|
+
/** Surfaces the file walk cannot see, such as a retired doc to delete. */
|
|
118
|
+
collectRetired?(target: string): RetiredSurface[]
|
|
119
|
+
/** Dropped from the walk, so neither matching nor orphaned. */
|
|
120
|
+
isExcluded?(file: InstalledFile): boolean
|
|
121
|
+
/** Defaults to applying. */
|
|
122
|
+
readonly nonInteractive?: NonInteractivePolicy
|
|
123
|
+
/** Runs on a completed sync, including one with no changes. */
|
|
124
|
+
onComplete?(target: string): Promise<void>
|
|
125
|
+
/** Where this domain's hashes are stamped. Unset domains go unstamped. */
|
|
126
|
+
readonly stamp?: StampSource
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Lists installed markdown, dotfiles included. `Bun.Glob` skips entries
|
|
131
|
+
* beginning with a dot unless `dot` is set, and every domain installs under
|
|
132
|
+
* `.claude/`, so a nested dot-directory would silently drop out of the walk.
|
|
133
|
+
*/
|
|
134
|
+
export function listInstalled(root: string, target: string): InstalledFile[] {
|
|
135
|
+
if (!existsSync(root)) return []
|
|
136
|
+
|
|
137
|
+
return [
|
|
138
|
+
...new Bun.Glob('**/*.md').scanSync({
|
|
139
|
+
cwd: root,
|
|
140
|
+
onlyFiles: true,
|
|
141
|
+
dot: true,
|
|
142
|
+
}),
|
|
143
|
+
]
|
|
144
|
+
.sort()
|
|
145
|
+
.map((relToRoot) => {
|
|
146
|
+
const path = resolve(root, relToRoot)
|
|
147
|
+
return { path, relToRoot, rel: relative(target, path) }
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Classifies every installed file against its source without writing anything.
|
|
153
|
+
* A file with no source is left alone rather than deleted, which is what keeps
|
|
154
|
+
* project-authored rules alive across a sync.
|
|
155
|
+
*/
|
|
156
|
+
export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
157
|
+
const entries: ScanEntry[] = []
|
|
158
|
+
const changes: SyncChange[] = []
|
|
159
|
+
|
|
160
|
+
const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
|
|
161
|
+
const walked = new Set<string>()
|
|
162
|
+
|
|
163
|
+
for (const file of listInstalled(adapter.installedRoot(target), target)) {
|
|
164
|
+
if (adapter.isExcluded?.(file) === true) continue
|
|
165
|
+
walked.add(toStampKey(file.rel))
|
|
166
|
+
|
|
167
|
+
const source = adapter.locateSource(file)
|
|
168
|
+
|
|
169
|
+
if (source === undefined || !existsSync(source)) {
|
|
170
|
+
entries.push({ state: 'orphaned', rel: file.rel })
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (sameContent(source, file.path)) {
|
|
175
|
+
entries.push({ state: 'matching', rel: file.rel })
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
entries.push({ state: attribute(hashes, file), rel: file.rel })
|
|
180
|
+
changes.push({
|
|
181
|
+
kind: 'copy',
|
|
182
|
+
source,
|
|
183
|
+
dest: file.path,
|
|
184
|
+
rel: file.rel,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
entries.push(...strandedByRelocation(target, hashes, walked))
|
|
189
|
+
|
|
190
|
+
const retired = adapter.collectRetired?.(target) ?? []
|
|
191
|
+
for (const surface of retired) {
|
|
192
|
+
changes.push({ kind: 'delete', dest: surface.path, rel: surface.rel })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { entries, retired, changes }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function applyChanges(
|
|
199
|
+
changes: readonly SyncChange[],
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
logStep('Applying changes')
|
|
202
|
+
|
|
203
|
+
for (const change of changes) {
|
|
204
|
+
if (change.kind === 'copy') {
|
|
205
|
+
await copyPreservingMode(change.source, change.dest)
|
|
206
|
+
logAdd(change.rel)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await rm(change.dest, { force: true })
|
|
211
|
+
logWarn(`removed ${change.rel}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface SyncRunOptions {
|
|
216
|
+
/** Path the sync refuses to run against, normally the toolkit root. */
|
|
217
|
+
readonly protectedRoot: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Runs one domain sync end to end and returns the process exit code. Callers
|
|
222
|
+
* register a command, build an adapter, and hand both to this function.
|
|
223
|
+
*/
|
|
224
|
+
export async function runDomainSync(
|
|
225
|
+
adapter: SyncAdapter,
|
|
226
|
+
target: string,
|
|
227
|
+
options: SyncRunOptions,
|
|
228
|
+
): Promise<number> {
|
|
229
|
+
intro(adapter.banner)
|
|
230
|
+
|
|
231
|
+
const resolved = resolve(target)
|
|
232
|
+
|
|
233
|
+
if (!isDirectory(resolved)) {
|
|
234
|
+
logError(`Target directory not found: ${target}`)
|
|
235
|
+
outro()
|
|
236
|
+
return 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (resolved === options.protectedRoot) {
|
|
240
|
+
logError(
|
|
241
|
+
'Cannot run against toolkit root. Files here are the source of truth.',
|
|
242
|
+
)
|
|
243
|
+
outro()
|
|
244
|
+
return 1
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const plan = planSync(adapter, resolved)
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
!existsSync(adapter.installedRoot(resolved)) &&
|
|
251
|
+
plan.retired.length === 0
|
|
252
|
+
) {
|
|
253
|
+
logWarn(adapter.missingMessage)
|
|
254
|
+
outro()
|
|
255
|
+
return 0
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
report(adapter, plan)
|
|
259
|
+
|
|
260
|
+
const count = plan.changes.length
|
|
261
|
+
if (count === 0) {
|
|
262
|
+
await adapter.onComplete?.(resolved)
|
|
263
|
+
await recordStamp(adapter, resolved, new Date())
|
|
264
|
+
outro()
|
|
265
|
+
process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
|
|
266
|
+
return 0
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const policy = adapter.nonInteractive ?? { kind: 'apply' }
|
|
270
|
+
if (
|
|
271
|
+
policy.kind === 'refuse' &&
|
|
272
|
+
isNonInteractive() &&
|
|
273
|
+
hasUnattributedDrift(plan)
|
|
274
|
+
) {
|
|
275
|
+
logWarn(policy.message)
|
|
276
|
+
logInfo(policy.hint)
|
|
277
|
+
outro()
|
|
278
|
+
return 0
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const shouldApply = await select({
|
|
282
|
+
message: `Apply ${count} changes?`,
|
|
283
|
+
options: [
|
|
284
|
+
{ value: true, label: 'Apply all' },
|
|
285
|
+
{ value: false, label: 'Cancel' },
|
|
286
|
+
],
|
|
287
|
+
nonInteractiveDefault: true,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
if (!shouldApply) {
|
|
291
|
+
logWarn('Sync cancelled')
|
|
292
|
+
outro()
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await applyChanges(plan.changes)
|
|
297
|
+
await adapter.onComplete?.(resolved)
|
|
298
|
+
await recordStamp(adapter, resolved, new Date())
|
|
299
|
+
|
|
300
|
+
outro()
|
|
301
|
+
process.stderr.write(
|
|
302
|
+
`${GREEN}✓ Sync complete${NC} \x1b[0;90m(${count} ${adapter.unit})${NC}\n`,
|
|
303
|
+
)
|
|
304
|
+
return 0
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reports in walk order rather than grouped by state, so a long rule tree
|
|
309
|
+
* reads as a directory listing with drift called out in place.
|
|
310
|
+
*/
|
|
311
|
+
function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
312
|
+
logStep(`Scanning ${adapter.label}`)
|
|
313
|
+
|
|
314
|
+
for (const entry of plan.entries) {
|
|
315
|
+
if (entry.state === 'matching') logInfo(entry.rel)
|
|
316
|
+
else if (entry.state === 'drifted') logWarn(entry.rel)
|
|
317
|
+
else if (entry.state === 'stale') logWarn(`${entry.rel} (toolkit updated)`)
|
|
318
|
+
else if (entry.state === 'customized')
|
|
319
|
+
logWarn(`${entry.rel} (locally customized)`)
|
|
320
|
+
else if (entry.state === 'stranded')
|
|
321
|
+
logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
|
|
322
|
+
else logWarn(`${entry.rel} (not in toolkit source, skipping)`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const surface of plan.retired) {
|
|
326
|
+
logWarn(surface.notice)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sameContent(left: string, right: string): boolean {
|
|
331
|
+
return readFileSync(left).equals(readFileSync(right))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Splits a difference by cause. Matching the stamp means the file is untouched
|
|
336
|
+
* since install and the toolkit is what moved, so the update is mechanical.
|
|
337
|
+
* Anything else is a local edit, and an uncovered file stays unattributed.
|
|
338
|
+
*/
|
|
339
|
+
function attribute(hashes: DomainHashes, file: InstalledFile): EntryState {
|
|
340
|
+
const stamped = hashes[toStampKey(file.rel)]
|
|
341
|
+
if (stamped === undefined) return 'drifted'
|
|
342
|
+
|
|
343
|
+
return stamped === hashFile(file.path) ? 'stale' : 'customized'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Stamped paths the walk never reached, because the toolkit installs to a root
|
|
348
|
+
* it no longer uses. Reporting them is what makes a relocation visible instead
|
|
349
|
+
* of silent. They are left alone, and queue no change.
|
|
350
|
+
*
|
|
351
|
+
* A key escaping the target is dropped rather than reported, so a hand-edited
|
|
352
|
+
* stamp cannot make the report name paths outside the project.
|
|
353
|
+
*/
|
|
354
|
+
function strandedByRelocation(
|
|
355
|
+
target: string,
|
|
356
|
+
hashes: DomainHashes,
|
|
357
|
+
walked: ReadonlySet<string>,
|
|
358
|
+
): ScanEntry[] {
|
|
359
|
+
const entries: ScanEntry[] = []
|
|
360
|
+
|
|
361
|
+
for (const key of Object.keys(hashes).sort()) {
|
|
362
|
+
if (walked.has(key)) continue
|
|
363
|
+
|
|
364
|
+
const path = resolve(target, key)
|
|
365
|
+
if (!isInside(target, path) || !existsSync(path)) continue
|
|
366
|
+
|
|
367
|
+
entries.push({ state: 'stranded', rel: relative(target, path) })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return entries
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isInside(target: string, path: string): boolean {
|
|
374
|
+
const rel = relative(target, path)
|
|
375
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Drift a refusing domain must not touch unattended. A file proven to match
|
|
380
|
+
* what was installed carries no local edit to lose, so it does not count.
|
|
381
|
+
*/
|
|
382
|
+
function hasUnattributedDrift(plan: SyncPlan): boolean {
|
|
383
|
+
return plan.entries.some(
|
|
384
|
+
(entry) => entry.state === 'customized' || entry.state === 'drifted',
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Records what the toolkit placed, after the copies land, so a partial apply
|
|
390
|
+
* that throws leaves the previous stamp rather than a claim the target does not
|
|
391
|
+
* meet. Files with no source are project-authored and stay out.
|
|
392
|
+
*
|
|
393
|
+
* Reads the installed tree rather than the caller's file list, so a partial
|
|
394
|
+
* install still stamps the domain's whole installed set.
|
|
395
|
+
*/
|
|
396
|
+
export async function recordStamp(
|
|
397
|
+
adapter: SyncAdapter,
|
|
398
|
+
target: string,
|
|
399
|
+
now: Date,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
const stampSource = adapter.stamp
|
|
402
|
+
if (stampSource === undefined) return
|
|
403
|
+
|
|
404
|
+
const hashes: Record<string, string> = {}
|
|
405
|
+
|
|
406
|
+
for (const file of listInstalled(adapter.installedRoot(target), target)) {
|
|
407
|
+
if (adapter.isExcluded?.(file) === true) continue
|
|
408
|
+
|
|
409
|
+
const source = adapter.locateSource(file)
|
|
410
|
+
if (source === undefined || !existsSync(source)) continue
|
|
411
|
+
|
|
412
|
+
hashes[toStampKey(file.rel)] = hashFile(file.path)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await writeStamp(target, stampSource, hashes, now)
|
|
416
|
+
}
|
package/src/sync/git.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import type { SyncDomain } from '@/sync/target'
|
|
4
|
+
import { pipeOutput } from '@/ui'
|
|
5
|
+
|
|
6
|
+
const FILE_LIST_MAX = 3
|
|
7
|
+
|
|
8
|
+
export type DomainVerb = 'Add' | 'Remove' | 'Update'
|
|
9
|
+
|
|
10
|
+
export interface DomainChange {
|
|
11
|
+
readonly domain: SyncDomain
|
|
12
|
+
readonly verb: DomainVerb
|
|
13
|
+
readonly names: readonly string[]
|
|
14
|
+
readonly paths: readonly string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class GitCommandError extends Error {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pulls the path out of each porcelain line by taking the last whitespace
|
|
21
|
+
* separated field, which is what the bash `awk '{print $NF}'` did. A rename
|
|
22
|
+
* reports `old -> new` and so yields the destination, and a sync never
|
|
23
|
+
* produces one because the workflow refuses a dirty tree before it starts.
|
|
24
|
+
*/
|
|
25
|
+
export function changedPaths(status: string): string[] {
|
|
26
|
+
const paths: string[] = []
|
|
27
|
+
|
|
28
|
+
for (const line of status.split('\n')) {
|
|
29
|
+
const trimmed = line.trim()
|
|
30
|
+
if (trimmed === '') continue
|
|
31
|
+
const fields = trimmed.split(/\s+/)
|
|
32
|
+
paths.push(fields[fields.length - 1])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return paths
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function changedNames(status: string): string[] {
|
|
39
|
+
const names = changedPaths(status).map((path) => basename(path))
|
|
40
|
+
return [...new Set(names)].sort()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classifies a domain's porcelain output as one verb for the pull request body.
|
|
45
|
+
* Only an all-untracked change reads as `Add` and only an all-deleted one as
|
|
46
|
+
* `Remove`, so any mix falls through to `Update`.
|
|
47
|
+
*/
|
|
48
|
+
export function classifyStatus(status: string): DomainVerb {
|
|
49
|
+
let hasModify = false
|
|
50
|
+
let hasNew = false
|
|
51
|
+
let hasDelete = false
|
|
52
|
+
|
|
53
|
+
for (const line of status.split('\n')) {
|
|
54
|
+
if (line === '') continue
|
|
55
|
+
const code = line.slice(0, 2)
|
|
56
|
+
|
|
57
|
+
if (code === '??') hasNew = true
|
|
58
|
+
else if (code === ' D' || code === 'D ' || code === 'DD') hasDelete = true
|
|
59
|
+
else hasModify = true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!hasModify && !hasDelete && hasNew) return 'Add'
|
|
63
|
+
if (!hasModify && !hasNew && hasDelete) return 'Remove'
|
|
64
|
+
return 'Update'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatFileList(files: readonly string[]): string {
|
|
68
|
+
if (files.length === 0) return ''
|
|
69
|
+
if (files.length <= FILE_LIST_MAX) return files.join(', ')
|
|
70
|
+
|
|
71
|
+
const head = files.slice(0, FILE_LIST_MAX).join(', ')
|
|
72
|
+
return `${head}, and ${files.length - FILE_LIST_MAX} more`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Names the branch at minute resolution, so two syncs inside the same minute
|
|
77
|
+
* collide by design and the collision check turns the second one away.
|
|
78
|
+
*/
|
|
79
|
+
export function syncBranchName(now: Date): string {
|
|
80
|
+
const pad = (value: number): string => String(value).padStart(2, '0')
|
|
81
|
+
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
|
82
|
+
const minute = `${pad(now.getHours())}${pad(now.getMinutes())}`
|
|
83
|
+
|
|
84
|
+
return `chore/aitk-sync-${day}-${minute}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function commitMessage(domains: readonly SyncDomain[]): string {
|
|
88
|
+
return `chore(sync): update ${domains.join(', ')} from toolkit`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function pullRequestBody(changes: readonly DomainChange[]): string {
|
|
92
|
+
const domains = changes.map((change) => change.domain).join(', ')
|
|
93
|
+
const lines = [
|
|
94
|
+
'## Summary',
|
|
95
|
+
'',
|
|
96
|
+
`Sync ${domains} from toolkit.`,
|
|
97
|
+
'',
|
|
98
|
+
'## Key Changes',
|
|
99
|
+
'',
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
for (const change of changes) {
|
|
103
|
+
lines.push(
|
|
104
|
+
`- ${change.verb} \`${change.domain}/\` ${formatFileList(change.names)}.`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The seam every git mutation goes through, so the workflow can be driven by a
|
|
113
|
+
* fake in tests without a repository behind it. Reads swallow failure the way
|
|
114
|
+
* the bash `2>/dev/null || true` did. Mutations throw, because a failed commit
|
|
115
|
+
* or push must not be reported as a completed sync.
|
|
116
|
+
*/
|
|
117
|
+
export interface GitRunner {
|
|
118
|
+
status(paths: readonly string[]): Promise<string>
|
|
119
|
+
currentBranch(): Promise<string>
|
|
120
|
+
branchExists(name: string): Promise<boolean>
|
|
121
|
+
remoteBranchExists(name: string): Promise<boolean>
|
|
122
|
+
createBranch(name: string): Promise<void>
|
|
123
|
+
stage(paths: readonly string[]): Promise<void>
|
|
124
|
+
commit(message: string): Promise<void>
|
|
125
|
+
push(branch: string): Promise<void>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PullRequestOpener {
|
|
129
|
+
create(title: string, body: string): Promise<string>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createGitRunner(target: string): GitRunner {
|
|
133
|
+
return {
|
|
134
|
+
status: (paths) => read(target, ['status', '--short', '--', ...paths]),
|
|
135
|
+
currentBranch: () => read(target, ['symbolic-ref', '--short', 'HEAD']),
|
|
136
|
+
branchExists: async (name) =>
|
|
137
|
+
(await read(target, ['branch', '--list', name])) !== '',
|
|
138
|
+
remoteBranchExists: async (name) =>
|
|
139
|
+
(await read(target, ['ls-remote', '--heads', 'origin', name])) !== '',
|
|
140
|
+
createBranch: async (name) => {
|
|
141
|
+
await mutate(target, ['checkout', '-b', name])
|
|
142
|
+
},
|
|
143
|
+
stage: async (paths) => {
|
|
144
|
+
await mutate(target, ['add', '--', ...paths])
|
|
145
|
+
},
|
|
146
|
+
commit: async (message) => {
|
|
147
|
+
await mutate(target, ['commit', '-m', message])
|
|
148
|
+
},
|
|
149
|
+
push: async (branch) => {
|
|
150
|
+
await mutate(target, ['push', '-u', 'origin', branch])
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Passes the body on stdin rather than through a temp file. The bash wrote one
|
|
157
|
+
* with `mktemp` and removed it afterwards, which left the file behind whenever
|
|
158
|
+
* `gh` failed.
|
|
159
|
+
*/
|
|
160
|
+
export function createPullRequestOpener(target: string): PullRequestOpener {
|
|
161
|
+
return {
|
|
162
|
+
create: async (title, body) => {
|
|
163
|
+
const result = await execa(
|
|
164
|
+
'gh',
|
|
165
|
+
['pr', 'create', '--title', title, '--body-file', '-'],
|
|
166
|
+
{ cwd: target, input: body, reject: false },
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (result.exitCode !== 0) {
|
|
170
|
+
throw new GitCommandError(
|
|
171
|
+
`gh pr create failed: ${result.stderr || result.stdout}`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result.stdout.trim()
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function hasGh(): Promise<boolean> {
|
|
181
|
+
const result = await execa('command', ['-v', 'gh'], {
|
|
182
|
+
reject: false,
|
|
183
|
+
shell: true,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return result.exitCode === 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function read(target: string, args: readonly string[]): Promise<string> {
|
|
190
|
+
const result = await execa('git', ['-C', target, ...args], { reject: false })
|
|
191
|
+
return result.exitCode === 0 ? result.stdout : ''
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function mutate(target: string, args: readonly string[]): Promise<void> {
|
|
195
|
+
const result = await execa('git', ['-C', target, ...args], { reject: false })
|
|
196
|
+
|
|
197
|
+
if (result.exitCode !== 0) {
|
|
198
|
+
throw new GitCommandError(
|
|
199
|
+
`git ${args[0]} failed: ${result.stderr || result.stdout}`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (result.stdout !== '') pipeOutput(result.stdout)
|
|
204
|
+
}
|