@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.
Files changed (41) hide show
  1. package/README.md +4 -5
  2. package/dist/hooks/batch-guard.js +4 -4
  3. package/dist/hooks/{chunk-V7RH56HU.js → chunk-244WYOEO.js} +2 -2
  4. package/dist/hooks/{chunk-IA7WC4NS.js → chunk-3ON7HOYB.js} +1 -1
  5. package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-BELI5XSS.js} +6 -2
  6. package/dist/hooks/{chunk-IBFC25IX.js → chunk-GNOITLRR.js} +4 -4
  7. package/dist/hooks/{chunk-M4U4YNRE.js → chunk-HJXQZ7V4.js} +3 -3
  8. package/dist/hooks/{chunk-3FWV6CFC.js → chunk-IJPBTDTB.js} +371 -178
  9. package/dist/hooks/{chunk-NKVLBB65.js → chunk-KOCK5FXQ.js} +3 -3
  10. package/dist/hooks/{chunk-O3PEDF3M.js → chunk-LDQDZYHX.js} +1 -1
  11. package/dist/hooks/{chunk-GIX5SAAB.js → chunk-M6K7EQDO.js} +8 -8
  12. package/dist/hooks/{chunk-OM5VDM6F.js → chunk-XSL4SRMA.js} +2 -2
  13. package/dist/hooks/codex-hook-adapter.js +10 -10
  14. package/dist/hooks/duplicate-read-guard.js +5 -5
  15. package/dist/hooks/format-edited.js +2 -2
  16. package/dist/hooks/investigation-guard.js +5 -5
  17. package/dist/hooks/pretool-guard.js +9 -9
  18. package/dist/hooks/stop-guard.js +3 -3
  19. package/dist/josh.js +6 -2
  20. package/dist/version/index.js +7 -4
  21. package/package.json +1 -1
  22. package/scripts/backlog/backlog-drive-cli.ts +5 -1
  23. package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
  24. package/scripts/backlog/backlog-drive.ts +7 -3
  25. package/scripts/git/git-worktree.ts +35 -4
  26. package/scripts/git/issue-labels.ts +19 -0
  27. package/scripts/git/repository-lock.ts +138 -0
  28. package/scripts/git/stash-sweep-lock.ts +6 -117
  29. package/scripts/init/project-profile.ts +9 -5
  30. package/scripts/init/start-plan.ts +145 -0
  31. package/scripts/init/start-prompt.ts +55 -0
  32. package/scripts/init/start-steps.ts +167 -0
  33. package/scripts/init/start.ts +92 -16
  34. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  35. package/scripts/josh/josh-commands-project.ts +6 -2
  36. package/scripts/run/run-retrospective.ts +34 -0
  37. package/scripts/run/run-step-cli.ts +3 -5
  38. package/scripts/run/run-step.ts +3 -15
  39. package/scripts/safe-chain/project-config.ts +55 -10
  40. package/scripts/sync/sync.ts +3 -1
  41. package/scripts/version/release-age.ts +11 -6
@@ -1,123 +1,18 @@
1
- import { randomUUID } from 'node:crypto'
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 lock
12
- // is keyed on the git common directory, so every work tree of one repository contends for one record.
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 stamp_file.stamp_path(LOCK_PREFIX, git_common_directory.repository(cwd) ?? cwd)
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
- if (!(await acquire(target, max_wait_ms))) return undefined
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 requested_profile(args: ReadonlyArray<string>): ProjectProfile | undefined {
33
- if (args.length === 0) return undefined
32
+ function is_profile_pair(args: ReadonlyArray<string>): boolean {
33
+ return args.length === PROFILE_ARG_LENGTH && args[0] === '--profile'
34
+ }
34
35
 
35
- if (args.length !== PROFILE_ARG_LENGTH || args[0] !== '--profile') {
36
- throw new Error('Usage: josh init [--profile static|node]')
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 }
@@ -1,30 +1,106 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { main as init_main } from './init'
4
- import { project_profile, type ProjectShape } from './project-profile'
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
- function prerequisite(shape: ProjectShape): string | undefined {
9
- if (!shape.has_git) return 'josh start requires Git. Run git init, or use josh init without Git.'
10
- if (!shape.has_github) return 'josh start requires a GitHub origin. Add one, or use josh init.'
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 undefined
44
+ return verdict.value
13
45
  }
14
46
 
15
- function main(args: ReadonlyArray<string> = []): void {
16
- // --profile uses the same override as josh init.
17
- const requested = project_profile.requested_profile(args)
18
- const shape = project_profile.inspect_project(process.cwd(), requested)
19
- const message = prerequisite(shape)
20
- if (message !== undefined) throw new Error(message)
21
- init_main(args)
22
- console.info('GitHub workflow ready. Use kickoff in your assistant to plan an Issue.')
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
- main(process.argv.slice(ARGUMENT_START_INDEX))
102
+ process.exitCode = await run(process.argv.slice(ARGUMENT_START_INDEX), process.stdin.isTTY)
27
103
  }
28
104
 
29
- const start = { prerequisite }
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": [