@joshuafolkken/kit 1.947.0 → 1.957.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/README.md +4 -5
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-V7RH56HU.js → chunk-244WYOEO.js} +2 -2
- package/dist/hooks/{chunk-IA7WC4NS.js → chunk-3ON7HOYB.js} +1 -1
- package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-BELI5XSS.js} +6 -2
- package/dist/hooks/{chunk-IBFC25IX.js → chunk-GNOITLRR.js} +4 -4
- package/dist/hooks/{chunk-M4U4YNRE.js → chunk-HJXQZ7V4.js} +3 -3
- package/dist/hooks/{chunk-3FWV6CFC.js → chunk-IJPBTDTB.js} +371 -178
- package/dist/hooks/{chunk-NKVLBB65.js → chunk-KOCK5FXQ.js} +3 -3
- package/dist/hooks/{chunk-O3PEDF3M.js → chunk-LDQDZYHX.js} +1 -1
- package/dist/hooks/{chunk-GIX5SAAB.js → chunk-M6K7EQDO.js} +8 -8
- package/dist/hooks/{chunk-OM5VDM6F.js → chunk-XSL4SRMA.js} +2 -2
- 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 +3 -3
- package/dist/josh.js +6 -2
- package/dist/version/index.js +7 -4
- package/package.json +1 -1
- package/scripts/backlog/backlog-drive-cli.ts +5 -1
- package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
- package/scripts/backlog/backlog-drive.ts +7 -3
- package/scripts/git/git-worktree.ts +35 -4
- package/scripts/git/issue-labels.ts +19 -0
- package/scripts/git/repository-lock.ts +138 -0
- package/scripts/git/stash-sweep-lock.ts +6 -117
- package/scripts/init/project-profile.ts +9 -5
- package/scripts/init/start-plan.ts +145 -0
- package/scripts/init/start-prompt.ts +55 -0
- package/scripts/init/start-steps.ts +167 -0
- package/scripts/init/start.ts +92 -16
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
- package/scripts/josh/josh-commands-project.ts +6 -2
- package/scripts/run/run-retrospective.ts +34 -0
- package/scripts/run/run-step-cli.ts +3 -5
- package/scripts/run/run-step.ts +3 -15
- package/scripts/safe-chain/project-config.ts +55 -10
- package/scripts/sync/sync.ts +3 -1
- package/scripts/version/release-age.ts +11 -6
|
@@ -1,123 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { linkSync, renameSync, rmSync } from 'node:fs'
|
|
3
|
-
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
-
import { process_identity } from '#scripts/josh/process-identity'
|
|
5
|
-
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
6
|
-
import { git_common_directory } from './git-common-directory'
|
|
1
|
+
import { repository_lock } from './repository-lock'
|
|
7
2
|
|
|
8
3
|
// One stash sweep at a time per repository (joshuafolkken/kit#2701). The stash is a single stack every
|
|
9
4
|
// work tree shares, and a `backlogrun` runs one sweep per lane seat at once: two sweeps that each
|
|
10
5
|
// resolve the same entry to `stash@{n}` and drop it would have the second drop land on whichever entry
|
|
11
|
-
// moved into that position, and two that carry the same ledger lines would both append them. The
|
|
12
|
-
//
|
|
6
|
+
// moved into that position, and two that carry the same ledger lines would both append them. The
|
|
7
|
+
// locking itself is `repository-lock.ts`, shared with the work-tree writes (joshuafolkken/kit#2736).
|
|
13
8
|
|
|
14
9
|
const LOCK_PREFIX = 'josh-stash-sweep-lock-'
|
|
15
10
|
// The locked phase is local git and one file append per entry, so a holder finishes in well under the
|
|
16
11
|
// wait; the cap only bounds a holder that hung, and a sweep that gives up keeps its entries.
|
|
17
|
-
const POLL_INTERVAL_MS = 200
|
|
18
12
|
const MAX_WAIT_MS = 30_000
|
|
19
13
|
|
|
20
|
-
interface LockOwner {
|
|
21
|
-
pid: number
|
|
22
|
-
process_start?: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
14
|
function lock_path(cwd: string = process.cwd()): string {
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type IsGone = (owner: LockOwner) => boolean
|
|
30
|
-
|
|
31
|
-
function parse_owner(raw: string | undefined): LockOwner | undefined {
|
|
32
|
-
if (raw === undefined) return undefined
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
return JSON.parse(raw) as LockOwner
|
|
36
|
-
} catch {
|
|
37
|
-
return undefined
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function read_owner(target: string): LockOwner | undefined {
|
|
42
|
-
return parse_owner(stamp_file.read_stamp_text(target))
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function is_owner_gone(owner: LockOwner): boolean {
|
|
46
|
-
return process_identity.is_same_process(owner.pid, owner.process_start) === false
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Put back a record that turned out not to be the stale one. `linkSync` refuses an existing path, so
|
|
50
|
-
// a lock someone claimed in the meantime is never overwritten.
|
|
51
|
-
function restore(aside: string, target: string): void {
|
|
52
|
-
try {
|
|
53
|
-
linkSync(aside, target)
|
|
54
|
-
} catch {
|
|
55
|
-
// Another claim already holds the path; the record set aside is dropped below.
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// The record's text when its owner is certainly gone, else `undefined`.
|
|
60
|
-
function stale_record(target: string, is_gone: IsGone): string | undefined {
|
|
61
|
-
const raw = stamp_file.read_stamp_text(target)
|
|
62
|
-
const owner = parse_owner(raw)
|
|
63
|
-
|
|
64
|
-
return owner !== undefined && is_gone(owner) ? raw : undefined
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// The path the record was moved to, or `undefined` when another sweep moved it first.
|
|
68
|
-
function move_aside(target: string): string | undefined {
|
|
69
|
-
const aside = `${target}.${String(process.pid)}.${randomUUID()}`
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
renameSync(target, aside)
|
|
73
|
-
|
|
74
|
-
return aside
|
|
75
|
-
} catch {
|
|
76
|
-
return undefined
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// A record whose process is certainly gone is a crashed sweep's, and is cleared before the claim; an
|
|
81
|
-
// unreadable record or an uncertain answer is left in place, since clearing a live holder's lock is the
|
|
82
|
-
// very race the lock exists to close. **The clear is a rename, then a compare**: two sweeps can both
|
|
83
|
-
// judge the same record stale, and the slower one's plain remove would delete the lock the faster one
|
|
84
|
-
// had just claimed. Renaming moves exactly one file aside atomically, and only a record byte-identical
|
|
85
|
-
// to the one judged stale is discarded — any other is a fresh claim and goes back.
|
|
86
|
-
function clear_stale(target: string, is_gone: IsGone = is_owner_gone): void {
|
|
87
|
-
const raw = stale_record(target, is_gone)
|
|
88
|
-
|
|
89
|
-
if (raw === undefined) return
|
|
90
|
-
|
|
91
|
-
const aside = move_aside(target)
|
|
92
|
-
|
|
93
|
-
if (aside === undefined) return
|
|
94
|
-
if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target)
|
|
95
|
-
rmSync(aside, { force: true })
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function claim(target: string): boolean {
|
|
99
|
-
clear_stale(target)
|
|
100
|
-
|
|
101
|
-
return stamp_file.create_stamp(target, process_identity.own_fields())
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function release(target: string): void {
|
|
105
|
-
const owner = read_owner(target)
|
|
106
|
-
|
|
107
|
-
if (owner !== undefined && process_identity.is_own_process(owner.pid, owner.process_start)) {
|
|
108
|
-
stamp_file.remove_stamp(target)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function acquire(target: string, max_wait_ms: number): Promise<boolean> {
|
|
113
|
-
const deadline = Date.now() + max_wait_ms
|
|
114
|
-
|
|
115
|
-
while (!claim(target)) {
|
|
116
|
-
if (Date.now() >= deadline) return false
|
|
117
|
-
await sleep(POLL_INTERVAL_MS)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return true
|
|
15
|
+
return repository_lock.lock_path(LOCK_PREFIX, cwd)
|
|
121
16
|
}
|
|
122
17
|
|
|
123
18
|
// Runs `work` holding the lock and answers its result, or `undefined` without running it when another
|
|
@@ -127,15 +22,9 @@ async function with_lock<T>(
|
|
|
127
22
|
target: string = lock_path(),
|
|
128
23
|
max_wait_ms: number = MAX_WAIT_MS,
|
|
129
24
|
): Promise<T | undefined> {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
return await work()
|
|
134
|
-
} finally {
|
|
135
|
-
release(target)
|
|
136
|
-
}
|
|
25
|
+
return await repository_lock.with_lock(work, target, max_wait_ms)
|
|
137
26
|
}
|
|
138
27
|
|
|
139
|
-
const stash_sweep_lock = { clear_stale, lock_path, with_lock }
|
|
28
|
+
const stash_sweep_lock = { clear_stale: repository_lock.clear_stale, lock_path, with_lock }
|
|
140
29
|
|
|
141
30
|
export { stash_sweep_lock }
|
|
@@ -29,12 +29,16 @@ function parse_profile(value: unknown): ProjectProfile | undefined {
|
|
|
29
29
|
return undefined
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function
|
|
33
|
-
|
|
32
|
+
function is_profile_pair(args: ReadonlyArray<string>): boolean {
|
|
33
|
+
return args.length === PROFILE_ARG_LENGTH && args[0] === '--profile'
|
|
34
|
+
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
function requested_profile(
|
|
37
|
+
args: ReadonlyArray<string>,
|
|
38
|
+
usage = 'josh init [--profile static|node]',
|
|
39
|
+
): ProjectProfile | undefined {
|
|
40
|
+
if (args.length === 0) return undefined
|
|
41
|
+
if (!is_profile_pair(args)) throw new Error(`Usage: ${usage}`)
|
|
38
42
|
|
|
39
43
|
const profile = parse_profile(args[1])
|
|
40
44
|
if (profile !== undefined) return profile
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ProjectProfile } from './project-profile'
|
|
2
|
+
|
|
3
|
+
type Visibility = 'private' | 'public'
|
|
4
|
+
|
|
5
|
+
interface StartOptions {
|
|
6
|
+
profile: ProjectProfile | undefined
|
|
7
|
+
is_yes: boolean
|
|
8
|
+
is_github: boolean
|
|
9
|
+
visibility: Visibility
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type StartStep = 'git_init' | 'initialize' | 'commit' | 'repository' | 'labels'
|
|
13
|
+
|
|
14
|
+
interface GitState {
|
|
15
|
+
has_git: boolean
|
|
16
|
+
has_github: boolean
|
|
17
|
+
has_origin: boolean
|
|
18
|
+
branch: string | undefined
|
|
19
|
+
has_commits: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface StepPlan {
|
|
23
|
+
steps: ReadonlyArray<StartStep>
|
|
24
|
+
refusal: string | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Interaction = 'interactive' | 'unattended'
|
|
28
|
+
type Consent = 'granted' | 'ask'
|
|
29
|
+
|
|
30
|
+
interface Verdict<T> {
|
|
31
|
+
value: T | undefined
|
|
32
|
+
refusal: string | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_BRANCH = 'main'
|
|
36
|
+
const FULL_STEPS: ReadonlyArray<StartStep> = [
|
|
37
|
+
'git_init',
|
|
38
|
+
'initialize',
|
|
39
|
+
'commit',
|
|
40
|
+
'repository',
|
|
41
|
+
'labels',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const STEP_LABELS: Readonly<Record<StartStep, string>> = {
|
|
45
|
+
git_init: 'Create a Git repository on main',
|
|
46
|
+
initialize: 'Initialize kit (the same setup as josh init)',
|
|
47
|
+
commit: 'Commit every file as the initial commit',
|
|
48
|
+
repository: 'Create the GitHub repository and push main',
|
|
49
|
+
labels: 'Add the workflow labels the repository is missing',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function refused(refusal: string): StepPlan {
|
|
53
|
+
return { steps: [], refusal }
|
|
54
|
+
}
|
|
55
|
+
|
|
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.
|
|
58
|
+
function existing_git_steps(state: GitState): StepPlan {
|
|
59
|
+
if (!state.has_commits) return { steps: FULL_STEPS.slice(1), refusal: undefined }
|
|
60
|
+
|
|
61
|
+
if (state.branch !== DEFAULT_BRANCH) {
|
|
62
|
+
return refused(
|
|
63
|
+
`josh start pushes ${DEFAULT_BRANCH}, but this repository is on ${state.branch ?? 'a detached HEAD'}. Switch to ${DEFAULT_BRANCH}, or run josh init to set up without GitHub.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { steps: ['initialize', 'repository', 'labels'], refusal: undefined }
|
|
68
|
+
}
|
|
69
|
+
|
|
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.
|
|
73
|
+
function plan_steps(state: GitState): StepPlan {
|
|
74
|
+
if (state.has_github) return { steps: ['initialize', 'labels'], refusal: undefined }
|
|
75
|
+
|
|
76
|
+
if (state.has_origin) {
|
|
77
|
+
return refused(
|
|
78
|
+
'josh start creates the GitHub repository as origin, but this repository already has an origin that is not on GitHub. Remove or rename it, or run josh init to set up without GitHub.',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (state.has_git) return existing_git_steps(state)
|
|
83
|
+
|
|
84
|
+
return { steps: FULL_STEPS, refusal: undefined }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
|
|
88
|
+
if (options.is_yes) return { value: 'unattended', refusal: undefined }
|
|
89
|
+
if (is_tty) return { value: 'interactive', refusal: undefined }
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
value: undefined,
|
|
93
|
+
refusal:
|
|
94
|
+
'josh start needs a terminal to confirm its choices. Pass --yes to accept the detected profile, and --github to create the GitHub repository.',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `--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.
|
|
100
|
+
function github_consent(
|
|
101
|
+
steps: ReadonlyArray<StartStep>,
|
|
102
|
+
options: StartOptions,
|
|
103
|
+
interaction: Interaction,
|
|
104
|
+
): Verdict<Consent> {
|
|
105
|
+
if (!steps.includes('repository') || options.is_github) {
|
|
106
|
+
return { value: 'granted', refusal: undefined }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
value: undefined,
|
|
113
|
+
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.',
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// What a failed run prints, so the user can tell how far it got before deciding how to resume.
|
|
119
|
+
function progress_report(
|
|
120
|
+
failed: StartStep,
|
|
121
|
+
completed: ReadonlyArray<StartStep>,
|
|
122
|
+
cause: string,
|
|
123
|
+
): string {
|
|
124
|
+
const done =
|
|
125
|
+
completed.length === 0 ? 'nothing' : completed.map((step) => STEP_LABELS[step]).join('; ')
|
|
126
|
+
|
|
127
|
+
return `josh start stopped at: ${STEP_LABELS[failed]}\nCompleted: ${done}\nCause: ${cause}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function plan_summary(steps: ReadonlyArray<StartStep>): string {
|
|
131
|
+
const lines = steps.map((step, index) => ` ${String(index + 1)}. ${STEP_LABELS[step]}`)
|
|
132
|
+
|
|
133
|
+
return ['josh start will:', ...lines].join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const start_plan = {
|
|
137
|
+
plan_steps,
|
|
138
|
+
interaction_of,
|
|
139
|
+
github_consent,
|
|
140
|
+
progress_report,
|
|
141
|
+
plan_summary,
|
|
142
|
+
STEP_LABELS,
|
|
143
|
+
}
|
|
144
|
+
export { start_plan }
|
|
145
|
+
export type { Consent, GitState, Interaction, StartOptions, StartStep, StepPlan, Visibility }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
3
|
+
import { createInterface, type Interface } from 'node:readline/promises'
|
|
4
|
+
import { ask_yes_no_simple } from '#scripts/git/git-prompt'
|
|
5
|
+
import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
|
|
6
|
+
import type { Consent, StartOptions } from './start-plan'
|
|
7
|
+
|
|
8
|
+
interface Choices {
|
|
9
|
+
options: StartOptions
|
|
10
|
+
shape: ProjectShape
|
|
11
|
+
consent: Consent
|
|
12
|
+
root: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DECLINED =
|
|
16
|
+
'Stopped before creating the GitHub repository. Nothing was changed. Run josh init to set up without GitHub.'
|
|
17
|
+
|
|
18
|
+
// The detected profile is the default, so pressing Enter accepts it; the reason is shown because an
|
|
19
|
+
// ambiguous project is exactly the case this question exists for.
|
|
20
|
+
async function ask_profile(prompt: Interface, shape: ProjectShape): Promise<ProjectProfile> {
|
|
21
|
+
const question = `Profile — static or node [${shape.profile}, ${shape.reason}]: `
|
|
22
|
+
const raw_answer = await prompt.question(question)
|
|
23
|
+
const answer = raw_answer.trim()
|
|
24
|
+
if (answer.length === 0) return shape.profile
|
|
25
|
+
|
|
26
|
+
return project_profile.requested_profile(['--profile', answer]) ?? shape.profile
|
|
27
|
+
}
|
|
28
|
+
|
|
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
|
+
|
|
33
|
+
if (!(await ask_yes_no_simple(prompt, question))) throw new Error(DECLINED)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function ask_choices(prompt: Interface, choices: Choices): Promise<ProjectProfile> {
|
|
37
|
+
const profile = choices.options.profile ?? (await ask_profile(prompt, choices.shape))
|
|
38
|
+
if (choices.consent === 'ask') await ask_repository(prompt, choices)
|
|
39
|
+
|
|
40
|
+
return profile
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function confirm_choices(choices: Choices): Promise<ProjectProfile> {
|
|
44
|
+
const prompt = createInterface({ input, output })
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return await ask_choices(prompt, choices)
|
|
48
|
+
} finally {
|
|
49
|
+
prompt.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const start_prompt = { confirm_choices }
|
|
54
|
+
export { start_prompt }
|
|
55
|
+
export type { Choices }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
3
|
+
import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
|
|
4
|
+
import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
|
|
5
|
+
import { execaSync } from 'execa'
|
|
6
|
+
import { main as init_main } from './init'
|
|
7
|
+
import { PACKAGE_DIR } from './init-paths'
|
|
8
|
+
import type { ProjectProfile, ProjectShape } from './project-profile'
|
|
9
|
+
import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
|
|
10
|
+
|
|
11
|
+
interface StepContext {
|
|
12
|
+
root: string
|
|
13
|
+
profile: ProjectProfile
|
|
14
|
+
visibility: Visibility
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
|
|
18
|
+
|
|
19
|
+
const DEFAULT_BRANCH = 'main'
|
|
20
|
+
const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
|
|
21
|
+
const GH_INSTALL_HINT =
|
|
22
|
+
'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
|
|
23
|
+
const GH_AUTH_HINT =
|
|
24
|
+
'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
|
|
25
|
+
|
|
26
|
+
function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
|
|
27
|
+
return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function read_output(
|
|
31
|
+
command: string,
|
|
32
|
+
args: ReadonlyArray<string>,
|
|
33
|
+
root: string,
|
|
34
|
+
): string | undefined {
|
|
35
|
+
const result = execaSync(command, args, { cwd: root, reject: false })
|
|
36
|
+
|
|
37
|
+
return result.exitCode === 0 ? result.stdout.trim() : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
|
|
41
|
+
// other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
|
|
42
|
+
function run(command: string, args: ReadonlyArray<string>, root: string): void {
|
|
43
|
+
execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function read_git_state(root: string, shape: ProjectShape): GitState {
|
|
47
|
+
const { has_git, has_github } = shape
|
|
48
|
+
|
|
49
|
+
if (!has_git) {
|
|
50
|
+
return { has_git, has_github, has_origin: false, branch: undefined, has_commits: false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
|
|
54
|
+
const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
|
|
55
|
+
const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
|
|
56
|
+
|
|
57
|
+
return { has_git, has_github, has_origin, branch, has_commits }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every step talks to GitHub, so the CLI is checked before the first write rather than at the step
|
|
61
|
+
// that needs it — a missing `gh` then leaves the directory exactly as it was.
|
|
62
|
+
function github_cli_refusal(root: string): string | undefined {
|
|
63
|
+
if (!succeeds('gh', ['--version'], root)) return GH_INSTALL_HINT
|
|
64
|
+
if (!succeeds('gh', ['auth', 'status'], root)) return GH_AUTH_HINT
|
|
65
|
+
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `josh init` refuses to run inside the kit source repository by printing and setting the exit code
|
|
70
|
+
// rather than throwing, so its step would pass and the later steps would commit and publish the
|
|
71
|
+
// source repository. The same check is raised before the first write instead.
|
|
72
|
+
function self_run_refusal(root: string): string | undefined {
|
|
73
|
+
return self_sync_guard.self_sync_refusal(PACKAGE_DIR, root)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function create_git_repository(context: StepContext): void {
|
|
77
|
+
run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], context.root)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The same setup `josh init` runs, called rather than copied, with the profile the user confirmed.
|
|
81
|
+
function initialize(context: StepContext): void {
|
|
82
|
+
init_main(['--profile', context.profile])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function commit_all(context: StepContext): void {
|
|
86
|
+
run('git', ['add', '--all'], context.root)
|
|
87
|
+
// The first commit of a new repository has no branch to come from, so the hook that keeps commits
|
|
88
|
+
// off main — installed by the initialize step in a node project — would refuse the only commit
|
|
89
|
+
// that has to land there.
|
|
90
|
+
run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
|
|
91
|
+
// A repository created by an older `git init` may name its unborn branch `master`.
|
|
92
|
+
run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], context.root)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function create_github_repository(context: StepContext): void {
|
|
96
|
+
const name = path.basename(context.root)
|
|
97
|
+
const visibility = `--${context.visibility}`
|
|
98
|
+
const source = ['--source', '.', '--remote', 'origin', '--push']
|
|
99
|
+
|
|
100
|
+
run('gh', ['repo', 'create', name, visibility, ...source], context.root)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Labels already present are left as they are — only the missing ones are created, so a repository
|
|
104
|
+
// that recolored a label keeps its choice. GitHub compares label names case-insensitively.
|
|
105
|
+
function missing_workflow_labels(existing: ReadonlyArray<string>): ReadonlyArray<WorkflowLabel> {
|
|
106
|
+
const names = new Set(existing.map((name) => name.toLowerCase()))
|
|
107
|
+
|
|
108
|
+
return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function create_label(label: WorkflowLabel, root: string): void {
|
|
112
|
+
const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
|
|
113
|
+
|
|
114
|
+
run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
|
|
115
|
+
console.info(` ✔ label ${label.name}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function add_missing_labels(context: StepContext): void {
|
|
119
|
+
const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
|
|
120
|
+
...git_gh_exec.direct_environment(),
|
|
121
|
+
cwd: context.root,
|
|
122
|
+
})
|
|
123
|
+
const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
|
|
124
|
+
|
|
125
|
+
for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
|
|
129
|
+
git_init: create_git_repository,
|
|
130
|
+
initialize,
|
|
131
|
+
commit: commit_all,
|
|
132
|
+
repository: create_github_repository,
|
|
133
|
+
labels: add_missing_labels,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function run_step(
|
|
137
|
+
step: StartStep,
|
|
138
|
+
context: StepContext,
|
|
139
|
+
completed: ReadonlyArray<StartStep>,
|
|
140
|
+
): void {
|
|
141
|
+
try {
|
|
142
|
+
STEP_ACTIONS[step](context)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const cause = error instanceof Error ? error.message : String(error)
|
|
145
|
+
|
|
146
|
+
throw new Error(start_plan.progress_report(step, completed, cause), { cause: error })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): void {
|
|
151
|
+
for (const [index, step] of steps.entries()) {
|
|
152
|
+
const position = `${String(index + 1)}/${String(steps.length)}`
|
|
153
|
+
|
|
154
|
+
console.info(`\n[${position}] ${start_plan.STEP_LABELS[step]}`)
|
|
155
|
+
run_step(step, context, steps.slice(0, index))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const start_steps = {
|
|
160
|
+
read_git_state,
|
|
161
|
+
github_cli_refusal,
|
|
162
|
+
self_run_refusal,
|
|
163
|
+
missing_workflow_labels,
|
|
164
|
+
run_steps,
|
|
165
|
+
}
|
|
166
|
+
export { start_steps }
|
|
167
|
+
export type { StepContext }
|
package/scripts/init/start.ts
CHANGED
|
@@ -1,30 +1,106 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { project_profile, type ProjectProfile } from './project-profile'
|
|
4
|
+
import {
|
|
5
|
+
start_plan,
|
|
6
|
+
type Consent,
|
|
7
|
+
type Interaction,
|
|
8
|
+
type StartOptions,
|
|
9
|
+
type StartStep,
|
|
10
|
+
} from './start-plan'
|
|
11
|
+
import { start_prompt, type Choices } from './start-prompt'
|
|
12
|
+
import { start_steps } from './start-steps'
|
|
5
13
|
|
|
6
14
|
const ARGUMENT_START_INDEX = 2
|
|
15
|
+
const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
|
|
16
|
+
const START_USAGE = 'josh start [--profile static|node] [--yes] [--github] [--public]'
|
|
17
|
+
const YES_FLAG = '--yes'
|
|
18
|
+
const GITHUB_FLAG = '--github'
|
|
19
|
+
const PUBLIC_FLAG = '--public'
|
|
20
|
+
const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG])
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
interface Prepared {
|
|
23
|
+
steps: ReadonlyArray<StartStep>
|
|
24
|
+
interaction: Interaction
|
|
25
|
+
choices: Choices
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The switches are peeled off first, so what remains is exactly the `--profile` pair `josh init`
|
|
29
|
+
// accepts — one parser for the profile in both commands.
|
|
30
|
+
function parse_start_options(args: ReadonlyArray<string>): StartOptions {
|
|
31
|
+
const profile_args = args.filter((argument) => !SWITCHES.has(argument))
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
profile: project_profile.requested_profile(profile_args, START_USAGE),
|
|
35
|
+
is_yes: args.includes(YES_FLAG),
|
|
36
|
+
is_github: args.includes(GITHUB_FLAG),
|
|
37
|
+
visibility: args.includes(PUBLIC_FLAG) ? 'public' : 'private',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function settle<T>(verdict: { value: T | undefined; refusal: string | undefined }): T {
|
|
42
|
+
if (verdict.value === undefined) throw new Error(verdict.refusal)
|
|
11
43
|
|
|
12
|
-
return
|
|
44
|
+
return verdict.value
|
|
13
45
|
}
|
|
14
46
|
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
function stop_on(refusal: string | undefined): void {
|
|
48
|
+
if (refusal !== undefined) throw new Error(refusal)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// An unattended run already holds every answer: `--profile` or the detected one, and a consent that
|
|
52
|
+
// `github_consent` only grants when `--github` was given.
|
|
53
|
+
async function resolve_profile(
|
|
54
|
+
choices: Choices,
|
|
55
|
+
interaction: Interaction,
|
|
56
|
+
): Promise<ProjectProfile> {
|
|
57
|
+
if (interaction === 'unattended') return choices.options.profile ?? choices.shape.profile
|
|
58
|
+
|
|
59
|
+
return await start_prompt.confirm_choices(choices)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every refusal is raised here, before the first write, so a run that stops here changed nothing.
|
|
63
|
+
function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
|
|
64
|
+
stop_on(start_steps.self_run_refusal(root))
|
|
65
|
+
const options = parse_start_options(args)
|
|
66
|
+
const interaction = settle(start_plan.interaction_of(options, is_tty))
|
|
67
|
+
const shape = project_profile.inspect_project(root, options.profile)
|
|
68
|
+
const plan = start_plan.plan_steps(start_steps.read_git_state(root, shape))
|
|
69
|
+
|
|
70
|
+
stop_on(plan.refusal)
|
|
71
|
+
const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
|
|
72
|
+
|
|
73
|
+
stop_on(start_steps.github_cli_refusal(root))
|
|
74
|
+
|
|
75
|
+
return { steps: plan.steps, interaction, choices: { options, shape, consent, root } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
|
|
79
|
+
const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
|
|
80
|
+
|
|
81
|
+
console.info(start_plan.plan_summary(steps))
|
|
82
|
+
const profile = await resolve_profile(choices, interaction)
|
|
83
|
+
const { root, options } = choices
|
|
84
|
+
|
|
85
|
+
start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
|
|
86
|
+
console.info(`\n${READY_MESSAGE}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
|
|
90
|
+
try {
|
|
91
|
+
await main(args, is_tty)
|
|
92
|
+
|
|
93
|
+
return 0
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
96
|
+
|
|
97
|
+
return 1
|
|
98
|
+
}
|
|
23
99
|
}
|
|
24
100
|
|
|
25
101
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
26
|
-
|
|
102
|
+
process.exitCode = await run(process.argv.slice(ARGUMENT_START_INDEX), process.stdin.isTTY)
|
|
27
103
|
}
|
|
28
104
|
|
|
29
|
-
const start = {
|
|
105
|
+
const start = { run, parse_start_options }
|
|
30
106
|
export { start }
|
|
@@ -1103,12 +1103,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
|
|
|
1103
1103
|
],
|
|
1104
1104
|
],
|
|
1105
1105
|
"start": [
|
|
1106
|
-
"[--profile static|node]",
|
|
1106
|
+
"[--profile static|node] [--yes] [--github] [--public]",
|
|
1107
1107
|
"developer",
|
|
1108
1108
|
[
|
|
1109
1109
|
"files",
|
|
1110
1110
|
"git",
|
|
1111
1111
|
"network",
|
|
1112
|
+
"processes",
|
|
1112
1113
|
],
|
|
1113
1114
|
],
|
|
1114
1115
|
"stash:pop": [
|