@joshuafolkken/kit 1.978.0 → 1.998.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.
Files changed (138) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +6 -5
  2. package/.claude/skills/workflow-commands/SKILL.md +9 -9
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
  6. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
  7. package/.claude/skills/workflow-commands/halfrun.md +4 -5
  8. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  9. package/.claude/skills/workflow-commands/issue-scout.md +21 -22
  10. package/.claude/skills/workflow-commands/kickoff.md +8 -9
  11. package/.claude/skills/workflow-commands/observation-filing.md +9 -10
  12. package/.claude/skills/workflow-commands/prerequisite.md +7 -6
  13. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  14. package/.claude/skills/workflow-commands/rule-residency.md +2 -2
  15. package/.claude/skills/workflow-commands/split-assessment.md +4 -3
  16. package/.claude/skills/workflow-commands/target-repository.md +7 -6
  17. package/.github/workflows/sonar-qube.yml +12 -2
  18. package/CLAUDE.md +2 -2
  19. package/README.md +30 -21
  20. package/dist/CLAUDE.md +2 -2
  21. package/dist/hooks/batch-guard.js +4 -4
  22. package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-4GDR2YTV.js} +4 -4
  23. package/dist/hooks/{chunk-2K52BRWV.js → chunk-56LGMJJV.js} +2 -2
  24. package/dist/hooks/{chunk-BNGGEY3Z.js → chunk-762BDXDU.js} +266 -327
  25. package/dist/hooks/{chunk-Q7LE77CO.js → chunk-7WAOP42A.js} +34 -30
  26. package/dist/hooks/{chunk-NXL3SBXY.js → chunk-A2H5I2VD.js} +2 -2
  27. package/dist/hooks/{chunk-CRGJTD6E.js → chunk-AWTFKKFW.js} +1 -1
  28. package/dist/hooks/{chunk-KQG4776P.js → chunk-GBJUERYK.js} +1 -1
  29. package/dist/hooks/{chunk-BVMSPG3M.js → chunk-KPZN42RT.js} +3 -3
  30. package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-PAXMLGSS.js} +42 -8
  31. package/dist/hooks/{chunk-F6OM6SIG.js → chunk-WMIR5SNX.js} +12 -12
  32. package/dist/hooks/codex-hook-adapter.js +10 -10
  33. package/dist/hooks/duplicate-read-guard.js +5 -5
  34. package/dist/hooks/format-edited.js +2 -2
  35. package/dist/hooks/investigation-guard.js +5 -5
  36. package/dist/hooks/pretool-guard.js +9 -9
  37. package/dist/hooks/stop-guard.js +99 -27
  38. package/dist/josh.js +21 -6
  39. package/package.json +5 -1
  40. package/prettier/basic.d.ts +3 -0
  41. package/prettier/basic.js +10 -0
  42. package/prettier/index.js +2 -2
  43. package/prettier/static.js +3 -10
  44. package/prompts/collaboration-workflow/epic-bundle.md +8 -8
  45. package/prompts/collaboration-workflow/issue-template.md +5 -5
  46. package/prompts/collaboration-workflow/rule-delivery.md +43 -30
  47. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  48. package/prompts/collaboration-workflow/wip-cap.md +3 -3
  49. package/prompts/review.md +11 -9
  50. package/prompts/sonar-hotspot-handling.md +6 -0
  51. package/prompts/testing-guide.md +1 -1
  52. package/scripts/agent/agent-diagnostics.ts +1 -0
  53. package/scripts/agent/agent-role-profile.ts +1 -1
  54. package/scripts/behavior/behavior-rules.ts +8 -1
  55. package/scripts/build/build-claude-md.ts +13 -4
  56. package/scripts/delegation/delegation-policy.ts +9 -9
  57. package/scripts/document/document-byte-budget.ts +1 -1
  58. package/scripts/gate/gate-steps.ts +1 -1
  59. package/scripts/gate/project-checks.ts +12 -11
  60. package/scripts/gate/type-check-command.ts +1 -1
  61. package/scripts/git/git-branch.ts +14 -3
  62. package/scripts/git/git-issue.ts +13 -5
  63. package/scripts/git/git-pr.ts +16 -10
  64. package/scripts/git/git-preflight.ts +138 -0
  65. package/scripts/git/telegram-notify.ts +21 -0
  66. package/scripts/hooks/stop-guard.ts +2 -0
  67. package/scripts/init/basic-path-migration.ts +24 -0
  68. package/scripts/init/distributed-paths.ts +22 -0
  69. package/scripts/init/init-actions.ts +27 -19
  70. package/scripts/init/init-ai-copy.ts +86 -28
  71. package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
  72. package/scripts/init/init-bootstrap.ts +4 -3
  73. package/scripts/init/init-install.ts +1 -1
  74. package/scripts/init/init-logic-templates.ts +1 -1
  75. package/scripts/init/init-logic.ts +1 -1
  76. package/scripts/init/init.ts +41 -19
  77. package/scripts/init/kit-setup-state.ts +30 -0
  78. package/scripts/init/kit-written-paths.ts +33 -0
  79. package/scripts/init/project-profile-cli.ts +15 -1
  80. package/scripts/init/project-profile.ts +41 -12
  81. package/scripts/init/session-language-environment.ts +83 -0
  82. package/scripts/init/start-exec.ts +28 -0
  83. package/scripts/init/start-plan.ts +35 -13
  84. package/scripts/init/start-prompt.ts +22 -8
  85. package/scripts/init/start-setup-pr.ts +138 -0
  86. package/scripts/init/start-steps.ts +30 -30
  87. package/scripts/init/start.ts +7 -3
  88. package/scripts/issue/issue-file-cli.ts +188 -0
  89. package/scripts/issue/issue-file.ts +192 -0
  90. package/scripts/issue/issue-lint-cli.ts +1 -1
  91. package/scripts/issue/issue-scout-cli.ts +41 -97
  92. package/scripts/issue/issue-scout-closed.ts +102 -0
  93. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +12 -4
  94. package/scripts/josh/josh-command-map.ts +1 -0
  95. package/scripts/josh/josh-commands-development.ts +3 -3
  96. package/scripts/josh/josh-commands-issue.ts +11 -0
  97. package/scripts/josh/josh-commands-project.ts +7 -3
  98. package/scripts/lint/cspell-cached.ts +3 -3
  99. package/scripts/lint/format.ts +1 -1
  100. package/scripts/lint/lint-parallel.ts +2 -2
  101. package/scripts/propagate/propagate-run.ts +1 -0
  102. package/scripts/propagate/propagate-select.ts +72 -0
  103. package/scripts/propagate/propagate-targets.ts +5 -3
  104. package/scripts/propagate/propagate.ts +86 -24
  105. package/scripts/rules/bash-triggers.ts +19 -6
  106. package/scripts/rules/delivered-rules.ts +28 -32
  107. package/scripts/rules/direct-filing.ts +38 -0
  108. package/scripts/rules/filing-cap.ts +12 -8
  109. package/scripts/rules/issue-citation.ts +30 -1
  110. package/scripts/rules/issue-fold.ts +4 -4
  111. package/scripts/rules/last-prompt.ts +79 -0
  112. package/scripts/rules/oracle-consulted.ts +4 -4
  113. package/scripts/rules/oracle-firing.ts +13 -16
  114. package/scripts/rules/rule-body-guard.ts +6 -9
  115. package/scripts/rules/stop-rules.ts +8 -5
  116. package/scripts/safe-chain/ci-installer-pin.ts +19 -12
  117. package/scripts/safe-chain/project-config.ts +1 -1
  118. package/scripts/sync/sync-ai-files.ts +204 -0
  119. package/scripts/sync/sync.ts +48 -184
  120. package/scripts/test/josh-harness-environment.ts +1 -1
  121. package/scripts/test/pilot-files.ts +0 -2
  122. package/scripts/test/test-declared-changed.ts +1 -1
  123. package/scripts/test/test-declared-logic.ts +55 -14
  124. package/scripts/test/test-declared.ts +23 -11
  125. package/scripts/test/test-unit-guard.ts +6 -6
  126. package/scripts/time-runtime/time-shell.ts +36 -2
  127. package/scripts/version/fresh-metadata-cache.ts +36 -0
  128. package/scripts/version/latest-update.ts +2 -1
  129. package/scripts-ai/git-workflow.ts +10 -0
  130. package/templates/CLAUDE.basic.md +8 -0
  131. package/templates/workflows/ci.yml +27 -0
  132. package/scripts/rules/issue-bug-label.ts +0 -85
  133. package/scripts/rules/issue-filing-args.ts +0 -50
  134. package/scripts/rules/issue-scout.ts +0 -64
  135. /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
  136. /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
  137. /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
  138. /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
