@guiho/runx 0.4.0 → 0.5.1

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/DOCS.md +24 -4
  3. package/README.md +6 -1
  4. package/devops/build-binaries.ts +3 -3
  5. package/devops/devops.xdocs.md +9 -6
  6. package/devops/extract-release-notes.spec.ts +54 -0
  7. package/devops/extract-release-notes.ts +40 -0
  8. package/devops/install.ps1 +29 -6
  9. package/devops/install.sh +36 -11
  10. package/devops/installers.spec.ts +152 -5
  11. package/devops/verify-release-assets.ts +14 -0
  12. package/docs/architecture/architecture.xdocs.md +2 -1
  13. package/docs/architecture/cli-architecture.md +18 -9
  14. package/docs/decisions/decisions.xdocs.md +4 -0
  15. package/docs/decisions/markdown-release-assets-and-version-scoped-notes.md +84 -0
  16. package/docs/plans/automatic-agent-maintenance.md +120 -0
  17. package/docs/plans/plans.xdocs.md +2 -0
  18. package/docs/plans/rfc-0034-cli-compliance-migration.md +17 -8
  19. package/docs/plans/upgrade-reliability-implementation.md +1 -1
  20. package/docs/reviews/implementation/automatic-agent-maintenance-review.md +63 -0
  21. package/docs/reviews/implementation/bash-installer-review.md +42 -0
  22. package/docs/reviews/implementation/implementation.xdocs.md +12 -2
  23. package/docs/reviews/implementation/rfc-0034-cli-compliance-migration-review.md +18 -5
  24. package/docs/reviews/implementation/unicode-help-tree-review.md +41 -0
  25. package/docs/reviews/implementation/upgrade-reliability-issue-12-review.md +62 -0
  26. package/docs/reviews/implementation/upgrade-reliability-issue-13-review.md +63 -0
  27. package/docs/reviews/plans/automatic-agent-maintenance-review.md +56 -0
  28. package/docs/reviews/plans/plans.xdocs.md +2 -0
  29. package/docs/superpowers/specs/2026-07-15-upgrade-reliability-design.md +1 -1
  30. package/docs/todo/automatic-agent-maintenance-implementation.md +55 -0
  31. package/docs/todo/automatic-agent-maintenance.md +86 -0
  32. package/docs/todo/bash-installer.md +45 -0
  33. package/docs/todo/rfc-0034-cli-compliance-migration-implementation.md +27 -3
  34. package/docs/todo/rfc-0034-cli-compliance-migration.md +9 -4
  35. package/docs/todo/todo.xdocs.md +12 -2
  36. package/docs/todo/unicode-help-tree.md +44 -0
  37. package/docs/todo/upgrade-reliability.md +51 -0
  38. package/docs/validation/automatic-agent-maintenance.md +63 -0
  39. package/docs/validation/bash-installer.md +55 -0
  40. package/docs/validation/rfc-0034-cli-compliance-migration.md +29 -7
  41. package/docs/validation/unicode-help-tree.md +43 -0
  42. package/docs/validation/upgrade-reliability-issue-12.md +74 -0
  43. package/docs/validation/upgrade-reliability-issue-13.md +72 -0
  44. package/docs/validation/upgrade-reliability.md +38 -99
  45. package/docs/validation/validation.xdocs.md +14 -4
  46. package/library/agent-maintenance.d.ts +19 -0
  47. package/library/agent-maintenance.d.ts.map +1 -0
  48. package/library/agent-maintenance.js +49 -0
  49. package/library/agents.d.ts +8 -2
  50. package/library/agents.d.ts.map +1 -1
  51. package/library/agents.js +58 -11
  52. package/library/cli.d.ts.map +1 -1
  53. package/library/cli.js +50 -15
  54. package/library/recovery.js +1 -1
  55. package/library/release-catalog.d.ts +2 -1
  56. package/library/release-catalog.d.ts.map +1 -1
  57. package/library/release-catalog.js +9 -1
  58. package/library/storage.d.ts +2 -1
  59. package/library/storage.d.ts.map +1 -1
  60. package/library/storage.js +13 -1
  61. package/package.json +1 -1
  62. package/skills/guiho-s-runx/SKILL.md +11 -0
  63. package/skills/guiho-s-runx/guiho-s-runx.xdocs.md +4 -1
package/CHANGELOG.md CHANGED
@@ -15,6 +15,58 @@ owner: runx
15
15
 
16
16
  # Changelog
17
17
 
