@joshuafolkken/kit 1.1435.0 → 1.1455.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-handoff-report.md +1 -1
- 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 +3 -2
- 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/prrun.md +2 -1
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/CLAUDE.md +1 -1
- package/README.md +10 -19
- package/dist/CLAUDE.md +1 -1
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
- 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 +229 -226
- 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 +64 -11
- package/dist/josh.js +34 -11
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/collaboration-workflow/shell-body.md +1 -1
- 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/gh/git-pr-ai-review.ts +12 -1
- package/scripts/gh/git-pr-confirmation.ts +11 -7
- 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/josh-commands-workflow.ts +1 -1
- 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/notify/confirmation-options.ts +59 -0
- package/scripts/notify/notify-opt-in.ts +33 -0
- package/scripts/notify/telegram-notify.ts +25 -8
- package/scripts/notify/telegram-test-logic.ts +33 -1
- package/scripts/notify/telegram-test.ts +4 -2
- 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/rules/shell-body-trigger.ts +3 -2
- package/scripts/rules/stop-rules.ts +2 -1
- package/scripts/run/entry/run-entry-stop.ts +6 -3
- package/scripts/run/run-stop-notify.ts +2 -0
- package/scripts/uninstall/distributed-files.ts +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
2
|
+
|
|
3
|
+
type ChangeKind = 'create' | 'merge' | 'overwrite' | 'delete'
|
|
4
|
+
|
|
5
|
+
interface FileChange {
|
|
6
|
+
path: string
|
|
7
|
+
before: string | undefined
|
|
8
|
+
after: string | undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type JsonRecord = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
const PACKAGE_JSON = 'package.json'
|
|
14
|
+
const JSON_EXTENSION = '.json'
|
|
15
|
+
const KIND_WIDTH = 'overwrite'.length
|
|
16
|
+
const NO_CHANGES = ' (no file changes)'
|
|
17
|
+
|
|
18
|
+
// Every path whose content differs between the two snapshots, sorted, so a run reads the same twice.
|
|
19
|
+
function changed_files(
|
|
20
|
+
before: ReadonlyMap<string, string>,
|
|
21
|
+
after: ReadonlyMap<string, string>,
|
|
22
|
+
): Array<FileChange> {
|
|
23
|
+
const paths = [...new Set([...before.keys(), ...after.keys()])].toSorted((left, right) =>
|
|
24
|
+
left.localeCompare(right),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return paths
|
|
28
|
+
.filter((file) => before.get(file) !== after.get(file))
|
|
29
|
+
.map((file) => ({ path: file, before: before.get(file), after: after.get(file) }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A changed file is a merge when every line it had survives — the project's own content is kept —
|
|
33
|
+
// and an overwrite when any of its lines is replaced or removed.
|
|
34
|
+
function keeps_every_line(before: string, after: string): boolean {
|
|
35
|
+
const remaining = new Set(after.split('\n'))
|
|
36
|
+
|
|
37
|
+
return before.split('\n').every((line) => remaining.has(line))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function is_kept_array(before: ReadonlyArray<unknown>, after: unknown): boolean {
|
|
41
|
+
if (!Array.isArray(after)) return false
|
|
42
|
+
const remaining = new Set(after.map((value) => JSON.stringify(value)))
|
|
43
|
+
|
|
44
|
+
return before.every((value) => remaining.has(JSON.stringify(value)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A JSON file is rewritten whole when kit adds to it — `package.json` written out again, a list grown — so
|
|
48
|
+
// it is compared by value: a merge when every value it had is still there, wherever the lines moved.
|
|
49
|
+
function is_kept(before: unknown, after: unknown): boolean {
|
|
50
|
+
if (Array.isArray(before)) return is_kept_array(before, after)
|
|
51
|
+
if (!json_value.is_record(before) || !json_value.is_record(after)) return before === after
|
|
52
|
+
|
|
53
|
+
return Object.keys(before).every((key) => is_kept(before[key], after[key]))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Undefined when either side is not plain JSON (a `tsconfig.json` with comments), which falls back
|
|
57
|
+
// to the line test.
|
|
58
|
+
function keeps_every_value(before: string, after: string): boolean | undefined {
|
|
59
|
+
const old_value = json_value.parse_or_undefined(before)
|
|
60
|
+
const new_value = json_value.parse_or_undefined(after)
|
|
61
|
+
if (old_value === undefined || new_value === undefined) return undefined
|
|
62
|
+
|
|
63
|
+
return is_kept(old_value, new_value)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function keeps_content(file: string, before: string, after: string): boolean {
|
|
67
|
+
const is_kept_by_value = file.endsWith(JSON_EXTENSION)
|
|
68
|
+
? keeps_every_value(before, after)
|
|
69
|
+
: undefined
|
|
70
|
+
|
|
71
|
+
return is_kept_by_value ?? keeps_every_line(before, after)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function change_kind(change: FileChange): ChangeKind {
|
|
75
|
+
if (change.before === undefined) return 'create'
|
|
76
|
+
if (change.after === undefined) return 'delete'
|
|
77
|
+
|
|
78
|
+
return keeps_content(change.path, change.before, change.after) ? 'merge' : 'overwrite'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parse_record(content: string | undefined): JsonRecord {
|
|
82
|
+
const parsed = content === undefined ? undefined : json_value.parse_or_undefined(content)
|
|
83
|
+
|
|
84
|
+
return json_value.is_record(parsed) ? parsed : {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function changed_keys(before: JsonRecord, after: JsonRecord): Array<string> {
|
|
88
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
89
|
+
|
|
90
|
+
return keys.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function entry_line(name: string, before: unknown, after: unknown): string {
|
|
94
|
+
if (before === undefined) return `+ ${name} ${JSON.stringify(after)}`
|
|
95
|
+
if (after === undefined) return `- ${name}`
|
|
96
|
+
|
|
97
|
+
return `~ ${name} ${JSON.stringify(before)} → ${JSON.stringify(after)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A missing field reads as an empty object, so a block added or removed whole is listed too.
|
|
101
|
+
function as_entries(value: unknown): JsonRecord | undefined {
|
|
102
|
+
if (value === undefined) return {}
|
|
103
|
+
|
|
104
|
+
return json_value.is_record(value) ? value : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A field that is an object (`scripts`, `devDependencies`) is listed entry by entry, so the plan
|
|
108
|
+
// names each script and dependency rather than the whole block.
|
|
109
|
+
function field_lines(key: string, before: unknown, after: unknown): Array<string> {
|
|
110
|
+
const old_entries = as_entries(before)
|
|
111
|
+
const new_entries = as_entries(after)
|
|
112
|
+
|
|
113
|
+
if (old_entries === undefined || new_entries === undefined) {
|
|
114
|
+
return [entry_line(key, before, after)]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return changed_keys(old_entries, new_entries).map((name) =>
|
|
118
|
+
entry_line(`${key}.${name}`, old_entries[name], new_entries[name]),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function package_json_lines(change: FileChange): Array<string> {
|
|
123
|
+
const before = parse_record(change.before)
|
|
124
|
+
const after = parse_record(change.after)
|
|
125
|
+
|
|
126
|
+
return changed_keys(before, after).flatMap((key) => field_lines(key, before[key], after[key]))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function change_lines(change: FileChange): Array<string> {
|
|
130
|
+
const head = ` ${change_kind(change).padEnd(KIND_WIDTH)} ${change.path}`
|
|
131
|
+
if (change.path !== PACKAGE_JSON || change.after === undefined) return [head]
|
|
132
|
+
|
|
133
|
+
return [head, ...package_json_lines(change).map((line) => ` ${line}`)]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function format_report(
|
|
137
|
+
title: string,
|
|
138
|
+
changes: ReadonlyArray<FileChange>,
|
|
139
|
+
dependency_lines: ReadonlyArray<string>,
|
|
140
|
+
): string {
|
|
141
|
+
const files =
|
|
142
|
+
changes.length === 0 ? [NO_CHANGES] : changes.flatMap((change) => change_lines(change))
|
|
143
|
+
const dependencies = dependency_lines.map((line) => ` ${line}`)
|
|
144
|
+
|
|
145
|
+
return [title, '', 'Files:', ...files, '', 'Dependencies:', ...dependencies].join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const init_dry_run_report = { changed_files, change_kind, package_json_lines, format_report }
|
|
149
|
+
export { init_dry_run_report }
|
|
150
|
+
export type { ChangeKind, FileChange }
|
|
@@ -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
|
}
|