rvlib-shipkit 0.23.0 → 0.25.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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/cli/cmd_Start.ts +2 -2
- package/src/git-bootstrap.ts +5 -3
- package/src/rules/SK011-sibling-unit-ranges.ts +21 -7
- package/src/serve/hub-app/hub.css +9 -3
- package/src/serve/hub-app/media-svc.ts +19 -4
- package/src/serve/hub-app/media-view.tsx +22 -9
- package/src/start-repo.ts +64 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
- **A pre-existing EMPTY dir (or a commitless git-only husk) starts like a brand-new one.** The full-bootstrap scaffold keyed on "this run created the dir", so `mkdir foo` followed by `shipkit start foo`, or a re-run over the husk an older shipkit left, still ended in git-only plus manual next steps. The everyday-cd guard exists to protect a folder holding files, and an empty folder holds none: emptiness (junk like `.DS_Store` aside) now counts as starting intent, a `.git` with no commits counts too, and a git dir with real history stays untouched. A folder with files keeps the old behavior exactly.
|
|
6
|
+
|
|
7
|
+
## 0.24.0
|
|
8
|
+
|
|
9
|
+
- **Custom panels lead the studio drawer, and the prompt folds.** A repo's `customPanel` tabs (the tint-mask painter, a legs editor) are the working surfaces, yet they rendered LAST in the drawer tab row, behind two json diagnostics. They come first now, `asset` then `roll meta` after, and a kind with exactly one custom panel gets it as the drawer's default tab until the user picks another. The prompt textarea folds to one ellipsized line by default (click to expand, a chevron folds it back, state holds for the session), so the stage and the rolls get the height back. The drawer toggle now reads `drawer ▾`, because with a custom panel as default content a button labelled JSON opened a paint surface.
|
|
10
|
+
|
|
11
|
+
- **`shipkit start` on a brand-new dir bootstraps a WORKING repo, end to end.** It used to stop at git + the github repo and hand back two `next:` steps, because init needs a package.json. Now the dir being created THIS RUN is the signal to scaffold: a minimal package.json (private, type module), a first `src/index.ts` so the ci gate is green from commit one instead of failing an imports scan over zero files, the baseline .gitignore before anything installs, `bun add -d rvlib-shipkit` so the wired scripts actually run, then init, first commit, push. The guards stay strict: a pre-existing folder without a project gets git only, `--github` on it forces the github half but never writes a project into someone's files, a subdirectory another work tree owns gets nothing, a temp dir or `SHIPKIT_NO_INSTALL=1` skips the install, and an install that cannot run (offline, no bun) is a WARN, never a stop.
|
|
12
|
+
|
|
13
|
+
- **SK011 checks the FLOOR, not just resolution.** `^0.1.1` resolves 0.1.2, so a range left behind by a bump passed the rule while a consumer tree already holding 0.1.1 kept it and never saw the release. A sibling range must now name exactly the version that sibling carries, which is what `shipkit bump` has always written. It found one real stale floor the moment it tightened.
|
|
14
|
+
|
|
3
15
|
## 0.23.0
|
|
4
16
|
|
|
5
17
|
- **Release order is a TOPOSORT of the units' own dependency graph.** It was "sub-units first, root last", so a `--all` run published in declaration order and a dependent could ship naming a version the registry did not have yet. Kahn's algorithm now, stable (ties keep declaration order, root always last), and a cycle degrades to declaration order rather than dropping a unit: a release that silently skips a package is worse than one in a bad order.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"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",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,9 +9,9 @@ export class StartCommand extends Command {
|
|
|
9
9
|
|
|
10
10
|
static override usage = Command.Usage({
|
|
11
11
|
category: 'Setup',
|
|
12
|
-
description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines,
|
|
12
|
+
description: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in: ensures the dir, seeds CLAUDE.md include lines, and on a BRAND-NEW dir scaffolds a minimal package.json + src/index.ts + the rvlib-shipkit dev dep, wires shipkit (repo.config.ts via init), then runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. A pre-existing folder counts as brand new when it is EMPTY or a commitless git husk. One holding files gets git only, never a scaffold (--github forces only the github half for it). Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
|
|
13
13
|
examples: [
|
|
14
|
-
['create or enter ~/dev/pong: dir, wiring, git, first commit, private github repo, push', 'shipkit start pong'],
|
|
14
|
+
['create or enter ~/dev/pong: dir, package.json, shipkit wiring, git, first commit, private github repo, push', 'shipkit start pong'],
|
|
15
15
|
['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
|
|
16
16
|
['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
|
|
17
17
|
['force the private github repo for a folder that was already there', 'shipkit start old-notes --github'],
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -48,8 +48,10 @@ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
|
48
48
|
/** what a repo with NO .gitignore at all gets before its first commit. Not a
|
|
49
49
|
* policy (rule SK004 owns that one), just the three things nobody ever meant
|
|
50
50
|
* to publish. A repo that already has a .gitignore keeps its own answer, and
|
|
51
|
-
* the commit refuses instead when that answer misses these
|
|
52
|
-
|
|
51
|
+
* the commit refuses instead when that answer misses these. Two writers:
|
|
52
|
+
* firstCommit below, and the start scaffold (BEFORE its dep install, so
|
|
53
|
+
* node_modules never reaches the first commit) */
|
|
54
|
+
export const IGNORE_BASELINE = ['node_modules/', '.env', '.env.*', '!.env.example', '.DS_Store']
|
|
53
55
|
|
|
54
56
|
/** the one line of gh or git output worth reporting — both merge stderr into
|
|
55
57
|
* stdout, so the first non-empty line is the message and the rest is chatter */
|
|
@@ -68,7 +70,7 @@ export function carveGhJson<T>(out: string): T | null {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
|
|
73
|
+
export const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
|
|
72
74
|
// a credential prompt would hang a bootstrap forever: git fails instead
|
|
73
75
|
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
74
76
|
const r = await spawnWithTimeout([bin, ...cmd], { cwd: root, timeoutMs, env })
|
|
@@ -23,9 +23,20 @@ type Pkg = {
|
|
|
23
23
|
|
|
24
24
|
const BLOCKS = ['dependencies', 'peerDependencies', 'optionalDependencies', 'devDependencies'] as const
|
|
25
25
|
|
|
26
|
-
/** does
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
/** does this range name the sibling's CURRENT version as its floor? Not
|
|
27
|
+
* "does it resolve": `^0.1.1` happily resolves 0.1.2, and then a consumer
|
|
28
|
+
* whose tree already holds 0.1.1 keeps it and misses the release. The floor
|
|
29
|
+
* moves on every bump, so a stale one means a bump nobody propagated.
|
|
30
|
+
* Deliberately small: `^x.y.z`, `~x.y.z` or an exact pin, and a range shape
|
|
31
|
+
* it cannot read is SKIPPED rather than guessed at. */
|
|
32
|
+
export function floorMatches(version: string, range: string): boolean | null {
|
|
33
|
+
const r = range.trim().match(/^([\^~]?)(\d+\.\d+\.\d+)$/)
|
|
34
|
+
if (r == null || !/^\d+\.\d+\.\d+/.test(version)) return null
|
|
35
|
+
return r[2] === version
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** does `version` satisfy a caret/tilde/exact range? Kept for the resolution
|
|
39
|
+
* question, which is a different one from the floor question above. */
|
|
29
40
|
export function satisfiesSimple(version: string, range: string): boolean | null {
|
|
30
41
|
const v = version.match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
31
42
|
const r = range.trim().match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)$/)
|
|
@@ -64,14 +75,17 @@ export const siblingUnitRanges: Rule = {
|
|
|
64
75
|
const actual = version.get(dep)
|
|
65
76
|
if (actual == null) continue
|
|
66
77
|
if (/^(link|file|portal|workspace):/.test(range)) continue
|
|
67
|
-
const ok =
|
|
78
|
+
const ok = floorMatches(actual, range)
|
|
68
79
|
if (ok == null) continue
|
|
69
80
|
checked++
|
|
70
|
-
if (!ok)
|
|
81
|
+
if (!ok)
|
|
82
|
+
bad.push(
|
|
83
|
+
`${u.id}: ${block}.${dep} floors at ${range}, ${dep} is ${actual}${satisfiesSimple(actual, range) === true ? ' (it resolves, but a tree already holding the older one keeps it)' : ''}`,
|
|
84
|
+
)
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
if (bad.length > 0)
|
|
74
|
-
t.fail(`${bad.join(' · ')} — a range
|
|
75
|
-
else t.ok(`${checked} sibling range(s)
|
|
88
|
+
t.fail(`${bad.join(' · ')} — a sibling range FLOORS at the version it names, so a stale floor lets a consumer keep an older copy and miss the release. \`shipkit bump\` moves every floor forward in the version commit, so a red here is a bump nobody propagated`)
|
|
89
|
+
else t.ok(`${checked} sibling range(s) floor at exactly the version each unit carries`)
|
|
76
90
|
},
|
|
77
91
|
}
|
|
@@ -60,12 +60,18 @@ aside{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto
|
|
|
60
60
|
|
|
61
61
|
/* ── main column ── */
|
|
62
62
|
main{display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
|
63
|
-
/* topbar: the prompt is its OWN full-width row (
|
|
64
|
-
|
|
63
|
+
/* topbar: the prompt is its OWN full-width row (folded by default to one
|
|
64
|
+
ellipsized line; expanded = small mono, auto-grows to its content,
|
|
65
|
+
scrolls only past ~14 lines), then ONE slim toolbar row */
|
|
65
66
|
.bar{display:flex;flex-direction:column;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
66
67
|
textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border:1px solid var(--line);
|
|
67
68
|
border-radius:9px;padding:9px 11px;font:12px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
|
|
68
|
-
.
|
|
69
|
+
.promptrow{display:flex;gap:6px;align-items:flex-start}
|
|
70
|
+
.promptrow .fold{flex:none;background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:9px;
|
|
71
|
+
padding:6px 9px;font-size:11px;line-height:1.5}
|
|
72
|
+
.bar .prompt{flex:1;min-height:36px;max-height:252px;overflow-y:auto;resize:none;font:11px/1.5 ui-monospace,Menlo,monospace;padding:7px 10px}
|
|
73
|
+
.bar .prompt.folded{min-height:0;max-height:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;cursor:pointer;
|
|
74
|
+
color:var(--dim);background:var(--bg);border:1px solid var(--line);border-radius:9px}
|
|
69
75
|
.toolbar{display:flex;gap:8px;align-items:center;min-height:30px}
|
|
70
76
|
.toolbar button{padding:5px 11px;font-size:12px}
|
|
71
77
|
.toolbar .sp{flex:1}
|
|
@@ -58,6 +58,11 @@ export class MediaSvc {
|
|
|
58
58
|
drawerOpen = false
|
|
59
59
|
jsonText = ''
|
|
60
60
|
jsonMsg = ''
|
|
61
|
+
/** prompt folded to one ellipsized line (session state, click toggles) */
|
|
62
|
+
promptOpen = false
|
|
63
|
+
|
|
64
|
+
/** latched by picking a NON-default tab, until then a sole custom panel is the default */
|
|
65
|
+
private tabPicked = false
|
|
61
66
|
|
|
62
67
|
private hbTimer: ReturnType<typeof setInterval> | null = null
|
|
63
68
|
private rectTimer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -66,8 +71,8 @@ export class MediaSvc {
|
|
|
66
71
|
private savedPrompt = ''
|
|
67
72
|
|
|
68
73
|
constructor(private st: HubSt) {
|
|
69
|
-
makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt'>(
|
|
70
|
-
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false })
|
|
74
|
+
makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'tabPicked'>(
|
|
75
|
+
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, tabPicked: false })
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
start(): void {
|
|
@@ -161,7 +166,11 @@ export class MediaSvc {
|
|
|
161
166
|
this.savedPrompt = r.asset.prompt
|
|
162
167
|
this.promptMsg = ''
|
|
163
168
|
if (this.deterministic) this.n = '1'
|
|
164
|
-
|
|
169
|
+
// custom panels are the working surfaces, a kind with exactly one
|
|
170
|
+
// gets it as the default tab (setTab latches an explicit divergence)
|
|
171
|
+
const sole = r.asset.custom.length === 1 ? `c:${r.asset.custom[0]!.id}` : null
|
|
172
|
+
if (this.tab.startsWith('c:') && !r.asset.custom.some((c) => `c:${c.id}` === this.tab)) this.tab = sole ?? 'asset'
|
|
173
|
+
else if (!this.tabPicked && sole != null) this.tab = sole
|
|
165
174
|
this.pickActive(r)
|
|
166
175
|
})
|
|
167
176
|
if (this.hasGeom) await this.loadRect()
|
|
@@ -279,6 +288,9 @@ export class MediaSvc {
|
|
|
279
288
|
this.promptMsg = 'copied'
|
|
280
289
|
setTimeout(() => runInAction(() => { this.promptMsg = '' }), 900)
|
|
281
290
|
}
|
|
291
|
+
togglePrompt(): void {
|
|
292
|
+
this.promptOpen = !this.promptOpen
|
|
293
|
+
}
|
|
282
294
|
|
|
283
295
|
toggleFold(cat: string): void {
|
|
284
296
|
if (this.st.query.trim() !== '') return // folds yield to a live search
|
|
@@ -370,9 +382,12 @@ export class MediaSvc {
|
|
|
370
382
|
await this.fillCat()
|
|
371
383
|
}
|
|
372
384
|
|
|
373
|
-
// ── drawer: asset json (read-only) · roll meta sidecar
|
|
385
|
+
// ── drawer: custom panels · asset json (read-only) · roll meta sidecar ─
|
|
374
386
|
setTab(tab: string): void {
|
|
375
387
|
this.tab = tab
|
|
388
|
+
// clicking the sole panel IS the default, only a different pick latches
|
|
389
|
+
const c = this.detail?.asset.custom ?? []
|
|
390
|
+
if (tab !== (c.length === 1 ? `c:${c[0]!.id}` : null)) this.tabPicked = true
|
|
376
391
|
this.drawerOpen = true
|
|
377
392
|
void this.loadTab()
|
|
378
393
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Media views — topbar, info chips, stage (image/audio + geometry), roll
|
|
2
|
-
// strip, category grid + size-true lineup, drawer (
|
|
3
|
-
//
|
|
2
|
+
// strip, category grid + size-true lineup, drawer (repo-defined custom
|
|
3
|
+
// panels · asset json · roll meta). Feature parity with the deleted vanilla
|
|
4
4
|
// studio SPA; state lives in MediaSvc (components read and call).
|
|
5
5
|
import { runInAction } from 'mobx'
|
|
6
6
|
import { observer } from 'mobx-react-lite'
|
|
@@ -27,12 +27,23 @@ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
|
|
|
27
27
|
if (t == null) return
|
|
28
28
|
t.style.height = 'auto'
|
|
29
29
|
t.style.height = `${t.scrollHeight + 2}px`
|
|
30
|
-
}, [m.prompt])
|
|
30
|
+
}, [m.prompt, m.promptOpen])
|
|
31
31
|
return (
|
|
32
32
|
<div className="bar">
|
|
33
|
-
{/* the prompt AUTOSAVES (debounced)
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
{/* the prompt AUTOSAVES (debounced), no save button, no fear.
|
|
34
|
+
folded (default) = one ellipsized line, click to expand */}
|
|
35
|
+
<div className="promptrow">
|
|
36
|
+
<button className="fold" title={m.promptOpen ? 'fold the prompt' : 'expand the prompt'}
|
|
37
|
+
onClick={() => m.togglePrompt()}>{m.promptOpen ? '▾' : '▸'}</button>
|
|
38
|
+
{m.promptOpen
|
|
39
|
+
? <textarea className="prompt" ref={taRef} value={m.prompt} placeholder="select an asset ←"
|
|
40
|
+
onChange={(e) => m.setPrompt(e.target.value)} />
|
|
41
|
+
: <div className="prompt folded" onClick={() => m.togglePrompt()}>
|
|
42
|
+
{/* placeholder only when NOTHING is selected, a selected
|
|
43
|
+
asset with an empty prompt shows an empty line */}
|
|
44
|
+
{m.curId == null ? 'select an asset ←' : m.prompt === '' ? '\u00a0' : m.prompt}
|
|
45
|
+
</div>}
|
|
46
|
+
</div>
|
|
36
47
|
<div className="toolbar">
|
|
37
48
|
{!manual ? <button disabled={m.curId == null} onClick={() => void m.generate()}>Generate</button> : null}
|
|
38
49
|
{!manual ? (
|
|
@@ -232,7 +243,7 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
|
232
243
|
)
|
|
233
244
|
})
|
|
234
245
|
|
|
235
|
-
// ── drawer: asset json (read-only) · roll meta
|
|
246
|
+
// ── drawer: custom panels · asset json (read-only) · roll meta ───────────
|
|
236
247
|
const PanelHost = observer(({ app }: { app: HubSt }) => {
|
|
237
248
|
const m = app.media
|
|
238
249
|
const ref = useRef<HTMLDivElement>(null)
|
|
@@ -273,11 +284,13 @@ export const Drawer = observer(({ app }: { app: HubSt }) => {
|
|
|
273
284
|
return (
|
|
274
285
|
<div className="drawer">
|
|
275
286
|
<div className="tabs">
|
|
276
|
-
{
|
|
287
|
+
{/* customs lead: they are the working surfaces, json tabs are diagnostics */}
|
|
288
|
+
{[...customs.map((c) => ({ id: `c:${c.id}`, label: c.id })), { id: 'asset', label: 'asset' }, { id: 'meta', label: 'roll meta' }].map((t) => (
|
|
277
289
|
<button key={t.id} className={m.tab === t.id ? 'on' : ''} onClick={() => m.setTab(t.id)}>{t.label}</button>
|
|
278
290
|
))}
|
|
279
291
|
<span className="sp" />
|
|
280
|
-
|
|
292
|
+
{/* names the drawer, not the tab set (a sole custom panel is the default content) */}
|
|
293
|
+
<button className="ghost" onClick={() => runInAction(() => { m.drawerOpen = !m.drawerOpen })}>drawer ▾</button>
|
|
281
294
|
</div>
|
|
282
295
|
{m.drawerOpen ? (
|
|
283
296
|
<div id="drawerbody" className="on">
|
package/src/start-repo.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// output is the resolved dir, so a sourced shell alias can
|
|
5
5
|
// `cd "$(shipkit start <name>)"` (the one thing a child process can't do
|
|
6
6
|
// for its parent shell).
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
8
|
import { homedir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
10
|
+
import { ensureGitRepo, firstGhLine, IGNORE_BASELINE, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
|
|
11
11
|
import { initRepo } from './init-repo.ts'
|
|
12
12
|
|
|
13
13
|
export type StartOpts = {
|
|
@@ -24,6 +24,9 @@ export type StartOpts = {
|
|
|
24
24
|
github?: boolean
|
|
25
25
|
/** how `gh` runs — injected by tests, which never touch a real account */
|
|
26
26
|
gh?: Run | null
|
|
27
|
+
/** how `bun` runs for the dep install — injected by tests, which never
|
|
28
|
+
* touch the network. An explicit null means no bun on this machine */
|
|
29
|
+
bun?: Run | null
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
|
|
@@ -32,10 +35,18 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
32
35
|
return 2
|
|
33
36
|
}
|
|
34
37
|
const dir = join(opts.root ?? join(homedir(), 'dev'), name)
|
|
35
|
-
// this run creating the dir
|
|
36
|
-
// which is what the github half
|
|
37
|
-
// everyday cd into a folder that already
|
|
38
|
+
// this run creating the dir is the strongest signal that a repo is being
|
|
39
|
+
// STARTED, which is what the scaffold and the github half key on:
|
|
40
|
+
// `ship <name>` is also the everyday cd into a folder that already
|
|
41
|
+
// exists. A pre-existing dir with NOTHING in it carries the same intent
|
|
42
|
+
// (there are no files to protect), and so does a git-only husk with no
|
|
43
|
+
// commits (what an interrupted start leaves behind). Captured here,
|
|
44
|
+
// BEFORE the include seeding writes CLAUDE.md into the dir
|
|
38
45
|
const justCreated = !existsSync(dir)
|
|
46
|
+
const JUNK = new Set(['.DS_Store', '.localized'])
|
|
47
|
+
const entries = justCreated ? [] : readdirSync(dir).filter((e) => !JUNK.has(e))
|
|
48
|
+
const emptyDir = !justCreated && entries.length === 0
|
|
49
|
+
const gitOnlyDir = !justCreated && entries.length === 1 && entries[0] === '.git'
|
|
39
50
|
if (justCreated) {
|
|
40
51
|
mkdirSync(dir, { recursive: true })
|
|
41
52
|
console.error(`ok: created ${dir}`)
|
|
@@ -66,11 +77,15 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
79
|
}
|
|
69
|
-
// start is the from-anywhere verb
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
80
|
+
// start is the from-anywhere verb, and a STARTED repo comes out READY:
|
|
81
|
+
// a brand-new dir gets a minimal package.json, a first src/index.ts, the
|
|
82
|
+
// rvlib-shipkit dev dep, repo.config.ts via init, git, first commit,
|
|
83
|
+
// private github repo, push. A dir that already holds a project
|
|
84
|
+
// (package.json) gets the same minus the scaffold. A PRE-EXISTING dir
|
|
85
|
+
// with NO project is the everyday cd into some old folder: git only,
|
|
86
|
+
// never a scaffold (--github forces only the github half, so it must
|
|
87
|
+
// never write a package.json into a folder that holds someone's files).
|
|
88
|
+
// init itself stays cwd-only, like git init.
|
|
74
89
|
// --skip means shipkit NEVER TOUCHES a listed repo (a day-job checkout
|
|
75
90
|
// belongs on that list), not just no CLAUDE.md. And the cd contract survives a
|
|
76
91
|
// failed adoption: the dir always prints, wiring trouble is a WARN.
|
|
@@ -83,6 +98,45 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
83
98
|
let wired = false
|
|
84
99
|
try { wired = await ensureGitRepo(dir, { print }) }
|
|
85
100
|
catch (e) { console.error(`WARN could not wire git in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
101
|
+
// the scaffold fires for a dir this run created, a pre-existing dir
|
|
102
|
+
// that was EMPTY, or a commitless git-only husk (never --github
|
|
103
|
+
// alone: forcing the github half on a folder holding files must not
|
|
104
|
+
// write a project into it), and only where git actually wired (a
|
|
105
|
+
// subdirectory of another work tree gets nothing): the minimal
|
|
106
|
+
// package.json lands so init below has its floor and the first
|
|
107
|
+
// commit carries a working repo, not an empty husk
|
|
108
|
+
let startable = justCreated || emptyDir
|
|
109
|
+
if (!startable && gitOnlyDir && wired)
|
|
110
|
+
startable = (await runner('git', dir, 120_000)(['rev-parse', '--verify', 'HEAD'])).code !== 0
|
|
111
|
+
if (wired && startable && !existsSync(join(dir, 'package.json')) && !existsSync(join(dir, 'repo.config.ts'))) {
|
|
112
|
+
const pkg = { name, version: '0.0.0', private: true, type: 'module', scripts: {} }
|
|
113
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 4) + '\n')
|
|
114
|
+
// the first source file, so the ci gate is green from commit one:
|
|
115
|
+
// an imports gate over zero files fails loud on purpose
|
|
116
|
+
mkdirSync(join(dir, 'src'), { recursive: true })
|
|
117
|
+
writeFileSync(join(dir, 'src', 'index.ts'), 'export {}\n')
|
|
118
|
+
console.error('ok: package.json + src/index.ts scaffolded (private, type module)')
|
|
119
|
+
// the install below creates node_modules/ BEFORE the first
|
|
120
|
+
// commit, so the baseline ignore must exist first (SK004's block,
|
|
121
|
+
// written by init, does not carry node_modules)
|
|
122
|
+
if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), `${IGNORE_BASELINE.join('\n')}\n`)
|
|
123
|
+
// the scripts init wires call node_modules/rvlib-shipkit — a
|
|
124
|
+
// scaffold whose scripts exit 127 is not a started repo. Skips
|
|
125
|
+
// mirror the github half's guards: an injected runner wins, the
|
|
126
|
+
// env switch turns it off machine-wide, a temp dir is a rehearsal
|
|
127
|
+
// that must not pull the network. init owns the fix line for
|
|
128
|
+
// every not-installed outcome
|
|
129
|
+
let bun: Run | null
|
|
130
|
+
if (opts.bun !== undefined) bun = opts.bun
|
|
131
|
+
else if ((process.env.SHIPKIT_NO_INSTALL ?? '') !== '') { console.error('skip: rvlib-shipkit install — SHIPKIT_NO_INSTALL is set'); bun = null }
|
|
132
|
+
else if (isScratch(dir)) { console.error(`skip: rvlib-shipkit install — ${dir} is a temp dir`); bun = null }
|
|
133
|
+
else bun = Bun.which('bun') == null ? null : runner('bun', dir, 180_000)
|
|
134
|
+
if (bun != null) {
|
|
135
|
+
const r = await bun(['add', '-d', 'rvlib-shipkit'])
|
|
136
|
+
if (r.code === 0) console.error('ok: rvlib-shipkit installed (dev dep)')
|
|
137
|
+
else console.error(`WARN rvlib-shipkit install failed: ${firstGhLine(r.out)}`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
86
140
|
let unscaffolded = false
|
|
87
141
|
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
88
142
|
if (existsSync(join(dir, 'package.json'))) {
|