rvlib-shipkit 0.7.0 → 0.8.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-07-22
4
+
5
+ New:
6
+
7
+ - `shipkit mv <old.md> <new.md>` in every repo: rename an md page + its
8
+ sibling asset dir + `<name>.check.ts`, sweeping every tracked file for
9
+ refs (full path, docs-root id, check filename, boundary-guarded so
10
+ lookalikes survive). Dry by default, `--write` applies, `--commit` adds
11
+ one conventional commit behind the repo's pre-commit gate. A repo
12
+ `mv:after` script (NOT `postmv`, bun npm-compat auto-runs post* names)
13
+ runs after the moves for repo-specific re-stamps. `movePage` exported.
14
+ `init` scaffolds the `mv` script; the package.json probe requires it.
15
+ Upgraders: re-run `shipkit init` (idempotent, only adds the gap).
16
+ - the hub detects its own staleness: lib version read at boot vs on disk
17
+ per attention load. A mismatched running process shows a red line telling
18
+ you to restart the task (field bug: a morning process served old studio
19
+ code all day and a pick looked broken).
20
+
3
21
  ## 0.7.0 — 2026-07-22
4
22
 
5
23
  New (the hub becomes THE dashboard; autonomy over nagging):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -184,7 +184,7 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
184
184
  else if (idn.state === 'rendered') io.print(`ok: identity re-rendered (brand.ts changed) — ${idn.detail}`)
185
185
  else if (idn.state === 'blocked') { io.print(`FAIL: identity blocked — ${idn.detail}`); bad++ }
186
186
  // manual assets: no rolls = nothing to ship = RED (the missing list
187
- // is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
187
+ // is the shopping list — build/game-audio doctrine); rolls-but-no-pick = WARN
188
188
  const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
189
189
  let manualBad = 0
190
190
  for (const f of manuals) {
@@ -0,0 +1,34 @@
1
+ // shipkit mv — md-page rename with repo-wide ref sweep. Thin clipanion
2
+ // surface; logic in mv/mv-md.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { movePage } from '../mv/mv-md.ts'
5
+
6
+ export class MvCommand extends Command {
7
+ static override paths = [['mv']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Wiki',
11
+ description: 'rename an md page + sibling dir + check.ts, sweeping every tracked ref; renames must be CHEAP',
12
+ examples: [
13
+ ['dry run (default): show what would change', 'shipkit mv docs/old-name.md docs/new-name.md'],
14
+ ['apply + one conventional commit', 'shipkit mv docs/old-name.md docs/new-name.md --commit'],
15
+ ],
16
+ })
17
+
18
+ oldPath = Option.String()
19
+ newPath = Option.String()
20
+ write = Option.Boolean('--write', false, { description: 'apply (default is a dry run)' })
21
+ commit = Option.Boolean('--commit', false, { description: 'apply + git commit (pre-commit hook is the gate)' })
22
+
23
+ async execute(): Promise<number> {
24
+ try {
25
+ return movePage(process.cwd(), this.oldPath, this.newPath, {
26
+ print: (l) => this.context.stdout.write(`${l}\n`),
27
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
28
+ }, { write: this.write, commit: this.commit })
29
+ } catch (e) {
30
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
31
+ return 1
32
+ }
33
+ }
34
+ }
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { CheckCommand } from './cli/cmd_Check.ts'
10
10
  import { CiCommand } from './cli/cmd_Ci.ts'
11
11
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
12
  import { InitCommand } from './cli/cmd_Init.ts'
13
+ import { MvCommand } from './cli/cmd_Mv.ts'
13
14
  import { ServeCommand } from './cli/cmd_Serve.ts'
14
15
  import { TuiCommand } from './cli/cmd_Tui.ts'