18
+ ## 0.5.1 - 2026-07-19
19
+
20
+ ### Fixed
21
+
22
+ - Enforced LF line endings for Bash scripts so Windows checkouts preserve
23
+ portable installer syntax.
24
+ - Gave the detached agent-maintenance integration test an explicit
25
+ platform-appropriate timeout so slower Windows CI runners validate the full
26
+ workflow without changing runtime behavior.
27
+
28
+ ## 0.5.0 - 2026-07-19
29
+
30
+ ### Added
31
+
32
+ - Added silent detached maintenance that keeps both global `guiho-s-runx`
33
+ skill installations current and reconciles one nearest managed `AGENTS.md`
34
+ block without changing foreground output or exit behavior.
35
+ - Added explicit regression coverage for every upgrade recovery outcome,
36
+ executable stable/prerelease installer verification, and the Unicode
37
+ description-aligned command tree.
38
+
39
+ ### Changed
40
+
41
+ - `runx upgrade list` now returns the complete published catalog, including
42
+ labeled prereleases, unless pagination is explicitly requested.
43
+ - The Linux/macOS installer and generated recovery commands now use Bash with
44
+ `set -euo pipefail`; canonical documentation no longer invokes `sh`.
45
+ - Release delivery now validates the exact twelve native binaries and two
46
+ Markdown agent assets and uses only the matching changelog version section
47
+ for GitHub Release notes.
48
+
49
+ ### Fixed
50
+
51
+ - Fixed `runx upgrade list` and `runx upgrade check` so Citty does not execute
52
+ the parent upgrade action after the selected leaf or append a second JSON
53
+ document.
54
+ - Fixed automatic agent-resource updates to use idempotent atomic writes,
55
+ preserve user-authored instructions, migrate legacy markers, and remain safe
56
+ under concurrent invocations.
57
+
58
+ ## 0.4.1 - 2026-07-18
59
+
60
+ ### Fixed
61
+
62
+ - Fixed `runx upgrade` so its required `--version`, `--arch`, `--variant`,
63
+ `--dry-run`, and `--format` flags route to the upgrade action instead of
64
+ being hidden by the command group or intercepted as the root version flag.
65
+ - Fixed no-argument startup so a decoded cached update notice is printed before
66
+ the exact RunX banner without waiting for network work.
67
+ - Fixed `runx agent prompt list --names` text output so it prints raw prompt
68
+ names, one per line, while JSON mode remains structured.
69
+
18
70
  ## 0.4.0 - 2026-07-18
19
71
 
20
72
  ### Added
package/DOCS.md CHANGED
@@ -99,6 +99,14 @@ Skill install, update, and uninstall default to global scope and target both:
99
99
 
100
100
  Use `--local` for the corresponding project-local paths.
101
101
 
102
+ Ordinary RunX commands schedule a hidden detached worker that compares the
103
+ bundled skill with both global copies and rewrites only missing or stale
104
+ targets. The worker finds the nearest existing `AGENTS.md` from effective
105
+ `--cwd`; when none exists in an ancestor, it creates `AGENTS.md` at effective
106
+ cwd. Reconciliation is atomic, migrates legacy RunX markers, preserves content
107
+ outside managed markers, and is silent when no change is required. Worker
108
+ failures never change foreground output or exit status.
109
+
102
110
  Instruction actions manage both `AGENTS.md` and `CLAUDE.md` when both exist,
103
111
  the existing one when only one exists, and create `AGENTS.md` when neither
104
112
  exists. The exact idempotent boundaries are:
@@ -114,8 +122,10 @@ names; `agent prompt show <id>` prints only the raw prompt.
114
122
  ## Upgrade And Installation
115
123
 
116
124
  `runx upgrade` accepts `--version`, `--arch`, `--variant`, `--dry-run`, and
117
- `--format`. x64 defaults to `baseline`. `upgrade list` supports positive
118
- `--page` and `--per-page` plus `--pre-releases`.
125
+ `--format`. x64 defaults to `baseline`. `upgrade list` returns the complete
126
+ published catalog, including labeled prereleases, by default. Positive
127
+ `--page` and `--per-page` values request an explicit view; `--pre-releases` is
128
+ accepted but is unnecessary because prereleases are never hidden.
119
129
 
120
130
  Replacement is transactional: download, native-format validation, backup,
121
131
  replacement, version verification, rollback on failure, agent-skill refresh,
@@ -150,6 +160,16 @@ runx-windows-arm64.exe
150
160
  runx-windows-x64.exe
151
161
  runx-windows-x64-baseline.exe
152
162
  runx-windows-x64-modern.exe
153
- guiho-s-runx
154
- guiho-i-runx
163
+ guiho-s-runx.md
164
+ guiho-i-runx.md
155
165
  ```
166
+
167
+ The Markdown suffix is part of the public GitHub Release filename contract.
168
+ Installers retain the standard installed skill filename `SKILL.md` and use the
169
+ instruction asset contents when reconciling managed instruction blocks. Before
170
+ either write, installers reject empty, executable, binary, invalid UTF-8, or
171
+ misidentified Markdown payloads.
172
+
173
+ Release descriptions contain only the matching `## <version> - <date>`
174
+ changelog section. The publish workflow fails closed when that exact section is
175
+ missing and refreshes the notes when rerunning an existing release.
package/README.md CHANGED
@@ -32,13 +32,18 @@ irm https://raw.githubusercontent.com/CGuiho/runx/main/devops/install.ps1 | iex
32
32
  POSIX:
33
33
 
34
34
  ```sh
35
- curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/CGuiho/runx/main/devops/install.sh | sh
35
+ curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/CGuiho/runx/main/devops/install.sh | bash
36
36
  ```
37
37
 
38
38
  The direct installers select and verify a native asset, install `runx` on PATH,
39
39
  install `guiho-s-runx` into both global agent-tool directories, and reconcile
40
40
  RunX instructions in the current project.
41
41
 
42
+ After installation, ordinary RunX invocations schedule a silent background
43
+ maintenance worker. It repairs missing or stale global skill copies and keeps
44
+ one compact RunX block in the nearest `AGENTS.md` without delaying or changing
45
+ the requested command's output.
46
+
42
47
  The npm package is a Node-compatible bootstrap that downloads and delegates to
