@joshuafolkken/kit 1.983.0 → 1.1003.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/.claude/skills/workflow-commands/backlogrun-child.md +4 -5
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.github/workflows/sonar-qube.yml +12 -2
- package/CLAUDE.md +2 -2
- package/README.md +30 -21
- package/dist/CLAUDE.md +2 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
- package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
- package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
- package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
- package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
- package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
- package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
- package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
- package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
- package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +98 -26
- package/dist/josh.js +5 -5
- package/package.json +5 -1
- package/prettier/basic.d.ts +3 -0
- package/prettier/basic.js +10 -0
- package/prettier/index.js +2 -2
- package/prettier/static.js +3 -10
- package/prompts/sonar-hotspot-handling.md +6 -0
- package/prompts/testing-guide.md +1 -1
- package/scripts/agent/agent-diagnostics.ts +1 -0
- package/scripts/agent/agent-role-profile.ts +1 -1
- package/scripts/behavior/behavior-rules.ts +8 -1
- package/scripts/build/build-claude-md.ts +13 -4
- package/scripts/ci/pr-classification.ts +11 -2
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/gate-steps.ts +1 -1
- package/scripts/gate/project-checks.ts +12 -11
- package/scripts/gate/type-check-command.ts +1 -1
- package/scripts/git/git-branch.ts +14 -3
- package/scripts/git/git-command.ts +5 -2
- package/scripts/git/git-issue.ts +13 -5
- package/scripts/git/git-pr.ts +16 -10
- package/scripts/git/git-preflight.ts +138 -0
- package/scripts/git/telegram-notify.ts +21 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/basic-path-migration.ts +24 -0
- package/scripts/init/distributed-paths.ts +22 -0
- package/scripts/init/init-actions.ts +27 -19
- package/scripts/init/init-ai-copy.ts +86 -28
- package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
- package/scripts/init/init-bootstrap.ts +4 -3
- package/scripts/init/init-install.ts +1 -1
- package/scripts/init/init-logic-templates.ts +1 -1
- package/scripts/init/init-logic.ts +1 -1
- package/scripts/init/init.ts +41 -19
- package/scripts/init/kit-setup-state.ts +30 -0
- package/scripts/init/kit-written-paths.ts +33 -0
- package/scripts/init/project-profile-cli.ts +15 -1
- package/scripts/init/project-profile.ts +41 -12
- package/scripts/init/session-language-environment.ts +83 -0
- package/scripts/init/start-exec.ts +28 -0
- package/scripts/init/start-plan.ts +35 -13
- package/scripts/init/start-prompt.ts +22 -8
- package/scripts/init/start-setup-pr.ts +138 -0
- package/scripts/init/start-steps.ts +30 -30
- package/scripts/init/start.ts +7 -3
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
- package/scripts/josh/josh-commands-development.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/lane/lane-leftover.ts +143 -0
- package/scripts/lane/lane-open.ts +14 -1
- package/scripts/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- package/scripts/propagate/propagate-steps.ts +14 -1
- package/scripts/rules/filing-cap.ts +1 -0
- package/scripts/rules/issue-citation.ts +30 -1
- package/scripts/rules/last-prompt.ts +79 -0
- package/scripts/rules/stop-rules.ts +5 -3
- package/scripts/run/run-hold.ts +3 -3
- package/scripts/run/run-preflight.ts +75 -5
- package/scripts/run/run-progress-cli.ts +14 -2
- package/scripts/safe-chain/ci-installer-pin.ts +19 -12
- package/scripts/safe-chain/project-config.ts +1 -1
- package/scripts/sync/sync-ai-files.ts +204 -0
- package/scripts/sync/sync.ts +48 -184
- package/scripts/test/josh-harness-environment.ts +1 -1
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/test/test-declared-changed.ts +1 -1
- package/scripts/test/test-declared-logic.ts +55 -14
- package/scripts/test/test-declared.ts +23 -11
- package/scripts/test/test-unit-guard.ts +6 -6
- package/scripts/time-runtime/time-shell.ts +36 -2
- package/scripts-ai/git-workflow.ts +10 -0
- package/templates/CLAUDE.basic.md +8 -0
- package/templates/workflows/ci.yml +27 -0
- /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
- /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
- /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
- /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
|
@@ -3,12 +3,26 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import { project_profile } from './project-profile'
|
|
4
4
|
|
|
5
5
|
const ARGUMENT_START_INDEX = 2
|
|
6
|
+
// A `ci.yml` synced before joshuafolkken/kit#2829 matches `*'profile: static ('*` on this output to
|
|
7
|
+
// hand a basic project to `josh gate`. A consumer can upgrade kit without re-syncing that workflow,
|
|
8
|
+
// so a basic project keeps printing the old phrase on a line of its own until every workflow reads
|
|
9
|
+
// the new name.
|
|
10
|
+
const LEGACY_BASIC_LINE = 'compat: profile: static (the name before kit#2829)'
|
|
11
|
+
|
|
12
|
+
function describe(result: { profile: string; reason: string }): string {
|
|
13
|
+
const line = `profile: ${result.profile} (${result.reason})`
|
|
14
|
+
|
|
15
|
+
return result.profile === 'basic' ? `${line}\n${LEGACY_BASIC_LINE}` : line
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
function main(): void {
|
|
8
19
|
const requested = project_profile.requested_profile(process.argv.slice(ARGUMENT_START_INDEX))
|
|
9
20
|
const result = project_profile.resolve_profile(process.cwd(), requested)
|
|
10
21
|
|
|
11
|
-
console.info(
|
|
22
|
+
console.info(describe(result))
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
if (process.argv[1] === fileURLToPath(import.meta.url)) main()
|
|
26
|
+
|
|
27
|
+
const project_profile_cli = { describe }
|
|
28
|
+
export { project_profile_cli }
|
|
@@ -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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
111
|
+
return { profile: 'full', reason: 'package dependencies' }
|
|
89
112
|
}
|
|
90
113
|
|
|
91
114
|
if (has_build_script(manifest['scripts'])) {
|
|
92
|
-
return { profile: '
|
|
115
|
+
return { profile: 'full', reason: 'build or dev script' }
|
|
93
116
|
}
|
|
94
117
|
|
|
95
|
-
return { profile: '
|
|
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: '
|
|
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 = {
|
|
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
|
-
//
|
|
57
|
-
//
|
|
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
|
|
88
|
+
return with_setup_pr(['initialize', 'repository', 'labels'], state)
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
// An existing GitHub origin is never replaced
|
|
71
|
-
//
|
|
72
|
-
// remote that already exists, so the run
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
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 }
|