15
16
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
@@ -27,6 +28,7 @@ const cli = new Cli({
27
28
  cli.register(Builtins.HelpCommand)
28
29
  cli.register(Builtins.VersionCommand)
29
30
  cli.register(CiCommand)
31
+ cli.register(MvCommand)
30
32
  cli.register(CheckCommand)
31
33
  cli.register(TuiCommand)
32
34
  cli.register(ServeCommand)
@@ -5,7 +5,7 @@ import { defineContract, dirExists, md, png } from 'rvlib-shipkit/src/contracts/
5
5
  export default defineContract({
6
6
  venue: 'discord',
7
7
  dir: '.rv-discord',
8
- owner: 'distribute/social.md',
8
+ owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
10
  detect: [dirExists('.rv-discord')],
11
11
  artifacts: [
@@ -6,7 +6,7 @@ import { defineContract, file, glob, png } from 'rvlib-shipkit/src/contracts/con
6
6
  export default defineContract({
7
7
  venue: 'identity',
8
8
  dir: '.rv-identity',
9
- owner: 'build/assets.md',
9
+ owner: 'build/generate-assets.md',
10
10
  appliesTo: 'always',
11
11
  gate: 'public-surface',
12
12
  detect: [],
@@ -5,7 +5,7 @@ import { custom, defineContract, dirExists } from 'rvlib-shipkit/src/contracts/c
5
5
  export default defineContract({
6
6
  venue: 'social',
7
7
  dir: '.rv-social',
8
- owner: 'distribute/social.md',
8
+ owner: 'distribute/post-publicly.md',
9
9
  appliesTo: 'declared',
10
10
  detect: [dirExists('.rv-social')],
11
11
  artifacts: [
@@ -68,6 +68,7 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
68
68
  { name: 'contracts', why: 'shipkit repo' },
69
69
  { name: 'tui', why: 'shipkit repo' },
70
70
  { name: 'hub', why: 'shipkit repo' },
71
+ { name: 'mv', why: 'shipkit repo' },
71
72
  ]
72
73
  if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
73
74
  if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
package/src/init-repo.ts CHANGED
@@ -42,7 +42,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
42
42
  let added = 0
43
43
  // canonical script NAME → shipkit command (hub runs `shipkit serve`) —
44
44
  // vocabulary owned by meta/rv-conventions/package-json.md
45
- const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve' } as const
45
+ const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
46
46
  for (const [k, cmd] of Object.entries(CANONICAL)) {
47
47
  const v = run(cmd)
48
48
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
@@ -0,0 +1,90 @@
1
+ // shipkit mv — rename an md page the AGILE way: one command moves the .md,
2
+ // its sibling asset dir and <name>.check.ts, then sweeps EVERY tracked text
3
+ // file for refs (full path, docs-root-relative id, check filename). Dry by
4
+ // default; --write applies; --commit adds one conventional commit (the
5
+ // repo's pre-commit hook is the gate). If package.json has a `mv:after`
6
+ // script it runs after the moves (repo-specific re-stamps). TS module moves
7
+ // are a different concern (a TS-aware renamer owns import graphs).
8
+ import { execSync } from 'child_process'
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
10
+ import { basename, dirname, join } from 'path'
11
+
12
+ export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
13
+ export type MvOpts = { write?: boolean; commit?: boolean }
14
+
15
+ const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
16
+
17
+ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo, o: MvOpts = {}): number {
18
+ const die = (m: string): number => { io.printErr(`FAIL: ${m}`); return 1 }
19
+ if (!oldRel.endsWith('.md') || !newRel.endsWith('.md')) return die('both paths must be .md (TS moves are not this tool\'s job)')
20
+ if (!existsSync(join(root, oldRel))) return die(`${oldRel} does not exist`)
21
+ if (existsSync(join(root, newRel))) return die(`${newRel} already exists`)
22
+ if (dirname(oldRel) !== dirname(newRel)) return die('cross-directory moves not supported yet, same dir only')
23
+
24
+ const write = o.write === true || o.commit === true
25
+ const oldName = basename(oldRel, '.md')
26
+ const newName = basename(newRel, '.md')
27
+ const oldBase = oldRel.slice(0, -3) // full path sans .md — the sibling dir
28
+ const newBase = newRel.slice(0, -3)
29
+ // docs-root-relative id: refs inside a wiki usually omit the top dir
30
+ // (`build/state`, not `wiki/build/state`) — sweep both forms
31
+ const tail = (p: string): string | null => { const seg = p.split('/'); return seg.length >= 3 ? seg.slice(1).join('/') : null }
32
+ const oldId = tail(oldBase)
33
+ const newId = tail(newBase)
34
+
35
+ // longest-first so `wiki/x/state.md` beats `x/state` beats bare tokens
36
+ const pairs: [string, string][] = [
37
+ [`${oldBase}.md`, `${newBase}.md`],
38
+ ...(oldId != null ? [[`${oldId}.md`, `${newId}.md`] as [string, string]] : []),
39
+ // probe file is named after its page; DIR-qualified so another
40
+ // page's same-named check.ts in a different dir never gets rewritten
41
+ [`${oldBase}/${oldName}.check.ts`, `${newBase}/${newName}.check.ts`],
42
+ ...(oldId != null ? [[`${oldId}/${oldName}.check.ts`, `${newId}/${newName}.check.ts`] as [string, string]] : []),
43
+ [`${oldBase}/`, `${newBase}/`],
44
+ ...(oldId != null ? [[`${oldId}/`, `${newId}/`] as [string, string]] : []),
45
+ [oldBase, newBase],
46
+ ...(oldId != null ? [[oldId, newId!] as [string, string]] : []),
47
+ ]
48
+ const bound = (t: string): RegExp =>
49
+ new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
50
+
51
+ const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
52
+ .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
53
+ let touched = 0
54
+ for (const f of tracked) {
55
+ const p = join(root, f)
56
+ if (!existsSync(p)) continue
57
+ const before = readFileSync(p, 'utf8')
58
+ let after = before
59
+ for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
60
+ if (after !== before) {
61
+ io.print(` ${f}`)
62
+ if (write) writeFileSync(p, after)
63
+ touched++
64
+ }
65
+ }
66
+ io.print(`${write ? 'renamed' : 'DRY RUN'}: ${oldBase} → ${newBase}, refs in ${touched} file(s)`)
67
+ if (!write) { io.print('re-run with --write (or --commit) to apply'); return 0 }
68
+
69
+ // moves AFTER the sweep (the sweep rewrote files at their old paths)
70
+ sh(`git mv ${JSON.stringify(oldRel)} ${JSON.stringify(newRel)}`, root)
71
+ if (existsSync(join(root, oldBase))) {
72
+ sh(`git mv ${JSON.stringify(oldBase)} ${JSON.stringify(newBase)}`, root)
73
+ const oldCheck = join(newBase, `${oldName}.check.ts`)
74
+ if (existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
75
+ }
76
+
77
+ // repo-specific re-stamps (e.g. wiki depth frontmatter) — loud on failure
78
+ const pkg = existsSync(join(root, 'package.json')) ? (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }) : {}
79
+ if (pkg.scripts?.['mv:after'] != null) {
80
+ try { execSync('bun run mv:after', { cwd: root, stdio: 'inherit' }) } catch { return die('mv:after failed, fix before committing') }
81
+ }
82
+ if (o.commit === true) {
83
+ sh('git add -A', root)
84
+ try {
85
+ execSync(`git commit -m ${JSON.stringify(`docs: rename ${oldBase} → ${newBase}`)}`, { cwd: root, stdio: 'inherit' })
86
+ } catch { return die('commit failed (pre-commit gate?). The rename is applied, fix and commit yourself') }
87
+ }
88
+ io.print(`ok: ${oldBase} → ${newBase}${o.commit === true ? ' (committed)' : ''}`)
89
+ return 0
90
+ }
@@ -5,7 +5,7 @@
5
5
  // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
6
  // default (4800-4979, hashed from the repo name — stable across launches;
7
7
  // pin serve.port when two repos collide).
8
- import { existsSync, readdirSync, statSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
11
  import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
@@ -63,6 +63,15 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
63
63
  }
64
64
  }
65
65
 
66
+ // the hub must KNOW when it is outdated (field bug 2026-07-22: a morning
67
+ // process served stale code all day, picks looked broken): version read at
68
+ // boot vs on disk per attention load; mismatch = red line telling him to
69
+ // restart the task
70
+ const libVersion = (): string => {
71
+ try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
72
+ }
73
+ const BOOT_VERSION = libVersion()
74
+
66
75
  // ── the / dashboard's data: everything red, one collector per source, each
67
76
  // failure-isolated (a broken source reports itself, never blanks the page)
68
77
  export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
@@ -71,6 +80,11 @@ export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit
71
80
  }
72
81
  return async () => {
73
82
  const sections = await Promise.all([
83
+ guard('hub process', 'red', async () => {
84
+ const now = libVersion()
85
+ if (now === BOOT_VERSION) return []
86
+ return [{ text: `hub is running shipkit ${BOOT_VERSION} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` }]
87
+ }),
74
88
  guard('failing checks', 'red', async () => {
75
89
  const p = join(root, 'STATUS.md')
76
90
  if (!existsSync(p)) return []
package/src/shipkit.ts CHANGED
@@ -58,3 +58,5 @@ export { lintMd } from './wiki/lint-md.ts'
58
58
  export { serveMdGraph } from './wiki/serve-graph.ts'
59
59
  export { graphHtml } from './wiki/graph-page.ts'
60
60
  export type { MdGraph, MdNode, MdEdge } from './wiki/md-graph.ts'
61
+ export { movePage } from './mv/mv-md.ts'
62
+ export type { MvIo, MvOpts } from './mv/mv-md.ts'
@@ -13,7 +13,7 @@ import { TuiStore } from './tui-store.ts'
13
13
 
14
14
  export async function runTui(root: string): Promise<number> {
15
15
  const c = await loadRepoConfig(root)
16
- // playbook (build/mobx): enforceActions warns on stray mutations — the
16
+ // playbook (build/mobx-mechanics): enforceActions warns on stray mutations — the
17
17
  // only signal. Read-warning flags stay off: useInput handler reads are
18
18
  // legitimately untracked and would false-positive.
19
19
  configure({ enforceActions: 'always' })
@@ -1,4 +1,4 @@
1
- // TUI state — MobX per the house playbook (build/state + build/mobx):
1
+ // TUI state — MobX per the house playbook (build/app-state-tree + build/mobx-mechanics):
2
2
  // `makeAutoObservable` WITH exceptions (the 2026-07-21 doctrine — no
3
3
  // decorators), runInAction around every post-await mutation. Panels are
4
4
  // plain data providers (no mobx): they signal mutations through update(),