@@ -4,7 +4,7 @@ import { repo_origin } from '#scripts/discovery/repo-origin'
4
4
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
5
5
  import { execaSync } from 'execa'
6
6
 
7
- type ProjectProfile = 'static' | 'node'
7
+ type ProjectProfile = 'basic' | 'full'
8
8
 
9
9
  interface ProfileResult {
10
10
  profile: ProjectProfile
@@ -22,11 +22,34 @@ const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', '.
22
22
  const WEB_EXTENSIONS = new Set(['.html', '.css', '.js', '.jsx', '.mjs', '.cjs'])
23
23
  const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx'])
24
24
  const PROFILE_ARG_LENGTH = 2
25
+ // The names before joshuafolkken/kit#2829. A project that recorded one, or a script that passes one
26
+ // to `--profile`, keeps working: each reads as the profile it was renamed to.
27
+ const PROFILE_NAMES: Readonly<Record<string, ProjectProfile>> = {
28
+ basic: 'basic',
29
+ full: 'full',
30
+ static: 'basic',
31
+ node: 'full',
32
+ }
25
33
 
26
34
  function parse_profile(value: unknown): ProjectProfile | undefined {
27
- if (value === 'static' || value === 'node') return value
35
+ return typeof value === 'string' && Object.hasOwn(PROFILE_NAMES, value)
36
+ ? PROFILE_NAMES[value]
37
+ : undefined
38
+ }
39
+
40
+ // The pre-rename name of each profile, which every kit with `--profile` accepts — the new one only
41
+ // from joshuafolkken/kit#2829 on.
42
+ const LEGACY_NAMES: Readonly<Record<ProjectProfile, string>> = { basic: 'static', full: 'node' }
28
43
 
29
- return undefined
44
+ // A `josh init` handed to the project's kit may run an older kit than the one handing off: the
45
+ // project pinned it, or its `minimumReleaseAge` held the new release back. The pre-rename spelling
46
+ // is the one both kits read.
47
+ function with_legacy_profile_names(args: ReadonlyArray<string>): Array<string> {
48
+ return args.map((argument, index) => {
49
+ const profile = args[index - 1] === '--profile' ? parse_profile(argument) : undefined
50
+
51
+ return profile === undefined ? argument : LEGACY_NAMES[profile]
52
+ })
30
53
  }
31
54
 
32
55
  function is_profile_pair(args: ReadonlyArray<string>): boolean {
@@ -35,14 +58,14 @@ function is_profile_pair(args: ReadonlyArray<string>): boolean {
35
58
 
36
59
  function requested_profile(
37
60
  args: ReadonlyArray<string>,
38
- usage = 'josh init [--profile static|node]',
61
+ usage = 'josh init [--profile basic|full]',
39
62
  ): ProjectProfile | undefined {
40
63
  if (args.length === 0) return undefined
41
64
  if (!is_profile_pair(args)) throw new Error(`Usage: ${usage}`)
42
65
 
43
66
  const profile = parse_profile(args[1])
44
67
  if (profile !== undefined) return profile
45
- throw new Error('Profile must be static or node')
68
+ throw new Error('Profile must be basic or full')
46
69
  }
47
70
 
48
71
  function read_manifest(root: string): Record<string, unknown> | undefined {
@@ -72,8 +95,8 @@ function has_build_script(value: unknown): boolean {
72
95
  }
73
96
 
74
97
  // kit itself is not evidence of a Node toolchain: `pnpm add -D @joshuafolkken/kit` before
75
- // `josh init` is the documented order, and it must still leave an `index.html` site static
76
- // (joshuafolkken/kit#2693).
98
+ // `josh init` is the documented order, and it must still leave an `index.html` site on the basic
99
+ // profile (joshuafolkken/kit#2693).
77
100
  function has_project_dependencies(value: unknown): boolean {
78
101
  if (!is_record(value)) return false
79
102
 
@@ -85,20 +108,20 @@ function inferred_profile(manifest: Record<string, unknown>): ProfileResult {
85
108
  has_project_dependencies(manifest['dependencies']) ||
86
109
  has_project_dependencies(manifest['devDependencies'])
87
110
  ) {
88
- return { profile: 'node', reason: 'package dependencies' }
111
+ return { profile: 'full', reason: 'package dependencies' }
89
112
  }
90
113
 
91
114
  if (has_build_script(manifest['scripts'])) {
92
- return { profile: 'node', reason: 'build or dev script' }
115
+ return { profile: 'full', reason: 'build or dev script' }
93
116
  }
94
117
 
95
- return { profile: 'static', reason: 'metadata-only package.json' }
118
+ return { profile: 'basic', reason: 'metadata-only package.json' }
96
119
  }
97
120
 
98
121
  function resolve_profile(root: string, requested?: ProjectProfile): ProfileResult {
99
122
  if (requested !== undefined) return { profile: requested, reason: 'explicit --profile' }
100
123
  const manifest = read_manifest(root)
101
- if (manifest === undefined) return { profile: 'static', reason: 'no package.json' }
124
+ if (manifest === undefined) return { profile: 'basic', reason: 'no package.json' }
102
125
  const profile = recorded_profile(manifest)
103
126
  if (profile !== undefined) return { profile, reason: 'package.json josh.profile' }
104
127
 
@@ -157,6 +180,12 @@ function inspect_project(root: string, requested?: ProjectProfile): ProjectShape
157
180
  return { ...profile, ...files, has_git, has_github: has_git && has_github_remote(root) }
158
181
  }
159
182
 
160
- const project_profile = { resolve_profile, inspect_project, requested_profile }
183
+ const project_profile = {
184
+ parse_profile,
185
+ resolve_profile,
186
+ inspect_project,
187
+ requested_profile,
188
+ with_legacy_profile_names,
189
+ }
161
190
  export { project_profile }
162
191
  export type { ProjectProfile, ProjectShape }
@@ -0,0 +1,83 @@
1
+ // `josh init` seeds `JOSH_SESSION_LANG` in `.env` from the OS locale, so a newly adopting user starts
2
+ // in their own language (joshuafolkken/kit#2818). A project already using kit keeps its language: the
3
+ // session language takes effect only through the `session:lang` hook, so a project whose
4
+ // `.claude/settings.json` already wires it is left alone, and an existing `.env` value — the `export`
5
+ // form included — is never touched. The unset default stays `ja`.
6
+
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { session_language } from '#scripts/josh/session-language'
10
+ import type { FileAction } from './init-actions'
11
+ import { PROJECT_ROOT } from './init-paths'
12
+
13
+ const ENV_FILE = '.env'
14
+ const CLAUDE_SETTINGS_PATH = path.join('.claude', 'settings.json')
15
+ const SESSION_LANG_HOOK = 'session:lang'
16
+ // POSIX precedence: `LC_ALL` overrides `LC_MESSAGES`, which overrides `LANG`.
17
+ const LOCALE_KEYS = ['LC_ALL', 'LC_MESSAGES', 'LANG'] as const
18
+ const JAPANESE_PREFIX = 'ja'
19
+ const NON_JAPANESE_LANG = 'en'
20
+ const KEY_LINE_PATTERN = new RegExp(
21
+ String.raw`^\s*(?:export\s+)?${session_language.ENV_KEY}\s*=`,
22
+ 'mu',
23
+ )
24
+
25
+ type LocaleEnvironment = Readonly<Record<string, string | undefined>>
26
+
27
+ function lang_from_locale(environment: LocaleEnvironment): string {
28
+ const locale = LOCALE_KEYS.map((key) => environment[key]?.trim()).find(Boolean) ?? ''
29
+
30
+ return locale.toLowerCase().startsWith(JAPANESE_PREFIX) ? JAPANESE_PREFIX : NON_JAPANESE_LANG
31
+ }
32
+
33
+ // A value the user already exported is their explicit choice, so it wins over the locale.
34
+ function resolve_lang(environment: LocaleEnvironment): string {
35
+ const exported = environment[session_language.ENV_KEY]?.trim()
36
+ if (exported) return exported
37
+
38
+ return lang_from_locale(environment)
39
+ }
40
+
41
+ function session_lang_line(lang: string): string {
42
+ return `${session_language.ENV_KEY}=${lang}\n`
43
+ }
44
+
45
+ function merge_session_lang(existing: string, lang: string): string {
46
+ if (KEY_LINE_PATTERN.test(existing)) return existing
47
+ const separator = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'
48
+
49
+ return `${existing}${separator}${session_lang_line(lang)}`
50
+ }
51
+
52
+ function is_session_lang_hooked(project_root: string): boolean {
53
+ const settings_path = path.join(project_root, CLAUDE_SETTINGS_PATH)
54
+
55
+ return (
56
+ existsSync(settings_path) && readFileSync(settings_path, 'utf8').includes(SESSION_LANG_HOOK)
57
+ )
58
+ }
59
+
60
+ // Built before `init` writes the AI files, so the hook check reads the project as it was.
61
+ function build_session_lang_actions(
62
+ environment: LocaleEnvironment = process.env,
63
+ project_root: string = PROJECT_ROOT,
64
+ ): ReadonlyArray<FileAction> {
65
+ if (is_session_lang_hooked(project_root)) return []
66
+ const lang = resolve_lang(environment)
67
+
68
+ return [
69
+ {
70
+ dest: ENV_FILE,
71
+ create: () => session_lang_line(lang),
72
+ merge: (existing) => merge_session_lang(existing, lang),
73
+ },
74
+ ]
75
+ }
76
+
77
+ const session_language_environment = {
78
+ build_session_lang_actions,
79
+ lang_from_locale,
80
+ merge_session_lang,
81
+ }
82
+
83
+ export { session_language_environment }
@@ -0,0 +1,28 @@
1
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
2
+ import { execaSync } from 'execa'
3
+
4
+ // The three spawn shapes every `josh start` step uses, shared by `start-steps.ts`, the setup pull
5
+ // request step and the `josh init` hint so none of them keeps a copy of its own.
6
+ function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
7
+ return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
8
+ }
9
+
10
+ function read_output(
11
+ command: string,
12
+ args: ReadonlyArray<string>,
13
+ root: string,
14
+ ): string | undefined {
15
+ const result = execaSync(command, args, { cwd: root, reject: false })
16
+
17
+ return result.exitCode === 0 ? result.stdout.trim() : undefined
18
+ }
19
+
20
+ // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
21
+ // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
22
+ function run(command: string, args: ReadonlyArray<string>, root: string): void {
23
+ execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
24
+ }
25
+
26
+ const start_exec = { succeeds, read_output, run }
27
+
28
+ export { start_exec }
@@ -1,4 +1,5 @@
1
1
  import type { ProjectProfile } from './project-profile'
2
+ import { start_setup_pr } from './start-setup-pr'
2
3
 
3
4
  type Visibility = 'private' | 'public'
4
5
 
@@ -9,7 +10,7 @@ interface StartOptions {
9
10
  visibility: Visibility
10
11
  }
11
12
 
12
- type StartStep = 'git_init' | 'initialize' | 'commit' | 'repository' | 'labels'
13
+ type StartStep = 'git_init' | 'initialize' | 'commit' | 'repository' | 'labels' | 'setup_pr'
13
14
 
14
15
  interface GitState {
15
16
  has_git: boolean
@@ -17,6 +18,7 @@ interface GitState {
17
18
  has_origin: boolean
18
19
  branch: string | undefined
19
20
  has_commits: boolean
21
+ has_kit_committed: boolean
20
22
  }
21
23
 
22
24
  interface StepPlan {
@@ -47,14 +49,33 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
47
49
  commit: 'Commit every file as the initial commit',
48
50
  repository: 'Create the GitHub repository and push main',
49
51
  labels: 'Add the workflow and release-classification labels the repository is missing',
52
+ setup_pr: 'Open an Issue and a pull request that add the kit setup to main',
50
53
  }
51
54
 
55
+ // The GitHub write steps `--yes` alone never consents to.
56
+ const GITHUB_WRITE_STEPS: ReadonlySet<StartStep> = new Set(['repository', 'setup_pr'])
57
+
52
58
  function refused(refusal: string): StepPlan {
53
59
  return { steps: [], refusal }
54
60
  }
55
61
 
56
- // A repository that already has a history keeps it: nothing is committed on the user's behalf, and
57
- // only `main` is pushed, because the Issue workflow branches from and merges into it.
62
+ // Main already has a history but not kit's setup, so the setup reaches main the way every later change
63
+ // does — an Issue and a pull request a person merges — rather than a commit on main
64
+ // (joshuafolkken/kit#2816). On another branch the pull request would not be based on main, so it waits
65
+ // — except the setup branch a failed hook left checked out, where a re-run resumes. Once kit is
66
+ // committed there, nothing is left for the step, so a later run on that branch plans none.
67
+ function needs_setup_pr(state: GitState): boolean {
68
+ if (!state.has_commits || state.has_kit_committed) return false
69
+
70
+ return state.branch === DEFAULT_BRANCH || start_setup_pr.is_setup_branch(state.branch)
71
+ }
72
+
73
+ function with_setup_pr(steps: ReadonlyArray<StartStep>, state: GitState): StepPlan {
74
+ return { steps: needs_setup_pr(state) ? [...steps, 'setup_pr'] : steps, refusal: undefined }
75
+ }
76
+
77
+ // A repository that already has a history keeps it: nothing is committed on main on the user's
78
+ // behalf, and only `main` is pushed, because the Issue workflow branches from and merges into it.
58
79
  function existing_git_steps(state: GitState): StepPlan {
59
80
  if (!state.has_commits) return { steps: FULL_STEPS.slice(1), refusal: undefined }
60
81
 
@@ -64,14 +85,15 @@ function existing_git_steps(state: GitState): StepPlan {
64
85
  )
65
86
  }
66
87
 
67
- return { steps: ['initialize', 'repository', 'labels'], refusal: undefined }
88
+ return with_setup_pr(['initialize', 'repository', 'labels'], state)
68
89
  }
69
90
 
70
- // An existing GitHub origin is never replaced or pushed to: only the additive steps run. Any other
71
- // origin is refused here, before the first write — `gh repo create --remote origin` cannot add a
72
- // remote that already exists, so the run would otherwise fail after initializing the directory.
91
+ // An existing GitHub origin is never replaced, and main is never pushed to: only the additive steps
92
+ // run, plus the setup pull request while main lacks kit. Any other origin is refused here, before the
93
+ // first write — `gh repo create --remote origin` cannot add a remote that already exists, so the run
94
+ // would otherwise fail after initializing the directory.
73
95
  function plan_steps(state: GitState): StepPlan {
74
- if (state.has_github) return { steps: ['initialize', 'labels'], refusal: undefined }
96
+ if (state.has_github) return with_setup_pr(['initialize', 'labels'], state)
75
97
 
76
98
  if (state.has_origin) {
77
99
  return refused(
@@ -96,22 +118,22 @@ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interac
96
118
  }
97
119
 
98
120
  // `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
99
- // unattended run creates and pushes a repository only when `--github` asks for it by name.
121
+ // unattended run creates a repository, pushes, or opens the setup Issue and pull request only when
122
+ // `--github` asks for it by name.
100
123
  function github_consent(
101
124
  steps: ReadonlyArray<StartStep>,
102
125
  options: StartOptions,
103
126
  interaction: Interaction,
104
127
  ): Verdict<Consent> {
105
- if (!steps.includes('repository') || options.is_github) {
106
- return { value: 'granted', refusal: undefined }
107
- }
128
+ const is_github_write = steps.some((step) => GITHUB_WRITE_STEPS.has(step))
108
129
 
130
+ if (!is_github_write || options.is_github) return { value: 'granted', refusal: undefined }
109
131
  if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
110
132
 
111
133
  return {
112
134
  value: undefined,
113
135
  refusal:
114
- 'josh start --yes does not create a GitHub repository or push. Add --github to create one, or run josh init to set up without GitHub.',
136
+ 'josh start --yes does not write to GitHub (create a repository, push, or open the setup pull request). Add --github to allow it, or run josh init to set up without GitHub.',
115
137
  }
116
138
  }
117
139
 
@@ -3,22 +3,25 @@ import { stdin as input, stdout as output } from 'node:process'
3
3
  import { createInterface, type Interface } from 'node:readline/promises'
4
4
  import { ask_yes_no_simple } from '#scripts/git/git-prompt'
5
5
  import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
6
- import type { Consent, StartOptions } from './start-plan'
6
+ import type { Consent, StartOptions, StartStep } from './start-plan'
7
7
 
8
8
  interface Choices {
9
9
  options: StartOptions
10
10
  shape: ProjectShape
11
11
  consent: Consent
12
12
  root: string
13
+ steps: ReadonlyArray<StartStep>
13
14
  }
14
15
 
15
16
  const DECLINED =
16
- 'Stopped before creating the GitHub repository. Nothing was changed. Run josh init to set up without GitHub.'
17
+ 'Stopped before writing to GitHub. Nothing was changed. Run josh init to set up without GitHub.'
18
+ const SETUP_PR_QUESTION =
19
+ 'Open an Issue and a pull request on GitHub that add the kit setup to main? (y/n): '
17
20
 
18
21
  // The detected profile is the default, so pressing Enter accepts it; the reason is shown because an
19
22
  // ambiguous project is exactly the case this question exists for.
20
23
  async function ask_profile(prompt: Interface, shape: ProjectShape): Promise<ProjectProfile> {
21
- const question = `Profile — static or node [${shape.profile}, ${shape.reason}]: `
24
+ const question = `Profile — basic or full [${shape.profile}, ${shape.reason}]: `
22
25
  const raw_answer = await prompt.question(question)
23
26
  const answer = raw_answer.trim()
24
27
  if (answer.length === 0) return shape.profile
@@ -26,16 +29,27 @@ async function ask_profile(prompt: Interface, shape: ProjectShape): Promise<Proj
26
29
  return project_profile.requested_profile(['--profile', answer]) ?? shape.profile
27
30
  }
28
31
 
29
- async function ask_repository(prompt: Interface, choices: Choices): Promise<void> {
30
- const name = path.basename(choices.root)
31
- const question = `Create a ${choices.options.visibility} GitHub repository "${name}" and push main? (y/n): `
32
-
32
+ async function ask_or_stop(prompt: Interface, question: string): Promise<void> {
33
33
  if (!(await ask_yes_no_simple(prompt, question))) throw new Error(DECLINED)
34
34
  }
35
35
 
36
+ // One question per GitHub write the plan holds, every one asked before the first step runs.
37
+ async function ask_github_writes(prompt: Interface, choices: Choices): Promise<void> {
38
+ if (choices.steps.includes('repository')) {
39
+ const name = path.basename(choices.root)
40
+
41
+ await ask_or_stop(
42
+ prompt,
43
+ `Create a ${choices.options.visibility} GitHub repository "${name}" and push main? (y/n): `,
44
+ )
45
+ }
46
+
47
+ if (choices.steps.includes('setup_pr')) await ask_or_stop(prompt, SETUP_PR_QUESTION)
48
+ }
49
+
36
50
  async function ask_choices(prompt: Interface, choices: Choices): Promise<ProjectProfile> {
37
51
  const profile = choices.options.profile ?? (await ask_profile(prompt, choices.shape))
38
- if (choices.consent === 'ask') await ask_repository(prompt, choices)
52
+ if (choices.consent === 'ask') await ask_github_writes(prompt, choices)
39
53
 
40
54
  return profile
41
55
  }
@@ -0,0 +1,138 @@
1
+ import { git_gh_api_path } from '#scripts/git/git-gh-api-path'
2
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
+ import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
4
+ import { git_issue, type IssueInfo } from '#scripts/git/git-issue'
5
+ import { git_pr } from '#scripts/git/git-pr'
6
+ import { github_issue_url } from '#scripts/git/github-issue-url'
7
+ import { IGNORE_FOR_RELEASE_LABEL } from '#scripts/git/issue-labels'
8
+ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
9
+ import { kit_written_paths } from './kit-written-paths'
10
+ import { start_exec } from './start-exec'
11
+
12
+ const SETUP_TITLE = `Set up ${KIT_PACKAGE_NAME}`
13
+ const SETUP_BODY = [
14
+ `Adds the files \`josh start\` wrote while setting up ${KIT_PACKAGE_NAME}, so main carries the`,
15
+ 'rules, settings and workflows every later Issue is run with. Only the files kit wrote are in the',
16
+ 'pull request; anything else in the working tree is left as it was.',
17
+ ].join('\n')
18
+ const NOTHING_TO_COMMIT = 'The kit setup has no changes to commit, so no pull request was opened.'
19
+ const OPEN_SETUP_ISSUES_QUERY = `?state=open&labels=${IGNORE_FOR_RELEASE_LABEL}&per_page=100`
20
+ const FIRST_SETUP_ISSUE_FILTER = `[.[] | select(.title == "${SETUP_TITLE}") | .number][0] // empty`
21
+
22
+ function lines_of(output: string | undefined): Array<string> {
23
+ return (output ?? '').split('\n').filter((line) => line.length > 0)
24
+ }
25
+
26
+ function setup_issue(issue_number: string): IssueInfo {
27
+ return git_issue.parse(`${SETUP_TITLE} #${issue_number}`)
28
+ }
29
+
30
+ // The Issue number when `branch` is the branch an earlier `josh start` created for the setup pull
31
+ // request — the branch a failed commit or push leaves checked out, so a re-run resumes there.
32
+ function setup_branch_number(branch: string | undefined): string | undefined {
33
+ const issue_number = branch === undefined ? undefined : git_issue.branch_number(branch)
34
+
35
+ if (issue_number === undefined) return undefined
36
+
37
+ return setup_issue(issue_number).branch_name === branch ? issue_number : undefined
38
+ }
39
+
40
+ function is_setup_branch(branch: string | undefined): boolean {
41
+ return setup_branch_number(branch) !== undefined
42
+ }
43
+
44
+ // Untracked files the ignore rules let through, plus tracked files the setup changed — two plain
45
+ // listings, so nothing has to be parsed out of `git status`. Every kit path is ASCII, so git's
46
+ // quoting of other names never hides one.
47
+ function changed_paths(root: string): Array<string> {
48
+ const untracked = start_exec.read_output(
49
+ 'git',
50
+ ['ls-files', '--others', '--exclude-standard'],
51
+ root,
52
+ )
53
+ const modified = start_exec.read_output('git', ['diff', '--name-only', 'HEAD'], root)
54
+
55
+ return [...lines_of(untracked), ...lines_of(modified)]
56
+ }
57
+
58
+ // The Issue carries the release classification the pull request is opened with, so the PR step finds
59
+ // one without a person adding it (joshuafolkken/kit#2816).
60
+ function file_setup_issue(): string {
61
+ const request = git_gh_issue_write.issue_create_request({
62
+ title: SETUP_TITLE,
63
+ body: SETUP_BODY,
64
+ labels: [IGNORE_FOR_RELEASE_LABEL],
65
+ })
66
+ const url = git_gh_exec.exec_gh_api_sync(request)
67
+ const issue_number = github_issue_url.parse(url)?.issue_number
68
+
69
+ if (issue_number === undefined) throw new Error(`Could not read the Issue number from ${url}`)
70
+
71
+ return issue_number
72
+ }
73
+
74
+ function open_setup_issue_number(): string | undefined {
75
+ const issue_number = git_gh_exec.exec_gh_api_sync({
76
+ path: `${git_gh_api_path.issues_api_path()}${OPEN_SETUP_ISSUES_QUERY}`,
77
+ jq_filter: FIRST_SETUP_ISSUE_FILTER,
78
+ })
79
+
80
+ return issue_number.length > 0 ? issue_number : undefined
81
+ }
82
+
83
+ // A re-run after a failed hook reuses what the failed run made — the branch it left checked out, or
84
+ // the setup Issue still open — so no Issue is filed twice.
85
+ function resolve_issue(branch: string | undefined): IssueInfo {
86
+ const reused = setup_branch_number(branch) ?? open_setup_issue_number()
87
+
88
+ return setup_issue(reused ?? file_setup_issue())
89
+ }
90
+
91
+ // An existing setup branch seen from another branch is not switched to: the setup just rewrote kit's
92
+ // files in this working tree, and a checkout would refuse to overwrite them part-way through.
93
+ function switch_to(branch: string, current: string | undefined, root: string): void {
94
+ if (current === branch) return
95
+
96
+ const reference = `refs/heads/${branch}`
97
+
98
+ if (start_exec.succeeds('git', ['rev-parse', '--verify', '--quiet', reference], root)) {
99
+ throw new Error(
100
+ `The setup branch ${branch} already exists. Run git switch ${branch}, then josh start again.`,
101
+ )
102
+ }
103
+
104
+ start_exec.run('git', ['switch', '--create', branch], root)
105
+ }
106
+
107
+ // The paths are named on the commit as well as on the add, so anything the user had staged before
108
+ // stays out of the setup commit. The project's own hooks run, as on every other commit.
109
+ function commit_kit_paths(paths: ReadonlyArray<string>, issue: IssueInfo, root: string): void {
110
+ if (paths.length === 0) return
111
+
112
+ start_exec.run('git', ['add', '--', ...paths], root)
113
+ start_exec.run('git', ['commit', '--message', issue.commit_message, '--', ...paths], root)
114
+ }
115
+
116
+ // Issue → branch → only kit's files committed → push → pull request. The merge is left to a person:
117
+ // it is the one step that changes main.
118
+ async function open(root: string): Promise<void> {
119
+ const paths = kit_written_paths.select(changed_paths(root))
120
+
121
+ if (paths.length === 0) {
122
+ console.info(NOTHING_TO_COMMIT)
123
+
124
+ return
125
+ }
126
+
127
+ const current = start_exec.read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
128
+ const issue = resolve_issue(current)
129
+
130
+ switch_to(issue.branch_name, current, root)
131
+ commit_kit_paths(paths, issue, root)
132
+ start_exec.run('git', ['push', '--set-upstream', 'origin', issue.branch_name], root)
133
+ await git_pr.create_with_issue_info(issue)
134
+ }
135
+
136
+ const start_setup_pr = { open, changed_paths, is_setup_branch }
137
+
138
+ export { start_setup_pr }
@@ -1,12 +1,13 @@
1
1
  import path from 'node:path'
2
- import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
2
  import { repository_labels } from '#scripts/repo/repository-labels'
4
3
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
- import { execaSync } from 'execa'
6
4
  import { main as init_main } from './init'
7
5
  import { PACKAGE_DIR } from './init-paths'
6
+ import { kit_setup_state } from './kit-setup-state'
8
7
  import type { ProjectProfile, ProjectShape } from './project-profile'
8
+ import { start_exec } from './start-exec'
9
9
  import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
10
+ import { start_setup_pr } from './start-setup-pr'
10
11
 
11
12
  interface StepContext {
12
13
  root: string
@@ -20,38 +21,28 @@ const GH_INSTALL_HINT =
20
21
  const GH_AUTH_HINT =
21
22
  'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
22
23
 
23
- function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
24
- return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
25
- }
26
-
27
- function read_output(
28
- command: string,
29
- args: ReadonlyArray<string>,
30
- root: string,
31
- ): string | undefined {
32
- const result = execaSync(command, args, { cwd: root, reject: false })
33
-
34
- return result.exitCode === 0 ? result.stdout.trim() : undefined
35
- }
36
-
37
- // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
38
- // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
39
- function run(command: string, args: ReadonlyArray<string>, root: string): void {
40
- execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
41
- }
24
+ const { succeeds, read_output, run } = start_exec
42
25
 
43
26
  function read_git_state(root: string, shape: ProjectShape): GitState {
44
27
  const { has_git, has_github } = shape
45
28
 
46
29
  if (!has_git) {
47
- return { has_git, has_github, has_origin: false, branch: undefined, has_commits: false }
30
+ return {
31
+ has_git,
32
+ has_github,
33
+ has_origin: false,
34
+ branch: undefined,
35
+ has_commits: false,
36
+ has_kit_committed: false,
37
+ }
48
38
  }
49
39
 
50
40
  const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
51
41
  const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
52
42
  const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
43
+ const has_kit_committed = kit_setup_state.is_kit_committed(root)
53
44
 
54
- return { has_git, has_github, has_origin, branch, has_commits }
45
+ return { has_git, has_github, has_origin, branch, has_commits, has_kit_committed }
55
46
  }
56
47
 
57
48
  // Every step talks to GitHub, so the CLI is checked before the first write rather than at the step
@@ -82,7 +73,7 @@ function initialize(context: StepContext): void {
82
73
  function commit_all(context: StepContext): void {
83
74
  run('git', ['add', '--all'], context.root)
84
75
  // The first commit of a new repository has no branch to come from, so the hook that keeps commits
85
- // off main — installed by the initialize step in a node project — would refuse the only commit
76
+ // off main — installed by the initialize step in a full project — would refuse the only commit
86
77
  // that has to land there.
87
78
  run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
88
79
  // A repository created by an older `git init` may name its unborn branch `master`.
@@ -103,21 +94,30 @@ function add_missing_labels(): void {
103
94
  repository_labels.ensure_labels('{owner}/{repo}')
104
95
  }
105
96
 
106
- const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
97
+ // Main already has a history here, so the setup reaches it the way every other change does: through
98
+ // an Issue and a pull request a person merges (joshuafolkken/kit#2816).
99
+ async function open_setup_pull_request(context: StepContext): Promise<void> {
100
+ await start_setup_pr.open(context.root)
101
+ }
102
+
103
+ type StepAction = (context: StepContext) => void | Promise<void>
104
+
105
+ const STEP_ACTIONS: Readonly<Record<StartStep, StepAction>> = {
107
106
  git_init: create_git_repository,
108
107
  initialize,
109
108
  commit: commit_all,
110
109
  repository: create_github_repository,
111
110
  labels: add_missing_labels,
111
+ setup_pr: open_setup_pull_request,
112
112
  }
113
113
 
114
- function run_step(
114
+ async function run_step(
115
115
  step: StartStep,
116
116
  context: StepContext,
117
117
  completed: ReadonlyArray<StartStep>,
118
- ): void {
118
+ ): Promise<void> {
119
119
  try {
120
- STEP_ACTIONS[step](context)
120
+ await STEP_ACTIONS[step](context)
121
121
  } catch (error) {
122
122
  const cause = error instanceof Error ? error.message : String(error)
123
123
 
@@ -125,12 +125,12 @@ function run_step(
125
125
  }
126
126
  }
127
127
 
128
- function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): void {
128
+ async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): Promise<void> {
129
129
  for (const [index, step] of steps.entries()) {
130
130
  const position = `${String(index + 1)}/${String(steps.length)}`
131
131
 
132
132
  console.info(`\n[${position}] ${start_plan.STEP_LABELS[step]}`)
133
- run_step(step, context, steps.slice(0, index))
133
+ await run_step(step, context, steps.slice(0, index))
134
134
  }
135
135
  }
136
136