43
48
  the version-matched native binary. Bun is not required for npm installation or
44
49
  first execution.
@@ -31,7 +31,7 @@ const binaryTargets: readonly BinaryTarget[] = [
31
31
  { bunTarget: 'bun-windows-x64-modern', assetName: 'runx-windows-x64-modern.exe' },
32
32
  ]
33
33
 
34
- const agentAssetNames = ['guiho-s-runx', 'guiho-i-runx'] as const
34
+ const agentAssetNames = ['guiho-s-runx.md', 'guiho-i-runx.md'] as const
35
35
  const expectedReleaseAssetNames = [...binaryTargets.map((target) => target.assetName), ...agentAssetNames]
36
36
 
37
37
  if (expectedReleaseAssetNames.length !== 14 || new Set(expectedReleaseAssetNames).size !== 14) {
@@ -69,8 +69,8 @@ if (import.meta.main) {
69
69
  process.stdout.write(`built: ${target.assetName}\n`)
70
70
  }
71
71
 
72
- await Bun.write(joinPath(bin, 'guiho-s-runx'), Bun.file(joinPath(root, 'skills', 'guiho-s-runx', 'SKILL.md')))
73
- await Bun.write(joinPath(bin, 'guiho-i-runx'), Bun.file(joinPath(root, 'prompts', 'guiho-i-runx.md')))
72
+ await Bun.write(joinPath(bin, 'guiho-s-runx.md'), Bun.file(joinPath(root, 'skills', 'guiho-s-runx', 'SKILL.md')))
73
+ await Bun.write(joinPath(bin, 'guiho-i-runx.md'), Bun.file(joinPath(root, 'prompts', 'guiho-i-runx.md')))
74
74
 
75
75
  const observed = [...new Bun.Glob('*').scanSync({ cwd: bin, onlyFiles: true })].sort()
76
76
  const expected = [...expectedReleaseAssetNames].sort()
@@ -4,11 +4,13 @@ description: Native release-asset compilation and direct RunX installer scripts.
4
4
  parent: runx
5
5
  children: []
6
6
  files:
7
- build-binaries.ts: Cross-compiles twelve RFC-named native binaries and adds the two exact agent assets.
8
- verify-release-assets.ts: Fails on any missing, duplicate, extra, or legacy release asset name.
9
- installers.spec.ts: Verifies both installers expose progress, validation, dual-tool skills, instruction reconciliation, and no POSIX Bun dependency.
7
+ build-binaries.ts: Cross-compiles twelve RFC-named native binaries and adds the two exact .md agent assets.
8
+ extract-release-notes.ts: Extracts one exact version section from CHANGELOG.md and fails closed when the heading or notes are missing.
9
+ extract-release-notes.spec.ts: Verifies exact heading boundaries and exclusion of frontmatter and other release sections.
10
+ verify-release-assets.ts: Fails on missing, duplicate, extra, legacy, empty, binary, or misidentified release assets.
11
+ installers.spec.ts: Verifies installer progress, resources, exact stable/prerelease normalization, executable version matching, Markdown rejection, and no POSIX Bun dependency.
10
12
  install.ps1: Resolves an exact Windows release, downloads and validates a compatible asset, transactionally replaces the canonical executable, verifies its version, and rolls back on failure.
11
- install.sh: POSIX installer for Linux or Darwin with progress, validation, PATH, dual skills, instructions, verification, and rollback.
13
+ install.sh: Bash installer for Linux or Darwin with strict mode, progress, validation, PATH, dual skills, instructions, verification, and rollback.
12
14
  documents: {}
13
15
  tags:
14
16
  - devops
@@ -24,5 +26,6 @@ flags: []
24
26
  status: stable
25
27
  ---
26
28
 
27
- These scripts build and verify the exact fourteen-asset release and support
28
- direct installation without npm or Bun.
29
+ These scripts build and verify the exact fourteen-asset release, validate the
30
+ downloaded Markdown resource contract, create exact version-only GitHub Release
31
+ notes, and support direct installation without npm or Bun.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @copyright Copyright © 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+
5
+ import { describe, expect, test } from 'bun:test'
6
+ import { extractReleaseNotes } from './extract-release-notes.js'
7
+
8
+ describe('extractReleaseNotes', () => {
9
+ test('extracts only the exact unbracketed version section', () => {
10
+ const changelog = `---
11
+ name: Example
12
+ ---
13
+
14
+ # Changelog
15
+
16
+ ## 0.4.10 - 2026-07-20
17
+
18
+ ### Fixed
19
+
20
+ - Newer patch.
21
+
22
+ ## 0.4.1 - 2026-07-18
23
+
24
+ ### Fixed
25
+
26
+ - Requested patch.
27
+
28
+ ## 0.4.0 - 2026-07-18
29
+
30
+ ### Added
31
+
32
+ - Older release.
33
+ `
34
+
35
+ expect(extractReleaseNotes(changelog, '0.4.1')).toBe(`## 0.4.1 - 2026-07-18
36
+
37
+ ### Fixed
38
+
39
+ - Requested patch.
40
+ `)
41
+ })
42
+
43
+ test('fails closed when the exact version heading is absent', () => {
44
+ expect(() => extractReleaseNotes('## 0.4.1 - 2026-07-18\n\n- Fixed.\n', '0.4.2')).toThrow(
45
+ 'Expected exactly one changelog heading for version 0.4.2.',
46
+ )
47
+ })
48
+
49
+ test('rejects an empty version section', () => {
50
+ expect(() => extractReleaseNotes('## 0.4.2 - 2026-07-19\n## 0.4.1 - 2026-07-18\n\n- Older.\n', '0.4.2')).toThrow(
51
+ 'Changelog section for version 0.4.2 has no release notes.',
52
+ )
53
+ })
54
+ })
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @copyright Copyright © 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+
5
+ export {
6
+ extractReleaseNotes,
7
+ }
8
+
9
+ function escapeRegExp(value: string): string {
10
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11
+ }
12
+
13
+ function extractReleaseNotes(changelog: string, version: string): string {
14
+ const normalized = changelog.replace(/\r\n/g, '\n')
15
+ const heading = new RegExp(`^## ${escapeRegExp(version)} - .+$`, 'gm')
16
+ const matches = [...normalized.matchAll(heading)]
17
+ if (matches.length !== 1 || matches[0]?.index === undefined) {
18
+ throw new Error(`Expected exactly one changelog heading for version ${version}.`)
19
+ }
20
+
21
+ const start = matches[0].index
22
+ const nextHeading = normalized.indexOf('\n## ', start + matches[0][0].length)
23
+ const section = normalized.slice(start, nextHeading === -1 ? normalized.length : nextHeading).trim()
24
+ if (!section.includes('\n')) {
25
+ throw new Error(`Changelog section for version ${version} has no release notes.`)
26
+ }
27
+
28
+ return `${section}\n`
29
+ }
30
+
31
+ if (import.meta.main) {
32
+ const [version, outputPath] = Bun.argv.slice(2)
33
+ if (!version || !outputPath) {
34
+ throw new Error('Usage: bun run devops/extract-release-notes.ts <version> <output-path>')
35
+ }
36
+
37
+ const changelog = await Bun.file(new URL('../CHANGELOG.md', import.meta.url)).text()
38
+ await Bun.write(outputPath, extractReleaseNotes(changelog, version))
39
+ process.stdout.write(`wrote release notes for ${version}: ${outputPath}\n`)
40
+ }
@@ -78,6 +78,25 @@ function Test-NativeBinary {
78
78
  finally { $stream.Dispose() }
79
79
  }
80
80
 
81
+ function Test-MarkdownAsset {
82
+ param([string]$Path, [string]$ExpectedName)
83
+ $bytes = [System.IO.File]::ReadAllBytes($Path)
84
+ if ($bytes.Length -eq 0) { throw "Downloaded Markdown asset is empty: $Path" }
85
+ if ($bytes.Length -ge 2 -and $bytes[0] -eq 0x4D -and $bytes[1] -eq 0x5A) {
86
+ throw "Downloaded Markdown asset has a Windows executable header: $Path"
87
+ }
88
+ if ($bytes -contains 0) { throw "Downloaded Markdown asset contains binary NUL bytes: $Path" }
89
+ try {
90
+ $text = ([System.Text.UTF8Encoding]::new($false, $true)).GetString($bytes).Replace("`r`n", "`n")
91
+ } catch {
92
+ throw "Downloaded Markdown asset is not valid UTF-8 text: $Path"
93
+ }
94
+ if (-not $text.StartsWith("---`n")) { throw "Downloaded Markdown asset does not begin with YAML frontmatter: $Path" }
95
+ if ($text -notmatch "(?m)^name:\s*$([Regex]::Escape($ExpectedName))\s*$") {
96
+ throw "Downloaded Markdown asset identity does not match $ExpectedName`: $Path"
97
+ }
98
+ }
99
+
81
100
  function Test-InstalledVersion {
82
101
  param([string]$Path, [string]$ExpectedVersion)
83
102
  $startInfo = New-Object System.Diagnostics.ProcessStartInfo
@@ -156,6 +175,8 @@ function Test-Shadowing {
156
175
  }
157
176
  }
158
177
 
178
+ if ($env:RUNX_INSTALLER_SOURCE_ONLY -eq '1') { return }
179
+
159
180
  $detectedArch = if ($Arch) { $Arch } else { switch ($env:PROCESSOR_ARCHITECTURE) { 'AMD64' { 'x64' } 'ARM64' { 'arm64' } default { throw "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } } }
160
181
  if ($detectedArch -notin @('x64', 'arm64')) { throw "Invalid architecture: $detectedArch" }
161
182
  if (-not [Environment]::Is64BitOperatingSystem) { throw 'Unsupported platform: Windows 32-bit is not supported.' }
@@ -208,13 +229,15 @@ try {
208
229
  if (-not $downloadedPath) { throw "No compatible RunX $targetVersion binary found at https://github.com/$Repo/releases" }
209
230
  Write-Host 'Replacing...'
210
231
  Install-Transactional -DownloadedPath $downloadedPath -Destination $destination -ExpectedVersion $targetVersion
211
- $skillAsset = Join-Path $temporaryDirectory 'guiho-s-runx'
212
- $promptAsset = Join-Path $temporaryDirectory 'guiho-i-runx'
232
+ $skillAsset = Join-Path $temporaryDirectory 'guiho-s-runx.md'
233
+ $promptAsset = Join-Path $temporaryDirectory 'guiho-i-runx.md'
213
234
  $assetBase = if ($DownloadBaseUrl) { "$DownloadBaseUrl/$encodedTag" } else { "https://github.com/$Repo/releases/download/$encodedTag" }
214
- Write-Host "Downloading skill asset: $assetBase/guiho-s-runx"
215
- Invoke-WebRequest -Uri "$assetBase/guiho-s-runx" -OutFile $skillAsset -UseBasicParsing
216
- Write-Host "Downloading instruction asset: $assetBase/guiho-i-runx"
217
- Invoke-WebRequest -Uri "$assetBase/guiho-i-runx" -OutFile $promptAsset -UseBasicParsing
235
+ Write-Host "Downloading skill asset: $assetBase/guiho-s-runx.md"
236
+ Invoke-WebRequest -Uri "$assetBase/guiho-s-runx.md" -OutFile $skillAsset -UseBasicParsing
237
+ Test-MarkdownAsset -Path $skillAsset -ExpectedName 'guiho-s-runx'
238
+ Write-Host "Downloading instruction asset: $assetBase/guiho-i-runx.md"
239
+ Invoke-WebRequest -Uri "$assetBase/guiho-i-runx.md" -OutFile $promptAsset -UseBasicParsing
240
+ Test-MarkdownAsset -Path $promptAsset -ExpectedName 'guiho-i-runx'
218
241
  foreach ($skillRoot in @((Join-Path $HOME '.agents\skills\guiho-s-runx'), (Join-Path $HOME '.claude\skills\guiho-s-runx'))) {
219
242
  New-Item -ItemType Directory -Force -Path $skillRoot | Out-Null
220
243
  Copy-Item -LiteralPath $skillAsset -Destination (Join-Path $skillRoot 'SKILL.md') -Force
package/devops/install.sh CHANGED
@@ -1,5 +1,5 @@
1
- #!/usr/bin/env sh
2
- set -eu
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
3
 
4
4
  REPO="${RUNX_REPO:-CGuiho/runx}"
5
5
  VERSION="${RUNX_VERSION:-latest}"
@@ -106,6 +106,25 @@ verify_native_binary() {
106
106
  esac
107
107
  }
108
108
 
109
+ verify_markdown_asset() {
110
+ path=$1
111
+ expected_name=$2
112
+ [ -s "$path" ] || fail "downloaded Markdown asset is empty: $path"
113
+ magic=$(od -An -tx1 -N2 "$path" 2>/dev/null | tr -d ' \n')
114
+ [ "$magic" != 4d5a ] || fail "downloaded Markdown asset has a Windows executable header: $path"
115
+ if od -An -tx1 "$path" 2>/dev/null | grep -Eq '(^|[[:space:]])00([[:space:]]|$)'; then
116
+ fail "downloaded Markdown asset contains binary NUL bytes: $path"
117
+ fi
118
+ iconv -f UTF-8 -t UTF-8 "$path" >/dev/null 2>&1 || fail "downloaded Markdown asset is not valid UTF-8 text: $path"
119
+ first_line=$(sed -n '1{s/\r$//;p;}' "$path")
120
+ [ "$first_line" = '---' ] || fail "downloaded Markdown asset does not begin with YAML frontmatter: $path"
121
+ awk -v expected="$expected_name" '
122
+ { sub(/\r$/, "") }
123
+ $0 == "name: " expected { found=1 }
124
+ END { exit(found ? 0 : 1) }
125
+ ' "$path" || fail "downloaded Markdown asset identity does not match $expected_name: $path"
126
+ }
127
+
109
128
  verify_installed_version() {
110
129
  verify_output=$(mktemp "${TMPDIR:-/tmp}/runx-version.XXXXXX") || return 1
111
130
  "$1" --version >"$verify_output" 2>&1 &
@@ -135,10 +154,10 @@ verify_installed_version() {
135
154
  ensure_path() {
136
155
  PATH="$INSTALL_DIR:$PATH"; export PATH
137
156
  profile="$HOME/.profile"
138
- case "${SHELL##*/}" in zsh) profile="$HOME/.zshrc" ;; bash) profile="$HOME/.bashrc" ;; fish) profile="$HOME/.config/fish/config.fish" ;; esac
157
+ case "${SHELL:-bash}" in */zsh|zsh) profile="$HOME/.zshrc" ;; */bash|bash) profile="$HOME/.bashrc" ;; */fish|fish) profile="$HOME/.config/fish/config.fish" ;; esac
139
158
  mkdir -p -- "$(dirname "$profile")"
140
159
  if ! grep -Fq "$INSTALL_DIR" "$profile" 2>/dev/null; then
141
- if [ "${SHELL##*/}" = fish ]; then printf '\n# Added by runx installer\nfish_add_path %s\n' "$INSTALL_DIR" >>"$profile"
160
+ if [ "${SHELL:-bash}" = fish ] || [ "${SHELL:-bash}" = */fish ]; then printf '\n# Added by runx installer\nfish_add_path %s\n' "$INSTALL_DIR" >>"$profile"
142
161
  else printf '\n# Added by runx installer\nexport PATH=%s:$PATH\n' "$INSTALL_DIR" >>"$profile"; fi
143
162
  fi
144
163
  }
@@ -183,15 +202,17 @@ install_transactional() {
183
202
 
184
203
  install_agent_assets() {
185
204
  tag_base=${DOWNLOAD_BASE_URL:-"https://github.com/${REPO}/releases/download"}
186
- skill_url="${tag_base%/}/${encoded_tag}/guiho-s-runx"
187
- prompt_url="${tag_base%/}/${encoded_tag}/guiho-i-runx"
205
+ skill_url="${tag_base%/}/${encoded_tag}/guiho-s-runx.md"
206
+ prompt_url="${tag_base%/}/${encoded_tag}/guiho-i-runx.md"
188
207
  printf 'Downloading skill asset: %s\n' "$skill_url"
189
- download_asset "$skill_url" "$TMP/guiho-s-runx" || fail 'could not download guiho-s-runx'
208
+ download_asset "$skill_url" "$TMP/guiho-s-runx.md" || fail 'could not download guiho-s-runx.md'
209
+ verify_markdown_asset "$TMP/guiho-s-runx.md" 'guiho-s-runx'
190
210
  printf 'Downloading instruction asset: %s\n' "$prompt_url"
191
- download_asset "$prompt_url" "$TMP/guiho-i-runx" || fail 'could not download guiho-i-runx'
211
+ download_asset "$prompt_url" "$TMP/guiho-i-runx.md" || fail 'could not download guiho-i-runx.md'
212
+ verify_markdown_asset "$TMP/guiho-i-runx.md" 'guiho-i-runx'
192
213
  for skill_root in "$HOME/.agents/skills/guiho-s-runx" "$HOME/.claude/skills/guiho-s-runx"; do
193
214
  mkdir -p -- "$skill_root"
194
- install -m 0644 "$TMP/guiho-s-runx" "$skill_root/SKILL.md"
215
+ install -m 0644 "$TMP/guiho-s-runx.md" "$skill_root/SKILL.md"
195
216
  printf 'Installed skill: %s\n' "$skill_root/SKILL.md"
196
217
  done
197
218
  targets=""
@@ -214,7 +235,7 @@ install_agent_assets() {
214
235
  cat "$clean_file"
215
236
  [ ! -s "$clean_file" ] || printf '\n'
216
237
  printf '<!-- BEGIN RUNX — DO NOT EDIT THIS SECTION -->\n'
217
- cat "$TMP/guiho-i-runx"
238
+ cat "$TMP/guiho-i-runx.md"
218
239
  printf '\n<!-- END RUNX -->\n'
219
240
  } >"$instruction_file"
220
241
  done
@@ -222,7 +243,7 @@ install_agent_assets() {
222
243
 
223
244
  main() {
224
245
  parse_args "$@"
225
- for command in awk cat curl date dirname grep head install mktemp mv od rm sed tr uname; do require_command "$command"; done
246
+ for command in awk cat curl date dirname grep head iconv install mktemp mv od rm sed tr uname; do require_command "$command"; done
226
247
  OS=$(detect_os); ARCH=$(detect_arch); TARGET_VERSION=$(resolve_target_version)
227
248
  TMP=$(mktemp -d "${TMPDIR:-/tmp}/runx-install.XXXXXX")
228
249
  mkdir -p -- "$INSTALL_DIR"
@@ -256,4 +277,8 @@ main() {
256
277
  printf 'Installed and verified RunX %s at %s\n' "$TARGET_VERSION" "$destination"
257
278
  }
258
279
 
280
+ if [ "${RUNX_INSTALLER_SOURCE_ONLY:-0}" = 1 ]; then
281
+ return 0 2>/dev/null || exit 0
282
+ fi
283
+
259
284
  main "$@"
@@ -3,29 +3,176 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from 'bun:test'
6
+ import { $ } from 'bun'
6
7
 
7
8
  describe('RunX direct installers', () => {
8
9
  test('PowerShell installer exposes the complete RFC sequence', async () => {
9
10
  const script = await Bun.file(new URL('./install.ps1', import.meta.url)).text()
10
11
  expect(script).toContain('Initiating GUIHO CLI Upgrade / Installation Sequence...')
11
- expect(script).toContain('guiho-s-runx')
12
- expect(script).toContain('guiho-i-runx')
12
+ expect(script).toContain('guiho-s-runx.md')
13
+ expect(script).toContain('guiho-i-runx.md')
13
14
  expect(script).toContain('.agents\\skills\\guiho-s-runx')
14
15
  expect(script).toContain('.claude\\skills\\guiho-s-runx')
15
16
  expect(script).toContain('BEGIN RUNX — DO NOT EDIT THIS SECTION')
16
17
  expect(script).toContain('Test-NativeBinary')
17
18
  expect(script).toContain('Install-Transactional')
19
+ expect(script).toContain('function Test-MarkdownAsset')
20
+ expect(script).toContain("Test-MarkdownAsset -Path $skillAsset -ExpectedName 'guiho-s-runx'")
21
+ expect(script).toContain("Test-MarkdownAsset -Path $promptAsset -ExpectedName 'guiho-i-runx'")
22
+ expect(script).toContain('Windows executable header')
23
+ expect(script).toContain('binary NUL bytes')
24
+ expect(script).toContain('not valid UTF-8 text')
18
25
  })
19
26
 
20
- test('POSIX installer selects Darwin assets and has no Bun dependency', async () => {
27
+ test('Bash installer selects Darwin assets and has no runtime Bun dependency', async () => {
21
28
  const script = await Bun.file(new URL('./install.sh', import.meta.url)).text()
29
+ expect(script).toStartWith('#!/usr/bin/env bash\nset -euo pipefail')
22
30
  expect(script).toContain("Darwin) printf 'darwin")
23
31
  expect(script).toContain('--progress-bar')
24
- expect(script).toContain('guiho-s-runx')
25
- expect(script).toContain('guiho-i-runx')
32
+ expect(script).toContain('guiho-s-runx.md')
33
+ expect(script).toContain('guiho-i-runx.md')
26
34
  expect(script).toContain('.agents/skills/guiho-s-runx')
27
35
  expect(script).toContain('.claude/skills/guiho-s-runx')
36
+ expect(script).toContain('verify_markdown_asset()')
37
+ expect(script).toContain("verify_markdown_asset \"$TMP/guiho-s-runx.md\" 'guiho-s-runx'")
38
+ expect(script).toContain("verify_markdown_asset \"$TMP/guiho-i-runx.md\" 'guiho-i-runx'")
39
+ expect(script).toContain('Windows executable header')
40
+ expect(script).toContain('binary NUL bytes')
41
+ expect(script).toContain('iconv -f UTF-8 -t UTF-8')
28
42
  expect(script).toContain('BEGIN RUNX — DO NOT EDIT THIS SECTION')
29
43
  expect(script).not.toContain(' bun ')
30
44
  })
45
+
46
+ test('Bash installer passes syntax, piped startup, exact versions, and executable verification', async () => {
47
+ const bash = await bashExecutable()
48
+ const script = Bun.fileURLToPath(new URL('./install.sh', import.meta.url))
49
+ const syntax = Bun.spawnSync([bash, '-n', script])
50
+ expect(syntax.exitCode).toBe(0)
51
+
52
+ const piped = Bun.spawnSync([
53
+ bash,
54
+ '-c',
55
+ 'cat "$1" | RUNX_INSTALLER_SOURCE_ONLY=1 bash -s --',
56
+ 'bash',
57
+ script,
58
+ ])
59
+ expect(piped.exitCode).toBe(0)
60
+ expect(piped.stderr.toString()).toBe('')
61
+
62
+ const functions = Bun.spawnSync([
63
+ bash,
64
+ '-c',
65
+ [
66
+ 'RUNX_INSTALLER_SOURCE_ONLY=1 source "$1"',
67
+ 'stable=$(normalize_version v1.2.3)',
68
+ 'prerelease=$(normalize_version @guiho/runx@1.3.0-alpha.2)',
69
+ 'test "$stable" = 1.2.3',
70
+ 'test "$prerelease" = 1.3.0-alpha.2',
71
+ 'TARGET_VERSION=$(bun --version)',
72
+ 'verify_installed_version "$(command -v bun)"',
73
+ ].join('; '),
74
+ 'bash',
75
+ script,
76
+ ])
77
+ expect(functions.exitCode).toBe(0)
78
+ })
79
+
80
+ test('platform installer rejects a PE payload disguised as Markdown', async () => {
81
+ const temporaryDirectory = `${Bun.env.TEMP ?? Bun.env.TMPDIR ?? '/tmp'}/runx-markdown-${crypto.randomUUID()}`
82
+ const validAsset = `${temporaryDirectory}/valid.md`
83
+ const executableAsset = `${temporaryDirectory}/payload.md`
84
+ await $`mkdir -p ${temporaryDirectory}`
85
+ try {
86
+ await Bun.write(validAsset, '---\nname: guiho-s-runx\n---\n\n# Skill\n')
87
+ await Bun.write(executableAsset, new Uint8Array([0x4d, 0x5a, 0x00, 0x00]))
88
+
89
+ if (process.platform === 'win32') {
90
+ const script = Bun.fileURLToPath(new URL('./install.ps1', import.meta.url))
91
+ const valid = Bun.spawnSync([
92
+ 'powershell',
93
+ '-NoLogo',
94
+ '-NoProfile',
95
+ '-NonInteractive',
96
+ '-ExecutionPolicy',
97
+ 'Bypass',
98
+ '-Command',
99
+ `$env:RUNX_INSTALLER_SOURCE_ONLY='1'; . '${script.replaceAll("'", "''")}'; Test-MarkdownAsset -Path '${validAsset.replaceAll("'", "''")}' -ExpectedName 'guiho-s-runx'`,
100
+ ])
101
+ const invalid = Bun.spawnSync([
102
+ 'powershell',
103
+ '-NoLogo',
104
+ '-NoProfile',
105
+ '-NonInteractive',
106
+ '-ExecutionPolicy',
107
+ 'Bypass',
108
+ '-Command',
109
+ `$env:RUNX_INSTALLER_SOURCE_ONLY='1'; . '${script.replaceAll("'", "''")}'; Test-MarkdownAsset -Path '${executableAsset.replaceAll("'", "''")}' -ExpectedName 'guiho-s-runx'`,
110
+ ])
111
+ expect(valid.exitCode).toBe(0)
112
+ expect(invalid.exitCode).not.toBe(0)
113
+ expect(invalid.stderr.toString()).toContain('Windows executable header')
114
+ } else {
115
+ const script = Bun.fileURLToPath(new URL('./install.sh', import.meta.url))
116
+ const bash = await bashExecutable()
117
+ const valid = Bun.spawnSync([
118
+ bash,
119
+ '-c',
120
+ `RUNX_INSTALLER_SOURCE_ONLY=1 . "$1"; verify_markdown_asset "$2" guiho-s-runx`,
121
+ 'bash',
122
+ script,
123
+ validAsset,
124
+ ])
125
+ const invalid = Bun.spawnSync([
126
+ bash,
127
+ '-c',
128
+ `RUNX_INSTALLER_SOURCE_ONLY=1 . "$1"; verify_markdown_asset "$2" guiho-s-runx`,
129
+ 'bash',
130
+ script,
131
+ executableAsset,
132
+ ])
133
+ expect(valid.exitCode).toBe(0)
134
+ expect(invalid.exitCode).not.toBe(0)
135
+ expect(invalid.stderr.toString()).toContain('Windows executable header')
136
+ }
137
+ } finally {
138
+ await $`rm -rf ${temporaryDirectory}`
139
+ }
140
+ })
141
+
142
+ if (process.platform === 'win32') {
143
+ test('PowerShell installer accepts exact stable and prerelease versions and verifies the executable', () => {
144
+ const script = Bun.fileURLToPath(new URL('./install.ps1', import.meta.url)).replaceAll("'", "''")
145
+ const bun = process.execPath.replaceAll("'", "''")
146
+ const command = [
147
+ "$env:RUNX_INSTALLER_SOURCE_ONLY='1'",
148
+ `. '${script}'`,
149
+ "$stable = Resolve-TargetVersion -RequestedVersion 'v1.2.3'",
150
+ "$prerelease = Resolve-TargetVersion -RequestedVersion '@guiho/runx@1.3.0-alpha.2'",
151
+ "if ($stable -ne '1.2.3' -or $prerelease -ne '1.3.0-alpha.2') { throw 'exact version normalization failed' }",
152
+ `Test-InstalledVersion -Path '${bun}' -ExpectedVersion '${process.versions.bun}'`,
153
+ "$mismatchRejected = $false",
154
+ `try { Test-InstalledVersion -Path '${bun}' -ExpectedVersion '0.0.0-impossible' } catch { $mismatchRejected = $true }`,
155
+ "if (-not $mismatchRejected) { throw 'version mismatch was not rejected' }",
156
+ ].join('; ')
157
+ const result = Bun.spawnSync([
158
+ 'powershell',
159
+ '-NoLogo',
160
+ '-NoProfile',
161
+ '-NonInteractive',
162
+ '-ExecutionPolicy',
163
+ 'Bypass',
164
+ '-Command',
165
+ command,
166
+ ])
167
+ expect(result.exitCode).toBe(0)
168
+ })
169
+ }
31
170
  })
171
+
172
+ async function bashExecutable(): Promise<string> {
173
+ const gitBash = 'C:\\Program Files\\Git\\bin\\bash.exe'
174
+ if (process.platform === 'win32' && await Bun.file(gitBash).exists()) return gitBash
175
+ const bash = Bun.which('bash')
176
+ if (!bash) throw new Error('Bash is required to validate devops/install.sh.')
177
+ return bash
178
+ }
@@ -14,4 +14,18 @@ if (JSON.stringify(observed) !== JSON.stringify(expected)) {
14
14
  throw new Error(`Expected exactly ${expected.length} release assets.\nExpected: ${expected.join(', ')}\nObserved: ${observed.join(', ')}`)
15
15
  }
16
16
 
17
+ for (const [assetName, expectedName] of [
18
+ ['guiho-s-runx.md', 'guiho-s-runx'],
19
+ ['guiho-i-runx.md', 'guiho-i-runx'],
20
+ ] as const) {
21
+ const bytes = await Bun.file(joinPath(bin, assetName)).bytes()
22
+ if (bytes.length === 0 || (bytes[0] === 0x4d && bytes[1] === 0x5a) || bytes.includes(0)) {
23
+ throw new Error(`Release Markdown asset is empty or binary: ${assetName}`)
24
+ }
25
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/\r\n/g, '\n')
26
+ if (!text.startsWith('---\n') || !new RegExp(`^name:\\s*${expectedName}\\s*$`, 'm').test(text)) {
27
+ throw new Error(`Release Markdown asset has invalid frontmatter identity: ${assetName}`)
28
+ }
29
+ }
30
+
17
31
  process.stdout.write(`${JSON.stringify({ count: observed.length, assets: observed }, null, 2)}\n`)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  subject: runx-architecture
3
- description: Technical architecture for the RunX manifest, Citty command tree, agent skill, and native distribution.
3
+ description: Technical architecture for the RunX manifest, Citty command tree, automatic agent maintenance, and native distribution.
4
4
  parent: runx-docs
5
5
  children: []
6
6
  files:
@@ -14,6 +14,7 @@ keywords:
14
14
  - bun
15
15
  - cli
16
16
  - citty
17
+ - agent maintenance
17
18
  flags: []
18
19
  status: stable
19
20
  ---