rvlib-shipkit 0.35.6 → 0.35.8
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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/core/run-check.ts +17 -6
- package/src/core/run-ci.ts +17 -27
- package/src/core/task-runner.ts +7 -1
- package/src/init-repo.ts +13 -10
- package/src/scaffold/project-scaffold.ts +81 -29
- package/src/start-repo.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.8
|
|
4
|
+
|
|
5
|
+
- **`release.requireHealthy` had become a no-op for exactly the checks it exists for.** `runCi` hardcoded `gateOnly: true` on its checks call, and `requireHealthy` is implemented one layer up as `onlyBlockingChecks: false` plus `failOnAdvisory: true`. The gate filter runs before severity is considered, so a repo that asked for a missing publish credential to stop its release had that request silently dropped the moment its cred check declared `gate = false`. Reproduced through `runCi` with the exact option shape the release path builds, against a check exiting 1 with a FAIL line: the line never appeared and the gate returned 0. `gateOnly` now yields to `failOnAdvisory`, so `requireHealthy` re-arms every opted-out check. Two tests, one per option shape.
|
|
6
|
+
- **`--only <check>` ran the check and then exited 1 saying it did not exist.** `selectTasks` validates the flag against the TASK list alone, so naming a discovered check or a rule ran it and still printed `unknown task id`. The flag addresses three surfaces, so the task runner is now handed the ids owned by the other two (rule ids, rule titles, check names, check paths). This is what makes the opt-out safe to use: a check taken off the gate stays reachable by name.
|
|
7
|
+
- The `run-check.ts` comment on the release-survey share path no longer describes a pre-warm `runCi` deleted, and says who the single owner is.
|
|
8
|
+
|
|
9
|
+
## 0.35.7
|
|
10
|
+
|
|
11
|
+
- **`shipkit ci` no longer pre-warms a release survey nothing in the gate reads.** `runCi` started `collectReports` next to the tasks and awaited it before the checks, to hide ~3s of npm registry and `gh` network behind the task DAG. Its only consumer was ever the lib-drift probe, which asks what is PUBLISHED and therefore belongs to `shipkit releases` and the bump gate rather than to a gate that asks whether this change is good. Measured on a repo whose drift checks had left the gate: warm no-op 4.3s with the pre-warm, 1.2s without. Deleted rather than made conditional, because nothing is left to condition it on and a second mechanism guessing which check wants a survey is exactly the source-sniffing this scanner already retired. `collectChecks` creates, shares and cleans up the share path when it was not handed one, so the checks that DO want a survey still pay for exactly one between them, and `shipkit check` is unchanged. Honest cost: a repo that keeps a lib-drift check inside its gate loses the overlap and is up to 3s slower there.
|
|
12
|
+
- **`export const gate = false` was parsed, filtered on, and never honoured** (the fix itself shipped in 0.35.6, which carried no note for it). The CLI declares `--only` as `Option.Array('--only', [])`, so a plain `shipkit ci` hands the check filter an EMPTY ARRAY and never `undefined`. The guard tested `only == null`, which is false for `[]`, so every gate run read as a narrowed one and no check ever opted out: a repo could declare fifteen world probes off-gate and watch all fifteen run anyway. `matchesOnly`, two hundred lines up in the same file, already used the right predicate, and the `only` field's own doc comment already said "Empty or absent means everything". Both readers of the CHECK filter now call one exported `isNarrowed`. `--only` also names rules and discovered checks, so the task runner is handed those ids too rather than answering `unknown task id` for a check it was never asked to run. Pinned by three tests that call `collectChecks` the way the CLI calls it: the gate skips a `gate = false` check, `shipkit check` still runs it, and naming it in `--only` still reaches it.
|
|
13
|
+
- **The adopt path writes `.vscode/tasks.json` alone, and derives what goes in it.** 0.35.6 wrote `settings.json` too, which turns eslint off and hands formatting to an oxc extension: landing that in a repo that lints with eslint silences the linter that repo chose, and that is an answer only its own author gets to give. It also wrote a canned `👁 TSC` watcher pointed at `tsconfig.json`. Against a solution config (`files: []` plus `references`) that watcher needs `--build`, type checks ZERO files without it, and reports a clean problems pane forever, which is worse than no task at all. The TSC subtask is now written only when the root tsconfig resolves inputs, `build/vscode-tasks.md` stays the owner of the per-project analysis for every other watcher, and `init` names that page in its output rather than implying the file is finished.
|
|
14
|
+
- **A symlinked `.vscode/` is left alone.** It is a dotfiles pattern pointing at a SHARED directory, and writing through it landed files outside the repo being wired.
|
|
15
|
+
- **A `repo.config.ts` with no `name` no longer kills `init` with an opaque `TypeError`.** The config loader validates nothing and hands back the raw default export, and the port arithmetic iterates the name it is given. That resolution now sits inside the same guard as the write, falls back to the directory name, and a failure there costs one WARN after everything else has already landed.
|
|
16
|
+
- **`shipkit start` advertises the port the hub will actually listen on.** The scaffold derived it from `package.json` while `init` names the config from the CALLER first, so a dir found holding a forked manifest got a tasks file pointing at a port nothing answers on. The caller now passes the port it resolved.
|
|
17
|
+
|
|
3
18
|
## 0.35.6
|
|
4
19
|
|
|
5
20
|
- **`shipkit init` furnishes an adopted repo with the folder-open half of the scaffold.** The day-one shape was written only for a dir with no commit history, so every repo wired AFTER it already existed got `repo.config.ts` and the scripts and no `.vscode/` at all: opening the folder started no watcher, and the hub the config advertises had no way in. `init` now writes `.vscode/settings.json` and `.vscode/tasks.json` on both its paths, the wiring run and the re-run, through the same writers and the same templates the start scaffold uses. The split it holds: a file that changes no output of any command lands on adopt, and anything saying what the repo IS (`tsconfig.json`, `src/index.ts`) or what an agent may DO in it (the verify skill, the `.claude/` policy files, the `agent/` wiki, the `.rv-reflections/` seed) still needs an explicit caller, because a repo with a history of its own already answered those. Create only, so a `.vscode/` a repo was forked with survives byte for byte, and a second run writes nothing and says so. The advertised port is resolved from `repo.config.ts` (`serve.port`, else derived from the config name), which is where the hub reads it, rather than from the folder name or a stale `package.json`. A `.vscode` path that cannot hold the two files (a read only tree, a path that is a file) costs one WARN naming the errno: this write runs after `repo.config.ts` is on disk and before the publish chain, and it is the least important thing the verb does, so it never kills the run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.8",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
package/src/core/run-check.ts
CHANGED
|
@@ -78,15 +78,25 @@ export type CheckOpts = {
|
|
|
78
78
|
* one they last saw on the wire. */
|
|
79
79
|
export function matchesOnly(only: string[] | undefined, ...names: string[]): boolean {
|
|
80
80
|
if (!isNarrowed(only)) return true
|
|
81
|
-
return only
|
|
81
|
+
return only.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** did the operator narrow this run? The ONE reading of `--only` everywhere: an
|
|
85
85
|
* empty list is not a selection, it is the absence of one. */
|
|
86
|
-
export function isNarrowed(only: string[] | undefined):
|
|
86
|
+
export function isNarrowed(only: string[] | undefined): only is string[] {
|
|
87
87
|
return only != null && only.length > 0
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** every id `--only` can name that is NOT a task: rule ids, rule titles,
|
|
91
|
+
* discovered check names and their repo-relative paths. `--only` addresses all
|
|
92
|
+
* three surfaces, so the task runner has to be told which ids belong to the
|
|
93
|
+
* other two, or it answers `unknown task id` for a check that does run. */
|
|
94
|
+
export function checkSideIds(root: string, c: RepoConfig): string[] {
|
|
95
|
+
const ids = [...RULES, ...(c.rules ?? [])].flatMap((r) => [r.id, r.title])
|
|
96
|
+
for (const d of discoverChecks(root)) ids.push(d.name, relative(root, d.path))
|
|
97
|
+
return ids
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
export type CheckRun = {
|
|
91
101
|
/** wire lines, with non-blocking failures already downgraded to warnings */
|
|
92
102
|
lines: Line[]
|
|
@@ -194,12 +204,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
194
204
|
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
195
205
|
})
|
|
196
206
|
}
|
|
197
|
-
// ONE release survey for the whole run:
|
|
198
|
-
// collectReports, each in its own subprocess, and the survey is ~
|
|
207
|
+
// ONE release survey for the whole run: several discovered checks call
|
|
208
|
+
// collectReports, each in its own subprocess, and the survey is ~3s of
|
|
199
209
|
// network. The path is per RUN and never reused across runs, so nothing
|
|
200
210
|
// here can serve a stale answer to a later gate (releases-share.ts).
|
|
201
|
-
//
|
|
202
|
-
//
|
|
211
|
+
// This is the ONLY owner: nothing else in shipkit sets the variable, and an
|
|
212
|
+
// inherited one can only come from a caller outside the binary, which then
|
|
213
|
+
// owns its own cleanup.
|
|
203
214
|
const inherited = process.env[SHARE_ENV]
|
|
204
215
|
const sharePath = inherited != null && inherited !== '' ? inherited : join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
|
|
205
216
|
const childEnv: Record<string, string | undefined> = {
|
package/src/core/run-ci.ts
CHANGED
|
@@ -8,15 +8,11 @@
|
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
10
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
11
|
-
import { collectChecks } from './run-check.ts'
|
|
11
|
+
import { checkSideIds, collectChecks } from './run-check.ts'
|
|
12
12
|
import { runFixPass } from './run-fixers.ts'
|
|
13
13
|
import { formatLine } from './wire.ts'
|
|
14
14
|
import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
|
|
15
15
|
import { writeStatus } from './write-status.ts'
|
|
16
|
-
import { rmSync } from 'fs'
|
|
17
|
-
|
|
18
|
-
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
|
-
import { collectReports } from '../release/run-releases.ts'
|
|
20
16
|
import { withContractsScope } from '../contracts/contracts-once.ts'
|
|
21
17
|
import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
|
|
22
18
|
import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
|
|
@@ -178,23 +174,15 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
178
174
|
if ((o.onlyIds ?? []).includes(u.id))
|
|
179
175
|
sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
|
|
180
176
|
}
|
|
181
|
-
// the release survey is ~4s of network that five discovered checks need
|
|
182
|
-
// and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
|
|
183
|
-
// costs the run nothing; started inside the first check that wants it, it
|
|
184
|
-
// is 4s nobody can overlap. Failures are the checks' to report, not this
|
|
185
|
-
// line's: a survey nobody can run just means the checks do it themselves.
|
|
186
|
-
const sharePath = join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
|
|
187
|
-
const priorShare = process.env[SHARE_ENV]
|
|
188
|
-
process.env[SHARE_ENV] = sharePath
|
|
189
|
-
// a NARROWED run pre-warms nothing: `--only cycles` asks one question, and
|
|
190
|
-
// paying a 4s network survey to answer it is the same bug --only was
|
|
191
|
-
// fixed for
|
|
192
|
-
const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
|
|
193
177
|
// REPLAY, before the DAG sees the list. A replayed task is removed from
|
|
194
178
|
// the run entirely, which is also what makes its dependents correct: the
|
|
195
179
|
// runner treats a filtered dep as satisfied, and only a GREEN is ever
|
|
196
180
|
// stored, so a dependent can never start on the back of a red nobody saw.
|
|
197
|
-
|
|
181
|
+
// --only addresses tasks, rules and checks alike, so the task runner is
|
|
182
|
+
// told which ids belong to the other two surfaces rather than answering
|
|
183
|
+
// `unknown task id` for a check it was never asked to run
|
|
184
|
+
const otherIds = checkSideIds(root, c)
|
|
185
|
+
const selectable = new Set(selectTasks({ tasks, logDir: '', skipGroups: o.skipGroups ?? [], withIds: o.withIds ?? [], onlyIds: o.onlyIds ?? [], otherIds }).selected.map((t) => t.id))
|
|
198
186
|
const replayedTasks = new Map<string, UnitOutcome>()
|
|
199
187
|
const toRun = tasks
|
|
200
188
|
.filter((t) => {
|
|
@@ -212,11 +200,6 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
212
200
|
// this is the same "a filtered dep counts as satisfied" the runner
|
|
213
201
|
// already applies to --skip and opt-in tasks.
|
|
214
202
|
.map((t) => (t.deps?.some((d) => replayedTasks.has(d)) === true ? { ...t, deps: t.deps.filter((d) => !replayedTasks.has(d)) } : t))
|
|
215
|
-
// the pre-warm exists to hide the survey behind the TASKS. When every task
|
|
216
|
-
// replayed there is nothing to hide it behind, and starting it anyway is a
|
|
217
|
-
// 4s network call the run may never need: the five checks that want it
|
|
218
|
-
// fetch it themselves, and when they replay too, nobody pays for it at all.
|
|
219
|
-
const survey = willRunChecks && toRun.length > 0 ? collectReports(root, c).catch(() => null) : null
|
|
220
203
|
// a NAMED task that replayed leaves the list, so its name has to leave the
|
|
221
204
|
// filters with it: the runner validates `--only`/`--with` against the list
|
|
222
205
|
// it was handed and answers `unknown task id`. `bun run typecheck` and
|
|
@@ -240,6 +223,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
240
223
|
skipGroups: o.skipGroups ?? [],
|
|
241
224
|
withIds,
|
|
242
225
|
onlyIds,
|
|
226
|
+
otherIds,
|
|
243
227
|
print: sink,
|
|
244
228
|
onTaskFinish: o.onTaskFinish,
|
|
245
229
|
})
|
|
@@ -268,12 +252,18 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
268
252
|
// a narrowed run says what it did NOT look at: a green that only ever
|
|
269
253
|
// asked one question must not read like the full gate
|
|
270
254
|
if ((o.onlyIds ?? []).length > 0 && o.skipChecks !== true) sink(`skip: checks not named by --only (${(o.onlyIds ?? []).join(', ')})`)
|
|
271
|
-
|
|
255
|
+
// no release survey is pre-warmed here. It is ~3s of registry and `gh`
|
|
256
|
+
// network, and the only thing that ever read it is the lib-drift probe,
|
|
257
|
+
// which asks what is PUBLISHED and so has no business in a gate that asks
|
|
258
|
+
// whether this change is good. collectChecks owns the share path, so the
|
|
259
|
+
// checks that do want it still pay for exactly one survey between them.
|
|
272
260
|
const checks = o.skipChecks === true
|
|
273
261
|
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
262
|
+
// `gate = false` opts a check out of the EVERYDAY gate. failOnAdvisory
|
|
263
|
+
// is release.requireHealthy, a repo saying it wants a missing cred to
|
|
264
|
+
// stop its publish, so it re-arms them: the opt-out must not silently
|
|
265
|
+
// drop the one request that asks for exactly these probes.
|
|
266
|
+
: await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false, gateOnly: o.failOnAdvisory !== true })
|
|
277
267
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
278
268
|
for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
|
|
279
269
|
for (const u of checks.undeclared) {
|
package/src/core/task-runner.ts
CHANGED
|
@@ -78,6 +78,10 @@ export type RunOpts = {
|
|
|
78
78
|
skipGroups?: string[]
|
|
79
79
|
onlyIds?: string[]
|
|
80
80
|
withIds?: string[]
|
|
81
|
+
/** ids `--only` may name that are NOT tasks: discovered checks and rules.
|
|
82
|
+
* The flag addresses all three surfaces, so a check name must not come
|
|
83
|
+
* back as `unknown task id` from the one surface that does not own it. */
|
|
84
|
+
otherIds?: string[]
|
|
81
85
|
/** print sink — tests capture, production defaults to console.log */
|
|
82
86
|
print?: (line: string) => void
|
|
83
87
|
color?: boolean
|
|
@@ -115,7 +119,9 @@ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[]
|
|
|
115
119
|
if ((t.cmd == null) === (t.logic == null)) errors.push(`task ${t.id}: exactly one of cmd/logic required`)
|
|
116
120
|
for (const d of t.deps ?? []) if (!ids.has(d)) errors.push(`task ${t.id}: unknown dep '${d}'`)
|
|
117
121
|
}
|
|
118
|
-
|
|
122
|
+
const elsewhere = new Set((o.otherIds ?? []).map((i) => i.toLowerCase()))
|
|
123
|
+
for (const id of [...(o.onlyIds ?? []), ...(o.withIds ?? [])])
|
|
124
|
+
if (!ids.has(id) && !elsewhere.has(id.toLowerCase())) errors.push(`unknown task id '${id}'`)
|
|
119
125
|
// cycle check — a dep cycle must die loud at select time, not hang the loop
|
|
120
126
|
const state = new Map<string, 0 | 1>()
|
|
121
127
|
const byId = new Map(o.tasks.map((t) => [t.id, t]))
|
package/src/init-repo.ts
CHANGED
|
@@ -96,7 +96,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
96
96
|
// the repos that need ONE WAY IN most are the ones that already exist,
|
|
97
97
|
// so a re-run REPAIRS rather than only seeding a fresh checkout
|
|
98
98
|
await repairOneWayIn(root, cfg, p)
|
|
99
|
-
furnishEditor(root, cfg
|
|
99
|
+
furnishEditor(root, cfg, p)
|
|
100
100
|
const mh = reportToMotherhome(root, cfg)
|
|
101
101
|
if (mh.note != null) p(mh.note)
|
|
102
102
|
if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
|
|
@@ -130,7 +130,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
130
130
|
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
131
131
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
132
132
|
await repairOneWayIn(root, defineRepo({ name }), p)
|
|
133
|
-
furnishEditor(root, name
|
|
133
|
+
furnishEditor(root, { name }, p)
|
|
134
134
|
// what the gate actually runs, in ONE line: a thin floor must be seen at
|
|
135
135
|
// init time rather than discovered after a bad commit slipped through.
|
|
136
136
|
// The per-task resolution is diagnostics, and a `skip:` naming a script
|
|
@@ -170,17 +170,20 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
170
170
|
* files are the only half of that scaffold an adopted repo can be handed
|
|
171
171
|
* unasked, because they change no output of any command (project-scaffold.ts
|
|
172
172
|
* owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
|
|
173
|
-
function furnishEditor(root: string,
|
|
173
|
+
function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): void {
|
|
174
174
|
// the least important thing init does, and it runs AFTER repo.config.ts is
|
|
175
|
-
// on disk: an unwritable .vscode (a read-only tree, a path that is a file)
|
|
176
|
-
//
|
|
177
|
-
// silence either
|
|
175
|
+
// on disk: an unwritable .vscode (a read-only tree, a path that is a file),
|
|
176
|
+
// or a config with no name at all, must not abort the run before the
|
|
177
|
+
// publish chain, and must not pass in silence either. The port resolution
|
|
178
|
+
// is INSIDE the try for that reason: it reads a config nothing validates
|
|
178
179
|
try {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
const name = repoName(root, undefined, cfg.name)
|
|
181
|
+
const port = cfg.serve?.port ?? defaultHubPort(name)
|
|
182
|
+
if (editorErgonomics(root, { name, port }).length === 0) return void p('ok: .vscode/ is this repo\'s own, left untouched')
|
|
183
|
+
p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
|
|
184
|
+
p(' next: add this stack\'s own watchers (dev server, css, tests) — build/vscode-tasks.md carries the shapes')
|
|
182
185
|
} catch (e) {
|
|
183
|
-
p(`WARN no .vscode/ here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
|
|
186
|
+
p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
|
|
184
187
|
}
|
|
185
188
|
}
|
|
186
189
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// package.json rewrite reformats the file it edits.
|
|
11
11
|
// Templates live beside this file in files/ and ship in the npm tarball, so
|
|
12
12
|
// none of them may name a machine-local tool or a private path.
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
13
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
14
14
|
import { dirname, join } from 'path'
|
|
15
15
|
import { defaultHubPort } from '../serve/hub-port.ts'
|
|
16
16
|
|
|
@@ -23,6 +23,10 @@ export type ScaffoldOpts = {
|
|
|
23
23
|
print?: (line: string) => void
|
|
24
24
|
/** the changelog's first dated entry — injected so callers stay testable */
|
|
25
25
|
today?: string
|
|
26
|
+
/** the hub port the caller resolved for the config it is about to wire.
|
|
27
|
+
* Absent falls back to the manifest name, which is only right when this
|
|
28
|
+
* run wrote the manifest too */
|
|
29
|
+
port?: number
|
|
26
30
|
/** write the .claude/ POLICY files: a pre-approved permission list and an
|
|
27
31
|
* attribution silencer. Both are decisions about what agents may do in
|
|
28
32
|
* someone's repo and what its git history says, so they are opt-in: only
|
|
@@ -99,10 +103,6 @@ function put(root: string, rel: string, body: string): boolean {
|
|
|
99
103
|
return true
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
/** the ERGONOMIC half of the day-one shape: the two files that decide what
|
|
103
|
-
* happens when the folder is opened in vscode, and nothing else. */
|
|
104
|
-
export const ERGONOMIC_FILES = ['.vscode/settings.json', '.vscode/tasks.json'] as const
|
|
105
|
-
|
|
106
106
|
export type ErgonomicsOpts = {
|
|
107
107
|
/** repo name, substituted into the templates */
|
|
108
108
|
name: string
|
|
@@ -112,35 +112,83 @@ export type ErgonomicsOpts = {
|
|
|
112
112
|
port: number
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/** the folder-open
|
|
116
|
-
*
|
|
115
|
+
/** the folder-open story, and the ONE file of it a repo shipkit did not create
|
|
116
|
+
* can be handed unasked.
|
|
117
|
+
*
|
|
118
|
+
* What lands is `tasks.json` alone. `settings.json` turns eslint off and hands
|
|
119
|
+
* formatting to an oxc extension, so writing it into a repo that lints with
|
|
120
|
+
* eslint silences the linter that repo chose: that is an answer about what the
|
|
121
|
+
* repo IS, and a repo with a history of its own already gave it. A tasks file
|
|
122
|
+
* is the opposite: with no `folderOpen` task nothing starts at all, so there
|
|
123
|
+
* is no existing answer to overrule.
|
|
117
124
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
125
|
+
* What lands INSIDE it is DERIVED, never assumed. `build/vscode-tasks.md` owns
|
|
126
|
+
* this artifact and its method is per-project analysis, so the only subtasks
|
|
127
|
+
* written here are the ones true of every wired repo (the hub, whose script
|
|
128
|
+
* `init` writes) plus a TSC watcher when this repo actually has a project for
|
|
129
|
+
* one. A canned watcher pointed at a solution tsconfig (`files: []` plus
|
|
130
|
+
* `references`) type checks ZERO files and reports a clean problems pane
|
|
131
|
+
* forever, which is worse than no task. Every other watcher a stack earns
|
|
132
|
+
* (a dev server, css, tests) stays the analysis step the owner page describes,
|
|
133
|
+
* and the caller says so out loud.
|
|
124
134
|
*
|
|
125
|
-
* Create-only
|
|
126
|
-
*
|
|
127
|
-
* byte. Returns the paths it wrote, so a caller can say "already current"
|
|
128
|
-
* when it wrote none. */
|
|
135
|
+
* Create-only: an adopted repo often carries a `.vscode/` from whatever it was
|
|
136
|
+
* forked from, and that one wins byte for byte. Returns the paths it wrote. */
|
|
129
137
|
export function editorErgonomics(root: string, opts: ErgonomicsOpts): string[] {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
return
|
|
138
|
+
// a symlinked .vscode is a dotfiles pattern pointing at a SHARED dir, and
|
|
139
|
+
// writing through it lands files outside the repo being wired
|
|
140
|
+
if (isLink(join(root, '.vscode'))) return []
|
|
141
|
+
const body = tasksTemplate({ NAME: opts.name, PORT: String(opts.port) }, typecheckableProject(root))
|
|
142
|
+
return put(root, '.vscode/tasks.json', body) ? ['.vscode/tasks.json'] : []
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isLink = (path: string): boolean => {
|
|
146
|
+
try {
|
|
147
|
+
return lstatSync(path).isSymbolicLink()
|
|
148
|
+
} catch {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** the tasks template with the TSC subtask REMOVED when this repo has no
|
|
154
|
+
* project it would check. One template, one subtraction: forking the file
|
|
155
|
+
* would put two copies of the house task shape in the tree. */
|
|
156
|
+
export function tasksTemplate(vars: Record<string, string>, withTsc: boolean): string {
|
|
157
|
+
const body = template('vscode-tasks.json', vars)
|
|
158
|
+
if (withTsc) return body
|
|
159
|
+
const parsed = JSON.parse(body.replace(/^\s*\/\/.*$/gm, '')) as { version: string; tasks: Array<Record<string, unknown>> }
|
|
160
|
+
parsed.tasks = parsed.tasks.filter((t) => t['label'] !== TSC_LABEL)
|
|
161
|
+
for (const t of parsed.tasks) {
|
|
162
|
+
const deps = t['dependsOn']
|
|
163
|
+
if (Array.isArray(deps)) t['dependsOn'] = deps.filter((d) => d !== TSC_LABEL)
|
|
164
|
+
}
|
|
165
|
+
return JSON.stringify(parsed, null, 3) + '\n'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const TSC_LABEL = '👁 TSC'
|
|
169
|
+
|
|
170
|
+
/** whether `tsc --project tsconfig.json` here would check anything at all. A
|
|
171
|
+
* solution config (`files: []` plus `references`) needs `--build`, which the
|
|
172
|
+
* house watcher does not pass, and answers zero files without it. */
|
|
173
|
+
export function typecheckableProject(root: string): boolean {
|
|
174
|
+
try {
|
|
175
|
+
const raw = readFileSync(join(root, 'tsconfig.json'), 'utf8')
|
|
176
|
+
const cfg = JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as { files?: unknown[]; include?: unknown[]; references?: unknown[] }
|
|
177
|
+
if ((cfg.references ?? []).length === 0) return true
|
|
178
|
+
return (cfg.files ?? []).length > 0 || (cfg.include ?? []).length > 0
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
135
182
|
}
|
|
136
183
|
|
|
137
184
|
export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
|
|
138
185
|
const p = opts.print ?? console.log
|
|
139
|
-
// the hub resolves its port from
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
// the hub resolves its port from repo.config, whose name `init` takes from
|
|
187
|
+
// the CALLER first and the manifest only after (init-repo.ts repoName). A
|
|
188
|
+
// dir that arrived holding a forked package.json would otherwise get a
|
|
189
|
+
// tasks.json advertising a port nothing listens on, so the caller that
|
|
190
|
+
// knows the name it is about to wire passes the port it resolves
|
|
191
|
+
const port = opts.port ?? defaultHubPort(packageName(root) ?? opts.name)
|
|
144
192
|
const vars = {
|
|
145
193
|
NAME: opts.name,
|
|
146
194
|
PORT: String(port),
|
|
@@ -152,8 +200,12 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
|
|
|
152
200
|
|
|
153
201
|
write('src/index.ts', 'export {}\n')
|
|
154
202
|
write('tsconfig.json', template('tsconfig.json', vars))
|
|
155
|
-
|
|
156
|
-
|
|
203
|
+
// tasks AFTER the tsconfig, so the TSC subtask is derived from the project
|
|
204
|
+
// this run just wrote rather than from the empty dir it found
|
|
205
|
+
;(editorErgonomics(root, { name: opts.name, port }).length > 0 ? written : skipped).push('.vscode/tasks.json')
|
|
206
|
+
// settings.json only here: it turns eslint off and hands formatting to an
|
|
207
|
+
// oxc extension, which is a decision only the repo's creator gets to make
|
|
208
|
+
write('.vscode/settings.json', template('vscode-settings.json', vars))
|
|
157
209
|
write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
|
|
158
210
|
// a permission grant and a git-history policy are decisions about someone
|
|
159
211
|
// else's repo, so they land only for a caller that declared a house, and
|
package/src/start-repo.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from 'path'
|
|
|
10
10
|
import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
|
|
11
11
|
import { initRepo } from './init-repo.ts'
|
|
12
12
|
import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
|
|
13
|
+
import { defaultHubPort } from './serve/hub-port.ts'
|
|
13
14
|
|
|
14
15
|
export type StartOpts = {
|
|
15
16
|
/** parent dir for repos — default ~/dev */
|
|
@@ -115,7 +116,11 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
115
116
|
// include lines: a caller that declares a house gets the house
|
|
116
117
|
// defaults, a bare `shipkit start` never has its agent
|
|
117
118
|
// permissions widened by a scaffold
|
|
118
|
-
|
|
119
|
+
// the port comes from the name being WIRED, which is the one on the
|
|
120
|
+
// command line: init takes the caller's name over the manifest's,
|
|
121
|
+
// and a dir found holding a forked package.json carries a name
|
|
122
|
+
// nothing will listen on
|
|
123
|
+
try { scaffoldProject(dir, { name, port: defaultHubPort(name), print, claudePolicy: (opts.include ?? []).length > 0 }) }
|
|
119
124
|
catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
120
125
|
// the wired scripts call node_modules binaries, so a scaffold
|
|
121
126
|
// whose scripts exit 127 is not a started repo. Skips mirror the
|