@joshuafolkken/kit 1.1435.0 → 1.1453.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-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +1 -1
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/README.md +15 -17
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +241 -240
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +6 -6
- package/dist/josh.js +33 -10
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/uninstall/distributed-files.ts +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
constants,
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
|
|
15
|
+
import { resolve_tsx_runner } from '#scripts/josh/josh-logic'
|
|
16
|
+
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
17
|
+
import { execaSync } from 'execa'
|
|
18
|
+
import { init_bootstrap } from './init-bootstrap'
|
|
19
|
+
import { init_dry_run_report, type FileChange } from './init-dry-run-report'
|
|
20
|
+
import { init_install } from './init-install'
|
|
21
|
+
import { PACKAGE_DIR, package_path } from './init-paths'
|
|
22
|
+
import { project_profile, type ProjectProfile } from './project-profile'
|
|
23
|
+
import { start_exec } from './start-exec'
|
|
24
|
+
|
|
25
|
+
// `josh init --dry-run` and `josh start --dry-run` answer "what would the setup change?" without
|
|
26
|
+
// a second copy of the setup's decisions: the real `josh init` runs on a scratch copy of the project,
|
|
27
|
+
// and the plan is the difference it left there. Every create, merge and skip is decided by the code
|
|
28
|
+
// that decides it for real, so the plan cannot drift from the run (#3830).
|
|
29
|
+
|
|
30
|
+
interface DryRunRequest {
|
|
31
|
+
root: string
|
|
32
|
+
profile: ProjectProfile | undefined
|
|
33
|
+
// Whether the setup meets a repository — `josh start` creates one before its setup runs.
|
|
34
|
+
has_git: boolean
|
|
35
|
+
is_install: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DryRunSplit {
|
|
39
|
+
is_dry_run: boolean
|
|
40
|
+
rest: ReadonlyArray<string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DRY_RUN_FLAG = '--dry-run'
|
|
44
|
+
const INIT_SCRIPT = 'scripts/init/init.ts'
|
|
45
|
+
const SCRATCH_PREFIX = 'josh-init-dry-run-'
|
|
46
|
+
const DEFAULT_BRANCH = 'main'
|
|
47
|
+
const TITLE = 'josh init --dry-run — nothing was written. The setup would:'
|
|
48
|
+
|
|
49
|
+
function split_dry_run_flag(args: ReadonlyArray<string>): DryRunSplit {
|
|
50
|
+
return {
|
|
51
|
+
is_dry_run: args.includes(DRY_RUN_FLAG),
|
|
52
|
+
rest: args.filter((argument) => argument !== DRY_RUN_FLAG),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A directory is copied unless the project scan skips it: an ignored one, or a link — followed, a
|
|
57
|
+
// link to the project or an ancestor would copy it into itself until the path is too long.
|
|
58
|
+
function is_copied_directory(source: string): boolean {
|
|
59
|
+
return (
|
|
60
|
+
!lstatSync(source).isSymbolicLink() &&
|
|
61
|
+
!project_profile.is_ignored_directory(path.basename(source))
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A link to a file is copied as the file it points to, so no write in the scratch copy can follow a
|
|
66
|
+
// link back into the project; a dangling one is left out, as the project scan leaves out what it
|
|
67
|
+
// cannot read. The root itself is always copied: `cpSync` filters it too, so a project in a
|
|
68
|
+
// directory named `build` or `dist` would otherwise be planned as an empty one.
|
|
69
|
+
function is_copied(root: string, source: string): boolean {
|
|
70
|
+
if (source === root) return true
|
|
71
|
+
if (!existsSync(source)) return false
|
|
72
|
+
|
|
73
|
+
return !statSync(source).isDirectory() || is_copied_directory(source)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The scratch repository answers the setup's git questions as the project's does: one exists, and
|
|
77
|
+
// its origin is the same URL, so a GitHub project is planned as one.
|
|
78
|
+
function mirror_git(root: string, scratch: string): void {
|
|
79
|
+
start_exec.git_succeeds(['init', '--quiet', `--initial-branch=${DEFAULT_BRANCH}`], scratch)
|
|
80
|
+
const origin = git_spawn_sync.origin_url(root)
|
|
81
|
+
|
|
82
|
+
if (origin !== undefined) start_exec.git_succeeds(['remote', 'add', 'origin', origin], scratch)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function prepare_scratch(root: string, has_git: boolean): string {
|
|
86
|
+
const scratch = mkdtempSync(path.join(os.tmpdir(), SCRATCH_PREFIX))
|
|
87
|
+
|
|
88
|
+
// A copy-on-write clone where the file system supports one, so mirroring a large project costs
|
|
89
|
+
// next to nothing there; a plain copy elsewhere.
|
|
90
|
+
cpSync(root, scratch, {
|
|
91
|
+
recursive: true,
|
|
92
|
+
dereference: true,
|
|
93
|
+
mode: constants.COPYFILE_FICLONE,
|
|
94
|
+
filter: (source) => is_copied(root, source),
|
|
95
|
+
})
|
|
96
|
+
if (has_git) mirror_git(root, scratch)
|
|
97
|
+
|
|
98
|
+
return scratch
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function digest_of(file: string): string {
|
|
102
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A snapshot holds each file's digest, not its content, so a project with large files is compared
|
|
106
|
+
// without holding them in memory; only the files the setup changed are read in full.
|
|
107
|
+
function snapshot(root: string): Map<string, string> {
|
|
108
|
+
const files = project_profile.list_files(root)
|
|
109
|
+
|
|
110
|
+
return new Map(files.map((file) => [path.relative(root, file), digest_of(file)]))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function content_of(
|
|
114
|
+
root: string,
|
|
115
|
+
file: string,
|
|
116
|
+
digests: ReadonlyMap<string, string>,
|
|
117
|
+
): string | undefined {
|
|
118
|
+
return digests.has(file) ? readFileSync(path.join(root, file), 'utf8') : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The project itself still holds what the scratch copy held before the setup ran.
|
|
122
|
+
function changes_since(
|
|
123
|
+
request: DryRunRequest,
|
|
124
|
+
scratch: string,
|
|
125
|
+
before: ReadonlyMap<string, string>,
|
|
126
|
+
): Array<FileChange> {
|
|
127
|
+
const after = snapshot(scratch)
|
|
128
|
+
|
|
129
|
+
return init_dry_run_report.changed_files(before, after).map((change) => ({
|
|
130
|
+
path: change.path,
|
|
131
|
+
before: content_of(request.root, change.path, before),
|
|
132
|
+
after: content_of(scratch, change.path, after),
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `--no-install` keeps the install out of the scratch copy, and the hand-off variable keeps this
|
|
137
|
+
// kit doing the setup itself — the scratch copy has no kit of its own to hand it to.
|
|
138
|
+
function run_init(scratch: string, profile: ProjectProfile | undefined): void {
|
|
139
|
+
const runner = resolve_tsx_runner()
|
|
140
|
+
const profile_args = profile === undefined ? [] : ['--profile', profile]
|
|
141
|
+
const args = [package_path(INIT_SCRIPT), init_install.NO_INSTALL_FLAG, ...profile_args]
|
|
142
|
+
const result = execaSync(runner.executable, [...runner.leading_arguments, ...args], {
|
|
143
|
+
cwd: scratch,
|
|
144
|
+
env: { [init_bootstrap.HANDOFF_ENV]: '1' },
|
|
145
|
+
all: true,
|
|
146
|
+
reject: false,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
if (result.exitCode !== 0) throw new Error(`the simulated josh init failed:\n${result.all}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function simulate(request: DryRunRequest): Array<FileChange> {
|
|
153
|
+
const scratch = prepare_scratch(request.root, request.has_git)
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const before = snapshot(scratch)
|
|
157
|
+
|
|
158
|
+
run_init(scratch, request.profile)
|
|
159
|
+
|
|
160
|
+
return changes_since(request, scratch, before)
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The same two questions the real run asks: whether to install at all, and whether this kit is the
|
|
167
|
+
// project's own or hands the setup to the one it installs.
|
|
168
|
+
function dependency_lines(root: string, is_install: boolean): Array<string> {
|
|
169
|
+
if (!is_install) return [`skip the install and the format (${init_install.NO_INSTALL_FLAG})`]
|
|
170
|
+
const steps = `run ${init_install.INSTALL_STEP.label}, then ${init_install.FORMAT_STEP.label}`
|
|
171
|
+
|
|
172
|
+
if (init_bootstrap.is_project_kit(PACKAGE_DIR, root)) return [steps]
|
|
173
|
+
|
|
174
|
+
return [`install ${KIT_PACKAGE_NAME} into the project and hand the setup to its josh init`, steps]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function plan_report(request: DryRunRequest, title: string = TITLE): string {
|
|
178
|
+
const changes = simulate(request)
|
|
179
|
+
|
|
180
|
+
return init_dry_run_report.format_report(
|
|
181
|
+
title,
|
|
182
|
+
changes,
|
|
183
|
+
dependency_lines(request.root, request.is_install),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const init_dry_run = { DRY_RUN_FLAG, split_dry_run_flag, plan_report }
|
|
188
|
+
export { init_dry_run }
|
|
189
|
+
export type { DryRunRequest }
|
|
@@ -62,6 +62,13 @@ function run_post_init_steps(project_root: string): string | undefined {
|
|
|
62
62
|
return undefined
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const init_install = {
|
|
65
|
+
const init_install = {
|
|
66
|
+
NO_INSTALL_FLAG,
|
|
67
|
+
INSTALL_STEP,
|
|
68
|
+
FORMAT_STEP,
|
|
69
|
+
split_install_flag,
|
|
70
|
+
did_step_succeed,
|
|
71
|
+
run_post_init_steps,
|
|
72
|
+
}
|
|
66
73
|
export { init_install }
|
|
67
74
|
export type { InstallStep }
|
package/scripts/init/init.ts
CHANGED
|
@@ -19,20 +19,27 @@ import { init_actions, PRETTIER_CONFIG_JS } from './init-actions'
|
|
|
19
19
|
import { init_ai_copy } from './init-ai-copy'
|
|
20
20
|
import { init_basic } from './init-basic'
|
|
21
21
|
import { init_bootstrap } from './init-bootstrap'
|
|
22
|
+
import { init_dry_run } from './init-dry-run'
|
|
22
23
|
import { init_file_action } from './init-file-action'
|
|
23
24
|
import { init_install } from './init-install'
|
|
24
25
|
import { init_logic } from './init-logic'
|
|
25
26
|
import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
|
|
26
|
-
import { kit_setup_state } from './kit-setup-state'
|
|
27
27
|
import { package_manager_pin } from './package-manager-pin'
|
|
28
28
|
import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
|
|
29
|
+
import { working_tree } from './working-tree'
|
|
29
30
|
|
|
30
31
|
const PACKAGE_JSON = 'package.json'
|
|
31
32
|
const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
|
|
32
33
|
const LEFTHOOK_BIN = 'lefthook'
|
|
33
34
|
const ARGUMENT_START_INDEX = 2
|
|
34
35
|
const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
|
|
35
|
-
const INIT_USAGE = 'josh init [--profile basic|full] [--no-install]'
|
|
36
|
+
const INIT_USAGE = 'josh init [--profile basic|full] [--no-install] [--dry-run]'
|
|
37
|
+
|
|
38
|
+
interface InitRequest {
|
|
39
|
+
is_dry_run: boolean
|
|
40
|
+
is_install: boolean
|
|
41
|
+
requested: ProjectProfile | undefined
|
|
42
|
+
}
|
|
36
43
|
|
|
37
44
|
const engine_pin_schema = z.looseObject({ name: z.string(), version: z.string() })
|
|
38
45
|
|
|
@@ -265,14 +272,21 @@ function set_up_project(requested: ProjectProfile | undefined, is_install: boole
|
|
|
265
272
|
console.info('\n✅ Done.\n')
|
|
266
273
|
}
|
|
267
274
|
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return false
|
|
272
|
-
const { is_install, rest } = init_install.split_install_flag(args)
|
|
273
|
-
// Validated before the hand-off, so a malformed invocation is refused before kit is installed.
|
|
274
|
-
const requested = project_profile.requested_profile(rest, INIT_USAGE)
|
|
275
|
+
// A dry run sets nothing up, so it answers false and points nowhere onward.
|
|
276
|
+
function print_dry_run({ requested, is_install }: InitRequest): false {
|
|
277
|
+
const has_git = project_profile.has_git(PROJECT_ROOT)
|
|
275
278
|
|
|
279
|
+
console.info(
|
|
280
|
+
init_dry_run.plan_report({ root: PROJECT_ROOT, profile: requested, has_git, is_install }),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function did_set_up_here(
|
|
287
|
+
{ requested, is_install }: InitRequest,
|
|
288
|
+
args: ReadonlyArray<string>,
|
|
289
|
+
): boolean {
|
|
276
290
|
const is_set_up_here = !is_install || !did_hand_off(args)
|
|
277
291
|
|
|
278
292
|
if (is_set_up_here) set_up_project(requested, is_install)
|
|
@@ -280,19 +294,43 @@ function main(args: ReadonlyArray<string> = []): boolean {
|
|
|
280
294
|
return is_set_up_here
|
|
281
295
|
}
|
|
282
296
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
297
|
+
// The flags are peeled off first, so what remains is exactly the `--profile` pair.
|
|
298
|
+
// Validated before the hand-off, so a malformed invocation is refused before kit is installed.
|
|
299
|
+
function parse_init_args(args: ReadonlyArray<string>): InitRequest {
|
|
300
|
+
const { is_dry_run, rest: run_args } = init_dry_run.split_dry_run_flag(args)
|
|
301
|
+
const { is_install, rest } = init_install.split_install_flag(run_args)
|
|
302
|
+
|
|
303
|
+
return { is_dry_run, is_install, requested: project_profile.requested_profile(rest, INIT_USAGE) }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// True when this process set the project up — false when it refused, or handed the run to the
|
|
307
|
+
// project's own kit, which then speaks for itself.
|
|
308
|
+
function main(args: ReadonlyArray<string> = []): boolean {
|
|
309
|
+
if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return false
|
|
310
|
+
const request = parse_init_args(args)
|
|
289
311
|
|
|
290
|
-
|
|
312
|
+
return request.is_dry_run ? print_dry_run(request) : did_set_up_here(request, args)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Only the command a person typed is refused, before anything is written or installed. `josh start`
|
|
316
|
+
// calls `main` itself and decides about its own tree (#3835); a handed-off run follows the check
|
|
317
|
+
// its caller already passed, on a tree the kit install it just made has changed.
|
|
318
|
+
function did_refuse_dirty_tree(): boolean {
|
|
319
|
+
const refusal = init_bootstrap.is_handed_off()
|
|
320
|
+
? undefined
|
|
321
|
+
: working_tree.dirty_tree_refusal(PROJECT_ROOT, 'josh init')
|
|
322
|
+
|
|
323
|
+
if (refusal === undefined) return false
|
|
324
|
+
console.error(`\n${refusal}\n`)
|
|
325
|
+
process.exitCode = 1
|
|
326
|
+
|
|
327
|
+
return true
|
|
291
328
|
}
|
|
292
329
|
|
|
293
330
|
function run_cli(args: ReadonlyArray<string>): void {
|
|
294
331
|
try {
|
|
295
|
-
if (
|
|
332
|
+
if (did_refuse_dirty_tree()) return
|
|
333
|
+
main(args)
|
|
296
334
|
} catch (error) {
|
|
297
335
|
console.error(`\n✖ ${error_text.message_of(error)}\n`)
|
|
298
336
|
process.exitCode = 1
|
|
@@ -312,5 +350,5 @@ const init = {
|
|
|
312
350
|
}
|
|
313
351
|
|
|
314
352
|
// `init` is the entry point; `main` is exported for its own unit test, the way `sync.ts` exports the
|
|
315
|
-
// `main()` its self-run guard lives in.
|
|
316
|
-
export { init, main }
|
|
353
|
+
// `main()` its self-run guard lives in, and `run_cli` for the refusal only the typed command makes.
|
|
354
|
+
export { init, main, run_cli }
|
|
@@ -1,30 +1,24 @@
|
|
|
1
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
1
2
|
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
2
3
|
import { start_exec } from './start-exec'
|
|
4
|
+
import { workflow_mark } from './workflow-mark'
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Whether the commit checked out holds `josh start`'s setup — its manifest lists kit and records the
|
|
7
|
+
// GitHub workflow mark — the sign that the setup has already reached the branch, so `josh start` has
|
|
8
|
+
// no setup pull request to open. Kit alone is not enough: a committed `josh init` setup lacks the mark
|
|
9
|
+
// and, on `basic`, `.claude/settings.json`, which `josh start`'s setup then writes and only a setup
|
|
10
|
+
// pull request carries to `main` (#3835).
|
|
9
11
|
function is_kit_committed(root: string): boolean {
|
|
10
12
|
// `./` resolves the path from `root` rather than from the repository's top level.
|
|
11
13
|
const manifest = start_exec.git_read(['show', 'HEAD:./package.json'], root)
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function is_git_work_tree(root: string): boolean {
|
|
17
|
-
return start_exec.git_succeeds(['rev-parse', '--is-inside-work-tree'], root)
|
|
18
|
-
}
|
|
15
|
+
if (manifest?.includes(`"${KIT_PACKAGE_NAME}"`) !== true) return false
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
// still uncommitted, and `josh start` is what carries it to GitHub.
|
|
22
|
-
function start_hint(root: string): string | undefined {
|
|
23
|
-
if (!is_git_work_tree(root) || is_kit_committed(root)) return undefined
|
|
17
|
+
const parsed = json_value.parse_or_undefined(manifest)
|
|
24
18
|
|
|
25
|
-
return
|
|
19
|
+
return workflow_mark.is_recorded(json_value.is_record(parsed) ? parsed : undefined)
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
const kit_setup_state = { is_kit_committed
|
|
22
|
+
const kit_setup_state = { is_kit_committed }
|
|
29
23
|
|
|
30
24
|
export { kit_setup_state }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// The configuration files of a formatter other than Prettier. A `basic` project holding one already
|
|
5
|
+
// formats its own files, so `josh init` gives it no Prettier and `josh format` / `josh gate` skip it
|
|
6
|
+
// (joshuafolkken/kit#3832).
|
|
7
|
+
const OWN_FORMATTER_CONFIGS = [
|
|
8
|
+
'biome.json',
|
|
9
|
+
'biome.jsonc',
|
|
10
|
+
'dprint.json',
|
|
11
|
+
'dprint.jsonc',
|
|
12
|
+
'.dprint.json',
|
|
13
|
+
'.dprint.jsonc',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function find_config(root: string): string | undefined {
|
|
17
|
+
return OWN_FORMATTER_CONFIGS.find((name) => existsSync(path.join(root, name)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const own_formatter = { find_config }
|
|
21
|
+
export { own_formatter }
|
|
@@ -5,6 +5,7 @@ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
|
|
|
5
5
|
import { json_value } from '#scripts/lib/json-value'
|
|
6
6
|
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
7
7
|
import { kit_written_paths } from './kit-written-paths'
|
|
8
|
+
import { own_formatter } from './own-formatter'
|
|
8
9
|
import { workflow_mark } from './workflow-mark'
|
|
9
10
|
|
|
10
11
|
type ProjectProfile = 'basic' | 'full'
|
|
@@ -15,7 +16,8 @@ interface ProfileResult {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
interface ProjectShape extends ProfileResult {
|
|
18
|
-
|
|
19
|
+
// Web files, and no formatter of the project's own (`own-formatter.ts`).
|
|
20
|
+
should_use_prettier: boolean
|
|
19
21
|
has_typescript: boolean
|
|
20
22
|
has_git: boolean
|
|
21
23
|
has_github: boolean
|
|
@@ -127,8 +129,12 @@ function resolve_profile(root: string, requested?: ProjectProfile): ProfileResul
|
|
|
127
129
|
return inferred_profile(manifest)
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
function is_ignored_directory(name: string): boolean {
|
|
133
|
+
return IGNORED_DIRECTORIES.has(name)
|
|
134
|
+
}
|
|
135
|
+
|
|
130
136
|
function should_visit(entry: Dirent): boolean {
|
|
131
|
-
return entry.isDirectory() && !
|
|
137
|
+
return entry.isDirectory() && !is_ignored_directory(entry.name)
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
function scan_directory(directory: string, pending: Array<string>): Array<string> {
|
|
@@ -152,7 +158,9 @@ function is_project_file(root: string, file: string): boolean {
|
|
|
152
158
|
return !kit_written_paths.is_kit_written(relative_path)
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
|
|
161
|
+
// Every file under `root` outside the directories no setup reads — the one walk the project scan and
|
|
162
|
+
// the `josh init --dry-run` snapshot share.
|
|
163
|
+
function list_files(root: string): Array<string> {
|
|
156
164
|
const pending = [root]
|
|
157
165
|
const files: Array<string> = []
|
|
158
166
|
|
|
@@ -160,15 +168,24 @@ function file_extensions(root: string): Array<string> {
|
|
|
160
168
|
files.push(...scan_directory(directory, pending))
|
|
161
169
|
}
|
|
162
170
|
|
|
163
|
-
return files
|
|
171
|
+
return files
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function file_extensions(root: string): Array<string> {
|
|
175
|
+
return list_files(root)
|
|
176
|
+
.filter((file) => is_project_file(root, file))
|
|
177
|
+
.map((file) => path.extname(file))
|
|
164
178
|
}
|
|
165
179
|
|
|
166
|
-
|
|
180
|
+
// A project that formats with its own tool gets no Prettier even with Web files: two formatters
|
|
181
|
+
// would rewrite each other's output (joshuafolkken/kit#3832).
|
|
182
|
+
function scan_files(root: string): { should_use_prettier: boolean; has_typescript: boolean } {
|
|
167
183
|
const extensions = file_extensions(root)
|
|
168
184
|
const has_web = extensions.some((extension) => WEB_EXTENSIONS.has(extension))
|
|
185
|
+
const should_use_prettier = has_web && own_formatter.find_config(root) === undefined
|
|
169
186
|
const has_typescript = extensions.some((extension) => TYPESCRIPT_EXTENSIONS.has(extension))
|
|
170
187
|
|
|
171
|
-
return {
|
|
188
|
+
return { should_use_prettier, has_typescript }
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
function has_github_remote(root: string): boolean {
|
|
@@ -205,6 +222,8 @@ const project_profile = {
|
|
|
205
222
|
resolve_profile,
|
|
206
223
|
has_git,
|
|
207
224
|
inspect_project,
|
|
225
|
+
is_ignored_directory,
|
|
226
|
+
list_files,
|
|
208
227
|
requested_profile,
|
|
209
228
|
with_legacy_profile_names,
|
|
210
229
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { repository_labels } from '#scripts/repo/repository-labels'
|
|
3
|
+
import { init_dry_run } from './init-dry-run'
|
|
4
|
+
import type { ProjectProfile } from './project-profile'
|
|
5
|
+
import type { StartStep } from './start-plan'
|
|
6
|
+
import type { Choices } from './start-prompt'
|
|
7
|
+
import { start_setup_pr } from './start-setup-pr'
|
|
8
|
+
import { workflow_mark } from './workflow-mark'
|
|
9
|
+
|
|
10
|
+
// What `josh start --dry-run` adds to the plan `josh start` prints: what each GitHub step would
|
|
11
|
+
// create, and what its setup step would write. Nothing here creates, pushes or writes (#3830).
|
|
12
|
+
|
|
13
|
+
const TITLE = 'josh start --dry-run — nothing was written. Its setup step would:'
|
|
14
|
+
const NONE = '(none)'
|
|
15
|
+
const UNREAD_LABELS =
|
|
16
|
+
'add the workflow labels it lacks — the repository’s labels could not be read to name them'
|
|
17
|
+
|
|
18
|
+
// The labels are read from the repository when it already exists — the same listing the real step
|
|
19
|
+
// compares against — and a repository this run would create has none.
|
|
20
|
+
function label_line(steps: ReadonlyArray<StartStep>): string {
|
|
21
|
+
const repo = steps.includes('repository') ? undefined : '{owner}/{repo}'
|
|
22
|
+
const labels = repository_labels.planned_labels(repo)
|
|
23
|
+
|
|
24
|
+
if (labels === undefined) return UNREAD_LABELS
|
|
25
|
+
|
|
26
|
+
return `add the labels it lacks: ${labels.length === 0 ? NONE : labels.join(', ')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function github_lines({ steps, root, options }: Choices): Array<string> {
|
|
30
|
+
const lines: Readonly<Partial<Record<StartStep, string | undefined>>> = {
|
|
31
|
+
repository: `create the ${options.visibility} repository ${path.basename(root)} as origin, and push main`,
|
|
32
|
+
labels: steps.includes('labels') ? label_line(steps) : undefined,
|
|
33
|
+
setup_pr: `open the Issue "${start_setup_pr.SETUP_TITLE}" (or reuse the open one), push a branch named after it, and open a pull request into main`,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return steps.map((step) => lines[step]).filter((line) => line !== undefined)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function github_report(choices: Choices): string {
|
|
40
|
+
const lines = github_lines(choices).map((line) => ` ${line}`)
|
|
41
|
+
|
|
42
|
+
return ['GitHub:', ...(lines.length === 0 ? [` ${NONE}`] : lines)].join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A caller's setup command writes files kit cannot name, so it is named rather than simulated. Kit's
|
|
46
|
+
// own setup runs on a scratch copy, told it sets up the GitHub workflow as the real step tells it,
|
|
47
|
+
// with the repository the run would have created by then.
|
|
48
|
+
function setup_report({ steps, root, options, shape }: Choices): string | undefined {
|
|
49
|
+
if (!steps.includes('initialize')) return undefined
|
|
50
|
+
const profile: ProjectProfile = options.profile ?? shape.profile
|
|
51
|
+
|
|
52
|
+
if (options.init_command !== undefined) {
|
|
53
|
+
return `The setup step would run ${options.init_command} --profile ${profile}, which is not simulated.`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
workflow_mark.request()
|
|
57
|
+
const has_git = shape.has_git || steps.includes('git_init')
|
|
58
|
+
|
|
59
|
+
return init_dry_run.plan_report({ root, profile, has_git, is_install: true }, TITLE)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function report(choices: Choices): string {
|
|
63
|
+
const setup = setup_report(choices)
|
|
64
|
+
const sections = [github_report(choices), ...(setup === undefined ? [] : [setup])]
|
|
65
|
+
|
|
66
|
+
return `\n${sections.join('\n\n')}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const start_dry_run = { report }
|
|
70
|
+
export { start_dry_run }
|
|
@@ -7,6 +7,7 @@ interface StartOptions {
|
|
|
7
7
|
profile: ProjectProfile | undefined
|
|
8
8
|
is_yes: boolean
|
|
9
9
|
is_github: boolean
|
|
10
|
+
is_dry_run: boolean
|
|
10
11
|
visibility: Visibility
|
|
11
12
|
init_command: string | undefined
|
|
12
13
|
push_preflight_command: string | undefined
|
|
@@ -120,8 +121,9 @@ function has_commit(steps: ReadonlyArray<StartStep>): boolean {
|
|
|
120
121
|
return steps.some((step) => COMMIT_STEPS.has(step))
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// A dry run asks nothing and changes nothing, so it needs neither a terminal nor `--yes`.
|
|
123
125
|
function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
|
|
124
|
-
if (options.is_yes) return { value: 'unattended', refusal: undefined }
|
|
126
|
+
if (options.is_yes || options.is_dry_run) return { value: 'unattended', refusal: undefined }
|
|
125
127
|
if (is_tty) return { value: 'interactive', refusal: undefined }
|
|
126
128
|
|
|
127
129
|
return {
|
|
@@ -133,13 +135,16 @@ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interac
|
|
|
133
135
|
|
|
134
136
|
// `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
|
|
135
137
|
// unattended run creates a repository, pushes, or opens the setup Issue and pull request only when
|
|
136
|
-
// `--github` asks for it by name.
|
|
138
|
+
// `--github` asks for it by name. A dry run writes to GitHub no more than it writes anywhere else, so
|
|
139
|
+
// it lists the GitHub steps without that consent.
|
|
137
140
|
function github_consent(
|
|
138
141
|
steps: ReadonlyArray<StartStep>,
|
|
139
142
|
options: StartOptions,
|
|
140
143
|
interaction: Interaction,
|
|
141
144
|
): Verdict<Consent> {
|
|
142
|
-
|
|
145
|
+
const is_consented = options.is_github || options.is_dry_run
|
|
146
|
+
|
|
147
|
+
if (is_consented || !has_push(steps)) return { value: 'granted', refusal: undefined }
|
|
143
148
|
if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
|
|
144
149
|
|
|
145
150
|
return {
|
|
@@ -9,28 +9,19 @@ import { IGNORE_FOR_RELEASE_LABEL } from '#scripts/issue/issue-labels'
|
|
|
9
9
|
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
10
10
|
import { kit_written_paths } from './kit-written-paths'
|
|
11
11
|
import { start_exec } from './start-exec'
|
|
12
|
+
import { working_tree } from './working-tree'
|
|
12
13
|
|
|
13
14
|
const SETUP_TITLE = `Set up ${KIT_PACKAGE_NAME}`
|
|
14
15
|
const SETUP_BODY = [
|
|
15
16
|
`Adds the files \`josh start\` wrote while setting up ${KIT_PACKAGE_NAME}, so main carries the`,
|
|
16
17
|
'rules, settings and workflows every later Issue is run with. Only the files the setup wrote are in',
|
|
17
|
-
'the pull request
|
|
18
|
+
'the pull request: `josh start` runs only on a working tree with nothing uncommitted.',
|
|
18
19
|
].join('\n')
|
|
19
20
|
const NAME_ONLY = '--name-only'
|
|
20
21
|
const NOTHING_TO_COMMIT = 'The kit setup has no changes to commit, so no pull request was opened.'
|
|
21
22
|
const OPEN_SETUP_ISSUES_QUERY = `?state=open&labels=${IGNORE_FOR_RELEASE_LABEL}&per_page=100`
|
|
22
23
|
const FIRST_SETUP_ISSUE_FILTER = `[.[] | select(.title == "${SETUP_TITLE}") | .number][0] // empty`
|
|
23
24
|
|
|
24
|
-
// Listings are read NUL-separated (`-z`), so a path is never quoted — a name a caller's initialize
|
|
25
|
-
// command wrote need not be ASCII the way kit's own are (#2872).
|
|
26
|
-
function paths_of(output: string | undefined = ''): Array<string> {
|
|
27
|
-
return output.split('\0').filter((listed) => listed.length > 0)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function listing(args: ReadonlyArray<string>, root: string): Array<string> {
|
|
31
|
-
return paths_of(start_exec.git_read([...args, '-z'], root))
|
|
32
|
-
}
|
|
33
|
-
|
|
34
25
|
function setup_issue(issue_number: string): IssueInfo {
|
|
35
26
|
return git_issue.parse(`${SETUP_TITLE} ${issue_cite.plain(issue_number)}`)
|
|
36
27
|
}
|
|
@@ -49,19 +40,10 @@ function is_setup_branch(branch: string | undefined): boolean {
|
|
|
49
40
|
return setup_branch_number(branch) !== undefined
|
|
50
41
|
}
|
|
51
42
|
|
|
52
|
-
// Untracked files the ignore rules let through, plus tracked files the setup changed — two plain
|
|
53
|
-
// listings, so nothing has to be parsed out of `git status`.
|
|
54
|
-
function changed_paths(root: string): Array<string> {
|
|
55
|
-
const untracked = listing(['ls-files', '--others', '--exclude-standard'], root)
|
|
56
|
-
const modified = listing(['diff', NAME_ONLY, 'HEAD'], root)
|
|
57
|
-
|
|
58
|
-
return [...untracked, ...modified]
|
|
59
|
-
}
|
|
60
|
-
|
|
61
43
|
// On the setup branch a failed commit left, that run's `git add` already staged its files, so they
|
|
62
44
|
// read as changed before this run's command ran — the staged ones count as setup output (#2872).
|
|
63
45
|
function carried_paths(root: string, current: string | undefined): Array<string> {
|
|
64
|
-
return is_setup_branch(current) ? listing(['diff', NAME_ONLY, '--cached'], root) : []
|
|
46
|
+
return is_setup_branch(current) ? working_tree.listing(['diff', NAME_ONLY, '--cached'], root) : []
|
|
65
47
|
}
|
|
66
48
|
|
|
67
49
|
// The Issue carries the release classification the pull request is opened with, so the PR step finds
|
|
@@ -124,13 +106,15 @@ function commit_kit_paths(paths: ReadonlyArray<string>, issue: IssueInfo, root:
|
|
|
124
106
|
|
|
125
107
|
// A caller's initialize command writes files kit cannot name, so with a `baseline` — what was already
|
|
126
108
|
// changed before that command ran — everything changed since joins kit's own files, while a change
|
|
127
|
-
// the user had made before the run still stays out (#2872).
|
|
109
|
+
// the user had made before the run still stays out (#2872). `josh start` refuses a dirty tree on
|
|
110
|
+
// `main` (#3835), so the baseline there is empty; it still keeps the two apart on the setup branch a
|
|
111
|
+
// failed commit left, where the re-run starts with that run's output uncommitted.
|
|
128
112
|
function setup_paths(
|
|
129
113
|
root: string,
|
|
130
114
|
baseline: ReadonlyArray<string> | undefined,
|
|
131
115
|
current: string | undefined,
|
|
132
116
|
): Array<string> {
|
|
133
|
-
const changed = changed_paths(root)
|
|
117
|
+
const changed = working_tree.changed_paths(root)
|
|
134
118
|
const kit_paths = kit_written_paths.select(changed)
|
|
135
119
|
|
|
136
120
|
if (baseline === undefined) return kit_paths
|
|
@@ -161,6 +145,11 @@ async function open(root: string, baseline?: ReadonlyArray<string>): Promise<voi
|
|
|
161
145
|
await git_pr.create_with_issue_info(issue)
|
|
162
146
|
}
|
|
163
147
|
|
|
164
|
-
const start_setup_pr = {
|
|
148
|
+
const start_setup_pr = {
|
|
149
|
+
SETUP_TITLE,
|
|
150
|
+
open,
|
|
151
|
+
changed_paths: working_tree.changed_paths,
|
|
152
|
+
is_setup_branch,
|
|
153
|
+
}
|
|
165
154
|
|
|
166
155
|
export { start_setup_pr }
|