@joshuafolkken/kit 1.921.0 → 1.947.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/background-commands.md +6 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
- package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
- package/.claude/skills/workflow-commands/observation-filing.md +3 -3
- package/.claude/skills/workflow-commands/prerequisite.md +1 -1
- package/.github/workflows/ci.yml +26 -8
- package/.github/workflows/pr-classification.yml +76 -0
- package/README.md +47 -21
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-LC765SUA.js → chunk-3FWV6CFC.js} +119 -20
- package/dist/hooks/{chunk-UULARQ5Q.js → chunk-EYFJ74MJ.js} +13 -15
- package/dist/hooks/{chunk-O2FBZPSW.js → chunk-GIX5SAAB.js} +248 -231
- package/dist/hooks/{chunk-OLTAGGHH.js → chunk-IA7WC4NS.js} +1 -1
- package/dist/hooks/{chunk-UANRLZV7.js → chunk-IBFC25IX.js} +4 -4
- package/dist/hooks/{chunk-XMR3BS35.js → chunk-M4U4YNRE.js} +3 -3
- package/dist/hooks/{chunk-NWD47MW3.js → chunk-NKVLBB65.js} +3 -3
- package/dist/hooks/{chunk-OYU6KPRA.js → chunk-O3PEDF3M.js} +2 -2
- package/dist/hooks/{chunk-3HZ5MJIW.js → chunk-OM5VDM6F.js} +2 -2
- package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
- package/dist/hooks/{chunk-77RLKZ5V.js → chunk-V7RH56HU.js} +2 -2
- package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
- package/dist/hooks/codex-hook-adapter.js +12 -12
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +11 -11
- package/dist/hooks/session-lang.js +2 -2
- package/dist/hooks/stop-guard.js +11 -5
- package/dist/josh.js +12 -14
- package/eslint/base.js +4 -1
- package/package.json +21 -6
- package/playwright.config.ts +9 -17
- package/ports/index.d.ts +1 -0
- package/ports/index.js +1 -0
- package/ports/web-server.d.ts +5 -0
- package/ports/web-server.js +73 -0
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/scripts/agent/agent-role-profile.ts +11 -0
- package/scripts/ci/pr-classification.ts +27 -6
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/hook-gate-reuse.ts +1 -1
- package/scripts/gate/project-checks.ts +60 -6
- package/scripts/gate/type-check-command.ts +43 -0
- package/scripts/git/constants.ts +4 -0
- package/scripts/git/git-command.ts +11 -5
- package/scripts/git/git-gh-pr-rollup.ts +56 -16
- package/scripts/git/git-staging.ts +6 -4
- package/scripts/git/git-stash.ts +73 -1
- package/scripts/git/stash-orphans.ts +24 -2
- package/scripts/git/stash-sweep-lock.ts +141 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/init-actions.ts +3 -2
- package/scripts/init/init-ai-copy.ts +8 -5
- package/scripts/init/init-logic-migrate.ts +10 -1
- package/scripts/init/init-logic-templates.ts +27 -7
- package/scripts/init/init-logic-workspace.ts +89 -5
- package/scripts/init/init-logic.ts +7 -1
- package/scripts/init/init-static.ts +4 -1
- package/scripts/init/init.ts +60 -19
- package/scripts/init/project-profile.ts +14 -5
- package/scripts/issue/issue-merged.ts +75 -0
- package/scripts/issue/measure-rerun-cli.ts +2 -2
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-command-types.ts +0 -3
- package/scripts/josh/josh-commands-ai.ts +6 -0
- package/scripts/josh/josh-commands-development.ts +6 -19
- package/scripts/lint/format.ts +68 -0
- package/scripts/lint/lint-parallel.ts +23 -36
- package/scripts/observations/observation-ledger-home.ts +22 -2
- package/scripts/observations/observation-ledger-line.ts +11 -0
- package/scripts/observations/observation-ledger-migrate.ts +146 -0
- package/scripts/observations/observation-ledger.ts +51 -3
- package/scripts/observations/observations-flush.ts +38 -7
- package/scripts/review/review-finding-ledger.ts +1 -1
- package/scripts/review/review-record-cli.ts +1 -7
- package/scripts/rules/delivered-rules.ts +10 -0
- package/scripts/rules/lane-background.ts +111 -0
- package/scripts/rules/stop-rules.ts +15 -2
- package/scripts/run/run-carry-flush.ts +2 -7
- package/scripts/run/run-hold-cli.ts +10 -2
- package/scripts/run/run-step-cli.ts +11 -1
- package/scripts/run/run-step.ts +36 -8
- package/scripts/run/run-tidy-cli.ts +50 -0
- package/scripts/run/run-tidy-lanes.ts +110 -0
- package/scripts/run/run-tidy-stashes.ts +160 -0
- package/scripts/run/run-tidy.ts +101 -0
- package/scripts/safe-chain/ci-installer-pin.ts +90 -0
- package/scripts/safe-chain/preinstall-command.ts +39 -0
- package/scripts/sync/sync.ts +5 -2
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/preinstall-version-update.ts +2 -0
- package/templates/pnpm-workspace.static.yaml +4 -0
- package/templates/workflows/ci.yml +21 -7
- package/tsconfig/base.json +4 -1
|
@@ -13,7 +13,11 @@ import type { StashEntry } from './git-stash'
|
|
|
13
13
|
// opens with `N: ` is `git_stash.work_message`'s; and a lane branch (`On N-lane: …`) names it by where
|
|
14
14
|
// the work was pushed from. A subject is `On <branch>: <message>` (or `WIP on <branch>: …` without
|
|
15
15
|
// `-m`), and a branch name carries no `:`, so `(?:^|: )` anchors the message start either way.
|
|
16
|
-
const
|
|
16
|
+
const HASH_PATTERN = /#(?<issue>\d+)\b/u
|
|
17
|
+
const LEADING_PATTERN = /(?:^|: )(?<issue>\d+): /u
|
|
18
|
+
const MESSAGE_PATTERNS: ReadonlyArray<RegExp> = [HASH_PATTERN, LEADING_PATTERN]
|
|
19
|
+
// Every `#N` in the message rather than the first, for `issues_named`.
|
|
20
|
+
const ALL_ISSUE_PATTERN = new RegExp(HASH_PATTERN.source, 'gu')
|
|
17
21
|
const BRANCH_PATTERN = /^(?:WIP on|On) (?<issue>\d+)-lane: /u
|
|
18
22
|
// A stash pushed without `-m` lists as `WIP on <branch>: <sha> <HEAD's commit subject>` — text no run
|
|
19
23
|
// wrote. This repository's commit subjects end in `#N` and merges read `Merge pull request #N`, so a
|
|
@@ -61,6 +65,24 @@ function issues_of(entries: ReadonlyArray<StashEntry>): Array<string> {
|
|
|
61
65
|
return [...new Set(issues.filter((issue): issue is string => issue !== undefined))]
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
function message_issues(subject: string): Array<string | undefined> {
|
|
69
|
+
const hashed = [...subject.matchAll(ALL_ISSUE_PATTERN)].map((match) => match.groups?.['issue'])
|
|
70
|
+
|
|
71
|
+
return [...hashed, LEADING_PATTERN.exec(subject)?.groups?.['issue']]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every issue a subject names, not just its owner (joshuafolkken/kit#2701): `backlogrun: paused #M for
|
|
75
|
+
// prerequisite #N` is tied to both, and the `#N` of a `run:hold reclaimed before #N` entry — which
|
|
76
|
+
// `issue_of` reads as no owner — still names the run the work was found by. A `WIP on` subject is
|
|
77
|
+
// read by its branch alone, for the reason `WIP_PREFIX` records.
|
|
78
|
+
function issues_named(subject: string): Array<string> {
|
|
79
|
+
const branch = BRANCH_PATTERN.exec(subject)?.groups?.['issue']
|
|
80
|
+
const named = subject.startsWith(WIP_PREFIX) ? [] : message_issues(subject)
|
|
81
|
+
const found = [...named, branch].filter((issue): issue is string => issue !== undefined)
|
|
82
|
+
|
|
83
|
+
return [...new Set(found)]
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
// An entry is reported when its issue is in `closed`, or when it names no issue at all — an entry with
|
|
65
87
|
// no owner is exactly the one no run will ever pop. An open issue's entry is still some run's to resume.
|
|
66
88
|
function orphans(
|
|
@@ -85,7 +107,7 @@ function format_report(found: ReadonlyArray<OrphanStash>): string | undefined {
|
|
|
85
107
|
return [HEADER, ...found.map((orphan) => format_line(orphan)), FOOTER].join(LINE_SEPARATOR)
|
|
86
108
|
}
|
|
87
109
|
|
|
88
|
-
const stash_orphans = { format_report, issue_of, issues_of, orphans }
|
|
110
|
+
const stash_orphans = { format_report, issue_of, issues_named, issues_of, orphans }
|
|
89
111
|
|
|
90
112
|
export type { OrphanStash }
|
|
91
113
|
export { stash_orphans }
|
|
@@ -0,0 +1,141 @@
|
|
|
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'
|
|
7
|
+
|
|
8
|
+
// One stash sweep at a time per repository (joshuafolkken/kit#2701). The stash is a single stack every
|
|
9
|
+
// work tree shares, and a `backlogrun` runs one sweep per lane seat at once: two sweeps that each
|
|
10
|
+
// 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.
|
|
13
|
+
|
|
14
|
+
const LOCK_PREFIX = 'josh-stash-sweep-lock-'
|
|
15
|
+
// The locked phase is local git and one file append per entry, so a holder finishes in well under the
|
|
16
|
+
// 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
|
+
const MAX_WAIT_MS = 30_000
|
|
19
|
+
|
|
20
|
+
interface LockOwner {
|
|
21
|
+
pid: number
|
|
22
|
+
process_start?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
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
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Runs `work` holding the lock and answers its result, or `undefined` without running it when another
|
|
124
|
+
// sweep held the lock for the whole wait.
|
|
125
|
+
async function with_lock<T>(
|
|
126
|
+
work: () => Promise<T>,
|
|
127
|
+
target: string = lock_path(),
|
|
128
|
+
max_wait_ms: number = MAX_WAIT_MS,
|
|
129
|
+
): 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
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stash_sweep_lock = { clear_stale, lock_path, with_lock }
|
|
140
|
+
|
|
141
|
+
export { stash_sweep_lock }
|
|
@@ -8,6 +8,7 @@ import { hook_decision } from '#scripts/josh/hook-decision'
|
|
|
8
8
|
import { session_language } from '#scripts/josh/session-language'
|
|
9
9
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
10
10
|
import { filing_cap } from '#scripts/rules/filing-cap'
|
|
11
|
+
import { lane_background } from '#scripts/rules/lane-background'
|
|
11
12
|
import { lane_park } from '#scripts/rules/lane-park'
|
|
12
13
|
import { stop_rules, type StopContext, type StopOutcome } from '#scripts/rules/stop-rules'
|
|
13
14
|
import { run_cut } from '#scripts/run/run-cut'
|
|
@@ -76,6 +77,7 @@ async function build_context(
|
|
|
76
77
|
headless_waiting: await run_headless.must_keep_waiting(),
|
|
77
78
|
headless_refusals: stop_rules.count_headless_refusals(tail),
|
|
78
79
|
lane_child: lane_child_marker.is_child_of(process.cwd()),
|
|
80
|
+
background_pending: lane_background.pending_background_ids(tail).length > 0,
|
|
79
81
|
session_lang: session_lang(),
|
|
80
82
|
}
|
|
81
83
|
}
|
|
@@ -218,8 +218,9 @@ function build_file_actions(shape?: ProjectShape): ReadonlyArray<FileAction> {
|
|
|
218
218
|
build_eslint_action(),
|
|
219
219
|
build_action(
|
|
220
220
|
PRETTIER_CONFIG_JS,
|
|
221
|
-
() =>
|
|
222
|
-
|
|
221
|
+
() =>
|
|
222
|
+
init_logic.generate_prettier_config(init_logic.detect_tailwind_stylesheet(PROJECT_ROOT)),
|
|
223
|
+
(existing) => init_logic.merge_prettier_config(existing, PROJECT_ROOT),
|
|
223
224
|
),
|
|
224
225
|
build_playwright_action(),
|
|
225
226
|
...build_config_file_actions().filter(
|
|
@@ -28,7 +28,13 @@ const STATIC_AI_FILES = [
|
|
|
28
28
|
GIT_ATTRIBUTES,
|
|
29
29
|
CODE_OF_CONDUCT,
|
|
30
30
|
SECURITY_MD,
|
|
31
|
+
WORKSPACE_YAML,
|
|
31
32
|
]
|
|
33
|
+
// A static project gets its own template for these, not kit's development copy (joshuafolkken/kit#2693).
|
|
34
|
+
const STATIC_SOURCES: Readonly<Record<string, string>> = {
|
|
35
|
+
[PRETTIER_IGNORE]: 'templates/prettierignore.static',
|
|
36
|
+
[WORKSPACE_YAML]: 'templates/pnpm-workspace.static.yaml',
|
|
37
|
+
}
|
|
32
38
|
const GITHUB_FILES = new Set([CODE_OF_CONDUCT, SECURITY_MD])
|
|
33
39
|
const STATIC_POINTER_MAPPING = { src: 'templates/cursorrules.static', dest: '.cursorrules' }
|
|
34
40
|
const PULL_REQUEST_TEMPLATE = '.github/pull_request_template.md'
|
|
@@ -112,11 +118,8 @@ function did_skip_workspace_yaml_copy(source_path: string, destination_path: str
|
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
function did_skip_ai_file_copy(filename: string, shape?: ProjectShape): boolean {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
? 'templates/prettierignore.static'
|
|
118
|
-
: filename,
|
|
119
|
-
)
|
|
121
|
+
const static_source = shape?.profile === 'static' ? STATIC_SOURCES[filename] : undefined
|
|
122
|
+
const source_path = package_path(static_source ?? filename)
|
|
120
123
|
const destination_path = path.join(PROJECT_ROOT, filename)
|
|
121
124
|
|
|
122
125
|
if (filename === WORKSPACE_YAML) {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { safe_chain_preinstall } from '#scripts/safe-chain/preinstall-command'
|
|
2
|
+
|
|
3
|
+
const PREINSTALL_KEY = 'preinstall'
|
|
1
4
|
const JF_PREFIX = 'jf-'
|
|
2
5
|
const JOSH_PREFIX = 'josh '
|
|
3
6
|
|
|
@@ -47,9 +50,15 @@ function migrate_jf_value(value: string): string {
|
|
|
47
50
|
return `${JOSH_PREFIX}${value.slice(JF_PREFIX.length)}`
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
function migrate_script(key: string, value: string): string {
|
|
54
|
+
const migrated = migrate_jf_value(value)
|
|
55
|
+
|
|
56
|
+
return key === PREINSTALL_KEY ? safe_chain_preinstall.migrate_preinstall(migrated) : migrated
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
function apply_jf_migrations(scripts: Record<string, string>): Record<string, string> {
|
|
51
60
|
return Object.fromEntries(
|
|
52
|
-
Object.entries(scripts).map(([key, value]) => [key,
|
|
61
|
+
Object.entries(scripts).map(([key, value]) => [key, migrate_script(key, value)]),
|
|
53
62
|
)
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
import { package_path } from './init-paths'
|
|
3
4
|
|
|
4
5
|
const ESLINT_VANILLA = `import { create_vanilla_config } from '@joshuafolkken/kit/eslint/vanilla'
|
|
@@ -111,20 +112,38 @@ function merge_eslint_config(existing: string): string {
|
|
|
111
112
|
return apply_rules_template(rules_blocks.join(',\n'))
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
// The default is SvelteKit's stylesheet, and prettier-plugin-tailwindcss aborts every format when
|
|
116
|
+
// the named file is missing — so it is written only for a project that has it (joshuafolkken/kit#2710).
|
|
117
|
+
function detect_tailwind_stylesheet(project_root: string): string | undefined {
|
|
118
|
+
return existsSync(path.join(project_root, DEFAULT_TAILWIND_STYLESHEET))
|
|
119
|
+
? DEFAULT_TAILWIND_STYLESHEET
|
|
120
|
+
: undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function generate_prettier_config(stylesheet?: string): string {
|
|
124
|
+
const stylesheet_line = stylesheet === undefined ? '' : `\ttailwindStylesheet: '${stylesheet}',\n`
|
|
125
|
+
|
|
115
126
|
return `import { config } from '@joshuafolkken/kit/prettier'
|
|
116
127
|
|
|
117
128
|
export default {
|
|
118
129
|
\t...config,
|
|
119
|
-
|
|
120
|
-
}
|
|
130
|
+
${stylesheet_line}}
|
|
121
131
|
`
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
// A named stylesheet is kept only while it exists: kit ≤1.929 wrote the SvelteKit default into every
|
|
135
|
+
// node project, and keeping that line would leave `josh sync` unable to repair the aborting format.
|
|
136
|
+
function existing_stylesheet(existing: string, project_root: string): string | undefined {
|
|
137
|
+
const named = TAILWIND_STYLESHEET_PATTERN.exec(existing)?.[1]
|
|
138
|
+
|
|
139
|
+
return named !== undefined && existsSync(path.resolve(project_root, named)) ? named : undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function merge_prettier_config(existing: string, project_root: string): string {
|
|
143
|
+
const stylesheet =
|
|
144
|
+
existing_stylesheet(existing, project_root) ?? detect_tailwind_stylesheet(project_root)
|
|
126
145
|
|
|
127
|
-
return generate_prettier_config(
|
|
146
|
+
return generate_prettier_config(stylesheet)
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
function generate_playwright_config(): string {
|
|
@@ -134,6 +153,7 @@ function generate_playwright_config(): string {
|
|
|
134
153
|
const init_logic_templates = {
|
|
135
154
|
generate_eslint_config,
|
|
136
155
|
merge_eslint_config,
|
|
156
|
+
detect_tailwind_stylesheet,
|
|
137
157
|
generate_prettier_config,
|
|
138
158
|
merge_prettier_config,
|
|
139
159
|
generate_playwright_config,
|
|
@@ -42,16 +42,100 @@ function remove_deprecated_yaml_keys(content: string): string {
|
|
|
42
42
|
return `${result.trimEnd()}\n`
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// pnpm 11+ writes this placeholder into `allowBuilds` for every build script it ignored, and fails
|
|
46
|
+
// every later install until it is answered. `pnpm add -D @joshuafolkken/kit` before `josh init`
|
|
47
|
+
// leaves one for esbuild, and the key-level merge below keeps an existing `allowBuilds` whole, so a
|
|
48
|
+
// placeholder the template decides is answered here (joshuafolkken/kit#2693).
|
|
49
|
+
const BUILD_PLACEHOLDER_PATTERN = /^([ \t]+)([^\s:#][^:#]*): set this to true or false$/gmu
|
|
50
|
+
const TEMPLATE_BUILD_PATTERN = /^[ \t]+(?<key>[^\s:#][^:#]*):[ \t]*(?<value>true|false)\b/u
|
|
51
|
+
|
|
52
|
+
function unquote(key: string): string {
|
|
53
|
+
return key.trim().replaceAll(/^['"]|['"]$/gu, '')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function template_build_values(template: string): Map<string, string> {
|
|
57
|
+
const lines = extract_yaml_block(template, 'allowBuilds').split('\n')
|
|
58
|
+
|
|
59
|
+
return new Map(
|
|
60
|
+
lines.flatMap((line) => {
|
|
61
|
+
const { key, value } = TEMPLATE_BUILD_PATTERN.exec(line)?.groups ?? {}
|
|
62
|
+
|
|
63
|
+
return key && value ? [[unquote(key), value] as const] : []
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function answer_build_placeholders(existing: string, template: string): string {
|
|
69
|
+
const values = template_build_values(template)
|
|
70
|
+
|
|
71
|
+
return existing.replaceAll(BUILD_PLACEHOLDER_PATTERN, (line, indent: string, key: string) => {
|
|
72
|
+
const value = values.get(unquote(key))
|
|
73
|
+
|
|
74
|
+
return value === undefined ? line : `${indent}${key}: ${value}`
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const BUILD_ENTRY_KEY_PATTERN = /^[ \t]+(?<key>[^\s:#][^:#]*):/u
|
|
79
|
+
|
|
80
|
+
function build_entry_key(line: string): string | undefined {
|
|
81
|
+
const key = BUILD_ENTRY_KEY_PATTERN.exec(line)?.groups?.['key']
|
|
82
|
+
|
|
83
|
+
return key === undefined ? undefined : unquote(key)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function build_entry_keys(block: string): Set<string> {
|
|
87
|
+
return new Set(block.split('\n').flatMap((line) => build_entry_key(line) ?? []))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The key-level merge keeps an existing `allowBuilds` whole, and `pnpm add -D @joshuafolkken/kit`
|
|
91
|
+
// before `josh init` always leaves one (esbuild's), so without this the template's other approvals
|
|
92
|
+
// never landed and the first `pnpm install` failed on the next build script, e.g. unrs-resolver's
|
|
93
|
+
// (joshuafolkken/kit#2710). Entries the project already answered keep their value.
|
|
94
|
+
// Only a block mapping can take appended entry lines; a flow map (`allowBuilds: { … }`) is left whole.
|
|
95
|
+
const BLOCK_MAPPING_HEADER_PATTERN = /^allowBuilds:[ \t]*(?:#[^\n]*)?(?:\n|$)/u
|
|
96
|
+
const LEADING_INDENT_PATTERN = /^[ \t]+/u
|
|
97
|
+
|
|
98
|
+
// Appended lines take the indent of the block's own entries: a mapping whose items start at
|
|
99
|
+
// different columns is invalid YAML, so a four-space block must not gain two-space lines.
|
|
100
|
+
function block_entry_indent(block: string): string | undefined {
|
|
101
|
+
const entry = block.split('\n').find((line) => build_entry_key(line) !== undefined)
|
|
102
|
+
|
|
103
|
+
return entry === undefined ? undefined : LEADING_INDENT_PATTERN.exec(entry)?.[0]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reindent(line: string, indent: string | undefined): string {
|
|
107
|
+
return indent === undefined ? line : line.replace(LEADING_INDENT_PATTERN, () => indent)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function add_missing_build_entries(existing: string, template: string): string {
|
|
111
|
+
const block = extract_yaml_block(existing, 'allowBuilds')
|
|
112
|
+
if (!BLOCK_MAPPING_HEADER_PATTERN.test(block)) return existing
|
|
113
|
+
const present = build_entry_keys(block)
|
|
114
|
+
const indent = block_entry_indent(block)
|
|
115
|
+
const missing = extract_yaml_block(template, 'allowBuilds')
|
|
116
|
+
.split('\n')
|
|
117
|
+
.filter((line) => {
|
|
118
|
+
const key = build_entry_key(line)
|
|
119
|
+
|
|
120
|
+
return key !== undefined && !present.has(key)
|
|
121
|
+
})
|
|
122
|
+
.map((line) => reindent(line, indent))
|
|
123
|
+
if (missing.length === 0) return existing
|
|
124
|
+
|
|
125
|
+
return existing.replace(block, () => [block, ...missing].join('\n'))
|
|
126
|
+
}
|
|
127
|
+
|
|
45
128
|
function merge_workspace_yaml(existing: string, template: string): string {
|
|
46
129
|
if (!existing.trim()) return template
|
|
47
130
|
const normalized = existing.endsWith('\n') ? existing : `${existing}\n`
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
131
|
+
const answered = answer_build_placeholders(remove_deprecated_yaml_keys(normalized), template)
|
|
132
|
+
const cleaned = add_missing_build_entries(answered, template)
|
|
133
|
+
if (!cleaned.trim()) return template
|
|
134
|
+
const existing_keys = new Set(extract_yaml_top_level_keys(cleaned))
|
|
51
135
|
const new_keys = extract_yaml_top_level_keys(template).filter((k) => !existing_keys.has(k))
|
|
52
|
-
if (new_keys.length === 0) return
|
|
136
|
+
if (new_keys.length === 0) return cleaned
|
|
53
137
|
|
|
54
|
-
return append_user_blocks(
|
|
138
|
+
return append_user_blocks(cleaned, new_keys, template)
|
|
55
139
|
}
|
|
56
140
|
|
|
57
141
|
const init_logic_workspace = { merge_workspace_yaml }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { json_format } from '#scripts/config-merge/json-format'
|
|
2
2
|
import { vscode_settings_schema } from '#scripts/lib/schemas'
|
|
3
|
+
import { safe_chain_preinstall } from '#scripts/safe-chain/preinstall-command'
|
|
3
4
|
import { distributed_paths } from './distributed-paths'
|
|
4
5
|
import { init_logic_deploy_vps } from './init-logic-deploy-vps'
|
|
5
6
|
import { init_logic_json_merge } from './init-logic-json-merge'
|
|
@@ -27,7 +28,7 @@ const NPMRC_LINES: ReadonlyArray<string> = [
|
|
|
27
28
|
const CSPELL_IMPORT = '@joshuafolkken/kit/cspell'
|
|
28
29
|
|
|
29
30
|
const LEFTHOOK_INSTALL_CMD = 'lefthook install'
|
|
30
|
-
const SAFE_CHAIN_CMD =
|
|
31
|
+
const { SAFE_CHAIN_CMD } = safe_chain_preinstall
|
|
31
32
|
const FIX_GH_PACKAGES_CMD = 'tsx node_modules/@joshuafolkken/kit/scripts/gh/fix-gh-packages.ts'
|
|
32
33
|
// Marker identifying a consumer script that already runs the fix-gh-packages command.
|
|
33
34
|
const FIX_GH_PACKAGES_MARKER = 'fix-gh-packages'
|
|
@@ -90,6 +91,7 @@ const AI_COPY_FILES: ReadonlyArray<string> = [
|
|
|
90
91
|
'.github/workflows/auto-tag.yml',
|
|
91
92
|
'.github/workflows/production.yml',
|
|
92
93
|
'.github/workflows/sonar-qube.yml',
|
|
94
|
+
'.github/workflows/pr-classification.yml',
|
|
93
95
|
'.github/pull_request_template.md',
|
|
94
96
|
'.github/release.yml',
|
|
95
97
|
'.github/dependabot.yml',
|
|
@@ -228,9 +230,12 @@ const PRETTIER_PLUGIN_DEV_DEPS: Record<string, string> = {
|
|
|
228
230
|
// format_json rather than JSON.stringify: it lays arrays out the way prettier does — inline while
|
|
229
231
|
// they fit within printWidth, one element per line once they do not — so the file kit writes is
|
|
230
232
|
// `prettier --check`-clean in the consumer whatever the entry count happens to be (#660).
|
|
233
|
+
// TypeScript 6 defaults `types` to `[]`, so a node project sees no `process` global — not even in the
|
|
234
|
+
// generated `playwright.config.ts` — until the Node types are named (joshuafolkken/kit#2710).
|
|
231
235
|
function generate_tsconfig(): string {
|
|
232
236
|
return json_format.format_json({
|
|
233
237
|
extends: TSCONFIG_EXTENDS,
|
|
238
|
+
compilerOptions: { types: ['node'] },
|
|
234
239
|
[TSCONFIG_EXCLUDE_FIELD]: TSCONFIG_EXCLUDE,
|
|
235
240
|
})
|
|
236
241
|
}
|
|
@@ -451,6 +456,7 @@ const init_logic = {
|
|
|
451
456
|
strip_kit_only_vscode_settings,
|
|
452
457
|
strip_kit_only_vscode_settings_content,
|
|
453
458
|
VSCODE_EXTENSIONS_FILENAME,
|
|
459
|
+
SAFE_CHAIN_CMD,
|
|
454
460
|
GUARDED_LEFTHOOK_CMD,
|
|
455
461
|
upgrade_prepare_lefthook_warning,
|
|
456
462
|
get_npmrc_lines,
|
|
@@ -36,7 +36,10 @@ function merge_static_manifest(
|
|
|
36
36
|
versions: StaticVersions,
|
|
37
37
|
): string {
|
|
38
38
|
const with_profile = with_recorded_profile(content, shape.profile)
|
|
39
|
-
const with_script = init_logic.merge_package_scripts(with_profile, {
|
|
39
|
+
const with_script = init_logic.merge_package_scripts(with_profile, {
|
|
40
|
+
preinstall: init_logic.SAFE_CHAIN_CMD,
|
|
41
|
+
josh: 'josh',
|
|
42
|
+
})
|
|
40
43
|
const with_kit = init_logic.merge_development_dependencies(with_script, {
|
|
41
44
|
[KIT_PACKAGE_NAME]: versions.kit,
|
|
42
45
|
})
|
package/scripts/init/init.ts
CHANGED
|
@@ -27,6 +27,7 @@ const LEFTHOOK_BIN = 'lefthook'
|
|
|
27
27
|
const SAMPLE_INDENT_WIDTH = 4
|
|
28
28
|
const ARGUMENT_START_INDEX = 2
|
|
29
29
|
const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
|
|
30
|
+
const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
|
|
30
31
|
|
|
31
32
|
function write_new_file(action: FileAction, destination_path: string): void {
|
|
32
33
|
mkdirSync(path.dirname(destination_path), { recursive: true })
|
|
@@ -77,12 +78,25 @@ function execute_file_action(action: FileAction): void {
|
|
|
77
78
|
merge_existing_file(action.merge, destination_path, action.dest)
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
81
|
+
const engine_pin_schema = z.looseObject({ name: z.string(), version: z.string() })
|
|
82
|
+
|
|
83
|
+
const published_development_engines_schema = z.looseObject({
|
|
84
|
+
devEngines: z.looseObject({ packageManager: engine_pin_schema.optional() }).optional(),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// pnpm strips `packageManager` from a published manifest, so the kit a consumer installs carries its
|
|
88
|
+
// pin only in `devEngines`. Falling back to that exact version keeps the consumer off the bare
|
|
89
|
+
// `>=12.1.0` range, which every later `pnpm` call rejects as an invalid packageManager specification.
|
|
90
|
+
function resolve_kit_package_manager(manifest: unknown): string | undefined {
|
|
91
|
+
const { packageManager: package_manager } = with_package_manager_schema.parse(manifest)
|
|
92
|
+
if (package_manager !== undefined && package_manager.length > 0) return package_manager
|
|
93
|
+
const pin = published_development_engines_schema.parse(manifest).devEngines?.packageManager
|
|
84
94
|
|
|
85
|
-
return
|
|
95
|
+
return pin === undefined ? undefined : `${pin.name}@${pin.version}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function get_kit_package_manager(): string | undefined {
|
|
99
|
+
return resolve_kit_package_manager(init_actions.read_package_json(PACKAGE_JSON))
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
function get_kit_development_engines(): Record<string, unknown> {
|
|
@@ -97,21 +111,34 @@ function get_kit_self_dependency(): Record<string, string> {
|
|
|
97
111
|
return { [KIT_PACKAGE_NAME]: version }
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
|
|
114
|
+
// Each config `init` generates runs a CLI or imports types the consumer has to resolve itself:
|
|
115
|
+
// `prettier.config.js` → prettier, `cspell.config.yaml` → cspell, `playwright.config.ts` →
|
|
116
|
+
// @types/node (with @playwright/test among the peers), `lefthook.yml` → lefthook. Without them the
|
|
117
|
+
// first `josh gate` after `pnpm install` fails (joshuafolkken/kit#2710).
|
|
118
|
+
const TOOL_DEVELOPMENT_DEPENDENCIES = ['prettier', 'cspell', '@types/node']
|
|
119
|
+
const LEFTHOOK_DEVELOPMENT_DEPENDENCY = 'lefthook'
|
|
120
|
+
|
|
121
|
+
function tool_dependency_names(has_git: boolean): ReadonlyArray<string> {
|
|
122
|
+
return has_git
|
|
123
|
+
? [...TOOL_DEVELOPMENT_DEPENDENCIES, LEFTHOOK_DEVELOPMENT_DEPENDENCY]
|
|
124
|
+
: TOOL_DEVELOPMENT_DEPENDENCIES
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Versions mirror kit's own devDependencies, so the consumer runs the toolchain kit is verified with.
|
|
128
|
+
function get_toolchain_development_dependencies(has_git: boolean): Record<string, string> {
|
|
101
129
|
const manifest = z
|
|
102
130
|
.object({
|
|
103
131
|
peerDependencies: z.record(z.string(), z.string()),
|
|
104
132
|
devDependencies: z.record(z.string(), z.string()),
|
|
105
133
|
})
|
|
106
134
|
.parse(init_actions.read_package_json(PACKAGE_JSON))
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
if (version === undefined) throw new Error(`Missing development version for ${name}`)
|
|
135
|
+
const names = [...Object.keys(manifest.peerDependencies), ...tool_dependency_names(has_git)]
|
|
136
|
+
const entries = names.map((name): [string, string] => {
|
|
137
|
+
const version = manifest.devDependencies[name]
|
|
138
|
+
if (version === undefined) throw new Error(`Missing development version for ${name}`)
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
return [name, version]
|
|
141
|
+
})
|
|
115
142
|
|
|
116
143
|
return Object.fromEntries(entries)
|
|
117
144
|
}
|
|
@@ -126,11 +153,11 @@ function apply_dependency_merges(content: string, has_git = true): string {
|
|
|
126
153
|
: Object.fromEntries(Object.entries(suggested).filter(([key]) => key !== 'prepare')),
|
|
127
154
|
)
|
|
128
155
|
const with_prettier = init_logic.merge_prettier_plugin_development_deps(merged)
|
|
129
|
-
const
|
|
156
|
+
const with_toolchain = init_logic.merge_development_dependencies(
|
|
130
157
|
with_prettier,
|
|
131
|
-
|
|
158
|
+
get_toolchain_development_dependencies(has_git),
|
|
132
159
|
)
|
|
133
|
-
const with_secretlint = init_logic.merge_secretlint_development_deps(
|
|
160
|
+
const with_secretlint = init_logic.merge_secretlint_development_deps(with_toolchain)
|
|
134
161
|
|
|
135
162
|
return init_logic.merge_development_dependencies(with_secretlint, get_kit_self_dependency())
|
|
136
163
|
}
|
|
@@ -191,12 +218,25 @@ function merge_project_package_json(shape: ProjectShape): void {
|
|
|
191
218
|
|
|
192
219
|
writeFileSync(package_json_path, merged)
|
|
193
220
|
console.info(` ✔ ${is_existing ? 'updated' : 'created'} package.json`)
|
|
221
|
+
// `init` writes the manifest but installs nothing, so a tool it just listed is absent until the
|
|
222
|
+
// user installs it — and `josh lint` then fails on it rather than skipping it
|
|
223
|
+
// (joshuafolkken/kit#2709). Saying so here is what tells the user the next step.
|
|
224
|
+
console.info(` ${INSTALL_HINT}`)
|
|
194
225
|
}
|
|
195
226
|
|
|
196
|
-
|
|
227
|
+
// A fresh project runs `init` before its first `pnpm install`, so lefthook — added to devDependencies
|
|
228
|
+
// above — is not there yet; the `prepare` script installs the hooks once it is.
|
|
229
|
+
function install_lefthook(project_root: string = PROJECT_ROOT): void {
|
|
197
230
|
console.info('\nLefthook:')
|
|
198
|
-
const bin = resolve_local_bin(
|
|
199
|
-
|
|
231
|
+
const bin = resolve_local_bin(project_root, LEFTHOOK_BIN)
|
|
232
|
+
|
|
233
|
+
if (!existsSync(bin)) {
|
|
234
|
+
console.info(' ℹ lefthook is not installed yet — `pnpm install` installs the git hooks')
|
|
235
|
+
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = execaSync(bin, ['install'], { cwd: project_root, stdio: 'inherit', reject: false })
|
|
200
240
|
|
|
201
241
|
if (result.exitCode === undefined) {
|
|
202
242
|
console.warn(' ⚠ lefthook install failed — run it manually: lefthook install')
|
|
@@ -281,6 +321,7 @@ const init = {
|
|
|
281
321
|
copy_ai_file: init_ai_copy.copy_ai_file,
|
|
282
322
|
apply_package_json_merges,
|
|
283
323
|
install_lefthook,
|
|
324
|
+
resolve_kit_package_manager,
|
|
284
325
|
}
|
|
285
326
|
|
|
286
327
|
// `init` is the entry point; `main` is exported for its own unit test, the way `sync.ts` exports the
|